CVE-2026-12445
Overview
Files Changed
chrome/browser/extensions/api/user_scripts/user_scripts_apitest.ccchrome/test/data/extensions/api_test/user_scripts/execute/script.jschrome/test/data/extensions/api_test/user_scripts/navigation_race/background.jschrome/test/data/extensions/api_test/user_scripts/navigation_race/manifest.jsonchrome/test/data/extensions/api_test/user_scripts/navigation_race/script.js
Patch
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
Regression Test / PoC
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';
Original Bug Report
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.ccextensions/browser/script_executor.ccextensions/browser/extension_api_frame_id_map.ccextensions/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.
- Install an extension with the
userScriptspermission and host permission forhttps://attacker.example/*. Ensure Developer Mode is enabled. - Create a tab with a subframe at
https://attacker.example/sub. - Trigger
chrome.userScripts.execute()targeting the subframe’s ID, requesting a large script file from the extension to maximize the race window. - While the file load is pending in the browser, navigate the subframe to a sensitive cross-origin site (e.g.,
https://victim.example/). - Observe that
ScriptInjectionTracker::WillExecuteCode()is called for thevictim.exampleprocess despite the extension lacking permissions for that origin. - (Post-poisoning) Verify that a compromised renderer in the
victim.exampleprocess can successfully invoke restricted extension APIs by claiming the identity of the attacker’s extension.
Recommended Fix
Follow the safe pattern implemented in scripting.executeScript (see extensions/browser/api/scripting/scripting_api.cc):
- Do not bind
ScriptExecutor*orframe_idsacross the asynchronous file load boundary. - In
DidLoadResources, re-invokescripting::CanAccessTarget()to re-verify permissions and re-acquire a validScriptExecutorand set of target frames before proceeding withscripting::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.