☰
深入解析 ptmalloc2 堆溢出:从漏洞原理到利用思路(CTF-Wiki)
2026/9/25 8:05:45 网站建设 项目流程
  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载

堆溢出(Heap Overflow)是 CTF 二进制利用中最基础也最核心的漏洞类型之一。本文以 CTF-Wiki 仓库中 docs/zh-tw/docs/pwn/linux/user-mode/heap/ptmalloc2/heapoverflow-basic.md 为主体骨架,结合仓库内 ptmalloc2 堆结构、malloc 实现与 unlink 等攻击技法的文档,系统讲解堆溢出的成因、检测方法与利用策略。读完本文,你将掌握:如何识别堆溢出漏洞、如何计算精确的溢出填充长度,以及如何基于 glibc 的 chunk 结构与 bin 机制将溢出转化为任意地址读写与控制程序执行流。

什么是堆溢出

堆溢出是指程序向某个堆块(chunk)中写入的字节数超过了该堆块本身可使用的字节数,从而导致数据溢出,覆盖到物理相邻的高地址的下一个堆块。

这里需要特别强调一个关键概念:之所以说是"可使用"而不是"用户申请的字节数",是因为堆管理器会对用户申请的字节数进行内部调整,这也导致实际可利用的字节数不小于用户申请的字节数。这一调整逻辑体现在 glibc 源码的request2size宏中(见下文"确定填充长度"一节)。

堆溢出漏洞发生的基本前提只有两点:

  • 程序向堆上写入数据;
  • 写入的数据大小没有被良好地控制。

对于攻击者来说,堆溢出漏洞轻则可以使程序崩溃(DoS),重则可以使攻击者完全控制程序的执行流程。

堆溢出与栈溢出的本质差异

堆溢出是一种特定的缓冲区溢出(除此之外还有栈溢出、bss 段溢出等)。但与栈溢出不同的是,堆上并不存在返回地址等可以让攻击者直接控制执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP(指令指针)。

一般来说,利用堆溢出的策略分为两条路线:

  1. 覆盖与其物理相邻的下一个 chunk 的内容,包括:
    • prev_size:前一个 chunk 的大小(仅当前一个 chunk 空闲时有效);
    • size:chunk 的大小,其中低三位比特位与真实大小共同决定布局——
      • NON_MAIN_ARENA(0x4):标记 chunk 是否不属于主线程 arena;
      • IS_MAPPED(0x2):标记 chunk 是否由 mmap 分配;
      • PREV_INUSE(0x1):标记前一个物理相邻 chunk 是否处于使用状态;
      • the True chunk size:chunk 的真实大小;
    • chunk content(用户数据),从而改变程序固有的执行流。
  2. 利用堆管理机制(如 unlink 等)实现任意地址写入(Write-Anything-Anywhere)或控制堆块中的内容,从而控制程序的执行流。

这两个策略分别对应了"直接覆盖元数据"与"利用堆算法逻辑"两类攻击范式,前者为后者的铺垫(覆盖 size、fd/bk 指针正是触发 unlink 等机制的前提)。

更系统的堆背景知识可参考仓库中的 堆概述(heap-overview.md) 与 堆相关数据结构(heap-structure.md)。

基本示例:溢出覆盖 top chunk

下面通过一个简单程序演示堆溢出的发生过程:

#include <stdio.h> int main(void) { char *chunk; chunk=malloc(24); puts("Get input:"); gets(chunk); return 0; }

这个程序的主要目的是调用malloc分配一块堆上的内存,之后向这个堆块中写入一个字符串。如果输入的字符串过长,会导致溢出 chunk 的区域并覆盖到其后的 top chunk 之中(实际上puts内部会调用 malloc 分配堆内存,覆盖到的可能并不是 top chunk)。

溢出前的堆内存布局(64 位系统):

0x602000: 0x0000000000000000 0x0000000000000021 <===chunk 0x602010: 0x0000000000000000 0x0000000000000000 0x602020: 0x0000000000000000 0x0000000000020fe1 <===top chunk 0x602030: 0x0000000000000000 0x0000000000000000 0x602040: 0x0000000000000000 0x0000000000000000

其中0x21即 chunk 的 size 字段(0x20为对齐后的 chunk 大小,低位的0x1是PREV_INUSE位),0x20fe1是 top chunk 的大小。当我们执行print 'A'*100进行写入后:

0x602000: 0x0000000000000000 0x0000000000000021 <===chunk 0x602010: 0x4141414141414141 0x4141414141414141 0x602020: 0x4141414141414141 0x4141414141414141 <===top chunk(已被溢出) 0x602030: 0x4141414141414141 0x4141414141414141 0x602040: 0x4141414141414141 0x4141414141414141

