Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassUAF
Tracker501805751
Fix commit505dce6554fb (chromium/src) +353/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
switch
chrome/browser/extensions/extension_action_runner.cc
modified
for
chrome/browser/extensions/extension_action_runner.cc
modified
if
chrome/browser/extensions/extension_action_runner.cc
modified
if
extensions/browser/script_executor.cc
modified
ActiveTabPermissionGranter
extensions/browser/script_injection_tracker.h
modified

Files Changed

  • extensions/browser/script_executor.cc
  • extensions/browser/script_injection_tracker.h
  • extensions/browser/script_injection_tracker.cc
  • chrome/browser/extensions/script_injection_tracker_browsertest.cc
  • chrome/browser/extensions/extension_action_runner.h
  • chrome/browser/extensions/extension_action_runner.cc
  • chrome/browser/extensions/chrome_extension_frame_host.cc
From 505dce6554fbc309c42f492706da8f4916fdda66 Mon Sep 17 00:00:00 2001
From: Eva Su <evasu@chromium.org>
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 <evasu@chromium.org>
Reviewed-by: Solomon Kinard <solomonkinard@chromium.org>
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)
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • extensions/browser/script_injection_tracker.cc
  • extensions/browser/script_executor.cc
  • extensions/browser/scripting_utils.cc
  • chrome/browser/extensions/api/scripting/scripting_api.cc
  • chrome/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

  1. Flawed Permission Check: In extensions/common/permissions/permissions_data.cc, PermissionsData::CanAccessPage checks the permission state via GetPageAccess. However, it incorrectly returns true if the access is withheld:
    // extensions/common/permissions/permissions_data.cc:423
    return result == PageAccess::kAllowed || result == PageAccess::kWithheld;
    
  2. Premature Tracker Update: Because CanAccessPage returns true, programmatic injection requests (e.g., chrome.scripting.executeScript) proceed to the ScriptExecutor. For JavaScript injections, ScriptExecutor::Handler::SendExecuteCode unconditionally calls ScriptInjectionTracker::WillExecuteCode before dispatching the IPC to the renderer (extensions/browser/script_executor.cc:269).
  3. Irreversible Poisoning: WillExecuteCode adds the extension ID to the RenderProcessHostUserData for the target renderer process. ScriptInjectionTracker does 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).
  4. 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 like util::CanRendererActOnBehalfOfExtension (extensions/browser/extension_util.cc:469) and MessageServiceBindings::IsValidMessagingSource (extensions/browser/api/messaging/message_service_bindings.cc:120) rely on ScriptInjectionTracker::DidProcessRunContentScriptFromExtension. Because the tracker was poisoned in step 3, these checks incorrectly return true, 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.

  1. Install an extension with host_permissions for https://example.com/* and the scripting permission.
  2. Set the extension’s “Site access” to “On click”.
  3. Configure the extension to call chrome.scripting.executeScript programmatically (e.g., via a chrome.tabs.onUpdated listener) when navigating to https://example.com.
  4. Navigate to https://example.com. The extension triggers the injection, but the script is withheld.
  5. Assume control of the renderer process for https://example.com (e.g., via a memory corruption vulnerability).
  6. From the compromised renderer, forge an IPC message to the browser process attempting to read chrome.storage.local or send a message via chrome.runtime.sendMessage, specifying the extension’s ID as the source.
  7. 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.

View on issue tracker