Exploiting Titan Quest
Titan Quest is a hack-and-slash video game released in 2006. In 2016, THQ Nordic released an Anniversary Edition. In the version provided by GOG, several development tools are installed with the game. These allow for the creation of new maps, items, and effects. This article details the various vulnerabilities found in the game engine as well as their exploitation.
Looking to improve your skills? Discover our trainings sessions! Learn more.
Introduction
Titan Quest is a hack-and-slash video game developed by Iron Lore and published by THQ. The game takes place during Antiquity, where the player must face hordes of mythological monsters. Each combat earns experience, allowing the player to acquire new skills and increase their character's attributes. Similar to tabletop role-playing games, the player explores dungeons and discovers new weapons, armor, and relics that will enable them to take on increasingly powerful bosses.
Although the game features a multiplayer mode, our research focused on content that can be shared by the community. Additional asset archives can be installed to add extra worlds playable in multiplayer. Installation is done manually in the C:\Users\user\Documents\My Games\Titan Quest - Immortal Throne\custommaps folder designated for this purpose. The analysis was performed on the THQ Nordic re-release version from 2016 on the following environment:
| Component | Version |
|---|---|
| Microsoft Windows 11 Professionnel | 10.0.26200 |
| Titan Quest : Anniversary Edition | v2.10.21415 |
Exploration
Titan Quest provides the development tools necessary to create maps, quests, equipment, and more. To use these tools, we invite you to read the guides written by the community: https://titanquestfans.net/index.php?topic=914.0
The map installation is done manually in the C:\Users\user\Documents\My Games\Titan Quest - Immortal Throne\custommaps folder designated for this purpose.
By playing around with the game's tools, we can observe various file formats, including the following non-exhaustive list:
.wrl: World file, contains references to level files. This type of file is created byEditor.exe.lvl: Describes a level, which can be a terrain or a dungeon..map: Compiled world file; this file gathers the different levels of the game. TheMapCompiler.exeutility produces this type of file..tga: Truevision TGA is an image file format.-
.tex: Compiled texture. .ssh: Shader file; these are small programs used to configure part of the graphics card's rendering..pfx: Visual effects file, used to manage graphical effects in the game such as explosions, smoke, dust, etc. ThePSEditor.exeutility allows you to open this type of file.-
.arc: Asset archive.
Numerous assets in .arc archives are available in the C:\GOG Galaxy\Titan Quest - Anniversary Edition\Resources folder. These assets can be extracted using the ARCExplorer tool, which provides example files to help understand the various formats.
Surface analysis
The Engine.dll DLL contains the majority of the methods that process the various file types. Each method takes a BinaryReader structure, defined below, as an input parameter. It describes the buffer to be deserialized.
struct __fixed GAME::BinaryReader // sizeof=0x10
{
unsigned char* buffer;
unsigned char* currentPtr;
int bufferSize;
int field_C;
};
The pseudocode below performs the deserialization of a WaterTypeobject. Each simple type member is consumed directly from the buffer, after which the currentPtr is incremented. Character strings are preceded by their size [1]. When the program needs to deserialize a more complex sub-object, it relies on the associated ::Load method.
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;
}
A total of 49 methods in the Engine.dll library take a BinaryReader structure as a parameter. Consequently, they represent a significant attack surface.
Map format
When a player loads a custom map, the GAME::World::LoadMap method is called. This is our starting point for understanding the map format. Each .map file begins with a signature followed by a version number. Next, the file contains headers divided into several data blocks. Each block consists of an integer indicating its type, followed by the block size, followed by the block data. Below are some examples of block types :
| Value | Type | Comment |
|---|---|---|
| 18h | CHUNK_TYPE_SECTOR | Data deserialized by the method GAME::SectorDataManager::Load |
| 19h | CHUNK_TYPE_MINIMAP | Metadata for map thumbnails |
| 1Bh | CHUNK_TYPE_QUEST | List of strings indicating quest files |
| 01h | CHUNK_TYPE_REGIONS | Metadata for levels (size, position from world, …) |
| 11h | CHUNK_TYPE_INSTANCE_DATA |
Data deserialized by the method |
Following the header, the format directly embeds .TGA files representing map thumbnails and .LVL files corresponding to the levels. The position and size of these files are specified in the CHUNK_TYPE_MINIMAP and CHUNK_TYPE_REGIONS blocks. The following diagram summarizes the overall structure of a .map file.
Next, the GAME::RegionLoader class loads the levels in a separate thread. The GAME::Level::Load method is called for each level. Much like .map files, .lvl files begin with a signature followed by a version number. They are composed of blocks of various types,
| Valeur | Type | Commentaire |
|---|---|---|
| 09h | CHUNK_TYPE_WATER | Physical parameters on water layers |
| 03h | CHUNK_TYPE_IMPASSABLE_DATA | Data on areas that are impassable for the player |
| 23h | CHUNK_TYPE_SECTOR_LAYERS | Loaded by GAME::SectorLayers::Load |
Vulnerabilities
When the program encounters a CHUNK_TYPE_IMPASSABLE_DATA, the GAME::ImpassableData::Load method is called. Below is an extract of the pseudocode:
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;
In this method, the program allocates a byte array whose size is determined by multiplying width and height. These variables are provided in the .lvl file. There is an initial arithmetic issue where the multiplication of width by height can lead to an integer overflow exceeding 32 bits [1]. Subsequently, a byte array of size count is copied into the previously allocated buffer [2]. The count variable is not verified, and its value can be greater than the buffer size. This is a heap overflow vulnerability.
Additional vulnerabilities were found in the GAME::EmitterData::InternalBinaryRead method. This method is called when loading a .pfx file representing a particle effect. Several arrays of arbitrary size are read from the file and copied into a fixed-size GAME::EmitterData structure [1] [2] [3] [4] [5] [6]. These are again heap overflow vulnerabilities; however, since the allocation size is uncontrolled, they are less interesting from an exploitation standpoint.
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
As a first step, a quick analysis using SystemInformer1 allows for obtaining the list of mitigations enabled on the binary and its DLLs. The game was compiled as a 32-bit application, and some of its DLLs are not subject to ASLR.
Note: The DLL DevIL.dll is packed with UPX2 and must be decompressed using the command upx -d DevIL.dll before being analyzed.
Next, we focused on mitigations related to the Windows heap.
Depending on the size of our allocation, the overflowing buffer will be placed either in the LFH (Low Fragmentation Heap) or in the Segment Heap if the multiplication of width by height results in a value greater than 0x4000. The goal is to overwrite an object's vtable; however, most game objects are small and reside in the LFH. Since LFH allocations are non-deterministic, precisely overwriting a specific object is difficult. Consequently, we chose to target objects in the Segment Heap.
An additional constraint arises: the buffer is freed after the level loads. The Windows allocator verifies the header of the next block. In the case of the NT Heap3, the header is encrypted (via XOR) with the Encoding field of the _HEAP structure. This key is randomly generated at each execution. If the header is corrupted, the allocator terminates the program. To bypass this, we must hijack the program's execution before the GAME::Level::Load method completes.
The only object positioned in the Segment Heap during the GAME::Level::Load method is the Level object itself. This presents a new problem: how can we ensure the buffer allocated by GAME::ImpassableData::Load is allocated before the Level object?
By studying the deterministic behavior of the Segment Heap, we observe that chunks of the same size are allocated sequentially. Furthermore, the allocation of same-sized chunks follows a FIFO (First-In, First-Out) pattern—the first chunk freed is the first chunk reused. Therefore, the objective is to prepare the heap in the following manner:
- Fill the heap with chunks the size of a Level object.
- Free chunk 2, then chunk 4.
- The level is allocated in place of chunk 4, and the next allocation of the same size as a Level object will be the Impassable Buffer. As a result, the Impassable Buffer array is positioned before the Level object, allowing it to be corrupted.
To perform these operations, we can abuse the GAME::Water::Load method. First, the program allocates WaterType objects that contain the characteristics of the various water zones on the map. These objects are identified by variable-length strings, which allows us to allocate chunks of an arbitrary size in the heap. The following pseudocode illustrates the loading of WaterType objects.
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 );
}
Then, these WaterType objects are associated with WaterLayer objects. The lifespan of a WaterType object is managed by a reference counter. At the end of the GAME::Water::Load method, any WaterType objects that were not associated with WaterLayer objects are freed. This behavior allows for the creation of "holes" in the heap that can be reused later.
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 );
The strategy is therefore as follows: build a first level that prepares the heap, then prepare a second level that triggers the vulnerability in GAME::ImpassableData::Load.
Now, let's address the second problem: how to hijack the program's execution before the GAME::Level::Load method finishes?
The water field of the Level class is particularly interesting to corrupt. This field is processed when the program encounters a chunk of type 09h. This block can be placed after an ImpassableData type block, and thus processed after the memory corruption has occurred.
The GAME::Level::NewWater method is called when processing a type 09h chunk. If the water member is non-null, the GAME::Water::~Water destructor is called. This method destroys the objects associated with the GAME::Water type object. The destruction of each WaterLayer is performed via an indirect call [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 );
}
The objective is therefore as follows: corrupt the water member of the Level class to make it point to a fake Water object consisting of a WaterLayer object. However, to achieve this, we must bypass ASLR. Since the program is 32-bit, it is easy to fill the address space via numerous allocations. WaterType objects are associated with a texture defined by a character string. Each string cannot exceed 1 MB; however, the program can load a maximum of 128 WaterLayer, each associated with a different WaterType.
In total, 128 MB of data can be loaded into the heap. We observe that each 1 MB allocation triggers the reservation of several memory pages. Consequently, each character string begins at an address range in the form XXXXX000 (as the 12 least significant bits are not subject to ASLR). The payload is therefore placed and repeated within each noiseTextureName of every WaterType.
Now, let’s look at the construction of the structure repeated in the heap. Reproducing an entire Water object is not mandatory. Only the numLayer field, which indicates the number of layers to be destroyed, and the layer array need to be initialized. The first element of the layer array points to a vtable containing a single gadget address: 0x10021b91.
0x10021b91:
xchg esp, eax
pop edi
pop esi
pop ebp
pop ebx
ret
When this gadget is executed, EAX contains the address of our Layer object. Once executed, the program pops the data and the return address present on the heap. The rest of the exploitation is fairly standard: a ROP chain is used to make the heap executable, followed by the execution of a shellcode. The diagram below provides a summary of the structure that is repeated in the heap.
Proof of concept
We have successfully exploited this vulnerability in a Windows 11 virtual machine, version 10.0.26200. This article illustrates once again that shared assets can serve as a vector for malicious payloads.
Timeline
| 23/03/2026 |
Start of vulnerability search
|
| 15/04/2026 | Initial contact with the publisher |
| 02/05/2026 | We reached out to the publisher again. |
| 29/07/2026 | Publication of the article |