CVE-2026-86922
Overview
Background
- B3 / `B3ReduceStrength`
- JSC’s low-level optimizing IR and its strength-reduction pass (
Source/JavaScriptCore/b3/B3ReduceStrength.cpp). It constant-folds and simplifies IR nodes, including WebAssembly GC cast operations lowered into B3. - WasmRefCast / WasmRefTest
- WebAssembly GC reference cast operations.
ref.test/br_on_cast*test whether a reference’s runtime type (RTT) is a subtype of a target type;ref.castasserts it. The IR node carries atargetRTT(), atargetHeapType(), and ashouldNegate()flag. - `shouldNegate()`
- Set for the negated form of the test (e.g.
br_on_cast_fail, which branches when the cast would fail). It inverts the boolean meaning of the subtype check. - `isSubRTT` / RTT
rtt->isSubRTT(*targetRTT)returns whether the value’s runtime type is a subtype of the target — i.e., whether the cast succeeds.
Root Cause Analysis
When B3ReduceStrength could resolve a cast’s subtype relationship at compile time (the non-typeIndexIsType branch at B3ReduceStrength.cpp:~3671), it folded the result to a constant with replaceWithNewValue(m_proc.addIntConstant(m_value, !!rtt->isSubRTT(*targetRTT))). This used the raw subtype result and ignored cast->shouldNegate(). For a negated cast such as br_on_cast_fail, the correct folded value is the inverse of the subtype test; by folding to the un-negated isSubRTT result, the optimizer produced a constant with inverted polarity. The branch/select driven by that constant then takes the wrong path: a reference that actually fails the cast is treated as having passed (or vice versa), so downstream code uses the reference as the cast-target struct type when it is in fact a different, incompatible type — a type confusion between WebAssembly GC struct types.
The patch computes const bool isSubtype = rtt->isSubRTT(*targetRTT); and folds to cast->shouldNegate() ? !isSubtype : isSubtype, honoring the negation flag so the constant matches the operation’s true semantics. The companion hunk at ~3604 (the structNew identity-replacement branch for a succeeding subtype) adds ASSERT(!cast->shouldNegate()), documenting/enforcing that this success-fold path is only valid for the non-negated WasmRefTest form and that a negated br_on_cast_fail must not reach it. The regression test (JSTests/wasm/gc/br_on_cast_fail.js) exports fail()/pass() built on br_on_cast_fail and runs them 200000 times to force tier-up into B3, then checks both return the correct values — failing if the folded polarity is wrong.
isSubRTT() directly and dropped the shouldNegate() flag, so negated casts (br_on_cast_fail) folded to the inverted boolean — turning a failed cast into an apparent success and confusing one GC struct type for another.Attack Path
- Craft a wasm GC module
Attacker builds a module with two struct types (e.g. a small
struct {i32}and a largerstruct {i32,i64,i64}) and a function usingbr_on_cast_failto cast between them. - Force optimization The function is called in a hot loop (the test uses 200000 iterations) so JSC tiers it up through B3, where the faulty fold applies.
- Fold inverts the test
B3ReduceStrengthconstant-folds the negated cast to the un-negatedisSubRTTresult, so the “cast failed” branch is taken as if it succeeded. - Type-confused access Code on the taken path treats the reference as the cast-target struct type, accessing fields/layout that do not match the object’s real type.
- Memory corruption → ACE Field reads/writes against the wrong struct layout give an out-of-bounds / type-confused access primitive; escalation to arbitrary code execution is the advisory’s stated impact (the controlled-write chain itself is inferred, not shown by the diff).
Impact Assessment
br_on_cast_fail / negated ref.test).Changed Functions
| Function | Change | Notes |
|---|---|---|
forJSTests/wasm/gc/br_on_cast_fail.js |
modified |
Files Changed
JSTests/wasm/gc/br_on_cast_fail.jsSource/JavaScriptCore/b3/B3ReduceStrength.cpp
Audit Directions
- Other `shouldNegate()`-carrying nodesAudit every B3/Air and DFG/FTL fold or lowering of cast/test nodes to confirm the negation flag is applied on all paths (constant-fold, identity, select lowering), not just some.
- Wasm GC cast lowering across tiersCompare BBQ/OMG and B3 handling of
ref.test/ref.cast/br_on_cast(_fail)for consistent polarity, especially where a subtype result is cached or reused. - Constant-folding of boolean predicatesLook for
!!predicatefolds elsewhere that drop a modifier flag (negate/strict/invert) carried on the IR node.
Patch
diff --git a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
index 0395c30e8395..65201a94a1fc 100644
--- a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
+++ b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
@@ -3604,6 +3604,8 @@ private:
SUPPRESS_UNCOUNTED_LOCAL const Wasm::RTT* targetRTT = cast->targetRTT();
if (!Wasm::typeIndexIsType(static_cast<Wasm::TypeIndex>(toHeapType))) {
if (rtt->isSubRTT(*targetRTT)) {
+ // shouldNegate can only be set on WasmRefTest.
+ ASSERT(!cast->shouldNegate());
replaceWithIdentity(structNew);
break;
}
@@ -3669,7 +3671,8 @@ private:
int32_t toHeapType = cast->targetHeapType();
SUPPRESS_UNCOUNTED_LOCAL const Wasm::RTT* targetRTT = cast->targetRTT();
if (!Wasm::typeIndexIsType(static_cast<Wasm::TypeIndex>(toHeapType))) {
- replaceWithNewValue(m_proc.addIntConstant(m_value, !!rtt->isSubRTT(*targetRTT)));
+ const bool isSubtype = rtt->isSubRTT(*targetRTT);
+ replaceWithNewValue(m_proc.addIntConstant(m_value, cast->shouldNegate() ? !isSubtype : isSubtype));
break;
}
}
diff --git a/JSTests/wasm/gc/br_on_cast_fail.js b/JSTests/wasm/gc/br_on_cast_fail.js
new file mode 100644
index 000000000000..7f764136334f
--- /dev/null
+++ b/JSTests/wasm/gc/br_on_cast_fail.js
@@ -0,0 +1,48 @@
+// (module
+// (rec
+// (type (;0;) (struct (field (mut i32))))
+// (type (;1;) (struct (field (mut i32)) (field (mut i64)) (field (mut i64))))
+// )
+// (type (;2;) (func (result (ref struct))))
+// (type (;3;) (func (result i32)))
+// (export "fail" (func 0))
+// (export "pass" (func 1))
+// (func (;0;) (type 3) (result i32)
+// block (type 2) (result (ref struct)) ;; label = @1
+// i32.const 42
+// struct.new 0
+// br_on_cast_fail 0 (;@1;) (ref struct) (ref 1)
+// drop
+// i32.const 0
+// return
+// end
+// drop
+// i32.const 1
+// )
+// (func (;1;) (type 3) (result i32)
+// block (type 2) (result (ref struct)) ;; label = @1
+// i32.const 99
+// i64.const 0
+// i64.const 0
+// struct.new 1
+// br_on_cast_fail 0 (;@1;) (ref struct) (ref 1)
+// drop
+// i32.const 1
+// return
+// end
+// drop
+// i32.const 0
+// )
+// )
+
+const bytes = new Uint8Array([0,97,115,109,1,0,0,0,1,28,3,78,2,79,0,95,1,127,1,79,0,95,3,127,1,126,1,126,1,96,0,1,100,107,96,0,1,127,3,3,2,3,3,7,15,2,4,102,97,105,108,0,0,4,112,97,115,115,0,1,10,54,2,23,0,2,2,65,42,251,0,0,251,25,0,0,107,1,26,65,0,15,11,26,65,1,11,28,0,2,2,65,227,0,66,0,66,0,251,0,1,251,25,0,0,107,1,26,65,1,15,11,26,65,0,11]);
+const mod = new WebAssembly.Module(bytes);
+const { pass, fail } = new WebAssembly.Instance(mod).exports;
+
+for (let i = 0; i < 200000; i++) {
+ fail();
+ pass();
+}
+
+if (fail() === 0 || pass() === 0)
+ throw new Error("br_cast_on_fail incorrect");
Regression Test / PoC
diff --git a/JSTests/wasm/gc/br_on_cast_fail.js b/JSTests/wasm/gc/br_on_cast_fail.js
new file mode 100644
index 000000000000..7f764136334f
--- /dev/null
+++ b/JSTests/wasm/gc/br_on_cast_fail.js
@@ -0,0 +1,48 @@
+// (module
+// (rec
+// (type (;0;) (struct (field (mut i32))))
+// (type (;1;) (struct (field (mut i32)) (field (mut i64)) (field (mut i64))))
+// )
+// (type (;2;) (func (result (ref struct))))
+// (type (;3;) (func (result i32)))
+// (export "fail" (func 0))
+// (export "pass" (func 1))
+// (func (;0;) (type 3) (result i32)
+// block (type 2) (result (ref struct)) ;; label = @1
+// i32.const 42
+// struct.new 0
+// br_on_cast_fail 0 (;@1;) (ref struct) (ref 1)
+// drop
+// i32.const 0
+// return
+// end
+// drop
+// i32.const 1
+// )
+// (func (;1;) (type 3) (result i32)
+// block (type 2) (result (ref struct)) ;; label = @1
+// i32.const 99
+// i64.const 0
+// i64.const 0
+// struct.new 1
+// br_on_cast_fail 0 (;@1;) (ref struct) (ref 1)
+// drop
+// i32.const 1
+// return
+// end
+// drop
+// i32.const 0
+// )
+// )
+
+const bytes = new Uint8Array([0,97,115,109,1,0,0,0,1,28,3,78,2,79,0,95,1,127,1,79,0,95,3,127,1,126,1,126,1,96,0,1,100,107,96,0,1,127,3,3,2,3,3,7,15,2,4,102,97,105,108,0,0,4,112,97,115,115,0,1,10,54,2,23,0,2,2,65,42,251,0,0,251,25,0,0,107,1,26,65,0,15,11,26,65,1,11,28,0,2,2,65,227,0,66,0,66,0,251,0,1,251,25,0,0,107,1,26,65,1,15,11,26,65,0,11]);
+const mod = new WebAssembly.Module(bytes);
+const { pass, fail } = new WebAssembly.Instance(mod).exports;
+
+for (let i = 0; i < 200000; i++) {
+ fail();
+ pass();
+}
+
+if (fail() === 0 || pass() === 0)
+ throw new Error("br_cast_on_fail incorrect");