CVE-2026-113195
Overview
Files Changed
content/browser/bad_message.htools/metrics/histograms/metadata/stability/enums.xmlcontent/browser/renderer_host/ipc_utils.cccontent/browser/security_exploit_browsertest.cc
Patch
From 65b540a17cb6ad6879e6aa152baa16e13ec6caaf Mon Sep 17 00:00:00 2001
From: Min Qin <qinmin@chromium.org>
Date: Fri, 25 Sep 2026 12:43:05 -0700
Subject: [PATCH] [Download] Require BlobURLToken for renderer blob: downloads
RenderFrameHostImpl::DownloadURL() only created a blob URL loader
factory when a BlobURLToken was supplied; for a blob: URL with no
token the request fell through to BeginDownloadInternal() which
falls back to an unpartitioned URLLoaderFactoryForUrl(). The
renderer always binds a token for blob: URLs, so a missing token is
not expected from a well-behaved renderer.
Changes:
- Terminate the renderer with RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN
in VerifyDownloadUrlParams() if a blob: URL is provided
without a BlobURLToken.
- Add SecurityExploitBrowserTest.DownloadUrlBlobWithoutToken test.
Bug: 518055043
Change-Id: I5451dd2707c3fbc20287cadce113c42b12e8a2ed
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8410624
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Commit-Queue: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1705067}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 37ed840..578b523 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -430,6 +430,7 @@
BIBI_BIND_PICTURE_IN_PICTURE_SERVICE_FOR_FENCED_FRAME = 402,
BIBI_BIND_PICTURE_IN_PICTURE_SERVICE_BLOCKED_BY_PERMISSIONS_POLICY = 403,
RFH_DETACH_WHILE_BFCACHED = 404,
+ RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN = 405,
// Please add new elements here. The naming convention is abbreviated class
// name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc
index 9c4baf6..64fbcc0 100644
--- a/content/browser/renderer_host/ipc_utils.cc
+++ b/content/browser/renderer_host/ipc_utils.cc
@@ -195,6 +195,15 @@
if (!VerifyBlobToken(process_id, params.blob_url_token, params.url))
return false;
+ // Unlike OpenURL (where a renderer may legitimately navigate to an external
+ // blob: URL without a token, e.g. via drag-and-drop), renderer-initiated
+ // downloads of blob: URLs always bind a BlobURLToken.
+ if (params.url.SchemeIsBlob() && !params.blob_url_token.is_valid()) {
+ bad_message::ReceivedBadMessage(
+ process_id, bad_message::RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN);
+ return false;
+ }
+
// Verify |params.initiator_origin|.
if (params.initiator_origin &&
!VerifyInitiatorOrigin(process_id, *params.initiator_origin))
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index 5219208..733f98e 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -590,6 +590,7 @@
<int value="403"
label="BIBI_BIND_PICTURE_IN_PICTURE_SERVICE_BLOCKED_BY_PERMISSIONS_POLICY"/>
<int value="404" label="RFH_DETACH_WHILE_BFCACHED"/>
+ <int value="405" label="RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN"/>
</enum>
<enum name="BadMessageReasonExtensions">
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 46ccfd3..d7d604e 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -420,6 +420,27 @@
EXPECT_EQ(bad_message::RVH_WEB_UI_BINDINGS_MISMATCH, kill_waiter.Wait());
}
+// A compromised renderer should not be able to ask the browser to download a
+// blob: URL without supplying the corresponding BlobURLToken.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ DownloadUrlBlobWithoutToken) {
+ GURL start_url("http://foo.com/simple_page.html");
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* compromised_renderer = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ compromised_renderer->GetProcess());
+
+ auto params = blink::mojom::DownloadURLParams::New();
+ params->url = GURL("blob:http://foo.com/" +
+ base::UnguessableToken::Create().ToString());
+ compromised_renderer->DownloadURL(std::move(params));
+
+ EXPECT_EQ(bad_message::RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN,
+ kill_waiter.Wait());
+}
+
// This is a test for crbug.com/312016 attempting to create duplicate
// RenderViewHosts. SetupForDuplicateHosts sets up this test case and leaves
// it in a state with pending RenderViewHost. Before the commit of the new
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 46ccfd3..d7d604e 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -420,6 +420,27 @@
EXPECT_EQ(bad_message::RVH_WEB_UI_BINDINGS_MISMATCH, kill_waiter.Wait());
}
+// A compromised renderer should not be able to ask the browser to download a
+// blob: URL without supplying the corresponding BlobURLToken.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ DownloadUrlBlobWithoutToken) {
+ GURL start_url("http://foo.com/simple_page.html");
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* compromised_renderer = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ compromised_renderer->GetProcess());
+
+ auto params = blink::mojom::DownloadURLParams::New();
+ params->url = GURL("blob:http://foo.com/" +
+ base::UnguessableToken::Create().ToString());
+ compromised_renderer->DownloadURL(std::move(params));
+
+ EXPECT_EQ(bad_message::RFH_DOWNLOAD_URL_BLOB_WITHOUT_TOKEN,
+ kill_waiter.Wait());
+}
+
// This is a test for crbug.com/312016 attempting to create duplicate
// RenderViewHosts. SetupForDuplicateHosts sets up this test case and leaves
// it in a state with pending RenderViewHost. Before the commit of the new
Original Bug Report
StorageKey Partitioning Bypass in DownloadManagerImpl::BeginDownloadInternal Fallback
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 compromised renderer can potentially bypass StorageKey partitioning by initiating a download of a blob URL with a null blob URL token. The browser-side validation in VerifyBlobToken accepts the null token, causing DownloadManagerImpl::BeginDownloadInternal to fall back to an unpartitioned loader factory. This allows the compromised renderer to trigger downloads of cross-partition blob contents directly to the local disk.
Affected files:
content/browser/download/download_manager_impl.cccontent/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/blob_storage/chrome_blob_storage_context.ccstorage/browser/blob/blob_url_registry.cc
Estimated timestamp from git blame: 2024-08-21
Potential StorageKey Partitioning Bypass in DownloadManagerImpl Fallback
Root Cause Analysis
When a download of a blob: URL is initiated, ipc_utils.cc:VerifyBlobToken verifies that if a BlobURLToken is present, the associated URL must have a blob: scheme. However, it does not enforce that a blob: URL must be accompanied by a valid token:
// content/browser/renderer_host/ipc_utils.cc
bool VerifyBlobToken(
int process_id,
const mojo::PendingRemote<blink::mojom::BlobURLToken>& received_token,
const GURL& received_url) {
DCHECK_NE(ChildProcessHost::kInvalidUniqueID, process_id);
if (received_token.is_valid()) {
if (!received_url.SchemeIsBlob()) {
bad_message::ReceivedBadMessage(
process_id, bad_message::BLOB_URL_TOKEN_FOR_NON_BLOB_URL);
return false;
}
}
return true; // A blob: URL with a null token returns true successfully.
}
Because VerifyDownloadUrlParams delegates to VerifyBlobToken, the browser successfully accepts a renderer-supplied blink::mojom::DownloadURLParams message where the url is a blob: URL (e.g., blob:https://victim.example/<uuid>) and the blob_url_token is null (since blob_url_token is optional in the Mojom definition).
Subsequently, inside RenderFrameHostImpl::DownloadURL, the token-based loader factory creation is skipped because blob_url_token is null:
// content/browser/renderer_host/render_frame_host_impl.cc
scoped_refptr<network::SharedURLLoaderFactory> blob_url_loader_factory;
if (blink_parameters->blob_url_token) {
blob_url_loader_factory =
ChromeBlobStorageContext::URLLoaderFactoryForToken(
GetStoragePartition(), std::move(blink_parameters->blob_url_token));
}
...
StartDownload(std::move(parameters), std::move(blob_url_loader_factory));
This null factory propagates through StartDownload and DownloadManagerImpl::DownloadUrl into DownloadManagerImpl::BeginDownloadInternal. Because the factory is null but the URL has a blob: scheme, BeginDownloadInternal falls back to creating an unpartitioned factory using ChromeBlobStorageContext::URLLoaderFactoryForUrl:
// content/browser/download/download_manager_impl.cc
if (!blob_url_loader_factory && params->url().SchemeIsBlob()) {
blob_url_loader_factory = ChromeBlobStorageContext::URLLoaderFactoryForUrl(
GetStoragePartitionForConfig(browser_context_, storage_partition_config),
params->url());
}
URLLoaderFactoryForUrl resolves the target blob URL using a direct registry lookup:
// content/browser/blob_storage/chrome_blob_storage_context.cc
storage::BlobURLLoaderFactory::Create(
static_cast<StoragePartitionImpl*>(storage_partition)
->GetBlobUrlRegistry()
->GetBlobFromUrl(url), // Raw lookup
url, blob_url_loader_factory_remote.InitWithNewPipeAndPassReceiver());
BlobUrlRegistry::GetBlobFromUrl returns the blob solely based on a matching URL without performing a StorageKey partitioning check (unlike the token-based path BlobURLStoreImpl::ResolveAsURLLoaderFactory, which enforces IsUrlMapped and kBlockCrossPartitionBlobUrlFetching). This potentially allows a compromised renderer to bypass the storage partition isolation constraints.
Potential Attack Scenario
Note: These are suggested/potential steps based on source-code analysis; our tooling agent does not have the ability to run code or compile a working proof-of-concept.
- A victim site (
https://victim.example) creates a sensitive blob containing private data. - The compromised renderer is hosted on a different top-level origin (or uses an unguessable UUID for a cross-origin target) but shares the same default
StoragePartition. - The compromised renderer calls
LocalFrameHost::DownloadURLwith the targetblob:https://victim.example/<uuid>and a nullblob_url_token. - The browser process validates the input, fails to verify partitioning because the token is missing, constructs an unpartitioned loader factory, and writes the contents of the target blob directly into the user’s Downloads directory.
Impact Limitation: Because the blob bytes are streamed directly to a file on local disk rather than being returned to the compromised renderer’s memory space, the attacker cannot read this cross-partition data directly. The attacker would have to rely on additional steps (such as social engineering or tricking the user into uploading the downloaded file via a file picker) to exfiltrate the file’s contents.
Suggested Fix
Ensure that all renderer-initiated downloads of blob: URLs are accompanied by a valid token. A similar safety pattern is already implemented for Dedicated Workers in content/browser/worker_host/dedicated_worker_host.cc, where a bad message report is generated if a blob: URL is provided without a valid token. Applying a similar check in VerifyDownloadUrlParams will ensure that all blob: download requests authenticate access via a valid token, enforcing StorageKey partitioning.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.