Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker513769158
Fix commita4279fe4fa11 (chromium/src) +29/-36
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • chrome/browser/extensions/content_verifier_browsertest.cc
  • chrome/browser/extensions/extension_browser_test_util.cc
  • chrome/browser/extensions/extension_browser_test_util.h
  • chrome/browser/extensions/external_extension_install_browsertest.cc
From a4279fe4fa115450fc0a8c524c7559b0415a2b78 Mon Sep 17 00:00:00 2001
From: Tim Judkins <tjudkins@chromium.org>
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 <andreaorru@chromium.org>
Commit-Queue: Tim <tjudkins@chromium.org>
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 =
Loading diff…

Regression Test / PoC

shipped with the fix
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 =
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • chrome/browser/extensions/api/webstore_private/webstore_private_api.cc
  • extensions/browser/crx_installer.cc
  • chrome/common/extensions/permissions/chrome_permission_message_provider.cc
  • extensions/browser/install_approval.cc
  • extensions/browser/webstore_installer.cc
  • extensions/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:

  1. 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 the manifest_type is TYPE_THEME, allowing a silent bypass of Family Link protections.
  2. Silent Permission Grant: The auto-approval results in an InstallApproval with manifest_check_level = ManifestCheckLevel::kLoose. When the actual extension is later installed via completeInstall, CrxInstaller::AllowInstall verifies the real downloaded manifest against the approved dummy manifest using IsPrivilegeIncrease. 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.

  1. Renderer Compromise: An attacker gains control over a renderer process for https://chromewebstore.google.com (e.g. via XSS or RCE).
  2. Initiate Install: The renderer calls chrome.webstorePrivate.beginInstallWithManifest3 with:
    • id: A real extension ID (e.g., one that requests activeTab and overrides the New Tab Page).
    • manifest: {"name":"spoofed","version":"1","manifest_version":3,"theme":{}}.
  3. Auto-Acceptance: The browser process creates a dummy extension, identifies it as a theme, and auto-accepts the installation prompt without showing any UI.
  4. Complete Install: The renderer calls chrome.webstorePrivate.completeInstall(id).
  5. 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.

View on issue tracker