← Chrome — 2026-W39

1d82312be9e18813ceacb379339d84b09ee93ced  [actor] Prevent UAF in ScriptTool and ModelContext

severity high class UAF CVSS 6.3 signal score 9.57 third_party/blink/renderer/core/ AI review CONFIRMED
Structural signals: smartptr_member_added (+4.87), null_guard (+1.2), msg_strong (+3.0), area:third_party/blink/renderer/core/ (+2.5), large_churn (-2.0)

The bug at a glance

This is a real renderer use-after-free: a const reference to ScriptTool::execution_id_ dangles after page JS synchronously destroys the ScriptTool. Two things lower the rating. The tests show it is gated on the WebMCP and Glic actor script-tools features (kWebMCP, kGlicActorEnableScriptTools). The freed object is only read as a 16-byte UnguessableToken, not used as a vtable or pointer, so the primitive is weak.

A malicious page that exposes a WebMCP tool to the browser’s AI actor can make the tool’s execute() or abort/toolcancel handler remove its own frame. This frees the renderer-side ScriptTool while Blink is still using a reference into it, a classic re-entrancy use-after-free reached through an agent-driven API.

Root cause

ScriptTool::Execute()/Cancel() passed execution_id_.value() by const reference into WebDocument::ExecuteScriptTool/CancelScriptTool and on to ModelContext::ExecuteTool/CancelTool/ExecuteV8Tool/ExecuteDeclarativeTool. These synchronously run page JavaScript (tool_function->Invoke() or abort_controller->abort()). If that JS detaches the frame, ToolExecutor and ScriptTool are destroyed, and ModelContext keeps reading invocation_id through the dangling reference. The fix copies the token onto the stack in ScriptTool and changes every ModelContext signature to take base::UnguessableToken by value.

Key code

Copy the execution token to the stack and pass it by value across the re-entrant JS call

+  const base::UnguessableToken execution_id = execution_id_.value();
   frame_->GetWebFrame()->GetDocument().ExecuteScriptTool(
-      execution_id_.value(), blink::WebString::FromUtf8(action_->name),
+      execution_id, blink::WebString::FromUtf8(action_->name),
...
-bool ModelContext::CancelTool(const base::UnguessableToken& invocation_id) {
+bool ModelContext::CancelTool(base::UnguessableToken invocation_id) {

Patch walkthrough

  • chrome/renderer/actor/script_tool.cc — Execute() and Cancel() copy execution_id_ into a local before calling into Blink, so the reference stays valid if this is destroyed by page JS. Cancel() keeps its weak_this check before resetting execution_id_.
  • third_party/blink/renderer/core/script_tools/model_context.cc — ExecuteTool, CancelTool, ExecuteDeclarativeTool and ExecuteV8Tool now take the invocation token by value. ModelContext keeps no reference into caller-owned memory across tool_function->Invoke() and abort().
  • third_party/blink/renderer/core/script_tools/model_context.h — Matching signature changes from const& to by-value.
  • chrome/browser/actor/tools/uaf_regression_browsertest.cc — New ActorUafRegressionScriptToolBrowserTest (with kWebMCP and kGlicActorEnableScriptTools enabled) checks that synchronous frame detachment during Execute and Cancel does not crash the renderer.
  • components/test/data/actor/script_tool_uaf_inner.html — Test page that registers tools whose execute() or abort/toolcancel handlers remove an ancestor iframe, which is the trigger shape.
  • third_party/blink/web_tests/external/wpt/webmcp/imperative/executeTool-target-detachment.https.html — WPTs that synchronously detach the tool’s frame from execute() and from the abort signal while executeTool() is in flight.

Background

WebMCP / document.modelContext — Experimental Blink API that lets pages register tools (registerTool) that an AI agent can invoke, with an AbortSignal for cancellation.

Actor ScriptTool — Chrome’s renderer-side actor tool that drives a page-registered WebMCP tool on behalf of the Glic browser agent.

base::UnguessableToken — A 128-bit random token; here it identifies one tool invocation and is used to look up pending executions.

Vulnerability window

  1. Reachability — Page-controlled JS in a stock build only when WebMCP and the Glic actor script-tools features are enabled (the tests enable kWebMCP and kGlicActorEnableScriptTools explicitly). The agent or user has to invoke or cancel the page’s tool, and the frame needs allow=“tools”.
  2. Trigger — The tool’s execute() callback, or its abort/toolcancel handler, synchronously removes an ancestor iframe and destroys ToolExecutor/ScriptTool while ModelContext::ExecuteV8Tool/CancelTool is still on the stack.
  3. Primitive — ModelContext reads invocation_id through a dangling reference into freed ScriptTool memory: a 16-byte read-after-free used as a lookup or result key (inferred).
  4. Escalation — Reclaiming the freed slot could let the attacker steer which invocation id is used, which might confuse bookkeeping for pending executions (inferred). Turning this into renderer code execution looks hard, and a sandbox escape would need a separate bug (inferred).

Proof of concept

Conceptually: in an iframe that has the tools permission, register a WebMCP tool whose execute handler (or abort-signal / toolcancel listener) removes the iframe’s ancestor container. When the browser agent invokes or cancels that tool, the frame and its ScriptTool are torn down mid-call. The browser tests in this commit model this shape and only assert no crash.

Exploitation

  1. Setup — Attacker page registers a tool and gets the agent to call it, for example by advertising a useful tool (inferred).
  2. Free — The synchronous frame detach inside the tool callback frees ScriptTool and ToolExecutor.
  3. Reuse — ModelContext dereferences the stale token reference after JS returns; heap reclaiming could control the bytes read (inferred).

Detection & hunting

For defenders and detection engineers:

  • weggli -R ‘func=Execute|Cancel’ ‘{ _ $func(…) { $obj.$m(_.value(), …); } }’ chrome/renderer/actor — Find places that pass optional/member .value() by reference into calls that can run script.
  • grep -rn “const base::UnguessableToken&” third_party/blink/renderer/core/script_tools — Any const-ref token parameter that spans an Invoke()/abort()/DispatchEvent call is a re-entrancy hazard.
  • ASan heap-use-after-free in ModelContext::ExecuteV8Tool / CancelTool / ScriptTool — Look for these frames in ClusterFuzz or crash reports with WebMCP enabled.

Audit directions

  • chrome/renderer/actor/*_tool.cc (other actor tools that call into Blink) — Check whether other tools pass member state by reference into Blink calls that can dispatch events or run JS and detach the frame.
  • ModelContext::ExecuteCrossDocumentTool and similar paths that still take const base::UnguessableToken& — The header still shows a const& invocation_id on a cross-document path; verify that it cannot outlive its owner across script execution.
  • AbortController::abort() callers in renderer code — abort() synchronously fires the abort event; audit callers that keep raw references or this-pointers across it.

Before / after

Loading diff…