Skip to main content

Attaques par débordement de tampon en C++ : guide pratique

Écrit par
Headshot of Snyk Security Research Team

Snyk Security Research Team

hero buffer overflow

28 juillet 2022

0 minutes de lecture

Un débordement de tampon est un type d’erreur d’exécution qui permet à un programme d’écrire au-delà de la fin d’un tampon ou d’un tableau — d’où le terme « débordement » — et de corrompre la mémoire adjacente. Comme la plupart des bogues, un débordement de tampon ne se manifeste pas à chaque exécution du programme. La vulnérabilité se déclenche plutôt dans certaines circonstances, par exemple lorsque l’utilisateur saisit des données inattendues.

Une attaque par débordement de tampon consiste à exploiter une vulnérabilité de débordement de tampon — généralement par un acteur malveillant qui cherche à obtenir un accès ou des informations. Dans cet article, nous expliquons comment se produit un débordement de tampon et vous montrons comment protéger votre code C++ contre ces attaques.

Exemple d’attaque par débordement de tampon

Pour comprendre comment se produit un débordement de tampon, examinons le code suivant. Il effectue une simple vérification de mot de passe et est vulnérable à une attaque par débordement de tampon :

#include <cstdio>
#include <cstring>
#include <iostream>

const char *PASSWORD_FILE = "rictro";

