CVE-2026-16806
Overview
Files Changed
third_party/blink/renderer/core/script_tools/model_context.cc
Patch
From 4e58c2cff37c50c6b6298bb514d0a8d6cb256484 Mon Sep 17 00:00:00 2001 From: Ben Greenstein <[email protected]> Date: Mon, 29 Jun 2026 16:51:15 -0700 Subject: [PATCH] [WebMCP] Take pending execution before running completion ModelContext::OnToolExecuted held an iterator into pending_executions_ while invoking probe::WebMCPToolFailed / WebMCPToolResponded and the stored completion callback. Both of these can re-enter ModelContext (via DevTools wrapping the thrown error, or via the caller's callback) and mutate pending_executions_, which can rehash the table and invalidate the iterator before it is erased. Use HashMap::Take() to move the entry out of the map before making any potentially re-entrant calls, matching the existing handling in CancelTool. Bug: 522064153 Change-Id: I4a439c4f9c93984f0164a63814c3c576b7e7bcf1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7987973 Reviewed-by: Dominic Farolino <[email protected]> Commit-Queue: Ben Greenstein <[email protected]> Cr-Commit-Position: refs/heads/main@{#1654471} --- 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 f9e5ddcc..b43715b 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.cc +++ b/third_party/blink/renderer/core/script_tools/model_context.cc @@ -512,13 +512,12 @@ // The pending_executions_ map might have been rehashed during DispatchEvent. auto pending_execution = - pending_executions_.find(String(invocation_id.ToString())); - if (pending_execution == pending_executions_.end()) { + pending_executions_.Take(String(invocation_id.ToString())); + if (pending_execution.callback.is_null()) { return false; } - OnToolFailed(std::move(pending_execution->value.callback), invocation_id, + OnToolFailed(std::move(pending_execution.callback), invocation_id, ScriptToolError(ScriptToolErrorCode::kToolCancelled)); - pending_executions_.erase(pending_execution); return true; } @@ -734,20 +733,20 @@ void ModelContext::OnToolExecuted( const base::UnguessableToken& invocation_id, base::expected<String, std::pair<ScriptValue, ScriptState*>> result) { - auto it = pending_executions_.find(String(invocation_id.ToString())); - if (it == pending_executions_.end()) { + auto pending_execution = + pending_executions_.Take(String(invocation_id.ToString())); + if (pending_execution.callback.is_null()) { return; } if (result.has_value()) { probe::WebMCPToolResponded(document_, result.value(), invocation_id); - std::move(it->value.callback).Run(result.value()); + std::move(pending_execution.callback).Run(result.value()); } else { ScriptToolError error(ScriptToolErrorCode::kToolInvocationFailed); probe::WebMCPToolFailed(document_, error, invocation_id, result.error()); - std::move(it->value.callback).Run(base::unexpected(error)); + std::move(pending_execution.callback).Run(base::unexpected(error)); } - pending_executions_.erase(it); } void ModelContext::MaybeRecordToolCount() {
Original Bug Report
Potential Use-After-Free in ModelContext::OnToolExecuted via Synchronous JS Re-entrancy
Flapjack, 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: A potential Use-After-Free exists in ModelContext::OnToolExecuted because a WTF::HashMap iterator is held across a DevTools probe that can execute synchronous user JavaScript. An attacker can use an AbortSignal inside this JS callback to re-entrantly modify the map, causing its backing store to shrink and be freed before the iterator is accessed. This dangling pointer could be leveraged to execute arbitrary code in the renderer process.
Affected files:
third_party/blink/renderer/core/script_tools/model_context.cc
Estimated timestamp from git blame: 2026-03-23
Summary
A potential Use-After-Free (UAF) vulnerability exists in ModelContext::OnToolExecuted within third_party/blink/renderer/core/script_tools/model_context.cc. The vulnerability occurs because a raw WTF::HashMap iterator is held across a call to probe::WebMCPToolFailed. This probe can trigger synchronous execution of user-controlled JavaScript when the DevTools inspector is open. This re-entrancy allows an attacker to manipulate the map and free its backing store, invalidating the iterator before it is subsequently used to execute a callback.
Vulnerability Details
In ModelContext::OnToolExecuted, an iterator it is obtained from the pending_executions_ map:
void ModelContext::OnToolExecuted(
const base::UnguessableToken& invocation_id,
base::expected<String, std::pair<ScriptValue, ScriptState*>> result) {
auto it = pending_executions_.find(String(invocation_id.ToString()));
// ...
if (result.has_value()) {
// ...
} else {
ScriptToolError error(ScriptToolErrorCode::kToolInvocationFailed);
probe::WebMCPToolFailed(document_, error, invocation_id, result.error());
std::move(it->value.callback).Run(base::unexpected(error));
}
pending_executions_.erase(it);
}
When a tool execution fails and probe::WebMCPToolFailed is called, the DevTools inspector (if attached) invokes InspectorWebMCPAgent::WebMCPToolFailed. This method attempts to format the JavaScript exception for the inspector protocol using v8_session_->wrapObject.
V8’s wrapObject delegates to descriptionForError (v8/src/inspector/value-mirror.cc), which attempts to fetch the name and stack properties of the error object. It does this via getErrorProperty(). While getErrorProperty() includes checks to prevent the execution of getters defined directly on the object (via GetOwnPropertyDescriptor), it falls back to standard property access (object->Get()) if the property is not found. For standard JS Error objects, the name property is inherited from Error.prototype. Therefore, standard property access is used, which evaluates getters on the prototype chain without any ScriptId safety checks.
An attacker can define a malicious getter on Error.prototype.name. When this getter executes synchronously, the attacker can invoke abortController.abort() on previously registered dummy tools. This triggers ModelContext::CancelTool, which calls pending_executions_.erase(). If enough tools are erased, WTF::HashMap::Shrink() is triggered, allocating a new backing buffer and freeing the old one via PartitionAlloc.
When control returns to ModelContext::OnToolExecuted, the iterator it continues to point into the freed backing buffer. The code then uses this dangling pointer to execute the tool’s callback (std::move(it->value.callback).Run(...)).
Potential Exploitation Steps
- The attacker ensures DevTools is open and attached to the page.
- The attacker defines a malicious JavaScript getter:
Object.defineProperty(Error.prototype, 'name', { get: function() { ... } });. - The attacker registers a large number of dummy WebMCP tools via the
ModelContextAPI, passing anAbortSignalfor each. - The attacker triggers execution of all dummy tools, growing the
pending_executions_map’s backing store. - The attacker executes a target tool, adding it to the map.
- The attacker intentionally causes the target tool’s execution to fail by throwing a standard JS
Error. - The failure triggers
ModelContext::OnToolExecuted, which fetches the iteratoritand invokes the DevTools probe. - The probe accesses the error’s
nameproperty, synchronously triggering the attacker’s getter. - Inside the getter, the attacker aborts all the dummy tools. This causes
pending_executions_to erase the dummy entries and shrink, freeing the backing buffer. - Still inside the getter, the attacker sprays the PartitionAlloc heap to replace the freed backing buffer with attacker-controlled data, forging a
base::OnceCallbackat the target tool’s index. - The getter returns.
OnToolExecutedresumes and executesstd::move(it->value.callback).Run(...)using the dangling iterator, hijacking the instruction pointer and achieving arbitrary Remote Code Execution (RCE) in the renderer process.
Suggested Fix
Do not hold the WTF::HashMap iterator across the probe::WebMCPToolFailed call, as the map can be mutated during the synchronous callback. The element should be removed from the map before the probe is invoked and the callback is executed. For example, use HashMap::Take() to extract the PendingExecution struct by value:
auto pending_execution = pending_executions_.Take(String(invocation_id.ToString()));
// Check if pending_execution is valid...
if (result.has_value()) {
probe::WebMCPToolResponded(document_, result.value(), invocation_id);
std::move(pending_execution.callback).Run(result.value());
} else {
ScriptToolError error(ScriptToolErrorCode::kToolInvocationFailed);
probe::WebMCPToolFailed(document_, error, invocation_id, result.error());
std::move(pending_execution.callback).Run(base::unexpected(error));
}
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
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.