bypass_branches_conditionnelles.md

15-02-2021

En attendant que j'ai plus de temps pour écrire des articles plus copieux, nous allons voir rapidement la technique de patching d'un binaire.

Nous allons plus précisement étudier le cas d'une branche conditionnelle et comment contourner une vérification.

ūüĒóLa situation initiale

Nous allons étudier un code C très simple, qui permet à un utilisateur autorisé d'executer un binaire root.

#include <stdio.h>
#include <stdlib.h>
void main(void){
    int isAdmin = 0;
    
    if(isAdmin) {
        printf("accessing very secret part\n");
        system("/bin/super_root_bin");
    } else {
        printf("you're a simple human, sorry...\n");
    }
}

Notons qu'ici, le code est très simpliste, dans un but de démonstration, il est très rare de trouver des structures aussi simples en réalité.

On compile avec gcc notre code et on l'execute, on obtient sans surprise you're a simple human, sorry....

Mais nous allons maintenant tenter de rentrer dans le if.

ūüĒóExploitation

On allons utiliser deux techniques pour rentrer dans la boucle conditionnelle: on verra comment patcher le binaire avec Cutter puis avec une méthode plus brute et radicale avec gdb

ūüĒóPremiere m√©thode: Patcher le binaire avec Cutter

ūüĒóPr√©sentation de Cutter

Cutter est une interface graphique très sympa pour r2 (radare2), un outil très pratique d'exploitation binaire. Dans ce billet, je n'entrerai pas en details dans le fonctionnement et l'utilisation de r2 qui est un outil extrêmement puissant et polyvalent et qui pourrait faire l'objet d'un article entier tant il est complet.

On ouvre Cutter et on choisit bien l'option -w qui permet d'ouvrir le binaire en lecture et en √©criture. Ensuite, l'interface principale de Cutter appara√ģt. Pr√©sentons succintement cette interface:

(1) correspond à l'interface principale de Cutter, par défaut, on atterrit sur l'onglet dissassembly qui permet d'avoir un code desassemblé du binaire ouvert, et en bas, plusieurs onglets permettent de naviguer dans les différentes vues.

(2) correspond √† l'explorateur de symboles du binaire: il liste tous les symboles et les diff√©rentes fonctions, et permet de faire des recherches dessus. On note d'ailleurs la pr√©sence de la fonction main qui va nous concerner tr√®s bient√īt.

(3) enfin correspond à la partie navigation de Cutter, et on voit une bar qui montre la répartition des adresses mémoire utilisées par le programme analysé.

ūüĒóEntrons dans le vif du sujet

Maintenant, il est temps de mettre les main dans le cambouis. Comme je l'ai introduit précédemment, on peut naviguer directement vers la fonction main en utilisant l'explorateur de symboles.

De l'assembleur... Bon gardons notre calme. M√™me sans grande connaissance de l'assembleur, il est plut√īt ais√© de comprendre ces lignes. Les instructions de 0x1149 √† 0x114d constituent le prologue de la fonction, et permettent de pousser sur la pile (la stack) les arguments de la fonction main.

Ensuite, on assigne √† var_4h 0: on reconna√ģt notre ligne int isAdmin = 0. Puis vient la partie la plus int√©ressante du code:

cmp     dword [var_4h], 0 ; if(var_4h == 0)
je      0x1178 ; jump if equal to 0x1178

C'est ici qu'on retrouve la gestion de la condition:

On compare d'abord la variable, précédemment allouée, à 0. Puis l'instruction je ou jump equal permet de sauter à l'adresse 0x1178 si la condition est vérifiée. En réalité, sous le capot, cmp définit le Zero flag en fonction du résultat de la comparaison, mais nous y reviendrons dans la deuxième technique.

Regardons ce qui se passe √† l'addresse 0x1178, on peut d'ailleurs voir que Cutter nous indique d'une petite fl√®che o√Ļ aller, c'est fort aimable.

0x00001178      lea     rdi, str.you_re_a_simple_human__sorry... ; 0x2038 ; const char *s

Gr√Ęce √† radare2, on comprend tr√®s facilement ce que cette ligne fait. En effet, r2 nous donne str.you_re_a_simple_human__sorry..., ce qui veut dire qu'on arrive bien dans notre branchement else de notre code.

c'est bizarre non? La comparaison est vrai donc on rentre dans le branchement conditionnel if qui correspond au else de notre code: cette différence est probablement liée à une optimisation de gcc.

Maintenant qu'on a localis√© la portion de code assembleur qui nous int√©resse, r√©flechissons √† ce qu'on veut modifier pour rentrer dans la condition. Pour cela, on peut regarder du c√īt√© des instructions et des opcodes disponibles en x86 et plus pr√©cisement les instructions de sauts conditionnels:

On voit dans cette documentation que JE, d'opcode 0x74 effectue le saut si ZF est à 1. Mais on apprend également qu'il existe l'instruction inverse JNE, d'opcode 0x75 qui effectue le saut si ZF est à 0.

Int√©ressant... Si on remplace JE par JNE, on ne sautera plus (puisque ZF sera toujours √† 1) et on continuera le flux d'ex√©cution. √Čtudions ce qui suit notre jump:

0x0000115e      lea     rdi, str.accessing_very_secret_part ; 0x2008 ; const char *s
0x00001165      call    puts       ; sym.imp.puts ; int puts(const char *s)
0x0000116a      lea     rdi, str.bin_super_root_bin ; 0x2023 ; const char *string
0x00001171      call    system     ; sym.imp.system ; int system(const char *string)

Plusieurs points tr√®s int√©ressants dans ce code: d√©j√†, on retrouve la cha√ģne accessing_very_secret_part qui permet de dire qu'on est dans la partie "prot√©g√©e" du programme. De plus, on remarque un call system qui correspond √† l'appel √† notre fonction privil√©gi√©e. Bingo, c'est bien l√† o√Ļ l'on veut aller. Il faut donc patcher (modifier) JE et le remplacer par JNE.

Mettons-nous au travail. Depuis l'interface de Cutter, on peut éditer l'instruction JE en JNE. Notez, qu'il est également possible de remplacer le JE par un NOP (NO oPeration) qui permet également de bypass la condition.

Ici, on remplace je 0x1178 par jne 0x1178 et radare2 va changer tout seul l'opcode (on passe bien de 0x74 à 0x75). Voilà, le binaire est patché :) . On peut quitter Cutter et vérfier cela avec objdump, un autre outil bien pratique à tout reverse engineer qui se respecte. On lance objdump --disassemble=main ./vuln qui permet desassembler la fonction main et on observe bien le changement d'opcode:

Hourra, et si on lance maintenant le binaire, on obtient un magnifique accessing very secret part suivi de l'exécution de notre programme sensible. Nous avons bien réussi à bypass la vérification \o/.

ūüĒóDeuxi√®me m√©thode: utiliser GDB

Nous avons vu une méthode très confortable pour éditer le binaire, mais maintenant, nous allons adopter une méthode plus brute et utile dans un contexte différent.

ūüĒóPetite partie th√©orique

Avant de rentrer dans le vif du sujet, nous allons rapidement revenir sur le concept d'EFLAGS. J'avais √©voqu√© la notion de ZF dans la partie pr√©c√©dente, et je vous avez promis d'y revenir, et bien, c'est le moment! En effet, ZF ou Zero Flag fait partie des flags qui consistuent le registre de status dans un processeur x86. Ce registre est un peu particulier et contient des informations qui d√©crivent l'√©tat du processeur √† chaque instant pour chaque programme. Ce registre porte le nom de FLAGS et fait 16 bits. Il a √©t√© √©tendue √† 32 et 64 bits pour les architectures modernes, avec respectivement les registres EFLAGS et RFLAGS. Il y a de nombreux flags dans ce registre, je ne compte pas tous les pr√©senter dans ce post mais on peut par exemple citer PF ou Parity Flag qui vaut 1 si le r√©sultat de la derniere op√©ration r√©alis√©e par le CPU est paire et 0 sinon ou encore SF pour Sign Flag, qui stock l'information du signe du r√©sultat de la derni√®re operation. Mais ZF dans tout √ßa? Le Zero Flag stock le r√©sultat de la derniere comparaison r√©alis√©e avec le CPU, notamment avec l'operation cmp. Plus exactement l'instruction cmp a b r√©alise une soustraction entre a et b et met ZF √† 1 si la diff√©rence est nulle (i.e a et b sont identiques), 0 sinon. Et les instructions de saut de la famille des J (comme JE, JNE, JZ, JNZ) se basent sur ce flag pour d√©cider si un jump doit √™tre fait ou pas. Voyez vous o√Ļ je veux en venir? En effet, si on peut manipuler la valeur de ZF, on peut manipuler le flux d'ex√©cution conditionnel d'un programme!

ūüĒó√Ä l'attaque!

Avec toutes ces bonnes idées en tête, nous allons nous attaquer au binaire déjà étudié en partie 1.

Pour cette partie, nous utiliserons GDB. GDB ou GNU DeBugger est le debugger le plus populaire sous linux, il permet de poser des breakpoints dans un programme, d'exécuter instruction par instruction et de récuperer à chaque étape l'état du CPU. on le lance en muet avec l'option -q

r est un raccourci de la commande run, qui permet de lancer le binaire, et on obtient notre habituel message qui nous signale que nous ne sommes que de simples humains. ensuite GDB quitte le programme et nous affiche le pid (process ID) du programme ainsi que son code d'arr√™t. Plut√īt d√©cu? Nous allons maintenant commencer √† vraiment utiliser la puissance de gdb: la premi√®re √©tape est de desassembler la fonction main afin de savoir o√Ļ poser un point d'arr√™t int√©ressant. On va utiliser la commande set disassembly-flavor intel pour demander √† gdb d'afficher le code assembleur avec la synthaxe intel, qui est plus simple √† lire

Vous commencez √† √™tre habitu√©s, voici le code assembleur de notre fonction main, qui ressemble beaucoup √† celui qu'on avait trouv√© avec Cutter. On reconnait l'instruction de comparaison puis le saut conditionnel, et on sait maintenant qu'il va falloir manipuler le fameux registre FLAGS √† ce moment l√†. Mais pour pouvoir √©tudier en d√©tail ce qu'il se passe, on va poser notre breakpoint au d√©but de la fonction main, en utilisant la commande b *main pour break au symbole main (d'o√Ļ la pr√©sence de l'ast√©risque). On relance avec r. Cette fois ci, gdb nous indique un breakpoint et son adresse dans la m√©moire: le programme est en pause. Maintenant, on peut inspecter l'√©tat des registres du processeur au moment de la pause, en utilisant la commande info register ou i r.

On peut voir la valeur de tout les registres classiques d'un processeur x86-64, comme le pointeur d'instruction rip (le r signifique qu'il pointe des instructions de 64 bits). Mais on voit également eflags, le fameux registre d'états! GDB nous indique directement les flags qui sont actifs: PF le flag de parité, ZF notre flag cible, ainsi que le flag de signe SF.

ūüĒóUn peu de binaire

Il peut être intéressant de s'arrêter quelques instant ici, pour mieux comprendre comment marche ce registre si particulier. nous avons vu que EFLAGS est un registre sur 16 bits (j'ai dit tout à l'heure que EFLAGS était étendue à 32 bits, mais pour des questions de simplicité, on allons ignorer les bits supplementaires). Mais concrètement, à quoi cela ressemble-t-il? Et bien, GDB peut nous aider à le comprendre: en effet, on peut utiliser la commande print/x $eflags pour afficher la valeur en hexadécimal du registre: 0x246. Pourquoi cette valeur? Pour mieux comprendre, il est préférable de regarder la valeur en binaire de 0x246, ce qui peut être fait avec en remplaçant x par t dans la commande precedente, et on obtient cette fois-ci: Ob1001000110 et tout devient plus clair. Les bits 2, 3, 7, 10 sont mis à 1, en comptant de droite à gauche. et on obtient bien les flags PF, ZF et SF comme prevu! Maintenant que nous savons lire le registre EFLAGS, il peut être intéressant de se demander comment manipuler ce registre. Comme beaucoup d'autres opérations bas niveau, il faut se pencher sur opérations bit à bit (ou bitwise operation).

En logique, une opération bit à bit est un calcul manipulant les données directement au niveau des bits, selon une arithmétique booléenne - Wikipedia

l'article wikipédia donne tout les éléments qui vont nous être utiles ici: décalage à gauche (ou plus couramment left bit shift), ou exclusif et ou (xor et or) vont être nos principaux outils.

ūüĒó√† l'attaque (le retour)

Maintenant que l'on sait o√Ļ on va, on peut revenir √† GDB, et step iinstruction (si) pour ex√©cuter la prochaine instruction puis mettre en pause le programme.On peut regarder ou nous sommes dans le binaire √† l'aide de disass qui indique par une fl√®che la ligne o√Ļ nous sommes arr√™t√©s. nous allons jusqu'√† l'instruction de jump.

La petite flèche nous indique que nous sommes arrivés. Si vous avez bien compris jusqu'ici, on doit trouver dans le registre EFLAGS le flag ZF à 1, car cmp a bien renvoyé 0. Si on vérifie avec i r, tout est bon. Et maintenant, nous allons utiliser des opérations bit à bit ainsi que la commande set de gdb pour passer le flag ZF à 0.

Qu'est ce que fait la ligne set $eflags ^= (1 << 6)? Et bien, elle applique l'operation xor sur le registre eflags avec la valeur binaire 1 << 6 ou 100000 (<< n signifie un décalage vers la gauche, le sens des chevrons, de n bits). on obtient donc 1 xor 1 = 0, puisque le ZF est déjà à 1 et se trouve en 7 ème position du registre(on commence bien à compter à 0). les autres registres restent inchangés.

Finalement, on obtient bien la partie protégée et on a bien contournée la vérification, victoire!

ūüĒóConclusion

Dans ce court article, je vous ai presenté quelques techniques de bases pour contourner la vérification d'une condition, mais également quelques outils très utiles lorsqu'on veut manipuler et étudier le comportement de binaire. Je ne suis pas allé très loin dans les détails d'implementation ni dans le fonctionnement exacte du registre EFLAGS, car cela sortait du cadre de cet article. De plus, ce sont des techniques appliquées dans des cas très simples, mais la logique restera similaire si l'on veut contourner une protection logicielle mal implementée, l'objectif restera de contourner les mecanismes de verification. Je pense consacrer un prochain article à l'utilisation plus poussée de Cutter et radare2, et à des techniques de reverse plus avancées.

Stay tuned, to be continued ~