Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Actor
DescriptionUse after free in Actor
ComponentActor
Sub-componentActor
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker516404074
Fix commit995c9093715d (chromium/src) +123/-25
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-25 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-22
Patch gap25 days — fix public in source before the advisory

Background

Actor
Chrome’s automation subsystem that lets an agent invoke “tools” (clicks, scrolls, form fills) against page targets on the user’s behalf, spanning a browser-process ToolExecutor and renderer-process tool logic.
`hidden=until-found`
an HTML attribute that keeps content collapsed until a find/scroll operation reveals it, at which point the engine synchronously fires a beforematch DOM event that page script can observe.
`base::SafeRef` vs `base::WeakPtr`
SafeRef asserts the pointee is still alive and crashes on use-after-free, whereas WeakPtr becomes null when the pointee is destroyed and can be safely null-checked.
`mojo::WrapCallbackWithDefaultInvokeIfNotRun`
a Mojo helper that guarantees a reply callback runs with a supplied default value if the underlying connection is dropped before the real reply arrives.

Root Cause Analysis

During tool initialization the renderer-side ToolBase::EnsureTargetInView() calls node.RevealAutoExpandableAncestors() to expose hidden=until-found ancestors of the target element, which synchronously dispatches a beforematch event into page script while the actor machinery is still on the stack. A malicious page handling that event can detach the owning frame, which destroys the ToolExecutor, the ToolBase, and the Journal that own the in-flight operation. The original code assumed these objects outlived the reveal call: EnsureTargetInView() continued to dereference journal_ and members after the event, the callers (ToolExecutor::InitializeToolImpl() / ToolExecutor::InvokeTool()) kept using this after the reveal, and Journal::PendingAsyncEntry held its Journal via base::SafeRef, all of which become use-after-free once the frame is gone.

The fix converts PendingAsyncEntry’s handle to base::WeakPtr<Journal> with null checks, makes EnsureTargetInView() and its callers re-check a weak this and abort with kFrameWentAway, and wraps the browser-side InitializeTool reply in mojo::WrapCallbackWithDefaultInvokeIfNotRun so a dropped connection still resolves cleanly instead of dangling.

Key insight
The core mistake is treating synchronous DOM-event dispatch (beforematch from revealing hidden=until-found ancestors) as if it could not re-enter and destroy the actor objects still on the stack; the fix replaces the “always-alive” SafeRef/raw-this assumptions with weak-pointer liveness checks that bail out to kFrameWentAway after any point where page script could have run.

Attack Path

  1. Set up a hidden target A page places an Actor tool target element (e.g. a scroll/click target) inside a hidden=until-found subtree and registers a beforematch handler on it.
  2. Trigger tool initialization The Actor drives a tool whose validation reaches ToolBase::EnsureTargetInView(), which calls RevealAutoExpandableAncestors() and synchronously fires beforematch into the page.
  3. Detach the frame from the handler Inside beforematch, page script detaches the owning frame, freeing the ToolExecutor, ToolBase, and Journal while EnsureTargetInView() and its callers are still executing on the stack.
  4. Use freed memory Control returns into the actor code, which dereferences the now-freed journal_/this (and the PendingAsyncEntry’s SafeRef<Journal>), producing the use-after-free.
  5. Exploit the freed allocation Controlling the contents of the reclaimed allocation to convert the dangling access into a stronger primitive is not shown by the patch. (inferred)

Impact Assessment

An attacker who can get an Actor tool to target an element under a page-controlled hidden=until-found subtree gains a use-after-free of renderer-side actor objects (ToolExecutor/ToolBase/Journal) in the renderer process, with the only precondition being that automation reaches the tool-initialization path on the attacker’s page. The advisory classifies this as a use-after-free in Actor and states no exploited-in-the-wild status, and any escalation from the dangling dereference to controlled read/write or code execution is (inferred) rather than demonstrated by the diff.

Changed Functions

FunctionChangeNotes
if
chrome/browser/actor/tools/page_tool.cc
modified
event_name_
chrome/renderer/actor/journal.cc
modified
if
chrome/renderer/actor/journal.cc
modified
if
chrome/renderer/actor/tool_base.cc
modified
if
chrome/renderer/actor/tool_executor.cc
modified

