Aller au contenu

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.

Publié le 7 min de lecture

Un débordement de tampon de pile survient lorsqu'un programme écrit dans un tableau alloué sur la pile plus de données que le tableau ne peut en contenir. C'est l'un des plus anciens bugs de sûreté mémoire, catalogué sous CWE-121, et il reste courant dans le code C et C++ qui manipule du texte, des paquets réseau et des formats de fichiers. Ce guide explique pourquoi le bug est dangereux, les schémas de code qui le produisent, et les défenses en couches qui le préviennent, le détectent et le contiennent. Si l'agencement du cadre de pile vous est nouveau, commencez par comment un processus organise sa mémoire.

Pourquoi quelques octets de trop comptent

Les variables locales d'une fonction partagent un cadre de pile avec les données dont le CPU a besoin pour reprendre l'appelant : le pointeur de cadre sauvegardé et l'adresse de retour. Les tableaux sont remplis des adresses basses vers les hautes, et les données sauvegardées se trouvent au-dessus d'eux. Une écriture qui dépasse la fin du tableau écrase donc ce que le compilateur a placé ensuite, et finalement l'adresse de retour sauvegardée.

cadre de pile x86-64 avec un canari entre le tampon local et le rbp sauvegardé et l'adresse de retour

Les conséquences s'étalent sur un spectre :

Ce qui est écraséSymptôme typiqueGravité pour un défenseur
Remplissage ou variable morteRien de visibleBug latent, doit tout de même être corrigé
Une autre variable localeComportement erroné, contournement de logique (un drapeau, une longueur, un pointeur)Peut être grave sans aucun crash
Le canari de pile*** stack smashing detected ***, SIGABRTLa mitigation a fonctionné, le bug est réel
Pointeur de cadre sauvegardé ou adresse de retourCrash au ret, backtrace corrompueRisque de détournement du flot d'exécution si les mitigations manquent

Cette dernière ligne explique pourquoi cette classe de bugs a acquis sa réputation : le contrôle de l'adresse de retour signifie le contrôle de ce que le programme exécute ensuite. Chaque mitigation abordée ci-dessous existe pour casser cette étape.

Les schémas qui le causent

La plupart des débordements de pile proviennent d'une poignée de formes récurrentes. Les reconnaître en revue de code est la défense la moins coûteuse.

Copies non bornées

/* Vulnérable : aucune borne sur la copie. */
void greet(const char *name) {
    char buf[32];
    strcpy(buf, name);           /* name peut dépasser 31 octets */
    printf("Hello, %s\n", buf);
}

strcpy, strcat, sprintf, gets (retiré de C11) et scanf("%s", ...) sans largeur copient tous jusqu'à la fin de la source, sans égard pour la taille de la destination.

Faire confiance à une longueur venue de l'entrée

/* Vulnérable : la longueur vient du paquet, pas de sizeof(buf). */
int parse_record(const uint8_t *pkt, size_t pkt_len) {
    uint8_t buf[64];
    size_t n = pkt[0];               /* contrôlé par l'attaquant, jusqu'à 255 */
    if (n > pkt_len - 1) return -1;  /* vérifie la source, pas la destination */
    memcpy(buf, pkt + 1, n);
    return process(buf, n);
}

La vérification valide que la source a assez d'octets, mais jamais que la destination a assez de place. C'est la forme la plus courante dans les analyseurs de protocoles binaires.

Erreur d'un cran (off-by-one)

/* Vulnérable : écrit le terminateur un octet au-delà de la fin. */
char path[256];
size_t n = strlen(dir);
if (n > sizeof(path)) return -1;   /* devrait être >= */
memcpy(path, dir, n);
path[n] = '\0';                    /* n == 256 écrit path[256] */

Un seul octet peut sembler inoffensif, mais il peut écraser l'octet de poids faible d'un pointeur adjacent ou du pointeur de cadre sauvegardé.

Tableaux de longueur variable et alloca

char buf[n] avec un n influencé par l'attaquant peut rendre le cadre si grand qu'il saute par-dessus la page de garde vers un autre mappage. -fstack-clash-protection de GCC et Clang sonde la pile page par page pour combler cet écart, et -Wvla signale la construction afin que vous puissiez la supprimer.

Corriger le code

La correction est toujours la même idée : borner chaque écriture par la taille de la destination, et vérifier les longueurs avant de copier.

/* Corrigé : borne de destination explicite, troncature détectée. */
int greet(const char *name) {
    char buf[32];
    int n = snprintf(buf, sizeof buf, "%s", name);
    if (n < 0 || (size_t)n >= sizeof buf) return -1;  /* trop long */
    printf("Hello, %s\n", buf);
    return 0;
}

/* Corrigé : valider par rapport à la destination, pas seulement à la source. */
int parse_record(const uint8_t *pkt, size_t pkt_len) {
    uint8_t buf[64];
    if (pkt_len < 1) return -1;
    size_t n = pkt[0];
    if (n > sizeof buf || n > pkt_len - 1) return -1;
    memcpy(buf, pkt + 1, n);
    return process(buf, n);
}

