Mozilla Firefox数学空间漏洞导致内存损坏分析

本文深入分析了Mozilla Firefox中IonMonkey JIT编译器存在的数学空间处理漏洞CVE-2025-4919,详细解释了边界检查消除机制中的缺陷如何导致越界内存访问,并提供了完整的漏洞利用链技术细节。

CVE-2025-4919:Mozilla Firefox中通过数学空间导致的损坏

2025年7月15日 | Hossein Lotfi

近年来,针对JavaScript引擎漏洞的攻击兴趣日益增加,特别是JIT引擎中的漏洞,因为它们提供了强大的原语且已有成熟的技术手段。在Pwn2Own Berlin 2025上,Manfred Paul利用IonMonkey中的一个漏洞攻破了Mozilla Firefox渲染器进程,但未进一步逃逸JavaScript引擎沙箱。IonMonkey是SpiderMonkey(Firefox JavaScript和WebAssembly引擎)的JIT编译器。该漏洞被分配为CVE-2025-4919,Mozilla在次日通过安全公告2025-36在Firefox 138.0.4中迅速修复。Trend Zero Day Initiative为该漏洞分配了ZDI-25-291编号。以下是漏洞利用视频:

注意:本博客大量依赖Manfred Paul在Pwn2Own比赛中提供的细节。

介绍

Ion JIT编译器在多个位置使用名为ExtractLinearSum的函数,将值节点转换为线性求和表达式。目的是能够推理仅包含加法和减法的子表达式。例如,考虑表示表达式(x+(2+3)) − (−3)的值节点图,这总是等于x+8,在边界检查消除或提升等上下文中更容易推理。ExtractLinearSum函数提取这种简化形式。要理解该函数的细节,请考虑其在jit/IonAnalysis.h中的声明:

1
2
3
SimpleLinearSum ExtractLinearSum(MDefinition* ins,
                                 MathSpace space = MathSpace::Unknown,
                                 int32_t recursionDepth = 0);

返回类型是结构体SimpleLinearSum,同样在IonAnalysis.h中声明:

1
2
3
4
5
6
7
// 简单线性求和形式为'n'或'x + n'
struct SimpleLinearSum {
    MDefinition* term;
    int32_t constant;
    SimpleLinearSum(MDefinition* term, int32_t constant)
        : term(term), constant(constant) {}
};

如注释所述,仅支持非常简单的线性表达式,如𝑥 + 𝑛,其中𝑥是任意值节点,𝑛是整数常量。纯常量也可以通过将term指针设置为NULL指针来表示。重要的是,任何(整数)表达式val都可以转换为SimpleLinearSum。在表达式本身不是加法或减法的简单情况下,ExtractLinearSum可以简单地返回一个SimpleLinearSum,term设置为val,constant设置为0。

ExtractLinearSum函数接受三个参数。第一个是要分析的表达式ins,应该是整数类型的值节点。第三个是简单的递归计数器(因为ExtractLinearSum函数可以递归调用自身来分析,例如加法的两侧),用于跟踪递归深度以防止堆栈耗尽。调用者始终将此参数保留为其默认值0。然而,最微妙的参数是第二个,描述所谓的MathSpace。此枚举也在IonAnalysis.h中声明:

1
2
3
4
5
// 线性求和中的数学可以在模空间中进行,在这种情况下溢出会回绕,
// 或者可以在整数空间中进行计算,在这种情况下我们必须检查求和常量时不会发生溢出。
//
// 当调用者忽略是哪个空间时,将使用定义来推导它。
enum class MathSpace { Modulo, Infinite, Unknown };

如注释所述,这样做是为了区分MAdd/Msub节点的两种不同可能行为:它们可以选择性地具有溢出检查,如果操作会溢出32位有符号整数范围,则导致bailout。如果启用此检查,操作的语义对应于实际(无限)整数。使用32位作为表示仅仅是一种推测性假设,如果违反则可能导致bailout。这些语义由Infinite MathSpace表示。另一方面,编译器还可以生成未检查的加法或减法。这些有意将输出截断为32位整数范围,因为使用站点可能无论如何都会执行这种截断(例如,在模式如(x+5)|0中,按位OR会将其操作数截断为32位整数)。这对应于执行模2^32的算术,因此由Modulo MathSpace表示。最后,Unknown MathSpace只是一个可以传递给ExtractLinearSum调用的默认值。此默认值的含义是,所描述的两种数学空间都是可接受的,函数将简单地根据最外层操作选择适当的空间。然而,它仍将通过将所选空间传递给递归调用而不是Unknown,确保所有涉及的操作只允许一致的MathSpace。

