Partie 7 - Analyse statique d'un mini-programme - la pile (3/5)
Analyse statique d’un mini-programme : la pile (3/5)
On va garder IDA encore ouvert très longtemps sans rien faire ?
Allez, encore un peu de théorie nécessaire avant de revenir au reverse de notre programme. En effet, si on ne comprend pas le fonctionnement de la pile, on ne pourra jamais comprendre :
- comment les variables locales sont utilisées
- comment les arguments sont transmis et utilisés
- comment une fonction retourne
- etc.
Contrairement au tas, la pile (ou pile d’exécution ou stack 🇬🇧) porte bien son nom car elle utilise effectivement la structure de donnée qu’est la pile. C’est-à-dire qu’il s’agit d’une zone mémoire de type LIFO (Last In First Out) ou Dernier Arrivé Premier Servi.
Empiler et dépiler
Je vous propose d’utiliser plusieurs exemples afin de comprendre son fonctionnement.
Par souci de clarté, nous nous limiterons à une analyse de la version 32 bits de pile étant donné que le fonctionnement en 64 bits est exactement le même.
La seule différence entre la pile en 32 bits et 64 bits est la taille maximum de chaque élément qu’elle peut stocker (respectivement 32 et 64).
Prenons une pile pouvant contenir jusqu’à 6 éléments. Si vous vous souvenez, la convention fait que les adresses basses sont situées en haut alors que les adresses hautes en bas.
Imaginons que la pile contienne un seul élément pour l’instant, nous avons donc ceci en mémoire :
Supposons que l’élément non vide provienne d’une fonction A mais que nous sommes désormais dans le code d’une fonction B. Alors esp
et ebp
sont confondus et pointent vers le haut de la pile.
Très souvent, pour ne pas dire tout le temps, lors de l’entrée dans une nouvelle fonction la pile a cette allure (après exécution du prologue), c’est-à-dire que
esp
etebp
pointent vers le haut de la pile.C’est en partie à cela que sert le prologue d’une fonction : avoir un nouvel état “clean” de pile.
Le haut de la pile n’est pas
0x70000100
car le haut de la pile est la dernière valeur ajoutée sur la pile.
Ajoutons quelques éléments à notre pile ! Par exemple :
- l’entier
0xdeadbeef
- l’entier nul
0x00000000
- le caractère
A
Voyons tout d’abord l’état de la pile après avoir ajouté 0xdeadbeef
:
Désormais, le haut de la pile est l’adresse qui pointe vers 0xdeadbeef
et qui est donc 0x70000110
, c’est pourquoi esp
pointe vers cette adresse.
Pour rappel :
esp
pointe vers le haut de la pileebp
pointe vers la base de la pile
Ce n’est pas à nous de mettre à jour à chaque fois
esp
pour qu’il pointe vers le haut de la pile. Cela est en effet réalisé automatiquement lorsque l’on ajoute (empile) une valeur sur la pile.
Maintenant, ajoutons nos deux autres éléments : 0x00000000
puis A
. L’état de la pile est alors le suivant :
Pour l’instant, c’est logique, tout va bien. Désormais je souhaite récupérer la valeur 0x00000000
de la pile. Le soucis est qu’en raison de la structure de données qu’est la pile, nous ne pouvons pas accéder directement à n’importe quelle valeur. En effet, seules deux opérations sont possibles :
- Empiler : ajouter un élément en haut de la pile. Cette opération est réalisée avec l’instruction assembleur
push ELT
oùELT
est l’élément mis en tête de la stack, exemple :push 0xdeadbeef
.ELT
peut être soit une valeur concrète comme0xdeadbeef
soit un registre commerax
auquel cas c’est la valeur contenue dans le registre qui est insérée en haut de la pile. - Dépiler : retirer un élément du haut de la pile. Cette opération est réalisée avec l’instruction assembleur
pop DEST
oùDEST
est la destination où sera stocké l’élément retiré.DEST
est toujours un registre.
Ainsi, pour accéder à 0x00000000
, il va falloir dépiler une première fois pour récupérer A
(nous ne sommes pas obligés d’en faire quelque chose) puis dépiler une deuxième fois pour avoir accès à la valeur qui nous intéresse.
Si je souhaite stocker 0x00000000
dans edi
, par exemple, je peux faire :
1
2
pop edi ; edi = 0x41 (encodage ASCII de 'A')
pop edi ; edi = 0x00000000
La pile aura ensuite l’allure suivante :
La stack frame
A présent que nous avons les idées plus claire sur la pile (stack), intéressons-nous à une manière d’utiliser la stack afin de bien gérer les appels de fonction, les variables locales et les arguments : la stack frame (ou cadre de pile 😴).
L’idée globale est que chaque fonction puisse pouvoir gérer de manière autonome :
- ses variables locales
- l’accès aux arguments
Prenons un exemple concret avec le code suivant qui calcul le discriminant d’un polynôme du second degré :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "stdio.h"
int discriminant(int a, int b, int c)
{
int result = b*b - (4*a*c);
return result;
}
int main()
{
// Le polynôme : x² + 10x + 3
int a = 1;
int b = 10;
int c = 3;
int result = discriminant(a,b,c);
printf("Le discriminant de mon polynôme est %d\n",result);
}
Supposons que l’état de la pile avant l’appel à la fonction discriminant(a,b,c)
soit dans un état quelconque :
La question que l’on peut se poser est : comment appeler la fonction discriminant
en faisant en sorte qu’elle ait sa propre stack frame sans empiéter sur celle de la fonction main
? La réponse à cette question est exactement ce que vont faire l’appel à la fonction ainsi que son prologue.
📣 L’appel de fonction
Je vous propose de décortiquer la manière dont l’appel à la fonction discriminant
est effectué en x86 :
- Ajout des arguments : tout d’abord, il va bien falloir que d’une façon ou d’une autre, la fonction
discrimiant
puisse avoir accès aux trois arguments. - Ajout de l’adresse de retour : une fois que la fonction
discriminant
sera exécutée, il faut qu’elle puisse retourner à l’instruction qui est située immédiatement après son appel dans la fonctionmain
- Création de la stack frame de
discriminant
: la fonctiondiscriminant
a besoin d’un minimum d’espace pour stocker son unique variable locale.
Ajout des arguments :
Comme la pile est une structure de données de type “Dernier Arrivé Premier Servi”, le dernier argument
3
est empilé en premier. Puis le deuxième argument10
est empilé. Enfin, le premier argument1
est empilé en dernier.
Ajout de la valeur de retour :
Il ne faut pas confondre adresse de retour et valeur de retour qui sont deux choses totalement différentes.
La valeur de retour est le résultat retourné par une fonction, par exemple
-1
dansreturn -1;
. La valeur de retour est toujours retourné viaeax
(et ses dérivées) sauf exception.L’adresse de retour est l’adresse de l’instruction, dans la fonction appelante, que le processeur va exécuter une fois que la fonction appelée est terminée.
Ok super ! Nous sommes dorénavant prêts pour rentrer dans le code de la fonction discriminant
. Tout d’abord, comme vous pouvez le constater, esp
et ebp
pointent vers les adresses de début et de fin de la stack frame de main
. Il ne faudrait donc pas qu’en créant notre nouvelle stack frame perdre ces informations. Nous allons donc les stocker sur la pile.
En fait nous n’avons besoin que de stocker
ebp
car, lorsque l’on quittera la fonctiondiscriminant
,esp
retombera naturellement sur la valeur qu’il avait avant d’entrer dans la fonction.
Le prologue
Vous vous souvenez du prologue ? C’étaient les instructions :
1
2
3
push ebp
mov ebp, esp
sub esp, 0x10
Après avoir sauvegardé ebp
avec push ebp
, la pile est dans cet état :
Ensuite, le processeur va créer une nouvelle stack frame en initialisant ebp
à la même valeur que esp
via mov ebp, esp
.
Cette instruction déplace (copie) le contenu de esp
dans ebp
, la pile devient alors de la sorte :
Bon, pour l’instant c’est pas fameux, on ne voit pas la stack frame de la fonction discriminant
par ce qu’elle est … tout simplement vide ! Or nous avons besoin d’un peu d’espace pour les variables locales, en l’occurrence ici LA variable locale resultat
.
Dans le prologue, c’est l’instruction sub esp, 0x10
qui se charge de réserver un tel espace pour les variables locales en soustrayant esp
de l’espace désiré. Ici cela revient à faire esp = esp - 0x10
.
Pour rappel, comme les adresses basses sont en haut et inversement, pour augmenter la taille de la pile on soustrait à
esp
Tandis que lorsque l’on souhaite réduire la taille de la pile, on ajoute à
esp
.
Enfin, la stack frame de discriminant
est la suivante :
On aurait pu, sur le schéma, inclure
Adresse de retour
dans la stack frame dediscriminant
. Le choix de ne pas l’inclure permet de garder en tête que la stack frame courante est tout l’espace compris entre les valeurs pointées paresp
(qui en délimite le haut) etebp
(qui en délimite la base)
J’ai pas très bien compris pourquoi on étend la pile de 16 (
0x10
) octets alors que l’on a qu’une seule variable localeint result
qui est de 4 octets ? C’est pas très écolo tout ça !
Excellente question et qui a dû en perturber plus d’un (et j’en fais partie 😅) ! En fait c’est une question d’alignement.
Le processeur aime bien que esp
soit aligné sur 8 bits, c’est-à-dire qu’il ait la forme suivante : 0xXXXXXXX0
. Autrement dit, que esp
soit un multiple de 16. C’est pourquoi que l’on ait 4, 8 ou 12 octets de variables locales, le processeur réservera 16 octets.
Dans la même optique d’aligner
esp
, vous verrez peut-être dans le prologue de certaines fonctions une instructionand esp, 0xfffffff0
qui réalise un ET logique de telle sorte à ce queesp
soit aligné sur 16 octets.
A partir de maintenant, le prologue est terminé et il est possible de récupérer les arguments, stocker les variables locales, faire des calculs etc.
Ainsi tout l’espace mémoire entre ebp
et esp
peut être utilisé pour stocker des variables locales.
L’épilogue
Pour rappel, une fonction est constituée de 3 parties :
- Le prologue
- Faire des trucs (calculs, stockage …)
- L’épilogue
En découpant la fonction main
de notre premier programme (celui qui fait une addition et retourne la somme) ouvert dans IDA, nous identifions bien ces 3 étapes distinctes :
Intéressons nous à l’épilogue qui est :
1
2
leave
ret
Il faut savoir que leave
n’est pas une instruction atomique. Cela signifie que lorsque le processeur exécute cette instruction, il exécutera en réalité plusieurs instructions.
En l’occurrence l’instruction leave
est totalement équivalente (en 32 bits) à :
1
2
mov esp, ebp
pop ebp
Ah mais c’est exactement l’inverse de ce qu’a fait le prologue ?
C’est ça !
Pour rappel le prologue est (en partie) :
1
2
push ebp
mov ebp, esp
L’instruction mov esp, ebp
permet de mettre fin à la stack frame courante en mettant esp
à la base de celle-ci. Tandis que l’instruction pop ebp
permet de restaurer l’ancienne valeur de ebp
: celle de la stack frame de la fonction main
.
Il n’y a pas besoin “d’inverser” l’instruction
sub esp, 0x10
car le fait de fairemov esp, ebp
implique queesp
pointe désormais vers le bas de la stack frame, indépendamment de la valeur qu’il avait auparavant.
L’instruction leave
permet ainsi de “sortir”, comme son nom l’indique, de la stack frame
de la fonction appelée. Si on reprend notre exemple avec la fonction main
et discriminant
, après l’instruction leave
la pile d’exécution a cette tête :
Il ne nous reste plus qu’à exécuter l’instruction ret
. En fait, pour aller droit au but, l’instruction ret
est exactement équivalente à faire pop eip
.
Or vous vous souvenez, eip
pointe vers l’instruction qui va être prochainement exécutée. Ainsi, après l’exécution de ret
, eip
sera égal à l’adresse de retour qui est, pour rappel, l’adresse dans le main
après l’appel de fonction.
Vu que ret
dépile l’adresse de retour, la stack frame de la fonction main
est restée intacte du début à la fin de l’appel de la fonction discriminant
.
Comme c’est la fonction
main
qui s’est chargée de mettre les arguments sur la pile, c’est à elle de s’en débarrasser 😏 !Même si, en réalité, ce n’est pas toujours le cas, en effet, dans certaines situations, c’est à la fonction appelée de s’en charger. Nous verrons cela un peu plus tard.
📋 Synthèse
Nous venons de voir le fonctionnement de la pile dans le contexte de l’exécution d’un programme. Plusieurs points ont été vus :
- La pile d’exécution est une structure du type LIFO : Dernier Arrivé Premier Servi
- Seules deux opérations sont possibles : empiler (avec
push
) et dépiler avecpop
- Chaque fonction a sa propre zone mémoire dédiée dans la pile nommée stack frame
- L’exécution d’une fonction est réalisé en 3 étapes :
- Prologue
- Autres opérations quelconques
- Épilogue
- Les registres
esp
etebp
permettent, entre autres, de délimiter respectivement le haut et la base d’une stack frame
➕ Bonus
On vient de voir qu’une stack frame est créée à chaque appel d’une fonction. Ainsi, si une fonction récursive (qui s’appelle elle-même) est appelée sans fin, cela va consommer de la mémoire pour chaque stack frame : c’est tout simplement ce que l’on appelle un stack overflow !
Voilà, vous savez désormais ce que signifie et d’où provient le nom du site éponyme😎.