← All reports

[JSC] Procedure::usesSIMD() not set when OMG inlines a SIMD callee into a non-SIMD root

HighJavaScriptCore WebAssembly OMG tierMemoryCorruption

CVE: CVE-2026-64780 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: The issue was addressed with improved checks. Credit: OpenAI Codex Security - Amy Burnett

9a17cd1 | Bugzilla 316918

High. A hook named for recording state only asserted, so the top-tier wasm compiler sized a stack frame for a function it believed had no vector values while emitting 128-bit accesses into it. The bundled test corrupts live JS object references; escalation needs control over which slot the over-wide access lands on.

A compiler that inlines has to keep two things in agreement: what a compilation unit claims about itself and what it actually contains. JavaScriptCore's top-tier WebAssembly compiler decides, at the moment it creates a compilation unit, whether that unit will ever touch 128-bit vector values — a decision that feeds stack-slot sizing and callee-saved register widths much later in the backend. That decision is read from the root function's precomputed metadata, before a single opcode of the body has been parsed, which is sound exactly as long as one unit means one wasm function.

The angle: A page can make the top-tier wasm compiler reserve too little stack space for a vector value and let a 128-bit write from an attacker-chosen integer land on top of live JavaScript object references in the same frame.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

// SIMD
- void NODELETE notifyFunctionUsesSIMD() { ASSERT(m_info.usesSIMD(m_functionIndex)); }
+ bool NODELETE usesSIMD() const { return m_info.usesSIMD(m_functionIndex); }
+ void NODELETE notifyFunctionUsesSIMD()
+ {
+ ASSERT(m_info.usesSIMD(m_functionIndex));
+ m_proc.setUsesSIMD();
+ }

Source/JavaScriptCore/b3/B3Procedure.h

- void setUsessSIMD()
+ void setUsesSIMD()
{
RELEASE_ASSERT(Options::useWasmSIMD());
m_usesSIMD = true;

Source/JavaScriptCore/b3/B3Procedure.cpp

Procedure::Procedure(bool usesSIMD)
, m_heaps(makeUniqueRef<AbstractHeapRepository>())
{
if (usesSIMD)
- setUsessSIMD();
+ setUsesSIMD();
// Initialize all our fields before constructing Air::Code since
// it looks into our fields.
m_code = std::unique_ptr<Air::Code>(new Air::Code(*this));

JSTests/wasm/stress/inline-wasm-simd-into-non-simd.js

+const NUM_REFS = 49;
+// refParams/refResults = " externref" x 49, pushResults = (local.get 1..49)
+const wat = `
+(module
+ (tag $T (param i64 i64 i64 v128))
+ (func $B (param i64)
+ (throw $T (i64.const 0) (i64.const 0) (i64.const 0)
+ (i64x2.splat (local.get 0))))
+ (func $A (export "A") (param i64${refParams}) (result${refResults})
+ (try
+ (do (call $B (local.get 0)))
+ (catch_all))
+ ${pushResults}
+ )
+)`;
+const sentinel = { marker: "SENTINEL" };
+const args = [0xan];
+for (let i = 0; i < NUM_REFS; i++)
+ args.push(sentinel);
+// Tier $A up to OMG.
+for (let i = 0; i < testLoopCount; i++)
+ last = A.apply(null, args);
+for (let k = 0; k < NUM_REFS; k++) {
+ if (last[k] !== sentinel) { bad = k; break; }
+}
+if (bad >= 0) {
+ throw new Error("usesSIMD desync");
+}

Three source files change, and only one of them changes behavior.

The behavioral hunk is in WasmOMGIRGenerator.cpp. OMGIRGenerator::notifyFunctionUsesSIMD() — the callback the wasm function parser invokes the instant it decodes a SIMD opcode — previously had a body consisting of nothing but ASSERT(m_info.usesSIMD(m_functionIndex)). It checked that the function currently being parsed is marked as SIMD-using in module metadata, and then did nothing with that knowledge. The patch keeps the assertion and adds one statement: m_proc.setUsesSIMD(), propagating the fact into the in-flight B3::Procedure. A companion read accessor, bool OMGIRGenerator::usesSIMD() const, is added alongside it.

The other two hunks are a spelling repair. B3::Procedure::setUsessSIMD() — with the doubled s — is renamed to setUsesSIMD() in B3Procedure.h, body untouched (RELEASE_ASSERT(Options::useWasmSIMD()); m_usesSIMD = true;), and its one existing call site in the Procedure::Procedure(bool usesSIMD) constructor is updated to match.

The regression test builds a two-function module through wabt. The exported root $A takes one i64 plus 49 externref parameters and returns those 49 externrefs; around a call to $B it wraps a try/catch_all. $B throws a tag whose payload includes a v128 built by i64x2.splat (local.get 0) — the vector's contents come straight from $A's caller-supplied i64. The test drives $A through testLoopCount iterations to reach OMG, then checks that every returned externref still identity-compares to the sentinel object it was passed. If any does not, it throws "usesSIMD desync".

OMG. JavaScriptCore compiles WebAssembly in tiers. Functions start in a fast-to-produce lower tier and "tier up" to OMG, the top-tier optimizing compiler, once they have run enough times. OMG can inline a callee's body into the function it is compiling.

B3, Air, and the Procedure. B3 is JSC's low-level SSA intermediate representation; Air is the machine-level, register-allocated IR beneath it. A B3::Procedure is the object representing one compilation unit — it owns the value graph and an Air::Code, and it carries unit-wide properties that later backend phases read when they make layout decisions.

Procedure::usesSIMD(). A unit-wide boolean asserting that this compilation unit works with 128-bit vector values. It originates from the constructor parameter Procedure(bool usesSIMD = false), and its setter is guarded by RELEASE_ASSERT(Options::useWasmSIMD()) — a check retained in every build, unlike ASSERT, which compiles to nothing in release.

ModuleInformation::usesSIMD(functionIndex). Per-function metadata computed when the wasm module is parsed and validated, recording whether a given function body contains SIMD opcodes. It is indexed per function, not per compilation unit.

v128, vector registers, and the ARM64 ABI. wasm's SIMD value type is 128 bits — twice the width of a double. A full 128-bit register save consumes twice the stack bytes, with stricter alignment, than a scalar FP save. On ARM64 the platform ABI preserves only the low 64 bits of callee-saved vector registers, so code that keeps whole vectors alive across a call must preserve the upper halves itself.

externref. A wasm reference type holding an opaque host reference. In JSC it occupies a JSValue-sized machine word that both the garbage collector and the JS engine interpret as a live object pointer.

Wasm exception handling and stack materialization. A tag declares an exception type with a typed payload; throw raises it; try/catch_all catches it. Per the comment in WasmOMGIRGenerator.cpp, when the OMG parser encounters Try/TryTable (or a loop with OSR), it materializes the whole wasm expression stack into B3::Variables so the catch entrypoint has fixed storage to restore into. Values that are live across a try therefore acquire dedicated frame slots.

Spill slots and callee saves. Values that cannot stay in registers across calls or control-flow merges get stack slots sized according to their type. Those sizes and offsets constitute the frame layout, and both the normal return path and the exception-unwind path must agree on it.

This is a state desynchronization between a summary bit and the thing it summarizes — the Procedure reports that it handles no vector values while its own value graph contains them.

  Before:                              After:
  Procedure(m_info.usesSIMD(root))     Procedure(m_info.usesSIMD(root))
    m_usesSIMD = false ─────┐            m_usesSIMD = false
  parse root $A             │          parse root $A
  inline callee $B          │          inline callee $B
    i64x2.splat  ──►        │            i64x2.splat  ──►
      notifyFunctionUsesSIMD│              notifyFunctionUsesSIMD
        ASSERT(...)  (no-op)│                ASSERT(...)
                            │                m_proc.setUsesSIMD() ──┐
  V128 Value in graph ──────┤          V128 Value in graph          │
  backend reads usesSIMD()◄─┘          backend reads usesSIMD() ◄───┘
    == false  →  narrow slot             == true  →  128-bit slot

The left column is the bug in one picture. Procedure is constructed before any function body has been parsed, and the boolean it is handed comes from ModuleInformation::usesSIMD(functionIndex) for the root function. That derivation is correct only while a compilation unit contains exactly one wasm function. OMG's inliner is precisely the transformation that breaks the premise: it pulls callee bodies into the root's Procedure, so a root whose own metadata says usesSIMD == false can end up owning V128 B3 values contributed by an inlined callee.

The mechanism that was supposed to catch this existed and was wired up. WasmFunctionParser calls notifyFunctionUsesSIMD() the moment it decodes a SIMD opcode, regardless of which function body it is currently walking — including an inlinee's. The hook was named for recording, but its whole body was an assertion:

// Before the fix — Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
void NODELETE notifyFunctionUsesSIMD() { ASSERT(m_info.usesSIMD(m_functionIndex)); }

ASSERT compiles away in release builds. In shipping Safari this function had no body at all: it confirmed that the currently-parsed function's metadata says "SIMD", then discarded the fact rather than propagating it to m_proc. The debug build's assertion did fire correctly on the inlinee — which is exactly what made the omission comfortable to miss, since the notification path looked live.

What the stale-false flag costs shows up in the backend. Procedure::usesSIMD() and its Air::Code counterpart are what JSC consults to decide that this unit must treat the FP register bank as 128 bits wide — most consequentially for the size and alignment of vector spill slots, and for the width at which callee-saved FP registers are saved and restored. With the flag false, the stack allocator likely sizes the vector value's storage at scalar-FP width while the emitted instruction still performs a 128-bit access, so the over-wide store reaches past its slot into the neighbouring one. A second, non-exclusive candidate is a mismatch between the frame layout the OMG prologue and epilogue emitted and the layout the throw/catch-entrypoint restore path assumes for FP callee-saves. Either way, adjacent frame storage takes the overflow.

The test is engineered to make that overflow land somewhere that can be checked from JavaScript:

  1. $A is exported with one i64 and 49 externref parameters, returned as 49 externref results.
  2. Those 49 references are live across the try, so the Try handling materializes the expression stack into B3::Variables and each reference gets a dedicated frame slot.
  3. $A calls $B, which OMG inlines. $B throws a tag whose payload contains a v128 produced by i64x2.splat (local.get 0) — the vector's bits are $A's i64 parameter, chosen by the JS caller.
  4. testLoopCount iterations drive $A to OMG tier-up, where the desynchronized flag takes effect.
  5. catch_all swallows the exception and $A returns its 49 references.
  6. Any returned externref that no longer identity-compares to the sentinel is corruption — "usesSIMD desync".

That is the reachability story in full: ordinary web content, no special privileges, no cross-process boundary crossed. The corrupted words are GC-visible object references, which means the damage need not surface at the point of corruption — a later garbage collection walking a mangled reference is an equally plausible manifestation, and matches Apple's "unexpected Safari crash" framing.

The fix makes the flag monotonically widenable during IR generation rather than frozen at construction: notifyFunctionUsesSIMD() now actually records what it was named to record, so the first SIMD opcode from any body folded into the unit — root or inlinee — promotes the Procedure before the backend reads it. The setUsessSIMD → setUsesSIMD rename carries no behavior, but it is a tell worth pausing on. A typo can only survive in a public header if nearly nobody calls the function; this setter had exactly one caller, the constructor.

A release-build no-op hook let an inlined SIMD callee leave the compilation unit claiming it uses no vectors, so the backend sized a frame that a 128-bit write from an attacker-chosen i64 then overran onto live JS object references.

Two grep-able tells converge here, and both generalize past this bug. A mutator whose only call site is a constructor silently encodes the claim "this property is immutable for the object's lifetime" — and inlining is the canonical transformation that falsifies such claims, because it changes what a compilation unit contains after the unit object already exists. The second is sharper still: a method named notify/record/mark whose body mutates no state. Because ASSERT compiles out, the release build's semantics were "do nothing" while the debug build's assertion supplied false confidence that the path was wired. That is an unusual failure shape — a build-configuration-dependent divergence in which the debug binary is more correct than the shipping one.