根本原因

ExtractLinearSum函数在Ion编译器中的几个地方使用。最直接的使用发生在jit/FoldLinearArithConstants.cpp中,编译器简单地将主要由常量组成的复杂线性表达式折叠为单个Madd节点。对于漏洞的目的,这种直接使用不会引起我们的关注。导致漏洞的使用位于jit/IonAnalysis.cpp中的TryEliminateBoundsCheck函数内:

 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
static bool TryEliminateBoundsCheck(BoundsCheckMap& checks, size_t blockIndex,
                                    MBoundsCheck* dominated, bool* eliminated) {
    ...
    MBoundsCheck* dominating =
        FindDominatingBoundsCheck(checks, dominated, blockIndex);
    ...
    if (dominating->length() != dominated->length()) {
        return true;
    }
    SimpleLinearSum sumA = ExtractLinearSum(dominating->index());
    SimpleLinearSum sumB = ExtractLinearSum(dominated->index());
    // 两个项都应为nullptr或相同的定义。
    if (sumA.term != sumB.term) {
        return true;
    }
    // 此边界检查是冗余的。
    *eliminated = true;
    // 根据两个索引中的常量偏移规范化范围。
    int32_t minimumA, maximumA, minimumB, maximumB;
    if (!SafeAdd(sumA.constant, dominating->minimum(), &minimumA) ||
        !SafeAdd(sumA.constant, dominating->maximum(), &maximumA) ||
        !SafeAdd(sumB.constant, dominated->minimum(), &minimumB) ||
        !SafeAdd(sumB.constant, dominated->maximum(), &maximumB)) {
        return false;
    }
    // 更新主导检查以覆盖两个范围,根据索引中的常量偏移反规范化结果。
    int32_t newMinimum, newMaximum;
    if (!SafeSub(std::min(minimumA, minimumB), sumA.constant, &newMinimum) ||
        !SafeSub(std::max(maximumA, maximumB), sumA.constant, &newMaximum)) {
        return false;
    }
    dominating->setMinimum(newMinimum);
    dominating->setMaximum(newMaximum);
    dominating->setBailoutKind(BailoutKind::HoistBoundsCheck);
    return true;
}

此函数的目的是合并同一对象的后续边界检查。例如,考虑以下JavaScript操作:

1
2
let x = array[i + 4];
let y = array[i + 7];

这些将生成两个单独的边界检查,索引分别为𝑖 + 4𝑖 + 7。类型为MboundsCheck的节点可以一次检查整个索引范围。为此,它们存储两个整数偏移量:minimum和maximum。当运行边界检查时,实际检查的索引对应于index + minimumindex + maximum,其中index是节点的实际输入值。在这种情况下,现有检查的minimum和maximum都将设置为0(实际上,对于这两个确切的边界检查,索引为𝑖,minimum/maximum分别设置为4(第一个检查)和7(第二个检查)是可能的,但为了简单起见,我们现在忽略这一点)。上述边界检查消除传递现在将尝试将这两个边界检查合并为一个。为此,首先检查两个索引表达式是否都是对应于相同项(在这种情况下是变量i)的线性求和。编译器不会生成索引为i的新MboundsCheck节点,而是简单地重用第一个边界检查并调整minimum和maximum字段。在这种情况下,由于第二个边界检查的索引值大3,maximum字段将设置为3,而minimum保持为0,实际索引值保持为i+4。

