Space Empire V

Introduction

En fouillant dans mon grenier j’ai retrouvé un vieux jeu de 2006 nommé … Space Empire V ! C’est un jeu 4X ( eXplore, eXpand, eXploit and eXterminate ) de stratégie en tour par tour développé par Malfador Machinations. Dans cet article nous partirons en quête de vulnérabilités dans le mode multijoueur.

Space Empire V - 1.33

Point d’accroche

Space Empire V propose un mode multijoueur en LAN basé sur DirectPlay. Lors d’une partie de Space Empire V chaque joueur doit créer un empire pour lequel il doit renseigner de nombreuses informations comme son nom, logo, style de navire etc … Au vu de la boîte d’information status, lors d’une partie en ligne le client semble envoyer un fichier d’empire à l’hôte de la partie. De manière générale le parsing de fichier peut s’avérer complexe et donner lieu à de nombreux bugs.

C’est pourquoi on va s’intéresser au traitement des fichiers d’empires. Tout d’abord récupérerons un de ces fameux fichiers.

Process Monitor1 permet de monitorer les accès par le processus au système de fichiers. Lorsque l’hôte reçoit un fichier d’empire, il le stocke temporairement dans le dossier Space Empire V\Temp.

D’après la commande binwalk le fichier d’empire est compressé.

$ binwalk 1.emp

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             Zlib compressed data, default compression

Une fois décompressé, le fichier semble contenir aucune chaîne de caractères, pas de nom d’empire, pas de nom de vaisseau … On en conclut que les données sont probablement chiffrées par une clé hardcodée ou générée par le programme.

Préparation pour l’analyse du binaire

Lorsqu’on ouvre le binaire dans le désassembleur IDA, on peut remarquer que les arguments des fonctions sont passés par les registres eax,edx et ecx ce qui n’est pas très habituel. Cette convention d’appel est utilisée par défaut par le compilateur Delphi. Un rapide coup d’oeil avec Detect It Easy permet de confirmer le compilateur utilisé.

Pour faciliter l’analyse du binaire par IDA, il est nécessaire de définir le bon compilateur dans le menu “Options” > “Compiler…”.

De nouveau pour faciliter l’analyse, on peut utiliser IDR (Interactive Delphi Reconstructor)2 pour retrouver les différentes classes et leur taille d’instance à partir des RTTI (Run Time Type Informations)3 présentes dans le binaire. IDR est capable de générer un script IDC ce qui permet d’importer ses résultats dans une base IDA.

Mécanisme de log

Lorsqu’on fait de la rétro-ingénierie de manière générale les chaînes de caractères sont une grande source d’information, c’est pourquoi on va s’attarder sur les mécanismes de logs avant de chercher la clé de chiffrement du fichier d’empire.

Lorsqu’on liste les strings (en utf-16) du binaire, on trouve de nombreux noms de fonctions. Le jeu semble avoir un système de logs. En fouillant dans les fichiers du jeu on trouve un manuel C:\Program Files\Empire Interactive\Malfador Machinations\Space Empires V\SE5_Modding.pdf qui décrit brièvement le contenu des différents assets . Le fichier Space Empires V\Data\DebugSettings.txt permet d’activer les logs.

On découvre par la même occasion qu’une grande partie des chaînes de caractères utilisées par le jeu sont stockées dans le fichier Space Empires V\Data\MainStrings.txt. Dans ce fichier les chaînes sont classées par catégories et identifiées par un ID.

En recherchant les catégories dans le binaire, on retrouve une correspondance ID de catégorie et catégorie.

A partir de l’ID de catégorie et l’ID de string on peut résoudre les chaînes de caractères chargés par la fonction que j’ai nommée StringIdToWStr.

On découvre les différents messages qui peuvent être envoyés lors d’une partie multijoueur. Ces informations seront utiles pour comprendre le protocole de communication réseau de Space Empire V. Mais dans un premier temps on va se concentrer sur le mécanisme de chiffrement du fichier d’Empire.

Mécanisme de chiffrement

Grâce aux RTTI on localise la VMT (Virtual Methods Table)4 de la classe TEmpire. A partir de cette VMT on documente la fonction qui s’occupe d’instancier un objet TEmpire. En parcourant les différentes xrefs sur le constructeur de l’objet TEmpire, on retrouve une fonction qui prend en paramètre un objet TEmpire.

Cette fonction s’occupe de créer un objet TReader. Les objets TEmpire et TReader sont passés en paramètre à une fonction qui s’occupe de remplir l’objet TEmpire à partir du fichier, ReadEmpireFile.

sub_6E07A0

Après rétro-ingénierie, il s’avère que les différentes données sont préfixées d’un tag indiquant leurs types, et éventuellement d’une taille dans le cas des chaînes de caractères.

Le fichier commence par un entier qui permet de dériver :

  • une première clé pour les chaînes de caractères
  • une seconde clé pour les autres types de données (entiers)

Le chiffrement s’effectue avec un XOR octets par octets, mais la variable globale g_CryptoPos qui permet de selectionner l’octets de chiffrement est commun pour les deux clés. g_CryptoPos avance à chaque opération de lecture, pour déchiffrer une donnée au milieu du fichier il faut avoir lu le début du fichier.

Un script python fourni en annexes permet d’effectuer des lectures dans le fichier d’empire chiffré. Une fois que l’on a compris le format du fichier d’empire on peut s’intéresser à son traitement.

Vulnérabilité

Lors du traitement du fichier d’empire en version 0.82, le programme lit et alloue une dizaine de chaînes de caractères depuis le fichier. Leurs pointeurs est stocké dans dans un tableau de 10 entrées membre de la classe TEmpire. En version 1.33, le nombre de chaînes de caractères lues depuis le fichier est défini par un entier de 8 bits qui peut être supérieur à 10, il y’a un risque d’overflow. Il y a aussi deux autres tableaux d’entiers qui semblent pouvoir déborder.

Avec la fonctionnalité “Search” > “Text” de IDA on peut retrouver les différents accès au tableau par le programme.

Autour des accès au tableau, le programme charge les chaînesde caractères “Point de passage” ou encore “Les navires récemment construits se déplaceront automatiquement vers”. On en déduit que les tableaux de la classe TEmpire ont été déclaré pour stocker les noms des points de ralliement, et leurs coordonnées.

Exploitation

Grâce à la vulnérabilité décrite précédement on peut remplacer un pointeur par un autre pointeur vers des données contrôlées. Au cours du traitement du fichier d’empire le programme fait appel à la fonction TCipherStream_ReadInt32List qui prend en paramètre un pointeur vers un objet de type Longintlist. Ce pointeur est issu de l’objet TEmpire et est écrasé lors de la 48ième lecture d’un point de passage.

Maintenant il faut trouver un moyen d’exploiter l’objet Longintlist pour faire appel à du code arbitraire. La fonction TCipherStream_ReadInt32List fait appel à TLongintlistClr qui finit par faire appel à la fonction ci-dessous (dans le cas où le membre MaxLength est négatif): Le paramètre newLength est fixé à 0 lors de l’appel de la fonction. Par conséquent pour appeler la première fonction de la vtable il suffit que le membre Length de l’objet soit supérieur à 0.

On contrôle entièrement le contenu de notre objet Longintlist, il ne reste plus qu’à trouver un pointeur de fonction intéressant dans le programme. J’ai orienté ma recherche dans les vtables des différents types du programme qui contiennent naturellement des pointeurs de fonctions.

Il se trouve que la vtable du type TCustomDXPlay contient une fonction intéressante.

La méthode en question prend en premier paramètre un objet TCustomDXPlay, puis appel une fonction dont le pointeur est situé directement dans l’objet à l’offset 0x40.

Par conséquent en settant la vtable de notre objet Longintlist à 0x58ACE0 et une valeur à l’offset 0x40, on est capable d’appel n’importe quel bout de code du programme.

Il ne reste plus qu’à trouver le moyen de pivot sur les données de notre objet. Le pointeur de notre objet se retrouve dans EBX avant l’appel de la fonction, si l’on conserve uniquement les gadgets qui utilisent le registre EBX on trouve le gadget pivot suivant:

A la fin de l’exécution, le pointeur de stack ESP pointe sur notre objet Longintlist et le programme dépile une adresse de retour depuis une zone dont on contrôle le contenu, c’est gagné ! Le schéma suivant résume la structure de l’objet TLongintlist pour pouvoir exécuter du code arbitraire.

Remote Code Execution

Maintenant que l’on a un exploit qui fonctionne en local, il suffit d’envoyer le fichier d’empire lors de la partie multijoueur. Les logs nous permettent de documenter la séquence de connexion.

A la fin de la séquence le joueur envoye des messages de type MSG_EMPIRE_FILE dont la structure est la suivante.

On peut facilement capturer et rejouer les paquets précédant l’envoi du fichier pour simuler la connexion du client au salon. Au moment de la requête DirectPlay “Request Player ID”, le serveur répond avec un nouvel ID de joueur qu’il faut réinjecter dans les paquets à rejouer.

Annexes

Script déchiffrement

import struct

Increments = [11,3,9,6,2,8,4,7,1,10,5]

Subkeys = [
	"",
	")(*@!#jookja;lsm[qwpeoi9309YU(*HYD(HW&HYTW(QQ", # 1
	"1290123870986fgisadclbl;aksjpe8q63287e6oiugyw", # 2
	"POIQWAL:SMNCJUBDSIOUQHWYHD(#@829*HWSUYHDHWOQQ", # 3
	"}{:}{P+_@(DJLASJCXNWUYE*&!@&*EGAJHBSJHBILCA*&", # 4
	"190820398703957120978310297318927319027309127", # 5
	"(@)*#HDHUS^%%R@FUKYGWXKJNXLOAIYW*&E^^!@GEUGAJ", # 6
	"klhYUIT8%$&%4edjGckjbHNBlo(*U907T64e^54dI&o98", # 7
	"2309ejs0ijcn08weu9012[awjodij)(*UAW*DY(*@#HDc", # 8
	")(*ijhoIUY876%^%e%#edHFcGKJbLh&tIU6g57fio7667", # 9
	"aiojsdoquhe38927ye832gkajebd.askn;lkash;haswd", # 10
]

IntegerKeys = [
	[0x2085 , 0x1255B, 0x6F5E, 0xC2AA , 0x8B27, 0x602B, 0x3D91,  0xDE39, 0x156A2, 0x144A3],
	[0x10A5B, 0x109F1, 0x870C, 0x18650, 0x1396, 0x4145,  0xE19, 0x16628, 0x1412C, 0x5534 ],
	[0xED1E ,  0x3F3B, 0x1612, 0x11AF9, 0x62D0, 0x40E9,0x177A1, 0x119F6, 0x152FF, 0x11A27],
	[0x151FB,  0xA586, 0x37FB,  0x3F7C, 0xD5CC, 0x1D1D,0x12E34,  0x1B5C,  0x5B99, 0x2A72 ],
	[0x49B6 ,  0xF6EF,0x13962,  0x88A8, 0xBE5B,0x1844E,0x17FB9,  0x5BA6,  0xC06C, 0x5733 ],
	[0xF2C8 ,  0x7322, 0x278D, 0x12263, 0x710C, 0x9577, 0x8836,  0x87CD, 0x13139, 0x924E ],
	[0x150B1,  0x3AA4, 0x7ABC,  0x8307, 0xF2E8, 0x2156, 0xB3F9,  0x948E, 0x50E5 , 0x13E58],
	[0x2F31 , 0x117BC,0x16FB0,  0x3B00, 0x1D5B, 0xE9E4, 0xF057,  0x8AC7, 0x16708, 0x49FC ],
	[0x11406, 0x168CB, 0xA558,  0xD4C8, 0x68AC,0x1178C, 0xF13C,  0x57F7, 0x161F8, 0xD21E ],
	[ 0x38FF,  0x6401, 0xCCC5,  0xD34A,0x10CE4, 0xA5F3,0x12414,   0xF2E,  0xC249, 0x3AF0 ],
	[ 0xAC6C,  0xF67C, 0xD544, 0x10971,  0x426, 0xE22A, 0x55FE, 0x14A54,  0xAAC8, 0x135B6]
]

