Aller au contenu principal
own2pwn
pwn/pwn-mitigations.tsx

NX, ASLR, canary : ce qui rend le pwn dur

Tour complet des protections modernes contre les Control Flow Attacks et Code Reuse Attacks : NX/DEP, ASLR, PIE, RELRO, stack canaries, CET. On explique ce que chaque mitigation fait, comment la vérifier, et pourquoi les attaquants s'en affranchissent quand même.

own2pwn··10 min de lecture

Prérequis

Hello ! o/

Dans l'article d'intro, on a vu que pwn c'est détourner le flow d'un binaire. Mais évidemment, les développeurs du kernel Linux, des compilateurs et des processeurs n'ont pas croisé les bras. Depuis les années 2000, une pile de mitigations a été empilée pour rendre la vie dure aux pwners.

On va faire le tour de toutes les grandes protections, comprendre ce qu'elles font concrètement, comment les détecter sur un binaire, et pourquoi elles ne sont pas magiques.

Vérifier les protections d'un binaire
La commande de référence c'est checksec --file=./binary (paquet pwntools ou checksec standalone). Elle lit les flags ELF et affiche toutes les mitigations en une ligne.
bash
$ checksec --file=./vuln
[*] '/tmp/vuln'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled

NX - No-eXecute (aka DEP, W^X)

Ce que c'est

NX (No-eXecute), ou DEP (Data Execution Prevention) sous Windows, est la mitigation la plus fondamentale. Elle impose que chaque page mémoire soit soit writable, soit executable, mais jamais les deux en même temps. C'est le principe W^X (Write XOR Execute).

Avant NX, l'attaque classique consistait a injecter un shellcode dans le buffer overflow, puis a faire pointer RIP dessus. Avec NX, la stack et le heap sont marqués non-executables : le processeur leve une exception si on essaie d'executer du code dans une zone data.

nx-memory-map.txt
  Carte memoire du processus (NX actif)
  ┌─────────────────────────────────────────────────────┐
  │  Segment      │  Permissions  │  Executable ?       │
  ├───────────────┼───────────────┼─────────────────────┤
  │  .text        │  R - X        │  OUI - code prog    │
  │  .rodata      │  R - -        │  non                │
  │  .data / .bss │  R W -        │  non                │
  │  [heap]       │  R W -        │  non                │
  │  [stack]      │  R W -        │  NON  <-- NX bloque │
  │  [libc]       │  R - X        │  OUI - code libc    │
  └─────────────────────────────────────────────────────┘
              ▲
    Si on fait pointer RIP vers la stack : SIGSEGV
NX : les pages .text sont R-X, la stack est RW- . Pas d'intersection possible.

Contournement

NX bloque l'injection directe de shellcode mais ne fait rien contre les Code Reuse Attacks. Avec du ROP (Return-Oriented Programming) ou un ret2libc, on enchaine des gadgets deja presents dans le code existant : on ne cree pas de nouvelle zone executable, on reutilise ce qui existe. NX est donc contournable sans injecter un seul octet de code.

bash
# NX visible dans les flags ELF (GNU_STACK)
$ readelf -l ./vuln | grep GNU_STACK
  GNU_STACK   0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
#                                                              ^^  pas de X = NX actif

# Si NX est desactive (binaire compile avec -z execstack) :
  GNU_STACK   0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
#                                                              ^^^ E = executable

ASLR - Address Space Layout Randomization

Ce que c'est

ASLR (Address Space Layout Randomization) est une protection du kernel (pas du compilateur). A chaque lancement du processus, les adresses de base de la stack, du heap et des bibliotheques partagees sont randomisees. L'attaquant ne peut donc pas hardcoder une adresse de retour vers system() dans la libc puisqu'il ne sait pas ou elle est chargee.

aslr-runs.txt
  Run 1 :
    [stack]   0x7ffd_a3c2_0000
    [libc]    0x7f88_41200000
    system()  0x7f88_41200000 + 0x4f420 = 0x7f88_4124f420

  Run 2 :
    [stack]   0x7ffb_e091_0000
    [libc]    0x7f21_8ab00000
    system()  0x7f21_8ab00000 + 0x4f420 = 0x7f21_8af4f420

  Run 3 :
    [stack]   0x7fff_1234_0000
    [libc]    0x7fde_cc300000
    system()  0x7fde_cc300000 + 0x4f420 = 0x7fde_cc34f420
                                           ^^^^^^^^^^^^^^^^
                        adresse differente a chaque fois
ASLR : la base de la libc change a chaque execution.

Niveaux ASLR

ASLR se configure via /proc/sys/kernel/randomize_va_space :

ValeurEffet
0ASLR desactive. Tout est fixe. Le reve pour un pwner.
1Stack et libs randomisees. Heap fixe.
2Stack, libs et heap randomises. Defaut Linux.

Contournement

ASLR randomise mais avec un nombre de bits limites (souvent 28 bits sur x86-64, moins sur x86-32). Sur x86-32, une attaque par brute force est faisable en quelques secondes (1/256 de chance par essai). Sur x86-64, c'est beaucoup plus dur mais les fuites d'adresses (info leak) restent le vecteur principal : si on arrive a lire une adresse depuis un pointeur en memoire, on peut recalculer la base et contourner ASLR.

PIE - Position-Independent Executable

Ce que c'est

PIE (Position-Independent Executable) est l'equivalent d'ASLR mais pour le binaire lui-meme. Sans PIE, le segment .text du binaire est charge a une adresse fixe (typiquement 0x400000 sur x86-64). Avec PIE, le binaire est compile comme une shared library et son adresse de chargement est randomisee par ASLR.

PIE + ASLR ensemble = toutes les regions memoire sont randomisees. Sans PIE, meme avec ASLR, les gadgets ROP dans le binaire principal ont des adresses fixes et exploitables directement.

bash
# Binaire sans PIE : adresse fixe visible dans readelf
$ readelf -h ./vuln_no_pie | grep Type
  Type: EXEC  (executable)

# Binaire avec PIE
$ readelf -h ./vuln_pie | grep Type
  Type: DYN   (position-independent executable)

# Difference en pratique - sans PIE :
$ gdb -q ./vuln_no_pie -ex "starti" -ex "info proc map" -batch | grep vuln
  0x400000  0x401000  rwxp  /tmp/vuln_no_pie   # toujours 0x400000

# Avec PIE :
  0x555555554000  0x555555555000  rwxp  /tmp/vuln_pie  # base aleatoire
PIE et les gadgets ROP
Meme avec PIE actif, si on a une fuite d'adresse dans le binaire, on recalcule la base et on a acces a tous les gadgets. PIE augmente la difficulte mais ne bloque pas les attaques - il faut obligatoirement un info leak pour contourner.

RELRO - RELocation Read-Only

Ce que c'est

RELRO (RELocation Read-Only) protege les sections de relocation du binaire ELF contre les ecritures apres le chargement. L'objectif principal : empecher la corruption de la GOT (Global Offset Table), une table de pointeurs vers les fonctions de la libc utilisee par le dynamic linker.

Sans RELRO (ou avec Partial RELRO seulement), la GOT est writable pendant toute la duree d'execution. Un overflow ou une primitive d'ecriture arbitraire permet d'ecraser l'entree printf@got avec l'adresse de system() par exemple.

ModeCe qui est protegeGOT writable ?
No RELRORienOUI - entierement
Partial RELRO.init_array, .fini_array, .jcr mis en lecture seuleOUI - GOT encore writable
Full RELROToutes les relocations resolues au demarrage, GOT en RONON - GOT read-only
Full RELRO a un cout
Full RELRO oblige le linker a resoudre toutes les fonctions dynamiques au demarrage (pas de lazy binding). Sur des binaires avec beaucoup de dependances, ca peut rallonger le temps de demarrage. C'est pourquoi Partial RELRO reste courant meme dans des distros modernes.

Stack Canary

Ce que c'est

Le stack canary est une valeur aleatoire generee au demarrage du programme et placee sur la stack entre les variables locales et l'adresse de retour. Avant qu'une fonction execute son ret, le compilateur insere une verification : si le canary a ete modifie, le programme appelle __stack_chk_fail() et s'arrete brutalement. L'objectif : detecter les stack overflows classiques qui ecrasent l'adresse de retour.

stack-canary.txt
  Stack frame de foo() avec canary

  adresses hautes
  ┌─────────────────────────────┐
  │  adresse de retour (saved RIP) │  <-- objectif de l'attaquant
  ├─────────────────────────────┤
  │  saved RBP                  │
  ├─────────────────────────────┤
  │  CANARY  (8 bytes, random)  │  <-- chien de garde
  ├─────────────────────────────┤
  │  variable locale int x      │
  ├─────────────────────────────┤
  │  char buf[64]               │  <-- buffer vulnerable
  └─────────────────────────────┘
  adresses basses

  Overflow sur buf[64] :
    buf[0..63]   = payload
    buf[64..71]  = ecrase x        (tolerable)
    buf[72..79]  = ecrase CANARY   --> __stack_chk_fail() --> crash
    buf[80..87]  = ecrase RBP      (jamais atteint)
    buf[88..95]  = ecrase saved RIP (jamais atteint)
Le canary se place entre les buffers et l'adresse de retour. Un overflow le detecte avant d'atteindre RIP.

Le format du canary

Sur Linux x86-64, le canary a toujours son octet de poids faible a 0x00. C'est voulu : si un overflow utilise strcpy() ou une fonction sensible aux null bytes, la copie s'arrete avant d'avoir ecrase completement le canary. Ca complique les attaques basees sur des strings.

c
// Ce que le compilateur genere (pseudo-code)
void foo(char *input) {
    char buf[64];
    unsigned long canary = __stack_chk_guard;  // lit le canary depuis fs:0x28

    strcpy(buf, input);   // vulnerable

    if (canary != __stack_chk_guard) {
        __stack_chk_fail();   // abort() -> crash avec message
    }
    return;
}

