Medium webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionThis issue was addressed through improved state management.
ComponentJSC
Bug ClassType Confusion
Tracker317450
Fix commit39a1fcf84d47 (webkit) +26/-4
CISA KEVNot listed
CreditedKun Peeks (@SwayZGl1tZyyy), OpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

WasmFunctionParser
JavaScriptCore’s single-pass validator/IR-builder for a WebAssembly function body (Source/JavaScriptCore/wasm/WasmFunctionParser.h). It walks the bytecode, maintaining a typed m_expressionStack that models the operand stack.
Structured control instruction
block/loop/if/else/try/catch. Each has a block signature declaring parameter and result types; when the construct ends, its results must appear on the stack with the declared result types.
`checkExpressionStack(controlData, shouldForceSignature)`
validates that the values left on the expression stack at a block end match the control frame’s result signature. When shouldForceSignature is true, it rewrites the static types of those stack entries to the frame’s declared result types rather than leaving them as the (possibly narrower) types of the actual fallthrough values.
Subtyping (reference types)
WebAssembly GC/reference types form a subtype lattice (e.g. a concrete struct ref is a subtype of externref/anyref). A value whose static type is a subtype may legitimately flow where the declared supertype is expected, but the block’s declared result type is what downstream code and codegen must rely on.

Root Cause Analysis

In the end-of-block handling for if/else frames, WasmFunctionParser decided whether to coerce the result stack entries to the frame’s signature result types with const bool shouldForceSignature = ControlType::isElse(data.controlData);. That is, the parser only forced the declared signature types when the frame being closed was an else block; for every other structured control instruction it passed the live fallthrough value types through unchanged. The WebAssembly specification, however, requires the output type of any structured control instruction to be the result type from its signature even when the fallthrough value is a subtype of that result type. When the fallthrough value’s static type was a strict subtype of the declared result type, the parser propagated the subtype to the enclosing expression stack instead of the declared supertype. Downstream IR generation then reasoned about the value using that narrower subtype — e.g. assuming a reference operand is a concrete GC cell when the declared result type (externref) actually permits non-cell/boxed representations — producing a type confusion between the representation the compiler assumed and the representation the value actually had at runtime.

The patch deletes the conditional and unconditionally forces the signature: WASM_FAIL_IF_HELPER_FAILS(checkExpressionStack(data.controlData, true));. Now every block end rewrites the result stack entries to the frame’s declared result types, so the enclosing stack and all later consumers see the spec-mandated supertype rather than an incidental subtype. The accompanying regression test (JSTests/wasm/regress/try-catch-result-type.js) builds a module whose main returns through a structured construct and feeds a nonCellExternref (a float bit-pattern that is a non-cell externref); under the old code the value’s narrowed type let the engine treat it as a cell, which the test guards against.

Key insight
The parser only coerced block results to their declared signature types for else frames, so for every other structured control instruction a fallthrough value kept its narrower subtype — letting later compilation treat a value (e.g. a non-cell externref) as a more specific type than the block actually produced, a type confusion.

Attack Path

  1. Delivery Attacker serves a page that instantiates a crafted WebAssembly.Module whose bytes validate but exploit the mis-typed block result (web-reachable; new WebAssembly.Instance runs the parser).
  2. Craft Author a function in which a structured control instruction (e.g. a try/catch or block with a reference result type) yields a fallthrough value whose static type is a strict subtype of the declared result type.
  3. Validate The old WasmFunctionParser accepts the module and records the narrower subtype on the enclosing expression stack instead of the signature result type.
  4. Confuse IR generation consumes the stack entry under the assumed narrower type and emits code that, for a value whose real representation differs (a non-cell externref boxed as a float), misinterprets the bits (e.g. treats a non-pointer as a GC cell).
  5. Primitive (inferred) The type confusion yields a mis-typed reference value flowing into cell-expecting code; concretely observed as a crash, with memory disclosure/corruption from pointer misinterpretation inferred but not proven by the diff.

Impact Assessment

Runs in the renderer (Safari web content) process with no special privilege — any site that can run WebAssembly reaches the parser. The substantiated primitive is a validator type confusion: a structured block’s result keeps a subtype rather than its declared signature type, so downstream codegen reasons about the wrong type. The advisory describes only an “unexpected Safari crash”; the diff substantiates a genuine type-confusion root cause, and whether the confusion is escalatable beyond a crash to a read/write primitive is inferred, not shown by the diff. Gating: requires only that the attacker can author/ship the Wasm module, which web content can.

Files Changed

  • JSTests/wasm/regress/try-catch-result-type.js
  • Source/JavaScriptCore/wasm/WasmFunctionParser.h

Audit Directions

  • Other result-type coercions
    Review every caller of checkExpressionStack and any place block/loop/try results are copied to the enclosing m_expressionStack, confirming the declared signature type (not the live value type) is propagated.
  • Subtype propagation in IR gen
    Audit where Wasm IR generation assumes a reference operand is a concrete cell vs. a boxed/non-cell externref, especially across control-flow merges and exception results.
  • `else`/`catch` asymmetry
    Look for other validator paths that special-case else (or a single frame kind) when a rule should apply to all structured control, a likely source of parallel bugs.
diff --git a/Source/JavaScriptCore/wasm/WasmFunctionParser.h b/Source/JavaScriptCore/wasm/WasmFunctionParser.h
index 7289d0863ca6..a4f1264dc6cd 100644
--- a/Source/JavaScriptCore/wasm/WasmFunctionParser.h
+++ b/Source/JavaScriptCore/wasm/WasmFunctionParser.h
@@ -3718,13 +3718,12 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
             WASM_TRY_ADD_TO_CONTEXT(addElse(data.controlData, m_expressionStack));
             m_expressionStack = WTF::move(data.elseBlockStack);
         }
-        // When ending an 'if'/'else', including a synthetic 'else' added right above,
-        // the spec requires the output type of 'if' to be the type from the signature.
-        const bool shouldForceSignature = ControlType::isElse(data.controlData);
+        // The spec requires the output type of a structured control instruction to be
+        // the result type from its signature, even when the fallthrough value is a subtype.
         // FIXME: This is a little weird in that it will modify the expressionStack for the result of the block.
         // That's a little too effectful for me but I don't have a better API right now.
         // see: https://bugs.webkit.org/show_bug.cgi?id=164353
-        WASM_FAIL_IF_HELPER_FAILS(checkExpressionStack(data.controlData, shouldForceSignature));
+        WASM_FAIL_IF_HELPER_FAILS(checkExpressionStack(data.controlData, true));
         WASM_TRY_ADD_TO_CONTEXT(endBlock(data, m_expressionStack));
         m_expressionStack.swap(data.enclosedExpressionStack);
         if (!ControlType::isTopLevel(data.controlData))
diff --git a/JSTests/wasm/regress/try-catch-result-type.js b/JSTests/wasm/regress/try-catch-result-type.js
new file mode 100644
index 000000000000..6d4c8a064ca4
--- /dev/null
+++ b/JSTests/wasm/regress/try-catch-result-type.js
@@ -0,0 +1,23 @@
+import * as assert from '../assert.js';
+
+const bytes = new Uint8Array([
+    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+    0x01, 0x8c, 0x80, 0x80, 0x80, 0x00,
+    0x03, 0x5e, 0x7e, 0x01, 0x60, 0x00, 0x00, 0x60, 0x01, 0x6f, 0x01, 0x7f,
+    0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x02,
+    0x0d, 0x83, 0x80, 0x80, 0x80, 0x00, 0x01, 0x00, 0x01,
+    0x07, 0x88, 0x80, 0x80, 0x80, 0x00, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00,
+    0x0a, 0x99, 0x80, 0x80, 0x80, 0x00, 0x01, 0x93, 0x80, 0x80, 0x80, 0x00,
+    0x00, 0x06, 0x6e, 0x20, 0x00, 0xfb, 0x1a, 0x07, 0x00,
+    0x41, 0x01, 0xfb, 0x07, 0x00, 0x0b, 0xfb, 0x14, 0x6a, 0x0b,
+]);
+
+const instance = new WebAssembly.Instance(new WebAssembly.Module(bytes));
+
+const storage = new ArrayBuffer(8);
+const view = new DataView(storage);
+view.setBigUint64(0, 0x4141414141414141n - (1n << 49n), true);
+const nonCellExternref = view.getFloat64(0, true);
+
+for (let i = 0; i < wasmTestLoopCount; ++i)
+    assert.eq(instance.exports.main(nonCellExternref), 0);
\ No newline at end of file
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/wasm/regress/try-catch-result-type.js b/JSTests/wasm/regress/try-catch-result-type.js
new file mode 100644
index 000000000000..6d4c8a064ca4
--- /dev/null
+++ b/JSTests/wasm/regress/try-catch-result-type.js
@@ -0,0 +1,23 @@
+import * as assert from '../assert.js';
+
+const bytes = new Uint8Array([
+    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+    0x01, 0x8c, 0x80, 0x80, 0x80, 0x00,
+    0x03, 0x5e, 0x7e, 0x01, 0x60, 0x00, 0x00, 0x60, 0x01, 0x6f, 0x01, 0x7f,
+    0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x02,
+    0x0d, 0x83, 0x80, 0x80, 0x80, 0x00, 0x01, 0x00, 0x01,
+    0x07, 0x88, 0x80, 0x80, 0x80, 0x00, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00,
+    0x0a, 0x99, 0x80, 0x80, 0x80, 0x00, 0x01, 0x93, 0x80, 0x80, 0x80, 0x00,
+    0x00, 0x06, 0x6e, 0x20, 0x00, 0xfb, 0x1a, 0x07, 0x00,
+    0x41, 0x01, 0xfb, 0x07, 0x00, 0x0b, 0xfb, 0x14, 0x6a, 0x0b,
+]);
+
+const instance = new WebAssembly.Instance(new WebAssembly.Module(bytes));
+
+const storage = new ArrayBuffer(8);
+const view = new DataView(storage);
+view.setBigUint64(0, 0x4141414141414141n - (1n << 49n), true);
+const nonCellExternref = view.getFloat64(0, true);
+
+for (let i = 0; i < wasmTestLoopCount; ++i)
+    assert.eq(instance.exports.main(nonCellExternref), 0);
\ No newline at end of file
Loading diff…