CVE-2026-11639
Overview
Background
- `DisplayLinkMacMojo`
- a macOS compositor helper that manages VSync IPC (
mojo::Remote,mojo::Receiver, vsync callbacks) bound to a dedicated VSync thread while living primarily on the browser main thread. - Thread-affine Mojo objects
mojo::Remote/mojo::ReceiverandVSyncCallbackMacinstances that must be constructed and destroyed on the same (VSync) thread that owns their sequence.- GPU process loss
- when the GPU process dies,
OnGpuProcessLost()runs on the main thread and must tear down the stale VSync IPC objects and reconnect. - `base::WeakPtr` vs `base::Unretained`
Unretainedforces a cross-thread cleanup task to run even during destruction, while aWeakPtrlets a recovery task skip itself if the instance is gone.
Root Cause Analysis
Pre-patch, OnGpuProcessLost() ran entirely from the main thread: it posted the old vsync_callbacks_, display_links_, external_begin_frame_controller_, and client_receiver_ to the VSync thread for destruction while, on the same main-thread turn, immediately calling ConnectVSyncIpc() to build fresh objects and re-adding the display observer. This interleaving meant new VSync-thread objects could be created before the old ones finished being destroyed on the VSync thread, and rapid successive GPU losses could stack these operations, causing the race conditions / DCHECK failures and a use-after-free of thread-affine state described in the commit.
The fix serializes the sequence: OnGpuProcessLost() now only increments pending_gpu_lost_count_, resets the display observer, and posts OnGpuProcessLostOnVSyncThread() (via base::Unretained) to perform cleanup on the VSync thread, which then hops back to ContinueGpuProcessLostOnMainThread() (via a WeakPtr) to reconnect only after cleanup completes and only if no newer loss is pending. The pending_gpu_lost_count_ gate ensures reconnection happens once, after the last queued cleanup, and ResetStateOnVSyncThread() centralizes destroying the thread-affine objects on their owning thread.
Attack Path
- Induce GPU process loss
Trigger a GPU process crash/reset (e.g. via GPU-heavy or malformed content) so the browser invokes
DisplayLinkMacMojo::OnGpuProcessLost()on the main thread. - Race the cross-thread teardown In the unpatched flow, old VSync-thread objects are posted for destruction while new ones are built on the main thread in the same turn, creating a window where VSync-thread state is concurrently torn down and recreated.
- Stack rapid losses Multiple GPU losses in quick succession compound the interleaving, so a destruction task and a reconnect operate on overlapping thread-affine objects.
- Use-after-free / DCHECK
The race leads to use of a destroyed
mojo::Remote/mojo::Receiver/vsync callback (or aDCHECKfailure) on the VSync thread; the specific freed object and any further primitive are(inferred).
Impact Assessment
DisplayLinkMacMojo into the destroy/recreate race, yielding a use-after-free of thread-affine VSync IPC objects in the browser process. The vendor advisory rates this critical; preconditions are the macOS compositor backend plus one or more GPU process losses, which page content can provoke but which is a timing-dependent race rather than a deterministic hit. Any escalation beyond the freed-then-used Mojo/VSync state the patch addresses (controlled reuse, code execution) is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifui/compositor/display_link_mac_mojo.mm |
modified |
Files Changed
ui/compositor/display_link_mac_mojo.hui/compositor/display_link_mac_mojo.mm
Audit Directions
- Cross-thread destroy-then-recreateFlag code that posts old thread-affine objects for destruction on one sequence while synchronously constructing replacements on another; require explicit serialization before reconnecting.
- Lifetime of thread-bound Mojo endpointsReview
mojo::Remote/mojo::Receivermembers whose dtors must run on a non-owning thread, ensuring cleanup completes before reuse and thatUnretainedvsWeakPtris chosen per the destroy-vs-recover rule. - Re-entrant GPU-loss handlersAudit
OnGpuProcessLost-style callbacks for reentrancy under rapid successive losses, confirming a pending-count or equivalent gate prevents overlapping recovery.
Patch
From c92de87bddf2a6bb86549a58f98e6f2d8c459ce3 Mon Sep 17 00:00:00 2001 From: Maggie Chen <[email protected]> Date: Fri, 29 May 2026 16:05:48 -0700 Subject: [PATCH] Fix GPU process lost race in DisplayLinkMacMojo DisplayLinkMacMojo manages objects like mojo::Remote, mojo::Receiver, and vsync callbacks that are bound to a dedicated VSync thread. Previously, OnGpuProcessLost attempts to recreate new objects on the main thread while destroying the old objects on the VSync thread, which can lead to race conditions or DCHECK failures. This CL serialize the destroy-then-recreate to ensures that these objects are cleaned up on the VSync thread before reconnecting the IPC on the main thread. Bug: 517227707 Change-Id: Ie052347db9869d69e8de7beec2248a3241ff4b65 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7881564 Reviewed-by: Jonathan Ross <[email protected]> Commit-Queue: Maggie Chen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1638792} --- diff --git a/ui/compositor/display_link_mac_mojo.h b/ui/compositor/display_link_mac_mojo.h index 7038b46..d2e2d76 100644 --- a/ui/compositor/display_link_mac_mojo.h +++ b/ui/compositor/display_link_mac_mojo.h @@ -5,6 +5,8 @@ #ifndef UI_COMPOSITOR_DISPLAY_LINK_MAC_MOJO_H_ #define UI_COMPOSITOR_DISPLAY_LINK_MAC_MOJO_H_ +#include <optional> + #include "base/task/single_thread_task_runner.h" #include "base/threading/thread.h" #include "mojo/public/cpp/bindings/receiver.h" @@ -84,7 +86,8 @@ private: // VSyncIpc is connected when DisplayLinkMacMojo is created, and it's // reconnected after the GPU process is lost. - void ConnectVSyncIpc(viz::HostFrameSinkManager* host_frame_sink_manager); + void ConnectVSyncIpcAndAddDisplayObserver( + viz::HostFrameSinkManager* host_frame_sink_manager); void InitDisplaysOnVSyncThread(); @@ -94,6 +97,31 @@ void DisplaysRemovedOnVSyncThread(std::vector<int64_t> display_ids); + // This sequence is called to prevent race conditions during GPU process loss. + // The flow is: + // OnGpuProcessLost() (Main thread) -> + // OnGpuProcessLostOnVSyncThread() (VSync thread) -> + // ContinueGpuProcessLostOnMainThread() (Main thread) + // + // Note: OnGpuProcessLostOnVSyncThread() must use base::Unretained(this) to + // ensure that thread-affine Mojo objects are always cleaned up on the VSync + // thread, even if this class is being destroyed. The recovery task + // (ContinueGpuProcessLostOnMainThread) uses a WeakPtr because recovery + // should only proceed if this instance has not been destroyed. + void ContinueGpuProcessLostOnMainThread( + viz::HostFrameSinkManager* host_frame_sink_manager); + void OnGpuProcessLostOnVSyncThread( + viz::HostFrameSinkManager* host_frame_sink_manager, + scoped_refptr<base::SingleThreadTaskRunner> main_task_runner, + base::WeakPtr<DisplayLinkMacMojo> weak_ptr); + + void ResetStateOnVSyncThread(); + + // To prevent AddObserver from being called multiple times + std::optional<display::ScopedDisplayObserver> display_observer_; + + int pending_gpu_lost_count_ = 0; + std::map<int64_t, scoped_refptr<DisplayLinkMac>> display_links_; std::map<int64_t, std::unique_ptr<VSyncCallbackMac>> vsync_callbacks_; diff --git a/ui/compositor/display_link_mac_mojo.mm b/ui/compositor/display_link_mac_mojo.mm index 0e3ddd3..b8509ba 100644 --- a/ui/compositor/display_link_mac_mojo.mm +++ b/ui/compositor/display_link_mac_mojo.mm @@ -6,6 +6,7 @@ #include <utility> +#include "base/task/bind_post_task.h" #include "components/viz/common/frame_sinks/begin_frame_args.h" #include "components/viz/host/host_frame_sink_manager.h" #include "mojo/public/cpp/bindings/pending_receiver.h" @@ -32,11 +33,7 @@ // To ensure VSyncThread task_runner() is valid, StartWithOptions() must be // called before ConnectVSyncIpc(). - ConnectVSyncIpc(host_frame_sink_manager); - - // Display AddObserver can only be called on the browser main thread. - DCHECK(display::Screen::HasScreen()); - display::Screen::Get()->AddObserver(this); + ConnectVSyncIpcAndAddDisplayObserver(host_frame_sink_manager); // Now |external_begin_frame_controller_| is valid after ConnectVSyncIpc(). We // can start getting DisplayLinks for all displays. @@ -46,9 +43,7 @@ } DisplayLinkMacMojo::~DisplayLinkMacMojo() { - if (display::Screen::HasScreen()) { - display::Screen::Get()->RemoveObserver(this); - } + display_observer_.reset(); // Stop the VSync thread. Stop(); @@ -67,54 +62,79 @@ void DisplayLinkMacMojo::CleanUp() { DCHECK_CALLED_ON_VALID_SEQUENCE(vsync_thread_sequence_checker_); + ResetStateOnVSyncThread(); +} + +void DisplayLinkMacMojo::ResetStateOnVSyncThread() { + DCHECK_CALLED_ON_VALID_SEQUENCE(vsync_thread_sequence_checker_); // Created on the VSync thread and must be destroyed on the same thread. vsync_callbacks_.clear(); display_links_.clear(); // The remote and receiver are connected to IPC to issue and receive mojom - // interrace calls on the VSync thread. These dtors are required to run on the + // interface calls on the VSync thread. These dtors are required to run on the // same thread. external_begin_frame_controller_.reset(); client_receiver_.reset(); } // Called on the browser main thread. +// OnGpuProcessLost can handle multiple GPU process losses occurring in rapid +// succession. void DisplayLinkMacMojo::OnGpuProcessLost( viz::HostFrameSinkManager* host_frame_sink_manager) { - // Destroy all DisplayLinks on the VSyncThread. - DCHECK(display::Screen::HasScreen()); - display::Screen::Get()->RemoveObserver(this); + pending_gpu_lost_count_++; - if (!vsync_callbacks_.empty()) { - task_runner()->PostTask( - FROM_HERE, base::DoNothingWithBoundArgs(std::move(vsync_callbacks_))); - } - if (!display_links_.empty()) { - task_runner()->PostTask( - FROM_HERE, base::DoNothingWithBoundArgs(std::move(display_links_))); + display_observer_.reset(); + + // Ensure the WeakPtr are created and bound to the sequence on CrBrowserMain. + // The dereferencing (checking if the pointer is still valid) and invalidation + // happens on the Main thread only. A WeakPtr is passed to the VSync thread + // only to be used as an argument for the recovery task + // (ContinueGpuProcessLostOnMainThread). + task_runner()->PostTask( + FROM_HERE, + base::BindOnce(&DisplayLinkMacMojo::OnGpuProcessLostOnVSyncThread, + base::Unretained(this), host_frame_sink_manager, + base::SingleThreadTaskRunner::GetCurrentDefault(), + weak_ptr_factory_.GetWeakPtr())); +} + +// Called on the browser main thread. +void DisplayLinkMacMojo::ContinueGpuProcessLostOnMainThread( + viz::HostFrameSinkManager* host_frame_sink_manager) { + pending_gpu_lost_count_--; + if (pending_gpu_lost_count_ > 0) { + return; } - // Reconnect IPC. Destory the old controller and the old receiver on the - // VSyncThread first. - if (external_begin_frame_controller_) { - task_runner()->PostTask(FROM_HERE, base::DoNothingWithBoundArgs(std::move( - external_begin_frame_controller_))); - } - if (client_receiver_) { - task_runner()->DeleteSoon(FROM_HERE, std::move(client_receiver_)); - } + ConnectVSyncIpcAndAddDisplayObserver(host_frame_sink_manager); - ConnectVSyncIpc(host_frame_sink_manager); - - display::Screen::Get()->AddObserver(this); task_runner()->PostTask( FROM_HERE, base::BindOnce(&DisplayLinkMacMojo::InitDisplaysOnVSyncThread, base::Unretained(this))); } +void DisplayLinkMacMojo::OnGpuProcessLostOnVSyncThread( + viz::HostFrameSinkManager* host_frame_sink_manager, + scoped_refptr<base::SingleThreadTaskRunner> main_task_runner, + base::WeakPtr<DisplayLinkMacMojo> weak_ptr) { + DCHECK_CALLED_ON_VALID_SEQUENCE(vsync_thread_sequence_checker_);
Original Bug Report
Potential data race in DisplayLinkMacMojo on GPU process loss
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential concurrency data race exists in DisplayLinkMacMojo on macOS. When the GPU process is lost, OnGpuProcessLost is executed on the Browser UI thread and mutates member variables that are concurrently accessed by the VSync thread without adequate synchronization.
Affected files:
ui/compositor/display_link_mac_mojo.mmui/compositor/display_link_mac_mojo.h
Estimated timestamp from git blame: 2025-12-17
Description
A potential thread-safety issue has been identified in ui::DisplayLinkMacMojo on macOS (enabled via the kCADisplayLinkInBrowser feature flag). The class manages macOS CADisplayLink callbacks and coordinates VSync IPC between the GPU process and the Browser process.
Several member variables are accessed and mutated concurrently across two different threads without synchronization primitives (such as locks) or enforcement of a single sequence:
- Browser UI Thread: Runs
OnGpuProcessLostwhen a GPU process crash is detected. - VSync Thread (
base::Thread): Runs CADisplayLink callbacks (OnDisplayLinkVSyncCallback) and handles incoming IPC calls (NeedsBeginFrameWithId).
Potential Vulnerability Mechanism
1. Unsynchronized Map Mutations
In ui/compositor/display_link_mac_mojo.mm, OnGpuProcessLost runs on the Browser UI thread and performs the following moves:
if (!vsync_callbacks_.empty()) {
task_runner()->PostTask(
FROM_HERE, base::DoNothingWithBoundArgs(std::move(vsync_callbacks_)));
}
if (!display_links_.empty()) {
task_runner()->PostTask(
FROM_HERE, base::DoNothingWithBoundArgs(std::move(display_links_)));
}
Concurrently, the VSync thread executes NeedsBeginFrameWithId and CADisplayLink callbacks, which read and write to display_links_ and vsync_callbacks_:
auto found = display_links_.find(display_id);
...
vsync_callbacks_[display_id] = std::move(vsync_callback);
Because std::map is not thread-safe, concurrent reads/writes and moves can lead to undefined behavior, including memory corruption of allocator-owned map nodes.
2. Unsynchronized Mojo Remote Operations
Similarly, OnGpuProcessLost moves external_begin_frame_controller_ on the Browser UI thread:
if (external_begin_frame_controller_) {
task_runner()->PostTask(FROM_HERE, base::DoNothingWithBoundArgs(std::move(
external_begin_frame_controller_)));
}
And then re-initializes it via ConnectVSyncIpc on the Browser UI thread. Concurrently, OnDisplayLinkVSyncCallback on the VSync thread accesses this remote:
if (!external_begin_frame_controller_.is_bound()) {
return;
}
...
external_begin_frame_controller_->IssueExternalVSync(viz_params);
Because mojo::Remote is not thread-safe and is bound to the VSync thread sequence, modifying it from the UI thread violates sequence safety, potentially leading to a use-after-free (UAF) of the underlying Mojo routing and endpoint objects.
Potential Steps to Reproduce
Note: These are potential steps based on static analysis; our tooling does not currently run dynamic proof-of-concept tests.
- Establish a regular VSync registration from the GPU process to trigger continuous
OnDisplayLinkVSyncCallbackcalls at 60Hz-120Hz on the Browser’s VSync thread. - Cause the GPU process to terminate or crash, triggering
OnGpuProcessLoston the Browser UI thread. - The Browser UI thread immediately attempts to tear down and recreate the Mojo remotes and internal maps while CADisplayLink callbacks continue to fire on the VSync thread, triggering the race condition.
Suggested Fix
To resolve this issue, ensure all state mutations and accesses to display_links_, vsync_callbacks_, external_begin_frame_controller_, and client_receiver_ are serialized on the VSync sequence.
Instead of mutating these members directly on the Browser UI thread in OnGpuProcessLost, post a task to the VSync thread’s task runner to safely clean up, disconnect, and re-initialize the state on the correct sequence.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.