CVE-2026-43745
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: OpenAI Codex Security - Amy Burnett, Khai Tran
Key insight. A validation invariant (reject modules mixing legacy exceptions and try_table) was enforced only on the non-streaming compile path, so streaming compilation accepted modules the engine otherwise forbids — a compile-path parity gap.
Patch Details
diff --git a/JSTests/wasm/stress/streaming-compile-try-table-agreement.js b/JSTests/wasm/stress/streaming-compile-try-table-agreement.js
new file mode 100644
index 000000000000..942083a56858
--- /dev/null
+++ b/JSTests/wasm/stress/streaming-compile-try-table-agreement.js
@@ -0,0 +1,106 @@
+// Streaming and non-streaming WebAssembly compilation should agree on the
+// outcome of a given module: both succeed, or both fail with
+// WebAssembly.CompileError. This test pins that equivalence for a couple of
+// modules that exercise try_table together with other exception handling
+// constructs.
+
+// (module
+// (tag $e)
+// (func (export "run")
+// block $h
+// try_table (catch_all $h)
+// throw $e
+// end
+// end
+// )
+// )
+const tryTableOnly = new Uint8Array([
+ 0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
+ 0x01,0x04, 0x01, 0x60,0x00,0x00,
+ 0x03,0x02, 0x01, 0x00,
+ 0x0d,0x03, 0x01, 0x00,0x00,
+ 0x07,0x07, 0x01, 0x03,0x72,0x75,0x6e, 0x00,0x00,
+ 0x0a,0x0f, 0x01, 0x0d,
+ 0x00,
+ 0x02,0x40,
+ 0x1f,0x40, 0x01, 0x02,0x00,
+ 0x08,0x00,
+ 0x0b,
+ 0x0b,
+ 0x0b,
+]);
+
+// (module
+// (tag $e)
+// (func (export "run")
+// try
+// nop
+// catch_all
+// rethrow 0
+// end
+// block $h
+// try_table (catch_all $h)
+// throw $e
+// end
+// end
+// )
+// )
+const mixedEH = new Uint8Array([
+ 0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
+ 0x01,0x04, 0x01, 0x60,0x00,0x00,
+ 0x03,0x02, 0x01, 0x00,
+ 0x0d,0x03, 0x01, 0x00,0x00,
+ 0x07,0x07, 0x01, 0x03,0x72,0x75,0x6e, 0x00,0x00,
+ 0x0a,0x16, 0x01, 0x14,
+ 0x00,
+ 0x06,0x40,
+ 0x01,
+ 0x19,
+ 0x09,0x00,
+ 0x0b,
+ 0x02,0x40,
+ 0x1f,0x40, 0x01, 0x02,0x00,
+ 0x08,0x00,
+ 0x0b,
+ 0x0b,
+ 0x0b,
+]);
+
+const cases = [tryTableOnly, mixedEH];
+
+function compileNonStreaming(bytes) {
+ try { new WebAssembly.Module(bytes); return null; }
+ catch (e) { return e; }
+}
+
+async function compileStreaming(bytes) {
+ try {
+ await $vm.createWasmStreamingCompilerForInstantiate(
+ (compiler) => { compiler.addBytes(bytes); });
+ return null;
+ } catch (e) { return e; }
+}
+
+async function main() {
+ for (const bytes of cases) {
+ const ns = compileNonStreaming(bytes);
+ const s = await compileStreaming(bytes);
+
+ const nsFailed = ns !== null;
+ const sFailed = s !== null;
+ if (nsFailed !== sFailed)
+ throw new Error(`compile-path mismatch: non-streaming=${ns}, streaming=${s}`);
+ if (nsFailed) {
+ if (!(ns instanceof WebAssembly.CompileError))
+ throw new Error("non-streaming threw non-CompileError: " + ns);
+ if (!(s instanceof WebAssembly.CompileError))
+ throw new Error("streaming threw non-CompileError: " + s);
+ }
+ }
+}
+
+main().catch(e => {
+ print(String(e));
+ print(String(e.stack));
+ $vm.abort();
+});
diff --git a/Source/JavaScriptCore/wasm/WasmEntryPlan.cpp b/Source/JavaScriptCore/wasm/WasmEntryPlan.cpp
index b6a0ddddbeba..abe34d949a22 100644
--- a/Source/JavaScriptCore/wasm/WasmEntryPlan.cpp
+++ b/Source/JavaScriptCore/wasm/WasmEntryPlan.cpp
@@ -229,10 +229,10 @@ void EntryPlan::compileFunctions()
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;
}
if (!areWasmToWasmStubsCompiled) {
@@ -267,6 +267,16 @@ void EntryPlan::complete()
}
}
+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;
+}
+
bool EntryPlan::completeSyncIfPossible()
{
Locker locker { m_lock };
diff --git a/Source/JavaScriptCore/wasm/WasmEntryPlan.h b/Source/JavaScriptCore/wasm/WasmEntryPlan.h
index d5b458d96fcc..c588ef36cdcb 100644
--- a/Source/JavaScriptCore/wasm/WasmEntryPlan.h
+++ b/Source/JavaScriptCore/wasm/WasmEntryPlan.h
@@ -123,6 +123,8 @@ protected:
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;
virtual void didCompleteCompilation() WTF_REQUIRES_LOCK(m_lock) = 0;
diff --git a/Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp b/Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp
index a11bfd15c004..b085b37a3d12 100644
--- a/Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp
+++ b/Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp
@@ -166,6 +166,8 @@ void IPIntPlan::didCompleteCompilation()
void IPIntPlan::completeInStreaming()
{
Locker locker { m_lock };
+ if (failIfMixedExceptionHandlingProposals())
+ return;
complete();
}
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-43745 |
| Bugzilla | 315365 |
| Component | JSC |
| Bug Class | LogicError |
| Severity | medium |
| Commit | 0f0de8f2a0582988… |
| Advisory | Apple Advisory |
Terminology
- Legacy vs modern wasm exceptions — The original exception-handling proposal (try/catch/rethrow) and the newer try_table construct; a module must not mix them.
- Streaming vs non-streaming compilation — Wasm can be compiled from a full buffer (WebAssembly.Module) or incrementally as bytes arrive (instantiateStreaming); both must reach the same accept/reject decision.
- EntryPlan / IPIntPlan — JSC compilation plans that drive wasm function compilation; IPIntPlan handles the in-place interpreter/streaming completion.
- Validation parity — The security-relevant property that all compilation entry points enforce identical validation, so no path accepts a module another rejects.
Root Cause Analysis
WebAssembly modules may not mix the legacy exception-handling proposal (try/catch, rethrow) with the modern try_table proposal in the same module; JSC rejects such modules with a CompileError. That guard was implemented only in the non-streaming compilation path: EntryPlan::compileFunctions() checked m_moduleInformation->m_usesModernExceptions && m_usesLegacyExceptions and called fail(...). The streaming compilation path (IPIntPlan::completeInStreaming()) had no equivalent check, so streaming and non-streaming compilation disagreed on the same bytes: a module mixing both EH schemes was rejected when compiled normally but accepted when compiled via the streaming API. Accepting such a module runs it through exception-handling codegen/metadata that assumes a single EH scheme, violating an invariant the rest of the EH machinery relies on. The fix factors the check into EntryPlan::failIfMixedExceptionHandlingProposals() (guarded by m_lock) and calls it from both the non-streaming path and IPIntPlan::completeInStreaming(), restoring the invariant that streaming and non-streaming compilation agree — both fail with a CompileError — for a module that mixes legacy exceptions and try_table. The added JSTests pin that streaming/non-streaming outcomes match for try_table-only and mixed-EH modules. That downstream EH handling of a wrongly-accepted mixed module is unsafe is an inference; the patch establishes the validation-parity gap and closes it.
Attack Path
1. Craft a mixed-EH module
Build a wasm module that uses both a legacy try/catch (or rethrow) and a modern try_table in its functions.
2. Compile via the streaming API
Instantiate it through streaming compilation (WebAssembly.instantiateStreaming / the streaming compiler), whose completion path lacked the mixed-EH guard.
3. Bypass validation
Streaming accepts the module that non-streaming would reject with a CompileError, so it is compiled and instantiable.
4. Exercise inconsistent EH codegen
Running the module drives exception-handling code paths that assume a single EH scheme (inference), risking mis-compiled EH and a crash/memory unsafety in WebContent.
Impact Assessment
The patch establishes a validation-parity gap: streaming compilation failed to reject modules that mix legacy and modern exception handling, which non-streaming compilation rejects. Running a wrongly-accepted mixed-EH module exercises exception-handling codegen that assumes a single scheme, which is a plausible route to memory unsafety (inference) rather than a demonstrated primitive; the concrete, patch-supported effect is inconsistent accept/reject behavior. Confined to WebContent (the wasm compiler). Rated medium.
Audit Directions
Other single-path validation
Diff EntryPlan/BBQ/OMG/IPInt streaming vs non-streaming completion for other checks present in one path only; grep for fail(makeString( guards not mirrored in completeInStreaming.
Proposal-mixing guards
Audit uses of m_usesModernExceptions/m_usesLegacyExceptions and other ‘usesX’ module flags to ensure every consumer enforces the same constraints.
Streaming completion paths
Review all *Plan::completeInStreaming()/completeSyncIfPossible() to confirm they run the full set of module-level validations before complete().
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
EntryPlan::failIfMixedExceptionHandlingProposals |
Source/JavaScriptCore/wasm/WasmEntryPlan.cpp |
added | Extracts the ‘uses both legacy exceptions and try_table’ check into one lock-guarded helper that fails the plan. |
EntryPlan::compileFunctions |
Source/JavaScriptCore/wasm/WasmEntryPlan.cpp |
modified | Now calls failIfMixedExceptionHandlingProposals() instead of an inline check. |
IPIntPlan::completeInStreaming |
Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp |
modified | Adds the previously-missing failIfMixedExceptionHandlingProposals() call so streaming rejects mixed-EH modules too. |
EntryPlan::failIfMixedExceptionHandlingProposals (decl) |
Source/JavaScriptCore/wasm/WasmEntryPlan.h |
modified | Declares the helper WTF_REQUIRES_LOCK(m_lock). |
Files Changed
Source Files
Source/JavaScriptCore/wasm/WasmEntryPlan.cppSource/JavaScriptCore/wasm/WasmEntryPlan.hSource/JavaScriptCore/wasm/WasmIPIntPlan.cpp
Test Files
JSTests/wasm/stress/streaming-compile-try-table-agreement.js
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/wasm/stress/streaming-compile-try-table-agreement.js b/JSTests/wasm/stress/streaming-compile-try-table-agreement.js
new file mode 100644
index 000000000000..942083a56858
--- /dev/null
+++ b/JSTests/wasm/stress/streaming-compile-try-table-agreement.js
@@ -0,0 +1,106 @@
+// Streaming and non-streaming WebAssembly compilation should agree on the
+// outcome of a given module: both succeed, or both fail with
+// WebAssembly.CompileError. This test pins that equivalence for a couple of
+// modules that exercise try_table together with other exception handling
+// constructs.
+
+// (module
+// (tag $e)
+// (func (export "run")
+// block $h
+// try_table (catch_all $h)
+// throw $e
+// end
+// end
+// )
+// )
+const tryTableOnly = new Uint8Array([
+ 0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
+ 0x01,0x04, 0x01, 0x60,0x00,0x00,
+ 0x03,0x02, 0x01, 0x00,
+ 0x0d,0x03, 0x01, 0x00,0x00,
+ 0x07,0x07, 0x01, 0x03,0x72,0x75,0x6e, 0x00,0x00,
+ 0x0a,0x0f, 0x01, 0x0d,
+ 0x00,
+ 0x02,0x40,
+ 0x1f,0x40, 0x01, 0x02,0x00,
+ 0x08,0x00,
+ 0x0b,
+ 0x0b,
+ 0x0b,
+]);
+
+// (module
+// (tag $e)
+// (func (export "run")
+// try
+// nop
+// catch_all
+// rethrow 0
+// end
+// block $h
+// try_table (catch_all $h)
+// throw $e
+// end
+// end
+// )
+// )
+const mixedEH = new Uint8Array([
+ 0x00,0x61,0x73,0x6d, 0x01,0x00,0x00,0x00,
+ 0x01,0x04, 0x01, 0x60,0x00,0x00,
+ 0x03,0x02, 0x01, 0x00,
+ 0x0d,0x03, 0x01, 0x00,0x00,
+ 0x07,0x07, 0x01, 0x03,0x72,0x75,0x6e, 0x00,0x00,
+ 0x0a,0x16, 0x01, 0x14,
+ 0x00,
+ 0x06,0x40,
+ 0x01,
+ 0x19,
+ 0x09,0x00,
+ 0x0b,
+ 0x02,0x40,
+ 0x1f,0x40, 0x01, 0x02,0x00,
+ 0x08,0x00,
+ 0x0b,
+ 0x0b,
+ 0x0b,
+]);
+
+const cases = [tryTableOnly, mixedEH];
+
+function compileNonStreaming(bytes) {
+ try { new WebAssembly.Module(bytes); return null; }
+ catch (e) { return e; }
+}
+
+async function compileStreaming(bytes) {
+ try {
+ await $vm.createWasmStreamingCompilerForInstantiate(
+ (compiler) => { compiler.addBytes(bytes); });
+ return null;
+ } catch (e) { return e; }
+}
+
+async function main() {
+ for (const bytes of cases) {
+ const ns = compileNonStreaming(bytes);
+ const s = await compileStreaming(bytes);
+
+ const nsFailed = ns !== null;
+ const sFailed = s !== null;
+ if (nsFailed !== sFailed)
+ throw new Error(`compile-path mismatch: non-streaming=${ns}, streaming=${s}`);
+ if (nsFailed) {
+ if (!(ns instanceof WebAssembly.CompileError))
+ throw new Error("non-streaming threw non-CompileError: " + ns);
+ if (!(s instanceof WebAssembly.CompileError))
+ throw new Error("streaming threw non-CompileError: " + s);
+ }
+ }
+}
+
+main().catch(e => {
+ print(String(e));
+ print(String(e.stack));
+ $vm.abort();
+});