Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebUI
DescriptionIncorrect authorization in WebUI
ComponentWebUI
Sub-componentReading mode
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker552416113
Fix commit8add9401a1e6 (chromium/src) +327/-78
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-08-25 (per vendor advisory)
Fix landed2026-08-31 (public git)
Disclosed2026-09-15
Patch gap15 days — fix public in source before the advisory

Background

Reading mode (Read Anything)
a Chrome side-panel feature that distills a page’s content into a simplified view; its UI runs in an untrusted (chrome-untrusted://) WebUI renderer that talks to the browser process over Mojo.
`ReadAnythingUntrustedPageHandler`
the browser-process Mojo endpoint that receives action requests (link clicks, image downloads, selection changes, scrolls) from the untrusted reading-mode renderer and executes them against the observed page.
`ui::AXTreeID`
an identifier for an accessibility tree; the handler resolves it back to a content::RenderFrameHost and only services requests for a tree it is actively observing (IsObservingTree).
Privileged / non-HTTP(S) page
a page whose committed URL is not http/https (e.g. a chrome:// or chrome-untrusted:// WebUI or file:// page), which should not be scriptable or actionable by untrusted content.

Root Cause Analysis

The renderer-facing handlers OnLinkClicked, OnImageDataRequested, and OnSelectionChange gated their work only on IsObservingTree(target_tree_id), which confirms the target tree is one reading mode is currently observing but says nothing about the scheme or privilege level of the underlying frame. Because reading mode can distill and observe privileged surfaces (non-HTTP/HTTPS pages such as WebUI), an action request naming that observed tree passed the only guard and the handler went on to actuate a link navigation, an image-data download (DownloadImageFromAxNode), or a selection change on the privileged page. The security invariant that reading mode must remain “read only” on privileged pages — never interacting back into them — was therefore not enforced for these three action paths.

The fix adds AreActionsAllowedInTree, which resolves the tree to its RenderFrameHost and returns true only when the contents are a PDF or rfh->GetLastCommittedURL().SchemeIsHTTPOrHTTPS() is true; each of the three handlers now calls it after the observing-tree check and returns early (logging kDisallowedActionOnPageType) when the frame is not an allowed page type. This tightens the check so link clicks, image downloads, and selection changes are refused on non-HTTP/HTTPS, non-PDF frames, while the less-privileged ScrollToTargetNode is deliberately left permitted.

Key insight
The handlers authorized action requests solely by “is this a tree I’m observing?” and never by “is this frame a page type on which such actions are permitted?”; the fix inserts a page-type authorization gate (AreActionsAllowedInTree) that restricts link-click, image-download, and selection actions to HTTP/HTTPS or PDF frames.

Attack Path

  1. Observe a privileged page in reading mode With the reading-mode side panel open, cause it to distill/observe a privileged (non-HTTP/HTTPS) surface, so the handler is actively observing that page’s AXTreeID.
  2. Control the untrusted reading-mode renderer An attacker who influences the untrusted (chrome-untrusted://) reading-mode renderer crafts Mojo action requests (OnLinkClicked, OnImageDataRequested, or OnSelectionChange) naming the observed privileged tree’s AXTreeID.
  3. Pass the only guard Pre-patch, the request satisfies IsObservingTree(target_tree_id), the sole gate, because the privileged tree is genuinely being observed; no scheme/page-type check exists.
  4. Actuate an action on the privileged page The browser process executes the request against the privileged frame — activating a link, downloading image data via DownloadImageFromAxNode, or altering the selection — i.e. reading mode “interacts back” into a page it should treat as read-only, an incorrect-authorization condition.
  5. Downstream escalation Any further consequence of triggering navigations/downloads/selection on a privileged WebUI surface beyond this unauthorized interaction is not demonstrated by the patch (inferred).

Impact Assessment

An attacker able to drive the untrusted reading-mode renderer gains the ability to make the browser process perform link-click, image-download, and selection actions against a privileged (non-HTTP/HTTPS) page that reading mode is observing, breaking the intended read-only isolation for such pages — an incorrect-authorization flaw in WebUI. The preconditions are that reading mode is active and observing a privileged surface and that the attacker can emit the corresponding Mojo requests from the untrusted renderer. The advisory classifies this as a WebUI logic/authorization error; any escalation to more serious primitives is (inferred) and not established by the patch, and no exploited-in-the-wild status is stated.

Changed Functions

FunctionChangeNotes
if
chrome/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.h
  • chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler_browsertest.cc
  • chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
  • tools/metrics/histograms/metadata/accessibility/enums.xml
  • tools/metrics/histograms/metadata/accessibility/histograms.xml

Audit Directions

  • Observing-tree-only authorization
    Search other Mojo handlers that gate work solely on an “am I observing this ID?” check and audit whether they also verify the frame’s scheme/privilege before actuating an effect on it.
  • Reading-mode / accessibility action sinks
    Review every renderer-triggered action in the reading-mode and AX action-handler surfaces (navigation, downloads, selection, focus) to confirm each enforces the HTTP/HTTPS-or-PDF page-type restriction rather than assuming HTTP-only content.
  • Privileged-page read-only invariant
    Where a feature is documented as “read only” on chrome:///chrome-untrusted:///file:// pages, verify that every write-back path (not just the obvious ones) rejects non-HTTP/HTTPS frames, since partial enforcement (as here, where ScrollToTargetNode is exempt) is easy to overlook.
From 8add9401a1e640c83e4b714353cb04405538ab17 Mon Sep 17 00:00:00 2001
From: Lauren Winston <[email protected]>
Date: Mon, 31 Aug 2026 14:47:35 -0700
Subject: [PATCH] [Reading mode] Limit actions on non HTTP/HTTPS pages.

Prevent OnLinkClicked, OnImageDataRequested, and OnSelectionChange
from being executed when on a non-pdf privileged page.

Note that other actions such as ScrollToTargetNode are still
allowed, as they are less privileged.

Note that content will still show and select-to-distill
still work on the privileged pages, but they will be in more of
"read only" state where reading mode can't interact back with
the main page.

Also log when this happens.

Fixed: 552416113
Change-Id: I4f55c0c235bf5da58024534924b08c6d94e41787
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8310969
Reviewed-by: Kristi Saney <[email protected]>
Reviewed-by: Evan Liu <[email protected]>
Commit-Queue: Lauren Winston <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1689299}
---
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 6f1bb42..1ada3c26 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
@@ -257,15 +257,13 @@
 #endif
 
 constexpr std::string_view kRendererLinkRequestHistogram =
-    "Accessibility.ReadAnything.RendererRequestForLinkClick.IsFromObservedTree";
+    "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
 constexpr std::string_view kRendererImageRequestHistogram =
-    "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
-    "IsFromObservedTree";
+    "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result";
 constexpr std::string_view kRendererScrollRequestHistogram =
-    "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode."
-    "IsFromObservedTree";
+    "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode.Result";
 constexpr std::string_view kRendererSelectionRequestHistogram =
-    "Accessibility.ReadAnything.RendererRequestForSelection.IsFromObservedTree";
+    "Accessibility.ReadAnything.RendererRequestForSelection.Result";
 
 }  // namespace
 
@@ -860,6 +858,24 @@
   return pdf_rfh && rfh == pdf_rfh;
 }
 
+bool ReadAnythingUntrustedPageHandler::AreActionsAllowedInTree(
+    const ui::AXTreeID& tree_id) const {
+  content::RenderFrameHost* rfh =
+      content::RenderFrameHost::FromAXTreeID(tree_id);
+  if (!rfh) {
+    return false;
+  }
+
+  content::WebContents* contents = GetWebContents();
+  bool are_contents_pdf =
+      chrome_pdf::features::IsOopifPdfEnabled()
+          ? !!extensions::mime_handler::MimeHandlerStreamManager::
+                 FromWebContents(contents)
+          : !!pdf_observer_;
+
+  return are_contents_pdf || rfh->GetLastCommittedURL().SchemeIsHTTPOrHTTPS();
+}
+
 void ReadAnythingUntrustedPageHandler::OnLineSpaceChange(
     read_anything::mojom::LineSpacing line_spacing) {
   profile_->GetPrefs()->SetInteger(prefs::kAccessibilityReadAnythingLineSpacing,
@@ -1075,13 +1091,26 @@
     return;
   }
 
-  bool is_observing_tree = IsObservingTree(target_tree_id);
-  base::UmaHistogramBoolean(kRendererLinkRequestHistogram, is_observing_tree);
-  if (!is_observing_tree) {
+  if (!IsObservingTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererLinkRequestHistogram,
+        ReadAnythingRendererRequestResult::kNotObservedTree);
     VLOG(1) << "Received link click request for tree_id " << target_tree_id
             << " which is not currently being observed";
     return;
   }
+
+  if (!AreActionsAllowedInTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererLinkRequestHistogram,
+        ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+    VLOG(1) << "Ignoring link click on non-HTTP/HTTPS frame";
+    return;
+  }
+
+  base::UmaHistogramEnumeration(kRendererLinkRequestHistogram,
+                                ReadAnythingRendererRequestResult::kAllowed);
+
   if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
     VLOG(1) << "Received link click request with invalid target_node_id "
             << target_node_id;
@@ -1098,18 +1127,32 @@
 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) {
+  if (!IsObservingTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererImageRequestHistogram,
+        ReadAnythingRendererRequestResult::kNotObservedTree);
     VLOG(1) << "Received image data request for tree_id " << target_tree_id
             << " which is not currently being observed";
     return;
   }
+
+  if (!AreActionsAllowedInTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererImageRequestHistogram,
+        ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+    VLOG(1) << "Ignoring image data request on non-HTTP/HTTPS frame";
+    return;
+  }
+
+  base::UmaHistogramEnumeration(kRendererImageRequestHistogram,
+                                ReadAnythingRendererRequestResult::kAllowed);
+
   if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
     VLOG(1) << "Received image data request with invalid target_node_id "
             << target_node_id;
     return;
   }
+
   main_observer_->web_contents()->DownloadImageFromAxNode(
       target_tree_id, target_node_id,
       /*preferred_size=*/gfx::Size(),
@@ -1153,13 +1196,17 @@
 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) {
+  if (!IsObservingTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererScrollRequestHistogram,
+        ReadAnythingRendererRequestResult::kNotObservedTree);
     VLOG(1) << "Received scroll request for tree_id " << target_tree_id
             << " which is not currently being observed";
     return;
   }
+
+  base::UmaHistogramEnumeration(kRendererScrollRequestHistogram,
+                                ReadAnythingRendererRequestResult::kAllowed);
   if (!ui::IsValidAXNodeIDFromRenderer(target_node_id)) {
     VLOG(1) << "Received scroll request with invalid target_node_id "
             << target_node_id;
@@ -1257,14 +1304,25 @@
     return;
   }
 
-  bool is_observing_tree = IsObservingTree(target_tree_id);
-  base::UmaHistogramBoolean(kRendererSelectionRequestHistogram,
-                            is_observing_tree);
-  if (!is_observing_tree) {
+  if (!IsObservingTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererSelectionRequestHistogram,
+        ReadAnythingRendererRequestResult::kNotObservedTree);
     VLOG(1) << "Received selection request for tree_id " << target_tree_id
             << " which is not currently being observed";
     return;
   }
+
+  if (!AreActionsAllowedInTree(target_tree_id)) {
+    base::UmaHistogramEnumeration(
+        kRendererSelectionRequestHistogram,
+        ReadAnythingRendererRequestResult::kDisallowedActionOnPageType);
+    VLOG(1) << "Ignoring selection change on non-HTTP/HTTPS frame";
+    return;
+  }
+
+  base::UmaHistogramEnumeration(kRendererSelectionRequestHistogram,
+                                ReadAnythingRendererRequestResult::kAllowed);
   if (!ui::IsValidAXNodeIDFromRenderer(anchor_node_id)) {
     VLOG(1) << "Received selection request with invalid anchor_node_id "
             << anchor_node_id;
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 62b2da2..7b37f99e 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
@@ -87,6 +87,18 @@
 
 // LINT.ThenChange(/tools/metrics/histograms/metadata/accessibility/enums.xml:ReadAnythingDistillationScheme)
 
+// LINT.IfChange(ReadAnythingRendererRequestResult)
+// These values are persisted to logs. Entries should not be renumbered and
+// numeric values should never be reused.
+enum class ReadAnythingRendererRequestResult {
+  kAllowed = 0,
+  kNotObservedTree = 1,
+  kDisallowedActionOnPageType = 2,
+  kMaxValue = kDisallowedActionOnPageType,
+};
+
+// LINT.ThenChange(/tools/metrics/histograms/metadata/accessibility/enums.xml:ReadAnythingRendererRequestResult)
+
 enum class ListenToThisPagePlaybackMetricState {
   kInactive = 0,
   kWaitingForAudioStart,
@@ -330,6 +342,11 @@
   // observed tree. If it's not, it may be a malicious request.
   bool IsObservingTree(const ui::AXTreeID& tree_id) const;
 
+  // Used to verify if an incoming action request (e.g. clicking a link or
+  // downloading an image) is allowed on the current tree. Actions are allowed
+  // if the page is HTTP/HTTPS or a PDF.
+  bool AreActionsAllowedInTree(const ui::AXTreeID& tree_id) const;
+
   // ui::AXActionHandlerObserver:
   void TreeRemoved(ui::AXTreeID ax_tree_id) override;
 
diff --git a/tools/metrics/histograms/metadata/accessibility/enums.xml b/tools/metrics/histograms/metadata/accessibility/enums.xml
index 1c832a0..9fe3f0a 100644
--- a/tools/metrics/histograms/metadata/accessibility/enums.xml
+++ b/tools/metrics/histograms/metadata/accessibility/enums.xml
@@ -2403,6 +2403,20 @@
 
 <!-- LINT.ThenChange(//chrome/browser/resources/side_panel/read_anything/shared/metrics_browser_proxy.ts:ReadAnythingVoiceType) -->
 
+<!-- LINT.IfChange(ReadAnythingRendererRequestResult) -->
+
+<enum name="ReadAnythingRendererRequestResult">
+  <summary>
+    The result of an action request from the Read Anything untrusted WebUI
+    renderer to the browser process.
+  </summary>
+  <int value="0" label="Allowed"/>
+  <int value="1" label="Failed: Not from observed tree"/>
+  <int value="2" label="Failed: Disallowed on page type"/>
+</enum>
+
+<!-- LINT.ThenChange(//chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h:ReadAnythingRendererRequestResult) -->
+
 <!-- LINT.IfChange(ReadAnythingScrollEvent) -->
 
 <enum name="ReadAnythingScrollEvent">
diff --git a/tools/metrics/histograms/metadata/accessibility/histograms.xml b/tools/metrics/histograms/metadata/accessibility/histograms.xml
index b4560f6..e8331ecd 100644
--- a/tools/metrics/histograms/metadata/accessibility/histograms.xml
+++ b/tools/metrics/histograms/metadata/accessibility/histograms.xml
@@ -3627,16 +3627,15 @@
   </token>
 </histogram>
 
-<histogram
-    name="Accessibility.ReadAnything.RendererRequestFor{Action}.IsFromObservedTree"
-    enum="Boolean" expires_after="2027-02-07">
+<histogram name="Accessibility.ReadAnything.RendererRequestFor{Action}.Result"
+    enum="ReadAnythingRendererRequestResult" expires_after="2027-02-07">
+  <owner>[email protected]</owner>
   <owner>[email protected]</owner>
   <owner>[email protected]</owner>
   <summary>
-    Records whether action requests received by the
-    ReadAnythingUntrustedPageHandler (e.g. link click or image download
-    requests) are from the currently observed tree. If not, this may be a
-    malicious request.
+    Records the validation result of action requests ({Action}) received by the
+    ReadAnythingUntrustedPageHandler. If not allowed, this may be a malicious
+    request.
   </summary>
   <token key="Action">
     <variant name="ImageDataDownload" summary="requested to download an image"/>
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 761e053..10dd524 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
@@ -39,6 +39,7 @@
 #include "chrome/common/read_anything/read_anything.mojom-shared.h"
 #include "chrome/common/read_anything/read_anything.mojom.h"
 #include "chrome/common/webui_url_constants.h"
+#include "chrome/test/base/chrome_test_path_utils.h"
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/language_detection/core/constants.h"
@@ -487,6 +488,36 @@
     handler_->OnTranslateDriverDestroyed(driver);
   }
 
+  void SetUpHandler() {
+    ASSERT_TRUE(
+        content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+    test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+    handler_ = CreateHandler();
+  }
+
+  void GrantUserActivation(content::RenderFrameHost* rfh) {
+    handler_remote_.FlushForTesting();
+    page_.receiver_.FlushForTesting();
+    content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+    content::SimulateMouseClick(web_contents_.get(), 0,
+                                blink::WebMouseEvent::Button::kLeft);
+    ASSERT_TRUE(base::test::RunUntil(
+        [&]() { return rfh->HasTransientUserActivation(); }));
+    EXPECT_TRUE(rfh->HasTransientUserActivation());
+  }
+
+  content::RenderFrameHost* LoadPdf(const GURL& url) {
+    content::WebContents* web_contents =
+        browser()->tab_strip_model()->GetActiveWebContents();
+    if (!ui_test_utils::NavigateToURL(browser(), url)) {
+      return nullptr;
+    }
+    if (!pdf_extension_test_util::EnsurePDFHasLoaded(web_contents)) {
+      return nullptr;
+    }
+    return pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+  }
+
  protected:
 #if BUILDFLAG(IS_CHROMEOS)
   raw_ptr<MockChromeOsExtensionWrapper> extension_wrapper_ptr_ = nullptr;
@@ -1038,6 +1069,14 @@
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
                        OnImageDataRequested_IgnoresBadTreeId) {
   base::HistogramTester histogram_tester;
+
+  // In order to test the bad tree id, first ensure that reading mode is not
+  // on a privileged page.
+  ASSERT_TRUE(embedded_test_server()->Start());
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(),
+      embedded_test_server()->GetURL("/downloads/large_image.html")));
+
   handler_ = CreateHandler();
   auto tree_id = ui::AXTreeID::CreateNewAXTreeID();
   ui::AXNodeID node_id = 1;
@@ -1045,9 +1084,8 @@
   OnImageDataRequested(tree_id, node_id);
 
   histogram_tester.ExpectUniqueSample(
-      "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
-      "IsFromObservedTree",
-      false, 1);
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kNotObservedTree, 1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -1065,9 +1103,8 @@
   OnImageDataRequested(tree_id, 1);
 
   histogram_tester.ExpectUniqueSample(
-      "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
-      "IsFromObservedTree",
-      true, 1);
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kAllowed, 1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2453,14 +2490,13 @@
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
                        OnLinkClicked_RequiresTransientUserActivation) {
   const std::string histogram_name =
-      "Accessibility.ReadAnything.RendererRequestForLinkClick."
-      "IsFromObservedTree";
+      "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
   base::HistogramTester histogram_tester;
 
-  ASSERT_TRUE(
-      content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
-  test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
-  handler_ = CreateHandler();
+  ASSERT_TRUE(embedded_test_server()->Start());
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("/simple.html")));
+  SetUpHandler();
 
   content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
   content::RenderFrameHost* main_rfh =
@@ -2475,29 +2511,24 @@
   histogram_tester.ExpectTotalCount(histogram_name, 0);
 
   // Grant User Activation via simulated mouse click.
-  content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
-  content::SimulateMouseClick(web_contents_.get(), 0,
-                              blink::WebMouseEvent::Button::kLeft);
-  ASSERT_TRUE(base::test::RunUntil(
-      [&]() { return rfh->HasTransientUserActivation(); }));
-  EXPECT_TRUE(rfh->HasTransientUserActivation());
+  GrantUserActivation(rfh);
   handler_remote_->OnLinkClicked(tree_id, node_id);
   handler_remote_.FlushForTesting();
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
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 761e053..10dd524 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
@@ -39,6 +39,7 @@
 #include "chrome/common/read_anything/read_anything.mojom-shared.h"
 #include "chrome/common/read_anything/read_anything.mojom.h"
 #include "chrome/common/webui_url_constants.h"
+#include "chrome/test/base/chrome_test_path_utils.h"
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/language_detection/core/constants.h"
@@ -487,6 +488,36 @@
     handler_->OnTranslateDriverDestroyed(driver);
   }
 
+  void SetUpHandler() {
+    ASSERT_TRUE(
+        content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
+    test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
+    handler_ = CreateHandler();
+  }
+
+  void GrantUserActivation(content::RenderFrameHost* rfh) {
+    handler_remote_.FlushForTesting();
+    page_.receiver_.FlushForTesting();
+    content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
+    content::SimulateMouseClick(web_contents_.get(), 0,
+                                blink::WebMouseEvent::Button::kLeft);
+    ASSERT_TRUE(base::test::RunUntil(
+        [&]() { return rfh->HasTransientUserActivation(); }));
+    EXPECT_TRUE(rfh->HasTransientUserActivation());
+  }
+
+  content::RenderFrameHost* LoadPdf(const GURL& url) {
+    content::WebContents* web_contents =
+        browser()->tab_strip_model()->GetActiveWebContents();
+    if (!ui_test_utils::NavigateToURL(browser(), url)) {
+      return nullptr;
+    }
+    if (!pdf_extension_test_util::EnsurePDFHasLoaded(web_contents)) {
+      return nullptr;
+    }
+    return pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+  }
+
  protected:
 #if BUILDFLAG(IS_CHROMEOS)
   raw_ptr<MockChromeOsExtensionWrapper> extension_wrapper_ptr_ = nullptr;
@@ -1038,6 +1069,14 @@
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
                        OnImageDataRequested_IgnoresBadTreeId) {
   base::HistogramTester histogram_tester;
+
+  // In order to test the bad tree id, first ensure that reading mode is not
+  // on a privileged page.
+  ASSERT_TRUE(embedded_test_server()->Start());
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(),
+      embedded_test_server()->GetURL("/downloads/large_image.html")));
+
   handler_ = CreateHandler();
   auto tree_id = ui::AXTreeID::CreateNewAXTreeID();
   ui::AXNodeID node_id = 1;
@@ -1045,9 +1084,8 @@
   OnImageDataRequested(tree_id, node_id);
 
   histogram_tester.ExpectUniqueSample(
-      "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
-      "IsFromObservedTree",
-      false, 1);
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kNotObservedTree, 1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -1065,9 +1103,8 @@
   OnImageDataRequested(tree_id, 1);
 
   histogram_tester.ExpectUniqueSample(
-      "Accessibility.ReadAnything.RendererRequestForImageDataDownload."
-      "IsFromObservedTree",
-      true, 1);
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kAllowed, 1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2453,14 +2490,13 @@
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
                        OnLinkClicked_RequiresTransientUserActivation) {
   const std::string histogram_name =
-      "Accessibility.ReadAnything.RendererRequestForLinkClick."
-      "IsFromObservedTree";
+      "Accessibility.ReadAnything.RendererRequestForLinkClick.Result";
   base::HistogramTester histogram_tester;
 
-  ASSERT_TRUE(
-      content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
-  test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
-  handler_ = CreateHandler();
+  ASSERT_TRUE(embedded_test_server()->Start());
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("/simple.html")));
+  SetUpHandler();
 
   content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
   content::RenderFrameHost* main_rfh =
@@ -2475,29 +2511,24 @@
   histogram_tester.ExpectTotalCount(histogram_name, 0);
 
   // Grant User Activation via simulated mouse click.
-  content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
-  content::SimulateMouseClick(web_contents_.get(), 0,
-                              blink::WebMouseEvent::Button::kLeft);
-  ASSERT_TRUE(base::test::RunUntil(
-      [&]() { return rfh->HasTransientUserActivation(); }));
-  EXPECT_TRUE(rfh->HasTransientUserActivation());
+  GrantUserActivation(rfh);
   handler_remote_->OnLinkClicked(tree_id, node_id);
   handler_remote_.FlushForTesting();
-  histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
-                                      /*expected_bucket_count=*/1);
+  histogram_tester.ExpectUniqueSample(
+      histogram_name, ReadAnythingRendererRequestResult::kAllowed,
+      /*expected_bucket_count=*/1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
                        OnSelectionChange_RequiresTransientUserActivation) {
   const std::string histogram_name =
-      "Accessibility.ReadAnything.RendererRequestForSelection."
-      "IsFromObservedTree";
+      "Accessibility.ReadAnything.RendererRequestForSelection.Result";
   base::HistogramTester histogram_tester;
 
-  ASSERT_TRUE(
-      content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
-  test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
-  handler_ = CreateHandler();
+  ASSERT_TRUE(embedded_test_server()->Start());
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("/simple.html")));
+  SetUpHandler();
 
   content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
   content::RenderFrameHost* main_rfh =
@@ -2512,17 +2543,13 @@
   histogram_tester.ExpectTotalCount(histogram_name, 0);
 
   // Grant User Activation via simulated mouse click.
-  content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
-  content::SimulateMouseClick(web_contents_.get(), 0,
-                              blink::WebMouseEvent::Button::kLeft);
-  ASSERT_TRUE(base::test::RunUntil(
-      [&]() { return rfh->HasTransientUserActivation(); }));
-  EXPECT_TRUE(rfh->HasTransientUserActivation());
+  GrantUserActivation(rfh);
   handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
                                      /*focus_node_id=*/2, 5);
   handler_remote_.FlushForTesting();
-  histogram_tester.ExpectUniqueSample(histogram_name, /*sample=*/true,
-                                      /*expected_bucket_count=*/1);
+  histogram_tester.ExpectUniqueSample(
+      histogram_name, ReadAnythingRendererRequestResult::kAllowed,
+      /*expected_bucket_count=*/1);
 }
 
 IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
@@ -2556,10 +2583,7 @@
     return view && !view->GetSelectedText().empty();
   }));
 
-  ASSERT_TRUE(
-      content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
-  test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
-  handler_ = CreateHandler();
+  SetUpHandler();
 
   content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
   ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
@@ -2574,11 +2598,7 @@
             initial_seq);
 
   // Grant User Activation via simulated mouse click.
-  content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
-  content::SimulateMouseClick(web_contents_.get(), 0,
-                              blink::WebMouseEvent::Button::kLeft);
-  ASSERT_TRUE(base::test::RunUntil(
-      [&]() { return rfh->HasTransientUserActivation(); }));
+  GrantUserActivation(rfh);
 
   handler_remote_->OnCopy();
   handler_remote_.FlushForTesting();
@@ -2603,10 +2623,7 @@
     return view && !view->GetSelectedText().empty();
   }));
 
-  ASSERT_TRUE(
-      content::NavigateToURL(web_contents_.get(), GURL(url::kAboutBlankURL)));
-  test_web_ui_->set_render_frame_host(web_contents_->GetPrimaryMainFrame());
-  handler_ = CreateHandler();
+  SetUpHandler();
 
   content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
 
@@ -2618,11 +2635,7 @@
       main_contents->GetRenderWidgetHostView()->GetSelectedText().empty());
 
   // Grant User Activation via simulated mouse click.
-  content::SimulateEndOfPaintHoldingOnPrimaryMainFrame(web_contents_.get());
-  content::SimulateMouseClick(web_contents_.get(), 0,
-                              blink::WebMouseEvent::Button::kLeft);
-  ASSERT_TRUE(base::test::RunUntil(
-      [&]() { return rfh->HasTransientUserActivation(); }));
+  GrantUserActivation(rfh);
 
   handler_remote_->OnCollapseSelection();
   handler_remote_.FlushForTesting();
@@ -2633,4 +2646,152 @@
   }));
 }
 
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+                       PrivilegedScheme_DisallowsActions) {
+  base::HistogramTester histogram_tester;
+
+  ASSERT_TRUE(
+      ui_test_utils::NavigateToURL(browser(), GURL("chrome://version/")));
+  SetUpHandler();
+
+  content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+  content::RenderFrameHost* main_rfh =
+      browser()->GetActiveTabInterface()->GetContents()->GetPrimaryMainFrame();
+  ui::AXTreeID tree_id = main_rfh->GetAXTreeID();
+
+  GrantUserActivation(rfh);
+
+  // Link clicks are dropped without error.
+  handler_remote_->OnLinkClicked(tree_id, /*target_node_id=*/1);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForLinkClick.Result",
+      ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+      /*expected_bucket_count=*/1);
+
+  // Image data requests are dropped without error.
+  handler_remote_->OnImageDataRequested(tree_id, /*target_node_id=*/1);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+      /*expected_bucket_count=*/1);
+
+  // Scroll requests succeed on privileged schemes.
+  handler_remote_->ScrollToTargetNode(tree_id, /*target_node_id=*/1);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForScrollToTargetNode.Result",
+      ReadAnythingRendererRequestResult::kAllowed,
+      /*expected_bucket_count=*/1);
+
+  // Selection change requests are dropped without error.
+  handler_remote_->OnSelectionChange(tree_id, /*anchor_node_id=*/1, 0,
+                                     /*focus_node_id=*/2, 5);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForSelection.Result",
+      ReadAnythingRendererRequestResult::kDisallowedActionOnPageType,
+      /*expected_bucket_count=*/1);
+}
+
+IN_PROC_BROWSER_TEST_F(ReadAnythingUntrustedPageHandlerTest,
+                       Pdf_AllowsActions) {
+  base::HistogramTester histogram_tester;
+
+  ASSERT_TRUE(embedded_test_server()->Start());
+  content::RenderFrameHost* pdf_rfh =
+      LoadPdf(embedded_test_server()->GetURL("/pdf/test.pdf"));
+  ASSERT_TRUE(pdf_rfh);
+  SetUpHandler();
+
+  content::RenderFrameHost* rfh = web_contents_->GetPrimaryMainFrame();
+  GrantUserActivation(rfh);
+
+  // Link clicks succeed for PDF tree.
+  handler_remote_->OnLinkClicked(pdf_rfh->GetAXTreeID(), /*target_node_id=*/1);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForLinkClick.Result",
+      ReadAnythingRendererRequestResult::kAllowed,
+      /*expected_bucket_count=*/1);
+
+  // Image data requests succeed for PDF tree.
+  handler_remote_->OnImageDataRequested(pdf_rfh->GetAXTreeID(),
+                                        /*target_node_id=*/1);
+  handler_remote_.FlushForTesting();
+  histogram_tester.ExpectUniqueSample(
+      "Accessibility.ReadAnything.RendererRequestForImageDataDownload.Result",
+      ReadAnythingRendererRequestResult::kAllowed,
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential Sandbox Escape via Reading Mode WebUI Accessibility Actions

Flapjack, 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 compromised Reading Mode WebUI (chrome-untrusted://) can receive the full accessibility tree of privileged chrome:// pages when they are focused. The untrusted WebUI can then send an OnLinkClicked IPC message to programmatically trigger clicks on sensitive elements within the chrome:// page, leading to a potential sandbox escape.

Affected files:

  • chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.cc
  • chrome/browser/ui/webui/side_panel/read_anything/read_anything_untrusted_page_handler.h

Estimated timestamp from git blame: 2023-01-26

Summary

A potential high-severity sandbox escape exists in the Reading Mode (Read Anything) side panel implementation. If an attacker compromises the untrusted Reading Mode renderer (chrome-untrusted://read-anything-side-panel.top-chrome/), they can abuse the ReadAnythingUntrustedPageHandler Mojo interface to interact with highly privileged chrome:// pages (such as chrome://settings). By programmatically clicking sensitive elements on these pages, an attacker could disable security protections, alter proxy settings, or clear user data without user interaction.

Technical Analysis

The Reading Mode WebUI communicates with the browser process via ReadAnythingUntrustedPageHandler. To function, it monitors the user’s active tab using a ReadAnythingWebContentsObserver, which enables kReadAnythingAXMode (ui::kAXModeWebContentsOnly | ui::AXMode::kHTML) on the active WebContents. This forces the active tab’s renderer to serialize and transmit its full accessibility tree to the browser.

When a user navigates the active tab to a privileged page like chrome://settings, the following sequence allows a compromised untrusted renderer to exploit the privileged page:

  1. Leaking the Privileged Tree ID: ReadAnythingUntrustedPageHandler::OnActiveAXTreeIDChanged sends the AXTreeID of the active chrome://settings page to the untrusted renderer via page_->OnActiveAXTreeIDChanged(). While there is a scheme check (!url.SchemeIsHTTPOrHTTPS()) in RequestDomDistillerDistillation(), this check occurs after the tree ID is transmitted and only prevents text distillation, failing to protect the accessibility tree ID.
  2. Unfiltered Event Forwarding: The browser receives accessibility updates from the chrome://settings page and invokes ReadAnythingUntrustedPageHandler::AccessibilityEventReceived. This function unconditionally forwards the entire ui::AXUpdatesAndEvents payload (containing the serialized chrome://settings DOM) to the untrusted renderer.
  3. Initiating an Unauthorized Click: The attacker parses the leaked accessibility tree to find the AXNodeID of a sensitive control (e.g., a toggle to disable Safe Browsing) and invokes the Mojo method OnLinkClicked(target_tree_id, target_node_id).
  4. Insufficient Action Validation: In ReadAnythingUntrustedPageHandler::OnLinkClicked, the browser verifies the request by calling IsObservingTree(target_tree_id). This function merely checks if the requested tree ID belongs to the currently observed WebContents. Since the attacker provided the legitimate tree ID of the active chrome://settings tab, this check passes. No scheme validation is performed.
  5. Simulating the Click: The browser constructs a ui::AXActionData object with the action set to ax::mojom::Action::kDoDefault and routes it to the chrome://settings renderer. The privileged renderer simulates a full click sequence (pointer down, mouse down, click, etc.) on the target element, successfully executing the sensitive action and bypassing the sandbox boundary.

Potential Exploitation Steps

Note: These steps are suggested based on static code analysis; our tooling agent cannot execute a live proof of concept.

  1. An attacker gains arbitrary code execution in the chrome-untrusted://read-anything-side-panel.top-chrome/ renderer process (e.g., via a V8 vulnerability or UXSS).
  2. The attacker’s script listens for OnActiveAXTreeIDChanged and AccessibilityEventReceived Mojo messages.
  3. The attacker waits for the user to navigate the active tab to chrome://settings/.
  4. Upon receiving the AccessibilityEventReceived payload, the attacker parses the serialized accessibility tree to locate the AXNodeID for the “Safe Browsing” toggle switch.
  5. The attacker invokes UntrustedPageHandler::OnLinkClicked(target_tree_id, safe_browsing_node_id).
  6. The browser translates this into a synthetic click in the chrome://settings renderer, turning off Safe Browsing without any actual user interaction.

Suggested Fix

  1. Scheme Validation for Observation: ReadAnythingUntrustedPageHandler and ReadAnythingWebContentsObserver must strictly validate the URL scheme of the active tab. They should refuse to enable accessibility observation, serialize accessibility trees, or send OnActiveAXTreeIDChanged for pages with privileged schemes (e.g., chrome://, file://, chrome-extension://).
  2. Strict Action Filtering: Add explicit validation inside OnLinkClicked, OnSelectionChange, and other action handlers to ensure the target_tree_id resolves to an unprivileged web page (HTTP/HTTPS) before forwarding any actions to the renderer.

Evaluated with Chrome root at commit: ae14e9176a277261f3c963589b6b045c43cec1dc


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