虽然在SimpleLinearSum中清晰区分环绕和无限加法(和减法)语义是一个好的设计选择,但在调用站点上似乎较少注意确保只使用适当的语义。例如,边界检查本质上具有无限语义。如果数组长度为20,则10是有效索引,但2^32 + 10不是。这意味着使用环绕Modulo数学空间可能导致错误结果。这在实际的边界检查消除中变得相当明显。考虑一个数组在索引i和i+10处被索引。通常i+10应该总是比i大 exactly 10,因此将第一个检查的maximum扩展10将覆盖它。但是,如果加法具有未检查的Modulo语义,这不再成立,因为如果i本身是一个大的32位整数,如2^32 − 5,则i+10可能潜在溢出。触发此错误的基本构造有些简单:

1
2
let x = array[(i+5)|0];
let y = array[(i+10)|0];

这里发生的是,按位OR与0将强制截断为32位整数,因此两个加法都变成未检查的环绕加法。到边界检查消除阶段运行时,按位OR将被消除,因为与0进行OR操作是NOP。这导致两个数组索引分别为i + 5和i + 10,其中两个加法现在都是环绕的。这意味着ExtractLinearSum将能够分析这些表达式,选择Modulo MathSpace,返回一个SimpleLinearSum,term设置为i。这反过来导致TryEliminateBoundsCheck认为第二个边界检查的索引总是比第一个大5,并通过将第一个检查的maximum增加5来消除它。然而,这显然已经不正确。例如,如果我们将i设置为2^31−7作为索引,那么第一个索引将等于2^31 – 2,但第二个将等于−2^31 + 3,这不是相应范围覆盖的值。如果涉及的边界检查是32位整数类型,这不会导致立即问题,因为它们会在遇到索引加上最大偏移超过2^31 − 1时bailout。然而,对于IntPtr类型的边界检查,只要长度足够大,这就不是问题。因此,我们需要一个长度大于2^31的数组来触发此错误,因为边界检查将检查高达实际索引(2^31 - 7) + 10 = 2^31 + 3。使用普通JavaScript数组这可能是不可能的。幸运的是,Firefox的内存限制足够大,允许创建一些相当大的类型化数组。因此,我们可以简单地创建一个所需大小的Uint8Array。由于错误基本上是关于整数与其32位环绕版本之间的混淆,它只能用于访问与有效索引相差正好2^32的索引。对于像上述示例中刚刚溢出的加法,可以实现接近−2^31的索引。这不容易利用,因为它会导致在类型化数组之前约2^31字节的内存中的读取和写入原语。因此,使用更大的2^32大小的数组是有意义的。只要索引值minimum和maximum都是非负的有符号32位整数,这将允许任何边界检查通过。

触发越界读取和写入错误

一个简单的越界读取POC可能如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let arr = new Uint8Array(2**32);
let out = {x: 42n};
function oobRead(arr, out, idx) {
    let idx1 = (idx+100)|0;
    let idx2 = (idx+(2**31-1))|0;
    let r1 = arr[idx1];
    let r2 = arr[idx2];
    out.x = idx2;
    return r2;
}
for (let i = 0; i < 1000000; i++){
    oobRead(arr, out, -50);
}
console.log(oobRead(arr, out, 2**31-200));

