Medium watch ✔︎ verified CVSS 5.4 chrome Spoofing 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Session
DescriptionUI misrepresentation in Session
ComponentSession
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — upheld by independent review by AI
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.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker514056835
Fix commitacc73923fea2 (chromium/src) +82/-35
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-08-04 (public git)
Disclosed2026-09-08
Patch gap35 days — fix public in source before the advisory

Background

Media session `source_title`
The origin string shown to the user for a media session, e.g. the origin label in a Picture-in-Picture (PiP) window, computed by BuildMetadata() in media_session_impl.cc.
Committed origin vs. committed URL
A frame’s GetLastCommittedOrigin() reflects security-inherited origin (an about:blank document inherits its creator’s origin; a sandboxed frame gets an opaque origin with a precursor), whereas GetLastCommittedURL() for about:blank is just about:blank.
Opaque origin / precursor
An opaque origin (e.g. for about:blank or sandboxing) carries a precursor tuple recoverable via GetTupleOrPrecursorTupleIfOpaque().
Opener chain
The GetOpener() relationship linking a popup to the frame that created it, which can navigate cross-origin independently after the popup is created.

Root Cause Analysis

In BuildMetadata(), the source origin was derived from the committed URL via url::Origin::Create(url). For an about:blank document this yields an opaque origin, so the old code entered a fallback that walked the opener chain, reading each WebContents’s current GetLastCommittedURL() and using the first valid precursor tuple it found. Because the opener’s current URL is read at title-computation time, an opener that had navigated cross-origin after opening the popup would supply a different origin than the popup actually inherited, so the PiP source title displayed the opener’s current (attacker-controlled) origin rather than the popup’s true inherited origin — a spoofable UI label.

The fix removes the opener-chain traversal entirely and instead reads web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin(), then formats origin.GetTupleOrPrecursorTupleIfOpaque().GetURL(). This is correct because GetLastCommittedOrigin() already encodes about:blank inheritance (the creator’s origin) and sandboxed precursors, so the title reflects the frame’s own committed origin and no longer depends on the mutable opener URL.

Key insight
The single mistake was reconstructing the source origin from the committed URL and, for about:blank, falling back to the opener’s current URL — which the opener can change after popup creation — instead of using the frame’s already-inherited committed origin; the fix reads GetPrimaryMainFrame()->GetLastCommittedOrigin() directly.

Attack Path

  1. Open an `about:blank` popup An attacker page on Host 1 calls window.open('about:blank'); the popup’s committed URL is about:blank but its committed origin is inherited from Host 1.
  2. Navigate the opener cross-origin The attacker navigates the opener tab to Host 2; the popup keeps its inherited Host 1 origin and its opener relationship.
  3. Trigger media metadata The popup plays a video and requests Picture-in-Picture, causing BuildMetadata() to compute the source title.
  4. Spoofed origin displayed Pre-patch, because the popup’s committed URL is about:blank (opaque), the opener-chain fallback read the opener’s current URL (Host 2) and displayed Host 2 as the media source origin, misrepresenting the true origin (Host 1) to the user.

Impact Assessment

An attacker who controls a page that opens an about:blank popup and then navigates its opener cross-origin can cause the PiP/media source-title UI to display an origin of their choosing rather than the popup’s actual inherited origin, a UI spoofing / misrepresentation issue in the media session. The advisory classifies this as UI misrepresentation (spoofing) in Session; no exploited-in-the-wild status is stated. It is confined to the origin label shown for media sessions and grants no memory or cross-origin data access; any escalation beyond visual origin confusion is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
ASSERT_TRUE
chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
modified

Files Changed

  • content/browser/media/session/media_session_impl.cc
  • chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc

Audit Directions

  • Origin derived from URL instead of committed origin
    Search for security- or UI-relevant origin computations using url::Origin::Create(GetLastCommittedURL()) and switch them to GetLastCommittedOrigin(), which correctly handles about:blank inheritance and sandboxed precursors.
  • Opener-chain / cross-document fallbacks
    Audit code that walks GetOpener() or other cross-WebContents relationships to recover an origin, since those objects can navigate independently and yield stale/attacker-controlled values at read time.
  • Opaque-origin handling in UI surfaces
    Review displays of origin/title (media, permissions, share dialogs, PiP) for how they resolve opaque origins, ensuring they use the precursor tuple of the frame’s own origin rather than an ancestor’s current state.
From acc73923fea2710e74d6c5d3b896ba295f8a22c5 Mon Sep 17 00:00:00 2001
From: Benjamin Keen <[email protected]>
Date: Tue, 04 Aug 2026 14:00:07 -0700
Subject: [PATCH] Compute source_title from the committed origin of the primary main frame

