← All reports

[JSC] Do not clone patchpoints for Wasm calls within a try block

HighJSC B3 optimizer / Wasm OMG JITTypeConfusion

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

9f07374 | Bugzilla 316791

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

RefPtr<PatchpointExceptionHandle> OMGIRGenerator::preparePatchpointForExceptions(...)
if (!mustSaveState)
return nullptr;
 
+ ASSERT(patch->kind().isCloningForbidden());
+
unsigned firstStackmapChildOffset = patch->numChildren();
unsigned firstStackmapParamOffset = firstStackmapChildOffset + m_proc.resultCount(patch->type());
auto OMGIRGenerator::createCallPatchpoint(BasicBlock* block, const RTT& signature, ...)
auto constrainedPatchArgs = createCallConstrainedArgs(block, wasmCalleeInfo, tmpArgs);
 
advanceCallSiteIndex();
- PatchpointValue* patchpoint = m_proc.add<PatchpointValue>(returnType, origin());
+ // Calls inside a try carry a catch-restoration stackmap keyed by CallSiteIndex, see preparePatchpointForExceptions.
+ // Forbid cloning so a B3 transform won't alias two call sites to one stackmap.
+ auto patchpointKind = m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint;
+ PatchpointValue* patchpoint = m_proc.add<PatchpointValue>(returnType, origin(), patchpointKind);
patchpoint->effects.writesPinned = true;
patchpoint->effects.readsPinned = true;
patchpoint->clobberEarly(RegisterSet::macroClobberedGPRs());

Source/JavaScriptCore/b3/B3ReduceStrength.cpp

&& (value->child(1)->isConstant() || value->child(2)->isConstant());
});
 
- if (select) {
+ if (!select)
+ break;
+
+ // All values between Select and Check must be cloneable.
+ bool canClone = true;
+ for (unsigned i = m_index; ; --i) {
+ Value* value = m_block->at(i);
+ if (value->kind().isCloningForbidden()) {
+ canClone = false;
+ break;
+ }
+ if (value == select)
+ break;
+ RELEASE_ASSERT(i); // Select should be found
+ }
+
+ if (canClone) {
specializeSelect(select);
m_didSpecializeSelect = true;
break;

JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js

+ const targetBody = body([[1, 0x6f]], [
+ 0x06, 0x6f, // try (result externref)
+ ...bits0..bits23 (global.get x24, i64 sentinels 0xfffe00000000002a + n*0x100)
+ 0xd0, 0x6f, // ref.null extern (constant select arm)
+ 0x23, 0x00, // global.get $object (other select arm)
+ ...localGet(0), // select condition
+ 0x1c, 0x01, 0x6f, // typed select -> externref
+ ...localSet(1), // $live = select result
+ ...localGet(1),
+ 0x10, 0x01, // call $helper (patchpoint inside try; always throws)
+ 0x1a,
+ ...localGet(1),
+ 0xd4, // ref.as_non_null -> the B3 Check
+ 0x1a,
+ 0xd0, 0x6f,
+ 0x19, // catch_all
+ ...localGet(1), // return restored $live
+ 0x0b,
+ ]);
+
+for (let iteration = 0; iteration < wasmTestLoopCount; ++iteration) {
+ const result = target(1);
+ if (result !== null)
+ throw new Error(`expected null catch restoration, got ${String(result)} at iteration ${iteration}`);
+}

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.

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.

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:

  1. global.get $bits0 … $bits23 — 24 attacker-controlled i64 sentinels shaped 0xfffe00000000002a + n*0x100, pushed as live values across the call.
  2. A typed select on externref with ref.null extern as the constant arm — the shape specializeSelect keys on.
  3. local.set 1 stores the result into $live, a reference-typed local.
  4. call $helper inside the try — the patchpoint that acquires the CallSiteIndex-keyed restoration stackmap. The helper always throws.
  5. ref.as_non_null on $live — lowers to the downstream B3 Check that closes the duplicated range.
  6. catch_all returns $live, and JavaScript checks it against null.

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.

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.