1. 一个字节能改变堆的“世界观”
1.1 off-by-null到底是什么
做堆题做到一定阶段,你会发现真正让人头疼的往往不是UAF那种“明牌”漏洞,而是off-by-null这种只有一个字节的暗伤。它指程序在向堆缓冲区写入数据时,多写了一个字节的\x00。常见触发场景有三个:一是strcpy/sprintf这类字符串函数在结尾自动补终止符;二是read/recv之后程序员手动执行buf[len] = '\0';三是循环拷贝时边界判断少了1,导致末尾多余写一个0。这个额外的空字节看起来人畜无害,但若它恰好落在下一个堆块的size字段上,整个堆管理器的判断逻辑就会从根上歪掉。
很多人理解off-by-null时,喜欢把它和off-by-one混在一起说。我的建议是分开记:off-by-one是越界写任意一个字节,而off-by-null固定越界写0x00。别小看这个限制,正因为它只能写0,攻击者没有办法随意把size改成别的值,只能把某个字节“抹掉”。于是整个利用思路就变成:想办法让这个被抹掉的0,正好落在size字段的低位字节上,从而把一个合法的chunk size变成另一个看起来合法、但物理边界完全错位的size。
这篇文章面向的读者,是已经知道malloc/free、见过unsorted bin、fastbin这些名词,但始终没搞懂off-by-null到底怎么打的新手。我会用glibc 2.23环境,走一个最经典、最不容易被高版本安全检查干扰的完整流程,带你把“一个\x00如何变成堆重叠、再变成任意写”的链路彻底打通。
1.2 为什么选低版本glibc练手
标题里特意写了“还没有涉及高版本”,不是偷懒,而是刻意为之。glibc 2.29之后的版本增加了不少针对堆利用的硬校验,比如对top chunk size的检查、对unsorted bin中chunk size与next chunk的一致性检查、以及对_int_free中更多边界条件的校验。这些检查会把很多低版本时代“理所当然”的利用步骤直接拦死。初学者若是直接拿高版本环境去试,会发现明明每一步都照着文章做,结果程序偏偏在某个malloc_printerr报错退出,非常打击信心。
glibc 2.23是我最推荐的学习版本,原因有三:第一,它没有tcache,bin结构只有fastbin、unsorted bin、small bin、large bin,逻辑更简洁,便于理解堆管理的本质;第二,free和malloc的检查相对宽松,off-by-null之后触发的向前合并(后向合并)不会遇到太多额外拦截;第三,当时的CTF题目多、教程多、工具链成熟,踩坑经验可以互相印证。等你在2.23上把这套链路玩明白了,再去看高版本的绕过方案,会轻松很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先看明白堆chunk的物理结构,再谈攻击
2.1 chunk头、预处理位、申请与可用长度的微妙关系
要理解off-by-null为什么可怕,先得把堆chunk的内存排布刻在脑子里。64位下,一个已分配的chunk结构是这样的:
| 偏移 | 字段 | 说明 |
|---|---|---|
| +0x00 | prev_size | 仅当前一个chunk空闲时才有意义 |
| +0x08 | size | 当前chunk大小,低3位是标志位 |
| +0x10 | user data | malloc返回的指针指向这里 |
size字段的低3位分别是PREV_INUSE(第0位)、IS_MMAPPED(第1位)、NON_MAIN_ARENA(第2位)。正常情况下,siz
