CVE-2026-95330
Overview
Background
- Parallel download
- a download split into multiple concurrent byte-range requests whose results are written into distinct regions of a single partial file.
- Received slices
- the set of
DownloadItem::ReceivedSliceranges recording which byte intervals of the partial file have actually been written, which may be non-contiguous (contain holes). - Hash state
- an incremental
crypto::SecureHashobject (hash_state_) plus the deriveddestination_info_.hash, tracking the running digest of bytes committed to the partial file. - Resume offset
- the byte position from which an interrupted download restarts, computed as the start of the first hole in the received slices when slices are non-contiguous.
Root Cause Analysis
When an interrupted download is resumed in download_item_impl.cc, the code computed a resume offset that, for non-contiguous received slices, points at the start of the first hole and is therefore smaller than GetReceivedBytes(). The retained hash_state_ and destination_info_.hash, however, cover all GetReceivedBytes() bytes previously received, including bytes past the hole that were written to disjoint regions of the file. Propagating this stale hash state as the hash of the prefix up to offset violates the invariant that the passed-in hash must correspond exactly to the contiguous byte prefix [0, offset) of the partial file.
The fix detects this mismatch by comparing offset != GetReceivedBytes() and, when they differ, calls hash_state_.reset() and destination_info_.hash.clear(), forcing BaseFile to re-derive the hash from the actual partial file on disk during non-parallel resumption. This restores correctness of the final content-integrity check for downloads that were resumed after a hole-producing interruption.
GetReceivedBytes() total bytes as if it were the hash of the contiguous prefix ending at the (smaller) resume offset; the fix drops the hash state whenever those two values disagree so the digest is recomputed from disk rather than trusted from stale, hole-spanning state.Attack Path
- Trigger a parallel download A user (or attacker-controlled page) initiates a download from a server supporting range requests so Chrome splits it into concurrent slices written to distinct regions of the partial file.
- Force a non-contiguous interruption
The download is interrupted (e.g.
DOWNLOAD_INTERRUPT_REASON_SERVER_CONTENT_LENGTH_MISMATCH) after later slices have been written but an earlier region remains a hole, leavingGetReceivedBytes()greater than the first-holeoffset. - Resume with stale hash
On resumption the pre-patch code sends the accumulated
hash_state_anddestination_info_.hash(covering all received bytes) as the hash for the prefix up to the smalleroffset, so the final digest is derived from an incorrect byte set. - Bypass integrity validation
The mismatched hash causes the download’s completion-time integrity/hash check to reflect data other than the actual reassembled file, undermining state validation of the completed download
(inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
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.cc
Audit Directions
- Offset/length invariant couplingAudit resume and recovery paths where an incremental digest or checksum is retained across an interruption but the restart offset is recomputed independently, ensuring the two always describe the same contiguous byte range.
- Parallel/sliced write reassemblyReview any code that reasons about
ReceivedSlicedata or holes, checking that per-region writes are not conflated with a linear prefix when computing hashes, sizes, or completion state. - State carried across DownloadItem transitionsInspect other fields persisted through interrupt-to-resume transitions in
download_item_impl.ccfor similar stale-state propagation when byte accounting shrinks.
Patch
From ab61c80673002509f9186dadb4b692cdb7aeb8cd Mon Sep 17 00:00:00 2001
From: Shakti Sahu <shaktisahu@chromium.org>
Date: Wed, 26 Aug 2026 14:59:43 -0700
Subject: [PATCH] [download] Drop stale hash state on resume with sliced offset
When an interrupted parallel download is resumed, the resume offset is
set to the start of the first hole in the received slices. If the
received slices were non-contiguous, the previous hash state covers
more bytes than the resume offset and includes data from unwritten
holes.
Clear the hash state and partial-file hash whenever the resume offset
differs from the total received bytes. This ensures BaseFile re-derives
the hash from the partial file on disk upon non-parallel resumption.
Fixed: b:517163294
Change-Id: Id043e7b4b8ce9be03119aedbfeecfc6cb9bb1fdb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8290374
Commit-Queue: Shakti Sahu <shaktisahu@chromium.org>
Reviewed-by: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1686727}
---
diff --git a/components/download/internal/common/download_item_impl.cc b/components/download/internal/common/download_item_impl.cc
index fc85b44e..b7558098 100644
--- a/components/download/internal/common/download_item_impl.cc
+++ b/components/download/internal/common/download_item_impl.cc
@@ -2661,6 +2661,15 @@
offset = GetReceivedBytes();
}
+ // The previous hash state covers GetReceivedBytes() bytes of the partial
+ // file. If the resume offset is smaller (i.e. there were non-contiguous
+ // received slices), it no longer matches the prefix up to |offset| and must
+ // be dropped so that BaseFile re-derives it from the partial file.
+ if (offset != GetReceivedBytes()) {
+ hash_state_.reset();
+ destination_info_.hash.clear();
+ }
+
download_params->set_offset(offset);
download_params->set_last_modified(GetLastModifiedTime());
download_params->set_etag(GetETag());
diff --git a/components/download/internal/common/download_item_impl_unittest.cc b/components/download/internal/common/download_item_impl_unittest.cc
index 265f231b..475f1c91 100644
--- a/components/download/internal/common/download_item_impl_unittest.cc
+++ b/components/download/internal/common/download_item_impl_unittest.cc
@@ -1116,6 +1116,51 @@
CleanupItem(item, download_file, DownloadItem::IN_PROGRESS);
}
+// When an interrupted download with non-contiguous received slices is resumed,
+// the resume offset is the start of the first hole, which is smaller than the
+// total number of received bytes. The hash state from the previous attempt
+// covers the total received bytes and therefore must not be propagated as the
+// hash of the prefix up to the resume offset.
+TEST_F(DownloadItemTest, ResumeWithNonContiguousSlicesClearsHashState) {
+ const DownloadItem::ReceivedSlices kReceivedSlices = {
+ DownloadItem::ReceivedSlice(0, 10), DownloadItem::ReceivedSlice(100, 50)};
+ DownloadItemImpl* item = CreateDownloadItem();
+ MockDownloadFile* download_file =
+ DoIntermediateRename(item, DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS);
+
+ item->DestinationObserverAsWeakPtr()->DestinationUpdate(60, 100,
+ kReceivedSlices);
+ EXPECT_EQ(kReceivedSlices, item->GetReceivedSlices());
+ EXPECT_EQ(60, item->GetReceivedBytes());
+
+ int64_t captured_offset = -1;
+ bool captured_has_hash_state = true;
+ std::string captured_hash_of_partial_file = "unset";
+ EXPECT_CALL(*mock_delegate(), MockResumeInterruptedDownload(_))
+ .WillOnce([&](DownloadUrlParameters* params) {
+ captured_offset = params->offset();
+ DownloadSaveInfo save_info = params->TakeSaveInfo();
+ captured_has_hash_state = (save_info.hash_state != nullptr);
+ captured_hash_of_partial_file = save_info.hash_of_partial_file;
+ });
+ EXPECT_CALL(*download_file, Detach());
+
+ std::unique_ptr<crypto::SecureHash> hash_state =
+ crypto::SecureHash::Create(crypto::SecureHash::SHA256);
+ hash_state->Update(kTestData1);
+ item->DestinationObserverAsWeakPtr()->DestinationError(
+ DOWNLOAD_INTERRUPT_REASON_SERVER_CONTENT_LENGTH_MISMATCH, 60,
+ std::move(hash_state));
+ task_environment_.RunUntilIdle();
+
+ EXPECT_EQ(10, captured_offset);
+ EXPECT_FALSE(captured_has_hash_state);
+ EXPECT_TRUE(captured_hash_of_partial_file.empty());
+ EXPECT_TRUE(item->GetHash().empty());
+
+ CleanupItem(item, nullptr, DownloadItem::IN_PROGRESS);
+}
+
// Test that resumption uses the final URL in a URL chain when resuming.
TEST_F(DownloadItemTest, ResumeUsesFinalURL) {
create_info()->save_info->prompt_for_save_location = false;
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 265f231b..475f1c91 100644
--- a/components/download/internal/common/download_item_impl_unittest.cc
+++ b/components/download/internal/common/download_item_impl_unittest.cc
@@ -1116,6 +1116,51 @@
CleanupItem(item, download_file, DownloadItem::IN_PROGRESS);
}
+// When an interrupted download with non-contiguous received slices is resumed,
+// the resume offset is the start of the first hole, which is smaller than the
+// total number of received bytes. The hash state from the previous attempt
+// covers the total received bytes and therefore must not be propagated as the
+// hash of the prefix up to the resume offset.
+TEST_F(DownloadItemTest, ResumeWithNonContiguousSlicesClearsHashState) {
+ const DownloadItem::ReceivedSlices kReceivedSlices = {
+ DownloadItem::ReceivedSlice(0, 10), DownloadItem::ReceivedSlice(100, 50)};
+ DownloadItemImpl* item = CreateDownloadItem();
+ MockDownloadFile* download_file =
+ DoIntermediateRename(item, DOWNLOAD_DANGER_TYPE_NOT_DANGEROUS);
+
+ item->DestinationObserverAsWeakPtr()->DestinationUpdate(60, 100,
+ kReceivedSlices);
+ EXPECT_EQ(kReceivedSlices, item->GetReceivedSlices());
+ EXPECT_EQ(60, item->GetReceivedBytes());
+
+ int64_t captured_offset = -1;
+ bool captured_has_hash_state = true;
+ std::string captured_hash_of_partial_file = "unset";
+ EXPECT_CALL(*mock_delegate(), MockResumeInterruptedDownload(_))
+ .WillOnce([&](DownloadUrlParameters* params) {
+ captured_offset = params->offset();
+ DownloadSaveInfo save_info = params->TakeSaveInfo();
+ captured_has_hash_state = (save_info.hash_state != nullptr);
+ captured_hash_of_partial_file = save_info.hash_of_partial_file;
+ });
+ EXPECT_CALL(*download_file, Detach());
+
+ std::unique_ptr<crypto::SecureHash> hash_state =
+ crypto::SecureHash::Create(crypto::SecureHash::SHA256);
+ hash_state->Update(kTestData1);
+ item->DestinationObserverAsWeakPtr()->DestinationError(
+ DOWNLOAD_INTERRUPT_REASON_SERVER_CONTENT_LENGTH_MISMATCH, 60,
+ std::move(hash_state));
+ task_environment_.RunUntilIdle();
+
+ EXPECT_EQ(10, captured_offset);
+ EXPECT_FALSE(captured_has_hash_state);
+ EXPECT_TRUE(captured_hash_of_partial_file.empty());
+ EXPECT_TRUE(item->GetHash().empty());
+
+ CleanupItem(item, nullptr, DownloadItem::IN_PROGRESS);
+}
+
// Test that resumption uses the final URL in a URL chain when resuming.
TEST_F(DownloadItemTest, ResumeUsesFinalURL) {
create_info()->save_info->prompt_for_save_location = false;
Original Bug Report
Safe Browsing bypass via stale hash state on parallel-to-serial download resumption
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential vulnerability exists where resuming an interrupted parallel download as a serial download leads to the propagation of a stale SHA256 hash state. Because the file on disk is truncated but the over-long hash state is retained, the completed download’s hash is calculated incorrectly. This mismatch allows a malicious file to bypass Safe Browsing blocklist checks by reporting a benign hash.
Affected files:
components/download/internal/common/download_item_impl.cccomponents/download/internal/common/base_file.cc
Estimated timestamp from git blame: 2019-05-30
Potential Security Vulnerability: Safe Browsing Bypass via Stale Hash State
There is a potential logic and state-tracking issue in Chromium’s download resumption code that can allow an attacker-controlled server to bypass Safe Browsing download blocklist checks.
When an interrupted parallel download with non-contiguous slices is resumed as a single-stream (non-parallelizable) download, Chrome can propagate a stale secure hash state covering a larger range of bytes than the resumption offset. As a result, the on-disk file is truncated to the resumption offset, but the over-long hash state is retained. Subsequent writes are appended, leading to a calculated file hash that does not match the actual file on disk.
Note: These are potential steps and behavior analyzed from the source code; our tooling does not currently have the capability to execute a live proof-of-concept.
Root Cause & Potential Exploit Path
1. Creation of Stale Hash State
During a parallel download session, if the download is interrupted, BaseFile::Finish() (components/download/internal/common/base_file.cc:321) is called to handle the partial state. For a sparse file, CalculatePartialHash() is executed, which sequentially reads and hashes the file from offset 0 up to bytes_so_far_ (the total sum of written bytes across all slices, e.g., A + B).
If slice 0 was written to [0, A) and slice 1 to [X, X + B) (where X > A), the sequential read hashes v1[0, A) followed by B zero bytes from the unwritten hole. The resulting secure_hash_ state covers A + B bytes and represents SHA256(v1[0, A) || zeroes^B). This state is saved on the download item.
2. Parallel-to-Serial Resumption and Mismatched Propagation
Upon resumption, Chrome requests the first hole offset A via download_params->set_offset(A). The stale hash_state_ (covering A + B bytes) is passed to the resumption parameters (components/download/internal/common/download_item_impl.cc:2618).
If the attacker’s server responds with 206 Partial Content but omits the Content-Length header, Chrome disqualifies the download from being parallelized further. The download job is converted to a standard serial job, and received_slices_ on the download item is cleared.
3. Bypassing Defensive Resets
This mismatched hash state survives because the defensive guards are bypassed:
- Sparse-file reset bypassed: In
BaseFile::Initialize(), becausereceived_slices_was cleared and there is only a single stream,IsSparseFile()evaluates tofalse. Therefore, thesecure_hash_.reset()call for sparse files is bypassed. - Re-hash calculation bypassed: Since
secure_hash_is already non-null,CalculatePartialHash()is not re-run. - Truncation reset bypassed: In
BaseFile::Open(), because the file on disk is larger than the resumption offsetA(due to the presence of slice 1), the file is truncated toAbytes. However, becauseA > 0, the reset blockif (bytes_so_far_ == 0) secure_hash_ = ...is bypassed, keeping the over-longsecure_hash_active.
4. Safe Browsing Bypass Impact
The remaining payload v2[A, end) is appended sequentially. At completion, the physical file on disk is v1[0, A) || v2[A, end), while the final computed hash is SHA256(v1[0, A) || zeroes^B || v2[A, end)).
When Chrome queries item->GetHash() to generate the ClientDownloadRequest for Safe Browsing, it sends the mismatched (benign) hash. This allows an attacker to deliver a malicious payload on disk while ensuring Safe Browsing scans only see a clean, pre-calculated benign hash.
Proposed Remediation
To resolve this state mismatch, when a download transitions from a parallel/sparse state to a serial/non-parallelizable state, the partial hash state should be invalidated.
Specifically, in DownloadItemImpl::OnDownloadFileInitialized() (components/download/internal/common/download_item_impl.cc), when received_slices_ is cleared because the job is no longer parallelizable, the associated hash_state_ should also be reset:
// If the download is not parallel, clear the |received_slices_| and the stale hash state.
if (!received_slices_.empty() && !job_->IsParallelizable()) {
destination_info_.received_bytes =
GetMaxContiguousDataBlockSizeFromBeginning(received_slices_);
received_slices_.clear();
hash_state_.reset(); // Prevent propagating the stale sparse hash state
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.