Medium CVSS 6.5 webkit Logic Error 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentJSC
Sub-componentJSC Wasm
Bug ClassLogic Error
Tracker315365
Fix commit0f0de8f2a058 (WebKit/WebKit) +123/-3
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett, Khai Tran
Disclosed2026-06-29

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/wasm/WasmEntryPlan.cpp
modified
catch
JSTests/wasm/stress/streaming-compile-try-table-agreement.js
modified
for
JSTests/wasm/stress/streaming-compile-try-table-agreement.js
modified
if
JSTests/wasm/stress/streaming-compile-try-table-agreement.js
modified

Files Changed

  • JSTests/wasm/stress/streaming-compile-try-table-agreement.js
  • Source/JavaScriptCore/wasm/WasmEntryPlan.cpp
  • Source/JavaScriptCore/wasm/WasmEntryPlan.h
  • Source/JavaScriptCore/wasm/WasmIPIntPlan.cpp

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().
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();
 }
 
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();
+});
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+});
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.