攻克时间悖论引擎:Linux内核漏洞利用深度解析

本文深入解析了STAR Labs夏季Pwnables挑战中的“时间悖论引擎”内核驱动漏洞。详细介绍了如何利用引用计数循环导致的内核级无限循环漏洞,结合跨缓存攻击、slab页回收及pipe页利用等高级技术,最终实现内核权限提升的全过程。

挑战 #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的per-cpu页面空闲列表,而不是那个陷入无限循环的CPU的列表,如果被释放的页面插入到卡住CPU的pcp(per-cpu页面)空闲列表中,那么重新获取页面将是不可能的。 我进行跨缓存攻击的方法是,在分配受害者事件之前和之后各持有一个事件,当CPU0上发生无限循环时,我在CPU1上的另一个线程释放之前和之后的事件,这样整个slub页面就会进入CPU1的pcp空闲列表。以下是我大致执行的步骤:

  1. 分配N组事件(我们的受害者事件包含在其中)
  2. 假设受害者是这N个分配事件中的第M个
  3. 分配0x400个事件(在N组事件之外)
  4. 从每个奇数事件释放0x100个事件到CPU0(填充CPU的部分列表)
  5. 从每个奇数事件释放0x100个事件到CPU1(填充CPU的部分列表)
  6. 在CPU1上:释放所有N个事件,除了第(M-1)个、第M个、第(M+1)个
  7. 在CPU0上:使用第M个事件触发漏洞,第M个事件被释放
  8. 在CPU1上:释放第(M-1)个和第(M+1)个事件,其slub页面将变空并放入CPU1的pcp空闲列表。

成功回收后,我们可以将causal_dependency置空,从而解除无限循环。 参与者还会注意到temporal_event的大小是0x70,跨缓存到另一个对象时很难找到对齐相同的对象。这就是为什么这个挑战变得如此困难的原因,但它应该可以用常见或已知的技术解决。

预期解决方案 #01

创建挑战后,我迅速构思了一个解决方案,不是使用可能需要更多时间来构建的常见技巧,而是使用了一个技巧,让双重释放变成页面释放后使用(UAF),而无需关心块或地址对齐。这也是提示#2的内容:

提示 #2: 🤫 这里有一个有趣的思维实验:当你偷看一下kfree,然后…哎呀…“意外地”释放了一些非slab管理的内存会发生什么?这可能正是你一直在等待的、解决非对齐kfree问题的剧情转折!

事实证明,如果你kfree一个非slub管理的内存区域,它只会释放其所在的页面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
 * kfree - 释放先前分配的内存
 * @object: kmalloc() 或 kmem_cache_alloc() 返回的指针
 *
 * 如果 @object 是 NULL,则不执行任何操作。
 */
void kfree(const void *object)
{
	struct folio *folio;
	struct slab *slab;
	struct kmem_cache *s;
	void *x = (void *)object;

	trace_kfree(_RET_IP_, object);

	if (unlikely(ZERO_OR_NULL_PTR(object)))
		return;

	folio = virt_to_folio(object);
	if (unlikely(!folio_test_slab(folio))) {
		free_large_kmalloc(folio, (void *)object);
		return;
	}

	slab = folio_slab(folio);
	s = slab->slab_cache;
	slab_free(s, slab, x, _RET_IP_);
}
EXPORT_SYMBOL(kfree);

static void free_large_kmalloc(struct folio *folio, void *object)
{
	unsigned int order = folio_order(folio);

	if (WARN_ON_ONCE(order == 0))
		pr_warn_once("object pointer: 0x%p\n", object);

	kmemleak_free(object);
	kasan_kfree_large(object);
	kmsan_kfree_large(object);

	lruvec_stat_mod_folio(folio, NR_SLAB_UNRECLAIMABLE_B,
			      -(PAGE_SIZE << order));
	folio_put(folio);
}

不关心任何对齐或任何地址,如果地址不是由slab支持的,它将把页面folio传递给free_large_kmalloc,并通过调用folio_put来释放页面。 所以我的计划是喷洒由管道支持的页面(其阶数为0),并精心设置其引用计数=1,然后它将再次调用event_put,从而释放我们的管道页面。管道页面(不同于pipe_buffer)也是一种已知的内核利用技术,如果你读过这篇文章或这篇文章,或者你可以查看这里的代码。 这样我们就有了一个通过kfree -> 页面释放原语释放的管道页面,但仍然被管道文件描述符持有。接下来,为了快速实现,我尝试用页表来回收已释放的管道页面。我们仍然可以通过写入管道来写入页表,因此我们可以利用这一点实现任意物理读/写。

预期解决方案 #02

