cours/content/secu_systeme/2_initiation_re/index.md

18 KiB

title date tags categories
Sécurité système : initiation au reverse engeneering 2023-09-21
ingénieurie inverse
assembleur
Sécurité système
Cours
TD

Nous avons revu dans le cours précédents les bases du fonctionnement d'un binaire de la compilation à son exécution. Dans cette partie nous allons appréhender les bases de l'ingénierie inverse. À l'issue de celle-ci nous devrions être en mesure de réaliser de petits crackme.

L'étape de compilation est en quelque sorte destructrice : Il n'est plus possible à partir du binaire compilé de retrouver le code source correspondant. Mais il est tout de même possible de désassembler : on reconstitue un code assembleur.

Ainsi afin de faire du reverse engineering, il est nécessaire de comprendre la compilation, de savoir de quoi se compose un binaire et comment il est exécuté.

Une compréhension de l'assembleur, des type de structures qu'il manipule est primordiale. Il est aussi nécessaire de maîtriser les outils à notre disposition, de savoir coder un minimum (en C, Python).

La gestion de la mémoire

Les sections .rodata, .bss et .data contiennent des données mémoires. Deux structures existent pour les manipuler : la pile et le tas.

La pile

Elle manipule des données statiques. C'est une structure de type LIFO (Last In First Out) qui croit vers le bas : plus on met de données sur la pile, plus on descend plus les adresses diminuent. Sa taille est alignées sur un int32 ou int64 en fonction de l'architecture (32 ou 64 bits).

Deux "fonctions" permettent de la manipuler : push() afin de placer un élément -- la pile descend alors d'une case -- et pop() pour reprendre un élément -- la pile remonte d'un élément.

Elle se situe dans l'espace mémoire d'un processus, ainsi chaque processus a sa pile. Elle a une structure linéaire; son accès est rapide (pas d'allocation de mémoire à effectuer) mais la taille des éléments que l'on peut y stockée est limitée.

Le tas

Il manipule des données dynamiques représentées par une structure de données hiérarchisée. Le tas est lui aussi positionné dans l'espace mémoire d'un processus.

Il n'est pas limité en taille (enfin presque pas...) mais nécessite de coûteuses allocations mémoire qui le rend plus lent. Le développeur peut lui même allouer de la mémoire sur le tas avec la présence de fonctions comme malloc() (et ne pas oublier de les libérer avec free()). L'implémentation de ces mécanismes mémoire dépendent de l'allocateur.

Initiation au reverse Engineering

L'assembleur

C'est un langage de bas niveau mais encore compréhensible par un humain (initié...). Il est dépendant de l'architecture, prenons par exemple le code C suivant:

int  sum (int a, int b ){
    return a + b;
}

Voici ce code en assembleur x86_64 :

sum(int, int):
   push    rbp
   mov     rbp, rsp
   mov     DWORD PTR [rbp-4], edi
   mov     DWORD PTR [rbp-8], esi
   mov     edx, DWORD PTR [rbp-4]
   mov     eax, DWORD PTR [rbp-8]
   add     eax, edx
   pop     rbp
   ret

Et en assembleur ARM64:

sum(int, int):
   sub     sp, sp, #16
   str     w0, [sp, 12]
   str     w1, [sp, 8]
   ldr     w1, [sp, 12]
   ldr     w0, [sp, 8]
   add     w0, w1, w0
   add     sp, sp, 16
   ret

On parle alors d'ISA pour Instruction Set Architecture, cela représente le jeu d'instructions disponible. Nous y trouvons les opérations élémentaires : addition, soustraction, multiplication, division, et / ou (exclusifs ou non) etc. On manipule les registres et la mémoire directement. Une ISA ne contient pas d'opérateur avancés comme les structures de contrôles que nous pouvons trouver dans les langages de haut niveau (while ..., if ... else ..., for ...).

L'assembleur x86 (32 et 64 bits)

Tout comme pour le cours de sécurité logicielle, nous utiliserons principalement l'assembleur x86_32 pour ce cours (et quelques fois sa version 64). Mais par contre nous utiliserons la syntaxe Intel majoritairement utilisée dans le monde de l'ingénierie inverse. Cette syntaxe est plus simple :

  • les suffixes de mnémoniques n'existent pas en syntaxe Intel, ainsi movl, movw ou encore movb deviennent mov;
  • les préfixes sur les registres et immédiats disparaissent : %eax, $1, $0x0ff deviennent eax, 1 et 0x0ff;
  • l'ordre des opérandes est inversé : ce n'est plus source, destination mais destination, source. movl $1, %eax devient mov eax, 1
  • les accès indirect à la mémoire sont aussi plus simple : (%eax) devient [eax] et 3(%eax) devient [eax + 3] voir le cours [accès à la mémoire de sécurité logicielle]({{<ref "secu_logicielle/4_acces_memoire/index.md#accès-indirects-à-la-mémoire">}})

Le vocabulaire reste le même que celui vu lors des cours [d'introduction de sécurité logicielle]({{<ref "secu_logicielle/1_introduction/index.md">}})

L'assembleur x86 : les registres

Les registres sont de petits espaces mémoire directement intégrés au processeur. Ce sont les espaces mémoire les plus rapides disponible sur un ordinateur, mais aussi les plus petits. Il sont dédiés entre autres au stockage de données.

Certains de ces registres ont des rôles bien déterminés par exemples :

  • eip pour extended instruction pointer pointe vers la prochaine instruction à exécuter1, il porte aussi le nom de Compteur Ordinal. il ne peut être modifié contrairement à tous les autres registres;
  • ebp pour extended base pointeur pointe vers le bas de la pile;
  • esp pour extented stack pointer pointe lui vers le haut de la pile.

Tous ces registres sont noté extended dans leurs versions 32 bits

Certains autres ont des spécificité, mais il est tout à fait possible pour le programmeur de les utiliser à sa guise :

  • ecx par exemple est utilisé comme compteur dans certaines instructions de répétition (typiquement les boucles);
  • esi et edi utilisés comme source (extended source index) et destination (extended destination index) pour certaines instructions de copie.

Les registres peuvent être découpés en sous registres :

a 8 bits -> ax 16 bits -> eax 32 bits -> rax 64 bits

Il est d'ailleurs possible de découper ax en deux registres de 8bits : al (bits 0 à 7) et ah (bits 8 à 15). Ces découpes permettent certaines optimisations comme par exemple le stockage de deux entiers 16 bits dans un registre 32bits ou encore pour les instruction se basant sur l'interprétation du contenu des registres.

Il existe aussi des registres d'états, mais nous les avons vu [en sécurité logicielle]({{<ref "secu_logicielle/3_assembleur_approfondissement/index.md#flags-de-résultats">}}) Dans le cadre de ce cours, le Zero Flag, Sign Flag et Carry flag sont importants. Le carry flag correspond à la présence d'une retenue voir sur Wikipedia.

Pour rappel, voici un exemple d'utilisation du Zero flag :


start:
    mov eax, 1
    dec eax     ; décrémente eax

    ; comme le résultat de la précédente instruction est 0
    ; alors notre programme va forcément brancher...
    jz hell

; [...]

hell:

Pratique

IDA Free

C'est un outils gratuit et multi plate-forme d'analyse de binaire. Il existe aussi plusieurs versions payantes (pro, home, corporate ...). La version free comporte un désassembler, un décompilateur "cloud" pour x86_64 seulement. Il permet la manipulation de binaire au formats différents (ELF, PE Mach-O). Il s'interface aussi avec des débogueras notamment GDB.

C'est un outil puissant mais difficile à prendre en main.

Étudions ls (binaire ELF)

Nous ouvrons notre binaire ls modifié avec lift lors du précédent TD pour l'étudier avec IDA Free.

question b

strcpy se trouve dans la section .dynsym, section des symboles résolus dynamiquement lors de son premier appel.

question c

Dans IDA, le code couleur utilisé entre autres dans la section .dynsym correspond aux éléments fournis à l'extérieur de notre binaire, comme dans les bibliothèques partagées.

question d

Les chiffres hexadécimaux dans ne nom de la fonction sub_xxxxxx correspondent aus décalage (offset) de celle-ci par rapport à l'adresse de chargement de notre binaire.

Les raccourcis clavier

Ils sont une part importante de l'utilisation d'IDA, en voici quelques-uns:

raccourcis fonction commentaires
crtl + w sauvegarder la base de données très utile cat IDA est instable
ctrl + shitf + w effectuer un snapshot de la base de données CF au dessus
ctrl + z annuler
n renommer un objet (variable, fonction, etc.) hexrays
y modifier le type d'un objet (variable, fonction, etc.) asm / hexrays
alt + a interpréter l'objet comme une chaine de caractères
h interpréter la selection comme un décimal / hexadécimal asm / hexrays
r interpréter la selection comme un caractère asm / hexrays
c interpréter la portion comme du code asm asm
d interpréter la portion comme data asm
u retirer l'interprétation asm
: ou ; ajouter un commentaire au niveau de l'instruction visualiseur
insert ajouter un commentaire avant l'instruction asm / hexrays
espace cycle entre la vue graph et linéaire hexrays
F5 décompiler (si possible)
entrée ou double-clic su un symbole -> aller où pointe le symbole asm / hexrays
echap revenir à la position précédente - en arrière asm / hexrays
ctrl + entrée revenir à la position précédente - en avant asm / hexrays
x afficher les cross références xref d'un objet asm / hexrays
alt + t recherche de texte
alt + b recherche de motif binaire
alt + i rechetche un immédiat
tab passer de la vue asm à la vue hexrays
ctrl + e afficher les points d'entrées du binaire
maj + F1 afficher la fenêtre des types locaux
maj + F12 afficher la fenêtre des chaîne de caractères

Il faut voir IDA comme un bloc note accompagnant le travail d'ingénierie inverse Il faut absolument documenter au fur et à mesure des investigations et faire autant d'instantanés que possible (stabilité...).

Analyse du binaire mysecrets

Question 1

Dans IDA, lorsque nous faisons ctrl+e nous atterrissons dans la liste des points d'entrées du binaire chargé. Dans le cas de mysecret, nous atterrissons dans la fonction main:. Ce qui est normal pour un binaire ELF.

Question 2

Pou trouver la section .rodata, il suffit de trouver la liste des segments (mais pas au sens ELF...) avec le raccourci clavier ctrl+s. Cette section contient les données en lecture seule, ici toutes les strings de notre binaire.

Question 3-a

Dans le code assembleur, il semble y avoir 3 arguments. IDA nous les donne en commentaire :

ebp + arg0 
ebp + arg4
ebp + arg8

Question 3-b

La convention d'appel utilisées ici est __cdecl (pour C declaration), c'est la convention utilisés par le langage C et C++ -- la convention d'appel dépend aussi de l'ABI système, du compilateur ). Ici les arguments sont placés sur la pile par la fonction appelante (caller). Il sont placé dans l'ordre inverse, prenons comme exemple le programme C suivant :

int add_3 ( int a, int b, int c){
    // [...]
}
main() {
    add_3 (1, 2, 3);
}

Ce qui donnera en assembleur :

main:
    push ebp     ; backup current base pointer
    mov ebp, esp ; get the new base pointer

    push 3
    push 2
    push 1
    call add_3
    ; [...]

Question 3-c

Les instructions push epb et mov epb, esp permettent de mettre en place le base pointer après l'avoir sauvegardé pour assurer sa remise en place lors du retour de notre fonction secrets.

Question 3-d

La décente de 0x18 dans la pile permet à la fonction appelée de mettre en place un espace pour les variables locales.

Les conventions d'appel

Nous avons vu dans le cas pratique (question 3-b) la convention d'appel __cdecl. Une convention d'appel définie les règles d'appel d'une fonction dont :

  • Comment sont transmis les paramètres à la fonction appelée;
  • L'ordre dans lesquels ces paramètres sont passés;
  • Quels registres doivent être préservés par la fonction appelantes;
  • Comment la pile est nettoyée lors du retour à la fonction appelante.

Une convention d'appels peut dépendre de l'architecture, du système d'exploitation, du compilateur, du langage (__cdecd est issue du C). Il existe aussi :

  • stdcall : variation de la convention pascal, utilisée par Open Watcom C++ et l'API Win32. Comme pour cdecl les éléments sont poussés de droite à gauche et les registres ebx, ecx et edx dont préservés pour l'appelant;
  • Microsoft fastcall : les deux premiers arguments ( à partir de la gauche) sont passés par les registres ecx et edx (s'ils rentrent) puis les autres sont poussés sur la pile de droite à gauche.

Deux convention principales pour x86_64

Pour ce qui concerne ce cours, nous parlerons de deux conventions principalement utilisées.

Convention de Microsoft

Les arguments sont passés -- dans l'ordre -- par les registres rcx, rdx, r8, r9 2 et le reste sur la pile de droite à gauche. Un espace mémoire de 32 octets est réservés sur la pile avec les arguments par la fonction appelante. Le retour se fait dans rax pour les entiers jusqu'à 64bits3 mais en vas de retour plus importants, alors rax contient un pointeur.

Convention Unix (SYS-V)

Les arguments sont passés par rdi, rsi, rdx, rcx, r8 et r94 et le reste sur la pile. La valeur de retour est positionnée dans rax pour les valeurs jusqu'à 64 bits et rax:rdx entre 64 et 128 bits5.

Pratique

Afin de découvrir ce que fait le programme secrets2 il faut commencer par l'exécuter si c'est possible. Ensuite nous passons dans IDA.

Un petit tours par les chaînes de caractères via le raccourci shift + F12 permet d'en apprendre plus. Nous pouvons ainsi voir le message d'aide avec ce qui semble être l'utilisation de la commande. Nous voyons aussi ce qui semble être un message qui invite l'utilisateur à la saisie.

En affichant les cross-references dans IDA, il est alors possible de voir ou est utilisé cette chaîne et de trouver le registre ou est stocké cette saisie (enfin l'adresse mémoire contenant la saisie). En retraçant sont utilisation, nous pouvons voir qu'elle est envoyée dans la fonction secret.

Il est primordial d;annoter le code assembleur dans IDA mais aussi de renommer les variables afin de rendre le code le plus lisible possible. Ainsi nous pouvons renommer la variable s en buffer.

Une fois toutes ces étapes réalisées nous pouvons en déduire que la fonction échappe les caractères qui pourraient être interprétés d'une chaîne résultant d'une commande ls.

Shellcode

Nous en avons déjà vu lors du cours de sécurité logicielle notamment [le TD 5]({{ <ref ../../secu_logicielle/td5-stackoverflow_shellcode/index.md }}), un shellcode est une représentation hexadécimale d'un code assembleur utilisé comme charge utile lors de l'exploitation d'une vulnerabilité.

Si nous trouvons un shellcode lors d'une opération de reverse-engineering sur un binaire, alors nous pouvons en déduire qu'il a été compromis.

Il existe des bibliothèques pour la création de shellcode comme pwbtools.

Protection de binaires

Certain développeurs cherche à protéger leurs binaires contre la retro-ingénieurie que se soit pour des questions de propriétés intellectuelles ou de licences. Ils cherchent alors à rendre l'opération plus difficile :

  • En ajoutant des anti-debug par l'ajout de code pour la détection d'environnement, de machine virtuelle. Certains code peuvent pas exemple faire planter gdb;
  • En obfusquant le code par l'ajout de couche de chiffrement, complexifiant les opération, ajoutant du code inutile;
  • En créant un bytecode spécifique et ajoutant une machine virtuelle pour l'interprétant;
  • En compressant le code binaire (packer) via plusieurs technique dont la suppression de certains éléments (sections etc.).

On considère qu'une protection qui tient 6 mois est de très bonne conception.

La plupart de ces protection on des impact négatifs sur les performances.

Pratique: analyse statique SimpleKeyGen

La première chose à faire ici c'est d'identifier l'architecture et le système. Comme vu plus haut nous pouvons en déduire la convention d'appel.

Ensuite nous pouvons nous concentrer sur le nom des fonctions connue comme printf, strlen etc. Nous pouvons en déduite certains nom de variables.

Dans le cadre de branchement, nous pouvons aussi éliminer les parties inutiles pour notre analyse comme l'affichage de message d'aide / d'erreurs. Dans IDA, l'utilisation de couleurs pour les bloc inutiles se révèle pratique.

Pratique: analyse statique de CrackMe2

L'identification nous permet de déduire que nous sommes en présence d'un binaire PE pour architecture x86_64. Beaucoup d'éléments sont superflus pour notre analyse et il est important de les ignorer comme par exemple la partie sur les secure_cookie ou le password_features.

Comme nous sommes en présence d'un exécutable Windows, l'exécuter nous permettra d'en apprendre plus sur ce qu'il faut. Nous pouvons aussi trouver des chaînes de caractères afficher pour les chercher avec IDA. Avec les cross-references nous pouvons identifier rapidement les parties ou elles sont utilisées.

Une fois les variables identifiées, le code commenté nous déduisons que nous somme en présence d'une s-box ou boite de substitution.


  1. en x86, dans certaine ISA, il pointe l'instruction en cours). ↩︎

  2. pour les entiers, struct et pointeurs. Pour les flottants sur les registres xmm0 à xmm3. ↩︎

  3. et xmms0 pour un flottant. ↩︎

  4. de xmm0 à xmm7 pour les flottants ↩︎

  5. xmm0 et xmm1 pour les flottants ↩︎