High watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Extensions
DescriptionInappropriate implementation in Extensions
ComponentExtensions
Sub-componentUI>Browser>Downloads
Bug ClassLogic Error
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.
Tracker436887350
Fix commitf9f478196e28 (chromium/src) +3/-1
CWECWE-288
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedAlesandro Ortiz
Reported2025-08-06 · bounty $4000 (per vendor advisory)
Fix landed2025-09-02 (public git)
Disclosed2025-10-28
Patch gap56 days — fix public in source before the advisory

Background

`chrome.downloads` API
the extension API implemented in chrome/browser/extensions/api/downloads/downloads_api.cc that lets an extension with the downloads permission start, pause, resume, cancel and query downloads.
`DownloadItem` state
a download is IN_PROGRESS (possibly paused via IsPaused()), INTERRUPTED, CANCELLED or COMPLETE, as reported by GetState().
`DownloadItem::CanResume()`
the download system’s own judgment of whether an item may be resumed; an interrupted download can be non-resumable depending on why it was interrupted.
`Fault(...)` / `kNotResumable`
the API helper pattern that, when its condition is true, sets an error string and makes the function return an error instead of performing the action.

Root Cause Analysis

The patched code is the extension-facing resume handler (the enclosing function name is not shown in the hunk; the use of params->download_id and the download_extension_errors::kNotResumable error indicate the downloads.resume implementation). Before the patch, the only resumability guard was Fault(download_item->IsPaused() && !download_item->CanResume(), ...), which consults CanResume() only for paused downloads; for a download that is INTERRUPTED or CANCELLED, IsPaused() is false, so the guard never fired and the handler went on to resume the item — as the commit message puts it, the check effectively always passed and skipped CanResume(). This violated the invariant that the extension API only resumes downloads that the download system itself considers resumable.

The fix tightens the check: it now rejects any CANCELLED download and any INTERRUPTED download for which CanResume() is false, while still allowing normal resume of paused in-progress downloads. Because the guard returns RespondNow(Error(...)) before the resume action, a non-resumable item can no longer be pushed back into progress via the extension API.

Key insight
The resume guard keyed on the wrong state (IsPaused()), so CanResume() was never consulted for the interrupted and cancelled downloads it exists to protect. The fix bases the check on GetState(), refusing cancelled downloads and interrupted downloads that CanResume() rejects.

Attack Path

  1. Extension with downloads access An installed extension holding the downloads permission identifies a download that is cancelled or interrupted in a non-resumable state.
  2. Call `downloads.resume` The extension invokes the resume API with that download_id.
  3. Guard bypassed Pre-patch, because the item is not paused, IsPaused() && !CanResume() evaluates false, no kNotResumable error is raised, and the handler proceeds to resume the item.
  4. Resume of a download the system refused to resume The download machinery is driven to resume an item that was cancelled or whose interruption reason made it non-resumable; if that interruption reflected a security block (for example a download blocked by a safety check), this could bypass that protection (inferred; the diff does not show which interruption reasons are non-resumable or what the resume path does next).

Impact Assessment

The flaw is a browser-process logic error in the extensions API: it lets an extension resume downloads that the download system had marked non-resumable or that had been cancelled, which could undermine download-level protections or user cancellation decisions (inferred). It requires an installed (malicious or compromised) extension with the downloads permission; ordinary web content cannot call this API, and no compromised renderer is needed. The vendor rates it High severity; the provided metadata does not state in-the-wild exploitation.

Files Changed

  • chrome/browser/extensions/api/downloads/downloads_api.cc

Audit Directions

  • State predicates that don't match the guarded state
    In other chrome.downloads handlers (pause, cancel, open, show, accept danger), check that preconditions are expressed against GetState()/danger type rather than a proxy like IsPaused() that is false for the states that matter.
  • Extension APIs bypassing backend policy
    Look for extension functions that invoke DownloadItem (or similar backend) actions directly without consulting the backend’s own permission predicate (CanResume(), CanOpenDownload(), danger/validation state), since extensions can reach states the normal UI never offers.
  • Cancelled/terminal objects revived via API
    Audit APIs that act on an ID lookup for objects in terminal states (cancelled, blocked, removed) to ensure terminal states are explicitly rejected rather than implicitly allowed.
From f9f478196e288d68bf0b64a30fa7618a27fe1fc2 Mon Sep 17 00:00:00 2001
From: Min Qin <[email protected]>
Date: Tue, 02 Sep 2025 16:44:44 -0700
Subject: [PATCH] Fix an issue when determining whether a download is resumable in extension

To resume a download, the download should be in an interrupted state,
rather than paused state. The current code will just always return
true and skip the canResume() check.

Bug: 436887350
Change-Id: Ie5cd0e7684a1eeacc4d80899098e89137dc1a85f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6907509
Reviewed-by: David Trainor <[email protected]>
Commit-Queue: Min Qin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1509939}
---

