Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in CredentialProvider
DescriptionRace in CredentialProvider
ComponentCredentialProvider
Bug ClassRace
Tracker511731805
Fix commit049218ea45ac (chromium/src) +259/-31
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-06

Changed Functions

FunctionChangeNotes
if
chrome/credential_provider/gaiacp/gcp_utils.cc
modified

Files Changed

  • chrome/credential_provider/gaiacp/gcp_utils.cc
From 049218ea45ac2ac8c41499b4b2729992077fabfc Mon Sep 17 00:00:00 2001
From: Greg Thompson <grt@chromium.org>
Date: Thu, 30 Jul 2026 23:28:20 -0700
Subject: [PATCH] [GCPW] Restrict GCPW's data directory to SYSTEM + Local Admin

GCPW uses the directory "C:\ProgramData\Google\Credential Provider" as
a convenient place to store its startup sentinel and other data. Apply
a DACL to this directory to restrict access to only local SYSTEM and
built-in administrators.

Fixed: 511731805
Change-Id: I5623da7fc5f2dc031849633a4c1e5b3cf46711c1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162382
Commit-Queue: Greg Thompson <grt@chromium.org>
Reviewed-by: Yusuf Sengul <yusufsn@google.com>
Cr-Commit-Position: refs/heads/main@{#1671646}
---

diff --git a/chrome/credential_provider/gaiacp/gcp_utils.cc b/chrome/credential_provider/gaiacp/gcp_utils.cc
index 11385717..fb32ae8 100644
--- a/chrome/credential_provider/gaiacp/gcp_utils.cc
+++ b/chrome/credential_provider/gaiacp/gcp_utils.cc
@@ -45,6 +45,8 @@
 #include "base/win/current_module.h"
 #include "base/win/embedded_i18n/language_selector.h"
 #include "base/win/ntsecapi_shim.h"
+#include "base/win/security_descriptor.h"
+#include "base/win/sid.h"
 #include "base/win/wbemidl_shim.h"
 #include "base/win/win_util.h"
 #include "base/win/wincred_shim.h"
@@ -146,16 +148,11 @@
 };
 
 base::FilePath GetStartupSentinelLocation(const std::wstring& version) {
-  base::FilePath sentinel_path;
-  if (!base::PathService::Get(base::DIR_COMMON_APP_DATA, &sentinel_path)) {
-    HRESULT hr = HRESULT_FROM_WIN32(::GetLastError());
-    LOGFN(ERROR) << "PathService::Get(DIR_COMMON_APP_DATA) hr=" << putHR(hr);
+  base::FilePath sentinel_path = GetDataDirectory();
+  if (sentinel_path.empty()) {
     return base::FilePath();
   }
 
-  sentinel_path = sentinel_path.Append(GetInstallParentDirectoryName())
-                      .Append(kCredentialProviderFolder);
-
   return sentinel_path.Append(version).AppendASCII(kSentinelFilename);
 }
 
@@ -270,17 +267,11 @@
 // and |file_dir|.
 base::FilePath GetDirectoryFilePath(const std::wstring& sid,
                                     const std::wstring& file_dir) {
-  base::FilePath path;
-  if (!base::PathService::Get(base::DIR_COMMON_APP_DATA, &path)) {
-    HRESULT hr = HRESULT_FROM_WIN32(::GetLastError());
-    LOGFN(ERROR) << "PathService::Get(DIR_COMMON_APP_DATA) hr=" << putHR(hr);
+  base::FilePath path = GetDataDirectory();
+  if (path.empty()) {
     return base::FilePath();
   }
-  path = path.Append(GetInstallParentDirectoryName())
-             .Append(kCredentialProviderFolder)
-             .Append(file_dir)
-             .Append(sid);
-  return path;
+  return path.Append(file_dir).Append(sid);
 }
 
 }  // namespace
@@ -921,6 +912,101 @@
          kHoursToDisableGCPW;
 }
 
