CVE-2026-113319
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
LabelsServiceOnceHelperchrome/browser/accessibility/accessibility_labels_service.cc |
modified |
Files Changed
chrome/browser/chrome_browser_interface_binders.ccchrome/browser/accessibility/accessibility_labels_service.ccchrome/browser/accessibility/accessibility_labels_service_browsertest.cc
Patch
From 7fbdf62cf2526b1569beace4f7960e56e56b2bc6 Mon Sep 17 00:00:00 2001 From: Kavya Mehta <[email protected]> Date: Mon, 28 Sep 2026 08:48:12 -0700 Subject: [PATCH] [A11y] Gate Image Annotator Binding on kLabelImages AXMode This CL helps resolve a security vulnerability issue which prevents compromised renderers from bypassing the AccessibilityImageLabelsEnabled enterprise policy forcing the privileged browser process to send arbitrary attacker-controlled bytes to Google cloud endpoints. The CL achieves this by adding additional verification to the image_annotation::mojom::Annotator binder to ensure that the renderer can only access this service when the ui::AXMode::kLabelImages mode is enabled. The changes also preserves support for both global settings and one-shot per-tab ("Describe image just once") user flows. Design Doc: https://docs.google.com/document/d/1kDORO30MOqsILRY-XDB3x5KVzXFZ7MT7G5DK7ZjU1Vw/edit?resourcekey=0-Zvc9kWjWsjcsaUKHVsRoxw&tab=t.0 Fixed: 502689637 Change-Id: I1b88d234a8e2da0955f0a8a70f263d3cc8bee9dd Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8446198 Commit-Queue: Kavya Mehta <[email protected]> Reviewed-by: Lucas Radaelli <[email protected]> Reviewed-by: Ken Buchanan <[email protected]> Cr-Commit-Position: refs/heads/main@{#1706157} --- diff --git a/chrome/browser/accessibility/accessibility_labels_service.cc b/chrome/browser/accessibility/accessibility_labels_service.cc index 5c73c99d..686d1a62 100644 --- a/chrome/browser/accessibility/accessibility_labels_service.cc +++ b/chrome/browser/accessibility/accessibility_labels_service.cc @@ -25,6 +25,8 @@ #include "content/public/browser/browser_accessibility_state.h" #include "content/public/browser/scoped_accessibility_mode.h" #include "content/public/browser/web_contents.h" +#include "content/public/browser/web_contents_observer.h" +#include "content/public/browser/web_contents_user_data.h" #include "google_apis/google_api_keys.h" #include "services/image_annotation/image_annotation_service.h" #include "ui/accessibility/ax_action_data.h" @@ -108,6 +110,34 @@ const raw_ptr<Profile> profile_; }; +class LabelsServiceOnceHelper + : public content::WebContentsUserData<LabelsServiceOnceHelper>, + public content::WebContentsObserver { + public: + ~LabelsServiceOnceHelper() override = default; + + // content::WebContentsObserver: + void PrimaryPageChanged(content::Page& page) override { + web_contents()->RemoveUserData(UserDataKey()); + } + + private: + explicit LabelsServiceOnceHelper(content::WebContents* web_contents) + : content::WebContentsUserData<LabelsServiceOnceHelper>(*web_contents), + content::WebContentsObserver(web_contents), + scoped_accessibility_mode_( + content::BrowserAccessibilityState::GetInstance() + ->CreateScopedModeForWebContents(web_contents, + ui::AXMode::kLabelImages)) {} + + friend class content::WebContentsUserData<LabelsServiceOnceHelper>; + WEB_CONTENTS_USER_DATA_KEY_DECL(); + + std::unique_ptr<content::ScopedAccessibilityMode> scoped_accessibility_mode_; +}; + +WEB_CONTENTS_USER_DATA_KEY_IMPL(LabelsServiceOnceHelper); + } // namespace AccessibilityLabelsService::AccessibilityLabelsService(Profile* profile) @@ -185,6 +215,8 @@ return; } + LabelsServiceOnceHelper::CreateForWebContents(web_contents); + // TODO(grt): Use ScopedAccessibilityMode here targeting the WC and remove // the action. // For Android, we call through the JNI (see below) and send the web contents @@ -303,6 +335,8 @@ return; } + LabelsServiceOnceHelper::CreateForWebContents(web_contents); + // We only need to fire this event for the active page. ui::AXActionData action_data; action_data.action = ax::mojom::Action::kAnnotatePageImages; diff --git a/chrome/browser/chrome_browser_interface_binders.cc b/chrome/browser/chrome_browser_interface_binders.cc index 81a92b1..455cbfb53 100644 --- a/chrome/browser/chrome_browser_interface_binders.cc +++ b/chrome/browser/chrome_browser_interface_binders.cc @@ -61,6 +61,7 @@ #include "content/public/browser/browser_context.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/render_process_host.h" +#include "content/public/browser/web_contents.h" #include "content/public/common/content_features.h" #include "content/public/common/url_constants.h" #include "mojo/public/cpp/bindings/binder_map.h" @@ -80,6 +81,7 @@ #include "third_party/blink/public/mojom/prerender/prerender.mojom.h" #include "third_party/blink/public/public_buildflags.h" #include "ui/accessibility/accessibility_features.h" +#include "ui/accessibility/ax_mode.h" #if BUILDFLAG(ENABLE_UNHANDLED_TAP) #include "chrome/browser/android/contextualsearch/unhandled_tap_notifier_impl.h" // nogncheck crbug.com/40147906 @@ -193,6 +195,12 @@ void BindImageAnnotator( content::RenderFrameHost* frame_host, mojo::PendingReceiver<image_annotation::mojom::Annotator> receiver) { + content::WebContents* web_contents = + content::WebContents::FromRenderFrameHost(frame_host); + if (!web_contents || !web_contents->GetAccessibilityMode().has_mode( + ui::AXMode::kLabelImages)) { + return; + } AccessibilityLabelsServiceFactory::GetForProfile( Profile::FromBrowserContext( frame_host->GetProcess()->GetBrowserContext())) diff --git a/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc b/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc index 6a1153e..3cede6cd 100644 --- a/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc +++ b/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc @@ -3,6 +3,7 @@ // found in the LICENSE file. #include "build/build_config.h" +#include "chrome/browser/chrome_browser_interface_binders.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/browser_commands.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" @@ -11,8 +12,13 @@ #include "chrome/test/base/ui_test_utils.h" #include "components/prefs/pref_service.h" #include "content/public/browser/browser_accessibility_state.h" +#include "content/public/browser/render_frame_host.h" #include "content/public/test/accessibility_notification_waiter.h" #include "content/public/test/browser_test.h" +#include "mojo/public/cpp/bindings/binder_map.h" +#include "mojo/public/cpp/bindings/generic_pending_receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "services/image_annotation/public/mojom/image_annotation.mojom.h" #if BUILDFLAG(IS_CHROMEOS) #include "chrome/browser/ash/accessibility/accessibility_manager.h" @@ -40,7 +46,15 @@ AccessibilityLabelsBrowserTest& operator=( const AccessibilityLabelsBrowserTest&) = delete; +#if BUILDFLAG(IS_CHROMEOS) // InProcessBrowserTest overrides: + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + content::BrowserAccessibilityState::GetInstance() + ->SetActivationFromPlatformEnabled(true); + } +#endif + void TearDownOnMainThread() override { EnableScreenReader(false); } void EnableScreenReader(bool enabled) { @@ -61,7 +75,8 @@ screen_reader_override_.reset(); } else if (!screen_reader_override_) { screen_reader_override_.emplace(ui::AXMode::kWebContents | - ui::AXMode::kExtendedProperties); + ui::AXMode::kExtendedProperties | + ui::AXMode::kScreenReader); } #endif // BUILDFLAG(IS_CHROMEOS) } @@ -154,8 +169,51 @@ AccessibilityLabelsServiceFactory::GetForProfile(profile); labels_service->EnableLabelsServiceOnce(web_contents); - // EnableOnce does not change the mode flags for the WebContents, so it's not - // trivial to verify that the change took place. + ax_mode = web_contents->GetAccessibilityMode(); + EXPECT_TRUE(ax_mode.has_mode(ui::AXMode::kLabelImages)); + + // Navigating away to a new page in the same tab should destroy the + // ScopedAccessibilityMode for the previous page. + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), GURL("data:text/html,<html><body>Second Page</body></html>"))); + ax_mode = web_contents->GetAccessibilityMode(); + EXPECT_FALSE(ax_mode.has_mode(ui::AXMode::kLabelImages)); + + // Requesting "just once" again on the new page should re-enable it. + labels_service->EnableLabelsServiceOnce(web_contents); + ax_mode = web_contents->GetAccessibilityMode(); + EXPECT_TRUE(ax_mode.has_mode(ui::AXMode::kLabelImages)); +} + +IN_PROC_BROWSER_TEST_F(AccessibilityLabelsBrowserTest, + AnnotatorBindingGatedOnAXMode) { + EnableScreenReader(true); + content::WebContents* web_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + content::RenderFrameHost* frame = web_contents->GetPrimaryMainFrame(); + + mojo::BinderMapWithContext<content::RenderFrameHost*> map; + chrome::internal::PopulateChromeFrameBinders(&map, frame); + + // 1. Without kLabelImages, binding is dropped. + { + mojo::Remote<image_annotation::mojom::Annotator> remote; + mojo::GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver()); + ASSERT_TRUE(map.TryBind(frame, &receiver)); + remote.FlushForTesting(); + EXPECT_FALSE(remote.is_connected()); + } + + // 2. With kLabelImages enabled via pref, binding succeeds. + { + browser()->GetProfile()->GetPrefs()->SetBoolean( + prefs::kAccessibilityImageLabelsEnabled, true); + mojo::Remote<image_annotation::mojom::Annotator> remote; + mojo::GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver()); + ASSERT_TRUE(map.TryBind(frame, &receiver)); + remote.FlushForTesting(); + EXPECT_TRUE(remote.is_connected()); + } } #endif
Regression Test / PoC
diff --git a/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc b/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc
index 6a1153e..3cede6cd 100644
--- a/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc
+++ b/chrome/browser/accessibility/accessibility_labels_service_browsertest.cc
@@ -3,6 +3,7 @@
// found in the LICENSE file.
#include "build/build_config.h"
+#include "chrome/browser/chrome_browser_interface_binders.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
@@ -11,8 +12,13 @@
#include "chrome/test/base/ui_test_utils.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/browser_accessibility_state.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/test/accessibility_notification_waiter.h"
#include "content/public/test/browser_test.h"
+#include "mojo/public/cpp/bindings/binder_map.h"
+#include "mojo/public/cpp/bindings/generic_pending_receiver.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "services/image_annotation/public/mojom/image_annotation.mojom.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "chrome/browser/ash/accessibility/accessibility_manager.h"
@@ -40,7 +46,15 @@
AccessibilityLabelsBrowserTest& operator=(
const AccessibilityLabelsBrowserTest&) = delete;
+#if BUILDFLAG(IS_CHROMEOS)
// InProcessBrowserTest overrides:
+ void SetUpOnMainThread() override {
+ InProcessBrowserTest::SetUpOnMainThread();
+ content::BrowserAccessibilityState::GetInstance()
+ ->SetActivationFromPlatformEnabled(true);
+ }
+#endif
+
void TearDownOnMainThread() override { EnableScreenReader(false); }
void EnableScreenReader(bool enabled) {
@@ -61,7 +75,8 @@
screen_reader_override_.reset();
} else if (!screen_reader_override_) {
screen_reader_override_.emplace(ui::AXMode::kWebContents |
- ui::AXMode::kExtendedProperties);
+ ui::AXMode::kExtendedProperties |
+ ui::AXMode::kScreenReader);
}
#endif // BUILDFLAG(IS_CHROMEOS)
}
@@ -154,8 +169,51 @@
AccessibilityLabelsServiceFactory::GetForProfile(profile);
labels_service->EnableLabelsServiceOnce(web_contents);
- // EnableOnce does not change the mode flags for the WebContents, so it's not
- // trivial to verify that the change took place.
+ ax_mode = web_contents->GetAccessibilityMode();
+ EXPECT_TRUE(ax_mode.has_mode(ui::AXMode::kLabelImages));
+
+ // Navigating away to a new page in the same tab should destroy the
+ // ScopedAccessibilityMode for the previous page.
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), GURL("data:text/html,<html><body>Second Page</body></html>")));
+ ax_mode = web_contents->GetAccessibilityMode();
+ EXPECT_FALSE(ax_mode.has_mode(ui::AXMode::kLabelImages));
+
+ // Requesting "just once" again on the new page should re-enable it.
+ labels_service->EnableLabelsServiceOnce(web_contents);
+ ax_mode = web_contents->GetAccessibilityMode();
+ EXPECT_TRUE(ax_mode.has_mode(ui::AXMode::kLabelImages));
+}
+
+IN_PROC_BROWSER_TEST_F(AccessibilityLabelsBrowserTest,
+ AnnotatorBindingGatedOnAXMode) {
+ EnableScreenReader(true);
+ content::WebContents* web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ content::RenderFrameHost* frame = web_contents->GetPrimaryMainFrame();
+
+ mojo::BinderMapWithContext<content::RenderFrameHost*> map;
+ chrome::internal::PopulateChromeFrameBinders(&map, frame);
+
+ // 1. Without kLabelImages, binding is dropped.
+ {
+ mojo::Remote<image_annotation::mojom::Annotator> remote;
+ mojo::GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver());
+ ASSERT_TRUE(map.TryBind(frame, &receiver));
+ remote.FlushForTesting();
+ EXPECT_FALSE(remote.is_connected());
+ }
+
+ // 2. With kLabelImages enabled via pref, binding succeeds.
+ {
+ browser()->GetProfile()->GetPrefs()->SetBoolean(
+ prefs::kAccessibilityImageLabelsEnabled, true);
+ mojo::Remote<image_annotation::mojom::Annotator> remote;
+ mojo::GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver());
+ ASSERT_TRUE(map.TryBind(frame, &receiver));
+ remote.FlushForTesting();
+ EXPECT_TRUE(remote.is_connected());
+ }
}
#endif
Original Bug Report
Policy Bypass and OAuth Abuse via Unconditional Annotator Binding
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: The image_annotation::mojom::Annotator interface is unconditionally bound for all frames, allowing a compromised renderer to bypass the AccessibilityImageLabelsEnabled enterprise policy. The renderer can supply arbitrary data that the browser process will send to Google APIs. If alternate routing is enabled, the browser acts as a confused deputy, sending the data as an OAuth-authenticated request using the user’s identity.
Affected files:
chrome/browser/accessibility/accessibility_labels_service.ccchrome/browser/chrome_browser_interface_binders.cc
Estimated timestamp from git blame: 2025-07-02
Description
The image_annotation::mojom::Annotator Mojo interface is unconditionally exposed to all renderer frames, regardless of user preferences or enterprise policies.
In chrome/browser/chrome_browser_interface_binders.cc, the PopulateChromeFrameBinders function registers the binder unconditionally:
map->Add<image_annotation::mojom::Annotator>(&BindImageAnnotator);
When a renderer requests this interface, AccessibilityLabelsService::BindImageAnnotator (chrome/browser/accessibility/accessibility_labels_service.cc) handles the binding. Unlike other accessibility features (such as Live Caption, which checks prefs::kLiveCaptionEnabled), this function fails to check if the feature is actually enabled via AccessibilityLabelsService::IsEnabled() or the AccessibilityImageLabelsEnabled enterprise policy before binding the remote.
Impact
Because the interface is bound without checks, a compromised renderer can bypass intended policies and force the browser process to make network requests:
- Immediate Unconsented Request: Simply binding the interface causes the
ImageAnnotationServiceto instantiate anAnnotatorobject. The constructor ofAnnotatorimmediately and unconditionally callsFetchServerLanguages(), which triggers an HTTP request to Google servers using the Chrome API key, even if the user or administrator has explicitly disabled the feature. - Arbitrary Data Exfiltration / Confused Deputy: The compromised renderer can call
AnnotateImage()and provide a Mojo remote to a renderer-hostedImageProcessor. The browser will request image data from this processor. The attacker can return arbitrary bytes and spoofed dimensions (to bypass theIsWithinDescPolicycheck). After a 300ms throttle, the browser process will batch and send this attacker-supplied data.- Path A (OAuth Abuse): If the
features::kImageDescriptionsAlternateRoutingflag is enabled (currently active in field trials), the request is routed through the Manta service (manta::AnchovyProvider). TheAnchovyProviderutilizes anEndpointFetcherconfigured for OAuth (signin::ConsentLevel::kSignin). The browser process will send the attacker’s arbitrary bytes tohttps://aratea-pa.googleapis.com/generateauthenticated with the user’s primary signed-in identity. This is a severe violation of the post-renderer security model (Sandbox Escape / Confused Deputy). - Path B (Policy Bypass): If alternate routing is disabled, the browser sends the attacker’s data to
https://ckintersect-pa.googleapis.com/v1/intersect/pixelsusing the global Chrome API key. While not using OAuth, this completely bypasses theAccessibilityImageLabelsEnabledenterprise policy designed to prevent sending data to Google.
- Path A (OAuth Abuse): If the
Potential Reproduction Steps
Note: These are suggested steps based on code analysis; our tooling agent does not yet have the ability to run code to verify this PoC.
- Achieve arbitrary code execution in a sandboxed renderer process.
- Ensure the browser profile has “Get Image Descriptions from Google” disabled (the default) or explicitly disabled via enterprise policy.
- From the compromised renderer, request the
image_annotation.mojom.Annotatorinterface viaBrowserInterfaceBroker. - Call
Annotator.AnnotateImage(source_id='test', page_language='en', image_processor=<renderer-hosted impl>). - When the browser invokes
ImageProcessor.GetJpgImageData(), return a payload of arbitrary bytes and dimensions (e.g., width=200, height=200). - Observe the browser process initiating a network request to Google APIs (either
ckintersect-pa.googleapis.comoraratea-pa.googleapis.com) carrying the payload, despite the feature being disabled.
Suggested Fix
Modify AccessibilityLabelsService::BindImageAnnotator to check the feature status before binding the interface or creating the service:
void AccessibilityLabelsService::BindImageAnnotator(
mojo::PendingReceiver<image_annotation::mojom::Annotator> receiver) {
if (!IsEnabled()) {
return;
}
// ... existing binding logic ...
}
Alternatively, implement the preference check directly in the binder function within chrome_browser_interface_binders.cc.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.