CVE-2025-9867
Overview
Background
- `DangerousDownloadDialog`
- the Android modal dialog (“File might be harmful”) that asks the user to confirm a download Chrome has flagged as dangerous, with a “Download anyway” positive button.
- Site-controlled filename
- the
fileNameshown in the dialog comes fromdownload_item->GetFileNameToReportUser(), which a website can influence (for example through the response’s suggested filename), so it is untrusted text inside a browser security prompt. - `TextUtils.expandTemplate`
- the Android helper that replaces
^1,^2, … placeholders withCharSequencevalues while keeping spans such as bold or color, which the patch uses instead ofString.format-style%1$splaceholders. - `FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains`
- a
url_formatterroutine that reduces a URL to a display domain; the patch uses it onitem->GetURL()to show where the download comes from.
Root Cause Analysis
Before the patch, DangerousDownloadDialog.show built its message as plain text: Do you want to download %1$s anyway? (or the _WITH_SIZE variant), with the site-controlled filename inserted unquoted and with the same styling as the Chrome-authored text around it, and with no indication of which site the file came from. A site could therefore pick a filename that reads like part of Chrome’s own sentence, or that hides the dangerous extension, and the user had no origin to judge the request by. The removed code also had the size condition backwards: when totalBytes > 0 it used the no-size string, and it used ..._TEXT_WITH_SIZE only when the size was unknown.
The fix adds getMessageParagraphs, which wraps the filename in quotation marks (in all four .grd strings) and in a StyleSpan(Typeface.BOLD), adds the download domain from the new native helper GetDownloadDomain in a link-colored ForegroundColorSpan, and selects among four templates using correct hasSize/hasDownloadDomain logic. These changes separate untrusted content from Chrome’s text and add origin information; the patch adds no new validation of the filename itself.
Attack Path
- Serve a flagged file with a crafted name A malicious site starts a download that Chrome on Android classifies as dangerous, choosing a filename meant to blend into the surrounding warning text or to disguise the file type (the exact spoofing strings are not in the diff; inferred).
- Dialog renders untrusted text inline
DangerousDownloadDialogBridgepassesGetFileNameToReportUser()toDangerousDownloadDialog.show, which before the patch substituted it intoIDS_DANGEROUS_DOWNLOAD_DIALOG_TEXTwith no quotes or styling and without any domain. - User misreads the warning With no clear boundary between the site’s text and Chrome’s text, and no “from <domain>” context, the user may believe the file is benign or comes from a trusted source (inferred).
- User confirms The user taps “Download anyway”, and the dangerous file is saved to the device. Whether it is then opened or run depends on later user actions (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
DangerousDownloadDialogBridgechrome/browser/download/android/dangerous_download_dialog_bridge.cc |
modified | |
ifchrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.java |
modified | |
ifchrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.java |
modified |
Files Changed
chrome/android/javatests/src/org/chromium/chrome/browser/download/dialogs/DownloadDialogIncognitoTest.javachrome/browser/download/android/dangerous_download_dialog_bridge.ccchrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.javachrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.javachrome/browser/ui/android/strings/android_chrome_strings.grdchrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT.png.sha1chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_DOMAIN.png.sha1chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE.png.sha1chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE_AND_DOMAIN.png.sha1
Audit Directions
- Untrusted strings in security promptsLook at other permission, download, external-protocol and file-picker dialogs (on all platforms) that insert filenames, page titles or URL fragments into a sentence without delimiters, for possible spoofing of Chrome-authored text, including bidirectional-text and newline characters.
- Origin attributionCheck whether consequential prompts display the right origin:
GetDownloadDomainusesitem->GetURL(), so check how this behaves forblob:/data:URLs, redirects and downloads started from a different initiator (an empty domain falls back to the no-domain template). - Template and condition errorsThe removed code chose the size template backwards (
totalBytes > 0selected the no-size string); look for similar inverted ternaries in other prompt-building code where the wrong template can hide security-relevant details.
Patch
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/download/dialogs/DownloadDialogIncognitoTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/download/dialogs/DownloadDialogIncognitoTest.java
index 7c59973..41667efc 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/download/dialogs/DownloadDialogIncognitoTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/download/dialogs/DownloadDialogIncognitoTest.java
@@ -44,6 +44,7 @@
private static final String DOWNLOAD_PATH = "/android/Download";
private static final String PAGE_URL = "www.pageurl.com/download";
private static final String FILE_NAME = "download.pdf";
+ private static final String DOWNLOAD_DOMAIN = "pageurl.com";
public static final int ICON_ID = R.drawable.btn_close;
@Rule
@@ -179,6 +180,7 @@
mModalDialogManager,
FILE_NAME,
TOTAL_BYTES,
+ DOWNLOAD_DOMAIN,
ICON_ID,
mResultCallback);
});
diff --git a/chrome/browser/download/android/dangerous_download_dialog_bridge.cc b/chrome/browser/download/android/dangerous_download_dialog_bridge.cc
index 100df18c..7039954 100644
--- a/chrome/browser/download/android/dangerous_download_dialog_bridge.cc
+++ b/chrome/browser/download/android/dangerous_download_dialog_bridge.cc
@@ -16,6 +16,7 @@
#include "chrome/browser/android/resource_mapper.h"
#include "chrome/browser/download/android/download_dialog_utils.h"
#include "chrome/grit/generated_resources.h"
+#include "components/url_formatter/elide_url.h"
#include "ui/android/window_android.h"
#include "ui/base/l10n/l10n_util.h"
@@ -25,6 +26,15 @@
using base::android::ConvertJavaStringToUTF8;
using base::android::JavaParamRef;
+namespace {
+// Gets the "download domain" string shown in the dialog. Currently, this is
+// derived from the download URL.
+std::u16string GetDownloadDomain(download::DownloadItem* item) {
+ return url_formatter::FormatUrlForDisplayOmitSchemePathAndTrivialSubdomains(
+ item->GetURL());
+}
+} // namespace
+
DangerousDownloadDialogBridge::DangerousDownloadDialogBridge() {
JNIEnv* env = base::android::AttachCurrentThread();
java_object_.Reset(Java_DangerousDownloadDialogBridge_create(
@@ -59,6 +69,8 @@
download_item->GetGuid(),
base::UTF8ToUTF16(download_item->GetFileNameToReportUser().value()),
download_item->GetTotalBytes(),
+ base::android::ConvertUTF16ToJavaString(env,
+ GetDownloadDomain(download_item)),
ResourceMapper::MapToJavaDrawableId(IDR_ANDROID_INFOBAR_WARNING));
}
diff --git a/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.java b/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.java
index 350a718..dbaabb4a 100644
--- a/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.java
+++ b/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/DangerousDownloadDialogBridge.java
@@ -45,6 +45,7 @@
* @param guid GUID of the download.
* @param fileName Name of the download file.
* @param totalBytes Total bytes of the file.
+ * @param downloadDomain Domain name to associate with the downloaded file.
* @param iconId The icon resource for the warning dialog.
*/
@CalledByNative
@@ -53,6 +54,7 @@
@JniType("std::string") String guid,
@JniType("std::u16string") String fileName,
long totalBytes,
+ String downloadDomain,
int iconId) {
Activity activity = windowAndroid.getActivity().get();
if (activity == null) {
@@ -66,6 +68,7 @@
((ModalDialogManagerHolder) activity).getModalDialogManager(),
fileName,
totalBytes,
+ downloadDomain,
iconId,
(accepted) -> {
if (accepted) {
diff --git a/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.java b/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.java
index 0c31593..34eb503 100644
--- a/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.java
+++ b/chrome/browser/download/android/java/src/org/chromium/chrome/browser/download/dialogs/DangerousDownloadDialog.java
@@ -5,6 +5,12 @@
package org.chromium.chrome.browser.download.dialogs;
import android.content.Context;
+import android.graphics.Typeface;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
import androidx.annotation.IntDef;
import androidx.core.content.res.ResourcesCompat;
@@ -13,6 +19,7 @@
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.build.annotations.NullMarked;
import org.chromium.chrome.browser.download.R;
+import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.util.DownloadUtils;
import org.chromium.ui.UiUtils;
import org.chromium.ui.modaldialog.DialogDismissalCause;
@@ -55,31 +62,25 @@
/**
* Called to show a warning dialog for dangerous download.
+ *
* @param context Context for showing the dialog.
* @param modalDialogManager Manager for managing the modal dialog.
* @param fileName Name of the download file.
* @param totalBytes Total bytes of the file.
+ * @param downloadDomain Domain name to associate with the downloaded file.
* @param iconId Icon ID of the warning dialog.
* @param callback Callback to run when confirming the download, true for accept the download,
- * false otherwise.
+ * false otherwise.
*/
public void show(
Context context,
ModalDialogManager modalDialogManager,
String fileName,
long totalBytes,
+ String downloadDomain,
int iconId,
Callback<Boolean> callback) {
var resources = context.getResources();
- String message =
- totalBytes > 0
- ? resources.getString(R.string.dangerous_download_dialog_text, fileName)
- : resources.getString(
- R.string.dangerous_download_dialog_text_with_size,
- fileName,
- DownloadUtils.getStringForBytes(context, totalBytes));
- ArrayList<CharSequence> message_paragraphs = new ArrayList<>(List.of(message));
-
var controller =
new ModalDialogProperties.Controller() {
@Override
@@ -112,13 +113,16 @@
}
}
};
+
PropertyModel propertyModel =
new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
.with(ModalDialogProperties.CONTROLLER, controller)
.with(
ModalDialogProperties.TITLE,
resources.getString(R.string.dangerous_download_dialog_title))
- .with(ModalDialogProperties.MESSAGE_PARAGRAPHS, message_paragraphs)
+ .with(
+ ModalDialogProperties.MESSAGE_PARAGRAPHS,
+ getMessageParagraphs(context, fileName, totalBytes, downloadDomain))
.with(
ModalDialogProperties.POSITIVE_BUTTON_TEXT,
resources.getString(
@@ -143,6 +147,64 @@
}
/**
+ * Selects the appropriate message string template and inserts formatted substitutions.
+ *
+ * @return an ArrayList suitable for setting as MESSAGE_PARAGRAPHS property for the dialog.
+ */
+ private static ArrayList<CharSequence> getMessageParagraphs(
+ Context context, String fileName, long totalBytes, String downloadDomain) {
+ boolean hasSize = totalBytes > 0;
+ boolean hasDownloadDomain = !downloadDomain.isEmpty();
+
+ int stringResId;
+ int numSubstitutions;
+ if (hasSize && hasDownloadDomain) {
+ stringResId = R.string.dangerous_download_dialog_text_with_size_and_domain;
+ numSubstitutions = 3;
+ } else if (hasSize) {
+ stringResId = R.string.dangerous_download_dialog_text_with_size;
+ numSubstitutions = 2;
+ } else if (hasDownloadDomain) {
+ stringResId = R.string.dangerous_download_dialog_text_with_domain;
+ numSubstitutions = 2;
+ } else {
+ stringResId = R.string.dangerous_download_dialog_text;
+ numSubstitutions = 1;
+ }
+ CharSequence messageTemplate = context.getString(stringResId);
+ ArrayList<CharSequence> substitutions = new ArrayList<CharSequence>();
+
+ SpannableString formattedFileName = new SpannableString(fileName);
+ formattedFileName.setSpan(
+ new StyleSpan(Typeface.BOLD),
+ 0,
+ fileName.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ substitutions.add(formattedFileName);
+
+ if (hasSize) {
+ String formattedSize = DownloadUtils.getStringForBytes(context, totalBytes);
+ substitutions.add(formattedSize);
+ }
+
+ if (hasDownloadDomain) {
+ SpannableString formattedDownloadDomain = new SpannableString(downloadDomain);
+ formattedDownloadDomain.setSpan(
+ new ForegroundColorSpan(SemanticColorUtils.getDefaultTextColorLink(context)),
+ 0,
+ downloadDomain.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ substitutions.add(formattedDownloadDomain);
+ }
+
+ assert substitutions.size() == numSubstitutions;
+ CharSequence message =
+ TextUtils.expandTemplate(
+ messageTemplate, substitutions.toArray(new CharSequence[0]));
+ return new ArrayList<>(List.of(message));
+ }
+
+ /**
* Collects dangerous download dialog UI event metrics.
*
* @param event The UI event to collect.
diff --git a/chrome/browser/ui/android/strings/android_chrome_strings.grd b/chrome/browser/ui/android/strings/android_chrome_strings.grd
index 95db4b7..4ecec15 100644
--- a/chrome/browser/ui/android/strings/android_chrome_strings.grd
+++ b/chrome/browser/ui/android/strings/android_chrome_strings.grd
@@ -2200,10 +2200,16 @@
Download anyway
</message>
<message name="IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT" desc="Warning message shown on a dialog to ask user to validate the download of a dangerous file.">
- Do you want to download <ph name="FILE_NAME">%1$s<ex>malware.exe</ex></ph> anyway?
+ Do you want to download "<ph name="FILE_NAME">^1<ex>malware.exe</ex></ph>" anyway?
+ </message>
+ <message name="IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_DOMAIN" desc="Warning message shown on a dialog to ask user to validate the download of a dangerous file, including the domain of the download.">
+ Do you want to download "<ph name="FILE_NAME">^1<ex>malware.exe</ex></ph>" from <ph name="DOWNLOAD_DOMAIN">^2<ex>badsite.com</ex></ph> anyway?
</message>
<message name="IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE" desc="Warning message shown on a dialog to ask user to validate the download of a dangerous file, with size information.">
- Do you want to download <ph name="FILE_NAME">%1$s<ex>malware.exe</ex></ph> (<ph name="FILE_SIZE">%2$s<ex>100MB</ex></ph>) anyway?
+ Do you want to download "<ph name="FILE_NAME">^1<ex>malware.exe</ex></ph>" (<ph name="FILE_SIZE">^2<ex>100MB</ex></ph>) anyway?
+ </message>
+ <message name="IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE_AND_DOMAIN" desc="Warning message shown on a dialog to ask user to validate the download of a dangerous file, with size information and the domain of the download.">
+ Do you want to download "<ph name="FILE_NAME">^1<ex>malware.exe</ex></ph>" (<ph name="FILE_SIZE">^2<ex>100MB</ex></ph>) from <ph name="DOWNLOAD_DOMAIN">^3<ex>badsite.com</ex></ph> anyway?
</message>
<message name="IDS_DANGEROUS_DOWNLOAD_DIALOG_TITLE" desc="Title of the dialog for asking user to confirm a dangerous download">
File might be harmful
diff --git a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT.png.sha1 b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT.png.sha1
index 743476d..72bfbc0 100644
--- a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT.png.sha1
+++ b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT.png.sha1
@@ -1 +1 @@
-0030062f1ca249adc2da7b55e5b100df323182fe
\ No newline at end of file
+560efd7c65ac68e98631a3e9b8556f6989b3e599
\ No newline at end of file
diff --git a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_DOMAIN.png.sha1 b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_DOMAIN.png.sha1
new file mode 100644
index 0000000..54d30020
--- /dev/null
+++ b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_DOMAIN.png.sha1
@@ -0,0 +1 @@
+157768c9a23ead635a19d3391587220293397e69
\ No newline at end of file
diff --git a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE.png.sha1 b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE.png.sha1
index 743476d..03f0040 100644
--- a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE.png.sha1
+++ b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE.png.sha1
@@ -1 +1 @@
-0030062f1ca249adc2da7b55e5b100df323182fe
\ No newline at end of file
+b2bdbe28d07a836efadad9fa6182a67525afa58d
\ No newline at end of file
diff --git a/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE_AND_DOMAIN.png.sha1 b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE_AND_DOMAIN.png.sha1
new file mode 100644
index 0000000..f8c89d08
--- /dev/null
+++ b/chrome/browser/ui/android/strings/android_chrome_strings_grd/IDS_DANGEROUS_DOWNLOAD_DIALOG_TEXT_WITH_SIZE_AND_DOMAIN.png.sha1
@@ -0,0 +1 @@
+d7a03d5ac212f99244db8e3c62edf0de5b8e4af4
\ No newline at end of file
Original Bug Report
"File might be harmful" dialog does not have origin
Steps to reproduce the problem
- Access poc.html
- Click the button
- The download pop-up will still appear on https://google.com
Problem Description
This vulnerability occurs when there is a condition that a file download pop-up origin can be overlaid on other origin. The file download pop-up should only be appear when user is accessing the real origin where the file download pop-up come from.
For more information you can read on crbug.com/40055527
Chrome version: 135.0.7049.111 (Official Build) (64-bit)
Summary
crbug.com/40055527 is still reproducible on newest version of Chrome
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: Yes