CVE-2026-95301
Overview
Background
- `AutomationEventRouter::BindForRenderer`
- The browser-process entry point that binds a renderer’s
mojom::RendererAutomationRegistryinterface so the renderer can drive the accessibility automation API. - `ProcessMap`
- A per-
BrowserContextregistry that maps a renderer process to the enabled extension it hosts, queried here viaGetEnabledExtensionByProcessID. - `AutomationInfo`
- The manifest handler data that indicates an extension declared the
automationpermission, retrieved withAutomationInfo::Get. - `bad_message::ReceivedBadMessage`
- The extensions IPC-safety helper that terminates a renderer process sending an unauthorized or malformed message.
Root Cause Analysis
The Mojo interface exposed by AutomationEventRouter::BindForRenderer was bound for any renderer that requested it, without confirming that the calling process actually hosted an enabled extension holding the automation permission. Because the browser trusted the renderer-initiated bind request implicitly, a non-extension renderer (a web page) or an extension process lacking the permission could obtain the privileged RendererAutomationRegistry interface, violating the invariant that automation registration is reserved to authorized automation extensions.
The fix resolves the calling process with content::RenderProcessHost::FromID, looks up the hosted extension through ProcessMap::GetEnabledExtensionByProcessID, and confirms AutomationInfo::Get returns non-null before proceeding. If any of these checks fail, the code calls bad_message::ReceivedBadMessage with the new AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING reason and returns, so the interface is never bound for an unauthorized process. This works because authorization is now enforced in the browser process, which cannot be bypassed by a compromised or misbehaving renderer.
Attack Path
- Obtain a renderer
An attacker controls or navigates a renderer process, either an ordinary web page or an extension process that never declared the
automationpermission. - Request the automation interface
From that renderer, the attacker issues the Mojo request that reaches
AutomationEventRouter::BindForRenderer, which previously performed no caller authorization check. - Receive the privileged binding
The browser binds
mojom::RendererAutomationRegistryfor the unauthorized process, exposing accessibility automation registration it should not have. - Abuse automation access The attacker uses the improperly granted interface to interact with the automation event routing that is meant to be restricted to permitted extensions, escalating beyond its intended privilege boundary.
Impact Assessment
automation permission, constituting a missing-authorization privilege boundary bypass. The only precondition is the ability to issue the bind request from a renderer; no automation permission or extension trust is required in the unpatched code. After the fix, such an attempt instead terminates the offending renderer via a bad-message kill.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifextensions/browser/api/automation_internal/automation_event_router.cc |
modified |
Files Changed
chrome/browser/extensions/extension_security_exploit_browsertest.cctools/metrics/histograms/metadata/stability/enums.xmlextensions/browser/bad_message.hextensions/browser/api/automation_internal/automation_event_router.cc
Audit Directions
- Renderer-initiated Mojo bindsReview other browser-side
BindForRenderer-style entry points that bind privileged interfaces and confirm each verifies the calling process’s extension and permission before binding. - Permission-gated APIs without process checksLook for APIs guarded only by a documented permission but not enforced against
ProcessMap::GetEnabledExtensionByProcessID, since the renderer identity, not just the API surface, must be validated. - Missing bad-message killsAudit browser IPC handlers that silently return on unexpected callers instead of calling
bad_message::ReceivedBadMessage, as silent failure can hide missing authorization that a security-sensitive path should reject loudly.
Patch
From b70c13072d35070057ca8731d17e2dbe8ba5211a Mon Sep 17 00:00:00 2001 From: David Tseng <[email protected]> Date: Tue, 18 Aug 2026 11:07:53 -0700 Subject: [PATCH] [Extensions] Validate process authorization in AutomationEventRouter::BindForRenderer This CL adds a check against ProcessMap and AutomationInfo in AutomationEventRouter::BindForRenderer. If the calling process is not hosting an enabled extension with automation permission, the request is rejected and the renderer process is terminated with a bad message. Fixed: 540265100 TAG=agy CONV=0fba6596-b871-40d8-8ac4-a6d2854a4d9c Change-Id: I107134d7419d5b797a3068932972feb097bb3f9c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8259787 Reviewed-by: Andrea Orru <[email protected]> Reviewed-by: Alexei Svitkine <[email protected]> Reviewed-by: Devlin Cronin <[email protected]> Commit-Queue: David Tseng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1681537} --- diff --git a/extensions/browser/api/automation_internal/automation_event_router.cc b/extensions/browser/api/automation_internal/automation_event_router.cc index 160691e..c4bb2ae3 100644 --- a/extensions/browser/api/automation_internal/automation_event_router.cc +++ b/extensions/browser/api/automation_internal/automation_event_router.cc @@ -16,12 +16,15 @@ #include "build/chromeos_buildflags.h" #include "extensions/browser/api/automation_internal/automation_internal_api_delegate.h" #include "extensions/browser/api/extensions_api_client.h" +#include "extensions/browser/bad_message.h" #include "extensions/browser/event_router.h" #include "extensions/browser/process_manager.h" +#include "extensions/browser/process_map.h" #include "extensions/browser/service_worker/worker_id.h" #include "extensions/common/api/automation_internal.h" #include "extensions/common/extension.h" #include "extensions/common/extension_id.h" +#include "extensions/common/manifest_handlers/automation.h" #include "mojo/public/cpp/bindings/associated_remote.h" #include "mojo/public/cpp/bindings/pending_associated_remote.h" #include "ui/accessibility/ax_action_data.h" @@ -349,6 +352,27 @@ RenderProcessHostId render_process_id, mojo::PendingAssociatedReceiver< extensions::mojom::RendererAutomationRegistry> receiver) { + content::RenderProcessHost* host = + content::RenderProcessHost::FromID(render_process_id); + if (!host) { + return; + } + + ProcessMap* process_map = ProcessMap::Get(host->GetBrowserContext()); + const Extension* extension = + process_map + ? process_map->GetEnabledExtensionByProcessID(render_process_id) + : nullptr; + + const AutomationInfo* automation_info = + extension ? AutomationInfo::Get(extension) : nullptr; + + if (!automation_info) { + bad_message::ReceivedBadMessage( + host, bad_message::AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING); + return; + } + AutomationEventRouter* router = AutomationEventRouter::GetInstance(); CHECK(router); diff --git a/extensions/browser/bad_message.h b/extensions/browser/bad_message.h index df3f2181..ce97edc7 100644 --- a/extensions/browser/bad_message.h +++ b/extensions/browser/bad_message.h @@ -80,6 +80,7 @@ WRA_INVALID_LISTENER_REGISTRATION = 40, WER_UNEXPECTED_EVENT_HANDLING_DONE = 41, WER_INVALID_EXTENSION_ID = 42, + AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING = 43, // Please add new elements here. The naming convention is abbreviated class // name (e.g. ExtensionHost becomes EH) plus a unique description of the // reason. After making changes, you MUST update histograms.xml by running: diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml index cfeff6ec..b72aadf1 100644 --- a/tools/metrics/histograms/metadata/stability/enums.xml +++ b/tools/metrics/histograms/metadata/stability/enums.xml @@ -603,6 +603,7 @@ <int value="40" label="WRA_INVALID_LISTENER_REGISTRATION"/> <int value="41" label="WER_UNEXPECTED_EVENT_HANDLING_DONE"/> <int value="42" label="WER_INVALID_EXTENSION_ID"/> + <int value="43" label="AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING"/> </enum> <enum name="BadMessageReasonGuestView"> diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc index 0fe3eb6..ffbac84 100644 --- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc +++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc @@ -30,6 +30,7 @@ #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/test_navigation_observer.h" +#include "extensions/browser/api/automation_internal/automation_event_router.h" #include "extensions/browser/api/messaging/channel_endpoint.h" #include "extensions/browser/api/messaging/message_service.h" #include "extensions/browser/api/storage/storage_api.h" @@ -47,6 +48,7 @@ #include "extensions/common/api/messaging/port_id.h" #include "extensions/common/constants.h" #include "extensions/common/extension_features.h" +#include "extensions/common/mojom/automation_registry.mojom.h" #include "extensions/common/mojom/event_router.mojom.h" #include "extensions/common/mojom/frame.mojom-test-utils.h" #include "extensions/common/mojom/message_port.mojom.h" @@ -2216,4 +2218,53 @@ run_loop.Run(); } +// Tests that attempting to bind the automation registry interface from an +// unauthorized process (such as a web page) results in the process being +// terminated. +IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest, + Automation_BindAutomationFromWebPage) { + GURL test_page_url = + embedded_test_server()->GetURL("foo.com", "/title1.html"); + auto* web_contents = GetActiveWebContents(); + ASSERT_TRUE(NavigateToURL(web_contents, test_page_url)); + + content::RenderProcessHost* main_frame_process = + web_contents->GetPrimaryMainFrame()->GetProcess(); + + RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame_process); + + mojo::AssociatedRemote<mojom::RendererAutomationRegistry> registry; + AutomationEventRouter::BindForRenderer( + main_frame_process->GetID(), + registry.BindNewEndpointAndPassDedicatedReceiver()); + + EXPECT_EQ(bad_message::AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING, + kill_waiter.Wait()); +} + +// Tests that attempting to bind the automation registry interface from an +// extension process that does not have the "automation" manifest permission +// results in the process being terminated. +IN_PROC_BROWSER_TEST_F( + ExtensionSecurityExploitBrowserTest, + Automation_BindAutomationFromExtensionWithoutPermission) { + InstallTestExtensions(); + GURL extension_page_url = active_extension().GetResourceURL("page.html"); + auto* web_contents = GetActiveWebContents(); + ASSERT_TRUE(NavigateToURL(web_contents, extension_page_url)); + + content::RenderProcessHost* extension_process = + web_contents->GetPrimaryMainFrame()->GetProcess(); + + RenderProcessHostBadIpcMessageWaiter kill_waiter(extension_process); + + mojo::AssociatedRemote<mojom::RendererAutomationRegistry> registry; + AutomationEventRouter::BindForRenderer( + extension_process->GetID(), + registry.BindNewEndpointAndPassDedicatedReceiver()); + + EXPECT_EQ(bad_message::AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING, + kill_waiter.Wait()); +} + } // namespace extensions
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 0fe3eb6..ffbac84 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -30,6 +30,7 @@
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/test_navigation_observer.h"
+#include "extensions/browser/api/automation_internal/automation_event_router.h"
#include "extensions/browser/api/messaging/channel_endpoint.h"
#include "extensions/browser/api/messaging/message_service.h"
#include "extensions/browser/api/storage/storage_api.h"
@@ -47,6 +48,7 @@
#include "extensions/common/api/messaging/port_id.h"
#include "extensions/common/constants.h"
#include "extensions/common/extension_features.h"
+#include "extensions/common/mojom/automation_registry.mojom.h"
#include "extensions/common/mojom/event_router.mojom.h"
#include "extensions/common/mojom/frame.mojom-test-utils.h"
#include "extensions/common/mojom/message_port.mojom.h"
@@ -2216,4 +2218,53 @@
run_loop.Run();
}
+// Tests that attempting to bind the automation registry interface from an
+// unauthorized process (such as a web page) results in the process being
+// terminated.
+IN_PROC_BROWSER_TEST_F(ExtensionSecurityExploitBrowserTest,
+ Automation_BindAutomationFromWebPage) {
+ GURL test_page_url =
+ embedded_test_server()->GetURL("foo.com", "/title1.html");
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+ content::RenderProcessHost* main_frame_process =
+ web_contents->GetPrimaryMainFrame()->GetProcess();
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame_process);
+
+ mojo::AssociatedRemote<mojom::RendererAutomationRegistry> registry;
+ AutomationEventRouter::BindForRenderer(
+ main_frame_process->GetID(),
+ registry.BindNewEndpointAndPassDedicatedReceiver());
+
+ EXPECT_EQ(bad_message::AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING,
+ kill_waiter.Wait());
+}
+
+// Tests that attempting to bind the automation registry interface from an
+// extension process that does not have the "automation" manifest permission
+// results in the process being terminated.
+IN_PROC_BROWSER_TEST_F(
+ ExtensionSecurityExploitBrowserTest,
+ Automation_BindAutomationFromExtensionWithoutPermission) {
+ InstallTestExtensions();
+ GURL extension_page_url = active_extension().GetResourceURL("page.html");
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, extension_page_url));
+
+ content::RenderProcessHost* extension_process =
+ web_contents->GetPrimaryMainFrame()->GetProcess();
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(extension_process);
+
+ mojo::AssociatedRemote<mojom::RendererAutomationRegistry> registry;
+ AutomationEventRouter::BindForRenderer(
+ extension_process->GetID(),
+ registry.BindNewEndpointAndPassDedicatedReceiver());
+
+ EXPECT_EQ(bad_message::AER_INVALID_PROCESS_FOR_AUTOMATION_BINDING,
+ kill_waiter.Wait());
+}
+
} // namespace extensions
Original Bug Report
Site Isolation bypass: the ungated extensions RendererAutomationRegistry handler lets a compromised web renderer read cross-origin/cross-profile accessibility trees (page text, frame URLs).
Report description
Site Isolation bypass: the ungated extensions RendererAutomationRegistry handler lets a compromised web renderer read cross-origin/cross-profile accessibility trees (page text, frame URLs).
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
Extensions automation registry: a compromised web renderer joins the accessibility broadcast and reads cross-origin / cross-profile page content (Site Isolation bypass)
TL;DR
The browser-side handler for the extensions associated interface extensions.mojom.RendererAutomationRegistry (BindAutomation) performs no caller validation, yet it is registered for every RenderFrameHost — including ordinary web frames. A compromised, site-locked web renderer can therefore register its own ax.mojom.Automation receiver into the process-global accessibility broadcast set.
Impact: while any legitimate chrome.automation consumer (e.g. ChromeVox on ChromeOS) is active, the browser broadcasts every tab’s full serialized accessibility tree — visible text, non-password form-field values, ARIA, cross-site frame URLs — to that rogue receiver. That is a cross-origin content read from a renderer Site Isolation is supposed to contain. Because the handler is a process-global singleton with no BrowserContext key, in source the reach also spans profiles including Incognito (the reproducer exercises the cross-origin case within one profile).
The sibling interface extensions.mojom.EventRouter, registered by the identical mechanism two lines below, validates the caller process (ProcessMap + CanAccessDataForOrigin + bad-message); automation does not.
Severity
Chrome VRP High per the rubric (https://chromium.googlesource.com/chromium/src/+/main/docs/security/severity-guidelines.md) — a Site Isolation bypass / cross-site data disclosure (severity-guidelines.md, “Web Platform & UX”: “Site Isolation bypasses: … Cross-site data disclosure”). The affected data (cross-origin page text, form-field values, URLs) is exactly the asset docs/security/compromised-renderers.md commits to protect from a compromised renderer.
Affected component
extensions/browser/api/automation_internal/automation_event_router.cc (AutomationEventRouter::BindAutomation / BindForRenderer / DispatchAccessibilityEvents); registration at chrome/browser/extensions/chrome_content_browser_client_extensions_part_bindings.cc:70-72. Verified on origin/main (Chrome 152, 2026-07). Default Stable configuration; no experimental flag.
Attack scenario
- Attacker: code executing in a compromised renderer process — the standard Site Isolation threat model (
docs/security/compromised-renderers.md: a compromised renderer “can forge malicious IPC messages, impersonate a Chrome Extension content script”). The process is site-locked to the attacker’s own origin. - What the attacker gains: read access to the full accessibility tree (visible text, non-password form-field values, ARIA, cross-site frame URLs) of other origins and other tabs — data the attacker’s process is isolated from. The handler is a process-global singleton with no profile key, so in source the reach also spans other profiles including Incognito (the attached reproducer exercises the cross-origin case within one profile).
- How: the renderer requests the associated interface
extensions.mojom.RendererAutomationRegistryfrom its own frame and callsBindAutomation, passing anAutomationreceiver it controls. The browser adds that receiver to the process-globalautomation_remote_set_with no check. From then on the browser pushes every serialized tree to it. - Prior access required: a single prior renderer compromise (the premise of the Site Isolation threat model). No extension permission, no user interaction on the attacker side.
- Victim assumption: a legitimate
chrome.automationconsumer is active. On ChromeOS this is routine (ChromeVox, Select-to-Speak, Switch Access arechrome.automationcomponent extensions). Perseverity-guidelines.md, that a bug “applies only to … those users running with accessibility features enabled” is explicitly not a mitigating factor.
The defect — an unenforced trust assumption its own sibling enforces
Extension associated interfaces are intentionally exposed to all renderer processes, because extension content scripts run inside web renderers. The security model therefore relies on browser-side per-caller validation. docs/security/ax-tree-security-guidelines.md states the invariant directly:
> “The tree … holds sensitive content that should not be passed back down to renderers to avoid data leaks between them.” > “Some fields … represent sensitive data from the origin … and should not be shared into unrelated renderer processes.”
EventRouter enforces this: EventRouter::BindForRenderer stores the calling render_process_id as receiver context, and every message validates it via ProcessMap::Get(browser_context_)->Contains(extension_id, process) + ChildProcessSecurityPolicy::CanAccessDataForOrigin, killing an unauthorized caller with ReportBadMessage (extensions/browser/event_router.cc:408-465).
AutomationEventRouter::BindForRenderer stores the identical render_process_id context (automation_event_router.cc:355) — but BindAutomation never reads it:
void AutomationEventRouter::BindAutomation(
mojo::PendingAssociatedRemote<ax::mojom::Automation> automation) {
automation_remote_set_.Add(std::move(automation)); // no caller check
}
The permission/tree-scope bookkeeping that should gate delivery (listeners_, populated only by the permission-gated Register/EnableTree extension functions) is a separate structure the dispatch loop never consults:
void AutomationEventRouter::DispatchAccessibilityEvents(...) {
...
for (const auto& remote : automation_remote_set_) // every remote
remote->DispatchAccessibilityEvents(tree_id, updates, ...); // every tree
}
So the browser passes the full cross-origin/cross-profile tree “back down to renderers” — the precise leak the security guidelines forbid — because the caller is never validated.
Reachability — a compromised web frame’s request routes to the unvalidated handler
The attacker’s sole capability is a compromised renderer forging an associated-interface request by name (docs/security/compromised-renderers.md). Below is the entire browser-side path from that request to the unvalidated BindAutomation, quoted from origin/main (Chrome 152).
1. The extensions associated interfaces are registered for every RenderFrameHost, unconditionally — chrome/browser/chrome_content_browser_client_receiver_bindings.cc:400-407:
void ChromeContentBrowserClient::
RegisterAssociatedInterfaceBindersForRenderFrameHost(
content::RenderFrameHost& render_frame_host,
blink::AssociatedInterfaceRegistry& associated_registry) {
for (auto& ep : extra_parts_) {
ep->ExposeInterfacesToRendererForRenderFrameHost(render_frame_host,
associated_registry);
}
No frame-type / scheme / origin filter guards the loop. (Chrome gates registration by extension scheme where it means to — the service-worker broker path at :372-383 early-returns if (!site.SchemeIs(extensions::kExtensionScheme)) — and deliberately does not here, because extension content scripts execute inside ordinary web renderers.)
2. That set includes RendererAutomationRegistry, bound with the frame’s own process id, no gate — chrome/browser/extensions/chrome_content_browser_client_extensions_part_bindings.cc:66-75:
content::ChildProcessId render_process_id = frame_host.GetProcess()->GetID();
...
associated_registry.AddInterface<mojom::RendererAutomationRegistry>(
base::BindRepeating(&AutomationEventRouter::BindForRenderer,
render_process_id));
...
associated_registry.AddInterface<mojom::EventRouter>(
base::BindRepeating(&EventRouter::BindForRenderer, render_process_id));
render_process_id is the browser’s own view of the calling frame’s process — auto-injected into the handler, unforgeable by the attacker. The sibling EventRouter is registered just below by the identical idiom (the elided ... is the RendererHost registration and an #if BUILDFLAG(ENABLE_EXTENSIONS) guarding automation — a build flag that is on for every desktop/ChromeOS Chrome, i.e. wherever the automation system exists at all, not a runtime gate): the exposure is intentional and shared, the caller id is trustworthy; the only question is whether the handler reads it.
3. A live frame’s request reaches the handler by name lookup, with no security gate — content/browser/renderer_host/render_frame_host_impl.cc:10299-10318 (GetAssociatedInterface, the browser end of the frame’s AssociatedInterfaceProvider):
void RenderFrameHostImpl::GetAssociatedInterface(
const std::string& name,
mojo::PendingAssociatedReceiver<blink::mojom::AssociatedInterface>
receiver) {
CHECK(associated_registry_);
if (mojo_binder_policy_applier_ && // prerender capability control only
!mojo_binder_policy_applier_->ApplyPolicyToAssociatedBinder(name)) {
return;
}
mojo::ScopedInterfaceEndpointHandle handle = receiver.PassHandle();
if (associated_registry_->TryBindInterface(name, &handle)) {
return;
}
}
The only pre-bind check, mojo_binder_policy_applier_, is non-null only while prerendering (null for a live, committed frame); no process-type, scheme, or origin check exists. associated_registry_ is the frame’s single registry, created in SetUpMojoConnection and handed to the step-1 embedder hook — render_frame_host_impl_interface_binders.cc:176 then :298-302 (GetContentClient()->browser()->RegisterAssociatedInterfaceBindersForRenderFrameHost(*this, *associated_registry_)) — so it is exactly the registry steps 1-2 populate. TryBindInterface(name, …) therefore resolves the step-2 callback: AutomationEventRouter::BindForRenderer(render_process_id, receiver).
4. The handler stores the caller id, then never reads it — extensions/browser/api/automation_internal/automation_event_router.cc:347-356 then :342-345:
void AutomationEventRouter::BindForRenderer(
RenderProcessHostId render_process_id,
mojo::PendingAssociatedReceiver<
extensions::mojom::RendererAutomationRegistry> receiver) {
AutomationEventRouter* router = AutomationEventRouter::GetInstance();
CHECK(router);
router->receivers_.Add(router, std::move(receiver), render_process_id);
}
void AutomationEventRouter::BindAutomation(
mojo::PendingAssociatedRemote<ax::mojom::Automation> automation) {
automation_remote_set_.Add(std::move(automation)); // caller id ignored
}
The sibling proves this is an omission, not the design. EventRouter stores the same context and its handlers consume it — RenderProcessHost::FromID(receivers_.current_context()) (event_router.cc:374) → ProcessMap::Get(...)->Contains(extension_id, process.GetID()) (:411-412) / ChildProcessSecurityPolicy::CanAccessDataForOrigin(...) (:420-422) → receivers_.ReportBadMessage(...) on failure (:447). Automation stores the identical context (:355) and reads it nowhere; a grep of automation_internal/ for CanAccessDataForOrigin|ProcessMap|ReportBadMessage is empty — the missing per-caller check is the sole defense.
Delivery is then an unfiltered broadcast to the process-global set — automation_event_router.cc:253-268:
void AutomationEventRouter::DispatchAccessibilityEvents(...) {
if (remote_router_) { ...; return; } // dead: RegisterRemoteRouter has no callers
for (const auto& remote : automation_remote_set_) {
remote->DispatchAccessibilityEvents(tree_id, updates, mouse_location, events);
}
}
listeners_, tree ids, and origin are never consulted; the set is a process-global base::Singleton (:32-37) with no BrowserContext key, so the reach spans profiles in source. The payload (services/accessibility/public/mojom/automation.mojom AXTreeUpdate) carries kName (text), kValue (non-password form-field values — password fields are masked in the accessibility tree), kUrl (cross-site frame URLs), placeholders, and descriptions.
Reproduction
RendererAutomationRegistry is an associated Mojo interface, reachable only through the frame’s AssociatedInterfaceProvider (render_frame_host_impl.cc:10299), not through BrowserInterfaceBroker. Blink’s MojoJS exposes only non-associated bindInterface — there is no way to acquire this interface (or pass the associated Automation remote) from page JavaScript, so no HTML/JS reproducer is constructible. A faithful compromised-renderer reproducer is therefore a browsertest driving the real production path, at the same fidelity Chromium’s own security test uses for the sibling: extension_security_exploit_browsertest.cc’s EventRouterExploitTest binds EventRouter::BindForRenderer(web_process->GetID(), receiver) “as though process were the renderer sending the message” (:1221-1228, :1256-1258), driving a real foo.com web-page process. The attached automation_registry_exploit_browsertest.cc applies that harness to automation, adding no production code and modifying no existing test:
- Navigate an attacker tab to
evil.com; capture its render process. - Navigate a victim tab to
victim.comcarrying a marker secret in an accessible node; stand up a normalchrome.automationconsumer over it (a stand-in for ChromeVox). Assert the two processes differ (Site Isolation active). - As that compromised renderer,
AutomationEventRouter::BindForRenderer(attacker_process_id, …)+BindAutomation(rogue_receiver)— the browser-side-static fidelity above (the routing that delivers a real web frame’s request to this handler is traced verbatim in §Reachability). - Trigger an accessibility event on
victim.com. - Assert the rogue receiver, hosted for the
evil.comweb renderer, receivesvictim.com’s tree id and the marker secret — with no bad-message / renderer kill (contrast:EventRouterExploitTestasserts a kill for the analogous spoof).
Build/run: add the file to chrome/browser/extensions/BUILD.gn test_sources, then autoninja -C out/Default browser_tests && out/Default/browser_tests --gtest_filter=AutomationRegistryExploitTest.* --single-process-tests.
Suggested remediation
Validate the caller in BindAutomation, mirroring the sibling. The calling render_process_id is already stored as receiver context (automation_event_router.cc:355); retrieve it (RenderProcessHost::FromID(receivers_.current_context())) and reject a process that does not host an extension holding the automation permission — the same authority EventRouter consults (util::CanRendererActOnBehalfOfExtension / ProcessMap) — with receivers_.ReportBadMessage(...). Defense in depth: scope DispatchAccessibilityEvents per-remote against the listeners_ permissions instead of broadcasting to the whole automation_remote_set_.
Scope considerations
- Realistic impact is ChromeOS-centric. ChromeVox / Select-to-Speak / Switch Access are
chrome.automationcomponent extensions routinely active on ChromeOS, so the precondition is satisfied there and the leak spans origins and profiles. On desktop theautomationpermission is allow-list-restricted, so a consumer must be one of those trusted/component extensions; the code-level crossing is real on all platforms, but the routine real-world exposure is ChromeOS. - Primitive is read-only. The ungated interface only lets the attacker receive; the accessibility action/write path (
performAction) is a separate permission-gated extension function and is not reachable here. - Unconditional low-value signal. Independent of any consumer, the rogue receiver also gets
DispatchTreeDestroyedEventfor every document teardown browser-wide (opaque tree ids) — a cross-profile activity-timing oracle. Noted for completeness; the content read above is the substantive issue.
Anticipated objections
- “Your reproducer calls the browser-side
BindForRendererdirectly, not a real renderer IPC — does a live web frame actually reach it?” Yes; §Reachability quotes the entire path verbatim: registration runs for every RenderFrameHost with no frame-type/scheme filter (receiver_bindings.cc:400-407), bindsRendererAutomationRegistrywith the frame’s auto-injected process id (extensions_part_bindings.cc:66-72), and a live frame’sGetAssociatedInterfacerequest reaches the handler through a name lookup with no security gate (render_frame_host_impl.cc:10299-10318) — no intermediate extension-frame/process-type check anywhere. The browser-side-static call is not a shortcut around a check; it is the exact fidelity Chromium’s own security suite uses for the identical interface class (EventRouterExploitTestbindsEventRouter::BindForRenderer(web_process->GetID(), receiver)“as thoughprocesswere the renderer”), applied to the sibling that omits the validation. The runtime PASS then confirms the handler accepts it with no bad-message. - “Broadcast-to-all is intended; the automation renderer bindings filter by tree id.” Renderer-side filtering is a functional convenience, not a boundary against a compromised renderer, which keeps every tree; the browser performs no per-remote/origin filter at dispatch. The sibling
EventRoutershows the intended enforcement point is browser-side. - “Requires a compromised renderer.” That is the premise of the Site Isolation threat model;
compromised-renderers.mdlists cross-site frame text and URLs as assets the browser must protect from exactly this attacker.
Disclosure
Reported privately to Google only; standard 14-week post-fix disclosure. Happy to adjust the timeline on request.
Impact analysis
Impact analysis
Confidentiality — cross-origin data disclosure (Site Isolation bypass). A single compromised,
site-locked renderer gains a persistent read feed of other origins’ serialized accessibility trees:
visible page text (kName), non-password form-field values (kValue; password fields are masked in the
AX tree), ARIA, and cross-site frame URLs (kUrl). This is exactly the “Text … of cross-site frames”
and “Full URL … of cross-site frames” that docs/security/compromised-renderers.md commits to protect
from a compromised renderer, and the “sensitive data from the origin [that] should not be shared into
unrelated renderer processes” that docs/security/ax-tree-security-guidelines.md forbids passing back
down to renderers.
Worst case. While any legitimate chrome.automation consumer is active, the rogue receiver reads
the full accessibility tree of every tab that consumer serializes — a broad cross-origin content read
from a process Site Isolation is meant to contain. The handler is a process-global singleton with no
BrowserContext key, so in source the reach also spans other profiles, including Incognito.
Affected population. The precondition — an active chrome.automation consumer — is routine on
ChromeOS, where ChromeVox, Select-to-Speak, and Switch Access are component extensions; so the realistic
exposure is ChromeOS-centric. Per the severity rubric, that a bug affects “only … users running with
accessibility features enabled” is explicitly not a mitigating factor.
Bounds. The primitive is read-only — the accessibility action/write path (performAction) is a
separate permission-gated function and is not reachable here. The cross-profile reach is established at
source level; the attached reproducer exercises the cross-origin case within one profile.
Severity. Chrome VRP High per the severity rubric — a Site Isolation bypass / cross-site data disclosure.
The cause
What version of Chrome have you found the security issue in?
152.0.7961.0 (main channel; commit 2a43c44b, 2026-07-28) — verified: the attached browsertest reproducer PASSes on this build against otherwise-pristine production code. The affected code (BindAutomation with no caller check, the RendererAutomationRegistry registration, and the validating EventRouter sibling) is core extensions/accessibility infrastructure enabled by default — no chrome://flags entry, no Finch/experimental gate, not DISABLED_BY_DEFAULT — so it is not a dev-channel-only feature.
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Sensitive data exposure
How would you like to be publicly acknowledged for your report?
OGINOME Tomohito
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/+/HEAD/extensions/browser/api/automation_internal/automation_event_router.cc
- https://chromium.googlesource.com/chromium/src/+/main/docs/security/severity-guidelines.md