CVE-2026-118330
Overview
Background
- `AppMenuZoomView`
- the views control (
chrome/browser/ui/views/app_menu/app_menu_zoom_view.cc) rendering the zoom row (-/+buttons and azoom_label_) inside the browser’s app menu. - `ActionItem` / `AddActionChangedCallback`
- the zoom action is an
actions::ActionItemowned byBrowserActionsthat outlives any single menu;AppMenuZoomViewsubscribes to its changes to keepzoom_label_text in sync. - `Label::SetText()` and native accessibility events
- updating label text synchronously raises a platform accessibility event. On Windows that is a UIA
kTextChanged(UIA_Text_TextChangedEventId). - COM STA message pump re-entrancy
- raising a UIA event on the Windows single-threaded-apartment pump can process an incoming UIA request inline, e.g.
IExpandCollapseProvider::Collapse()on the app-menu button, which closes and destroys the menu while the callback is still on the stack. - `ScopedAccessibilityEventBlocker`
- a views helper that suppresses accessibility event emission for the scope of its lifetime.
Root Cause Analysis
The zoom label’s action-changed callback was a lambda bound with a raw views::Label* label (zoom_label_) and the ActionItem*. When the zoom percentage changed, the callback compared and then called label->SetText(...) followed by label->GetViewAccessibility().AnnounceAlert(...). The hazard is re-entrancy through accessibility: SetText() synchronously raises the native kTextChanged UIA event, which can spin the COM STA message pump and let an incoming UIA request (such as Collapse() on the app-menu button) run inline. That request closes and destroys the menu — and with it AppMenuZoomView and its zoom_label_ — while SetText() and the enclosing callback are still executing. Because the callback held a raw pointer to the now-freed Label (and zoom_child outlives the menu, so the callback itself can still fire), the continued use of label (the subsequent AnnounceAlert() and the tail of SetText()) is a use-after-free.
The fix removes both the dangling-raw-pointer binding and the re-entrant event. The callback is now bound with weak_ptr_factory_.GetWeakPtr() (a base::WeakPtr<AppMenuZoomView>) instead of the raw zoom_label_; it early-returns if !self || !self->zoom_label_, and only then dereferences self->zoom_label_. Additionally, the SetText() call is wrapped in a views::ScopedAccessibilityEventBlocker so the intermediate kTextChanged event is suppressed and cannot re-entrantly destroy the menu mid-update; the explicit AnnounceAlert() at the end still fires the intended single alert. A base::WeakPtrFactory<AppMenuZoomView> is added to the header. The advisory’s Use after free in Browser matches the diff precisely; the component (“Browser”) is just the surface. The regression test ZoomLabelUpdateSurvivesReentrantMenuDestruction simulates a UIA client closing/destroying the menu during the kTextChanged raise and asserts survival.
Label and let Label::SetText() synchronously raise a UIA kTextChanged event that could spin the Windows STA pump and destroy the whole menu (and label) re-entrantly, so the callback kept using freed memory — fixed with a WeakPtr guard plus an accessibility-event blocker around SetText().Attack Path
- Open the app menu User/attacker-influenced flow opens the browser app menu showing the zoom row. (local UI precondition)
- UIA client present A UIA/accessibility client (assistive tech, automation, or a malicious local UIA client) is connected on Windows. (gating: Windows + UIA)
- Change zoom
The zoom percentage changes while the menu is open, firing the action-changed callback and
zoom_label_->SetText(). - Re-entrant close
SetText()raiseskTextChanged; the STA pump processes an inlineIExpandCollapseProvider::Collapse()that closes and destroys the menu, freeingAppMenuZoomView/zoom_label_. - Primitive
The still-running callback dereferences the freed
Label(e.g.,AnnounceAlert()), a browser-process use-after-free. (escalation inferred)
Impact Assessment
Collapse() during the kTextChanged raise — i.e., local accessibility tooling or a malicious local UIA client rather than direct web content. The achieved primitive is a re-entrant use-after-free of AppMenuZoomView/zoom_label_; its strength and any escalation beyond a crash are (inferred), as the diff shows the lifetime/re-entrancy fix but not an exploit sink. This is a genuine UAF, not a mislabel.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/app_menu/app_menu_zoom_view.cc |
modified | |
TEST_Fchrome/browser/ui/views/app_menu/action_app_menu_unittest.cc |
modified | |
ifchrome/browser/ui/views/app_menu/action_app_menu_unittest.cc |
modified |
Files Changed
chrome/browser/ui/views/app_menu/app_menu_zoom_view.hchrome/browser/ui/views/app_menu/app_menu_zoom_view.ccchrome/browser/ui/views/app_menu/action_app_menu_unittest.cc
Audit Directions
- Raw pointers in `AddActionChangedCallback`/callback-list bindingsAudit views callbacks bound with raw
View*/Label*where the subscription source (e.g., anActionItem) outlives the view; these dangle if the view is destroyed first. - `SetText()` / label updates on WindowsReview UI update paths that call
Label::SetText()(or otherwise raise native accessibility events) while on a destructible menu/dialog stack; synchronous UIA dispatch can spin the STA pump and re-enter. - Re-entrant menu/dialog destructionLook for other menu interactions (expand/collapse, invoke) reachable via UIA that can close the menu mid-callback, and verify callbacks are WeakPtr-guarded.
- Missing accessibility-event scopingIdentify multi-step UI mutations that should wrap intermediate steps in
ScopedAccessibilityEventBlockerto avoid premature/synchronous event dispatch.
Patch
From 8ecdbc248b46ef86145e571c7e342c0cb03c4975 Mon Sep 17 00:00:00 2001 From: Steven Luong <[email protected]> Date: Thu, 22 Oct 2026 10:08:21 -0700 Subject: [PATCH] [ChroMenu] Fix potential UAF when updating zoom label text When the zoom percentage changes while the app menu is open, AppMenuZoomView updates `zoom_label_` via `Label::SetText()` and then calls `AnnounceAlert()`. On Windows, `Label::SetText()` synchronously raises a native `kTextChanged` accessibility event (`UIA_Text_TextChangedEventId`), which can spin the COM STA message pump and process an incoming UIA request (such as `IExpandCollapseProvider::Collapse()` on the app menu button) that closes and destroys the menu while `SetText()` and the action-changed callback are still on the stack. To prevent this re-entrant use-after-free: 1. Bind a `base::WeakPtr<AppMenuZoomView>` in the zoom label's action changed callback instead of an unowned raw pointer. 2. Wrap `label->SetText()` in a `views::ScopedAccessibilityEventBlocker` so the intermediate `kTextChanged` event is suppressed before the explicit `AnnounceAlert()` call at the end of the callback. Fixed: 575090745 Change-Id: Ic10de222f12c7c90f5b6aa9dd4868c9ae638c57a Bug: 575090745 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8616544 Auto-Submit: Steven Luong <[email protected]> Commit-Queue: Darryl James <[email protected]> Commit-Queue: Steven Luong <[email protected]> Reviewed-by: Darryl James <[email protected]> Cr-Commit-Position: refs/heads/main@{#1724911} --- diff --git a/chrome/browser/ui/views/app_menu/app_menu_zoom_view.cc b/chrome/browser/ui/views/app_menu/app_menu_zoom_view.cc index 7612c2e..32738bea 100644 --- a/chrome/browser/ui/views/app_menu/app_menu_zoom_view.cc +++ b/chrome/browser/ui/views/app_menu/app_menu_zoom_view.cc @@ -121,15 +121,33 @@ zoom_label_->SetPreferredSize(gfx::Size( GetZoomLabelMaxWidth(), zoom_label_->GetPreferredSize().height())); + // Bind a WeakPtr to `this` rather than a raw pointer to `zoom_label_` + // because `zoom_child` is owned by BrowserActions and outlives the menu; + // synchronous native accessibility event dispatch (e.g., UIA on Windows) + // can also spin an STA message pump and re-entrantly close/destroy the + // menu while action callbacks are being notified. This is needed to + // prevent a UAF bug as reported in crbug.com/575090745. zoom_label_subscription_ = zoom_child->AddActionChangedCallback(base::BindRepeating( - [](views::Label* label, actions::ActionItem* item) { + [](base::WeakPtr<AppMenuZoomView> self, + actions::ActionItem* item) { + if (!self || !self->zoom_label_) { + return; + } + views::Label* label = self->zoom_label_; if (label->GetText() != item->GetText()) { - label->SetText(std::u16string(item->GetText())); + // Block the intermediate kTextChanged event during SetText() + // so synchronous UIA event dispatch cannot re-entrantly + // destroy `label` mid-update before AnnounceAlert() runs. + { + views::ScopedAccessibilityEventBlocker blocker( + label->GetViewAccessibility()); + label->SetText(std::u16string(item->GetText())); + } label->GetViewAccessibility().AnnounceAlert(item->GetText()); } }, - zoom_label_, zoom_child)); + weak_ptr_factory_.GetWeakPtr(), zoom_child)); } else { views::ImageButton* const zoom_child_button = AddChildView(CreateZoomButton(zoom_child)); diff --git a/chrome/browser/ui/views/app_menu/app_menu_zoom_view.h b/chrome/browser/ui/views/app_menu/app_menu_zoom_view.h index af65716..3a317de 100644 --- a/chrome/browser/ui/views/app_menu/app_menu_zoom_view.h +++ b/chrome/browser/ui/views/app_menu/app_menu_zoom_view.h @@ -11,6 +11,7 @@ #include "base/callback_list.h" #include "base/containers/flat_map.h" #include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" #include "ui/actions/actions.h" #include "ui/base/metadata/metadata_header_macros.h" #include "ui/color/color_id.h" @@ -80,6 +81,7 @@ raw_ptr<views::ImageButton> zoom_plus_button_ = nullptr; base::CallbackListSubscription zoom_label_subscription_; std::vector<base::CallbackListSubscription> button_subscriptions_; + base::WeakPtrFactory<AppMenuZoomView> weak_ptr_factory_{this}; }; #endif // CHROME_BROWSER_UI_VIEWS_APP_MENU_APP_MENU_ZOOM_VIEW_H_ diff --git a/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc b/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc index 3b70064..2eabe11 100644 --- a/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc +++ b/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc @@ -1604,6 +1604,61 @@ } #endif // !BUILDFLAG(IS_MAC) +// Regression test for crbug.com/575090745: Firing a synchronous native +// accessibility event during zoom label text updates can spin the Windows COM +// STA message pump and re-entrantly close and destroy the menu. +TEST_F(ActionAppMenuTest, ZoomLabelUpdateSurvivesReentrantMenuDestruction) { + content::RenderViewHostTestEnabler rvh_test_enabler; + tabs::MockTabInterface mock_tab; + std::unique_ptr<content::WebContents> web_contents = + content::WebContentsTester::CreateTestWebContents(profile_.get(), + nullptr); + zoom::ZoomController::CreateForWebContents(web_contents.get()); + + EXPECT_CALL(mock_window_interface_, GetActiveTabInterface()) + .WillRepeatedly(testing::Return(&mock_tab)); + EXPECT_CALL(mock_tab, GetContents()) + .WillRepeatedly(testing::Return(web_contents.get())); + + auto menu = std::make_unique<ActionAppMenu>(&mock_window_interface_, + base::DoNothing()); + menu->RunMenu(button_->button_controller(), views::MenuRunner::NO_FLAGS); + ASSERT_TRUE(menu->IsShowing()); + + views::MenuItemView* const root = menu->root_menu_item_for_testing(); + ASSERT_TRUE(root); + + views::MenuItemView* zoom_item = root->GetMenuItemByID(kActionZoomSubmenu); + ASSERT_TRUE(zoom_item); + + auto* zoom_view = + views::AsViewClass<AppMenuZoomView>(zoom_item->children()[0]); + ASSERT_TRUE(zoom_view); + + views::Label* const zoom_label = zoom_view->zoom_label_for_testing(); + ASSERT_NE(zoom_label, nullptr); + + // Simulate a UIA client re-entrantly invoking AppMenuButton::CloseMenu() + // during the kTextChanged accessibility event raise inside Label::SetText(). + zoom_label->GetViewAccessibility().set_accessibility_events_callback( + base::BindLambdaForTesting( + [&menu](const ui::AXPlatformNodeDelegate*, ax::mojom::Event event) { + if (event == ax::mojom::Event::kTextChanged && menu) { + menu->CloseMenu(); + menu.reset(); + } + })); + + actions::ActionItem* normal_action = + actions::ActionManager::Get().FindAction(kActionZoomNormal, nullptr); + ASSERT_TRUE(normal_action); + + normal_action->SetText(base::FormatPercent(200)); + EXPECT_EQ(zoom_label->GetText(), base::FormatPercent(200)); + EXPECT_TRUE(menu->IsShowing()); + menu->CloseMenu(); +} + TEST_F(ActionAppMenuTest, FullscreenActionUpdatesAccessibilityName) { base::MockCallback<base::RepeatingClosure> on_menu_closed; ActionAppMenu menu(&mock_window_interface_, on_menu_closed.Get());
Regression Test / PoC
diff --git a/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc b/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc
index 3b70064..2eabe11 100644
--- a/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc
+++ b/chrome/browser/ui/views/app_menu/action_app_menu_unittest.cc
@@ -1604,6 +1604,61 @@
}
#endif // !BUILDFLAG(IS_MAC)
+// Regression test for crbug.com/575090745: Firing a synchronous native
+// accessibility event during zoom label text updates can spin the Windows COM
+// STA message pump and re-entrantly close and destroy the menu.
+TEST_F(ActionAppMenuTest, ZoomLabelUpdateSurvivesReentrantMenuDestruction) {
+ content::RenderViewHostTestEnabler rvh_test_enabler;
+ tabs::MockTabInterface mock_tab;
+ std::unique_ptr<content::WebContents> web_contents =
+ content::WebContentsTester::CreateTestWebContents(profile_.get(),
+ nullptr);
+ zoom::ZoomController::CreateForWebContents(web_contents.get());
+
+ EXPECT_CALL(mock_window_interface_, GetActiveTabInterface())
+ .WillRepeatedly(testing::Return(&mock_tab));
+ EXPECT_CALL(mock_tab, GetContents())
+ .WillRepeatedly(testing::Return(web_contents.get()));
+
+ auto menu = std::make_unique<ActionAppMenu>(&mock_window_interface_,
+ base::DoNothing());
+ menu->RunMenu(button_->button_controller(), views::MenuRunner::NO_FLAGS);
+ ASSERT_TRUE(menu->IsShowing());
+
+ views::MenuItemView* const root = menu->root_menu_item_for_testing();
+ ASSERT_TRUE(root);
+
+ views::MenuItemView* zoom_item = root->GetMenuItemByID(kActionZoomSubmenu);
+ ASSERT_TRUE(zoom_item);
+
+ auto* zoom_view =
+ views::AsViewClass<AppMenuZoomView>(zoom_item->children()[0]);
+ ASSERT_TRUE(zoom_view);
+
+ views::Label* const zoom_label = zoom_view->zoom_label_for_testing();
+ ASSERT_NE(zoom_label, nullptr);
+
+ // Simulate a UIA client re-entrantly invoking AppMenuButton::CloseMenu()
+ // during the kTextChanged accessibility event raise inside Label::SetText().
+ zoom_label->GetViewAccessibility().set_accessibility_events_callback(
+ base::BindLambdaForTesting(
+ [&menu](const ui::AXPlatformNodeDelegate*, ax::mojom::Event event) {
+ if (event == ax::mojom::Event::kTextChanged && menu) {
+ menu->CloseMenu();
+ menu.reset();
+ }
+ }));
+
+ actions::ActionItem* normal_action =
+ actions::ActionManager::Get().FindAction(kActionZoomNormal, nullptr);
+ ASSERT_TRUE(normal_action);
+
+ normal_action->SetText(base::FormatPercent(200));
+ EXPECT_EQ(zoom_label->GetText(), base::FormatPercent(200));
+ EXPECT_TRUE(menu->IsShowing());
+ menu->CloseMenu();
+}
+
TEST_F(ActionAppMenuTest, FullscreenActionUpdatesAccessibilityName) {
base::MockCallback<base::RepeatingClosure> on_menu_closed;
ActionAppMenu menu(&mock_window_interface_, on_menu_closed.Get());