Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DOM
DescriptionUse after free in DOM
ComponentDOM
Sub-componentDOM
Bug ClassUAF
Tracker553845833
Fix commit9d15343544a0 (chromium/src) +168/-0
CISA KEVNot listed
CreditedZabith Mohammed
Disclosed2026-10-20

Files Changed

  • third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
  • third_party/blink/renderer/core/dom/events/event_target.cc
  • third_party/blink/renderer/core/dom/events/event.h
From 9d15343544a0a6e56a8bf4686196a002ca2c194b Mon Sep 17 00:00:00 2001
From: Dominic Farolino <[email protected]>
Date: Wed, 23 Sep 2026 14:03:03 -0700
Subject: [PATCH] DOM: Fix Observable::when() cross-world error leak

This CL fixes a small leak whereby event listeners registered via
`Observable::when()` would get run for the subset of events that, when
constructed, get stamped with the ID of the world they were born in,
and ordinarily prevented from being exposed to normal
`addEventListener()` JS listeners. `ErrorEvent` is one of these few
events with this property.

Therefore, after this CL, `window.when('error')` subscription handlers
created in the main world will not get fired for uncaught exceptions
thrown in an isolated world. This matches the behavior of
`window.addEventListener()` handlers also created in the main world.

R=masonf

Bug: 553845833
Change-Id: I2ee7a3a7ab84ebdc7424317c22ab2f7bea359730
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8419940
Reviewed-by: Mason Freed <[email protected]>
Commit-Queue: Dominic Farolino <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1703755}
---
diff --git a/third_party/blink/renderer/core/dom/events/event.h b/third_party/blink/renderer/core/dom/events/event.h
index 746f5023..f9a663d 100644
--- a/third_party/blink/renderer/core/dom/events/event.h
+++ b/third_party/blink/renderer/core/dom/events/event.h
@@ -286,6 +286,12 @@
 
   // Events that must not leak across isolated world, similar to how
   // ErrorEvent behaves, can override this method.
+  //
+  // Every path that surfaces an `Event` to script must consult this. Ordinary
+  // event listeners do so in `JSBasedEventListener::Invoke()`, but
+  // `EventTarget::FireEventListeners()` itself does not, so a
+  // `NativeEventListener` that forwards the raw `Event` on to script (e.g. the
+  // one backing `EventTarget#when()`) is responsible for checking it directly.
   virtual bool CanBeDispatchedInWorld(const DOMWrapperWorld&) const {
     return true;
   }
diff --git a/third_party/blink/renderer/core/dom/events/event_target.cc b/third_party/blink/renderer/core/dom/events/event_target.cc
index 6da24729..d585491 100644
--- a/third_party/blink/renderer/core/dom/events/event_target.cc
+++ b/third_party/blink/renderer/core/dom/events/event_target.cc
@@ -281,6 +281,21 @@
   // `EventTarget::FireEventListeners()` early-returns if its `ExecutionContext`
   // is detached.
   DCHECK(script_state_->ContextIsValid());
+
+  // Some events are bound to the `DOMWrapperWorld` they were created in (see
+  // `Event::CanBeDispatchedInWorld()`), and must not be surfaced to script in
+  // any other world. Ordinary event listeners enforce this in
+  // `JSBasedEventListener::Invoke()`; since `this` is a `NativeEventListener`,
+  // it does not go through that path and must perform the same check itself.
+  //
+  // This must happen before `ScriptValue::From()` below: creating the wrapper
+  // in the subscriber's world is itself the leak, not merely invoking the
+  // subscriber. Note that this filters in both directions, since
+  // `script_state_` is the *subscriber's* script state.
+  if (!event->CanBeDispatchedInWorld(script_state_->World())) {
+    return;
+  }
+
   ScriptState::Scope scope(script_state_);
   ScriptValue script_value = ScriptValue::From(script_state_, event);
 
