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:
- Resolve the kernel image offset with the prefetch side channel unless
--nokaslror--image-offsetis supplied. - Repeatedly race
close(parent_ep)againstepoll_ctl(shared_ep, EPOLL_CTL_ADD, leaf_ep, ...). Reclaim the freed parentstruct eventpollwith sprayed user-key objects and make the kernel interpret reclaimed memory as fake epoll graph metadata. - Use the resulting controlled traversal to clear selected kernel variables and to force oopses that leak registers from the kernel log.
- Convert the register leaks into a leak of the next
struct credallocation. - Use the same primitive again to zero
cred->euidand nearby credential fields. - Pre-fork many helper processes so the final overwrite has many candidate
credobjects around the predicted target page to hit, then race oncore_patternonce 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.handexploit/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
npermspray that places the payload in a predictable kernel-reachable location is wrapped inexploit/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, andcore_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)afterparent_ephas already been linked toshared_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-addrcan 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
eventpollobject from the right cache; nperm: place a fake chain of epoll-controlled words at a predictable kernel address so the reclaimedeventpollcan 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->refsfrom the reclaimed object; - that value is treated as
&epitem->fllink; - the kernel reconstructs the containing
struct epitemby subtracting0x50; - the resulting fake
epi->epis fed back intoep_get_upwards_depth_proc().
The important offsets are:
eventpoll.refsoffset:0xa0;epitem.epoffset:0x48;epitem.fllinkoffset: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_tintogen.
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_patternstring template; - load layout values from the target database;
- resolve
image_offsetwith prefetch sidechannel attack; - create
EpollRatcControllerandNpermStageRunner; - 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
eventpollcan be reclaimed with pointers into the futurenpermchain; - 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:
nperm_stage_runner->run_stage(0, 0);epoll_ratc->run_phase("stage1", ...);klog_reader.read_oops_registers()forGS.
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, andprintk_console_no_auto_verbosestate, mainly to suppress console output for speed and also because later clears are constrained byrefs == 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)churnscred_jar;flag_readers.cleanup()kills old helper groups from the previous attempt.
Then leak_cred_rbx_until_valid() runs:
nperm_stage_runner->run_stage(0, leaked_gs);epoll_ratc->run_phase("stage1.5", ...);klog_reader.read_oops_registers()forRBX.
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
0x1cto0x30: also cleared because this primitive requiresrefs == 0, so hitting one target field often first requires clearing the correspondingrefs-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
666and prints the flag; - shell mode uses file descriptor marker
665and 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
/flagto/dev/ttyS0, or - duplicates the parent TTY file descriptors and spawns
/bin/bash -i.
Important constants and why they matter
Layout-derived offsets
eventpoll.refsoffset (0xa0): tells the exploit where to place the fake reverse-reference head inside reclaimedstruct eventpollmemory.epitem.fllinkoffset (0x50): used to convert anhlist_nodepointer back into the containing fakestruct epitemand to reconstruct leakedstruct credpointers fromRBX.pcpu_cred_jar_offset(0x3b6d0): fixed per-CPU offset from the leakedGSbase to thecred_jar->cpu_slabpointer 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
168bytes: chosen soadd_key()allocations reclaim the same slab size class as the freed parentstruct eventpollon the target. kSprayTotal = 103: number of key objects per round; the comment inepoll_phase.hexplains 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, andprintk_console_no_auto_verbose: adjusted to suppress console output for speed and to zero the nearby words that laterrefs == 0constrained 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:
- both threads prepare their side;
- the parent closes
parent_epwhile the adder beginsepoll_ctl(... ADD ...); - 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 thecred_jarfreelist 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 rewritecore_pattern.
Environmental requirements
CONFIG_EPOLL=yis 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/iomemand switches to the unmovablenpermspray path.
Success rate
In GitHub Actions testing, the exploit succeeded 98 times out of 100 runs
on lts-6.12.82.