缓存时间:
2026/07/20 21:29
# Linux 内核 0-day 之旅 - 从有限 UAF 到物理内存读写 来源: https://1day.dev/posts/linux-kernel-0day.html 2026 - @kiks 1. 01引言 (https://1day.dev/posts/linux-kernel-0day.html#introduction) 2. 02漏洞 (https://1day.dev/posts/linux-kernel-0day.html#the-bug) 3. 03原语 (https://1day.dev/posts/linux-kernel-0day.html#the-primitives) 4. 04利用 (https://1day.dev/posts/linux-kernel-0day.html#the-exploit) 5. 初步评估 (https://1day.dev/posts/linux-kernel-0day.html#first-assessment) 6. 第一阶段:从 slab UAF 到 page UAF (https://1day.dev/posts/linux-kernel-0day.html#first-stage-from-slab-uaf-to-page-uaf) 7. 第二阶段:从 page UAF 到物理内存读写 (https://1day.dev/posts/linux-kernel-0day.html#second-stage-from-page-uaf-to-physical-memory-r-w) 8. 第三阶段:任意物理内存读写 (https://1day.dev/posts/linux-kernel-0day.html#third-stage-arbitrary-physical-memory-read-and-write) 9. 第四阶段:uid=0 (https://1day.dev/posts/linux-kernel-0day.html#fourth-stage-uid-0) 10. 05关于 AI 利用开发及其他原语的说明 (https://1day.dev/posts/linux-kernel-0day.html#some-notes-on-ai-exploit-development-and-the-other-primitive) 11. 结论 (https://1day.dev/posts/linux-kernel-0day.html#conclusions) 12. 06参考文献 (https://1day.dev/posts/linux-kernel-0day.html#references) ## \# 引言 (https://1day.dev/posts/linux-kernel-0day.html#introduction) 本文介绍了我最近发现并最初为 Pwn2own 2026(Red Hat 类别)利用的一个 Linux 内核 0-day。遗憾的是,我等待注册时间过长(尽管是在截止日期前),最终被忽略(由于提交数量超出预期,这是合理的),因此未能参赛。该漏洞后来被另一位研究人员报告,但至今尚未公开该漏洞的详细信息(也无 CVE)及其可利用性。该漏洞是网络调度器子系统(即 red 调度器)中的“又一个漏洞”。我选择该子系统作为目标,主要基于三个原因:Red Hat 中启用了非特权用户命名空间;调度器历来是一个有趣的子系统(因其高度复杂且与多个网络组件交互);以及我拥有适合此类目标的模糊测试工具(syzkaller 对此类子系统存在局限性)。除了漏洞本身,最具吸引力的部分是利用策略——将最初有限的 slab UAF 转化为 page UAF,最终实现物理内存读写。 ## \# 漏洞 (https://1day.dev/posts/linux-kernel-0day.html#the-bug) 该漏洞影响“red 网络调度器”(多么“巧合”,因为我当时正瞄准 Red Hat),在 commit 3f14b37 (https://github.com/torvalds/linux/commit/3f14b377d01d8357eba032b4cabc8c1149b458b6)(2024 年 1 月)中引入,在 commit a8a0289 (https://github.com/torvalds/linux/commit/a8a02897f2b479127db261de05cbf0c28b98d159)(2026 年 6 月)中修复,历时 2.5 年。修复代码非常简单直接: `` --- a/net/sched/cls_api.c +++ b/net/sched/cls_api.c @@ -4049,6 +4049,9 @@ struct sk_buff *tcf_qevent_handle(struct tcf_qevent *qe, struct Qdisc *sch, stru skb_do_redirect(skb); *ret = __NET_XMIT_STOLEN; return NULL; + case TC_ACT_CONSUMED: + *ret = __NET_XMIT_STOLEN; + return NULL; } return skb; `` 已有提交说明,我无法比它解释得更好了,因此我只提取关键部分并补充更多细节: `` tcf_classify() can return TC_ACT_CONSUMED while the skb is held by the defragmentation engine (e.g. act_ct on out-of-order fragments). When that happens the skb is no longer owned by the caller and must not be touched again. tcf_qevent_handle() did not handle TC_ACT_CONSUMED: it fell through the switch and returned the skb to the caller as if classification had passed. `` 通过代码实际观察,red 的入队函数(`red_enqueue` (https://elixir.bootlin.com/linux/v6.12.83/source/net/sched/sch_red.c#L70))负责将数据包入队到其自身的调度器。 `` static int red_enqueue(struct sk_buff *skb, struct Qdisc *sch, struct sk_buff **to_free) { switch (red_action(&q->parms, &q->vars, q->vars.qavg)) { // [2] case RED_DONT_MARK: break; case RED_PROB_MARK: // .. if (INET_ECN_set_ce(skb)) { q->stats.prob_mark++; skb = tcf_qevent_handle(&q->qe_mark, sch, skb, to_free, &ret); // [1] if (!skb) return NET_XMIT_CN | ret; // .. /* Non-ECT packet in ECN nodrop mode: queue it. */ break; case RED_HARD_MARK: // .. if (INET_ECN_set_ce(skb)) { q->stats.forced_mark++; skb = tcf_qevent_handle(&q->qe_mark, sch, skb, to_free, &ret); // [1] if (!skb) return NET_XMIT_CN | ret; // .. } // .. ret = qdisc_enqueue(skb, child, to_free); // [5] // .. } `` 当 `red_action` (https://elixir.bootlin.com/linux/v6.12.83/source/include/net/red.h#L414) [2] 返回 `RED_PROB_MARK` 或 `RED_HARD_MARK` 时(即当用户可控的阈值被命中时),`tcf_qevent_handle` [1] 函数会在多个位置被调用。当返回这两个值之一且 ECN 被设置(可通过 `tc` 命令行配置)时,将调用 `tcf_qevent_handle` (https://elixir.bootlin.com/linux/v6.12.83/source/net/sched/cls_api.c#L4015): `` struct sk_buff *tcf_qevent_handle(struct tcf_qevent *qe, struct Qdisc *sch, struct sk_buff *skb, struct sk_buff **to_free, int *ret) { // .. switch (tcf_classify(skb, NULL, fl, &cl_res, false)) { // [3] case TC_ACT_SHOT: qdisc_qstats_drop(sch); __qdisc_drop(skb, to_free); *ret = __NET_XMIT_BYPASS; return NULL; case TC_ACT_STOLEN: case TC_ACT_QUEUED: case TC_ACT_TRAP: __qdisc_drop(skb, to_free); *ret = __NET_XMIT_STOLEN; return NULL; case TC_ACT_REDIRECT: skb_do_redirect(skb); *ret = __NET_XMIT_STOLEN; return NULL; } return skb; } // called from tcf_classify after few dereferences TC_INDIRECT_SCOPE int tcf_ct_act(struct sk_buff *skb, const struct tc_action *a, struct tcf_result *res) { // .. err = tcf_ct_handle_fragments(net, skb, family, p->zone, &defrag); if (err) goto out_frag; // .. out_frag: if (err != -EINPROGRESS) tcf_action_inc_drop_qstats(&c->common); return TC_ACT_CONSUMED; // [4] } `` 这就是漏洞所在,仅仅是缺失了对 `tcf_classify` [3] 可能返回的 `TC_ACT_CONSUMED` 值的 `case` 处理。该值可以在多种用户可控场景下返回(例如发送分片数据包时),其含义是所有权已转移给 skb 重组引擎,调用者必须丢弃该 skb。`tcf_ct_act` (https://elixir.bootlin.com/linux/v6.1.83/source/net/sched/act_ct.c#L1104) 在经过几次解引用后从 `tcf_classify` 被调用,当 `tcf_ct_handle_fragments` (https://elixir.bootlin.com/linux/v6.1.83/source/net/sched/act_ct.c#L845) 返回某个值时(例如发送第一个分片数据包时),它会返回 `TC_ACT_CONSUMED` [4]。然而,`tcf_qevent_handle` 中的 switch 忽略了 `TC_ACT_CONSUMED` 分支,导致 fallthrough 并返回 `skb` 而非 `NULL`,最终通过 `qdisc_enqueue` (https://elixir.bootlin.com/linux/v6.12.83/source/include/net/sch_generic.h#L893) [5] 将数据包入队到调度器队列,间接调用 `bfifo_enqueue` (https://elixir.bootlin.com/linux/v6.12.83/source/net/sched/sch_fifo.c#L19),后者会使用 `skb->next` 链表将 `skb` 指针(仍未释放)作为队列最后一个元素存储 [6]。 `` static int bfifo_enqueue(struct sk_buff *skb, struct Qdisc *sch, struct sk_buff **to_free) { if (likely(sch->qstats.backlog + qdisc_pkt_len(skb) <= READ_ONCE(sch->limit))) return qdisc_enqueue_tail(skb, sch); return qdisc_drop(skb, sch, to_free); } static inline int qdisc_enqueue_tail(struct sk_buff *skb, struct Qdisc *sch) { __qdisc_enqueue_tail(skb, &sch->q); qdisc_qstats_backlog_inc(sch, skb); return NET_XMIT_SUCCESS; } static inline void __qdisc_enqueue_tail(struct sk_buff *skb, struct qdisc_skb_head *qh) { struct sk_buff *last = qh->tail; if (last) { skb->next = NULL; last->next = skb; // [6] qh->tail = skb; } else { qh->tail = skb; qh->head = skb; } qh->qlen++; } `` 这里的 `skb` 指针尚未释放,但会在第二个分片数据包到达时释放,从而在队列中留下悬空指针作为最后一个元素。更准确地说,它会在 `inet_frag_reasm_prepare` (https://elixir.bootlin.com/linux/v6.12.83/source/net/ipv4/inet_fragment.c#L450) 中通过 `consume_skb` (https://elixir.bootlin.com/linux/v6.12.83/source/net/ipv4/inet_fragment.c#L494) 调用被释放(总是从 `tcf_classify` 调用开始)。 ## \# 原语 (https://1day.dev/posts/linux-kernel-0day.html#the-primitives) 从展示的代码中已经可以看出第一个原语: `` static inline void __qdisc_enqueue_tail(struct sk_buff *skb, struct qdisc_skb_head *qh) { struct sk_buff *last = qh->tail; // [7] if (last) { skb->next = NULL; last->next = skb; // [6] qh->tail = skb; // [8] } else { qh->tail = skb; qh->head = skb; } qh->qlen++; } `` 如前所述,当 `skb` 被错误地插入队列时,它仍处于分配状态但所有权已转移。当第二个分片到达时,`skb` 被释放,在队列尾部(`qh->tail` [7])留下悬空指针,该指针存储在 `last` 变量中,并导致 [8] 处的原语:**指针写入**。在 `qh->tail = skb` 这一行 [8] 中,`qh->tail` 是一个悬空指针,最重要的是,我们可以通过使用同样的技术让 `skb` 也成为悬空指针。这里存在一个从利用角度而言的有趣细节。当发送两个分片数据包时,第一个数据包的长度设置为 0,而第二个携带两个数据包的总大小。这为什么重要?因为,如果你仔细观察 `bfifo_enqueue` (https://elixir.bootlin.com/linux/v6.12.83/source/net/sched/sch_fifo.c#L19) 中的 if 条件,数据包只有当不超过调度器限制(可从用户空间调整)时才会被入队,这意味着我们可以确定性地放置一个指针,然后使用同样的漏洞原语(例如两个分片中的第一个)将其释放,从而在偏移 0 处(`sk_buff` (https://elixir.bootlin.com/linux/v6.12.83/source/include/linux/skbuff.h#L756) 结构体中 `next` (https://elixir.bootlin.com/linux/v6.12.83/source/include/linux/skbuff.h#L870) 的偏移)实现更有趣的**已释放指针写入**。这并非该漏洞提供的唯一原语。另一个相当强大的原语来自 `red_dequeue` 函数(负责出队已入队的数据包),将在专门的“关于 AI 利用开发及其他原语的说明”章节中提及,并说明我为何选择此原语而不是另一个。 ## \# 利用 (https://1day.dev/posts/linux-kernel-0day.html#the-exploit) ### \# 初步评估 (https://1day.dev/posts/linux-kernel-0day.html#first-assessment) 至此,我拥有一个相当受限的 UAF 原语,距离 pwn2own 报名截止日期还有一周半的时间,我必须抓紧。我花了一些时间寻找最适合通过跨缓存(Cross Cache)重新分配的对象,覆盖其前 8 个字节,并利用其副作用——无论是另一个指针、大小值(考虑到巨大的 `0xffff...` 半控制值)、引用计数,还是其他类似内容。唯一的跨缓存约束是悬空指针起始于 order 1(2 页)的缓存 `skbuff_head_cache`(在拥有 4 核或更多核心的系统中)。 ### \# 第一阶段:从 slab UAF 到 page UAF (https://1day.dev/posts/linux-kernel-0day.html#first-stage-from-slab-uaf-to-page-uaf) 某个时刻,我想起了一个公开的利用 (https://ssd-disclosure.com/lpe-via-refcount-imbalance-in-the-af_unix-of-ubuntus-kernel/),该利用恰恰针对 `sk_buff`,而正是在这里我偶然发现了 `pgv` 结构体 (https://elixir.bootlin.com/linux/v6.12.83/source/net/packet/internal.h#L55),尽管它并未像我计划的那样使用。 `` static int packet_setsockopt(struct socket *sock, int level, int optname, sockptr_t optval, unsigned int optlen) { // .. switch (optname) { case PACKET_RX_RING: case PACKET_TX_RING: // [9] { // .. case TPACKET_V3: // .. if (!ret) ret = packet_set_ring(sk, &req_u, 0, optname == PACKET_TX_RING); release_sock(sk); return ret; } // .. } static int packet_set_ring(struct sock *sk, union tpacket_req_u *req_u, int closing, int tx_ring) { // .. if (req->tp_block_nr) { // .. order = get_order(req->tp_block_size); pg_vec = alloc_pg_vec(req, order); // .. } // .. } tatic struct pgv *alloc_pg_vec(struct tpacket_req *req, int order) { unsigned int block_nr = req->tp_block_nr; struct pgv *pg_vec; int i; pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN); // [10] // .. for (i = 0; i < block_nr; i++) { pg_vec[i].buffer = alloc_one_pg_vec_page(order); // [11] if (unlikely(!pg_vec[i].buffer)) goto out_free_pgvec; } out: return pg_vec; // .. } static char *alloc_one_pg_vec_page(unsigned long order) { // .. buffer = (char *) __get_free_pages(gfp_flags, order); // [12] // .. } `` `pgv` 结构体因其灵活性而极具吸引力,但最重要的是其内容。它可以通过带 `PACKET_TX_RING` (https://elixir.bootlin.com/linux/v6.12.83/source/net/packet/af_packet.c#L3866) [9] 参数的 `setsockopt` 系统调用进行分配,并根据用户在 `block_nr` [10] 中指定的用户可控大小,将对象分配到一个任意的通用缓存(例如 `kmalloc-XXXX`)中。该分配会被填充为页面指针 [11](通过 `__get_free_pages` [12] 分配),其中 `order` 值也可由用户控制。既然我们可以覆盖偏移 0 处的指针,还有什么比一个页面指针列表更好的呢?在讨论第二个对象的重新分配之前,我们先看看用悬空指针损坏其中一个 `pgv` 指针会带来什么原语。起初,我陷入了一个死胡同,浪费了整整一天多的时间。给定一个已分配的 `pgv`,我们可以通过 `packet_mmap` (https://elixir.bootlin.com/linux/v6.12.83/source/net/packet/af_packet.c#L4621) 文件操作将其映射。 `` static int packet_mmap(struct file *file, struct socket *sock, struct vm_area_struct *vma) { // .. for (rb = &po->rx_ring; rb <= &po->tx_ring; rb++) { // .. for (i = 0; i < rb->pg_vec_len; i++) { void *kaddr = rb->pg_vec[i].buffer; // .. for (pg_num = 0; pg_num < rb->pg_vec_pages; pg_num++) { page = pgv_to_page(kaddr); err = vm_insert_page(vma, start, page); // [13] // .. } } } // .. } static int insert_page(struct vm_area_struct *vma, unsigned long addr, struct page *page, pgprot_t prot) { // .. retval = validate_page_before_insert(vma, page); if (retval) goto out; retval = -ENOMEM; pte = get_locked_pte(vma->vm_mm, addr, &ptl); if (!pte) goto out; retval = insert_page_into_pte_locked(vma, pte, addr, page, prot); pte_unmap_unlock(pte, ptl); out: return retval; } static int validate_page_before_insert(struct vm_area_struct *vma, struct page *page) { struct folio *folio = page_folio(page); // .. if (folio_test_anon(folio) || folio_test_slab(folio) || page_has_type(page)) return -EINVAL; flush_dcache_folio(folio); return 0; } `` 从代码可以看出,通过在该文件描述符上调用 `mmap`,`mmap` 处理程序会遍历刚刚分配的 `pgv` 指针,并对每个指针调用 `vm_insert_page` [13]。考虑到我们“控制”了一个指针,这可能意味着我们可以将任意指针映射到用户空间。这确实是真的,但并非全部真相。首先,`pgv_to_page` (https://elixir.bootlin.com/linux/v6.12.83/source/net/packet/af_packet.c#L393) 调用 `virt_to_page`,并通过将指针页面对齐(考虑到我们已经将一个页面对齐的指针替换为可能未页面对齐的 slab 指针)为我们提供了很大便利,然后调用 `v`