CVE-2026-113259
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/frame/local_dom_window.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/frame/local_dom_window_test.cc |
modified | |
XProbethird_party/blink/web_tests/fast/events/window-event-isolated-world.html |
modified | |
attributeChangedCallbackthird_party/blink/web_tests/fast/events/window-event-isolated-world.html |
modified | |
forthird_party/blink/web_tests/fast/events/window-event-isolated-world.html |
modified |
Files Changed
third_party/blink/renderer/core/frame/local_dom_window_test.ccthird_party/blink/web_tests/fast/events/window-event-isolated-world.htmlthird_party/blink/renderer/core/frame/local_dom_window.cc
Patch
From 32f1a1b7f2066c319f0afe45faf46d84bc208554 Mon Sep 17 00:00:00 2001 From: Mike Taylor <[email protected]> Date: Wed, 16 Sep 2026 01:28:05 -0700 Subject: [PATCH] Ensure window.event respects the world of the current event Event listeners are only invoked when an event can be dispatched in the listener's DOM wrapper world. However, the window.event getter previously returned the current event regardless of the caller's world, allowing code executing in a different world to inspect events scoped to another world. Update LocalDOMWindow::event() to return undefined when the current event cannot be dispatched in the calling world. This aligns the getter with event dispatch semantics and matches existing world-scoping checks in ErrorEvent and PromiseRejectionEvent. TAG=agy Bug: 554008013 Change-Id: I8ce8d42cd6a42564555704bd05cd3a95a93371cb Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8416961 Reviewed-by: Kent Tamura <[email protected]> Commit-Queue: Mike Taylor <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698730} --- diff --git a/third_party/blink/renderer/core/frame/local_dom_window.cc b/third_party/blink/renderer/core/frame/local_dom_window.cc index 0269587..225f487a 100644 --- a/third_party/blink/renderer/core/frame/local_dom_window.cc +++ b/third_party/blink/renderer/core/frame/local_dom_window.cc @@ -319,8 +319,11 @@ } ScriptValue LocalDOMWindow::event(ScriptState* script_state) { - // If current event is null, return undefined. - if (!current_event_) { + // If current event is null, or belongs to a different world than the + // calling one (mirroring the check made before event listeners are + // invoked, see JSBasedEventListener::Invoke()), return undefined. + if (!current_event_ || + !current_event_->CanBeDispatchedInWorld(script_state->World())) { return ScriptValue(script_state->GetIsolate(), v8::Undefined(script_state->GetIsolate())); } diff --git a/third_party/blink/renderer/core/frame/local_dom_window_test.cc b/third_party/blink/renderer/core/frame/local_dom_window_test.cc index cd0e3b8c..d5ffc55 100644 --- a/third_party/blink/renderer/core/frame/local_dom_window_test.cc +++ b/third_party/blink/renderer/core/frame/local_dom_window_test.cc @@ -40,6 +40,11 @@ #include "third_party/blink/public/web/web_window_features.h" #include "third_party/blink/renderer/bindings/core/v8/isolated_world_csp.h" #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_promise_rejection_event_init.h" +#include "third_party/blink/renderer/core/dom/events/event.h" +#include "third_party/blink/renderer/core/event_type_names.h" +#include "third_party/blink/renderer/core/events/error_event.h" +#include "third_party/blink/renderer/core/events/promise_rejection_event.h" #include "third_party/blink/renderer/core/execution_context/agent.h" #include "third_party/blink/renderer/core/frame/csp/content_security_policy.h" #include "third_party/blink/renderer/core/frame/local_frame.h" @@ -48,6 +53,8 @@ #include "third_party/blink/renderer/core/testing/core_unit_test_helper.h" #include "third_party/blink/renderer/core/testing/mock_policy_container_host.h" #include "third_party/blink/renderer/core/testing/page_test_base.h" +#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h" +#include "third_party/blink/renderer/platform/bindings/script_state.h" #include "third_party/blink/renderer/platform/heap/garbage_collected.h" #include "third_party/blink/renderer/platform/scheduler/public/event_loop.h" #include "third_party/blink/renderer/platform/testing/unit_test_helpers.h" @@ -335,6 +342,65 @@ net::StorageAccessApiStatus::kAccessViaAPI); } +TEST_F(LocalDOMWindowTest, EventRespectsWorldOfCurrentEvent) { + LocalFrame* frame = &GetFrame(); + LocalDOMWindow* window = frame->DomWindow(); + ScriptState* main_world_script_state = ToScriptStateForMainWorld(frame); + v8::Isolate* isolate = main_world_script_state->GetIsolate(); + + constexpr int kIsolatedWorldId = 1; + DOMWrapperWorld* isolated_world = + DOMWrapperWorld::EnsureIsolatedWorld(isolate, kIsolatedWorldId); + ASSERT_TRUE(isolated_world->IsIsolatedWorld()); + ScriptState* isolated_world_script_state = + ToScriptState(frame, *isolated_world); + + auto event_visible_in = [window](ScriptState* script_state) { + ScriptState::Scope scope(script_state); + return !window->event(script_state).IsUndefined(); + }; + + // An event with no world binding is visible in every world. + window->SetCurrentEvent(Event::Create(event_type_names::kChange)); + EXPECT_TRUE(event_visible_in(main_world_script_state)); + EXPECT_TRUE(event_visible_in(isolated_world_script_state)); + + // An ErrorEvent bound to an isolated world is only visible in that world. + { + ScriptState::Scope scope(isolated_world_script_state); + window->SetCurrentEvent(ErrorEvent::Create(isolated_world_script_state)); + } + EXPECT_FALSE(event_visible_in(main_world_script_state)); + EXPECT_TRUE(event_visible_in(isolated_world_script_state)); + + // An ErrorEvent bound to the main world is only visible in the main world. + { + ScriptState::Scope scope(main_world_script_state); + window->SetCurrentEvent(ErrorEvent::Create(main_world_script_state)); + } + EXPECT_TRUE(event_visible_in(main_world_script_state)); + EXPECT_FALSE(event_visible_in(isolated_world_script_state)); + + // A PromiseRejectionEvent bound to an isolated world is only visible in + // that world. + { + ScriptState::Scope scope(isolated_world_script_state); + v8::Local<v8::Promise::Resolver> resolver = + v8::Promise::Resolver::New(isolated_world_script_state->GetContext()) + .ToLocalChecked(); + auto* init = PromiseRejectionEventInit::Create(); + init->setPromise( + MemberScriptPromise<IDLAny>(isolate, resolver->GetPromise())); + window->SetCurrentEvent(PromiseRejectionEvent::Create( + isolated_world_script_state, event_type_names::kUnhandledrejection, + init)); + } + EXPECT_FALSE(event_visible_in(main_world_script_state)); + EXPECT_TRUE(event_visible_in(isolated_world_script_state)); + + window->SetCurrentEvent(nullptr); +} + TEST_F(LocalDOMWindowTest, CanExecuteScriptsDuringDetach) { GetFrame().Loader().DetachDocument(); EXPECT_NE(GetFrame().DomWindow(), nullptr); diff --git a/third_party/blink/web_tests/fast/events/window-event-isolated-world.html b/third_party/blink/web_tests/fast/events/window-event-isolated-world.html new file mode 100644 index 0000000..40676b7 --- /dev/null +++ b/third_party/blink/web_tests/fast/events/window-event-isolated-world.html @@ -0,0 +1,89 @@ +<!DOCTYPE html> +<title>window.event is only exposed to the world the event belongs to</title> +<script src="../../resources/testharness.js"></script> +<script src="../../resources/testharnessreport.js"></script> +<x-probe id="probe"></x-probe> +<script> +'use strict'; + +// Records what the main world observes as window.event when a custom element +// reaction re-enters the main world synchronously from inside an event +// listener that runs in another world. +const mainWorldSaw = new Map(); + +class XProbe extends HTMLElement { + static get observedAttributes() { + return ['trigger']; + } + attributeChangedCallback(name, oldValue, newValue) { + mainWorldSaw.set(newValue, window.event); + } +} +customElements.define('x-probe', XProbe); + +function isolatedWorldScript() { + const probe = document.getElementById('probe'); + const report = (key, value) => { + document.documentElement.setAttribute('data-isolated-' + key, value); + }; + + const cases = [ + ['error-event', new ErrorEvent('test-error')], + [ + 'rejection-event', + new PromiseRejectionEvent('test-rejection', {promise: Promise.resolve()}) + ], + ['custom-event', new CustomEvent('test-custom')], + ]; + for (const [key, event] of cases) { + const listener = () => { + report(key, String(window.event === event)); + // Re-enter the main world synchronously through a custom element + // reaction while this world's event is the window's current event. + probe.setAttribute('trigger', key); + }; + document.addEventListener(event.type, listener); + document.dispatchEvent(event); + document.removeEventListener(event.type, listener); + } +} + +test(() => { + assert_own_property(window, 'testRunner', + 'this test requires content_shell'); + testRunner.evaluateScriptInIsolatedWorld( + 1, `(${isolatedWorldScript.toString()})();`); + + const isolatedWorldSaw = key => + document.documentElement.getAttribute('data-isolated-' + key); + assert_equals(isolatedWorldSaw('error-event'), 'true', + 'the isolated world sees its own ErrorEvent'); + assert_equals(isolatedWorldSaw('rejection-event'), 'true', + 'the isolated world sees its own PromiseRejectionEvent'); + assert_equals(isolatedWorldSaw('custom-event'), 'true', + 'the isolated world sees its own CustomEvent'); + + assert_equals(mainWorldSaw.get('error-event'), undefined, + 'an ErrorEvent bound to an isolated world is not exposed ' + + 'to the main world'); + assert_equals(mainWorldSaw.get('rejection-event'), undefined, + 'a PromiseRejectionEvent bound to an isolated world is not ' + + 'exposed to the main world'); + assert_true(mainWorldSaw.get('custom-event') instanceof CustomEvent, + 'an event with no world binding stays visible across worlds'); + assert_equals(mainWorldSaw.get('custom-event').type, 'test-custom'); +}, 'window.event does not expose events bound to another world'); + +test(() => { + let seen; + const event = new ErrorEvent('test-main-error'); + const listener = () => { + seen = window.event; + }; + document.addEventListener('test-main-error', listener); + document.dispatchEvent(event); + document.removeEventListener('test-main-error', listener); + assert_equals(seen, event, + 'window.event exposes an ErrorEvent to its own world'); +}, 'window.event still exposes same-world events'); +</script>
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/frame/local_dom_window_test.cc b/third_party/blink/renderer/core/frame/local_dom_window_test.cc
index cd0e3b8c..d5ffc55 100644
--- a/third_party/blink/renderer/core/frame/local_dom_window_test.cc
+++ b/third_party/blink/renderer/core/frame/local_dom_window_test.cc
@@ -40,6 +40,11 @@
#include "third_party/blink/public/web/web_window_features.h"
#include "third_party/blink/renderer/bindings/core/v8/isolated_world_csp.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_promise_rejection_event_init.h"
+#include "third_party/blink/renderer/core/dom/events/event.h"
+#include "third_party/blink/renderer/core/event_type_names.h"
+#include "third_party/blink/renderer/core/events/error_event.h"
+#include "third_party/blink/renderer/core/events/promise_rejection_event.h"
#include "third_party/blink/renderer/core/execution_context/agent.h"
#include "third_party/blink/renderer/core/frame/csp/content_security_policy.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
@@ -48,6 +53,8 @@
#include "third_party/blink/renderer/core/testing/core_unit_test_helper.h"
#include "third_party/blink/renderer/core/testing/mock_policy_container_host.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
+#include "third_party/blink/renderer/platform/bindings/script_state.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/scheduler/public/event_loop.h"
#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
@@ -335,6 +342,65 @@
net::StorageAccessApiStatus::kAccessViaAPI);
}
+TEST_F(LocalDOMWindowTest, EventRespectsWorldOfCurrentEvent) {
+ LocalFrame* frame = &GetFrame();
+ LocalDOMWindow* window = frame->DomWindow();
+ ScriptState* main_world_script_state = ToScriptStateForMainWorld(frame);
+ v8::Isolate* isolate = main_world_script_state->GetIsolate();
+
+ constexpr int kIsolatedWorldId = 1;
+ DOMWrapperWorld* isolated_world =
+ DOMWrapperWorld::EnsureIsolatedWorld(isolate, kIsolatedWorldId);
+ ASSERT_TRUE(isolated_world->IsIsolatedWorld());
+ ScriptState* isolated_world_script_state =
+ ToScriptState(frame, *isolated_world);
+
+ auto event_visible_in = [window](ScriptState* script_state) {
+ ScriptState::Scope scope(script_state);
+ return !window->event(script_state).IsUndefined();
+ };
+
+ // An event with no world binding is visible in every world.
+ window->SetCurrentEvent(Event::Create(event_type_names::kChange));
+ EXPECT_TRUE(event_visible_in(main_world_script_state));
+ EXPECT_TRUE(event_visible_in(isolated_world_script_state));
+
+ // An ErrorEvent bound to an isolated world is only visible in that world.
+ {
+ ScriptState::Scope scope(isolated_world_script_state);
+ window->SetCurrentEvent(ErrorEvent::Create(isolated_world_script_state));
+ }
+ EXPECT_FALSE(event_visible_in(main_world_script_state));
+ EXPECT_TRUE(event_visible_in(isolated_world_script_state));
+
+ // An ErrorEvent bound to the main world is only visible in the main world.
+ {
+ ScriptState::Scope scope(main_world_script_state);
+ window->SetCurrentEvent(ErrorEvent::Create(main_world_script_state));
+ }
+ EXPECT_TRUE(event_visible_in(main_world_script_state));
+ EXPECT_FALSE(event_visible_in(isolated_world_script_state));
+
+ // A PromiseRejectionEvent bound to an isolated world is only visible in
+ // that world.
+ {
+ ScriptState::Scope scope(isolated_world_script_state);
+ v8::Local<v8::Promise::Resolver> resolver =
+ v8::Promise::Resolver::New(isolated_world_script_state->GetContext())
+ .ToLocalChecked();
+ auto* init = PromiseRejectionEventInit::Create();
+ init->setPromise(
+ MemberScriptPromise<IDLAny>(isolate, resolver->GetPromise()));
+ window->SetCurrentEvent(PromiseRejectionEvent::Create(
+ isolated_world_script_state, event_type_names::kUnhandledrejection,
+ init));
+ }
+ EXPECT_FALSE(event_visible_in(main_world_script_state));
+ EXPECT_TRUE(event_visible_in(isolated_world_script_state));
+
+ window->SetCurrentEvent(nullptr);
+}
+
TEST_F(LocalDOMWindowTest, CanExecuteScriptsDuringDetach) {
GetFrame().Loader().DetachDocument();
EXPECT_NE(GetFrame().DomWindow(), nullptr);
diff --git a/third_party/blink/web_tests/fast/events/window-event-isolated-world.html b/third_party/blink/web_tests/fast/events/window-event-isolated-world.html
new file mode 100644
index 0000000..40676b7
--- /dev/null
+++ b/third_party/blink/web_tests/fast/events/window-event-isolated-world.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<title>window.event is only exposed to the world the event belongs to</title>
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<x-probe id="probe"></x-probe>
+<script>
+'use strict';
+
+// Records what the main world observes as window.event when a custom element
+// reaction re-enters the main world synchronously from inside an event
+// listener that runs in another world.
+const mainWorldSaw = new Map();
+
+class XProbe extends HTMLElement {
+ static get observedAttributes() {
+ return ['trigger'];
+ }
+ attributeChangedCallback(name, oldValue, newValue) {
+ mainWorldSaw.set(newValue, window.event);
+ }
+}
+customElements.define('x-probe', XProbe);
+
+function isolatedWorldScript() {
+ const probe = document.getElementById('probe');
+ const report = (key, value) => {
+ document.documentElement.setAttribute('data-isolated-' + key, value);
+ };
+
+ const cases = [
+ ['error-event', new ErrorEvent('test-error')],
+ [
+ 'rejection-event',
+ new PromiseRejectionEvent('test-rejection', {promise: Promise.resolve()})
+ ],
+ ['custom-event', new CustomEvent('test-custom')],
+ ];
+ for (const [key, event] of cases) {
+ const listener = () => {
+ report(key, String(window.event === event));
+ // Re-enter the main world synchronously through a custom element
+ // reaction while this world's event is the window's current event.
+ probe.setAttribute('trigger', key);
+ };
+ document.addEventListener(event.type, listener);
+ document.dispatchEvent(event);
+ document.removeEventListener(event.type, listener);
+ }
+}
+
+test(() => {
+ assert_own_property(window, 'testRunner',
+ 'this test requires content_shell');
+ testRunner.evaluateScriptInIsolatedWorld(
+ 1, `(${isolatedWorldScript.toString()})();`);
+
+ const isolatedWorldSaw = key =>
+ document.documentElement.getAttribute('data-isolated-' + key);
+ assert_equals(isolatedWorldSaw('error-event'), 'true',
+ 'the isolated world sees its own ErrorEvent');
+ assert_equals(isolatedWorldSaw('rejection-event'), 'true',
+ 'the isolated world sees its own PromiseRejectionEvent');
+ assert_equals(isolatedWorldSaw('custom-event'), 'true',
+ 'the isolated world sees its own CustomEvent');
+
+ assert_equals(mainWorldSaw.get('error-event'), undefined,
+ 'an ErrorEvent bound to an isolated world is not exposed ' +
+ 'to the main world');
+ assert_equals(mainWorldSaw.get('rejection-event'), undefined,
+ 'a PromiseRejectionEvent bound to an isolated world is not ' +
+ 'exposed to the main world');
+ assert_true(mainWorldSaw.get('custom-event') instanceof CustomEvent,
+ 'an event with no world binding stays visible across worlds');
+ assert_equals(mainWorldSaw.get('custom-event').type, 'test-custom');
+}, 'window.event does not expose events bound to another world');
+
+test(() => {
+ let seen;
+ const event = new ErrorEvent('test-main-error');
+ const listener = () => {
+ seen = window.event;
+ };
+ document.addEventListener('test-main-error', listener);
+ document.dispatchEvent(event);
+ document.removeEventListener('test-main-error', listener);
+ assert_equals(seen, event,
+ 'window.event exposes an ErrorEvent to its own world');
+}, 'window.event still exposes same-world events');
+</script>
Original Bug Report
[Blink][window.event] page retypes leaked isolated Event to invoke extension-private MV3 service-worker functions
Report description
[Blink][window.event] page retypes leaked isolated Event to invoke extension-private MV3 service-worker functions
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
Vulnerability Description
Blink correctly filters normal JS event listeners using Event::CanBeDispatchedInWorld(listener_world).
During a valid isolated-world listener invocation, however, JSBasedEventListener::Invoke() temporarily stores the native Event* in the shared LocalDOMWindow::current_event_. If the isolated listener synchronously re-enters MAIN world (the PoC uses a custom-element reaction triggered by setAttribute()), MAIN can read window.event.
LocalDOMWindow::event(ScriptState*) wraps the shared CurrentEvent() into the caller’s ScriptState, but does not perform the same CanBeDispatchedInWorld(script_state->World()) authorization before exposing it.
This leaks the exact native isolated-world ErrorEvent to the hostile page.
The leak is stronger than disclosure of Event fields:
- A MAIN-created
ErrorEventwith the same visible fields cannot reach the isolated listener. - The leaked native Event can be replayed into the isolated world even after replay makes
isTrusted == false. - MAIN can call
Event.initEvent(attackerSelectedType, ...).initEvent()changes the public event type and clears trustedness, but does not clearErrorEvent’s retainedworld_. ErrorEvent::CanBeDispatchedInWorld()still authorizes against that retained world, so the stolen Event can be repurposed fromerrorto an unrelated attacker-selected listener type.- The attached PoC retypes the leaked Event to
ext-privileged, selects a shared target, enters one delegated isolated-world listener, retrieves a function from an extension-privateWeakMap, and invokes a Manifest V3 service-worker function with a page-controlled argument. - The worker exercises extension-only
chrome.storage.localauthority and returns an extension-private synthetic value.
No extension function reference, capability object, chrome.runtime, or chrome.storage object is exposed to MAIN.
The critical distinction is:
MAIN-created matching ErrorEvent
-> initEvent("ext-privileged")
-> isolated listener NOT invoked
leaked isolated-world ErrorEvent
-> initEvent("ext-privileged")
-> isolated listener invoked
-> private WeakMap capability selected
-> chrome.runtime.sendMessage()
-> MV3 service-worker function executes
-> chrome.storage.local result returned
Root cause
The normal listener path enforces the world boundary before invocation:
JSBasedEventListener::Invoke()
-> event->CanBeDispatchedInWorld(GetWorld())
-> window->SetCurrentEvent(event)
While the listener runs, LocalDOMWindow stores only one shared native current_event_.
The window.event getter then returns that native Event in whichever ScriptState calls the getter:
LocalDOMWindow::event(MAIN ScriptState)
-> CurrentEvent()
-> ToV8(... MAIN ScriptState ...)
There is no equivalent world authorization at this getter boundary.
The retype amplifier is independent but composes with the leak:
Event::initEvent()
-> changes type_
-> sets is_trusted_ = false
-> does not clear ErrorEvent::world_
ErrorEvent::CanBeDispatchedInWorld(world)
-> retained world_ still matches extension world
Therefore a single leaked extension-world ErrorEvent is a reusable, type-retargetable cross-world dispatch capability.
Attack Preconditions
- A Chrome extension content script executes in its normal isolated world on an attacker-controlled page.
- An isolated-world event listener performs a synchronous DOM operation that re-enters page MAIN world while Blink’s current Event is installed. The PoC uses a custom-element
attributeChangedCallback()reached throughsetAttribute(). - The extension has an event-driven privileged capability reachable from the selected listener/target. The PoC keeps this capability only in an isolated-world
WeakMapand forwards to an MV3 service worker.
The page does not need chrome.runtime, chrome.storage, an extension function reference, access to the extension’s private JS objects, or a trusted replayed Event.
Reproduction Steps / POC
Download attached poc files
-
Extract the ZIP.
-
Start the local server:
cd poc py run_server.py -
Open
chrome://extensions. -
Enable Developer mode.
-
Choose Load unpacked and select the
pocdirectory. -
Open:
http://localhost:8025/index.html -
The page runs automatically and prints one JSON result.
A successful run ends with:
"verdict": "VULNERABLE_MAX_IMPACT_CONFIRMED"
The decisive fields are:
{
"pageHasChromeRuntime": false,
"pageHasChromeStorage": false,
"ordinaryMainErrorCount": 0,
"leaked": {
"present": true,
"trustedBeforeReplay": true,
"stillErrorEvent": true
},
"cloneControl": {
"visibleFieldsEqual": true,
"extensionListenerRan": false
},
"attack": {
"retypedTo": "ext-privileged",
"eventTrustedAfterInit": false,
"stillErrorEvent": true,
"isolatedListenerRan": true,
"privateWeakMapCapabilitySelected": true,
"serviceWorkerExecuted": true,
"pageControlledArgumentPreserved": true,
"extensionPrivateValueRecovered": true
}
}
The PoC also prints navigator.userAgent as ua for the exact tested browser build.
Impact analysis
Maximum proven impact: hostile page invokes extension-private MV3 service-worker functionality with page-controlled input
The hostile webpage has no extension APIs and cannot normally address the extension’s private content-script or service-worker functions.
After one window.event leak, the page obtains a native Event carrying isolated-world provenance that an equivalent MAIN Event cannot reproduce. MAIN can retype that Event to an unrelated attacker-selected listener type and dispatch it on an attacker-selected shared target.
The attached PoC demonstrates the resulting authority transfer end-to-end:
hostile MAIN page
-> leaks isolated-world Event
-> retypes it to attacker-selected privileged event type
-> selects shared EventTarget
-> one isolated delegated listener runs
-> event.target selects function/object from extension-private WeakMap
-> page-controlled argument reaches private capability
-> content script calls chrome.runtime.sendMessage()
-> MV3 extension service-worker private function executes
-> chrome.storage.local authority is exercised
-> synthetic extension-private value is returned
This is not a claim of arbitrary JavaScript execution. The maximum demonstrated impact is attacker-selected invocation of pre-existing extension-private functionality, including execution in the extension service worker with extension API authority.
The initEvent() result materially widens applicability: exploitation is not limited to extensions with an isolated-world error handler. The stolen ErrorEvent was experimentally retyped to click, change, input, custom, and privileged custom event types while preserving the hidden world authority needed to enter isolated listeners. Handlers that independently require isTrusted, a specific event subclass such as MouseEvent, or additional semantic checks may still reject the replay.
Relevant components:
LocalDOMWindow::event(ScriptState*)/ sharedcurrent_event_: https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/frame/local_dom_window.ccLocalDOMWindow::current_event_: https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/frame/local_dom_window.h- normal event-listener world check and
SetCurrentEvent(event): https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/bindings/core/v8/js_based_event_listener.cc ErrorEvent::CanBeDispatchedInWorld()/ retainedworld_: https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/events/error_event.ccEvent::initEvent(): https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/dom/events/event.cc
The cause
What version of Chrome have you found the security issue in?
154.0.8027.0+dev, 152.0.7977.64+stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Privilege Escalation
How would you like to be publicly acknowledged for your report?
Zabith Mohammed
- http://localhost:8025/index.html
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/bindings/core/v8/js_based_event_listener.cc
- https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/dom/events/event.cc
- https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/events/error_event.cc
- https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/frame/local_dom_window.cc
- https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/frame/local_dom_window.h