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)
Tracking
()
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)
|
2.15 KB,
text/plain
|
Details | |
|
2.17 KB,
patch
|
Details | Diff | Splinter Review | |
|
3.52 KB,
text/plain
|
Details |
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
- Branch: main
- Revision: b5f92c07c92ce1a6870d2905c7594487e53dfeac
- Timestamp: 2026-07-13T01:27:29+00:00
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
- 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).
- 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*.
- 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.
- 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.
- 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.
- 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).
- 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
- Build the SpiderMonkey/Firefox ASAN fuzzing shell: build_firefox with mozconfig /app/mozconfigs/mozconfig.linux.asan.fuzzing (build_dir: /firefox/obj-firefox-asan).
- 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).
- 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.
- 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
| Reporter | ||
Comment 1•3 months ago
|
||
| Reporter | ||
Comment 2•3 months ago
|
||
| Reporter | ||
Comment 3•3 months ago
|
||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Comment 5•3 months ago
|
||
Updated•3 months ago
|
| Assignee | ||
Comment 8•3 months ago
|
||
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.
Comment 10•3 months ago
|
||
Comment 11•3 months ago
|
||
Please add uplift requests for Beta and ESR153
Comment 12•3 months ago
|
||
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
| Assignee | ||
Comment 13•3 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D312708
Comment 14•3 months ago
|
||
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
| Assignee | ||
Comment 15•3 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D312708
| Assignee | ||
Updated•3 months ago
|
Updated•3 months ago
|
Updated•3 months ago
|
Comment 16•3 months ago
|
||
| uplift | ||
Updated•3 months ago
|
Updated•3 months ago
|
Comment 17•3 months ago
|
||
| uplift | ||
Updated•3 months ago
|
Updated•2 months ago
|
Updated•2 months ago
|
Updated•15 days ago
|
Description
•