资讯动态

从tcache attack看glibc堆利用:原理、攻击手法与缓解机制

发布时间:2026/10/19 10:55:08 来源:尧图企业网站定制
如果你做过一段时间二进制漏洞研究或者打过几场CTF大概率绕不开“Tcache attack”这个名字。简单说这是针对 glibc 内存分配器中 tcache 机制的一类堆利用手法的统称在 glibc 2.26 版本之后tcache 几乎成了堆漏洞利用的默认入口。无论你是刚接触 pwn 的初学者还是想在二进制安全方向深入的安全从业者理解 tcache attack 都会让你对整个堆管理的攻防模型有更清晰的认识。它能帮你完成任意地址分配、控制流劫持、信息泄露等目标但同时也被 glibc 的各类缓解机制死死盯着。这篇文章会从 tcache 的结构讲起梳理常见的攻击变体并给出我在实际调试和刷题过程中踩过的坑和总结的经验。1. 为什么现代堆攻击绕不开tcache1.1 从一次CTF比赛说起先讲一个实际遇到过的情况。某次线上赛里有一道堆题题目环境是 glibc 2.31漏洞点是一个 UAFuse-after-free程序只提供了 add、delete、edit 三个功能而且 delete 之后没有清空指针。很多新手上来第一反应是打 fastbin attack因为以前的老题都是这么教的。可是调试到一半就会发现free 掉一个大小在 0x20 到 0x410 之间的 chunk 时它根本没有进入 fastbin而是先进入了 tcache。如果 tcache 还没满后续一系列基于 fastbin 的操作全都对不上号。这就是现代堆题的真实状态只要 glibc 版本高于 2.26tcache 就是一道绕不过去的门槛。那道题最终的解法其实很简单利用 UAF 改写 tcache 中某个 chunk 的 next 指针让它指向目标地址再连续两次 malloc 就能拿到任意地址写。放在以前这种操作对应的可能是 unsorted bin attack 或者 fastbin 的 double free而现在 tcache poisoning 是最直接的那条路。1.2 tcache到底是什么tcache 的全称是 thread cache是 glibc 为了减少多线程下锁竞争而引入的每线程缓存机制。它的设计思路很朴素每个线程维护一组单向链表每个链表对应一种 chunk 大小。当线程释放一块小内存时不直接归还给主分配区而是先塞进本线程的 tcache bin 里当线程需要分配同样大小的内存时优先从 tcache bin 里取。这个机制的出现显著提升了多线程程序的性能但也给堆利用带来了全新的攻击面。tcache bin 中的 chunk 记录方式是单向链表链表的头部存放在线程自己的一块固定结构里而每个空闲 chunk 的头部fd 字段位置被当作 next 指针使用。由于 tcache bin 不像 fastbin 那样有严格的完整性校验甚至在早期版本2.26 到 2.28里连基本的双重释放检查都没有攻击者只需要改掉一个 next 指针就能让后续 malloc 返回任意地址。这就是“tcache attack”这个名字的由来——与其说它是一个漏洞类型不如说是一整套针对 tcache 管理逻辑的攻击方法。对学习者来说我的建议是不要死记各种“house of xxx”的名字而是先把 tcache 的数据结构、分配和释放流程吃透。因为所有变种不管名字多花哨本质都是在回答同一个问题我怎么让分配器把一个错误的地址当作合法的空闲 chunk 返回给我2. 理解tcache的底层结构是攻击的前提2.1 tcache_perthread_struct与tcache_entry在 glibc 源码中tcache 的核心是一个叫 tcache_perthread_struct 的结构体它作为每个线程的第一个 chunk 存放在堆的起始位置附近。这个结构体里有两个成员一个 counts 数组和一个 entries 数组。counts 数组记录每个大小对应的 tcache bin 里当前有多少个 chunkentries 数组记录每个大小对应的链表头部指针。chunk 大小按 16 字节对齐所以这两个数组的长度并不是写死的而是根据 malloc 的配置在初始化时算出来的。链表节点本身是 tcache_entry 结构源码里大致长这样typedef struct tcache_entry { struct tcache_entry *next; /* 用于检测双重释放 */ struct tcache_perthread_struct *key; } tcache_entry;现实中这里的 next 就是空闲 chunk 的 fd 位置。key 字段在 2.29 之后被加入用于检测双重释放——如果同一个 chunk 被再次释放可以检查它的 key 字段是否仍然指向 tcache_perthread_struct。这个字段是后来攻击者在双释放时需要重点关注的东西。还有一个容易忽略的点tcache 中存储的 next 指针指向的是 chunk 中用户数据区的起始位置也就是 chunk 指针本身而不是像 fastbin 那样指向 fd 的偏移位置。这意味着你用 UAF 改写 next 时改写的其实是下一个返回的 chunk 的地址。这个差别直接决定了攻击时的偏移计算方式也影响了 safe-linking 的引入。2.2 tcache的分配和释放流程从释放路径看当调用 free 时如果 chunk 大小落在 tcache 支持的范围默认最大 0x408也就是 1032 字节并且当前大小对应的 tcache bin 还没有满默认每个 bin 最多 7 个那么 chunk 会被放入 tcache。放入的过程很简单把当前 tcache bin 的 entries 头指针赋值给新释放 chunk 的 next然后让 entries 指向这个 chunk再把 counts 加一。这里没有像 fastbin 那样做指针取整校验也没有对相邻 chunk 的完整性做检查。分配路径则反过来malloc 时会先检查请求大小对应的 tcache bin 是否为空如果不为空直接把 entries 指向的 chunk 返回同时 entries 更新为该 chunk 的 next 字段counts 减一。整个过程没有任何对返回地址合法性、对齐要求或者内存权限的检查。这种设计在工程上是极简高效的但站在攻击者角度它就像一条只信任链表头的队列——只要你能控制链表里任何一个节点的 next 字段整个链表的后续走向就被你掌握了。控制 next 字段的最常见手段就是 UAFchunk 在被释放后仍然可以通过悬空指针读写于是你就能改写它头部的 next 和 key 字段。2.3 这里的“为什么”是基础很多刚学堆利用的同学会问为什么非要从 tcache 结构讲起直接给我利用模板不行吗我的回答是如果你不理解 tcache bin 的单向链表特性你在遇到 glibc 2.32 以上的 safe-linking 时就会一头雾水如果你不理解 counts 的限制你在构造 tcache double free 时可能怎么都触发不了如果你不理解 key 字段的位置你在 2.29 后的题目里改 next 时就会把 key 一并改坏导致 double free 检测直接报错。我自己的体会是把 tcache 当成一个有容量限制、先入后出其实更像一个栈的缓存池来理解比把 tcache_entry 当普通链表容易得多。每次 free 一个符合大小的 chunk相当于把一个元素压入栈顶每次 malloc 相同大小相当于从栈顶弹出一个元素。攻击者要做的就是在入栈时偷偷替换掉栈顶元素的“向下一个元素的指针”这样弹出时就会弹出一个伪造的地址。为了防止你在真实题目里按图索骥却不对位下面我把常见攻击手法按触发条件整理一下。3. 整理一下常见的tcache攻击手法3.1 tcache poisoning从修改next到任意地址写tcache poisoning 可以说是 tcache attack 里最基本、也最直接的手法。它的触发条件很简单你有一个 UAF 或堆溢出能够修改一个已释放 chunk 头部的前 8 字节也就是 next 字段。修改之后下一次 malloc 会把这个被篡改的 next 指针当作新的返回地址。举个例子假设当前大小为 0x50 的 tcache bin 里有一个空闲 chunk A它的 next 指针原本是 NULL。你通过 UAF 把 A 的 next 改成一个目标地址 target。然后第一次 malloc(0x40)拿到 Atcache bin 的 entries 更新为 A 的 next也就是 target。第二次 malloc(0x40)分配器直接把 target 当作 chunk 返回给你。如果 target 是一块你已经控制好的内存比如另一个堆块那你相当于获得了一块额外内存如果 target 是一个重要全局变量的地址比如__free_hook那你就拿到了任意地址写。后面你就可以往这个地址写入 one_gadget 或 system 函数地址从而控制程序流程。这个手法的关键点在于目标地址必须满足对齐要求通常是 16 字节对齐同时malloc返回地址在写入时会让分配器认为 target 是一个合法 chunk。如果 target 指向一个全局变量附近你甚至可以用负偏移来让返回地址恰好落在目标变量上。这里我建议你在调试时重点关注下一次 malloc 返回前分配器会不会对 target 做合法性检查。在 glibc 2.31 及之前的版本里tcache 路径基本不查所以成功率极高。3.2 tcache double freekey检查的逻辑与盲区double free 是指同一个 chunk 被释放两次。在 fastbin 时代glibc 通过检测链表头是否等于当前释放的 chunk 来阻止这种情况。但 tcache 在 2.26 到 2.28 版本里完全没有这个检查所以你可以连续两次 free 同一个 chunk让 tcache bin 里出现一个自循环链表。这样一来连续两次 malloc 会拿到同一个地址两次于是你就有了同一块内存的两个别名指针。glibc 2.29 加入了一个 key 字段来缓解这个问题当一个 chunk 被放入 tcache 时会在它的 key 字段写入 tcache_perthread_struct 的地址释放时如果发现当前 chunk 的 key 已经是 tcache_perthread_struct就认为它是双重释放并触发 abort。但这里有个盲区如果攻击者在两次 free 之间修改了 chunk 的 key 字段把它改成别的值那么第二次 free 时的检测就会绕过。这在 UAF 场景下非常常见——你 free 一次之后立刻通过 UAF 把 key 改掉然后再次 free就绕过了检查。还有一个绕过思路是构造 tcache bin 与 fastbin 之间的交互把 chunk 先释放到 fastbin再通过与 tcache 的某种转换最终进入 tcache从而让 key 字段没有被设置为 tcache_perthread_struct。实际做题时我遇到最多的情况不是不知道怎么绕过 key而是忘记 tcache bin 有容量上限。默认每个 bin 最多放 7 个 chunk如果你已经放了 7 个再次 free 同类大小是不会进入 tcache 的double free 自然也不成立。所以在构造攻击时第一件事就是看看对应 tcache bin 当前 count 是多少别上来就双释放。3.3 其他变种tcache dup、tcache stash、house of botcaketcache dup 本质上是 double free 的另一种叫法强调在同一块内存上制造重复分配。它利用的是 tcache bin 的先进后出特性通过连续两次 malloc 把同一个 chunk 分配出来从而制造两个指向同一地址的指针。后续你可以对其中一个指针进行释放另一个指针仍然可以使用这就形成了一种“伪 UAF”。tcache stash 这个叫法不太常见但它在某些堆利用里很有用。它指的是攻击者通过修改 tcache bin 的 entries 指针而不是空闲 chunk 的 next直接“塞入”一个伪造的链表节点。实现这个操作的前提是你能够改写 tcache_perthread_struct 本身也就是要拿到堆开头附近的内存越权写。这种手法在堆布局可控的题目里可以一步到位直接控制任意大小的 tcache bin 链表。house of botcake 是一个更综合的手法核心思路是制造 unsorted bin 与 tcache 之间的重叠先填满 tcache binfree 一个大 chunk 进入 unsorted bin然后从中间切出一个 chunk让剩余部分仍然在 unsorted bin 中再通过某种方式把 tcache 中的一个 chunk 与 unsorted bin 的 chunk 重叠。这种手法主要用于绕过 glibc 2.31 后的一些保护拿到 unsorted bin 的地址用于信息泄露。它本质上不是单纯的 tcache attack而是 tcache 与 main arena 管理逻辑的联动。变种虽然多但我还是想强调它们都绕不开 next 指针的可控性。只要你能控制 tcache 链表上的任意一个 next或者控制 tcache_perthread_struct 的 entries 数组你就掌握了 tcache 的核心命脉。4. 一次典型构造的实操拆解思路向4.1 目标与前提条件为了把前面讲的原理落到具体操作上我以一个模拟项目 X 的题目为例。假设程序运行在 glibc 2.31漏洞点是 delete 函数没有清空指针导致 UAF。程序提供了三种功能add 申请一块 0x40 大小的 chunk 并允许写入内容delete 释放指定索引的 chunkshow 打印指定索引的 chunk 内容。目标是通过 tcache attack 拿到任意地址写最终读取 flag。在这个前提下我们需要的东西是一个 UAF 的堆块、一次可以 overwrite 的写入机会、一个目标地址。目标地址可以来自泄露的 libc 基址或者程序给出的全局变量地址。这里我选择使用free_hook附近的地址作为演示方向原因是因为写它可以直接控制后续 free 的流程。需要注意的是不同 glibc 版本里__free_hook的偏移不同你在实际做题时需要通过泄露一个 libc 函数地址来计算。泄露的方式一般是先把 chunk 释放到 unsorted bin再通过 show 读取 main_arena 附近的数据。这一步不是 tcache attack 的核心但几乎每道堆题都绕不开所以我建议先把 unsorted bin 的信息泄露练熟。4.2 一步步构建tcache poisoning的核心逻辑一旦你有了 UAF 和一个目标地址构造 tcache poisoning 的步骤可以这样拆解先 add 两次 0x40 大小的 chunk得到索引 0 和索引 1。delete 索引 1让 chunk1 进入 tcache bin。此时 tcache bin 的 entries 指向 chunk1chunk1 的 next 为 NULL。delete 索引 0让 chunk0 进入 tcache bin。此时 tcache bin 的 entries 指向 chunk0chunk0 的 next 指向 chunk1。利用 UAF 修改 chunk0 的 next 字段把它改成目标地址 target。第一次 add拿到 chunk0tcache bin 的 entries 更新为 target。第二次 add拿到 target 对应的内存并向其中写入任意数据。这个流程看起来很简单但有几个细节必须处理好。首先步骤 2 和步骤 3 的顺序决定了 tcache bin 的先进后出特性如果你先删了索引 0再删索引 1那么后续拿到的顺序会反过来。其次步骤 4 中修改 chunk0 的 next 时需要保证写入的 8 字节就是 target 本身。如果你用 edit 功能写入要注意写入的数据是从用户数据区开始的而 next 字段位于用户数据区的第一个 8 字节正好对应。关于 target 的选择我建议在早期学习时不要直接挑战__free_hook而是先选择一块堆上的 chunk看看能不能成功把 malloc 引向它。等理解了分配器的行为后再去尝试写全局变量地址。毕竟直接打 libc 地址时如果目标地址不满足对齐要求malloc 可能会在后续操作中崩溃。4.3 关键判断什么时候选择tcache攻击而不是fastbin我在教学过程中经常被问到一个问题为什么 free 一个 0x40 大小的 chunk 后它进的是 tcache 而不是 fastbin这就要看 glibc 的释放逻辑了。在 2.26 及之后的版本中free 的第一步是先判断大小是否在 tcache 支持范围内如果是且对应 bin 没有满就直接放入 tcache。只有当 tcache bin 满了chunk 才会降级到 fastbin 或 unsorted bin。这就导致了一个普遍的做题策略如果你看到的 malloc 大小在 0x20 到 0x410 之间优先考虑 tcache attack而不是 fastbin attack。fastbin 攻击需要伪造 chunk 并绕过各种检查在 tcache 出现后已经逐渐沦为备选方案。只有在 tcache bin 已经填满、或者题目刻意让你操作大于 0x410 的 chunk 时才需要把注意力转向 unsorted bin 或者 large bin。不过也要注意一个陷阱tcache 的容量上限是 7当某个大小已经存在 7 个空闲 chunk 时新的 free 就会绕过 tcache 进入 fastbin 或 unsorted bin。所以很多题目会故意让你先填充 tcache再通过 UAF 操作 fastbin/unsorted bin 中的 chunk形成跨 bin 利用。这种情况下的攻击并不是不选 tcache而是把 tcache 当作一个需要先“填满”的闸门。4.4 用调试工具确认状态光看代码分析很容易在 head 里把 tcache 链表的结构搞混。我的习惯是把题目做好之后先用 gdb 插件比如 pwndbg 或 gef观察 tcache 的实际状态。在 pwndbg 里heap命令可以看到所有堆块的状态tcache命令可以直接列出每个 tcache bin 的 entries 和 count。具体确认什么第一确认 free 之后 chunk 确实进入了 tcache bin而不是去了 fastbin。第二确认修改 next 之后bin 的链表头还是原来的 chunk而不是直接变成了目标地址。第三确认 count 的变化避免在后续 malloc 时因 count 不为正而触发问题。我遇到过很多次count 明明显示 1但链表头却不是预期地址这通常意味着某个 next 指针被改错了。调试时还有一个常见误区直接用malloc分配内存后认为返回的地址就是 next 指针指向的地址。实际上malloc返回的是 chunk 的用户数据区起始地址而 tcache 链表中的指针也指向同一位置所以在 tcache 场景下两者一致。但在 fastbin 里链表指针指向的是 fd 字段的位置也就是 chunk 头部偏移 16 字节的地方这很容易让人混淆。如果你用 fastbin 的思路去调试 tcache就会出现偏移上的偏差。5. glibc的缓解机制与攻击需要跨过的门槛5.1 从key检查到count上限glibc 对 tcache attack 的防御是逐步加强的。2.29 加入的 key 字段是我在前面提到的 double free 检测但这只是最基础的一层。实际上即使攻击者绕过 key 检查还要面对 count 上限的问题。count 上限指的是每个 tcache bin 最多容纳 7 个 chunk。这个限制本身不是专门用来防御攻击的但它确实给攻击者制造了障碍。比如你要在 tcache bin 中布置一个较长的恶意链表就必须考虑 count 是否还有富余。当你把 next 指针修改为目标地址后分配器并不会校验这个目标地址是否真的是一个空闲 chunk但它会认为目标地址处存在一个合法的 tcache_entry并在下一次分配时读取该地址的前 8 字节作为新的 next。如果那 8 字节不可读或者没有按预期设置后续 malloc 就会崩溃。在实战中我发现在生成这个“伪造节点”时经常需要先向目标地址写入一个合法的 next 值通常是 NULL这就是很多利用里要先泄露堆地址的原因——你需要在伪造链表的 next 字段填上一个合理的堆地址确保分配器读取时不会触发非法内存访问。5.2 safe-linkingtcache指针的异或混淆glibc 2.32 引入了 safe-linking这是一个影响深远的机制。它的核心思想很简单在把指针存入 tcache 链表之前先将指针值与它所在地址右移 12 位后的值进行异或然后再存储取出时再反向异或恢复真实指针。这个 12 位右移对应的是页大小 0x1000也就是说指针混淆依赖的是内存地址本身的一部分熵。对于攻击者而言这意味着你不能再简单地通过 UAF 写入一个“裸指针”来篡改 next而必须知道当前 chunk 所在页面的高地址信息并且把混淆后的值计算出来。在实际利用中safe-linking 显著提高了 tcache poisoning 的门槛。以前改 next 只需要写 8 字节的目标地址现在需要先泄露堆地址然后计算(target 12) ^ target这种混淆值。如果目标地址和当前 chunk 不在同一页或者你无法准确获得堆地址攻击就会失败。学习时我建议你不要死记公式而是理解它的目的让攻击者无法轻易伪造一个指向任意地址的链表节点。safe-linking 同样应用于 fastbin所以这套思想在 glibc 堆利用里是通用的。在 2.32 以上的版本中几乎所有堆攻击的第一步都变成了“如何泄露堆地址”。5.3 绕过思路在防守端的价值很多人看到“绕过”两个字会兴奋但我更建议把绕过思路当作理解防御机制的入口。比如 safe-linking 的绕过通常需要你能够泄露堆基址。泄露堆基址的方法有很多常见的是通过 unsorted bin 中的 chunk 残留 fd/bk 指针找到 main_arena 附近地址再根据偏移算出堆段地址。这个过程繁琐但能让你深刻理解 glibc 的地址布局。另一个防守端价值是你可以利用这些缓解机制来加固自己的程序。比如在你自己的项目中实现一个类似 safe-linking 的指针混淆层或者在使用自定义内存池时对空闲链表节点做完整性校验。了解攻击者如何绕过才能真正知道防御要加在哪里。我并不建议初学者一上来就追求“全绕过”因为那会让你陷入碎片化的技巧泥潭。我自己的学习路径是先在老版本 glibc2.27上把 tcache poisoning 和 double free 的套路练熟然后再逐步升级到 2.31 理解 key 检查最后在 2.32 上研究 safe-linking。每一步都是在前一步的基础上增加新的限制条件而不是推倒重来。6. 实际题目中的常见问题与排查手册6.1 为什么double free没触发这是新手最常问的问题。如果你在 2.29 以上的环境里尝试 double free结果程序直接 abort说明你的第二次 free 被 key 字段检测到了。解决办法有两个方向要么在两次 free 之间通过 UAF 把 chunk 的 key 字段改掉要么让同一个 chunk 不是连续两次进入 tcache而是穿插一次 fastbin 操作。如果是 2.26 到 2.28 的老环境double free 没有 key 检查但仍然要满足 tcache bin 未满的条件。你可以先填满其他大小的 bin或者把当前大小 bin 的 count 控制在 6 以下留出插入两次 free 的空间。还有一点free 的 chunk 必须与 tcache bin 中的某个 chunk 大小完全一致不能四舍五入。调试 double free 时我建议用 gdb 在第二次 free 处下断点查看当前 chunk 的 key 字段值。如果它等于 tcache_perthread_struct 的地址说明检测已触发。如果它已经被你改成其他值但程序仍然 abort那就要检查是不是你在 fake 写入时破坏了其他字段。6.2 为什么tcache数量一直不对tsache 数量显示异常通常是操作顺序有问题。你在 printf 中看到的 count 是tcache_perthread_struct-counts[tc_idx]它反映的是当前该大小 bin 里有几个 chunk。如果你 free 了一个 chunk但 count 没有增加原因大概率是该大小的 bin 已经满了或者你 free 的 chunk 实际上不在 tcache 支持的大小范围内。还有一种是 count 减少但链表头不变的情况。这种情况通常是因为你手动修改了某个 chunk 的 next 字段导致 malloc 时虽然取出了链表头的 chunk但链表头的 next 并没有按照预想更新。比如你把链表头的 next 改成了一个非法地址malloc 在取出链表头后会把 next 赋值给 entries然后返回链表头此时 count 减一但 entries 已经指向非法地址后续 malloc 就会崩。解决这类问题我建议在每个 malloc 之后都打印 tcache bin 的状态。别嫌麻烦堆调试的容错率很低一个小数位的偏差都会导致连锁反应。6.3 为什么改写后malloc崩了改写 next 后 malloc 崩溃最常见的两个原因一是目标地址不对齐二是目标地址处的“下一节点指针”不可访问。在 tcache 中malloc 返回 target 后分配器需要读 target 位置的前 8 字节作为新的链表头。如果 target 指向的是一块受保护的内存比如某个只读页读取就会触发段错误。解决办法是在把 target 写入 next 之前先在 target 地址处写入一个 8 字节的可控值。通常写入 NULL因为 malloc 拿到 target 后下一步如果再次 malloc期望从 target 的 next 字段读取一个地址NULL 可以让链表正常终结如果你有后续利用也可以写入一个可控的堆地址。另外对齐要求也值得反复检查。x86-64 下 malloc 返回的地址要求 16 字节对齐。如果你选择的 target 是全局变量地址比如某个函数表的中间位置很可能是 8 字节对齐但非 16 字节对齐。这时 malloc 会直接 crash。如果要打全局变量附近可以往前或往后挪 8 字节让返回地址落到目标变量上这样就满足对齐了。6.4 防御视角如何让自己的程序少踩这类坑最后说点和利用相反的内容。如果我们在写自己的 C/C 程序或者在做 CTF 题目以外的工程如何避免被 tcache attack 影响最直接的做法是free 后立即将指针置空。这能消除绝大多数 UAF 漏洞也让 tcache double free 失去前提。其次如果你在实现内存池或自定义分配器最好对空闲链表加一个 magic 校验字段比如在 chunk 头部存放一个固定魔数取出时验证。这个思路和 tcache 的 key 字段类似但你可以做得更严谨。第三个建议是开启编译器和系统层面的缓解选项。Linux 下可以启用 ASLR、PIE 和堆地址随机化这会让 tcache attack 中的目标地址更难猜测。当然这些缓解并不是万无一失的因为攻击者可以通过信息泄露拿到地址但工程里逐层加固会让攻击成本指数级上升。最后分享一个我个人的小技巧在分析 tcache 相关问题时手动画一个链表变化图非常有效。不要只在脑子里推演把每一步 malloc/free 后 tcache bin 的链表头和节点 next 字段画出来用箭头表示指针关系。很多看似复杂的绕过流程画完图之后其实一目了然。我刷题时经常用一张白纸记录每一步的关键状态这个习惯帮我避开了无数低级错误。如果你刚开始接触 tcache attack我强烈建议你也试试效果比单纯记技巧好得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