☰
ctf-wiki 堆利用基础:深入剖析 ptmalloc2 的 unlink 宏与 malloc_printerr 错误处理机制
2026/9/25 3:13:31 网站建设 项目流程
  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

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

本文以 ctf-wiki 仓库中《ptmalloc2 实现 - 基礎操作》文档为核心,结合 glibc malloc 的源码级实现(free/malloc中的调用链与malloc_chunk数据结构),系统讲解双向链表摘除操作unlink的使用场景、宏实现、安全校验及其在地址泄漏中的妙用,并完整解析错误检测入口malloc_printerr的触发与终止流程。读完本文,你将掌握 unlink 的原理与绕过思路,能够理解堆溢出漏洞中常见的 "corrupted double-linked list" 报错根源,并为后续阅读 ctf-wiki 中的 unlink 利用实战 打下坚实基础。

1. unlink 是什么

在 ptmalloc2 的堆管理中,所有空闲 chunk会被挂载到对应的双向链表(bin)中。unlink的作用就是把一个已经存在于双向链表中的空闲 chunk 从链表中摘除。由于堆的分配与释放过程会频繁地插入、摘除 chunk,glibc 将这一高频操作实现为一个宏unlink(AV, P, BK, FD)。

1.1 使用场景

根据仓库文档的归纳,unlink 主要出现在以下四类路径中:

触发函数具体场景
malloc从恰好大小合适的 large bin 中获取 chunk(注意:fastbin 与 small bin 的取出不使用 unlink,这正是漏洞常出现在这两个 bin 的原因);依次遍历处理 unsorted bin 时也不使用 unlink;从比请求 chunk 所在 bin 更大的 bin 中取 chunk 时使用
free后向合并(合并物理相邻的低地址空闲 chunk);前向合并(合并物理相邻的高地址空闲 chunk,top chunk 除外)
malloc_consolidate后向合并、前向合并(除 top chunk 外),将 fastbin 中的 chunk 与相邻空闲 chunk 合并
realloc前向扩展(合并物理相邻的高地址空闲 chunk,top chunk 除外)

从仓库的 free 实现文档 可以看到,_int_free中正是通过unlink(av, p, bck, fwd)完成后向合并,通过unlink(av, nextchunk, bck, fwd)完成前向合并;在 malloc 实现文档 的 large bin 分配路径中,取出合适 chunk 前同样会执行unlink(av, victim, bck, fwd)。也就是说,凡是涉及“空闲链表摘除”的操作,都逃不开 unlink。

2. unlink 宏的源码级拆解

glibc 中 unlink 的实现如下(即 基礎操作文档 中的原始宏):

/* Take a chunk off a bin list */ // unlink p #define unlink(AV, P, BK, FD) { \ // 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致。 if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \ malloc_printerr ("corrupted size vs. prev_size"); \ FD = P->fd; \ BK = P->bk; \ // 防止攻击者简单篡改空闲的 chunk 的 fd 与 bk 来实现任意写的效果。 if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \ malloc_printerr (check_action, "corrupted double-linked list", P, AV); \ else { \ FD->bk = BK; \ BK->fd = FD; \ // 下面主要考虑 P 对应的 nextsize 双向链表的修改 if (!in_smallbin_range (chunksize_nomask (P)) \ // 如果P->fd_nextsize为 NULL,表明 P 未插入到 nextsize 链表中。 // 那么其实也就没有必要对 nextsize 字段进行修改了。 // 这里没有去判断 bk_nextsize 字段,可能会出问题。 && __builtin_expect (P->fd_nextsize != NULL, 0)) { \ // 类似于小的 chunk 的检查思路 if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \ || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \ malloc_printerr (check_action, \ "corrupted double-linked list (not small)", \ P, AV); \ // 这里说明 P 已经在 nextsize 链表中了。 // 如果 FD 没有在 nextsize 链表中 if (FD->fd_nextsize == NULL) { \ // 如果 nextsize 串起来的双链表只有 P 本身,那就直接拿走 P // 令 FD 为 nextsize 串起来的 if (P->fd_nextsize == P) \ FD->fd_nextsize = FD->bk_nextsize = FD; \ else { \ // 否则我们需要将 FD 插入到 nextsize 形成的双链表中 FD->fd_nextsize = P->fd_nextsize; \ FD->bk_nextsize = P->bk_nextsize; \ P->fd_nextsize->bk_nextsize = FD; \ P->bk_nextsize->fd_nextsize = FD; \ } \ } else { \ // 如果在的话,直接拿走即可 P->fd_nextsize->bk_nextsize = P->bk_nextsize; \ P->bk_nextsize->fd_nextsize = P->fd_nextsize; \ } \ } \ } \ }

2.1 宏的语义拆解

逐段理解这个宏:

  1. 大小一致性检查:chunksize(P)取的是 P 自身size字段记录的(掩去标志位后的)大小,prev_size(next_chunk(P))是 P 的物理相邻后一块 chunk 中记录的“前一块大小”。由于空闲 chunk 会有两个地方记录自身大小(自身size与后一 chunk 的prev_size),这里检查二者是否一致,防止伪造 size。
  2. 取出前后指针:FD = P->fd、BK = P->bk,即 P 在链表中的后继与前驱。
  3. 双向链表完整性检查:FD->bk != P || BK->fd != P。正常情况下,P 的后继的bk必然指向 P,P 的前驱的fd也必然指向 P。这一检查的目的正是防止攻击者直接篡改空闲 chunk 的 fd/bk 实现任意写——如果攻击者把 fd 改成target-12、bk 改成expect,那么FD->bk = BK等价于*(target-12+12) = expect,直接构成任意地址写,所以 glibc 用双向一致性校验加以拦截。
  4. 摘除主链:FD->bk = BK; BK->fd = FD;,完成双向链表的标准摘除。
  5. nextsize 链处理:仅当 P 属于large bin 范围(!in_smallbin_range)且P->fd_nextsize != NULL时才需要处理。large bin 中的 chunk 除了挂在fd/bk主链上,还会按大小挂一条fd_nextsize/bk_nextsize的“跳表”链(fd_nextsize指向下一个不同大小的空闲块,bk_nextsize指向上一个不同大小的空闲块),用于加速按大小查找。摘除 P 时需要同步维护这条链:
    • 先检查P->fd_nextsize->bk_nextsize == P与P->bk_nextsize->fd_nextsize == P;
    • 若 FD 不在 nextsize 链中(FD->fd_nextsize == NULL):若 nextsize 链只有 P 一个节点(P->fd_nextsize == P),则让 FD 自成环;否则把 FD 插入到 nextsize 链中;
    • 若 FD 已在 nextsize 链中,则直接摘除 P 即可。

源码结构提示:宏中只判断了fd_nextsize是否为 NULL 来决定是否进入 nextsize 分支,而没有判断bk_nextsize,这是 glibc 源码中一个值得留意的细节,在构造 large bin 场景的堆布局时可能被利用。

2.2 small bin 场景的直观理解

原文档以small bin 的 unlink为例给出示意图(large bin 的 unlink 与之类似,只是多了 nextsize 链的处理):

可以看到,P 最后的 fd 和 bk 指针本身并不会发生变化,但由于前驱后继的指针被改写,遍历整个双向链表时已经无法再遍历到 P。这一特性在堆利用中非常有用——可以利用 unlink 后的残留指针来泄漏地址:

  • 泄漏 libc 地址
    • P 位于双向链表头部,bk泄漏;
    • P 位于双向链表尾部,fd泄漏;
    • 双向链表只包含一个空闲 chunk 时,P 位于链表中,fd和bk均可以泄漏。
  • 泄漏堆地址(双向链表包含多个空闲 chunk 时)
    • P 位于双向链表头部,fd泄漏;
    • P 位于双向链表中,fd和bk均可以泄漏;
    • P 位于双向链表尾部,bk泄漏。

注意:这里的“头部”指 bin 的fd指向的 chunk,即双向链表中最新加入的 chunk;“尾部”指 bin 的bk指向的 chunk,即双向链表中最先加入的 chunk。

2.3 检查代码小结

无论是fd/bk还是fd_nextsize/bk_nextsize,unlink 都会对指针的一致性进行校验,对应代码如下:

// fd bk if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \ malloc_printerr (check_action, "corrupted double-linked list", P, AV); \ // next_size related if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \ || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \ malloc_printerr (check_action, \ "corrupted double-linked list (not small)", \ P, AV);

表面上看这些检查都很正常:P 的 forward chunk(后继)的bk自然是 P,backward chunk(前驱)的fd也自然是 P。但如果没有这些检查,攻击者只需修改 P 的fd与bk就能轻松实现任意地址写。这正是 unlink 利用 章节中“古老的 unlink”与“当前带检查的 unlink”两种利用手法的分水岭——后者通过伪造fakeFD + 12与fakeBK + 8指向同一处可控内存来绕过校验。

注意:堆的第一个 chunk 所记录的prev_inuse位默认为 1。这一约定在 heap-structure 文档 中有详细说明:size字段低三位分别是NON_MAIN_ARENA(0x4)、IS_MAPPED(0x2)、PREV_INUSE(0x1),堆中第一个被分配的内存块的 P 位被置为 1,以阻止向前访问非法内存;当 P 位为 0 时,才能通过prev_size字段回溯上一个 chunk 的大小与地址,进而进行空闲 chunk 的合并。

3. malloc_printerr:错误检测与终止

在 glibc malloc 检测到堆元数据被破坏(如 unlink 校验失败)时,会调用malloc_printerr函数。在 glibc 2.23 版本中其实现如下:

static void malloc_printerr(const char *str) { __libc_message(do_abort, "%s\n", str); __builtin_unreachable(); }

该函数的核心是调用__libc_message并传入do_abort动作标志,最终触发abort()终止进程:

if ((action & do_abort)) { if ((action & do_backtrace)) BEFORE_ABORT(do_abort, written, fd); /* Kill the application. */ abort(); }

在abort()函数内部,glibc 2.23 版本会首先执行fflush(NULL)刷新所有输出流:

/* Flush all streams. We cannot close them now because the user might have registered a handler for SIGABRT. */ if (stage == 1) { ++stage; fflush (NULL); }

从实践角度理解malloc_printerr:

  • 它不返回(__builtin_unreachable()告知编译器后续代码不可达),程序必然终止;
  • 触发的典型报错字符串与 unlink 直接相关的有:"corrupted size vs. prev_size"(大小一致性检查失败)、"corrupted double-linked list"(fd/bk 双向链表校验失败)、"corrupted double-linked list (not small)"(large bin 的 nextsize 链校验失败)。

当你在调试堆漏洞时看到这些错误,就说明 unlink 宏中的某一道安全防线被触碰了,此时应回头检查伪造的 chunk 头(size、prev_size)与fd/bk(以及 large bin 场景的fd_nextsize/bk_nextsize)是否满足宏中的约束。

4. 与相邻模块的关联

unlink 并不是孤立的原语,它与 ptmalloc2 的其它实现细节紧密相关,这里给出仓库内的延伸阅读路径:

  • 堆数据结构(malloc_chunk):unlink 操作的字段fd、bk、fd_nextsize、bk_nextsize只有在 chunk 空闲时才有效;而prev_size的复用机制解释了为什么 unlink 需要同时检查两处记录的大小。
  • free 实现:_int_free中“后向合并 / 前向合并”两处 unlink 的调用上下文,以及“只有非 fastbin 的 chunk 才会触发 unlink”的结论(fastbin 中的 chunk 的 inuse 位始终为 1,不与相邻空闲 chunk 合并)。
  • malloc 实现:large bin 取出 chunk 时的 unlink 调用,以及 unsorted bin 遍历(FIFO)不使用 unlink 的细节。
  • unlink 利用实战:从“古老的 unlink”(无检查,可直接改写 GOT)到“当前 safe unlink”(通过伪造fakeFD + 12与fakeBK + 8指向同一指针,使*P = P - 8、*P = P - 12,将指向 chunk 的指针改写为ptr - 0x18),再到 2014 HITCON stkof、2016 ZCTF note2 等真实题目的完整利用链。

5. 小结

  • unlink 的本质:从双向空闲链表中摘除一个节点,宏内实现,涉及fd/bk主链与 large bin 的fd_nextsize/bk_nextsize跳表链两套数据结构。
  • 安全校验:大小一致性检查 + 双向链表完整性检查 + large bin nextsize 链检查,任何一项失败都会进入malloc_printerr并最终abort()。
  • 利用价值:尽管 unlink 不再能直接实现任意写,但“摘除后 P 的指针残留”可被用于泄漏 libc/堆地址;配合精心构造的 fake chunk 与指针布局,仍可完成指针改写(如ptr → ptr - 0x18),这是众多堆利用技巧(house-of-* 系列、unsorted bin attack 等)的基础。

建议读者在掌握本文的宏语义后,继续阅读 unlink 利用实战 中的完整 exp,亲手验证“伪造 fakeFD/fakeBK 绕过检查”与“通过 unlink 将指针改为ptr - 0x18”这两个核心手法。

  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载
上一篇:5个高效技巧:进阶用户如何优化LaTeX-Workshop配置
下一篇:MASTG 移动应用安全测试框架定制开发文档:开发过程文档

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

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

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

立即咨询