Medium watch CVSS 5.4 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Downloads
DescriptionInsufficient validation of untrusted input in Downloads
ComponentDownloads
Sub-componentWindows
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker444803530
Fix commit5e9cd600b865 (chromium/src) +25/-2
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedAzur
Reported2025-09-13 · bounty $3000 (per vendor advisory)
Fix landed2025-10-31 (public git)
Disclosed2026-01-13
Patch gap74 days — fix public in source before the advisory

Background

`DownloadTargetDeterminer`
the browser-process class in chrome/browser/download/download_target_determiner.cc that computes a download’s final path. When a prompt is needed, it builds a sanitised suggested name before calling delegate_->RequestConfirmation.
Windows filename sanitisation
a BUILDFLAG(IS_WIN)-only block that strips environment-variable markers (%...%) and trailing . characters from the suggested name, so the Save dialog cannot be given an expandable or malformed name.
`GenerateSafeFileName`
a helper that takes the path, an extension and the MIME type and makes the extension safe. Dangerous types such as .lnk are rewritten (the test expects foo2.download).
`base::FilePath::Extension`
returns the substring from the last . of the basename, so its result depends on whether the trailing junk has already been removed.

Root Cause Analysis

The hunk sits in the Windows-only branch that runs just before delegate_->RequestConfirmation. The enclosing function’s name is not shown in the hunk; it is likely DownloadTargetDeterminer::DoRequestConfirmation, but this is unconfirmed. That branch sanitised the name into sanitized_name, but then called GenerateSafeFileName(&sanitized_path, virtual_path_.Extension(), ...). The extension was taken from the original, unsanitised virtual_path_ rather than from the name actually being suggested. The trimming was also incomplete: only trailing . characters were removed, so trailing whitespace, including U+00A0 NBSP, survived after %% was stripped. For an attacker-supplied name like foo2.lnk %%, the sanitised basename keeps a real .lnk type hidden behind trailing whitespace. The extension check then evaluated the wrong (stale) extension, so the dangerous .lnk was not rewritten. The commit message says this let “blocked types such as .lnk reappear” and could produce names like .txt .txt.

The fix adds base::TrimWhitespace(..., TRIM_TRAILING, ...) after the dot-stripping loop and passes post_sanitize_ext, computed from base::FilePath(sanitized_name).Extension(), to GenerateSafeFileName. This makes the extension check match the name that will actually be proposed.

Key insight
The safety check ran against the extension of the pre-sanitisation path while the user was offered the post-sanitisation name, and trailing whitespace or NBSP was never trimmed. The fix trims trailing whitespace and derives the extension from the sanitised basename, so a dangerous type like .lnk is recognised and neutralised.

Attack Path

  1. Serve a crafted filename A website triggers a download whose suggested name is like foo2.lnk %% or foo2.lnk %%, served as application/octet-stream (the test uses the URL http://example.com/foo2.lnk %%).
  2. Reach the prompt path on Windows The download goes to the confirmation path (the test uses SAVE_AS with TARGET_DISPOSITION_PROMPT), which runs the IS_WIN sanitisation block.
  3. Sanitisation leaves a hidden dangerous type %% is stripped but trailing whitespace is not, and GenerateSafeFileName is given virtual_path_.Extension() instead of the extension of the sanitised name, so .lnk is not rewritten to a safe type.
  4. Dangerous file saved The user accepts the Save dialog and gets a .lnk-type file on disk, which they may later open (inferred: Windows’ normalisation of trailing whitespace in names and the subsequent shortcut execution are not shown in the diff).

Impact Assessment

A malicious website can bypass Chrome’s dangerous-file-type handling on Windows and get a blocked type such as .lnk saved through the download prompt. The bug is in browser-process download logic, reached by ordinary web content in a stock Windows build. The preconditions are Windows only (the code is under BUILDFLAG(IS_WIN)), a download that goes through the confirmation or Save-As prompt, and the user accepting the save. Any code execution would also require the user to open the file afterwards (inferred). The vendor rates it medium severity (“Insufficient validation of untrusted input in Downloads”). The advisory text given here says nothing about in-the-wild exploitation.

Files Changed

  • chrome/browser/download/download_target_determiner.cc
  • chrome/browser/download/download_target_determiner_unittest.cc

Audit Directions

  • Check against the same string that is used
    Look for download, file-picker and File System Access code that validates or classifies an extension computed from one version of a filename (raw, pre-trim, pre-normalisation) while writing or suggesting another.
  • Incomplete trimming of Windows-ignored characters
    Windows drops trailing dots and spaces from names. Audit every sanitiser for trailing whitespace, Unicode spaces (NBSP, ideographic space), alternate data stream : suffixes and environment-variable markers, and check the order these steps run in.
  • Platform-specific sanitisation branches
    IS_WIN-only blocks in download_target_determiner.cc and net/base/filename_util are less tested. Compare them with the cross-platform path to find steps (like extension recomputation) one branch skips.
From 5e9cd600b865a126eb6d14ffb06dd311d6267faa Mon Sep 17 00:00:00 2001
From: Kovacs Zeteny <brightbulbapp@gmail.com>
Date: Fri, 31 Oct 2025 10:07:46 -0700
Subject: [PATCH] Windows: fix bug that could re-attach dangerous extension

This CL ensures that after environment variable markers and trailing
characters are stripped from a filename on Windows, the extension is
derived from the sanitized basename. Without this, Chrome could fall
back to re-attaching the original extension, leading to cases like
".txt .txt" or allowing blocked types such as ".lnk" to reappear.

Bug: 444803530
Change-Id: Ie8f19b7ef4777721e7ac1376957cda55250142b4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6990751
Reviewed-by: Min Qin <qinmin@chromium.org>
Reviewed-by: Xinghui Lu <xinghuilu@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1538683}
---

