Exploiting Anno 1404
Anno 1404 est un jeu de stratégie développé par Related Designs et édité par Ubisoft. C'est un jeu de stratégie en temps réel qui se focalise sur la gestion et la construction d'une ville. L'extension Anno 1404 : Venise, publiée en 2010, inclut un mode multijoueur en ligne et en réseau local. Au cours de nos recherches, plusieurs vulnérabilités ont été découvertes. Combinées, elles permettent d'exécuter du code arbitraire depuis le mode multijoueur.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Introduction
Anno 1404 : Venise est un jeu de stratégie disponible sur plusieurs plateformes telles que Steam, Ubisoft Connect ou GOG.com. Nos recherches se basent sur la version v2.01.5010 sans DRM disponible sur GOG. Dans cette version, seul le mode multijoueur en réseau local est disponible.
Le mode multijoueur permet de sauvegarder pour reprendre la partie ultérieurement. Un fichier de sauvegarde .sww est créé pour l'hôte et chaque joueur. Lorsque le client se connecte à un hôte qui héberge une partie sauvegardée, le fichier est automatiquement transféré. Cela offre des perspectives intéressantes en termes de recherche de vulnérabilité : comment est transféré le fichier ? Est-ce qu'il y a des restrictions sur les fichiers autorisés ? Le format des sauvegardes étant propriétaire cela-peut constituer aussi une surface d'attaque intéressante. De plus, le processus est en 32 bits et n'a aucune mitigation d'activée, comme le montre la capture de System Informer ci-dessous.
Il existe quelques problèmes d'incompatibilité graphique lorsqu'on démarre le jeu dans une VM VirtualBox avec l'accélération 3D activée. Cela peut être corrigé en forçant la version de DirectX via le fichier %APPDATA%\Ubisoft\Anno1404Addon\Config\Engine.ini
<UseDDSTextures>1</UseDDSTextures>
<DirectXVersion>9</DirectXVersion> <!-- force usage of DirectX 9 -->
<EnableTextureMemoryManagement>0</EnableTextureMemoryManagement>
<EnableModelMemoryManagement>0</EnableModelMemoryManagement>
Protocole réseau
Le jeu est développé majoritairement en C++. Les RTTI et les noms de fonctions exportés par les DLLs facilitent le travail de rétro-ingénierie. Côté réseau, le jeu a son propre protocole basé sur UDP qui est implémenté dans la DLL NetComEngine3.dll. Dans un premier temps, les différents messages transitant entre le serveur et le client ont été listés. Les messages de type RMC ont particulièrement retenu notre attention, car ils permettent d'atteindre une grande surface d'attaque. D'après la chaîne de log de la méthode ci-dessous, le programme expose des objets à travers un système de RPC (Remote Procedure Call).
char __stdcall RMC_CallMessage(ByteStream *input, char a3, ByteStream *source, WString *a5)
{
ByteStream *v4; // esi
unsigned int targetObject; // ebp
const wchar_t *v6; // eax
const wchar_t *targetName; // [esp-Ch] [ebp-24h]
const wchar_t *methodName; // [esp-8h] [ebp-20h]
unsigned int v10; // [esp+8h] [ebp-10h] BYREF
ByteStream *v11; // [esp+Ch] [ebp-Ch] BYREF
int Flags; // [esp+10h] [ebp-8h] BYREF
int methodID; // [esp+14h] [ebp-4h] BYREF
v4 = input;
Flags = 0;
v11 = 0;
v10 = 0;
ByteStream::ReadElements(input, &input, 2, 1);
ByteStream::ReadElements(v4, &Flags, 4, 1);
ByteStream::ReadInteger(v4, (unsigned int *)&v11);
ByteStream::ReadInteger(v4, &v10);
ByteStream::ReadElements(v4, &methodID, 2, 1);
if ( (_BYTE)source )
{
targetObject = v10;
source = v11;
methodName = ClassToMethodName(&v10, methodID);
targetName = TargetName(&v10);
v6 = TargetName(&v11);
WString::Format(
a5,
(wchar_t *)L"RMC_CALL message RMC_ID: %d, Flags: %d, Source: %x (%s), TargetObject: %x (%s), Method: %s",
(unsigned __int16)input,
Flags,
source,
v6,
targetObject,
targetName,
methodName);
}
if ( a3 )
return sub_100633D0((int)v4, (unsigned __int16 *)&input, &v11, &Flags, &v10, (__int16 *)&methodID);
else
return 1;
}
Les messages RMC sont composés des champs : ID, Flags, Source, TargetObject et Method d'après le message de log affiché dans la fonction RMC_CallMessage. Bruteforcer tous les ID de classes et méthodes possibles permet d'obtenir une vue exhaustive de la surface d'attaque atteignable via ce type de message. Pour cela, un script frida explore-surface.js a été écrit et dont la sortie est affichée en dessous de ce paragraphe :
> frida -l explore-surface.js Addon.exe
800000 = RootDO
c00000 = Station
- 10 = SignalAsFaulty
1000000 = Session
- 8 = RetrieveURLs
- 9 = SynchronizeTermination
1400000 = IDGenerator
- 4 = RequestIDRangeFromMaster
1800000 = PromotionReferee
- 5 = ConfirmElection
- 6 = DeclinePromotion
- 7 = ElectNewMaster
3000000 = DefaultCell
4800000 = SessionClock
- 11 = AdjustTime
- 12 = SyncRequest
- 13 = SyncResponse
7400000 = Player
- 16 = ForceKickPlayer
- 17 = Kick
- 18 = OnCancelSendFile
- 19 = OnReceivedFileData
- 20 = OnSendFileData
- 21 = OnSendFileInit
7800000 = Chat
- 14 = onNewChatLine
7c00000 = GameSettings
- 15 = ExecuteOnHost
8000000 = SyncProtocol
- 22 = ClientToServerPing
- 23 = ClientToServerSync
- 24 = ConfirmHost
- 25 = IdentifyHost
- 26 = LeftGame
- 27 = RequestMsgResend
- 28 = ServerToClientPing
- 29 = ServerToClientSync
Le script affiche les ID valides et noms de chaque objet ainsi que les ID et noms de chaque méthode valide. On retrouve des méthodes relatives au transfert de fichiers OnSendFileData, OnSendFileInit, OnReceivedFileData, OnCancelSendFile. Côté serveur, la méthode rd::netcom3::CNetComEngine3::sendFile est utilisée pour envoyer des fichiers vers le client. Un premier paquet OnSendFileInit est envoyé au client, celui-ci contient le nom de la sauvegarde (par défaut Sauvegarde.sww). On peut rajouter plusieurs ../ au nom de la sauvegarde avec frida pour tester le comportement de client. Ce script est disponible en annexe.
Comme on peut le voir ci-dessus, la sauvegarde est enregistrée dans C:\User\user au lieu de C:\Users\user\Documents\ANNO 1404 Venise\Savegames\MPShare. Il ne semble pas y avoir de vérification sur le nom du fichier côté du client. Cette première vulnérabilité de type path traversal permet de déposer un fichier avec les droits de l'application à peu près n'importe où sur le système. Les ACLs sur le dossier d'installation du jeu sont permissives ce qui permet à un programme utilisateur d'y déposer n'importe quel fichier.
Sous Windows les bibliothèques de liens dynamique sont chargés à partir du répertoire de l'application, ensuite à partir le répertoire système. On peut déposer une DLL qui sera chargée au prochain lancement du jeu. Ce qui permet d'obtenir une exécution de code arbitraire à condition que le jeu soit relancé. Le but est d'obtenir un exécution de code sans avoir à relancer le jeu. L'idée principale est de remplacer une asset par une asset corrompue.
Format RDA
La série des jeux Anno utilise le format RDA pour stocker les différentes ressources du jeu (modèle 3D, son, texture, cartes, … ).
On retrouve ces archives dans le dossier addon et maindata. Des outils comme RDAExplorer permettent d'explorer, extraire et modifier le contenu des archives.
Il n'existe pas de documentation officielle sur le format RDA, cependant une documentation issue de travaux de rétro-ingénierie est disponible sur github1.
En résumé, un fichier RDA est découpé en blocs chaînés de taille variable. Chaque bloc contient un certain nombre de fichiers et les métadonnées compressées de ses fichiers. Les métadonnées d'un fichier indiquent notamment sa position dans l'archive et sa taille. Les données sont compressées avec l'algorithme DEFLATE implémenté dans la bibliothèque zlib. Le schéma ci-dessous résume la structure d'une archive RDA.
Certains blocs peuvent être chiffrés selon le champ flag des métadonnées. Le chiffrement utilisé est basé sur un xor et un générateur de nombre pseudo-aléatoire initialisé avec une constante. Voici ci-dessous le pseudo-code de la fonction de déchiffrement.
char __cdecl xor_decrypt(wchar_t *buf, unsigned int size)
{
signed int index; // esi
srand(0xA2C2Au);
index = 0;
if ( size >> 1 )
{
do
buf[index++] ^= rand();
while ( index < (int)(size >> 1) );
}
return 1;
}
Avec Process Monitor, on observe de nombreux accès en lecture aux archives. Par conséquent, les métadonnées sont probablement chargées en mémoire au lancement du jeu. Ensuite, le contenu des fichiers n'est chargé en mémoire que si nécessaire. On observe que de nombreux fichiers .gr2 sont chargés lors d'une partie multijoueur.
Format GR2
Les fichiers .gr2 sont des modèles 3D enregistrés dans un format propriétaire développé par Granny Studio. C'est un format qui a été utilisé dans de nombreux jeu vidéo de l'époque comme le MMORPG Ragnarok Online. La communauté autour de ce jeu a documenté partiellement le format GR22. Anno 1404 utilise une bibliothèque à part nommé granny2.dll pour manipuler ce type de fichier. En résumé, le format permet de stocker des informations sur la géométrie du modèle (Mesh), son squelette, les textures et les matériaux utilisés dans différentes sections. Le schéma ci-dessous présente la structure d'un fichier .gr2 :
La fonction GrannyReadEntireFile permet de charger un fichier Granny 3D en mémoire. Le programme commence par lire l'entête du fichier et la table des sections.
Chaque section est décompressée puis chargée en mémoire (de la place est réservée pour chaque section via malloc). Ensuite, pour chaque section le programme applique une table des relocations.
Il est probable que les sections contiennent des références vers des objets contenus dans les sections. Puisque les sections ne sont pas mappées à une adresse fixe, la bibliothèque mets à jour les références.
Une entrée dans la table de relocation est composé de trois éléments :
- Section Offset : position de la référence qui sera mise à jour (par rapport au début de la section associé a la table de relocation)
- Section Number : identifie la section contenant l'objet pointé
- Offset : position de l'objet référencé par rapport au début de la section associé à l'objet
Vulnérabilité
Une vulnérabilité a été découverte lors de l'étude du mécanisme d'application des relocations. Les entrées dans la table des relocations ne sont pas vérifiées, plus précisément :
- Le membre SectionIndex n'est pas vérifié ce qui peut mener à une lecture hors limite à partir du tableau array (array contient les adresses de base de chaque section). [1]
- Le membre SectionOffset n'est pas vérifié ce qui peut mener à une écriture hors limite à partir du tableau destination. [2]
int *__cdecl GrannyGRNFixUp_0(DWORD RelocationCount, Relocation *PointerFixupArray, int *SectionArray, char *destination)
{
int *result; // eax
DWORD v6; // ebp
Relocation *v7; // ecx
int v8; // edx
result = (int *)RelocationCount;
if ( RelocationCount )
{
v6 = RelocationCount;
do
{
v7 = PointerFixupArray;
v8 = SectionArray[PointerFixupArray->SectionNumber]; // [1] Out-of-bound read
result = (int *)&destination[PointerFixupArray->SectionOffset]; // [2] Compute write address
++PointerFixupArray;
*result = v8; // [2] Out-of-bound write
if ( v8 )
*result = v8 + v7->Offset;
--v6;
}
while ( v6 );
}
return result;
}
On peut imaginer construire une primitive de write arbitraire en mémoire si l'on connait l'adresse mémoire de la section (destination). Cependant, à cause de l'ASLR les sections ne résident pas toujours à la même adresse.
Exploitation
Il est possible d'arriver dans une configuration mémoire intéressante, où le tableau des adresses de section (SectionArray) est situé avant les données de la première section mémoire. Dans ce cas de figure l'offset entre le tableau de pointeur de section et le contenu de la section est connu.
Pour réaliser deux allocations adjacentes, il est nécessaire de connaître quelques détails sur le fonctionnement de la heap Windows 10. Le white-paper Windows 10 Segment Heap Internals3 explique très bien les mécanismes sous-jacents. Il y a deux types d'allocateur, la NT Heap (utilisé dans notre cas) et la Segment Heap. L'allocateur NT Heap est divisé en 2 composants :
- LFH (Low Fragmentation Heap) : Pour les allocations de petite taille
- backend : Pour les allocations de taille supérieures.
Le seuil qui détermine quel allocateur à utiliser est défini par la constante RtlpLargestLfhBlock dans la fonction RtlpAllocateHeapInternal de la ntdll.dll. Celle-ci est égale à 0x4000. Depuis Windows 8, La LFH est soumis à une randomisation des allocations, on ne peut pas s'assurer qu'un bloc de données soit alloué à côté d'un autre, ce qui rend l'exploitation non fiable. Tandis que l'allocateur backend à un comportement déterministe. Par conséquent, un fichier avec un grand nombre de sections est conçu pour que le tableau des pointeurs de sections soit alloué dans un bloc de taille supérieur à 0x4000 octets. Afin de remplir les éventuels trous dans la heap, plusieurs fichiers .gr2 ont été créer. Cela permet d'améliorer la fiabilité de l'exploit. Le schéma ci-dessous représente l'agencement du fichier en mémoire une fois les sections chargées.
La taille de la structure GrannyFile dépends du nombre de sections, ainsi avec un nombre de sections $n$ = 2720, on obtient la taille souhaitée :
\begin{align} 0x20 + 0x20 + n * 1 + n * 1 + n * 4 = 0x4000 \end{align}
La table de relocation de la première section est construite de manière à modifier le pointeur SectionContentArray[1]. L'allocateur dans la bibliothèque granny.dll s'appuie sur l'allocateur Windows, mais rajoute un entête de 0x1F octets. L'allocateur Windows lui rajoute un entête de 0x10 octets et la taille de l'allocation est alignée sur un multiple de 8 ou 16 octets selon le type de système (32 ou 64 bits). Par conséquent, un offset -0x3FF0 ( 0x4000 - 0x20 - 0x20 + 0x30 ) permet d'écrire dans le tableau SectionContentArray.
Maintenant que l'adresse de la deuxième section est connue, on peut écrire des valeurs arbitraires en mémoire grâce à la table des relocations appliqué sur la deuxième section. La bibliothèque granny.dll n'est pas soumise à l'ASLR et la DEP n'est pas activé. Ainsi, on peut remplacer les callback alloc/free dans la bibliothèque pour exécuter du code.
Démonstration
Ces vulnérabilités ont été exploitées avec succès sur un Windows 10 (ver 10.0.19045.2965).
Annexes
Script frida pour tester la vulnérabilité de type path traversal
const ByteStreamWriteStringPtr = ptr('0x1003A250');
const ByteStreamWriteStringFn = new NativeFunction(ByteStreamWriteStringPtr,'pointer',['pointer','pointer'])
const mem = Memory.alloc(1024);
mem.writeUtf16String('..\\..\\..\\..\\Sauvegarde.sww');
Interceptor.attach(ByteStreamWriteStringFn, {
onEnter(args) {
if(args[1].readPointer().readUtf16String().includes('Sauvegarde.sww'))
{
console.log(hexdump(args[1].readPointer(), {
offset: 0,
length: 256,
header: true,
ansi: true
}));
args[1].writePointer(mem);
console.log(hexdump(args[1].readPointer(), {
offset: 0,
length: 256,
header: true,
ansi: true
}));
}
}
});