【学习记录】Week15(二):栈防卫的突破与堆结构的精妙手术——fmtstr+Canary 与 Off-by-one+Tcache

📅 2026/7/5 22:59:20 👁️ 阅读次数 📝 编程学习
【学习记录】Week15(二):栈防卫的突破与堆结构的精妙手术——fmtstr+Canary 与 Off-by-one+Tcache

写在前面:在上一篇中,我们通过 ret2win 和 ret2libc 进行了综合实战的热身。今天,我们将面对 CTF 中最常见的两种防御机制与漏洞的组合拳:栈保护堆指针加密。当栈溢出遇到 Canary,当堆溢出只有单字节(Off-by-one)且面临 Tcache 的 Safe-Linking 机制时,单纯的暴力覆盖已不再可行。我们需要利用格式化字符串进行“精准外科手术”,并利用 Off-by-one 制造堆块重叠,最终完成控制流劫持。

📑 目录

  1. 突破栈防卫:fmtstr 泄露 Canary + ROP 组合
  2. 堆的精妙手术:Off-by-one 原理与重叠构造
  3. 绕过指针加密:Tcache Poisoning 实战
  4. 综合实战:从漏洞触发到 Getshell 的闭环
  5. 总结与下篇预告

1. 突破栈防卫:fmtstr 泄露 Canary + ROP 组合

Canary(栈金丝雀)是位于栈帧中局部变量与返回地址之间的随机值。程序在返回前会检查该值是否被篡改,一旦改变则触发__stack_chk_fail直接终止。传统的栈溢出因为连续覆盖,必定会破坏 Canary。

1.1 组合策略:信息泄露 + 精确溢出

如果程序同时存在格式化字符串漏洞(如printf(buf);)和栈溢出漏洞(如read(0, buf, 0x100);),我们可以分两步走:

  1. 利用 fmtstr 泄露栈上的 Canary 值。
  2. 在栈溢出 payload 中,将原位置的 Canary 原封不动地填回,实现“无损穿越”,随后覆盖返回地址执行 ROP。

1.2 实战利用流程

第一步:定位 Canary
在 64 位系统中,Canary 通常在栈上的rbp - 0x8位置。由于printf的参数是通过寄存器和栈传递的,我们可以通过发送%p序列(如%p.%p.%p...)或利用%N$p定位到 Canary。
Canary 的特征是最低位通常为\x00(用于截断字符串防止泄露)。
假设通过调试发现,第 7 个参数输出的是类似于0x7fffa1234500的值,且末尾为00,则判定该位置为 Canary。

# 伪代码:泄露 Canary p.sendline(b'%7$p') canary = int(p.recvuntil(b'00', drop=True), 16) log.success(f'Canary leaked: {hex(canary)}')

第二步:构造 ROP Payload
假设栈溢出的偏移量是 24 个字节(其中前 16 字节为普通变量,后 8 字节为 Canary)。

# 伪代码:Canary + ROP payload = b'A' * 24 # 填充到 Canary 位置 payload += p64(canary) # 填回真实的 Canary,骗过检查 payload += p64(0) # 填充 8 字节的 Saved RBP (旧栈基址) payload += p64(pop_rdi_ret) # ROP 链开始 payload += p64(bin_sh_addr) payload += p64(system_addr) p.sendline(payload)

通过这种组合,Canary 形同虚设。

2. 堆的精妙手术:Off-by-one 原理与重叠构造

在堆利用中,off-by-one(差一字节溢出)是指向堆块写入数据时,多写了一个字节(通常是字符串末尾的\x00)。这一个字节往往溢出到了下一个堆块的size字段的最低位。

2.1 核心危害:Poison Null Byte(毒化空字节)

在 glibc 中,size字段的最低位是PREV_INUSE标志位(表示前一个堆块是否在使用中)。
如果我们多写了一个\x00,就会将下一个堆块的size最低位由1变为0,欺骗 glibc 认为前一个堆块是空闲的

2.2 构造堆块重叠

结合 Off-by-one,我们可以构造经典的 A-B-C 堆块模型,制造堆块重叠,进而实现任意地址写。

布局策略

  1. 分配堆块 A (size: 0x18, 实际分配 0x20),B (size: 0x108, 实际分配 0x110),C (size: 0x108, 实际分配 0x110)。
  2. 释放 B,B 进入 Unsorted Bin。
  3. 利用 A 的 Off-by-one 漏洞,向 A 写入 0x19 字节数据。最后一个\x00溢出到 B 的 size 字段,将 B 的 size 从0x111修改为0x100
    • 注意:这同时也清除了 C 的PREV_INUSE位。
    • 同时,我们需要在 B 的prev_size位置(即 A 的最后 8 字节)伪造 B 的前一个堆块大小为 0x20(即 A 的大小)。
  4. 分配一个比 B 小的块 B1 (size: 0xf8),从 B 中切割走 0x100 的空间。此时 B 剩余的 0x10 空间因为太小,不会被单独分配。
  5. 关键操作:释放 C。由于 C 的PREV_INUSE为 0,glibc 会检查 C 的prev_size(即 0x100),认为前一个堆块(B)是空闲的,于是触发Backward Consolidation(向后合并)
  6. glibc 将“想象中空闲的 B 块”与 C 合并,放入 Unsorted Bin。但实际上 B1 仍在被使用!
  7. 此时,我们再次分配一个大小为 0x100 的块 D,它会从合并后的大块中切出。由于 B1 仍在这个范围内,D 和 B1 发生了内存重叠!