`BuildMetadata()` previously derived the source origin from the
committed URL. For about:blank documents, this created an opaque origin
and fell back to reading the opener's current URL, which could be
spoofed if the opener navigated cross-origin.

This CL fixes this by using the committed origin of the primary main
frame directly, which handles about:blank inheritance and sandboxed
precursors, allowing the opener-chain fallback to be removed.

Bug: 514056835
Change-Id: Ia489373461d54591fe1df8af3e7071941f80d9b9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8191294
Commit-Queue: Benjamin Keen <[email protected]>
Reviewed-by: Tommy Steimel <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1673651}
---
diff --git a/content/browser/media/session/media_session_impl.cc b/content/browser/media/session/media_session_impl.cc
index a4580de..297a5049 100644
--- a/content/browser/media/session/media_session_impl.cc
+++ b/content/browser/media/session/media_session_impl.cc
@@ -8,7 +8,6 @@
 #include <memory>
 #include <utility>
 
-#include "base/containers/flat_set.h"
 #include "base/functional/bind.h"
 #include "base/strings/string_util.h"
 #include "base/strings/utf_string_conversions.h"
@@ -2063,35 +2062,13 @@
       source_title =
           content_client->GetLocalizedString(IDS_MEDIA_SESSION_DATA_SOURCE);
     } else {
-      url::Origin origin = url::Origin::Create(url);
-      GURL format_url = origin.GetURL();
-
-      // If the origin is opaque, use its precursor origin if available.
-      // Otherwise, traverse the opener chain to find the closest ancestor with
-      // a valid precursor origin. This ensures we display a recognizable origin
-      // to the user.
-      if (origin.opaque()) {
-        WebContents* current_web_contents = web_contents();
-        base::flat_set<WebContents*> seen_web_contents;
-
-        while (current_web_contents &&
-               !seen_web_contents.contains(current_web_contents)) {
-          seen_web_contents.insert(current_web_contents);
-
-          url::Origin current_origin =
-              url::Origin::Create(current_web_contents->GetLastCommittedURL());
-          const auto& precursor =
-              current_origin.GetTupleOrPrecursorTupleIfOpaque();
-          if (precursor.IsValid()) {
-            format_url = precursor.GetURL();
-            break;
-          }
-
-          RenderFrameHost* opener = current_web_contents->GetOpener();
-          current_web_contents =
-              opener ? WebContents::FromRenderFrameHost(opener) : nullptr;
-        }
-      }
+      // Use the frame's committed origin to determine the source title.
+      // This retrieves the inherited origin for "about:blank" documents
+      // (which is the origin of their creator) and the precursor origin for
+      // sandboxed documents (which have opaque origins).
+      const url::Origin& origin =
+          web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin();
+      GURL format_url = origin.GetTupleOrPrecursorTupleIfOpaque().GetURL();
 
       source_title = url_formatter::FormatUrl(
           format_url,
diff --git a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
index ce23ee9c..b0c50c4b 100644
--- a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
@@ -2565,7 +2565,7 @@
   ASSERT_TRUE(ExecJs(popup_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back to the opener's origin (example.com).
+  // reflects the precursor origin of the opaque sandboxed frame (example.com).
   SetUpWindowController(popup_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2622,8 +2622,7 @@
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back through the nested openers to the original origin
-  // (example.com).
+  // reflects the inherited precursor origin of the nested popups (example.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2635,7 +2634,7 @@
 }
 
 IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
-                       SourceTitle_ClosestAncestorFallback) {
+                       SourceTitle_InheritedFromNavigatedOpener) {
   const std::string kHost1 = "example.com";
   const std::string kHost2 = "another-site.com";
   const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost2);
@@ -2691,7 +2690,8 @@
       {video_url.spec()}, nullptr);
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
-  // Verify source title is Host 2 (the closest opener with a valid precursor).
+  // Verify the source title reflects the opener's origin at the time of the
+  // popup's creation (another-site.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2702,6 +2702,76 @@
   }));
 }
 
+IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
+                       SourceTitle_AboutBlankPopupUsesInheritedOrigin) {
+  const std::string kHost1 = "example.com";
+  const std::string kHost2 = "another-site.com";
+  const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost1);
+
+  // Open a non-sandboxed page on Host 1.
+  GURL url1 = embedded_test_server()->GetURL(kHost1, "/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url1));
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  // Open an about:blank popup. The popup's committed URL is about:blank, but
+  // its committed origin is inherited from Host 1.
+  content::WebContents* popup_contents;
+  {
+    content::WebContentsAddedObserver observer;
+    ASSERT_TRUE(ExecJs(active_web_contents, "window.open('about:blank');"));
+    popup_contents = observer.GetWebContents();
+  }
+  ASSERT_FALSE(
+      popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+  ASSERT_EQ(url::Origin::Create(url1),
+            popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+
+  // Navigate the opener cross-origin to Host 2. The popup keeps its inherited
+  // Host 1 origin and its opener relationship.
+  GURL url2 = embedded_test_server()->GetURL(kHost2, "/title1.html");
+  {
+    content::TestNavigationObserver nav_observer(active_web_contents);
+    ASSERT_TRUE(ExecJs(active_web_contents,
+                       base::StringPrintf("window.location.href = '%s';",
+                                          url2.spec().c_str())));
+    nav_observer.Wait();
+  }
+  ASSERT_EQ(active_web_contents->GetPrimaryMainFrame(),
+            popup_contents->GetOpener());
+
+  // Play video in the popup and request Picture-in-Picture.
+  GURL video_url = embedded_test_server()->GetURL(kHost1, "/media/bear.webm");
+  std::string script = base::ReplaceStringPlaceholders(
+      R"(
+        const video = document.createElement('video');
+        video.src = '$1';
+        video.loop = true;
+        document.body.appendChild(video);
+        video.play().then(() => video.requestPictureInPicture());
+      )",
+      {video_url.spec()}, nullptr);
+  ASSERT_TRUE(ExecJs(popup_contents, script));
+
+  // Wait until the Picture-in-Picture window is visible and its source title
+  // has been populated.
+  SetUpWindowController(popup_contents);
+  ASSERT_TRUE(base::test::RunUntil([&]() {
+    auto* overlay_window = GetOverlayWindow();
+    return overlay_window && overlay_window->IsVisible() &&
+           overlay_window->origin_for_testing() &&
+           !overlay_window->origin_for_testing()->GetText().empty();
+  }));
+
+  // The source title must reflect the popup's own (inherited) origin, not the
+  // opener's current origin.
+  EXPECT_TRUE(
+      base::StartsWith(GetOverlayWindow()->origin_for_testing()->GetText(),
+                       kExpectedTitlePrefix))
+      << "source title is '"
+      << GetOverlayWindow()->origin_for_testing()->GetText() << "'";
+}
+
 struct InteractionTestParam {
   ui::EventType event_type;
   bool title_should_be_visible;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
index ce23ee9c..b0c50c4b 100644
--- a/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/video_picture_in_picture_window_controller_browsertest.cc
@@ -2565,7 +2565,7 @@
   ASSERT_TRUE(ExecJs(popup_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back to the opener's origin (example.com).
+  // reflects the precursor origin of the opaque sandboxed frame (example.com).
   SetUpWindowController(popup_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2622,8 +2622,7 @@
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
   // Wait until the Picture-in-Picture window is visible and its source title
-  // correctly falls back through the nested openers to the original origin
-  // (example.com).
+  // reflects the inherited precursor origin of the nested popups (example.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2635,7 +2634,7 @@
 }
 
 IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
-                       SourceTitle_ClosestAncestorFallback) {
+                       SourceTitle_InheritedFromNavigatedOpener) {
   const std::string kHost1 = "example.com";
   const std::string kHost2 = "another-site.com";
   const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost2);
@@ -2691,7 +2690,8 @@
       {video_url.spec()}, nullptr);
   ASSERT_TRUE(ExecJs(popup2_contents, script));
 
-  // Verify source title is Host 2 (the closest opener with a valid precursor).
+  // Verify the source title reflects the opener's origin at the time of the
+  // popup's creation (another-site.com).
   SetUpWindowController(popup2_contents);
   ASSERT_TRUE(base::test::RunUntil([&]() {
     auto* overlay_window = GetOverlayWindow();
@@ -2702,6 +2702,76 @@
   }));
 }
 
+IN_PROC_BROWSER_TEST_F(VideoPictureInPictureWindowControllerBrowserTest,
+                       SourceTitle_AboutBlankPopupUsesInheritedOrigin) {
+  const std::string kHost1 = "example.com";
+  const std::string kHost2 = "another-site.com";
+  const std::u16string kExpectedTitlePrefix = base::ASCIIToUTF16(kHost1);
+
+  // Open a non-sandboxed page on Host 1.
+  GURL url1 = embedded_test_server()->GetURL(kHost1, "/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url1));
+  content::WebContents* active_web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  // Open an about:blank popup. The popup's committed URL is about:blank, but
+  // its committed origin is inherited from Host 1.
+  content::WebContents* popup_contents;
+  {
+    content::WebContentsAddedObserver observer;
+    ASSERT_TRUE(ExecJs(active_web_contents, "window.open('about:blank');"));
+    popup_contents = observer.GetWebContents();
+  }
+  ASSERT_FALSE(
+      popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+  ASSERT_EQ(url::Origin::Create(url1),
+            popup_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+
+  // Navigate the opener cross-origin to Host 2. The popup keeps its inherited
+  // Host 1 origin and its opener relationship.
+  GURL url2 = embedded_test_server()->GetURL(kHost2, "/title1.html");
+  {
+    content::TestNavigationObserver nav_observer(active_web_contents);
+    ASSERT_TRUE(ExecJs(active_web_contents,
+                       base::StringPrintf("window.location.href = '%s';",
+                                          url2.spec().c_str())));
+    nav_observer.Wait();
+  }
+  ASSERT_EQ(active_web_contents->GetPrimaryMainFrame(),
+            popup_contents->GetOpener());
+
+  // Play video in the popup and request Picture-in-Picture.
+  GURL video_url = embedded_test_server()->GetURL(kHost1, "/media/bear.webm");
+  std::string script = base::ReplaceStringPlaceholders(
+      R"(
+        const video = document.createElement('video');
+        video.src = '$1';
+        video.loop = true;
+        document.body.appendChild(video);
+        video.play().then(() => video.requestPictureInPicture());
+      )",
+      {video_url.spec()}, nullptr);
+  ASSERT_TRUE(ExecJs(popup_contents, script));
+
+  // Wait until the Picture-in-Picture window is visible and its source title
+  // has been populated.
+  SetUpWindowController(popup_contents);
+  ASSERT_TRUE(base::test::RunUntil([&]() {
+    auto* overlay_window = GetOverlayWindow();
+    return overlay_window && overlay_window->IsVisible() &&
+           overlay_window->origin_for_testing() &&
+           !overlay_window->origin_for_testing()->GetText().empty();
+  }));
+
+  // The source title must reflect the popup's own (inherited) origin, not the
+  // opener's current origin.
+  EXPECT_TRUE(
+      base::StartsWith(GetOverlayWindow()->origin_for_testing()->GetText(),
+                       kExpectedTitlePrefix))
+      << "source title is '"
+      << GetOverlayWindow()->origin_for_testing()->GetText() << "'";
+}
+
 struct InteractionTestParam {
   ui::EventType event_type;
   bool title_should_be_visible;
Loading diff…

Original Bug Report

reported by [email protected]

Origin Spoofing in Media Controls via insecure opener URL traversal

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: MediaSessionImpl::BuildMetadata incorrectly identifies the source origin for about:blank popups by traversing the window opener chain and using the opener’s current URL. An attacker can exploit this to spoof a victim site’s origin in Global Media Controls and Picture-in-Picture overlays.

Affected files:

  • content/browser/media/session/media_session_impl.cc

Estimated timestamp from git blame: 2026-01-23

The MediaSessionImpl::BuildMetadata function determines the source_title (origin label) displayed in trusted browser UI components, such as Global Media Controls (GMC), Picture-in-Picture (PiP) window labels, and the ChromeOS lock screen.

A logic vulnerability exists in how this origin is calculated for pages with opaque origins, such as about:blank popups. The implementation currently creates a temporary origin from the frame’s URL string using url::Origin::Create(web_contents()->GetLastCommittedURL()). For about:blank, this results in an opaque origin that lacks precursor information. To find a displayable origin, the code then enters a loop that follows the popup’s opener chain using GetOpener() and evaluates each ancestor’s current last committed URL.

Because the window opener relationship persists across cross-origin navigations (unless restricted by COOP), an attacker can open a popup, navigate their own main window to a target victim site, and then update the popup’s media metadata. The browser will traverse the opener chain, see the victim site’s current URL, and use it as the source label for the attacker-controlled media.

Potential Attack Scenario

  1. A user visits https://attacker.com, which opens a popup to about:blank via window.open().
  2. The popup starts media playback (e.g., a hidden audio element) and sets navigator.mediaSession.metadata.
  3. The original attacker window navigates itself to https://trusted-site.com.
  4. The popup updates its metadata with phishing content (e.g., “Security Alert: Sign-in Required”).
  5. When MediaSessionImpl::BuildMetadata runs, it traverses the opener chain from the about:blank popup. It reaches the opener window, sees its current URL is https://trusted-site.com, and uses this origin as the source_title.
  6. Trusted browser surfaces like GMC and PiP overlays display “trusted-site.com” as the source of the attacker’s media and metadata.

Replace the use of url::Origin::Create(web_contents()->GetLastCommittedURL()) and the subsequent manual opener traversal loop with a direct call to the frame’s security origin: web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin(). This returns the correctly inherited security origin for about:blank popups, which already contains the proper precursor origin (the origin that launched the popup), preventing spoofing based on the opener’s current state.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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