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 <[email protected]> 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 <[email protected]> Commit-Queue: Ben Greenstein <[email protected]> Auto-Submit: Mason Freed <[email protected]> Commit-Queue: Mason Freed <[email protected]> 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