Dépassement de tampon - Définition

Source: Wikipédia sous licence CC-BY-SA 3.0.
La liste des auteurs est disponible ici.

En informatique, un dépassement ou débordement de tampon (en anglais, buffer overflow) est un bogue pouvant être exploité pour violer la politique de sécurité d’un système. Cette technique est couramment utilisée par les pirates informatiques.

Lorsque le bogue est provoqué inintentionnellement, le comportement de la machine devient complètement imprévisible. Cela se manifeste souvent par un blocage du programme, voire de tout le système.

Lorsque le bogue est exploité à des fins malveillantes, la stratégie du pirate est de détourner le programme bogué en lui faisant exécuter un code externe, hostile ou non.

Plus techniquement, le principe est de profiter de l’accès à certaines variables du programme, souvent par le biais de fonctions telles scanf() (analyse de chaîne de caractères) ou strcpy() (copie de chaîne de caractères) en langage C, qui ne contrôlent pas la taille de la chaîne à enregistrer dans un tampon, afin d’écraser la mémoire du processeur jusqu’à l’adresse de retour de la fonction en cours d’exécution. On peut ainsi choisir quelles seront les prochaines instructions exécutées par le processeur. Le code introduit est généralement exécuté avec les droits du programme détourné.

Afin d’éviter ces dépassements, certaines fonctions ont été réécrites pour prendre en paramètre la taille du buffer dans lequel les données sont copiées, et éviter ainsi de copier plus qu’il ne contient. Ainsi strncpy() est une version de strcpy() qui prend la taille du buffer. Dès que les techniques de débordement de tampon commencèrent à se généraliser, l’équipe de FreeBSD cessa tout nouveau développement pendant plusieurs mois afin de colmater toutes les brèches de ce genre dans leur code source. Celle de Linux temporisa un peu plus.

strncpy présente l'inconvénient majeur d'être peu efficace, puisqu'elle remplit toute la fin du tampon de zéros. Theo de Raadt et Todd Miller ont conçu strlcpy et strlcat qui ne présentent pas ce défaut. Disponible initialement sous OpenBSD, ces fonctions tendent à se répandre dans divers logiciels (rsync, kde, etc.). Seul Linux, et plus particulièrement Ulrich Drepper, font de la résistance active...

Cas particulier: débordement entier

Il est fréquent d'allouer dynamiquement des tableaux de structure de données, ce qui nécessite de calculer la taille totale comme un produit taille_el * nombre_el. Si on n'y prend pas garde, un tel produit peut donner un nombre trop grand pour être représentable (débordement de capacité). On se retrouve alors avec une zone mémoire allouée de taille inférieure à ce qu'on pense avoir alloué. C'est un cas très particulier de dépassement de tampon, qui peut être détourné par un pirate.

Ces failles de sécurité ont été exploitées de façon courante depuis le début des années 2000, en particulier dans OpenSSH, et dans les bibliothèques de lecture de pratiquement tous les types d'image.

Détails techniques sur architecture 80x86 (Intel)

Un programme en exécution (on parle de processus) découpe la zone de mémoire adressable en zones distinctes :

  • la zone de code où sont stockées les instructions du programme en train de s’exécuter.
  • la zone des données où sont stockées les données que manipule le programme. Cette zone est généralement à son tour découpée en plusieurs zones (variables statiques, initialisées ou non, etc.)
  • la zone de la pile
  • la zone du tas.

Les deux dernières zones servent de mémoire temporaire au code. Leur spécificité est qu’elles sont toutes les deux dynamiques au contraire des autres. Ce qui veut dire que leur taille peut varier tout au long de l’exécution d’un processus.

La zone du tas correspond aux variables déclarées en dehors de toute fonction (globale) et la zone de la pile correspond aux variables locales à une fonction. À noter que le tas contient aussi toutes les données déclarées dynamiquement (c.-à-d. malloc en C ou new en C++).

La zone de la pile est nécessaire aux sous-fonctions (variables locales et passage d’arguments). Elle se comporte comme son nom l’indique, i.e., dernier entré, premier sorti. Les variables déclarées au sein d’une fonction sont empilées à leur déclaration et dépilées à la fin de la fonction.

La convention générale d'appel de fonctions, telle qu'utilisée par la plupart des langages (autres que le langage machine), est la suivante:

  1. Empiler les arguments de la fonction (dans l’ordre inverse généralement) via l’instruction push
  2. Appeler la fonction, via l’instruction call avec comme argument l’adresse mémoire de la sous-routine.

Une fonction est un sous-ensemble de code ayant un comportement particulier : il est possible à tout moment de retourner de cette fonction, via l'instruction ret. L’exécution du code doit alors reprendre à l’instruction suivant l’appel de fonction.

