High watch CVSS 8.1 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Chromoting
DescriptionRace condition in Chromoting
ComponentChromoting
Sub-componentRemoting
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker524423633
Fix commitdf9740a82a96 (chromium/src) +373/-25
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-16 (per vendor advisory)
Fix landed2026-08-21 (public git)
Disclosed2026-09-08
Patch gap18 days — fix public in source before the advisory

Background

Chromoting / Chrome Remote Desktop host (Windows)
The remoting host code that launches and supervises an elevated worker process in another WTS session to service remote-desktop connections.
Windows job object exit notifications
When a member of a job object terminates, Windows posts a completion event to WtsSessionProcessDelegate::Core::OnIOCompleted(); a clean exit arrives as JOB_OBJECT_MSG_EXIT_PROCESS while a crash/unhandled exception arrives as JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS.
`worker_process_pid_`
The atomic field caching the PID of the elevated worker; it is meant to hold a live worker’s PID and is consumed by OnProcessLaunchDetected() when the intermediate launcher exits, to wire up the Mojo IPC channel.
PID recycling
Windows is free to reassign a terminated process’s PID to an unrelated (possibly attacker-controlled) process, so a stale cached PID no longer reliably identifies the intended target.

Root Cause Analysis

WtsSessionProcessDelegate::Core::OnIOCompleted() only cleared worker_process_pid_ on the clean-exit notification JOB_OBJECT_MSG_EXIT_PROCESS; it had no case for JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS, which is what Windows actually delivers when the worker dies from a crash or unhandled exception.

As a result, after an abnormal worker exit the field retained the dead worker’s PID, violating the invariant that worker_process_pid_ names a live job member. If the intermediate elevated launcher subsequently exited without spawning a replacement worker, that stale (and now potentially recycled) PID was treated as a successful launch and passed to OnProcessLaunchDetected(), which would open the process and send it the privileged Mojo OutgoingInvitation.

The fix handles JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS identically to the clean-exit case so the PID is reset, also clears worker_process_pid_ in CloseChannel(), and adds a defense-in-depth ::IsProcessInJob() check in OnProcessLaunchDetected() so IPC is only initiated to a process confirmed to belong to the delegate’s own job object. This closes the window in which a recycled PID could be trusted as the elevated worker.

Key insight
The single mistake was treating only JOB_OBJECT_MSG_EXIT_PROCESS as the worker-gone signal while ignoring JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS, leaving a stale, recyclable PID that was later trusted as a live worker; the fix resets worker_process_pid_ on abnormal exit (and in CloseChannel()) and verifies job membership via ::IsProcessInJob() before establishing IPC.

Attack Path

  1. Elevated worker crashes The Chromoting worker process running in the console session dies from an unhandled exception, so Windows posts JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS rather than JOB_OBJECT_MSG_EXIT_PROCESS to OnIOCompleted().
  2. Stale PID retained Pre-patch OnIOCompleted() has no branch for the abnormal-exit message, so worker_process_pid_ still holds the dead worker’s PID instead of being reset to base::kNullProcessId.
  3. PID recycled by another process Windows reassigns the freed PID to a different, attacker-controlled process (inferred), so the cached PID now points at a process the host never launched.
  4. Launcher exit triggers launch detection The intermediate elevated launcher exits without spawning a replacement worker; the stale worker_process_pid_ is treated as a successful launch and passed to OnProcessLaunchDetected().
  5. Privileged IPC sent to the wrong process OnProcessLaunchDetected() opens the recycled PID and sends the Mojo OutgoingInvitation to it, handing an unrelated process the IPC channel intended for the trusted elevated worker (inferred escalation); the added ::IsProcessInJob() guard now rejects any such non-member process first.

Impact Assessment

An attacker who wins the race to occupy the recycled PID could receive the privileged Mojo IPC channel that the Chromoting host intended to hand to its trusted elevated worker, running in the context of the remoting host on Windows. Exploitation is local and requires precise timing: a worker must exit abnormally, the launcher must exit without relaunching, and the attacker must acquire the recycled PID before OnProcessLaunchDetected() runs. The advisory classifies this as a race condition in Chromoting with no exploited-in-the-wild claim; any resulting privilege escalation or IPC hijack beyond the misdirected invitation the patch guards against is (inferred).

Changed Functions

FunctionChangeNotes
if
remoting/host/win/wts_session_process_delegate.cc
modified
SingleThreadTaskRunner
remoting/host/win/wts_session_process_delegate.h
modified
PlatformChannelServerEndpoint
remoting/host/win/wts_session_process_delegate.h
modified
TestApi
remoting/host/win/wts_session_process_delegate.h
modified
TEST_F
remoting/host/win/wts_session_process_delegate_unittest.cc
modified

Files Changed

  • remoting/host/win/wts_session_process_delegate.cc
  • remoting/host/win/wts_session_process_delegate.h
  • remoting/host/win/wts_session_process_delegate_unittest.cc

