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
中的声明:
|
|
返回类型是结构体SimpleLinearSum,同样在IonAnalysis.h
中声明:
|
|
如注释所述,仅支持非常简单的线性表达式,如𝑥 + 𝑛
,其中𝑥是任意值节点,𝑛是整数常量。纯常量也可以通过将term指针设置为NULL指针来表示。重要的是,任何(整数)表达式val都可以转换为SimpleLinearSum。在表达式本身不是加法或减法的简单情况下,ExtractLinearSum可以简单地返回一个SimpleLinearSum,其中term设置为val,constant设置为0。
ExtractLinearSum函数接受三个参数。第一个是要分析的表达式ins,应该是整数类型的值节点。第三个是简单的递归计数器(因为ExtractLinearSum函数可以递归调用自身来分析,例如加法的两侧),它仅跟踪递归深度以防止堆栈耗尽。调用者总是将此参数保留为其默认值0。最微妙的参数是第二个,描述所谓的MathSpace。此枚举同样在IonAnalysis.h
中声明:
|
|
如注释所述,这是为了分离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函数:
|
|
此函数的目的是合并同一对象上的后续边界检查。例如,考虑以下JavaScript操作:
|
|
这些将生成两个单独的边界检查,索引为𝑖 + 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 简单:
|
|
这里发生的是,按位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可能看起来像以下:
|
|
这里,数组访问的边界检查将再次合并为一个。这个单个边界检查将具有索引等于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
中有一些逻辑检查是否需要符号扩展:
|
|
如果唯一用途是作为加载的索引,则假定值根本不能为负(因为这只能由 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上关注团队以获取最新的利用技术和安全补丁。