Medium watch CVSS 6.5 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in Storage
DescriptionSide-channel information leakage in Storage
ComponentStorage
Sub-componentFileSystemChooser
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker428189824
Fix commitd207a18ef236 (chromium/src) +154/-4
CWECWE-1300, CWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedAlesandro Ortiz
Reported2025-06-27 · bounty $5000 (per vendor advisory)
Fix landed2025-08-15 (public git)
Disclosed2025-09-30
Patch gap46 days — fix public in source before the advisory

Background

Document Picture-in-Picture
a web-exposed feature (blink::features::kDocumentPictureInPictureAPI) that lets a site render arbitrary, site-controlled HTML inside an always-on-top floating window managed by PictureInPictureWindowManager.
File System Access chooser
the native file picker spawned by the showOpenFilePicker() / Storage File System Access flow, created in content/browser/file_system_access via FileSystemChooser::CreateAndShow.
`base::ScopedClosureRunner`
an RAII object that runs a bound closure when it is destroyed, used here to scope a mitigation to the lifetime of the dialog.
Tucking
moving/hiding the Picture-in-Picture window off the way (ScopedTuckPictureInPicture, IsPictureInPictureForceTucked) so it cannot sit on top of another UI surface.

Root Cause Analysis

When a page invoked the File System Access picker, FileSystemChooser::ScopedObjects was constructed only with a fullscreen_block scoper and no handling for an open document Picture-in-Picture window, so a site-controlled always-on-top PiP window could remain over the native file picker while the chooser was displayed. Because the PiP window renders attacker HTML and stays above other windows, it could overlay, occlude, or sit alongside the security-sensitive file dialog, enabling a side-channel/UI observation against the Storage file chooser.

The fix threads a new pip_tucker base::ScopedClosureRunner into ScopedObjects, obtained from ContentBrowserClient::MaybeGetScopedPictureInPictureTucker (overridden in ChromeContentBrowserClient), which — when PictureInPictureWindowManager::ShouldFileDialogTuckPictureInPicture is true — holds a live ScopedTuckPictureInPicture for the lifetime of the chooser. This guarantees the PiP window is force-tucked for exactly as long as the picker is open and automatically restored when the scoper is destroyed, removing the overlay opportunity; the shipped browser test TuckOnOpenFilePicker confirms the window is tucked during SelectFileImpl and untucked afterward.

Key insight
The core mistake was that the file picker code path accounted for dropping fullscreen but never neutralized a concurrently-open, site-controlled always-on-top Picture-in-Picture window, leaving it free to overlay the Storage file dialog; the fix binds a PiP-tucking scoper to the chooser’s ScopedObjects so the PiP window is tucked for precisely the dialog’s lifetime.

Attack Path

  1. Enter document Picture-in-Picture A malicious page opens a document Picture-in-Picture window (requires the kDocumentPictureInPictureAPI feature), giving it an always-on-top window rendering content it fully controls.
  2. Trigger the Storage file picker The page calls into the File System Access API (e.g. window.showOpenFilePicker()), reaching FileSystemAccessManagerImpl which builds FileSystemChooser::ScopedObjects and calls FileSystemChooser::CreateAndShow.
  3. PiP overlays the dialog Pre-patch, ScopedObjects carried only fullscreen_block, so the attacker’s always-on-top PiP window stayed above/beside the native file picker while it was open, creating a UI side channel against the file dialog.
  4. Leak information The attacker uses that overlay/co-presence as a side-channel information-leak against the Storage chooser UI (specific observation primitive not shown in the diff) (inferred).

Impact Assessment

Per the vendor advisory this is a medium-severity side-channel information leakage in Storage: a web page could use an always-on-top document Picture-in-Picture window to overlay or observe the File System Access file picker and infer information it should not see. The precondition is that the attacker page has an active document Picture-in-Picture window and triggers the file picker; the advisory does not report in-the-wild exploitation, and any escalation beyond UI-based information disclosure is not demonstrated by the patch (inferred).

Changed Functions

