When AI Makes 0-Days Feel Like N-Days
A technical analysis of exploiting a use-after-free vulnerability in the Linux kernel's net/sched subsystem, discovered with AI assistance. The author details race condition optimization techniques and demonstrates how AI accelerated the exploit development process, including bug discovery, KASAN PoC creation, and race improvement.
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 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.
- Binder threads
This thread utilizes the error path in filter creation. It submits a filter create request with two actions:
Action with idx 42
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 owner); return err; }
This saves the overhead of a second netlink operation to delete the newly created filter.
- Deleter threads
This thread does 2 main things:
Creates a filter with one action, idx 42
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
[truncated for AI cost control]