Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DevTools
DescriptionUse after free in DevTools
ComponentDevTools
Sub-componentPlatform>DevTools
Bug ClassUAF
Tracker553931271
Fix commit0e5527264722 (devtools/devtools-frontend) +255/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
front_end/models/bindings/ResourceScriptMapping.ts
modified

Files Changed

  • front_end/models/persistence/NetworkPersistenceManager.ts
  • test/e2e/console/console-errors.test.ts
  • front_end/models/bindings/NetworkProject.ts
  • front_end/models/bindings/ResourceScriptMapping.ts
  • front_end/models/persistence/NetworkPersistenceManager.test.ts
  • front_end/models/bindings/ResourceScriptMapping.test.ts
  • front_end/models/persistence/BUILD.gn
From 0e5527264722c85d3771c38762ce63e9485dc165 Mon Sep 17 00:00:00 2001
From: Danil Somsikov <dsv@chromium.org>
Date: Mon, 21 Sep 2026 02:42:10 -0700
Subject: [PATCH] Prevent sourceURL annotations from spoofing origins and being locally overridden

Scripts can specify sourceURL annotations and source maps that are fully controlled by the page. This change ensures that:
- HTTP and HTTPS sourceURL annotations must match the origin of the script frame to prevent cross-origin resource spoofing.
- sourceURL annotations cannot evict existing UISourceCodes fetched directly from the network.
- UISourceCodes generated from sourceURL annotations or source maps cannot be saved as local overrides, preventing override directory poisoning.

Bug: 553931271
Change-Id: Ib9ac1c265445980585706830385697ee6d6d29d1
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8421102
Commit-Queue: Danil Somsikov <dsv@chromium.org>
Reviewed-by: Simon Zünd <szuend@chromium.org>
Auto-Submit: Danil Somsikov <dsv@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
---
diff --git a/front_end/models/bindings/NetworkProject.ts b/front_end/models/bindings/NetworkProject.ts
index 03a5bec..6866340 100644
--- a/front_end/models/bindings/NetworkProject.ts
+++ b/front_end/models/bindings/NetworkProject.ts
@@ -15,6 +15,7 @@
 
 export class NetworkProjectManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
   readonly #projectToTargetMap = new WeakMap<Workspace.Workspace.Project, SDK.Target.Target>();
+  readonly #sourceURLSynthesizedUISourceCodes = new WeakSet<Workspace.UISourceCode.UISourceCode>();
 
   static instance({forceNew}: {
     forceNew: boolean,
@@ -41,6 +42,14 @@
   getTargetForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): SDK.Target.Target|null {
     return this.#projectToTargetMap.get(uiSourceCode.project()) ?? null;
   }
+
+  setSourceURLSynthesized(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
+    this.#sourceURLSynthesizedUISourceCodes.add(uiSourceCode);
+  }
+
+  isSourceURLSynthesized(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
+    return this.#sourceURLSynthesizedUISourceCodes.has(uiSourceCode);
+  }
 }
 
 export const enum Events {
@@ -59,6 +68,27 @@
 }
 
 export class NetworkProject {
+  /**
+   * Records that `uiSourceCode` was synthesized from a `//# sourceURL=` annotation,
+   * and therefore doesn't correspond to an actual network resource.
+   */
+  static setSourceURLSynthesized(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
+    // eslint-disable-next-line @devtools/no-instance-of-migrated-singletons
+    NetworkProjectManager.instance().setSourceURLSynthesized(uiSourceCode);
+  }
+
+  /**
+   * Whether `uiSourceCode` was synthesized from a `//# sourceURL=` annotation.
+   *
+   * Both the URL and the content of such a source are fully controlled by the page,
+   * so it must never be treated like a genuine network resource (for example it must
+   * not be persisted as a local override, see b/553931271).
+   */
+  static isSourceURLSynthesized(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
+    // eslint-disable-next-line @devtools/no-instance-of-migrated-singletons
+    return NetworkProjectManager.instance().isSourceURLSynthesized(uiSourceCode);
+  }
+
   static resolveFrame(uiSourceCode: Workspace.UISourceCode.UISourceCode, frameId: Protocol.Page.FrameId):
       SDK.ResourceTreeModel.ResourceTreeFrame|null {
     const target = NetworkProject.targetForUISourceCode(uiSourceCode);
diff --git a/front_end/models/bindings/ResourceScriptMapping.test.ts b/front_end/models/bindings/ResourceScriptMapping.test.ts
index 8c073d2..f296fdf 100644
--- a/front_end/models/bindings/ResourceScriptMapping.test.ts
+++ b/front_end/models/bindings/ResourceScriptMapping.test.ts
@@ -7,7 +7,9 @@
 import * as Platform from '../../core/platform/platform.js';
 import * as SDK from '../../core/sdk/sdk.js';
 import * as TextUtils from '../../core/text_utils/text_utils.js';
+import {setupLocaleHooks} from '../../testing/LocaleHelpers.js';
 import {MockDebuggerBackend} from '../../testing/MockScopeChain.js';
+import {getMainFrame, SECURITY_ORIGIN} from '../../testing/ResourceTreeHelpers.js';
 import {setupRuntimeHooks} from '../../testing/RuntimeHelpers.js';
 import {setupSettingsHooks} from '../../testing/SettingsHelpers.js';
 
@@ -16,6 +18,7 @@
 const {urlString} = Platform.DevToolsPath;
 
 describe('ResourceScriptMapping', () => {
+  setupLocaleHooks();
   setupRuntimeHooks();
   setupSettingsHooks();
 
@@ -73,7 +76,9 @@
     });
 
     it('maps UI locations in inline scripts with sourceURL', async () => {
-      const script = await backend.addScript(target, {content: contentWithSourceUrl, url, hasSourceURL: true}, null);
+      getMainFrame(target);
+      const script = await backend.addScript(
+          target, {content: contentWithSourceUrl, url: `${SECURITY_ORIGIN}/example.js`, hasSourceURL: true}, null);
       const uiSourceCode = resourceScriptMapping.uiSourceCodeForScript(script);
       assert.exists(uiSourceCode);
 
@@ -109,4 +114,85 @@
 
     assert.isNull(uiSourceCode);
   });
+
+  describe('for scripts with a `//# sourceURL` annotation', () => {
+    const content = contentWithSourceUrl;
+
+    it('creates a UISourceCode for same-origin URLs', async () => {
+      getMainFrame(target);
+
+      const script = await backend.addScript(
+          target, {content, url: `${SECURITY_ORIGIN}/from-source-url.js`, hasSourceURL: true}, null);
+
+      const uiSourceCode = resourceScriptMapping.uiSourceCodeForScript(script);
+      assert.exists(uiSourceCode);
+      assert.isTrue(Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(uiSourceCode));
+    });
+
+    it('ignores annotations claiming a cross-origin URL', async () => {
+      getMainFrame(target);
+
+      const script = await backend.addScript(
+          target, {content, url: 'https://not-example.com/from-source-url.js', hasSourceURL: true}, null);
+
+      assert.isNull(resourceScriptMapping.uiSourceCodeForScript(script));
+    });
+
+    it('accepts annotations with schemes that cannot be network resources', async () => {
+      getMainFrame(target);
+
+      const script = await backend.addScript(
+          target, {content, url: 'webpack-internal:///./src/index.js', hasSourceURL: true}, null);
+
+      assert.exists(resourceScriptMapping.uiSourceCodeForScript(script));
+    });
+
+    it('does not evict the UISourceCode of a script that was fetched from the network', async () => {
+      getMainFrame(target);
+      const networkURL = `${SECURITY_ORIGIN}/example.js`;
+      const networkScript = await backend.addScript(
+          target, {content: contentWithoutSourceUrl, url: networkURL, hasSourceURL: false}, null);
+      const networkUISourceCode = resourceScriptMapping.uiSourceCodeForScript(networkScript);
+      assert.exists(networkUISourceCode);
+
+      const spoofingScript = await backend.addScript(target, {content, url: networkURL, hasSourceURL: true}, null);
+
+      assert.isNull(resourceScriptMapping.uiSourceCodeForScript(spoofingScript));
+      assert.strictEqual(resourceScriptMapping.uiSourceCodeForScript(networkScript), networkUISourceCode);
+      assert.isFalse(Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(networkUISourceCode));
+    });
+
+    it('falls back to the inspected origin when the frame is unknown', async () => {
+      target.setInspectedURL(urlString`${`${SECURITY_ORIGIN}/index.html`}`);
+
+      const sameOrigin = await backend.addScript(
+          target, {content, url: `${SECURITY_ORIGIN}/from-source-url.js`, hasSourceURL: true}, null);
+      const crossOrigin = await backend.addScript(
+          target, {content, url: 'https://not-example.com/from-source-url.js', hasSourceURL: true}, null);
+
+      assert.exists(resourceScriptMapping.uiSourceCodeForScript(sameOrigin));
+      assert.isNull(resourceScriptMapping.uiSourceCodeForScript(crossOrigin));
+    });
+
+    it('ignores annotations when no origin can be established', async () => {
+      // Neither a frame nor an inspected URL: we can't tell whether the annotation
+      // spoofs another origin, so it must not be used.
+      const script = await backend.addScript(
+          target, {content, url: `${SECURITY_ORIGIN}/from-source-url.js`, hasSourceURL: true}, null);
+
+      assert.isNull(resourceScriptMapping.uiSourceCodeForScript(script));
+    });
+
+    it('replaces the UISourceCode of an earlier script with the same annotation', async () => {
+      getMainFrame(target);
+      const sourceURL = `${SECURITY_ORIGIN}/from-source-url.js`;
+      const oldScript = await backend.addScript(target, {content, url: sourceURL, hasSourceURL: true}, null);
+      assert.exists(resourceScriptMapping.uiSourceCodeForScript(oldScript));
+
+      const newScript = await backend.addScript(target, {content, url: sourceURL, hasSourceURL: true}, null);
+
+      assert.exists(resourceScriptMapping.uiSourceCodeForScript(newScript));
+      assert.isNull(resourceScriptMapping.uiSourceCodeForScript(oldScript));
+    });
+  });
 });
diff --git a/front_end/models/bindings/ResourceScriptMapping.ts b/front_end/models/bindings/ResourceScriptMapping.ts
index e01c6f7..185ba78 100644
--- a/front_end/models/bindings/ResourceScriptMapping.ts
+++ b/front_end/models/bindings/ResourceScriptMapping.ts
@@ -14,6 +14,19 @@
 import {NetworkProject} from './NetworkProject.js';
 import {metadataForURL} from './ResourceUtils.js';
 
+/**
+ * The security origin of the URL that `target` (or its closest ancestor that has
+ * one) is inspecting, or `null` if none of them has an inspected URL yet.
+ */
+function inspectedSecurityOrigin(target: SDK.Target.Target): SDK.SecurityOrigin.SecurityOrigin|null {
+  for (let current: SDK.Target.Target|null = target; current; current = current.parentTarget()) {
+    if (current.inspectedURL()) {
+      return current.inspectedSecurityOrigin();
+    }
+  }
+  return null;
+}
+
 export class ResourceScriptMapping implements DebuggerSourceMapping {
   readonly debuggerModel: SDK.DebuggerModel.DebuggerModel;
   #workspace: Workspace.Workspace.WorkspaceImpl;
@@ -197,6 +210,19 @@
       // Try to resolve `//# sourceURL=` annotations relative to
       // the base URL, according to the sourcemap specification.
       url = SDK.SourceMapManager.SourceMapManager.resolveRelativeSourceURL(script.debuggerModel.target(), url);
+
+      // `//# sourceURL=` annotations are fully controlled by the page, so don't let
+      // them impersonate a resource of a different origin (b/553931271).
+      if (!this.#isTrustworthySourceURL(script, url)) {
+        return;
+      }
+
+      // Don't let a synthetic source evict the `UISourceCode` of a script that was
+      // actually fetched from the network under the same URL.
+      const previousUISourceCode = this.project(script).uiSourceCodeForURL(url);
+      if (previousUISourceCode && !NetworkProject.isSourceURLSynthesized(previousUISourceCode)) {
+        return;
+      }
     } else {
       // Ignore inline <script>s without `//# sourceURL` annotation here.
       if (script.isInlineScript()) {
@@ -225,6 +251,9 @@
     // Create UISourceCode.
     const originalContentProvider = script.originalContentProvider();
     const uiSourceCode = project.createUISourceCode(url, originalContentProvider.contentType());
+    if (script.hasSourceURL) {
+      NetworkProject.setSourceURLSynthesized(uiSourceCode);
+    }
     NetworkProject.setInitialFrameAttribution(uiSourceCode, script.frameId);
     const metadata = metadataForURL(this.debuggerModel.target(), script.frameId, url);
 
@@ -238,6 +267,39 @@
     void this.debuggerWorkspaceBinding.updateLocations(script);
   }
 
+  /**
+   * Whether the (already resolved) `//# sourceURL=` annotation `url` of `script` may
+   * be used as-is.
+   *
+   * Only `http(s)` URLs can collide with genuine network resources, so an origin is
+   * only enforced for those. Annotations using other schemes (e.g. `webpack-internal://`,
+   * `snippet://` or `chrome-extension://` for content scripts) cannot impersonate a
+   * network resource and are always accepted.
+   *
+   * The origin of the script's frame is authoritative. For targets that don't have a
+   * frame (e.g. workers) we fall back to the inspected URL of the target (or of its
+   * closest ancestor that has one). If no origin can be established at all, the
+   * annotation is rejected, since we cannot rule out that it spoofs another origin.
+   */
+  #isTrustworthySourceURL(script: SDK.Script.Script, url: Platform.DevToolsPath.UrlString): boolean {
+    if (script.isContentScript()) {
+      return true;
+    }
+    const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
+    if (!parsedURL || !['http', 'https'].includes(parsedURL.scheme)) {
+      return true;
+    }
+    const target = script.debuggerModel.target();
+    const frame = script.frameId ?
+        target.model(SDK.ResourceTreeModel.ResourceTreeModel)?.frameForId(script.frameId) ?? null :
+        null;
+    const securityOrigin = frame ? frame.securityOrigin() : inspectedSecurityOrigin(target);
+    if (!securityOrigin) {
+      return false;
+    }
+    return SDK.SecurityOrigin.SecurityOrigin.create(url).isSameOriginWith(securityOrigin);
+  }
+
   scriptFile(uiSourceCode: Workspace.UISourceCode.UISourceCode): ResourceScriptFile|null {
     return this.#uiSourceCodeToScriptFile.get(uiSourceCode) || null;
   }
diff --git a/front_end/models/persistence/NetworkPersistenceManager.test.ts b/front_end/models/persistence/NetworkPersistenceManager.test.ts
index 3b12287..5cde2c7 100644
--- a/front_end/models/persistence/NetworkPersistenceManager.test.ts
+++ b/front_end/models/persistence/NetworkPersistenceManager.test.ts
@@ -17,6 +17,7 @@
 import {setupSettingsHooks} from '../../testing/SettingsHelpers.js';
 import {createTarget} from '../../testing/TargetHelpers.js';
 import {createFileSystemUISourceCode} from '../../testing/UISourceCodeHelpers.js';
+import * as Bindings from '../bindings/bindings.js';
 import * as Formatter from '../formatter/formatter.js';
 import * as Persistence from '../persistence/persistence.js';
 import * as Workspace from '../workspace/workspace.js';
@@ -127,6 +128,48 @@
     assert.isTrue(saveSpy.notCalled, 'should not attempt to save override');
   });
 
+  it('does not persist sources that come from a source map', async () => {
+    const url = 'http://www.example.com/src/script.ts';
+    const resourceType = Common.ResourceType.resourceTypes.SourceMapScript;
+
+    const {uiSourceCode} = setUpEnvironmentWithUISourceCode(url, resourceType);
+    const networkPersistenceManager = await createWorkspaceProject(urlString`file:///path/to/overrides`, []);
+    const overridesProject = networkPersistenceManager.project();
+    assert.exists(overridesProject);
+    const createFileSpy = sinon.spy(overridesProject, 'createFile');
+
+    // The context menu still offers "Override content" for source mapped files, but it
+    // redirects to the deployed file instead of persisting the source mapped one.
+    assert.isTrue(networkPersistenceManager.isUISourceCodeOverridable(uiSourceCode));
+
+    const actual = await networkPersistenceManager.setupAndStartLocalOverrides(uiSourceCode);
+    await networkPersistenceManager.saveUISourceCodeForOverrides(uiSourceCode);
+
+    createFileSpy.restore();
+
+    assert.isFalse(actual, 'should not allow override');
+    assert.isTrue(createFileSpy.notCalled, 'should not write an override file');
+  });
+
+  it('does not allow overrides for sources synthesized from a `//# sourceURL` annotation', async () => {
+    const url = 'http://www.example.com/script.js';
+    const resourceType = Common.ResourceType.resourceTypes.Script;
+
+    const {uiSourceCode} = setUpEnvironmentWithUISourceCode(url, resourceType);
+    Bindings.NetworkProject.NetworkProject.setSourceURLSynthesized(uiSourceCode);
+    const networkPersistenceManager = await createWorkspaceProject(urlString`file:///path/to/overrides`, []);
+
+    assert.isFalse(networkPersistenceManager.isUISourceCodeOverridable(uiSourceCode));
+
+    const saveSpy = sinon.spy(networkPersistenceManager, 'saveUISourceCodeForOverrides');
+    const actual = await networkPersistenceManager.setupAndStartLocalOverrides(uiSourceCode);
+
+    saveSpy.restore();
+
+    assert.isFalse(actual, 'should not allow override');
+    assert.isTrue(saveSpy.notCalled, 'should not attempt to save override');
+  });
+
   it('does not save override on working copy committed for data URLs', async () => {
     const url = 'data:/../victim.com/script.js';
     const resourceType = Common.ResourceType.resourceTypes.Script;
diff --git a/front_end/models/persistence/NetworkPersistenceManager.ts b/front_end/models/persistence/NetworkPersistenceManager.ts
index 59e4284..80d91a5 100644
--- a/front_end/models/persistence/NetworkPersistenceManager.ts
+++ b/front_end/models/persistence/NetworkPersistenceManager.ts
@@ -9,6 +9,7 @@
 import * as SDK from '../../core/sdk/sdk.js';
 import * as TextUtils from '../../core/text_utils/text_utils.js';
 import * as Protocol from '../../generated/protocol.js';
+import * as Bindings from '../bindings/bindings.js';
 import * as Breakpoints from '../breakpoints/breakpoints.js';
 import * as Workspace from '../workspace/workspace.js';
 
@@ -431,8 +432,32 @@
   }
 
   isUISourceCodeOverridable(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
-    return uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network &&
-        !NetworkPersistenceManager.isForbiddenNetworkUrl(uiSourceCode.url());
+    if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.Network) {
+      return false;
+    }
+    if (NetworkPersistenceManager.isForbiddenNetworkUrl(uiSourceCode.url())) {
+      return false;
+    }
+    // A `//# sourceURL=` annotation is fully controlled by the page and doesn't refer
+    // to an actual network resource, so there is nothing to override here. Persisting
+    // it would poison the overrides folder with a file that masquerades as a genuine
+    // resource (b/553931271).
+    if (Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(uiSourceCode)) {
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Whether the contents of `uiSourceCode` may be written into the overrides folder.
+   *
+   * Sources that originate from a source map are overridable in the sense that the
+   * deployed resource they are mapped from can be overridden (see
+   * `PersistenceActions`), but their own URL and content are page controlled and
+   * must never be persisted themselves (b/553931271).
+   */
+  #canPersistUISourceCodeAsOverride(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
+    return this.isUISourceCodeOverridable(uiSourceCode) && !uiSourceCode.contentType().isFromSourceMap();
   }
 
   #isUISourceCodeAlreadyOverridden(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
@@ -445,12 +470,12 @@
   }
 
   #canSaveUISourceCodeForOverrides(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
-    return this.#active && this.isUISourceCodeOverridable(uiSourceCode) &&
+    return this.#active && this.#canPersistUISourceCodeAsOverride(uiSourceCode) &&
... (file diff truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

DevTools Local Overrides Spoofing via Unvalidated UISourceCode URLs

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: DevTools Local Overrides determines override file paths and persisted contents based on a UISourceCode’s reported URL and content without verifying provenance from an actual network response. Attacker-controlled scripts can forge UISourceCode URLs and content via //# sourceURL annotations and source maps. If a developer initiates local overrides on a mapped asset, attacker-controlled code can be persisted to disk and intercepted for genuine first-party requests across sessions.

Affected files:

  • third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts
  • third_party/devtools-frontend/src/front_end/panels/sources/PersistenceActions.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/ResourceScriptMapping.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/CompilerScriptMapping.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/DebuggerWorkspaceBinding.ts

Estimated timestamp from git blame: 2017-10-18

Summary

DevTools Local Overrides allows developers to save local modifications of network resources to disk and intercept future network requests to serve the local copies. However, DevTools determines which URL to override and what content to save based entirely on UISourceCode.url() and UISourceCode.requestContentData(), without validating that the UISourceCode is backed by a verified network response for that origin.

Because //# sourceURL and source map comments allow web scripts to declare arbitrary URLs in projectTypes.Network projects, an attacker-injected script can replace genuine workspace resources and redirect override actions, causing DevTools to persist malicious content as a persistent local override for a first-party origin.

Affected Code

  • third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/ResourceScriptMapping.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/CompilerScriptMapping.ts
  • third_party/devtools-frontend/src/front_end/panels/sources/PersistenceActions.ts
  • third_party/devtools-frontend/src/front_end/models/bindings/DebuggerWorkspaceBinding.ts

Root Cause Analysis

  1. Unchecked Overridability: NetworkPersistenceManager.isUISourceCodeOverridable (NetworkPersistenceManager.ts:433-436) only verifies that the project type is Workspace.Workspace.projectTypes.Network and that the URL is not in a restricted list (e.g., chrome: URLs). It does not verify the backing resource or network provenance.
  2. Workspace Poisoning via Source Comments: In ResourceScriptMapping.ts:186-239, when a script has script.hasSourceURL, DevTools resolves the URL with SourceMapManager.resolveRelativeSourceURL() (which keeps absolute URLs intact). ResourceScriptMapping assigns this to a projectTypes.Network project and deletes any previous genuine UISourceCode registered at that URL (ResourceScriptMapping.ts:215-223).
  3. Source-Map Redirection Deception: In PersistenceActions.ts:150-161 and 226-248, right-clicking a source-mapped original and selecting “Override content” resolves the deployed script via DebuggerWorkspaceBinding.uiSourceCodeForScript(script). DebuggerWorkspaceBinding prioritizes ResourceScriptMapping (DebuggerWorkspaceBinding.ts:602-608), returning the attacker-controlled UISourceCode.
  4. Persistence & Interception: When the user confirms the override dialog, NetworkPersistenceManager.setupAndStartLocalOverrides commits and saves the file via saveUISourceCodeForOverrides (NetworkPersistenceManager.ts:487-508), writing the attacker’s script text to the local overrides folder under the victim domain path. DevTools then registers a CDP Fetch.enable interception pattern (NetworkPersistenceManager.ts:753-790), causing future genuine network requests for that first-party URL to be intercepted and served with the attacker’s payload.

Potential Attack Steps

(Note: These are suggested/potential reproduction steps based on static analysis, as automated live execution has not been run.)

  1. An attacker gains script execution on https://victim.example (e.g., via a compromised third-party script/ad) or hosts a page inspected by a developer.
  2. The script executes an evaluated bundle or loads an external script containing:
    eval(realBundle + backdoorPayload +
      "\n//# sourceMappingURL=data:application/json;base64," + btoa(mapJson) +
      "\n//# sourceURL=https://victim.example/static/main.min.js");
    
    where mapJson defines sources: ["https://victim.example/src/app.ts"].
  3. The developer opens DevTools, views https://victim.example/src/app.ts under the Sources panel, and selects Override content.
  4. DevTools displays the expected dialog: "‘app.ts’ is a source mapped file and cannot be overridden. Override ‘main.min.js’ instead?".
  5. Upon developer confirmation, DevTools writes the attacker’s payload to overrides/victim.example/static/main.min.js and enables local overrides.
  6. On subsequent visits or reloads with DevTools open, requests for https://victim.example/static/main.min.js are fulfilled with the local override, running the backdoor in the victim’s origin.

Suggested Fix

  1. Ensure isUISourceCodeOverridable and saveUISourceCodeForOverrides verify that the target UISourceCode originates from an actual network response (e.g., backed by an active SDK.NetworkRequest.NetworkRequest matching the URL) rather than purely from script.sourceURL comments or synthetic source-map entries.
  2. Prevent scripts marked with hasSourceURL from evicting or overriding legitimate network-fetched UISourceCode instances in ResourceScriptMapping.

Evaluated with Chrome root at commit: 796d95f11afe18561709217227313319d6660322


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker