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.
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.
Les conséquences s'étalent sur un spectre :
| Ce qui est écrasé | Symptôme typique | Gravité pour un défenseur |
|---|---|---|
| Remplissage ou variable morte | Rien de visible | Bug latent, doit tout de même être corrigé |
| Une autre variable locale | Comportement erroné, contournement de logique (un drapeau, une longueur, un pointeur) | Peut être grave sans aucun crash |
| Le canari de pile | *** stack smashing detected ***, SIGABRT | La mitigation a fonctionné, le bug est réel |
| Pointeur de cadre sauvegardé ou adresse de retour | Crash au ret, backtrace corrompue | Risque 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),memcpyavec 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 à
sizeofde la destination. - En C++, utilisez
std::string,std::vectoretstd::spanau lieu de tableaux bruts, et activez le durcissement de la bibliothèque (-D_GLIBCXX_ASSERTIONSpour libstdc++, les modes de durcissement de libc++) pour queoperator[]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 :
| Mitigation | Drapeau de compilation | Ce qu'elle fait contre les débordements de pile |
|---|---|---|
| Canari de pile | -fstack-protector-strong | Détecte les écrasements linéaires des données sauvegardées avant le ret |
| Réordonnancement des variables | Partie du stack protector | Place les tableaux au-dessus des scalaires pour que les débordements ne puissent pas les atteindre |
| Pile non exécutable | Par défaut, -Wl,-z,noexecstack | Les données écrites sur la pile ne peuvent pas s'exécuter |
| ASLR + PIE | -fPIE -pie | Les 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-protection | Empê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 -pieet 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 detecteden production comme un bug de sécurité avec un core dump à analyser.