搞堆利用的人应该都有个共同感受:fastbin和unsorted bin的链路里要防的检查太多,size校验、fd/bk完整性、consolidate时机,一步没算对就可能直接打崩。而tcache attack是另一种画风——glibc从2.26引入tcache之后,堆利用的很多经典套路被大幅简化,一个UAF甚至就能通过改写next指针让malloc返回任意地址。这篇文章围绕tcache attack这条技术线,聊清楚它的结构基础、最经典的poisoning、double free在2.29之后的绕过方式,以及2.32引入safe-linking之后的玩法变化,最后会落到实战中的版本识别和调试习惯。下面的讨论都以CTF题目和本地漏洞研究程序为场景,不要拿去对真实未授权环境做任何事情。
1. 先看tcache的实际结构:它凭什么让堆利用变得“简单”
1.1 tcache_perthread_struct与bin的组织方式
tcache的全称是thread-local cache,设计初衷是性能优化:每次malloc和free都要进arena、加锁、走bin链表,多线程下锁竞争非常严重。glibc给每个线程维护一个独立的缓存区域,小块内存的分配释放直接在这块区域里完成,不进全局bin,也不需要碰arena锁。
这块区域就是tcache_perthread_struct,位于堆的起始位置(或者线程独立mmap出来的区域里)。它的结构大致是:
typedef struct tcache_perthread_struct { uint16_t counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS]; } tcache_perthread_struct; typedef struct tcache_entry { struct tcache_entry *next; struct tcache_perthread_struct *key; // 2.29之后才有 } tcache_entry;counts记录每个bin里当前缓存的chunk数量,entries是每个bin的链表头。bin的编号由chunk size决定:64位下chunk size从0x20开始,每0x10一个档,0x20对应tc_idx=0,0x30对应tc_idx=1,以此类推。每个bin默认最多缓存7个chunk,超过7个之后,free的chunk会走常规流程进入fastbin或者unsorted bin。
tcache的链表操作是典型的单链表LIFO。释放时,新chunk插到头部;分配时,从头部取。这跟fastbin的直觉一致,但有一个关键差异:tcache的get和put逻辑极少校验size和next的合法性,只要指针满足对齐,基本就能成功分配。这一点直接决定了后面所有tcache攻击的基础。
1.2 tcache_entry字段演进:从next到key再到safe-linking
不同glibc版本下,tcache_entry的结构和检查策略完全不同,做题目第一步就是要确认libc版本。我把几个关键分界点列出来:
| glibc版本 | tcache_entry结构 | double free检查 | next指针保护 | 常见利用目标 |
|---|---|---|---|---|
| 2.26 ~ 2.28 | 仅next | 无 | 无 | __malloc_hook/__free_hook |
| 2.29 ~ 2.31 | next + key | e->key == tcache则报错 | 无 | __free_hook |
| 2.32 ~ 2.33 | next + key | key检查保留 | PROTECT_PTR保护 | __free_hook,但需绕过safe-linking |
| 2.34+ | next + key | key检查保留 | PROTECT_PTR保护 | hook已移除,转向栈/IO结构 |
2.29加入key字段的动机很直接:补上tcache double free的检测。每次free入链时会把e->key指向当前线程的tcache_perthread_struct;malloc取出时会清零。如果某个chunk已经在tcache里,再次free时发现e->key还是那个结构体指针,就会报free(): double free detected in tcache 2。
2.32加入safe-linking则是为了防御对next指针的直接篡改,后面单独讲。版本差异不是背下来就完事,而是要转化成做题时的直觉:看到2.27直接想poisoning和裸double free;看到2.31想怎么绕key;看到2.32+先想怎么拿堆地址。
2. 最经典的tcache poisoning:改一个next指针的完整推导
2.1 最小Demo:把tcache链表头改成任意地址
tcache poisoning的核心思路,说穿了就是一句话:你控制了某个tcache bin链表头部entry的next,malloc两次就能把你要的地址分配出来。
假设程序有UAF漏洞,允许chunk释放后继续读写。我们申请两个0x30的chunk,记为A和B,然后释放:
size_t *a = malloc(0x30); size_t *b = malloc(0x30); free(a); free(b);释放完,tcache idx对应0x30 bin的链表是b -> a -> NULL。也就是说当前bin头部是b,b的next存放着a的地址。
现在通过UAF改掉b的第一个size_t:
b[0] = (size_t)target; // 把b->next改成target此时链表逻辑上变成b -> target -> ...。接着做两次malloc:
void *p = malloc(0x30); // 第一次返回b,链表头变成target void *q = malloc(0x30); // 第二次返回target第一次malloc把b取走,tcache_get会把bin头部更新为REVEAL_PTR(b->next),也就是target。第二次malloc就不会再从正常的堆区分配,而是直接把target当作下一个entry返回。
关键点在于:tcache_get返回的时候,它根本不去检查target位置上有没有合法的chunk header、size字段对不对、prev_inuse位是什么。它只要求target地址按16字节对齐。这就是tcache attack比fastbin攻击舒服很多的原因。
补充一点:b[0] = (size_t)target这个写法有个前提,就是你能写的是b的用户区。tcache_entry就放在chunk用户区的起始位置,所以用户区的第一个size_t就是next字段。平时用*(size_t **)b = (size_t *)target也是同一个意思。
2.2 一个能真正反弹shell的本地验证程序
上面只是原理演示,实际利用一般会把target指向__free_hook。glibc 2.34之前,free被调用时会先检查__free_hook是否非空,非空就跳过去执行。覆盖它是最经典的操作。
在glibc 2.31环境下,可以写这样一个完整demo:
#include <stdio.h> #include <stdlib.h> #include <string.h> extern void *__free_hook; int main(void) { setbuf(stdout, NULL); char *a = malloc(0x30); char *b = malloc(0x30); free(a); free(b); // UAF:改写b的next,指向__free_hook *(size_t **)b = (size_t *)&__free_hook; malloc(0x30); // 返回b,tcache头部变成__free_hook char *p = malloc(0x30); // 返回__free_hook *(void **)p = (void *)system; // 把hook覆盖成system地址 char *cmd = malloc(0x30); strcpy(cmd, "/bin/sh"); free(cmd); // free触发__free_hook,等价于system("/bin/sh") return 0; }注意看第二次malloc返回的p,它现在指向的就是__free_hook符号所在地址。我们往p里写system,等于直接修改libc数据段里的hook指针。最后free(cmd)调用时,系统发现hook非空,于是跳去执行system(cmd),而cmd的内容是/bin/sh,shell就起来了。
这类代码只能在本地隔离的实验环境跑,别用它去碰任何不归你管的系统。CTF题目里的利用思路基本就是这个骨架,区别只是漏洞点可能藏在菜单题的edit、delete逻辑里,而不是直接写个裸UAF。
2.3 为什么tcache比fastbin更好打:检查点对比
很多人学堆利用时是先接触fastbin attack,再到tcache,会明显感觉到难度降了一截。背后是检查点的差异:
| 检查点 | fastbin | tcache |
|---|---|---|
| 出链时校验chunk size | 不同版本有差异,多数会校验 | 不校验目标地址的size字段 |
| 指针对齐 | 16字节对齐 | 16字节对齐 |
| double free检测 | 有专门检查或依赖fd隔离 | 2.29前无检测,2.29后只查key |
| 链表节点数量 | 无强限制 | 默认每个bin最多7个 |
| 加锁 | 需要通过arena锁 | per-thread无锁 |
fastbin在取出chunk时,malloc会检查fake chunk的size字段是否落在当前fastbin的size范围内,还要看prev_inuse位。tcache完全没有这套约束,所以到处都可以是目标地址,哪怕是全局变量区、栈上、或者某个没有合法chunk header的地址。
这也带来一个容易踩的坑:malloc请求的大小必须对应同一个tcache_idx。如果目标bin是0x40那一档,利用时所有malloc请求都必须是能落到这一档的size。比如你想分配一个0x30的chunk,但target地址上的内容根本不影响idx分类,真正决定走哪个bin的是你请求的size。所以poisoning时,之前申请、释放的chunk size和目标后续要拿的chunk size必须对齐,这也是做题时最常犯的低级错误。
3. double free与key字段:2.29之后的绕过思路
3.1 2.29之前的裸double free:同一块内存分配两次
在glibc 2.26到2.28这段时间,tcache没有任何double free检测。释放同一个chunk两次,tcache链表里会出现两个相同的entry。后面malloc两次,会拿到同一个地址。这被称为tcache dup。
最简单的演示:
char *a = malloc(0x30); free(a); free(a); // 2.29之前不会报错,tcache里出现两个a char *x = malloc(0x30); // 返回a char *y = malloc(0x30); // 再次返回a拿到两个指向同一块内存的指针后,就可以做很多事。比如程序只允许对某块内存写一次,但你想在写完后还能修改它,double free给你一个额外的别名;或者你可以在第二次分配时把chunk的next字段改掉,形成任意分配。
裸double free在2.29之后直接失效,因为第二次free时e->key仍然等于tcache指针,触发free(): double free detected in tcache 2。但绕过思路也没那么复杂。
3.2 key检查逻辑与三类绕过手法
2.29之后,free入tcache时:
e->key = tcache;malloc取出时:
e->key = NULL;第二次free同一个chunk时,如果发现e->key == tcache,说明这个chunk还躺在tcache里,于是报错。
绕过思路主要围绕“让第二次free时key不等于tcache”展开:
第一类:UAF改写key。第一次free之后,用UAF把e->key改成别的值,比如0或某个堆地址,再触发第二次free。检查就失效了。这是最通用的做法,因为很多堆题本来就有UAF写,多写一个字段即可。
第二类:先取出再释放。第一次free后,通过malloc把chunk从tcache里取出来,此时key被清成NULL。接着再free,自然不满足e->key == tcache。但这其实已经不构成严格意义的double free,因为tcache里只有一份副本。这种手法真正的价值是配合UAF形成“已分配但还能改”的状态,属于堆风水的一部分。
第三类:制造tcache链表循环。如果能让e->next指向自身,再配合key检查的特殊分支逻辑,有机会绕过或者跳过检测。这个在真实利用里相对少见,更多是作为研究点存在。
做题时最高效的做法是:先把key改掉,再double free,把两条相同entry链进同一个bin,后面就能配合poisoning把某个地址返回出来。这种组合在glibc 2.29~2.31的题目里非常常见,模型简单,效果稳定。
3.3 stashing unlink attack:通过small bin把伪造chunk塞进tcache
除了直接绕key,还有一种更精巧的利用叫tcache stashing unlink attack,主要出现在glibc 2.29之后的版本里。
它的原理来自malloc在分配small bin chunk时的“stash”逻辑。当请求的size对应tcache有空位时,malloc会尝试从small bin尾部取出chunk,把它们补进tcache,每次最多补满剩余空位。这个过程中有两个关键写操作:
- 取出尾部chunk V后,执行
bin->bk = V->bk和V->bk->fd = bin - 如果第二次循环取出的fake chunk也被当作合法small bin chunk,就会执行类似的写,并且把fake chunk链接进tcache
我们可以控制small bin尾部chunk的bk指针,把它改成一个fake chunk地址附近,从而利用V->bk->fd = bin实现一次任意地址写(写入的值是main_arena某个偏移)。同时fake chunk还会被塞进tcache,之后malloc时可能直接返回这个fake chunk。
作用可以归纳成两个:
- 一次任意size_t写,通常用来改栈上数据、函数指针或者某个关键全局。
- 往tcache里塞fake chunk,实现“在任意地址分配”。
这个手法的前置条件是:small bin里存在至少两个chunk,tcache对应bin还有空位,并且攻击者有能力篡改small bin尾部chunk的bk指针。实际构造一般依赖UAF或任意写,做题时需要先把small bin的布局整理成想要的形态。写栈、FSOP、改__free_hook,都是基于这两种效果的扩展。
4. safe-linking与stashing:2.32之后的攻击面扩张
4.1 PROTECT_PTR与REVEAL_PTR的计算逻辑
glibc 2.32给tcache和fastbin的next指针加了一层地址混淆保护,官方叫safe-linking。核心宏:
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr))) #define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)入链时:
e->next = PROTECT_PTR(&e->next, tcache->entries[tc_idx]);出链时:
tcache->entries[tc_idx] = REVEAL_PTR(e->next);计算逻辑不复杂:把存储位置的地址右移12位,再跟原始指针异或。由于存储位置的地址低12位是零(因为指针按页对齐),实际上就是“页基址”跟真实指针做异或。
有了这层保护,你直接往b[0]写一个明文target是没用的,因为malloc取链表头时会做REVEAL,你写入的明文会被当成encoded去解码,结果完全不是你想要的。
4.2 绕过safe-linking的三条现实路径
路径一:泄露堆地址,重新编码写入。这是最正统的办法。既然enc = (pos >> 12) ^ ptr,那我们知道pos、知道目标ptr,自然能算出要写入的enc。
pos通常是某个tcache entry里next字段自己的地址。只要泄露了堆基址,再加上偏移就能算出pos。堆地址怎么拿?最直接的是读tcache entry里的key字段。2.29之后,chunk free入tcache时e->key = tcache,这个key就是tcache_perthread_struct的地址,而它通常位于heap的起始位置,泄露出来就等于泄露了堆基址。
也可以在safe-linking下直接读出encoded next,再结合已知的pos反推下一个chunk地址,公式是reveal = (pos >> 12) ^ encoded。拿到两个堆地址之后,堆基址基本就确定了。
路径二:partial overwrite。如果目标地址跟next原值在同一个页(或至少高位相同),可以利用safe-linking的一个性质:enc的低12位事实上就是ptr的低12位(因为pos >> 12的低12位为零)。所以只改写低2字节,高位保持不动,REVEAL之后得到的新指针只会改变低位偏移,高位跟原来一致。这在堆内部做chunk地址搬运时很好用,不需要泄露堆基址。
路径三:利用不受保护的数据源。比如某些全局结构、栈上的残留指针、或者程序自己保存的明文指针。安全研究里常说“保护的是指针,不是信息”,只要程序某个角落还存着明文堆指针,safe-linking就只是多一道计算题,不是一堵墙。
实际做题时我建议这些方法组合使用:先用key字段泄露堆基址,再计算encoded写入,最稳妥。partial overwrite适合特定场景,但受对齐限制比较大,不如编码写入通用。
4.3 现代libc下的目标迁移:hook被删了怎么办
glibc 2.34把__malloc_hook、__free_hook、__realloc_hook等一组hook符号全部移除。这直接改变了tcache attack的落点:你再也不能通过poisoning把某个hook覆盖成system了。
但tcache poisoning这个原语本身没有失效,只是final target换了。现在常见的方向有:
- 往栈上写ROP链,覆盖返回地址或函数指针。
- 改
_IO_list_all相关结构,打FSOP。 - 利用
environ泄露栈地址,再结合tcache poisoning把某个bin的链表头改到栈上,分配出栈地址。 - 覆盖
setcontext附近的gadget,配合寄存器控制完成迁移。 - 改程序自己的全局函数指针、跳转表,这类目标不依赖libc内部符号。
简单说,hook没了不代表tcache attack废了,而是从“一步拿shell”变成“需要更多信息泄露和更精巧的payload”。做题时2.34+的题目,通常要先完成信息泄露,再把tcache的任意分配指向栈或IO结构。
5. 实战视角:版本识别、调试方法与踩坑记录
5.1 拿到堆题先判断能不能用tcache
不同版本的libc对应不同的攻击面,我拿到一个程序会先做三件事:
- 确认libc版本。最直接的方式是看题目给的libc文件名,或者用
strings libc.so.6 | grep "GNU C Library"。 - 确认漏洞原语。UAF是核心,能多次读写是最好的;只能写一次的情况会限制攻击链;纯溢出可能要走堆重叠。
- 确认目标地址可写性。如果glibc版本在2.34以下,优先考虑hook;如果在2.34以上,就要想信息泄露和ROP落点。
一张简单的原语对照表可以帮自己快速定位:
| 漏洞场景 | 首选利用方式 |
|---|---|
| UAF可写,glibc < 2.32 | tcache poisoning 直达hook |
| UAF可写,glibc >= 2.32 | 先泄露堆地址,再编码写入进行poisoning |
| 可free两次,glibc < 2.29 | 裸double free,制造chunk别名 |
| 可free两次,glibc >= 2.29 | 改key后再double free,或配合堆风水绕检查 |
| 能控制small bin链,glibc >= 2.29 | tcache stashing unlink attack |
| 2.34+ | poisoning + 栈/IO结构,需要信息泄露链 |
这张表不是万能的,但能避免你满脑子house of xxx却选错方向。
5.2 调试技巧:直接在gdb里观察tcache状态
做题时我最常用的不是纸上推演,而是用gdb把tcache的状态打印出来直接看。有pwndbg或gef这类插件时,heap bins、tcache两条命令就能看到每个bin的链表情况:
pwndbg> heap binspwndbg> tcache没有插件时,手工查看也不难。通过heap命令找到tcache_perthread_struct地址,直接打印:
p *(struct tcache_perthread_struct*)$heap_base x/20gx $heap_base在恶意free之后,立刻用x/4gx查看chunk用户区,就能看到next和key字段当前的值。特别是safe-linking下,你可以直接读到encoded的next,然后拿到堆地址。这是做2.32+题目时的标准动作。
调试还有一个习惯:每次malloc前后都把tcache链表状态记录下来,确认自己预判的LIFO顺序没有错。很多poisoning失败不是利用思路问题,而是释放和分配的顺序跟想象中不一样。
5.3 我踩过的几个坑
坑一:忘记tcache的LIFO顺序。释放A再释放B,bin头部是B。第一次malloc返回B,第二次才返回A。很多人把顺序记反,导致写错chunk、错位一次malloc,最终分配的地址完全不对。
坑二:忽略tcache bins容量上限。默认每个bin最多存7个chunk。第8个释放时不会再进tcache,而是进fastbin或unsorted bin。如果一门心思做tcache poisoning,却发现free后counts没有增加,就是撞上这个上限。
坑三:safe-linking下直接写明文指针。2.32之后还往next里写裸地址会百思不得其解。编码公式一定要先算对,同时想清楚pos是存储位置的地址,不是chunk用户区起始地址。
坑四:还在找__free_hook。2.34之后编译链接libc就没有这个符号了。打了半天gdb发现符号不存在,先看版本再做目标规划。
坑五:分配目标没有对齐。tcache出链要求entry地址16字节对齐。目标选在__free_hook+4这种地方,malloc直接报unaligned tcache chunk,相当于白折腾。
坑六:hook覆盖后的参数完全没控制。覆盖__free_hook成system后,被free的指针会作为参数传给system。如果指针内容不是合法的shell命令地址,程序会直接崩掉。所以一般要先在可控堆块里写/bin/sh,再free那个chunk。
如果刚接触这块,我建议按这样的顺序刷题:先做glibc 2.27的tcache poisoning,再做2.29的double free绕key,接着做2.31的stashing unlink,最后上2.32+的safe-linking题目。每一步都把手工计算和gdb观察对上,后面再遇到house of系列其实就是这些基础原语的排列组合。
我个人在实际操作中的体会是:tcache attack真正难的不是某个特定技巧,而是把所有“malloc会返回什么”的细节用链表视角想清楚。每道题拿到手,先把libc版本、漏洞原语、tcache链表形态这三件事列出来,攻击链基本就浮出水面了。