← All reports

[1] Wasm validator omits result widening on the unreachable `End` path

CriticalJSC WebAssembly front-endTypeConfusion

A prior fix taught one `End` handler to widen. The parser has two.

36058b8

Critical. A narrow type survives a two-predecessor merge, and BBQ reads that type as proof that ref.cast needs no work — so an arbitrary externref reaches struct.get's fast path as if it were a typed GC object. Nothing gates it behind heap grooming; a plain module is enough.

A WebAssembly module is typechecked once, at validation time, and every JIT tier below that point treats the recorded types as proven facts rather than re-deriving them. The single-pass validator, Wasm::FunctionParser, walks the bytecode maintaining an abstract value stack whose entries pair a backend value handle with a static Type, and hands those types straight to the code generators. Where two control-flow edges join, the type published for the merged value is supposed to be the block's declared result type — an upper bound over every incoming edge, not the contribution of any one of them.

The angle: any page that can instantiate a WebAssembly module can hand struct.get a fully attacker-chosen 64-bit value that the compiler believes is a typed GC object, because the cast that would have checked it was compiled away.

Source/JavaScriptCore/wasm/WasmFunctionParser.h

- checkExpressionStack(data.controlData);
+ checkBlockFallthrough(data.controlData, MergePoint);

Two things happen. First a mechanical clarification: checkExpressionStack(const ControlType&, bool forceSignature = false) becomes checkBlockFallthrough(const ControlType&, FallThroughStateTag), the defaulted boolean replaced by a two-valued enum — NewSiblingBlock for arms that hand values to a sibling whose own end does the widening (Else, Catch, CatchAll, Delegate, and the if-arm check inside the reachable End), MergePoint for the reachable End merge, which widens. The helper's body is otherwise unchanged: it still fails validation on !isSubtype(actualType, expectedType) and now calls setType(expectedType) when the tag is MergePoint.

Second, the fix itself. FunctionParser::End exists twice: once in the main opcode switch, and once inside parseUnreachableExpression(), the path taken when a block's tail is statically dead. Both synthesize an else arm for an if that has none, forwarding the if's saved parameters as the block's results. The unreachable copy — the path that calls addElseToUnreachable() and reinstalls data.elseBlockStack — changes from checkExpressionStack(data.controlData), with widening off by default, to checkBlockFallthrough(data.controlData, MergePoint), with widening on. That brings the two copies back into agreement.

Type widening omitted on one of two physically separate copies of a control-flow merge handler, leaving a merged value typed by a single predecessor.

Where this lives. Wasm::FunctionParser is the single shared bytecode parser and validator that both validates a module and drives every compilation tier — IPInt/LLInt, BBQ, and OMG. There is no second typechecking pass below it.

The typed expression stack. The parser models the wasm operand stack as TypedExpression entries, each pairing a static Type with a backend value handle. The Type is not diagnostic bookkeeping; it is the type fact each generator consults when deciding whether a runtime check is necessary.

Structured control and if without else. An if with a block signature but no else arm is legal only when the parameters and results line up, and the validator synthesizes the missing arm by forwarding the saved parameter values as results.

checkBlockFallthrough and the fallthrough tag. The helper walks the fallthrough values against blockSignature.returnType(i) and verifies isSubtype(actualType, expectedType). Its second argument is a FallThroughStateTag: NewSiblingBlock marks an arm whose values a sibling construct will end up widening, while MergePoint marks a genuine control-flow join, and only that tag additionally rewrites the recorded type through setType(expectedType).

Reachable versus unreachable parsing. Once code becomes statically unreachable, the parser switches into parseUnreachableExpression(), a reduced decoder that still has to track the control stack so it can find the matching End. That is why a second, physically separate End implementation exists at all.

The bug is a type-lattice unsoundness: the parser records a static type strictly narrower than the set of values that can reach the program point, and the tiers below treat that record as a proof.

  if (param (ref 0)) (result anyref)
  ------------------------------------------------------
   then-arm ends in `br 0`         synthesized else arm
     any.convert_extern              forwarded param
     (arbitrary JS value)            typed (ref 0)
     checked vs anyref  OK           checked vs anyref  OK
            |                               |
            +---------------+---------------+
                            v
                 merge, declared result anyref
    reachable End   : setType(anyref)  -> join type recorded
    unreachable End : type stays (ref 0) -> one edge's type
                            v
                 ref.cast (ref 0) sees static type == target
                 BBQ emitRefTestOrCast elides IsCell /
                 IsWasmGCObject -> cast becomes a no-op
                            v
                 struct.get 0 0 runs its fast path

Both edges pass the subtype check, because (ref 0) genuinely is a subtype of anyref. What differs is what gets written back. Without the setType widening, the merged value leaves the block on the parent stack typed (ref 0) even though the join of the two edges is anyref. The very next instruction in the regression test, ref.cast (ref 0), then observes a static operand type already equal to its own target. The comment in the added test states that BBQ's emitRefTestOrCast trusts that stale narrow type and elides the IsCell / IsWasmGCObject runtime checks, at which point the cast is a no-op for a value that at runtime may be neither a cell nor a wasm GC object.

The value on the br edge is under full script control: the test converts an externref holding an arbitrary JS value with any.convert_extern and branches with it. So the fast-path struct.get 0 0 that follows computes a field address from whatever bit pattern the embedder handed in.

The commit message frames this explicitly as an incomplete-fix variant. A prior branch fix, 305413.1013@safari-7624.5-branch, introduced the widening at the reachable merge; the unreachable End handler — a separate copy of the same logic living in a different function — was missed. That is the discovery angle in the open: the way to find this bug was to ask which other code paths implement End, not to fuzz.

This vulnerability weakens the boundary that makes wasm safe to run at all — the guarantee that the validator's type facts bound what a compiled reference operation can be handed. With that guarantee broken, script-controlled bits reach a field-address computation with no runtime check between them.