Audit Directions

  • Incomplete notification-message coverage
    Audit every switch/case over Windows job or IO completion messages (e.g. JOB_OBJECT_MSG_*) for handlers that cover the “normal” message but omit the abnormal/exceptional variant that carries the same state-clearing obligation.
  • Cached OS identifiers trusted after the resource dies
    Look for PIDs, handles, or session IDs cached in long-lived fields and later used to open or message a resource without re-verifying it is still the intended target, since OS identifiers (especially PIDs) can be recycled.
  • IPC endpoints established without membership checks
    Review paths that send Mojo invitations or hand out privileged channels to a process identified only by PID, and confirm they validate job/session/parentage membership (like the added ::IsProcessInJob()) before establishing trust.
From df9740a82a96fa627ee039fabcec20d823aabf95 Mon Sep 17 00:00:00 2001
From: Joe Downing <joedow@google.com>
Date: Fri, 21 Aug 2026 16:49:47 -0700
Subject: [PATCH] [remoting][win] Handle abnormal process exit in job notifications

WtsSessionProcessDelegate::Core::OnIOCompleted() only handled
JOB_OBJECT_MSG_EXIT_PROCESS. Windows delivers
JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS instead when a process in the job
terminates due to an unhandled exception or crash, leaving
worker_process_pid_ stale. If the intermediate launcher then exited
without spawning a replacement worker, the stale PID was treated as a
successful launch and passed to OnProcessLaunchDetected().

This change:
1. Handles JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS identically to
   JOB_OBJECT_MSG_EXIT_PROCESS in OnIOCompleted(), resetting
   worker_process_pid_.
2. Resets worker_process_pid_ in CloseChannel().
3. Adds defense-in-depth verification in OnProcessLaunchDetected() via
   ::IsProcessInJob() to ensure the opened process is a member of the
   delegate's job object before initiating IPC.
4. Adds unit tests covering normal and abnormal exit handling, launcher
   exit behavior, and job membership validation.

TAG=agy
CONV=2622fa76-8d29-4859-9d74-e7b26cd10bb8

Bug: 524423633
Change-Id: I71c315b964507b9abba8931bc18e847b0b7fe6fc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8271670
Reviewed-by: Yuwei Huang <yuweih@chromium.org>
Commit-Queue: Joe Downing <joedow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1684314}
---
diff --git a/remoting/host/win/wts_session_process_delegate.cc b/remoting/host/win/wts_session_process_delegate.cc
index e38aeeb..02a7554 100644
--- a/remoting/host/win/wts_session_process_delegate.cc
+++ b/remoting/host/win/wts_session_process_delegate.cc
@@ -92,6 +92,15 @@
 
   bool AssignProcessToJobForTesting(base::ProcessHandle process);
   void SetCoreDeletedCallbackForTesting(base::OnceClosure callback);
+  void SimulateJobNotificationForTesting(DWORD event, base::ProcessId pid);
+  base::ProcessId GetWorkerProcessPidForTesting() const;
+  void SetElevatedLauncherPidForTesting(base::ProcessId pid);
+  void SetWorkerProcessPidForTesting(base::ProcessId pid);
+  void OnProcessLaunchDetectedForTesting(base::ProcessId pid);
+  void SetElevatedServerEndpointForTesting(
+      mojo::PlatformChannelServerEndpoint endpoint);
+  void SetFatalErrorCallbackForTesting(base::OnceClosure callback);
+  void SetProcessLaunchedCallbackForTesting(base::OnceClosure callback);
 
  private:
   friend class base::RefCountedThreadSafe<Core>;
@@ -198,6 +207,8 @@
   bool job_process_assigned_ = false;
 
   base::OnceClosure deleted_callback_for_testing_;
+  base::OnceClosure fatal_error_callback_for_testing_;
+  base::OnceClosure process_launched_callback_for_testing_;
 };
 
 WtsSessionProcessDelegate::Core::Core(
@@ -297,6 +308,7 @@
   channel_.reset();
   elevated_server_endpoint_.reset();
   elevated_launcher_pid_ = base::kNullProcessId;
+  worker_process_pid_ = base::kNullProcessId;
   mojo_invitation_ = {};
 }
 
@@ -345,6 +357,56 @@
   deleted_callback_for_testing_ = std::move(callback);
 }
 
+void WtsSessionProcessDelegate::Core::SimulateJobNotificationForTesting(
+    DWORD event,
+    base::ProcessId pid) {
+  if (!io_task_runner_->BelongsToCurrentThread()) {
+    io_task_runner_->PostTask(
+        FROM_HERE, base::BindOnce(&Core::SimulateJobNotificationForTesting,
+                                  this, event, pid));
+    return;
+  }
+  OnIOCompleted(reinterpret_cast<base::MessagePumpForIO::IOContext*>(
+                    static_cast<uintptr_t>(pid)),
+                event, 0);
+}
+
+base::ProcessId WtsSessionProcessDelegate::Core::GetWorkerProcessPidForTesting()
+    const {
+  return worker_process_pid_.load();
+}
+
+void WtsSessionProcessDelegate::Core::SetElevatedLauncherPidForTesting(
+    base::ProcessId pid) {
+  elevated_launcher_pid_ = pid;
+}
+
+void WtsSessionProcessDelegate::Core::SetWorkerProcessPidForTesting(
+    base::ProcessId pid) {
+  worker_process_pid_ = pid;
+}
+
+void WtsSessionProcessDelegate::Core::OnProcessLaunchDetectedForTesting(
+    base::ProcessId pid) {
+  DCHECK(caller_task_runner_->BelongsToCurrentThread());
+  OnProcessLaunchDetected(pid);
+}
+
+void WtsSessionProcessDelegate::Core::SetElevatedServerEndpointForTesting(
+    mojo::PlatformChannelServerEndpoint endpoint) {
+  elevated_server_endpoint_ = std::move(endpoint);
+}
+
+void WtsSessionProcessDelegate::Core::SetFatalErrorCallbackForTesting(
+    base::OnceClosure callback) {
+  fatal_error_callback_for_testing_ = std::move(callback);
+}
+
+void WtsSessionProcessDelegate::Core::SetProcessLaunchedCallbackForTesting(
+    base::OnceClosure callback) {
+  process_launched_callback_for_testing_ = std::move(callback);
+}
+
 WtsSessionProcessDelegate::Core::~Core() {
   DCHECK(!channel_);
   DCHECK(!event_handler_);
@@ -382,7 +444,8 @@
       }
       break;
     }
