Chapitre 5 — Vulnérabilités mémoire bas niveau et exploitation¶
Présentation de ce chapitre
1. Retour sur les fondamentaux (lien avec le module Algorithmique et C)¶
Ce chapitre approfondit, sous l'angle de l'exploitation, les vulnérabilités mémoire déjà identifiées comme risques au module Algorithmique et Programmation en C (chapitres 5 et 6) : débordement de tampon, use-after-free, format string.
Contrairement aux vulnérabilités applicatives des chapitres précédents (qui exploitent une confusion entre code et donnée au niveau d'un interpréteur de haut niveau), les vulnérabilités mémoire exploitent une propriété du modèle d'exécution bas niveau de langages comme le C : l'absence de vérification automatique des limites d'accès mémoire, laissée à la responsabilité du développeur. Cette caractéristique, qui confère au C des performances proches du matériel, constitue également sa principale source de risque de sécurité.
2. Anatomie d'un débordement de tampon sur la pile (stack overflow)¶
La pile d'exécution contient, pour chaque appel de fonction, les variables locales et l'adresse de retour (l'endroit où l'exécution doit reprendre après la fin de la fonction). Un débordement de tampon local mal borné peut écraser cette adresse de retour :
void fonction_vulnerable(char *entree) {
char buffer[64];
strcpy(buffer, entree); // aucune vérification de taille
}
Si entree dépasse 64 octets, les octets excédentaires écrasent la mémoire adjacente sur la pile, potentiellement jusqu'à l'adresse de retour. Un attaquant maîtrisant précisément le contenu de ce dépassement peut rediriger l'adresse de retour vers du code de son choix (souvent injecté dans le buffer lui-même, technique historique dite du « shellcode »), détournant ainsi le flot d'exécution du programme.
Déroulement schématique d'une exploitation classique¶
- Reconnaissance : identifier une fonction utilisant une copie non bornée (
strcpy,sprintf,gets) sur une entrée dont la taille est contrôlée par l'attaquant. - Détermination du décalage (offset) : par des envois successifs d'entrées de longueurs croissantes (ou de motifs uniques identifiables), déterminer précisément à partir de quel octet du buffer débute l'écrasement de l'adresse de retour.
- Construction de la charge : préparer une entrée qui, une fois copiée, place à l'emplacement exact de l'adresse de retour une nouvelle valeur pointant vers un code que l'attaquant souhaite exécuter.
- Déclenchement : provoquer le retour de la fonction vulnérable, moment où le processeur charge la valeur (désormais falsifiée) de l'adresse de retour et y transfère l'exécution.
Cette description, volontairement simplifiée à des fins pédagogiques, ignore délibérément les protections systémiques modernes (section suivante) qui rendent une exploitation aussi directe généralement impraticable sur un système correctement configuré.
3. Contre-mesures systémiques (défense en profondeur au niveau du système)¶
Les systèmes d'exploitation et compilateurs modernes ont introduit plusieurs mécanismes rendant l'exploitation d'un débordement de tampon bien plus difficile qu'à l'époque des premières attaques historiques (fin des années 1990) :
| Contre-mesure | Principe |
|---|---|
| Stack canary | valeur secrète placée entre les variables locales et l'adresse de retour, vérifiée avant le retour de fonction ; toute modification (par débordement) est détectée et provoque l'arrêt du programme |
| ASLR (Address Space Layout Randomization) | randomise l'emplacement en mémoire du tas, de la pile et des bibliothèques à chaque exécution, rendant plus difficile de prédire l'adresse exacte vers laquelle rediriger l'exécution |
| DEP/NX (Data Execution Prevention / No-eXecute) | marque les zones mémoire de données (dont la pile) comme non exécutables, empêchant l'exécution directe d'un shellcode injecté dans un buffer |
| RELRO, PIE | durcissements complémentaires sur les tables de symboles et le positionnement du code exécutable |
Portée et limites de chacune de ces protections¶
Il est pédagogiquement important de comprendre que chacune de ces protections cible un maillon précis de la chaîne d'exploitation décrite ci-dessus, et qu'aucune n'est suffisante isolément :
- Le stack canary ne protège que l'adresse de retour immédiatement adjacente à un débordement séquentiel classique ; il ne détecte rien si l'attaquant parvient à écraser une autre donnée sensible en mémoire (ex. un pointeur de fonction stocké ailleurs) sans passer par le chemin surveillé par le canary.
- ASLR rend la prédiction d'adresse difficile mais pas impossible : une fuite d'information annexe (ex. via une autre vulnérabilité révélant une adresse mémoire) peut permettre à un attaquant de déduire le décalage appliqué, neutralisant l'effet de la randomisation pour l'exploitation visée.
- DEP/NX empêche l'exécution directe de code injecté dans une zone de données, mais pas la réutilisation de code déjà exécutable présent ailleurs dans le programme — d'où la technique de contournement décrite ci-dessous.
Ces protections ont donné naissance à des techniques d'exploitation plus avancées (return-oriented programming — ROP, qui réutilise des fragments de code déjà exécutables présents dans le programme plutôt que d'injecter du code nouveau, contournant DEP/NX), objet d'un approfondissement possible au-delà de ce cours introductif. Le principe du ROP consiste à enchaîner, via une suite d'adresses de retour soigneusement choisies, de courts fragments de code déjà présents dans le binaire ou ses bibliothèques (dits « gadgets »), chacun se terminant par une instruction de retour qui enchaîne immédiatement vers le gadget suivant, permettant de reconstruire un comportement arbitraire sans jamais introduire de nouveau code exécutable.
4. Autres vulnérabilités mémoire exploitables¶
- Débordement de tampon sur le tas (heap overflow) : cible des structures de gestion du tas ou des données adjacentes, exploitation généralement plus complexe que sur la pile mais toujours d'actualité (rappel module C, chapitre 6).
- Use-after-free : exploitation d'un pointeur pendant vers une zone mémoire libérée puis réallouée à d'autres données, permettant potentiellement à un attaquant de manipuler des structures de données internes du programme.
- Vulnérabilité de chaîne de format (format string) : passage d'une entrée utilisateur directement comme chaîne de format à une fonction comme
printf(entree_utilisateur)(au lieu deprintf("%s", entree_utilisateur)), permettant à un attaquant, via des spécificateurs de format (%x,%n), de lire voire d'écrire en mémoire. - Débordement d'entier (integer overflow) : un calcul de taille qui déborde silencieusement peut conduire à une allocation mémoire plus petite que prévu, provoquant ensuite un débordement lors du remplissage de cette zone sous-dimensionnée.
Illustration : use-after-free¶
struct utilisateur *u = allouer_utilisateur("alice");
traiter_utilisateur(u);
liberer_utilisateur(u); // la mémoire de *u est rendue au système
// ... plus loin dans le programme, par erreur de logique ...
afficher_nom(u); // utilisation d'un pointeur déjà libéré :
// la zone mémoire pointée par u a pu, entre
// temps, être réallouée à une tout autre
// structure de données par le programme
Si, entre la libération et cette utilisation tardive, l'allocateur mémoire a réutilisé cette même zone pour une structure différente (potentiellement sous l'influence indirecte d'un attaquant qui provoque des allocations à des moments choisis), afficher_nom(u) accède alors à des données qui n'ont plus rien à voir avec l'utilisateur d'origine, avec un comportement imprévisible allant du simple plantage à la corruption exploitable de structures internes du programme. Contre-mesure directe : réaffecter systématiquement un pointeur à NULL immédiatement après libération, et vérifier cette valeur avant toute utilisation ultérieure.
Illustration : vulnérabilité de chaîne de format¶
// Vulnérable : l'entrée utilisateur est utilisée directement
// comme chaîne de format
printf(entree_utilisateur);
// Si entree_utilisateur vaut "%x %x %x %x", printf interprète chaque
// %x comme une demande de lire un argument supplémentaire sur la pile
// (qui n'a pourtant pas été fourni), révélant ainsi le contenu de la
// pile à l'attaquant. Le spécificateur %n est plus grave encore : il
// demande à printf d'ÉCRIRE en mémoire, à l'adresse fournie en argument,
// le nombre de caractères déjà affichés — une primitive d'écriture
// mémoire arbitraire si l'attaquant contrôle cette chaîne de format.
// Corrigé : la donnée utilisateur est passée comme argument, jamais
// comme chaîne de format elle-même
printf("%s", entree_utilisateur);
Illustration : débordement d'entier menant à une allocation sous-dimensionnée¶
// nombre_elements est un entier non signé fourni (indirectement) par
// l'utilisateur, par exemple lu depuis un fichier ou un flux réseau
unsigned int nombre_elements = lire_valeur_entree();
unsigned int taille = nombre_elements * sizeof(struct enregistrement);
struct enregistrement *tableau = malloc(taille);
// Si nombre_elements est suffisamment grand, la multiplication déborde
// silencieusement (le résultat "taille" revient à une petite valeur),
// malloc alloue alors un espace bien plus petit que ce que le programme
// s'apprête ensuite à remplir en croyant disposer de "nombre_elements"
// emplacements valides : un débordement de tampon sur le tas s'ensuit.
Contre-mesure : vérifier explicitement qu'une multiplication ou addition de tailles ne dépasse pas les bornes du type utilisé avant d'effectuer l'allocation (ou utiliser des fonctions d'allocation qui détectent nativement ce cas, disponibles dans certaines bibliothèques standard modernes).
5. Démarche d'analyse d'une vulnérabilité mémoire (aperçu, développé en TP3)¶
- Identifier le point d'entrée de données non fiables et la fonction dangereuse concernée.
- Déterminer la structure mémoire environnante (variables locales, ordre en mémoire, présence de protections).
- Construire une entrée de test provoquant un comportement anormal observable (plantage contrôlé).
- Affiner progressivement pour comprendre précisément l'impact (simple déni de service par plantage, ou contrôle du flot d'exécution).
Le fuzzing comme méthode de découverte automatisée¶
Au-delà de l'analyse manuelle, le fuzzing (test par entrées aléatoires ou mutées automatiquement) constitue une méthode standard de découverte de vulnérabilités mémoire : un outil génère un très grand nombre de variations d'une entrée valide de départ (mutation de bits, insertion de valeurs limites), exécute le programme cible avec chacune, et surveille tout plantage ou comportement anormal détecté (souvent combiné à une instrumentation comme AddressSanitizer, ci-dessous, pour détecter des corruptions mémoire qui ne provoqueraient pas nécessairement un plantage immédiat et visible). Cette approche est particulièrement efficace pour les vulnérabilités mémoire, précisément parce qu'un très grand nombre de cas limites mal gérés (tailles, décalages, valeurs extrêmes) échappent facilement à une revue manuelle mais se révèlent rapidement par exploration automatisée massive.
6. Prévention à la source : la meilleure défense¶
Au-delà des contre-mesures systémiques (qui compliquent l'exploitation mais ne l'empêchent pas toujours), la prévention la plus fiable reste à la source : fonctions bornées (snprintf plutôt que sprintf/strcpy), vérification systématique des tailles, compilation avec les avertissements activés et des outils d'analyse statique/dynamique (AddressSanitizer, valgrind — rappel module C, TP6), et, pour de nouveaux développements sensibles, l'usage de langages à sécurité mémoire garantie par construction (Rust, Go) lorsque le contexte le permet.
Pourquoi certains langages éliminent structurellement cette classe de vulnérabilité¶
Des langages comme Rust imposent, au moment de la compilation, des règles strictes de possession et d'emprunt (ownership et borrowing) garantissant qu'un pointeur ne peut jamais être utilisé après la libération de la mémoire qu'il désigne, et que tout accès à un tableau est automatiquement vérifié par rapport à ses bornes réelles avant exécution. Ce choix de conception élimine, par construction et sans intervention du développeur à chaque accès, des classes entières de vulnérabilités décrites dans ce chapitre (use-after-free, débordement de tampon), au prix d'une contrainte de conception plus stricte qu'un langage comme le C, qui laisse au contraire l'ensemble de ces vérifications à la discipline du développeur.
À retenir¶
- Un débordement de tampon sur la pile peut, en l'absence de protections, permettre à un attaquant de rediriger le flot d'exécution du programme en écrasant l'adresse de retour.
- Les protections systémiques modernes (canary, ASLR, DEP/NX) rendent l'exploitation bien plus difficile mais ne remplacent pas une prévention rigoureuse à la source, et ont donné naissance à des techniques de contournement plus avancées (ROP).
- Use-after-free, vulnérabilité de chaîne de format et débordement d'entier sont trois autres classes de vulnérabilités mémoire exploitables, chacune avec son propre mécanisme et sa propre contre-mesure directe.
- Le fuzzing constitue une méthode standard et efficace de découverte automatisée de vulnérabilités mémoire, en complément de la revue manuelle.
- La prévention à la source (fonctions bornées, vérifications systématiques, outils d'analyse) reste la défense la plus fiable, et certains langages modernes (Rust) éliminent structurellement des classes entières de ces vulnérabilités, en cohérence directe avec les pratiques déjà vues au module Algorithmique et Programmation en C.
Dr. Zakaria Sawadogo — École Polytechnique de Ouagadougou