深入理解glibc堆管理:fastbin、double free与malloc_consolidate组合利用实战
1. 一道老题教会我的事sleepyHolder与三个堆知识点的组合拳最近在复盘积压的CTF笔记翻到了sleepyHolder这道题。它来自2016年的一场知名国际赛题目本身是个菜单式堆利用但把fastbin的检查机制、malloc_consolidate、double free三个知识点串在了一条完整利用链里。刚入门那会儿我盯着wp看了半天没看懂后来自己动手调了几遍才明白这几个机制放在一起有多巧妙——它不是简单的“送你一个UAF”而是逼着你把glibc堆管理器在fastbin和smallbin之间的转换逻辑吃透。这道题适合三类人看正在刷堆题想进阶的CTFer、想搞明白fastbin到底怎么检查double free的读者、以及被malloc_consolidate这个冷门机制劝退过的朋友。看完你会知道这三个词不是孤立考点而是glibc在内存管理上的一套“组合拳”。我尽量把每一步的为什么讲透而不是丢给你一段exp完事。先说结论sleepyHolder的利用核心是构造一个fastbin chunk触发malloc_consolidate把它并进unsorted bin从而在保留原指针的情况下拿到libc地址再通过double free和fastbin的size检查绕过实现任意地址写改写全局变量触发后门拿shell。整条链路里任何一个检查条件没满足程序都会当场崩给你看。2. 题目程序长什么样一个小型“持有者”管理器的漏洞入口2.1 菜单功能与内存模型sleepyHolder是一个典型的堆利用练习程序主流程是个循环菜单大概有创建create、释放delete和秘密secret三个操作。创建时会让你选择三种尺寸之一代码里通常定义为small、big、huge分别对应0x20、0x80、0x400这样级别的chunk大小有的版本还会搭配一个最大数量限制。这里的关键点是三种尺寸的chunk各有各的管理槽位但程序用的是一个全局数组来保存指针并且释放时只调用free不清空数组。也就是说你free了某个槽位之后数组里存的仍然是原来的堆地址。这个漏洞说白了就是UAFUse After Free——你手里还攥着一个指向已释放内存的指针之后无论你读还是写这块内存用的都是那个悬空指针。2.2 释放操作的三个常见陷阱很多新手看到free后不清指针第一反应是“直接double free”。确实可行但glibc 2.23以后的fastbin检查并不是吃素的。我在调试时整理了三个容易踩的点fastbin的double free检查glibc在free一个chunk时会检查它是否和fastbin头部的chunk是同一个地址如果是就报错这是为了防最简单的double free。chunk size校验malloc从fastbin取chunk时会校验取出的chunk的size是否属于该fastbin的大小范围否则直接abort。释放chunk与top chunk相邻时的合并如果你的释放操作让chunk和top合并了fastbin的布局会发生变化影响后续利用。在sleepyHolder里由于三种尺寸的槽位互不干扰程序允许你对同一个槽位反复申请和释放这就给了我们构造绕过检查的空间。真正巧的不是UAF本身而是怎么在fastbin检查的缝隙里完成double free又不触发abort。3. fastbin的检查机制以及它为什么拦不住double free3.1 glibc 2.23的fastbin检查到底检查了什么fastbin是glibc为小尺寸chunk默认最大0x80字节设计的高速缓存特点是单链表、LIFO、不进行合并。它牺牲了一些内存紧凑性换取了分配和释放的速度。因为简单所以它的保护机制也很朴素。在2.23版本中主要就两处free时检查if ((old p) || (unsigned long) (old) (unsigned long) (p))也就是判断当前释放的chunk是否与fastbin头部的chunk相同如果相同就报“double free or corruption (fasttop)”。malloc时检查从fastbin弹出chunk时会校验取出的chunk的size字段与fastbin索引区间是否匹配即if (__builtin_expect (fastbin_index (chunksize (victim)) ! idx, 0))不匹配就报“malloc(): memory corruption (fast)”。注意它并没有记录整个fastbin链上的所有chunk只检查头部那一个。所以经典的绕过方式很直接在两次free之间插入一次free或malloc操作让fastbin头部变成别的chunk就能骗过检查。具体做法是释放A此时fastbin头部A释放B头部B释放A此时检查头部是否等于A头部是B不等于通过这样fastbin链的顺序变成A - B - A形成了环。下次malloc两次就能拿回同一个A的地址两次从而得到一个重复的堆指针。这个手法就是fastbin dup。3.2 为什么尺寸检查反而帮了忙fastbin的size检查听起来很严格但在利用double free时它反而给我们提供了“制造fake chunk”的可能性。因为malloc从fastbin取chunk时只校验这个chunk的size字段落在对应fastbin区间内并不校验这个chunk是否真的被释放过、是否和别的chunk重叠。这意味着只要你把一个伪造的size值写进某个可控内存的合适位置然后想办法让fastbin链指向那个地址malloc就会把那个位置当作合法chunk返回。在sleepyHolder这道题里这个特性至关重要因为我们要改写的全局变量很可能落在.bss段而不是堆上。利用fastbin dup拿到一个指向栈或全局区的chunk指针后我们就能往那个位置写入受控数据把后门条件改掉。3.3 double free的实战构造细节我把构造过程拆解成三个动作照着做就行这里假设目标chunk是A和BA是small chunkB是big chunk申请Asmall、Bbig释放A。释放B。此时fastbin链是 B - A头部是B。再释放A。fastbin检查头部B不等于A通过。链变成 A - B - A。注意这里A和B的尺寸必须落在同一个fastbin区间吗不一定。fastbin不同的size桶是独立的只有同一size桶才会排成同一条链。因此如果你想构造A和B在同一个fastbin链上它们的实际chunk大小必须落在同一个fastbin分类里。在sleepyHolder中small和big差了很大通常需要先把big的大小改成small大小或者利用huge chunk重新调整。稳妥的做法是只用同一个size的chunk做double free比如都用small。如果控制不好malloc两次后得到同一个堆地址同时这个地址还保留在另一个槽位的全局指针里就出现了两个指针指向同一块内存的情况这就为后续任意地址写打下了基础。4. malloc_consolidate把fastbin变成unsorted bin的魔术时刻4.1 触发条件与内部行为malloc_consolidate是glibc在分配非fastbin chunk时的一个内部动作。如果你malloc的请求大小超过了fastbin的最大值比如申请一个0x400的huge chunkmalloc会先调用malloc_consolidate把fastbin中的所有chunk摘下来尝试合并相邻的空闲chunk然后把合并后的chunk放进unsorted bin。这个机制原本是为了减少内存碎片提高大块分配的效率。但在利用视角下它等于帮我们把fastbin里的chunk“升级”到unsorted bin链上。unsorted bin中的chunk的fd和bk指针会指向main_arena中的地址因此只要我们能读出这个chunk的内容就能泄露出libc的基址。sleepyHolder就借用了这个机制先构造好fastbin dup的状态然后申请一个big或huge chunk触发malloc_consolidate把fastbin链上那个已经被我们控制的chunk合并或迁移到unsorted bin。这里有个关键点如果fastbin中有两个chunk是相邻的consolidate会把它俩合并成一个更大的chunk放到unsorted bin如果它们不相邻那么每个chunk会单独进入unsorted bin。我们利用时通常希望看到合并且原来的chunk内容里出现main_arena相关的指针。4.2 为什么泄露的是main_arena而不是libc基址unsorted bin是一个双向循环链表当链上只有一个chunk时它的fd和bk都指向main_arena中的某个地址通常是main_arena88之类的偏移。由于libc基址和main_arena地址之间有固定的偏移因此只要拿到这个地址减去固定偏移就能得到libc基址。但要注意这个偏移在不同glibc版本中不一样。所以我当年在解题时第一步是确认题目给的libc版本然后从本地libc中用调试器看main_arena与基址的偏移。如果你不确定可以用p main_arena - libc_base来算或者直接查网上整理的偏移表。4.3 合并后的大小与指针的“残留”问题malloc_consolidate在合并fastbin chunk时会执行unlink操作把相邻的空闲chunk从fastbin或unsorted bin中摘下来然后合并成一个大的空闲块。这里有个很容易被忽略的坑合并后的chunk的size可能很大如果它与top chunk相邻可能直接并入top导致unsorted bin为空泄露就失败了。为了避免这个问题通常要让待合并的fastbin chunk不要紧挨着top chunk或者在其上方再保留一个已分配的chunk防止合并后直接并入top。在sleepyHolder里因为每个尺寸槽位只有一个chunk布局相对简单但你要是顺序搞错malloc_consolidate后unsorted bin里什么都没有那就只能重启进程重来了。所以我的建议是在触发malloc_consolidate之前先把目标fastbin chunk释放好然后保证其上方有至少一个已分配的小chunk挡着别让它和top合并。这样consolidate后目标chunk会进入unsorted bin并且fd/bk指针可以被读出来。5. 完整利用链从内存泄露到拿到shell5.1 第一步布置堆布局我以常见的exp结构为例说明每一步的作用。创建small chunk称S和big chunk称B。S申请出来后在内部写入一些标记数据。释放S再释放B再释放S构造fastbin链 S - B - S。注意B也必须和S在同一个fastbin尺寸桶里所以需要把B的大小调整到合适的值或者干脆用两个small。如果是原题big比small大一般会small、huge、small这样构造因为huge尺寸不是fastbin用big的话得先通过其他方式改大小。后面我会专门说这个问题。此时调用malloc两次每次申请相同size的小chunk会拿到S地址两次。第一次malloc返回S第二次malloc也返回S因为链上还有一个S。我们可以在第二次malloc返回后往这个S里写入我们构造的fake chunk的地址。这样第三次malloc时就会返回fake chunk的地址。5.2 第二步触发malloc_consolidate泄露libc在fastbin dup成功后我们手里有两个指向S的指针一个在全局数组里一个在第二次malloc的返回值中。现在申请一个足够大的chunk比如huge触发malloc_consolidate。由于S已经被双重释放过它在fastbin链上出现了两次但consolidate会把它们都处理掉。注意同一个chunk如果在fastbin链上出现两次consolidate会尝试合并两个相同的chunk这里可能产生奇怪的unsorted bin节点。在本题的常规解法中一般不会让同一个chunk被consolidate两次。更优雅的做法是先double free构造出S-B-S的链然后malloc两次分别取出S和B此时它们的堆地址分别被我们持有但全局数组里还残留着S和B的旧指针。接着释放S此时S进入fastbin再申请huge触发consolidateS进入unsorted bin。由于全局数组还指向S我们可以打印S的内容读取fd和bk得到libc地址。这里我推荐在exp里先打印一下堆地址然后试调偏移确保泄露出来的是main_arena而不是什么奇怪的值。5.3 第三步任意地址写与后门触发拿到libc基址之后需要确认程序的“秘密”功能要求什么条件。多数版本里有一个全局变量只有它的值等于某个magic number时才能调用system(/bin/sh)。这个magic number往往是某个特定值比如0xdeadbeef。我们需要利用fastbin dup的另一个方向让malloc返回一个指向全局变量地址的chunk然后往里面写magic number。具体方式是重新构造一个fastbin dup让fastbin链头指向一个我们可控的地址附近的fake chunk。把fake chunk的size字段改成合法的fastbin尺寸。malloc得到该fake chunk然后写入datadata覆盖到目标全局变量。实际操作中我们通常需要先通过unsorted bin控制global_var前面某个可控区域的内存比如一个已有的chunk然后在其中构造fake chunk header。更常用的一个技巧是利用already有指针指向的堆块写入global_var - 0x10再通过fastbin dup两次把链头指向这个地址。之后malloc时glibc会认为global_var-0x10是一个空闲chunk它的size字段需要被我们预先设置成合法值比如0x60否则malloc的size检查会abort。这里要特别小心目标地址所在的内存可能不能随意写入或者写之前会破坏其他数据。所以在写入前建议先算好偏移只覆盖那些不会影响程序运行的字段。5.4 后门触发与最终效果改写成功后调用secret功能程序检查全局变量等于magic number就执行system(/bin/sh)拿到shell。整个利用链完成。概括为表阶段操作目的布局申请small和big或两个small释放顺序构造fastbin dup建立double free条件泄露触发malloc_consolidate读unsorted bin fd/bk得到main_arena地址计算libc基址改写再次fastbin dupfake chunk指向全局变量附近获得任意地址写拿shell写入magic number调用secret执行system(/bin/sh)6. 实战踩坑记录几个最容易翻车的细节6.1 不同尺寸chunk在fastbin里的“隔离”我最开始做的时候以为fastbin是一个桶所有fastbin chunk都在一条链上。这是错的。fastbin按chunk size分了很多个桶0x20、0x30、0x40……每个桶独立管理。如果你double free的两个chunk尺寸不同它们根本不会出现在同一条链上也就没法构造A-B-A。sleepyHolder里small和big的尺寸差距很大直接对big做double free没有任何意义。要构造跨尺寸的链得先通过某种方式伪造size字段把big的size改成与small相同但这通常需要先获得任意写。所以现实做法是只对同一尺寸的chunk做double free或者用large/small组合时先确认它们是否在同一桶比如big可能实际是0x80也属于fastbin桶如果small是0x70就不行。你可以用malloc_usable_size或者gdb的heap命令确认每个chunk的实际size。6.2 malloc_consolidate的触发时机是个分岔口不是所有malloc都会触发consolidate只有那些请求大小超过fastbin最大长度默认128字节的分配才会。sleepyHolder里的big/huge申请通常满足这个条件。但在利用链中如果你顺序不对比如先申请big再释放smallconsolidate会被推迟泄露的数据可能不对。我建议每次构造后都用gdb看一眼unsorted bin的状态确认目标chunk确实在里面并且fd/bk不是空指针。另外如果目标chunk在consolidate时和top合并了unsorted bin里就看不到它泄露会失败。我在本地复现时就遇到过因为忘记在它上方留一个已分配chunk导致consolidate后直接并入top浪费了大半天。后来调整堆布局在目标chunk上方保留一个不让合并的小块问题就解决了。6.3 版本偏移表不通用必须自己算网上流传的偏移表是基于特定libc版本的如果你的题目libc不同直接套用会失败。我建议在exp开头先泄露几个地址然后用gdb找到当前libc的偏移再写死到脚本里。如果题目服务端和本地libc一致那么偏移只在一个小范围内波动。否则你需要通过unsorted bin的地址反推。具体做法是泄露出的地址减去main_arena在libc中的符号偏移就得到libc基址。比如本地main_arena偏移是0x3c4b20那么base leaked - 0x3c4b20。这个值可以在gdb里用p main_arena查看再用info proc mappings查看libc基址两者相减。6.4 别忘了一处隐性的size检查除了fastbin取出时的size检查free时还会检查chunk的size是否和预存的prev_size匹配以及double free检查。有些时候你构造的fake chunk的size合法但free的时候会因为chunk不属于任何fastbin桶而报错。所以如果你需要往fake chunk里写内容尽量避免对它调用free。在sleepyHolder中我们一般不会free fake chunk所以问题不大。6.5 后门函数的参数问题有些版本的sleepyHolder后门函数里用的是system(/bin/sh)有些是直接输出flag。如果是前者你要确保shell拿到后交互正常需要sendline一个命令来验证。如果是后者直接读flag即可。我遇到过一个坑改完后门条件后程序没有重新读取标准输入shell虽然是root权限但stdin没有正确重定向交互会卡住。解决方案是使用sock.shell()或者连续发送cat flag。7. 复盘心得这道题给我留下最深的三个体会第一fastbin的检查看着弱但每个检查点都是一次博弈。double free检查只查头部是因为glibc假设fastbin的使用者不会故意构造环。当你理解了它的弱点是“只查不跟踪”时很多看似神奇的技巧其实都是顺理成章的。第二malloc_consolidate不是只能用来防御碎片它也是堆风水的重要工具。它把fastbin里的空闲块“倒”进unsorted bin从而把攻击面从小尺寸桶扩展到unsorted bin的指针操作。sleepyHolder最妙的地方就是把double free和consolidate放在一起用让同一个chunk先当fastbin的“重弹”再变unsorted bin的“情报员”。第三CTF堆题不是背exp而是理解堆管理器在每个分支下做了什么。你能控制哪些字段哪些字段是内核glibc自己维护的每步之后fastbin/unsorted bin的状态是什么把状态机画在脑子里题目就变成了填空。现在再回头看这道题其实套路不复杂但坑密集很适合用来检验自己对堆管理机制的理解。如果你写exp过程中崩了建议先别急着改payload先用gdb盯着fastbin和unsorted_bin的状态走一遍多数问题出在你以为的状态和实际状态不一致。复盘多了你会发现所有崩溃信息都在替glibc说话就看你能不能听懂了。