High firefox UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionUse-after-free in the JavaScript Engine component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2068395
Fix commit40efda3cb054 (firefox) +82/-27
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

Sparse vs. dense indexed properties
Integer-keyed properties can live either as fast dense elements or as sparse named properties. After a sparse indexed property is added, SpiderMonkey may densify the sparse elements back into the dense representation via NativeObject::maybeDensifySparseElements.
`maybeDensifySparseElements`
A fallible operation (returns DenseElementResult::{Success, Failure, Incomplete}): it can allocate (and thus OOM / GC) while converting sparse named properties into a dense elements array, rewriting the object’s storage layout.
Add-property hook
Objects with an addProperty class hook (and arrays) need the hook invoked when a property is added. The old code had a dense-specific variant, CallAddPropertyHookDense, used only on the early-return densify path.
Slot span
obj->slotSpan() is the number of occupied slots; densification is only attempted when the new property landed in the last slot (slot == slotSpan() - 1), a perf guard from bug 1782487.

Root Cause Analysis

The old AddOrChangeProperty interleaved a fallible densification before the normal add-property hook and short-circuited on success. Concretely, right after installing the sparse indexed property it ran maybeDensifySparseElements, and on DenseElementResult::Success it called CallAddPropertyHookDense(cx, obj, index, desc.value()) and returned true immediately. This put a layout-rewriting, allocation-performing step (densification) in the middle of property definition, and ran the add-property hook from two different places depending on whether densification happened. That ordering is the defect: densification reallocates/relocates the object’s element and slot storage, and the subsequent hook (or, on the non-collapsed path, the rest of the function) could then operate against now-stale assumptions, while an OOM/GC inside the fallible densify left the just-added property and the element/slot fields only partially synchronized. The regression test densify-sparse-oom.js drives exactly this: it shapes an array so adding index 10 uses the last slot and makes the slot span a power of two (so densification triggers), then forces oomAtAllocation during the Object.defineProperty(a, 10, …) add and checks length consistency and verifyprebarriers() afterward — i.e. it targets the inconsistent-state / barrier window around densification.

The patch reorders the operation so densification is the last thing done, after the add-property hook and after the rest of the property installation. It deletes the in-place densify-and-early-return block (and the CallAddPropertyHookDense special case), and adds, near the end, a single guarded call: if (id.isInt() && slot == obj->slotSpan() - 1) { edResult = NativeObject::maybeDensifySparseElements(cx, obj); if (Failure) return false; } with MOZ_ASSERT_IF(Success, !desc.isAccessorDescriptor()). The new comment states it plainly: “Investigate converting the object to dense indexes. This is fallible so we do this after the add-property hook.” By moving the only fallible/reentrant step to the end and funneling the add-property hook through the single normal path, the object is fully consistent before densification runs, so an OOM there can no longer leave stale element/slot state. The advisory labels this a use-after-free in the JS engine; the diff substantiates a state-synchronization defect around a fallible densification whose failure/reentrancy window is the UAF source (the exact freed-object reuse is below the diff and is (inferred)).

Key insight
A fallible, storage-rewriting densification (maybeDensifySparseElements) ran in the middle of property definition — before the add-property hook and with a success-only early return — so an OOM/GC during densification, or the hook running against relocated storage, left the object’s element/slot fields and the just-added property out of sync; the fix defers densification to after the hook so it is the last, consistency-preserving step.

Attack Path

  1. Shape a densifiable sparse object Script builds an array/object whose sparse indexed properties are arranged (as in makeSparse()) so adding a specific index lands in the last slot and the slot span becomes a power of two, satisfying maybeDensifySparseElements’s trigger.
  2. Define the triggering property Object.defineProperty(a, 10, desc) enters AddOrChangeProperty on the Add path, installing the sparse property and reaching the densify step.
  3. Induce failure/reentrancy mid-densify Apply memory pressure (modeled by oomAtAllocation(n)) so maybeDensifySparseElements allocates and fails/GCs while the object’s element and slot fields are mid-update.
  4. Observe inconsistent state With the old ordering the early-return/hook path leaves the dense-element and slot-span state inconsistent with the installed property (the test asserts on a.length, Object.hasOwn(a, 10) and runs verifyprebarriers() to catch barrier violations).
  5. Primitive The stale/partially-synchronized element state yields a dangling reference to a GC thing (use-after-free) in the content process (inferred: concrete reuse depends on heap/GC timing).

Impact Assessment

The code path is reachable from unprivileged JavaScript in the content (renderer) process through ordinary property definition (obj[i] = … / Object.defineProperty) on objects that transition between sparse and dense indexed storage; no special privilege is required. Triggering is gated on the densification heuristic (property in the last slot, power-of-two slot span) and on a failure/GC occurring inside the fallible densify step — a timing/OOM-sensitive window an attacker presses on with memory pressure and repeated definitions. The advisory classifies the outcome as a use-after-free; the diff substantiates a state-synchronization/ordering defect around a fallible operation, with the realized dangling-pointer primitive consistent with that class but with the precise freed object not shown by the diff (inferred). Such a UAF is a strong foundation for renderer compromise.

Changed Functions

FunctionChangeNotes
if
js/src/vm/NativeObject.cpp
modified
for
js/src/jit-test/tests/arrays/densify-sparse-oom.js
modified
if
js/src/jit-test/tests/arrays/densify-sparse-oom.js
modified

Files Changed

  • js/src/jit-test/tests/arrays/densify-sparse-oom.js
  • js/src/vm/NativeObject.cpp

Audit Directions

  • Fallible steps mid-mutation
    Audit NativeObject property/element mutators for other fallible or hook-invoking calls (maybeDensifySparseElements, capacity/slot growth, CallAddPropertyHook*) placed in the middle of a multi-step update rather than at the end, where OOM/GC could leave fields unsynchronized.
  • Sparse↔dense transitions
    Review every densify/sparsify site (maybeDensifySparseElements, NativeObject::sparsifyDenseElements, setDenseInitializedLength) for invariants that must hold before vs. after a reallocation and for early-return paths that duplicate hook invocation.
  • Add-property hook ordering
    Confirm all callers now invoke the add-property hook exactly once on a single path, since this patch removed the dense-specific duplicate; look for other places that branch the hook by representation.
  • OOM/barrier regression coverage
    Use the oomAtAllocation + verifyprebarriers test style to probe other element-storage transitions for inconsistent-state windows.
diff --git a/js/src/vm/NativeObject.cpp b/js/src/vm/NativeObject.cpp
index 66fab363c7b..909657bffef 100644
--- a/js/src/vm/NativeObject.cpp
+++ b/js/src/vm/NativeObject.cpp
@@ -1453,36 +1453,12 @@ static MOZ_ALWAYS_INLINE bool AddOrChangeProperty(
 
   MOZ_ASSERT(slot < obj->slotSpan());
 
-  // Clear any existing dense index after adding a sparse indexed property,
-  // and investigate converting the object to dense indexes.
+  // Clear any existing dense index after adding a sparse indexed property.
   if (id.isInt()) {
-    uint32_t index = id.toInt();
     if constexpr (AddOrChange == IsAddOrChange::Add) {
-      MOZ_ASSERT(!obj->containsDenseElement(index));
+      MOZ_ASSERT(!obj->containsDenseElement(id.toInt()));
     } else {
-      obj->removeDenseElementForSparseIndex(index);
-    }
-    // Only try to densify sparse elements if the property we just added/changed
-    // is in the last slot. This avoids a perf cliff in pathological cases: in
-    // maybeDensifySparseElements we densify if the slot span is a power-of-two,
-    // but if we get slots from the free list, the slot span will stay the same
-    // until the free list is empty. This means we'd get quadratic behavior by
-    // trying to densify for each sparse element we add. See bug 1782487.
-    if (slot == obj->slotSpan() - 1) {
-      DenseElementResult edResult =
-          NativeObject::maybeDensifySparseElements(cx, obj);
-      if (edResult == DenseElementResult::Failure) {
-        return false;
-      }
-      if (edResult == DenseElementResult::Success) {
-        MOZ_ASSERT(!desc.isAccessorDescriptor());
-        if constexpr (AddOrChange == IsAddOrChange::Add) {
-          if (!CallAddPropertyHookDense(cx, obj, index, desc.value())) {
-            return false;
-          }
-        }
-        return true;
-      }
+      obj->removeDenseElementForSparseIndex(id.toInt());
     }
   }
 
@@ -1498,6 +1474,25 @@ static MOZ_ALWAYS_INLINE bool AddOrChangeProperty(
     }
   }
 
