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.
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
| Sanitizer | Flag | Détecte | Surcoût typique | Compilateur |
|---|---|---|---|---|
| AddressSanitizer (ASan) | -fsanitize=address | Hors-limites sur le tas, la pile et les globales ; use-after-free ; double free ; use-after-return (optionnel) | ~2x CPU, 2–3x mémoire | GCC, Clang, MSVC |
| LeakSanitizer (LSan) | activé avec ASan, ou -fsanitize=leak | Fuites mémoire à la sortie | Faible | GCC, Clang |
| UndefinedBehaviorSanitizer (UBSan) | -fsanitize=undefined | Dé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=memory | Lectures de mémoire non initialisée | ~3x CPU | Clang uniquement |
| ThreadSanitizer (TSan) | -fsanitize=thread | Accès concurrents (data races) | 5–15x CPU, 5–10x mémoire | GCC, Clang |
| HWASan | -fsanitize=hwaddress | Comme ASan, à l'aide de marqueurs de pointeur | Moins de mémoire qu'ASan | Clang, 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,strcpyetfreesont 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
-O1garde la build assez rapide tout en laissant le code reconnaissable ;-O0marche aussi mais est plus lent sous ASan.-g -fno-omit-frame-pointerproduit des piles d'appels exactes et symbolisées.-fno-sanitize-recover=undefinedfait 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_FLAGSetCMAKE_EXE_LINKER_FLAGS, ou utilisezadd_compile_optionsetadd_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"
| Option | Effet |
|---|---|
abort_on_error=1 | Avorte (et vide un core si activé) au lieu de _exit, utile pour les débogueurs et collecteurs de crashs |
detect_leaks=1 | Lance LeakSanitizer à la sortie |
detect_stack_use_after_return=1 | Attrape les pointeurs vers des locales utilisés après le retour de la fonction (coûte de la mémoire) |
strict_string_checks=1 | Vérifie que les arguments de type chaîne sont correctement terminés |
quarantine_size_mb=N | Une quarantaine plus grande attrape les use-after-free avec des délais plus longs |
symbolize=1 | Symbolise 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 :
- Type, direction et taille.
heap-buffer-overflow, unWRITEde 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). - Pile d'accès. Le cadre
#0dans 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. - 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.
- 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.
- Octets shadow. Confirment la taille de l'objet et le type de redzone (
faredzone de tas,fdmémoire libérée,f1/f2/f3redzones 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 :
| Rapport | Cause racine habituelle |
|---|---|
heap-buffer-overflow | Borne manquante ou erronée, off-by-one, bug d'arithmétique de taille |
stack-buffer-overflow | Dépassement d'un tableau local |
global-buffer-overflow | Dépassement d'un tableau statique ou global |
heap-use-after-free | Bug de durée de vie ou de propriété |
attempting double-free | Deux propriétaires, un chemin d'erreur libère deux fois |
stack-use-after-return | Un pointeur vers une locale s'échappe de sa fonction |
SEGV on unknown address | Pointeur 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_OPTIONSetUBSAN_OPTIONSexplicitement 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.