
Technical analysis and proof-of-concept exploit for CVE-2026-8389, a SpiderMonkey BaselineJIT type confusion vulnerability in Firefox, demonstrated with bytecode truncation and exception unwinding manipulation.
This vulnerability was intended to be used at Pwn2Own 2026 Berlin, but was patched in version 150.0.3.
SpiderMonkey BaselineJIT pcOffset bitfield truncation on the eager off-thread baseline-compile path, leading to a wrong-but-in-bounds bytecode pc during exception unwinding and a subsequent type confusion.
RetAddrEntry stores the bytecode offset pcOffset_ as a 28-bit bitfield. The BaselineMaxScriptLength = 0x0fffffff constant defined in the same header is sized to exactly match that bitfield range.
// 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");
}
Before the fix, this upper bound was enforced in release builds only inside CanEnterBaselineJIT (js/src/jit/BaselineJIT.cpp), which is the warmup / OSR main-thread entry path. The eager off-thread baseline-compile path did not pass through that check, so a script whose bytecode exceeds 256 MB could clear baseline compilation while its pcOffset was silently truncated by the 28-bit store (pcOffset & 0x0FFFFFFF). The two informative MOZ_ASSERTs above (pcOffset_ == pcOffset and pcOffset <= BaselineMaxScriptLength) are no-ops in release builds, so the truncation went undetected.
The eager off-thread baseline-compile path:
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
Pre-patch, MaybeDoEagerBaselineCompilations gated only on script->baselineDisabled() and jit::CanBaselineInterpretScript(script). Neither validates script length, so an over-long script reached baseline compilation through this path.
// js/src/frontend/Stencil.cpp, MaybeDoEagerBaselineCompilations (pre-patch)
if (script->baselineDisabled()) {
continue;
}
if (!jit::CanBaselineInterpretScript(script)) {
continue;
}
By contrast, the main-thread path enforced the bound (this block was later moved into the shared CanBaselineCompileScript):
// js/src/jit/BaselineJIT.cpp, CanEnterBaselineJIT (pre-patch)
if (script->length() > BaselineMaxScriptLength) {
script->disableBaselineCompile();
return Method_CantCompile;
}
The truncated pcOffset is converted back to a bytecode pointer in JSJitFrameIter::baselineScriptAndPc, via 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_);
}
That pc is handed to the exception handler HandleExceptionBaseline. Because the truncated offset is smaller than the real script length, offsetToPC returns an in-bounds but incorrect pc, so the failure is silent rather than an obvious out-of-range crash.
// 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);
That wrong pc drives incorrect try-note matching during exception unwinding (HandleExceptionBaseline keys on script->trynotes()), leading to an incorrect stack-slot read. A slot that is not a JSObject* can then be treated as one and dispatched through its vtable, producing a type confusion that is the basis for further exploitation.