Closed Bug 2077075 (CVE-2026-118026) Opened 27 days ago Closed 21 days ago

Gray wrapper transplantation violates a SpiderMonkey GC invariant

Categories

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

defect

Tracking

()

RESOLVED FIXED
159 Branch
Tracking Status
firefox-esr115 --- wontfix
firefox-esr153 --- wontfix
firefox157 --- wontfix
firefox158 --- wontfix
firefox159 --- fixed

People

(Reporter: bugmon, Assigned: jonco)

References

(Blocks 1 open bug)

Details

(4 keywords, Whiteboard: [adv-main159+])

Attachments

(3 files, 1 external file)

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

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

  1. Create a transplantable object and two globals in separate compartments.
  2. Retain the object through a WeakMap rooted by grayRoot() in the holder compartment, then perform a full collection.
  3. Start an incremental collection of only the destination zone and advance past Prepare and MarkRoots.
  4. Transplant the object into the destination compartment while the holder’s zone remains uncollected.
  5. Finish the collection. Incoming-gray-edge tracing encounters the gray wrapper’s nursery target and triggers MOZ_CRASH.

Steps to Reproduce

  1. Use clean Firefox revision 8445f37d6a14937595eabb96376b4433b4eb67e7.
  2. Build with build_firefox(firefox_dir="/firefox", mozconfig_path="/app/mozconfigs/mozconfig.js.linux.debug").
  3. 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.
  4. Observe crashed=true, timed_out=false, exit_code=1, and MOZ_CRASH at js/src/gc/Marking.cpp:258.
  5. Inspect /app/output/verification-tmpj56p8wtx/crash_stack.txt for the independent crash and /app/output/verification-tmpj56p8wtx/reproduction.json for build details and controls.
  6. 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
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —

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.

Group: core-security → javascript-core-security
See Also: → 1651001
Duplicate of this bug: 2077072
Component: JavaScript Engine → JavaScript: GC
Blocks: sm-security
Severity: -- → S3
Priority: -- → P3
Assignee: nobody → jcoppeard

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.

: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.

Flags: needinfo?(jcoppeard)

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.

Group: javascript-core-security
Flags: needinfo?(jcoppeard)

The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.

Status: UNCONFIRMED → NEW
Ever confirmed: true
Attachment #9654855 - Attachment description: (secure) → Bug 2077075 - Remove marking check that doesn't hold r?#spidermonkey-reviewers
Status: NEW → RESOLVED
Closed: 21 days ago
Resolution: --- → FIXED
Target Milestone: --- → 159 Branch
Whiteboard: [adv-main159+]
Alias: CVE-2026-117857
Alias: CVE-2026-117857 → CVE-2026-118026
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: