【学习记录】Week10(一):Off-by-one 单字节溢出——从一字节到全盘崩溃的堆溢出艺术
写在前面在 Week9 中我们系统攻克了 glibc 堆结构、堆风水、UAF 以及 Tcache Poisoning 等核心利用技术。从本周开始我们将进入 Week10 的学习聚焦于更细微、更隐蔽的内存破坏漏洞。今天我们要探讨的是二进制安全中极具艺术感的一种漏洞——Off-by-one单字节溢出。仅仅一个字节的溢出看似微不足道却在堆内存管理机制下能引发蝴蝶效应最终导致任意代码执行。 目录Off-by-one 漏洞原理与成因堆中的 Off-by-one低字节覆盖的威力经典场景覆盖prev_size触发后向合并现代场景Tcache 下的 Off-by-one 与堆重叠实战演练构造一个基础的 Off-by-one 利用链总结与下篇预告1. Off-by-one 漏洞原理与成因1.1 什么是 Off-by-oneOff-by-one 漏洞是指在操作内存如数组、缓冲区时越界读写了仅一个字节8 bit的错误。这种错误通常发生在循环边界条件判断错误或字符串操作函数使用不当的情况下。在栈溢出中单字节溢出往往只能覆盖保存的基址指针EBP/RBP的最低字节利用难度较高且受限。但在堆内存中由于堆块结构的特殊性一个字节的改变可以完全破坏堆管理器的元数据从而劫持控制流。1.2 常见触发场景Off-by-one 漏洞通常由以下编程错误引起循环边界错误最常见的错误使用了而不是。char buf[10]; for (int i 0; i 10; i) { // 错误应为 i 10 buf[i] getchar(); }字符串操作函数未截断strncpy或strncat在源字符串长度大于等于指定长度n时不会自动在末尾添加\x00。如果开发者误以为它会自动添加空字节终止符可能导致后续操作越界。char buf[10]; strncpy(buf, user_input, 10); // 如果 user_input 长度 10buf 末尾无 \x00 // 后续如果对 buf 进行 strlen 或其他操作可能导致越界读/写Off-by-one 写入空字节开发者主动写入\x00但写错了位置。char buf[10]; memcpy(buf, user_input, len); buf[len] \x00; // 如果 len 10则 buf[10] 发生了单字节溢出2. 堆中的 Off-by-one低字节覆盖的威力在 glibc 的 ptmalloc2 中堆块是连续分配的。当一个堆块发生 Off-by-one 溢出时它覆盖的通常是下一个相邻堆块的元数据prev_size或size字段的最低位字节。让我们回顾一下 64 位系统下的 chunk 结构64位下大小为 0x10 字节的头部----------------------------------------------------------------- | prev_size (8 bytes) | size (8 bytes) | user_data ... | -----------------------------------------------------------------2.1 关键字段解析prev_size前一个 chunk 的大小。仅当前一个 chunk 处于空闲状态时此字段才有效。如果前一个 chunk 正在使用中这个字段属于前一个 chunk 的 user_data 区域可以用来存储数据。size当前 chunk 的大小。低 3 位用作标志位A(0x4): NON_MAIN_ARENAM(0x2): IS_MMAPPEDP(0x1):PREV_INUSE。当前一个 chunk 正在使用时该位为 1当前一个 chunk 空闲时该位为 0。2.2 Off-by-one 覆盖的两种情况假设 chunk A 正在使用chunk B 是紧随其后的下一个 chunk。我们对 chunk A 的 user_data 进行写入发生了 Off-by-one 溢出。情况一覆盖 chunk B 的prev_size最低字节由于 chunk A 正在使用chunk B 的prev_size字段实际上被 chunk A 用作存储数据。覆盖这个字节通常没有直接危害但如果配合后续的释放顺序可以伪造prev_size。情况二覆盖 chunk B 的size最低字节最致命Off-by-one 直接覆盖了 chunk B 的size字段的最低字节。这会产生两个极其强大的效果修改 chunk 大小改变了size的低 8 位相当于修改了 chunk B 的大小。修改标志位特别是修改P位PREV_INUSE。核心原理清零 PREV_INUSE 位如果我们通过 Off-by-one 将 chunk B 的size字段的最低字节清零例如写入\x00那么P位会被置为 0。这会欺骗堆管理器让它以为 chunk A前一个 chunk是空闲的当 chunk B 被释放进入 unsorted bin或触发合并机制时glibc 会执行后向合并检查P位发现为 0认为前一个 chunk 空闲。读取prev_size字段获取前一个 chunk 的大小。通过chunk_B_addr - prev_size计算出前一个 chunk 的头部地址。将该伪造的“前一个 chunk”从其所在的 bin 链表中摘除执行 Unlink 操作。将伪造的 chunk 与 chunk B 合并。这就是经典的Off-by-one 触发 Unlink利用链的根源。3. 经典场景覆盖prev_size触发后向合并在 glibc 2.23 及以前版本中Off-by-one 的标准利用思路就是触发 Unlink。虽然现代 glibc 加强了 Unlink 的检查但理解这一过程是掌握堆溢出的基础。3.1 利用条件存在 Off-by-one 漏洞能够覆盖下一个 chunk 的size字段的P位。能够伪造prev_size字段的值。能够满足 glibc 2.23 的 Unlink 检查条件已知一个指向 chunk 的指针。3.2 利用流程图布置堆块: A(可控) | B(目标) | C(防顶合并)利用 A 的 Off-by-one 漏洞覆盖 B 的 size 低字节, 清除 P 位伪造 B 的 prev_size使其指向伪造的 Fake Chunk释放 Chunk Bglibc 检查 P 位为 0触发后向合并对 Fake Chunk 执行 UnlinkUnlink 绕过检查后实现任意地址写覆盖 GOT 表或 Hook 函数获取 Shell3.3 详细步骤解析假设内存中有连续的 chunk A 和 chunk B。伪造 Fake Chunk在 chunk A 的 user_data 区域内伪造一个 fake chunk。我们需要知道一个指向 fake chunk 的指针ptr通常存储在全局数组或栈上。fake-fd ptr - 0x18(满足fd-bk ptr)fake-bk ptr - 0x10(满足bk-fd ptr)伪造 prev_size在 chunk A 的末尾即 chunk B 的prev_size处写入 fake chunk 的大小使得chunk_B_addr - prev_size fake_chunk_addr。触发 Off-by-one溢出一个\x00覆盖 chunk B 的size最低字节。如果原 size 为0x111覆盖后变为0x100P位被清零。释放 chunk B调用free(B)。glibc 认为 chunk A 空闲执行 Unlink 取出 fake chunk并合并。控制权获取Unlink 执行后ptr的值被修改为ptr - 0x18。之后我们可以通过编辑ptr利用程序的 edit 功能来修改全局指针进而实现任意地址读写。4. 现代场景Tcache 下的 Off-by-one 与堆重叠在 glibc 2.29 版本中Unlink 的检查变得极其严格且 Tcache 的引入改变了堆的释放流程。传统的 Off-by-one - Unlink 利用链变得困难。此时攻击者转向利用 Off-by-one 制造堆块重叠。4.1 堆重叠原理通过修改相邻 chunk 的size字段使其变大从而在后续分配时覆盖掉原本独立的下一个 chunk造成内存重叠。重叠的 chunk 可以用于泄露 libc 地址、修改 Tcache 的 next 指针等。4.2 利用步骤 (Tcache 场景)分配堆块分配 chunk A (0x18), chunk B (0x28), chunk C (0x88), chunk D (0x18 防顶合并)。修改 size利用 chunk A 的 Off-by-one 漏洞将 chunk B 的size从0x31修改为0x41增大了 0x10。释放并重新分配释放 chunk B它进入 Tcache[0x40]因为大小被改成了 0x40。再次申请 0x28 的内存glibc 会从 Tcache[0x40] 中取出 chunk B。此时我们获得了 chunk B 的控制权且程序认为它大小是 0x28。制造重叠在 chunk B 中写入数据覆盖原本属于 chunk C 的prev_size和size字段。申请 0x18 内存从 Tcache[0x40] 中分割出 chunk B 的剩余部分B2。此时如果我们释放 chunk C它仍然保留着原始的指针。但 chunk C 的头部已经被 chunk B 的数据覆盖。信息泄露与 Tcache Poisoning将 chunk C 释放进入 unsorted bin需填满 Tcache泄露 libc 地址。通过覆盖 chunk C 的 Tcache next 指针实现任意地址分配。5. 实战演练构造一个基础的 Off-by-one 利用链让我们通过一段简化的伪代码回顾 Off-by-one 导致 size 被修改的经典场景。5.1 漏洞代码示例#include stdio.h #include stdlib.h #include string.h void vulnerable_func(char *input) { char *chunk_a malloc(0x18); // 0x20 chunk char *chunk_b malloc(0x18); // 0x20 chunk char *chunk_c malloc(0x88); // 0x90 chunk防止与 top chunk 合并 // 漏洞点strncpy 不保证 null 终止如果 input 长度为 0x18 // 会导致 chunk_b 的 size 最低字节被覆盖为 \x00 strncpy(chunk_a, input, 0x18); // 如果 input 填满 0x18 字节chunk_b 的 size 将被破坏 // 假设原本 size 为 0x21 (PREV_INUSE1) // 覆盖后变为 0x20 (PREV_INUSE0)且大小看起来没变 free(chunk_b); free(chunk_c); free(chunk_a); } int main() { char input[0x20]; memset(input, A, 0x18); vulnerable_func(input); return 0; }5.2 GDB 调试分析在strncpy执行前堆内存布局如下pwndbg vis 0x555555559290 0x0000000000000000 0x0000000000000021 --- chunk_a (size0x21, P1) 0x5555555592a0 0x4141414141414141 0x4141414141414141 0x5555555592b0 0x4141414141414141 0x0000000000000021 --- chunk_b (size0x21, P1) 0x5555555592c0 0x0000000000000000 0x0000000000000000 0x5555555592d0 0x0000000000000000 0x0000000000000091 --- chunk_c (size0x91, P1)strncpy(chunk_a, input, 0x18)执行后发生了 Off-by-onepwndbg vis 0x555555559290 0x0000000000000000 0x0000000000000021 --- chunk_a 0x5555555592a0 0x4141414141414141 0x4141414141414141 0x5555555592b0 0x4141414141414141 0x0000000000000020 --- chunk_b (size 被覆盖为 0x20!) 0x5555555592c0 0x0000000000000000 0x0000000000000000 0x5555555592d0 0x0000000000000000 0x0000000000000091 --- chunk_c此时chunk_b的PREV_INUSE位被清零。如果后续触发free(chunk_c)glibc 会检查前一个 chunkchunk_b是否空闲从而可能触发错误合并或利用链。6. 总结与下篇预告6.1 核心知识点总结Off-by-one 是隐蔽的杀手虽然只能覆盖一个字节但在堆结构中覆盖size的低字节可以改变堆块大小和标志位引发严重后果。清零 PREV_INUSE 位是关键通过写入\x00欺骗 glibc 前一个 chunk 空闲从而触发后向合并和 Unlink 机制。从 Unlink 到堆重叠随着 glibc 版本升级Unlink 检查变严。Off-by-one 的利用重点从直接触发 Unlink 转向构造堆块重叠进而配合 Tcache Poisoning 进行利用。常见来源边界错误、strncpy不截断、主动写入\x00越界。6.2 下篇预告下一篇我们将深入探讨Unlink 基础利用与堆溢出覆盖 fd/bk的进阶技术包括glibc 2.23 与高版本 Unlink 检查机制对比如何构造满足检查的 Fake Chunk堆溢出直接覆盖 fd/bk 指针的利用技巧实战 CTF 例题解析最终结论Off-by-one 漏洞证明了在系统安全中哪怕是一个字节的疏漏在精妙的堆布局下也能成为撬动整个系统安全防线的支点。理解 Off-by-one 对size字段的修改及其引发的连锁反应是迈向高级堆利用的关键一步。参考文献CTF Wiki - Heap Exploitationglibc malloc.c 源码分析堆利用详解Off-by-one 与 Unlink