Assembleur - Notions de base
Salut tout le monde, voici un nouvel article qui va permettre, je pense, d’éclaircir bon nombre de notions que j’ai déjà abordées dans mes articles précédents, et qui permettront également de faciliter la compréhension des articles à venir.
Cet article a un but modeste : Comprendre la sortie d’un disass main
sur un programme relativement simple (Mais si ! vous savez, cette commande dans gdb qui permet de désassembler - i.e. produire le code assembleur - un binaire)
(gdb) set disassembly-flavor intel
(gdb) disass main
Dump of assembler code for function main:
0x080483f2 <+0>: push ebp
0x080483f3 <+1>: mov ebp,esp
0x080483f5 <+3>: sub esp,0x18
0x080483f8 <+6>: mov DWORD PTR [esp+0x4],0x2
0x08048400 <+14>: mov DWORD PTR [esp],0x28
0x08048407 <+21>: call 0x80483dc <add>
0x0804840c <+26>: mov DWORD PTR [ebp-0x4],eax
0x0804840f <+29>: mov eax,DWORD PTR [ebp-0x4]
0x08048412 <+32>: leave
0x08048413 <+33>: ret
End of assembler dump.
(gdb) set disassembly-flavor att
(gdb) disass main
Dump of assembler code for function main:
0x080483f2 <+0>: push %ebp
0x080483f3 <+1>: mov %esp,%ebp
0x080483f5 <+3>: sub $0x18,%esp
0x080483f8 <+6>: movl $0x2,0x4(%esp)
0x08048400 <+14>: movl $0x28,(%esp)
0x08048407 <+21>: call 0x80483dc <add>
0x0804840c <+26>: mov %eax,-0x4(%ebp)
0x0804840f <+29>: mov -0x4(%ebp),%eax
0x08048412 <+32>: leave
0x08048413 <+33>: ret
End of assembler dump.
Mais diantres, que veut dire ce charabia ? Et puis pourquoi la même commande a produit deux résultats différents ? C’est ce que nous allons voir maintenant, ce code n’aura plus de secrets pour vous…;
Syntaxe
Dans un premier temps, nous allons expliquer pourquoi la même commande a produit deux résultats (pas vraiment) différents. C’est tout simplement une question de syntaxe. Il existe deux principales syntaxes pour représenter du langage assembleur x86 : La syntaxe Intel (plutôt retrouvée dans les environnements Windows) et la syntaxe AT&T (retrouvée dans les environnements Unix). Les différences entre ces deux syntaxes sont minimes. Avant de les lister, voyons la structure commune de ces deux syntaxes :
OPERATION [ARG1 [, ARG2]]
L’opération est le nom de l’opération à effectuer. Les opérations prennent 0, 1 ou 2 arguments.
Pour supprimer toutes ambiguïtés entre les deux syntaxes, voici les différences :
Ordre des paramètres
Lorsqu’une opération prend deux paramètres et que l’opération n’est pas commutative (i.e. a OP b et b OP a ne donnent pas le même résultat), il est important de connaître l’ordre de ces paramètres. Si nous voulions par exemple copier le nombre 42 dans le registre EAX
, voici les deux syntaxes que nous retrouverions :
Intel :
OPERATION DESTINATION, SOURCE
Exemple :
mov eax, 42
AT&T :
OPERATION SOURCE, DESTINATION
Exemple :
mov $42, %eax
Taille des paramètres
Intel :
Comme la taille des paramètres ne doit être indiquée que pour les paramètres non immédiats (non constant, donc avec une taille inconnue) c’est à dire les registres, elle est tout simplement intégrée au nom du registre :
RAX
, EAX
, AX
, AL
impliquent respectivement qword (64 bits), long (double word, 32 bits), word (16 bits) et byte (octet 8 bits).
AT&T :
Les noms des opérations sont suffixés avec une lettre correspondant à la taille des paramètres manipulés.
q
, l
, w
et b
(comme vus pour la syntaxe Intel)
movl $42, %eax
42 sera copié dans EAX
, sur une taille de 32 bits (l’espace non occupé sera mis à zéro)
Préfixe de variable
Intel :
Les variables ne sont pas préfixées comme nous avons pu le voir :
mov eax, 42
AT&T :
En revanche, en ce qui concerne la syntaxe AT&T, nous trouvons un $
devant les valeurs immédiates (i.e. les constantes) et un %
devant les registres, comme dans cet exemple :
movl $42, %eax
Adresse effective
Lorsqu’on parle de variables en mémoire, l’adresse effective représente l’adresse de la case mémoire où est stockée la variable. En assembleur x86, nous avons différents éléments pour définir une adresse mémoire
- base : Registre de 32 bits (contenant le plus souvent une adresse)
- index (Optionnel) : Registre de 32 bits (contenant le plus souvent une adresse)
- scale (Optionnel) : Facteur valant 1, 2, 4 ou 8 multipliant index
- disp (Optionnel) : Déplacement (displacement), ajouté ou déduit à la fin du calcul
- segreg (Optionnel) : Segment mémoire (Segment Register) indiquant le segment dans lequel se trouve la donnée
Intel :
segreg:[base+index*scale+disp]
Le calcul est effectué, puis les crochets indiquent que le résultat est une adresse mémoire (l’adresse effective), comme dans cet exemple :
mov eax, [ ebx + ecx*2 + 0x80848c48 ]
Dans cet exemple, le double du contenu de ECX
est ajouté au contenu de EBX
, auquel on ajoute l’offset indiquée (ici 0x8084c48
), ce qui nous donne une nouvelle adresse. La valeur contenue à cette adresse est assignée à EAX
.
Prenons un cas plus simple, pour être certains de ne pas nous emmêler les pinceaux. Soit :
ebx = 0x80000000
ecx = 0x00000002
Si on trouve l’instruction
mov eax, [ ebx + ecx*2 + 0x0000000a]
Alors le contenu des crochets se décompose de la manière suivante
ebx + 2*ecx = 0x80000004
Puis on ajoute l’offset
0x80000004 + 0x0000000a = 0x8000000e
Ensuite, on cherche ce qu’il y a en mémoire à l’adresse 0x8000000e
, et ce qu’on y trouve, on le met dans EAX
.
AT&T :
La syntaxe est particulière et assez peu intuitive comparée à celle d’Intel. Sa forme générique est
%segreg:disp(base,index,scale)
Comme dans l’exemple suivant :
movl 0x80848c48(%ebx,%ecx,4), %eax
Exemple qui a le même comportement que celui donné pour Intel.
Voilà la fin d’un rapide résumé des différences entre les deux syntaxes les plus retrouvées. Dans l’ensemble de mes articles, j’utilise la syntaxe Intel, qui, bien qu’elle soit connotée Windows, me semble beaucoup plus claire donc adaptée à ces articles.
Nous allons voir maintenant les instructions les plus rencontrées lorsque l’on désassemble un programme. Cette liste est loin d’être exhaustive, mais elle permettra de s’y retrouver dans la majorité des exemples que j’ai donnés ou que je fournirai plus tard.
Instructions communes
Opérations mathématiques
SUB
Permet de soustraire une valeur à une autre
sub eax, 42
eax = eax - 42
ADD
Permet d’additionner deux valeurs
add eax, 42
eax = eax + 42
Opérations logiques
AND
Effectue un ET logique
AND 0x5, 0x3
5 est représenté en binaire par 101
et 3 par 011
donc un ET logique donne 001 = 0x1
. Ce code n’est pas utile, puisque le résultat n’est sauvé nulle part, on fera cette opération avec au moins un des deux paramètre qui est un registre.
XOR
Effectue un XOR logique. Souvent utilisé pour initialiser une variable à 0 via XOR var, var
XOR eax, eax
Ce code est très souvent retrouvé pour initialiser le registre eax à zéro, puisqu’un xor ne donne 1 que si les bits sont différents.
Assignations
MOV
Assigne une valeur à une variable
mov eax, 0x00000042
EAX
va contenir la valeur 0x00000042
LEA
Assigne l’adresse d’une variable à une variable. LEA
a une particularité, c’est que le deuxième argument est entre crochets, mais contrairement à d’habitude, cela ne veut pas dire qu’il sera déréférencé (c’est à dire que ça ne signifie pas que le résultat sera la variable située à l’adresse entre crochets).
LEA eax, [ebp - 0xc]
Si EBP
avait pour valeur 0xbffff484
, alors ebp - 0xc
a pour valeur 0xbffff478
, et c’est bien cette adresse (et non la valeur contenue à cette adresse) qui sera stockée dans EAX
.
Manipulation de la pile
PUSH
Pousse l’argument passé à PUSH
au sommet de la pile
PUSH ebp
La valeur contenue dans EBP
est mise sur le dessus de la pile
POP
Retire l’élément au sommet de la pile, et l’assigne à la valeur passée en argument. (Si nous voulons être plus exacts, l’élément au sommet de la pile reste là où il est, et le registre ESP
qui pointe sur le sommet de la pile est mis à jour en pointant vers la valeur précédente sur la pile)
POP ebp
L’élément qui était au sommet de la pile est assigné à EBP
, et est retiré de la pile
Tests
CMP
Compare les deux valeurs passées en argument
CMP ecx, 0x10
Pour comparer ces deux éléments, une soustraction signée ecx - 0x10
est effectuée
TEST EAX, EAX
Cette opération est logiquement équivalente à
cmp eax, 0
Donc ce test permet de savoir si eax est positif ou non. Cependant, CMP
effectue une soustraction, ce qui est plus lent que TEST
qui effectue un AND
. Mais le résultat est le même.
Jumps
Il existe de nombreuses instruction qui sautent à un autre endroit du code. Une instruction qui saute quelque soit la condition, et d’autres qui dépendent du résultat d’un test précédemment effectué. Sans condition, nous avons l’instruction
JMP
JMP 0x80844264
qui va sauter à l’instruction située à l’adresse indiquée, quoiqu’il arrive.
Cependant, il existe de multiple sauts conditionnels. Nous n’allons pas tous les voir en détails ici, seulement ceux que nous retrouvons le plus. Ils seront présentés par paire, la condition et sa négation, représentée par un N (Not)
JE - JNE
Egal (Equal) - différent (Non Equal)
JZ - JNZ
Nul (Zero) - Non null (Non Zero)
JA/JB - JNA/JNB (Non signé)
Supérieur strictement (Above)/Inférieur strictement (Below) - Inférieur ou égal/Supérieur ou égal
JAE/JBE - JNAE/JNBE
Supérieur ou égal (Above or Equal)/Inférieur ou égal (Below or Equal) - Strictement inférieur/Strictement supérieur
JG/JL (Signé)
Supérieur (Greater)/ Inférieur (Lower)
Fonctions
CALL adresse
L’instruction call
permet de faire appel au code d’une autre fonction située à un espace mémoire différent. L’adresse qui lui est passée en argument permet de trouver ce code. Cet appel est en fait un condensé de deux instructions. La première permet de sauvegarder l’instruction qui suit le call (pour le retour de la fonction, afin de reprendre le fil d’exécution du programme) et la deuxième permet d’effectivement sauter à la fonction recherchée. Comme nous l’avons vu dans un article précédent sur le fonctionnement de la pile, le registre qui contient l’instruction suivante est EIP
. Un call est donc finalement la suite de ces deux instructions :
PUSH EIP
JMP adresse
LEAVE
A l’inverse LEAVE
permet de préparer la sortie d’une fonction en récupérant les variables enregistrées lors du début de la fonction afin de retrouver le contexte d’exécution tel qu’il avait été enregistré juste avant d’exécuter le code de la fonction, tout détruisant ce qu’il restait du stackframe :
MOV ESP, EBP
POP EBP
RET
Enfin, l’instruction RET
permet de finaliser le travail de LEAVE
en récupérant l’adresse de l’instruction à exécuter après le call, adresse qui avait été enregistrée sur la pile lors de l’instruction CALL
, et de sauter à cette adresse
POP EIP
EIP
a été modifiée et c’est l’instruction qui se situe à l’adresse contenue dans EIP
qui sera ensuite traitée.
Misc
Pour finir, une instruction qui peut paraître anodine comme ça, mais qui a sont importance certaine : L’instruction NOP
(No OPeration). Cette instruction … ne fait rien. Si le processeur tombe sur cette instruction, il va tout simplement ne rien faire, et passer à l’instruction suivante.
Voilà, vous avez tous les éléments en main pour comprendre le programme désassemblé fourni au début de l’article. Y arriverez-vous ?
Comme je suis de bonne humeur et que je n’aime pas faire les choses à moitié, nous allons le faire ensemble ! Retroussez vos manches, c’est parti !
Mise en pratique
Rappelons le code du début de l’article, et ne prenons que la version dans la syntaxe Intel.
(gdb) disass main
Dump of assembler code for function main:
0x080483f2 <+0>: push ebp
0x080483f3 <+1>: mov ebp,esp
0x080483f5 <+3>: sub esp,0x18
0x080483f8 <+6>: mov DWORD PTR [esp+0x4],0x2
0x08048400 <+14>: mov DWORD PTR [esp],0x28
0x08048407 <+21>: call 0x80483dc <add>
0x0804840c <+26>: mov DWORD PTR [ebp-0x4],eax
0x0804840f <+29>: mov eax,DWORD PTR [ebp-0x4]
0x08048412 <+32>: leave
0x08048413 <+33>: ret
End of assembler dump.
Pour que vous puissiez suivre, je ferai référence aux lignes telles qu’indiquées entre chevrons dans le code désassemblé. Par exemple, la ligne +3
correspond à la ligne 0x080483f5 <+3>: sub esp,0x18
donc à l’instruction sub esp, 0x18
Allons-y ! Nous avons donc le code assembleur de la fonction main
d’un programme que nous ne connaissons pas. La fonction main
est une fonction comme une autre du point de vue du processeur, il convient donc, comme n’importe quelle fonction, de commencer par les 3 premières lignes typiques d’un début de fonction (parfois un peu plus, mais le principe reste le même), qu’on appelle le prologue. Ces lignes permettent en sommes de sauvegarder l’état de la fonction précédente, et de préparer la pile pour les variables locales de la fonction courante.
La ligne +0
push ebp
permet de pousser le registre EBP
sur la pile. Pour rappel, EBP
(Base Pointer) est le registre qui contient l’adresse du début du stackframe de la fonction courante. Comme nous entrons dans une fonction, il faut sauvegarder le début du stackframe de la fonction précédente, ce que fait cette ligne +0
. Une fois ceci fait, il faut maintenant donner la valeur de notre nouvelle base de stackframe à EBP
. Comme nous entrons à peine dans la fonction, nous n’avons encore rien empilé qui soit propre à la fonction, donc le sommet de la pile actuel correspond à la base du futur stackframe de la fonction main. Et où est contenue l’adresse du sommet de la pile ? Vous vous en souvenez, dans ESP
(Stack Pointer ! Si ça vous est inconnu, je vous invite à relire l’article sur le fonctionnement de la pile). La ligne +1
enregistre alors le contenu de ESP
dans EBP
mov ebp,esp
Voilà, notre registre EBP
est prêt, il pointe sur le début du stackframe de la fonction main
. Que fait la ligne suivante, ligne +3 ?
sub esp,0x18
Tout juste, elle soustrait 0x18
au registre ESP
. 0x18
en hexadécimal, ça fait 1x16 + 8x1 = 24
en décimal. Rappelons que la pile grossit vers le bas pour les processeurs x86, cela veut dire que plus elle grossit, plus l’adresse du sommet de pile diminue. En soustrayant 24 de ESP
, cela veut dire qu’on a fait grossir la pile de 24 octets. 24 octets sont alors alloués à la fonction main
pour ses variables locales.
Voilà, nous avons le registre EBP
qui pointe sur le début du stackframe, le registre ESP
qui pointe sur le sommet de la pile, 24 octets plus loin.
Les deux lignes suivantes sont relativement similaires :
0x080483f8 <+6>: mov DWORD PTR [esp+0x4],0x2
0x08048400 <+14>: mov DWORD PTR [esp],0x28
Ce sont deux instructions MOV
, mais un peu plus compliquées que ce que nous avons vu jusque là. La première des deux lignes +6
met la valeur 0x2
dans DWORD PTR [esp+0x4]
. DWORD
signifie que 0x2
va prendre la place d’un double word (32 bits). Or 0x2
pouvant être stockée sur un octet, les 3 autres seront initialisé à 0. PTR [esp+0x4]
indique que 0x2
va être stocké à l’adresse esp+0x4
. Rappelons encore que ESP
contient l’adresse du sommet de la pile, donc ESP + 0x4
contient l’adresse du deuxième emplacement de la pile (Une variable étant de la taille d’un DWORD
, donc de 4 octets, sur une architecture 32 bits - parce que oui, 32 bits = 4 octets). La ligne +6
met donc le nombre 2 en deuxième position sur la pile.
Avec ces explications, que fait la ligne +14
?
Elle met la valeur 0x28
(40 en décimal) à l’adresse contenue dans ESP
, donc 0x28
est placé au sommet de la pile. Voici où nous en sommes :
Mais pourquoi donc placer ces valeurs arbitrairement comme ça ? Pourquoi sur la pile ? Quelle utilité ? Regardons la ligne suivante :
call 0x80483dc <add>
Une instruction CALL
! Elle fait appel à la fonction située à l’adresse 0x80483dc
, et gdb nous a même retrouvé le nom de cette fonction, qui s’appelle add()
. Fort bien, nous allons pouvoir désassembler add
pour voir de quoi il en retourne !
(gdb) disass add
Dump of assembler code for function add:
0x080483dc <+0>: push ebp
0x080483dd <+1>: mov ebp,esp
0x080483df <+3>: sub esp,0x10
0x080483e2 <+6>: mov eax,DWORD PTR [ebp+0xc]
0x080483e5 <+9>: mov edx,DWORD PTR [ebp+0x8]
0x080483e8 <+12>: add eax,edx
0x080483ea <+14>: mov DWORD PTR [ebp-0x4],eax
0x080483ed <+17>: mov eax,DWORD PTR [ebp-0x4]
0x080483f0 <+20>: leave
0x080483f1 <+21>: ret
End of assembler dump.
Nous retrouvons le même schéma sur les trois premières lignes que celui de la fonction main()
, le prologue de la fonction qui sauvegarde EBP
de la fonction précédente (la fonction main
), puis assigne ESP
à EBP
pour initialiser le début de la stackframe, et enfin qui décale le sommet de la pile de 16 octets pour que la fonction add
puisse travailler avec ses variables locales.
Ensuite les lignes +6
et +9
sont similaires
0x080483e2 <+6>: mov eax,DWORD PTR [ebp+0xc]
0x080483e5 <+9>: mov edx,DWORD PTR [ebp+0x8]
Ce sont deux instructions MOV
qui initialisent eax
et edx
. Si on regarde l’instruction à la ligne +12
, add eax,edx
, on remarque que ces deux registres vont être additionnés.
Par ailleurs, le nom de la fonction étant add
, il y a fort à parier que le but de cette fonction est d’additionner deux nombres. Bref, revenons-en à nos deux lignes : Nous avons déjà vu la syntaxe DWORD PTR [ebp + 0xc]
dans la fonction main
. Cela signifie que nous allons chercher à l’adresse EBP + 0xc
, et nous allons prendre le DWORD
(32 bits) qui se situe là bas. Qu’y a-t-il à EBP + 0xc
? Un petit schéma de l’état de la pile s’impose
Avant l’appel de la fonction, les deux variables 0x2
et 0x28
ont été poussées sur la pile. Ensuite EIP
a été poussé pendant le call
et enfin EBP
, ce qui explique le schéma précédent. Je vous rappelle que la pile part des adresses hautes et grandit en direction des adresses basses, mais qu’une variable en mémoire est lue dans le sens classique, donc des adresses basses vers les adresses hautes. La variable située à l’adresse EBP + 0xc
a une taille de 4 octets. Ces 4 octets sont EBP + 0xc + 0x0
, EBP + 0xc + 0x1
, EBP + 0xc + 0x2
et EBP + 0xc + 0x3
.
Dans le schéma précédent, à EBP
on trouve la valeur de la sauvegarde du EBP
de la fonction appelante. Puis à EBP + 0x4
se trouve la sauvegarde de EIP, à EBP + 0x8
se trouve une des valeurs poussées avant le call
et à EBP + 0xc
se trouve la deuxième valeur. On monte comme ça de 4 en 4 car ces variables sont des adresses (EBP et EIP) ou des entiers donc ils prennent 4 octets en mémoire.
EAX va donc valloir 0x2
et EDX
va recevoir la valeur 0x28
. Nous avons vu que la ligne suivante +12
additionnait les deux valeurs et enregistrait le résultat dans EAX
add eax,edx
Les deux lignes qui suivent sont un petit peu plus complexes à comprendre
0x080483ea <+14>: mov DWORD PTR [ebp-0x4],eax
0x080483ed <+17>: mov eax,DWORD PTR [ebp-0x4]
La première ligne +14
permet de sauvegarder le résultat du calcul en case ebp-0x4
, première case libre de la stackframe. La seconde permet de récupérer cette valeur, et la met dans EAX
. Conventionnellement, EAX
est le registre utilisé pour enregistrer le résultat d’une fonction que l’on veut retourner (return something;
).
Les deux dernières lignes +20
et +21
permettent de retrouver l’état des registres avant d’exécuter la fonction.
leave
ret
L’instruction LEAVE
est en fait un condensé des deux opérations suivantes, comme nous l’avons vu au début de cet article :
MOV ESP, EBP
POP EBP
La première permet de rebaser le sommet de la pile au niveau de EBP
, donc ça supprime tout le reste de la pile, et la deuxième permet de récupérer l’ancienne valeur de EBP
pour pouvoir retourner à la fonction main
. Pour cela, la fonction RET
, équivalente à l’opération suivante :
POP EIP
permet de récupérer la valeur de EIP
sauvegardée lors du call
, et saute à cette instruction pour continuer la suite du programme :
0x0804840c <+26>: mov DWORD PTR [ebp-0x4],eax
0x0804840f <+29>: mov eax,DWORD PTR [ebp-0x4]
Nous avons vu précédemment que le résultat de add
était retourné dans EAX
. Ce résultat est sauvegardé dans la première case de la stackframe, puis est à nouveau assignée à EAX
exactement comme la fin de la fonction add
. Encore une fois, cela signifie que c’est la valeur de retour de la fonction main.
Nous quittons ensuite la fonction main
comme nous avons quitté la fonction add
:
0x080483f0 <+20>: leave
0x080483f1 <+21>: ret
Parfait ! Nous avons tout vu !
Avez-vous deviné le code C du programme après cette étude ? Deux nombres 0x2
(2) et 0x28
(40) sont envoyés à la fonction add
, qui retourne leur somme, que retourne également la fonction main
:
##include <stdio.h>
int add(int a, int b)
{
int result = a + b;
return result;
}
int main(int argc)
{
int answer;
answer = add(40, 2);
return answer;
}
Vous aviez la même chose ? Félicitations ! J’espère que cet article vous aura été utile. Si des notions ou des paragraphes ont besoin d’être clarifiés, n’hésitez pas à poster des commentaires, je suis ouvert à toutes propositions !