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.
Ce challenge tourne sur un docker et n'est pas disponible
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.
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
Ce que l'on peut en déduire : la fonction encrypt chiffre chaque caractère de manière indépendante.
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
Il y a surement un façon plus simple avec Pwntools d'automatiser GDB, mais je n'ai pas réussi à le faire fonctionner de mon côté
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
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()