Tcache attack 这名字一听就知道是堆利用那点事。玩过一阵子 CTF 的人应该都有感触,以前学堆利用,先是 fastbin 各种花样,后来 glibc 2.26 之后引入了 Tcache,整个堆利用的思路一下子清爽了不少,但坑也跟着变多了。这道题我前前后后折腾了几个晚上,从最初的 Tcache poisoning 到最后的任意地址分配,中间踩了不少坑,也整理出了一些很实用的经验。这篇博文就当作一次完整的复盘笔记,从机制原理讲到动手实践,再讲讲我在调试过程中遇到的那些稀奇古怪的报错和排查思路,希望能帮到正在啃这块知识的你。
1. 内容整体设计与思路拆解
1.1 为什么 Tcache 成了堆利用的“新手村”
先说点背景。Tcache 全称是 Thread Cache,是 glibc 2.26 正式引入的一个线程级缓存机制。设计初衷很简单:多线程程序里,每个线程都会频繁地 malloc 和 free,如果每次都得去访问全局堆区,锁竞争会吃掉大量性能,所以给每个线程都整一个独立的缓存池,小内存块先存这儿,用的时候直接从这儿拿,快很多。
但问题来了,这个机制本身为了极致性能,安全校验做得相当精简,甚至可以说几乎是“裸奔”的。它不像 fastbin 那样每次都检查 double-free,也不像 unsorted bin 那样严格要求双向链表完整性。Tcache 只维护了一个单向链表,每个节点就是一个空闲 chunk,而且链表头部还可以被直接写进一个已知地址。这意味着什么?意味着只要你控制了某个 tcache 节点的 fd 指针,下一次 malloc 就能把这个链表指向任意地址,那基本上就等于拿到了任意地址写。
我在真实的模拟项目 X 里复现了这套逻辑。这个模拟项目是一个带漏洞的堆管理程序,跑在 Ubuntu 22.04 上,对应的 libc 是 2.35。最开始我的想法是走常规的 unsorted bin attack,但折腾了一轮发现,目标环境对 large bin 的校验越来越严格,反而是 Tcache poisoning 简单粗暴,几乎一打一个准。
1.2 Tcache 和 fastbin 的底层差异,决定了利用思路完全不同
这里我特别想说一个对比。我知道很多新手一开始学堆利用的时候,都是从 fastbin 的 double-free 和 house of spirit 开始。但把 Tcache 和 fastbin 放在一起就会发现,二者的利用哲学完全不一样。
Fastbin 的链表检查非常“神经质”。它在取出 chunk 的时候会校验下一个 chunk 的 size 字段是否处于合法范围,还会检查 double-free,也就是当前释放的 chunk 是否正好是 fastbin 链表的头部。这导致传统的 fastbin attack 需要精心构造一个 fake chunk,或者先绕过 size 校验,操作空间相对小。
Tcache 链表的检查几乎是零。它不检查 chunk 的物理位置,不检查 size 字段和请求大小的匹配,也不检查 double-free,几乎只需要“链表头指针可控”这一条就够了。这意味着什么?你只要拿到一次任意写的机会,把某个已释放 chunk 的 fd 改成任意地址,就能把目标地址当作 chunk 分配出来。你说夸张不夸张。
所以我的整体设计思路是:利用 Tcache poisoning 直接构造一个任意地址分配,把目标指向一个可控区域,然后通过这个区域改写关键指针,最终拿到 shell。这条路径相比 unsorted bin 的路线,省去了大量的堆风水操作,特别适合作为进入堆利用世界的第一个完整闭环。
1.3 题目设计思路和整体链路规划
模拟项目 X 的漏洞点其实是个典型的 UAF(Use After Free)。程序提供了一套菜单,可以创建一段数据、编辑一段数据、删除一段数据。问题出在删除之后,指针没有置空,而且释放的是 tcache 范围内的 chunk,所以后续还可以继续编辑这段已经释放的内存。
我的整条链路规划是这样的:
第一步,先分配两个大小相同的 chunk,比如都是 0x50 字节,用于触发 Tcache 机制。第二步,把其中一个释放掉,此时它会进入 tcache bin 的链表头部。第三步,通过 UAF 改写这个已释放 chunk 的 fd 字段,把它指向目标地址。第四步,连续分配两次同大小的 chunk,第一次分配会把那个恶意 fd 从链表头部弹出去,第二次分配就会直接把目标地址当作一个正常的 chunk 返回给你。第五步,在目标地址上做手脚,改写某个全局指针,把流程劫持到后门函数。
整体思路并不复杂,但每个环节都有细节需要注意。比如,目标地址需要考虑对齐问题,因为 glibc 会检查返回值是否 16 字节对齐。再比如,分配两次的时候,第二次分配出来的 chunk 的 size 字段其实是非法的,所以之后千万不能对这个地址所在的堆块做 free 操作,否则你的程序会直接报错退出。这些都是我在实际调试中吃了大亏之后才总结出来的。
2. 核心细节解析与实操要点
2.1 Tcache 的数据结构,必须严谨到每一个字段
要真正理解 Tcache,就得先把它的数据结构扒干净。在 glibc 的实现里,每个线程有一个 tcache_perthread_struct,它包含两部分:一个是 tcache_entry 数组,另一个是 counts 数组。
typedef struct tcache_entry { struct tcache_entry *next; struct tcache_perthread_struct *key; } tcache_entry; typedef struct tcache_perthread_struct { uint16_t counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS]; } tcache_perthread_struct;注意,tcache_entry 的第二个字段是 key,这个字段是在 glibc 2.30 之后引入的,专门用来检测 double-free。它的机制很简单:当 chunk 被放入 tcache 时,key 字段会被设置成指向 tcache_perthread_struct 自身。当你再次 free 这个 chunk 时,glibc 会检查 key 字段是否等于 tcache_perthread_struct 的地址,如果相等,就会遍历整个 tcache bin 看看这个 chunk 是不是已经在链表里了,从而拦截 double-free。
这个检查确实比早期版本严格了,但它有个非常明显的漏洞:只要你修改了 key 字段,就能绕过 double-free 检测。在 UAF 场景下,对已释放的 chunk 做一次写入,把 key 字段改掉,再 free 一次,就能构造出 Tcache double-free。这一点比 fastbin 的检查要轻松得多。
2.2 Tcache poisoning 的核心手法与关键点
Tcache poisoning 的本质是篡改 tcache 链表节点的 next 指针。它的前提条件有三条:
- 目标对象必须位于 tcache 管理的范围内,也就是请求大小小于等于 0x410(在 glibc 2.35 中)。
- 必须存在 UAF,或者能够改写一个已经处于 tcache 链表中的 chunk。
- 目标地址不能是不可写的内存区域,因为分配器在返回该内存地址之前,会向这个地址写入一个 tcache_entry 结构,也就是写入 next 指针。
第三条特别关键,也是很多人容易忽略的。当你通过 Tcache poisoning 把一个任意地址放入 tcache 链表后,下一次 malloc 返回这个地址之前,glibc 会把这个地址当作一个新的 tcache 头部节点,然后把原来链表中剩余的节点地址写到目标地址的前 8 个字节里。如果你指向的是只读内存,程序会直接报错。
我在模拟项目 X 里专门把这一点写成了一条笔记:
注意:Tcache poisoning 的“任意地址分配”并不完全任意。你必须保证目标地址可写。另外,如果你在目标地址写入的 next 值不合法,后续 free 这个 fake chunk 时也会触发异常。所以目标地址最好是一个你可以完全控制内容的内存区域。
这里我推荐的目标地址通常有三个选择:
第一,某个全局变量的地址,特别是堆指针或者函数指针。第二,栈上的某个可控区域,但前提是你能预测栈地址。第三,另一个堆 chunk 的 user data 区域,这种情况最稳妥,因为你完全掌握这块内存的内容,可以提前布局 fake chunk 的完整结构。
2.3 double-free 的构造技巧和踩坑记录
Tcache double-free 和 fastbin double-free 的用法有一个显著区别。Fastbin 的 double-free 需要按照“free A、free B、free A”的顺序来绕检查,而 Tcache 在旧版本中甚至可以“free A、free A”直接连续两次释放,因为早期的 Tcache 根本不检查这种情况。哪怕到了 glibc 2.35,只要你在第一次 free 之后通过 UAF 篡改了 key 字段,依然可以连续 free 两次。
为什么会这样?因为 double-free 检测的逻辑是:如果当前释放的 chunk 的 key 字段等于 tcache_perthread_struct 的地址,就认为它可能在链表中,需要做遍历检查。只要你把 key 字段改成一个别的值,比如一个不可预测的堆地址,那检查就直接短路了,你的 chunk 就能再次进入链表。这样就实现了同一个 chunk 在 Tcache bin 中出现两次的效果。
这有什么用?用处可太大了。同一个 chunk 在链表中出现两次,意味着你可以通过一次 malloc 拿到这个 chunk,然后它还在链表里,你还能再拿一次。这种“一个块用两次”的局面,常用于构造堆重叠,或者把一个已经分配出去的块继续留在空闲链表里,造成一个 chunk 同时被两个指针引用,从而实现 double write。
我在实际调试中第一次构造 Tcache double-free 时,程序直接输出了free(): double free detected in tcache 2。当时的排查步骤是这样的:先查看 chunk 的 key 字段,发现它还指向 tcache_perthread_struct;然后在第二次 free 之前,对 chunk 写入了一个非法的 key 值;再 free 就成功了。这个排查思路我已经整理成下面的速查表,供你参考。
3. 实操过程与核心环节实现
3.1 前置准备:关闭 ASLR、调整 libc 版本
模拟项目 X 的调试环境是这样的:一台 Ubuntu 22.04 虚拟机,默认 libc 版本是 2.35。这里多提一句,不同 libc 版本的 Tcache 机制有一些细节差异,特别是 key 字段的引入时间,以及 tcache 最大的 bin 数量。在 2.30 之前没有 key 字段,double-free 检查形同虚设;2.30 到 2.31 之间引入了 key,但检查逻辑存在一些漏洞;2.32 之后又在 tcache 的 next 上加了一个异或混淆,导致 Tcache poisoning 不能直接写裸地址了。
我们的模拟环境是 2.35,所以有两个必须注意的变化:
- 第一个,tcache_entry 的 next 字段经过了异或混淆,实际存储值是
(next >> 12) ^ next_address。这意味着你在写 fd 指针的时候,不能直接写目标地址本身,而是要先protect_ptr转换一下。 - 第二个,key 字段存在,需要绕过 double-free 检测时记得修改它。
在实际做题时,你可以通过/lib/x86_64-linux-gnu/libc.so.6的输出来确认 glibc 版本。版本不同,你的利用代码写法也会有差异,强烈建议开局先看清楚环境。
$ /lib/x86_64-linux-gnu/libc.so.6 GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.6) stable release version 2.35.3.2 完整利用流程分步教学
我先声明一下,下面的内容是基于模拟项目 X 的脱敏流程,目标是为了讲清楚 Tcache attack 的利用逻辑,而非针对某个真实系统。真正写 exploit 的时候,你需要根据具体的交互菜单来调整偏移。
第一步,建立基础堆布局。我们执行三次 malloc,申请三个大小相同的 chunk,分别是 A、B、C,大小都为 0x80 字节。这里选择 0x80 是因为它处于 Tcache 的范围内,而且可以保证在分配和释放时不会跟 top chunk 产生过多的纠缠。
第二步,用 free 释放 B。此时 B 进入 tcache bin,链表头部是 B,它的 next 指针指向 NULL。
第三步,通过 UAF 修改 B 的 next 指针。这里因为 glibc 2.35 有异或保护,所以不能直接写目标地址。假设目标地址被放入变量target,那么实际写入的值为(target >> 12) ^ target。
# safe_libc = libc.symbols['__free_hook'] 或者某个全局指针的地址 # 这里演示的是异或混淆的计算公式 payload = p64((target >> 12) ^ target) edit(1, payload) # 对 B 进行写入第四步,连续分配两次同大小 chunk。第一次 malloc 会把 B 弹出链表,此时链表头部变成了 target。第二次 malloc 会把 target 当作一个 chunk 返回给程序,并且在这个地址开始的 8 字节写入 next 指针(即链表下一个节点,如果没构造下一个节点则为空)。
第五步,对第二次 malloc 返回的区域进行写入操作。因为这里其实不是真正的堆块,而是一个可以由我们任意控制内容的内存区域,所以我们可以直接布局后续要用到的数据。比如,把一个全局的函数指针覆盖为后门函数的地址。
第六步,触发程序跳转到后门函数。这里不同的题目有不同触发方式。最常用的是通过__free_hook或者__malloc_hook这类钩子函数,当你再执行一次 free 或者 malloc 时,流程就会转移到你写入的地址上。不过在 glibc 2.35 里,__free_hook已经被移除了,所以你需要找其他目标。常见的选择是覆盖exit相关的函数指针,或者是某个自定义菜单中的函数指针。
3.3 任意地址分配的具体细节与边界条件
上述流程的第四步是一个关键节点,我再展开讲讲。当你第一次 malloc 之后,Tcache bin 的链表头部指针会移动到 target 位置。这个时候,链表里其实只有一个节点,也就是 target,它没有合法的 next 指针,甚至 target 地址处的内存内容可能是乱七八糟的。
第二次 malloc 执行时,glibc 会做以下几件事:
- 从 tcache 链表中取出头部节点,也就是 target。
- 读取 target 地址处的 8 字节作为 next 指针。
- 如果 next 指针非空,则把它当作新的链表头部。
- 返回 target 给用户。
问题来了:如果 target 地址处的前 8 字节恰好是一个非空值,而且是一个非法的堆地址,那么当程序执行第三次 malloc 时,glibc 会尝试从这个非法的 next 指针处取出下一个 chunk,导致段错误。所以,在通过第二次 malloc 拿到 target 之后,建议立刻把 target 地址处的前 8 字节清零,避免后续分配出错。
这是我几个深夜调试里最容易踩的坑。很多时候你明明覆盖成功了,但程序在下一步操作时莫名其妙崩溃,多半就是这个 next 指针没有被清空导致的。
3.4 后门函数跳转的实现与触发细节
后门函数的跳转,本质上就是把程序的执行流引到我们自己可控的代码区域。在真实题目里,这个后门函数通常是一个打印 flag 的函数,或者是一个直接开启 shell 的函数。在模拟项目 X 里,我定义了一个win()函数,它直接读取一个模拟的 flag 文件并输出。
覆盖目标的选择有一些门道。我首选的是__malloc_hook,因为它是全局符号,地址可以直接通过 libc 的基址计算得到。但在 glibc 2.35 以上,__malloc_hook已经被移除,所以退而求其次,我选择覆盖了一个动态链接器管理的内存指针。在真实场景里,还有几种选择:
__free_hook:如果你在 2.34 之前的 libc 上操作,这个是最经典的目标。.fini_array段:程序退出时会调用这个数组里的函数指针,适合做退出后触发。GOT表项:覆盖某个函数的 GOT 地址,比如free或puts,这样调用时跳转到后门。exit函数的 GOT 项:如果你在执行菜单操作时会调用 exit,可以覆盖它的 GOT 地址。
每种选择都有各自的触发条件和稳定性差异。我个人的经验是,优先选择那些在正常菜单操作中一定会被调用的函数,这样触发起来更省事,不需要额外构造特殊的操作序列。
4. 常见问题与排查技巧实录
4.1 故障排查速查表
我把这段时间调试过程中遇到的高频问题整理成了一张表,社区里的伙伴们可以直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
free(): double free detected in tcache 2 | 第二次 free 前没有修改 chunk 的 key 字段 | 通过 UAF 修改 chunk 偏移 8 字节处的 key 值,再重新 free |
malloc(): memory corruption | Tcache 链表中的 next 指针指向了一个非法地址 | 检查异或混淆后的 fd 值是否计算正确,并确认目标地址可写 |
| 程序在分配 fake chunk 时崩溃 | 目标地址处的前 8 字节被读取为一个非法的 next 指针 | 在拿到目标地址后立即清零该地址的前 8 字节 |
| 覆盖了某个函数指针,但没有触发跳转 | 函数指针的地址计算错误,或者目标函数被调用前还有一层封装 | 用调试器确认覆盖是否生效,并检查函数调用的解析方式 |
| 各种尝试后依然无法泄露 libc 基址 | Tcache bin 里还有残留 chunk,导致 unsorted bin 无法正常工作 | 把 Tcache bin 填满(7 个 chunk),再释放一个 chunk 进入 unsorted bin |
4.2 异或混淆机制的真面目
刚才说了,glibc 2.32 之后的 Tcache 有一个异或保护,很多人第一次遇到都会懵。我来解释一下这个保护的本质。
旧的 Tcache entry 直接保存下一个 chunk 的地址,修改起来太容易了。所以新版本加了一个safe-linking机制,让 next 字段保存的不是原始地址,而是一个经过异或混淆的值。混淆规则是:存储值 = 链表头地址右移 12 位,再异或真实下一节点地址。
也就是说,你篡改一个 tcache 节点的 next 字段时,不能直接写p64(target),而必须写p64((target >> 12) ^ target)。这个target就是你想让下一次 malloc 返回的地址。
我在模拟项目 X 里最开始忽略了这一点,直接写裸地址,结果程序在 malloc 时取出的 next 值完全错乱,一路分配到了不可控的地址,然后直接崩溃。后来查了源码才明白,这个保护其实非常精巧,但也没有彻底阻断 Tcache poisoning,只是增加了需要计算混淆值的步骤。
提示:如果你在做题时发现堆地址固定,混淆值可以提前算出来,利用起来照样轻松。但如果程序开启了 ASLR,你需要先通过一次泄露拿到堆地址,才能计算正确的混淆值。
4.3 如何处理 Tcache bin 与 unsorted bin 之间的切换
做堆题的时候,很多人习惯先释放大块进入 unsorted bin 来泄露 libc 基址。但 Tcache 引入之后,这个操作有一个很大的坑:你释放的 chunk 如果落在 tcache 范围内,它不会进入 unsorted bin,而是直接进 tcache。
具体来说,如果请求大小不大于 0x410,释放的 chunk 先进 tcache;当 tcache 对应的 bin 满了,也就是已经有 7 个 chunk 时,再释放的同类 chunk 才会进入 unsorted bin。所以在泄露 libc 之前,你需要先把 tcache bin 塞满。
操作步骤并不复杂。分配至少 8 个同大小的 chunk,逐个释放前 7 个,让 tcache bin 满员,然后释放第 8 个。此时第 8 个 chunk 就会进入 unsorted bin,它的 fd 和 bk 指针会指向 main_arena 附近的一个地址,通过这个地址就能算出 libc 基址。
还有一点要特别注意:当你从 unsorted bin 中取出 chunk 时,如果它的大小落在 tcache 范围内,glibc 在特定情况下会把一部分 chunk 重新放回 tcache,这是一种叫做tcache的“预取”机制。也就是说,你分配一个大块时,可能需要多分配几次,才能把真正想要的数据落到可控区域。这个细节在多阶段利用中经常让人困惑,建议做实验的时候多打印几次堆布局,观察 chunk 的流向。
4.4 关于堆风水的一些个人经验
堆利用做到后期,你会发现自己一半时间都在布置堆布局,而不是直接“打过去”。Tcache 的引入虽然简化了很多攻击原语,但也没有完全消除堆风水的需求。真正的堆风水,核心只干三件事:让目标 chunk 落到预期位置、让关键指针可以被改写、让后续分配不产生意外的副作用。
我在模拟项目 X 中有一些具体的体会:
第一个,同一个大小类别的 chunk,尽量多分配几个。因为释放和再分配时,Tcache 的 LIFO(后进先出)特性会导致 chunk 的分配顺序完全倒过来。如果你需要某个特定 chunk 出现在某个特定顺序,就不妨先释放一个“占位” chunk,把它垫在链表底部。
第二个,注意 chunk 的相邻关系。Tcache 不会检查 chunk 是否已经被分配,所以如果你释放了一个堆块,又通过 UAF 修改它的 size 字段,再触发一次打印操作,就能做到堆块重叠读取,把相邻 chunk 的数据泄露出来。这个技巧常常用于泄露堆地址或者 libc 地址。
第三个,多利用 gdb 插件来看堆布局。我个人的习惯是每次操作一个关键步骤之前,先打印堆布局,确认每个 chunk 的状态。等掌握规律之后再逐步省略打印过程。不要一上来就盲打 explot,堆利用这个东西,眼睛看不到堆结构是没法做下去的。
5. 从 Tcache 到现代堆利用的延伸思考
5.1 Tcache 机制背后的安全与性能权衡
很多人把 Tcache 单纯看作一个“漏洞百出的机制”,但我觉得需要换个角度看。Tcache 的设计目标首先是性能,它把线程局部的快速缓存做到极致,安全校验被压缩到只保留最低限度,因此才有这么多可利用的点。
从防御方的视角看,glibc 后续版本逐步加上了 safe-linking、key 字段、chunk 头检查等机制,本质上都是在寻找性能和安全的平衡点。了解 Tcache 攻击,不仅是为了做题,更是为了理解现代内存分配器的设计哲学。你在看 glibc 源码中 Tcache 相关的部分时,会发现它的代码注释里频繁出现“performance”这个单词,安全相关检查反而是零星出现的。这就是非常典型的例子:一个机制只有在真实攻击出现之后,才会被逐步加固。
在真实世界的安全研究里,Tcache attack 也出现过一些实战案例。比如某些开源服务器软件存在 UAF 漏洞,攻击者利用 Tcache poisoning 篡改堆指针,最终拿到代码执行能力。虽然现代系统大多加了各种缓解措施,但理解这一整套利用链路,依然是理解更高级堆利用的基石。
5.2 常见防御机制的绕过思路
对于现代堆利用,最常见的几个防御机制是 ASLR、PIE、safe-linking、Pointer Guard 和 GOT 保护。这些机制叠加之后,Tcache attack 的门槛确实高了不少,但并非无解。
- 应对 ASLR 和 PIE:先泄露地址,通常是先泄露堆地址,再泄露 libc 基址。泄露堆地址可以从 tcache 的 key 字段或者 unsorted bin 的 fd/bk 指针里找到线索。
- 应对 safe-linking:获取堆地址后,按照混淆公式计算正确的 next 值,问题不大。
- 应对 GOT 保护:不再依赖 GOT 改写,而是找机会写
__free_hook或者更现代的 hook 位置,或者直接在程序中寻找自己的函数指针表。 - 应对 Pointer Guard:这是针对函数指针的一种加密机制,真正的场景中需要先解密。但很多 CTF 题目并不会启用这一层,模拟环境里我也没有额外处理。
对抗和防御永远在交替演进。你在做题时应该以“能绕过多少层”为目标,而不是一味追求最复杂的利用方式。很多时候,最稳妥的方案反而是最简单的方案。
5.3 写在后面的话
既然已经把 Tcache 从结构原理讲到实际利用,再从利用讲到防御绕过,那这次复盘也应该回归到一个更实际的层面。如果你正准备参加 CTF,或者正在学习堆利用,我的建议是:不要急着在本地起一个复杂的题目环境,而是先把 Tcache 相关的几个核心机制彻底搞明白,再上手做实验。先用一个最简单的菜单题练手,把 Tcache poisoning 的整个流程跑通,再逐步引入 double-free 和 libc 泄露,最后再看那些多阶段利用的题目,会顺畅得多。
根据我个人经验,Tcache 这套机制虽然已经被防御方加固过,但依然是堆利用入门最友好的突破口之一。理解了 Tcache 的链表操作,后面再上手 large bin attack 或者 House 系列,思路会连贯很多。这篇博文里的每一步,我都在模拟项目 X 里反复跑过,踩过的坑也都尽量写清楚了。如果你在复现过程中遇到和我当时一样的问题,不妨先对照着速查表排查一遍,也许能少熬一个夜。