DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-31431 \"Copy Fail\": A Deep Dive into the Linux algif_aead Privilege Escalation

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:

  1. The input scatterlist is laid out as AAD || ciphertext || auth tag.
  2. AAD and ciphertext are genuinely copied via memcpy_sglist() into the user's receive buffer. Safe — it's a real copy.
  3. The auth tag (the last authsize bytes) is not copied. Instead, its scatterlist entries are chained by reference onto the end of the output scatterlist with sg_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)
Enter fullscreen mode Exit fullscreen mode

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 ──┘
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 make assoclen + cryptlen land 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

  1. Open an AF_ALG socket and bind to authencesn(hmac(sha256),cbc(aes)) — no privileges needed.
  2. sendmsg() a crafted AAD — bytes 4–7 carry the 4 bytes you want written.
  3. 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.
  4. 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.
  5. Repeat, then execve(). Loop steps 2–4 to place a full payload 4 bytes at a time inside the target binary's .text section, then execve() 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(), or execve() 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In containerized or multi-tenant environments, blocking socket(AF_ALG, ...) via a seccomp profile is a solid additional line of defense.

Top comments (0)