FunctionChangeNotes
GetAutoPipInfo
content/public/browser/content_browser_client.cc
modified
FilePath
content/public/browser/content_browser_client.h
modified
Location
content/public/browser/content_browser_client.h
modified
SequencedTaskRunner
content/public/browser/content_browser_client.h
modified
ScopedClosureRunner
content/public/browser/content_browser_client.h
modified
TuckingTestSelectFileDialog
chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
modified
was_tucked_
chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
modified
TuckingTestSelectFileDialogFactory
chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
modified
DocumentPictureInPictureWindowControllerTuckingBrowserTest
chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
modified

Files Changed

  • chrome/browser/chrome_content_browser_client.cc
  • chrome/browser/chrome_content_browser_client.h
  • chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
  • content/browser/file_system_access/file_system_access_manager_impl.cc
  • content/browser/file_system_access/file_system_chooser.cc
  • content/browser/file_system_access/file_system_chooser.h
  • content/public/browser/content_browser_client.cc
  • content/public/browser/content_browser_client.h

Audit Directions

  • Always-on-top surfaces vs. security UI
    Audit every code path that shows a native/security-sensitive dialog (permission prompts, print, save/open, credential UI) for whether a site-controlled always-on-top window (document Picture-in-Picture, popups) can overlay or observe it, mirroring how only fullscreen_block was previously handled here.
  • Scoped mitigation lifetime
    Review uses of base::ScopedClosureRunner/RAII scopers around dialogs to confirm the mitigation is held for the full dialog lifetime and correctly released, so tucking/blocking neither ends early nor leaks.
  • Embedder hook defaults
    Check other ContentBrowserClient virtuals whose default returns a no-op (empty scoper/null) to ensure //content callers do not silently ship without the Chrome-layer protection on platforms or builds where the override is absent.
diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc
index aac5b7a..511b9d8 100644
--- a/chrome/browser/chrome_content_browser_client.cc
+++ b/chrome/browser/chrome_content_browser_client.cc
@@ -25,6 +25,7 @@
 #include "base/files/file_path.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
+#include "base/functional/callback_helpers.h"
 #include "base/i18n/base_i18n_switches.h"
 #include "base/i18n/character_encoding.h"
 #include "base/memory/raw_ptr.h"
@@ -115,6 +116,8 @@
 #include "chrome/browser/performance_manager/public/chrome_browser_main_extra_parts_performance_manager.h"
 #include "chrome/browser/performance_manager/public/chrome_content_browser_client_performance_manager_part.h"
 #include "chrome/browser/performance_monitor/chrome_browser_main_extra_parts_performance_monitor.h"
+#include "chrome/browser/picture_in_picture/picture_in_picture_window_manager.h"
+#include "chrome/browser/picture_in_picture/scoped_tuck_picture_in_picture.h"
 #include "chrome/browser/plugins/plugin_utils.h"
 #include "chrome/browser/policy/policy_util.h"
 #include "chrome/browser/policy/profile_policy_connector.h"
@@ -6943,6 +6946,22 @@
   return content::VideoOverlayWindow::Create(controller);
 }
 
+base::ScopedClosureRunner
+ChromeContentBrowserClient::MaybeGetScopedPictureInPictureTucker(
+    content::WebContents* web_contents) {
+#if !BUILDFLAG(IS_ANDROID)
+  if (PictureInPictureWindowManager::GetInstance()
+          ->ShouldFileDialogTuckPictureInPicture(web_contents)) {
+    // Make the `ScopedTuckPictureInPicture` share the same lifecycle as the
+    // `ScopedClosureRunner`.
+    auto tucker = std::make_unique<ScopedTuckPictureInPicture>();
+    return base::ScopedClosureRunner(
+        base::DoNothingWithBoundArgs(std::move(tucker)));
+  }
+#endif
+  return base::ScopedClosureRunner();
+}
+
 media::PictureInPictureEventsInfo::AutoPipInfo
 ChromeContentBrowserClient::GetAutoPipInfo(
     const content::WebContents& web_contents) const {
diff --git a/chrome/browser/chrome_content_browser_client.h b/chrome/browser/chrome_content_browser_client.h
index 63c5ae80..ac5795f 100644
--- a/chrome/browser/chrome_content_browser_client.h
+++ b/chrome/browser/chrome_content_browser_client.h
@@ -829,6 +829,8 @@
   std::unique_ptr<content::VideoOverlayWindow>
   CreateWindowForVideoPictureInPicture(
       content::VideoPictureInPictureWindowController* controller) override;
+  base::ScopedClosureRunner MaybeGetScopedPictureInPictureTucker(
+      content::WebContents* web_contents) override;
   media::PictureInPictureEventsInfo::AutoPipInfo GetAutoPipInfo(
       const content::WebContents& web_contents) const override;
   void RegisterRendererPreferenceWatcher(
diff --git a/content/browser/file_system_access/file_system_access_manager_impl.cc b/content/browser/file_system_access/file_system_access_manager_impl.cc
index a87c6b9..8debb42 100644
--- a/content/browser/file_system_access/file_system_access_manager_impl.cc
+++ b/content/browser/file_system_access/file_system_access_manager_impl.cc
@@ -226,10 +226,16 @@
     return;
   }
 #endif
+
   FileSystemChooser::ScopedObjects scoped_objects(
       // Drop fullscreen mode so that the user sees the URL bar.
       /*fullscreen_block=*/web_contents->ForSecurityDropFullscreen(
-          display::kInvalidDisplayId));
+          display::kInvalidDisplayId),
+      // Maybe tuck the pip window so that it would not block the file picker
+      // UI.
+      /*pip_tucker=*/GetContentClient()
+          ->browser()
+          ->MaybeGetScopedPictureInPictureTucker(web_contents));
 
   FileSystemChooser::CreateAndShow(web_contents, options, std::move(callback),
                                    std::move(scoped_objects));
diff --git a/content/browser/file_system_access/file_system_chooser.cc b/content/browser/file_system_access/file_system_chooser.cc
index f0ba97c2..50ca62bc 100644
--- a/content/browser/file_system_access/file_system_chooser.cc
+++ b/content/browser/file_system_access/file_system_chooser.cc
@@ -9,6 +9,7 @@
 
 #include "base/check.h"
 #include "base/files/file_path.h"
+#include "base/functional/callback_helpers.h"
 #include "base/i18n/file_util_icu.h"
 #include "base/i18n/rtl.h"
 #include "base/strings/string_util.h"
@@ -287,8 +288,10 @@
     ScopedObjects&&) = default;
 
 FileSystemChooser::ScopedObjects::ScopedObjects(
-    base::ScopedClosureRunner&& fullscreen_block)
-    : fullscreen_block(std::move(fullscreen_block)) {}
+    base::ScopedClosureRunner&& fullscreen_block,
+    base::ScopedClosureRunner&& pip_tucker)
+    : fullscreen_block(std::move(fullscreen_block)),
+      pip_tucker(std::move(pip_tucker)) {}
 
 // static
 void FileSystemChooser::CreateAndShow(
diff --git a/content/browser/file_system_access/file_system_chooser.h b/content/browser/file_system_access/file_system_chooser.h
index f05aa355..440e0d0f 100644
--- a/content/browser/file_system_access/file_system_chooser.h
+++ b/content/browser/file_system_access/file_system_chooser.h
@@ -83,9 +83,11 @@
     ScopedObjects& operator=(ScopedObjects&&);
     ScopedObjects(const ScopedObjects&) = delete;
     ScopedObjects& operator=(const ScopedObjects&) = delete;
-    explicit ScopedObjects(base::ScopedClosureRunner&& fullscreen_block);
+    ScopedObjects(base::ScopedClosureRunner&& fullscreen_block,
+                  base::ScopedClosureRunner&& pip_tucker);
 
     base::ScopedClosureRunner fullscreen_block;
+    base::ScopedClosureRunner pip_tucker;
   };
 
   static void CreateAndShow(WebContents* web_contents,
diff --git a/content/public/browser/content_browser_client.cc b/content/public/browser/content_browser_client.cc
index a1968cc5..51635fb9 100644
--- a/content/public/browser/content_browser_client.cc
+++ b/content/public/browser/content_browser_client.cc
@@ -1358,6 +1358,12 @@
   return nullptr;
 }
 
