
Технический анализ и эксплойт-доказательство концепции для CVE-2026-8389, уязвимости типа путаницы типов в SpiderMonkey BaselineJIT в Firefox, продемонстрированной с помощью усечения байт-кода и манипуляции раскруткой исключений.
Эта уязвимость предназначалась для использования на Pwn2Own 2026 Berlin, но была исправлена в версии 150.0.3.
Усечение битового поля pcOffset в BaselineJIT SpiderMonkey на пути упреждающей компиляции baseline вне основного потока, приводящее к неверному, но в пределах границ, 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. Путь упреждающей компиляции baseline вне основного потока не проходил эту проверку, поэтому скрипт, чей байт-код превышает 256 МБ, мог пройти компиляцию baseline, в то время как его pcOffset незаметно усекался 28-битным сохранением (pcOffset & 0x0FFFFFFF). Два информативных MOZ_ASSERT выше (pcOffset_ == pcOffset и pcOffset <= BaselineMaxScriptLength) являются no-ops в релизных сборках, поэтому усечение оставалось незамеченным.
Путь упреждающей компиляции baseline вне основного потока:
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). Ни одна из этих проверок не проверяет длину скрипта, поэтому чрезмерно длинный скрипт достигал компиляции baseline через этот путь.
// 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 преобразуется обратно в указатель на байт-код в 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-заметок во время раскрутки исключений (HandleExceptionBaseline опирается на script->trynotes()), что приводит к некорректному чтению слота стека. Слот, который не является JSObject*, затем может быть обработан как таковой и диспетчеризован через его vtable, создавая путаницу типов, которая является основой для дальнейшей эксплуатации.