Revers(ibl)e Engineering [1/2]

404CTF{e9d749db81e9f8caf745a5547da13579}

Catégorie: Reverse Difficulté: medium Flag: -

Challenge

Description


Après une année éprouvante marquée par les compétitions, vous décidez de rentrer dans votre village natal. C'est avec beaucoup d'émotion que vous apercevez le dojo de votre enfance et décidez de vous y rendre. Votre ancienne sensei vous y attend, le sourire aux lèvres.

"La clairvoyance est l'arme la plus redoutable du combattant. Anticiper chaque mouvement avant qu'il ne soit lancé, voilà la véritable maîtrise du combat. Relève mon défi et prouve ta valeur."


Récupérer une archive zip avec netcat contenant un crackme et un token, renvoyer le token avec la solution du crackme à un deuxième serveur, recevoir un flag... Facile. Petit détail : vous avez vingt secondes pour faire tout ça, et le binaire change à chaque essai.

Connexion : nc challenges.404ctf.fr 31998 > chall.zip nc challenges.404ctf.fr 31999

Préparation

On va commencer par construire, en Python, une classe pour faciliter la réception et l'étude des binaires. On fera évoluer ce code au fil de notre progression

import socket
import os
from io import BytesIO
from zipfile import ZipFile


HOST = 'challenges.404ctf.fr'
PORT_BINARY = 31998
PORT_SOLVE = 31999

class Binary:
    FOLDER = 'out'
    
    @staticmethod
    def fetch() -> 'Binary':
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client.connect((HOST, PORT_BINARY))
        archive = b''
        while True:
            data = client.recv(4096)
            if len(data) == 0:
                break
            archive += data
        archive = ZipFile(BytesIO(archive))
        crackme = archive.read('crackme.bin')
        token = archive.read('token.txt').decode()
        return Binary(crackme, token)

    @staticmethod
    def open(filename):
        token = filename.split('_')[1][:-4]
        with open(os.path.join(Binary.FOLDER, filename), 'rb') as f:
            crackme = f.read()
        return Binary(crackme, token)

    def __init__(self, crackme: bytes, token: str) -> None:
        self.crackme = crackme
        self.token = token
        self.filename = f'crackme_{self.token}.bin'
        self.filepath = os.path.join(self.FOLDER, self.filename)
    
    def save(self):
        if not os.path.exists(self.FOLDER):
            os.mkdir(self.FOLDER)
        with open(self.filepath, 'wb') as f:
            f.write(self.crackme)
        os.system(f'chmod +x {self.filepath}')
    
    def solve(self) -> str:
        # A compléter
        pass


def main() -> None:
    binary = Binary.fetch()
    print(f'Token: {binary.token}')
    


if __name__ == '__main__':
    main()

Ce script permet de récupérer l'archive depuis le serveur, extraire le crackme et le token associé, puis de les enregistrer localement. On peut également ouvrir un crackme déjà enregistré.

La fonction solve sera implémentée plus tard et retournera le mot de passe qui résout le challenge


Analyse des binaires reçus

On peut s'amuser à enregistrer 2-3 crackme et les décompiler pour comparer leurs différences.

int main(int argc,char **argv) {
  int exit_code;
  int is_different;
  size_t size_password;
  void *encrypted_password;
  undefined8 key_part_1;
  undefined8 key_part_2;
  int size;
  
  if (argc < 2) {
    puts("J'ai besoin d'un argument!");
    exit_code = 1;
  }
  else {
    size_password = strlen(argv[1]);
    size = (int)size_password;
    if (size == 16) {
      key_part_1 = 0xf2861cf7978787b5;
      key_part_2 = 0x1c940c8696a668b4;
      encrypted_password = encrypt((long)argv[1]);
      is_different = memcmp(encrypted_password,&key_part_1,16);
      if (is_different == 0) {
        puts("GG!");
        exit_code = 0;
      }
      else {
        puts("Dommage... Essaie encore!");
        exit_code = 1;
      }
    }
    else {
      puts("L'argument doit comporter 16 caractères.");
      exit_code = 1;
    }
  }
  return exit_code;
}

La structure est toujours la même :

  • On doit rentrer un mot de passe en argument.

  • Le mot de passe doit faire 16 caractères.

Mais 2 choses changent constamment :

  • les opérations faites dans la fonction encrypt qui est utilisée sur notre mot de passe

  • la valeur de key à laquelle le résultat de notre mot de passe chiffré est comparé

On ne va pas s'amuser à reverse la fonction encrypt mais on peut voir sa sortie en exécutant un des binaires avec GDB (ou autres débugueurs) et en plaçant un breakpoint juste avant la comparaison. Le breakpoint à placer est sur memcmp@plt (je précise "plt" car nous voulons break avant de rentrer dans la résolution de l'adresse de la vraie fonction memcmp et ainsi pouvoir regarder la valeur des paramètres situés dans les registres RAX et RCX).

$ gdb ./crackme_329455874d977e59dcb72eb58779ec5c.bin

gef➤ b memcmp@plt
Breakpoint 1 at 0x1050

gef➤ r 0123456789abcdef
[#0] Id 1, Name: "crackme_3294558", stopped 0x555555555050 in memcmp@plt (), reason: BREAKPOINT

gef➤  x/2xg $rax
0x7fffffffdd10: 0xf2861cf7978787b5      0x1c940c8696a668b4

gef➤  x/2xg $rcx
0x5555555592a0: 0xb5f492d3f1d4f2d7      0xa4c28385a0866a6b

Dans RAX, on voit bien les valeurs croisées pendant la décompilation. Et dans RCX, sans oubliler que c'est du Little Endian, on a :

Mot de passe : 0123456789abcdef
RCX : d7f2d4f1d392f4b56b6a86a08583c2a4

En relançant avec un mot de passe différent, on obtient :

Mot de passe : 0000111122223333
RCX : d7d7d7d7f2f2f2f2d4d4d4d4f1f1f1f1

On peut donc essayer de faire un mapping en testant toute une liste de caractères et regarder en quoi ils sont transformés. Une fois ce mapping fait, il suffira alors de regarder la valeur de key pour regarder les correspondances attendues.


Automatisation de GDB

Ici, on va utiliser un script python que l'on passera avec l'option --command à GDB. Ce script va pouvoir utiliser l'API de GDB pour exécuter des commandes (comme ajouter un breakpoint et lire les registres).

import json

# Les caractères possibles pour le mot de passe
CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
KEY_SIZE = 16

# Récupère la valeur 16 bytes d'une adresse dans un registre
def get_register(register: str) -> bytes:
	return b''.join([bytes.fromhex(n.strip())[::-1] for n in gdb.execute(f'x/2xg ${register}', False, True).split('\t0x')[1:]])

# Lancer le binaires avec un mot de passe
def run_password(password: str) -> tuple[bytes, dict]:
	gdb.execute(f'r {password}')
	key = get_register('rax')
	encrypted = get_register('rcx')
	# Retourne le tuple (clé en mémoire, mapping partiel)
	return key, {encrypted[i]: password[i] for i in range(KEY_SIZE)}

# Utilise le mapping pour retrouver le bon mot de passe
def solve_key(key: bytes, mapping: dict) -> str:
	return ''.join([mapping[n] for n in key])

# Break avant la comparaison
gdb.execute('b memcmp@plt')

# Map contenant le couple {chiffré: caractère}
mapping = {}

# Coupe notre charset en bloc de 16 caractères
for i in range(0, len(CHARSET), KEY_SIZE):
	# Complète au cas où il n'y en a pas assez avec des 'a'
	password = CHARSET[i:i+KEY_SIZE].ljust(KEY_SIZE, 'a')

	key, partial_mapping = run_password(password)
	mapping.update(partial_mapping)

# Détermine le mot de passe
password = solve_key(key, mapping)

# Le print dans la console
print(password)

# Quit GDB
gdb.execute(f'quit')

On peut tester en lançant le binaire avec gdb et le script en option

$ gdb ./out/crackme_329455874d977e59dcb72eb58779ec5c.bin --command gdbscript.py
[ gdb écrit plein de trucs ]
7AAQUJa1vnDqaZRJ

Maintenant si on lance le binaire avec le mot de passe

$ ./out/crackme_329455874d977e59dcb72eb58779ec5c.bin 7AAQUJa1vnDqaZRJ        
GG!

Bingo ! Plus qu'à compléter notre fonction solve dans notre classe Binary et envoyer le résultat au serveur.


Résolution

Voici le script python au complet pour résoudre le challenge (ne pas oublier gdbscript.py)

solve.py
import socket
import os
from io import BytesIO
from subprocess import check_output
from zipfile import ZipFile


HOST = 'challenges.404ctf.fr'
PORT_BINARY = 31998
PORT_SOLVE = 31999

class Binary:
    FOLDER = 'out'
    
    @staticmethod
    def fetch() -> 'Binary':
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client.connect((HOST, PORT_BINARY))
        archive = b''
        while True:
            data = client.recv(4096)
            if len(data) == 0:
                break
            archive += data
        archive = ZipFile(BytesIO(archive))
        crackme = archive.read('crackme.bin')
        token = archive.read('token.txt').decode()
        return Binary(crackme, token)

    @staticmethod
    def open(filename):
        token = filename.split('_')[1][:-4]
        with open(os.path.join(Binary.FOLDER, filename), 'rb') as f:
            crackme = f.read()
        return Binary(crackme, token)

    def __init__(self, crackme: bytes, token: str) -> None:
        self.crackme = crackme
        self.token = token
        self.filename = f'crackme_{self.token}.bin'
        self.filepath = os.path.join(self.FOLDER, self.filename)
    
    def save(self):
        if not os.path.exists(self.FOLDER):
            os.mkdir(self.FOLDER)
        with open(self.filepath, 'wb') as f:
            f.write(self.crackme)
        os.system(f'chmod +x {self.filepath}')
    
    def solve(self) -> str:
        # Au cas où
        self.save()
        # Récupère la dernière ligne print, donc le mot de passe trouvé
        return check_output(['gdb', '--command', 'gdbscript.py', self.filepath]).splitlines()[-1].strip().decode()


def main() -> None:
    # Récupère le binaire sur le serveur
    binary = Binary.fetch()
    # Lance gdb avec le script 
    password = binary.solve()

    print(f'Token:    {binary.token}')
    print(f'Password: {password}')

    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Se connecte au serveur
    client.connect((HOST, PORT_SOLVE))
    client.recv(2048)
    # Envoi du token pour identifier le binaire sur lequel on travallait
    client.send(f'{binary.token}\n'.encode())
    client.recv(2048)
    # Envoi du mot de passe pour prouver notre résolution
    client.send(f'{password}\n'.encode())
    # Récupération du flag
    print(client.recv(2048).decode())


if __name__ == '__main__':
    main()

Dernière mise à jour

Cet article vous a-t-il été utile ?