+base::ScopedClosureRunner
+ContentBrowserClient::MaybeGetScopedPictureInPictureTucker(
+    WebContents* web_contents) {
+  return base::ScopedClosureRunner();
+}
+
 media::PictureInPictureEventsInfo::AutoPipInfo
 ContentBrowserClient::GetAutoPipInfo(const WebContents& web_contents) const {
   return media::PictureInPictureEventsInfo::AutoPipInfo();
diff --git a/content/public/browser/content_browser_client.h b/content/public/browser/content_browser_client.h
index b73ee6e7..f9131c7b 100644
--- a/content/public/browser/content_browser_client.h
+++ b/content/public/browser/content_browser_client.h
@@ -125,6 +125,7 @@
 class FilePath;
 class Location;
 class SequencedTaskRunner;
+class ScopedClosureRunner;
 }  // namespace base
 
 namespace blink {
@@ -2519,6 +2520,11 @@
   CreateWindowForVideoPictureInPicture(
       VideoPictureInPictureWindowController* controller);
 
+  // Creates a scoper that will tuck picture-in-picture windows for the lifetime
+  // of the scoper.
+  virtual base::ScopedClosureRunner MaybeGetScopedPictureInPictureTucker(
+      WebContents* web_contents);
+
   // Returns information related to auto picture in picture. The auto picture in
   // picture reason is recorded in metrics.
   virtual media::PictureInPictureEventsInfo::AutoPipInfo GetAutoPipInfo(
diff --git a/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
index 3958a4c..eb90a111 100644
--- a/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
@@ -49,6 +49,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/fenced_frame_test_util.h"
+#include "content/public/test/file_system_chooser_test_helpers.h"
 #include "content/public/test/media_start_stop_observer.h"
 #include "content/public/test/prerender_test_util.h"
 #include "content/public/test/test_navigation_observer.h"
@@ -70,6 +71,8 @@
 #include "ui/display/screen.h"
 #include "ui/events/base_event_utils.h"
 #include "ui/gfx/codec/png_codec.h"
+#include "ui/shell_dialogs/select_file_dialog.h"
+#include "ui/shell_dialogs/select_file_dialog_factory.h"
 #include "ui/views/controls/button/image_button.h"
 #include "ui/views/test/button_test_api.h"
 #include "ui/views/view_observer.h"
@@ -980,3 +983,106 @@
             browser_view->GetAccessibleTabLabel(
                 browser()->tab_strip_model()->active_index()));
 }
+
+// A dialog that checks if the picture-in-picture window is force-tucked
+// when the dialog is shown, and then immediately cancels itself.
+class TuckingTestSelectFileDialog : public ui::SelectFileDialog {
+ public:
+  TuckingTestSelectFileDialog(Listener* listener,
+                              std::unique_ptr<ui::SelectFilePolicy> policy,
+                              bool* was_tucked)
+      : ui::SelectFileDialog(listener, std::move(policy)),
+        was_tucked_(was_tucked) {}
+
+ private:
+  ~TuckingTestSelectFileDialog() override = default;
+
+  // ui::SelectFileDialog overrides
+  void SelectFileImpl(Type type,
+                      const std::u16string& title,
+                      const base::FilePath& default_path,
+                      const FileTypeInfo* file_types,
+                      int file_type_index,
+                      const base::FilePath::StringType& default_extension,
+                      gfx::NativeWindow owning_window,
+                      const GURL* caller) override {
+    *was_tucked_ = PictureInPictureWindowManager::GetInstance()
+                       ->IsPictureInPictureForceTucked();
+    listener_->FileSelectionCanceled();
+  }
+
+  bool IsRunning(gfx::NativeWindow owning_window) const override {
+    return true;
+  }
+  void ListenerDestroyed() override {}
+  bool HasMultipleFileTypeChoicesImpl() override { return false; }
+
+  raw_ptr<bool> was_tucked_;
+  base::WeakPtrFactory<TuckingTestSelectFileDialog> weak_factory_{this};
+};
+
+class TuckingTestSelectFileDialogFactory : public ui::SelectFileDialogFactory {
+ public:
+  explicit TuckingTestSelectFileDialogFactory(bool* was_tucked)
+      : was_tucked_(was_tucked) {}
+  ~TuckingTestSelectFileDialogFactory() override = default;
+
+  ui::SelectFileDialog* Create(
+      ui::SelectFileDialog::Listener* listener,
+      std::unique_ptr<ui::SelectFilePolicy> policy) override {
+    return new TuckingTestSelectFileDialog(listener, std::move(policy),
+                                           was_tucked_);
+  }
+
+ private:
+  raw_ptr<bool> was_tucked_;
+};
+
+class DocumentPictureInPictureWindowControllerTuckingBrowserTest
+    : public DocumentPictureInPictureWindowControllerBrowserTest {
+ public:
+  DocumentPictureInPictureWindowControllerTuckingBrowserTest() = default;
+
+  void SetUp() override {
+    feature_list_.InitWithFeatures(
+        {blink::features::kDocumentPictureInPictureAPI,
+         blink::features::kDocumentPictureInPicturePreferInitialPlacement,
+         media::kFileDialogsTuckPictureInPicture},
+        {});
+    InProcessBrowserTest::SetUp();
+  }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+};
+
+// Test when the file picker is opened, the pip window is tucked.
+IN_PROC_BROWSER_TEST_F(
+    DocumentPictureInPictureWindowControllerTuckingBrowserTest,
+    TuckOnOpenFilePicker) {
+  LoadTabAndEnterPictureInPicture(browser());
+  // Initially it was not tucked
+  EXPECT_FALSE(PictureInPictureWindowManager::GetInstance()
+                   ->IsPictureInPictureForceTucked());
+
+  // `was_tucked_during_picker` will be updated if the window is tucked while
+  // the file picker dialog is open.
+  bool was_tucked_during_picker = false;
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<TuckingTestSelectFileDialogFactory>(
+          &was_tucked_during_picker));
+
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  auto result = EvalJs(web_contents, "window.showOpenFilePicker();");
+  EXPECT_TRUE(result.ExtractError().find("aborted") != std::string::npos)
+      << result;
+
+  ui::SelectFileDialog::SetFactory(nullptr);
+
+  // The pip window should be tucked once, and now it's back to non-tucked
+  // state.
+  EXPECT_TRUE(was_tucked_during_picker);
+  EXPECT_FALSE(PictureInPictureWindowManager::GetInstance()
+                   ->IsPictureInPictureForceTucked());
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc b/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
index 3958a4c..eb90a111 100644
--- a/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
+++ b/chrome/browser/picture_in_picture/document_picture_in_picture_window_controller_browsertest.cc
@@ -49,6 +49,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/fenced_frame_test_util.h"
+#include "content/public/test/file_system_chooser_test_helpers.h"
 #include "content/public/test/media_start_stop_observer.h"
 #include "content/public/test/prerender_test_util.h"
 #include "content/public/test/test_navigation_observer.h"
@@ -70,6 +71,8 @@
 #include "ui/display/screen.h"
 #include "ui/events/base_event_utils.h"
 #include "ui/gfx/codec/png_codec.h"
+#include "ui/shell_dialogs/select_file_dialog.h"
+#include "ui/shell_dialogs/select_file_dialog_factory.h"
 #include "ui/views/controls/button/image_button.h"
 #include "ui/views/test/button_test_api.h"
 #include "ui/views/view_observer.h"
@@ -980,3 +983,106 @@
             browser_view->GetAccessibleTabLabel(
                 browser()->tab_strip_model()->active_index()));
 }
