3、x86-64堆溢出:堆溢出原理、覆盖相邻chunk、修改size域、泄露libc基址

堆溢出,说白了就是在堆上写数据时写过了头,把别人的地盘给占了。这跟栈溢出有点像,但堆上的布局更复杂,利用起来也更有意思。我刚开始学堆利用时,总觉得堆像个黑盒子,摸不着头脑。后来调了几个实际漏洞,才慢慢摸到门道。

这一章我们聚焦 x86-64 平台,从最基础的堆溢出开始,一步步拆解如何覆盖相邻 chunk、篡改 size 域,最终泄露 libc 基址。嗯,这是堆利用的入门必修课。

3.1 堆溢出是怎么发生的?

堆溢出的根源其实很简单:程序在堆上分配了一块内存,但写入的数据长度超过了这块内存的大小。多余的数据就会溢出到相邻的内存区域。

看个典型例子:

// 漏洞代码示例
char *buf = (char *)malloc(64);  // 分配 64 字节
read(0, buf, 128);               // 读取 128 字节,溢出!

这里 malloc(64) 只给了 64 字节的空间,但 read 却读了 128 字节。多出来的 64 字节会直接写到后面相邻的 chunk 里。

注意: 堆溢出不是简单的"写多了",而是写到了不该写的地方。glibc 的堆管理机制会因此被破坏,轻则程序崩溃,重则被攻击者利用。

3.2 堆内存布局:chunk 是怎么排的?

要理解堆溢出,先得搞清楚 chunk 在内存里是怎么排列的。glibc 的堆管理基于 chunk 结构,每个 chunk 都有头部和用户数据区。

一个典型的 chunk 结构长这样:

// chunk 结构(64位系统)
+------------------+
| prev_size (8B)   |  // 前一个 chunk 的大小(如果前一个空闲)
+------------------+
| size (8B)        |  // 当前 chunk 的大小,含标志位
+------------------+
| user data ...    |  // 用户数据区
| ...              |
+------------------+

两个相邻的 chunk 在内存中是紧挨着的。chunk A 的用户数据区后面,紧跟着 chunk B 的头部(prev_size 和 size)。

我画了一张图,帮你直观理解这个布局:

堆内存中相邻 chunk 布局 Chunk A 用户数据区(64字节) [0x00 ~ 0x3f] Chunk B 头部(prev_size + size) Chunk B 用户数据区(64字节) [0x50 ~ 0x8f] 溢出数据覆盖这里 Chunk A 数据区 Chunk B 头部(溢出目标) Chunk B 数据区

从图上可以清楚看到:如果我们往 Chunk A 里写超过 64 字节的数据,多出来的部分就会覆盖 Chunk B 的头部。这就是堆溢出的核心——覆盖相邻 chunk 的元数据。

3.3 覆盖相邻 chunk:我们能改什么?

一旦溢出到相邻 chunk 的头部,我们主要能修改两个关键字段:

字段 偏移 作用
prev_size chunk 起始 + 0 前一个 chunk 的大小,用于向后合并
size chunk 起始 + 8 当前 chunk 的大小,含标志位(P、M、A)

修改 size 域是最常见的操作。为什么?因为 glibc 的堆管理算法(尤其是 free 和 malloc)严重依赖 size 域来判断 chunk 的大小和状态。篡改 size 域,就能欺骗堆管理器,让它以为这个 chunk 比实际更大或更小。

关键点: 修改 size 域可以导致:
  • chunk 大小被放大:free 时,堆管理器会认为这个 chunk 很大,从而覆盖到更远的区域。
  • 标志位被篡改:比如清除 P 位(prev_inuse),触发向前合并(unlink 操作)。
  • chunk 大小被缩小:导致堆管理器认为 chunk 结束位置提前,后续操作出现错位。

3.4 实战思路:从溢出到泄露 libc 基址

光知道能改 size 还不够,我们得串联起一条完整的利用链。我个人习惯把堆溢出利用拆成三步:

  1. 触发溢出,篡改 size 域:通过溢出修改相邻 chunk 的 size,为后续操作铺路。
  2. 构造 fake chunk 或触发 unlink:利用篡改后的 size,让堆管理器执行我们期望的操作。
  3. 泄露 libc 地址:通过 unsorted bin 或 small bin 中的指针,获取 libc 基址。

这里我重点讲第三步——泄露 libc 基址。这是堆利用中最关键的一步,没有 libc 基址,后面的 getshell 就无从谈起。

3.5 泄露 libc 基址:unsorted bin 的妙用

泄露 libc 基址最经典的方法,就是利用 unsorted bin 中的 fd/bk 指针。当一个 chunk 被放入 unsorted bin 时,它的 fd 和 bk 指针会指向 main_arena 中的某个位置。而 main_arena 位于 libc 的数据段中。

具体流程是这样的:

  1. 分配两个 chunk:chunk A 和 chunk B。
  2. 通过堆溢出,修改 chunk B 的 size 域,让它看起来比实际大。
  3. free 掉 chunk B(此时它被放入 unsorted bin)。
  4. 再分配一个比 chunk B 实际大小更大的 chunk,触发 unsorted bin 的切割或合并。
  5. 此时,chunk B 的 fd/bk 指针会写入 libc 地址。
  6. 通过未初始化的内存读取,或者再次分配并读取,拿到 libc 地址。

看个简化版的代码示例:

// 泄露 libc 基址的简化流程
// 假设 chunk A 和 chunk B 相邻

// 1. 分配两个 chunk
char *a = malloc(0x80);  // chunk A
char *b = malloc(0x80);  // chunk B

// 2. 通过堆溢出,修改 chunk B 的 size 域
// 假设 a 存在溢出漏洞,写入数据覆盖 b 的 size
// 将 b 的 size 从 0x91 改为 0xc1(放大)
// 注意:需要保留标志位

// 3. free 掉 chunk B
free(b);  // b 进入 unsorted bin

// 4. 分配一个更大的 chunk,触发 unsorted bin 操作
char *c = malloc(0x100);

// 5. 此时 b 的 fd/bk 指向 main_arena+88
// 通过读取 b 的内容(如果程序允许),即可获得 libc 地址
uint64_t *ptr = (uint64_t *)b;
uint64_t libc_addr = ptr[0];  // fd 指针
小技巧: 在实际调试中,我经常用 GDB 的 heap 命令查看堆布局,用 x/8gx 查看 chunk 内容。泄露 libc 地址后,记得减去偏移量(main_arena 相对于 libc 基址的偏移)才能得到真正的基址。

3.6 避坑指南:我踩过的几个坑

堆溢出利用看着简单,实际调试时坑不少。我把自己踩过的坑列出来,你遇到了可以少走弯路:

  • size 域的标志位不能乱改:P 位(最低位)表示前一个 chunk 是否在使用中。如果错误清除 P 位,free 时会触发 unlink,导致程序崩溃。我曾经在这个问题上卡了一整天。
  • chunk 对齐问题:x86-64 下,chunk 大小必须是 16 字节对齐。如果你篡改的 size 不对齐,malloc 内部会报错。
  • tcache 的干扰:glibc 2.26 之后引入了 tcache。小 chunk(默认 < 0x410)被 free 后会先进入 tcache,而不是 unsorted bin。这意味着你泄露 libc 地址的路径可能被堵死。解决办法是填满 tcache(7 个),或者直接操作更大的 chunk。
  • ASLR 的影响:每次运行 libc 基址都不同,但泄露出来的地址偏移是固定的。多调试几次,确认偏移量。
重要提醒: 堆溢出利用高度依赖 glibc 版本。不同版本的堆管理算法、tcache 行为、安全检查(如 size 校验)都有差异。做 exploit 前,先确认目标系统的 libc 版本。

3.7 本章小结

堆溢出的本质是写越界,覆盖相邻 chunk 的元数据。篡改 size 域是最核心的操作,可以欺骗堆管理器,为后续利用铺路。泄露 libc 基址通常通过 unsorted bin 的 fd/bk 指针实现,这是堆利用的基石。