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 đ€:
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
vaut0x401150
- LâĂ©tat a la contrainte :
[rbp+var_4] == 0xDEADBEEF
- Le registre
state_rouge
:- Le registre
RIP
vaut0x401157
- LâĂ©tat a la contrainte :
[rbp+var_4] != 0xDEADBEEF
- Le registre
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 sureax_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 estvar < 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 :
p = angr.Project("./exemple_1")
permet de crĂ©er un projet âangrâ en spĂ©cifiant le programme que lâon souhaite utiliserstate_0 = p.factory.blank_state(addr= 0x401122)
: on crĂ©e un Ă©tat initial âvideâ qui dĂ©marre Ă la premiĂšre instruction dumain
Ă lâadresse0x401122
.- 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â. - Ensuite, on demande au simulation_manager dâatteindre le bloc âvertâ ( la comparaison avec
0xdeadbeef
est réussie) en spécifiantfind
et dâĂ©viter le bloc en rouge ( la comparaison a Ă©chouĂ©) en spĂ©cifiantavoid
.
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 :
- 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 ;
- 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 ;
- 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 ;
- 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 ;
- 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 :
- Appeler la fonction
hook
de angr sur le projet avec trois arguments : - 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 avecprint
en python.
Dans le code exécuté, angr va agir de la sorte :
- 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âinstructioncall _atoi
- Le hook Python
hook_atoi
est alors exĂ©cutĂ© au lieu de lâinstruction - 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 plusieursfound
, 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 typeavoid
, 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 :- Sa valeur
- 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 :- Le nom de la variable symbolique
- 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ĂštesBVV
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 derax
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 :
- Récupérer la valeur de
rbp
- 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 :
- Tout dâabord, on vĂ©rifie quâil y a au moins un Ă©tat qui est arrivĂ© Ă destination (bloc vert), sinon, on quitte
- 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) - On appelle le solveur de notre Ă©tat
found
viafound.solver.eval
. Les deux paramĂštres possibles sont :- La variable symbolique dont on veut au moins une valeur possible
- 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.
- 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 derax
.
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