Chrome · Agents
CVE-2026-3917
UAF in Agents
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified | |
ReentrantListenerthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/script_tools/model_context.ccthird_party/blink/renderer/core/script_tools/model_context_test.cc
Patch
From cf01b963406f63ce3fbf5d5b1383e5dca34682fa Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
Date: Wed, 11 Feb 2026 17:23:05 -0800
Subject: [PATCH] Fix UAF in ModelContext::CancelTool due to iterator invalidation
ModelContext::CancelTool held a HashMap iterator across a synchronous
DispatchEvent call for the 'toolcancel' event. Because this event
dispatches to JavaScript, a script listener could re-enter the
ModelContext (e.g., by calling executeTool()) and cause the
pending_executions_ map to rehash or mutate. This invalidated the
iterator held by CancelTool, leading to a UAF when the iterator was
subsequently used to access the callback or erase the entry.
Note from the bug: the recent fix in
https://chromium-review.googlesource.com/c/chromium/src/+/7531756
originally trying to fix the UaF, but the later commit in
https://chromium-review.googlesource.com/c/chromium/src/+/7540370
introduces this UAF.
Fixed: 483569512
Change-Id: Ic97b93f1f40ee09dc2742266b1b80bc5b554b876
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7569343
Reviewed-by: Ben Greenstein <bengr@chromium.org>
Commit-Queue: Ben Greenstein <bengr@chromium.org>
Auto-Submit: Mason Freed <masonf@chromium.org>
Commit-Queue: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1583638}
---
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 6d07706..6cc73e5 100644
--- a/third_party/blink/renderer/core/script_tools/model_context.cc
+++ b/third_party/blink/renderer/core/script_tools/model_context.cc
@@ -254,17 +254,24 @@
}
void ModelContext::CancelTool(uint32_t execution_id) {
+ auto it = pending_executions_.find(execution_id);
+ if (it == pending_executions_.end()) {
+ return;
+ }
+ String tool_name = it->value.tool_name;
+
+ if (LocalDOMWindow* window = document_->domWindow()) {
+ // This is a synchronous, non-cancelable event. Note that this can re-enter
+ // JavaScript and modify `pending_executions_`.
+ window->DispatchEvent(
+ *WebMCPEvent::Create(event_type_names::kToolcancel, tool_name));
+ }
+
+ // The pending_executions_ map might have been rehashed during DispatchEvent.
auto pending_execution = pending_executions_.find(execution_id);
if (pending_execution == pending_executions_.end()) {
return;
}
-
- if (LocalDOMWindow* window = document_->domWindow()) {
- // This is a synchronous, non-cancelable event.
- window->DispatchEvent(*WebMCPEvent::Create(
- event_type_names::kToolcancel, pending_execution->value.tool_name));
- }
-
task_runner_->PostTask(
FROM_HERE,
blink::BindOnce(std::move(pending_execution->value.callback),
diff --git a/third_party/blink/renderer/core/script_tools/model_context_test.cc b/third_party/blink/renderer/core/script_tools/model_context_test.cc
index 7373e36..cb304cc 100644
--- a/third_party/blink/renderer/core/script_tools/model_context_test.cc
+++ b/third_party/blink/renderer/core/script_tools/model_context_test.cc
@@ -16,6 +16,7 @@
#include "third_party/blink/public/web/web_script_source.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
#include "third_party/blink/renderer/core/dom/abort_controller.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/web_local_frame_impl.h"
#include "third_party/blink/renderer/core/script_tools/model_context_supplement.h"
@@ -72,6 +73,7 @@
private:
ScopedWebMCPForTest scoped_webmcp_{true};
+ ScopedWebMCPTestingForTest scoped_webmcp_testing_{true};
};
TEST_F(ModelContextTest, ExecuteTool) {
@@ -965,4 +967,79 @@
EXPECT_EQ(EvalJsString("window.select_val"), "3");
}
+class ReentrantListener : public NativeEventListener {
+ public:
+ explicit ReentrantListener(ModelContext* model_context)
+ : model_context_(model_context) {}
+ void Invoke(ExecutionContext*, Event*) override {
+ // Trigger HashMap modification by adding a new execution.
+ model_context_->ExecuteTool("echo", "{}", nullptr, base::DoNothing());
+ }
+ void Trace(Visitor* visitor) const override {
+ visitor->Trace(model_context_);
+ NativeEventListener::Trace(visitor);
+ }
+
+ private:
+ Member<ModelContext> model_context_;
+};
+
+TEST_F(ModelContextTest, CancelToolReentrancy) {
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ v8::HandleScope handle_scope(Window().GetIsolate());
+ ScriptState::Scope script_scope(
+ ToScriptStateForMainWorld(Window().GetFrame()));
+
+ main_resource.Complete(R"(
+ <body>
+ <script>
+ async function hang(obj) {
+ return new Promise(() => {});
+ }
+
+ navigator.modelContext.registerTool({
+ execute: hang,
+ name: "hang",
+ description: "never resolves",
+ });
+
+ // We also need another tool that can be executed.
+ navigator.modelContext.registerTool({
+ execute: async () => "done",
+ name: "echo",
+ description: "echo",
+ });
+ </script>
+)");
+
+ auto* model_context =
+ ModelContextSupplement::modelContext(*Window().navigator());
+ ASSERT_TRUE(model_context);
+
+ Window().addEventListener(
+ event_type_names::kToolcancel,
+ MakeGarbageCollected<ReentrantListener>(model_context), false);
+
+ base::RunLoop run_loop;
+
+ std::optional<uint32_t> execution_id = model_context->ExecuteTool(
+ "hang", "{}", /* signal= */ nullptr,
+ base::BindLambdaForTesting(
+ [&](base::expected<WebString, WebDocument::ScriptToolError> res) {
+ EXPECT_FALSE(res.has_value());
+ EXPECT_EQ(res.error(),
+ WebDocument::ScriptToolError::kToolCancelled);
+ run_loop.Quit();
+ }));
+
+ ASSERT_TRUE(execution_id.has_value());
+
+ // This should trigger the toolcancel event, which re-enters and modifies
+ // pending_executions_.
+ model_context->CancelTool(*execution_id);
+
+ run_loop.Run();
+}
+
} // namespace blink
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/renderer/core/script_tools/model_context_test.cc b/third_party/blink/renderer/core/script_tools/model_context_test.cc
index 7373e36..cb304cc 100644
--- a/third_party/blink/renderer/core/script_tools/model_context_test.cc
+++ b/third_party/blink/renderer/core/script_tools/model_context_test.cc
@@ -16,6 +16,7 @@
#include "third_party/blink/public/web/web_script_source.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
#include "third_party/blink/renderer/core/dom/abort_controller.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/web_local_frame_impl.h"
#include "third_party/blink/renderer/core/script_tools/model_context_supplement.h"
@@ -72,6 +73,7 @@
private:
ScopedWebMCPForTest scoped_webmcp_{true};
+ ScopedWebMCPTestingForTest scoped_webmcp_testing_{true};
};
TEST_F(ModelContextTest, ExecuteTool) {
@@ -965,4 +967,79 @@
EXPECT_EQ(EvalJsString("window.select_val"), "3");
}
+class ReentrantListener : public NativeEventListener {
+ public:
+ explicit ReentrantListener(ModelContext* model_context)
+ : model_context_(model_context) {}
+ void Invoke(ExecutionContext*, Event*) override {
+ // Trigger HashMap modification by adding a new execution.
+ model_context_->ExecuteTool("echo", "{}", nullptr, base::DoNothing());
+ }
+ void Trace(Visitor* visitor) const override {
+ visitor->Trace(model_context_);
+ NativeEventListener::Trace(visitor);
+ }
+
+ private:
+ Member<ModelContext> model_context_;
+};
+
+TEST_F(ModelContextTest, CancelToolReentrancy) {
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ v8::HandleScope handle_scope(Window().GetIsolate());
+ ScriptState::Scope script_scope(
+ ToScriptStateForMainWorld(Window().GetFrame()));
+
+ main_resource.Complete(R"(
+ <body>
+ <script>
+ async function hang(obj) {
+ return new Promise(() => {});
+ }
+
+ navigator.modelContext.registerTool({
+ execute: hang,
+ name: "hang",
+ description: "never resolves",
+ });
+
+ // We also need another tool that can be executed.
+ navigator.modelContext.registerTool({
+ execute: async () => "done",
+ name: "echo",
+ description: "echo",
+ });
+ </script>
+)");
+
+ auto* model_context =
+ ModelContextSupplement::modelContext(*Window().navigator());
+ ASSERT_TRUE(model_context);
+
+ Window().addEventListener(
+ event_type_names::kToolcancel,
+ MakeGarbageCollected<ReentrantListener>(model_context), false);
+
+ base::RunLoop run_loop;
+
+ std::optional<uint32_t> execution_id = model_context->ExecuteTool(
+ "hang", "{}", /* signal= */ nullptr,
+ base::BindLambdaForTesting(
+ [&](base::expected<WebString, WebDocument::ScriptToolError> res) {
+ EXPECT_FALSE(res.has_value());
+ EXPECT_EQ(res.error(),
+ WebDocument::ScriptToolError::kToolCancelled);
+ run_loop.Quit();
+ }));
+
+ ASSERT_TRUE(execution_id.has_value());
+
+ // This should trigger the toolcancel event, which re-enters and modifies
+ // pending_executions_.
+ model_context->CancelTool(*execution_id);
+
+ run_loop.Run();
+}
+
} // namespace blink
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page