
Análise técnica e exploit de prova de conceito para CVE-2026-8389, uma vulnerabilidade de confusão de tipos no BaselineJIT do SpiderMonkey no Firefox, demonstrada com truncamento de bytecode e manipulação de desenrolamento de exceções.
Esta vulnerabilidade foi projetada para ser usada no Pwn2Own 2026 Berlin, mas foi corrigida na versão 150.0.3.
Truncamento do bitfield pcOffset do BaselineJIT do SpiderMonkey no caminho de compilação baseline ansiosa fora da thread, levando a um pc de bytecode incorreto, mas dentro dos limites, durante o unwind de exceções e a uma subsequente confusão de tipos.
RetAddrEntry armazena o offset de bytecode pcOffset_ como um campo de bits de 28 bits. A constante BaselineMaxScriptLength = 0x0fffffff, definida no mesmo cabeçalho, tem tamanho exato para corresponder a esse intervalo do campo de bits.
// 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");
}
Antes da correção, esse limite superior era aplicado em builds de release apenas dentro de CanEnterBaselineJIT (js/src/jit/BaselineJIT.cpp), que é o caminho de entrada da thread principal para warmup/OSR. O caminho de compilação baseline ansiosa fora da thread não passava por essa verificação, portanto, um script cujo bytecode excedesse 256 MB podia concluir a compilação baseline enquanto seu pcOffset era silenciosamente truncado pelo armazenamento de 28 bits (pcOffset & 0x0FFFFFFF). As duas MOZ_ASSERTs informativas acima (pcOffset_ == pcOffset e pcOffset <= BaselineMaxScriptLength) não têm efeito em builds de release, então o truncamento passou despercebido.
O caminho de compilação baseline ansiosa fora da thread:
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
Antes do patch, MaybeDoEagerBaselineCompilations só verificava script->baselineDisabled() e jit::CanBaselineInterpretScript(script). Nenhuma das duas validava o comprimento do script, portanto, um script longo demais chegava à compilação baseline por esse caminho.
// js/src/frontend/Stencil.cpp, MaybeDoEagerBaselineCompilations (pre-patch)
if (script->baselineDisabled()) {
continue;
}
if (!jit::CanBaselineInterpretScript(script)) {
continue;
}
Em contraste, o caminho da thread principal aplicava o limite (esse bloco foi posteriormente movido para o CanBaselineCompileScript compartilhado):
// js/src/jit/BaselineJIT.cpp, CanEnterBaselineJIT (pre-patch)
if (script->length() > BaselineMaxScriptLength) {
script->disableBaselineCompile();
return Method_CantCompile;
}
O pcOffset truncado é convertido de volta em um ponteiro de bytecode em JSJitFrameIter::baselineScriptAndPc, por meio de 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_);
}
Esse pc é passado para o manipulador de exceções HandleExceptionBaseline. Como o offset truncado é menor que o comprimento real do script, offsetToPC retorna um pc dentro dos limites, porém incorreto, portanto, a falha é silenciosa em vez de um crash óbvio fora dos limites.
// 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);
Esse pc incorreto leva a uma correspondência incorreta de try-notes durante o unwind de exceções (HandleExceptionBaseline usa script->trynotes() como chave), resultando em uma leitura incorreta de slot da pilha. Um slot que não é um JSObject* pode então ser tratado como tal e despachado por meio de sua vtable, produzindo uma confusão de tipos que serve de base para exploração adicional.