CVE-2026-87530
Overview
Background
- GCPW (Google Credential Provider for Windows)
- the
credential_providercomponent whosegcp_setup.exeinstaller/uninstaller runs elevated to manage a Windows logon credential provider. - `RelaunchUninstaller()`
- the function in
setup_lib.ccthat copies the installer into a fresh temporary directory and relaunches it from there, using that directory as both the child’s application directory and working directory. - Uncontrolled search path element
- a DLL/binary search path that includes a directory a lower-privileged actor can write to, allowing a planted module to be loaded by an elevated process (DLL planting / hijacking).
- `base::DIR_SYSTEM_TEMP` vs. per-user `%TEMP%`
- the machine-wide system temp directory (writable only by administrators) versus the per-user temp directory, which for a split-token elevated admin resolves to the user-writable
%TEMP%.
Root Cause Analysis
Before the patch, RelaunchUninstaller() in setup_lib.cc obtained its staging directory via base::CreateNewTempDirectory(), which, as the added comment states, resolves to the per-user %TEMP% for an elevated admin with a split token. Because the function copies gcp_setup.exe into that directory and relaunches it with the directory as both the application and working directory, any directory writable by the non-elevated user became part of the elevated child’s binary/DLL search path — the “uncontrolled search path element” described by the advisory.
The fix retrieves base::DIR_SYSTEM_TEMP via base::PathService::Get() and creates the staging directory there with base::CreateTemporaryDirInDir(), relocating the staged copy into an administrator-only location. As defense in depth, the patch also adds EnableSecureDllLoading() (calling SetDefaultDllDirectories() with LOAD_LIBRARY_SEARCH_SYSTEM32 in non-component builds) invoked from the setup entry point, and adds /DEPENDENTLOADFLAG:0x800 to gcp_setup’s link flags to enforce secure dependent-DLL loading at the PE-header level. Component builds now fail early with E_FAIL because copying only gcp_setup.exe into an isolated directory would omit dependent component DLLs. Together these move the relaunch out of a user-writable directory and constrain where DLLs may be loaded from.
base::CreateNewTempDirectory()), which placed an attacker-controllable directory on the elevated process’s search path; the fix stages under the administrator-only base::DIR_SYSTEM_TEMP and additionally restricts DLL loading to %SYSTEM32%/full paths.Attack Path
- Elevated relaunch reaches the vulnerable staging
During uninstall,
RelaunchUninstaller()runs elevated (per the added comment, “This function only runs elevated”) and callsbase::CreateNewTempDirectory()to obtain a staging directory that resolves to the per-user, user-writable%TEMP%. - Attacker plants a module in the user-writable directory A local, non-elevated user writes a malicious DLL (or binary) into that per-user temp location, which the elevated child will use as its application and working directory. (inferred — the specific planted DLL name and the exact loader search order are not shown in the diff.)
- Elevated process loads the attacker-controlled module
The relaunched
gcp_setup.exe, running with the elevated token, resolves a dependent DLL from the uncontrolled search path element and loads the attacker’s module, executing attacker code in the elevated context. (inferred — the diff shows only the directory relocation and DLL-search hardening, not a demonstrated load of a specific module.) - Fix removes the search-path exposure
Post-patch, staging occurs under
base::DIR_SYSTEM_TEMP(admin-only),EnableSecureDllLoading()restricts loads to%SYSTEM32%/full paths, and/DEPENDENTLOADFLAG:0x800enforces secure dependent-DLL loading, so the attacker-writable directory is no longer on the search path.
Impact Assessment
gcp_setup.exe process, whose preconditions are local access to the machine and write access to the per-user temp directory used by the split-token elevated admin during the uninstall relaunch. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.1. Escalation to code execution in the elevated context is the natural consequence of loading an attacker-controlled DLL, but the diff itself only demonstrates the directory relocation and DLL-search hardening, so full privilege escalation / code execution is marked (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/credential_provider/test/gcp_setup_unittest.cc |
modified |
Files Changed
chrome/credential_provider/setup/setup.ccchrome/credential_provider/test/gcp_setup_unittest.ccchrome/credential_provider/setup/setup_utils.ccchrome/credential_provider/setup/setup_utils.hchrome/credential_provider/setup/setup_lib.ccchrome/credential_provider/setup/BUILD.gn
Audit Directions
- Elevated relaunch from user-writable tempSearch for other elevated code paths using
base::CreateNewTempDirectory()(or raw%TEMP%/GetTempPath) to stage and relaunch executables, and compare against installer/updater code that already usesbase::DIR_SYSTEM_TEMP. - Missing secure DLL loading in setup/elevated binariesAudit Windows setup and helper executables that do not call
SetDefaultDllDirectories()/ lack/DEPENDENTLOADFLAG:0x800, since they may resolve dependent DLLs from the working or application directory. - Split-token path resolution assumptionsLook for code that assumes a per-user path is trustworthy when running elevated with a split token, where
PathServiceresults differ between the elevated and interactive user contexts.
Patch
From 5a583ca6b6a2fac3b481b1cd7744534531965d6c Mon Sep 17 00:00:00 2001 From: Gabriel Charette <[email protected]> Date: Mon, 17 Aug 2026 00:56:59 -0700 Subject: [PATCH] Reland "[gcpw] Relaunch the uninstaller from the system temp directory" This reverts commit 16832512e03a6e690ef18007c56dfc9538b049e7. Reason for reland: Fix test under component build. Original change's description: > Revert "[gcpw] Relaunch the uninstaller from the system temp directory" > > This reverts commit af7d71b118cb2b0f8a623258ac777726e05217e8. > > Reason for revert: Failing on Win10 > > Failure Link: https://ci.chromium.org/ui/p/chromium/builders/ci/Win10%20Tests%20x64%20(dbg)/54781/overview > > Original change's description: > > [gcpw] Relaunch the uninstaller from the system temp directory > > > > RelaunchUninstaller() copies gcp_setup.exe into a fresh temp directory > > and relaunches it from there with that directory as both the > > application directory and working directory of the child. The > > directory was obtained via base::CreateNewTempDirectory(), which for > > an elevated admin with a split token resolves to the per-user %TEMP%. > > > > This function only runs after the IsUserAnAdmin() gate, so stage the > > relaunched copy under base::DIR_SYSTEM_TEMP instead, matching what > > chrome/installer/setup, chrome/updater, and > > chrome/enterprise_companion already do for the same operation. > > > > Additionally, this change: > > - Restricts DLL loading to full paths or %SYSTEM32% by calling > > EnableSecureDllLoading() (SetDefaultDllDirectories( > > LOAD_LIBRARY_SEARCH_SYSTEM32) or LOAD_LIBRARY_SEARCH_DEFAULT_DIRS > > in component builds) in setup.cc. > > - Adds /DEPENDENTLOADFLAG:0x800 to gcp_setup ldflags in BUILD.gn to > > enforce secure dependent DLL loading at the PE header level > > (matching gcp_sfx). > > - Fails early with E_FAIL in RelaunchUninstaller() for component > > builds since copying only gcp_setup.exe into an isolated temp > > directory does not copy dependent component DLLs. > > - Adds a regression test > > (GcpSetupTest.RelaunchUninstallerStagesUnderSystemTemp) that > > overrides DIR_SYSTEM_TEMP and verifies the staging directory is > > created beneath it. > > > > [email protected] > > > > TAG=agy > > CONV=9b81bd43-a335-43ec-81e4-e60832a65aed > > > > Bug: 518081914 > > Change-Id: I76501070ac866647c48ea9612720b72da8e4580c > > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8240372 > > Reviewed-by: Greg Thompson <[email protected]> > > Auto-Submit: Gabriel Charette <[email protected]> > > Commit-Queue: Gabriel Charette <[email protected]> > > Cr-Commit-Position: refs/heads/main@{#1678221} > > Bug: 518081914 > No-Presubmit: true > No-Tree-Checks: true > No-Try: true > Change-Id: I08ee9a9b2a8079a87733c48b151d7c103b361f14 > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8247942 > Auto-Submit: Kenichi Ishibashi <[email protected]> > Owners-Override: Kenichi Ishibashi <[email protected]> > Commit-Queue: [email protected] <[email protected]> > Bot-Commit: [email protected] <[email protected]> > Cr-Commit-Position: refs/heads/main@{#1678376} Fixed: 518081914 Change-Id: Ie25b253c88dc28efcbf7d92efd8307c8adee82c7 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8256641 Auto-Submit: Gabriel Charette <[email protected]> Commit-Queue: Greg Thompson <[email protected]> Reviewed-by: Greg Thompson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1680390} --- diff --git a/chrome/credential_provider/setup/setup.cc b/chrome/credential_provider/setup/setup.cc index 3abe46d9..ab45c25 100644 --- a/chrome/credential_provider/setup/setup.cc +++ b/chrome/credential_provider/setup/setup.cc @@ -62,6 +62,8 @@ HINSTANCE /*hPrevInstance*/, wchar_t* lpCmdLine, int /*nCmdShow*/) { + credential_provider::EnableSecureDllLoading(); + HRESULT hr = S_OK; // Initialize base. Command line will be set from GetCommandLineW(). diff --git a/chrome/credential_provider/setup/setup_lib.cc b/chrome/credential_provider/setup/setup_lib.cc index 407d412..7f01b57 100644 --- a/chrome/credential_provider/setup/setup_lib.cc +++ b/chrome/credential_provider/setup/setup_lib.cc @@ -9,6 +9,7 @@ #include <iomanip> #include <string> +#include "base/base_paths.h" #include "base/command_line.h" #include "base/compiler_specific.h" #include "base/file_version_info.h" @@ -274,10 +275,24 @@ } HRESULT RelaunchUninstaller(const base::FilePath& installer_path) { +#if defined(COMPONENT_BUILD) + // In component builds, dependent DLLs are not copied to the temporary + // directory, so the executable cannot launch. + return E_FAIL; +#else + // This function only runs elevated, so stage and relaunch the copy of the + // installer from the system temp directory rather than the per-user one. + base::FilePath system_temp; + if (!base::PathService::Get(base::DIR_SYSTEM_TEMP, &system_temp)) { + LOGFN(ERROR) << "PathService::Get(DIR_SYSTEM_TEMP) failed"; + return E_FAIL; + } + base::FilePath temp_path; - if (!base::CreateNewTempDirectory(FILE_PATH_LITERAL("gcp"), &temp_path)) { + if (!base::CreateTemporaryDirInDir(system_temp, FILE_PATH_LITERAL("gcp"), + &temp_path)) { HRESULT hr = HRESULT_FROM_WIN32(::GetLastError()); - LOGFN(ERROR) << "CreateNewTempDirectory hr=" << putHR(hr); + LOGFN(ERROR) << "CreateTemporaryDirInDir hr=" << putHR(hr); return hr; } @@ -317,6 +332,7 @@ base::Process process(base::LaunchProcess(cmdline, options)); return process.IsValid() ? S_OK : E_FAIL; +#endif // defined(COMPONENT_BUILD) } int EnableStatsCollection(const base::CommandLine& cmdline) { diff --git a/chrome/credential_provider/setup/setup_utils.cc b/chrome/credential_provider/setup/setup_utils.cc index 8b9df89c..32d28bd5 100644 --- a/chrome/credential_provider/setup/setup_utils.cc +++ b/chrome/credential_provider/setup/setup_utils.cc @@ -323,4 +323,14 @@ return true; } +bool EnableSecureDllLoading() { +#if defined(COMPONENT_BUILD) + const DWORD directory_flags = LOAD_LIBRARY_SEARCH_DEFAULT_DIRS; +#else + const DWORD directory_flags = LOAD_LIBRARY_SEARCH_SYSTEM32; +#endif + + return ::SetDefaultDllDirectories(directory_flags); +} + } // namespace credential_provider diff --git a/chrome/credential_provider/setup/setup_utils.h b/chrome/credential_provider/setup/setup_utils.h index a619b01..269e847 100644 --- a/chrome/credential_provider/setup/setup_utils.h +++ b/chrome/credential_provider/setup/setup_utils.h @@ -84,6 +84,9 @@ base::DictValue installer_data_dictionary_; }; +// Restricts DLL loads to either full paths or %SYSTEM32%. +bool EnableSecureDllLoading(); + } // namespace credential_provider #endif // CHROME_CREDENTIAL_PROVIDER_SETUP_SETUP_UTILS_H_ diff --git a/chrome/credential_provider/test/gcp_setup_unittest.cc b/chrome/credential_provider/test/gcp_setup_unittest.cc index bba356f..59823bb 100644 --- a/chrome/credential_provider/test/gcp_setup_unittest.cc +++ b/chrome/credential_provider/test/gcp_setup_unittest.cc @@ -18,6 +18,7 @@ #include "base/environment.h" #include "base/file_version_info.h" #include "base/files/file.h" +#include "base/files/file_enumerator.h" #include "base/files/file_util.h" #include "base/files/scoped_temp_dir.h" #include "base/process/launch.h" @@ -327,6 +328,10 @@ } void GcpSetupTest::SetUp() { + if (!::IsUserAnAdmin()) { + GTEST_SKIP() << "Test requires administrative privileges."; + } + // Get the path to the setup exe (this exe during unit tests) and the // chrome version. GetModulePathAndProductVersion(&module_path_, &product_version_); @@ -355,10 +360,6 @@ 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 @@ -705,6 +706,32 @@ .empty()); } +TEST_F(GcpSetupTest, RelaunchUninstallerStagesUnderSystemTemp) { +#if defined(COMPONENT_BUILD) + GTEST_SKIP() << "RelaunchUninstaller is not supported in component builds."; +#else + base::ScopedTempDir system_temp; + EXPECT_TRUE(system_temp.CreateUniqueTempDir()); + base::ScopedPathOverride system_temp_override(base::DIR_SYSTEM_TEMP, + system_temp.GetPath()); + + base::ScopedTempDir source_dir; + EXPECT_TRUE(source_dir.CreateUniqueTempDir()); + base::FilePath installer_path = + source_dir.GetPath().Append(FILE_PATH_LITERAL("gcp_setup.exe")); + EXPECT_TRUE(base::WriteFile(installer_path, "stub")); + + RelaunchUninstaller(installer_path); + + base::FileEnumerator enumerator(system_temp.GetPath(), /*recursive=*/false, + base::FileEnumerator::DIRECTORIES, + FILE_PATH_LITERAL("gcp*")); + base::FilePath staged_dir = enumerator.Next(); + EXPECT_FALSE(staged_dir.empty()); + EXPECT_TRUE(base::PathExists(staged_dir.Append(installer_path.BaseName()))); +#endif // defined(COMPONENT_BUILD) +} + TEST_F(GcpSetupTest, ValidLsaWithNoExistingUser) { logging::ResetEventSourceForTesting(); diff --git a/chrome/credential_provider/setup/BUILD.gn b/chrome/credential_provider/setup/BUILD.gn index 063669a..4325145 100644 --- a/chrome/credential_provider/setup/BUILD.gn +++ b/chrome/credential_provider/setup/BUILD.gn @@ -98,4 +98,5 @@ "//content/public/common:static_switches", ] configs += [ "//build/config/win:windowed" ] + ldflags = [ "/DEPENDENTLOADFLAG:0x800" ] }
Regression Test / PoC
diff --git a/chrome/credential_provider/test/gcp_setup_unittest.cc b/chrome/credential_provider/test/gcp_setup_unittest.cc
index bba356f..59823bb 100644
--- a/chrome/credential_provider/test/gcp_setup_unittest.cc
+++ b/chrome/credential_provider/test/gcp_setup_unittest.cc
@@ -18,6 +18,7 @@
#include "base/environment.h"
#include "base/file_version_info.h"
#include "base/files/file.h"
+#include "base/files/file_enumerator.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/process/launch.h"
@@ -327,6 +328,10 @@
}
void GcpSetupTest::SetUp() {
+ if (!::IsUserAnAdmin()) {
+ GTEST_SKIP() << "Test requires administrative privileges.";
+ }
+
// Get the path to the setup exe (this exe during unit tests) and the
// chrome version.
GetModulePathAndProductVersion(&module_path_, &product_version_);
@@ -355,10 +360,6 @@
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
@@ -705,6 +706,32 @@
.empty());
}
+TEST_F(GcpSetupTest, RelaunchUninstallerStagesUnderSystemTemp) {
+#if defined(COMPONENT_BUILD)
+ GTEST_SKIP() << "RelaunchUninstaller is not supported in component builds.";
+#else
+ base::ScopedTempDir system_temp;
+ EXPECT_TRUE(system_temp.CreateUniqueTempDir());
+ base::ScopedPathOverride system_temp_override(base::DIR_SYSTEM_TEMP,
+ system_temp.GetPath());
+
+ base::ScopedTempDir source_dir;
+ EXPECT_TRUE(source_dir.CreateUniqueTempDir());
+ base::FilePath installer_path =
+ source_dir.GetPath().Append(FILE_PATH_LITERAL("gcp_setup.exe"));
+ EXPECT_TRUE(base::WriteFile(installer_path, "stub"));
+
+ RelaunchUninstaller(installer_path);
+
+ base::FileEnumerator enumerator(system_temp.GetPath(), /*recursive=*/false,
+ base::FileEnumerator::DIRECTORIES,
+ FILE_PATH_LITERAL("gcp*"));
+ base::FilePath staged_dir = enumerator.Next();
+ EXPECT_FALSE(staged_dir.empty());
+ EXPECT_TRUE(base::PathExists(staged_dir.Append(installer_path.BaseName())));
+#endif // defined(COMPONENT_BUILD)
+}
+
TEST_F(GcpSetupTest, ValidLsaWithNoExistingUser) {
logging::ResetEventSourceForTesting();
Original Bug Report
Potential Local Privilege Escalation in GCPW RelaunchUninstaller via Insecure Temp Directory
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: During GCPW uninstallation, RelaunchUninstaller copies and executes gcp_setup.exe from a temporary directory. Due to an elevation-type mismatch in base’s admin check, the directory is created under the user-writable %TEMP% directory rather than SystemTemp. A local attacker running at Medium Integrity Level could potentially exploit this to achieve High Integrity Level code execution.
Affected files:
chrome/credential_provider/setup/setup_lib.ccchrome/credential_provider/setup/setup.cc
Estimated timestamp from git blame: 2018-10-04
Summary
There is a potential local privilege escalation (LPE) vulnerability in the Google Credential Provider for Windows (GCPW) installer/uninstaller during the uninstall process. When the uninstaller is relaunched under an elevated context via RelaunchUninstaller, it creates a temporary directory and launches a copy of gcp_setup.exe from that directory. Due to how base::CreateNewTempDirectory evaluates UAC-split administrators, this temporary directory is created within the user-writable %TEMP% directory instead of the secure SystemTemp directory. Furthermore, the uninstaller does not implement strict DLL search path mitigations, leaving it potentially open to DLL hijacking or binary replacement by a lower-privilege process running in the same session.
Root Cause Analysis
In chrome/credential_provider/setup/setup_lib.cc, the function RelaunchUninstaller copies gcp_setup.exe to a temporary directory and executes the copy:
HRESULT RelaunchUninstaller(const base::FilePath& installer_path) {
base::FilePath temp_path;
if (!base::CreateNewTempDirectory(FILE_PATH_LITERAL("gcp"), &temp_path)) { ... }
base::FilePath new_installer_path = temp_path.Append(installer_path.BaseName());
if (!base::CopyFile(installer_path, new_installer_path)) { ... }
...
base::CommandLine cmdline(new_installer_path);
...
base::LaunchOptions options;
options.handles_to_inherit.push_back(this_process_handle_handle);
options.current_directory = temp_path;
base::Process process(base::LaunchProcess(cmdline, options));
}
The temporary directory is resolved in base/files/file_util_win.cc via GetSecureTempDirectory:
bool GetSecureTempDirectory(FilePath* temp_dir) {
...
return internal::IsUserDefaultAdmin()
? PathService::Get(DIR_SYSTEM_TEMP, temp_dir)
: GetTempDir(temp_dir);
}
When a UAC-split administrator authorizes the uninstaller, the process runs with an elevated token of type TokenElevationTypeFull. However, IsUserDefaultAdmin() expects the elevation type to be TokenElevationTypeDefault:
bool IsUserDefaultAdmin() {
...
TOKEN_ELEVATION_TYPE elevation_type = TokenElevationTypeDefault;
::GetTokenInformation(::GetCurrentThreadEffectiveToken(), TokenElevationType, &elevation_type, ...);
return elevation_type == TokenElevationTypeDefault;
}
Because elevation_type is TokenElevationTypeFull, IsUserDefaultAdmin() returns false. This causes GetSecureTempDirectory to fall back to GetTempDir(), creating the directory in C:\Users\<username>\AppData\Local\Temp instead of C:\Windows\SystemTemp.
Since the subdirectory is created with ::CreateDirectory(path, NULL) inside CreateTemporaryDirInDir, it inherits the standard user-writable DACL of %TEMP%, permitting Medium-IL processes running in the same user context to modify, delete, or replace files within it.
Additionally, gcp_setup.exe does not call SetDefaultDllDirectories or load DLLs securely, making it potentially susceptible to DLL side-loading from the application directory.
Potential Exploitation Scenario
An attacker running as a Medium-IL process in the same user session could potentially monitor and exploit the uninstaller relaunch process via the following sequence:
- The attacker monitors
%TEMP%for a directory starting withgcpusing directory change notifications. - Once the directory
temp_pathis created, the High-IL process copiesgcp_setup.exeinto it. - Binary Replacement Method: Utilizing
FILE_DELETE_CHILDpermissions inherited from%TEMP%, the Medium-IL attacker process deletes the High-ILgcp_setup.execopy and replaces it with a malicious executable payload of the same name before it is executed. - DLL Sideloading Method: The attacker writes a malicious DLL (targeting a startup dependency of
gcp_setup.exe) to the same temporary folder. - The original uninstaller launches the newly copied executable via
base::LaunchProcessunder the elevated High-IL context. The system either executes the replaced binary or loads the attacker’s planted DLL, resulting in privilege escalation.
(Note: These are potential exploitation steps; our tooling has not executed a live proof-of-concept.)
Recommended Fix
- Configure the
gcp_setupbinary with secure DLL search paths. This can be done by callingSetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)during initialization insetup.ccor by compiling with/DEPENDENTLOADFLAG:0x800(which is already done forgcp_sfxinchrome/credential_provider/setup/BUILD.gn). - Ensure that temporary directories used for elevated relaunch operations are created with a restricted security descriptor to prevent write or delete operations by lower-privilege processes, or update
IsUserDefaultAdminto correctly handleTokenElevationTypeFullcontexts during elevation.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.