Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in ShadowDOM
DescriptionMemory corruption in ShadowDOM
ComponentShadowDOM
Sub-componentBlink>DOM>ShadowDOM
Bug ClassMemory Corruption
Tracker553390794
Fix commit683f2a2e86d8 (chromium/src) +200/-9
CISA KEVNot listed
CreditedZabith Mohammed
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/core/events/input_event.cc
modified

Files Changed

  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
  • third_party/blink/renderer/core/events/input_event.cc
  • third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
From 683f2a2e86d835e9c97a2728a3d320aed2f10517 Mon Sep 17 00:00:00 2001
From: Joey Arhar <jarhar@chromium.org>
Date: Fri, 18 Sep 2026 17:47:42 -0700
Subject: [PATCH] Fix shadow root leak from beforeinput event

The nodes returned in beforeinput event's getTargetRanges() method was
not doing retargeting and could leak shadow nodes. This patch fixes that
by doing retargeting.

fast/events/inputevents/inputevent-transpose.html was updated because
the first subtest left a beforeinput listener on document that relied on
<textarea>'s internal UA shadow root nodes being leaked via
getTargetRanges() when the second subtest ran.

Fixed: 553390794
Change-Id: Ib774191fef0c54e3f14f649f37f40c87f22c7bf4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8400446
Auto-Submit: Joey Arhar <jarhar@chromium.org>
Reviewed-by: Mason Freed <masonf@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1701270}
---
diff --git a/third_party/blink/renderer/core/events/input_event.cc b/third_party/blink/renderer/core/events/input_event.cc
index e3706e7..9ee8ee3 100644
--- a/third_party/blink/renderer/core/events/input_event.cc
+++ b/third_party/blink/renderer/core/events/input_event.cc
@@ -222,8 +222,14 @@
 
 StaticRangeVector InputEvent::getTargetRanges() const {
   StaticRangeVector static_ranges;
-  for (const auto& range : ranges_)
+  for (const auto& range : ranges_) {
+    if ((Retarget(range->startContainer()) != range->startContainer() ||
+         Retarget(range->endContainer()) != range->endContainer()) &&
+        RuntimeEnabledFeatures::GetTargetRangesRetargetingEnabled()) {
+      continue;
+    }
     static_ranges.push_back(StaticRange::Create(range));
+  }
   return static_ranges;
 }
 
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 7dafee225..e0a6fb4 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -3456,6 +3456,12 @@
       status: "stable",
     },
     {
+      // Performs retargeting on the output of InputEvent.getTargetRanges() to
+      // prevent leaking nodes in shadow roots.
+      name: "GetTargetRangesRetargeting",
+      status: "stable",
+    },
+    {
       name: "GetUserMediaEchoCancellationModes",
       status: "stable",
     },
diff --git a/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html b/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
index 5598a8a2..089524a 100644
--- a/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
+++ b/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
@@ -5,16 +5,22 @@
 <div id="editable" contenteditable></div>
 <textarea id="txt"></textarea>
 <script>
-test(() => {
+test(t => {
     assert_not_equals(window.testRunner, undefined, 'This test requires testRunner.');
 
     let eventRecorder = '';
-    document.addEventListener('beforeinput', event => {
+    const onBeforeInput = event => {
         const range = event.getTargetRanges()[0];
         eventRecorder += `beforeinput-${event.inputType}-${event.data}-${range.startOffset}-${range.endOffset}-`;
-    });
-    document.addEventListener('input', event => {
+    };
+    const onInput = event => {
         eventRecorder += `input-${event.inputType}`;
+    };
+    document.addEventListener('beforeinput', onBeforeInput);
+    document.addEventListener('input', onInput);
+    t.add_cleanup(() => {
+        document.removeEventListener('beforeinput', onBeforeInput);
+        document.removeEventListener('input', onInput);
     });
 
     const editable = document.getElementById('editable');
@@ -30,15 +36,22 @@
     assert_equals(eventRecorder, 'beforeinput-insertTranspose-cb-1-3-input-insertTranspose');
 }, 'Transpose on contenteditable');
 
-test(() => {
+test(t => {
     assert_not_equals(window.testRunner, undefined, 'This test requires testRunner.');
 
     let eventRecorder = '';
-    document.addEventListener('beforeinput', event => {
+    const onBeforeInput = event => {
+        assert_equals(event.getTargetRanges().length, 0);
         eventRecorder += `beforeinput-${event.inputType}-${event.data}-`;
-    });
-    document.addEventListener('input', event => {
+    };
+    const onInput = event => {
         eventRecorder += `input-${event.inputType}`;
+    };
+    document.addEventListener('beforeinput', onBeforeInput);
+    document.addEventListener('input', onInput);
+    t.add_cleanup(() => {
+        document.removeEventListener('beforeinput', onBeforeInput);
+        document.removeEventListener('input', onInput);
     });
 
     const txt = document.getElementById('txt');
diff --git a/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html b/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
new file mode 100644
index 0000000..91557ccd
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
@@ -0,0 +1,166 @@
+<!DOCTYPE html>
+<title>InputEvent.getTargetRanges() with Shadow DOM</title>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/553390794">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+
+<div id="host">
+  <template shadowrootmode="open">
+    <div id="editor" contenteditable="true" style="min-height: 20px;">test</div>
+  </template>
+</div>
+
+<div id="nested_host">
+  <template shadowrootmode="open">
+    <div id="inner_host">
+      <template shadowrootmode="open">
+        <div id="editor" contenteditable="true" style="min-height: 20px;">test</div>
+      </template>
+    </div>
+  </template>
+</div>
+
+<div id="synthetic_host">
+  <template shadowrootmode="open">hello</template>
+</div>
+
+<script>
+promise_test(async test => {
+  const host = document.getElementById('host');
+  const editor = host.shadowRoot.getElementById('editor');
+
+  let innerBeforeInputRangesLength = -1;
+  let hostBeforeInputRangesLength = -1;
+  let docBeforeInputRangesLength = -1;
+  let innerInputRangesLength = -1;
+  let hostInputRangesLength = -1;
+  let docInputRangesLength = -1;
+
+  editor.addEventListener('beforeinput', (e) => {
+    innerBeforeInputRangesLength = e.getTargetRanges().length;
+  });
+  editor.addEventListener('input', (e) => {
+    innerInputRangesLength = e.getTargetRanges().length;
+  });
+
+  host.addEventListener('beforeinput', (e) => {
+    hostBeforeInputRangesLength = e.getTargetRanges().length;
+  });
+  host.addEventListener('input', (e) => {
+    hostInputRangesLength = e.getTargetRanges().length;
+  });
+
+  const docBeforeInputListener = (e) => {
+    docBeforeInputRangesLength = e.getTargetRanges().length;
+  };
+  const docInputListener = (e) => {
+    docInputRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('beforeinput', docBeforeInputListener);
+  document.addEventListener('input', docInputListener);
+  test.add_cleanup(() => {
+    document.removeEventListener('beforeinput', docBeforeInputListener);
+    document.removeEventListener('input', docInputListener);
+  });
+
+  editor.focus();
+  await test_driver.send_keys(host, 'a');
+
+  assert_equals(innerBeforeInputRangesLength, 1,
+      'beforeinput getTargetRanges() inside shadow root should return target ranges');
+  assert_equals(hostBeforeInputRangesLength, 0,
+      'beforeinput getTargetRanges() on shadow host should return empty array');
+  assert_equals(docBeforeInputRangesLength, 0,
+      'beforeinput getTargetRanges() on document should return empty array');
+
+  assert_equals(innerInputRangesLength, 0,
+      'input event getTargetRanges() inside shadow root should be empty');
+  assert_equals(hostInputRangesLength, 0,
+      'input event getTargetRanges() on shadow host should be empty');
+  assert_equals(docInputRangesLength, 0,
+      'input event getTargetRanges() on document should be empty');
+}, 'InputEvent.getTargetRanges() should not leak nodes from a shadow root during typing.');
+
+promise_test(async test => {
+  const outerHost = document.getElementById('nested_host');
+  const innerHost = outerHost.shadowRoot.getElementById('inner_host');
+  const editor = innerHost.shadowRoot.getElementById('editor');
+
+  let innerRangesLength = -1;
+  let outerShadowRangesLength = -1;
+  let docRangesLength = -1;
+
+  editor.addEventListener('beforeinput', (e) => {
+    innerRangesLength = e.getTargetRanges().length;
+  });
+
+  innerHost.addEventListener('beforeinput', (e) => {
+    outerShadowRangesLength = e.getTargetRanges().length;
+  });
+
+  const docListener = (e) => {
+    docRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('beforeinput', docListener);
+  test.add_cleanup(() => document.removeEventListener('beforeinput', docListener));
+
+  editor.focus();
+  await test_driver.send_keys(outerHost, 'a');
+
+  assert_equals(innerRangesLength, 1,
+      'getTargetRanges() inside inner shadow root should return target ranges');
+  assert_equals(outerShadowRangesLength, 0,
+      'getTargetRanges() inside intermediate shadow root should return empty array');
+  assert_equals(docRangesLength, 0,
+      'getTargetRanges() on document for nested shadow root should return empty array');
+}, 'InputEvent.getTargetRanges() should not leak nodes across nested shadow roots.');
+
+test(test => {
+  const host = document.getElementById('synthetic_host');
+  const shadow = host.shadowRoot;
+  const textNode = shadow.firstChild;
+
+  const staticRange = new StaticRange({
+    startContainer: textNode,
+    startOffset: 0,
+    endContainer: textNode,
+    endOffset: 4
+  });
+
+  let innerEventRangesLength = -1;
+  let hostEventRangesLength = -1;
+  let docEventRangesLength = -1;
+
+  shadow.addEventListener('input', (e) => {
+    innerEventRangesLength = e.getTargetRanges().length;
+  });
+
+  host.addEventListener('input', (e) => {
+    hostEventRangesLength = e.getTargetRanges().length;
+  });
+
+  const docListener = (e) => {
+    docEventRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('input', docListener);
+  test.add_cleanup(() => document.removeEventListener('input', docListener));
+
+  const syntheticEvent = new InputEvent('input', {
+    bubbles: true,
+    composed: true,
+    targetRanges: [staticRange]
+  });
+  textNode.dispatchEvent(syntheticEvent);
+
+  assert_equals(innerEventRangesLength, 1,
+      'Synthetic InputEvent getTargetRanges() inside shadow root should return target range');
+  assert_equals(hostEventRangesLength, 0,
+      'Synthetic InputEvent getTargetRanges() on shadow host should return empty array');
+  assert_equals(docEventRangesLength, 0,
+      'Synthetic InputEvent getTargetRanges() on document should return empty array');
+}, 'Synthetic InputEvent with targetRanges should not leak nodes from a shadow root.');
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html b/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
new file mode 100644
index 0000000..91557ccd
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
@@ -0,0 +1,166 @@
+<!DOCTYPE html>
+<title>InputEvent.getTargetRanges() with Shadow DOM</title>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://issues.chromium.org/issues/553390794">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+
+<div id="host">
+  <template shadowrootmode="open">
+    <div id="editor" contenteditable="true" style="min-height: 20px;">test</div>
+  </template>
+</div>
+
+<div id="nested_host">
+  <template shadowrootmode="open">
+    <div id="inner_host">
+      <template shadowrootmode="open">
+        <div id="editor" contenteditable="true" style="min-height: 20px;">test</div>
+      </template>
+    </div>
+  </template>
+</div>
+
+<div id="synthetic_host">
+  <template shadowrootmode="open">hello</template>
+</div>
+
+<script>
+promise_test(async test => {
+  const host = document.getElementById('host');
+  const editor = host.shadowRoot.getElementById('editor');
+
+  let innerBeforeInputRangesLength = -1;
+  let hostBeforeInputRangesLength = -1;
+  let docBeforeInputRangesLength = -1;
+  let innerInputRangesLength = -1;
+  let hostInputRangesLength = -1;
+  let docInputRangesLength = -1;
+
+  editor.addEventListener('beforeinput', (e) => {
+    innerBeforeInputRangesLength = e.getTargetRanges().length;
+  });
+  editor.addEventListener('input', (e) => {
+    innerInputRangesLength = e.getTargetRanges().length;
+  });
+
+  host.addEventListener('beforeinput', (e) => {
+    hostBeforeInputRangesLength = e.getTargetRanges().length;
+  });
+  host.addEventListener('input', (e) => {
+    hostInputRangesLength = e.getTargetRanges().length;
+  });
+
+  const docBeforeInputListener = (e) => {
+    docBeforeInputRangesLength = e.getTargetRanges().length;
+  };
+  const docInputListener = (e) => {
+    docInputRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('beforeinput', docBeforeInputListener);
+  document.addEventListener('input', docInputListener);
+  test.add_cleanup(() => {
+    document.removeEventListener('beforeinput', docBeforeInputListener);
+    document.removeEventListener('input', docInputListener);
+  });
+
+  editor.focus();
+  await test_driver.send_keys(host, 'a');
+
+  assert_equals(innerBeforeInputRangesLength, 1,
+      'beforeinput getTargetRanges() inside shadow root should return target ranges');
+  assert_equals(hostBeforeInputRangesLength, 0,
+      'beforeinput getTargetRanges() on shadow host should return empty array');
+  assert_equals(docBeforeInputRangesLength, 0,
+      'beforeinput getTargetRanges() on document should return empty array');
+
+  assert_equals(innerInputRangesLength, 0,
+      'input event getTargetRanges() inside shadow root should be empty');
+  assert_equals(hostInputRangesLength, 0,
+      'input event getTargetRanges() on shadow host should be empty');
+  assert_equals(docInputRangesLength, 0,
+      'input event getTargetRanges() on document should be empty');
+}, 'InputEvent.getTargetRanges() should not leak nodes from a shadow root during typing.');
+
+promise_test(async test => {
+  const outerHost = document.getElementById('nested_host');
+  const innerHost = outerHost.shadowRoot.getElementById('inner_host');
+  const editor = innerHost.shadowRoot.getElementById('editor');
+
+  let innerRangesLength = -1;
+  let outerShadowRangesLength = -1;
+  let docRangesLength = -1;
+
+  editor.addEventListener('beforeinput', (e) => {
+    innerRangesLength = e.getTargetRanges().length;
+  });
+
+  innerHost.addEventListener('beforeinput', (e) => {
+    outerShadowRangesLength = e.getTargetRanges().length;
+  });
+
+  const docListener = (e) => {
+    docRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('beforeinput', docListener);
+  test.add_cleanup(() => document.removeEventListener('beforeinput', docListener));
+
+  editor.focus();
+  await test_driver.send_keys(outerHost, 'a');
+
+  assert_equals(innerRangesLength, 1,
+      'getTargetRanges() inside inner shadow root should return target ranges');
+  assert_equals(outerShadowRangesLength, 0,
+      'getTargetRanges() inside intermediate shadow root should return empty array');
+  assert_equals(docRangesLength, 0,
+      'getTargetRanges() on document for nested shadow root should return empty array');
+}, 'InputEvent.getTargetRanges() should not leak nodes across nested shadow roots.');
+
+test(test => {
+  const host = document.getElementById('synthetic_host');
+  const shadow = host.shadowRoot;
+  const textNode = shadow.firstChild;
+
+  const staticRange = new StaticRange({
+    startContainer: textNode,
+    startOffset: 0,
+    endContainer: textNode,
+    endOffset: 4
+  });
+
+  let innerEventRangesLength = -1;
+  let hostEventRangesLength = -1;
+  let docEventRangesLength = -1;
+
+  shadow.addEventListener('input', (e) => {
+    innerEventRangesLength = e.getTargetRanges().length;
+  });
+
+  host.addEventListener('input', (e) => {
+    hostEventRangesLength = e.getTargetRanges().length;
+  });
+
+  const docListener = (e) => {
+    docEventRangesLength = e.getTargetRanges().length;
+  };
+  document.addEventListener('input', docListener);
+  test.add_cleanup(() => document.removeEventListener('input', docListener));
+
+  const syntheticEvent = new InputEvent('input', {
+    bubbles: true,
+    composed: true,
+    targetRanges: [staticRange]
+  });
+  textNode.dispatchEvent(syntheticEvent);
+
+  assert_equals(innerEventRangesLength, 1,
+      'Synthetic InputEvent getTargetRanges() inside shadow root should return target range');
+  assert_equals(hostEventRangesLength, 0,
+      'Synthetic InputEvent getTargetRanges() on shadow host should return empty array');
+  assert_equals(docEventRangesLength, 0,
+      'Synthetic InputEvent getTargetRanges() on document should return empty array');
+}, 'Synthetic InputEvent with targetRanges should not leak nodes from a shadow root.');
+</script>
diff --git a/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html b/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
index 5598a8a2..089524a 100644
--- a/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
+++ b/third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.html
@@ -5,16 +5,22 @@
 <div id="editable" contenteditable></div>
 <textarea id="txt"></textarea>
 <script>
-test(() => {
+test(t => {
     assert_not_equals(window.testRunner, undefined, 'This test requires testRunner.');
 
     let eventRecorder = '';
-    document.addEventListener('beforeinput', event => {
+    const onBeforeInput = event => {
         const range = event.getTargetRanges()[0];
         eventRecorder += `beforeinput-${event.inputType}-${event.data}-${range.startOffset}-${range.endOffset}-`;
-    });
-    document.addEventListener('input', event => {
+    };
+    const onInput = event => {
         eventRecorder += `input-${event.inputType}`;
+    };
+    document.addEventListener('beforeinput', onBeforeInput);
+    document.addEventListener('input', onInput);
+    t.add_cleanup(() => {
+        document.removeEventListener('beforeinput', onBeforeInput);
+        document.removeEventListener('input', onInput);
     });
 
     const editable = document.getElementById('editable');
@@ -30,15 +36,22 @@
     assert_equals(eventRecorder, 'beforeinput-insertTranspose-cb-1-3-input-insertTranspose');
 }, 'Transpose on contenteditable');
 
-test(() => {
+test(t => {
     assert_not_equals(window.testRunner, undefined, 'This test requires testRunner.');
 
     let eventRecorder = '';
-    document.addEventListener('beforeinput', event => {
+    const onBeforeInput = event => {
+        assert_equals(event.getTargetRanges().length, 0);
         eventRecorder += `beforeinput-${event.inputType}-${event.data}-`;
-    });
-    document.addEventListener('input', event => {
+    };
+    const onInput = event => {
         eventRecorder += `input-${event.inputType}`;
+    };
+    document.addEventListener('beforeinput', onBeforeInput);
+    document.addEventListener('input', onInput);
+    t.add_cleanup(() => {
+        document.removeEventListener('beforeinput', onBeforeInput);
+        document.removeEventListener('input', onInput);
     });
 
     const txt = document.getElementById('txt');
Loading diff…

Original Bug Report

reported by za...@gmail.com

[Blink][InputEvent] cached targetRanges bypass shadow isolation, enabling page-controlled extension API invocation


Report description

[Blink][InputEvent] cached targetRanges bypass shadow isolation, enabling page-controlled extension API invocation


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/events/input_event.cc


The problem

Please describe the technical details of the vulnerability

Vulnerability Description

A hostile page can cache InputEvent.getTargetRanges()[0] from a trusted beforeinput fired inside an extension-created closed ShadowRoot and retain the exact extension-private endpoint node even though Blink’s normal Shadow DOM event protections remain effective.

The PoC deliberately mutates the private tree during the same trusted event:

PAGE capture listener
  event.target === public shadow host
  composedPath() hides private editor/action
  cache event.getTargetRanges()[0]

EXTENSION isolated-world target listener
  event.target === private editor
  detach the complete private wrapper

PAGE bubble listener, same trusted event
  event.target === public shadow host       // protection still holds
  composedPath() still hides private nodes

AFTER dispatch
  sameEvent.getTargetRanges().length === 0 // InputEvent cleanup occurred
  cachedRange.startContainer               // still exact private node

Blink has explicit lifecycle hardening for listener-induced Shadow DOM mutation: TargetInShadowDeterminedBeforeListener saves shadow-tree state before calling a listener (Chromium issue 41408360). The PoC confirms the normal event-facing isolation remains stable across the mutation.

However, InputEvent::getTargetRanges() snapshots its internal ranges without applying the caller’s tree-scope/shadow boundary:

StaticRangeVector InputEvent::getTargetRanges() const {
  StaticRangeVector static_ranges;
  for (const auto& range : ranges_)
    static_ranges.push_back(StaticRange::Create(range));
  return static_ranges;
}

StaticRange::Create() then stores the original endpoint nodes directly:

return MakeGarbageCollected<StaticRange>(
    range.StartPosition().ComputeContainerNode(),
    range.StartPosition().ComputeOffsetInContainerNode(),
    range.EndPosition().ComputeContainerNode(),
    range.EndPosition().ComputeOffsetInContainerNode());

InputEvent::DispatchEvent() clears ranges_ after dispatch, but that does not affect a StaticRange already returned to the page. The cached StaticRange independently retains its endpoint Node* values.

The page can therefore use the cached private endpoint after dispatch to recover the detached extension-private wrapper, locate the exact listener-bearing private action, move that real Node into page DOM, and invoke its pre-existing isolated-world listener. In the PoC that listener successfully reads a synthetic value from chrome.storage.local and returns it through the recovered action; a SHA-256 digest verifies the value.

The page itself has neither chrome.runtime nor chrome.storage.

Attack Preconditions

  • An extension content script creates editable content inside a closed ShadowRoot on an attacker-controlled page.
  • A private action in the same component has a pre-existing isolated-world listener using extension authority.
  • The user performs a normal edit, such as typing one character.
  • The component changes/detaches the private subtree during that trusted beforeinput dispatch. The PoC performs this mutation deterministically in the extension listener.
  • The hostile page has an ordinary outer beforeinput listener.

No security-disabling flags, DevTools shadow inspection, synthetic browser event, or direct page access to extension APIs is used.

Reproduction Steps / POC

The attached files are:

manifest.json
content.js
poc.html
run_server.py
  1. Open a terminal in poc/ and run:
py run_server.py
  1. Open chrome://extensions, enable Developer mode, choose Load unpacked, and select the poc directory.

  2. Open:

http://127.0.0.1:19957/poc.html
  1. Click the purple extension-private editor and type one character.

  2. The page should display:

REPRODUCED: CACHED TARGETRANGE RETAINS EXTENSION CAPABILITY

The decisive conditions are:

pageHasChromeRuntime = false
pageHasChromeStorage = false
hostShadowRootIsNull = true
directPrivateActionBefore = false

early.isTrusted = true
early.eventTargetIsPublicHost = true
early.composedPathContainsPrivateEditor = false
early.targetRangeCount = 1

middle.extensionSawPrivateEditorAsTarget = true
middle.privateWrapperDetached = true

late.sameTrustedEvent = true
late.eventTargetStillPublicHost = true
late.composedPathContainsPrivateEditor = false

afterDispatch.sameEventTargetRangeCount = 0
afterDispatch.blinkRangeCleanupObserved = true

cached.cachedStartNodeStillPrivate = true
cached.cachedRootIsDetachedPrivateWrapper = true
cached.privateActionRecoveredAfterDispatch = true

capability.exactPrivateActionMovedIntoPageDOM = true
capability.originalClosedShadowHostRemovedBeforeActivation = true
capability.isolatedWorldListenerRan = true
capability.syntheticStorageProofRecovered = true
capability.storageProofDigestMatches = true

finalReproduced = true

Impact analysis

The returned range endpoint is an actual extension-private DOM capability, not copied metadata.

The demonstrated chain is:

hostile page
  -> trusted beforeinput
  -> event target/composedPath correctly preserve closed-shadow isolation
  -> listener-time tree mutation occurs
  -> same event remains correctly retargeted
  -> InputEvent ranges are cleared after dispatch
  -> cached StaticRange still retains exact extension-private Node
  -> page recovers private listener-bearing action
  -> page transfers exact action into page DOM
  -> page activates it
  -> pre-existing isolated-world listener executes
  -> chrome.storage.local read succeeds
  -> digest-verified synthetic extension-private value reaches the page

This demonstrates cross-boundary capability transfer and page-controlled invocation of pre-existing extension-world JavaScript with extension API authority.

This report does not claim arbitrary JavaScript execution in the extension world, direct page access to chrome.storage, sandbox escape, or compromise of every extension. The impact depends on the private listener-bearing controls present in the affected component.

The security issue is that the event’s Shadow DOM exposure boundary is preserved while a parallel getTargetRanges() snapshot path hands the outer page a caller-retainable reference to the exact private node, allowing the private capability to survive the event and be transferred.

Root cause

InputEvent keeps editing ranges internally and exposes them by constructing StaticRange objects from the original endpoint nodes. There is no caller TreeScope check or closed-shadow endpoint rescoping in getTargetRanges() / StaticRange::Create().

The event-path side already has explicit lifecycle-aware Shadow DOM handling, and InputEvent also clears its internal ranges after dispatch. Those controls both work in the PoC. The leak survives through a separate retained object: the StaticRange already returned to the hostile caller continues tracing the original private endpoint nodes after dispatch and after the private subtree is detached.


The cause

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

152.0.7977.64 +stable, 154.0.8027.0+dev

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