Aller au contenu

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.

Publié le 8 min de lecture

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.

Espace d'adressage virtuel simplifié d'un processus Linux 64 bits : text, rodata, data et bss en bas, puis le tas qui croît vers le haut, la région mmap avec les bibliothèques partagées, et la pile en haut qui croît vers le bas

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 LOAD est 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_STACK est RW, pas RWE, ce qui indique au noyau de mapper la pile non exécutable. C'est la mitigation NX/DEP dans sa forme ELF.
  • GNU_RELRO marque 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

SectionPermissions typiquesContenuPourquoi les défenseurs s'y intéressent
.textR-XCode machineNe devrait jamais être inscriptible
.rodataR--Littéraux de chaîne, tables constantesLes chaînes de format vont ici, pas dans l'entrée utilisateur
.dataRW-Globales initialiséesLes pointeurs de fonction globaux sont une cible attirante
.bssRW-Globales initialisées à zéroDe grands tampons statiques vivent souvent ici
.got / .got.pltRW- ou R--Adresses des fonctions et données externesEn lecture seule seulement avec RELRO complet
.init_array / .fini_arrayR-- après RELROPointeurs de constructeur et destructeurProté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 :

MitigationCe qu'elle change dans l'agencementConséquence de bug qu'elle limite
NX / DEPLes pages de pile, de tas et de données ne sont pas exécutablesLes octets injectés ne peuvent pas s'exécuter comme du code
ASLRLes bases de pile, tas, mmap et vDSO changent à chaque exécutionLes adresses codées en dur cessent de fonctionner
PIELa base de l'exécutable lui-même est aussi randomiséeLe code du binaire principal ne peut pas être localisé à l'aveugle
RELROLa GOT et les tableaux init/fini deviennent en lecture seuleLes cibles d'appel de bibliothèque ne peuvent pas être redirigées
Canari de pileUne valeur secrète se place entre les locales et les données sauvegardéesLes débordements de pile linéaires sont détectés au retour
Pages de gardePages non mappées autour des pilesL'é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_STACK sans E signifie une pile non exécutable, et GNU_RELRO protè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 -l et checksec vous permettent de vérifier l'agencement, les permissions et la randomisation au lieu de les présumer.

Guides associés

0x1000 · Classes de vulnérabilités

Débordements de tas, use-after-free et double free

Comment les bugs mémoire du tas corrompent l'allocateur et l'état des objets, pourquoi le use-after-free est si dangereux, et les défenses qui les arrêtent.