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_MMAPPED(M位),表示这个chunk是否由mmap直接分配。第三位是NON_MAIN_ARENA(A位),表示这个chunk是否属于非主分配区。因为chunk的大小一定是16字节对齐的(64位系统下是16字节,32位系统下是8字节),所以size字段的低三位天然就是0,glibc就拿来当标志位用了。
这个设计直接影响了利用方式。比如在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字段为0x41(0x40大小加上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开始,引入了一个新的机制叫tcache(thread 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,对齐到0x20),size字段的值是0x21。我在计算偏移的时候经常多算或少算8字节,导致覆盖到了错误的位置。后来我养成了一个习惯:在gdb里用堆分析插件确认每个chunk的实际地址和大小,不再靠心算。
第二个坑是fastbin的LIFO顺序搞反了。我以为free的顺序和malloc的顺序是一样的,结果构造double free的时候链表顺序完全不对。后来我在纸上画了一个简单的栈式结构图,每次free就压栈,每次malloc就弹栈,才把这个逻辑理顺。这个看似简单的顺序问题,在构造复杂的利用链时非常关键。
第三个坑是忽略了tcache的存在。我按照fastbin attack的思路构造利用链,结果发现malloc返回的地址和我预期的不一样。后来才意识到glibc 2.26之后tcache优先于fastbin,free的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的位置可能离真正的错误点很远。所以我的建议是:每写一步都验证一步,不要等到最后再调试。堆漏洞利用的调试成本很高,越早发现问题越好。