Copy Fail: A 4-Byte Page-Cache Write, and What It Does to Your Rootless Homelab

Copy Fail (CVE-2026-31431) lets any local user write 4 bytes into the page cache of any readable file, and one 732-byte script turns that into root. Container isolation never touched the page cache: rootless Podman stops the root, not the poisoned binary your other containers execute.

Share

The short version

On April 29, 2026, Theori disclosed CVE-2026-31431, which they call Copy Fail. It is a logic bug in the Linux kernel's algif_aead crypto module, introduced by a 2017 in-place optimization, that lets any unprivileged local user write 4 controlled bytes into the page cache of any file they can read. A 732-byte Python script turns that into root on essentially every distribution shipped since 2017, with no race conditions and no per-distro tuning.

The reason this lands differently on a homelab than on a cloud VM: the page cache is not namespaced. Container isolation (mounts, UIDs, capabilities, seccomp) never touched it. Rootless Podman stops the exploit from becoming host root, but it does not stop the exploit from poisoning the shared cached copy of a binary that your other containers will execute. That is the gap this note works through, and the mitigations at the end close most of it.

How the bug works

Xint's write-up is the best source for the chain, because it shows the mechanism end to end rather than a black-box description [1]. The pieces, in order:

1. AF_ALG exposes kernel crypto to unprivileged users. AF_ALG is a socket family that lets any process request kernel crypto operations. No capability required. The exploit opens a SOCK_SEQPACKET socket and binds it to authencesn(hmac(sha256),cbc(aes)), the AEAD template IPsec uses for Extended Sequence Numbers.

2. splice() delivers page cache pages by reference. splice(2) moves file data through pipes without copying to userspace. The pages it passes are the kernel's cached pages of that file. When you splice a file into an AF_ALG socket, the socket's input scatterlist ends up holding direct references to the page cache pages backing every read(), mmap(), and execve() of that file. The pages are shared with the rest of the system. They are not copies.

3. The 2017 in-place optimization put those pages in a writable list. For AEAD decryption over AF_ALG, the kernel copies the AAD and ciphertext out of the input scatterlist into the receive buffer, but the final authsize bytes (the authentication tag) are chained by reference onto the output scatterlist with sg_chain(). The code then sets req->src = req->dst, so the operation is treated as in-place. The output scatterlist now contains page cache pages, in a region the kernel treats as writable [1].

4. authencesn writes scratch bytes past its own output boundary. authencesn rearranges the 64-bit IPsec sequence number by using the destination buffer as scratch: it reads AAD bytes 0-7, shuffles seqno_hi/seqno_lo around, and writes 4 bytes at offset assoclen + cryptlen, past the tag. No other standard AEAD in the kernel does this; GCM, CCM, and regular authenc all stay inside their output region [1]. In the in-place path, that write walks off the end of the user buffer and lands in the chained page cache page.

5. The write persists; the operation fails; nobody notices. The HMAC over the fabricated ciphertext fails, recvmsg() returns an error, and the kernel never marks the corrupted page dirty for writeback. The on-disk file is byte-for-byte unchanged. On-disk checksumming (AIDE, Tripwire, sha256sum in a cron) sees nothing [1][8].

The attacker controls all three degrees of freedom: which file (anything readable by the current user), which 4-byte offset (via splice offset, splice length, and assoclen), and which 4-byte value (bytes 4-7 of the AAD in sendmsg) [1]. Repeating the primitive at successive offsets stages a payload a few dozen bytes at a time.

The public exploit does exactly that against /usr/bin/su. Andrea Veri's lab notes are the clearest source for the on-the-ground trace, since he disassembled the embedded shellcode before running anything [3]. The payload is a golfed ~233-byte static ELF: setuid(0) followed by execve("/bin/sh"). The strace capture shows the loop:

socket(AF_ALG, SOCK_SEQPACKET|SOCK_CLOEXEC, 0) = 4
bind(4, {sa_family=AF_ALG, salg_type="aead",
         salg_name="authencesn(hmac(sha256),cbc(aes))"}, 88) = 0
sendmsg(5, {msg_iov=[{iov_base="AAAA\177ELF", iov_len=8}]}, MSG_MORE) = 8
splice(3, [0], 7, NULL, 4, 0) = 4