class CryptoContext:
	def __init__(self,KeySel):
		self.KeySel = KeySel
		self.WStringKey = self._ComputeKeyForString()
		self.CurPos = 0
		print("WStringKey = %s" % self.WStringKey)
		
	def _ComputeKeyForString(self):
		FinalKey = ""
		Count = 10
		Index = self.KeySel
		while Count != 0:
			Index += Increments[self.KeySel]
			while Index > 10:
				Index-=10
			print("Index = %d" % Index)
			FinalKey += Subkeys[Index]
			Count-=1
		return FinalKey
	
	def _UseIntegerKey(self):
		Key = IntegerKeys[self.KeySel][self.CurPos % 10]
		self.CurPos += 1
		return Key
		
	def DecryptString(self,WideString):
		Plaintext = ""
		if len(self.WStringKey) < self.CurPos:
			self.CurPos = 1
		
		for c in WideString:
			if self.CurPos != 0:
				Plaintext += chr(ord(c) ^ ord(self.WStringKey[self.CurPos - 1]))
			else:
				Plaintext += c
				
			self.CurPos += 1
			if len(self.WStringKey) < self.CurPos:
				self.CurPos = 1
		
		return Plaintext
		
	def DecryptInteger(self,Value):
		return Value ^ self._UseIntegerKey()

class SE5FileReader:
	def __init__(self,filename):
		self.fp = open(filename,"rb")
		self.kInit = False
		
		KeySel = self.ReadInteger()
		self.crypto = CryptoContext(KeySel)
		self.kInit = True
		
	def CurrentPos(self):
		return self.fp.tell()
		
	def ReadValue(self):
		return ord(self.fp.read(1))
		
	def ReadDirectInt32(self):
		if self.kInit:
			value = struct.unpack("<I",self.fp.read(4))[0]
			return self.crypto.DecryptInteger(value)
		else:
			return self.ReadInteger()
			
	def ReadInteger(self, mask=0xFFFFFFFF):
		value = 0
		sizeofint = self.ReadValue()
		if sizeofint == 2:
			value = ord(self.fp.read(1))
		elif sizeofint == 3:
			value = struct.unpack("<H",self.fp.read(2))[0]
		elif sizeofint == 4:
			value = struct.unpack("<I",self.fp.read(4))[0]
		else:
			raise Exception("Unknown property")
			
		if self.kInit:
			return self.crypto.DecryptInteger(value) & mask
		else:
			return value
			
	def ReadInt16(self):
		return self.ReadInteger(mask=0xFFFF)
	
	def ReadInt8(self):
		return self.ReadInteger(mask=0xFF)
		
	def ReadBoolean(self):
		boolean = int(self.ReadValue() == 9)
		if self.kInit:
			return (self.crypto.DecryptInteger(boolean) % 2) == 0
		else:
			return boolean
			
	def ReadString(self):
		prop = self.ReadValue()
		if prop != 0x12:
			raise Exception("Invalid property")
		length = struct.unpack("<I",self.fp.read(4))[0]
		WideString = str(self.fp.read(length * 2),'utf16')
		
		if self.kInit:
			return self.crypto.DecryptString(WideString)
		else:
			return WideString
		
fr = SE5FileReader("empiretestfile.uncomp")
sVersion = fr.ReadString()
print(sVersion)

Références

  1. Process Monitor
  2. Interactive Delphi Reconstructor
  3. Run Time Type Information
  4. Virtual Method Table