Submarine Titans

Introduction

Pour changer un peu, on va rechercher une RCE dans Submarine Titans. C’est un jeu de stratégie développé par Ellipse Studio et sorti en 2000. L’environement à mettre en place est similaire à celui préparé dans l’article sur Age Of Empire I.

Submarine Titans - Version 1.1.0

Point d’accroche

A l’instar de l’article précédent on navigue dans les différentes interfaces du jeu pour générer du trafic sur le réseau. A première vue le jeu utilise DirectPlay,

On peut rapidement adapter le script de l’article Age Of Empire I pour répondre aux requêtes Direct Play Enum Session avec le GUID de Submarine Titans. Malheureusement un nom de partie trop grand ne suffit pas à faire crasher le jeu. On va se concentrer sur un autre aspect du multijoueur.

Submarine Titans propose au joueur d’éditer ses propres cartes. Les cartes peuvent être utilisées en ligne, lorsque le joueur n’a pas la carte demandée, l’hôte de session envoye automatiquement la carte au joueur.

Si l’on monitore les opérations sur les fichiers avec l’outil Process Monitor1, on remarque que le programme enregistre la nouvelle carte qui est composée de deux fichiers .DKD et .DKX .

On va se concentrer sur la fonctionnalité du téléchargement de carte.

La fenêtre de téléchargement contient 2 strings “LANCE NOUVEAU COMBAT” et “Downloading map. %d%% copiés." qui peuvent être utilisés comme points d’entrée pour le reverse. Les strings ne sont pas présente dans le binaire principal, cependant dans le répertoire d’installation il y a une DLL nommée st_string.dll qui contient de nombreuses ressources dont des tables de strings.

Chaque chaîne est identifiée par un uID et est chargée par la fonction LoadStringA qui a le prototype suivant:

int LoadStringA(
  HINSTANCE hInstance,
  UINT      uID,
  LPSTR     lpBuffer,
  int       cchBufferMax
);

hInstance est un handle sur le module qui contient les chaînes, il retourné par la fonction GetModuleHandle. On devrait trouver dans le code de Submarine Titans un appel à

GetModuleHandle("st_string.dll");

Une simple recherche de l’uID 252Fh ( 9519 ) dans l’assembleur permet de retrouver deux bouts de code (en 005E8FED et 005E90B7 ) qui pousse la constante sur la stack. On retrouve une variable globale que j’ai nommé ghModStString qui est setté dans la fonction WinMain par un appel à GetModuleHandle .

On peut confirmer que la fonction est appelée en plaçant un breakpoint dessus. Malheureusement on manque un peu de contexte pour pouvoir documenter les champs des différentes structures. La fonction englobante sub_5E84D0 est composé d’un grand switch case, on peut imaginer qu’elle traite différents types de paquets réseau.

Analyse du protocole

Penchons-nous sur le traffic réseau …

Lorsque le joueur est dans le salon de prépartie le jeu semble envoyer constamment des paquets en UDP sur le port 2350. Le serveur répond a chacun des paquets sur le même port. Lorsque le transfert de map commence, on observe des paquets plus grands (ex n°47) en plus des petits (ex n°38). Lorsque le joueur envoye un message dans le chat, on retrouve le nom de l’utilisateur dans la trace réseau mais le message semble compressé (seuls quelques bouts du message apparaissent en clair).

Pour faciliter le reverse du protocole réseau on va essayer de retrouver notre objet DirectPlay. Même technique que dans l’article sur Age Of Empire I, on pose un breakpoint après DirectPlayCreate pour récupérer l’objet. L’interface IDirectPlay contient deux méthodes Receive et Send qui sont utilisées pour envoyer des messages spécifiques à l’application.

En posant un breakpoint a l’entrée de Receive ( addresse contenue dans la vtable à l’offset +0x64 ), on récupère l’adresse de retour dans la stack 006B71B4.

DPRESULT Receive( LPDPID lpidFrom, LPDPID lpidTo, ReceiveFlags dwFlags, [out] LPVOID lpData, [out] LPDWORD lpdwDataSize );

Le paquet commence par un header spécifique à DirectPlay car lpData commence 8 octets plus loin. Ce header contient les champs idTo puis idFrom sur 32 bits.

Ensuite la fonction 006B7190 commence le parsing du paquet en testant le premier octet, qui est probablement un id de commande. La fonction effectue un traitement particulier pour les paquets avec CommandId égal à 5 ou 6, tout les autres sont insérés dans une liste chainée. En suivant l’utilisation de cette liste, on retrouve notre fonction de décompression ( 007519D0 ), et quelques informations complémentaires sur les paquets.

Au final les paquets ont le format suivant,

Lorsque le bit 7 du champ Cmd est set et que Unknown vaut FFFFFFFFh, le champ Data est compressé. Le champ Size indique la taille du champ Data une fois décompressé.

D’après la capture Wireshark on peut retrouver quelque valeur possible pour le champ Cmd :

  • 9Ah : ping
  • 9Bh : chat message
  • A6h : download de carte

Emulation

Malheureusement l’algorithme utilisé pour la compression ne ressemble à rien de connu. Pour décompresser on peut tenter de recoder la fonction, instrumenter le binaire avec un debugger, ou encore utiliser unicorn2 pour émuler la fonction de décompression.

Pour valider l’emulation, il nous faut un clair connu. Les messages envoyés par le chat sont compressés, si notre émulation fonctionne correctement on doit pouvoir lire les messages du chat.

from pwn import *
from unicorn import *
from unicorn.x86_const import *

UNCOMPRESS_BEGIN_OFFSET_FILE = 0x3519D0
UNCOMPRESS_END_OFFSET_FILE   = 0x351B07

UNCOMPRESS_BEGIN_VADDR = 0x007519D0
UNCOMPRESS_END_VADDR = 0x00751B06
BASE_VADDR = 0x750000

ESP = 0x3000

def extract_code(path):
    f = open(path,"rb")
    f.seek(UNCOMPRESS_BEGIN_OFFSET_FILE)
    CODE = f.read(UNCOMPRESS_END_OFFSET_FILE - UNCOMPRESS_BEGIN_OFFSET_FILE)
    f.close()
    return CODE
    
def emulate_uncompress(compressed_data):
    
    code = extract_code("ST.exe")
    
    uc = Uc(UC_ARCH_X86,UC_MODE_32)
    # create .text map
    uc.mem_map(BASE_VADDR,0x4000)
    # create stack
    uc.mem_map(0x1000,0x4000)
    # compressed
    uc.mem_map(0x8000,0x4000)
    # uncompressed
    uc.mem_map(0xC000,0x80000)
    
    uc.mem_write(UNCOMPRESS_BEGIN_VADDR,code)

    uc.mem_write(0x8000,compressed_data)

    # prepare execution context
    uc.reg_write(UC_X86_REG_ESP,ESP)
    uc.reg_write(UC_X86_REG_EBP,ESP)
    uc.reg_write(UC_X86_REG_EIP,UNCOMPRESS_BEGIN_VADDR)
    
    # prepare stack context
    uc.mem_write(ESP + 4,p32(0x8000)) # pCompressed
    uc.mem_write(ESP + 8,p32(0xC000)) # pUncompressed
    
    try:
        uc.emu_start(UNCOMPRESS_BEGIN_VADDR,UNCOMPRESS_END_VADDR - UNCOMPRESS_BEGIN_VADDR)
    except:
        pass
        
    # read return value
    EAX = uc.reg_read(UC_X86_REG_EAX)
    data = uc.mem_read(0xC000,EAX)
    return data

Ci-dessous un exemple de décompression d’un message du chat :

Dans la même idée, on peut émuler la fonction de compression pour envoyer un peu ce qu’on veut …

Vulnérabilitée

Lorsqu’on décompresse les paquets associés au téléchargement de carte on remarque que les deux fichiers .DKD et .DKX sont transféré par bloc de taille fixe. Chaque bloc est accompagné d’une entête au format suivant

  • ChunkNum : numéro du bloc
  • nChunkToReceive : nombre total de blocs à envoyer
  • TotalMapSize : taille totale du transfert en octets ( taille du fichier .DKD + taille du fichier .DKX + longueur du nom de la carte )
  • DKDSize : taille du fichier .DKD
  • DKXSize : taille du fichier .DKX
  • ChunkSize : taille du champ Data, constante à 0x400 pour chaque paquet