...repeating until the whole malicious ELF sits in the cached pages of su, then execve("/usr/sbin/su") loads the poisoned version and runs it [3]. Disk never changes.

Three properties make this different from Dirty Pipe or the usual LPE fare [1][12]:

  • Deterministic. No race, no retries, no timing. Straight-line logic.
  • Portable. The same 732-byte script rooted Ubuntu 24.04, Amazon Linux 2023, RHEL 10.1, and SUSE 16 in Xint's demo; it only needs Python 3.10+ (for os.splice).
  • Invisible to file integrity tooling. The corruption lives in RAM until eviction or reboot.

Why rootless stops the privesc but not the poisoning

This is the part that matters most for homelabs, and Veri's weekend lab on Fedora 43 + rootless Podman is the best evidence for it, because he ran the real exploit inside a rootless container and watched the kernel decide [3].

Inside the container: the exploit worked. The page cache write landed, the shellcode executed, setuid(0) returned success, and the prompt became root@.... A bpftrace probe on the host (strace misses the call because ptrace on a SUID exec triggers secureexec and blinds the tracer) confirmed the kernel returned 0 for the setuid [3].

On the host: nothing changed, because of the UID map. From inside the container:

$ cat /proc/self/uid_map
0 1000 1
1 100000 65536
65537 524288 65536

The first line is the whole argument: 0 1000 1 means container UID 0 maps to host UID 1000 (Veri's unprivileged podman user). Veri confirmed from the host side that a sleep 100 started from the "root" container shell appeared in ps owned by podman, not root. The exploit's root shell can't touch host system files, can't read /etc/shadow, can't signal host processes. User namespaces did their job [3].

But the page cache write is a different question than privilege, and the namespace boundary does not extend to it. The page cache is keyed by (filesystem device, inode number) and shared by every process on the host, including every container. When Veri published his conclusions that rootless containment worked, he added an edit the next day, and it is the correct summary of where the real exposure is [3]:

While rootless containers prevent the attacker from escalating to host root, the page cache is still shared across the host. Containers that re-use the same base image layers share the same cached pages for those layers. If a malicious CI job corrupts a binary in the page cache, other containers launched from that same image could end up executing the poisoned version.

That is container-to-container isolation failure, not host escape. Rootless raised the cost of the next step (you need to know which images share layers, which of their binaries get executed by other workloads, and when) but did not remove the step.

What gets poisoned on a shared homelab

The mechanism in a multi-container host, following Stream Security's EKS validation (Kubernetes 1.35, kernel 6.12.77, Amazon Linux 2023) [2]:

  1. Container images are layers. When two images share a base layer (say, both built on ubuntu:24.04), the runtime stores that layer once on the node and assembles each container's view with overlayfs. The base layer files are the same physical data, and therefore the same page cache entries.
  2. An attacker in pod A corrupts a shared binary, /usr/bin/cat, in the page cache. In Stream's demo this took about 2 seconds: 58 four-byte writes staging a 233-byte payload.
  3. Pods B and C, which run liveness probes that execute cat /tmp/healthcheck, picked up the poisoned binary within one probe interval (10 seconds). Kubernetes itself executed the attacker's code, no human in the loop. Pod C ran it with its cluster-admin service account.
  4. The node was not affected: the host's own /usr/bin/cat has a different inode on a different filesystem (ext4 vs overlayfs), so it is a different page cache entry. Stream's framing is deliberately precise here: this is container-to-container lateral movement, not a container escape [2].

Which workloads run the poisoned page on a typical homelab? Anywhere a shared binary gets executed by a timer, a probe, or another service:

  • k3s/K8s liveness and readiness probes that invoke shell binaries. Stream's whole demo rode on this [2].
  • CI runners on shared images. If your GitLab or Gitea runners share a base image, one compromised job can plant a payload in a binary that the next job's build step executes. This is Veri's motivating scenario from the GNOME runners [3].
  • Shared base images across compose stacks. A web app and a backup container both built on the same distro base layer share the cached /bin/sh, coreutils, and libc.
  • Setuid binaries inside images. Few images ship setuid-root binaries, which is why the classic su chain usually fails inside containers. But the primitive does not need setuid: it needs any file that will be executed later by a more privileged context (a daemon binary, a probe, another pod's entrypoint). Trend Micro's detection guidance explicitly watches "setuid binary page cache manipulation" and the exec of substitute user binaries, because the substitute-binary variant is the one that generalizes [4].
  • Rootful Docker hosts, where the page cache path can reach host binaries that the runtime re-executes. The in-house research memo walks the specific chain: a container reads the host's runc (via bind mount or shared layer), poisons its page cache, and the next docker exec triggers the corrupted binary from cache [internal memo].

A useful contrast from the research memo: Sean Rickerd's OpenShift testing showed the exploit corrupting page cache but achieving no escalation or escape from restricted-v2 pods, because the restricted seccomp profile blocks AF_ALG socket creation [internal memo]. The seccomp block is the control that actually closes the door, not the pod security posture alone.

Interim mitigations, in priority order

1. Patch the kernel. The upstream fix is commit a664bf3d603d, which reverts the 2017 in-place optimization entirely. Its commit message says it plainly: "There is no benefit in operating in-place in algif_aead since the source and destination come from different mappings." It landed at 7.0-rc7 (confirmed via Debian Security Tracker kernel notes linking to git.kernel.org/linus/a664bf3d603dc3bdcf9ae47cc21e0daec706d7a5). [1][8] Per-distro status as of this note:

Distro Status Source
Ubuntu Kernel fixes released for all releases before 26.04 (Resolute); an interim kmod package (USN-8226-1/-2) blocked the module on disclosure and will be reverted once the kernel fix ships [5][6]
Debian Fixed: bullseye 5.10.251-3 (security backport, DLA-4560-1), bookworm 6.1.170-1 (DSA-6243-1), trixie 6.12.85-1 (DSA-6238-1), unstable 6.19.12-1 [7]
Rocky Linux Patches available; interim mitigation via grubby --update-kernel=ALL --args="initcall_blacklist=algif_aead_init" [9][10]
Fedora Backported into the 6.19.x stable tree from 6.19.12; older stable lines (e.g. 6.17.x) vulnerable until updated [3][11]
Docker Engine v29.4.3+ blocks AF_ALG for containers via AppArmor/SELinux (deny network alg,); v29.4.2's seccomp-only attempt was bypassed via socketcall/int $0x80 and broke 32-bit userspace [internal memo]

2. Disable the module, if you cannot reboot. From CERT/CoE's advisory [8] and the Ubuntu/Rocky notes:

# If algif_aead is a loadable module:
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null

# If it is compiled in (common on some distros), boot parameter instead:
initcall_blacklist=algif_aead_init

Cost is near zero in practice: AF_ALG AEAD is used by IPsec daemons and disk-encryption tooling, not by ordinary workloads. Stream Security notes no web app, agent, or CI pipeline creates AF_ALG sockets in containers [2].

3. Block AF_ALG with seccomp in untrusted workloads. socket(AF_ALG) is the mandatory first step of the exploit, and it is the one call with essentially no legitimate use in a container [2][4]. Trend Micro's alert recommends enforcing a seccomp profile that blocks it for untrusted workloads regardless of patch state [4]. If you run rootful Docker, note the socketcall subtlety: a seccomp filter on socket() alone can be sidestepped on amd64 via the 32-bit int $0x80 path, which is why Docker's second attempt moved enforcement to the LSM layer [internal memo].

4. Break the shared-layer coupling. Blast radius scales with how much your workloads share. Stream's matrix: all workloads on one base image = high; mixed images = medium; unique distroless/scratch images per workload = low; privileged daemonsets sharing base images with untrusted workloads = critical [2]. On a homelab the cheap moves are: don't build your untrusted/CI workloads on the same base as your management workloads, and prefer distroless images for anything that only needs to run one binary.

5. Reboot clears the poison (and the patched kernel). The corruption is in RAM. A reboot drops the page cache and the poisoned pages are gone, which is why the "patch kernel + reboot" sequence is the terminal remediation, and why a reboot window after a suspected compromise is a legitimate hygiene step even before the kernel update lands [1][8].

6. Detection, because the write leaves no disk trace. The syscall sequence is distinctive and correlation is what every vendor converged on [2][4][5]:

  • socket(AF_ALG, SOCK_SEQPACKET) + setsockopt(SOL_ALG, ...) + splice() from a process that has no business touching kernel crypto. Stream's agent fires on that three-event correlation inside containers with near-zero false positives [2].
  • A one-liner to verify your own mitigation is live (from Cloudflare's writeup [12]):
python3 -c 'import socket; s = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0); s.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))'

On a mitigated machine this raises PermissionError or FileNotFoundError instead of succeeding.

  • Trend Micro ships a VSAPI pattern for the known exploit (Trojan.Python.CVE202631431.A), a Falco rule for setuid-binary page cache manipulation, and Observed Attack Techniques for the exec side ("Page Cache Corruption Attack via Substitute User Binary", "Python Process Spawns Substitute User Binary with Privilege Escalation") [4].
  • Axonius frames the exposure from the asset-inventory side: identify every Linux host with a kernel compiled since 2017, and treat untrusted-code environments (K8s, CI, sandboxes) as the priority cohort because the page cache is shared across the host [13].

The HN thread is worth a read for the community texture: most of the discussion centers on the setuid-targeting of the PoC and whether hosts without setuid binaries are actually exposed (they are not immune: the write works on any readable file, so a binary that a privileged process will exec later is a valid target), plus SELinux allowlisting on mobile and notes that the on-disk file never changes, which makes incident response a memory question rather than a file question [14].

What this means for a Malwlab-style host

My setup is the shared-host case: one Proxmox box, Docker on the host, a k3s node, and rootless Podman for the CI-ish work. Copy Fail is the strongest argument I have seen so far for the split I was already leaning on: untrusted code in rootless containers, trusted/management workloads in VMs, and no shared base image between the two groups. Rootless Podman survives this CVE's privesc vector, as Veri demonstrated [3]. What it does not survive is a shared-layer poisoning of a binary that something else executes, and the fix for that is image hygiene plus the seccomp AF_ALG block, not a new kernel per container.

The runc trio from November 2025 (the maskedPaths, /dev/console, and LSM-label chains) got host root from a container that could control its mounts [15]. Copy Fail gets there through a completely different door: no mount control needed, no capability needed, just a readable file and a page cache that outlives the container that poisoned it. They are two sides of the same audit question, which is whether any single container on this host can touch kernel state that another consumer will execute. GhostLock (CVE-2026-43499) is the Ghost-side cousin: an unauthenticated path on a service that fronts my whole stack [16]. The pattern across all three is the same: a boundary everyone assumed held (container runtime, page cache, API auth) turns out to be doing work the documentation never acknowledged.

Follow-ups

  • [ ] Run the Cloudflare one-liner on every host and VM before and after the module blacklist.
  • [ ] Inventory base images across k3s and Docker; flag any pair sharing a layer between trusted and untrusted workloads.
  • [ ] Add the seccomp AF_ALG deny to the runner/compose defaults.
  • [ ] Confirm k3s node kernel is at or above the distro's fixed version; calendar the reboot.
  • [ ] If any incident is suspected: reboot before collecting disk forensics, because disk will look clean.
  • Three runc flaws chain into a container escape to host root (draft): the mount-control path to the same destination (CVE-2025-31133, CVE-2025-52565, CVE-2025-52881).
  • GhostLock, CVE-2026-43499: unauthenticated exposure on the Ghost service fronting the blog.
  • A GGUF File Is Not a Download, It Is a Binary: parsing an untrusted file is executing code; the intake boundary is the same lesson.

Sources

  1. Xint Code (Theori), "Copy Fail: 732 Bytes to Root on Every Major Linux Distribution" (2026-04-29), incl. root-cause walkthrough, demo distros, fix commit, disclosure timeline: https://xint.io/blog/copy-fail-linux-distributions (fetched 2026-09-02)
  2. Petr Zuzanov (Stream Security), "CVE-2026-31431: how Copy Fail behaves in Kubernetes" (2026-05-04), EKS validation, overlayfs/page-cache analysis, detection, mitigation matrix: https://www.stream.security/post/cve-2026-31431-how-copy-fail-behaves-in-kubernetes (fetched 2026-09-02)
  3. Andrea Veri, "CVE-2026-31431: Copy Fail vs. rootless containers" (2026-05-04, edits 2026-05-05), shellcode analysis, rootless Podman lab, bpftrace capture, uid_map proof, shared-layer edit: https://www.dragonsreach.it/2026/05/04/cve-2026-31431-copy-fail-rootless-containers (fetched 2026-09-02)
  4. Trend Micro, "SECURITY ALERT: 'Copy Fail' Linux Kernel Local Privilege Escalation Vulnerability (CVE-2026-31431)" (KA-0023295, updated 2026-05-12), detection guidance and mitigations: https://success.trendmicro.com/en-US/solution/KA-0023295 (fetched 2026-09-02)
  5. Canonical, "Fixes available for CVE-2026-31431 (Copy Fail) Linux Kernel Local Privilege Escalation Vulnerability": https://canonical.com/blog/copy-fail-vulnerability-fixes-available (fetched 2026-09-02)
  6. Ubuntu Security, CVE-2026-31431 status page (CVSS vector, USN references, kmod mitigation): https://ubuntu.com/security/CVE-2026-31431 (fetched 2026-09-02)
  7. Debian Security Tracker, CVE-2026-31431 fixed-version table (DLA-4560-1, DSA-6243-1, DSA-6238-1): https://security-tracker.debian.org/tracker/CVE-2026-31431 (fetched 2026-09-02)
  8. CERT/CoE, "VU#260001 - Linux kernel contains local privilege escalation vulnerability (Copy Fail)" (module-disable and initcall_blacklist mitigations): https://kb.cert.org/vuls/id/260001 (fetched 2026-09-02)
  9. Rocky Linux Forum, "CVE-2026-31431: Copy Fail - Linux kernel crypto vulnerability" (community thread, grubby interim fix): https://forums.rockylinux.org/t/cve-2026-31431-copy-fail-linux-kernel-crypto-vulnerability/20375 (fetched 2026-09-02)
  10. Rocky Linux Forum, "CopyFail (CVE-2026-31431): Patches Now Available for Rocky Linux": https://forums.rockylinux.org/t/copyfail-cve-2026-31431-patches-now-available-for-rocky-linux/20422 (fetched 2026-09-02)
  11. Fedora Discussion, "Is the Copy Fail (CVE-2026-31431) vulnerability already patched in Fedora 43?" (6.19.12 backport): https://discussion.fedoraproject.org/t/is-the-copy-fail-cve-2026-31431-vulnerability-already-patched-in-fedora-43/190016 (fetched 2026-09-02)
  12. Cloudflare, "How Cloudflare responded to the 'Copy Fail' Linux vulnerability": https://blog.cloudflare.com/copy-fail-linux-vulnerability-mitigation (fetched 2026-09-02)
  13. Axonius, "Copy Fail (CVE-2026-31431): Identify & fix vulnerable assets": https://www.axonius.com/blog/cve-2026-31431-copy-fail (fetched 2026-09-02)
  14. Hacker News, "Copy Fail" discussion: https://news.ycombinator.com/item?id=47952181 (fetched 2026-09-02)
  15. Internal MALWLAB draft, "Lab note: three runc flaws chain into a container escape to host root" (draft.md, 2026-08-29): CVE-2025-31133, CVE-2025-52565, CVE-2025-52881, fixed in runc 1.2.8/1.3.3/1.4.0-rc.3.
  16. Internal MALWLAB draft, "GhostLock" CVE-2026-43499.
  17. [internal memo] In-house research memo (cve-2026-31431-research.md) (2026-07-22): Docker Engine v29.4.2/v29.4.3 fix details, socketcall/int $0x80 bypass, Sean Rickerd OpenShift restricted-v2 results, runc/proc-self-exe host chain.
  18. NVD, CVE-2026-31431 (CWE-669, CISA KEV entry added 2026-05-01, due 2026-05-15): https://nvd.nist.gov/vuln/detail/cve-2026-31431 (fetched 2026-09-02)
Topics: