打CTF或者做二进制研究的朋友,对“堆任意分配”这个词应该都不陌生。但真正让我把这一整套流程跑通,还是最近复盘某场比赛的堆题时:题目只给了UAF和realloc能力,却要求先泄露libc再拿shell。我当时的思路是先搞定任意分配,再打IO流泄露信息,中间还顺手用了一下realloc特性和unsorted bin的expand逻辑。今天就把这条利用链完整地拆开讲,从原语建立到信息泄露的每一步,包括为什么这么做、怎么做、以及我踩过的坑。这篇笔记适合已经了解堆基础结构、想进阶利用手法的读者,也适合在做防御分析时想搞清楚对手攻击路径的朋友。
1. 利用链全景:四个环节各司其职
1.1 为什么绕一大圈去攻击IO流
先说一个很直接的问题:堆利用的目标通常是getshell,但为什么第一步往往不是控制rip,而是泄露信息?
原因在于现代glibc的保护机制。题目给的libc基址往往是未知的,PIE下程序基址也可能不固定。你伪造一个vtable指针、改一个__free_hook,都需要先知道这些符号的真实地址。没有信息泄露,后续所有劫持都无从下手。所以“泄露信息”不是可有可无的步骤,而是整条利用链的第一优先级。
那信息泄露的方式有很多:打unsorted bin的fd/bk泄露main_arena地址、打stdout的write_base泄露libc段内容,甚至利用格式化字符串直接读栈。但为什么专门攻击IO流?
因为很多题目给的原语很有限。可能你只有一次UAF、一次edit,或者只能malloc固定大小。直接读unsorted bin地址需要你有打印功能,但对方可能就是断掉了常规的堆信息输出。这时候攻击标准库的IO结构体,让程序自己帮我们把内存“打印”出来,就成了一个强制性更高、容错性更好的路径。
这也带出了标题里的核心链条:拿到堆上的任意分配原语以后,把目标指向_IO_2_1_stdout_,伪造一个FILE结构体,然后触发一次put或printf,让libc的输出机制替我们完成信息泄露。整个过程需要解决三个问题:分配原语怎么来、FILE结构体怎么伪造、伪造之后怎么触发输出且不崩溃。
1.2 四个原语在利用链中的分工
把标题里的四个关键词拆开看:
- 堆任意分配:这是利用链的“脚”。让malloc返回一个我们能控制的地址,相当于拿到了一个写任意地址的窗口。没有这一步,后面所有操作都够不到IO结构体。
- 攻击IO流:这是利用链的“手”。通过构造位于libc数据段的fake
_IO_FILE,篡改输出边界,让标准库输出函数泄露我们指定的内存区域。 - realloc特性:这是利用链的“杠杆”。realloc在特定条件下可以原地扩大或缩小chunk,借助它我们可以改变一个指针所指向chunk的真实size,制造出堆重叠,为后续对unsorted bin的操作创造条件。
- unsorted bin expand:这是利用链的“放大器”。通过unsorted bin分配时对chunk的分割逻辑,结合伪造的size,分配出一个超出原有范围的chunk,进一步扩大可控区域。
这四个原语不一定是独立使用的。实际利用中,realloc特性往往是制造堆重叠的手段,而堆重叠又能让你改写相邻chunk的size从而触发unsorted bin expand,expand之后又能腾出一块更大的可控区域去精确覆盖到FILE结构体。整个过程像搭积木一样一环套一环。
2. 第一环:堆任意分配原语的建立
2.1 任意分配的本质与常见入口
所谓“任意分配”,本质上是让glibc的malloc在返回指针时,指向了我们想要的内存地址。这中间没有什么魔法,核心在于操纵bin链表中的指针。
最常见的入口是tcache poisoning。glibc 2.26以后引入了tcache,它本质上是每个size一个的单向链表,结构很简单:一个chunk的fd字段直接存下一个chunk的地址。malloc在tcache_get时,直接从head取出第一个entry,然后head = entry->next。如果攻击者能改写这个next指针,malloc下次request对应size时就会把next指针指向的地址当作chunk返回给他。
其他常见入口还包括:fastbin的double-free链、unsorted bin attack配合伪造bk、large bin attack的bk_nextsize覆盖等等。但tcache因为结构简单、利用门槛低,已经成为“任意分配”最优先考虑的手段。
需要特别强调的是,tcache poisoning并不是万能的,它有一些前置条件:你首先要有一个UAF或者edit原语,能在chunk被free进tcache之后、再次malloc之前,改写它的fd字段。同时,目标地址必须对齐到16字节(malloc返回的chunk指针本身是对齐的)。大多数情况下这就够了。
2.2 基于UAF的tcache poisoning实操
以最常见的组合“UAF + edit”为例,整个过程分三步:
第一步,准备好一个被free进tcache的chunk。假设我们有一个p = malloc(0x40),然后free(p),此时tcache[0x50]的头部指向p,p->fd原本为NULL。
第二步,利用UAF或edit功能,把p的fd字段改写为目标地址target。注意,如果题目给的是write接口,写入位置通常是chunk的data区域起始(即chunk指针本身,也是fd所在位置)。写入内容用p64(target_addr)即可。
第三步,调用两次malloc(0x40)。第一次malloc返回p,同时tcache的头部变成我们伪造的next指针;第二次malloc时,glibc直接从tcache的head取出target_addr,把它当作一个合法chunk返回。
# 伪代码示例 free(p) edit(p, p64(target)) # 改写 tcahce 的 next a = malloc(0x40) # 返回 p b = malloc(0x40) # 返回 target这里有一个初学者容易踩的坑:target的取值和第二个malloc的request size必须匹配。因为tcache是按size分桶的,target_addr必须是“看起来像一个合法chunk起始”的地址。如果我们想分配到_IO_2_1_stdout_的起始地址,那么第二次malloc(0x40)时候,target应设为stdout_addr;但如果我们要分配到某个结构体中间(比如从_IO_write_base开始写),target就要减去对应偏移。
我在做某道题时,就遇到过因为target没有对齐导致malloc直接abort的情况。glibc在tcache_get时会检查aligned_OK(e),不对齐会直接触发malloc_printerr。所以16字节对齐这条红线千万别碰。
2.3 目标地址的选择与对齐策略
任意识别到“任意地址”后,第一件事是想清楚要写哪里。
如果你只是想泄露信息,目标通常是三类:_IO_2_1_stdout_、_IO_2_1_stderr_、_IO_list_all。stdout用得最多,因为程序基本都会用puts或printf往外打数据,改stdout的字段最直接。
这里有一个很实用的对齐技巧:如果目标地址是stdout,但我们只想改_IO_write_base这一个字段(偏移0x20),那我们可以把第二个malloc的返回地址设成stdout_addr直接覆盖整个_IO_FILE;或者设成stdout_addr + 0x20,这样malloc返回的指针正好落在write_base处,后续只需写入从write_base开始的几个字段即可。
不过要注意,如果把返回指针定在结构体中间,那第一个malloc返回的那个chunk和这个“中间地址”之间会产生一个奇怪的堆对象,可能会被后续的free或realloc误操作。建议尽量用完整的fake FILE布局覆盖,把前面几个字段也一并写掉,避免遗留脏数据。
在实际调试中,我会习惯先把stdout的偏移打印出来(通过泄露libc基址后加偏移),再用pwnlib.gdb在malloc调用处下断点,观察返回的指针和预期值是否一致。这一点不赘述太多,后面调试部分会细说。
3. 第二环:攻击IO流泄露信息
3.1 _IO_FILE关键字段速查
伪造IO流之前,先把_IO_FILE结构体里跟输出相关的关键字段弄清楚。结构体定义在libio.h中,简化后长这样:
| 偏移 | 字段 | 作用 |
|---|---|---|
| 0x00 | _flags | 文件状态标志,决定IO模式 |
| 0x08 | _IO_read_ptr | 读缓冲区的当前位置 |
| 0x10 | _IO_read_end | 读缓冲区的末尾 |
| 0x18 | _IO_read_base | 读缓冲区的起始 |
| 0x20 | _IO_write_base | 写缓冲区的起始 |
| 0x28 | _IO_write_ptr | 写缓冲区的当前位置 |
| 0x30 | _IO_write_end | 写缓冲区的末尾 |
| 0x38 | _IO_buf_base | 缓冲区基地址 |
| 0x40 | _IO_buf_end | 缓冲区末尾 |
在C语言里,printf最终会调用_IO_puts或_IO_new_file_xsputn,其内部逻辑是:如果_IO_write_ptr < _IO_write_end,就把数据从write_ptr开始写入,直到write_end。也就是说,输出的“边界”由write_base到write_ptr决定。
我们攻击stdout,就是篡改这组指针。经典思路是:让_IO_write_base指向我们要泄露的内存起始地址(比如某个libc内地址),让_IO_write_ptr指向更高的地址,这样libc会把从base到ptr这一大段内容原样输出。
3.2 构造伪造stdout泄露指定内存
我以修改_IO_2_1_stdout_为例,给出一个可以直接用的fake布局:
# 假设已经拿到任意写,把以下数据写到 stdout 起始处 fake_stdout = b"" fake_stdout += p64(0xfbad1800) # _flags: MAGIC + CURRENTLY_PUTTING + IS_APPENDING fake_stdout += p64(0) * 3 # _IO_read_ptr/end/base 清零 fake_stdout += p64(leak_from) # _IO_write_base:从哪开始泄露 fake_stdout += p64(leak_to) # _IO_write_ptr:泄露到哪结束 fake_stdout += p64(leak_to) # _IO_write_end 与 ptr 保持一致 fake_stdout += p64(0) * 4 # buf_base/buf_end 等后续字段置零_flags值0xfbad1800是我最常用的一个组合。它包含_IO_MAGIC(0xfbad0000)、_IO_CURRENTLY_PUTTING(0x0800)、_IO_IS_APPENDING(0x1000)。这个组合能确保_IO_puts走我们期望的输出分支。在部分libc版本中,0xfbad2887也可以,但0xfbad1800在2.23到2.31的兼容性都不错。
设好这些字段后,调用一次puts("x"),你会在输出里看到一段从leak_from到leak_to的内存内容。通常我们把leak_from设为某个libc地址附近(比如__environ或GOT表),就能一次性把libc基址、程序基址甚至栈地址都泄露出来。
这里有一个非常容易翻车的地方:_IO_write_ptr和_IO_write_base的差值不能太大。glibc内部在输出时可能会对长度做一些限制,或者因为缓冲区边界问题触发_IO_flush_all等额外操作,导致崩溃。我实际测试下来,把差值控制在0x200以内比较稳,够泄露你需要的地址段即可,别贪多。
另一个坑是:_flags如果设置得不对,puts可能直接走_IO_OVERFLOW分支,然后调用vtable里的函数,而不是我们预期的xsputn输出路径。表现为程序卡住或直接abort。这时候优先检查flags位,确认_IO_CURRENTLY_PUTTING和_IO_IS_APPENDING这两个位是否置上。
3.3 vtable校验与版本差异
从glibc 2.24开始,FILE结构体上方的vtable指针(在_IO_FILE结构体末尾偏移0xd8附近)不能随意指向任意地址了。IO_validate_vtable会检查vtable指针是否落在libc的__libc_IO_vtables段内,不在就调_IO_vtable_check然后abort。
这就意味着,在老版本里直接把vtable劫持到我们控制的堆地址的做法行不通了。现在的思路是:
- 把vtable指向libc内部的合法vtable,比如
_IO_str_jumps、_IO_wfile_jumps等; - 配合伪造的
_IO_strfile结构体,让_IO_str_overflow用我们控制的函数指针完成RCE; - 如果只是为了泄露信息,很多时候不需要劫持vtable,只需要改对write系列字段,vtable保持原样即可。
所以在我这篇笔记里,泄露信息这一步,vtable并不是优先级最高的事。只要你的任意写能力能精确覆盖到stdout的0x20偏移处,把write_base和write_ptr这两个字段改好,输出机制就会按你的剧本走。真正需要动vtable,是后面getshell阶段的事,那个是另一个话题了。
不过有一类情况例外:如果你分配的地址不在_IO_FILE开头,而是直接落在vtable区域附近(比如想改_IO_wfile_jumps),那就要仔细核对版本对应的vtable偏移。每个libc版本的_IO_2_1_stdout_偏移、_IO_str_jumps偏移都是不同的,调试时需要用libc-database或pwninit的符号表对一下,别凭经验硬套老偏移。
3.4 泄露之后的收尾处理
伪造stdout泄露完信息,并不意味着攻击结束。程序后面可能还要继续用stdout输出正常内容,甚至还要多次输入输出。如果我们的fake FILE没有恢复,下一次puts会继续从我们设置的write_base输出一大段垃圾,严重影响流程稳定性。
所以泄露完信息之后,我一般会把stdout恢复成原样。实际操作中,泄露libc地址以后,利用任意写能力把原先保存好的原始stdout前0x40字节重新写回去,再触发一次puts,输出就恢复正常了。
这里有一个技巧:在篡改stdout之前,先通过正常的puts输出,把原始_IO_2_1_stdout_的内容dump下来。怎么得?方法很多,最直接的是利用堆上已泄露的地址,算到stdout偏移后,直接读libc的虚拟内存镜像。libc的_IO_2_1_stdout_初始内容在内存中是确定且可预测的,还可以直接用libc.dump_io()或从本地的libc.so文件里提取出来。拿原始数据跟我们的fake布局拼接,只改需要改的字段,其他保持原样,这样更稳定。
在我的exp里,通常用一个字典保存关键偏移,然后在泄露完成后把write_base和write_ptr两个字段改成正常值,其余字段不动,这样能省一次完整的重写。
4. 第三环:realloc特性——堆布局的搅局者
4.1 realloc三种行为模式的判定
realloc在许多题目里是默认提供的功能,看起来平平无奇,但它的内部逻辑非常值得玩味。glibc的realloc(ptr, size)在不同条件下会走完全不同的路径:
ptr == NULL:等价于malloc(size)。size == 0:等价于free(ptr),然后返回NULL。这个特性叫REALLOC_ZERO_BYTES_FREES,在很多版本中默认开启。ptr != NULL && size != 0:这个是重头戏,会进入_int_realloc,根据新旧大小和相邻chunk的状态决定是原地扩展、原地收缩、还是分配新块后拷贝。
这三种行为里,前两种可以当成现成的free和malloc来用。第三种才是我们真正需要研究透的。
这里有个很关键的判断逻辑:_int_realloc在nb > oldsize(新size超过旧size)时,会检查相邻的下一个chunk(nextchunk)。如果nextchunk是空闲的,且oldsize + next_size >= nb,它就尝试把nextchunk从bin链中摘下来,跟oldp合并,实现原地扩展;如果nextchunk是整块top chunk,也能直接扩容。如果都不满足,才走“malloc新块 + memcpy + free旧块”的兜底路径。
兜底路径也有文章可做:它实质上对旧块做了一次free。如果题目逻辑里没有别的free机会,你可以用realloc的兜底路径作为“合法free”来触发unsorted bin或tcache的释放,这个思路在构造攻击链时非常有用。
4.2 原地扩展与收缩的边界条件
来看_int_realloc中“原地扩展”的检查逻辑,我用伪代码还原一下:
if (nb <= oldsize) { // 原地收缩,把旧chunk分成两块: // 前面一块大小为 nb,后面剩余部分作为新chunk返回给系统 // 注意:剩余部分如果进入top,就不会产生可用的bin chunk; // 如果被free进bin,则产生一个新的可控chunk } if (nb > oldsize) { nextchunk = chunk_at_offset(oldp, oldsize); if (nextchunk == top) { // 如果 topchunk 足够大,直接扩展 oldp, // top 指针向上移动,oldsize 变大 } else { nextsize = chunksize(nextchunk); if (nextsize + oldsize >= nb && !prev_inuse(nextchunk)) { // 这里会 unlink(nextchunk) // unlink 成功后 oldp 扩展,覆盖到 nextchunk 的区域 } } }也就是说,“原地扩展”的触发条件本质是:下一个物理相邻的chunk是空闲的,两件套加在一起足够覆盖新的size。而unlink操作会触碰nextchunk的fd和bk指针。如果能控制这两个指针,就能在unlink的时候完成一次任意地址写入,这就是realloc参与攻击的一个方式。
另一个容易被利用的点是“原地收缩”。realloc(ptr, smaller_size)时,oldp会被分割,前面一部分作为返回值,后面一部分成为一个新的chunk。如果这个新chunk紧接着某个目标结构(比如另一个chunk的fd字段),那它天然就提供了一个可控的相邻chunk,后面配合溢出或改size就能实现更复杂的布局。
4.3 用realloc制造堆重叠
我在实际利用中,用realloc制造堆重叠的典型场景是这样的:
首先,malloc两个相邻chunk A和B,大小都为0x100。free(B),B进入unsorted bin。此时B的fd和bk都指向main_arena附近。
接着,用realloc(A, 0x300)。由于B刚好是空闲的,且A和B的总大小满足0x300的需求,_int_realloc会把B从unsorted bin中unlink,然后A扩展为0x300,完全覆盖B原来的区域。
现在,A的data区域中包含B原本的fd和bk,以及B被free后的整个chunk内容。你不仅拿到了一个更大的chunk,还把这个大chunk的边界扩展到了原来B再往后的区域。如果再配合一次malloc(0x200),返回的指针会落在A+0x200的内部,但逻辑上它属于A这个大chunk,这就形成了重叠。
在这个重叠状态下,你能做两件事:第一,直接读写A的data,去修改“内部chunk”的数据,比如改它的size字段,伪造出一个更大的chunk;第二,通过A的扩展边界,覆盖到原本在B后面的一个chunk C,改写C的头字段。C如果是free状态,修改它的size就能触发后面要讲的unsorted bin expand。
这里有个关键细节:当realloc把B unlink时,会执行经典的unlink校验fd->bk == P && bk->fd == P。B的fd和bk在unsorted bin中都是正常的(指向main_arena附近),所以只要你不提前改坏它们,这个unlink能顺利通过。如果你已经用其他方式改过B的fd或bk,那就必须先修复它们,否则realloc会直接abort。
5. 第四环:unsorted bin expand的原理与配合
5.1 unsorted bin的分配与分割逻辑
unsorted bin是glibc中一个特殊的bin,它存储的chunk不按size分类,而是FIFO双向链表。当一个chunk被free到unsorted bin后,下一次malloc时会优先在unsorted bin里找。
_int_malloc在遍历unsorted bin时会有几个分支:
- 如果unsorted chunk的size正好等于request size,直接取出。
- 如果unsorted chunk的size大于request size,则把它分割成两块:一块给request,剩余部分(remainder)继续留在unsorted bin。
- 如果unsorted chunk的size小于request size,则看下一块。
那个“分割剩余部分”的过程,就是unsorted bin最核心的逻辑,也常被称为last remainder机制。攻击的要点在于:分割的依据是chunk头部记录的size,而不是真实分配时的大小。如果我们能伪造一个unsorted chunk的size字段为很大的值,那么malloc一个小请求时,系统就会基于这个伪造的size执行分割,返回一个覆盖范围远超实际的chunk。
这就是unsorted bin expand的本质:不是真的把chunk扩大,而是让分配算法相信一个chunk“足够大”,从而给你切出一块超出常规范围的区域。
5.2 伪造size触发expand的实操
假设现在的状态是:我们已经通过realloc制造了堆重叠,可以改写某个已free chunk的header。
具体做法如下。让一个chunk X处于unsorted bin中,它的物理地址附近目标结构是T。我们先泄露出X的真实地址(通过之前的信息泄露),然后利用任意写能力,把X的size字段改成一个更大的值,使得X的边界扩展到T附近,或者至少扩展到能覆盖到我们要控制的相邻chunk。
伪代码习惯如下:
# offset 是 X 与目标区域之间的字节偏移 fake_size = real_size + offset edit(chunk_X_addr, p64(fake_size))改完size后,调用malloc(real_size)时注意请求大小要小于fake_size。_int_malloc检查到X的size为fake_size,会执行分割:切出request大小的一个chunk,remainder被重新放回unsorted bin。
关键点在于,这一次分割的边界是以fake_size计算的。因此切出来的chunk虽然名义上是request大小,但它实际占用的物理范围可以跨越到原X之后很大一片区域,只要fake_size涵盖得到。一旦malloc返回这个chunk,你就获得了从X开始的、大小远超常规的可写区域,目标结构T就在里面。
这也就是标题里“unsorted bin expand”最直白的一种实现。它在逻辑上等价于:用一个不可信的size头,让unsorted bin的分配器做了超出常规尺度的切分,从而把一个“饱满”的可控区域交到你手里。
5.3 expand与realloc的组合拳
expand和realloc在完整利用链里通常是连续动作,顺序一般是:
第一步,用realloc制造初始重叠。这一步的核心是让相邻chunk能被合法unlink,从而让A覆盖到B和C。
第二步,用得到的重叠区域改写C的size字段,伪造一个更大的size。C必须处于free状态且位于unsorted bin。如果不是,需要想办法先把它free,或者把C的prev_inuse位处理好。
第三步,malloc一个适当的size,触发unsorted bin对C的分割(expand),返回一个覆盖范围远大于请求大小的chunk。
第四步,在这个大chunk里定位到IO结构体(比如stdout),写入fake FILE布局,触发输出完成信息泄露。
实际操作中,第二、三步之间还有一个隐含约束:伪造size时必须保证chunk不会在free检查或unsorted bin遍历时被判定为非法。比如chunk的size不能小于MINSIZE,size的低三位要正确处理PREV_INUSE、IS_MMAPPED、NON_MAIN_ARENA标志。通常低三位设为0或者只保留PREV_INUSE即可。
另外,remainder判定的“last remainder reuse”机制也值得关注。如果连续多次malloc的大小都从同一个large chunk切分,glibc会不断更新剩余部分的位置。这个特性在堆风水中有妙用,可以制造出“指针在堆上漂移”的效果。但恰恰因为它会更新remainder的fd/bk,如果你在过程中篡改了这些指针,很容易触发malloc_printerr。我在一次调试中就是因为在remainder的fd上动了手脚,导致下一次malloc时unsorted bin corrupted直接崩了。这一点务必注意。
6. 完整利用链串联与调试实录
6.1 完整流程的执行序列
把前面各环节拼起来,一条完整利用链大致长这样:
- 利用UAF泄露堆地址。从fastbin或tcache chunk的fd中读出堆指针,算清楚堆的基址。
- 利用tcache poisoning实现第一次任意分配。目标设在我们能写的位置(通常是堆上的某个chunk头部),先把这个chunk的size伪造好,为realloc重叠做准备。
- 调用realloc触发合并。用realloc把chunk A从0x100扩到0x300,让空闲的B被unlink,实现堆重叠。
- 在重叠区域写C的size头,伪造fake_size,让unsorted bin在下次malloc时执行expand分割,返回一个覆盖到目标区域附近的chunk。
- 在这个大chunk的可控范围内,直接覆写
_IO_2_1_stdout_。写入fake FILE布局:flags设为0xfbad1800,write_base指向我们要泄露的内存起点,write_ptr指向终点。 - 调用puts触发输出,从输出中解析libc基址、堆基址、栈地址。
- 泄露完成后,将stdout恢复为正常字段,继续后续的攻击。
这个序列里,第1步和第2步其实是很多题目共有的起手式。第3步到第5步是亮点,也是最容易卡壳的地方。
6.2 调试关键点:断点该下在哪
调试这类利用链,最忌讳的是在肉眼层面猜测堆的状态。我的习惯是在__libc_realloc和_int_malloc处分别下断点,观察几个关键寄存器:
- 进入realloc时,
rdi是oldp,rsi是new size。确认oldp是我们预期的chunk。 - 在
_int_realloc的unlink分支处断点,检查nextchunk的fd和bk值是否正常。unlink校验失败会直接走到abort,所以这一步能提前暴露错误。 - 在
_int_malloc的unsorted bin遍历循环中,观察当前遍历的chunk的size值。如果你伪造的fake_size没有生效,这里能看到真实值,直接定位到问题。 - 在
_IO_puts入口断点,查看_IO_2_1_stdout_的write_base和write_ptr是否已经被正确改写。确认之后再决定是继续触发输出还是回寄存器去修字段。
如果你用的是pwntools,可以这样快速在gdb中定位:
gdb.attach(p, ''' set pagination off break *__libc_realloc commands silent printf "realloc: old=%p, size=%#lx\\n", $rdi, $rsi continue end ''')一个很重要的经验是:不要把所有断点都一口气enable,那样调试信息太密集,反而看不清楚。我通常先观察堆状态(用vis看堆布局),再针对当前环节强制跳转一次,命中后再继续。
6.3 常见问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
malloc返回后程序abort,报malloc(): unaligned tcache chunk | 目标地址未按16字节对齐 | 检查tcache的next地址,退位或前移到对齐位置 |
realloc时报corrupted double-linked list | unlink校验失败,说明nextchunk的fd/bk被改写 | 重新泄露并修复nextchunk的fd/bk |
puts崩溃或卡在_IO_OVERFLOW分支 | fake FILE的flags设置不正确 | 检查flags是否包含_IO_CURRENTLY_PUTTING和_IO_IS_APPENDING |
| puts正常执行但输出为空 | write_base和write_ptr指向了不可读的输出区域 | 确认leak_from指向的内存可读,且ptr - base差值合适 |
第二次malloc触发unsorted bin corrupted | fake_size覆盖了unsorted bin中remainder的fd/bk | 停止在remainder上动手脚,或者分配前重新初始化它的fd/bk |
泄露内容包含大量\x00但找不到地址 | 目标地址距离write_base太远,中间被空字节截断 | 把write_base精度调细,分段泄露 |
排查时往往还有一个现象是“第一次运行正常、第二次必崩”。这大多是因为你第一次伪造的fake FILE没有复原,或者tcache的链表被改了之后没有修复完整的next链。我的建议是每次malloc之后都用heap和bins命令检查链表状态,确认下一次分配不会踩到残留的旧指针。
除速查表之外,还有一个核心调试技巧:gdb中直接调用分配函数来测试一个步骤。比如在任意分配写完了fake FILE后,用gdb的call (int)puts("x")看输出。如果这一步在gdb里能正常泄露,而在实际运行中崩溃,多是因为gdb环境下的libc版本或者环境变量与我们exp中计算偏移的基础不一致,优先检查ldd和版本符号。
让这条链成为你自己的生产力
我第一次把这条利用链完整跑通,花了整整一个晚上,大部分时间都耗在那些“看似合理但就是不对”的细节上。比如realloc到底有没有触发unlink——这个判断决定了堆布局是否按预期变化;再比如伪造unsorted bin chunk的size时,低三位的标志位处理,一个PREV_INUSE没置对就能让系统以为上一块还在用,合并逻辑整个错乱。
但恰恰是这些细节,让我对glibc分配器的运行逻辑有了更立体的理解。以前“malloc就是给块内存”的简单认知,被彻底替换成了“malloc是一套基于链表和标志位的状态机”。当你把tcache、realloc、unsorted bin这三个齿轮咬合在一起,你会发现你能做的远不止泄露信息,后续的任意写、劫持控制流都只是这套机制的延伸。
如果你也在做类似的利用链分析,我的建议是不要贪图一上来就跑完整exp。先把每一步拆开,单独验证原语是否成立:tcache能不能poison成功?realloc能不能原地扩展?unsorted bin能不能按伪造的size切分?一步一步在gdb里看到状态变化,再串联整体,成功率会高很多。
最后一个小技巧:每次大改动前,先把libc版本、偏移值、保护状态这些基础信息打印出来核对一遍。我踩过无数次“版本偏移记错导致未定义行为”的坑,现在每次都老老实实把符号表拿出来对一遍再动手。这不会影响你的攻击效率,反而能省掉搜索报错的数个小时。堆利用是一条越走越深的路线,但只要你把每一步的原理吃透,它的套路其实比想象中要清晰得多。