off-by-null堆利用:单字节溢出到overlapping chunk的完整解析

发布时间:2026/10/27 2:52:20

off-by-null堆利用:单字节溢出到overlapping chunk的完整解析 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的老题练手。等你把“合并之前需要哪些条件”背都背不下来的时候说明你真的会了。
延伸阅读

更多相关文章

2026/10/26 20:17:55

MySQL日期维度表设计:公历农历双表构建200年日历数据

简介:面向MySQL开发者与数据统计人员的日历数据表资源,内含两个SQL脚本,分别创建公历表和农历表,完整覆盖1900—2100年共200年的日期数据。公历表设计有 week_day 、 is_weekend 、 is_holiday 等字段,农历表包含…

2026/10/24 6:26:35

MySQL 8.0高性能实战:索引、事务与主从复制全解析

高性能MySQL和普通MySQL的差别,很多时候不是版本高低,而是从“能跑”变成“知道它为什么快、为什么慢”。这几年我一直在做数据库相关的企业级应用,最深的体会是:面试题里背过的索引、事务、锁,到了生产环境每一个都会…

2026/10/24 0:12:28

MySQL从入门到精通:从环境搭建到SQL优化的完整学习指南

MySQL 是很多人接触到的第一套关系型数据库,也是后端项目里出现频率最高的数据存储方案。如果标题里的“从入门到精通”让你有点焦虑,先放轻松:这个目标不需要你背下所有命令,而是要把环境、SQL 基础、查询进阶、索引和事务这条主…

2026/10/27 2:47:46

Unity物理绳子实现:ConfigurableJoint关节链深度解析

1. 项目概述:为什么一根“会动的绳子”在Unity里比看起来难得多在Unity3D项目里,当你需要让角色拽着一根软软的缆绳攀爬、让吊灯在风中自然摇晃、或者让机械臂末端拖着柔性导管运动时,你很快会发现——Unity自带的LineRenderer画出来的只是一…

2026/10/27 2:47:46

机器人技术十年演进:从传感器革命到大模型赋能

1. 从实验室走向产业化的十年征程2015年那个闷热的夏天,我在深圳华强北电子市场的地下仓库里,第一次见到了能自主规划路径的物流机器人原型机。这个由三个大学生用树莓派和二手电机拼凑的装置,在堆满纸箱的过道里跌跌撞撞地行进,却…

2026/10/27 2:47:46

无人机道路损坏识别数据集与分割模型实践

1. 数据集项目概述这个无人机视角路面损坏和冲刷道路滑坡预警识别分割数据集,是一个专门为道路基础设施监测设计的专业图像数据集。它包含了579张高分辨率无人机航拍图像,覆盖了四种典型的道路损坏和自然灾害类别。作为一名长期从事计算机视觉和基础设施…

2026/10/27 2:47:46

Linux内核模块开发实战:Rootkit源码深度解析与调试

简介:这是一份面向Linux内核安全与逆向学习者的轻量级Rootkit开发实践资源,适用于具备C语言基础和Linux系统调用认知的中级开发者,用于理解内核模块加载、进程/模块隐藏等典型Rootkit技术原理。压缩包共46个文件,总大小仅36KB&…

2026/10/27 2:47:46

基于知识蒸馏的高光谱图像轻量化分类方案

1. 项目背景与核心价值高光谱图像分类是遥感领域的重要研究方向,相比传统RGB三通道图像,高光谱图像包含数十甚至数百个连续光谱波段,能够提供更丰富的地物特征信息。然而这种数据优势也带来了计算复杂度高、模型参数量大等实际问题&#xff0…

2026/10/27 2:42:45

LeetCode 829题解:连续整数求和的数学与算法

1. 问题背景与理解LeetCode 829题"连续整数求和"要求我们找出所有可能的连续正整数序列,这些序列的和等于给定的整数n。这道题看似简单,但蕴含着数学规律和编程技巧的结合。我们先从一个具体例子入手理解题目要求:假设n15&#xff…

2026/10/26 0:20:20

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/26 1:53:47

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/27 0:59:21

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价

“模式选错”的典型代价 摘要 在软件工程中,设计模式、架构模式、并发模式乃至团队协作模式的选择,往往在项目早期就决定了后续的开发效率和维护成本。本文通过多个真实场景,剖析“模式选错”带来的典型代价——包括返工成本、技术债务累积、…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式20_新手推荐路径

新手推荐路径 摘要 在 AI 辅助开发工具快速普及的今天,新手面对最多的困惑往往不是"工具够不够强",而是"我该从哪一步开始用"。本文提出一条经过实践验证的三阶段学习路径:先从 Ask(问答式交互)起…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