Exploit details for CVE-2026-43074

May 17, 2026 · View on GitHub

If you need the bug background first, read vulnerability.md.

Overview

This exploit is a data-only privilege-escalation chain for the eventpoll use-after-free in CVE-2026-43074 ("epollution"). It does not try to get direct RIP control. Instead, it reuses the freed struct eventpoll object to steer ep_get_upwards_depth_proc() into controlled memory, uses repeated oopses as an information leak, and then uses the resulting write primitive to clear credential fields until one of the forked helper processes becomes root.

At a high level the chain is:

  1. Resolve the kernel image offset with the prefetch side channel unless --nokaslr or --image-offset is supplied.
  2. Repeatedly race close(parent_ep) against epoll_ctl(shared_ep, EPOLL_CTL_ADD, leaf_ep, ...). Reclaim the freed parent struct eventpoll with sprayed user-key objects and make the kernel interpret reclaimed memory as fake epoll graph metadata.
  3. Use the resulting controlled traversal to clear selected kernel variables and to force oopses that leak registers from the kernel log.
  4. Convert the register leaks into a leak of the next struct cred allocation.
  5. Use the same primitive again to zero cred->euid and nearby credential fields.
  6. Pre-fork many helper processes so the final overwrite has many candidate cred objects around the predicted target page to hit, then race on core_pattern once any one of those helpers becomes privileged.

In GitHub Actions testing, the submission succeeded 98 times out of 100 runs on the lts-6.12.82 target.

Code map

The implementation is already split by responsibility:

  • Triggering and timing the race lives in exploit/lts-6.12.82/modules/epoll_phase.h and exploit/lts-6.12.82/modules/epoll_ratc.cpp.
  • KASLR leakage lives in exploit/lts-6.12.82/modules/kaslr_prefetch.cpp.
  • Kernel-log parsing for oops register leaks lives in exploit/lts-6.12.82/modules/klog_reader.cpp.
  • Primitive construction and target selection live in exploit/lts-6.12.82/modules/payload.cpp.
  • The large nperm spray that places the payload in a predictable kernel-reachable location is wrapped in exploit/lts-6.12.82/modules/nperm.cpp.
  • The top-level orchestration, helper-process logic, and Step 0-Step 7 comments live in exploit/lts-6.12.82/exploit.cpp.

Techniques and primitives

1. KASLR resolution by prefetch side channel

Before the exploit starts using any payload addresses, KaslrOffsetResolver derives the kernel image offset unless --nokaslr or --image-offset is supplied.

This is not a heap leak. It is a side channel used so the exploit can turn built-in symbol offsets and the configured nperm_addr into runtime kernel addresses.

That offset is later used for both:

  • global kernel targets such as panic_on_oops, epnested_mutex, dmesg_restrict, console_printk, printk_console_no_auto_verbose, and core_pipe_limit;
  • the runtime address of the fake epoll payload staged by nperm.

2. UAF trigger and same-cache reclaim spray

The bug is triggered with three epoll objects:

  • parent_ep: the ancestor epoll instance that becomes the freed object;
  • shared_ep: the shared epoll instance whose reverse-reference list is walked;
  • leaf_ep: child epoll instances repeatedly added and removed.

Conceptually the graph is:

parent_ep -> shared_ep -> leaf_ep

The race is between:

  • parent thread on CPU 0: close(parent_ep) after parent_ep has already been linked to shared_ep;
  • adder thread on CPU 1: epoll_ctl(shared_ep, EPOLL_CTL_ADD, leaf_ep, &ev).

The important data structures are:

  • struct eventpoll::refs: reverse-reference list head;
  • struct epitem::fllink: list node used during the upward traversal.

One thread can still be traversing shared_ep->refs in ep_get_upwards_depth_proc() while the other thread runs __ep_remove(), unlinks the parent edge with hlist_del_rcu(&epi->fllink), drops the final reference, and frees the parent struct eventpoll with kfree().

