Skip to content

CVE-2026-31431 — Linux AF_ALG AEAD Page-Cache Write to Local Root

Published 2026-06-06

CISA Known Exploited Vulnerability

CVE-2026-31431 ("Copy Fail") was added to the CISA Known Exploited Vulnerabilities catalog on 2026-05-01 with a remediation due date of 2026-05-15. Treat this as actively exploited.

Field Detail
Project Linux kernel
Affected component crypto/algif_aead.c — AF_ALG AEAD socket interface
Severity HIGH
CVSS CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H (7.8)
CWE CWE-669
Affected versions 4.14 – 5.10.253 · 5.11 – 5.15.203 · 5.16 – 6.1.169 · 6.2 – 6.6.136 · 6.7 – 6.12.84 · 6.13 – 6.18.21 · 6.19 – 6.19.11
Fixed version 5.10.254 · 5.15.204 · 6.1.170 · 6.6.137 · 6.12.85 · 6.18.22 · 6.19.12
Advisory GHSA-2274-3hgr-wxv6

1. Vulnerability Overview

The Linux kernel exposes cryptographic operations to userspace through the AF_ALG socket family. One of its interfaces, algif_aead, handles Authenticated Encryption with Associated Data (AEAD) ciphers and is enabled by default on every major Linux distribution since around 2012.

CVE-2026-31431, nicknamed "Copy Fail," allows any local unprivileged user to gain root. By combining the AF_ALG AEAD interface with the splice() system call and the authencesn AEAD template, an attacker obtains a reliable 4-byte write primitive against any file's in-memory page cache, without touching the on-disk copy. Applied iteratively, that primitive is sufficient to overwrite security-sensitive files such as /usr/bin/su or /etc/passwd in memory and obtain a real root shell. No ASLR bypass, kernel symbol leak, or race condition is required; the write is fully deterministic.

The flaw was introduced by commit 72548b093ee3 ("crypto: algif_aead - copy AAD from src to dst") on 2017-07-30 and went undetected for roughly nine years until Theori disclosed it publicly in 2026.

Root Cause

The bug is in _aead_recvmsg() in crypto/algif_aead.c. A 2017 performance optimization made algif_aead perform decryption in-place to avoid memory copies. To do this, it built a combined scatterlist: the user's receive buffer (containing AAD and ciphertext) was chained to the page-cache pages from the source TX scatter-gather list that held the authentication tag. This chaining used af_alg_pull_tsgl(sk, processed, areq->tsgl, processed - as), where the dst_offset argument (processed - as) caused only the tag-bearing pages to be reassigned to the destination, meaning original page-cache pages became part of the decryption output scatterlist.

When the authencesn AEAD template processes a decryption request, it uses dst[assoclen + cryptlen] (the 4 bytes immediately past the ciphertext) as scratch space to rearrange Extended Sequence Number (ESN) bytes. It writes seqno_lo (bytes 4–7 of the AAD, fully attacker-controlled) at that offset. Because of the in-place page-cache chaining, that offset resolves into one of the chained page-cache pages. HMAC verification then fails, deliberately, but the page-cache write has already happened. The kernel's writeback machinery never marks the corrupted page dirty, so it is never flushed to disk. Any subsequent read() or exec() of the file returns the attacker's bytes from the in-memory cache rather than the on-disk data.

Patch commit a664bf3d603d, authored by Herbert Xu on 2026-03-26, reverts the in-place optimization entirely. It removes the dst_offset parameter from af_alg_count_tsgl and af_alg_pull_tsgl and rewrites _aead_recvmsg to always operate out-of-place: TX SGL pages are transferred to a per-request buffer, the AAD is copied with memcpy_sglist, and the AEAD request is issued with TX SGL as source and RX SGL as destination. Page-cache pages are never chained into the writable output.

--- a/crypto/algif_aead.c
+++ b/crypto/algif_aead.c
@@ -152,23 +152,24 @@ static int _aead_recvmsg(...)
+    /* Always operate out-of-place */
     processed = used + ctx->aead_assoclen;
-    /* [old: searched for first valid TX SGL page, then branched on enc/dec
-       for decryption: af_alg_pull_tsgl(sk, processed, areq->tsgl, processed - as)
-       chained page-cache tag pages into RX SGL destination] */
+    areq->tsgl_entries = af_alg_count_tsgl(sk, processed);
+    areq->tsgl = sock_kmalloc(sk, ...);
+    af_alg_pull_tsgl(sk, processed, areq->tsgl);   /* no dst_offset */
+    tsgl_src = areq->tsgl;

+    memcpy_sglist(rsgl_src, tsgl_src, ctx->aead_assoclen);  /* copy AAD */

-    aead_request_set_crypt(..., rsgl_src,           /* src = RX buf */
+    aead_request_set_crypt(..., tsgl_src,           /* src = TX buf (copy) */
                            areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv);
--- a/crypto/af_alg.c
+++ b/crypto/af_alg.c
-unsigned int af_alg_count_tsgl(struct sock *sk, size_t bytes, size_t offset)
+unsigned int af_alg_count_tsgl(struct sock *sk, size_t bytes)
 /* offset parameter removed — no more selective starting position */

-void af_alg_pull_tsgl(struct sock *sk, size_t used, struct scatterlist *dst,
-                      size_t dst_offset)
+void af_alg_pull_tsgl(struct sock *sk, size_t used, struct scatterlist *dst)
 /* dst_offset removed — all pages always reassigned from offset 0 */

2. Vulnerable Environment

The environment is an AWS EC2 instance running Ubuntu 24.04 LTS, booting AMI ami-0d17372de612983fe (build 20260321) in ap-southeast-1. The stock kernel on that AMI is 6.17.0-1009-aws, which falls within the affected range for the 6.17.x series (fixed at 6.18.22). The instance type is t3.small (Nitro, 2 vCPU burstable). The trigger is deterministic, so burstable compute is adequate.

Infrastructure is defined as code using OpenTofu. The key files are available for download alongside this page:

On first boot, the cloud-init script apt-mark holds all kernel packages and masks unattended-upgrades so the kernel cannot be silently patched. It then runs a gate that parses uname -r, computes the per-series fixed stable tag, and writes the readiness marker /root/.lab-ready only when the running kernel is confirmed within the affected range. It also binds an AF_ALG authencesn(hmac(sha256),cbc(aes)) socket to verify the transport is usable. On a patched kernel or the wrong AMI, the gate fails and the marker is never written.

A single unprivileged user lowpriv (uid 1001, no sudo, no capabilities) is created. The VM also stages a clean root-owned baseline:

  • /usr/bin/su — intact setuid-root binary (sha256 c74311fe5636b7d7f9a56239fa8adeeab12ba86fe7d41b91afa85bf9bbdae78b)
  • /etc/passwd — with lowpriv at UID 1001: lowpriv:x:1001:1001::/home/lowpriv:/bin/bash
  • /var/lib/lab-marker — a root-only-writable directory for the post-exploit side-effect observation

Baseline metadata is recorded in /root/lab-baseline.json (readable only via the privileged OOB channel).

Verifying the Environment is Vulnerable

SSH into the VM as ubuntu and check the readiness marker and kernel version through the privileged channel:

ssh -i env/tofu/lab_key.pem -o StrictHostKeyChecking=accept-new ubuntu@13.229.212.201 \
  'uname -r; \
   sudo -n test -f /root/.lab-ready && echo LAB-READY || echo NOT-READY; \
   sudo -n cat /root/.lab-ready; \
   sudo -n cat /root/lab-baseline.json'

Observed output (2026-06-03):

6.17.0-1009-aws
LAB-READY
2026-06-03T01:59:41Z
{ "cve": "CVE-2026-31431", "kernel": "6.17.0-1009-aws", "kernel_version": "6.17.0",
  "fixed_tag_for_series": "6.18.22", "vulnerable": true,
  "unprivileged_actor": "lowpriv", "lowpriv_uid": 1001,
  "lowpriv_passwd_line": "lowpriv:x:1001:1001::/home/lowpriv:/bin/bash",
  "setuid_target": "/usr/bin/su",
  "setuid_target_sha256_baseline": "c743...ae78b",
  "marker_dir": "/var/lib/lab-marker", "marker_dir_owner": "root" }

Note

/root is mode 0700. The readiness marker must be read through the privileged OOB channel (sudo -n ...). A bare cat /root/.lab-ready as the ubuntu user (without sudo) returns Permission denied and would incorrectly indicate the environment is not ready. Always use sudo -n. If the output shows NOT-READY, inspect /var/log/lab-init.log for a SUBSTRATE_FAILED: line; if present, the environment substrate is inadequate and the run must not proceed.

3. How to Exploit

The exploit uses the page-cache write primitive to corrupt the lowpriv user's UID and GID fields in /etc/passwd in memory. Once the page cache reflects lowpriv:x:0:0:, su lowpriv reads UID 0 from the cached page and spawns a real root shell, which then drops a root-owned marker file that proves the escalation. The on-disk copy of /etc/passwd is never touched.

The full exploit bundle is available for download: exploit.zip

Individual scripts:

Step 1 — Confirm the clean baseline

Before running the exploit, verify that the VM is at a clean baseline: lowpriv is UID 1001, the marker directory is empty, and no previous run has dirtied the page cache.

ssh -i env/tofu/lab_key.pem -o StrictHostKeyChecking=accept-new ubuntu@13.229.212.201 \
  'uname -r; sudo -n test -f /root/.lab-ready && echo LAB-READY || echo NOT-READY; \
   sudo -n id lowpriv; sudo -n ls -la /var/lib/lab-marker/'

Expected output: 6.17.0-1009-aws, LAB-READY, lowpriv reported as uid=1001(lowpriv), and an empty /var/lib/lab-marker.

If the environment is dirty from a prior run, reboot to clear the page cache before proceeding:

ssh -i env/tofu/lab_key.pem ubuntu@13.229.212.201 \
  'sudo -n rm -f /var/lib/lab-marker/pwned_by_lowpriv; sudo -n reboot'

Step 2 — Run the exploit as the unprivileged actor

run.sh stages all scripts onto the VM, installs them owned by lowpriv, and drives the full escalation chain:

bash exploit/run.sh env/tofu/lab_key.pem 13.229.212.201 ubuntu lowpriv /var/lib/lab-marker pwned_by_lowpriv
Argument Value Meaning
key env/tofu/lab_key.pem SSH private key (run-dir relative)
host 13.229.212.201 VM public IP
ssh_user ubuntu SSH login user (passwordless sudo)
actor lowpriv Unprivileged actor (uid 1001) the exploit runs as
marker_dir /var/lib/lab-marker Root-only directory for the post-exploit side effect
marker_name pwned_by_lowpriv Marker file created by the escalated root shell

Script execution order:

  1. Copies pagecache_write.py, passwd_patch.py, and escalate.sh to the VM and installs them owned by lowpriv.
  2. Runs escalate.sh as lowpriv. This script calls passwd_patch.py, which locates the lowpriv line in /etc/passwd, identifies the byte offsets of the 1001 UID and GID fields, and calls pagecache_write.py twice: once for the UID field and once for the GID field. Each call opens /etc/passwd read-only, creates an AF_ALG authencesn(hmac(sha256),cbc(aes)) socket, splice()s the relevant page-cache pages into its TX scatter-gather list, and calls recv(). The authencesn template writes seqno_lo (the 4 attacker-controlled AAD bytes) into the chained page-cache page. recv() returns EBADMSG due to the deliberate HMAC failure; the write has already landed.
  3. With the in-memory /etc/passwd now reading lowpriv:x:0:0::/home/lowpriv:/bin/bash, escalate.sh runs su lowpriv. The su binary reads the cached UID 0, skips authentication (caller appears as root), and spawns a shell with real UID 0. That shell creates the marker file in /var/lib/lab-marker, a directory lowpriv cannot write to under its real identity.

Step 3 — Confirm the exploit succeeded through the independent root channel

The proof of escalation is read through the privileged OOB channel (the ubuntu user's passwordless sudo over SSH), which is entirely separate from the exploit process and its output. Reading from this independent channel establishes that the effect was caused by the exploit and not by any legitimate root action:

ssh -i env/tofu/lab_key.pem -o StrictHostKeyChecking=accept-new ubuntu@13.229.212.201 \
  'sudo -n stat -c "owner=%U(%u) group=%G(%g) mode=%a name=%n" /var/lib/lab-marker/pwned_by_lowpriv; \
   sudo -n cat /var/lib/lab-marker/pwned_by_lowpriv; \
   sudo -n id lowpriv; sudo -n getent passwd lowpriv; \
   sudo -n -u lowpriv -- id'

Observed output after the exploit:

Primary observable — root-owned side effect in a root-only directory:

owner=root(0) group=root(0) mode=644 name=/var/lib/lab-marker/pwned_by_lowpriv
created-by-unprivileged-actor-via-CVE-2026-31431

The file exists, is owned by UID 0, and lives in a directory that lowpriv cannot write to. Creating it is a UID-0 action that an unprivileged process cannot perform on a patched kernel.

Equivalent observable — the principal is now treated as UID 0:

uid=0(root) gid=0(root) groups=0(root)          ← id lowpriv (root OOB channel)
lowpriv:x:0:0::/home/lowpriv:/bin/bash          ← getent passwd lowpriv (page-cache read)
uid=0(root) gid=0(root)                         ← sudo -u lowpriv -- id (independent spawn)

Step 4 — Confirm the corruption is page-cache-only

A raw-disk read via O_DIRECT (bypassing the page cache) shows the on-disk /etc/passwd is untouched. This is the definitive signature of the CVE-2026-31431 page-cache write primitive and rules out any legitimate write path:

sudo -n grep "^lowpriv" /etc/passwd                 # page-cache read
sudo -n python3 /tmp/rawread.py                     # O_DIRECT raw-disk read
# page-cache read:
lowpriv:x:0000:0000::/home/lowpriv:/bin/bash   ← CORRUPTED (in-memory only)
# raw-disk read:
lowpriv:x:1001:1001::/home/lowpriv:/bin/bash   ← CLEAN (on-disk untouched)

On a patched kernel the page-cache write never occurs: lowpriv remains UID 1001, no marker is created, and recv() returns EINVAL rather than EBADMSG.

Teardown

Once finished, destroy the EC2 instance and all associated resources:

cd env/tofu
export AWS_PROFILE=cve-lab
tofu destroy -auto-approve

This removes the instance (i-0e8daa1d3611847b9), security group, ephemeral key pair, and the local lab_key.pem.

4. Security Advice

Remediation

The only complete fix is upgrading to a kernel version that includes patch commit a664bf3d603d. These stable releases carry the fix:

Stable series Fixed at
5.10.x 5.10.254
5.15.x 5.15.204
6.1.x 6.1.170
6.6.x 6.6.137
6.12.x 6.12.85
6.18.x 6.18.22
6.19.x 6.19.12

Vendor-specific fixed releases follow their own cadence; consult your distribution's security advisory. Red Hat has published a vendor advisory for RHEL. CISA requires remediation by 2026-05-15 for systems covered by Binding Operational Directive 22-01.

Mitigations and Workarounds

If an immediate kernel upgrade is not feasible, disabling the algif_aead kernel module eliminates the vulnerable code path:

modprobe -r algif_aead
echo "blacklist algif_aead" >> /etc/modprobe.d/disable-algif-aead.conf

This prevents the AF_ALG AEAD socket from being bound. Applications that rely on AF_ALG AEAD will be affected; in practice this is uncommon, as most userspace crypto goes through OpenSSL or a similar library rather than the kernel socket interface. Test before deploying.

A finer-grained alternative is restricting unprivileged access to AF_ALG sockets via a kernel lockdown policy or a seccomp filter that blocks AF_ALG socket creation, for cases where modprobe -r is not an option.

References