Moderate firefox UAF 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionUse-after-free in the JavaScript: GC component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2080787
Fix commitf46522623831 (firefox) +29/-2
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

TenuringTracer
the SpiderMonkey GC helper (js/src/gc/Tenuring.cpp) that, during a minor GC, moves (“promotes”) live nursery objects out of the collected nursery region, either into the tenured heap or, under semispace nursery collection, into the surviving nursery region.
Dependent string / root base
a dependent string stores no characters of its own; it references a range inside a base string. rootBaseDuringMinorGC() walks to the non-dependent root base, which itself cannot be dependent (bounding the recursion to one level).
`promotedToNursery`
a TenuringTracer member flag that records, for the object currently being promoted, whether it was relocated into the surviving nursery region (semispace) rather than tenured. The caller consults this flag to update remembered-set / store-buffer bookkeeping for the promoted object.
Semispace nursery
a nursery-collection mode (--gc-param=semispaceNurseryEnabled=1, used by the regression test) where survivors are copied to a second nursery region instead of being tenured; correct per-object promotion tracking is essential here.

Root Cause Analysis

In js::gc::TenuringTracer::promoteString, promoting a dependent string requires first promoting its root base via InCollectedNurseryRegion(base) ? promoteOrForward(base) : base. The promoteOrForward call recurses through the generic promotion machinery, and that nested promotion of the base overwrites the tracer’s promotedToNursery member to reflect where the base ended up. When control returns to promoteString, the flag no longer describes the dependent string (src/dst) that the outer call is promoting — it has been clobbered with the base’s result. The caller of promoteString then uses this stale promotedToNursery value to decide the dependent string’s remembered-set/store-buffer treatment, so the dependent string can be mis-recorded (e.g. treated as tenured when it actually survived in the nursery region, or vice versa).

The patch saves the flag before the recursive promotion and restores it afterward: bool promotedToNurseryPrev = promotedToNursery; ... promotedToNursery = promotedToNurseryPrev; immediately after computing promotedBase. This guarantees that when promoteString returns, promotedToNursery describes the dependent string, not its base, so the post-GC tracking of the dependent string is correct. A mis-tracked nursery string is not registered for tracing in the next minor GC; its base pointer (or the dependent string itself) is then left dangling across a later collection, which is the use-after-free the advisory describes. The accompanying regression test (bug-2080787.js) exercises exactly this: it builds dependent strings (string concatenations held in a WebAssembly.Table externref slot) and drives repeated semispace minor GCs under allocation pressure.

Key insight
Promoting a dependent string recursively promotes its base, and that recursion overwrote the shared promotedToNursery state; the outer promotion then applied the base’s promotion result to the dependent string, corrupting the GC’s record of where the dependent string lives.

Attack Path

  1. Web content script allocates many short-lived dependent strings, e.g. by concatenating strings so the result is represented as a dependent string borrowing a nursery-allocated base (inferred from the test’s prefix + round + suffix pattern).
  2. Reachability pin a live reference to such a string is parked where it survives a minor GC (the test stores it in a WebAssembly.Table externref slot).
  3. Trigger minor GC sustained allocation pressure forces semispace minor GCs while the dependent string and its base are still in the collected nursery region (inferred: requires semispace nursery, which the test enables explicitly).
  4. State clobber during promotion of the dependent string, promoteOrForward(base) recurses and overwrites promotedToNursery; the dependent string is mis-recorded as to its promotion.
  5. Dangling reference a subsequent minor GC fails to trace the mis-tracked string/base; the character storage is reclaimed or moved while a live reference still points at it.
  6. Primitive use-after-free of a JSString’s backing storage reachable from script (read of freed/reused memory; corruption potential inferred).

Impact Assessment

The bug is in the content JavaScript engine and runs in the renderer process with no special privilege — any web page that can execute JavaScript can reach promoteString. The achieved primitive is a use-after-free of a dependent string’s character storage across a minor GC, giving an attacker reads of reclaimed/reused memory and a foothold for further corruption; full control is not shown by the diff and is (inferred). Gating preconditions: the dependent-string shape and a semispace-nursery minor GC occurring at the right moment. Mozilla rates it moderate; the diff is consistent with a genuine, script-reachable UAF rather than a mere crash.

Changed Functions

FunctionChangeNotes
if
js/src/gc/Tenuring.cpp
modified
for
js/src/jit-test/tests/gc/bug-2080787.js
modified

Files Changed

  • js/src/gc/Tenuring.cpp
  • js/src/jit-test/tests/gc/bug-2080787.js

Audit Directions

  • Shared tracer state across recursion
    audit every site in Tenuring.cpp where a promotion routine recurses (promoteOrForward, promoteObject, rope/atom handling) while relying on a TenuringTracer member such as promotedToNursery; any member read after a recursive promotion is a candidate for the same clobber.
  • Dependent/rope string promotion
    review base-pointer rewiring in updateToPromotedBase/setBase and rope flattening paths for other cases where the base is promoted mid-operation and per-object GC bookkeeping must survive the recursion.
  • Semispace-specific tracking
    because the flag only matters under semispace nursery, search for logic guarded by InCollectedNurseryRegion or semispace checks where survivor-region vs tenured classification feeds the store buffer.
diff --git a/js/src/gc/Tenuring.cpp b/js/src/gc/Tenuring.cpp
index 3bf2d31a9ed..3211dc6220e 100644
--- a/js/src/gc/Tenuring.cpp
+++ b/js/src/gc/Tenuring.cpp
@@ -1294,11 +1294,13 @@ JSString* js::gc::TenuringTracer::promoteString(JSString* src) {
     JSLinearString* base = src->asDependent().rootBaseDuringMinorGC();
 
     // Limited recursion: the root base cannot be dependent, so this will not
-    // recurse again.
+    // recurse again. Preserve the value of |promotedToNursery|.
+    bool promotedToNurseryPrev = promotedToNursery;
     JSString* promotedBase =
         InCollectedNurseryRegion(base) ? promoteOrForward(base) : base;
-    MOZ_ASSERT(!promotedBase->isDependent());
+    promotedToNursery = promotedToNurseryPrev;
 
+    MOZ_ASSERT(!promotedBase->isDependent());
     dst->asDependent().setBase(&promotedBase->asLinear());
     if (base != promotedBase) {
       dst->asDependent().updateToPromotedBase(base);
diff --git a/js/src/jit-test/tests/gc/bug-2080787.js b/js/src/jit-test/tests/gc/bug-2080787.js
new file mode 100644
index 00000000000..7c26a41e675
--- /dev/null
+++ b/js/src/jit-test/tests/gc/bug-2080787.js
@@ -0,0 +1,25 @@
+// |jit-test| --gc-param=semispaceNurseryEnabled=1
+
+const retained = new Array(256);
+const prefix = "a".repeat(701);
+const suffix = "b".repeat(733);
+const extension = "c".repeat(769);
+let serial = 0;
+let checksum = 0;
+function pressure(count) {
+  for (let index = 0; index < count; index++) {
+    retained[index & 255] = {serial: serial++};
+  }
+}
+for (let round = 0; round < 100; round++) {
+  const table = new WebAssembly.Table({element: "externref", initial: 1});
+  const older = prefix + round + suffix;
+  table.set(0, older);
+  const spacing = 2500 + round % 5 * 500;
+  pressure(spacing);
+  checksum ^= (older + extension).toLowerCase().charCodeAt(0);
+  pressure(spacing);
+  table.grow(257);
+  pressure(6000);
+  checksum ^= table.get(0).charCodeAt(0);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/gc/bug-2080787.js b/js/src/jit-test/tests/gc/bug-2080787.js
new file mode 100644
index 00000000000..7c26a41e675
--- /dev/null
+++ b/js/src/jit-test/tests/gc/bug-2080787.js
@@ -0,0 +1,25 @@
+// |jit-test| --gc-param=semispaceNurseryEnabled=1
+
+const retained = new Array(256);
+const prefix = "a".repeat(701);
+const suffix = "b".repeat(733);
+const extension = "c".repeat(769);
+let serial = 0;
+let checksum = 0;
+function pressure(count) {
+  for (let index = 0; index < count; index++) {
+    retained[index & 255] = {serial: serial++};
+  }
+}
+for (let round = 0; round < 100; round++) {
+  const table = new WebAssembly.Table({element: "externref", initial: 1});
+  const older = prefix + round + suffix;
+  table.set(0, older);
+  const spacing = 2500 + round % 5 * 500;
+  pressure(spacing);
+  checksum ^= (older + extension).toLowerCase().charCodeAt(0);
+  pressure(spacing);
+  table.grow(257);
+  pressure(6000);
+  checksum ^= table.get(0).charCodeAt(0);
+}
Loading diff…