CVE-2026-87503
Overview
Background
- `DownloadItemImplDelegate::ShouldCompleteDownload()`
- the gate
DownloadItemImplconsults before renaming an in-progress download from its intermediate path to its final path; the base implementation is used when no richer embedder delegate is attached. - Content-check-pending danger types
- the enum values
DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT,DOWNLOAD_DANGER_TYPE_ASYNC_SCANNING, andDOWNLOAD_DANGER_TYPE_ASYNC_LOCAL_PASSWORD_SCANNING, which mark a download whose asynchronous verdict has not yet resolved. - `InProgressDownloadManager`
- the minimal (non-full-browser) download owner, on Android especially, that inherits the base
DownloadItemImplDelegatestub rather than the fullDownloadManagerImploverride. - `IsDangerous()`
- the predicate that returns
falseforDOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT, so a pending-verdict download is not treated as “dangerous” and is not held back on that basis alone.
Root Cause Analysis
The base DownloadItemImplDelegate::ShouldCompleteDownload() returned true unconditionally, telling DownloadItemImpl that a download was ready to be finalized regardless of its danger type. When InProgressDownloadManager (which inherits this stub) owned a resumed download in minimal-browser mode with danger type DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT, the unconditional true let the item proceed to RenameAndAnnotate() — moving the file to its final path — before any content check had run, as stated in the commit message. Because that danger type returns false from IsDangerous(), no other guard held the download back, so the “do not finalize until the content verdict resolves” invariant was silently violated.
The fix replaces the unconditional return true with a switch on download->GetDangerType() that returns false for the three content-check-pending danger types and true otherwise, keeping such downloads in IN_PROGRESS until a delegate that can actually run the check takes over. Because DownloadManagerImpl already overrides ShouldCompleteDownload(), full-browser behavior is unchanged and only the minimal-browser path is corrected.
return true) even for downloads whose safety verdict was still pending; the fix teaches the gate to defer (return false) precisely those content-check-pending danger types so the file is never renamed to its final path ahead of its check.Attack Path
- Reach the minimal-browser download owner
An attacker gets a victim on Android to download content handled by
InProgressDownloadManager(minimal-browser mode), which inherits the baseDownloadItemImplDelegaterather thanDownloadManagerImpl. - Land in a pending-verdict state
The download is assigned danger type
DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT(content check not yet resolved), a state for whichIsDangerous()returnsfalse, so no danger-based guard holds it back. - Resume to trigger completion
The download is resumed and its data completes, driving
DownloadItemImplto consultShouldCompleteDownload(), which pre-patch returnedtrueunconditionally. - File finalized before the check
With completion approved, the item reaches
RenameAndAnnotate()and the file is renamed to its final path before the content check runs, so a file that a pending scan might have flagged is committed to its destination anyway (the diff only adds a deferral check; it does not itself demonstrate any further exploitation). - Downstream execution of unscanned content (inferred) — A file placed at its final path without its content verdict could then be opened/executed by the user or another component as if vetted; the patch does not demonstrate this.
Impact Assessment
InProgressDownloadManager) contexts where the base DownloadItemImplDelegate is used; the precondition is a resumed download carrying a content-check-pending danger type such as DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT. This is a logic error (inappropriate implementation) that weakens the download safety-check ordering rather than a memory-corruption primitive. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. The advisory reports no exploited-in-the-wild status or severity beyond the “Inappropriate implementation in Downloads” classification.Changed Functions
| Function | Change | Notes |
|---|---|---|
ASSERT_TRUEcomponents/download/internal/common/download_item_impl_unittest.cc |
modified | |
TEST_Fcomponents/download/internal/common/download_item_impl_unittest.cc |
modified |
Files Changed
components/download/internal/common/download_item_impl_unittest.cccomponents/download/internal/common/download_item_impl_delegate.cc
Audit Directions
- Unconditional base/default delegate stubsLook for base-class or default-delegate methods that
return true/approve an action unconditionally where a subclass (e.g.DownloadManagerImpl) implements the real safety logic, since minimal-mode owners inheriting the stub silently bypass the check. - Danger-type gates that trust `IsDangerous()`Audit completion, rename, and open paths that rely on
IsDangerous()alone, since pending-verdict types likeDOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENTreturnfalseand can slip past danger-only guards while their async verdict is still outstanding. - Async-verdict ordering on resume/minimal-browser pathsTrace resumed-download and
InProgressDownloadManagerflows to confirm no state transition (especiallyRenameAndAnnotate()/ finalization) can run before every pending async scan (ASYNC_SCANNING,ASYNC_LOCAL_PASSWORD_SCANNING) has resolved.
Patch
From 79649f9623c5d46ec339b1c1bc34bc4d490d56c1 Mon Sep 17 00:00:00 2001
From: Yaw Frempong <yawfrempong@google.com>
Date: Thu, 30 Jul 2026 14:02:44 -0700
Subject: [PATCH] [Downloads] Defer completion while content check is pending
DownloadItemImplDelegate::ShouldCompleteDownload() is the gate
DownloadItemImpl consults before renaming a download to its final path.
The base implementation returned true unconditionally.
InProgressDownloadManager inherits this stub, so on Android when a
download with danger type MAYBE_DANGEROUS_CONTENT is resumed in
minimal-browser mode it could reach RenameAndAnnotate() before its
content check ran (that danger type returns false for IsDangerous()).
Make the base implementation return false for the content-check-pending
danger types so the download stays IN_PROGRESS until a delegate that can
run the check takes over. DownloadManagerImpl already overrides this
method, so full-browser behaviour is unchanged.
Reviewed in https://crrev.com/i/9585676
Bug: 497986036
Change-Id: I391289156e8aa68eeaeadbfc6cd4f886aba906d3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8164176
Reviewed-by: Min Qin <qinmin@chromium.org>
Commit-Queue: Yaw Frempong <yawfrempong@google.com>
Cr-Commit-Position: refs/heads/main@{#1671401}
---
diff --git a/components/download/internal/common/download_item_impl_delegate.cc b/components/download/internal/common/download_item_impl_delegate.cc
index 53c3082..797254c 100644
--- a/components/download/internal/common/download_item_impl_delegate.cc
+++ b/components/download/internal/common/download_item_impl_delegate.cc
@@ -48,7 +48,17 @@
bool DownloadItemImplDelegate::ShouldCompleteDownload(
DownloadItemImpl* download,
base::OnceClosure complete_callback) {
- return true;
+ // The default delegate has no way to run a content check, so defer
+ // completion of downloads whose danger type still indicates a pending
+ // verdict until a delegate that can resolve it takes over.
+ switch (download->GetDangerType()) {
+ case DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT:
+ case DOWNLOAD_DANGER_TYPE_ASYNC_SCANNING:
+ case DOWNLOAD_DANGER_TYPE_ASYNC_LOCAL_PASSWORD_SCANNING:
+ return false;
+ default:
+ return true;
+ }
}
bool DownloadItemImplDelegate::ShouldOpenDownload(
diff --git a/components/download/internal/common/download_item_impl_unittest.cc b/components/download/internal/common/download_item_impl_unittest.cc
index e368d7e..bd52c17 100644
--- a/components/download/internal/common/download_item_impl_unittest.cc
+++ b/components/download/internal/common/download_item_impl_unittest.cc
@@ -27,6 +27,7 @@
#include "base/task/single_thread_task_runner.h"
#include "base/test/gmock_move_support.h"
#include "base/test/metrics/histogram_tester.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "base/threading/thread.h"
@@ -2015,6 +2016,37 @@
EXPECT_EQ(DownloadItem::COMPLETE, item->GetState());
}
+// The default DownloadItemImplDelegate (used when no embedder-level delegate
+// is attached, e.g. by InProgressDownloadManager) must defer completion of a
+// download whose content check is still pending so the file is not renamed to
+// its final path before the check resolves.
+TEST_F(DownloadItemTest,
+ DefaultDelegateDefersCompletionForPendingContentCheck) {
+ DownloadItemImpl* item = CreateDownloadItem();
+ MockDownloadFile* download_file =
+ DoIntermediateRename(item, DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT);
+ EXPECT_FALSE(item->IsDangerous());
+ EXPECT_EQ(DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT,
+ item->GetDangerType());
+
+ EXPECT_CALL(*mock_delegate(), ShouldCompleteDownload_(item, _))
+ .WillRepeatedly([&](DownloadItemImpl* download, base::OnceClosure& cb) {
+ return mock_delegate()
+ ->DownloadItemImplDelegate::ShouldCompleteDownload(download,
+ std::move(cb));
+ });
+ EXPECT_CALL(*download_file, RenameAndAnnotate(_, _, _, _, _, _, _)).Times(0);
+ item->DestinationObserverAsWeakPtr()->DestinationCompleted(
+ 0, std::unique_ptr<crypto::SecureHash>());
+ ASSERT_TRUE(base::test::RunUntil([&]() { return item->AllDataSaved(); }));
+
+ EXPECT_TRUE(item->AllDataSaved());
+ EXPECT_EQ(DownloadItem::IN_PROGRESS, item->GetState());
+ EXPECT_EQ(DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT,
+ item->GetDangerType());
+ CleanupItem(item, download_file, DownloadItem::IN_PROGRESS);
+}
+
TEST_F(DownloadItemTest, CopyDownload) {
DownloadItemImpl* item = CreateDownloadItem();
MockDownloadFile* download_file =
Regression Test / PoC
diff --git a/components/download/internal/common/download_item_impl_unittest.cc b/components/download/internal/common/download_item_impl_unittest.cc
index e368d7e..bd52c17 100644
--- a/components/download/internal/common/download_item_impl_unittest.cc
+++ b/components/download/internal/common/download_item_impl_unittest.cc
@@ -27,6 +27,7 @@
#include "base/task/single_thread_task_runner.h"
#include "base/test/gmock_move_support.h"
#include "base/test/metrics/histogram_tester.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "base/threading/thread.h"
@@ -2015,6 +2016,37 @@
EXPECT_EQ(DownloadItem::COMPLETE, item->GetState());
}
+// The default DownloadItemImplDelegate (used when no embedder-level delegate
+// is attached, e.g. by InProgressDownloadManager) must defer completion of a
+// download whose content check is still pending so the file is not renamed to
+// its final path before the check resolves.
+TEST_F(DownloadItemTest,
+ DefaultDelegateDefersCompletionForPendingContentCheck) {
+ DownloadItemImpl* item = CreateDownloadItem();
+ MockDownloadFile* download_file =
+ DoIntermediateRename(item, DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT);
+ EXPECT_FALSE(item->IsDangerous());
+ EXPECT_EQ(DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT,
+ item->GetDangerType());
+
+ EXPECT_CALL(*mock_delegate(), ShouldCompleteDownload_(item, _))
+ .WillRepeatedly([&](DownloadItemImpl* download, base::OnceClosure& cb) {
+ return mock_delegate()
+ ->DownloadItemImplDelegate::ShouldCompleteDownload(download,
+ std::move(cb));
+ });
+ EXPECT_CALL(*download_file, RenameAndAnnotate(_, _, _, _, _, _, _)).Times(0);
+ item->DestinationObserverAsWeakPtr()->DestinationCompleted(
+ 0, std::unique_ptr<crypto::SecureHash>());
+ ASSERT_TRUE(base::test::RunUntil([&]() { return item->AllDataSaved(); }));
+
+ EXPECT_TRUE(item->AllDataSaved());
+ EXPECT_EQ(DownloadItem::IN_PROGRESS, item->GetState());
+ EXPECT_EQ(DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT,
+ item->GetDangerType());
+ CleanupItem(item, download_file, DownloadItem::IN_PROGRESS);
+}
+
TEST_F(DownloadItemTest, CopyDownload) {
DownloadItemImpl* item = CreateDownloadItem();
MockDownloadFile* download_file =
Original Bug Report
Potential Safe Browsing bypass via Android minimal-mode background resumption
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential security vulnerability exists in Chrome for Android where Safe Browsing content scans can be bypassed by exploiting background download resumption in minimal browser mode. When an interrupted download is resumed and completed by the background task, the InProgressDownloadManager fails to perform the mandatory ShouldCompleteDownload check, allowing a potentially dangerous file to be finalized without a scan.
Affected files:
components/download/internal/common/in_progress_download_manager.cccomponents/download/internal/common/download_item_impl_delegate.cccomponents/download/internal/common/download_item_impl.ccchrome/android/java/src/org/chromium/chrome/browser/download/service/DownloadBackgroundTask.javachrome/browser/download/android/download_manager_service.cc
Estimated timestamp from git blame: 2023-11-09
Summary
A potential vulnerability exists in Chrome for Android where Safe Browsing protection can be bypassed for downloads that are resumed via the DownloadBackgroundTask in minimal browser mode. In this mode, the InProgressDownloadManager acts as the DownloadItemImplDelegate but fails to implement the ShouldCompleteDownload check that triggers Safe Browsing content scans. Consequently, malicious files can be downloaded and saved to the user’s device without undergoing a content scan, even if they were originally flagged for scanning.
Technical Details
When a download is initiated in the full browser, ChromeDownloadManagerDelegate determines if a Safe Browsing scan is needed. If so, it sets the danger_type to DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT. In the full browser, ChromeDownloadManagerDelegate::ShouldCompleteDownload blocks completion until the Safe Browsing verdict is received.
On Android, Chrome can be terminated (e.g., due to memory pressure), and interrupted downloads are resumed by the DownloadBackgroundTask. This task often runs Chrome in “minimal browser mode” (supportsMinimalBrowser() returns true). In this mode, the full content::DownloadManager (and the ChromeDownloadManagerDelegate which normally enforces Safe Browsing checks) is not initialized because RunBrowserProcessMain is skipped.
Instead, InProgressDownloadManager is used as the delegate for restored downloads. InProgressDownloadManager does not override ShouldCompleteDownload. It inherits the base-class implementation in DownloadItemImplDelegate, which returns true unconditionally. Furthermore, DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT returns false for IsDangerous(), making these downloads eligible for auto-resumption and allowing them to bypass the IsDownloadReadyForCompletion() danger check.
As a result, when the background task resumes and completes the download, MaybeCompleteDownload() calls ShouldCompleteDownload(), receives true immediately, and the file is moved to its final path without the Safe Browsing content check ever being performed.
Potential Reproduction Steps
(Note: These are potential steps based on code analysis; our tooling agent doesn’t yet have the ability to run code to verify them.)
- Use an Android device and initiate a download for a file type that triggers a Safe Browsing content scan (e.g., an APK or executable) from an attacker-controlled server.
- Accept any initial download prompt. Chrome sets the
danger_typetoMAYBE_DANGEROUS_CONTENTand persists the state to the in-progress database. - The attacker server intentionally drops the connection midway through the download.
- Ensure the Chrome process is terminated (e.g., by opening other apps to trigger an OOM kill or using developer tools to stop the process).
- Wait for the Android
JobSchedulerto fire theDownloadBackgroundTask. This task will start Chrome in minimal mode. - The background task identifies the resumable download and calls
Resume(). The attacker server serves the full malicious payload upon resumption. - Once all data is saved,
MaybeCompleteDownloadis called. Because the delegate isInProgressDownloadManager(which lacks aShouldCompleteDownloadoverride), it returnstrueimmediately without checking with Safe Browsing. - The file is renamed to its final path in the Downloads folder, bypassing the content scan.
Suggested Fix
Implement ShouldCompleteDownload in InProgressDownloadManager. If a download has a danger_type indicating it needs a Safe Browsing scan (such as DOWNLOAD_DANGER_TYPE_MAYBE_DANGEROUS_CONTENT), the manager should either:
- Delay completion and trigger the initialization of the full browser process so that
ChromeDownloadManagerDelegatecan perform the scan, OR - Block the completion and mark the download as interrupted or failed if the scan cannot be performed in minimal mode.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.