+  // Investigate converting the object to dense indexes. This is fallible so we
+  // do this after the add-property hook.
+  //
+  // Only try to densify sparse elements if the property we just added/changed
+  // is in the last slot. This avoids a perf cliff in pathological cases: in
+  // maybeDensifySparseElements we densify if the slot span is a power-of-two,
+  // but if we get slots from the free list, the slot span will stay the same
+  // until the free list is empty. This means we'd get quadratic behavior by
+  // trying to densify for each sparse element we add. See bug 1782487.
+  if (id.isInt() && slot == obj->slotSpan() - 1) {
+    DenseElementResult edResult =
+        NativeObject::maybeDensifySparseElements(cx, obj);
+    if (edResult == DenseElementResult::Failure) {
+      return false;
+    }
+    MOZ_ASSERT_IF(edResult == DenseElementResult::Success,
+                  !desc.isAccessorDescriptor());
+  }
+
   return true;
 }
 
diff --git a/js/src/jit-test/tests/arrays/densify-sparse-oom.js b/js/src/jit-test/tests/arrays/densify-sparse-oom.js
new file mode 100644
index 00000000000..8571d39d5c8
--- /dev/null
+++ b/js/src/jit-test/tests/arrays/densify-sparse-oom.js
@@ -0,0 +1,60 @@
+// |jit-test| skip-if: !hasFunction.oomAtAllocation
+
+gczeal(0);
+
+// Arrange for adding index 10 to use the last property slot and make the slot
+// span a power of two, so the sparse elements can be densified.
+function makeSparse() {
+  let a = [];
+  Object.defineProperty(a, 0, {
+    value: 0, writable: true, configurable: true, enumerable: false
+  });
+  a.x = 0;
+  a.y = 0;
+  delete a.x;
+  a.x = 0;
+  for (let i = 6; i < 10; i++) {
+    a[i] = i;
+  }
+  a.z = 0;
+  delete a.x;
+  a.w = 0;
+  delete a.z;
+  Object.defineProperty(a, 0, {enumerable: true});
+  return a;
+}
+
+const desc = {
+  value: {}, writable: true, configurable: true, enumerable: true
+};
+function add(a) {
+  Object.defineProperty(a, 10, desc);
+}
+
+add(makeSparse());
+for (let n = 1; ; n++) {
+  let a = makeSparse();
+  oomAtAllocation(n);
+  try {
+    add(a);
+  } catch (e) {
+  }
+  let hadOOM = resetOOMFailure();
+
+  assertEq(a.length, Object.hasOwn(a, 10) ? 11 : 10);
+
+  Object.defineProperty(a, 10, {
+    value: {}, writable: true, configurable: true, enumerable: true
+  });
+  assertEq(a.length, 11);
+
+  verifyprebarriers();
+  a.length = a.length;
+  a.length = 10;
+  verifyprebarriers();
+  assertEq(Object.hasOwn(a, 10), false);
+
+  if (!hadOOM) {
+    break;
+  }
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/arrays/densify-sparse-oom.js b/js/src/jit-test/tests/arrays/densify-sparse-oom.js
new file mode 100644
index 00000000000..8571d39d5c8
--- /dev/null
+++ b/js/src/jit-test/tests/arrays/densify-sparse-oom.js
@@ -0,0 +1,60 @@
+// |jit-test| skip-if: !hasFunction.oomAtAllocation
+
+gczeal(0);
+
+// Arrange for adding index 10 to use the last property slot and make the slot
+// span a power of two, so the sparse elements can be densified.
+function makeSparse() {
+  let a = [];
+  Object.defineProperty(a, 0, {
+    value: 0, writable: true, configurable: true, enumerable: false
+  });
+  a.x = 0;
+  a.y = 0;
+  delete a.x;
+  a.x = 0;
+  for (let i = 6; i < 10; i++) {
+    a[i] = i;
+  }
+  a.z = 0;
+  delete a.x;
+  a.w = 0;
+  delete a.z;
+  Object.defineProperty(a, 0, {enumerable: true});
+  return a;
+}
+
+const desc = {
+  value: {}, writable: true, configurable: true, enumerable: true
+};
+function add(a) {
+  Object.defineProperty(a, 10, desc);
+}
+
+add(makeSparse());
+for (let n = 1; ; n++) {
+  let a = makeSparse();
+  oomAtAllocation(n);
+  try {
+    add(a);
+  } catch (e) {
+  }
+  let hadOOM = resetOOMFailure();
+
+  assertEq(a.length, Object.hasOwn(a, 10) ? 11 : 10);
+
+  Object.defineProperty(a, 10, {
+    value: {}, writable: true, configurable: true, enumerable: true
+  });
+  assertEq(a.length, 11);
+
+  verifyprebarriers();
+  a.length = a.length;
+  a.length = 10;
+  verifyprebarriers();
+  assertEq(Object.hasOwn(a, 10), false);
+
+  if (!hadOOM) {
+    break;
+  }
+}
Loading diff…