Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Actor
DescriptionUse after free in Actor
ComponentActor
Sub-componentActor
Bug ClassUAF
Tracker524319033
Fix commit9664cd02ac8d (chromium/src) +121/-14
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/actor/actor_proto_conversion.cc
modified
ActorProtoConversionTest
chrome/browser/actor/actor_proto_conversion_unittest.cc
modified
ActorProtoConversionTest
chrome/browser/actor/actor_proto_conversion_unittest.cc
modified
TEST_F
chrome/browser/actor/actor_proto_conversion_unittest.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/actor/tools/script_tool_browsertest.cc
modified

Files Changed

  • chrome/browser/actor/tools/script_tool_browsertest.cc
  • chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
  • chrome/browser/actor/actor_proto_conversion_unittest.cc
  • chrome/browser/actor/actor_proto_conversion.cc
  • chrome/browser/actor/tools/script_tool_request.h
From 9664cd02ac8d554a6351c6d39d7a538f253a978a Mon Sep 17 00:00:00 2001
From: mark a. foltz <[email protected]>
Date: Tue, 22 Sep 2026 16:33:00 -0700
Subject: [PATCH] [actor] Require document_identifier in ScriptTool requests

Previously, CreateScriptToolRequest() fell back to populating missing
document identifiers using the current primary main frame token.

This CL removes the fallback in CreateScriptToolRequest() and requires
ScriptToolAction to provide a valid document_identifier.

Fixed: 524319033
Change-Id: I3a197130e2d7c9db38219f0cfa871dd36c8d9bc3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423266
Reviewed-by: Ben Greenstein <[email protected]>
Commit-Queue: Mark Foltz <[email protected]>
Reviewed-by: Charles Harrison <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1703054}
---
diff --git a/chrome/browser/actor/actor_proto_conversion.cc b/chrome/browser/actor/actor_proto_conversion.cc
index 889687eb..9574ec1 100644
--- a/chrome/browser/actor/actor_proto_conversion.cc
+++ b/chrome/browser/actor/actor_proto_conversion.cc
@@ -695,19 +695,13 @@
     return nullptr;
   }
 
-  // TODO(khushalsagar): Remove once the callers are setting up this ID
-  // correctly.
-  std::optional<base::UnguessableToken> document_identifier;
-  if (action.has_document_identifier()) {
-    document_identifier = base::UnguessableToken::DeserializeFromString(
-        action.document_identifier().serialized_token());
-  } else {
-    auto* main_rfh = tab_handle.Get()->GetContents()->GetPrimaryMainFrame();
-    document_identifier = optimization_guide::DocumentIdentifierUserData::
-                              GetOrCreateForCurrentDocument(main_rfh)
-                                  ->token();
+  if (!action.has_document_identifier()) {
+    return nullptr;
   }
 
+  std::optional<base::UnguessableToken> document_identifier =
+      base::UnguessableToken::DeserializeFromString(
+          action.document_identifier().serialized_token());
   if (!document_identifier) {
     return nullptr;
   }
diff --git a/chrome/browser/actor/tools/script_tool_request.h b/chrome/browser/actor/tools/script_tool_request.h
index b204aff..e3e4bfe 100644
--- a/chrome/browser/actor/tools/script_tool_request.h
+++ b/chrome/browser/actor/tools/script_tool_request.h
@@ -36,6 +36,14 @@
 
   bool RequiresOpeningWebContents() const override;
 
+  const base::UnguessableToken& GetTargetDocumentIdForTesting() const {
+    return target_document_id_;
+  }
+  const std::string& GetNameForTesting() const { return name_; }
+  const std::string& GetInputArgumentsForTesting() const {
+    return input_arguments_;
+  }
+
  private:
   base::UnguessableToken target_document_id_;
   std::string name_;
diff --git a/chrome/browser/actor/actor_proto_conversion_unittest.cc b/chrome/browser/actor/actor_proto_conversion_unittest.cc
index ed6fc0037..67c1f04a 100644
--- a/chrome/browser/actor/actor_proto_conversion_unittest.cc
+++ b/chrome/browser/actor/actor_proto_conversion_unittest.cc
@@ -7,6 +7,12 @@
 #include <optional>
 #include <vector>
 
+#include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
+#include "base/unguessable_token.h"
+#include "chrome/browser/actor/tools/script_tool_request.h"
+#include "components/actor/core/actor_features.h"
+#include "components/optimization_guide/proto/features/actions_data.pb.h"
 #include "components/optimization_guide/proto/features/common_quality_data.pb.h"
 #include "components/optimization_guide/proto/features/common_quality_data_fuzzable.pb.h"
 #include "components/origin_gating/core/task_policy_config.h"
@@ -35,12 +41,35 @@
   return Rule(std::move(navigation_sources), resources, capabilities);
 }
 
