CVE-2026-11239
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.cc |
modified | |
ifchrome/browser/extensions/extension_security_exploit_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/api/declarative_content/content_action.ccchrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.ccchrome/browser/extensions/extension_security_exploit_browsertest.cc
Patch
From 3995e723497838c70fbd0a6447ee4501a0f745d0 Mon Sep 17 00:00:00 2001
From: Justin Lulejian <jlulejian@chromium.org>
Date: Tue, 14 Apr 2026 17:24:24 -0700
Subject: [PATCH] [Extensions] Harden Declarative Content API against spoofed IPCs
Before this commit, a compromised renderer could spoof the
WatchedPageChange IPC message to report that a CSS selector matched on a
page. This could be used to trick ScriptInjectionTracker into granting
trust for unauthorized pages, leading to unpermitted script injection.
The issue is two-part:
1) The WatchedPageChange IPC could be for any selector and will be
considered valid.
2) Even if the WatchedPageChange IPC was an existing selector, we still
don't check if the extension has host permission before script
injection.
After this commit we address both parts:
For the WatchedPageChange IPC: we add a coarse IPC validation check in
DeclarativeContentCssConditionTracker::OnWatchedPageChanged to ensure
reported selectors are at least known globally. If not, we ignore the
unknown selector so it doesn't trigger any actions. We ignore instead of
terminate the renderer because of a possible race where the browser
might stop tracking the selector before the IPC arrives.
For the script injection: we add a host permission check before granting
script injection trust in
RequestContentScript::InstructRenderProcessToInject.
Both scenarios/parts are tested in
extension_security_exploit_browsertest.cc.
Bug: 497025738
Change-Id: Ic962c0c0e4dffa5fb6086cd32b4c3b72d235b635
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7748669
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Commit-Queue: Justin Lulejian <jlulejian@chromium.org>
Auto-Submit: Justin Lulejian <jlulejian@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1614799}
---
diff --git a/chrome/browser/extensions/api/declarative_content/content_action.cc b/chrome/browser/extensions/api/declarative_content/content_action.cc
index b1c32f5..85cd204 100644
--- a/chrome/browser/extensions/api/declarative_content/content_action.cc
+++ b/chrome/browser/extensions/api/declarative_content/content_action.cc
@@ -34,6 +34,7 @@
#include "extensions/common/mojom/host_id.mojom.h"
#include "extensions/common/mojom/match_origin_as_fallback.mojom-shared.h"
#include "extensions/common/mojom/run_location.mojom-shared.h"
+#include "extensions/common/permissions/permissions_data.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_skia.h"
@@ -374,6 +375,18 @@
void RequestContentScript::InstructRenderProcessToInject(
content::WebContents* contents,
const Extension* extension) const {
+ // Verify that the extension has permission to access the page before
+ // granting trust for script injection. This prevents a compromised renderer
+ // from fully bypassing permission checks (for example: a spoofed
+ // `extensions::mojom::LocalFrameHost::WatchedPageChange` IPC bypassing the
+ // check we have for an invalid selector and getting here).
+ std::string error;
+ if (!extension->permissions_data()->CanAccessPage(
+ contents->GetLastCommittedURL(), ExtensionTabUtil::GetTabId(contents),
+ &error)) {
+ return;
+ }
+
ScriptInjectionTracker::WillExecuteCode(base::PassKey<RequestContentScript>(),
contents->GetPrimaryMainFrame(),
*extension);
diff --git a/chrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.cc b/chrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.cc
index c5d2230d..2a9b8c2f 100644
--- a/chrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.cc
+++ b/chrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.cc
@@ -14,6 +14,8 @@
#include "chrome/browser/profiles/profile.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_process_host.h"
+#include "content/public/browser/web_contents.h"
+#include "extensions/browser/bad_message.h"
#include "extensions/browser/renderer_startup_helper.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/api/declarative/declarative_constants.h"
@@ -218,7 +220,18 @@
content::WebContents* contents,
const std::vector<std::string>& css_selectors) {
DCHECK(per_web_contents_tracker_.contains(contents));
- per_web_contents_tracker_[contents]->OnWatchedPageChanged(css_selectors);
+
+ std::vector<std::string> valid_selectors;
+ for (const std::string& selector : css_selectors) {
+ // A compromised renderer might send selectors that were not requested.
+ // We ignore unrecognized selectors to avoid race conditions where a
+ // legitimate message arrives after the browser stopped watching a selector.
+ if (watched_css_selector_predicate_count_.contains(selector)) {
+ valid_selectors.push_back(selector);
+ }
+ }
+
+ per_web_contents_tracker_[contents]->OnWatchedPageChanged(valid_selectors);
}
bool DeclarativeContentCssConditionTracker::EvaluatePredicate(
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 151756f..85d5859 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -35,6 +35,7 @@
#include "extensions/browser/extension_web_contents_observer.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/renderer_startup_helper.h"
+#include "extensions/browser/script_injection_tracker.h"
#include "extensions/browser/service_worker/service_worker_host.h"
#include "extensions/common/constants.h"
#include "extensions/common/extension_features.h"
@@ -85,6 +86,35 @@
open_extension_mutator_ = std::move(open_extension_mutator);
}
+ void SimulateWatchedPageChange(
+ const std::vector<std::string>& css_selectors) {
+ GetForwardingInterface()->WatchedPageChange(css_selectors);
+ }
+
+ using WatchedPageChangeMutator =
+ base::RepeatingCallback<void(std::vector<std::string>& css_selectors)>;
+ void SetWatchedPageChangeMutator(
+ WatchedPageChangeMutator watched_page_change_mutator) {
+ watched_page_change_mutator_ = std::move(watched_page_change_mutator);
+ }
+
+ // We cannot call `FlushForTesting()` on the receiver side because
+ // `ExtensionFrameHost` uses `content::RenderFrameHostReceiverSet` which does
+ // not expose `FlushForTesting()`. Instead, we do a Mojo round-trip by calling
+ // a method with a callback (`GetAppInstallState`) to ensure all previous
+ // messages on the pipe have been processed.
+ void FlushForTesting() {
+ base::RunLoop run_loop;
+ GetForwardingInterface()->GetAppInstallState(
+ GURL("http://example.com"),
+ base::BindOnce(
+ [](base::OnceClosure quit_closure, const std::string&) {
+ std::move(quit_closure).Run();
+ },
+ run_loop.QuitClosure()));
+ run_loop.Run();
+ }
+
private:
mojom::LocalFrameHost* GetForwardingInterface() override {
return scoped_swap_impl_.old_impl();
@@ -125,9 +155,22 @@
std::move(port_host));
}
+ void WatchedPageChange(
+ const std::vector<std::string>& css_selectors) override {
+ std::vector<std::string> mutated_selectors = css_selectors;
+ content::RenderFrameHost* current_target_frame =
+ extension_frame_host_->receivers_for_testing().GetCurrentTargetFrame();
+ if (watched_page_change_mutator_ && frame_ == current_target_frame) {
+ watched_page_change_mutator_.Run(mutated_selectors);
+ }
+
+ GetForwardingInterface()->WatchedPageChange(mutated_selectors);
+ }
+
const raw_ptr<content::RenderFrameHost> frame_ = nullptr;
RequestMutator request_mutator_;
OpenChannelToExtensionMutator open_extension_mutator_;
+ WatchedPageChangeMutator watched_page_change_mutator_;
const raw_ptr<ExtensionFrameHost> extension_frame_host_ = nullptr;
const mojo::test::ScopedSwapImplForTesting<mojom::LocalFrameHost>
scoped_swap_impl_;
@@ -745,4 +788,174 @@
kill_waiter.Wait());
}
+// Tests that a renderer sending an unrecognized selector (one the browser
+// didn't ask to watch) is ignored and does not trigger actions.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+ WatchedPageChange_InvalidSelector_Ignored) {
+ // Install an extension that watches "div.target" and injects a script.
+ TestExtensionDir dir;
+ dir.WriteManifest(R"({
+ "name": "Extension",
+ "version": "1.0",
+ "manifest_version": 2,
+ "permissions": [ "declarativeContent", "http://foo.com/*" ],
+ "background": {"scripts": ["background.js"]}
+ })");
+ dir.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+ chrome.runtime.onInstalled.addListener(() => {
+ chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
+ chrome.declarativeContent.onPageChanged.addRules([{
+ conditions: [
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 151756f..85d5859 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -35,6 +35,7 @@
#include "extensions/browser/extension_web_contents_observer.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/renderer_startup_helper.h"
+#include "extensions/browser/script_injection_tracker.h"
#include "extensions/browser/service_worker/service_worker_host.h"
#include "extensions/common/constants.h"
#include "extensions/common/extension_features.h"
@@ -85,6 +86,35 @@
open_extension_mutator_ = std::move(open_extension_mutator);
}
+ void SimulateWatchedPageChange(
+ const std::vector<std::string>& css_selectors) {
+ GetForwardingInterface()->WatchedPageChange(css_selectors);
+ }
+
+ using WatchedPageChangeMutator =
+ base::RepeatingCallback<void(std::vector<std::string>& css_selectors)>;
+ void SetWatchedPageChangeMutator(
+ WatchedPageChangeMutator watched_page_change_mutator) {
+ watched_page_change_mutator_ = std::move(watched_page_change_mutator);
+ }
+
+ // We cannot call `FlushForTesting()` on the receiver side because
+ // `ExtensionFrameHost` uses `content::RenderFrameHostReceiverSet` which does
+ // not expose `FlushForTesting()`. Instead, we do a Mojo round-trip by calling
+ // a method with a callback (`GetAppInstallState`) to ensure all previous
+ // messages on the pipe have been processed.
+ void FlushForTesting() {
+ base::RunLoop run_loop;
+ GetForwardingInterface()->GetAppInstallState(
+ GURL("http://example.com"),
+ base::BindOnce(
+ [](base::OnceClosure quit_closure, const std::string&) {
+ std::move(quit_closure).Run();
+ },
+ run_loop.QuitClosure()));
+ run_loop.Run();
+ }
+
private:
mojom::LocalFrameHost* GetForwardingInterface() override {
return scoped_swap_impl_.old_impl();
@@ -125,9 +155,22 @@
std::move(port_host));
}
+ void WatchedPageChange(
+ const std::vector<std::string>& css_selectors) override {
+ std::vector<std::string> mutated_selectors = css_selectors;
+ content::RenderFrameHost* current_target_frame =
+ extension_frame_host_->receivers_for_testing().GetCurrentTargetFrame();
+ if (watched_page_change_mutator_ && frame_ == current_target_frame) {
+ watched_page_change_mutator_.Run(mutated_selectors);
+ }
+
+ GetForwardingInterface()->WatchedPageChange(mutated_selectors);
+ }
+
const raw_ptr<content::RenderFrameHost> frame_ = nullptr;
RequestMutator request_mutator_;
OpenChannelToExtensionMutator open_extension_mutator_;
+ WatchedPageChangeMutator watched_page_change_mutator_;
const raw_ptr<ExtensionFrameHost> extension_frame_host_ = nullptr;
const mojo::test::ScopedSwapImplForTesting<mojom::LocalFrameHost>
scoped_swap_impl_;
@@ -745,4 +788,174 @@
kill_waiter.Wait());
}
+// Tests that a renderer sending an unrecognized selector (one the browser
+// didn't ask to watch) is ignored and does not trigger actions.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+ WatchedPageChange_InvalidSelector_Ignored) {
+ // Install an extension that watches "div.target" and injects a script.
+ TestExtensionDir dir;
+ dir.WriteManifest(R"({
+ "name": "Extension",
+ "version": "1.0",
+ "manifest_version": 2,
+ "permissions": [ "declarativeContent", "http://foo.com/*" ],
+ "background": {"scripts": ["background.js"]}
+ })");
+ dir.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+ chrome.runtime.onInstalled.addListener(() => {
+ chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
+ chrome.declarativeContent.onPageChanged.addRules([{
+ conditions: [
+ new chrome.declarativeContent.PageStateMatcher({
+ css: ["div.target"]
+ })
+ ],
+ actions: [
+ new chrome.declarativeContent.RequestContentScript({
+ js: ["script.js"]
+ })
+ ]
+ }]);
+ });
+ });
+ )");
+ dir.WriteFile(FILE_PATH_LITERAL("script.js"), "console.log('injected');");
+
+ const Extension* extension = LoadExtension(dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Navigate to a test page on foo.com where the extension has permission.
+ GURL test_page_url =
+ embedded_test_server()->GetURL("foo.com", "/title1.html");
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+ content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+
+ // Prepare to intercept WatchedPageChange.
+ auto interceptor =
+ std::make_unique<ExtensionFrameHostInterceptor>(main_frame);
+
+ // Simulate a spoofed IPC with a selector the browser didn't ask to watch.
+ std::vector<std::string> spoofed_selectors = {"div.spoofed"};
+ interceptor->SimulateWatchedPageChange(spoofed_selectors);
+ interceptor->FlushForTesting();
+
+ // Verify that `ScriptInjectionTracker` doesn't show injection for the
+ // extension.
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_frame->GetProcess(), extension->id()));
+
+ // Now simulate a valid selector to prove it *would* work.
+ std::vector<std::string> valid_selectors = {"div.target"};
+ interceptor->SimulateWatchedPageChange(valid_selectors);
+ interceptor->FlushForTesting();
+
+ // Verify that `ScriptInjectionTracker` does show injection now.
+ EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_frame->GetProcess(), extension->id()));
+}
+
+// Tests that a renderer cannot trigger content script injection on a page the
+// extension has no permission for, even if it spoofs a valid CSS selector. This
+// is a regression test for crbug.com/497025738 and exercises the host
+// permission check in `RequestContentScript::InstructRenderProcessToInject`.
+//
+// High-level flow:
+// 1. Install Extension A that watches "div.target" on google.com. This makes
+// the selector globally known to the browser's CSS condition tracker. This
+// allows a compromised renderer to pass the check we have for valid
+// selectors on the `extensions::mojom::LocalFrameHost::WatchedPageChange`
+// IPC.
+// 2. Install Extension B that has a declarative rule to inject a script when
+// "div.target" matches, but Extension B only has host permission for
+// bar.com.
+// 3. Navigate to foo.com (where Extension B has no permission).
+// 4. Simulate a compromised renderer sending a
+// `extensions::mojom::LocalFrameHost::WatchedPageChange` IPC claiming that
+// "div.target" matched on foo.com.
+// 5. Verify that the browser blocks the script injection of Extension B because
+// Extension B lacks permission for foo.com, despite the selector being
+// valid.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+ WatchedPageChange_ValidSelector_PermissionDenied) {
+ // 1. Install Extension A to make "div.target" globally known.
+ TestExtensionDir dir_a;
+ dir_a.WriteManifest(R"({
+ "name": "Extension A",
+ "version": "1.0",
+ "manifest_version": 2,
+ "permissions": [ "declarativeContent", "http://google.com/*" ],
+ "background": {"scripts": ["background.js"]}
+ })");
+ dir_a.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+ var rule = {
+ conditions: [
+ new chrome.declarativeContent.PageStateMatcher({
+ pageUrl: { hostEquals: 'google.com' },
+ css: ['div.target']
+ })
+ ],
+ actions: [ new chrome.declarativeContent.ShowAction() ]
+ };
+ chrome.runtime.onInstalled.addListener(function() {
+ chrome.declarativeContent.onPageChanged.addRules([rule]);
+ });
+ )");
+ const Extension* extension_a = LoadExtension(dir_a.UnpackedPath());
+ ASSERT_TRUE(extension_a);
+
+ // 2. Install Extension B (target). It only has permission for bar.com.
+ TestExtensionDir dir_b;
+ dir_b.WriteManifest(R"({
+ "name": "Extension B",
+ "version": "1.0",
+ "manifest_version": 2,
+ "permissions": [ "declarativeContent", "http://bar.com/*" ],
+ "background": {"scripts": ["background.js"]}
+ })");
+ dir_b.WriteFile(FILE_PATH_LITERAL("background.js"), R"(
+ var rule = {
+ conditions: [
+ new chrome.declarativeContent.PageStateMatcher({
+ css: ['div.target']
+ })
+ ],
+ actions: [ new chrome.declarativeContent.RequestContentScript({
+ js: ["content.js"]
+ }) ]
+ };
+ chrome.runtime.onInstalled.addListener(function() {
+ chrome.declarativeContent.onPageChanged.addRules([rule]);
+ });
+ )");
+ dir_b.WriteFile(FILE_PATH_LITERAL("content.js"), R"(
+ chrome.test.sendMessage('injected');
+ )");
+ const Extension* extension_b = LoadExtension(dir_b.UnpackedPath());
+ ASSERT_TRUE(extension_b);
+
+ // Navigate to foo.com (where Extension B has no permission).
+ GURL test_page_url =
+ embedded_test_server()->GetURL("foo.com", "/title1.html");
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+ content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+
+ // Prepare to intercept WatchedPageChange.
+ auto interceptor =
+ std::make_unique<ExtensionFrameHostInterceptor>(main_frame);
+
+ // Simulate a spoofed IPC with a selector that is valid globally, bypassing
+ // the IPC check.
+ std::vector<std::string> spoofed_selectors = {"div.target"};
+ interceptor->SimulateWatchedPageChange(spoofed_selectors);
+ interceptor->FlushForTesting();
+
+ // Verify that `ScriptInjectionTracker` doesn't show injection for Extension B
+ // on foo.com because we've checked that the extension has host permission
+ // before allowing the script injection.
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_frame->GetProcess(), extension_b->id()));
+}
+
} // namespace extensions
Original Bug Report
Privilege escalation via spoofed WatchedPageChange IPC in declarativeContent
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can spoof the WatchedPageChange IPC to trick the browser into matching a declarativeContent rule with a RequestContentScript action. This unconditionally grants the renderer permanent content script privileges for that extension, allowing it to bypass security checks and communicate with the extension’s background page.
Affected files:
chrome/browser/extensions/api/declarative_content/content_action.ccchrome/browser/extensions/chrome_extension_frame_host.ccchrome/browser/extensions/api/declarative_content/declarative_content_css_condition_tracker.ccextensions/browser/script_injection_tracker.ccchrome/browser/extensions/api/declarative_content/chrome_content_rules_registry.ccextensions/browser/api/messaging/message_service_bindings.cc
Estimated timestamp from git blame: 2023-10-19
Description
A logic flaw exists in the handling of the declarativeContent extension API, specifically concerning the experimental RequestContentScript action.
When a renderer sends an extensions::mojom::LocalFrameHost::WatchedPageChange IPC message containing CSS selectors, the browser’s DeclarativeContentCssConditionTracker stores these selectors verbatim and re-evaluates declarative rules. If an installed extension uses a PageStateMatcher that depends only on these CSS selectors, the browser will trigger the associated RequestContentScript action.
The vulnerability occurs in RequestContentScript::InstructRenderProcessToInject. This function unconditionally calls ScriptInjectionTracker::WillExecuteCode, which permanently adds the extension’s ID to the renderer process’s content_scripts_ allowed list and pushes an isolated world URLLoaderFactory. This happens without verifying if the extension actually has host permissions to run on the attacker-controlled page.
Once added to the content_scripts_ tracker, the compromised renderer can spoof messaging IPCs (like OpenChannelToExtension) claiming to be the extension’s content script. The browser’s message service bindings will check ScriptInjectionTracker::DidProcessRunContentScriptFromExtension, which will now return true, bypassing the bad_message::EMF_INVALID_EXTENSION_ID_FOR_CONTENT_SCRIPT check. The renderer can then attack the extension’s privileged background page.
Note: RequestContentScript is currently an experimental API, which limits the real-world impact to extensions specifically utilizing this feature on supported channels.
Potential Attack Steps
Note: These are suggested steps based on code analysis; our tooling agent does not currently have the ability to run a live proof-of-concept.
- An attacker compromises a main-frame renderer process.
- The attacker identifies an installed extension using
declarativeContent.RequestContentScripttriggered by a CSS-onlyPageStateMatcher. - The compromised renderer sends a spoofed
extensions::mojom::LocalFrameHost::WatchedPageChangeIPC to the browser, providing the exact CSS selectors the target extension is looking for. - The browser blindly accepts these selectors and triggers the
RequestContentScriptaction. RequestContentScript::InstructRenderProcessToInjectunconditionally callsScriptInjectionTracker::WillExecuteCode, permanently granting the renderer process content-script-level trust for that extension.- The compromised renderer sends
extensions::mojom::LocalFrameHost::OpenChannelToExtension, setting the source endpoint type tokContentScriptand the extension ID to the target extension. - The browser validates the request against the newly poisoned
ScriptInjectionTracker, allows the connection, and the attacker gains full messaging access to the extension’s background page.
Suggested Fix
- Permission Check Before Trust Grant: In
RequestContentScript::InstructRenderProcessToInject(withinchrome/browser/extensions/api/declarative_content/content_action.cc), add a permissions check to verify that the extension is actually authorized to inject a script into the givenWebContents(e.g., checking host permissions) before callingScriptInjectionTracker::WillExecuteCode. - IPC Validation: Consider adding validation to
ChromeExtensionFrameHost::WatchedPageChangeto ensure the renderer is only reporting on selectors the browser actually requested it to watch.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.