
이 취약점은 Pwn2Own 2026 Berlin에서 사용할 예정이었으나, 버전 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 메인 스레드 진입 경로입니다. 즉시 오프스레드 베이스라인 컴파일 경로는 해당 검사를 거치지 않으므로, 바이트코드가 256MB를 초과하는 스크립트는 pcOffset이 28비트 저장에 의해 조용히 잘리는(pcOffset & 0x0FFFFFFF) 상황에서도 베이스라인 컴파일을 통과할 수 있었습니다. 위의 두 정보 제공용 MOZ_ASSERT(pcOffset_ == pcOffset 및 pcOffset <= BaselineMaxScriptLength)는 릴리스 빌드에서 no-op이므로 잘림 현상은 감지되지 않았습니다.
즉시 오프스레드 베이스라인 컴파일 경로:
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은 RetAddrEntry::pc -> JSScript::offsetToPC를 거쳐 JSJitFrameIter::baselineScriptAndPc에서 바이트코드 포인터로 다시 변환됩니다.
// 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*가 아닌 슬롯이 그런 객체로 취급되어 vtable을 통해 디스패치될 수 있으며, 이는 추가 악용의 기반이 되는 타입 혼동을 발생시킵니다.