堆溢出 (Heap Overflow) 是指发生在程序堆 (Heap) 内存区域的缓冲区溢出漏洞。

与栈溢出(Stack Overflow)相比,堆溢出的利用难度通常更高,机制更复杂,但也更灵活。因为堆是动态分配的(malloc/new),没有像栈那样固定的“返回地址”可以覆盖,所以攻击者必须利用堆管理器的内部机制覆盖堆上的函数指针/对象结构来劫持控制流。

1. 堆与栈的区别 (为什么堆溢出难搞?)

特性栈 (Stack)堆 (Heap)
内存分配编译器自动分配释放程序员手动 malloc/free
数据排列连续紧密,结构固定 (LIFO)碎片化,链表连接,结构动态
溢出目标返回地址 (Return Address)元数据 (Metadata)函数指针C++虚表
利用核心覆盖 EIP/RIP欺骗堆管理器 (Heap Manager) 进行任意地址写

2. 堆的内部结构 (以 Linux Glibc Ptmalloc 为例)

要理解堆溢出,必须理解Chunk (堆块)

当我们调用 p = malloc(10) 时,分配器不仅仅给了你 10 字节。它还在内存中给这块地“加了头”和“加了脚”,用来记录这块内存的大小、状态(是否空闲)。这个整体结构叫 Chunk

Chunk 的典型结构(64位)

一个被分配使用的 Chunk 在内存中长这样:

+-------------------+-------------------+
|  prev_size (8B)   |     size (8B)     |  <-- Chunk Header (元数据)
+-------------------+-------------------+ <--- 用户拿到的指针 p 指向这里
|                                       |
|               User Data               |
|          (用户申请的数据区域)           |
|                                       |
+-------------------+-------------------+
  • prev_size: 如果前一个物理相邻的 Chunk 是空闲的,这里记录它的大小(用于合并)。
  • size: 当前 Chunk 的大小,最低几位包含标志位(如 PREV_INUSE,表示前一个块是否正在使用)。

关键点: 堆块在内存中是紧挨着排列的。如果你在 Chunk A 中写入过多数据,就会直接覆盖到 Chunk B 的 Header (元数据)

3. 堆溢出的攻击原理

堆溢出的核心利用方式主要有两大类:

方式一:攻击堆上的应用数据 (Data Corruption)

这是最直观的。如果堆上存储了关键的控制数据,直接覆盖它。

  • 覆盖函数指针:结构体中包含函数指针,溢出修改它指向 Shellcode 或 system
  • 覆盖对象虚表指针 (vptr):C++ 对象存储在堆上,溢出覆盖 vptr,劫持虚函数调用。
  • 覆盖认证标志:例如 is_admin = 0 变成了 1

方式二:攻击堆管理器元数据 (Metadata Corruption) —— 经典“Unlink”攻击

这是堆溢出的精髓。通过破坏 Chunk Header,欺骗 free() 函数在回收内存时执行“非法操作”。

Unlink (脱链) 机制: 当一个 Chunk 被 free 时,如果它相邻的 Chunk 也是空闲的,堆管理器会把它们从双向链表(Bin)中取出来合并。取出(Unlink)的操作大致如下:

// 标准的双向链表删除操作
FD = P->fd; // 前向指针
BK = P->bk; // 后向指针

FD->bk = BK; // 关键写操作 1
BK->fd = FD; // 关键写操作 2

利用逻辑:

  1. 溢出:攻击者控制了 Chunk P,并溢出覆盖了 P 的 fdbk 指针。
  2. 伪造:将 fd 指向目标地址 target_addr - offset,将 bk 指向恶意值的地址。
  3. 触发:当 free(P) 发生并触发 Unlink 时,堆管理器会执行 FD->bk = BK
  4. 后果:这实际上变成了 *target_addr = value。即实现了任意地址写 (Write-What-Where)
    • 攻击者可以将 free 的 GOT 表地址修改为 system 的地址。
    • 下次调用 free 时,实际执行的是 system

(注:现代 glibc 对 Unlink 增加了保护检查 P->fd->bk == P,这需要更高级的绕过技巧,如利用 Tcache Poisoning 或 Fastbin Attack)

4. 堆溢出代码示例

这里展示一个较简单的覆盖函数指针的例子,以便理解溢出的物理过程。

漏洞代码 (heap_vuln.c)

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

struct Data {
    char buffer[16];
    void (*func_ptr)(); // 一个函数指针
};

void hacker_function() {
    printf("Heap Overflow Successful! Shell obtained.\n");
    // system("/bin/sh");
}

void normal_function() {
    printf("Normal function executed.\n");
}

int main(int argc, char **argv) {
    if (argc < 2) return 1;

    // 1. 在堆上分配内存
    struct Data *d = malloc(sizeof(struct Data));
    
    // 2. 初始化函数指针指向正常函数
    d->func_ptr = normal_function;

    // 3. 漏洞点:strcpy 不检查长度
    // 如果 argv[1] 超过 16 字节,就会覆盖后面的 func_ptr
    strcpy(d->buffer, argv[1]);

    // 4. 调用函数指针
    d->func_ptr();

    free(d);
    return 0;
}

内存布局分析

malloc 分配 struct Data 时,内存布局大致如下:

[ 低地址 ]
| buffer[0] ... buffer[15] |  <-- 16字节
| func_ptr (8字节)         |  <-- 第17-24字节
[ 高地址 ]

攻击过程

  1. 用户输入 "AAAAAAAAAAAAAAAA" (16个 ‘A’):

    • buffer 填满。
    • func_ptr 保持不变,指向 normal_function
    • 输出 “Normal function executed.”。
  2. 用户输入 "AAAAAAAAAAAAAAAA" + [hacker_function 的地址]

    • buffer 被填满。
    • strcpy 继续写入,覆盖了 func_ptr 的值
    • func_ptr 变成了 hacker_function 的地址。
  3. 程序执行 d->func_ptr() 时,跳转到 hacker_function

5. 进阶:现代堆利用技术 (Glibc Heap Exploitation)

由于“Unlink”这种古老的攻击被修复,现在的堆利用更加复杂,针对的是 glibc 的特定机制(Bins, Tcache 等)。

  1. Use-After-Free (UAF):

    • 指针 pfree(p) 后没有置为 NULL。
    • 攻击者申请新内存 q = malloc(...),系统可能把刚才释放的那块地分给 q
    • 此时 pq 指向同一块内存。通过修改 q 的内容,可以篡改程序通过 p 访问的数据(或者 vtable)。
  2. Fastbin Attack / Double Free:

    • 利用 Fastbin(存放小块空闲内存的单向链表)的特性。
    • 连续两次释放同一个 Chunk,欺骗堆管理器形成环路。
    • 再次 malloc 时,可以诱导堆管理器返回一个指向任意地址(比如栈、GOT表)的指针,从而在该地址写入数据。
  3. Tcache Poisoning (Thread Local Cache):

    • Glibc 2.26 引入的机制,为了性能不加锁。
    • 非常容易攻击,只需覆盖空闲 Chunk 中的 next 指针,就能让下一次 malloc 返回攻击者指定的任意地址。

总结

  • 原理:堆上的缓冲区溢出,破坏了相邻的数据或元数据。
  • 核心难点:堆布局动态变化,没有固定返回地址。
  • 后果
    1. 数据篡改:修改关键变量、标志位。
    2. 控制流劫持:修改函数指针、虚表指针。
    3. 任意地址写:利用堆管理器的分配/回收逻辑(Unlink, Tcache Poisoning)去修改 GOT 表或其他内存。
  • 防御
    • 代码层面:检查长度。
    • 系统层面:ASLR(堆基址随机化)。
    • 库层面:Glibc 加入了大量的 Check(如检查 Double Free,检查 Chunk Size 合法性)。