CVE-2026-10954
Overview
Background
- Actor tools (Glic actor)
- renderer-side helpers in
chrome/renderer/actor/(ClickDispatcher,DragAndReleaseTool,KeyDispatcher,TypeTool) that carry out agent-requested actions by synthesizing real input events into a page’sWebWidget. - `WebWidget::HandleInputEvent`
- synchronously dispatches a synthesized
WebMouseEvent/WebKeyboardEventinto Blink, so page JavaScript event handlers (mouse, key, input) run before the call returns. - `base::WeakPtr` re-entrancy guard
- taking
weak_ptr_factory_.GetWeakPtr()before a re-entrant call and testing it afterwards detects whetherthiswas destroyed during that call. - Synchronous frame detachment
- page script removing an iframe from the DOM during an event handler tears down that frame (and per-frame objects tied to it) before control returns to the caller.
Root Cause Analysis
Every actor tool dispatched synthetic input with widget->HandleInputEvent(...) (directly, or via InjectMouseEvent/CreateAndDispatchKeyEvent) and then kept using this: ClickDispatcher called Finish(...) and mouse_up_event_.reset(), DragAndReleaseTool called from_target.GetWidget(*this) and wrote task_runner_, KeyDispatcher/TypeTool called journal_->Log(task_id_, ...) and touched is_key_down_/key_sequence_. Because HandleInputEvent runs page event handlers synchronously, a page can detach its own frame inside a handler, which destroys the tool object while it is still on the stack. The existing code already anticipated that the widget could die (“Re-check widget after each input since it could be destroyed as part of input handling”) but never considered that the tool object itself could be freed, so the post-dispatch member accesses were use-after-free.
The fix snapshots a WeakPtr to the tool before every dispatch and returns immediately (without touching members or running the callback) if it has been invalidated; the regression tests are named *_HandlesSynchronousFrameDetachment, confirming the trigger.
HandleInputEvent as a non-reentrant call and only re-validated the WebWidget, not their own lifetime; the patch adds a weak_this liveness check after every synchronous input dispatch so no member is touched once page script has destroyed the tool.Attack Path
- Get the actor acting on attacker content
the regression test builds an A-B-A frame tree (
a.testouter,b.testmid,a.testinner) and has the actor click, type, drag or select a#targetin the innermost frame. - Synthetic input reaches page script
the tool calls
widget->HandleInputEvent(...)for a mouse move/down/up or key down/char/up, which synchronously runs the inner page’s event handlers. - Handler detaches the frame
the inner page’s handler (in the truncated
*_uaf_inner.htmlfixtures) synchronously detaches the frame hosting the tool, freeing the tool/dispatcher object during the call (mechanism of reaching the ancestor via the same-site top frame is inferred from the A-B-A setup). - Stale `this` is dereferenced
on return, pre-patch code ran
Finish(...),mouse_up_event_.reset(),journal_->Log(task_id_, ...),GetWidget(*this)ortask_runner_->PostDelayedTask(...)on the freed object, a renderer use-after-free. - Memory-corruption follow-on reclaiming the freed allocation to turn the stale member accesses into controlled reads/writes in the renderer (inferred; not shown in the patch).
Impact Assessment
(inferred), and a sandbox escape would require a separate bug. The advisory does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/renderer/actor/click_dispatcher.cc |
modified | |
ifchrome/renderer/actor/drag_and_release_tool.cc |
modified | |
ifchrome/renderer/actor/key_dispatcher.cc |
modified | |
ifchrome/renderer/actor/type_tool.cc |
modified | |
forchrome/renderer/actor/type_tool.cc |
modified |
Files Changed
chrome/browser/actor/BUILD.gnchrome/browser/actor/tools/select_tool_browsertest.ccchrome/browser/actor/tools/uaf_regression_browsertest.ccchrome/renderer/actor/click_dispatcher.ccchrome/renderer/actor/drag_and_release_tool.ccchrome/renderer/actor/key_dispatcher.ccchrome/renderer/actor/type_tool.ccchrome/test/data/actor/click_tool_uaf_inner.htmlchrome/test/data/actor/drag_and_release_tool_uaf_inner.htmlchrome/test/data/actor/type_tool_uaf_inner.html
Audit Directions
- Synchronous dispatch into script followed by member accessaudit every caller of
WebWidget::HandleInputEvent,WebElement::PasteText, focus/selection changes and other script-reaching calls in browser-feature renderer code for unguarded use ofthisafterwards. - Re-validating the wrong objectwhere code already re-fetches a widget/frame “since it could be destroyed”, check whether the calling object’s lifetime is tied to that same frame and needs its own
WeakPtrguard. - Sibling tools and callbacksthe earlier
SelectToolfix (crbug.com/506377279) and this batch suggest a family; review remaining actor tools (scroll, select, mouse-over) and anyRenderFrameObserver-owned helpers that run acallbackafter injecting input.
Patch
diff --git a/chrome/renderer/actor/click_dispatcher.cc b/chrome/renderer/actor/click_dispatcher.cc
index fdd1153..461b026 100644
--- a/chrome/renderer/actor/click_dispatcher.cc
+++ b/chrome/renderer/actor/click_dispatcher.cc
@@ -55,9 +55,14 @@
mouse_move.SetPositionInWidget(target.widget_point);
// Mouse move is considered optional, so we don't check this result.
+ base::WeakPtr<ClickDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
widget->HandleInputEvent(
blink::WebCoalescedInputEvent(mouse_move, ui::LatencyInfo()));
+ if (!weak_this) {
+ return;
+ }
+
base::TimeDelta delay = features::kGlicActorMoveBeforeClickDelay.Get();
base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
FROM_HERE,
@@ -103,9 +108,14 @@
// mouse_event_.SetPositionInScreen(point.x() + offset.x(),
// point.y() + offset.y());
+ base::WeakPtr<ClickDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
blink::WebInputEventResult result = widget->HandleInputEvent(
blink::WebCoalescedInputEvent(mouse_down, ui::LatencyInfo()));
+ if (!weak_this) {
+ return;
+ }
+
if (result == blink::WebInputEventResult::kHandledSuppressed) {
Finish(MakeResult(mojom::ActionResultCode::kClickSuppressed,
/*requires_page_stabilization=*/false));
@@ -140,9 +150,15 @@
}
mouse_up_event_->SetTimeStamp(ui::EventTimeForNow());
+ base::WeakPtr<ClickDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
blink::WebInputEventResult result =
widget->HandleInputEvent(blink::WebCoalescedInputEvent(
std::move(*mouse_up_event_), ui::LatencyInfo()));
+
+ if (!weak_this) {
+ return;
+ }
+
mouse_up_event_.reset();
if (result == blink::WebInputEventResult::kHandledSuppressed) {
Finish(MakeResult(mojom::ActionResultCode::kClickSuppressed,
diff --git a/chrome/renderer/actor/drag_and_release_tool.cc b/chrome/renderer/actor/drag_and_release_tool.cc
index bcedbab..f292215 100644
--- a/chrome/renderer/actor/drag_and_release_tool.cc
+++ b/chrome/renderer/actor/drag_and_release_tool.cc
@@ -79,16 +79,23 @@
CHECK(widget);
// TODO(crbug.com/409333494): How should partial success be returned.
-
// Move and press down the mouse on the from_point.
+ base::WeakPtr<DragAndReleaseTool> weak_this = weak_ptr_factory_.GetWeakPtr();
if (!InjectMouseEvent(*widget, from_target.widget_point,
EventType::kMouseMove,
WebMouseEvent::Button::kNoButton)) {
+ if (!weak_this) {
+ return;
+ }
std::move(callback).Run(
MakeResult(mojom::ActionResultCode::kDragAndReleaseFromMoveSuppressed));
return;
}
+ if (!weak_this) {
+ return;
+ }
+
// Re-check widget after each input since it could be destroyed as part of
// input handling.
widget = from_target.GetWidget(*this);
@@ -100,12 +107,19 @@
if (!InjectMouseEvent(*widget, from_target.widget_point,
EventType::kMouseDown, WebMouseEvent::Button::kLeft)) {
+ if (!weak_this) {
+ return;
+ }
std::move(callback).Run(
MakeResult(mojom::ActionResultCode::kDragAndReleaseDownSuppressed,
/*requires_page_stabilization=*/true));
return;
}
+ if (!weak_this) {
+ return;
+ }
+
// We need a time delay between the click and first move for some pages.
task_runner_ = base::SequencedTaskRunner::GetCurrentDefault();
task_runner_->PostDelayedTask(
@@ -119,6 +133,7 @@
void DragAndReleaseTool::ProcessDrag(ResolvedTarget from,
ResolvedTarget to,
ToolFinishedCallback callback) {
+ base::WeakPtr<DragAndReleaseTool> weak_this = weak_ptr_factory_.GetWeakPtr();
WebWidget* widget = from.GetWidget(*this);
if (!widget) {
std::move(callback).Run(
@@ -146,11 +161,17 @@
if (!InjectMouseEvent(*widget, drag_point, EventType::kMouseMove,
WebMouseEvent::Button::kLeft)) {
+ if (!weak_this) {
+ return;
+ }
std::move(callback).Run(
MakeResult(mojom::ActionResultCode::kDragAndReleaseToMoveSuppressed,
/*requires_page_stabilization=*/true));
return;
}
+ if (!weak_this) {
+ return;
+ }
if (!done) {
task_runner_->PostDelayedTask(
FROM_HERE,
@@ -175,6 +196,7 @@
void DragAndReleaseTool::ProcessRelease(ResolvedTarget to_target,
ToolFinishedCallback callback) {
+ base::WeakPtr<DragAndReleaseTool> weak_this = weak_ptr_factory_.GetWeakPtr();
WebWidget* widget = to_target.GetWidget(*this);
if (!widget) {
std::move(callback).Run(
@@ -184,12 +206,19 @@
if (!InjectMouseEvent(*widget, to_target.widget_point, EventType::kMouseUp,
WebMouseEvent::Button::kLeft)) {
+ if (!weak_this) {
+ return;
+ }
std::move(callback).Run(
MakeResult(mojom::ActionResultCode::kDragAndReleaseUpSuppressed,
/*requires_page_stabilization=*/true));
return;
}
+ if (!weak_this) {
+ return;
+ }
+
std::move(callback).Run(MakeOkResult());
}
diff --git a/chrome/renderer/actor/key_dispatcher.cc b/chrome/renderer/actor/key_dispatcher.cc
index c3f64a5..42169a9 100644
--- a/chrome/renderer/actor/key_dispatcher.cc
+++ b/chrome/renderer/actor/key_dispatcher.cc
@@ -98,8 +98,12 @@
WebWidget* widget = resolved_target_->GetWidget(*type_tool_);
if (widget) {
const KeyDispatcher::KeyParams& params = key_sequence_[current_key_];
+ base::WeakPtr<KeyDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult up_result = CreateAndDispatchKeyEvent(
*widget, WebInputEvent::Type::kKeyUp, params);
+ if (!weak_this) {
+ return;
+ }
if (up_result == WebInputEventResult::kHandledSuppressed) {
ACTOR_LOG() << "Warning: KeyUp event for key " << params.dom_key
<< " suppressed during cancelation.";
@@ -163,9 +167,14 @@
}
if (!is_key_down_) {
+ base::WeakPtr<KeyDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult down_result = CreateAndDispatchKeyEvent(
*widget, WebInputEvent::Type::kRawKeyDown, params);
+ if (!weak_this) {
+ return;
+ }
+
// Only the KeyDown event will check for and report failure. The reason the
// other events don't is that if the KeyDown event was dispatched to the
// page, the key input was observable to the page and it may mutate itself
@@ -182,7 +191,8 @@
return;
}
- // Input handling could destroy the widget so it needs to be re-read.
+ // Input handling could cause the widget to be destroyed so it must be
+ // re-read.
widget = resolved_target_->GetWidget(*type_tool_);
if (!widget) {
Finish(MakeResult(mojom::ActionResultCode::kFrameWentAway,
@@ -190,9 +200,13 @@
"No widget during incremental typing"));
return;
}
+
if (params.dom_key != "Dead") {
WebInputEventResult char_result = CreateAndDispatchKeyEvent(
*widget, WebInputEvent::Type::kChar, params);
+ if (!weak_this) {
+ return;
+ }
if (char_result == WebInputEventResult::kHandledSuppressed) {
ACTOR_LOG() << "Warning: Char event for key " << params.dom_key
<< " suppressed.";
@@ -201,8 +215,12 @@
is_key_down_ = true;
} else {
+ base::WeakPtr<KeyDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult up_result =
CreateAndDispatchKeyEvent(*widget, WebInputEvent::Type::kKeyUp, params);
+ if (!weak_this) {
+ return;
+ }
if (up_result == WebInputEventResult::kHandledSuppressed) {
ACTOR_LOG() << "Warning: KeyUp event for key " << params.dom_key
<< " suppressed.";
@@ -261,7 +279,7 @@
WebInputEventResult KeyDispatcher::CreateAndDispatchKeyEvent(
WebWidget& widget,
WebInputEvent::Type type,
- KeyDispatcher::KeyParams key_params) {
+ KeyParams key_params) {
WebKeyboardEvent key_event(type, key_params.modifiers, ui::EventTimeForNow());
key_event.windows_key_code = key_params.windows_key_code;
key_event.native_key_code = key_params.native_key_code;
@@ -272,8 +290,14 @@
key_event.text[0] = key_params.text;
key_event.unmodified_text[0] = key_params.unmodified_text;
+ base::WeakPtr<KeyDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult result = widget.HandleInputEvent(
WebCoalescedInputEvent(key_event, ui::LatencyInfo()));
+
+ if (!weak_this) {
+ return result;
+ }
+
journal_->Log(task_id_, WebInputEvent::GetName(type),
JournalDetailsBuilder()
.Add("key", key_params.dom_key)
diff --git a/chrome/renderer/actor/type_tool.cc b/chrome/renderer/actor/type_tool.cc
index a7600c3d..ac52d94 100644
--- a/chrome/renderer/actor/type_tool.cc
+++ b/chrome/renderer/actor/type_tool.cc
@@ -390,8 +390,14 @@
key_event.text[0] = key_params.text;
key_event.unmodified_text[0] = key_params.unmodified_text;
+ base::WeakPtr<TypeTool> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult result = widget.HandleInputEvent(
WebCoalescedInputEvent(key_event, ui::LatencyInfo()));
+
+ if (!weak_this) {
+ return result;
+ }
+
journal_->Log(task_id_, WebInputEvent::GetName(type),
JournalDetailsBuilder()
.Add("key", key_params.dom_key)
@@ -411,9 +417,14 @@
"No widget during simulate key down");
}
+ base::WeakPtr<TypeTool> weak_this = weak_ptr_factory_.GetWeakPtr();
WebInputEventResult down_result = CreateAndDispatchKeyEvent(
*widget, WebInputEvent::Type::kRawKeyDown, params);
+ if (!weak_this) {
+ return nullptr;
+ }
+
// Only the KeyDown event will check for and report failure. The reason the
// other events don't is that if the KeyDown event was dispatched to the page,
// the key input was observable to the page and it may mutate itself in a way
@@ -440,6 +451,9 @@
if (params.dom_key != "Dead") {
WebInputEventResult char_result =
CreateAndDispatchKeyEvent(*widget, WebInputEvent::Type::kChar, params);
+ if (!weak_this) {
+ return nullptr;
+ }
if (char_result == WebInputEventResult::kHandledSuppressed) {
ACTOR_LOG() << "Warning: Char event for key " << params.dom_key
<< " suppressed.";
@@ -457,6 +471,9 @@
WebInputEventResult up_result =
CreateAndDispatchKeyEvent(*widget, WebInputEvent::Type::kKeyUp, params);
+ if (!weak_this) {
+ return nullptr;
+ }
if (up_result == WebInputEventResult::kHandledSuppressed) {
ACTOR_LOG() << "Warning: KeyUp event for key " << params.dom_key
<< " suppressed.";
@@ -545,7 +562,11 @@
journal_->Log(
task_id_, "TypeTool::Execute::FocusElementEditable",
JournalDetailsBuilder().Add("focus", focused_element).Build());
+ base::WeakPtr<TypeTool> weak_this = weak_ptr_factory_.GetWeakPtr();
PrepareTargetForMode(*focused_frame, action_->mode);
+ if (!weak_this) {
+ return;
+ }
} else {
if (focused_element) {
journal_->Log(
@@ -583,8 +604,12 @@
if (can_simulate_typing) {
if (!base::FeatureList::IsEnabled(features::kGlicActorIncrementalTyping)) {
+ base::WeakPtr<TypeTool> weak_this = weak_ptr_factory_.GetWeakPtr();
for (const auto& param : key_sequence_) {
mojom::ActionResultPtr result = SimulateKeyPress(param);
+ if (!weak_this) {
+ return;
+ }
if (!IsOk(*result)) {
// The initial click may have changed the page.
result->requires_page_stabilization = true;
@@ -613,8 +638,12 @@
.Add("text", action_->text)
.Add("focus", focused_element)
.Build());
+ base::WeakPtr<TypeTool> weak_this = weak_ptr_factory_.GetWeakPtr();
focused_element.PasteText(WebString::FromUtf8(action_->text),
/*replace_all=*/false);
+ if (!weak_this) {
+ return;
+ }
std::move(callback).Run(MakeOkResult());
} else {
std::move(callback).Run(MakeResult(
diff --git a/chrome/browser/actor/tools/select_tool_browsertest.cc b/chrome/browser/actor/tools/select_tool_browsertest.cc
index 4df0768..e52ff28b 100644
--- a/chrome/browser/actor/tools/select_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/select_tool_browsertest.cc
@@ -408,51 +408,5 @@
"delta");
}
-// Regression test for UAF in Glic actor tools.
-// See crbug.com/506377279.
-IN_PROC_BROWSER_TEST_F(ActorSelectToolBrowserTest,
- SelectTool_HandlesSynchronousFrameDetachment) {
- GURL outer_url = embedded_https_test_server().GetURL(
- "a.test", "/actor/select_tool_uaf_outer.html");
- GURL mid_url = embedded_https_test_server().GetURL(
- "b.test", "/actor/select_tool_uaf_mid.html");
- GURL inner_url = embedded_https_test_server().GetURL(
- "a.test", "/actor/select_tool_uaf_inner.html");
-
- ASSERT_TRUE(content::NavigateToURL(web_contents(), outer_url));
- content::WaitForLoadStop(web_contents());
-
- // Set mid iframe src
- ASSERT_TRUE(content::ExecJs(
- web_contents(),
- content::JsReplace("document.getElementById('mid').src = $1", mid_url)));
- content::WaitForLoadStop(web_contents());
-
- content::RenderFrameHost* mid_rfh = content::ChildFrameAt(main_frame(), 0);
- ASSERT_TRUE(mid_rfh);
-
- // Set inner iframe src
- ASSERT_TRUE(content::ExecJs(
- mid_rfh, content::JsReplace("document.getElementById('inner').src = $1",
- inner_url)));
- content::WaitForLoadStop(web_contents());
-
- content::RenderFrameHost* inner_rfh = content::ChildFrameAt(mid_rfh, 0);
- ASSERT_TRUE(inner_rfh);
-
- const std::string select_id = "#s";
- const int32_t select_dom_node_id =
- GetDOMNodeId(*inner_rfh, select_id).value();
-
- // Trigger SelectTool on the inner frame.
- std::unique_ptr<ToolRequest> action =
- MakeSelectRequest(*inner_rfh, select_dom_node_id, "b");
- ActResultFuture result;
- actor_task().Act(ToRequestList(action), result.GetCallback());
-
- // With the fix, this should not crash the renderer.
- ASSERT_TRUE(result.Wait());
-}
-
} // namespace
} // namespace actor
diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
new file mode 100644
index 0000000..dd032bba
--- /dev/null
+++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
@@ -0,0 +1,153 @@
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/actor/tools/select_tool_browsertest.cc b/chrome/browser/actor/tools/select_tool_browsertest.cc
index 4df0768..e52ff28b 100644
--- a/chrome/browser/actor/tools/select_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/select_tool_browsertest.cc
@@ -408,51 +408,5 @@
"delta");
}
-// Regression test for UAF in Glic actor tools.
-// See crbug.com/506377279.
-IN_PROC_BROWSER_TEST_F(ActorSelectToolBrowserTest,
- SelectTool_HandlesSynchronousFrameDetachment) {
- GURL outer_url = embedded_https_test_server().GetURL(
- "a.test", "/actor/select_tool_uaf_outer.html");
- GURL mid_url = embedded_https_test_server().GetURL(
- "b.test", "/actor/select_tool_uaf_mid.html");
- GURL inner_url = embedded_https_test_server().GetURL(
- "a.test", "/actor/select_tool_uaf_inner.html");
-
- ASSERT_TRUE(content::NavigateToURL(web_contents(), outer_url));
- content::WaitForLoadStop(web_contents());
-
- // Set mid iframe src
- ASSERT_TRUE(content::ExecJs(
- web_contents(),
- content::JsReplace("document.getElementById('mid').src = $1", mid_url)));
- content::WaitForLoadStop(web_contents());
-
- content::RenderFrameHost* mid_rfh = content::ChildFrameAt(main_frame(), 0);
- ASSERT_TRUE(mid_rfh);
-
- // Set inner iframe src
- ASSERT_TRUE(content::ExecJs(
- mid_rfh, content::JsReplace("document.getElementById('inner').src = $1",
- inner_url)));
- content::WaitForLoadStop(web_contents());
-
- content::RenderFrameHost* inner_rfh = content::ChildFrameAt(mid_rfh, 0);
- ASSERT_TRUE(inner_rfh);
-
- const std::string select_id = "#s";
- const int32_t select_dom_node_id =
- GetDOMNodeId(*inner_rfh, select_id).value();
-
- // Trigger SelectTool on the inner frame.
- std::unique_ptr<ToolRequest> action =
- MakeSelectRequest(*inner_rfh, select_dom_node_id, "b");
- ActResultFuture result;
- actor_task().Act(ToRequestList(action), result.GetCallback());
-
- // With the fix, this should not crash the renderer.
- ASSERT_TRUE(result.Wait());
-}
-
} // namespace
} // namespace actor
diff --git a/chrome/browser/actor/tools/uaf_regression_browsertest.cc b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
new file mode 100644
index 0000000..dd032bba
--- /dev/null
+++ b/chrome/browser/actor/tools/uaf_regression_browsertest.cc
@@ -0,0 +1,153 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/test/test_future.h"
+#include "chrome/browser/actor/actor_test_util.h"
+#include "chrome/browser/actor/tools/tool_request.h"
+#include "chrome/browser/actor/tools/tools_test_util.h"
+#include "chrome/common/actor.mojom.h"
+#include "chrome/common/actor/action_result.h"
+#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/gfx/geometry/point.h"
+#include "ui/gfx/geometry/point_conversions.h"
+
+namespace actor {
+
+namespace {
+
+using base::test::TestFuture;
+using content::ChildFrameAt;
+using content::ExecJs;
+using content::GetDOMNodeId;
+using content::RenderFrameHost;
+using ActResultFuture = TestFuture<std::vector<ActionResultWithLatencyInfo>>;
+
+class ActorUafRegressionBrowserTest : public ActorToolsTest {
+ public:
+ void SetUpOnMainThread() override {
+ ActorToolsTest::SetUpOnMainThread();
+ ASSERT_TRUE(embedded_test_server()->Start());
+ embedded_https_test_server().SetSSLConfig(
+ net::EmbeddedTestServer::CERT_TEST_NAMES);
+ ASSERT_TRUE(embedded_https_test_server().Start());
+ }
+
+ void SetupAbaFrames(const std::string& inner_path) {
+ GURL outer_url =
+ embedded_https_test_server().GetURL("a.test", "/actor/uaf_outer.html");
+ GURL mid_url =
+ embedded_https_test_server().GetURL("b.test", "/actor/uaf_mid.html");
+ GURL inner_url = embedded_https_test_server().GetURL("a.test", inner_path);
+
+ ASSERT_TRUE(content::NavigateToURL(web_contents(), outer_url));
+ content::WaitForLoadStop(web_contents());
+
+ // Set mid iframe src
+ ASSERT_TRUE(
+ ExecJs(web_contents(),
+ content::JsReplace("document.getElementById('mid').src = $1",
+ mid_url)));
+ content::WaitForLoadStop(web_contents());
+
+ RenderFrameHost* mid_rfh = ChildFrameAt(main_frame(), 0);
+ ASSERT_TRUE(mid_rfh);
+
+ // Set inner iframe src
+ ASSERT_TRUE(ExecJs(
+ mid_rfh, content::JsReplace("document.getElementById('inner').src = $1",
+ inner_url)));
+ content::WaitForLoadStop(web_contents());
+ }
+
+ RenderFrameHost* GetInnerRfh() {
+ RenderFrameHost* mid_rfh = ChildFrameAt(main_frame(), 0);
+ if (!mid_rfh) {
+ return nullptr;
+ }
+ return ChildFrameAt(mid_rfh, 0);
+ }
+};
+
+// Regression test for UAF in Glic actor tools.
+// See crbug.com/506150628.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+ ClickTool_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/click_tool_uaf_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 = MakeClickRequest(*inner_rfh, target_id);
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
+// Regression test for UAF in Glic actor tools.
+// See crbug.com/506150628.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+ TypeTool_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/type_tool_uaf_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 = MakeTypeRequest(
+ *inner_rfh, target_id, "hello", /* follow_by_enter= */ false);
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
+// Regression test for UAF in Glic actor tools.
+// See crbug.com/506150628.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+ DragAndReleaseTool_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/drag_and_release_tool_uaf_inner.html");
+ RenderFrameHost* inner_rfh = GetInnerRfh();
+ ASSERT_TRUE(inner_rfh);
+
+ gfx::RectF bounds = GetBoundingClientRect(*inner_rfh, "#target");
+ gfx::Point from_point = gfx::ToRoundedPoint(bounds.CenterPoint());
+ gfx::Point to_point = from_point + gfx::Vector2d(100, 100);
+
+ // Drag from target to somewhere else.
+ std::unique_ptr<ToolRequest> action =
+ MakeDragAndReleaseRequest(*active_tab(), from_point, to_point);
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
+// Regression test for UAF in Glic actor tools.
+// See crbug.com/506377279.
+IN_PROC_BROWSER_TEST_F(ActorUafRegressionBrowserTest,
+ SelectTool_HandlesSynchronousFrameDetachment) {
+ SetupAbaFrames("/actor/select_tool_uaf_inner.html");
+ RenderFrameHost* inner_rfh = GetInnerRfh();
+ ASSERT_TRUE(inner_rfh);
+
+ const int32_t target_id = GetDOMNodeId(*inner_rfh, "#s").value();
+
+ std::unique_ptr<ToolRequest> action =
+ MakeSelectRequest(*inner_rfh, target_id, "b");
+ ActResultFuture result;
+ actor_task().Act(ToRequestList(action), result.GetCallback());
+
+ // This should not crash the renderer.
+ ASSERT_TRUE(result.Wait());
+}
+
+} // namespace
+} // namespace actor
diff --git a/chrome/test/data/actor/click_tool_uaf_inner.html b/chrome/test/data/actor/click_tool_uaf_inner.html
new file mode 100644
index 0000000..86ef17c1
--- /dev/null
+++ b/chrome/test/data/actor/click_tool_uaf_inner.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <div id="target" style="width: 100px; height: 100px; background: red;"></div>
+ <script>
+ const target = document.getElementById('target');
+ target.addEventListener('mousedown', () => {
+ // 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>
diff --git a/chrome/test/data/actor/drag_and_release_tool_uaf_inner.html b/chrome/test/data/actor/drag_and_release_tool_uaf_inner.html
new file mode 100644
index 0000000..19cfb22
--- /dev/null
+++ b/chrome/test/data/actor/drag_and_release_tool_uaf_inner.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <div id="target" style="width: 100px; height: 100px; background: blue;"></div>
+ <script>
+ const target = document.getElementById('target');
+ target.addEventListener('mousedown', () => {
+ parent.parent.document.getElementById('mid').remove();
+ });
+ </script>
+</body>
+</html>
diff --git a/chrome/test/data/actor/type_tool_uaf_inner.html b/chrome/test/data/actor/type_tool_uaf_inner.html
new file mode 100644
index 0000000..c55a208b
--- /dev/null
+++ b/chrome/test/data/actor/type_tool_uaf_inner.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <input id="target" type="text">
+ <script>
+ const target = document.getElementById('target');
+ target.addEventListener('keydown', () => {
+ parent.parent.document.getElementById('mid').remove();
+ });
+ </script>
+</body>
+</html>
diff --git a/chrome/test/data/actor/select_tool_uaf_mid.html b/chrome/test/data/actor/uaf_mid.html
similarity index 100%
rename from chrome/test/data/actor/select_tool_uaf_mid.html
rename to chrome/test/data/actor/uaf_mid.html
diff --git a/chrome/test/data/actor/select_tool_uaf_outer.html b/chrome/test/data/actor/uaf_outer.html
similarity index 100%
rename from chrome/test/data/actor/select_tool_uaf_outer.html
rename to chrome/test/data/actor/uaf_outer.html
Original Bug Report
UAF Write in Glic Actor Dispatchers via Synchronous Frame Detachment
Project Fortify, 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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A Use-After-Free (UAF) vulnerability exists in the Glic Actor’s ClickDispatcher and TypeTool when dispatching input events. The synchronous HandleInputEvent() call can trigger a JavaScript listener that detaches the local root frame, synchronously destroying the tool’s heap allocation. When execution resumes, it uses a dangling this pointer to write to the freed chunk, creating an exploitable primitive that bypasses MiraclePtr.
Affected files:
chrome/renderer/actor/click_dispatcher.ccchrome/renderer/actor/click_tool.hchrome/renderer/actor/tool_executor.ccchrome/renderer/actor/type_tool.h
Estimated timestamp from git blame: 2025-12-10
Summary
A Use-After-Free (UAF) write vulnerability occurs in ClickDispatcher and TypeTool within the sandboxed renderer process. These classes dispatch user input events to the DOM using widget->HandleInputEvent(). This call synchronously executes the target page’s JavaScript event listeners. If a malicious listener executes script to detach the RenderFrame hosting the actor (e.g., by removing an out-of-process iframe parent), the entire ownership chain—ChromeRenderFrameObserver -> ToolExecutor -> ClickTool/TypeTool—is synchronously destroyed.
When HandleInputEvent() returns, execution resumes in ClickDispatcher::DoMouseDown (or equivalent TypeTool functions) using a dangling this pointer. This leads to a deterministic heap write of the WebMouseEvent into freed memory, providing a powerful primitive for renderer Remote Code Execution (RCE).
Root Cause Analysis
In chrome/renderer/actor/click_dispatcher.cc, the DoMouseDown method initiates a click:
blink::WebInputEventResult result = widget->HandleInputEvent(
blink::WebCoalescedInputEvent(mouse_down, ui::LatencyInfo()));
// execution resumes here after synchronous JS
if (result == blink::WebInputEventResult::kHandledSuppressed) {
Finish(...);
return;
}
// UAF WRITE: copies ~136 bytes into the potentially freed tool allocation
mouse_up_event_ = mouse_down;
A similar pattern exists in TypeTool::SimulateKeyPress and TypeTool::CreateAndDispatchKeyEvent, where journal_ and task_id_ are accessed after the input dispatch.
Crucially, MiraclePtr (BackupRefPtr) does not mitigate this. ClickDispatcher is stored inline as a std::optional inside ClickTool. The only BRP-managed pointer to the ClickTool allocation is the raw_ref<const ToolBase> tool_ stored inside the ClickDispatcher. When the frame is detached, the ClickTool destructor is invoked, destroying the ClickDispatcher and its raw_ref. This drives the BRP reference count to zero before PartitionAlloc frees the chunk. The chunk is released immediately, skipping the BRP quarantine, allowing an attacker to reclaim it deterministically via a heap spray inside their synchronous JS listener.
Potential Reproduction Steps
- Setup a page with an A-B-A frame hierarchy (Top-A -> cross-origin iframe B -> same-process local-root iframe inner-A).
- Register a
mousedownlistener on an element in inner-A. - The attacker triggers the Glic Actor to click the element.
- When
DoMouseDowndispatches the event, the listener synchronously fires. - The listener executes
window.top.document.getElementById('b').remove();. - This synchronously detaches inner-A, destroying the
ChromeRenderFrameObserver,ToolExecutor, and theClickTool/ClickDispatcher. - The listener performs a heap spray to reclaim the freed
ClickToolmemory chunk. - The
widget->HandleInputEvent()call returns, andDoMouseDownexecutesmouse_up_event_ = mouse_down;, overwriting the reclaimed chunk with the attacker-influencedWebMouseEventstruct.
Proposed Fix
The affected dispatcher methods should use base::WeakPtr to check if the instance survived the synchronous HandleInputEvent call.
base::WeakPtr<ClickDispatcher> weak_this = weak_ptr_factory_.GetWeakPtr();
blink::WebInputEventResult result = widget->HandleInputEvent(
blink::WebCoalescedInputEvent(mouse_down, ui::LatencyInfo()));
if (!weak_this) {
return;
}
This pattern should be applied to ClickDispatcher::ClickDispatcher, ClickDispatcher::DoMouseDown, ClickDispatcher::DoMouseUpImpl, TypeTool::CreateAndDispatchKeyEvent, and KeyDispatcher::CreateAndDispatchKeyEvent.
Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.
Raised in root component due to access or custom field issues on 1707859