Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper state validation in Downloads
DescriptionImproper state validation in Downloads
ComponentDownloads
Sub-componentDownload
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.
Tracker517163294
Fix commitab61c8067300 (chromium/src) +54/-0
CWECWE-754
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-27 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-22
Patch gap27 days — fix public in source before the advisory

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::ReceivedSlice ranges recording which byte intervals of the partial file have actually been written, which may be non-contiguous (contain holes).
Hash state
an incremental crypto::SecureHash object (hash_state_) plus the derived destination_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.

Key insight
The single mistake is treating a hash accumulated over 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

  1. 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.
  2. 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, leaving GetReceivedBytes() greater than the first-hole offset.
  3. Resume with stale hash On resumption the pre-patch code sends the accumulated hash_state_ and destination_info_.hash (covering all received bytes) as the hash for the prefix up to the smaller offset, so the final digest is derived from an incorrect byte set.
  4. 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

An attacker who can influence a resumable parallel download can cause Chrome to compute or report an incorrect content hash for a partial file resumed across non-contiguous slices, corrupting the download’s integrity-validation state. This is a logic/state-validation defect in the browser-process Downloads component and requires a resumable, range-supporting download that is interrupted with a hole in the received slices. No exploited-in-the-wild status or severity is stated in the advisory beyond the described improper state validation, and any escalation to file-content spoofing or a stronger primitive is (inferred).

Changed Functions

FunctionChangeNotes
TEST_F
components/download/internal/common/download_item_impl_unittest.cc
modified

Files Changed

  • components/download/internal/common/download_item_impl_unittest.cc
  • components/download/internal/common/download_item_impl.cc

Audit Directions

  • Offset/length invariant coupling
    Audit 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 reassembly
    Review any code that reasons about ReceivedSlice data 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 transitions
    Inspect other fields persisted through interrupt-to-resume transitions in download_item_impl.cc for similar stale-state propagation when byte accounting shrinks.
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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;
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • components/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(), because received_slices_ was cleared and there is only a single stream, IsSparseFile() evaluates to false. Therefore, the secure_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 offset A (due to the presence of slice 1), the file is truncated to A bytes. However, because A > 0, the reset block if (bytes_so_far_ == 0) secure_hash_ = ... is bypassed, keeping the over-long secure_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.

View on issue tracker