CVE-2026-113317
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
SkBitmapchrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h |
modified | |
TestAXMediaAppUntrustedServicechrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h |
modified | |
RenderFrameHostchrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h |
modified | |
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified | |
forui/accessibility/ax_location_and_scroll_updates.cc |
modified | |
forui/accessibility/ax_node_data.cc |
modified | |
forui/accessibility/ax_tree_update.cc |
modified | |
forui/accessibility/ax_updates_and_events.cc |
modified |
Files Changed
ui/accessibility/ax_tree_update.ccchrome/browser/accessibility/media_app/ax_media_app_untrusted_service.ccui/accessibility/ax_updates_and_events.hcontent/browser/accessibility/render_accessibility_host_unittest.ccui/accessibility/ax_node_data_unittest.cccontent/test/BUILD.gnui/accessibility/ax_updates_and_events.ccui/accessibility/ax_location_and_scroll_updates.ccui/accessibility/ax_tree_update_util_unittest.cccontent/browser/accessibility/render_accessibility_host.ccui/accessibility/ax_node_data.hui/accessibility/ax_location_and_scroll_updates.hcontent/browser/renderer_host/render_frame_host_impl.ccui/accessibility/ax_node_data.ccchrome/browser/accessibility/media_app/ax_media_app_untrusted_service.hchrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.ccchrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.hui/accessibility/ax_tree_update.h
Patch
From b35403786d6758af074e01c0e36ab15b2403b588 Mon Sep 17 00:00:00 2001 From: Aeryn Moss <[email protected]> Date: Fri, 25 Sep 2026 10:46:21 -0700 Subject: [PATCH] [a11y] Validate renderer-provided AXNodeIDs at browser entry points Previously, crrev.com/c/7727677 added AXNodeID validation in shared StructTraits<AXNodeData>::Read and StructTraits<AXTreeUpdate>::Read. However, because AXTreeUpdate and AXNodeData are used bidirectionally (such as Browser -> ChromeOS Extension Renderer in automation and reading mode), those StructTraits checks caused valid browser-generated negative internal node IDs to be rejected when sent to ChromeOS extensions, and were reverted in crrev.com/c/8250159 and crrev.com/c/8288690. This CL reintroduces browser-side validation of renderer-provided AXNodeIDs specifically at untrusted browser-side entry points, avoiding the bidirectional StructTraits deserialization path: 1. RenderAccessibilityHost::HandleAXEvents and HandleAXLocationChanges (covers all web renderers and PDF renderers via blink.mojom.RenderAccessibilityHost), with defense-in-depth checks also in RenderFrameHostImpl. 2. AXMediaAppUntrustedService::OnPageOcred (ScreenAI OCR utility output). ReadAnythingUntrustedPageHandler was already validated in crrev.com/c/7795304. To support this cleanly, validation helper functions are added to ui/accessibility: - IsValidAXNodeDataFromRenderer - IsValidAXTreeUpdateFromRenderer - IsValidAXLocationAndScrollUpdatesFromRenderer - IsValidAXUpdatesAndEventsFromRenderer Unit and browser tests are added to verify that invalid IDs trigger a Mojo bad message or are rejected. Bug: 498205735, 555337558 Change-Id: Icb84364ebf251293a8519d8988e73b929c3f35ad Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8433909 Commit-Queue: Aeryn Moss <[email protected]> Reviewed-by: Jonathan Ross <[email protected]> Reviewed-by: David Tseng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1704923} --- diff --git a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.cc b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.cc index ce9fed0..69c5cb0 100644 --- a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.cc +++ b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.cc @@ -1312,7 +1312,13 @@ // TODO(b/319536234): Validate tree ID. // !tree_update.has_tree_data || // ui::AXTreeIDUnknown() == tree_update.tree_data.tree_id || - ui::kInvalidAXNodeID == tree_update.root_id)) { + ui::kInvalidAXNodeID == tree_update.root_id) && + mojo::IsInMessageDispatch()) { + mojo::ReportBadMessage("OnPageOcred() bad tree update from Screen AI."); + return; + } + if (!tree_update.HasValidAXNodeIDsFromRenderer() && + mojo::IsInMessageDispatch()) { mojo::ReportBadMessage("OnPageOcred() bad tree update from Screen AI."); return; } diff --git a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h index 97ef4a6..ddf6e90 100644 --- a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h +++ b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service.h @@ -47,6 +47,10 @@ class SkBitmap; +namespace ash::test { +class TestAXMediaAppUntrustedService; +} // namespace ash::test + namespace content { class RenderFrameHost; @@ -137,6 +141,9 @@ std::optional<std::string> PopDirtyPage(); virtual void OcrNextDirtyPageIfAny(); + private: + friend class ash::test::TestAXMediaAppUntrustedService; + size_t min_pages_per_batch_ = 2u; size_t pages_ocred_on_initial_load_ = 0u; // `AXMediaApp` should outlive this handler. diff --git a/content/browser/accessibility/render_accessibility_host.cc b/content/browser/accessibility/render_accessibility_host.cc index 8b88ab15..84305ec 100644 --- a/content/browser/accessibility/render_accessibility_host.cc +++ b/content/browser/accessibility/render_accessibility_host.cc @@ -13,6 +13,8 @@ #include "content/browser/renderer_host/render_frame_host_impl.h" #include "content/public/browser/browser_thread.h" #include "mojo/public/cpp/bindings/message.h" +#include "ui/accessibility/ax_location_and_scroll_updates.h" +#include "ui/accessibility/ax_updates_and_events.h" namespace content { namespace { @@ -70,6 +72,12 @@ } return; } + if (!updates_and_events.HasValidAXNodeIDsFromRenderer() || + !location_and_scroll_updates.HasValidAXNodeIDsFromRenderer()) { + mojo::ReportBadMessage("Invalid AXNodeID from renderer."); + std::move(callback).Run(); + return; + } // Post the HandleAXEvents task onto the UI thread, and then when that // finishes, post back the response callback onto this runner (to satisfy // the mojo contract). @@ -97,6 +105,10 @@ std::memory_order_relaxed)) { return; } + if (!changes.HasValidAXNodeIDsFromRenderer()) { + mojo::ReportBadMessage("Invalid AXNodeID from renderer."); + return; + } GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce(&RenderFrameHostImpl::HandleAXLocationChanges, diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index b20c841..4c108e6 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -11597,6 +11597,15 @@ return; } + if (!updates_and_events.HasValidAXNodeIDsFromRenderer() || + !location_and_scroll_updates.HasValidAXNodeIDsFromRenderer()) { + if (report_bad_message_callback) { + std::move(report_bad_message_callback) + .Run("Invalid AXNodeID from renderer."); + } + return; + } + // Don't process this IPC if either we're waiting on a reset and this IPC // doesn't have the matching token ID. // The token prevents obsolete data from being processed. @@ -11733,6 +11742,14 @@ return; } + if (!changes.HasValidAXNodeIDsFromRenderer()) { + if (report_bad_message_callback) { + std::move(report_bad_message_callback) + .Run("Invalid AXNodeID from renderer."); + } + return; + } + if (*accessibility_reset_token_ != reset_token && reset_token != kAccessibilityResetTokenForTesting) { DVLOG(1) << "Ignoring obsolete accessibility data."; diff --git a/ui/accessibility/ax_location_and_scroll_updates.cc b/ui/accessibility/ax_location_and_scroll_updates.cc index 207ec02..9a23d4b 100644 --- a/ui/accessibility/ax_location_and_scroll_updates.cc +++ b/ui/accessibility/ax_location_and_scroll_updates.cc @@ -4,6 +4,7 @@ #include "ui/accessibility/ax_location_and_scroll_updates.h" +#include "ui/accessibility/ax_node_id_forward.h" #include "ui/accessibility/ax_relative_bounds.h" namespace ui { @@ -28,4 +29,18 @@ AXLocationAndScrollUpdates&& other) = default; AXLocationAndScrollUpdates::~AXLocationAndScrollUpdates() = default; +bool AXLocationAndScrollUpdates::HasValidAXNodeIDsFromRenderer() const { + for (const AXLocationChange& change : location_changes) { + if (!IsValidAXNodeIDFromRenderer(change.id)) { + return false; + } + } + for (const AXScrollChange& change : scroll_changes) { + if (!IsValidAXNodeIDFromRenderer(change.id)) { + return false; + } + } + return true; +} + } // namespace ui diff --git a/ui/accessibility/ax_location_and_scroll_updates.h b/ui/accessibility/ax_location_and_scroll_updates.h index 0603f8b..ddbe135 100644 --- a/ui/accessibility/ax_location_and_scroll_updates.h +++ b/ui/accessibility/ax_location_and_scroll_updates.h @@ -58,6 +58,9 @@ ~AXLocationAndScrollUpdates(); + // Returns true if all node IDs are valid from an untrusted renderer. + bool HasValidAXNodeIDsFromRenderer() const; + std::vector<AXLocationChange> location_changes; std::vector<AXScrollChange> scroll_changes; }; diff --git a/ui/accessibility/ax_node_data.cc b/ui/accessibility/ax_node_data.cc index a890148..7785ac1 100644 --- a/ui/accessibility/ax_node_data.cc +++ b/ui/accessibility/ax_node_data.cc @@ -897,6 +897,18 @@ return ui::SupportsExpandCollapse(role); } +bool AXNodeData::HasValidAXNodeIDsFromRenderer() const { + if (!IsValidAXNodeIDFromRenderer(id)) { + return false; + } + for (AXNodeID child_id : child_ids) { + if (!IsValidAXNodeIDFromRenderer(child_id)) { + return false; + } + } + return true; +} + // TODO(accessibility) Consider reusing code from AXTreeFormatterBlink, where // the |verbose| parameter alters the property filter. Would remove ~800 lines. std::string AXNodeData::ToString(bool verbose) const { diff --git a/ui/accessibility/ax_node_data.h b/ui/accessibility/ax_node_data.h index ddbf52f..c5b954f 100644 --- a/ui/accessibility/ax_node_data.h +++ b/ui/accessibility/ax_node_data.h @@ -402,6 +402,9 @@ bool HasChildTreeID() const; std::optional<AXTreeID> GetChildTreeID() const; + // Returns true if all node IDs are valid from an untrusted renderer. + bool HasValidAXNodeIDsFromRenderer() const; + // Return a string representation of this data, for debugging. std::string ToString(bool verbose = true) const; diff --git a/ui/accessibility/ax_tree_update.cc b/ui/accessibility/ax_tree_update.cc index 110fbf6..9e89f0a 100644 --- a/ui/accessibility/ax_tree_update.cc +++ b/ui/accessibility/ax_tree_update.cc @@ -10,6 +10,7 @@ #include "base/strings/strcat.h" #include "base/strings/string_number_conversions.h" #include "ui/accessibility/ax_enum_util.h" +#include "ui/accessibility/ax_node_id_forward.h" #include "ui/accessibility/ax_tree_checks.h" #include "ui/accessibility/ax_tree_data.h" @@ -97,4 +98,19 @@ } } +bool AXTreeUpdate::HasValidAXNodeIDsFromRenderer() const { + if (!IsValidAXNodeIDFromRenderer(node_id_to_clear)) { + return false; + } + if (!IsValidAXNodeIDFromRenderer(root_id)) { + return false; + } + for (const AXNodeData& node : nodes) { + if (!node.HasValidAXNodeIDsFromRenderer()) { + return false; + } + } + return true; +} + } // namespace ui diff --git a/ui/accessibility/ax_tree_update.h b/ui/accessibility/ax_tree_update.h index 9d99163be..de228ea 100644 --- a/ui/accessibility/ax_tree_update.h +++ b/ui/accessibility/ax_tree_update.h @@ -62,6 +62,9 @@ void AccumulateSize(AXNodeData::AXNodeDataSize& node_data_size) const; + // Returns true if all node IDs are valid from an untrusted renderer. + bool HasValidAXNodeIDsFromRenderer() const; + // If |has_tree_data| is true, the value of |tree_data| should be used // to update the tree data, otherwise it should be ignored. bool has_tree_data = false; diff --git a/ui/accessibility/ax_updates_and_events.cc b/ui/accessibility/ax_updates_and_events.cc index 95c876b..2cd64f2 100644 --- a/ui/accessibility/ax_updates_and_events.cc +++ b/ui/accessibility/ax_updates_and_events.cc @@ -5,6 +5,7 @@ #include "ui/accessibility/ax_updates_and_events.h" #include "ui/accessibility/ax_event.h" +#include "ui/accessibility/ax_node_id_forward.h" namespace ui { @@ -16,4 +17,18 @@ AXUpdatesAndEvents::~AXUpdatesAndEvents() = default; +bool AXUpdatesAndEvents::HasValidAXNodeIDsFromRenderer() const { + for (const AXTreeUpdate& update : updates) { + if (!update.HasValidAXNodeIDsFromRenderer()) { + return false; + } + } + for (const AXEvent& event : events) { + if (!IsValidAXNodeIDFromRenderer(event.id)) { + return false; + } + } + return true; +} + } // namespace ui diff --git a/ui/accessibility/ax_updates_and_events.h b/ui/accessibility/ax_updates_and_events.h index fe6c02887..4afdbe2 100644 --- a/ui/accessibility/ax_updates_and_events.h +++ b/ui/accessibility/ax_updates_and_events.h @@ -29,6 +29,9 @@ ~AXUpdatesAndEvents(); + // Returns true if all node IDs are valid from an untrusted renderer. + bool HasValidAXNodeIDsFromRenderer() const; + // The unique ID of the accessibility tree this event bundle applies to. AXTreeID ax_tree_id; diff --git a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc index 847174f..9f65f2f 100644 --- a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc +++ b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc @@ -479,6 +479,23 @@ } IN_PROC_BROWSER_TEST_F(AXMediaAppUntrustedServiceTest, + OnPageOcredWithInvalidNodeId) { + mojo::FakeMessageDispatchContext fake_dispatch_context; + mojo::test::BadMessageObserver bad_message_observer; + + ui::AXTreeUpdate tree_update; + tree_update.root_id = ui::kFirstGeneratedBrowserNodeID; + ui::AXNodeData node; + node.id = ui::kFirstGeneratedBrowserNodeID; + tree_update.nodes.push_back(node); + + service_->OnPageOcredForTesting("page1", tree_update); + + EXPECT_EQ("OnPageOcred() bad tree update from Screen AI.", + bad_message_observer.WaitForBadMessage()); +} + +IN_PROC_BROWSER_TEST_F(AXMediaAppUntrustedServiceTest, PageMetadataUpdatedWithDeleteAndUndoDelete) { service_->DisableStatusNodesForTesting(); service_->DisablePostamblePageForTesting(); diff --git a/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h b/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h index b74b86ee..f03a5f7 100644 --- a/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h +++ b/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h @@ -100,6 +100,11 @@ void PushDirtyPageForTesting(const std::string& dirty_page_id); std::string PopDirtyPageForTesting(); + void OnPageOcredForTesting(const std::string& dirty_page_id, + const ui::AXTreeUpdate& tree_update) { + OnPageOcred(dirty_page_id, tree_update); + } + protected: void OcrNextDirtyPageIfAny() override; diff --git a/content/browser/accessibility/render_accessibility_host_unittest.cc b/content/browser/accessibility/render_accessibility_host_unittest.cc new file mode 100644 index 0000000..91dbbc15 --- /dev/null +++ b/content/browser/accessibility/render_accessibility_host_unittest.cc @@ -0,0 +1,137 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/accessibility/render_accessibility_host.h" + +#include <memory> + +#include "base/functional/callback_helpers.h" +#include "base/run_loop.h" +#include "content/public/test/browser_task_environment.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "mojo/public/cpp/test_support/test_utils.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/public/mojom/render_accessibility.mojom.h" +#include "ui/accessibility/ax_location_and_scroll_updates.h" +#include "ui/accessibility/ax_node_id_forward.h" +#include "ui/accessibility/ax_updates_and_events.h" + ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc
index 847174f..9f65f2f 100644
--- a/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc
+++ b/chrome/browser/accessibility/media_app/ax_media_app_untrusted_service_browsertest.cc
@@ -479,6 +479,23 @@
}
IN_PROC_BROWSER_TEST_F(AXMediaAppUntrustedServiceTest,
+ OnPageOcredWithInvalidNodeId) {
+ mojo::FakeMessageDispatchContext fake_dispatch_context;
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ ui::AXTreeUpdate tree_update;
+ tree_update.root_id = ui::kFirstGeneratedBrowserNodeID;
+ ui::AXNodeData node;
+ node.id = ui::kFirstGeneratedBrowserNodeID;
+ tree_update.nodes.push_back(node);
+
+ service_->OnPageOcredForTesting("page1", tree_update);
+
+ EXPECT_EQ("OnPageOcred() bad tree update from Screen AI.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+IN_PROC_BROWSER_TEST_F(AXMediaAppUntrustedServiceTest,
PageMetadataUpdatedWithDeleteAndUndoDelete) {
service_->DisableStatusNodesForTesting();
service_->DisablePostamblePageForTesting();
diff --git a/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h b/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h
index b74b86ee..f03a5f7 100644
--- a/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h
+++ b/chrome/browser/accessibility/media_app/test/test_ax_media_app_untrusted_service.h
@@ -100,6 +100,11 @@
void PushDirtyPageForTesting(const std::string& dirty_page_id);
std::string PopDirtyPageForTesting();
+ void OnPageOcredForTesting(const std::string& dirty_page_id,
+ const ui::AXTreeUpdate& tree_update) {
+ OnPageOcred(dirty_page_id, tree_update);
+ }
+
protected:
void OcrNextDirtyPageIfAny() override;
diff --git a/content/browser/accessibility/render_accessibility_host_unittest.cc b/content/browser/accessibility/render_accessibility_host_unittest.cc
new file mode 100644
index 0000000..91dbbc15
--- /dev/null
+++ b/content/browser/accessibility/render_accessibility_host_unittest.cc
@@ -0,0 +1,137 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/accessibility/render_accessibility_host.h"
+
+#include <memory>
+
+#include "base/functional/callback_helpers.h"
+#include "base/run_loop.h"
+#include "content/public/test/browser_task_environment.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/render_accessibility.mojom.h"
+#include "ui/accessibility/ax_location_and_scroll_updates.h"
+#include "ui/accessibility/ax_node_id_forward.h"
+#include "ui/accessibility/ax_updates_and_events.h"
+
+namespace content {
+
+class RenderAccessibilityHostTest : public testing::Test {
+ public:
+ RenderAccessibilityHostTest() = default;
+
+ protected:
+ BrowserTaskEnvironment task_environment_;
+};
+
+TEST_F(RenderAccessibilityHostTest, HandleAXEvents_ValidIds) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXUpdatesAndEvents updates_and_events;
+ ui::AXTreeUpdate update;
+ update.root_id = 1;
+ updates_and_events.updates.push_back(update);
+ ui::AXLocationAndScrollUpdates location_and_scroll_updates;
+
+ base::RunLoop run_loop;
+ remote->HandleAXEvents(updates_and_events, location_and_scroll_updates, 0,
+ run_loop.QuitClosure());
+ run_loop.Run();
+
+ EXPECT_FALSE(bad_message_observer.got_bad_message());
+}
+
+TEST_F(RenderAccessibilityHostTest, HandleAXEvents_InvalidEventId) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXUpdatesAndEvents updates_and_events;
+ ui::AXEvent event;
+ event.id = ui::kFirstGeneratedBrowserNodeID;
+ updates_and_events.events.push_back(event);
+ ui::AXLocationAndScrollUpdates location_and_scroll_updates;
+
+ remote->HandleAXEvents(updates_and_events, location_and_scroll_updates, 0,
+ base::DoNothing());
+ EXPECT_EQ("Invalid AXNodeID from renderer.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(RenderAccessibilityHostTest, HandleAXEvents_InvalidUpdateId) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXUpdatesAndEvents updates_and_events;
+ ui::AXTreeUpdate update;
+ update.root_id = ui::kFirstGeneratedBrowserNodeID;
+ updates_and_events.updates.push_back(update);
+ ui::AXLocationAndScrollUpdates location_and_scroll_updates;
+
+ remote->HandleAXEvents(updates_and_events, location_and_scroll_updates, 0,
+ base::DoNothing());
+ EXPECT_EQ("Invalid AXNodeID from renderer.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(RenderAccessibilityHostTest, HandleAXEvents_InvalidLocationChangeId) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXUpdatesAndEvents updates_and_events;
+ ui::AXLocationAndScrollUpdates location_and_scroll_updates;
+ ui::AXLocationChange loc;
+ loc.id = ui::kFirstGeneratedBrowserNodeID;
+ location_and_scroll_updates.location_changes.push_back(loc);
+
+ remote->HandleAXEvents(updates_and_events, location_and_scroll_updates, 0,
+ base::DoNothing());
+ EXPECT_EQ("Invalid AXNodeID from renderer.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(RenderAccessibilityHostTest, HandleAXLocationChanges_ValidId) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXLocationAndScrollUpdates changes;
+ ui::AXLocationChange loc;
+ loc.id = 1;
+ changes.location_changes.push_back(loc);
+
+ remote->HandleAXLocationChanges(changes, 0);
+ remote.FlushForTesting();
+
+ EXPECT_FALSE(bad_message_observer.got_bad_message());
+}
+
+TEST_F(RenderAccessibilityHostTest, HandleAXLocationChanges_InvalidId) {
+ mojo::Remote<blink::mojom::RenderAccessibilityHost> remote;
+ RenderAccessibilityHost host(nullptr, ui::AXTreeID::CreateNewAXTreeID());
+ host.Bind(remote.BindNewPipeAndPassReceiver());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ ui::AXLocationAndScrollUpdates changes;
+ ui::AXLocationChange loc;
+ loc.id = ui::kFirstGeneratedBrowserNodeID;
+ changes.location_changes.push_back(loc);
+
+ remote->HandleAXLocationChanges(changes, 0);
+ EXPECT_EQ("Invalid AXNodeID from renderer.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index ec89b629..f7db997 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -2557,6 +2557,7 @@
"../browser/accessibility/browser_accessibility_state_impl_unittest.cc",
"../browser/accessibility/browser_accessibility_unittest.cc",
"../browser/accessibility/one_shot_accessibility_tree_search_unittest.cc",
+ "../browser/accessibility/render_accessibility_host_unittest.cc",
"../browser/accessibility/scoped_mode_collection_unittest.cc",
"../browser/agent_cluster_key_unittest.cc",
"../browser/back_forward_cache/back_forward_cache_impl_unittest.cc",
diff --git a/ui/accessibility/ax_node_data_unittest.cc b/ui/accessibility/ax_node_data_unittest.cc
index fb3b9cf..b03c5a7 100644
--- a/ui/accessibility/ax_node_data_unittest.cc
+++ b/ui/accessibility/ax_node_data_unittest.cc
@@ -10,7 +10,9 @@
#include <utility>
#include "base/test/gtest_util.h"
+#include "base/test/scoped_feature_list.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/accessibility/accessibility_features.h"
#include "ui/accessibility/ax_enum_util.h"
#include "ui/accessibility/ax_enums.mojom.h"
#include "ui/accessibility/ax_role_properties.h"
@@ -474,4 +476,42 @@
sizeof(AXNodeData::actions) * 8);
}
+TEST(AXNodeDataTest, IsValidAXNodeDataFromRenderer) {
+ // Test IsValidAXNodeIDFromRenderer directly.
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(1));
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(42));
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(kInvalidAXNodeID));
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(kFirstGeneratedRendererNodeID));
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(kFirstGeneratedRendererNodeID - 1));
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(kLastGeneratedRendererNodeID));
+
+ EXPECT_FALSE(IsValidAXNodeIDFromRenderer(kFirstGeneratedBrowserNodeID));
+ EXPECT_FALSE(IsValidAXNodeIDFromRenderer(-100));
+ EXPECT_FALSE(IsValidAXNodeIDFromRenderer(kLastGeneratedBrowserNodeID));
+ EXPECT_FALSE(IsValidAXNodeIDFromRenderer(kInitialEmptyDocumentRootNodeID));
+
+ // Test AXNodeData.HasValidAXNodeIDsFromRenderer.
+ AXNodeData valid_node;
+ valid_node.id = 1;
+ valid_node.child_ids = {2, 3, kFirstGeneratedRendererNodeID};
+ EXPECT_TRUE(valid_node.HasValidAXNodeIDsFromRenderer());
+
+ AXNodeData invalid_node_id;
+ invalid_node_id.id = kFirstGeneratedBrowserNodeID;
+ EXPECT_FALSE(invalid_node_id.HasValidAXNodeIDsFromRenderer());
+
+ AXNodeData invalid_child_id;
+ invalid_child_id.id = 1;
+ invalid_child_id.child_ids = {2, kInitialEmptyDocumentRootNodeID};
+ EXPECT_FALSE(invalid_child_id.HasValidAXNodeIDsFromRenderer());
+
+ // When feature flag is disabled, all IDs are accepted.
+ base::test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndDisableFeature(
+ ::features::kAccessibilityCheckAXNodeIDs);
+ EXPECT_TRUE(IsValidAXNodeIDFromRenderer(kFirstGeneratedBrowserNodeID));
+ EXPECT_TRUE(invalid_node_id.HasValidAXNodeIDsFromRenderer());
+ EXPECT_TRUE(invalid_child_id.HasValidAXNodeIDsFromRenderer());
+}
+
} // namespace ui
diff --git a/ui/accessibility/ax_tree_update_util_unittest.cc b/ui/accessibility/ax_tree_update_util_unittest.cc
index 340d689..614d3508 100644
--- a/ui/accessibility/ax_tree_update_util_unittest.cc
+++ b/ui/accessibility/ax_tree_update_util_unittest.cc
@@ -5,7 +5,9 @@
#include "ui/accessibility/ax_tree_update_util.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/accessibility/ax_location_and_scroll_updates.h"
#include "ui/accessibility/ax_tree_update.h"
+#include "ui/accessibility/ax_updates_and_events.h"
namespace ui {
@@ -283,4 +285,74 @@
}
}
+TEST(AXTreeUpdateUtilTest, IsValidAXTreeUpdateFromRenderer) {
+ AXTreeUpdate valid_update;
+ valid_update.root_id = 1;
+ valid_update.node_id_to_clear = kInvalidAXNodeID;
+ AXNodeData node;
+ node.id = 1;
+ node.child_ids = {2};
+ valid_update.nodes.push_back(node);
+ EXPECT_TRUE(valid_update.HasValidAXNodeIDsFromRenderer());
+
+ AXTreeUpdate invalid_root;
+ invalid_root.root_id = kFirstGeneratedBrowserNodeID;
+ EXPECT_FALSE(invalid_root.HasValidAXNodeIDsFromRenderer());
+
+ AXTreeUpdate invalid_clear;
+ invalid_clear.node_id_to_clear = kFirstGeneratedBrowserNodeID;
+ EXPECT_FALSE(invalid_clear.HasValidAXNodeIDsFromRenderer());
+
+ AXTreeUpdate invalid_node;
+ invalid_node.root_id = 1;
+ AXNodeData bad_node;
+ bad_node.id = kFirstGeneratedBrowserNodeID;
+ invalid_node.nodes.push_back(bad_node);
+ EXPECT_FALSE(invalid_node.HasValidAXNodeIDsFromRenderer());
+}
+
+TEST(AXTreeUpdateUtilTest, IsValidAXLocationAndScrollUpdatesFromRenderer) {
... (truncated)
Original Bug Report
macOS sandbox escape: renderer AXNodeID in the browser-reserved range frees a BrowserAccessibility still used by ApproximateHitTest, giving browser-process code execution. MiraclePtr NOT protected.
Report description
macOS sandbox escape: renderer AXNodeID in the browser-reserved range frees a BrowserAccessibility still used by ApproximateHitTest, giving browser-process code execution. MiraclePtr NOT protected.
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
Summary
An attacker who already controls a sandboxed renderer on macOS can send an accessibility node ID from the range reserved for browser-generated nodes.
Lazy macOS table-node creation then replaces and frees that renderer-created node while BrowserAccessibility::ApproximateHitTest() still holds a plain pointer to it.
Ten renderer-controlled table IDs reclaim the 40-byte allocation and redirect the next browser-process virtual call.
In two independent Chromium 154.0.8025.0 runs under different ASLR bases, a real VoiceOver position query made the browser process call system("id>/tmp/c849"). A causal control changed only the call target; it created no output file, and the browser faulted with PC = 0x7f123456789abcde.
This demonstrates a renderer sandbox escape with code execution in the browser process. It does not provide the initial renderer compromise or a web-content-only exploit.
The selected transfer gadget does not satisfy the original C++ return ABI, so the browser crashes after the command has completed.
I also reproduced the underlying browser-process heap use-after-free twice in an official full-browser ASan build. Both fully symbolized traces report an 8-byte read in -[BrowserAccessibilityCocoa accessibilityHitTest:] and end with MiraclePtr Status: NOT PROTECTED.
Affected code and regression
Chromium’s renderer-ID predicate accepts IDs >= 0 and renderer-generated IDs <= -1000000001. The interval [-1000000000, -1] is reserved for browser-generated accessibility nodes. The PoC uses -12, inside that interval, but the relevant deserialization checks were removed.
Public issue 498205735 led to four relevant changes:
-
CL 7727677 added validation for
AXNodeData.id, everychild_id,AXTreeUpdate.root_id, andnode_id_to_clear, together with accessibility hardening. -
CL 8250159 introduced the renderer-boundary defect exploited here by removing the
idandchild_idchecks.Commit
c56d0e6b9a3272147387dc9101bc3c9474c69149landed on 2026-08-13 atrefs/heads/main@{#1678826}. It changes onlyui/accessibility/mojom/ax_node_data_mojom_traits.cc. -
CL 8246142 removed the class-level hardening.
-
CL 8288690 removed the remaining tree-update checks.
I verified that published Chrome for Testing Early Stable 153.0.8010.12 contains the two removals needed by this PoC. I recorded that source and its ancestry.
The primary exploitability matrix ran at revision f041faa4a987d4634ce90a4b08b22b75fc99b670, which reports Chromium 154.0.8025.0. I did not infer coverage for Beta or Canary packages that I did not test.
A separate 2-positive/1-control matrix on the intact Stable binary also reached system(). That matrix supplied runtime addresses externally and used a native accessibility client plus forced accessibility, so I include it only as product corroboration.
Root cause
At the tested revision, ui/accessibility/mojom/ax_node_data_mojom_traits.cc accepts a renderer’s node ID and child IDs without rejecting the browser-reserved interval:
out->id = data.id();
// ...
if (!data.ReadChildIds(&out->child_ids))
return false;
The PoC inserts a cell/sibling with ID -12 and an 11-column table. When table information is requested lazily, AXTableInfo::UpdateExtraMacNodes() creates one browser-side node per column and one table-header node. The last generated node also receives ID -12.
BrowserAccessibilityManager::OnNodeCreated() stores wrappers by ID without rejecting an existing entry:
id_wrapper_map_[node->id()] = CreateBrowserAccessibility(node);
The new browser-generated wrapper therefore destroys the renderer-created -12 wrapper. This occurs during recursive traversal while BrowserAccessibility::ApproximateHitTest() retains plain pointers:
BrowserAccessibility* child_result = nullptr;
for (BrowserAccessibility* child = PlatformGetLastChild(); child != nullptr;
child = child->PlatformGetPreviousSibling()) {
BrowserAccessibility* result = child->ApproximateHitTest(point);
if (result == child && !child_result)
child_result = result;
}
The stale result reaches the Cocoa caller, which performs another virtual call:
BrowserAccessibility* hit = manager->CachingAsyncHitTest(point);
if (!hit)
return nil;
return NSAccessibilityUnignoredAncestor(hit->GetNativeViewAccessible().Get());
The official ASan traces show the 40-byte allocation being freed from BrowserAccessibilityManager::OnNodeCreated() before the stale read in accessibilityHitTest:.
The free is reached through AXTableInfo::UpdateExtraMacNodes(), AXTableInfo::Update(), AXTableInfo::Create(), and AXTree::GetTableInfo().
Reproduction: sandbox escape
Use a disposable macOS arm64 test account. The positive and control cases intentionally terminate the browser.
- Check out the tested revision and apply the renderer-only patch:
git checkout f041faa4a987d4634ce90a4b08b22b75fc99b670
git apply compromised-renderer-simulation.patch
- Build the fully linked browser with the attached arguments:
mkdir -p out/cycle3839_functional
cp args.gn out/cycle3839_functional/args.gn
gn gen out/cycle3839_functional
autoninja -C out/cycle3839_functional chrome
- Put
poc.htmlbeside the generatedgen/directory and serve that directory with Python:
cp poc.html out/cycle3839_functional/poc.html
python3 -m http.server 8000 --bind 127.0.0.1 \
--directory out/cycle3839_functional
-
In VoiceOver Utility, set the mouse pointer to ignore the VoiceOver cursor. Ensure VoiceOver is initially off, close other Chromium instances, and remove any old
/tmp/c849. -
Launch Chromium directly, without CDP or a harness:
out/cycle3839_functional/Chromium.app/Contents/MacOS/Chromium \
--user-data-dir=profile-positive \
--no-first-run --no-default-browser-check --use-mock-keychain \
--enable-blink-features=MojoJS \
--enable-logging --log-file=positive.log \
--window-position=0,0 --window-size=1400,900 \
'http://localhost:8000/poc.html?incremental=1&startDelayMs=15000'
-
Wait for
CYCLE3839_PRIMED. Put the pointer over the yellow surface near(100, 220), activate Finder so Chromium remains in the background, and start VoiceOver. -
After
CYCLE3839_REPRIMEDandCYCLE3839_SENT:count=1, invoke VoiceOver’s Move VoiceOver cursor to mouse pointer command (Control-Option-Shift-F5; addFnif the keyboard uses media keys). Move the pointer one pixel over the surface. -
Expected positive result:
/tmp/c849is newly created with the browser process’sidoutput, then Chromium crashes at address0x1. Repeat with a fresh profile to obtain the second positive. -
Causal control: remove the marker, use a fresh profile, and repeat with:
http://localhost:8000/poc.html?incremental=1&control=1&startDelayMs=15000
Expected control result: no marker, and the crash record has PC = 0x7f123456789abcde.
Reproduction: official ASan UAF
This diagnostic stage uses poc-asan.html and trigger_ax_hit_only.swift. It intentionally uses a minimal native AX client and forced accessibility. It is separate from the VoiceOver command-execution matrix.
- Extract the official macOS arm64 ASan archive for revision
8b88f283e4a86e6c20ac46aa5c14c6798f254ee9. Placepoc-asan.htmlin the archive root, where itsgen/directory is available, and serve that root:
python3 -m http.server 8819 --bind 127.0.0.1 --directory ASAN_ARCHIVE_ROOT
swiftc -O trigger_ax_hit_only.swift -o trigger_ax_hit_only
- Grant the terminal Accessibility permission, then launch the official Chromium binary:
ASAN_ARCHIVE_ROOT/Chromium.app/Contents/MacOS/Chromium \
--user-data-dir=profile-asan --no-first-run --no-default-browser-check \
--use-mock-keychain --enable-blink-features=MojoJS \
--force-renderer-accessibility=complete --enable-logging=stderr \
'--vmodule=browser_accessibility_manager*=3,ax_tree*=3,render_accessibility_host*=3' \
--file-url-path-alias=/gen=ASAN_ARCHIVE_ROOT/gen \
--window-position=0,0 --window-size=1400,900 \
'http://localhost:8819/poc-asan.html?startupDelayMs=10000'
- After the log contains
FULL_ROOT_SENT:groomA:count=1, issue one position query at a point inside the yellow surface:
./trigger_ax_hit_only "$(pgrep -nx Chromium)" 100 220
Expected result: Chromium reports a browser-process heap-use-after-free and exits. The helper then prints result=-25204 hit=false; -25204 is kAXErrorCannotComplete because the target died while servicing the synchronous request. Both attached traces end with MiraclePtr Status: NOT PROTECTED.
I verified exact text equality for the five relevant production functions between this ASan revision and the command-execution revision.
Suggested fix
Reject the browser-reserved interval [-1000000000, -1] unconditionally at the renderer trust boundary, before RenderAccessibilityHost::HandleAXEvents() posts the update to the UI thread.
Check AXNodeData.id, every child_id, AXTreeUpdate.root_id, and node_id_to_clear. Reject the entire message on any violation.
The current IsValidAXNodeIDFromRenderer() helper is feature-gated, so calling it without also making the check fail-closed would not be sufficient. A dedicated unconditional reserved-range check at this IPC boundary avoids that ambiguity.
As defense in depth, reject unexpected replacement of an existing browser-generated wrapper in BrowserAccessibilityManager::OnNodeCreated(). Stack traversal state that survives recursive accessibility calls should use raw_ptr. Neither layer should replace renderer-boundary validation.
Scope
This report starts from an already-compromised renderer and demonstrates execution in the browser process. It does not claim the initial renderer compromise, a web-only trigger, a VrpFlags controlled-write result on an unmodified release, or a stable post-exploitation chain.
Attachments
poc-asan.htmlandtrigger_ax_hit_only.swiftreproduce the use-after-free on the official ASan build.official-asan-run1-fully-symbolized.txtandofficial-asan-run2-fully-symbolized.txtare the two fully symbolized traces.poc.html,compromised-renderer-simulation.patchandargs.gnreproduce the browser-process code execution.
Further receipts exist for the causal matrix, the source equivalence between the two revisions, and the Stable corroboration. I can attach any of them on request.
Impact analysis
Who can exploit it: an attacker who already has native code execution in a sandboxed macOS renderer. The attached patch simulates that starting capability; it is process-guarded and changes renderer-side code only.
When it fires: the trigger is ordinary VoiceOver use, not a special key sequence. With VoiceOver’s “pointer moves the VoiceOver cursor” preference, any mouse movement makes VoiceOver ask the browser which element is under the pointer, which is the query that reaches the freed object. A separate one-shot receipt records a positive with VoiceOver already running before launch and a single mouse movement, with no keyboard command at all. The step-by-step recipe below uses the explicit cursor command only because that is what the two sealed positive runs did.
What they gain: execution of an attacker-selected function with an attacker-controlled first argument in the browser process.
Ten controlled 32-bit IDs reclaim the 40-byte object and encode the fake object used by the stale virtual call. The exact layout is in the attached poc.html.
The renderer patch supplies two capabilities that MojoJS alone cannot provide in this case:
- it obtains the browser Framework mapping with
proc_pidinfo()and derives the shared-cache slide from the renderer’s dyld state; - it ignores
RenderAccessibilityManager::SetMode()so the compromised renderer can retain its forged tree instead of having Blink’s normal serializer replace it.
No browser-process source is modified. The final runs use the normal renderer sandbox, MojoJS for the forged accessibility update, and a real VoiceOver element-under-mouse query.
They do not use CDP, remote debugging, a debugger, a native AX helper, --force-renderer-accessibility, VrpFlags, or --no-sandbox.
The causal matrix:
- Positive 1: Framework base
0x12e604000; fresh marker inode289404402; expectedidoutput. - Control: Framework base
0x13b484000; only the target changes to0x7f123456789abcde; no marker; crashPCequals that target. - Positive 2: Framework base
0x121cf0000; fresh marker inode289407857; identicalidoutput under a different base.
The two positive files have the same contents and SHA-256 but different inodes. All three crash records show the native position-query path followed by Chrome’s accessibility hit test.
The cause
What version of Chrome have you found the security issue in?
153.0.8010.12 - currently both Stable and Beta on macOS; I verified in source that this revision contains both removals. 154.0.8025.0 - currently Dev; this is the version reported by the fully linked build at revision f041faa4a987d4634ce90a4b08b22b75fc99b670 used for the browser-process code-execution matrix. The two use-after-free traces come from the official macOS arm64 ASan build at revision 8b88f283e4a86e6c20ac46aa5c14c6798f254ee9. Canary is 154.0.8036.0 and was not tested. OS: macOS 26.5.2 build 25F84, arm64. Requires –enable-blink-features=MojoJS.
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Sandbox Escape
How would you like to be publicly acknowledged for your report?
Findingz
- http://localhost:8000/poc.html?incremental=1&control=1&startDelayMs=15000
- http://localhost:8000/poc.html?incremental=1&startDelayMs=15000
- http://localhost:8819/poc-asan.html?startupDelayMs=10000
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium-review.googlesource.com/c/chromium/src/+/7727677
- https://chromium-review.googlesource.com/c/chromium/src/+/8246142
- https://chromium-review.googlesource.com/c/chromium/src/+/8250159
- https://chromium-review.googlesource.com/c/chromium/src/+/8288690
- https://chromium.googlesource.com/chromium/src/+/c56d0e6b9a3272147387dc9101bc3c9474c69149
- https://chromium.googlesource.com/chromium/src/+/f041faa4a987d4634ce90a4b08b22b75fc99b670
- https://chromium.googlesource.com/chromium/src/+/main/ui/accessibility/mojom/ax_node_data_mojom_traits.cc