Une brève introduction à la rétro-ingénierie

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 :

  1. Convertir l’exécutable en assembleur ; ce processus s’appelle le désassemblage.
  2. 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 :

Vue en graphe du désassemblage de radare2

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 :

  1. 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.
  2. 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.
  3. ARM propose une documentation officielle pour son architecture AArch64.
  4. Ici vous pouvez télécharger des crackmes pour pratiquer vos compétences en rétro-ingénierie.
  5. 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.

Généré avec Hugo
Thème Stack conçu par Jimmy