← All reports

[6] `try`/`catch` publishes the catch arm's type as the block result

HighJSC WebAssembly front-endTypeConfusion

The catch arm never ran, but it decided what type the block returned.

8f229fb

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.

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.

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.

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:

  1. view.setBigUint64(0, 0x4141414141414141n - (1n << 49n)) writes a pattern chosen so that JSC's double-encode offset of 2^49 cancels out.
  2. getFloat64 reads it back as a double whose boxed JSValue representation is exactly 0x4141414141414141.
  3. That value is passed in as the externref parameter and internalized by any.convert_extern.
  4. 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.