Mozilla Firefox数学空间漏洞导致内存损坏(CVE-2025-4919)

本文详细分析了Mozilla Firefox中IonMonkey JIT编译器存在的数学空间处理漏洞(CVE-2025-4919),该漏洞允许攻击者通过包装算术操作绕过边界检查,实现越界内存读写,最终导致远程代码执行。

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

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

注意:本博客 heavily 依赖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
// Simple linear sum of the form 'n' or '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
6
7
8
// Math done in a Linear sum can either be in a modulo space, in which case
// overflow are wrapped around, or they can be computed in the integer-space in
// which case we have to check that no overflow can happen when summing
// constants.
//
// When the caller ignores which space it is, the definition would be used to
// deduce it.
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中,编译器简单地将 mostly 常量的复杂线性表达式折叠为单个Madd节点。此直接使用不会 concern 我们对于漏洞的目的。导致漏洞的使用位于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
37
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());
    // Both terms should be nullptr or the same definition.
    if (sumA.term != sumB.term) {
        return true;
    }
    // This bounds check is redundant.
    *eliminated = true;
    // Normalize the ranges according to the constant offsets in the two indexes.
    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;
    }
    // Update the dominating check to cover both ranges, denormalizing the
    // result per the constant offset in the index.
    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 + minimum和index + maximum,其中index是节点的实际输入值。在这种情况下,现有检查将具有minimum和maximum都设置为0(实际上,对于这两个 exact 边界检查,索引为𝑖,minimum/maximum都设置为4(第一个检查)和7(第二个检查)是可能的,但为了简单起见,我们现在忽略这一点)。上述边界检查消除传递现在将尝试将这两个边界检查合并为一个。为此,首先检查两个索引表达式是否是对应于相同项(在这种情况下是变量i)的线性求和。编译器不会生成索引为i的新MboundsCheck节点,而是简单地重用第一个边界检查并调整minimum和maximum字段。在这种情况下,由于第二个边界检查的索引值大3,maximum字段将设置为3,而minimum保持为0,实际索引值保持为i+4。虽然在SimpleLinearSum中创建包装和无限加法(和减法)语义之间的 clean 概念差异是一个好的设计选择,但在调用站点似乎 less care 确保只使用适当的语义。例如,边界检查 inherently 具有无限语义。如果数组长度为20,则10是有效索引,但2^32 + 10不是。这意味着使用包装Modulo数学空间可能导致错误结果。这在 actual 边界检查消除中变得相当明显。考虑一个数组在索引i和i+10处被索引。通常i+10应该总是比i大 exactly 10,因此将第一个检查的maximum扩展10将覆盖它。但如果加法具有未检查的Modulo语义,这不再成立,因为如果i本身是一个大的32位整数,如2^32 − 5,则i+10可能 potentially 溢出。触发此错误的基本构造 somewhat 简单:

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 for both。这反过来导致TryEliminateBoundsCheck相信第二个边界检查的索引将总是比第一个大5,并通过将第一个检查的maximum增加5来消除它。然而,这 clearly 已经不正确。例如,如果我们将i设置为2^31−7作为索引,则第一个索引将等于2^31 – 2,但第二个将等于−2^31 + 3,这不是相应范围覆盖的值。如果涉及的边界检查是32位整数类型,这不会导致立即问题,因为它们会在遇到索引加上最大偏移超过2^31 − 1时bailout。然而,对于IntPtr类型的边界检查,只要长度足够大,这就不是问题。因此,我们需要一个长度大于2^31的数组来击中此错误,因为边界检查将检查 up to 实际索引(2^31 - 7) + 10 = 2^31 + 3在上面的例子中。这 probably 不可能使用普通JavaScript数组。幸运的是,Firefox的内存限制足够大,允许创建一些相当巨大的类型化数组。因此,我们可以简单地创建一个所需大小的Uint8Array。由于错误 fundamentally 是关于整数和其32位包装版本之间的混淆,它只能用于访问有效索引 exactly 2^32的偏移。对于 barely 溢出的加法,如上面的例子,可以实现接近−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 the result is only used by instructions that expect a bounds-checked
    // index, we must have eliminated or hoisted a bounds check and we can assume
    // the index is non-negative. This lets us generate more efficient code.
    if (ins->canBeNegative()) {
        bool canBeNegative = false;
        for (MUseDefIterator iter(ins); iter; iter++) {
            ...
            if (iter.def()->isLoadUnboxedScalar() || ...) {
                continue;
            }
            canBeNegative = true;
            break;
        }
        if (!canBeNegative) {
            ins->setCanNotBeNegative();
        }
    }
    ...

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

相关错误的可能性

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

利用

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

最后说明

尽管长时间大规模模糊测试JavaScript引擎,仍然存在高质量的错误 yet to be found。值得注意的是,这里描述的错误可能 challenging 使用模糊测试器找到,因为它需要大分配,可能触发性能惩罚。这 yet again 证明了源代码审查对于找到高质量错误的重要性。您也可以阅读Mozilla关于此Pwn2Own条目的更多内容在这篇文章中。

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

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