Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Chromoting
DescriptionInappropriate implementation in Chromoting
ComponentChromoting
Bug ClassLogic Error
Tracker499116954
Fix commit98ca974c0178 (chromium/src) +112/-29
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
ScopedImpersonation
remoting/host/webauthn/remote_webauthn_extension_notifier.cc
modified
if
remoting/host/webauthn/remote_webauthn_extension_notifier.cc
modified
GetRemoteStateChangeContext
remoting/host/webauthn/remote_webauthn_extension_notifier.cc
modified
RemoteWebAuthnExtensionNotifier
remoting/host/webauthn/remote_webauthn_extension_notifier.cc
modified
for
remoting/host/webauthn/remote_webauthn_extension_notifier.cc
modified

Files Changed

  • remoting/host/webauthn/remote_webauthn_extension_notifier.cc
From 98ca974c017839632e9293dfa4a8f8332d9bbb8e Mon Sep 17 00:00:00 2001
From: Yuwei Huang <[email protected]>
Date: Thu, 09 Apr 2026 15:12:50 -0700
Subject: [PATCH] [crd host][win] Mitigate TOCTOU in WebAuthn extension notifier on Windows

This CL addresses a potential local privilege escalation vulnerability
in the Chrome Remote Desktop Windows host by implementing user
impersonation when writing WebAuthn extension wakeup files.

Previously, the remoting_desktop.exe process (running as SYSTEM) would
write a 1-byte file to the user's LocalAppData directory without
impersonation. This could allow an unprivileged local attacker to use
NTFS junctions and oplocks to redirect the write to an arbitrary file,
potentially leading to SYSTEM compromise.

Changes:
- Added a ScopedImpersonation helper on Windows to safely impersonate
  the interactive user during file operations.
- Updated RemoteWebAuthnExtensionNotifier to capture and pass the
  Windows user token to its background IO core.
- Modified Core::WakeUpExtension to impersonate the user before
  verifying directory existence and writing the wakeup files.
- Refactored GetRemoteStateChangePaths to return the user token on
  Windows.
- Updated unit tests to handle the new constructor requirements.

Verified: Do WebAuthn forwarding on Chrome within a CRD session.
Bug: 499116954
Change-Id: Ia4dcd4677c37955a448afb296e7c472be1719b96
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7740552
Reviewed-by: Joe Downing <[email protected]>
Commit-Queue: Yuwei Huang <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1612534}
---

diff --git a/remoting/host/webauthn/remote_webauthn_extension_notifier.cc b/remoting/host/webauthn/remote_webauthn_extension_notifier.cc
index ad0c713..29c529a5 100644
--- a/remoting/host/webauthn/remote_webauthn_extension_notifier.cc
+++ b/remoting/host/webauthn/remote_webauthn_extension_notifier.cc
@@ -48,6 +48,47 @@
 // Content of file doesn't matter so we just write an empty string.
 static constexpr char kExtensionWakeupFileContent[] = "";
 
+#if BUILDFLAG(IS_WIN)
+// Helper class to impersonate a user and revert back when it goes out of scope.
+// Note that Windows impersonation is bound to the current thread, so it is
+// thread-safe.
+class ScopedImpersonation {
+ public:
+  ScopedImpersonation(const ScopedImpersonation&) = delete;
+  ScopedImpersonation& operator=(const ScopedImpersonation&) = delete;
+
+  explicit ScopedImpersonation(HANDLE user_token) {
+    if (user_token != nullptr && user_token != INVALID_HANDLE_VALUE) {
+      if (ImpersonateLoggedOnUser(user_token)) {
+        is_impersonating_ = true;
+      } else {
+        PLOG(ERROR) << "ImpersonateLoggedOnUser failed";
+      }
+    }
+  }
+
+  ~ScopedImpersonation() {
+    if (is_impersonating_) {
+      RevertToSelf();
+    }
+  }
+
+  bool is_impersonating() const { return is_impersonating_; }
+
+ private:
+  bool is_impersonating_ = false;
+};
+#endif
+
+}  // namespace
+
+RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext::
+    RemoteStateChangeContext() = default;
+RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext::
+    RemoteStateChangeContext(RemoteStateChangeContext&&) = default;
+RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext::
+    ~RemoteStateChangeContext() = default;
+
 // Returns a list of directories that different Chrome channels might use to
 // watch for file changes for firing the onRemoteSessionStateChange event on the
 // extension.
