CVE-2026-113105
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchchrome/browser/extensions/extension_action_runner.cc |
modified | |
forchrome/browser/extensions/extension_action_runner.cc |
modified | |
ifchrome/browser/extensions/extension_action_runner.cc |
modified | |
ifextensions/browser/script_executor.cc |
modified | |
ActiveTabPermissionGranterextensions/browser/script_injection_tracker.h |
modified |
Files Changed
extensions/browser/script_executor.ccextensions/browser/script_injection_tracker.hextensions/browser/script_injection_tracker.ccchrome/browser/extensions/script_injection_tracker_browsertest.ccchrome/browser/extensions/extension_action_runner.hchrome/browser/extensions/extension_action_runner.ccchrome/browser/extensions/chrome_extension_frame_host.cc
Patch
From 505dce6554fbc309c42f492706da8f4916fdda66 Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Wed, 16 Sep 2026 13:41:51 -0700 Subject: [PATCH] [Extensions] Fix ScriptInjectionTracker poisoning on withheld access When chrome.scripting.executeScript targets a frame where host permissions are withheld, script execution is deferred until permission is granted via ExtensionActionRunner. Previously, ScriptExecutor unconditionally recorded the injection in ScriptInjectionTracker prior to dispatching the IPC. This prematurely associated the extension with the target process even if the script never executed, potentially allowing a compromised renderer to bypass IPC security checks by impersonating the extension. This CL fixes the issue by ensuring ScriptInjectionTracker accurately reflects when a script is actually permitted to execute: - ScriptExecutor now skips calling WillExecuteCode when page access is evaluated as withheld. - ExtensionActionRunner now records the injection right before running pending scripts upon the user granting permission. - ExtensionActionRunner also updates the tracker in the dynamically allowed case of `OnRequestScriptInjectionPermission` to handle race conditions where permissions are granted while the initial ExecuteCode IPC is in flight to the renderer. TAG=agy Fixed: 501805751 Change-Id: I3973efa8c82787c7b824a6cae56a64b6d7f1a3eb Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8401237 Commit-Queue: Eva Su <[email protected]> Reviewed-by: Solomon Kinard <[email protected]> Cr-Commit-Position: refs/heads/main@{#1699234} --- diff --git a/chrome/browser/extensions/chrome_extension_frame_host.cc b/chrome/browser/extensions/chrome_extension_frame_host.cc index 12b02d09..c3bdc5a 100644 --- a/chrome/browser/extensions/chrome_extension_frame_host.cc +++ b/chrome/browser/extensions/chrome_extension_frame_host.cc @@ -55,7 +55,8 @@ std::move(callback).Run(false); return; } - runner->OnRequestScriptInjectionPermission(extension_id, script_type, + runner->OnRequestScriptInjectionPermission(&receivers_.CurrentTargetFrame(), + extension_id, script_type, run_location, std::move(callback)); } diff --git a/chrome/browser/extensions/extension_action_runner.cc b/chrome/browser/extensions/extension_action_runner.cc index 0dcadbf..2025f3a2 100644 --- a/chrome/browser/extensions/extension_action_runner.cc +++ b/chrome/browser/extensions/extension_action_runner.cc @@ -39,6 +39,7 @@ #include "extensions/browser/permissions/scripting_permissions_modifier.h" #include "extensions/browser/permissions/site_permissions_helper.h" #include "extensions/browser/permissions_manager.h" +#include "extensions/browser/script_injection_tracker.h" #include "extensions/buildflags/buildflags.h" #include "extensions/common/api/extension_action/action_info.h" #include "extensions/common/extension.h" @@ -48,6 +49,7 @@ #include "extensions/common/permissions/permission_set.h" #include "extensions/common/permissions/permissions_data.h" #include "url/origin.h" +#include "url/url_constants.h" #if !BUILDFLAG(IS_ANDROID) #include "chrome/browser/ui/browser_window.h" @@ -58,9 +60,15 @@ namespace extensions { ExtensionActionRunner::PendingScript::PendingScript( + content::RenderFrameHost* frame, + mojom::InjectionType script_type, mojom::RunLocation run_location, ScriptInjectionCallback permit_script) - : run_location(run_location), permit_script(std::move(permit_script)) {} + : frame_id(frame ? frame->GetGlobalId() + : content::GlobalRenderFrameHostId()), + script_type(script_type), + run_location(run_location), + permit_script(std::move(permit_script)) {} ExtensionActionRunner::PendingScript::~PendingScript() = default; @@ -245,6 +253,7 @@ PermissionsData::PageAccess ExtensionActionRunner::RequiresUserConsentForScriptInjection( + content::RenderFrameHost* render_frame_host, const Extension* extension, mojom::InjectionType type) { CHECK(extension); @@ -254,7 +263,12 @@ return PermissionsData::PageAccess::kAllowed; } - GURL url = web_contents()->GetLastCommittedURL(); + GURL url = render_frame_host ? render_frame_host->GetLastCommittedURL() + : web_contents()->GetLastCommittedURL(); + if (render_frame_host && + (url.is_empty() || url.SchemeIs(url::kAboutScheme))) { + url = render_frame_host->GetLastCommittedOrigin().GetURL(); + } int tab_id = sessions::SessionTabHelper::IdForTab(web_contents()).id(); switch (type) { case mojom::InjectionType::kContentScript: @@ -269,12 +283,14 @@ void ExtensionActionRunner::RequestScriptInjection( const Extension* extension, + content::RenderFrameHost* render_frame_host, + mojom::InjectionType script_type, mojom::RunLocation run_location, ScriptInjectionCallback callback) { CHECK(extension); PendingScriptList& list = pending_scripts_[extension->id()]; - list.push_back( - std::make_unique<PendingScript>(run_location, std::move(callback))); + list.push_back(std::make_unique<PendingScript>( + render_frame_host, script_type, run_location, std::move(callback))); // If this was the first entry, we need to notify that a new extension wants // to run. @@ -314,11 +330,31 @@ iter->second.swap(scripts); pending_scripts_.erase(extension->id()); - // Run all pending injections for the given extension. - RunCallbackOnPendingScript(scripts, true); + for (const auto& script : scripts) { + content::RenderFrameHost* frame = nullptr; + bool is_allowed = false; + if (script->frame_id) { + frame = content::RenderFrameHost::FromID(script->frame_id); + is_allowed = frame && (RequiresUserConsentForScriptInjection( + frame, extension, script->script_type) == + PermissionsData::PageAccess::kAllowed); + } else { + is_allowed = RequiresUserConsentForScriptInjection(nullptr, extension, + script->script_type) == + PermissionsData::PageAccess::kAllowed; + } + + if (is_allowed && frame) { + ScriptInjectionTracker::WillExecuteCode( + base::PassKey<ExtensionActionRunner>(), script->script_type, frame, + *extension); + } + std::move(script->permit_script).Run(is_allowed); + } } void ExtensionActionRunner::OnRequestScriptInjectionPermission( + content::RenderFrameHost* render_frame_host, const ExtensionId& extension_id, mojom::InjectionType script_type, mojom::RunLocation run_location, @@ -335,12 +371,22 @@ ++num_page_requests_; - switch (RequiresUserConsentForScriptInjection(extension, script_type)) { + switch (RequiresUserConsentForScriptInjection(render_frame_host, extension, + script_type)) { case PermissionsData::PageAccess::kAllowed: + // Ensure the tracker is updated if the permission was granted after the + // injection was initially dispatched by ScriptExecutor or if it became + // allowed dynamically. + if (render_frame_host) { + ScriptInjectionTracker::WillExecuteCode( + base::PassKey<ExtensionActionRunner>(), script_type, + render_frame_host, *extension); + } std::move(callback).Run(true); break; case PermissionsData::PageAccess::kWithheld: - RequestScriptInjection(extension, run_location, std::move(callback)); + RequestScriptInjection(extension, render_frame_host, script_type, + run_location, std::move(callback)); break; case PermissionsData::PageAccess::kDenied: std::move(callback).Run(false); diff --git a/chrome/browser/extensions/extension_action_runner.h b/chrome/browser/extensions/extension_action_runner.h index a77ff25d..8385ca06 100644 --- a/chrome/browser/extensions/extension_action_runner.h +++ b/chrome/browser/extensions/extension_action_runner.h @@ -17,6 +17,7 @@ #include "base/memory/raw_ptr.h" #include "base/scoped_observation.h" #include "chrome/browser/ui/extensions/reload_page_dialog_controller.h" +#include "content/public/browser/global_routing_id.h" #include "content/public/browser/web_contents_observer.h" #include "extensions/browser/blocked_action_type.h" #include "extensions/browser/extension_action.h" @@ -112,6 +113,7 @@ // Handles mojom::LocalFrameHost::RequestScriptInjectionPermission(). It // replies back with `callback`. void OnRequestScriptInjectionPermission( + content::RenderFrameHost* render_frame_host, const ExtensionId& extension_id, mojom::InjectionType script_type, mojom::RunLocation run_location, @@ -124,12 +126,14 @@ PermissionsData::PageAccess RequiresUserConsentForScriptInjectionForTesting( const Extension* extension, mojom::InjectionType type) { - return RequiresUserConsentForScriptInjection(extension, type); + return RequiresUserConsentForScriptInjection(nullptr, extension, type); } void RequestScriptInjectionForTesting(const Extension* extension, mojom::RunLocation run_location, ScriptInjectionCallback callback) { - return RequestScriptInjection(extension, run_location, std::move(callback)); + return RequestScriptInjection(extension, nullptr, + mojom::InjectionType::kProgrammaticScript, + run_location, std::move(callback)); } void ClearInjectionsForTesting(const Extension& extension) { pending_scripts_.erase(extension.id()); @@ -144,12 +148,20 @@ DoNotResetExtensionActionRunner); struct PendingScript { - PendingScript(mojom::RunLocation run_location, + PendingScript(content::RenderFrameHost* frame, + mojom::InjectionType script_type, + mojom::RunLocation run_location, ScriptInjectionCallback permit_script); PendingScript(const PendingScript&) = delete; PendingScript& operator=(const PendingScript&) = delete; ~PendingScript(); + // The target frame ID where the script is to be injected. + content::GlobalRenderFrameHostId frame_id; + + // The script injection type. + mojom::InjectionType script_type; + // The run location that the script wants to inject at. mojom::RunLocation run_location; @@ -164,12 +176,15 @@ // user consent. If this is true, the caller should then register a request // via RequestScriptInjection(). PermissionsData::PageAccess RequiresUserConsentForScriptInjection( + content::RenderFrameHost* render_frame_host, const Extension* extension, mojom::InjectionType type); // `callback`. The only assumption that can be made about when (or if) // `callback` is run is that, if it is run, it will run on the current page. void RequestScriptInjection(const Extension* extension, + content::RenderFrameHost* render_frame_host, + mojom::InjectionType script_type, mojom::RunLocation run_location, ScriptInjectionCallback callback); diff --git a/extensions/browser/script_executor.cc b/extensions/browser/script_executor.cc index 2a3dac9..2ad16a8 100644 --- a/extensions/browser/script_executor.cc +++ b/extensions/browser/script_executor.cc @@ -20,6 +20,8 @@ #include "base/strings/stringprintf.h" #include "base/types/pass_key.h" #include "components/sessions/content/session_tab_helper.h" +#include "content/public/browser/navigation_controller.h" +#include "content/public/browser/navigation_entry.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/render_process_host.h" #include "content/public/browser/render_view_host.h" @@ -34,6 +36,7 @@ #include "extensions/common/mojom/match_origin_as_fallback.mojom-shared.h" #include "extensions/common/permissions/permissions_data.h" #include "pdf/buildflags.h" +#include "url/url_constants.h" #if BUILDFLAG(ENABLE_PDF) #include "extensions/common/constants.h" @@ -110,15 +113,15 @@ root_frame_token_ = pending_render_frames_[0]->GetFrameToken(); } + int tab_id = -1; + if (host_id_.type == mojom::HostID::HostType::kExtensions) { + tab_id = sessions::SessionTabHelper::IdForTab(web_contents).id(); + } + // If we are to include subframes, iterate over all descendants of frames in // `pending_render_frames_` and add them if they are alive (and not already // contained in `pending_frames`). if (scope == ScriptExecutor::INCLUDE_SUB_FRAMES) { - int tab_id = -1; - if (host_id_.type == mojom::HostID::HostType::kExtensions) { - tab_id = sessions::SessionTabHelper::IdForTab(web_contents).id(); - } - // We iterate over the requested frames. Note we can't use an iterator // as the for loop will mutate `pending_render_frames_`. const size_t requested_frame_count = pending_render_frames_.size(); @@ -131,7 +134,7 @@ } for (content::RenderFrameHost* frame : pending_render_frames_) - SendExecuteCode(pass_key, params.Clone(), frame); + SendExecuteCode(pass_key, params.Clone(), frame, extension, tab_id); if (pending_render_frames_.empty()) { Finish(); @@ -285,7 +288,9 @@ // the number of pending messages. void SendExecuteCode(base::PassKey<ScriptExecutor> pass_key, mojom::ExecuteCodeParamsPtr params, - content::RenderFrameHost* frame) { + content::RenderFrameHost* frame, + const Extension* extension, + int tab_id) { DCHECK(frame->IsRenderFrameLive()); DCHECK(std::ranges::contains(pending_render_frames_, frame)); @@ -300,8 +305,35 @@ case mojom::ExecutionWorld::kUserScript: script_type = ScriptInjectionTracker::ScriptType::kUserScript; } - ScriptInjectionTracker::WillExecuteCode(pass_key, script_type, frame, - host_id_); + + // When the extension's host permission for `frame`'s document is + // withheld, the renderer defers execution and asks the browser for + // permission instead of running the script. In that case the + // ScriptInjectionTracker is updated by ExtensionActionRunner if and when + // the user grants access and the deferred script is permitted to run. + bool record_injection = true; + if (!is_web_view_ && extension) { + GURL url = frame->GetLastCommittedURL(); + if (url.is_empty() || url.SchemeIs(url::kAboutScheme)) { + content::NavigationEntry* pending_entry = + web_contents()->GetController().GetPendingEntry(); + if (pending_entry && !pending_entry->GetURL().is_empty()) { + url = pending_entry->GetURL(); + } else { + url = frame->GetLastCommittedOrigin().GetURL(); + } + } + if (!url.is_empty()) { + record_injection = (extension->permissions_data()->GetPageAccess( + url, tab_id, + /*error=*/nullptr) != + PermissionsData::PageAccess::kWithheld); + } + } + if (record_injection) { + ScriptInjectionTracker::WillExecuteCode(pass_key, script_type, frame, + host_id_); + } } ExtensionWebContentsObserver::GetForWebContents(web_contents()) ->GetLocalFrameChecked(frame) diff --git a/extensions/browser/script_injection_tracker.cc b/extensions/browser/script_injection_tracker.cc index 2a213e36..0fa5fba 100644 --- a/extensions/browser/script_injection_tracker.cc +++ b/extensions/browser/script_injection_tracker.cc @@ -839,6 +839,25 @@ } // static +void ScriptInjectionTracker::WillExecuteCode( + base::PassKey<ExtensionActionRunner> pass_key, + mojom::InjectionType script_type, + content::RenderFrameHost* frame, + const Extension& extension) { + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + TRACE_EVENT("extensions", "ScriptInjectionTracker::WillExecuteCode/3", + ChromeTrackEvent::kRenderProcessHost, *frame->GetProcess(), + ChromeTrackEvent::kChromeExtensionId, + ExtensionIdForTracing(extension.id())); + + // A programmatic injection that was deferred while host access was withheld + // is being permitted to run; this is treated like any other programmatic + // content script injection. + HandleProgrammaticScriptInjection(PassKey(), ScriptType::kContentScript, + frame, extension); +} + +// static void ScriptInjectionTracker::WillGrantActiveTab( base::PassKey<ActiveTabPermissionGranter> pass_key, const Extension& extension, diff --git a/extensions/browser/script_injection_tracker.h b/extensions/browser/script_injection_tracker.h index d7acd29..8f0ba4c 100644 --- a/extensions/browser/script_injection_tracker.h +++ b/extensions/browser/script_injection_tracker.h @@ -12,6 +12,7 @@ #include "extensions/common/extension_id.h" #include "extensions/common/mojom/context_type.mojom-forward.h" #include "extensions/common/mojom/host_id.mojom-forward.h" +#include "extensions/common/mojom/injection_type.mojom-shared.h" #include "url/gurl.h" namespace content { @@ -25,6 +26,7 @@ class ActiveTabPermissionGranter; ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/extensions/script_injection_tracker_browsertest.cc b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
index 376f431..3fc6de6 100644
--- a/chrome/browser/extensions/script_injection_tracker_browsertest.cc
+++ b/chrome/browser/extensions/script_injection_tracker_browsertest.cc
@@ -16,6 +16,8 @@
#include "base/trace_event/trace_log.h"
#include "build/buildflag.h"
#include "chrome/browser/apps/platform_apps/app_browsertest_util.h"
+#include "chrome/browser/extensions/blocked_action_waiter.h"
+#include "chrome/browser/extensions/extension_action_runner.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "chrome/browser/extensions/extension_tab_util.h"
#include "chrome/browser/extensions/extension_util.h"
@@ -48,6 +50,7 @@
#include "extensions/browser/extension_system.h"
#include "extensions/browser/permissions/active_tab_permission_granter.h"
#include "extensions/browser/permissions/permissions_test_util.h"
+#include "extensions/browser/permissions/scripting_permissions_modifier.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/user_script_manager.h"
#include "extensions/common/extension_features.h"
@@ -328,6 +331,212 @@
*background_frame->GetProcess(), extension->id()));
}
+// Tests that a `chrome.scripting.executeScript` call which is deferred because
+// the extension's host permission is withheld is only recorded by
+// ScriptInjectionTracker once the user grants access and the script actually
+// runs.
+IN_PROC_BROWSER_TEST_F(ScriptInjectionTrackerBrowserTest,
+ ProgrammaticContentScript_WithheldHostPermission) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Install a test extension with a host permission and the scripting
+ // permission, but no manifest content scripts.
+ TestExtensionDir dir;
+ const char kManifest[] = R"(
+ {
+ "name": "ProgrammaticContentScript_WithheldHostPermission",
+ "version": "1.0",
+ "manifest_version": 3,
+ "permissions": ["scripting", "tabs"],
+ "host_permissions": ["http://foo.com/*"],
+ "background": {"service_worker": "worker.js"}
+ } )";
+ dir.WriteManifest(kManifest);
+ dir.WriteFile(FILE_PATH_LITERAL("worker.js"), "");
+ const Extension* extension = LoadExtension(dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ // Withhold the extension's host permissions (equivalent to the user choosing
+ // "On click" site access).
+ ScriptingPermissionsModifier(profile(), extension)
+ .SetWithholdHostPermissions(true);
+
+ // Navigate to a page that matches the (withheld) host permission.
+ GURL page_url = embedded_test_server()->GetURL("foo.com", "/title1.html");
+ content::WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, page_url));
+
+ content::RenderProcessHost* page_process =
+ web_contents->GetPrimaryMainFrame()->GetProcess();
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *page_process, extension->id()));
+
+ ExtensionActionRunner* runner =
+ ExtensionActionRunner::GetForWebContents(web_contents);
+ ASSERT_TRUE(runner);
+
+ // Ask the extension to inject a programmatic content script. Because the host
+ // permission is withheld the renderer will defer execution and request
+ // permission from the browser; wait for that request to be queued.
+ const char kInjected[] = "injected";
+ ExtensionTestMessageListener inject_listener(kInjected);
+ {
+ BlockedActionWaiter blocked_action_waiter(runner);
+ int tab_id = ExtensionTabUtil::GetTabId(web_contents);
+ std::string background_script = content::JsReplace(
+ R"(chrome.scripting.executeScript({
+ target: {tabId: $1},
+ func: () => {
+ document.body.innerText = 'content script has run';
+ chrome.test.sendMessage('injected');
+ }
+ }).catch(() => {});)",
+ tab_id);
+ ASSERT_TRUE(BackgroundScriptExecutor::ExecuteScriptAsync(
+ profile(), extension->id(), background_script));
+ blocked_action_waiter.Wait();
+ }
+
+ // The injection was deferred pending user action, so the script has not run
+ // and the tracker should not yet associate the extension with the page's
+ // process.
+ EXPECT_TRUE(runner->WantsToRun(extension));
+ EXPECT_FALSE(inject_listener.was_satisfied());
+ EXPECT_EQ("This page has no title.",
+ content::EvalJs(web_contents, "document.body.innerText"));
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *page_process, extension->id()));
+
+ // Grant the extension access to the page (as if the user clicked the action)
+ // and wait for the deferred script to run.
+ runner->RunAction(extension, /*grant_tab_permissions=*/true);
+ ASSERT_TRUE(inject_listener.WaitUntilSatisfied());
+
+ EXPECT_FALSE(runner->WantsToRun(extension));
+ EXPECT_EQ("content script has run",
+ content::EvalJs(web_contents, "document.body.innerText"));
+ EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *page_process, extension->id()));
+}
+
+// Tests that a `chrome.scripting.executeScript` call with `allFrames: true`
+// on a page where host permissions are withheld correctly defers injection and
+// once permission is granted, updates ScriptInjectionTracker for both the main
+// frame and subframe processes.
+IN_PROC_BROWSER_TEST_F(
+ ScriptInjectionTrackerBrowserTest,
+ ProgrammaticContentScript_WithheldHostPermission_Subframe) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ TestExtensionDir dir;
+ const char kManifest[] = R"(
+ {
+ "name": "ProgrammaticContentScript_WithheldHostPermission_Subframe",
+ "version": "1.0",
+ "manifest_version": 3,
+ "permissions": ["scripting", "tabs"],
+ "host_permissions": ["http://foo.com/*"],
+ "background": {"service_worker": "worker.js"}
+ } )";
+ dir.WriteManifest(kManifest);
+ dir.WriteFile(FILE_PATH_LITERAL("worker.js"), "");
+ const Extension* extension = LoadExtension(dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ ScriptingPermissionsModifier(profile(), extension)
+ .SetWithholdHostPermissions(true);
+
+ GURL page_url = embedded_test_server()->GetURL("foo.com", "/title1.html");
+ content::WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, page_url));
+
+ GURL iframe_url = embedded_test_server()->GetURL("foo.com", "/title2.html");
+ const char kScript[] = R"(
+ let iframe = document.createElement('iframe');
+ iframe.src = $1;
+ document.body.appendChild(iframe);
+ )";
+ ASSERT_TRUE(ExecJs(web_contents, content::JsReplace(kScript, iframe_url)));
+ content::WaitForLoadStop(web_contents);
+
+ content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+ content::RenderFrameHost* subframe = content::ChildFrameAt(main_frame, 0);
+ ASSERT_TRUE(subframe);
+
+ content::RenderProcessHost* main_process = main_frame->GetProcess();
+ content::RenderProcessHost* subframe_process = subframe->GetProcess();
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_process, extension->id()));
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *subframe_process, extension->id()));
+
+ ExtensionActionRunner* runner =
+ ExtensionActionRunner::GetForWebContents(web_contents);
+ ASSERT_TRUE(runner);
+
+ const char kMainFrameInjected[] = "mainframe_injected";
+ ExtensionTestMessageListener main_frame_listener(kMainFrameInjected);
+
+ // Ask the extension to inject into all frames. Because host permissions
+ // are withheld on the page, the main frame injection is deferred pending
+ // user permission. The subframe is not allowed to run either.
+ {
+ BlockedActionWaiter blocked_action_waiter(runner);
+ int tab_id = ExtensionTabUtil::GetTabId(web_contents);
+ std::string background_script = content::JsReplace(
+ R"(chrome.scripting.executeScript({
+ target: {tabId: $1, allFrames: true},
+ func: () => {
+ if (window === window.top) {
+ chrome.test.sendMessage('mainframe_injected');
+ }
+ }
+ }).catch(() => {});)",
+ tab_id);
+ ASSERT_TRUE(BackgroundScriptExecutor::ExecuteScriptAsync(
+ profile(), extension->id(), background_script));
+ blocked_action_waiter.Wait();
+ }
+
+ EXPECT_TRUE(runner->WantsToRun(extension));
+ EXPECT_FALSE(main_frame_listener.was_satisfied());
+ // While withheld, neither main frame nor subframe process should be tracked.
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_process, extension->id()));
+ EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *subframe_process, extension->id()));
+
+ // Grant the extension access to the tab and run the blocked action.
+ runner->RunAction(extension, /*grant_tab_permissions=*/true);
+ ASSERT_TRUE(main_frame_listener.WaitUntilSatisfied());
+ EXPECT_FALSE(runner->WantsToRun(extension));
+
+ // The main frame injection was resumed.
+ EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *main_process, extension->id()));
+
+ // Now inject into the subframe now that tab permissions are granted.
+ const char kInjected[] = "subframe_injected";
+ ExtensionTestMessageListener inject_listener(kInjected);
+ int tab_id = ExtensionTabUtil::GetTabId(web_contents);
+ std::string inject_subframe_script = content::JsReplace(
+ R"(chrome.scripting.executeScript({
+ target: {tabId: $1, allFrames: true},
+ func: () => {
+ if (window !== window.top) {
+ chrome.test.sendMessage('subframe_injected');
+ }
+ }
+ });)",
+ tab_id);
+ ASSERT_TRUE(BackgroundScriptExecutor::ExecuteScriptAsync(
+ profile(), extension->id(), inject_subframe_script));
+ ASSERT_TRUE(inject_listener.WaitUntilSatisfied());
+
+ EXPECT_TRUE(ScriptInjectionTracker::DidProcessRunContentScriptFromExtension(
+ *subframe_process, extension->id()));
+}
+
// Tests tracking of user scripts through the ScriptExecutor.
// The vast majority of implementation is the same for content script and user
// script tracking, so this is the main spot we explicitly test user script
Original Bug Report
ScriptInjectionTracker poisoning via withheld permissions
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 Chrome Security team.
Overview: PermissionsData::CanAccessPage incorrectly treats withheld permissions as allowed during programmatic script injection. This causes the browser to prematurely update the ScriptInjectionTracker, allowing a compromised renderer to bypass IPC security checks and impersonate an extension’s content script.
Affected files:
extensions/common/permissions/permissions_data.ccextensions/browser/script_injection_tracker.ccextensions/browser/script_executor.ccextensions/browser/scripting_utils.ccchrome/browser/extensions/api/scripting/scripting_api.ccchrome/browser/extensions/api/tabs/tabs_api.cc
Estimated timestamp from git blame: 2024-10-08
Summary
A logical flaw in how the browser handles programmatic script injections for withheld permissions allows an unprivileged, compromised renderer to escalate privileges and impersonate an extension’s content script.
When an extension attempts to programmatically inject a script into a page where the user has set access to “On click” (resulting in a withheld permission), the browser-side permission check PermissionsData::CanAccessPage incorrectly evaluates this as permissible. Consequently, the browser proceeds with the injection request and updates the ScriptInjectionTracker to record that the target renderer process is authorized to execute scripts on behalf of the extension.
Although the uncompromised renderer correctly blocks the injection pending user consent, the browser-side tracker is irreversibly poisoned. A compromised renderer can leverage this poisoned state to bypass IPC authorization checks, granting it unauthorized access to the extension’s storage APIs and messaging capabilities.
Vulnerability Details
- Flawed Permission Check: In
extensions/common/permissions/permissions_data.cc,PermissionsData::CanAccessPagechecks the permission state viaGetPageAccess. However, it incorrectly returnstrueif the access is withheld:// extensions/common/permissions/permissions_data.cc:423 return result == PageAccess::kAllowed || result == PageAccess::kWithheld; - Premature Tracker Update: Because
CanAccessPagereturnstrue, programmatic injection requests (e.g.,chrome.scripting.executeScript) proceed to theScriptExecutor. For JavaScript injections,ScriptExecutor::Handler::SendExecuteCodeunconditionally callsScriptInjectionTracker::WillExecuteCodebefore dispatching the IPC to the renderer (extensions/browser/script_executor.cc:269). - Irreversible Poisoning:
WillExecuteCodeadds the extension ID to theRenderProcessHostUserDatafor the target renderer process.ScriptInjectionTrackerdoes not have a mechanism to revert this state; once a renderer process is marked as having run a script for an extension, it retains that authorization for its lifetime (extensions/browser/script_injection_tracker.cc:278). - Authorization Bypass: A compromised renderer process can now forge Extension API IPCs (e.g., to access
chrome.storage) or messaging IPCs (e.g.,chrome.runtime.sendMessage). When the browser validates these requests, checks likeutil::CanRendererActOnBehalfOfExtension(extensions/browser/extension_util.cc:469) andMessageServiceBindings::IsValidMessagingSource(extensions/browser/api/messaging/message_service_bindings.cc:120) rely onScriptInjectionTracker::DidProcessRunContentScriptFromExtension. Because the tracker was poisoned in step 3, these checks incorrectly returntrue, allowing the spoofed IPCs.
Impact
This vulnerability breaks the ContentScriptTracker security boundary designed to prevent compromised renderers from spoofing extension identities (as documented in docs/security/compromised-renderers.md). A compromised renderer can escalate its privileges to access extension storage and intercept or send extension messages, potentially leading to cross-origin data exfiltration or further attacks against the extension.
Potential Reproduction Steps
Note: These are suggested steps to trigger the vulnerability; a fully working Proof of Concept exploit has not been executed by our tooling.
- Install an extension with
host_permissionsforhttps://example.com/*and thescriptingpermission. - Set the extension’s “Site access” to “On click”.
- Configure the extension to call
chrome.scripting.executeScriptprogrammatically (e.g., via achrome.tabs.onUpdatedlistener) when navigating tohttps://example.com. - Navigate to
https://example.com. The extension triggers the injection, but the script is withheld. - Assume control of the renderer process for
https://example.com(e.g., via a memory corruption vulnerability). - From the compromised renderer, forge an IPC message to the browser process attempting to read
chrome.storage.localor send a message viachrome.runtime.sendMessage, specifying the extension’s ID as the source. - The browser will incorrectly authorize the IPC, granting access to the extension’s privileges.
Suggested Fix
PermissionsData::CanAccessPage should likely only return true for PageAccess::kAllowed. If kWithheld must be handled specially to allow queuing on the renderer side, the ScriptExecutor or ScriptInjectionTracker must be updated to defer recording the injection until the renderer explicitly confirms execution, rather than updating the tracker unconditionally before dispatching the ExecuteCode IPC.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.