🔝 Retour au Sommaire
Le standard C++ définit un ensemble d'opérations dont le résultat est explicitement "indéfini" — le compilateur est libre de produire n'importe quel comportement, y compris un comportement qui semble correct. C'est ce qu'on appelle le undefined behavior (UB), et c'est le concept le plus traître du langage.
Pourquoi traître ? Parce que le UB ne plante pas. Pas tout de suite, en tout cas. Un entier signé qui overflow produit un résultat qui a l'air correct — jusqu'au jour où le compilateur, à un niveau d'optimisation différent, exploite l'hypothèse que les entiers signés ne débordent jamais et supprime votre vérification de limites. Un décalage de bits invalide retourne une valeur qui, par chance, est celle que vous attendiez — jusqu'à un changement de compilateur ou d'architecture. Un accès via un pointeur null lit une valeur quelconque de la mémoire — jusqu'à ce que le système d'exploitation décide que cette adresse est protégée.
Le UB est un bug dormant. Il fonctionne par accident, pas par design. Et quand il cesse de fonctionner, le symptôme peut se manifester à des milliers de lignes de distance de sa cause.
UndefinedBehaviorSanitizer — UBSan — détecte ces situations à l'exécution. Chaque opération susceptible de provoquer un comportement indéfini est instrumentée par une vérification. Si l'opération est invalide, UBSan signale l'erreur avec la ligne exacte, l'opération en cause, et les valeurs impliquées.
g++ -fsanitize=undefined -g -O1 -fno-omit-frame-pointer -o prog main.cppComme pour ASan, le flag -fsanitize=undefined doit être passé au compilateur et au linker. La combinaison avec ASan est recommandée et n'entraîne qu'un surcoût marginal :
# La combinaison recommandée pour le développement quotidien
g++ -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer -o prog main.cppC'est l'atout majeur d'UBSan : son impact sur les performances est quasi négligeable.
| Métrique | Surcoût typique |
|---|---|
| Temps d'exécution | ~5-20 % plus lent |
| Mémoire | Négligeable |
| Taille du binaire | ~10-15 % plus gros |
Ce surcoût minimal s'explique par la nature des vérifications : UBSan ajoute quelques instructions de test avant les opérations sensibles (overflow, shift, division), mais ne maintient pas de shadow memory ni de quarantaine comme ASan. Le programme instrumenté reste proche du programme normal en termes de comportement et de performance.
Ce faible coût rend UBSan utilisable dans des contextes où ASan serait trop coûteux — y compris, dans certains cas, sur des builds proches de la production.
Le flag undefined est un raccourci qui active un ensemble de vérifications. Comprendre lesquelles vous permet de choisir un sous-ensemble plus ciblé si nécessaire.
| Check | Détecte |
|---|---|
signed-integer-overflow |
Débordement d'entier signé (INT_MAX + 1) |
unsigned-integer-overflow |
|
shift |
Décalage de bits invalide (négatif ou ≥ largeur du type) |
divide-by-zero |
Division entière par zéro |
null |
Déréférencement de pointeur null |
alignment |
Accès mémoire mal aligné |
bool |
Chargement d'un bool avec une valeur autre que 0 ou 1 |
enum |
Chargement d'un enum avec une valeur hors de sa plage |
float-cast-overflow |
Conversion flottant → entier hors limites |
float-divide-by-zero |
Division flottante par zéro |
return |
Atteindre la fin d'une fonction non-void sans return |
unreachable |
Exécution de __builtin_unreachable() |
vla-bound |
Tableau à taille variable avec taille ≤ 0 |
object-size |
Accès via un pointeur dont la taille de l'objet est connue |
pointer-overflow |
Arithmétique de pointeur qui overflow |
vptr |
Appel virtuel ou cast via un objet du mauvais type dynamique |
nonnull-attribute |
Passage de nullptr à un paramètre marqué __attribute__((nonnull)) |
returns-nonnull-attribute |
Retour de nullptr d'une fonction marquée nonnull |
builtin |
Argument invalide passé à un builtin (__builtin_clz(0)) |
⚠️ L'overflow d'entier non signé n'est PAS un comportement indéfini en C++. Il est défini comme un modulo 2^N. UBSan ne l'inclut pas dans-fsanitize=undefined. Si vous voulez le détecter (parce qu'un overflow non signé est souvent un bug logique même s'il n'est pas un UB), activez-le explicitement :-fsanitize=unsigned-integer-overflow.
Vous pouvez activer un check spécifique au lieu du groupe complet :
# Seulement les overflows d'entiers signés
g++ -fsanitize=signed-integer-overflow -g -O1 -o prog main.cpp
# Overflows signés + non signés
g++ -fsanitize=signed-integer-overflow,unsigned-integer-overflow -g -O1 -o prog main.cpp
# Tout undefined + unsigned overflow (la couverture maximale)
g++ -fsanitize=undefined,unsigned-integer-overflow -g -O1 -o prog main.cppPour activer le groupe undefined mais exclure un check qui produit trop de bruit :
# Tout sauf l'alignement (courant avec du code legacy ou du SIMD)
g++ -fsanitize=undefined -fno-sanitize=alignment -g -O1 -o prog main.cppLe comportement indéfini le plus courant et le plus sous-estimé. En C++, l'overflow d'un entier signé est indéfini. Cela signifie que le compilateur peut supposer qu'il n'arrive jamais — et exploiter cette hypothèse pour des optimisations agressives.
// signed_overflow.cpp
#include <cstdio>
#include <climits>
int main() {
int x = INT_MAX;
int y = x + 1; // UB : overflow d'entier signé
std::printf("INT_MAX + 1 = %d\n", y);
return 0;
}Sans UBSan, ce programme affiche probablement INT_MAX + 1 = -2147483648 (le comportement de wrap-around en complément à deux). Ça a l'air de "fonctionner". Mais c'est un mirage — le compilateur a le droit de faire autre chose, et certaines optimisations exploitent exactement cette liberté.
g++ -fsanitize=undefined -g -O1 -fno-omit-frame-pointer -o signed_overflow signed_overflow.cpp
./signed_overflowsigned_overflow.cpp:6:19: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior signed_overflow.cpp:6:19
Le rapport est concis et précis : le fichier, la ligne, la colonne, l'opération exacte (2147483647 + 1), et la raison (cannot be represented in type 'int').
Considérez cette vérification de limites :
bool is_safe(int offset, int length) {
return offset + length > 0; // Vérifie que la somme est positive
}Si offset et length sont tous deux positifs, le compilateur peut raisonner ainsi : "La somme de deux entiers signés positifs ne peut pas être négative (car l'overflow est UB et n'arrive donc jamais). Donc cette fonction retourne toujours true." Et il élimine la vérification. Votre garde de sécurité disparaît du binaire compilé.
C'est le mécanisme exact par lequel des vulnérabilités de sécurité naissent de UB exploité par le compilateur. UBSan les détecte avant qu'elles ne causent des problèmes.
Un décalage de bits est indéfini quand le nombre de positions est négatif ou supérieur ou égal à la largeur du type, ou quand un shift gauche sur un entier signé produit un overflow.
// shift_ub.cpp
#include <cstdio>
#include <cstdint>
int main() {
int32_t a = 1;
int32_t b = a << 32; // UB : shift >= largeur du type (32 bits)
std::printf("1 << 32 = %d\n", b);
int32_t c = -1;
int32_t d = c << 2; // UB avant C++20 : shift gauche d'un négatif
std::printf("-1 << 2 = %d\n", d);
int shift_amount = -3;
int32_t e = a << shift_amount; // UB : shift par valeur négative
std::printf("1 << -3 = %d\n", e);
return 0;
}shift_ub.cpp:6:22: runtime error: shift exponent 32 is too large for 32-bit type 'int'
shift_ub.cpp:9:22: runtime error: left shift of negative value -1
shift_ub.cpp:12:22: runtime error: shift exponent -3 is negative
Trois erreurs, trois lignes, trois explications distinctes. Chaque rapport identifie le problème exact.
📝 Note C++20 : depuis C++20, le shift gauche d'une valeur négative est défini (résultat en complément à deux). UBSan ajuste ses vérifications selon le standard utilisé (
-std=c++20vs-std=c++17). Le shift par une valeur ≥ largeur du type reste indéfini dans tous les standards.
La division entière par zéro est un comportement indéfini. Sur la plupart des architectures, elle provoque un signal SIGFPE, mais ce n'est pas garanti par le standard.
// div_zero.cpp
#include <cstdio>
int average(int total, int count) {
return total / count; // UB si count == 0
}
int main() {
std::printf("Moyenne : %d\n", average(100, 0));
return 0;
}div_zero.cpp:4:18: runtime error: division by zero
Simple, direct. Sans UBSan, ce programme crashe probablement avec un SIGFPE, mais le signal ne vous donne pas la ligne exacte ni le contexte — UBSan oui.
Accéder à la mémoire via un pointeur null est un comportement indéfini. Le résultat le plus courant est un SIGSEGV, mais le compilateur peut aussi optimiser en supposant que le pointeur n'est jamais null.
// null_deref.cpp
#include <cstdio>
struct Config {
int port;
const char* host;
};
void print_config(Config* cfg) {
std::printf("Host: %s, Port: %d\n", cfg->host, cfg->port);
}
int main() {
Config* cfg = nullptr;
print_config(cfg); // UB : déréférencement de nullptr
return 0;
}null_deref.cpp:10:48: runtime error: member access within null pointer of type 'Config'
UBSan identifie précisément l'opération : un accès membre (cfg->host) via un pointeur null de type Config. Le rapport apparaît avant le crash — UBSan intercepte le UB, pas le signal résultant.
Considérez cette variante :
void print_config(Config* cfg) {
if (cfg == nullptr) {
std::printf("Configuration vide\n");
return;
}
std::printf("Host: %s\n", cfg->host);
}
int main() {
Config* cfg = nullptr;
cfg->port = 8080; // UB : écriture via nullptr
print_config(cfg); // Le compilateur peut éliminer le check null
}Après la ligne cfg->port = 8080, le compilateur est en droit de supposer que cfg != nullptr (sinon, il y aurait un UB, et le programme n'a pas le droit de provoquer un UB). En conséquence, il peut éliminer le if (cfg == nullptr) dans print_config comme étant "mort" — il "sait" que cfg n'est pas null.
UBSan détecte le problème dès la ligne cfg->port = 8080, bien avant que les optimisations n'aient effacé vos gardes de sécurité.
La conversion d'un nombre flottant vers un type entier est indéfinie si la valeur flottante est hors de la plage représentable par l'entier.
// float_cast.cpp
#include <cstdio>
#include <cmath>
int main() {
double big = 1e18;
int truncated = static_cast<int>(big); // UB : 1e18 > INT_MAX
std::printf("Tronqué : %d\n", truncated);
double nan = std::nan("");
int from_nan = static_cast<int>(nan); // UB : NaN → int
std::printf("NaN → int : %d\n", from_nan);
return 0;
}float_cast.cpp:6:21: runtime error: 1e+18 is outside the range of representable values of type 'int'
float_cast.cpp:9:23: runtime error: value nan is outside the range of representable values of type 'int'
Ce type de bug survient fréquemment dans le traitement de données externes — un fichier JSON contient un nombre flottant que vous convertissez en entier sans vérification de limites.
Atteindre la fin d'une fonction déclarée comme retournant une valeur sans exécuter de return est un comportement indéfini. Le compilateur peut générer n'importe quelle valeur de retour — ou ne rien retourner du tout.
// missing_return.cpp
#include <cstdio>
int classify(int value) {
if (value > 0) return 1;
if (value < 0) return -1;
// Bug : pas de return quand value == 0
}
int main() {
std::printf("Classification de 0 : %d\n", classify(0));
return 0;
}missing_return.cpp:6:1: runtime error: execution reached the end of a value-returning
function without returning a value
Le compilateur émet généralement un warning (-Wreturn-type) pour ce cas. Mais le warning n'apparaît qu'à la compilation — si vous n'avez pas -Werror, le bug passe en production. UBSan le détecte à l'exécution, quand le chemin problématique est réellement emprunté.
Certaines architectures exigent que les accès mémoire soient alignés : un int (4 octets) doit être lu à une adresse multiple de 4. Un accès mal aligné est un comportement indéfini — il peut fonctionner (x86 le tolère avec une pénalité de performance), crasher (ARM strict), ou produire des données corrompues.
// alignment.cpp
#include <cstdio>
#include <cstdint>
int main() {
char buffer[16] = {};
// Forcer un pointeur int* mal aligné
int* misaligned = reinterpret_cast<int*>(buffer + 1);
*misaligned = 42; // UB : adresse non alignée sur 4 octets
std::printf("Valeur : %d\n", *misaligned);
return 0;
}alignment.cpp:9:6: runtime error: store to misaligned address 0x7ffc12345679 for type
'int', which requires 4 byte alignment
0x7ffc12345679: note: pointer points here
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Sur x86_64, ce code "fonctionne" — le processeur tolère les accès mal alignés. Mais c'est du UB, et UBSan le signale à raison. Sur ARM ou dans du code SIMD, ce même accès crasherait ou produirait un résultat incorrect.
Le check alignment est le plus susceptible de produire du bruit dans du code legacy ou du code qui fait du networking/sérialisation (casting de buffers en structures). Si les faux positifs sont trop nombreux :
g++ -fsanitize=undefined -fno-sanitize=alignment -g -O1 -o prog main.cppLe check vptr détecte les appels de fonctions virtuelles via un objet dont le type dynamique est incorrect — typiquement, un objet qui a été détruit mais dont la mémoire est réutilisée.
// bad_vptr.cpp
#include <cstdio>
#include <cstdlib>
struct Base {
virtual void process() { std::printf("Base::process\n"); }
virtual ~Base() = default;
};
struct Derived : Base {
void process() override { std::printf("Derived::process\n"); }
};
int main() {
Base* obj = new Derived();
obj->~Base(); // Destruction manuelle
obj->process(); // UB : vtable détruite
std::free(obj); // Libération sans passer par delete
return 0;
}bad_vptr.cpp:14:9: runtime error: member call on address 0x602000000010 which does
not point to an object of type 'Base'
0x602000000010: note: object has invalid vptr
Ce check nécessite des informations RTTI. Il est activé par défaut avec -fsanitize=undefined mais peut être désactivé si vous compilez avec -fno-rtti :
# Si votre projet utilise -fno-rtti, désactivez vptr
g++ -fsanitize=undefined -fno-sanitize=vptr -fno-rtti -g -O1 -o prog main.cppPar défaut, UBSan a un comportement différent d'ASan :
- ASan : arrête le programme à la première erreur (
abort) - UBSan : affiche le rapport et continue l'exécution
Ce comportement par défaut est voulu : un programme peut contenir de nombreux UB bénins (du point de vue fonctionnel), et arrêter à la première occurrence masquerait les autres. Vous voyez tous les problèmes en une seule exécution.
Pour arrêter le programme à la première erreur UBSan :
# Option 1 : via variable d'environnement
UBSAN_OPTIONS="halt_on_error=1" ./prog
# Option 2 : via flag de compilation (piège le UB comme un signal)
g++ -fsanitize=undefined -fno-sanitize-recover=all -g -O1 -o prog main.cppLe flag -fno-sanitize-recover=all transforme chaque UB détecté en arrêt fatal. C'est recommandé en CI — un build avec UBSan doit échouer si un UB est détecté.
Vous pouvez choisir quels checks sont fatals et lesquels ne font qu'avertir :
# Overflow signé = fatal, tout le reste = warning
g++ -fsanitize=undefined \
-fno-sanitize-recover=signed-integer-overflow \
-g -O1 -o prog main.cppPar défaut, UBSan affiche uniquement la ligne de l'erreur, sans pile d'appels. Pour obtenir un backtrace :
UBSAN_OPTIONS="print_stacktrace=1" ./progLe rapport devient :
signed_overflow.cpp:6:19: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
#0 0x4f4b26 in compute(int, int) signed_overflow.cpp:6
#1 0x4f4c10 in process_batch(std::vector<int> const&) batch.cpp:42
#2 0x4f5012 in main main.cpp:15
La pile d'appels montre le chemin complet qui a mené à l'opération invalide. C'est indispensable pour diagnostiquer un UB dans du code imbriqué.
L'option print_stacktrace=1 nécessite que le programme soit compilé avec -fno-omit-frame-pointer pour des backtraces fiables. C'est une raison de plus d'inclure ce flag systématiquement.
Pour les environnements très contraints (embarqué, production), UBSan peut être compilé en mode trap : au lieu d'afficher un rapport, il exécute une instruction trap qui provoque un arrêt immédiat du programme, sans aucune dépendance à la bibliothèque runtime d'UBSan.
g++ -fsanitize=undefined -fsanitize-trap=all -g -O1 -o prog main.cppAvantages :
- Aucune bibliothèque runtime nécessaire (pas de
libubsan) - Aucune sortie texte (pas de fuite d'information en production)
- Surcoût de taille quasi nul
- Le core dump résultant permet d'identifier la ligne via GDB
Inconvénient : pas de message d'erreur. Vous voyez uniquement un crash (SIGILL ou SIGTRAP) et devez charger le core dump dans GDB pour comprendre quel check a déclenché le trap.
C'est un compromis intéressant pour la production : le programme est protégé contre les UB les plus graves (il s'arrête proprement au lieu de continuer dans un état corrompu), sans le surcoût du runtime de reporting.
Comme pour ASan, UBSan se combine bien avec GDB. Quand UBSan est configuré pour être fatal (-fno-sanitize-recover=all ou halt_on_error=1), GDB intercepte l'erreur :
UBSAN_OPTIONS="halt_on_error=1" gdb ./prog
(gdb) run
# UBSan détecte un UB et appelle abort()
(gdb) backtrace
(gdb) info locals
(gdb) print xVous pouvez aussi poser un breakpoint sur le handler UBSan :
(gdb) break __ubsan_handle_add_overflow
(gdb) run
Les noms des handlers suivent le pattern __ubsan_handle_<check_name>. Les plus courants :
| Handler | Check |
|---|---|
__ubsan_handle_add_overflow |
Overflow sur addition |
__ubsan_handle_sub_overflow |
Overflow sur soustraction |
__ubsan_handle_mul_overflow |
Overflow sur multiplication |
__ubsan_handle_divrem_by_zero |
Division par zéro |
__ubsan_handle_shift_out_of_bounds |
Shift invalide |
__ubsan_handle_type_mismatch_v1 |
Null deref, alignement, vptr |
__ubsan_handle_missing_return |
Fonction non-void sans return |
L'overflow d'entier non signé n'est pas un comportement indéfini — le standard définit un wrap-around modulo 2^N. Cependant, un overflow non signé est presque toujours un bug logique. Un compteur qui wrappe de UINT_MAX à 0, une soustraction qui wrappe de 0 à UINT_MAX — ce sont des bugs, même s'ils ne sont pas des UB.
UBSan propose un check dédié, exclu de -fsanitize=undefined par défaut :
g++ -fsanitize=unsigned-integer-overflow -g -O1 -o prog main.cpp// unsigned_overflow.cpp
#include <cstdio>
#include <cstdint>
int main() {
uint32_t a = 0;
uint32_t b = a - 1; // Wrap-around : b = 4294967295
std::printf("0 - 1 = %u\n", b);
return 0;
}unsigned_overflow.cpp:6:22: runtime error: unsigned integer overflow:
0 - 1 cannot be represented in type 'unsigned int'
Activez-le si votre code n'utilise pas intentionnellement le wrap-around non signé. Désactivez-le (ou supprimez les faux positifs) si votre code fait du hashing, de la cryptographie, ou du calcul modulaire où le wrap-around est voulu.
# La couverture maximale (avec tous les overflows)
g++ -fsanitize=undefined,unsigned-integer-overflow \
-g -O1 -fno-omit-frame-pointer -o prog main.cppPour marquer une fonction qui utilise intentionnellement le wrap-around :
__attribute__((no_sanitize("unsigned-integer-overflow")))
uint32_t hash_combine(uint32_t seed, uint32_t value) {
return seed ^ (value + 0x9e3779b9 + (seed << 6) + (seed >> 2));
}La configuration CMake standard pour UBSan, compatible avec la structure présentée en section 29.4 :
option(ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
if(ENABLE_UBSAN)
add_compile_options(
-fsanitize=undefined
-fno-omit-frame-pointer
-fno-sanitize-recover=all # Fatal en CI
)
add_link_options(-fsanitize=undefined)
endif()Pour la couverture maximale :
if(ENABLE_UBSAN)
set(UBSAN_FLAGS
-fsanitize=undefined,unsigned-integer-overflow
-fno-omit-frame-pointer
-fno-sanitize-recover=all
)
add_compile_options(${UBSAN_FLAGS})
add_link_options(-fsanitize=undefined,unsigned-integer-overflow)
endif()Les deux compilateurs supportent UBSan, mais avec quelques différences :
| Aspect | GCC 15 | Clang 20 |
|---|---|---|
| Checks de base | ✅ Tous supportés | ✅ Tous supportés |
-fsanitize-trap |
✅ Supporté | ✅ Supporté |
unsigned-integer-overflow |
✅ Supporté | ✅ Supporté |
-fsanitize=implicit-conversion |
❌ Non supporté | ✅ Supporté |
-fsanitize=local-bounds |
❌ Non supporté | ✅ Supporté |
| Qualité des messages d'erreur | Bonne | Légèrement meilleure |
Clang propose deux checks supplémentaires intéressants :
implicit-conversion: détecte les conversions implicites qui changent la valeur (par exemple, unintnégatif implicitement converti enunsigned).local-bounds: détecte les accès hors limites sur des tableaux locaux dont la taille est connue à la compilation.
Si vous avez Clang disponible, c'est un argument pour l'utiliser comme compilateur de sanitizer, même si votre build de production utilise GCC.
- ☐ Activer UBSan avec ASan —
-fsanitize=address,undefinedcomme configuration de développement par défaut - ☐
-fno-sanitize-recover=allen CI — chaque UB détecté fait échouer le build - ☐
print_stacktrace=1— toujours inclure les piles d'appels dans les rapports - ☐ Considérer
unsigned-integer-overflow— l'activer si votre code ne fait pas de calcul modulaire intentionnel - ☐
-fno-sanitize=alignmentsi nécessaire — pour du code réseau/sérialisation qui fait des casts de buffers - ☐ Zéro UB toléré — un comportement indéfini est toujours un bug, même quand il "fonctionne"
À retenir : le comportement indéfini est un bug qui fonctionne par accident. UBSan le détecte avec un surcoût quasi négligeable (~5-20 %), ce qui en fait le sanitizer le plus facile à adopter. Combiné avec ASan, il forme le socle de détection automatique qui devrait être actif par défaut dans tout projet C++ sérieux. Si un UB est détecté, corrigez-le — même s'il "ne pose pas de problème" aujourd'hui. C'est un problème en attente.