int main()
{
  char input[8];
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  // std::cout << "Address of input: " << &input << "\n";
  // std::cout << "Address of password: " << &password << "\n";
  // std::cout << "Input: " << input << "\n";
  // std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input, 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

L’extrait de code demande à l’utilisateur de saisir un mot de passe (ligne 14). Il compare ensuite cette saisie au mot de passe enregistré (ligne 23), qu’il a préalablement chargé (ligne 12). Si les deux correspondent, l’accès est accordé à l’utilisateur.

En pratique, nous lirions le mot de passe depuis un fichier à l’aide de std::fscanf, mais, pour simplifier cet exemple, nous le lirons plutôt depuis une chaîne constante. Idéalement, nous stockerions également une version salée et hachée de notre mot de passe au lieu de l’original. Pour simplifier, nous utiliserons toutefois un mot de passe en texte brut.

Un mécanisme de ce type pourrait servir à déverrouiller la version shareware — ou d’essai — d’une application, ou à accorder à l’utilisateur l’accès à des informations et fonctionnalités de l’application après saisie du mot de passe d’un administrateur.

Exécutons le programme pour voir ce qui se passe :

Enter password: rictro
Access granted

Comme prévu, la saisie du bon mot de passe nous donne accès à l’application. À l’inverse, un mot de passe incorrect entraîne un refus d’accès :

Enter password: hello
Access denied

Jusqu’ici, tout fonctionne comme prévu. Cependant, le bon mot de passe n’est pas le seul moyen d’accéder à cette application. Voyons ce qui se passe si nous saisissons « sunshinesunshine » à la place :

Enter password: sunshinesunshine
Access granted

C’est inattendu. Nous avons saisi une chaîne très différente du bon mot de passe « rictro » — et l’accès nous a tout de même été accordé.

L’explication, c’est que nous avons réussi à lancer une attaque par débordement de tampon contre le programme en question. Pour comprendre comment cela s’est produit, décommentons les affichages de débogage des lignes 18 à 21 et relançons l’application.

Commençons par l’exécuter avec la bonne saisie :

Enter password: rictro
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: rictro
Password: rictro
Access granted

La sortie ne présente rien de notable.

Essayons un mot de passe incorrect :

Enter password: hello
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: hello
Password: rictro
Access denied

Une fois encore, l’application se comporte comme prévu.

Voyons maintenant ce qui se passe lorsque nous saisissons notre mot de passe spécial, « sunshinesunshine ».

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: sunshinesunshine
Password: sunshine
Access granted

Le mot de passe n’a plus la valeur « rictro » : il contient maintenant « sunshine ».

Nous utilisons deux tableaux de 8 octets : password stocke le mot de passe de l’application et input stocke la saisie de l’utilisateur. Le compilateur utilisé dans cet exemple (GCC 10.3) place password huit octets après input (0x7ffc5581e4b0 - 0x7ffc5581e4a8 = 8), de sorte que les tableaux sont adjacents en mémoire. Notez qu’un autre compilateur peut produire des résultats différents.

Les mots de passe de moins de huit caractères produisent des blocs mémoire qui ressemblent à ceci :

Tableau montrant les champs de saisie et de mot de passe, avec « hello\0 » dans la ligne de saisie et « rictro\0 » dans la ligne du mot de passe.

En revanche, si nous saisissons « sunshinesunshine », la mémoire ressemble à ceci :

Tableau illustrant un dépassement de tampon : la valeur saisie « sunshine » déborde sur le champ de mot de passe adjacent.

Le terminateur nul \0 est écrit au-delà de la fin de password, remplaçant ce qui se trouve alors sur la pile.

Cela se produit parce que std::cin (ligne 15) n’effectue aucune vérification des limites. Il lit les données saisies dans la console jusqu’à rencontrer un saut de ligne — autrement dit, jusqu’à ce que l’utilisateur appuie sur Entrée — sans vérifier que le tampon de destination est assez grand pour contenir la saisie.

Comme nous comparons uniquement les huit premiers caractères de password et de input à l’aide de std::strncmp (ligne 23), afin d’éviter de lire au-delà de la fin des tableaux, nous obtenons une correspondance qui ne devrait pas exister.

Comment prévenir les débordements de tampon en C++

Comparé à d’autres langages de programmation de haut niveau, le C++ est particulièrement vulnérable aux débordements de tampon, car une grande partie de son écosystème, y compris certaines parties de la bibliothèque standard C++, utilise encore des pointeurs bruts. Nous pouvons utiliser des tampons gérés comme std::vector ou std::string dans notre propre code, mais nous perdons les vérifications des limites dès que nous utilisons des API de style C qui nous obligent à transmettre vector::data ou string::c_str.

Appliquez les bonnes pratiques de programmation en C++

La meilleure façon de prévenir les débordements de tampon consiste à utiliser des API qui n’y sont pas vulnérables. En C++, cela signifie privilégier les tampons et les chaînes gérés plutôt que les tableaux et les pointeurs bruts.

À privilégier

À éviter

std::string

char*

std::vector

int array[15]

std::cin

scanf

<iostream>

<cstdio>

Nous pouvons corriger notre exemple d’application à l’aide de std::string.

Examinons la version corrigée. Notez les modifications apportées aux lignes 4, 10 et 24 :

#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>

const char *PASSWORD_FILE = "rictro";

int main()
{
  std::string input;
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  std::cout << "Address of input: " << &input << "\n";
  std::cout << "Address of password: " << &password << "\n";
  std::cout << "Input: " << input << "\n";
  std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input.c_str(), 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

std::string surcharge l’opérateur d’extraction (>>), ce qui lui permet de lire les flux en toute sécurité. Pour cela, il interroge le flux afin de connaître la taille des données saisies, alloue suffisamment de mémoire, puis lit les données. Il nous est ainsi impossible de provoquer un débordement du tampon, quelle que soit la longueur de la saisie. Au pire, nous pourrions manquer de mémoire.

Enter password: hellooo…ooo
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
Input: hellooo…ooo
Password: rictro
Access denied

Si vous devez utiliser des API de style C, essayez d’utiliser leurs équivalents « sécurisés », s’ils existent. Ces versions vérifient les limites, acceptent un paramètre size supplémentaire et refusent de lire ou d’écrire au-delà de celui-ci.

À privilégier

À éviter

scanf_s

scanf

fscanf_s

fscanf

sscanf_s

sscanf

strncmp

strcmp

strncpy

strcpy

strncat

strcat

Utilisez AddressSanitizer pour prévenir les débordements de tampon

Outre les bonnes pratiques de programmation, des outils automatisés peuvent aider à détecter les débordements de tampon. AddressSanitizer (ASan) est l’un des plus populaires. Il est pris en charge par tous les principaux compilateurs, notamment Visual Studio (v16.9 et versions ultérieures), GCC (v4.8 et versions ultérieures), Clang (v3.1 et versions ultérieures) et Xcode (v7.0 et versions ultérieures). La détection des erreurs d’accès mémoire peut être activée avec l’option /fsanitize=address dans Visual Studio et l’option -fsanitize=address dans GCC/Clang.

ASan remplit deux fonctions :

  • Il ajoute quelques octets de « mémoire empoisonnée » autour de tous les objets de la pile et de toutes les allocations sur le tas en remplaçant malloc par une version modifiée.

  • Le compilateur insère du code dans votre application pour détecter toute tentative d’accès à la mémoire empoisonnée.

Nous avons vu précédemment comment nos tableaux input et password sont disposés en mémoire lors d’une compilation avec GCC 10.3. Si nous activons la détection des erreurs d’accès mémoire, leur disposition peut changer et ressembler à ceci :

Tableau montrant des champs de saisie et de mot de passe avec des cellules de caractères, notamment « xhello\0 » et « x x r i c t r o\0 x ».

Les symboles ✗ représentent la mémoire empoisonnée placée autour de nos tableaux.

Le code inséré par le compilateur contient une logique qui détecte toute tentative d’accès à la mémoire empoisonnée. Prenons un simple extrait de code avant transformation :

array[i] = 5;

Après son traitement par ASan, le code ressemble à ceci :

if (is_poisoned(&array[i]))
{
  print_error();
  std::abort();
}
array[i] = 5;

Pour déterminer si l’application tente d’accéder à de la mémoire empoisonnée — en implémentant la fonction is_poisoned —, ASan utilise la « mémoire fantôme ». Il s’agit d’une région mémoire distincte qui stocke des métadonnées sur la mémoire réelle de l’application.

En pratique, l’algorithme prend en compte davantage de facteurs que ne le permet le cadre de cet article, mais cette implémentation native simplifiée illustre le principe.

Imaginez un simple tableau de bits dans lequel chaque octet auquel l’application accède correspond à un bit dans la mémoire fantôme indiquant si cet octet est empoisonné.

Une simple déclaration pourrait ressembler à ceci :

char password[6];

Pour mettre en œuvre la mémoire fantôme, la déclaration pourrait être transformée comme suit :

char temp[8];
char* password = &temp[1];
shadow_memory[&temp...&temp+7] = 0b10000001;

L’utilisateur déclare sur la pile un char array de 6 octets. Le compilateur crée plutôt un tableau de 8 octets et renvoie un pointeur vers son milieu, en ajoutant 1 octet de chaque côté. Il écrit ensuite le motif binaire 10000001 dans le bitset shadow_memory, en utilisant l’adresse temp comme index. Cela indique que les 6 octets de password sont sains, tandis que les deux octets qui les entourent sont empoisonnés.

Le compilateur peut alors facilement déterminer si nous essayons d’accéder à de la mémoire empoisonnée. Prenons l’extrait suivant :

*pointer = 5;

After the compiler injects code, the snippet now looks like this:

if (shadow_memory[pointer] == 1)
{
  print_error();
  std::abort();
}
*pointer = 5;

Maintenant que vous comprenez les principes de base de la détection des erreurs d’accès mémoire, voyons ce qui se passe si nous transmettons la saisie malveillante « sunshinesunshine » à notre application vulnérable d’origine, cette fois avec la détection activée :

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
=============================
==15857==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc5581e4b0

Notez deux choses :

  • L’écart entre nos tableaux input et password est passé de 8 à 32 octets (0x7ffc5581e4c8 - 0x7ffc5581e4a8 = 32), afin de laisser de la place à la mémoire empoisonnée.

  • Le compilateur détecte une corruption de mémoire à l’adresse 0x7ffc5581e4b0, exactement 8 octets après input, ce qui correspond au premier octet empoisonné rencontré.

Protégez vos projets C++

Dans cet article, nous avons présenté les principes de base des attaques par débordement de tampon en C++ et les meilleures façons de protéger vos projets. En résumé, un débordement de tampon est une vulnérabilité qui permet à un programme d’écrire au-delà de la fin d’un tampon, entraînant une corruption de la mémoire — laquelle peut ensuite être exploitée pour obtenir l’accès à des applications ou informations protégées.

Parmi les langages de haut niveau, le C++ est particulièrement vulnérable aux débordements de tampon, car de nombreuses API utilisent encore des pointeurs bruts et ne vérifient pas les limites. Vous pouvez limiter ce risque en utilisant des tampons et des chaînes gérés plutôt que des API de style C. Si vous devez utiliser une API de style C, privilégiez ses versions sécurisées, qui acceptent un paramètre de taille supplémentaire. Les attaques par débordement de tampon peuvent également être prévenues à l’aide d’outils de détection des erreurs d’accès mémoire, qui repèrent les défauts et les dépassements de mémoire.

Pour en savoir plus sur la sécurité en C++, consultez notre introduction accessible aux vulnérabilités en C/C++ et découvrez les vulnérabilités de traversée de répertoires en C/C++.

Lancez-vous dans les compétitions Capture The Flag

Apprenez à résoudre des défis de capture du drapeau en regardant à la demande notre atelier virtuel d’initiation.