After that free, the exploit immediately reclaims the same object with sprayed user-key payloads created by add_key("user", ...). This spray is chosen because, on the target kernel, the freed parent_ep and the user-key payload allocation land in the same slab size class, kmalloc-192.

This spray is not the payload itself. Its job is only to refill the freed struct eventpoll with attacker-controlled words before the stale pointer is used again.

3. nperm payload placement

The actual fake traversal payload is produced by build_nperm_payload() in payload.cpp and placed by NpermStageRunner in nperm.cpp.

The concrete contract visible in this repository is:

  • the payload base is options.layout.nperm_addr;
  • the runtime payload address is computed as image_offset + nperm_addr + offset;
  • --nperm-addr can override the layout-provided base.

That is why the initial reclaim spray fills the freed eventpoll with:

final_addr = nperm_addr + image_offset + 0x10

The point of the nperm stage is therefore different from the user-key spray:

  • user-key spray: reclaim the freed eventpoll object from the right cache;
  • nperm: place a fake chain of epoll-controlled words at a predictable kernel address so the reclaimed eventpoll can point into it.

This exploit uses NPerm v2 for that payload placement step. Compared with the original NPerm from CVE-2025-38477_cos/docs/novel-techniques.md, it drops the userns+pgv dependency and handles both the <4G and >4G image-placement cases. The rationale and measurements are summarized in novel-techniques.md.

4. Converting the UAF into a traversal primitive

The core primitive comes from how nested epoll traversal consumes reverse references:

static int ep_get_upwards_depth_proc(struct eventpoll *ep, int depth)
{
    int result = 0;
    struct epitem *epi;

    if (ep->gen == loop_check_gen)
            return ep->loop_check_depth;
    hlist_for_each_entry_rcu(epi, &ep->refs, fllink)
            result = max(result, ep_get_upwards_depth_proc(epi->ep, depth + 1) + 1);
    ep->gen = loop_check_gen;
    ep->loop_check_depth = result;
    return result;
}

Once the freed parent_ep has been reclaimed with attacker-controlled words, the stale object is no longer interpreted as a real struct eventpoll. Instead:

  • the kernel reads eventpoll->refs from the reclaimed object;
  • that value is treated as &epitem->fllink;
  • the kernel reconstructs the containing struct epitem by subtracting 0x50;
  • the resulting fake epi->ep is fed back into ep_get_upwards_depth_proc().

The important offsets are:

  • eventpoll.refs offset: 0xa0;
  • epitem.ep offset: 0x48;
  • epitem.fllink offset: 0x50.

The payload layout used by setup_spray_payload() is therefore:

final_addr = nperm_addr + image_offset + 0x10

ffffffff85141000  0000000000000000  [payload 1]
ffffffff85141010  ffffffff85141020  [payload 2]
ffffffff85141020  ffffffff85141030  [payload 3]
ffffffff85141030  ffffffff85141040  [payload 4]
ffffffff85141040  0000000000000000  0000000000000000

When ep->refs is interpreted as pointing at ffffffff85141010, the kernel treats that word as epi->fllink, reconstructs the fake epitem base at ffffffff85141010 - 0x50, and reads fake_epi->ep from the preceding controlled qword.

This gives a compact chained payload where each controlled word can become the next recursive ep argument.

5. Two useful outcomes from the traversal

The exploit uses the same traversal machinery in two different modes.

5A. Traversal termination: constrained zero-write primitive

If the forged ep->refs is zero, the traversal stops and returns. In that case the kernel still updates fields in the interpreted struct eventpoll, notably:

  • a one-byte zero into loop_check_depth;
  • a small uint64_t into gen.

This is the constrained write primitive used to clear selected kernel values and, later, credential fields.

5B. Faulting traversal: oops-based register leak primitive

If the derived epi->ep is invalid, the kernel triggers an oops and leaks register state. Crucially, the fake epitem is reconstructed as:

epitem = ep->refs - 0x50

which means the kernel effectively dereferences attacker-controlled data at:

*(ep + 0xa0)

to obtain ep->refs, and then walks backward to form the fake epitem. As a result, RBX ends up containing this reconstructed epitem base, giving a direct leak of a pointer derived from controlled memory. This is exactly the primitive used by payload.cpp in the credential leak stage.

This is how the exploit turns the UAF into an information leak without ever taking direct control of instruction flow.

6. Oops log parsing

The deliberate faults only become useful because KernelLogReader extracts specific registers from the fresh oops log:

  • stage 1 reads GS;
  • stage 1.5 reads RBX.

The first payload stage also clears panic_on_oops and dmesg_restrict, so the machine survives the deliberate crash and the oops output is readable.

7. RATC timing helper

When --use-ratc is enabled, the exploit arms a timerfd-based timing helper before the first add operation of each round and optionally adapts the timeout window over time. This is purely a reliability aid for lining up the close-vs-add overlap; it is not required to understand the primitive itself. The RATC idea is not ours; see Project Zero's "Racing against the clock: hitting a tiny kernel race window" (https://projectzero.google/2022/03/racing-against-clock-hitting-tiny.html) for the general technique and timing rationale.

8. Cred leak primitive

The value stored in cred_jar->cpu_slab is effectively fixed—it represents a stable per-CPU offset due to the deterministic nature of per-CPU allocations. After stage 1 leaks the CPU-1 GS base, the payload combines this fixed offset with cpu1_gs_base to resolve the correct runtime address of cred_jar->cpu_slab.

ep is chosen so that, using the faulting traversal primitive described in 5B, the kernel evaluates *cred_jar->cpu_slab. This is equivalent to dereferencing the per-CPU freelist pointer, i.e. *(void **)freelist, which yields the next struct cred object to be allocated.

Step-by-step flow in exploit.cpp

The following section matches the comments and call order in main().

Step 1: initialization, layout resolution, KASLR, and initial reclaim payload

initialize_exploit() performs the setup that the rest of the exploit depends on:

  • pin the main thread to CPU 1 so the leaked GS base matches later operations;
  • parse runtime options;
  • set up the coredump helper fd and core_pattern string template;
  • load layout values from the target database;
  • resolve image_offset with prefetch sidechannel attack;
  • create EpollRatcController and NpermStageRunner;
  • build the initial reclaim payload with setup_spray_payload().

That last step is where the exploit deliberately ties the reclaimed eventpoll to the future nperm payload address:

final_addr = nperm_addr + image_offset + 0x10

So after this step:

  • the stale eventpoll can be reclaimed with pointers into the future nperm chain;
  • the exploit knows how to compute runtime addresses for both kernel symbols and payload words.

Step 2: first nperm stage plus stage1 epoll race to leak GS

leak_gs_base() runs:

  1. nperm_stage_runner->run_stage(0, 0);
  2. epoll_ratc->run_phase("stage1", ...);
  3. klog_reader.read_oops_registers() for GS.

The stage-1 payload is the setup payload, not the credential payload. It clears or relaxes global state so deliberate oopses become usable:

  • set panic_on_oops = 0
  • clear epnested_mutex
  • set dmesg_restrict = 0
  • set console_printk = 0
  • set printk_console_no_auto_verbose = 1

The payload clears or relaxes:

  • panic_on_oops, so the machine does not immediately die on the deliberate crash.
  • epnested_mutex, so the exploit can retry the nested-epoll race multiple times.
  • dmesg_restrict, because without clearing it the later oops logs are not readable.
  • nearby types__syslog, printk_time, console_printk, console_owner, and printk_console_no_auto_verbose state, mainly to suppress console output for speed and also because later clears are constrained by refs == 0, so some surrounding words must be zeroed first to make the intended target reachable without breaking the walk too early.

epnested_mutex is worth calling out separately because it is a global lock for the "epoll inside epoll" add path. The relevant control flow in do_epoll_ctl() looks like:

