Post

1. [Bad epoll CVE-2026-46242] epoll close vs close race and UAF

1. [Bad epoll CVE-2026-46242] epoll close vs close race and UAF

GOAL

Help the reader understand why the UAF occurs and how the 8-byte UAF write primitive works.


Series Introduction

This is the first part of a three-part series analyzing CVE-2026-46242 (Bad Epoll), a 1-day vulnerability in the Linux kernel’s epoll subsystem.

  • Part 1 (this article) - The Root of the Bug: epoll Close-vs-Close Race and UAF
  • Part 2 - Heap! : The SLUB Allocator and Cross-Cache
  • Part 3 - Opening the Kernel: From Arbitrary Read to ROP and Root

1. Why Is This Bug So Dangerous? — Two Holes Left by a Single Commit

Bad Epoll (CVE-2026-46242) is a race-condition-based use-after-free (UAF) vulnerability in the Linux kernel’s epoll subsystem. An unprivileged user can potentially escalate privileges all the way to root using this single bug, making it relevant to both Linux servers and Android rooting.

According to publicly available research, exploits achieve approximately a 99% success rate on kernel 6.12, and there is no practical kill-switch that can mitigate the vulnerability simply by disabling a specific configuration option. All kernels v6.4 and later are affected.

But the truly interesting part of this vulnerability lies in its origin.

One Commit, Two Races

This bug traces back to a single commit introduced in 2023:

58c9b016e128 (2023-04-08)

The problem is that this commit introduced two distinct race conditions into roughly 2,500 lines of epoll code.

Those two races eventually became two separate CVEs.

One was CVE-2026-43074, discovered by Mythos.

The other was the Bad Epoll vulnerability (CVE-2026-46242) — the second race that Mythos missed despite analyzing the same code.

And that’s the key story:

“An AI model scanned the same 2,500 lines of code and caught Race A (CVE-2026-43074), but missed Race B (Bad Epoll), which was sitting right next to it.”

The race window is only a few instructions wide, and the evidence it leaves behind at runtime is difficult to observe. These characteristics make the second race particularly easy to miss during automated analysis.

This series digs into exactly that “second hole that even AI missed.”

Now, let’s get into the vulnerability itself. To do that, we first need to understand how epoll is structured internally.


2. epoll Fundamentals — struct eventpoll, struct epitem, and Nested epoll

epoll is an I/O multiplexing mechanism that allows Linux to monitor multiple file descriptors (fds) simultaneously and report only the ones on which an event has occurred.

You create an epoll instance with epoll_create(), add or remove monitored file descriptors with epoll_ctl(), and wait for events with epoll_wait().

Internally, two major structures represent these relationships.

struct eventpoll

When epoll_create() is called, the kernel allocates a struct eventpoll and creates a backing struct file for it, which is then exposed to userspace as an fd.

In other words:

One epoll fd = one struct file + one struct eventpoll

struct epitem — The “Watcher ↔ Target” Relationship

Whenever a target fd is registered with an epoll fd using EPOLL_CTL_ADD, the kernel creates a struct epitem to represent that relationship.

An epitem is linked into lists in two directions:

  • eventpoll → epitem: the list of targets monitored by this epoll instance
  • target file → epitem: the list of epoll instances currently monitoring this file

The link in the second direction is epi->fllink (struct hlist_node), while the head of the list on the target file is file->f_ep.

In other words, when a target file is closed, the kernel needs to follow file->f_ep and clean up the associated epitems.

This reverse-direction cleanup is at the heart of the race we are analyzing.

Nested epoll — An epoll Watching Another epoll

Here’s an important fact:

An epoll fd is itself a file, so another epoll instance can monitor it.

That means epoll A can register epoll B as a monitored target. This is known as nested epoll.

Nested epoll structure

  • ep_waiter: the epoll instance doing the monitoring
  • ep_target: the epoll instance being monitored
    (Its backing file is associated with the struct eventpoll we are targeting.)

Once ep_waiter is configured to monitor ep_target, an epitem is created to represent the relationship between the two objects. The file associated with ep_target then points to that epitem through f_ep.

If both epoll instances are closed concurrently, two separate cleanup paths can end up racing over the same target object.

That is the setup we need to understand before diving into the actual close-vs-close race.


3. Close vs. Close — Where the Two Cleanup Paths Collide: One Frees While the Other Writes

The essence of the bug can be summarized in a single sentence:

“Two different epoll close paths execute concurrently: while one path frees an object, the other writes to that same object.”

If ep_waiter and ep_target are closed concurrently on different CPUs, the following two paths execute in parallel.

Thread A (CPU1)

When ep_waiter is closed, a cleanup path runs to remove the relationship (epitem) between ep_waiter and the ep_target it was monitoring.

Thread A cleanup path

__ep_remove() acquires file->f_lock to break the monitoring relationship and removes its epitem from ep_target’s file.

As part of this process, it marks the target file as no longer having an epoll relationship:

1
2
WRITE_ONCE(file_target->f_ep, NULL);
// "This file is no longer connected to epoll"

While still holding file_target->f_lock, it then proceeds to remove the epitem from the actual list with:

1
hlist_del_rcu(&epi->fllink);

The problem is that Thread A assumes that file_target and the struct eventpoll associated with it are still alive at this point.

Thread B (CPU0)

At roughly the same time, ep_target’s fd is closed on another CPU. As a result, the last reference to the struct file representing ep_target disappears, and the kernel begins its final teardown of that file.

Thread B release path

eventpoll_release() contains a lockless fast path — an optimization that says, “if this file has no epoll relationship left, there’s nothing to clean up”:

1
2
if (likely(READ_ONCE(file_target->f_ep) == NULL))
        return;   // No epoll relationship to clean up → proceed to free

When this fast path is taken, teardown continues straight through ep_clear_and_put() to ep_free(), which frees the struct eventpoll (and the struct file) associated with ep_target.

The Collision Point

Now let’s overlay the two execution paths.

Thread A Thread B interleaving

Immediately after Thread A executes:

1
WRITE_ONCE(file_target->f_ep, NULL);

Thread A has not yet finished unlinking the epitem.

At this point, if Thread B executes READ_ONCE(file_target->f_ep), it sees NULL.

Thread B interprets this as: “There is no epoll relationship left to clean up.” It therefore skips the remaining cleanup and proceeds to ep_free(). As a result, the objects associated with ep_target, including its struct eventpoll and struct file, are freed.

But Thread A hasn’t finished yet.

Thread A is still holding the lock and still holds a pointer referring to the object that Thread B has just freed. It then continues its cleanup and attempts to access the freed object — leaving Thread A with a dangling pointer to freed memory.

And that is the Use-After-Free (UAF).

The key point is that WRITE_ONCE(file_target->f_ep, NULL) happens before the epitem is actually unlinked. That creates a narrow window in which Thread B can observe NULL, incorrectly conclude that cleanup is unnecessary, and free the object while Thread A is still operating on it.


4. Six-Instruction Race Window — Why It Rarely Happens Naturally and How a Timer Interrupt Widens the Window

The reason this bug remained hidden for years after being introduced in 2023 is that the race window is extremely narrow.

Why Is the Window So Narrow?

The critical section lies between:

  • Start: immediately after WRITE_ONCE(file_target->f_ep, NULL) executes, and
  • End: before releasing file_target->f_lock, immediately before the write performed by hlist_del_rcu().

This gap is only about six CPU instructions wide.

Thread B must execute READ_ONCE(file_target->f_ep) at precisely the moment when those six instructions are being executed. Only then can it observe NULL and take the incorrect cleanup path. If Thread B arrives even slightly too early or too late, nothing happens.

Moreover, Thread A is holding a spinlock during this period, so the CPU running Thread A cannot simply be preempted in the middle of the critical section. Under normal execution, the probability of naturally hitting this exact timing is therefore extremely low.

So How Can We Trigger It Reliably?

The narrow race window needs to be artificially widened.

The key idea is to pin the two threads to different CPUs so they can execute truly in parallel, and then use an external event such as a timer interrupt to briefly delay one thread at a carefully chosen point, allowing the timing of the two execution paths to line up.


5. Results — What We Got: An 8-Byte Write-After-Free Primitive

The write performed by Thread A on the freed memory originates from the following line:

1
hlist_del_rcu(&epi->fllink);

This macro ultimately expands into an operation that removes the node from the linked list:

1
2
3
// Inside hlist_del_rcu(): update the previous node's next pointer
// so that it points to my next node
*pprev = next;   // pprev = the location pointed to by &epi->fllink.pprev

Here, the location pointed to by epi->fllink.pprev resides inside the struct eventpoll of the ep_target that was just freed.

Therefore, this operation effectively writes a pointer-sized (8-byte) value into slab memory that has already been freed and is waiting to be reclaimed.

In short, the primitive we obtain from this vulnerability is:

An 8-byte write-after-free into a freed struct eventpoll object occupying a slot in the kmalloc slab.

We can then turn this primitive into an exploitation chain:

  1. Arrange four epoll objects into two pairs — one pair drives the race, while the other pair serves as the victim.
  2. Cross-cache heap reuse replaces the freed slot with an attacker-controlled object, turning the 8-byte write into a UAF against a struct file.
  3. Back the hijacked struct file with a pipe, then use /proc/self/fdinfo to obtain an arbitrary kernel memory read primitive.
  4. Use the leaked kernel addresses to hijack control flow → ROP → root.

Step 2 will be the focus of Part 2, while Steps 3–4 will be covered in Part 3.

Reference — How Was This Bug Patched?

To wrap things up, let’s briefly look at how upstream fixed this race.

The fix commit (a6dc643c6931, 2026-04-24) fundamentally takes the approach of pinning the target file before operating on it.

Before touching file->f_lock, the code obtains an additional reference to the file using epi_fget() and holds a guarded struct file *file __free(fput).

This ensures that the target file’s refcount cannot drop to zero during the cleanup operation. As a result, Path B cannot reach __fput() and free the object before Path A has finished operating on it.

In other words:

“Acquire the reference first, then take the lock.”

The fix eliminates the premise of the race itself, rather than merely trying to handle the UAF after it occurs.


Part 1 Summary

  • Bad Epoll (CVE-2026-46242) is the second of two races introduced by a single commit (58c9b016e128) — the bug that Mythos missed.
  • epoll represents monitoring relationships using struct eventpoll + struct epitem, with nested epoll, where one epoll instance monitors another, providing the setting for the race.
  • When ep_waiter and ep_target are closed concurrently, the cleanup path (Thread A) races with the file-release path (Thread B).
  • Immediately after Path A executes WRITE_ONCE(f_ep, NULL), Thread B’s lockless READ_ONCE(f_ep) == NULL causes it to skip cleanup and free the object, while Thread A subsequently writes to the freed memory via hlist_del_rcuUAF.
  • The race window is only about six instructions wide, but can be widened using a timer interrupt, achieving roughly ~99% reliability.
  • The resulting primitive is an 8-byte write-after-free against a freed struct eventpoll. In Part 2, we amplify this primitive using cross-cache heap reuse.

Preview of Part 2 — How Does an 8-Byte Write Lead to Control of struct file?

From the internals of the SLUB allocator to the mechanics of cross-cache attacks, Part 2 covers the background needed to turn this primitive into an actual exploitation weapon.

This post is licensed under CC BY 4.0 by the author.