Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
|
||||
@@ -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
|
||||
@@ -186,16 +192,26 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS)
|
||||
|
||||
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 +223,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 +245,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,12 @@
|
||||
|
||||
[](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. 31 Linux LPE modules covering 26 CVEs from 2016 → 2026.
|
||||
> 22 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 +43,15 @@ 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):
|
||||
**31 modules covering 26 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **22 of the 26 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 4 still-pending entries are
|
||||
blocked by their target environment, 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 +64,29 @@ 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 (22 of 26 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) | af_packet · ptrace_traceme · sudo_samedit |
|
||||
| Ubuntu 20.04 (5.4 stock + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia |
|
||||
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10) | af_unix_gc · dirty_pipe · entrybleed · nf_tables · nft_set_uaf · overlayfs_setuid · stackrot · sudoedit_editor |
|
||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||
| Debian 12 (6.1 stock) | pack2theroot |
|
||||
|
||||
**Not yet verified (4):** `vmwgfx` (VMware-guest-only — no public
|
||||
Vagrant box), `dirty_cow` (needs ≤ 4.4 kernel — older than every
|
||||
supported box), `dirtydecrypt` & `fragnesia` (need Linux 7.0 — not
|
||||
shipping as any distro kernel yet). All four 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 +97,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
|
||||
|
||||
@@ -181,29 +197,37 @@ 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.6.0 cut 2026-05-23.** 31 modules across 26 CVEs, **22 empirically
|
||||
verified** against real Linux VMs (Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10 from
|
||||
kernel.ubuntu.com). 88-test unit harness on every push.
|
||||
|
||||
Reliability + accuracy work in v0.6.0:
|
||||
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
|
||||
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
|
||||
to every module via `ctx->host`. 26 of 27 distinct modules consume it.
|
||||
- **Test harness** (`tests/test_detect.c`, `make test`) — 44 unit
|
||||
tests over mocked host fingerprints; runs as a non-root user in CI.
|
||||
- `--auto` upgrades: auto-enables `--active`, per-detect 15s timeout,
|
||||
fork-isolated detect + exploit so a crashing module can't tear down
|
||||
the dispatcher, structured per-module verdict table, scan summary.
|
||||
- `--dry-run` flag (preview without firing; no `--i-know` needed).
|
||||
- Pinned mainline fix commits for the 3 ported modules — `detect()`
|
||||
is version-pinned, not just precondition-only.
|
||||
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. 22 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; 10 of 26 modules cover KEV-listed CVEs.
|
||||
- **119 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 (4 of 26 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `dirtydecrypt` + `fragnesia` (need
|
||||
Linux 7.0 — not shipping yet). Rationale in
|
||||
[`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 +238,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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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 = "",
|
||||
},
|
||||
};
|
||||
|
||||
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"
|
||||
|
||||
@@ -48,4 +48,11 @@ void skeletonkey_register_dirtydecrypt(void);
|
||||
void skeletonkey_register_fragnesia(void);
|
||||
void skeletonkey_register_pack2theroot(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,46 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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 = "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_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_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",
|
||||
},
|
||||
};
|
||||
|
||||
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,202 @@
|
||||
## 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,30 @@
|
||||
{"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"}
|
||||
+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. 31 Linux privilege-escalation modules from 2016 to 2026. 22 of 26 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 119 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="31 Linux LPE modules; 22 of 26 CVEs empirically verified in real VMs. 119 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.6.0 — released 2026-05-23
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>31 Linux LPE modules</strong> from 2016 to 2026.
|
||||
<strong>22 of 26 CVEs empirically verified</strong> 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="31">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="22">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="10">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="119">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>119 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>
|
||||
10 of 26 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>22 of 26 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.15.5 / 6.1.10. Records baked into the binary;
|
||||
<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>26 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>
|
||||
26 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>22 of 26 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>119 detection rules across all four SIEM formats</li>
|
||||
<li><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.6.0 · 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: 122 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="32" fill="#c5c5d3" font-weight="500">
|
||||
Curated Linux LPE corpus.
|
||||
</text>
|
||||
<text x="80" y="282" font-family="'Inter',sans-serif" font-size="32" fill="#c5c5d3" font-weight="500">
|
||||
22 of 26 CVEs verified in real Linux VMs.
|
||||
</text>
|
||||
|
||||
<!-- stat chips -->
|
||||
<g transform="translate(80,360)">
|
||||
<!-- 31 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">31</text>
|
||||
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||
|
||||
<!-- 22 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">22</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 10 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">10</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 119 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">119</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)
|
||||
|
||||
@@ -673,7 +673,8 @@ static int dd_active_probe(void)
|
||||
* or weird distro rebuilds the version check missed)
|
||||
*/
|
||||
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
};
|
||||
static const struct kernel_range dirtydecrypt_range = {
|
||||
.patched_from = dirtydecrypt_patched_branches,
|
||||
@@ -921,6 +922,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 +1003,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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.7.1"
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
@@ -62,6 +64,23 @@ extern const struct skeletonkey_module pwnkit_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 +108,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 +122,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
|
||||
@@ -486,6 +531,143 @@ 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
|
||||
|
||||
/* ── 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 +677,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
+299
@@ -0,0 +1,299 @@
|
||||
#!/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."""
|
||||
print(f"[*] fetching CISA KEV catalog ({KEV_URL})", file=sys.stderr)
|
||||
try:
|
||||
with urllib.request.urlopen(KEV_URL, timeout=30) as r:
|
||||
data = r.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[!] KEV fetch 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
+152
@@ -0,0 +1,152 @@
|
||||
# -*- 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; reboot via 'vagrant reload'"
|
||||
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?
|
||||
if uname -r | grep -q "^${KVER}-[0-9]\\+-generic"; then
|
||||
echo "[=] mainline ${KVER} already booted ($(uname -r))"
|
||||
exit 0
|
||||
fi
|
||||
# already installed on disk (waiting on reboot)?
|
||||
if ls /boot/vmlinuz-${KVER}-* >/dev/null 2>&1; then
|
||||
echo "[=] mainline ${KVER} already installed; needs reboot"
|
||||
exit 0
|
||||
fi
|
||||
echo "[+] fetching kernel.ubuntu.com mainline v${KVER}"
|
||||
URL="https://kernel.ubuntu.com/mainline/v${KVER}/amd64/"
|
||||
TMP=$(mktemp -d)
|
||||
cd "$TMP"
|
||||
# Pick the 4 canonical generic-kernel .debs by pattern match against
|
||||
# the directory index. Skip lowlatency variants.
|
||||
DEBS=$(curl -sL "$URL" | \\
|
||||
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 $URL — does the version exist on kernel.ubuntu.com?" >&2
|
||||
exit 2
|
||||
fi
|
||||
for f in $DEBS; do
|
||||
echo "[+] $f"
|
||||
curl -fsSL -O "${URL}${f}"
|
||||
done
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg -i *.deb || apt-get install -f -y -qq
|
||||
update-grub 2>&1 | tail -3
|
||||
echo "[i] mainline ${KVER} installed; reboot via 'vagrant reload'"
|
||||
SHELL
|
||||
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
+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,222 @@
|
||||
# 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: OK
|
||||
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: OK
|
||||
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: OK
|
||||
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: debian12
|
||||
kernel_pkg: "" # only Linux 7.0+ has the bug — needs custom kernel
|
||||
kernel_version: "7.0.0"
|
||||
expect_detect: OK
|
||||
notes: "CVE-2026-31635; bug introduced in 7.0 rxgk path. NO mainline 7.0 distro shipping yet — Debian 12 will report OK (predates the bug). Verifying exploit() needs a hand-built 7.0-rc kernel."
|
||||
manual_for_exploit_verify: true
|
||||
|
||||
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: debian12
|
||||
kernel_pkg: ""
|
||||
kernel_version: "7.0.0"
|
||||
expect_detect: OK
|
||||
notes: "CVE-2026-46300; XFRM ESP-in-TCP bug. Needs 7.0-rc; Debian 12 reports OK."
|
||||
manual_for_exploit_verify: true
|
||||
|
||||
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
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/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
|
||||
|
||||
# Reboot if any kernel pin was applied (uname -r != 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"
|
||||
sleep 5
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run the explain probe.
|
||||
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
|
||||
|
||||
echo "[*] running verifier..."
|
||||
vagrant provision "$VM_HOSTNAME" --provision-with build-and-verify 2>&1 | tee "$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