Post

Partie 1 - Introduction

Introduction 💭

angr est un moteur d’exĂ©cution symbolique open source qui permet d’analyser et d’émuler des programmes binaires. Il utilise l’exĂ©cution symbolique pour explorer toutes les branches d’exĂ©cution possibles d’un programme. Il permet, entre autres, de dĂ©couvrir les vulnĂ©rabilitĂ©s, les bugs et conditions qui permettent d’atteindre certaines parties d’un programme.

L’un des principaux avantages d’angr est sa capacitĂ© Ă  analyser les programmes sans avoir besoin de les exĂ©cuter rĂ©ellement. Cela permet d’éviter les problĂšmes de sĂ©curitĂ© (exemple : malware) et d’analyser plus facilement un bout de code sans avoir Ă  l’exĂ©cuter.

angr est utilisĂ© dans de nombreux domaines de la sĂ©curitĂ© informatique, tels que la recherche de bugs, l’analyse de malware, la sĂ©curitĂ© des systĂšmes embarquĂ©s et dans les challenges !

Il est compatible avec de nombreuses architectures de processeurs et prend en charge de nombreux formats de fichiers binaires.

Ah oui au fait, en termes de prononciation đŸ€“:

angueur_pas_angr

Les diffĂ©rents types d’analyse

Avant de nous intĂ©resser directement Ă  l’exĂ©cution symbolique, voyons d’abord quelles sont les deux principales mĂ©thodes utilisĂ©es pour analyser un programme.

Le programme utilisĂ© en guise d’exemple est le suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h>

int main(int argc, char *argv[]) 
{
    int arg = atoi(argv[1]);

    if (arg == 0xdeadbeef) 
    {
        return 1337;
    } 
    else 
    {
        return -1;
    }
}

L’analyse statique

Ce type d’analyse est qualifiĂ© de “statique” car il ne nĂ©cessite pas l’exĂ©cution du programme. GĂ©nĂ©ralement, on se sert d’outils qui permettent d’extraire des informations d’un programme et de le comprendre.

On peut se servir d’un dĂ©sassembleur afin de convertir des donnĂ©es brutes d’octets en instructions assembleur, exemple : objdump, radare2, capstone.

Exemple du précédent code désassemblé (aprÚs compilation) :

On se sert Ă©galement de dĂ©compilateurs afin d’avoir des informations supplĂ©mentaires, telles que du code, plus facilement lisible pour un humain. On peut citer, par exemple : Ida Pro, Ghidra, Binary Ninja, Cutter 


Exemple du précédent code décompilé :

En utilisant ces divers outils il est, souvent, déjà possible de comprendre ce que fait un programme, comment sont appelées ses diverses fonctions et comment elle interagissent entre elles.

L’analyse dynamique

Contrairement Ă  l’analyse statique, l’analyse dynamique nĂ©cessite l’exĂ©cution du programme. Cette exĂ©cution peut ĂȘtre rĂ©alisĂ©e sur une machine physique, un Ă©mulateur (Qemu par exemple), une machine virtuelle 


Divers outils, appelés debuggers, permettant de réaliser une analyse dynamique en exécutant pas à pas un programme. Par exemple : GDB, windbg, x64dbg 


Exemple de l’exĂ©cution de la fonction main dans GDB :

Ce type d’analyse permet gĂ©nĂ©ralement de confirmer ce qui a Ă©tĂ© vu en analyse statique ou de comprendre certaines fonctionnalitĂ©s qui n’ont pas pu ĂȘtre analysĂ©es correctement.

Par exemple, les malwares les plus robustes disposent souvent de plusieurs couches d’obfuscation qui ne ralentissent et limitent la comprĂ©hension de leur fonctionnement en analyse statique.

Par exemple, certaines fonctions auront un code dĂ©compilĂ© illisible. Dans d’autres cas il peut arriver de ne pas du tout pouvoir dĂ©compiler le code assembleur du programme.

Ainsi, l’objectif de l’analyse dynamique est de reproduire l’environnement d’exĂ©cution du programme Ă©tudiĂ© afin d’analyser au mieux son comportement Ă  travers l’analyse de son exĂ©cution. Il ne s’agit pas seulement d’utiliser un debugger, mais Ă©galement d’autres outils de monitoring afin d’observer les processus crĂ©Ă©s, les fichiers modifiĂ©s, les Ă©vĂšnements dĂ©clenchĂ©s


En reverse, on ne choisit pas soit d’utiliser l’analyse statique soit d’utiliser l’analyse dynamique. Au contraire, on prĂ©fĂšre gĂ©nĂ©ralement combiner les deux et tirer profit des avantages de chacune d’elles.

L’exĂ©cution symbolique

L’exĂ©cution symbolique est gĂ©nĂ©ralement moins connue et moins maĂźtrisĂ©e du grand publique. Afin de comprendre son fonctionnement et son utilitĂ©, reprenons le prĂ©cĂ©dent programme :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "stdlib.h"

int main(int argc, char *argv[]) 
{
    int arg = atoi(argv[1]);

    if (arg == 0xdeadbeef) 
    {
        return 1337;
    } 
    else 
    {
        return -1;
    }
}

Le fonctionnement du programme est assez trivial : le programme rĂ©cupĂšre le premier paramĂštre saisi par l’utilisateur et le compare Ă  0xdeadbeef.

Si les valeurs sont identiques, la valeur retournĂ©e est 1337, sinon c’est -1. A ce stade, l’analyse statique nous permet d’ores et dĂ©jĂ  de trouver la bonne valeur Ă  saisir. Essayons tout de mĂȘme de trouver le bon input afin que la valeur de retour soit 1337 grĂące Ă  angr.

Tout d’abord, crĂ©ez un fichier “exemple_1.c” contenant le prĂ©cĂ©dent programme. Puis compilez le avec la commande : gcc -no-pie exemple_1.c -o exemple_1.

L’option -no-pie implique que les instructions du programme sera toujours chargĂ© Ă  la mĂȘme adresse et ne sera pas (totalement) soumis Ă  l’ASLR. De cette maniĂšre angr ne nous demandera pas de lui spĂ©cifier une adresse de base, ce qui est plus commode pour nous.

Ouvrons le programme fraĂźchement compilĂ© exemple_1 avec IDA (IDA Free fera l’affaire ;) ) :

Finalement rien de surprenant, on retrouve bien les deux blocs de code, l’un lorsque la comparaison est rĂ©ussie (en vert) l’autre lorsque la comparaison Ă©choue (en rouge).

Avant d’aller plus loin, il est nĂ©cessaire de se familiariser avec quelques notions cruciales lorsque l’on aborde l’exĂ©cution symbolique.

Les Ă©tats

La notion d’état en exĂ©cution symbolique est une notion trĂšs importante. En comprenant comment fonctionne la gestion des Ă©tats, on comprend comment fonctionne l’exĂ©cution symbolique. De la mĂȘme maniĂšre, une gestion dĂ©sastreuse des Ă©tats limite fortement la puissance que l’on peut tirer de l’exĂ©cution symbolique.

Un Ă©tat en exĂ©cution symbolique est le contexte dans lequel est actuellement exĂ©cutĂ© le programme. Un contexte est donc totalement dĂ©terminĂ© par la valeur qu’ont ses registres et les diffĂ©rentes zones mĂ©moire assignĂ©es. Ainsi, deux Ă©tats sont diffĂ©rents si et seulement si il ont au moins un registre, une zone qui a une valeur diffĂ©rente ou des variables qui ont des contraintes diffĂ©rentes.

Un Ă©tat est un peu comme ce qui est affichĂ© dans le prĂ©cĂ©dent screenshot de gdb dans la section “Analyse dynamique” avec les diffĂ©rentes valeurs des registres, de la mĂ©moire 


angr subdivise l’état courant quand il rencontre un branchement vers deux chemins diffĂ©rents qui ont chacun leur contrainte. Par exemple, lorsque notre Ă©tat initial arrivera Ă  l’instruction 0x40114E : jnz 0x401157, deux cas sont possibles :

  • Soit [rbp+var_4] == 0xDEADBEEF
  • Soit [rbp+var_4] != 0xDEADBEEF

Il est possible que les adresses utilisĂ©es dans ce tutoriel ne soient pas en adĂ©quation avec le programme “exemple_1” si vous l’avez compilĂ© sur votre machine.

Il suffit d’adapter le script en modifiant les diffĂ©rentes adresse Ă  partir des captures d’écran de ce tutoriel pour que cela corresponde aux adresses utilisĂ©es par votre programme.

Ainsi, il y a une contrainte sur la valeur contenue Ă  [rbp+var_4] qui est diffĂ©rente en fonction du chemin parcouru. Que va faire angr dans ce cas ? C’est trĂšs simple. Il va prendre l’état initial state_0 et rĂ©aliser deux “copies” de cet Ă©tat, nommons les state_vert et state_rouge.

Les deux différences entre state_vert et state_rouge sont les suivantes :

  • state_vert :
    • Le registre RIP vaut 0x401150
    • L’état a la contrainte : [rbp+var_4] == 0xDEADBEEF
  • state_rouge :
    • Le registre RIP vaut 0x401157
    • L’état a la contrainte : [rbp+var_4] != 0xDEADBEEF

Au-delĂ  de ces deux diffĂ©rences, les autres registres et zones mĂ©moire de ces deux sous-Ă©tats sont les mĂȘmes. La gestion de plusieurs Ă©tats simultanĂ©ment est ce qui fait la force de l’exĂ©cution symbolique car cela permet de parcourir bien plus de code qu’avec une simple exĂ©cution du programme.