Files Changed

  • components/test/data/actor/scroll_to_tool_detached_frame_inner.html
  • chrome/renderer/actor/journal.h
  • chrome/renderer/actor/tool_executor.cc
  • chrome/renderer/actor/journal.cc
  • chrome/browser/actor/tools/page_tool.cc
  • chrome/browser/actor/tools/uaf_regression_browsertest.cc
  • chrome/renderer/actor/tool_base.cc
  • chrome/renderer/actor/tool_base.h

Audit Directions

  • Synchronous DOM events under revealing/scrolling APIs
    audit every actor/automation path that calls RevealAutoExpandableAncestors, ScrollIntoViewIfNeeded, or similar, since these can fire beforematch/scroll events that re-enter script and free stack-held objects.
  • `SafeRef`/raw-`this` held across re-entrancy
    flag any base::SafeRef or bare this/member dereference that survives a call capable of running page script, and convert to WeakPtr with post-call liveness checks.
  • Unguarded reply callbacks on frame teardown
    check Mojo tool/validation callbacks that are not wrapped with mojo::WrapCallbackWithDefaultInvokeIfNotRun, as a dropped connection during frame detach can leave them unresolved or dangling.
From 995c9093715d5526e1c1567604be0d449242ac14 Mon Sep 17 00:00:00 2001
From: mark a. foltz <[email protected]>
Date: Fri, 28 Aug 2026 13:35:37 -0700
Subject: [PATCH] [actor] Handle frame detach during tool initialization.

ToolBase::EnsureTargetInView() can synchronously dispatch DOM events
(beforematch) when revealing hidden=until-found ancestors of the target.
The page may use this to detach the owning frame, destroying the
ToolExecutor and ToolBase while they are still on the stack.

Guard the affected paths with weak_this checks:
- ToolBase::EnsureTargetInView() bails if destroyed.
- ToolExecutor::InitializeToolImpl() and InvokeTool() bail after
  EnsureTargetInView and return kFrameWentAway.
- Journal::PendingAsyncEntry now holds Journal as a WeakPtr so
  on-stack entries survive Journal destruction.
- PageTool::Validate() wraps the InitializeTool callback with
  mojo::WrapCallbackWithDefaultInvokeIfNotRun so it resolves with
  kFrameWentAway if the renderer connection drops.

Add a regression browser test to verify synchronous frame detachment
during tool initialization is handled cleanly.

Fixed: 516404074
Change-Id: Ib2ef0b668b1fb8610ed38f26c8ecd1dedb3eca75
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8291278
Commit-Queue: Mark Foltz <[email protected]>
Reviewed-by: Dave Tapuska <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1688282}
---
diff --git a/chrome/browser/actor/tools/page_tool.cc b/chrome/browser/actor/tools/page_tool.cc
index 93092da..8113d4bb 100644
--- a/chrome/browser/actor/tools/page_tool.cc
+++ b/chrome/browser/actor/tools/page_tool.cc
@@ -310,10 +310,18 @@
   std::string text = request_->GetTextContentSentToRenderer();
   if (text.empty() || !scanning_enabled) {
     if (validation_supported) {
+      // If the renderer connection drops (e.g. frame detached during
+      // tool initialization), ensure the callback is invoked with
+      // kFrameWentAway.
+      ToolCallback wrapped_callback =
+          mojo::WrapCallbackWithDefaultInvokeIfNotRun(
+              std::move(callback),
+              MakeResult(mojom::ActionResultCode::kFrameWentAway));
       chrome_render_frame_->InitializeTool(
           std::move(invocation),
           base::BindOnce(&PageTool::OnInitializeToolComplete,
-                         weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
+                         weak_ptr_factory_.GetWeakPtr(),
+                         std::move(wrapped_callback)));
     } else {
       std::move(callback).Run(MakeOkResult());
     }
diff --git a/chrome/renderer/actor/journal.cc b/chrome/renderer/actor/journal.cc
index b731ded8..c2ac62b0 100644
--- a/chrome/renderer/actor/journal.cc
+++ b/chrome/renderer/actor/journal.cc
@@ -18,11 +18,11 @@
 }  // namespace
 
 Journal::PendingAsyncEntry::PendingAsyncEntry(base::PassKey<Journal> pass_key,
-                                              base::SafeRef<Journal> journal,
+                                              base::WeakPtr<Journal> journal,
                                               TaskId task_id,
                                               std::string_view event_name)
     : pass_key_(pass_key),
