Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Accessibility
DescriptionUse after free in Accessibility
ComponentAccessibility
Bug ClassUAF
Tracker498205735
Fix commit65fba3dbd962 (chromium/src) +34/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
for
ui/accessibility/mojom/ax_node_data_mojom_traits.cc
modified
COMPONENT_EXPORT
ui/accessibility/platform/browser_accessibility.h
modified

Files Changed

  • ui/accessibility/ax_node_id_forward.h
  • ui/accessibility/mojom/ax_node_data_mojom_traits.cc
  • ui/accessibility/mojom/ax_tree_update_mojom_traits.cc
  • ui/accessibility/platform/browser_accessibility.h
  • ui/accessibility/platform/browser_accessibility_manager.cc
From 65fba3dbd962b7c36cc657c48ce7705e76ba38c9 Mon Sep 17 00:00:00 2001
From: Stephen Nusko <[email protected]>
Date: Thu, 23 Apr 2026 21:26:52 -0700
Subject: [PATCH] Validate AXNodeID from the renderer.

Currently the renderer can send any value as an AXNodeID, but some are
reserved as invalid or browser specific. This prevents the renderer from
setting them thus causing a collision.

This is phase 1 fix (internal-only): go/project-fortify-review-explainer

Fuchsia-Binary-Size: Size increase is just includes for security fix
Bug: 498205735
Change-Id: I1b62bc2fe2eaa0e96cc44e11c48790c15c14cb88
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7727677
Reviewed-by: Hidehiko Abe <[email protected]>
Commit-Queue: Stephen Nusko <[email protected]>
Reviewed-by: David Tseng <[email protected]>
Auto-Submit: Stephen Nusko <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1619955}
---

diff --git a/ui/accessibility/ax_node_id_forward.h b/ui/accessibility/ax_node_id_forward.h
index f5a3e85..6fcd1b5 100644
--- a/ui/accessibility/ax_node_id_forward.h
+++ b/ui/accessibility/ax_node_id_forward.h
@@ -35,6 +35,12 @@
 static constexpr AXNodeID kFirstGeneratedRendererNodeID = -1000000001;
 static constexpr AXNodeID kLastGeneratedRendererNodeID = INT_MIN;
 
+// Validation for AXNodeID from a renderer.
+// Browser reserves [-1,000,000,000, -1] for internal nodes.
+constexpr bool IsValidAXNodeIDFromRenderer(int32_t id) {
+  return id >= kInvalidAXNodeID || id <= ui::kFirstGeneratedRendererNodeID;
+}
+
 }  // namespace ui
 
 #endif  // UI_ACCESSIBILITY_AX_NODE_ID_FORWARD_H_
diff --git a/ui/accessibility/mojom/ax_node_data_mojom_traits.cc b/ui/accessibility/mojom/ax_node_data_mojom_traits.cc
index 7414fa88..61b76450 100644
--- a/ui/accessibility/mojom/ax_node_data_mojom_traits.cc
+++ b/ui/accessibility/mojom/ax_node_data_mojom_traits.cc
@@ -5,6 +5,7 @@
 #include "ui/accessibility/mojom/ax_node_data_mojom_traits.h"
 
 #include "base/containers/flat_map.h"
+#include "ui/accessibility/ax_node_id_forward.h"
 #include "ui/accessibility/mojom/ax_relative_bounds.mojom-shared.h"
 #include "ui/accessibility/mojom/ax_relative_bounds_mojom_traits.h"
 
