← All reports

[5] OMG emits an integer zero into a float Wasm field

LowJSC OMG Wasm tierTypeConfusion

7842f48

Low — the payload is a zero either way, so the bits stored are correct regardless; what breaks is B3's type bookkeeping, and the release assert catches it by killing the process. Reliable and remote-triggerable, which is the only reason it isn't lower.

JSC compiles WebAssembly through several tiers, the topmost being OMG, an optimizing JIT that lowers Wasm bytecode into B3 — the mid-level IR shared with the FTL JavaScript compiler. Every value in B3 carries an explicit type, and optimization passes rely on those types matching: when a load reads back a slot that was just written, the CSE pass can replace the load with the stored value, but only if the two agree on type. Wasm GC's struct.new_default and array.new_default allocate an object and zero every field, so the IR generator must produce a zero constant for each field — carrying that field's declared type.

The angle: any page can serve a few-line Wasm module that deterministically aborts the WebContent process of every tab that loads it.

The struct.new_default wasm instruction currently writes Int32(0) for f32 types. If store-to-load forwarding occurs, B3::Value::replaceWithIdentity will fail a RELEASE_ASSERT because the expected type is f32, not i32. This patch adjusts OMGIRGenerator::addStructNewDefault and OMGIRGenerator::addArrayNewDefault to create a constant of the correct type instead of defaulting to int types.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

// addArrayNewDefault
initValue = m_currentBlock->appendNew<WasmConstRefValue>(m_proc, origin(), JSValue::encode(jsNull()));
else if (elementType.elementSize() == 16)
initValue = constant(V128, v128_t { });
- else if (elementType.elementSize() <= 4)
- initValue = constant(Int32, 0);
else
- initValue = constant(Int64, 0);
+ initValue = constant(toB3Type(elementType.unpacked()), 0);
 
// addStructNewDefault
initValue = m_currentBlock->appendNew<WasmConstRefValue>(m_proc, origin(), JSValue::encode(jsNull()));
else if (typeSizeInBytes(fieldType) == 16)
initValue = constant(V128, v128_t { });
- else if (typeSizeInBytes(fieldType) <= 4)
- initValue = constant(Int32, 0);
else
- initValue = constant(Int64, 0);
+ initValue = constant(toB3Type(fieldType.unpacked()), 0);

JSTests/wasm/stress/new_default-f32.js

+ // (type (struct (field f32))) ; struct.new_default 0; struct.get 0 0; global.set 0
+ for (let i = 0; i < wasmTestLoopCount; ++i) fn();
+ // (type (array f32)) ; array.new_default 0; array.get 0; global.set 0
+ for (let i = 0; i < wasmTestLoopCount; ++i) fn();

Both OMGIRGenerator::addArrayNewDefault and OMGIRGenerator::addStructNewDefault previously chose the B3 type of their zero-initializer from the field's byte size: two branches, size <= 4 producing constant(Int32, 0) and everything else producing constant(Int64, 0). The patch collapses each pair into a single initValue = constant(toB3Type(<wasmType>.unpacked()), 0) call, deriving the type from the declared Wasm value type instead. The preceding reference-type and 16-byte V128 branches are untouched. The regression test builds two modules — a struct with one f32 field, and an array of f32 — calls struct.new_default/array.new_default followed by an immediate read of the field or element, and runs the function in a hot loop (wasmTestLoopCount) to promote it into OMG.

Type-tagging mismatch in JIT IR construction — B3 constant produced from a byte-size heuristic instead of from the declared Wasm value type.

B3 and its type invariant. B3 is JSC's mid-level optimizing IR, shared by the FTL JavaScript JIT and the OMG Wasm JIT. Every B3 Value carries a TypeInt32, Int64, Float, Double, V128, and others — and IR-level invariants require operations to agree on operand types.

CSE and store-to-load forwarding. Common subexpression elimination and store-to-load forwarding are B3 optimizations that detect a load from an address just written and replace the load with the stored value, via Value::replaceWithIdentity. That replacement requires the source and target B3 types to be equal.

RELEASE_ASSERT. A WebKit assertion macro that fires in both debug and release builds and aborts the process when its condition is false.

OMG and the Wasm GC default constructors. OMG is JSC's optimizing Wasm JIT tier, sitting above BBQ; OMGIRGenerator walks Wasm bytecode and emits B3 IR. struct.new_default and array.new_default are Wasm GC instructions that allocate a struct or array and zero-initialize every field or element.

Wasm value types and toB3Type. Wasm value types include i32, i64, f32, f64, v128, and reference types; f32 occupies 4 bytes and f64 occupies 8. toB3Type is the helper mapping a Wasm value type to its B3 counterpart — f32 to Float, f64 to Double.

The bug is a type-tagging error at IR construction time: OMG derived the initializer's B3 type from storage size rather than from the declared type.

  Wasm decl        old size branch       B3 constant    field's B3 type
  ──────────       ───────────────       ───────────    ───────────────
  i32 (4B)         size <= 4             Int32(0)       Int32     ✓
  f32 (4B)         size <= 4             Int32(0)       Float     ✗ mismatch
  i64 (8B)         else                  Int64(0)       Int64     ✓
  f64 (8B)         else                  Int64(0)       Double    ✗ mismatch

  struct.new_default          struct.get
  ──────────────────          ──────────
  WasmStructSet(field,  ────► WasmStructGet(field) : Float
     Int32(0))                     │
                                   ▼  B3 CSE: store-to-load forwarding
                            replaceWithIdentity(Int32 → Float)
                                   └─ RELEASE_ASSERT → abort

For a 4-byte f32 field the size heuristic produced an Int32 B3 value fed into WasmStructSet/WasmArraySet even though the field's declared B3 type is Float; the else branch produced the analogous Int64-into-Double mismatch for f64. As long as nothing looked at the stored value again, the mismatch stayed latent. The moment B3's CSE spots a later load from the same struct field or array slot and tries to forward the stored value through Value::replaceWithIdentity, the type equality requirement fails — load is Float, replacement is Int32 — and the RELEASE_ASSERT fires during OMG compilation, terminating the WebContent process. That replaceWithIdentity is the specific assertion site is relayed from the commit message; B3's CSE implementation is not part of the supplied context. The fix routes the type through toB3Type(<wasmType>.unpacked()), restoring the invariant that a stored value's type matches the slot's declared type.

The regression test is effectively the PoC, and the shape is what matters:

  1. Declare a struct type with one f32 field (or an array of f32).
  2. Call struct.new_default / array.new_default.
  3. Immediately read the field back with struct.get / array.get.
  4. Store the result somewhere observable — global.set 0 in the test.
  5. Call the exported function in a tight loop until OMG promotes it.

Step 3 is the load that CSE will try to forward from step 2's store; steps 4 and 5 exist to keep the sequence alive and reach the optimizing tier. Delivered from the web this is WebAssembly.instantiate on a handful of bytes plus a loop — a reliable, any-origin renderer crash.

There is no architectural evidence in the change of a downstream memory-safety primitive. The bit patterns of Int32(0) and Float(0.0), and of Int64(0) and Double(0.0), are identical, so even if the assertion were absent the stored bytes would be correct; the harm is the IR-level type confusion itself, which the RELEASE_ASSERT catches by aborting. A type-confused B3 graph could in principle mislead a later optimization pass, but no such downstream miscompilation is visible in the patch or the surrounding code. Since this lives in the WebContent process, a crash here terminates the renderer hosting the offending tab and does not yield a sandbox escape.

The trigger shape — a tiny module with a single f32 field plus a hot loop — is exactly what a Wasm grammar fuzzer restricted to GC types produces, making fuzzing the most likely discovery path. Variant analysis is also plausible: once one B3 type-mismatch assertion crash is known, grepping OMGIRGenerator for constant(Int32, 0) and constant(Int64, 0) surfaces these two sites immediately.

This vulnerability weakens WebContent process availability. The Wasm trust boundary assumes any well-formed module, adversarial or not, compiles through OMG without aborting the renderer; before the fix, a one-line module using struct.new_default or array.new_default over an f32/f64 type reliably killed the tab.

Insight: this class recurs wherever a JIT IR generator picks IR types from byte-size or storage-size heuristics instead of the source language's declared type. The if (size == 4) Int32 else Int64 shape works silently for integer fields and breaks the moment a floating-point or vector field of matching size appears. Routing the type through a toB3Type(declaredType) helper rather than rederiving it from size is the canonical mitigation.