自1988年莫里斯蠕蟲誕生以來,緩沖區(qū)溢出漏洞就威脅著從Linux到Windows的各類系統(tǒng)環(huán)境。
緩沖區(qū)溢出漏洞長久以來一直是計算機安全領(lǐng)域的一大特例。事實上,世界上首個能夠自我傳播的互聯(lián)網(wǎng)蠕蟲——誕生于1988年的莫里斯蠕蟲——就是通過Unix系統(tǒng)中的守護進程利用緩沖區(qū)溢出實現(xiàn)傳播的。而在二十七年后的今天,緩沖區(qū)溢出仍然在一系列安全隱患當(dāng)中扮演著關(guān)鍵性角色。聲威顯赫的Windows家族就曾在2000年初遭遇過兩次基于緩沖區(qū)溢出的成規(guī)模安全侵襲。而就在今年5月,某款Linux驅(qū)動程序中遺留的潛在緩沖區(qū)溢出漏洞更是讓數(shù)百萬臺家庭及小型辦公區(qū)路由設(shè)備身陷風(fēng)險之中。
但頗為諷刺的是,作為一種肆虐多年的安全隱患,緩沖區(qū)溢出漏洞的核心卻只是由一種實踐性結(jié)果衍生出的簡單bug。計算機程序會頻繁使用多組讀取自某個文件、網(wǎng)絡(luò)甚至是源自鍵盤輸入的數(shù)據(jù)。程序為這些數(shù)據(jù)分配一定量的內(nèi)存塊——也就是緩沖區(qū)——作為存儲資源。而所謂緩沖區(qū)漏洞的產(chǎn)生原理就是,寫入或者讀取自特定緩沖區(qū)的數(shù)據(jù)總量超出了該緩沖區(qū)所能容納量的上限。
事實上,這聽起來像是一種相當(dāng)愚蠢、毫無技術(shù)含量的錯誤。畢竟程序本身很清楚緩沖區(qū)的具體大小,因此我們似乎能夠很輕松地確保程序只向緩沖區(qū)發(fā)送不超出上限的數(shù)據(jù)量。這么想確實沒錯,但緩沖區(qū)溢出仍在不斷出現(xiàn),并始終成為眾多安全攻擊活動的導(dǎo)火線。
為了了解緩沖區(qū)溢出問題的發(fā)生原因——以及為何其影響如此嚴(yán)重——我們需要首先談?wù)劤绦蚴侨绾问褂脙?nèi)存資源以及程序員是如何編寫代碼的。(需要注意的是,我們將以堆棧緩沖區(qū)溢出作為主要著眼對象。雖然這并不是惟一一種溢出問題,但卻擁有著典型性地位以及極高的知名度。)
堆疊起來
緩沖區(qū)溢出只會給原生代碼造成影響——也就是那些直接利用處理器指令集編寫而成的程序,而不會影響到利用Java或者Python等中間開發(fā)機制構(gòu)建的代碼。不同操作系統(tǒng)有著自己的特殊處理方式,但目前各類常用系統(tǒng)平臺則普遍遵循基本一致的運作模式。要了解這些攻擊是如何出現(xiàn)的,進而著手阻止此類攻擊活動,我們首先要了解內(nèi)存資源的使用機制。
在這方面,最重要的核心概念就是內(nèi)存地址。內(nèi)存當(dāng)中每個獨立的字節(jié)都擁有一個與之對應(yīng)的數(shù)值地址。當(dāng)處理器從主內(nèi)存(也就是RAM)中加載或者向其中寫稿數(shù)據(jù)時,它會利用內(nèi)存地址來確定讀取或?qū)懭胨赶虻奈恢?。系統(tǒng)內(nèi)存并不單純用于承載數(shù)據(jù),它同時也被用于執(zhí)行那些構(gòu)建軟件的可執(zhí)行代碼。這意味著處于運行中的程序,其每項功能都會擁有對應(yīng)的地址。
在計算機制發(fā)展的早期階段,處理器與操作系統(tǒng)使用的是物理內(nèi)存地址:每個內(nèi)存地址都會直接與RAM中的特定位置相對應(yīng)。盡管目前某些現(xiàn)代操作系統(tǒng)仍然會有某些組成部分繼續(xù)使用這類物理內(nèi)存地址,但現(xiàn)在所有操作系統(tǒng)都會在廣義層面采用另一種機制——也就是虛擬內(nèi)存。
在虛擬內(nèi)存機制的幫助下,內(nèi)存地址與RAM中物理位置直接對應(yīng)的方式被徹底打破。相反,軟件與處理器會利用虛擬內(nèi)存地址保證自身運轉(zhuǎn)。操作系統(tǒng)與處理器配合起來共同維護著一套虛擬機內(nèi)存地址與物理內(nèi)存地址之間的映射機制。
這種虛擬化方式帶來了一系列非常重要的特性。首先也是最重要的,即“受保護內(nèi)存”。具體而言,每項獨立進程都擁有屬于自己的地址集合。對于一個32位進程而言,這部分對應(yīng)地址從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)存資源一般也要占用一部分地址空間。而在虛擬地址機制中,這些限制都將不復(fù)存在。
那么進程會在自己對應(yīng)的地址空間中藏進什么小秘密呢?總體來講,大致有四種覺類別,我們會著重討論其中三種。這惟一一種不值得探討的也就是大多數(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)原理。
我們首先需要關(guān)注的就是構(gòu)建程序的各類可執(zhí)行代碼與庫。主可執(zhí)行代碼及其全部配套庫都會被載入到對應(yīng)進程的地址空間當(dāng)中,而且所有組成部分都擁有自己的對應(yīng)內(nèi)存地址。
其次就是程序用于存儲自身數(shù)據(jù)的內(nèi)存,這部分內(nèi)存資源通常被稱為heap、也就是內(nèi)存堆。舉例來說,內(nèi)存堆可以用于存儲當(dāng)前正在編輯的文檔、瀏覽的網(wǎng)頁(包括其中的全部JavaScript對象、CSS等等)或者當(dāng)前游戲的地圖資源等等。
第三也是最重要的一項概念即call stack,即調(diào)用堆——也簡稱為棧。內(nèi)存??梢哉f是最復(fù)雜的相關(guān)概念了。進程中的每個分線程都擁有自己的內(nèi)存棧。棧其實就是一個內(nèi)存塊,用于追蹤某個線程當(dāng)前正在運行的函數(shù)以及所有前趨函數(shù)——所謂前趨函數(shù),是指那些當(dāng)前函數(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ù)結(jié)構(gòu)的一個特殊版本。棧是一種用于存儲對象且大小可變的結(jié)構(gòu)。新對象能夠被加入到(即’push‘)該棧的一端(一般為對應(yīng)內(nèi)存棧的’top‘端,即頂端),也可從棧中進行移除(即’pop’)。只有內(nèi)存棧頂端的部分能夠通過push或者pop進行修改,因此棧會強制執(zhí)行一種排序機制:最近添加進入的項目也會被首先移除。而首個添加進入的項目則會被最后移除。
調(diào)用堆棧最為重要的任務(wù)就是存儲返回地址。在大多數(shù)情況下,當(dāng)一款程序調(diào)用某項函數(shù)時,該函數(shù)會按照既定設(shè)計發(fā)生作用(包括調(diào)用其它函數(shù)),并隨后返回至調(diào)用它的函數(shù)處。為了能夠切實返回至正確的調(diào)用函數(shù),必須存在一套記錄系統(tǒng)來注明進行調(diào)用的源函數(shù):即應(yīng)當(dāng)在函數(shù)調(diào)用指令執(zhí)行之后從指令中恢復(fù)回來。這條指令所對應(yīng)的地址就被稱為返回地址。棧用于維護這些返回地址,就是說每當(dāng)有函數(shù)被調(diào)用時,返回地址都會被push到其內(nèi)存棧當(dāng)中。而在函數(shù)返回之后,對應(yīng)返回地址則從內(nèi)存棧中被移除,處理器隨后開始在該地址上執(zhí)行指令。
棧的功能非常重要,甚至可以說是整個流程的核心所在,而處理器也會以內(nèi)置方式支持這些處理概念。以x86處理器為例,在x86所定義的各個寄存器當(dāng)中(所謂寄存器,是指處理器內(nèi)的小型存儲位置,其能夠直接由處理器指令進行訪問),最為重要的兩類就是eip(即指令指針)以及esp(即棧指針)。
esp始終容納有棧頂端的對應(yīng)地址。每一次有數(shù)據(jù)被添加到該棧中時,esp中的值都會降低。而每當(dāng)有數(shù)據(jù)從棧中被移除時,esp的值則相應(yīng)增加。這意味著該棧的值出現(xiàn)“下降”時,則代表有更多數(shù)據(jù)被添加到了該棧當(dāng)中,而esp中的存儲地址則會不斷向下方移動。不過盡管如此,esp所使用的參考內(nèi)存位置仍然被稱為該內(nèi)存棧的“頂端”。
eip 為現(xiàn)有執(zhí)行指令提供內(nèi)存地址,而處理器則負責(zé)維護eip本身的正常運作。處理器會從內(nèi)存當(dāng)中根據(jù)eip增量讀取指令流,從而保證始終能夠獲得正確的指令地址。x86擁有一項用于函數(shù)調(diào)用的指令,名為call,另一項用于從函數(shù)處返回的指令則名為ret。
call 會獲取一個操作數(shù),也就是欲調(diào)用函數(shù)的地址(當(dāng)然,我們也可以利用其它方式來獲取欲調(diào)用函數(shù)的地址)。當(dāng)執(zhí)行call指令時,棧指針esp會通過4個字節(jié)(32位)來表現(xiàn),而緊隨call之后的指令地址——也就是返回地址——則會被寫入至當(dāng)前esp的參考內(nèi)存位置。換句話說,返回地址會被添加至內(nèi)存棧中。接下來,eip會將該地址指定為call的操作數(shù),并以該地址為起始位置進行后續(xù)操作。
ret 的作用則完全相反。簡單的ret指令不會獲取任何操作數(shù)。處理器首先從esp當(dāng)中的內(nèi)存地址處讀取值,而后對esp進行4字節(jié)的數(shù)值增量——這意味著其將返回地址從內(nèi)存棧中移除出去。這時eip接受值設(shè)定,并以此為起始位置進行后續(xù)操作。
分享到微信 ×
打開微信,點擊底部的“發(fā)現(xiàn)”,
使用“掃一掃”即可將網(wǎng)頁分享至朋友圈。