Pile d’appel
Considérons ce morceau de code :
int B(int b1, int b2) {
int secret = 777;
int resultat_b = b1 + b2 + secret;
return resultat_b;
}
int A(int arg_a1) {
int val_a = 10;
int res_b = B(arg_a1, val_a);
return res_b + val_a;
}
int main(void) {
int total = A(5);
return 0;
}
Idées générale
A chaque appel fonction, on empile le contexte de l’appel dans la pile d’appel (stack) : les arguments (= valeur des paramètres) et les variables locales. Il y a plusieurs façons de procéder… on peut en imaginer. D’ailleurs Unix et Windows n’ont pas la même façon d’organiser la pile d’appel. On décrit celle utilisée par Unix. Les choix utilisées s’appellent les conventions d’appel (calling conventions), intégrées dans les ABI (Application Binary Interfaces).
Definitions
Le RIP est le register instruction pointer. Il contient un pointeur vers l’instruction à exécuter. C’est l’adresse mémoire vers la case mémoire qui contient l’instruction à exécuter.
Le RSP est le register stack pointer. Il contient un pointeur vers le sommet de pile. Il est utile pour savoir où empiler.
Le RBP est le register base pointer. Il pointe dans la pile et permet de se repérer pour accéder aux variables locales, la valeur RIP après le retour de la fonction, et les arguments. C’est une ancre. Juste au dessus (adresse mémoire inférieure), on a les variables locales. Juste au dessous (adresse mémoire supérieure + 8), on a la valeur de RIP pour retourner. Puis les arguments qui ont été déposés par l’appelant.
A quoi ça ressemble plus précisément
Adresses BASSES (Sommet de la pile / RSP)
RSP ──────────►┌────────────────────────────────────────────────────────┐
│ int resultat_b [ 4 octets ] 792 │ (RBP_B - 8)
│ int secret [ 4 octets ] 777 │ (RBP_B - 4)
RBP de B ─────►├────────────────────────────────────────────────────────┤
│ Saved RBP de B [ 8 octets ] Pointe sur RBP de A │ (RBP_B + 0)
├────────────────────────────────────────────────────────┤
│ Adresse de retour [ 8 octets ] Saved RIP (dans A) │ (RBP_B + 8)
├────────────────────────────────────────────────────────┤
│ Arg 1 de B (`arg_b1`)[ 4 octets ] 5 (valeur de arg_a1)│ (RBP_B + 16)
│ Arg 2 de B (`arg_b2`)[ 4 octets ] 10 (valeur de val_a) │ (RBP_B + 20)
├────────────────────────────────────────────────────────┤
│ int res_b [ 4 octets ] (en attente de B) │ (RBP_A - 8)
│ int val_a [ 4 octets ] 10 │ (RBP_A - 4)
RBP de A ─────►├────────────────────────────────────────────────────────┤
│ Saved RBP de A [ 8 octets ] Pointe sur RBP main │ (RBP_A + 0)
├────────────────────────────────────────────────────────┤
│ Adresse de retour [ 8 octets ] Saved RIP (dans main│ (RBP_A + 8)
├────────────────────────────────────────────────────────┤
│ Arg 1 de A (`arg_a1`)[ 4 octets ] 5 │ (RBP_A + 16)
├────────────────────────────────────────────────────────┤
│ int total [ 4 octets ] (en attente de A) │ (RBP_main - 4)
RBP de main ──►├────────────────────────────────────────────────────────┤
│ Saved RBP de main [ 8 octets ] 0x0 (fond de pile) │
└────────────────────────────────────────────────────────┘
Adresses HAUTES (Fond de la pile)
Exemple de vulnérabilité
#include <stdio.h>
#include <stdlib.h>
void secret(void) {
printf("[+] Fonction secret() executee avec succes !\n");
exit(0);
}
void vuln(void) {
void *buffer[1];
// Sur x86-64 avec GCC (sans optimisations) :
// buffer[0] : variable locale
// buffer[1] : saved RBP
// buffer[2] : adresse de retour (RIP)
printf("[*] Redirection du flux d'execution vers secret()...\n");
buffer[2] = (void *)secret;
}
int main(void) {
printf("[*] Début du programme\n");
vuln();
printf("[-] Ce message ne s'affichera jamais.\n");
return 0;
}
En compilant avec
`gcc test.c`
ça ne marche pas car le compilateur fait des optimisations et ajoute des mécanismes de protection.
Par contre avec
gcc -fno-stack-protector -O0 -no-pie test.c -o test
on exécute la fonction secrète.