Paradoxalement, la subdivision en plusieurs Ă©tats est Ă©galement ce qui fait la faiblesse de l’exĂ©cution symbolique : plus il y a de branchements dans un programme, plus il y a d’états Ă  gĂ©rer, plus cela consomme de la RAM. Ainsi, dans un programme qui effectue un grand nombre de boucles ou qui contient des boucles dans des boucles, la mĂ©moire vive peut vite saturer et faire planter l’exĂ©cution symbolique. Nous ferons par la suite un exemple de programme qui provoque une explosion de chemins.

Voici grossomodo le contenu des trois précédents états :

Mais la contrainte portait sur [rbp+var_4] pourquoi est-elle maintenant sur eax_val ?

Certes la contrainte porte sur la valeur contenu à [rbp+var_4], mais quelle variable est à l’origine de [rbp+var_4] ?

Si on regarde quelques instructions plus haut, on voit : 0x401144 : mov [rbp+var_4], eax oĂč eax est la valeur de retour de atoi. Ainsi, mettre une contrainte sur [rbp+var_4] revient Ă  mettre une contrainte sur le contenu de eax Ă  la sortie de atoi que l’on nomme eax_val.

eax_val est une variable symbolique et c’est sur elle que sera Ă©tablie la contrainte.

Les variables symboliques

Une autre notion importante en exĂ©cution symbolique est la notion de variables symboliques. En fait, pour qu’un moteur d’exĂ©cution symbolique puisse parcourir plusieurs chemins via plusieurs Ă©tats simultanĂ©ment, il faut que certaines variables soient symboliques.

Contrairement aux variables ayant des valeurs concrĂštes, les variables symboliques peuvent, au dĂ©part, avoir n’importe quelle valeur. Ce n’est qu’au fur et Ă  mesure de l’exĂ©cution du programme, du choix du chemin lors de branchements if / else que des contraintes seront ajoutĂ©es Ă  la variable symbolique.

Imaginons que eax_val ait une valeur concrĂšte au retour de atoi, par exemple 0xcafebabe. Il ne sera pas possible d’imposer des contraintes sur eax_val car la variable a dĂ©jĂ  la contrainte suivante eax_val == 0xcafebabe.

Ainsi, initialement, une variable symbolique peut avoir n’importe quelle valeur selon son type.

Par exemple :

  • une variable de 8 bits aura une valeur initialement comprise entre 0x00 et 0xff (255)
  • une variable de 32 bits aura une valeur initialement comprise entre 0x00 et 0xffffffff (4294967295)

Contraintes d’une variable symbolique

GĂ©nĂ©ralement, une variable symbolique va subir plusieurs contraintes au fil de l’exĂ©cution et du chemin empruntĂ© par le moteur d’exĂ©cution symbolique. Il existe alors trois cas possibles pour cette variable une fois l’exĂ©cution stoppĂ©e aprĂšs avoir suivi un certain chemin :

  • Il y a une unique solution : Au vu des contraintes sur la variable, il ne peut y avoir qu’une unique solution valable.
  • Il y a plusieurs solutions possibles : Par exemple, le chemin suivi ne peut ĂȘtre parcouru que si la taille de la chaĂźne de caractĂšres ( qui est une valeur symbolique ) est strictement positive.
  • Il n’y a aucune solution possible : Cela peut arriver lorsque plusieurs contraintes ne sont pas satisfaisables en mĂȘme temps. Par exemple si une des contraintes est var >= 10 et l’autre est var < 8, il n’existe pas de solution possible.

En fait, angr ne dĂ©termine pas tout seul si, au moins une ou plusieurs solutions sont possibles. Il s’aide de ce que l’on appelle un SMT solveur. Il s’agit d’un outil qui prend en entrĂ©e un ensemble de formules logiques qui spĂ©cifient des contraintes sur des variables et retourne un rĂ©sultat, si cela est possible.

Ce n’est pas parce qu’un problĂšme est satisfaisable que le solveur retournera facilement une solution. Certaines contraintes sur une variable peuvent ĂȘtre tellement lourdes et complexes que cela prendra des minutes voire des heures avant de trouver un rĂ©sultat.

Parmi les SMT solveurs les plus connus il y a : Z3, Boolector, Bitwuzla 


Quant Ă  angr, il utilise Z3 en tant que solveur.

Le SMT solveur Z3

Un solveur SMT (Satisfiability Modulo Theories), tel que Z3, est un outil logiciel qui permet de résoudre des problÚmes de satisfiabilité. Il est utilisé pour vérifier si une certaine formules logique disposant de combinaisons de contraintes est satisfaisable ou non.

Ce qui est encore plus impressionnant avec un solveur est que, lorsqu’il existe au moins une solution, il arrive souvent Ă  nous retourner une solution. Dans les cas oĂč la formule est vraiment trĂšs compliquĂ©e et que la machine utilisĂ©e n’est pas trĂšs puissante, il se peut qu’il y ait un timeout sans trouver de solution.

Prenons un exemple concret dans lequel nous allons demander à z3 de résoudre deux équations :

  • Une ayant plusieurs solutions possibles
  • Une n’ayant aucune solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from z3 import *

# Création de la variable x
x = Int('x')
# Création de l'équation
equation = x - 7 >= 2
# Création du solveur Z3
solveur = Solver()
# Ajout de l'Ă©quation au solveur
solveur.add(equation)

# RĂ©solution du solveur
if solveur.check() == sat:
	# Si une solution est trouvée, affiche la valeur de x qui satisfait l'équation
	modele = solveur.model()
	solution = modele[x]
	print("Une solution de l'Ă©quation est : x =", solution)

else:
	# Si aucune solution n'est trouvée
	print("Pas de solution trouvée.")

En exĂ©cutant ce script python, une sortie que l’on peut avoir est Une solution de l'Ă©quation est : x = 9 qui est bien une solution de l’équation x - 7 >= 2 (oĂč x est un entier).

Maintenant, ajoutons une autre contrainte avec les deux lignes suivantes en dessous de solveur = Solver() :

1
2
equation_2 = x < 0
solveur.add(equation_2)

Les contraintes sur x n’étant pas satisfaisables, l’exĂ©cution du script retourne Pas de solution trouvĂ©e.. L’idĂ©e n’étant pas de savoir utiliser de maniĂšre avancĂ©e z3 (angr le fera pour nous đŸ€­ ) mais de comprendre Ă  quoi sert un solveur et comment les utiliser.

Utilisation d’angr

Nous avons parlĂ© des principaux Ă©lĂ©ments thĂ©oriques liĂ©s Ă  l’exĂ©cution symbolique (variable symbolique, Ă©tat, contraintes, solveur 
). Passons Ă  la partie pratique avec cet exemple.

L’idĂ©e globale est de demander Ă  angr d’exĂ©cuter la fonction main et de passer par le bloc vert afin qu’il nous donne le bon input pour y arriver.

Voici le dĂ©but du script qui utilise angr nous permettant de rĂ©aliser ça (j’utilise les mĂȘmes adresses que celles que l’on a vu prĂ©cĂ©demment) :

1
2
3
4
5
6
7
8
9
import angr

p = angr.Project("./exemple_1")
state_0 = p.factory.blank_state(addr= 0x401122)

sm = p.factory.simulation_manager(state_0)

print("[+] Exploration en Cours ....")
sm.explore( find = 0x401150, avoid = 0x401157)

DĂ©cortiquons ensemble ce script :

  1. p = angr.Project("./exemple_1") permet de crĂ©er un projet “angr” en spĂ©cifiant le programme que l’on souhaite utiliser
  2. state_0 = p.factory.blank_state(addr= 0x401122) : on crĂ©e un Ă©tat initial “vide” qui dĂ©marre Ă  la premiĂšre instruction du main Ă  l’adresse 0x401122.
  3. Une fois que notre Ă©tat initial state_0 est crĂ©Ă©, il va falloir crĂ©er le simulation_manager. Il s’agit d’un objet qui va gĂ©rer tous les Ă©tats lors de l’exĂ©cution symbolique. Au dĂ©part, il n’y a qu’un seul Ă©tat, celui que l’on vient de crĂ©er. Toutefois, lorsque angr va rencontrer des branchement, par exemple lors d’un “if-else”, il va “subdiviser” l’état courant en deux “sous-Ă©tats” oĂč chacun prendra respectivement le chemin du “if” et du “else”.
  4. Ensuite, on demande au simulation_manager d’atteindre le bloc “vert” ( la comparaison avec 0xdeadbeef est rĂ©ussie) en spĂ©cifiant find et d’éviter le bloc en rouge ( la comparaison a Ă©chouĂ©) en spĂ©cifiant avoid.

Le Simulation Manager

C’est ce gros “truc” qui va gĂ©rer tous nos Ă©tats lors de l’exĂ©cution symbolique. A un instant T de l’exĂ©cution symbolique, les Ă©tats peuvent avoir diffĂ©rents statuts :

  1. active : Un Ă©tat actif reprĂ©sente un chemin d’exĂ©cution en cours d’exploration par angr. Cela signifie qu’angr est en train d’exĂ©cuter (symboliquement) des instructions pour ce chemin spĂ©cifique ;
  2. inactive : Un Ă©tat inactif est un chemin d’exĂ©cution qui a Ă©tĂ© entiĂšrement explorĂ©. Cela peut se produire lorsque toutes les instructions du programme ont Ă©tĂ© suivies pour ce chemin spĂ©cifique ou qu’il s’agit d’une destination atteinte ; angr n’a plus besoin de le considĂ©rer ;
  3. found : Lorsqu’angr atteint un Ă©tat “found” , cela signifie que le chemin d’exĂ©cution satisfait une condition spĂ©cifique dĂ©finie par l’utilisateur. Par exemple, cela peut ĂȘtre le cas lorsque le programme atteint une certaine adresse, quand il atteint une fonction spĂ©cifique ou lorsqu’une autre condition dĂ©finie est satisfaite ;
  4. avoid : De la mĂȘme maniĂšre qu’un Ă©tat found signifie qu’on a atteint du code dont le contexte satisfait certaines conditions, un Ă©tat avoid est un Ă©tat dans lequel on souhaite que l’exĂ©cution du programme soit stoppĂ©e ;
  5. unsat : Un Ă©tat “unsat” (insatisfaisable) est un chemin d’exĂ©cution qui mĂšne Ă  une contradiction ou Ă  une condition impossible Ă  satisfaire. Cela se produit gĂ©nĂ©ralement lorsqu’une condition de programme invalide est rencontrĂ©e, ce qui signifie qu’angr ne peut pas explorer ce chemin d’exĂ©cution plus loin.

Voici un exemple dans lequel le SM (Simulation Manager) contient seulement deux Ă©tats :

  • un Ă©tat de type found 🟱
  • un Ă©tat de type avoid 🔮

PremiÚre exécution du script

On exĂ©cute le script avec python3 angr_explore.py et lĂ , et bien rien ! Le script ne semble pas faire grand chose 
 Vous constaterez qu’angr n’est pas trĂšs content et vous le fait savoir via plusieurs warnings. Certains sont anodins (et nous verrons plus tard pourquoi) mais il y en a un qui revient souvent et nous permet de comprendre pourquoi le script ne fait pas grand-chose.

Il s’agit de ce warning :

1
2
WARNING | angr.storage.memory_mixins.default_filler_mixin | Filling memory at 0xfffffffffffffc0b with 1 unconstrained bytes referenced from 0x539  
fa0 (atoi+0x0 in libc.so.6 (0x39fa0))

Bon ça a l’air d’ĂȘtre du gros charabia pour nous mais essayons quand mĂȘme de comprendre la logique dans tout ça. En tout cas, de ce que l’on voit, c’est qu’il semble y avoir un petit soucis Ă  cette adresse : (atoi+0x0 in libc.so.6 (0x39fa0)).

DÚs les premiÚres instructions de la fonction atoi, angr est dans les choux. Ce qui est en réalité normal. En effet, atoi est une fonction importée. Elle est donc exécutée dynamiquement par le programme en faisant appel à la bibliothÚque standard libc.

Comme angr n’exĂ©cute rien dynamiquement, il ne charge mĂȘme pas la libc au dĂ©marrage de l’application. Nous allons donc devoir gĂ©rer l’appel Ă  atoi afin de ne plus ĂȘtre embĂȘtĂ© par la suite.

En rĂ©alitĂ© angr gĂšre plutĂŽt bien certaines fonctions de base de la libc. Mais il vaut mieux parfois prendre les rĂȘnes afin de savoir exactement ce qui est rĂ©alisĂ©.

Ajout d’un hook

Il existe diffĂ©rentes maniĂšres de gĂ©rer soi-mĂȘme ou de contourner l’appel Ă  une fonction (ou une instruction de maniĂšre gĂ©nĂ©rale). La plus simple est l’utilisation de hooks, c’est celle que nous allons utiliser. Il existe une autre maniĂšre plus avancĂ©e de faire des hooks via SimProcedure (cf les SimProcedures).

Voici comment implémenter un hook dans angr :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import angr

def hook_atoi(state):
	# Faire des trucs
	return

p = angr.Project("./exemple_1")
# Ne pas oublier d'adapter en fonction de vos adresses
state_0 = p.factory.blank_state(addr= 0x401122)

sm = p.factory.simulation_manager(state_0)
p.hook(0x40113f, hook_atoi,5)

print("[+] Exploration en Cours ....")
sm.explore( find = 0x401150, avoid = 0x401157)

Cela se déroule en deux étapes :

  1. Appeler la fonction hook de angr sur le projet avec trois arguments :
    • L’adresse de l’instruction dont on veut intercepter ou modifier le fonctionnement
    • la fonction python qui sera exĂ©cutĂ©e Ă  la place
    • la taille totale de l’instruction (ou des instructions) Ă  hook, ici, 5 octets :
  2. DĂ©finir la fonction qui sera appelĂ©e lors du hook. En fonction des instructions ou fonctions hookĂ©es, son contenu ne sera pas le mĂȘme. Par exemple, si on hook la fonction printf, la fonction de hook pourrait simplement afficher une chaĂźne de caractĂšres Ă  l’écran avec print en python.

