ROP: costruire codice quando non puoi iniettarlo
Per anni l'exploit di uno stack overflow finiva così: scrivi la tua shellcode
nel buffer, sovrascrivi l'indirizzo di ritorno per puntarci dentro, e la CPU
esegue i tuoi byte. Poi è arrivato NX (No-eXecute, la bit
W^X): le pagine sono scrivibili oppure eseguibili, mai
entrambe. Lo stack è dati, quindi non esegue. La shellcode nel buffer diventa
byte inerti.
Il return-oriented programming aggira il problema alla radice: non serve iniettare codice se il codice che ti serve è già mappato. Il testo del binario e della libc sono eseguibili per definizione. Il trucco è usarli a pezzi.
Il gadget
Un gadget è una sequenza corta di istruzioni che termina con
ret. Per esempio:
pop rdi
ret
Questo gadget preleva un valore dallo stack in rdi e poi ritorna.
Il ret è la chiave: preleva il prossimo indirizzo dallo stack e ci
salta. Se lo stack lo controlli tu, controlli quale gadget viene eseguito dopo.
La catena diventa un elenco di indirizzi di gadget intervallati dai dati che ogni
gadget consuma.
Perché pop rdi conta
Nella convenzione di chiamata System V (Linux x86-64) i primi argomenti interi
passano in rdi, rsi, rdx, rcx, r8, r9. Per chiamare
system("/bin/sh") devi mettere il puntatore alla stringa in
rdi e saltare a system. Ecco perché
pop rdi; ret è il gadget più corteggiato dell'intera disciplina.
# catena concettuale sullo stack, dall'indirizzo di ritorno in poi:
[ &(pop rdi; ret) ] # gadget: carica il prossimo qword in rdi
[ &"/bin/sh" ] # dato consumato dal pop
[ &system ] # ret salta qui, con rdi già impostato
Il problema dell'allineamento dello stack
Un dettaglio che fa perdere ore: la ABI richiede che rsp sia
allineato a 16 byte al momento di una call. Molte funzioni di libc
moderne usano istruzioni SSE (movaps) che segfaultano
se lo stack è disallineato. Se la tua catena chiama system e crasha
dentro do_system con un movaps, non è un bug tuo: aggiungi
un ret a vuoto prima della chiamata per riallineare.
[ &(ret) ] # gadget di allineamento: consuma 8 byte, riallinea rsp
[ &(pop rdi; ret) ]
[ &"/bin/sh" ]
[ &system ]
ASLR: senza un leak non vai da nessuna parte
Con ASLR gli indirizzi della libc cambiano a ogni esecuzione, quindi non puoi
scrivere &system a mano. Ti serve un leak: far
stampare al programma un indirizzo che punta dentro la libc, calcolarne la base
per differenza, e da lì derivare tutto il resto.
Lo schema classico in due fasi, quando hai una sola primitiva di scrittura ma
il binario chiama puts:
# Fase 1: leak. Stampa l'indirizzo GOT di puts, poi torna a main.
[ &(pop rdi; ret) ]
[ &puts@got ] # rdi = puntatore alla entry GOT di puts
[ &puts@plt ] # puts(puts@got) -> stampa l'indirizzo runtime di puts
[ &main ] # ritorna a main per una seconda mandata di input
Ricevuto il leak, calcoli la base:
libc_base = leaked_puts - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
La seconda mandata di input usa gli indirizzi ormai noti per
la catena finale verso system("/bin/sh"). Il presupposto è che tu
conosca la versione esatta della libc del bersaglio: il leak di un solo simbolo
più il famoso libc database (offset noti per ogni build) di solito basta
a identificarla.
Quando i gadget non bastano: ret2csu
A volte il binario è minuscolo e non contiene un pop rdx; ret, che
ti serve per il terzo argomento (pensa a execve, o a
mprotect(addr, len, RWX)). Il salvavita si chiama
ret2csu: sfrutta due blocchi che il linker inserisce
sempre nei binari collegati dinamicamente, dentro
__libc_csu_init.
Il primo blocco fa una sfilza di pop che riempie
rbx, rbp, r12, r13, r14, r15; il secondo copia
r13/r14/r15 in rdx/rsi/rdi (edi) e poi chiama
call [r12+rbx*8]. Impostando rbx=0 e r12 a
un puntatore a puntatore a funzione, ottieni il controllo di tre registri di
argomento e una chiamata indiretta, il tutto senza gadget dedicati. È il coltellino
svizzero quando il binario è spoglio.
Come si trovano i gadget
ROPgadget --binary ./target | grep ': pop rdi'
# oppure, molto più comodo, dentro pwntools:
from pwn import *
elf = ELF('./target')
rop = ROP(elf)
rop.raw(rop.find_gadget(['pop rdi', 'ret']))
rop.call('system', [next(elf.search(b'/bin/sh'))])
Dove ci si difende oggi
ROP non è imbattibile. Le contromisure moderne attaccano proprio la sua
meccanica: CET Shadow Stack tiene una copia protetta degli
indirizzi di ritorno e scatta se un ret non corrisponde; le
stack canary intercettano l'overflow lineare prima che
raggiunga l'indirizzo di ritorno; il CFI (Control-Flow
Integrity) verifica che i salti indiretti finiscano su target legittimi. Ma su
codice legacy, driver, firmware e binari compilati senza queste protezioni, la
catena di gadget resta la tecnica di riferimento per trasformare una scrittura
fuori dai limiti in esecuzione di codice.