Maintenant qu’on connaît le format du paquet on peut documenter un peu plus le code de la sub_5E84D0, et notamment la partie traitement des messages de type A6h.

	v44 = v5->downloadctx;
	v45 = v5->nRestChunk - 1;
	v5->field_1A7B = *(_DWORD *)v5->gap61;
	v5->nRestChunk = v45;
	percent = (unsigned int)(100 * v44->nChunkToReceive - 100 * v5->nRestChunk)
		  / v44->nChunkToReceive;
	format_downloadprogress = (const CHAR *)ST_LoadString(0x252Fu, hModStString);// Downloading map. %d%% copiés.
	wsprintfA(GlobalTempBuffer, format_downloadprogress, percent);
	j_WaitTy::AddStr(GlobalTempBuffer, 1);
}
if ( !v5->nRestChunk )
{
	if ( !j_WriteMapToDisk(v5->downloadctx) )
	{

Comme on peut le voir dans le code lorsque le transfert est terminé les fichiers de cartes sont écrit sur le disque. On entre dans la fonction j_WriteMapToDisk et là c’est le drame …

Le nom de la carte est copié dans un buffer local de taille fixe (260 bytes) et sa taille n’est pas vérifiée, il y’a stack based buffer overflow.

DKXSize = v21->DKXSize;
DKDSize = v21->DKDSize;
MapNameFromFile = (const char *)(v21->BufferMap + DKDSize + DKXSize);
NumberOfBytesWritten = v21->TotalBytesToReceive - DKDSize - DKXSize;
strncpy(MapName, MapNameFromFile, NumberOfBytesWritten); // vuln
v7 = !v23;
FileName[NumberOfBytesWritten + 0x103] = 0;

Exploitation

On peut tenter d’écraser l’adresse de retour, mais pour cela il faut atteindre la fin de la fonction sans erreur. La stack frame de la fonction n’est pas très arrangeante car pour écraser l’adresse de retour on corrompt NumberOfBytesWritten (par une valeur sans octet null à cause du strncpy).

Le programme a de fortes chances de crasher sur la ligne suivante si NumberOfBytesWritten n’est pas négatif.

FileName[NumberOfBytesWritten + 0x103] = 0;

On va plutôt exploiter le programme via le SEH3. Pour rappel SEH est une extension au C par Microsoft pour gérer les exceptions comme access violation.

Les handlers d’exception sont représentée par une liste chainée,

typedef struct _EXCEPTION_REGISTRATION_RECORD
{
   struct _EXCEPTION_REGISTRATION_RECORD *Next;
   PEXCEPTION_ROUTINE                     Handler;
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;

Lorsqu’une exception se produit, le système parcourt la liste jusqu’à trouver le handler approprié pour gérer l’exception. Les structures EXCEPTION_REGISTRATION_RECORD sont poussées sur la stack en début de stack frame. Par conséquent il est possible de les écraser avec la vulnérabilité et de modifier le handler associé.

On pose un breakpoint sur strncpy , on a en vert l’adresse de notre buffer MapName et en rouge l’adresse d’une structure de type EXCEPTION_REGISTRATION_RECORD.

Ce qui nous fait 12FF20h - 12F5ACh soit un nom de carte de 974h bytes pour écraser la structure.

Pour simuler le serveur de jeu, j’ai rejoué la quasi-totalité des paquets DirectPlay grâce à la capture Wireshark jusqu’au transfert de map.

Avec un nom de carte assez grand, on écrase le membre Next avec DEADBEEFh et Handler par 41414141h, voyons ce qui se passe au debugger (la première exception doit être passée au programme).

Le programme plante sur l’endroit attendu 41414141h, à noter on retrouve sur la stack l’adresse de notre EXCEPTION_REGISTRATION_RECORD corrompu. Un gadget de type pop pop ret ( par exemple en 005DB460h ) nous permet de sauter sur la structure.

On a 4 octets dans le membre Next pour composer un jmp de manière à brancher sur un shellcode qui sera contenu dans le buffer MapName . 90 90 EB 80 suffit à sauter 126 octets plus bas dans la stack.

On y place le shellcode habituel à base de WinExec et on a une calc :D

Références

  1. https://docs.microsoft.com/en-us/sysinternals/downloads/procmon
  2. https://github.com/unicorn-engine/unicorn
  3. https://www.exploit-db.com/docs/english/17505-structured-exception-handler-exploitation.pdf