+optimization_guide::proto::Actions CreateActionsWithScriptTool(
+    std::optional<int32_t> tab_id,
+    std::optional<std::string> document_identifier) {
+  optimization_guide::proto::Actions actions;
+  optimization_guide::proto::ScriptToolAction* script_action =
+      actions.add_actions()->mutable_script_tool();
+  if (tab_id.has_value()) {
+    script_action->set_tab_id(*tab_id);
+  }
+  script_action->set_tool_name("echo");
+  script_action->set_input_arguments(R"({"text":"sample_input"})");
+  if (document_identifier.has_value()) {
+    script_action->mutable_document_identifier()->set_serialized_token(
+        *document_identifier);
+  }
+  return actions;
+}
+
 }  // namespace
 
 class ActorProtoConversionTest : public testing::Test {
  public:
-  ActorProtoConversionTest() = default;
+  ActorProtoConversionTest() {
+    feature_list_.InitAndEnableFeature(kGlicActorEnableScriptTools);
+  }
   ~ActorProtoConversionTest() override = default;
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
 };
 
 TEST_F(ActorProtoConversionTest, ConvertEmptyConfig) {
@@ -367,6 +396,60 @@
   EXPECT_FALSE(ValidateActionsAreScriptTools(actions));
 }
 
+TEST_F(
+    ActorProtoConversionTest,
+    BuildToolRequest_ScriptTool_ValidProto_CreatesRequestWithCorrectParameters) {
+  base::UnguessableToken token = base::UnguessableToken::Create();
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/100, token.ToString());
+
+  BuildToolRequestResult requests = BuildToolRequest(actions);
+  ASSERT_TRUE(requests.has_value());
+  ASSERT_EQ(requests.value().size(), 1u);
+
+  ToolRequest& created_request = *requests.value().front();
+  EXPECT_EQ(ScriptToolRequest::kName, created_request.Name());
+
+  const ScriptToolRequest& script_request =
+      static_cast<const ScriptToolRequest&>(created_request);
+  EXPECT_EQ(100, script_request.GetTabHandle().raw_value());
+  EXPECT_EQ(token, script_request.GetTargetDocumentIdForTesting());
+  EXPECT_EQ("echo", script_request.GetNameForTesting());
+  EXPECT_EQ(R"({"text":"sample_input"})",
+            script_request.GetInputArgumentsForTesting());
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_MissingDocumentIdentifier_ReturnsError) {
+  optimization_guide::proto::Actions actions = CreateActionsWithScriptTool(
+      /*tab_id=*/100, /*document_identifier=*/std::nullopt);
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_InvalidDocumentIdentifier_ReturnsError) {
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/100, "invalid_token");
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_MissingTabId_ReturnsError) {
+  base::UnguessableToken token = base::UnguessableToken::Create();
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/std::nullopt, token.ToString());
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
 void CanConvertAnyProto(
     const fuzzable::optimization_guide::proto::AgentContainerConfig&
         fuzzable_config_proto) {
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 6f2c58a..307ca97c 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -188,6 +188,14 @@
       R"JSON({"type":"object","properties":{"text":{"description":)JSON"
       R"JSON("Value to echo","type":"string"}},"required":["text"]})JSON";
   EXPECT_EQ(response->tool->input_schema, expected_input_schema);
+
+  // Navigate to a new document before invoking the tool; a request bound to
+  // the previous document's identifier must fail with kTabWentAway.
+  auto stale_action =
+      MakeScriptToolRequest(*main_frame(), "echo", input_arguments);
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
+  RunScriptToolExpectingError(std::move(stale_action),
+                              mojom::ActionResultCode::kTabWentAway);
 }
 
 IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, DeclarativeTool) {
@@ -807,6 +815,17 @@
   auto action = MakeScriptToolRequest(subframe, "echo", input_arguments);
   auto [action_result, response] = RunScriptTool(std::move(action));
   EXPECT_EQ(response->result, "cross_origin_subframe");
+
+  // Navigating the subframe before invoking the tool invalidates the old
+  // subframe document ID (kTabWentAway).
+  auto stale_subframe_action =
+      MakeScriptToolRequest(subframe, "echo", input_arguments);
+  const GURL new_subframe_url =
+      embedded_https_test_server().GetURL("c.com", "/actor/script_tool.html");
+  ASSERT_TRUE(
+      content::NavigateIframeToURL(web_contents(), "iframe", new_subframe_url));
+  RunScriptToolExpectingError(std::move(stale_subframe_action),
+                              mojom::ActionResultCode::kTabWentAway);
 }
 
 IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, CrossOriginSubframeNavigates) {
diff --git a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
index bea09a3..cf36bc8 100644
--- a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
+++ b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
@@ -1687,8 +1687,11 @@
 
 TEST_F(GlicExperimentalTriggeringCoordinatorWithTabTest,
        ExecuteActions_Complete) {
-  ExperimentalTriggeringRequest request =
-      CreateExecuteScriptToolRequest(mock_tab_.GetHandle().raw_value());
+  auto* user_data = optimization_guide::DocumentIdentifierUserData::
+      GetOrCreateForCurrentDocument(web_contents_->GetPrimaryMainFrame());
+
+  ExperimentalTriggeringRequest request = CreateExecuteScriptToolRequest(
+      mock_tab_.GetHandle().raw_value(), user_data->serialized_token());
 
   base::test::TestFuture<ExperimentalTriggeringResponse> update_future;
   coordinator_->OnRequest(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/actor/actor_proto_conversion_unittest.cc b/chrome/browser/actor/actor_proto_conversion_unittest.cc
index ed6fc0037..67c1f04a 100644
--- a/chrome/browser/actor/actor_proto_conversion_unittest.cc
+++ b/chrome/browser/actor/actor_proto_conversion_unittest.cc
@@ -7,6 +7,12 @@
 #include <optional>
 #include <vector>
 
+#include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
+#include "base/unguessable_token.h"
+#include "chrome/browser/actor/tools/script_tool_request.h"
+#include "components/actor/core/actor_features.h"
+#include "components/optimization_guide/proto/features/actions_data.pb.h"
 #include "components/optimization_guide/proto/features/common_quality_data.pb.h"
 #include "components/optimization_guide/proto/features/common_quality_data_fuzzable.pb.h"
 #include "components/origin_gating/core/task_policy_config.h"
@@ -35,12 +41,35 @@
   return Rule(std::move(navigation_sources), resources, capabilities);
 }
 
+optimization_guide::proto::Actions CreateActionsWithScriptTool(
+    std::optional<int32_t> tab_id,
+    std::optional<std::string> document_identifier) {
+  optimization_guide::proto::Actions actions;
+  optimization_guide::proto::ScriptToolAction* script_action =
+      actions.add_actions()->mutable_script_tool();
+  if (tab_id.has_value()) {
+    script_action->set_tab_id(*tab_id);
+  }
+  script_action->set_tool_name("echo");
+  script_action->set_input_arguments(R"({"text":"sample_input"})");
+  if (document_identifier.has_value()) {
+    script_action->mutable_document_identifier()->set_serialized_token(
+        *document_identifier);
+  }
+  return actions;
+}
+
 }  // namespace
 
 class ActorProtoConversionTest : public testing::Test {
  public:
-  ActorProtoConversionTest() = default;
+  ActorProtoConversionTest() {
+    feature_list_.InitAndEnableFeature(kGlicActorEnableScriptTools);
+  }
   ~ActorProtoConversionTest() override = default;
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
 };
 
 TEST_F(ActorProtoConversionTest, ConvertEmptyConfig) {
@@ -367,6 +396,60 @@
   EXPECT_FALSE(ValidateActionsAreScriptTools(actions));
 }
 
+TEST_F(
+    ActorProtoConversionTest,
+    BuildToolRequest_ScriptTool_ValidProto_CreatesRequestWithCorrectParameters) {
+  base::UnguessableToken token = base::UnguessableToken::Create();
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/100, token.ToString());
+
+  BuildToolRequestResult requests = BuildToolRequest(actions);
+  ASSERT_TRUE(requests.has_value());
+  ASSERT_EQ(requests.value().size(), 1u);
+
+  ToolRequest& created_request = *requests.value().front();
+  EXPECT_EQ(ScriptToolRequest::kName, created_request.Name());
+
+  const ScriptToolRequest& script_request =
+      static_cast<const ScriptToolRequest&>(created_request);
+  EXPECT_EQ(100, script_request.GetTabHandle().raw_value());
+  EXPECT_EQ(token, script_request.GetTargetDocumentIdForTesting());
+  EXPECT_EQ("echo", script_request.GetNameForTesting());
+  EXPECT_EQ(R"({"text":"sample_input"})",
+            script_request.GetInputArgumentsForTesting());
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_MissingDocumentIdentifier_ReturnsError) {
+  optimization_guide::proto::Actions actions = CreateActionsWithScriptTool(
+      /*tab_id=*/100, /*document_identifier=*/std::nullopt);
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_InvalidDocumentIdentifier_ReturnsError) {
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/100, "invalid_token");
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+       BuildToolRequest_ScriptTool_MissingTabId_ReturnsError) {
+  base::UnguessableToken token = base::UnguessableToken::Create();
+  optimization_guide::proto::Actions actions =
+      CreateActionsWithScriptTool(/*tab_id=*/std::nullopt, token.ToString());
+
+  EXPECT_THAT(BuildToolRequest(actions),
+              base::test::ErrorIs(testing::Pair(
+                  0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
 void CanConvertAnyProto(
     const fuzzable::optimization_guide::proto::AgentContainerConfig&
         fuzzable_config_proto) {
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 6f2c58a..307ca97c 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -188,6 +188,14 @@
       R"JSON({"type":"object","properties":{"text":{"description":)JSON"
       R"JSON("Value to echo","type":"string"}},"required":["text"]})JSON";
   EXPECT_EQ(response->tool->input_schema, expected_input_schema);
+
+  // Navigate to a new document before invoking the tool; a request bound to
+  // the previous document's identifier must fail with kTabWentAway.
+  auto stale_action =
+      MakeScriptToolRequest(*main_frame(), "echo", input_arguments);
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
+  RunScriptToolExpectingError(std::move(stale_action),
+                              mojom::ActionResultCode::kTabWentAway);
 }
 
 IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, DeclarativeTool) {
@@ -807,6 +815,17 @@
   auto action = MakeScriptToolRequest(subframe, "echo", input_arguments);
   auto [action_result, response] = RunScriptTool(std::move(action));
   EXPECT_EQ(response->result, "cross_origin_subframe");
+
+  // Navigating the subframe before invoking the tool invalidates the old
+  // subframe document ID (kTabWentAway).
+  auto stale_subframe_action =
+      MakeScriptToolRequest(subframe, "echo", input_arguments);
+  const GURL new_subframe_url =
+      embedded_https_test_server().GetURL("c.com", "/actor/script_tool.html");
+  ASSERT_TRUE(
+      content::NavigateIframeToURL(web_contents(), "iframe", new_subframe_url));
+  RunScriptToolExpectingError(std::move(stale_subframe_action),
+                              mojom::ActionResultCode::kTabWentAway);
 }
 
 IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, CrossOriginSubframeNavigates) {
diff --git a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
index bea09a3..cf36bc8 100644
--- a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
+++ b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
@@ -1687,8 +1687,11 @@
 
 TEST_F(GlicExperimentalTriggeringCoordinatorWithTabTest,
        ExecuteActions_Complete) {
-  ExperimentalTriggeringRequest request =
-      CreateExecuteScriptToolRequest(mock_tab_.GetHandle().raw_value());
+  auto* user_data = optimization_guide::DocumentIdentifierUserData::
+      GetOrCreateForCurrentDocument(web_contents_->GetPrimaryMainFrame());
+
+  ExperimentalTriggeringRequest request = CreateExecuteScriptToolRequest(
+      mock_tab_.GetHandle().raw_value(), user_data->serialized_token());
 
   base::test::TestFuture<ExperimentalTriggeringResponse> update_future;
   coordinator_->OnRequest(
Loading diff…

Original Bug Report

reported by [email protected]

TOCTOU bypass in ScriptToolHost allows cross-origin data leak of AI action arguments

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

Overview: When a ScriptToolAction lacks a document identifier, it defaults to capturing the current frame’s token at request creation. If the tab navigates during the AI model’s roundtrip, TimeOfUseValidation tautologically compares this new token against itself, bypassing security checks. This allows an attacker to execute the tool on a different origin, leaking sensitive input arguments intended for the original page.

Affected files:

  • chrome/browser/actor/actor_proto_conversion.cc
  • chrome/browser/actor/tools/script_tool_host.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

1. Summary of the Issue (Meant for Human Triage)

A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the Chrome Glic ScriptTool execution pipeline, potentially leading to a cross-origin data leak. When the AI model generates a ScriptToolAction proto, it currently omits the document_identifier field. During the proto-to-request conversion step (CreateScriptToolRequest), the system compensates for this omission by capturing the document token of whatever document is currently loaded in the target tab’s main frame.

Because the AI model’s server-side inference involves a multi-second roundtrip, an attacker can navigate the target tab cross-origin during this window. If a navigation commits before CreateScriptToolRequest executes, the fallback logic will capture the attacker’s document token. Subsequently, ScriptToolHost::TimeOfUseValidation evaluates this newly generated token against the then-current main frame’s token. Because both tokens were captured after the navigation, the validation performs a tautological check (X == X), successfully validating the attacker’s document.

Unlike PageTool::TimeOfUseValidation, ScriptToolHost entirely ignores the last_observation parameter, meaning it never compares the target document against the original state observed by the model. Furthermore, default navigation throttles (kGlicConfirmNavigationToNewOrigins = false) permit this cross-origin transition. If the attacker registers a tool with the same name as the intended action, the AI agent’s sensitive input_arguments (such as transaction details or PII) will be dispatched to the attacker’s page.

2. Proof-of-Concept & Detailed Execution Flow

Below is the step-by-step execution flow detailing how a potential attacker could trigger the vulnerability. (Note: Our tooling agent does not run live code, so these are the theoretically modeled steps based on codebase analysis).

  1. Preconditions & Attacker Setup: The user has the Glic Actor feature enabled (kGlicActor and kGlicActorEnableScriptTools are enabled). The attacker establishes a mechanism to navigate a target tab cross-origin. For example, the user visits attacker.com, which opens a popup to a sensitive site like bank.com, retaining a window.opener reference that allows the attacker to manipulate the popup’s location.
  2. The user activates the Glic AI Agent in the context of the sensitive bank.com tab.
  3. The browser gathers context about bank.com (the observation) and sends it to the server-side AI model to compute the next actions. This network request and model inference takes several seconds. The ActorTask state transitions to kReflecting.
  4. During this round-trip window, the attacker executes JavaScript on their page (e.g., window.opener.location = "https://attacker.com/") to navigate the target tab from bank.com to attacker.com.
  5. The navigation initiates. ActorNavigationThrottle::WillStartOrRedirectRequest (chrome/browser/actor/actor_navigation_throttle.cc) inspects the navigation by calling actor::MayActOnUrl.
  6. Inside actor::MayActOnUrl (chrome/browser/actor/site_policy.cc:347), the origin_gating_cache parameter is passed as std::nullopt. Because of this, the check (!origin_gating_cache || ...) inside MayActOnUrlInternal (chrome/browser/actor/site_policy.cc:264) evaluates to true. The logic short-circuits to decision_wrapper->Accept(), permitting the navigation.
  7. ExecutionEngine::ShouldDeferNavigation (chrome/browser/actor/execution_engine.cc:284) defers the navigation to perform safety checks via HandleNavigationToNewOrigin.
  8. Inside HandleNavigationToNewOrigin (chrome/browser/actor/execution_engine.cc:486), the condition if (!kGlicConfirmNavigationToNewOrigins.Get()) is evaluated. Under default configurations, kGlicConfirmNavigationToNewOrigins is set to false (components/actor/core/actor_features.cc:58-62). The code automatically runs the callback with may_continue=true (execution_engine.cc:503), allowing the navigation to attacker.com to commit.
  9. attacker.com successfully loads in the target tab. The attacker’s page uses the Glic API to register a script tool with the exact name expected to be invoked on the original page (e.g., transfer).
  10. The server-side AI model completes its processing and returns an optimization_guide::proto::Actions response containing a ScriptToolAction intended for bank.com. This action carries sensitive data in its input_arguments field, but lacks a document_identifier field.
  11. The browser receives the response and GlicActorTaskManager::PerformActions calls actor::BuildToolRequest(actions) (chrome/browser/glic/actor/glic_actor_task_manager.cc:580).
  12. BuildToolRequest processes each action, calling CreateScriptToolRequest(script_tool_action) (chrome/browser/actor/actor_proto_conversion.cc:837).
  13. Inside CreateScriptToolRequest, because action.has_document_identifier() is false, the code falls into the else block to generate a fallback identifier (chrome/browser/actor/actor_proto_conversion.cc:670).
  14. The fallback logic retrieves the current primary main frame of the tab: auto* main_rfh = tab_handle.Get()->GetContents()->GetPrimaryMainFrame(); (actor_proto_conversion.cc:671). Because the navigation committed in Step 8, this frame now belongs to attacker.com.
  15. CreateScriptToolRequest captures the document token for attacker.com via DocumentIdentifierUserData::GetOrCreateForCurrentDocument(main_rfh)->token() (actor_proto_conversion.cc:672) and binds it as the target_document_id_ of the newly created ScriptToolRequest object.
  16. Following request creation, ActorKeyedService::PerformActions calls ActorTask::Act (chrome/browser/actor/actor_keyed_service.cc:587), which transitions the task state to kActing and calls ExecutionEngine::Act (chrome/browser/actor/actor_task.cc:380).
  17. ExecutionEngine::Act initiates execution through KickOffNextAction() and ExecuteNextAction(), which instantiates ScriptToolHost and ultimately calls ToolController::Invoke after posting tasks across the message loop (chrome/browser/actor/execution_engine.cc).
  18. Immediately prior to invocation, ToolController::Invoke calls ScriptToolHost::TimeOfUseValidation(last_observation) (chrome/browser/actor/tools/tool_controller.cc:198).
  19. ScriptToolHost::TimeOfUseValidation fetches the current primary main frame’s document token, stored as primary_document_id (chrome/browser/actor/tools/script_tool_host.cc:93). Since the tab is still at attacker.com, this evaluates to attacker.com’s token.
  20. The validation logic checks if (primary_document_id != target_document_id_) (chrome/browser/actor/tools/script_tool_host.cc:97). Because both tokens were captured after the navigation to attacker.com, they are identical (attacker.com_token != attacker.com_token).
  21. The comparison evaluates to false. Furthermore, ScriptToolHost ignores the last_observation parameter (unlike PageTool::TimeOfUseValidation), meaning the document is never compared against the original bank.com observation state. The validation succeeds and returns MakeOkResult() (chrome/browser/actor/tools/script_tool_host.cc:105), bypassing the intended TOCTOU boundary.
  22. Execution proceeds to ScriptToolHost::Invoke, which constructs a ToolInvocation IPC carrying the sensitive input_arguments originally intended for bank.com (chrome/browser/actor/tools/script_tool_host.cc:140).
  23. ScriptToolHost::Invoke calls target_document_render_frame_->InvokeTool(...), dispatching the IPC over mojo to the attacker.com renderer (chrome/browser/actor/tools/script_tool_host.cc:153).
  24. The attacker’s registered script tool on attacker.com receives the invocation, successfully reading and exfiltrating the sensitive data.

Suggested Fix

Remove the else fallback block in CreateScriptToolRequest (chrome/browser/actor/actor_proto_conversion.cc:670-675) and strictly require the proto to specify the correct document identifier. If !action.has_document_identifier() is true, the method should simply return nullptr. Alternatively, if a fallback mechanism is functionally necessary, ScriptToolHost::TimeOfUseValidation must be updated to align with PageTool::TimeOfUseValidation by verifying the target document directly against the last_observation parameter, ensuring the document identity matches the original model input context.

3. Technical Verification Details (Automated Audit Logs)

Prior Critic verdict: > Severity: Medium (S2) > Brief Notes / Reasoning: Probability real: 99%, Details correct: 99%, Exploitable: 90%. The vulnerability report correctly identifies a TOCTOU flaw in CreateScriptToolRequest combined with ScriptToolHost::TimeOfUseValidation. When the document_identifier is missing from the ScriptToolAction proto, the conversion code in actor_proto_conversion.cc defaults to fetching the current document token of the main frame. Subsequently, ScriptToolHost::TimeOfUseValidation compares this token against the current document token of the main frame, which is always a tautology (X == X) if a navigation committed before CreateScriptToolRequest is called. Unlike PageTool::TimeOfUseValidation, ScriptToolHost does not validate the last_observation token, meaning it entirely fails to verify that the target document is the same one the model originally observed. By navigating the tab during the model’s round-trip, an attacker can cause the ScriptToolAction intended for the original page (e.g., bank.com) to be executed on their own origin, leading to a cross-origin data leak of any PII contained in the input_arguments. Code inspection confirms that navigation gating checks (HandleNavigationToNewOrigin) default to allowing the navigation (kGlicConfirmNavigationToNewOrigins is false). Severity Justification: S2 (Medium). The issue allows an attacker to read cross-origin data generated by the model for a different site. While cross-origin leaks can sometimes be High (S1), the requirement for the user (or an extension) to invoke the AI agent on the target page, combined with the timing requirement of navigating the tab during the model round-trip, makes Medium (S2) the appropriate rating. Per Chrome’s severity guidelines, because kGlicActor is an off-by-default feature without a field trial, we assess the severity as if the flag were enabled (effectively S2, though it carries Security_Impact-None in production today).

Audit Checks Executed & Confirmed:

  • Token Retrieval TOCTOU Verification: Codebase Investigator confirmed that actor_proto_conversion.cc:671 fetches tab_handle.Get()->GetContents()->GetPrimaryMainFrame() sequentially on request creation, generating a new UnguessableToken uniquely tied to the post-navigation frame.
  • Token Comparison Logic Check: ScriptToolHost::TimeOfUseValidation (script_tool_host.cc:93-97) compares this newly retrieved target_document_id_ with the primary_document_id. The codebase agent confirmed via Task Lifecycle analysis that multiple PostTask asynchronous boundaries occur between request creation (BuildToolRequest) and tool validation (ToolController::Invoke), confirming the timing gap. However, since the navigation completes before request creation, the validation is inherently X == X and succeeds.
  • Navigation Gating Absence Verification: Evaluated ActorNavigationThrottle::WillStartOrRedirectRequest. Determined it calls MayActOnUrl with std::nullopt as the origin_gating_cache. Evaluated MayActOnUrlInternal (site_policy.cc:264) confirming that !origin_gating_cache evaluates to true, causing a short-circuit to decision_wrapper->Accept(). Evaluated kGlicConfirmNavigationToNewOrigins in actor_features.cc:58-62, confirming its default state is indeed false, granting automatic bypass inside ExecutionEngine::HandleNavigationToNewOrigin.
  • No Mitigation Found in Actor Controls: Evaluated auxiliary security mechanisms (SetFocusState, IncrementCapturerCount, ForbidExternalPopupMenus). Codebase Investigator confirmed none of these block JavaScript-driven navigation (e.g., window.opener.location), ensuring the attacker navigation path is fully unobstructed.
  • Observation Data Boundary: Codebase Investigator checked script_tool_host.cc and verified that the last_observation parameter passed to TimeOfUseValidation is never used to validate the frame identity, a critical divergence from PageTool::TimeOfUseValidation.

Evaluated with Chrome root at commit: 849d34c7b2ab92513cebb86abb5203390a703eeb


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
Links in the report