CVE-2026-3918
Overview
Background
- `DeclarativeWebMCPTool`
- an interface for form-based (“declarative”) WebMCP tools, implemented by the
HTMLFormElement-associated tool object (calledHTMLFormMcpToolin the commit message), exposingExecuteToolandComputeInputSchema. - `ModelContext::ToolData`
- the per-tool record stored in
ModelContext’stool_map_, holding thescript_tooldescription plus either av8_tool_function(JS tool) or adeclarative_tool(form tool). - Oilpan tracing
- Blink’s garbage collector only keeps an object alive if it is reachable through traced
Member<>fields; a raw C++ pointer to a garbage-collected object is invisible to the GC and can dangle after collection. - `GarbageCollectedMixin`
- the Oilpan base used for interfaces/mixins so that a
Member<Interface>can be traced to the concrete garbage-collected implementation.
Root Cause Analysis
Before the patch, ModelContext::ToolData stored the registered form tool as a raw DeclarativeWebMCPTool* declarative_tool, and ModelContext::ToolData::Trace traced only v8_tool_function, so a registered declarative tool was not kept alive by ModelContext. Per the commit message, if the form-associated tool was reclaimed by GC while still registered, a later walk of tool_map_ (via ForEachScriptTool, reachable from navigator.modelContext.listTools()) would dereference the dangling pointer when it calls declarative_tool->ComputeInputSchema() to refresh input_schema. The violated invariant is that every object referenced from tool_map_ must remain alive while its entry is registered.
The fix makes DeclarativeWebMCPTool a GarbageCollectedMixin, changes the field to Member<DeclarativeWebMCPTool>, traces it in ModelContext::ToolData::Trace, and marks the form tool’s Trace as override of the mixin’s virtual Trace. The regression test ForEachScriptToolGC confirms the tool now survives CollectAllGarbageForTesting() while registered and is reclaimed only after unregisterTool. The diff does not show which function encloses the tool_map_ loop at model_context.cc line ~162 (its name is not in the hunk); the commit message attributes the dereference to ForEachScriptTool.
Member<> (with the interface made a GarbageCollectedMixin) ties the tool’s lifetime to its registration.Attack Path
- Register a declarative tool
Page markup/script creates a form that registers itself with
ModelContextas a declarative WebMCP tool viaRegisterDeclarativeTool, storing a raw pointer inToolData. - Drop all strong references
The page arranges for the form-associated tool object to become unreachable to Oilpan (e.g., by removing and releasing the form) while its
tool_map_entry remains; the exact DOM sequence is(inferred). - Trigger garbage collection
A GC cycle reclaims the tool object because
ToolDatadid not trace it. - Enumerate tools
Calling
navigator.modelContext.listTools()walkstool_map_and invokesComputeInputSchema()on the freeddeclarative_tool, a use-after-free of a polymorphic object; control over the reclaimed slot is(inferred).
Impact Assessment
(inferred). Preconditions: the WebMCP runtime feature (and its declarative form-tool support) must be enabled — the test suite enables it explicitly, and the diff does not show whether it is on by default — and the page must be able to get the form tool collected and then call listTools(); no compromised renderer or extension is required by anything in the diff.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
Audit Directions
- Raw pointers to GC objects in registriesGrep
third_party/blink/renderer/core/script_tools/and other agent/AI-facing Blink code for rawT*fields inGarbageCollected/DISALLOW_NEWstructs that point at Oilpan objects withoutMember<>/WeakMember<>. - Interfaces implemented by GC classesLook for abstract C++ interfaces implemented by garbage-collected DOM classes (forms, elements) that are stored by pointer elsewhere; they need
GarbageCollectedMixinplus tracing, or explicit unregistration on teardown. - Unregistration on DOM removalCheck that declarative tools are removed from
ModelContextwhen forms are detached or documents shut down, as weak-lifetime assumptions here can also leave stale entries.
Patch
From 2ba59d32b3ea3125e8c5aa6acddaf7d411fca7a7 Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
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 <masonf@chromium.org>
Reviewed-by: Ben Greenstein <bengr@chromium.org>
Commit-Queue: Ben Greenstein <bengr@chromium.org>
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