Gray wrapper transplantation violates a SpiderMonkey GC invariant
Categories
(Core :: JavaScript: GC, defect, P3)
Tracking
()
People
(Reporter: bugmon, Assigned: jonco)
References
(Blocks 1 open bug)
Details
(4 keywords, Whiteboard: [adv-main159+])
Attachments
(3 files, 1 external file)
|
1.40 KB,
patch
|
Details | Diff | Splinter Review | |
|
9.45 KB,
text/plain
|
Details | |
|
425 bytes,
text/plain
|
Details |
ProxyObject::renew requests nursery eviction only when the wrapper’s own zone has started GC. During transplantation, wrapper remapping can preserve a gray wrapper in an uncollected zone while retargeting it to a nursery object in the collected destination zone. Incoming-gray-edge tracing then reaches the invariant crash in ShouldMarkCrossCompartment.
A JavaScript author with access to SpiderMonkey’s shell testing functions can terminate a debug shell by transplanting an object retained through a gray cross-compartment wrapper during a destination-only incremental collection. The unchanged testcase reproduces under --fuzzing-safe. The ASAN build exits normally; memory corruption and browser-content reachability are not demonstrated.
This finding was discovered during variant analysis seeded by bug 1651001.
Build Info
- Branch: main
- Revision: 8445f37d6a14937595eabb96376b4433b4eb67e7
- Timestamp: 2026-09-29T00:12:36+00:00
Affected Code
File: js/src/proxy/Proxy.cpp, line 1004-1011
setCrossCompartmentPrivate(priv);
// The post barrier may add a store buffer entry but since this may be called
// on a dying object we don't know if they will survive GC. Therefore we need
// to set a flag to ensure we clear out the nursery before doing any sweeping.
if (isTenured() && zone()->wasGCStarted() && priv.isGCThing() &&
!priv.toGCThing()->isTenured()) {
runtimeFromMainThread()->gc.storeBuffer().setMayHavePointersToDeadCells();
}
File: js/src/proxy/CrossCompartmentWrapper.cpp, line 624-635
for (CompartmentsIter c(cx->runtime()); !c.done(); c.next()) {
if (ObjectWrapperMap::Ptr wp = c->lookupWrapper(oldTarget)) {
// We found a wrapper. Remember and root it.
if (!toTransplant.append(WrapperValue(wp))) {
return false;
}
}
}
for (const WrapperValue& v : toTransplant) {
RemapWrapper(cx, v, newTarget);
}
File: js/src/vm/Compartment.h, line 488-489
explicit WrapperValue(const ObjectWrapperMap::Ptr& ptr)
: value(ptr->value().unbarrieredGet()) {}
File: js/src/gc/Marking.cpp, line 243-260
static inline bool ShouldMarkCrossCompartment(GCMarker* marker, JSObject* src,
Cell* dstCell, const char* name) {
#ifdef DEBUG
if (src->isMarkedGray() && !dstCell->isTenured()) {
// Bug 1743098: This shouldn't be possible but it does seem to happen. Log
// some useful information in debug builds.
SEprinter printer;
printer.printf(
"ShouldMarkCrossCompartment: cross compartment edge '%s' from gray "
"object to nursery thing\n",
name);
printer.put("src: ");
src->dump(printer);
printer.put("dst: ");
dstCell->dump(printer);
MOZ_CRASH("Found cross compartment edge from gray object to nursery thing");
}
#endif
JS_TransplantObject calls RemapAllWrappersForObject at js/src/jsapi.cpp:703. WrapperValue avoids the read barrier, while ProxyObject::renew conditions nursery-eviction bookkeeping on the wrapper’s zone. GCRuntime::markGrayRoots traces incoming gray edges at js/src/gc/Sweeping.cpp:724, and Compartment::traceWrapperTargetsInCollectedZones reaches the proxy target at js/src/vm/Compartment.cpp:500.
Exploit Chain
- Create a transplantable object and two globals in separate compartments.
- Retain the object through a WeakMap rooted by grayRoot() in the holder compartment, then perform a full collection.
- Start an incremental collection of only the destination zone and advance past Prepare and MarkRoots.
- Transplant the object into the destination compartment while the holder’s zone remains uncollected.
- Finish the collection. Incoming-gray-edge tracing encounters the gray wrapper’s nursery target and triggers MOZ_CRASH.
Steps to Reproduce
- Use clean Firefox revision 8445f37d6a14937595eabb96376b4433b4eb67e7.
- Build with build_firefox(firefox_dir="/firefox", mozconfig_path="/app/mozconfigs/mozconfig.js.linux.debug").
- Pass the complete contents of /app/output/verification-tmpj56p8wtx/gray-wrapper-transplant.js to js_shell_evaluator with js_binary="/firefox/obj-js-debug/dist/bin/js", flags=[], and timeout=30.
- Observe crashed=true, timed_out=false, exit_code=1, and MOZ_CRASH at js/src/gc/Marking.cpp:258.
- Inspect /app/output/verification-tmpj56p8wtx/crash_stack.txt for the independent crash and /app/output/verification-tmpj56p8wtx/reproduction.json for build details and controls.
- Independently inserting minorgc() after transplantation, replacing grayRoot() with blackRoot(), or also scheduling the holder zone makes the debug testcase exit successfully.
Security Impact
- Severity: Low
- Attacker capability: Trigger a demonstrated SpiderMonkey GC-invariant failure and terminate the debug shell. No memory corruption, sandbox escape, or browser-content impact was demonstrated.
- Preconditions: Access to SpiderMonkey’s permitted shell testing functions, a debug build, a gray cross-compartment wrapper in an uncollected zone, and transplantation during an incremental collection of the destination zone.
ASAN Report
The ASAN build completed the unchanged testcase with exit code 0 and no sanitizer report. The independently reproduced debug-build output was:
ShouldMarkCrossCompartment: cross compartment edge 'proxy target' from gray object to nursery thing
[8594] Hit MOZ_CRASH(Found cross compartment edge from gray object to nursery thing) at js/src/gc/Marking.cpp:258
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==8594==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x55c09e30a243 bp 0x7eec7eeaadf0 sp 0x7eec7eeaada0 T8594)
==8594==The signal is caused by a READ memory access.
==8594==Hint: address points to the zero page.
#0 0x55c09e30a243 in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3
#1 0x55c09e30a243 in ShouldMarkCrossCompartment(js::GCMarker*, JSObject*, js::gc::Cell*, char const*) /firefox/obj-js-debug/js/src/gc/./../../../../js/src/gc/Marking.cpp:258:5
#2 0x55c09e30a243 in ShouldTraceCrossCompartment(JSTracer*, JSObject*, js::gc::Cell*, char const*) /firefox/obj-js-debug/js/src/gc/./../../../../js/src/gc/Marking.cpp:331:10
#5 0x55c09f32dc6e in JS::Compartment::traceWrapperTargetsInCollectedZones(JSTracer*, JS::Compartment::EdgeSelector) /firefox/obj-js-debug/js/src/vm/./../../../../js/src/vm/Compartment.cpp:500:9
#6 0x55c09f32e6cf in JS::Compartment::traceIncomingCrossCompartmentEdgesForZoneGC(JSTracer*, JS::Compartment::EdgeSelector) /firefox/obj-js-debug/js/src/vm/./../../../../js/src/vm/Compartment.cpp:517:10
#7 0x55c09e3d0fa1 in js::gc::GCRuntime::markGrayRoots(JS::SliceBudget&, js::gcstats::PhaseKind) /firefox/obj-js-debug/js/src/gc/./../../../../js/src/gc/Sweeping.cpp:724:5
| Reporter | ||
Comment 1•27 days ago
|
||
| Reporter | ||
Comment 2•27 days ago
|
||
| Reporter | ||
Comment 3•27 days ago
|
||
Comment 4•27 days ago
|
||
This bug was filed automatically by an AI-powered security testing tool. The analysis, testcase, and any suggested patches were generated by an AI model and have NOT been verified by a human.
Please review all details carefully before triaging. The issue was confirmed by an independent verifier, but for non-ASAN issues, the verifier has been made less strict. Additionally, the root cause analysis and suggested fix may contain errors.
Updated•27 days ago
|
Updated•27 days ago
|
Updated•23 days ago
|
| Assignee | ||
Updated•22 days ago
|
| Assignee | ||
Comment 6•22 days ago
|
||
There's a block at the start of ShouldMarkCrossCompartment that crashes if it
detects gray to nursery edges, on the grounds that it shouldn't be possible for
such edges to exist.
The reasoning is wrong and this bug demonstrates how they can be created: by
wrapper remapping, which touches gray (and possibly dead) things.
It's not a problem to hit these edges here. We will return false from the first
if block because the nursery target will be reported as marked black. The
nursery object will be tenured and traced on the next minor GC, due to the
store buffer entry added by the postbarrier when we created the edge.
Comment 7•21 days ago
|
||
:jonco, please review the release status flags for beta, release, and ESR and update each one as appropriate.
For each version, indicate whether it is affected, unaffected, disabled, or wontfix if it is affected but does not need to be fixed on that branch.
| Assignee | ||
Comment 8•21 days ago
|
||
I think this invariant is wrong. Gray to nursery edges can happen (as demonstrated) but it's not a problem. Marking this is as not security sensitive. This check is present in all supported versions.
Comment 9•21 days ago
|
||
The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.
Updated•21 days ago
|
Comment 10•21 days ago
|
||
Comment 11•21 days ago
|
||
| bugherder | ||
Updated•1 day ago
|
Updated•1 day ago
|
Updated•1 day ago
|
Description
•