Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker496614553
Fix commit5cca17e7f0c5 (chromium/src) +449/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
EventRouterExploitTest
chrome/browser/extensions/extension_security_exploit_browsertest.cc
modified

Files Changed

  • chrome/browser/extensions/extension_security_exploit_browsertest.cc
From 5cca17e7f0c540b6c8b6472e1c04e0aa5a65440d Mon Sep 17 00:00:00 2001
From: Tim Judkins <[email protected]>
Date: Fri, 17 Apr 2026 12:46:44 -0700
Subject: [PATCH] [Extensions] Add extra validation and bad messages to EventRouter

This CL adds some hardening to registering listeners with the extensions
EventRouter. Specifically:
 * When an extension_id is specified we check that the render process is
   either an extension process for that ID or a process which that
   extension ID has injected a content or user script into.
 * When a listener_url is specified we verify that the process can
   access the origin for the URL.
 * When a scope_url is specified (when registering for a service worker)
   we verify the process can access the origin of the scope URL.

If any of these checks fail we emit a bad message and kill the renderer.
Also adds related tests to cover all these cases and verify the bad
message emitted.

Bug: 496614553
Change-Id: I289056d78432fc8e96e1b47b24b0711641bc3d65
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7745162
Reviewed-by: Andrea Orru <[email protected]>
Reviewed-by: Nasko Oskov <[email protected]>
Commit-Queue: Tim <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1616788}
---

diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 85d5859..d6de6e61 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -39,10 +39,13 @@
 #include "extensions/browser/service_worker/service_worker_host.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension_features.h"
+#include "extensions/common/mojom/event_router.mojom.h"
 #include "extensions/common/mojom/frame.mojom-test-utils.h"
 #include "extensions/common/mojom/service_worker_host.mojom-test-utils.h"
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
+#include "mojo/public/cpp/bindings/associated_remote.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -958,4 +961,369 @@
       *main_frame->GetProcess(), extension_b->id()));
 }
 
+class EventRouterExploitTest : public ExtensionSecurityExploitBrowserTest {
+ public:
+  EventRouterExploitTest() = default;
+
+  void SetUpOnMainThread() override {
+    ExtensionSecurityExploitBrowserTest::SetUpOnMainThread();
+    // This installs two extensions (`active_extension()` and
+    // `spoofed_extension()`) which are identical except for differing extension
+    // IDs. In tests below we treat them so `active_extension()` should have
+    // access to do things, while `spoofed_extension()` should not.
+    InstallTestExtensions();
+  }
+};
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying a listener URL will work for a URL the process has
+// access to (i.e. the actual URL of the page), but fail for one it does not
+// have access.
+IN_PROC_BROWSER_TEST_F(EventRouterExploitTest,
+                       AddListenerForMainThread_BadListenerUrl) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try to add a listener for the actual URL of the page. This should be
+    // fine.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewListenerUrl(test_page_url);
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try to add a listener for "chrome://settings". It should trigger a bad
+    // message as the process doesn't have access to that origin.
+    mojo::test::BadMessageObserver bad_message_observer;
+
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewListenerUrl(GURL("chrome://settings"));
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_EQ(
+        "Tried to add an event listener for an unauthorized listener URL.",
+        bad_message_observer.WaitForBadMessage());
+  }
+}
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying an extension ID will work for an extension that has
+// injected a content script, but fail for one that has not.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddListenerForMainThread_SpoofedExtensionIdWithContentScript) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  // Simulate a content script from `active_extension()` being injected into the
+  // process.
+  ScriptInjectionTracker::AddExtensionThatRanContentScriptsInProcessForTesting(
+      *main_frame_process, active_extension_id());
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try to add a listener for `active_extension_id()`. This should be fine
+    // because the extension will appear to have run a content script in the
+    // page.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(active_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try to add a listener for `spoofed_extension_id()`. It should trigger a
+    // bad message because the content script was injected for
+    // `active_extension_id()`.
+    mojo::test::BadMessageObserver bad_message_observer;
+
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_EQ(
+        "Tried to add an event listener for an unauthorized extension ID.",
+        bad_message_observer.WaitForBadMessage());
+  }
+}
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying an extension ID will work for an extension that has
+// injected a user script, but fail for one that has not.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddListenerForMainThread_SpoofedExtensionIdWithUserScript) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  // Simulate a user script from `active_extension()` being injected into the
+  // process.
+  ScriptInjectionTracker::AddExtensionThatRanUserScriptsInProcessForTesting(
+      *main_frame_process, active_extension_id());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 85d5859..d6de6e61 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -39,10 +39,13 @@
 #include "extensions/browser/service_worker/service_worker_host.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension_features.h"
+#include "extensions/common/mojom/event_router.mojom.h"
 #include "extensions/common/mojom/frame.mojom-test-utils.h"
 #include "extensions/common/mojom/service_worker_host.mojom-test-utils.h"
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
+#include "mojo/public/cpp/bindings/associated_remote.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -958,4 +961,369 @@
       *main_frame->GetProcess(), extension_b->id()));
 }
 
+class EventRouterExploitTest : public ExtensionSecurityExploitBrowserTest {
+ public:
+  EventRouterExploitTest() = default;
+
+  void SetUpOnMainThread() override {
+    ExtensionSecurityExploitBrowserTest::SetUpOnMainThread();
+    // This installs two extensions (`active_extension()` and
+    // `spoofed_extension()`) which are identical except for differing extension
+    // IDs. In tests below we treat them so `active_extension()` should have
+    // access to do things, while `spoofed_extension()` should not.
+    InstallTestExtensions();
+  }
+};
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying a listener URL will work for a URL the process has
+// access to (i.e. the actual URL of the page), but fail for one it does not
+// have access.
+IN_PROC_BROWSER_TEST_F(EventRouterExploitTest,
+                       AddListenerForMainThread_BadListenerUrl) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try to add a listener for the actual URL of the page. This should be
+    // fine.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewListenerUrl(test_page_url);
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try to add a listener for "chrome://settings". It should trigger a bad
+    // message as the process doesn't have access to that origin.
+    mojo::test::BadMessageObserver bad_message_observer;
+
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewListenerUrl(GURL("chrome://settings"));
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_EQ(
+        "Tried to add an event listener for an unauthorized listener URL.",
+        bad_message_observer.WaitForBadMessage());
+  }
+}
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying an extension ID will work for an extension that has
+// injected a content script, but fail for one that has not.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddListenerForMainThread_SpoofedExtensionIdWithContentScript) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  // Simulate a content script from `active_extension()` being injected into the
+  // process.
+  ScriptInjectionTracker::AddExtensionThatRanContentScriptsInProcessForTesting(
+      *main_frame_process, active_extension_id());
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try to add a listener for `active_extension_id()`. This should be fine
+    // because the extension will appear to have run a content script in the
+    // page.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(active_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try to add a listener for `spoofed_extension_id()`. It should trigger a
+    // bad message because the content script was injected for
+    // `active_extension_id()`.
+    mojo::test::BadMessageObserver bad_message_observer;
+
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_EQ(
+        "Tried to add an event listener for an unauthorized extension ID.",
+        bad_message_observer.WaitForBadMessage());
+  }
+}
+
+// Tests that attempting to add a main thread listener for the process of a
+// webpage by specifying an extension ID will work for an extension that has
+// injected a user script, but fail for one that has not.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddListenerForMainThread_SpoofedExtensionIdWithUserScript) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  // Simulate a user script from `active_extension()` being injected into the
+  // process.
+  ScriptInjectionTracker::AddExtensionThatRanUserScriptsInProcessForTesting(
+      *main_frame_process, active_extension_id());
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try to add a listener for `active_extension_id()`. This should be fine
+    // because the extension will appear to have run a user script on the page.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(active_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try to add a listener for `spoofed_extension_id()`. It should trigger a
+    // bad message because the user script was injected for
+    // `active_extension_id()`.
+    mojo::test::BadMessageObserver bad_message_observer;
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id());
+    event_listener->event_name = "test.eventName";
+
+    event_router->AddListenerForMainThread(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_EQ(
+        "Tried to add an event listener for an unauthorized extension ID.",
+        bad_message_observer.WaitForBadMessage());
+  }
+}
+
+// Tests that attempting to add a service worker listener to the process of a
+// webpage and specifying an extension ID as the `listener_owners` will fail.
+IN_PROC_BROWSER_TEST_F(EventRouterExploitTest,
+                       AddListenerForServiceWorker_BadExtensionIdForWebPage) {
+  GURL test_page_url =
+      embedded_test_server()->GetURL("foo.com", "/title1.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  auto event_listener = mojom::EventListener::New();
+  event_listener->listener_owner =
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id());
+  event_listener->event_name = "test.eventName";
+  event_listener->service_worker_context = mojom::ServiceWorkerContext::New();
+  event_listener->service_worker_context->scope_url = spoofed_extension().url();
+  event_listener->service_worker_context->version_id = 1;
+  event_listener->service_worker_context->thread_id = 1;
+
+  event_router->AddListenerForServiceWorker(std::move(event_listener));
+  event_router.FlushForTesting();
+
+  EXPECT_EQ("Tried to add an event listener for an unauthorized extension ID.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that attempting to add a service worker listener to the process of an
+// extension page works for that extension, but specifying an extension ID of
+// another extension will fail.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddListenerForServiceWorker_BadExtensionIdForExtensionPage) {
+  // Navigate to an extension page, so the associated process is authorized for
+  // `active_extension_id()`.
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  EXPECT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+
+  mojo::AssociatedRemote<mojom::EventRouter> event_router;
+  EventRouter::BindForRenderer(
+      main_frame_process->GetID(),
+      event_router.BindNewEndpointAndPassDedicatedReceiver());
+
+  EXPECT_FALSE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+      "test.eventName"));
+
+  {
+    // Try adding a listener for the extension that owns the page. This should
+    // be fine.
+    auto event_listener = mojom::EventListener::New();
+    event_listener->listener_owner =
+        mojom::EventListenerOwner::NewExtensionId(active_extension_id());
+    event_listener->event_name = "test.eventName";
+    event_listener->service_worker_context = mojom::ServiceWorkerContext::New();
+    event_listener->service_worker_context->scope_url =
+        active_extension().url();
+    event_listener->service_worker_context->version_id = 1;
+    event_listener->service_worker_context->thread_id = 1;
+
+    event_router->AddListenerForServiceWorker(std::move(event_listener));
+    event_router.FlushForTesting();
+
+    EXPECT_TRUE(EventRouter::Get(profile())->HasNonLazyEventListenerForTesting(
+        "test.eventName"));
+  }
+
+  {
+    // Try adding a listener but specifying a `listener_owner` for a different
+    // extension ID. This should trigger a bad message.
+    mojo::test::BadMessageObserver bad_message_observer;
+
+    auto event_listener = mojom::EventListener::New();
+    // Pass the `listener_owner` as the ID of the `spoofed_extension()`.
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Renderer Sandbox Bypass via Spoofed EventRouter Listener URL

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A compromised renderer can spoof its origin when registering for Extension EventRouter events via Mojo. This allows an attacker to bypass origin and context type restrictions, intercepting sensitive API events (e.g., fileSystemProvider, sockets.tcp) intended for privileged contexts like chrome-untrusted://terminal/.

Affected files:

  • extensions/browser/events/event_dispatch_helper.cc
  • extensions/browser/event_router.cc
  • extensions/browser/process_map.cc
  • chrome/browser/ash/file_system_provider/request_dispatcher_impl.cc

Estimated timestamp from git blame: 2024-08-01

Summary

A vulnerability in the Extensions EventRouter allows a compromised renderer process to spoof its origin during event listener registration. By providing an arbitrary listener_url to the mojom::EventRouter::AddListenerForMainThread Mojo interface, the renderer can bypass origin-based event restrictions and context type validation. This allows the attacker to intercept sensitive events, such as file system operations (fileSystemProvider) and raw socket data (sockets.tcp), intended for specific WebUI contexts like the ChromeOS Terminal app.

Note: These are potential steps and findings as our setup does not currently have the ability to run code or provide a working proof of concept.

Technical Details

  1. Lack of Origin Validation in Event Registration: The mojom::EventRouter interface is exposed to all render frames (via ChromeContentBrowserClientExtensionsPart::ExposeInterfacesToRendererForRenderFrameHost). In extensions/browser/event_router.cc, the AddListenerForMainThread method accepts the listener_url provided by the renderer without validating that the renderer’s ChildProcessSecurityPolicy is authorized for that origin. It only verifies that the URL is syntactically valid.

  2. Origin Restriction Bypass: When sensitive events are dispatched using DispatchEventToURL(restrict_to_url, event) (e.g., from RequestDispatcherImpl::DispatchRequest for the Terminal app), the system checks if the listener’s origin matches the restriction. In extensions/browser/events/event_dispatch_helper.cc, ListenerMeetsRestrictions compares the restrict_to_url against the spoofed listener->listener_url(). Because both origins match (chrome-untrusted://terminal/), this check incorrectly passes.

  3. Context Type Misclassification: During dispatch, EventDispatchHelper calls ProcessMap::GetMostLikelyContextType (extensions/browser/process_map.cc) to determine the context type. Because the listener was added via URL (meaning the extension pointer is null), GetMostLikelyContextType bases its decision entirely on the scheme of the spoofed URL. Seeing the chrome-untrusted:// scheme, it returns mojom::ContextType::kUntrustedWebUi, bypassing any validation against the actual process’s site instance.

  4. Feature Availability Bypass: With the origin matching and the context misclassified as kUntrustedWebUi, EventDispatchHelper::CheckFeatureAvailability consults _api_features.json. Since APIs like fileSystemProvider and sockets.tcp are allowed for kUntrustedWebUi contexts matching chrome-untrusted://terminal/*, the check succeeds. The sensitive event data is then dispatched via IPC to the attacker’s compromised regular web renderer process.

Potential Attacker Steps to Trigger the Vulnerability

  1. Gain arbitrary code execution within a regular web renderer process (e.g., browsing https://evil.com).
  2. Use Mojo to bind the mojom::EventRouter interface, which is exposed to all render frames.
  3. Send an AddListenerForMainThread IPC message. Set the event_name to a sensitive API event (e.g., fileSystemProvider.onReadFileRequested or sockets.tcp.onReceive) and spoof the listener_owner URL to chrome-untrusted://terminal/.
  4. Wait for a legitimate system action to trigger the event (e.g., the user interacts with an SFTP mount in the ChromeOS Terminal).
  5. The browser process will dispatch the event containing sensitive cross-origin data directly to the compromised renderer process, bypassing the sandbox.

Suggested Fix

Add origin validation in EventRouter::AddListenerForMainThread and EventRouter::AddListenerForServiceWorker when processing a listener_url. Specifically, before calling AddEventListenerForURL, verify that the RenderProcessHost is authorized to commit or represent the provided listener_url (e.g., using ChildProcessSecurityPolicy::GetInstance()->CanCommitURL(process_id, listener_url)). Additionally, ProcessMap::GetMostLikelyContextType could be hardened to verify the actual process type when extension is null, rather than relying solely on the URL scheme.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker
Links in the report