
Cross-platform C port of the Copy Fail Linux LPE (CVE-2026-31431). Disclosed 2026-04-29 by Theori / Xint.
English (en) ∙ 日本語 (ja) ∙ 简体中文 (zh-cn) ∙ 한국어 (ko) ∙ Русский (ru)
A cross-platform C reimplementation of the Copy Fail Linux LPE (CVE-2026-31431), disclosed 2026-04-29 by Theori / Xint. See the canonical writeup at copy.fail for the full vulnerability description, timeline, and Theori's discovery process.
The publicly-released proof-of-concept is a 732-byte Python script. This C port demonstrates that the same exploit can be expressed as portable C compilable to any architecture nolibc supports, with no per-arch hex blobs or inline assembly in the project's own source.
Author of this port: Tony Gies [email protected]. Discovery and original disclosure: Theori / Xint.
copy-fail-c/
├── exploit.c the dropper (binary-mutation variant)
├── exploit-passwd.c the dropper (/etc/passwd UID-flip variant)
├── vulnerable.c non-destructive vulnerability checker
├── payload.c the body that gets dropped (setgid+setuid+execve sh)
├── utils.c, utils.h shared AF_ALG/splice page-cache mutation primitive
├── Makefile build orchestration
├── nolibc/ vendored from torvalds/linux tools/include/nolibc
└── README.md this file
After make:
├── payload tiny static ELF, embedded into the dropper as bytes
├── payload.o payload wrapped as a relocatable .o by `ld -r -b binary`
├── exploit dropper, binary-mutation variant
├── exploit-passwd dropper, /etc/passwd UID-flip variant
└── vulnerable non-destructive vulnerability checker
exploit.c opens the target binary read-only, then for each 4-byte window of
the embedded payload runs one bogus AEAD-decrypt through AF_ALG whose
ciphertext input is supplied via splice() from the target's page-cache pages.
The authencesn template's in-place optimization treats the splice'd source
pages as both the ciphertext input and the plaintext destination, so the
(failing) decrypt has already overwritten the page-cache page by the time
authentication verification rejects the request. After 4 * N iterations the
target's cached image has been replaced byte-for-byte with the payload.
execve()'ing the target loads the mutated pages; the on-disk inode is still
setuid root, so the kernel grants root credentials and runs the payload.
payload.c is plain portable C: setgid(0); setuid(0); execve("/bin/sh", ...). nolibc supplies the _start, the syscall machinery, and the per-arch
register-juggling.
A second variant, exploit-passwd.c, mutates four bytes of /etc/passwd's page
cache instead of a setuid binary's image. It needs no embedded payload and
works on systems where the binary-mutation route is blocked, but its cashout
surface is much narrower.
vulnerable.c is not an exploit. It creates a local testfile containing the
string init, then runs the same patch_chunk() primitive against that file's
own page cache to overwrite the bytes with vulnerable. If the read-back
contents match, the running kernel is in-window for CVE-2026-31431. The
on-disk inode is never modified; testfile is removed on exit; the page-cache
mutation evaporates with it. Runs unprivileged. Exits 100 if vulnerable, 0
if the primitive ran but the mutation did not take, 2 if the AF_ALG socket
family or authencesn template is unavailable so patch state cannot be
determined, and 1 for other runtime errors.
Default (host-arch native):
make
Cross-compile to aarch64 (or any other Linux arch a cross-toolchain is installed for):
make CC=aarch64-linux-gnu-gcc LD=aarch64-linux-gnu-ld
Architectures supported by the vendored nolibc (per upstream): x86_64, i386,
arm, aarch64, riscv32/64, mips, ppc, s390x, loongarch, m68k, sh, sparc.
nolibc dispatches on the compiler's arch macros, so picking the right
CC/LD is sufficient.
Required to build:
cc, gcc, or any cross variant)ld -r -b binary (binutils ld and lld both do)linux/if_alg.h and <asm/unistd.h>
(Debian/Ubuntu: linux-libc-dev; cross variants: typically pulled in by
the cross-toolchain package)Header sets older than Linux 5.6 predate __kernel_old_time_t and
struct __kernel_old_timespec, which the vendored nolibc uses. compat.h
(force-included into the payload build) supplies them when absent, so an
older linux-libc-dev still builds. It is a no-op on 5.6+ headers.
There are no external library dependencies. The payload is built freestanding
against nolibc; the dropper links against the host libc only for fprintf
and perror.
A few small toolchain features carry most of the weight in keeping the source portable and the payload small.
nolibc/ is the kernel's tiny header-only libc replacement, vendored from
torvalds/linux tools/include/nolibc/. It provides _start, a portable
syscall() macro, and inline syscall wrappers, with the per-arch register
conventions encoded in nolibc/arch-*.h. Building the payload with
-nostdlib -static -ffreestanding -Inolibc produces a tiny static ELF that
calls into the kernel directly without dragging in glibc startup, TLS init,
or stack-canary plumbing. Result once packed and section-stripped (both
below): ~720 bytes on x86_64, ~1.2 KB on aarch64, versus ~17 KB for the same
payload.c linked against musl-static or ~700 KB against glibc-static.
ld -r -b binary for embeddingThe Makefile turns the built payload ELF into payload.o via ld -r -b binary -o payload.o payload. The linker emits the input bytes verbatim as
the data section of a relocatable object file and synthesizes three symbols
from the input filename:
_binary_payload_start address of first payload byte
_binary_payload_end address one past the last payload byte
_binary_payload_size absolute symbol whose value is the size in bytes
exploit.c declares the first two as extern const unsigned char[] and
computes the size as _binary_payload_end - _binary_payload_start.
-Wl,-N plus tight max-page-sizeThe payload is statically linked with -Wl,-N -Wl,-z,max-page-size=0x10,
which collapses .text/.rodata/.data into a single LOAD segment with
16-byte file-alignment instead of the kernel-page-aligned 4 KB-per-segment
default. This produces an "RWX permissions" warning from ld, which is
informational only - the payload's runtime memory protection doesn't matter to
its single-purpose program. Without this flag, the same code links to ~13 KB
on x86_64 (mostly inter-segment zero padding); with it, ~1.3 KB before the
section-header strip below.
After linking, objcopy --strip-section-headers removes the payload's section
header table and .shstrtab. The kernel ELF loader maps a program from its
program headers alone, so those bytes never load at runtime, and because
payload is embedded verbatim they also inflate the drop and the
patch_chunk iteration count. Stripping them takes the x86_64 payload from
~1.3 KB to 720 bytes (322 four-byte iterations down to 180). Two link-time
flags shave the rest: -Wl,--build-id=none drops the build-id note, and
-fcf-protection=none drops the x86 CET note where the compiler supports it.
The strip needs binutils >= 2.40. Cross builds pass the target's objcopy via
OBJCOPY= (e.g. OBJCOPY=aarch64-linux-gnu-objcopy); when objcopy cannot do
it, the build prints a note and keeps a valid, larger payload.