Aller au contenu

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.

Publié le 6 min de lecture

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éterministeLa même entrée doit produire le même comportement, sinon les crashs ne se reproduiront pas
RapideVisez des milliers d'exécutions par seconde ; évitez le disque, le réseau et les temporisations
Aucune fuite d'état globalLibérez ce que vous allouez, réinitialisez les globales, sinon fuites et rémanence d'état masquent les bugs
Point d'entrée étroitFuzzez le parseur directement, pas toute l'application autour de lui
Exerce pleinement l'APIS'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 :

OptionRôle
corpus/Répertoire d'entrées d'amorçage ; les nouvelles entrées intéressantes y sont réécrites
-max_len=NBorne supérieure sur la taille des entrées
-dict=file.dictJetons à insérer dans les entrées (mots-clés, nombres magiques)
-jobs=N -workers=NExécutions parallèles
-max_total_time=SS'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=fuzzer sous afl-clang-fast vous donne cela automatiquement.
  • CmpLog (-c avec 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.

OptionCe que c'estConvient à
OSS-FuzzLe service gratuit de fuzzing continu de Google pour les projets open source critiques, bâti sur ClusterFuzzLes projets open source acceptés dans le programme
ClusterFuzzLiteUne 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 CILancer chaque harnais quelques minutes à chaque pull request avec -max_total_timeDétecter les régressions tôt
ClusterFuzz auto-hébergéLa plateforme complète sur votre propre infrastructureLes 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), plus arbitrary pour 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.

Guides associés