Incident de sécurité ? Suspicion de compromission ? 09 71 18 27 69csirt@synacktiv.com

Exploiting Titan Quest

Rédigé par Thomas Dubier - 29/07/2026 - dans Exploit - Téléchargement

Titan Quest est un jeu vidéo de type « hack-and-slash » sorti en 2006. En 2016, THQ Nordic a publié une version Anniversary Edition. Dans la version fournie par GOG, plusieurs outils de développement sont installés avec le jeu. Ceux-ci permettent de créer de nouvelles cartes, des objets et des effets. Cet article détaille les différentes vulnérabilités trouvées dans le moteur de jeu ainsi que leur exploitation. 

Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus

Introduction

Titan Quest est un jeu vidéo de type « hack-and-slash » développé par Iron Lore et publié par THQ. Le jeu se déroule durant l'Antiquité, où le joueur doit affronter des hordes de monstres mythologiques. Chaque combat permet de gagner de l'expérience, offrant au joueur la possibilité d'acquérir de nouvelles compétences et d'augmenter les attributs de son personnage. À l'instar des jeux de rôle sur table, le joueur explore des donjons et découvre de nouvelles armes, armures et reliques qui lui permettront de se confronter à des boss de plus en plus puissants. La réédition Titan Quest Anniversary Edition de THQ Nordic est disponible sur la plateforme GOG.

Titan Quest : Anniversary Edition
antTitan Quest : Anniversary Edition

Bien que le jeu propose un mode multijoueur, nos recherches se sont concentrées sur le contenu pouvant être partagé par la communauté. Des archives d'assets additionnels peuvent être installées pour ajouter des mondes supplémentaires jouables en multijoueur. L'installation se fait manuellement dans le dossier C:\Users\user\Documents\My Games\Titan Quest - Immortal Throne\custommaps prévu à cet effet. Les recherches ont été effectuées sur une machine Windows 11 Professionnel (version 10.0.26200) avec Titan Quest Anniversary Edition v2.10.21415 installée.

Exploration

Titan Quest fournit les outils de développement nécessaires à la création de cartes, de quêtes, d'équipements et bien plus encore. Pour utiliser ces outils, nous vous invitons à consulter les guides rédigés par la communauté : https://titanquestfans.net/index.php?topic=914.0

Editeur de carte
Éditeur de carte

L'installation des cartes se fait manuellement dans le dossier C:\Users\user\Documents\My Games\Titan Quest - Immortal Throne\custommaps prévu à cet effet.

En manipulant les outils du jeu, on peut observer divers formats de fichiers, dont voici une liste non exhaustive :

  • .wrl : Fichier "World", contient des références aux fichiers de niveaux. Ce type de fichier est créé par Editor.exe.
  • .lvl : Décrit un niveau, qu'il s'agisse d'un terrain ou d'un donjon.
  • .map : Fichier "World" compilé, ce fichier rassemble les différents niveaux du jeu. L'utilitaire MapCompiler.exe produit ce type de fichier.
  • .tga : Le Truevision TGA est un format de fichier image.
  • .tex : Texture compilée.
  • .ssh : Fichier de shader, il s'agit de petits programmes utilisés pour configurer une partie du rendu de la carte graphique.
  • .pfx : Fichier d'effets visuels, utilisé pour gérer les effets graphiques du jeu tels que les explosions, la fumée, la poussière, etc. L'utilitaire PSEditor.exe permet d'ouvrir ce type de fichier.
  • .arc : Archive d'assets.

De nombreux assets contenus dans des archives .arc sont disponibles dans le dossier C:\GOG Galaxy\Titan Quest - Anniversary Edition\Resources. Ces assets peuvent être extraits à l'aide de l'outil ARCExplorer, qui fournit des fichiers d'exemple pour aider à comprendre les différents formats.

ArcExplorer
ArcExplorer

Analyse de la surface

La DLL Engine.dll contient la majorité des méthodes qui traitent les différents types de fichiers. Chaque méthode prend en paramètre d'entrée une structure BinaryReader, définie ci-dessous. Elle décrit le buffer à désérialiser.

