CVE-2026-117872
Overview
Background
- Dense elements, `length`, and initialized length
- A dense array keeps an
ObjectElementsheader withlength(the JS-visible array length) andinitializedLength(how many dense slots actually hold liveValues). The GC traces exactlyinitializedLengthslots. A core invariant isinitializedLength <= length. - Fallible vs. infallible regions
- The truncation code has a boundary: after a certain point “all operations … must be infallible, so that all element fields remain properly synchronized.” Fallible operations (those that can OOM or run hooks/GC) must leave the element fields in a consistent state before they run.
- `setLength` / `setDenseInitializedLengthMaybeNonExtensible`
setLengthwrites the new header length;setDenseInitializedLengthMaybeNonExtensiblelowers the initialized length (with pre-barriers) while respecting non-extensible arrays.
Root Cause Analysis
The trimming of initializedLength to min(initializedLength, newLen) originally lived below arr->setLength(cx, newLen), in the region that also performed a potentially fallible attribute change (setNonWritableLength and surrounding work). So the sequence was: reduce the visible length to newLen, then — only later, after a fallible step — bring initializedLength down to newLen (and shrinkCapacityToInitializedLength). Between those two points the array had length == newLen but initializedLength still at its old, larger value, violating initializedLength <= length. If the intervening fallible operation triggered an OOM/GC (the regression test set-length-oom.js forces exactly this with oomAtAllocation), the GC would trace initializedLength dense slots — including slots in [newLen, oldInitializedLength) that the truncation logically deleted — so stale Values pointing at already-freed or soon-reclaimed GC things would be traced/retained, and the pre-barrier accounting for those slots was inconsistent. That is the use-after-free the advisory describes.
The patch hoists the trim to run before the fallible attribute change: immediately after the deletion loop it calls arr->setDenseInitializedLengthMaybeNonExtensible(cx, min(getDenseInitializedLength(), newLen)), then setLength(cx, newLen). It also removes the old late trim and the shrinkCapacityToInitializedLength call (capacity was already reduced during element deletion), and hardens the invariant with MOZ_ASSERT(getDenseInitializedLength() <= length) inside ArrayObject::setLength. Now the initialized length is lowered — with correct pre-barriers on the discarded slots — before any step that can fail or reenter, so the initializedLength <= length invariant holds continuously and the GC never traces logically deleted slots.
initializedLength > length; an OOM/GC in that window let the collector trace already-deleted dense slots holding stale Values, producing a use-after-free.Attack Path
- Build a truncatable dense array
Script creates a dense array with many initialized elements and arranges the shape so
lengthis a custom, non-last property (the test’smake()witha.extra = 0). - Enter the generic deletion path
Redefine
lengthto a smaller value while an active enumeration forces the generic element-deletion path (truncate()defineslengthto{value:16, writable:false}inside afor..in). - Force allocation failure mid-operation
Using memory pressure (the shell models this with
oomAtAllocation), cause the fallible attribute step aftersetLengthto fail whileinitializedLengthis still larger than the newlength. - Trigger tracing of stale slots
A GC/pre-barrier pass observes
initializedLength > lengthand traces dense slots in[newLen, oldInit)whoseValues refer to GC things that truncation intended to drop. - Primitive A use-after-free / dangling reference to a GC thing in the content process, groomable into a stronger exploitation primitive (inferred).
Impact Assessment
length truncation on a dense array; no elevated privilege is needed. The trigger is gated on hitting the fallible post-setLength step while an allocation failure/GC occurs in the narrow window where initializedLength > length — a timing/OOM-sensitive condition, but one an attacker can press on with memory pressure and repeated truncations. The resulting primitive is a use-after-free of a GC-managed Value/object (the stale dense slots), i.e. a dangling pointer suitable as the foundation of a renderer compromise; full control is not demonstrated by the diff and is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forjs/src/jit-test/tests/arrays/set-length-oom.js |
modified | |
ifjs/src/jit-test/tests/arrays/set-length-oom.js |
modified |
Files Changed
js/src/builtin/Array.cppjs/src/jit-test/tests/arrays/set-length-oom.jsjs/src/vm/ArrayObject.h
Audit Directions
- Invariant across fallible callsAudit other dense-element mutators (splice/shift/
setLengthvariants,shrinkElements, deletion helpers) for places that changelengthandinitializedLengthacross a fallible/reentrant step, leavinginitializedLength > lengthtransiently. - GC tracing assumptionsReview code that trusts
initializedLengthfor tracing/barriers to confirm callers always trim it before any operation that can GC or OOM. - "must be infallible" regionsGrep
js/src/builtin/Array.cppandNativeObjectelement code for the “must be infallible” comment pattern and verify nothing fallible slipped above the field-synchronization point. - Non-extensible / shrinkCapacity pathsCheck that removing
shrinkCapacityToInitializedLengthhere did not shift a capacity-vs-initialized-length assumption onto callers that still expect capacity trimming.
Patch
diff --git a/js/src/builtin/Array.cpp b/js/src/builtin/Array.cpp
index 9d6722fe939..2c68b8d3066 100644
--- a/js/src/builtin/Array.cpp
+++ b/js/src/builtin/Array.cpp
@@ -907,6 +907,12 @@ bool js::ArraySetLength(JSContext* cx, Handle<ArrayObject*> arr, HandleId id,
}
} while (false);
+ // Trim the initialized length, if needed, to preserve the <= length
+ // invariant. This must happen before the fallible attribute change below.
+ // (Capacity was already reduced during element deletion, if necessary.)
+ arr->setDenseInitializedLengthMaybeNonExtensible(
+ cx, std::min(arr->getDenseInitializedLength(), newLen));
+
// Update array length. Technically we should have been doing this
// throughout the loop, in step 19.d.iii.
arr->setLength(cx, newLen);
@@ -926,16 +932,6 @@ bool js::ArraySetLength(JSContext* cx, Handle<ArrayObject*> arr, HandleId id,
// All operations past here until the |!succeeded| code must be infallible,
// so that all element fields remain properly synchronized.
- // Trim the initialized length, if needed, to preserve the <= length
- // invariant. (Capacity was already reduced during element deletion, if
- // necessary.)
- ObjectElements* header = arr->getElementsHeader();
- header->initializedLength = std::min(header->initializedLength.get(), newLen);
-
- if (!arr->isExtensible()) {
- arr->shrinkCapacityToInitializedLength(cx);
- }
-
if (desc.hasWritable() && !desc.writable()) {
arr->setNonWritableLength(cx);
}
diff --git a/js/src/vm/ArrayObject.h b/js/src/vm/ArrayObject.h
index ce8c3971681..5f5ba9ef0de 100644
--- a/js/src/vm/ArrayObject.h
+++ b/js/src/vm/ArrayObject.h
@@ -55,6 +55,7 @@ class ArrayObject : public NativeObject {
.hasSeenArrayExceedsInt32LengthFuse.popFuse(cx);
}
getElementsHeader()->length = length;
+ MOZ_ASSERT(getDenseInitializedLength() <= length);
}
void assertInt32LengthFuse(JSContext* cx) {
diff --git a/js/src/jit-test/tests/arrays/set-length-oom.js b/js/src/jit-test/tests/arrays/set-length-oom.js
new file mode 100644
index 00000000000..4b856da6915
--- /dev/null
+++ b/js/src/jit-test/tests/arrays/set-length-oom.js
@@ -0,0 +1,63 @@
+// |jit-test| skip-if: !hasFunction.oomAtAllocation
+
+gczeal(0);
+
+function put(a, i, v) {
+ a[i] = v;
+}
+function make() {
+ let a = new Array(32).fill(1);
+ // Make the custom length property non-last in the shape.
+ a.extra = 0;
+ return a;
+}
+const desc = {value: 16, writable: false};
+function truncate(a) {
+ // Keep an iterator active to use the generic element-deletion path.
+ for (let key in a) {
+ Object.defineProperty(a, "length", desc);
+ break;
+ }
+}
+
+// Attach an in-bounds dense-hole store IC for arrays with this shape.
+let donor = make();
+for (let i = 0; i < 100; i++) {
+ delete donor[0];
+ put(donor, 0, 1);
+}
+truncate(make());
+
+let arrays = [];
+for (let n = 1; ; n++) {
+ let a = make();
+ oomAtAllocation(n);
+ try {
+ truncate(a);
+ } catch (e) {
+ }
+ let hadOOM = resetOOMFailure();
+
+ if (Object.getOwnPropertyDescriptor(a, "length").writable) {
+ put(a, 31, {});
+ assertEq(a.length, 32);
+ arrays.push(a);
+ } else {
+ assertEq(a.length, 16);
+ assertEq(31 in a, false);
+ }
+
+ if (!hadOOM) {
+ break;
+ }
+}
+
+verifyprebarriers();
+for (let a of arrays) {
+ a.length = a.length;
+ a.length = 16;
+}
+verifyprebarriers();
+for (let a of arrays) {
+ assertEq(31 in a, false);
+}
Regression Test / PoC
diff --git a/js/src/jit-test/tests/arrays/set-length-oom.js b/js/src/jit-test/tests/arrays/set-length-oom.js
new file mode 100644
index 00000000000..4b856da6915
--- /dev/null
+++ b/js/src/jit-test/tests/arrays/set-length-oom.js
@@ -0,0 +1,63 @@
+// |jit-test| skip-if: !hasFunction.oomAtAllocation
+
+gczeal(0);
+
+function put(a, i, v) {
+ a[i] = v;
+}
+function make() {
+ let a = new Array(32).fill(1);
+ // Make the custom length property non-last in the shape.
+ a.extra = 0;
+ return a;
+}
+const desc = {value: 16, writable: false};
+function truncate(a) {
+ // Keep an iterator active to use the generic element-deletion path.
+ for (let key in a) {
+ Object.defineProperty(a, "length", desc);
+ break;
+ }
+}
+
+// Attach an in-bounds dense-hole store IC for arrays with this shape.
+let donor = make();
+for (let i = 0; i < 100; i++) {
+ delete donor[0];
+ put(donor, 0, 1);
+}
+truncate(make());
+
+let arrays = [];
+for (let n = 1; ; n++) {
+ let a = make();
+ oomAtAllocation(n);
+ try {
+ truncate(a);
+ } catch (e) {
+ }
+ let hadOOM = resetOOMFailure();
+
+ if (Object.getOwnPropertyDescriptor(a, "length").writable) {
+ put(a, 31, {});
+ assertEq(a.length, 32);
+ arrays.push(a);
+ } else {
+ assertEq(a.length, 16);
+ assertEq(31 in a, false);
+ }
+
+ if (!hadOOM) {
+ break;
+ }
+}
+
+verifyprebarriers();
+for (let a of arrays) {
+ a.length = a.length;
+ a.length = 16;
+}
+verifyprebarriers();
+for (let a of arrays) {
+ assertEq(31 in a, false);
+}