Closed Bug 2055676 Opened 3 months ago Closed 3 months ago

Type confusion in [@ ForEachReaction] via double-settled promise: attacker-controlled JSObject* from PromiseSlot_ReactionsOrResult after onNewPromise re-entrancy in PromiseObject::unforgeableReject

Categories

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

defect

Tracking

()

RESOLVED FIXED
155 Branch
Tracking Status
firefox-esr115 --- wontfix
firefox-esr140 --- wontfix
firefox-esr153 154+ fixed
firefox153 --- wontfix
firefox154 + fixed
firefox155 + fixed

People

(Reporter: bugmon, Assigned: arai)

References

(Blocks 2 open bugs)

Details

(5 keywords, Whiteboard: [adv-main154+r][adv-esr153.1+r])

Attachments

(3 files, 3 external files)

PromiseObject::unforgeableReject (js/src/builtin/Promise.cpp) creates a new promise via CreatePromiseObjectWithoutResolutionFunctions and then rejects it with RejectPromiseInternal, assuming the promise is still pending. However, promise creation fires the Debugger API hook DebugAPI::onNewPromise (Promise.cpp:2160), which re-enters arbitrary debugger JS between creation and rejection. If that hook settles the promise first (in the JS shell via the fuzzing-safe testing builtin resolvePromise(), which bypasses the missing default resolving functions), the pending-state invariant is violated. The check in the static ResolvePromise worker (Promise.cpp:2028) and in unforgeableReject (Promise.cpp:6063) is MOZ_ASSERT-only, so release builds settle the promise a second time.

PromiseObject uses a single fixed slot, PromiseSlot_ReactionsOrResult, for both the reactions list (while pending) and the result value (once settled). On the second settle, ResolvePromise reads the slot as the reactions list (Promise.cpp:2042) even though it now contains the earlier resolution RESULT, and passes it to TriggerPromiseReactions. ForEachReaction (Promise.cpp:2420) calls Value::toObject() on it; for a non-object value such as a crafted double, release-mode toObject() extracts the NaN-box payload bits as a raw JSObject*. The testcase encodes 0x0000414141410000 in a double, and the engine immediately dereferences 0x414141410000 while reading the fake object's shape/class (rsi = 0xfffe414141410000 in the ASAN crash). With the payload pointing at attacker-groomed mapped memory, the confused 'reaction record' is subsequently read and written through (EnqueuePromiseReactionJob mutates reaction-record fixed slots), yielding a powerful memory-corruption primitive.

The JS-reachable trigger for unforgeableReject is a Debugger onEnterFrame hook returning {throw: v} on an async function frame before its internal generator object exists (AdjustGeneratorResumptionValue, js/src/debugger/Debugger.cpp:1876). The crash was independently reproduced under --fuzzing-safe on an ASAN build (SEGV on the attacker-controlled address) and a debug build (Assertion failure: promise->state() == JS::PromiseState::Pending, Promise.cpp:6063).

Build Info

Affected Code

File: js/src/builtin/Promise.cpp, line 6051-6072

PromiseObject* PromiseObject::unforgeableReject(JSContext* cx,
                                                HandleValue value) {
  cx->check(value);

  // Step 2. Let promiseCapability be ? NewPromiseCapability(C).
  Rooted<PromiseObject*> promise(
      cx, CreatePromiseObjectWithoutResolutionFunctions(cx));   // <-- fires DebugAPI::onNewPromise -> arbitrary user JS can settle `promise`
  if (!promise) {
    return nullptr;
  }

  MOZ_ASSERT(promise->state() == JS::PromiseState::Pending);    // <-- debug-only; violated in release
  MOZ_ASSERT(IsPromiseWithDefaultResolvingFunction(promise));

  // Step 3. Perform ? Call(promiseCapability.[[Reject]], undefined, « r »).
  if (!RejectPromiseInternal(cx, promise, value)) {             // <-- second settle of an already-settled promise
    return nullptr;
  }
  return promise;
}

File: js/src/builtin/Promise.cpp, line 2147-2164

[[nodiscard]] PromiseObject* js::CreatePromiseObjectWithoutResolutionFunctions(
    JSContext* cx, int32_t extraFlags) {
  JS::Rooted<PromiseObject*> promise(cx, CreatePromiseObjectInternal(cx));
  if (!promise) {
    return nullptr;
  }

  AddPromiseFlags(*promise,
                  PROMISE_FLAG_DEFAULT_RESOLVING_FUNCTIONS | extraFlags);

  // Let the Debugger know about this Promise, after we've set
  // flags and slots.
  DebugAPI::onNewPromise(cx, promise);   // <-- re-entrancy point: onNewPromise hook runs user JS while caller assumes promise stays pending

  return promise;
}

File: js/src/builtin/Promise.cpp, line 2023-2081

[[nodiscard]] static bool ResolvePromise(
    JSContext* cx, Handle<PromiseObject*> promise, HandleValue valueOrReason,
    JS::PromiseState state,
    Handle<SavedFrame*> unwrappedRejectionStack = nullptr) {
  // Step 1. Assert: The value of promise.[[PromiseState]] is pending.
  MOZ_ASSERT(promise->state() == JS::PromiseState::Pending);   // <-- debug-only pending check
  ...
  RootedValue reactionsVal(cx, promise->reactions());          // <-- reads PromiseSlot_ReactionsOrResult: after first settle this is the RESULT value (a crafted double), not a reactions list
  ...
  promise->setFixedSlot(PromiseSlot_ReactionsOrResult, valueOrReason);
  ...
  return TriggerPromiseReactions(cx, reactionsVal, state, valueOrReason);  // <-- result-vs-reactions type confusion
}

File: js/src/builtin/Promise.cpp, line 2415-2426

template <typename F>
static bool ForEachReaction(JSContext* cx, HandleValue reactionsVal, F f) {
  if (reactionsVal.isUndefined()) {
    return true;
  }

  RootedObject reactions(cx, &reactionsVal.toObject());        // <-- release toObject() on a double extracts NaN-box payload bits as a raw JSObject* (0x414141410000)
  RootedObject reaction(cx);

  if (reactions->is<PromiseReactionRecord>() || IsWrapper(reactions) ||   // <-- first deref of attacker-controlled pointer (shape/class read) => ASAN SEGV
      JS_IsDeadWrapper(reactions)) {
    return f(&reactions);
  }

File: js/src/debugger/Debugger.cpp, line 1870-1885

    } else {
      // We're before entering the actual function code.

      // 1.  `throw <value>` creates a promise rejected with the value *vp.
      // 1.  `return <value>` creates a promise resolved with the value *vp.
      JSObject* promise = resumeMode == ResumeMode::Throw
                              ? PromiseObject::unforgeableReject(cx, vp)   // <-- JS-reachable trigger: onEnterFrame returns {throw: v} on an async fn frame before its generator exists
                              : PromiseObject::unforgeableResolve(cx, vp);
      if (!promise) {
        return false;
      }
      vp.setObject(*promise);

      // 2.  Return normally in both cases.
      resumeMode = ResumeMode::Return;
    }

The invariant 'promise is pending' is enforced only by MOZ_ASSERT along the entire path. unforgeableReject creates a promise (firing the onNewPromise debugger hook, which runs arbitrary user JS) and then rejects it without re-checking the state in release builds. ResolvePromise then misinterprets the result value stored in the shared PromiseSlot_ReactionsOrResult slot as a reactions list, and ForEachReaction performs toObject() on it, materializing an attacker-controlled object pointer from a double's NaN-box payload.

