# 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:UsersuserDocumentsMy GamesTitan Quest – Immortal Thronecustommaps` folder designated for this purpose. The analysis was performed on the THQ Nordic re-release version from 2016 on the following environment:

ComponentVersionMicrosoft Windows 11 Professionnel10.0.26200Titan Quest : Anniversary Editionv2.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:UsersuserDocumentsMy GamesTitan Quest – Immortal Thronecustommaps` 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 by `Editor.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. The `MapCompiler.exe` utility 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. The `PSEditor.exe` utility allows you to open this type of file.
– `.arc`: Asset archive.

Numerous assets in `.arc` archives are available in the `C:GOG GalaxyTitan Quest – Anniversary EditionResources`  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 `WaterType` object. 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 :

ValueTypeComment18hCHUNK_TYPE_SECTORData deserialized by the method 19hCHUNK_TYPE_MINIMAPMetadata for map thumbnails1BhCHUNK_TYPE_QUESTList of strings indicating quest files01hCHUNK_TYPE_REGIONSMetadata for levels (size, position from world, …)11hCHUNK_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,

ValeurTypeCommentaire09hCHUNK_TYPE_WATERPhysical parameters on water layers03hCHUNK_TYPE_IMPASSABLE_DATAData on areas that are impassable for the player23hCHUNK_TYPE_SECTOR_LAYERSLoaded by

## 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:

1. Fill the heap with chunks the size of a _Level_ object.
2. Free chunk
3. 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 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