SSEcret (reverse, 500)

Description du challenge

Trouvez le secret qui affichera le flag.

Solution

Un autre crackme très sympathique et à rebondissements.

╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abc 
[1]    10875 segmentation fault (core dumped)  ./ssecret.bin abc
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcd
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcde
[1]    10890 segmentation fault (core dumped)  ./ssecret.bin abcde
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcdef   
[1]    10898 segmentation fault (core dumped)  ./ssecret.bin abcdef
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcdef
g[1]    10906 segmentation fault (core dumped)  ./ssecret.bin abcdef
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcdefg
[1]    10923 segmentation fault (core dumped)  ./ssecret.bin abcdefg
╭─face0xff@aniesu-chan /den/ctf/fcsc  
╰─$ ./ssecret.bin abcdefgh

Étonnant, le binaire semble segfault lorsque l'entrée n'est pas de longueur multiple de 4. Sinon, il n'affiche juste rien. Le but est donc de lui faire cracher le flag. Sans plus attendre, laissons place à Ghidra. Quelques variables ont été renommées pour la lisibilité :

La fonction main calcule la longueur de notre argument et appelle la fonction FUN_00400860. Je passe sur les détails parce que ce n'est pas la partie intéressante ; cette fonction décode notre entrée comme de la base64, renvoie son adresse et stocke sa taille en octets une fois décodée dans local_18.

La fonction FUN_00601050 est appelée avec l'adresse de notre mot de passe décodé et sa longueur.

J'ai grandement élagué le code pour pas que ce soit trop lourd. La structure de la fonction est la suivante :

  • On vérifie que notre mot de passe fait au moins 16 octets

  • 128 blocs très similaires qui font des calculs à l'aide de notre entrée et de constantes 128 bits

  • Une vérification à la fin de tous ces blocs (juste avant le syscall) mettant aussi en jeu une constante 128 bits

  • Si la vérification est passée, alors une routine semble déchiffrer quelque chose à l'aide de notre mot de passe, qui agit comme une clé AES 128 bits.

Après un moment sous IDA à suivre l'exécution du programme en analysant assidument les registres XMM, on arrive à reconstituer la logique suivante.

Ce premier bloc vient charger auVar9 avec la valeur 0x80000000000000000000000000000000. C'est une initialisation que l'on ne retrouve plus dans les blocs suivants ; à la place, on y retrouvera à chaque fois un décalage de 1 bit vers la droite, autrement dit à l'itération i cette variable vaudra l'entier binaire 128 bits qui possède un unique 1 en i-ème position (de poids fort à poids faible).

Il charge ensuite auVar8 avec une constante 128 bits, ici 85 D1 31 C4 C8 26 CD 0D D5 5F FF B8 9E 4B BF 9C (telle qu'affichée dans le débugger d'IDA, en little-endian).

auParm1 contient les 16 premiers octets de notre entrée, vus comme un entier 128 bits. Un ET logique est effectué entre notre entrée et la constante formée. Enfin, la fonction popcnt permet de compter le nombre de bits à 1 dans un registre. Cela est fait en deux étapes à travers les registres 64 bits, mais la finalité est la même : si le XOR (ou l'addition des bits modulo 2...) des bits constituant le résultat du ET logique vaut 1, alors on rentre dans le if, qui va initialiser auVar8 à auVar9.

Le contenu de ce if fait probablement plus de sens dans les blocs suivants :

auVar8 est XORé avec auVar10, ce qui est équivalent à mettre le i-ème bit à 1 dans auVar8.

Ainsi, le résultat de ces 128 blocs est la formation d'un entier 128 bits c tel que c[i] est la somme modulo 2 des bits de (key & a[i]), où a est la i-ème constante magique.

Le dernier bloc (le "129ème") est un peu illisible avec les CONCAT et les SUB de Ghidra et est plus clair en assembleur :

A l'aide de PCMPEQD, on compare deux registres XMM, à savoir celui contenant c et une constante chargée, ici F4 A4 ... E9 62.

Bien ! Il ne reste plus qu'à reformuler ça mathématiquement (le chall a le tag "maths" après tout, même si ça reste assez léger à mon sens 🧐). Chaque bloc peut être reformulé de la façon suivante :

aixcimod2\langle \: a_i \: | \: x \: \rangle \equiv c_i \: \mod{2}

xx est l'inconnue (notre password), (ai)0i<128(a_i)_{0 \leq i \lt 128} les constantes magiques et (ci)0i<128(c_i)_{0 \leq i \lt 128} les bits de la valeur magique de comparaison finale. xx et (ai)(a_i) sont des vecteurs 128 bits qui codent l'entier qu'ils représentent.

On a 128 équations de ce genre, que l'on peut donc reformuler globalement ainsi :

AxCmod2Ax \equiv C \: \mod{2}

AA est la matrice de bits dont les lignes sont les aia_i, et CC le vecteur colonne des cic_i.

Cette équation se résout en trois lignes de Sage :

Il ne reste plus qu'à coder un petit script pour parser le code afin de récupérer toutes les valeurs magiques et c'est gagné !

Je passe les détails : on trouve une solution, on la décodé en binaire puis ré-encode en base64 et on obtient eqFUxbL2zNoSFXuPo3P64A==. On la passe au binaire pour obtenir le flag :

Mince... que s'est-il passé ?

On lance IDA et on débug pour voir pourquoi ça n'a pas marché. On se rend compte qu'en fait si, ça a marché ; la condition est bien passée et on n'a pas emprunté le syscall exit. On rentre dans la routine de déchiffrement AES et là on commence à voir venir la couille...

La routine AES déchiffre un nouveau bloc de code du programme et va jump dessus. Pas très grave me direz vous, il suffit d'analyser ce que fait ce nouveau bloc de code, éventuellement trouver la façon dont est générée le flag sur le passage et c'est plié.

Sauf que le bloc de code généré ressemble exactement à la fonction principale initiale, aux constantes magiques près. Ce nouveau code utilise les 16 prochains octets de notre mot de passe.

On effectue un rapide calcul, et on se rend compte qu'il y a en fait environ 127 blocs de code obfusqué de même longueur que la première fonction dans le binaire, soit au total 128 problèmes de ce type à résoudre. Il va falloir scripter intelligemment... !

Je passe sur les détails, il s'agit de parser le binaire directement pour récupérer les valeurs magiques, résoudre l'équation matricielle, utiliser le morceau de clé trouvé pour déchiffrer le prochain bloc de code et répéter. Les difficultés principales résidaient dans le parsing correct du binaire (j'ai passé beaucoup de temps à débugger une regex erronnée) et dans la compréhension de la routine de déchiffrement, qui est certes du AES classique, mais il faut bien identifier ce qu'on chiffre avec quelle clé à chaque itération. Il s'agissait en fait d'AES en mode CTR (avec le compteur "canonique" si je puis dire).

Le script met un peu de temps à s'exécuter (2 minutes sur ma machine), probablement à cause des appels à Sage... (je ne connais pas bien Sage donc je ne sais pas s'il y a un moyen plus simple de "wrapper" tout ça, j'ai juste vu que je pouvais exécuter le script en ligne de commande donc j'ai foncé là-dessus).

On trouve alors enfin le (long) mot de passe à la 128ème itération, et on prie pour qu'il n'y ait pas une étape supplémentaire à la fin :

Enjoy !

Last updated

Was this helpful?