Exploit Chain

  1. Create two globals: a debuggee global g and a helper global M; in M, attach Debugger dbg1 to g with an onEnterFrame hook that returns {throw: 1} when an async function frame is entered (before the frame's internal generator object exists).
  2. Craft a double whose raw IEEE-754 bits are 0x0000414141410000 via ArrayBuffer/BigUint64Array/Float64Array aliasing; when release-mode Value::toObject() is applied to this double, the NaN-box payload 0x414141410000 becomes a raw JSObject*.
  3. Attach a second Debugger dbg2 to g with an onNewPromise hook. Call an async function in g: onEnterFrame fires, returns {throw: 1}, and AdjustGeneratorResumptionValue (Debugger.cpp:1876) calls PromiseObject::unforgeableReject.
  4. unforgeableReject calls CreatePromiseObjectWithoutResolutionFunctions, which fires DebugAPI::onNewPromise. The hook stashes the brand-new pending promise and force-settles it with the crafted double using the fuzzing-safe shell builtin resolvePromise(promise, evilDouble) — the promise has no default resolving functions yet, so no [[AlreadyResolved]] guard stops this. The promise state becomes Fulfilled and PromiseSlot_ReactionsOrResult now holds the double.
  5. Control returns to unforgeableReject, which (release build: MOZ_ASSERT pending is compiled out) calls RejectPromiseInternal -> ResolvePromise on the already-settled promise. ResolvePromise reads PromiseSlot_ReactionsOrResult as the reactions list, obtaining the crafted double, and passes it to TriggerPromiseReactions.
  6. ForEachReaction calls reactionsVal.toObject() on the double, materializing the fake JSObject* 0x414141410000, and immediately dereferences it to read its shape/class (JSObject::is<PromiseReactionRecord>) — SEGV on the attacker-chosen address (rsi = 0xfffe414141410000).
  7. With the payload pointing at attacker-groomed mapped memory instead of 0x414141410000, the fake object passes the class checks and flows into EnqueuePromiseReactionJob, which reads and WRITES fixed slots of the fake PromiseReactionRecord (e.g. setTargetStateAndHandlerArg), converting the confusion into controlled memory read/write and a path toward arbitrary code execution.

Steps to Reproduce

  1. Build the SpiderMonkey/Firefox ASAN fuzzing shell: build_firefox with mozconfig /app/mozconfigs/mozconfig.linux.asan.fuzzing (build_dir: /firefox/obj-firefox-asan).
  2. Run the testcase (testcase.js from the finding) via js_shell_evaluator with js_binary /firefox/obj-firefox-asan/dist/bin/js (the evaluator always passes --fuzzing-safe).
  3. Observe AddressSanitizer SEGV (READ) in js::gc::HeaderWord::get via ForEachReaction<...> at Promise.cpp:2423, with rsi = 0xfffe414141410000 showing the attacker-controlled NaN-boxed pointer.
  4. Optionally corroborate the violated invariant on a debug shell: build with /app/mozconfigs/mozconfig.js.linux.debug (build_dir: /firefox/obj-js-debug) and run the same testcase; observe 'Assertion failure: promise->state() == JS::PromiseState::Pending, at js/src/builtin/Promise.cpp:6063'.

Security Impact

  • Severity: High
  • Attacker capability: The attacker turns a fully controlled 48-bit NaN-box payload into a JSObject* that the engine dereferences and then reads/writes through as if it were a PromiseReactionRecord (fixed-slot reads and writes in EnqueuePromiseReactionJob). Pointed at groomed memory, this is a type-confusion read/write primitive suitable for building arbitrary read/write and ultimately code execution in the process hosting the JS engine.
  • Preconditions: Requires the ability to run the Debugger API against a debuggee global (JS shell, or chrome-privileged/DevTools contexts in the browser) plus a way to settle the freshly created promise from within the onNewPromise hook; in the --fuzzing-safe shell this is the resolvePromise() testing builtin, whose [[AlreadyResolved]] guard does not apply because the promise has no resolving functions yet. Reproduces deterministically with no timing or OOM dependence.

ASAN Report

==4972==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x5cc073d041e0 bp 0x7ffc6cd954d0 sp 0x7ffc6cd95380 T0)
==4972==The signal is caused by a READ memory access.
==4972==Hint: this fault was caused by a dereference of a high value address (see register values below).
    #0 0x5cc073d041e0 in js::gc::HeaderWord::get() const /firefox/js/src/gc/Cell.h:113:23
    #1 0x5cc073d041e0 in js::gc::CellWithTenuredGCPointer<js::gc::Cell, js::Shape>::headerPtr() const /firefox/js/src/gc/Cell.h:876:60
    #2 0x5cc073d041e0 in JSObject::shape() const /firefox/js/src/vm/JSObject.h:100:37
    #3 0x5cc073d041e0 in JSObject::getClass() const /firefox/js/src/vm/JSObject.h:121:44
    #4 0x5cc073d041e0 in bool JSObject::is<PromiseReactionRecord>() const /firefox/js/src/vm/JSObject.h:530:12
    #5 0x5cc073d041e0 in bool ForEachReaction<TriggerPromiseReactions(...)::$_0>(JSContext*, JS::Handle<JS::Value>, ...) js/src/builtin/Promise.cpp:2423:18
    #6 0x5cc073d041e0 in TriggerPromiseReactions(JSContext*, JS::Handle<JS::Value>, JS::PromiseState, JS::Handle<JS::Value>) js/src/builtin/Promise.cpp:2459:10
    #7 0x5cc073d041e0 in ResolvePromise(JSContext*, JS::Handle<js::PromiseObject*>, JS::Handle<JS::Value>, JS::PromiseState, JS::Handle<js::SavedFrame*>) js/src/builtin/Promise.cpp:2081:10
    #8 0x5cc073d0ba80 in js::RejectPromiseInternal(JSContext*, JS::Handle<js::PromiseObject*>, JS::Handle<JS::Value>, JS::Handle<js::SavedFrame*>) js/src/builtin/Promise.cpp:2094:10
    #9 0x5cc073d0ba80 in js::PromiseObject::unforgeableReject(JSContext*, JS::Handle<JS::Value>) js/src/builtin/Promise.cpp:6067:8
    #10 0x5cc073e9b287 in AdjustGeneratorResumptionValue(JSContext*, js::AbstractFramePtr, js::ResumeMode&, JS::MutableHandle<JS::Value>) js/src/debugger/Debugger.cpp:1876:33
    #11 0x5cc073e9b287 in ApplyFrameResumeMode(...) js/src/debugger/Debugger.cpp:284:8
    #14 0x5cc073e94f56 in js::Debugger::dispatchResumptionHook(...) js/src/debugger/Debugger.cpp:2498:23
    #15 0x5cc073e94f56 in js::DebugAPI::slowPathOnEnterFrame(JSContext*, js::AbstractFramePtr) js/src/debugger/Debugger.cpp:929:10
    #16 0x5cc0755c357f in js::DebugAPI::onEnterFrame(JSContext*, js::AbstractFramePtr) /firefox/js/src/debugger/DebugAPI-inl.h:117:12
    #17 0x5cc0755c357f in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3363:12
    ...