Contournements

Le canary n'est pas incontournable. Les techniques classiques :

  • Info leak du canary : si on peut lire la memoire (format string vuln, buffer over-read), on recupere la valeur du canary et on la reecrit a l'identique dans le payload.
  • Brute force (fork servers) : si le processus fait fork(), le canary est herite par les enfants. On peut le bruteforcer octet par octet (256 essais par octet au lieu de 2^64).
  • Ecraser sans toucher le canary : certains overflows permettent de cibler directement des pointeurs de fonction stockes ailleurs sur la stack, en sautant le canary.

CET - Control-flow Enforcement Technology

Ce que c'est

CET (Control-flow Enforcement Technology) est une mitigation hardware Intel introduite avec les processeurs Tiger Lake (2020). Elle ajoute deux mecanismes independants au niveau CPU :

  • IBT (Indirect Branch Tracking) : les sauts indirects (calls via registre, jmp rax, etc.) ne peuvent atterrir que sur des instructions marquees ENDBR64. Ca detruit les chaines ROP/JOP qui utilisent des gadgets arbitraires.
  • Shadow Stack : une pile separee (en memoire protegee) contient uniquement les adresses de retour. A chaque ret, le CPU compare l'adresse de retour sur la stack normale avec celle sur la shadow stack. Si elles different - crash immediat, sans passer par le canary.
cet-shadow-stack.txt
  call foo()
       |
       v
  Stack normale         Shadow Stack (CPU-protected)
  ┌─────────────┐       ┌─────────────┐
  │ saved RIP   │       │ saved RIP   │  <- copie identique
  │ 0x401234    │       │ 0x401234    │     mise par le CPU au CALL
  ├─────────────┤       └─────────────┘
  │ canary      │
  ├─────────────┤
  │ buf[64]     │  <-- overflow
  └─────────────┘

  Overflow ecrase saved RIP sur stack normale :
  ┌─────────────┐       ┌─────────────┐
  │ 0xdeadbeef  │  !=   │ 0x401234    │
  └─────────────┘       └─────────────┘
           |
           v
      CPU : divergence detectee --> #CP (Control Protection fault)
CET Shadow Stack : deux copies de l'adresse de retour. On ne peut pas en modifier une sans que l'autre ne le detecte.

CET en pratique

CET necessite le support du noyau (Linux 6.x), de la toolchain (GCC/Clang avec -fcf-protection=full), et du CPU. Sur un binaire compile avec CET, on peut voir les ENDBR64 au debut de chaque fonction :

bash
# Verifier si CET est supporte par le CPU
$ grep -m1 ibt /proc/cpuinfo
flags : ... ibt shstk ...

# Voir les ENDBR64 au debut des fonctions
$ objdump -d ./vuln_cet | grep -A2 "<main>:"
0000000000001149 <main>:
    1149:  f3 0f 1e fa   endbr64      # <-- marqueur IBT
    114d:  55            push   rbp
CET n'est pas encore universel
En 2025, CET est present sur les CPU Intel depuis Tiger Lake et AMD depuis Zen 3. Mais son adoption dans les distros et les binaires tiers reste partielle. Beaucoup de binaires systeme ne sont pas encore compiles avec -fcf-protection=full.

Vue d'ensemble : quelle protection bloque quoi

Aucune mitigation n'est suffisante seule. En pratique, elles se complementent. Voila comment elles se repartissent face aux familles d'attaques :

MitigationShellcode injecteROP / ret2libcGOT overwriteStack overflow direct
NXBloqueNonNonPartiel
ASLR + PIECompliqueCompliqueCompliqueNon
Stack CanaryNonBloque (sans leak)NonBloque
Full RELRONonNonBloqueNon
CETBloque (IBT)Bloque (Shadow Stack + IBT)NonBloque (Shadow Stack)
La vraie securite : profondeur de defense
Un binaire durci combine toutes ces mitigations ensemble. Meme avec tout a "Full", un info leak change tout. C'est pourquoi les vraies exploitations modernes enchainent plusieurs vulnerabilites : une pour leaker des adresses, une pour la primitive d'ecriture, une pour le pivot. Le jeu du chat et de la souris continue.

En resume

On vient de couvrir les six grandes mitigations contre les attaques de flow :

  • NX : interdit d'executer du code dans les zones data (stack, heap). Contourne par ROP.
  • ASLR : randomise les adresses du kernel. Contourne par info leak.
  • PIE : randomise le binaire lui-meme. Idem, info leak necessaire.
  • RELRO : protege la GOT contre les ecritures. Full RELRO elimine les GOT overwrites.
  • Stack Canary : detecte les overflows classiques. Contourne par leak ou brute force.
  • CET : protection hardware - Shadow Stack + IBT. La mitigation la plus solide a ce jour, mais pas encore universelle.

La suite, c'est de voir en pratique comment on contourne ces protections une par une et en combinaison. A bientot !

Articles liés