Use-after-free via missing post-write barrier in [@ js::gc::GCRuntime::relocateFinalizationObserverTarget]
Categories
(Core :: JavaScript: GC, defect, P1)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox151 | --- | wontfix |
| firefox152 | --- | wontfix |
| firefox153 | --- | fixed |
People
(Reporter: bugmon, Assigned: jonco)
References
Details
(Keywords: ai-involved, assertion, testcase)
Attachments
(4 files, 1 external file)
|
184 bytes,
text/plain
|
Details | |
|
900 bytes,
patch
|
Details | Diff | Splinter Review | |
|
2.10 KB,
patch
|
Details | Diff | Splinter Review | |
|
2.11 KB,
text/plain
|
Details |
js::gc::GCRuntime::relocateFinalizationObserverTarget (FinalizationObservers.cpp:658-701), invoked from JS_TransplantObject (jsapi.cpp:708), moves the WeakRef/FinalizationRegistry observer state from the object being transplanted (oldTarget) onto the transplant's new identity object (newTarget). At line 681 it rewrites every observing WeakRef's stored target via weakRef->setTargetUnbarriered(newTarget). That setter (WeakRefObject.cpp:242) calls NativeObject::setReservedSlotGCThingAsPrivateUnbarriered, which performs a raw slot write with NO generational post-write barrier (no gc::PostWriteBarrierCell), unlike the barriered setReservedSlotGCThingAsPrivate used in WeakRefObject::construct (WeakRefObject.cpp:96).
WeakRefObject instances are always tenured (class flags JSCLASS_FOREGROUND_FINALIZE without JSCLASS_SKIP_NURSERY_FINALIZE). When newTarget is a nursery-allocated object, the resulting tenured->nursery edge is never recorded in the store buffer. The next minor GC promotes/forwards newTarget but never fixes up the WeakRef's TargetSlot, leaving it pointing at the freed/forwarded nursery cell. WeakRef.prototype.deref() (and the GC itself during compaction) then dereferences this dangling cell -> use-after-free.
This is the regression privately tracked as bug 2036691, introduced by the relocate code added in bug 2032512. In the browser the new identity object is a nursery-allocatable WindowProxy (e.g. an iframe WindowProxy that is a WeakRef/FinalizationRegistry target being navigated/transplanted), so the bug is reachable directly. In the --fuzzing-safe js shell every transplant-capable proxy is forced tenured, so a 7-line shell-harness-only patch (making the test-only TransplantableProxyHandler nursery-allocatable, exactly like browser WindowProxies) is used to restore reachability; the vulnerable file FinalizationObservers.cpp is unmodified.
Build Info
- Branch: main
- Revision: 1f7030c8de8f2b349c7d91d7b5a3253c109a1cc1
- Timestamp: 2026-05-21T16:50:48+00:00
Affected Code
File: js/src/gc/FinalizationObservers.cpp, line 677-682
// 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->setTargetUnbarriered(newTarget); // <-- missing post-write barrier
}
File: js/src/builtin/WeakRefObject.cpp, line 242-244
void WeakRefObject::setTargetUnbarriered(Value target) {
setReservedSlotGCThingAsPrivateUnbarriered(TargetSlot, target.toGCThing());
}
File: js/src/vm/NativeObject.h, line 1729-1749
void setReservedSlotGCThingAsPrivate(uint32_t slot, gc::Cell* cell) {
HeapSlot* pslot = getSlotAddress(slot);
Cell* prev = nullptr;
if (!pslot->isUndefined()) {
prev = static_cast<gc::Cell*>(pslot->toPrivate());
privatePreWriteBarrier(pslot);
}
setReservedSlotGCThingAsPrivateUnbarriered(slot, cell);
gc::PostWriteBarrierCell(this, prev, cell); // <-- present here, absent in Unbarriered variant
}
void setReservedSlotGCThingAsPrivateUnbarriered(uint32_t slot, gc::Cell* cell) {
MOZ_ASSERT(slot < JSCLASS_RESERVED_SLOTS(getClass()));
MOZ_ASSERT(cell);
getReservedSlotRef(slot).unbarrieredSet(PrivateValue(cell));
}
File: js/src/builtin/WeakRefObject.cpp, line 153-157
const JSClass WeakRefObject::class_ = {
"WeakRef",
JSCLASS_HAS_RESERVED_SLOTS(SlotCount) |
JSCLASS_HAS_CACHED_PROTO(JSProto_WeakRef) | JSCLASS_FOREGROUND_FINALIZE,
&classOps_, &classSpec_, &classExtension_}; // foreground-finalized => always tenured
The defect: relocate updates the tenured WeakRef's target slot to the (possibly nursery) newTarget with an unbarriered write, so no store-buffer entry is created and the slot is not updated on the next minor GC.
Exploit Chain
- Create a WeakRef whose target is a transplant-capable object: var w = new WeakRef(src) where src is the object returned by transplantableObject(). registerWeakRef stores src in the zone's weakRefMap and stores src in the (tenured) WeakRef's TargetSlot with a correct post-write barrier.
- Transplant src into another compartment: t.transplant(g) -> JS_TransplantObject(src, newIdentity). Because src->compartment() != destination, jsapi.cpp:708 calls GCRuntime::relocateFinalizationObserverTarget(ObjectValue(*src), ObjectValue(*newIdentity)).
- relocate extracts src's WeakRef observer list and, at FinalizationObservers.cpp:681, rewrites each WeakRef's TargetSlot to newIdentity using setTargetUnbarriered. When newIdentity is nursery-allocated (browser WindowProxy; shell with the harness patch), this stores a nursery pointer into a tenured WeakRef with NO store-buffer (whole-cell) entry.
- Run a minor GC (minorgc(), or any nursery-collecting allocation churn). newIdentity is promoted/forwarded out of the nursery, but because there is no store-buffer entry, the WeakRef's TargetSlot is never updated and now points at the freed/forwarded nursery cell.
- Call w.deref(): WeakRefObject::deref -> readBarrier -> target() reads the dangling TargetSlot and dereferences the freed nursery cell (reads its class/shape), producing a use-after-free. A compacting GC that visits the WeakRef (WeakRefObject::trace during Compacting.cpp pointer fixup) hits the same dangling cell. The freed slot can also be reclaimed by a fresh nursery object, giving a controllable dangling-pointer / type-confusion primitive.
Steps to Reproduce
- Build the SpiderMonkey js shell (ASAN: obj-firefox-asan; debug: obj-js-debug).
- Apply the included harness-only patch transplantable-nursery.patch to js/src/shell/js.cpp (adds canNurseryAllocate()=true to the test-only TransplantableProxyHandler so the transplant new-identity proxy is nursery-allocated, mirroring browser WindowProxies) and rebuild the shell. The vulnerable file FinalizationObservers.cpp is NOT modified.
- Run the testcase: js --fuzzing-safe uaf_relocate.js
- Observe the crash: ASAN reports a SEGV use-after-free read in WeakRefObject::readBarrier -> JSObject::getClass; the debug build asserts 'this->is<T>()' in WeakRefObject::target().
Security Impact
- Severity: High
- Attacker capability: JavaScript-reachable use-after-free of a GC cell. After transplanting a WeakRef/FinalizationRegistry target whose new identity is nursery-allocated, the WeakRef's internal target pointer is left dangling after a minor GC. WeakRef.prototype.deref() then returns/dereferences a freed (and potentially attacker-reoccupied) nursery cell, yielding a controllable dangling pointer and type-confusion primitive that is a strong foundation for remote code execution in the content process.
- Preconditions: No special privileges. In the browser the trigger occurs naturally when an object that is a WeakRef/FinalizationRegistry target is transplanted to a nursery-allocatable new identity (e.g. an iframe WindowProxy navigated/transplanted while a WeakRef observes it). In the --fuzzing-safe js shell, the only transplant-capable proxies are forced tenured (TransplantableProxyHandler/Wrapper-of-tenured-proxy), so a 7-line shell-harness change is required to make the transplant new identity nursery-allocated and expose the identical engine bug.
ASAN Report
==ERROR: AddressSanitizer: SEGV on unknown address (READ memory access); dereference of a high (freed nursery) address (e.g. rdi=0x60000037a29b7050)
#0 js::gc::HeaderWord::get() const /firefox/js/src/gc/Cell.h:113:23
#1 js::gc::TenuredCellWithNonGCPointer<JSClass const>::headerPtr() const /firefox/js/src/gc/Cell.h:763:54
#2 js::BaseShape::clasp() const /firefox/js/src/vm/Shape.h:242:41
#3 js::Shape::getObjectClass() const /firefox/js/src/vm/Shape.h:390:58
#4 JSObject::getClass() const /firefox/js/src/vm/JSObject.h:113:53
#5 js::WeakRefObject::readBarrier(JSContext*, JS::Handle<js::WeakRefObject*>) /firefox/js/src/builtin/WeakRefObject.cpp:258:46
#6 js::WeakRefObject::deref(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/WeakRefObject.cpp:213:3
SUMMARY: AddressSanitizer: SEGV /firefox/js/src/gc/Cell.h:113:23 in js::gc::HeaderWord::get() const
Debug build assertion (same root cause):
Assertion failure: this->is<T>(), at /firefox/js/src/gc/Cell.h:201
#1 JS::Symbol* js::gc::Cell::as<JS::Symbol, void>() /firefox/js/src/gc/Cell.h:201:5
#2 js::WeakRefObject::target() /firefox/js/src/builtin/WeakRefObject.cpp:189:28
#3 js::WeakRefObject::readBarrier(JSContext*, JS::Handle<js::WeakRefObject*>) /firefox/js/src/builtin/WeakRefObject.cpp:253:32
#4 js::WeakRefObject::deref(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/WeakRefObject.cpp:213:3
| 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
|
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Updated•5 months ago
|
| Assignee | ||
Comment 6•5 months ago
|
||
In the browser the new identity object is a nursery-allocatable WindowProxy
This is not true as far as I can tell, which makes this whole thing moot. Since the window proxy can't be allocated in the nursery the crash can't be produced.
It's probably worth adding the suggested barrier in case this ever does happen in the future though.
| Assignee | ||
Comment 7•5 months ago
|
||
This is not a problem at the moment as the new target can't be in the nursery
but having a barrier is the safe default.
Comment 9•5 months ago
|
||
| bugherder | ||
Updated•5 months ago
|
Description
•