diff --git a/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
new file mode 100644
index 0000000..53a6fdb4
--- /dev/null
+++ b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<title>Per-world event dispatch rules across the MAIN/isolated world boundary</title>
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<div id="target"></div>
+<script>
+// Blink stamps certain events with the DOMWrapperWorld they were created in
+// (`ErrorEvent`, `PromiseRejectionEvent`, and the device-connection events).
+// `Event::CanBeDispatchedInWorld()` then gates delivery, and the *only* place
+// that gate is consulted on the dispatch path is
+// `JSBasedEventListener::Invoke()`.
+//
+// These tests pin down the resulting matrix, including the `EventTarget.when()`
+// hole tracked by crbug.com/553845833.
+
+const ISOLATED_WORLD_ID = 1;
+
+function inIsolatedWorld(script) {
+  return testRunner.evaluateScriptInIsolatedWorldAndReturnValue(
+      ISOLATED_WORLD_ID, script);
+}
+
+setup(() => {
+  assert_implements(
+      window.testRunner, 'This test requires testRunner (isolated worlds).');
+  assert_implements(
+      window.when, 'This test requires EventTarget.when().');
+
+  // Install the isolated world's listeners once, up front:
+  //   - a cancelling "error" listener on #target, used by the first two tests
+  //     to detect whether a MAIN-world-dispatched event reached it.
+  //   - a helper that dispatches an isolated-world-stamped ErrorEvent on
+  //     window, used by the last two tests.
+  inIsolatedWorld(`
+    window.targetListenerRunCount = 0;
+    document.getElementById('target').addEventListener('error', e => {
+      window.targetListenerRunCount++;
+      e.preventDefault();
+    });
+
+    // Constructed in this world, so ErrorEvent::world_ is the isolated world.
+    window.dispatchIsolatedErrorEvent = () => {
+      window.dispatchEvent(new ErrorEvent('error', {
+        message: 'isolated-world-private',
+        filename: 'isolated-world-private.js',
+        lineno: 42,
+        colno: 7,
+      }));
+    };
+  `);
+});
+
+function isolatedTargetListenerRunCount() {
+  return inIsolatedWorld('window.targetListenerRunCount');
+}
+
+const target = document.getElementById('target');
+
+// ---------------------------------------------------------------------------
+// MAIN -> isolated.
+// ---------------------------------------------------------------------------
+
+test(() => {
+  const before = isolatedTargetListenerRunCount();
+  const notCanceled =
+      target.dispatchEvent(new Event('error', {cancelable: true}));
+
+  assert_equals(isolatedTargetListenerRunCount(), before + 1,
+                'isolated-world listener should have run');
+  assert_false(notCanceled,
+               'the isolated-world listener\'s preventDefault() is observable ' +
+               'from the main world');
+}, 'A main-world plain Event reaches an isolated-world listener, because the ' +
+   'base Event has no world stamp.');
+
+test(() => {
+  const before = isolatedTargetListenerRunCount();
+  const notCanceled =
+      target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+
+  assert_equals(isolatedTargetListenerRunCount(), before,
+                'isolated-world listener must not run');
+  assert_true(notCanceled, 'nothing should have canceled the event');
+}, 'A main-world ErrorEvent does not reach an isolated-world listener, ' +
+   'because its constructor stamps it with the main world.');
+
+// ---------------------------------------------------------------------------
+// isolated -> MAIN.
+// ---------------------------------------------------------------------------
+
+test(() => {
+  let listenerRunCount = 0;
+  const listener = () => { listenerRunCount++; };
+  window.addEventListener('error', listener);
+
+  try {
+    inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+    assert_equals(listenerRunCount, 0,
+                  'main-world listener must not observe an isolated-world ' +
+                  'ErrorEvent');
+  } finally {
+    window.removeEventListener('error', listener);
+  }
+}, 'An isolated-world ErrorEvent does not reach a main-world ' +
+   'addEventListener() listener.');
+
+test(() => {
+  let subscriberRunCount = 0;
+  const controller = new AbortController();
+  window.when('error').subscribe(() => { subscriberRunCount++; },
+                                 {signal: controller.signal});
+
+  try {
+    inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+    assert_equals(subscriberRunCount, 0,
+                  'when() must apply the same per-world filter that ' +
+                  'addEventListener() applies');
+  } finally {
+    controller.abort();
+  }
+}, 'An isolated-world ErrorEvent does not reach a main-world when() ' +
+   'subscriber.');
+
+// The filter is symmetric, because `ObservableEventListener::Invoke()` checks
+// the event against the *subscriber's* world: an isolated-world `when()`
+// subscriber must no more observe main-world-stamped events than the reverse.
+test(() => {
+  inIsolatedWorld(`
+    window.targetSubscriberRunCount = 0;
+    window.targetSubscriberController = new AbortController();
+    document.getElementById('target').when('error').subscribe(
+        () => { window.targetSubscriberRunCount++; },
+        {signal: window.targetSubscriberController.signal});
+  `);
+
+  try {
+    target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+    assert_equals(inIsolatedWorld('window.targetSubscriberRunCount'), 0,
+                  'isolated-world when() subscriber must not observe a ' +
+                  'main-world-stamped ErrorEvent');
+  } finally {
+    inIsolatedWorld('window.targetSubscriberController.abort();');
+  }
+}, 'A main-world ErrorEvent does not reach an isolated-world when() ' +
+   'subscriber.');
+</script>
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
new file mode 100644
index 0000000..53a6fdb4
--- /dev/null
+++ b/third_party/blink/web_tests/fast/events/cross-world-event-dispatch.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<title>Per-world event dispatch rules across the MAIN/isolated world boundary</title>
+<script src="../../resources/testharness.js"></script>
+<script src="../../resources/testharnessreport.js"></script>
+<div id="target"></div>
+<script>
+// Blink stamps certain events with the DOMWrapperWorld they were created in
+// (`ErrorEvent`, `PromiseRejectionEvent`, and the device-connection events).
+// `Event::CanBeDispatchedInWorld()` then gates delivery, and the *only* place
+// that gate is consulted on the dispatch path is
+// `JSBasedEventListener::Invoke()`.
+//
+// These tests pin down the resulting matrix, including the `EventTarget.when()`
+// hole tracked by crbug.com/553845833.
+
+const ISOLATED_WORLD_ID = 1;
+
+function inIsolatedWorld(script) {
+  return testRunner.evaluateScriptInIsolatedWorldAndReturnValue(
+      ISOLATED_WORLD_ID, script);
+}
+
+setup(() => {
+  assert_implements(
+      window.testRunner, 'This test requires testRunner (isolated worlds).');
+  assert_implements(
+      window.when, 'This test requires EventTarget.when().');
+
+  // Install the isolated world's listeners once, up front:
+  //   - a cancelling "error" listener on #target, used by the first two tests
+  //     to detect whether a MAIN-world-dispatched event reached it.
+  //   - a helper that dispatches an isolated-world-stamped ErrorEvent on
+  //     window, used by the last two tests.
+  inIsolatedWorld(`
+    window.targetListenerRunCount = 0;
+    document.getElementById('target').addEventListener('error', e => {
+      window.targetListenerRunCount++;
+      e.preventDefault();
+    });
+
+    // Constructed in this world, so ErrorEvent::world_ is the isolated world.
+    window.dispatchIsolatedErrorEvent = () => {
+      window.dispatchEvent(new ErrorEvent('error', {
+        message: 'isolated-world-private',
+        filename: 'isolated-world-private.js',
+        lineno: 42,
+        colno: 7,
+      }));
+    };
+  `);
+});
+
+function isolatedTargetListenerRunCount() {
+  return inIsolatedWorld('window.targetListenerRunCount');
+}
+
+const target = document.getElementById('target');
+
+// ---------------------------------------------------------------------------
+// MAIN -> isolated.
+// ---------------------------------------------------------------------------
+
+test(() => {
+  const before = isolatedTargetListenerRunCount();
+  const notCanceled =
+      target.dispatchEvent(new Event('error', {cancelable: true}));
+
+  assert_equals(isolatedTargetListenerRunCount(), before + 1,
+                'isolated-world listener should have run');
+  assert_false(notCanceled,
+               'the isolated-world listener\'s preventDefault() is observable ' +
+               'from the main world');
+}, 'A main-world plain Event reaches an isolated-world listener, because the ' +
+   'base Event has no world stamp.');
+
+test(() => {
+  const before = isolatedTargetListenerRunCount();
+  const notCanceled =
+      target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+
+  assert_equals(isolatedTargetListenerRunCount(), before,
+                'isolated-world listener must not run');
+  assert_true(notCanceled, 'nothing should have canceled the event');
+}, 'A main-world ErrorEvent does not reach an isolated-world listener, ' +
+   'because its constructor stamps it with the main world.');
+
+// ---------------------------------------------------------------------------
+// isolated -> MAIN.
+// ---------------------------------------------------------------------------
+
+test(() => {
+  let listenerRunCount = 0;
+  const listener = () => { listenerRunCount++; };
+  window.addEventListener('error', listener);
+
+  try {
+    inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+    assert_equals(listenerRunCount, 0,
+                  'main-world listener must not observe an isolated-world ' +
+                  'ErrorEvent');
+  } finally {
+    window.removeEventListener('error', listener);
+  }
+}, 'An isolated-world ErrorEvent does not reach a main-world ' +
+   'addEventListener() listener.');
+
+test(() => {
+  let subscriberRunCount = 0;
+  const controller = new AbortController();
+  window.when('error').subscribe(() => { subscriberRunCount++; },
+                                 {signal: controller.signal});
+
+  try {
+    inIsolatedWorld('window.dispatchIsolatedErrorEvent();');
+    assert_equals(subscriberRunCount, 0,
+                  'when() must apply the same per-world filter that ' +
+                  'addEventListener() applies');
+  } finally {
+    controller.abort();
+  }
+}, 'An isolated-world ErrorEvent does not reach a main-world when() ' +
+   'subscriber.');
+
+// The filter is symmetric, because `ObservableEventListener::Invoke()` checks
+// the event against the *subscriber's* world: an isolated-world `when()`
+// subscriber must no more observe main-world-stamped events than the reverse.
+test(() => {
+  inIsolatedWorld(`
+    window.targetSubscriberRunCount = 0;
+    window.targetSubscriberController = new AbortController();
+    document.getElementById('target').when('error').subscribe(
+        () => { window.targetSubscriberRunCount++; },
+        {signal: window.targetSubscriberController.signal});
+  `);
+
+  try {
+    target.dispatchEvent(new ErrorEvent('error', {cancelable: true}));
+    assert_equals(inIsolatedWorld('window.targetSubscriberRunCount'), 0,
+                  'isolated-world when() subscriber must not observe a ' +
+                  'main-world-stamped ErrorEvent');
+  } finally {
+    inIsolatedWorld('window.targetSubscriberController.abort();');
+  }
+}, 'A main-world ErrorEvent does not reach an isolated-world when() ' +
+   'subscriber.');
+</script>
+
Loading diff…

Original Bug Report

reported by [email protected]

[Blink][EventTarget.when] cross-world Event leak enables page-controlled isolated-world extension API invocation and private storage exfiltration


Report description

[Blink][EventTarget.when] cross-world Event leak enables page-controlled isolated-world extension API invocation and private storage exfiltration


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://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/events/event_target.cc


The problem

Please describe the technical details of the vulnerability

The maximum demonstrated impact is cross-world capability transfer enabling page-controlled invocation of pre-existing isolated-world extension JavaScript with extension API authority and recovery of extension-private data.

The PoC proves:

hostile page
  -> has no chrome.storage
  -> ordinary MAIN error listener is filtered
  -> EventTarget.when() receives trusted extension-world ErrorEvent
  -> page retains the exact native Event
  -> exact MAIN-world clone with identical visible fields cannot reach extension listener
  -> page replays the leaked Event on a shared target
  -> replay is correctly isTrusted=false
  -> pre-existing isolated-world extension callback nevertheless executes
  -> page controls the storage-key argument through the shared target
  -> callback performs chrome.storage.local.get(pageControlledKey)
  -> extension-private value is returned to the page

The page can also choose among multiple pre-existing isolated-world callbacks by choosing the replay target; this was independently confirmed in the extended matrix.

Current EventTarget.when() uses ObservableEventListener, which derives from NativeEventListener. ObservableEventListener::Invoke() forwards the native Event into the subscriber’s ScriptState without checking Event::CanBeDispatchedInWorld() for that subscriber’s DOMWrapperWorld.

Ordinary JS listeners perform that world check before wrapping/invoking the listener.

A robust fix would apply the same world-dispatch authorization before forwarding an Event from ObservableEventListener::Invoke(), e.g. reject when:

!event->CanBeDispatchedInWorld(script_state_->World())

before creating the ScriptValue or calling subscriber_->next().

Impact analysis

The maximum demonstrated impact is cross-world capability transfer enabling page-controlled invocation of pre-existing isolated-world extension JavaScript with extension API authority and recovery of extension-private data.

The PoC proves:

hostile page
  -> has no chrome.storage
  -> ordinary MAIN error listener is filtered
  -> EventTarget.when() receives trusted extension-world ErrorEvent
  -> page retains the exact native Event
  -> exact MAIN-world clone with identical visible fields cannot reach extension listener
  -> page replays the leaked Event on a shared target
  -> replay is correctly isTrusted=false
  -> pre-existing isolated-world extension callback nevertheless executes
  -> page controls the storage-key argument through the shared target
  -> callback performs chrome.storage.local.get(pageControlledKey)
  -> extension-private value is returned to the page

The page can also choose among multiple pre-existing isolated-world callbacks by choosing the replay target; this was independently confirmed in the extended matrix.

This is not generic AJE: MAIN-world expando/function/getter/prototype mutations did not cross into the isolated wrapper. The demonstrated primitive is stronger and more specific: the leaked native Event acts as a reusable world-qualified capability that lets the page reach extension callbacks it cannot reach with a page-created Event.

Current EventTarget.when() uses ObservableEventListener, which derives from NativeEventListener. ObservableEventListener::Invoke() forwards the native Event into the subscriber’s ScriptState without checking Event::CanBeDispatchedInWorld() for that subscriber’s DOMWrapperWorld.

Ordinary JS listeners perform that world check before wrapping/invoking the listener.

A robust fix would apply the same world-dispatch authorization before forwarding an Event from ObservableEventListener::Invoke(), e.g. reject when:

!event->CanBeDispatchedInWorld(script_state_->World())

before creating the ScriptValue or calling subscriber_->next().


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