High chrome OOB 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Network
DescriptionMemory corruption in Network
ComponentNetwork
Bug ClassOOB
Tracker572055997
Fix commitbaeba815b067 (chromium/src) +128/-10
CISA KEVNot listed
CreditedCristian Di Nicola (crih.exe) (https://crihexe.com)
Disclosed2026-10-29

Background

HTTP 206 / range requests
a server may answer a Range: request with a partial body described by a Content-Range header; 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 via CacheWrite.
`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.

Key insight
The cache trusted a 206 response’s body length over its own Content-Range, letting a server overrun the range write; a CheckedNumeric bounds check (IsNetworkReadOK) now rejects overlong reads.

Attack Path

  1. ENTRY The victim fetches a cacheable resource for which the browser issues an HTTP range (206) request (an attacker-controlled or MITM’d origin).
  2. CONTROLLED INPUT The server returns a 206 whose body is longer than the byte count implied by its Content-Range (overlong range).
  3. MISSING CHECK The cache writer wrote the full network read at current_range_start_ without confirming it fit within current_range_end_.
  4. 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.)
  5. IMPACT Memory corruption in the network service, reachable by any origin the victim loads.

Impact Assessment

A server (or network attacker) can drive an out-of-bounds write in the HTTP cache via an overlong 206 range response, a corruption primitive in the network-service process (exploit chain inferred). Preconditions are low — the victim need only load a resource that triggers a range request. Advisory severity high; no ITW stated.

Changed Functions

FunctionChangeNotes
if
net/http/http_cache_writers.cc
modified
if
net/http/partial_data.cc
modified
TEST_F
net/http/http_cache_unittest.cc
modified
for
net/http/http_cache_unittest.cc
modified
if
net/http/http_cache_unittest.cc
modified

Files Changed

  • net/http/partial_data.h
  • net/http/partial_data.cc
  • net/http/http_cache_writers.cc
  • net/http/http_cache_unittest.cc
  • net/http/http_cache_writers.h
  • net/http/http_cache_transaction.cc

Audit Directions

  • Length trust on partial responses
    Audit all 206/sparse-cache write paths for validation that the received length matches the declared Content-Range before writing.
  • CheckedNumeric at range math
    Review other current_range_* arithmetic for unchecked additions that could overflow or exceed the range.
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.
Loading diff…

Regression Test / PoC

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

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.