High watch CVSS 7.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Chromoting
DescriptionUse after free in Chromoting
ComponentChromoting
Sub-componentCRD
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker501833981
Fix commitd426f672125b (chromium/src) +47/-13
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-12 (per vendor advisory)
Fix landed2026-04-20 (public git)
Disclosed2026-05-05
Patch gap15 days — fix public in source before the advisory

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 the io_task_runner_ thread via the IOHandler/OnIOCompleted path.
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.

Key insight
The core mistake was letting a ref-counted object that the kernel references by a raw integer completion key be destroyed while job notifications were still pending, so a post-destruction 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

  1. Establish the worker/job lifecycle A remoting-host session launches a worker process assigned to a Job object, registering Core with the I/O completion port so the kernel holds its pointer as a raw integer key.
  2. 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 of Core.”
  3. Destroy `Core` while a notification is in flight With no reference held across the drain window, the last ref to Core drops and the object is freed before the kernel’s queued notification is dispatched.
  4. Dispatch to freed object OnIOCompleted runs on the io_task_runner_ thread against the now-freed Core, 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

An attacker able to win the shutdown race in the Windows Chromoting host gains a use-after-free of the 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

FunctionChangeNotes
WtsSessionProcessDelegate
remoting/host/win/wts_session_process_delegate.cc
modified
if
remoting/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 MiraclePtr
    Audit any object whose address is stored as an integer in an OS primitive (completion-port keys, OVERLAPPED/APC context, timer or window LPARAM); such references are invisible to raw_ptr and need explicit ref-counted lifetime extension until the callback is guaranteed drained.
  • Fire-and-forget async shutdown
    Review ref-counted objects that tear down while asynchronous notifications are still queued; ensure a self-reference (or base::RetainedRef on every cross-thread PostTask) keeps the object alive until a definitive “no more callbacks” signal (here JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO).
  • Cross-thread fields without synchronization
    Look 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.
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());
Loading diff…

Original Bug Report

reported by [email protected]

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

  1. From a compromised lower-privileged process (e.g., a sandboxed renderer), send an IPC message to the remoting_host daemon (running as SYSTEM) to manage a worker session.
  2. Trigger the termination of the session, invoking WtsSessionProcessDelegate::Core::Stop().
  3. The host calls TerminateJobObject() and immediately runs its internal DrainJobNotifications task, releasing the Core object.
  4. Through IPC heap spraying, reallocate the freed Core object memory with attacker-controlled data, including a valid Core vtable to bypass Clang CFI.
  5. The asynchronous termination of the Job finishes, causing the kernel to queue the JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO packet.
  6. The IO thread dequeues the packet, casts the dangling ULONG_PTR completion key back to IOHandler*, and executes the OnIOCompleted() 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.

View on issue tracker