CVE-2026-8536
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc |
modified |
Files Changed
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.ccchrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h
Patch
From 1b02db0ad60ce3c3273b633290adf68585fc9142 Mon Sep 17 00:00:00 2001 From: Kristi Saney <[email protected]> Date: Mon, 30 Mar 2026 17:29:26 -0700 Subject: [PATCH] [Reading mode] Verify tree id for a11y actions. Change-Id: I3639dd7a8f4df8246dc354cc959931c8cb887aed Bug: 495857582 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7706162 Reviewed-by: Evan Liu <[email protected]> Commit-Queue: Kristi Saney <[email protected]> Reviewed-by: Lauren Winston <[email protected]> Cr-Commit-Position: refs/heads/main@{#1607501} --- diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc index 5a05aa6d..15cec83 100644 --- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc +++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc @@ -6,6 +6,7 @@ #include <algorithm> #include <string> +#include <string_view> #include <utility> #include <vector> @@ -57,6 +58,7 @@ #include "content/public/browser/render_frame_host.h" #include "content/public/browser/render_process_host.h" #include "content/public/browser/scoped_accessibility_mode.h" +#include "content/public/browser/web_contents.h" #include "content/public/browser/web_ui.h" #include "content/public/common/content_switches.h" #include "content/public/common/url_constants.h" @@ -240,6 +242,17 @@ } #endif +constexpr std::string_view kRendererLinkRequestHistogram = + "Accessibility.ReadAnything.RendererRequestForLinkClick.IsFromObservedTree"; +constexpr std::string_view kRendererImageRequestHistogram = + "Accessibility.ReadAnything.RendererRequestForImageDataDownload." + "IsFromObservedTree"; +constexpr std::string_view kRendererScrollRequestHistogram = + "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode." + "IsFromObservedTree"; +constexpr std::string_view kRendererSelectionRequestHistogram = + "Accessibility.ReadAnything.RendererRequestForSelection.IsFromObservedTree"; + } // namespace ReadAnythingWebContentsObserver::ReadAnythingWebContentsObserver( @@ -817,6 +830,38 @@ } } +bool ReadAnythingUntrustedPageHandler::IsObservingTree( + const ui::AXTreeID& tree_id) const { + content::RenderFrameHost* rfh = + content::RenderFrameHost::FromAXTreeID(tree_id); + if (!rfh) { + return false; + } + + content::WebContents* contents = !!pdf_observer_ + ? pdf_observer_->web_contents() + : main_observer_->web_contents(); + + if (!contents) { + return false; + } + + bool are_contents_pdf = + chrome_pdf::features::IsOopifPdfEnabled() + ? !!pdf::PdfViewerStreamManager::FromWebContents(contents) + : !!pdf_observer_; + + if (!are_contents_pdf) { + return rfh == contents->GetPrimaryMainFrame(); + } + + content::RenderFrameHost* pdf_rfh = + chrome_pdf::features::IsOopifPdfEnabled() + ? pdf_frame_util::FindFullPagePdfExtensionHost(contents) + : pdf_frame_util::FindPdfChildFrame(contents->GetPrimaryMainFrame()); + return pdf_rfh && rfh == pdf_rfh; +} + void ReadAnythingUntrustedPageHandler::OnLineSpaceChange( read_anything::mojom::LineSpacing line_spacing) { profile_->GetPrefs()->SetInteger(prefs::kAccessibilityReadAnythingLineSpacing, @@ -921,6 +966,13 @@ void ReadAnythingUntrustedPageHandler::OnLinkClicked( const ui::AXTreeID& target_tree_id, ui::AXNodeID target_node_id) { + bool is_observing_tree = IsObservingTree(target_tree_id); + base::UmaHistogramBoolean(kRendererLinkRequestHistogram, is_observing_tree); + if (!is_observing_tree) { + VLOG(1) << "Received link click request for tree_id " << target_tree_id + << " which is not currently being observed"; + return; + } ui::AXActionData action_data; action_data.target_tree_id = target_tree_id; action_data.action = ax::mojom::Action::kDoDefault; @@ -932,6 +984,13 @@ void ReadAnythingUntrustedPageHandler::OnImageDataRequested( const ui::AXTreeID& target_tree_id, ui::AXNodeID target_node_id) { + bool is_observing_tree = IsObservingTree(target_tree_id); + base::UmaHistogramBoolean(kRendererImageRequestHistogram, is_observing_tree); + if (!is_observing_tree) { + VLOG(1) << "Received image data request for tree_id " << target_tree_id + << " which is not currently being observed"; + return; + } main_observer_->web_contents()->DownloadImageFromAxNode( target_tree_id, target_node_id, /*preferred_size=*/gfx::Size(), @@ -949,6 +1008,7 @@ const GURL& image_url, const std::vector<SkBitmap>& bitmaps, const std::vector<gfx::Size>& sizes) { + CHECK(IsObservingTree(target_tree_id)); bool download_was_successful = network::IsSuccessfulStatus(http_status_code) || http_status_code == 0; @@ -968,6 +1028,13 @@ void ReadAnythingUntrustedPageHandler::ScrollToTargetNode( const ui::AXTreeID& target_tree_id, ui::AXNodeID target_node_id) { + bool is_observing_tree = IsObservingTree(target_tree_id); + base::UmaHistogramBoolean(kRendererScrollRequestHistogram, is_observing_tree); + if (!is_observing_tree) { + VLOG(1) << "Received scroll request for tree_id " << target_tree_id + << " which is not currently being observed"; + return; + } ui::AXActionData action_data; action_data.target_tree_id = target_tree_id; action_data.target_node_id = target_node_id; @@ -1033,6 +1100,7 @@ void ReadAnythingUntrustedPageHandler::PerformActionInTargetTree( const ui::AXActionData& data) { + CHECK(IsObservingTree(data.target_tree_id)); ui::AXActionHandlerBase* handler = ui::AXActionHandlerRegistry::GetInstance()->GetActionHandler( data.target_tree_id); @@ -1048,6 +1116,14 @@ int anchor_offset, ui::AXNodeID focus_node_id, int focus_offset) { + bool is_observing_tree = IsObservingTree(target_tree_id); + base::UmaHistogramBoolean(kRendererSelectionRequestHistogram, + is_observing_tree); + if (!is_observing_tree) { + VLOG(1) << "Received selection request for tree_id " << target_tree_id + << " which is not currently being observed"; + return; + } ui::AXActionData action_data; action_data.target_tree_id = target_tree_id; action_data.action = ax::mojom::Action::kSetSelection; @@ -1055,13 +1131,8 @@ action_data.anchor_offset = anchor_offset; action_data.focus_node_id = focus_node_id; action_data.focus_offset = focus_offset; - ui::AXActionHandlerBase* handler = - ui::AXActionHandlerRegistry::GetInstance()->GetActionHandler( - target_tree_id); - if (!handler) { - return; - } - handler->PerformAction(action_data); + + PerformActionInTargetTree(action_data); } void ReadAnythingUntrustedPageHandler::OnCollapseSelection() { diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h index bc58576..32357703 100644 --- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h +++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h @@ -293,6 +293,10 @@ content::WebContents* old_contents, content::WebContents* new_contents); + // Used to verify that an incoming action request is for the currently + // observed tree. If it's not, it may be a malicious request. + bool IsObservingTree(const ui::AXTreeID& tree_id) const; + // ui::AXActionHandlerObserver: void TreeRemoved(ui::AXTreeID ax_tree_id) override; @@ -300,7 +304,6 @@ void GetDependencyParserModel( GetDependencyParserModelCallback callback) override; void OnCopy() override; -
Regression Test / PoC
diff --git a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
index de8e46b..c7f708a4 100644
--- a/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
+++ b/chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
@@ -57,6 +57,8 @@
#include "ui/accessibility/mojom/ax_event.mojom.h"
#include "ui/accessibility/mojom/ax_tree_id.mojom.h"
#include "ui/accessibility/mojom/ax_tree_update.mojom.h"
+#include "ui/accessibility/platform/browser_accessibility.h"
+#include "ui/accessibility/platform/browser_accessibility_manager.h"
#include "ui/gfx/geometry/size.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "base/test/bind.h"
@@ -192,18 +194,6 @@
test_web_ui,
/*use_screen_ai_service=*/false) {}
#endif
- void OnImageDataRequested(const ui::AXTreeID& target_tree_id,
- ui::AXNodeID target_node_id) override {
- OnImageDataDownloaded(target_tree_id, target_node_id, /*id=*/0,
- /*http_status_code=*/0, GURL(),
- /*bitmaps=*/{test_bitmap_},
- /*sizes=*/{gfx::Size(10, 10)});
- }
-
- void SetTestBitmap(SkBitmap bitmap) { test_bitmap_ = bitmap; }
-
- private:
- SkBitmap test_bitmap_;
};
class FakeTtsEngineDelegate : public content::TtsEngineDelegate {
@@ -1050,16 +1040,39 @@
base::DictValue().Set(kLang1, kVoice).Set(kLang2, kVoice)));
}
-IN_PROC_BROWSER_TEST_P(ReadAnythingUntrustedPageHandlerTest, BadImageData) {
- auto test_handler_u_ptr = CreateHandler();
- auto* test_handler = test_handler_u_ptr.get();
- handler_ = std::move(test_handler_u_ptr);
+IN_PROC_BROWSER_TEST_P(ReadAnythingUntrustedPageHandlerTest,
+ OnImageDataRequested_IgnoresBadTreeId) {
+ base::HistogramTester histogram_tester;
+ handler_ = CreateHandler();
auto tree_id = ui::AXTreeID::CreateNewAXTreeID();
ui::AXNodeID node_id = 1;
- SkBitmap bitmap;
- test_handler->SetTestBitmap(bitmap);
+
OnImageDataRequested(tree_id, node_id);
- EXPECT_CALL(page_, OnImageDataDownloaded(_, _, _)).Times(0);
+
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
+ "IsFromObservedTree",
+ false, 1);
+}
+
+IN_PROC_BROWSER_TEST_P(ReadAnythingUntrustedPageHandlerTest,
+ OnImageDataRequested_WithGoodTreeId) {
+ base::HistogramTester histogram_tester;
+ ASSERT_TRUE(embedded_test_server()->Start());
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(),
+ embedded_test_server()->GetURL("/downloads/large_image.html")));
+ handler_ = CreateHandler();
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ auto tree_id = web_contents->GetPrimaryMainFrame()->GetAXTreeID();
+
+ OnImageDataRequested(tree_id, 1);
+
+ histogram_tester.ExpectUniqueSample(
+ "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
+ "IsFromObservedTree",
+ true, 1);
}
IN_PROC_BROWSER_TEST_P(ReadAnythingUntrustedPageHandlerTest,
Original Bug Report
Potential Privilege Escalation via Unvalidated target_tree_id in Reading Mode
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: The ReadAnythingUntrustedPageHandler does not validate the target_tree_id received from the untrusted Reading Mode renderer. This allows a compromised renderer to perform unauthorized actions (like injecting clicks or reading images) on cross-origin frames within the same tab, bypassing the Same-Origin Policy. The issue stems from passing the unvalidated ID to global lookup functions that lack ownership checks.
Affected files:
chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cccontent/browser/web_contents/web_contents_impl.cc
Estimated timestamp from git blame: 2025-10-22
Description
A potential security vulnerability exists in the Reading Mode implementation where ReadAnythingUntrustedPageHandler fails to validate the target_tree_id received from the chrome-untrusted://read-anything renderer via Mojo. This lack of validation could allow a compromised renderer to perform unauthorized actions on cross-origin subframes within the same tab, effectively bypassing the Same-Origin Policy (SOP).
When Reading Mode is enabled, the ReadAnythingWebContentsObserver enables accessibility for the entire WebContents by creating a scoped accessibility mode. Consequently, the browser sends accessibility updates for every frame in the tab, including cross-origin out-of-process iframes (OOPIFs), to the Reading Mode renderer. These updates include the unguessable ax_tree_id for each frame.
A compromised chrome-untrusted:// renderer can extract these ax_tree_ids and use them in Mojo calls back to the browser. Several handlers in ReadAnythingUntrustedPageHandler, such as OnLinkClicked, OnSelectionChange, ScrollToTargetNode, and OnImageDataRequested, accept a target_tree_id without verifying that it belongs to the main frame or is otherwise authorized for the Reading Mode session.
For example, OnLinkClicked constructs a ui::AXActionData object with the provided target_tree_id and calls PerformActionInTargetTree. This helper performs a global lookup in the ui::AXActionHandlerRegistry (AXActionHandlerRegistry::GetInstance()->GetActionHandler(data.target_tree_id)) and dispatches the action (e.g., kDoDefault for a click). Because the registry is global and no ownership check is performed against the current WebContents, the action is executed in the target frame’s context, regardless of origin.
Similarly, OnImageDataRequested passes the unvalidated target_tree_id to WebContentsImpl::DownloadImageFromAxNode. This method globally resolves the frame via RenderFrameHostImpl::FromAXTreeID(tree_id) without verifying that the returned frame belongs to the WebContentsImpl instance on which it was called, allowing the renderer to exfiltrate cross-origin image data.
Potential Impact
If the chrome-untrusted://read-anything renderer is compromised (e.g., via an XSS in its JavaScript handling attacker-controlled accessibility data), an attacker could achieve the following against cross-origin subframes in the same tab:
- Arbitrary Click Injection (CSRF Bypass): By sending
OnLinkClickedwith theax_tree_idandnode_idof an element in a victim iframe, the attacker can trigger a click. This action executes in the context of the victim’s origin, utilizing the user’s authenticated session cookies to submit forms or click sensitive buttons. - Cross-Origin Image Read: Using
OnImageDataRequested, the attacker can force the browser to download images from cross-origin frames and return the bitmap data to the untrusted renderer.
Hypothetical Attack Scenario
- An attacker hosts a malicious page (
attacker.com) that embeds an iframe pointing to a sensitive page onvictim.com(e.g.,<iframe src='https://victim.com/settings'>). - The user navigates to
attacker.comand opens Reading Mode. - The browser enables accessibility for the entire tab and forwards the accessibility tree updates (including the
ax_tree_idfor thevictim.comiframe) to thechrome-untrusted://read-anythingrenderer. - The attacker compromises the
chrome-untrusted://renderer (e.g., via a renderer exploit or XSS). - The compromised renderer extracts the
victim_tree_idand calls the Mojo methodUntrustedPageHandler::OnLinkClicked(victim_tree_id, button_node_id). - The browser performs a global lookup for the
victim_tree_id, finds theRenderFrameHostImplfor thevictim.comiframe, and dispatches a click action, resulting in a click on a sensitive button with the victim’s cookies.
Suggested Fix
In ReadAnythingUntrustedPageHandler, before processing any action involving a target_tree_id (in OnLinkClicked, OnImageDataRequested, ScrollToTargetNode, OnSelectionChange, etc.), validate that the provided target_tree_id belongs to the WebContents being observed by the main_observer_ or pdf_observer_.
This could be achieved by verifying that the RenderFrameHostImpl associated with the target_tree_id is part of the frame tree of the observed WebContents.
Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651
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. Please feel free to reach out to me if you have concerns or feedback.