diff --git a/chrome/browser/download/download_target_determiner.cc b/chrome/browser/download/download_target_determiner.cc
index 1d88eb7..9e019f4 100644
--- a/chrome/browser/download/download_target_determiner.cc
+++ b/chrome/browser/download/download_target_determiner.cc
@@ -606,13 +606,17 @@
       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 (sanitized_name.empty()) {
         sanitized_name = base::UTF8ToWide(
             l10n_util::GetStringUTF8(IDS_DEFAULT_DOWNLOAD_FILENAME));
       }
       sanitized_path =
           virtual_path_.DirName().Append(base::FilePath(sanitized_name));
-      GenerateSafeFileName(&sanitized_path, virtual_path_.Extension(),
+      const base::FilePath::StringType post_sanitize_ext =
+          base::FilePath(sanitized_name).Extension();
+      GenerateSafeFileName(&sanitized_path, post_sanitize_ext,
                            download_->GetMimeType());
 #endif  // BUILDFLAG(IS_WIN)
       delegate_->RequestConfirmation(
diff --git a/chrome/browser/download/download_target_determiner_unittest.cc b/chrome/browser/download/download_target_determiner_unittest.cc
index f3043a21..2505800 100644
--- a/chrome/browser/download/download_target_determiner_unittest.cc
+++ b/chrome/browser/download/download_target_determiner_unittest.cc
@@ -2714,7 +2714,26 @@
        DownloadItem::TARGET_DISPOSITION_PROMPT,
 
        EXPECT_CRDOWNLOAD},
-      {// 3: File name is an env var.
+      {// 3: Double extension bug leading to dangerous extensions after removing env var.
+       SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+       DownloadFileType::NOT_DANGEROUS, "http://example.com/foo2.lnk %%",
+       "application/octet-stream", FILE_PATH_LITERAL(""),
+
+       FILE_PATH_LITERAL("foo2.download"),
+       DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+       EXPECT_CRDOWNLOAD},
+      {// 4: Unicode char bug leading to dangerous extensions after removing env var.
+       // NOTE: The space before "%%" is a non-breaking space (U+00A0), not a normal space.
+       SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+       DownloadFileType::NOT_DANGEROUS, "http://example.com/foo2.lnk %%",
+       "application/octet-stream", FILE_PATH_LITERAL(""),
+
+       FILE_PATH_LITERAL("foo2.download"),
+       DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+       EXPECT_CRDOWNLOAD},
+      {// 5: File name is an env var.
        SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
        DownloadFileType::NOT_DANGEROUS, "http://example.com/%foo.txt%",
        "text/plain", FILE_PATH_LITERAL(""),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/download/download_target_determiner_unittest.cc b/chrome/browser/download/download_target_determiner_unittest.cc
index f3043a21..2505800 100644
--- a/chrome/browser/download/download_target_determiner_unittest.cc
+++ b/chrome/browser/download/download_target_determiner_unittest.cc
@@ -2714,7 +2714,26 @@
        DownloadItem::TARGET_DISPOSITION_PROMPT,
 
        EXPECT_CRDOWNLOAD},
-      {// 3: File name is an env var.
+      {// 3: Double extension bug leading to dangerous extensions after removing env var.
+       SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+       DownloadFileType::NOT_DANGEROUS, "http://example.com/foo2.lnk %%",
+       "application/octet-stream", FILE_PATH_LITERAL(""),
+
+       FILE_PATH_LITERAL("foo2.download"),
+       DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+       EXPECT_CRDOWNLOAD},
+      {// 4: Unicode char bug leading to dangerous extensions after removing env var.
+       // NOTE: The space before "%%" is a non-breaking space (U+00A0), not a normal space.
+       SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+       DownloadFileType::NOT_DANGEROUS, "http://example.com/foo2.lnk %%",
+       "application/octet-stream", FILE_PATH_LITERAL(""),
+
+       FILE_PATH_LITERAL("foo2.download"),
+       DownloadItem::TARGET_DISPOSITION_PROMPT,
+
+       EXPECT_CRDOWNLOAD},
+      {// 5: File name is an env var.
        SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
        DownloadFileType::NOT_DANGEROUS, "http://example.com/%foo.txt%",
        "text/plain", FILE_PATH_LITERAL(""),
Loading diff…

Original Bug Report

reported by br...@gmail.com

Windows download logic flaw: % triggers double extension sanitization bypass (.lnk .lnk, .scf .scf)

Description

Chrome on Windows can be tricked into delivering dangerous file types (for example, .lnk or .scf) despite the download sanitization that should rewrite them to .download.

When a suggested filename includes certain % sequences after a space following the extension, Chrome strips the % tokens, then re-applies the original extension, yielding a double extension (for example, file.lnk .lnk). As a result, the forbidden extension survives instead of being rewritten.

This appears to be a Windows-only issue, because the Windows file dialog expands or strips %…% patterns and the sanitization path subsequently reattaches the stale extension.

Examples

  • Content-Disposition: attachment; filename="file.lnk %%"
    Result: file.lnk .lnk (expected: file.download)
  • Content-Disposition: attachment; filename="file.scf .%%"
    Result: file.scf .scf (expected: file.download)

Expected behavior Dangerous extensions (for example, .lnk, .scf) should always be rewritten to .download. No combination of spaces or % should allow the original extension to persist or reappear.

Observed behavior Appending " %%" (or similar) after the extension leads to a double extension, allowing .lnk or .scf to survive (for example, file.lnk .lnk). This bypasses Chrome’s dangerous-file protection and presents users with a misleading, executable shortcut.

Security impact This is a sanitization bypass that enables delivery of files Chrome intends to block. A user can be tricked into saving and executing a Windows shortcut (.lnk) that appears benign (for example, disguised as an “image” or document link). Impact includes code execution or persistence via shortcut abuse.

Steps to reproduce

  1. Run the attached PoC server (poc.py). It serves a page with two tiles and download endpoints that set Content-Disposition.
  2. Visit http://127.0.0.1:8000/ in Chrome on Windows.
  3. Right-click → “Save link as…” on:
    • Control: /download?case=lnk_plain → filename="file.lnk" (should be rewritten to .download).
    • Bypass: /download?case=lnk_pct → filename="file.lnk %%" (often shows file.lnk .lnk).
  4. Observe the suggested filename in the Save dialog and the final saved name.

Note: The PoC serves a harmless payload.download file so the saved file is never HTML. It isolates the filename/extension behavior under Content-Disposition.

Affected versions (observed)

  • Stable: 140.0.7339.128
  • Beta: 141.0.7390.16
  • Dev: 142.0.7405.4

Bisect results (observed)

  • Last known developer build without the issue: Win/1023679 (105.0.5178.0 Dev).
  • First developer build with the issue: Win/1025642 (105.0.5190.0 Dev).
  • This aligns with commit 5e105f0bc3443e873924b92778574ffbb792c645, which landed in 105.0.5183.0 and modified the Windows filename sanitization logic.

Root cause (high level)

  • On Windows, the Save dialog layer strips %…% from the basename. With inputs like "file.lnk %%", this leaves "file.lnk " (with a trailing space).
  • Later, the download target logic calls safe-name handling with the pre-sanitization extension (for example, .lnk) while the post-sanitized basename appears extensionless.
  • The safe-name logic then re-attaches the old extension, producing file.lnk .lnk and bypassing the .lnk → .download rewrite.

PoC Attach poc.py (serves a self-contained HTML page and Content-Disposition endpoints). Usage:

View on issue tracker
Uploaded PoC files
Links in the report