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 <[email protected]> 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 <[email protected]> Commit-Queue: Maks Orlovich <[email protected]> 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.