-      journal_(journal),
+      journal_(std::move(journal)),
       task_id_(task_id),
       event_name_(event_name) {}
 
@@ -37,17 +37,23 @@
   CHECK(!terminated_);
   terminated_ = true;
   ACTOR_LOG() << "End " << event_name_ << ": " << details;
-  journal_->AddEndEvent(pass_key_, task_id_, event_name_, std::move(details));
+  if (journal_) {
+    journal_->AddEndEvent(pass_key_, task_id_, event_name_, std::move(details));
+  }
 }
 
 void Journal::PendingAsyncEntry::Log(std::string_view event_name) {
-  journal_->Log(task_id_, event_name, {});
+  if (journal_) {
+    journal_->Log(task_id_, event_name, {});
+  }
 }
 
 void Journal::PendingAsyncEntry::Log(
     std::string_view event_name,
     std::vector<mojom::JournalDetailsPtr> details) {
-  journal_->Log(task_id_, event_name, std::move(details));
+  if (journal_) {
+    journal_->Log(task_id_, event_name, std::move(details));
+  }
 }
 
 Journal::Journal() = default;
@@ -89,7 +95,7 @@
       std::string(event_name), MakeRendererTrackUUID(task_id),
       std::move(details)));
   return base::WrapUnique(new PendingAsyncEntry(base::PassKey<Journal>(),
-                                                weak_factory_.GetSafeRef(),
+                                                weak_factory_.GetWeakPtr(),
                                                 task_id, event_name));
 }
 
diff --git a/chrome/renderer/actor/journal.h b/chrome/renderer/actor/journal.h
index a9cc604..5e87ba0c 100644
--- a/chrome/renderer/actor/journal.h
+++ b/chrome/renderer/actor/journal.h
@@ -34,8 +34,10 @@
    public:
     // Creation of the event is only from the Journal itself. Use
     // `Journal::CreatePendingAsyncEntry` to create this object.
+    // Holds a WeakPtr<Journal> so on-stack entries survive frame/journal
+    // destruction during synchronous DOM events without crashing.
     PendingAsyncEntry(base::PassKey<Journal>,
-                      base::SafeRef<Journal> journal,
+                      base::WeakPtr<Journal> journal,
                       TaskId task_id,
                       std::string_view event_name);
     ~PendingAsyncEntry();
@@ -53,7 +55,7 @@
    private:
     base::PassKey<Journal> pass_key_;
     bool terminated_ = false;
-    base::SafeRef<Journal> journal_;
+    base::WeakPtr<Journal> journal_;
     TaskId task_id_;
     std::string event_name_;
   };
diff --git a/chrome/renderer/actor/tool_base.cc b/chrome/renderer/actor/tool_base.cc
index d12bfb19..e961f64 100644
--- a/chrome/renderer/actor/tool_base.cc
+++ b/chrome/renderer/actor/tool_base.cc
@@ -574,25 +574,52 @@
   int32_t dom_node_id = target_->get_dom_node_id();
   WebElement node = GetNodeFromIdIncludingPopup(frame_.get(), dom_node_id)
                         .DynamicTo<WebElement>();
