CVE-2026-5887
Overview
Background
- Download target determiner
- the browser-process component that decides the final on-disk filename for a download, including sanitizing an attacker-influenced suggested name.
- `RemoveEnvVarFromFileName`
- a Windows helper that strips environment-variable-style tokens (delimited by
%) from a filename so they cannot be expanded. - Trailing-dot/whitespace stripping
- Windows silently drops trailing
.and spaces when opening a path, so Chrome must strip them first to see the file’s true final extension before applying dangerous-file policy. - Stale extension
- a dangerous extension (e.g.
.lnk) left intact because normalization failed to remove trailing junk that was masking it.
Root Cause Analysis
The Windows filename-sanitization block in download_target_determiner.cc normalized a suggested name by, in a single pass, popping trailing . characters in a while loop and then calling base::TrimWhitespace(..., TRIM_TRAILING) exactly once. This one-shot ordering fails when dots and whitespace are interleaved at the end of the name (as produced after RemoveEnvVarFromFileName removes multiple %% tokens): stripping dots can expose trailing whitespace, and trimming whitespace can expose further trailing dots, but the second kind of junk is never revisited. For input like foo.lnk .%% .%%, the old code left a residual trailing ./space sequence so the dangerous .lnk extension survived normalization.
The fix wraps both steps in an outer while loop that records the length, strips trailing dots, trims trailing whitespace, and repeats until the length stops changing, guaranteeing all interleaved trailing dots and whitespace are removed so the real extension (here, none — yielding the default foo.download) is what policy evaluates.
Attack Path
- Serve a crafted download name
A malicious site offers a download whose suggested filename ends with interleaved dots, spaces, and double-percent tokens, e.g.
foo.lnk .%% .%%. - Partial normalization
RemoveEnvVarFromFileNamestrips the%-delimited tokens, and the old single-pass dot/whitespace trimming leaves a residual trailing./space sequence. - Stale extension survives
Because normalization stops early, the dangerous
.lnkextension remains on the name instead of being stripped to the safe default. - Policy bypass The download is saved/classified against the un-normalized name, so a dangerous file type can masquerade past the dangerous-extension handling (inferred; the diff shows the normalization gap, not the downstream classification outcome).
Impact Assessment
.lnk) that it was supposed to strip, potentially evading dangerous-file handling in the browser process; exploitation requires the user to initiate/accept the download (the test uses the SAVE_AS path) and is Windows-specific (wchar_t, RemoveEnvVarFromFileName<wchar_t>). The vendor advisory rates this medium (insufficient validation of untrusted input in Downloads) and does not report in-the-wild exploitation; the concrete security bypass is (inferred) from the stale-extension behavior.Files Changed
chrome/browser/download/download_target_determiner.ccchrome/browser/download/download_target_determiner_unittest.cc
Audit Directions
- Single-pass sanitization of adversarial stringslook for filename/path normalizers that strip two or more trailing character classes once each; interleaved input defeats non-iterated passes.
- Order-dependent trimmingflag any “remove A then remove B” normalization where removing B can re-expose A (or vice versa) and no fixed-point loop guarantees convergence.
- Post-token-removal residueafter helpers like
RemoveEnvVarFromFileNamedelete substrings, re-audit downstream trimming for the new trailing junk those deletions can expose, especially on Windows where trailing dots/spaces are semantically dropped.
Patch
From 8c1145d4b23d6a8cf2867b922716153f6ea72d9e Mon Sep 17 00:00:00 2001 From: Andrew Liu <[email protected]> Date: Wed, 04 Mar 2026 12:47:47 -0800 Subject: [PATCH] Filter out multiple "double percent signs" when normalizing download files Bug: 486079015 Change-Id: I7047e2f0a210de1ae17cbb95884f3a6c6a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7629495 Reviewed-by: Min Qin <[email protected]> Reviewed-by: Lily Chen <[email protected]> Commit-Queue: Andrew Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1594178} --- diff --git a/chrome/browser/download/download_target_determiner.cc b/chrome/browser/download/download_target_determiner.cc index 760025c..1dd2dbab 100644 --- a/chrome/browser/download/download_target_determiner.cc +++ b/chrome/browser/download/download_target_determiner.cc @@ -599,13 +599,19 @@ // file name first. std::wstring sanitized_name = ui::RemoveEnvVarFromFileName<wchar_t>( virtual_path_.BaseName().value(), L"%"); - // remove leading "." to avoid resorting to potential extension - // bug: 41486690 - while (!sanitized_name.empty() && sanitized_name.back() == L'.') { - sanitized_name.pop_back(); + // 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; } - // 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)); diff --git a/chrome/browser/download/download_target_determiner_unittest.cc b/chrome/browser/download/download_target_determiner_unittest.cc index dff43fc..890c8305 100644 --- a/chrome/browser/download/download_target_determiner_unittest.cc +++ b/chrome/browser/download/download_target_determiner_unittest.cc @@ -2712,6 +2712,15 @@ FILE_PATH_LITERAL("download"), DownloadItem::TARGET_DISPOSITION_PROMPT, + EXPECT_CRDOWNLOAD}, + {// 6: Multiple env vars need to be filtered out. + SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS, + DownloadFileType::NOT_DANGEROUS, "http://example.com/foo.lnk .%% .%%", + "application/octet-stream", FILE_PATH_LITERAL(""), + + FILE_PATH_LITERAL("foo.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 dff43fc..890c8305 100644
--- a/chrome/browser/download/download_target_determiner_unittest.cc
+++ b/chrome/browser/download/download_target_determiner_unittest.cc
@@ -2712,6 +2712,15 @@
FILE_PATH_LITERAL("download"), DownloadItem::TARGET_DISPOSITION_PROMPT,
+ EXPECT_CRDOWNLOAD},
+ {// 6: Multiple env vars need to be filtered out.
+ SAVE_AS, download::DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS,
+ DownloadFileType::NOT_DANGEROUS, "http://example.com/foo.lnk .%% .%%",
+ "application/octet-stream", FILE_PATH_LITERAL(""),
+
+ FILE_PATH_LITERAL("foo.download"),
+ DownloadItem::TARGET_DISPOSITION_PROMPT,
+
EXPECT_CRDOWNLOAD}};
RunTestCasesWithActiveItem(kSaveEnvPathTestCases);
Original Bug Report
.lnk File Download Bypass Using "Save As" Link (Bypass of #444803530)
Steps to reproduce the problem
- Run poc.py, and then open http://127.0.0.1:8000/
- Right click
download mehyperlink and then chooseSave File As - Choose
Savebutton and .lnk file will be downloaded
Problem Description
This is a bypass of https://issues.chromium.org/issues/444803530, where an attacker uses a double percent sign (%%) to bypass Chrome’s dangerous file protection. Although it has already been fixed, it can still be bypassed by using a double “double percent sign,” such as: file.lnk .%% .%%
Summary
.lnk File Download Bypass Using “Save As” Link (Bypass of #444803530)
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A \