Engine-invariant violation in [@ js::AsyncModuleExecutionFulfilled] via Debugger-forced throw during TLA ancestor re-execution (entry: AsyncModuleExecutionFulfilledHandler)
Categories
(Core :: JavaScript: Debugger API, defect, P3)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox152 | --- | wontfix |
| firefox153 | + | fixed |
| firefox154 | + | fixed |
People
(Reporter: bugmon, Assigned: allstars.chh)
References
(Blocks 2 open bugs)
Details
(5 keywords, Whiteboard: [adv-main153+r])
Attachments
(3 files, 6 external files)
|
993 bytes,
text/plain
|
Details | |
|
1003 bytes,
patch
|
Details | Diff | Splinter Review | |
|
1.33 KB,
text/plain
|
Details |
Top-level-await (TLA) modules in SpiderMonkey register their async-evaluation reaction handlers in js/src/vm/AsyncFunction.cpp. AsyncFunctionGeneratorObject::create(cx, ModuleObject*) (AsyncFunction.cpp:336-351) attaches AsyncModuleExecutionFulfilledHandler / AsyncModuleExecutionRejectedHandler (AsyncFunction.cpp:288-312) to the module's result promise. When a TLA module's async dependency settles, AsyncModuleExecutionFulfilledHandler runs as a promise-reaction job (driven from the microtask queue) and calls js::AsyncModuleExecutionFulfilled (Modules.cpp:2461).
That function gathers the dependency's now-available async-ancestor modules and, for each ancestor that itself has top-level await, RE-ENTERS ExecuteAsyncModule() (Modules.cpp:2555) to drive that ancestor's top-level JS body via ModuleObject::execute -> Execute. The code then asserts (Modules.cpp:2556-2557) that ExecuteAsyncModule can only ever fail due to OutOfMemory or over-recursion, and on the assumption that no other failure is possible it unconditionally clears any pending exception (Modules.cpp:2558).
This assumption is false. A Debugger onStep handler that returns {throw: <value>} while that nested ancestor body executes causes ExecuteAsyncModule to return false with an ordinary, script-induced catchable exception pending. In the debug build the MOZ_ASSERT fires and triggers a deliberate null-pointer write in MOZ_CrashSequence (SEGV at address 0x0). In opt/release builds the assert is compiled out and cx->clearPendingException() silently swallows the script-visible exception while the loop continues processing the remaining ancestors, leaving a half-evaluated TLA module in EvaluatingAsync async-evaluation bookkeeping with an abandoned generator — a security-relevant broken invariant. The crash is fully deterministic and reachable using only --fuzzing-safe shell APIs.
Build Info
- Branch: main
- Revision: 1f7030c8de8f2b349c7d91d7b5a3253c109a1cc1
- Timestamp: 2026-05-21T16:50:48+00:00
Affected Code
File: js/src/vm/AsyncFunction.cpp, line 288-300
static bool AsyncModuleExecutionFulfilledHandler(JSContext* cx, unsigned argc,
Value* vp) {
CallArgs args = CallArgsFromVp(argc, vp);
JSFunction& func = args.callee().as<JSFunction>();
Rooted<ModuleObject*> module(
cx, &func.getExtendedSlot(FunctionExtended::MODULE_SLOT)
.toObject()
.as<ModuleObject>());
AsyncModuleExecutionFulfilled(cx, module); // void: cannot signal/handle broken state
args.rval().setUndefined();
return true;
}
File: js/src/vm/AsyncFunction.cpp, line 336-351
RootedObject onFulfilled(
cx, NewHandler(cx, AsyncModuleExecutionFulfilledHandler, module));
if (!onFulfilled) {
return nullptr;
}
RootedObject onRejected(
cx, NewHandler(cx, AsyncModuleExecutionRejectedHandler, module));
if (!onRejected) {
return nullptr;
}
if (!JS::AddPromiseReactionsIgnoringUnhandledRejection(
cx, resultPromise, onFulfilled, onRejected)) {
return nullptr;
}
File: js/src/vm/Modules.cpp, line 2552-2559
} else if (m->hasTopLevelAwait()) {
// Step 12.b.i. Perform ExecuteAsyncModule(m).
if (!ExecuteAsyncModule(cx, m)) {
MOZ_ASSERT(!cx->isExceptionPending() || cx->isThrowingOutOfMemory() ||
cx->isThrowingOverRecursed()); // <-- violated by debugger-forced throw
cx->clearPendingException(); // <-- release: silently swallows exception
}
}
The reaction handler that is the entry point lives in the target file AsyncFunction.cpp and unconditionally invokes the void AsyncModuleExecutionFulfilled (it cannot propagate or react to a broken state). AsyncModuleExecutionFulfilled then asserts an over-narrow failure mode of ExecuteAsyncModule.
Exploit Chain
- Create a debuggee global and attach a Debugger with an onEnterFrame hook that installs an onStep handler.
- Define two TLA modules: a 'child' module that top-level-awaits a controllable promise (exposing its resolver as globalThis.__pc) and exports a binding, and a 'parent' module that imports from 'child', top-level-awaits its own controllable promise (globalThis.__pp), then uses the imported binding.
- Link and evaluate 'parent'; both modules run their bodies to their top-level await and suspend. parent has a pending async dependency on child.
- Resolve parent's own await (globalThis.__pp(1)) and drain, leaving parent blocked only on its async dependency child.
- Arm the debugger, then settle child (globalThis.__pc(1)). Completing child runs AsyncModuleExecutionFulfilled(child) as a promise-reaction job; it finds parent available and re-enters ExecuteAsyncModule(parent) to drive parent's resumed top-level body.
- The armed onStep handler returns {throw: ...} while parent's body executes, so ExecuteAsyncModule(parent) returns false with an ordinary catchable exception pending.
- The MOZ_ASSERT at Modules.cpp:2556-2557 is violated -> debug build crashes via MOZ_CrashSequence null-write SEGV; release build silently clears the exception and continues, leaving parent in an inconsistent EvaluatingAsync state with an abandoned generator.
Steps to Reproduce
- Build the SpiderMonkey debug shell (assertions enabled): /firefox/obj-js-debug/dist/bin/js
- Run: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe testcase.js
- Observe the deterministic crash: 'Assertion failure: !cx->isExceptionPending() || cx->isThrowingOutOfMemory() || cx->isThrowingOverRecursed(), at js/src/vm/Modules.cpp:2557' followed by a SEGV in MOZ_CrashSequence, with AsyncModuleExecutionFulfilledHandler (AsyncFunction.cpp:297) in the stack.
Security Impact
- Severity: Moderate
- Attacker capability: An actor able to use the Debugger API against a debuggee that loads top-level-await module graphs can deterministically violate an engine invariant in js::AsyncModuleExecutionFulfilled. On debug/ASAN builds this is an immediate controlled crash (DoS). On release builds the assertion is compiled out and the script-visible exception is silently discarded by cx->clearPendingException(), leaving a top-level-await module stuck in the EvaluatingAsync state with an abandoned/closed generator while the async-evaluation loop continues over the rest of the sorted ancestor list. This corrupts module-graph async-evaluation bookkeeping and is the kind of inconsistent state the MOZ_ASSERT was added to forbid, providing a foothold for further memory-unsafety when the stale module/generator is later touched.
- Preconditions: Requires the SpiderMonkey Debugger API (onStep force-throw) to inject a non-OOM exception into a re-entrantly-executed top-level-await ancestor module body. In the JS shell this is reachable under --fuzzing-safe. In Firefox the Debugger API is devtools/chrome-privileged rather than web-content reachable, so practical exploitation requires that capability or another path that makes ExecuteAsyncModule fail with a non-OOM/non-over-recursion exception.
ASAN Report
Assertion failure: !cx->isExceptionPending() || cx->isThrowingOutOfMemory() || cx->isThrowingOverRecursed(), at js/src/vm/Modules.cpp:2557
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000
==The signal is caused by a WRITE memory access.
==Hint: address points to the zero page.
#0 MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3
#1 js::AsyncModuleExecutionFulfilled(JSContext*, JS::Handle<js::ModuleObject*>) /firefox/js/src/vm/Modules.cpp:2556:9
#2 AsyncModuleExecutionFulfilledHandler(JSContext*, unsigned int, JS::Value*) /firefox/js/src/vm/AsyncFunction.cpp:297:3
#3 CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:498:13
#4 js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:594:12
#5 js::Call(...) /firefox/js/src/vm/Interpreter.cpp:693:8
... PromiseReactionJob -> JS::RunJSMicroTask -> js::RunJobs -> DrainJobQueue -> JS_ExecuteScript ...
| Reporter | ||
Comment 1•5 months ago
|
||
| Reporter | ||
Comment 2•5 months ago
|
||
| Reporter | ||
Comment 3•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•5 months ago
|
| Assignee | ||
Updated•4 months ago
|
| Assignee | ||
Comment 5•4 months ago
•
|
||
The root cause of the exception throwing from ExecuteAsyncModule:
- BytecodeEmitter::emitScript calls AsyncEmitter::prepareForModule, which emits 3 prolog opcodes 1:
1. JSOp::Generator.
2. emit Assignment on .generator
3. JSOp::Pop
Then switchToMain() is called 2 to record the offset of the main bytecode.
And later the main bytecode, JSOp::Try, which is emitted in AsyncEmitter::prepareForBody 3,
and the TryNote for TLA modules is added later in AsyncEmitter::emitEndModule 4 as well.
-
The module graph is linked, and the top-level module is being evaluated
by ExecuteAsyncModule, which calls ModuleObject::execute.
Here the interpreter loop starts, and each opcode dispatch is intercepted byEnableInterruptsPseudoOpcode,
which in turns calls DebugAPI::onSingleStep 5. -
And in the test, it throws in the 3rd onStep call (see the
pcvariable),
and returns { throw: 'boom-from-debugger' } in onStep. -
ApplyFrameResumeMode is called with the throwing value from onStep handler,
it sets a pending exception on the JSContext, and returns false 6. -
Back to the interpreter in step 2, since DebugAPI::onSingleStep
returns false, it now calls HandleError() 7. -
In HandleError(), it calls ProcessTryNotes 8.
-
In ProcessTryNotes, recall that now pc points to the 3rd opcode, which is
still in the prolog of the bytecodes. And the 1st Try opcode (from main entry)
is the 4th opcode (See step 1).
So ProcessTryNotes won't process the pending exception since no matched TryNote is found,
the for loop of TryNoteIterInterpreter doesn't run and ProcessTryNotes returnsSuccessfulReturnContinuation. -
Back to HandleError(), the
okvariable is still false afterDebugAPI::onLeaveFrame,
so HandleError() returnsErrorReturnContinuation. -
The
ErrorReturnContinuationexits the interpreter loop and returns false. 9,
which leads to ModuleObject::execute returns false.
In summary, the debugger onStep hook fires in the prolog of the bytecodes and throws an exception,
which causes the interpreter to bail out early.
At this time, the module's promise has not yet been set up (that happens at JSOp::AsyncAwait),
so we can't reject the exception through JSOp::AsyncReject.
The fix is to reject the pending exception through RejectExecutionWithPendingException in AsyncModuleExecutionFulfilled,
and propagate the rejection to its async parent modules.
| Assignee | ||
Comment 6•4 months ago
|
||
| Assignee | ||
Comment 7•4 months ago
|
||
| Assignee | ||
Comment 8•4 months ago
|
||
| Assignee | ||
Comment 9•4 months ago
|
||
This problem is only observed in jit-test, so I'd land the tests as well.
Comment 10•4 months ago
|
||
Comment 11•4 months ago
|
||
https://hg.mozilla.org/mozilla-central/rev/f1917d74a590
https://hg.mozilla.org/mozilla-central/rev/598a96c9971c
https://hg.mozilla.org/mozilla-central/rev/61adfab8b08f
Comment 12•4 months ago
|
||
Please add a beta uplift reqeust
| Assignee | ||
Comment 13•4 months ago
|
||
Updated•4 months ago
|
| Assignee | ||
Comment 14•4 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D309827
Updated•4 months ago
|
Comment 15•4 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined/Reason for urgency: Assertion failure of pending exception after the execution of a TLA module in debug build.
In release build, the bug makes the TLA module in a pending state (unsettled)
- Code covered by automated testing?: yes
- Fix verified in Nightly?: no
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: Update the assertion and make sure the pending promise is settled.
- String changes made/needed?: no
- Is Android affected?: yes
| Assignee | ||
Comment 16•4 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D309992
| Assignee | ||
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Comment 17•4 months ago
|
||
| uplift | ||
Updated•4 months ago
|
Updated•3 months ago
|
Updated•1 month ago
|
Description
•