@@ -34,6 +35,9 @@
 bool StructTraits<ax::mojom::AXNodeDataDataView, ui::AXNodeData>::Read(
     ax::mojom::AXNodeDataDataView data,
     ui::AXNodeData* out) {
+  if (!ui::IsValidAXNodeIDFromRenderer(data.id())) {
+    return false;
+  }
   out->id = data.id();
   out->role = data.role();
   out->state = ui::AXStates(data.state());
@@ -108,8 +112,16 @@
     return false;
   out->html_attributes = std::move(html_attributes).extract();
 
-  if (!data.ReadChildIds(&out->child_ids))
+  std::vector<int32_t> child_ids;
+  if (!data.ReadChildIds(&child_ids)) {
     return false;
+  }
+  for (int32_t child_id : child_ids) {
+    if (!ui::IsValidAXNodeIDFromRenderer(child_id)) {
+      return false;
+    }
+  }
+  out->child_ids = std::move(child_ids);
 
   if (!data.ReadRelativeBounds(&out->relative_bounds))
     return false;
diff --git a/ui/accessibility/mojom/ax_tree_update_mojom_traits.cc b/ui/accessibility/mojom/ax_tree_update_mojom_traits.cc
index d8bfe823..59672c8 100644
--- a/ui/accessibility/mojom/ax_tree_update_mojom_traits.cc
+++ b/ui/accessibility/mojom/ax_tree_update_mojom_traits.cc
@@ -4,6 +4,8 @@
 
 #include "ui/accessibility/mojom/ax_tree_update_mojom_traits.h"
 
+#include "ui/accessibility/ax_node_id_forward.h"
+
 namespace mojo {
 
 // static
@@ -13,7 +15,13 @@
   out->has_tree_data = data.has_tree_data();
   if (!data.ReadTreeData(&out->tree_data))
     return false;
+  if (!ui::IsValidAXNodeIDFromRenderer(data.node_id_to_clear())) {
+    return false;
+  }
   out->node_id_to_clear = data.node_id_to_clear();
+  if (!ui::IsValidAXNodeIDFromRenderer(data.root_id())) {
+    return false;
+  }
   out->root_id = data.root_id();
   if (!data.ReadNodes(&out->nodes))
     return false;
diff --git a/ui/accessibility/platform/browser_accessibility.h b/ui/accessibility/platform/browser_accessibility.h
index 6d674e2..7aba93b3 100644
--- a/ui/accessibility/platform/browser_accessibility.h
+++ b/ui/accessibility/platform/browser_accessibility.h
@@ -15,6 +15,7 @@
 #include <vector>
 
 #include "base/component_export.h"
+#include "base/memory/advanced_memory_safety_checks.h"
 #include "base/memory/raw_ptr.h"
 #include "build/build_config.h"
 #include "ui/accessibility/ax_enums.mojom-forward.h"
@@ -42,6 +43,9 @@
 // Web.
 class COMPONENT_EXPORT(AX_PLATFORM) BrowserAccessibility
     : public AXPlatformNodeDelegate {
+  // TODO(b/498205735): Remove once hardening protections are no longer needed.
+  ADVANCED_MEMORY_SAFETY_CHECKS();
+
  public:
   // Creates a platform specific BrowserAccessibility. Ownership passes to the
   // caller.
diff --git a/ui/accessibility/platform/browser_accessibility_manager.cc b/ui/accessibility/platform/browser_accessibility_manager.cc
index d7e4cb1..f5bcdc43 100644
--- a/ui/accessibility/platform/browser_accessibility_manager.cc
+++ b/ui/accessibility/platform/browser_accessibility_manager.cc
@@ -268,7 +268,9 @@
     return;
   }
 
-  auto* wrapper = GetFromAXNode(node);
+  // Mitigation for b/498205735. Using raw_ptr on the stack as defense-in-depth
+  // to ensure memory is quarantined if freed during the loop below.
+  raw_ptr<BrowserAccessibility> wrapper = GetFromAXNode(node);
   DCHECK(wrapper);
 
   const auto& node_data = wrapper->GetData();
Loading diff…

Original Bug Report

reported by [email protected]

Browser Process UAF via AXNodeID collision in BrowserAccessibilityManager

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A compromised renderer can send an accessibility tree update with negative node IDs, which are meant to be reserved for internal browser nodes. This causes an ID collision that triggers a Use-After-Free in the Browser process during ARIA notification processing, leading to a potential Sandbox Escape.

Affected files:

  • ui/accessibility/platform/browser_accessibility_manager.cc
  • ui/accessibility/mojom/ax_node_data_mojom_traits.cc
  • ui/accessibility/ax_tree.cc
  • ui/accessibility/platform/browser_accessibility_manager_auralinux.cc
  • ui/accessibility/platform/browser_accessibility.cc

Estimated timestamp from git blame: 2025-10-22

Root Cause

In Chromium’s accessibility system, negative node IDs are reserved for internal nodes generated by the Browser process (e.g., extra announcement nodes for ARIA notifications). However, when the Browser process deserializes an AXTreeUpdate from the renderer, it blindly accepts the provided node IDs without validation. Specifically, in ui/accessibility/mojom/ax_node_data_mojom_traits.cc, StructTraits::Read executes out->id = data.id(); without enforcing that the ID is strictly positive.

Vulnerability Mechanism and Proposed Attack Steps

An attacker with a compromised renderer can exploit this missing validation to achieve Sandbox Escape Remote Code Execution (RCE) in the Browser process. The suggested steps to trigger the vulnerability are:

  1. Craft Malicious Node: The attacker sends an AXTreeUpdate via the RenderAccessibilityHost::HandleAXEvents Mojo IPC. This update creates a node explicitly assigned an ID of -1.
  2. Attach Announcements: The malicious node is given a kAriaNotificationAnnouncements attribute containing an array of two strings. The first string is sized to exactly match the memory bucket of a BrowserAccessibility object (to serve as a heap grooming payload), while the second string triggers the subsequent logic.
  3. Store the Wrapper: The Browser process creates the node and stores its BrowserAccessibility wrapper in id_wrapper_map_[-1].
  4. Trigger Event Loop: The announcements trigger an ARIA_NOTIFICATIONS_POSTED event. BrowserAccessibilityManager::FireGeneratedEvent handles this event and fetches a raw pointer to the attacker’s node wrapper: auto* wrapper = GetFromAXNode(node);.
  5. First Iteration & Node Collision: FireGeneratedEvent iterates over the announcements. On the first iteration (i = 0), it calls FireAriaNotificationEvent(wrapper, announcements[0], ...). On AuraLinux (with ATK < 2.50.0), this attempts to fetch an “extra announcement node”. Because they don’t exist yet for this tree, AXTree::CreateExtraAnnouncementNodes() is invoked.
  6. UAF Trigger: CreateExtraAnnouncementNodes generates an internal node using GetNextNegativeInternalNodeId(), which assigns it the ID -1. The browser then calls OnNodeCreated for this internal node, executing id_wrapper_map_[-1] = CreateBrowserAccessibility(node);. This overwrites the dictionary entry, destroying the attacker’s BrowserAccessibility object.
  7. Bypass MiraclePtr: Because wrapper is a stack-allocated raw pointer (auto*), no MiraclePtr (BRP) reference count is incremented. The object’s memory is immediately freed and made available for reallocation.
  8. Heap Grooming: During the remainder of the first iteration, synchronous ATK notification processing allocates the attacker’s first announcement string. This payload reclaims the newly freed BrowserAccessibility object in memory, replacing its vtable and manager_ pointer.
  9. RCE Execution: On the second loop iteration (i = 1), FireAriaNotificationEvent(wrapper, announcements[1], ...) is called. The dangling wrapper pointer is dereferenced, leading to a hijacked virtual method call (ShouldExposeExtraAnnouncementNodes) on the attacker-controlled manager_ pointer.

Proposed Fix

Enforce that all AXNodeIDs originating from the renderer are strictly positive. Add validation to reject data.id() <= 0 in StructTraits<ax::mojom::AXNodeDataDataView, ui::AXNodeData>::Read. Similar validation should also be considered for root_id and node_id_to_clear in AXTreeUpdate deserialization.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from 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