11、堆溢出原理:堆块结构、unlink攻击与fastbin攻击
堆溢出,说实话,是比栈溢出更“阴险”的一种漏洞。栈溢出好歹有固定的返回地址可以覆盖,堆呢?你覆盖了某个堆块,到底能影响到什么?我早年刚接触堆溢出时,也一头雾水。直到有一次在项目中调试一个内存崩溃的bug,追了三天才发现是堆块被相邻数据写坏了。嗯,从那以后,我就把堆块结构研究了个透。
堆块长什么样?chunk header 与 fd/bk 指针
在 glibc 的 ptmalloc 实现里,每个堆块(chunk)在内存中并不是光秃秃的数据。它前面带了一个“头”,也就是 chunk header。这个头决定了堆块的大小、状态,以及它在空闲链表里的前后关系。
一个典型的已分配堆块结构如下:
+-------------------+
| prev_size (8字节) | ← 前一个堆块的大小(如果前一个空闲)
+-------------------+
| size (8字节) | ← 当前堆块的大小,含标志位
+-------------------+
| user data ... | ← 你 malloc 返回的指针指向这里
+-------------------+
如果这个堆块是 空闲 的,那么 user data 区域的前 16 个字节会被用来存放两个指针:
- fd(forward pointer):指向下一个空闲堆块
- bk(backward pointer):指向上一个空闲堆块
说白了,空闲堆块通过 fd 和 bk 串成了一个双向链表。glibc 在分配和释放时,就是靠这两个指针来维护空闲链表的。
关键点:fd 和 bk 存放在原本属于用户数据的位置。如果你能通过堆溢出改写这些指针,就能控制 glibc 的内存管理逻辑——这就是堆利用的起点。
堆溢出如何覆盖相邻堆块
堆溢出的本质很简单:你往一个堆块里写的数据,超出了它应有的边界,把后面的堆块 header 或数据给覆盖了。
举个例子:
char *a = malloc(32); // 分配一个 32 字节的堆块
char *b = malloc(32); // 相邻分配另一个堆块
// 假设 a 和 b 在内存中是相邻的
// 如果向 a 写入超过 32 字节的数据:
strcpy(a, "AAAA...AAAA"); // 写入了 40 字节
// 那么 b 的 prev_size 和 size 字段就被覆盖了
覆盖了 b 的 size 字段会怎样?glibc 在释放 b 时,会读取 b 的 size 来决定这个堆块有多大,以及它前面的堆块在哪里。如果你篡改了 size,glibc 就会算错位置,从而把伪造的地址当作堆块来操作。
我个人习惯把这种攻击路径画成一张图,方便理解:
你想想看,一旦 B 的 size 被篡改,glibc 后续的 unlink 操作就会基于错误的大小去计算 fd 和 bk 的位置。这就是堆溢出最直接的危害。
unlink 攻击:经典的指针篡改
unlink 是 glibc 在释放堆块时执行的一个宏,用来从空闲双向链表中摘除一个节点。它的逻辑大致如下:
// 简化后的 unlink 宏
FD = P->fd; // 取当前堆块的 fd
BK = P->bk; // 取当前堆块的 bk
FD->bk = BK; // 将下一个堆块的 bk 指向 BK
BK->fd = FD; // 将上一个堆块的 fd 指向 FD
这里的问题在于:如果 P 的 fd 和 bk 都是你伪造的,那么 FD->bk = BK 就相当于往一个你指定的地址写入你指定的值。这就是 任意地址写。
我曾经在分析一个老版本的 Linux 内核漏洞时,就见过类似的 unlink 利用。攻击者通过堆溢出修改了一个空闲堆块的 fd 和 bk,使得 unlink 时把某个全局指针改成了 shellcode 的地址。嗯,这种攻击在 glibc 2.26 之前非常流行。
注意:现代 glibc 加入了 safe unlink 检查,会验证 P->fd->bk == P 和 P->bk->fd == P。但这并不意味着 unlink 攻击完全失效——如果能构造出满足检查的 fake chunk,依然可以绕过。
fastbin 攻击:小而快,但也很危险
fastbin 是 glibc 为小尺寸堆块(默认 < 64 字节)设计的快速分配机制。它使用 单向链表(只有 fd 指针),分配和释放都不做复杂的检查——说白了就是为了性能牺牲了安全性。
fastbin 攻击的核心思路:
- 通过堆溢出修改某个 fastbin 空闲块的 fd 指针
- 让 fd 指向一个伪造的堆块地址(比如栈上、全局变量附近)
- 下次 malloc 时,glibc 会从 fastbin 头部取出一个块,然后头部指向 fd
- 再 malloc 一次,就会返回你伪造的那个地址
一旦 malloc 返回了你控制的地址,后续往这个地址写数据,就等于任意地址写。
实战技巧:fastbin 攻击最常见的利用目标是 __malloc_hook 或 __free_hook。如果能将 fastbin 的 fd 指向 __malloc_hook 附近,再通过两次 malloc 拿到这块内存,就可以把 hook 改成 one_gadget 或 system 的地址。
两种攻击的对比
| 攻击类型 | 链表结构 | 检查机制 | 利用难度 | 适用场景 |
|---|---|---|---|---|
| unlink 攻击 | 双向链表(fd + bk) | 有 safe unlink 检查 | 较高 | 需要构造 fake chunk 绕过检查 |
| fastbin 攻击 | 单向链表(仅 fd) | 几乎无检查 | 较低 | 小尺寸堆块,glibc 2.26 之前 |
我个人觉得,fastbin 攻击是入门堆利用最好的起点。它逻辑简单,不需要像 unlink 那样构造复杂的 fake chunk。但要注意,glibc 2.26 之后引入了 tcache,fastbin 的攻击面被大幅压缩了——不过那是另一个话题了。
避坑指南:我曾经在调试一个 fastbin 攻击的 poc 时,发现 malloc 返回的地址总是不对。查了半天,原来是堆块对齐的问题——glibc 要求所有堆块地址必须是 16 字节对齐。如果你伪造的 fd 没有对齐,malloc 会直接返回 NULL。嗯,这个坑我踩过,你也要小心。
堆溢出的魅力在于:你改的不是代码逻辑,而是内存管理的数据结构。glibc 本身成了你的“帮凶”。理解堆块结构、fd/bk 指针的含义,是玩转堆利用的第一步。至于 unlink 和 fastbin,只是这条路上的两个经典关卡罢了。
公众号:蓝海资料掘金营,微信deep3321