[JSC] Procedure::usesSIMD() not set when OMG inlines a SIMD callee into a non-SIMD root
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
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
Source/JavaScriptCore/b3/B3Procedure.h
Source/JavaScriptCore/b3/B3Procedure.cpp
JSTests/wasm/stress/inline-wasm-simd-into-non-simd.js
Patch Details
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".
Background
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.
Analysis
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:
$Ais exported with onei64and 49externrefparameters, returned as 49externrefresults.- Those 49 references are live across the
try, so theTryhandling materializes the expression stack intoB3::Variables and each reference gets a dedicated frame slot. $Acalls$B, which OMG inlines.$Bthrows a tag whose payload contains av128produced byi64x2.splat (local.get 0)— the vector's bits are$A'si64parameter, chosen by the JS caller.testLoopCountiterations drive$Ato OMG tier-up, where the desynchronized flag takes effect.catch_allswallows the exception and$Areturns its 49 references.- Any returned
externrefthat 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.
Insight
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.
Audit directions
-
Unit-wide capability flags snapshotted before the unit's contents are final. The invariant: any property of a compilation unit derived before its contents are final must be monotonically widenable while those contents grow. Narrow — enumerate every read of
m_info.per-function metadata inWasmOMGIRGenerator.cppandWasmBBQJIT.cppand check whether the function index in play is the root's or the currently-parsed inlinee's; start at theProcedure(bool usesSIMD)construction site and sweep sibling flags (exception-handler presence, tail-call usage, memory/table usage, GC type usage). Wider — anyB3::ProcedureorAir::Codemember initialized in the constructor and never written afterward; the shape to notice in search results is a private bool or enum whose setter is only ever called from an initializer list or constructor body. Widest — this is the general "inliner merges units with divergent capability attributes" class, and the same invariant governs LLVM'starget-featurescompatibility check at inline sites, V8 Turbofan's per-function feedback and feature flags, and Rust's#[target_feature]inlining restrictions. Match tell anywhere: a per-unit attribute consulted by codegen that has no join or merge operation applied when two units are combined. -
Notification hooks whose entire body is an assertion. The invariant: a method named for recording state must mutate state; validation is a separate concern. Narrow — search
Source/JavaScriptCore/wasm/andSource/JavaScriptCore/b3/for one-line member functions matchingvoid notify.*ASSERT(orvoid (mark|record|set).*\{ ASSERT\(.*\); \}. The highest-yield starting point is theWasmFunctionParsercallback surface implemented by each IR generator (BBQ, OMG, the validators alongside them) — each generator implements the same callback set, and divergence between them is exactly where one forgets to record. Wider — the class extends to any bookkeeping guarded by#if ASSERT_ENABLEDor performed inside anASSERT/DEBUG_ASSERTargument expression, and to any "observer" method that checks a precondition instead of updating a summary. Widest — "semantic work hidden inside a debug-only assertion" covers ChromiumDCHECKwith side-effecting arguments, Rustdebug_assert!wrapping a mutation, and Javaassertstatements with side effects. The portable test: if deleting the assertion macro's expansion changes nothing about program state, but the function's name promises it does, that is the bug. -
Audit the flag's consumers, not just its producer. The invariant: frame-layout and ABI decisions must be derived from the IR's contents, not from a summary bit computed before the contents were known. Narrow — enumerate reads of
B3::Procedure::usesSIMD()andAir::Code::usesSIMD()acrossSource/JavaScriptCore/b3/andSource/JavaScriptCore/b3/air/, focusing on stack-slot sizing, callee-saveRegisterAtOffsetListwidth selection, and the wasm catch-entrypoint register restore; for each, ask whether aV128Value/Tmpreaching that phase with the flag false would produce an undersized slot or a narrow save. Wider — the same shape appears wherever a width, alignment, or register-bank decision reads a boolean summary instead of scanning for the widest type in scope; in search results the tell is anifor ternary selecting between two sizes or alignments where the predicate is a flag rather than a query over the value list. Widest — applies to any compiler where frame layout is fixed from a pre-pass summary that a later pass can invalidate. The question to carry: can any transformation introduce a value whose storage requirement exceeds what the summary predicted, and is the summary recomputed after that transformation? -
RELEASE_ASSERTreachability widened by the fix itself.setUsesSIMD()opens withRELEASE_ASSERT(Options::useWasmSIMD()), retained in shipping builds. Before this patch it was reachable only from theProcedureconstructor, whose caller had already consulted module metadata; it is now reachable from the parser callback. Narrow — verify thatWasmFunctionParser/WasmSectionParserrejects everyv128type and SIMD opcode before OMG IR generation whenOptions::useWasmSIMD()is false, including through imported function signatures, tag payload types, and global types. A gap there converts a validation miss into a release-build abort. Wider — the class covers anyRELEASE_ASSERTwhose reachability set a patch expands; the tell is a fix that adds a call to an existing release-asserted function from a caller that does not itself establish the asserted precondition. Widest — "refactor widens the caller set of a fail-fast check without re-proving its precondition" applies to any codebase built on abort-on-violation invariants, including Rustassert!/unwrapin newly shared helpers and Gopanicin promoted utilities. The invariant to carry: a fail-fast precondition is part of a function's contract, and every new caller must independently establish it.