Aller au contenu

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.

Publié le 6 min de lecture

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éfaillanceExempleRésultat
Débordement / bouclagecount * sizeof(struct item) dépasse SIZE_MAXUne allocation minuscule pour un compteur énorme
Troncatureuint16_t len = strlen(s);Longueur silencieusement réduite modulo 65536
Confusion de signeint len depuis l'entrée est négatif, passé à memcpy comme size_tUne valeur négative devient une taille énorme
Sous-dépassementsize_t remaining = total - used; avec used > totalBoucle 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 drapeauAttrape
-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
-ftrapvPiège sur débordement signé (plus ancien, moins précis qu'UBSan)
-Wconversion -Wsign-conversionConversions risquées à la compilation
CodeQL / CoverityValeurs 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-security signale 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=2 ajoute -Wformat-nonliteral et davantage. Debian, Ubuntu et Fedora activent -Wformat -Wformat-security dans 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=2 ou plus, les variantes vérifiées de printf de la glibc avortent si %n apparaî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_t dè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_*_overflow ou des pré-contrôles contre les limites.
  • Utilisez calloc ou une multiplication vérifiée pour chaque allocation count * size.
  • Compilez avec -Wall -Wextra -Wformat=2 -Werror=format-security, et -Wconversion sur le code de parsing.
  • Annotez les wrappers de journalisation avec __attribute__((format(printf, ...))).
  • Exécutez les tests avec -fsanitize=address,undefined et fuzzez les parsers qui calculent des tailles.

Guides associés

0x1000 · Classes de vulnérabilités

Les débordements de tampon de pile expliqués aux défenseurs

Pourquoi écrire au-delà d'un tampon de pile est dangereux, les schémas C qui le causent, comment les compilateurs et sanitizers le détectent, et les corrections et mitigations qui le contiennent.