什麼是緩沖區溢出? 緩沖區以前可能被定義為“包含相同數據類型的實例的一個連續計算機內存塊”。在 C 和 C++ 中,緩沖區通常是使用數組和諸如 malloc() 和 new 這樣的內存分配例程來實現的。極其常見的緩沖區種類是簡單的字符數組。溢出 是指數據被添加到分
什麼是緩沖區溢出? 緩沖區以前可能被定義為“包含相同數據類型的實例的一個連續計算機內存塊”。在 C 和 C++ 中,緩沖區通常是使用數組和諸如 malloc() 和 new 這樣的內存分配例程來實現的。極其常見的緩沖區種類是簡單的字符數組。溢出 是指數據被添加到分配給該緩沖區的內存塊之外。
如果攻擊者能夠導致緩沖區溢出,那麼它就能控制程序中的其他值。雖然存在許多利用緩沖區溢出的方法,不過最常見的方法還是“stack-smashing”攻擊。Elias Levy (又名為 Aleph One)的一篇經典文章“Smashing the Stack for Fun and Profit”解釋了 stack-smashing 攻擊,Elias Levy 是
Bugtraq 郵件列表(請參閱 參考資料 以獲得相關鏈接)的前任主持人。
為了理解 stack-smashing 攻擊(或其他任何緩沖區攻擊)是如何進行的,您需要了解一些關於計算機在機器語言級實際如何工作的
知識。在類 UNIX 系統上,每個進程都可以劃分為三個主要區域:文本、數據和堆棧。文本區域 包括代碼和只讀數據,通常不能對它執行寫入操作。數據區域 同時包括靜態分配的內存(比如全局和靜態數據)和動態分配的內存(通常稱為 堆)。堆棧區域 用於允許函數/方法調用;它用於記錄函數完成之後的返回位置,存儲函數中使用的本地變量,向函數傳遞參數,以及從函數返回值。每當調用一個函數,就會使用一個新的 堆棧幀 來支持該調用。了解這些之後,讓我們來考察一個簡單的程序。
清單 1. 一個簡單的程序
void function1(int a, int b, int c) {
char buffer1[5];
gets(buffer1); /* DON'T DO THIS */
}
void main() {
function(1,2,3);
}
假設使用 gcc 來編譯清單 1 中的簡單程序,在 X86 上的 Linux 中運行,並且緊跟在對 gets()的調用之後中止。此時的內存內容看起來像什麼樣子呢?答案是它看起來類似圖 1,其中展示了從左邊的低位地址到右邊的高位地址排序的內存布局。
內存的底部 內存的頂部
buffer1 sfp ret a b c
<--- 增長 --- [ ] [ ] [ ] [ ] [ ] [ ] ...
堆棧的頂部 堆棧的底部
許多計算機處理器,包括所有 x86 處理器,都支持從高位地址向低位地址“倒”增長堆棧。因此,每當一個函數調用另一個函數,更多的數據將被添加到左邊(低位地址),直至系統的堆棧空間耗盡。在這個例子中,當 main() 調用 function1()時,它將 c 的值壓入堆棧,然後壓入 b 的值,最後壓入 a 的值。之後它壓入 return (ret)值,這個值在 function1()完成時告訴 function1() 返回到 main() 中的何處。它還把所謂的“已保存的幀指針(saved frame pointer,sfp)”記錄到堆棧上;這並不是必須保存的內容,此處我們不需要理解它。在任何情況下,function1()在啟動以後,它會為 buffer1()預留空間,這在圖 1 中顯示為具有一個低地址位置。
現在假設攻擊者發送了超過 buffer1() 所能處理的數據。接下來會發生什麼情況呢?當然,C 和 C++
程序員不會自動檢查這個問題,因此除非程序員明確地阻止它,否則下一個值將進入內存中的“下一個”位置。那意味著攻擊者能夠改寫 sfp(即已保存的幀指針),然後改寫 ret(返回地址)。之後,當 function1() 完成時,它將“返回”—— 不過不是返回到 main(),而是返回到攻擊者想要運行的任何代碼。
通常攻擊者會使用它想要運行的惡意代碼來使緩沖區溢出,然後攻擊者會更改返回值以指向它們已發送的惡意代碼。這意味著攻擊者本質上能夠在一個操作中完成整個攻擊!Aleph On 的文章(請參閱 參考資料)詳細介紹了這樣的攻擊代碼是如何創建的。例如,將一個 ASCII 0 字符壓入緩沖區通常是很困難的,而該文介紹了攻擊者一般如何能夠解決這個問題。
除了 smashing-stack 和更改返回地址外,還存在利用緩沖區溢出
缺陷的其他途徑。與改寫返回地址不同,攻擊者可以 smashing-stack(使堆棧上的緩沖區溢出),然後改寫局部變量以利用緩沖區溢出缺陷。緩沖區根本就不必在堆棧上 —— 它可以是堆中動態分配的內存(也稱為“malloc”或“new”區域),或者在某些靜態分配的內存中(比如“global”或“static”內存)。基本上,如果攻擊者能夠溢出緩沖區的邊界,麻煩或許就會找上你了。 然而,最危險的緩沖區溢出攻擊就是 stack-smashing 攻擊,因為如果程序對攻擊者很脆弱,攻擊者獲得整個機器的控制權就特別容易。
為什麼緩沖區溢出如此常見? 在幾乎所有計算機語言中,不管是新的語言還是舊的語言,使緩沖區溢出的任何嘗試通常都會被該語言本身自動檢測並阻止(比如通過引發一個異常或根據需要給緩沖區添加更多空間)。但是有兩種語言不是這樣:C 和 C++ 語言。C 和 C++ 語言通常只是讓額外的數據亂寫到其余內存的任何位置,而這種情況可能被利用從而導致恐怖的結果。更糟糕的是,用 C 和 C++ 編寫正確的代碼來始終如一地處理緩沖區溢出則更為困難;很容易就會意外地導致緩沖區溢出。除了 C 和 C++ 使用得 非常 廣泛外,上述這些可能都是不相關的事實;例如,Red Hat Linux 7.1 中 86% 的代碼行都是用 C 或 C ++ 編寫的。因此,大量的代碼對這個問題都是脆弱的,因為實現語言無法保護代碼避免這個問題。
在 C 和 C++ 語言本身中,這個問題是不容易解決的。該問題基於 C 語言的根本設計決定(特別是 C 語言中指針和數組的處理方式)。由於 C++ 是最兼容的 C 語言超集,它也具有相同的問題。存在一些能防止這個問題的 C/C++ 兼容版本,但是它們存在極其嚴重的性能問題。而且一旦改變 C 語言來防止這個問題,它就不再是 C 語言了。許多語言(比如 Java 和 C#)在語法上類似 C,但它們實際上是不同的語言,將現有 C 或 C++ 程序改為使用那些語言是一項艱巨的任務。
然而,其他語言的用戶也不應該沾沾自喜。有些語言存在允許緩沖區溢出發生的“轉義”子句。Ada 一般會檢測和防止緩沖區溢出(即針對這樣的嘗試引發一個異常),但是不同的程序可能會禁用這個特性。C# 一般會檢測和防止緩沖區溢出,但是它允許程序員將某些例程定義為“不
安全的”,而這樣的代碼 可能 會導致緩沖區溢出。因此如果您使用那些轉義機制,就需要使用 C/C++ 程序所必須使用的相同種類的保護機制。許多語言都是用 C 語言來實現的(至少部分是用 C 語言來實現的 ),並且用任何語言編寫的所有程序本質上都依賴用 C 或 C++ 編寫的庫。因此,所有程序都會繼承那些問題,所以了解這些問題是很重要的。
導致緩沖區溢出的常見 C 和 C++ 錯誤 從根本上講,在程序將數據讀入或復制到緩沖區中的任何時候,它需要在復制 之前 檢查是否有足夠的空間。能夠容易看出來的異常就不可能會發生 —— 但是程序通常會隨時間而變更,從而使得不可能成為可能。
遺憾的是,C 和 C++ 附帶的大量危險函數(或普遍使用的庫)甚至連這點(指檢查空間)也無法做到。程序對這些函數的任何使用都是一個警告信號,因為除非慎重地使用它們,否則它們就會成為程序缺陷。您不需要記住這些函數的列表;我的真正目的是說明這個問題是多麼普遍。這些函數包括 strcpy(3)、strcat(3)、sprintf(3)(及其同類 vsprintf(3))和 gets(3)。scanf()函數集(scanf(3)、fscanf(3)、sscanf(3)、vscanf(3)、
vsscanf(3) 和 vfscanf(3))可能會導致問題,因為使用一個沒有定義最大長度的格式是很容易的(當讀取不受信任的輸入時,使用格式“%s”總是一個錯誤)。
其他危險的函數包括 realpath(3)、getopt(3)、getpass(3)、streadd(3)、strecpy(3) 和 strtrns(3)。 從理論上講,snprintf()應該是相對安全的 —— 在現代 GNU/Linux 系統中的確是這樣。但是非常老的 UNIX 和 Linux 系統沒有實現 snprintf() 所應該實現的保護機制。
Microsoft 的庫中還有在相應平台上導致同類問題的其他函數(這些函數包括 wcscpy()、_tcscpy()、_mbscpy()、wcscat()、_tcscat()、_mbscat() 和 CopyMemory())。注意,如果使用 Microsoft 的 MultiByteToWideChar() 函數,還存在一個常見的危險錯誤 —— 該函數需要一個最大尺寸作為字符數目,但是程序員經常將該尺寸以字節計(更普遍的需要),結果導致緩沖區溢出缺陷。
另一個問題是 C 和 C++ 對整數具有非常弱的類型檢查,一般不會檢測操作這些整數的問題。由於它們要求程序員手工做所有的問題檢測工作,因此以某種可被利用的方式不正確地操作那些整數是很容易的。特別是,當您需要跟蹤緩沖區長度或讀取某個內容的長度時,通常就是這種情況。但是如果使用一個有符號的值來存儲這個長度值會發生什麼情況呢 —— 攻擊者會使它“成為負值”,然後把該數據解釋為一個實際上很大的正值嗎?當數字值在不同的尺寸之間轉換時,攻擊者會利用這個操作嗎?數值溢出可被利用嗎? 有時處理整數的方式會導致程序缺陷。
防止緩沖區溢出的新技術 當然,要讓程序員 不 犯常見錯誤是很難的,而讓程序(以及程序員)改為使用另一種語言通常更為困難。那麼為何不讓底層系統自動保護程序避免這些問題呢?最起碼,避免 stack-smashing 攻擊是一件好事,因為 stack-smashing 攻擊是特別容易做到的。
一般來說,更改底層系統以避免常見的安全問題是一個極好的想法,我們在本文後面也會遇到這個主題。事實證明存在許多可用的防御措施,而一些最受歡迎的措施可分組為以下類別:
基於探測方法(canary)的防御。這包括 StackGuard(由 Immunix 所使用)、ProPolice(由 OpenBSD 所使用)和 Microsoft 的 /GS 選項。
非執行的堆棧防御。這包括 Solar Designer 的 non-exec 補丁(由 OpenWall 所使用)和 exec shield(由 Red Hat/Fedora 所使用)。
其他方法。這包括 libsafe(由 Mandrake 所使用)和堆棧分割方法。
遺憾的是,迄今所見的所有方法都具有弱點,因此它們不是萬能藥,但是它們會提供一些幫助。
基於探測方法的防御 研究人員 Crispen Cowan 創建了一個稱為 StackGuard 的有趣方法。Stackguard 修改 C 編譯器(gcc),以便將一個“探測”值插入到返回地址的前面。“探測儀”就像煤礦中的探測儀:它在某個地方出故障時發出警告。在任何函數返回之前,它執行檢查以確保探測值沒有改變。如果攻擊者改寫返回地址(作為 stack-smashing 攻擊的一部分),探測儀的值或許就會改變,系統內就會相應地中止。這是一種有用的方法,不過要注意這種方法無法防止緩沖區溢出改寫其他值(攻擊者仍然能夠利用這些值來攻擊系統)。人們也曾擴展這種方法來保護其他值(比如堆上的值)。Stackguard(以及其他防御措施)由 Immunix 所使用。
IBM 的 stack-smashing 保護程序(ssp,起初名為 ProPolice)是 StackGuard 的方法的一種變化形式。像 StackGuard 一樣,ssp 使用一個修改過的編譯器在函數調用中插入一個探測儀以檢測堆棧溢出。然而,它給這種基本的思路添加了一些有趣的變化。 它對存儲局部變量的位置進行重新排序,並復制函數參數中的指針,以便它們也在任何數組之前。這樣增強了ssp 的保護能力;它意味著緩沖區溢出不會修改指針值(否則能夠控制指針的攻擊者就能使用指針來控制程序保存數據的位置)。默認情況下,它不會檢測所有函數,而只是檢測確實需要保護的函數(主要是使用字符數組的函數)。從理論上講,這樣會稍微削弱保護能力,但是這種默認行為改進了性能,同時仍然能夠防止大多數問題。考慮到實用的因素,它們以獨立於體系結構的方式使用 gcc 來實現它們的方法,從而使其更易於運用。從 2003 年 5 月的發布版本開始,廣受贊譽的 OpenBSD(它重點關注安全性)在他們的整個發行套件中使用了 ssp(也稱為 ProPolice)。
Microsoft 基於 StackGuard 的成果,添加了一個編譯器標記(/GS)來實現其 C 編譯器中的探測儀。
非執行的堆棧防御 另一種方法首先使得在堆棧上執行代碼變得不可能。 遺憾的是,x86 處理器(最常見的處理器)的內存保護機制無法容易地支持這點;通常,如果一個內存頁是可讀的,它就是可執行的。一個名叫 Solar Designer 的
開發人員想出了一種內核和處理器機制的聰明組合,為 Linux 內核創建了一個“非執行的堆棧補丁”;有了這個補丁,堆棧上的程序就不再能夠像通常的那樣在 x86 上運行。 事實證明在有些情況下,可執行程序 需要 在堆棧上;這包括信號處理和跳板代碼(trampoline)處理。trampoline 是有時由編譯器(比如 GNAT Ada 編譯器)生成的奇妙結構,用以支持像嵌套子例程之類的結構。Solar Designer 還解決了如何在防止攻擊的同時使這些特殊情況不受影響的問題。
Linux 中實現這個目的的最初補丁在 1998 年被 Linus Torvalds 拒絕,這是因為一個有趣的原因。即使不能將代碼放到堆棧上,攻擊者也可以利用緩沖區溢出來使程序“返回”某個現有的子例程(比如 C 庫中的某個子例程),從而進行攻擊。簡而言之,僅只是擁有非可執行的堆棧是不足夠的。
一段時間之後,人們又想出了一種防止該問題的新思路:將所有可執行代碼轉移到一個稱為“ASCII 保護(ASCII armor)”區域的內存區。要理解這是如何工作的,就必須知道攻擊者通常不能使用一般的緩沖區溢出攻擊來插入 ASCII NUL 字符(0)這個事實。 這意味著攻擊者會發現,要使一個程序返回包含 0 的地址是很困難的。由於這個事實,將所有可執行代碼轉移到包含 0 的地址就會使得攻擊該程序困難多了。
具有這個屬性的最大連續內存范圍是從 0 到 0x01010100 的一組內存地址,因此它們就被命名為 ASCII 保護區域(還有具有此屬性的其他地址,但它們是分散的)。與非可執行的堆棧相結合,這種方法就相當有價值了:非可執行的堆棧阻止攻擊者發送可執行代碼,而 ASCII 保護內存使得攻擊者難於通過利用現有代碼來繞過非可執行堆棧。這樣將保護程序代碼避免堆棧、緩沖區和函數指針溢出,而且全都不需重新編譯。
然而,ASCII 保護內存並不適用於所有程序;大程序也許無法裝入 ASCII 保護內存區域(因此這種保護是不完美的),而且有時攻擊者 能夠 將 0 插入目的地址。 此外,有些實現不支持跳板代碼,因此可能必須對需要這種保護的程序禁用該特性。Red Hat 的 Ingo Molnar 在他的“exec-shield”補丁中實現了這種思想,該補丁由 Fedora 核心(可從 Red Hat 獲得它的免費版本)所使用。最新版本的 OpenWall GNU/Linux (OWL)使用了 Solar Designer 提供的這種方法的實現(請參閱 參考資料 以獲得指向這些版本的鏈接)。
其他方法 還有其他許多方法。一種方法就是使標准庫對攻擊更具抵抗力。Lucent Technologies 開發了 Libsafe,這是多個標准 C 庫函數的包裝,也就是像 strcpy() 這樣已知的對 stack-smashing 攻擊很脆弱的函數。Libsafe 是在 LGPL 下授予許可證的開放源代碼軟件。那些函數的 libsafe 版本執行相關的檢查,確保數組改寫不會超出堆棧桢。然而,這種方法僅保護那些特定的函數,而不是從總體上防止堆棧溢出缺陷,並且它僅保護堆棧,而不保護堆棧中的局部變量。它們的最初實現使用了 LD_PRELOAD,而這可能與其他程序產生沖突。Linux 的 Mandrake 發行套件(從 7.1 版開始)包括了 libsafe。
另一種方法稱為“分割控制和數據堆棧”—— 基本的思路是將堆棧分割為兩個堆棧,一個用於存儲控制信息(比如“返回”地址),另一個用於控制其他所有數據。Xu et al. 在 gcc 中實現了這種方法,StackShield 在匯編程序中實現了這種方法。這樣使得操縱返回地址困難多了,但它不會阻止改變調用函數的數據的緩沖區溢出攻擊。
事實上還有其他方法,包括隨機化可執行程序的位置;Crispen 的“PointGuard”將這種探測儀思想引申到了堆中,等等。如何保護當今的計算機現在已成了一項活躍的研究任務。
一般保護是不足夠的 如此多不同的方法意味著什麼呢?對用戶來說,好的一面在於大量創新的方法正在試驗之中;長期看來,這種“競爭”會更容易看出哪種方法最好。而且,這種多樣性還使得攻擊者躲避所有這些方法更加困難。然而,這種多樣性也意味著開發人員需要避免 編寫會干擾其中任何一種方法的代碼。這在實踐上是很容易的;只要不編寫對堆棧桢執行低級操作或對堆棧的布局作假設的代碼就行了。即使不存在這些方法,這也是一個很好的建議。
操作系統供應商需要參與進來就相當明顯了:至少挑選一種方法,並使用它。緩沖區溢出是第一號的問題,這些方法中最好的方法通常能夠減輕發行套件中幾乎半數已知缺陷的影響。可以證明,不管是基於探測儀的方法更好,還是基於非可執行堆棧的方法更好,它們都具有各自的優點。可以將它們結合起來使用,但是少數方法不支持這樣使用,因為附加的性能損失使得這樣做不值得。我並沒有其他意思,至少就這些方法本身而言是這樣;libsafe 和分割控制及數據堆棧的方法在它們所提供的保護方面都具有局限性。當然,最糟糕的解決辦法就是根本不對這個第一號的缺陷提供保護。還沒有實現一種方法的軟件供應商需要立即計劃這樣做。從 2004 年開始,用戶應該開始避免使用這樣的操作系統,即它們至少沒有對緩沖區溢出提供某種自動保護機制。
然而,沒有哪種方法允許開發人員忽略緩沖區溢出。所有這些方法都能夠被攻擊者破壞。 攻擊者也許能夠通過改變函數中其他數據的值來利用緩沖區溢出;沒有哪種方法能夠防止這點。如果能夠插入某些難於創建的值(比如 NUL 字符),那麼這其中的許多方法都能被攻擊者繞開;隨著多媒體和壓縮數據變得更加普遍,攻擊者繞開這些方法就更容易了。從根本上講,所有這些方法都能減輕從程序接管攻擊到拒絕服務攻擊的緩沖區溢出攻擊所帶來的破壞。遺憾的是,隨著計算機系統在更多關鍵場合的使用,即使拒絕服務通常也是不可接受的。因而,盡管發行套件應該至少包括一種適當的防御方法,並且開發人員應該使用(而不是反對)那些方法,但是開發人員仍然需要最初就編寫無缺陷的軟件。
C/C++ 解決方案 針對緩沖區溢出的一種簡單解決辦法就是轉為使用能夠防止緩沖區溢出的語言。畢竟,除了 C 和 C++ 外,幾乎每種高級語言都具有有效防止緩沖區溢出的內置機制。但是許多開發人員因為種種原因還是選擇使用 C 和 C++。那麼您能做什麼呢?
事實證明存在許多防止緩沖區溢出的不同技術,但它們都可劃分為以下兩種方法:靜態分配的緩沖區和動態分配的緩沖區。首先,我們將講述這兩種方法分別是什麼。然後,我們將討論靜態方法的兩個例子(標准 C strncpy/strncat 和 OpenBSD 的 strlcpy/strlcat),接著討論動態方法的兩個例子(SafeStr 和 C++ 的 s
td::string)。
重要選擇:靜態和動態分配的緩沖區 緩沖區具有有限的空間。因此實際上存在處理緩沖區空間不足的兩種可能方式。
“靜態分配的緩沖區”方法:也就是當緩沖區用完時,您抱怨並拒絕為緩沖區增加任何空間。
“動態分配的緩沖區”方法:也就是當緩沖區用完時,動態地將緩沖區大小調整到更大的尺寸,直至用完所有內存。
靜態方法具有一些缺點。事實上,靜態方法有時可能會帶來不同的缺陷。靜態方法基本上就是丟棄“過多的”數據。如果程序無論如何還是使用了結果數據,那麼攻擊者會嘗試填滿緩沖區,以便在數據被截斷時使用他希望的任何內容來填充緩沖區。如果使用靜態方法,應該確保攻擊者能夠做的最糟糕的事情不會使得預先的假設無效,而且檢查最終結果也是一個好主意。
動態方法具有許多優點:它們能夠向上適用於更大的問題(而不是帶來任意的限制),而且它們沒有導致安全問題的字符數組截斷問題。但它們也具有自身的問題:在接受任意大小的數據時,可能會遇到內存不足的情況 —— 而這在輸入時也許不會發生。任何內存分配都可能會失敗,而編寫真正很好地處理該問題的 C 或 C++ 程序是很困難的。甚至在內存真正用完之前,也可能導致計算機變得太忙而不可用。簡而言之,動態方法通常使得攻擊者發起拒絕服務攻擊變得更加容易。因此仍然需要限制輸入。此外,必須小心設計程序來處理任意位置的內存耗盡問題,而這不是一件容易的事情。
標准 C 庫方法 最簡單的方法之一是簡單地使用那些設計用於防止緩沖區溢出的標准 C 庫函數(即使在使用 C ++,這也是可行的),特別是 strncpy(3) 和 strncat(3)。這些標准 C 庫函數一般支持靜態分配方法,也就是在數據無法裝入緩沖區時丟棄它。這種方法的最大優點在於,您可以肯定這些函數在任何機器上都可用,並且任何 C/C++ 開發人員都會了解它們。許許多多的程序都是以這種方式編寫的,並且確實可行。
遺憾的是,要正確地做到這點卻是令人吃驚的困難。下面是其中的一些問題:
strncpy(3) 和 strncat(3) 都要求您給出 剩余的 空間,而不是給出緩沖區的總大小。這之所以會成為問題是因為,雖然緩沖區的大小一經分配就不會變化,但是緩沖區中剩余的空間量會在每次添加或刪除數據時發生變化。這意味著程序員必須始終跟蹤或重新計算剩余的空間。這種跟蹤或重新計算很容易出錯,而任何錯誤都可能給緩沖區攻擊打開方便之門。
在發生了溢出(和數據丟失)時,兩個函數都不會給出簡單的報告,因此如果要檢測緩沖區溢出,程序員就必須做更多的工作。
如果源字符串至少和目標一樣長,那麼函數 strncpy(3) 還不會使用 NUL 來結束字符串;這可能會在以後導致嚴重破壞。因而,在運行 strncpy(3)之後,您通常需要重新結束目標字符串。
函數 strncpy(3)還可以用來僅把源字符串的一部分復制到目標中。 在執行這個操作時,要復制的字符的數目通常是基於源字符串的相關信息來計算的。 這樣的危險之處在於,如果忘了考慮可用的緩沖區空間,那麼 即使在使用 strncpy(3) 時也可能會留下緩沖區攻擊隱患。這個函數也不會復制 NUL 字符,這可能也是一個問題。
可以通過一種防止緩沖區溢出的方式使用 sprintf(),但是意外地留下緩沖區溢出攻擊隱患是非常容易的。sprintf() 函數使用一個控制字符串來指定輸出格式,該控制字符串通常包括“%s”(字符串輸出)。如果指定字符串輸出的精確指定符(比如 %.10s),那麼您就能夠通過指定輸出的最大長度來防止緩沖區溢出。甚至可以使用“*”作為精確指定符(比如“%.*s”),這樣您就可以傳入一個最大長度值,而不是在控制字符串中嵌入最大長度值。這樣的問題在於,很容易就會不正確地使用 sprintf()。一個“字段寬度”(比如“%10s”)僅指定了最小長度 —— 而不是最大長度。“字段寬度”指定符會留下緩沖區溢出隱患,而字段寬度和精確寬度指定符看起來幾乎完全相同 —— 唯一的區別在於安全的版本具有一個點號。另一個問題在於,精確字段僅指定一個參數的最大長度,但是緩沖區需要針對組合起來的數據的最大尺寸調整大小。
scanf() 系列函數具有一個最大寬度值,至少 IEEE Standard 1003-2001 清楚地規定這些函數一定不能讀取超過最大寬度的數據。遺憾的是,並非所有規范都清楚地規定了這一點,我們不清楚是否所有實現都正確地實現了這些限制(這在如今的 GNU/Linux 系統上就 不能 正確地工作)。如果您依賴它,那麼在安裝或初始化期間運行小
測試來確保它能正確工作,這樣做將是明智的。
strncpy(3) 還存在一個惱人的性能問題。從理論上講,strncpy(3) 是 strcpy(3)的安全替代者,但是 strncpy(3) 還會在源字符串結束時使用 NUL 來填充整個目標空間。 這是很奇怪的,因為實際上並不存在這樣做的很好理由,但是它從一開始就是這樣,並且有些程序還依賴這個特性。這意味著從 strcpy(3) 切換到 strncpy(3) 會降低性能 —— 這在如今的計算機上通常不是一個嚴重的問題,但它仍然是有害的。
那麼可以使用標准 C 庫的例程來防止緩沖區溢出嗎?是的,不過並不容易。如果計劃沿著這條路線走,您需要理解上述的所有要點。或者,您可以使用下面幾節將要講述的一種替代方法。
OpenBSD 的 strlcpy/strlcat OpenBSD 開發人員開發了一種不同的靜態方法,這種方法基於他們開發的新函數 strlcpy(3) 和 strlcat(3)。這些函數執行字符串復制和拼接,不過更不容易出錯。這些函數的原型如下:
size_t strlcpy (char *dst, const char *src, size_t size);
size_t strlcat (char *dst, const char *src, size_t size);
strlcpy() 函數把以 NUL 結尾的字符串從“src”復制到“dst”(最多 size-1 個字符)。strlcat()函數把以 NUL 結尾的字符串 src 附加到 dst 的結尾(但是目標中的字符數目將不超過 size-1)。
初看起來,它們的原型和標准 C 庫函數並沒有多大區別。但是事實上,它們之間存在一些顯著區別。這些函數都接受目標的總大小(而不是剩余空間)作為參數。這意味著您不必連續地重新計算空間大小,而這是一項易於出錯的任務。此外,只要目標的大小至少為 1,兩個函數都保證目標將以 NUL 結尾(您不能將任何內容放入零長度的緩沖區)。如果沒有發生緩沖區溢出,返回值始終是組合字符串的長度;這使得檢測緩沖區溢出真正變得容易了。
遺憾的是,strlcpy(3) 和 strlcat(3) 並不是在類 UNIX 系統的標准庫中普遍可用。OpenBSD 和 Solaris 將它們內置在 <string.h> 中,但是 GNU/Linux 系統卻不是這樣。這並不是一件那麼困難的事情;因為當底層系統沒有提供它們時,您甚至可以將一些小函數直接包括在自己的程序源代碼中。
SafeStr Messier 和 Viega 開發了“SafeStr”庫,這是一種用於 C 的動態方法,它自動根據需要調整字符串的大小。使用 malloc() 實現所使用的相同技巧,Safestr 字符串很容易轉換為常規的 C“char *”字符串:safestr 在傳遞指針“之前”的地址處存儲重要信息。這種技術的優點在於,在現有程序中使用 SafeStr 將會很容易。SafeStr 還支持“只讀”和“受信任”的字符串,這也可能是有用的。這種方法的一個問題在於它需要 XXL(這是一個給 C 添加異常處理和資源管理支持的庫),因此您實際上要僅為了處理字符串而引入一個重要的庫。Safestr 是在開放源代碼的 BSD 風格的許可證下發布的。
C++ std::string 針對 C++ 用戶的另一種解決方案是標准的 std::string類,這是一種動態的方法(緩沖區根據需要而增長)。它幾乎是不需要傷腦筋的,因為 C++ 語言直接支持該類,因此不需要做特殊的工作就可使用它,並且其他庫也可能會使用它。就其本身而言,std::string 通常會防止緩沖區溢出,但是如果通過它提取一個普通 C 字符串(比如使用 data() 或 c_str()),那麼上面討論的所有問題都會重新出現。還要記住 data()並不總是返回以 NUL 結尾的字符串。
由於種種歷史原因,許多 C++ 庫和預先存在的程序都創建了它們自己的字符串類。這可能使得 std::string 更難於使用,並且在使用那些庫或修改那些程序時效率很低,因為不同的字符串類型將不得不連續地來回轉換。並非其他所有那些字符串類都會防止緩沖區溢出,並且如果它們對 C 不受保護的 char* 類型執行自動轉換,那麼緩沖區溢出缺陷很容易引入那些類中。
工具 有許多工具可以在緩沖區溢出缺陷導致問題之前幫助檢測它們。 例如,像我的 Flawfinder 和 Viega 的 RATS 這樣的工具能夠搜索源代碼,識別出可能被不正確地使用的函數(基於它們的參數來歸類)。這些工具的一個缺點在於,它們不是完美的 —— 它們會遺漏一些緩沖區溢出缺陷,並且它們會識別出一些實際上不是問題的“問題”。但是使用它們仍然是值得的,因為與手工查找相比,它們將幫助您在短得多的時間內識別出代碼中的潛在問題。
結束語 借助知識、謹慎和工具,C 和 C++ 中的緩沖區溢出缺陷是可以防止的。不過做起來並沒有那麼容易,特別是在 C 中。如果使用 C 和 C++ 來編寫安全的程序,您需要真正理解緩沖區溢出和如何防止它們。
一種替代方法是使用另一種編程語言,因為如今的幾乎其他所有語言都能防止緩沖區溢出。但是使用另一種語言並不會消除所有問題。許多語言依賴 C 庫,並且許多語言還具有關閉該保護特性的機制(為速度而犧牲安全性)。但是即便如此,不管您使用哪種語言,開發人員都可能會犯其他許多錯誤,從而帶來引入缺陷。
不管您做什麼,開發沒有錯誤的程序都是極其困難的,即使最仔細的復查通常也會遺漏其中一些錯誤。 開發安全程序的最重要方法之一是 最小化特權。那意味著程序的各個部分應該具有它們需要的唯一特權,一點也不能多。這樣,即使程序具有缺陷(誰能無過?),也可能會避免將該缺陷轉化為安全事故。但是在實踐中如何做到這點呢?下一篇文章將研究如何實際地最小化 Linux/UNIX 系統中的特權,以便您能防止自己不可避免的錯誤所帶來安全隱患。