
1. 从一次内存越界说起堆漏洞利用到底在解决什么问题很多人第一次接触堆漏洞利用脑子里冒出来的第一个念头是栈溢出还没玩明白怎么又来个堆我当初也是这个反应。栈上的缓冲区溢出你至少能看到函数返回地址被覆盖调试器里一眼就能定位到偏移量。但堆不一样堆是一大片由分配器动态管理的内存区域没有固定的返回地址给你覆盖也没有清晰的栈帧结构给你参照。你面对的是malloc、free、chunk、bin这些概念以及一个看起来杂乱无章的内存布局。但恰恰是这种“杂乱”让堆漏洞利用成为二进制安全领域里最有意思、也最能体现攻击者创造力的一块内容。堆漏洞利用的核心不是简单地覆盖某个地址而是通过操纵内存分配器的内部数据结构让程序在后续的malloc或free操作中按照攻击者的意图去读写任意内存。换句话说你不是在直接改写控制流而是在“欺骗”分配器让它帮你完成任意地址写。这个实验的核心价值在于它强迫你理解glibc malloc的底层实现。你平时写代码调用malloc(16)觉得这就是个黑盒给我一块内存就完事了。但当你需要利用堆漏洞的时候你必须知道这16字节背后发生了什么——分配器从哪里切的块、chunk header里存了什么、free之后这块内存去了哪个bin、下一次malloc的时候又从哪里取。这些细节平时写业务代码根本不会关心但在漏洞利用中每一个字段都是可以利用的抓手。适合谁来学这块内容我的判断是你已经能熟练使用gdb进行调试看得懂汇编理解栈溢出的基本原理和ret2libc、ROP这类基础利用手法。如果你连栈溢出的利用链都还没亲手写过一遍直接上堆漏洞会非常痛苦因为堆利用的调试复杂度比栈高出一个量级。另外你需要对C语言的内存模型有基本认知知道什么是指针、什么是结构体对齐、什么是内存页。这些不是可选项是前置条件。这篇文章会围绕堆漏洞利用的完整学习路径展开从glibc malloc的核心机制讲起然后逐一拆解几种经典的堆漏洞类型和对应的利用手法接着给出调试技巧和实操建议最后分享一些我在学习和实践过程中踩过的坑。内容会尽量贴近实际动手的场景不会只停留在概念层面。2. glibc malloc的chunk管理机制利用的基石2.1 chunk的内存布局与size字段的低三位玄机要理解堆漏洞利用第一步必须搞清楚chunk在内存里长什么样。当你调用malloc(24)的时候分配器实际给你的内存块比24字节要大。一个已分配的chunk包含两部分chunk header和用户数据区。在64位系统上chunk header占16字节分别是prev_size和size字段各8字节。prev_size只在物理相邻的前一个chunk空闲时才有意义用来记录前一个chunk的大小size字段则记录了当前chunk的总大小包括header本身。size字段有个非常关键的设计它的低三位被复用为标志位。最低位是PREV_INUSE简称P位表示物理相邻的前一个chunk是否正在被使用。第二位是IS_MMAPPEDM位表示这个chunk是否由mmap直接分配。第三位是NON_MAIN_ARENAA位表示这个chunk是否属于非主分配区。因为chunk的大小一定是16字节对齐的64位系统下是16字节32位系统下是8字节所以size字段的低三位天然就是0glibc就拿来当标志位用了。这个设计直接影响了利用方式。比如在off-by-one漏洞中你只能溢出一个字节恰好能覆盖到下一个chunk的size字段的最低位。如果你把这个P位从1改成0分配器就会认为前一个chunk是空闲的从而在free当前chunk的时候触发向前合并操作。这就是经典的off-by-one null byte漏洞的利用起点。注意很多初学者在调试时看到size字段的值是0x31、0x41这种以为是chunk大小就是49或65字节实际上去掉低三位的标志位之后真实大小是0x30和0x40也就是48和64字节。这个细节在计算偏移的时候非常容易搞错。2.2 fastbin、smallbin、largebin与unsortedbin的分流逻辑free一个chunk之后它会根据大小被放入不同的bin中。理解这个分流逻辑是利用堆漏洞的前提。fastbin是最小的一类bin专门管理大小在0x20到0x80之间64位系统默认情况下的chunk。fastbin的特点是采用单链表结构LIFO后进先出顺序而且free的时候不会清除下一个chunk的PREV_INUSE位也不会触发合并操作。这意味着fastbin里的chunk可以保持一种“假空闲”状态为double free和fastbin attack提供了土壤。smallbin管理的是小于0x400字节的chunk不包括fastbin范围采用双向循环链表FIFO顺序。unsortedbin是一个特殊的bin当你free一个不属于fastbin范围的chunk时它首先会被放入unsortedbin。下一次malloc的时候分配器会先检查unsortedbin如果找到合适大小的chunk就直接返回否则把它归类到对应的smallbin或largebin中。largebin管理的是大于等于0x400字节的chunk结构更复杂维护了两个双向链表。这个分流逻辑的关键在于不同bin的分配和回收策略不同利用方式也不同。fastbin attack利用的是fastbin单链表和缺乏完整性检查的特点unsortedbin attack利用的是unsortedbin在分配时会将chunk从链表中摘除并写入bk指针的机制largebin attack则利用了largebin中复杂的链表操作。2.3 从malloc到free一次完整的内存分配生命周期让我用一个具体的例子把整个流程串起来。假设程序依次执行以下操作void *a malloc(0x30); // 分配chunk A void *b malloc(0x30); // 分配chunk B void *c malloc(0x30); // 分配chunk C free(b); // 释放chunk B void *d malloc(0x30); // 再次分配第一步malloc(0x30)时分配器从top chunk上切下一块chunk A的size字段为0x410x40大小加上P位1。第二步malloc(0x30)时从top chunk继续切chunk B的size也是0x41。第三步同理。此时chunk A、B、C在内存中物理相邻。free(b)的时候因为0x40大小属于fastbin范围chunk B被放入fastbin链表中。此时chunk B的fd指针指向fastbin链表的头部可能是NULL而且chunk C的PREV_INUSE位不会被清除因为fastbin不触发合并。再次malloc(0x30)时分配器首先检查fastbin中是否有合适大小的chunk。有就是chunk B于是直接返回chunk B。注意此时返回的指针和之前free的b是同一个地址。这个流程看起来简单但每一步都藏着可以利用的细节。比如fastbin的LIFO特性意味着如果你连续free两个chunk再malloc拿到的是后free的那个。再比如如果你能控制fastbin中某个chunk的fd指针下一次malloc就能返回任意地址。3. 几类经典堆漏洞的成因与利用路径3.1 fastbin double free最简单的入口最深的坑Double free是堆漏洞里最容易理解的一种同一个chunk被free了两次。但glibc对fastbin的double free有一个基本的检查如果fastbin链表的头部chunk就是你正要free的chunk就会报错。所以直接连续free同一个指针是不行的。绕过方法很经典free(a)free(b)再free(a)。这样fastbin链表变成了a - b - a。当你第一次malloc的时候拿到a第二次malloc拿到b第三次malloc又拿到a。此时a同时在fastbin链表里和用户手中你可以往a里写数据修改它的fd指针下一次malloc就能分配到任意地址。这个利用路径的关键在于fastbin的分配检查只验证了链表头部chunk的size字段是否合法没有做更深入的完整性校验。所以你可以把fd指针指向一个伪造的chunk只要那个地址上的size字段看起来合理就行。实操中常见的坑是伪造的地址必须满足对齐要求而且size字段的值必须在fastbin范围内。很多人找了一个可写的目标地址但忘了在那个地址附近布置一个合法的size值导致malloc的时候直接crash。3.2 unsortedbin attack改写bk指针实现任意写Unsortedbin attack的核心思路是利用unsortedbin在分配时的摘链操作。当你free一个不属于fastbin范围的chunk时它会被放入unsortedbin。unsortedbin是一个双向链表chunk的fd和bk指针分别指向前后节点。关键点在于当分配器从unsortedbin中取出一个chunk时会执行类似这样的操作victim unsortedbin-bk; unsortedbin-bk victim-fd; victim-fd-bk unsortedbin;如果你能控制victim的bk指针就能在unsortedbin-bk victim-fd这一步把一个可控的值写入目标地址。虽然这个写操作的值是victim-fd不一定完全可控但足以覆盖某些全局变量比如把某个标志位改大或者覆盖global_max_fast来扩大fastbin的范围。这个攻击手法的限制在于写入的值是fd指针的值通常是一个堆地址不是任意值。所以它更适合用来做“把某个变量改成一个很大的数”这种操作而不是精确写入。3.3 off-by-one与chunk overlapping一个字节引发的连锁反应Off-by-one是指程序在写入时多写了一个字节通常是因为循环边界判断错误或者字符串没有正确处理结尾符。在堆场景下这一个字节可能恰好覆盖到下一个chunk的size字段的最低字节。如果这个字节是0x00那么下一个chunk的size字段的最低位P位被清零分配器会认为前一个chunk是空闲的。当你free下一个chunk的时候就会触发向前合并把“前一个chunk”也合并进来。但前一个chunk实际上还在使用中这就造成了chunk overlapping——两个指针指向了同一块内存区域。利用chunk overlapping你可以实现很多操作修改已分配chunk的size字段让它覆盖更大的范围或者构造出重叠的chunk使得free一个chunk之后另一个chunk的内容被改写。这个手法的利用链通常比较长需要精心布置多个chunk的大小和顺序。3.4 tcache poisoning现代glibc中最常见的利用方式从glibc 2.26开始引入了一个新的机制叫tcachethread local caching。每个线程有一个自己的tcache里面维护了64个bin每个bin最多缓存7个chunk。tcache的引入大大加快了小内存分配的速度但也带来了新的利用方式。tcache poisoning的核心是tcache的fd指针没有做任何完整性检查。你free一个chunk到tcache之后直接改写它的fd指针为目标地址下一次malloc同样大小的chunk时就会先返回这个chunk然后再返回你指定的目标地址。也就是说你可以在目标地址上获得一个可写的chunk。这个手法比fastbin attack更简单因为tcache的检查更少。但需要注意tcache的count限制每个bin最多7个chunk超过之后才会走fastbin或unsortedbin的逻辑。另外从glibc 2.29开始tcache的fd指针加入了key字段做校验直接改写fd会导致检测失败。绕过方法通常是先泄露heap地址然后伪造key值。4. 调试堆漏洞时那些教科书不写的实操细节4.1 用gdb插件看清堆的真实布局裸gdb调试堆非常痛苦因为你只能看到一片内存地址分不清哪个是chunk header、哪个是用户数据、哪个是空闲的。我强烈建议在调试堆漏洞时使用gdb的堆分析插件。这类插件可以以可视化的方式展示chunk的分布、bin链表的状态、tcache的内容。具体来说你需要关注几个信息当前所有已分配chunk的地址和大小、fastbin/tcache链表中每个chunk的fd指针、unsortedbin中chunk的fd和bk指针。这些信息在调试时能帮你快速定位问题。比如你构造了一个double free但malloc的时候crash了通过查看fastbin链表就能发现是不是fd指针被写坏了。另外一个实用技巧是在关键位置下断点比如malloc返回之后、free执行之前然后手动检查堆的状态。不要等到程序crash了再回头看那时候现场可能已经被破坏了。4.2 堆地址随机化对利用脚本的影响ASLR地址空间布局随机化对堆漏洞利用的影响非常大。堆的基地址每次运行都会变化这意味着你无法在脚本里硬编码地址。解决办法通常有两种一是通过信息泄露漏洞获取堆地址或libc基地址二是在脚本中动态解析。信息泄露是堆利用中非常关键的一环。常见的泄露方式包括利用unsortedbin的fd/bk指针泄露libc地址因为unsortedbin的链表头在libc的main_arena结构中、利用tcache的fd指针泄露堆地址。在写利用脚本时你需要先泄露地址计算出libc基地址和堆基地址然后再构造后续的利用链。提示在本地调试时可以临时关闭ASLR来简化分析。但最终的利用脚本必须能在ASLR开启的情况下工作否则就没有实际意义。4.3 利用脚本的编写节奏与调试方法写堆利用脚本和写栈溢出脚本的节奏完全不同。栈溢出的利用链通常比较线性填充缓冲区、覆盖返回地址、布置ROP链。堆利用则更像是在下一盘棋你需要精确控制每一步malloc和free的顺序以及每次写入的内容。我的建议是先用Python的pwntools框架搭好脚本骨架把交互函数封装好。然后分阶段调试第一阶段只做信息泄露确认能拿到正确的地址第二阶段构造double free或tcache poisoning确认能分配到目标地址第三阶段写入目标值并触发getshell。每个阶段都单独验证不要一口气写完再跑否则出了问题很难定位。另外堆利用脚本中经常需要处理输入输出的同步问题。比如程序在malloc之后会打印一个提示信息你需要精确接收这个信息再发送下一步的数据。如果同步没做好脚本会卡住或者发送错误的数据。5. 从实验环境到真实场景堆漏洞利用的边界与思考5.1 实验环境中的简化与真实软件的差异实验环境里的堆漏洞题目通常是精心构造的漏洞点明确、利用路径清晰、保护机制可能只开启了一部分。但真实软件中的堆漏洞要复杂得多漏洞可能隐藏在复杂的业务逻辑中堆的布局受到大量其他分配操作的影响保护机制可能全部开启。比如实验环境中你可能会遇到一个简单的菜单程序有add、delete、edit、show四个功能漏洞点就在edit功能中没有检查新输入的长度。但在真实软件中堆漏洞可能出现在解析器、序列化库、网络协议处理等场景中你需要先理解整个数据流才能定位漏洞。另外实验环境通常关闭了Full RELRO、PIE、FORTIFY_SOURCE等保护机制或者只开启了一部分。真实软件中这些保护往往全部开启你需要考虑如何绕过。比如Full RELRO意味着GOT表不可写你就不能通过改写GOT表来劫持控制流需要转向其他目标如__malloc_hook、__free_hook或栈上的返回地址。5.2 堆漏洞利用的常见防护机制与绕过思路现代glibc和编译器引入了多种针对堆漏洞的防护机制。除了前面提到的tcache key校验还有Safe-Linking从glibc 2.32开始fastbin和tcache的fd指针被加密存储加密方式是(address 12) | (address 48)。这意味着你不能直接写入目标地址需要先泄露堆地址才能计算出正确的加密值。Chunk size检查在free和malloc时增加了对size字段的合法性检查比如检查size是否对齐、是否在合理范围内。Double free检测tcache中增加了对double free的检测如果free的chunk已经在tcache中会触发报错。绕过这些防护需要更精细的利用手法。比如Safe-Linking的绕过需要先泄露堆地址然后计算出加密后的指针值。tcache double free的绕过通常需要先清空tcache中的某个bin或者利用其他chunk来间接实现。5.3 学习堆漏洞利用的进阶路线建议如果你已经完成了这个实验想继续深入堆漏洞利用我的建议是第一把glibc的malloc源码读一遍。不需要全部读懂但malloc、free、_int_malloc、_int_free这几个核心函数的逻辑要搞清楚。源码在glibc的malloc/malloc.c文件中配合调试信息看效果最好。第二刷题。可以找一些经典的堆漏洞题目来练手从最简单的double free开始逐步过渡到unsortedbin attack、largebin attack、house of系列。每道题都亲手写利用脚本不要只看writeup。第三关注glibc的版本变化。不同版本的glibc在堆管理上有差异比如tcache是2.26引入的Safe-Linking是2.32引入的。你写的利用脚本在2.27上能跑通在2.31上可能就失效了。理解版本差异是进阶的必经之路。第四学习真实漏洞的利用分析。可以找一些公开的CTF题目或者CVE漏洞分析来读看看别人是怎么在复杂的保护机制下完成利用的。这能帮你建立更完整的攻击思维。6. 我在堆漏洞实验里踩过的几个典型坑第一个坑是chunk size的计算错误。我一开始总是忘记size字段包含chunk header的16字节而且低三位是标志位。比如malloc(0x18)实际分配的chunk大小是0x20用户数据0x18加上header的0x8对齐到0x20size字段的值是0x21。我在计算偏移的时候经常多算或少算8字节导致覆盖到了错误的位置。后来我养成了一个习惯在gdb里用堆分析插件确认每个chunk的实际地址和大小不再靠心算。第二个坑是fastbin的LIFO顺序搞反了。我以为free的顺序和malloc的顺序是一样的结果构造double free的时候链表顺序完全不对。后来我在纸上画了一个简单的栈式结构图每次free就压栈每次malloc就弹栈才把这个逻辑理顺。这个看似简单的顺序问题在构造复杂的利用链时非常关键。第三个坑是忽略了tcache的存在。我按照fastbin attack的思路构造利用链结果发现malloc返回的地址和我预期的不一样。后来才意识到glibc 2.26之后tcache优先于fastbinfree的chunk首先进入tcache而不是fastbin。要触发fastbin的逻辑必须先把tcache对应的bin填满7个chunk。这个版本差异让我浪费了不少时间。第四个坑是信息泄露的地址计算错误。unsortedbin的fd和bk指针指向的是main_arena结构中的地址而main_arena在libc中的偏移量是固定的。我一开始直接用泄露的地址减去main_arena的偏移但忘了main_arena的地址是链表的头部地址不是chunk本身的地址。正确的计算方式是泄露的fd指针减去main_arena中对应bin的偏移得到libc基地址。这个偏移量在不同glibc版本中可能不同需要根据具体版本查表。第五个坑是脚本的交互同步问题。堆利用脚本通常需要和程序进行多次交互每次malloc、free、edit之后都要接收程序的输出。我一开始用recvuntil的时候没有匹配到正确的字符串导致脚本卡住。后来我在每次交互之后都打印一下接收到的数据确认同步正确再继续。这个习惯帮我节省了大量调试时间。这些坑看起来都是小问题但在实际调试中一个小错误就会导致整个利用链失败而且crash的位置可能离真正的错误点很远。所以我的建议是每写一步都验证一步不要等到最后再调试。堆漏洞利用的调试成本很高越早发现问题越好。