Copy Fail: Three Kernel Commits, Nine Years Apart, One Root Shell

A friend mentioned CVE-2026-31431 while we were hanging out. He runs a VPS we use for self-hosting and Minecraft, and he'd just read about it: a Linux local privilege escalation, publicly disclosed ten days prior, 732-byte Python script, same binary roots Ubuntu, Amazon Linux, RHEL, and SUSE without modification. I was curious enough to set it up and test it myself.

This post is about what the vulnerability does. Not "here's the PoC, run it." The mechanism is the interesting part, and it takes three separate pieces of the kernel that nobody connected for nine years.


What Makes This Different

The two Linux LPEs most people know are Dirty COW (CVE-2016-5195) and Dirty Pipe (CVE-2022-0847).

Dirty COW exploited a race condition in the VM subsystem's copy-on-write path. It required winning that race, which meant multiple attempts, occasional system crashes, and timing sensitivity that made it unreliable enough to be annoying. Dirty Pipe was cleaner but version-specific: it required precise pipe buffer manipulation and only applied to kernels 5.8 and later.

Copy Fail (CVE-2026-31431) is a straight-line logic flaw. No race window. No crash-prone timing. No per-distro offsets, no recompilation, no version checks in the exploit. The same script works on every kernel built between 2017 and the patch. It requires only an unprivileged local user account. No network access, no kernel debugging features, nothing pre-installed.

There is also a stealth property that neither predecessor had. More on that later.


The Two Primitives: AF_ALG and splice()

To understand the bug, you need to understand what AF_ALG and splice() do, and what happens when you combine them.

AF_ALG is a socket family (address family 38) that exposes the kernel's cryptographic subsystem to unprivileged userspace. You open a socket, bind it to a named algorithm, set a key, and you can encrypt or decrypt arbitrary data through it. No privileges required. It ships enabled in essentially every mainstream Linux distribution's default kernel configuration. The entire point of AF_ALG is to let userspace use the kernel's crypto hardware offload paths without writing kernel code.

a = socket.socket(38, 5, 0)  # AF_ALG, SOCK_SEQPACKET
a.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))

splice() transfers data between file descriptors and pipes without copying it. The mechanism is pass-by-reference at the page level: instead of copying the bytes, the kernel hands off the page cache pages themselves. When you splice a file into a pipe and then into an AF_ALG socket, the socket's input scatterlist holds direct references to the kernel's cached pages of that file. The pages are not duplicated. The scatterlist entries point at the same physical pages that back every read(), mmap(), and execve() of that file system-wide.

This is efficient. It is also, it turns out, pretty dangerous under specific conditions.


The Root Cause: Page Cache Pages in the Writable Scatterlist

Inside algif_aead.c, the code that handles AEAD (Authenticated Encryption with Associated Data) decryption sets up the crypto request. The input format is:

AAD  ||  Ciphertext  ||  Authentication Tag

In 2017, a commit added an in-place optimization. For decryption, rather than maintaining separate source and destination scatterlists, the code would construct a combined scatterlist and set req->src = req->dst. The idea was to avoid unnecessary allocation.

Here is exactly what that combined scatterlist looks like when the input comes from a splice:

Input SGL:    [ AAD  ||  Ciphertext  ||  Tag (page cache pages) ]
                                              ^
                                              | splice delivered these
                                              | directly from file's page cache

Output SGL:   [ AAD copy | CT copy ] ──sg_chain──▶ [ Tag (page cache pages) ]
              |                    |                 |                        |
              +── RX buffer ───────+                 +── chained from TX SGL ─+
                  (user memory)                          (file's page cache)

req->src ──┐
           └──▶ head of Output SGL
req->dst ──┘

The AAD and ciphertext data are byte-copied from the input into the output buffer via memcpy_sglist. Real copies. The page cache pages are only read during those copies. The authentication tag, however, is not copied. The kernel retains those scatterlist entries and chains them onto the end of the output scatterlist using sg_chain().

The result: the output scatterlist now has page cache pages in it. Both req->src and req->dst point at the head of a scatterlist that terminates in the kernel's cached pages of the target file. The design assumes every AEAD algorithm will confine its writes to the legitimate output region: the RX buffer, before the chained tag pages. Nothing in the API enforces this. Nothing documents it as a requirement.

One AEAD algorithm breaks this invariant.


authencesn's Scratch Write

authencesn is a kernel AEAD wrapper implementing IPsec ESP's Extended Sequence Number support (RFC 4303). IPsec uses 64-bit sequence numbers split across two fields: seqno_hi (bytes 0–3 of the AAD) and seqno_lo (bytes 4–7). The wire format carries only seqno_lo; seqno_hi is implicit.

For HMAC computation, authencesn needs these in a specific order: seqno_hi at the front of the hash input, seqno_lo appended at the end. It performs this rearrangement by using the caller's destination buffer as scratch space. In crypto_authenc_esn_decrypt():

scatterwalk_map_and_copy(tmp,     dst, 0,                    8, 0); // read AAD bytes 0–7
scatterwalk_map_and_copy(tmp,     dst, 4,                    4, 1); // overwrite dst[4..7] with seqno_hi
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen,  4, 1); // write seqno_lo past the tag

The first two calls shuffle bytes within the AAD region: a temporary modification that gets restored. The third call writes 4 bytes at offset assoclen + cryptlen. This is past the end of the AEAD output. Past the authentication tag. The algorithm is using memory it does not own as a scratch pad, and it never writes the original content back.

In the pre-2017 world, this went to the user's RX buffer. Past the legitimate output boundary, but still in user memory. Unexpected, but not catastrophic.

In the post-2017 world, with the in-place optimization, dst[assoclen + cryptlen] is inside the chained page cache pages. scatterwalk_map_and_copy walks past the RX buffer, maps the page cache page via kmap_local_page, and writes seqno_lo directly into the kernel's in-memory cached copy of the target file.

The HMAC computation then runs over the rearranged data and fails (the ciphertext was fabricated), so recvmsg() returns an error. The 4-byte write has already happened. The kernel does not undo it.

The attacker controls three things: which file (any file readable by the current user), which 4-byte offset within that file's page cache (controlled through assoclen, splice offset, and splice length), and which value to write there (seqno_lo comes from bytes 4–7 of the AAD, constructed by the attacker in sendmsg()).

That's a fully controlled, arbitrary 4-byte write to the page cache of any readable file. Repeated in a loop, it becomes an arbitrary write of any length.


How Three Reasonable Commits Became One Bug

None of the individual commits that created this were mistakes in isolation.

2011 — commit a5079d084f8b: authencesn added to the kernel for IPsec ESN support. The scratch write was present from the start, but harmless. The only caller was the kernel's internal xfrm layer, and associated data lived in a separate scatterlist.

2015 — commit 104880a6b470: AF_ALG gained AEAD support. authencesn was converted to the new AEAD interface, introducing the assoclen + cryptlen offset. But AF_ALG still used out-of-place operation at this point. Page cache pages from splice were in req->src (read-only). The scratch write went to req->dst (the user's buffer). Not yet exploitable.

2017 — commit 72548b093ee3: The in-place optimization. req->src = req->dst. Page cache pages from splice are now in the writable destination scatterlist. authencesn's write at dst[assoclen + cryptlen] now reaches them.

Nobody connected all three. Each change was reviewed and merged on its own merits. The vulnerability lives entirely at the intersection: the gap between what algif_aead.c assumed about its callers' write behavior, what authencesn actually did, and what splice() delivered into the scatterlist.

It was silently exploitable for nearly a decade.


The Exploit Chain

The published PoC targets /usr/bin/su by default: a setuid-root binary present on every major Linux distribution.

Step 1: Socket setup. Open an AF_ALG socket, bind to authencesn(hmac(sha256),cbc(aes)), set a key, accept a request socket. Unprivileged. No sudo.

Step 2: Construct the write. For each 4-byte chunk of the shellcode payload, construct a sendmsg() + splice() pair. The sendmsg provides the AAD: bytes 4–7 carry the 4 bytes to write. The splice delivers the target file's page cache pages as ciphertext and tag. The AEAD parameters are chosen so that dst[assoclen + cryptlen] falls on the target offset within /usr/bin/su's .text section.

Step 3: Trigger the write. recv() triggers the decrypt operation. authencesn reads the ESN bytes, writes seqno_lo at dst[assoclen + cryptlen]. The write hits the page cache page. recvmsg returns an error (expected). The write persists.

Step 4: Execute. After all chunks are written, execve("/usr/bin/su"). The kernel loads the binary from page cache. The page cache version contains injected shellcode. Because su is setuid-root, the shellcode runs as UID 0.

The entire PoC is 732 bytes of Python. Standard library only: os, socket, zlib. Python 3.10+ for os.splice. No compiled payloads.


The Lab

I tested this on a throwaway VPS: Ubuntu 24.04.4 LTS, kernel 6.8.0-110-generic. The surface you check for first is whether the AF_ALG AEAD module is even loaded:

$ lsmod | grep algif_aead
algif_aead             12288  0
af_alg                 32768  1 algif_aead

From there the chain I described above runs as an ordinary unprivileged user and ends in a root shell. It worked on the first try, with no race window and no retries.

I'm not going to post the exploit script or a run-it-in-one-line command. The point of this write-up is the mechanism, not a copy-paste root. If you want to reproduce it, build the chain yourself from the primitives above, and only ever on a disposable, isolated VM with no data and no network path to anything you care about. The patched kernel below closes it.


The Stealth Property

This is the part that matters most for defenders.

The kernel's writeback machinery marks a page dirty when a write goes through the normal VFS path. A dirty page eventually gets flushed back to disk. authencesn's scratch write does not go through that path. It writes directly into the page cache via kmap_local_page, never marking the page dirty. The on-disk file is unchanged.

Verification, from inside the root shell and then from outside it after exit:

# sha256sum /usr/bin/su
44900c631391f0d60eb6d271b8374a08dc1d9be76e403390d27a91ed5f179be9  /usr/bin/su

$ sha256sum /usr/bin/su
44900c631391f0d60eb6d271b8374a08dc1d9be76e403390d27a91ed5f179be9  /usr/bin/su

Identical. File integrity monitoring tools that compare on-disk checksums (AIDE, Tripwire, and anything else that hashes what's on disk) see nothing. The binary that actually runs (from page cache) is not the binary they measured.

The page cache corruption is not persistent across reboot, which limits the attack window. It is, however, immediately system-wide: the page cache is shared across all processes on the host, including across container boundaries. A pod with the right primitives can compromise the node and cross tenant lines. (The Xint team has a Part 2 on the Kubernetes escape. Not published yet as of this writing.)


The Mitigation

Before the kernel patch ships for a given distribution, the interim mitigation is to prevent algif_aead from loading:

echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif_aead.conf
rmmod algif_aead 2>/dev/null

When the exploit tries to bind to the AEAD socket type, the kernel attempts to auto-load algif_aead via modprobe. Modprobe runs /bin/false, exits with code 1, and the kernel returns ENOENT (errno 2, "No such file or directory"). Python's socket layer receives ENOENT and raises it as FileNotFoundError.

This is why the unmodified exploit script fails with:

FileNotFoundError: [Errno 2] No such file or directory

There is no missing file. The error is the kernel returning ENOENT because the algorithm module could not be loaded. Python maps errno 2 to FileNotFoundError regardless of whether it originated from a file operation or a socket call. The exploit dies at the bind, before it ever reaches the page cache.

What the mitigation breaks: essentially nothing for most systems. AF_ALG's AEAD interface is not used by dm-crypt, LUKS, kTLS, IPsec, OpenSSL, GnuTLS, or SSH in their default configurations. If lsof | grep AF_ALG returns nothing, disabling the module costs you nothing.


The Fix

The patch (commit a664bf3d603d) reverts algif_aead.c to out-of-place operation. The in-place optimization is removed entirely. Before and after:

// Before: both req->src and req->dst point at the same combined scatterlist
// (which includes chained page cache pages from splice)
aead_request_set_crypt(&areq->cra_u.aead_req,
    areq->first_rsgl.sgl.sgt.sgl,   // RX SGL — writable, contains page cache pages
    areq->first_rsgl.sgl.sgt.sgl,   // same
    used, ctx->iv);

// After: req->src is the TX SGL, req->dst is the RX SGL (user's buffer only)
aead_request_set_crypt(&areq->cra_u.aead_req,
    tsgl_src,                        // TX SGL — may have page cache pages; read-only here
    areq->first_rsgl.sgl.sgt.sgl,   // RX SGL — writable, user memory only
    used, ctx->iv);

req->src now points at the TX SGL, which may contain page cache pages from splice but is only read. req->dst points at the RX SGL, which is the user's recvmsg buffer and the only region anything should be writing to. The sg_chain() call that linked page cache tag pages into the writable destination is gone.

The commit message on the fix: "There is no benefit in operating in-place in algif_aead since the source and destination come from different mappings."

Nine years of a silent invariant violation, fixed in eight lines.


Closing

The interesting part of Copy Fail is not the primitives (AF_ALG and splice are well-understood), and it is not authencesn's scratch write in isolation, which is sloppy code in a module that had no dangerous callers at the time. It is the nine-year gap between the write becoming unsafe and anyone noticing.

The 2017 in-place optimization was committed with a comment about performance. The reviewer checked that AAD and ciphertext were correctly copied. Nobody checked whether every AEAD implementation in the crypto subsystem confined its writes to the output boundary, because nothing in the API required them to and the assumption was apparently shared and unwritten.

How many other assumptions in that API are shared, unwritten, and unbroken only because no one has spliced the right file into the right algorithm yet??


CVE-2026-31431 was discovered by Taeyang Lee and the Xint Code team at Theori, disclosed April 29, 2026. The PoC and full technical writeup are at copy.fail and xint.io.