← All reports

Streaming compiler does not reject mixed legacy and spec-correct EH

MediumJSC WebAssembly compilation pipelineLogicError

CVE: CVE-2026-43745 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: An out-of-bounds write issue was addressed with improved input validation. Credit: OpenAI Codex Security - Amy Burnett, Khai Tran

0f0de8f | Bugzilla 315365

Medium — two compilation entry points, one acceptance predicate, and only one of them evaluated it. The streaming path handed the interpreter a module class the validator exists to reject; the established outcome is a renderer crash, and anything past that rides on how far the mismatched rethrow-slot index drifts.

WebAssembly shipped exception handling twice: an original proposal that JSC still accepts for backwards compatibility, and the standardized replacement that superseded it. The two use incompatible control-frame conventions, so a module is required to pick one family and stay in it — JSC records which family a module touched in two flags on ModuleInformation and rejects modules that set both. The catch is that "did this module use both?" is a question you can only answer after every function body has been parsed, and JSC has two structurally different places where compilation reaches that point.

The angle: A page serving hand-crafted bytes over WebAssembly.compileStreaming() gets a compiled, callable module that the ordinary constructor refuses — one whose function body mixes two exception-handling conventions the interpreter's rethrow machinery was never written to see together.

Source/JavaScriptCore/wasm/WasmEntryPlan.cpp

for (uint32_t index = functionIndex; index < functionIndexEnd; ++index)
compileFunction(FunctionCodeIndex(index));
 
