CVE-2026-7925
Overview
Background
- `WtsSessionProcessDelegate::Core`
- a ref-counted (
base::RefCountedThreadSafe) helper in the Windows remoting (Chromoting) host that launches and supervises a worker process inside a Job object and implements asynchronous “fire-and-forget” shutdown. - Job object completion-port notifications
- Windows kernel messages (
JOB_OBJECT_MSG_NEW_PROCESS,JOB_OBJECT_MSG_EXIT_PROCESS,JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) delivered asynchronously on theio_task_runner_thread via theIOHandler/OnIOCompletedpath. - MiraclePtr (`raw_ptr`)
- Chrome’s hardening that quarantines freed objects to turn use-after-free into a safe crash, but it only protects C++ pointers, not a pointer the kernel stores as a plain integer in a completion-port key.
- `self_` / `base::RetainedRef`
- a self-held
scoped_refptr<Core>and the task-binding helper used to keep a ref-counted object alive while asynchronous work referencing it is still pending.
Root Cause Analysis
Core registered its job object with an I/O completion port whose completion key stores the Core pointer as a raw kernel integer, and during shutdown the object could be destroyed while the kernel still had pending job notifications queued for that key. Nothing held a reference to Core across the window between tearing it down and the arrival of the final notifications, so an asynchronous OnIOCompleted could fire on the io_task_runner_ thread after the object had been freed — a use-after-free that, as the commit states, “completely bypassed” MiraclePtr because the dangling reference lived in the kernel as an integer rather than as a raw_ptr. The elevated_launcher_pid_ and worker_process_pid_ fields were also read/written across the caller and I/O threads without synchronization, a data race on the same objects.
The fix makes Core hold itself alive via a self_ member set when the job is initialized and cleared only after the completion queue is drained (keyed on receiving JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO), adds a stopped_ flag so a Stop() that races initialization still drains correctly, routes cross-thread PostTask callbacks through ownership-retaining binds, and makes the two pid fields std::atomic.
OnIOCompleted dereferenced freed memory; the fix keeps Core alive via a self-reference until JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO confirms no further notifications will arrive.Attack Path
- Establish the worker/job lifecycle
A remoting-host session launches a worker process assigned to a Job object, registering
Corewith the I/O completion port so the kernel holds its pointer as a raw integer key. - Trigger shutdown during pending notifications
Shutdown (
Stop()→KillProcess()→ queue drain) races with the asynchronous delivery of job notifications, as the commit describes a “race condition during the shutdown ofCore.” - Destroy `Core` while a notification is in flight
With no reference held across the drain window, the last ref to
Coredrops and the object is freed before the kernel’s queued notification is dispatched. - Dispatch to freed object
OnIOCompletedruns on theio_task_runner_thread against the now-freedCore, a use-after-free that MiraclePtr does not catch because the stale reference is the kernel’s integer key (any further memory-corruption escalation is(inferred); the diff shows only the lifetime race).
Impact Assessment
Core object, dereferenced on the I/O thread via a kernel-delivered job notification, in the remoting host process (not a sandboxed renderer). Preconditions are triggering host/worker shutdown while job-object notifications are still pending, and because the dangling reference is a kernel integer key, MiraclePtr provides no mitigation. The vendor advisory rates it High and does not report in-the-wild exploitation; escalation beyond the freed-object access is (inferred).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
Audit Directions
- Kernel-held raw pointers bypassing MiraclePtrAudit any object whose address is stored as an integer in an OS primitive (completion-port keys,
OVERLAPPED/APCcontext, timer or windowLPARAM); such references are invisible toraw_ptrand need explicit ref-counted lifetime extension until the callback is guaranteed drained. - Fire-and-forget async shutdownReview ref-counted objects that tear down while asynchronous notifications are still queued; ensure a self-reference (or
base::RetainedRefon every cross-threadPostTask) keeps the object alive until a definitive “no more callbacks” signal (hereJOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO). - Cross-thread fields without synchronizationLook for members read on one task runner and written on another (the pid fields here) that lack
std::atomic/locking; such data races often accompany, and can widen, the lifetime UAF window.
Patch
From d426f672125bfa1f92653bd0b4ab39814b4340b5 Mon Sep 17 00:00:00 2001
From: Joe Downing <joedow@google.com>
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 <joedow@chromium.org>
Reviewed-by: Yuwei Huang <yuweih@chromium.org>
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.