[6] `try`/`catch` publishes the catch arm's type as the block result
The catch arm never ran, but it decided what type the block returned.
High. Normalization to the declared signature ran only for else blocks, so a try (result anyref) whose catch arm pushes a concrete array reference hands that concrete type downstream — while at runtime the value that actually flows out is the try arm's caller-supplied externref. The test's JS driver makes that value a chosen 64-bit non-cell pattern.
Wasm's value types were, until GC types arrived, close enough to a flat set that "the type this arm actually produced" and "the type the block declared" nearly always coincided. Wasm::FunctionParser's end handler carried that history forward through a shouldForceSignature flag, which controlled whether the outgoing block-result types were normalized to the block's declared signature. The invariant it is supposed to uphold is that the static type carried out of a control-flow merge is the declared join type, because those static types are precisely what the BBQ and OMG generators consult when deciding how much runtime checking a reference operation needs.
The angle: a module can make the optimized tiers believe a register holds a non-null array of a declared type while it actually holds a fully attacker-chosen 64-bit non-cell value.
Patch Details
shouldForceSignature was computed as ControlType::isElse(...), restricting the normalization to else blocks. The patch generalizes it so every structured control instruction's end normalizes its outgoing results to the block's declared signature, rather than leaving whatever static Type the last-parsed arm produced on the expression stack.
Result-type normalization at a control-flow merge special-cased to one construct, leaving every other block's result typed by its last-parsed arm.
Background
Where this lives. FunctionParser in Source/JavaScriptCore/wasm/WasmFunctionParser.h is templated over the compilation context and shared by all wasm tiers. It owns the control stack and the typed expression stack.
TypedExpression and the tiers. Each stack entry pairs a static Type with a backend handle. WasmBBQJIT.cpp and WasmOMGIRGenerator.cpp read that Type while emitting code.
Subtyping and validation. Wasm validation requires each arm's contributed value to be a subtype of the declared result. Subtyping is a one-directional relation: passing the check says nothing about which of the two types should be recorded.
Why else was special-cased. Before Wasm GC there was effectively no subtype lattice among value types, so the discrepancy between an arm's actual type and the declared result type was visible in practice only for if/else, and that one construct got the flag.
JSC's double encoding. JSC boxes doubles with an offset of 2^49 added to the raw bits, so a chosen JSValue bit pattern can be produced from JS by subtracting that offset before writing the double.
Analysis
This is a type confusion arising from a static-type merge error, and the test module makes the divergence between static and dynamic type maximally sharp.
try (result anyref) catch
------------------- -----
any.convert_extern(local.get 0) array.new_default $0, len 1
-> caller's externref, internalized -> concrete (ref $0)
to an `any` reference (array (mut i64))
| |
| runtime: no exception thrown |
| so THIS value flows out parser's live stack
| slice at `end` is
v the CATCH arm's
value out of block = attacker externref -> recorded (ref $0)
|
v
ref.test (ref array)
static operand (ref $0) is a strict subtype of (ref array)
-> premise trivially satisfied; runtime boxed-non-cell test
can be folded away
The two halves come apart because the parser's live expression-stack slice at end is the catch arm's, while the value that actually flows out at runtime is the try arm's — no exception is thrown in the test.
The JS driver then makes the runtime value as hostile as the encoding allows:
view.setBigUint64(0, 0x4141414141414141n - (1n << 49n))writes a pattern chosen so that JSC's double-encode offset of 2^49 cancels out.getFloat64reads it back as a double whose boxedJSValuerepresentation is exactly0x4141414141414141.- That value is passed in as the
externrefparameter and internalized byany.convert_extern. - The compiler believes it holds a non-null array of type
$0; the register holds a fully attacker-chosen 64-bit non-cell pattern.
The regression test asserts the answer is 0. The supplied material contains no pre-patch output, so the exact pre-fix observable — a wrong non-zero answer versus a fault while reading type metadata off the forged pointer — follows from the fix's shape rather than from an observed run.
Exploitability is the same class as the other validator-widening bugs in this issue, with one sharpening: because the confused value is delivered through a JS-visible parameter rather than synthesized inside the module, the attacker chooses the 64-bit pattern exactly. What the change establishes is the confusion between a chosen non-cell value and a typed GC reference; escalation past that would require the confused pointer to be dereferenced at controlled offsets, which this test does not itself arrange.
This vulnerability weakens the type discipline that lets the tiers skip runtime reference checks at all — the assumption that a validator-recorded reference type bounds the runtime values that can reach the operation.
Audit directions
- Normalization gated on one construct. The pattern is a correctness step applied under a predicate that names a specific control type rather than the situation the step exists for. Search
WasmFunctionParser.hfor flags computed fromControlType::isX(...); each one is asserting that the property only matters for construct X, and that assertion is the thing to check. The code-review tell is a boolean derived from a control-type predicate feeding a type-normalization path. - Pre-GC assumptions that survived GC types. Anywhere the validator's logic is only sound when the subtype lattice is flat, GC types have invalidated it. Enumerate the places where "the arm's actual type" and "the declared type" are used interchangeably.
- Arms that do not execute versus arms that were parsed last. The parser's live stack slice at
endreflects parse order, not runtime control flow. Any decision made from the live slice at a construct with multiple arms should be checked against which arm can actually reach the continuation.