Quelques règles pratiques :

  • Préférez les API qui prennent la taille de la destination : snprintf, strlcpy/strlcat (disponibles dans glibc depuis 2.38), memcpy avec une longueur explicitement validée.
  • Traitez tout champ de longueur venu de l'extérieur du processus comme hostile jusqu'à ce qu'il soit vérifié par rapport à sizeof de la destination.
  • En C++, utilisez std::string, std::vector et std::span au lieu de tableaux bruts, et activez le durcissement de la bibliothèque (-D_GLIBCXX_ASSERTIONS pour libstdc++, les modes de durcissement de libc++) pour que operator[] vérifie les bornes.
  • Là où une réécriture est réaliste, migrez les analyseurs vers un langage sûr pour la mémoire. Voir les langages sûrs pour la mémoire.

L'attraper avant la sortie

Avertissements du compilateur et FORTIFY_SOURCE

-Wstringop-overflow et -Warray-bounds de GCC attrapent de nombreux débordements quand les tailles sont connues à la compilation. -D_FORTIFY_SOURCE=2 ou =3 (avec l'optimisation activée) va plus loin : il remplace les appels tels que memcpy, strcpy et sprintf par des variantes vérifiées chaque fois que le compilateur peut déterminer la taille de la destination, et s'interrompt à l'exécution avec *** buffer overflow detected *** si la borne est dépassée. Le niveau 3 (GCC 12+ avec glibc 2.34+, et Clang récent) gère aussi les tailles qui ne sont connues qu'à l'exécution.

AddressSanitizer

ASan place des zones rouges empoisonnées autour de chaque tableau de pile et vérifie chaque accès mémoire. Le débordement ci-dessus est signalé à l'instruction fautive exacte, avec le nom de la variable et le cadre :

==4121==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc3a1e0a60
WRITE of size 40 at 0x7ffc3a1e0a60 thread T0
    #0 0x4f3c21 in __interceptor_strcpy
    #1 0x5270b4 in greet greet.c:6
    #2 0x527183 in main greet.c:14
Address 0x7ffc3a1e0a60 is located in stack of thread T0 at offset 64 in frame
    #0 0x52701f in greet greet.c:4
  This frame has 1 object(s):
    [32, 64) 'buf' (line 5) <== Memory access at offset 64 overflows this variable

Le guide des sanitizers explique comment lire chaque ligne de ce rapport, et le fuzzing avec libFuzzer et AFL++ montre comment générer les entrées qui le déclenchent.

Analyse statique

Des outils tels que CodeQL, clang-tidy (avec les vérifications bugprone-* et cert-*), Coverity et l'analyseur statique de Clang trouvent les copies non bornées et les champs de longueur non vérifiés au travers des frontières de fonctions. Ils produisent des faux positifs, mais une vérification des fonctions bannies pour strcpy, sprintf et gets en CI ne coûte rien.

Contenir ce qui passe à travers

Aucun processus n'attrape tous les bugs, donc les binaires de production embarquent des mitigations qui rendent bien plus difficile de transformer un débordement survivant en exécution de code :

MitigationDrapeau de compilationCe qu'elle fait contre les débordements de pile
Canari de pile-fstack-protector-strongDétecte les écrasements linéaires des données sauvegardées avant le ret
Réordonnancement des variablesPartie du stack protectorPlace les tableaux au-dessus des scalaires pour que les débordements ne puissent pas les atteindre
Pile non exécutablePar défaut, -Wl,-z,noexecstackLes données écrites sur la pile ne peuvent pas s'exécuter
ASLR + PIE-fPIE -pieLes adresses de code et de pile diffèrent à chaque exécution
Pile fantôme-fcf-protection=full (CET x86)Le matériel conserve une copie protégée des adresses de retour
Authentification de pointeur-mbranch-protection=standard (AArch64)Les adresses de retour sont signées cryptographiquement
Protection contre le stack clash-fstack-clash-protectionEmpêche les grands cadres de sauter par-dessus la page de garde

Chacune a des limites, ce qui explique pourquoi on les empile. Les guides drapeaux de durcissement des binaires et intégrité du flot de contrôle les couvrent en profondeur, y compris comment vérifier qu'un binaire les possède réellement.

Détection en production

Quand un canari se déclenche, glibc affiche *** stack smashing detected ***: terminated et lève SIGABRT. Traitez ce message dans les journaux ou les rapports de crash comme un bug de sûreté mémoire confirmé, pas comme du bruit : quelque chose a écrit au-delà d'un tampon de pile. Collectez le core dump, identifiez la fonction à partir de la backtrace (le cadre qui a appelé __stack_chk_fail), et reproduisez avec une compilation ASan. Le guide des rapports de crash déroule ce flux de travail.

Liste de contrôle

  • Bannissez les fonctions de chaîne non bornées (strcpy, strcat, sprintf, gets) dans le nouveau code et signalez-les en CI.
  • Validez chaque longueur fournie de l'extérieur par rapport à la taille de la destination, pas seulement par rapport à la source.
  • Compilez avec -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection -fPIE -pie et la protection du flot de contrôle adaptée à votre architecture.
  • Exécutez les tests et les fuzzers sous ASan ; corrigez chaque rapport, même quand le programme « avait l'air d'aller bien ».
  • Traitez stack smashing detected en production comme un bug de sécurité avec un core dump à analyser.

Guides associés