High firefox UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionUse-after-free in the JavaScript: Standard Library component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2068410
Fix commitbf82d965a621 (firefox) +70/-10
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

Dense elements, `length`, and initialized length
A dense array keeps an ObjectElements header with length (the JS-visible array length) and initializedLength (how many dense slots actually hold live Values). The GC traces exactly initializedLength slots. A core invariant is initializedLength <= 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`
setLength writes the new header length; setDenseInitializedLengthMaybeNonExtensible lowers 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.

Key insight
The array’s initialized length was trimmed after a fallible step that followed the length reduction, leaving a window where 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

  1. Build a truncatable dense array Script creates a dense array with many initialized elements and arranges the shape so length is a custom, non-last property (the test’s make() with a.extra = 0).
  2. Enter the generic deletion path Redefine length to a smaller value while an active enumeration forces the generic element-deletion path (truncate() defines length to {value:16, writable:false} inside a for..in).
  3. Force allocation failure mid-operation Using memory pressure (the shell models this with oomAtAllocation), cause the fallible attribute step after setLength to fail while initializedLength is still larger than the new length.
  4. Trigger tracing of stale slots A GC/pre-barrier pass observes initializedLength > length and traces dense slots in [newLen, oldInit) whose Values refer to GC things that truncation intended to drop.
  5. Primitive A use-after-free / dangling reference to a GC thing in the content process, groomable into a stronger exploitation primitive (inferred).

Impact Assessment

The bug is reachable from unprivileged JavaScript in the content (renderer) process via ordinary array 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

FunctionChangeNotes
for
js/src/jit-test/tests/arrays/set-length-oom.js
modified
if
js/src/jit-test/tests/arrays/set-length-oom.js
modified

Files Changed

  • js/src/builtin/Array.cpp
  • js/src/jit-test/tests/arrays/set-length-oom.js
  • js/src/vm/ArrayObject.h

Audit Directions

  • Invariant across fallible calls
    Audit other dense-element mutators (splice/shift/setLength variants, shrinkElements, deletion helpers) for places that change length and initializedLength across a fallible/reentrant step, leaving initializedLength > length transiently.
  • GC tracing assumptions
    Review code that trusts initializedLength for tracing/barriers to confirm callers always trim it before any operation that can GC or OOM.
  • "must be infallible" regions
    Grep js/src/builtin/Array.cpp and NativeObject element code for the “must be infallible” comment pattern and verify nothing fallible slipped above the field-synchronization point.
  • Non-extensible / shrinkCapacity paths
    Check that removing shrinkCapacityToInitializedLength here did not shift a capacity-vs-initialized-length assumption onto callers that still expect capacity trimming.
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);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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);
+}
Loading diff…