Vulnerability Overview
| Field | Detail |
|---|---|
| CVE ID | CVE-2026-31431 |
| Nickname | Copy Fail |
| CVSS 3.1 | 7.8 (HIGH) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Affected component | Linux kernel crypto/algif_aead.c, crypto/authenc_esn.c
|
| Impact | Local privilege escalation (LPE), container escape |
| Affected kernels | 4.14 – 6.18.21 / 6.19.11 (introduced 2017, patched April 2026) |
| Discovered by | Theori's Taeyang Lee, assisted by Xint Code automated analysis |
| Fix commit | a664bf3d603d |
Copy Fail isn't a race condition or a timing attack. It's a deterministic logic bug: call the right syscalls in the right order and it fires every single time. An unprivileged local user can write a controlled 4-byte value at a controlled offset inside the kernel's page cache for any readable file — and turning that into a root shell just takes patience.
1. Three Building Blocks You Need First
Before touching the vulnerable code path, it helps to lay out three kernel concepts that each look harmless in isolation.
AF_ALG. The Linux kernel exposes its own crypto primitives (AES, SHA, etc.) to userspace through a socket family called AF_ALG. Open the socket, bind() to an algorithm, sendmsg() your data, recvmsg() the result. No privileges required at any step.
splice(). Normally reading a file means the kernel copies its contents into a userspace buffer (read()). splice() skips that copy: it hands a reference to the kernel's own page cache — the in-memory cached copy of the file — directly to a pipe or socket. Fast, but it also means whoever receives that reference is now holding a pointer straight into the real, live cache of that file.
AEAD and authencesn. AEAD (Authenticated Encryption with Associated Data) bundles encryption with an integrity tag. authencesn is the IPsec-specific flavor used for Extended Sequence Numbers (ESN) — a 64-bit sequence counter split into a high half (seqno_hi) and low half (seqno_lo). To compute the HMAC correctly, it needs to temporarily rearrange these bytes, and it does that rearrangement by treating the destination buffer as disposable scratch space. That habit is the trigger for everything that follows.
Each of these three pieces is reasonable on its own. The bug lives at their intersection.
2. Root Cause: The 2017 In-Place Optimization
The decrypt path in algif_aead.c originally kept separate source and destination scatterlists. In 2017, commit 72548b093ee3 introduced an in-place optimization: source and destination collapse into the same memory.
Here's what happens on decrypt:
- The input scatterlist is laid out as
AAD || ciphertext || auth tag. -
AADand ciphertext are genuinely copied viamemcpy_sglist()into the user's receive buffer. Safe — it's a real copy. - The auth tag (the last
authsizebytes) is not copied. Instead, its scatterlist entries are chained by reference onto the end of the output scatterlist withsg_chain().
Input SGL: [ AAD ] [ Ciphertext ] [ Tag ]
| | └─ sg_chain() — reference only, still page cache
v v
Output SGL: [ AAD ] [ Ciphertext ] ─────────┘
(RX buffer, real copy) (original page cache, referenced)
The kernel then sets req->src = req->dst, pointing both at the same combined chain:
req->src ─┐
v
req->dst ─→ [ AAD | Ciphertext ] → [ Tag (raw page cache) ]
└──── userspace RX buffer ────┘ └── chained from the spliced file's cache ──┘
This design silently assumes every AEAD algorithm confines its writes to the legitimate output region. Nothing in the API enforces that assumption — and one algorithm breaks it.
3. The Trigger: authencesn's Scratch Write
authencesn's decrypt routine (crypto_authenc_esn_decrypt) uses the destination buffer as scratch space for ESN byte rearrangement. Reconstructed conceptually, it looks like this:
// crypto/authenc_esn.c — decrypt path (conceptual reconstruction)
// ① Read the first 8 AAD bytes (seqno_hi + seqno_lo) into a temp buffer
scatterwalk_map_and_copy(tmp, dst, 0, 8, 0);
// ② Overwrite dst[4..7] with seqno_hi, for HMAC computation
scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);
// ③ Write seqno_lo (4 bytes) right past the auth tag boundary
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);
Steps ① and ② stay inside the AAD region and get restored, so they're harmless. Step ③ is the problem. assoclen + cryptlen is exactly the offset where the auth tag begins — the AEAD output contract says destination should only ever contain AAD || plaintext. authencesn writes 4 bytes past that contract, and nothing ever restores the original bytes at that position. crypto_authenc_esn_decrypt_tail() reads seqno_lo back out, but never writes back whatever was there before. Once overwritten, it's gone.
No other AEAD algorithm in the kernel does this — GCM, CCM, and plain authenc all stay inside their output region. authencesn alone reaches past the boundary. Before 2017, when src and dst were separate, this stray write just landed harmlessly at the tail of the user's own RX buffer. After the in-place optimization, that same write now walks straight into the sg_chain()-linked tag pages — the real page cache of the spliced file.
Three things are fully attacker-controlled:
- Which file — anything readable by the current user, including setuid binaries
-
Which offset — by choosing the splice file offset, splice length, and
assoclen, the attacker can makeassoclen + cryptlenland on any 4-byte position they want inside the file's cached pages -
Which value — the overwritten bytes come straight from bytes 4–7 of the attacker-supplied AAD in
sendmsg()
That's a fully controlled arbitrary 4-byte write primitive against the page cache of any readable file.
4. Exploit Flow
-
Open an AF_ALG socket and bind to
authencesn(hmac(sha256),cbc(aes))— no privileges needed. - sendmsg() a crafted AAD — bytes 4–7 carry the 4 bytes you want written.
-
splice() the target file (e.g.
/usr/bin/su) through a pipe into the socket, choosing offset/length so the tag boundary lands exactly where you want to write. - recvmsg() triggers decryption. authencesn's scratch write lands in the chained page cache pages. HMAC verification fails and the call returns an error — but the 4-byte write already happened.
-
Repeat, then execve(). Loop steps 2–4 to place a full payload 4 bytes at a time inside the target binary's
.textsection, thenexecve()it. Because it's setuid-root, the injected code runs as UID 0.
The critical detail: the kernel never marks the corrupted page dirty. Ordinary writes go through the VFS write path and get flagged for writeback to disk; this write bypasses that entirely.
- The on-disk file is untouched — checksum-based integrity tools miss it completely.
- But every read,
mmap(), orexecve()of that file goes through the page cache, so the corruption is live system-wide immediately. - Since the page cache is shared host-wide, the same primitive crosses container boundaries.
5. How Three Reasonable Commits Became One Bug
| Year | Commit | Change | Why it was safe at the time |
|---|---|---|---|
| 2011 | a5079d084f8b |
authencesn added; uses dst as ESN scratch space | Only caller was the kernel's internal xfrm layer |
| 2015 | 104880a6b470 |
authencesn ported to the new AEAD interface, introducing the assoclen+cryptlen out-of-bounds write |
AF_ALG still used separate src/dst scatterlists |
| 2015 | (algif_aead introduced) | AF_ALG gains AEAD + splice support | Page cache pages could now reach the crypto layer |
| 2017 | 72548b093ee3 |
algif_aead switches to in-place operation, req->src = req->dst
|
This is where all three pieces collide |
Each commit made sense in isolation. Nobody connected authencesn's scratch-write habit, splice's page-cache-by-reference delivery, and algif_aead's in-place design until they'd been sitting together, silently exploitable, for almost a decade.
6. The Fix: Back to Out-of-Place
Fix commit a664bf3d603d effectively reverts the 2017 optimization.
// Before — src and dst point to the same (in-place) scatterlist
aead_request_set_crypt(&areq->cra_u.aead_req,
rsgl_src, // RX SGL
areq->first_rsgl.sgl.sgt.sgl, // RX SGL (same)
used, ctx->iv);
// After — src is the TX SGL, dst is the RX SGL (out-of-place)
aead_request_set_crypt(&areq->cra_u.aead_req,
tsgl_src, // TX SGL (may hold spliced page cache)
areq->first_rsgl.sgl.sgt.sgl, // RX SGL (user buffer, separate)
used, ctx->iv);
After the patch, req->src (which may contain spliced page cache pages) and req->dst (the user's buffer) are fully separated. Only the AAD gets copied from src to dst, and the sg_chain() logic that linked tag pages into the writable destination is removed entirely. Now, even if authencesn writes past assoclen + cryptlen, that position only ever contains user memory — never page cache.
The commit message states it plainly: there's no benefit to in-place operation in algif_aead, since source and destination come from entirely different memory mappings to begin with.
7. Mitigation Before You Can Patch
If an immediate kernel update isn't possible, blocking the vulnerable module removes the attack surface entirely:
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null || true
In containerized or multi-tenant environments, blocking socket(AF_ALG, ...) via a seccomp profile is a solid additional line of defense.


Top comments (0)