Au cours des dernières semaines, j’ai appris l’assembleur x86_64, l’assembleur AArch64, ainsi que diverses bases. Récemment, j’ai enfin vécu ma toute première expérience de rétro-ingénierie. Je suis heureux de partager davantage de détails sur cette expérience.
Qu’est-ce que la rétro-ingénierie
Un ordinateur est en réalité une machine plutôt stupide. Il ne résoudra aucun problème de lui-même ; il ne fait qu’exécuter la solution — plus précisément, il n’exécute que les instructions des êtres humains, une par une, dans l’ordre. Pour résoudre un problème avec une telle machine, nous procédons généralement ainsi :
- analyser un problème ;
- trouver une solution à ce problème ;
- mettre en œuvre la solution en concevant un algorithme ;
- mettre en œuvre l’algorithme en écrivant du code ;
- produire l’exécutable à partir du code en le compilant ;
- fournir cet exécutable à cet ordinateur stupide pour résoudre le problème.
C’est une procédure qui consiste à convertir la solution en code, puis en instructions.
En général, nous pouvons facilement comprendre ce qu’un programme fait en lisant le code. C’est le point intermédiaire entre la solution et les instructions. Dans les années 1970, le code était généralement partagé entre les personnes, afin que chacun puisse étudier, améliorer et partager des solutions au sein de la communauté.
Cependant, les choses ont changé depuis l’apparition de ces monstres du développement de logiciels propriétaires extrêmement malfaisants. Ils choisissent de refuser au peuple l’accès au code, de retirer aux utilisateurs le contrôle de leur informatique, de mettre en œuvre toutes sortes de fonctionnalités malveillantes pour exploiter leurs utilisateurs, et d’utiliser toutes sortes d’approches pour cacher ce que leurs programmes font — la solution. Ils construisent des barrières. Ils détruisent le partage. Ils créent des désastres et du désespoir. Ils font tout contre le peuple, la communauté et l’humanité. Ils font tout cela pour leurs profits immoraux et iniques.
Le logiciel doit servir le peuple, et non les profits immoraux et iniques des capitalistes. Pourtant, ce sont ces développeurs de logiciels propriétaires immoraux et iniques, qui ne devraient jamais exister et qui devraient être bannis du monde, qui rendent le monde pire. Mais nous ne pouvons pas éliminer d’un seul coup les logiciels propriétaires. Nous développons pour cela des remplacements libres, et reprenons progressivement le contrôle de l’informatique.
Il est donc temps d’effectuer la procédure ci-dessus dans l’ordre inverse. En d’autres termes, nous :
- analysons l’exécutable ;
- essayons de récupérer le code qui fait ce que l’exécutable fait ;
- trouvons l’algorithme mis en œuvre par le code ;
- inversons la solution mise en œuvre par l’algorithme ;
- réimplémentons la solution pour résoudre le problème.
C’est la conversion des instructions en code, puis en solution.
La pratique qui consiste à faire cela dans l’ordre inverse s’appelle la rétro-ingénierie, ou RE.
Pourquoi la rétro-ingénierie est importante
La raison la plus importante pour laquelle la rétro-ingénierie est importante est qu’elle nous aide à porter des systèmes d’exploitation libres, tels que postmarketOS et LineageOS, sur divers appareils. C’est aussi la principale raison pour laquelle j’apprends la rétro-ingénierie.
Sur les ordinateurs de bureau, la liberté logicielle est beaucoup plus facile à obtenir, car nous pouvons généralement exécuter toutes sortes de distributions GNU/Linux libres sans problèmes graves. Cependant, sur les appareils mobiles, tels que les smartphones ou les tablettes, ce n’est pas le cas.
Ces appareils ont souvent de nombreux réglages spécifiques au fournisseur et à l’appareil, et il est presque impossible de trouver une solution universelle qui fonctionne partout pour faire tourner un système d’exploitation libre sur ces appareils. Pire encore, les fabricants choisissent de cacher ces détails techniques, et même Google a choisi de ne plus publier les device trees et les vendor blobs. Nous devons donc maintenant analyser les blobs binaires du micrologiciel et en déduire la logique afin de faire fonctionner postmarketOS, LineageOS ou d’autres systèmes d’exploitation libres sur ces appareils.
En réalité, la Free Software Foundation travaille également sur ce problème dans le cadre du projet Librephone. Ce projet ne peut pas se poursuivre sans rétro-ingénierie.
Cependant, l’application de la rétro-ingénierie ne se limite en rien au portage de systèmes d’exploitation libres. Elle est aussi très importante pour :
- comprendre les détails des protocoles propriétaires,
- trouver des preuves de fonctionnalités malveillantes dans des logiciels propriétaires,
- contourner la gestion numérique des restrictions,
- et bien plus encore.
Comment faire de la rétro-ingénierie
Une rétro-ingénierie typique consiste en ces deux étapes :
- Convertir l’exécutable en assembleur ; ce processus s’appelle le désassemblage.
- Essayer de récupérer les idées ou de réécrire un code équivalent en lisant le code assembleur.
Nous pouvons faire cela directement sur l’exécutable sans l’exécuter, ce qu’on appelle l’analyse statique, ou pendant qu’il est en cours d’exécution, ce qu’on appelle l’analyse dynamique.
Exemple d’analyse statique
Prenons cet exemple :
#include <stdio.h>
#include <string.h>
const char *PASS = "weakpasswd";
int main()
{
char buf[64];
for (int i = 0; i < 3; i++)
{
printf("%d essais restants.\nMot de passe : ", 3 - i);
fgets(buf, sizeof buf, stdin);
if (strncmp(PASS, buf, strlen(PASS)))
puts("Mot de passe incorrect !\n");
else
{
puts("Correct !\nBienvenue dans le monde "
"de la rétro-ingénierie !\n");
break;
}
}
return 0;
}
Compilez cela avec GCC :
$ gcc -o ./demo ./demo.c
Ouvrez l’exécutable dans radare2 :
$ r2 ./demo
Vous verrez ceci :
[0x00000800]>
Analysons l’exécutable :
[0x00000800]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
INFO: Analyze imports (af@@@i)
INFO: Analyze entrypoint (af@ entry0)
INFO: Analyze symbols (af@@@s)
INFO: Analyze all functions arguments/locals (afva@@F)
INFO: Analyze function calls (aac)
INFO: Analyze len bytes of instructions for references (aar)
INFO: Finding and parsing C++ vtables (avrr)
INFO: Analyzing methods (af @@ method.*)
INFO: Finding function preludes (aap)
INFO: Emulate functions to find computed references (aaef)
INFO: Recovering local variables (afva@@@F)
INFO: Type matching analysis for all functions (afft)
INFO: Propagate noreturn information (aanr)
INFO: Use -AA or aaaa to perform additional experimental analysis
INFO: Finding xrefs in noncode sections (e anal.in=io.maps.x; aav)
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0
Listez les fonctions :
[0x00000800]> afl
0x00000750 1 16 sym.imp.strlen
0x00000760 1 16 sym.imp.__libc_start_main
0x00000770 1 16 sym.imp.__cxa_finalize
0x00000780 1 32 sym.imp.strncmp
0x000007a0 1 16 sym.imp.abort
0x000007b0 1 16 sym.imp.puts
0x000007c0 1 16 sym.imp.printf
0x000007d0 1 20 sym.imp.fgets
0x00000800 1 48 entry0
0x00000834 3 20 sym.call_weak_fn
0x00000860 4 48 sym.deregister_tm_clones
0x00000890 4 60 sym.register_tm_clones
0x000008cc 5 80 entry.fini0
0x00000920 1 8 entry.init0
0x000009f8 1 24 sym._fini
0x00000928 7 208 main
0x00000708 1 28 sym._init
0x00000730 1 32 fcn.00000730
Nous pouvons maintenant entrer dans la fonction main et afficher le désassemblage :
[0x00000800]> s main
[0x00000928]> pdf
; DATA XREF from entry0 @ 0x820(r)
; DATA XREF from entry.fini0 @ 0x8e0(r)
┌ 208: int main (int argc);
│ `- args(x0) vars(5:sp[0x4..0x70])
│ 0x00000928 fd7bb9a9 stp x29, x30, [sp, -0x70]!
│ 0x0000092c fd030091 mov x29, sp
│ 0x00000930 f30b00f9 str x19, [var_10h]
│ 0x00000934 ff6f00b9 str wzr, [var_6ch] ; argc
│ ┌─< 0x00000938 29000014 b 0x9dc
│ │ ; CODE XREF from main @ 0x9e4(x)
│ ┌──> 0x0000093c 61008052 mov w1, 3
│ ╎│ 0x00000940 e06f40b9 ldr w0, [var_6ch]
│ ╎│ 0x00000944 2000004b sub w0, w1, w0
│ ╎│ 0x00000948 e103002a mov w1, w0
│ ╎│ 0x0000094c 00000090 adrp x0, 0
│ ╎│ 0x00000950 00a02891 add x0, x0, str._d_retries_remaining._nPassword: ; 0xa28 ; "%d retries remaining.\nPassword: " ; const char *format
│ ╎│ 0x00000954 9bffff97 bl sym.imp.printf ; int printf(const char *format)
│ ╎│ 0x00000958 e00000f0 adrp x0, 0x1f000
│ ╎│ 0x0000095c 00e447f9 ldr x0, [x0, 0xfc8] ; [0x1ffc8:4]=0
│ ╎│ ; reloc.stdin
│ ╎│ 0x00000960 010040f9 ldr x1, [x0] ; int size
│ ╎│ 0x00000964 e0a30091 add x0, sp, 0x28 ; char *s
│ ╎│ 0x00000968 e20301aa mov x2, x1 ; FILE *stream
│ ╎│ 0x0000096c 01088052 mov w1, 0x40
│ ╎│ 0x00000970 98ffff97 bl sym.imp.fgets ; char *fgets(char *s, int size, FILE *stream)
│ ╎│ 0x00000974 00010090 adrp x0, reloc.strlen ; 0x20000
│ ╎│ 0x00000978 00600191 add x0, x0, 0x58
│ ╎│ 0x0000097c 130040f9 ldr x19, [x0] ; [0xa18:4]=0x6b616577 ; "weakpasswd"
│ ╎│ 0x00000980 00010090 adrp x0, reloc.strlen ; 0x20000
│ ╎│ 0x00000984 00600191 add x0, x0, 0x58
│ ╎│ 0x00000988 000040f9 ldr x0, [x0] ; [0xa18:4]=0x6b616577 ; "weakpasswd" ; const char *s
│ ╎│ 0x0000098c 71ffff97 bl sym.imp.strlen ; size_t strlen(const char *s)
│ ╎│ 0x00000990 e10300aa mov x1, x0
│ ╎│ 0x00000994 e0a30091 add x0, sp, 0x28
│ ╎│ 0x00000998 e20301aa mov x2, x1 ; size_t n
│ ╎│ 0x0000099c e10300aa mov x1, x0 ; const char *s2
│ ╎│ 0x000009a0 e00313aa mov x0, x19 ; const char *s1
│ ╎│ 0x000009a4 77ffff97 bl sym.imp.strncmp ; int strncmp(const char *s1, const char *s2, size_t n)
│ ╎│ 0x000009a8 1f000071 cmp w0, 0
│ ┌───< 0x000009ac a0000054 b.eq 0x9c0
│ │╎│ 0x000009b0 00000090 adrp x0, 0
│ │╎│ 0x000009b4 00402991 add x0, x0, str.Wrong_password__n ; 0xa50 ; "Mot de passe incorrect !\n" ; const char *s
│ │╎│ 0x000009b8 7effff97 bl sym.imp.puts ; int puts(const char *s)
│ ┌────< 0x000009bc 05000014 b 0x9d0
│ ││╎│ ; CODE XREF from main @ 0x9ac(x)
│ │└───> 0x000009c0 00000090 adrp x0, 0
│ │ ╎│ 0x000009c4 00a02991 add x0, x0, str.Correct__nWelcome_to_the_world_of_reverse_engineering__n ; 0xa68 ; "Correct !\nBienvenue dans le monde de la rétro-ingénierie !\n" ; const char *s
│ │ ╎│ 0x000009c8 7affff97 bl sym.imp.puts ; int puts(const char *s)
│ │┌───< 0x000009cc 07000014 b 0x9e8
│ ││╎│ ; CODE XREF from main @ 0x9bc(x)
│ └────> 0x000009d0 e06f40b9 ldr w0, [var_6ch]
│ │╎│ 0x000009d4 00040011 add w0, w0, 1
│ │╎│ 0x000009d8 e06f00b9 str w0, [var_6ch]
│ │╎│ ; CODE XREF from main @ 0x938(x)
│ │╎└─> 0x000009dc e06f40b9 ldr w0, [var_6ch]
│ │╎ 0x000009e0 1f080071 cmp w0, 2
│ │└──< 0x000009e4 cdfaff54 b.le 0x93c
│ │ ; CODE XREF from main @ 0x9cc(x)
│ └───> 0x000009e8 00008052 mov w0, 0
│ 0x000009ec f30b40f9 ldr x19, [var_10h]
│ 0x000009f0 fd7bc7a8 ldp x29, x30, [sp], 0x70
└ 0x000009f4 c0035fd6 ret
Radare2 ne nous donne pas seulement le code assembleur, mais aussi l’adresse correspondant à chaque instruction et les relations d’appel entre elles.
Cependant, ce n’est pas encore très clair. Affichons donc le désassemblage sous forme de graphe :
[0x00000928]> VV
Vous verrez alors :

Dans le graphe, t signifie saut lorsque la condition est vraie. f signifie saut lorsque la condition est fausse. v signifie saut inconditionnel.
La vie devient alors beaucoup plus facile. Radare2 a déjà compris les relations logiques entre les blocs de code assembleur, et il ne vous reste plus qu’à comprendre ces blocs eux-mêmes.
Dans le graphe, nous pouvons facilement repérer la logique de vérification du mot de passe à la fin du bloc [0x93c] :
; int strncmp(const char *s1, const char *s2, size_t n)
bl sym.imp.strncmp
cmp w0, 0
b.eq 0x9c0
Ici, il appelle sym.imp.strncmp, puis vérifie la valeur renvoyée dans w0 par cette fonction. Si elle est égale à 0, il saute vers le bloc [0x9c0], indiquant une réussite. Sinon, il continue vers le bloc [0x9b0], indiquant un échec.
Selon la convention d’appel AArch64, les pointeurs vers les deux chaînes à comparer se trouvent respectivement dans les registres x0 et x1, puis nous appelons sym.imp.strncmp. Le résultat est alors stocké dans le registre x0 (w0 n’est que la moitié inférieure de x0), et si les deux chaînes sont identiques, nous obtenons 0.
Regardons maintenant en arrière et voyons :
mov x0, x19
Puis remontons encore plus loin pour trouver l’instruction qui agit sur x19 :
; [0xa18:4]=0x6b616577
; "weakpasswd"
ldr x19, [x0]
Radare2 nous a déjà révélé le secret. Essayons :
$ ./demo
3 essais restants.
Mot de passe : weakpasswd
Correct !
Bienvenue dans le monde de la rétro-ingénierie !
Voilà.
Si nous regardons le graphe de plus près, nous pouvons apprendre davantage que le simple mot de passe. Par exemple, nous pouvons voir un cycle dans le graphe : [0x9dc] -> [0x93c] -> [0x9b0] -> [0x9d0] -> [0x9dc]. Dans un graphe, un cycle indique souvent un flot de contrôle en boucle. C’est un état d’esprit très important en rétro-ingénierie.
Dans le bloc [0x9dc] :
ldr w0, [var_6ch]
cmp w0, 2
b.le 0x93c
Il charge la variable var_6ch dans un registre et la compare à 2. Si elle est supérieure, il passe au bloc [0x9e8] et se termine. Sinon, il passe au bloc [0x93c].
Regardez la fin du bloc [0x93c]. Nous savons déjà que si la vérification du mot de passe réussit, il sautera vers le bloc [0x9c0], affichera le message indiquant la réussite, puis sautera vers le bloc [0x9e8], qui termine le programme.
Mais que se passe-t-il si la vérification échoue ? Il passera au bloc [0x9b0]. Celui-ci se contente d’afficher le message d’erreur, donc il n’y a rien d’intéressant ici ; passons au bloc [0x9d0]. Nous y trouvons quelque chose d’intéressant :
ldr w0, [var_6ch]
add w0, w0, 1
str w0, [var_6ch]
Il charge la variable var_6ch dans un registre, incrémente la valeur de un, puis la stocke à nouveau. Ensuite, il revient au bloc [0x9dc], qui vérifie la valeur de var_6ch.
Appuyez deux fois sur q pour revenir à l’invite. Exécutez afv et afvd pour lister les variables et leurs informations :
[0x000009b0]> afv
arg int argc @ x0
var int64_t var_70h @ sp+0x0
var int64_t var_70h_2 @ sp+0x8
var int64_t var_10h @ sp+0x10
var char * s2 @ sp+0x28
var int64_t var_6ch @ sp+0x6c
[0x000009b0]> afvd
arg argc = 0x00000000 0x00010102464c457f .ELF.... @ pstate
var var_6ch = 0x0017806c = (qword)0x0000000000000000
var s2 = 0x00178028 = ""
var var_10h = 0x00178010 = (qword)0x0000000000000000
var var_70h = 0x00178000 = (qword)0x0000000000000000
var var_70h_2 = 0x00178008 = (qword)0x0000000000000000
Nous pouvons voir que la valeur var_6ch est un int64_t, et que sa valeur initiale est 0.
Nous pouvons maintenant en déduire que var_6ch est un compteur. Sa valeur initiale est 0. Si la vérification du mot de passe échoue, le compteur est incrémenté de un. Une fois qu’il dépasse 2, le programme ne demande plus le mot de passe et se termine. Nous pouvons maintenant dire que cela correspond à notre code C for (int i = 0; i < 3; i++) !
Cependant, il s’agit d’un exemple très simple. Dans le monde réel, les choses deviennent plus difficiles, car les développeurs de logiciels propriétaires utilisent souvent des techniques d’anti-analyse et d’anti-débogage.
Comprendre les optimisations du compilateur
Maintenant, compilons ce code encore plus simple :
#include <stdio.h>
int main()
{
int a;
scanf("%d", &a);
printf("%d", a % 65536);
return 0;
}
Il lit un entier depuis l’entrée et affiche son modulo 65536.
Désassemblons sa fonction main :
[0x00000700]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
......
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0
[0x00000700]> s main
[0x00000828]> pdf
; DATA XREF from entry0 @ 0x720(r)
; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 76: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│ 0x00000828 fd7bbea9 stp x29, x30, [sp, -0x20]!
│ 0x0000082c fd030091 mov x29, sp
│ 0x00000830 e0730091 add x0, sp, 0x1c
│ 0x00000834 e10300aa mov x1, x0
│ 0x00000838 00000090 adrp x0, 0
│ 0x0000083c 00602291 add x0, x0, 0x898
│ 0x00000840 94ffff97 bl sym.imp.__isoc23_scanf
│ 0x00000844 e01f40b9 ldr w0, [var_1ch]
│ 0x00000848 e103006b negs w1, w0
│ 0x0000084c 003c0012 and w0, w0, 0xffff
│ 0x00000850 213c0012 and w1, w1, 0xffff
│ 0x00000854 0044815a csneg w0, w0, w1, mi
│ 0x00000858 e103002a mov w1, w0
│ 0x0000085c 00000090 adrp x0, 0
│ 0x00000860 00602291 add x0, x0, 0x898 ; const char *format
│ 0x00000864 97ffff97 bl sym.imp.printf ; int printf(const char *format)
│ 0x00000868 00008052 mov w0, 0
│ 0x0000086c fd7bc2a8 ldp x29, x30, [sp], 0x20
└ 0x00000870 c0035fd6 ret
Vous pourriez être surpris, car vous ne voyez rien au sujet d’une opération modulo (sdiv, mul, sub, etc.) ici. À la place, vous voyez :
and w0, w0, 0xffff
and w1, w1, 0xffff
C’est parce que les compilateurs modernes sont assez intelligents pour détecter certains modèles de calcul particuliers et les optimiser en instructions plus simples. 65536 est 2^16, donc le nombre binaire modulo 65536 correspond simplement à ses 16 bits de poids faible. Une opération bit à bit suffit ; il n’est pas nécessaire d’utiliser des additionneurs ou des multiplicateurs.
Cependant, si nous remplaçons 65536 par autre chose, par exemple 50000, ce n’est plus le cas :
; DATA XREF from entry0 @ 0x720(r)
; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 80: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│ 0x00000828 fd7bbea9 stp x29, x30, [sp, -0x20]!
│ 0x0000082c fd030091 mov x29, sp
│ 0x00000830 e0730091 add x0, sp, 0x1c
│ 0x00000834 e10300aa mov x1, x0
│ 0x00000838 00000090 adrp x0, 0
│ 0x0000083c 00602291 add x0, x0, 0x898
│ 0x00000840 94ffff97 bl sym.imp.__isoc23_scanf
│ 0x00000844 e01f40b9 ldr w0, [var_1ch]
│ 0x00000848 016a9852 mov w1, 0xc350
│ 0x0000084c 020cc11a sdiv w2, w0, w1
│ 0x00000850 016a9852 mov w1, 0xc350
│ 0x00000854 417c011b mul w1, w2, w1
│ 0x00000858 0000014b sub w0, w0, w1
│ 0x0000085c e103002a mov w1, w0
│ 0x00000860 00000090 adrp x0, 0
│ 0x00000864 00602291 add x0, x0, 0x898 ; const char *format
│ 0x00000868 96ffff97 bl sym.imp.printf ; int printf(const char *format)
│ 0x0000086c 00008052 mov w0, 0
│ 0x00000870 fd7bc2a8 ldp x29, x30, [sp], 0x20
└ 0x00000874 c0035fd6 ret
Nous voyons maintenant les instructions sdiv, mul et sub ici, qui sont combinées pour réaliser l’opération modulo.
Et l’analyse dynamique ?
Pour l’analyse dynamique, vous aurez besoin de GDB. Avec GDB, vous pouvez afficher le désassemblage, définir des points d’arrêt, inspecter la mémoire et les registres, et même modifier les valeurs des registres et des variables pendant que le programme s’exécute.
Je n’ai pas encore exploré l’analyse dynamique en profondeur. Je pourrais écrire un nouvel article de blog pour l’expliquer à l’avenir.
Ressources recommandées
Ce que j’ai expliqué ici n’est que la partie émergée de l’iceberg. Pour apprendre la rétro-ingénierie, il faut aller plus loin, pratiquer davantage, et apprendre en faisant. Voici une liste de ressources recommandées :
- Pwn.college est un bon endroit pour commencer. Vous pouvez apprendre l’assembleur x86_64 dans leur dojo de départ, puis passer à la partie Rétro-ingénierie du module Intro to Cybersecurity.
- Reverse Engineering for Beginners est un très bon livre sur la rétro-ingénierie. Il est libre (au sens de la liberté), sous licence CC BY-SA 4.0, et disponible en téléchargement ici.
- ARM propose une documentation officielle pour son architecture AArch64.
- Ici vous pouvez télécharger des crackmes pour pratiquer vos compétences en rétro-ingénierie.
- Voici un dépôt GitHub contenant de nombreuses ressources pour apprendre la rétro-ingénierie.
Avertissement
Cet article est à des fins éducatives uniquement. Veuillez consulter vos lois locales pour savoir ce que vous n’avez pas le droit de faire. Je ne suis responsable d’aucune de vos actions.