CVE-2026-8527
Overview
Background
- `GenerateSafeFileName`
- Chrome’s download filename sanitizer that decides a download’s final extension and whether the file type is dangerous.
- `RemoveEnvVarFromFileName`
- A
uihelper that strips Windows environment-variable syntax (e.g.%...%) from a filename so it is not later evaluated by the shell/dialog. - Windows "Save As" basename trimming
- The native Windows file dialog silently strips trailing dots and spaces from a filename’s basename before saving, which can change the effective extension.
- Internet Shortcut (`.url`)
- A Windows file type that can execute/navigate when opened, so hiding a
.urlextension behind a benign-looking one is dangerous.
Root Cause Analysis
The pre-patch code performed a single pass of ui::RemoveEnvVarFromFileName and then a trailing-dot/whitespace trim loop, but these steps interacted with the native Windows dialog trimming in a way the sanitizer did not model. An attacker-controlled filename such as photo.jpg %%.url had its %% removed by RemoveEnvVarFromFileName, leaving photo.jpg .url; the sanitizer saw .url preceded by a space so its extension analysis was defeated, and separately the Windows “Save As” dialog natively strips the trailing space of the basename, so the file was actually written as a .url Internet Shortcut while being displayed to the user as photo.jpg. The invariant violated is that the extension Chrome evaluates for danger must equal the extension Windows ultimately saves.
The fix replaces the one-shot logic with SanitizeDownloadFileName, an iterative loop that alternately re-runs env-var removal and reproduces the dialog’s basename dot/space trimming (splitting off extension, trimming the basename, reassembling, then trimming the whole name) until the name stabilizes, so any hidden extension is surfaced and handed to GenerateSafeFileName.
Attack Path
- Craft a deceptive filename
The attacker serves a download whose suggested name embeds env-var characters and a trailing space before a dangerous extension, e.g.
photo.jpg%20%25%25.url(decodedphoto.jpg %%.url), per added test case 7. - Single-pass strip leaves residue
RemoveEnvVarFromFileNameremoves%%once, yieldingphoto.jpg .url, which the old loop does not re-process into a correct extension view. - Dialog hides the real extension
In the “Save As” dialog the native basename trim drops the trailing space so the user sees
photo.jpg, concealing the true.urlextension thatGenerateSafeFileNamewas not made aware of. - Dangerous file saved unflagged
The file is written as an Internet Shortcut without the sanitization/danger evaluation that a
.urlwould normally receive, so the user is deceived into saving an executable-class file.
Impact Assessment
.url Internet Shortcut) as a benign file in the Save As dialog, defeating Chrome’s extension-based danger checks. Preconditions are a Windows client and a user accepting the download; the advisory does not report in-the-wild exploitation, and any resulting code execution depends on the user later opening the saved shortcut (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
whilechrome/browser/download/download_target_determiner.cc |
modified |
Files Changed
chrome/browser/download/download_target_determiner.ccchrome/browser/download/download_target_determiner_unittest.cc
Audit Directions
- Sanitizer vs. platform post-processing mismatchLook for filename/path sanitizers that run once but feed into OS APIs (dialogs, shells) that further mutate the string, where the final form is never re-validated.
- Order-dependent transforms on untrusted namesAudit chained transforms (unescape, env-var strip, trim) on attacker-controlled filenames where a later step can resurrect a dangerous extension hidden by an earlier one; prefer fixed-point iteration.
- Extension-based danger checksReview every place a decision about download danger is keyed on the parsed extension, ensuring the parsed value equals what will actually be written to disk on each platform.
Patch
From 9c229906472adc5a601eef5e419b2a6726e91cc7 Mon Sep 17 00:00:00 2001
From: Andrew Liu <liu@chromium.org>
Date: Wed, 18 Mar 2026 11:36:07 -0700
Subject: [PATCH] Fix Windows "Save As" dialog extension bypass
When a user downloads a file, Chrome sanitizes the filename by
removing environment variables to prevent evaluation on the client.
However, an attacker could supply a filename like "photo.jpg %%.url".
The single-pass environment variable removal would strip "%%" and
leave "photo.jpg .url".
Because the Windows "Save As"" dialog natively strips trailing spaces
and dots from the basename of a file, "photo.jpg .url" is displayed
to the user simply as "photo.jpg", hiding the true ".url" extension.
Because Chrome's GenerateSafeFileName was unaware of this native
Windows trimming, it allowed the file to be saved as an Internet
Shortcut without proper sanitization.
This CL fixes the bypass by implementing an iterative sanitization
loop in `DownloadTargetDeterminer`. This loop mimics the exact basename
trimming behavior of the Windows "Save As" dialog, reconstructing and
cleaning the filename until it stabilizes. This ensures that any
hidden extensions are exposed and properly evaluated by
`GenerateSafeFileName`.
Bug: 486761172
Change-Id: I532f733d8eee0ee23f34eebd7d44f6c66a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7639229
Reviewed-by: Min Qin <qinmin@chromium.org>
Reviewed-by: Lily Chen <chlily@chromium.org>
Commit-Queue: Andrew Liu <liu@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1601428}
---
diff --git a/chrome/browser/download/download_target_determiner.cc b/chrome/browser/download/download_target_determiner.cc
index 1dd2dbab..630f43c 100644
--- a/chrome/browser/download/download_target_determiner.cc
+++ b/chrome/browser/download/download_target_determiner.cc
@@ -13,6 +13,7 @@
#include "base/location.h"
#include "base/metrics/histogram_functions.h"
#include "base/rand_util.h"
+#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
@@ -120,6 +121,52 @@
}
}
+#if BUILDFLAG(IS_WIN)
+// Iteratively sanitizes a download filename for Windows by removing environment
+// variables and trimming trailing dots and spaces. This mimics the behavior of
+// the Windows "Save As" dialog to ensure extension checks are accurate.
+// See crbug.com/41486690 and crbug.com/486079015 for more context.
+std::wstring SanitizeDownloadFileName(std::wstring_view initial_name) {
+ std::wstring current_name(initial_name);
+
+ const auto trim_trailing_dots_and_whitespace = [](std::wstring_view s) {
+ while (!s.empty() &&
+ (s.back() == L'.' || base::IsUnicodeWhitespace(s.back()))) {
+ s.remove_suffix(1);
+ }
+ return s;
+ };
+
+ while (true) {
+ std::wstring next_name =
+ ui::RemoveEnvVarFromFileName<wchar_t>(current_name, L"%");
+
+ const base::FilePath next_path(next_name);
+ const std::wstring extension = next_path.Extension();
+
+ std::wstring_view current_basename = next_path.value();
+ CHECK_LE(extension.length(), current_basename.length());
+ current_basename.remove_suffix(extension.length());
+
+ // Iteratively trim trailing dots and spaces from the basename since
+ // the Windows Save As dialog natively strips trailing spaces and dots
+ // from the basename before saving. We must simulate this to check the
+ // true final extension.
+ current_basename = trim_trailing_dots_and_whitespace(current_basename);
+
+ next_name = std::wstring(current_basename) + extension;
+
+ // Then, trim trailing dots and whitespace from the entire filename again.
+ next_name.resize(trim_trailing_dots_and_whitespace(next_name).length());
+
+ if (next_name.length() == current_name.length()) {
+ return current_name;
+ }
+ current_name = std::move(next_name);
+ }
+}
+#endif // BUILDFLAG(IS_WIN)
+
} // namespace
DownloadTargetDeterminerDelegate::~DownloadTargetDeterminerDelegate() = default;
@@ -597,21 +644,9 @@
// Windows prompt dialog will resolve all env variables in the file name,
// which may generate unexpected results. Remove env variables from the
// file name first.
- std::wstring sanitized_name = ui::RemoveEnvVarFromFileName<wchar_t>(
- virtual_path_.BaseName().value(), L"%");
- // Remove trailing "." and whitespace to avoid resorting to potential
- // extensions.
- // See crbug.com/41486690 and crbug.com/486079015 for more context.
- while (!sanitized_name.empty()) {
- size_t length = sanitized_name.length();
- while (!sanitized_name.empty() && sanitized_name.back() == L'.') {
- sanitized_name.pop_back();
- }
- // trim trailing whitespace (space, tab, NBSP) to prevent stale extensions
- base::TrimWhitespace(sanitized_name, base::TrimPositions::TRIM_TRAILING, &sanitized_name);
- if (length == sanitized_name.length())
- break;
- }
+ std::wstring sanitized_name =
+ SanitizeDownloadFileName(virtual_path_.BaseName().value());
+
if (sanitized_name.empty()) {
sanitized_name = base::UTF8ToWide(
l10n_util::GetStringUTF8(IDS_DEFAULT_DOWNLOAD_FILENAME));
diff --git a/chrome/browser/download/download_target_determiner_unittest.cc b/chrome/browser/download/download_target_determiner_unittest.cc
index 890c8305..c875ed04 100644
--- a/chrome/browser/download/download_target_determiner_unittest.cc
+++ b/chrome/browser/download/download_target_determiner_unittest.cc
@@ -2721,6 +2721,27 @@
FILE_PATH_LITERAL("foo.download"),
DownloadItem::TARGET_DISPOSITION_PROMPT,
+ EXPECT_CRDOWNLOAD},
+ {// 7: Prevent hiding extensions (like .url) in the Windows Save As dialog
+ // by wrapping trailing spaces inside environment variables.
+ SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+ DownloadFileType::NOT_DANGEROUS,
+ "http://example.com/photo.jpg%20%25%25.url", "text/plain",
+ FILE_PATH_LITERAL(""),
+
+ FILE_PATH_LITERAL("photo.jpg.url"),
+ DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+ EXPECT_CRDOWNLOAD},
+
+ {// 8: Ensure completely empty filenames made of just dots and environment
+ // variables collapse safely without retaining any dangerous extensions.
+ SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+ DownloadFileType::NOT_DANGEROUS, "http://example.com/.%25%25.%25%25",
+ "text/plain", FILE_PATH_LITERAL(""),
+
+ FILE_PATH_LITERAL("download"), DownloadItem::TARGET_DISPOSITION_PROMPT,
+
EXPECT_CRDOWNLOAD}};
RunTestCasesWithActiveItem(kSaveEnvPathTestCases);
Regression Test / PoC
diff --git a/chrome/browser/download/download_target_determiner_unittest.cc b/chrome/browser/download/download_target_determiner_unittest.cc
index 890c8305..c875ed04 100644
--- a/chrome/browser/download/download_target_determiner_unittest.cc
+++ b/chrome/browser/download/download_target_determiner_unittest.cc
@@ -2721,6 +2721,27 @@
FILE_PATH_LITERAL("foo.download"),
DownloadItem::TARGET_DISPOSITION_PROMPT,
+ EXPECT_CRDOWNLOAD},
+ {// 7: Prevent hiding extensions (like .url) in the Windows Save As dialog
+ // by wrapping trailing spaces inside environment variables.
+ SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+ DownloadFileType::NOT_DANGEROUS,
+ "http://example.com/photo.jpg%20%25%25.url", "text/plain",
+ FILE_PATH_LITERAL(""),
+
+ FILE_PATH_LITERAL("photo.jpg.url"),
+ DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+ EXPECT_CRDOWNLOAD},
+
+ {// 8: Ensure completely empty filenames made of just dots and environment
+ // variables collapse safely without retaining any dangerous extensions.
+ SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+ DownloadFileType::NOT_DANGEROUS, "http://example.com/.%25%25.%25%25",
+ "text/plain", FILE_PATH_LITERAL(""),
+
+ FILE_PATH_LITERAL("download"), DownloadItem::TARGET_DISPOSITION_PROMPT,
+
EXPECT_CRDOWNLOAD}};
RunTestCasesWithActiveItem(kSaveEnvPathTestCases);
Original Bug Report
Chrome on Windows can be tricked into saving a dangerous .url InternetShortcut without the .download
Steps to reproduce the problem
- Run: poc_alt.py (serves on 127.0.0.1:8001).
- Chrome on Windows → open http://127.0.0.1:8001/.
- Right-click “Bypass: photo.jpg %25%25.url” → “Save link as…”.
- Save dialog suggests photo.jpg .url (type: Internet Shortcut). No .download rewrite.
- Save & open: launches calc.exe (payload).
Problem Description
- Windows shell strips %…% from the suggested filename, making the basename appear extensionless.
- GenerateSafeFileName() in download_target_determiner.cc (Windows path) calls net::GenerateSafeFileName() with old_extension captured before sanitization.
- Because the basename now seems extensionless, the dangerous-extension rewrite is skipped, and .url is reattached verbatim.
Summary
Chrome on Windows can be tricked into saving a dangerous .url InternetShortcut without the .download
Additional Data
Category: Security
Chrome Channel: Stable
Regression: N/A \