Nell'ambito della programmazione e dello sviluppo software, il fuzzing (o fuzz testing) è una tecnica automatizzata di test del software che consiste nel fornire in input a un programma dati non validi, inattesi o casuali. Il programma viene quindi monitorato per individuare anomalie quali crash, il fallimento di asserzioni interne al codice o potenziali memory leak. Tipicamente i fuzzer sono utilizzati per testare programmi che accettano input strutturati. Tale struttura è specificata, ad esempio, in un formato di file o in un protocollo, e distingue gli input validi da quelli non validi. Un fuzzer efficace genera input semi-validi, cioè "sufficientemente validi" da non essere rifiutati direttamente dal parser, ma "sufficientemente non validi" da provocare comportamenti inattesi nelle parti più profonde del programma ed esporre casi limite non gestiti correttamente.
A fini di sicurezza, gli input che attraversano un confine di fiducia (trust boundary) sono spesso i più utili. Ad esempio, è più importante sottoporre a fuzzing il codice che gestisce un file caricato da un utente qualsiasi rispetto a quello che analizza un file di configurazione accessibile solo a un utente privilegiato.
Dalla diffusione, a partire dal 2013, dei fuzzer coverage-guided come AFL, il fuzzing è divenuto una delle tecniche più efficaci per la scoperta automatica di vulnerabilità ed è oggi integrato nei processi di sviluppo di grandi progetti software.
Storia
Precursori: il test con input casuali Il test di un programma con input casuali è una pratica che precede il fuzzing vero e proprio ed è nota anche come random testing, monkey testing o Monte Carlo debugging (in analogia con i metodi Monte Carlo).
Nel 1981 Duran e Ntafos indagarono formalmente l'efficacia del test di un programma con input casuali. Sebbene il random testing fosse ampiamente percepito come il peggior metodo per testare un programma, gli autori dimostrarono che si tratta di un'alternativa economicamente vantaggiosa rispetto a tecniche di test più sistematiche. Nel 1983 Steve Capps presso Apple sviluppò "The Monkey", uno strumento che generava input casuali per le applicazioni del Mac OS classico, come MacPaint. La "scimmia" figurativa rimanda al teorema delle scimmie infinite. Nel 1991 fu rilasciato lo strumento crashme, concepito per verificare la robustezza dei sistemi operativi Unix e Unix-like eseguendo casualmente chiamate di sistema con parametri scelti a caso.
La nascita del «fuzz» (1988) Il termine fuzz nasce da un progetto del 1988 svolto nell'ambito del corso magistrale di sistemi operativi avanzati (CS736) tenuto dal professor Barton Miller presso l'Università del Wisconsin-Madison, i cui risultati furono successivamente pubblicati nel 1990. Sottoporre a fuzz testing una utility UNIX significava generare automaticamente input e parametri da riga di comando casuali per il programma. Il progetto era concepito per verificare l'affidabilità dei programmi a riga di comando di UNIX eseguendo un gran numero di input casuali in rapida successione fino a provocarne il crash. Il gruppo di Miller riuscì a mandare in crash dal 25 al 33 per cento delle utility testate. Successivamente effettuò il debug di ciascun crash per determinarne la causa e classificò ogni anomalia rilevata. Per consentire ad altri ricercatori di condurre esperimenti analoghi su altro software, il codice sorgente degli strumenti, le procedure di test e i dati grezzi furono resi pubblicamente disponibili. Questa forma primordiale di fuzzing sarebbe oggi definita fuzzing black box, generazionale e non strutturato (dumb o "classico").
Secondo Barton Miller:
Un contributo chiave di questo lavoro pionieristico fu un oracolo di test semplice (quasi semplicistico): un programma falliva il test se andava in crash o si bloccava sotto l'input casuale, e veniva altrimenti considerato superato. Sebbene la costruzione degli oracoli di test possa risultare complessa, l'oracolo di questi primi fuzz test era semplice e universalmente applicabile.
Diffusione e industrializzazione Nell'aprile 2012 Google annunciò ClusterFuzz, un'infrastruttura di fuzzing basata su cloud per i componenti critici dal punto di vista della sicurezza del browser Chromium. I ricercatori di sicurezza possono caricare i propri fuzzer e ottenere bug bounty se ClusterFuzz individua un crash con il fuzzer caricato.
Nel settembre 2014 Shellshock fu reso noto come una famiglia di vulnerabilità della shell Bash di UNIX, ampiamente diffusa; la maggior parte delle vulnerabilità di Shellshock fu individuata tramite il fuzzer AFL. Molti servizi esposti su Internet, come alcune installazioni di server web, utilizzano Bash per elaborare determinate richieste, consentendo a un attaccante di indurre le versioni vulnerabili di Bash a eseguire comandi arbitrari.
Nell'aprile 2015 Hanno Böck mostrò come il fuzzer AFL avrebbe potuto individuare la vulnerabilità Heartbleed del 2014. Heartbleed è una grave vulnerabilità, introdotta accidentalmente in OpenSSL, che consente a un attaccante di decifrare comunicazioni altrimenti cifrate. Nel gennaio 2017 Shodan segnalava ancora circa 200.000 macchine potenzialmente vulnerabili.
Nell'agosto 2016 la Defense Advanced Research Projects Agency (DARPA) tenne la finale della prima Cyber Grand Challenge, una competizione di tipo capture the flag completamente automatizzata, durata 11 ore. L'obiettivo era sviluppare sistemi in grado di scoprire, sfruttare e correggere in tempo reale i difetti del software; il fuzzing fu impiegato come efficace strategia offensiva. Il sistema vincitore fu "Mayhem", sviluppato dal gruppo ForAllSecure guidato da David Brumley.
Sempre nel 2016 Microsoft annunciò Project Springfield, un servizio di fuzz testing basato su cloud, e Google annunciò OSS-Fuzz, che consente il fuzzing continuo di diversi progetti open source critici per la sicurezza. Alla conferenza Black Hat del 2018, Christopher Domas dimostrò l'uso del fuzzing per rivelare l'esistenza di un core RISC nascosto all'interno di un processore. Nel settembre 2020 Microsoft rilasciò OneFuzz, una piattaforma di fuzzing-as-a-service auto-ospitabile, poi archiviata il 1º novembre 2023.
Funzionamento La maggior parte dei fuzzer moderni condivide un modello algoritmico comune, descritto in letteratura come un ciclo iterativo di generazione ed esecuzione di input. A partire da una configurazione iniziale, il fuzzer ripete le seguenti fasi finché non esaurisce il proprio budget di tempo o risorse: seleziona l'input o il seed successivo (scheduling), genera un nuovo caso di test, lo esegue osservando il comportamento del programma e aggiorna il proprio stato in base a ciò che ha osservato. Gli input che si rivelano interessanti, ad esempio perché aumentano la copertura del codice o provocano un fallimento, vengono conservati e riutilizzati.
Preparazione e seed Nella fase iniziale (preprocessing) il fuzzer viene predisposto per la campagna: il programma bersaglio può essere strumentato, e viene raccolto un insieme di input iniziali detti seed. Il corpus di seed può contenere migliaia di input potenzialmente simili; la selezione automatica dei seed (o riduzione della suite di test) consente di scegliere i seed migliori per massimizzare il numero totale di bug individuati durante una campagna di fuzzing.
Scheduling A ogni iterazione il fuzzer deve decidere quale input della propria collezione utilizzare come base per il caso di test successivo e quante risorse (energia) dedicargli. Una scelta oculata dello scheduling migliora sensibilmente l'efficienza: AFLFast, ad esempio, modella il fuzzing coverage-guided come una catena di Markov e assegna più energia ai seed che esercitano percorsi di programma raramente raggiunti.
Generazione degli input Un fuzzer mutation-based (basato sulla mutazione) sfrutta il corpus di seed esistenti, generando nuovi input modificando (o meglio, mutando) quelli forniti. Ad esempio, nel fuzzing della libreria di immagini libpng, l'utente fornisce alcuni file PNG validi come seed, mentre il fuzzer li modifica per produrre varianti semi-valide. Un fuzzer generation-based (basato sulla generazione) genera invece gli input da zero, tipicamente a partire da un modello di input; a differenza di quelli mutation-based, non dipende dall'esistenza o dalla qualità di un corpus di seed. Alcuni fuzzer sono in grado di fare entrambe le cose.
Esecuzione e osservazione Ogni input generato viene fornito al programma, di cui si osserva il comportamento. Per esporre i bug, un fuzzer deve distinguere il comportamento atteso (normale) da quello inatteso (anomalo); poiché una macchina non sempre riesce a distinguere un bug da una funzionalità, ciò è noto come problema dell'oracolo di test. Tipicamente un fuzzer distingue tra input che provocano un crash e input che non lo provocano: i crash sono facilmente identificabili e possono indicare potenziali vulnerabilità (ad esempio denial of service o esecuzione di codice arbitrario). Tuttavia l'assenza di crash non indica l'assenza di vulnerabilità: un programma scritto in C, ad esempio, può andare in crash oppure no in presenza di un buffer overflow, poiché il suo comportamento è indefinito.
Per rendere un fuzzer più sensibile a fallimenti diversi dai crash si utilizzano dei sanitizer, che iniettano asserzioni per mandare in crash il programma quando viene rilevato un fallimento. Esistono diversi sanitizer per diverse classi di bug:
errori di memoria come buffer overflow e use-after-free (AddressSanitizer); race condition e deadlock (ThreadSanitizer); comportamenti indefiniti (UndefinedBehaviorSanitizer); memory leak (LeakSanitizer); violazioni dell'integrità del flusso di controllo (CFISanitizer).
Nei fuzzer coverage-guided l'esecuzione è inoltre osservata tramite strumentazione che traccia gli elementi di codice (blocchi di base o transizioni) esercitati dall'input, così da misurare l'aumento di copertura del codice. In assenza di un'implementazione di riferimento è anche possibile ricorrere al differential testing: gli stessi input vengono eseguiti su due implementazioni distinte dello stesso programma (ad esempio lighttpd e httpd, entrambi server web) e una divergenza negli output segnala un possibile bug in una delle due.
Triage e minimizzazione Un fuzzer produce un enorme numero di input in poco tempo: nel 2016 il progetto Google OSS-Fuzz ne generava circa 4000 miliardi alla settimana. Per questo molti fuzzer includono una toolchain che automatizza le attività successive alla generazione degli input che inducono il fallimento. Il triage automatico raggruppa gli input in base alla causa radice e ne stabilisce la priorità in base alla gravità, poiché molti input distinti possono esporre lo stesso bug e solo alcuni sono critici per la sicurezza. Ad esempio, i Linux triage tools del CERT Coordination Center raggruppano i crash in base allo stack trace e ne stimano la probabilità di sfruttamento, mentre lo strumento "!exploitable" di Microsoft assegna a ciascun crash una valutazione di sfruttabilità. La minimizzazione automatica dell'input (o test case reduction) isola invece la porzione di input che effettivamente induce il fallimento, rimuovendo quanti più byte possibile pur continuando a riprodurre il bug: il Delta Debugging ne è un esempio, basato su un algoritmo di ricerca dicotomica esteso.
Classificazione dei fuzzer Un fuzzer può essere classificato secondo tre assi tra loro ortogonali:
generation-based o mutation-based, a seconda che gli input siano generati da zero o modificando input esistenti (vedi #Generazione degli input); dumb (non strutturato) o smart (strutturato), a seconda che sia consapevole o meno della struttura dell'input; white box, gray box o black box, a seconda che sia consapevole o meno della struttura del programma.
Consapevolezza della struttura del programma