Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Save to Drive
DescriptionInsufficient validation of untrusted input in Save to Drive
ComponentSave to Drive
Bug ClassLogic Error
Tracker515438919
Fix commitacfd83c6a15d (chromium/src) +59/-23
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • chrome/app/generated_resources.grd
  • chrome/app/generated_resources_grd/IDS_ACCOUNT_CHOOSER_SUBTITLE.png.sha1
  • chrome/browser/save_to_drive/save_to_drive_flow.cc
  • chrome/browser/save_to_drive/save_to_drive_flow.h
  • chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
  • chrome/browser/ui/save_to_drive/get_account.h
  • chrome/browser/ui/views/save_to_drive/account_chooser_controller.cc
  • chrome/browser/ui/views/save_to_drive/account_chooser_controller.h
From acfd83c6a15d997d3f907028014eb5251169eefe Mon Sep 17 00:00:00 2001
From: Yihong Guo <yhguo@google.com>
Date: Fri, 05 Jun 2026 13:15:50 -0700
Subject: [PATCH] [SaveToDrive] Display filename in Account Chooser UI

Fetch the document title in SaveToDriveFlow::Run and pass it to
AccountChooser::GetAccount. Update AccountChooserController and
AccountChooserView to receive and display the filename in the subtitle,
improving transparency for the user. This implements bullet point 1 of
the suggested fix for bug 515438919.

Long name ex: https://screenshot.googleplex.com/3LZT7nWmAy44zxZ.png

Short name ex: https://screenshot.googleplex.com/68MDUzm6pw4UotC.png

Bug: 515438919
Change-Id: I3bbf51f29bff6ed89bfd9dc7a6977abc4da0800d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7886192
Commit-Queue: Yihong Guo <yhguo@google.com>
Reviewed-by: Caroline Rising <corising@chromium.org>
Reviewed-by: Darryl James <dljames@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1642546}
---

diff --git a/chrome/app/generated_resources.grd b/chrome/app/generated_resources.grd
index 446e663..5895fa38 100644
--- a/chrome/app/generated_resources.grd
+++ b/chrome/app/generated_resources.grd
@@ -19540,7 +19540,7 @@
       Choose your account
     </message>
     <message name="IDS_ACCOUNT_CHOOSER_SUBTITLE" desc="Text description for saving files to Drive via Chrome PDF viewer.">
-      Find your file in the <ph name="FEATURE_NAME">$1<ex>Saved from Chrome</ex></ph> folder
+      Find your <ph name="FILENAME">$1<ex>document.pdf</ex></ph> in the <ph name="FEATURE_NAME">$2<ex>Saved from Chrome</ex></ph> folder
     </message>
     <message name="IDS_SAVE_TO_DRIVE_FOLDER_NAME" desc="Name of the folder in Drive where files saved from Chrome are stored." meaning="Name of the folder in Drive where files saved from Chrome are stored. Please use the short version of the branded term for Google Chrome i.e. Chrome">
       Saved from Chrome
diff --git a/chrome/app/generated_resources_grd/IDS_ACCOUNT_CHOOSER_SUBTITLE.png.sha1 b/chrome/app/generated_resources_grd/IDS_ACCOUNT_CHOOSER_SUBTITLE.png.sha1
index 1f3e698..4c78f16 100644
--- a/chrome/app/generated_resources_grd/IDS_ACCOUNT_CHOOSER_SUBTITLE.png.sha1
+++ b/chrome/app/generated_resources_grd/IDS_ACCOUNT_CHOOSER_SUBTITLE.png.sha1
@@ -1 +1 @@
-d7d3d3fbe1e8641a34aed3252d3379d0f2fa8a92
\ No newline at end of file
+533d83b42c3b269e2412dae232bb19f33add5c92
\ No newline at end of file
diff --git a/chrome/browser/save_to_drive/save_to_drive_flow.cc b/chrome/browser/save_to_drive/save_to_drive_flow.cc
index da5f8166..ed9d57dd 100644
--- a/chrome/browser/save_to_drive/save_to_drive_flow.cc
+++ b/chrome/browser/save_to_drive/save_to_drive_flow.cc
@@ -126,7 +126,9 @@
 
   WebContents* contents = GetTabWebContents(&render_frame_host());
   CHECK(contents);
