CVE-2026-7925
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
WtsSessionProcessDelegateremoting/host/win/wts_session_process_delegate.cc |
modified | |
ifremoting/host/win/wts_session_process_delegate.cc |
modified |
Files Changed
remoting/host/win/wts_session_process_delegate.cc
Patch
From d426f672125bfa1f92653bd0b4ab39814b4340b5 Mon Sep 17 00:00:00 2001 From: Joe Downing <[email protected]> Date: Mon, 20 Apr 2026 15:10:41 -0700 Subject: [PATCH] CRD: Fix Use-After-Free in WtsSessionProcessDelegate::Core A race condition during the shutdown of WtsSessionProcessDelegate::Core in the Windows Remoting Host allowed asynchronous Job object notifications to be processed after the object's destruction. Because the object pointer is stored in the Windows kernel as a raw integer, MiraclePtr protections were completely bypassed. This patch ensures that WtsSessionProcessDelegate::Core stays alive until the JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification is received, confirming that all processes in the job have exited and no further notifications will be sent to the completion port. Additionally, all PostTask calls in Core now use base::RetainedRef(this) to ensure the object is kept alive while tasks are pending on other threads. Bug: 501833981 Change-Id: I5dbfc73acf22425f038a381fd852baeaba3ef0d9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7773702 Commit-Queue: Joe Downing <[email protected]> Reviewed-by: Yuwei Huang <[email protected]> Cr-Commit-Position: refs/heads/main@{#1617797} --- diff --git a/remoting/host/win/wts_session_process_delegate.cc b/remoting/host/win/wts_session_process_delegate.cc index b0c8d60..4bfeed9 100644 --- a/remoting/host/win/wts_session_process_delegate.cc +++ b/remoting/host/win/wts_session_process_delegate.cc @@ -7,6 +7,7 @@ #include "remoting/host/win/wts_session_process_delegate.h" +#include <atomic> #include <memory> #include <utility> @@ -45,7 +46,7 @@ #include "remoting/host/worker_process_ipc_delegate.h" #include "remoting/host/worker_process_launcher.h" -using base::win::ScopedHandle; + using base::win::ScopedHandle; // Name of the default session desktop. const char kDefaultDesktopName[] = "winsta0\\default"; @@ -55,6 +56,12 @@ // A private class actually implementing the functionality provided by // |WtsSessionProcessDelegate|. This class is ref-counted and implements // asynchronous fire-and-forget shutdown. +// +// Most methods of this class run on the caller's thread (the thread that +// created the Core object). However, it also uses an I/O task runner (the +// `io_task_runner_` member) to receive and handle job object notifications +// (e.g., process creation and exit events). The class coordinates between +// these two threads. class WtsSessionProcessDelegate::Core : public base::RefCountedThreadSafe<Core>, public base::MessagePumpForIO::IOHandler, @@ -166,15 +173,21 @@ mojo::PlatformChannelServerEndpoint elevated_server_endpoint_; // If launching elevated, this is the pid of the launcher process. - base::ProcessId elevated_launcher_pid_ = base::kNullProcessId; + std::atomic<base::ProcessId> elevated_launcher_pid_ = base::kNullProcessId; // Tracks the id of the worker process. - base::ProcessId worker_process_pid_ = base::kNullProcessId; + std::atomic<base::ProcessId> worker_process_pid_ = base::kNullProcessId; // The pending process connection for the process being launched. mojo::OutgoingInvitation mojo_invitation_; mojo::AssociatedRemote<mojom::WorkerProcessControl> worker_process_control_; + + // Keeps this object alive until all job object notifications are received. + scoped_refptr<Core> self_; + + // True if Stop() has been called. + bool stopped_ = false; }; WtsSessionProcessDelegate::Core::Core( @@ -236,6 +249,8 @@ void WtsSessionProcessDelegate::Core::Stop() { DCHECK(caller_task_runner_->BelongsToCurrentThread()); + stopped_ = true; + KillProcess(); // Drain the completion queue to make sure all job object notifications have @@ -328,27 +343,27 @@ break; } case JOB_OBJECT_MSG_NEW_PROCESS: { - if (elevated_launcher_pid_ == base::kNullProcessId) { + if (elevated_launcher_pid_.load() == base::kNullProcessId) { // Ignore process launch events when we don't have a valid launcher pid. return; } - if (process_id != elevated_launcher_pid_) { - DCHECK_EQ(worker_process_pid_, base::kNullProcessId); + if (process_id != elevated_launcher_pid_.load()) { + DCHECK_EQ(worker_process_pid_.load(), base::kNullProcessId); worker_process_pid_ = process_id; } break; } case JOB_OBJECT_MSG_EXIT_PROCESS: { - if (process_id == worker_process_pid_) { + if (process_id == worker_process_pid_.load()) { // In official builds the first launch of a UiAccess enabled binary // will fail due to 'STATUS_ELEVATION_REQUIRED'. This is an artifact of // using ShellExecuteEx() to launch the process. In this scenario, we // will clear out the previously stored value for |worker_process_pid_| // and retry after the subsequent relaunch of the worker process. worker_process_pid_ = base::kNullProcessId; - } else if (process_id == elevated_launcher_pid_) { - if (worker_process_pid_ == base::kNullProcessId) { + } else if (process_id == elevated_launcher_pid_.load()) { + if (worker_process_pid_.load() == base::kNullProcessId) { // The elevated launcher process can fail to launch without attemping // to launch the worker. In this scenario, the failure will be // detected outside this method and the elevated launcher will be @@ -358,7 +373,7 @@ caller_task_runner_->PostTask( FROM_HERE, base::BindOnce(&Core::OnProcessLaunchDetected, this, - worker_process_pid_)); + worker_process_pid_.load())); } break; } @@ -455,6 +470,7 @@ } if (launch_elevated_) { + elevated_launcher_pid_ = GetProcessId(worker_process.Get()); if (!AssignProcessToJobObject(job_.Get(), worker_process.Get())) { PLOG(ERROR) << "Failed to assign the worker to the job object"; ReportFatalError(); @@ -474,7 +490,6 @@ // worker process launch is detected. Until then, store the values needed in // fields. See OnProcessLaunchDetected for their use. elevated_server_endpoint_ = elevated_mojo_channel->TakeServerEndpoint(); - elevated_launcher_pid_ = GetProcessId(worker_process.Get()); DCHECK(elevated_server_endpoint_.is_valid()); } else { mojo::OutgoingInvitation::Send(std::move(mojo_invitation_), @@ -500,10 +515,14 @@ if (job_.is_valid()) { job_.Close(); - // Drain the completion queue to make sure all job object notification have + // Drain the completion queue to make sure all job object notifications have // been received. io_task_runner_->PostTask( FROM_HERE, base::BindOnce(&Core::DrainJobNotifications, this)); + } else { + // The job object has been closed and the completion port queue has been + // drained. + self_ = nullptr; } } @@ -526,8 +545,23 @@ DCHECK(caller_task_runner_->BelongsToCurrentThread()); DCHECK(!job_.is_valid()); + if (stopped_) { + // If Stop() was called before the job was initialized, we must still ensure + // the completion port is drained because InitializeJob() has already + // called RegisterJobObject(). + job_ = std::move(job); + self_ = this; + DrainJobNotificationsCompleted(); + return; + } + job_ = std::move(job); + // Keep this object alive until the job object notifications have been + // drained. This ensures that OnIOCompleted() is not called with a dangling + // pointer. + self_ = this; + if (launch_pending_) { DoLaunchProcess(); } @@ -546,7 +580,7 @@ void WtsSessionProcessDelegate::Core::OnProcessLaunchDetected( base::ProcessId pid) { DCHECK(caller_task_runner_->BelongsToCurrentThread()); - DCHECK_NE(pid, elevated_launcher_pid_); + DCHECK_NE(pid, elevated_launcher_pid_.load());
Original Bug Report
Potential Use-After-Free in Windows Remoting Host Job Object Draining
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 without the Chrome Security team.
Overview: A race condition during the shutdown of WtsSessionProcessDelegate::Core in the Windows Remoting Host allows asynchronous Job object notifications to be processed after the object’s destruction. Because the object pointer is stored in the Windows kernel as a raw integer, MiraclePtr protections are completely bypassed. This Use-After-Free could potentially allow a compromised lower-privileged process to achieve Remote Code Execution as SYSTEM.
Affected files:
remoting/host/win/wts_session_process_delegate.cc
Estimated timestamp from git blame: 2025-11-10
Description
A potential Use-After-Free (UAF) vulnerability exists in the Chrome Remote Desktop host (remoting_host) on Windows, specifically in WtsSessionProcessDelegate::Core.
The vulnerability stems from a race condition during the teardown of a Windows Job Object. The shutdown sequence assumes that a single task round-trip to the IO thread (DrainJobNotifications()) is sufficient to drain pending Job Object notifications. However, TerminateJobObject() is an asynchronous kernel operation. Processes within the Job may continue to terminate after the draining task completes.
Consequently, the Core object drops its final reference and is freed, but the Job Object remains associated with the IO Completion Port (IOCP). When the processes finally exit, the Windows kernel queues a JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO packet to the IOCP. The packet contains a completion key pointing directly to the freed Core object. When the IO thread dequeues this packet, it triggers a virtual call to OnIOCompleted() on the deleted object, resulting in a UAF.
MiraclePtr Bypass
During Job Object registration, the Core pointer is cast to a ULONG_PTR and passed to the Windows kernel. Because it is stored as a raw integer outside of Chromium’s memory space, PartitionAlloc’s BackupRefPtr (MiraclePtr) reference count is not incremented. When the object is freed on the caller thread, the memory is immediately returned to the allocator without being quarantined. This completely bypasses MiraclePtr protections and allows reliable reallocation.
Potential Reproduction Steps
(Note: These are suggested steps based on static analysis; our tooling agent cannot execute code to verify a PoC.)
- From a compromised lower-privileged process (e.g., a sandboxed renderer), send an IPC message to the
remoting_hostdaemon (running as SYSTEM) to manage a worker session. - Trigger the termination of the session, invoking
WtsSessionProcessDelegate::Core::Stop(). - The host calls
TerminateJobObject()and immediately runs its internalDrainJobNotificationstask, releasing theCoreobject. - Through IPC heap spraying, reallocate the freed
Coreobject memory with attacker-controlled data, including a validCorevtable to bypass Clang CFI. - The asynchronous termination of the Job finishes, causing the kernel to queue the
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZEROpacket. - The IO thread dequeues the packet, casts the dangling
ULONG_PTRcompletion key back toIOHandler*, and executes theOnIOCompleted()virtual call on the attacker-controlled memory, leading to RCE as SYSTEM.
Suggested Fix
To fix this race condition, WtsSessionProcessDelegate::Core must not be destroyed until the Job Object is verifiably empty. Instead of relying on a fire-and-forget draining task, the Core object should retain a scoped_refptr to itself until it explicitly receives the JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification via OnIOCompleted(). This ensures the object definitively outlives all possible IOCP callbacks generated by the kernel.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.