Welcome here! | Articles | Main projects | About me
(FR) Etudions l’assembleur avec GDB !
Introduction
Au cours de ce bref article, nous allons nous amuser à décortiquer le code assembleur d’une application très basique. Le petit programme, très simple, demande un mot de passe et affiche un résultat en conséquence. Pour ce faire, nous allons compiler un code C et étudier ce qu’il se passe “sous le capot”. Nous utiliserons un processeur de la famille Intel, qui correspond à ma machine.
Note importante
Pour appréhender cet article dans les meilleures conditions possibles, il est conseillé d’avoir des bases en C et en assembleur. Connaître GDB n’est pas indispensable, mais cela reste recommandé.
Sommaire
Exemple de programme à désassembler
Dans le cadre de ce petit exercice, voici le code que nous allons compiler :
// pass.c
#include <string.h> // strcmp
#include <stdio.h> // printf, scanf
#define INPUT_SIZE 50
int main() {
// on demande un mot de passe à l'utilisateur
char input[INPUT_SIZE];
printf("Password?\n");
scanf("%s", input);
// on teste le mot de passe entré
if (! strcmp(input, "PASSWORD")) {
printf("Granted");
} else {
printf("Denied");
}
}
Une fois compilé, le programme demande un mot de passe et affiche “Granted” si le mot de passe entré est valide, et “Denied” dans les autres cas.
Pour le compiler, nous utiliserons GCC : gcc -g pass.c
.
Tentons à présent d’étudier le code assembleur correspondant. En bonus, amusons-nous à regarder comment, par hasard, lui faire afficher “Granted” même sans connaître le mot de passe attendu !
Note : Si vous voulez le code source complet dans un fichier, vous pouvez compiler avec l’opion -S
: gcc -S -masm=intel pass.c
. Le code assembleur correspondant à pass.c
sera écrit dans le fichier pass.s
.
En avant avec GDB !
Voyons avec GDB ce à quoi ressemble l’assembleur généré :
$ gdb -q a.out
Reading symbols from a.out...done.
(gdb) disass main
Dump of assembler code for function main:
0x0000000000401550 <+0>: push rbp
0x0000000000401551 <+1>: mov rbp,rsp
0x0000000000401554 <+4>: sub rsp,0x60
0x0000000000401558 <+8>: call 0x401670 <__main>
0x000000000040155d <+13>: lea rcx,[rip+0x2a9c] # 0x404000
0x0000000000401564 <+20>: call 0x402ab0 <puts>
0x0000000000401569 <+25>: lea rax,[rbp-0x40]
0x000000000040156d <+29>: mov rdx,rax
0x0000000000401570 <+32>: lea rcx,[rip+0x2a93] # 0x40400a
0x0000000000401577 <+39>: call 0x402aa8 <scanf>
0x000000000040157c <+44>: lea rax,[rbp-0x40]
0x0000000000401580 <+48>: lea rdx,[rip+0x2a86] # 0x40400d
0x0000000000401587 <+55>: mov rcx,rax
0x000000000040158a <+58>: call 0x402a98 <strcmp>
0x000000000040158f <+63>: test eax,eax
0x0000000000401591 <+65>: jne 0x4015a1 <main+81>
0x0000000000401593 <+67>: lea rcx,[rip+0x2a7c] # 0x404016
0x000000000040159a <+74>: call 0x402ab8 <printf>
0x000000000040159f <+79>: jmp 0x4015ad <main+93>
0x00000000004015a1 <+81>: lea rcx,[rip+0x2a76] # 0x40401e
0x00000000004015a8 <+88>: call 0x402ab8 <printf>
0x00000000004015ad <+93>: mov eax,0x0
0x00000000004015b2 <+98>: add rsp,0x60
0x00000000004015b6 <+102>: pop rbp
0x00000000004015b7 <+103>: ret
End of assembler dump.
Note : Cet output correspond à ce qui a été compilé sur ma machine, qui tourne sous Windows. Pour voir l’équivalent sur Linux, c’est par ici !
Mieux encore, en passant l’option /s
à disass
, on peut afficher le code C correspondant !
(gdb) disass /s main
Dump of assembler code for function main:
pass.c:
8 int main() {
0x0000000000401550 <+0>: push rbp
0x0000000000401551 <+1>: mov rbp,rsp
0x0000000000401554 <+4>: sub rsp,0x60
0x0000000000401558 <+8>: call 0x401670 <__main>
10 char input[INPUT_SIZE];
11 printf("Password?\n");
0x000000000040155d <+13>: lea rcx,[rip+0x2a9c] # 0x404000
0x0000000000401564 <+20>: call 0x402ab0 <puts>
12 scanf("%s", input);
0x0000000000401569 <+25>: lea rax,[rbp-0x40]
0x000000000040156d <+29>: mov rdx,rax
0x0000000000401570 <+32>: lea rcx,[rip+0x2a93] # 0x40400a
0x0000000000401577 <+39>: call 0x402aa8 <scanf>
15 if (! strcmp(input, "PASSWORD")) {
0x000000000040157c <+44>: lea rax,[rbp-0x40]
0x0000000000401580 <+48>: lea rdx,[rip+0x2a86] # 0x40400d
0x0000000000401587 <+55>: mov rcx,rax
0x000000000040158a <+58>: call 0x402a98 <strcmp>
0x000000000040158f <+63>: test eax,eax
0x0000000000401591 <+65>: jne 0x4015a1 <main+81>
16 printf("Granted");
0x0000000000401593 <+67>: lea rcx,[rip+0x2a7c] # 0x404016
0x000000000040159a <+74>: call 0x402ab8 <printf>
0x000000000040159f <+79>: jmp 0x4015ad <main+93>
17 } else {
18 printf("Denied");
0x00000000004015a1 <+81>: lea rcx,[rip+0x2a76] # 0x40401e
0x00000000004015a8 <+88>: call 0x402ab8 <printf>
0x00000000004015ad <+93>: mov eax,0x0
19 }
20 }
0x00000000004015b2 <+98>: add rsp,0x60
0x00000000004015b6 <+102>: pop rbp
0x00000000004015b7 <+103>: ret
End of assembler dump.
Note : Sur un système 32 bits, les registres ne seront pas rax
, rbx
, … mais eax
, ebx
et autres. Les registres commençant par r
, tels que rax
sont des registres 64 bits, tandis que ceux commençant par e
(eax
ou autres) sont des registres 32 bits.
Petite parenthèse : je m’attendais à voir passer les arguments aux diverses fonctions (puts
, scanf
, …) par la pile. Néanmoins, il semble que ce code utilise la convention d’appel Microsoft x64, qui utilise certains registres (à commencer par rcx
) plutôt que la pile.
Les trois premières lignes sont ce que l’on appelle le prologue de la fonction. Elles mettent de côté l’adresse actuelle du haut de la pile et allouent un espace d’une taille donnée. En somme, elles servent à mettre en place le contexte d’exécution de la fonction.
push rbp ; on met rbp (base pointer) sur la pile
mov rbp,rsp ; on stocke rsp (stack pointer) dans rbp
sub rsp,0x60 ; on alloue 0x60 (96) octets sur la pile
Les trois dernières, quant à elles, sont l’épilogue de la fonction. Elles restaurent la pile à son état d’origine.
add rsp,0x60 ; on restaure rsp après avoir alloué 0x60 octets sur la pile
pop rbp ; on restaure rbp, qui contenait la valeur de rsp
; une autre option est d'utiliser l'opérande 'leave'
ret ; on retourne de main()
Analyse du code
Le code assembleur de la fonction main
peut donc être segmenté ainsi :
; prologue
0x0000000000401550 <+0>: push rbp
0x0000000000401551 <+1>: mov rbp,rsp
0x0000000000401554 <+4>: sub rsp,0x60
; fonction propre à GCC : nous ne nous attarderons pas dessus
0x0000000000401558 <+8>: call 0x401670 <__main>
; on demande un mot de passe à l'utilisateur
0x000000000040155d <+13>: lea rcx,[rip+0x2a9c] # 0x404000
0x0000000000401564 <+20>: call 0x402ab0 <puts>
0x0000000000401569 <+25>: lea rax,[rbp-0x40]
0x000000000040156d <+29>: mov rdx,rax
0x0000000000401570 <+32>: lea rcx,[rip+0x2a93] # 0x40400a
0x0000000000401577 <+39>: call 0x402aa8 <scanf>
0x000000000040157c <+44>: lea rax,[rbp-0x40]
0x0000000000401580 <+48>: lea rdx,[rip+0x2a86] # 0x40400d
0x0000000000401587 <+55>: mov rcx,rax
; c'est ici qu'on teste le mot de passe entré !
0x000000000040158a <+58>: call 0x402a98 <strcmp>
0x000000000040158f <+63>: test eax,eax
0x0000000000401591 <+65>: jne 0x4015a1 <main+81> ; ce saut correspond au 'else'
0x0000000000401593 <+67>: lea rcx,[rip+0x2a7c] # 0x404016
0x000000000040159a <+74>: call 0x402ab8 <printf> ; ici, nous sommes dans le 'if'
0x000000000040159f <+79>: jmp 0x4015ad <main+93> ; on sort du bloc 'if/else'
0x00000000004015a1 <+81>: lea rcx,[rip+0x2a76] # 0x40401e
0x00000000004015a8 <+88>: call 0x402ab8 <printf> ; on affiche "Denied"
0x00000000004015ad <+93>: mov eax,0x0 ; on assigne 0 à eax
; épilogue
0x00000000004015b2 <+98>: add rsp,0x60
0x00000000004015b6 <+102>: pop rbp
0x00000000004015b7 <+103>: ret ; on retourne la valeur de eax
À quoi correspond l’adresse 0x404000
et les adresses voisines ? Regardons ça : objdump -M intel -D --no-show-raw-insn a.out | grep 404000
.
Parmi les résultats, nous voyons ceci :
0000000000404000 <.rdata>:
Or, .rdata
équivaut grossièrement à la version en lecture seule du segment .data
. Il s’agit donc des données constantes du programme. Nous pouvons par conséquent nous attendre à y trouver nos textes “Granted” et “Denied” ! Il y a même fort à parier que le texte “Granted” se trouve à l’adresse 0x404016
et que “Denied” se situe à l’adresse 0x40401e
! En effet, on charge ces adresses dans rcx
avant les appels à printf
, et la différence entre leurs adresses est de 8, soit la longueur de Granted\0
, \0
étant le caractère de fin de chaîne. A priori, il est possible d’établir la table de correspondance suivante :
Adresse | Chaîne |
---|---|
0x404000 |
Password? |
0x40400a |
%s |
0x40400d |
PASSWORD |
0x404016 |
Granted |
0x40401e |
Denied |
Il est intéressant de constater que le premier appel à printf
est remplacé par un appel à puts
. Ce dernier rajoutant un saut de ligne (caractère \n
), le compilateur a optimisé l’appel à printf("Password?\n")
en le remplaçant par puts("Password?")
.
Note sur test eax, eax
:
- Si
eax & eax == 0
(soit sieax == 0
), alors le “zero flag” est setté à 1 ; - L’instruction
je
exécute le jump si le “zero flag” vaut 1.jne
fait le test inverse, à savoir si le “zero flag” vaut 0.
Cela équivaut donc à :
if (eax != 0) {
goto <main+81>;
}
Ainsi, pour accéder à la portion de code qui affiche “Granted”, il suffit de remplacer jne
, par exemple par une suite de no-op (nop
) ! Ce faisant, aucun saut ne sera effectué, et toute entrée ne correspondant pas au mot de passe attendu exécutera l’affichage de “Granted” ! Nous entrerons donc dans le corps du if
quoi qu’il arrive, et le code du else
sera toujours ignoré.
Conclusion
Au cours de cet article, nous avons analysé en surface un code assembleur basique. Nous avons même déterminé où se trouve la lecture du mot de passe demandé ! Ainsi, nous sommes théoriquement capables d’ignorer sa vérification ! À l’aide d’outils tels que objdump ou hexdump, il est certainement possible d’aller localiser l’appel à jne
et de le remplacer par une suite de nop
!