CVE-2026-117870
Overview
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
addPropertyclass 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)).
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
- 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, satisfyingmaybeDensifySparseElements’s trigger. - Define the triggering property
Object.defineProperty(a, 10, desc)entersAddOrChangePropertyon theAddpath, installing the sparse property and reaching the densify step. - Induce failure/reentrancy mid-densify
Apply memory pressure (modeled by
oomAtAllocation(n)) somaybeDensifySparseElementsallocates and fails/GCs while the object’s element and slot fields are mid-update. - 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 runsverifyprebarriers()to catch barrier violations). - 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
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
| Function | Change | Notes |
|---|---|---|
ifjs/src/vm/NativeObject.cpp |
modified | |
forjs/src/jit-test/tests/arrays/densify-sparse-oom.js |
modified | |
ifjs/src/jit-test/tests/arrays/densify-sparse-oom.js |
modified |
Files Changed
js/src/jit-test/tests/arrays/densify-sparse-oom.jsjs/src/vm/NativeObject.cpp
Audit Directions
- Fallible steps mid-mutationAudit
NativeObjectproperty/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 transitionsReview 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 orderingConfirm 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 coverageUse the
oomAtAllocation+verifyprebarrierstest style to probe other element-storage transitions for inconsistent-state windows.
Patch
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;
+ }
+}
Regression Test / PoC
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;
+ }
+}