Aller au contenu
Sécurité medium

Fuzzing Python avec Atheris : trouver des bugs cachés

18 min de lecture

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.

  • Installer Atheris et vérifier qu'il s'importe sans compilateur.
  • Écrire un harnais avec instrument_imports et FuzzedDataProvider.
  • 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).

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 :

Fenêtre de terminal
python3 -m venv fuzz-venv
source fuzz-venv/bin/activate
pip install atheris

La 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: atheris
Successfully installed atheris-3.1.0

Vérifiez que l'import fonctionne (aucun message d'erreur attendu) :

Fenêtre de terminal
python -c "import atheris, sys; print('atheris importe, python', sys.version.split()[0])"
atheris importe, python 3.12.3

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.

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.

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.

Fenêtre de terminal
cd python
python 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 rle
INFO: Using built-in libfuzzer
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1530575919
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: 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 exited
SUMMARY: libFuzzer: fuzz target exited
artifact_prefix='./'; Test unit written to ./crash-596d7f413d70de13592c879cf9554760fb2c8875
Base64: 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).

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) :

Fenêtre de terminal
python fuzz_rle_fixed.py -atheris_runs=200000

Le 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: 41Mb
Done 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.

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 :

Fenêtre de terminal
python fuzz_rle.py crash-596d7f413d70de13592c879cf9554760fb2c8875

Le 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.

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 :

Fenêtre de terminal
pip install atheris
# Rejoue les crashs archives : echoue si un bug connu revient
python fuzz_rle.py corpus/
# Fuzzing court borne pour chercher du nouveau
python 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é.

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.

  • 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, puis Setup et Fuzz.
  • 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.

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