Open Bug 2078861 Opened 1 month ago Updated 1 month ago

MOZ_CRASH(Invalid object. Dead wrapper?) in JSObject::maybeUnwrapAs<SavedFrame> via shell PrintUnhandledRejection after resolution-site wrapper is nuked

Categories

(Core :: JavaScript Engine, defect, P3)

defect

Tracking

()

UNCONFIRMED

People

(Reporter: bugmon, Unassigned)

References

(Blocks 1 open bug)

Details

(Keywords: crash, testcase)

Attachments

(3 files)

PrintUnhandledRejection in js/src/shell/js.cpp reads promise->resolutionSite() into a rooted site before calling JS_ValueToSource on the rejection reason, and uses site in JS::BuildStackString afterwards without re-checking it. PromiseObject::resolutionSite() filters dead wrappers only at the time of that read. JS_ValueToSource invokes a script-defined toSource, which can nuke the cross-compartment wrapper held in site; BuildStackString then passes the resulting dead proxy to js::UnwrapSavedFrame, and JSObject::maybeUnwrapAs<SavedFrame>() reaches MOZ_CRASH("Invalid object. Dead wrapper?").

A script author running code in the SpiderMonkey shell can abort the shell process with a release-mode MOZ_CRASH by leaving an unhandled rejection whose stack was captured in another compartment and whose reason's toSource calls nukeAllCCWs(). The demonstrated effect is a controlled abort at shell exit, not memory corruption. The faulty caller is JS shell code and the trigger relies on the shell-only nukeAllCCWs() testing function; no path from web content was demonstrated, and the Gecko callers of JS::BuildStackString were not examined.

Build Info

Affected Code

File: js/src/shell/js.cpp, line 12428-12455

[[nodiscard]] static bool PrintUnhandledRejection(
    JSContext* cx, Handle<PromiseObject*> promise) {
  RootedValue reason(cx, promise->reason());
  RootedObject site(cx, promise->resolutionSite());

  RootedString str(cx, JS_ValueToSource(cx, reason));
  ...
  if (!site) {
    fputs("(no stack trace available)\n", stderr);
    return true;
  }

  JSPrincipals* principals = cx->realm()->principals();
  RootedString stackStr(cx);
  if (!BuildStackString(cx, principals, site, &stackStr, 2)) {
    return false;
  }

File: js/src/builtin/Promise.cpp, line 656-666

JSObject* PromiseObject::resolutionSite() {
  auto debugInfo = PromiseDebugInfo::FromPromise(this);
  if (debugInfo) {
    JSObject* site = debugInfo->resolutionSite();
    if (site && !JS_IsDeadWrapper(site)) {
      MOZ_ASSERT(UncheckedUnwrap(site)->is<SavedFrame>());
      return site;
    }
  }
  return nullptr;
}

File: js/src/builtin/Promise.cpp, line 601-616

    RootedObject stack(cx, unwrappedRejectionStack);
    if (stack) {
      // The exception stack is always unwrapped so it might be in
      // a different compartment.
      if (!cx->compartment()->wrap(cx, &stack)) {
        cx->clearPendingException();
        return;
      }
    } else {
      ...
    }

    debugInfo->setFixedSlot(Slot_ResolutionSite, ObjectOrNullValue(stack));

File: js/src/vm/SavedStacks.cpp, line 753-767

js::SavedFrame* js::UnwrapSavedFrame(JSContext* cx, JSPrincipals* principals,
                                     HandleObject obj,
                                     JS::SavedFrameSelfHosted selfHosted,
                                     bool& skippedAsync) {
  if (!obj) {
    return nullptr;
  }

  Rooted<SavedFrame*> frame(cx, obj->maybeUnwrapAs<SavedFrame>());
  if (!frame) {
    return nullptr;
  }

  return GetFirstSubsumedFrame(cx, principals, frame, selfHosted, skippedAsync);
}

File: js/src/vm/JSObject.h, line 681-700

template <class T>
inline T* JSObject::maybeUnwrapAs() {
  ...
  if (is<T>()) {
    return &as<T>();
  }

  JSObject* unwrapped = js::CheckedUnwrapStatic(this);
  if (!unwrapped) {
    return nullptr;
  }

  if (MOZ_LIKELY(unwrapped->is<T>())) {
    return &unwrapped->as<T>();
  }

  MOZ_CRASH("Invalid object. Dead wrapper?");
}

File: js/src/shell/js.cpp, line 7688-7701

static bool NukeAllCCWs(JSContext* cx, unsigned argc, Value* vp) {
  CallArgs args = CallArgsFromVp(argc, vp);
  ...
  NukeCrossCompartmentWrappers(cx, AllCompartments(), cx->realm(),
                               NukeWindowReferences, NukeAllReferences);
  args.rval().setUndefined();
  return true;
}

The shell reporter fetches the resolution site (where dead wrappers are filtered) before a call that runs script, then uses it after. The site is a cross-compartment wrapper because setResolutionInfo wraps the unwrapped exception stack into the promise's compartment. maybeUnwrapAs<T> has no non-crashing path for an object that unwraps to something other than T.

Exploit Chain

  1. Create a second global in a separate compartment (newGlobal({newCompartment: true})) and define a function there that throws its argument, so the exception stack (a SavedFrame) is captured in that compartment.
  2. Call that function from an async function in the main compartment with an object o as the thrown value; the async function's promise is rejected and PromiseDebugInfo::setResolutionInfo stores a cross-compartment wrapper to the foreign SavedFrame as the resolution site (js/src/builtin/Promise.cpp:601-616).
  3. Leave the rejection unhandled so the shell calls ReportUnhandledRejections -> PrintUnhandledRejection at exit.
  4. PrintUnhandledRejection reads promise->resolutionSite() (live wrapper at this point), then calls JS_ValueToSource(reason), which invokes o.toSource().
  5. o.toSource() calls the shell function nukeAllCCWs(), turning the rooted site wrapper into a dead proxy.
  6. PrintUnhandledRejection passes site to JS::BuildStackString -> js::UnwrapSavedFrame -> JSObject::maybeUnwrapAs<SavedFrame>(), which hits MOZ_CRASH("Invalid object. Dead wrapper?") at js/src/vm/JSObject.h:699 and the shell aborts.

Steps to Reproduce

  1. Start from an unmodified tree (git -C /firefox diff is empty); no patches, prefs, or extra shell flags.
  2. Build the JS shell with build_firefox using /app/mozconfigs/mozconfig.js.linux.debug (build_dir /firefox/obj-js-debug).
  3. Run testcase.js with js_shell_evaluator and js_binary=/firefox/obj-js-debug/dist/bin/js (the evaluator adds --fuzzing-safe):

var g = newGlobal({newCompartment: true});
g.evaluate("function thrower(o) { throw o; }");
var o = {toSource() { nukeAllCCWs(); return "o"; }};
async function f() { g.thrower(o); }
f();
4. Observe crashed=true with 'Hit MOZ_CRASH(Invalid object. Dead wrapper?) at /firefox/js/src/vm/JSObject.h:699'.
5. Controls run by the verifier, both crashed=false (exit code 3, ordinary unhandled-rejection report): (a) same testcase with nukeAllCCWs() removed from toSource; (b) nukeAllCCWs() called at top level after f() instead of inside toSource, where resolutionSite() filters the dead wrapper.

Security Impact

  • Severity: Low
  • Attacker capability: Abort the SpiderMonkey JS shell with a deterministic MOZ_CRASH at shell exit. No out-of-bounds access, use-after-free, or type confusion was observed; the crash is an intentional abort on a dead wrapper. The faulty ordering is in js/src/shell/js.cpp, which is shell code, and the trigger uses the shell-only nukeAllCCWs() function, so no impact on Firefox was demonstrated.
  • Preconditions: Ability to run script in the JS shell with access to the shell testing functions newGlobal and nukeAllCCWs (both available under --fuzzing-safe). Not shown to be reachable from web content.

ASAN Report

Unhandled rejection: o
[2221] Hit MOZ_CRASH(Invalid object. Dead wrapper?) at /firefox/js/src/vm/JSObject.h:699
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==2221==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x55ee9cf78956 bp 0x7ec7e9500a70 sp 0x7ec7e9500a60 T2221)
==2221==The signal is caused by a READ memory access.
==2221==Hint: address points to the zero page.
    #0 0x55ee9cf78956 in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3
    #1 0x55ee9cf78956 in js::SavedFrame* JSObject::maybeUnwrapAs<js::SavedFrame>() /firefox/js/src/vm/JSObject.h:699:3
    #2 0x55ee9d0439a5 in js::UnwrapSavedFrame(JSContext*, JSPrincipals*, JS::Handle<JSObject*>, JS::SavedFrameSelfHosted, bool&) /firefox/obj-js-debug/js/src/vm/./../../../../js/src/vm/SavedStacks.cpp:761:38
    #3 0x55ee9d045b07 in JS::BuildStackString(JSContext*, JSPrincipals*, JS::Handle<JSObject*>, JS::MutableHandle<JSString*>, unsigned long, js::StackFormat) /firefox/obj-js-debug/js/src/vm/./../../../../js/src/vm/SavedStacks.cpp:1066:16
    #4 0x55ee9ba23f37 in PrintUnhandledRejection(JSContext*, JS::Handle<js::PromiseObject*>) /firefox/obj-js-debug/js/src/shell/./../../../../js/src/shell/js.cpp:12453:8
    #5 0x55ee9ba23f37 in ReportUnhandledRejections(JSContext*) /firefox/obj-js-debug/js/src/shell/./../../../../js/src/shell/js.cpp:12519:10
    #6 0x55ee9b9ddfe2 in Shell(JSContext*, js::cli::OptionParser*) /firefox/obj-js-debug/js/src/shell/./../../../../js/src/shell/js.cpp:12653:19
    #7 0x55ee9b9d63d6 in main /firefox/obj-js-debug/js/src/shell/./../../../../js/src/shell/js.cpp:13056:12
    #8 0x7ee9fe96f1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #9 0x7ee9fe96f28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #10 0x55ee9b99bb98 in _start (/firefox/obj-js-debug/dist/bin/js+0x2766b98) (BuildId: 538a5e1d223f99074750b183c7f52e6d)

UndefinedBehaviorSanitizer can not provide additional info.
SUMMARY: UndefinedBehaviorSanitizer: SEGV /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3 in MOZ_CrashSequence(void*, long)
==2221==ABORTING
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Attached file testcase.js —

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
Blocks: sm-security
Severity: -- → S3
Priority: -- → P3

This is a bug in a shell testing function. (The proposed patch only touches js/src/shell/js.cpp.) This isn't a sec bug.

Group: javascript-core-security
Keywords: sec-other
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: