CSAPP buffer lab為深入理解計算機系統(原書第二版)的配套的緩沖區溢出實驗,該實驗要求利用緩沖區溢出的原理解決5個難度遞增的問題,分別為smoke(level 0)、fizz(level 1)、bang(level 2)、boom(level 3)、kaboom(level 4).在實踐中加深對函數調用和緩沖區溢出機制的理解(針對IA-32體系結構)。
本記錄使用的是取自原書配套網站的self-study handout版本,網址為http://csapp.cs.cmu.edu/2e/labs.html。
原課程實驗指導為CSAPP buffer lab writeup:http://csapp.cs.cmu.edu/2e/buflab.pdf
關於實驗中gdb、gcc的使用問題可以參考筆者另一篇博客:Linux下編輯、編譯、調試命令總結——gcc和gdb描述
Buffer lab的self-study handout版本為一個 .tar 打包文件。解壓可參考文章Linux下文件的打包、解壓縮指令——tar,gzip,bzip2
實驗准備:
解壓得到包含三個可執行文件的名為buflab-handout的目錄:
bufbomb:用來攻擊的緩沖區炸彈程序;
hex2raw:用來進行十六進制向二進制串轉換的工具(可能所需輸入並不是ASCII的可打印字符,借助此工具進行轉換);
makecookie:在實驗中是根據ID生成特定的cookie的工具,在self-study中不使用;
使用命令行./bufbomb -u IDname運行bufbomb,程序會根據輸入的IDname生成特定的cookie。實驗過程中需根據實驗要求,構造能夠實現特定功能的輸入序列,完成對應的五個任務。
hex2raw:由於輸入序列所需要的二進制串可能無法完全對應ASCII中的可打印字符,如地址高位為0x80時,ASCII對應的字符是不可通過鍵盤輸入的,可使用hex2raw進行轉換。hex2raw讀取輸入文件中的十六進制數串(每兩位用空格隔開),hex2raw將其轉換為對應的二進制序列。
可使用 cat filename| ./hex2raw| bufbomb 輸入構造的字符串流,其中filename為存放有帶轉換的十六進制串的文件。
進行調試操作時,可使用 ./hex2raw< filename > output.bin 將轉換后的二進制流輸出至output.bin,再通過 r < output.bin在gdb中調試
(這里需要注意,實驗所提供的hex2rax工具為64位版本,可通過命令 file hex2raw 查看。若在32位環境下運行,會提示cann't execute binary file:format error.同時,bufbomb文件為32位版本)
實驗分析:(詳細可參考buffer lab的writeup介紹)
getbuf()
bufbomb通過一個自定義的getbuf()函數讀取輸入,並將其復制到目標內存中。該函數主要通過Gets()函數實現。
/* Buffer size for getbuf */ #define NORMAL_BUFFER_SIZE 32 int getbuf() { char buf[NORMAL_BUFFER_SIZE]; Gets(buf); return 1; }
Gets()函數從標准輸入中讀取輸入(以"\n"或者EOF作為輸入結尾,並不檢查讀取長度),並將其以"\n"結尾存放在指定的內存區域中。這里可知目標字符數組buf長度為32字節。
注意:buf數組的長度為32個字節,但是數組實際在內存(棧)中被分配的長度和位置隨編譯器的不同而有所區別,這里由於直接給出了可執行程序,可以使用objdump -d bufbomb 查看getbuf的實現。

根據函數調用時參數入棧的規則,可知調用Gets函數之前buf數組的首地址會入棧,易知%eax存儲的 %ebp - 40 為buf數組的首地址,這里可以看出已分配的空間和數組長度並不嚴格相同!(為滿足數據對齊,IA32保證每個棧幀的長度為16的整數倍)。getbuf的棧結構如下:

當輸入不足32個字節時,輸出如下:

輸入超過32個字節時,輸出如下:

使用命令行./bufbomb -u IDname運行bufbomb,程序會根據輸入的IDname生成特定的cookie。實驗過程中需根據實驗要求,構造能夠實現特定功能的輸入序列,完成對應的五個任務。
hex2raw:由於輸入序列所需要的二進制串可能無法完全對應ASCII中的可打印字符,如地址高位為0x80時,ASCII對應的字符無法通過鍵盤輸入(可參考xxxxx),可使用hex2raw進行轉換。hex2raw讀取輸入文件中的十六進制數串,並將兩個十六進制數轉換為對應字節的二進制流,如想要輸入高位地址0x80時,直接在輸入文件中寫入其對應的十六進制表示 80,hex2raw自動將其轉換為對應的二進制序列。可使用 cat filename| ./hex2raw| bufbomb 輸入構造的字符串流,其中filename為存放有帶轉換的十六進制串的文件。(這里需要注意,實驗所提供的hex2rax工具為64位版本,可通過命令 file hex2raw 查看。若在32位環境下運行,會提示cann't execute binary file:format error.同時,bufbomb文件位32位版本,)
實驗過程:
level 0 Candle(10 pts)
在bufbomb中存在函數test(),其調用getbuf()函數讀取輸入,並通過uniqueval()函數進行堆棧是否被破壞的檢查,之后根據讀取后的情況進行相應的輸出。
void test() 2 { 3 int val; 4 /* Put canary on stack to detect possible corruption */ 5 volatile int local = uniqueval(); 6 7 val = getbuf(); 8 9 /* Check for corrupted stack */ 10 if (local != uniqueval()) { 11 printf("Sabotaged!: the stack has been corrupted\n"); 12 } 13 else if (val == cookie) { 14 printf("Boom!: getbuf returned 0x%x\n", val); 15 validate(3); 16 } else { 5 17 printf("Dud: getbuf returned 0x%x\n", val); 18 } 19 }
同時,bufbomb文件中還存在函數smoke(),level 0即改變程序控制流,使得test函數調用getbuf()后,在getbuf()返回時直接調用smoke()函數,而不是返回函數test()
void smoke() { printf("Smoke!: You called smoke()\n"); validate(0); exit(0); }
這里主要考察函數調用時返回地址相關的知識。我們知道在函數調用過程中,控制權跳轉至目標函數之前,會將返回地址(調用點處的下一條指令的地址)入棧,在函數調用結束后,通過該地址來繼續執行調用點的下一條指令。這里,想要在函數調用結束時直接調用smoke()函數,主要在於修改函數調用的返回地址為smoke()函數的地址。注意,雖然test中有檢查堆棧破環的canary,但任務目的是在getbuf結束后直接調轉至另一函數,而沒有執行后續的堆棧是否被破壞的檢查,所以可直接構造超出數組長度的字符串來覆蓋返回地址,使其指向目標函數的地址。
由前文對getbuf的棧空間的分析,可知想要構造覆蓋返回地址的函數,則字符串的結構應為 44個字節的填充字符(buf分配的空間+ebp) + 新的返回地址。
通過gdb動態調試可知smoke函數起始地址為0x08048c18.

構造的字符串為:0*44 + 18 8c 04 08 .(小端法存放)。構造的用於hex2raw轉換的文件如圖所示,其中30為字符0對應的ASCII的十六進制表示,最后4個字節位小端法表示的smoke函數起始地址。
成功調用了smoke函數:

level 1 Sparkler(10 pts)
bufbomb中存在另一個函數fizz(),其源碼如下.fizz()函數將實參與cookie比較,當cookie與實參相等時,則表示成功。這里的cookie即為運行./bufbomb -u UesrID 時根據UserID生成的cookie。
void fizz(int val) { if (val == cookie) { printf("Fizz!: You called fizz(0x%x)\n", val); validate(1); } else printf("Misfire: You called fizz(0x%x)\n", val); exit(0); }
level 1的任務為(1)修改getbuf函數返回地址為fizz()函數,而不是返回函數test() ; (2)在fizz()中驗證成功,即需傳入cookie值作為參數;
這里主要考察的是關於函數的參數傳遞方面的知識。
下左圖:
(1)對於有參數的被調函數,函數調用之前會將參數按從右至左的順序入棧,之后在被調函數中通過%ebp+8、%ebp+12等地址獲得函數調用的實參。
(2)函數調用指令call會將函數的返回地址入棧,被調函數會將原函數的棧幀指針即存儲在%ebp中的值入棧,並使新的%ebp的值等於%esp,從而使%ebp指向被調函數的棧幀,這樣新%ebp指向的地址與函數的參數存放處間隔保存的%ebp和返回地址,故可以通過%ebp+8獲得函數的第一參數

上右圖:
(1)左側箭頭標識%esp位置。函數返回時,首先將%ebp值賦值給%esp,則棧頂為位置(1).之后push %ebp,將%ebp還原,%esp在位置(2)。最后ret指令恢復返回地址,%esp指向位置(3);
(2)由於需返回fizz函數,故返回地址已被修改為fizz的地址。注意這里沒有call指令,沒有返回地址入棧。fizz函數按正常流程執行,其棧自紅線處開始。先將%ebp入棧,新的%ebp位置如圖所示。fizz函數正常按照%ebp+8的位置取其參數,故圖示棧中的位置(1)應被覆蓋為cookie值;
故構造的輸入字符串應為:44個填充字節 + fizz函數起始地址 + 4個填充字節 + cookie值.fizz函數的起始地址可使用gdb查看,cookie值為bufbomb生成的值,這里注意使用小端法書寫即可。


構造的字符串為:0*44 + 42 8c 04 08(fizz起始地址) + 0*4 +da 31 3e 37(cookie) .

結果如圖所示:

level 2 Firecracker(15 pts)
更復雜的構造輸入字符串的方法是在字符串中包含有實際功能的機器語言代碼,並修改函數的返回地址使其指向構造的代碼,從而執行這段代碼實現設定的功能。
bufbomb中存在函數bang,函數同樣驗證cookie與實參的值,並根據結果進行驗證,同時輸出全局變量global_value的值。
int global_value = 0; void bang(int val) { if (global_value == cookie) { printf("Bang!: You set global_value to 0x%x\n", global_value); validate(2); } else printf("Misfire: global_value = 0x%x\n", global_value); exit(0); }
level 2的任務為:(1)通過執行輸入構造的機器指令修改global_value的值為cookie的值; (2)如level 0中所執行的,test函數調用getbuf()后,在getbuf()返回時直接調用fizz()函數,而不是返回函數test() ;
任務的關鍵在於如何構造機器代碼,以及使得程序跳轉至輸入的機器代碼處執行,同時注意函數bang的參數傳遞過程。
構造輸入字符串的過程:
(1)全局變量global_value在程序執行的過程中邏輯地址不發生變化,可直接在gdb中得到其地址,使用mov指令對其進行賦值;
(2)將getbuf函數的返回地址修改,指向構造的機器代碼的開始處,這里即buf數組的起始地址;
(3)由於getbuf函數的返回地址已經被用於指向輸入的機器代碼,故跳轉至bang函數的實現需要使用額外的指令。這里由於程序是已經編譯好的,所以bang函數的邏輯地址不變,故可以直接使用邏輯地址調用。使用push 將bang函數地址入棧,再使用ret指令進行跳轉。(push指令將數據放置在棧頂,ret取棧頂的數據並將其作為地址進行跳轉);
(4)這里需要注意函數bang的參數傳遞過程。之前的level 0與level 1,機器代碼存放在代碼段,由PC指示,數據操作在棧上,由%esp指示。level 2中第一次跳轉后,正在執行的機器代碼位於棧上的緩沖區中,由PC指示,數據操作也在棧上,由%esp指示,這里需要注意兩者的區別,前者是用於執行的,后者用於操作。圖示為函數ret指令之后%esp和PC的位置。同樣,對函數參數的傳遞可參考level 1或者直接使用%esp + 4尋址賦值;

構造輸入字符串:可執行的機器代碼 + 填充字符 + 指向輸入機器代碼的地址。
通過gdb得到bang函數的起始地址位0x08048c9d。

同樣在bang函數的反匯編中,將0x804d100與0x804d108處的值進行了比較,查看地址0x804d108,發現存放的是cookie,則0x804d100處即為全局變量global_value的值。

在getbuf函數內部設置斷點,並運行至函數內部,得到buf數組的起始地址為0x55683978.(這里注意要運行至getbuf內部是由於需要使得%ebp指向的是getbuf的棧幀,這樣%ebp-40才是buf數組的首地址,否則直接輸出%ebp-40可能指向的其他地方)

構造的可執行代碼為:
mov $0x373e31da,0x804d100 #將cookie值賦值給global_value mov 0x804d100,%eax mov %eax,4(%esp) #將global_value的值作為實參放置在棧中作為bang的參數 push $0x08048c9d #將bang函數起始地址入棧,注意常數的書寫方式,加上$ ret #將棧頂數據作為地址進行跳轉
可以將上訴匯編指令進行編譯,編譯方法可見實驗writeup的最后一部分,再使用objdump或gdb反匯編來得到所需的機器代碼的十六進制表示。(注意這里和gdb中的代碼並沒有明確給出指令后綴b、w、l、q,但在實際書寫中需加上后綴才能編譯)
實際使用的輸入字符串如圖所示,其中可執行代碼(25bytes) + 填充字符(19bytes) + 數組首地址(4bytes)

執行后的結果為:

level 3 Dynamite(20 pts)
目前為止的操作都是使得正常控制流改變並跳轉至其它函數,最終使得程序停止,故而以上操作中對於棧的破壞、破壞保留值等操作對於程序運行是可以接受的。更復雜的緩沖區攻擊在於執行某些構造的指令改變寄存器或內存中的值,並使程序能正常返回原控制流執行。level 3的任務為通過構造指令,使得getbuf正常返回至test函數,並使得getbuf返回值為cookie值。
需要注意以下幾點:
(1)構造的機器指令是存放在getbuf的緩沖區中,想要執行輸入的構造代碼,只有修改geubuf函數返回時的地址,注意當跳轉至構造的代碼處執行時,getbuf是已經結束了,返回值1存放在寄存器%eax中;(正是結束時的ret指令才跳轉至修改后的地址處)
(2)回想函數調用過程,call指令調用函數時將返回地址放置在棧頂,進入函數后的第一步為保存%ebp,這樣在覆蓋修改返回地址時必將覆蓋保存的%ebp也覆蓋掉了。在getbuf函數結束時,會將%esp的值賦值為getbuf棧幀指針%ebp的值(mov指令),之后將保存的%ebp值賦值給寄存器%ebp。前面所述,保存的%ebp在覆蓋返回地址時已經被覆蓋,故此時%ebp會是一個任意值;
(3)由於題目的要求是正常返回test函數,而該函數存在一定的對緩沖區覆蓋的檢查(uniqueval函數),故可能需要注意構造的字符串的長度;
如上所述,構造的字符串應完成的功能為:(1)修改存放返回值的寄存%eax; (2)恢復寄存器%ebp的值為正常值,這里即test函數的棧幀; (3)將getbuf正常返回地址放置在棧頂,並通過ret指令返回test函數。
通過gdb調試,在getbuf函數內部設置斷點,查看保存的返回地址、保存的%ebp等信息。p $ebp獲得getbuf棧幀指針的信息,再使用x /2xw $ebp獲得地址%ebp處開始的連續兩個4字節空間的值(回憶一下getbuf的棧結構,這兩個空間存放的即為保存的%ebp和返回地址)。得到returnaddress為0x08048dbe,保存的%ebp為0x556839d0.

構造的可執行代碼為:
movl $0x373e31da,%eax #修改返回值為cookie值 movl $0x556839d0,%ebp #恢復被破壞的保存的%ebp的值 push $0x08048dbe #將返回地址入棧 ret #跳轉
構造的字符串序序列如下,其中 構造代碼(16字節) + 填充字符(28字節) + 修改的返回地址,即為數組起始地址(4字節)

執行結果如下:

level 4 Nitroglycerin(10 pts)
對於一個給定的程序而言,程序每次運行時尤其是被不同用戶運行時的使用的棧位置是不同的。造成棧位置變化的原因有很多,其中一個是由於程序在運行時,所有必要的環境變量都以字符串的形式被放置在棧的底部(高地址單元)。對於不同值的環境變量,其所需要的棧空間自然不同,從而使得棧位置的變化,對於不同用戶而言這一點更為顯著。相應的,程序自然運行與在gdb環境下運行的棧位置也可能不同,因為部分gdb本身運行所需的數據被放置在了棧中。
getbuf函數內置了使棧空間穩定的特性,從而使得進行緩沖區攻擊時能夠直接獲得固定的所需要的地址數據,並采用直接利用的方式寫入機器代碼中,這也大大降低了實現難度。而這在實際應用情況下是過分理想的。在level 4環節,用戶需要在啟動bufbomb時使用 -n 選項,從而使得棧空間不再穩定,並在此基礎上進行基於緩沖區溢出原理的實驗。
程序運行時啟用了 -n 選項時,程序在讀取輸入時會啟用 getbufn函數(而不是前面的getbuf)。getbufn函數有與getbuf相似的功能,但前者輸入數組的長度為512字節。調用getbufn函數之前,程序會先在棧上分配一個隨機長度的空間,從而使得getbufn函數的棧空間在不同調用情況下不再是固定的,實際上%ebp的差值達到±240。在應用 -n 選項的情況下,程序會要求提交輸入字符串 5 次,5次輸入會面對5個不同的棧空間,並要求每次都成功返回cookie值。level 4的任務與level 3一致,即要求getbufn函數返回調用函數testn時返回cookie值,而不是常規的1.
程序的運行過程加入了棧隨機化的操作,即在程序調用之前,先分配一個隨機大小的空間,這個空間程序並不使用,但是長度不定,從而使得每次運行時的棧空間地址結構產生變化(主要是在棧相對結構不變的情況下,各個棧中元素的地址發生了變化)。這一操作的顯著影響是之前所采用的使用固定的新返回地址覆蓋getbufn返回地址的方法受到限制。由於每次棧空間不同,則輸入的機器代碼的起始位置也不同(回憶上文,每次均是將機器代碼放在輸入字符串的開始位置,這樣每次修改返回地址為輸入數組的起始地址即可執行構造的代碼,其中輸入數組起始地址是固定的),相應的直接指定出構造代碼的地址變得不可行。
這里對於棧隨機化的破解可以借助“空操作雪橇”(nop sled)的技巧。所謂nop sled是在構造的機器代碼之前加入nop指令(no operation的縮寫,機器碼位 0x90),其作用為僅將PC增加而不執行任何操作。在這種情況下,只要覆蓋的地址能夠指向nop序列所處的任意一個地址,就可以順序執行nop指令,直到遇到真正構造的機器代碼,這樣的情況下,對於用於覆蓋的返回地址的要求就降低了。
即構造出的字符串位:nop指令串 + 構造的機器代碼 + 返回地址。

如圖所示,由於隨機分配的地址空間的存在,棧上各個元素的地址會發生變化,從而使得用於覆蓋的返回地址難以確定。使用空操作雪橇時,會在構造的代碼之前填入nop指令。題中的緩沖區有512個字節,同時%ebp的差值為±240。正常情況如上圖,則存在一個區間,只要返回地址為該區間內的地址,則總可以通過nop指令向上“滑行”至真正執行的構造代碼處,從而實現攻擊。
查看getbufn函數的實現,可知數組的分配的長度位520個字節(0x208),覆蓋返回地址需要填充 520(數組長度)+ 4(保存的%ebp) = 524個字節。

通過 p $eax查看%ebp的值,通過 x /2xw $ebp 查看保存的%ebp和返回地址的值。

解題思路如下:
(1)為達到能返回cookie值至testn函數的目的,同樣需要getbufn修改返回地址使其執行構造的代碼,完成包括修改返回值、恢復%ebp、返回testn函數這三個步驟;
(2)在步驟(1)中,修改返回值即%eax與返回testn函數的操作與level 3是一樣的。總是將返回值修改為cookie,返回testn函數的地址也總是不變的(注意這里程序應用的是棧隨機化的操作,影響的是棧空間上的地址,可執行代碼是存放在代碼段,在題設環境下是不受影響的);
(3)關於如何恢復被覆蓋%ebp的問題。棧隨機化是在棧上分配一段不定長的內存空間使得棧中元素的地址發生變化。但是,由於程序總是執行相同的操作,使得在不同的執行情況下,程序所使用的棧中元素的相對位置(距離)不發生變化,可嘗試在此前提下恢復%ebp。恢復過程是由輸入的構造代碼執行的,此時%ebp已經被賦予了“廢值”(見level 3分析),但%esp是有效的值,可以通過%esp推出被覆蓋的保存的%ebp的值。從上面獲得的保存的%ebp的值(0x556839d0)和%ebp的值(%0x556839a0),在構造代碼執行時,%esp應為%ebp+8 = 0x556839a0 + 8 = 0x556839a8 ,則可以看到在差值為 0x556839d0 - 0x556839a8 = 0x28.上述地址在不同運行情況下是會改變的,但其相對差值不變,故總是可以通過執行%esp + 0x28得到原有的被破壞的%ebp值
以下是借助gdb調試程序的過程中兩次運行時的棧空間的變化。可以看到,在兩次運行中,%ebp和保存的%ebp改變了,而返回地址沒有改變,這是由於返回地址指向的是位於代碼段的固定位置處的代碼,不受棧隨機化的影響,但位於棧上的數據則受到了影響。


構造的可執行代碼為:
movl $373e31da,%eax //修改返回值 movl %esp,%ebx addl $0x28,%ebx movl %ebx,%ebp //根據%esp的值得到需要恢復的%ebp的值 pushl $0x08048e3a ret //跳轉至原函數
構造的輸入字符串為 :nop指令串(506字節) + 構造指令(18字節) + 用於覆蓋的新地址(4字節,上述區間中的一個地址即可)

最終結果為:

