堆溢出 (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
利用逻辑:
- 溢出:攻击者控制了 Chunk P,并溢出覆盖了 P 的
fd和bk指针。 - 伪造:将
fd指向目标地址target_addr - offset,将bk指向恶意值的地址。 - 触发:当
free(P)发生并触发 Unlink 时,堆管理器会执行FD->bk = BK。 - 后果:这实际上变成了
*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字节
[ 高地址 ]
攻击过程
-
用户输入
"AAAAAAAAAAAAAAAA"(16个 ‘A’):buffer填满。func_ptr保持不变,指向normal_function。- 输出 “Normal function executed.”。
-
用户输入
"AAAAAAAAAAAAAAAA" + [hacker_function 的地址]:buffer被填满。strcpy继续写入,覆盖了func_ptr的值。func_ptr变成了hacker_function的地址。
-
程序执行
d->func_ptr()时,跳转到hacker_function。
5. 进阶:现代堆利用技术 (Glibc Heap Exploitation)
由于“Unlink”这种古老的攻击被修复,现在的堆利用更加复杂,针对的是 glibc 的特定机制(Bins, Tcache 等)。
-
Use-After-Free (UAF):
- 指针
p被free(p)后没有置为 NULL。 - 攻击者申请新内存
q = malloc(...),系统可能把刚才释放的那块地分给q。 - 此时
p和q指向同一块内存。通过修改q的内容,可以篡改程序通过p访问的数据(或者 vtable)。
- 指针
-
Fastbin Attack / Double Free:
- 利用 Fastbin(存放小块空闲内存的单向链表)的特性。
- 连续两次释放同一个 Chunk,欺骗堆管理器形成环路。
- 再次
malloc时,可以诱导堆管理器返回一个指向任意地址(比如栈、GOT表)的指针,从而在该地址写入数据。
-
Tcache Poisoning (Thread Local Cache):
- Glibc 2.26 引入的机制,为了性能不加锁。
- 非常容易攻击,只需覆盖空闲 Chunk 中的
next指针,就能让下一次malloc返回攻击者指定的任意地址。
总结
- 原理:堆上的缓冲区溢出,破坏了相邻的数据或元数据。
- 核心难点:堆布局动态变化,没有固定返回地址。
- 后果:
- 数据篡改:修改关键变量、标志位。
- 控制流劫持:修改函数指针、虚表指针。
- 任意地址写:利用堆管理器的分配/回收逻辑(Unlink, Tcache Poisoning)去修改 GOT 表或其他内存。
- 防御:
- 代码层面:检查长度。
- 系统层面:ASLR(堆基址随机化)。
- 库层面:Glibc 加入了大量的 Check(如检查 Double Free,检查 Chunk Size 合法性)。