Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Accessibility
DescriptionInappropriate implementation in Accessibility
ComponentAccessibility
Bug ClassLogic Error
Tracker514022635
Fix commita84db1230994 (chromium/src) +142/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
UniqueAXNodeIdDelegate
content/browser/accessibility/browser_accessibility_android_unittest.cc
modified
if
content/browser/accessibility/browser_accessibility_android_unittest.cc
modified
MockWebContentsAccessibilityAndroid
content/browser/accessibility/browser_accessibility_android_unittest.cc
modified
BrowserAccessibilityAndroidTest
content/browser/accessibility/browser_accessibility_android_unittest.cc
modified
TEST_F
content/browser/accessibility/browser_accessibility_android_unittest.cc
modified
if
content/browser/accessibility/web_contents_accessibility_android.cc
modified

Files Changed

  • content/browser/accessibility/browser_accessibility_android_unittest.cc
  • content/browser/accessibility/web_contents_accessibility_android.cc
From a84db1230994d5ce2875f12d22dec783d691e294 Mon Sep 17 00:00:00 2001
From: Greg Thompson <[email protected]>
Date: Tue, 19 May 2026 04:22:07 -0700
Subject: [PATCH] [a11y] Prevent node ID collisions for tree snapshots on Android

WebContentsAccessibilityAndroid can be used both for normal WebContents
and for working with AXTree snapshots. In the latter case,
WebContentsAccessibilityAndroid itself is the ui::AXNodeIdDelegate
responsible for mapping a blink node identifier to platform node
IDs. Previously, this class directly mapped the blink node ID to a
platform node ID. This could lead to collisions with true platform node
IDs. In this CL, we switch to having WebContentsAccessibilityAndroid
provide unique IDs in the same manner as RenderFrameHostImpl.

AX-Relnotes: n/a.
Fixed: 514022635
Change-Id: I11b53e63034641d9f67b3c75ffff1c49a0b6582b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7852918
Auto-Submit: Greg Thompson <[email protected]>
Commit-Queue: Greg Thompson <[email protected]>
Reviewed-by: Lucas Radaelli <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1632763}
---

diff --git a/content/browser/accessibility/browser_accessibility_android_unittest.cc b/content/browser/accessibility/browser_accessibility_android_unittest.cc
index 7d783d3..a63f4df 100644
--- a/content/browser/accessibility/browser_accessibility_android_unittest.cc
+++ b/content/browser/accessibility/browser_accessibility_android_unittest.cc
@@ -17,6 +17,7 @@
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "ui/accessibility/ax_enums.mojom-shared.h"
+#include "ui/accessibility/platform/ax_unique_id.h"
 #include "ui/accessibility/platform/browser_accessibility_manager.h"
 #include "ui/accessibility/platform/test_ax_node_id_delegate.h"
 #include "ui/accessibility/platform/test_ax_platform_tree_manager_delegate.h"
@@ -29,6 +30,26 @@
     ui::BrowserAccessibilityManager* manager) {
   return static_cast<BrowserAccessibilityManagerAndroid*>(manager);
 }
+
+// A trivial AXNodeIdDelegate that maintains unique AXNodeIDs in a container.
+class UniqueAXNodeIdDelegate : public ui::AXNodeIdDelegate {
+ public:
+  ui::AXPlatformNodeId GetOrCreateAXNodeUniqueId(
+      ui::AXNodeID ax_node_id) override {
+    auto [iter, inserted] =
+        unique_ids_.try_emplace(ax_node_id, ui::AXUniqueId::CreateInvalid());
+    if (inserted) {
+      iter->second = ui::AXUniqueId::Create();
+    }
+    return iter->second;
+  }
+  void OnAXNodeDeleted(ui::AXNodeID ax_node_id) override {
+    unique_ids_.erase(ax_node_id);
+  }
+
+ private:
+  absl::flat_hash_map<ui::AXNodeID, ui::AXUniqueId> unique_ids_;
+};
 }  // namespace
 
 using RetargetEventType = ui::AXTreeManager::RetargetEventType;
