CVE-2026-3918
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/script_tools/model_context.cc |
modified | |
ifthird_party/blink/renderer/core/script_tools/model_context.cc |
modified | |
AbortSignalthird_party/blink/renderer/core/script_tools/model_context.h |
modified | |
DeclarativeWebMCPToolthird_party/blink/renderer/core/script_tools/model_context.h |
modified | |
MockDeclarativeToolthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified | |
ifthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/forms/html_form_element.hthird_party/blink/renderer/core/script_tools/model_context.ccthird_party/blink/renderer/core/script_tools/model_context.hthird_party/blink/renderer/core/script_tools/model_context_test.cc
Patch
From 2ba59d32b3ea3125e8c5aa6acddaf7d411fca7a7 Mon Sep 17 00:00:00 2001 From: Mason Freed <[email protected]> Date: Fri, 13 Feb 2026 14:04:07 -0800 Subject: [PATCH] Make DeclarativeWebMCPTool garbage collected Previously, ModelContext::RegisterDeclarativeTool stored a raw pointer to a DeclarativeWebMCPTool in ToolData. If the underlying tool (e.g., an HTMLFormMcpTool associated with a form) was reclaimed by GC while still registered, subsequent calls to ForEachScriptTool (via navigator.modelContext.listTools()) would dereference a dangling pointer. This CL fixes the issue by making DeclarativeWebMCPTool garbage collected. This ensures that any registered declarative tool is kept alive as long as it remains in the ModelContext tool map. Fixed: 483853103 Change-Id: I5422ffaafc52cb7c52549823e6a243627992066c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7577275 Auto-Submit: Mason Freed <[email protected]> Reviewed-by: Ben Greenstein <[email protected]> Commit-Queue: Ben Greenstein <[email protected]> Cr-Commit-Position: refs/heads/main@{#1584900} --- diff --git a/third_party/blink/renderer/core/html/forms/html_form_element.h b/third_party/blink/renderer/core/html/forms/html_form_element.h index a47237c..25725b7 100644 --- a/third_party/blink/renderer/core/html/forms/html_form_element.h +++ b/third_party/blink/renderer/core/html/forms/html_form_element.h @@ -283,7 +283,7 @@ return active_submit_button_; } void CallDoneCallback(McpToolCallbackResult result); - void Trace(Visitor* visitor) const; + void Trace(Visitor* visitor) const override; private: bool is_currently_running_ = false; 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 58d7f83..7eb8c208 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.cc +++ b/third_party/blink/renderer/core/script_tools/model_context.cc @@ -162,7 +162,7 @@ for (const auto& tool : tool_map_) { auto tool_data = tool.value; // Always update the input schema, since the DOM might have changed. - if (auto* declarative_tool = tool_data->declarative_tool) { + if (auto declarative_tool = tool_data->declarative_tool) { tool_data->script_tool->input_schema = declarative_tool->ComputeInputSchema(); } @@ -527,6 +527,7 @@ void ModelContext::ToolData::Trace(Visitor* visitor) const { visitor->Trace(v8_tool_function); + visitor->Trace(declarative_tool); } } // namespace blink 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 94baf85d..c238f6b 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.h +++ b/third_party/blink/renderer/core/script_tools/model_context.h @@ -25,7 +25,7 @@ class AbortSignal; -class DeclarativeWebMCPTool { +class DeclarativeWebMCPTool : public GarbageCollectedMixin { public: // Executes the associated tool and invokes `done_callback` with the result // when the execution is finished. The callback is invoked with a null string @@ -113,7 +113,7 @@ // A JS-provided MCP tool: Member<V8ToolFunction> v8_tool_function; // Used for declarative (form-based) MCP tools only: - DeclarativeWebMCPTool* declarative_tool; + Member<DeclarativeWebMCPTool> declarative_tool; }; bool RegisterTool(ScriptState* script_state, 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 cb304cc..0cf36a2c 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 @@ -22,6 +22,7 @@ #include "third_party/blink/renderer/core/script_tools/model_context_supplement.h" #include "third_party/blink/renderer/core/testing/sim/sim_request.h" #include "third_party/blink/renderer/core/testing/sim/sim_test.h" +#include "third_party/blink/renderer/platform/heap/thread_state.h" #include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h" namespace blink { @@ -1042,4 +1043,58 @@ run_loop.Run(); } +class MockDeclarativeTool : public GarbageCollected<MockDeclarativeTool>, + public DeclarativeWebMCPTool { + public: + void ExecuteTool(String input_arguments, + base::OnceCallback<void( + base::expected<String, WebDocument::ScriptToolError>)> + done_callback) override {} + + String ComputeInputSchema() override { return "{}"; } + void Trace(Visitor* visitor) const override {} +}; + +TEST_F(ModelContextTest, ForEachScriptToolGC) { + SimRequest main_resource("https://example.com/", "text/html"); + LoadURL("https://example.com/"); + main_resource.Complete("<body></body>"); + + auto* model_context = + ModelContextSupplement::modelContext(*Window().navigator()); + ASSERT_TRUE(model_context); + + { + auto* mock_tool = MakeGarbageCollected<MockDeclarativeTool>(); + model_context->RegisterDeclarativeTool("test_tool", "description", + mock_tool); + } + + // Trigger GC, which should not reclaim mock_tool. + ThreadState::Current()->CollectAllGarbageForTesting(); + + // This should not crash and should find the tool. + bool found = false; + model_context->ForEachScriptTool([&](const mojom::blink::ScriptTool& tool) { + if (tool.name == "test_tool") { + found = true; + } + }); + EXPECT_TRUE(found); + + // Now unregister it. + model_context->unregisterTool("test_tool", ASSERT_NO_EXCEPTION); + + // Trigger GC again. Now it should be reclaimed. + ThreadState::Current()->CollectAllGarbageForTesting(); + + found = false; + model_context->ForEachScriptTool([&](const mojom::blink::ScriptTool& tool) { + if (tool.name == "test_tool") { + found = true; + } + }); + EXPECT_FALSE(found); +} + } // namespace blink
Regression Test / PoC
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 cb304cc..0cf36a2c 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
@@ -22,6 +22,7 @@
#include "third_party/blink/renderer/core/script_tools/model_context_supplement.h"
#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
#include "third_party/blink/renderer/core/testing/sim/sim_test.h"
+#include "third_party/blink/renderer/platform/heap/thread_state.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
namespace blink {
@@ -1042,4 +1043,58 @@
run_loop.Run();
}
+class MockDeclarativeTool : public GarbageCollected<MockDeclarativeTool>,
+ public DeclarativeWebMCPTool {
+ public:
+ void ExecuteTool(String input_arguments,
+ base::OnceCallback<void(
+ base::expected<String, WebDocument::ScriptToolError>)>
+ done_callback) override {}
+
+ String ComputeInputSchema() override { return "{}"; }
+ void Trace(Visitor* visitor) const override {}
+};
+
+TEST_F(ModelContextTest, ForEachScriptToolGC) {
+ SimRequest main_resource("https://example.com/", "text/html");
+ LoadURL("https://example.com/");
+ main_resource.Complete("<body></body>");
+
+ auto* model_context =
+ ModelContextSupplement::modelContext(*Window().navigator());
+ ASSERT_TRUE(model_context);
+
+ {
+ auto* mock_tool = MakeGarbageCollected<MockDeclarativeTool>();
+ model_context->RegisterDeclarativeTool("test_tool", "description",
+ mock_tool);
+ }
+
+ // Trigger GC, which should not reclaim mock_tool.
+ ThreadState::Current()->CollectAllGarbageForTesting();
+
+ // This should not crash and should find the tool.
+ bool found = false;
+ model_context->ForEachScriptTool([&](const mojom::blink::ScriptTool& tool) {
+ if (tool.name == "test_tool") {
+ found = true;
+ }
+ });
+ EXPECT_TRUE(found);
+
+ // Now unregister it.
+ model_context->unregisterTool("test_tool", ASSERT_NO_EXCEPTION);
+
+ // Trigger GC again. Now it should be reclaimed.
+ ThreadState::Current()->CollectAllGarbageForTesting();
+
+ found = false;
+ model_context->ForEachScriptTool([&](const mojom::blink::ScriptTool& tool) {
+ if (tool.name == "test_tool") {
+ found = true;
+ }
+ });
+ EXPECT_FALSE(found);
+}
+
} // namespace blink
Original Bug Report
UAF in ModelContext::ForEachScriptTool
Summary
navigator.modelContextTesting.listTools() reaches ModelContext::ForEachScriptTool, which unconditionally calls ComputeInputSchema() on each declarative tool pointer stored in tool_map_. Declarative tool registration stores a raw pointer in ModelContext::ToolData::declarative_tool, and the pointer is not traced in GC. After iframe/document shutdown, a lifecycle gap in form-side unregister logic can leave a stale map entry; once the underlying HTMLFormMcpTool is reclaimed, ForEachScriptTool dereferences a dangling pointer and triggers renderer UAF/use-after-poison.
> Note that the root cause of this issue is different with the recent reported issue 483569512. And the fix in the issue 483569512 does not mitigate this issue as well.
Details
The current implementation path for DeclarativeWebMCPTool is: declarative form registration -> raw pointer retained in ModelContext::tool_map_ -> unregister skipped in a shutdown edge case -> later schema refresh dereferences stale pointer.
In ModelContext::RegisterDeclarativeTool, declarative registration stores DeclarativeWebMCPTool* into ToolData:
void ModelContext::RegisterDeclarativeTool(String name,
String description,
DeclarativeWebMCPTool* tool) {
auto script_tool = mojom::blink::ScriptTool::New();
auto* tool_data = MakeGarbageCollected<ToolData>();
script_tool->name = name;
script_tool->description = description;
script_tool->input_schema = "{}"; // For now
tool_data->script_tool = std::move(script_tool);
tool_data->declarative_tool = tool;
tool_map_.insert(name, std::move(tool_data));
OnToolsChanged();
}
In ModelContext::ToolData::Trace, only the V8 tool function is traced; the declarative pointer is not:
void ModelContext::ToolData::Trace(Visitor* visitor) const {
visitor->Trace(v8_tool_function);
}
In HTMLFormElement::UpdateMcpDefinitionsIfNeeded, the function returns early when ModelContext is unavailable (e.g., detached document with no domWindow()/navigator()), so the unregister path is skipped in that state:
ModelContext* model_context = nullptr;
if (auto* window = GetDocument().domWindow(); window && window->navigator()) {
model_context = ModelContextSupplement::modelContext(*window->navigator());
}
if (!model_context) {
return;
}
if (IsValidWebMCPForm()) {
...
model_context->unregisterTool(active_webmcp_tool_->ToolName(),
ASSERT_NO_EXCEPTION);
active_webmcp_tool_ = nullptr;
}
Later, ModelContext::ForEachScriptTool updates declarative schemas on iteration and dereferences the stale pointer:
void ModelContext::ForEachScriptTool(
base::FunctionRef<void(const mojom::blink::ScriptTool&)> func) const {
for (const auto& tool : tool_map_) {
auto tool_data = tool.value;
// Always update the input schema, since the DOM might have changed.
if (auto* declarative_tool = tool_data->declarative_tool) {
tool_data->script_tool->input_schema =
declarative_tool->ComputeInputSchema();
}
func(*tool_data->script_tool);
}
}
Therefore, we can leverage the following chain to achieve UAF: implementation stores an untraced raw pointer -> lifecycle edge skips unregister -> GC reclaims the declarative tool object -> listTools() reaches ForEachScriptTool and dereferences freed memory.
REPRODUCTION
Build args on commit 349bddcf54ebcd90e7d4d6b00433982956d3b33e in Linux:
is_asan=true
is_component_build=true
dcheck_always_on=false
is_debug=false
Run command:
./chrome --user-data-dir=/tmp/xx --enable-features=WebMCPTesting --enable-experimental-web-platform-features --no-first-run --no-sandbox --js-flags=--expose-gc poc.html
We should observe the Use-after-Poison crash shown in the asan.txt.
This has been reachable in the M145, and will be shipped in the M146 dev trial.
Bisection
This issue is introduced by the commit 3df80580897d24fbb730c0ee411f25ffe04e766b.
SUGGESTED FIX
Make declarative tool lifetime GC-safe at the ToolData boundary and enforce stale-entry pruning during iteration.
We may:
- Change
ToolData::declarative_toolfrom rawDeclarativeWebMCPTool*to a GC-traceable handle. - Update
ToolData::Trace()to trace declarative tool references. - Add a defensive guard in
ForEachScriptToolto skip and remove entries whose declarative tool is no longer valid before callingComputeInputSchema().
This keeps tool_map_ entries from holding dangling declarative pointers and prevents listTools() from dereferencing reclaimed objects.
- https://issuetracker.google.com/issues/483569512
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/forms/html_form_element.cc;l=302
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script_tools/model_context.cc;l=160
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script_tools/model_context.cc;l=475
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script_tools/model_context.cc;l=528
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script_tools/model_context.h;l=116
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/script_tools/model_context.h;l=127