how2heap 的练习我前后刷过三遍,每次都会发现一些之前忽略的细节。今天想聊的是第二篇里一个非常经典的 2.31 考点:fastbin_reverse_into_tcache。这个技巧本质上是把 fastbin 里的伪造节点反向塞进 tcache,从而绕过 tcache 的 double free 检查和 fastbin 自己的头部检查,最后让malloc把一块原本不可控的地址当作正常堆块返回。
适合读者很明确:已经懂一点 tcache 基础,想深入理解 fastbin 和 tcache 之间交互的人。如果你只听说过“填满 tcache 再 double free”,但没搞懂为什么后续要连续 malloc 那么多次,那这篇内容可以帮你彻底打通。我会按从原理到实操的顺序写,最后的报错排查表也是我自己踩坑踩出来的,建议收藏。
1. 先搞清楚:为什么 fastbin 和 tcache 之间还能“反转”
1.1 从 tcache 的 key 检查说起
glibc 从 2.29 开始,给 tcache 加了一个 key 字段,目的就是防 tcache 里的 double free。每个 tcache_entry 不再是单纯的一个next指针,而是:
typedef struct tcache_entry { struct tcache_entry *next; uintptr_t key; } tcache_entry;free一个 chunk 进 tcache 时,tcache_put会把这个 chunk 的 tcache_entry 里的key设置成tcache_key,这是一个全局变量。下次你再 free 同一个 chunk 时,tcache_get之前的检测逻辑会发现e->key == tcache_key,于是它会遍历整个 tcache bin,看看里面是否已经存在相同地址。如果找到了,直接报free(): double free detected in tcache 1。
所以 2.31 下,想直接在 tcache 里做 double free 是极其困难的。你不能简简单单free(a); free(a);,第一次 free 后 key 被写上,第二次 free 一进来就被识破。
这带来了一个连锁反应:很多旧版 fastbin attack 的思路,都得先绕开 tcache 这关。而fastbin_reverse_into_tcache这个技巧,干脆不直接碰 tcache 的 double free,而是让 fastbin 里的伪造节点“被搬运”进 tcache,搬运过程不经过 free 的 key 检查,于是检查形同虚设。
1.2 fastbin 自己的 double free 关卡
fastbin 也有自己的防 double free 机制,但比 tcache 弱得多。它维护的是一个单链表,FILO 顺序。free进 fastbin 时,_int_free会检查当前要 free 的 chunk 是不是 fastbin 链表头部:
if (__builtin_expect (old == p, 0)) malloc_printerr ("double free or corruption (fasttop)");也就是说,只有连续两次 free 同一个块才会直接报错。如果你free(a); free(b); free(a);这样的顺序,第二次 free a 的时候,fastbin 头是 b,不是 a,检查就通过了。
但是检查通过不意味着安全。fastbin 链表里会出现环:a -> b -> a -> b -> ...。在 2.31 里,这个环如果不去管它,后续malloc连续取块时就会一直在 a、b 之间打转,最终可能因为触到某个不可写地址而崩溃。所以构造 fastbin 里的 double free 只是第一步,之后一定要篡改某个节点的fd,把链表改写成我们想要的形状。
1.3 malloc 批量搬运才是关键
fastbin_reverse_into_tcache最核心的一点,是_int_malloc在从 fastbin 取块时,会顺手把 fastbin 里剩下的块塞进 tcache。
代码逻辑大概是这样的:当malloc的请求大小命中 fastbin,并且 fastbin 不为空时,首先从 fastbin 头部摘下一个 chunk 返回;然后,如果对应的 tcache bin 还没满(默认能放 7 个),它就继续从 fastbin 头往后遍历,把后续节点一个个tcache_put进 tcache。
关键在这:这个搬运过程调用的是tcache_put,只负责把节点挂到 tcache 链表上并写 key,不检查“这个节点是不是已经在 tcache 里”。也就是说,它信任 fastbin 链表的内容。如果我们篡改了 fastbin 某个节点的fd,让 fastbin 指向一个伪造地址,那么伪造地址也会被当作正常空闲块搬运进 tcache。
后续再malloc一次,就能从 tcache 里直接把这个伪造地址弹出。弹出之后,这块地址就变成了“合法”的可写内存,我们可以随便往里写。这就是“反转”二字的含义:正常情况是 free 把 chunk 从 fastbin 挪到 tcache,这个技巧是 malloc 把 fastbin 的伪造节点反向塞进 tcache。
2. 环境准备与实验目标
2.1 怎么搞到 glibc 2.31 环境
这个技巧依赖 glibc 2.31 的具体行为,如果你的系统是更新的 glibc,直接编译运行可能对不上。我建议用 Docker 拉一个 Ubuntu 20.04 的镜像,默认就是 glibc 2.31。或者你用 pwn 常用的配环境思路:把题目给的 libc 和 ld 放到本地,用 patchelf 修改二进制动态链接器路径。
检查当前 glibc 版本最简单的方式:
ldd --version | head -1如果输出里是glibc (Ubuntu GLIBC 2.31-...)就可以。不用死磕版本号小数,2.31 的mp_.tcache_count默认是 7,fastbin 最大 chunk size 是 0x80,这些常数一致就行。
实验不需要任何第三方库,纯 C 就够了。gdb 建议配一下 pwndbg 或类似插件,后面看 tcache 和 fastbin 会方便很多。
2.2 实验程序照这个写
我给出一个完整的复现程序。它做的事很简单:先填满 tcache,再构造 fastbin double free,篡改fd指向栈上的 fake chunk,最后连续 malloc 拿到 fake chunk。
#include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main() { setvbuf(stdout, NULL, _IONBF, 0); size_t sz = 0x40; // 实际 chunk size 是 0x50 intptr_t *a[9]; int i; // 分配 9 个 0x40 大小的 chunk for (i = 0; i < 9; i++) a[i] = malloc(sz); // 第一步:填满 tcache 的 0x50 bin,默认上限 7 for (i = 0; i < 7; i++) free(a[i]); // 第二步:构造 fastbin double free free(a[7]); free(a[8]); free(a[7]); // 此时 fastbin 头是 a[8],不是 a[7],检查通过 // 第三步:在栈上放一个 fake chunk static char fake_chunk[0x30] __attribute__((aligned(16))); memset(fake_chunk, 0, sizeof(fake_chunk)); *(size_t *)(fake_chunk + 8) = 0x50; // 伪造 size 字段 intptr_t *fake_p = (intptr_t *)(fake_chunk + 0x10); // 第四步:篡改 fastbin 头节点 a[7] 的 fd a[7][0] = (intptr_t)fake_p; // 第五步:连续 malloc,观察返回 for (i = 0; i < 9; i++) { void *p = malloc(sz); printf("malloc[%d] = %p\n", i, p); } printf("fake_p = %p\n", fake_p); return 0; }编译命令:
gcc -g -O0 -o exp exp.c不需要-no-pie,这个实验不涉及地址泄露问题。运行后,预期最后两行里会有一个malloc[8]等于fake_p。如果你的输出一致,说明整套攻击链已经走通。
2.3 先明确实验目标
这个实验的目标不是立刻 getshell,而是证明我们可以让malloc返回一个任意构造的地址。fake_p指向栈上的fake_chunk + 0x10,它本身没什么利用价值,只是用来演示“任意分配”已经实现。
真正的实战里,我们会把fake_p换成某些关键 hook 地址附近的合法 fake chunk 起点。但那个起点需要满足 size 检查,不是随便拿一个地址就能用。后面第 4 节我会专门说怎么从任意分配走到__free_hook。
3. 一步步复现 fastbin_reverse_into_tcache
3.1 填满 tcache,先把“名额”耗尽
很多人第一步就栽在填 tcache 上。
我们一共分配了 9 个 chunk,然后先释放前 7 个。释放顺序是a[0]到a[6],因为 tcache 是 LIFO,所以释放完后 tcache 0x50 bin 里的链表头是a[6],尾是a[0],count 变成 7。
此时 tcache 已经满到不能再接收新的 chunk。如果你直接free(a[7]),它不会进 tcache,而是会走后面的_int_free流程,因为 fastbin 最大 size 包含 0x50,所以它会进 fastbin。
一定要记住这个前提:free进 fastbin 是因为 tcache 满了,而不是你不想进 tcache。如果你在前面只释放了 6 个,那么free(a[7])会进 tcache,后面所有步骤全部作废。
3.2 构造 fastbin 双链表,绕过头部检查
fastbin 的 double free 检查只看当前free的 chunk 是不是链表头。所以标准的绕过顺序是:
free(a[7]) -> fastbin: a7 free(a[8]) -> fastbin: a8 -> a7 free(a[7]) -> fastbin: a7 -> a8 -> a7 -> ...第二次free(a[7])时,fastbin 头是a[8],不是a[7],检查通过。但链表已经成环了。
这个环节最容易让人困惑的是:明明a[7]已经 free 过一次,为什么还能再 free 一次?因为在 2.31 下,fastbin 的检查只做了“当前节点是不是头节点”这层判断,没有扫描整个链表。只要你中间隔了另一个 chunk,它就不认为你是 double free。这种设计在单链表 FILO 下是有漏洞的,我们只是利用了这个缺陷。
调试时可以先用 gdb 停在第三次free(a[7])之后,用 pwndbg 的fastbins命令看一下 0x50 bin。你会看到类似:
fastbins 0x50: 0x...a7 -> 0x...a8 -> 0x...a7 -> ...看到这个环,就说明 double free 已经成功。
3.3 劫持 fd,让 fastbin 指向 fake chunk
成环之后,fastbin 头是a[7]。我们需要把a[7]用户区的前 8 字节,也就是它的fd字段,改成fake_p。
这里有个非常关键的细节:fake_p不是fake_chunk本身,而是fake_chunk + 0x10。
fastbin 链表里存的是 chunk 的用户指针,也就是malloc返回的地址。这个地址在 chunk 结构里对应chunk + 0x10的位置。fastbin 的fd字段就存放在用户区开头。如果我们让 fastbin 指向fake_chunk,系统会把fake_chunk当成用户指针来用,那么它检查 size 时就会读取fake_chunk - 8,那个位置完全不可控,基本直接崩。正确做法是让 fastbin 指向fake_chunk + 0x10,这样它检查 size 时读到的是fake_chunk + 8,也就是我们预先写好的0x50。
所以代码里:
a[7][0] = (intptr_t)fake_p;而fake_p定义为:
intptr_t *fake_p = (intptr_t *)(fake_chunk + 0x10);改了之后,fastbin 链表从:
a7 -> a8 -> a7 -> ...变成:
a7 -> fake_p -> 0原来的a8被跳出链表了,这不重要,反正我们不会再分配它。
还有一点:fake_p处的前 8 字节(也就是fake_chunk + 0x10)必须存放一个安全值,最好是 0。因为在搬运时,系统会继续从fake_p的fd字段读取下一个节点。如果那里是个野指针,tcache_put时就会崩。我们代码里对整个fake_chunk做了memset,所以这一项已经处理好了。
3.4 触发 reverse,用连续 malloc 拿到任意地址
现在 tcache 是满的,fastbin 链是a7 -> fake_p -> 0。注意一个容易忽略的问题:你直接malloc(0x40)时,_int_malloc会优先从 tcache 取 chunk。因为 tcache 里还有 7 个空闲块,根本没走到 fastbin 分支。
所以触发 reverse 前,需要先把 tcache 里的 7 个名额全部消费掉。这也是为什么代码里最后有个 9 次循环。
逐次分析:
malloc[0]到malloc[6]:这 7 次把 tcache 原来的 7 个 chunk 全部弹出。顺序是a6, a5, ..., a0,因为 tcache 是 LIFO。malloc[7]:此时 tcache count 为 0,条件检查发现对应 tcache bin 没满,于是进入 fastbin 分支。fastbin 头是a7,系统先把a7取下并返回。随后 while 循环发现 tcache 还没满,于是把 fastbin 新的头fake_p放进 tcache。malloc[8]:tcache count 现在是 1,所以直接从 tcache 弹出fake_p。
执行完这一步,malloc[8] == fake_p,我们的任意分配就完成了。
为什么整个过程中 tcache 的 key 检查没有触发?因为 fake_p 不是通过free放进 tcache 的,而是被_int_malloc的搬运逻辑塞进去的。搬运代码只做tcache_put,不会去检查 key,更不会遍历链表找相同地址。这相当于让恶意节点“空降”进 tcache,身份是合法空闲块。
4. 用 gdb 验证每一步状态
4.1 看 tcache 和 fastbin 的 count 变化
我在实验时一般会在三个位置断点:
- 第三次
free(a[7])之后 a[7][0] = (intptr_t)fake_p之后- 最后一次
malloc之前
第二次断点后,用 pwndbg 的tcache和fastbins命令看:
tcache 0x50 [ 7]: 0x...a6 -> 0x...a5 -> ... -> 0x...a0 fastbins 0x50: 0x...a7 -> 0x...fake_p这里fastbins会直接显示fake_p,说明我们的修改已生效。
第三次断点前,也就是执行第 8 次malloc之前,tcache的 0x50 bin 应该从 count 7 降到 0,fastbins里还是a7 -> fake_p。等你单步执行完第 8 次malloc,tcache0x50 bin 会突然多出fake_p,这正好印证了 reverse 动作。
4.2 验证 size 检查为什么卡住
如果 fake chunk 的 size 写错位置,最常见报错是:
malloc(): size not correct这个报错就来自_int_malloc搬运 fastbin 节点进 tcache 时的检查。它要求 fastbin 节点的实际 chunk size 必须等于请求对应的 chunk size。
我们在 gdb 里可以直接看 fake_chunk 的内存:
(gdb) x/4gx &fake_chunk 0x...: 0x0000000000000000 0x0000000000000050 0x...: 0x0000000000000000 0x0000000000000000fake_chunk + 0x8处是0x50,而fake_p = fake_chunk + 0x10。所以系统读fake_p - 0x8时正好读到0x50,检查通过。如果你不小心把 fake_p 定义成fake_chunk,它会读fake_chunk - 0x8,那是未知内存,于是报 size not correct。
另外还有一个对齐问题。64 位下 malloc 返回的指针必须 16 字节对齐,fastbin 节点也要求对齐。栈上数组如果不加__attribute__((aligned(16))),fake_chunk + 0x10很可能不是 16 的倍数。这时报错会是:
malloc(): unaligned fastbin chunk detected 2这个报错我一开始也遇到过来回折腾,后来直接加 aligned 属性省事得多。
4.3 从任意分配走到 __free_hook
演示程序拿到fake_p只是第一步。实战中我们通常希望malloc返回一个能改写__free_hook的地址,然后把它覆盖成system,再free一个包含/bin/sh的 chunk 拿 shell。
但直接让fake_p = __free_hook是不行的,因为__free_hook - 0x8处必须有一个满足 fastbin size 检查的值。__free_hook本身是个符号,它前面的内存大概率是 0,0 不是合法 fastbin size。
标准做法是找一个“天然存在合法 size 值”的地址作为 fake chunk 的起点。最常见的是:
fake_p = __malloc_hook - 0x23 + 0x10也就是让__malloc_hook - 0x23 + 0x8处的值看起来像0x7f,这个0x7f是 libc 内部数据留下的,恰好可以被当成一个 0x70 大小的 fastbin chunk 的 size。利用时,malloc请求大小选择 0x60,实际 chunk size 是 0x70,就能对上号。
我自己的经验是:别在实验环境里硬背偏移,用 gdb 动态看。p &__free_hook、p &__malloc_hook和x/20gx把对应地址前后打出来,找到哪个偏移处有合适的 size 值,再反推 fake_p。用 fastbin_reverse_into_tcache 做任意分配只是一个传输入口,后续写到哪、写什么,完全取决于目标地址附近的内存布局。
需要说明的是,glibc 2.34 之后__free_hook这类 hook 已经被移除,这套打法在 2.31 里仍然成立,但在新版本里不能继续用同样的 hook 目标。学习 2.31 的意义在于理解 tcache、fastbin、malloc 三者之间十六年前的交互逻辑。
5. 常见报错与排查笔记
下面这几个错误,基本覆盖了做这个实验时能遇到的绝大多数情况。我按现象、原因、对策整理成了一张表,方便你对照。
| 现象 | 原因 | 对策 |
|---|---|---|
free(): double free detected in tcache 1 | tcache 没填满时直接反复 free 同一个 chunk,key 检查触发 | 先把 tcache 的对应 bin 填满到 7,再走 fastbin |
malloc(): memory corruption (fast) | 篡改 fd 后 fastbin 链表进入非法地址,或伪造节点未对齐 | 检查 fake_p 的 16 字节对齐,检查 fake_p 处的 next 是否为 0 |
malloc(): unaligned fastbin chunk detected 2 | fake_p 地址不是 16 的倍数,系统认为 fastbin 节点非法 | 给 fake_chunk 加 aligned(16) 属性,确认 fake_p = fake_chunk + 0x10 |
malloc(): size not correct | fake chunk 的 size 字段与请求不匹配,或 size 写错位置 | 确认写入的是 fake_chunk + 8 而不是 fake_chunk;确认写的是 0x50 |
| 搬运后直接段错误 | fake_p 的 fd 字段指向了不可写/非法地址,while 循环继续读 | 将 fake_p 用户区第一个 8 字节置 0 |
| malloc 返回的不是 fake_p | tcache 没有先被清空,malloc 直接弹 tcache,没走 fastbin 搬运 | 先连续 malloc 7 次清空 tcache,再触发下一次 malloc |
我自己踩得最深的一个坑是:明明 fastbin 头已经改了,为什么连续 malloc 很多次还是拿不到 fake_p?后来断点一看,饿缓存里还有 7 个 chunk,每次 malloc 都直接从 tcache 返回,根本没机会走到 fastbin。所以顺序必须是free填满 →malloc清空 → fastbin reverse → 再malloc拿到 fake。这个节奏感很重要。
另外一个细节:如果你在篡改 fd 后急着只 malloc 一次,你拿到的会是 fastbin 头节点a7,而不是 fake_p。因为系统的搬运发生在取下 fastbin 头之后,它会把 fake_p 塞进 tcache,但当前这次 malloc 仍然是返回a7。这也是很多初看源码的人理解不了“为什么不是马上拿到任意地址”的原因。理解了这一点,整个 trick 就没有盲区了。
6. 写在最后的小经验
可能有人会问:为什么要费这么大力气绕一圈?直接伪造 fastbin fd 做 fastbin attack 不行吗?
2.31 的 tcache 让很多东西变得麻烦。你伪造完 fastbin fd 后,如果 tcache 对应 bin 是空的,你 malloc 时大概率会先经过 tcache,根本没机会从 fastbin 里取伪造节点。fastbin_reverse_into_tcache 就是专门解决这种尴尬的:它利用 malloc 自身的搬运逻辑,让伪造节点以合法身份进入 tcache,然后从 tcache 里被取出来。整个过程干净利落,不需要你去碰 tcache 的 key 检查。
如果你是从 2.23 时代过来的老玩家,可能会觉得这套流程有点绕。但现代堆利用里,理解 tcache 和 fastbin 的交互已经成了基本功。建议把代码跑通之后,再自己改一改 fake_chunk 的 size、改一改 malloc 的请求大小,看看报错有什么变化。这种主动触发报错的练习,比单纯读源码记忆深刻得多。
最后再分享一个调试建议:做这类实验时,千万别只看输出结果。把 gdb 断点打在实际触发点,用tcache、fastbins、x/4gx这些命令一步步看内存。这个实验的每一个状态变化都很直观,看一次全流程,胜过自己盲调十次。