@@ -63,6 +84,13 @@
     : public WebContentsAccessibilityAndroid {
  public:
   MockWebContentsAccessibilityAndroid() {}
+  explicit MockWebContentsAccessibilityAndroid(int64_t ax_tree_update_ptr)
+      : WebContentsAccessibilityAndroid(ax_tree_update_ptr) {}
+
+  BrowserAccessibilityAndroid* GetAXFromUniqueIDForTesting(
+      int32_t unique_id) const {
+    return GetAXFromUniqueID(unique_id);
+  }
 };
 
 class BrowserAccessibilityAndroidTest : public ::testing::Test {
@@ -1740,4 +1768,88 @@
 
   EXPECT_FALSE(container_node->IsFocusable());
 }
+
+TEST_F(BrowserAccessibilityAndroidTest, SnapshotIdsDoNotCollideWithLiveTree) {
+  UniqueAXNodeIdDelegate live_node_id_delegate;
+  ui::AXNodeData live_root;
+  live_root.id = 1;
+  live_root.role = ax::mojom::Role::kRootWebArea;
+
+  std::unique_ptr<ui::BrowserAccessibilityManager> live_manager(
+      BrowserAccessibilityManagerAndroid::Create(
+          MakeAXTreeUpdateForTesting(live_root), live_node_id_delegate,
+          test_browser_accessibility_delegate_.get()));
+
+  BrowserAccessibilityAndroid* live_node =
+      static_cast<BrowserAccessibilityAndroid*>(
+          live_manager->GetBrowserAccessibilityRoot());
+  int32_t live_unique_id = live_node->GetUniqueId();
+
+  // Now create snapshot tree using MockWebContentsAccessibilityAndroid as
+  // delegate.
+  ui::AXNodeData snapshot_root;
+  snapshot_root.id = 1;  // Same Blink ID
+  snapshot_root.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update =
+      new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(snapshot_root));
+  MockWebContentsAccessibilityAndroid snapshot_wcaa(
+      reinterpret_cast<intptr_t>(update));
+
+  int32_t snapshot_unique_id = snapshot_wcaa.GetRootId(nullptr);
+
+  // If the fix is active, snapshot_unique_id should be different from
+  // live_unique_id.
+  EXPECT_NE(live_unique_id, snapshot_unique_id);
+
+  // Also test that lookup works for both, and they don't interfere.
+  EXPECT_EQ(live_node,
+            BrowserAccessibilityAndroid::GetFromUniqueId(live_unique_id));
+
+  BrowserAccessibilityAndroid* snapshot_node =
+      BrowserAccessibilityAndroid::GetFromUniqueId(snapshot_unique_id);
+  EXPECT_NE(nullptr, snapshot_node);
+
+  EXPECT_EQ(snapshot_node,
+            snapshot_wcaa.GetAXFromUniqueIDForTesting(snapshot_unique_id));
+  EXPECT_EQ(nullptr, snapshot_wcaa.GetAXFromUniqueIDForTesting(live_unique_id));
+}
+
+TEST_F(BrowserAccessibilityAndroidTest, TwoSnapshotsDoNotCollide) {
+  ui::AXNodeData root1;
+  root1.id = 1;
+  root1.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update1 = new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(root1));
+  MockWebContentsAccessibilityAndroid wcaa1(
+      reinterpret_cast<intptr_t>(update1));
+  int32_t unique_id1 = wcaa1.GetRootId(nullptr);
+
+  ui::AXNodeData root2;
+  root2.id = 1;  // Same Blink ID
+  root2.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update2 = new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(root2));
+  MockWebContentsAccessibilityAndroid wcaa2(
+      reinterpret_cast<intptr_t>(update2));
+  int32_t unique_id2 = wcaa2.GetRootId(nullptr);
+
+  // They should have different unique IDs.
+  EXPECT_NE(unique_id1, unique_id2);
+
+  // Each should only find its own node.
+  BrowserAccessibilityAndroid* node1 =
+      BrowserAccessibilityAndroid::GetFromUniqueId(unique_id1);
+  BrowserAccessibilityAndroid* node2 =
+      BrowserAccessibilityAndroid::GetFromUniqueId(unique_id2);
+  ASSERT_NE(nullptr, node1);
+  ASSERT_NE(nullptr, node2);
+
+  EXPECT_EQ(node1, wcaa1.GetAXFromUniqueIDForTesting(unique_id1));
+  EXPECT_EQ(nullptr, wcaa1.GetAXFromUniqueIDForTesting(unique_id2));
+
+  EXPECT_EQ(node2, wcaa2.GetAXFromUniqueIDForTesting(unique_id2));
+  EXPECT_EQ(nullptr, wcaa2.GetAXFromUniqueIDForTesting(unique_id1));
+}
+
 }  // namespace content
diff --git a/content/browser/accessibility/web_contents_accessibility_android.cc b/content/browser/accessibility/web_contents_accessibility_android.cc
index 41753f1..260fe9a 100644
--- a/content/browser/accessibility/web_contents_accessibility_android.cc
+++ b/content/browser/accessibility/web_contents_accessibility_android.cc
@@ -677,12 +677,16 @@
 
 ui::AXPlatformNodeId WebContentsAccessibilityAndroid::GetOrCreateAXNodeUniqueId(
     ui::AXNodeID ax_node_id) {
-  // Per-tab uniqueness is not necessary in snapshots, so return the blink node
-  // id.
-  return ui::AXPlatformNodeId(MakePassKey(), 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 WebContentsAccessibilityAndroid::OnAXNodeDeleted(ui::AXNodeID ax_node_id) {
+  ax_unique_ids_.erase(ax_node_id);
 }
 
 void WebContentsAccessibilityAndroid::ConnectInstanceToRootManager(
@@ -2824,7 +2828,18 @@
 
 BrowserAccessibilityAndroid* WebContentsAccessibilityAndroid::GetAXFromUniqueID(
     int32_t unique_id) const {
-  return BrowserAccessibilityAndroid::GetFromUniqueId(unique_id);
+  BrowserAccessibilityAndroid* node =
+      BrowserAccessibilityAndroid::GetFromUniqueId(unique_id);
+  if (!node) {
+    return nullptr;
+  }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/accessibility/browser_accessibility_android_unittest.cc b/content/browser/accessibility/browser_accessibility_android_unittest.cc
index 7d783d3..a63f4df 100644
--- a/content/browser/accessibility/browser_accessibility_android_unittest.cc
+++ b/content/browser/accessibility/browser_accessibility_android_unittest.cc
@@ -17,6 +17,7 @@
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "ui/accessibility/ax_enums.mojom-shared.h"
+#include "ui/accessibility/platform/ax_unique_id.h"
 #include "ui/accessibility/platform/browser_accessibility_manager.h"
 #include "ui/accessibility/platform/test_ax_node_id_delegate.h"
 #include "ui/accessibility/platform/test_ax_platform_tree_manager_delegate.h"
@@ -29,6 +30,26 @@
     ui::BrowserAccessibilityManager* manager) {
   return static_cast<BrowserAccessibilityManagerAndroid*>(manager);
 }
+
+// A trivial AXNodeIdDelegate that maintains unique AXNodeIDs in a container.
+class UniqueAXNodeIdDelegate : public ui::AXNodeIdDelegate {
+ public:
+  ui::AXPlatformNodeId GetOrCreateAXNodeUniqueId(
+      ui::AXNodeID ax_node_id) override {
+    auto [iter, inserted] =
+        unique_ids_.try_emplace(ax_node_id, ui::AXUniqueId::CreateInvalid());
+    if (inserted) {
+      iter->second = ui::AXUniqueId::Create();
+    }
+    return iter->second;
+  }
+  void OnAXNodeDeleted(ui::AXNodeID ax_node_id) override {
+    unique_ids_.erase(ax_node_id);
+  }
+
+ private:
+  absl::flat_hash_map<ui::AXNodeID, ui::AXUniqueId> unique_ids_;
+};
 }  // namespace
 
 using RetargetEventType = ui::AXTreeManager::RetargetEventType;
@@ -63,6 +84,13 @@
     : public WebContentsAccessibilityAndroid {
  public:
   MockWebContentsAccessibilityAndroid() {}
+  explicit MockWebContentsAccessibilityAndroid(int64_t ax_tree_update_ptr)
+      : WebContentsAccessibilityAndroid(ax_tree_update_ptr) {}
+
+  BrowserAccessibilityAndroid* GetAXFromUniqueIDForTesting(
+      int32_t unique_id) const {
+    return GetAXFromUniqueID(unique_id);
+  }
 };
 
 class BrowserAccessibilityAndroidTest : public ::testing::Test {
@@ -1740,4 +1768,88 @@
 
   EXPECT_FALSE(container_node->IsFocusable());
 }
+
+TEST_F(BrowserAccessibilityAndroidTest, SnapshotIdsDoNotCollideWithLiveTree) {
+  UniqueAXNodeIdDelegate live_node_id_delegate;
+  ui::AXNodeData live_root;
+  live_root.id = 1;
+  live_root.role = ax::mojom::Role::kRootWebArea;
+
+  std::unique_ptr<ui::BrowserAccessibilityManager> live_manager(
+      BrowserAccessibilityManagerAndroid::Create(
+          MakeAXTreeUpdateForTesting(live_root), live_node_id_delegate,
+          test_browser_accessibility_delegate_.get()));
+
+  BrowserAccessibilityAndroid* live_node =
+      static_cast<BrowserAccessibilityAndroid*>(
+          live_manager->GetBrowserAccessibilityRoot());
+  int32_t live_unique_id = live_node->GetUniqueId();
+
+  // Now create snapshot tree using MockWebContentsAccessibilityAndroid as
+  // delegate.
+  ui::AXNodeData snapshot_root;
+  snapshot_root.id = 1;  // Same Blink ID
+  snapshot_root.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update =
+      new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(snapshot_root));
+  MockWebContentsAccessibilityAndroid snapshot_wcaa(
+      reinterpret_cast<intptr_t>(update));
+
+  int32_t snapshot_unique_id = snapshot_wcaa.GetRootId(nullptr);
+
+  // If the fix is active, snapshot_unique_id should be different from
+  // live_unique_id.
+  EXPECT_NE(live_unique_id, snapshot_unique_id);
+
+  // Also test that lookup works for both, and they don't interfere.
+  EXPECT_EQ(live_node,
+            BrowserAccessibilityAndroid::GetFromUniqueId(live_unique_id));
+
+  BrowserAccessibilityAndroid* snapshot_node =
+      BrowserAccessibilityAndroid::GetFromUniqueId(snapshot_unique_id);
+  EXPECT_NE(nullptr, snapshot_node);
+
+  EXPECT_EQ(snapshot_node,
+            snapshot_wcaa.GetAXFromUniqueIDForTesting(snapshot_unique_id));
+  EXPECT_EQ(nullptr, snapshot_wcaa.GetAXFromUniqueIDForTesting(live_unique_id));
+}
+
+TEST_F(BrowserAccessibilityAndroidTest, TwoSnapshotsDoNotCollide) {
+  ui::AXNodeData root1;
+  root1.id = 1;
+  root1.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update1 = new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(root1));
+  MockWebContentsAccessibilityAndroid wcaa1(
+      reinterpret_cast<intptr_t>(update1));
+  int32_t unique_id1 = wcaa1.GetRootId(nullptr);
+
+  ui::AXNodeData root2;
+  root2.id = 1;  // Same Blink ID
+  root2.role = ax::mojom::Role::kRootWebArea;
+
+  auto* update2 = new ui::AXTreeUpdate(MakeAXTreeUpdateForTesting(root2));
+  MockWebContentsAccessibilityAndroid wcaa2(
+      reinterpret_cast<intptr_t>(update2));
+  int32_t unique_id2 = wcaa2.GetRootId(nullptr);
+
+  // They should have different unique IDs.
+  EXPECT_NE(unique_id1, unique_id2);
+
+  // Each should only find its own node.
+  BrowserAccessibilityAndroid* node1 =
+      BrowserAccessibilityAndroid::GetFromUniqueId(unique_id1);
+  BrowserAccessibilityAndroid* node2 =
+      BrowserAccessibilityAndroid::GetFromUniqueId(unique_id2);
+  ASSERT_NE(nullptr, node1);
+  ASSERT_NE(nullptr, node2);
+
+  EXPECT_EQ(node1, wcaa1.GetAXFromUniqueIDForTesting(unique_id1));
+  EXPECT_EQ(nullptr, wcaa1.GetAXFromUniqueIDForTesting(unique_id2));
+
+  EXPECT_EQ(node2, wcaa2.GetAXFromUniqueIDForTesting(unique_id2));
+  EXPECT_EQ(nullptr, wcaa2.GetAXFromUniqueIDForTesting(unique_id1));
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by [email protected]

Potential accessibility ID collision between snapshots and live trees on Android

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A namespace collision in the process-global accessibility ID map on Android allows snapshot trees (like Paint Preview) to overwrite entries for live tabs. This can lead to potential cross-origin information spoofing and denial of service for accessibility services like TalkBack.

Affected files:

  • content/browser/accessibility/browser_accessibility_android.cc
  • content/browser/accessibility/web_contents_accessibility_android.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Potential Vulnerability: Accessibility ID Namespace Collision on Android

Description

On Android, Chrome maintains a process-wide static map, GetUniqueIdMap(), which maps unique accessibility IDs to BrowserAccessibilityAndroid instances. This map is used by JNI entry points to retrieve C++ accessibility nodes when the Android accessibility framework (e.g., TalkBack) interacts with a web page. This map is located in content/browser/accessibility/browser_accessibility_android.cc.

There are two distinct ways these unique IDs are generated, leading to a potential namespace collision:

  1. Live Trees: For active web pages, IDs are generated using AXUniqueId::Create(), which uses a process-global counter and tracks assigned IDs in a global set (ui/accessibility/platform/ax_unique_id.cc). These IDs typically start at 1 and increment.
  2. Snapshot Trees: For snapshots like Paint Preview or Assist Data, WebContentsAccessibilityAndroid::GetOrCreateAXNodeUniqueId (in content/browser/accessibility/web_contents_accessibility_android.cc) bypasses the global AXUniqueId counter and returns the raw Blink AXNodeID (the ID assigned by the renderer process). These also start at 1.

Because both ID spaces start at 1, they overlap. When a snapshot tree is created (e.g., when a tab is backgrounded and Paint Preview is triggered), its nodes are registered in the global GetUniqueIdMap(). The constructor for BrowserAccessibilityAndroid uses the operator[] on the map, which silently overwrites any existing entry with the same ID. This means a node from a snapshot can replace a node from a live tab in the global map.

Furthermore, the BrowserAccessibilityAndroid destructor erases entries from the map by key without verifying the value. Consequently, when a snapshot is destroyed, it may evict a valid live node that had subsequently re-registered the same ID.

All JNI entry points use GetAXFromUniqueID, which performs an unscoped lookup in this global map without checking if the retrieved node belongs to the caller’s WebContentsAccessibilityAndroid instance.

Potential Impact

  1. Cross-Origin Accessibility Spoofing: When a user interacts with a victim tab while an attacker’s snapshot has overwritten the map entries, JNI calls like PopulateAccessibilityNodeInfo will retrieve the attacker’s snapshot node. This allows an attacker to control the text, labels, and metadata read by TalkBack, enabling potential phishing of screen-reader users.
  2. Action Drop (DoS): Actions dispatched via the accessibility framework (e.g., Click) to the collided IDs will hit the snapshot nodes. Since snapshot managers are initialized without a delegate, these actions are silently dropped, making the victim page potentially unresponsive to accessibility interactions.
  3. Node Eviction (DoS): The destruction of a snapshot can evict colliding live nodes from the global map, making them unreachable by the accessibility framework until the tree is regenerated.

Potential Steps to Reproduce

  1. An attacker opens a page and populates it with many elements to ensure the renderer assigns a range of low-integer AXNodeIDs.
  2. The attacker backgrounds their tab, triggering a Paint Preview snapshot. The snapshot nodes are registered in the global GetUniqueIdMap() using their raw Blink IDs (e.g., 1 to 500).
  3. The user navigates to a victim page in a different tab. The victim’s live nodes are assigned AXUniqueIds that overlap with the snapshot’s IDs.
  4. The snapshot nodes in the map overwrite the victim’s live node entries for those IDs.
  5. When the user interacts with the victim tab using TalkBack, the accessibility service queries nodes by ID. The global lookup returns the attacker’s snapshot nodes, causing TalkBack to read attacker-controlled content.

Suggested Fix

Modify WebContentsAccessibilityAndroid::GetOrCreateAXNodeUniqueId in content/browser/accessibility/web_contents_accessibility_android.cc to use ui::AXUniqueId::Create() (or a similar mechanism that respects the global counter) for snapshot nodes, ensuring they do not collide with live node IDs. Additionally, GetAXFromUniqueID should verify that the retrieved node belongs to the current WebContentsAccessibilityAndroid instance.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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.

View on issue tracker