Débordement de tampon ou Buffer Overflow
Qu'est-ce qu'un buffer overflow ?
Un buffer overflow, ou dépassement de mémoire tampon, arrive quand un programme écrit plus de données qu'un buffer n'est capable d'en contenir. Ça se produit surtout en C ou en C++, des langages qui ne vérifient pas automatiquement les limites des tableaux. Le langage fait confiance au développeur et quand celui-ci se trompe, c'est la mémoire du programme qui en subit les conséquences.
Selon ce qui se trouve juste après le buffer, le dépassement peut modifier une variable voisine sans que personne ne s'en aperçoive ou corrompre des données essentielles au fonctionnement du programme. Dans les cas les plus sérieux, on peut aller jusqu'à écraser l'adresse de retour d'une fonction et détourner le cours normal de l'exécution. Le programme peut alors planter avec un Segmentation Fault ou pire, faire exactement ce que l'attaquant lui demande.
Un premier exemple concret
#include <stdio.h>
void demander_nom() {
char buffer_nom[20];
printf("Entrez votre nom\n");
scanf("%s", buffer_nom);
printf("Votre nom est %s\n", buffer_nom);
}
int main() {
demander_nom();
return 0;
}
Ce code paraît anodin et pourtant il contient une faille exploitable.
buffer_nom[20] réserve 20 cases mémoire sur la pile, chacune pouvant stocker un caractère. Le problème vient de cette ligne :
scanf("%s", buffer_nom);
%s lit tout ce que l'utilisateur tape sans jamais vérifier si ça tient dans le buffer. Si Aaron entre Aaron, ça fait 5 caractères plus le \0 de fin de chaîne, largement dans les clous. Mais s'il entre 100 caractères, scanf va continuer d'écrire au-delà des 20 cases prévues dans les zones mémoire qui suivent. Ces zones contiennent d'autres variables, voire des informations critiques comme l'adresse de retour de la fonction. Les écraser peut faire planter le programme ou pire, permettre à un attaquant d'en prendre le contrôle en choisissant précisément ce qu'il écrit à la place.
Petit détail qui a son importance : on écrit scanf("%s", buffer_nom); et non scanf("%s", &buffer_nom);. Un tableau, passé à une fonction se convertit automatiquement en pointeur vers son premier élément. Ajouter un & devant est une erreur fréquente, même si elle peut parfois sembler fonctionner.
Un exemple d'exploitation simple
Pour voir concrètement ce que ça donne, reprenons le même programme, sans aucune protection compilée (on désactive volontairement le stack canary pour l'exemple, avec gcc -fno-stack-protector -o demo demo.c).
Quand demander_nom est appelée, la pile ressemble à ceci, juste avant l'appel à scanf :
adresses hautes
+---------------------------+
| adresse de retour | <- où le programme doit reprendre après demander_nom
+---------------------------+
| ancien frame pointer |
+---------------------------+
| buffer_nom[20] | <- scanf commence à écrire ici
+---------------------------+
adresses basses
scanf écrit dans buffer_nom en partant des adresses basses vers les adresses hautes. Si on entre une chaîne de 20 caractères exactement, le buffer est plein mais tout le reste est intact. Si on entre 32 caractères, les 12 derniers dépassent le buffer et viennent écraser le frame pointer puis l'adresse de retour avec des octets qu'on a nous-mêmes choisis.
Au moment où demander_nom se termine, le processeur va lire ce qui se trouve à l'emplacement de l'adresse de retour pour savoir où reprendre l'exécution. Si on l'a remplie avec du texte au lieu d'une vraie adresse de fonction, le processeur va tenter de sauter à cet endroit qui n'existe pas en mémoire exécutable. Résultat, le programme plante avec un Segmentation Fault :
$ ./demo
Entrez votre nom
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault (core dumped)
Ce crash est déjà la preuve que l'adresse de retour a été écrasée. C'est le point de départ de toute exploitation : au lieu d'y mettre des A au hasard, un attaquant calculerait précisément quels octets placer à cet endroit pour rediriger l'exécution vers une adresse de son choix plutôt que de faire planter le programme.
Comment l'éviter
La faille vient du fait que scanf("%s", ...) n'a aucune notion de la taille du buffer qu'on lui donne. La correction la plus simple consiste à imposer une limite de lecture, avec %19s par exemple, pour garder de la place pour le \0 final :
#include <stdio.h>
void demander_nom() {
char buffer_nom[20];
printf("Entrez votre nom\n");
scanf("%19s", buffer_nom);
printf("Votre nom est %s\n", buffer_nom);
}
int main() {
demander_nom();
return 0;
}
Avec cette limite, même si l'utilisateur tape 100 caractères, seuls les 19 premiers seront écrits dans le buffer et le vingtième octet reste réservé pour le \0. Le débordement n'a simplement plus la place de se produire.
Les protections modernes
Heureusement, les systèmes actuels intègrent plusieurs défenses.
Le Stack Canary place une valeur aléatoire entre le buffer et l'adresse de retour. Avant de retourner, le programme vérifie que cette valeur n'a pas bougé. Si elle a changé, c'est qu'un débordement a eu lieu et le programme s'arrête net avant de sauter n'importe où.
Le NX (No eXecute) marque la pile comme non exécutable. Même si un attaquant arrive à y écrire du code, le processeur refusera de l'exécuter.
L'ASLR randomise les adresses mémoire à chaque lancement du programme. Sans adresse prévisible, l'attaquant ne sait plus où placer son saut.
Le PIE fait la même chose, mais pour l'adresse de base du binaire lui-même. Combiné à l'ASLR, il rend la prédiction des adresses très difficile.
Le FORTIFY_SOURCE ajoute des vérifications à certaines fonctions qui manipulent la mémoire comme memcpy, strcpy ou sprintf. Il vérifie notamment que les données ne dépassent pas la taille du buffer et peut bloquer l'opération si un débordement risque de se produire.
Ces protections ne suppriment pas les buffer overflows mais elles rendent leur exploitation nettement plus complexe. C'est pour ça qu'un pentester doit connaître non seulement la faille, mais aussi chacune de ces défenses et la manière de les contourner.
Conclusion
Un buffer overflow est une erreur simple à introduire, mais dont les conséquences peuvent aller du plantage anodin à la compromission complète d'un système. Tout part du même principe, ne jamais faire confiance à la longueur d'une entrée utilisateur et toujours vérifier qu'un buffer a assez de place avant d'y écrire quoi que ce soit.