首页 / 资讯中心 / 文章详情

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

tcache攻击实战:从poisoning到safe-linking ★ FEATURED ARTICLE
搞堆利用的人应该都有个共同感受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_idx00x30对应tc_idx1以此类推。每个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_hook2.29 ~ 2.31next keye-key tcache则报错无__free_hook2.32 ~ 2.33next keykey检查保留PROTECT_PTR保护__free_hook但需绕过safe-linking2.34next keykey检查保留PROTECT_PTR保护hook已移除转向栈/IO结构2.29加入key字段的动机很直接补上tcache double free的检测。每次free入链时会把e-key指向当前线程的tcache_perthread_structmalloc取出时会清零。如果某个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的nextmalloc两次就能把你要的地址分配出来。假设程序有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头部是bb的next存放着a的地址。现在通过UAF改掉b的第一个size_tb[0] (size_t)target; // 把b-next改成target此时链表逻辑上变成b - target - ...。接着做两次mallocvoid *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); // 返回btcache头部变成__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/shshell就起来了。这类代码只能在本地隔离的实验环境跑别用它去碰任何不归你管的系统。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。作用可以归纳成两个一次任意size_t写通常用来改栈上数据、函数指针或者某个关键全局。往tcache里塞fake chunk实现“在任意地址分配”。这个手法的前置条件是small bin里存在至少两个chunktcache对应bin还有空位并且攻击者有能力篡改small bin尾部chunk的bk指针。实际构造一般依赖UAF或任意写做题时需要先把small bin的布局整理成想要的形态。写栈、FSOP、改__free_hook都是基于这两种效果的扩展。4. safe-linking与stashing2.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.32tcache poisoning 直达hookUAF可写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 attack2.34poisoning 栈/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再释放Bbin头部是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_hook4这种地方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链表形态这三件事列出来攻击链基本就浮出水面了。
阅读完成 · 觉得有帮助?
咨询建站