CVE-2026-7994
Overview
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
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.