Use of freed GC arena in [@ js::gc::ArenaCellSet::trace] via stale whole-cell store buffer entry created by GCRuntime::relocateFinalizationObserverTarget retargeting dead WeakRefs during incremental sweep
Categories
(Core :: JavaScript: GC, defect)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox151 | --- | unaffected |
| firefox152 | --- | unaffected |
| firefox153 | + | fixed |
People
(Reporter: bugmon, Assigned: jonco)
References
(Blocks 1 open bug, Regression)
Details
(6 keywords)
Attachments
(4 files, 2 external files)
When JS_TransplantObject transplants an object that is registered as a WeakRef/FinalizationRegistry target, GCRuntime::relocateFinalizationObserverTarget (js/src/gc/FinalizationObservers.cpp) walks the target zone's weakRefMap observer list and calls weakRef->setTarget(newTarget) on every linked WeakRefObject without checking liveness. WeakRefObjects whose own zone is in the middle of incremental sweeping can be dead but still linked in the list: the observer map lives in the (uncollected) target's zone, and dead WeakRefs are only unlinked by their JSCLASS_FOREGROUND_FINALIZE finalizer, which is spread over many sweep slices. Because transplanting can run in the mutator windows between sweep slices, setTarget writes into these dead cells.
setTarget uses NativeObject::setReservedSlotGCThingAsPrivate, whose post-write barrier (gc::PostWriteBarrierCell, js/src/gc/StoreBuffer.cpp:421-432) inserts the dead tenured WeakRef cell into the whole-cell store buffer whenever the transplant's new identity object is nursery-allocated. The engine's only mitigation for store-buffer entries referencing dying cells — StoreBuffer::setMayHavePointersToDeadCells, consumed by GCRuntime::prepareForSweepSlice to flush the nursery during sweeping — is set only by ProxyObject::swap (vm/ProxyObject.cpp:219-223), gated on the swapped proxies' zones, which in this scenario are not the collected zone, so it never fires. Foreground finalization subsequently finalizes the dead WeakRef and releases its arena (Arena::release sets allocKind to AllocKind::LIMIT and clears bufferedCells_), while the stale ArenaCellSet remains queued in the whole-cell buffer. The next minor GC traces the freed arena from Nursery::traceRoots -> WholeCellBuffer::trace -> ArenaCellSet::trace (js/src/gc/Tenuring.cpp:511-529): with an unreused arena it reads allocKind==LIMIT and performs an out-of-bounds read of the constexpr trace-kind table (caught by the sanitizer in this build); with a reused arena it walks stale per-cell bit offsets over unrelated newly allocated cells and traces/moves them as the wrong kind, performing tenuring-trace reads and forwarded-pointer writes through attacker-groomable fake cells — a strong type-confusion/UAF exploitation primitive inside the GC.
The vulnerability is in the FinalizationRegistry/WeakRef browser-integration feature (marker file dom/webidl/FinalizationRegistry.webidl); the vulnerable relocation code was added by bugs 2032512/2032328 to update finalization observers when window proxies are transplanted on navigation. The proof of concept triggers it with the shell's transplantableObject(), which exists specifically to model the browser's transplantable RemoteObjectProxy/WindowProxy objects; nursery allocation of these proxies is supported engine behavior that the browser does not currently use (its transplant targets are tenured today), so the bug is latent in the browser but fully reachable, deterministic engine behavior under standard --fuzzing-safe shell builtins on the unmodified tree.
Build Info
- Branch: main
- Revision: 862319e7e4192197ecf480aa72c9e91edf4278b7
- Timestamp: 2026-06-05T02:30:36+00:00
Affected Code
File: js/src/gc/FinalizationObservers.cpp, line 659-701
bool GCRuntime::relocateFinalizationObserverTarget(const Value& oldTarget,
const Value& newTarget) {
...
ObserverList weakRefList = oldObservers->extractWeakRefObservers(oldTarget);
ObserverList recordList = oldObservers->extractRecordObservers(oldTarget);
...
// Update the target stored in each WeakRefObject.
for (auto iter = weakRefList.iter(); !iter.done(); iter.next()) {
auto* weakRef = &iter.get()->as<WeakRefObject>();
MOZ_ASSERT(weakRef->target() == oldTarget);
weakRef->setTarget(newTarget); // <-- no liveness check: weakRef may be a
// dead-but-unfinalized cell in a zone that
// is currently being incrementally swept
}
...
File: js/src/vm/NativeObject.h, line 1729-1743
void setReservedSlotGCThingAsPrivate(uint32_t slot, gc::Cell* cell) {
...
setReservedSlotGCThingAsPrivateUnbarriered(slot, cell);
gc::PostWriteBarrierCell(this, prev, cell); // <-- puts *this* (the dead
// WeakRef) into the whole-cell
// store buffer when `cell` is
// in the nursery
}
File: js/src/gc/StoreBuffer.cpp, line 421-432
void js::gc::PostWriteBarrierCell(Cell* cell, Cell* prev, Cell* next) {
if (!next || !cell->isTenured()) {
return;
}
StoreBuffer* buffer = next->storeBuffer();
if (!buffer || (prev && prev->storeBuffer())) {
return;
}
buffer->putWholeCell(cell); // <-- no check that `cell` is alive / that its
// zone is mid-sweep
}
File: js/src/vm/ProxyObject.cpp, line 219-223
// The ONLY setter of the dead-cells mitigation; gated on the swapped
// proxies' zones, so transplant-time observer relocation never arms it:
if (a->isTenured() || b->isTenured()) {
if (a->zone()->wasGCStarted()) {
cx->runtime()->gc.storeBuffer().setMayHavePointersToDeadCells();
}
}
File: js/src/gc/Tenuring.cpp, line 511-529
void ArenaCellSet::trace(TenuringTracer& mover) {
for (ArenaCellSet* cells = this; cells; cells = cells->next) {
cells->check();
Arena* arena = cells->arena;
arena->bufferedCells() = &ArenaCellSet::Empty;
JS::TraceKind kind = MapAllocToTraceKind(arena->getAllocKind());
// ^ arena was RELEASED by foreground finalization: getAllocKind() ==
// AllocKind::LIMIT (45) -> OOB read of the trace-kind table; if the
// arena was reused, stale cell offsets are traced as the wrong kind
...
The unguarded write to potentially-dead WeakRefObjects during transplant-time observer relocation, the post-write barrier that records the dead cell in the whole-cell store buffer, the only (never firing) mitigation site, and the minor-GC trace of the freed arena.
Exploit Chain
- Create three zones: zone C holding transplantable proxy targets (models browser WindowProxy/RemoteObjectProxy objects), zone Z for WeakRefs, and destination zone D.
- In zone Z, create ~200,000 WeakRefs to kept-alive objects and immediately drop the WeakRefs; these doomed foreground-finalized WeakRefObjects stall sweep-phase finalization across many incremental slices.
- Create 64 WeakRefs in zone Z targeting the 64 transplantable objects in zone C, and drop them; they are registered in zone C's weakRefMap (zone C is never collected, so only the WeakRefs' own finalizers can unlink them).
- Run a minor GC to empty the nursery and store buffer, then start an incremental GC that collects only zone Z. Marking determines all the dropped WeakRefs are dead.
- While the GC is in the Sweep state (mutator windows between slices, foreground finalization still pending for the WeakRef arenas), transplant the 64 targets into zone D. JS_TransplantObject -> relocateFinalizationObserverTarget retargets each linked WeakRef: weakRef->setTarget(newIdentity) writes the nursery-allocated new identity proxy into each DEAD WeakRef and PostWriteBarrierCell records each dead WeakRef cell in the whole-cell store buffer. setMayHavePointersToDeadCells is never set, so the buffer is not flushed.
- Continue GC slices: foreground finalization finalizes the dead WeakRefs and releases their now-empty arenas back to the chunk pool (allocKind becomes AllocKind::LIMIT); the stale ArenaCellSet entries still reference these arenas.
- Trigger a minor GC: Nursery::traceRoots -> WholeCellBuffer::trace -> ArenaCellSet::trace walks the freed arena. In this build the sanitizer reports the OOB trace-kind table read; in an exploitation scenario the attacker instead grooms reuse of the freed arena so the stale cell-offset bitmap overlays attacker-controlled object data, causing the tenuring tracer to interpret fake headers/shapes/slots and perform controlled reads and forwarded-pointer writes — a memory corruption primitive inside the GC.
Steps to Reproduce
- Build Firefox from the unmodified tree with /app/mozconfigs/mozconfig.linux.asan.fuzzing (builds the JS shell too via --enable-js-shell).
- Run poc.js in the resulting SpiderMonkey shell (<build_dir>/dist/bin/js), e.g. via the js_shell_evaluator tool (default --fuzzing-safe; no extra flags, prefs, patches, or services needed).
- Observe 'GC done; slices=~978 transplanted=64' followed by the sanitizer report: index 45 out of bounds for 'const JS::TraceKind[45]' in js::gc::MapAllocToTraceKind called from js::gc::ArenaCellSet::trace during Nursery::traceRoots (deterministic, 3/3 runs).
- Corroboration: a debug shell build fails 'Assertion failure: IsValidAllocKind(allocKind), at js/src/gc/Heap.h:262' at the same site; a control variant performing the 64 transplants after the incremental GC completes prints SURVIVED with no error, proving the mid-sweep window is the trigger.
Security Impact
- Severity: High
- Attacker capability: Use of a freed GC arena through a stale whole-cell store buffer entry. With the freed arena left unreused the minor GC performs an out-of-bounds read of the global trace-kind table; with the arena reused (groomable by the attacker, since arena reuse within the chunk pool is allocation-controlled) the tenuring tracer walks stale per-cell bit offsets over unrelated new cells and traces/moves them as the wrong TraceKind, yielding type-confused reads and forwarded-pointer writes through attacker-controlled fake cell contents — a path to arbitrary memory corruption in the content process JS engine.
- Preconditions: Requires a JS_TransplantObject call whose new identity object is nursery-allocated while a WeakRef/FinalizationRegistry observer of the transplanted target is dead-but-unfinalized in a zone undergoing incremental sweeping. The proof of concept uses the SpiderMonkey shell's transplantableObject() (available under --fuzzing-safe), which exists to model the browser's transplantable window/remote proxies; nursery allocation of such proxies is supported engine behavior that Gecko's current transplant targets (outer/remote window proxies) do not yet use, so the bug is latent in the browser today and immediately content-reachable if any Gecko transplant target becomes nursery-allocatable.
ASAN Report
/firefox/js/src/gc/AllocKind.h:295:10: runtime error: index 45 out of bounds for type 'const JS::TraceKind[45]'
#0 0x56b704648a9b in js::gc::MapAllocToTraceKind(js::gc::AllocKind) /firefox/js/src/gc/AllocKind.h:295:10
#1 0x56b704648a9b in js::gc::ArenaCellSet::trace(js::gc::TenuringTracer&) /firefox/js/src/gc/Tenuring.cpp:516:24
#2 0x56b704648ba7 in js::gc::StoreBuffer::WholeCellBuffer::trace(js::gc::TenuringTracer&, js::gc::StoreBuffer*) /firefox/js/src/gc/Tenuring.cpp:538:14
#3 0x56b7045f1ee6 in js::Nursery::traceRoots(js::gc::AutoGCSession&, js::gc::TenuringTracer&) /firefox/js/src/gc/Nursery.cpp:1670:8
#4 0x56b7045ef1d9 in js::Nursery::doCollection(js::gc::AutoGCSession&, JS::GCOptions, JS::GCReason) /firefox/js/src/gc/Nursery.cpp:1550:3
#5 0x56b7045edd8a in js::Nursery::collect(JS::GCOptions, JS::GCReason) /firefox/js/src/gc/Nursery.cpp:1409:31
#6 0x56b70456658c in js::gc::GCRuntime::collectNursery(JS::GCOptions, JS::GCReason, js::gcstats::PhaseKind) /firefox/js/src/gc/GC.cpp:5496:13
#7 0x56b704548c70 in js::gc::GCRuntime::minorGC(JS::GCReason, js::gcstats::PhaseKind) /firefox/js/src/gc/GC.cpp:5465:3
#8 0x56b703ea4e2e in JSContext::minorGC(JS::GCReason) /firefox/js/src/vm/JSContext-inl.h:286:17
#9 0x56b703ea4e2e in MinorGC(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/TestingFunctions.cpp:837:7
#10 0x56b70563fc4a in CallJSNative /firefox/js/src/vm/Interpreter.cpp:498:13
#11 0x56b70563fc4a in js::InternalCallOrConstruct /firefox/js/src/vm/Interpreter.cpp:594:12
#12 0x56b7048aefab in js::jit::DoCallFallback /firefox/js/src/jit/BaselineIC.cpp:1713:10
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /firefox/js/src/gc/AllocKind.h:295:10
[Debug build corroboration: Assertion failure: IsValidAllocKind(allocKind), at /firefox/js/src/gc/Heap.h:262 — the arena referenced by the whole-cell store buffer entry has been released (allocKind == AllocKind::LIMIT).]
| Reporter | ||
Comment 1•5 months ago
|
||
| Reporter | ||
Comment 2•5 months ago
|
||
| Reporter | ||
Comment 3•5 months ago
|
||
| Reporter | ||
Comment 4•5 months ago
|
||
Updated•5 months ago
|
Comment 6•5 months ago
|
||
Jon, could you look at this newer sec-high bug when you have a chance.
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Comment 7•5 months ago
|
||
This is a problem because it can add store buffer entries that point into the
dying object. We can just skip this update. The target will not be accessed
again if the WeakRef is dying.
| Assignee | ||
Comment 8•5 months ago
|
||
Comment 10•5 months ago
|
||
https://hg.mozilla.org/mozilla-central/rev/d63f2bb722a3
https://hg.mozilla.org/mozilla-central/rev/8cf61acff3cb
Comment 11•5 months ago
|
||
Set release status flags based on info from the regressing bug 2042879
Updated•5 months ago
|
Updated•4 months ago
|
Updated•1 month ago
|
Description
•