ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

05|Use-After-Free:钥匙还在你手里,柜子已经不是你的了

05|Use-After-Free:钥匙还在你手里,柜子已经不是你的了

Use-After-Free:钥匙还在你手里,柜子已经不是你的了

UAF,Use After Free,翻译过来就是「释放后重用」。

这个词听着抽象,其实用一个场景就讲明白了——而且这个场景你八成真遇到过。

先说这个场景

去健身房,办卡的时候管理员给你一个储物柜。

1. 租柜子 → 管理员把 10 号柜给你,钥匙在你手上 2. 放东西 → 你把衣服塞进 10 号柜 3. 退柜子 → 你跟前台说不用了,系统里把 10 号柜标成"空闲" 4. 但你没还钥匙 → 你昨天下班顺手配了一把带着

第二天,另一个人来健身,前台看 10 号柜空着,就租给了他,他把钱包手机放了进去。

你拿着昨天配的那把钥匙,开门了。

抽屉里不是你的衣服,是别人的钱包。

这就是 UAF。柜子重新租出去了,但钥匙没作废。

对应到程序里:

储物柜 = 一块内存 钥匙 = 指向它的指针 退柜子 = free() 配的钥匙 = 悬垂指针(dangling pointer)

代码上就这么几行

#include <stdlib.h> #include <string.h> #include <stdio.h> int main() { char *p = malloc(100); strcpy(p, "正常的数据"); free(p); /* 注意:free 只告诉系统"我不用了",p 里存的地址一个字都没变 */ char *q = malloc(100); strcpy(q, "别人的数据"); printf("%s\n", p); /* 打印出来的是"别人的数据" */ return 0; }

跑一下就知道:p和q大概率是同一个地址,所以printf(p)打印的是q写进去的东西。

这不是巧合。分配器为了效率,永远优先复用刚释放的内存——你 free 完,它立刻躺在回收站里等着被再分配。

但关键不在"复用了",在"被谁接手"

我之前一直以为 UAF 就是「用了已释放的内存」,一句话讲完。

后来才发现,真正的分水岭是:free 之后,那块内存进了哪个 bin。

因为它进了哪里,你读出来的东西完全不一样:

进 tcache(2.26+,尺寸 ≤ 0x410)

+0x00 next ← 堆地址,或者 NULL +0x08 key ← 2.29 起才有,值是 tcache 结构地址

进 fastbin(尺寸 ≤ 0x80)

+0x00 fd ← 堆地址,或者 NULL

进 unsorted bin(尺寸大于 fastbin/tcache 上限,且后面没挨着 top)

+0x00 fd ← main_arena + 0x58/0x60,是个 libc 地址 +0x08 bk ← 同上

所以:

块进 tcache / fastbin → 你泄到的是堆地址,或者 NULL 块进 unsorted → 你泄到的是 libc 地址

这就是「UAF 能用来泄 libc」的真正原因——不是 UAF 本身能泄什么,是它 free 完之后恰好躺进了带 libc 指针的那个 bin。

三种常见的来路

第一种:free 完忘了置空。

free(p); /* 少了 p = NULL; */

最常见,也最好查。

第二种:别名。

char *a = malloc(0x20); char *b = a; free(a); strcpy(b, "x"); /* b 还在用 */

这种最难查,free(a)那行看起来毫无问题。

第三种:回调 / 闭包 / 观察者。

cfg->on_event = handler; /* 外部存了指针 */ config_free(cfg); /* cfg 释放了 */ /* 之后 handler 被触发 → UAF */

真实软件里这种最多,因为生命周期的管理权被分散到了两边。

黑客拿它能干什么

三个方向,按门槛从低到高:

读——如果程序有show之类的功能能读已释放的块,那就是一个泄露点。

写——能往 freed chunk 里写,就能改 next,这就是 tcache poisoning / fastbin attack 的入口。

改完再触发——不需要控制分配器,只要程序后面还会用这块内存。比如:

typedef struct { char buf[0x18]; void (*fn)(void); } obj_t; obj_t *o = malloc(sizeof(obj_t)); o->fn = hello; free(o); /* 指针没清 */ o->fn = pwned; /* UAF:改写函数指针 */ o->fn(); /* 调用它 */

这段能跑通,输出pwned。没有任何"堆布局"可言,就是纯改内存。

一个容易忽略的细节

进了 tcache 的块,前 8 字节是 next、第 2 个 8 字节是 key。

如果你想改的是别的字段(比如上面那个fn,在+0x18),那两个位置随便写都无所谓——不影响链,也不影响你要改的地方。

所以「UAF 能改什么」取决于两件事:它进了哪个 bin,以及你要改的字段在不在 bin 元数据的范围内。

和 double free 别混

UAF 是用一块已经不归你的内存;double free 是再还一次。

2.29 给 tcache 加的key检查只防 double free:

if (__glibc_unlikely (e->key == tcache)) /* 再遍历链表确认 → 报 "free(): double free detected in tcache 2" */

它完全防不住 UAF。所以 2.29 之后 UAF 照样能打,只是 tcache 那条投毒的路变窄了。

怎么修

写代码的话,就一条:

free(p); p = NULL; /* 立刻作废这把钥匙 */

后面再误用p,程序会立刻崩在空指针上——崩溃比被利用好一百倍,至少你知道出事了。

语言层面的解法:

  • Java / Python / Go / C#:GC 自动回收,根本没有"手动 free"这一步
  • Rust:所有权 + 借用检查,悬垂指针在编译期就过不去
  • 现代 C++:std::unique_ptr/std::shared_ptr,生命周期结束自动释放并置空

版本

glibc

UAF 能做什么

≤ 2.25

泄 libc、改 next、改结构体,全都能

2.26 ~ 2.28

多了 tcache,投毒更简单

2.29+

能读能写,但 double free 路径被 key 堵住

2.32+

next 加密了,改链要先泄堆地址

2.34+

hook 被删,落点得换_IO那条线

变的是利用手法,不是漏洞本身。UAF 一直都在。

最后

UAF 不是一个"技巧",是一个入口。

它给你的只是「一块已经释放、但还能操作的内存」。至于拿它泄地址、改链、还是改个函数指针——取决于它此刻躺在哪个 bin 里。

所以下次碰到 UAF,先别急着写 payload,先问一句:

这块内存现在归谁?


📌 相关阅读
· 上一篇:《04|Large Bin Attack:把一个堆地址写到任意位置》
· 下一篇:《06|Chunk Extend:改一个 size,造出两块重叠内存》
· 实战配套:《18|Use-After-Free 实战:双档 bin 分离改函数指针》

全部 exp 与 gdb 脚本:<GitHub 链接>

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进