Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Bug ClassUAF
Tracker513199795
Fix commit4e3702db97c2 (chromium/src) +144/-32
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-16

Files Changed

  • chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
  • chrome/test/data/extensions/api_test/user_scripts/execute/script.js
  • chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js
  • chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json
  • chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
From 4e3702db97c2dd7de414837c8056e847bce2fc3a Mon Sep 17 00:00:00 2001
From: Tim Judkins <[email protected]>
Date: Fri, 12 Jun 2026 02:13:48 -0700
Subject: [PATCH] [Extensions] Move userscript access validation after async file load

This CL moves the access validation checks for `userScripts.execute` to
happen after any associated asynchronous file load that happens,
aligning it with the similar checks we do for `scripting.executeScript`.
Previously if a cross-origin navigation happened between the time of
checking access and actual execution, the script would still be blocked
by later checks, but the process could get incorrectly marked as having
executed a user script. This CL resolves this case and adds a test to
cover it.

As an added bonus resolves a potential use-after-free crash that might
have happened if the tab was closed during the async load, due to the
raw ScriptExecutor pointer no longer needing to be passed through to
UserScriptsExecuteFunction::DidLoadResources.

Additionally a small tweak was needed for the existing
userScripts.execute test. It tests some error messages around access
being blocked, but it was specifying a script file that didn't actually
exist. Since the access check has been moved to after the file load,
this resulted in an error for the file not existing instead of access.
To resolve this the `script.js` file has been added to the test
extension so the file load actually succeeds.

Fixed: 513199795
Change-Id: Ide343225a8c531acc6a11a47be81fe94a9fb08a0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7924186
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Tim <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1645782}
---

diff --git a/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc b/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
index 9e7c447..4c8f0ef 100644
--- a/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
+++ b/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
@@ -19,10 +19,12 @@
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "extensions/browser/background_script_executor.h"
+#include "extensions/browser/extension_file_task_runner.h"
 #include "extensions/browser/extension_prefs.h"
 #include "extensions/browser/extension_registry.h"
 #include "extensions/browser/extension_util.h"
 #include "extensions/browser/renderer_startup_helper.h"
+#include "extensions/browser/script_injection_tracker.h"
 #include "extensions/browser/user_script_manager.h"
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/extension_features.h"
@@ -199,6 +201,68 @@
       << message_;
 }
 
+// Tests that executing user scripts correctly handles a cross-document
+// navigation occurring during the asynchronous file loading phase, preventing
+// unauthorized script execution or improper marking of the new process.
+IN_PROC_BROWSER_TEST_F(UserScriptsAPITest,
+                       ExecuteUserScripts_CrossDocumentNavigationRace) {
+  ExtensionTestMessageListener ready_listener("ready",
+                                              ReplyBehavior::kWillReply);
+  ExtensionTestMessageListener tab_created_listener("tab_created",
+                                                    ReplyBehavior::kWillReply);
+  ExtensionTestMessageListener execute_called_listener(
+      "execute_called", ReplyBehavior::kWillReply);
+
+  ResultCatcher catcher;
+
+  const Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("user_scripts/navigation_race"));
+  ASSERT_TRUE(extension);
+
+  user_scripts_test_util::SetUserScriptsAPIAllowed(profile(), extension->id(),
+                                                   /*allowed=*/true);
+
+  ASSERT_TRUE(ready_listener.WaitUntilSatisfied());
+  ready_listener.Reply(
+      embedded_test_server()->GetURL("a.com", "/empty.html").spec());
+
+  ASSERT_TRUE(tab_created_listener.WaitUntilSatisfied());
+
+  // Block the file task runner.
+  base::WaitableEvent block_file_task_runner;
+  GetExtensionFileTaskRunner()->PostTask(
+      FROM_HERE, base::BindLambdaForTesting([&]() {
+        base::ScopedAllowBaseSyncPrimitivesForTesting allow_blocking;
+        block_file_task_runner.Wait();
+      }));
+
+  tab_created_listener.Reply("");
+
+  ASSERT_TRUE(execute_called_listener.WaitUntilSatisfied());
+
+  // Now userScripts.execute() has been called. The file task is queued.
+  // Navigate the tab to c.com, which the extension does not have permission
+  // for.
+  content::WebContents* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(
+      web_contents, embedded_test_server()->GetURL("c.com", "/empty.html")));
+
+  // Unblock file task runner
+  block_file_task_runner.Signal();
+
+  execute_called_listener.Reply("");
+
+  // Wait for the result from the extension. The execute should result in an
+  // expected error due to the script being blocked after the navigation.
+  EXPECT_TRUE(catcher.GetNextResult()) << catcher.message();
+
+  // Verify that the new document's process was not marked as having run user
+  // scripts.
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunUserScriptFromExtension(
+      *main_frame->GetProcess(), extension->id()));
+}
+
 // TODO(crbug.com/335421977): Flaky on "Linux ChromiumOS MSan Tests".
 #if BUILDFLAG(IS_CHROMEOS) && defined(MEMORY_SANITIZER)
 #define MAYBE_ConfigureWorld DISABLED_ConfigureWorld
diff --git a/chrome/test/data/extensions/api_test/user_scripts/execute/script.js b/chrome/test/data/extensions/api_test/user_scripts/execute/script.js
new file mode 100644
index 0000000..214229f
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/execute/script.js
@@ -0,0 +1,5 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+document.title = 'Injected';
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js
new file mode 100644
index 0000000..a4549227
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js
@@ -0,0 +1,33 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+chrome.test.getConfig(async function(config) {
+  const url_a = await chrome.test.sendMessage('ready');
+  const tab = await chrome.tabs.create({url: url_a});
+  await chrome.test.sendMessage('tab_created');
+
+  // C++ replies when the C++ side has blocked the file task runner.
+  // We now call execute.
+  const promise = chrome.userScripts.execute({
+    target: {tabId: tab.id},
+    js: [{file: 'script.js'}],
+  });
+
+  // Let C++ know we called execute so it can navigate the tab.
+  await chrome.test.sendMessage('execute_called');
+
+  try {
+    await promise;
+    chrome.test.fail('Execution succeeded incorrectly');
+  } catch (e) {
+    // Because the extension doesn't have the 'tabs' permission, the
+    // browser-side check generates a generic error rather than leaking the
+    // cross-origin URL.
+    if (e.message.includes('Cannot access contents of the page')) {
+      chrome.test.succeed();
+    } else {
+      chrome.test.fail('Unexpected error: ' + e.message);
+    }
+  }
+});
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json
new file mode 100644
index 0000000..b3de9f7
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json
@@ -0,0 +1,14 @@
+{
+  "name": "UserScripts Navigation Race",
+  "version": "1.0",
+  "manifest_version": 3,
+  "background": {
+    "service_worker": "background.js"
+  },
+  "permissions": [
+    "userScripts"
+  ],
+  "host_permissions": [
+    "http://a.com/*"
+  ]
+}
\ No newline at end of file
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
new file mode 100644
index 0000000..214229f
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
@@ -0,0 +1,5 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc b/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
index 9e7c447..4c8f0ef 100644
--- a/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
+++ b/chrome/browser/extensions/api/user_scripts/user_scripts_apitest.cc
@@ -19,10 +19,12 @@
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "extensions/browser/background_script_executor.h"
+#include "extensions/browser/extension_file_task_runner.h"
 #include "extensions/browser/extension_prefs.h"
 #include "extensions/browser/extension_registry.h"
 #include "extensions/browser/extension_util.h"
 #include "extensions/browser/renderer_startup_helper.h"
+#include "extensions/browser/script_injection_tracker.h"
 #include "extensions/browser/user_script_manager.h"
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/extension_features.h"
@@ -199,6 +201,68 @@
       << message_;
 }
 