==4972==Register values:
rax = 0x1fffc82828282000  rbx = 0x000077d670be0c80  rcx = 0x00000eface17c193  rdx = 0x00000fff8d9b2a81
rdi = 0x000077d670be0c98  rsi = 0xfffe414141410000  rbp = 0x00007ffc6cd954d0  rsp = 0x00007ffc6cd95380
SUMMARY: AddressSanitizer: SEGV /firefox/js/src/gc/Cell.h:113:23 in js::gc::HeaderWord::get() const

Debug-build corroboration (same testcase, /firefox/obj-js-debug/dist/bin/js):
[5346] Assertion failure: promise->state() == JS::PromiseState::Pending, at js/src/builtin/Promise.cpp:6063
    #1 js::PromiseObject::unforgeableReject(JSContext*, JS::Handle<JS::Value>) Promise.cpp:6063
    #2 AdjustGeneratorResumptionValue(...) Debugger.cpp:1876
Attached file testcase.js —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Assignee: nobody → arai.unmht
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attached file (secure) —
Severity: -- → S3
Priority: -- → P2
Pushed by nfay@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/6ef37b3f695a https://hg.mozilla.org/integration/autoland/rev/3212eab9050a Revert "Bug 2055676 - Throw an error if a debugger hook breaks the promise invariant. r=mgaudet" for causing bc and xpcshell failures

I overlooked that the PromiseObject::create case can already resolved the promise before calling the debugger hook.
I'll fix the hook caller to check that the state is actually modified.

Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 3 months ago
Resolution: --- → FIXED
Target Milestone: --- → 155 Branch

Please add uplift requests for Beta and ESR153

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: Possible crash or exploit when an user operates on Debugger in a specific way on attacker-controlled website.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing: none
  • Risk associated with taking this patch: low
  • Explanation of risk level: This patch adds an additional check inside the debugger's code, which is executed only when the user operates on the Debugger, and the additional path throws a regular error, which code path is well-tested.
  • String changes made/needed?: none
  • Is Android affected?: yes
Attachment #9613864 - Flags: approval-mozilla-beta?
Attached file (secure) —

firefox-esr153 Uplift Approval Request

  • User impact if declined/Reason for urgency: Possible crash or exploit when an user operates on Debugger in a specific way on attacker-controlled website.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing: none
  • Risk associated with taking this patch: low
  • Explanation of risk level: This patch adds an additional check inside the debugger's code, which is executed only when the user operates on the Debugger, and the additional path throws a regular error, which code path is well-tested.
  • String changes made/needed?: none
  • Is Android affected?: yes
Attachment #9613865 - Flags: approval-mozilla-esr153?
Attached file (secure) —
Flags: needinfo?(arai.unmht)
Attachment #9613864 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Attachment #9613865 - Flags: approval-mozilla-esr153? → approval-mozilla-esr153+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c155/b154]
Whiteboard: [adv-main154+r][adv-esr53.1+r]
Whiteboard: [adv-main154+r][adv-esr53.1+r] → [adv-main154+r][adv-esr153.1+r]
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: