Étape 2 - Greenshard brawl
Description
Well done agent! This license key should allow us to authenticate against the organisation's private game server.
Indeed, we have confirmed that the organisation runs their own game, called Green Shard Brawl, for both leisure and communication purposes.
Our next target will be the organisation's lead developer. We know for a fact that the target is an avid player of Green Shard Brawl, and is thus highly likely to be connected to the game.
While you were busy deciphering the communications, our intelligence team has managed to lay their hands on a few assets of utmost value:
* the sources of the server;
* a Linux build of the game client.
With enough reconnaissance, we have also been able to craft a Dockerfile that accurately mimics the target's desktop environment (yes!). All assets were compiled into a single archive that you can download here.
Your mission is to gain entry into the target's machine through a reverse shell. As the game client is written in C and involves a custom protocol, surely there are bugs you can leverage to hack your way through...
This would allow us to further infiltrate the organisation's network, and perhaps pivot to other servers or actors owning resources that would greatly benefit our investigation. We hold high hopes in you!
Résolution
J’ai beaucoup aimé cette étape qui consiste donc à tenter la compromission d’un client qui se connecte automatiquement au serveur de jeu indiqué. Cela change des traditionnels pwnable notamment parce qu’il s’agit de maîtriser l’état d’une exécution client au travers des informations reçues par un composant tier non maîtrisé et (normalement) non vulnérable.
De plus, l’intégralité des fichiers nécessaires pour exécuter le serveur de jeu et le client sont données ; les informations données sont complètes et peuvent être retrouvées ici :
NOTE:Le script
exploit_full.py
permet de lancer l’exploit et a des bonnes chances de succès.
Avant de commencer
Comme mentionné au-dessus, il s’agit de compromettre un client, donc les points suivants risquent de ralentir l’exploitation :
- L’impossibilité de contrôler directement les actions du client ciblé : l’interface du serveur de jeu entre nous et la cible limite ainsi potentiellement les différentes actions qu’on peut réaliser ;
- Les trames de jeu sont envoyées en UDP avec un système de polling des clients, ce qui va entraîner la nécessité de bien gérer l’état à la fois du serveur de jeu et de la cible, ainsi que de détecter des changements d’états entre des trames successives.
Toutefois, les créateurs du challenge ont choisi de contre-balancer ces difficultés :
- Le code source du serveur de jeu est intégralement fourni et en python, facile à lire ; le client est quant à lui non obfusqué et assez léger, et les symboles sont fournis comme la structure principale _Player, ce qui rend l’étape de reverse-engineering plutôt linéaire et sans difficulté majeure ;
- Un premier survol du code source mentionne l’absence d’anti-cheat dans un commentaire, on peut donc imaginer que cela va impliquer l’absence de contrôle par le client des messages envoyés par le serveur de jeu et par nous ;
- Exécuter et jouer au jeu pour comprendre les interactions possibles révèle rapidement un comportement suspect qui facilite la découverte de la vulnérabilité principale.
Setup
Le challenge peut être lancé via le docker-compose fourni :
cd dist
docker-compose up -d
L’exécution de ce docker-compose provoque la venue régulière d’un joueur avec le client fourni, qu’on essaie donc de compromettre. En cas de crash, le client est redémarré automatiquement. Le client est un client en C qui utilise la bibliothèque SDL pour l’implémentation de la partie graphique et des interactions utilisateur.
Pour l’interaction avec le serveur de jeu et les PDU envoyés, j’ai créé une classe Loop
dans utils/recv_thread.py
qui se charge de récupérer tous les PDU des différents joueurs et qui prend en argument les handlers pour les différents types de PDU. On réutilise également le fichier de protocole utils/protocol.py
fourni dans les sources du challenge.
NOTE: Il est possible de paramétrer une autre constante de timeout (MAX_TIMEOUT) dans le serveur de jeu pour faciliter les sessions de débogage (vu que les pauses peuvent être longues, cela finit par déconnecter les joueurs et nécessite de refaire le processus d’authentification). Cela se fait au détriment du nettoyage des joueurs au niveau du serveur de jeu qui doit être redémarré régulièrement pour la stabilité de l’exploit dans ce cas.
Il est également possible de rejoindre le serveur de jeu en tant qu’observateur pour voir ce qui se passe visuellement. Cela n’a toutefois d’intérêt qu’au début, l’essentiel des interactions se faisant ensuite statiquement.
Un comportement intriguant
La quantité globale d’interaction que permet le jeu reste limitée, essentiellement, il s’agit de bouger dans trois zones différentes, il semble y avoir 2 équipes (rouge et bleue), on peut attaquer les autres joueurs présents, et finalement activer un bouclier temporaire lorsqu’on marche sur une gemme verte (d’où le nom du jeu).
Les points de vie de chaque joueur sont initialisés à 100 lorsqu’on rejoint une partie et sont visibles en haut à gauche. Toutefois, lorsqu’on prend une gemme verte et qu’on est suffisamment rapide pour bouger sur un autre écran, les points de vie affichés semblent ne correspondre à rien.
En réalité, il s’agit d’une fuite d’information dont la compréhension de l’origine va s’avérer décisive pour la suite :

Danse avec les bloups
Après avoir confirmé que ce comportement allait être intéressant, je me suis dit que cela valait la peine de prendre le temps d’automatiser la partie “poussage” du joueur adverse en dehors de l’écran pour provoquer ce comportement de manière automatique. Réaliser cette action est facilité par l’absence de mécanisme anti-triche : d’où qu’on soit situé sur la carte, on peut envoyer des PDU d’attaque à un joueur ; et le client fourni applique ces attaques et bouge le joueur en conséquence. Il suffit de détecter où est le client par rapport à la gemme et de l’attaquer dans la bonne direction.
Le script le_bouclier_tu_prendras.py
réalise cette opération. Afin de contrôler finement les opérations qui se déroulent dans la zone du tas qui nous intéresse, la fonction en charge de pousser le joueur attend que tous les objets sur la carte soient présents (3 au maximum, avec 2 coeurs et 1 gemme). Cela permet d’assumer un layout du tas fixe (sinon les objets sont générés aléatoirement et les allocations mémoire qui surviennent au moment de la génération des objets peuvent interférer avec les allocations mémoire liées aux événements de jeu (joueur apparaissant, chat, …)).
wait_for_all_objects_and_find_cristal(ctxt)
push_target_on_player(ctxt)
wait_for_all_objects(ctxt) push_outside(ctxt)
On attend ainsi la génération d’une nouvelle gemme verte après avoir poussé le joueur sur la précédente pour la même raison. Ensuite on pousse le joueur à l’extérieur de l’écran, déclenchant le bug.
Leak de l’arena principale (non main)
On peut facilement identifier dans le code l’endroit où la donnée des points de vie du joueur est affichée :

Ainsi, si la valeur des points de bouclier du joueur n’est pas nulle, l’attribut greenshard_hp de l’objet shield associé au joueur est considéré.
Cet affichage est réalisé côté client, mais qu’en est-il des informations envoyées à l’ensemble des autres joueurs ? Il s’avère que l’information est également envoyée dans le même cas de figure :

Le PDU CLIENT_PLAYER_INFO_PDU étant public, tous les joueurs reçoivent ainsi en permanence des trames contenant la valeur de l’attribut greenshard_hp du bouclier des joueurs utilisant le client fourni, si passés par un bouclier.
Revenons rapidement sur les trois “structures” principales fournies dans le client :
- Player : modélise un joueur (taille : 0xb8) ;
- Object : modélise un bouclier ou un cœur (taille : 0x20) ;
- Chat data : buffer de données pour recevoir les PDU de chat et les afficher, ils sont libérés 5 secondes après la réception et sont de taille variable et contrôlable par l’utilisateur qui envoie un message dans un PDU de type CHAT.
La structure Player est assez volumineuse mais contient principalement des attributs de jeu :

On ne trouve ainsi que 2 pointeurs dans cette structure, incluant un pointeur vers une texture SDL et un autre vers le bouclier potentiel du joueur. Le nom du dernier joueur ayant attaqué le joueur local est également une variable intéressante puisqu’on la contrôle depuis des messages envoyés au serveur de jeu.
La structure Object est plus limitée, mais elle montre que greenshard_hp est le premier attribut de cette structure :

Lorsque le joueur se trouve à l’emplacement d’un bouclier, le client associe un pointeur vers cet objet à l’attribut shield du joueur. Cependant, lorsque le joueur change d’écran, le compteur de référence associé au bouclier est décrémenté et l’objet du bouclier détruit, sans que l’attribut shield soit réinitialisé à 0 :

On a donc un bel use-after-free ! Et en prime, si on attaque le joueur, on modifie la valeur qui est stockée par ce pointeur. En cas d’allocation de même taille, cela provoque un crash du client (on a donc à la fois une primitive de lecture et d’écriture sur un pointeur).
Lorsqu’on pousse un joueur utilisant le client, avec un bouclier, hors de l’écran en étant sûr qu’aucun objet / player ne va être alloué entre temps, on obtient donc la valeur de ce qui est écrit à l’emplacement de l’attribut greenshard_hp de l’objet libéré après sa libération.
Comme il s’agit d’un objet de faible taille et qu’a priori on est dans un thread associé à une arena qui n’est pas beaucoup utilisée, les TCaches sont disponibles pour accueillir l’objet libéré lors de l’appel à free
.
Et même mieux, comme aucun chunk de taille 0x30 n’a été libéré avant la libération à l’origine de l’use-after-free, il n’y a pas de pointeur à réécrire à l’offset 0 du chunk libéré. Et heureusement !
En effet, depuis la version 2.32 de GLIBC, il existe des mécanismes de safe-linking dont une implémentation de protection des pointeurs tcache : les pointeurs vers les éléments suivants de la chaîne sont xorés avec une valeur qui dépend de l’adresse du pointeur elle-même (mmap_base) shiftée, cf. l’article de Checkpoint.
En pratique, ce mécanisme prévient de réécritures réussies de pointeurs en randomisant le pointeur écrit. Ainsi, lorsqu’on leak l’élément suivant d’un tcache, on ne leak pas la valeur de l’adresse de l’élément, mais la valeur de cette adresse xorée par elle-même après shift, ce qui empêche l’accès à l’adresse véritable. Or, ce mécanisme est aussi appliqué sur des pointeurs qui seraient nuls, comme c’est le cas ici puisque le tcache 0x30 est vide.
La libération de mémoire écrit alors (addr >> 12) ^ 0 = arena_base
à l’emplacement de greenshard_hp, ce qui permet d’accéder à l’adresse de l’arena utilisée.
La suite de l’exploit se décompose en deux parties principales :
- Mise en place d’un mécanisme pour lire et écrire à des emplacements arbitraires ;
- Fuites d’information successives jusqu’à retrouver le thread courant et résoudre les symboles permettant de construire une ropchain complète, avant de réécrire le pointeur de pile par la suite (on verra plus bas pourquoi cette méthode a été choisie).
En route pour l’écriture de données
Ultimement, on cherche à être en capacité de lire et d’écrire à n’importe quelle adresse mémoire. Or, via les données locales (proches) accessibles dans notre espace de tas, il n’existe pas beaucoup de moyens d’obtenir cette primitive au travers des PDU qui vont être reçus par le joueur. Il existe principalement deux moyens fiables d’interaction entre deux joueurs dont l’un utilisant le client fourni, via le serveur de jeu : envoi de messages via le chat, et d’attaques.
L’envoi de messages via le chat présente l’intérêt de pouvoir provoquer l’allocation de données avec une taille contrôlée. Cela se fait au détriment d’une libération de la mémoire qui survient systématiquement 5 secondes après sur la même zone. De plus, cet ensemble allocation / libération s’effectue dans un thread dédié, ce qui a un impact sur la gestion du tas comme on le verra plus bas.
L’autre méthode semble donc plus appropriée ici. En effet, lorsqu’on émet une attaque dirigée contre un joueur utilisant le client, ce dernier applique une différence à la valeur des points de vie du joueur ou des points de vie du greenshard si le joueur a un bouclier : en d’autres termes on peut agir sur la valeur qui est pointée par l’objet shield qui est référencé au niveau de la structure du joueur. Cela signifie également que si on arrive à réécrire ce pointeur sur la structure du joueur local, alors on pourra lire et écrire à n’importe quelle adresse en attaquant le joueur.
L’objectif devient donc d’être en mesure de réécrire le pointeur shield à l’offset 0xa8 de la première structure player allouée sur le tas (vu que le client ne considère l’effet des actions reçues que sur le joueur initialisé au début).
C’est l’étape la plus tricky du challenge et les schémas suivants la résument. Il reste possible de lancer l’exploit exploit_full.py
pour voir l’impact pas-à-pas de chaque allocation.

On commence par la situation globale suivante (à titre d’information, allouer une structure player requiert des blocs des chunks de taille 0xc0, mais alloue également un de taille 0x30 pour la texture) :
- localplayer instancié, tous les objets (3) sont sur le terrain ;
- d’autres joueurs se sont connectés, certains se sont déconnectés. Il s’agit d’avoir des leaks et un tas fragmenté, et la méthode qui a été employée pour réaliser cela tient à beaucoup d’essais-erreurs. La présence d’allocations / libérations de mémoire de plusieurs threads différents sur la même arena semble avoir des impacts sur des opérations qui n’ont pas forcément lieu d’être dans le cas général et qui tend à rassembler des chunks même si ceux-ci sont dans des espaces en théorie faiblement “rassemblables” (et notamment, les tcaches / fastbin) ;

On déconnecte alors l’avant-dernier joueur, laissant vacant un chunk de taille 0xc0 et un autre de taille 0x30, coincés donc non fusionnés dans d’autres espaces ; ainsi que le premier joueur alloué après le poussage du joueur initial par un premier joueur malveillant.

On a donc à la fois deux chunks de taille 0xc0 vacants, et le chunk qui est toujours en utilisation car pointé par le champ shield du joueur ciblé qui est en haut du tcache 0x30, pointant vers le second chunk de taille 0x30 libre.
= Loop() # pousse le joueur
tacle_player = Loop() # l'allocation de 0x30 remplit l'espace laissé vacant par la libération du shield
l = Loop()
l4hole = Loop() # avant dernier joueur
l2 = Loop() # un dernier joueur pour maîtriser les données libérées lors de la déconnexion de l2
l3hole # on déconnecte l2 puis l : tcache pointe vers la texture de l qui était l'ancien shield (et toujours référencé par localplayer), qui pointe vers un autre chunk de 0x30
l2.disconnect() l.disconnect()
Une fois dans cette situation, on peut déclarer 2 chunks Chat de taille 0x60 qui vont remplir l’espace vacant de taille 0xc0. Ces chunks vont être consécutifs. On crée artificiellement un faux chunk 0x30 qui se situe à la fin du 1er chunk 0x60 et “empiète” sur le début du suivant.


On attend 5 secondes, les deux chunks de taille 0x60 sont libérés dans le tcache correspondant.
On modifie également la valeur pointée par le top chunk tcache 0x30 en attaquant le joueur, pour faire pointer next à l’adresse du faux chunk qu’on a artificiellement créé. La prochaine allocation de 0x30 réussit car malloc vérifie que le chunk est bien légitime à accueillir des données de taille 0x30 mais on l’a créé pour ça. On en profite pour réécrire le pointeur next du tcache 0x60 en tête vers la structure du localplayer.

Auparavant, on aura pris soin d’attaquer le joueur avec un joueur ayant un nom de la forme xxxxxxxxxxxxxxE
(c’est-à-dire un nom de 15 caractères finissant par la lettre “E” = 0x65, qui permet là encore de créer un fake chunk autorisé pour l’allocation mémoire de 0x60 octets lors d’un prochain malloc).
NOTE: Ah oui j’ai oublié de le préciser, pour simplifier sur les schémas tous les chunks sont donnés alignés avec 0x10, évidemment il faut prendre en compte les bits PREV_INUSE et NON_MAIN_ARENA, qui valent ici true et true donc 5, le 0x60 est un 0x65.

On alloue ainsi deux nouveaux chats de taille 0x60, le deuxième pointe alors vers la structure localplayer, et on a la place pour écrire des données jusqu’au pointeur shield, finalisant ainsi le scénario.
Fin de l’exploitation
Une fois la primitive de lecture et d’écriture en place, la fin de l’exploitation est assez directe, même s’il faut attendre au moins 5 secondes à chaque fois entre chaque leak pour laisser le temps à chaque message envoyé par chat de réécrire l’adresse, puis de remettre le tcache 0x60 dans son état vulnérable pour le prochain malloc.
En l’occurrence, voici ici résumées les différentes actions effectuées (la plupart des leaks du début ont été visualisés empiriquement via gdb pour vérifier que dans toutes les situations les objets fuités aux offsets indiqués correspondent toujours à la même donnée, il existe très sûrement des chemins plus courts) :
- Leak du pointeur de la texture SDL sur un joueur (Tas, associé aux éléments alloués lors de l’appel à des fonctions de la SDL) ;
- Leak d’une donnée vers libSDL2-2.0.so.0 (BSS) ;
- Leak d’une donnée vers libc.so (section data, pthread_setname_np) ;
- Leak de 16 octets consécutifs dans libc.so pour identifier l’offset exact ;
- Leak de la fonction libc system ;
- Leak de la variable environ de libc pointant sur la stack principale ;
- Leak de la variable de retour dans la fonction main dans le thread principal depuis main_loop, pointeur vers la base du programme ;
- Leak de la variable globale network_thread créée avec pthread_create et gérant le thread en charge des interactions réseau ;
- Leak pour confirmer qu’on va réécrire au bon endroit (optionnel) ;
- Préparation d’un premier payload : un grand buffer avec notre commande shell à exécuter, au milieu de données qui pointent vers ce dernier (on peut facilement obtenir le bon offset si on leak n’importe quelle partie du buffer) ;
- Écriture de la charge pour obtenir l’adresse exacte de la chaîne de commande après un leak ;
- Écriture d’un deuxième payload : un grand buffer avec une ropchain minimale et un ret-sled avant, également envoyé via PDU chat ;
- Écriture d’un
pop rsp; ret
sur la pile du thread network_thread, avec rsp à la suite qui pointe vers le buffer précédent, via la primitive d’écriture (x2) ; - Écriture du gadget de désynchronisation de la pile à l’adresse de retour de l’appel à la fonction handle_server_attack_pdu dans le thread réseau, qui est donc immédiatement appelé. Il est nécessaire d’avoir un gadget 1 shot vu que la gestion de l’attaque ne permet d’écrire que 8 octets contrôlés d’un coup. Le gadget
add rsp, 0x118; ret;
fait très bien l’affaire (comme de par hasard, ça tombe sur les 16 octets écrits juste avant).
Pfiou. C’était long mais c’est assez déterministe surtout sur l’environnement distant (une fois n’est pas coutume).
NOTE: Pour toutes celles et ceux qui se demandent pourquoi faire une chaîne de leaks aussi longue par rapport à l’exploitation de techniques plus simples comme l’écriture de hooks connus comme __free_hook, notez que ces derniers ne sont plus utilisés depuis la version 2.34 de GLIBC. Je ne le savais pas et j’ai été bien déconfit de le découvrir.
Le shell tant attendu nous permet de récupérer l’adresse de l’étape suivante :
cat memo.txt
gotta check out this link my bro sent me some time http://163.172.99.233:8080/956a07cd264c1df26beedcef1b3187ad
my password because my dumbass brain keeps forgetting it: ave_viridis_crystallum
curl http://163.172.99.233:8080/956a07cd264c1df26beedcef1b3187ad 2>&1|grep SSTIC\{
...SSTIC{ae50935e902c926aa72cd5c526d...}
Analyse post-mortem
Au bout d’un moment, il s’avère fastidieux de lancer les premières étapes de l’exploitation à la main : attendre qu’une gemme apparaisse, puis pousser le joueur dessus, puis le pousser suffisamment rapidement à l’extérieur du cadre, avec en plus un taux de succès non parfait. Prendre le temps d’automatiser cette étape (script le_bouclier_tu_prendras.py
) a été un pari gagnant pour la suite.
Cependant, l’erreur principale effectuée lors de cette étape a été de passer trop vite à la partie exploitation sans creuser davantage d’autres potentielles vulnérabilités dans le client.
Il existe en effet une vulnérabilité de type format-string qui permet en théorie de faciliter grandement l’exploitation ci-dessus sans avoir besoin de contrôler si finement le tas. Cela permet également de diminuer la durée totale de l’exploit, supprimant les 5 secondes nécessaires entre chaque étape (d’ailleurs, si la durée de disparation du bouclier avait été de l’ordre de la minute, cela aurait rendu l’exploitation infernale).
NOTE: L’exploit fonctionnant avec des très bonnes chances de succès, la primitive n’a pas été utilisée lors de l’étape de nettoyage et de rédaction.