+bool SecureCreateDirectory(const base::FilePath& path) {
+  if (path.empty()) {
+    return false;
+  }
+
+  // Create the parent directory, allowing ordinary inherited permissions.
+  base::File::Error error = base::File::FILE_OK;
+  if (base::FilePath parent = path.DirName();
+      !parent.empty() && parent != path &&
+      !base::CreateDirectoryAndGetError(parent, &error)) {
+    LOGFN(ERROR) << "Failed to create parent directory for " << path << "; "
+                 << base::File::ErrorToString(error);
+    return false;
+  }
+
+  base::win::SecurityDescriptor sd;
+  sd.set_dacl_protected(true);  // Protects from inheriting parent DACLs
+
+  // Add DACL entries strictly for SYSTEM and BuiltinAdministrators with full
+  // control.
+  if (!sd.SetDaclEntry(base::win::WellKnownSid::kLocalSystem,
+                       base::win::SecurityAccessMode::kGrant,
+                       GENERIC_ALL | STANDARD_RIGHTS_ALL,
+                       SUB_CONTAINERS_AND_OBJECTS_INHERIT) ||
+      !sd.SetDaclEntry(base::win::WellKnownSid::kBuiltinAdministrators,
+                       base::win::SecurityAccessMode::kGrant,
+                       GENERIC_ALL | STANDARD_RIGHTS_ALL,
+                       SUB_CONTAINERS_AND_OBJECTS_INHERIT)) {
+    LOGFN(ERROR) << "Failed to set DACL entries for " << path;
+    return false;
+  }
+
+  // Attempt to create the directory with the security descriptor attached.
+  auto self_relative = sd.ToSelfRelative();
+  if (!self_relative) {
+    LOGFN(ERROR) << "Failed to convert SecurityDescriptor to self-relative for "
+                 << path;
+    return false;
+  }
+  SECURITY_ATTRIBUTES sa{sizeof(SECURITY_ATTRIBUTES), self_relative->get(),
+                         FALSE};
+
+  if (::CreateDirectory(path.value().c_str(), &sa)) {
+    return true;
+  }
+
+  // If CreateDirectory failed (e.g., because the directory already exists),
+  // open a handle to the directory to set the DACL.
+  base::File handle(::CreateFile(
+      path.value().c_str(), WRITE_DAC | READ_CONTROL,
+      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
+      OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
+      nullptr));
+  if (!handle.IsValid()) {
+    HRESULT hr = HRESULT_FROM_WIN32(::GetLastError());
+    LOGFN(ERROR) << "Failed to open handle for existing path " << path
+                 << " hr=" << putHR(hr);
+    return false;
+  }
+
+  BY_HANDLE_FILE_INFORMATION file_info = {};
+  if (!::GetFileInformationByHandle(handle.GetPlatformFile(), &file_info)) {
+    HRESULT hr = HRESULT_FROM_WIN32(::GetLastError());
+    LOGFN(ERROR) << "GetFileInformationByHandle failed for " << path
+                 << " hr=" << putHR(hr);
+    return false;
+  }
+
+  // Verify that a directory (not a file) was just opened.
+  if ((file_info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
+    LOGFN(ERROR) << "Path exists but is not a directory: " << path;
+    return false;
+  }
+
+  // Verify that the directory does not have a reparse point (e.g., it's not a
+  // symlink or a junction point).
+  if ((file_info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+    LOGFN(ERROR) << "Path exists but is a reparse point (junction/symlink): "
+                 << path;
+    return false;
+  }
+
+  // Apply the DACL to the directory.
+  if (!sd.WriteToHandle(handle.GetPlatformFile(),
+                        base::win::SecurityObjectType::kFile,
+                        DACL_SECURITY_INFORMATION)) {
+    HRESULT hr = HRESULT_FROM_WIN32(::GetLastError());
+    LOGFN(ERROR) << "Failed to write SecurityDescriptor to handle for " << path
+                 << " hr=" << putHR(hr);
+    return false;
+  }
+
+  return true;
+}
+
 bool WriteToStartupSentinel() {
   LOGFN(VERBOSE);
   // Always try to write to the startup sentinel file. If writing or opening
@@ -939,14 +1025,11 @@
       GetStartupSentinelLocation(TEXT(CHROME_VERSION_STRING));
   if (!startup_sentinel_path.empty()) {
     base::FilePath startup_sentinel_directory = startup_sentinel_path.DirName();
-    if (!base::DirectoryExists(startup_sentinel_directory)) {
-      base::File::Error error;
-      if (!base::CreateDirectoryAndGetError(startup_sentinel_directory,
-                                            &error)) {
-        LOGFN(ERROR) << "Could not create sentinel directory='"
-                     << startup_sentinel_directory << "' error=" << error;
-        return false;
-      }
+    base::File::Error error;
+    if (!base::CreateDirectoryAndGetError(startup_sentinel_directory, &error)) {
+      LOGFN(ERROR) << "Could not create sentinel directory='"
+                   << startup_sentinel_directory << "' error=" << error;
+      return false;
     }
     base::File startup_sentinel(
         startup_sentinel_path,
@@ -1114,6 +1197,24 @@
   return S_OK;
 }
 
+base::FilePath GetDataDirectory() {
+  base::FilePath path;
+  if (!base::PathService::Get(base::DIR_COMMON_APP_DATA, &path)) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/credential_provider/gaiacp/gcp_utils_unittest.cc b/chrome/credential_provider/gaiacp/gcp_utils_unittest.cc
index fb018ddd..64269494 100644
--- a/chrome/credential_provider/gaiacp/gcp_utils_unittest.cc
+++ b/chrome/credential_provider/gaiacp/gcp_utils_unittest.cc
@@ -4,6 +4,10 @@
 
 #include "chrome/credential_provider/gaiacp/gcp_utils.h"
 
+#include <windows.h>
+
+#include <shlobj.h>
+
 #include <algorithm>
 #include <array>
 #include <string_view>
@@ -11,22 +15,37 @@
 #include "base/command_line.h"
 #include "base/compiler_specific.h"
 #include "base/containers/span.h"
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
 #include "base/process/launch.h"
 #include "base/strings/strcat.h"
 #include "base/strings/strcat_win.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/test/gmock_expected_support.h"
+#include "base/test/test_file_util.h"
 #include "base/test/test_reg_util_win.h"
 #include "base/values.h"
+#include "base/win/access_token.h"
 #include "base/win/scoped_handle.h"
+#include "base/win/security_descriptor.h"
+#include "base/win/security_util.h"
+#include "base/win/sid.h"
 #include "build/build_config.h"
 #include "chrome/credential_provider/common/gcp_strings.h"
 #include "chrome/credential_provider/gaiacp/mdm_utils.h"
 #include "chrome/credential_provider/gaiacp/reg_utils.h"
 #include "chrome/credential_provider/test/gcp_fakes.h"
+#include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace credential_provider {
 
+using ::testing::AllOf;
+using ::testing::HasSubstr;
+using ::testing::Not;
+using ::testing::Optional;
+using ::testing::StartsWith;
+
 TEST(GcpPasswordTest, GenerateRandomPassword) {
   wchar_t password[64];
 
@@ -615,4 +634,86 @@
                        ::testing::Values("true"),
                        ::testing::Values("device_resource_id", "")));
 
+class GcpUtilsSecureCreateDirectoryTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    if (!::IsUserAnAdmin()) {
+      GTEST_SKIP() << "Test requires administrative privileges.";
+    }
+    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
+  }
+
+  void TearDown() override {
+    if (temp_dir_.IsValid() && ::IsUserAnAdmin()) {
+      if (auto token = base::win::AccessToken::FromCurrentProcess(); token) {
+        std::vector<base::win::Sid> sids;
+        sids.push_back(token->User().Clone());
+        base::win::GrantAccessToPath(
+            temp_dir_.GetPath(), sids, GENERIC_ALL | STANDARD_RIGHTS_ALL,
+            CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE);
+      }
+    }
+  }
+
+  base::ScopedTempDir temp_dir_;
+};
+
+TEST_F(GcpUtilsSecureCreateDirectoryTest, NewDirectory) {
+  base::FilePath new_dir = temp_dir_.GetPath().Append(L"NewSecureDir");
+  EXPECT_FALSE(base::DirectoryExists(new_dir));
+  EXPECT_TRUE(SecureCreateDirectory(new_dir));
+  EXPECT_TRUE(base::DirectoryExists(new_dir));
+  EXPECT_THAT(base::GetFileDacl(new_dir),
+              AllOf(StartsWith(L"D:P"), HasSubstr(L";;;BA)"),
+                    HasSubstr(L";;;SY)"), Not(HasSubstr(L";;;WD)"))));
+}
+
+TEST_F(GcpUtilsSecureCreateDirectoryTest, ExistingDirectory) {
+  base::FilePath existing_dir = temp_dir_.GetPath().Append(L"ExistingDir");
+  EXPECT_TRUE(base::CreateDirectory(existing_dir));
+  EXPECT_TRUE(base::DirectoryExists(existing_dir));
+  EXPECT_TRUE(SecureCreateDirectory(existing_dir));
+  EXPECT_THAT(base::GetFileDacl(existing_dir),
+              AllOf(StartsWith(L"D:P"), HasSubstr(L";;;BA)"),
+                    HasSubstr(L";;;SY)"), Not(HasSubstr(L";;;WD)"))));
+}
+
+TEST_F(GcpUtilsSecureCreateDirectoryTest,
+       IdempotentOnExistingSecuredDirectory) {
+  base::FilePath target_dir = temp_dir_.GetPath().Append(L"SecuredDir");
+  EXPECT_TRUE(SecureCreateDirectory(target_dir));
+  EXPECT_THAT(base::GetFileDacl(target_dir),
+              AllOf(StartsWith(L"D:P"), HasSubstr(L";;;BA)"),
+                    HasSubstr(L";;;SY)"), Not(HasSubstr(L";;;WD)"))));
+
+  // Apply SecureCreateDirectory a second time to an already secured directory.
+  EXPECT_TRUE(SecureCreateDirectory(target_dir));
+  EXPECT_THAT(base::GetFileDacl(target_dir),
+              AllOf(StartsWith(L"D:P"), HasSubstr(L";;;BA)"),
+                    HasSubstr(L";;;SY)"), Not(HasSubstr(L";;;WD)"))));
+}
+
+TEST_F(GcpUtilsSecureCreateDirectoryTest, FailOnExistingFileConflict) {
+  base::FilePath file_path = temp_dir_.GetPath().Append(L"ConflictingFile");
+  ASSERT_TRUE(base::WriteFile(file_path, "dummy data"));
+  EXPECT_TRUE(base::PathExists(file_path));
+  EXPECT_FALSE(base::DirectoryExists(file_path));
+
+  // Should fail because file_path exists as a file, not a directory.
+  EXPECT_FALSE(SecureCreateDirectory(file_path));
+}
+
+TEST_F(GcpUtilsSecureCreateDirectoryTest, FailOnReparsePoint) {
+  base::FilePath target_dir = temp_dir_.GetPath().Append(L"TargetDir");
+  base::FilePath link_dir = temp_dir_.GetPath().Append(L"LinkDir");
+  ASSERT_TRUE(base::CreateDirectory(target_dir));
+  if (::CreateSymbolicLinkW(link_dir.value().c_str(),
+                            target_dir.value().c_str(),
+                            SYMBOLIC_LINK_FLAG_DIRECTORY)) {
+    // If symlink/reparse point creation succeeded, SecureCreateDirectory must
+    // reject it.
+    EXPECT_FALSE(SecureCreateDirectory(link_dir));
+  }
+}
+
 }  // namespace credential_provider
diff --git a/chrome/credential_provider/test/gcp_setup_unittest.cc b/chrome/credential_provider/test/gcp_setup_unittest.cc
index 69a3d834..bba356f 100644
--- a/chrome/credential_provider/test/gcp_setup_unittest.cc
+++ b/chrome/credential_provider/test/gcp_setup_unittest.cc
@@ -6,6 +6,7 @@
 
 #include <datetimeapi.h>
 #include <lmerr.h>
+#include <shlobj.h>
 #include <wrl/client.h>
 
 #include <memory>
@@ -354,6 +355,10 @@
   programdata_override_ = std::make_unique<base::ScopedPathOverride>(
       base::DIR_COMMON_APP_DATA, scoped_temp_progdata_dir_.GetPath());
 
+  if (!::IsUserAnAdmin()) {
+    GTEST_SKIP() << "Test requires administrative privileges.";
+  }
+
   ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
 
   // In non-component builds, base::FILE_MODULE will always return the path
diff --git a/chrome/credential_provider/test/gls_runner_test_base.cc b/chrome/credential_provider/test/gls_runner_test_base.cc
index 72277f4d..98a2352 100644
--- a/chrome/credential_provider/test/gls_runner_test_base.cc
+++ b/chrome/credential_provider/test/gls_runner_test_base.cc
@@ -4,6 +4,8 @@
 
 #include "gls_runner_test_base.h"
 
+#include <shlobj.h>
+
 #include <memory>
 
 #include "base/base_switches.h"
@@ -167,6 +169,10 @@
   ASSERT_TRUE(scoped_temp_progdata_dir_.CreateUniqueTempDir());
   programdata_override_ = std::make_unique<base::ScopedPathOverride>(
       base::DIR_COMMON_APP_DATA, scoped_temp_progdata_dir_.GetPath());
+
+  if (!::IsUserAnAdmin()) {
+    GTEST_SKIP() << "Test requires administrative privileges.";
+  }
 }
 
 void GlsRunnerTestBase::TearDown() {
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential LPE to SYSTEM via TOCTOU in GCPW Startup Sentinel

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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Google Credential Provider for Windows (GCPW) creates its configuration directory with default permissions, allowing unprivileged users to create subdirectories. A Time-of-Check to Time-of-Use (TOCTOU) vulnerability in the startup sentinel logic can be exploited by an attacker using a junction point to redirect a SYSTEM-level file open to an attacker-controlled named pipe. This allows the attacker to steal the SYSTEM token via named pipe impersonation.

Affected files:

  • chrome/credential_provider/gaiacp/gcp_utils.cc
  • chrome/credential_provider/gaiacp/dllmain.cc

Estimated timestamp from git blame: 2018-12-19

Summary

A potential Local Privilege Escalation (EoP) vulnerability exists in the Google Credential Provider for Windows (GCPW). A local, unprivileged attacker can potentially gain SYSTEM privileges by exploiting insecure directory permissions combined with a Time-of-Check to Time-of-Use (TOCTOU) race condition in the startup sentinel logic.

Technical Details

GCPW manages a sentinel file located at C:\ProgramData\Google\Credential Provider\<version>\gcpw_startup.sentinel to track crashes. This file is accessed by WriteToStartupSentinel() in chrome/credential_provider/gaiacp/gcp_utils.cc.

The vulnerability relies on two issues:

  1. Insecure Directory Permissions: The Credential Provider directory inherits default permissions from C:\ProgramData, allowing BUILTIN\Users to create new subdirectories.
  2. TOCTOU Race Condition: WriteToStartupSentinel() is called by DllGetClassObject running within LogonUI.exe as SYSTEM. It first checks for the directory’s existence using base::DirectoryExists(), and then opens the sentinel file using base::File. Because base::File on Windows does not specify FILE_FLAG_OPEN_REPARSE_POINT or SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS by default, an attacker who swaps the directory for a junction point between the check and the use can redirect the file access to a malicious named pipe with SecurityImpersonation privileges.

Potential Exploit Scenario

Note: These are proposed steps based on code analysis; a working proof of concept has not been executed.

  1. An attacker pre-creates the directory C:\ProgramData\Google\Credential Provider\<version>\ as an NTFS junction pointing to a local SMB share (e.g., \\localhost\C$\attacker_dir). The SMB protocol introduces network latency, significantly widening the race window.
  2. The attacker creates an Object Manager symbolic link at \RPC Control\gcpw_startup.sentinel pointing to an attacker-controlled named pipe (e.g., \??\pipe\exploit).
  3. The attacker runs a high-priority thread that rapidly swaps the target of the <version> junction between the benign SMB path and the malicious \RPC Control directory.
  4. The attacker locks the workstation, triggering LogonUI.exe (SYSTEM) to load GCPW and call WriteToStartupSentinel().
  5. Time of Check: If the junction points to the benign SMB path, base::DirectoryExists() succeeds.
  6. Time of Use: The racer thread swaps the junction to \RPC Control. base::File attempts to open the sentinel file with OPEN_ALWAYS and FILE_APPEND_DATA. The kernel follows the junction to \RPC Control\gcpw_startup.sentinel, and then follows the symlink to the attacker’s named pipe.
  7. LogonUI.exe connects to the named pipe as SYSTEM and writes a “0” byte to it.
  8. The attacker’s pipe server receives the connection and calls ImpersonateNamedPipeClient(), assuming the SYSTEM token for Local Privilege Escalation.

Suggested Fix

  1. Harden Directory Permissions: The GCPW installer should explicitly apply a restrictive DACL to C:\ProgramData\Google\Credential Provider, preventing unprivileged users from creating subdirectories.
  2. Prevent Impersonation: Consider configuring base::File or the specific file operations in GCPW to use the SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS flags (or SECURITY_IDENTIFICATION) when opening files in potentially unsafe locations to prevent named pipe impersonation attacks.
  3. Safe Path Traversal: Ensure that file operations in privileged contexts do not inadvertently follow attacker-controlled reparse points.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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