[5] `delegate` ends a `try` without widening its result to the block signature
High, and narrow in exactly the way its sibling widening bugs are: delegate was tagged as a continuing arm rather than a merge, so the continuation value keeps the fallthrough edge's concrete type while a br_if to the same label only ever promised anyref. The elided ref.cast does the rest.
Exception handling in WebAssembly gives a try several ways to end, and only some of them continue the construct — catch and catch_all are further arms of the same try, whereas delegate terminates it and forwards any thrown exception outward to an enclosing handler. JSC's validator, Wasm::FunctionParser, distinguishes these with a tag it passes to its block-ending helper: NewSiblingBlock for arms whose values are about to be replaced anyway, and MergePoint for endings whose continuation is a real control-flow join. At a join, the type published for each result must be the block's declared result type, because the join is reached both by the fallthrough edge and by every br / br_if targeting the block's label.
The angle: a module that branches out of a try (result anyref) and then ends it with delegate gets its following ref.cast compiled to nothing, so a boxed JS number flows into struct.get's field-address computation.
Source/JavaScriptCore/wasm/WasmFunctionParser.h
Patch Details
The Delegate handler in FunctionParser::parseExpression stops calling checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock) and instead calls the new endBlockAndCheckResultTypes, the helper End also uses — so the fix is a de-duplication rather than a new check. The added comment on the helper states that it widens each result to the block signature type before ending the block, and the comment at the Delegate site records why it belongs there: delegate ends the try block, so it widens results. The regression test's comment names the hazard it guards against directly: the tiers must not elide IsCell / IsWasmGCObject checks.
A block terminator classified as a continuing sibling arm rather than a control-flow join, skipping result widening at a real merge.
Background
Where this lives. FunctionParser is the single-pass bytecode parser and validator shared by pure validation, the BBQ baseline JIT (WasmBBQJIT.cpp) and the OMG optimizing JIT (WasmOMGIRGenerator.cpp). It maintains the abstract expression stack — each entry a value plus its static Type — and the control stack of ControlEntry.
Arms versus terminators. catch and catch_all open a new arm of the same try; the block's values on the stack are about to be replaced by the arm's own, so the type recorded on the way in is discarded. delegate is different: it terminates the try and its continuation is where control resumes.
Fallthrough tags. NewSiblingBlock and MergePoint are the two tags the block-ending path takes. They select different handling of the values on the stack at that point.
Static types as check-elision inputs. The IR generators use a statically-provable subtype relation to decide that a ref.cast (ref 0) needs no runtime work at all. The types the parser records are the input to that decision.
Analysis
This is a validator type-soundness failure, exploitable downstream as type confusion, and the missing invariant is the same one as in [1]: the static type published for a value at a merge point must be the block's declared result type — an upper bound over all incoming edges — never the type contributed by one particular edge.
try (result anyref)
...
br_if 0 --- edge A: guaranteed only anyref
...
local.get 2 --- edge B (fallthrough): typed (ref 0)
delegate 0
|
+--> continuation. join of A and B is anyref.
pre-fix, NewSiblingBlock tag -> no widening
recorded type = (ref 0) <- edge B's type only
|
+--> ref.cast (ref 0)
statically provable -> no runtime work emitted
|
+--> struct.get 0 0
field address computed from edge A's value
At runtime the value arriving on the br_if edge is whatever JSValue any.convert_extern produced from the externref parameter — for a JS number, a boxed non-cell value. The subsequent struct.get 0 0 computes a field address from a value that was never a JSWebAssemblyStruct pointer.
The End handler already used the widening path, which is why the correct fix was to route Delegate through the same helper rather than to add a parallel check. That shape is also the discovery angle: comparing the terminator handlers against each other, and asking which of them classify their continuation as a join, surfaces the discrepancy without needing to execute anything.
Exploitability rests on how much control the module has over the value on the branch edge. any.convert_extern applied to a function parameter gives direct control from the JS caller, so the field-address computation runs against a bit pattern the embedder chooses. The confusion primitive is what this establishes; converting it into a read or write would additionally need control over which offsets that computed address lands on.
This vulnerability weakens the guarantee that a ref.cast means something. Once a cast can be compiled to a no-op on the strength of a type the validator recorded from one edge of a two-edge merge, the entire GC-type check surface below it inherits the gap.
Audit directions
- Terminators misclassified as arms. The reusable pattern is a control-flow construct whose ending is tagged for "the values are about to be replaced" when in fact control continues past it. Enumerate every
checkBlockFallthroughcall site inWasmFunctionParser.hand, for each, ask whether the continuation is reachable from any branch instruction. The code-review tell isNewSiblingBlockpassed at a site whose block can be abrtarget. - Duplicated block-ending logic. This fix collapsed two divergent implementations into
endBlockAndCheckResultTypes. Wherever a validator has two ways to end the same conceptual construct, the divergence is the bug surface — prefer auditing for duplicate logic over auditing each copy for correctness. - Exception-handling constructs specifically.
try,catch,catch_all,delegateandtry_tableall interact with the control stack differently, and the newest of them have had the least review. Cross-check each against the spec's rule for what its continuation's result types are.