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 被删,落点得换 |
变的是利用手法,不是漏洞本身。UAF 一直都在。
最后
UAF 不是一个"技巧",是一个入口。
它给你的只是「一块已经释放、但还能操作的内存」。至于拿它泄地址、改链、还是改个函数指针——取决于它此刻躺在哪个 bin 里。
所以下次碰到 UAF,先别急着写 payload,先问一句:
这块内存现在归谁?
📌 相关阅读
· 上一篇:《04|Large Bin Attack:把一个堆地址写到任意位置》
· 下一篇:《06|Chunk Extend:改一个 size,造出两块重叠内存》
· 实战配套:《18|Use-After-Free 实战:双档 bin 分离改函数指针》
全部 exp 与 gdb 脚本:<GitHub 链接>