-  account_chooser_->GetAccount(contents,
+  std::u16string title = contents->GetTitle();
+  upload_title_ = base::UTF16ToUTF8(title);
+  account_chooser_->GetAccount(contents, title,
                                base::BindOnce(&SaveToDriveFlow::OnAccountChosen,
                                               weak_ptr_factory_.GetWeakPtr()));
 }
@@ -172,8 +174,6 @@
     OnUploadProgress(std::move(progress));
     return;
   }
-  auto* web_contents = WebContents::FromRenderFrameHost(&render_frame_host());
-  std::string title = base::UTF16ToUTF8(web_contents->GetTitle());
 
   auto upload_progress_callback = base::BindRepeating(
       &SaveToDriveFlow::OnUploadProgress, weak_ptr_factory_.GetWeakPtr());
@@ -182,11 +182,11 @@
 
   if (base::ByteCount(content_reader_->GetSize()) < kMultipartUploadThreshold) {
     drive_uploader_ = std::make_unique<MultipartDriveUploader>(
-        std::move(title), std::move(account_info),
+        std::move(upload_title_), std::move(account_info),
         std::move(upload_progress_callback), profile, content_reader_.get());
   } else {
     drive_uploader_ = std::make_unique<ResumableDriveUploader>(
-        std::move(title), std::move(account_info),
+        std::move(upload_title_), std::move(account_info),
         std::move(upload_progress_callback), profile, content_reader_.get());
   }
   drive_uploader_->Start();
diff --git a/chrome/browser/save_to_drive/save_to_drive_flow.h b/chrome/browser/save_to_drive/save_to_drive_flow.h
index 0b3d9e2..e971904 100644
--- a/chrome/browser/save_to_drive/save_to_drive_flow.h
+++ b/chrome/browser/save_to_drive/save_to_drive_flow.h
@@ -130,6 +130,8 @@
 
   // This is set when an account is chosen.
   std::optional<SaveToDriveAccountInfo> save_to_drive_account_info_;
+  // This is the title of the document that is being saved to Drive.
+  std::string upload_title_;
   // This is set after the upload starts.
   std::optional<extensions::api::pdf_viewer_private::SaveToDriveProgress>
       upload_progress_;
diff --git a/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc b/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
index a3829a5..f912176 100644
--- a/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
+++ b/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
@@ -6,6 +6,7 @@
 
 #include <memory>
 #include <optional>
+#include <string>
 #include <utility>
 
 #include "base/functional/callback.h"
@@ -65,6 +66,7 @@
   MOCK_METHOD(void,
               GetAccount,
               (content::WebContents * web_contents,
+               const std::u16string& upload_title,
                base::OnceCallback<void(std::optional<AccountInfo>)>
                    on_account_selected_callback),
               (override));
@@ -171,7 +173,9 @@
         .WillOnce(
             [account_info = std::move(account_info), this](
                 content::WebContents* web_contents,
+                const std::u16string& upload_title,
                 base::OnceCallback<void(std::optional<AccountInfo>)> callback) {
+              EXPECT_EQ(upload_title, web_contents->GetTitle());
               // The callback could kill the flow, which would destroy the
               // account chooser. It needs to be reset to avoid a dangling
               // pointer.
diff --git a/chrome/browser/ui/save_to_drive/get_account.h b/chrome/browser/ui/save_to_drive/get_account.h
index 28cb1a2..9559020 100644
--- a/chrome/browser/ui/save_to_drive/get_account.h
+++ b/chrome/browser/ui/save_to_drive/get_account.h
@@ -7,6 +7,7 @@
 
 #include <memory>
 #include <optional>
+#include <string>
 
 #include "base/functional/callback_forward.h"
 
@@ -30,6 +31,7 @@
   // account has been chosen. If the account chooser is canceled, the callback
   // will be called with a `std::nullopt`.
   virtual void GetAccount(content::WebContents* web_contents,
+                          const std::u16string& upload_title,
                           base::OnceCallback<void(std::optional<AccountInfo>)>
                               on_account_chosen_callback);
 
diff --git a/chrome/browser/ui/views/save_to_drive/account_chooser_controller.cc b/chrome/browser/ui/views/save_to_drive/account_chooser_controller.cc
index 2e8664d..c659a81 100644
--- a/chrome/browser/ui/views/save_to_drive/account_chooser_controller.cc
+++ b/chrome/browser/ui/views/save_to_drive/account_chooser_controller.cc
@@ -90,9 +90,11 @@
 
 AccountChooserController::AccountChooserController(
     content::WebContents* web_contents,
-    signin::IdentityManager* identity_manager)
+    signin::IdentityManager* identity_manager,
+    const std::u16string& upload_title)
     : tab_(tabs::TabInterface::MaybeGetFromContents(web_contents)),
       identity_manager_(identity_manager),
+      upload_title_(upload_title),
       add_account_popup_observer_(
           std::make_unique<AddAccountPopupObserver>(this)) {
   CHECK(identity_manager_);
@@ -170,7 +172,8 @@
   }
   std::unique_ptr<AccountChooserView> account_chooser_view =
       std::make_unique<AccountChooserView>(this, profile_info.accounts,
-                                           profile_info.primary_account_id);
+                                           profile_info.primary_account_id,
+                                           upload_title_);
   account_chooser_view_ = account_chooser_view.get();
 
   account_chooser_dialog_delegate_ =
diff --git a/chrome/browser/ui/views/save_to_drive/account_chooser_controller.h b/chrome/browser/ui/views/save_to_drive/account_chooser_controller.h
index 9c6978d1..de54d84 100644
--- a/chrome/browser/ui/views/save_to_drive/account_chooser_controller.h
+++ b/chrome/browser/ui/views/save_to_drive/account_chooser_controller.h
@@ -6,6 +6,7 @@
 #define CHROME_BROWSER_UI_VIEWS_SAVE_TO_DRIVE_ACCOUNT_CHOOSER_CONTROLLER_H_
 
 #include <optional>
+#include <string>
 #include <vector>
 
 #include "base/functional/callback_forward.h"
@@ -37,7 +38,8 @@
                                  public AccountChooserViewDelegate {
  public:
   AccountChooserController(content::WebContents* web_contents,
-                           signin::IdentityManager* identity_manager);
+                           signin::IdentityManager* identity_manager,
+                           const std::u16string& upload_title);
   AccountChooserController(const AccountChooserController&) = delete;
   AccountChooserController& operator=(const AccountChooserController&) = delete;
   ~AccountChooserController() override;
@@ -118,6 +120,7 @@
   std::unique_ptr<views::DialogDelegate> account_chooser_dialog_delegate_;
   std::unique_ptr<views::Widget> account_chooser_widget_;
   std::optional<AccountInfo> selected_account_;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc b/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
index a3829a5..f912176 100644
--- a/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
+++ b/chrome/browser/save_to_drive/save_to_drive_flow_browsertest.cc
@@ -6,6 +6,7 @@
 
 #include <memory>
 #include <optional>
+#include <string>
 #include <utility>
 
 #include "base/functional/callback.h"
@@ -65,6 +66,7 @@
   MOCK_METHOD(void,
               GetAccount,
               (content::WebContents * web_contents,
+               const std::u16string& upload_title,
                base::OnceCallback<void(std::optional<AccountInfo>)>
                    on_account_selected_callback),
               (override));
@@ -171,7 +173,9 @@
         .WillOnce(
             [account_info = std::move(account_info), this](
                 content::WebContents* web_contents,
+                const std::u16string& upload_title,
                 base::OnceCallback<void(std::optional<AccountInfo>)> callback) {
+              EXPECT_EQ(upload_title, web_contents->GetTitle());
               // The callback could kill the flow, which would destroy the
               // account chooser. It needs to be reset to avoid a dangling
               // pointer.
diff --git a/chrome/browser/ui/views/save_to_drive/account_chooser_controller_interactive_uitest.cc b/chrome/browser/ui/views/save_to_drive/account_chooser_controller_interactive_uitest.cc
index 5134718..e7b5db52 100644
--- a/chrome/browser/ui/views/save_to_drive/account_chooser_controller_interactive_uitest.cc
+++ b/chrome/browser/ui/views/save_to_drive/account_chooser_controller_interactive_uitest.cc
@@ -31,6 +31,7 @@
 using ::save_to_drive::testing::GetTestAccounts;
 
 constexpr char kAvatarUrl[] = "https://avatar.com/avatar.png";
+constexpr char16_t kTestUploadTitle[] = u"test.pdf";
 
 AccountChosenCallback GetOnAccountChosenCallback(
     const AccountInfo& expected_account,
@@ -91,7 +92,8 @@
       account_chooser_controller_ = std::make_unique<AccountChooserController>(
           browser()->tab_strip_model()->GetActiveWebContents(),
           identity_test_environment_adaptor_->identity_test_env()
-              ->identity_manager());
+              ->identity_manager(),
+          kTestUploadTitle);
     });
   }
diff --git a/chrome/browser/ui/views/save_to_drive/account_chooser_view_unittest.cc b/chrome/browser/ui/views/save_to_drive/account_chooser_view_unittest.cc
index a5adae8..a2e4b7f 100644
--- a/chrome/browser/ui/views/save_to_drive/account_chooser_view_unittest.cc
+++ b/chrome/browser/ui/views/save_to_drive/account_chooser_view_unittest.cc
@@ -4,6 +4,7 @@
 
 #include "chrome/browser/ui/views/save_to_drive/account_chooser_view.h"
 
+#include "base/strings/utf_string_conversions.h"
 #include "chrome/browser/ui/views/chrome_layout_provider.h"
 #include "chrome/browser/ui/views/save_to_drive/account_chooser_radio_group_view.h"
 #include "chrome/browser/ui/views/save_to_drive/account_chooser_test_util.h"
@@ -28,6 +29,7 @@
 namespace {
 
 const char kTestDomain[] = "test.com";
+const char16_t kTestUploadTitle[] = u"test_file.pdf";
 
 using ::save_to_drive::testing::GetTestAccounts;
 using ::save_to_drive::testing::VerifyAccountChooserRow;
@@ -42,6 +44,8 @@
       static_cast<views::StyledLabel*>(header_view->children().back());
   // Verify the subtitle label.
   ASSERT_TRUE(subtitle_label);
+  EXPECT_THAT(base::UTF16ToUTF8(subtitle_label->GetText()),
+              ::testing::HasSubstr(base::UTF16ToUTF8(kTestUploadTitle)));
 }
 
 void VerifyAccountChooserViewFooter(views::View* footer_view) {
@@ -135,7 +139,8 @@
   std::vector<AccountInfo> accounts = GetTestAccounts({"pothos"}, kTestDomain);
   AccountChooserView* account_chooser_view =
       anchor_view_->AddChildView(std::make_unique<AccountChooserView>(
-          &mock_account_chooser_view_delegate_, accounts, std::nullopt));
+          &mock_account_chooser_view_delegate_, accounts, std::nullopt,
+          kTestUploadTitle));
   TestSingleAccount(account_chooser_view, accounts.front());
 }
 
@@ -144,7 +149,8 @@
       GetTestAccounts({"pothos", "fern"}, kTestDomain);
   AccountChooserView* account_chooser_view =
       anchor_view_->AddChildView(std::make_unique<AccountChooserView>(
-          &mock_account_chooser_view_delegate_, accounts, std::nullopt));
+          &mock_account_chooser_view_delegate_, accounts, std::nullopt,
+          kTestUploadTitle));
   TestMultiAccount(account_chooser_view, accounts);
 }
 
@@ -152,7 +158,8 @@
   std::vector<AccountInfo> accounts = GetTestAccounts({"pothos"}, kTestDomain);
   AccountChooserView* account_chooser_view =
       anchor_view_->AddChildView(std::make_unique<AccountChooserView>(
-          &mock_account_chooser_view_delegate_, accounts, std::nullopt));
+          &mock_account_chooser_view_delegate_, accounts, std::nullopt,
+          kTestUploadTitle));
   TestSingleAccount(account_chooser_view, accounts.front());
   std::vector<AccountInfo> new_accounts =
       GetTestAccounts({"pothos", "fern"}, kTestDomain);
@@ -165,7 +172,8 @@
       GetTestAccounts({"pothos", "fern"}, kTestDomain);
   AccountChooserView* account_chooser_view =
       anchor_view_->AddChildView(std::make_unique<AccountChooserView>(
-          &mock_account_chooser_view_delegate_, accounts, std::nullopt));
+          &mock_account_chooser_view_delegate_, accounts, std::nullopt,
+          kTestUploadTitle));
   TestMultiAccount(account_chooser_view, accounts);
   std::vector<AccountInfo> new_accounts =
       GetTestAccounts({"pothos"}, kTestDomain);
Loading diff…

Original Bug Report

reported by vm...@google.com

Arbitrary Google Drive Write via Compromised PDFium Renderer

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: A logic vulnerability in Chrome’s ‘Save to Drive’ feature allows a compromised PDFium renderer to perform authenticated writes of arbitrary data to a user’s Google Drive. The browser process trusts renderer-provided metadata and file content during the upload flow, bypassing intended sandbox restrictions. The attack is facilitated by the lack of filename visibility in the ‘Save to Drive’ user interface.

Affected files:

  • chrome/browser/save_to_drive/pdf_content_reader.cc
  • chrome/browser/save_to_drive/multipart_drive_uploader.cc
  • chrome/browser/save_to_drive/resumable_drive_uploader.cc
  • chrome/browser/save_to_drive/save_to_drive_flow.cc
  • chrome/browser/extensions/api/pdf_viewer_private/pdf_viewer_private_api.cc
  • components/pdf/browser/pdf_document_helper.cc

Estimated timestamp from git blame: 2025-09-08

Summary

A logic vulnerability has been identified in the Chrome ‘Save to Drive’ flow that allows a compromised, sandboxed PDFium renderer process to write arbitrary files to a user’s Google Drive. The browser process acts as a confused deputy, using its privileged OAuth credentials to upload data and metadata provided by the untrusted renderer without sufficient validation or user transparency.

Technical Analysis

When a user interacts with a PDF, the sandboxed renderer can influence the ‘Save to Drive’ process through several vectors:

  1. Filename Spoofing: The PDF renderer (plugin) can set the document title by sending a message to the extension frame, which then calls the pdfViewerPrivate.setPdfDocumentTitle API. The browser process implementation in PdfViewerPrivateSetPdfDocumentTitleFunction::Run (chrome/browser/extensions/api/pdf_viewer_private/pdf_viewer_private_api.cc) updates the WebContents title to this renderer-provided string without sanitization or origin validation.

  2. UI Masking: When the ‘Save to Drive’ flow is initiated, the browser displays an AccountChooser dialog (chrome/browser/ui/views/save_to_drive/account_chooser_view.cc). This dialog displays generic text (e.g., ‘Save to Google Drive’) but does not show the filename to the user. This prevents the user from noticing if the filename has been spoofed (e.g., changed from document.pdf to malicious.exe).

  3. Content Control: The browser retrieves the file content from the renderer via the pdf::mojom::SaveDataBufferHandler Mojo interface. The PDFContentReader (chrome/browser/save_to_drive/pdf_content_reader.cc) trusts the total_file_size and the bytes provided by the renderer-hosted Mojo implementation.

  4. Privileged Upload: The SaveToDriveFlow (chrome/browser/save_to_drive/save_to_drive_flow.cc) retrieves the spoofed title from the WebContents and uses it as the filename for the upload. The browser then uses an OAuth2 token with full https://www.googleapis.com/auth/drive scope to perform the upload via the browser-process URLLoaderFactory.

Because the PDFium renderer is normally denied network and filesystem access, the ability to perform authenticated writes to the user’s cloud storage represents a significant bypass of sandbox isolation.

Potential Impact

An attacker who has already compromised the PDFium renderer can place arbitrary files into a user’s Google Drive. This could be used to deliver malicious executables, plant spoofed documents, or exfiltrate data by masquerading as a legitimate ‘Save to Drive’ operation. While the attack requires the user to initiate the save and select an account, the lack of filename visibility makes the operation effectively silent.

Potential Reproduction Steps

  1. Compromise a sandboxed PDFium renderer (e.g., via a simulated memory corruption exploit).
  2. From the renderer, send a metadata update to change the document title to payload.exe.
  3. The extension UI will call chrome.pdfViewerPrivate.setPdfDocumentTitle('payload.exe'), updating the browser’s tab title.
  4. The renderer triggers the save flow by calling chrome.pdfViewerPrivate.saveToDrive() (this API does not require a user gesture in the IDL).
  5. The user is presented with the Account Chooser. They see a request to ‘Save to Google Drive’ and select their account.
  6. The browser process fetches the title payload.exe and the malicious bytes from the renderer.
  7. The browser uploads the malicious file to the user’s Google Drive.

Suggested Fix

  1. User Transparency: Modify the AccountChooser UI in chrome/browser/ui/views/save_to_drive/account_chooser_view.cc to explicitly display the filename being saved.
  2. Metadata Validation: Sanitize and validate the filename in the browser process before using it in the Drive API request. Ensure the extension is consistent with the expected file type (e.g., enforce a .pdf extension).
  3. Content Verification: Perform basic validation on the data returned by the SaveDataBufferHandler (e.g., verify the PDF magic header) to ensure the renderer is not providing entirely different file types.

Evaluated with Chrome root at commit: 29093e11cf509e3593f6229e4b1b075cca356049


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