Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Frames
DescriptionUse after free in Frames
ComponentFrames
Sub-componentAndroid, Fuchsia, Linux, Mac, Windows, ChromeOS
Bug ClassUAF
Tracker554008013
Fix commit32f1a1b7f206 (chromium/src) +160/-2
CISA KEVNot listed
CreditedZabith Mohammed
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/frame/local_dom_window.cc
modified
TEST_F
third_party/blink/renderer/core/frame/local_dom_window_test.cc
modified
XProbe
third_party/blink/web_tests/fast/events/window-event-isolated-world.html
modified
attributeChangedCallback
third_party/blink/web_tests/fast/events/window-event-isolated-world.html
modified
for
third_party/blink/web_tests/fast/events/window-event-isolated-world.html
modified

Files Changed

  • third_party/blink/renderer/core/frame/local_dom_window_test.cc
  • third_party/blink/web_tests/fast/events/window-event-isolated-world.html
  • third_party/blink/renderer/core/frame/local_dom_window.cc
From 32f1a1b7f2066c319f0afe45faf46d84bc208554 Mon Sep 17 00:00:00 2001
From: Mike Taylor <miketaylr@chromium.org>
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 <tkent@chromium.org>
Commit-Queue: Mike Taylor <miketaylr@chromium.org>
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by za...@gmail.com

[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?

https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/frame/local_dom_window.cc


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:

  1. A MAIN-created ErrorEvent with the same visible fields cannot reach the isolated listener.
  2. The leaked native Event can be replayed into the isolated world even after replay makes isTrusted == false.
  3. MAIN can call Event.initEvent(attackerSelectedType, ...). initEvent() changes the public event type and clears trustedness, but does not clear ErrorEvent’s retained world_.
  4. ErrorEvent::CanBeDispatchedInWorld() still authorizes against that retained world, so the stolen Event can be repurposed from error to an unrelated attacker-selected listener type.
  5. 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-private WeakMap, and invokes a Manifest V3 service-worker function with a page-controlled argument.
  6. The worker exercises extension-only chrome.storage.local authority 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 through setAttribute().
  • The extension has an event-driven privileged capability reachable from the selected listener/target. The PoC keeps this capability only in an isolated-world WeakMap and 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

  1. Extract the ZIP.

  2. Start the local server:

    cd poc
    py run_server.py
    
  3. Open chrome://extensions.

  4. Enable Developer mode.

  5. Choose Load unpacked and select the poc directory.

  6. Open:

    http://localhost:8025/index.html
    
  7. 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:


The cause

What version of Chrome have you found the security issue in?

154.0.8027.0+dev, 152.0.7977.64+stable

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

View on issue tracker