Aller au contenu
Sécurité medium

Fuzzing Go natif : tester avec go test -fuzz

21 min de lecture

Le fuzzing Go natif génère automatiquement des milliers d'entrées imprévues pour casser votre code, sans dépendance externe. Depuis Go 1.18, go test -fuzz fait partie de la chaîne d'outils standard. Dans ce guide, vous écrivez un test de fuzzing sur un encodeur maison, vous laissez le moteur trouver un crash réel en moins d'une seconde, puis vous corrigez le bug et vérifiez la correction en re-fuzzant.

Ce guide s'adresse aux développeurs Go intermédiaires à avancés qui écrivent des parseurs, décodeurs ou toute fonction traitant des entrées non fiables. Vous saurez rédiger un FuzzXxx, lire la sortie du moteur, rejouer un crash en régression et connaître les angles morts du fuzzing natif. Tout le code et toutes les sorties de ce guide viennent d'un lab réel sous Go 1.26.

  • Écrire un test de fuzzing avec testing.F et l'invariant de round-trip.
  • Lancer go test -fuzz et interpréter sa sortie (baseline, crash, minimisation).
  • Comprendre le corpus : graines, testdata/fuzz/, cache généré.
  • Rejouer un crash minimisé comme test de régression.
  • Corriger un bug débusqué par le fuzzer, puis re-fuzzer pour confirmer.
  • Connaître les limites du fuzzing natif et comment les compléter.

Le fuzzing est intégré au langage depuis Go 1.18, il n'y a donc rien à installer. Ce guide a été exécuté avec Go 1.26. Vérifiez votre version, la couverture instrumentée n'est disponible que sur AMD64 et ARM64 :

Fenêtre de terminal
go version

La sortie doit afficher une version supérieure ou égale à 1.18 :

go version go1.26.5 linux/amd64

Une aisance minimale avec go test et les tests unitaires Go suffit. Si le concept même du fuzzing (familles, corpus, sanitizers) est encore flou, lisez d'abord la page dédiée avant de revenir ici.

Pour rendre l'exercice concret, on part d'un encodeur run-length (RLE) volontairement naïf. L'idée du RLE est simple : compresser les répétitions. La chaîne "aaabb" devient "a3b2" (trois a, deux b). Le fichier rle.go contient un défaut réel que le fuzzer va débusquer.

// Package rle implemente un encodage run-length naif, avec un bug latent.
// Format : chaque caractere est suivi de son nombre de repetitions en decimal.
package rle
import (
"strconv"
"strings"
)
// Encode compresse une chaine en run-length encoding.
func Encode(s string) string {
if s == "" {
return ""
}
var b strings.Builder
runes := []rune(s)
prev := runes[0]
count := 1
for _, c := range runes[1:] {
if c == prev {
count++
continue
}
b.WriteRune(prev)
b.WriteString(strconv.Itoa(count))
prev = c
count = 1
}
b.WriteRune(prev)
b.WriteString(strconv.Itoa(count))
return b.String()
}
// Decode decompresse une chaine RLE. Il lit un caractere puis TOUS les chiffres
// consecutifs comme compteur.
func Decode(s string) string {
var b strings.Builder
runes := []rune(s)
i := 0
for i < len(runes) {
char := runes[i]
i++
digits := strings.Builder{}
for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' {
digits.WriteRune(runes[i])
i++
}
count := 1
if digits.Len() > 0 {
count, _ = strconv.Atoi(digits.String())
}
for j := 0; j < count; j++ {
b.WriteRune(char)
}
}
return b.String()
}

À l'oeil nu, ce code paraît correct : Encode("aaabb") donne bien "a3b2" et Decode("a3b2") reconstruit "aaabb". C'est exactement le genre de faux sentiment de sécurité que le fuzzing démonte. Deux défauts se cachent ici : la conversion []rune d'une part, et l'ambiguïté du format avec les chiffres d'autre part. Vous allez les découvrir dans l'ordre où le moteur les remonte.

Un test de fuzzing Go n'a rien d'ésotérique : c'est une fonction FuzzXxx placée dans un fichier _test.go, qui reçoit un *testing.F. La bonne stratégie ici est le test de propriété (property-based) : plutôt que de deviner des cas précis, on affirme un invariant qui doit tenir pour toute entrée. Pour un encodeur, l'invariant naturel est le round-trip : Decode(Encode(s)) doit toujours redonner s.

