Angry Keygen

Le Midnight CTF est une compétition type Capture the Flag qui s’est déroulé à l’ESNA (Ecole Supérieur du Numérique Appliqué) de Bretagne. L’article suivant présente la résolution du challenge de rétro-ingénierie Angry Keygen.

Sommaire

  • Etats des lieux
  • Angr
  • Annexes
  • Références

Etats des lieux

D’après la commande file le binaire est un exécutable conçu pour linux (format ELF) et lié statiquement avec la libc. Celui-ci est “not stripped” c’est-à-dire qu’il contient des symboles.

$ file angry_keygen
angry_keygen: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=e9241b9cb6302be38f6ef1be6c20d70fca7ace02, for GNU/Linux 3.2.0, not stripped

Le binaire affiche sa version,une bannière puis demande un couple nom d’utilisateur/mot de passe. L’énoncé précise qu’il faut trouver un mot de passe valide pour l’utilisateur Patrick. Le binaire attend un certain nombre de caractères pour le mot de passe entré. Dans le cas où le nombre de caractère est insuffisant, il quitte en erreur mais sans afficher de message.

$ ./angry_keygen
 ------------- AngryKeygen v1.8.2 -------------
|                                              |
|   The solution to keep your passwords safe   |
|                  by HellCorp                 |
|                                              |
 ----------------------------------------------

Username : Patrick
Password : eojkreoofpkeopfelmzkfroepzkpfoedsflkmds
Wrong password !

Une rapide analyse avec IDA permet de voir que c’est la fonction login qui s’occupe de vérifier le mot de passe.

La fonction login login est brouillée (obfuscated), un grand nombre d’opérations opaques sont réalisées à partir de morceaux du nom d’utilisateur et du mot de passe. Une des premières opérations vérifie que le mot de passe commence par ‘MCTF’, nous sommes sur le bon chemin. Voyons comment on peut utiliser Angr pour résoudre ce challenge.

Angr

Angr 1 est une sorte de “couteau-suisse” pour l’analyse de binaire. Il est destiné à être utilisé depuis une console python.

Tout d’abord on charge le binaire. Le sous-module angr en charge de cela est nommé CLE (CLE Load Everythings) il permet aussi de charger les dépendances du binaire telles que la libc.

project = angr.Project("./angry_keygen")

Ensuite on va démarrer une simulation à partir de l’adresse de la fonction login. L’état du programme (registres, mémoire, …) à un instant t est représenté par un objet de type SimState. La fonction call_state permet d’instancier un SimState construit de manière à appeler login avec les bons arguments.

Angr travaille principalement avec des vecteurs de bits (BV) car les entiers python n’ont pas exactement la sémantique que les entiers traités par un CPU.

Une des forces de Angr est de pouvoir travailler avec des valeurs non concrètes autrement appelées des variables symboliques (BVS). Dans notre cas on définit le mot de passe comme étant une variable symbolique de taille 0x25 * 8 bits. Le reste de la mémoire et des registres est initialisé avec des valeures nulles grâce aux options ZERO_FILL_UNCONSTRAINED_MEMORY et ZERO_FILL_UNCONSTRAINED_REGISTERS.

login_address = 0x04018C0
    
username = claripy.BVV(b'Patrick\x00')
password = claripy.BVS('password', 0x25*8)

ptr_username = angr.PointerWrapper(username,buffer=True)
ptr_password = angr.PointerWrapper(password,buffer=True)

initial_state = project.factory.call_state(login_address,ptr_username,ptr_password,add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})

Pour la suite on va créer gestionnaire de simulation (Simulation Manager), cet objet est en charge de réaliser l’exécution du programme à partir d’un état donné. Chaque étape de l’exécution va générer un ou plusieurs états dans le cas où angr rencontre un branchement qu’il ne peut pas prédire. Le gestionnaire de simulation va pouvoir gérer l’avancement de chacun de ces états en paralèlle.

simulation = project.factory.simgr(initial_state)

On peut demander au gestionnaire de simulation d’explorer les états possibles jusqu’à trouver un certain état. On peut aussi préciser des états à éviter. Dans notre cas on cherche à atteindre le puts qui affiche le message suivant "You successfully logged in !" et éviter le printf qui affiche "Wrong password !".

fail = [0x0040190F, 0x0040172D]
win = 0x00403D77

simulation.explore(find=win, avoid=fail)

La simulation s’arrête lorsque Angr n’a plus d’état à explorer ou qu’il a trouvé celui qui nous intéresse.

if simulation.found:
	solution_state = simulation.found[0]

L’exécution symbolique permet de déterminer à chaque étape les conditions nécessaires pour prendre un branchement ou non. Chaque branchement ajoute des contraintes sur les variables symboliques, ces contraintes peuvent être vues comme un système d’équations mathématiques. En résolvant le système d’équations on peut obtenir des valeurs concrètes qui permettent d’atteindre l’état recherché. Angr intègre un solveur nommé claripy pour résoudre les systèmes d’équations.

	solution0 = solution_state.solver.eval(username,cast_to=bytes).decode()
	solution1 = solution_state.solver.eval(password,cast_to=bytes).decode()
	solution = ' '.join([ solution0, solution1 ])
	
	print(solution)

On obtiens ainsi le flag :

$ python3 solve.py ./angry_keygen
WARNING  | 2023-06-27 19:34:50,849 | angr.calling_conventions | Guessing call prototype. Please specify prototype.
Patrick MCTF{angr_f4ct0ry_st4t3s_4re_th3_k3y}

Annexe

import angr
import claripy
import sys

fail = [0x0040190F, 0x0040172D]
win = 0x00403D77

def main(argv):
    path_to_binary = argv[1]
    project = angr.Project(path_to_binary)

    login_address = 0x04018C0
    
    username = claripy.BVV(b'Patrick\x00')
    password = claripy.BVS('password', 0x25*8)
    
    ptr_username = angr.PointerWrapper(username,buffer=True)
    ptr_password = angr.PointerWrapper(password,buffer=True)
    
    initial_state = project.factory.call_state(login_address,ptr_username,ptr_password,add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})
    
    simulation = project.factory.simgr(initial_state)

    simulation.explore(find=win, avoid=fail)

    if simulation.found:
        solution_state = simulation.found[0]

        # Get the values the memory addresses should store
        solution0 = solution_state.solver.eval(username,cast_to=bytes).decode()
        solution1 = solution_state.solver.eval(password,cast_to=bytes).decode()
        solution = ' '.join([ solution0, solution1 ])

        print(solution)
    else:
        raise Exception('Could not find the solution')

if __name__ == '__main__':
    main(sys.argv)

Références