一周快过去了,我们在第三天发布了提示#1和#2,并思考是否需要发布第三个提示。这次我在想,也许我的第一个预期解决方案可能不太常见(或者挑战太难了)。结果,Jun Rong在我们发布第三个提示之前解决了这个挑战,正如我之前所预料的那样。 尽管受害者块放置在0x70对齐的地址上看起来很难解决,但我确信并且有足够的信心,可以使用更常见的技术来解决,比如msg_msg或pipe_buffer,或者它可能无法用常见技术解决? 然后我迅速验证,只基于msg_msg和pipe_buffer来制定这个解决方案,结果证明是可行的(虽然只有大约50%的可靠性,但仍然不错)。 这个想法是,我们不断尝试,直到受害者事件与我们的目标kmalloc缓存对齐。例如,如果我们幸运的话,我们可以与kmalloc-128、192、256对齐。你可以自己计算一下,大约有10%的概率是对齐的。在此基础上,我有了另一个想法,让它多次运行,直到对齐正确而不使内核崩溃,并使这个解决方案的可靠性达到50%左右。 我们可以再次查看代码如何释放所有事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
        list_for_each_entry(tl, &session->all_timelines, session_node) {
                list_for_each_entry_safe(event, tmp_event, &tl->events_head, timeline_node) {
                        struct temporal_event *cause = event->causal_dependency;
                        list_del(&event->timeline_node);
                        while (cause) {
                                struct temporal_event *next_in_chain = cause->causal_dependency;
                                event_put(cause);
                                cause = next_in_chain;
                        }
                        event->causal_dependency = NULL;
                        event_put(event);
                }
        }

以下是该想法的大致步骤:

  1. 插入第一个事件,caused_by指向自身(victim_event)
  2. 插入第二个事件,caused_by指向自身(确保它与第一个事件在不同的slab页面上)
  3. 插入第三个事件,使其caused_by指向第一个事件
  4. 关闭文件描述符,在释放第一个事件时它将进入无限循环。
  5. 用msg_msgseg回收,假设是kmalloc-192,我们不需要精心构造任何内容,只需将所有内容置零
  6. 无限循环将被打破
  7. 来自第二个事件的另一个无限循环正在运行
  8. 在另一个CPU上,我将观察成功替换受害者事件的msg_msgseg,其内容在某些偏移处不应为零(因为refcount_dec会修改内容)
  9. 所以我知道引用计数的偏移量,并且我将知道对象位置是否与msg_msgseg相同。
  10. 如果不同,我释放第二个无限循环(当然是通过再次用null回收第二个事件)
  11. 然后它将释放第三个事件,尽管第三个事件会释放其导致的事件(即受害者事件),但它不会做任何事情(如果引用计数不为1,event_put是无害的)
  12. 回到开头(再次运行)
  13. 如果相同,我替换我们的msg_msgseg(通过删除并重新分配)并精心构造temporal_event,使其引用计数=1
  14. 我释放第二个事件的无限循环
  15. 然后它将释放第三个事件,causal_dependency仍然指向受害者事件,并且现在引用计数=1,然后它将尝试kfree受害者事件,而该事件实际上仍由我们的msg_msg持有

经过这些步骤,我们在msg_msgseg上获得了释放后使用(UAF)。这看起来太复杂了,但只要有创造力+时间,参与者应该能找到类似的想法。在msg_msgseg上获得UAF之后,就意味着我们在通用缓存上获得了UAF。在我的案例中,我将msg_msgseg上的UAF转换为pipe_buffer上的UAF(pipe_buffer与msg_msgseg在同一个缓存中),你可以利用这一点获得内核任意读写,或者直接覆盖内核函数指针并进行ROP操作。 附带说明一下,这个解决方案仍然只有50%的可靠性,因为有时我们可能会遇到这样的情况:event->causal_dependency存储的位置与当我们用另一个对象回收时的空闲列表值相同,因为新的slab页面被分配,并且它们会重新初始化整个slab页面的空闲列表值,因此当内核解引用event->causal_dependency时可能会导致崩溃。

结束语

尽管我意识到我创造了一个比大多数学生想象中或能够做到的更难的挑战,但我很高兴三名学生凭借创造力和毅力成功解决了它。这三名学生以不同于预期解决方案的方式解决了它,这对于内核CTF挑战来说非常正常。其中一人使用了refcount_dec原语来编辑页表,另一人使用部分覆盖来将页面释放后使用(UAF)提升到更高的阶,看到他们的创造力真的很酷。这正是我们在CTF甚至现实世界中面对困难的利用问题时所需要的。

学生解题报告

参考文献

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计