CVE-2026-17930
Overview
Files Changed
chrome/browser/extensions/content_verifier_browsertest.ccchrome/browser/extensions/extension_browser_test_util.ccchrome/browser/extensions/extension_browser_test_util.hchrome/browser/extensions/external_extension_install_browsertest.cc
Patch
From a4279fe4fa115450fc0a8c524c7559b0415a2b78 Mon Sep 17 00:00:00 2001 From: Tim Judkins <[email protected]> Date: Wed, 03 Jun 2026 10:37:37 -0700 Subject: [PATCH] [Extensions] Move GetExtensionIdFromPrivateKeyFile to shared util file ExternalExtensionInstallBrowserTest and ContentVerifierTest both had a function for deriving an extension ID based on the private PEM key created when packing an extension. This CL moves this function to a shared util in browser_test_util and has them both reference that instead. This is done as set up for calling it from another location, which will be added in crrev.com/c/7856455 Bug: 513769158 Change-Id: I1fb3f87d6ceca63e9c629f25d4f0e340c6198ad8 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7897652 Reviewed-by: Andrea Orru <[email protected]> Commit-Queue: Tim <[email protected]> Cr-Commit-Position: refs/heads/main@{#1641053} --- diff --git a/chrome/browser/extensions/content_verifier_browsertest.cc b/chrome/browser/extensions/content_verifier_browsertest.cc index 1b63a2d..193050a 100644 --- a/chrome/browser/extensions/content_verifier_browsertest.cc +++ b/chrome/browser/extensions/content_verifier_browsertest.cc @@ -28,6 +28,7 @@ #include "chrome/browser/extensions/content_verifier_test_utils.h" #include "chrome/browser/extensions/corrupted_extension_reinstaller.h" #include "chrome/browser/extensions/devtools_util.h" +#include "chrome/browser/extensions/extension_browser_test_util.h" #include "chrome/browser/extensions/extension_browsertest.h" #include "chrome/browser/extensions/extension_management_test_util.h" #include "chrome/browser/extensions/external_provider_manager.h" @@ -264,22 +265,6 @@ testing::UnorderedElementsAre(disable_reason::DISABLE_CORRUPTED)); } - // Reads private key from |private_key_path| and generates extension id using - // it. - std::string GetExtensionIdFromPrivateKeyFile( - const base::FilePath& private_key_path) { - std::string private_key_contents; - EXPECT_TRUE( - base::ReadFileToString(private_key_path, &private_key_contents)); - std::string private_key_bytes; - EXPECT_TRUE( - Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes)); - auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo( - base::as_byte_span(private_key_bytes)); - std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo(); - return crx_file::id_util::GenerateId(public_key); - } - // Creates a random signing key and sets |extension_id| according to it. crypto::keypair::PrivateKey CreateExtensionSigningKey( std::string& extension_id) { @@ -1046,8 +1031,9 @@ test_data_dir_.AppendASCII("content_verifier/storage_permission"); base::FilePath crx_path = PackExtension(unpacked_path); ASSERT_TRUE(base::PathExists(crx_path.DirName().AppendASCII("temp.pem"))); - const std::string extension_id = GetExtensionIdFromPrivateKeyFile( - crx_path.DirName().AppendASCII("temp.pem")); + const std::string extension_id = + browser_test_util::GetExtensionIdFromPrivateKeyFile( + crx_path.DirName().AppendASCII("temp.pem")); TestContentVerifySingleJobObserver observer( extension_id, base::FilePath().AppendASCII("background.js")); diff --git a/chrome/browser/extensions/extension_browser_test_util.cc b/chrome/browser/extensions/extension_browser_test_util.cc index f1787821..8c0df08b 100644 --- a/chrome/browser/extensions/extension_browser_test_util.cc +++ b/chrome/browser/extensions/extension_browser_test_util.cc @@ -11,10 +11,13 @@ #include "base/strings/stringprintf.h" #include "base/threading/thread_restrictions.h" #include "base/values.h" +#include "components/crx_file/id_util.h" +#include "crypto/keypair.h" #include "extensions/buildflags/buildflags.h" #include "extensions/common/api/web_accessible_resources.h" #include "extensions/common/api/web_accessible_resources_mv2.h" #include "extensions/common/constants.h" +#include "extensions/common/extension.h" #include "extensions/common/file_util.h" #include "extensions/common/manifest_constants.h" #include "testing/gtest/include/gtest/gtest.h" @@ -330,4 +333,18 @@ return true; } +std::string GetExtensionIdFromPrivateKeyFile( + const base::FilePath& private_key_path) { + base::ScopedAllowBlockingForTesting allow_file_io_in_scope; + std::string private_key_contents; + EXPECT_TRUE(base::ReadFileToString(private_key_path, &private_key_contents)); + std::string private_key_bytes; + EXPECT_TRUE( + Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes)); + auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo( + base::as_byte_span(private_key_bytes)); + std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo(); + return crx_file::id_util::GenerateId(public_key); +} + } // namespace extensions::browser_test_util diff --git a/chrome/browser/extensions/extension_browser_test_util.h b/chrome/browser/extensions/extension_browser_test_util.h index 78ca16c..b5367b2 100644 --- a/chrome/browser/extensions/extension_browser_test_util.h +++ b/chrome/browser/extensions/extension_browser_test_util.h @@ -83,6 +83,11 @@ const base::FilePath& input_path, base::FilePath* out_path); +// Reads a private key from `private_key_path` and generates an extension id +// using it. +std::string GetExtensionIdFromPrivateKeyFile( + const base::FilePath& private_key_path); + } // namespace extensions::browser_test_util #endif // CHROME_BROWSER_EXTENSIONS_EXTENSION_BROWSER_TEST_UTIL_H_ diff --git a/chrome/browser/extensions/external_extension_install_browsertest.cc b/chrome/browser/extensions/external_extension_install_browsertest.cc index 8a03ee70..31418559 100644 --- a/chrome/browser/extensions/external_extension_install_browsertest.cc +++ b/chrome/browser/extensions/external_extension_install_browsertest.cc @@ -7,6 +7,7 @@ #include "base/strings/stringprintf.h" #include "base/test/bind.h" #include "base/threading/thread_restrictions.h" +#include "chrome/browser/extensions/extension_browser_test_util.h" #include "chrome/browser/extensions/extension_browsertest.h" #include "chrome/browser/extensions/extension_management_internal.h" #include "chrome/browser/extensions/external_provider_manager.h" @@ -34,23 +35,6 @@ public: ExternalExtensionInstallBrowserTest() = default; ~ExternalExtensionInstallBrowserTest() override = default; - - // Reads private key from `private_key_path` and generates extension id using - // it. - std::string GetExtensionIdFromPrivateKeyFile( - const base::FilePath& private_key_path) { - base::ScopedAllowBlockingForTesting allow_file_io_in_scope; - std::string private_key_contents; - EXPECT_TRUE( - base::ReadFileToString(private_key_path, &private_key_contents)); - std::string private_key_bytes; - EXPECT_TRUE( - Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes)); - auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo( - base::as_byte_span(private_key_bytes)); - std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo(); - return crx_file::id_util::GenerateId(public_key); - } }; // Verify that an externally installed extension is disabled on update if the @@ -81,7 +65,8 @@ } // Read the actual RSA key content, ensuring a perfect match. - const std::string extension_id = GetExtensionIdFromPrivateKeyFile(pem_path); + const std::string extension_id = + browser_test_util::GetExtensionIdFromPrivateKeyFile(pem_path); // Instantiate the necessary providers for an external extension installation. ExternalProviderManager* external_provider_manager =
Regression Test / PoC
diff --git a/chrome/browser/extensions/content_verifier_browsertest.cc b/chrome/browser/extensions/content_verifier_browsertest.cc
index 1b63a2d..193050a 100644
--- a/chrome/browser/extensions/content_verifier_browsertest.cc
+++ b/chrome/browser/extensions/content_verifier_browsertest.cc
@@ -28,6 +28,7 @@
#include "chrome/browser/extensions/content_verifier_test_utils.h"
#include "chrome/browser/extensions/corrupted_extension_reinstaller.h"
#include "chrome/browser/extensions/devtools_util.h"
+#include "chrome/browser/extensions/extension_browser_test_util.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "chrome/browser/extensions/extension_management_test_util.h"
#include "chrome/browser/extensions/external_provider_manager.h"
@@ -264,22 +265,6 @@
testing::UnorderedElementsAre(disable_reason::DISABLE_CORRUPTED));
}
- // Reads private key from |private_key_path| and generates extension id using
- // it.
- std::string GetExtensionIdFromPrivateKeyFile(
- const base::FilePath& private_key_path) {
- std::string private_key_contents;
- EXPECT_TRUE(
- base::ReadFileToString(private_key_path, &private_key_contents));
- std::string private_key_bytes;
- EXPECT_TRUE(
- Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes));
- auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo(
- base::as_byte_span(private_key_bytes));
- std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo();
- return crx_file::id_util::GenerateId(public_key);
- }
-
// Creates a random signing key and sets |extension_id| according to it.
crypto::keypair::PrivateKey CreateExtensionSigningKey(
std::string& extension_id) {
@@ -1046,8 +1031,9 @@
test_data_dir_.AppendASCII("content_verifier/storage_permission");
base::FilePath crx_path = PackExtension(unpacked_path);
ASSERT_TRUE(base::PathExists(crx_path.DirName().AppendASCII("temp.pem")));
- const std::string extension_id = GetExtensionIdFromPrivateKeyFile(
- crx_path.DirName().AppendASCII("temp.pem"));
+ const std::string extension_id =
+ browser_test_util::GetExtensionIdFromPrivateKeyFile(
+ crx_path.DirName().AppendASCII("temp.pem"));
TestContentVerifySingleJobObserver observer(
extension_id, base::FilePath().AppendASCII("background.js"));
diff --git a/chrome/browser/extensions/external_extension_install_browsertest.cc b/chrome/browser/extensions/external_extension_install_browsertest.cc
index 8a03ee70..31418559 100644
--- a/chrome/browser/extensions/external_extension_install_browsertest.cc
+++ b/chrome/browser/extensions/external_extension_install_browsertest.cc
@@ -7,6 +7,7 @@
#include "base/strings/stringprintf.h"
#include "base/test/bind.h"
#include "base/threading/thread_restrictions.h"
+#include "chrome/browser/extensions/extension_browser_test_util.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "chrome/browser/extensions/extension_management_internal.h"
#include "chrome/browser/extensions/external_provider_manager.h"
@@ -34,23 +35,6 @@
public:
ExternalExtensionInstallBrowserTest() = default;
~ExternalExtensionInstallBrowserTest() override = default;
-
- // Reads private key from `private_key_path` and generates extension id using
- // it.
- std::string GetExtensionIdFromPrivateKeyFile(
- const base::FilePath& private_key_path) {
- base::ScopedAllowBlockingForTesting allow_file_io_in_scope;
- std::string private_key_contents;
- EXPECT_TRUE(
- base::ReadFileToString(private_key_path, &private_key_contents));
- std::string private_key_bytes;
- EXPECT_TRUE(
- Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes));
- auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo(
- base::as_byte_span(private_key_bytes));
- std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo();
- return crx_file::id_util::GenerateId(public_key);
- }
};
// Verify that an externally installed extension is disabled on update if the
@@ -81,7 +65,8 @@
}
// Read the actual RSA key content, ensuring a perfect match.
- const std::string extension_id = GetExtensionIdFromPrivateKeyFile(pem_path);
+ const std::string extension_id =
+ browser_test_util::GetExtensionIdFromPrivateKeyFile(pem_path);
// Instantiate the necessary providers for an external extension installation.
ExternalProviderManager* external_provider_manager =
Original Bug Report
Silent extension installation via spoofed theme manifest in webstorePrivate API
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: A compromised Chrome Web Store renderer can potentially bypass extension installation prompts by spoofing a theme manifest in the webstorePrivate API. The browser process automatically accepts theme installations from the Web Store, allowing an attacker to silently install extensions that use permissions not triggering install-time warnings. This bypass also affects supervised users by skipping parent approval checks.
Affected files:
chrome/browser/extensions/extension_install_prompt.ccchrome/browser/extensions/api/webstore_private/webstore_private_api.ccextensions/browser/crx_installer.ccchrome/common/extensions/permissions/chrome_permission_message_provider.ccextensions/browser/install_approval.ccextensions/browser/webstore_installer.ccextensions/common/manifest.cc
Estimated timestamp from git blame: 2013-08-23
Root Cause Analysis
A potential vulnerability exists in the way the webstorePrivate API handles extension installation requests, specifically in the beginInstallWithManifest3 flow. This API allows the renderer to provide a manifest string which the browser process uses to create a “dummy” extension object for the purpose of evaluating and displaying the installation prompt.
In chrome/browser/extensions/extension_install_prompt.cc, the ShowDialog method contains logic to automatically accept installations if the extension is identified as a theme and is from the Web Store:
// chrome/browser/extensions/extension_install_prompt.cc:527
if (extension->is_theme() && extension->from_webstore() &&
prompt_->type() != EXTENSION_REQUEST_PROMPT &&
prompt_->type() != EXTENSION_PENDING_REQUEST_PROMPT) {
std::move(done_callback_).Run(DoneCallbackPayload(Result::ACCEPTED));
return;
}
A compromised renderer (e.g., via RCE or XSS on the chromewebstore.google.com origin) can provide a manifest string containing a "theme": {} key. This causes the dummy extension’s internal type to be set to kTheme. Because the webstorePrivate API hard-codes the Extension::FROM_WEBSTORE flag for this dummy extension (in chrome/browser/extensions/api/webstore_private/webstore_private_api.cc:525), the installation prompt is automatically bypassed and accepted synchronously.
Privilege Escalation and Parental Approval Bypass
This bypass has significant security implications:
- Parental Approval Bypass: In
PerformSynchronousChecks(chrome/browser/extensions/api/webstore_private/extension_install_status.cc:115), supervised users are required to obtain parental approval for extension installations. However, this check is skipped if themanifest_typeisTYPE_THEME, allowing a silent bypass of Family Link protections. - Silent Permission Grant: The auto-approval results in an
InstallApprovalwithmanifest_check_level = ManifestCheckLevel::kLoose. When the actual extension is later installed viacompleteInstall,CrxInstaller::AllowInstallverifies the real downloaded manifest against the approved dummy manifest usingIsPrivilegeIncrease. This check only returns true if the real extension adds permissions that generate user-facing warning messages.
Many powerful permissions—including activeTab, storage, alarms, and New Tab Page overrides (chrome_url_overrides.newtab)—do not generate install-time warnings. An attacker can therefore silently install a malicious extension with these permissions by initially misrepresenting it as a theme.
Potential Trigger Path
Note: These steps are based on code analysis; a functional exploit has not been verified.
- Renderer Compromise: An attacker gains control over a renderer process for
https://chromewebstore.google.com(e.g. via XSS or RCE). - Initiate Install: The renderer calls
chrome.webstorePrivate.beginInstallWithManifest3with:id: A real extension ID (e.g., one that requestsactiveTaband overrides the New Tab Page).manifest:{"name":"spoofed","version":"1","manifest_version":3,"theme":{}}.
- Auto-Acceptance: The browser process creates a dummy extension, identifies it as a theme, and auto-accepts the installation prompt without showing any UI.
- Complete Install: The renderer calls
chrome.webstorePrivate.completeInstall(id). - Silent Installation: The browser downloads the real extension CRX. Since its permissions do not generate warnings, the “loose” manifest check passes, and the extension is installed and enabled immediately without user interaction or notification.
Suggested Fix
The webstorePrivate API should not rely on a renderer-provided manifest to determine the extension’s type for the purpose of bypassing prompts. Instead, the browser should verify the extension type against trusted metadata from the Web Store before deciding to skip the prompt. Additionally, the InstallApproval should use a stricter manifest check if the initial approval was granted based on the extension being a theme, ensuring that a theme install cannot be swapped for a non-theme extension during the completion phase.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.