Voici rle_test.go. Notez les deux briques : f.Add fournit des graines (des cas de départ valides qui amorcent le moteur) et f.Fuzz reçoit la fonction pilotée par les entrées générées.

package rle
import "testing"
// FuzzRoundTrip verifie l'invariant Decode(Encode(s)) == s pour toute chaine s.
func FuzzRoundTrip(f *testing.F) {
// Graines : des cas valides qui amorcent le moteur.
f.Add("aaabb")
f.Add("abc")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
encoded := Encode(s)
decoded := Decode(encoded)
if decoded != s {
t.Errorf("round-trip casse : %q -> %q -> %q", s, encoded, decoded)
}
})
}

Trois contraintes structurantes encadrent ce test. D'abord, un seul f.Fuzz par fonction de fuzzing. Ensuite, les arguments de f.Add doivent avoir exactement les mêmes types, dans le même ordre, que ceux de la fonction passée à f.Fuzz : ici un string unique. Enfin, seuls les types scalaires sont acceptés (string, []byte, les entiers signés et non signés, rune, byte, float32/64, bool). Pas de struct, pas de slice composite, pas de map ni de pointeur : le moteur ne sait muter que des octets bruts.

Le fuzzing se déclenche avec le flag -fuzz, qui prend une expression régulière ne devant matcher qu'une seule fonction. Sans ce flag, go test exécute les graines comme de simples tests unitaires et s'arrête. On borne la durée avec -fuzztime, indispensable pour un run reproductible :

Fenêtre de terminal
go test -fuzz=FuzzRoundTrip -fuzztime=20s

La sortie réelle du lab tient en quelques lignes. Le moteur mesure d'abord une couverture de référence (baseline) sur les graines, puis lance ses workers. Le crash tombe immédiatement :

fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 16 workers
fuzz: minimizing 38-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzRoundTrip (0.03s)
--- FAIL: FuzzRoundTrip (0.00s)
rle_test.go:17: round-trip casse : "\xe8" -> "�1" -> "�"
Failing input written to testdata/fuzz/FuzzRoundTrip/5845f96f56664c01
To re-run:
go test -run=FuzzRoundTrip/5845f96f56664c01
FAIL
exit status 1
FAIL example.com/rle 0.038s

Trois informations comptent ici. Le moteur a trouvé une entrée fautive en moins de trois centièmes de seconde. Il l'a minimisée : parti d'un fichier de 38 octets, il l'a réduit à un seul octet significatif, "\xe8". Et il a écrit l'entrée dans testdata/fuzz/FuzzRoundTrip/ avec la commande exacte pour la rejouer. Le hash exact varie d'un run à l'autre (une autre exécution est tombée sur "\xae"), mais la classe de bug est toujours la même.

Le fuzzing Go manipule trois emplacements de corpus qu'il ne faut pas confondre. Le seed corpus (graines) combine les appels f.Add du code et le contenu versionné de testdata/fuzz/<NomDuFuzz>/. Le corpus généré, lui, vit dans $GOCACHE/fuzz et n'existe que pendant un run avec -fuzz : c'est le réservoir d'entrées intéressantes que le moteur garde parce qu'elles augmentent la couverture. Ce cache n'est pas censé être versionné.

Le point clé pour la qualité de votre code : dès qu'un crash est trouvé, Go écrit l'entrée minimisée dans testdata/fuzz/, sous Git. Elle rejoint le seed corpus et devient un test de régression rejoué à chaque go test ordinaire, même sans -fuzz. Le fichier généré est du texte lisible :

go test fuzz v1
string("\xae")

La première ligne est un en-tête de version, la suivante porte la valeur typée. Ce format explique aussi comment injecter un cas connu à la main : il suffit de déposer un tel fichier dans testdata/fuzz/<NomDuFuzz>/. L'outil file2fuzz (dans golang.org/x/tools) convertit un fichier binaire arbitraire vers ce format.

Une fois le reproducteur écrit, vous n'avez pas besoin de re-fuzzer pour retravailler le bug. Le flag -run cible le cas précis via son hash, comme n'importe quel sous-test :

Fenêtre de terminal
go test -run="FuzzRoundTrip/0ae9e7fb4a758a1c"

L'échec se rejoue à l'identique, instantanément et sans workers :

--- FAIL: FuzzRoundTrip (0.00s)
--- FAIL: FuzzRoundTrip/0ae9e7fb4a758a1c (0.00s)
rle_test.go:17: round-trip casse : "\xae" -> "�1" -> "�"
FAIL
exit status 1
FAIL example.com/rle 0.002s

Ce cycle rejeu rapide, correction, re-vérification est la vraie boucle de travail. On garde le fuzzing long pour chercher de nouveaux cas, et on utilise -run pour déboguer un cas déjà connu sans attendre.

Le message "\xae" -> "�1" -> "�" cache le premier bug, et il est spécifique à Go. L'octet 0xae seul n'est pas de l'UTF-8 valide. Or Encode fait []rune(s) : cette conversion remplace tout octet invalide par le caractère de remplacement U+FFFD (affiché ). L'information de l'octet d'origine est perdue dès la conversion, donc Decode ne peut jamais reconstruire "\xae". Le round-trip casse avant même que la logique RLE n'entre en jeu.

Le fuzzer a remonté ce cas en premier parce qu'il est le plus court : un seul octet. Mais le second bug, celui du format, est bien présent lui aussi. Il apparaît dès qu'une entrée contient un chiffre. Vérifiez-le directement :

Encode("a1") // -> "a111"
Decode("a111") // -> "a" repete 111 fois

La sortie réelle du lab confirme l'ampleur du dégât :

"a1" -> "a111" -> "aaaaaaaaaaa..." (len 111)
"x9" -> "x191" -> "xxxxxxxxxxx..." (len 191)

La cause est structurelle : Encode("a1") produit "a1" + "1" (le a suivi de son compteur 1, puis le 1 suivi de son compteur 1), soit "a111". Au décodage, Decode lit le a puis avale tous les chiffres consécutifs comme compteur : il lit 111 et répète a cent-onze fois. Le format mélange donnée et compteur sans séparateur : il est ambigu, donc irréparable en l'état.

La correction doit traiter les deux causes d'un coup. On abandonne string/[]rune pour travailler sur []byte (aucun octet n'est réinterprété), et on rend le format non ambigu en émettant des paires (compteur, octet) où le compteur est un octet brut jamais confondu avec la donnée. Voici rle_fixed.go :

package rle
// EncodeFixed travaille sur []byte (aucun octet reinterprete) et emet des
// paires (compteur, octet) : le compteur est un octet brut, jamais melange
// avec la donnee. Les runs de plus de 255 octets sont scindes.
func EncodeFixed(data []byte) []byte {
var out []byte
i := 0
for i < len(data) {
b := data[i]
count := 1
for i+count < len(data) && data[i+count] == b && count < 255 {
count++
}
out = append(out, byte(count), b)
i += count
}
return out
}
// DecodeFixed inverse EncodeFixed : il lit des paires (compteur, octet).
func DecodeFixed(data []byte) []byte {
var out []byte
for i := 0; i+1 < len(data); i += 2 {
count := int(data[i])
b := data[i+1]
for j := 0; j < count; j++ {
out = append(out, b)
}
}
return out
}

On écrit ensuite un nouveau test de fuzzing sur cette version, avec des graines qui incluent explicitement les cas qui cassaient l'ancienne implémentation ("a1", "\xae", "\xe8") :

package rle
import (
"bytes"
"testing"
)
func FuzzRoundTripFixed(f *testing.F) {
f.Add([]byte("aaabb"))
f.Add([]byte("abc"))
f.Add([]byte(""))
f.Add([]byte("a1"))
f.Add([]byte("\xae"))
f.Add([]byte("\xe8"))
f.Fuzz(func(t *testing.T, data []byte) {
encoded := EncodeFixed(data)
decoded := DecodeFixed(encoded)
if !bytes.Equal(decoded, data) {
t.Errorf("round-trip casse : %q -> %q -> %q", data, encoded, decoded)
}
})
}

On re-fuzze la version corrigée pendant 20 secondes. Cette fois le moteur ne trouve aucun échec et se termine sur un PASS net :

fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 1379419 (459472/sec), new interesting: 12 (total: 18)
fuzz: elapsed: 12s, execs: 5692415 (471533/sec), new interesting: 12 (total: 18)
fuzz: elapsed: 20s, execs: 9491673 (457307/sec), new interesting: 12 (total: 18)
PASS
ok example.com/rle 20.107s

Neuf millions et demi d'exécutions, aucun crash : la ligne new interesting se stabilise, signe que le moteur ne trouve plus de nouveaux chemins intéressants. Ce n'est pas une preuve d'absence de bug, mais une confiance largement renforcée que l'invariant tient désormais.

Le fuzzing en intégration continue obéit à une règle simple : borner le temps. Une campagne de plusieurs heures a sa place en continu (via OSS-Fuzz), pas sur une pull request. En CI, on lance un run court qui vérifie surtout que les régressions du corpus versionné passent et qu'aucun crash évident ne surgit :

Fenêtre de terminal
go test -fuzz=FuzzRoundTrip -fuzztime=30s ./...

Deux réflexes rendent cette étape utile. D'abord, versionner testdata/fuzz/ pour que chaque go test normal rejoue les régressions connues, même dans les jobs sans -fuzz. Ensuite, garder le fuzz borné court (30 secondes suffisent pour attraper les régressions rapides), et déléguer le fuzzing long à une infrastructure dédiée. Le contrôle Fuzzing d'OpenSSF Scorecard valorise justement la présence de ce harnais dans votre dépôt.

Le fuzzing intégré à Go est remarquablement accessible, mais il ne voit pas tout. C'est un moteur coverage-guided sur types scalaires, et cela dessine des angles morts précis qu'il faut connaître pour ne pas se croire couvert à tort. Le tableau ci-dessous résume ce qu'il ne détecte pas par lui-même.

Angle mortPourquoiComment compléter
Data racesLe moteur juge la sortie, pas la concurrenceLancer avec -race
Integer overflowsUn dépassement silencieux ne panique pasAssertions, revue de code
Goroutine leaksAucune notion de fuite de goroutineDétecteurs dédiés en test
Timeouts / boucles infiniesUne exécution figée coupe à 1 s sans forcément échouerBornes explicites, budget de temps
Comparaisons complexesLe moteur peine à franchir un == sur un long littéralDictionnaires, graines ciblées
Types compositesNi struct, ni map, ni structure-awareEncoder/décoder manuellement en []byte

La parade la plus rentable est de coupler -fuzz et -race : le second instrumente les accès concurrents que le premier ignore. Pour aller au-delà (fuites de goroutines, invariants métier riches), des outils tiers existent, comme gosentry, qui ajoutent des détecteurs par-dessus le moteur natif. Le message à retenir : le fuzzing natif est un excellent point de départ, pas une couverture complète. Une erreur attendue (une error que votre code renvoie volontairement) ne doit d'ailleurs jamais faire échouer le test : on n'échoue que sur une panique, un invariant violé ou une corruption.

  • Le fuzzing Go est natif depuis 1.18 : aucune dépendance, juste go test -fuzz.
  • Un test de fuzzing est une fonction FuzzXxx(f *testing.F) avec un seul f.Fuzz et des types scalaires uniquement.
  • Le test de propriété (round-trip Decode(Encode(s)) == s) est le patron le plus productif pour un encodeur ou un parseur.
  • Le moteur minimise l'entrée fautive (ici 38 octets ramenés à 1) et l'écrit dans testdata/fuzz/ comme test de régression versionné.
  • Rejouez un crash connu avec -run=FuzzXxx/<hash>, sans re-lancer de campagne.
  • Le fuzzer remonte le cas le plus court d'abord : ici le bug UTF-8 ([]rune remplace un octet invalide par U+FFFD) avant le bug de format ambigu sur les chiffres.
  • Corriger un décodeur d'octets se fait sur []byte avec un format non ambigu ; la version corrigée passe 9,5 millions d'exécutions sans échec.
  • Le natif ne détecte ni data races, ni overflows, ni fuites de goroutines : complétez avec -race et, au besoin, un outil tiers.

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