CVE-2026-7994
Overview
Background
- Chrome Remote Desktop (CRD) host
- on Windows the
remoting_desktop.exeprocess runs asSYSTEMand, for WebAuthn forwarding, writes small “wakeup” files into the interactive user’sLocalAppDatadirectory. - 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
ScopedImpersonationRAII helper wraps this, restoringSYSTEMon scope exit. - `RemoteStateChangeContext`
- the new struct that carries both the target
dirsand (on Windows) the captureduser_token, replacing the plainstd::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.
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
- Observe the SYSTEM writer
A local unprivileged attacker knows the CRD host (
remoting_desktop.exe, running asSYSTEM) writes WebAuthn wakeup files into directories under the user’sLocalAppDataduring a session. - 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.
- Win the TOCTOU race
The
SYSTEMprocess’s unimpersonatedDirectoryExists/base::Filecreate-always then follows the junction, writing/overwriting an attacker-chosen protected file asSYSTEM. - 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
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
| Function | Change | Notes |
|---|---|---|
ScopedImpersonationremoting/host/webauthn/remote_webauthn_extension_notifier.cc |
modified | |
ifremoting/host/webauthn/remote_webauthn_extension_notifier.cc |
modified | |
GetRemoteStateChangeContextremoting/host/webauthn/remote_webauthn_extension_notifier.cc |
modified | |
RemoteWebAuthnExtensionNotifierremoting/host/webauthn/remote_webauthn_extension_notifier.cc |
modified | |
forremoting/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 pathsaudit 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 operationsflag
DirectoryExists/PathExistsfollowed byFLAG_CREATE_ALWAYSor similar writes where both steps must occur under the same (impersonated) security context. - Impersonation failure handlingverify every impersonation site aborts (rather than silently continuing as the privileged account) when
ImpersonateLoggedOnUserfails, as this patch’sreturn-on-failure does.
Patch
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);
Regression Test / PoC
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() {
Original Bug Report
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.ccremoting/host/desktop_session_agent.ccremoting/host/desktop_session_proxy.ccremoting/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():
- Lack of Impersonation: The thread writes to a user-controlled directory (
%LOCALAPPDATA%\...\WebAuthenticationProxyRemoteSessionStateChange) using theremoting_desktop.exeprocess’sSYSTEMtoken. No impersonation (e.g.,ImpersonateLoggedOnUser) occurs. - TOCTOU Race Condition: The code calls
base::DirectoryExists(dir)to verify the target directory outside of a loop that iterates over multiple extension IDs. - Unsafe File Creation: Inside the loop, it uses
base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITEwithout 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.
- Setup Bait: As an unprivileged interactive user, create the target directory:
%LOCALAPPDATA%\Google\Chrome\User Data\WebAuthenticationProxyRemoteSessionStateChange. - Set Oplock: Create a file named after the first WebAuthn extension ID (e.g.,
djjmngfglakhkhmgcfdmjalogilepkhd) and place an Exclusive Oplock on it usingFSCTL_REQUEST_OPLOCK. - Trigger: Initiate a CRD connection negotiating the
kRemoteWebAuthnCapability. This prompts theSYSTEMprocess to write the extension files. - Pause Execution: The
SYSTEMprocess callsbase::DirectoryExists(), which succeeds. It then attempts to open the first extension file. This triggers the oplock, pausing theSYSTEMthread. - The Switch: While the thread is paused, the attacker renames the
WebAuthenticationProxyRemoteSessionStateChangedirectory. - Create Junction: The attacker creates an NTFS junction at the original
WebAuthenticationProxyRemoteSessionStateChangepath, pointing it to the Windows Object Manager namespace\RPC Control. - 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. - Release Oplock: The attacker releases the oplock. The
SYSTEMthread resumes, finishes the first file, and moves to the second. - Arbitrary Write: The
SYSTEMthread attempts to write the second file. The kernel redirects the path through the junction and symlink. Because the thread runs asSYSTEMand usesFLAG_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 fullSYSTEMLocal Privilege Escalation.
Suggested Fix
- Use Impersonation: The most robust fix is to impersonate the interactive user before performing any file operations in the
%LOCALAPPDATA%directory. This ensures theSYSTEMprocess cannot write anywhere the unprivileged user cannot already write. - Avoid Junction Traversal: If impersonation is not feasible, use
base::File::FLAG_WIN_OPEN_REPARSE_POINT(or equivalent) to ensureCreateFiledoes 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.