-    case JOB_OBJECT_MSG_EXIT_PROCESS: {
+    case JOB_OBJECT_MSG_EXIT_PROCESS:
+    case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS: {
       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
@@ -654,6 +717,16 @@
     ReportFatalError();
     return;
   }
+
+  BOOL is_in_job = FALSE;
+  if (!job_.is_valid() ||
+      !::IsProcessInJob(worker_process.Get(), job_.Get(), &is_in_job) ||
+      !is_in_job) {
+    LOG(ERROR) << "Process " << pid << " is not in the expected job object.";
+    ReportFatalError();
+    return;
+  }
+
   elevated_launcher_pid_ = base::kNullProcessId;
   mojo::OutgoingInvitation::Send(std::move(mojo_invitation_),
                                  worker_process.Get(),
@@ -668,7 +741,12 @@
 
   WorkerProcessLauncher* event_handler = event_handler_;
   event_handler_ = nullptr;
-  event_handler->OnFatalError();
+  if (event_handler) {
+    event_handler->OnFatalError();
+  }
+  if (fatal_error_callback_for_testing_) {
+    std::move(fatal_error_callback_for_testing_).Run();
+  }
 }
 
 void WtsSessionProcessDelegate::Core::ReportProcessLaunched(
@@ -692,9 +770,12 @@
   }
   ScopedHandle limited_handle(temp_handle);
 
-  if (delegate_) {
+  if (delegate_ && event_handler_) {
     delegate_->WatchProcess(std::move(limited_handle));
   }
+  if (process_launched_callback_for_testing_) {
+    std::move(process_launched_callback_for_testing_).Run();
+  }
 }
 
 WtsSessionProcessDelegate::WtsSessionProcessDelegate(
@@ -738,14 +819,58 @@
   core_->KillProcess();
 }
 
-bool WtsSessionProcessDelegate::AssignProcessToJobForTesting(
+WtsSessionProcessDelegate::TestApi::TestApi(WtsSessionProcessDelegate* delegate)
+    : delegate_(delegate) {}
+
+bool WtsSessionProcessDelegate::TestApi::AssignProcessToJob(
     base::ProcessHandle process) {
-  return core_->AssignProcessToJobForTesting(process);  // IN-TEST
+  return delegate_->core_->AssignProcessToJobForTesting(process);
 }
 
-void WtsSessionProcessDelegate::SetCoreDeletedCallbackForTesting(
+void WtsSessionProcessDelegate::TestApi::SetCoreDeletedCallback(
     base::OnceClosure callback) {
-  core_->SetCoreDeletedCallbackForTesting(std::move(callback));  // IN-TEST
+  delegate_->core_->SetCoreDeletedCallbackForTesting(std::move(callback));
+}
+
+void WtsSessionProcessDelegate::TestApi::SimulateJobNotification(
+    DWORD event,
+    base::ProcessId pid) {
+  delegate_->core_->SimulateJobNotificationForTesting(event, pid);
+}
+
+base::ProcessId WtsSessionProcessDelegate::TestApi::GetWorkerProcessPid()
+    const {
+  return delegate_->core_->GetWorkerProcessPidForTesting();
+}
+
+void WtsSessionProcessDelegate::TestApi::SetElevatedLauncherPid(
+    base::ProcessId pid) {
+  delegate_->core_->SetElevatedLauncherPidForTesting(pid);
+}
+
+void WtsSessionProcessDelegate::TestApi::SetWorkerProcessPid(
+    base::ProcessId pid) {
+  delegate_->core_->SetWorkerProcessPidForTesting(pid);
+}
+
+void WtsSessionProcessDelegate::TestApi::OnProcessLaunchDetected(
+    base::ProcessId pid) {
+  delegate_->core_->OnProcessLaunchDetectedForTesting(pid);
+}
+
+void WtsSessionProcessDelegate::TestApi::SetElevatedServerEndpoint(
+    mojo::PlatformChannelServerEndpoint endpoint) {
+  delegate_->core_->SetElevatedServerEndpointForTesting(std::move(endpoint));
+}
+
+void WtsSessionProcessDelegate::TestApi::SetFatalErrorCallback(
+    base::OnceClosure callback) {
+  delegate_->core_->SetFatalErrorCallbackForTesting(std::move(callback));
+}
+
+void WtsSessionProcessDelegate::TestApi::SetProcessLaunchedCallback(
+    base::OnceClosure callback) {
+  delegate_->core_->SetProcessLaunchedCallbackForTesting(std::move(callback));
 }
 
 }  // namespace remoting
diff --git a/remoting/host/win/wts_session_process_delegate.h b/remoting/host/win/wts_session_process_delegate.h
index e613eaaf..2141ed5 100644
--- a/remoting/host/win/wts_session_process_delegate.h
+++ b/remoting/host/win/wts_session_process_delegate.h
@@ -11,6 +11,7 @@
 
 #include "base/compiler_specific.h"
 #include "base/functional/callback_forward.h"
+#include "base/memory/raw_ptr.h"
 #include "base/memory/scoped_refptr.h"
 #include "base/memory/weak_ptr.h"
 #include "base/process/process_handle.h"
@@ -22,6 +23,10 @@
 class SingleThreadTaskRunner;
 }  // namespace base
 
+namespace mojo {
+class PlatformChannelServerEndpoint;
+}  // namespace mojo
+
 namespace remoting {
 
 // Implements logic for launching and monitoring a worker process in a different
@@ -51,14 +56,46 @@
   void CrashProcess(const base::Location& location) override;
   void KillProcess() override;
 
-  // Assigns |process| to the job object so that tests can exercise the
-  // job-object shutdown path without launching a process in another session.
-  // Returns true on success. The delegate must have been created with
-  // |launch_elevated| and the job must have been initialized.
-  bool AssignProcessToJobForTesting(base::ProcessHandle process);
+  class TestApi {
+   public:
+    explicit TestApi(WtsSessionProcessDelegate* delegate);
 
-  // Registers |callback| to be run when the internal Core object is destroyed.
-  void SetCoreDeletedCallbackForTesting(base::OnceClosure callback);
+    // Assigns |process| to the job object so that tests can exercise the
+    // job-object shutdown path without launching a process in another session.
+    // Returns true on success. The delegate must have been created with
+    // |launch_elevated| and the job must have been initialized.
+    bool AssignProcessToJob(base::ProcessHandle process);
+
+    // Registers |callback| to be run when the internal Core object is
+    // destroyed.
+    void SetCoreDeletedCallback(base::OnceClosure callback);
+
+    // Simulates an I/O completion event from the job object on the I/O thread.
+    void SimulateJobNotification(DWORD event, base::ProcessId pid);
+
+    // Returns the currently stored worker process ID.
+    base::ProcessId GetWorkerProcessPid() const;
+
+    // Sets the elevated launcher PID.
+    void SetElevatedLauncherPid(base::ProcessId pid);
+
+    // Sets the worker process PID.
+    void SetWorkerProcessPid(base::ProcessId pid);
+
+    // Invokes OnProcessLaunchDetected directly.
+    void OnProcessLaunchDetected(base::ProcessId pid);
+
+    // Sets the elevated server endpoint.
+    void SetElevatedServerEndpoint(
+        mojo::PlatformChannelServerEndpoint endpoint);
+
+    // Registers callbacks for fatal error and process launched events.
+    void SetFatalErrorCallback(base::OnceClosure callback);
+    void SetProcessLaunchedCallback(base::OnceClosure callback);
+
+   private:
+    raw_ptr<WtsSessionProcessDelegate> delegate_;
+  };
 
  private:
   // The actual implementation resides in WtsSessionProcessDelegate::Core class.
diff --git a/remoting/host/win/wts_session_process_delegate_unittest.cc b/remoting/host/win/wts_session_process_delegate_unittest.cc
index 484ef86..6fd332a1 100644
--- a/remoting/host/win/wts_session_process_delegate_unittest.cc
+++ b/remoting/host/win/wts_session_process_delegate_unittest.cc
@@ -21,6 +21,7 @@
 #include "base/test/test_timeouts.h"
 #include "base/threading/thread.h"
 #include "base/win/scoped_handle.h"
+#include "mojo/public/cpp/platform/named_platform_channel.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "testing/multiprocess_func_list.h"
 
@@ -79,14 +80,16 @@
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child.Handle()));
 
   bool core_deleted = false;
   base::RunLoop run_loop;
-  delegate->SetCoreDeletedCallbackForTesting(base::BindLambdaForTesting([&]() {
-    core_deleted = true;
-    run_loop.Quit();
-  }));
+  WtsSessionProcessDelegate::TestApi(delegate.get())
+      .SetCoreDeletedCallback(base::BindLambdaForTesting([&]() {
+        core_deleted = true;
+        run_loop.Quit();
+      }));
 
   // Destroying the delegate calls Stop(), which terminates the job. The Core
   // must remain alive until the I/O thread has delivered the final job
@@ -122,13 +125,15 @@
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child1.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child1.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child1.Handle()));
 
   base::Process child2 = base::SpawnMultiProcessTestChild(
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child2.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child2.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child2.Handle()));
 
   // Terminate child1 so a JOB_OBJECT_MSG_EXIT_PROCESS / ACTIVE_PROCESS_ZERO is
   // generated, but child2 remains active in the job object.
@@ -137,10 +142,11 @@
 
   bool core_deleted = false;
   base::RunLoop run_loop;
-  delegate->SetCoreDeletedCallbackForTesting(base::BindLambdaForTesting([&]() {
-    core_deleted = true;
-    run_loop.Quit();
-  }));
+  WtsSessionProcessDelegate::TestApi(delegate.get())
+      .SetCoreDeletedCallback(base::BindLambdaForTesting([&]() {
+        core_deleted = true;
+        run_loop.Quit();
+      }));
 
   // Destroying delegate stops the job. Since child2 is still in the job, Core
   // must stay alive until child2 is terminated.