这里,数组访问的边界检查将再次合并为一个。这个单一的边界检查将索引等于idx1 + 100,但maximum设置为2^31 – 101,因为TryEliminateBoundsCheck计算这是两个索引的差异,再次忽略了环绕的可能性。循环中的训练迭代(部分)由解释器而不是JIT编译评估,将简单地访问索引(−50) + 100 = 50和(−50) + (2^31 − 10) = 2^31 – 60,这两个都在边界内。然而,对于最终评估,idx1将等于(2^31 − 200) + 100 = 2^31 − 100,但idx2将等于(2^31 − 200) + (2^31 − 1) − 2^31 = −201,由于环绕行为。然而,合并的边界检查仍将通过,因为它只检查从2^31 – 100开始有2^31 − 99个有效索引的范围,这是真的。这导致接受负索引并访问其开始之前的字节。在这种情况下执行了越界读取,但利用逻辑对于写入原语是相同的。唯一需要解释的是代码中out对象的作用。这是由于MInt32ToIntPtr节点的行为的技术必要性。插入此节点以将我们的32位值idx2转换为实际的64位数组索引。然而,在访问Int32ToIntPtr节点期间,jit/Lowering.cpp中有一些逻辑检查是否需要符号扩展:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void LIRGenerator::visitInt32ToIntPtr(MInt32ToIntPtr* ins) {
    MDefinition* input = ins->input();
    MOZ_ASSERT(input->type() == MIRType::Int32);
    MOZ_ASSERT(ins->type() == MIRType::IntPtr);
#ifdef JS_64BIT
    // 如果结果仅由期望边界检查索引的指令使用,
    // 我们必须已消除或提升了一个边界检查,并且我们可以假设索引是非负的。
    // 这让我们生成更高效的代码。
    if (ins->canBeNegative()) {
        bool canBeNegative = false;
        for (MUseDefIterator iter(ins); iter; iter++) {
            ...
            if (iter.def()->isLoadUnboxedScalar() || ...) {
                continue;
            }
            canBeNegative = true;
            break;
        }
        if (!canBeNegative) {
            ins->setCanNotBeNegative();
        }
    }
    ...

如果唯一用途是作为加载的索引,则假定该值根本不能为负(因为这只能由损坏的边界检查导致,如我们的情况),并执行简单的零扩展而不是通常的符号扩展。然而,将值转换为BigInt也使用Int32ToIntPtr节点作为中间体,导致另一个用途并禁用此优化。通过out对象返回此BigInt防止其被优化掉。

相关错误的可能性

如上所述,TryEliminateBoundsCheck不是唯一使用ExtractLinearSum的地方。值得注意的是,它还在jit/RangeAnalysis.cpp中的边界检查提升期间在多个地方使用(有时也通过相关函数ExtractLinearInequality)。这里,函数再次使用其默认的第二个参数MathSpace::Unknown。同样,这个选择似乎不正确。考虑一个循环如for (let i = 0; i + 3 < 10 − 2; i = i+1) {...}。这里循环分析将基本得出结论(通过提取各种线性求和)最多将进行5次迭代。然后使用此信息来提升可能线性依赖于某些循环变量的边界检查。然而,如果任何这些加法被未检查的环绕加法替换,则这些不变量可能不再成立。例如,循环for (let i = 2^31− 5; i <= 2^31-1; i = (i+1)|0) {...}将永远运行。然而,有一个障碍似乎使这更难(也许不可能)利用,这是负责摆脱不必要的按位OR的编译阶段在边界检查提升之后(尽管在边界检查消除之前)。WebAssembly也不能使用,因为此优化在该编译模式中被禁用。

利用

一如既往,受控的越界读取和写入原语对于进一步利用是非常强大的工具。唯一的轻微困难是访问来自非常大的类型化数组而不是更通常的小堆对象,这意味着它不会分配在例如nursery堆中。实验上,效果良好的是获得对大型Map对象的访问。分配其中一些几乎总是导致一个map正好在巨大类型化数组之前。此map对象包含指向其值的标记指针。读取这样的指针直接导致addrOf原语。相反,覆盖标记指针导致fakeObj原语。一旦获得这两个原语,利用变得相当标准。在一些(小)数组缓冲区的固定内联存储中制作一些假结构,这些结构是通过addrOf确定的对象位置的固定偏移。使用这个,制作一个完整的假对象,其元素也是假的,具有巨大的长度。此对象不完全有效,因为有一些指向例如对象类的指针难以伪造,但它足够好以暂时获得另一个越界访问,这次进入另一个重叠的ArrayBuffer。这最终可用于覆盖此数组缓冲区的数据地址,导致任意读取和写入原语。通过将shellcode嵌入WASM函数的浮点常量中,可以将shellcode放入可执行内存。最后,剩下的就是覆盖WASM函数的入口点偏移以执行shellcode。利用演示可在https://www.youtube.com/watch?v=TG029NAGKs0 获得。

最后说明

尽管长时间大规模fuzzing JavaScript引擎,仍然存在高质量的错误有待发现。值得注意的是,这里描述的错误可能难以使用fuzzer发现,因为它需要大分配,可能触发性能惩罚。这再次证明了源代码审查对于发现高质量错误的重要性。您还可以在此帖子中阅读更多关于Mozilla对此Pwn2Own条目的看法。

您可以在Twitter上找到我@hosselot,并在Twitter、Mastodon、LinkedIn或Bluesky上关注团队,以获取最新的利用技术和安全补丁。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计