Aller au contenu

AddressSanitizer, UBSan et compagnie : guide pratique

Comment fonctionnent ASan, UBSan, MSan, TSan et HWASan, quels flags et options utiliser, comment lire un rapport ASan ligne par ligne, et les lancer en CI.

Publié le 7 min de lecture

Les sanitizers sont de l'instrumentation du compilateur plus une bibliothèque d'exécution qui vérifient, pendant que le programme tourne, les erreurs que le C et le C++ laissent autrement silencieuses. Ils constituent l'outil isolé le plus efficace pour trouver des bugs de sûreté mémoire avant la livraison : un débordement de tas qui corromprait discrètement la mémoire dans une build normale devient un rapport immédiat et précis, avec des piles d'appels. Ce guide couvre les principaux sanitizers de GCC et Clang, comment les activer, comment lire leur sortie, et comment en faire une partie du test quotidien. Associez-le au fuzzing guidé par couverture, qui alimente les builds instrumentées avec les entrées qui déclenchent les bugs.

La famille des sanitizers

SanitizerFlagDétecteSurcoût typiqueCompilateur
AddressSanitizer (ASan)-fsanitize=addressHors-limites sur le tas, la pile et les globales ; use-after-free ; double free ; use-after-return (optionnel)~2x CPU, 2–3x mémoireGCC, Clang, MSVC
LeakSanitizer (LSan)activé avec ASan, ou -fsanitize=leakFuites mémoire à la sortieFaibleGCC, Clang
UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefinedDébordement signé, décalages invalides, usage de pointeur mal aligné ou nul, mauvais casts, indices de tableau hors-limites (certains)Faible à modéréGCC, Clang
MemorySanitizer (MSan)-fsanitize=memoryLectures de mémoire non initialisée~3x CPUClang uniquement
ThreadSanitizer (TSan)-fsanitize=threadAccès concurrents (data races)5–15x CPU, 5–10x mémoireGCC, Clang
HWASan-fsanitize=hwaddressComme ASan, à l'aide de marqueurs de pointeurMoins de mémoire qu'ASanClang, AArch64 (x86-64 expérimental)

ASan et UBSan ont leur place dans la matrice de tests de tout projet C et C++. MSan et TSan sont spécialisés : ajoutez-les quand les lectures non initialisées ou les bugs de concurrence sont un risque réaliste.

Comment fonctionne AddressSanitizer

ASan associe chaque tranche de 8 octets de la mémoire de l'application à un octet de shadow memory qui enregistre combien de ces 8 octets sont adressables. Le compilateur insère une vérification avant chaque chargement et stockage : calculer l'adresse shadow, lire l'octet shadow, et signaler si l'accès touche de la mémoire empoisonnée.

application memory     shadow byte   meaning
[ 8 bytes ]      ->    00            all 8 bytes addressable
[ 8 bytes ]      ->    04            first 4 bytes addressable
[ 8 bytes ]      ->    fa            heap redzone
[ 8 bytes ]      ->    fd            freed heap memory
[ 8 bytes ]      ->    f1 f2 f3      stack redzones (left, mid, right)

Trois fonctionnalités d'exécution lui font attraper autant de choses :

  • Redzones. Chaque allocation sur le tas, tableau de pile et globale reçoit un rembourrage empoisonné autour de lui, de sorte que les débordements touchent immédiatement du poison.
  • Quarantaine. La mémoire libérée est empoisonnée et tenue à l'écart de la réutilisation pendant un moment (256 Mo par défaut sur Linux 64 bits), de sorte qu'un use-after-free touche du poison plutôt qu'un nouvel objet.
  • Intercepteurs. Des fonctions comme memcpy, strcpy et free sont remplacées par des versions vérificatrices qui valident toute leur plage.

Compiler avec des sanitizers

# Build de test : ASan + UBSan, bonnes piles d'appels
clang -O1 -g -fno-omit-frame-pointer \
      -fsanitize=address,undefined \
      -fno-sanitize-recover=undefined \
      -o parser_test parser.c parser_test.c
  • -O1 garde la build assez rapide tout en laissant le code reconnaissable ; -O0 marche aussi mais est plus lent sous ASan.
  • -g -fno-omit-frame-pointer produit des piles d'appels exactes et symbolisées.
  • -fno-sanitize-recover=undefined fait avorter UBSan à la première erreur au lieu d'afficher et de continuer, de sorte que les tests échouent.
  • Passez les mêmes flags -fsanitize= à l'étape d'édition de liens. Avec CMake, ajoutez-les à la fois à CMAKE_C_FLAGS et CMAKE_EXE_LINKER_FLAGS, ou utilisez add_compile_options et add_link_options.

Options d'exécution

Les sanitizers lisent leurs options depuis des variables d'environnement. Un défaut raisonnable pour la CI :

export ASAN_OPTIONS="abort_on_error=1:detect_leaks=1:strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
OptionEffet
abort_on_error=1Avorte (et vide un core si activé) au lieu de _exit, utile pour les débogueurs et collecteurs de crashs
detect_leaks=1Lance LeakSanitizer à la sortie
detect_stack_use_after_return=1Attrape les pointeurs vers des locales utilisés après le retour de la fonction (coûte de la mémoire)
strict_string_checks=1Vérifie que les arguments de type chaîne sont correctement terminés
quarantine_size_mb=NUne quarantaine plus grande attrape les use-after-free avec des délais plus longs
symbolize=1Symbolise les piles d'appels (nécessite llvm-symbolizer dans le PATH)

Lire un rapport ASan

Considérez un bug jouet : une fonction qui copie le nom d'un enregistrement dans un tampon de tas dimensionné un octet trop petit.

char *dup_name(const char *src, size_t n) {
    char *out = malloc(n);        /* oubli de la place pour le terminateur */
    memcpy(out, src, n);
    out[n] = '\0';                /* écrit un octet au-delà de l'allocation */
    return out;
}

Le rapport, annoté :

==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000015 at pc 0x55f1c2a3b4e7 bp 0x7ffe... sp 0x7ffe...
WRITE of size 1 at 0x602000000015 thread T0            <- (1) type, direction et taille
    #0 0x55f1c2a3b4e6 in dup_name records.c:4           <- (2) où l'accès fautif a eu lieu
    #1 0x55f1c2a3b61a in parse_record records.c:17
    #2 0x55f1c2a3b7d0 in main records.c:31

0x602000000015 is located 0 bytes after 5-byte region [0x602000000010,0x602000000015)
                                                        <- (3) position relative à l'objet
allocated by thread T0 here:                            <- (4) d'où vient l'objet
    #0 0x7f1a4c8b1c57 in malloc
    #1 0x55f1c2a3b4c1 in dup_name records.c:2
    #2 0x55f1c2a3b61a in parse_record records.c:17

SUMMARY: AddressSanitizer: heap-buffer-overflow records.c:4 in dup_name
Shadow bytes around the buggy address:
  0x0c047fff8000: fa fa 00 05 fa fa ...                 <- (5) 05 = 5 octets adressables, puis redzone

Comment le lire :

  1. Type, direction et taille. heap-buffer-overflow, un WRITE de 1 octet. Les écritures sont généralement plus graves que les lectures, et les petits débordements sont souvent des erreurs d'un pas (off-by-one).
  2. Pile d'accès. Le cadre #0 dans votre code est le point de départ. Les cadres à l'intérieur des intercepteurs (memcpy, strcpy) pointent vers l'appelant un cadre plus haut.
  3. Position relative. « 0 bytes after 5-byte region » est la signature classique du off-by-one. « Located 4096 bytes to the right » signifie un accès bien plus sauvage, souvent un bug d'entier dans la taille ou l'indice.
  4. Pile d'allocation. Montre quel objet était impliqué. Pour les rapports de use-after-free, il y a aussi une pile « freed by thread T0 here », habituellement la route la plus rapide vers la cause racine.
  5. Octets shadow. Confirment la taille de l'objet et le type de redzone (fa redzone de tas, fd mémoire libérée, f1/f2/f3 redzones de pile).

Les types de rapport courants correspondent directement aux classes de bugs décrites dans corruption du tas et use-after-free et débordements de tampon de pile :

RapportCause racine habituelle
heap-buffer-overflowBorne manquante ou erronée, off-by-one, bug d'arithmétique de taille
stack-buffer-overflowDépassement d'un tableau local
global-buffer-overflowDépassement d'un tableau statique ou global
heap-use-after-freeBug de durée de vie ou de propriété
attempting double-freeDeux propriétaires, un chemin d'erreur libère deux fois
stack-use-after-returnUn pointeur vers une locale s'échappe de sa fonction
SEGV on unknown addressPointeur sauvage ou NULL ; regardez l'adresse pour distinguer lequel

UndefinedBehaviorSanitizer

UBSan vérifie le comportement indéfini qui précède souvent la corruption mémoire, en particulier les bugs d'entiers (voir débordements d'entiers et chaînes de format). Ses messages tiennent en une ligne avec fichier, ligne et colonne :

decode.c:58:19: runtime error: signed integer overflow: 2147483600 + 100 cannot be represented in type 'int'
decode.c:73:9: runtime error: load of misaligned address 0x55d3e1c0a2b3 for type 'uint32_t', which requires 4 byte alignment

-fsanitize=undefined active un groupe par défaut. Ajouts utiles dans Clang : -fsanitize=integer (inclut le débordement non signé et les conversions implicites, qui ne sont pas des UB mais souvent des bugs) et -fsanitize=bounds (vérifications d'indice de tableau quand la taille est connue). UBSan peut aussi tourner en mode minimal ou trap (-fsanitize-trap=undefined) sans bibliothèque d'exécution, ce que certains projets utilisent dans des builds de production durcies pour une poignée de vérifications peu coûteuses.

Les sanitizers en CI

  • Compilez une variante instrumentée de votre suite de tests et lancez-la à chaque pull request. Traitez tout rapport comme un échec de test.
  • Gardez les suppressions minimales et revues. Les suppressions LSan pour des fuites connues de tiers sont acceptables ; supprimer des erreurs ASan dans votre propre code ne l'est pas.
  • Instrumentez les dépendances là où cela compte. ASan tolère les bibliothèques non instrumentées (il intercepte les fonctions courantes), mais les bugs à l'intérieur de ces bibliothèques ne sont attrapés que si elles sont instrumentées aussi. MSan exige une instrumentation complète.
  • Lancez les fuzzers contre les builds instrumentées. Les sanitizers ont besoin d'entrées qui exercent le bug ; les fuzzers sont le moyen le plus efficace de les produire.
  • Notez la version de la chaîne d'outils. Les formats de rapport et les options par défaut changent d'une version à l'autre.

Ce que les sanitizers ne font pas

Les sanitizers ne trouvent que les bugs sur les chemins de code que les tests exécutent réellement, et seulement pour les types de bugs qu'ils instrumentent. ASan ne détecte pas les lectures de mémoire non initialisée (MSan si), les accès concurrents (TSan si) ni les débordements intra-objet à l'intérieur d'une structure. Aucun d'eux ne remplace les mitigations en production. Pour une détection en production, regardez GWP-ASan, un allocateur d'échantillonnage utilisé dans Chrome, Android et ailleurs, qui place une petite fraction aléatoire des allocations sur des pages gardées à coût négligeable, et le marquage mémoire matériel (Arm MTE) là où il est disponible.

Checklist

  • Ajoutez une build ASan+UBSan avec -O1 -g -fno-omit-frame-pointer à la CI et faites-la échouer sur tout rapport.
  • Définissez ASAN_OPTIONS et UBSAN_OPTIONS explicitement pour que le comportement ne dépende pas des défauts.
  • Lisez les rapports depuis le haut : type et taille, pile d'accès, position relative, piles d'allocation et de libération.
  • Ajoutez des builds MSan et TSan là où la mémoire non initialisée ou la concurrence sont des risques réels.
  • Alimentez la build instrumentée avec un fuzzer, et utilisez les techniques de triage des crashs pour prioriser les résultats.

Guides associés