Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Accessibility
DescriptionUse after free in Accessibility
ComponentAccessibility
Bug ClassUAF
Tracker503419515
Fix commit7bf572e7a2ed (chromium/src) +55/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-28

Changed Functions

FunctionChangeNotes
TEST_F
ui/accessibility/platform/ax_platform_node_win_unittest.cc
modified

Files Changed

  • ui/accessibility/platform/ax_platform_node_win.cc
  • ui/accessibility/platform/ax_platform_node_win.h
  • ui/accessibility/platform/ax_platform_node_win_unittest.cc
From 7bf572e7a2ed8c0d893e686b232f018d36bcd6cb Mon Sep 17 00:00:00 2001
From: Benjamin Beaudry <[email protected]>
Date: Thu, 16 Apr 2026 17:11:41 -0700
Subject: [PATCH] [a11y] Guard against node destruction during alert event dispatch

This CL fixes a potential use-after-free in AXPlatformNodeWin where a
node destroyed during the reentrant message pump inside an alert event
notification would be inserted into the process-global alert targets
set as a dangling pointer. See bug for details.

We fix it by preventing AddAlertTarget() from inserting a node that is
already destroyed, and add a regression test that reproduces the
post-reentrancy state and asserts the global set is unchanged.

Fixed: 503419515
Change-Id: I6721a22bd0ba67b0d6d4c8ef84d7cabd5447d6d9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7769271
Auto-Submit: Benjamin Beaudry <[email protected]>
Commit-Queue: Benjamin Beaudry <[email protected]>
Reviewed-by: Kevin Babbitt <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1616207}
---

diff --git a/ui/accessibility/platform/ax_platform_node_win.cc b/ui/accessibility/platform/ax_platform_node_win.cc
index a5354a2..5832649 100644
--- a/ui/accessibility/platform/ax_platform_node_win.cc
+++ b/ui/accessibility/platform/ax_platform_node_win.cc
@@ -8315,9 +8315,23 @@
 }
 
 void AXPlatformNodeWin::AddAlertTarget() {
+  // Firing an alert event can reentrantly destroy this node via the STA
+  // message pump; don't insert a dangling pointer. crbug.com/503419515.
+  if (IsDestroyed()) {
+    return;
+  }
   GetAlertTargets().insert(this);
 }
 
+// static
+size_t AXPlatformNodeWin::GetAlertTargetCountForTesting() {
+  return GetAlertTargets().size();
+}
+
+void AXPlatformNodeWin::AddAlertTargetForTesting() {
+  AddAlertTarget();
+}
+
 void AXPlatformNodeWin::RemoveAlertTarget() {
   if (GetAlertTargets().find(this) != GetAlertTargets().end()) {
     GetAlertTargets().erase(this);
diff --git a/ui/accessibility/platform/ax_platform_node_win.h b/ui/accessibility/platform/ax_platform_node_win.h
index 7413fc3..1101800 100644
--- a/ui/accessibility/platform/ax_platform_node_win.h
+++ b/ui/accessibility/platform/ax_platform_node_win.h
@@ -1231,6 +1231,12 @@
   // see above.
   static Counts ResetCountsForTesting();
 
+  // Returns the size of the process-global alert targets set.
+  static size_t GetAlertTargetCountForTesting();
+
+  // Test-only wrapper around AddAlertTarget().
+  void AddAlertTargetForTesting();
+
   bool IsUIAControl() const;
 
  protected:
diff --git a/ui/accessibility/platform/ax_platform_node_win_unittest.cc b/ui/accessibility/platform/ax_platform_node_win_unittest.cc
index a34792d..9bb2610b 100644
--- a/ui/accessibility/platform/ax_platform_node_win_unittest.cc
+++ b/ui/accessibility/platform/ax_platform_node_win_unittest.cc
@@ -8291,4 +8291,39 @@
   }
 }
 
+// Regression test for crbug.com/503419515: a node destroyed mid-event must
+// not be inserted into the global alert targets set.
+TEST_F(AXPlatformNodeWinTest, DestroyedNodeNotAddedToAlertTargets) {
+  AXNodeData root;
+  root.id = 1;
+  root.role = ax::mojom::Role::kRootWebArea;
+  root.child_ids = {2};
+
+  AXNodeData alert;
+  alert.id = 2;
+  alert.role = ax::mojom::Role::kAlert;
+
+  Init(root, alert);
+  AXNode* alert_ax_node = GetRoot()->children()[0];
+
+  auto* alert_node = static_cast<AXPlatformNodeWin*>(
+      AXPlatformNodeFromNode(alert_ax_node));
+  ASSERT_TRUE(alert_node);
+
+  const size_t initial_count =
+      AXPlatformNodeWin::GetAlertTargetCountForTesting();
+
+  // Put the node in the IsDestroyed() state without actually destroying it,
+  // so the wrapper can still tear down cleanly at the end of the test.
+  AXPlatformNodeDelegate* original_delegate =
+      alert_node->SetDelegateForTesting(nullptr);
+  ASSERT_TRUE(alert_node->IsDestroyed());
+
+  alert_node->AddAlertTargetForTesting();
+  EXPECT_EQ(initial_count,
+            AXPlatformNodeWin::GetAlertTargetCountForTesting());
+
+  alert_node->SetDelegateForTesting(original_delegate);
+}
+
 }  // namespace ui
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ui/accessibility/platform/ax_platform_node_win_unittest.cc b/ui/accessibility/platform/ax_platform_node_win_unittest.cc
index a34792d..9bb2610b 100644
--- a/ui/accessibility/platform/ax_platform_node_win_unittest.cc
+++ b/ui/accessibility/platform/ax_platform_node_win_unittest.cc
@@ -8291,4 +8291,39 @@
   }
 }
 
+// Regression test for crbug.com/503419515: a node destroyed mid-event must
+// not be inserted into the global alert targets set.
+TEST_F(AXPlatformNodeWinTest, DestroyedNodeNotAddedToAlertTargets) {
+  AXNodeData root;
+  root.id = 1;
+  root.role = ax::mojom::Role::kRootWebArea;
+  root.child_ids = {2};
+
+  AXNodeData alert;
+  alert.id = 2;
+  alert.role = ax::mojom::Role::kAlert;
+
+  Init(root, alert);
+  AXNode* alert_ax_node = GetRoot()->children()[0];
+
+  auto* alert_node = static_cast<AXPlatformNodeWin*>(
+      AXPlatformNodeFromNode(alert_ax_node));
+  ASSERT_TRUE(alert_node);
+
+  const size_t initial_count =
+      AXPlatformNodeWin::GetAlertTargetCountForTesting();
+
+  // Put the node in the IsDestroyed() state without actually destroying it,
+  // so the wrapper can still tear down cleanly at the end of the test.
+  AXPlatformNodeDelegate* original_delegate =
+      alert_node->SetDelegateForTesting(nullptr);
+  ASSERT_TRUE(alert_node->IsDestroyed());
+
+  alert_node->AddAlertTargetForTesting();
+  EXPECT_EQ(initial_count,
+            AXPlatformNodeWin::GetAlertTargetCountForTesting());
+
+  alert_node->SetDelegateForTesting(original_delegate);
+}
+
 }  // namespace ui
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in AXPlatformNodeWin via reentrant accessibility event firing

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 go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A potential Use-After-Free exists in the Windows accessibility implementation where a node can be destroyed during a synchronous COM call and subsequently added to a global set. This results in a permanently dangling pointer that is later dereferenced via virtual function calls. The issue can potentially be triggered by a compromised renderer to achieve a browser-process sandbox escape.

Affected files:

  • ui/accessibility/platform/ax_platform_node_win.cc
  • ui/accessibility/platform/ax_platform_node_base.h
  • ui/accessibility/platform/ax_platform_node.h

Estimated timestamp from git blame: 2026-02-24

Summary

A potential Use-After-Free (UAF) vulnerability exists in the browser process on Windows within the AXPlatformNodeWin accessibility implementation. The issue is caused by a deterministic race condition during the firing of alert events. A node can be destroyed during a synchronous COM/Win32 call before it is added to a global tracking set. This results in a permanently dangling pointer in a global container, which is later dereferenced via virtual function calls. Because the container stores raw pointers, MiraclePtr does not mitigate the issue.

Vulnerability Details

  1. Reentrant Event Notification: In ui/accessibility/platform/ax_platform_node_win.cc, NotifyAccessibilityEvent(kAlert) fires events to the OS using ::NotifyWinEvent (line 760) and ::UiaRaiseAutomationEvent (line 789). As documented in Chromium, firing these events can cause UIA to call back into Chromium’s APIs, leading to synchronous reentrancy and pumping the thread’s Single-Threaded Apartment (STA) message loop.
  2. The Race Condition: If a compromised renderer queues an IPC to close the UI element (e.g., window.close()) immediately after triggering an alert event, the IPC can be processed during the reentrant message pump inside ::UiaRaiseAutomationEvent.
  3. Premature Destruction: Processing the IPC destroys the views::View, which in turn destroys its ViewAXPlatformNodeDelegate. The delegate owns the AXPlatformNode via a unique_ptr, which calls AXPlatformNodeWin::Destroy() (line 685).
  4. Missed Removal: The first action in Destroy() is RemoveAlertTarget(). However, because execution is still paused inside NotifyAccessibilityEvent and hasn’t reached the addition step, the node is not in the set, and the removal is a no-op.
  5. Adding the Dangling Pointer: Once the synchronous OS call returns, NotifyAccessibilityEvent resumes. At line 795, it calls AddAlertTarget(), which blindly inserts the now-freed (or soon-to-be-freed) this pointer into the process-global absl::flat_hash_set<AXPlatformNodeWin*> returned by GetAlertTargets().
  6. Permanent Dangling Pointer: Crucially, the destructor ~AXPlatformNodeWin() (lines 399-405) does not call RemoveAlertTarget(). The pointer remains in the set permanently.
  7. The Dereference: The dangling pointer is dereferenced when an assistive technology queries the “alerts” relation via IAccessible2_2::get_relationTargetsOfType(L"alerts"). This method iterates over GetAlertTargets() (line 2147) and calls IsDescendant(target), which invokes the pure virtual method target->IsDescendantOf(this). This results in a vtable fetch and an indirect call on freed memory.

Potential Exploitation Steps

(Note: These are suggested steps based on code analysis; our tooling has not executed a working proof-of-concept.)

  1. An attacker compromises a renderer process.
  2. The attacker triggers a UI element in the browser process that fires a kAlert event (e.g., a Geolocation permission prompt).
  3. Immediately after, the attacker sends an IPC to close the prompt.
  4. The kAlert event triggers the synchronous ::UiaRaiseAutomationEvent call, which pumps the message loop, processes the close IPC, and destroys the node.
  5. The node’s raw pointer is leaked into the global GetAlertTargets set.
  6. The attacker grooms the browser process heap to control the vtable of the reclaimed object.
  7. The attacker triggers a query for the “alerts” relation, causing a virtual call on the dangling pointer, leading to arbitrary code execution in the browser process (sandbox escape).

Suggested Fix

There are two main ways to fix this:

  1. Update AddAlertTarget() or NotifyAccessibilityEvent to check if the node has been destroyed before adding it to the set (e.g., if (event_type == ax::mojom::Event::kAlert && !IsDestroyed())).
  2. Modify the destructor AXPlatformNodeWin::~AXPlatformNodeWin() to ensure RemoveAlertTarget() is called when the object memory is actually freed.

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


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.

View on issue tracker