此漏洞原计划在 Pwn2Own 2026 柏林上使用,但在 150.0.3 版本中已被修补。
SpiderMonkey BaselineJIT 在急切的离线程基线编译路径上发生了 pcOffset 位字段截断,导致异常展开期间出现错误但在边界内的字节码 pc,进而引发类型混淆。
RetAddrEntry 将字节码偏移量 pcOffset_ 存储为 28 位位字段。同头文件中定义的常量 BaselineMaxScriptLength = 0x0fffffff 大小正好匹配该位字段的范围。
// js/src/jit/BaselineJIT.h:73
static constexpr uint32_t BaselineMaxScriptLength = 0x0fffffffu;
// js/src/jit/BaselineJIT.h:100-105
class RetAddrEntry {
// Offset from the start of the JIT code where call instruction is.
uint32_t returnOffset_;
// The offset of this bytecode op within the JSScript.
uint32_t pcOffset_ : 28;
// js/src/jit/BaselineJIT.h:141-156 (RetAddrEntry constructor)
RetAddrEntry(uint32_t pcOffset, Kind kind, CodeOffset retOffset)
: returnOffset_(uint32_t(retOffset.offset())),
pcOffset_(pcOffset),
kind_(uint32_t(kind)) {
MOZ_ASSERT(returnOffset_ == retOffset.offset(),
"retOffset must fit in returnOffset_");
// The pc offset must fit in at least 28 bits, since we shave off 4 for
// the Kind enum.
MOZ_ASSERT(pcOffset_ == pcOffset);
static_assert(BaselineMaxScriptLength <= (1u << 28) - 1);
MOZ_ASSERT(pcOffset <= BaselineMaxScriptLength);
MOZ_ASSERT(kind < Kind::Invalid);
MOZ_ASSERT(this->kind() == kind, "kind must fit in kind_ bit field");
}
在修复之前,这个上界只在发布版本中的 CanEnterBaselineJIT (js/src/jit/BaselineJIT.cpp) 内强制执行,这是预热/OSR 主线程入口路径。急切的离线程基线编译路径没有经过这个检查,因此字节码超过 256 MB 的脚本可以清除基线编译,而其 pcOffset 被 28 位存储 (pcOffset & 0x0FFFFFFF) 静默截断。上述两个信息性 MOZ_ASSERT(pcOffset_ == pcOffset 和 pcOffset <= BaselineMaxScriptLength)在发布版本中是空操作,所以截断未被检测到。
急切的离线程基线编译路径:
CompilationStencil::instantiateStencils
-> MaybeDoEagerBaselineCompilations (js/src/frontend/Stencil.cpp:2720)
-> DispatchOffThreadBaselineBatchEager (js/src/jit/BaselineJIT.cpp:386)
-> BaselineCompileTask::runTask (js/src/jit/BaselineCompileTask.cpp:69)
-> BaselineCompile
补丁前,MaybeDoEagerBaselineCompilations 仅以 script->baselineDisabled() 和 jit::CanBaselineInterpretScript(script) 作为门控条件。两者均未验证脚本长度,因此超长脚本可通过此路径进入基线编译。
// js/src/frontend/Stencil.cpp, MaybeDoEagerBaselineCompilations (pre-patch)
if (script->baselineDisabled()) {
continue;
}
if (!jit::CanBaselineInterpretScript(script)) {
continue;
}
相比之下,主线程路径强制执行了该界限(此代码块后来被移入共享函数 CanBaselineCompileScript):
// js/src/jit/BaselineJIT.cpp, CanEnterBaselineJIT (pre-patch)
if (script->length() > BaselineMaxScriptLength) {
script->disableBaselineCompile();
return Method_CantCompile;
}
被截断的 pcOffset 通过 JSScript::offsetToPC 在 JSJitFrameIter::baselineScriptAndPc 中转换回字节码指针,经由 RetAddrEntry::pc -> JSScript::offsetToPC。
// js/src/jit/JSJitFrameIter.cpp:155-160
// address.
uint8_t* retAddr = resumePCinCurrentFrame();
const RetAddrEntry& entry =
script->baselineScript()->retAddrEntryFromReturnAddress(retAddr);
*pcRes = entry.pc(script);
}
// js/src/jit/BaselineJIT.h:162-164 (RetAddrEntry::pc)
jsbytecode* pc(JSScript* script) const {
return script->offsetToPC(pcOffset_);
}
该 pc 被传递给异常处理器 HandleExceptionBaseline。由于截断后的偏移量小于实际脚本长度,offsetToPC 返回一个在边界内但错误的 pc,因此该失败是静默的,而非明显的越界崩溃。
// js/src/jit/JitFrames.cpp:584-591
static void HandleExceptionBaseline(JSContext* cx, JSJitFrameIter& frame,
CommonFrameLayout* prevFrame,
ResumeFromException* rfe) {
MOZ_ASSERT(frame.isBaselineJS());
MOZ_ASSERT(prevFrame);
jsbytecode* pc;
frame.baselineScriptAndPc(nullptr, &pc);
错误的 pc 会导致异常展开过程中 try-note 匹配错误(HandleExceptionBaseline 依赖于 script->trynotes()),进而引发错误的栈槽读取。一个本不是 JSObject* 的槽位可能被视为 JSObject*,并通过其虚函数表进行分发,从而产生类型混淆,为后续利用奠定基础。