CVE-2026-76029
Overview
Background
- HTTP 206 / range requests
- a server may answer a
Range:request with a partial body described by aContent-Rangeheader; the cache stores each range in a sparse entry. - `PartialData`
- tracks the current byte range (
current_range_start_..current_range_end_, inclusive) and writes received bytes into the sparse cache entry viaCacheWrite. - `HttpCache::Writers` / `Transaction`
- drive network reads that feed the cache writer.
Root Cause Analysis
When handling a 206 partial response, the cache wrote each network read into the sparse entry at current_range_start_ without verifying that the number of bytes returned actually fit inside the declared range. A malicious or buggy server could return more body bytes than its own Content-Range specified, so the read overran the range being written (the test appends "extra stuff" to a 0-9 range response).
The fix adds PartialData::IsNetworkReadOK(data_len), which uses base::CheckedNumeric to compute current_range_start_ + (data_len-1) and rejects the read when it overflows or exceeds current_range_end_; both DoPartialNetworkReadCompleted and Writers::DoNetworkReadComplete now convert an overlong read into ERR_CONTENT_LENGTH_MISMATCH before it is written. The clarified current_range_end_ “Inclusive” comment and the GetPartial() refactor support the check. What the diff proves is a missing length check: an overlong, server-controlled data_len reaches CacheWrite→WriteSparseData(current_range_start_, data, data_len, …) without verifying it fits the range. The out-of-bounds write itself is the inferred sink — the sparse-cache backend that consumes data_len is not in this patch, and the test’s 256-byte read buffer is not itself overflowed. Classified OOB (refined from the advisory’s generic “memory corruption”) on the strength of the dedicated bounds+overflow check, with the heap sink unproven in-diff.
Content-Range, letting a server overrun the range write; a CheckedNumeric bounds check (IsNetworkReadOK) now rejects overlong reads.Attack Path
- ENTRY The victim fetches a cacheable resource for which the browser issues an HTTP range (206) request (an attacker-controlled or MITM’d origin).
- CONTROLLED INPUT
The server returns a 206 whose body is longer than the byte count implied by its
Content-Range(overlong range). - MISSING CHECK
The cache writer wrote the full network read at
current_range_start_without confirming it fit withincurrent_range_end_. - PRIMITIVE
The surplus, server-controlled length reaches the sparse-cache write past the intended range end; an out-of-bounds write is the inferred consequence. (Not shown in-diff: the diff proves the length gap and that the overrun reaches
WriteSparseData, not the heap OOB itself.) - IMPACT Memory corruption in the network service, reachable by any origin the victim loads.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/http/http_cache_writers.cc |
modified | |
ifnet/http/partial_data.cc |
modified | |
TEST_Fnet/http/http_cache_unittest.cc |
modified | |
fornet/http/http_cache_unittest.cc |
modified | |
ifnet/http/http_cache_unittest.cc |
modified |
Files Changed
net/http/partial_data.hnet/http/partial_data.ccnet/http/http_cache_writers.ccnet/http/http_cache_unittest.ccnet/http/http_cache_writers.hnet/http/http_cache_transaction.cc
Audit Directions
- Length trust on partial responsesAudit all 206/sparse-cache write paths for validation that the received length matches the declared
Content-Rangebefore writing. - CheckedNumeric at range mathReview other
current_range_*arithmetic for unchecked additions that could overflow or exceed the range.
Patch
From baeba815b067b34e56da00977450e5da55a99268 Mon Sep 17 00:00:00 2001
From: Maks Orlovich <morlovich@chromium.org>
Date: Thu, 22 Oct 2026 18:05:58 -0700
Subject: [PATCH] HttpCache::Transaction reject overlong range responses
...that is, those that don't match the length of their own
Content-Range.
Bug: 572055997
Change-Id: I7a0ca8652462fa890cf04944c21e0674d3d21815
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8608994
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Commit-Queue: Maks Orlovich <morlovich@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1725388}
---
diff --git a/net/http/http_cache_transaction.cc b/net/http/http_cache_transaction.cc
index 63696e4..2a1028c 100644
--- a/net/http/http_cache_transaction.cc
+++ b/net/http/http_cache_transaction.cc
@@ -2574,6 +2574,10 @@
int HttpCache::Transaction::DoPartialNetworkReadCompleted(int result) {
DCHECK(partial_);
+ if (result > 0 && handling_206_ && !partial_->IsNetworkReadOK(result)) {
+ result = ERR_CONTENT_LENGTH_MISMATCH;
+ }
+
// Go to the next range if nothing returned or return the result.
// TODO(shivanisha) Simplify this condition if possible. It was introduced
// in https://codereview.chromium.org/545101
diff --git a/net/http/http_cache_writers.cc b/net/http/http_cache_writers.cc
index 5a8ae428..804b2ba2 100644
--- a/net/http/http_cache_writers.cc
+++ b/net/http/http_cache_writers.cc
@@ -398,6 +398,18 @@
HttpCache::Writers::WaitingForRead::~WaitingForRead() = default;
HttpCache::Writers::WaitingForRead::WaitingForRead(WaitingForRead&&) = default;
+PartialData* HttpCache::Writers::GetPartial() const {
+ // The active transaction must be alive if this is a partial request, as
+ // partial requests are exclusive and hence will always be the active
+ // transaction.
+ // TODO(shivanisha): When partial requests support parallel writing, this
+ // assumption will not be true.
+ if (active_transaction_) {
+ return all_writers_.find(active_transaction_)->second.partial;
+ }
+ return nullptr;
+}
+
int HttpCache::Writers::DoLoop(int result) {
DCHECK_NE(State::UNSET, next_state_);
DCHECK_NE(State::NONE, next_state_);
@@ -480,6 +492,11 @@
}
int HttpCache::Writers::DoNetworkReadComplete(int result) {
+ PartialData* partial = GetPartial();
+ if (result > 0 && partial && !partial->IsNetworkReadOK(result)) {
+ result = ERR_CONTENT_LENGTH_MISMATCH;
+ }
+
if (result < 0) {
next_state_ = State::NONE;
OnNetworkReadFailure(result);
@@ -521,15 +538,7 @@
int rv = 0;
- PartialData* partial = nullptr;
- // The active transaction must be alive if this is a partial request, as
- // partial requests are exclusive and hence will always be the active
- // transaction.
- // TODO(shivanisha): When partial requests support parallel writing, this
- // assumption will not be true.
- if (active_transaction_) {
- partial = all_writers_.find(active_transaction_)->second.partial;
- }
+ PartialData* partial = GetPartial();
if (!partial) {
last_disk_cache_access_start_time_ = base::TimeTicks::Now();
diff --git a/net/http/http_cache_writers.h b/net/http/http_cache_writers.h
index f4bfa4cd..7847baf 100644
--- a/net/http/http_cache_writers.h
+++ b/net/http/http_cache_writers.h
@@ -200,6 +200,8 @@
using TransactionMap = std::map<Transaction*, TransactionInfo>;
+ PartialData* GetPartial() const;
+
// Runs the state transition loop. Resets and calls |callback_| on exit,
// unless the return value is ERR_IO_PENDING.
int DoLoop(int result);
diff --git a/net/http/partial_data.cc b/net/http/partial_data.cc
index 9c34bb17..2a76735 100644
--- a/net/http/partial_data.cc
+++ b/net/http/partial_data.cc
@@ -433,11 +433,26 @@
return rv;
}
+bool PartialData::IsNetworkReadOK(int data_len) const {
+ // Check to see if the network has overrun the range.
+ if (data_len) {
+ base::CheckedNumeric<int64_t> write_end_inclusive = current_range_start_;
+ write_end_inclusive += (data_len - 1);
+ int64_t write_end_inclusive_extracted;
+ if (!write_end_inclusive.AssignIfValid(&write_end_inclusive_extracted) ||
+ write_end_inclusive_extracted > current_range_end_) {
+ return false;
+ }
+ }
+ return true;
+}
+
int PartialData::CacheWrite(disk_cache::Entry* entry,
IOBuffer* data,
int data_len,
CompletionOnceCallback callback) {
DVLOG(3) << "To write: " << data_len;
+
if (sparse_entry_) {
return entry->WriteSparseData(current_range_start_, data, data_len,
std::move(callback));
diff --git a/net/http/partial_data.h b/net/http/partial_data.h
index 4094fcf..d220e569e 100644
--- a/net/http/partial_data.h
+++ b/net/http/partial_data.h
@@ -109,6 +109,11 @@
int data_len,
CompletionOnceCallback callback);
+ // Returns true if the returned length from network is reasonable.
+ // Expected to be called before deciding to transition towards cache
+ // writing.
+ bool IsNetworkReadOK(int data_len) const;
+
// Writes |data_len| bytes to cache. This is basically a wrapper around the
// API of the cache that provides the right arguments for the current range.
int CacheWrite(disk_cache::Entry* entry,
@@ -137,7 +142,7 @@
// The portion we're trying to get, either from cache or network.
int64_t current_range_start_ = 0;
- int64_t current_range_end_ = 0;
+ int64_t current_range_end_ = 0; // Inclusive.
// Next portion available in the cache --- this may be what's currently being
// read, or the next thing that will be read if the current network portion
diff --git a/net/http/http_cache_unittest.cc b/net/http/http_cache_unittest.cc
index d15b408..8d0248b 100644
--- a/net/http/http_cache_unittest.cc
+++ b/net/http/http_cache_unittest.cc
@@ -2466,6 +2466,89 @@
}
}
+// Range physical length not matching what's specified in Content-Range.
+// See crbug.com/572055997
+TEST_F(HttpCacheRangeGetTest, RangeOverrun) {
+ for (const bool use_cache : {true, false}) {
+ MockHttpCache cache;
+ SCOPED_TRACE(use_cache);
+
+ ScopedMockTransaction transaction(kRangeGET_TransactionOK);
+ transaction.request_headers = "Range: bytes = 0-9\r\n" EXTRA_HEADER;
+ std::string response_headers = transaction.response_headers;
+ if (!use_cache) {
+ response_headers += "Cache-Control: no-store\n";
+ }
+ transaction.response_headers = response_headers.c_str();
+
+ transaction.handler = base::BindRepeating(
+ [](const HttpRequestInfo* request, std::string* response_status,
+ std::string* response_headers, std::string* response_data) -> void {
+ RangeTransactionServer::RangeHandler(request, response_status,
+ response_headers, response_data);
+ *response_data += "extra stuff";
+ });
+
+ {
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(256);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 256, read_callback.callback()));
+ EXPECT_EQ(rv, ERR_CONTENT_LENGTH_MISMATCH);
+ }
+
+ {
+ // Test it over two reads as well.
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(10);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 10, read_callback.callback()));
+ ASSERT_EQ(rv, 10);
+ EXPECT_EQ(base::as_string_view(buf->first(10)),
+ std::string_view("rg: 00-09 "));
+
+ TestCompletionCallback read_callback2;
+ rv = read_callback2.GetResult(
+ http_trans->Read(buf.get(), 10, read_callback2.callback()));
+ EXPECT_EQ(rv, ERR_CONTENT_LENGTH_MISMATCH);
+ }
+
+ {
+ // Try again, without any weirdness.
+ transaction.handler =
+ base::BindRepeating(&RangeTransactionServer::RangeHandler);
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(256);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 256, read_callback.callback()));
+ ASSERT_EQ(rv, 10);
+ EXPECT_EQ(base::as_string_view(buf->first(10)),
+ std::string_view("rg: 00-09 "));
+ }
+ }
+}
+
// Tests that when a range request transaction becomes a writer for the first
// range and then fails conditionalization for the next range and decides to
// doom the entry, then there should not be a dcheck assertion hit.
Regression Test / PoC
diff --git a/net/http/http_cache_unittest.cc b/net/http/http_cache_unittest.cc
index d15b408..8d0248b 100644
--- a/net/http/http_cache_unittest.cc
+++ b/net/http/http_cache_unittest.cc
@@ -2466,6 +2466,89 @@
}
}
+// Range physical length not matching what's specified in Content-Range.
+// See crbug.com/572055997
+TEST_F(HttpCacheRangeGetTest, RangeOverrun) {
+ for (const bool use_cache : {true, false}) {
+ MockHttpCache cache;
+ SCOPED_TRACE(use_cache);
+
+ ScopedMockTransaction transaction(kRangeGET_TransactionOK);
+ transaction.request_headers = "Range: bytes = 0-9\r\n" EXTRA_HEADER;
+ std::string response_headers = transaction.response_headers;
+ if (!use_cache) {
+ response_headers += "Cache-Control: no-store\n";
+ }
+ transaction.response_headers = response_headers.c_str();
+
+ transaction.handler = base::BindRepeating(
+ [](const HttpRequestInfo* request, std::string* response_status,
+ std::string* response_headers, std::string* response_data) -> void {
+ RangeTransactionServer::RangeHandler(request, response_status,
+ response_headers, response_data);
+ *response_data += "extra stuff";
+ });
+
+ {
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(256);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 256, read_callback.callback()));
+ EXPECT_EQ(rv, ERR_CONTENT_LENGTH_MISMATCH);
+ }
+
+ {
+ // Test it over two reads as well.
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(10);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 10, read_callback.callback()));
+ ASSERT_EQ(rv, 10);
+ EXPECT_EQ(base::as_string_view(buf->first(10)),
+ std::string_view("rg: 00-09 "));
+
+ TestCompletionCallback read_callback2;
+ rv = read_callback2.GetResult(
+ http_trans->Read(buf.get(), 10, read_callback2.callback()));
+ EXPECT_EQ(rv, ERR_CONTENT_LENGTH_MISMATCH);
+ }
+
+ {
+ // Try again, without any weirdness.
+ transaction.handler =
+ base::BindRepeating(&RangeTransactionServer::RangeHandler);
+ MockHttpRequest request(transaction);
+ std::unique_ptr<HttpTransaction> http_trans = cache.CreateTransaction();
+ TestCompletionCallback callback;
+ int rv = callback.GetResult(
+ http_trans->Start(&request, callback.callback(), NetLogWithSource()));
+ ASSERT_EQ(rv, OK);
+
+ TestCompletionCallback read_callback;
+ auto buf = base::MakeRefCounted<IOBufferWithSize>(256);
+ rv = read_callback.GetResult(
+ http_trans->Read(buf.get(), 256, read_callback.callback()));
+ ASSERT_EQ(rv, 10);
+ EXPECT_EQ(base::as_string_view(buf->first(10)),
+ std::string_view("rg: 00-09 "));
+ }
+ }
+}
+
// Tests that when a range request transaction becomes a writer for the first
// range and then fails conditionalization for the next range and decides to
// doom the entry, then there should not be a dcheck assertion hit.