- if (m_moduleInformation->m_usesModernExceptions.loadRelaxed() && m_moduleInformation->m_usesLegacyExceptions.loadRelaxed()) {
+ {
Locker locker { m_lock };
- fail(makeString("Module uses both legacy exceptions and try_table"_s));
- return;
+ if (failIfMixedExceptionHandlingProposals())
+ return;
}
 
+bool EntryPlan::failIfMixedExceptionHandlingProposals()
+{
+ if (m_moduleInformation->m_usesModernExceptions.loadRelaxed()
+ && m_moduleInformation->m_usesLegacyExceptions.loadRelaxed()) {
+ fail(makeString("Module uses both legacy exceptions and try_table"_s));
+ return true;
+ }
+ return false;
+}

Source/JavaScriptCore/wasm/WasmEntryPlan.h

bool isComplete() const override { return m_state == State::Completed; }
void complete() WTF_REQUIRES_LOCK(m_lock) override;
 
+ bool failIfMixedExceptionHandlingProposals() WTF_REQUIRES_LOCK(m_lock);
+
virtual bool prepareImpl() = 0;
virtual void compileFunction(FunctionCodeIndex functionIndex) = 0;

Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp

void IPIntPlan::completeInStreaming()
{
Locker locker { m_lock };
+ if (failIfMixedExceptionHandlingProposals())
+ return;
complete();
}

JSTests/wasm/stress/streaming-compile-try-table-agreement.js

+// (module
+// (tag $e)
+// (func (export "run")
+// try
+// nop
+// catch_all
+// rethrow 0 ;; legacy EH
+// end
+// block $h
+// try_table (catch_all $h) ;; spec-correct EH
+// throw $e
+// end
+// end
+// )
+// )
+const mixedEH = new Uint8Array([
+ 0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
+ ...
+ 0x06,0x40, // try
+ 0x01, // nop
+ 0x19, // catch_all
+ 0x09,0x00, // rethrow 0
+ 0x0b,
+ 0x02,0x40, // block
+ 0x1f,0x40, 0x01, 0x02,0x00, // try_table (catch_all $h)
+ 0x08,0x00, // throw $e
+ ...
+]);
+
+async function main() {
+ for (const bytes of cases) {
+ const ns = compileNonStreaming(bytes);
+ const s = await compileStreaming(bytes);
+ if ((ns !== null) !== (s !== null))
+ throw new Error(`compile-path mismatch: non-streaming=${ns}, streaming=${s}`);
+ ...
+ }
+}

Three source changes and a test, and only one of the three is load-bearing.

In WasmEntryPlan.cpp, the rejection that previously sat inline at the tail of EntryPlan::compileFunctions() is lifted verbatim into a new method, EntryPlan::failIfMixedExceptionHandlingProposals(). The predicate itself is unchanged: read the two relaxed atomics m_usesModernExceptions and m_usesLegacyExceptions off ModuleInformation, and if both are set, call fail("Module uses both legacy exceptions and try_table") and return true. What changed at the call site is the locking shape — the old code tested the two flags before taking m_lock and only entered the locked region to record the failure; the new code takes m_lock unconditionally and evaluates the predicate inside it. That is what lets the helper be annotated WTF_REQUIRES_LOCK(m_lock) in WasmEntryPlan.h, which in turn makes the compiler's thread-safety analysis enforce that every future caller holds the lock. Extraction plus annotation, not a change in what is rejected.

The actual fix is the two-line insertion in WasmIPIntPlan.cpp. IPIntPlan::completeInStreaming() — the finalization routine the streaming compiler drives, which never executes compileFunctions()'s tail — now calls the shared helper under m_lock and returns early before complete(). Before this line existed, that function was Locker then complete(), with nothing in between.

The regression test is worth reading as an artifact in its own right. Rather than asserting "this module must throw", streaming-compile-try-table-agreement.js hand-assembles two modules — one using only try_table, one mixing legacy try/catch_all/rethrow 0 with try_table — and pushes each through both new WebAssembly.Module() and $vm.createWasmStreamingCompilerForInstantiate(). It fails if the two paths disagree on whether compilation succeeded, and, when both fail, if either produced something other than a WebAssembly.CompileError. It is an equivalence oracle, not an expectation.

The commit message also lists changes to WasmIPIntSlowPaths.cpp/.h touching JSC::IPInt::rethrowSlotForDepth — the routine that maps a control depth to the interpreter stack slot holding a caught exception. Those hunks are not part of the diff reproduced above, but their presence in the changelog tells you which downstream computation the module-level check was standing guard over.

WebAssembly exception handling, two generations. The original ("legacy") EH proposal added try (0x06), catch, catch_all (0x19), rethrow (0x09) and delegate: a try opens a block, handlers attach to it, and rethrow N re-throws the exception caught by the handler N control levels out. The standardized proposal that superseded it replaces all of that with a single instruction, try_table (0x1f), whose catch clauses branch to an enclosing label carrying the exception values as block results rather than opening a handler block. The spec defines only the latter; JSC continues to accept the former for backwards compatibility, and tracks which family a module used in ModuleInformation::m_usesLegacyExceptions and m_usesModernExceptions, two relaxed atomics set during parsing.

rethrow N and the rethrow slot. Because legacy rethrow names its target by control depth rather than by value, the runtime has to be able to walk from a depth number to the saved exception for that handler. In IPInt this is a stack slot addressed from the control depth, computed by rethrowSlotForDepth. try_table handlers maintain no such slot — their exception values travel as branch operands.

Plan objects and their states. A Plan (and its subclass EntryPlan) is the JSC object that owns one module's compilation job. It carries a state machine — Initial → Validated → Prepared → Compiled → Completed — and a lock m_lock that guards both failure recording and the transition into Completed. fail() stores an error message on the plan; that message is what surfaces to JavaScript as a WebAssembly.CompileError.

Batch versus streaming compilation. new WebAssembly.Module(bytes) is the batch path: the whole binary is available up front, so the plan runs prepare() and then compileFunctions(), which loops compileFunction() over every function index and then runs a tail that generates Wasm-to-Wasm stubs and performs final module-wide bookkeeping. WebAssembly.compileStreaming() and instantiateStreaming() take the incremental path: bytes are fed to a StreamingParser as the network delivers them, each function body is compiled the moment it is complete, and finalization runs through completeInStreaming() — a function entirely separate from compileFunctions()'s tail. Both routes converge on the same code generator and the same executable module.

IPInt. The In-Place Interpreter is JSC's baseline Wasm execution tier. It executes Wasm bytecode directly against side metadata produced by parseAndCompileMetadata, escaping to C++ slow paths in WasmIPIntSlowPaths.cpp for operations like throw and rethrow, and tiers up to BBQ/OMG when a function gets hot.

$vm.createWasmStreamingCompilerForInstantiate. A JSC test-shell hook that drives the same streaming compiler backing WebAssembly.instantiateStreaming(), letting a test feed bytes synchronously without a network fetch.

This is a validator-parity bypass: an acceptance predicate that could only be evaluated after the last function body was seen, written into one of two pipeline termini.

  module bytes
      │
      ├─ new WebAssembly.Module()          ├─ compileStreaming()
      │      prepare()                     │      StreamingParser
      │      compileFunctions()            │        └─ compileFunction()  ×N
      │        ├─ compileFunction() ×N     │           (per-function checks OK)
      │        └─ TAIL: mixed-EH check ────┤      completeInStreaming()
      │             │                      │        └─ complete()   ← no check
      │             ▼                      │             │
      │        CompileError                │             ▼
      │                                    │        State::Completed

The two columns above share compileFunction() — which is exactly why function-level validation was never at risk here. Anything decidable while looking at a single body funnels through that shared call and gets checked on both routes. The mixed-EH predicate is not decidable there: a module is only illegal once you know that some function used legacy opcodes and some function used try_table, and that knowledge is complete only at end-of-input. Deferred checks like that have to be written at a terminus, and the batch terminus is the one an author is looking at when they write the loop.

So EntryPlan::compileFunctions() grew the check at its tail, and IPIntPlan::completeInStreaming() — three lines long, take the lock, call complete() — did not. The invariant "a module that reaches State::Completed uses exactly one EH proposal" held on one entry path and was simply absent on the other.

The test module in the diff is the minimal witness. Its single function body is:

0x06,0x40,        try (void)
0x01,               nop
0x19,             catch_all
0x09,0x00,          rethrow 0     ← legacy: names handler by control depth
0x0b,             end
0x02,0x40,        block
0x1f,0x40,0x01,     try_table (catch_all → label 0)   ← spec-correct
0x08,0x00,            throw $e
0x0b,             end
0x0b,             end

Parsing that body sets m_usesLegacyExceptions (from try/catch_all/rethrow) and m_usesModernExceptions (from try_table). Handed to new WebAssembly.Module(), it walks the batch column, hits the tail check, and throws WebAssembly.CompileError. Handed to compileStreaming() — the identical bytes, the identical parser, the identical metadata generator — it walked the right-hand column and arrived at State::Completed with IPInt callees registered. The module instantiates. Its export is callable.

What that buys the attacker is a runtime state nobody wrote code for. The two EH families do not share a control-frame convention: legacy rethrow N resolves its exception through a slot addressed from control depth via rethrowSlotForDepth, while try_table handlers keep no such slot because their exception values are branch operands. When both nest inside one function, the depth→slot mapping the slow path computes no longer corresponds to the frame layout the metadata generator actually emitted — rethrow reads an interpreter stack location holding something other than a live exception. The blanket module-level rejection was the only thing keeping that computation off attacker-reachable input; there is no per-frame discriminator downstream that would catch the mismatch independently. Apple classifies the result as an out-of-bounds write addressed by improved input validation, which is consistent with a slot index derived from a control depth that the emitted frame does not have.

The fix closes the gap at the point where it opened. completeInStreaming() now evaluates the same predicate under the same lock before transitioning to Completed, so both columns of the diagram terminate through the identical acceptance decision. Reachability is ordinary web content — any page that can fetch bytes can drive compileStreaming() — and both compilation and IPInt execution live in the WebContent process, so the blast radius stops at the renderer sandbox absent a separate escape. The established outcome is an attacker-triggered crash; a stronger primitive, a type-confused exception object pulled from a mis-selected rethrow slot, would depend on rethrowSlotForDepth behaviour beyond what this diff settles.

Streaming Wasm compilation skipped the module-level mixed-EH rejection entirely, letting any page compile a function whose legacy rethrow indexes a frame slot the metadata generator never laid out.

The durable contribution here isn't the two-line insertion — it's that the predicate stopped being inline code and became failIfMixedExceptionHandlingProposals(), a named method with a WTF_REQUIRES_LOCK annotation and an obvious call site for the next module-level check somebody adds. Equally reusable is the shape of the test: instead of asserting that one path throws, it asserts that both paths agree on outcome and on error class for the same bytes. That's an equivalence oracle, and it drops straight into a differential fuzzing harness without modification.