//nefariousplan

Dirty Frag: the patched half is the half Ubuntu already mitigated

patterns

cve

proof of concept

CVE-2026-43284 patches a branch added to esp_input in January 2017. The branch was correct in 2017. It was correct again in 2018. It was correct in 2020 and in 2022. The thing that broke it was added to a different file in 2023, and nobody who reviewed the 2023 change was reading esp_input.

The patch lives at the consumer end of the chain. It linearises a fragmented skb before letting crypto_authenc_esn write four bytes of recovered sequence-number state into a scatterlist destination. That is the entire fix. Four bytes, written in the wrong place, was a local privilege escalation against every distribution kernel from 6.12 through 6.19.

The CVE record is for the half of the bug that has a patch. The half that does not have a patch is the half that works on Ubuntu.

The store value is a netlink attribute

Most kernel write primitives have to bend the world to choose what gets written. Dirty Frag does not. The attacker registers an XFRM Security Association via xfrm_user netlink and supplies XFRMA_REPLAY_ESN_VAL. One field of that attribute, seq_hi, is the high 32 bits of the extended sequence number. The attacker types it.

The kernel will faithfully copy that 32-bit integer into the AEAD scratch buffer during crypto_authenc_esn_decrypt, then write it back to the destination scatterlist as part of an ESN reordering dance:

scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1)

If dst is a kernel page that backs a file in the page cache, those four bytes land in the page cache. The on-disk file is untouched. AIDE, tripwire, auditd, every inotify watcher in the world: nothing fires. The next process that reads the file gets the modified bytes from cache.

/usr/bin/su is a setuid binary. The kernel checks the setuid bit at execve time and trusts the page cache for the bytes that follow. V4bel's PoC patches a four-byte window inside su to make it skip the password prompt. The disk is clean. The exploit is a write that nothing on the system can see.

Two correct decisions made years apart

The interesting part is not the sink. The interesting part is the route the four bytes take to reach it.

In January 2017, commit cac2661c53f3 added a fast path to esp_input for non-ESN, non-fragmented skbs. The branch was guarded by the right invariants for 2017. Every IPsec receive path back then assembled its skb out of locally-allocated pages, and skb_has_shared_frag() reliably caught the cases where someone had pinned an external page into the skb's frag list. Linearisation was expensive, and skipping it when nothing was shared was a sensible optimisation.

In February 2023, commit 7c8a61d9ee1d introduced MSG_SPLICE_PAGES for UDP. The point of the change was to let userspace splice() or vmsplice() arbitrary pipe pages into a UDP socket without copying them. __ip_append_data and __ip6_append_data learned to attach those pages directly to the outgoing skb's frag array. The flag set on those frags is SKBFL_PURE_ZEROCOPY. The flag that is not set is SKBFL_SHARED_FRAG.

That asymmetry is the whole bug.

skb_has_shared_frag() returns false on a MSG_SPLICE_PAGES skb. The 2017 fast path in esp_input therefore declines to call skb_linearize. The skb proceeds into the AEAD pipeline with frags that point into a different process's pipe, which itself points at a page in the kernel page cache for /usr/bin/su. crypto_authenc_esn_decrypt walks the destination scatterlist, locates the four-byte ESN reordering window, and writes the attacker's seq_hi directly into the page cache.

Neither commit was wrong on its own. The 2017 commit was guarded for the world it lived in. The 2023 commit added a new way for skbs to enter that world, and the guard did not know about it. There is no individual whose mistake produced this bug. There is a six-year reviewer gap between two correct patches.

The crypto sink has now produced two CVEs

Before Dirty Frag there was Copy Fail, CVE-2026-31431. The same crypto_authenc_esn_decrypt write happens in Copy Fail too. The difference is the route in. Copy Fail uses algif_aead, the userspace AF_ALG socket that lets unprivileged processes drive a kernel AEAD transform directly. The PoC is a sixty-line Python script that opens a socket of family 38 (AF_ALG), binds it to aead/authencesn(hmac(sha256),cbc(aes)), splices /usr/bin/su in as the destination via splice() between a pipe and the socket fd, and lets the kernel walk the AEAD scatterlist into the page cache.

Two CVEs. Different syscalls. Different subsystems. Same four-byte write at the same line of crypto/authencesn.c.

The sink is doing exactly what it was designed to do. scatterwalk_map_and_copy does not ask whether the destination scatterlist points at a frag attached by the network stack, by algif_aead, by splice, or by a kernel module's bounce buffer. It writes. Every caller of crypto_authenc_esn_decrypt is a potential delivery vector for the same primitive, and the policy that pages backing read-only setuid binaries should not be writable destinations was never encoded at the sink. It was encoded, separately and incompletely, at every caller.

The patch is in two files

Look at where the upstream fix landed. The cve.org record cites four programFiles entries:

net/ipv4/esp4.c
net/ipv4/ip_output.c
net/ipv6/esp6.c
net/ipv6/ip6_output.c

esp4.c and esp6.c get the linearisation fix: drop the skb_has_shared_frag() guard for the ESN case and unconditionally linearise. ip_output.c and ip6_output.c get the labelling fix: when __ip_append_data attaches a MSG_SPLICE_PAGES page, also set SKBFL_SHARED_FRAG, so every existing consumer that already checks the flag will start working again.

Two patches for one bug because one patch would not have been enough. Fixing only esp_input would leave every other 2017-era branch that trusts skb_has_shared_frag() exposed to the same primitive via the same MSG_SPLICE_PAGES route. Fixing only ip_output.c would close this delivery vector and leave four other in-tree paths that build skb frags directly without consulting the IP output code at all.

The fix is split because the bug is a missing edge between two subsystems, and you cannot fill an edge from one side.

The half that defeats Ubuntu has no upstream patch

V4bel's chain is two CVEs. CVE-2026-43284 is one half. The other half is CVE-2026-43500, a use-after-free in RxRPC, the kernel module that implements the AFS RPC transport. Why both?

Because Ubuntu 24.04 ships AppArmor with unprivileged_userns_apparmor_policy=1. An unprivileged process cannot unshare(CLONE_NEWUSER | CLONE_NEWNET) to gain the CAP_NET_ADMIN it needs to register an XFRM SA. The xfrm_user route is closed on Ubuntu without root.

rxrpc.ko is loaded by default on Ubuntu. Triggering the RxRPC use-after-free does not require namespace creation. The RxRPC bug elevates to the point where you can reach xfrm_user with the right capability set, and then the page-cache write proceeds.

On RHEL 10, AlmaLinux 10, Rocky 10, the inverse holds. Userns are usable without AppArmor blocking, so the XFRM half is reachable directly. RxRPC is not loaded by default. The chain dispatches per distribution: whichever half is unblocked is the half the exploit uses, and the other half is dormant.

CVE-2026-43284, the one with the patch, is the half that the Ubuntu kernel was already mitigating in user space via AppArmor. The half that defeats Ubuntu is CVE-2026-43500, which is the unpatched RxRPC use-after-free that is upstream as of this writing. Distribution mitigation summaries that report "patched" against CVE-2026-43284 are not lying. They are reporting on the half of the bug that was already harder on their kernel.

Debian shipped 7.0.4-1 to sid and backported via DSA-6253-1 to trixie and DLA-4572-1 to bullseye. AlmaLinux pushed both fixes to 9 and 10 production. Proxmox issued PSA-2026-00019. Rocky, Amazon Linux 2023, NixOS, RHEL: no advisories at the time of writing for CVE-2026-43500. The xfrm-ESP half travels well. The RxRPC half travels alone.

What the PoC does not say

The exp.c that V4bel published, and that 6abc and 0xBlackash forked verbatim, is a 67kB C program. The first hundred lines are entirely setup: it carves out a 192-byte x86_64 shellcode ELF whose entry point at 0x400078 calls setuid(0); setgid(0); execve("/bin/sh", ...), opens /usr/bin/su read-only, sets up pipes, and primes the XFRM netlink configuration.

The shellcode is not the interesting part. The interesting part is the 32-bit value the exploit places into XFRMA_REPLAY_ESN_VAL.seq_hi at the moment the AEAD walk crosses the four-byte window inside su's text segment. The exploit does not need to upload code. It only needs to corrupt the four bytes of su that decide whether to call pam_authenticate, and it needs to do that without touching the disk.

The PoC weighs 67kB because most of it is the framework for placing four specific bytes at a specific page offset. The vulnerability itself is a single 32-bit netlink attribute.

The thing the PoC does not advertise: it works on a kernel that an EDR is watching. There is no ptrace, no process_vm_writev, no /proc/<pid>/mem write, no kernel module load, no bpf() syscall, no ftrace manipulation. The system call sequence is socket(AF_NETLINK), sendmsg, splice, sendmsg, recv, execve("/usr/bin/su"). The audit subsystem sees a normal su invocation that succeeds. Forensics on the disk shows no modified files. The page cache containing the modified su evicts on memory pressure, after which the disk version reloads and the evidence is gone.

This is the property the Dirty Frag name preserves from Dirty Pipe and Dirty COW: a write that targets the page cache, not the disk, leaves no artifact that the standard incident-response toolchain knows to look for.

The mitigation script ships separately

A third-party mitigation script published by scriptzteam blacklists esp4, esp6, and rxrpc via modprobe. The README warns that this disables IPsec ESP, which is to say strongSwan, libreswan, and most enterprise VPN configurations, and disables AFS. The script is correct. It is also revealing.

Defending against Dirty Frag without the upstream patches means turning off IPsec. The patched-half is the half whose mitigation is "blacklist esp4 and esp6 modules and stop running VPNs". The unpatched half is the half whose mitigation is "blacklist rxrpc and stop running AFS". For a great many production systems, both of those are non-options. The mitigation script ships with a warning because the cost of the workaround is high enough that the script's author wants to make sure you read it before you run it.

The cost of the workaround is the price of the missing upstream patch. CVE-2026-43500 has no fix. As long as it does not, the modprobe blacklist is what stands between an Ubuntu fleet and an unauthenticated local privilege escalation.

The reviewer gap is the real artifact

Six years passed between the 2017 commit that introduced the vulnerable branch and the 2023 commit that armed it. Nine years between the 2017 commit and the CVE. There is no review process in the kernel that runs against esp_input every time someone touches __ip_append_data. There is no static analyser that knows the relationship between SKBFL_SHARED_FRAG and skb_has_shared_frag() and the crypto_authenc_esn reordering write. The relationship existed only in the head of whoever wrote the 2017 patch, and that person had no obligation, and probably no occasion, to revisit it in 2023.

This is what the emergent-primitive pattern looks like in the kernel. Two correct patches compose into one wrong primitive over a six-year window, and the fix has to be in both files at once because each file individually is doing a thing that was correct under its own local invariants.

It is also what the design-debt-driver pattern looks like at the AEAD sink. crypto_authenc_esn_decrypt is now confirmed to deliver attacker-controlled four-byte writes to wherever the destination scatterlist resolves, regardless of route. Copy Fail came in via algif_aead. Dirty Frag came in via MSG_SPLICE_PAGES plus xfrm. Whichever syscall arrives next at this sink will produce the third CVE in the series.

The CVE record is the patch's name. The bug's name is Dirty Frag.