Closed Bug 2045403 Opened 5 months ago Closed 4 months ago

Type confusion in [@ ResolvePromiseFunction] safe-resolve path settles a CrossCompartmentWrapper as a PromiseObject, corrupting wrapper internals

Categories

(Core :: JavaScript: Standard Library, defect, P2)

defect

Tracking

()

RESOLVED FIXED
153 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox152 --- unaffected
firefox153 --- fixed
firefox154 --- fixed

People

(Reporter: bugmon, Assigned: mgaudet)

References

(Blocks 2 open bugs, Regression)

Details

(6 keywords)

Attachments

(7 files)

SpiderMonkey's NIGHTLY-only Promise "safe resolve" feature (thenable-curtailment proposal, pref javascript.options.experimental.promise_safe_resolve) contains a type confusion that lets web content corrupt the internal slots of a live cross-compartment wrapper (CCW) ProxyObject in the content process. In ResolvePromiseFunction's doSafeResolve block (js/src/builtin/Promise.cpp:1677), the promise taken from the resolve function's [[Promise]] extended slot is cast with an unchecked as<PromiseObject>(). That slot can legitimately hold a CCW of the promise: when a promise from compartment A is resolved with a thenable whose then function lives in compartment B, EnqueuePromiseResolveThenableJob enters B's realm and wraps the promise into B's compartment, and PromiseResolveThenableJob calls CreateResolvingFunctions with the wrapped promise. All legacy consumers of this slot use wrapper-aware helpers (IsSettledMaybeWrappedPromise / FulfillMaybeWrappedPromise / RejectMaybeWrappedPromise); the new deferred-resolution path does not.

The confused ProxyObject is stored in a DeferredResolveJob. When the job runs, "fixed slot 0" of the fake PromiseObject aliases the CCW's private slot (its boxed target pointer), which reads as state Pending, so PerformPromiseResolution executes. Passing the promise's own CCW as the resolution value takes the unguarded self-resolution path (Promise.cpp:3092-3100), which calls RejectPromiseInternal directly on the proxy. The internal ResolvePromise then performs raw fixed-slot writes over the proxy's memory: it overwrites the wrapper's reserved slot 0 with the rejection reason, overwrites the CCW's private slot — the wrapper target pointer — with an Int32-tagged flags value, and clobbers reserved slot 1 (NativeObject fixed slots and ProxyObject's inline ProxyValueArray both start at byte offset 24). Subsequent use of the wrapper dereferences an attacker-influenced forged pointer: on the same ASAN build, the JS shell variant crashes with a wild-address ASAN SEGV (READ 0x50008df58000) in js::CrossCompartmentWrapper::get / js::Wrapper::wrappedObject. In the browser, the settle additionally reaches the unhandled-rejection tracker where JS::GetNonCCWObjectGlobal's release assertion catches the type-confused wrapper — after the corrupting writes have already executed — deterministically crashing the content process.

Web content reaches the bug with the experimental pref enabled by combining two same-site, cross-origin documents (x.example.com / y.example.com — different compartments under Gecko's compartment-per-origin model, same content process) that both set document.domain='example.com' to obtain transparent CCWs. The parent resolves its promise with a thenable from the child; the engine hands the child resolving functions whose [[Promise]] slot is a CCW of the parent promise; the child calls resolve(promiseCCW, true), triggering deferral (GetPropertyPure fails on proxies), the confused cast, and the corrupting deferred job.

Build Info

Affected Code

File: js/src/builtin/Promise.cpp, line 1666-1684

#ifdef NIGHTLY_BUILD
  // Thenable-curtailment proposal: if the caller asked for safe resolution
  // and the value would run user code, defer to a microtask. ...
  if (doSafeResolve) {
    bool needsDeferral = false;
    if (!RequiresDeferredPromiseResolution(cx, resolutionVal, &needsDeferral)) {
      return false;
    }
    if (needsDeferral) {
      Rooted<PromiseObject*> promiseObj(cx, &promise->as<PromiseObject>());  // <-- UNCHECKED CAST: `promise` may be a CrossCompartmentWrapper (ProxyObject)
      if (!EnqueueDeferredResolveJob(cx, promiseObj, resolutionVal)) {
        return false;
      }
      args.rval().setUndefined();
      return true;
    }
  }
#endif  // NIGHTLY_BUILD

File: js/src/builtin/Promise.cpp, line 3091-3101

  // Step 2.  (PerformPromiseResolution, run by the DeferredResolveJob)
  if (resolution.isObject() && &resolution.toObject() == promise) {
    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
                              JSMSG_CANNOT_RESOLVE_PROMISE_WITH_ITSELF);
    RootedValue selfResolutionError(cx);
    Rooted<SavedFrame*> stack(cx);
    if (!MaybeGetAndClearExceptionAndStack(cx, &selfResolutionError, &stack)) {
      return false;
    }
    return RejectPromiseInternal(cx, promise, selfResolutionError, stack);  // <-- direct write path; no MaybeWrapped handling, `promise` is the confused ProxyObject
  }