Pour ce faire, l’instruction call enregistre l’état du programme avant l’appel de fonction, i.e., elle stocke l’adresse à laquelle devra retourner la fonction à sa terminaison. Lors de l’exécution de l’instruction ret qui permet à une sous-fonction de se terminer, le processeur récupère alors l’adresse de retour qu’il a précédemment stockée et le processus peut continuer son exécution. Cette adresse de retour est aussi stockée dans la pile.

La procédure d’appel de fonction devient alors :

  1. Empiler les arguments de la fonction (push)
  2. Appeler la fonction (call), ce qui se décompose du point de vue du processeur en :
    1. Sauvegarde de l’adresse de retour
  3. Entrée de la fonction (par convention) :
    1. Sauvegarde de l'état actuel de la pile (registre ebp)
    2. Initialisation d'une nouvelle pile " locale "
    3. Allocation des variables locales (manipulation de esp)
  4. Exécution de la fonction
  5. Sortie de la fonction (par convention) :
    1. Restauration de l'état précédent de la pile
  6. Exécution de l’instruction ret :
    1. Récupération de l’adresse de retour et branchement à cette adresse

Illustration

Soit l’extrait de programme C suivant (volontairement simplifié) :

#include 
void foo(char *str)
{
char buffer[32];
strcpy(buffer, str);
/* ... */
}
int main(int argc, char *argv[])
{
if (argc > 1) {
/* appel avec le premier argument de la ligne de commandes */
foo(argv[1]);
}
/* ... */
return 0;
}

Ce qui est traduit par un compilateur C (ici Gcc), par (syntaxe Intel) :

push ebp              ; entrée de la fonction
mov ebp,esp           ;
sub esp,40            ; 40 octets sont "alloués" (32 + les 2 variables qui serviront
; à l'appel de strcpy)
mov eax,[ebp+0x8]     ; argument de la fonction (str)
mov [esp+0x4],eax     ; préparation de l'appel de fonction: 2e argument
lea eax,[ebp-0x20]    ; ebp-0x20 contient la variable locale 'buffer'
mov [esp],eax         ; 1er argument
call strcpy
; sortie de la fonction
leave                 ; équivalent à mov esp,ebp et pop ebp
ret

Voici l'état de la pile et de ces deux registres (ebp et esp) juste avant l'appel de la fonction strcpy

État de la pile avant l'appel à la fonction strcpy
État de la pile avant l'appel à la fonction strcpy

Ainsi, strcpy copiera le contenu de str dans buffer. strcpy fonctionne sans aucune vérification: il copiera le contenu de str jusqu'à trouver un caractère de fin de chaîne (caractère nul).

Si str fait plus de 32 octets de long, strcpy continuera à copier le contenu de la chaîne au-delà de la zone allouée par la variable locale buffer. C’est ainsi que les variables stockées dans la pile pourront être écrasées et en particulier l’adresse de retour de la fonction.

Débordement de la pile : la fonction strcpy copie la chaîne vers la zone mémoire indiquée et finit par dépasser la zone allouée et écraser l'adresse de retour de la fonction foo
Débordement de la pile : la fonction strcpy copie la chaîne vers la zone mémoire indiquée et finit par dépasser la zone allouée et écraser l'adresse de retour de la fonction foo

C’est ce qui est généralement considéré comme un bogue du programme : le programmeur a, par exemple, prévu une certaine taille de stockage qu’il juge maximale (256 octets pour stocker le nom d’un fichier devraient être suffisants) et l’appelant passe en paramètre une chaîne plus longue que prévu. L’adresse de retour de la pile est écrasée et à la terminaison de la fonction (ret), le processeur tente de brancher vers une adresse non prévue : le résultat est généralement une adresse en dehors de la plage adressable et le programme " plante " en renvoyant un message d’erreur (segmentation fault).

Un utilisateur malveillant peut utiliser ce comportement à ses fins. Par exemple : en connaissant la taille du tampon (dans l’exemple précédent 32 octets), il peut vouloir écraser l’adresse de retour pour la faire pointer vers un code à lui, de manière à prendre le contrôle du programme. De cette manière, il a les droits d’exécution associés au programme et peut dans certains cas accéder à des ressources critiques.

La forme d’attaque la plus simple consiste à passer dans une chaîne un code assembleur suivi d’une adresse de retour vers ce même code. Le but du code est généralement d’avoir le plus de possibilités : sur les systèmes Unix, cela consiste généralement à ouvrir un shell.

La forme de défense la plus simple consiste à modifier un peu la source du compilateur pour qu’il insère irrégulièrement des codes NOP de façon imprévisible, puis à utiliser ce nouveau compilateur pour recompiler le noyau et les applications (opérations de routine en Linux). Cela ne ralentit que peu les programmes, mais complique énormément la tâche du pirate qui ne sait plus aussi bien quelles adresses il doit viser.

Dans l’exemple précédent, si str contient un code malveillant sur 32 octets suivis de l’adresse de buffer, au retour de la fonction, le processeur exécutera le code contenu dans str.

Pour que cette " technique " soit efficace, il y a cependant deux contraintes :

  • le code malveillant ne doit contenir aucun caractère nul, sans quoi strcpy() s’arrêtera de copier, mais pas memcpy().
  • l’adresse de retour ne doit pas non plus contenir de caractère nul

Saut indirect

La technique précédente nécessite généralement pour l’attaquant de deviner l’adresse de retour vers le code malveillant. Ceci est assez contraignant, car cela demande généralement des essais successifs, ce qui n’est pas une méthode très " discrète ". De plus, il y a de fortes chances que l’adresse de la pile change d’une version à l’autre du programme vulnérable et d’un système à l’autre.

Le but du pirate est généralement de pouvoir exploiter une faille sur le plus de versions possible du même programme (afin peut-être de concevoir un virus ou ver). Pour s’affranchir des précédentes contraintes, il doit trouver une méthode qui lui permette de brancher sur son code en se préoccupant le moins possible de la version du système et du programme vulnérable tout en étant le plus discret possible (le moins possible de tentatives).

Il est possible dans certains cas, de se servir du contexte d’exécution du système cible. Par exemple, sur des systèmes Windows, la plupart des programmes, même les plus simples " contiennent " un ensemble de primitives systèmes accessibles au programme (DLL). Il est possible de trouver des bibliothèques dont l’adresse mémoire lors de l’exécution change peu en fonction de la version du système. Le but pour l’attaquant est alors de trouver dans ces plages mémoires des instructions qui manipulent la pile et lui permettront d’exécuter son code.

Par exemple, en supposant que la fonction strcpy manipule un registre processeur (eax) pour y stocker l’adresse source. Dans l’exemple précédent, eax contiendra une adresse proche de l’adresse de buffer au retour de strcpy. Le but de l’attaquant est donc de trouver dans la zone mémoire supposée " fixe " (la zone des DLL par exemple) un code qui permet de sauter vers le contenu de eax (call eax ou jmp eax). Il construira alors son buffer en plaçant son code suivi de l’adresse d’une instruction de saut (call eax ou jmp eax). Au retour de strcpy, le processeur branchera vers une zone mémoire contenant call eax ou jmp eax et puisque eax contient l’adresse du buffer, il branchera de nouveau vers le code et l’exécutera...

Autres techniques

Il existe bien d’autres techniques d’exploitation de telles failles.

La deuxième difficulté pour l’attaquant est la construction du code d’exploitation appelé shellcode. Dans certains cas, le code doit être construit sur un jeu de caractères réduit : chaîne Unicode, chaîne alphanumérique, etc. En pratique, ces limitations seules n'arrêteront pas un pirate déterminé. On recense des cas avérés de piratage utilisant du code machine limité aux caractères légaux d'une chaîne Unicode (mais il faut pouvoir exécuter du code automodifiant.

D’autres techniques existent pour exploiter les variables contenues dans d’autres parties de la mémoire, en particulier les attaques sur les variables du tas, appelées heap overflow (dépassement de tas).

Prévention

Pour se prémunir de telles attaques, plusieurs options sont offertes au programmeur. Les différentes techniques de prévention font débat. En voici quelques-unes :

Protections logicielles

  • Utiliser un autre langage que le C/C++, qui ne comprend aucun mécanisme de vérification de dépassement des bornes. Utiliser éventuellement des outils externes qui permettent en mode développement de tester les cas " litigieux ", par exemple la bibliothèque Electric Fence ou Valgrind,
  • Bannir de son utilisation les fonctions dites " non protégées ". Préférer par exemple strncpy à strcpy qui effectue un contrôle de taille. Les compilateurs récents peuvent prévenir le programmeur s’il utilise des fonctions " à risque ", même si l’exploitation reste possible,
  • Protéger, côté système, la pile :
    • Rendre la pile non exécutable,
    • Mettre en place un mécanisme de vérification de la pile ([technique du canari] le principe est de stocker une clé, générée à l’exécution entre la fin de la pile et la valeur de retour. Si cette clé est modifiée, l’exécution est avortée (disponible en option dans les compilateurs C récents - éventuellement avec recours à des patchs). La principale critique étant que l’appel de fonction est ralenti.

Aucune de ces solutions ne s’est pour l’instant imposée dans le monde du développement industriel. Pourtant, ces attaques représentent une part encore importante des failles permettant le développement de vers ou virus, ou d'attaques manuelles.

Protections matérielles

  • les processeurs récents, 64 bits notamment, implémentent des protections efficaces (technologies NX Bit et XD bit).
Page générée en 0.007 seconde(s) - site hébergé chez Contabo
Ce site fait l'objet d'une déclaration à la CNIL sous le numéro de dossier 1037632
A propos - Informations légales | Partenaire: HD-Numérique
Version anglaise | Version allemande | Version espagnole | Version portugaise