手把手教你棧溢出從入門到放棄(下)

手把手教你棧溢出從入門到放棄(下)


轉載知乎上的一篇文章 原文鏈接:https://zhuanlan.zhihu.com/p/25892385,文章深入淺出地介紹了緩衝區溢出攻擊的原理,內容詳實精彩,推薦學習。


0x10 上期回顧

上篇文章介紹了棧溢出的原理和兩種執行方法,兩種方法都是通過覆蓋返回地址來執行輸入的指令片段(shellcode)或者動態庫中的函數(return2libc)。本篇會繼續介紹另外兩種實現方法。一種是覆蓋返回地址來執行內存內已有的代碼片段(ROP),另一種是將某個函數的地址替換成另一個函數的地址(hijack GOT)。


0x20 相關知識

0x21 寄存器

在上篇的背景知識中,我們提到了函數狀態相關的三個寄存器--esp,ebp,eip。下面的內容會涉及更多的寄存器,所以我們大致介紹下寄存器在執行程序指令中的不同用途。

32位x86架構下的寄存器可以被簡單分爲通用寄存器特殊寄存器兩類,通用寄存器在大部分彙編指令下是可以任意使用的(雖然有些指令規定了某些寄存器的特定用途),而特殊寄存器只能被特定的彙編指令使用,不能用來任意存儲數據。

32位x86架構下的通用寄存器包括一般寄存器(eax、ebx、ecx、edx),索引寄存器(esi、edi),以及堆棧指針寄存器(esp、ebp)。

一般寄存器用來存儲運行時數據,是指令最常用到的寄存器,除了存放一般性的數據,每個一般寄存器都有自己較爲固定的獨特用途。eax 被稱爲累加寄存器(Accumulator),用以進行算數運算和返回函數結果等。ebx 被稱爲基址寄存器(Base),在內存尋址時(比如數組運算)用以存放基地址。ecx 被稱爲記數寄存器(Counter),用以在循環過程中記數。edx 被稱爲數據寄存器(Data),常配合 eax 一起存放運算結果等數據。

索引寄存器通常用於字符串操作中,esi 指向要處理的數據地址(Source Index),edi 指向存放處理結果的數據地址(Destination Index)。

堆棧指針寄存器(esp、ebp)用於保存函數在調用棧中的狀態,上篇已有詳細的介紹。

32位x86架構下的特殊寄存器包括段地址寄存器(ss、cs、ds、es、fs、gs),標誌位寄存器(EFLAGS),以及指令指針寄存器(eip)。

現代操作系統內存通常是以分段的形式存放不同類型的信息的。我們在上篇談及的函數調用棧就是分段的一個部分(Stack Segment)。內存分段還包括堆(Heap Segment)、數據段(Data Segment),BSS段,以及代碼段(Code Segment)。代碼段存儲可執行代碼和只讀常量(如常量字符串),屬性可讀可執行,但通常不可寫。數據段存儲已經初始化且初值不爲0的全局變量和靜態局部變量,BSS段存儲未初始化或初值爲0的全局變量和靜態局部變量,這兩段數據都有可寫的屬性。堆用於存放程序運行中動態分配的內存,例如C語言中的 malloc() 和 free() 函數就是在堆上分配和釋放內存。各段在內存的排列如下圖所示。

內存分段的典型佈局

Fig1. 內存分段的典型佈局

段地址寄存器就是用來存儲內存分段地址的,其中寄存器 ss 存儲函數調用棧(Stack Segment)的地址,寄存器 cs 存儲代碼段(Code Segment)的地址,寄存器 ds 存儲數據段(Data Segment)的地址,es、fs、gs 是附加的存儲數據段地址的寄存器。


標誌位寄存器(EFLAGS)32位中的大部分被用於標誌數據或程序的狀態,例如 OF(Overflow Flag)對應數值溢出、IF(Interrupt Flag)對應中斷、ZF(Zero Flag)對應運算結果爲0、CF(Carry Flag)對應運算產生進位等等。

指令指針寄存器(eip)存儲下一條運行指令的地址,上篇已有詳細的介紹。

0x22 彙編指令

爲了更好地理解後面的內容,一點點彙編語言的知識也是必要的,本節會介紹一些基礎指令的含義。32位x86架構下的彙編語言有 Intel 和 AT&T 兩種格式,本文所用匯編指令都是 Intel 格式。兩者最主要的差別如下。

Intel 格式,寄存器名稱和數值前無符號:


“指令名稱 目標操作數 DST,源操作數 SRC”

AT&T 格式,寄存器名稱前加“%”,數值前加“$”:


“指令名稱 源操作數 SRC,目標操作數 DST”
一些最常用的彙編指令如下:
  • MOV:數據傳輸指令,將 SRC 傳至 DST,格式爲
    MOV DST, SRC;
  • PUSH:壓入堆棧指令,將 SRC 壓入棧內,格式爲
    PUSH SRC;
  • POP:彈出堆棧指令,將棧頂的數據彈出並存至 DST,格式爲
    POP DST;
  • LEA:取地址指令,將 MEM 的地址存至 REG ,格式爲
    LEA REG, MEM;
  • ADD/SUB:加/減法指令,將運算結果存至 DST,格式爲
    ADD/SUB DST, SRC;
  • AND/OR/XOR:按位與/或/異或,將運算結果存至 DST ,格式爲
    AND/OR/XOR DST,SRC;
  • CALL:調用指令,將當前的 eip 壓入棧頂,並將 PTR 存入 eip,格式爲
    CALL PTR;
  • RET:返回指令,操作爲將棧頂數據彈出至 eip,格式爲
    RET;

介紹完以上背景知識,就可以繼續棧溢出技術之路了。

0x30 ROP ( Return Oriented Programming )

--修改返回地址,讓其指向內存中已有的一段指令

根據上面副標題的說明,要完成的任務包括:在內存中確定某段指令的地址,並用其覆蓋返回地址。可是既然可以覆蓋返回地址並定位到內存地址,爲什麼不直接用上篇提到的 return2libc 呢?因爲有時目標函數在內存內無法找到,有時目標操作並沒有特定的函數可以完美適配。這時就需要在內存中尋找多個指令片段,拼湊出一系列操作來達成目的。假如要執行某段指令(我們將其稱爲“gadget”,意爲小工具),溢出數據應該以下面的方式構造(padding 長度和內容的確定方式參見上篇):


payload : padding + address of gadget

Fig 2. 包含單個 gadget 的溢出數據

如果想連續執行若干段指令,就需要每個 gadget 執行完畢可以將控制權交給下一個 gadget。所以 gadget 的最後一步應該是 RET 指令,這樣程序的控制權(eip)才能得到切換,所以這種技術被稱爲返回導向編程( Return Oriented Programming )。要執行多個 gadget,溢出數據應該以下面的方式構造:


payload : padding + address of gadget 1 + address of gadget 2 + ......
+ address of gadget n

在這樣的構造下,被調用函數返回時會跳轉執行 gadget 1,執行完畢時 gadget 1 的 RET 指令會將此時的棧頂數據(也就是 gadget 2 的地址)彈出至 eip,程序繼續跳轉執行 gadget 2,以此類推。

Fig 3. 包含多個 gadget 的溢出數據


現在任務可以分解爲:針對程序棧溢出所要實現的效果,找到若干段以 ret 作爲結束的指令片段,按照上述的構造將它們的地址填充到溢出數據中。所以我們要解決以下幾個問題。

首先,棧溢出之後要實現什麼效果?

ROP 常見的拼湊效果是實現一次系統調用,Linux系統下對應的彙編指令是 int 0x80。執行這條指令時,被調用函數的編號應存入 eax,調用參數應按順序存入 ebx,ecx,edx,esi,edi 中。例如,編號125對應函數

mprotect (void *addr, size_t len, int prot)

,可用該函數將棧的屬性改爲可執行,這樣就可以使用 shellcode 了。假如我們想利用系統調用執行這個函數,eax、ebx、ecx、edx 應該分別爲“125”、內存棧的分段地址(可以通過調試工具確定)、“0x10000”(需要修改的空間長度,也許需要更長)、“7”(RWX 權限)。

其次,如何尋找對應的指令片段?

有若干開源工具可以實現搜索以 ret 結尾的指令片段,著名的包括 ROPgadgetrp++ropeme 等,甚至也可以用 grep 等文本匹配工具在彙編指令中搜索 ret 再進一步篩選。搜索的詳細過程在這裏就不再贅述,有興趣的同學可以參考上述工具的說明文檔。

最後,如何傳入系統調用的參數?

對於上面提到的 mprotect 函數,我們需要將參數傳輸至寄存器,所以可以用 pop 指令將棧頂數據彈入寄存器。如果在內存中能找到直接可用的數據,也可以用 mov 指令來進行傳輸,不過寫入數據再 pop 要比先搜索再 mov 來的簡單,對吧?如果要用 pop 指令來傳輸調用參數,就需要在溢出數據內包含這些參數,所以上面的溢出數據格式需要一點修改。對於單個 gadget,pop 所傳輸的數據應該在 gadget 地址之後,如下圖所示。

Fig 4. gadget “pop eax; ret;”


在調用 mprotect() 爲棧開啓可執行權限之後,我們希望執行一段 shellcode,所以要將 shellcode 也加入溢出數據,並將 shellcode 的開始地址加到 int 0x80 的 gadget之後。但確定 shellcode 在內存的確切地址是很困難的事(想起上篇裏面艱難試探的過程了嗎?),我們可以使用 push esp 這個 gadget(假如可以找到的話)。

Fig 5. gadget “push esp; ret;”

我們假設現在內存中可以找到如下幾條指令:

pop eax; ret;    # pop stack top into eax
pop ebx; ret;    # pop stack top into ebx
pop ecx; ret;    # pop stack top into ecx
pop edx; ret;    # pop stack top into edx
int 0x80; ret;   # system call
push esp; ret;   # push address of shellcode

對於所有包含 pop 指令的 gadget,在其地址之後都要添加 pop 的傳輸數據,同時在所有 gadget 最後包含一段 shellcode,最終溢出數據結構應該變爲如下格式。

payload : padding + address of gadget 1 + param for gadget 1 + address of gadget 2 + param for gadget 2 + ...... + address of gadget n + shellcode

Fig 6. 包含多個 gadget 的溢出數據(修改後)

此處爲了簡單,先假定輸入溢出數據不受“\x00"字符的影響,所以 payload 可以直接包含 “\x7d\x00\x00\x00”(傳給 eax 的參數125)。如果希望實現更爲真實的操作,可以用多個 gadget 通過運算得到上述參數。比如可以通過下面三條 gadget 來給 eax 傳遞參數。

pop eax; ret;         # pop stack top 0x1111118e into eax
pop ebx; ret;         # pop stack top 0x11111111 into ebx
sub eax, ebx; ret;    # eax -= ebx

解決完上述問題,我們就可以拼接出溢出數據,輸入至程序來爲程序調用棧開啓可執行權限並執行 shellcode。同時,由於 ROP 方法帶來的靈活性,現在不再需要痛苦地試探 shellcode 起始地址了。回顧整個輸入數據,只有棧的分段地址需要獲取確定地址。如果利用 gadget 讀取 ebp 的值再加上某個合適的數值,就可以保證溢出數據都具有可執行權限,這樣就不再需要獲取確切地址,也就具有了繞過內存隨機化的可能。

出於演示的目的,我們假設(簡直是欽點)了所有需要的 gadget 的存在。在實際搜索及拼接 gadget 時,並不會像上面一樣順利,有兩個方面需要注意。

第一,很多時候並不能一次湊齊全部的理想指令片段,這時就要通過數據地址的偏移、寄存器之間的數據傳輸等方法來“曲線救國”。舉個例子,假設找不到下面這條 gadget


pop ebx; ret; 

但假如可以找到下面的 gadget

mov ebx, eax; ret;

我們就可以將它和

pop eax; ret; 

組合起來實現將數據傳輸給 ebx 的功能。上面提到的用多個 gadget 避免輸入“\x00”也是一個實例應用。

第二,要小心 gadget 是否會破壞前面各個 gadget 已經實現的部分,比如可能修改某個已經寫入數值的寄存器。另外,要特別小心 gadget 對 ebp 和 esp 的操作,因爲它們的變化會改變返回地址的位置,進而使後續的 gadget 無法執行。


0x40 Hijack GOT

--修改某個被調用函數的地址,讓其指向另一個函數

根據上面副標題的說明,要完成的任務包括:在內存中修改某個函數的地址,使其指向另一個函數。爲了便於理解,不妨假設修改 printf() 函數的地址使其指向 system(),這樣修改之後程序內對 printf() 的調用就執行 system() 函數。要實現這個過程,我們就要弄清楚發生函數調用時程序是如何“找到”被調用函數的。


程序對外部函數的調用需要在生成可執行文件時將外部函數鏈接到程序中,鏈接的方式分爲靜態鏈接和動態鏈接。靜態鏈接得到的可執行文件包含外部函數的全部代碼,動態鏈接得到的可執行文件並不包含外部函數的代碼,而是在運行時將動態鏈接庫(若干外部函數的集合)加載到內存的某個位置,再在發生調用時去鏈接庫定位所需的函數。

可程序是如何在鏈接庫內定位到所需的函數呢?這個過程用到了兩張表--GOT 和 PLT。GOT 全稱是全局偏移量表(Global Offset Table),用來存儲外部函數在內存的確切地址。GOT 存儲在數據段(Data Segment)內,可以在程序運行中被修改。PLT 全稱是程序鏈接表(Procedure Linkage Table),用來存儲外部函數的入口點(entry),換言之程序總會到 PLT 這裏尋找外部函數的地址。PLT 存儲在代碼段(Code Segment)內,在運行之前就已經確定並且不會被修改,所以 PLT 並不會知道程序運行時動態鏈接庫被加載的確切位置。那麼 PLT 表內存儲的入口點是什麼呢?就是 GOT 表中對應條目的地址。

PLT 和 GOT 表
Fig 7. PLT 和 GOT 表

等等,我們好像發現了一個不合理的地方,外部函數的內存地址存儲在 GOT 而非 PLT 表內,PLT 存儲的入口點又指向 GOT 的對應條目,那麼程序爲什麼選擇 PLT 而非 GOT 作爲調用的入口點呢?在程序啓動時確定所有外部函數的內存地址並寫入 GOT 表,之後只使用 GOT 表不是更方便嗎?這樣的設計是爲了程序的運行效率。GOT 表的初始值都指向 PLT 表對應條目中的某個片段,這個片段的作用是調用一個函數地址解析函數。當程序需要調用某個外部函數時,首先到 PLT 表內尋找對應的入口點,跳轉到 GOT 表中。如果這是第一次調用這個函數,程序會通過 GOT 表再次跳轉回 PLT 表,運行地址解析程序來確定函數的確切地址,並用其覆蓋掉 GOT 表的初始值,之後再執行函數調用。當再次調用這個函數時,程序仍然首先通過 PLT 表跳轉到 GOT 表,此時 GOT 表已經存有獲取函數的內存地址,所以會直接跳轉到函數所在地址執行函數。整個過程如下面兩張圖所示。

第一次調用函數時解析函數地址並存入 GOT 表

Fig 8. 第一次調用函數時解析函數地址並存入 GOT 表

Fig 9. 再次調用函數時直接讀取 GOT 內的地址

上述實現遵循的是一種被稱爲 LAZY 的設計思想,它將需要完成的操作(解析外部函數的內存地址)留到調用實際發生時才進行,而非在程序一開始運行時就解析出全部函數地址。這個過程也啓示了我們如何實現函數的僞裝,那就是到 GOT 表中將函數 A 的地址修改爲函數 B 的地址。這樣在後面所有對函數 A 的調用都會執行函數 B。

那麼我們的目標可以分解爲如下幾部分:確定函數 A 在 GOT 表中的條目位置,確定函數 B 在內存中的地址,將函數 B 的地址寫入函數 A 在 GOT 表中的條目。

首先,如何確定函數 A 在 GOT 表中的條目位置?

程序調用函數時是通過 PLT 表跳轉到 GOT 表的對應條目,所以可以在函數調用的彙編指令中找到 PLT 表中該函數的入口點位置,從而定位到該函數在 GOT 中的條目。

例如

call 0x08048430 <printf@plt>

就說明 printf 在 PLT 表中的入口點是在 0x08048430,所以 0x08048430 處存儲的就是 GOT 表中 printf 的條目地址。

其次,如何確定函數 B 在內存中的地址?

如果系統開啓了內存佈局隨機化,程序每次運行動態鏈接庫的加載位置都是隨機的,就很難通過調試工具直接確定函數的地址。假如函數 B 在棧溢出之前已經被調用過,我們當然可以通過前一個問題的答案來獲得地址。但我們心儀的攻擊函數往往並不滿足被調用過的要求,也就是 GOT 表中並沒有其真實的內存地址。幸運的是,函數在動態鏈接庫內的相對位置是固定的,在動態庫打包生成時就已經確定。所以假如我們知道了函數 A 的運行時地址(讀取 GOT 表內容),也知道函數 A 和函數 B 在動態鏈接庫內的相對位置,就可以推算出函數 B 的運行時地址。

最後,如何實現 GOT 表中數據的修改?

很難找到合適的函數來完成這一任務,不過我們還有強大的 ROP(DIY大法好)。假設我們可以找到以下若干條 gadget(繼續欽點),就不難改寫 GOT 表中數據,從而實現函數的僞裝。ROP 的具體實現請回看上一章,這裏就不再贅述了。

pop eax; ret; 		# printf@plt -> eax
mov ebx [eax]; ret;	# printf@got -> ebx
pop ecx; ret; 		# addr_diff = system - printf -> ecx
add [ebx] ecx; ret; 	# printf@got += addr_diff

從修改 GOT 表的過程可以看出,這種方法也可以在一定程度上繞過內存隨機化。


0x50 防禦措施

介紹過幾種棧溢出的基礎方法,我們再來補充一下操作系統內有哪些常見的措施可以進行防禦。首先,通常情況下程序在默認編譯設置下都會取消棧上數據的可執行權限,這樣簡單的 shellcode 溢出攻擊就無法實現了。其次,可以在操作系統內開啓內存佈局隨機化(ASLR),這樣可以增大確定堆棧內數據和動態庫內函數的內存地址的難度。編譯程序時還可以設置某些編譯選項,使程序在運行時會在函數棧上的 ebp 地址和返回地址之間生成一個特殊的值,這個值被稱爲“金絲雀”(關於這個典故,請大家自行谷歌)。這樣一旦發生了棧溢出並覆蓋了返回地址,這個值就會被改寫,從而實現函數棧的越界檢查。最後值得強調的是,儘可能寫出安全可靠的代碼,不給棧溢出提供寫入越界的可能。


0x60 全篇小結

本文簡要介紹了棧溢出這種古老而經典的技術領域,並概括描述了四種入門級的實現方法。至此我們專欄的第一講就全部結束啦,接下來專欄會陸續放出計算機安全相關的更多文章,內容也會涵蓋網絡安全、Web滲透、密碼學、操作系統,還會有ctf 比賽題解等等......

最後感謝大家的關注,讓我們一起學習,共同進步!


References:

編輯於 2017-03-22
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章