⭐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
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.
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 passela 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
$ ./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
)
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 ?