透過防禦性程式化保護程式碼 |
級別: 入門 Gary McGrawReliable Software Technologies 2000 年 3 月 01 日 在 上一篇專欄文章中, 描述了高水準的緩衝區溢位攻擊,以及討論了為什麼緩衝區溢位是如此嚴重的安全性問題。本專欄文章的主題是,透過防禦性程式化保護程式碼不受緩衝區溢位攻 擊。我們將論及 C 程式化語言中的主要安全性陷阱,顯示應該避免特殊建構的原因,以及示範推薦的程式化實踐。最後,將討論有助於有效防止緩衝區溢位的其它技術。 C 中大多數緩衝區溢位問題可以直接追溯到標準 C 函式庫。最有害的罪魁禍首是不進行自變數檢查的、有問題的字串作業(strcpy、strcat、sprintf 和 gets)。一般來講,像「避免使用 strcpy()」和「永遠不使用 gets()」這樣嚴格的規則接近於這個要求。 今天,編寫的程式仍然利用這些呼叫,因為從來沒有人教工作人員避免使用它們。某些人從各處獲得某個提示,但即使是優秀的工作人員也會被這弄糟。他們也許在危險函式的自變數上使用自己總結編寫的檢查,或者錯誤地推論出使用潛在危險的函式在某些特殊情況下是「安全」的。 第 一位公共敵人是 gets()。永遠不要使用 gets()。該函式從標準輸入讀入使用者輸入的一行純文字,它在遇到 EOF 字元或換行字元之前,不會停止讀入純文字。也就是︰gets() 根本不執行邊界檢查。因此,使用 gets() 總是有可能使任何緩衝區溢位。作為一個替代方法,可以使用方法 fgets()。它可以做與 gets() 所做的同樣的事情,但它接受用來限制讀入字元數目的大小參數,因此,提供了一種防止緩衝區溢位的方法。例如,不要使用以下程式碼︰
而使用以下程式碼︰
C 語言中一些標準函式很有可能使您陷入困境。但不是所有函式使用都不好。通常,利用這些函式之一需要任意輸入傳遞給該函式。這個清單包括︰
壞消息是我們推薦,如果有任何可能,避免使用這些函式。好消息是,在大多數情況下,都有合理的替代方法。我們將仔細檢查它們中的每一個,所以可以看到什麼構成了它們的誤用,以及如何避免它。 strcpy() 函式將來源字串複製到緩衝區。沒有指定要複製字元的具體數目。複製字元的數目直接取決於來源字串中的數目。如果來源字串碰巧來自使用者輸入,且沒有專門限制其大小,則有可能會陷入大的麻煩中﹗ 如果知道目的地緩衝區的大小,則可以加入明確的檢查︰
完成同樣目的的更容易方式是使用 strncpy() 函式庫例程︰
如果 src 比 dst 大,則該函式不會拋出一個錯誤;當達到最大尺寸時,它祇是停止複製字元。注意上面呼叫 strncpy() 中的 -1。如果 src 比 dst 長,則那給我們留有空間,將一個空字元放在 dst 陣列的末尾。 當然,可能使用 strcpy() 不會帶來任何潛在的安全性問題,正如在以下範例中所見︰
即使這個作業造成 buf 的溢位,但它祇是對幾個字元這樣而已。由於我們靜態地知道那些字元是什麼,並且很明顯,由於沒有危害,所以這裡無須擔心。當然,除非可以用其它方式覆寫字串「Hello」所在的靜態儲存器。 確保 strcpy() 不會溢位的另一種方式是,在需要它時就指派空間,確保透過在來源字串上呼叫 strlen() 來指派足夠的空間。例如︰
strcat() 函式非常類似於 strcpy(),除了它可以將一個字串合併到緩衝區末尾。它也有一個類似的、更安全的替代方法 strncat()。如果可能,使用 strncat() 而不要使用 strcat()。 函式 sprintf() 和 vsprintf() 是用來製作格式純文字和將其存入緩衝區的通用函式。它們可以用直接的方式模仿 strcpy() 行為。換句話說,使用 sprintf() 和 vsprintf() 與使用 strcpy() 一樣,都很容易對程式造成緩衝區溢位。例如,考慮以下程式碼︰
我們經常會看到類似上面的程式碼。它看起來沒有 什麼危害。它建立一個知道如何呼叫這個程式字串。那樣,可以變更二進位的名稱,這個程式的輸出將自動反映這個變更。 雖然如此, 該程式碼有嚴重的問題。文件系統傾向於將任何文件的名稱限制於特定數目的字元。那麼,您應該認為如果您的緩衝區足夠大,可以處理可能的最長名稱,您的程式 會安全,對嗎?祇要將 1024 改為對我們的作業系統適合的任何數目,就好了嗎?但是,不是這樣的。透過編寫我們自己的小程式來推翻上面所說的,可能容易地推翻這個限制︰
函式 execl() 啟動第一個參數中命名的程式。第二個參數作為 argv[0] 傳遞給被呼叫的程式。我們可以使那個字串要多長有多長﹗ 那麼如何解決 {v}sprintf() 帶來得問題呢?遺憾的是,沒有完全可移植的方法。某些系統架構提供了 snprintf() 方法,即容許程式員指定將多少字元從每個源複製到緩衝區中。例如,如果我們的系統上有 snprintf,則可以修正一個範例成為︰
注意,在第四個變數之前,snprintf() 與 sprintf() 是一樣的。第四個變數指定了從第三個變數中應被複製到緩衝區的字元最大數目。注意,1024 是錯誤的數目﹗我們必須確保要複製到緩衝區使用的字串總長不超過緩衝區的大小。所以,必須考慮一個空字元,加上所有格式字串中的這些字元,再減去格式說明 符 %s。該數字結果為 1000, 但上面的程式碼是更具有可維護性,因為如果格式字串偶然發生變化,它不會出錯。 {v}sprintf() 的許多(但不是全部)版本帶有使用這兩個函式的更安全的方法。可以指定格式字串本身每個自變數的精準度。例如,另一種修正上面有問題的 sprintf() 的方法是︰
注意,百分比符號後與 s 前的 .1000。該語法表明,從相關變數(本例中是 argv[0])複製的字元不超過 1000 個。 如果任一解決方案在您的程式必須執行的系統上行不通,則最佳的解決方案是將 snprintf() 的工作版本與您的程式碼放置在一個包中。可以找到以 sh 歸檔格式的、自由使用的版本;請參閱參考資料。 繼續,scanf 系列的函式也設計得很差。在這種情況下,目的地緩衝區會發生溢位。考慮以下程式碼︰
如果輸入的字大於 buf 的大小,則有溢位的情況。幸運的是,有一種簡便的方法可以解決這個問題。考慮以下程式碼,它沒有安全性方面的薄弱環節︰
百分比符號和 s 之間的 255 指定了實際儲存在變數 buf 中來自 argv[0] 的字元不會超過 255 個。其餘匹配的字元將不會被複製。 接下來,我們討論 streadd() 和 strecpy()。由於,不是每台機器開始就有這些呼叫,那些有這些函式的程式員,在使用它們時,應該小心。這些函式可以將那些含有不可讀字元的字串轉換成可列印的表示。例如,考慮以下程式︰
這個程式列印︰
而不是列印所有空白。如果程式員沒有預料到需要 多大的輸出緩衝區來處理輸入緩衝區(不發生緩衝區溢位),則 streadd() 和 strecpy() 函式可能有問題。如果輸入緩衝區包括單一字元,假設是 ASCII 001(control-A)⑵ 則它將列印成四個字元「/001」。這是字串增長的最壞情況。如果沒有指派足夠的空間,以至於輸出緩衝區的大小總是輸入緩衝區大小的四倍,則可能發生緩衝 區溢位。 另一個較少使用的函式是 strtrns(),因為許多機器上沒有該函式。函式 strtrns() 取三個字串和結果字串應該放在其內的一個緩衝區,作為其自變數。第一個字串必須複製到該緩衝區。一個字元被從第一個字串中複製到緩衝區,除非那個字元出現 在第二個字串中。如果出現的話,那麼會置換掉第三個字串中同一索引中的字元。這聽上去有點令人迷惑。讓我們看一下,將所有小寫字元轉換成大寫字元的範例︰
以上程式碼實際上不包括緩衝區溢位。但如果我們使用了固定大小的靜態緩衝區,而不是用 malloc() 指派足夠空間來複製 argv[1],則可能會引起緩衝區溢位情況。 realpath() 函式接受可能包括相對路徑的字串,並將它轉換成指同一文件的字串,但是透過絕對路徑。在做這件事時,它展開了所有符號連結。 該 函式取兩個自變數,第一個作為要規範化的字串,第二個作為將儲存結果的緩衝區。當然,需要確保結果緩衝區足夠大,以處理任何大小的路徑。指派的 MAXPATHLEN 緩衝區應該足夠大。然而,使用 realpath() 有另一個問題。如果傳遞給它的、要規範化的路徑大小大於 MAXPATHLEN,則 realpath() 實作內部的靜態緩衝區會溢位﹗雖然實際上沒有存取溢位的緩衝區,但無論如何它會傷害您的。結果是,應該明確不使用 realpath(),除非確保檢查您試圖規範化的路徑長度不超過 MAXPATHLEN。 其它廣泛可用的呼叫也有類似的問題。經 常使用的 syslog() 呼叫也有類似的問題,直到不久前,才注意到這個問題並修正了它。大多數機器上已經糾正了這個問題,但您不應該依賴正確的行為。最好總是假定程式碼正執行在 可能最不友好的環境中,只是萬一在哪天它真的這樣。getopt() 系列呼叫的各種實作,以及 getpass() 函式,都可能產生內部靜態緩衝區溢位問題。如果您不得不使用這些函式,最佳解決方案是設定傳遞給這些函式的輸入長度的閾值。 自己模擬 gets() 的安全性問題以及所有問題是非常容易的。 例如,下面這段程式碼︰
哎呀﹗可以用來讀入字元的任何函式都存在這個問題,包括 getchar()、fgetc()、getc() 和 read()。 緩衝區溢位問題的準則是︰總是確保做邊界檢查。 C 和 C++ 不能夠自動地做邊界檢查,這實在不好,但確實有很好的原因,來解釋不這樣做的理由。邊界檢查的代價是效率。一般來講,C 在大多數情況下注重效率。然而,獲得效率的代價是,C 程式員必須十分警覺,並且有極強的安全意識,才能防止他們的程式出現問題,而且即使這些,使程式碼不出問題也不容易。 在現在,變數 檢查不會嚴重影響程式的效率。大多數應用程式不會注意到這點差異。所以,應該總是進行邊界檢查。在將資料複製到您自己的緩衝區之前,檢查資料長度。同樣, 檢查以確保不要將過大的資料傳遞給另一個函式庫,因為您也不能相信其它人的程式碼﹗(回憶一下前面所討論的內部緩衝區溢位。) 遺憾的是,即使是系統呼叫的「安全」版本,譬如,相對於 strcpy() 的 strncpy() 也不完全安全。也有可能把事情搞糟。即使「安全」的呼叫有時會留下未終止的字串,或者會發生微妙的相差一位錯誤。當然,如果您偶然使用比來源緩衝區小的結 果緩衝區,則您可能發現自己處於非常困難的境地。 與 我們目前所討論的相比,往往很難犯這些錯誤,但您應該仍然意識到它們。當使用這類呼叫時,要仔細考慮。如果不仔細留意緩衝區大小,包括 bcopy()、fgets()、memcpy()、snprintf()、strccpy()、strcadd()、strncpy() 和 vsnprintf(),許多函式會行為失常。 另一個要避免的系統呼叫是 getenv()。使用 getenv() 的最大問題是您從來不能假定特殊環境變數是任何特定長度的。我們將在後續的專欄文章中討論環境變數帶來的種種問題。 到 目前為止,我們已經給出了一大堆常見 C 函式,這些函式容易引起緩衝區溢位問題。當然,還有許多函式有相同的問題。特別是,注意第三方 COTS 軟體。不要設想關於其它人軟體行為的任何事情。還要意識到我們沒有仔細檢查每個平臺上的每個常見函式庫(我們不想做那一工作),並且還可能存在其它有問題 的呼叫。 即使我們檢查了每個常見函式庫的各個地方,如果我們試圖聲稱已經列出了將在任何時候遇到的所有問題,則您應該持非常非常懷疑的態度。我們祇是想給您起一個頭。其餘全靠您了。 我們將在以後的專欄文章中更加詳細地介紹一些脆弱性偵測的工具,但現在值得一提的是兩種已被證明能有效幫助找到和去除緩衝區溢位問題的掃瞄工具。 這兩個主要類別的分析工具是靜態工具(考慮程式碼但永不執行)和動態工具(執行程式碼以確定行為)。 可 以使用一些靜態工具來尋找潛在的緩衝區溢位問題。很糟糕的是,沒有一個工具對一般公眾是可用的﹗許多工具做得一點也不比自動化 grep 指令多,可以執行它以找到來源程式碼中每個有問題函式的案例。由於存在更好的技術,這仍然是高效的方式將幾萬行或幾十萬行的大程式縮減到祇有數百個「潛在 的問題」。(在以後的專欄文章中,將示範一個基於這種方法的、草草了事的掃瞄工具,並告訴您有關如何建置它的想法。) 較好的靜態工 具利用以某些方式表示的資料串流訊息來斷定哪個變數會影響到其它哪個變數。用這種方法,可以丟棄來自基於 grep 的分析的某些「假肯定」。David Wagner 在他的工作中已經實作了這樣的方法(在「Learning the basics of buffer overflows」中描述;請參閱參考資料),在 Reliable Software Technologies 的研究人員也已實作。目前,資料串流相關方法的問題是它目前引入了假否定(即,它沒有標誌可能是真正問題的某些呼叫)。 第二類方法涉及動態分析的使用。動態工具通常把注意力放在程式碼執行時的情況,尋找潛在的問題。一種已在實驗室使用的方法是故障注入。這個想法是以這樣一種方式來偵測程式︰對它進行實驗,執行「假設」遊戲,看它會發生什麼。有一種故障注入工具 ⑵ FIST(請參閱參考資料)已被用來尋找可能的緩衝區溢位脆弱性。 最終,動態和靜態方法的某些組合將會給您的投資帶來回報。但在確定最佳組合方面,仍然有許多工作要做。 如上一篇專欄文章中所提到的(請參閱參考資料), 堆疊搗毀是最惡劣的一種緩衝區溢位攻擊,特別是,當在特權模式下搗毀了堆疊。這種問題的優秀解決方案是非可執行堆疊。 通常,利用程式碼是在程式堆疊上編寫,並在那裡執行的。(我們將在下一篇專欄文章中解釋這是如何做到的。)獲取許多作業系統(包括 Linux 和 Solaris)的非可執行堆疊補綴是可能的。(某些作業系統甚至不需要這樣的補綴;它們本身就帶有。) 非可執行堆疊涉及到一些效 能問題。(沒有免費的午餐。)此外,在既有堆疊溢位又有堆溢位的程式中,它們易出問題。可以利用堆疊溢位使程式跳轉至利用程式碼,該程式碼被放置在堆上。 沒有實際執行堆疊中的程式碼,祇有堆中的程式碼。這些基本問題非常重要,我們將在下一篇專欄文章中專門刊載。 當然,另一種選項是使 用類型安全的語言,譬如 Java。較溫和的措施是獲取對 C 程式中進行陣列邊界檢查的編譯器。對於 gcc 存在這樣的工具。這種技術可以防止所有緩衝區溢位,堆和堆疊。不利的一面是,對於那些大量使用指針、速度是至關重要的程式,這種技術可能會影響效能。但是 在大多數情況下,該技術執行得非常好。 Stackguard 工具實作了比一般性邊界檢查更為有效的技術。它將一些資料放在已指派資料堆疊的末尾,並且以後會在緩衝區溢位可能發生前,檢視這些資料是否仍然在那裡。這 種模式被稱之為「金絲雀」。(威爾士的礦工將 金絲雀放在礦井內來顯示危險的狀況。當空氣開始變得有毒時,金絲雀會昏倒,使礦工有足夠時間注意到並逃離。) Stackguard 方法不如一般性邊界檢查安全,但仍然相當有用。Stackguard 的主要缺點是,與一般性邊界檢查相比,它不能防止堆溢位攻擊。一般來講,最好用這樣一個工具來保護整個作業系統,否則,由程式呼叫的不受保護函式庫(譬 如,標準函式庫)可以仍然為基於堆疊的利用程式碼攻擊開啟了大門。 類似於 Stackguard 的工具是記憶體完整性檢查套裝軟體,譬如,Rational 的 Purify。這類工具甚至可以保護程式防止堆溢位,但由於效能開銷,這些工具一般不在產品程式碼中使用。 在本專欄的上兩篇文章中,我們已經介紹了緩衝區溢位,並指導您如何編寫程式碼來避免這些問題。我們還討論了可幫助使您的程式安全遠離可怕的緩衝區溢位的幾 個工具。表 1 總結了一些程式化建構,我們建議您小心使用或避免一起使用它們。如果有任何認為我們應該將其它函式加入該清單,請則通知我們,我們將更新該清單。
在我們急匆匆講述這些基礎知識時,到現在為止,已經遺漏了一些緩衝區溢位很酷的細節。在下幾篇專欄文章中,我們將深入這台「引擎」的工作,並給它加點黃油。我們將詳細地瞭解緩衝區溢位的工作原理,甚至還會示範一些利用程式碼。
|