if (op == EPOLL_CTL_ADD) {
    if (READ_ONCE(fd_file(f)->f_ep) || ep->gen == loop_check_gen ||
        is_file_epoll(fd_file(tf))) {
        mutex_unlock(&ep->mtx);
        error = epoll_mutex_lock(&epnested_mutex, 0, nonblock);
        ...
        error = epoll_mutex_lock(&ep->mtx, 0, nonblock);
    }
}
...
if (full_check)
    mutex_unlock(&epnested_mutex);

When we deliberately oops in the middle of that path, normal unwinding does not reach the final mutex_unlock(&epnested_mutex). Per-object locks can often be sidestepped by creating fresh epoll objects, but this one is global: every nested-epoll add goes through the same epnested_mutex. So if we do not clear it back to the unlocked state, later race attempts stop here.

After that, the stage-1 epoll race intentionally faults and KernelLogReader extracts the CPU-1 GS base from the oops log.

Step 3: per-iteration cred_jar grooming and stage1.5 cred leak

The main loop starts by grooming the next cred allocation and cleaning up old helper processes:

  • spawn_and_release_children(5) churns cred_jar;
  • flag_readers.cleanup() kills old helper groups from the previous attempt.

Then leak_cred_rbx_until_valid() runs:

  1. nperm_stage_runner->run_stage(0, leaked_gs);
  2. epoll_ratc->run_phase("stage1.5", ...);
  3. klog_reader.read_oops_registers() for RBX.

This is the stage that turns the GS leak into a leak of the next struct cred candidate.

If the recovered RBX does not look like a plausible heap pointer, the code retries and even forks sacrificial processes to skip bad cred candidates.

Step 4: Spawn helper processes around the predicted cred allocation

Once a plausible cred candidate has been leaked, spawn_flag_readers() forks many helper processes.

These helpers attempt to reclaim cred objects near the recently leaked freelist candidate. They then repeatedly try to write to core_pattern. We fork them before the final overwrite so they can occupy many nearby cred slots first. Then the overwrite does not need to hit one exact object; it only needs to hit any one of those helper credentials.

The leaked freelist entry only predicts the next struct cred allocation well enough to narrow the search to one slab page. It does not guarantee that the final write lands on one exact object. Forking gives the exploit many helper processes and therefore many candidate credentials to hit around that predicted target page.

This logic is not part of the bug trigger. It is post-exploitation orchestration designed to turn "one of these nearby helper creds became root" into a reliable flag read or interactive shell.

Step 5: rebuild the payload for the leaked struct cred and do epoll race to clear credential fields

After the helpers are in place, main() runs:

nperm_stage_runner->run_stage(state.leaked_rbx, 0)

This switches build_nperm_payload() into its final credential-targeting mode.

In step 5, the goal is to overwrite cred->euid to 0. However, the exploit does not target only the specific cred object predicted from the freelist. Since that freelist entry may be reclaimed by another allocation, the payload also targets multiple other candidate cred objects within the same slab page.

Concretely, it sprays writes across several page-aligned offsets around the leaked address, covering other cred slots that are likely to reside in the same slab. This increases the chance of hitting a valid, in-use credential.

  • direct target based on the leaked cred pointer;
  • eight additional page offsets (0x0, 0x240, 0x3c0, 0x600, 0x6c0, 0xc00, 0xcc0, 0xf00) within the same cred slab page.

These extra offsets are not random noise. They are a reliability mechanism: the leaked freelist entry narrows the search, but does not always identify the exact cred. The exploit therefore keeps many helper candidates in play and then writes several candidate offsets, expecting that one of them will eventually land on one of those helper credentials.

The important victim fields are:

  • cred->euid: the primary privilege bit the exploit wants to clear.
  • nearby UID/GID and capability fields around offsets 0x1c to 0x30: also cleared because this primitive requires refs == 0, so hitting one target field often first requires clearing the corresponding refs-side words around it.

This stage is the kernel-memory overwrite step. The overwrite is still data-only: no control-flow object is corrupted.

