Blog

Bad Epoll (CVE-2026-46242): six instructions of race — and any user becomes root

CVE-2026-46242_bad-epoll
CVE / Linux / Security

Bad Epoll (CVE-2026-46242): six instructions of race — and any user becomes root

An ordinary user with zero privileges opens a terminal on your server, runs an exploit, and gets root within a couple of seconds. Not through a web app hole, not through a leaked password — through epoll, the Linux kernel mechanism that literally everything runs on: nginx, systemd, any async service, browsers, Android. You can’t turn it off. This isn’t hypothetical: it got assigned CVE-2026-46242 on May 30, 2026, and on July 1 researcher Jaeyoung Chung published a working exploit with roughly 99% reliability on a 6.12.67 test kernel.

On the kernel.org and NVD scale, the vulnerability scores CVSS 3.1 — 7.8 (High), vector AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H. Here’s what that means in practice: local attack vector, low complexity, minimal privileges required, no user interaction needed, and full compromise of confidentiality, integrity, and availability. In plain terms: an ordinary local shell, and after a few attempts, root. CWE-416 — use-after-free, a classic. Red Hat rates it slightly more conservatively at 7.0, fairly noting the race window is tiny — but a 99% exploit success rate speaks for itself.

The PoC is already public, sitting on GitHub from the researcher who found it. It’s not on CISA’s KEV list as of publication, and there’s no confirmed exploitation in the wild yet — but the patch sat quietly for two months without any emergency security announcement before the backport caught up, and that’s exactly the kind of calm that’s deceptive.

WHAT IS EPOLL

epoll is how a Linux process efficiently watches many file descriptors at once — sockets, pipes, other epoll instances. Instead of polling each descriptor one by one, the process registers all of them in a single epoll object and gets back, in one syscall, the list of those ready for reading or writing. This is the mechanism that nginx, Node.js, Python’s asyncio, and Android’s event loop all depend on for performance — basically any service handling many connections at the same time.

Inside the kernel, each epoll instance is a struct eventpoll, and every descriptor it watches is represented by a struct epitem. The special case is when an epoll instance watches another epoll instance: you end up with a linked chain of objects, where closing one has to cleanly detach it from the other. That cleanup is exactly where the hole was found.

HOW THE BUG WORKS

The problem sits in ep_remove(), or more precisely in ep_remove_file(). The function is supposed to tear down that link safely: clear file->f_ep under the protection of file->f_lock, then do the rest of the work. But the code kept using the @file variable inside the critical section even after f_ep had already been cleared — it called is_file_epoll(), then hlist_del_rcu() on the pointer to the list’s head element, and only then spin_unlock().

A concurrent call to __fput() — the final release of a file object once its reference count hits zero — can slip into that window. If __fput() takes the fast path through eventpoll_release() at exactly that moment, it sees the already-cleared f_ep, decides epoll cleanup isn’t needed, skips eventpoll_release_file(), and runs straight to f_op->release and then file_free().

For the epoll-watches-epoll case, f_op->release is ep_eventpoll_release(), which calls ep_clear_and_put(), which in turn calls ep_free(). That call chain frees the watched eventpoll structure itself via kfree(). Its embedded hlist_head, named ->refs, sits exactly where epi->fllink.pprev points — the pointer to the previous element in the linked list. When hlist_del_rcu() then runs, its *pprev = next operation writes into memory that’s already been freed from the kmalloc-192 slab cache.

There’s a second half to this problem. struct file is marked SLAB_TYPESAFE_BY_RCU, which means the memory slot backing it can be immediately recycled by alloc_empty_file() for a completely different file — reinitializing f_lock and f_ep — while ep_remove() still formally believes it holds a lock on the old object. The end result is an attacker-controllable kmem_cache_free() call against the wrong slab cache.

HOW IT IS EXPLOITED

The exploit uses four epoll objects grouped into two pairs. One pair triggers the race itself, the other becomes the victim of the memory corruption. The 8-byte use-after-free write first turns into control over a struct file via a cross-cache attack — a technique where the attacker forces the kernel allocator to recycle freed memory into exactly the object they need.

Once it controls the contents of the file object, the exploit reaches an arbitrary kernel memory read through /proc/self/fdinfo — a dangling struct file backed by a pipe lets it pull kernel addresses straight out of that pseudo-file’s output. From there it’s a control-flow hijack and a ROP chain (return-oriented programming: the attacker stitches together existing kernel code fragments into the sequence they need), which hands over a root shell.

The race window itself is about six machine instructions wide. A normal attempt almost never hits it. The exploit solves this with a timer interrupt: it deliberately arranges load so the timer fires right in the middle of the vulnerable code’s execution window, and retries in a loop that never crashes the kernel on a miss. That mechanism is what produces the claimed 99% reliability on the lts-6.12.67 target and 98% on cos-121-18867.294.100 in Google’s kernelCTF program.

WHY THIS BUG STANDS OUT

Out of roughly 130 vulnerabilities ever exploited under kernelCTF, only about ten are candidates for rooting Android. Bad Epoll is one of them. Most of the high-profile Linux LPEs of the past few months, like Copy Fail, need modules that Android simply never loads. Here the constraints are different: the bug lives in the epoll core itself, not in some optional module, and it can be triggered even from inside Chrome’s renderer sandbox, which blocks almost every other kernel vulnerability. That opens up a theoretical chain: a browser renderer exploit plus Bad Epoll gets you kernel-level code execution — exactly the scenario Google Project Zero described in its writeup on going from Chrome renderer code exec to the kernel via MSG_OOB.

Copy Fail and its variants have an easy temporary fix — unload the vulnerable module. Bad Epoll doesn’t: epoll can’t be switched off, it’s a core kernel feature that the operating system, network services, and browsers all depend on. The only thing that actually works is applying the patch.

TIMELINE

On April 8, 2023, commit 58c9b016e128 introduced not one but two separate race conditions into the epoll code, in a section of roughly 2,500 lines. Both would later turn out to be critical privilege-escalation vulnerabilities.

On February 17, 2026, Chung’s team reported the bug to [email protected]. The same day, maintainers proposed a patch prototype — but it turned out to be an incomplete fix, and the discussion stalled.

On April 2, 2026, a fix for the first of the two bugs landed in mainline — the one Anthropic’s Mythos found, now tracked as CVE-2026-43074. Mythos’s finding is notable in its own right: kernel race conditions are one of the hardest bug classes to spot, and a frontier model finding one in this code region says something real about the progress of automated security analysis.

On April 22, 2026, Chung’s team re-reported the remaining issue — the one that would become Bad Epoll. On April 24, 2026, the fix for it finally landed in mainline as commit a6dc643c6931. Then on July 1, 2026, the full technical writeup dropped with a working PoC — meaning more than two months passed between the quiet patch and the public disclosure.

WHY THE AI MISSED THIS BUG

A single 2023 commit planted both race conditions in epoll. Mythos, examining this code region, found the first one and missed the second — even though it’s reasonable to assume the model looked at the same code with meaningful depth, given that it caught the neighboring bug. The exact reason for the miss can’t be known for certain, but researchers point to two likely factors.

First, the race window is extremely narrow — about six instructions — and imagining the precise thread interleaving is hard even when looking directly at the vulnerable code. Second, once the first bug (CVE-2026-43074) was fixed, the second bug’s use-after-free usually stopped triggering KASAN, the kernel’s primary memory-error detector. Without that signal, the model probably didn’t have enough confidence to flag it as a genuine vulnerability.

It’s also telling that the bug was hard not just to find but to fix: the maintainers’ first patch didn’t fully resolve the issue, and the correct fix only landed two months after the initial report — an unusually long time for a kernel that normally treats security issues with urgency.

WHY IT MATTERS

Bad Epoll hits exactly the infrastructure where local isolation is the entire product: shared hosting, multi-tenant hosts, CI/CD runners, container nodes running untrusted code. Any user with ordinary shell access on a server like that is a potential root. And because epoll is used so universally, there’s no way to simply disable this attack surface: local privilege escalation stops being a theoretical risk here and becomes a boundary someone can actually cross.

For hosting providers, this is a direct threat to keeping clients isolated from each other on shared hardware. For teams running Kubernetes clusters and CI runners, it’s the risk that a compromised sub-container or an untrusted pipeline script gets root on the node and access to every other workload on it. For owners of ordinary VPS or dedicated servers, the situation is only slightly calmer in one sense: if you have no untrusted local users, the attack vector requires that someone has already obtained at least an unprivileged shell — say, through a web application vulnerability. But that’s exactly why Bad Epoll is the second step that turns any initial access into a full server compromise.

UPDATE

The official fix is upstream commit a6dc643c69311677c574a0f17a3f4d66a5f3744b, merged into the main kernel tree. For distributions, the fix is available as a backport in the stable branches: 5.15.209 and later in the 5.15 line, 6.1.175 and later in 6.1, 6.18.33 and later in 6.18, 7.0.10 and later in 7.0, and fully in 7.1 (the commit was already present as of 7.1-rc1). Start by checking which kernel version you’re actually running right now — not what’s installed, what’s loaded in memory:

uname -r

A bare version number isn’t enough, because distributions backport fixes into their own version numbering. The reliable way to check whether this specific fix made it into your installed kernel package is to read the package’s own changelog and search it for the CVE number. On Debian and Ubuntu, that looks like this:

apt-get changelog linux-image-$(uname -r) | grep -i "CVE-2026-46242"

If the command returns a line mentioning CVE-2026-46242, the patch is already in your installed kernel package, and all that’s left is rebooting so it actually takes effect. If the line is empty, the kernel doesn’t have the fix yet, and you need to update the package via apt update && apt upgrade, wait for the new kernel to install, and reboot. On RHEL, AlmaLinux, Rocky Linux, and other RPM-based systems, the same check runs through rpm:

rpm -q --changelog kernel | grep -i "CVE-2026-46242"

The logic is identical: an empty result means the fix isn’t there yet, and you need to update via dnf update kernel (or yum update kernel on older systems), then reboot the host.

It’s also worth checking your specific distribution’s security tracker rather than relying on the local changelog alone: Debian, for instance, marks the bookworm and bullseye branches as flat-out not-affected — the vulnerable code simply isn’t there, since the bug was only introduced starting with the 6.4 kernel branch. Newer Debian branches and other distributions on current kernels need to be checked separately.

After installing the new kernel package, a reboot is mandatory — the live kernel in memory keeps running the vulnerable code until it’s restarted, and simply updating the package without rebooting changes nothing for a system that’s already running. To confirm you’re actually booted into the new kernel, run uname -r again and check the version number against what you expected after the update.

There’s no temporary workaround inside epoll itself — the mechanism can’t be disabled or restricted through configuration. If a reboot genuinely isn’t possible right now, the only real way to reduce exposure in the meantime is to shrink the number of untrusted local users and processes capable of running arbitrary code on the host: remove unnecessary shell access, tighten isolation for CI runners and containers, and move genuinely untrusted workloads onto dedicated, closely monitored nodes. This doesn’t fix the vulnerability — it temporarily narrows who can reach it.

If rebooting a production host is a problem in principle, live-patching services can close holes like this without a restart. On Ubuntu, that’s canonical-livepatch — after installing the client (sudo snap install canonical-livepatch and activating your token with sudo canonical-livepatch enable <your-token>), you check patching status with:

sudo canonical-livepatch status --verbose

In the output, the patch state: line should read all applicable livepatch kernel modules applied — meaning every live-patch available for your kernel has already been applied. If you see kernel state: ✗ kernel not supported by Canonical, or a note that your kernel has aged out of Livepatch’s coverage window, that kernel isn’t covered yet, and you need a full package update plus reboot. For RHEL-compatible systems, Red Hat kpatch plays the equivalent role; for other distributions, there’s KernelCare or SUSE Live Patching. Either way, before you rely on live-patching as protection, confirm explicitly with the vendor that the patch for CVE-2026-46242 specifically has shipped to your feed — not just for the related CVE-2026-43074. Fixing one use-after-free in this code region doesn’t guarantee the second one is automatically closed, and live-patching CVE-coverage reports sometimes lag behind the actual state of the feed.

CONCLUSIONS

If you run shared hosting or multi-tenant virtual machines, check your kernel version right now and cross-reference it with your distribution’s security tracker; for environments like this, this should sit at the same patching priority as critical RCEs, because the isolation boundary between your clients literally rests on this code.

If you manage a Kubernetes cluster or CI/CD infrastructure with runners executing untrusted code, updating node kernels should happen before your next deployment cycle, not on the regular patch schedule — this is exactly the kind of vulnerability that turns a compromised pod into root on the entire node.

If you run an ordinary dedicated server or VPS with no untrusted local users, update at your first routine opportunity, but don’t panic: the attack vector requires local access you’d already have to grant, so Bad Epoll here is more a second line of defense worth closing than an incident actively on fire.

And in every case — don’t trust a bare kernel version number. Check your specific distribution’s security tracker: backport policies and branch statuses vary, and what looks like an old vulnerable version might already be patched, while a version that looks recent might still carry the hole.

Leave your thought here

Your email address will not be published. Required fields are marked *