01|Tcache Poisoning 理论:2.27 为什么完全不校验 fd
一句话:tcache 是单链表,malloc 取块只读entries[tc_idx]的 next,不做任何地址校验。
核心:*B = target→ 两次 malloc → 第二次返回 target
适用:glibc 2.26 ~ 2.28(2.29 加 key,2.32 加 Safe-Linking)
0. 前提条件
想用 tcache poisoning,必须同时满足这四条:
条件 | 说明 | 不满足会怎样 |
glibc ≥ 2.26 | 有 tcache 机制 | 只能走 fastbin / unsorted bin |
目标尺寸 ≤ 0x410 | tcache 的最大档位 | 块会进 unsorted,没有 tcache 链 |
能改已释放块的 next | UAF / 堆溢出 / 块重叠 | 没有入口,无法投毒 |
目标地址可写 | 否则第二次 malloc 用的时候崩 | 只能改「可写且不在乎原值」的地方 |
1. 什么是 Tcache
在 C 语言里,我们用malloc()申请内存、用free()释放内存,这些内存来自堆(Heap)。
把堆想象成一个巨大的公共图书馆:malloc()是借书,free()是还书。
以前每次还书,图书管理员都要严格按分类把书放回指定书架,还要把相邻的空位合并起来——严谨,但慢。
为了提速,Linux 的内存管理器(glibc 2.26 起)引入了一个新机制:Tcache(Thread Local Caching,线程本地缓存)。
它就像管理员桌边的一辆「临时还书车」:
- 速度极快:还书时不再去书架找位置,直接扔到还书车最上面。
- 后进先出(LIFO):有人借同样大小的书,直接从最上面拿刚放上去的那本。
- 容量有限:2.26~2.29 默认每档最多7个(
mp_.tcache_count),2.30 起改为64个。放满后剩下的才进 fastbin / unsorted bin。
2. Tcache 在内存里长什么样
内存块正在使用时,里面装的是你的数据。
但它被free()放进 tcache 之后,前 16 个字节会被系统征用(64 位):
+0x00 next ← 指向链表中下一个空闲块 +0x08 key ← 2.29 起才有,存的是 tcache 结构自身地址,用于检测 double free于是这些释放的块被串成了一条单向链表。
假设依次申请 A、B,然后依次释放:
free(A) → tcache: A -> NULL free(B) → tcache: B -> A -> NULL (后进先出,B 在最前) malloc() → 返回 B,链表变回 A -> NULL账本结构(tcache_perthread_struct)
tcache 不是一个独立对象,它的「账本」就放在堆的第一块里,共0x250字节:
heap + 0x000 tcache_perthread_struct ├─ counts[64] 每档已缓存的数量(1 字节 × 64) └─ entries[64] 每档的链表头(8 字节 × 64) heap + 0x250 ← 第一个用户 chunk 从这里开始布局图:
+----------------------+ 0x000 | counts[0..63] | | entries[0..63] | +----------------------+ 0x250 | 用户 malloc 的第一块 | +----------------------+这就是为什么算堆偏移时,要先扣掉 0x250。
3. 为什么 Tcache 容易被攻击
Tcache 设计的唯一目的就是快。为了极致速度,早期版本(2.26 / 2.27)几乎去掉了所有安全检查:
- 不检查重复释放:同一本书连续还两次,管理员不会发现。
- 不检查指针合法性:还书车里夹了张写着奇怪地址的纸条,管理员照单全收。
这就给了攻击者巨大的操作空间。
3.1 源码依据:为什么改 next 就能拿到任意地址
取块路径(_int_malloc,glibc 2.27):
size_t tc_idx = csize2tidx (tbytes); if (tc_idx < mp_.tcache_bins && tcache && tcache->entries[tc_idx] != NULL) /* ← 只判断非空 */ return tcache_get (tc_idx);tcache_get:
static __always_inline void * tcache_get (size_t tc_idx) { tcache_entry *e = tcache->entries[tc_idx]; tcache->entries[tc_idx] = e->next; /* ← 把我们的 next 当成新链头 */ --(tcache->counts[tc_idx]); return (void *) e; /* ← 直接返回,不校验 */ }两行决定一切:
entries = e->next → 我们改的 next 变成了新链头 return (void *)e → e 被当成合法内存交出去全程没有任何一处检查:e在不在堆上、是否对齐、size 对不对。
注意:判断能否取块看的是entries[tc_idx]是否为空,不是counts。
所以即使 counts 被减成负数,只要 entries 非空就还能取。
4. 核心攻击手法:Tcache Poisoning
终极目标:欺骗malloc(),让它把一块我们本不该拥有的内存(比如存放关键函数指针的区域)分配给我们;拿到之后就能随意篡改。
前提:程序存在 UAF(Use-After-Free)——内存被释放后,指针没有被清空,你依然能往里写。
第一步:申请内存
申请两个内存块:Chunk A、Chunk B。
第二步:释放内存
依次free(A)、free(B),链表变成:
B -> A -> NULL系统认为:下一次 malloc 给 B,再下一次给 A。
第三步:投毒
利用 UAF 改掉已经释放的 B 里那个指向 A 的 next,写成目标地址:
B -> Target_Address系统被骗了——它以为 B 的下一个空闲块就是那个地址。
第四步:收网
第一次malloc():取走 B,同时把entries更新成B->next,也就是Target_Address。
tcache: Target_Address第五步:达成
第二次malloc():系统把Target_Address当成合法空闲块返回给你。
到这里,你就拿到了那块内存的写权限。
关键:必须两次 malloc。第一次只是把原块取回来,第二次才返回目标地址。
4.1 触发条件
项 | 要求 |
释放次数 | ≥ 1 次(让块进入 tcache) |
投毒时机 | free 之后、malloc 之前 |
投毒长度 | 8 字节就够(只需改 next) |
取块次数 | 第2次 malloc 才返回目标地址 |
目标地址 | 必须可写 |
5. 版本边界与防御机制
因为早期 Tcache 太容易被攻击,后续版本不断打补丁。
glibc 2.29:加入 key 检查
系统在块的+0x08处多写一个key字段,值是tcache 结构自身的地址。
free时如果发现e->key等于该地址,会再遍历链表确认是否重复,命中就报错:
free(): double free detected in tcache 2绕过思路:用 UAF 把 key 覆盖掉,或者走更复杂的堆重叠。
glibc 2.32:Safe-Linking(指针加密)
next 不再明文保存,而是异或加密:
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))注意:右移的是「存 next 这个字段自身的地址」,不是块地址。
存的时候:stored = (pos >> 12) ^ real_ptr
取的时候:real_ptr = (pos >> 12) ^ stored
绕过思路:先用信息泄露拿到堆地址,本地算好异或结果再写进去。
版本边界表
glibc | tcache | 校验 | 投毒可行性 |
≤ 2.25 | 无 | — | 走 fastbin |
2.26 ~ 2.28 | 有 | 无 | ✅ 本文 |
2.29 | 有 |
| ⚠️ 单次 UAF 仍可 |
2.32+ | 有 | Safe-Linking(next 加密) | ❌ 需先泄堆地址 |
2.34+ | 有 | 同上 | ❌ 且 |
6. 完整例子
假设有个全局变量admin_status,地址0x601000,正常为 0,我们的目标是改成 1。
程序存在 UAF:内存还给系统后,指针没断,你依然能往里写。
6.1 攻击代码
#include <stdlib.h> /* 目标:想篡改的全局变量,No PIE 下地址固定 */ long admin_status = 0; int main() { /* 【第一步】申请内存:两个 0x20 请求 → 各得 0x30 的 chunk */ long *A = malloc(0x20); long *B = malloc(0x20); /* 【第二步】释放:先 A 后 B,LIFO 所以 B 在链表头 */ free(A); free(B); /* 【第三步】投毒:UAF,改 B 的 next 指向目标地址 */ *B = 0x601000; /* 【第四步】收网:两次 malloc */ long *C = malloc(0x20); /* 返回 B,链头变成 0x601000 */ long *D = malloc(0x20); /* 返回 0x601000 */ /* 【第五步】致命一击 */ *D = 1; /* 等价于 admin_status = 1 */ return 0; }编译运行:
gcc -o demo demo.c -no-pie ./ld-2.27.so --library-path . ./demo预期:结束时admin_status从0变成1。
注意:真实程序里*B = ...是「用已释放的指针」,这就是 UAF。
真实利用里通常通过edit之类的功能写,而不是直接解引用。
6.2 内存里的暗箱操作
第一步 · 申请
系统从堆里切两块给 A、B,此时 tcache 空:
tcache: 空第二步 · 释放
tcache: [ Chunk B ] -> [ Chunk A ] -> NULL第三步 · 投毒
正常逻辑里,B 的前 8 字节存的是指向 A 的指针。
我们执行*B = 0x601000,把这张「纸条」改掉:
tcache: [ Chunk B ] -> [ 0x601000 ]系统并不会检查0x601000是不是合法空闲内存,它只认指针。
第四步 · 收网
malloc() 拿 C → 返回 B,链表变: [ 0x601000 ] malloc() 拿 D → 返回 0x601000第五步 · 致命一击
*D = 1; /* 实际写的是 admin_status */本来只能在堆里活动,靠欺骗 tcache,拿到了一个不在堆上的地址的控制权。
7. 与其他技巧对比
技巧 | 写入值 | 值可控 | 前置条件 |
tcache poisoning | 你填的任意值 | ✅ | 能改已释放块的 next |
fastbin attack | 你填的任意值 | ✅ | 目标 +8 处的 size 要匹配 fastbin index |
unsorted bin attack | bin 头地址(libc 地址) | ❌ | 块要在 unsorted bin 里 |
large bin attack | victim 的堆地址 | ❌ | 需要两次插入排序 |
一句话:tcache 是唯一「无校验 + 值可控」的组合,所以最省事。
8. 常见误判
误判 | 实际情况 |
改一次 next 就能拿到目标 | 必须两次 malloc,第二次才返回目标 |
counts 归零就取不出来了 | 只看 entries 是否为空 |
2.29 之后完全不能用 | 单次 UAF 仍可,受限的是 double free |
可以把 system 地址写进 next | next 是「下次 malloc 返回的地址」,不是函数指针的值 |
目标地址随便填 | 必须可写,否则拿到后使用时崩溃 |
9. 一句话总结
tcache 的 next 是整条链上唯一不被校验的指针;改它,就等于改了下一次 malloc 的返回值。
📌 相关阅读
- 下一篇:《02|Fastbin Attack 理论:size 校验与错位 chunk 构造》
- 实战配套:《16|Tcache Poisoning 实战:UAF 改 fd 打 __free_hook》
- 进阶:《15|House of Pig 理论:两次 large bin attack + FSOP》
全部 exp 与 gdb 脚本:<GitHub 链接>