Summer Pwnables:时间悖论引擎解决方案
挑战 #002:详细解析
上个月,Jacob邀请我为Summer Pwnables活动创建一个CTF挑战。我选择制作一个内核pwnable挑战,目标是向学生传授一些更高级的Linux内核利用技术——这种挑战不会在一天内被解决(希望也不会被AI解决)。
在构建挑战和解决方案后,我预计学生应该能在3-7天内破解它。事实证明我对时间线的预测是正确的,但只有一个人真正解决了它。Jun Rong Lam是第一个解决者,他用了一周时间。第二周,Lucas Tan Yi Je解决了它。第三周,Elijah Chia解决了这个挑战,总共用了三周时间。我对这些学生的技能和毅力感到惊讶。
在解释解决方案之前,你可以在此获取挑战。
作者解决方案
参与者会注意到,对象是在其专用的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个事件触发漏洞,第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来释放页面。
所以我的计划是喷洒页面支持的管道(即order 0),并精心构造refcount = 1,它将再次调用event_put,从而释放我们的管道页面。管道页面(与pipe_buffer不同)也被认为是一种内核利用技术,如果你读过这个或这个,或者你可以查看这里的代码。
所以我们有了使用kfree -> 页面释放原语释放的管道页面,但仍然由管道fd持有。接下来,为了快速实现,我尝试用页表回收释放的管道页面。我们仍然可以通过写入管道来写入页表,因此我们可以使用这个实现任意物理读写。
预期解决方案 #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会修改内容)
- 所以我知道refcount的偏移量,并且我会知道对象位置是否与msg_msgseg相同
- 如果不同,我释放第二个无限循环(当然是通过用null再次回收第二个事件)
- 然后它将释放第三个事件,尽管第三个事件将放置导致的事件(即受害者事件),但它不会做任何事情(如果refcount不是1,event_put是无害的)
- 回到开头(再次运行)
- 如果相同,我替换我们的msg_msgseg(通过删除和再次分配)并构造temporal_event使其refcount = 1
- 我释放第二个事件无限循环
- 然后它将释放第三个事件,causal_dependency仍然指向受害者事件,现在refcount=1,然后它将尝试kfree受害者事件,该事件实际上仍然由我们的msg_msg持有
经过这些步骤后,我们在msg_msgseg上有了use-after-free。这看起来太复杂了,但考虑到创造力 + 时间,参与者应该能找到类似的想法。在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,另一人使用部分覆盖将页面使用后释放转换为更高阶,看到他们的创造力真的很酷。这正是我们在CTF甚至现实世界中面对困难利用问题时所需要的。
学生解题报告
- Lam Jun Rong: https://jro.sg/CTFs/STAR%20Labs%20Summer%20Pwnables/Temporal%20Paradox%20Engine/
- Lucas Tan: https://samuzora.com/posts/starlabs-summer-pwn-2025#challenge-002
- 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