High firefox UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionUse-after-free in the JavaScript: GC component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2058653
Fix commita52b040d2697 (firefox) +31/-1
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

Ephemeron edge
In SpiderMonkey’s GC, a WeakMap entry key → value is modeled as an ephemeron edge: the value is only kept alive if the key is reachable. These edges are stored per-key in EphemeronEdgeVectors and replayed during marking by MarkingTracerT<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 if targetColor == 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 WeakMap in one global can hold keys/values that live in another global/zone (the test wires gh’s h2.s to g1’s Symbol). 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).

Key insight
The ephemeron-edge marker checked the color but not the zone of the edge target, so a cross-zone WeakMap value could be traversed in a GC that is not collecting its zone — driving the marker into memory with inconsistent mark/sweep state and breaking the liveness invariant that underpins safe freeing.

Attack Path

  1. 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) with h2.s pointing at a Symbol from a different global).
  2. 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.
  3. Drop strong references All strong references (K1, h1, h2, the Symbol) are nulled so the objects are reachable only through the weak/gray graph.
  4. Drive incremental GC startgc(1) + repeated gcslice runs the incremental marker; markEphemeronEdges replays the cross-zone edge and, lacking the zone check, marks/traverses a target whose zone is not being collected this cycle.
  5. 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

This is a content-process (renderer) memory-safety bug reachable from unprivileged JavaScript via ordinary 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.cpp
  • js/src/jit-test/tests/gc/bug-2058653.js

Audit Directions

  • Other edge replays in the marker
    Review every site in js/src/gc/Marking.cpp that calls markAndTraverse/ApplyGCThingTyped on a stored target (weak edges, weak references, finalization/weakref queues) for a missing shouldMarkInZone check on the target’s zone, not just color.
  • Cross-zone weak containers
    Audit WeakMap/WeakSet/WeakRef/FinalizationRegistry marking 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 collector
    Examine gray-root replay and AutoAssertNoGC/color-matching paths for assumptions that targetColor == markColor() alone implies the target is safe to trace.
  • `shouldMarkInZone` call sites
    Grep for places that gate marking on color but omit a zone predicate, as the pattern fixed here likely recurs in less-exercised tracers.
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);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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);
+}
Loading diff…