-  if (node && node.VisibleBoundsInWidget().IsEmpty()) {
-    gfx::Rect bounds_before = node.BoundsInWidget();
-    node.RevealAutoExpandableAncestors();
-    node.ScrollIntoViewIfNeeded();
+  if (!node) {
+    return false;
+  }
 
-    journal_->Log(task_id_, "ScrollIntoView",
-                  JournalDetailsBuilder()
-                      .Add("target", node)
-                      .Add("CurBounds", bounds_before)
-                      .Add("NewBounds", node.BoundsInWidget())
-                      .Add("NewBoundsVisible", node.VisibleBoundsInWidget())
-                      .Add("ViewportBounds",
-                           node.GetDocument().GetFrame()->VisibleContentRect())
-                      .Build());
-
+  // Bounds queries force lifecycle/layout updates in Blink. Guard against the
+  // possibility of layout side effects detaching the owning frame and
+  // destroying this tool while executing on the stack.
+  base::WeakPtr<ToolBase> weak_this = weak_ptr_factory_.GetWeakPtr();
+  if (!node.VisibleBoundsInWidget().IsEmpty()) {
+    return false;
+  }
+  if (!weak_this) {
     return true;
   }
 
-  return false;
+  gfx::Rect bounds_before = node.BoundsInWidget();
+  if (!weak_this) {
+    return true;
+  }
+
+  // Revealing auto-expandable ancestors synchronously dispatches DOM events
+  // (`beforematch`) that might run script detaching the owning frame and
+  // destroying this tool.
+  node.RevealAutoExpandableAncestors();
+  if (!weak_this) {
+    return true;
+  }
+  node.ScrollIntoViewIfNeeded();
+  if (!weak_this) {
+    return true;
+  }
+
+  blink::WebLocalFrame* local_frame = node.GetDocument().GetFrame();
+  gfx::Rect viewport_bounds =
+      local_frame ? local_frame->VisibleContentRect() : gfx::Rect();
+
+  journal_->Log(task_id_, "ScrollIntoView",
+                JournalDetailsBuilder()
+                    .Add("target", node)
+                    .Add("CurBounds", bounds_before)
+                    .Add("NewBounds", node.BoundsInWidget())
+                    .Add("NewBoundsVisible", node.VisibleBoundsInWidget())
+                    .Add("ViewportBounds", viewport_bounds)
+                    .Build());
+
+  return true;
 }
 
 mojom::ActionResultPtr ToolBase::ValidateTimeOfUse(
diff --git a/chrome/renderer/actor/tool_base.h b/chrome/renderer/actor/tool_base.h
index 9f8790a..d44d3cd 100644
--- a/chrome/renderer/actor/tool_base.h
+++ b/chrome/renderer/actor/tool_base.h
@@ -11,6 +11,7 @@
 
 #include "base/functional/callback_forward.h"
 #include "base/memory/raw_ref.h"
+#include "base/memory/weak_ptr.h"
 #include "base/time/time.h"
 #include "base/types/expected.h"
 #include "chrome/common/actor.mojom-forward.h"
@@ -179,6 +180,11 @@
                                  bool check_aria) const;
 
   bool is_revalidation_ = false;
+
+  // Used to verify this object is still alive across synchronous DOM event
+  // dispatches (such as `beforematch`) that might detach the frame and destroy
+  // this tool while executing on the stack.
+  base::WeakPtrFactory<ToolBase> weak_ptr_factory_{this};
 };
 }  // namespace actor
 
diff --git a/chrome/renderer/actor/tool_executor.cc b/chrome/renderer/actor/tool_executor.cc
index 96938e28..b4867ca2a 100644
--- a/chrome/renderer/actor/tool_executor.cc
+++ b/chrome/renderer/actor/tool_executor.cc
@@ -156,7 +156,16 @@
       NOTREACHED();
   }
 
