[3] Wasm block parameters keep the incoming type instead of the declared one
Rated High — the validator admits a value on a loop's back edge under the declared parameter type while the body was checked, and compiled, for the narrower type that entered on the fall-in edge. The first added test drives an arbitrary host value into a struct.get validated for a concrete struct; the branch-target half of the mechanism rests on spec semantics and the test's design rather than on inspected branch-typing code.
Structured control instructions in WebAssembly can take parameters, which means a block's body is entered from more than one place: the fall-in edge and every branch that targets its label. Wasm::FunctionParser, JSC's single-pass validator, checks that body once against an abstract stack of typed values, and the same pass drives the code generators for the LLInt, BBQ and OMG tiers through addBlock / addLoop / addIf / addTry. The spec's rule for entering a block is push_ctrl(op, in, out) performing push_vals(in) — push the declared parameter types — so the body is checked against the contract every entry edge is admitted under, not against whichever value happened to arrive first.
The angle: a page can enter a loop (param anyref) with a struct, get the body compiled for that concrete struct type, then deliver an arbitrary host value on the back edge into a struct.get that was validated for a real struct.
Patch Details
Every block-entry handler previously ran a local loop that checked arity and asserted isSubtype(incomingType, declaredParamType), then left the stack slot carrying the narrower incoming type — no setType anywhere. Those loops are removed and replaced with a shared projection of the declared parameter types onto the entry stack. The same replacement is applied to two fused compare-and-if fast paths, binaryCompareCase and unaryCompareCase, each of which carried its own copy of the arity-plus-subtype loop.
On the exit side the widening to the declared result type already existed but was gated on fallthrough == MergePoint, which excluded the Else, Catch, CatchAll and End-of-if fallthrough paths; those now widen as well.
Block entry recorded the incoming value's concrete type instead of the declared parameter type, specializing a multi-entry body for a single edge.
Background
Where this lives. FunctionParser in Source/JavaScriptCore/wasm/WasmFunctionParser.h is the single-pass abstract-stack typechecker that validates a function body while simultaneously driving the IR generator. Its per-slot TypedExpression::type() is both the validation state and the static type handed onward to the tiers.
Block parameters and multi-entry bodies. Since the multi-value proposal, a block can declare parameter types, popping values off the enclosing stack on entry. Loops are the interesting case: a br to a loop label re-enters the loop, so the body has as many entry edges as it has back edges plus one.
Type widening. Wasm has a subtype lattice once GC types are in play. Widening is the operation of publishing the declared supertype for a slot rather than the concrete subtype an edge delivered; the projection exists so the body is checked once against a bound that holds for all edges.
Nullability as a static property. (ref $0) and (ref null $0) are distinct types, and JSC's struct accessors treat static nullability as a compile-time input to whether a null check is emitted.
Analysis
The missing widening leaves the validator's view of a block parameter strictly narrower than the type contract the block's other entry edges are admitted under. On a block or if, which have one entry, this is non-conformance without teeth. On a loop, it is unsound: per the spec a branch to a loop label is checked against the loop's declared parameter types, while pre-fix the body was checked — and compiled — against the type of the value that came in on the fall-in edge. The branch-target typing code, unifyControl and the Br/BrIf handlers, is not part of the supplied source, so that half of the mechanism follows from spec semantics plus the added test's design.
The added loop-param-type-widening.js pins down both divergences.
- Part 1 declares
loop (param anyref)and enters it with a(ref $0)struct. Pre-fix, the entry path left the slot at(ref $0). - Under that type,
struct.get 0 0typechecks, since its operand rule requires(ref null $0)or a subtype. The test's comment states the module was accepted before the fix. - The back edge pushes
any.convert_externof anexternref— an arbitrary host value that is not a struct of type 0. - That value reaches a field access validated for a concrete struct type.
Part 2 exercises the nullability variant: the loop declares (param (ref null $0)) but is entered with a non-null (ref $0), so pre-fix the body saw the non-nullable type. The test asserts that after the fix a ref.null none delivered on the back edge produces a clean WebAssembly.RuntimeError, which implies JSC's struct accessor lowering keys its null check on the static nullability of the operand. That lowering is not in the supplied context, so the inference comes from the test's design rather than from inspected codegen.
Exploitability follows the same route as the other widening bugs in this issue: the primitive the validator hands downstream is a field access on a value whose type was never checked. Part 1's shape gives a page direct control of the value through externref, so the field-address computation runs against attacker-chosen bits. What the change establishes is the type confusion itself; building it into a read or write primitive would additionally require control over what the confused field offsets land on.
The replication of the same missing-widening loop across every block-entry handler, including the two fused compare paths, is the discovery angle here — this reads as a pattern audit over entry handlers rather than a single-site fix.
Audit directions
- Entry-side type projection versus exit-side widening. The exit path had widening but gated it on one fallthrough tag; the entry path had none at all. For every structured control instruction, check both directions independently — a construct whose
Endwidens correctly says nothing about what its entry recorded. - Copies of a validation loop inside fast paths.
binaryCompareCaseandunaryCompareCaseeach carried a private copy of the arity-plus-subtype loop because they fuse the compare with theif. The reusable pattern is a fused-opcode fast path that reimplements a slow path's validation inline; grepWasmFunctionParser.hfor arity loops outside the canonical block handlers. The code-review tell is aforloop oversignature.argumentCount()callingisSubtypewith nosetTypein the body. - Spec
push_ctrlconformance as a checklist. Where the validator's shape diverges from the spec'spush_ctrl(op, in, out)/push_vals(in)formulation, treat the divergence as the thing to justify. Every construct that pushes something other than the declaredintypes on entry is a candidate.