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

Overview

High
Severity
8.3
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.
Tracker515473074
Fix commitb600e4a96327 (chromium/src) +248/-4
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-21 (per vendor advisory)
Fix landed2026-07-24 (public git)
Disclosed2026-08-25
Patch gap32 days — fix public in source before the advisory

Background

`WtsSessionProcessDelegate::Core`
a ref-counted implementation object that launches and monitors an elevated worker process in a Windows session, using fire-and-forget asynchronous shutdown.
Job object completion port
Core registers itself as the I/O completion-port handler for its Windows job object, so the kernel holds a raw pointer and posts notifications such as JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO.
`TerminateJobObject`
a Win32 call that requests termination of all processes in a job but does not block until they actually exit.
`self_`
the scoped_refptr that keeps Core alive across the asynchronous shutdown round-trip to the I/O thread.

Root Cause Analysis

Stop() closed the job handle and released the final self_ reference after a single task round-trip to the I/O thread. But TerminateJobObject() does not wait for the worker process to exit, so the kernel could still post JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO to the completion port after Core had already been destroyed, and the completion-port handler (OnIOCompleted) would then run on freed memory — a use-after-free, since the kernel holds a raw pointer to Core. The invariant that Core outlives every job-object notification was violated.

The fix records job_process_assigned_ when a process is assigned to the job and makes Stop() return early (deferring the handle close and self_ release) while a process is still assigned; teardown instead happens from OnActiveProcessZero(), which runs on the caller thread only after the I/O thread has dequeued the terminal notification and which now confirms via QueryInformationJobObject that no active processes remain. Once the terminal notification is delivered the job is empty, closing the handle destroys the kernel job object, and the existing drain round-trip safely releases the last reference.

Key insight
The mistake was releasing Core on a fixed round-trip after TerminateJobObject() without waiting for the job to actually empty, letting a late completion-port notification reach a freed object; the fix defers teardown until OnActiveProcessZero() confirms the job has no remaining active processes.

Attack Path

  1. Worker assigned to job Core launches an elevated worker process and assigns it to its job object, arming completion-port notifications.
  2. Stop terminates without waiting Stop() calls TerminateJobObject(), which returns before the worker has actually exited, then (pre-patch) closes the handle and schedules release of self_.
  3. Core freed early the single I/O round-trip releases the last reference and destroys Core while the worker is still winding down.
  4. Stale notification hits freed object the kernel later posts JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO to the completion port, invoking OnIOCompleted on the freed Core (use-after-free), matching the regression test’s “premature release” comment.

Impact Assessment

The advisory classifies this as a use-after-free in Chromoting; it occurs in the Chrome Remote Desktop host process on Windows during worker-process shutdown. Triggering it requires a race between TerminateJobObject() and worker-process exit, and leveraging the freed Core for further memory corruption or code execution is (inferred). The precondition is that the vulnerable session-process shutdown path runs while a worker is still exiting.

Changed Functions

FunctionChangeNotes
if
remoting/host/win/wts_session_process_delegate.cc
modified
Core
remoting/host/win/wts_session_process_delegate.h
modified
WtsSessionProcessDelegateTest
remoting/host/win/wts_session_process_delegate_unittest.cc
modified
io_thread_
remoting/host/win/wts_session_process_delegate_unittest.cc
modified
TEST_F
remoting/host/win/wts_session_process_delegate_unittest.cc
modified
MULTIPROCESS_TEST_MAIN
remoting/host/win/wts_session_process_delegate_unittest.cc
modified

Files Changed

  • remoting/host/win/BUILD.gn
  • 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

  • Kernel/OS raw-pointer callbacks
    audit objects registered as completion-port/APC/callback handlers where the OS holds a raw pointer; ensure the object outlives every possible asynchronous notification, not just a fixed number of round-trips.
  • Terminate-without-wait teardown
    review shutdown paths that call TerminateJobObject/TerminateProcess and then free state, since termination is asynchronous and later exit notifications can still arrive.
  • Ref-count release timing
    check async fire-and-forget objects that drop their self-reference on a timer or fixed round-trip rather than on a definitive “all work drained” signal.
From b600e4a963272a207b2fa9c6de23a4d35bd59378 Mon Sep 17 00:00:00 2001
From: Yuwei Huang <yuweih@chromium.org>
Date: Fri, 24 Jul 2026 10:23:39 -0700
Subject: [PATCH] CRD: Keep WtsSessionProcessDelegate::Core alive until job empty

WtsSessionProcessDelegate::Core registers itself as the completion-port
handler for its job object, so the kernel holds a raw pointer to it.
Stop() previously closed the job handle and released |self_| after a
single task round-trip to the I/O thread, but TerminateJobObject() does
not wait for the worker process to exit, so
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO can be posted to the completion port
after Core has been destroyed.

Track whether a process has been assigned to the job and defer closing
the job handle and releasing |self_| until OnActiveProcessZero() runs on
the caller thread, which only happens after the I/O thread has dequeued
the terminal job notification. Once that notification has been delivered
the job contains no processes, closing the handle destroys the kernel
job object, and the existing drain round-trip can safely release the
final reference.

Move wts_session_process_delegate.cc into the :win source set so that
remoting_unittests can link it, drop the unused host_main.h include, and
add testing hooks plus a unit test that exercises the job-object
shutdown path with a real child process.

Bug: 515473074
Change-Id: I521d5738648f0d0241b6e3042f3182ff3e625d59
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8142304
Commit-Queue: Yuwei Huang <yuweih@chromium.org>
Reviewed-by: Joe Downing <joedow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667967}
---
diff --git a/remoting/host/win/wts_session_process_delegate.cc b/remoting/host/win/wts_session_process_delegate.cc
index e8f0204a..e54b8ab 100644
--- a/remoting/host/win/wts_session_process_delegate.cc
+++ b/remoting/host/win/wts_session_process_delegate.cc
@@ -15,6 +15,7 @@
 #include "base/compiler_specific.h"
 #include "base/files/file_path.h"
 #include "base/functional/bind.h"
+#include "base/functional/callback.h"
 #include "base/logging.h"
 #include "base/memory/raw_ptr.h"
 #include "base/memory/scoped_refptr.h"
@@ -37,7 +38,6 @@
 #include "mojo/public/cpp/platform/platform_handle.h"
 #include "mojo/public/cpp/system/invitation.h"
 #include "remoting/host/base/switches.h"
-#include "remoting/host/host_main.h"
 #include "remoting/host/ipc_constants.h"
 #include "remoting/host/mojom/desktop_session.mojom.h"
 #include "remoting/host/win/launch_process_with_token.h"
@@ -46,13 +46,13 @@
 #include "remoting/host/worker_process_ipc_delegate.h"
 #include "remoting/host/worker_process_launcher.h"
 
-    using base::win::ScopedHandle;
-
 // Name of the default session desktop.
 const char kDefaultDesktopName[] = "winsta0\\default";
 
 namespace remoting {
 
+using base::win::ScopedHandle;
+
 // A private class actually implementing the functionality provided by
 // |WtsSessionProcessDelegate|. This class is ref-counted and implements
 // asynchronous fire-and-forget shutdown.
@@ -90,6 +90,9 @@
   void CrashProcess(const base::Location& location);
   void KillProcess();
 
+  bool AssignProcessToJobForTesting(base::ProcessHandle process);
+  void SetCoreDeletedCallbackForTesting(base::OnceClosure callback);
+
  private:
   friend class base::RefCountedThreadSafe<Core>;
   ~Core() override;
@@ -188,6 +191,13 @@
 
   // True if Stop() has been called.
   bool stopped_ = false;
+
+  // True if a process has been assigned to the job object and the resulting
+  // JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification has not yet been delivered
+  // by the I/O thread.
+  bool job_process_assigned_ = false;
+
+  base::OnceClosure deleted_callback_for_testing_;
 };
 
 WtsSessionProcessDelegate::Core::Core(
@@ -320,10 +330,28 @@
   worker_process_.Close();
 }
 
+bool WtsSessionProcessDelegate::Core::AssignProcessToJobForTesting(
+    base::ProcessHandle process) {
+  DCHECK(caller_task_runner_->BelongsToCurrentThread());
+  if (!job_.is_valid() || !::AssignProcessToJobObject(job_.Get(), process)) {
+    return false;
+  }
+  job_process_assigned_ = true;
+  return true;
+}
+
+void WtsSessionProcessDelegate::Core::SetCoreDeletedCallbackForTesting(
+    base::OnceClosure callback) {
+  deleted_callback_for_testing_ = std::move(callback);
+}
+
 WtsSessionProcessDelegate::Core::~Core() {
   DCHECK(!channel_);
   DCHECK(!event_handler_);
   DCHECK(!worker_process_.is_valid());
+  if (deleted_callback_for_testing_) {
+    std::move(deleted_callback_for_testing_).Run();
+  }
 }
 
 void WtsSessionProcessDelegate::Core::OnIOCompleted(
@@ -477,6 +505,7 @@
       ReportFatalError();
       return;
     }
+    job_process_assigned_ = true;
   }
 
   if (!ResumeThread(worker_thread.Get())) {
@@ -514,6 +543,15 @@
   DCHECK(caller_task_runner_->BelongsToCurrentThread());
 
   if (job_.is_valid()) {
+    // Closing the last handle to the job destroys the kernel job object only
+    // once every process in the job has terminated. TerminateJobObject() does
+    // not wait for the processes to exit, so defer closing the handle until
+    // OnActiveProcessZero() has run so that no further notifications will be
+    // posted to the completion port after the handle is closed.
+    if (job_process_assigned_) {
+      return;
+    }
+
     job_.Close();
 
     // Drain the completion queue to make sure all job object notifications have
@@ -571,6 +609,26 @@
 void WtsSessionProcessDelegate::Core::OnActiveProcessZero() {
   DCHECK(caller_task_runner_->BelongsToCurrentThread());
 
+  if (job_.is_valid()) {
+    JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info;
+    if (QueryInformationJobObject(job_.Get(),
+                                  JobObjectBasicAccountingInformation, &info,
+                                  sizeof(info), nullptr)) {
+      if (info.ActiveProcesses > 0) {
+        return;
+      }
+    }
+  }
+
+  job_process_assigned_ = false;
+
+  if (stopped_) {
+    // The I/O thread has now processed all pending job object notifications,
+    // so it is safe to close the job handle and release |self_|.
+    DrainJobNotificationsCompleted();
+    return;
+  }
+
   if (launch_pending_) {
     LOG(ERROR) << "The worker process exited before connecting via IPC.";
     launch_pending_ = false;
@@ -680,4 +738,14 @@
   core_->KillProcess();
 }
 
+bool WtsSessionProcessDelegate::AssignProcessToJobForTesting(
+    base::ProcessHandle process) {
+  return core_->AssignProcessToJobForTesting(process);  // IN-TEST
+}
+
+void WtsSessionProcessDelegate::SetCoreDeletedCallbackForTesting(
+    base::OnceClosure callback) {
+  core_->SetCoreDeletedCallbackForTesting(std::move(callback));  // IN-TEST
+}
+
 }  // namespace remoting
diff --git a/remoting/host/win/wts_session_process_delegate.h b/remoting/host/win/wts_session_process_delegate.h
index e2fbcd9..e613eaaf 100644
--- a/remoting/host/win/wts_session_process_delegate.h
+++ b/remoting/host/win/wts_session_process_delegate.h
@@ -10,8 +10,10 @@
 #include <memory>
 
 #include "base/compiler_specific.h"
+#include "base/functional/callback_forward.h"
 #include "base/memory/scoped_refptr.h"
 #include "base/memory/weak_ptr.h"
+#include "base/process/process_handle.h"
 #include "base/sequence_checker.h"
 #include "remoting/host/win/windows_process_delegate.h"
 
@@ -49,6 +51,15 @@
   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);
+
+  // Registers |callback| to be run when the internal Core object is destroyed.
+  void SetCoreDeletedCallbackForTesting(base::OnceClosure callback);
+
  private:
   // The actual implementation resides in WtsSessionProcessDelegate::Core class.
   class Core;
diff --git a/remoting/host/win/wts_session_process_delegate_unittest.cc b/remoting/host/win/wts_session_process_delegate_unittest.cc
new file mode 100644
index 0000000..484ef86
--- /dev/null
+++ b/remoting/host/win/wts_session_process_delegate_unittest.cc
@@ -0,0 +1,164 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "remoting/host/win/wts_session_process_delegate.h"
+
+#include <windows.h>
+
+#include <memory>
+#include <tuple>
+
+#include "base/command_line.h"
+#include "base/functional/callback_helpers.h"
+#include "base/message_loop/message_pump_type.h"
+#include "base/process/process.h"
+#include "base/run_loop.h"
+#include "base/task/single_thread_task_runner.h"
+#include "base/test/bind.h"
+#include "base/test/multiprocess_test.h"
+#include "base/test/task_environment.h"
+#include "base/test/test_timeouts.h"
+#include "base/threading/thread.h"
+#include "base/win/scoped_handle.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "testing/multiprocess_func_list.h"
+
+namespace remoting {
+
+class WtsSessionProcessDelegateTest : public testing::Test {
+ public:
+  WtsSessionProcessDelegateTest()
+      : task_environment_(base::test::TaskEnvironment::MainThreadType::UI),
+        io_thread_("WtsSessionProcessDelegateTest IO") {}
+
+  void SetUp() override {
+    base::Thread::Options options;
+    options.message_pump_type = base::MessagePumpType::IO;
+    ASSERT_TRUE(io_thread_.StartWithOptions(std::move(options)));
+  }
+
+  void TearDown() override { io_thread_.Stop(); }
+
+ protected:
+  void FlushIoThread() {
+    base::RunLoop run_loop;
+    io_thread_.task_runner()->PostTaskAndReply(FROM_HERE, base::DoNothing(),
+                                               run_loop.QuitClosure());
+    run_loop.Run();
+  }
+
+  base::test::TaskEnvironment task_environment_;
+  base::Thread io_thread_;
+};
+
+// Verifies that the delegate's internal Core stays alive while a worker process
+// is still assigned to the job object, and is only released once the I/O thread
+// has reported that the job has no remaining active processes.
+TEST_F(WtsSessionProcessDelegateTest, CoreOutlivesJobNotifications) {
+  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());
+
+  // Initialize() creates the job object and registers it with the I/O thread's
+  // completion port, but is expected to fail to create a session token when the
+  // test is not running with the required privileges. The job-object plumbing
+  // is set up regardless of the return value, which is what this test relies
+  // on, so the result is intentionally ignored.
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+
+  // Allow the asynchronous job-object initialization to complete.
+  FlushIoThread();
+
+  // Launch a child process and assign it to the delegate's job object so that
+  // the job will post completion-port notifications when the child exits.
+  base::Process child = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child.Handle()));
+
+  bool core_deleted = false;
+  base::RunLoop run_loop;
+  delegate->SetCoreDeletedCallbackForTesting(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
+  // notifications.
+  delegate.reset();
+  EXPECT_FALSE(core_deleted);
+
+  // Wait for the I/O thread to deliver all job-object notifications and for the
+  // Core to be released. Any premature release of Core would result in
+  // OnIOCompleted() being invoked on a freed object.
+  run_loop.Run();
+  EXPECT_TRUE(core_deleted);
+
+  // The job object terminates the child as part of Stop().
+  int exit_code = 0;
+  EXPECT_TRUE(
+      child.WaitForExitWithTimeout(TestTimeouts::action_timeout(), &exit_code));
+}
+
+TEST_F(WtsSessionProcessDelegateTest,
+       StaleJobNotificationIgnoredIfNewProcessAssigned) {
+  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();
+
+  base::Process child1 = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child1.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child1.Handle()));
+
+  base::Process child2 = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child2.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child2.Handle()));
+
+  // Terminate child1 so a JOB_OBJECT_MSG_EXIT_PROCESS / ACTIVE_PROCESS_ZERO is
+  // generated, but child2 remains active in the job object.
+  child1.Terminate(0, false);
+  FlushIoThread();
+
+  bool core_deleted = false;
+  base::RunLoop run_loop;
+  delegate->SetCoreDeletedCallbackForTesting(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.
+  delegate.reset();
+  EXPECT_FALSE(core_deleted);
+
+  run_loop.Run();
+  EXPECT_TRUE(core_deleted);
+
+  int exit_code = 0;
+  EXPECT_TRUE(child2.WaitForExitWithTimeout(TestTimeouts::action_timeout(),
+                                            &exit_code));
+}
+
+MULTIPROCESS_TEST_MAIN(WtsSessionProcessDelegateTestChild) {
+  // Block until the parent terminates this process via the job object.
+  ::Sleep(INFINITE);
+  return 0;
+}
+
+}  // namespace remoting
diff --git a/remoting/host/win/BUILD.gn b/remoting/host/win/BUILD.gn
index c5aca2b..78dd098 100644
--- a/remoting/host/win/BUILD.gn
+++ b/remoting/host/win/BUILD.gn
@@ -179,6 +179,7 @@
     "windows_process_delegate.h",
     "wts_session_change_observer.cc",
     "wts_session_change_observer.h",