-  if (tool_->EnsureTargetInView()) {
+  // EnsureTargetInView() may synchronously dispatch DOM events (`beforematch`)
+  // that can run page script that detaches the owning frame and destroys
+  // this executor.
+  base::WeakPtr<ToolExecutor> weak_this = weak_ptr_factory_.GetWeakPtr();
+  const bool performed_scroll = tool_->EnsureTargetInView();
+  if (!weak_this) {
+    return mojom::InitializeToolResult::NewErrorResult(
+        MakeResult(mojom::ActionResultCode::kFrameWentAway));
+  }
+  if (performed_scroll) {
     performed_scroll_into_view_ = true;
   }
 
@@ -220,8 +229,14 @@
   // helps when debugging unresponsive renderers.
   journal_->SendLogBuffer();
   actor::TaskId task_id = invocation->task_id;
+  // InitializeToolImpl() may synchronously dispatch DOM events which can
+  // destroy the owning frame and this executor.
+  base::WeakPtr<ToolExecutor> weak_this = weak_ptr_factory_.GetWeakPtr();
   mojom::InitializeToolResultPtr result =
       InitializeToolImpl(std::move(invocation));
+  if (!weak_this) {
+    return;
+  }
   if (result->is_error_result()) {
     // The tool failed to initialize because another tool is active. Abort this
     // invocation immediately without disturbing the running tool.
diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
index 34086a4..879cc476 100644
--- a/chrome/browser/actor/tools/uaf_regression_browsertest.cc
+++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
@@ -148,5 +148,23 @@
   ASSERT_TRUE(result.Wait());
 }
 
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+                       ScrollToTool_HandlesSynchronousFrameDetachment) {
+  SetupAbaFrames("/actor/scroll_to_tool_detached_frame_inner.html");
+  RenderFrameHost* inner_rfh = GetInnerRfh();
+  ASSERT_TRUE(inner_rfh);
+
+  const int32_t target_id = GetDOMNodeId(*inner_rfh, "#target").value();
+
+  std::unique_ptr<ToolRequest> action =
+      MakeScrollToRequest(*inner_rfh, target_id);
+  ActResultFuture result;
+  actor_task().Act(ToRequestList(action), result.GetCallback());
+
+  // Frame detachment during tool initialization should be handled cleanly
+  // without crashing the renderer.
+  ASSERT_TRUE(result.Wait());
+}
+
 }  // namespace
 }  // namespace actor
diff --git a/components/test/data/actor/scroll_to_tool_detached_frame_inner.html b/components/test/data/actor/scroll_to_tool_detached_frame_inner.html
new file mode 100644
index 0000000..c8e38366
--- /dev/null
+++ b/components/test/data/actor/scroll_to_tool_detached_frame_inner.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<body>
+  <div id="wrap" hidden="until-found">
+    <button id="target">Target</button>
+  </div>
+  <script>
+    const wrap = document.getElementById('wrap');
+    wrap.addEventListener('beforematch', () => {
+      // Remove the mid iframe from the top window.
+      // Since mid is cross-origin, removing it synchronously detaches this frame.
+      parent.parent.document.getElementById('mid').remove();
+    });
+  </script>
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
index 34086a4..879cc476 100644
--- a/chrome/browser/actor/tools/uaf_regression_browsertest.cc
+++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
@@ -148,5 +148,23 @@
   ASSERT_TRUE(result.Wait());
 }
 
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+                       ScrollToTool_HandlesSynchronousFrameDetachment) {
+  SetupAbaFrames("/actor/scroll_to_tool_detached_frame_inner.html");
+  RenderFrameHost* inner_rfh = GetInnerRfh();
+  ASSERT_TRUE(inner_rfh);
+
+  const int32_t target_id = GetDOMNodeId(*inner_rfh, "#target").value();
+
+  std::unique_ptr<ToolRequest> action =
+      MakeScrollToRequest(*inner_rfh, target_id);
+  ActResultFuture result;
+  actor_task().Act(ToRequestList(action), result.GetCallback());
+
+  // Frame detachment during tool initialization should be handled cleanly
+  // without crashing the renderer.
+  ASSERT_TRUE(result.Wait());
+}
+
 }  // namespace
 }  // namespace actor
diff --git a/components/test/data/actor/scroll_to_tool_detached_frame_inner.html b/components/test/data/actor/scroll_to_tool_detached_frame_inner.html
new file mode 100644
index 0000000..c8e38366
--- /dev/null
+++ b/components/test/data/actor/scroll_to_tool_detached_frame_inner.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<body>
+  <div id="wrap" hidden="until-found">
+    <button id="target">Target</button>
+  </div>
+  <script>
+    const wrap = document.getElementById('wrap');
+    wrap.addEventListener('beforematch', () => {
+      // Remove the mid iframe from the top window.
+      // Since mid is cross-origin, removing it synchronously detaches this frame.
+      parent.parent.document.getElementById('mid').remove();
+    });
+  </script>
+</body>
+</html>
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.