Dans le code exécuté, angr va agir de la sorte :

  1. En arrivant Ă  l’adresse 0x40113f, il se rend compte que 5 octets Ă  partir de cette adresse doivent ĂȘtre hookĂ©s et qu’ils seront donc gĂ©rĂ©s par notre script. Cela correspond exactement Ă  l’instruction call _atoi
  2. Le hook Python hook_atoi est alors exĂ©cutĂ© au lieu de l’instruction
  3. Une fois la fonction hook_atoi terminĂ©e, angr reprend l’exĂ©cution symbolique Ă  partir de 5 octets plus loin

Ce qui est intĂ©ressant avec les fonctions de hook est qu’elles peuvent disposer, en paramĂštre, de l’état (ici state) courant lorsque le hook a Ă©tĂ© dĂ©clenchĂ©. Cela est Ă©normĂ©ment pratique pour consulter la valeur des registres, les modifier, inspecter la mĂ©moire, la stack etc.

Pour l’instant, la fonction de hook est vide, elle ne fait rien. Remplissons-lĂ  ✏!

On sait que la fonction atoi convertit une chaĂźne de caractĂšres en un entier. Ce que l’on aurait pu faire pour garder le mĂȘme fonctionnement de atoi est d’utiliser la variable argv de Python pour retourner un entier arbitraire, choisi au moment du lancement du script.

Mais ce n’est pas ce que nous allons faire. Rappelons nous de l’objectif que nous souhaitons atteindre grĂące Ă  l’exĂ©cution symbolique : Trouver le bon argument Ă  donner au programme pour qu’il retourne 1337.

Utilisation d’une variable symbolique

Ainsi notre argument argv[1] ne doit pas ĂȘtre concret mais symbolique. Nous devons mettre la valeur de retour dans le registre rax Ă©tant donnĂ© que c’est le registre qui contient la valeur retournĂ©e par une fonction en x86_64.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import angr
import claripy

# Variable symbolique de 64 bits
arg_symb = claripy.BVS('argv', 8*8)

def hook_atoi(state):
	print("[i] La fonction atoi a été hookée")
	# On retourne la variable symbolique via rax
	state.regs.rax = arg_symb
	
p = angr.Project("./exemple_1")
state_0 = p.factory.blank_state(addr= 0x401122)

sm = p.factory.simulation_manager(state_0)
p.hook(0x40113f, hook_atoi,5)

print("[+] Exploration en Cours ....")
sm.explore( find = 0x401150, avoid = 0x401157)
print("[+] Arrivé à destination")

print("[+] Chemins explorés : ",sm)

En exécutant cette version du script, on obtient aprÚs quelques warnings :

1
2
3
[i] La fonction atoi a été hookée  
[+] Arrivé à destination  
[+] Chemins explorés :  <SimulationManager with 1 found, 1 avoid>

Tout s’est bien passĂ© comme prĂ©vu et angr a pu parcourir deux chemins au total :

  • found qui regroupe les Ă©tats issus des chemins parcourus qui ont pu atteindre l’objectif fixĂ©, ici : 0x401150 (il peut y avoir plusieurs found, dans notre cas, il n’y en a qu’un)
  • avoid qui regroupe les Ă©tats issus des chemins parcourus et qui doivent s’arrĂȘter s’ils rencontrent une adresse de type avoid, ici : 0x401157 (dans notre cas, il n’y en a qu’un)

Voyons ensemble quelques explications quant Ă  la variable symbolique arg_symb. Tout d’abord nous avons importĂ© le module claripy qui est un module qu’utilise angr pour gĂ©rer les variables symboliques et concrĂštes ainsi que l’utilisation du solveur z3.

Les deux types de variables peuvent ĂȘtre dĂ©clarĂ©s de cette maniĂšre :

  • Les variables concrĂštes (ex : var = claripy.BVV(0xdeadbeef, 8*4)) : pour dĂ©clarer une variable concrĂšte, deux arguments sont Ă  renseigner :
    1. Sa valeur
    2. Sa taille ( en bits ! et non en octets, attention ! ), dans cet exemple, la variable est de 32 bits ( 4 octets )
  • Les variables symboliques (ex : var_symb = claripy.BVS('x', 8)): pour dĂ©clarer une variable symbolique, deux arguments sont Ă©galement Ă  renseigner :
    1. Le nom de la variable symbolique
    2. Sa taille (ici 1 octet, utile pour représenter, par exemple, une variable de type char)

J’insiste : la taille spĂ©cifiĂ©e lors de la crĂ©ation de variables symboliques BVS ou concrĂštes BVV avec claripy est en BITS !

Ici la variable symbolique que nous utilisons est dĂ©signĂ©e par arg_symb ( ou argv du point de vue de claripy) et elle a une taille de 8 octets (64 bits). Nous l’utilisons lors du hook de atoi afin de la retourner ( via rax).

Désormais, angr sait que la valeur de retour est symbolique, la comparaison avec 0xdeadbeef peut ainsi échouer ou réussir ici :

Si on voulait optimiser le script, on aurait pu seulement retourner une valeur de 32 bits via eax étant donné que seule les 4 premiers octets de rax sont utilisés pour la comparaison.

Mais attends, tu nous as pas dit pourquoi il y a un 36000 mille messages de warning đŸ˜”â€đŸ’«?

En fait les diffĂ©rents warnings que l’on a pas traitĂ©s concernent des zones mĂ©moire que nous n’avons pas initialisĂ©es et qui sont manipulĂ©es par le programme. Par exemple, les premiĂšres instructions de la fonction main sont :

1
2
0000000000401122   push    rbp
0000000000401123   mov     rbp, rsp

Ainsi, dÚs la premiÚre instruction, angr (qui exécute symboliquement les instructions) doit effectuer push rbp.

Ainsi, deux choses sont à réaliser :

  1. Récupérer la valeur de rbp
  2. La mettre sur la stack

Le soucis est qu’angr ne sait pas ce que vaut rbp ni Ă  quelle zone mĂ©moire est la stack. En effet, nous n’avons spĂ©cifiĂ© aucune de ces valeurs, elles sont donc considĂ©rĂ©es comme non contraintes par dĂ©faut !

Ce que fait angr en nous affichant un message de ce type :

1
WARNING | Filling register rbp with 8 unconstrained bytes referenced from 0x401122 (main+0x0 in exemple_1 (0x401122))

est qu’il “remplit” rbp avec des valeurs sans contraintes afin qu’il puisse “exĂ©cuter” l’instruction push rbp en ayant une valeur (quelconque) Ă  mettre dans la stack. Idem pour l’adresse de la stack.

Si on voulait absolument donner une valeur Ă  rsp et rbp, on pourrait faire quelque chose de la sorte :

1
2
3
state_0 = p.factory.blank_state(addr= 0x401122)
state_0.regs.rsp = 0x7fffff0000
state_0.regs.rbp = 0x7fffff0008

Cela peut ĂȘtre utile lorsque l’on veut absolument avoir les mĂȘmes adresses mĂ©moire que celles qui sont affichĂ©es par un debugger lorsque l’on rĂ©alise une analyse / exĂ©cution dynamique.

RĂ©cupĂ©ration de l’entrĂ©e valide

On a pu faire en sorte qu’angr atteigne l’adresse du bloc oĂč la comparaison est rĂ©alisĂ©e correctement. NĂ©anmoins, angr ne nous as pas dit avec quelle entrĂ©e valide il a pu en arriver lĂ . Je vous rassure, on y est presque 😅!

Pour rappel, le simulation manager sm a pu avoir au moins un état found. Il suffit désormais de :

  • se placer (ou switcher) dans le contexte de l’état qui est arrivĂ© dans le bloc “en vert” ( il s’agit du seul Ă©tat prĂ©sent dans sm.found)
  • faire appel au solveur afin qu’il nous retourne une valeur de arg_symb qui a permis Ă  cet Ă©tat d’arriver dans le bloc qui nous intĂ©resse
  • afficher ladite valeur !

Voici le script final :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import angr
import claripy

# Variable symbolique de 64 bits
arg_symb = claripy.BVS('argv[1]', 8*8)

def hook_atoi(state):
	print("[i] La fonction atoi a été hookée")
	# On retourne la variable symbolique via rax
	state.regs.rax = arg_symb

p = angr.Project("./exemple_1")
state_0 = p.factory.blank_state(addr= 0x401122)

sm = p.factory.simulation_manager(state_0)
p.hook(0x40113f, hook_atoi,5)

print("[+] Exploration en Cours ....")
sm.explore( find = 0x401150, avoid = 0x401157)
print("[+] Arrivé à destination")

if len(sm.found) == 0:
	print("[-] Il n'a pas été possible d'atteindre la destination")
	quit()
else :
	print("[+] DĂ©termination de l'input valide")

	# Récupération de l'état qui est arrivé dans le bon bloc
	found = sm.found[0]
	# Appel au solveur pour retourner au moins une solution
	res = found.solver.eval(arg_symb)
	print("[+] Le bon input est : ",hex(res))

En exécutant ce script, on obtient bien le bon input !

1
2
3
4
5
[+] Exploration en Cours ....
[i] La fonction atoi a été hookée  
[+] Arrivé à destination  
[+] DĂ©termination de l'input valide  
[+] Le bon input est :  0xdeadbeef

Maintenant, passons Ă  l’explication des diffĂ©rentes Ă©tapes :

  1. Tout d’abord, on vĂ©rifie qu’il y a au moins un Ă©tat qui est arrivĂ© Ă  destination (bloc vert), sinon, on quitte
  2. Si tout est ok, on rĂ©cupĂšre le premier Ă©tat de type found (ici il n’y en a qu’un mais parfois il peut y en avoir plusieurs)
  3. On appelle le solveur de notre Ă©tat found via found.solver.eval. Les deux paramĂštres possibles sont :
    1. La variable symbolique dont on veut au moins une valeur possible
    2. Le format du rĂ©sultat final (facultatif), par exemple : cast_to=bytes afin d’avoir des bytes en sortie. En ce qui nous concerne, un entier fera l’affaire.
  4. Affichage du bon input

Comment le solveur arrive-t-il Ă  trouver le bon input ?

Etant donnĂ© que nous avons vu comment, globalement, fonctionne un solveur, il va ĂȘtre plus simple de comprendre comment angr rĂ©ussit Ă  trouver le bon input.

Tout d’abord, rappelez vous, nous avons dĂ©clarĂ© la variable symbolique reprĂ©sentant l’input de cette maniĂšre : arg_symb = claripy.BVS('argv[1]', 8*8). A ce stade arg_symb ne dispose d’aucune contrainte et peut donc valoir n’importe quelle valeur de 64 bits.

Toutefois, lors de l’exĂ©cution du programme, cette variable symbolique va ĂȘtre soumise Ă  une ou plusieurs contraintes qui seront automatiquement ajoutĂ©es par angr.

Par exemple, lors du retour de atoi, le registre rax contient notre variable symbolique arg_symb. Or une comparaison est immédiatement réalisée ensuite :

Ainsi, pour que l’instruction jnz ne soit pas exĂ©cutĂ©e et que l’on aille directement dans le bloc “vert”, il est nĂ©cessaire que la condition suivante soit vĂ©rifiĂ©e : eax == 0xdeadbeef. Or eax contient les 32 bits de poids faible de arg_symb.

De cette maniĂšre, angr ajoute automatiquement une contrainte du type arg_symb[32:64] == 0xdeadbeef.

Comme la comparaison est effectuĂ©e sur 32 bits via eax, il n’y a aucune contrainte sur les 32 bits de poids fort de rax.

S’exercer

Voici un petit programme assez simple qui prend un chaĂźne de caractĂšre hexadĂ©cimale et vĂ©rifie s’il s’agit de la bonne clĂ©.

Quelques différences avec le programme que nous avons étudié sont à noter :

  • L’input n’est plus rĂ©cupĂ©rĂ©e via argv
  • Plusieurs fonctions de la libc ont Ă©tĂ© ajoutĂ©es ( les hooker ?)

L’objectif n’est pas de devenir un pro de angr avec ce challenge mais de savoir utiliser les fonctionnalitĂ©s de base de angr.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include "stdio.h"
#include "stdlib.h"
#include "string.h"

unsigned long long hash(unsigned long long arg)
{
  unsigned long long result = 0;
  unsigned char x =0;
  unsigned long long temp =0;
  
  unsigned long long key =0xef9e8bd8f3afe9eb;
  for (int i =0;i<8;i++)
  {
    x = (arg >> (i*8)) &0xff;
    switch(x % 2) 
    {
            case 0:
                temp = 0xff;
                break;
            case 1:
                temp = x ^ (unsigned char)((key >> (i*8)) &0xff);
                break;

      }

      result = result | (temp << (i*8));
  }
    return result;
}
int main() 
{
    char key_buffer[16] = {0};
    puts("Give me the key in hexadecimal : ");
    read(0,key_buffer,16);
    unsigned long long arg = strtoull(key_buffer,NULL,16);
    if (hash(arg) == 0xdeadbeefcafebabe) 
    {
      puts("Win !");
      return 1337;
    } 
    else 
    {
      puts("Loose !");
      return -1;
    }
}

Pour le compiler : gcc -no-pie main.c -o exe.

Résumé

Nous avons pu voir ensemble au cours de ce chapitre plusieurs points :

  • Rappel sur ce qu’est l’analyse statique et l’analyse symbolique
  • Les Ă©tats sont des contextes d’exĂ©cution symbolique qui permettent de pouvoir parcourir plusieurs chemin lors d’une mĂȘme exĂ©cution (symbolique). Les Ă©tats diffĂšrent principalement par les contraintes appliquĂ©es Ă  leurs variables
  • Les contraintes permettent de restreindre la valeur que peut avoir une variable symbolique
  • Le SMT solveur permet de prouver qu’une Ă©quation dispose d’une solution, plusieurs ou aucune. Ces Ă©quations sont rĂ©alisĂ©es Ă  partir des contraintes sur les variables
This post is licensed under CC BY 4.0 by the author.