Summer Pwnables:时间悖论引擎解决方案
挑战 #002:详细解析
上个月,Jacob请我为Summer Pwnables活动创建一个CTF挑战。我选择了一个内核pwnable,因为我的目标是教学生一些更高级的Linux内核利用技术——这种挑战不会在一天内被解决(也希望不会被AI解决)。
在构建挑战和解决方案后,我预计学生应该能在3-7天内破解它。事实证明我对时间线的估计是正确的,但只有一个人真正解决了它。Jun Rong Lam是第一个解决者,他在一周内解决了这个挑战。第二周Lucas Tan Yi Je解决了它。第三周Elijah Chia解决了这个挑战,总共三周。我对这些学生的技能和毅力感到惊讶。
在我解释解决方案之前,你可以在这里获取挑战。
如果你需要提示,可以查看这里和这里。
挑战 #002:详细解析
现在让我介绍这个挑战。像通常的内核pwnable一样,我们给他们一个有漏洞的内核驱动。
|
|
我们注册了一个内核设备,可以通过打开"/dev/paradox_engine"与之交互。我们还有重要的数据结构,称为timeline和temporal_event。当我们打开内核驱动时,它会分配paradox_session_data并将其存储在private_data中。paradox_session_data将通过链表保存所有timeline。timeline也将通过链表保存其所有的temporal_event。
当我们打开驱动时,它会调用paradox_engine_open并分配初始timeline(第一个timeline),在第一个timeline中它会分配第一个事件。还有ioctl接口可以交互,我们可以使用PARADOX_CREATE_TIMELINE创建另一个timeline,使用PARADOX_CREATE_EVENT创建另一个事件。
这个bug并不难发现。在PARADOX_CREATE_EVENT中,我们可以通过指定cause_event_id来指定导致它的事件。代码将通过timeline事件链表搜索来获取导致的事件。bug在于,我们想要创建的事件会先插入到链表中,如果我们让cause_event_id与刚刚插入的事件的ID相同,将导致无限依赖循环。
|
|
当我们释放文件时,它会调用paradox_engine_release,释放所有的timeline和所有的事件。如果我们成功触发了这个bug,这段代码将运行无限循环,因为causal_dependency指向自身。
|
|
event_put将不断递减引用计数直到为零,并使用kfree释放事件,但CPU将仍然卡在这个无限循环中。在引用计数为零并释放事件后,引用计数递减不会将引用计数再次带回0,而是引用计数总是饱和为0xC0000000,因为现在的值将始终低于1。
|
|
作者解决方案
参与者会注意到,对象是在其专用的slub缓存(temporal_event_cache)上分配的。所以我们需要进行跨缓存攻击。关于如何做到这一点有很多资源。基本上,我们只需要让slab页面返回到伙伴分配器,并通过喷洒用另一个对象回收slab页面。你可以从这个演讲或这里阅读更多关于跨缓存的内容。对于内核利用技术,你可以从Google的KernelCTF仓库学习大量资源。
在这个挑战中,跨缓存还需要一个约束条件:slab页面必须返回到我们的CPU的每CPU页面空闲列表,而不是将卡在无限循环的CPU,如果释放的页面插入到卡住CPU的pcp(每CPU页面)空闲列表中,回收将是不可能的。
我进行跨缓存的方法是在分配受害者事件之前和之后各持有一个事件,在CPU0发生无限循环后,我在CPU1的另一个线程中释放之前和之后的事件,这样整个slub页面将进入CPU1的pcp空闲列表。以下是我做的大致步骤:
- 分配N组事件(我们的受害者事件包含在其中)
- 假设受害者是分配的N个事件中的第M个事件
- 分配0x400个事件(在N组事件之外)
- 从每个奇数事件释放0x100个事件到CPU0(填充cpu部分列表)
- 从每个奇数事件释放0x100个事件到CPU1(填充cpu部分列表)
- 在CPU1中:释放所有N个事件,除了第(M-1)个、第M个、第(M+1)个事件
- 在CPU0中:使用第M个事件触发bug,第M个事件被释放
- 在CPU1中:释放第(M-1)个和第(M+1)个事件,其slub页面将为空并放入CPU1的pcp空闲列表
成功回收后,我们可以将causal_dependency置空,从而释放无限循环。
参与者还会注意到temporal_event的大小是0x70,通过跨缓存到另一个对象很难找到相同对齐的对象。这就是这个挑战变得更难的原因,但它应该可以通过常见或已知的技术解决。
预期解决方案 #01
在我创建挑战后,我迅速构建了解决方案,不是使用可能需要更多时间来构建的常见技巧,而是使用这个技巧使双重释放变成页面使用后释放,而不关心块或地址对齐。这也是提示#2的内容:
提示#2:🤫 这是一个有趣的思想实验:当你偷看kfree然后……哎呀……“意外地"释放一些非slab内存时会发生什么?可能正是你的非对齐kfree问题一直在等待的剧情转折!
事实证明,如果你kfree非slub管理的内存区域,它只会释放其页面:
|
|
不关心任何对齐或任何地址,如果地址不由slab支持,它将把页面folio传递给free_large_kmalloc,并通过调用folio_put释放页面。
所以我的计划是喷洒页面支持的pipe(这是order 0),并制作refcount = 1,它将再次调用event_put,这将释放我们的pipe页面。Pipe页面(与pipe_buffer不同)也被认为是内核利用技术,如果你读过这个或这个,或者你可以在这里查看代码。
所以我们有通过使用kfree -> 页面释放原语释放的pipe页面,但仍然由pipe fd持有。接下来,为了快速方法,我只是尝试用页表回收释放的pipe页面。我们仍然可以通过写入pipe来写入页表,因此我们可以使用它进行任意物理读写。
预期解决方案 #02
一周几乎过去了,我们在第三天发布了提示#1和#2,并考虑是否需要发布另一个提示。这次我想也许我的第一个预期解决方案可能太不常见(或者挑战太难了)。事实证明,Jun Rong在我们发布第三个提示之前解决了挑战,就像我之前预期的那样。
尽管受害者块放置在0x70对齐地址上看起来很难解决,但我确信并有足够的信心可以使用更常见的技术解决,比如msg_msg或pipe_buffer,或者可能无法使用常见技术解决?
然后我迅速验证了仅基于msg_msg和pipe_buffer的解决方案,结果它有效(虽然只有大约50%的可靠性,但仍然不错)。
这个想法是我们不断尝试,直到我们的受害者事件与我们的目标kmalloc缓存具有相同的对齐方式。例如,如果我们幸运,我们可以与kmalloc-128、192、256具有相同的对齐方式。你可以自己计算,大约有10%的概率会是相同的对齐方式。除此之外,我还有一个想法让它多次运行,直到正确对齐而不使内核崩溃,并使这个解决方案的可靠性达到约50%。
我们可以再次查看代码如何释放所有事件:
|
|
这是想法的大致步骤:
- 插入第一个事件,caused_by指向自身(victim_event)
- 插入第二个事件,caused_by指向自身(确保它与第一个事件在不同的slab页面)
- 插入第三个事件,使其caused_by指向第一个事件
- 关闭fd,在释放第一个事件时将无限循环
- 用msg_msgseg回收,假设是kmalloc-192,我们不需要制作任何东西,只需将所有内容置空
- 无限循环将中断
- 第二个事件的另一个无限循环正在运行
- 在另一个CPU中,我将观察成功替换受害者事件的msg_msgseg,某些偏移处的内容不应该为空(因为refcount_dec将修改内容)
- 所以我知道引用计数的偏移量,我将知道对象位置是否与msg_msgseg相同
- 如果不同,我释放第二个无限循环(当然通过用null再次回收第二个事件)
- 然后它将释放第三个事件,虽然第三个事件将放置导致的事件(即受害者事件),但它不会做任何事情(如果引用计数不为1,event_put是无害的)
- 回到开始(再次运行)
- 如果相同,我替换我们的msg_msgseg(通过删除和再次分配)并制作temporal_event使其引用计数=1
- 我释放第二个事件无限循环
- 然后它将释放第三个事件,causal_dependency仍然指向受害者事件,现在引用计数=1,然后它将尝试kfree受害者事件,实际上仍然由我们的msg_msg持有
经过这些步骤后,我们在msg_msgseg上获得了使用后释放。这看起来太复杂了,但考虑到创造力+时间,参与者应该能找到类似的想法。在msg_msgseg上获得UAF后,意味着我们在通用缓存上获得了UAF。在我的情况下,我将msg_msgseg上的UAF转换为著名的pipe_buffer上的UAF,它与msg_msgseg相同的缓存,你可以通过这个获得内核任意读写,或者直接覆盖内核函数指针并进行ROP。
作为旁注,这个解决方案仍然只有50%的可靠性,因为有时我们会遇到这样的情况:当用另一个对象回收时,event->causal_dependency存储在与空闲列表值相同的位置,因为新的slab页面被分配,它们将在整个slab页面上重新初始化空闲列表值,因此当内核解引用event->causal_dependency时可能会崩溃。
结束语
虽然我意识到我创建了一个比大多数学生能够想象或能够做到的更难的挑战,但我很高兴三个学生凭借创造力和毅力解决了它。这三个学生以不同于预期解决方案的方式解决了它,这对于内核CTF挑战来说非常正常。其中一个使用refcount_dec原语编辑pgtable,另一个使用部分覆盖将页面使用后释放转换为更高order,看到他们的创造力真的很酷。这正是我们在CTF甚至现实世界中面对困难利用问题时所需要的。
学生解题报告
- by Lam Jun Rong: https://jro.sg/CTFs/STAR%20Labs%20Summer%20Pwnables/Temporal%20Paradox%20Engine/
- by Lucas Tan: https://samuzora.com/posts/starlabs-summer-pwn-2025#challenge-002
- by Elijah Chia: https://elijahchia.gitbook.io/ctf-blog/star-labs-summer-pwnables-2025/level-2-not-quite-baby-kernel-linux-kernel-pwn
参考资料
- https://i.blackhat.com/Asia-24/Presentations/Asia-24-Wu-Game-of-Cross-Cache.pdf
- https://kaligulaarmblessed.github.io/post/cross-cache-for-lazy-people/
- https://hoefler.dev/articles/vsock.html
- https://www.interruptlabs.co.uk/articles/pipe-buffer
- https://elixir.bootlin.com/linux/v6.12.46/source/fs/pipe.c#L513
© 2025 STAR Labs
Powered by Hugo & PaperMod