CVE-2026-65336
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: Josef Korbel
Key insight. The IPInt interpreter reconstructed the ref-cast allowNull flag from a fixed bytecode offset (loadb 2[PC]) instead of validated metadata, so casts could use the wrong nullability; storing allowNull in RefTestCastMetadata and reading it from there fixes the type confusion.
Patch Details
diff --git a/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js b/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js
new file mode 100644
index 000000000000..751087b87498
--- /dev/null
+++ b/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js
@@ -0,0 +1,75 @@
+"use strict";
+// Canonical and overlong LEB128 encodings of a GC sub-opcode must produce
+// identical execution semantics. This test checks that for br_on_cast_fail
+// with FLAGS=0x03 - divergence means IPInt is reading the flags byte from a
+// position the encoding moved.
+
+function uleb(n) { const o=[]; do { let b=n&0x7f; n>>>=7; if(n) b|=0x80; o.push(b); } while(n); return o; }
+function section(id, b) { return [id, ...uleb(b.length), ...b]; }
+function str(s) { return [s.length, ...Array.from(s, c => c.charCodeAt(0))]; }
+
+function buildModule(brOnCastFail) {
+ const typeSec = [
+ 0x03,
+ 0x5f, 0x00, // type 0: (struct)
+ 0x60, 0x00, 0x00, // type 1: () -> void
+ 0x60, 0x00, 0x01, 0x7f, // type 2: () -> i32
+ ];
+ const funcSec = [0x02, 0x01, 0x02];
+ // global 0: (mut (ref null any)) init = (struct.new_default 0)
+ const globalSec = [0x01, 0x63, 0x6e, 0x01, 0xfb, 0x01, 0x00, 0x0b];
+ const exportSec = [
+ 0x02,
+ ...str("poison"), 0x00, 0x00,
+ ...str("is_poisoned"), 0x00, 0x01,
+ ];
+ const poisonBody = [
+ 0x00,
+ 0x02, 0x63, 0x6e, // block (result (ref null any))
+ 0xd0, 0x6e, // ref.null any
+ ...brOnCastFail, // br_on_cast_fail 0 (FLAGS=0x03 in either encoding)
+ 0x1a, // drop (drops the (ref null 0) cast result)
+ 0xfb, 0x01, 0x00, // struct.new_default 0
+ 0x0c, 0x00, // br 0
+ 0x0b,
+ 0x24, 0x00, // global.set 0
+ 0x0b,
+ ];
+ const checkBody = [
+ 0x00,
+ 0x23, 0x00, // global.get 0
+ 0xd1, // ref.is_null
+ 0x0b,
+ ];
+ const codeSec = [
+ 0x02,
+ ...uleb(poisonBody.length), ...poisonBody,
+ ...uleb(checkBody.length), ...checkBody,
+ ];
+ return new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+ ...section(1, typeSec),
+ ...section(3, funcSec),
+ ...section(6, globalSec),
+ ...section(7, exportSec),
+ ...section(10, codeSec),
+ ]);
+}
+
+// prefix sub-op flags label ht1 ht2
+const CANONICAL = [0xfb, 0x19, 0x03, 0x00, 0x6e, 0x00];
+const OVERLONG = [0xfb, 0x99,0x80,0x00, 0x03, 0x00, 0x6e, 0x00];
+
+const a = new WebAssembly.Instance(new WebAssembly.Module(buildModule(CANONICAL)));
+const b = new WebAssembly.Instance(new WebAssembly.Module(buildModule(OVERLONG)));
+
+a.exports.poison();
+b.exports.poison();
+
+const rA = a.exports.is_poisoned();
+const rB = b.exports.is_poisoned();
+
+if (rA !== rB)
+ throw new Error("LEB encoding of GC sub-opcode affects br_on_cast_fail semantics: "
+ + "canonical=" + rA + " overlong=" + rB
+ + " — IPInt reads flags from PC at hardcoded offset");
diff --git a/JSTests/wasm/stress/br-on-cast-overlong-leb128.js b/JSTests/wasm/stress/br-on-cast-overlong-leb128.js
new file mode 100644
index 000000000000..acb7d7784641
--- /dev/null
+++ b/JSTests/wasm/stress/br-on-cast-overlong-leb128.js
@@ -0,0 +1,75 @@
+"use strict";
+// Canonical and overlong LEB128 encodings of a GC sub-opcode must produce
+// identical execution semantics. This test checks that for br_on_cast with
+// FLAGS=0x03 - divergence means IPInt is reading the flags byte from a
+// position the encoding moved.
+
+function uleb(n) { const o=[]; do { let b=n&0x7f; n>>>=7; if(n) b|=0x80; o.push(b); } while(n); return o; }
+function section(id, b) { return [id, ...uleb(b.length), ...b]; }
+function str(s) { return [s.length, ...Array.from(s, c => c.charCodeAt(0))]; }
+
+function buildModule(brOnCast) {
+ const typeSec = [
+ 0x03,
+ 0x5f, 0x00, // type 0: (struct)
+ 0x60, 0x00, 0x00, // type 1: () -> void
+ 0x60, 0x00, 0x01, 0x7f, // type 2: () -> i32
+ ];
+ const funcSec = [0x02, 0x01, 0x02];
+ // global 0: (mut (ref null 0)) init = (struct.new_default 0)
+ const globalSec = [0x01, 0x63, 0x00, 0x01, 0xfb, 0x01, 0x00, 0x0b];
+ const exportSec = [
+ 0x02,
+ ...str("poison"), 0x00, 0x00,
+ ...str("is_poisoned"), 0x00, 0x01,
+ ];
+ const poisonBody = [
+ 0x00,
+ 0x02, 0x63, 0x00, // block (result (ref null 0))
+ 0xd0, 0x6e, // ref.null any
+ ...brOnCast, // br_on_cast 0 (FLAGS=0x03 in either encoding)
+ 0x1a, // drop
+ 0xfb, 0x01, 0x00, // struct.new_default 0 (only reached if cast missed)
+ 0x0c, 0x00, // br 0
+ 0x0b,
+ 0x24, 0x00, // global.set 0
+ 0x0b,
+ ];
+ const checkBody = [
+ 0x00,
+ 0x23, 0x00, // global.get 0
+ 0xd1, // ref.is_null
+ 0x0b,
+ ];
+ const codeSec = [
+ 0x02,
+ ...uleb(poisonBody.length), ...poisonBody,
+ ...uleb(checkBody.length), ...checkBody,
+ ];
+ return new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+ ...section(1, typeSec),
+ ...section(3, funcSec),
+ ...section(6, globalSec),
+ ...section(7, exportSec),
+ ...section(10, codeSec),
+ ]);
+}
+
+// prefix sub-op flags label ht1 ht2
+const CANONICAL = [0xfb, 0x18, 0x03, 0x00, 0x6e, 0x00];
+const OVERLONG = [0xfb, 0x98,0x80,0x00, 0x03, 0x00, 0x6e, 0x00];
+
+const a = new WebAssembly.Instance(new WebAssembly.Module(buildModule(CANONICAL)));
+const b = new WebAssembly.Instance(new WebAssembly.Module(buildModule(OVERLONG)));
+
+a.exports.poison();
+b.exports.poison();
+
+const rA = a.exports.is_poisoned();
+const rB = b.exports.is_poisoned();
+
+if (rA !== rB)
+ throw new Error("LEB encoding of GC sub-opcode affects br_on_cast semantics: "
+ + "canonical=" + rA + " overlong=" + rB
+ + " — IPInt reads flags from PC at hardcoded offset");
diff --git a/Source/JavaScriptCore/llint/InPlaceInterpreter64.asm b/Source/JavaScriptCore/llint/InPlaceInterpreter64.asm
index 4833cb2b6f81..c572f822e86e 100644
--- a/Source/JavaScriptCore/llint/InPlaceInterpreter64.asm
+++ b/Source/JavaScriptCore/llint/InPlaceInterpreter64.asm
@@ -3585,9 +3585,7 @@ end)
ipintOp(_br_on_cast, macro()
validateOpcodeConfig(a1)
loadi IPInt::RefTestCastMetadata::toHeapType[MC], a1
- # fb 18 FLAGS
- loadb 2[PC], a2
- rshifti 1, a2 # bit 1 = null2
+ loadb IPInt::RefTestCastMetadata::allowNull[MC], a2
loadq [sp], a3
operationCall(macro() cCall3(_ipint_extern_ref_test) end)
@@ -3603,9 +3601,7 @@ end)
ipintOp(_br_on_cast_fail, macro()
validateOpcodeConfig(a1)
loadi IPInt::RefTestCastMetadata::toHeapType[MC], a1
- loadb 2[PC], a2
- # fb 19 FLAGS
- rshifti 1, a2 # bit 1 = null2
+ loadb IPInt::RefTestCastMetadata::allowNull[MC], a2
loadq [sp], a3
operationCall(macro() cCall3(_ipint_extern_ref_test) end)
diff --git a/Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp b/Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp
index fde5d1d88718..3cc1d04b57a4 100644
--- a/Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp
+++ b/Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp
@@ -1368,20 +1368,22 @@ WARN_UNUSED_RETURN PartialResult IPIntGenerator::addStructSet(ExpressionType, co
return { };
}
-WARN_UNUSED_RETURN PartialResult IPIntGenerator::addRefTest(ExpressionType, bool, int32_t heapType, bool, ExpressionType&)
+WARN_UNUSED_RETURN PartialResult IPIntGenerator::addRefTest(ExpressionType, bool allowNull, int32_t heapType, bool, ExpressionType&)
{
m_metadata->appendMetadata<IPInt::RefTestCastMetadata>({
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65336 |
| Bugzilla | 317349 |
| Component | JSC |
| Bug Class | TypeConfusion |
| Severity | medium |
| Commit | 5b76326e8fc95ec2… |
| Advisory | Apple Advisory |
Terminology
- ref.test / ref.cast (WASM GC) — Instructions that test/cast a reference to a target heap type, with an allowNull flag deciding whether null matches.
- In-Place Interpreter (IPInt) — JSC’s low-level WASM interpreter driven by metadata; it must read instruction operands from validated metadata, not ad-hoc bytecode offsets.
- RefTestCastMetadata — The per-instruction metadata for ref cast/test; it must carry allowNull so the interpreter uses the correct semantics.
Root Cause Analysis
This fixes a WebAssembly GC type-confusion in the In-Place Interpreter (IPInt) where the ‘allow null’ flag for ref.test/ref.cast/br_on_cast was read from the wrong place. ref.test/ref.cast (and br_on_cast/br_on_cast_fail) check whether a reference matches a target heap type, parameterized by allowNull (whether a null reference satisfies the cast). Pre-patch, the IPInt generator (addRefTest/addRefCast/addBranchCast) IGNORED the allowNull argument and did not store it in RefTestCastMetadata, and the interpreter (InPlaceInterpreter64.asm) instead tried to recover the null bit directly from the bytecode stream — loadb 2[PC] then rshifti 1 (bit 1) — a fragile, incorrect read of a flags byte at a fixed PC offset. As a result the effective allowNull used at runtime could be wrong, so a null reference could be accepted by a non-nullable cast (or a nullable cast mis-evaluated), yielding a value of the wrong nullability/type where a valid typed reference is expected — a type confusion. The fix threads allowNull properly: RefTestCastMetadata gains a uint8_t allowNull field, the generator stores static_cast<uint8_t>(allowNull) for ref.test/ref.cast/br_on_cast, and the interpreter loads it from validated metadata (loadb IPInt::RefTestCastMetadata::allowNull[MC]) instead of the ad-hoc 2[PC] bit-shift. The restored invariant is that the null-allowed semantics of a ref cast/test come from the validated metadata emitted by the generator, not a mis-derived bytecode byte.
Attack Path
1. Run a ref.cast/ref.test in IPInt
Execute WASM GC casts (ref.cast/ref.test/br_on_cast) under the In-Place Interpreter.
2. Exploit the mis-read null flag
Because the interpreter derived allowNull from the wrong bytecode byte, arrange a cast whose effective nullability is wrong.
3. Slip a null / wrong-typed ref through
A null (or wrong-nullability) reference is accepted by a cast that should reject it, or a non-null cast is mis-evaluated.
4. Type confusion
The mistyped reference is used as a valid typed reference, a memory-safety violation crashing the WebContent process.
Impact Assessment
A WebAssembly GC type-confusion in the WebContent process, reachable from crafted WASM using ref.cast/ref.test. Mis-evaluating nullability lets a null or wrong-typed reference pass a cast, producing a mistyped reference — a memory-safety primitive the advisory rates as a crash.
Audit Directions
Operand provenance in IPInt
Audit InPlaceInterpreter*.asm for operands recovered via loadb N[PC]/bit-shifts rather than named metadata fields; each is a chance to read the wrong byte.
Generator/interpreter metadata parity
Grep IPIntGenerator for instruction args (flags) that are accepted but not stored into the corresponding *Metadata the interpreter reads.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
IPIntGenerator::addRefTest / addRefCast / addBranchCast |
Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp |
modified | Store the allowNull flag into RefTestCastMetadata instead of ignoring it. |
RefTestCastMetadata |
Source/JavaScriptCore/wasm/WasmIPIntGenerator.h |
modified | Adds a uint8_t allowNull field carrying the validated null-allowed semantics. |
ipintOp _br_on_cast / _br_on_cast_fail (and ref.test/cast) |
Source/JavaScriptCore/llint/InPlaceInterpreter64.asm |
modified | Reads allowNull from RefTestCastMetadata (loadb …::allowNull[MC]) instead of the fragile loadb 2[PC]/rshifti-1 bytecode bit. |
Files Changed
Source Files
Source/JavaScriptCore/llint/InPlaceInterpreter64.asmSource/JavaScriptCore/wasm/WasmIPIntGenerator.cppSource/JavaScriptCore/wasm/WasmIPIntGenerator.h
Test Files
JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.jsJSTests/wasm/stress/br-on-cast-overlong-leb128.js
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js b/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js
new file mode 100644
index 000000000000..751087b87498
--- /dev/null
+++ b/JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js
@@ -0,0 +1,75 @@
+"use strict";
+// Canonical and overlong LEB128 encodings of a GC sub-opcode must produce
+// identical execution semantics. This test checks that for br_on_cast_fail
+// with FLAGS=0x03 - divergence means IPInt is reading the flags byte from a
+// position the encoding moved.
+
+function uleb(n) { const o=[]; do { let b=n&0x7f; n>>>=7; if(n) b|=0x80; o.push(b); } while(n); return o; }
+function section(id, b) { return [id, ...uleb(b.length), ...b]; }
+function str(s) { return [s.length, ...Array.from(s, c => c.charCodeAt(0))]; }
+
+function buildModule(brOnCastFail) {
+ const typeSec = [
+ 0x03,
+ 0x5f, 0x00, // type 0: (struct)
+ 0x60, 0x00, 0x00, // type 1: () -> void
+ 0x60, 0x00, 0x01, 0x7f, // type 2: () -> i32
+ ];
+ const funcSec = [0x02, 0x01, 0x02];
+ // global 0: (mut (ref null any)) init = (struct.new_default 0)
+ const globalSec = [0x01, 0x63, 0x6e, 0x01, 0xfb, 0x01, 0x00, 0x0b];
+ const exportSec = [
+ 0x02,
+ ...str("poison"), 0x00, 0x00,
+ ...str("is_poisoned"), 0x00, 0x01,
+ ];
+ const poisonBody = [
+ 0x00,
+ 0x02, 0x63, 0x6e, // block (result (ref null any))
+ 0xd0, 0x6e, // ref.null any
+ ...brOnCastFail, // br_on_cast_fail 0 (FLAGS=0x03 in either encoding)
+ 0x1a, // drop (drops the (ref null 0) cast result)
+ 0xfb, 0x01, 0x00, // struct.new_default 0
+ 0x0c, 0x00, // br 0
+ 0x0b,
+ 0x24, 0x00, // global.set 0
+ 0x0b,
+ ];
+ const checkBody = [
+ 0x00,
+ 0x23, 0x00, // global.get 0
+ 0xd1, // ref.is_null
+ 0x0b,
+ ];
+ const codeSec = [
+ 0x02,
+ ...uleb(poisonBody.length), ...poisonBody,
+ ...uleb(checkBody.length), ...checkBody,
+ ];
+ return new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+ ...section(1, typeSec),
+ ...section(3, funcSec),
+ ...section(6, globalSec),
+ ...section(7, exportSec),
+ ...section(10, codeSec),
+ ]);
+}
+
+// prefix sub-op flags label ht1 ht2
+const CANONICAL = [0xfb, 0x19, 0x03, 0x00, 0x6e, 0x00];
+const OVERLONG = [0xfb, 0x99,0x80,0x00, 0x03, 0x00, 0x6e, 0x00];
+
+const a = new WebAssembly.Instance(new WebAssembly.Module(buildModule(CANONICAL)));
+const b = new WebAssembly.Instance(new WebAssembly.Module(buildModule(OVERLONG)));
+
+a.exports.poison();
+b.exports.poison();
+
+const rA = a.exports.is_poisoned();
+const rB = b.exports.is_poisoned();
+
+if (rA !== rB)
+ throw new Error("LEB encoding of GC sub-opcode affects br_on_cast_fail semantics: "
+ + "canonical=" + rA + " overlong=" + rB
+ + " — IPInt reads flags from PC at hardcoded offset");
diff --git a/JSTests/wasm/stress/br-on-cast-overlong-leb128.js b/JSTests/wasm/stress/br-on-cast-overlong-leb128.js
new file mode 100644
index 000000000000..acb7d7784641
--- /dev/null
+++ b/JSTests/wasm/stress/br-on-cast-overlong-leb128.js
@@ -0,0 +1,75 @@
+"use strict";
+// Canonical and overlong LEB128 encodings of a GC sub-opcode must produce
+// identical execution semantics. This test checks that for br_on_cast with
+// FLAGS=0x03 - divergence means IPInt is reading the flags byte from a
+// position the encoding moved.
+
+function uleb(n) { const o=[]; do { let b=n&0x7f; n>>>=7; if(n) b|=0x80; o.push(b); } while(n); return o; }
+function section(id, b) { return [id, ...uleb(b.length), ...b]; }
+function str(s) { return [s.length, ...Array.from(s, c => c.charCodeAt(0))]; }
+
+function buildModule(brOnCast) {
+ const typeSec = [
+ 0x03,
+ 0x5f, 0x00, // type 0: (struct)
+ 0x60, 0x00, 0x00, // type 1: () -> void
+ 0x60, 0x00, 0x01, 0x7f, // type 2: () -> i32
+ ];
+ const funcSec = [0x02, 0x01, 0x02];
+ // global 0: (mut (ref null 0)) init = (struct.new_default 0)
+ const globalSec = [0x01, 0x63, 0x00, 0x01, 0xfb, 0x01, 0x00, 0x0b];
+ const exportSec = [
+ 0x02,
+ ...str("poison"), 0x00, 0x00,
+ ...str("is_poisoned"), 0x00, 0x01,
+ ];
+ const poisonBody = [
+ 0x00,
+ 0x02, 0x63, 0x00, // block (result (ref null 0))
+ 0xd0, 0x6e, // ref.null any
+ ...brOnCast, // br_on_cast 0 (FLAGS=0x03 in either encoding)
+ 0x1a, // drop
+ 0xfb, 0x01, 0x00, // struct.new_default 0 (only reached if cast missed)
+ 0x0c, 0x00, // br 0
+ 0x0b,
+ 0x24, 0x00, // global.set 0
+ 0x0b,
+ ];
+ const checkBody = [
+ 0x00,
+ 0x23, 0x00, // global.get 0
+ 0xd1, // ref.is_null
+ 0x0b,
+ ];
+ const codeSec = [
+ 0x02,
+ ...uleb(poisonBody.length), ...poisonBody,
+ ...uleb(checkBody.length), ...checkBody,
+ ];
+ return new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+ ...section(1, typeSec),
+ ...section(3, funcSec),
+ ...section(6, globalSec),
+ ...section(7, exportSec),
+ ...section(10, codeSec),
+ ]);
+}
+
+// prefix sub-op flags label ht1 ht2
+const CANONICAL = [0xfb, 0x18, 0x03, 0x00, 0x6e, 0x00];
+const OVERLONG = [0xfb, 0x98,0x80,0x00, 0x03, 0x00, 0x6e, 0x00];
+
+const a = new WebAssembly.Instance(new WebAssembly.Module(buildModule(CANONICAL)));
+const b = new WebAssembly.Instance(new WebAssembly.Module(buildModule(OVERLONG)));
+
+a.exports.poison();
+b.exports.poison();
+
+const rA = a.exports.is_poisoned();
+const rB = b.exports.is_poisoned();
+
+if (rA !== rB)
+ throw new Error("LEB encoding of GC sub-opcode affects br_on_cast semantics: "
+ + "canonical=" + rA + " overlong=" + rB
+ + " — IPInt reads flags from PC at hardcoded offset");