É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é :
- Octet 0 :
Invalid status code
; - Octet 1 :
Invalid session ID
; - Octet 17 :
Invalid ISO 7816-4 padding
; - Dernier octet :
Payload length cannot be higher than 1013
.
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 :

Exploitation
En boîte noire et sans autre information, deux attaques principales viennent en tête :
- Soit certains bloc sont réutilisés ou il existe des anomalies statistiques dans les données interceptées, qui pourraient traduire la réutilisation de données ou l’utilisation de suites cryptographiques faibles ;
- Soit il est possible de provoquer des comportements du côté de l’un ou l’autre des participants qui traduisent un manque de contrôle sur les données reçues.
Dans le cas présent, les erreurs générées lors de nouvelles modifications des messages initiaux fournissent les informations suivantes :
- Soit le paquet a une taille invalide ne validant pas les tailles autorisées 16*x + 1 et le paquet n’est pas traité ;
- Soit on modifie les données entre le premier bloc et le bloc N-2, ce qui provoque des erreurs diverses, mais le plus souvent un invalid session ID ;
- Soit on modifie un caractère parmi les deux derniers blocs et un message de padding ISO7816-4 invalide est retourné.
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) :

Avec les types de paquets suivants :
- 01 = Hello
- 02 = authenticate (chaîne de 0x40 caractères hexadécimaux envoyée en argument)
- 03 = ?
- 04 = ?
- 05 = version
- 06 = envoi du timestamp courant
- 07 = envoi d’un json {“username”: ““,”password”: ““,”activation key”: ““} (sorte de getProfil)
- 08 = ?
- 09 = ts:0
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 :
- Fréquence des lettres et des chiffres ;
- Présence plus probable de caractères spéciaux avant d’autres caractères spéciaux (formatage json) ;
- Padding constitué de 0x0 et 0x80, on peut tenter ces caractères en priorité dans le dernier bloc de chaque message.
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 :
- Intercepter les messages qui transitent ;
- Lorsqu’on voit passer un très gros message, cela signifie que c’est la session d’un compte activé qui est utilisée ;
- On peut donc déchiffrer le message d’origine émis par le client pour récupérer la session associée ;
- On greffe cette session dans le message de type 7 {“username”: ““,”password”: ““,”activation key”: ““} et on renvoie le message chiffré au serveur ;
- On déchiffre la réponse du serveur, qui devrait nous afficher les données relatives au compte activé.
Greffer la session se fait assez facilement via un simple xor des 8 premiers octets d’un message chiffré de type 7 :
= refresh_decrypted()
all_decrypted_messages = get_all_messages()
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")
= all_messages[index]
base_message # Extract the session to recompute the good one after
= bytes.fromhex(dm.decrypted)[:8]
decrypted_session
# Extract the encrypted session, we will replace it with target later
= bytes.fromhex(base_message.enc_bytes)[1:9]
base_message_enc_session = bytes.fromhex(base_message.enc_bytes)[9:]
remaining_message_enc
# When receiving the origin packet for a big message ID 9 received (as input_data)
= decrypt_and_act(13337, input_data[1:], 0)
out = out[:8]
session = xor( xor(decrypted_session, session), base_message_enc_session )
target_enc_session = target_enc_session + remaining_message_enc
message = send_receive(message)
to_decrypt_final
# 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.