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 里。
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 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 比实际更大或更小。
- chunk 大小被放大:free 时,堆管理器会认为这个 chunk 很大,从而覆盖到更远的区域。
- 标志位被篡改:比如清除 P 位(prev_inuse),触发向前合并(unlink 操作)。
- chunk 大小被缩小:导致堆管理器认为 chunk 结束位置提前,后续操作出现错位。
3.4 实战思路:从溢出到泄露 libc 基址
光知道能改 size 还不够,我们得串联起一条完整的利用链。我个人习惯把堆溢出利用拆成三步:
- 触发溢出,篡改 size 域:通过溢出修改相邻 chunk 的 size,为后续操作铺路。
- 构造 fake chunk 或触发 unlink:利用篡改后的 size,让堆管理器执行我们期望的操作。
- 泄露 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 的数据段中。
具体流程是这样的:
- 分配两个 chunk:chunk A 和 chunk B。
- 通过堆溢出,修改 chunk B 的 size 域,让它看起来比实际大。
- free 掉 chunk B(此时它被放入 unsorted bin)。
- 再分配一个比 chunk B 实际大小更大的 chunk,触发 unsorted bin 的切割或合并。
- 此时,chunk B 的 fd/bk 指针会写入 libc 地址。
- 通过未初始化的内存读取,或者再次分配并读取,拿到 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 指针
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 基址都不同,但泄露出来的地址偏移是固定的。多调试几次,确认偏移量。
3.7 本章小结
堆溢出的本质是写越界,覆盖相邻 chunk 的元数据。篡改 size 域是最核心的操作,可以欺骗堆管理器,为后续利用铺路。泄露 libc 基址通常通过 unsorted bin 的 fd/bk 指针实现,这是堆利用的基石。