Fuzzing guidé par couverture avec libFuzzer et AFL++
Écrire un harnais de fuzzing, le compiler avec des sanitizers, lancer libFuzzer et AFL++, gérer corpus et dictionnaires, et fuzzer en continu en CI.
Le fuzzing alimente un programme avec de grandes quantités d'entrées générées automatiquement et guette les crashs. Les fuzzers guidés par couverture rendent cela nettement plus efficace : ils instrumentent la cible, conservent toute entrée qui atteint du code nouveau, et mutent davantage ces entrées. Combiné aux sanitizers, qui transforment une corruption mémoire silencieuse en crash immédiat, le fuzzing guidé par couverture est la façon la plus productive de trouver des bugs de sûreté mémoire dans les parseurs, décodeurs et gestionnaires de protocoles. Ce guide montre comment écrire un harnais, le lancer sous libFuzzer et AFL++, et le maintenir en fonctionnement en CI.
Comment fonctionne le guidage par couverture
+-----------+ mutate +-----------+
| corpus | ------------> | new input |
+-----------+ +-----+-----+
^ |
| keep if new v run instrumented target
| coverage +---------------+
+------------------ | coverage map | --> crash? save it
+---------------+
Le compilateur insère des compteurs légers sur les arêtes entre blocs de base. Après chaque exécution, le fuzzer compare la carte de couverture avec tout ce qu'il a déjà vu. Les entrées qui atteignent de nouvelles arêtes rejoignent le corpus et deviennent des parents pour les mutations futures. Au fil du temps, le corpus s'enfonce plus profondément dans la logique du programme, bien au-delà de ce que des entrées aléatoires pourraient atteindre.
Écrire un harnais
Un harnais est une petite fonction qui prend un tampon d'octets et le passe au code testé. La convention libFuzzer, également comprise par AFL++, honggfuzz et OSS-Fuzz, est :
#include <stddef.h>
#include <stdint.h>
#include "config_parser.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
struct config cfg;
if (config_parse(&cfg, data, size) == 0) {
config_free(&cfg);
}
return 0; /* les valeurs de retour non nulles sont réservées */
}
Les bons harnais partagent quelques propriétés :
| Propriété | Pourquoi |
|---|---|
| Déterministe | La même entrée doit produire le même comportement, sinon les crashs ne se reproduiront pas |
| Rapide | Visez des milliers d'exécutions par seconde ; évitez le disque, le réseau et les temporisations |
| Aucune fuite d'état global | Libérez ce que vous allouez, réinitialisez les globales, sinon fuites et rémanence d'état masquent les bugs |
| Point d'entrée étroit | Fuzzez le parseur directement, pas toute l'application autour de lui |
| Exerce pleinement l'API | S'il y a un sérialiseur, faites un aller-retour de parsing et de sérialisation pour atteindre plus de code |
Pour les API structurées prenant plusieurs arguments, FuzzedDataProvider (un en-tête fourni avec Clang) découpe l'entrée en valeurs typées :
#include <fuzzer/FuzzedDataProvider.h>
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
FuzzedDataProvider fdp(data, size);
auto mode = fdp.ConsumeIntegralInRange<int>(0, 3);
auto key = fdp.ConsumeRandomLengthString(64);
auto value = fdp.ConsumeRemainingBytes<uint8_t>();
kv_store_put(mode, key.c_str(), value.data(), value.size());
return 0;
}
Lancer libFuzzer
libFuzzer est intégré à Clang :
clang -g -O1 -fsanitize=fuzzer,address,undefined \
config_parser.c fuzz_config.c -o fuzz_config
mkdir -p corpus
cp tests/fixtures/*.conf corpus/ # amorcer avec de vrais exemples valides
./fuzz_config corpus/ -max_len=4096 -jobs=4 -workers=4
Options utiles :
| Option | Rôle |
|---|---|
corpus/ | Répertoire d'entrées d'amorçage ; les nouvelles entrées intéressantes y sont réécrites |
-max_len=N | Borne supérieure sur la taille des entrées |
-dict=file.dict | Jetons à insérer dans les entrées (mots-clés, nombres magiques) |
-jobs=N -workers=N | Exécutions parallèles |
-max_total_time=S | S'arrête après S secondes, utile en CI |
-runs=0 corpus/ | Exécute le corpus une fois sans fuzzer, pour détecter les régressions |
Quand un crash survient, libFuzzer affiche le rapport du sanitizer et écrit l'entrée sous la forme crash-<sha1>. Reproduisez-le en passant le fichier en argument : ./fuzz_config crash-<sha1>.
Amorces et dictionnaires
Un bon corpus d'amorçage est le plus grand accélérateur à lui seul. Utilisez de vrais fichiers valides issus de votre suite de tests ; le fuzzer mute alors à partir d'une structure porteuse de sens au lieu de la découvrir de zéro. Pour les formats textuels ou fondés sur des jetons, un dictionnaire aide le fuzzer à franchir les comparaisons de mots-clés :
# config.dict
kw_section="[section]"
kw_include="include"
kw_true="true"
magic="\x7fCFG"
Lancer AFL++
AFL++ est un successeur d'AFL maintenu par la communauté, avec de nombreuses améliorations. Il peut compiler le même harnais LLVMFuzzerTestOneInput, ou fuzzer un programme qui lit depuis un fichier ou l'entrée standard.
# Compiler avec l'instrumentation LLVM d'AFL++ et ASan
export AFL_USE_ASAN=1
afl-clang-fast -g -O1 -fsanitize=fuzzer config_parser.c fuzz_config.c -o fuzz_config_afl
# Ou instrumenter un programme entier qui lit un fichier
CC=afl-clang-lto ./configure && make
afl-fuzz -i corpus/ -o findings/ -x config.dict -- ./fuzz_config_afl
Quelques fonctionnalités d'AFL++ à connaître :
- L'instrumentation LTO (
afl-clang-lto) attribue des identifiants d'arête sans collision au moment de l'édition de liens, ce qui améliore la précision de la couverture. - Le mode persistant exécute de nombreuses entrées dans un même processus, la même idée que libFuzzer ; compiler un harnais de style libFuzzer avec
-fsanitize=fuzzersousafl-clang-fastvous donne cela automatiquement. - CmpLog (
-cavec un binaire CmpLog compilé séparément) capture les opérandes de comparaison afin que le fuzzer puisse résoudre les valeurs magiques et les sommes de contrôle que la mutation pure atteint rarement. - Le fuzzing parallèle : lancez une instance principale (
-M) et plusieurs secondaires (-S) partageant le même répertoire de sortie.
Triage et minimisation
Une campagne accumule rapidement des entrées qui font planter, dont beaucoup sont des doublons. Réduisez et dédupliquez avant d'ouvrir des tickets de bugs :
# libFuzzer : minimiser une entrée qui plante tout en conservant le crash
./fuzz_config -minimize_crash=1 -runs=10000 crash-<sha1>
# libFuzzer : minimiser un corpus au plus petit ensemble ayant la même couverture
./fuzz_config -merge=1 corpus_min/ corpus/
# AFL++ : minimiser une entrée, et le corpus
afl-tmin -i findings/default/crashes/id:000000* -o crash.min -- ./fuzz_config_afl
afl-cmin -i findings/default/queue -o corpus_min -- ./fuzz_config_afl
Regroupez les crashs par les cadres de tête du rapport du sanitizer et par type de bug, puis corrigez la cause racine, ajoutez l'entrée minimisée à vos tests de régression et relancez le corpus. Le guide de triage des crashs couvre la déduplication et l'évaluation de la gravité plus en profondeur.
Fuzzing continu
Le fuzzing paie le plus quand il tourne en continu et que le nouveau code est fuzzé dès qu'il arrive.
| Option | Ce que c'est | Convient à |
|---|---|---|
| OSS-Fuzz | Le service gratuit de fuzzing continu de Google pour les projets open source critiques, bâti sur ClusterFuzz | Les projets open source acceptés dans le programme |
| ClusterFuzzLite | Une version légère qui tourne dans votre propre CI (GitHub Actions, GitLab et d'autres) | Tout projet, y compris du code privé |
| Fuzzing de vérification en CI | Lancer chaque harnais quelques minutes à chaque pull request avec -max_total_time | Détecter les régressions tôt |
| ClusterFuzz auto-hébergé | La plateforme complète sur votre propre infrastructure | Les grandes organisations avec de nombreuses cibles |
Une étape CI minimale peut se résumer à compiler les fuzzers avec des sanitizers, exécuter le corpus stocké avec -runs=0 pour détecter les régressions, puis fuzzer pendant un budget de temps fixe et téléverser tout fichier crash-* en tant qu'artefact.
Au-delà du C et du C++
La même approche fonctionne dans les langages sûrs pour la mémoire, où elle trouve des panics, des boucles infinies, des allocations excessives et des bugs de logique :
- Rust :
cargo fuzz(basé sur libFuzzer), plusarbitrarypour les entrées structurées. - Go : fuzzing natif avec
func FuzzXxx(f *testing.F)depuis Go 1.18. - Python : Atheris. Java/JVM : Jazzer.
Checklist
- Écrivez un harnais par parseur ou décodeur, ciblant le point d'entrée utile le plus étroit.
- Compilez les fuzzers avec ASan et UBSan ; amorcez-les avec de vraies entrées valides et ajoutez un dictionnaire pour les formats riches en jetons.
- Suivez la couverture ; quand elle plafonne, améliorez le harnais ou les amorces plutôt que d'ajouter seulement du CPU.
- Minimisez et dédupliquez les crashs, corrigez la cause racine et conservez chaque entrée de crash comme test de régression.
- Lancez les fuzzers en continu avec OSS-Fuzz ou ClusterFuzzLite, et brièvement à chaque pull request.