内存型漏洞常见模式(三):Use-After-Free (UAF) 的触发机理与生命周期

封面信息图

在现代二进制漏洞攻防中,随着操作系统与编译器对静态栈溢出防护(Stack Canary、NX、PIE)的日趋完善,攻击者逐渐将火力集中到更为复杂多变的堆(Heap)内存空间。而在堆利用技术领域,释放后使用(Use-After-Free, UAF) 长期占据着统治地位。

UAF 漏洞不仅广泛存在于浏览器内核(如 Chrome V8、WebKit)、操作系统内核(Windows/Linux Kernel)中,也是复杂 C/C++ 多线程应用中最难以通过静态代码分析彻底根除的逻辑缺陷之一。

一、UAF 漏洞的根本成因与生命周期模型

UAF 漏洞的物理本质极其纯粹:一块堆内存被 free()delete 释放后,指向该内存的原始指针(悬垂指针, Dangling Pointer)未被及时置为 NULL,并在随后的生命周期中被程序错误地再次引用(Dereference)

阶段 1: 内存分配 (Alloc)
   Pointer A ---> [ 堆块 Chunk (大小 N, 包含虚表/函数指针) ]

阶段 2: 内存释放未清零 (Free -> Dangling)
   Pointer A ---> [ 已释放的 Chunk (进入 Bin 链表, 悬垂状态) ]

阶段 3: 堆占位 (Heap Spray / Reallocation)
   Pointer B ---> [ 重新申请相同大小 N 的 Chunk (攻击者控制数据) ]
   (此时 Pointer A 与 Pointer B 指向同一块物理内存)

阶段 4: 触发执行 (Use)
   执行 A->func_ptr() ---> 实际执行了攻击者伪造的指针,控制流被劫持!

二、经典 UAF 漏洞代码复现与虚表劫持

为了直观呈现控制流被劫持的全过程,我们通过一段包含函数指针的 C 结构体代码进行分析:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct UserSession {
    char username[24];
    void (*print_role)(void); // 8 字节函数指针 (64位)
} UserSession;

typedef struct AttackerControlledChunk {
    char fake_payload[32];    // 同样分配 32 字节大小
} AttackerControlledChunk;

void normal_user_role(void) {
    printf("[*] 普通用户权限\n");
}

void privileged_admin_shell(void) {
    printf("[+] [CRITICAL] 成功劫持控制流!获取管理员特权 Shell\n");
    // 实际利用中此函数指向 system("/bin/sh") 或 ROP 链
}

int main(int argc, char *argv[]) {
    printf("=== Use-After-Free 原理演示 ===\n");

    // 1. 分配原始对象
    UserSession *session = (UserSession *)malloc(sizeof(UserSession));
    strncpy(session->username, "alice", sizeof(session->username) - 1);
    session->print_role = normal_user_role;
    
    printf("[1] 原始会话创建完成,调用函数指针: ");
    session->print_role();

    // 2. 漏洞发生点:释放内存但未置空指针 (产生悬垂指针)
    free(session);
    printf("[2] 会话内存已释放,但 session 指针依然保留地址: %p\n", (void*)session);

    // 3. 攻击者申请相同大小的堆块,ptmalloc/glibc 会优先复用刚释放的 tcache/fastbin 块
    AttackerControlledChunk *fake_chunk = (AttackerControlledChunk *)malloc(sizeof(AttackerControlledChunk));
    
    // 填充 24 字节垃圾数据,精准覆盖 print_role 函数指针位置
    memset(fake_chunk->fake_payload, 'A', 24);
    *(unsigned long long *)(fake_chunk->fake_payload + 24) = (unsigned long long)privileged_admin_shell;
    printf("[3] 恶意堆块已占位原内存地址: %p\n", (void*)fake_chunk);

    // 4. 再次调用原悬垂指针 (Use)
    printf("[4] 再次触发原指针调用: ");
    session->print_role(); // 此时执行了 privileged_admin_shell

    return 0;
}

在上述代码中,当 session 释放后,堆分配器(如 glibc 的 tcache bin)会将该内存标记为空闲。随后 fake_chunk 请求相同尺寸的内存,分配器为了提升性能直接将刚释放的内存返回给它。此时攻击者注入的恶意函数指针恰好覆盖了原本 session->print_role 的偏移位置,悬垂指针一旦被解引用,程序控制流即刻失陷。

三、现实对抗中的堆风水(Heap Feng Shui)与利用壁垒

在复杂的真实应用程序(如浏览器内核)中,利用 UAF 远比简单的玩具代码艰难,攻击者通常需要精巧运用以下技术:

1. 堆风水与内存占位(Heap Spraying)

由于应用中存在大量并发分配与释放,为了确保目标堆块被攻击者的数据 100% 占位,攻击者通常先进行“堆排布整理(Heap Grooming)”:

  • 批量申请大量固定大小的对象,填满零散的内存碎片。
  • 制造出交替排列的对象序列,再主动释放特定对象留下“空洞”。
  • 触发目标 UAF 的释放逻辑,并立即批量喷射带有恶意虚表指针的对象填补空洞。

2. 绕过 ASLR 的两阶段利用

在开启全保护的环境下,攻击者无法直接预知目标函数的内存绝对地址:

  • 第一阶段(信息泄露):利用 UAF 覆盖对象的长度字段或读取残留指针,造成越界读(Out-of-Bounds Read),将动态库或堆基址泄露给攻击脚本。
  • 第二阶段(控制流劫持):根据计算出的基址动态组装伪造的虚表(Fake Vtable),再次触发 UAF 执行特权代码。

四、防御与系统级免疫方案

治理 UAF 漏洞必须从语言层面、分配器加固与运行时检测三维发力:

  1. 指针安全规范(Safe Pointer Idiom)
    • 严格落实 FREE_AND_NULL 宏,在执行 free(p) 后强制紧跟 p = NULL;
    • 在 C++ 代码中全面淘汰原始裸指针(Raw Pointers),强制使用 std::unique_ptrstd::shared_ptr 智能指针管理对象生命周期。
  2. 现代安全分配器(Hardened Allocators)
    • 使用具备隔离堆(Isolated Heap)与延迟释放机制的安全分配器(如 Google PartitionAlloc、DieHarder、Scudo)。
    • 安全清零(Zero-on-Free):在内存释放时立即覆写随机噪声或置零,破坏利用残留数据构造的信息泄露。
  3. 运行时动态检测门禁
    • 研发与集成测试阶段全面开启 LLVM AddressSanitizer(ASan)。ASan 会在堆块周围插入隔离区(Redzones),并将释放的内存放入隔离池(Quarantine),一旦检测到访问已释放内存即刻打印完整的调用溯源并中止进程。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