Gray JS::Symbol exposed to script via WeakMap.prototype.get (MOZ_CRASH in ContextChecks::checkAtom)
Categories
(Core :: JavaScript: GC, defect, P1)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox149 | --- | wontfix |
| firefox150 | + | fixed |
| firefox151 | + | fixed |
People
(Reporter: bugmon, Assigned: jonco)
References
(Blocks 2 open bugs, Regression)
Details
(6 keywords, Whiteboard: [prefs-checked][pp2][adv-main150+r])
Attachments
(3 files, 3 external files)
|
262 bytes,
text/plain
|
Details | |
|
2.05 KB,
patch
|
tjr
:
sec-approval+
|
Details | Diff | Splinter Review |
|
3.31 KB,
text/plain
|
Details |
Gray JS::Symbol exposed to script via WeakMap.prototype.get (MOZ_CRASH in ContextChecks::checkAtom)
JS::Symbol is the only atoms-zone type that can be marked gray in the per-zone atom marking bitmap. When a Symbol is used as a WeakMap key and is only gray-reachable (e.g. via a cross-compartment gray root, as modeled in the shell by grayRoot() and in the browser by cycle-collector / chrome wrapper edges), WeakMap marking colors the entry's value with min(map, key) = gray. When the gray key is later read back through a cross-compartment wrapper it is unbarriered and re-marked black, but WeakMap.prototype.get then returns the associated value Symbol directly via args.rval().set(ptr->value()) without firing a read barrier or calling cx->markAtom() for the current zone.
As a result, a Symbol that is still marked gray in the zone's atom bitmap is exposed to running script. The DEBUG-only sanity check js::ContextChecks::checkAtom<JS::Symbol> in JSContext-inl.h detects this and hits MOZ_CRASH("Atom is marked gray for zone ..."). This assertion guards a real memory-safety invariant: a Symbol that is not black in a zone's atom bitmap is considered unreferenced by that zone, so a subsequent per-zone GC may sweep it while it is still reachable from the JS stack/heap, leading to a use-after-free.
This belongs to the recent cluster of Symbol gray-marking issues (bug 1994871, 1997822, 1997896, 2004443) stemming from JS::Symbol being grayable in per-zone atom bitmaps.
Build Info
- Branch: main
- Revision: 98bf4b92d3f5d7a9855281df4bf333210bcfbbc4
- Timestamp: 2026-04-01T20:06:40+00:00
Affected Code
File: js/src/builtin/WeakMapObject.cpp, line 66-85
/* static */ MOZ_ALWAYS_INLINE bool WeakMapObject::get_impl(
JSContext* cx, const CallArgs& args) {
MOZ_ASSERT(WeakMapObject::is(args.thisv()));
if (!CanBeHeldWeakly(args.get(0))) {
args.rval().setUndefined();
return true;
}
if (Map* map = args.thisv().toObject().as<WeakMapObject>().getMap()) {
Value key = args[0];
if (Map::Ptr ptr = map->lookup(key)) {
args.rval().set(ptr->value()); // <-- gray Symbol returned without read barrier / markAtom
return true;
}
}
args.rval().setUndefined();
return true;
}
File: js/src/vm/JSContext-inl.h, line 100-116
#ifdef DEBUG
// Atoms which move across zone boundaries need to be marked in the atom
// marking bitmap for the new zone, see JS_MarkCrossZoneId.
// Note that the atom marking state may not be up-to-date if incremental
// marking is taking place.
gc::GCRuntime* gc = &cx->runtime()->gc;
bool isGCMarking =
gc->state() >= gc::State::Prepare && gc->state() <= gc::State::Sweep;
if (zone() && !isGCMarking) {
gc::CellColor color = gc->atomMarking.getAtomMarkColor(zone(), thing);
if (color != gc::CellColor::Black) {
MOZ_CRASH_UNSAFE_PRINTF(
"*** Atom is marked %s for zone %p at argument %d",
gc::CellColorName(color), zone(), argIndex);
}
}
#endif
File: js/src/vm/Interpreter.cpp, line 489-497
AutoRealm ar(cx, &args.callee());
bool ok = native(cx, args.length(), args.base());
if (ok) {
cx->check(args.rval()); // <-- detects gray Symbol in rval and crashes
MOZ_ASSERT_IF(!alreadyThrowing, !cx->isExceptionPending());
} else {
AssertExceptionResult(cx);
}
return ok;
File: js/src/gc/AtomMarking-inl.h, line 58-72
size_t bit = getAtomBit(cell);
size_t blackBit = bit + size_t(ColorBit::BlackBit);
size_t grayOrBlackBit = bit + size_t(ColorBit::GrayOrBlackBit);
MOZ_ASSERT(grayOrBlackBit / JS_BITS_PER_WORD < allocatedWords);
{
mozilla::Maybe<AutoEnterOOMUnsafeRegion> oomUnsafe;
if constexpr (!Fallible) {
oomUnsafe.emplace();
}
bool ok = zone->markedAtoms().setBit(blackBit);
if constexpr (std::is_same_v<T, JS::Symbol>) {
ok = ok && zone->markedAtoms().setBit(grayOrBlackBit);
}
File: js/src/vm/SymbolType.cpp, line 28-43
Symbol* Symbol::new_(JSContext* cx, JS::SymbolCode code,
HandleString description) {
Rooted<JSAtom*> atom(cx);
if (description) {
atom = AtomizeString(cx, description);
if (!atom) {
return nullptr;
}
}
Symbol* sym = newInternal(cx, code, cx->runtime()->randomHashCode(), atom);
if (sym) {
cx->markAtom(sym);
}
return sym;
}
WeakMapObject::get_impl hands the stored HeapPtr<Value> back to script without ExposeValueToActiveJS / cx->markAtom on a Symbol value. Because JS::Symbol (defined in SymbolType.cpp/h) uses both black and gray bits in the per-zone atom bitmap (AtomMarking-inl.h), a Symbol value kept alive only via a gray WeakMap edge stays gray after the key is unbarriered, and the cx->check(args.rval()) in CallJSNative trips the checkAtom MOZ_CRASH.
Exploit Chain
- Create two Symbols s1, s2 and a WeakMap wm; wm.set(s1, s2).
- Make s1 reachable only via a gray root in another compartment (in the browser: a cycle-collector / chrome wrapper edge; in the shell: grayRoot()[0] = s1). Drop all other references to s1 and s2.
- Run a full GC. WeakMap marking marks the entry value s2 with min(map=black, key=gray) = gray in the zone's atom bitmap.
- Read s1 back through the cross-compartment edge; the wrapper read barrier black-marks s1 in the current zone.
- Call wm.get(s1). get_impl returns s2 directly with no read barrier; s2 is still gray in the zone bitmap -> black->gray invariant violated.
- In a release build, store the returned s2 somewhere in the zone and trigger a per-zone GC. The atom-marking sweep treats s2 as unreferenced by this zone and may free it -> use-after-free of the JS::Symbol cell.
Steps to Reproduce
- Build a debug SpiderMonkey JS shell (e.g. /firefox/obj-js-debug/dist/bin/js).
- Save the testcase below as test.js.
- Run: obj-js-debug/dist/bin/js --fuzzing-safe test.js
- Observe MOZ_CRASH(*** Atom is marked gray for zone ... at argument 0) at js/src/vm/JSContext-inl.h:113.
- test.js:
let other = newGlobal({newCompartment: true});
let s1 = Symbol();
let s2 = Symbol();
let wm = new WeakMap();
wm.set(s1, s2);
other.s = s1;
other.eval('grayRoot()[0] = s; s = undefined');
s1 = s2 = undefined;
gc();
let r = other.eval('grayRoot()[0]');
wm.get(r);
Security Impact
- Severity: High
- Attacker capability: Violation of the GC black->gray invariant for JS::Symbol allows a live Symbol to be considered unreferenced by a zone and swept while still reachable from script, yielding a use-after-free of a GC cell in the atoms zone. UAF of a GC cell is a memory-safety issue that can be leveraged toward type confusion and arbitrary code execution in the content process.
- Preconditions: Attacker-controlled JavaScript must create a WeakMap whose Symbol key becomes only gray-reachable (via a cross-compartment gray root such as a cycle-collector / chrome wrapper edge in the browser), then re-expose the key and call WeakMap.prototype.get before the value Symbol is otherwise black-marked. No user interaction or non-default configuration is required.
ASAN Report
[142442] Hit MOZ_CRASH(*** Atom is marked gray for zone 583cc5c40950 at argument 0) at /firefox/js/src/vm/JSContext-inl.h:113
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==142442==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x583c833f4d2f bp 0x7ffec1be06e0 sp 0x7ffec1be06b0 T142442)
==142442==The signal is caused by a WRITE memory access.
==142442==Hint: address points to the zero page.
#0 0x583c833f4d2f in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3
#1 0x583c833f4d2f in MOZ_Crash(char const*, int, char const*) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:373:3
#2 0x583c833f4d2f in void js::ContextChecks::checkAtom<JS::Symbol>(JS::Symbol*, int) /firefox/js/src/vm/JSContext-inl.h:111:9
#3 0x583c84750fd1 in void JSContext::checkImpl<JS::MutableHandle<JS::Value>>(JS::MutableHandle<JS::Value> const&) /firefox/js/src/vm/JSContext-inl.h:216:33
#4 0x583c84750fd1 in void JSContext::check<JS::MutableHandle<JS::Value>>(JS::MutableHandle<JS::Value> const&) /firefox/js/src/vm/JSContext-inl.h:223:5
#5 0x583c84750fd1 in CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:492:9
#6 0x583c8472c2b5 in js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:586:12
#7 0x583c8473c05c in js::CallFromStack(JSContext*, JS::CallArgs const&, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:658:10
#8 0x583c8473c05c in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3272:16
#9 0x583c8472b92a in js::RunScript(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:460:13
#10 0x583c8472e790 in js::ExecuteKernel(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, js::AbstractFramePtr, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:850:10
#11 0x583c8472ebee in js::Execute(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:880:10
#12 0x583c849e8121 in ExecuteScript(JSContext*, JS::Handle<JSObject*>, JS::Handle<JSScript*>, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:548:10
#13 0x583c849e8303 in JS_ExecuteScript(JSContext*, JS::Handle<JSScript*>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:572:10
#14 0x583c8333052d in RunFile(JSContext*, char const*, _IO_FILE*, CompileUtf8, bool, bool) /firefox/js/src/shell/js.cpp:1388:10
#15 0x583c8332fa19 in Process(JSContext*, char const*, bool, FileKind) /firefox/js/src/shell/js.cpp
#16 0x583c832eb53c in ProcessArgs(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12130:10
#17 0x583c832eb53c in Shell(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12383:12
#18 0x583c832e2400 in main /firefox/js/src/shell/js.cpp:12789:12
#19 0x7d16fbac51c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9)
#20 0x7d16fbac528a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a)
#21 0x583c832a9bd8 in _start (/firefox/obj-js-debug/dist/bin/js+0x2690bd8)
SUMMARY: UndefinedBehaviorSanitizer: SEGV /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:235:3 in MOZ_CrashSequence(void*, long)
==142442==ABORTING
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
| Reporter | ||
Comment 3•7 months ago
|
||
Updated•7 months ago
|
Updated•7 months ago
|
| Assignee | ||
Comment 6•7 months ago
|
||
Great find.
if (Map::Ptr ptr = map->lookup(key)) {
args.rval().set(ptr->value()); // <-- gray Symbol returned without read barrier / markAtom
The read barrier should be here in lookup: https://searchfox.org/firefox-main/source/js/src/gc/WeakMap.h#408-416
Looks like something is going wrong though.
Updated•7 months ago
|
Comment 7•7 months ago
|
||
No prefs required — grayRoot() models CC gray edges that exist in the browser since bug 1994871 added Symbols to the CC graph.
(In reply to comment #6)
The read barrier should be here in lookup
Confirmed it does fire. The problem is downstream: ExposeValueToActiveJS → ExposeGCThingToActiveJS checks TenuredCellIsMarkedBlack(cell) against the Symbol's atoms-zone mark bits (which are black, since the Symbol is reachable) and early-returns at HeapAPI.h:886. The per-zone atom bitmap is never consulted there because it has no notion of the calling zone.
Reproduced in a non-debug ASAN shell using getAtomMarkColor:
s2 color after GC (before get): gray
s2 color after wm.get(): gray <-- bug; should be black
Verified attachment 9565218 [details] [diff] [review]: applies cleanly, builds, and valueReadBarrier now black-marks the Symbol in the WeakMap's zone bitmap (gray → black after get). Also covers the Object-key + Symbol-value variant and the getOrInsert / lookupForAdd path. Existing gc/weak* and gray-Symbol jit-tests pass.
This is an automated analysis result. If this result is incorrect please add a needinfo and feel free to correct the error.
Updated•7 months ago
|
| Assignee | ||
Updated•7 months ago
|
| Assignee | ||
Comment 9•7 months ago
|
||
ExposeValueToActiveJS can't do this because it doesn't know the referring zone.
| Assignee | ||
Comment 10•7 months ago
|
||
Updated•7 months ago
|
Comment 11•7 months ago
|
||
Since this is a sec-high I made it S2
| Assignee | ||
Updated•7 months ago
|
| Assignee | ||
Comment 12•7 months ago
|
||
Comment on attachment 9567065 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Very difficult.
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: Yes
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: All back to 146
- If not all supported branches, which bug introduced the flaw?: Bug 1994871
- Do you have backports for the affected branches?: No
- If not, how different, hard to create, and risky will they be?: Should be trivial.
- How likely is this patch to cause regressions; how much testing does it need?: Unlikely. This is a simple fix with not much room to go wrong.
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Updated•7 months ago
|
Updated•7 months ago
|
Comment 13•7 months ago
|
||
Set release status flags based on info from the regressing bug 1994871
Updated•7 months ago
|
Updated•7 months ago
|
Updated•7 months ago
|
Comment 14•7 months ago
|
||
Updated•7 months ago
|
Comment 15•7 months ago
|
||
Comment 16•7 months ago
|
||
Comment 17•7 months ago
|
||
Comment 18•7 months ago
|
||
Updated•7 months ago
|
| Assignee | ||
Comment 20•7 months ago
|
||
ExposeValueToActiveJS can't do this because it doesn't know the referring zone.
Original Revision: https://phabricator.services.mozilla.com/D292494
Updated•7 months ago
|
Comment 21•7 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined/Reason for urgency: Possible crash / security vulnerability.
- Code covered by automated testing?: yes
- Fix verified in Nightly?: yes
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: This is a simple fix and has been on central for three days with no issues.
- String changes made/needed?: None.
- Is Android affected?: yes
Updated•7 months ago
|
Updated•7 months ago
|
Comment 22•7 months ago
|
||
| uplift | ||
Updated•7 months ago
|
| Assignee | ||
Updated•7 months ago
|
Updated•7 months ago
|
Updated•2 months ago
|
Updated•2 months ago
|
Comment 23•2 months ago
|
||
Comment 24•2 months ago
|
||
Comment 25•2 months ago
|
||
Backed out for causing lint failures
Backout Link
Push with failures
Failure Log
Failure line TEST-UNEXPECTED-ERROR | /builds/worker/checkouts/gecko/js/src/testcase.js:1:13 | 'newGlobal' is not defined. (no-undef)
Comment 26•2 months ago
|
||
Comment 27•2 months ago
|
||
| bugherder | ||
Updated•2 months ago
|
Description
•