On vous demande d'écrire un générateur d'entrées valides pour ce binaire, puis de le valider sur les entrées fournies par le service distant afin d'obtenir le flag.
Service : nc challenges2.france-cybersecurity-challenge.fr 3000
Solution
Ce crackme était une aventure intense et pleine de rebondissements. J'ai du passer environ 8 heures non-stop dessus pour le résoudre. Il y a probablement quelques cracks qui l'ont poutrée beaucoup plus vite, donc je suis curieux de connaître les méthodes qui permettaient d'accélérer le processus de résolution. Quoi qu'il en soit, je suis fier de constater le fruit de mon acharnement et de ma persévérance sur cette longue épreuve !
Rentrons dans le vif du sujet : on nous donne un ELF 64 bits qui nous demande un username ainsi qu'un serial associé :
La ligne importante est la suivante, où j'ai renommé les arguments :
r =FUN_0010096a(&DAT_001024e0,0x400, username, len_username, serial, len_serial);
Pour gagner, il faut que cet appel renvoie autre chose que 0. 0x001024e0 est l'adresse d'un grand tableau de 0x400 = 1024 octets hardcodés dans le binaire et qui ne font a priori pas encore sens.
On rentre dans la fonction, et là c'est le drame. D'abord, une petite capture du flow graph largement dézoomé sous IDA :
A ce moment-là c'est simple : on baisse les bras et on va tenter d'autres épreuves 😁
Puis on revient à nouveau dessus en se disant qu'elle vaut quand même 500 points et qu'une fois passée l'étape de tout bien lire ce qu'il se passe, elle doit être franchement faisable.
Examinons d'abord le prologue de cette fonction. J'ai renommé data la référence au bloc de 1024 octets qui et passé en argument.
Le switch case est effectué sur la valeur de uVar5. uVar11 est un compteur, qui avance la plupart du temps de 4 en 4 (on lit des mots de 32 bits à chaque fois donc). Là ça commence à mettre la puce à l'oreille... Je vais renommer uVar11 "pc", uVar1 "opcode" et uVar5 "type" 😁
Voici un court extrait maintenant de quelques entrées du switch case :
En effet, en fonction de la variable type, on va effectuer des opérations différentes, et on va incrémenter le pc de 4. Tout est clair dès à présent ; il s'agit là d'une mini machine virtuelle 32 bits qui lit et interprète un jeu d'instructions (qui ressemble un peu à du RISC ?). Le pc (Program Counter) donne la position courante dans la lecture de data qui n'est rien d'autre que le bytecode du programme que l'on exécute. La zone de 64 octets initialement nuls en 0x00303040 représenta, nous le verrons, les 16 registres du processeur et enfin la zone après les 1024 octets du programme, en 0x00303480, sert à des fins de mémoire (comme une heap).
Le type d'un opcode est donné par opcode >> 0x18, autrement ses 8 bits de poids fort, d'où le switch case à 256 entrées.
Analysons l'extrait. Pour type = 0, on prend opcode >> 0x14 et opcode >> 0x10 & 0xf, autrement dit les 4 bits et 4 bits suivant le type de l'opcode, et ces valeurs (entre 0 et 15 donc en décimal) sont des indices de registres (situés en 0x00303040). Cette instruction effectue donc ce qui s'apparente à un mov tel que l'on le noterait en assembleur classique x86 par exemple.
Les autres types (0x1f, 0x20, 0x21) semblent prendre un certain registre donné, et le XORer avec une constante donnée.
En fait, si l'on analyse tout le code, on se rend compte que ces étranges instructions de XOR très arbitraires occupent tous les types de 0x1f à 0xfd, ce qui diminue pas mal le nombre d'instructions réelles. On pourra coder un script pour extraire toutes ces instructions de XOR à partir du code généré par Ghidra.
Quant-aux autres instructions, il convient de les étudier chacune à la main. C'est un travail fastidieux et je vais directement passer à l'explication de l'ISA.
Tout d'abord, on pose quelques notations :
type_op k m p q
00000000 0000 0000 0000 000000000000
-----------------s ssss
s = pour les opérations de shift (5 bits)
. = concaténation de bits
Ensuite on détaille chaque type d'opcode. J'appelle text la mémoire composée du programme et du heap, indicée à partir de zéro (début du programme). La fonction swap_endianness change le boutisme d'un mot de 32 bits, par exemple 0x11223344 devient 0x44332211. La fonction AES est en réalité l'instruction x86 aesenc, qui n'effectue qu'un seul "round" de chiffrement (j'ai perdu beaucoup de temps là-dessus !).
00 -> reg[k] = reg[m]
01 -> reg[k] = text[reg[m]]
02 -> reg[k] = m.p (8 bits)
03 -> text[reg[k]] = reg[m]
04 -> text[reg[k]] = text[reg[m]]
05 -> text[reg[k]] = m . p (8 bits)
06 -> sauvegarde PC+4; pc = k.m.p.q; (CALL)
07 -> jump_flag = reg[k] - reg[m] (CMP between two registers)
08 -> jump_flag = reg[k] - m.p (CMP with immediate)
09 -> jump to k.m.p.q if jump_flag = 0 (JE)
0a -> jump to k.m.p.q if jump_flag != 0 (JNE)
0b -> reg[k] = reg[k] + reg[m]
0c -> reg[k] = reg[k] + m.p
0d -> reg[k] = reg[k] * reg[m]
0e -> reg[k] = reg[k] * m.p
0f -> reg[k] += 1
10 -> reg[k] = reg[k] % reg[m]
11 -> reg[k] = reg[k] % m.p
12 -> reg[k] = reg[k] ^ reg[m]
13 -> reg[k] = reg[k] ^ m.p
14 -> jump to k.m.p.q if jump_flag < 0 (JL)
15 -> jump to k.m.p.q if jump_flag > 0 (JG)
16 -> reg[k] = reg[k] - reg[m]
17 -> reg[k] = reg[k] - m.p
18 -> jump to k.m.p.q
19 -> reg[k] = reg[k] >> s
1a -> reg[k] = PC + 4
1b -> reg[k] = swap_endianness(text[reg[m]])
1c -> text[reg[k]] = swap_endianness(reg[m])
1d -> reg[k] = reg[k] << s
1e -> text[reg[k]] = AES(text[reg[m]], text[reg[p]])
1f -> fd : reg[something] ^= some hardcoded value
fe -> récupère l'adresse de retour et jump (RET)
ff -> fin du prog et retourne reg[0]
Bon, eh bien il semblerait que nous avons maintenant toutes les clés en main pour... s'amuser à coder un interpréteur, ainsi qu'un désassembleur !
Je passe sur les détails et je vous montre directement mon script. Cela allant de soi, la résolution ne se déroulant pas comme voulue, il a fallu aussi coder un débugger minimaliste (affichage des registres, de la mémoire et des breakpoints).
On peut voir qu'il y a des zones étranges où les fameuses opérations XOR sont répétées sans aucun sens. C'est là que j'ai tiqué : ces instructions ne servent à rien dans la logique du programme. Elles sont en réalité ici uniquement à des fins d'obfuscation. Prenons par exemple cette routine :
Ce qu'on fait ici, c'est qu'on charge la valeur de PC+4 dans r15 et on lui ajoute 72 : r15 contient l'adresse 0x0334. Puis une boucle va venir déchiffrer ce tableau de mots à l'aide de swaps et de xors. Difficile donc d'analyser directement ce code de façon statique. Heureusement, je peux maintenant poser des points d'arrêts qui me permettront de suivre le comportement réel du programme en direct !
Aperçu d'un début de session de debug :
Le "main" du programme se décompose en plusieurs calls. Le premier permet de s'assurer que la longueur du serial est de 256 octets. Le deuxième n'est pas très difficile à analyser ; il s'assure que le serial soit en fait de l'hexadécimal et le décode, en le stockant un peu plus loin dans la mémoire. Les trois calls suivants sont des routines obfusquées.
Je passe les étapes de reconstitution de la logique des routines, c'était un travail assez fastidieux car des erreurs pouvaient se cacher à tous les niveaux et n'étaient pas toujours évidentes à corriger (mauvaise compréhension du binaire et donc de la logique de certaines instructions de l'ISA, erreur d'implémentation, mauvaise compréhension/visualisation des routines...).
Voici globalement les étapes du programme :
Le serial doit faire 256 caractères hexadécimaux, puis est décodé
On dérive une clé de 96 octets à partir de notre username, à l'aide de boucles de multiplications et de xors
On effectue 32 itérations d'une série de tours de chiffrement AES de différents blocs du serial, dont les clés sont aussi des blocs du serial
Le résultat obtenu est comparé à la clé de 96 octets dérivée de l'username
Première remarque : y'a 32 octets qui partent dans le vent. Du coup, on peut générer plein de clés valides en paddant le buffer de 96 octets avec des octets arbitraires (ça tombe bien, le serveur demande à chaque fois deux clés valides pour l'username donné !)
Deuxième remarque : il faut faire très attention à l'ordre dans lesquels sont faits les aesenc parce que le serial se réécrit par dessus à chaque itération, et il faut le prendre en compte pour l'algo inverse.
Ceci étant dit, il ne reste plus qu'à coder le fameux keygen.
from binascii import unhexlify as unhex, hexlify as tohexfrom pwn import*import aes as cryptodefaesd(a,b): aes = crypto.AES()return aes.AESDEC(a, b)defwrite(text,offset,value):for i inrange(len(value)): text[offset + i]= value[i]definvert(serial):for i inrange(32): old_serial = serial[:16][:]write(serial, 0, aesd(serial[16:16+16], serial[96:96+16]))write(serial, 16, aesd(serial[32:32+16], serial[96:96+16])) old_serial48 = serial[48:48+16][:]write(serial, 48, aesd(serial[64:64+16], serial[112:112+16]))write(serial, 32, aesd(old_serial48, serial[48:48+16]))write(serial, 64, aesd(serial[80:80+16], serial[112:112+16]))write(serial, 80, aesd(old_serial, serial[:16]))defkeygen(username,random=b'\x00'): buffer = [0] *96for i inrange(len(username)):for c inrange(16): buffer[(i + c) %16]^= (((username[i]+ c) *13) ^37) %255for i inrange(5):for j inrange(16): buffer[(i +1) *16+ j]= (((buffer[i *16+ j]*3)) ^0xff) %256 buffer += [ord(random)] * (128-96)invert(buffer) key =tohex(bytes(buffer))return keyr =remote('challenges2.france-cybersecurity-challenge.fr', 3000)whileTrue: msg = r.recv(4096)print(msg)ifb'>>> 'notin msg: r.recv(4096) username = msg.split(b': ')[1].split(b'\n')[0] key1 =keygen(username) key2 =keygen(username, random=b'\x01') r.send(key1 +b'\n')print(r.recv(4096)) r.send(key2 +b'\n')
Résultat :
$ python keygenkoo.py
[+] Opening connection to challenges2.france-cybersecurity-challenge.fr on port 30
00: Done
b'Give me two valid serials for username: Jame Feldkamp\n>>> '
b'Give me two valid serials for username: Billy Natalie\n>>> '
b'Give me two valid serials for username: Charlotte Adams\n>>> '
b'Give me two valid serials for username: Nickole Muraoka\n>>> '
b'Give me two valid serials for username: Jacob Link\n>>> '
b'Give me two valid serials for username: Stephanie Williams\n>>> '
b'Give me two valid serials for username: Chelsey Hatch\n>>> '
b'Give me two valid serials for username: Bernice Ott\n>>> '
b'Give me two valid serials for username: Richard Harvey\n>>> '
[...]
b'Give me two valid serials for username: hjg48Itso7JNDjjjWVoOI\n>>> '
b'Well done! Here is the flag: FCSC{38b1135bc705b2f1464da07f3052611a91f26a957647a24ceb9607646a19c2dc}\n'