安全漏洞是如何造成的:緩沖區(qū)溢出
譯文自1988年莫里斯蠕蟲誕生以來,緩沖區(qū)溢出漏洞就威脅著從Linux到Windows的各類系統(tǒng)環(huán)境。
緩沖區(qū)溢出漏洞長久以來一直是計算機安全領域的一大特例。事實上,世界上首個能夠自我傳播的互聯(lián)網(wǎng)蠕蟲——誕生于1988年的莫里斯蠕蟲——就是通過Unix系統(tǒng)中的守護進程利用緩沖區(qū)溢出實現(xiàn)傳播的。而在二十七年后的今天,緩沖區(qū)溢出仍然在一系列安全隱患當中扮演著關鍵性角色。聲威顯赫的Windows家族就曾在2000年初遭遇過兩次基于緩沖區(qū)溢出的成規(guī)模安全侵襲。而就在今年5月,某款Linux驅動程序中遺留的潛在緩沖區(qū)溢出漏洞更是讓數(shù)百萬臺家庭及小型辦公區(qū)路由設備身陷風險之中。
但頗為諷刺的是,作為一種肆虐多年的安全隱患,緩沖區(qū)溢出漏洞的核心卻只是由一種實踐性結果衍生出的簡單bug。計算機程序會頻繁使用多組讀取自某個文件、網(wǎng)絡甚至是源自鍵盤輸入的數(shù)據(jù)。程序為這些數(shù)據(jù)分配一定量的內(nèi)存塊——也就是緩沖區(qū)——作為存儲資源。而所謂緩沖區(qū)漏洞的產(chǎn)生原理就是,寫入或者讀取自特定緩沖區(qū)的數(shù)據(jù)總量超出了該緩沖區(qū)所能容納量的上限。
事實上,這聽起來像是一種相當愚蠢、毫無技術含量的錯誤。畢竟程序本身很清楚緩沖區(qū)的具體大小,因此我們似乎能夠很輕松地確保程序只向緩沖區(qū)發(fā)送不超出上限的數(shù)據(jù)量。這么想確實沒錯,但緩沖區(qū)溢出仍在不斷出現(xiàn),并始終成為眾多安全攻擊活動的導火線。
為了了解緩沖區(qū)溢出問題的發(fā)生原因——以及為何其影響如此嚴重——我們需要首先談談程序是如何使用內(nèi)存資源以及程序員是如何編寫代碼的。(需要注意的是,我們將以堆棧緩沖區(qū)溢出作為主要著眼對象。雖然這并不是惟一一種溢出問題,但卻擁有著典型性地位以及極高的知名度。)
堆疊起來
緩沖區(qū)溢出只會給原生代碼造成影響——也就是那些直接利用處理器指令集編寫而成的程序,而不會影響到利用Java或者Python等中間開發(fā)機制構建的代碼。不同操作系統(tǒng)有著自己的特殊處理方式,但目前各類常用系統(tǒng)平臺則普遍遵循基本一致的運作模式。要了解這些攻擊是如何出現(xiàn)的,進而著手阻止此類攻擊活動,我們首先要了解內(nèi)存資源的使用機制。
在這方面,最重要的核心概念就是內(nèi)存地址。內(nèi)存當中每個獨立的字節(jié)都擁有一個與之對應的數(shù)值地址。當處理器從主內(nèi)存(也就是RAM)中加載或者向其中寫稿數(shù)據(jù)時,它會利用內(nèi)存地址來確定讀取或寫入所指向的位置。系統(tǒng)內(nèi)存并不單純用于承載數(shù)據(jù),它同時也被用于執(zhí)行那些構建軟件的可執(zhí)行代碼。這意味著處于運行中的程序,其每項功能都會擁有對應的地址。
在計算機制發(fā)展的早期階段,處理器與操作系統(tǒng)使用的是物理內(nèi)存地址:每個內(nèi)存地址都會直接與RAM中的特定位置相對應。盡管目前某些現(xiàn)代操作系統(tǒng)仍然會有某些組成部分繼續(xù)使用這類物理內(nèi)存地址,但現(xiàn)在所有操作系統(tǒng)都會在廣義層面采用另一種機制——也就是虛擬內(nèi)存。
在虛擬內(nèi)存機制的幫助下,內(nèi)存地址與RAM中物理位置直接對應的方式被徹底打破。相反,軟件與處理器會利用虛擬內(nèi)存地址保證自身運轉。操作系統(tǒng)與處理器配合起來共同維護著一套虛擬機內(nèi)存地址與物理內(nèi)存地址之間的映射機制。
這種虛擬化方式帶來了一系列非常重要的特性。首先也是最重要的,即“受保護內(nèi)存”。具體而言,每項獨立進程都擁有屬于自己的地址集合。對于一個32位進程而言,這部分對應地址從0開始(作為首個字節(jié))一直到4294967295(在十六進制下表示為0xffff'ffff; 232 - 1)。而對于64位進程,其能夠使用的地址則進一步增加至18446744073709551615(十六進制中的0xffff'ffff'ffff'ffff, 264 - 1)。也就是說,每個進程都擁有自己的地址0,自己的地址1、地址2并以此類推。
(在文章的后續(xù)部分,除非另行強調(diào),否則我將主要針對32位系統(tǒng)進行講解。其實32位與64位系統(tǒng)的工作機理是完全相同的,因此單獨著眼于前者不會造成任何影響,這只是為了盡量讓大家將注意力集中在單一對象身上。)
由于每個進程都擁有自己的一套地址,而這種規(guī)劃就以一種非常簡單的方式防止了不同進程之間相互干擾:一個進程所能使用的全部參考內(nèi)存地址都將直接歸屬于該進程。在這種情況下,進程也能夠更輕松地完成對物理內(nèi)存地址的管理。值得一提的是,雖然物理內(nèi)存地址幾乎遵循同樣的工作原理(即以0為起始字節(jié)),但實際使用中可能帶來某些問題。舉例來說,物理內(nèi)存地址通常是非連續(xù)的;地址0x1ff8'0000被用于處理器的系統(tǒng)管理模式,而另有一小部分物理內(nèi)存地址會作為保留而無法被普通軟件所使用。除此之外,由PCIe卡提供的內(nèi)存資源一般也要占用一部分地址空間。而在虛擬地址機制中,這些限制都將不復存在。
那么進程會在自己對應的地址空間中藏進什么小秘密呢?總體來講,大致有四種覺類別,我們會著重討論其中三種。這惟一一種不值得探討的也就是大多數(shù)操作系統(tǒng)所必不可少的“操作系統(tǒng)內(nèi)核”。出于性能方面的考量,內(nèi)存地址空間通常會被拆分為兩半,其中下半部分為程序所使用、上半部分由作為系統(tǒng)內(nèi)核的專用地址空間。內(nèi)核所占用的這一半內(nèi)存無法訪問程序那一半的內(nèi)容,但內(nèi)核自身卻可以讀取程序內(nèi)存,這也正是數(shù)據(jù)向內(nèi)核功能傳輸?shù)膶崿F(xiàn)原理。
我們首先需要關注的就是構建程序的各類可執(zhí)行代碼與庫。主可執(zhí)行代碼及其全部配套庫都會被載入到對應進程的地址空間當中,而且所有組成部分都擁有自己的對應內(nèi)存地址。
其次就是程序用于存儲自身數(shù)據(jù)的內(nèi)存,這部分內(nèi)存資源通常被稱為heap、也就是內(nèi)存堆。舉例來說,內(nèi)存堆可以用于存儲當前正在編輯的文檔、瀏覽的網(wǎng)頁(包括其中的全部JavaScript對象、CSS等等)或者當前游戲的地圖資源等等。
第三也是最重要的一項概念即call stack,即調(diào)用堆——也簡稱為棧。內(nèi)存??梢哉f是最復雜的相關概念了。進程中的每個分線程都擁有自己的內(nèi)存棧。棧其實就是一個內(nèi)存塊,用于追蹤某個線程當前正在運行的函數(shù)以及所有前趨函數(shù)——所謂前趨函數(shù),是指那些當前函數(shù)需要調(diào)用的其它函數(shù)。舉例來說,如果函數(shù)a調(diào)用函數(shù)b,而函數(shù)b又調(diào)用函數(shù)c,那么棧內(nèi)所包含的信息則依次為a、b和c。
在這里我們可以看到棧的基本布局,首先是名為name的64字符緩沖區(qū),接下來依次為幀指針以及返回地址。esp擁有此內(nèi)存棧的上半部分地址,ebp則擁有內(nèi)存棧的下半部分地址。
調(diào)用堆棧屬于通用型“棧”數(shù)據(jù)結構的一個特殊版本。棧是一種用于存儲對象且大小可變的結構。新對象能夠被加入到(即’push‘)該棧的一端(一般為對應內(nèi)存棧的’top‘端,即頂端),也可從棧中進行移除(即’pop’)。只有內(nèi)存棧頂端的部分能夠通過push或者pop進行修改,因此棧會強制執(zhí)行一種排序機制:最近添加進入的項目也會被首先移除。而首個添加進入的項目則會被最后移除。
調(diào)用堆棧最為重要的任務就是存儲返回地址。在大多數(shù)情況下,當一款程序調(diào)用某項函數(shù)時,該函數(shù)會按照既定設計發(fā)生作用(包括調(diào)用其它函數(shù)),并隨后返回至調(diào)用它的函數(shù)處。為了能夠切實返回至正確的調(diào)用函數(shù),必須存在一套記錄系統(tǒng)來注明進行調(diào)用的源函數(shù):即應當在函數(shù)調(diào)用指令執(zhí)行之后從指令中恢復回來。這條指令所對應的地址就被稱為返回地址。棧用于維護這些返回地址,就是說每當有函數(shù)被調(diào)用時,返回地址都會被push到其內(nèi)存棧當中。而在函數(shù)返回之后,對應返回地址則從內(nèi)存棧中被移除,處理器隨后開始在該地址上執(zhí)行指令。
棧的功能非常重要,甚至可以說是整個流程的核心所在,而處理器也會以內(nèi)置方式支持這些處理概念。以x86處理器為例,在x86所定義的各個寄存器當中(所謂寄存器,是指處理器內(nèi)的小型存儲位置,其能夠直接由處理器指令進行訪問),最為重要的兩類就是eip(即指令指針)以及esp(即棧指針)。
esp始終容納有棧頂端的對應地址。每一次有數(shù)據(jù)被添加到該棧中時,esp中的值都會降低。而每當有數(shù)據(jù)從棧中被移除時,esp的值則相應增加。這意味著該棧的值出現(xiàn)“下降”時,則代表有更多數(shù)據(jù)被添加到了該棧當中,而esp中的存儲地址則會不斷向下方移動。不過盡管如此,esp所使用的參考內(nèi)存位置仍然被稱為該內(nèi)存棧的“頂端”。
eip 為現(xiàn)有執(zhí)行指令提供內(nèi)存地址,而處理器則負責維護eip本身的正常運作。處理器會從內(nèi)存當中根據(jù)eip增量讀取指令流,從而保證始終能夠獲得正確的指令地址。x86擁有一項用于函數(shù)調(diào)用的指令,名為call,另一項用于從函數(shù)處返回的指令則名為ret。
call 會獲取一個操作數(shù),也就是欲調(diào)用函數(shù)的地址(當然,我們也可以利用其它方式來獲取欲調(diào)用函數(shù)的地址)。當執(zhí)行call指令時,棧指針esp會通過4個字節(jié)(32位)來表現(xiàn),而緊隨call之后的指令地址——也就是返回地址——則會被寫入至當前esp的參考內(nèi)存位置。換句話說,返回地址會被添加至內(nèi)存棧中。接下來,eip會將該地址指定為call的操作數(shù),并以該地址為起始位置進行后續(xù)操作。
ret 的作用則完全相反。簡單的ret指令不會獲取任何操作數(shù)。處理器首先從esp當中的內(nèi)存地址處讀取值,而后對esp進行4字節(jié)的數(shù)值增量——這意味著其將返回地址從內(nèi)存棧中移除出去。這時eip接受值設定,并以此為起始位置進行后續(xù)操作。
【視頻】
在實際操作中了解call與ret。
如果調(diào)用堆棧當中只包含一組返回地址序列,那么問題當然就很簡單了。但真正的難點在于,其它數(shù)據(jù)也會被添加到該內(nèi)存棧當中。內(nèi)存棧的自身定位就是速度快且效率高的數(shù)據(jù)存儲位置。存儲在內(nèi)存堆上的數(shù)據(jù)相對比較復雜;程序需要全程追蹤內(nèi)存堆內(nèi)的當前可用空間、當前所使用數(shù)據(jù)片段各自占用多大空間外加其它一系列需要關注的指標。不過內(nèi)存棧本身則非常簡單;要為某些數(shù)據(jù)騰出空間,只需要降低棧指針即可。而在數(shù)據(jù)不需要繼續(xù)駐留在內(nèi)存中時,則增加棧指針。
這種便捷性讓內(nèi)存棧成為一套邏輯空間,能夠存儲歸屬于函數(shù)的各類變量。每項函數(shù)擁有256字節(jié)的緩沖空間來讀取用戶的輸入內(nèi)容。簡單來講,我們只需要在棧指針中減去256這一數(shù)值就能創(chuàng)建出該緩沖區(qū)。而在函數(shù)執(zhí)行結束時,向棧指針內(nèi)添加添加256就能丟棄這個緩沖區(qū)。
當我們正確使用程序時,鍵盤輸入內(nèi)容會被存儲至name緩沖區(qū)中,隨后為null(即0)字節(jié)。幀指針與返回地址則保持不變。
但這種處理方式也存在局限。內(nèi)存棧并不適合保存規(guī)模龐大的對象;內(nèi)存的整體可用容量通常在線程創(chuàng)建之時就被確定下來了,而且通常大小為1 MB。因此,那些大型對象必須被保存在內(nèi)存堆中。棧也不適合保存那些需要長久存在,甚至生命周期比單一函數(shù)調(diào)用更長的對象。由于每個分配的內(nèi)存棧都會在函數(shù)執(zhí)行完成后被撤銷,因此任何存在于該棧中的對象將無法在函數(shù)結束后繼續(xù)駐留。不過存在于內(nèi)存堆中的對象則不受此類限制,它們能夠獨立于函數(shù)之外實現(xiàn)長期駐留。
內(nèi)存棧存儲機制并不只適用于程序員在程序中明確創(chuàng)建的命名變量,同時亦可用于存儲其它任何程序可能需要的數(shù)值。從傳統(tǒng)上講,這算是x86架構的一大問題。X86處理器并不能提供太多寄存器(寄存器的總體數(shù)量只有8個,而且其中一部分,例如eip與esp,還需要留作特定用途),因此函數(shù)幾乎無法在寄存器中長期保留所有數(shù)值。為了在不影響現(xiàn)有數(shù)值以供今后檢索的同時釋放寄存器空間,編譯器會將寄存器中的數(shù)值添加到內(nèi)存棧當中。在此之后,相關數(shù)值可以pop方式從棧內(nèi)轉移回寄存器。用編譯器的術語來講,這種節(jié)約寄存器空間并保證數(shù)值可重復使用的操作被稱為spilling。
最后,內(nèi)存棧通常被用于向函數(shù)傳遞參數(shù)。調(diào)用函數(shù)會將每個參數(shù)添加到內(nèi)存棧中,而受調(diào)用函數(shù)之后則能夠將這些參數(shù)移除出去。這并不是惟一一種參數(shù)傳遞方式——舉例來說,也可以在寄存器內(nèi)部進行參數(shù)傳遞——但卻是最為靈活的方式。
函數(shù)在內(nèi)存棧上的所有具體內(nèi)容——包括其本地變量、spilling寄存器操作以及任何準備傳遞給其它函數(shù)的參數(shù)——被整體稱為一個“棧幀”。由于棧幀中的數(shù)據(jù)會被廣泛使用,因此需要一種能夠實現(xiàn)快速引用的辦法與之配合。
棧指針也能完成這項任務,但它的實現(xiàn)方式有些尷尬:棧指針總會指向內(nèi)存棧的頂端,因此它需要在添加與移除的數(shù)據(jù)之間來回移動。舉例來說,某個變量可能以esp + 4地址作為起始位置,而在有另外兩個數(shù)值被添加到棧中時,就意味著該變量現(xiàn)在的訪問位置變成了esp + 12。而一旦某個數(shù)值被移除出去,那么該變量的位置又變成了esp + 8。
這倒不是什么無法克服的障礙,編譯器本身能夠很輕松地加以解決。不過這仍然無法真正回避棧指針以“內(nèi)存棧頂端”作為起始位置的問題,特別是在手工編碼的匯編程序當中。
為了簡化實現(xiàn)流程,最常見的辦法就是使用一個次級指針——其需要始終將數(shù)據(jù)保存在每個棧幀的底部(起始)位置——我們往往將該值稱為幀指針。在x86架構中,甚至還有名為ebp的專門寄存器用于存儲這一值。由于這種機制不會對特定函數(shù)造成任何內(nèi)部變更,因此我們可以利用它作為訪問函數(shù)變量的一種固定方式:位于ebp – 4位置的值在整個函數(shù)中始終保持自己的ebp – 4位置。這種效果不僅有助于程序員理解,同時也能夠顯著簡化調(diào)試程序的處理流程。
以上截圖來自Visual Studio,其中顯示了某簡單x86程序完成上述操作的過程。在x86處理器當中,名為esp的寄存器負責容納頂端內(nèi)存棧中的地址——在本示例中為0x0018ff00,以藍色高亮表示(在x86架構中,內(nèi)存棧實際上會不斷向下推進并指向地址0,但其仍然會以棧頂端為起點進行地址調(diào)用)。該函數(shù)只擁有一個棧變量,即name,以粉色高亮表示。其緩沖區(qū)大小固定為32字節(jié)。由于屬于惟一一個變量,因此其位置同樣為0x0018ff00,與該內(nèi)存棧的頂端保持一致。
x86還擁有一個名為ebp的寄存器,以紅色高亮表示,其通常專門用于保存幀指針的位置。幀指針的位置緊隨棧變量之后。幀指針之后則為返回地址,以綠色高亮表示。返回地址所引用的代碼片段地址為0x00401048。在這條指令之后的是call指令,很明顯返回地址會從調(diào)用函數(shù)剩余的地址位置處執(zhí)行恢復。
遺憾的是,gets()實在是個極其愚蠢的函數(shù)。如果我們按住鍵盤上的A鍵,那么該函數(shù)會不間斷地一直向name緩沖區(qū)內(nèi)寫入“A”。在此過程中,該函數(shù)一直向內(nèi)存中寫入數(shù)據(jù),覆蓋幀指針、返回地址以及其它一切能夠被覆蓋的內(nèi)容。
在以上截圖當中,name屬于會定期被覆蓋的緩沖區(qū)類型。其大小固定為64字符。在這里的示例中,它被填寫進一大堆數(shù)字,并最終以null結尾。從上圖中可以清楚地看到,如果name緩沖區(qū)的寫入內(nèi)容超出了64字節(jié),那么該內(nèi)存棧中的其它數(shù)值也會受到影響。如果有額外的4字節(jié)內(nèi)容被寫入,那么該幀指針就會被破壞。而如果寫入的內(nèi)容為額外8個字節(jié),那么幀指針與返回地址將雙雙被覆蓋。
很明顯,這會導致程序數(shù)據(jù)遭到破壞,但緩存區(qū)溢出還會造成其它更加嚴重的后果:通常會影響到代碼執(zhí)行。之所以會出現(xiàn)這種情況,是因為緩沖區(qū)溢出不僅會覆蓋數(shù)據(jù),同時也可能覆蓋內(nèi)存棧中的返回地址乃至其它更為重要的內(nèi)容。返回地址負責控制處理器在完成當前函數(shù)之后,接下來執(zhí)行哪些指令。返回地址正常來說應該處于調(diào)用函數(shù)之內(nèi)的某個位置,但如果由于緩沖區(qū)溢出而被覆蓋,那么返回地址的指向位置將變得隨機而不可控制。如果攻擊者能夠利用這種緩沖區(qū)溢出手段,則能夠選定處理器接下來要執(zhí)行的代碼位置。
在這一過程中,攻擊者可能并沒有什么理想的、便捷的“設備入侵”方法可供選擇,但這并不會影響惡意活動的發(fā)生。用于覆蓋返回地址的緩沖區(qū)同時也可以被用于保存一小段可執(zhí)行代碼,也就是所謂shellcode,其隨后將能夠下載一段惡意可執(zhí)行代碼、開啟某個網(wǎng)絡連接或者是實現(xiàn)其它一些攻擊手段。
從傳統(tǒng)角度講,這確實是種令人有些意外的、小處引發(fā)的大問題:總體而言,每款程序在每次運行時都會使用同樣的內(nèi)存地址——即使在經(jīng)過重啟之后也不例外。這意味著內(nèi)存棧上的緩沖區(qū)位置將永遠不會變化,所以用于覆蓋返回地區(qū)的值也可以不斷重復加以使用。攻擊者只需要一次性找出對應地址,就能夠在任何運行著存在漏洞的代碼的計算機上再度實施攻擊。#p#
攻擊者的工具箱
在理想狀態(tài)下——當然,這是從攻擊者的角度出發(fā)的——被覆蓋的返回地址可以就是緩沖區(qū)的所在位置。當程序從文件或者網(wǎng)絡處讀取輸入數(shù)據(jù)時,往往就會符合這一條件。
不過在其它情況下,攻擊者則需要動用一點小技巧。在負責處理我們能夠直接閱讀的文本內(nèi)容的函數(shù)中,0字節(jié)(或者稱為‘null’)通常會被特殊處理;它表示一條字符串的結尾,而用于操作這些字符串的函數(shù)——包括復制、比較以及整合等——將會在接觸到null字符后直接中止。這意味著如果該shellcode中包含有null字符,那么執(zhí)行程度到這里一定會停止。
【視頻】
查看整個緩沖區(qū)溢出過程。在這段視頻中,我們將shellcode添加到了緩沖區(qū)內(nèi),而后通過執(zhí)行以棋牌室返回地址。我們的shellcode運行了Windows計算器程序。
為了利用這種溢出手段而非單純向內(nèi)存棧中寫入大量“A”以破壞一切內(nèi)容,攻擊者需要在緩沖區(qū)中添加shellcode:這是一小段可執(zhí)行代碼,其能夠執(zhí)行攻擊者所選定的一系列操作。在此之后,返回地址會被緩沖區(qū)所引用的地址所覆蓋,進而在從某函數(shù)調(diào)用返回后將處理器定向至shellcode執(zhí)行位置。
為了實際這一目標,攻擊者可以選擇多種技術手段。代碼片段可以將包含有null字符的shellcode轉換為具備同等作用的形式以避免出現(xiàn)問題。它們甚至能夠處理更為嚴格的限制,例如一條已被篡改的函數(shù)可能只接收能夠通過標準鍵盤進行輸入的結果。
內(nèi)存棧本身的地址中通常也包含有null字節(jié),這同樣會引發(fā)問題:這意味著返回地址無法直接被設定為棧緩沖區(qū)的地址。一般來講這倒不是什么大問題,畢竟那些可用于填寫緩沖區(qū)的函數(shù)(當然,也會造成潛在的溢出隱患)會自行寫入一個null字節(jié)。但在某些情況下,它們則可被用于將null字節(jié)添加到正確的位置當中,從而篡改內(nèi)存棧中的返回地址。
即使無法進行返回地址篡改,這種狀況也可被攻擊者們用于重新定向。程序及其全部相關庫的存在意味著,內(nèi)存當中可以駐留可執(zhí)行代碼。大部分此類可執(zhí)行代碼都能夠擁有屬于自己的“安全”地址,也就是說其中不包含任何null字節(jié)。
攻擊者們要做的就是找到一個包含一條指令的可用地址,例如x86架構中的call esp,其會將棧指針的值作為函數(shù)地址看待并加以執(zhí)行——這顯然非常適合用來承載shellcode。攻擊者隨后會利用callesp指令的地址來覆蓋返回地址;如此一來,處理器會在該地址處進行一次額外的跳轉,但最終仍會運行該shellcode。這項利用其它地址強行實現(xiàn)代碼執(zhí)行的方法被稱為“trampolining”,也就是蹦床。
有時候我們很難利用緩沖區(qū)地址來覆蓋返回地址。為了解決這個問題,我們可以利用目標程序(或者其對應庫)中的特定可執(zhí)行代碼片段地址來覆蓋返回地址。這部分代碼片段能幫助我們對緩沖區(qū)位置進行轉換。
之所以這種方式能夠奏效,是因為正如前面提提到,程序及其配套庫在每時運行時都會使用同樣的內(nèi)存地址——即使是多次啟動甚至在不同設備之上都不會改變這一點。而非常有趣的是,用于提供“蹦床”的庫本身并不需要執(zhí)行call esp指令。該庫只需要提供兩個字節(jié)(在本次示例中為0xff與0xd4)并保證彼此相鄰即可。它們可以作為其它指令中的組成部分甚至直接以數(shù)字形式存在;x86對于這類內(nèi)容并不挑剔。另外,x86的指令長度可以相當之長(最高為15字節(jié)!)并指向任意地址。如果處理器從中間部分讀取某條指令——例如在一條長度為4字節(jié)的指令中從第二個字節(jié)開始讀取——那么最終的執(zhí)行結果可能會完全不同、但卻仍然切實生效。考慮到這一點,攻擊者確實可以很輕松地找到可資利用的“蹦床”。
不過有時候,攻擊活動無法直接將返回地址篡改為所需位置。不過由于內(nèi)存布局總是非常相似,不同設備或者不同運行進程之間的設定幾乎完全相同。舉例來說,某個可利用的緩沖區(qū)的具體位置可能會出現(xiàn)變化,而存在差異的幾個字節(jié)則取決于系統(tǒng)名稱或者IP地址。另外,軟件的小型更新可能也會讓內(nèi)存地址出現(xiàn)稍許變動。為了解決這一問題,攻擊者只需要找到返回地址的大概正確位置即可,而不必保證其完全符合實際情況。
面對這類狀況,攻擊者的處理辦法也很簡單,這就是使用所謂“NOP sled”技術。相較于直接向緩沖區(qū)內(nèi)寫入shellcode,攻擊者可以在真正的shellcode之前編寫數(shù)量龐大的多條“NOP”指令(所謂NOP也就是’no-op‘,是指那些不會真正執(zhí)行的指令),有時候可以多達數(shù)百條。要運行該shellcode,攻擊者只需要將返回地址設定在這些NOP指令當中的某個位置即可。只要該地址被包含在NOP當中,處理器就會快速將其略過并直接執(zhí)行真正的shellcode。
你的錯、他的錯——都是C的錯
導致上述攻擊得以實現(xiàn)的核心bug——具體來講,就是向緩沖區(qū)內(nèi)寫入超出其容納能力的內(nèi)容——聽起來可以很輕松地加以避免。將這些問題完全歸咎于C編程語言及其各類兼容性分支方案——例如C++以及Objective-C——或許有些夸張,但也不能說毫無道理。C語言本身已經(jīng)相當陳舊,但卻應用廣泛且作為我們操作系統(tǒng)以及各類軟件的基礎性元素存在。正是由于C語言的流行,才讓這些本來可以輕松避免的bug長期生效并影響到無數(shù)開發(fā)者與用戶。
作為C語言自身阻礙安全開發(fā)實踐的一項實例,我們在這里要著重談談gets()。作為一項函數(shù),gets()會獲取一條參數(shù)——也就是一個緩沖區(qū)——并從標準輸入內(nèi)容中(通常意味著’鍵盤輸入內(nèi)容‘)讀取一行數(shù)據(jù),而后將其添加到緩沖區(qū)當中。細心的朋友可能已經(jīng)注意到,gets()當中并不會對將被添加至緩沖區(qū)內(nèi)的參數(shù)長度作出限制,而且作為C語言設計中的一種有趣現(xiàn)象,我們沒辦法利用gets()了解緩沖區(qū)的實際大小。這是因為gets()并不會對輸入內(nèi)容的大小作出任何要求:它只負責從標準輸入內(nèi)容中讀取數(shù)據(jù)——直到電腦前的操作者按下回車——而后嘗試將全部內(nèi)容添加到緩沖區(qū)內(nèi),即使操作者寫入的內(nèi)容遠遠超出了緩沖區(qū)容納能力,gets()也完全不予理會。
很明顯,這項函數(shù)屬于徹頭徹尾的安全隱患。由于我們無法制約通過鍵盤輸入的文本內(nèi)容總量,因此也就不可能避免由gets()引發(fā)的緩沖區(qū)溢出結果。C語言標準的制定者們確實意識到了這個問題,并在1999年的再版C語言規(guī)范中對gets()加以棄用,最終在2011年的更新中將其完全移除。但它的存在——以及不時出現(xiàn)的實際使用——證明了C語言確實給用戶們挖了一個非常危險的潛在陷阱。
而作為誕生于1988年的世界首個可通過互聯(lián)網(wǎng)傳輸?shù)淖晕覐椭茞阂廛浖?,莫里斯蠕蟲利用的恰恰是這項函數(shù)。BSD 4.3 fingerd程序會通過端口79對網(wǎng)絡連接進行監(jiān)聽,也就是我們常說的finger端口。事實上,finger也是一個非常古老的Unix程序,其作為網(wǎng)絡協(xié)議存在并負責識別是誰登錄到了遠程系統(tǒng)當中。它的使用方式分為兩種;其一是遠程系統(tǒng)可以利用它來查詢當前已經(jīng)登錄的每位用戶,其二則是用于查詢特定用戶名并告知我們與該用戶相關的部分信息。
每當有連接出現(xiàn)在finger的后臺進程當中,它都會利用gets()從網(wǎng)絡中讀取數(shù)據(jù)并將其添加到內(nèi)存棧中一個512字節(jié)的緩沖區(qū)內(nèi)。在通常操作中,fingerd會隨后生成finger程序,并在可能的情況下向其傳遞相關用戶名。該finger程序才是真正負責監(jiān)聽用戶接入或者提供與特定用戶相關信息的主體,而fingerd本身僅僅負責監(jiān)聽網(wǎng)絡并在需要時啟動finger。
鑒于惟一的“真實”參數(shù)基本只會是用戶名,因此512字節(jié)的緩沖區(qū)設定已經(jīng)不算小了。應該沒人會設定一個長達512位的用戶名——不過系統(tǒng)本身并不會對此作出強制要求,因為在這里負責內(nèi)容獲取工作的正是臭名昭著的gets()函數(shù)。當我們通過網(wǎng)絡發(fā)出超過512字節(jié)的用戶名時,fingerd就會乖乖地造成緩沖區(qū)溢出狀況。而這也正是Robert Morris的具體作法:他向fingerd發(fā)送了537字節(jié)的數(shù)據(jù)內(nèi)容(其中包含537個字節(jié)外中一個換行符,這直接導致gets()停止讀取輸入數(shù)據(jù)),順利實現(xiàn)緩沖區(qū)溢出并覆蓋了返回地址。在此之后,返回地址被輕松設置為內(nèi)存棧中的緩沖區(qū)地址。
莫里斯蠕蟲的可執(zhí)行負載非常簡單。它會發(fā)起400條NOP指令,從而讓內(nèi)存棧布局出現(xiàn)輕微的變化,而后再接上一小段代碼片段。這些代碼會生成一條shell,即/bin/sh。這是攻擊負載當中很常見的選擇;fingerd程序會以root權限運行,因此在遭到攻擊并被迫運行shell時,該shell也將擁有root權限。另外,fingerd會被引導至網(wǎng)絡當中,這意味著其接收的“鍵盤輸入內(nèi)容”可以實際來源于網(wǎng)絡傳輸,并將輸出結果通過網(wǎng)絡發(fā)送出去。這兩大特性都明顯昭示其為shell所利用的潛在可能性,也就是說這一root shell現(xiàn)在已經(jīng)能夠為攻擊者所遠程操控。
盡管想要繞開gets()并不困難——事實上即使是在莫里斯蠕蟲剛剛誕生的時候,就出現(xiàn)了能夠徹底禁用gets()的fingerd修復版本——但C語言的其它一些組成部分仍然難以被忽略,甚至幾乎不可能被徹底修復。C語言對于文本字符的處理方式就是一種常見的問題根源。正如之前所提到,C語言在處理字符串時會在讀取至null字節(jié)后中止。在C語言中,一條字符串就是一段字符序列,其末尾以null字節(jié)作為字符串中止標記。C語言當中有一系列函數(shù)負責操作這些字符串。其中最典型的例子要數(shù)strcpy()——負責從來源處將一條字符串復制至目標位置——以及strcat()——負責從來源處將一條字符串添加至目標位置——這對奇葩了。這兩項函數(shù)都沒有對指向目標緩沖區(qū)的參數(shù)作出長度限制,因此添加之后會不會造成緩沖區(qū)溢出根本就不在這二者的考量范圍之內(nèi)。
即使C語言的字符串處理函數(shù)能夠對指向緩沖區(qū)的參數(shù)長度作出限制,同樣的錯誤及溢出狀況仍然得不到徹底解決。C語言分別為strcat()與strcpy()提供一對姐妹函數(shù),分別名為strncat()與strncpy()。名稱當中額外的n代表的正是其所獲取參數(shù)的長度。但正如很多資深C語言程序員們所知,這個n并不是將要寫入的緩沖區(qū)的具體大小;相反,它其實是來源處將要進行復制的字符數(shù)量。如果來源提供的數(shù)據(jù)量超出了對應字符限制(因為達到了null字節(jié)的位置),那么strncpy()與strncat()將會通過向目標位置復制更多null字節(jié)的方式來補足差額。換句話來說,這些函數(shù)仍然完全不關心目標緩沖區(qū)的實際大小。
與gets()不同,我們其實有能力以安全方式使用以上函數(shù),只不過有點困難罷了。C++與Objective-C都針對C語言的函數(shù)庫提供更理想的替代方案,這使得我們能夠更輕松且更安全地實現(xiàn)字符串操作——不過由于向下兼容的考量,某些C語言中的陳舊特性仍然被繼承了下來。
除此之外,二者還包含了C語言的一大根本性缺陷:緩沖區(qū)自身并不了解自己的確切大小,而且C語言也根本不會驗證緩沖區(qū)之上所執(zhí)行的讀取與寫入操作——這就使得緩沖區(qū)溢出成為了可能。正是同樣的機制導致OpenSSL當中曝出了Hearbleed漏洞,但值得強調(diào)的是,它并不算是溢出、而屬于讀取越界。OpenSSL當中的C代碼會嘗試讀取超出緩沖區(qū)容納能力的內(nèi)容,并最終導致敏感信息泄露至外部環(huán)境。#p#
修復此類漏洞
無需贅言,隨著人類智慧的進一步發(fā)展,我們?nèi)缃褚呀?jīng)擁有了更多更出色的語言選項——它們會對指向緩沖區(qū)的讀取與寫入操作進行驗證,這就徹底阻斷了溢出問題的發(fā)生。由Mozilla打造的Rust等編譯語言、安全運行時環(huán)境的杰出代表Java以及.NET,外加Python、JavaScript、Lua以及Perl等虛擬化腳本語言都徹底解決了緩沖區(qū)溢出的問題(當然,.NET仍然允許開發(fā)人員直接關閉所有保障措施,在這種選項設置之下緩沖區(qū)溢出會再度成為可能)。
緩沖區(qū)溢出目前仍然作為安全領域的一大關注重點存在,同時也是C語言持久生命力的有效證明。任何存在這一問題的遺留代碼都有可能引發(fā)重大的安全事故。但目前世界上仍在運行的C代碼依舊數(shù)不勝數(shù),其中包括眾多主流操作系統(tǒng)的內(nèi)核以及OpenSSL等高人氣代碼庫。即使開發(fā)人員傾向于使用C#這樣安全性更出色的語言,他們也仍然需要使用大量由C語言編寫而成的第三方庫。
性能水平則是C語言繼續(xù)被廣泛使用的另一大理由,雖然關于這方面的具體判斷方式仍然比較模糊。確實,經(jīng)過編譯的C與C++代碼能夠帶來更理想的執(zhí)行速度表現(xiàn),而且在某些情況下起到了無可替代的重要作用。然而目前大多數(shù)用戶所使用的處理器在絕大部分情況下都處于資源閑置的狀態(tài);如果我們能夠犧牲百分之十的總體性能來讓自己的瀏覽器獲得更為堅實的安全保障,包括緩沖區(qū)溢出以及其它眾多潛在安全隱患,那么相信大家絕對會選擇這種方式。只要有廠商愿意開發(fā)出這樣值得依賴的瀏覽器,我們就能夠根據(jù)自己的實際需要作出權衡。
盡管如此,C語言和它的整個大家族卻仍然廣泛存在——當然也包括由其帶來的緩沖區(qū)溢出風險。
目前已經(jīng)有不少相關舉措努力阻止溢出錯誤影響到開發(fā)人員以及使用者。在開發(fā)過程中,我們可以選擇多種工具對源代碼進行分析,并通過程序運行來檢測其中是否存在危險結構或者溢出錯誤,這就避免了此類bug被實際添加到軟件成品當中。AddressSantizer等新型工具以及Valgrind等傳統(tǒng)方案都可以實現(xiàn)上述功能。
然而,這些工具需要開發(fā)人員的積極采用方能奏效,否則就是一堆毫無意義的0和1——也就是說仍有相當多的程序并沒有將其納入開發(fā)流程。另有一些系統(tǒng)層面的保護手段,能夠在緩沖區(qū)溢出問題真正發(fā)生之后盡可能保證其它軟件免受其侵害。在這方面,操作系統(tǒng)以及編譯器開發(fā)者們已經(jīng)采取了一系列方案,旨在提高攻擊者使用這些溢出漏洞的難度。
某些系統(tǒng)的存在目的正是讓一部分特定攻擊活動變得更難實現(xiàn)。當前的多套Linux系統(tǒng)補丁就能夠確保系統(tǒng)庫全部被加載在底端內(nèi)存地址處,從而保證其地址中至少包含一個null字節(jié)。在這種情況下,攻擊者將很難利用C字符串處理方式在緩沖區(qū)溢出攻擊中使用這些地址。
其它防御機制也更為普遍。目前很多編譯器都擁有某種類型的內(nèi)存棧保護機制,其會將一個名為“canary”(意為金絲雀)的運行時檢測值寫入到返回地址存儲位置附近的內(nèi)存棧末尾。在每項函數(shù)執(zhí)行結束之前,系統(tǒng)都會檢查該值以確定返回指令是否遭到了修改。如果該canary值發(fā)生了變化(因為其在緩沖區(qū)溢出中被覆蓋),那么該程序將立即崩潰而非繼續(xù)執(zhí)行。
而最重要的單項保護手段之一正是名為W^X(意為’單純寫入或執(zhí)行‘)、DEP(意為’數(shù)據(jù)執(zhí)行保護‘)、NX(意為‘不執(zhí)行’)、XD(意為‘執(zhí)行禁用’)、EVP(意為‘增強病毒保護’,AMD公司往往比較喜歡使用這一術語)、XN(即‘從不執(zhí)行’)等一系列措施。它們所采取的概念非常簡單。這些系統(tǒng)會盡可能讓內(nèi)存擁有可寫入能力(適用于緩沖區(qū))或者可執(zhí)行能力(適用于庫及程序代碼),但不會使其二者兼?zhèn)?。因此,即使攻擊者能夠使緩沖區(qū)出現(xiàn)溢出并控制其中的返回地址,處理器最終仍然會拒絕執(zhí)行對應的shellcode。
無論具體使用什么樣的名稱,這都是一項重要的技術,這主要是因為其能夠在無需額外成本的前提下起效——這類方案使用的是處理器自身內(nèi)置的、作為虛擬內(nèi)存硬件支持而存在的保護機制。
正如之前所提到,在虛擬內(nèi)存當中每個進程都擁有屬于自己的內(nèi)存地址。操作系統(tǒng)與處理器會共同保持一套映射機制,從而令虛擬地址指向其它位置;有時候一個虛擬地址可能會對應一個物理內(nèi)存地址,但有時候其會對應磁盤上某個文件的一部分,有時候甚至會因為尚未分配而不對應任何對象。這是映射機制是高度細化的,通常以4096字節(jié)為一個區(qū)塊——也就是我們所說的page單位。
用于存儲這一映射的數(shù)據(jù)結構不僅包含有每個page的位置(物理內(nèi)存、磁盤以及無位置),同時(通常)也包含有另外三個用定義page保護的字位:即該page是否可以讀取、其是否可以寫入以及其是否可以執(zhí)行。在這樣的保護之下,進程對應的內(nèi)存區(qū)域能夠被標記為可讀取、可寫入但不可執(zhí)行。相反,程序的可執(zhí)行代碼片段以及庫則會被標記為可讀取、可執(zhí)行但不可寫入。
NX的一大出色之處在于,操作系統(tǒng)通過更新獲得對應的支持能力之后,它就能夠以追溯方式應用于現(xiàn)有程序。某些程序偶爾也會在運行中遇到問題。Java以及.NET當中所使用的即時編譯器就會在運行時環(huán)境下在內(nèi)存中生成可執(zhí)行代碼,這些代碼則要求內(nèi)存同時具備可寫入性與可執(zhí)行性(不過嚴格來講,這些代碼一般不會同時要求這兩種能力)。在NX出現(xiàn)之前,內(nèi)存始終同時具備可讀取性與可執(zhí)行性,因此這些即時編譯器完全無需針對其可讀取/可寫入緩沖區(qū)作出任何調(diào)整。但在NX出現(xiàn)之后,即時編譯器必須要確保將內(nèi)存保護機制從讀取-寫入變更為讀取-執(zhí)行。
市場對于NX這類安全方案的需求非常明確,特別是對于微軟陣營來說。早在2000年初,兩大蠕蟲的相繼出現(xiàn)就證明了微軟公司的系統(tǒng)代碼當中存在著一些嚴重的安全問題:Code Red于2001年7月感染了35萬9千套運行有微軟IIS Web Server的Windows 2000系統(tǒng),而隨后的SQL Slammer則于2003年1月侵入了超過7萬5千套運行有微軟SQL Server數(shù)據(jù)庫的系統(tǒng)。這些都讓軟件巨頭陷入嚴重的被動局面當中。
這兩種蠕蟲利用的都是內(nèi)存棧中的緩沖區(qū)溢出漏洞,而且令人吃驚的是雖然距離莫里斯蠕蟲誕生已經(jīng)分別過去了13年和15年,但它們的開發(fā)方式幾乎完全相同。三者都將惡意負載添加到內(nèi)存棧的緩沖區(qū)內(nèi),并通過覆蓋返回地址的方式加以執(zhí)行。(惟一的區(qū)別在于,這兩位相對年輕的繼任者使用了‘蹦床’技術。相較于當初直接將返回地址設置為內(nèi)存棧地址的方式,這二者將返回地址設置成了一條能夠傳遞至內(nèi)存棧并執(zhí)行的指令。)
當然,這些蠕蟲方案在其它多個方面也算有所發(fā)展。Code Red的負載不僅能夠實現(xiàn)自我復制,同時也會侵入網(wǎng)頁并試圖執(zhí)行拒絕服務攻擊。SQL Slammer則囊括了一切感染其它計算設備并在網(wǎng)絡上進行傳播的功能組件,同時將自身體積控制在數(shù)百字節(jié)水平——這意味著受感染的機器上不會留下明顯的痕跡,而且重新啟動之后這些痕跡就會徹底消失。這兩種蠕蟲也都開始以互聯(lián)網(wǎng)作為著眼重點,這也使它們超越了老祖宗莫里斯蠕蟲、成功感染了更多計算機設備。
不過問題的關鍵在于,這樣一種能夠被直接利用的緩沖區(qū)溢出漏洞已經(jīng)算是古董級別的隱患了。正是由于兩種蠕蟲病毒的相繼出現(xiàn),才使人們對使用Windows接入互聯(lián)網(wǎng)并作為服務器系統(tǒng)產(chǎn)生了質(zhì)疑情緒。面對重重壓力,微軟公司表示將開始認真對待安全問題。Windows XP SP2就是第一款真正讓安全意識融入其中的成品。它對軟件進行了一系列調(diào)整,包括提供軟件防火墻、調(diào)整IE以避免工具欄乃至插件的靜默安裝——當然,也實現(xiàn)了對NX的支持。
在硬件層面支持NX在2004年之后成為主流,當時英特爾公司剛剛推出了其奔騰4處理器。而操作系統(tǒng)對于NX的支持也在Windows XP SP2邁出第一步后成為了業(yè)界共識。Windows 8在這方面表現(xiàn)得更加果斷,干脆不支持未配備NX硬件的陳舊處理器。#p#
后NX時代
隨著NX支持能力的逐步普及,緩沖區(qū)溢出也在當下找到了新的實現(xiàn)途徑——換言之,攻擊者們發(fā)現(xiàn)了一系列能夠有效繞開NX的技術手段。
其中最早的一種與前面提到的“蹦床”機制非常相似,它能夠通過來自其它庫或者可執(zhí)行代碼的指令繞開系統(tǒng)在內(nèi)存棧緩沖區(qū)內(nèi)對shellcode的控制。不同于以往尋找可執(zhí)行代碼片段來直接將shellcode傳遞至內(nèi)存棧當中,攻擊者們?nèi)缃褶D而開始特色確實擁有實際作用的代碼片段。
而其中最理想的選項也許要數(shù)Unix的system()函數(shù)了。這項函數(shù)會獲取一個參數(shù):一條字符串的地址代表著一條將被執(zhí)行的命令行,從傳統(tǒng)角度講該參數(shù)會被傳遞至內(nèi)存棧當中。攻擊者可以創(chuàng)建一條命令行字符串,并將其添加至內(nèi)存棧中以實現(xiàn)溢出效果,而且由于在傳統(tǒng)角度上內(nèi)存中所承載的內(nèi)容不會發(fā)生位置變動,因此該字符串的地址將以已知形式存在、并作為內(nèi)存棧中配合攻擊活動的組成部分。在這種情況下,被覆蓋的返回地址不會再被設置為緩沖區(qū)地址,而是被設置為system()函數(shù)的地址。當造成緩沖區(qū)溢出的函數(shù)執(zhí)行完成后,它不會返回至調(diào)用函數(shù)處,而是運行system()以執(zhí)行攻擊者選定的命令。
這就巧妙地繞過了NX的保護。作為系統(tǒng)庫的組成部分,system()函數(shù)始終處于執(zhí)行狀態(tài)。這種漏洞利用方式并不需要在內(nèi)存棧中執(zhí)行代碼,而只需要從內(nèi)存棧中讀取已有命令行。這項技術被稱為“return-to-libc”(即回庫),最初是由俄羅斯計算機安全專家Solar Designer于1997年發(fā)明的。(libc也就是Unix庫的名稱,其負責實現(xiàn)多種關鍵性函數(shù),包括system()。Unix庫通常會被載入到每個單獨的Unix進程當中,而這也使其成為攻擊活動的首選目標。)
雖然確切有效,但這項技術在某種程度上亦可以被扼制。一般來講,函數(shù)并不會從內(nèi)存線中獲取自己的參數(shù),而傾向于將其傳遞到寄存器當中。在命令行字符串中傳遞參數(shù)以實現(xiàn)執(zhí)行雖然想法不錯,但卻往往會因為其中出現(xiàn)的惱人null字節(jié)而導致運轉停止。另外,這會讓多個函數(shù)同時調(diào)用變得非常困難。雖然并非無法解決——同時提供多個返回地址而非一個——但我們將完全無法變更參數(shù)順序、使用返回值或者實現(xiàn)其它操作。
相較于利用shellcode填寫緩沖區(qū),我們現(xiàn)在選擇利用返回地址與數(shù)據(jù)序列進行填充。這些返回地址會在目標程序及其庫之內(nèi)傳遞對現(xiàn)有可執(zhí)行代碼片段的控制權。每個代碼片段都會執(zhí)行一項操作而后返回,將控制權傳遞給下一個返回地址。
在過去幾年當中,return-to-libc技術被廣泛用于突破現(xiàn)有安全保護措施。2001年末,安全業(yè)界就曾記錄下多種通過擴展return-to-libc執(zhí)行多函數(shù)調(diào)用的方法,并提供了解決null字節(jié)問題的辦法。這些技術并未受到嚴格限制,因此2007年由此衍生出的另一種復雜度更高的攻擊手段開始出現(xiàn)——這種消除了大部分上述限制的方案正是ROP,即“返回導向編程”技術。
其基本設計思路與“回庫”以及“蹦床”差不多,但卻從普適性方面更進了一步。“蹦床”是利用單一代碼片段將可執(zhí)行shellcode添加到緩沖區(qū)當中,而ROP則是利用大量被稱為“gadget”的代碼片段。每個gadget都遵循一種特定模式:它會執(zhí)行某些操作(包括向寄存器中添加一個值、向內(nèi)存中寫入或者添加兩個寄存器等等),而后加上一條返回指令。x86的固有特性讓“蹦床”的設計思路在這邊再度起效;進程當中所加載的系統(tǒng)庫中包含著成百上千個能夠被解釋為“執(zhí)行一項操作,而后返回”的序列,因此它們也成為了實現(xiàn)ROP攻擊的潛在基礎。
這些gadget彼此之間通過一條長返回地址序列(也可以是其它任何有用或者必需的數(shù)據(jù))被串連在一起,并作為緩沖區(qū)溢出的組成部分被寫入至內(nèi)存棧當中。返回指令則很少甚至完全無需借助處理器中的calling函數(shù)——而是單純利用returning函數(shù)——在gadget之間跳轉。值得注意的是,人們發(fā)現(xiàn)可資利用的gadget的數(shù)量與種類如此之多(至少在x86平臺上是如此),攻擊者幾乎能夠利用它們實現(xiàn)任何目標。這一奇特的x86子集在特定使用方式之下往往會呈現(xiàn)出完備的圖靈特性(雖然其具體功能范圍取決于特定程序所加載的庫類型,并以此決定哪些gadget能夠切實起效)。
正如“回庫”技術一樣,所有可執(zhí)行代碼實際上都來源于系統(tǒng)庫,因此NX保護也就無處發(fā)力了。這套方案的出色靈活性意味著,攻擊者能夠甚至能夠實現(xiàn)原本依靠“回庫”技術所難于完成的任務,包括調(diào)用從寄存器內(nèi)獲取參數(shù)的函數(shù)或者將來自某一函數(shù)的返回值作為另一函數(shù)的參數(shù)等等。
ROP負載可謂變化多端。有時候它們只以簡單的“創(chuàng)建一個shell“形式的代碼出現(xiàn),但大多數(shù)情況下攻擊者都會利用ROP來調(diào)用某項系統(tǒng)函數(shù),從而變更某一內(nèi)存page的NX狀態(tài)、將其由可寫入轉變?yōu)榭蓤?zhí)行。通過這種方式,攻擊者將能夠利用便捷的非ROP負載在執(zhí)行過程中實現(xiàn)ROP。
隨機性提升
NX的這一弱點早已為安全專家們所了解,而這同時也成為最大的薄弱環(huán)節(jié)在各類攻擊活動中反復出現(xiàn):攻擊者們在動手之前就已經(jīng)掌握了內(nèi)存棧與系統(tǒng)庫的確切內(nèi)存地址。正因為各類攻擊活動皆以此類知識作為基礎,因此解決安全隱患的最佳途徑就是使這些知識失去效用。有鑒于此,地址空間布局隨機化(簡稱ASLO)技術應運而生:它會對內(nèi)存棧的位置、內(nèi)存內(nèi)庫以及可執(zhí)行代碼的位置進行隨機化處理。一般來講,這些位置在程序每次運行時、系統(tǒng)啟動時或者二者同時發(fā)生時都會出現(xiàn)變化。
這極大地增加了攻擊活動的實施難度,因為幾乎在一夜之間,攻擊者根本不知道哪些ROP指令片段會駐留在內(nèi)存當中、甚至弄不清楚要實現(xiàn)溢出的緩沖區(qū)到底在哪里。
ASLR在多個方面與NX攜手合作,因為它主要負責封殺“回庫”以及“返回導向編程”這兩大NX未能堵住的缺口。然而遺憾的是,它的介入深度有些過度。除了即時編譯器以及少數(shù)其它非常用程序之外,NX幾乎能夠被成功添加到任何現(xiàn)有軟件當中。但ASLR在這方面則問題多多,程序及庫需要確保自身的正常運行不會受到內(nèi)存地址隨機變化的影響。
舉例來說,在Windows當中,DLL就基本不會受到內(nèi)存地址隨機化的影響。DLL在Windows系統(tǒng)上始終支持利用不同內(nèi)存地址加載數(shù)據(jù),但EXE文件就沒這么幸運了。在ASLR出現(xiàn)之前,EXE文件會始終以0x0040000作為起始加載位置,并安全地以此為運行前提。但ASLR出現(xiàn)之后,情況就完全不同了。為了確保不出現(xiàn)差錯,Windows在默認條件下要求EXE可執(zhí)行文件對ASLR提供支持,并提供啟用選項。不過出于安全的考慮,即使對應程序并未明確表達支持能力,Windows仍然默認在所有可執(zhí)行程序及庫中啟用該選項。而且在大多數(shù)情況下,結果還是令人滿意的。
比較糟心的情況出現(xiàn)在x86 Linux系統(tǒng)當中。在使用ASLR的情況下,Linux平臺的性能損失可能高達26%。除此之外,這套方案明確要求可執(zhí)行程序與庫以ASLR支持模式進行編譯。這意味著管理員根本無法像在Windows環(huán)境中那樣對ASLR進行授權。(x64也沒能徹底解決Linux的性能損失問題,不過損失程度得到了顯著降低。)
在ASLR啟用之后,它能夠為系統(tǒng)提供良好的緩沖區(qū)溢出狀況保護。不過ASLR本身還遠遠稱不上完美——舉例來說,其能夠提供的隨機水平就比較有限,而且這種情況在32位系統(tǒng)中表現(xiàn)得尤為嚴重。盡管內(nèi)存空間所能提供的地址數(shù)量高達40億個,但并不是所有地址都能夠被用于加載庫或者旋轉內(nèi)存棧。
相反,其分配方式會受到各種約束,而且其中一部分還屬于泛用性目標??傮w而言,操作系統(tǒng)傾向于將各個庫以相鄰方式進行加載,以保證各個進程的地址空間首尾相連,這樣就能盡可能多地為應用程序運行提供充裕的內(nèi)存容量。大家當然也不希望讓一個庫以256 MB為單位遍布在整個內(nèi)存空間當中——256 MB是我們能夠作為整體進行分配的最大內(nèi)存單位,這種作法會限制應用程序處理大型數(shù)據(jù)庫集的能力。
可執(zhí)行文件和庫通常在啟動后必須進行加載,且至少被包含在一個page當中。通常來講,這意味著它們必須以4096整數(shù)倍的形式進行加載。平臺也可以對內(nèi)存棧采用類似的協(xié)議;舉例來說,Linux會以16字節(jié)的整數(shù)倍形式啟動內(nèi)存棧。迫于內(nèi)存的壓力,系統(tǒng)有時候需要對隨機性進行進一步削減,從而保證一切能夠順利運行。
這種變化看起來影響不大,但卻意味著攻擊者有時候可以猜測到某個地址的可能位置,而且有相當高的機率猜測成功。即使猜對的可能性非常低——例如二百五十六分之一——在某種情況下攻擊者依然足以利用其實施惡意活動。當攻擊某臺會自動重啟崩潰進程的Web服務器時,256次攻擊中有255次出現(xiàn)崩潰完全不是什么大問題。只需要經(jīng)過簡單重啟,攻擊者就能再次嘗試下一個內(nèi)存地址。
不過在64位系統(tǒng)當中,由于地址空間變得更加龐大,單純的猜測就不足以解決問題了。攻擊者面對的很可能是上百萬個——甚至數(shù)十億個——潛在內(nèi)存地址,機率如此之低也就不值得我們?yōu)橹畱n心了。
另外,對于攻擊者來說,猜測與崩潰這段手段不適用于瀏覽器之類的場景;沒有哪個用戶會連續(xù)對瀏覽器進行256次重啟來“幫助”攻擊者完成試探。也就是說,在這種情況下NX與ASLR的聯(lián)手協(xié)作將讓攻擊者們變得無機可乘。
但如果有其它幫助手段存在,情況就不一樣了。在瀏覽器當中的一種常見實現(xiàn)途徑在于利用JavaScript或者Flash——二者都包含著有能力生成可執(zhí)行代碼的即時編譯器——向內(nèi)存中塞進大量經(jīng)過精心構建的可執(zhí)行代碼。由此生成的大型NOP sled也就是我們目前經(jīng)常提到的“heap spraying”(也就是堆噴射)技術。另一種實現(xiàn)方式則是找出某個有可能泄露庫或者棧內(nèi)存地址的次級漏洞,從而幫助攻擊者獲得構建自定義ROP返回地址組所必需的相關信息。
第三種方法在瀏覽器當中比較常見:利用那些不支持ASLR的代碼庫。舉例來說,Adobe PDF插件或者微軟Office瀏覽器插件的某些早期版本就不支持ASLR,而且Windows在默認情況下不會強制在非ASLR代碼中啟用該功能。如果攻擊者能夠強制載入這類庫(舉例來說,通過在隱藏的瀏覽器幀中加載某個PDF文件),那么他們就能夠直接繞過ASLR,即利用這些非ASLR庫容納自己的ROP負載。
一場永遠休止的戰(zhàn)爭
攻擊技術與保護技術之爭就像是貓與老鼠的競逐。像ASLR以及NX這樣強大的保護系統(tǒng)能夠提高安全漏洞的利用門檻,從而在一定時期內(nèi)徹底阻止緩沖區(qū)溢出這類簡單漏洞的肆虐。然而聰明的攻擊者們?nèi)匀荒軌蛘业狡渌踩毕?,并將它們組合起來以繼續(xù)發(fā)動攻勢。
這場軍備競賽仍在不斷升級。微軟公司的EMET(即‘增強緩解體驗工具包’)當中包含一系列半實驗性保護方案,旨在檢測“堆噴射”乃至其它任何以ROP為基礎嘗試利用特定高危函數(shù)的行為。不過在這場永無休止的數(shù)字化對抗中,這些安全技術同樣在持續(xù)遭到淘汰。這并不是說它們沒有作用——各類新型保護技術的出現(xiàn)確實提高了漏洞利用的難度與成本——但大家必須正視一個現(xiàn)實,即警惕之心須長久保持。
英文:How security flaws work: The buffer overflow