Closed Bug 2026308 Opened 7 months ago Closed 7 months ago

Heap corruption in [@ js::atomics_notify_impl] via cross-realm Atomics.waitAsync same-context fast path

Categories

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

defect

Tracking

()

RESOLVED INVALID

People

(Reporter: bugmon, Unassigned)

References

(Blocks 1 open bug)

Details

(6 keywords, Whiteboard: [prefs-checked])

Attachments

(5 files, 2 obsolete files)

Summary

Use-after-free / heap corruption in /firefox/js/src/builtin/AtomicsObject.cpp at lines 1494-1530. When Atomics.waitAsync is called from an iframe's realm and Atomics.notify is called from the parent realm on the same thread, the waiter->cx() == cx check in atomics_notify_impl evaluates true (there is only one JSContext per main thread). This takes the same-context fast path which calls OffThreadPromiseTask::ExtractAndForget, destroying the PersistentRooted<PromiseObject*> that rooted the iframe-realm promise, then enters the iframe's realm via AutoRealm and resolves the promise directly. This cross-realm resolution leaves the GC heap in an inconsistent state β€” subsequent garbage collection sweeps objects that are still referenced, and cycle collection crashes while traversing the corrupted graph.

The trigger path involves DelayedDispatchToEventLoop in /firefox/dom/base/nsJSEnvironment.cpp:1726-1756, which uses GetCurrentGlobal() to register the WaitAsyncTimeoutTask in the iframe's TimeoutManager. This creates the cross-realm setup where the waiter lives in the SAB's futex list but the timeout lives in the iframe's TimeoutManager and the promise lives in the iframe's realm. The crash manifests during nsJSContext::CycleCollectNow at nsJSEnvironment.cpp:1109.

Affected Code

File: js/src/builtin/AtomicsObject.cpp, lines 1494-1530

// If we are notifying a waiter that was created by the current
// context, we resolve the promise directly instead of dispatching
// a task to the event loop.
OffThreadPromiseTask* task = asyncWaiter->notifyTask();
if (waiter->cx() == cx) {
  // Add the promise to a list to resolve as soon as we've left the
  // critical section.
  PromiseObject* promise =
      OffThreadPromiseTask::ExtractAndForget(task, helperLock);
  if (!promisesToResolve.append(promise)) {
    return false;
  }
} else {
  // Dispatch a task to resolve the promise with value "ok".
  task->removeFromCancellableListAndDispatch(helperLock);
}
...
// Step 10 (reordered)
RootedValue resultMsg(cx, StringValue(cx->names().ok));
for (uint32_t i = 0; i < promisesToResolve.length(); i++) {
  AutoRealm ar(cx, promisesToResolve[i]);
  if (!PromiseObject::resolve(cx, promisesToResolve[i], resultMsg)) {
    MOZ_ASSERT(cx->isThrowingOutOfMemory() || cx->isThrowingOverRecursed());
    return false;
  }
}

The waiter->cx() == cx check compares JSContext* pointers. On the main thread there is exactly one JSContext shared by all realms (parent window, iframes, etc.), so this check is true even when the waiter was created in a completely different realm. ExtractAndForget then deletes the WaitAsyncNotifyTask, destroying the PersistentRooted<PromiseObject*> that was the only strong GC root keeping the iframe-realm promise alive. The promise is temporarily held by promisesToResolve (a stack Rooted<GCVector>), resolved via AutoRealm into the foreign realm, and then unrooted when the function returns. The cross-realm resolution path leaves dangling references that survive into CC graph traversal.

File: dom/base/nsJSEnvironment.cpp, lines 1726-1756

static bool DelayedDispatchToEventLoop(
    void* closure, js::UniquePtr<JS::Dispatchable>&& aDispatchable,
    uint32_t aDelay) {
  ...
  nsIGlobalObject* global = GetCurrentGlobal();
  TimeoutManager* timeoutManager = global->GetTimeoutManager();
  if (timeoutManager) {
    JSContext* cx = nsContentUtils::GetCurrentJSContext();
    RefPtr<TimeoutHandler> handler =
        new DelayedJSDispatchableHandler(cx, std::move(aDispatchable));
    int32_t handle;
    timeoutManager->SetTimeout(handler, aDelay, /* aIsInterval */ false,
                               Timeout::Reason::eJSTimeout, &handle);
  }
  ...
}

GetCurrentGlobal() returns the global of the current compartment. When Atomics.waitAsync is called via win.Atomics.waitAsync(...) in the iframe's realm, this returns the iframe's nsGlobalWindowInner, and the timeout is registered in the iframe's TimeoutManager β€” creating the cross-realm lifetime mismatch that the notify fast-path fails to handle.

Exploit Chain

  1. Page creates a SharedArrayBuffer (requires COOP/COEP headers in production, or the bypassCOOP_COEP testing pref).
  2. Page creates a same-origin iframe and obtains iframe.contentWindow.
  3. Page constructs an Int32Array over the SAB using the iframe's constructor: new win.Int32Array(sab).
  4. Page calls win.Atomics.waitAsync(iframeI32, 0, 0, timeout) repeatedly β€” this runs in the iframe's realm, creating PromiseObjects in the iframe's realm, each rooted by a WaitAsyncNotifyTask's PersistentRooted. DelayedDispatchToEventLoop (nsJSEnvironment.cpp:1726) registers each timeout in the iframe's TimeoutManager.
  5. Page calls Atomics.notify(i32, 0, N) from the parent realm. In atomics_notify_impl, waiter->cx() == cx is true (same thread), so ExtractAndForget destroys each notify task and its PersistentRooted.
  6. PromiseObject::resolve is called with AutoRealm entering the iframe's realm for each extracted promise.
  7. atomics_notify_impl returns; promisesToResolve goes out of scope; the iframe-realm promises lose their last root.
  8. GC runs and sweeps iframe-realm GC cells that still have untracked references.
  9. Cycle collection runs CCGraphBuilder::BuildGraph β†’ JSObject::traceChildren encounters a JSObject whose slots/shape point to swept memory β†’ crash at varying locations in the CC graph builder.

Steps to Reproduce

  • Set pref dom.postMessage.sharedArrayBuffer.bypassCOOP_COEP.insecure.enabled = true (or serve the page with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers in production).
  • Set pref javascript.options.shared_memory = true (default-enabled).
  • Load the testcase. FuzzingFunctions.garbageCollect() / cycleCollect() are used for deterministic timing; in production builds, GC/CC occur automatically and the bug would trigger non-deterministically.

Security Impact

Severity: High. This is heap corruption in the content process triggered entirely by web-exposed JavaScript APIs. The crash location varies between runs (observed: JSClass::hasTrace, CCGraphBuilder::AddNode, EntrySlot::setLive, PtrInfoCache::Match, BaseShape::traceChildren), indicating the corruption affects heap structures broadly rather than failing at a single deterministic point. An attacker who can serve content with COOP/COEP headers (any HTTPS site) can trigger this. Successful exploitation could lead to arbitrary code execution in the content process.

Preconditions: SharedArrayBuffer must be available β€” this requires COOP/COEP response headers, which any attacker-controlled origin can set. No user interaction beyond page load is required.

ASAN Report

==116466==ERROR: AddressSanitizer: ABRT on unknown address 0x00000000013b (pc 0x7a60143edfee bp 0x7ffdc8213510 sp 0x7ffdc8213460 T0)
    #0 0x7a60143edfee in JSClass::hasTrace() const /firefox/obj-x86_64-pc-linux-gnu/dist/include/js/Class.h:654:34
    #1 0x7a60143edfee in JSObject::traceChildren(JSTracer*) /firefox/js/src/vm/JSObject.cpp:3360:14
    #5 0x7a6012fb5625 in JS::TraceChildren(JSTracer*, JS::GCCellPtr) /firefox/js/src/gc/Tracer.cpp:58:3
    #6 0x7a6003019a15 in mozilla::CycleCollectedJSRuntime::NoteGCThingJSChildren /firefox/xpcom/base/CycleCollectedJSRuntime.cpp:990:3
    #10 0x7a6003094394 in CCGraphBuilder::BuildGraph(JS::SliceBudget&) /firefox/xpcom/base/nsCycleCollector.cpp:2284:39
    #14 0x7a6008579ce3 in nsJSContext::CycleCollectNow /firefox/dom/base/nsJSEnvironment.cpp:1109:3
    #15 0x7a600a2b6d1d in mozilla::dom::FuzzingFunctions_Binding::cycleCollect

SUMMARY: AddressSanitizer: ABRT

Note: ASAN reports ABRT rather than heap-use-after-free because SpiderMonkey GC arenas are not instrumented by ASAN. The swept GC cells contain poison patterns (e.g. JS_SWEPT_TENURED_PATTERN = 0x4B) that cause crashes when dereferenced as pointers.

Suggested Fix

Restrict the same-context fast path in atomics_notify_impl to only apply when the waiter's promise is in the current realm, not merely the current JSContext:

// In js/src/builtin/AtomicsObject.cpp, atomics_notify_impl:
OffThreadPromiseTask* task = asyncWaiter->notifyTask();
// Only resolve directly if same thread AND same realm. Cross-realm
// promises must use the dispatch path to maintain correct GC rooting
// across the realm boundary.
if (waiter->cx() == cx && task->promiseRealm() == cx->realm()) {
  PromiseObject* promise =
      OffThreadPromiseTask::ExtractAndForget(task, helperLock);
  if (!promisesToResolve.append(promise)) {
    return false;
  }
} else {
  task->removeFromCancellableListAndDispatch(helperLock);
}

This requires adding a promiseRealm() accessor to OffThreadPromiseTask that returns promise_->realm(). Alternatively, always use the dispatch path (removeFromCancellableListAndDispatch) and remove the fast-path optimization entirely β€” the correctness of GC rooting outweighs the minor performance benefit of avoiding one event-loop dispatch.

Attached file Testcase: test.html β€”
Attached file Prefs file for reproduction (obsolete) β€”
Attached file Crash stack trace β€”
Group: core-security → javascript-core-security

Unable to reproduce bug 2026308 using build mozilla-central 20260325212151-b431043d8af2. Without a baseline, bugmon is unable to analyze this bug.
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.

Keywords: bugmon
Assignee: nobody → arai.unmht
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attached file (secure) (obsolete) β€”
Whiteboard: [prefs-checked]

Prefs: javascript.options.shared_memory is default-true on all channels (all.js:1024). bypassCOOP_COEP is testing convenience β€” any HTTPS origin can serve COOP/COEP headers to enable SAB legitimately. FuzzingFunctions is GC-timing convenience. Not unsupported-config.


Reproduction & root-cause findings (the comment 0 analysis is partially incorrect):

Reproduced the crash on current origin/main (ASAN build, content process, CCGraphBuilder::BuildGraph β†’ JSObject::traceChildren on swept cells). Isolation experiments:

Variant Result
waitAsync + notify, all in parent realm (no iframe) clean
waitAsync in iframe realm, no notify clean
waitAsync in iframe + parent-realm notify (comment 0's scenario) crash
waitAsync in iframe + iframe-realm notify (win.Atomics.notify) crash
waitAsync+notify entirely inside iframe via injected <script> (no CCW call path) crash
Any crashing variant + await setTimeout(0) between notify and GC clean
Any crashing variant + await setTimeout(0) at start of iteration (drains prior iframe.remove() runnables) clean

So: the waiter->cx() == cx check is not the culprit β€” the fast path corrupts even when cx->realm() matches the waiter's realm. The trigger is the synchronous ExtractAndForget + PromiseObject::resolve sequence running while a WindowDestroyedEvent from a prior iteration's iframe.remove() is still pending on the event loop. The dispatch path happens to be safe because it yields to the event loop before resolve runs. I was not able to pin the exact corrupted edge β€” keeping the PersistentRooted alive through resolve (mirroring OffThreadPromiseTask::run) did not help, so it's not a simple rooting-order bug.

Fix: remove the same-context fast path; always dispatch via removeFromCancellableListAndDispatch, matching the cross-thread and ~FutexWaiterListHead cases. Verified against all four crashing variants above (500 iters each) + a correctness check that .value promises still resolve with "ok".

Spec impact: same-agent notify now resolves as an event-loop task rather than synchronously (observable ordering difference if a reaction is attached before notify returns). Restoring a safe fast path β€” once the underlying WindowDestroyedEvent interaction is understood β€” is follow-up.

ExtractAndForget is now dead; left in place for a minimal-diff security patch.

This is the analysis tool's suggested fix. Feel welcome to adopt it as a starting point and evolve it as needed to meet our coding standards.

Attachment #9558732 - Attachment is obsolete: true
Assignee: arai.unmht → choller
Assignee: choller → nobody
Status: ASSIGNED → NEW

arai, I think this was a collision, the bot started working while you were working on the bug as well. Please go ahead with your patch (maybe compare to the patch the AI proposed, my comment is AI generated).

Flags: needinfo?(arai.unmht)

looks like it's beyond my capability.
I've confirmed that the problematic situation in the bug comment #0 happens, and the patch avoid that,
but I couldn't even reproduce the crash without my patch, so I cannot investigate more.

iain, can you take a look?

Flags: needinfo?(arai.unmht) → needinfo?(iireland)

I was confused by the initial comment, since it doesn't clearly identify a place where things go wrong. The updated comment is a bit more useful: in particular, "The trigger is the synchronous ExtractAndForget + PromiseObject::resolve sequence running while a WindowDestroyedEvent from a prior iteration's iframe.remove() is still pending on the event loop" seems like a plausible description for a thing that could go wrong.

However, after trying for quite some time, I've been unable to reproduce a crash. It doesn't crash in my regular firefox build. None of the ASAN builds I can find from CI have fuzzing enabled, so I built an ASAN+fuzzing version locally. That also doesn't fail, even after cranking up the number of iterations and running in rr chaos mode.

The proposed fix seems wrong to me. The spec is clear about the distinction between notifying an async waiter in the same agent vs different agents: if it's in the same agent, we resolve it immediately (step 3), and if it's in a different agent, we enqueue a job (step 4).

decoder, it sounds like you/Claude successfully reproduced this failure in comment 8. Arai and I have been unable to do so. Is there anything about your test environment that is not included in the bug description? I'm running with dom.postMessage.sharedArrayBuffer.bypassCOOP_COEP.insecure.enabled and fuzzing.enabled. Here is my mozconfig:

mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-firefox-asan
ac_add_options --enable-address-sanitizer
ac_add_options --disable-jemalloc
ac_add_options --disable-crashreporter
ac_add_options --disable-elf-hack
ac_add_options --disable-install-strip
ac_add_options --enable-debug-symbols=-gline-tables-only
ac_add_options --enable-valgrind
ac_add_options --enable-fuzzing
Flags: needinfo?(iireland) → needinfo?(choller)

jkratzer: Minor issue: This testcase requires fuzzing.enabled, but it doesn't show up in the prefs file for reproduction.

Flags: needinfo?(jkratzer)

For the record, my best guess at what a fix might be would be to check in this code whether the realm is dead, and skip the promise resolution in that case. But we don't have direct access to IsDying on the nsIGlobalObject, and I don't really want to throw in random checks until we can actually reproduce a crash and investigate more closely.

truber, can you help Iain to run this in Grizzly? I assume there's some part of the toolchain (prefpicker pref, or something Grizzly does) that is needed to make this work.

Flags: needinfo?(choller) → needinfo?(jschwartzentruber)
Attached file prefs.json β€”
Attached file prefs.js β€”

Prefs generated by prefpicker (with the additional ones from clauditor)

Attachment #9558246 - Attachment is obsolete: true

That prefs.js didn't immediately reproduce a crash for me, but my build was slightly stale. Retrying with a fresh build. Also going to try getting a pernosco recording.

Keywords: pernosco-wanted

I confirm that running the testcase with the attached prefs.js in an ASAN build does not reproduce a crash.

Is it possible that this was some sort of problem with WindowDestroyedEvent that was recently fixed? This bug report does not list the build where the crash was reproduced.

One thing to note is that, with the possible exception of parallel GC threads, this testcase shouldn't involve any race conditions, so if it fails, it should fail deterministically.

I was also unable to reproduce, but when I asked claude it said the bug is still present. I even got it to create another testcase that should hit it, and that didn't repro either.

Flags: needinfo?(jschwartzentruber)

To be clear, I don't actually understand what the bug is supposed to be. Without a failing testcase, this report is just pointing a finger at some complicated code and telling us that something mysterious goes wrong. The waitAsync code is intricate and poorly tested, so I would not be shocked if there's a bug there, but this report doesn't help locate it.

This is starting to remind me of bug 2024229 (and its dupe, bug 2022997). In those bugs, Claude found a real bug in generated JIT code, but then somehow lost track of the actual problem, and made superficially plausible but unfounded claims about something going wrong elsewhere. In that case, though, it was at least able to provide a testcase.

As one last sanity check, I downloaded an ASAN build from March 20th, five days before this bug was reported. I still couldn't reproduce a failure, making it less likely that the bug was fixed between discovery and filing.

If this were an external reporter, I would have closed this bug by now.

Status: NEW → UNCONFIRMED
Ever confirmed: false

Marking S2, P1 for now, though I guess if we cannot reproduce at some point this might get closed.

Blocks: sm-security
Severity: -- → S2
Priority: -- → P1

Iain and I discussed this yesterday and my working theory is that this is a shutdown hang, nothing more. The ABRT is coming from here: https://searchfox.org/firefox-main/rev/0ea834f7eeb962c70f3232e782830290e63c0951/ipc/chromium/src/chrome/common/process_watcher_posix_sigchld.cc#279

Severity: S2 → S4
Flags: needinfo?(jkratzer)
Keywords: sec-high → sec-other
Priority: P1 → P5

I got Claude to do a quick audit of the Atomics.waitAsync timeout/shutdown code, looking for shutdown hangs instead of vulnerabilities. It found the same issue Jan is fixing here, but otherwise concluded that accumulating hundreds of WindowDestroyedEvent runnables in the task queue and then firing them all at once is a plausible culprit for overwhelming the GC/CC. It didn't find any unknown waitAsync-specific problems.

Status: UNCONFIRMED → RESOLVED
Closed: 7 months ago
Resolution: --- → INVALID
Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: