
off-by-null这个话题在pwn圈子里被聊了快十年但每过一阵都会有新人踩进同一个坑里。说它是堆利用的“入门必修课”一点都不夸张——它只溢出一个字节却能把glibc的堆管理机制搅得天翻地覆。这里说的“还没有涉及高版本”指的是一般glibc 2.23及以下的环境。这个版本没有tcache机制fastbin、unsorted bin、smallbin的检查相对朴素非常适合搞懂堆利用最底层的逻辑chunk结构、prev_inuse位、unlink、向前/向后合并。如果你能在这个版本下把off-by-null的原理和利用路径理清楚之后再去碰2.27以上带tcache的题目会顺很多。这篇文章适合已经对堆分配、malloc/free基本流程有概念的读者。如果你是零基础建议先把chunk结构、bins分类、top chunk这些补一补再回来看效果会好很多。1. off-by-null到底是什么从堆布局看单字节的杀伤力1.1 malloc chunk的二进制布局与prev_inuse位在glibc的实现里每个chunk都有一个16字节的头部64位环境下prev_size如果前一个chunk空闲这个字段保存前一个chunk的大小如果前一个chunk在使用这个字段可以被前一个chunk复用。size当前chunk的大小低3位是标志位其中最低位是prev_inuseP位表示前一个chunk是否正在使用。P位的作用非常关键。glibc在释放当前chunk时会先检查自己的P位P位为1说明前一个chunk在用不需要做向前合并P位为0说明前一个chunk空闲此时glibc会读取当前chunk的prev_size找到前一个空闲chunk把两个chunk合并成一个大的空闲块。off-by-null的攻击点就在这里。假设当前chunk的size是0x121内存里小端存放为21 01 00 00 00 00 00 00。如果你只向最低字节写入0x00size会变成0x100。注意不只是P位被清零整个size都变了——原本chunk的边界从0x120收缩到了0x100。这就是为什么off-by-null被称为“毒化单字节”它同时做了两件事伪造了前一个chunk为“空闲”并且把当前chunk的实际大小改小了。单字节写入0看似克制实际上是精准地命中了一个堆管理器的核心信任假设P位是由前一个chunk的分配/释放状态自动维护的glibc不会去反向验证。而利用者恰恰就是利用这个“不验证”把一个绝对不可能发生的状态喂给了堆管理器。1.2 常见触发场景off-by-null的触发点特别多常见的有read(fd, buf, size)读入实际数据刚好粘着下一个chunk的headerstrcpy/strcat/sprintf这类字符串函数拷贝后自动补一个\x00手动赋值时下标计算错误多写了一个字节的0某些自定义的序列化、编码逻辑在缓冲区末尾加结束符。看个典型伪代码char *a malloc(0x108); char *b malloc(0x110); ... read(0, a, 0x109); // 你以为自己只写0x108实际多了1字节malloc(0x108)实际拿到的user data长度是0x108从用户指针开始数最后一个可写字节刚好是下一个chunk的size字段的前一个字节。多写1字节就精确覆盖了B的size低1字节。写到这里顺便说一个很多人会搞混的点覆盖到的是“B的size首字节”而不是“prev_size首字节”。因为glibc分配时会把请求大小加SIZE_SZ后对齐实际usable size比理论上的“size - 0x10”大8字节。这个多出来的8字节正好能把下一chunk的prev_size和size都框在你的写范围内。如果你在调试时发现写完指针后看到的是prev_size变了多半是测试代码的malloc大小没选对。2. 低版本环境下为什么要用它没有tcache的利用思路2.1 为什么选2.23堆机制更“朴素”glibc 2.23是CTF里最常见的老牌环境Ubuntu 16.04原生用的就是它。这个版本没有tcache释放小chunk时要么进fastbin要么进unsorted bin要么进smallbin每个bin的管理逻辑都清清楚楚。对比一下2.27以上的环境tcache的引入给堆利用塞进了一条“快车道”一个空指针放松检查、一个没清理的key就能做tcache dup利用成本低很多。但问题也在这里——很多人在高版本上能轻松打穿题回到2.23却一头雾水。因为高版本里你绕过的是“附加规则”而2.23逼你把glibc原始的链表维护逻辑搞清楚。所以学off-by-null选2.23是合适的。先不看高版本那些花活把核心原理吃透。2.2 一切都是为了制造overlapping chunkoff-by-null本身不是最终目标最终目标基本都指向一个东西overlapping chunk也就是重叠内存块。有了重叠chunk你可以通过一个已分配chunk的合法读写去修改另一个正被程序引用chunk的内容把一个正在使用的大chunk的头部改掉释放后进入bins再通过分配重新拿回来修改某些关键函数指针比如__free_hook劫持控制流。一句话概括overlap是很多堆利用链的地基。off-by-null是打地基最经典的工具之一。2.3 两种实现路径unlink与chunk shrink低版本下利用off-by-null通常走两条路。第一条是unlink。通过P位清零触发向前合并让glibc去unlink一个位于当前chunk前面的“假chunk”。unlink过程中会对假chunk的fd和bk做检查即FD-bk P BK-fd P。如果你能在可控区域内伪造一个chunk让fd和bk都指向它自身就能绕过检查。这种思路一般用于把目标区域释放进unsorted bin或者配合全局指针做任意写。第二条是chunk shrink也就是把当前chunk的size改小让堆管理器认为当前chunk的结束地址比实际小从而在堆里留下一个“幽灵区域”——这个区域既没有被释放堆管理器也不认识它但程序仍然持有指针。实际题目中更多是把两条结合起来先用chunk shrink制造尺寸差再用fake chunk配合P位清零完成合并最终形成的重叠区域越大后续利用空间就越宽裕。2.4 一个容易被忽视的点prev_size怎么来很多人第一次试off-by-null都会踩同一个坑只记得把下一chunk的size清Pfree之后直接报错corrupted size vs. prev_size。原因很简单size被清P后glibc会认为前一个chunk空闲并去读当前chunk的prev_size。这个值如果不对合并逻辑立刻校验失败。所以利用off-by-null时prev_size同样需要你提前布置好。好消息是很多场景下prev_size恰好落在你能够正常写的范围内。比如malloc(0x108)时下一chunk的prev_size正好在a 0x100处而你的user data range覆盖到了a 0x107。也就是说正常情况下你就能控制prev_size只有size需要通过溢出来写。这就让整个利用链非常顺滑。3. 动手验证2.23环境下的最小可复现demo3.1 环境准备我用的环境是glibc 2.23可以直接起一个Ubuntu 16.04的docker搞定docker run -it --rm ubuntu:16.04 bash apt update apt install -y gcc gdb python-pip pip install pwntools调试工具强烈建议pwndbg看heap、bins、unsorted bin都非常直观。git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh准备好这些就可以开始复现了。3.2 内存布局设计布局是这种利用手法的灵魂。我设计的测试代码如下#include stdio.h #include stdlib.h int main() { setbuf(stdout, NULL); void *a malloc(0x108); void *b malloc(0x110); void *c malloc(0x100); printf(a%p\nb%p\nc%p\n, a, b, c); printf(usable size of a: 0x%zx\n, malloc_usable_size(a)); // fake chunk 放在 a 用户区偏移 0x30 处 size_t fake_addr (size_t)a 0x30; printf(fake chunk addr: 0x%zx\n, fake_addr); // fake chunk header *(size_t*)(fake_addr 0x00) 0; // prev_size *(size_t*)(fake_addr 0x08) 0xd0; // size必须等于 b-prev_size *(size_t*)(fake_addr 0x10) fake_addr; // fd *(size_t*)(fake_addr 0x18) fake_addr; // bk // 在 a 的可写范围内设置 b-prev_size *(size_t*)((char*)a 0x100) 0xd0; // b 的 prev_size 位于 a0x100 // 触发 off-by-nulla 的 usable size 是 0x108a[0x108] 越界到 b-size ((char*)a)[0x108] 0; // b-size: 0x121 - 0x100清除 prev_inuse printf(before free, b size0x%zx\n, *((size_t*)b - 1)); free(b); printf(after free, unsorted chunk at 0x%zx\n, fake_addr); return 0; }这段代码的核心是把几个关键地址之间的关系理清楚。假设运行时a的地址为0x...010b的地址为0x...120c的地址为0x...240。关系如下表项地址说明a的用户数据区0x...010长度为0x108可写范围为a0x00到a0x107a的chunk起始0x...000a chunk 0x10b的chunk起始0x...110a的chunk size是0x110b的prev_size0x...110即a0x100在a的可写范围内b的size0x...118即a0x108是off-by-null的落点fake chunk0x...040即a0x30在a的用户数据区内fake到b的间距0x...110 - 0x...040 0xd0用于设置fake size和b-prev_size所以fake_addr 0x8的size设置成0xd0同时b的prev_size也设置成0xd0free(b)时glibc在向前合并时就会走到fake chunk的位置。3.3 关键代码与调试编译运行gcc -o demo demo.c ./demo如果一切顺利程序会输出after free不会有任何glibc报错。此时用gdb检查堆gdb ./demo b *free0x0 run或者直接跑完后趁进程没退出用pwndbg的heap bins看看unsorted bin的状态pwndbg heap bins正常情况下unsorted bin里会出现一个起始地址为fake_addr、大小为0x1d0左右的chunk。为什么是0x1d0因为合并后的大小是fake chunk的0xd0加上被收缩后的b的size0x100再加起来正好0x1d0。在堆管理器的视角里原来a末尾到b之间的这一整块区域都被回收了而实际上a的用户数据区还在程序手里。我们care一下关键的一次检查b-prev_size 0xd0则向前合并的目标是b_pos - 0xd0正好是fake chunkfake chunk size 0xd0通过chunksize(P) ! prev_size校验fake chunk的fd和bk都指向自身通过unlink的FD-bk P BK-fd P校验同时unlink写回过程中只是把自己写回自己不会破坏其他关键数据。这就是一个标准、干净、可复现的off-by-null合并demo。3.4 合并成功之后overlap验证与后续利用方向合并成功只是第一步重点在于合并之后造成的overlap。继续往下写void *d malloc(0x100); printf(d%p\nb%p\n, d, b);因为unsorted bin里现在有一个从fake chunk开始的0x1d0大小的空闲块malloc(0x100)会从这个大块里切出一个0x110大小的chunk返回。于是d的用户指针大约在fake_addr 0x10也就是a 0x40。而b的用户指针在a 0x110。这两个地址的关系是d的chunk范围覆盖了a 0x40到a 0x150左右而b的数据区从a 0x110开始。也就是说d完全有能力写到b的整个数据区甚至写到b的header。这就是重叠。有了overlap后续的利用路线就开放了如果能先leak libc可以d修改b的size把它改成fastbin大小再free(b)后配合fastbin dup把__free_hook的地址写进fastbin链表如果没有单独的leak原语可以通过unsorted bin的fd/bk读地址把主线程arena附近的地址dump出来再算libc基址也可以把合并后的大块二次分解制造更精细的布局去改__malloc_hook或者__free_hook。这些属于“拿到overlap之后的下半场”。作为初探我建议先把前半场——布局、溢出、合并、验证overlap——练到闭眼能写再往后推进。4. 常见问题与排错记录4.1 free之后直接crash怎么办这是最常见的失败。不同报错对应的原因不一样列一个速查表报错内容问题定位corrupted size vs. prev_size while consolidatingfake chunk的size和b-prev_size不匹配corrupted double-linked listfake chunk的fd/bk指向错误unlink检查没过free(): invalid pointerb的位置不对可能计算偏移时少算了prev_sizemalloc(): memory corruption覆盖size后b的结束地址和c的起始地址对不上调试思路很简单在free处打断点用gdb的x/20gx看b header、prev_size、fake chunk这三处的内存布局逐项对着校验。只要它们三者自洽这个free就一定能过。4.2 为什么unsorted bin里看不到合并chunk一种情况是b的大小其实小于fastbin阈值。比如把b的size改成了0x70以下free(b)后它不会进unsorted bin而是直接进fastbin。2.23没有tcache但这不代表所有off-by-null都一定会合并进unsorted bin需要保证合并后的目标chunk大于0x80。另一种情况是合并后紧接着与top chunk合并了。如果在b后面没有guard chunk也就是demo里的cfree(b)时glibc发现top chunk的prev_inuse为0会直接把合并后的chunk再并入top。这样你在bins里当然看不到它它变成top的一部分了。所以guard chunk是必须的除非你后续就是想通过这种方式控制top chunk的size那是另一种更进阶的玩法。4.3 高版本迁移的提示最后说一句版本。2.23的off-by-null是“裸奔”的你亲手处理每一个链表的校验。到了2.27以上有了tcacheoff-by-null往往可以直接配合tcache poisoning甚至可以绕过一些原本棘手的检查。但核心思想没变先通过溢出改size、伪造prev_size、完成合并、制造overlap。只要这个底层理解透了高版本只是多几层规则的问题。我个人在实际操作中的体会是off-by-null这东西纸上谈兵没有用必须自己动手把demo跑通、把gdb里的布局看清楚、把每个报错原因搞明白。建议就从本文这个最小demo开始跑通之后再去刷how2heap里的poison_null_byte和house_of_einherjar相关示例最后找几道glibc 2.23的老题练手。等你把“合并之前需要哪些条件”背都背不下来的时候说明你真的会了。