Ce guide vous fait débusquer un vrai bug de parseur Python en moins d'une seconde de fuzzing. Vous installez Atheris (le moteur de fuzzing couverture-guidé de Google pour Python), vous écrivez un harnais de dix lignes qui teste un invariant de round-trip, et vous regardez le fuzzer casser le code presque instantanément. Ensuite vous corrigez le défaut, vous re-fuzzez pour confirmer que le bug a disparu, et vous branchez le tout en CI avec un budget borné. Public visé : développeurs et ingénieurs sécurité à l'aise avec Python 3.11+ qui écrivent ou auditent des parseurs, décodeurs ou désérialiseurs.
Le fuzzing complète le SAST : là où l'analyse statique lit le code sans l'exécuter, le fuzzing l'exécute avec des milliers d'entrées imprévues pour révéler les crashs que la relecture manque. Pour les fondations conceptuelles (familles, corpus, sanitizers), voyez la page Fuzzing : trouver les bugs par injection de données.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Installer Atheris et vérifier qu'il s'importe sans compilateur.
- Écrire un harnais avec
instrument_importsetFuzzedDataProvider. - Lancer le fuzzer et lire la sortie libFuzzer d'un crash réel.
- Comprendre puis corriger un bug de format de parseur.
- Rejouer un crash à partir de son fichier artefact.
- Borner le fuzzing en CI et produire la preuve de l'exigence socle SOCLE-APP-AST-4 (fuzzing sur les composants critiques).
Prérequis
Section intitulée « Prérequis »Atheris 3.1.0 exige Python 3.11 à 3.14. Les versions 3.10 et antérieures
ne sont plus supportées. Sur Linux x86-64, l'installation tire une wheel
précompilée (manylinux), donc aucun Clang ni libFuzzer à compiler
soi-même. La compilation depuis les sources n'est nécessaire que pour fuzzer
des extensions natives (dernière section).
Créez un environnement virtuel isolé et installez le paquet :
python3 -m venv fuzz-venvsource fuzz-venv/bin/activatepip install atherisLa sortie confirme la wheel récupérée et sa taille (36,9 Mo, c'est normal, elle embarque libFuzzer) :
Collecting atheris Using cached atheris-3.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (17 kB)Using cached atheris-3.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (36.9 MB)Installing collected packages: atherisSuccessfully installed atheris-3.1.0Vérifiez que l'import fonctionne (aucun message d'erreur attendu) :
python -c "import atheris, sys; print('atheris importe, python', sys.version.split()[0])"atheris importe, python 3.12.3Le code à tester
Section intitulée « Le code à tester »Notre cible est un encodeur RLE (run-length encoding) naïf, le genre de
petit parseur qu'on écrit sans y penser et qui cache un défaut. Le format
retenu colle chaque caractère à son compteur de répétitions en décimal :
"aaabb" devient "a3b2", "abc" devient "a1b1c1". C'est une cible idéale
de fuzzing parce que c'est rapide, déterministe et sans état : trois
qualités exigées pour un harnais reproductible.
Voici rle.py, avec l'encodeur et le décodeur :
def rle_encode(s: str) -> str: """Compresse une chaine en run-length encoding.""" if not s: return "" out = [] prev = s[0] count = 1 for c in s[1:]: if c == prev: count += 1 else: out.append(prev + str(count)) prev = c count = 1 out.append(prev + str(count)) return "".join(out)
def rle_decode(s: str) -> str: """Decompresse une chaine RLE. Lit un caractere puis TOUS les chiffres consecutifs comme compteur.""" out = [] i = 0 n = len(s) while i < n: char = s[i] i += 1 digits = "" while i < n and s[i].isdigit(): digits += s[i] i += 1 count = int(digits) if digits else 1 out.append(char * count) return "".join(out)Le défaut est subtil : le décodeur lit un caractère, puis avale tous les chiffres consécutifs comme compteur. Tant que la donnée d'origine ne contient pas de chiffre, tout va bien. Le fuzzer, lui, va justement fabriquer une entrée qui en contient. Une relecture rapide ne voit rien d'anormal, et c'est exactement là que le fuzzing gagne.
Écrire le harnais Atheris
Section intitulée « Écrire le harnais Atheris »Le harnais est la fonction que le fuzzer appelle des milliers de fois par
seconde, chaque fois avec des octets différents. Sa responsabilité : traduire
ces octets en entrée exploitable, exécuter le code cible, et lever une
exception si une propriété est violée. Ici la propriété est un invariant de
round-trip : pour toute chaîne s, on doit avoir
rle_decode(rle_encode(s)) == s. Si l'égalité casse, c'est un bug.
Trois éléments d'API structurent fuzz_rle.py. Le bloc
with atheris.instrument_imports(): instrumente le module importé pour que
le moteur mesure la couverture de code et oriente ses mutations. Le
FuzzedDataProvider transforme les octets bruts du fuzzer en types Python
utiles, ici une chaîne Unicode via ConsumeUnicodeNoSurrogates. Enfin
atheris.Setup puis atheris.Fuzz() démarrent la boucle de fuzzing.
import sys
import atheris
with atheris.instrument_imports(): from rle import rle_decode, rle_encode
def test_one_input(data: bytes) -> None: fdp = atheris.FuzzedDataProvider(data) s = fdp.ConsumeUnicodeNoSurrogates(sys.maxsize) encoded = rle_encode(s) decoded = rle_decode(encoded) if decoded != s: raise AssertionError( "round-trip casse : %r -> %r -> %r" % (s, encoded, decoded) )
def main() -> None: atheris.Setup(sys.argv, test_one_input) atheris.Fuzz()
if __name__ == "__main__": main()Le choix de l'invariant fait tout. On ne cherche pas un crash mémoire
(impossible en Python pur) mais une propriété métier violée : c'est du
property-based testing propulsé par un moteur couverture-guidé. Le message
d'AssertionError embarque l'entrée fautive et les deux formes intermédiaires,
pour qu'on comprenne le bug d'un coup d'oeil dès qu'il tombe.
Lancer le fuzzer
Section intitulée « Lancer le fuzzer »On lance le harnais comme un script Python, en lui passant des drapeaux
libFuzzer. Le drapeau -max_total_time borne la durée en secondes,
-artifact_prefix indique où écrire le fichier de crash. Placez-vous dans le
dossier des sources pour que l'import from rle import ... résolve.
cd pythonpython fuzz_rle.py -max_total_time=20 -artifact_prefix=./Le fuzzer démarre sur un corpus vide, découvre du code (les lignes NEW avec
la couverture cov: qui grimpe), puis lève l'exception en une fraction de
seconde. Voici la sortie réelle de ce lab, tronquée sur la ligne géante du
décodage :
INFO: Instrumenting rleINFO: Using built-in libfuzzerINFO: Running with entropic power schedule (0xFF, 100).INFO: Seed: 1530575919INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytesINFO: A corpus is not provided, starting from an empty corpus#2 INITED cov: 4 ft: 4 corp: 1/1b exec/s: 0 rss: 41Mb#27 NEW cov: 13 ft: 13 corp: 2/3b lim: 4 L: 2/2 MS: 5 CrossOver-ChangeBinInt-ChangeByte-ChangeByte-CopyPart-#30 NEW cov: 15 ft: 16 corp: 3/7b lim: 4 L: 4/4 MS: 3 ShuffleBytes-ShuffleBytes-CopyPart-#64 NEW cov: 18 ft: 27 corp: 4/10b lim: 4 L: 3/4 MS: 4 CopyPart-ChangeByte-ChangeBit-InsertByte-#75 NEW cov: 18 ft: 28 corp: 5/14b lim: 4 L: 4/4 MS: 1 ChangeByte-#79 NEW cov: 18 ft: 37 corp: 6/18b lim: 4 L: 4/4 MS: 4 CrossOver-EraseBytes-InsertByte-CrossOver-
=== Uncaught Python exception: ===AssertionError: round-trip casse : '676' -> '617161' -> '66666666666...[108 fois '6', tronqué]'Traceback (most recent call last): File "/home/bob/Projets/lab-fuzzing/python/fuzz_rle.py", line 26, in test_one_input raise AssertionError(==790945== ERROR: libFuzzer: fuzz target exitedSUMMARY: libFuzzer: fuzz target exitedartifact_prefix='./'; Test unit written to ./crash-596d7f413d70de13592c879cf9554760fb2c8875Base64: t7a3tg==Le bug tombe après quelques dizaines d'exécutions, ici juste après la
dernière entrée intéressante #79. Le nombre exact varie d'un lancement à
l'autre parce que la graine (Seed:) est aléatoire, mais l'ordre de grandeur
reste le même : quelques millisecondes. L'entrée fautive est '676'. Encodée,
elle donne '617161', puis le décodeur relit '6' suivi des chiffres '17161'
comme un compteur de 17161... et le round-trip explose. Le fuzzer a écrit le
reproducteur dans crash-596d7f413d70de13592c879cf9554760fb2c8875 (4 octets,
b7 b6 b7 b6).
Comprendre et corriger le bug
Section intitulée « Comprendre et corriger le bug »La cause racine est un format ambigu, pas un simple oubli. Coller le compteur au caractère marche uniquement si les données ne contiennent jamais de chiffre. Dès qu'un chiffre apparaît dans l'entrée, le décodeur ne sait plus où s'arrête le caractère et où commence le compteur. C'est une faiblesse de conception : le format n'est pas auto-délimité.
La correction rend le compteur explicite et délimité. Chaque répétition
s'écrit désormais <compteur>:<caractère>. Le décodeur lit les chiffres
jusqu'au séparateur :, consomme ce séparateur, puis prend exactement un
caractère. Le caractère répété peut alors être n'importe quoi (un chiffre, un
: lui-même) sans jamais casser le round-trip. Voici rle_fixed.py :
def rle_encode(s: str) -> str: """Compresse une chaine en run-length encoding delimite.""" if not s: return "" out = [] prev = s[0] count = 1 for c in s[1:]: if c == prev: count += 1 else: out.append("%d:%s" % (count, prev)) prev = c count = 1 out.append("%d:%s" % (count, prev)) return "".join(out)
def rle_decode(s: str) -> str: """Lit le compteur jusqu'au ':', consomme le separateur, puis prend un seul caractere : plus d'ambiguite.""" out = [] i = 0 n = len(s) while i < n: digits = "" while i < n and s[i].isdigit(): digits += s[i] i += 1 if not digits or i >= n or s[i] != ":": raise ValueError("format RLE invalide a la position %d" % i) i += 1 # consomme le separateur ':' char = s[i] i += 1 out.append(char * int(digits)) return "".join(out)Avec ce format, "676" s'encode en "1:61:71:6" et se redécode correctement
en "676". On re-fuzze la version corrigée avec un harnais identique qui
importe rle_fixed au lieu de rle, sur un budget de 200 000 exécutions via
-atheris_runs (préférable à -runs car il produit un rapport de
couverture propre en sortant) :
python fuzz_rle_fixed.py -atheris_runs=200000Le fuzzer tourne jusqu'au bout sans lever d'exception et sans écrire de fichier de crash. La dernière ligne l'atteste :
#198522 REDUCE cov: 20 ft: 117 corp: 36/2019b lim: 1480 exec/s: 28360 rss: 41MbDone 200000 in 8 second(s)Done 200000 in 8 second(s) et aucun crash-* : le round-trip tient. Ce
n'est pas une preuve d'absence totale de bug (le fuzzing ne prouve jamais
l'absence), mais 200 000 entrées mutées, dont le fuzzer a maximisé la
couverture, n'ont trouvé aucune violation. Le format ambigu, lui, tombait en
quelques dizaines d'essais.
Rejouer un crash
Section intitulée « Rejouer un crash »Le fichier de crash est un test de régression déguisé. Quand un harnais
échoue, Atheris écrit l'entrée fautive brute dans un fichier crash-<hash> (le
hash est la somme SHA-1 du contenu). Ce fichier permet de reproduire le bug à
l'identique, sans refuzzer, en le passant en argument positionnel au
harnais :
python fuzz_rle.py crash-596d7f413d70de13592c879cf9554760fb2c8875Le harnais s'exécute une seule fois sur ces octets et relève immédiatement
la même AssertionError. Versionnez ces fichiers dans le dépôt (un dossier
corpus/ ou crashes/) : chaque crash reproductible devient un cas de
non-régression que vous rejouez avant de refuzzer, pour garantir qu'une
correction passée ne réapparaît pas. C'est la discipline qui fait du fuzzing un
outil durable plutôt qu'un test ponctuel.
Fuzzer en CI
Section intitulée « Fuzzer en CI »En CI, on ne fuzze pas des heures : on borne le budget. Un job de pull
request doit rester rapide et déterministe. Deux drapeaux servent à cela :
-max_total_time=N fixe une durée maximale en secondes, -atheris_runs=N fixe
un nombre exact d'exécutions. Le second est préférable en CI car sa durée est
reproductible d'une machine à l'autre, indépendamment de la puissance du
runner.
Un job minimal enchaîne l'installation, le rejeu du corpus de régression (pour bloquer tout retour d'un bug connu), puis un fuzzing court sur les nouvelles mutations :
pip install atheris# Rejoue les crashs archives : echoue si un bug connu revientpython fuzz_rle.py corpus/# Fuzzing court borne pour chercher du nouveaupython fuzz_rle.py -atheris_runs=50000 -artifact_prefix=./Le fuzzing long (des heures ou des jours) se fait en continu, pas en CI. C'est le rôle d'infrastructures comme OSS-Fuzz pour les projets open source, ou de ClusterFuzzLite pour un fuzzing court déclenché sur chaque PR. La CI de PR sert à attraper les régressions évidentes et à faire tourner le corpus existant ; la recherche profonde de nouveaux bugs demande du temps machine dédié.
Le piège des extensions natives
Section intitulée « Le piège des extensions natives »Atheris sait fuzzer du code natif (extensions C, Rust, Cython), mais pas
n'importe comment. Le code Python pur ne bénéficie pas des sanitizers
(ASan, UBSan) : ces détecteurs de corruption mémoire n'ont de sens que pour du
code compilé. Pour fuzzer une extension native avec détection de
débordements et d'usages après libération, il faut la recompiler avec Clang
et les drapeaux -fsanitize=address,fuzzer-no-link, puis précharger la
bibliothèque ASan d'Atheris via LD_PRELOAD (souvent avec -detect_leaks=0).
Le piège documenté est le suivant : n'enveloppez pas dans
instrument_imports() une extension native qui n'a pas été instrumentée à la
compilation. D'après la documentation d'Atheris, cette combinaison peut
provoquer un plantage brutal (segfault). La règle prudente : instrumentez le
code Python pur avec instrument_imports, et traitez les modules natifs par la
voie compilation + LD_PRELOAD décrite dans le guide
native_extension_fuzzing.md du dépôt. Vérifiez toujours la procédure exacte
dans la documentation officielle avant de fuzzer une extension binaire, car les
détails dépendent de votre chaîne de compilation.
À retenir
Section intitulée « À retenir »- Atheris 3.1.0 s'installe en une commande (
pip install atheris) sur Python 3.11 à 3.14, wheel précompilée, sans Clang pour du Python pur. - Le harnais tient en dix lignes :
instrument_imports,FuzzedDataProvider, un invariant, puisSetupetFuzz. - Un invariant de round-trip transforme le fuzzing en property-based testing : on cherche une propriété violée, pas un crash mémoire.
- Le bug est tombé en quelques dizaines d'exécutions : entrée
'676', format ambigu où le compteur avale les chiffres de la donnée. - La correction rend le format auto-délimité (
<compteur>:<caractère>) ; le re-fuzz de 200 000 exécutions ressort vert, sans fichier de crash. - Chaque crash reproductible est un test de régression : archivez les
fichiers
crash-<hash>et rejouez-les avant de refuzzer. - En CI, bornez le budget avec
-atheris_runs(durée reproductible) ; le fuzzing long reste une campagne continue, pas un job de PR. - Le fuzzing prouve la présence de bugs, jamais leur absence. La preuve attendue par l'exigence socle SOCLE-APP-AST-4 est une campagne de fuzzing sur les composants critiques ; cette pratique nourrit aussi le contrôle Fuzzing d'OpenSSF Scorecard.