File: js/src/builtin/Promise.cpp, line 2095-2124

  // internal ResolvePromise(), reached via RejectPromiseInternal on the proxy:
  RootedValue reactionsVal(cx, promise->reactions());            // reads proxy reserved slot 0
  promise->setFixedSlot(PromiseSlot_ReactionsOrResult, valueOrReason);  // WRITE over proxy reserved slot 0
  int32_t flags = promise->flags();                              // reads low 32 bits of the CCW target pointer
  flags |= PROMISE_FLAG_RESOLVED;
  ...
  promise->setNeverGCThingFixedSlot(PromiseSlot_Flags, Int32Value(flags));  // WRITE: destroys the CCW's private slot (wrapper target pointer!)
  promise->setFixedSlot(PromiseSlot_RejectFunction, UndefinedValue());      // WRITE over proxy reserved slot 1
  PromiseObject::onSettled(cx, promise, unwrappedRejectionStack);           // -> rejection tracker -> GetNonCCWObjectGlobal release assert (browser)

File: js/src/builtin/Promise.cpp, line 2973-2992

  // Why the resolve function's [[Promise]] slot can hold a wrapper:
  RootedField<JSObject*, 2> then(roots, &thenVal.toObject());
  AutoFunctionOrCurrentRealm ar(cx, then);          // enter the `then` function's realm
  ...
  // Wrap the `promiseToResolve` and `thenable` arguments.
  if (!cx->compartment()->wrap(cx, &promiseToResolve)) {
    return false;
  }
  ...
  // At this point the promise is guaranteed to be wrapped into the job's
  // compartment.
  RootedField<JSObject*, 3> promise(roots, &promiseToResolve.toObject());
  // PromiseResolveThenableJob later calls CreateResolvingFunctions(cx, promise)
  // with this wrapped promise, storing the CCW in ResolveFunctionSlot_Promise.

The doSafeResolve block performs an unchecked PromiseObject cast on a value that can be a cross-compartment wrapper, and the deferred job's PerformPromiseResolution then settles the wrapper directly via RejectPromiseInternal, whose raw fixed-slot stores land on the ProxyObject's private/reserved slots.

Exploit Chain

  1. Enable (or target a Nightly profile with) the experimental pref javascript.options.experimental.promise_safe_resolve=true, which gives every Promise resolving function the optional doSafeResolve second parameter.
  2. Host attacker content on two same-site but cross-origin hosts, x.example.com and y.example.com. Gecko places them in different JS compartments (compartment-per-origin) inside the same content process (same site).
  3. Both documents set document.domain = 'example.com', making them same-origin-domain so objects flow between the two compartments through transparent CrossCompartmentWrappers.
  4. The parent page (x) creates a promise p with new Promise(r => res = r) and resolves it with a thenable t obtained from the child frame (y): res(t). Since t.then is a function from the child compartment, EnqueuePromiseResolveThenableJob enters the child realm and wraps p into the child compartment; the PromiseResolveThenableJob calls CreateResolvingFunctions(cx, CCW(p)) and invokes t.then(r, j) with resolving functions whose [[Promise]] slot holds the CCW of p.
  5. Inside t.then, the child calls r(pp, true) where pp is the child's CCW view of p (the same uniqued wrapper object). RequiresDeferredPromiseResolution uses GetPropertyPure, which fails on proxies, so needsDeferral=true and ResolvePromiseFunction executes promise->as<PromiseObject>() on the ProxyObject — the type confusion — and enqueues a DeferredResolveJob holding the proxy as both the promise and the resolution value.
  6. At the next microtask checkpoint the DeferredResolveJob runs: the fake PromiseObject's flags slot aliases the CCW's private slot (an aligned pointer, low bits 0) so state() reads Pending; PerformPromiseResolution detects self-resolution (resolution object == promise object) and calls RejectPromiseInternal directly on the proxy.
  7. ResolvePromise performs raw fixed-slot writes over the ProxyObject: reserved slot 0 := rejection reason (Error object), private slot (wrapper target pointer) := Int32Value(low32(ptr)|PROMISE_FLAG_RESOLVED), reserved slot 1 := undefined. The live wrapper's target pointer is now an attacker-influenced integer-tagged value — memory corruption achieved.
  8. Any subsequent use of the corrupted wrapper dereferences the forged pointer (shell PoC: next microtask touches pp -> ASAN wild-address SEGV READ at 0x50008df58000 in js::CrossCompartmentWrapper::get / js::Wrapper::wrappedObject). In the browser, the settle path additionally reaches the unhandled-rejection tracker where JS::GetNonCCWObjectGlobal release-asserts on the type-confused wrapper, crashing the content process after the corruption.

Steps to Reproduce

  1. Build Firefox with /app/mozconfigs/mozconfig.linux.asan.fuzzing (ASAN + fuzzing, NIGHTLY_BUILD; no source modifications needed).
  2. Launch the build against testcase.html served over HTTP on localhost with the following prefs: javascript.options.experimental.promise_safe_resolve=true; network.dns.localDomains="x.example.com,y.example.com"; network.proxy.autoconfig_url set to a PAC that returns DIRECT for localhost/127.0.0.1/x.example.com/y.example.com and blackholes everything else; dom.security.https_first=false; dom.security.https_only_mode=false; dom.origin_agent_cluster.default=false.
  3. The page redirects itself to http://x.example.com:<port>/testcase.html, iframes http://y.example.com:<port>/testcase.html, both set document.domain='example.com', and the parent resolves a promise with a child-compartment thenable whose then callback calls r(parentPromiseCCW, true).
  4. The content process crashes deterministically (~5s) with stack PerformPromiseResolution (Promise.cpp:3100) -> RejectPromiseInternal -> ResolvePromise -> PromiseObject::onSettled -> PromiseRejectionTrackerCallback -> xpc::NativeGlobal -> JS::GetNonCCWObjectGlobal release assert (jsapi.cpp:1197) firing on the type-confused CrossCompartmentWrapper after its internals were overwritten.
  5. Supporting memory-corruption evidence: run shell-testcase.js with /firefox/obj-firefox-asan/dist/bin/js --enable-promise-safe-resolve — AddressSanitizer reports SEGV at wild address 0x50008df58000 (READ) in js::CrossCompartmentWrapper::get dereferencing the smashed wrapper target pointer.

Security Impact

  • Severity: High
  • Attacker capability: Web content controlling two same-site origins can type-confuse a live CrossCompartmentWrapper ProxyObject as a PromiseObject and have the engine perform attacker-driven raw fixed-slot writes over the wrapper's internals, replacing the wrapper's target pointer with a partially attacker-influenced Int32-tagged value and overwriting its reserved slots with attacker-reachable objects. Subsequent wrapper operations treat the corrupted value as a JSObject*, yielding a forged/dangling object pointer primitive in the content process — a strong foundation for crafting reads/writes on forged objects and ultimately content-process code execution. The bug also breaks core compartment/wrapper invariants (a CCW is settled as if it were a local promise).
  • Preconditions: Nightly build (the feature is #ifdef NIGHTLY_BUILD) with the experimental pref javascript.options.experimental.promise_safe_resolve enabled (default false; flippable experiment). Attacker must control content on two same-site, cross-origin hosts and document.domain must be available (shipping default: dom.origin_agent_cluster.default=false). No user interaction beyond loading the page.

ASAN Report

[8753] Assertion failure: !IsCrossCompartmentWrapper(obj), at js/src/jsapi.cpp:1197
==8753==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (T0)
==8753==The signal is caused by a WRITE memory access.
    #0 MOZ_CrashSequence(void*, long) dist/include/mozilla/Assertions.h:261:3
    #1 JS::GetNonCCWObjectGlobal(JSObject*) js/src/jsapi.cpp:1197:3
    #2 xpc::NativeGlobal(JSObject*) js/xpconnect/wrappers/WrapperFactory.cpp:665:9
    #3 mozilla::CycleCollectedJSContext::PromiseRejectionTrackerCallback(...) xpcom/base/CycleCollectedJSContext.cpp:392:39
    #4 js::PromiseObject::onSettled(...) js/src/builtin/Promise.cpp:8034:20
    #5 ResolvePromise(...) js/src/builtin/Promise.cpp:2124:3
    #6 js::RejectPromiseInternal(...) js/src/builtin/Promise.cpp:2143:10
    #7 PerformPromiseResolution(...) js/src/builtin/Promise.cpp:3100:12
    #8 JS::RunJSMicroTask(...) js/src/builtin/Promise.cpp:8565:16
    #12 mozilla::CycleCollectedJSContext::PerformMicroTaskCheckPoint(...)
    #15 mozilla::EventListenerManager::HandleEventSingleListener(...)  (iframe onload)
    #22 nsGlobalWindowInner::FireFrameLoadEvent()
SUMMARY: AddressSanitizer: SEGV (libxul.so+0x22ccd3b0)

--- Same root cause, JS shell from the same ASAN build (--enable-promise-safe-resolve), demonstrating the wild-pointer dereference of the corrupted wrapper ---
==6662==ERROR: AddressSanitizer: SEGV on unknown address 0x50008df58000 (READ)
    #0 js::gc::detail::ChunkPtrHasStoreBuffer(void const*) js/HeapAPI.h:720:38
    #2 js::gc::ExposeGCThingToActiveJS(JS::GCCellPtr) js/HeapAPI.h:895:7
    #3 JS::ExposeObjectToActiveJS(JSObject*) js/HeapAPI.h:978:3
    #4 js::Wrapper::wrappedObject(JSObject*) js/src/proxy/Wrapper.cpp:308:5
    #5 js::CrossCompartmentWrapper::get(...) js/src/proxy/CrossCompartmentWrapper.cpp:173:24
    #18 PromiseReactionJob(...) js/src/builtin/Promise.cpp:2772:10
    #19 JS::RunJSMicroTask(...) js/src/builtin/Promise.cpp:8534:12
SUMMARY: AddressSanitizer: SEGV in js::gc::detail::ChunkPtrHasStoreBuffer
(rcx = 0xfff880006fb010d1: the wrapper's target slot after being overwritten with the Int32-tagged flags value)
Attached file testcase.html —
Attached file shell-testcase.js —
Attached file prefs.json —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Attached file README.txt —
Attached file prefs.js —
Group: core-security → javascript-core-security
Keywords: regression
Regressed by: 2033915

This is still off by default.

Blocks: 2033912

:mgaudet, since you are the author of the regressor, bug 2033915, could you take a look? Also, could you set the severity field?

For more information, please visit BugBot documentation.

Flags: needinfo?(mgaudet)
Blocks: sm-security
Severity: -- → S3
Priority: -- → P2

The severity field for this bug is set to S3. However, the bug is flagged with the sec-high keyword.
:gtonietto, could you consider increasing the severity of this security bug?

For more information, please visit BugBot documentation.

Flags: needinfo?(gtonietto)
Severity: S3 → S2
Flags: needinfo?(gtonietto)

This code is actually going to be deleted in bug 2046110, as this functionality is no longer going to be part of this proposal.

If we were to rebuild a future proposal here we should resuscitate this test case.

Severity: S2 → S3
Flags: needinfo?(mgaudet)

oops. Midair changed sev.

Severity: S3 → S2

Resolved by Bug 2046110

Status: UNCONFIRMED → RESOLVED
Closed: 4 months ago
Resolution: --- → FIXED

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

Group: javascript-core-security → core-security-release
Assignee: nobody → mgaudet
Depends on: 2046110
Target Milestone: --- → 153 Branch
QA Whiteboard: [sec] [qa-triage-done-c154/b153]
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: