Débordements d'entier et bugs de chaîne de format, expliqués
Comment débordement d'entier, troncature et erreurs de signe mènent à la corruption mémoire, et pourquoi les chaînes de format contrôlées sont risquées.
Les bugs d'entier et les bugs de chaîne de format sont rarement dangereux en soi. Ils le deviennent quand leur résultat alimente une opération mémoire : une longueur passée à memcpy, une taille passée à malloc, un index dans un tableau, ou une chaîne passée à printf comme format. Ce guide couvre les deux classes du côté du défenseur : les motifs vulnérables, les fonctionnalités du compilateur qui les attrapent, et les règles de codage qui les rendent impossibles. Il complète débordements de tampon de pile et corruption du tas, qui sont fréquemment la seconde moitié d'un bug d'entier.
Bugs d'entier
Les entiers C ont des largeurs fixes, des signatures mixtes et des conversions implicites. Quatre modes de défaillance représentent la plupart des bugs réels :
| Mode de défaillance | Exemple | Résultat |
|---|---|---|
| Débordement / bouclage | count * sizeof(struct item) dépasse SIZE_MAX | Une allocation minuscule pour un compteur énorme |
| Troncature | uint16_t len = strlen(s); | Longueur silencieusement réduite modulo 65536 |
| Confusion de signe | int len depuis l'entrée est négatif, passé à memcpy comme size_t | Une valeur négative devient une taille énorme |
| Sous-dépassement | size_t remaining = total - used; avec used > total | Boucle vers une valeur proche de SIZE_MAX |
Le débordement de taille d'allocation
C'est le bug entier-vers-tas canonique (CWE-190 menant à CWE-122) :
/* Vulnérable : la multiplication peut boucler sur un size_t 32 bits ou avec des compteurs énormes. */
struct item *load_items(uint32_t count, const uint8_t *src) {
struct item *items = malloc(count * sizeof(struct item));
if (!items) return NULL;
for (uint32_t i = 0; i < count; i++)
parse_item(&items[i], src, i); /* écrit bien au-delà d'un petit tampon */
return items;
}
Si count * sizeof(struct item) boucle, malloc réussit avec un petit tampon et la boucle écrit count items complets dedans. La correction est de vérifier l'arithmétique avant de lui faire confiance :
#include <stdckdint.h> /* C23; GCC 14+, Clang 18+ */
struct item *load_items(uint32_t count, const uint8_t *src) {
size_t bytes;
if (count > MAX_ITEMS) return NULL; /* limite de domaine d'abord */
if (ckd_mul(&bytes, (size_t)count, sizeof(struct item))) /* vrai en cas de débordement */
return NULL;
struct item *items = malloc(bytes);
...
}
Sur les chaînes d'outils plus anciennes, __builtin_mul_overflow(a, b, &res) dans GCC et Clang fait le même travail. Le plus simple de tous, calloc(count, sizeof(struct item)) effectue le contrôle de débordement en interne et met la mémoire à zéro.
Signe et longueur négative
/* Vulnérable : len est signé ; une valeur négative passe le contrôle. */
int copy_field(char *dst, size_t dst_size, const char *src, int len) {
if (len > (int)dst_size) return -1; /* -1 n'est pas > dst_size */
memcpy(dst, src, len); /* -1 se convertit en SIZE_MAX */
return 0;
}
Corrigez-le en rendant les longueurs non signées dès le moment où elles entrent dans le programme, et en validant les deux bornes :
int copy_field(char *dst, size_t dst_size, const char *src, size_t len) {
if (len > dst_size) return -1;
memcpy(dst, src, len);
return 0;
}
GCC et Clang avertissent sur beaucoup de ces conversions avec -Wconversion et -Wsign-conversion. Ils sont bruyants sur le code hérité, si bien qu'une stratégie courante consiste à les activer d'abord pour les nouveaux modules et pour la couche de parsing.
Le comportement indéfini fait disparaître les contrôles
Le débordement signé est un comportement indéfini, si bien que le compilateur est autorisé à supposer qu'il ne se produit jamais. Un contrôle écrit en fonction du débordement lui-même peut être optimisé et supprimé :
/* Contrôle cassé : le compilateur peut le supprimer, puisque a + b « ne peut pas » déborder. */
if (a + b < a) return -1;
/* Correct : vérifier avant de faire l'arithmétique. */
if (b > 0 && a > INT_MAX - b) return -1;
La règle est simple : ne jamais détecter un débordement en le provoquant. Utilisez les builtins d'arithmétique vérifiée ou comparez aux limites au préalable.
Outillage pour les bugs d'entier
| Outil ou drapeau | Attrape |
|---|---|
-fsanitize=signed-integer-overflow (UBSan) | Débordement signé à l'exécution |
-fsanitize=unsigned-integer-overflow (Clang uniquement) | Bouclage non signé (pas un UB, mais souvent un bug) |
-fsanitize=implicit-conversion (Clang) | Conversions implicites tronquantes et changeant le signe |
-ftrapv | Piège sur débordement signé (plus ancien, moins précis qu'UBSan) |
-Wconversion -Wsign-conversion | Conversions risquées à la compilation |
| CodeQL / Coverity | Valeurs contaminées atteignant des tailles d'allocation et des index |
Le guide des sanitizers montre comment combiner UBSan avec ASan dans un build de test.
Bugs de chaîne de format
Les fonctions de la famille printf interprètent leur premier argument comme un petit programme : les spécificateurs de conversion tels que %s, %x et %p disent à la fonction combien d'arguments supplémentaires lire depuis les registres et la pile, et comment les interpréter. Si un attaquant contrôle ce premier argument, il contrôle la façon dont la fonction parcourt la zone d'arguments.
Le motif vulnérable
/* Vulnérable : l'entrée utilisateur est utilisée comme chaîne de format. */
void log_user(const char *msg) {
printf(msg);
}
/* Corrigé : le format est une constante ; l'entrée utilisateur est une donnée. */
void log_user(const char *msg) {
printf("%s", msg);
}
Avec la version vulnérable, une entrée contenant %x ou %p fait que printf imprime des valeurs qu'on ne lui a jamais données, ce qui fuite le contenu de la pile et, souvent, des adresses qui mettent en échec l'ASLR. Le spécificateur %n est pire : au lieu d'imprimer, il écrit le nombre de caractères produits jusque-là dans un pointeur pris dans la liste d'arguments. C'est pourquoi cette classe de bugs était historiquement jugée aussi grave qu'un débordement de tampon. Voir l'entrée de glossaire sur les vulnérabilités de chaîne de format pour une définition courte.
Le même bug apparaît avec fprintf, sprintf, snprintf, syslog, err/warn, et, le plus souvent dans le code moderne, les wrappers de journalisation personnalisés qui transmettent une chaîne fournie par l'appelant comme format.
Des défenses qui le rendent difficile à écrire
- Avertissements.
-Wformat -Wformat-securitysignale les appels où le format n'est pas un littéral de chaîne et où il n'y a pas d'autres arguments ;-Wformat=2ajoute-Wformat-nonliteralet davantage. Debian, Ubuntu et Fedora activent-Wformat -Wformat-securitydans leurs drapeaux de build de paquets par défaut, et plusieurs en font une erreur avec-Werror=format-security. - Annotez vos wrappers. Dites au compilateur quel argument est le format afin que les mêmes contrôles s'appliquent à vos propres fonctions de journalisation :
void log_msg(int level, const char *fmt, ...)
__attribute__((format(printf, 2, 3)));
- FORTIFY_SOURCE. Avec
-D_FORTIFY_SOURCE=2ou plus, les variantes vérifiées deprintfde la glibc avortent si%napparaît dans une chaîne de format située en mémoire inscriptible, ce qui neutralise la primitive d'écriture classique. - Contrôles positionnels et de largeur. Les builds fortifiés vérifient aussi que les arguments positionnels (
%1$s) sont utilisés de façon cohérente. - Règle de revue de code. Un argument de format doit être un littéral de chaîne ou provenir d'une table constante et fiable. Il n'y a pas d'exception qui vaille le risque.
Comment ces bugs se manifestent au triage
Les bugs d'entier se présentent généralement sous une autre forme : un heap-buffer-overflow d'ASan dont la taille d'allocation paraît absurdement petite, ou un memcpy avec une taille proche de 0xffffffffffffffff. Quand vous voyez cela, remontez à l'arithmétique qui a produit la taille. Les builds UBSan pointent l'expression exacte :
parser.c:41:25: runtime error: unsigned integer overflow:
4294967295 * 24 cannot be represented in type 'unsigned int'
Les bugs de chaîne de format se présentent comme une sortie de journal brouillée ou d'apparence hexadécimale, des crashs à l'intérieur de vfprintf, ou *** %n in writable segment detected *** d'un binaire fortifié. Chacun est une correction de code, pas un problème de configuration. Le guide des rapports de crash couvre le flux de travail du triage.
Checklist
- Représentez les tailles et longueurs en
size_tdès le point où elles entrent dans le programme, et validez-les contre des maxima documentés. - Ne jamais détecter un débordement en le provoquant. Utilisez
ckd_add/ckd_mul,__builtin_*_overflowou des pré-contrôles contre les limites. - Utilisez
callocou une multiplication vérifiée pour chaque allocationcount * size. - Compilez avec
-Wall -Wextra -Wformat=2 -Werror=format-security, et-Wconversionsur le code de parsing. - Annotez les wrappers de journalisation avec
__attribute__((format(printf, ...))). - Exécutez les tests avec
-fsanitize=address,undefinedet fuzzez les parsers qui calculent des tailles.