@@ -155,6 +161,186 @@
                                             &exit_code));
 }
 
+TEST_F(WtsSessionProcessDelegateTest, AbnormalWorkerExitClearsWorkerPid) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/remoting/host/win/wts_session_process_delegate_unittest.cc b/remoting/host/win/wts_session_process_delegate_unittest.cc
index 484ef86..6fd332a1 100644
--- a/remoting/host/win/wts_session_process_delegate_unittest.cc
+++ b/remoting/host/win/wts_session_process_delegate_unittest.cc
@@ -21,6 +21,7 @@
 #include "base/test/test_timeouts.h"
 #include "base/threading/thread.h"
 #include "base/win/scoped_handle.h"
+#include "mojo/public/cpp/platform/named_platform_channel.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "testing/multiprocess_func_list.h"
 
@@ -79,14 +80,16 @@
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child.Handle()));
 
   bool core_deleted = false;
   base::RunLoop run_loop;
-  delegate->SetCoreDeletedCallbackForTesting(base::BindLambdaForTesting([&]() {
-    core_deleted = true;
-    run_loop.Quit();
-  }));
+  WtsSessionProcessDelegate::TestApi(delegate.get())
+      .SetCoreDeletedCallback(base::BindLambdaForTesting([&]() {
+        core_deleted = true;
+        run_loop.Quit();
+      }));
 
   // Destroying the delegate calls Stop(), which terminates the job. The Core
   // must remain alive until the I/O thread has delivered the final job
@@ -122,13 +125,15 @@
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child1.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child1.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child1.Handle()));
 
   base::Process child2 = base::SpawnMultiProcessTestChild(
       "WtsSessionProcessDelegateTestChild",
       base::GetMultiProcessTestChildBaseCommandLine(), {});
   ASSERT_TRUE(child2.IsValid());
-  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child2.Handle()));
+  ASSERT_TRUE(WtsSessionProcessDelegate::TestApi(delegate.get())
+                  .AssignProcessToJob(child2.Handle()));
 
   // Terminate child1 so a JOB_OBJECT_MSG_EXIT_PROCESS / ACTIVE_PROCESS_ZERO is
   // generated, but child2 remains active in the job object.
@@ -137,10 +142,11 @@
 
   bool core_deleted = false;
   base::RunLoop run_loop;
-  delegate->SetCoreDeletedCallbackForTesting(base::BindLambdaForTesting([&]() {
-    core_deleted = true;
-    run_loop.Quit();
-  }));
+  WtsSessionProcessDelegate::TestApi(delegate.get())
+      .SetCoreDeletedCallback(base::BindLambdaForTesting([&]() {
+        core_deleted = true;
+        run_loop.Quit();
+      }));
 
   // Destroying delegate stops the job. Since child2 is still in the job, Core
   // must stay alive until child2 is terminated.
@@ -155,6 +161,186 @@
                                             &exit_code));
 }
 
+TEST_F(WtsSessionProcessDelegateTest, AbnormalWorkerExitClearsWorkerPid) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
+      /*launch_elevated=*/true,
+      /*channel_security=*/std::string());
+
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+  FlushIoThread();
+
+  constexpr base::ProcessId kLauncherPid = 1000;
+  constexpr base::ProcessId kWorkerPid = 2000;
+
+  WtsSessionProcessDelegate::TestApi test_api(delegate.get());
+  test_api.SetElevatedLauncherPid(kLauncherPid);
+  test_api.SetWorkerProcessPid(kWorkerPid);
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), kWorkerPid);
+
+  // Simulate JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS for the worker process.
+  test_api.SimulateJobNotification(JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS,
+                                   kWorkerPid);
+  FlushIoThread();
+
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), base::kNullProcessId);
+}
+
+TEST_F(WtsSessionProcessDelegateTest, NormalWorkerExitClearsWorkerPid) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
+      /*launch_elevated=*/true,
+      /*channel_security=*/std::string());
+
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+  FlushIoThread();
+
+  constexpr base::ProcessId kLauncherPid = 1000;
+  constexpr base::ProcessId kWorkerPid = 2000;
+
+  WtsSessionProcessDelegate::TestApi test_api(delegate.get());
+  test_api.SetElevatedLauncherPid(kLauncherPid);
+  test_api.SetWorkerProcessPid(kWorkerPid);
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), kWorkerPid);
+
+  // Simulate JOB_OBJECT_MSG_EXIT_PROCESS for the worker process.
+  test_api.SimulateJobNotification(JOB_OBJECT_MSG_EXIT_PROCESS, kWorkerPid);
+  FlushIoThread();
+
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), base::kNullProcessId);
+}
+
+TEST_F(WtsSessionProcessDelegateTest,
+       LauncherExitAfterAbnormalWorkerExitDoesNotReportStalePid) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
+      /*launch_elevated=*/true,
+      /*channel_security=*/std::string());
+
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+  FlushIoThread();
+
+  constexpr base::ProcessId kLauncherPid = 1000;
+  constexpr base::ProcessId kWorkerPid = 2000;
+
+  WtsSessionProcessDelegate::TestApi test_api(delegate.get());
+  test_api.SetElevatedLauncherPid(kLauncherPid);
+  test_api.SetWorkerProcessPid(kWorkerPid);
+
+  // 1. Worker process crashes abnormally.
+  test_api.SimulateJobNotification(JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS,
+                                   kWorkerPid);
+  FlushIoThread();
+
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), base::kNullProcessId);
+
+  // 2. Intermediate launcher exits without launching a new worker.
+  test_api.SimulateJobNotification(JOB_OBJECT_MSG_EXIT_PROCESS, kLauncherPid);
+  FlushIoThread();
+
+  // The stale worker PID must not be retained or processed.
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), base::kNullProcessId);
+}
+
+TEST_F(WtsSessionProcessDelegateTest,
+       LauncherExitWithActiveWorkerTriggersLaunchDetection) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
+      /*launch_elevated=*/true,
+      /*channel_security=*/std::string());
+
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+  FlushIoThread();
+
+  mojo::NamedPlatformChannel::Options options;
+  mojo::NamedPlatformChannel channel(options);
+  WtsSessionProcessDelegate::TestApi test_api(delegate.get());
+  test_api.SetElevatedServerEndpoint(channel.TakeServerEndpoint());
+
+  constexpr base::ProcessId kLauncherPid = 1000;
+  test_api.SetElevatedLauncherPid(kLauncherPid);
+
+  // Spawn a child process and assign it to the job object.
+  base::Process child = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child.IsValid());
+  ASSERT_TRUE(test_api.AssignProcessToJob(child.Handle()));
+  FlushIoThread();
+  EXPECT_EQ(test_api.GetWorkerProcessPid(), child.Pid());
+
+  bool process_launched = false;
+  base::RunLoop run_loop;
+  test_api.SetProcessLaunchedCallback(base::BindLambdaForTesting([&]() {
+    process_launched = true;
+    run_loop.Quit();
+  }));
+
+  // Launcher exits after spawning worker.
+  test_api.SimulateJobNotification(JOB_OBJECT_MSG_EXIT_PROCESS, kLauncherPid);
+
+  // Wait for OnProcessLaunchDetected to run on main thread.
+  run_loop.Run();
+  EXPECT_TRUE(process_launched);
+
+  // Clean up child process.
+  child.Terminate(0, false);
+  int exit_code = 0;
+  EXPECT_TRUE(
+      child.WaitForExitWithTimeout(TestTimeouts::action_timeout(), &exit_code));
+}
+
+TEST_F(WtsSessionProcessDelegateTest, ProcessNotInJobRejectedInLaunchDetected) {
+  auto target_command =
+      std::make_unique<base::CommandLine>(base::CommandLine::NO_PROGRAM);
+  auto delegate = std::make_unique<WtsSessionProcessDelegate>(
+      io_thread_.task_runner(), std::move(target_command),
+      /*launch_elevated=*/true,
+      /*channel_security=*/std::string());
+
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+  FlushIoThread();
+
+  mojo::NamedPlatformChannel::Options options;
+  mojo::NamedPlatformChannel channel(options);
+  WtsSessionProcessDelegate::TestApi test_api(delegate.get());
+  test_api.SetElevatedServerEndpoint(channel.TakeServerEndpoint());
+
+  // Spawn a child process that is NOT assigned to the job object.
+  base::Process unassigned_child = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(unassigned_child.IsValid());
+
+  bool fatal_error_reported = false;
+  base::RunLoop run_loop;
+  test_api.SetFatalErrorCallback(base::BindLambdaForTesting([&]() {
+    fatal_error_reported = true;
+    run_loop.Quit();
+  }));
+
+  // Trigger OnProcessLaunchDetected with the unassigned child's PID.
+  test_api.OnProcessLaunchDetected(unassigned_child.Pid());
+
+  // Wait for ReportFatalError to be called.
+  run_loop.Run();
+  EXPECT_TRUE(fatal_error_reported);
+
+  // Clean up child process.
+  unassigned_child.Terminate(0, false);
+  int exit_code = 0;
+  EXPECT_TRUE(unassigned_child.WaitForExitWithTimeout(
+      TestTimeouts::action_timeout(), &exit_code));
+}
+
 MULTIPROCESS_TEST_MAIN(WtsSessionProcessDelegateTestChild) {
   // Block until the parent terminates this process via the job object.
   ::Sleep(INFINITE);
Loading diff…

Original Bug Report

reported by aw...@chromium.org

Potential SYSTEM LPE in CRD via stale worker_process_pid_ and ipcz DuplicateHandle

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: In Chrome Remote Desktop on Windows, an abnormally terminating worker process leaves a stale process ID due to an unhandled job object message. If a subsequent relaunch fails, the SYSTEM daemon mistakenly attempts to establish IPC with the recycled PID, invoking DuplicateHandle on IPC shared memory. This pushes SYSTEM-level handles into a medium-integrity attacker’s process before named pipe DACL checks apply, enabling a potential Local Privilege Escalation.

Affected files:

  • remoting/host/win/wts_session_process_delegate.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

1. Summary of the Issue (Meant for Human Triage)

A potential Local Privilege Escalation (LPE) to LocalSystem exists in the Chrome Remote Desktop (CRD) host service on Windows. The vulnerability stems from a state inconsistency in how the WtsSessionProcessDelegate tracks the lifecycle of its elevated worker process, combined with a bypass of Mojo/Ipcz named pipe DACL protections.

The CRD daemon uses a Windows Job Object to monitor the elevated worker process. When a process terminates normally, Windows sends JOB_OBJECT_MSG_EXIT_PROCESS. When it crashes or terminates abnormally, it sends JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS. The daemon’s IO completion port handler (OnIOCompleted) handles the former but entirely omits a case for the latter. Consequently, if the worker crashes, its process ID (worker_process_pid_) remains cached and becomes stale.

An attacker operating as a standard user in the remoted WTS session can crash the worker and aggressively spawn processes to recycle this stale PID. When the CRD daemon automatically attempts to relaunch the worker via an intermediate launcher process, the attacker can force the launcher to fail (e.g., via desktop heap exhaustion). The launcher’s subsequent exit tricks the daemon into assuming the worker launched successfully. The daemon then calls OpenProcess on the attacker’s recycled PID and passes it to mojo::OutgoingInvitation::Send to establish IPC.

Critically, although the CRD Mojo named pipe is protected by a strict SYSTEM-only DACL, the Ipcz driver establishes the connection by generating an initial handshake message and synchronously serializing it before the named pipe fully connects. During this synchronous serialization, EncodeHandle is invoked, which calls the Windows DuplicateHandle API. This takes SYSTEM-owned handles (such as IPC shared memory regions) and directly injects them into the attacker’s process handle table. The attacker can then use these highly privileged handles to compromise the daemon, completely bypassing the named pipe’s DACL.

2. Proof-of-Concept & Detailed Execution Flow

Note: The following outlines the potential steps an attacker would follow to trigger the vulnerability. Our tooling agent does not have the ability to run live code to provide a compiled PoC, but this execution flow has been mechanically verified against the Chromium codebase.

Step 1: Trigger Abnormal Worker Exit

  • The attacker (with medium-integrity code execution in the remote WTS session) forces the legitimate, running elevated worker process (remoting_desktop.exe) to crash.
  • Windows sends JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS to WtsSessionProcessDelegate::Core’s job object completion port.

Step 2: Stale PID and Process Recycling

  • In remoting/host/win/wts_session_process_delegate.cc (OnIOCompleted), the switch block lacks a case for JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS.
  • worker_process_pid_ is not reset to base::kNullProcessId.
  • The attacker immediately spams short-lived process creations to capture the freed PID, obtaining control of the process backing the stale worker_process_pid_.

Step 3: Relaunch and Launcher Failure

  • WorkerProcessLauncher detects the worker exit and attempts a relaunch, creating a new suspended launcher process (LaunchProcessWithToken).
  • The launcher is assigned to the Job Object, emitting JOB_OBJECT_MSG_NEW_PROCESS.
  • The attacker induces resource exhaustion (e.g., desktop heap limits) to force the new launcher to fail before it spawns a new worker.
  • Because no worker is spawned, no JOB_OBJECT_MSG_NEW_PROCESS overrides the stale worker_process_pid_.

Step 4: Erroneous IPC Initialization

  • The failed launcher process exits gracefully, sending JOB_OBJECT_MSG_EXIT_PROCESS to the Job Object.
  • In OnIOCompleted, handling the launcher’s exit checks if (worker_process_pid_.load() == base::kNullProcessId). Because the PID is stale, the check fails.
  • The daemon erroneously posts OnProcessLaunchDetected(stale_pid) to the caller thread.

Step 5: Process Handle Duplication (The Sink)

  • OnProcessLaunchDetected invokes base::Process::OpenWithAccess(stale_pid, SYNCHRONIZE | PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION) on the attacker’s PID without verifying the process token or image name.
  • The base::ProcessHandle is passed to mojo::OutgoingInvitation::Send.
  • In mojo/core/ipcz_driver/invitation.cc (Invitation::Send), the target_process handle is unwrapped and locally cloned. It then calls GetIpczAPI().ConnectNode(), passing the transport driver object.
  • NodeConnector::ConnectNode calls connector->ActivateTransport(), initiating asynchronous named pipe connection. Immediately after, it synchronously calls connector->Connect(), creating the introductory IPC handshake message.
  • DriverTransport::Transmit is called synchronously on the calling thread to send the message, invoking Transport::SerializeObject (mojo/core/ipcz_driver/transport.cc).
  • For each platform handle (e.g., the NodeLinkMemory section), Transport::SerializeObject calls EncodeHandle(..., HandleOwner::kRecipient, ...).
  • EncodeHandle invokes ::DuplicateHandle(GetCurrentProcess(), source_handle, attacker_process_handle, ..., DUPLICATE_SAME_ACCESS).
  • Result: SYSTEM read/write handles to the daemon’s internal IPC memory are injected directly into the attacker’s process handle table, enabling full Local Privilege Escalation.

Suggested Fix:

  1. Handle Abnormal Exits: Add a case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS: to WtsSessionProcessDelegate::Core::OnIOCompleted that explicitly resets worker_process_pid_ = base::kNullProcessId;.
  2. Verify Process Identity: In OnProcessLaunchDetected, before calling mojo::OutgoingInvitation::Send, perform an explicit validation of the opened process handle using IsProcessInJob() (to ensure it belongs to the CRD job object) or query its token/image path to ensure it is the legitimate SYSTEM worker.

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

Prior Critic Verdict:

*   Severity: 1 (High / S1)
*   Brief Notes / Reasoning:
    The vulnerability describes a Local Privilege Escalation (LPE) to SYSTEM in Chrome Remote Desktop. While the previous Critic correctly identified a flaw in the report's proposed race condition, it incorrectly concluded the bug was unexploitable. The issue is a valid, High-severity (S1) LPE.
    1. Stale PID: As the report states, JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS is unhandled in WtsSessionProcessDelegate::Core::OnIOCompleted. Abnormal termination of the worker leaves worker_process_pid_ stale.
    2. Exploit Path: The report's race condition (dropping NEW_PROCESS because elevated_launcher_pid_ is unset) is indeed impossible in the current codebase, as it is set prior to AssignProcessToJobObject. However, an attacker can achieve the exact same state by forcing the launcher to fail to launch the worker (e.g., via standard desktop heap/resource exhaustion). If the launcher fails to spawn the worker, no NEW_PROCESS is generated to overwrite the stale worker_process_pid_. When the launcher subsequently exits, EXIT_PROCESS posts OnProcessLaunchDetected with the attacker-controlled stale PID.
    3. Refuting the IPC Catch-22: The prior Critic claimed that because the attacker cannot bypass the SYSTEM-only DACL to connect to the named pipe, IPC setup and DuplicateHandle never occur. This is false due to the design of the ipcz driver. OnProcessLaunchDetected passes the attacker's process handle to mojo::OutgoingInvitation::Send, which synchronously invokes ipcz_connect_node. To prepare the initial broker handshake message, DriverTransport::Transmit synchronously calls Transport::SerializeObject on the calling thread. During serialization, EncodeHandle is invoked with HandleOwner::kRecipient. This immediately executes ::DuplicateHandle(GetCurrentProcess(), handle, remote_process_.Handle(), ...) for all handles in the message (crucially, the ipcz NodeLinkMemory allocation).
    4. Conclusion: The handles are pushed directly into the attacker's handle table before the named pipe connection is even initiated on the IO thread. The pipe DACL is irrelevant. The attacker gains read/write handles to the SYSTEM daemon's IPC shared memory (with DUPLICATE_SAME_ACCESS), enabling trivial LPE. S1 is the correct severity.

Codebase Investigation Logs & Proof of Reachability:

  • Missing Job Object Notification: codebase_investigator checked remoting/host/win/wts_session_process_delegate.cc:329-381. JOB_OBJECT_MSG_EXIT_PROCESS is handled at line 357. JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS is entirely absent from the switch block. Windows API documentation explicitly defines these as mutually exclusive; thus, an abnormal exit leaves worker_process_pid_ stale.
  • Launcher Exit Triggers Launch Detection: codebase_investigator verified OnIOCompleted lines 357-379. If process_id == elevated_launcher_pid_.load() and worker_process_pid_.load() is not null (retained from the crash), caller_task_runner_->PostTask(FROM_HERE, base::BindOnce(&Core::OnProcessLaunchDetected, this, worker_process_pid_.load())); is executed.
  • No Security Checks in OpenProcess: codebase_investigator traced OnProcessLaunchDetected (lines 580-603). base::Process::OpenWithAccess(pid, SYNCHRONIZE | PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION) is called. There are no calls to GetTokenInformation, IsProcessInJob, or equivalent identity verification routines before sending the handle to Mojo.
  • Synchronous Transport Serialization Defeating DACL: codebase_investigator traced mojo/core/ipcz_driver/invitation.cc (lines 214-307). OutgoingInvitation::Send calls GetIpczAPI().ConnectNode(). third_party/ipcz/src/ipcz/node_connector.cc executes ActivateTransport() (which asynchronously begins named pipe connection), immediately followed by Connect(), which synchronously creates the introductory message and transmits it via DriverTransport::Transmit. This delegates to Transport::SerializeObject (mojo/core/ipcz_driver/transport.cc:429-515), invoking EncodeHandle. EncodeHandle calls ::DuplicateHandle unconditionally for HandleOwner::kRecipient on Windows. This mechanically verifies that handle duplication occurs before the asynchronous IO thread completes the named pipe connection, proving the DACL is bypassed.
  • Environmental Assumptions: Target is Windows OS. Chrome Remote Desktop daemon runs as LocalSystem. Attacker is a standard user in the remote session.

Evaluated with Chrome root at commit: 8c517fbcbb533e59ec9cedac868c8a9bdc30beb2


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.

View on issue tracker