+// Tests that executing user scripts correctly handles a cross-document
+// navigation occurring during the asynchronous file loading phase, preventing
+// unauthorized script execution or improper marking of the new process.
+IN_PROC_BROWSER_TEST_F(UserScriptsAPITest,
+                       ExecuteUserScripts_CrossDocumentNavigationRace) {
+  ExtensionTestMessageListener ready_listener("ready",
+                                              ReplyBehavior::kWillReply);
+  ExtensionTestMessageListener tab_created_listener("tab_created",
+                                                    ReplyBehavior::kWillReply);
+  ExtensionTestMessageListener execute_called_listener(
+      "execute_called", ReplyBehavior::kWillReply);
+
+  ResultCatcher catcher;
+
+  const Extension* extension =
+      LoadExtension(test_data_dir_.AppendASCII("user_scripts/navigation_race"));
+  ASSERT_TRUE(extension);
+
+  user_scripts_test_util::SetUserScriptsAPIAllowed(profile(), extension->id(),
+                                                   /*allowed=*/true);
+
+  ASSERT_TRUE(ready_listener.WaitUntilSatisfied());
+  ready_listener.Reply(
+      embedded_test_server()->GetURL("a.com", "/empty.html").spec());
+
+  ASSERT_TRUE(tab_created_listener.WaitUntilSatisfied());
+
+  // Block the file task runner.
+  base::WaitableEvent block_file_task_runner;
+  GetExtensionFileTaskRunner()->PostTask(
+      FROM_HERE, base::BindLambdaForTesting([&]() {
+        base::ScopedAllowBaseSyncPrimitivesForTesting allow_blocking;
+        block_file_task_runner.Wait();
+      }));
+
+  tab_created_listener.Reply("");
+
+  ASSERT_TRUE(execute_called_listener.WaitUntilSatisfied());
+
+  // Now userScripts.execute() has been called. The file task is queued.
+  // Navigate the tab to c.com, which the extension does not have permission
+  // for.
+  content::WebContents* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(
+      web_contents, embedded_test_server()->GetURL("c.com", "/empty.html")));
+
+  // Unblock file task runner
+  block_file_task_runner.Signal();
+
+  execute_called_listener.Reply("");
+
+  // Wait for the result from the extension. The execute should result in an
+  // expected error due to the script being blocked after the navigation.
+  EXPECT_TRUE(catcher.GetNextResult()) << catcher.message();
+
+  // Verify that the new document's process was not marked as having run user
+  // scripts.
+  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_FALSE(ScriptInjectionTracker::DidProcessRunUserScriptFromExtension(
+      *main_frame->GetProcess(), extension->id()));
+}
+
 // TODO(crbug.com/335421977): Flaky on "Linux ChromiumOS MSan Tests".
 #if BUILDFLAG(IS_CHROMEOS) && defined(MEMORY_SANITIZER)
 #define MAYBE_ConfigureWorld DISABLED_ConfigureWorld
diff --git a/chrome/test/data/extensions/api_test/user_scripts/execute/script.js b/chrome/test/data/extensions/api_test/user_scripts/execute/script.js
new file mode 100644
index 0000000..214229f
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/execute/script.js
@@ -0,0 +1,5 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+document.title = 'Injected';
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js
new file mode 100644
index 0000000..a4549227
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/background.js
@@ -0,0 +1,33 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+chrome.test.getConfig(async function(config) {
+  const url_a = await chrome.test.sendMessage('ready');
+  const tab = await chrome.tabs.create({url: url_a});
+  await chrome.test.sendMessage('tab_created');
+
+  // C++ replies when the C++ side has blocked the file task runner.
+  // We now call execute.
+  const promise = chrome.userScripts.execute({
+    target: {tabId: tab.id},
+    js: [{file: 'script.js'}],
+  });
+
+  // Let C++ know we called execute so it can navigate the tab.
+  await chrome.test.sendMessage('execute_called');
+
+  try {
+    await promise;
+    chrome.test.fail('Execution succeeded incorrectly');
+  } catch (e) {
+    // Because the extension doesn't have the 'tabs' permission, the
+    // browser-side check generates a generic error rather than leaking the
+    // cross-origin URL.
+    if (e.message.includes('Cannot access contents of the page')) {
+      chrome.test.succeed();
+    } else {
+      chrome.test.fail('Unexpected error: ' + e.message);
+    }
+  }
+});
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json
new file mode 100644
index 0000000..b3de9f7
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.json
@@ -0,0 +1,14 @@
+{
+  "name": "UserScripts Navigation Race",
+  "version": "1.0",
+  "manifest_version": 3,
+  "background": {
+    "service_worker": "background.js"
+  },
+  "permissions": [
+    "userScripts"
+  ],
+  "host_permissions": [
+    "http://a.com/*"
+  ]
+}
\ No newline at end of file
diff --git a/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
new file mode 100644
index 0000000..214229f
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
@@ -0,0 +1,5 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+document.title = 'Injected';
Loading diff…

Original Bug Report

reported by [email protected]

TOCTOU and Use-After-Free in userScripts.execute() leads to process poisoning

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A Time-of-Check to Time-of-Use (TOCTOU) vulnerability in the userScripts.execute() API allows a malicious extension to bypass permission checks when a target frame navigates cross-origin during a script file load. This results in the irreversible poisoning of the browser-side ScriptInjectionTracker for the victim process, potentially allowing a compromised renderer to impersonate the extension and access its private data and APIs.

Affected files:

  • extensions/browser/api/user_scripts/user_scripts_api.cc
  • extensions/browser/script_executor.cc
  • extensions/browser/extension_api_frame_id_map.cc
  • extensions/browser/script_injection_tracker.cc

Estimated timestamp from git blame: 2025-01-09

Description

A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the userScripts.execute() extension API (introduced in extensions/browser/api/user_scripts/user_scripts_api.cc). The vulnerability occurs because the API validates target-frame permissions before performing an asynchronous file load but fails to re-validate them before proceeding with the script injection.

Root Cause Analysis

In UserScriptsExecuteFunction::Run(), the API first validates whether the extension has permission to inject scripts into the target frames using scripting::CanAccessTarget(). If the injection involves files, it initiates an asynchronous file load via scripting::CheckAndLoadFiles(). The results of the initial permission check, including the target frame_ids and a raw ScriptExecutor*, are bound into the callback DidLoadResources().

During the window between the initial permission check and the completion of the file load, a target frame can navigate to a different (cross-origin) site. When DidLoadResources() eventually runs, it proceeds to call Execute() and then scripting::ExecuteScript() using the stale frame_ids without re-verifying permissions against the current state of the frames.

Inside the ScriptExecutor::Handler (which manage the injection), the browser re-resolves the integer frame_id to its current RenderFrameHost using ExtensionApiFrameIdMap::GetRenderFrameHostById(). This method uses an unsafe lookup that returns the current RenderFrameHost for the frame tree node, even if it has navigated cross-process.

Impact: ScriptInjectionTracker Poisoning

Before the execution IPC is sent to the renderer, the browser calls ScriptInjectionTracker::WillExecuteCode(). This call irreversibly marks the RenderProcessHost associated with the victim frame as having executed a script from the extension.

Even if the renderer-side security checks (e.g., ProgrammaticScriptInjector::CanExecuteOnFrame()) later block the actual script execution because the extension lacks host permission for the new origin, the browser-side poisoning has already occurred.

An attacker with a compromised renderer in the victim process can then leverage this state to bypass several browser-side security gates, such as CanRendererActOnBehalfOfExtension(). This allows the compromised renderer to:

  • Invoke extension APIs restricted to content/user script contexts (e.g., chrome.storage).
  • Open messaging channels to the extension’s background page.
  • Access sensitive data or privileges granted to the extension’s scripts.

Secondary Issue: Potential Use-After-Free

UserScriptsExecuteFunction::Run() binds a raw ScriptExecutor* to the DidLoadResources callback. Since ScriptExecutor is typically owned by a TabHelper associated with a WebContents, closing the tab during the asynchronous file load will result in a dangling pointer. When DidLoadResources or Execute eventually dereferences this pointer, it will cause a Use-After-Free (UAF) crash in the browser process.

Suggested Reproduction Steps

Note: These are potential steps as our analysis is based on code review.

  1. Install an extension with the userScripts permission and host permission for https://attacker.example/*. Ensure Developer Mode is enabled.
  2. Create a tab with a subframe at https://attacker.example/sub.
  3. Trigger chrome.userScripts.execute() targeting the subframe’s ID, requesting a large script file from the extension to maximize the race window.
  4. While the file load is pending in the browser, navigate the subframe to a sensitive cross-origin site (e.g., https://victim.example/).
  5. Observe that ScriptInjectionTracker::WillExecuteCode() is called for the victim.example process despite the extension lacking permissions for that origin.
  6. (Post-poisoning) Verify that a compromised renderer in the victim.example process can successfully invoke restricted extension APIs by claiming the identity of the attacker’s extension.

Follow the safe pattern implemented in scripting.executeScript (see extensions/browser/api/scripting/scripting_api.cc):

  1. Do not bind ScriptExecutor* or frame_ids across the asynchronous file load boundary.
  2. In DidLoadResources, re-invoke scripting::CanAccessTarget() to re-verify permissions and re-acquire a valid ScriptExecutor and set of target frames before proceeding with scripting::ExecuteScript().

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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