[JSC] Do not clone patchpoints for Wasm calls within a try block
CVE: CVE-2026-65334 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A memory corruption issue was addressed with improved state management. Credit: OpenAI Codex Security - Amy Burnett
High. An optimizer that duplicated a call site left two machine-code locations sharing one exception-restoration record — and that record describes where every live reference lives at throw time. Reachable from plain web content; the forged-reference half needs the two clones' register layouts to diverge in the attacker's favor.
A JIT that reorders and duplicates code has to keep one promise to the runtime that unwinds through it: whatever the compiler tells the exception handler about where live values are stored must still be true when a throw actually reaches that point. WebAssembly's OMG tier keeps that promise with a per-call-site record — a stackmap keyed by CallSiteIndex — that names, slot by slot, the location of every Wasm value live across a call inside a try. The invariant is a counting one: one key, one call site, one layout. Break the count and the catch block refills its locals from a description of code that is not the code that threw.
The angle: A page can shape a WebAssembly module so that a catch block hands JavaScript a reference-typed value refilled from a slot that never held a reference.
Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
Source/JavaScriptCore/b3/B3ReduceStrength.cpp
JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
Patch Details
Two production changes, one on each side of the contract, plus a hand-assembled regression test.
On the producer side, OMGIRGenerator::createCallPatchpoint() no longer builds every Wasm call's PatchpointValue with the bare Patchpoint kind. The kind is now computed from the generator's current nesting state — m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint — so a call emitted anywhere inside a try is stamped non-duplicable at the moment the node is created, before any optimizer has a chance to look at it. Calls outside a try are unchanged and remain freely cloneable, which matters: this is the population of call sites that never acquires a catch-restoration record, and marking them would cost optimization opportunities for nothing.
A matching ASSERT(patch->kind().isCloningForbidden()) lands at the top of OMGIRGenerator::preparePatchpointForExceptions(), placed deliberately after the if (!mustSaveState) return nullptr; early-out — that is, on exactly the path that goes on to append stackmap children and register a PatchpointExceptionHandle. The assertion is a tripwire for the general rule rather than for this one call site: any future IR-generation path that attaches an exception handle to a patchpoint that some transform is still allowed to copy trips it in a debug build.
On the consumer side, ReduceStrength's Check handler is restructured. Previously, finding a candidate Select with a constant arm was sufficient to fire the transform: if (select) { specializeSelect(select); ... }. The patch inverts the shape into an early break on failure, then inserts a backward scan from m_index down to the Select itself. Every value in that inclusive range is the set the transform is about to duplicate, so the loop asks each one value->kind().isCloningForbidden(); a single hit clears canClone and the specialization is abandoned. A RELEASE_ASSERT(i) inside the loop keeps the scan from running off the front of the block if the Select is somehow not found — a structural invariant the transform already depended on implicitly, now made explicit.
The test, JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js, is assembled as raw bytes rather than written in WAT, because the in-tree WAT assemblers cannot express GC/reference types and exception handling in the same module. Its exported $target builds exactly the IR shape the transform used to eat: a typed select on an externref with one constant (ref.null extern) arm, a call to a throwing helper inside the try, and then ref.as_non_null on the select's result — which lowers to the downstream B3 Check that specializeSelect keys on. The helper is padded with 600 nops to stay above the inlining threshold, and the driver loop runs wasmTestLoopCount iterations to force OMG tier-up.
Background
B3 and patchpoints. B3 is JSC's low-level SSA intermediate representation and backend, shared by the FTL JIT for JavaScript and by the OMG JIT for WebAssembly. A PatchpointValue is an IR node that reserves a hole in the generated machine code which the compiler's client fills with hand-written assembly. Alongside its ordinary operands, a patchpoint carries stackmap children: extra values the client declares as live at that point. B3 reports back to the client where each of those values ended up after register allocation — a specific register, or a specific stack slot.
Stackmaps and CallSiteIndex. The record of those locations is the stackmap. Wasm OMG assigns every call an incrementing CallSiteIndex via advanceCallSiteIndex() and uses that index as the key under which the runtime finds metadata for that code location during unwinding.
Wasm exception handling in OMG. For a call inside a try block, OMGIRGenerator::preparePatchpointForExceptions() appends the currently live Wasm values as stackmap children and returns a PatchpointExceptionHandle. When an exception unwinds to a catch or catch_all, the runtime looks up the handler by CallSiteIndex and refills the catch entrypoint's state from the recorded locations. As the file's own comment explains, try/catch and OSR loops "materialize" the Wasm expression stack into B3::Variables precisely so that the catch entrypoint has a fixed place to restore into.
Kind::isCloningForbidden(). A B3 Kind carries flags alongside the opcode. cloningForbidden(Patchpoint) constructs a Patchpoint kind bearing a flag that means "transforms must not duplicate this value." The flag predates this commit — per the commit message it arrived with 266643@main, for throw/rethrow patchpoints.
specializeSelect in ReduceStrength. ReduceStrength is B3's strength-reduction and canonicalization phase. Among its transforms is a specialization for Select(cond, a, b) feeding a downstream Check: the block is split at the Select and the values between the Select and its consumer are duplicated into two arm-specialized copies, so that each copy sees a concrete arm — often a constant — instead of a runtime choice. The backward scan added by this patch walks exactly that range.
externref in JSC. A Wasm externref is represented as a JSValue and can be returned directly to JavaScript. A Wasm-visible reference slot and a 64-bit scalar slot are therefore the same width, distinguished only by static typing.
OMG tier-up. OMG is the top optimizing tier for Wasm. A function reaches it only after enough executions, which is why the regression test drives target() in a loop rather than calling it once.
Analysis
The root cause is metadata aliasing induced by a compiler transform: a code region owning a uniquely-keyed side-table entry was duplicated, leaving two copies pointing at one entry that accurately describes only one of them.
Before the fix (specializeSelect fires): After:
Select(cond, null, $object) Select(cond, null, $object)
│ │ ← scan hits cloningForbidden
┌────┴────┐ block split + range cloned │ → transform bails
▼ ▼ ▼
call#7 call#7' ← two machine call sites call#7 ← one call site
│ │ │
└────┬────┘ │
▼ ▼
stackmap[CallSiteIndex 7] stackmap[CallSiteIndex 7]
(describes ONE layout) (describes THE layout)
Before the fix, the producer never said the call was special. createCallPatchpoint() called advanceCallSiteIndex() and then created a plain PatchpointValue. Only afterwards, if the call sat inside a try, did preparePatchpointForExceptions() append the live Wasm values as stackmap children and register a PatchpointExceptionHandle keyed by that call's CallSiteIndex. That registration is a one-to-one mapping by construction — one code location, one description of where each live value sits at throw time — but nothing in the IR node itself recorded that fact. Meanwhile the consumer, specializeSelect, duplicated the whole value range between a Select and its downstream Check without ever consulting Kind::isCloningForbidden(). The flag existed; this transform simply did not read it. Both halves had to fail for the bug to exist, and both did.
In the diagram above, the two clones are the crux. They are specialized on opposite Select arms — one sees the constant ref.null extern, the other sees the runtime $object — so everything downstream of each clone is optimized independently. Their live-value sets and, critically, their register and spill-slot assignments are computed separately. The commit message states it plainly: the clones have "potentially differing live-value layouts." Only one stackmap survives the duplication to describe both.
What happens at runtime follows mechanically. Unwinding from a throw inside one clone locates the handler by CallSiteIndex, then refills the catch entrypoint's B3::Variables from the locations named in that single stackmap. If those locations describe the other clone, each live Wasm value is reconstructed from whatever the throwing clone happened to leave in that register or spill slot. Nothing checks the reconstruction — the whole point of a stackmap is that its contents are trusted as ground truth about the frame.
The regression test's data is a threat model in disguise. Its trigger sequence is short enough to walk directly:
global.get $bits0…$bits23— 24 attacker-controlledi64sentinels shaped0xfffe00000000002a + n*0x100, pushed as live values across the call.- A typed
selectonexternrefwithref.null externas the constant arm — the shapespecializeSelectkeys on. local.set 1stores the result into$live, a reference-typed local.call $helperinside thetry— the patchpoint that acquires theCallSiteIndex-keyed restoration stackmap. The helper always throws.ref.as_non_nullon$live— lowers to the downstream B3Checkthat closes the duplicated range.catch_allreturns$live, and JavaScript checks it againstnull.
Those 24 sentinels sitting adjacent to an externref in the live set are not padding to provoke a crash; they are bait for a scalar landing in a reference slot. The test asserts the catch path yields null and fails on anything else.
The security consequence is a break in the Wasm type system's most basic guarantee inside the WebContent process: that a reference-typed local always holds a reference. The immediate, test-evidenced primitive is a reference-typed local restored in a catch_all with a value that was never live there — observable from JavaScript as wrong-object disclosure, or as a crash when a non-reference bit pattern is dereferenced. Projecting one step further: if the mismatched stackmap slot maps onto one of the attacker-supplied i64 argument locations, the restored externref would carry a fully attacker-chosen 64-bit pattern, which would constitute a forged-reference type confusion in the Wasm/JS heap. That range — from disclosure of an internal JSValue or heap address up to a forged reference suitable for building stronger primitives — is why Apple's advisory-level "unexpected Safari crash" wording understates the shape of the bug. Reaching any of it needs only ordinary web content: a WebAssembly module combining exception handling with reference types, warmed until OMG tier-up. Everything happens inside the renderer, though; B3 and WasmOMGIRGenerator are JSC compiler components, and an attacker would still need a separate escape to leave the WebContent sandbox.
The fix restores the count from both directions. The producer stamps in-try call patchpoints cloningForbidden so the IR carries its own non-duplicability, and the consumer scans the range it is about to copy and refuses to specialize when any value in it carries the flag.
Duplicating a call site whose exception-restoration stackmap is keyed by CallSiteIndex leaves two code locations sharing one layout description — the catch block refills references from the wrong frame.
Insight
This is the second half of an incomplete earlier fix, and it failed in both directions at once. The cloningForbidden flag arrived with 266643@main to stop throw/rethrow patchpoints from being duplicated — but it was applied only to those specific patchpoints, never to the much larger population of ordinary call patchpoints that also acquire a CallSiteIndex-keyed stackmap whenever m_tryCatchDepth != 0, and it was honored by some cloning transforms while specializeSelect ignored it. The structural reason a single missed call site could reopen the hole is that enforcement lives in the callers of the cloning machinery rather than inside the cloning primitive itself. The new ASSERT in preparePatchpointForExceptions() is a good tripwire for the producer-side omission, but it is debug-only: a release build gets no diagnostic if some other IR-generation path attaches an exception handle to a cloneable patchpoint.
Audit directions
-
IR values that own a uniquely-keyed runtime metadata record while remaining structurally duplicable. The invariant: a side-table entry keyed by code location must have exactly one code location. Narrow — enumerate every
m_proc.add<PatchpointValue>/add<CCallValue>site inWasmOMGIRGenerator.cppand in the FTL lowering, and for each ask whether an exception handle or OSR record gets attached later; the syntactic tell is a patchpoint constructed with the barePatchpointkind on a path that can reachpreparePatchpointForExceptions()or anyadvanceCallSiteIndex()-keyed registration. Wider — the class appears wherever a code-location-keyed record is created: OSR exit descriptors,CallSiteIndex-keyed inline-call-frame records, GC maps. Audit which IR node types the generator assumes are never copied without saying so in theKind; the tell is a metadata registration capturing an index or an origin with no corresponding non-duplicable marking on the node. Widest — this invariant holds in any compiler that keys runtime metadata by instruction identity while permitting IR duplication: LLVM statepoints and patchpoints under tail duplication and jump threading, V8 TurboFan deopt frame states under node cloning, .NET GC info under IL inlining. The mental check that transfers: if this instruction were copied, would the metadata still describe both copies? -
An opt-out capability flag whose enforcement is distributed across consumers rather than centralized in the mutation primitive — one unaudited consumer voids the whole guarantee. Narrow — grep
Source/JavaScriptCore/b3andSource/JavaScriptCore/b3/airforisCloningForbidden, then separately for every routine that copies aValue(cloneValue, tail duplication inB3DuplicateTails, block cloning, loop peeling and unrolling,specializeSelect). The match tell is a copy site not preceded by anisCloningForbiddenquery — exactly the asymmetry this patch fixed. Wider — run the same reader-vs-writer census over B3's other per-node capability bits (Effects,traps, terminal-ness) and over the DFG's equivalent "do not hoist / do not clone" predicates, then ask whether the check belongs inside the clone primitive so new transforms inherit it for free; in code-search results the shape to notice is a predicate defined once and consulted in N places where N is smaller than the number of mutation sites. Widest — the generic "enforcement at the caller instead of at the choke point" class, applicable to any codebase with immutability or pinning flags: Rust'sPincontract upheld by unsafe blocks, GC pinning APIs, copy-on-write guards in database engines. The invariant to carry: a flag that forbids an operation should be checked inside that operation, not by everyone who calls it. -
Correctness of exception and OSR state restoration on paths a later optimizer restructures — state is snapshotted at IR-generation time but consumed at runtime by index, so any transform in between can invalidate the mapping. Narrow — trace every caller of
advanceCallSiteIndex()inWasmOMGIRGenerator.cpp(direct calls,call_indirect, tail calls, throw/rethrow, and any inlined-callee call sites) and confirm each one'sm_tryCatchDepthhandling matches the newcloningForbiddenrule; the tell is a call-site-index consumer whose patchpoint kind is not derived fromm_tryCatchDepth. Wider — the same snapshot-then-restore-by-index shape governs FTL OSR exit stackmaps and Wasm OSR entry data (WasmOSREntryData), so check whether any B3 phase running after those records are built can duplicate, merge, or re-order the values they name; look for a record built during lowering that stores value pointers or indices consulted only after register allocation. Widest — the class is bound to compilers that materialize interpreter or handler state from machine-level location maps, and transfers directly to V8's deoptimization frame states and to any JIT with on-stack replacement. The carried question: which transforms are allowed to run after this map is fixed, and does anything re-key or duplicate the locations it names? -
Verify the debug-only enforcement is sufficient. The new
ASSERT(patch->kind().isCloningForbidden())compiles out of release builds, so check whetherB3Validate— or an equivalent post-phase validator — can be extended to reject any Procedure containing a Patchpoint that has stackmap children registered for exception restoration but a cloneableKind. The match tell for an auditor: a validator that checks IR well-formedness but not client-attached metadata consistency. That gap between the compiler and its client is where this class of bug hides.