Aller au contenu
Sécurité medium

Fuzzing : trouver les bugs par injection de données

12 min de lecture

En avril 2014, la faille Heartbleed (CVE-2014-0160) expose la mémoire de centaines de milliers de serveurs web. La cause : une extension TLS d'OpenSSL qui faisait confiance à une longueur envoyée par le client sans vérifier qu'elle correspondait aux données réellement reçues. C'est exactement la classe de bugs qu'un fuzzer traque : du code qui traite une entrée non fiable et se comporte mal sur une valeur imprévue. Dans les mois qui ont suivi, l'essor des fuzzers modernes et de plateformes comme OSS-Fuzz a rendu ce type de contrôle systématique sur les bibliothèques critiques.

Le fuzzing consiste à bombarder un programme d'entrées imprévues, générées automatiquement, pour observer s'il plante ou viole une propriété attendue. Cette page est une introduction pour développeurs et ops débutants à intermédiaires : vous y trouverez la définition, les familles de fuzzing, les concepts clés (corpus, minimisation, sanitizers) et les cas où l'outil brille ou montre ses limites. Deux guides pratiques prolongent la lecture pour Python et Go.

  • Ce qu'est le fuzzing et en quoi il diffère d'un test unitaire classique.
  • Les familles de fuzzing : coverage-guided, black-box, mutation, structure-aware.
  • Les concepts clés : seed corpus, minimisation, dictionnaires, sanitizers, property-based.
  • Quand le fuzzing est pertinent et pourquoi il ne prouve jamais l'absence de bug.
  • Comment aborder le fuzzing en Python (atheris) et en Go (natif).

Le fuzzing (ou fuzz testing) est une technique de test qui génère automatiquement un très grand nombre d'entrées et les injecte dans une fonction cible pour détecter les comportements anormaux : crash, exception non gérée, corruption mémoire ou invariant violé. Là où un test unitaire vérifie un cas que vous avez imaginé, le fuzzer explore des milliers de cas que vous n'auriez jamais écrits à la main.

L'analogie la plus simple est celle d'un testeur infatigable qui tape au hasard sur toutes les touches d'un formulaire, colle des fichiers corrompus, envoie des caractères exotiques, et note chaque fois que l'application tombe. Sauf qu'ici, le fuzzer est guidé : il retient les entrées qui font progresser l'exploration et mute celles-ci pour aller plus loin. Cette boucle génère-teste-observe tourne des millions de fois par minute.

Le résultat concret d'une campagne de fuzzing est un reproducteur : le plus petit fichier d'entrée qui déclenche le bug. Vous le rejouez à volonté, vous corrigez le code, puis vous le conservez comme test de régression.

Toute donnée qui entre dans votre programme depuis l'extérieur est une entrée non fiable : un fichier uploadé, une requête réseau, un message d'API, un format binaire. Un attaquant contrôle cette donnée et cherchera précisément la valeur qui casse votre parseur. Les tests écrits à la main couvrent les cas nominaux et quelques cas d'erreur, mais l'espace des entrées possibles est gigantesque : c'est là que le fuzzing prend le relais.

Le fuzzing s'inscrit dans la démarche shift-left, qui consiste à détecter les défauts au plus tôt, quand ils coûtent le moins cher à corriger. Un crash trouvé sur le poste du développeur ou dans la CI n'atteint jamais la production. À l'inverse d'une analyse statique qui lit le code sans l'exécuter, le fuzzing exécute réellement la cible : il révèle des bugs de logique et de manipulation mémoire qu'un analyseur de patterns ne verrait pas.

Tous les fuzzers ne se ressemblent pas. Ils se distinguent surtout par la manière dont ils génèrent les entrées et par ce qu'ils savent du code cible. Comprendre ces familles vous aide à choisir l'outil adapté à votre langage et à votre format d'entrée.

FamillePrincipeExemple
Coverage-guidedLe code est instrumenté ; le fuzzer garde les entrées qui augmentent la couverture et mute à partir d'elleslibFuzzer, fuzzing natif Go, atheris
Black-boxAucune visibilité sur le code ; on envoie des entrées et on observe la sortiefuzzers de protocole réseau
Mutation-basedPart d'un corpus existant et le mute (bit-flip, insertions, découpages)mode par défaut de la plupart des fuzzers
Generation / structure-awareConstruit des entrées valides par construction à partir d'une grammaire ou d'un schémafuzzing guidé par Protobuf, grammaires

Le coverage-guided fuzzing est aujourd'hui la référence pour le code applicatif. Le fuzzer instrumente votre programme à la compilation, mesure quelles branches sont atteintes, et conserve toute entrée qui explore un chemin nouveau. Cette rétroaction transforme une recherche aléatoire en une exploration dirigée : le fuzzer apprend, en quelques secondes, à franchir des conditions que le pur hasard n'atteindrait jamais.

Le structure-aware fuzzing répond à un autre problème : quand l'entrée doit respecter un format strict (un en-tête de fichier, un message sérialisé), muter des octets au hasard produit surtout des entrées rejetées d'emblée. Générer à partir d'une grammaire garde le fuzzer dans la zone utile du code, au-delà de la validation d'entrée.

Le vocabulaire du fuzzing revient dans toutes les documentations. Ces quelques notions suffisent à lire un guide d'outil et à interpréter une campagne.

Le corpus est l'ensemble des entrées de test. On distingue le seed corpus (les graines que vous fournissez : quelques entrées valides et représentatives) du corpus généré, que le fuzzer construit et conserve automatiquement au fil de l'exploration. Un bon seed corpus accélère énormément le démarrage : il donne au fuzzer des points de départ crédibles plutôt que de partir d'octets vides.

La minimisation réduit une entrée fautive à sa forme la plus petite qui déclenche encore le bug. Un crash provoqué par un fichier de 47 octets est souvent réductible à 1 seul octet significatif : le reproducteur minimal est plus facile à comprendre et à corriger. Les dictionnaires sont des listes de tokens (mots-clés, magic bytes, séparateurs) injectés par le fuzzer pour franchir des comparaisons littérales qu'il ne devinerait pas au hasard, par exemple un mot-clé attendu dans un en-tête.

Les sanitizers comme ASan (AddressSanitizer) ou UBSan (UndefinedBehaviorSanitizer) instrumentent le binaire pour détecter les corruptions mémoire (débordements, use-after-free) et les comportements indéfinis. Point crucial : ils n'ont de sens que pour du code natif (C, C++, Rust, ou une extension native appelée depuis Python ou Go). Du code Python pur ou Go pur ne bénéficie pas d'ASan : la sécurité mémoire y est déjà assurée par le langage.

Enfin, deux objectifs coexistent. La recherche de crash attend que le programme s'effondre (panic, segfault, exception fatale). Le property-based (ou fuzzing par invariant) vérifie une propriété qui doit toujours tenir, par exemple le round-trip decode(encode(x)) == x. L'invariant révèle des bugs de logique silencieux qui ne feraient jamais planter le programme mais corrompraient les données.

Le fuzzing donne son meilleur rendement sur le code qui transforme une entrée non fiable en structure interne : parseurs, décodeurs, désérialiseurs, lecteurs de formats (JSON, XML, images, protocoles réseau). Toute frontière d'entrée où une donnée externe rencontre votre logique est une cible de choix. À l'inverse, une fonction purement interne, dont les entrées sont déjà validées en amont, tire peu de bénéfice du fuzzing.

Deux conditions rendent une cible fuzzable efficacement. Elle doit être rapide (des milliers d'exécutions par seconde) et déterministe : pas d'état global persistant entre deux appels, pas d'accès réseau, pas de dépendance à l'horloge ni au hasard. Sans déterminisme, un crash n'est pas reproductible, ce qui ruine tout l'intérêt.

Sur du code natif, il faut associer des sanitizers pour rendre visibles les corruptions mémoire. Sur du Go pur, le fuzzer natif ne détecte pas les dépassements d'entiers, les data races ni les fuites de goroutines : il faut compléter avec le détecteur de course -race et la revue. Aucun fuzzer ne remplace une réflexion sur votre modèle de menaces.

Deux écosystèmes populaires intègrent le coverage-guided fuzzing avec des philosophies différentes. Les regarder côte à côte éclaire les concepts vus plus haut.

En Python, la bibliothèque atheris (développée par Google) branche libFuzzer sur l'interpréteur. Vous écrivez un harnais qui reçoit des octets bruts, les convertit en valeurs typées via un FuzzedDataProvider, puis appelle votre code. L'installation se fait par pip install atheris (wheels précompilées, Python 3.11 et au-delà). Voici un harnais property-based minimal qui vérifie un round-trip :

import atheris
import sys
with atheris.instrument_imports():
from rle import rle_encode, rle_decode
def TestOneInput(data):
fdp = atheris.FuzzedDataProvider(data)
s = fdp.ConsumeUnicodeNoSurrogates(256)
assert rle_decode(rle_encode(s)) == s
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()

En Go, le fuzzing est natif depuis Go 1.18 : pas de dépendance externe, il vit dans le framework de test standard. Une fonction FuzzXxx déclare des graines avec f.Add, puis f.Fuzz reçoit des arguments typés générés par le moteur. La même vérification de round-trip s'écrit ainsi :

func FuzzRoundTrip(f *testing.F) {
f.Add("aaab") // graine
f.Fuzz(func(t *testing.T, s string) {
if rleDecode(rleEncode(s)) != s {
t.Errorf("round-trip cassé pour %q", s)
}
})
}

Lancé avec go test -fuzz=FuzzRoundTrip, le moteur trouve un contre-exemple, le minimise automatiquement et l'écrit dans testdata/fuzz/ où il devient un test de régression rejoué à chaque go test :

--- FAIL: FuzzRoundTrip (0.04s)
rle_test.go:17: round-trip cassé : "\xae" -> "?1" -> "?"
Failing input written to testdata/fuzz/FuzzRoundTrip/0ae9e7fb4a758a1c

Ce contre-exemple illustre un point subtil : le fuzzer remonte d'abord le cas le plus court, ici un octet UTF-8 invalide, avant même le bug de format que le développeur soupçonnait. Les deux guides pratiques détaillent le harnais complet, l'installation, l'intégration en CI et l'interprétation des résultats, pour Python avec atheris et pour Go natif.

  • Le fuzzing génère automatiquement des entrées imprévues pour révéler crashs, exceptions et invariants violés.
  • Le coverage-guided (libFuzzer, Go natif, atheris) instrumente le code et garde les entrées qui explorent des chemins nouveaux : c'est la référence actuelle.
  • Le seed corpus accélère le démarrage ; la minimisation réduit un crash à son reproducteur le plus petit, conservé en test de régression.
  • Les sanitizers (ASan, UBSan) ne servent que pour du code natif ; le Python et le Go purs n'en tirent rien.
  • Une erreur attendue n'est pas un bug : un harnais échoue sur panic, exception non prévue ou invariant cassé, jamais sur un rejet propre.
  • Le fuzzing vise les parseurs, décodeurs et frontières d'entrée non fiable ; la cible doit être rapide et déterministe.
  • Le fuzzing prouve la présence de bugs, jamais leur absence : il complète revue, tests et analyse statique.
  • Un projet qui expose une surface de parsing gagne à activer un contrôle de fuzzing dans sa chaîne de qualité, comme le mesure OpenSSF Scorecard.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn