CVE-2026-113162
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/renderer/actor/script_tool.cc |
modified | |
ActorUafRegressionScriptToolBrowserTestchrome/browser/actor/tools/uaf_regression_browsertest.cc |
modified | |
ActorUafRegressionScriptToolBrowserTestchrome/browser/actor/tools/uaf_regression_browsertest.cc |
modified | |
ifcomponents/test/data/actor/script_tool_uaf_inner.html |
modified |
Files Changed
third_party/blink/renderer/core/script_tools/model_context.hthird_party/blink/renderer/core/script_tools/model_context.cccomponents/test/data/actor/uaf_mid.htmlthird_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.htmlcomponents/test/data/actor/script_tool_uaf_inner.htmlchrome/renderer/actor/script_tool.cccomponents/test/data/actor/uaf_outer.htmlchrome/browser/actor/tools/uaf_regression_browsertest.cc
Patch
From 1d82312be9e18813ceacb379339d84b09ee93ced Mon Sep 17 00:00:00 2001 From: mark a. foltz <[email protected]> Date: Fri, 25 Sep 2026 13:47:43 -0700 Subject: [PATCH] [actor] Prevent UAF in ScriptTool and ModelContext When executing or cancelling a WebMCP tool, ScriptTool::Execute() and ScriptTool::Cancel() previously passed execution_id_.value() by const reference down to ModelContext::ExecuteTool() / CancelTool(). Because ModelContext::ExecuteV8Tool() (via tool_function->Invoke()) and ModelContext::CancelTool() (via abort_controller->abort()) synchronously execute page JavaScript, the handler can destroy ToolExecutor and ScriptTool while ModelContext is still on the call stack and holding a dangling reference to ScriptTool::execution_id_. This CL: 1. Copies execution_id_ onto the stack in ScriptTool::Execute() and ScriptTool::Cancel() before calling into ModelContext. 2. Passes base::UnguessableToken invocation_id by value across ModelContext::ExecuteTool(), CancelTool(), ExecuteDeclarativeTool(), and ExecuteV8Tool(). 3. Adds browser tests in ActorUafRegressionScriptToolBrowserTest verifying synchronous frame detachment during ScriptTool::Execute() and ScriptTool::Cancel(). 4. Adds web platform tests for synchronous frame detachment during executeTool(), both directly and through the signal handler. Fixed: 513147880 Change-Id: I70c08e3afe425a278ce00bf4b25cb9a4a804906a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8430470 Reviewed-by: Dominic Farolino <[email protected]> Commit-Queue: Mark Foltz <[email protected]> Reviewed-by: Dave Tapuska <[email protected]> Cr-Commit-Position: refs/heads/main@{#1705139} --- diff --git a/chrome/renderer/actor/script_tool.cc b/chrome/renderer/actor/script_tool.cc index 010cffc1..41f3564a7 100644 --- a/chrome/renderer/actor/script_tool.cc +++ b/chrome/renderer/actor/script_tool.cc @@ -110,8 +110,13 @@ ScriptTool::~ScriptTool() = default; void ScriptTool::Execute(ToolFinishedCallback callback) { + // ExecuteScriptTool() synchronously invokes page JavaScript, which may detach + // the owning frame or spin a nested message loop that cancels/destroys + // `this`. Copy `execution_id_` onto the stack so the reference passed into + // Blink remains valid across re-entrancy. + const base::UnguessableToken execution_id = execution_id_.value(); frame_->GetWebFrame()->GetDocument().ExecuteScriptTool( - execution_id_.value(), blink::WebString::FromUtf8(action_->name), + execution_id, blink::WebString::FromUtf8(action_->name), blink::WebString::FromUtf8(action_->input_arguments), base::BindOnce(&OnToolExecuted, action_->name, action_->input_arguments) .Then(std::move(callback))); @@ -122,10 +127,12 @@ return; } // CancelScriptTool() synchronously dispatches DOM events that might destroy - // the owning frame and this tool. Use a weak pointer to detect if `this` is - // still valid. + // the owning frame and this tool. Copy `execution_id_` onto the stack so the + // reference passed into Blink stays valid, and use a weak pointer to detect + // if `this` is still valid before resetting `execution_id_`. + const base::UnguessableToken execution_id = execution_id_.value(); base::WeakPtr<ScriptTool> weak_this = weak_ptr_factory_.GetWeakPtr(); - frame_->GetWebFrame()->GetDocument().CancelScriptTool(execution_id_.value()); + frame_->GetWebFrame()->GetDocument().CancelScriptTool(execution_id); if (weak_this) { execution_id_.reset(); } diff --git a/third_party/blink/renderer/core/script_tools/model_context.cc b/third_party/blink/renderer/core/script_tools/model_context.cc index 86fab626..f42aa714 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.cc +++ b/third_party/blink/renderer/core/script_tools/model_context.cc @@ -522,7 +522,7 @@ blink::BindOnce(std::move(callback), base::unexpected(std::move(error)))); } -bool ModelContext::ExecuteTool(const base::UnguessableToken& invocation_id, +bool ModelContext::ExecuteTool(base::UnguessableToken invocation_id, const String& name, const String& input_arguments, ScriptToolExecutedCallback tool_executed_cb) { @@ -576,7 +576,7 @@ return success; } -bool ModelContext::CancelTool(const base::UnguessableToken& invocation_id) { +bool ModelContext::CancelTool(base::UnguessableToken invocation_id) { CHECK(document_->IsActive()); // It's possible for `invocation_id` to not match any pending execution, for @@ -691,7 +691,7 @@ // but respondWith() isn't called, an error is reported back to the agent.) void ModelContext::ExecuteDeclarativeTool( DeclarativeWebMCPTool* tool, - const base::UnguessableToken& invocation_id, + base::UnguessableToken invocation_id, const String& input_arguments, AbortController* abort_controller, ScriptToolExecutedCallback tool_executed_cb) { @@ -747,7 +747,7 @@ // waits for the promise to resolve, JSON-stringifies the result, and passes // it to OnToolExecuted(). bool ModelContext::ExecuteV8Tool(V8ToolExecuteCallback* tool_function, - const base::UnguessableToken& invocation_id, + base::UnguessableToken invocation_id, const String& name, const String& input_arguments, AbortController* abort_controller, diff --git a/third_party/blink/renderer/core/script_tools/model_context.h b/third_party/blink/renderer/core/script_tools/model_context.h index f875322a..d4abf18 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.h +++ b/third_party/blink/renderer/core/script_tools/model_context.h @@ -157,7 +157,7 @@ std::optional<ScriptToolDeclaration> GetScriptToolDeclaration( const String& name) const; - bool ExecuteTool(const base::UnguessableToken& invocation_id, + bool ExecuteTool(base::UnguessableToken invocation_id, const String& name, const String& input_arguments, ScriptToolExecutedCallback tool_executed_cb); @@ -167,7 +167,7 @@ const base::UnguessableToken& invocation_id, CrossDocumentScriptToolResultCallback result_callback); - bool CancelTool(const base::UnguessableToken& invocation_id); + bool CancelTool(base::UnguessableToken invocation_id); void RegisterDeclarativeTool(DeclarativeWebMCPTool* tool); void PauseExecution(); @@ -206,13 +206,13 @@ bool IsModelContextAllowed() const; bool ExecuteV8Tool(V8ToolExecuteCallback* tool_function, - const base::UnguessableToken& invocation_id, + base::UnguessableToken invocation_id, const String& name, const String& input_arguments, AbortController* abort_controller, ScriptToolExecutedCallback tool_executed_cb); void ExecuteDeclarativeTool(DeclarativeWebMCPTool* tool, - const base::UnguessableToken& invocation_id, + base::UnguessableToken invocation_id, const String& input_arguments, AbortController* abort_controller, ScriptToolExecutedCallback tool_executed_cb); diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc index 75607165..3502d67 100644 --- a/chrome/browser/actor/tools/uaf_regression_browsertest.cc +++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc @@ -2,15 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include "base/test/scoped_feature_list.h" #include "base/test/test_future.h" #include "chrome/browser/actor/actor_test_util.h" #include "chrome/browser/actor/tools/tool_request.h" #include "chrome/browser/actor/tools/tools_test_util.h" #include "chrome/common/actor.mojom.h" #include "chrome/common/actor/action_result.h" +#include "components/actor/core/actor_features.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" +#include "content/public/test/test_utils.h" #include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/public/common/features_generated.h" #include "ui/gfx/geometry/point.h" #include "ui/gfx/geometry/point_conversions.h" @@ -225,5 +229,61 @@ ASSERT_TRUE(result.Wait()); } +class ActorUafRegressionScriptToolBrowserTest + : public ActorUafRegressionBrowserTest { + public: + ActorUafRegressionScriptToolBrowserTest() { + features_.InitWithFeatures( + {blink::features::kWebMCP, actor::kGlicActorEnableScriptTools}, {}); + } + + private: + base::test::ScopedFeatureList features_; +}; + +// Regression test for UAF in ScriptTool and ModelContext. +// See crbug.com/513147880. +IN_PROC_BROWSER_TEST_F(ActorUafRegressionScriptToolBrowserTest, + ScriptToolExecute_HandlesSynchronousFrameDetachment) { + SetupAbaFrames("/actor/script_tool_uaf_inner.html"); + RenderFrameHost* inner_rfh = GetInnerRfh(); + ASSERT_TRUE(inner_rfh); + + std::unique_ptr<ToolRequest> action = + MakeScriptToolRequest(*inner_rfh, "detach_on_execute", "{}"); + ActResultFuture result; + actor_task().Act(ToRequestList(action), result.GetCallback()); + + // This should not crash the renderer. + ASSERT_TRUE(result.Wait()); +} + +// Regression test for UAF in ScriptTool and ModelContext. +// See crbug.com/513147880. +IN_PROC_BROWSER_TEST_F(ActorUafRegressionScriptToolBrowserTest, + ScriptToolCancel_HandlesSynchronousFrameDetachment) { + SetupAbaFrames("/actor/script_tool_uaf_inner.html"); + RenderFrameHost* inner_rfh = GetInnerRfh(); + ASSERT_TRUE(inner_rfh); + + content::DOMMessageQueue message_queue(web_contents()); + std::unique_ptr<ToolRequest> action = + MakeScriptToolRequest(*inner_rfh, "hang", "{}"); + ActResultFuture result; + actor_task().Act(ToRequestList(action), result.GetCallback()); + + std::string message; + ASSERT_TRUE(message_queue.WaitForMessage(&message)); + EXPECT_EQ("\"hang_started\"", message); + + // The page's `abort` / `toolcancel` handler synchronously detaches the + // local-root frame running the tool while ScriptTool::Cancel and + // ToolExecutor::CancelTool are on the stack. + actor_task().CancelOngoingActions(mojom::ActionResultCode::kActionsCancelled); + + // This should not crash the renderer. + ASSERT_TRUE(result.Wait()); +} + } // namespace } // namespace actor diff --git a/components/test/data/actor/script_tool_uaf_inner.html b/components/test/data/actor/script_tool_uaf_inner.html new file mode 100644 index 0000000..5b38c6b --- /dev/null +++ b/components/test/data/actor/script_tool_uaf_inner.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> +<body> + <script> + function detachMidFrame() { + const mid = parent.parent.document.getElementById('mid'); + if (mid) { + mid.remove(); + } + } + + document.modelContext.registerTool({ + name: 'detach_on_execute', + description: 'synchronously detaches frame during execute()', + inputSchema: { type: 'object', properties: {} }, + execute: () => { + detachMidFrame(); + return new Promise(() => {}); + }, + }); + + document.modelContext.registerTool({ + name: 'hang', + description: 'never resolves; detaches frame on abort/toolcancel', + inputSchema: { type: 'object', properties: {} }, + execute: (input, options) => { + if (options && options.signal) { + options.signal.addEventListener('abort', detachMidFrame); + } + window.domAutomationController.send('hang_started'); + return new Promise(() => {}); + }, + }); + + window.addEventListener('toolcancel', detachMidFrame); + </script> +</body> +</html> diff --git a/components/test/data/actor/uaf_mid.html b/components/test/data/actor/uaf_mid.html index 39847961..e0cb525 100644 --- a/components/test/data/actor/uaf_mid.html +++ b/components/test/data/actor/uaf_mid.html @@ -1,6 +1,6 @@ <!DOCTYPE html> <html> <body> - <iframe id="inner" width="400" height="400"></iframe> + <iframe id="inner" width="400" height="400" allow="tools"></iframe> </body> </html> diff --git a/components/test/data/actor/uaf_outer.html b/components/test/data/actor/uaf_outer.html index 8a0f98a..1bada103 100644 --- a/components/test/data/actor/uaf_outer.html +++ b/components/test/data/actor/uaf_outer.html @@ -1,6 +1,6 @@ <!DOCTYPE html> <html> <body> - <iframe id="mid" width="500" height="500"></iframe> + <iframe id="mid" width="500" height="500" allow="tools"></iframe> </body> </html> diff --git a/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html b/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html index a962346..2278faa 100644 --- a/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html +++ b/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html @@ -109,6 +109,91 @@ 'Promise should be rejected with UnknownError when target frame is detached mid-execution' ); }, 'executeTool() rejects when target frame is detached mid-execution'); + +promise_test(async t => { + const iframe = document.createElement('iframe'); + iframe.src = '/common/blank.html'; + iframe.allow = 'tools *'; + + const load_promise = new Promise(resolve => iframe.onload = resolve); + document.body.appendChild(iframe); + t.add_cleanup(() => iframe.remove()); + await load_promise; + + const toolchange_promise = new Promise(resolve => { + document.modelContext.addEventListener('toolchange', resolve, { once: true }); + }); + + await iframe.contentWindow.document.modelContext.registerTool({ + name: 'sync_detach_on_execute', + description: 'Synchronously detaches its own frame during execute()', + execute: () => { + iframe.remove(); + return new Promise(() => {}); + } + }); + + await toolchange_promise; + + const tools = await document.modelContext.getTools(); + const tool = tools.find(t => t.name === 'sync_detach_on_execute'); + assert_true(!!tool, 'Tool should be retrieved successfully'); + + await promise_rejects_dom( + t, + 'UnknownError', + document.modelContext.executeTool(tool), + 'Promise should reject when target frame synchronously detaches itself during execute()' + ); +}, 'executeTool() rejects cleanly when tool callback synchronously detaches its own frame'); + +promise_test(async t => { + const iframe = document.createElement('iframe'); + iframe.src = '/common/blank.html'; + iframe.allow = 'tools *'; + + const load_promise = new Promise(resolve => iframe.onload = resolve); + document.body.appendChild(iframe); + t.add_cleanup(() => iframe.remove()); + await load_promise; + + const toolchange_promise = new Promise(resolve => { + document.modelContext.addEventListener('toolchange', resolve, { once: true }); + }); + + const { promise: toolStarted, resolve: resolveToolStarted } = Promise.withResolvers(); + await iframe.contentWindow.document.modelContext.registerTool({ + name: 'sync_detach_on_abort', + description: 'Synchronously detaches its own frame when options.signal aborts', + execute: (input, options) => { + options.signal.addEventListener('abort', () => { + iframe.remove(); + }); + resolveToolStarted(); + return new Promise(() => {}); + } + }); + + await toolchange_promise; + + const tools = await document.modelContext.getTools(); + const tool = tools.find(t => t.name === 'sync_detach_on_abort'); + assert_true(!!tool, 'Tool should be retrieved successfully'); + + const controller = new AbortController(); + const execute_promise = document.modelContext.executeTool( + tool, {}, { signal: controller.signal }); + + await toolStarted; + controller.abort('abort and detach'); + + await promise_rejects_exactly( + t, + 'abort and detach', + execute_promise, + 'Promise should reject with abort reason when options.signal abort handler detaches frame' + ); +}, 'executeTool() abort cleanly handles synchronous frame detachment inside options.signal abort listener'); </script> </body> </html>
Regression Test / PoC
diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
index 75607165..3502d67 100644
--- a/chrome/browser/actor/tools/uaf_regression_browsertest.cc
+++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
@@ -2,15 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "chrome/browser/actor/actor_test_util.h"
#include "chrome/browser/actor/tools/tool_request.h"
#include "chrome/browser/actor/tools/tools_test_util.h"
#include "chrome/common/actor.mojom.h"
#include "chrome/common/actor/action_result.h"
+#include "components/actor/core/actor_features.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_utils.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/features_generated.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/point_conversions.h"
@@ -225,5 +229,61 @@
ASSERT_TRUE(result.Wait());
}
+class ActorUafRegressionScriptToolBrowserTest
+ : public ActorUafRegressionBrowserTest {
+ public:
+ ActorUafRegressionScriptToolBrowserTest() {
+ features_.InitWithFeatures(
+ {blink::features::kWebMCP, actor::kGlicActorEnableScriptTools}, {});
+ }
+
+ private:
+ base::test::ScopedFeatureList features_;
+};
+
+// Regression test for UAF in ScriptTool and ModelContext.
+// See crbug.com/513147880.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionScriptToolBrowserTest,
+ ScriptToolExecute_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/script_tool_uaf_inner.html");
+ RenderFrameHost* inner_rfh = GetInnerRfh();
+ ASSERT_TRUE(inner_rfh);
+
+ std::unique_ptr<ToolRequest> action =
+ MakeScriptToolRequest(*inner_rfh, "detach_on_execute", "{}");
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
+// Regression test for UAF in ScriptTool and ModelContext.
+// See crbug.com/513147880.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionScriptToolBrowserTest,
+ ScriptToolCancel_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/script_tool_uaf_inner.html");
+ RenderFrameHost* inner_rfh = GetInnerRfh();
+ ASSERT_TRUE(inner_rfh);
+
+ content::DOMMessageQueue message_queue(web_contents());
+ std::unique_ptr<ToolRequest> action =
+ MakeScriptToolRequest(*inner_rfh, "hang", "{}");
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ std::string message;
+ ASSERT_TRUE(message_queue.WaitForMessage(&message));
+ EXPECT_EQ("\"hang_started\"", message);
+
+ // The page's `abort` / `toolcancel` handler synchronously detaches the
+ // local-root frame running the tool while ScriptTool::Cancel and
+ // ToolExecutor::CancelTool are on the stack.
+ actor_task().CancelOngoingActions(mojom::ActionResultCode::kActionsCancelled);
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
} // namespace
} // namespace actor
diff --git a/components/test/data/actor/script_tool_uaf_inner.html b/components/test/data/actor/script_tool_uaf_inner.html
new file mode 100644
index 0000000..5b38c6b
--- /dev/null
+++ b/components/test/data/actor/script_tool_uaf_inner.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <script>
+ function detachMidFrame() {
+ const mid = parent.parent.document.getElementById('mid');
+ if (mid) {
+ mid.remove();
+ }
+ }
+
+ document.modelContext.registerTool({
+ name: 'detach_on_execute',
+ description: 'synchronously detaches frame during execute()',
+ inputSchema: { type: 'object', properties: {} },
+ execute: () => {
+ detachMidFrame();
+ return new Promise(() => {});
+ },
+ });
+
+ document.modelContext.registerTool({
+ name: 'hang',
+ description: 'never resolves; detaches frame on abort/toolcancel',
+ inputSchema: { type: 'object', properties: {} },
+ execute: (input, options) => {
+ if (options && options.signal) {
+ options.signal.addEventListener('abort', detachMidFrame);
+ }
+ window.domAutomationController.send('hang_started');
+ return new Promise(() => {});
+ },
+ });
+
+ window.addEventListener('toolcancel', detachMidFrame);
+ </script>
+</body>
+</html>
diff --git a/components/test/data/actor/uaf_mid.html b/components/test/data/actor/uaf_mid.html
index 39847961..e0cb525 100644
--- a/components/test/data/actor/uaf_mid.html
+++ b/components/test/data/actor/uaf_mid.html
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html>
<body>
- <iframe id="inner" width="400" height="400"></iframe>
+ <iframe id="inner" width="400" height="400" allow="tools"></iframe>
</body>
</html>
diff --git a/components/test/data/actor/uaf_outer.html b/components/test/data/actor/uaf_outer.html
index 8a0f98a..1bada103 100644
--- a/components/test/data/actor/uaf_outer.html
+++ b/components/test/data/actor/uaf_outer.html
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html>
<body>
- <iframe id="mid" width="500" height="500"></iframe>
+ <iframe id="mid" width="500" height="500" allow="tools"></iframe>
</body>
</html>
diff --git a/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html b/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html
index a962346..2278faa 100644
--- a/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html
+++ b/third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html
@@ -109,6 +109,91 @@
'Promise should be rejected with UnknownError when target frame is detached mid-execution'
);
}, 'executeTool() rejects when target frame is detached mid-execution');
+
+promise_test(async t => {
+ const iframe = document.createElement('iframe');
+ iframe.src = '/common/blank.html';
+ iframe.allow = 'tools *';
+
+ const load_promise = new Promise(resolve => iframe.onload = resolve);
+ document.body.appendChild(iframe);
+ t.add_cleanup(() => iframe.remove());
+ await load_promise;
+
+ const toolchange_promise = new Promise(resolve => {
+ document.modelContext.addEventListener('toolchange', resolve, { once: true });
+ });
+
+ await iframe.contentWindow.document.modelContext.registerTool({
+ name: 'sync_detach_on_execute',
+ description: 'Synchronously detaches its own frame during execute()',
+ execute: () => {
+ iframe.remove();
+ return new Promise(() => {});
+ }
+ });
+
+ await toolchange_promise;
+
+ const tools = await document.modelContext.getTools();
+ const tool = tools.find(t => t.name === 'sync_detach_on_execute');
+ assert_true(!!tool, 'Tool should be retrieved successfully');
+
+ await promise_rejects_dom(
+ t,
+ 'UnknownError',
+ document.modelContext.executeTool(tool),
+ 'Promise should reject when target frame synchronously detaches itself during execute()'
+ );
+}, 'executeTool() rejects cleanly when tool callback synchronously detaches its own frame');
+
+promise_test(async t => {
+ const iframe = document.createElement('iframe');
+ iframe.src = '/common/blank.html';
+ iframe.allow = 'tools *';
+
+ const load_promise = new Promise(resolve => iframe.onload = resolve);
+ document.body.appendChild(iframe);
+ t.add_cleanup(() => iframe.remove());
+ await load_promise;
+
+ const toolchange_promise = new Promise(resolve => {
+ document.modelContext.addEventListener('toolchange', resolve, { once: true });
+ });
+
+ const { promise: toolStarted, resolve: resolveToolStarted } = Promise.withResolvers();
+ await iframe.contentWindow.document.modelContext.registerTool({
+ name: 'sync_detach_on_abort',
+ description: 'Synchronously detaches its own frame when options.signal aborts',
+ execute: (input, options) => {
+ options.signal.addEventListener('abort', () => {
+ iframe.remove();
+ });
+ resolveToolStarted();
+ return new Promise(() => {});
+ }
+ });
+
+ await toolchange_promise;
+
+ const tools = await document.modelContext.getTools();
+ const tool = tools.find(t => t.name === 'sync_detach_on_abort');
+ assert_true(!!tool, 'Tool should be retrieved successfully');
+
+ const controller = new AbortController();
+ const execute_promise = document.modelContext.executeTool(
+ tool, {}, { signal: controller.signal });
+
+ await toolStarted;
+ controller.abort('abort and detach');
+
+ await promise_rejects_exactly(
+ t,
+ 'abort and detach',
+ execute_promise,
+ 'Promise should reject with abort reason when options.signal abort handler detaches frame'
+ );
+}, 'executeTool() abort cleanly handles synchronous frame detachment inside options.signal abort listener');
</script>
</body>
</html>
Original Bug Report
Renderer UAF in ScriptTool and ToolExecutor due to JS re-entrancy during Cancel()
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 Use-After-Free (UAF) vulnerability potentially exists in the renderer process when ScriptTool::Cancel dispatches a synchronous ’toolcancel’ DOM event. Malicious JavaScript execution during this event can cause the destruction of the ScriptTool and its owner during a nested message loop, leading to memory corruption when execution resumes.
Affected files:
chrome/renderer/actor/script_tool.ccchrome/renderer/actor/tool_executor.ccthird_party/blink/renderer/core/script_tools/model_context.ccchrome/renderer/chrome_render_frame_observer.cc
Estimated timestamp from git blame: 2026-02-03
Vulnerability Description
A potential Use-After-Free (UAF) vulnerability has been identified in the actor::ScriptTool and actor::ToolExecutor classes within the renderer process. These classes are part of the Glic ‘actor’ feature. The issue stems from a synchronous DOM event dispatch in ScriptTool::Cancel() that allows for JavaScript re-entrancy without appropriate liveness guards for the objects on the stack.
Technical Details
When ScriptTool::Cancel() is invoked (e.g., via a cancellation request from the Glic browser UI), it calls into Blink to cancel the script tool execution:
// chrome/renderer/actor/script_tool.cc
void ScriptTool::Cancel() {
if (!execution_id_.has_value()) {
return;
}
// synchronous call into Blink
frame_->GetWebFrame()->GetDocument().CancelScriptTool(execution_id_.value());
execution_id_.reset(); // <--- Potential Use-After-Free write
}
The Blink implementation reaches ModelContext::CancelTool, which dispatches a synchronous kToolcancel event on the LocalDOMWindow:
// third_party/blink/renderer/core/script_tools/model_context.cc
bool ModelContext::CancelTool(const base::UnguessableToken& invocation_id) {
...
if (LocalDOMWindow* window = document_->domWindow()) {
// Dispatches a synchronous event; allows JS re-entrancy.
window->DispatchEvent(*WebMCPEvent::Create(event_type_names::kToolcancel, tool_name));
}
...
}
Potential Attack Path
- Preparation: An attacker registers a listener for the
'toolcancel'event (e.g.,window.addEventListener('toolcancel', ...)). - Triggering the Nested Loop: When the event is dispatched, the listener calls
window.print(). This enters a nested message loop insidePrintRenderFrameHelperdue to a synchronous Mojo call (ScriptedPrint) which is marked[Sync]inprint.mojom. - Frame Destruction: While in the nested loop, the attacker triggers an action (e.g., from a parent frame) that detaches the current
RenderFrame. The destruction of theRenderFramecausesChromeRenderFrameObserverto be deleted viaOnDestruct(), which in turn deletes theToolExecutorand its ownedScriptToolobject. - Use-After-Free: When the nested loop finishes and the stack unwinds:
ScriptTool::Cancel()resumes and writesexecution_id_.reset()to freed memory.ToolExecutor::CancelToolresumes and callsToolFinished(), which performs multiple writes to its own freed memory, including callingunique_ptr::reset()on thetool_member.
Impact
This is a heap UAF write in the sandboxed renderer process. The unique_ptr::reset() calls in ToolExecutor provide a strong primitive, as they could potentially trigger a controlled delete of an attacker-chosen pointer if the heap is successfully manipulated. This could lead to arbitrary code execution (RCE) within the renderer sandbox.
Suggested Fix
To mitigate this, ScriptTool and ToolExecutor should use base::WeakPtr to check for liveness after returning from synchronous calls that allow JavaScript execution. Alternatively, the 'toolcancel' event should be dispatched asynchronously, or the cancellation logic should be structured to ensure that member variables are not accessed after the event dispatch without a liveness check.
Note: This analysis was performed using static code review and automated tools; a functional proof-of-concept exploit has not yet been developed.
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.