diff --git a/chrome/browser/extensions/api/downloads/downloads_api.cc b/chrome/browser/extensions/api/downloads/downloads_api.cc
index f89b9608..4dfb639 100644
--- a/chrome/browser/extensions/api/downloads/downloads_api.cc
+++ b/chrome/browser/extensions/api/downloads/downloads_api.cc
@@ -1288,7 +1288,9 @@
       browser_context(), include_incognito_information(), params->download_id);
   std::string error;
   if (InvalidId(download_item, &error) ||
-      Fault(download_item->IsPaused() && !download_item->CanResume(),
+      Fault(download_item->GetState() == DownloadItem::CANCELLED ||
+                (download_item->GetState() == DownloadItem::INTERRUPTED &&
+                 !download_item->CanResume()),
             download_extension_errors::kNotResumable, &error)) {
     return RespondNow(Error(std::move(error)));
   }
Loading diff…

Original Bug Report

reported by [email protected]

Security: Extension can download file by resuming interrupted download

SUMMARY

An extension with downloads permission can download a file:// URL by calling downloads.download(), waiting for the download to be interrupted, and then calling downloads.resume() on the interrupted download. This causes the download to be initiated by the browser, bypassing DownloadRequestUtils::IsURLSafe()/CPSP::CanRequestURL() checks.

Downloading local files is meant to be blocked when the extension does not have file:// permissions. The tabs.create() workaround to download some local files was also blocked in https://crrev.com/c/4772028 (August 2023, also see issue 40063229).

The resume bypass also works with all other ways to resume downloads, including resumes made through UI.

When chained with other bugs, such as issue 433800617 or issue 435684924, or if the attacker otherwise convinces user to upload the downloaded file, the attacker can steal the downloaded file’s contents.

VULNERABILITY DETAILS

In DownloadManagerImpl::BeginDownloadInternal()…

void DownloadManagerImpl::BeginDownloadInternal(
    std::unique_ptr<download::DownloadUrlParameters> params,
    scoped_refptr<network::SharedURLLoaderFactory> blob_url_loader_factory,
    bool is_new_download,
    const std::string& serialized_embedder_download_data) {
  // Check if the renderer is permitted to request the requested URL.
  if (params->render_process_host_id() >= 0 &&
      !DownloadRequestUtils::IsURLSafe(params->render_process_host_id(),
                                       params->url())) {
    CreateInterruptedDownload(
        std::move(params),
        download::DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
        weak_factory_.GetWeakPtr());
    return;
  }
  // ...
}

…CPSP::CanRequestURL() is called through DownloadRequestUtils::IsURLSafe() only if render_process_host_id() >= 0.

When downloads.download() is called with a file:// URL, render_process_host_id is greater than zero, and CPSP::CanRequestURL() returns false for file:// URLs, therefore the download is interrupted.

However, when downloads.resume() is called for the interrupted download, render_process_host_id is -1, which per this comment means it’s not associated with a frame. This means the IsURLSafe()/CPSP::CanRequestURL() check is bypassed, and the file is downloaded.

Additional context

In the DownloadUrlParameters() constructor that leaves render_process_host_id at -1, there is this comment that points out the security checks bypass:

  // Constructs a download not associated with a frame.
  //
  // It is not safe to have downloads not associated with a frame and
  // this should only be done in a limited set of cases where the download URL
  // has been previously vetted. A download that's initiated without
  // associating it with a frame don't receive the same security checks
  // as a request that's associated with one. Hence, downloads that are not
  // associated with a frame should only be made for URLs that are either
  // trusted or URLs that have previously been successfully issued using a
  // non-privileged frame.
  DownloadUrlParameters(
      const GURL& url,
      const net::NetworkTrafficAnnotationTag& traffic_annotation);

Unfortunately, this constructor is used in the downloads.resume() code path and most other download resume code paths: DownloadsResumeFunction::Run() calls DownloadItem::Resume() which calls DownloadItemImpl::ResumeInterruptedDownload().

ResumeInterruptedDownload() has this comment explaining why the unsafe constructor is used for resumes:

  // Avoid using the WebContents even if it's still around. Resumption requests
  // are consistently routed through the no-renderer code paths so that the
  // request will not be dropped if the WebContents (and by extension, the
  // associated renderer) goes away before a response is received.
  std::unique_ptr<DownloadUrlParameters> download_params(
      new DownloadUrlParameters(GetURL(), traffic_annotation));

BISECT

Likely commit 00b621f5126d538df488090c19175ee892a7161b (February 2016) which starts using the unsafe constructor in resume code path.

I’ll verify bisect later today.

VERSION

Chrome version: 139.0.7258.67 Stable, 141.0.7340.0 Canary

Operating System: Windows 10

REPRODUCTION CASE

To download a file in a user directory, the PoC first makes a throwaway download to get the username from the download path. Then we make the second download exploiting the vulnerability.

The core vulnerability itself doesn’t need user interaction to download the file, nor does it need to show an extension page or otherwise open a new tab.

I’ll upload chained scenarios in comments.

Setup:

  1. Install attached extension: manifest.json, background.js, page.html, page.js.
  2. Disable file:// URL access in the extension details page (this is enabled by default for unpacked extensions).

Scenario 1: Chrome history file

  1. Click extension icon to trigger download. (Note: Initiating/resuming downloads doesn’t require user interaction, so this step is only for PoC.)
  2. Click the page, then select the downloaded file. (Note: When chained, we don’t need user to explicitly upload file.)

Observed: Extension can download file:// URLs without file permission. When chained with other vulns, or through social engineering, user can read downloaded local file.

Expected: Extension cannot download file:// URLs without file permission.

Debug scenario

To see the attack occurring more slowly, you can modify background.js and set debug to true, which delays the resume() call by two seconds. Reminder: Disable file:// access in extension for proper repro.

Credit Information

Reporter credit: Alesandro Ortiz https://AlesandroOrtiz.com

View on issue tracker
Uploaded PoC files
Links in the report