← WebKit Silent-Fix Report — 2026-W34

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

severity high class TypeConfusion confidence 0.90 OMG/B3 patchpoint exception stackmap exploitable-grade
Dan Hecht Sun Aug 23 13:31:15 2026 -0700 full: 9f07374e9eb2398ebf629352f5fc0a509ae631b8 bug report ↗ view on GitHub ↗
Primitive: Wasm JIT clones patchpoints for calls inside try block, corrupting exception stackmaps
Triage note: Cloning patchpoints for Wasm calls within a try block duplicated exception stackmap entries (see select/reduce-strength stackmap test), a JIT soundness bug that can miscompile values live at exception handlers.
Contents

The bug at a glance

Reachable from any WebAssembly module that places an exception-capable call inside a try block whose result feeds a Select whose sink is a Check (e.g. ref.as_non_null) - all instantiable from untrusted script via WebAssembly.Module/Instance, no special privilege. When B3 Select specialization (or B3DuplicateTails) clones the OMG call patchpoint, the two resulting call sites alias a single exception-restoration stackmap even though their live-value layouts differ, so the catch handler restores values from the wrong registers/slots and can reinterpret a raw i64 bit-pattern as an externref (object) or vice versa. That is a JIT-introduced type confusion on a GC reference, the standard precursor to a fake-object / arbitrary-read-write primitive, justifying High / 8.1.

An OMG (Wasm optimizing tier) call inside a try block is not just a call - it is an exception boundary that carries a stackmap, keyed by its CallSiteIndex, describing exactly which values are live and where so the catch handler can restore them. B3 treats an ordinary Patchpoint as freely cloneable, and Select specialization happily duplicates the tail of a block - including that call patchpoint - to fold a Select into control flow. The result is two physically distinct call sites pointing at one shared exception stackmap, even though after duplication their live-value layouts diverge. Restore the wrong layout at the catch entry and an integer sentinel becomes an object reference: a miscompile that hands the attacker a type confusion straight out of the JIT.

Root cause

In OMGIRGenerator::createCallPatchpoint, every Wasm call is lowered to a B3 PatchpointValue. When that call sits inside a try block, OMGIRGenerator::preparePatchpointForExceptions attaches a PatchpointExceptionHandle: a stackmap keyed by the call’s CallSiteIndex (assigned via advanceCallSiteIndex) that records the values live at the point of the call so the runtime can reconstruct them when control unwinds into the catch handler. There is exactly one stackmap per CallSiteIndex.

B3 considers a plain Patchpoint kind cloneable. B3ReduceStrength’s specializeSelect() optimizes a Select whose value flows into a Check by duplicating the run of values between the Select and the Check into two specialized copies (one per Select arm); B3DuplicateTails performs a similar tail duplication. If the exception-capable call patchpoint lies in that duplicated range, it is cloned into two call sites. Both clones keep the original’s association to the single CallSiteIndex-keyed exception stackmap, but after cloning the two paths can have different values live in different registers and stack slots. Restoring at one call site using the layout baked for the other reads a value of the wrong type from the wrong location.

The test exercises exactly this: a typed select produces an externref local $live, a subsequent exception-capable call to $helper (which always throws) is the patchpoint specializeSelect would clone, and ref.as_non_null ($live) is the Check that drives the specialization. Twenty-four mutable i64 globals plus an externref are kept live across the call precisely so the two clones end up with divergent live-value layouts; the i64 sentinels use the raw pattern 0xfffe00000000002a, which resembles a boxed JSValue, so a corrupted restore surfaces an integer where a reference is expected. Correct execution restores $live (null) at catch_all and returns null.

The fix makes any exception-carrying call patchpoint non-cloneable. createCallPatchpoint now builds the patchpoint with kind cloningForbidden(Patchpoint) whenever m_tryCatchDepth is non-zero, mirroring the existing treatment of throw/rethrow patchpoints from 266643@main. An ASSERT(patch->kind().isCloningForbidden()) is added in preparePatchpointForExceptions so that any patchpoint that acquires an exception handle is provably un-cloneable. Complementarily, B3ReduceStrength::specializeSelect now scans every value in the [Select, Check] range and bails (canClone=false) if any has kind().isCloningForbidden(), so the transform refuses to duplicate a range containing a protected call.

Key code

createCallPatchpoint forbids cloning of exception-carrying call patchpoints (WasmOMGIRGenerator.cpp)

    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);

// preparePatchpointForExceptions:
     if (!mustSaveState)
         return nullptr;
+    ASSERT(patch->kind().isCloningForbidden());

// B3ReduceStrength specializeSelect guard:
+            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);
+            }
+            if (canClone) { specializeSelect(select); ... }

Patch walkthrough

  • Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp — In createCallPatchpoint, the patchpoint kind is now conditionally hardened: auto patchpointKind = m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint, and the PatchpointValue is added with that kind. Any call emitted while inside a try (m_tryCatchDepth != 0) is therefore marked non-cloneable, so no later B3 transform can duplicate the site that owns its CallSiteIndex-keyed exception stackmap. An ASSERT(patch->kind().isCloningForbidden()) is added at the top of preparePatchpointForExceptions to enforce the invariant that any patchpoint receiving an exception handle is already forbidden from cloning.
  • Source/JavaScriptCore/b3/B3ReduceStrength.cpp — specializeSelect’s guard is restructured. It now early-outs if there is no qualifying Select (if (!select) break). Then it walks backward from m_index down to the Select, and if any intervening value reports kind().isCloningForbidden() it sets canClone = false. Only when the entire [Select, Check] range is cloneable does it call specializeSelect(select). This is a defense-in-depth catch: even if some future construct produces a forbidden value in the duplication window, the transform declines rather than aliasing two sites to one stackmap. A RELEASE_ASSERT(i) guards that the Select is always found in the scan.
  • JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js — A hand-assembled Wasm module (raw bytes, because in-tree WAT assemblers do not support GC + exceptions together) building the precise miscompile shape: a try producing an externref, a typed select, an exception-capable call to a padded $helper that always throws, and a ref.as_non_null Check. Twenty-four i64 globals plus an externref are held live to force divergent live-value layouts between the two potential clones. The loop runs to the OMG tier and asserts the catch restoration always yields null; before the fix the aliased stackmap restored a wrong-typed value.

Background

B3 PatchpointValue — A B3 IR node representing an opaque code stub whose machine code is generated by a callback, with explicit register/stack constraints. Wasm calls, throws and rethrows are lowered to patchpoints. B3 assigns each a kind; an ordinary Patchpoint kind is considered semantically cloneable by transforms that duplicate code.

Exception stackmap / PatchpointExceptionHandle — For a call inside a Wasm try, the OMG generator records a stackmap keyed by the call’s CallSiteIndex describing which values are live and in which registers/stack slots, so the unwinder can materialize them at the catch handler. There is one stackmap per CallSiteIndex - aliasing two physical call sites to it corrupts restoration.

specializeSelect / B3DuplicateTails — B3 strength-reduction transforms that duplicate a run of instructions to specialize control flow - e.g. folding a Select feeding a Check by cloning the [Select, Check] tail once per arm. Both physically copy values, which is unsound for a value that must remain the unique owner of an exception stackmap.

cloningForbidden(Kind) — A B3 Kind modifier (queried via Kind::isCloningForbidden()) that marks a value as ineligible for duplication by cloning transforms. Introduced for throw/rethrow patchpoints in 266643@main; this patch extends it to in-try call patchpoints.

Vulnerability window

  1. Baseline behavior — throw/rethrow patchpoints were already marked cloningForbidden (266643@main), but exception-capable call patchpoints inside a try were emitted as ordinary, cloneable Patchpoints.
  2. Miscompile trigger — A try block whose select result feeds a Check, with an exception-capable call in between, lets B3 specializeSelect / DuplicateTails clone the call patchpoint.
  3. Corruption — The two clones share one CallSiteIndex-keyed exception stackmap despite divergent live-value layouts; the catch handler restores a wrong-typed value (an i64 sentinel where an externref is expected).
  4. Discovery / fix — Dan Hecht (rdar://178657225, bug 316791) extended cloningForbidden to call patchpoints when m_tryCatchDepth != 0 and hardened specializeSelect to bail on any forbidden value in its clone range.
  5. Backport — Originally landed as 305413.972 on safari-7624.5-branch (db24355101bd, rdar://185368817), canonicalized as 319664@main.

Proof of concept

The upstream test is a complete trigger PoC. It drives $target to the OMG tier; with the bug, cloning the $helper call patchpoint corrupts the catch restoration of $live so target(1) returns a non-null (wrong-typed) value instead of null. It demonstrates the miscompile but does not itself weaponize it into a memory-corruption primitive.

// Adapted from JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
// (module hand-assembled as raw bytes; see makeModule() in the test)
// $target: try (result externref)
//   global.get $bits0 .. $bits23      // 24 live i64 sentinels
//   ref.null extern                    // select arm A
//   global.get $object                 // select arm B
//   local.get 0                        // predicate
//   select (result externref)          // -> $live
//   local.set 1                        //    ($live)
//   local.get 1
//   call $helper                       // exception-capable call cloned by specializeSelect
//   drop
//   local.get 1
//   ref.as_non_null                    // the Check that drives specialization
//   drop
//   ref.null extern                    // unreachable normal path ($helper always throws)
// catch_all
//   local.get 1                        // restored $live -> function result
// end
const target = new WebAssembly.Instance(
    new WebAssembly.Module(makeModule()), { m: imports }).exports.target;
for (let i = 0; i < wasmTestLoopCount; ++i) {
    const result = target(1);
    if (result !== null)
        throw new Error(`expected null catch restoration, got ${String(result)}`);
}

Exploitation

  1. Reach the JIT — Instantiate a crafted Wasm module (GC + exceptions) and call the export in a hot loop to force OMG compilation, where B3 specializeSelect/DuplicateTails runs and clones the in-try call patchpoint.
  2. Force a type confusion — Shape the live-value layout so an i64 (or other scalar) sentinel occupies the slot the corrupted stackmap restores as an externref (or reverse). The 0xfffe…002a-style sentinels model a controlled fake-JSValue bit pattern surfacing where a GC reference is expected.
  3. Escalate — A confused externref becomes a fake object handle; combined with additional heap grooming this is the standard path to addrof/fakeobj and ultimately arbitrary read/write. Not demonstrated in the patch and would require substantial further work.

Detection & hunting

For defenders and SOC / detection engineers:

  • OMG-compiled Wasm modules with try/catch
  • Duplicate CallSiteIndex ownership
  • Catch-restore anomalies

Audit directions

  • Other cloning transforms
  • Other stackmap-bearing patchpoints
  • m_tryCatchDepth accounting

Before / after

Loading diff…