Il est recommandé de finir Revers(ibl)e Engineering [1/2] avant d'essayer ce challenge.Du fait de la nature des binaires générés, vous pourriez vouloir les executer dans une VM.
"Je vois que mon enseignement a porté ses fruits, augmentons le niveau. Quand lors d'un combat anticiper devient impossible, seul celui qui sait danser sur le fil de l'imprévisible peut triompher dans ce ballet de l'inconnu."
Même principe que l'épreuve précèdente, mais cette fois vous avez dix minutes. La chance ! Par contre, les crackme sont à usage unique. Pas très eco-friendly, je sais..
Ce challenge tourne sur un docker et n'est pas disponible
Ce challenge est la suite de Revers(ibl)e Engineering [1/2], il est nécessaire d'avoir lu sa solution avant pour comprendre celle-ci
Préparation
Ici, nous utiliserons le même script de préparation que dans la partie 1 du challenge pour les mêmes raisons.
import socket
import os
from io import BytesIO
from zipfile import ZipFile
import typing
HOST = 'challenges.404ctf.fr'
PORT_BINARY = 31990
PORT_SOLVE = 31991
class Binary:
FOLDER = 'out'
# Sauvegarde de toutes les instances de binaires téléchargés
INSTANCES: typing.Dict['str', 'Binary'] = {}
@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)
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)
# Récupération des données envoyées par le serveur
self.responses = self.get_server_data()
# Ajout de notre objet à la liste des instances existances
self.INSTANCES[self.token] = self
self.save()
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 get_server_data(self):
# A compléter
pass
def solve(self):
# A compléter
pass
Analyse des binaires reçus
Cette partie liste les fonctions du binaire, si vous avez la flemme, les explications de résolution commence après
Commençons par une décompilation d'un binaire (avec Ghidra pour ma part). J'ai renommé toutes les variables, modifié un peu le code pour le simplifier et ajouté des commentaires histoire de mieux comprendre ce qu'il se passe.
main
int main(void) {
bool wrong_password;
int code;
long i;
char message [0x80];
long password [0x2];
char key [0x10];
long functions [0x21]; // tableau contenant les adresses de fonctions mises en mémoire
// Initialisation du tableau à 0
for (i = 0; i != 0x20; i++) functions[i] = 0x0;
// Contacte le serveur pour récupérer les fonctions mises en mémoire
code = recv_functions((long)functions);
if (code < 0) {
printf("Une erreur est survenue lors de la verification :(");
exit(1);
}
while( true ) {
// Contacte le serveur pour récupérer une clé et le message à afficher
code = recv_key_and_message(key,message);
if (code < 0) {
printf("Une erreur est survenue lors de la verification :(");
exit(1);
}
if (code == 1337) break;
read_password(message,password);
wrong_password = verify(functions, key, password);
if (wrong_password) {
puts("Mauvaise clé, dommage..");
exit(1);
}
}
puts("C\'est bon, tu as bien mérité ton flag.. Envoie toutes les clés concaténées au serveur de vérification pour le recevoir.");
return 0;
}
recv_functions
int recv_functions(long *functions) {
int sock_fd;
int recved_size;
char *buffer;
void *memory_dest;
int i;
i = 0;
while( true ) {
// Créé un buffer tampon
buffer = (char *)malloc(0x400);
// Alloue un espace en mémoire où sera stocké la fonction reçu
memory_dest = mmap((void *)0, 0x400, 0x7, 0x22, -1, 0);
if (buffer == NULL) return -1;
// Se connecte au serveur et envoie le token associé au binaire
sock_fd = connect_and_send_token();
// Reçoit en réponse la fonction compilée
recved_size = recv_function(sock_fd,buffer);
// Ferme la connexion
close(sock_fd);
if (recved_size < 0) break;
if (recved_size == 0) return 0;
// Copie la fonction reçue dans la mémoire
memcpy(memory_dest, buffer, recved_size);
// Ajoute l'adresse mémoire à la liste des fonctions
functions[i] = (long)memory_dest;
// Libère le buffer tampon
free(buffer);
i = i + 1;
}
return -1;
}
connect_and_send_token
int connect_and_send_token() {
int sock_fd;
int success;
size_t size;
sockaddr_in serv_addr;
// Définis un socket(2 (AF_INET), 1 (SOCK_STREAM), 0 (protocol par défaut - TCP))
sock_fd = socket(2, 1, 0);
if (sock_fd < 0) {
printf("Impossible de se connecter au serveur..");
exit(1);
}
// Configure les infos du serveur
serv_addr.sin_family = 2;
serv_addr.sin_port = htons(31992);
serv_addr.sin_addr = inet_addr("162.19.101.153");
// Se connecte
success = connect(sock_fd, (sockaddr *)&serv_addr, 0x10);
if (success < 0) {
printf("Impossible de se connecter au serveur..");
exit(1);
}
// Envoie le token
size = strlen("55b20a291d201497d871b76063dbd74e\n");
send(sock_fd,"55b20a291d201497d871b76063dbd74e\n", size, 0);
return sock_fd;
}
recv_function
int recv_function(int sock_fd,char *buff) {
char recved [0x408];
char *found_at_index;
size_t recved_size;
size_t total_recved_size;
total_recved_size = 0;
do {
// Récupère 0x400 (1024) octets sur le socket
recved_size = recv(sock_fd,recved,0x400,0x0);
if ((long)recved_size < 0x0) return -1;
if (recved_size == 0) break;
// Copies à la suite les données reçues dans le buffer
memcpy(buff + total_recved_size,recved, recved_size);
total_recved_size = total_recved_size + recved_size;
// Si les données reçues sont uniquement "endfunc", alors la boucle s'arrête
found_at_index = strstr(recved, "endfunc");
} while (found_at_index == (char *)0);
// Retourne
return (int)total_recved_size + -0x7;
}
recv_key_and_message
int recv_key_and_message(void *key,void *message) {
int sock_fd;
ssize_t recved_size;
sock_fd = connect_and_send_token();
// Reçoit la clé de 16 octets
recved_size = recv(sock_fd,key,0x10,0x0);
if (recved_size < 1) return -1; // Erreur de réception du message
memset(message,0x0,0x80);
// Reçoit le message pour demander le mot de passe à l'utilisateur
recved_size = recv(sock_fd,message,0x80,0x0);
if (recved_size < 1) return -1; // Erreur de réception du message
// Si le message reçu est "done", alors nous avons passé toutes les vérifs
if (memcmp(message,"done",0x4) == 0) return 1337;
return 0;
}
read_password
void read_password(char *message, char *password) {
// Affiche le message pour demander le mot de passe à l'utilisateur
printf("%s",message);
// Récupère notre mot de passe dans le STDIN, max 32 octets
fgets(password, 0x20, stdin);
return;
}
verify
bool verify(long *functions, char *key, char *password) {
int diff;
void *key_encrypted;
// Applique les fonctions spécifiées sur le mot de passe
apply_functions(functions, password);
// Chiffre la clé (key) pour obtenir une version chiffrée
key_encrypted = encrypt(key);
// Compare les 16 premiers octets de password avec key_encrypted
diff = memcmp(password, key_encrypted, 16);
// Si les 16 premiers octets de password ne correspondent pas à key_encrypted,
// retourne true (c'est-à-dire que la vérification a échoué)
return diff != 0;
}
apply_functions
void apply_functions(long *functions, char *password) {
int i;
// Parcourt le tableau de fonctions jusqu'à trouver la sentinelle (0x0)
for (i = 0; (code *)functions[i] != (code *)0x0; i = i + 1) {
// Appelle chaque fonction sur le mot de passe
(*(code *)functions[i])(password);
}
return;
}
Pour résumer, voici les étapes depuis le lancement du binaire :
Une connexion s'ouvre vers le serveur 162.19.101.153:31992 pour récupérer des fonctions compilées jusqu'à recevoir le message endfunc
Tant que la boucle n'est pas cassée
Une nouvelle connexion s'ouvre pour récupérer une clé de 16 octets et un message de taille variable
Demande le mot de passe en affichant le message récupéré
Applique toutes les fonctions sur notre mot de passe
Chiffre la clé
Compare la clé chiffrée avec notre mot de passe modifié
La petite chose à noter est que le serveur auquel se connecte notre binaire n'envoie les réponses qu'une seule fois. Autrement dit, on ne peut lancer le binaire qu'une seule fois.
L'astuce utilisée ici est de contacter le serveur distant qui envoie les fonctions et clés en amont, son IP et son port ne changent d'un binaire à l'autre.
Ainsi, avant de lancer notre binaire, on va récupérer les réponses qu'il était censé recevoir du serveur et les enregistrer. Ensuite, on viendra lancer un serveur local qui imitera le comportement du serveur distant en renvoyant ces données dans le même ordre. Il ne reste alors plus qu'à rediriger les requêtes faites par le binaire vers le serveur distant sur notre serveur local pour qu'il n'y voie que feu et qu'on puisse le lancer sans limites.
Résolution
Redirection des flux
L'objectif est de capturer ce qui part vers le serveur distant sur le port 31192 et de le rediriger vers notre serveur local sur le port 3000. Mais nous devons aussi pouvoir récupérer les données du serveur distant, pour ça, on peut mapper un autre port libre (ici le 40000) vers le vrai port 31992 car si on utilise directement le vrai port, il sera capturé par notre première règle.
Il nous faut donc 2 règles iptables :
162.19.101.153:31992 redirigé vers localhost:3000
162.19.101.153:xxxx redirigé vers 162.19.101.153:31992
Voici un petit script qui permet d'ajouter les règles et les supprimer (tout en vérifiant si elles sont déjà en place).
from subprocess import getstatusoutput
EXTERNAL_IP = '162.19.101.153' # IP du serveur distant
EXTERNAL_PORT = 31992 # Port du serveur distant
FAKE_EXTERNAL_PORT = 40000 # Nouveau port du serveur distant
LOCAL_PORT = 3000 # Port du serveur local
def apply_rules(option: str):
rules = [
f'sudo iptables -t nat {option} OUTPUT -p tcp -d {EXTERNAL_IP} --dport {EXTERNAL_PORT} -j DNAT --to-destination 127.0.0.1:{LOCAL_PORT}',
f'sudo iptables -t nat {option} OUTPUT -p tcp -d {EXTERNAL_IP} --dport {FAKE_EXTERNAL_PORT} -j DNAT --to-destination {EXTERNAL_IP}:{EXTERNAL_PORT}'
]
for rule in rules:
if getstatusoutput(rule)[0] != 0:
raise Exception('La règle n\'existe pas')
def add_rules():
try:
apply_rules('-C')
except:
apply_rules('-A')
def delete_rules():
apply_rules('-D')
Récupération des données
On va compléter la fonction get_server_data de notre classe Binary, elle va servir à récupérer les données envoyées par le serveur distant en se faisant passer pour le binaire.
class Binary
def get_server_data(self):
# Liste des réponses reçues
responses = []
while True:
# Connexion au serveur distant via le port de redirigé avec iptables
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((EXTERNAL_IP, FAKE_EXTERNAL_PORT))
# Envoi du token
client.send(f'{self.token}\n'.encode())
# Réception des données
data = client.recv(4096)
client.close()
# Ajout des données à la liste
responses.append(data)
if b'done' in data:
break
return responses
Serveur local
A chaque fois que le binaire fait une requête avec son token, on doit envoyer les données suivantes. Le serveur va être lancé dans un thread pour ne pas bloquer l'exécution du binaire ensuite.
# Décorateur pour lancer automatiquement une fonction dans un thread
def threaded(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
thread.start()
return thread
return wrapper
class Server:
@threaded
@staticmethod
def run():
# Compteurs pour savoir quelle données à envoyer en fonction du token
counters = {}
# Création du serveur local et écoute
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('127.0.0.1', LOCAL_PORT))
server.listen()
while True:
conn, _ = server.accept()
with conn:
# Récupération du message envoyé par le binaire
token = conn.recv(1024).strip().decode()
# Permet de réinitialiser tous les compteurs
if token.startswith('reset'):
counters = {}
conn.send(b'Reset done !\n')
continue
# Si l'on a les réponses associées au token
if token in Binary.INSTANCES:
binary = Binary.INSTANCES[token]
if token not in counters:
counters[token] = 0
data = binary.responses[counters[token]]
conn.sendall(data)
# Incrémentation du compteur
counters[token] = (counters[token] + 1) % len(binary.responses)
GDBScript et solve
On fait la même technique que dans la version 1 du challenge, on met un breakpoint sur le memcmp@plt et l'on regarde les registres. Petite variation sur le fait qu'au total on nous demande 3 mots de passe à la suite.
import json
import socket
# Les caractères possibles pour le mot de passe
CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
KEY_SIZE = 16
# Reset les compteurs du serveur local
def reset() -> None:
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('162.19.101.153', 31992))
client.send(b'reset\n')
# 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, skip: int) -> tuple[bytes, dict]:
reset()
# Ajoute le mot de passe dans le STDIN, où il est récupéré par le fgets dans le binaire
gdb.execute(f'r <<< {password}')
# Skip le nombre de breakpoint
for _ in range(skip):
gdb.execute('c')
key = get_register('rcx')
rax = get_register('rax')
# Retourne le tuple (clé en mémoire, mapping partiel)
return key, {rax[i]: password[i] for i in range(KEY_SIZE)}
# Utilise le mapping pour retrouver le bon mot de passe
def solve_key(target: bytes, mapping: dict):
return ''.join([mapping[n] for n in target])
# 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')
# skip le premier breakpoint, ce qui n'est pas ce qui nous intéresse
target1, partial_mapping = run_password(password, skip=1)
mapping.update(partial_mapping)
key1 = solve_key(target1, mapping)
target2, _ = run_password(key1.ljust(31, 'A') + '0000111122223333', skip=3)
key2 = solve_key(target2, mapping)
target3, _ = run_password(key1.ljust(31, 'A') + key2.ljust(31, 'A') + '0000111122223333', skip=5)
key3 = solve_key(target3, mapping)
# Concaténation des mots de passe et le print
print(f'{key1+key2+key3}')
# Quit GDB
gdb.execute(f'quit')
Script complet
import socket
import os
from io import BytesIO
from zipfile import ZipFile
from subprocess import getstatusoutput
import typing
import threading
import functools
from subprocess import check_output
HOST = 'challenges.404ctf.fr'
PORT_BINARY = 31990
PORT_SOLVE = 31991
EXTERNAL_IP = '162.19.101.153' # IP du serveur distant
EXTERNAL_PORT = 31992 # Port du serveur distant
FAKE_EXTERNAL_PORT = 40000 # Nouveau port du serveur distant
LOCAL_PORT = 3000 # Port du serveur local
############################################################################
def apply_rules(option: str):
rules = [
f'sudo iptables -t nat {option} OUTPUT -p tcp -d {EXTERNAL_IP} --dport {EXTERNAL_PORT} -j DNAT --to-destination 127.0.0.1:{LOCAL_PORT}',
f'sudo iptables -t nat {option} OUTPUT -p tcp -d {EXTERNAL_IP} --dport {FAKE_EXTERNAL_PORT} -j DNAT --to-destination {EXTERNAL_IP}:{EXTERNAL_PORT}'
]
for rule in rules:
if getstatusoutput(rule)[0] != 0:
raise Exception('La règle n\'existe pas')
def add_rules():
try:
apply_rules('-C')
except:
apply_rules('-A')
def delete_rules():
apply_rules('-D')
############################################################################
class Binary:
FOLDER = 'out'
INSTANCES: typing.Dict['str', 'Binary'] = {}
@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)
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)
self.responses = self.get_server_data()
self.INSTANCES[self.token] = self
self.save()
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 get_server_data(self):
# Liste des réponses reçues
responses = []
while True:
# Connexion au serveur distant via le port de redirigé avec iptables
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((EXTERNAL_IP, FAKE_EXTERNAL_PORT))
# Envoi du token
client.send(f'{self.token}\n'.encode())
# Réception des données
data = client.recv(4096)
client.close()
# Ajout des données à la liste
responses.append(data)
if b'done' in data:
break
return responses
def solve(self) -> str:
return check_output(['gdb', '--command', 'gdbscript.py', self.filepath]).splitlines()[-1].strip().decode()
############################################################################
# Décorateur pour lancer automatiquement une fonction dans un thread
def threaded(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
thread.start()
return thread
return wrapper
class Server:
@threaded
@staticmethod
def run():
# Compteurs pour savoir quelle données à envoyer en fonction du token
counters = {}
# Création du serveur local et écoute
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('127.0.0.1', LOCAL_PORT))
server.listen()
while True:
conn, _ = server.accept()
with conn:
# Récupération du message envoyé par le binaire
token = conn.recv(1024).strip().decode()
# Permet de réinitialiser tous les compteurs
if token.startswith('reset'):
counters = {}
conn.send(b'Reset done !\n')
continue
# Si l'on a les réponses associées au token
if token in Binary.INSTANCES:
binary = Binary.INSTANCES[token]
if token not in counters:
counters[token] = 0
data = binary.responses[counters[token]]
conn.sendall(data)
# Incrémentation du compteur
counters[token] = (counters[token] + 1) % len(binary.responses)
############################################################################
def main() -> None:
# Création des règles iptables
add_rules()
# Lancement du serveur local
Server.run()
# Récupération d'un binaire
binary = Binary.fetch()
print(f'Token: {binary.token}')
# Lancement de la résolution du binaire avec gdb
password = binary.solve()
print(f'Password: {password}')
# Envoi de la solution sur le serveur
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOST, PORT_SOLVE))
client.recv(2048)
client.send(f'{binary.token}\n'.encode())
client.recv(2048)
client.send(f'{password}\n'.encode())
# Récupération du flag
print(client.recv(2048).decode())
# Suppression des règles iptables
delete_rules()
# Quitte tous les threads
os._exit(0)
if __name__ == '__main__':
main()