Closed Bug 2029295 Opened 7 months ago Closed 2 months ago

Gray JS::Symbol exposed to script via WeakMap.prototype.get (MOZ_CRASH in ContextChecks::checkAtom)

Categories

(Core :: JavaScript: GC, defect, P1)

defect

Tracking

()

RESOLVED FIXED
151 Branch
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)

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

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

  1. Create two Symbols s1, s2 and a WeakMap wm; wm.set(s1, s2).
  2. 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.
  3. Run a full GC. WeakMap marking marks the entry value s2 with min(map=black, key=gray) = gray in the zone's atom bitmap.
  4. Read s1 back through the cross-compartment edge; the wrapper read barrier black-marks s1 in the current zone.
  5. 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.
  6. 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

  1. Build a debug SpiderMonkey JS shell (e.g. /firefox/obj-js-debug/dist/bin/js).
  2. Save the testcase below as test.js.
  3. Run: obj-js-debug/dist/bin/js --fuzzing-safe test.js
  4. Observe MOZ_CRASH(*** Atom is marked gray for zone ... at argument 0) at js/src/vm/JSContext-inl.h:113.
  5. 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
Attached file test.js —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Component: JavaScript Engine → JavaScript: GC

More symbol weakmap key shenanigans.

Flags: needinfo?(jcoppeard)

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.

Whiteboard: [prefs-checked]

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.

Whiteboard: [prefs-checked] → [prefs-checked][pp2]
Assignee: nobody → jcoppeard
Flags: needinfo?(jcoppeard)
Attached file (secure) —

ExposeValueToActiveJS can't do this because it doesn't know the referring zone.

Blocks: GC.stability
Severity: -- → S3
Priority: -- → P1

Since this is a sec-high I made it S2

Blocks: sm-security
Severity: S3 → S2
Keywords: regression
Regressed by: 1994871

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
Attachment #9567065 - Flags: sec-approval?
Attachment #9565218 - Flags: sec-approval?
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true

Set release status flags based on info from the regressing bug 1994871

Attachment #9567065 - Flags: sec-approval? → sec-approval+
Attachment #9565218 - Flags: sec-approval? → sec-approval+
Pushed by jcoppeard@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/b2614937805b https://hg.mozilla.org/integration/autoland/rev/fcda089fff19 Add read barrier to mark symbols retrieved from weak maps in the atom marking bitmap for the zone r=sfink
Pushed by asilaghi@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/fdf0b9f07796 https://hg.mozilla.org/integration/autoland/rev/c86a31ed7386 Revert "Bug 2029295 - Add read barrier to mark symbols retrieved from weak maps in the atom marking bitmap for the zone r=sfink" for causing xpcshell failures at AtomsZone
Pushed by asilaghi@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/0083cb8be7c8 https://hg.mozilla.org/integration/autoland/rev/82e5b992b4d9 Add read barrier to mark symbols retrieved from weak maps in the atom marking bitmap for the zone r=sfink
Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 7 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch
Flags: needinfo?(jcoppeard)

Please nominate this for Beta uplift.

Flags: needinfo?(jcoppeard)
Attached file (secure) —

ExposeValueToActiveJS can't do this because it doesn't know the referring zone.

Original Revision: https://phabricator.services.mozilla.com/D292494

Attachment #9569666 - Flags: approval-mozilla-beta?

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
Attachment #9569666 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c151/b150]
Flags: needinfo?(jcoppeard)
Whiteboard: [prefs-checked][pp2] → [prefs-checked][pp2][adv-main150+r]
Blocks: 2034837
Group: core-security-release
Attachment #9567066 - Attachment description: (secure) → Bug 2029295 - Add testcase r?sfink

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)

Status: RESOLVED → REOPENED
Flags: needinfo?(jcoppeard)
Resolution: FIXED → ---
Status: REOPENED → RESOLVED
Closed: 7 months ago → 2 months ago
Resolution: --- → FIXED
Flags: needinfo?(jcoppeard) → in-testsuite+
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: