CVE-2026-87554
Overview
Background
- Chromoting / Chrome Remote Desktop host (Windows)
- The
remotinghost 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 asJOB_OBJECT_MSG_EXIT_PROCESSwhile a crash/unhandled exception arrives asJOB_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.
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
- 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_PROCESSrather thanJOB_OBJECT_MSG_EXIT_PROCESStoOnIOCompleted(). - Stale PID retained
Pre-patch
OnIOCompleted()has no branch for the abnormal-exit message, soworker_process_pid_still holds the dead worker’s PID instead of being reset tobase::kNullProcessId. - 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.
- 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 toOnProcessLaunchDetected(). - Privileged IPC sent to the wrong process
OnProcessLaunchDetected()opens the recycled PID and sends the MojoOutgoingInvitationto 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
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
| Function | Change | Notes |
|---|---|---|
ifremoting/host/win/wts_session_process_delegate.cc |
modified | |
SingleThreadTaskRunnerremoting/host/win/wts_session_process_delegate.h |
modified | |
PlatformChannelServerEndpointremoting/host/win/wts_session_process_delegate.h |
modified | |
TestApiremoting/host/win/wts_session_process_delegate.h |
modified | |
TEST_Fremoting/host/win/wts_session_process_delegate_unittest.cc |
modified |
Files Changed
remoting/host/win/wts_session_process_delegate.ccremoting/host/win/wts_session_process_delegate.hremoting/host/win/wts_session_process_delegate_unittest.cc
Audit Directions
- Incomplete notification-message coverageAudit 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 diesLook 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 checksReview 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.
Patch
From df9740a82a96fa627ee039fabcec20d823aabf95 Mon Sep 17 00:00:00 2001 From: Joe Downing <[email protected]> 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 <[email protected]> Commit-Queue: Joe Downing <[email protected]> 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)
Regression Test / PoC
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);
Original Bug Report
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_PROCESStoWtsSessionProcessDelegate::Core’s job object completion port.
Step 2: Stale PID and Process Recycling
- In
remoting/host/win/wts_session_process_delegate.cc(OnIOCompleted), theswitchblock lacks a case forJOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS. worker_process_pid_is not reset tobase::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
WorkerProcessLauncherdetects 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_PROCESSoverrides the staleworker_process_pid_.
Step 4: Erroneous IPC Initialization
- The failed launcher process exits gracefully, sending
JOB_OBJECT_MSG_EXIT_PROCESSto the Job Object. - In
OnIOCompleted, handling the launcher’s exit checksif (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)
OnProcessLaunchDetectedinvokesbase::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::ProcessHandleis passed tomojo::OutgoingInvitation::Send. - In
mojo/core/ipcz_driver/invitation.cc(Invitation::Send), thetarget_processhandle is unwrapped and locally cloned. It then callsGetIpczAPI().ConnectNode(), passing the transport driver object. NodeConnector::ConnectNodecallsconnector->ActivateTransport(), initiating asynchronous named pipe connection. Immediately after, it synchronously callsconnector->Connect(), creating the introductory IPC handshake message.DriverTransport::Transmitis called synchronously on the calling thread to send the message, invokingTransport::SerializeObject(mojo/core/ipcz_driver/transport.cc).- For each platform handle (e.g., the
NodeLinkMemorysection),Transport::SerializeObjectcallsEncodeHandle(..., HandleOwner::kRecipient, ...). EncodeHandleinvokes::DuplicateHandle(GetCurrentProcess(), source_handle, attacker_process_handle, ..., DUPLICATE_SAME_ACCESS).- Result:
SYSTEMread/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:
- Handle Abnormal Exits: Add a
case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:toWtsSessionProcessDelegate::Core::OnIOCompletedthat explicitly resetsworker_process_pid_ = base::kNullProcessId;. - Verify Process Identity: In
OnProcessLaunchDetected, before callingmojo::OutgoingInvitation::Send, perform an explicit validation of the opened process handle usingIsProcessInJob()(to ensure it belongs to the CRD job object) or query its token/image path to ensure it is the legitimateSYSTEMworker.
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_investigatorcheckedremoting/host/win/wts_session_process_delegate.cc:329-381.JOB_OBJECT_MSG_EXIT_PROCESSis handled at line 357.JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESSis entirely absent from theswitchblock. Windows API documentation explicitly defines these as mutually exclusive; thus, an abnormal exit leavesworker_process_pid_stale. - Launcher Exit Triggers Launch Detection:
codebase_investigatorverifiedOnIOCompletedlines 357-379. Ifprocess_id == elevated_launcher_pid_.load()andworker_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_investigatortracedOnProcessLaunchDetected(lines 580-603).base::Process::OpenWithAccess(pid, SYNCHRONIZE | PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION)is called. There are no calls toGetTokenInformation,IsProcessInJob, or equivalent identity verification routines before sending the handle to Mojo. - Synchronous Transport Serialization Defeating DACL:
codebase_investigatortracedmojo/core/ipcz_driver/invitation.cc(lines 214-307).OutgoingInvitation::SendcallsGetIpczAPI().ConnectNode().third_party/ipcz/src/ipcz/node_connector.ccexecutesActivateTransport()(which asynchronously begins named pipe connection), immediately followed byConnect(), which synchronously creates the introductory message and transmits it viaDriverTransport::Transmit. This delegates toTransport::SerializeObject(mojo/core/ipcz_driver/transport.cc:429-515), invokingEncodeHandle.EncodeHandlecalls::DuplicateHandleunconditionally forHandleOwner::kRecipienton 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.