A Linux Kernel 0-day Journey – From a limited UAF to Physical Memory R/W

speckx1 pts0 comments

A Linux Kernel 0-day Journey - From a limited UAF to Physical Memory R/W - @kiks

← blog

A Linux Kernel 0-day Journey - From a limited UAF to Physical Memory R/W

2026 - @kiks

table of contents<br>12

01Introduction

02The bug

03The primitives

04The exploit

First assessment

First stage: from slab UAF to page UAF

Second stage: from page UAF to physical memory r/w

Third stage: arbitrary physical memory read and write

Fourth stage: uid=0

05Some notes on AI exploit development and the other primitive

Conclusions

06References

# Introduction

This article is related to a Linux Kernel 0-day I have recently found and exploited initially for Pwn2own 2026 (Red Hat category). Unfortunately, I waited too long to register (even if it was before the deadline) and ended up being ignored (legitimately due to the unexpected number of submissions) and so, I was not able to participate. The bug ended up being reported from another researcher, but no public details (and no CVE) have been still published about it and on its exploitability.<br>The bug is just another bug in the network scheduler subsystem, the red scheduler. I have picked this subsystem as a target for three main reasons: unprivileged user namespaces are enabled in Red Hat, schedulers are an historically interesting subsystem (due to the high complexity and interactions with multiple network parts) and I had a fuzzer suitable for that kind of targets (syzkaller has its own limitations with this type of subsystems).<br>Besides of the bug, the most interesting part is the exploitation strategy, turning an initially limited slab UAF into a page UAF and ultimately into a physical memory read and write.

# The bug

The vulnerability affects the "red network scheduler" (what a coincidence since I was targeting Red Hat) and has been introduced in commit 3f14b37 (January 2024) and fixed in commit a8a0289 (June 2026), lasting for 2.5 years. The fix is really straightforward and immediate:

--- a/net/sched/cls_api.c<br>+++ b/net/sched/cls_api.c<br>@@ -4049,6 +4049,9 @@ struct sk_buff *tcf_qevent_handle(struct tcf_qevent *qe, struct Qdisc *sch, stru<br>skb_do_redirect(skb);<br>*ret = __NET_XMIT_STOLEN;<br>return NULL;<br>+ case TC_ACT_CONSUMED:<br>+ *ret = __NET_XMIT_STOLEN;<br>+ return NULL;

return skb;

I couldn't explain any better than what has been already done in the commit, so I'm just gonna extract the key part from it and add more details on top:

tcf_classify() can return TC_ACT_CONSUMED while the skb is held by the<br>defragmentation engine (e.g. act_ct on out-of-order fragments). When<br>that happens the skb is no longer owned by the caller and must not be<br>touched again.

tcf_qevent_handle() did not handle TC_ACT_CONSUMED: it fell through the<br>switch and returned the skb to the caller as if classification had<br>passed.

To see it in practice through the code, the red enqueue function (red_enqueue) is responsible to enqueue packets on its own scheduler.

static int red_enqueue(struct sk_buff *skb, struct Qdisc *sch,<br>struct sk_buff **to_free)<br>switch (red_action(&q->parms, &q->vars, q->vars.qavg)) { // [2]<br>case RED_DONT_MARK:<br>break;

case RED_PROB_MARK:<br>// ..<br>if (INET_ECN_set_ce(skb)) {<br>q->stats.prob_mark++;<br>skb = tcf_qevent_handle(&q->qe_mark, sch, skb, to_free, &ret); // [1]<br>if (!skb)<br>return NET_XMIT_CN | ret;<br>// ..<br>/* Non-ECT packet in ECN nodrop mode: queue it. */<br>break;<br>case RED_HARD_MARK:<br>// ..<br>if (INET_ECN_set_ce(skb)) {<br>q->stats.forced_mark++;<br>skb = tcf_qevent_handle(&q->qe_mark, sch, skb, to_free, &ret); // [1]<br>if (!skb)<br>return NET_XMIT_CN | ret;<br>// ..<br>// ..<br>ret = qdisc_enqueue(skb, child, to_free); // [5]<br>// ..

The tcf_qevent_handle [1] function is called in multiple locations when red_action [2] returns RED_PROB_MARK or RED_HARD_MARK (basically when an user-controllable threshold is reached). When one of the two values is returned, and ECN is set (configurable from the tc command line), tcf_qevent_handle is called:

struct sk_buff *tcf_qevent_handle(struct tcf_qevent *qe, struct Qdisc *sch, struct sk_buff *skb,<br>struct sk_buff **to_free, int *ret)<br>// ..<br>switch (tcf_classify(skb, NULL, fl, &cl_res, false)) { // [3]<br>case TC_ACT_SHOT:<br>qdisc_qstats_drop(sch);<br>__qdisc_drop(skb, to_free);<br>*ret = __NET_XMIT_BYPASS;<br>return NULL;<br>case TC_ACT_STOLEN:<br>case TC_ACT_QUEUED:<br>case TC_ACT_TRAP:<br>__qdisc_drop(skb, to_free);<br>*ret = __NET_XMIT_STOLEN;<br>return NULL;<br>case TC_ACT_REDIRECT:<br>skb_do_redirect(skb);<br>*ret = __NET_XMIT_STOLEN;<br>return NULL;

return skb;

// called from tcf_classify after few dereferences<br>TC_INDIRECT_SCOPE int tcf_ct_act(struct sk_buff *skb, const struct tc_action *a,<br>struct tcf_result *res)<br>// ..<br>err = tcf_ct_handle_fragments(net, skb, family, p->zone, &defrag);<br>if (err)<br>goto out_frag;

// ..<br>out_frag:<br>if (err != -EINPROGRESS)<br>tcf_action_inc_drop_qstats(&c->common);<br>return TC_ACT_CONSUMED; // [4]

This is where the bug lives, and is simply a missing case for the TC_ACT_CONSUMED value that can be returned from tcf_classify [3]. This value can be returned in multiple user...

struct return from case sk_buff tcf_qevent_handle

Related Articles