Step 1 - un peu de crypto pour l’échauffement

Étape 1

Description

Thanks to your work, we have discovered that the owner of the car is actually a technician that belongs to the organization. They frequently went to a server room, of which we have found the location.

Our operatives have managed to intrude into the building and place an MITM chip on the target's network. It is situated right in front of what we suspect to be a license server, probably used for a game that is played by some of the organization's members.

The server manages accounts and account activation, and does not host the game in itself. This means the legitimate client of this server is not the whole game but only the game launcher.

We first need you to make sense of the communications between this server and its various clients, and retrieve a license key. This may allow us to get a foothold on their private game server, and move on further with the investigation.

To connect to the MITM setup, you need to expose a TCP port to the internet (this may be achieved through a reverse tunnel such as serveo or ngrok). When a client tries to connect to the license server, it will instead connect to you: you can then choose whether you will forward messages to the server, and even tamper with them.

By starting an instance, you will be assigned two ports: one for the MITM setup server, and one for the license server. First, you need to specify which host and port you are listening on to the MITM setup server. Then, any client trying to reach the server will attempt to connect to you through this port, and you shall be able to forward connections to the license server.

To make your life easier, we provide a small template script that you may use to connect to the MITM chip and view all communications going through it: mitm.py.

We are looking forward to your success on this mission. Good luck, agent!

Résolution

Cette épreuve est assez linéaire dans sa résolution, et bien ficelée : les informations données sont complètes et on nous donne même un script python comme squelette pour l’exploitation.

La description du challenge nous place dans le contexte d’une interception des communications entre deux participants, on cherche ainsi à déchiffrer et comprendre le format de ce qui est échangé entre eux, afin d’identifier une clé de license pour s’infiltrer plus loin.

Setup

Pour ce challenge et pour les suivants, l’interface Web utilisée pour lancer l’épreuve sera toujours identique, à savoir une requête HTTP pour lancer une instance distante, qui communique en retour l’adresse IP et le(s) port(s) du/des service(s) associé(s) à l’étape. Ici cela ne fait pas exception et pour gagner du temps sur la suite, on va abstraire ça dans un petit morceau de python qui sera chargé d’injecter le contexte en tant que dépendance pour les codes d’exploitation ultérieurs.

Le morceau de code en question peut être retrouvé dans le fichier common/wussticcommon/ensure_service/instance_lifecycle.py.

NOTE: Le code en question utilise une dépendance maison (initialement privée) qui ne demande pas à être analysée et qui est fournie ici uniquement à une fin de reproductibilité complète (il s’agit de code non documenté que certains qualifieront d’une usine à gaz en devenir, non sans raison). Il est recommandé de faire abstraction du code et de se concentrer sur les abstractions plutôt explicites qu’il déclare et qui sont utilisées lors des différentes résolutions.

De même, le processus d’interception et les forward de port ont été automatisés pour faciliter la récolte, la sauvegarde et finalement le traitement des différents paquets transitant.

Une fois automatisé la phase d’interception, on peut attaquer le challenge (NDLR : l’automatisation a été effectuée au moment de l’écriture du write-up, ralentissant considérablement cette dernière).

Reconnaissance

La première étape consiste à observer les données brutes échangées entre les deux participants, on peut donc encapsuler le script mitm.py fourni et l’utiliser dans step1/00-mitm_dump.py qui se contente d’afficher les données reçues pour mieux comprendre de quoi il en retourne :

# print data
python 00-mitm_dump.py
# save data
python 01-mitm_collect.py

L’intégralité des données reçues est sauvegardée pour un traitement ultérieur avec le script 01-mitm_collect.py. Il est globalement conseillé de laisser tourner ce script pendant au moins une heure pour être sûr de récolter la majorité des données nécessaires à la résolution du challenge. On observe globalement le même schéma d’interaction, à savoir plusieurs clients communiquant avec le serveur sur un mode requête / réponse, avec un faible volume de données échangées. Au bout de quelques échanges, la connexion est fermée par le client.

Les paquets suivent tous le schéma suivant :

+----+------------------------------------------------------------+
| 01 | Données d'entropie maximales et de taille multiple de 0x10 |
+----+------------------------------------------------------------+

Lorsqu’on tente d’envoyer des paquets corrompus au serveur, le premier caractère change et un message d’erreur est retourné :

On en conclut un premier format de message très simple :

+----------+------------------------------------------------------------+
| 00 ou 01 | 00 + message d'erreur                                      |
|          | 01 + données chiffrées avec une taille multiple de 0x10    |
+----------+------------------------------------------------------------+

Le troisième message d’erreur est assez intéressant car il évoque le padding ISO 7816-4, ce qui pourrait signifier le chiffrement de données complétées par cette méthode :

Padding ISO 7816-4 (Wikipedia)

Exploitation

En boîte noire et sans autre information, deux attaques principales viennent en tête :

Dans le cas présent, les erreurs générées lors de nouvelles modifications des messages initiaux fournissent les informations suivantes :

Le dernier message d’erreur permet de discriminer une attaque de type padding-oracle. Pour en avoir le coeur net, le script step1_confirm_padding_oracle.py teste, pour deux blocs de chiffré (32 octets), les 256 chars possibles pour le dernier caractère du premier bloc (offset 0xf), en prenant n’importe quel dernier bloc (ici 16 octets de valeur 0x1). En cas de vulnérabilité de type padding oracle, on va avoir normalement un seul paquet correctement déchiffré sur 256 (sans exception liée à un padding incorrect) dans une écrasante majorité de cas. En effet, le seul padding correct pour le dernier octet est 0x80, sauf si l’avant dernier octet est lui-même 0x80 (ce qui a 1 chance sur 256 de se produire si on considère la sortie du déchiffrement AES du dernier bloc comme complètement aléatoire).

Ici, on a bien ce comportement, donc on va pouvoir déchiffrer les messages qui transitent normalement.

Pour cela, on va réaliser l’attaque suivante : pour chaque bloc de 16 octets de données inconnues, on place ce bloc en dernier et deuxième bloc d’un message qu’on va fournir au serveur. Le premier bloc, initialement 16 octets nuls, va servir pour identifier les octets valide du dernier bloc déchiffré.

NOTE: L’implémentation réalisée rajoute en fait un premier bloc aléatoire en plus au début (sans effet crypto), car de temps à autre des connexions se fermaient abruptement (sans pouvoir investiguer sur la cause) dans l’implémentation décrite, aboutissant à l’impossibilité de déchiffrer certains blocs (fermeture de connexion avant la réception de l’oracle).

Le dernier bloc déchiffré est xoré avec l’avant-dernier bloc chiffré pour fourni le dernier bloc de texte clair obtenu. Le padding de ce dernier est vérifié, il n’est valide que pour les données 0x80 0x00 … 0x00. Pour une position donnée on a donc un oracle qui renseigne une fois sur 256 quand le char testé xoré avec le plaintext correspondant vaut 0x80, si les chars suivants sont nuls. Commencer par la fin permet ainsi de calculer les bonnes valeurs du plaintext pour avoir des octets nuls jusqu’à la position testée.

Les articles de référence de SkullSec et Pixis détaillent (avec des schémas dans le deuxième) l’attaque.

Pour résumer, les messages qui sont déchiffrés par une implémentation accessible vulnérable à une attaque de type padding oracle peuvent être déchiffrés par un attaquant utilisant l’oracle de déchiffrement. A aucun moment on ne retrouve la clé, et les messages à déchiffrer doivent avoir été chiffrés par la même clé que celle de l’implémentation pour obtenir du contenu intelligible.

La petite subtilité

Une fois l’attaque en place, on peut déchiffrer tous les paquets envoyés des clients vers le serveur :

python 02-mitm_decrypt_server.py

NOTE: Le déchiffrement est loin d’être instantané : pour chaque caractère qu’on cherche à déchiffrer, il faut compter en moyenne 128 essais sans optimisation. C’est pourquoi une heuristique bas de gamme a été implémentée pour tester en priorité certains caractères en fonction du contexte de déchiffrement.

On comprend alors la structure globale du format des messages sur la base des différents paquets déchiffrés (client vers serveur) :

Format général des messages

Avec les types de paquets suivants :

De plus, les 16 premiers octets d’un message de type 0x1 constituent le vecteur d’initialisation de l’opération cryptographique, et les 8 premiers octets de chaque paquet déchiffrés constituent un identifiant de session : modifier un caractère de ce dernier produit le message d’erreur “Invalid Session ID” pour quasiment tous les paquets.

Le message “Hello” donne a priori un identifiant de session car c’est le seul pour lequel la session envoyée par le client est constituée de 8 octets nuls.

Pas de clé de license a priori en vue, toutefois on observe des paquets contenant du json avec les champs username, password, … (comme une sorte de getProfil à partir de la session en cours). En réponse, on peut voir des paquets assez volumineux renvoyés par le serveur.

Naturellement, on tente de faire la même chose avec les messages chiffrés par le serveur et renvoyés au client. Mais lorsqu’on réalise l’opération de la même manière que précédemment (en utilisant le padding oracle du serveur), l’attaque réussit sans que cela révèle de données en clair intelligibles.

C’est bien embêtant car effectivement rien ne nous dit que le processus de chiffrement client vers serveur est le même que le processus inverse, que les clés sont les mêmes, ou qu’une vulnérabilité similaire est présente.

Qu’à cela ne tienne, la première chose qui vient à l’esprit maintenant et de tenter d’exploiter la même vulnérabilité mais dans le sens inverse, à savoir du serveur vers le client. Ainsi, à la place de fournir le paquet au serveur et de le renvoyer au client, on va immédiatement renvoyer un paquet au client qu’on aura modifié de la même manière que précédemment.

Comme les concepteurs du challenge sont des gens sensés, l’attaque réussit et on peut cette fois-ci déchiffrer les paquets initialement envoyés du serveur vers les clients qui s’y connectent. On peut en déduire que la méthode utilisée est la même mais que la clé de chiffrement diffère.

NOTE: Si le déchiffrement des données client vers serveur était long, alors celui des données serveur vers client est extrêmement long. En effet, contrairement au premier cas, on est obligé d’attendre que les clients se connectent, pour leur envoyer un paquet qui va provoquer dans la majorité du temps un padding incorrect et stopper la connexion. Un beau déni de service par ailleurs.

Le délai de connexion variant généralement de l’ordre d’une seconde entre chaque session reçue, cela devient vite très long de déchiffrer le moindre paquet. L’heuristique précédente est alors la bienvenue :

python 03-mitm_decrypt_client.py

On trouve alors le mot de passe de plusieurs comptes dont rambro et test. Mais pas de trace de notre clé de license. De plus, les comptes sont non activés d’après le JSON retourné.

Mieux vaut compter une journée durant laquelle on laisse tourner le script pour déchiffrer la majorité des paquets différents reçus.

La petite dernière subtilité

Finalement, on se rend compte qu’une fois authentifié (disposant d’une session valide), certaines fonctionnalités nécessitent de plus d’être en possession d’un compte activé. C’est notamment le cas du message ID 9. Lorsqu’on déchiffre la réponse gigantesque de ce dernier, on constate qu’il s’agit de notes de mise à jour. Mais où est le compte associé ? Aucune trace de message de type 7 avec la session utilisée, donc on aurait pu attendre longtemps, jamais on n’aurait vu passer la réponse du message d’authentification avec la session du compte activé.

Mais on peut générer nous même ce message : on a déjà le format requis et le contenu du message, ne nous reste qu’à réutiliser la session du compte qu’on sait activé.

La démarche est donc la suivante :

Greffer la session se fait assez facilement via un simple xor des 8 premiers octets d’un message chiffré de type 7 :

all_decrypted_messages = refresh_decrypted()
all_messages = get_all_messages()
for index, dm in all_decrypted_messages.items():
    if b'"username": "", "password": ""'.hex() in dm.decrypted:
        print("[+] Found one message ok matching target, taking it")
        base_message = all_messages[index]
        # Extract the session to recompute the good one after
        decrypted_session = bytes.fromhex(dm.decrypted)[:8]

# Extract the encrypted session, we will replace it with target later
base_message_enc_session = bytes.fromhex(base_message.enc_bytes)[1:9]
remaining_message_enc = bytes.fromhex(base_message.enc_bytes)[9:]

# When receiving the origin packet for a big message ID 9 received (as input_data)
out = decrypt_and_act(13337, input_data[1:], 0)
session = out[:8]
target_enc_session = xor( xor(decrypted_session, session), base_message_enc_session )
message = target_enc_session + remaining_message_enc
to_decrypt_final = send_receive(message)

# At last we decrypt to_decrypt_final and get the flag

Le tout est automatisé dans 04-mitm_fish_and_pwn.py (il est conseillé de prendre un bon café en attendant que ça finisse):

python 04-mitm_fish_and_pwn.py

Les données récupérées incluent une license qui permet de valider le challenge :

00000000  18 fe 4b f6 bb dc 70 d4 07 36 00 7b 22 75 73 65  |.þKö»ÜpÔ.6.{"use|
00000010  72 6e 61 6d 65 22 3a 20 22 74 72 69 6e 69 74 79  |rname": "trinity|
00000020  22 2c 20 22 70 61 73 73 77 6f 72 64 22 3a 20 22  |", "password": "|
00000030  44 30 64 67 33 54 68 31 73 22 2c 20 22 61 63 74  |D0dg3Th1s", "act|
00000040  69 76 61 74 69 6f 6e 20 6b 65 79 22 3a 20 22 50  |ivation key": "P|
00000050  52 32 59 55 35 43 5a 47 43 59 4d 53 32 37 32 47  |R2YU5CZGCYMS272G|
00000060  4c 5a 31 57 41 34 33 57 37 50 34 34 49 37 53 22  |LZ1WA43W7P44I7S"|
00000070  7d 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00  |}...............|

On peut aussi récupérer le mot de passe d’autres utilisateurs comme antonio, julio_mafioso ou godfather, avec les mots de passe G7Amj28mOwZ05Fnt2, abc123 ou encore TheLordWatchingYou.

On peut trouver le flag de l’étape à l’adresse http://163.172.99.233:8080/PR2YU5CZGCYMS272GLZ1WA43W7P44I7S :

curl http://163.172.99.233:8080/PR2YU5CZGCYMS272GLZ1WA43W7P44I7S 2>&1|grep SSTIC\{
  ...SSTIC{f4746e9051d51bcf26c77f02ccb...}

Analyse post-mortem

Cette étape est la seule pour laquelle aucune erreur majeure n’a été faite ralentissant la résolution.

NOTE: Finalement si : au moment de la rédaction du write-up, la volonté de remettre le code au propre s’est soldée par un semi-échec sur la partie déchiffrement client et une perte de temps importante #asyncio……. Toutefois, ce challenge demeure intéressant dans la mesure où les temps d’exécution pour l’oracle sont non négligeables, et où la mise en place d’heuristiques pour choisir les bons messages à déchiffrer et les caractères à choisir peut s’avérer utile.