Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Installer
DescriptionInappropriate implementation in Installer
ComponentInstaller
Bug ClassLogic Error
Tracker500483038
Fix commit3adaf2da2306 (chromium/src) +111/-22
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/elevation_service/elevated_recovery_impl.cc
modified
CommandLine
chrome/elevation_service/elevated_recovery_impl.h
modified
FilePath
chrome/elevation_service/elevated_recovery_impl.h
modified
Version
chrome/elevation_service/elevated_recovery_impl.h
modified

Files Changed

  • chrome/browser/component_updater/recovery_improved_component_installer_win.cc
  • chrome/elevation_service/BUILD.gn
  • chrome/elevation_service/DEPS
  • chrome/elevation_service/elevated_recovery_impl.cc
  • chrome/elevation_service/elevated_recovery_impl.h
  • chrome/elevation_service/elevated_recovery_unittest.cc
From 3adaf2da2306d13857e560d3ae863621946f230a Mon Sep 17 00:00:00 2001
From: S Ganesh <ganesh@chromium.org>
Date: Mon, 27 Apr 2026 20:44:45 -0700
Subject: [PATCH] [elevation_service] Add version check for chrome recovery

This change adds rollback protection for chrome recovery by reading the
CRX manifest after unpacking and rejecting packages with a version
older than the currently running Chrome instance.

Fixed: 500483038
Change-Id: Ic8d98662bf5c991c4a9ae7918324f216d10fd894
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7793834
Reviewed-by: Sorin Jianu <sorin@chromium.org>
Commit-Queue: S Ganesh <ganesh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621527}
---

diff --git a/chrome/browser/component_updater/recovery_improved_component_installer_win.cc b/chrome/browser/component_updater/recovery_improved_component_installer_win.cc
index 4711618..f4844f6a 100644
--- a/chrome/browser/component_updater/recovery_improved_component_installer_win.cc
+++ b/chrome/browser/component_updater/recovery_improved_component_installer_win.cc
@@ -25,6 +25,7 @@
 #include "chrome/browser/component_updater/recovery_improved_component_installer.h"
 #include "chrome/elevation_service/elevation_service_idl.h"
 #include "chrome/install_static/install_util.h"
+#include "chrome/installer/util/util_constants.h"
 #include "components/version_info/version_info.h"
 
 namespace component_updater {
@@ -114,7 +115,8 @@
 base::CommandLine RecoveryComponentActionHandlerWin::MakeCommandLine(
     const base::FilePath& unpack_path) const {
   base::CommandLine command_line(unpack_path.Append(kRecoveryFileName));
-  command_line.AppendSwitchASCII("browser-version", GetBrowserVersion());
+  command_line.AppendSwitchASCII(installer::switches::kBrowserVersionSwitch,
+                                 GetBrowserVersion());
   command_line.AppendSwitchASCII("sessionid", session_id());
   const auto app_guid = GetBrowserAppId();
   if (!app_guid.empty()) {
diff --git a/chrome/elevation_service/BUILD.gn b/chrome/elevation_service/BUILD.gn
index 0ce72b2..11ec3c4d 100644
--- a/chrome/elevation_service/BUILD.gn
+++ b/chrome/elevation_service/BUILD.gn
@@ -66,6 +66,7 @@
     "//chrome/installer/util:isolation_support",
     "//chrome/windows_services/service_program:common",
     "//components/crx_file",
+    "//components/update_client",
     "//content/public/common:static_switches",
     "//third_party/zlib/google:zip",
   ]
diff --git a/chrome/elevation_service/DEPS b/chrome/elevation_service/DEPS
index 8a35a16d5..edae50163 100644
--- a/chrome/elevation_service/DEPS
+++ b/chrome/elevation_service/DEPS
@@ -3,5 +3,6 @@
   "+chrome/installer/util",
   "+chrome/windows_services/service_program",
   "+components/crx_file",
+  "+components/update_client",
   "+third_party/zlib",
 ]
diff --git a/chrome/elevation_service/elevated_recovery_impl.cc b/chrome/elevation_service/elevated_recovery_impl.cc
index 74a1b235..60bfb51 100644
--- a/chrome/elevation_service/elevated_recovery_impl.cc
+++ b/chrome/elevation_service/elevated_recovery_impl.cc
@@ -23,10 +23,14 @@
 #include "base/process/process.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/version.h"
+#include "base/win/registry.h"
 #include "base/win/scoped_process_information.h"
+#include "chrome/install_static/install_modes.h"
 #include "chrome/install_static/install_util.h"
+#include "chrome/installer/util/util_constants.h"
 #include "chrome/windows_services/service_program/scoped_client_impersonation.h"
 #include "components/crx_file/crx_verifier.h"
+#include "components/update_client/utils.h"
 #include "third_party/zlib/google/zip.h"
 
 namespace elevation_service {
@@ -317,10 +321,41 @@
   if (FAILED(hr))
     return hr;
 
+  // Read version autonomously from secured HKLM machine registries based on
+  // AppID.
+  base::win::RegKey key(
+      HKEY_LOCAL_MACHINE,
+      install_static::GetClientsKeyPath(browser_appid.c_str()).c_str(),
+      KEY_QUERY_VALUE);
+  std::wstring registry_version;
+  if (key.ReadValue(FILE_PATH_LITERAL("version"), &registry_version) !=
+      ERROR_SUCCESS) {
+    // Fall back on RPC caller version if registry read fails. Registry keys
+    // may be missing or corrupted on severely broken environments that recovery
+    // specifically targets. Note that deliberately modifying the HKLM version
+    // is something that only administrators can do, and medium integrity
+    // attackers cannot natively bypass floor checks by clearing those keys.
+    registry_version = browser_version;
+  }
+  const base::Version registry_version_parsed(
+      base::WideToASCII(registry_version));
+  if (!registry_version_parsed.IsValid()) {
+    return E_FAIL;
+  }
+
+  // Trapping attacks by returning E_ACCESSDENIED on discrepancies.
+  const base::Version browser_version_parsed(
+      base::WideToASCII(browser_version));
+  if (!browser_version_parsed.IsValid() ||
+      browser_version_parsed != registry_version_parsed) {
+    return E_ACCESSDENIED;
+  }
+
   base::CommandLine args(base::CommandLine::NO_PROGRAM);
   if (!browser_appid.empty())
     args.AppendSwitchNative("appguid", browser_appid);
-  args.AppendSwitchNative("browser-version", browser_version);
+  args.AppendSwitchNative(installer::switches::kBrowserVersionSwitch,
+                          browser_version);
   args.AppendSwitchNative("sessionid", session_id);
   args.AppendSwitch("system");
 
@@ -329,7 +364,7 @@
   if (FAILED(hr))
     return hr;
 
-  return RunCRX(crx_path, args,
+  return RunCRX(crx_path, args, browser_version_parsed,
                 crx_file::VerifierFormat::CRX3_WITH_PUBLISHER_PROOF,
                 GetRecoveryCRXHash(), unpack_dir,
                 base::FilePath(kRecoveryExeName), caller_proc_id, proc_handle);
@@ -337,6 +372,7 @@
 
 HRESULT RunCRX(const base::FilePath& crx_path,
                const base::CommandLine& args,
+               const base::Version& min_crx_version,
                const crx_file::VerifierFormat& crx_format,
                const std::vector<uint8_t>& crx_hash,
                const base::FilePath& unpack_under_path,
@@ -361,6 +397,24 @@
   if (FAILED(hr))
     return hr;
 
+  const auto manifest = update_client::ReadManifest(unpacked_crx_dir.GetPath());
+  if (!manifest) {
+    return E_FAIL;
+  }
+  const std::string* manifest_version_str = manifest->FindString("version");
+  if (!manifest_version_str) {
+    return E_FAIL;
+  }
+  const base::Version manifest_version(*manifest_version_str);
+  if (!manifest_version.IsValid()) {
+    return E_FAIL;
+  }
+
+  // Trapping attacks by mapping E_ACCESSDENIED on rollbacks.
+  if (manifest_version < min_crx_version) {
+    return E_ACCESSDENIED;
+  }
+
   const base::FilePath path_and_name =
       unpacked_crx_dir.GetPath().Append(exe_filename);
   base::CommandLine command_line(path_and_name);
diff --git a/chrome/elevation_service/elevated_recovery_impl.h b/chrome/elevation_service/elevated_recovery_impl.h
index 64ef31b..d7012a0 100644
--- a/chrome/elevation_service/elevated_recovery_impl.h
+++ b/chrome/elevation_service/elevated_recovery_impl.h
@@ -16,6 +16,7 @@
 
 class CommandLine;
 class FilePath;
+class Version;
 
 }  // namespace base
 
@@ -45,8 +46,11 @@
 // |crx_path|. The returned |proc_handle| is a process handle that is valid for
 // the |caller_proc_id| process, or the current process if |caller_proc_id| is
 // 0. |unpacked_under_path| is expected to be eventually deleted by the caller.
+// |min_crx_version| represents the minimum version of the CRX manifest allowed,
+// to prevent version rollback attacks.
 HRESULT RunCRX(const base::FilePath& crx_path,
                const base::CommandLine& args,
+               const base::Version& min_crx_version,
                const crx_file::VerifierFormat& crx_format,
                const std::vector<uint8_t>& crx_hash,
                const base::FilePath& unpack_under_path,
diff --git a/chrome/elevation_service/elevated_recovery_unittest.cc b/chrome/elevation_service/elevated_recovery_unittest.cc
index dfd5fe7..f36d0a15 100644
--- a/chrome/elevation_service/elevated_recovery_unittest.cc
+++ b/chrome/elevation_service/elevated_recovery_unittest.cc
@@ -14,6 +14,7 @@
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/path_service.h"
+#include "base/version.h"
 #include "base/win/scoped_com_initializer.h"
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/elevation_service/elevated_recovery_unittest.cc b/chrome/elevation_service/elevated_recovery_unittest.cc
index dfd5fe7..f36d0a15 100644
--- a/chrome/elevation_service/elevated_recovery_unittest.cc
+++ b/chrome/elevation_service/elevated_recovery_unittest.cc
@@ -14,6 +14,7 @@
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/path_service.h"
+#include "base/version.h"
 #include "base/win/scoped_com_initializer.h"
 #include "chrome/elevation_service/elevated_recovery_impl.h"
 #include "chrome/windows_services/service_program/test_support/scoped_mock_context.h"
@@ -121,33 +122,35 @@
 
 TEST_F(ElevatedRecoveryTest, Do_RunCRX_InvalidArgs) {
   base::win::ScopedHandle proc_handle;
+  base::CommandLine command_line(base::CommandLine::NO_PROGRAM);
+  command_line.AppendSwitchNative("browser-version", L"0.0.0.0");
 
   // Non-matching CRX/CRX-hash.
   EXPECT_EQ(CRYPT_E_NO_MATCH,
             elevation_service::RunCRX(
-                TestFile("valid_no_publisher.crx3"),
-                base::CommandLine(base::CommandLine::NO_PROGRAM),
+                TestFile("valid_no_publisher.crx3"), command_line,
+                base::Version("0.0.0.0"),
                 crx_file::VerifierFormat::CRX3_WITH_PUBLISHER_PROOF,
                 GetValidPublisherCrx3Hash(), GetUnpackDir(),
                 base::FilePath(kManifestJSONFileName), ::GetCurrentProcessId(),
                 &proc_handle));
 
   // Non-existent CRX file.
-  EXPECT_EQ(HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND),
-            elevation_service::RunCRX(
-                TestFile("nonexistent.crx3"),
-                base::CommandLine(base::CommandLine::NO_PROGRAM),
-                crx_file::VerifierFormat::CRX3_WITH_PUBLISHER_PROOF,
-                GetValidPublisherCrx3Hash(), GetUnpackDir(),
-                base::FilePath(kManifestJSONFileName), ::GetCurrentProcessId(),
-                &proc_handle));
+  EXPECT_EQ(
+      HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND),
+      elevation_service::RunCRX(
+          TestFile("nonexistent.crx3"), command_line, base::Version("0.0.0.0"),
+          crx_file::VerifierFormat::CRX3_WITH_PUBLISHER_PROOF,
+          GetValidPublisherCrx3Hash(), GetUnpackDir(),
+          base::FilePath(kManifestJSONFileName), ::GetCurrentProcessId(),
+          &proc_handle));
 
   // manifest.json is not a Windows executable, ::CreateProcess therefore
   // returns ERROR_BAD_EXE_FORMAT.
   EXPECT_EQ(HRESULT_FROM_WIN32(ERROR_BAD_EXE_FORMAT),
             elevation_service::RunCRX(
-                TestFile("valid_publisher.crx3"),
-                base::CommandLine(base::CommandLine::NO_PROGRAM),
+                TestFile("valid_publisher.crx3"), command_line,
+                base::Version("0.0.0.0"),
                 crx_file::VerifierFormat::CRX3_WITH_PUBLISHER_PROOF,
                 GetValidPublisherCrx3Hash(), GetUnpackDir(),
                 base::FilePath(kManifestJSONFileName), ::GetCurrentProcessId(),
@@ -156,16 +159,17 @@
 
 TEST_F(ElevatedRecoveryTest, Do_RunCRX_ValidArgs) {
   base::win::ScopedHandle proc_handle;
+  base::CommandLine command_line(base::CommandLine::NO_PROGRAM);
+  command_line.AppendSwitchNative("browser-version", L"0.0.0.0");
 
   // ChromeRecovery.crx3 contains ChromeRecovery.exe which returns a hardcoded
   // value of 1877345072.
-  EXPECT_EQ(S_OK,
-            elevation_service::RunCRX(
-                TestFile("ChromeRecovery.crx3"),
-                base::CommandLine(base::CommandLine::NO_PROGRAM),
-                crx_file::VerifierFormat::CRX3, GetRunactionTestWinCrx3Hash(),
-                GetUnpackDir(), base::FilePath(kRecoveryExeName),
-                ::GetCurrentProcessId(), &proc_handle));
+  EXPECT_EQ(S_OK, elevation_service::RunCRX(
+                      TestFile("ChromeRecovery.crx3"), command_line,
+                      base::Version("0.0.0.0"), crx_file::VerifierFormat::CRX3,
+                      GetRunactionTestWinCrx3Hash(), GetUnpackDir(),
+                      base::FilePath(kRecoveryExeName), ::GetCurrentProcessId(),
+                      &proc_handle));
 
   EXPECT_EQ(WAIT_OBJECT_0, ::WaitForSingleObject(proc_handle.Get(), 500));
   DWORD exit_code = 0;
@@ -173,6 +177,28 @@
   EXPECT_EQ(1877345072UL, exit_code);
 }
 
+TEST_F(ElevatedRecoveryTest, Do_RunCRX_VersionCheck) {
+  base::win::ScopedHandle proc_handle;
+  base::CommandLine command_line(base::CommandLine::NO_PROGRAM);
+
+  // Trapping attacks by mapping E_ACCESSDENIED on rollbacks.
+  EXPECT_EQ(E_ACCESSDENIED,
+            elevation_service::RunCRX(
+                TestFile("ChromeRecovery.crx3"), command_line,
+                base::Version("1000.0.0.0"), crx_file::VerifierFormat::CRX3,
+                GetRunactionTestWinCrx3Hash(), GetUnpackDir(),
+                base::FilePath(kRecoveryExeName), ::GetCurrentProcessId(),
+                &proc_handle));
+
+  // Verification handling successfully passing low floors.
+  EXPECT_EQ(S_OK, elevation_service::RunCRX(
+                      TestFile("ChromeRecovery.crx3"), command_line,
+                      base::Version("0.0.0.0"), crx_file::VerifierFormat::CRX3,
+                      GetRunactionTestWinCrx3Hash(), GetUnpackDir(),
+                      base::FilePath(kRecoveryExeName), ::GetCurrentProcessId(),
+                      &proc_handle));
+}
+
 TEST(ElevatedRecoveryCleanupTest, Do_CleanupChromeRecoveryDirectory) {
   base::FilePath recovery_dir;
   ASSERT_TRUE(base::PathService::Get(base::DIR_EXE, &recovery_dir));
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential SYSTEM LPE via Insecure CWD in Elevation Service

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: The Chrome Elevation Service exposes an interactive COM interface that executes a provided recovery CRX as SYSTEM. During execution, it insecurely sets the child process’s Current Working Directory to the user-writable C:\Windows\Temp. By supplying a historically signed CRX and planting a malicious DLL, a local attacker can achieve privilege escalation to SYSTEM via DLL hijacking.

Affected files:

  • chrome/elevation_service/elevated_recovery_impl.cc

Estimated timestamp from git blame: 2018-11-04

Summary

The Chrome Elevation Service (elevation_service.exe), running with LocalSystem privileges, incorrectly sets the Current Working Directory (CWD) of recovery child processes to the system temporary directory (C:\Windows\Temp). Because this directory is writable by unprivileged interactive users, it creates a Local Privilege Escalation (LPE) vector via DLL hijacking. An attacker can trigger this by providing a historically signed, older version of ChromeRecovery.crx3 that lacks modern DLL loading mitigations.

Vulnerability Details

  1. COM Accessibility: The Elevation Service exposes the IElevator COM interface. During startup (chrome/windows_services/service_program/service.cc:384), Service::InitializeComSecurity() explicitly adds Sids::Interactive() to the DACL, allowing any locally logged-in user to invoke its methods.
  2. Downgrade Attack: The interface exposes RunRecoveryCRXElevated, taking a user-provided CRX path. The CRX verification (chrome/elevation_service/elevated_recovery_impl.cc:185) only validates the signature against a hardcoded public key hash (GetRecoveryCRXHash()). It does not enforce versioning or rollback prevention. An attacker can supply an older, legitimately signed ChromeRecovery.crx3 from a time before Chromium implemented the EnableSecureDllLoading() mitigation (which relies on SetDefaultDllDirectories).
  3. Insecure CWD Setting: When launching the unpacked binary, LaunchCmd prepares a base::LaunchOptions structure and sets the current directory to the temp dir:
    // chrome/elevation_service/elevated_recovery_impl.cc:213-216
    base::LaunchOptions options = {};
    options.feedback_cursor_off = true;
    base::GetTempDir(&options.current_directory);
    base::Process proc = base::LaunchProcess(command_line, options);
    
  4. Temp Dir Resolution: Because the service runs as LocalSystem, base::GetTempDir() resolves to C:\Windows\Temp. By default, Windows allows standard BUILTIN\Users to write files (AppendData) to this directory.
  5. DLL Hijack: ChromeRecovery.exe is launched as SYSTEM with its CWD explicitly set to C:\Windows\Temp. Because the attacker provided an older binary lacking SetDefaultDllDirectories mitigations, the Windows loader uses Safe DLL Search Mode. If a dependency is missing from the application or system directories, the loader will check the CWD. The attacker can place a malicious DLL (e.g., version.dll or profapi.dll) in C:\Windows\Temp. The loader maps it into the SYSTEM process, executing the attacker’s DllMain.

Note: These steps are suggested based on static analysis, as our tooling cannot currently run arbitrary code to produce a live proof of concept.

Suggested Fix

Use base::GetSecureTempDirectory() instead of base::GetTempDir() in LaunchCmd(). GetSecureTempDirectory() is specifically designed for SYSTEM processes to resolve to an administrator-only directory (e.g., %SystemRoot%\SystemTemp), preventing lower-privileged users from tampering with it. Alternatively, do not set options.current_directory at all, allowing the child process to safely inherit the Elevation Service’s CWD (typically C:\Windows\System32).

Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234


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