+
+// A dialog that checks if the picture-in-picture window is force-tucked
+// when the dialog is shown, and then immediately cancels itself.
+class TuckingTestSelectFileDialog : public ui::SelectFileDialog {
+ public:
+  TuckingTestSelectFileDialog(Listener* listener,
+                              std::unique_ptr<ui::SelectFilePolicy> policy,
+                              bool* was_tucked)
+      : ui::SelectFileDialog(listener, std::move(policy)),
+        was_tucked_(was_tucked) {}
+
+ private:
+  ~TuckingTestSelectFileDialog() override = default;
+
+  // ui::SelectFileDialog overrides
+  void SelectFileImpl(Type type,
+                      const std::u16string& title,
+                      const base::FilePath& default_path,
+                      const FileTypeInfo* file_types,
+                      int file_type_index,
+                      const base::FilePath::StringType& default_extension,
+                      gfx::NativeWindow owning_window,
+                      const GURL* caller) override {
+    *was_tucked_ = PictureInPictureWindowManager::GetInstance()
+                       ->IsPictureInPictureForceTucked();
+    listener_->FileSelectionCanceled();
+  }
+
+  bool IsRunning(gfx::NativeWindow owning_window) const override {
+    return true;
+  }
+  void ListenerDestroyed() override {}
+  bool HasMultipleFileTypeChoicesImpl() override { return false; }
+
+  raw_ptr<bool> was_tucked_;
+  base::WeakPtrFactory<TuckingTestSelectFileDialog> weak_factory_{this};
+};
+
+class TuckingTestSelectFileDialogFactory : public ui::SelectFileDialogFactory {
+ public:
+  explicit TuckingTestSelectFileDialogFactory(bool* was_tucked)
+      : was_tucked_(was_tucked) {}
+  ~TuckingTestSelectFileDialogFactory() override = default;
+
+  ui::SelectFileDialog* Create(
+      ui::SelectFileDialog::Listener* listener,
+      std::unique_ptr<ui::SelectFilePolicy> policy) override {
+    return new TuckingTestSelectFileDialog(listener, std::move(policy),
+                                           was_tucked_);
+  }
+
+ private:
+  raw_ptr<bool> was_tucked_;
+};
+
+class DocumentPictureInPictureWindowControllerTuckingBrowserTest
+    : public DocumentPictureInPictureWindowControllerBrowserTest {
+ public:
+  DocumentPictureInPictureWindowControllerTuckingBrowserTest() = default;
+
+  void SetUp() override {
+    feature_list_.InitWithFeatures(
+        {blink::features::kDocumentPictureInPictureAPI,
+         blink::features::kDocumentPictureInPicturePreferInitialPlacement,
+         media::kFileDialogsTuckPictureInPicture},
+        {});
+    InProcessBrowserTest::SetUp();
+  }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+};
+
+// Test when the file picker is opened, the pip window is tucked.
+IN_PROC_BROWSER_TEST_F(
+    DocumentPictureInPictureWindowControllerTuckingBrowserTest,
+    TuckOnOpenFilePicker) {
+  LoadTabAndEnterPictureInPicture(browser());
+  // Initially it was not tucked
+  EXPECT_FALSE(PictureInPictureWindowManager::GetInstance()
+                   ->IsPictureInPictureForceTucked());
+
+  // `was_tucked_during_picker` will be updated if the window is tucked while
+  // the file picker dialog is open.
+  bool was_tucked_during_picker = false;
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<TuckingTestSelectFileDialogFactory>(
+          &was_tucked_during_picker));
+
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  auto result = EvalJs(web_contents, "window.showOpenFilePicker();");
+  EXPECT_TRUE(result.ExtractError().find("aborted") != std::string::npos)
+      << result;
+
+  ui::SelectFileDialog::SetFactory(nullptr);
+
+  // The pip window should be tucked once, and now it's back to non-tucked
+  // state.
+  EXPECT_TRUE(was_tucked_during_picker);
+  EXPECT_FALSE(PictureInPictureWindowManager::GetInstance()
+                   ->IsPictureInPictureForceTucked());
+}
Loading diff…

Original Bug Report

reported by [email protected]

Security: PiP window obscures FSA API file picker dialog (env var leak)

SUMMARY

A PiP window can obscure the file/directory picker dialog, using the File System Access (FSA) API. Without user awareness, an attacker can:

  • Read environment variables (similar to issue 40057200)
  • Read/write files (similar to issue 40076120)

Using the FSA API bypasses existing protections that only apply to file/dir picker dialogs opened from file-type inputs.

VULNERABILITY DETAILS

A PiP window can obscure a file/directory picker dialog opened through the FSA API: showSaveFilePicker(), showOpenFilePicker(), or showDirectoryPicker().

Prior fixes only mitigate for file-type inputs, not FSA API

There are two fixes for issue 40076120, but neither mitigate the same vulnerability in file/dir pickers opened through the FSA API. They only mitigate the vuln for file/dir pickers opened through file-type inputs.

The first fix in https://crrev.com/c/5753110 (September 2024) blocked or closed PiP windows if a file picker was open (feature flag FileDialogsBlockPictureInPicture).

The first fix was disabled in https://crrev.com/c/6318768 (March 3rd, 2025) and a new fix was introduced in https://crrev.com/c/6449682 (June 10th, 2025). The new fix tucks the PiP window instead of blocking/closing it (feature flag FileDialogsTuckPictureInPicture).

Neither fix mitigates the vulnerability when using FSA API file/dir pickers, verified by enabling/disabling the features (see attached videos). On current 138.0.7204.50 Stable, neither fix is enabled so both attack approaches work anyway.

Impacts

Scenario 1: Read environment variables

An attacker can exfiltrate environment variables (similar to issue 40057200) on Windows by saving a file with the environment variable names (e.g. %username%-%service_secret_key%). The env var names are replaced with their values when saving the file, and we can read the file name containing the values.

To perform the attack, we write the env var names into the clipboard and then ask user to paste the payload into the file picker dialog while it’s obscured by the PiP window. After the user presses enter to save the file, we read the file name which now contains the env var values. We then delete the file to clean up the attack.

There’s code to sanitize pre-filled file names in the file picker dialog, but we work around this by having the user paste our payload into the dialog.

Env var exfil is possible with the FSA API but not with file-type inputs because file-type inputs use open dialogs, not save dialogs. An attacker needs to use save dialogs to get a file handle with the env var values in its file name.

Scenario 2: Read+write file/dir

An attacker can read and write to a mostly-arbitrary file/dir without user awareness.

Similar to the first scenario, we write the file/dir path into the clipboard and ask user to paste payload into the dialog while it’s obscured. This allows us to use env vars or known paths that would otherwise not be pre-fillable through the FSA API.

After issue 40076120 was filed, more protections were added to block access to common sensitive directories, but users may still have files of interest in non-blocked directories. Outside of these blocked dirs, path is arbitrary.

VERSION

Chrome version: 138.0.7204.50 Stable, 140.0.7262.0 Canary

For potentially mitigating feature flags (FileDialogsBlockPictureInPicture, FileDialogsTuckPictureInPicture) I verified repro with:

  • default config (on Stable, both disabled; on Canary, only tuck enabled)
  • both disabled
  • only one enabled

Operating System: Windows 10

REPRODUCTION CASE

Minimal comparison: Mitigation on file-type input dialog vs. FSA API dialog

This page shows observed mitigation differences between file-type input dialog (mitigated) and FSA API dialog (not mitigated). If mitigated, close/tuck occurs even if file picker and PiP window don’t overlap.

  1. Run Chrome with --enable-features=FileDialogsTuckPictureInPicture --disable-features=FileDialogsBlockPictureInPicture (or the opposite enabled/disabled feature combination) to enable one of the mitigations.
  2. Navigate to https://alesandroortiz.com/security/chromium/filepicker-pip-minimal.html and choose the opening method.
  3. Click anywhere to open PiP window.
  4. Click again to open file picker.

Observed: When file picker dialog is opened using file-type input, PiP window is closed or tucked (based on enabled mitigation). When same dialog is opened using FSA API, PiP window is not closed or tucked.

Expected: When file picker dialog is opened by attacker in any way, PiP window is closed or tucked (based on enabled mitigation).

Scenario 1: Read environment variables

  1. Navigate to https://alesandroortiz.com/security/chromium/filepicker-pip.html
  2. Press any key twice (or press and hold any key).
  3. Press Ctrl+V, then enter.

Observed: File picker dialog is obscured by PiP window. Attacker can read environment variables without user awareness.

Expected: FIle picker dialog is visible to user. Ideally, attacker cannot read sensitive environment variables even if not obscured.

Scenario 2a: Read file

Setup: Create a file in Documents named example.txt with any content.

  1. Navigate to https://alesandroortiz.com/security/chromium/filepicker-pip.html?mode=readfile

Steps 2-3: Same as first scenario.

Observed: File picker dialog is obscured by PiP window. File is read from attacker-specified path without user awareness.

Expected: File picker dialog is visible to user.

Scenario 2b: Write file

  1. Navigate to https://alesandroortiz.com/security/chromium/filepicker-pip.html?mode=writefile

Steps 2-3: Same as first scenario.

Observed: File picker dialog is obscured by PiP window. File is written to attacker-specified path without user awareness.

Expected: File picker dialog is visible to user.

Credit Information

Reporter credit: Alesandro Ortiz https://AlesandroOrtiz.com

View on issue tracker
Uploaded PoC files
Links in the report