+    "wts_session_process_delegate.cc",
     "wts_session_process_delegate.h",
     "wts_terminal_monitor.cc",
... (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
new file mode 100644
index 0000000..484ef86
--- /dev/null
+++ b/remoting/host/win/wts_session_process_delegate_unittest.cc
@@ -0,0 +1,164 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "remoting/host/win/wts_session_process_delegate.h"
+
+#include <windows.h>
+
+#include <memory>
+#include <tuple>
+
+#include "base/command_line.h"
+#include "base/functional/callback_helpers.h"
+#include "base/message_loop/message_pump_type.h"
+#include "base/process/process.h"
+#include "base/run_loop.h"
+#include "base/task/single_thread_task_runner.h"
+#include "base/test/bind.h"
+#include "base/test/multiprocess_test.h"
+#include "base/test/task_environment.h"
+#include "base/test/test_timeouts.h"
+#include "base/threading/thread.h"
+#include "base/win/scoped_handle.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "testing/multiprocess_func_list.h"
+
+namespace remoting {
+
+class WtsSessionProcessDelegateTest : public testing::Test {
+ public:
+  WtsSessionProcessDelegateTest()
+      : task_environment_(base::test::TaskEnvironment::MainThreadType::UI),
+        io_thread_("WtsSessionProcessDelegateTest IO") {}
+
+  void SetUp() override {
+    base::Thread::Options options;
+    options.message_pump_type = base::MessagePumpType::IO;
+    ASSERT_TRUE(io_thread_.StartWithOptions(std::move(options)));
+  }
+
+  void TearDown() override { io_thread_.Stop(); }
+
+ protected:
+  void FlushIoThread() {
+    base::RunLoop run_loop;
+    io_thread_.task_runner()->PostTaskAndReply(FROM_HERE, base::DoNothing(),
+                                               run_loop.QuitClosure());
+    run_loop.Run();
+  }
+
+  base::test::TaskEnvironment task_environment_;
+  base::Thread io_thread_;
+};
+
+// Verifies that the delegate's internal Core stays alive while a worker process
+// is still assigned to the job object, and is only released once the I/O thread
+// has reported that the job has no remaining active processes.
+TEST_F(WtsSessionProcessDelegateTest, CoreOutlivesJobNotifications) {
+  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());
+
+  // Initialize() creates the job object and registers it with the I/O thread's
+  // completion port, but is expected to fail to create a session token when the
+  // test is not running with the required privileges. The job-object plumbing
+  // is set up regardless of the return value, which is what this test relies
+  // on, so the result is intentionally ignored.
+  std::ignore = delegate->Initialize(WTSGetActiveConsoleSessionId());
+
+  // Allow the asynchronous job-object initialization to complete.
+  FlushIoThread();
+
+  // Launch a child process and assign it to the delegate's job object so that
+  // the job will post completion-port notifications when the child exits.
+  base::Process child = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child.Handle()));
+
+  bool core_deleted = false;
+  base::RunLoop run_loop;
+  delegate->SetCoreDeletedCallbackForTesting(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
+  // notifications.
+  delegate.reset();
+  EXPECT_FALSE(core_deleted);
+
+  // Wait for the I/O thread to deliver all job-object notifications and for the
+  // Core to be released. Any premature release of Core would result in
+  // OnIOCompleted() being invoked on a freed object.
+  run_loop.Run();
+  EXPECT_TRUE(core_deleted);
+
+  // The job object terminates the child as part of Stop().
+  int exit_code = 0;
+  EXPECT_TRUE(
+      child.WaitForExitWithTimeout(TestTimeouts::action_timeout(), &exit_code));
+}
+
+TEST_F(WtsSessionProcessDelegateTest,
+       StaleJobNotificationIgnoredIfNewProcessAssigned) {
+  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();
+
+  base::Process child1 = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child1.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child1.Handle()));
+
+  base::Process child2 = base::SpawnMultiProcessTestChild(
+      "WtsSessionProcessDelegateTestChild",
+      base::GetMultiProcessTestChildBaseCommandLine(), {});
+  ASSERT_TRUE(child2.IsValid());
+  ASSERT_TRUE(delegate->AssignProcessToJobForTesting(child2.Handle()));
+
+  // Terminate child1 so a JOB_OBJECT_MSG_EXIT_PROCESS / ACTIVE_PROCESS_ZERO is
+  // generated, but child2 remains active in the job object.
+  child1.Terminate(0, false);
+  FlushIoThread();
+
+  bool core_deleted = false;
+  base::RunLoop run_loop;
+  delegate->SetCoreDeletedCallbackForTesting(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.
+  delegate.reset();
+  EXPECT_FALSE(core_deleted);
+
+  run_loop.Run();
+  EXPECT_TRUE(core_deleted);
+
+  int exit_code = 0;
+  EXPECT_TRUE(child2.WaitForExitWithTimeout(TestTimeouts::action_timeout(),
+                                            &exit_code));
+}
+
+MULTIPROCESS_TEST_MAIN(WtsSessionProcessDelegateTestChild) {
+  // Block until the parent terminates this process via the job object.
+  ::Sleep(INFINITE);
+  return 0;
+}
+
+}  // namespace remoting
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential Use-After-Free in Windows Remoting Host due to IOCP draining race

Flapjack, 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: A potential Use-After-Free vulnerability exists in the Windows Remoting Host’s WtsSessionProcessDelegate::Core class. The teardown sequence for Windows Job Objects relies on a brief task-posting loop that races against asynchronous OS process termination. This race allows late I/O completion notifications to invoke virtual methods on a freed object, completely bypassing MiraclePtr protections.

Affected files:

  • remoting/host/win/wts_session_process_delegate.cc

Estimated timestamp from git blame: 2012-10-10

Summary

A potential Use-After-Free (UAF) vulnerability exists in WtsSessionProcessDelegate::Core (remoting/host/win/wts_session_process_delegate.cc). The class fails to correctly synchronize its destruction with the asynchronous termination of a Windows Job Object. If a worker process takes longer to terminate than a single task round-trip to the IO thread, the Core object is freed prematurely. When the delayed process termination notification arrives from the Windows kernel, it dereferences a dangling pointer, leading to a UAF.

Because the dangling pointer is retrieved as a raw ULONG_PTR completion key directly from the Windows kernel, PartitionAlloc’s MiraclePtr (BackupRefPtr) is entirely bypassed.

Root Cause Analysis

  1. IOCP Registration: During setup, Core::InitializeJob() registers the Core object as an IOHandler for the Job Object. It passes this to the OS kernel as the CompletionKey.
  2. Teardown Initiation: When the session ends, Core::Stop() calls KillProcess(), which invokes the Windows API TerminateJobObject(). Crucially, this API is asynchronous; it signals termination but does not block until processes are fully destroyed.
  3. Flawed Draining: Immediately after, Stop() initiates a drain sequence. DrainJobNotificationsCompleted() closes the job handle and posts a task (DrainJobNotifications()) to the IO thread. The IO thread simply posts back to the caller thread.
  4. Premature Destruction: After this single, rapid task round-trip, the caller thread assumes the IOCP queue is empty. It clears its internal reference (self_ = nullptr;), causing the Core object to be deleted.
  5. The Race: Because process teardown is significantly slower than a local task round-trip, the worker process is often still terminating in the background when the Core object is destroyed.
  6. UAF Trigger: Once the worker process finally finishes terminating, the Windows kernel queues the JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification to the IOCP. The Host’s IO thread dequeues it (GetQueuedCompletionStatus), casts the raw ULONG_PTR back to an IOHandler*, and calls the virtual method OnIOCompleted(). Since the object was freed, this is a UAF.

Attack Scenario / Trigger Steps

Note: These are theoretical steps, as our tooling agent does not yet have the ability to run code or build a live proof-of-concept.

An attacker who has already compromised the sandboxed worker process could potentially escalate privileges to the SYSTEM-level Remoting Host process using the following sequence:

  1. The Remoting Host begins to shut down the session, invoking KillProcess().
  2. The attacker’s worker process intentionally delays its own termination (e.g., by creating complex handle dependencies or maintaining high system load) to widen the race condition window.
  3. The Remoting Host completes its fast “drain” round-trip and frees the Core object.
  4. The attacker sprays the Remoting Host’s heap (e.g., by sending crafted IPC messages before full termination) to reallocate the freed Core memory, placing a malicious payload with a fake vtable at the object’s address.
  5. The worker process finally terminates. The OS sends the delayed JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification.
  6. The Remoting Host’s IO thread casts the raw completion key to the attacker-controlled memory and invokes OnIOCompleted(), hijacking execution flow.

Suggested Fix

The Core object’s lifetime must be strictly tied to the receipt of the terminal job object notification, rather than relying on an arbitrary task round-trip.

Do not drop the self_ reference in DrainJobNotificationsCompleted() simply because a task round-trip completed. Instead, the self_ reference should only be cleared inside OnIOCompleted() upon receiving the JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO notification (or if RegisterJobObject explicitly failed during initialization). This ensures the object remains alive in memory as long as the OS kernel holds the raw pointer.

Evaluated with Chrome root at commit: a9e0d24757518a6f49f01bf5068f2cadfc6092f4


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