游戏漏洞挖掘
《英雄无敌 V》是一款2006年发行的策略类主机游戏。研究发现其地图文件解析存在堆缓冲区溢出漏洞,攻击者可利用自定义地图触发漏洞并执行恶意代码。 2025-9-29 12:34:1 Author: www.freebuf.com(查看原文) 阅读量:2 收藏

《英雄无敌 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 类用于表示中央目录表中条目的元数据。

图片

在 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.", 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 。

typedefunsigned(*in_func)(void FAR *, z_const unsignedchar FAR * FAR *);typedefint(*out_func)(void FAR *, unsignedchar 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, constvoid *uncompressedData, unsignedint uncompressedDataSize){ qmemcpy(*ppDest, uncompressedData, uncompressedDataSize); *ppDest += uncompressedDataSize;return0;}

文件 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); }returnmalloc(size); }if ( size )returnmalloc(size); [...]}

Hfive_alloc_internal 使用按大小排序的自由块链表,这类似于 Linux 上的 tcache 堆分配器,相同大小的自由块位于由 VirtualAlloc 分配的同一内存区域中。

void *__fastcall Hfive_alloc_internal(int a2, signedint 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。

图片

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

import idaapiimport ida_segmentea_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

文章来源: https://www.freebuf.com/articles/vuls/451111.html
如有侵权请联系:admin#unsafe.sh