struct __fixed GAME::BinaryReader // sizeof=0x10
{
    unsigned char* buffer;
    unsigned char* currentPtr;
    int bufferSize;
    int field_C;
};

Le pseudo-code ci-dessous effectue la désérialisation d'un objet WaterType. Chaque membre de type simple est consommé directement depuis le buffer, après quoi le currentPtr est incrémenté. Les chaînes de caractères sont précédées de leur taille [1]. Lorsque le programme doit désérialiser un sous-objet plus complexe, il s'appuie sur la méthode ::Load associée.

bool __thiscall GAME::WaterType::Load(GAME::WaterType *this, struct GAME::BinaryReader *binaryReader)
{
  [...]
  this->reflectivity = v12;
  v13 = *(float *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->direction = v13;
  v14 = *(_DWORD *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->textureScale.X = v14;
  v15 = *(_DWORD *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->textureScale.Y = v15;
  v16 = *(_DWORD *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->color.R = v16;
  v17 = *(_DWORD *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->color.G = v17;
  v18 = *(_DWORD *)binaryReader->currentPtr;
  binaryReader->currentPtr += 4;
  this->color.B = v18;
  if ( v4 >= 2 )
  {
    v19 = *(_DWORD *)binaryReader->currentPtr;
    binaryReader->currentPtr += 4;
    this->reflectivityEnabled = v19;
  }
  currentPtr = binaryReader->currentPtr;
  v21 = *(_DWORD *)currentPtr; // [1]
  v22 = currentPtr + 4;
  binaryReader->currentPtr = currentPtr + 4;
  if ( v21 <= 0x100000 && (unsigned int)&v22[v21 - binaryReader->buffer] <= binaryReader->bufferSize )
  {
    std::string::assign(&this->typeName, v22, v21);
    binaryReader->currentPtr += v21;
  }

Au total, 49 méthodes de la bibliothèque Engine.dll prennent une structure BinaryReader en paramètre. Ce qui représente une surface d'attaque significative.

Map format

Lorsqu'un joueur charge une carte personnalisée, la méthode GAME::World::LoadMap est appelée. C'est notre point de départ pour comprendre le format de la carte. Chaque fichier .map commence par une signature suivie d'un numéro de version. Ensuite, le fichier contient des en-têtes divisés en plusieurs blocs de données. Chaque bloc est constitué d'un entier indiquant son type, suivi de la taille du bloc, puis des données du bloc. Voici quelques exemples de types de blocs :

Value Type Comment
18h CHUNK_TYPE_SECTOR Données désérialisées par la méthode GAME::SectorDataManager::Load
19h CHUNK_TYPE_MINIMAP Metadonnées pour les miniatures de carte
1Bh CHUNK_TYPE_QUEST Liste de chaîne de caractère indiquant les quêtes
01h CHUNK_TYPE_REGIONS Métadonnées pour les niveaux (taille, position autour du monde…,)
11h CHUNK_TYPE_INSTANCE_DATA

Données désérialisées par la méthode GAME::InstanceGroupManager::Load

À la suite de l'en-tête, le format intègre directement des fichiers .TGA représentant les miniatures de la carte ainsi que des fichiers .LVL correspondant aux niveaux. La position et la taille de ces fichiers sont spécifiées dans les blocs CHUNK_TYPE_MINIMAP et CHUNK_TYPE_REGIONS. Le diagramme suivant résume la structure globale d'un fichier .map.
 

Format .MAP
Format .MAP

 

Ensuite, la classe GAME::RegionLoader charge les niveaux dans un thread séparé. La méthode GAME::Level::Load est appelée pour chaque niveau. Tout comme les fichiers .map, les fichiers .lvl commencent par une signature suivie d'un numéro de version. Ils sont composés de blocs de types variés.

Valeur Type Commentaire
09h CHUNK_TYPE_WATER Paramètre physique de l'eau
03h CHUNK_TYPE_IMPASSABLE_DATA Données sur les zones non traversables par le joueur
23h CHUNK_TYPE_SECTOR_LAYERS Chargé par GAME::SectorLayers::Load

Vulnérabilités

Après analyse statique des méthodes de désérialisation de carte, une vulnérabilité a été trouvée. Lorsque le programme rencontre un CHUNK_TYPE_IMPASSABLE_DATA, la méthode GAME::ImpassableData::Load est appelée. Voici un extrait du pseudo-code :

bool __thiscall GAME::ImpassableData::Load(GAME::ImpassableData *this, struct GAME::BinaryReader *binaryRead)
{
    [...]
    binaryRead->currentPtr = v4 + 4;
    this->__width = v6;
    v7 = *(_DWORD *)binaryRead->currentPtr;
    binaryRead->currentPtr += 4;
    this->field_20 = v7;
    v8 = *(_DWORD *)binaryRead->currentPtr;
    binaryRead->currentPtr += 4;
    this->__height = v8;
    count = *(_DWORD *)binaryRead->currentPtr;
    binaryRead->currentPtr += 4;
    if ( count > 0 )
    {
      buffer = (bool *)operator new[](this->__width * this->__height); // [1]
      this->impassableBuffer = buffer;
      memcpy(buffer, binaryRead->currentPtr, count); // [2]
      binaryRead->currentPtr += count;

Dans cette méthode, le programme alloue un tableau d'octets dont la taille est déterminée par la multiplication width et height. Ces variables sont fournies dans le fichier .lvl. Il existe un premier problème arithmétique où la multiplication de la largeur par la hauteur peut entraîner un integer overflow excédant 32 bits [1]. Par la suite, un tableau d'octets de taille count est copié dans le buffer précédemment alloué [2]. La variable count n'est pas vérifiée, et sa valeur peut être supérieure à la taille du buffer. Il s'agit d'une vulnérabilité de type heap overflow (dépassement de tas).

D'autres vulnérabilités ont été trouvés dans la méthode GAME::EmitterData::InternalBinaryRead. Cette méthode est appelée lors du chargement d'un fichier .pfx représentant un effet de particules. Plusieurs tableaux de taille arbitraire sont récupérés depuis le fichier et copié dans une structure GAME::EmitterData de taille fixe [1] [2] [3] [4] [5] [6]. Il s'agit à nouveau de vulnérabilités de type heap overflow (dépassement de tas). Cependant, la taille de l'allocation n'étant pas contrôlé, elles sont moins intéressantes en termes d'exploitation.

char __thiscall GAME::EmitterData::InternalBinaryRead(GAME::EmitterData *this, struct GAME::BinaryReader *a2)
{
  currentPtr = a2->currentPtr;
  *(_DWORD *)first = *(_DWORD *)currentPtr;
  a2->currentPtr = currentPtr + 4;
  self = this;
  v32 = this;
  if ( strncmp(Str1: first, Str2: "PFX1", MaxCount: 4u) != 0 )
    return 0;
  a2->currentPtr += 8;
  counter = *(_DWORD *)a2->currentPtr;
  count1 = counter;
  a2->currentPtr += 4;
  if ( counter > 0 )
  {
    strings = (std::string *)self->strings;
    do
    {
      v7 = a2->currentPtr;
      v8 = *(_DWORD *)v7;
      v9 = v7 + 4;
      a2->currentPtr = v7 + 4;
      if ( v8 <= 0x100000 && (unsigned int)&v9[v8 - a2->buffer] <= a2->bufferSize )
      {
        std::string::assign(this: strings, s: v9, len: v8);
        a2->currentPtr += v8;
        count1 = counter;
      }
      ++strings;
      counter = --count1;
    }
    while ( count1 != 0 ); // [1]
    self = v32;
  }
  count2 = *(_DWORD *)a2->currentPtr;
  index = 0;
  a2->currentPtr += 4;
  if ( count2 > 0 )
  {
    do
    {
      v12 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      self->boolArray[index++] = v12 != 0;
    }
    while ( index < count2 ); // [2]
  }
  v13 = self->boolArray[0];
  self->byte3A4 = v13;
  self->byte36C = v13;
  self->byte44C = v13;
  self->byte484 = v13;
  self->byte4BC = v13;
  self->byte254 = v13;
  self->byte28C = v13;
  self->byte2C4 = v13;
  self->byte1E4 = v13;
  self->byte1AC = v13;
  self->byte52C = v13;
  self->byte59C = v13;
  self->byte564 = v13;
  self->byte5D4 = v13;
  count3 = *(_DWORD *)a2->currentPtr;
  a2->currentPtr += 4;
  if ( count3 > 0 )
  {
    p_dword40 = self->dwordArray;
    do
    {
      v16 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      *p_dword40++ = v16;
      --count3;
    }
    while ( count3 != 0 ); // [3]
  }
  v17 = *(_DWORD *)a2->currentPtr;
  a2->currentPtr += 4;
  v18 = nullptr;
  if ( v17 >= 4 )
  {
    count4 = ((unsigned int)(v17 - 4) >> 2) + 1;
    p_dword4C = &self->dword4C;
    v32 = (GAME::EmitterData *)(4 * count4);
    do
    {
      v21 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      *(p_dword4C - 1) = v21;
      v22 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      *p_dword4C = v22;
      v23 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      p_dword4C[1] = v23;
      v24 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      p_dword4C[2] = v24;
      p_dword4C += 4;
      --count4;
    }
    while ( count4 != 0 ); // [4]
    v18 = v32;
  }
  if ( (int)v18 < v17 )
  {
    count5 = v17 - (_DWORD)v18;
    v26 = (int *)((char *)self + 4 * ((_DWORD)&v18->strings[0].ptr + 14));
    do
    {
      v27 = *(_DWORD *)a2->currentPtr;
      a2->currentPtr += 4;
      *v26++ = v27;
      --count5;
    }
    while ( count5 != 0 ); // [5]
  }
  count6 = *(_DWORD *)a2->currentPtr;
  a2->currentPtr += 4;
  if ( count6 > 0 )
  {
    curveData = (GAME::CurveData *)&self->game__curvedata58;
    do
    {
      GAME::CurveData::BinaryRead(this: curveData++, reader: a2);
      --count6;
    }
    while ( count6 != 0 ); // [6]
  }
  return 1;
}

 

Exploitation

Dans un premier temps, une analyse rapide avec SystemInformer1 permet d'obtenir la liste des mitigations activées sur le binaire et ses DLLs. Le jeu a été compilé en 32 bits et quelques-unes de ses DLLs ne sont pas soumises à l'ASLR. 

Modules Titan Quest sans ASLR
DLLs Titan Quest sans ASLR

Note : La DLL DevIL.dll est compressée avec UPX2 et doit être décompressée à l'aide de la commande upx -d DevIL.dll avant d'être analysée.

Dans un second temps, nous nous sommes intéressés aux mitigations liées à la heap Windows.

Selon la taille de notre allocation, le buffer débordant sera placé soit dans la LFH (Low Fragmentation Heap), soit dans la Segment Heap si la multiplication de la largeur par la hauteur donne une valeur supérieure à 0x4000. L'objectif est d'écraser la vtable d'un objet, cependant, la plupart des objets du jeu sont de petite taille et résident dans le LFH. Comme les allocations du LFH sont non déterministes, il est difficile d'écraser un objet spécifique. Par conséquent, nous avons choisi de cibler des objets dans le Segment Heap.

Une contrainte supplémentaire apparaît : le buffer est libéré après le chargement du niveau. L'allocateur Windows vérifie l'en-tête du bloc suivant. Dans le cas de la NT Heap3, l'en-tête est chiffrée (via un xor) avec le champ Encoding de la structure _HEAP. Cette clef est générée aléatoirement à chaque exécution. En cas de corruption de l'entête, l'allocateur termine le programme. Pour contourner cela, nous devons détourner l'exécution du programme avant que la méthode GAME::Level::Load ne se termine.

Le seul objet positionné dans le Segment Heap durant la méthode GAME::Level::Load est l'objet Level lui-même. Cela pose un nouveau problème : comment garantir que le buffer alloué par GAME::ImpassableData::Load soit alloué avant l'objet Level ?

En étudiant le comportement déterministe du Segment Heap, nous observons que les blocs de même taille sont alloués séquentiellement. De plus, l'allocation de blocs de même taille suit un modèle FIFO (First-In, First-Out) — le premier bloc libéré est le premier bloc réutilisé. L'objectif est donc de préparer le tas de la manière suivante :

  1. Remplir le tas avec des blocs de la taille d'un objet Level.
  2. Libérer le bloc 2, puis le bloc 4.
  3. Le niveau est alloué à la place du bloc 4, et la prochaine allocation de même taille qu'un objet Level sera le buffer Impassable. En conséquence, le tableau du buffer Impassable est positionné avant l'objet Level, permettant ainsi sa corruption.
Préparation de heap
Préparation de la heap

Pour effectuer ces opérations, nous pouvons abuser de la méthode GAME::Water::Load. Dans un premier temps, le programme alloue des objets WaterType qui contiennent les caractéristiques des différentes zones d'eau de la carte. Ces objets sont identifiés par des chaînes de caractères de longueur variable, ce qui nous permet d'allouer des blocs de taille arbitraire dans le tas. Le pseudo-code suivant illustre le chargement des objets WaterType.

if ( countWaterType )
  {
    if ( countWaterType > 0x3FFFFFFF )
      std::_Xlength_error("vector<T> too long");
    std::vector::grow(&v42, countWaterType);
    do
    {
      GAME::WaterType::WaterType(&v43);
      LOBYTE(v44) = 1;
      GAME::WaterType::Load(&v43, a2);
      v13 = GAME::WaterTypeManager::instance;
      if ( !GAME::WaterTypeManager::instance )
      {
        v13 = (GAME::WaterTypeManager *)operator new(0xCu);
        if ( v13 )
        {
          v13->vectWaterTypeRefStart = nullptr;
          v13->vectWaterTypeRefEnd = 0;
          v13->field_8 = 0;
        }
        else
        {
          v13 = nullptr;
        }
        GAME::WaterTypeManager::instance = v13;
      }
      layer = (void **)GAME::WaterTypeManager::AddWaterType(v13, &v43);
      if ( layer )
        sub_1001CCD0(&v42, &layer);
      LOBYTE(v44) = 0;
      GAME::WaterType::~WaterType(&v43);
      --countWaterType;
    }
    while ( countWaterType );
  }

Ensuite, ces objets WaterType sont associés à des objets WaterLayer. La durée de vie d'un objet WaterType est gérée par un compteur de références. À la fin de la méthode GAME::Water::Load, tous les objets WaterType qui n'ont pas été associés à des objets WaterLayer sont libérés. Ce comportement permet de créer des « trous » dans le tas (heap) qui pourront être réutilisés ultérieurement.

v40 = (struct GAME::BinaryReader *)(v42._Mylast - v42._Myfirst);
  if ( v40 )
  {
    v29 = GAME::WaterTypeManager::instance;
    do
    {
      if ( !v29 )
      {
        v29 = (GAME::WaterTypeManager *)operator new(0xCu);
        if ( v29 )
        {
          v29->vectWaterTypeRefStart = nullptr;
          v29->vectWaterTypeRefEnd = 0;
          v29->field_8 = 0;
        }
        else
        {
          v29 = nullptr;
        }
        GAME::WaterTypeManager::instance = v29;
      }
      waterType = (GAME::WaterType *)Myfirst[_index];
      if ( waterType )
      {
        indexRef = 0;
        nRef = (signed int)(v29->vectWaterTypeRefEnd - (unsigned int)v29->vectWaterTypeRefStart) >> 3;
        if ( nRef )
        {
          pRef = v29->vectWaterTypeRefStart;
          while ( pRef->waterType != waterType )
          {
            ++indexRef;
            ++pRef;
            if ( indexRef >= nRef )
              goto LABEL_62;
          }
          refCount = pRef->refCount;
          if ( refCount > 0 )
          {                                     // Release unattached waterType
            pRef->refCount = refCount - 1;
            v29 = GAME::WaterTypeManager::instance;
          }
          if ( pRef->refCount <= 0 )
          {
            GAME::WaterType::~WaterType(waterType);
            operator delete(waterType);
            pRef->waterType = nullptr;
            pRef->refCount = 0;
            v29 = GAME::WaterTypeManager::instance;
          }
LABEL_62:
          Myfirst = v42._Myfirst;
        }
      }
      ++_index;
    }
    while ( _index < (unsigned int)v40 );

 

La stratégie est donc la suivante : construire un premier niveau qui prépare le tas, puis préparer un second niveau qui déclenche la vulnérabilité dans GAME::ImpassableData::Load.

Abordons maintenant le second problème : comment détourner l'exécution du programme avant que la méthode GAME::Level::Load ne se termine ?

Le champ water de la classe Level est particulièrement intéressant à corrompre. Ce champ est traité lorsque le programme rencontre un bloc de type 09h. Ce bloc peut être placé après un bloc de type ImpassableData, et donc être traité après que la corruption de mémoire a eu lieu.

La méthode GAME::Level::NewWater est appelée lors du traitement d'un bloc de type 09h. Si le membre water est non nul, le destructeur GAME::Water::~Water est appelé. Cette méthode détruit les objets associés à l'objet de type GAME::Water. La destruction de chaque WaterLayer est effectuée via un appel indirect [1].

void __thiscall sub_10263B80(GAME::Water *this)
{
  [...]
  i = 0;
  if ( this->numLayers )
  {
    layer = this->layer;
    do
    {
      if ( *layer )
      {
        (*layer)->vtable->release(*layer, 1); // [1]
        *layer = nullptr;
      }
      ++i;
      ++layer;
    }
    while ( i < this->numLayers );
  }

L’objectif est donc le suivant : corrompre le membre water de la classe Level pour le faire pointer vers un faux objet Water composé d'un objet WaterLayer. Cependant, pour y parvenir, nous devons contourner l'ASLR. Le programme étant en 32 bits, il est facile de remplir l'espace d'adressage via de nombreuses allocations. Les objets WaterType sont associés à une texture définie par une chaîne de caractères. Chaque chaîne ne peut excéder 1 Mo ; toutefois, le programme peut charger un maximum de 128 WaterLayer, chacun étant associé à un WaterType différent.

Au total, 128 Mo de données peuvent être chargés dans le tas. Nous observons que chaque allocation de 1 Mo déclenche la réservation de plusieurs pages mémoire. Par conséquent, chaque chaîne de caractères commence à une plage d'adresses de la forme XXXXX000 (car les 12 bits de poids faible ne sont pas soumis à l'ASLR). La charge utile (payload) est donc placée et répétée au sein de chaque noiseTextureName de chaque WaterType.

Intéressons-nous maintenant à la construction de la structure répétée dans le tas. Reproduire un objet Water entier n'est pas obligatoire. Seuls le champ numLayer, qui indique le nombre de couches à détruire, et le tableau layer doivent être initialisés. Le premier élément du tableau layer pointe vers une vtable contenant une seule adresse de gadget : 0x10021b91.

0x10021b91:
xchg esp, eax
pop edi
pop esi
pop ebp
pop ebx
ret

Lorsque ce gadget est exécuté, EAX contient l'adresse de notre objet Layer. Une fois exécuté, le programme dépile les données et l'adresse de retour présente sur le tas (heap). Le reste de l'exploitation est assez classique : une ROP chain permet de rendre le tas exécutable, puis d'exécuter un shellcode. Le schéma ci-dessous présente un résumé de la structure qui est répétée dans le tas.

 

Charge utile
Payload

Démonstration

Nous avons réussi à exploiter cette vulnérabilité dans une machine virtuelle Windows 11, version 10.0.26200. Cet article illustre une nouvelle fois que les assets partagés peuvent servir de vecteur pour des charges malveillantes.

Video file

Chronologie

23/03/2026 Début de la recherche de vulnérabilité
15/04/2026 Prise de contact avec l'éditeur
02/05/2026 Relance
29/07/2026 Publication de l'article