CVE-2026-86872
Overview
Background
- `WasmFunctionParser`
- JSC’s single-pass WebAssembly validator/parser (
Source/JavaScriptCore/wasm/WasmFunctionParser.h). It type-checks the operand (“expression”) stack as it reads each opcode. - Block signatures with parameters
block/loop/if/try/try_tablecan declare a(param ...)type. On entry, values are popped from the stack as the block’s parameters; inside the block, those values must be typed as the declared parameter types.- Subtyping and widening
- The declared parameter/result type may be a supertype of the concrete value on the stack (e.g. a concrete
(ref $0)flowing into a(param anyref)block). Per the spec’spush_ctrl/push_valssemantics, the block body must be validated against the declared type, not the narrower incoming subtype; the operand’s static type must be widened to the declared type. - `checkBlockFallthrough` (old) → `checkArgumentsAndWiden` / `checkResultsAndWiden` (new)
- The refactor splits argument-entry checking from result-exit checking and makes both perform widening.
Root Cause Analysis
On block entry, the validator checked only that each incoming operand was a subtype of the declared parameter type (WASM_VALIDATOR_FAIL_IF(!isSubtype(... argumentType(i)))) but then left the operand’s static type unchanged — it never widened the stack slot to the declared parameter type. As a result the block body was validated against the narrower concrete type that flowed in rather than the declared (wider) parameter type. Operations that are valid only on the narrower type were therefore accepted inside a block that declares a wider type. The regression tests make this precise: a block/if/try/try_table (and loop) declaring (param anyref) entered with a concrete (ref $0) on the stack, whose body does struct.get 0 0, was incorrectly accepted — even though struct.get is invalid on anyref — because the validator still saw the concrete (ref $0). (The old result-exit path, checkBlockFallthrough(..., MergePoint), already widened results via setType(expectedType), but only at merge points, and argument entry did no widening at all.)
The patch adds slot.setType(expectedType) to checkArgumentsAndWiden for every block argument, and checkResultsAndWiden unconditionally widens every result slot to the declared result type (dropping the old MergePoint-only condition). All block-entry sites (block, loop, if including the comparison-folded binaryCompareCase/unaryCompareCase fast paths, try, try_table) now route through checkArgumentsAndWiden, and all block-exit/sibling sites (else, catch, catch_all, end of if, endBlockAndCheckResultTypes) route through checkResultsAndWiden. This enforces that the body sees declared types, so an operation invalid for the declared supertype is rejected, and — as the loop test shows — a value typed at the declared nullable type keeps its null check so a null delivered on a loop back-edge traps cleanly instead of being treated as a guaranteed-non-null narrower reference.
A WebAssembly type-system soundness hole of this shape is a type-confusion vector: validating a body against a narrower type than values may actually hold at runtime means operations (field access, null-dereference) run on references whose real type/nullability does not match the compiler’s assumption. The advisory labels this merely “logic issue … unexpected behavior,” which understates what the diff shows — a validation soundness failure; the concrete memory-safety primitive is not demonstrated by the diff (inferred).
struct.get on a value declared anyref) that the declared type forbids, a WebAssembly type-soundness hole.Attack Path
- Craft a module
Attacker builds a module with a struct type and a block/loop/if/try declaring a parameter whose type is a supertype (e.g.
anyref) of the concrete reference pushed before it. - Put an invalid op in the body
The block body performs an operation valid only for the narrower concrete type (e.g.
struct.get 0 0, valid on(ref $0)but not onanyref). - Pass validation
Pre-patch, the validator checks the body against the concrete
(ref $0)and accepts the module that should have been rejected. - Supply a mismatching value at runtime Via a loop back-edge or the block’s declared-type contract, a value of the declared (wider) type or a null reaches the operation whose checks assumed the narrower type.
- Type-confused / unchecked access
struct.get(or similar) runs on a reference of unexpected type or a missing null check fires, yielding type confusion / null-dereference (escalation to a controlled memory-corruption primitive is inferred, not shown by the diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forSource/JavaScriptCore/wasm/WasmFunctionParser.h |
modified |
Files Changed
JSTests/wasm/stress/block-param-type-widening.jsJSTests/wasm/stress/loop-param-type-widening.jsSource/JavaScriptCore/wasm/WasmFunctionParser.h
Audit Directions
- Operand-stack typing after subtype checksAudit any validator path that checks
isSubtype(...)but then uses the operand’s original static type; the type must be set to the expected/declared type wherever the spec widens (push_vals). - Loop back-edge nullabilityReview loops whose declared param types are nullable references: confirm body operations keep null checks for values that can be re-delivered on the back-edge.
- Comparison-fold fast pathsThe
iffast paths inbinaryCompareCase/unaryCompareCaseduplicated the signature check; audit other duplicated validation fast paths that may have missed the widening fix. - GC/reference-type coverageRe-check
br_on_cast,select (result t), andref.*validation for the same “validated against incoming subtype, not declared type” assumption.
Patch
diff --git a/Source/JavaScriptCore/wasm/WasmFunctionParser.h b/Source/JavaScriptCore/wasm/WasmFunctionParser.h
index f0b33f5b9380..d00289163589 100644
--- a/Source/JavaScriptCore/wasm/WasmFunctionParser.h
+++ b/Source/JavaScriptCore/wasm/WasmFunctionParser.h
@@ -212,13 +212,8 @@ private:
WARN_UNUSED_RETURN PartialResult parseUnreachableExpression();
WARN_UNUSED_RETURN PartialResult unifyControl(ArgumentList&, unsigned level);
WARN_UNUSED_RETURN PartialResult checkLocalInitialized(uint32_t);
-
- enum FallThroughStateTag {
- NewSiblingBlock,
- MergePoint,
- };
-
- WARN_UNUSED_RETURN PartialResult checkBlockFallthrough(const ControlType&, FallThroughStateTag);
+ WARN_UNUSED_RETURN PartialResult checkArgumentsAndWiden(const BlockSignature&);
+ WARN_UNUSED_RETURN PartialResult checkResultsAndWiden(const BlockSignature&);
WARN_UNUSED_RETURN PartialResult endBlockAndCheckResultTypes(ControlEntry&);
enum BranchConditionalityTag {
@@ -621,10 +616,7 @@ auto FunctionParser<Context>::binaryCompareCase(OpType op, BinaryOperationHandle
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get if's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few arguments on stack for if block. If expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. If block has signature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i)
- WASM_VALIDATOR_FAIL_IF(!isSubtype(m_expressionStack[offset + i].type(), inlineSignature.m_signature->argumentType(i)), "Loop expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", m_expressionStack[i].type());
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -689,10 +681,7 @@ auto FunctionParser<Context>::unaryCompareCase(OpType op, UnaryOperationHandler
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get if's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few arguments on stack for if block. If expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. If block has signature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i)
- WASM_VALIDATOR_FAIL_IF(!isSubtype(m_expressionStack[offset + i].type(), inlineSignature.m_signature->argumentType(i)), "Loop expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", m_expressionStack[i].type());
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -1813,21 +1802,40 @@ auto FunctionParser<Context>::checkLocalInitialized(uint32_t index) -> PartialRe
}
template<typename Context>
-auto FunctionParser<Context>::checkBlockFallthrough(const ControlType& controlData, FallThroughStateTag fallthrough) -> PartialResult
+auto FunctionParser<Context>::checkArgumentsAndWiden(const BlockSignature& blockSignature) -> PartialResult
+{
+ const FunctionSignature* signature = blockSignature.m_signature;
+ const uint32_t argumentCount = signature->argumentCount();
+ WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < argumentCount, "Too few values on stack for block. Block expects "_s, argumentCount, ", but only "_s, m_expressionStack.size(), " were present. Block has signature: "_s, signature->toString());
+ const uint32_t offset = m_expressionStack.size() - argumentCount;
+ for (unsigned i = 0; i < argumentCount; ++i) {
+ auto& slot = m_expressionStack[offset + i];
+ const auto expectedType = signature->argumentType(i);
+ WASM_VALIDATOR_FAIL_IF(!isSubtype(slot.type(), expectedType), "Block expects the argument at index "_s, i, " to be "_s, expectedType, " but argument has type "_s, slot.type());
+ // Widen the operand to the block's declared parameter type, per the spec's
+ // push_ctrl(op, in, out) doing push_vals(in): the block body must be validated
+ // against its declared parameter types, not the narrower subtype that flowed in.
+ // https://webassembly.github.io/spec/core/bikeshed/#validation-of-opcode-sequences
+ slot.setType(expectedType);
+ }
+
+ return { };
+}
+
+template<typename Context>
+auto FunctionParser<Context>::checkResultsAndWiden(const BlockSignature& blockSignature) -> PartialResult
{
- auto blockSignature = controlData.signature();
const FunctionSignature* signature = blockSignature.m_signature;
WASM_VALIDATOR_FAIL_IF(signature->returnCount() != m_expressionStack.size(), " block with type: "_s, signature->toString(), " returns: "_s, signature->returnCount(), " but stack has: "_s, m_expressionStack.size(), " values"_s);
for (unsigned i = 0; i < signature->returnCount(); ++i) {
- const auto actualType = m_expressionStack[i].type();
+ auto& slot = m_expressionStack[i];
const auto expectedType = signature->returnType(i);
- WASM_VALIDATOR_FAIL_IF(!isSubtype(actualType, expectedType), "control flow returns with unexpected type. "_s, actualType, " is not a "_s, expectedType);
- // 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: We should support some sort of abstract interpretation so this can be the
- // least upper bound of the merging CFG.
- if (fallthrough == MergePoint)
- m_expressionStack[i].setType(expectedType);
+ WASM_VALIDATOR_FAIL_IF(!isSubtype(slot.type(), expectedType), "control flow returns with unexpected type. "_s, slot.type(), " is not a "_s, expectedType);
+ // Widen the operand to the block's declared result type, per the spec's
+ // end doing push_vals(frame.end_types): results leave the block as the
+ // declared type, not the narrower subtype that reached the end.
+ // https://webassembly.github.io/spec/core/bikeshed/#validation-of-opcode-sequences
+ slot.setType(expectedType);
}
return { };
@@ -1839,7 +1847,7 @@ auto FunctionParser<Context>::endBlockAndCheckResultTypes(ControlEntry& entry) -
// Widen each result to the block signature type before ending the block.
// FIXME: mutating the expression stack for the block result is effectful, but there's no
// better API yet. See https://bugs.webkit.org/show_bug.cgi?id=164353
- WASM_FAIL_IF_HELPER_FAILS(checkBlockFallthrough(entry.controlData, MergePoint));
+ WASM_FAIL_IF_HELPER_FAILS(checkResultsAndWiden(entry.controlData.signature()));
// We should avoid adding other callsites of endBlock. Since a new block is a sign of a
// merge point and it would be a security bug to fail to widen the types.
WASM_TRY_ADD_TO_CONTEXT(endBlock(entry, m_expressionStack));
@@ -3357,12 +3365,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get block's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few values on stack for block. Block expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. Block has inlineSignature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i) {
- Type type = m_expressionStack.at(offset + i).type();
- WASM_VALIDATOR_FAIL_IF(!isSubtype(type, inlineSignature.m_signature->argumentType(i)), "Block expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", type);
- }
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -3379,12 +3382,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get loop's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few values on stack for loop block. Loop expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. Loop has inlineSignature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i) {
- Type type = m_expressionStack.at(offset + i).type();
- WASM_VALIDATOR_FAIL_IF(!isSubtype(type, inlineSignature.m_signature->argumentType(i)), "Loop expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", type);
- }
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -3405,10 +3403,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
WASM_TRY_POP_EXPRESSION_STACK_INTO(condition, "if condition"_s);
WASM_VALIDATOR_FAIL_IF(!condition.type().isI32(), "if condition must be i32, got ", condition.type());
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few arguments on stack for if block. If expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. If block has signature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i)
- WASM_VALIDATOR_FAIL_IF(!isSubtype(m_expressionStack[offset + i].type(), inlineSignature.m_signature->argumentType(i)), "Loop expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", m_expressionStack[i].type());
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -3428,7 +3423,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
ControlEntry& controlEntry = m_controlStack.last();
WASM_VALIDATOR_FAIL_IF(!ControlType::isIf(controlEntry.controlData), "else block isn't associated to an if");
- WASM_FAIL_IF_HELPER_FAILS(checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock));
+ WASM_FAIL_IF_HELPER_FAILS(checkResultsAndWiden(controlEntry.controlData.signature()));
WASM_TRY_ADD_TO_CONTEXT(addElse(controlEntry.controlData, m_expressionStack));
m_expressionStack = WTF::move(controlEntry.elseBlockStack);
resetLocalInitStackToHeight(controlEntry.localInitStackHeight);
@@ -3440,10 +3435,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get try's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few arguments on stack for try block. Try expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. Try block has signature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i)
- WASM_VALIDATOR_FAIL_IF(!isSubtype(m_expressionStack[offset + i].type(), inlineSignature.m_signature->argumentType(i)), "Try expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", m_expressionStack[i].type());
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
int64_t oldSize = m_expressionStack.size();
Stack newStack;
@@ -3468,7 +3460,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
ControlEntry& controlEntry = m_controlStack.last();
WASM_VALIDATOR_FAIL_IF(!isTryOrCatch(controlEntry.controlData), "catch block isn't associated to a try");
- WASM_FAIL_IF_HELPER_FAILS(checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock));
+ WASM_FAIL_IF_HELPER_FAILS(checkResultsAndWiden(controlEntry.controlData.signature()));
ResultList results;
Stack preCatchStack;
@@ -3494,7 +3486,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
ControlEntry& controlEntry = m_controlStack.last();
WASM_VALIDATOR_FAIL_IF(!isTryOrCatch(controlEntry.controlData), "catch block isn't associated to a try");
- WASM_FAIL_IF_HELPER_FAILS(checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock));
+ WASM_FAIL_IF_HELPER_FAILS(checkResultsAndWiden(controlEntry.controlData.signature()));
ResultList results;
Stack preCatchStack;
@@ -3511,12 +3503,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
BlockSignature inlineSignature;
WASM_PARSER_FAIL_IF(!parseBlockSignatureAndNotifySIMDUseIfNeeded(inlineSignature), "can't get try_table's signature"_s);
- WASM_VALIDATOR_FAIL_IF(m_expressionStack.size() < inlineSignature.m_signature->argumentCount(), "Too few values on stack for block. Block expects ", inlineSignature.m_signature->argumentCount(), ", but only ", m_expressionStack.size(), " were present. Block has inlineSignature: ", inlineSignature.m_signature->toString());
- unsigned offset = m_expressionStack.size() - inlineSignature.m_signature->argumentCount();
- for (unsigned i = 0; i < inlineSignature.m_signature->argumentCount(); ++i) {
- Type type = m_expressionStack.at(offset + i).type();
- WASM_VALIDATOR_FAIL_IF(!isSubtype(type, inlineSignature.m_signature->argumentType(i)), "Block expects the argument at index", i, " to be ", inlineSignature.m_signature->argumentType(i), " but argument has type ", type);
- }
+ WASM_FAIL_IF_HELPER_FAILS(checkArgumentsAndWiden(inlineSignature));
uint32_t numberOfCatches;
Vector<CatchHandler> targets;
@@ -3738,7 +3725,7 @@ FOR_EACH_WASM_MEMORY_STORE_OP(CREATE_CASE)
case End: {
ControlEntry data = m_controlStack.takeLast();
if (ControlType::isIf(data.controlData)) {
- WASM_FAIL_IF_HELPER_FAILS(checkBlockFallthrough(data.controlData, NewSiblingBlock));
+ WASM_FAIL_IF_HELPER_FAILS(checkResultsAndWiden(data.controlData.signature()));
WASM_TRY_ADD_TO_CONTEXT(addElse(data.controlData, m_expressionStack));
m_expressionStack = WTF::move(data.elseBlockStack);
}
diff --git a/JSTests/wasm/stress/block-param-type-widening.js b/JSTests/wasm/stress/block-param-type-widening.js
new file mode 100644
index 000000000000..a7925c95b52f
--- /dev/null
+++ b/JSTests/wasm/stress/block-param-type-widening.js
@@ -0,0 +1,72 @@
+import * as assert from "../assert.js";
+
+// When a block/if/try/try_table declares a parameter type that is a *supertype*
+// of the value actually on the stack, the block body must be validated against
+// the DECLARED type, not the narrower concrete type that flowed in
+// Each case below is built twice with an identical body:
+// - declared type = anyref (wide): `struct.get 0 0` is invalid on anyref, so
+// the module must be REJECTED. Before widening, the body saw the concrete
+// `(ref null 0)` and this was (incorrectly) accepted.
+// - declared type = (ref null 0) (concrete): `struct.get 0 0` is valid, so the
+// module must VALIDATE. This control confirms the scaffolding is otherwise
+// well-formed, so the rejection above is due to widening alone.
+
+function uleb128(n) { const r = []; do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; r.push(b); } while (n); return r; }
+function encodeString(s) { const b = []; for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i)); return [...uleb128(b.length), ...b]; }
+function section(id, content) { return [id, ...uleb128(content.length), ...content]; }
+
+// Module layout:
+// type 0: struct { i64 mut }
+// type 1: <blockSig> (the signature of the block under test)
+// type 2: func () -> i64 (the exported function "test")
+function buildModule(blockSig, body0) {
+ const typeSection = section(1, [
+ 0x03,
+ 0x5F, 0x01, 0x7E, 0x01, // type 0: struct { i64 mut }
+ ...blockSig, // type 1
+ 0x60, 0x00, 0x01, 0x7E, // type 2: () -> i64
+ ]);
+ const funcSection = section(3, [0x01, 0x02]); // func 0 : type 2
+ const exportSection = section(7, [0x01, ...encodeString("test"), 0x00, 0x00]); // export "test" func 0
+ const codeSection = section(10, [0x01, ...uleb128(body0.length), ...body0]);
+ return new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
+ ...typeSection, ...funcSection, ...exportSection, ...codeSection]);
+}
+
+const sigParam = (t) => [0x60, 0x01, ...t, 0x01, 0x7E]; // (t) -> (i64)
+const ANYREF = [0x6E];
+const REF_NULL_0 = [0x63, 0x00]; // (ref null 0)
+
+const GET = [0xFB, 0x02, 0x00, 0x00]; // struct.get 0 0
+const REF_NULL_TYPE0 = [0xD0, 0x00]; // ref.null 0 -> (ref null 0)
+
+// name -> { sig: (refTypeBytes) -> typeEntry, body }
+const cases = {
+ // (block (param T) (result i64) (struct.get 0 0))
+ "block param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x02, 0x01, ...GET, 0x0B, 0x0B],
+ },
+ // (if (param T) (result i64) (then struct.get 0 0) (else drop i64.const 0))
+ "if param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x41, 0x01, 0x04, 0x01, ...GET, 0x05, 0x1A, 0x42, 0x00, 0x0B, 0x0B],
+ },
+ // (try (param T) (result i64) (do struct.get 0 0) (catch_all i64.const 0))
+ "try param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x06, 0x01, ...GET, 0x19, 0x42, 0x00, 0x0B, 0x0B],
+ },
+ // (try_table (param T) (result i64) (struct.get 0 0)) -- 0 catch clauses
+ "try_table param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x1F, 0x01, 0x00, ...GET, 0x0B, 0x0B],
+ },
+};
+
+for (const [name, { sig, body }] of Object.entries(cases)) {
+ assert.falsy(WebAssembly.validate(buildModule(sig(ANYREF), body)),
+ `${name}: struct.get on a widened anyref must be rejected (declared type not the concrete incoming type)`);
+ assert.truthy(WebAssembly.validate(buildModule(sig(REF_NULL_0), body)),
+ `${name}: struct.get on the concrete (ref null 0) must validate`);
+}
diff --git a/JSTests/wasm/stress/loop-param-type-widening.js b/JSTests/wasm/stress/loop-param-type-widening.js
new file mode 100644
index 000000000000..0fcedc39743e
--- /dev/null
+++ b/JSTests/wasm/stress/loop-param-type-widening.js
@@ -0,0 +1,138 @@
+function uleb128(n) {
+ const r = [];
+ do {
+ let b = n & 0x7f;
+ n >>>= 7;
+ if (n) b |= 0x80;
+ r.push(b);
+ } while (n);
+ return r;
+}
+function encodeString(s) {
+ const b = [];
+ for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i));
+ return [...uleb128(b.length), ...b];
+}
+function section(id, content) { return [id, ...uleb128(content.length), ...content]; }
+
+// --- Part 1 ---------------------------------------------------------------
+// A loop declaring `(param anyref)` entered with a `(ref $0)` on the stack must
+// typecheck its body against `anyref`, so `struct.get 0 0` at the top of the body
+// is a validation error. Previously the body was typechecked against the narrower
+// `(ref $0)` and the module was (unsoundly) accepted.
+{
+ // Type 0: struct { i64 mut }
+ // Type 1: func (anyref) -> (i64) — loop block signature
+ // Type 2: func (i32, externref, ref 0) -> (i64)
+ // Type 3: func () -> (ref 0)
+ const typeSection = section(1, [
+ 0x04,
+ 0x5F, 0x01, 0x7E, 0x01,
+ 0x60, 0x01, 0x6E, 0x01, 0x7E,
+ 0x60, 0x03, 0x7F, 0x6F, 0x64, 0x00, 0x01, 0x7E,
+ 0x60, 0x00, 0x01, 0x64, 0x00,
+ ]);
+ const funcSection = section(3, [0x02, 0x02, 0x03]);
+ const exportSection = section(7, [
+ 0x02,
+ ...encodeString("f"), 0x00, 0x00,
+ ...encodeString("make"), 0x00, 0x01,
+ ]);
+ const body0 = [
+ 0x01, 0x01, 0x7E, // 1 local: i64
+ 0x20, 0x02, // local.get 2 (ref $0)
+ 0x03, 0x01, // loop (type 1) — param anyref
+ 0xFB, 0x02, 0x00, 0x00, // struct.get 0 0 <-- must fail: anyref !<: (ref null $0)
+ 0x21, 0x03, // local.set 3
+ 0x20, 0x01, // local.get 1 (externref)
+ 0xFB, 0x1A, // any.convert_extern
+ 0x20, 0x00, // local.get 0
+ 0x0D, 0x00, // br_if 0
+ 0x1A, // drop
+ 0x20, 0x03, // local.get 3
+ 0x0B, // end loop
+ 0x0B, // end func
+ ];
+ const body1 = [0x00, 0x42, 0x00, 0xFB, 0x00, 0x00, 0x0B]; // i64.const 0; struct.new 0
+ const codeSection = section(10, [
+ 0x02,
+ ...uleb128(body0.length), ...body0,
+ ...uleb128(body1.length), ...body1,
+ ]);
+ const bin = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
+ ...typeSection, ...funcSection, ...exportSection, ...codeSection,
+ ]);
+
+ if (WebAssembly.validate(bin))
+ throw new Error("Part 1: module with struct.get on anyref loop param must not validate");
+}
+
+// --- Part 2 ---------------------------------------------------------------
+// A loop declaring `(param (ref null $0))` entered with a non-null `(ref $0)` is
+// valid, but the body must be compiled against the nullable type: struct.get must
+// emit its null check so a null delivered on the back-edge traps cleanly.
+{
+ // Type 0: struct { i64 mut }
+ // Type 1: func (ref null 0) -> (i64) — loop block signature
+ // Type 2: func (i32, ref 0) -> (i64)
+ // Type 3: func () -> (ref 0)
+ const typeSection = section(1, [
+ 0x04,
+ 0x5F, 0x01, 0x7E, 0x01,
+ 0x60, 0x01, 0x63, 0x00, 0x01, 0x7E,
+ 0x60, 0x02, 0x7F, 0x64, 0x00, 0x01, 0x7E,
+ 0x60, 0x00, 0x01, 0x64, 0x00,
+ ]);
+ const funcSection = section(3, [0x02, 0x02, 0x03]);
+ const exportSection = section(7, [
+ 0x02,
+ ...encodeString("f"), 0x00, 0x00,
+ ...encodeString("make"), 0x00, 0x01,
+ ]);
+ const body0 = [
+ 0x01, 0x01, 0x7E, // 1 local: i64
+ 0x20, 0x01, // local.get 1 (ref $0, non-null)
+ 0x03, 0x01, // loop (type 1) — param (ref null $0)
+ 0xFB, 0x02, 0x00, 0x00, // struct.get 0 0 <-- must keep null check
+ 0x21, 0x02, // local.set 2
+ 0xD0, 0x71, // ref.null none
+ 0x20, 0x00, // local.get 0
+ 0x0D, 0x00, // br_if 0 <-- back-edge with null
+ 0x1A, // drop
+ 0x20, 0x02, // local.get 2
+ 0x0B, // end loop
+ 0x0B, // end func
+ ];
+ const body1 = [0x00, 0x42, 0x2A, 0xFB, 0x00, 0x00, 0x0B]; // i64.const 42; struct.new 0
+ const codeSection = section(10, [
+ 0x02,
+ ...uleb128(body0.length), ...body0,
... (file diff truncated)
Regression Test / PoC
diff --git a/JSTests/wasm/stress/block-param-type-widening.js b/JSTests/wasm/stress/block-param-type-widening.js
new file mode 100644
index 000000000000..a7925c95b52f
--- /dev/null
+++ b/JSTests/wasm/stress/block-param-type-widening.js
@@ -0,0 +1,72 @@
+import * as assert from "../assert.js";
+
+// When a block/if/try/try_table declares a parameter type that is a *supertype*
+// of the value actually on the stack, the block body must be validated against
+// the DECLARED type, not the narrower concrete type that flowed in
+// Each case below is built twice with an identical body:
+// - declared type = anyref (wide): `struct.get 0 0` is invalid on anyref, so
+// the module must be REJECTED. Before widening, the body saw the concrete
+// `(ref null 0)` and this was (incorrectly) accepted.
+// - declared type = (ref null 0) (concrete): `struct.get 0 0` is valid, so the
+// module must VALIDATE. This control confirms the scaffolding is otherwise
+// well-formed, so the rejection above is due to widening alone.
+
+function uleb128(n) { const r = []; do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; r.push(b); } while (n); return r; }
+function encodeString(s) { const b = []; for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i)); return [...uleb128(b.length), ...b]; }
+function section(id, content) { return [id, ...uleb128(content.length), ...content]; }
+
+// Module layout:
+// type 0: struct { i64 mut }
+// type 1: <blockSig> (the signature of the block under test)
+// type 2: func () -> i64 (the exported function "test")
+function buildModule(blockSig, body0) {
+ const typeSection = section(1, [
+ 0x03,
+ 0x5F, 0x01, 0x7E, 0x01, // type 0: struct { i64 mut }
+ ...blockSig, // type 1
+ 0x60, 0x00, 0x01, 0x7E, // type 2: () -> i64
+ ]);
+ const funcSection = section(3, [0x01, 0x02]); // func 0 : type 2
+ const exportSection = section(7, [0x01, ...encodeString("test"), 0x00, 0x00]); // export "test" func 0
+ const codeSection = section(10, [0x01, ...uleb128(body0.length), ...body0]);
+ return new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
+ ...typeSection, ...funcSection, ...exportSection, ...codeSection]);
+}
+
+const sigParam = (t) => [0x60, 0x01, ...t, 0x01, 0x7E]; // (t) -> (i64)
+const ANYREF = [0x6E];
+const REF_NULL_0 = [0x63, 0x00]; // (ref null 0)
+
+const GET = [0xFB, 0x02, 0x00, 0x00]; // struct.get 0 0
+const REF_NULL_TYPE0 = [0xD0, 0x00]; // ref.null 0 -> (ref null 0)
+
+// name -> { sig: (refTypeBytes) -> typeEntry, body }
+const cases = {
+ // (block (param T) (result i64) (struct.get 0 0))
+ "block param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x02, 0x01, ...GET, 0x0B, 0x0B],
+ },
+ // (if (param T) (result i64) (then struct.get 0 0) (else drop i64.const 0))
+ "if param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x41, 0x01, 0x04, 0x01, ...GET, 0x05, 0x1A, 0x42, 0x00, 0x0B, 0x0B],
+ },
+ // (try (param T) (result i64) (do struct.get 0 0) (catch_all i64.const 0))
+ "try param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x06, 0x01, ...GET, 0x19, 0x42, 0x00, 0x0B, 0x0B],
+ },
+ // (try_table (param T) (result i64) (struct.get 0 0)) -- 0 catch clauses
+ "try_table param": {
+ sig: sigParam,
+ body: [0x00, ...REF_NULL_TYPE0, 0x1F, 0x01, 0x00, ...GET, 0x0B, 0x0B],
+ },
+};
+
+for (const [name, { sig, body }] of Object.entries(cases)) {
+ assert.falsy(WebAssembly.validate(buildModule(sig(ANYREF), body)),
+ `${name}: struct.get on a widened anyref must be rejected (declared type not the concrete incoming type)`);
+ assert.truthy(WebAssembly.validate(buildModule(sig(REF_NULL_0), body)),
+ `${name}: struct.get on the concrete (ref null 0) must validate`);
+}
diff --git a/JSTests/wasm/stress/loop-param-type-widening.js b/JSTests/wasm/stress/loop-param-type-widening.js
new file mode 100644
index 000000000000..0fcedc39743e
--- /dev/null
+++ b/JSTests/wasm/stress/loop-param-type-widening.js
@@ -0,0 +1,138 @@
+function uleb128(n) {
+ const r = [];
+ do {
+ let b = n & 0x7f;
+ n >>>= 7;
+ if (n) b |= 0x80;
+ r.push(b);
+ } while (n);
+ return r;
+}
+function encodeString(s) {
+ const b = [];
+ for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i));
+ return [...uleb128(b.length), ...b];
+}
+function section(id, content) { return [id, ...uleb128(content.length), ...content]; }
+
+// --- Part 1 ---------------------------------------------------------------
+// A loop declaring `(param anyref)` entered with a `(ref $0)` on the stack must
+// typecheck its body against `anyref`, so `struct.get 0 0` at the top of the body
+// is a validation error. Previously the body was typechecked against the narrower
+// `(ref $0)` and the module was (unsoundly) accepted.
+{
+ // Type 0: struct { i64 mut }
+ // Type 1: func (anyref) -> (i64) — loop block signature
+ // Type 2: func (i32, externref, ref 0) -> (i64)
+ // Type 3: func () -> (ref 0)
+ const typeSection = section(1, [
+ 0x04,
+ 0x5F, 0x01, 0x7E, 0x01,
+ 0x60, 0x01, 0x6E, 0x01, 0x7E,
+ 0x60, 0x03, 0x7F, 0x6F, 0x64, 0x00, 0x01, 0x7E,
+ 0x60, 0x00, 0x01, 0x64, 0x00,
+ ]);
+ const funcSection = section(3, [0x02, 0x02, 0x03]);
+ const exportSection = section(7, [
+ 0x02,
+ ...encodeString("f"), 0x00, 0x00,
+ ...encodeString("make"), 0x00, 0x01,
+ ]);
+ const body0 = [
+ 0x01, 0x01, 0x7E, // 1 local: i64
+ 0x20, 0x02, // local.get 2 (ref $0)
+ 0x03, 0x01, // loop (type 1) — param anyref
+ 0xFB, 0x02, 0x00, 0x00, // struct.get 0 0 <-- must fail: anyref !<: (ref null $0)
+ 0x21, 0x03, // local.set 3
+ 0x20, 0x01, // local.get 1 (externref)
+ 0xFB, 0x1A, // any.convert_extern
+ 0x20, 0x00, // local.get 0
+ 0x0D, 0x00, // br_if 0
+ 0x1A, // drop
+ 0x20, 0x03, // local.get 3
+ 0x0B, // end loop
+ 0x0B, // end func
+ ];
+ const body1 = [0x00, 0x42, 0x00, 0xFB, 0x00, 0x00, 0x0B]; // i64.const 0; struct.new 0
+ const codeSection = section(10, [
+ 0x02,
+ ...uleb128(body0.length), ...body0,
+ ...uleb128(body1.length), ...body1,
+ ]);
+ const bin = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
+ ...typeSection, ...funcSection, ...exportSection, ...codeSection,
+ ]);
+
+ if (WebAssembly.validate(bin))
+ throw new Error("Part 1: module with struct.get on anyref loop param must not validate");
+}
+
+// --- Part 2 ---------------------------------------------------------------
+// A loop declaring `(param (ref null $0))` entered with a non-null `(ref $0)` is
+// valid, but the body must be compiled against the nullable type: struct.get must
+// emit its null check so a null delivered on the back-edge traps cleanly.
+{
+ // Type 0: struct { i64 mut }
+ // Type 1: func (ref null 0) -> (i64) — loop block signature
+ // Type 2: func (i32, ref 0) -> (i64)
+ // Type 3: func () -> (ref 0)
+ const typeSection = section(1, [
+ 0x04,
+ 0x5F, 0x01, 0x7E, 0x01,
+ 0x60, 0x01, 0x63, 0x00, 0x01, 0x7E,
+ 0x60, 0x02, 0x7F, 0x64, 0x00, 0x01, 0x7E,
+ 0x60, 0x00, 0x01, 0x64, 0x00,
+ ]);
+ const funcSection = section(3, [0x02, 0x02, 0x03]);
+ const exportSection = section(7, [
+ 0x02,
+ ...encodeString("f"), 0x00, 0x00,
+ ...encodeString("make"), 0x00, 0x01,
+ ]);
+ const body0 = [
+ 0x01, 0x01, 0x7E, // 1 local: i64
+ 0x20, 0x01, // local.get 1 (ref $0, non-null)
+ 0x03, 0x01, // loop (type 1) — param (ref null $0)
+ 0xFB, 0x02, 0x00, 0x00, // struct.get 0 0 <-- must keep null check
+ 0x21, 0x02, // local.set 2
+ 0xD0, 0x71, // ref.null none
+ 0x20, 0x00, // local.get 0
+ 0x0D, 0x00, // br_if 0 <-- back-edge with null
+ 0x1A, // drop
+ 0x20, 0x02, // local.get 2
+ 0x0B, // end loop
+ 0x0B, // end func
+ ];
+ const body1 = [0x00, 0x42, 0x2A, 0xFB, 0x00, 0x00, 0x0B]; // i64.const 42; struct.new 0
+ const codeSection = section(10, [
+ 0x02,
+ ...uleb128(body0.length), ...body0,
+ ...uleb128(body1.length), ...body1,
+ ]);
+ const bin = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
+ ...typeSection, ...funcSection, ...exportSection, ...codeSection,
+ ]);
+
+ if (!WebAssembly.validate(bin))
+ throw new Error("Part 2: module must validate");
+ const inst = new WebAssembly.Instance(new WebAssembly.Module(bin));
+ const s = inst.exports.make();
+
+ for (let i = 0; i < wasmTestLoopCount; i++) {
+ if (inst.exports.f(0, s) !== 42n)
+ throw new Error("Part 2: expected 42");
+ }
+
+ let trapped = false;
+ try {
+ inst.exports.f(1, s);
+ } catch (e) {
+ if (!(e instanceof WebAssembly.RuntimeError))
+ throw new Error("Part 2: expected WebAssembly.RuntimeError, got " + e);
+ trapped = true;
+ }
+ if (!trapped)
+ throw new Error("Part 2: expected null-dereference trap on back-edge");
+}