Files

57 KiB
Raw Permalink Blame History

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:<pad>:/:/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.

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 <profile>//&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 <profile>, 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 <user> 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
  2. CVE-2026-31431 — Copy Fail
  3. CVE-2026-43284 — Dirty Frag (xfrm-ESP)
  4. CVE-2026-43500 — Dirty Frag (RxRPC)
  5. Build
  6. Usage
  7. How DIRTYFAIL detects each CVE
  8. How DIRTYFAIL exploits each CVE
  9. Mitigations
  10. Ethics & disclosure
  11. Credits

Companion docs:


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: https://copy.fail/
  • Original PoC (C): Smarttfoxx/copyfail
  • Original PoC (Python): 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:

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 10009999) 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 <user> 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
  • Researcher: Hyunwoo Kim (@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:

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)
  • 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:

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 privilegesadd_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: https://github.com/V4bel/dirtyfrag.


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.
  • 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 <linux/xfrm.h>, <linux/netlink.h>, <linux/rtnetlink.h>, <linux/if.h>.
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

git clone https://github.com/<you>/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:

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):

./dirtyfail --scan

Active sentinel probe per CVE (~10s, modifies /tmp sentinels only):

./dirtyfail --scan --active

JSON for SIEM/fleet ingestion:

$ ./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)

./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

./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:

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<n>", v1_token{session_key=K})
     udp_srv = bind 127.0.0.1:port_S
     rxsk    = AF_RXRPC + SECURITY_KEY=df-evil<n> + 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 <mode>_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 <mode>_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:
    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:
    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:
    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:
    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)

sudo sh -c '
cat > /etc/modprobe.d/dirtyfail.conf <<EOF
install algif_aead /bin/false
install esp4 /bin/false
install esp6 /bin/false
install rxrpc /bin/false
EOF
rmmod algif_aead esp4 esp6 rxrpc 2>/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:

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:
    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) — 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:<pad>:/:/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 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
https://copy.fail/ Anonymous Original Copy Fail disclosure
https://github.com/Smarttfoxx/copyfail Smarttfoxx C PoC (shellcode-in-su variant)
https://github.com/rootsecdev/cve_2026_31431 rootsecdev Python detector + UID-flip PoC; the ergonomics of DIRTYFAIL's --exploit-copyfail mode follow this approach.
https://github.com/V4bel/dirtyfrag Hyunwoo Kim (@v4bel) Dirty Frag discovery, full chain PoC, kernel patches
https://github.com/0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo 0xdeadbeefnetwork GCM-variant exploit, IPv6 PoC, AppArmor userns bypass technique
https://www.bleepingcomputer.com/news/security/new-linux-dirty-frag-zero-day-with-poc-exploit-gives-root-privileges/ 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.


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.