news 2026/10/19 9:17:50

tcache攻击实战:从poisoning到safe-linking

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
tcache攻击实战:从poisoning到safe-linking

搞堆利用的人应该都有个共同感受: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.31next + keye->key == tcache则报错无__free_hook
2.32 ~ 2.33next + keykey检查保留PROTECT_PTR保护__free_hook,但需绕过safe-linking
2.34+next + keykey检查保留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,会明显感觉到难度降了一截。背后是检查点的差异:

检查点fastbintcache
出链时校验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。

作用可以归纳成两个:

  1. 一次任意size_t写,通常用来改栈上数据、函数指针或者某个关键全局。
  2. 往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对应不同的攻击面,我拿到一个程序会先做三件事:

  1. 确认libc版本。最直接的方式是看题目给的libc文件名,或者用strings libc.so.6 | grep "GNU C Library"。
  2. 确认漏洞原语。UAF是核心,能多次读写是最好的;只能写一次的情况会限制攻击链;纯溢出可能要走堆重叠。
  3. 确认目标地址可写性。如果glibc版本在2.34以下,优先考虑hook;如果在2.34以上,就要想信息泄露和ROP落点。

一张简单的原语对照表可以帮自己快速定位:

漏洞场景首选利用方式
UAF可写,glibc < 2.32tcache 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.29tcache stashing unlink attack
2.34+poisoning + 栈/IO结构,需要信息泄露链

这张表不是万能的,但能避免你满脑子house of xxx却选错方向。

5.2 调试技巧:直接在gdb里观察tcache状态

做题时我最常用的不是纸上推演,而是用gdb把tcache的状态打印出来直接看。有pwndbg或gef这类插件时,heap bins、tcache两条命令就能看到每个bin的链表情况:

pwndbg> heap bins
pwndbg> 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链表形态这三件事列出来,攻击链基本就浮出水面了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 9:17:44

代码评审工程化:从评审单元设计到门禁落地的完整实践

1. 代码评审不是“走过场”&#xff0c;而是一条工程流水线先讲一个我经历过的事。之前在一个业务团队里&#xff0c;代码评审这词儿谁都不陌生&#xff0c;但实际操作基本是“有 PR 就点一下 Approve”&#xff0c;评审意见永远是“LGTM”。直到某次大促前夜&#xff0c;一个看…

作者头像 李华
网站建设 2026/10/19 9:17:14

Qwen2-VL与vLLM部署实战:从单卡到多卡的多模态服务化方案

最近在业务里遇到一个很典型的需求&#xff1a;让系统能够“看懂图片”。输入是产品截图、工单图片、纸质单据照片&#xff0c;模型不仅要理解图里的内容&#xff0c;还要给出描述或者回答。纯文本大模型肯定做不到&#xff0c;最终我们选了 Qwen2-VL 配合 vLLM 来做服务化部署…

作者头像 李华
网站建设 2026/10/19 9:16:44

MFC图形与界面编程实战:GDI绘图、消息映射与双缓冲技巧

简介&#xff1a;这是一份围绕MFC对话框编程的完整示例工程&#xff0c;面向C/Windows桌面应用开发者&#xff0c;适合想要掌握图形界面编程的入门及中级读者。资源以ShowDialog为项目名&#xff0c;系统演示了从CDialog派生对话框类、重写OnInitDialog进行控件初始化、调用DoM…

作者头像 李华
网站建设 2026/10/19 9:16:32

离线配置yum源:内网服务器无外网安装软件的完整指南

先说结论&#xff1a;这批服务器的最终解决方案&#xff0c;就是题目里写的那套——“离线配置yum源”。当时我接手这个环境&#xff0c;一台崭新的机器要装二十多个依赖包&#xff0c;外网不通&#xff0c;光盘里又没有想要的软件版本&#xff0c;折腾到半夜才彻底理顺。今天这…

作者头像 李华
网站建设 2026/10/19 9:15:17

滑动窗口协议课程设计:C语言实现停等、GBN与SR协议模拟

简介&#xff1a;面向北京邮电大学“计算机网络”课程设计与期末大作业场景&#xff0c;压缩包完整模拟数据链路层的滑动窗口协议&#xff0c;适合正在完成计网实验、需要参考可靠传输机制实现思路的本科生。核心内容包括选择性重传、回退N帧等经典协议算法的C语言源码&#xf…

作者头像 李华
网站建设 2026/10/19 9:14:49

番茄成熟度检测数据集实战:VOC+YOLO格式与训练避坑指南

搞目标检测的人应该都有同感&#xff1a;模型结构能抄、训练trick能背&#xff0c;唯独数据这件事&#xff0c;真不是想有就能立刻到手的。尤其农业场景&#xff0c;你得蹲大棚、等果实长到不同成熟度、还要凑光照合适的天气去采集&#xff0c;难度和成本都不低。所以看到“智慧…

作者头像 李华