Post

Partie 3 - Fonctionnalités de base (bis)

Utiliser les hooks comme un pro đŸ’Ș

Si vous n’avez pas la mĂ©moire trop courte, vous devriez vous rappeler de la maniĂšre dont on avait utilisĂ© un hook. Pour rappel on avait fait un truc du genre :

1
p.hook(0x40113f, hook_atoi,5)

Cela permettait de hooker l’instruction call atoi (de taille 5 octets) afin que l’on puisse en modifier le comportement via Python. Cependant il y a d’autres cas d’usage dans lesquels on peut utiliser les hooks.

Par exemple, vous avez dĂ» remarquer que lorsqu’un programme utilise puts ou printf, on ne voit jamais la chaĂźne de caractĂšre affichĂ©e directement. Essayons de modifier ce comportement avec un hook grĂące aux SimProcedures afin de toujours afficher le contenu de puts.

DĂ©finir son propre hook via SimProcedure

Les SimProcedures permettent de faire du hooking avancé en ayant, par exemple, facilement accÚs aux arguments de la fonction hookée.

Faisons un simple programme C qui réalise des appels successifs à puts :

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>  
  
int main()  
{  
     puts("Salut");  
     puts("tout");  
     puts("le");  
     puts("monde !");  
     return 0;  
}

Si on exĂ©cute le programme avec angr jusqu’au return, on ne verra pas les chaĂźnes de caractĂšres dans notre terminal ( sauf si on trifouille dans state.posix.dumps(sys.stdout.fileno()) pour avoir accĂšs Ă  l’output).

Avant de balancer tout le code en question, analysons ensemble le bout de code de hook afin de bien le comprendre :

1
2
3
4
5
class MyPuts(angr.SimProcedure):  
     def run(self, addr_str):  
          #(...)

p.hook_symbol('puts', MyPuts())

Dans les arguments de hook et hook_symbol, lorsqu’une classe dĂ©rivĂ©e de SimProcedure est utilisĂ©e, il faut absolument mettre les parenthĂšses sinon angr risque d’ĂȘtre 
 angry 🙃

Pour pouvoir utiliser les SimProcedures, il faut toujours dĂ©clarer sa classe dĂ©rivĂ©e de la sorte : MaClasse(angr.SimProcedure). Cela permet par la suite d’avoir accĂšs Ă  certaines fonctions prĂ©-Ă©tablies telle que run qui est la fonction exĂ©cutĂ©e lorsque notre hook sera dĂ©clenchĂ©.

Maintenant, il va bien falloir remplir cette fonction run, qu’allons-nous mettre ?

Bah c’est simple on a qu’à faire print(addr_str) 🙄

Bien tentĂ© mais cela ne fonctionnera pas ! En fait il faut voir l’argument addr_str comme l’argument de puts en C. Or l’argument de puts est une chaĂźne de caractĂšre, plus prĂ©cisĂ©ment, un pointeur vers une zone mĂ©moire contenant des caractĂšres dont la fin est signalĂ©e par un octet nul.

Il va donc falloir bricoler un peu pour rĂ©cupĂ©rer la chaĂźne de caractĂšres Ă  l’adresse addr_str. Rien de bien mĂ©chant, une boucle for et le tour est jouĂ© :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyPuts(angr.SimProcedure):  
    def run(self, addr_str):  
        string = ""  
        # Récupération de la chaßne de caractÚre
        # en lisant octet par octet  
        for i in range(1000) :  
              val = self.state.memory.load(addr_str+i,1).concrete_value  
              # Fin de la chaĂźne  
              if val == 0 :  
                 break

              string += chr(val)  
        # Affichage de la chaĂźne  
        print(string)  
  
        return 0

Quelques remarques :

  • On a accĂšs Ă  l’état courant via self.state
  • On utilise le fameux state.memory.load pour lire en mĂ©moire et rĂ©cupĂ©rer les bytes de donnĂ©es Ă  l’adresse addr_str[i] dans la boucle
  • Lorsque l’on arrive Ă  l’octet nul, c’est la fin de la chaĂźne de caractĂšres
  • range(1000) est utilisĂ© comme garde-fou pour ne pas tourner en rond indĂ©finiment

Le script final est celui-ci (attention à modifier l’adresse du return par celle de votre programme) :

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
import angr  

# Initialisation du projet, Ă©tat initial ...
p = angr.Project("./exe")  
main = p.loader.find_symbol("main")  
state_0 = p.factory.blank_state(addr= main.rebased_addr)  
sm = p.factory.simulation_manager(state_0)  
  
class MyPuts(angr.SimProcedure):  
     def run(self, addr_str):  
          string = ""  
          # Récupération de la chaßne de caractÚre  
          for i in range(1000) :  
               val = self.state.memory.load(addr_str+i,1).concrete_value  
               # Fin de la chaĂźne  
               if val == 0 :  
                         break  
                      
               string += chr(val)  
          # Affichage de la chaĂźne  
          print(string)  
  
          return 0  
       
p.hook_symbol('puts', MyPuts())  
  
# Adresse du 'return'  
sm.explore( find = 0x401193)

En exécutant ce script on voit bien dans le terminal lex chaßnes de caractÚres attendues :

1
2
3
4
Salut  
tout  
le  
monde !

Définir un hook avec un décorateur

Il est possible d’utiliser un dĂ©corateur Python pour dĂ©finir un hook. Par exemple, pour le hook que l’on a dĂ©jĂ  vu :

1
p.hook(0x40113f, hook_atoi,5)

Il est possible de faire :

1
2
3
@project.hook(0x40113f, length=5)
def hook_atoi(state):
	# (...)

Cela permet de dĂ©finir le hook en mĂȘme temps que la fonction associĂ©e. C’est un peu plus joli et c’est plus lisible quand on lit le script.

Il est possible de dĂ©finir plusieurs hooks en utilisant plusieurs dĂ©corateurs autour du hook associĂ©. Cela est utile lorsqu’une fonction hookĂ©e est appelĂ©e Ă  de maintes reprises dans le programme :

1
2
3
4
@project.hook(0x40113f, length=5)
@project.hook(0x409795, length=5)
def hook_atoi(state):
	# (...)

Une histoire de symboles

Hooker des fonctions de la libc est chose aisée car :

  • soit angr le fait dĂ©jĂ 
  • soit on a accĂšs au symbole (et donc on peut rĂ©cupĂ©rer l’adresse de la fonction via son nom) que le programme soit strippĂ© ou non

Toutefois, lorsque le programme est strippĂ© (les symboles des fonctions internes sont supprimĂ©s), on a plus accĂšs au nom des fonctions internes. MĂȘme le main n’est plus accessible directement via son symbole avec main = p.loader.find_symbol("main") 😱.

Dans une telle situation, lorsque l’on veut hooker une fonction fun_prgrm du programme, on a deux maniùres de faire :

  • Soit on sait exactement oĂč est appelĂ©e cette fonction et il suffit de hooker toutes les instructions du type : call fun_prgrm
  • Soit on ne sait pas oĂč cela est fait et il va falloir hooker toute la fonction

On a déjà été confronté au premier cas, et on sait gérer. Mais comment faire alors si on se retrouve dans le second cas ?

Dans le second cas, il y a deux maniĂšres de faire :

  1. Utiliser un hook classique : c’est laborieux car il faut calculer la taille de la fonction, sortir de la fonction nous mĂȘme en modifiant rip avec la valeur idoine đŸ„± 

  2. Utiliser une classe dĂ©rivĂ©e de SimProcedure : il s’agit de la mĂ©thode la plus simple car on n’aura pas besoin de calculer la taille de la fonction ni mĂȘme besoin de retourner nous-mĂȘme ; angr le fait dĂ©jĂ  pour nous

Utiliser un hook classique

La premiĂšre mĂ©thode peut ĂȘtre intĂ©ressante dans le cas oĂč on veut modifier le comportement d’un gros bloc de code qui n’est pas une fonction appelĂ©e. Par exemple, si vous arrivez Ă  identifier un bout de code qui fait de la dĂ©tection anti-debug, sleep ou qui n’est pas trĂšs intĂ©ressant, vous pouvez simplement le hooker avec une fonction qui ne fait rien ( cela revient Ă  “NOPer” tout le bout de code).

Exemple :

1
2
3
4
# NOPer plusieurs instructions 
@p.hook(adresse_de_depart, length=taille_totale_des_instructions)
def nop(state):
	print("NOP")

Utiliser une classe dérivée de SimProcedure

La condition pour utiliser cette mĂ©thode est seulement de savoir oĂč se situe la fonction que l’on souhaite hooker (appelons-la fun_prgrm). Ensuite on utilise une classe dĂ©rivĂ©e de SimProcedure et cette derniĂšre se chargera toute seule de retourner comme il faut.

Par exemple, si fun_prgrm est située à 0x401149, on peut faire :

1
2
3
4
5
6
7
class MyFunc(angr.SimProcedure):
	def run(self):
		print("'fun_prgrm' hookée")
		# (...)
		return

p.hook(0x401149, MyFunc())

Les limites d’angr

AprĂšs avoir vu les principales fonctionnalitĂ©s qu’offre angr, vous vous dites sĂ»rement que vous allez pouvoir enfin dĂ©molir tous les crackmes et reverse bien plus aisĂ©ment n’importe quel programme. Eh bien malheureusement ce n’est pas aussi simple que cela car angr a tout de mĂȘme pas mal de limitation đŸ«Ł 


Moteur d’exĂ©cution codĂ© en Python 🐍

L’une des faiblesse majeure d’angr face Ă  d’autres outils d’exĂ©cution symbolique tels que Triton ou Binsec est qu’il est codĂ© totalement en Python.

Ainsi, mĂȘme le moteur d’exĂ©cution est codĂ© en Python contrairement Ă  d’autres outils dont le Python est simplement un wrapper pour en faciliter l’utilisation.

Python c’est chouette, c’est simple mais qu’est-ce que c’est lent ^^’ !

L’explosion de chemin đŸ’„

On en a briĂšvement parlĂ© mais il s’agit d’un des plus gros problĂšmes de l’exĂ©cution symbolique. Cela ne concerne pas seulement angr mais n’importe quel moteur d’exĂ©cution symbolique.

Prenons un exemple concret pour voir comment va rĂ©agir angr lors d’une explosion de chemin.

Voici le code C que nous allons utiliser :

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
#include <stdio.h>

int un()
{
	return 1;
}
int zero()
{
	return 0;
}

int main()
{

	unsigned char data[16];
	printf("Enter 16 bytes of data: ");
	fread(data, sizeof(unsigned char), 16, stdin);
	
	for (int i = 0; i < 16; i++)
	{
		for (int j = 7; j >= 0; j--)
		{
			if ((data[i] >> j) & 1)
			{
				un();
			} 
			else
			{
				zero();
			}
		}
	}
	  
	return 0;
}

Il s’agit d’un code assez simple, son fonctionnement devrait vous ĂȘtre facile Ă  comprendre.

Compilons-le avec gcc main.c -o exe. Maintenant, lançons angr sur le programme en lui donnant une adresse inatteignable lors de l’exploration :

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
import angr
import IPython
import claripy
import os
import signal

# Code pour pouvoir ouvrir IPython
# avec Ctrl+C
def kill():
	current_pid = os.getpid()
	os.kill(current_pid, signal.SIGTERM)

def sigint_handler(signum, frame):
	print('Kill with : kill()')
	IPython.embed()

signal.signal(signal.SIGINT, sigint_handler)

p = angr.Project("./exe")
flag = claripy.BVS('flag', 16*8)

# On utilise 'rebased_addr' car le programme est compilé
# avec la protection PIE
main = p.loader.find_symbol("main")
state_0 = p.factory.blank_state(addr= main.rebased_addr,stdin=flag)
sm = p.factory.simulation_manager(state_0)

# Adresse inatteignable
print("Exploration en cours depuis le main")
sm.explore( find = 0xdeadbeef)

Plusieurs remarques :

  • Le programme que l’on vient de compiler n’est pas strippĂ© donc on a accĂšs Ă  tous les symboles, dont le symbole main via p.loader.find_symbol("main")
  • Comme on a compilĂ© le programme sans l’option -no-pie, le main est Ă  l’offset 0x11a7. Mais lors de l’exĂ©cution, il sera exĂ©cutĂ© Ă  une adresse alĂ©atoire du type : adresse_de_base_aleatoire + 0x11a7, par exemple : 0x00005555555551af. Ainsi, on utilise main.rebased_addr pour ne pas avoir Ă  se prĂ©occuper du PIE
  • On insĂšre le bout de code permettant d’ouvrir IPython avec Ctrl+C, cela nous sera utile !

En lançant le script Python, on constate qu’il consomme de plus en plus de mĂ©moire. Initialement on a : Puis aprĂšs quelques secondes / minutes d’exĂ©cution :

On constate de que le script consomme Ă©normĂ©ment de mĂ©moire et comme on a pas envie que le PC finisse par freeze đŸ„¶, on utilise l’arme fatale du Ctrl+C đŸ”«.

Un terminal IPython s’ouvre alors et on peut analyser ce qu’il se passe. Essayons de voir ce que contient le simulation manager qui, pour rappel, gĂšre tous les Ă©tats.

On voit que 1410 Ă©tats sont actifs, ce qui est Ă©norme ! DĂ©jĂ  quand on en a plus d’une centaine faut commencer Ă  se poser des questions, mais lĂ  c’est beaucoup trop !

Je vous conseille d’en finir avec le script en saisissant kill() dans le terminal IPython pour libĂ©rer les gigas de RAM occupĂ©es par le script.

Cet exemple vous permet de comprendre la principale limite de l’exĂ©cution symbolique Ă  travers l’explosion de chemins.

Les bibliothĂšques externes

Une autre faiblesse d’angr est qu’il gĂšre mal les bibliothĂšques un peu complexes. Autant pour la libc certaines fonctions comme printf, read etc., ça, il sait faire. Autant des fonctions comme celles de l’API Windows, il galĂšre davantage đŸ€•.

De ce fait, lorsque l’on analyse un programme Windows avec angr (par exemple, un malware), il va falloir hooker pas mal de fonction pour que le script n’aille pas dans les choux.

Cela ne veut pas dire qu’angr ne peut pas s’exĂ©cuter sur un programme Windows, c’est juste qu’il va falloir faire plus attention et faire plus d’analyse sur le code en amont avant d’entamer un script avec angr.

Je vous rassure, angr ne sert pas QUE pour la rĂ©solution de crackme. Il peut ĂȘtre utilisĂ© pour dĂ©sobfusquer certains programmes. On peut mentionner Ă  ce titre la dĂ©sobfuscation des switch tables pour VM Protect.

Quand utiliser angr ?

Pour conclure, je vous propose de lister les cas dans lesquels il peut ĂȘtre intĂ©ressant/facile d’utiliser angr et, a contrario, les cas dans lesquels ce n’est pas forcĂ©ment la meilleure idĂ©e.

Evidemment, c’est une liste assez subjective et ce n’est pas parce que l’on a classĂ© un cas dans ceux oĂč il faut Ă©viter d’utiliser angr que c’est une vĂ©ritĂ© absolu.

Dans l’idĂ©al il s’agit de regarder au cas par cas l’objectif attendu et la maniĂšre dont est conçu le binaire ( programme, firmware 
) Ă  analyser.

Les cas favorables ✅

  • Un crackme qui utilise un algo assez linĂ©aire avec des opĂ©rations simples (xor, add,sub 
)
  • Un programme Linux : oui angr a un peu plus de mal avec les programmes Windows ( notamment les bibliothĂšques utilisĂ©es)
  • Un bout d’assembleur : cela peut ĂȘtre une fonction ou simplement un bout de code dĂ©sassemblĂ© dont vous souhaitez comprendre le fonctionnment. angr permet en effet de charger directement de l’assembleur et de l’exĂ©cuter.
  • DĂ©sobfuscation classique : sachez qu’il est possible de dĂ©sobfusquer de maniĂšre efficace un programme avec angr. Cela demandera peut-ĂȘtre des notions avancĂ©es mais angr dispose d’un panel d’outils qui, utilisĂ©s ensemble, peuvent permettre de dĂ©sobfusquer un programme. Cela Ă©tant, on parle ici d’obfuscation classique (switch table linĂ©aire, prĂ©dicats opaques, MBA 
) et pas d’obfuscation poussĂ©e (switch tables non linĂ©aires, nĂ©cessitĂ© d’exĂ©cuter en dynamique 
)

Les cas dĂ©favorables ⚠

  • Les programmes Windows : cf la raison plus haut. Evidemment cela ne veut pas dire qu’il n’est pas possible d’utiliser angr sur un malware (et c’est parfois utile d’ailleurs), mais c’est juste qu’il va falloir faire attention Ă  la maniĂšre dont vous configurez angr.
  • Un programme qui fait trop souvent appel Ă  des fonctions externes : typiquement les programmes Windows qui font 1000 appels aux fonctions de l’API Windows
  • Programmes fortement obfusquĂ©s avec de l’obfuscation trĂšs poussĂ©e
  • Programmes qui utilisent de la crypto state of the art ( c’est pas demain qu’angr va casser AES :) )
This post is licensed under CC BY 4.0 by the author.