可以看到,'A'已经覆盖了相邻的 top chunk 区域。在本例中由于溢出的数据没有继续被堆管理器校验,程序可能只是崩溃;但若被覆盖的是某个已释放 chunk 的fd/bk指针或 size 字段,就可能引发 unlink 等更严重的后果。

堆溢出利用的三步分析法

堆溢出利用中比较重要的几个步骤,可归纳为:寻找堆分配函数 → 寻找危险函数 → 确定填充长度。

第一步:寻找堆分配函数

通常来说,堆是通过调用 glibc 函数malloc进行分配的。在某些情况下会使用calloc分配。calloc与malloc的区别是calloc 在分配后会自动进行清空(清零),这对于某些信息泄露漏洞的利用来说是致命的(因为申请到的内存中不会残留前序数据,也就无法通过打印未初始化数据来泄露信息):

calloc(0x20); //等同于 ptr=malloc(0x20); memset(ptr,0,0x20);

除此之外,还有一种分配是经由realloc进行的。realloc函数可以身兼malloc和free两个函数的功能:

#include <stdio.h> int main(void) { char *chunk,*chunk1; chunk=malloc(16); chunk1=realloc(chunk,32); return 0; }

realloc的操作并不是像字面意义上那么简单,其内部会根据不同情况执行不同操作:

  • 当realloc(ptr,size)的size不等于ptr的 size 时:
    • 如果申请的size> 原来的 size:
      • 如果 chunk 与 top chunk 相邻,直接扩展这个 chunk 到新 size 大小;
      • 如果 chunk 与 top chunk 不相邻,相当于free(ptr); malloc(new_size)。
    • 如果申请的size< 原来的 size:
      • 如果相差不足以容得下一个最小 chunk(64 位下 32 字节,32 位下 16 字节),则保持不变;
      • 如果相差可以容得下一个最小 chunk,则切割原 chunk 为两部分,free 掉后一部分。
  • 当realloc(ptr,size)的size等于 0 时,相当于free(ptr);
  • 当realloc(ptr,size)的size等于ptr的 size 时,不进行任何操作。

从源码层面看,glibc 中并不存在名为malloc的函数,用户调用的malloc实际是封装了_int_malloc的__libc_malloc。它会先检查__malloc_hook钩子,再通过arena_get获取分配区,最后调用_int_malloc完成分配;若分配失败还会用arena_get_retry重试其他 arena。详见仓库中的 implementation/malloc.md。

第二步:寻找危险函数

通过寻找危险函数,我们可以快速确定程序是否可能有堆溢出,以及(如果有的话)堆溢出的位置在哪里。常见的危险函数如下:

  • 输入类:
    • gets:直接读取一行,忽略'\x00'(不检查长度,是最高危的输入函数);
    • scanf、vscanf:格式化输入,%s同样不做长度限制;
  • 输出类:
    • sprintf:向缓冲区格式化输出,不检查目标缓冲区大小;
  • 字符串类:
    • strcpy:字符串复制,遇到'\x00'停止;
    • strcat:字符串拼接,遇到'\x00'停止;
    • bcopy:内存拷贝,不检查目标大小。

这些函数共同的特点是不接收或未正确使用目标缓冲区的容量参数。在逆向分析时,可重点检索对这些函数的调用点,并结合其参数判断溢出是否可控、溢出偏移是多少。

第三步:确定填充长度

这一部分主要是计算我们开始写入的地址与所要覆盖的地址之间的距离,它直接决定了溢出 payload 的布局。

一个常见的误区是认为 malloc 的参数等于实际分配堆块的大小。但事实上,ptmalloc 分配出来的大小是对齐的。这个对齐长度一般是字长的 2 倍,例如 32 位系统是 8 字节,64 位系统是 16 字节。对于不大于 2 倍字长的请求,malloc 会直接返回 2 倍字长的块(即最小 chunk)。例如 64 位系统执行malloc(0)会返回用户区域为 16 字节的块:

#include <stdio.h> int main(void) { char *chunk; chunk=malloc(0); puts("Get input:"); gets(chunk); return 0; }

其内存布局如下(根据系统位数,malloc 会分配 8 或 16 字节的用户空间):

//根据系统的位数,malloc会分配8或16字节的用户空间 0x602000: 0x0000000000000000 0x0000000000000021 0x602010: 0x0000000000000000 0x0000000000000000 0x602020: 0x0000000000000000 0x0000000000020fe1 0x602030: 0x0000000000000000 0x0000000000000000

注意:用户区域的大小不等于chunk_head.size。二者满足关系:chunk_head.size = 用户区域大小 + 2 * 字长(即 chunk 头部 prev_size 与 size 两个字段的大小)。

借用的 prev_size:chunk 间的空间复用

之前提到的"用户申请的内存大小会被堆管理器修改"还体现在另一个机制上——申请到的 chunk 有可能会使用与其物理相邻的下一个 chunk 的 prev_size 字段来储存内容。回顾前面的示例:

#include <stdio.h> int main(void) { char *chunk; chunk=malloc(24); puts("Get input:"); gets(chunk); return 0; }

我们申请的 chunk 大小是 24 字节。但将其编译为 64 位可执行程序时,实际上分配的可用空间是 16 字节而不是 24 字节:

0x602000: 0x0000000000000000 0x0000000000000021 0x602010: 0x0000000000000000 0x0000000000000000 0x602020: 0x0000000000000000 0x0000000000020fe1

16 字节的空间如何装得下 24 字节的内容?答案是借用了下一个块的 prev_size 域。因为根据 chunk 结构知识,prev_size字段仅当物理相邻的前一块处于释放状态时才起作用;当前一块在使用状态时,该字段可以被前一块当作数据空间复用。结合 16 字节的 user data 与下一个 chunk 的 8 字节 prev_size,恰好凑足 24 字节。

request2size:申请大小与实际大小的转换

用户申请的内存大小与 glibc 实际分配的内存大小之间的转换,由request2size宏完成(定义于 glibc 的 malloc.c,仓库的 heap-structure.md 中有完整引用):

/* pad request bytes into a usable size -- internal version */ //MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1 #define request2size(req) \ (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) \ ? MINSIZE \ : ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

当req=24时,request2size(24)=32。而除去 chunk 头部的 16 字节(2 × SIZE_SZ),实际上用户可用的 chunk 字节数为 16。再加上下一个 chunk 可复用的 prev_size 字段(8 字节),正好 24 字节。

实际上,ptmalloc 分配内存是以双字(double word)为基本单位的。以 64 位系统为例,分配出来的空间是 16 的整数倍,即用户申请的 chunk 都是 16 字节对齐的。

与request2size配套的还有checked_request2size,它先通过REQUEST_OUT_OF_RANGE判断请求大小是否会因填充对齐而发生回绕(wrap around),若溢出则返回ENOMEM错误。理解这一转换过程对计算溢出偏移至关重要——填充长度必须以实际可用的对齐后大小(而不是 malloc 参数)为基准来计算。

源码视角:为什么堆溢出如此危险

malloc_chunk 结构:被溢出者与溢出载体

ptmalloc 中一切堆块都用统一的malloc_chunk结构表示,无论其处于分配还是释放状态(见 heap-structure.md):

struct malloc_chunk { INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */ INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */ struct malloc_chunk* fd; /* double links -- used only if free. */ struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */ struct malloc_chunk* bk_nextsize; };

其中INTERNAL_SIZE_T默认为size_t,SIZE_SZ = sizeof(INTERNAL_SIZE_T)(64 位系统为 8)。相关宏包括:

  • chunk2mem(p)/mem2chunk(mem):chunk 头指针与用户指针互转,偏移量为 2 × SIZE_SZ;
  • MIN_CHUNK_SIZE:最小 chunk 大小,即offsetof(struct malloc_chunk, fd_nextsize);
  • MINSIZE:对齐后的最小可分配 chunk;
  • PREV_INUSE(0x1)、IS_MMAPPED(0x2)、NON_MAIN_ARENA(0x4):size 字段低三位的标志位;
  • chunksize(p):忽略 SIZE_BITS 后获取 chunk 真实大小;
  • next_chunk(p):获取物理相邻的下一个 chunk。

堆溢出的威胁正源于此:一旦溢出数据越过了 chunk 的 user data 边界,第一个受害对象就是物理相邻下一个 chunk 的 header(prev_size 与 size);若下一个 chunk 处于空闲状态并被放入 bin 链表,其fd/bk(乃至fd_nextsize/bk_nextsize)也随之暴露在攻击者面前,从而为 unlink、House of 系列等高级攻击提供入口。

_int_malloc 分配流程:理解溢出触发的上下文

从 implementation/malloc.md 的源码分析可知,_int_malloc是内存分配的核心函数,其核心思路为:

  1. 根据用户申请的内存块大小以及相应大小 chunk 通常使用的频度(fastbin / small / large chunk),依次实现不同的分配方法;
  2. 由小到大依次检查不同的 bin 中是否有相应的空闲块可以满足用户请求;
  3. 当所有的空闲 chunk 都无法满足时,考虑 top chunk;
  4. 当 top chunk 也无法满足时,堆分配器才真正向系统申请内存。

函数开头首先调用checked_request2size(bytes, nb)将用户请求转换为内部 chunk 大小。分配路径依次为:fastbin(nb <= get_max_fast())→ smallbin(in_smallbin_range(nb))→ 合并 fastbins 后进入 unsorted bin 大循环 → large bin / top chunk / sysmalloc。

理解这一流程对堆溢出利用的意义在于:溢出后堆块布局的变化(如伪造的 size、被篡改的 fd/bk)会在下一次 malloc/free 时被堆管理器消费,从而决定攻击的成败与触发时机。

从堆溢出到 unlink:任意地址写

堆溢出最常见的进阶利用之一就是 unlink。其核心思想是:通过溢出修改一个空闲 chunk 的fd和bk指针,当堆管理器对该 chunk 执行 unlink(双向链表摘除)操作时,就会发生两次受控的写入。仓库的 unlink.md 给出了详细原理:

  • 古老的 unlink(无检查时代):FD = P->fd,BK = P->bk,随后执行FD->bk = BK、BK->fd = FD,可直接实现任意地址写;
  • 当前的 unlink(带检查):要求FD->bk == P && BK->fd == P。通过精心构造 fakeFD 与 fakeBK 使*(fakeFD+12)与*(fakeBK+8)均指向同一个指向 P 的指针,即可让该指针被改写为P - 0x18,实现"使指向 UAF chunk 的指针 ptr 变为 ptr - 0x18"的效果,进而篡改指针、获得任意读写。

利用条件通常为:存在 UAF,可修改 free 状态下 smallbin 或 unsorted bin 的fd/bk指针;已知位置存在一个指针指向可进行 UAF 的 chunk。这与本文"覆盖物理相邻 chunk 的 size 与内容"的策略完全衔接——堆溢出是制造这些伪造条件的最常见手段。

相关衍生漏洞:off-by-one 与 House 系列

在掌握堆溢出基础后,建议继续研读仓库中的关联章节:

  • off-by-one.md:单字节溢出的原理与利用,重点介绍了 NULL byte off-by-one(通过溢出 NULL 字节清除PREV_INUSE位、伪造prev_size造成堆块重叠),并给出了 Asis CTF 2016 b00ks、plaidctf 2015 plaiddb 等真实赛题分析;
  • unlink.md:任意地址写的高级技法;
  • house-of-force.md 与 house-of-einherjar.md:通过溢出伪造 top chunk size 或前块实现特殊分配;
  • fastbin-attack.md:溢出篡改 fastbin 链表指针,实现任意地址分配。

实战利用路线小结

综合上述分析,一次典型的 ptmalloc2 堆溢出利用流程如下:

  1. 确定分配函数:明确目标程序使用的是malloc、calloc还是realloc,注意calloc的清零行为会阻碍信息泄露;
  2. 定位危险函数:通过gets、strcpy、sprintf等危险调用点确定溢出位置与写入可控性;
  3. 计算填充长度:依据request2size的对齐规则,算出从写入起点到目标字段(下一个 chunk 的 size / fd / bk)的字节距离;特别注意malloc(0)、malloc(24)这类请求实际可用空间大于参数值的"空间复用"情况;
  4. 选择利用策略:目标字段是 size 位 → 考虑 House of Force、off-by-null 造成的堆重叠;目标是空闲 chunk 的 fd/bk → 考虑 unlink、fastbin attack;目标是函数指针 / hook 附近 → 在获得任意写后劫持__free_hook、__malloc_hook或 one_gadget;
  5. 验证与调试:使用 gdb 观察堆内存布局(如示例中的0x602000起始布局),确认溢出偏移与写入效果,再编写完整的 pwntools exploit。

小结

堆溢出是理解现代堆利用技术的基石。它的本质是"写多了一个字节"到"写多了一整块"的边界失控,而 ptmalloc2 精心设计的 chunk 元数据(prev_size/size/fd/bk)与 bin 管理机制,恰好为攻击者提供了将溢出转化为信息泄露、堆块重叠、任意地址写乃至代码执行的完整链路。掌握本文介绍的"寻找分配函数 → 定位危险函数 → 计算填充长度"三步法,并结合 heap-structure.md 的宏定义与 implementation/malloc.md 的分配源码深入学习,即可为后续的 unlink、House 系列、tcache 攻击等进阶主题打下坚实基础。

  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载

相关推荐

上一篇:GoCV安装终极指南:5个常见问题快速解决
下一篇:Haystack 集成 Mirage:用 MirageShellTool 为 Agent 打造统一虚拟文件系统 Shell

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询