Closed Bug 2042756 Opened 5 months ago Closed 4 months ago

Engine-invariant violation in [@ js::AsyncModuleExecutionFulfilled] via Debugger-forced throw during TLA ancestor re-execution (entry: AsyncModuleExecutionFulfilledHandler)

Categories

(Core :: JavaScript: Debugger API, defect, P3)

defect

Tracking

()

RESOLVED FIXED
154 Branch
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)

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

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

  1. Create a debuggee global and attach a Debugger with an onEnterFrame hook that installs an onStep handler.
  2. 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.
  3. Link and evaluate 'parent'; both modules run their bodies to their top-level await and suspend. parent has a pending async dependency on child.
  4. Resolve parent's own await (globalThis.__pp(1)) and drain, leaving parent blocked only on its async dependency child.
  5. 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.
  6. The armed onStep handler returns {throw: ...} while parent's body executes, so ExecuteAsyncModule(parent) returns false with an ordinary catchable exception pending.
  7. 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

  1. Build the SpiderMonkey debug shell (assertions enabled): /firefox/obj-js-debug/dist/bin/js
  2. Run: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe testcase.js
  3. 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 ...
Attached file testcase.js —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Component: JavaScript Engine → JavaScript: Debugger API
Severity: -- → S3
Priority: -- → P3
Assignee: nobody → allstars.chh
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true

The root cause of the exception throwing from ExecuteAsyncModule:

  1. 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.

  1. 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 by EnableInterruptsPseudoOpcode,
    which in turns calls DebugAPI::onSingleStep 5.

  2. And in the test, it throws in the 3rd onStep call (see the pc variable),
    and returns { throw: 'boom-from-debugger' } in onStep.

  3. ApplyFrameResumeMode is called with the throwing value from onStep handler,
    it sets a pending exception on the JSContext, and returns false 6.

  4. Back to the interpreter in step 2, since DebugAPI::onSingleStep
    returns false, it now calls HandleError() 7.

  5. In HandleError(), it calls ProcessTryNotes 8.

  6. 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 returns SuccessfulReturnContinuation.

  7. Back to HandleError(), the ok variable is still false after DebugAPI::onLeaveFrame,
    so HandleError() returns ErrorReturnContinuation.

  8. The ErrorReturnContinuation exits 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.

This problem is only observed in jit-test, so I'd land the tests as well.

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

Please add a beta uplift reqeust

Attachment #9604165 - Flags: approval-mozilla-beta?
Attachment #9604166 - Flags: approval-mozilla-beta?

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
Attachment #9604167 - Flags: approval-mozilla-beta?
Flags: needinfo?(allstars.chh)
Attachment #9604165 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Attachment #9604166 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Attachment #9604167 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [qa-triage-done-c154/b153]
Whiteboard: [adv-main153+r]
Duplicate of this bug: 2053270
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: