L'agencement mémoire d'un processus : pile, tas et ELF
Visite guidée pour défenseur de l'espace d'adressage d'un processus Linux : segments ELF, pile et conventions d'appel, tas, mmap, et le rôle des permissions.
Tout bug de corruption mémoire est en fin de compte une écriture ou une lecture qui atterrit là où elle ne devrait pas. Pour juger de sa gravité, il faut savoir ce qui vit à destination : une adresse de retour, un pointeur de fonction, des métadonnées d'allocateur, une chaîne que personne ne relira, ou une page non mappée qui fera planter le processus sans dommage. Ce guide construit ce modèle mental pour un processus Linux 64 bits. C'est le fondement du reste du site, des débordements de tampon de pile aux drapeaux de durcissement des binaires.
La mémoire virtuelle en un paragraphe
Un processus ne voit jamais la RAM physique. Il voit un espace d'adressage virtuel privé, découpé en pages de 4 Kio (des pages plus grandes existent mais ne changent pas le modèle). Le noyau mappe chaque page vers de la mémoire physique, vers un fichier, ou vers rien du tout, et y attache des permissions : lisible, inscriptible, exécutable, ou une combinaison. Toucher une page non mappée, écrire sur une page en lecture seule ou exécuter une page non exécutable lève une faute que le noyau délivre sous forme de SIGSEGV. Ce simple fait explique pourquoi tant de bugs de sûreté mémoire se manifestent comme des erreurs de segmentation, et pourquoi les permissions de page sont l'une des mitigations les plus importantes dont nous disposons.
Du fichier ELF au processus en cours d'exécution
Un exécutable sur Linux est un fichier ELF. L'éditeur de liens regroupe les sections du programme en segments, décrits par des en-têtes de programme, et le noyau et l'éditeur de liens dynamique mappent ces segments en mémoire. Vous pouvez les lister avec readelf :
readelf -lW ./server | sed -n '/Program Headers/,/Section to Segment/p'
Type Offset VirtAddr ... Flg Align
PHDR 0x000040 0x0000000000000040 ... R 0x8
INTERP 0x000318 0x0000000000000318 ... R 0x1
LOAD 0x000000 0x0000000000000000 ... R 0x1000
LOAD 0x001000 0x0000000000001000 ... R E 0x1000
LOAD 0x002000 0x0000000000002000 ... R 0x1000
LOAD 0x002db8 0x0000000000003db8 ... RW 0x1000
DYNAMIC 0x002dc8 0x0000000000003dc8 ... RW 0x8
GNU_STACK 0x000000 0x0000000000000000 ... RW 0x10
GNU_RELRO 0x002db8 0x0000000000003db8 ... R 0x1
Trois choses dans cette sortie comptent pour la sécurité :
- Un seul segment
LOADest exécutable (R E), et il n'est pas inscriptible. Un code modifiable à l'exécution est un code qu'un attaquant peut modifier. GNU_STACKestRW, pasRWE, ce qui indique au noyau de mapper la pile non exécutable. C'est la mitigation NX/DEP dans sa forme ELF.GNU_RELROmarque une plage que le chargeur rend en lecture seule après application des relocations, protégeant la table des offsets globaux (GOT). Avec RELRO complet, les pointeurs de fonction utilisés pour les appels dans les bibliothèques partagées ne peuvent plus être écrasés.
Les adresses virtuelles commencent à zéro parce que ce binaire est indépendant de la position (PIE). Le chargeur choisit une base aléatoire à l'exécution, ce qui rend l'ASLR efficace pour le programme principal et pas seulement pour les bibliothèques.
Les sections que vous rencontrerez le plus souvent
| Section | Permissions typiques | Contenu | Pourquoi les défenseurs s'y intéressent |
|---|---|---|---|
.text | R-X | Code machine | Ne devrait jamais être inscriptible |
.rodata | R-- | Littéraux de chaîne, tables constantes | Les chaînes de format vont ici, pas dans l'entrée utilisateur |
.data | RW- | Globales initialisées | Les pointeurs de fonction globaux sont une cible attirante |
.bss | RW- | Globales initialisées à zéro | De grands tampons statiques vivent souvent ici |
.got / .got.plt | RW- ou R-- | Adresses des fonctions et données externes | En lecture seule seulement avec RELRO complet |
.init_array / .fini_array | R-- après RELRO | Pointeurs de constructeur et destructeur | Protégés par RELRO |
La pile et les conventions d'appel
Chaque thread a sa propre pile, une région contiguë qui croît vers les adresses basses. Chaque appel de fonction empile un nouveau cadre : de l'espace pour les variables locales, les registres sauvegardés et l'adresse où revenir.
Sur Linux x86-64, l'ABI System V passe les six premiers arguments entiers ou pointeurs dans des registres (rdi, rsi, rdx, rcx, r8, r9), et la valeur de retour revient dans rax. L'instruction call empile l'adresse de retour ; un prologue typique sauvegarde ensuite le pointeur de cadre de l'appelant et réserve de l'espace pour les locales :
adresses hautes
+--------------------------------+
| cadre de l'appelant |
+--------------------------------+
| adresse de retour | <- empilée par call
+--------------------------------+
| rbp sauvegardé | <- rbp pointe ici
+--------------------------------+
| canari de pile (si activé) |
+--------------------------------+
| tableaux locaux |
| scalaires locaux |
+--------------------------------+ <- rsp
adresses basses
Voici la conséquence gênante. Les tableaux locaux sont écrits des adresses basses vers les adresses hautes, mais l'adresse de retour se trouve au-dessus d'eux. Une copie qui dépasse la fin d'un tableau local marche donc vers les données de contrôle sauvegardées. Cette géométrie est la raison pour laquelle les débordements de pile étaient historiquement si dangereux, et la raison pour laquelle le canari de pile se trouve exactement là où il est.
D'autres architectures diffèrent dans des détails qui comptent pendant le triage. AArch64 garde l'adresse de retour dans le registre de lien x30 et ne la déverse sur la pile que dans les fonctions non-feuilles ; avec l'authentification de pointeurs activée, la valeur déversée est signée. Windows x64 passe quatre arguments dans rcx, rdx, r8 et r9 et réserve un espace fantôme de 32 octets pour l'appelé. Savoir quelle ABI on regarde évite de mal lire une trace de pile.
Pointeurs de cadre et traces de pile
Les compilateurs omettent souvent le pointeur de cadre à -O2 (-fomit-frame-pointer) et utilisent rbp comme registre généraliste. Les débogueurs se reposent alors sur les informations de déroulement DWARF (.eh_frame) pour reconstruire la pile d'appels. Plusieurs distributions, dont Fedora et Ubuntu, construisent désormais les paquets avec -fno-omit-frame-pointer pour rendre le profilage et l'analyse de crash plus fiables. Si vos traces de pile de production semblent tronquées, l'absence d'informations de déroulement ou de pointeurs de cadre en est une cause probable, sujet sur lequel nous revenons dans lire les rapports de crash.
Le tas
La mémoire demandée avec malloc, calloc, realloc ou new provient de l'allocateur de tas. Dans la glibc, les petites et moyennes demandes sont taillées dans des arènes qui croissent via brk (l'arène principale) ou mmap (les arènes de thread), tandis que les demandes au-dessus d'un seuil (128 Kio par défaut, ajusté dynamiquement) obtiennent leur propre mapping mmap.
L'allocateur stocke les métadonnées à côté des données utilisateur. Chaque chunk glibc commence par un champ de taille, et les chunks libérés sont chaînés dans des bins par des pointeurs stockés à l'intérieur de la mémoire libérée elle-même :
chunk utilisé chunk libre (tcache)
+-------------------+ +-------------------+
| prev_size / data | | prev_size / data |
| size | A | M | P | | size | A | M | P |
+-------------------+ <- ptr +-------------------+
| données utilis... | | next (masqué) |
| | | key |
+-------------------+ +-------------------+
Cette conception est rapide, mais elle signifie qu'un débordement de tas ou une écriture par un pointeur pendant peut corrompre la comptabilité interne de l'allocateur. La glibc moderne répond par des contrôles d'intégrité qui avortent sur des métadonnées incohérentes et par le safe-linking, qui masque les pointeurs de liste libre. Les détails, et les options défensives telles que les allocateurs durcis, sont traités dans corruption du tas et use-after-free.
La région mmap et les bibliothèques partagées
Entre le tas et la pile se trouve la région où le noyau place les mappings mmap : les bibliothèques partagées telles que libc.so.6, l'éditeur de liens dynamique, les grandes allocations, les fichiers mappés en mémoire et les piles de threads. Avec l'ASLR activé, sa base est randomisée à chaque exécution.
Vous pouvez inspecter l'agencement en direct de tout processus que vous possédez :
cat /proc/self/maps
55d4c8a00000-55d4c8a01000 r--p 00000000 08:01 1837 /usr/bin/cat
55d4c8a01000-55d4c8a05000 r-xp 00001000 08:01 1837 /usr/bin/cat
...
55d4ca1f2000-55d4ca213000 rw-p 00000000 00:00 0 [heap]
7f3b1c600000-7f3b1c628000 r--p 00000000 08:01 4410 /usr/lib/x86_64-linux-gnu/libc.so.6
7f3b1c628000-7f3b1c7bd000 r-xp 00028000 08:01 4410 /usr/lib/x86_64-linux-gnu/libc.so.6
...
7ffd6f9e1000-7ffd6fa02000 rw-p 00000000 00:00 0 [stack]
7ffd6fbd4000-7ffd6fbd6000 r-xp 00000000 00:00 0 [vdso]
Lancez-le deux fois et les adresses changent : c'est l'ASLR à l'œuvre. Cherchez tout mapping qui est à la fois inscriptible et exécutable (rwxp). Dans un programme moderne bien construit, il ne devrait y en avoir aucun, hormis les cas délibérés tels que les compilateurs JIT, qui ont leurs propres stratégies de durcissement.
Comment permissions et randomisation se combinent
L'agencement mémoire n'est pas seulement un support pédagogique. C'est exactement ce que manipulent les principales mitigations :
| Mitigation | Ce qu'elle change dans l'agencement | Conséquence de bug qu'elle limite |
|---|---|---|
| NX / DEP | Les pages de pile, de tas et de données ne sont pas exécutables | Les octets injectés ne peuvent pas s'exécuter comme du code |
| ASLR | Les bases de pile, tas, mmap et vDSO changent à chaque exécution | Les adresses codées en dur cessent de fonctionner |
| PIE | La base de l'exécutable lui-même est aussi randomisée | Le code du binaire principal ne peut pas être localisé à l'aveugle |
| RELRO | La GOT et les tableaux init/fini deviennent en lecture seule | Les cibles d'appel de bibliothèque ne peuvent pas être redirigées |
| Canari de pile | Une valeur secrète se place entre les locales et les données sauvegardées | Les débordements de pile linéaires sont détectés au retour |
| Pages de garde | Pages non mappées autour des piles | L'épuisement de pile provoque une faute au lieu d'un chevauchement de mémoire |
Vous pouvez vérifier la posture d'un binaire en quelques secondes avec checksec --file=./server ou avec readelf, comme montré dans drapeaux de durcissement des binaires.
Points clés à retenir
- Un processus voit un espace d'adressage virtuel fait de pages, chacune avec ses propres permissions de lecture, écriture et exécution. Les fautes sur les mauvais accès surgissent comme
SIGSEGV. - Les en-têtes de programme ELF décident de ce qui est exécutable.
GNU_STACKsansEsignifie une pile non exécutable, etGNU_RELROprotège les données de relocation. - Sur la pile, les tableaux locaux se placent sous les données de contrôle sauvegardées, si bien que les débordements linéaires marchent vers l'adresse de retour. C'est pourquoi les canaris et la réorganisation des variables existent.
- Le tas garde les métadonnées en ligne avec les données utilisateur, si bien que les bugs de tas peuvent corrompre l'allocateur lui-même.
/proc/<pid>/maps,readelf -letchecksecvous permettent de vérifier l'agencement, les permissions et la randomisation au lieu de les présumer.