Step 6: Post-exploitation via core_pattern

Once one helper process runs with an effective root credential, it rewrites core_pattern to point at a memfd-backed copy of the exploit binary:

  • fast mode uses file descriptor marker 666 and prints the flag;
  • shell mode uses file descriptor marker 665 and re-enters the binary as an interactive shell helper.

The exploit also sets core_pipe_limit to a non-zero value in the earlier payload so the coredump helper can access the relevant file descriptors.

When the helper crashes itself with SIGSEGV, the kernel invokes the coredump helper as root. The re-entered binary detects that it is running via /proc/<pid>/fd/<magic> and either:

  • copies /flag to /dev/ttyS0, or
  • duplicates the parent TTY file descriptors and spawns /bin/bash -i.

Important constants and why they matter

Layout-derived offsets

  • eventpoll.refs offset (0xa0): tells the exploit where to place the fake reverse-reference head inside reclaimed struct eventpoll memory.
  • epitem.fllink offset (0x50): used to convert an hlist_node pointer back into the containing fake struct epitem and to reconstruct leaked struct cred pointers from RBX.
  • pcpu_cred_jar_offset (0x3b6d0): fixed per-CPU offset from the leaked GS base to the cred_jar->cpu_slab pointer used in the second leak stage.

Payload sizing constants

  • NPERM_PAYLOAD_SIZE (0x4a0): size of the staged fake-node area used by the controlled traversal.
  • spray payload length 168 bytes: chosen so add_key() allocations reclaim the same slab size class as the freed parent struct eventpoll on the target.
  • kSprayTotal = 103: number of key objects per round; the comment in epoll_phase.h explains that this value is empirically stable and avoids waiting for delayed recycling.

Kernel symbols written by the exploit

These addresses are normally resolved via Kernel XDK, not hard-coded by the exploit logic:

  • panic_on_oops: cleared to keep the machine alive through deliberate faults.
  • epnested_mutex: unlocked so the nested epoll race can be repeated.
  • dmesg_restrict: cleared so kernel-log based leakage becomes readable at all.
  • nearby types__syslog, printk_time, console_printk, console_owner, and printk_console_no_auto_verbose: adjusted to suppress console output for speed and to zero the nearby words that later refs == 0 constrained clears must step through.
  • core_pipe_limit: set non-zero so the coredump helper can inherit and use file descriptors.

Multi-threading, forking, and synchronization

Why multiple threads are needed

The vulnerability is a race condition, so the exploit needs concurrent execution of two code paths:

  • parent thread: creates and closes the ancestor epoll;
  • adder thread: repeatedly adds leaf epolls to the shared epoll.

The two threads synchronize with a reusable barrier in epoll_phase.h. Each round has three synchronization points:

  1. both threads prepare their side;
  2. the parent closes parent_ep while the adder begins epoll_ctl(... ADD ...);
  3. both threads finish cleanup before the next round.

Why CPU affinity is needed

The main thread is pinned to CPU 1 in initialize_exploit(), and the adder side leaks the per-CPU GS base used later in stage 1.5.

This reduces scheduler noise and makes the leaked GS base usable for the later cred_jar->cpu_slab calculation. If the relevant thread migrated unpredictably, the leaked per-CPU address would not reliably match the later slab metadata.

Why forking is needed

Forking appears in two different roles:

  • spawn_and_release_children(5): groom the cred_jar freelist before the leak stage;
  • spawn_flag_readers(): create many helper processes so that the predicted cred slab page is populated with live candidates, then let whichever one gets hit race to rewrite core_pattern.

Environmental requirements

  • CONFIG_EPOLL=y is required.
  • No capabilities are required.
  • User namespaces are not required.
  • If the kernel image is mapped above the 4 GiB window the exploit checks for that via /proc/iomem and switches to the unmovable nperm spray path.

Success rate

In GitHub Actions testing, the exploit succeeded 98 times out of 100 runs on lts-6.12.82.