分配 A, B, C

释放 B 进 Unsorted Bin

A 触发 Off-by-one
修改 B size 低位为 00
清除 C 的 PREV_INUSE

分配 B1 从 B 中切割
B1 仍在使用中

释放 C
触发 Backward Consolidation

glibc 误以为 B 是空闲的
将 B 和 C 合并

分配大块 D
D 与 B1 内存重叠!

通过修改 D 的内容
即可篡改 B1 的结构
实现任意地址写

3. 绕过指针加密:Tcache Poisoning 实战

在 glibc 2.32+ 中,Tcache 引入了Safe-Linking机制。Tcache 链表中 chunk 的fd指针不再直接指向下一个 chunk 的地址,而是:
fd_encrypted = (chunk_addr >> 12) ^ next_chunk_addr

3.1 利用前提

通过上述的 Off-by-one 制造堆重叠后,我们可以控制一个在 Tcache 链表中的 chunk 的fd指针。

3.2 构造 Poisoning

假设我们通过重叠块 D,可以修改处于 Tcache 链表中 B1 的fd指针。我们想将下一个分配指向target_addr

  1. 泄露堆基址:由于 B1 的fd当前是加密状态,我们可以读取它。如果 B1 是链表尾部,next_chunk_addr为 0,则fd_encrypted = (chunk_addr >> 12) ^ 0 = chunk_addr >> 12。我们读取这个值,即可获得堆基址的高位(堆地址右移 12 位)
  2. 计算伪造密钥:得知堆基址后,我们计算key = B1_addr >> 12
  3. 加密目标地址:我们想分配到target_addr,则计算fake_fd = key ^ target_addr
  4. 写入伪造指针:通过重叠块 D,将B1->fd修改为fake_fd
  5. 连续调用两次malloc,第二次malloc返回的便是target_addr,实现任意地址写。

4. 综合实战:从漏洞触发到 Getshell 的闭环

将上述技术组合起来,面对一道高版本的综合 PWN 题,我们的完整思维链路如下:

  1. 漏洞识别:发现程序存在 Off-by-one(或单字节溢出)。
  2. 堆风水布局:分配 A-B-C,通过 Off-by-one 修改 B 的 size 和 C 的 prev_inuse。
  3. 制造重叠:切割 B,释放 C 触发合并,分配 D 与 B1 重叠。
  4. 获取原语:将 B1 释放进 Tcache。利用重叠块 D 读取 B1 的fd,泄露堆基址;然后修改 B1 的fd为加密后的__free_hook(glibc ≤2.33) 或_IO_list_all(glibc ≥2.34)。
  5. 劫持控制流
    • 如果 ≤2.33:分配到__free_hook,写入system,释放包含/bin/sh的块。
    • *如果 ≥2.34*:分配到_IO_list_all,伪造 House of Apple 链,调用exit触发 Getshell。
  6. (如果在栈题中):利用 fmtstr 泄露 Canary -> 精确覆盖 -> ROP 到system或 ORW 链。

5. 总结与下篇预告

5.1 核心知识点总结

  1. Canary 绕过:格式化字符串是泄露 Canary 的最佳利器,掌握%N$p定位和末尾\x00特征识别是关键。
  2. Off-by-one 破坏力:一个\x00足以改变堆块的合并逻辑,通过 Poison Null Byte 制造堆块重叠,是现代堆题的核心起手式之一。
  3. Safe-Linking 破解:利用 Tcache 链表尾节点泄露堆基址右移 12 位的值,是完成高版本 Tcache Poisoning 的必经之路。

5.2 下篇预告

在下一篇中,我们将挑战更复杂的叠加场景:

  • UAF + Tcache Poisoning:更直接的堆劫持,结合 IO_FILE (House of Apple) 打通 glibc 2.34+。
  • 多漏洞叠加(fmt + UAF + ORW):模拟 2022 年后的高频赛事题型,当程序既有格式化字符串又有 UAF,且开了沙箱时,如何规划最优利用路径?

结语:无论是栈上的 Canary,还是堆上的 Safe-Linking,防护机制的本质都是“引入未知随机性”。而攻破它们的秘诀,就是寻找一切机会“泄露这种随机性”。掌握了 Off-by-one 制造重叠和 fmtstr 泄露,你就拥有了在现代保护机制下撕开裂口的战术组合。