Partie 13 - La gestion des variables
La gestion des variables
Dans cette partie, nous allons nous intĂ©resser Ă la maniĂšre dont sont gĂ©rĂ©es les variables en assembleur. Nous en avons dĂ©jĂ un peu parlĂ© Ă plusieurs reprises lorsque nous avions parlĂ© du fonctionnement de la pile ainsi que des diffĂ©rents segments mĂ©moire (code, donnĂ©es, tas âŠ) et ce quâils contenaient.
Ce sera aussi lâoccasion de parler dâune chose que jâai souhaitĂ© garder de cĂŽtĂ© pour lâinstant đ«Ł.
Dans cette partie, nous allons beaucoup nous intĂ©resser Ă des zones mĂ©moire Ă partir dâoffsets relatifs Ă
ebp.Il est vivement recommandĂ© de se munir dâun âïž et dâune đïž afin de reprĂ©senter soi-mĂȘme les variables en mĂ©moire pour savoir comment elles seront agencĂ©es.
Les types de données
Les types de base
Tout dâabord, il serait pas mal de se rafraĂźchir la mĂ©moire avec les types de base en C, voici un tableau synthĂ©tique de ces diffĂ©rents types avec leur taille.
Ce quâil faut retenir avec les types de base est quâils ont des tailles variables (un int nâa pas la mĂȘme taille quâun char). Ainsi, si vous faites le reverse dâun programme et quâIDA pense avoir trouvĂ© un tableau de 10 int, il est possible que ce soit en rĂ©alitĂ© un tableau de 40 char.
Bon, et si on essayait de voir comment ces types sont reprĂ©sentĂ©s en mĂ©moire avec un petit exemple ? Voici un petit programme qui fera lâaffaire :
1
2
3
4
5
6
7
8
9
int main(int argc, char *argv[])
{
char chr = 'A';
int nombre = 0xdeadbeef;
unsigned short sh = 0xcafe;
unsigned long lg = 0xaabbccdd;
return 0;
}
Pour le compiler : gcc -m32 -fno-pie -fno-stack-protector main.c -o exe.
Comme dâhabâ, on lâouvre dans IDA :
On constate que les variables locales sont bien sauvegardées dans la pile. Un bon exercice serait de faire un schéma de la pile avec ces différentes valeurs.
Vous pouvez comparer ensuite ce que vous avez trouvé avec le schéma suivant (état de la pile à 0x11ab):
On constate plusieurs choses :
- les noms des variables locales ne sont pas gardées aprÚs la compilation, mais ça, on le savait déjà .
- Les variables dans la pile sont insĂ©rĂ©es de la premiĂšre variable dĂ©clarĂ©e Ă la derniĂšre en remontant dans la pile. Ainsi, lorsque lâon lit la pile de haut en bas, les valeurs sont affichĂ©es dans le sens inverse de leur dĂ©claration dans le code C.
- il y a des trous (qui contiennent des valeurs quelconques, par forcĂ©ment nulles) alors que lâon aurait pu faire tenir toutes les variables sur 11 octets au lieu de 16.
- que la variable soit signĂ©e ou non, le code assembleur nâa pas cette information sur chaque valeur. Ce qui va permettre de les diffĂ©rencier est les instructions (signĂ©es ou non) utilisĂ©es. Exemple :
jb(non signé) oujl(signé).
Concernant lâhistoire des trous, il sâagit encore une fois dâune histoire dâalignement qui arrange le processeur lorsquâil souhaite accĂ©der Ă certaines valeurs.
Lâencodage ASCII
Y a un truc que je comprends pas. Dans le code on a définit notre variable
char chr = 'A';, pourquoi cela a été remplacé par0x41?
TrĂšs bonne question ! Câest vrai que la premiĂšre fois que lâon voit de lâASCII on est un peu perdus âŠ
En fait, les caractĂšres nâont pas rĂ©ellement de sens pour un ordinateur. Ce quâil sait traiter ce sont des bits. Ces bits peuvent ensuite ĂȘtre regroupĂ©s pour reprĂ©senter des donnĂ©es, notamment des nombres.
Les nombres peuvent facilement ĂȘtre reprĂ©sentĂ©s en notation binaire ou hexadĂ©cimale, câest pourquoi, par exemple, lâinstruction mov reg, 0x213 a du sens pour le processeur.
Pour ce qui est des caractĂšres, câest pas Ă©vident. Câest pourquoi il a Ă©tĂ© convenu dâaffecter un nombre Ă chaque caractĂšre. Ainsi, lorsque lâon souhaite manipuler un caractĂšre, il suffit de manipuler lâencodage (nombre) associĂ©.
Voici ce que lâon appelle la table ASCII qui donne lâencodage de chaque caractĂšre :
Ainsi, on constate bien que la caractÚre A est encodé 0x41.
ConnaĂźtre par cĆur ces valeurs nâa que trĂšs peu dâintĂ©rĂȘt, par contre il est intĂ©ressant de savoir dĂ©tecter des caractĂšres ASCII lorsque lâon voit des nombre compris entre 0x20 et 0x7e. En effet, la beacoup de programmes (crackmes ou autre) encode leurs strings en ASCII.
Sous Windows, lâencodage principalement utilisĂ© nâest pas ASCII mais lâUTF-16. Il sâagit dâun encodage diffĂ©rent de lâASCII, notamment par le fait quâil soit encodĂ© sur deux octets (au lieu dâun).
Cela permet de pouvoir encoder bien plus de caractĂšres, notamment ceux de langues non latines (arabe, chinois âŠ).
Les structures et les tableaux
ConsidĂ©rĂ©es comme les ancĂȘtres des classes (en C++), les structures permettent de regrouper plusieurs variables de types diffĂ©rents dans un seul type. Les tableaux, quant Ă eux, permettent de regrouper un certain nombre de variables de mĂȘme type.
Voyons comment sont représentés en mémoire ces deux types de variable avec cet exemple :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct MaStructure
{
int nb;
char ch;
unsigned int u_nb;
unsigned char u_ch;
};
int main()
{
struct MaStructure ma_struct;
ma_struct.nb = 0xdeadbeef;
ma_struct.ch = 'a';
ma_struct.u_nb = 0xcafebabe;
ma_struct.u_ch = 'b';
int tab[5] = {0x10, 0x20, 0x30, 0x40, 0x50};
return 0;
}
En le compilant avec gcc -m32 -fno-pie -fno-stack-protector main.c -o exe, on obtient :
Lorsque le processeur arrivera Ă 0x11cc, la pile aura donc cette forme :
Finalement, il nây a pas de grandes diffĂ©rences avec la gestion des types de base si ce nâest que :
- lâordre des Ă©lĂ©ments du tableau et de la structure sont affichĂ©s dans le bon ordre lorsque lâon lit les valeurs de haut en bas (alors quâavec les variables de base, câĂ©tait lâinverse)
- les
charne sont pas positionnĂ©s sur lâoctet de poids fort mais sur lâoctet de poids faible
Si on a choisi de parler des structure et des tableaux dans le mĂȘme endroit, câest parce quâen termes dâassembleur il y a pas mal de similitudes entre les deux. Dâailleurs, il se peut parfois quâIDA confonde une structure avec un tableau.
Par ailleurs, on remarque quâil y a toujours un respect de lâalignement lâagencement en mĂ©moire de la structure. Câest pourquoi il est important de faire attention Ă la maniĂšre dont on dĂ©clare une structure si on souhaite Ă©conomiser de la mĂ©moire en tant que dĂ©veloppeur.
Voici un exemple pour illustrer ces propos oĂč deux structures avec les mĂȘmes Ă©lĂ©ments sont utilisĂ©es mais sont lâagencement des Ă©lĂ©ments (et donc en mĂ©moire) est diffĂ©rent :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
struct MaStructure
{
int nb; // 4 octets
char ch; // 1 octet
unsigned int u_nb; // 4 octets
unsigned char u_ch; // 1 octet
};
struct MaStructure_bis
{
int nb; // 4 octets
unsigned int u_nb; // 4 octets
unsigned char u_ch;// 1 octet
char ch; // 1 octet
};
int main()
{
struct MaStructure ma_struct;
ma_struct.nb = 0xdeadbeef;
ma_struct.ch = 'a';
ma_struct.u_nb = 0xcafebabe;
ma_struct.u_ch = 'b';
struct MaStructure_bis ma_struct_bis;
ma_struct_bis.nb = 0xdeadbeef;
ma_struct_bis.ch = 'a';
ma_struct_bis.u_nb = 0xcafebabe;
ma_struct_bis.u_ch = 'b';
return 0;
}
En compilant le code, on sâaperçoit que ces deux structures sont agencĂ©es diffĂ©remment en mĂ©moire :
Comme vous pouvez le constater dans lâexemple prĂ©cĂ©dent, ce nâest pas lâinitialisation des variables qui compte mais leur ordre dans la dĂ©claration de la structure et de ses Ă©lĂ©ments.
On aurait mĂȘme pu ajouter 2 variables char dans ma_struct_bis, le rĂ©sultat en mĂ©moire aurait toujours Ă©tĂ© plus compact quâavec ma_struct.
Les pointeurs
Câest une notion qui gĂ©nĂ©ralement est compliquĂ©e Ă apprĂ©hender lorsque lâon commence le C. En reverse câest plus simple car on voit directement comment fonctionne un pointeur en mĂ©moire : il sâagit dâune adresse qui pointe vers des donnĂ©es situĂ©es quelque part en mĂ©moire.
Contrairement aux autre types de donnĂ©es, un pointeur a toujours la mĂȘme taille :
- 32 bits (en x86)
- ou 64 bits (en x86_64, généralement en user land seuls 48 bits suffisent)
Généralement on les reconnaßt assez facilement car leurs octets de poids fort identifient une base (ou début de zone mémoire) en particulier, par exemple :
- les adresses
0x400010,0x41a010et0x40ff1fcorrespondent Ă des pointeurs vers une zone mĂ©moire du programme mappĂ© en mĂ©moire ( cela peut ĂȘtre la partiedata,codeâŠ) dont la base est `0x400000. - les adresses
0x7ffdd050,0x7ffdddd0ou0x7ffdf004correspondent Ă des adresses basses, qui pointent notamment vers la pile dont lâadresse de base ici est0x7ffdd000
Selon lâOS et la version du programme (32/64 bits), les adresses de base de la pile, du code, des donnĂ©es etc. ne sont pas les mĂȘmes.
Dâautant plus que les programmes sont dĂ©sormais soumis Ă lâASLR qui tend Ă rendre alĂ©atoire certains octets (de poids fort) dâune adresse dâune exĂ©cution Ă une autre.
Concernant leur agencement en mĂ©moire, il nây a pas de soucis en particulier car que ce soit 4 octets ou 8 octets, les pointeurs seront alignĂ©s avec le reste des donnĂ©es.
Quid des chaĂźnes de caractĂšres ?
Il existe plusieurs maniÚres de déclarer des chaßnes de caractÚres en C qui, au final, reviennent toutes à deux formes :
- un tableau de caractĂšres.
- Exemple :
char chaine[] = {'H', 'e', 'l', 'l', 'o', '\0'};
- Exemple :
- un pointeur vers un tableau de caractĂšres
- Exemple :
char *chaine = malloc(taille_de_string);
- Exemple :
Les portées des variables
Nous avons vu ci-dessus comment sont stockĂ©es les diffĂ©rents types de variables sur la pile lorsquâelles sont dĂ©clarĂ©es de maniĂšre locale, câest-Ă -dire au sein dâune fonction (sans le mot clĂ© static).
Toutefois, ce nâest pas la seule maniĂšre de dĂ©clarer une variable. Il est possible de dĂ©clarer des variables ayant diffĂ©rentes portĂ©es dans le code. Cela implique Ă©galement une zone de stockage diffĂ©rente pour les variables selon leur dĂ©claration et portĂ©e.
Intéressons-nous aux portées suivantes :
- đĄ les variables locales : dĂ©clarĂ©es au sein dâune fonction (sans le mot clĂ©
static) - đą les variables globales : dĂ©clarĂ©es en dehors de toute fonction et ayant une portĂ©e plus globale dans le code
- đą les variables statiques : dĂ©clarĂ©es dans une fonction avec le mot clĂ©
static - đ” les variables dynamiques : elles peuvent ĂȘtre dĂ©clarĂ©es Ă divers endroits mais leur affectation est le rĂ©sultat dâune allocation dynamique (avec
mallocet compagnie ounewen C++) - đŁ les variables constantes : ces variables sont dĂ©clarĂ©es avec le mot clĂ©
const
đĄ Les variables locales
A force de les avoir utilisĂ©es lors des divers exemples, on a pris lâhabitude dâanalyser ce type de variables. Bien que ces variables puissent avoir des types diffĂ©rents, elles ont toute un point commun : elles sont stockĂ©es dans la pile.
Exemple
1
2
3
4
5
int main()
{
int ma_var_locale = 10; // dans la pile
return 0;
}
đą Les variables globales et statiques
Nous allons nous intĂ©resser Ă ces deux maniĂšres de dĂ©clarer une variable en mĂȘme temps car elles sont stockĂ©es de la mĂȘme maniĂšre en mĂ©moire.
Nous allons distinguer deux cas :
- la variable nâest pas initialisĂ©e (ou initialisĂ©e Ă 0) : elle est stockĂ©e dans la section
.bss - la variable est initialisée à une valeur non nulle : elle est stockée dans la section
.data
.bss est .data sont deux sections du segments de donnĂ©es modifiable (RW). Leur point commun est quâelles permettent de stocker des donnĂ©es qui peuvent ĂȘtre modifiĂ©es au cours de lâexĂ©cution.
Leur principale diffĂ©rence est que .bss contient des variables initialisĂ©es Ă 0 lors de lâexĂ©cution du programme tandis que .data contient des variables qui sont initialisĂ©s Ă une valeur non nulle lors de lâexĂ©cution du programme.
Exemple
1
2
3
4
5
6
7
8
9
10
int global_var; // dans .bss
int global_var_2 = 0; // dans .bss
int global_non_nulle = 0x10; // dans .data
int main()
{
static int stat ; // dans .bss
static int stat_non_nulle = 213; // dans .data
return 0;
}
đŁ Les variables constantes
Les variables dĂ©clarĂ©es avec le mot clĂ© const ne doivent pas pouvoir ĂȘtre modifiĂ©es aprĂšs leur dĂ©claration.
Ainsi, elles se retrouveront dans les données en lecture seule, plus précisément dans la section .rodata.
Parfois, lorsque certaines variables ou valeurs sont constantes dans une fonction, le compilateur peut parfois les optimiser en les insérant leur valeur directement dans des instructions.
Par exemple, si je crée un variable
int x = 0x46;qui nâest jamais modifiĂ©e puis que je faisy = y + x;, lâinstruction associĂ©e pourrait alors ĂȘtre :add eax, 0x46.
Exemple
1
2
3
4
5
6
7
8
#include <stdio.h>
int main()
{
const char *message = "Hello !"; // dans .rodata
printf("%s\n", message);
return 0;
}
đ” Les variables dynamiques
Nous lâavons vu prĂ©cĂ©demment : les variables dynamiques sont des variables dont le contenu est allouĂ© dynamiquement avec une fonction dâallocation (malloc, calloc, new âŠ).
Mais concrĂštement, quâest-ce cela implique sur ces variables ? Tout dâabord ces variables vont ĂȘtre stockĂ©es dans le tas (ou heap).
Encore une fois, le terme âtasâ nâest pas Ă prendre au sens algorithmique mais plutĂŽt dans le sens oĂč il sâagit dâune zone mĂ©moire qui regroupe un tas de variables.
Je vous propose dâanalyser un petit exemple pour comprendre de quoi il sâagit :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *falestine = malloc(20); // Alloue de l'espace pour 20 caractĂšres
if (falestine == NULL ) {
printf("Allocation de mémoire échouée.\n");
return -1;
}
strcpy(falestine, "Toujours la !");
free(falestine); // ;)
return 0;
}
Comme son nom lâindique, les variables dynamiques sont ⊠dynamiques ! (merci Sherlock đ”ïžââïž). Ainsi nous nâallons pas pouvoir voir oĂč elles sont stockĂ©es via une analyse statique.
Comme pour la pile, le tas nâest mappĂ© en mĂ©moire que lors de lâexĂ©cution du programme.
Bah on fait comment ?
Je sais, je sais, je ne vous ai pas encore dit ni expliquĂ© comment utiliser un debugger mais ça arrive đ ! En attendant, je vais vous montrer ce qui se passe lorsque lâon dĂ©bogue le programme.
AprĂšs compilation, lorsque lâon exĂ©cute le programme pas Ă pas jusquâĂ arriver Ă lâappel de free : call free on obtient ceci :
Dans le code, lâappel Ă©tait le suivant free(falestine);. Lâargument de free est donc ce qui doit ĂȘtre libĂ©rĂ© ⊠lâadresse de notre string. En lâoccurrence il sâagit de lâadresse 0x5655a1a0.
Dans un debugger, on peut lister les diffĂ©rents segments du processus en cours dâexĂ©cution :
On constate quâeffectivement, lâadresse 0x5655a1a0 appartient Ă la heap et non aux autres segments mĂ©moire.
Il y a tellement Ă dire concernant le tas, notamment du fait que les donnĂ©es soient stockĂ©es en suivant divers mĂ©canismes et agencements (mĂ©tadonnĂ©es, listes, listes doublement chaĂźnĂ©es âŠ).
En tant que reverser analysant du code, il nây a pas de nĂ©cessitĂ© Ă comprendre en dĂ©tail le fonctionnement de la heap. Cela est cependant trĂšs important lorsque lâon souhaite faire de la recherche de vulnĂ©rabilitĂ©, exploitation de binaires (pwn) âŠ
đ SynthĂšse
Voici une synthÚse de la localisation des variables selon leur déclaration :












