CVE-2026-113110
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/events/input_event.cc |
modified |
Files Changed
third_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/web_tests/fast/events/inputevents/inputevent-transpose.htmlthird_party/blink/renderer/core/events/input_event.ccthird_party/blink/web_tests/external/wpt/input-events/input-events-get-target-ranges-shadow-dom.html
Patch
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>
Regression Test / PoC
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');
Original Bug Report
[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?
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
beforeinputdispatch. The PoC performs this mutation deterministically in the extension listener. - The hostile page has an ordinary outer
beforeinputlistener.
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
- Open a terminal in
poc/and run:
py run_server.py
-
Open
chrome://extensions, enable Developer mode, choose Load unpacked, and select thepocdirectory. -
Open:
http://127.0.0.1:19957/poc.html
-
Click the purple extension-private editor and type one character.
-
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
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