CVE-2026-87562
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
AXNodeIdDelegateui/accessibility/platform/ax_platform_node_id.h |
modified | |
TEST_Fui/accessibility/platform/browser_accessibility_mac_unittest.mm |
modified | |
ifui/accessibility/platform/test_ax_node_id_delegate.cc |
modified |
Files Changed
ui/accessibility/platform/ax_node_id_delegate.hui/accessibility/platform/ax_platform_node_id.hui/accessibility/platform/browser_accessibility_cocoa.mmui/accessibility/platform/browser_accessibility_mac_unittest.mmui/accessibility/platform/test_ax_node_id_delegate.cc
Patch
From 551ccd1418a14d9f4a4dc1a52da82cc8f8e99ffa Mon Sep 17 00:00:00 2001
From: Avi Drissman <avi@chromium.org>
Date: Thu, 30 Jul 2026 08:05:00 -0700
Subject: [PATCH] [a11y][mac] Give BrowserAccessibilityCocoa stable identity
-isEqual: was implemented as hash equality, and -hash returned the
per-tree AXNodeID while the wrapper was active and [super hash] once
detached. AXNodeID is only unique within a single tree, so wrappers from
different frames could compare equal, and -hash changed mid-lifetime for
objects that AppKit may still be holding in hash-keyed collections.
Fix this by using the unique id of the nodes, and caching it.
Fixed: 513135531
Change-Id: Ie1c4136a3dcdac0b3d7b0517e04941346a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8156962
Commit-Queue: Avi Drissman <avi@chromium.org>
Auto-Submit: Avi Drissman <avi@chromium.org>
Reviewed-by: David Tseng <dtseng@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671071}
---
diff --git a/ui/accessibility/platform/ax_node_id_delegate.h b/ui/accessibility/platform/ax_node_id_delegate.h
index e05d51b..70b5abe 100644
--- a/ui/accessibility/platform/ax_node_id_delegate.h
+++ b/ui/accessibility/platform/ax_node_id_delegate.h
@@ -28,13 +28,7 @@
virtual void OnAXNodeDeleted(AXNodeID ax_node_id) = 0;
protected:
- using PassKey = base::PassKey<AXNodeIdDelegate>;
-
AXNodeIdDelegate() = default;
-
- // Returns a PassKey to be used by implementations so that they may create
- // AXPlatformNodeId instances with values of their own choosing.
- static constexpr PassKey MakePassKey() { return PassKey(); }
};
} // namespace ui
diff --git a/ui/accessibility/platform/ax_platform_node_id.h b/ui/accessibility/platform/ax_platform_node_id.h
index e62e936..1ab7121 100644
--- a/ui/accessibility/platform/ax_platform_node_id.h
+++ b/ui/accessibility/platform/ax_platform_node_id.h
@@ -12,8 +12,6 @@
namespace ui {
-class AXNodeIdDelegate;
-
// The underlying type of a uniquely-generated accessibility node identifier for
// use with the platform. The actual id store can only a accommodate 32 bit
// integers on Windows, because IAccessible2 uses LONG for get_uniqueID() and
@@ -37,12 +35,6 @@
constexpr AXPlatformNodeId()
: base::StrongAlias<class AXPlatformNodeIdTag, int32_t>::StrongAlias(0) {}
- // Allow implementations of AXNodeIdDelegate to create instances, as they are
- // responsible for per-window allocation of unique identifiers.
- constexpr explicit AXPlatformNodeId(base::PassKey<AXNodeIdDelegate>,
- int32_t v)
- : base::StrongAlias<class AXPlatformNodeIdTag, int32_t>::StrongAlias(v) {}
-
// Allow implicit conversion to the platform's node id type.
constexpr operator const int32_t&() const { return value_; }
diff --git a/ui/accessibility/platform/browser_accessibility_cocoa.mm b/ui/accessibility/platform/browser_accessibility_cocoa.mm
index 03984bef..03d484e7 100644
--- a/ui/accessibility/platform/browser_accessibility_cocoa.mm
+++ b/ui/accessibility/platform/browser_accessibility_cocoa.mm
@@ -495,12 +495,19 @@
@end
@implementation BrowserAccessibilityCocoa {
- // Dangling pointer https://crbug.com/1475830.
+ // Dangling pointer https://crbug.com/40928052.
raw_ptr<ui::BrowserAccessibility, DanglingUntriaged> _owner;
+
+ // The global unique ID of the `_owner`. Cached because this value might be
+ // needed for comparisons/hashing after the owner value is reset.
+ ui::AXPlatformNodeId _uniqueId;
+
// An array of children of this object. Cached to avoid re-computing.
NSMutableArray* __strong _children;
+
// 1-byte bitfield bundle. Main thread only (AppKit / NSAccessibility).
BrowserAccessibilityCocoaBitfields _bitfields;
+
// Stores the previous value of an edit field.
std::u16string _oldValue;
}
@@ -560,6 +567,7 @@
withPlatformNode:(ui::AXPlatformNodeMac*)platform_node {
if ((self = [super initWithNode:platform_node])) {
_owner = accessibility;
+ _uniqueId = static_cast<ui::AXPlatformNodeDelegate*>(_owner)->GetUniqueId();
_bitfields.gettingChildren = 0;
_bitfields.emptyGroupCache = kEmptyGroupCacheUnknown;
}
@@ -3556,16 +3564,16 @@
}
- (BOOL)isEqual:(id)object {
- if (![object isKindOfClass:[BrowserAccessibilityCocoa class]])
- return NO;
- return ([self hash] == [object hash]);
+ if (BrowserAccessibilityCocoa* objectAccessibility =
+ base::apple::ObjCCast<BrowserAccessibilityCocoa>(object)) {
+ return _uniqueId == objectAccessibility->_uniqueId;
+ }
+
+ return NO;
}
- (NSUInteger)hash {
- // Potentially called during dealloc.
- if (![self instanceActive])
- return [super hash];
- return _owner->GetId();
+ return _uniqueId;
}
- (BOOL)accessibilityNotifiesWhenDestroyed {
diff --git a/ui/accessibility/platform/browser_accessibility_mac_unittest.mm b/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
index c11015c..2cd7ef28 100644
--- a/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
+++ b/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
@@ -234,6 +234,46 @@
EXPECT_NSEQ(nil, retainedFirstChild.accessibilityLabel);
}
+// AppKit may retain a wrapper and key it in a hash-based collection, so
+// -hash must remain stable for the lifetime of the wrapper, and distinct
+// wrappers must never compare equal even if their backing nodes happen to
+// share the same per-tree id.
+TEST_F(BrowserAccessibilityMacTest, IdentityIsPerWrapperAndStable) {
+ // Build a second, independent tree whose root re-uses the same per-tree id
+ // as a node in the fixture's tree.
+ AXNodeData other_root;
+ other_root.id = 1000;
+ other_root.role = ax::mojom::Role::kRootWebArea;
+ TestAXNodeIdDelegate other_node_id_delegate;
+ std::unique_ptr<BrowserAccessibilityManager> other_manager =
+ std::make_unique<BrowserAccessibilityManagerMac>(
+ MakeAXTreeUpdateForTesting(other_root), other_node_id_delegate,
+ nullptr);
+ BrowserAccessibilityCocoa* other_wrapper =
+ base::apple::ObjCCastStrict<BrowserAccessibilityCocoa>(
+ other_manager->GetBrowserAccessibilityRoot()
+ ->GetNativeViewAccessible()
+ .Get());
+
+ ASSERT_NE(accessibility_, other_wrapper);
+ EXPECT_FALSE([accessibility_ isEqual:other_wrapper]);
+ EXPECT_FALSE([other_wrapper isEqual:accessibility_]);
+
+ // Hold the wrapper past detach, as the system might.
+ NS_VALID_UNTIL_END_OF_SCOPE BrowserAccessibilityCocoa* retained =
+ accessibility_;
+ const NSUInteger hash_before = retained.hash;
+ EXPECT_TRUE([retained isEqual:retained]);
+
+ // Tearing down the manager detaches the wrapper.
+ manager_.reset();
+ ASSERT_FALSE([retained instanceActive]);
+
+ EXPECT_EQ(hash_before, retained.hash);
+ EXPECT_TRUE([retained isEqual:retained]);
+ EXPECT_FALSE([retained isEqual:other_wrapper]);
+}
+
TEST_F(BrowserAccessibilityMacTest, TestComputeTextEdit) {
root_ = AXNodeData();
root_.id = 1;
diff --git a/ui/accessibility/platform/test_ax_node_id_delegate.cc b/ui/accessibility/platform/test_ax_node_id_delegate.cc
index 39e1a1b..e2d609f 100644
--- a/ui/accessibility/platform/test_ax_node_id_delegate.cc
+++ b/ui/accessibility/platform/test_ax_node_id_delegate.cc
@@ -6,10 +6,21 @@
namespace ui {
-AXPlatformNodeId TestAXNodeIdDelegate::GetOrCreateAXNodeUniqueId(
- AXNodeID ax_node_id) {
- // Per-tab uniqueness is not necessary in tests, so return the blink node id.
- return AXPlatformNodeId(MakePassKey(), ax_node_id);
+TestAXNodeIdDelegate::TestAXNodeIdDelegate() = default;
+TestAXNodeIdDelegate::~TestAXNodeIdDelegate() = default;
+
+ui::AXPlatformNodeId TestAXNodeIdDelegate::GetOrCreateAXNodeUniqueId(
+ ui::AXNodeID ax_node_id) {
+ auto [iter, inserted] =
+ ax_unique_ids_.try_emplace(ax_node_id, ui::AXUniqueId::CreateInvalid());
+ if (inserted) {
+ iter->second = ui::AXUniqueId::Create();
+ }
+ return iter->second;
+}
+
+void TestAXNodeIdDelegate::OnAXNodeDeleted(ui::AXNodeID ax_node_id) {
Regression Test / PoC
diff --git a/ui/accessibility/platform/browser_accessibility_mac_unittest.mm b/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
index c11015c..2cd7ef28 100644
--- a/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
+++ b/ui/accessibility/platform/browser_accessibility_mac_unittest.mm
@@ -234,6 +234,46 @@
EXPECT_NSEQ(nil, retainedFirstChild.accessibilityLabel);
}
+// AppKit may retain a wrapper and key it in a hash-based collection, so
+// -hash must remain stable for the lifetime of the wrapper, and distinct
+// wrappers must never compare equal even if their backing nodes happen to
+// share the same per-tree id.
+TEST_F(BrowserAccessibilityMacTest, IdentityIsPerWrapperAndStable) {
+ // Build a second, independent tree whose root re-uses the same per-tree id
+ // as a node in the fixture's tree.
+ AXNodeData other_root;
+ other_root.id = 1000;
+ other_root.role = ax::mojom::Role::kRootWebArea;
+ TestAXNodeIdDelegate other_node_id_delegate;
+ std::unique_ptr<BrowserAccessibilityManager> other_manager =
+ std::make_unique<BrowserAccessibilityManagerMac>(
+ MakeAXTreeUpdateForTesting(other_root), other_node_id_delegate,
+ nullptr);
+ BrowserAccessibilityCocoa* other_wrapper =
+ base::apple::ObjCCastStrict<BrowserAccessibilityCocoa>(
+ other_manager->GetBrowserAccessibilityRoot()
+ ->GetNativeViewAccessible()
+ .Get());
+
+ ASSERT_NE(accessibility_, other_wrapper);
+ EXPECT_FALSE([accessibility_ isEqual:other_wrapper]);
+ EXPECT_FALSE([other_wrapper isEqual:accessibility_]);
+
+ // Hold the wrapper past detach, as the system might.
+ NS_VALID_UNTIL_END_OF_SCOPE BrowserAccessibilityCocoa* retained =
+ accessibility_;
+ const NSUInteger hash_before = retained.hash;
+ EXPECT_TRUE([retained isEqual:retained]);
+
+ // Tearing down the manager detaches the wrapper.
+ manager_.reset();
+ ASSERT_FALSE([retained instanceActive]);
+
+ EXPECT_EQ(hash_before, retained.hash);
+ EXPECT_TRUE([retained isEqual:retained]);
+ EXPECT_FALSE([retained isEqual:other_wrapper]);
+}
+
TEST_F(BrowserAccessibilityMacTest, TestComputeTextEdit) {
root_ = AXNodeData();
root_.id = 1;
Original Bug Report
Potential NSObject contract violation in BrowserAccessibilityCocoa leads to memory leaks
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: BrowserAccessibilityCocoa incorrectly implements isEqual: and hash using non-unique renderer-supplied IDs. This leads to hash instability when objects are detached and potential cross-origin element collisions in macOS accessibility collections.
Affected files:
ui/accessibility/platform/browser_accessibility_cocoa.mmui/accessibility/platform/ax_platform_node_cocoa.mm
Estimated timestamp from git blame: 2010-07-07
Description
The implementation of -[BrowserAccessibilityCocoa isEqual:] and -[BrowserAccessibilityCocoa hash] in ui/accessibility/platform/browser_accessibility_cocoa.mm violates the fundamental contract for NSObject identity. This results in two primary issues: hash instability during object lifecycle transitions and cross-origin element collisions in the browser process.
1. Hash Mutation After Detach (Memory Leak)
When an accessibility node is removed or a navigation occurs, the native wrapper is detached. The detachAndNotifyDestroyed: method nulls the internal pointers used to identify the node.
In ui/accessibility/platform/browser_accessibility_cocoa.mm:
- (NSUInteger)hash {
if (![self instanceActive])
return [super hash];
return _owner->GetId();
}
Once the object is detached, [self instanceActive] returns NO, causing the hash to transition from the node’s ID to a pointer-based hash ([super hash]). If this object is currently stored as a key in an NSDictionary or within an NSSet used by AppKit or assistive tools (like VoiceOver), the collection will be unable to locate the object to remove it after the hash changes. This leads to a persistent memory leak in the browser process that can be triggered by a malicious or malfunctioning renderer.
2. Cross-Tree ID Collisions (Information Confusion)
The current implementation uses _owner->GetId(), which returns an AXNodeID. These IDs are only unique within a single accessibility tree (frame). Consequently, nodes in different frames (potentially from different origins) often share the same ID (e.g., multiple frames having a node with id=1).
Because isEqual: is implemented solely by comparing these hashes:
- (BOOL)isEqual:(id)object {
if (![object isKindOfClass:[BrowserAccessibilityCocoa class]])
return NO;
return ([self hash] == [object hash]);
}
Two distinct BrowserAccessibilityCocoa wrappers representing elements in different origins will be considered equal. If a system collection caches these objects, an element from a victim origin could be erroneously substituted for one from an attacker-controlled origin.
Potential Attack Steps
- An attacker-controlled renderer creates an accessibility tree with predictable node IDs (e.g., beginning at 1).
- The attacker embeds a cross-origin iframe (the victim) which also contains nodes starting with the same IDs.
- The macOS accessibility architecture (VoiceOver) queries and caches elements from both frames in a hash-based collection.
- Due to the collision in
isEqual:andhash, the collection may return the attacker’s element when the victim’s element is requested, leading to UI spoofing or action redirection. - The attacker navigates the frame, triggering a detach. The hash of the cached objects changes, causing them to become ‘stuck’ in the system’s accessibility cache, leaking memory in the browser process.
Recommendation
BrowserAccessibilityCocoa should be modified to use GetUniqueId() instead of GetId(). The AXPlatformNodeId returned by GetUniqueId() is guaranteed to be unique within the browser process and remains stable throughout the wrapper’s lifetime, even after the backing BrowserAccessibility object is detached.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.