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了某个槽位之后,数组里存的仍然是原来的堆地址。这个漏洞说白了就是UAF(Use 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 free
3.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和B,A是small chunk,B是big chunk):
- 申请A(small)、B(big),释放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 chunk,malloc会先调用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_arena+88之类的偏移)。由于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. 完整利用链:从内存泄露到拿到shell
5.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触发consolidate,S进入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,然后写入data,data覆盖到目标全局变量。
实际操作中,我们通常需要先通过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 dup,fake 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再释放small,consolidate会被推迟,泄露的数据可能不对。我建议每次构造后都用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说话,就看你能不能听懂了。