CVE-2025-8881
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/web_contents/file_chooser_impl.cc |
modified | |
MockWebContentsDelegateForFileChoosercontent/browser/web_contents/file_chooser_impl_unittest.cc |
modified | |
FileChooserImplTestcontent/browser/web_contents/file_chooser_impl_unittest.cc |
modified | |
TEST_Fcontent/browser/web_contents/file_chooser_impl_unittest.cc |
modified |
Files Changed
content/browser/web_contents/file_chooser_impl.cccontent/browser/web_contents/file_chooser_impl_unittest.cccontent/test/BUILD.gnthird_party/blink/public/mojom/choosers/file_chooser.mojom
Patch
From ebee769e5e2e914e34b4ce20f956863d62bb7c3b Mon Sep 17 00:00:00 2001 From: Alesandro Ortiz <[email protected]> Date: Fri, 25 Jul 2025 16:46:32 -0700 Subject: [PATCH] Clear `default_file_name` for non-save file pickers. This only affects file pickers opened by renderer. This code path is used by file-type inputs. Bug: 433800617 Change-Id: Ia144bda955fdd4b827448e49ebd051d48dd7dbf7 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6786387 Reviewed-by: Austin Sullivan <[email protected]> Reviewed-by: Charlie Reis <[email protected]> Reviewed-by: Mustafa Emre Acer <[email protected]> Commit-Queue: Alesandro Ortiz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1492358} --- diff --git a/content/browser/web_contents/file_chooser_impl.cc b/content/browser/web_contents/file_chooser_impl.cc index 75db7b5..7104add 100644 --- a/content/browser/web_contents/file_chooser_impl.cc +++ b/content/browser/web_contents/file_chooser_impl.cc @@ -170,6 +170,12 @@ return; } + // Do not allow open dialogs to have renderer-controlled default_file_name. + // See https://crbug.com/433800617 for context. + if (params->mode != blink::mojom::FileChooserParams::Mode::kSave) { + params->default_file_name = base::FilePath(); + } + // Don't allow page with open FileChooser to enter BackForwardCache to avoid // any unexpected behaviour from BackForwardCache. BackForwardCache::DisableForRenderFrameHost( diff --git a/content/browser/web_contents/file_chooser_impl_unittest.cc b/content/browser/web_contents/file_chooser_impl_unittest.cc new file mode 100644 index 0000000..c736462 --- /dev/null +++ b/content/browser/web_contents/file_chooser_impl_unittest.cc @@ -0,0 +1,122 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "content/browser/web_contents/file_chooser_impl.h" + +#include "base/files/file_path.h" +#include "base/run_loop.h" +#include "content/browser/renderer_host/render_frame_host_impl.h" +#include "content/public/browser/web_contents_delegate.h" +#include "content/test/test_web_contents.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +using ::testing::_; +using ::testing::Invoke; + +namespace content { + +class MockWebContentsDelegateForFileChooser : public WebContentsDelegate { + public: + // Mock the method to inspect the parameters it receives. + MOCK_METHOD(void, + RunFileChooser, + (RenderFrameHost* render_frame_host, + scoped_refptr<FileSelectListener> listener, + const blink::mojom::FileChooserParams& params), + (override)); +}; + +class FileChooserImplTest : public RenderViewHostTestHarness { + public: + void SetUp() override { + RenderViewHostTestHarness::SetUp(); + mock_web_contents_delegate_ = + std::make_unique<MockWebContentsDelegateForFileChooser>(); + auto test_web_contents = + TestWebContents::Create(browser_context(), nullptr); + test_web_contents->SetDelegate(mock_web_contents_delegate_.get()); + SetContents(std::move(test_web_contents)); + + // Navigate to page, otherwise OpenFileChooser() returns early. + NavigateAndCommit(GURL(url::kAboutBlankURL)); + } + + void TearDown() override { + mock_web_contents_delegate_.reset(); + RenderViewHostTestHarness::TearDown(); + } + + protected: + std::unique_ptr<MockWebContentsDelegateForFileChooser> + mock_web_contents_delegate_; +}; + +TEST_F(FileChooserImplTest, DefaultFileNameClearedWhenModeIsNotSave) { + FileChooserImpl* file_chooser_impl = + FileChooserImpl::CreateForTesting( + static_cast<RenderFrameHostImpl*>(main_rfh())) + .first; + + auto params = blink::mojom::FileChooserParams::New(); + params->mode = blink::mojom::FileChooserParams::Mode::kOpen; + const base::FilePath kInitialFile = + base::FilePath(FILE_PATH_LITERAL("file.txt")); + params->default_file_name = kInitialFile; + + blink::mojom::FileChooserParamsPtr captured_params; + EXPECT_CALL(*mock_web_contents_delegate_, RunFileChooser(_, _, _)) + .WillOnce(Invoke( + [&](RenderFrameHost* rfh, scoped_refptr<FileSelectListener> listener, + const blink::mojom::FileChooserParams& passed_params) { + // Capture the arguments for later inspection. + captured_params = passed_params.Clone(); + + // Avoid logging error on destruction in test. + static_cast<FileChooserImpl::FileSelectListenerImpl*>( + listener.get()) + ->SetListenerFunctionCalledTrueForTesting(); + })); + + file_chooser_impl->OpenFileChooser(std::move(params), base::DoNothing()); + + // Verify the default file name was cleared. + ASSERT_TRUE(captured_params); + EXPECT_EQ(captured_params->default_file_name, base::FilePath()); +} + +TEST_F(FileChooserImplTest, DefaultFileNamePreservedWhenModeIsSave) { + FileChooserImpl* file_chooser_impl = + FileChooserImpl::CreateForTesting( + static_cast<RenderFrameHostImpl*>(main_rfh())) + .first; + + auto params = blink::mojom::FileChooserParams::New(); + params->mode = blink::mojom::FileChooserParams::Mode::kSave; + const base::FilePath kInitialFile = + base::FilePath(FILE_PATH_LITERAL("file.txt")); + params->default_file_name = kInitialFile; + + blink::mojom::FileChooserParamsPtr captured_params; + EXPECT_CALL(*mock_web_contents_delegate_, RunFileChooser(_, _, _)) + .WillOnce(Invoke( + [&](RenderFrameHost* rfh, scoped_refptr<FileSelectListener> listener, + const blink::mojom::FileChooserParams& passed_params) { + // Capture the arguments for later inspection. + captured_params = passed_params.Clone(); + + // Avoid logging error on destruction in test. + static_cast<FileChooserImpl::FileSelectListenerImpl*>( + listener.get()) + ->SetListenerFunctionCalledTrueForTesting(); + })); + + file_chooser_impl->OpenFileChooser(std::move(params), base::DoNothing()); + + // Verify the default file name was preserved. + ASSERT_TRUE(captured_params); + EXPECT_EQ(captured_params->default_file_name, kInitialFile); +} + +} // namespace content diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn index 4c4b74ad4..1e0218fe 100644 --- a/content/test/BUILD.gn +++ b/content/test/BUILD.gn @@ -2891,6 +2891,7 @@ "../browser/tracing/tracing_scenario_unittest.cc", "../browser/usb/web_usb_service_impl_unittest.cc", "../browser/web_contents/aura/gesture_nav_simple_unittest.cc", + "../browser/web_contents/file_chooser_impl_unittest.cc", "../browser/web_contents/web_contents_delegate_unittest.cc", "../browser/web_contents/web_contents_impl_unittest.cc", "../browser/web_contents/web_contents_user_data_unittest.cc", diff --git a/third_party/blink/public/mojom/choosers/file_chooser.mojom b/third_party/blink/public/mojom/choosers/file_chooser.mojom index 411e5aee..c7929be 100644 --- a/third_party/blink/public/mojom/choosers/file_chooser.mojom +++ b/third_party/blink/public/mojom/choosers/file_chooser.mojom @@ -31,7 +31,7 @@ kOpenDirectory, // Allows picking a nonexistent file, and prompts to overwrite if the file - // already exists. This is not for Blink but for PPAPI. + // already exists. kSave, }; Mode mode = kOpen; @@ -40,7 +40,8 @@ // which will be either "Open" or "Save" depending on the mode. mojo_base.mojom.String16 title; - // Default file name to select in the dialog with kSave mode. + // Default file name to select in the dialog with kSave mode. This value + // is cleared if mode is not kSave. mojo_base.mojom.FilePath default_file_name; // |selected_files| has filenames which a file upload control already
Regression Test / PoC
diff --git a/content/browser/web_contents/file_chooser_impl_unittest.cc b/content/browser/web_contents/file_chooser_impl_unittest.cc
new file mode 100644
index 0000000..c736462
--- /dev/null
+++ b/content/browser/web_contents/file_chooser_impl_unittest.cc
@@ -0,0 +1,122 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/web_contents/file_chooser_impl.h"
+
+#include "base/files/file_path.h"
+#include "base/run_loop.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/public/browser/web_contents_delegate.h"
+#include "content/test/test_web_contents.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+using ::testing::_;
+using ::testing::Invoke;
+
+namespace content {
+
+class MockWebContentsDelegateForFileChooser : public WebContentsDelegate {
+ public:
+ // Mock the method to inspect the parameters it receives.
+ MOCK_METHOD(void,
+ RunFileChooser,
+ (RenderFrameHost* render_frame_host,
+ scoped_refptr<FileSelectListener> listener,
+ const blink::mojom::FileChooserParams& params),
+ (override));
+};
+
+class FileChooserImplTest : public RenderViewHostTestHarness {
+ public:
+ void SetUp() override {
+ RenderViewHostTestHarness::SetUp();
+ mock_web_contents_delegate_ =
+ std::make_unique<MockWebContentsDelegateForFileChooser>();
+ auto test_web_contents =
+ TestWebContents::Create(browser_context(), nullptr);
+ test_web_contents->SetDelegate(mock_web_contents_delegate_.get());
+ SetContents(std::move(test_web_contents));
+
+ // Navigate to page, otherwise OpenFileChooser() returns early.
+ NavigateAndCommit(GURL(url::kAboutBlankURL));
+ }
+
+ void TearDown() override {
+ mock_web_contents_delegate_.reset();
+ RenderViewHostTestHarness::TearDown();
+ }
+
+ protected:
+ std::unique_ptr<MockWebContentsDelegateForFileChooser>
+ mock_web_contents_delegate_;
+};
+
+TEST_F(FileChooserImplTest, DefaultFileNameClearedWhenModeIsNotSave) {
+ FileChooserImpl* file_chooser_impl =
+ FileChooserImpl::CreateForTesting(
+ static_cast<RenderFrameHostImpl*>(main_rfh()))
+ .first;
+
+ auto params = blink::mojom::FileChooserParams::New();
+ params->mode = blink::mojom::FileChooserParams::Mode::kOpen;
+ const base::FilePath kInitialFile =
+ base::FilePath(FILE_PATH_LITERAL("file.txt"));
+ params->default_file_name = kInitialFile;
+
+ blink::mojom::FileChooserParamsPtr captured_params;
+ EXPECT_CALL(*mock_web_contents_delegate_, RunFileChooser(_, _, _))
+ .WillOnce(Invoke(
+ [&](RenderFrameHost* rfh, scoped_refptr<FileSelectListener> listener,
+ const blink::mojom::FileChooserParams& passed_params) {
+ // Capture the arguments for later inspection.
+ captured_params = passed_params.Clone();
+
+ // Avoid logging error on destruction in test.
+ static_cast<FileChooserImpl::FileSelectListenerImpl*>(
+ listener.get())
+ ->SetListenerFunctionCalledTrueForTesting();
+ }));
+
+ file_chooser_impl->OpenFileChooser(std::move(params), base::DoNothing());
+
+ // Verify the default file name was cleared.
+ ASSERT_TRUE(captured_params);
+ EXPECT_EQ(captured_params->default_file_name, base::FilePath());
+}
+
+TEST_F(FileChooserImplTest, DefaultFileNamePreservedWhenModeIsSave) {
+ FileChooserImpl* file_chooser_impl =
+ FileChooserImpl::CreateForTesting(
+ static_cast<RenderFrameHostImpl*>(main_rfh()))
+ .first;
+
+ auto params = blink::mojom::FileChooserParams::New();
+ params->mode = blink::mojom::FileChooserParams::Mode::kSave;
+ const base::FilePath kInitialFile =
+ base::FilePath(FILE_PATH_LITERAL("file.txt"));
+ params->default_file_name = kInitialFile;
+
+ blink::mojom::FileChooserParamsPtr captured_params;
+ EXPECT_CALL(*mock_web_contents_delegate_, RunFileChooser(_, _, _))
+ .WillOnce(Invoke(
+ [&](RenderFrameHost* rfh, scoped_refptr<FileSelectListener> listener,
+ const blink::mojom::FileChooserParams& passed_params) {
+ // Capture the arguments for later inspection.
+ captured_params = passed_params.Clone();
+
+ // Avoid logging error on destruction in test.
+ static_cast<FileChooserImpl::FileSelectListenerImpl*>(
+ listener.get())
+ ->SetListenerFunctionCalledTrueForTesting();
+ }));
+
+ file_chooser_impl->OpenFileChooser(std::move(params), base::DoNothing());
+
+ // Verify the default file name was preserved.
+ ASSERT_TRUE(captured_params);
+ EXPECT_EQ(captured_params->default_file_name, kInitialFile);
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 4c4b74ad4..1e0218fe 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -2891,6 +2891,7 @@
"../browser/tracing/tracing_scenario_unittest.cc",
"../browser/usb/web_usb_service_impl_unittest.cc",
"../browser/web_contents/aura/gesture_nav_simple_unittest.cc",
+ "../browser/web_contents/file_chooser_impl_unittest.cc",
"../browser/web_contents/web_contents_delegate_unittest.cc",
"../browser/web_contents/web_contents_impl_unittest.cc",
"../browser/web_contents/web_contents_user_data_unittest.cc",
Original Bug Report
Security: Compromised renderer can steal cross-site data with minimal user interaction
SUMMARY
With a compromised renderer, a page can perform a credentialed download of a cross-site URL and then upload the downloaded response. The only user interaction needed is holding the enter key. The attack works for single files or multiple files.
To perform the attack, the PoCs performs these steps without user interaction:
- Downloads one or more credentialed cross-site URLs, without user interaction.
- Opens file pickers with filename prefilled with the download’s predicted filename, also without user interaction.
We also bypass the user activation checks of several gated features. The only user interaction needed is holding enter to press the “Open” button in the file pickers.
This is an improved report chaining several behaviors to minimize user interaction, based on earlier research in issue 428189828.
VULNERABILITY DETAILS
Download credentialed cross-site URL
A compromised renderer can call DownloadURL() to initiate a credentialed download of a cross-site URL without user interaction. Cross-site URL downloads are credentialed and all cookies except SameSite=Strict cookies are sent. We can do this for multiple URLs when we bypass the download throttler. (I previously mentioned using DownloadURL() in https://crbug.com/428189828#comment4 )
Due to browser-side checks, we cannot set the download filename, but we can accurately predict the filename since it’s generated based on the URL path (see getPredictedFilename() in PoC source for details).
Download throttler bypass
For the multiple files scenario, we need to bypass the download throttler which limits downloads to one per WebContents (window or tab). To bypass the download throttler, the compromised renderer calls LocalFrame::NotifyUserInteraction() followed by window.open() to create a popup without user interaction. We repeat this for as many downloads as we need. (Download throttle bypass used to be easier.)
Set filename in open file picker
For security reasons, the open file picker isn’t usually allowed to have a prefilled filename. However, there are no browser-side checks to enforce this in the file-type input code path, so a compromised renderer can call OpenFileChooser() with default_file_name to set the prefilled filename (absolute paths are rejected by browser). We set this to the download’s filename to automatically select the downloaded file in the open file picker dialog.
In comparison, the FSA API browser-side code ignores any filename provided by renderer when using the Open dialog.
This behavior could be a standalone vulnerability since this may be useful in other attack scenarios; let me know if I should file separate crbug for this.
User activation bypasses
To bypass various user activation checks to show file pickers, open popups, and start downloads, the compromised renderer calls LocalFrame::NotifyUserInteraction(). This is a known issue.
Mitigation: Last selected directory
There is a notable mitigation: The attack’s happy path depends on the browser profile’s last_selected_directory() being set to the user’s downloads folder (on Windows, Chromium’s default download folder is %userprofile%/Downloads/). This value is set when a user selects a file using certain open file pickers, including the one used by OpenFileChooser() (see callers of set_last_selected_directory()).
In the PoC, we try to detect if the last selected folder is not the downloads folder by checking if a file isn’t selected within a couple of seconds. After the timeout, we ask the user to select their downloads folder and click “Open”. Since the filename is prefilled, there’s no need to click a specific file.
If mitigated due to a non-downloads folder, we can try to salvage the attack attempt:
-
For the single file scenario, if we hit the non-downloads folder case, the attack becomes largely moot. The only attacker benefit is the prefilled filename. The page could try to convince the user they’re uploading a “verification” file, but this may raise suspicion and the same goal can be achieved with pure social engineering.
-
For the multiple file scenario, if we hit the non-downloads folder case, an attacker can still benefit. If the attacker convinces user to select the first file from the downloads folder, which can be an innocuous file, then holding the enter key will result in a successful attack for an unlimited number of files. The first step may raise suspicion for some users, but if the attacker succeeds there, the rest of the attack is less visible and very likely to be successful.
Anecdotally, most of the files I upload are from my downloads folder (downloading from website A to upload to website B). This may also be the case for many users who frequently download/upload PDFs, CSVs, images, etc. across websites in a similar manner.
PROPOSED FIXES
There’s several things that can be fixed, to break the chain and also mitigate any standalone impacts:
-
Prevent cross-site credentialed downloads initiated by user or compromised renderer, through Alt+Click/Enter or directly through
DownloadURL()respectively. This work is tracked in issue 428189828 per https://crbug.com/428189828#comment3, but as a non-security bug. This should break any variants that depend on cross-site credentialed download, such as the one reported in that other issue. -
Ensure browser ignores
default_file_nameinOpenFileChooser()params from renderer if the chooser isn’t inkSavemode. This is what the FSA API currently does. -
Longer-term: Implement browser-enforced user activation, to avoid bypasses when showing file pickers, opening popups, or starting downloads. This is a known issue but there are some features which currently have robust mitigations by checking
WebContents::HasRecentInteraction(). Of all three actions the browser could restrict, file pickers is probably the least risky option.
VERSION
Chrome version:
- Cross-site credentialed download: 138.0.7204.169 Stable, 140.0.7313.0 Canary
- Set filename in file picker: Verified with custom build based on
aad34245b04df3c637ce7c51b5a10af879e1ac98(July 3rd)
Operating System: Windows 10
REPRODUCTION CASE
General notes:
- The repro shown in videos is slower than the actual repro time, due to resource constraints caused by video recording.
- In this PoC, we wait for user to interact with page before initiating downloads, but compromised renderer can perform multiple downloads at any time. We wait to avoid user suspicion before user interacts with page.
Patch to simulate compromised renderer
To simulate a compromised renderer, the patch:
- Disables some browser DCHECKs in
chrome/browser/file_select_helper.ccandui/shell_dialogs/base_shell_dialog_win.cc. You can alternatively build with DCHECKs disabled. - Updates
third_party/blink/renderer/core/html/html_marquee_element.ccto callNotifyUserActivation()whenmarqueeElem.stop()is called in JS, to bypass user activation checks. - Updates
third_party/blink/renderer/core/html/forms/file_input_type.ccto callDownloadURL()directly and setdefault_file_namein open file picker. See patch comments for how to use attributes to change behavior.
Setup for both PoCs:
- If self-hosting or want to edit using DevTools, optionally configure the target URL in the source code (e.g.
https://myaccount.google.com/personal-info). - If using default target (such as on hosted PoC), navigate to https://aogarantiza.com/set-cookies.php to set cookies on target.
- Apply attached patch and build Chromium.
Scenario: Single file
Using patched browser:
- Navigate to https://alesandroortiz.com/security/chromium/download-cross-site-theft-cr.html
- Press and hold enter.
- For happy path: Wait for attack to complete within a couple of seconds.
- For sad path (non-downloads folder case): Select downloads folder, then click “Open” button.
Scenario: Multiple files
Using patched browser:
- Navigate to https://alesandroortiz.com/security/chromium/download-cross-site-theft-cr-multiple.html
- Press and hold enter.
- For happy path: Wait for attack to complete within a few seconds.
- For sad path (non-downloads folder case): Select downloads folder, then click “Open” button. Then continue from step 2 into happy path.
For both scenarios:
Observed: Compromised renderer can download multiple cross-site credentialed URLs and show open file pickers with prefilled filename. Because user is holding enter, the downloaded file is uploaded to attacker page shortly after file picker is shown.
Expected: Cross-site downloads are not credentialed, for downloads initiated by either regular or compromised renderers. Compromised renderer cannot show open file pickers with prefilled filename. To a lesser extent, a compromised renderer should not be able to download multiple files by bypassing popup blocker.
Credit Information
Reporter credit: Alesandro Ortiz https://AlesandroOrtiz.com
- https://AlesandroOrtiz.com
- https://alesandroortiz.com/security/chromium/download-cross-site-theft-cr-multiple.html
- https://alesandroortiz.com/security/chromium/download-cross-site-theft-cr.html
- https://aogarantiza.com/set-cookies.php
- https://crbug.com/40091540
- https://crbug.com/40762068
- https://crbug.com/428189828#comment3
- https://crbug.com/428189828#comment4
- https://issuetracker.google.com/issues/428189828
- https://myaccount.google.com/personal-info
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/profiles/profile_impl.cc;l=886;drc=1840486733c9d51af6b1c8c71d044f685a089496
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/profiles/profile_impl.cc;l=890;drc=1840486733c9d51af6b1c8c71d044f685a089496
- https://source.chromium.org/chromium/chromium/src/+/main:components/download/internal/common/download_response_handler.cc;l=128;drc=5be4364dd3e4b307b5c6b3bafc5bc8652972177d
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/file_system_access/file_system_access_manager_impl.cc;l=715;drc=23c8085b03a0c5b8cb812e4737454ad2f55a6e8b
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/web_contents/file_chooser_impl.cc;l=166;drc=a789d5c7fac66ba6dc47f5b1bc02b31f6131207b
- https://source.chromium.org/chromium/chromium/src/+/main:content/public/browser/web_contents.h;l=1522;drc=a3c9c4dcd0d6de500f41d7272d9515b6b81b7729
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/choosers/file_chooser.mojom;l=136;drc=6c792755c1b149566fafb18355fecd7af082b799
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/choosers/file_chooser.mojom;l=35;drc=6c792755c1b149566fafb18355fecd7af082b799
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/choosers/file_chooser.mojom;l=44;drc=6c792755c1b149566fafb18355fecd7af082b799
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/frame/frame.mojom;l=514;drc=a5ef13fdb0138d4718cc0010b5798ec7fc753001
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/frame/frame.mojom;l=888;drc=a5ef13fdb0138d4718cc0010b5798ec7fc753001