Medium watch CVSS 7.8 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
7.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Chromoting
DescriptionInappropriate implementation in Chromoting
ComponentChromoting
Sub-componentCrd host
Bug ClassLogic Error
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.
Tracker499116954
Fix commit98ca974c0178 (chromium/src) +112/-29
CWECWE-269
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-03 (per vendor advisory)
Fix landed2026-04-09 (public git)
Disclosed2026-05-05
Patch gap26 days — fix public in source before the advisory

Background

Chrome Remote Desktop (CRD) host
on Windows the remoting_desktop.exe process runs as SYSTEM and, for WebAuthn forwarding, writes small “wakeup” files into the interactive user’s LocalAppData directory.
TOCTOU via NTFS junctions + oplocks
an unprivileged local attacker can race a privileged file operation by swapping a directory for a reparse-point (junction) and stalling the process with an opportunistic lock, redirecting a write to an attacker-chosen path.
User impersonation (`ImpersonateLoggedOnUser` / `RevertToSelf`)
Windows lets a thread temporarily assume another user’s security token so that subsequent file operations are access-checked as that (less-privileged) user; the new ScopedImpersonation RAII helper wraps this, restoring SYSTEM on scope exit.
`RemoteStateChangeContext`
the new struct that carries both the target dirs and (on Windows) the captured user_token, replacing the plain std::vector<base::FilePath> returned previously.

Root Cause Analysis

The CRD Windows host’s Core::WakeUpExtension iterated the computed state-change directories and wrote a 1-byte wakeup file into each while running as SYSTEM, performing the base::DirectoryExists check and the base::File FLAG_CREATE_ALWAYS write with full SYSTEM privileges. Because those directories live under the user-writable LocalAppData, a local unprivileged attacker could replace a directory with an NTFS junction and use an oplock to win the time-of-check/time-of-use race, causing the SYSTEM process to create/overwrite an arbitrary protected file — the classic path to local privilege escalation described in the commit message.

The fix captures the interactive user’s token (WTSQueryUserToken) into RemoteStateChangeContext::user_token and, in WakeUpExtension, constructs a ScopedImpersonation from that token before any directory check or file write; if impersonation was requested but failed, it aborts with a return rather than writing as SYSTEM. Under impersonation, both the DirectoryExists check and the file creation are access-checked as the ordinary user, so a junction redirecting into a protected location is denied by the user’s own permissions. This restores the invariant that these user-directory writes carry only the user’s authority, not SYSTEM’s.

Key insight
The core mistake was performing SYSTEM-privileged directory checks and file writes into a user-controlled directory without impersonating the user, enabling a junction/oplock TOCTOU redirection; the fix wraps the operations in ScopedImpersonation (and aborts on impersonation failure) so they run with the user’s reduced privileges.

Attack Path

  1. Observe the SYSTEM writer A local unprivileged attacker knows the CRD host (remoting_desktop.exe, running as SYSTEM) writes WebAuthn wakeup files into directories under the user’s LocalAppData during a session.
  2. Plant a junction + oplock The attacker replaces one of the target directories with an NTFS junction pointing at a protected location and arms an opportunistic lock to stall the host between the existence check and the write.
  3. Win the TOCTOU race The SYSTEM process’s unimpersonated DirectoryExists/base::File create-always then follows the junction, writing/overwriting an attacker-chosen protected file as SYSTEM.
  4. Escalate to SYSTEM Per the commit message this “could … potentially lead to SYSTEM compromise,” i.e. local privilege escalation; the concrete end-to-end escalation is stated by the author’s description rather than demonstrated by the diff, so treat full compromise as (inferred).

Impact Assessment

A local, unprivileged attacker on a Windows machine running a CRD host session could redirect the SYSTEM process’s wakeup-file writes to arbitrary files via a junction/oplock TOCTOU, which the commit describes as potential local privilege escalation to SYSTEM; the advisory classifies it as Inappropriate implementation in Chromoting at medium severity. Preconditions are local access and an active CRD session performing WebAuthn forwarding. The advisory does not report in-the-wild exploitation, and the terminal SYSTEM compromise is (inferred) from the author’s description rather than proven by the patch.

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

Audit Directions

  • SYSTEM writes into user-writable paths
    audit privileged host/service code that creates or opens files under user-controlled directories (LocalAppData, %APPDATA%, temp) without impersonating the user, a prime junction/oplock TOCTOU target.
  • Check-then-use file operations
    flag DirectoryExists/PathExists followed by FLAG_CREATE_ALWAYS or similar writes where both steps must occur under the same (impersonated) security context.
  • Impersonation failure handling
    verify every impersonation site aborts (rather than silently continuing as the privileged account) when ImpersonateLoggedOnUser fails, as this patch’s return-on-failure does.
From 98ca974c017839632e9293dfa4a8f8332d9bbb8e Mon Sep 17 00:00:00 2001
From: Yuwei Huang <yuweih@chromium.org>
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 <joedow@chromium.org>
Commit-Queue: Yuwei Huang <yuweih@chromium.org>
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 vm...@google.com

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