@@ -71,13 +112,18 @@
 //
 // Caller should check if the directory exists before writing files to it. A
 // directory only exists if the corresponding Chrome version is installed.
-std::vector<base::FilePath> GetRemoteStateChangeDirPaths() {
+RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext
+RemoteWebAuthnExtensionNotifier::GetRemoteStateChangeContext() {
 #if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC)
   constexpr base::FilePath::CharType kStateChangeDirName[] =
       FILE_PATH_LITERAL("WebAuthenticationProxyRemoteSessionStateChange");
 #endif
 
-  std::vector<base::FilePath> dirs;
+  RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext context;
+
+#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC)
+  std::vector<base::FilePath>& dirs = context.dirs;
+#endif
 
 #if BUILDFLAG(IS_LINUX)
   // See: chrome/common/chrome_paths_linux.cc
@@ -111,15 +157,15 @@
   HANDLE user_token = nullptr;
   if (!WTSQueryUserToken(WTS_CURRENT_SESSION, &user_token)) {
     PLOG(ERROR) << "Failed to get current user token";
-    return dirs;
+    return context;
   }
-  base::win::ScopedHandle scoped_user_token(user_token);
+  context.user_token.Set(user_token);
   base::win::ScopedCoMem<wchar_t> local_app_data_path_buf;
   if (!SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, /* dwFlags= */ 0,
-                                      scoped_user_token.get(),
+                                      context.user_token.get(),
                                       &local_app_data_path_buf))) {
     PLOG(ERROR) << "SHGetKnownFolderPath failed";
-    return dirs;
+    return context;
   }
 
   base::FilePath base_path = base::FilePath(local_app_data_path_buf.get());
@@ -144,7 +190,7 @@
   base::FilePath base_path;
   if (!base::PathService::Get(base::DIR_APP_DATA, &base_path)) {
     LOG(ERROR) << "Failed to get app data dir";
-    return dirs;
+    return context;
   }
   base::FilePath base_path_google = base_path.Append("Google");
   dirs.push_back(base_path_google.Append("Chrome").Append(kStateChangeDirName));
@@ -156,16 +202,14 @@
 #else
   NOTIMPLEMENTED();
 #endif
-  return dirs;
+  return context;
 }
 
-}  // namespace
-
 // Core class for writing wakeup files on the IO sequence. Must be used and
 // deleted on the same sequence.
 class RemoteWebAuthnExtensionNotifier::Core final {
  public:
-  explicit Core(std::vector<base::FilePath> remote_state_change_dirs);
+  explicit Core(RemoteStateChangeContext context);
   ~Core();
 
   void WakeUpExtension();
@@ -173,13 +217,12 @@
  private:
   SEQUENCE_CHECKER(sequence_checker_);
 
-  std::vector<base::FilePath> remote_state_change_dirs_;
+  RemoteStateChangeContext context_;
   base::WeakPtrFactory<Core> weak_factory_{this};
 };
 
-RemoteWebAuthnExtensionNotifier::Core::Core(
-    std::vector<base::FilePath> remote_state_change_dirs)
-    : remote_state_change_dirs_(std::move(remote_state_change_dirs)) {}
+RemoteWebAuthnExtensionNotifier::Core::Core(RemoteStateChangeContext context)
+    : context_(std::move(context)) {}
 
 RemoteWebAuthnExtensionNotifier::Core::~Core() {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
@@ -188,7 +231,17 @@
 void RemoteWebAuthnExtensionNotifier::Core::WakeUpExtension() {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  for (const base::FilePath& dir : remote_state_change_dirs_) {
+#if BUILDFLAG(IS_WIN)
+  ScopedImpersonation impersonation(context_.user_token.get());
+  if (context_.user_token.is_valid() && !impersonation.is_impersonating()) {
+    PLOG(ERROR) << "Aborting file writes due to impersonation failure.";
+    return;
+  }
+#endif
+
+  for (const base::FilePath& dir : context_.dirs) {
+    // Note: We check DirectoryExists as the user. If they've swapped the
+    // directory for a junction to a protected area, the check will fail.
     if (!base::DirectoryExists(dir)) {
       VLOG(1) << "Ignored non-directory path: " << dir;
       continue;
@@ -196,8 +249,18 @@
     for (const auto& id : GetRemoteWebAuthnExtensionIds()) {
       auto file_path = dir.Append(id);
       VLOG(1) << "Writing extension wakeup file: " << file_path;
+      // We are impersonating the user, so any junction-based redirection will
+      // be restricted by the user's permissions.
       base::File file(file_path,
                       base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITE);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/remoting/host/webauthn/remote_webauthn_extension_notifier_unittest.cc b/remoting/host/webauthn/remote_webauthn_extension_notifier_unittest.cc
index 2e318c0..f4f680a 100644
--- a/remoting/host/webauthn/remote_webauthn_extension_notifier_unittest.cc
+++ b/remoting/host/webauthn/remote_webauthn_extension_notifier_unittest.cc
@@ -5,6 +5,7 @@
 #include "remoting/host/webauthn/remote_webauthn_extension_notifier.h"
 
 #include <memory>
+#include <vector>
 
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
@@ -17,6 +18,7 @@
 #include "base/task/thread_pool.h"
 #include "base/test/bind.h"
 #include "base/test/task_environment.h"
+#include "build/build_config.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace remoting {
@@ -71,12 +73,14 @@
   EXPECT_TRUE(scoped_temp_dir_2_.CreateUniqueTempDir());
   io_task_runner_ = base::ThreadPool::CreateSequencedTaskRunner(
       {base::MayBlock(), base::WithBaseSyncPrimitives()});
-  notifier_ = base::WrapUnique(new RemoteWebAuthnExtensionNotifier(
-      {
-          scoped_temp_dir_1_.GetPath(),
-          scoped_temp_dir_2_.GetPath(),
-      },
-      io_task_runner_));
+
+  RemoteWebAuthnExtensionNotifier::RemoteStateChangeContext context;
+  context.dirs = {
+      scoped_temp_dir_1_.GetPath(),
+      scoped_temp_dir_2_.GetPath(),
+  };
+  notifier_ = base::WrapUnique(
+      new RemoteWebAuthnExtensionNotifier(std::move(context), io_task_runner_));
 }
 
 RemoteWebAuthnExtensionNotifierTest::~RemoteWebAuthnExtensionNotifierTest() {
Loading diff…

Original Bug Report

reported by [email protected]

Potential LPE in CRD Windows host via TOCTOU junction following in WebAuthn notifier

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A potential local privilege escalation vulnerability exists in the Chrome Remote Desktop (CRD) Windows host due to unsafe file operations in the WebAuthn extension notifier. The SYSTEM-privileged remoting_desktop.exe process writes files to a user-controlled directory without impersonation, exposing a Time-of-Check to Time-of-Use (TOCTOU) race condition. An unprivileged local attacker could potentially exploit this using oplocks and NTFS junctions to arbitrarily create or truncate system files, leading to full SYSTEM compromise.

Affected files:

  • remoting/host/webauthn/remote_webauthn_extension_notifier.cc
  • remoting/host/desktop_session_agent.cc
  • remoting/host/desktop_session_proxy.cc
  • remoting/host/webauthn/remote_webauthn_message_handler.cc

Estimated timestamp from git blame: 2024-08-27

Summary

In Chrome Remote Desktop for Windows, the remoting_desktop.exe process runs as SYSTEM in the interactive user’s session. To signal WebAuthn extensions, RemoteWebAuthnExtensionNotifier::Core::WakeUpExtension() writes a 1-byte NUL file to specific paths inside the interactive user’s %LOCALAPPDATA% directory.

Because this operation runs as SYSTEM without impersonating the interactive user, and because of a Time-of-Check to Time-of-Use (TOCTOU) flaw in the directory verification, a local unprivileged user can potentially redirect this file write to an arbitrary location on the file system.

Technical Details

The vulnerability stems from the implementation of RemoteWebAuthnExtensionNotifier::Core::WakeUpExtension():

  1. Lack of Impersonation: The thread writes to a user-controlled directory (%LOCALAPPDATA%\...\WebAuthenticationProxyRemoteSessionStateChange) using the remoting_desktop.exe process’s SYSTEM token. No impersonation (e.g., ImpersonateLoggedOnUser) occurs.
  2. TOCTOU Race Condition: The code calls base::DirectoryExists(dir) to verify the target directory outside of a loop that iterates over multiple extension IDs.
  3. Unsafe File Creation: Inside the loop, it uses base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITE without specifying flags to prevent traversing reparse points.

Because there are multiple extension IDs written sequentially, an attacker can use a standard Windows “Bait-and-Switch” oplock attack to pause the execution between the directory check and the second file write, swapping the directory for an NTFS junction.

Potential Exploitation Steps

Note: These are suggested steps; a working proof of concept has not yet been executed in our tooling environment.

  1. Setup Bait: As an unprivileged interactive user, create the target directory: %LOCALAPPDATA%\Google\Chrome\User Data\WebAuthenticationProxyRemoteSessionStateChange.
  2. Set Oplock: Create a file named after the first WebAuthn extension ID (e.g., djjmngfglakhkhmgcfdmjalogilepkhd) and place an Exclusive Oplock on it using FSCTL_REQUEST_OPLOCK.
  3. Trigger: Initiate a CRD connection negotiating the kRemoteWebAuthnCapability. This prompts the SYSTEM process to write the extension files.
  4. Pause Execution: The SYSTEM process calls base::DirectoryExists(), which succeeds. It then attempts to open the first extension file. This triggers the oplock, pausing the SYSTEM thread.
  5. The Switch: While the thread is paused, the attacker renames the WebAuthenticationProxyRemoteSessionStateChange directory.
  6. Create Junction: The attacker creates an NTFS junction at the original WebAuthenticationProxyRemoteSessionStateChange path, pointing it to the Windows Object Manager namespace \RPC Control.
  7. Create Symlink: Inside \RPC Control, the attacker creates an Object Manager symbolic link named after the second extension ID (e.g., inomeogfingihgjfjlpeplalcfajhgai), pointing to a protected target file like \??\C:\Windows\System32\target_file.dll.
  8. Release Oplock: The attacker releases the oplock. The SYSTEM thread resumes, finishes the first file, and moves to the second.
  9. Arbitrary Write: The SYSTEM thread attempts to write the second file. The kernel redirects the path through the junction and symlink. Because the thread runs as SYSTEM and uses FLAG_CREATE_ALWAYS, it bypasses ACLs, truncating the target DLL to 0 bytes and writing a single NUL byte. This primitive is a known vector for full SYSTEM Local Privilege Escalation.

Suggested Fix

  1. Use Impersonation: The most robust fix is to impersonate the interactive user before performing any file operations in the %LOCALAPPDATA% directory. This ensures the SYSTEM process cannot write anywhere the unprivileged user cannot already write.
  2. Avoid Junction Traversal: If impersonation is not feasible, use base::File::FLAG_WIN_OPEN_REPARSE_POINT (or equivalent) to ensure CreateFile does not traverse unexpected junctions or symlinks when operating on user-controlled directories.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker