- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
本文以 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 宏的语义拆解
逐段理解这个宏:
- 大小一致性检查:
chunksize(P)取的是 P 自身size字段记录的(掩去标志位后的)大小,prev_size(next_chunk(P))是 P 的物理相邻后一块 chunk 中记录的“前一块大小”。由于空闲 chunk 会有两个地方记录自身大小(自身size与后一 chunk 的prev_size),这里检查二者是否一致,防止伪造 size。 - 取出前后指针:
FD = P->fd、BK = P->bk,即 P 在链表中的后继与前驱。 - 双向链表完整性检查:
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 用双向一致性校验加以拦截。 - 摘除主链:
FD->bk = BK; BK->fd = FD;,完成双向链表的标准摘除。 - 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均可以泄漏。
- P 位于双向链表头部,
- 泄漏堆地址(双向链表包含多个空闲 chunk 时)
- P 位于双向链表头部,
fd泄漏; - P 位于双向链表中,
fd和bk均可以泄漏; - P 位于双向链表尾部,
bk泄漏。
- P 位于双向链表头部,
注意:这里的“头部”指 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!
相关推荐
CTF-Wiki 堆利用基础:深入剖析 glibc ptmalloc2 的 perturb_byte 内存填充机制
CTF Wiki 堆利用基础:深入剖析 glibc ptmalloc2 的 perturb_byte 内存填充机制 perturb_byte 是 glibc p
文档网络安全教程ctf-wiki ptmalloc2 堆检查机制全解析:malloc/free/unlink 的安全校验与报错信息对照
ctf wiki ptmalloc2 堆检查机制全解析:malloc/free/unlink 的安全校验与报错信息对照 导读 本文以 ctf wiki 仓库中
文档网络安全教程CTF-Wiki 堆利用系列:ptmalloc2 Unlink 攻击原理与实战(HITCON/ZCTF 经典题目全解析)
CTF Wiki 堆利用系列:ptmalloc2 Unlink 攻击原理与实战(HITCON/ZCTF 经典题目全解析) 本文以 CTF Wiki 仓库中 un
文档网络安全教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考