上一篇我們確認了一件很重要的事:透過 Stack Buffer Overflow 加上 cyclic 找出 Offset,成功把 Saved RIP 蓋成 0x4242424242424242,也就是程式 ret 之後要跳去哪裡,已經可以由我們決定。
但回頭看一下上一篇的流程,其實還蠻煩的——產生 cyclic pattern、貼進程式、Crash、開 GDB 看 Stack、算 Offset、自己用 Python 拼 Payload、再丟回去跑。如果每一題 Pwn 都要這樣手動操作,大概寫沒幾題就會開始懷疑人生。
所以今天要介紹一個接下來幾乎每篇都會出現的工具:Pwntools。
Pwntools 是一套 Python Library,專門用來寫 Exploit。它把 Pwn 常用的操作都包好了:啟動 Process、連線 Remote Server、收送資料、產生 cyclic pattern、計算 Offset、Pack / Unpack Integer、讀取 ELF、查 Symbol、組 Payload⋯⋯這些事情如果自己做,大概會需要 subprocess、socket、struct.pack 各種東西混在一起。用 Pwntools 之後通常可以省掉非常多工。
一個典型的 Exploit Script 大概長這樣:
from pwn import *
elf = ELF("./chall")
io = process("./chall")
payload = b"A" * 40
payload += p64(0xdeadbeef)
io.sendline(payload)
io.interactive()
現在可能還看不懂每一行在幹嘛,沒關係,今天就是要把這些最常用的東西一個一個拆開來看。
如果環境中還沒有 Pwntools,直接用 pip 裝:
python3 -m pip install --upgrade pwntools
裝完之後進 Python 確認能不能正常 import:
python3
>>> from pwn import *
沒噴 Error 基本上就沒問題了。也可以跑 pwn version 確認版本。
沿用昨天的 Binary chall,建一個 solve.py:
from pwn import *
io = process("./chall")
io.interactive()
跑起來之後應該會看到程式輸出 Input:,等你輸入東西。
這裡 process("./chall") 就是幫我們啟動 ./chall,而 io 就是之後拿來跟這個 Process 溝通的物件——送資料、收資料都透過它。
Pwn 最常做的事情其實很單純:收資料、送資料。Pwntools 幫我們包好了一堆方便的 function。
送資料:
io.send(b"AAAA") # 送出 AAAA
io.sendline(b"AAAA") # 送出 AAAA\n(自動加換行)
收資料:
io.recv() # 收一些資料回來
io.recvline() # 收一整行
io.recvuntil(b"Input:") # 一直讀到看見 "Input:" 為止
CTF 題目常常會有那種一問一答的互動,像是先印 Name: 等你輸入,再印 Password: 等你輸入。如果每次都自己寫 recvuntil 再 sendline,程式碼會變得很囉唆:
io.recvuntil(b"Name:")
io.sendline(b"shark")
io.recvuntil(b"Password:")
io.sendline(b"password")
Pwntools 有一個更簡潔的寫法,把「等到看見某個字串」和「送出資料」合在一起:
io.sendlineafter(b"Name:", b"shark")
io.sendlineafter(b"Password:", b"password")
像我們昨天的程式會輸出 Input:,就可以直接寫成:
io.sendlineafter(b"Input:", b"AAAA")
你可能有注意到,Pwntools 裡的字串前面常常會加一個 b,寫成 b"AAAA" 而不是 "AAAA"。
這個 b 代表 bytes。看起來差不多,但其實是不同的型別:
a = "AAAA"
b = b"AAAA"
print(type(a)) # <class 'str'>
print(type(b)) # <class 'bytes'>
在 Binary Exploitation 裡,我們處理的是 raw bytes,不是一般的文字字串。Payload 裡面可能會出現 \x00、\xff、\x90 這種東西,所以寫 Exploit 時通常都直接用 b"..."。
這也是剛開始寫 Pwntools 最容易踩到的坑——如果不小心把 str 跟 bytes 混在一起,就會噴出:
can't concat str to bytes
看到這個 Error 的話,回去檢查哪邊少了 b 就好。
上一篇的 Payload 結構是 40 Bytes Padding 加上 8 Bytes Return Address。假設今天想把 RIP 蓋成 0xdeadbeef,不能直接這樣寫:
payload = b"A" * 40 + 0xdeadbeef # 這會噴 Error
因為 0xdeadbeef 是一個 Integer,我們需要把它轉換成 CPU 實際看到的 bytes。Pwntools 提供了 p64() 來做這件事——把一個 Integer pack 成 64-bit Little Endian 的 bytes:
payload = b"A" * 40
payload += p64(0xdeadbeef)
如果是 32-bit 的 Binary,就用 p32()。簡單記:
p32()
p64()
之後處理 Leak 的時候還會看到反過來的 u32() 和 u64(),到時候再說。
上一篇我們是這樣手動塞 Payload 的:
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"B"*8)'
現在用 Pwntools 改寫:
from pwn import *
io = process("./chall")
offset = 40
payload = b"A" * offset
payload += p64(0x4242424242424242)
io.sendlineafter(b"Input:", payload)
io.wait()
跑起來之後程式一樣會 Segfault,因為 0x4242424242424242 當然不是一個合法的地址。但重點不是讓它不 Crash,而是我們已經可以用 Script 穩定地產生 Payload 了。
上一篇我們用 pwndbg 的 cyclic 100 來產生 pattern,其實 Pwntools 裡面也有同樣的功能:
from pwn import *
print(cyclic(100))
# aaaabaaacaaadaaaeaaafaaag...
如果 Crash 之後在 Stack 上看到某段 pattern,比如 kaaa,可以直接用 cyclic_find 算出它的 Offset:
offset = cyclic_find(b"kaaa")
print(offset)
所以之後找 Offset 不需要另外寫 pattern generator,Pwntools 都幫你做好了。
另一個之後會一直看到的東西是 ELF():
from pwn import *
elf = ELF("./chall")
Pwntools 會幫你解析這個 ELF Binary,之後就可以從 elf 裡面拿到各種資訊,像是 elf.arch 可以看 Architecture。
更實用的是,假設 Binary 裡面有一個 function 叫 win,我們不需要自己跑 nm chall 然後手動把 address 複製到 Exploit 裡,可以直接:
win = elf.symbols["win"]
從 ELF Symbol Table 把 win 的 address 拉出來用。之後做 ret2win 的時候這招會非常方便。
Pwntools 另一個很讚的地方是,本機測試跟打遠端的 Exploit 幾乎不用改。本機就用 process(),遠端就換成 remote():
io = process("./chall") # 本機
io = remote("example.com", 1337) # 遠端
後面的 io.sendline()、io.recvuntil()、io.interactive() 全部都一樣,不用動。
所以 Exploit 常常會寫成這樣,用一個 flag 切換 local / remote:
from pwn import *
elf = ELF("./chall")
if args.REMOTE:
io = remote("example.com", 1337)
else:
io = process("./chall")
平常本機測就直接 python3 solve.py,要打遠端就 python3 solve.py REMOTE。打 CTF 的時候幾乎每個人都是這樣寫的。
最後一個很重要的 function 是 io.interactive()。它的意思就是:Exploit 做完了,接下來把控制權交回 Terminal,讓我自己跟程式互動。
為什麼需要這個?因為如果 Exploit 成功拿到 Shell,你會想要自己打指令,像是 whoami、cat flag 之類的。如果沒有 io.interactive(),Script 送完 Payload 就直接結束了,Shell 拿到也沒用。
所以之後的 Exploit 結尾幾乎都是這個 pattern:
io.sendline(payload)
io.interactive()
Exploit 負責把程式打到我們要的狀態,最後再交給人去操作。
整理一下今天學到的東西,可以先存一個基本的 Template,之後寫題直接拿來改:
from pwn import *
context.binary = elf = ELF("./chall")
if args.REMOTE:
io = remote("example.com", 1337)
else:
io = process(elf.path)
offset = 40
payload = b"A" * offset
payload += p64(0x4242424242424242)
io.sendlineafter(b"Input:", payload)
io.interactive()
仔細看的話,真正跟漏洞有關的其實只有中間那三行:
offset = 40
payload = b"A" * offset
payload += p64(0x4242424242424242)
其他都在處理啟動程式、建立連線、收送資料這些重複性的工作。Pwntools 就是幫我們把這些 boilerplate 處理掉,讓我們可以專心在 Exploit 本身。
今天沒有介紹新的漏洞,而是把前面手動做的事情開始用 Script 自動化。整理一下今天碰到的 Pwntools function:
| 用途 | Function |
|---|---|
| 啟動 / 連線 | process(), remote() |
| 送資料 | send(), sendline(), sendlineafter() |
| 收資料 | recv(), recvline(), recvuntil() |
| 找 Offset | cyclic(), cyclic_find() |
| Pack / Unpack | p32(), p64(), u32(), u64() |
| 讀 ELF | ELF() |
| 互動 | interactive() |
真正開始寫題目之後,每一題都在重複 ELF → process → recv → 組 Payload → send → interactive 這個流程,寫個幾題自然就記住了。
上一篇我們已經知道 Offset 是 40,也確認 Saved RIP 可以被控制。今天把這件事寫成了 Script。現在真正剩下的問題就只有一個:RIP 到底要跳去哪裡?
如果 Binary 裡面剛好藏著一個從來不會被正常呼叫的 function:
void win() {
system("/bin/sh");
}
那有沒有可能可以跳到這個 function 呢?
Day 20 就來看 Pwn 最經典的入門題型——ret2win。