Post

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Ă©) ou jl (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é par 0x41 ?

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 char ne 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,0x41a010 et 0x40ff1f correspondent Ă  des pointeurs vers une zone mĂ©moire du programme mappĂ© en mĂ©moire ( cela peut ĂȘtre la partie data, code 
) dont la base est `0x400000.
  • les adresses 0x7ffdd050,0x7ffdddd0 ou 0x7ffdf004 correspondent Ă  des adresses basses, qui pointent notamment vers la pile dont l’adresse de base ici est 0x7ffdd000

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'};
  • un pointeur vers un tableau de caractĂšres
    • Exemple : char *chaine = malloc(taille_de_string);

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 malloc et compagnie ou new en 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 :

  1. la variable n’est pas initialisĂ©e (ou initialisĂ©e Ă  0) : elle est stockĂ©e dans la section .bss
  2. 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 fais y = 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 :

This post is licensed under CC BY-NC 4.0 by the author.