High patch-now ~ plausible CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactBuffer overflow in Network
DescriptionBuffer overflow in Network
ComponentNetwork
Sub-componentHttpCacheTransaction
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — upheld by independent review by AI
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.
Independent review by AIPlausible
Reviewer notes: The buffer-swap mechanism fits the diff, but the overflowing body read is not shown. Impact wrongly says the advisory gives no severity; it lists high.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker543798025
Fix commitf0171e077fe3 (chromium/src) +97/-10
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited0xAlessandro
Reported2026-08-07 (per vendor advisory)
Fix landed2026-08-14 (public git)
Disclosed2026-08-20
Patch gap6 days — fix public in source before the advisory

Background

`HttpCache::Transaction`
the state machine in //net that serves an HTTP request from the disk cache, the network, or a mix of both; it is driven by DoXxx/DoXxxComplete states such as STATE_CACHE_READ_RESPONSE_COMPLETE.
`read_buf_` / `read_buf_len_`
transaction members that hold the consumer’s buffer and its length as passed to Read(), per the header comment “Length of the buffer passed in Read()”; later body reads copy up to read_buf_len_ bytes into read_buf_.
`PartialData`
the helper that lets the cache “glue” byte-range (Range:/206) responses together from cached and network pieces, validating stored headers before reusing a sparse or truncated entry.
`Vary: Range`
a response header saying the representation depends on the request’s Range header. It makes a stored entry’s validity depend on the specific range, which conflicts with combining several ranges into one response.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: The buffer-swap mechanism fits the diff, but the overflowing body read is not shown. Impact wrongly says the advisory gives no severity; it lists high.

When the transaction re-read the cached response metadata, it allocated the metadata buffer into the same member used for the caller’s data buffer. The code that allocates IOBufferWithSize(io_buf_len_) for the kResponseInfoIndex stream and passes it to ReadData (the state before DoCacheReadResponseComplete) stored it in read_buf_. DoCacheReadResponseComplete then parsed from read_buf_. That works when it happens before any Read(). With range requests and Vary: Range, however, the transaction can go back to reading response info in the middle of a Read(). The regression test does this: it reads the 40–49 piece, has a concurrent LOAD_BYPASS_CACHE transaction replace the entry with a different range, and then reads the next piece with a 6000-byte buffer. At that point read_buf_ is silently replaced by a buffer sized to the response-info blob, while read_buf_len_ still holds the caller’s larger length. The next cache or network body read into read_buf_ can then write past the end of the smaller heap buffer, and the caller’s real buffer never receives the data. The exact sink is (inferred) from the member semantics, since the diff does not show it.

The patch fixes this in two ways. A dedicated cache_buf_ member now holds response-info reads, so read_buf_ always remains the caller’s buffer. Separately, PartialData’s stored-header check returns false when any Vary value equals range, case-insensitively, so the cache no longer tries to combine pieces of such entries.

Key insight
The transaction reused the consumer-owned read_buf_ as scratch space for cached response metadata. Once a Vary: Range range-gluing path re-entered that state mid-read, the buffer pointer and the length read_buf_len_ no longer matched. The fix keeps metadata reads in a separate cache_buf_ and refuses partial-data gluing for Vary: Range entries.

Attack Path

  1. Serve `Vary: Range` ranges An attacker-controlled server returns 206 byte-range responses carrying Vary: range along with Last-Modified/Accept-Ranges, so Chrome caches a sparse partial entry.
  2. Extend the range Page content (for example media loading or fetch with a Range header; the exact initiator is (inferred)) requests a larger range covering the cached piece plus uncached bytes. PartialData then serves the cached part first.
  3. Concurrent entry replacement A second request for the same URL that bypasses the cache replaces the entry with a different, still-in-flight range while the first transaction is between pieces, as the VaryRange test does.
  4. Buffer confusion The first transaction re-reads response info, and before the fix this overwrote read_buf_ with a buffer of io_buf_len_ bytes. The following body read of up to read_buf_len_ bytes then writes past that allocation (inferred), causing the heap buffer overflow the advisory reports.

Impact Assessment

A malicious server and page could cause a heap buffer overflow in the network stack’s HTTP cache code. That code runs in the network service process, which on some platforms runs inside the browser process. The trigger needs attacker-controlled Vary: Range partial responses and two overlapping requests to the same URL, both of which appear achievable from ordinary web content (inferred). The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8. The advisory states only “Buffer overflow in Network” with no severity or in-the-wild details.

Changed Functions

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

Files Changed

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

Audit Directions

  • Member buffers reused as scratch
    In HttpCache::Transaction and similar state machines (HttpStreamParser, Writers, cache ReadData/WriteData callers), look for internal I/O that assigns a member that also holds a consumer buffer. Check that every buffer/length pair (read_buf_/read_buf_len_, io_buf_len_) is updated together on every re-entrant path.
  • Re-entrant states during `Read()`
    Trace which states can run after the first Read(): range continuation, revalidation, entry doom or replacement, and Vary mismatch restarts. Confirm that none of them re-run “start of transaction” logic that assumes there is no outstanding caller buffer.
  • Header semantics that break gluing
    Review PartialData and the cache’s validation for other headers that make combining ranges unsafe (Vary variants such as * or list values, Content-Encoding, mismatched Content-Range totals). Also check that the Vary enumeration catches comma-separated values that contain range.
From f0171e077fe31182c571370f49f8959369e7cb8d Mon Sep 17 00:00:00 2001
From: Maks Orlovich <morlovich@chromium.org>
Date: Fri, 14 Aug 2026 10:01:58 -0700
Subject: [PATCH] HttpCacheTransaction: fix confusion around range request w/Vary:Range

Bug: 543798025
Change-Id: Idaceab5116fe9fedccc97fdd13333b0cd9fc9f0b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8243730
Reviewed-by: Adam Rice <ricea@chromium.org>
Commit-Queue: Maks Orlovich <morlovich@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1679626}
---
diff --git a/net/http/http_cache_transaction.cc b/net/http/http_cache_transaction.cc
index 543a583..4fe7e55 100644
--- a/net/http/http_cache_transaction.cc
+++ b/net/http/http_cache_transaction.cc
@@ -1635,18 +1635,18 @@
   TransitionToState(STATE_CACHE_READ_RESPONSE_COMPLETE);
 
   io_buf_len_ = entry_->GetEntry()->GetDataSize(kResponseInfoIndex);
-  read_buf_ = base::MakeRefCounted<IOBufferWithSize>(io_buf_len_);
+  cache_buf_ = base::MakeRefCounted<IOBufferWithSize>(io_buf_len_);
 
   net_log_.BeginEvent(NetLogEventType::HTTP_CACHE_READ_INFO);
   BeginDiskCacheAccessTimeCount();
-  return entry_->GetEntry()->ReadData(kResponseInfoIndex, 0, read_buf_.get(),
+  return entry_->GetEntry()->ReadData(kResponseInfoIndex, 0, cache_buf_.get(),
                                       io_buf_len_, io_callback_);
 }
 
 int HttpCache::Transaction::DoCacheReadResponseComplete(int result) {
   TRACE_EVENT_INSTANT(TRACE_DISABLED_BY_DEFAULT("net"),
                       "DoCacheReadResponseComplete", track_for_state_change_,
-                      "result", result, "io_buf_len", read_buf_->size());
+                      "result", result, "io_buf_len", cache_buf_->size());
   net_log_.EndEventWithNetErrorCode(NetLogEventType::HTTP_CACHE_READ_INFO,
                                     result);
   EndDiskCacheAccessTimeCount(DiskCacheAccessType::kRead);
@@ -1654,8 +1654,8 @@
   // Record the time immediately before the cached response is parsed.
   read_headers_since_ = TimeTicks::Now();
 
-  if (result != read_buf_->size() ||
-      !HttpCache::ParseResponseInfo(read_buf_->span(), &response_,
+  if (result != cache_buf_->size() ||
+      !HttpCache::ParseResponseInfo(cache_buf_->span(), &response_,
                                     &truncated_)) {
     return OnCacheReadError(result, true);
   }
diff --git a/net/http/http_cache_transaction.h b/net/http/http_cache_transaction.h
index 29e9263..66bcb78 100644
--- a/net/http/http_cache_transaction.h
+++ b/net/http/http_cache_transaction.h
@@ -765,6 +765,7 @@
   // Length of the buffer passed in Read().
   int read_buf_len_ = 0;
 
+  scoped_refptr<IOBuffer> cache_buf_;
   int io_buf_len_ = 0;
   int read_offset_ = 0;
   // Disk offset for reads on the compressed cache path. Advances by the
diff --git a/net/http/partial_data.cc b/net/http/partial_data.cc
index 3b2373dd..9c34bb17 100644
--- a/net/http/partial_data.cc
+++ b/net/http/partial_data.cc
@@ -185,6 +185,17 @@
                                           disk_cache::Entry* entry,
                                           bool truncated,
                                           bool writing_in_progress) {
+  // Vary: range is a very confusing for us when we're gluing things
+  // together, so just give up on it.
+  size_t iter = 0;
+  constexpr std::string_view name = "vary";
+  std::optional<std::string_view> vary_entry;
+  while ((vary_entry = headers->EnumerateHeader(&iter, name))) {
+    if (base::EqualsCaseInsensitiveASCII(vary_entry.value(), "range")) {
+      return false;
+    }
+  }
+
   resource_size_ = 0;
   if (truncated) {
     DCHECK_EQ(headers->response_code(), 200);
diff --git a/net/http/http_cache_unittest.cc b/net/http/http_cache_unittest.cc
index ceeb606..3760bba 100644
--- a/net/http/http_cache_unittest.cc
+++ b/net/http/http_cache_unittest.cc
@@ -562,7 +562,9 @@
                                                  start, end, length_);
   response_headers->append(content_range);
 
-  if (!request->extra_headers.HasHeader("If-None-Match") || modified_) {
+  if ((!request->extra_headers.HasHeader("If-None-Match") &&
+       !request->extra_headers.HasHeader("If-Modified-Since")) ||
+      modified_) {
     std::string data;
     if (end == start) {
       EXPECT_EQ(0, end % 10);
@@ -581,8 +583,14 @@
       int64_t len = end - start + 1;
       std::string content_length =
           base::StringPrintf("Content-Length: %" PRId64 "\n", len);
-      response_headers->replace(response_headers->find("Content-Length:"),
-                                content_length.size(), content_length);
+      size_t length_start = response_headers->find("Content-Length:");
+      CHECK_NE(length_start, std::string::npos);
+      size_t length_end = response_headers->find('\n', length_start);
+      if (length_end == std::string::npos) {
+        length_end = response_headers->length();
+      }
+      response_headers->replace(length_start, length_end - length_start,
+                                content_length);
     }
   } else {
     response_status->assign("HTTP/1.1 304 Not Modified");
@@ -3831,6 +3839,74 @@
   EXPECT_EQ(1, cache.disk_cache()->create_count());
 }
 
+TEST_F(HttpCacheRangeGetTest, VaryRange) {
+  MockHttpCache cache;
+  cache.disk_cache()->set_double_create_check(false);
+
+  ScopedMockTransaction transaction(kRangeGET_TransactionOK);
+  transaction.response_headers =
+      "Last-Modified: Sat, 18 Apr 2007 01:10:43 GMT\n"
+      "Date: Sat, 18 Apr 2007 02:10:43 GMT\n"
+      "Accept-Ranges: bytes\n"
+      "Vary: range\n"
+      "Content-Length: 10\n";
+
+  RangeTransactionServer range_support;
+  range_support.set_length(60000);
+
+  // Request the transaction's 40-49 range, and get it cached.
+  {
+    std::string headers;
+    RunTransactionTestWithResponse(cache.http_cache(), transaction, &headers);
+    Verify206Response(headers, 40, 49);
+  }
+
+  // Now request an extension.
+  transaction.request_headers = "Range: bytes = 40-59009\r\n" EXTRA_HEADER;
+  MockHttpRequest request(transaction);
+  Context c;
+
+  c.trans = cache.CreateTransaction();
+  ASSERT_TRUE(c.trans);
+
+  int rv = c.callback.GetResult(
+      c.trans->Start(&request, c.callback.callback(), NetLogWithSource()));
+  ASSERT_THAT(rv, IsOk());
+
+  // First read the 40-49 portion.
+  scoped_refptr<IOBufferWithSize> buf =
+      base::MakeRefCounted<IOBufferWithSize>(10);
+  rv = c.callback.GetResult(
+      c.trans->Read(buf.get(), buf->size(), c.callback.callback()));
+  EXPECT_EQ(10, rv);
+  EXPECT_EQ(buf->first(10), base::byte_span_from_cstring("rg: 40-49 "));
+
+  // Replace with an in-progress different entry, with a different range, that's
+  // in-flight.
+  Context c2;
+  ScopedMockTransaction transaction2(transaction);
+  transaction2.request_headers = "Range: bytes = 30-39\r\n" EXTRA_HEADER;
+  transaction2.load_flags = LOAD_BYPASS_CACHE;
+  transaction2.data = "rg: 30-39 ";
+  MockHttpRequest request2(transaction2);
+
+  c2.trans = cache.CreateTransaction();
+  ASSERT_TRUE(c2.trans);
+
+  rv = c2.callback.GetResult(
+      c2.trans->Start(&request2, c2.callback.callback(), NetLogWithSource()));
+  ASSERT_THAT(rv, IsOk());
+
+  // Try to read the 50-5059 portion. The right bits should come in.
+  scoped_refptr<IOBufferWithSize> buf2 =
+      base::MakeRefCounted<IOBufferWithSize>(6000);
+  std::ranges::fill(buf2->span(), 'A');
+  rv = c.callback.GetResult(
+      c.trans->Read(buf2.get(), buf2->size(), c.callback.callback()));
+  EXPECT_LE(10, rv);
+  EXPECT_EQ(buf2->first(10), base::byte_span_from_cstring("rg: 50-59 "));
+}
+
 // A test of doing a range request to a cached 301 response
 TEST_F(HttpCacheRangeGetTest, CachedRedirect) {
   RangeTransactionServer handler;
@@ -9603,7 +9679,6 @@
   std::string headers;
   MockTransaction transaction2(kRangeGET_TransactionOK);
   RangeTransactionServer handler;
-  handler.set_not_modified(true);
   RunTransactionTestWithResponse(cache.http_cache(), transaction2, &headers);
 
   // We are expecting a 206.
@@ -9637,7 +9712,7 @@
   base::RunLoop().RunUntilIdle();
 
   // Now we should receive a range from the server and drop the stored entry.
-  handler.set_not_modified(false);
+  handler.set_modified(true);
   transaction2.request_headers = kRangeGET_TransactionOK.request_headers;
   RunTransactionTestWithResponse(cache.http_cache(), transaction2, &headers);
   Verify206Response(headers, 40, 49);
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 ceeb606..3760bba 100644
--- a/net/http/http_cache_unittest.cc
+++ b/net/http/http_cache_unittest.cc
@@ -562,7 +562,9 @@
                                                  start, end, length_);
   response_headers->append(content_range);
 
-  if (!request->extra_headers.HasHeader("If-None-Match") || modified_) {
+  if ((!request->extra_headers.HasHeader("If-None-Match") &&
+       !request->extra_headers.HasHeader("If-Modified-Since")) ||
+      modified_) {
     std::string data;
     if (end == start) {
       EXPECT_EQ(0, end % 10);
@@ -581,8 +583,14 @@
       int64_t len = end - start + 1;
       std::string content_length =
           base::StringPrintf("Content-Length: %" PRId64 "\n", len);
-      response_headers->replace(response_headers->find("Content-Length:"),
-                                content_length.size(), content_length);
+      size_t length_start = response_headers->find("Content-Length:");
+      CHECK_NE(length_start, std::string::npos);
+      size_t length_end = response_headers->find('\n', length_start);
+      if (length_end == std::string::npos) {
+        length_end = response_headers->length();
+      }
+      response_headers->replace(length_start, length_end - length_start,
+                                content_length);
     }
   } else {
     response_status->assign("HTTP/1.1 304 Not Modified");
@@ -3831,6 +3839,74 @@
   EXPECT_EQ(1, cache.disk_cache()->create_count());
 }
 
+TEST_F(HttpCacheRangeGetTest, VaryRange) {
+  MockHttpCache cache;
+  cache.disk_cache()->set_double_create_check(false);
+
+  ScopedMockTransaction transaction(kRangeGET_TransactionOK);
+  transaction.response_headers =
+      "Last-Modified: Sat, 18 Apr 2007 01:10:43 GMT\n"
+      "Date: Sat, 18 Apr 2007 02:10:43 GMT\n"
+      "Accept-Ranges: bytes\n"
+      "Vary: range\n"
+      "Content-Length: 10\n";
+
+  RangeTransactionServer range_support;
+  range_support.set_length(60000);
+
+  // Request the transaction's 40-49 range, and get it cached.
+  {
+    std::string headers;
+    RunTransactionTestWithResponse(cache.http_cache(), transaction, &headers);
+    Verify206Response(headers, 40, 49);
+  }
+
+  // Now request an extension.
+  transaction.request_headers = "Range: bytes = 40-59009\r\n" EXTRA_HEADER;
+  MockHttpRequest request(transaction);
+  Context c;
+
+  c.trans = cache.CreateTransaction();
+  ASSERT_TRUE(c.trans);
+
+  int rv = c.callback.GetResult(
+      c.trans->Start(&request, c.callback.callback(), NetLogWithSource()));
+  ASSERT_THAT(rv, IsOk());
+
+  // First read the 40-49 portion.
+  scoped_refptr<IOBufferWithSize> buf =
+      base::MakeRefCounted<IOBufferWithSize>(10);
+  rv = c.callback.GetResult(
+      c.trans->Read(buf.get(), buf->size(), c.callback.callback()));
+  EXPECT_EQ(10, rv);
+  EXPECT_EQ(buf->first(10), base::byte_span_from_cstring("rg: 40-49 "));
+
+  // Replace with an in-progress different entry, with a different range, that's
+  // in-flight.
+  Context c2;
+  ScopedMockTransaction transaction2(transaction);
+  transaction2.request_headers = "Range: bytes = 30-39\r\n" EXTRA_HEADER;
+  transaction2.load_flags = LOAD_BYPASS_CACHE;
+  transaction2.data = "rg: 30-39 ";
+  MockHttpRequest request2(transaction2);
+
+  c2.trans = cache.CreateTransaction();
+  ASSERT_TRUE(c2.trans);
+
+  rv = c2.callback.GetResult(
+      c2.trans->Start(&request2, c2.callback.callback(), NetLogWithSource()));
+  ASSERT_THAT(rv, IsOk());
+
+  // Try to read the 50-5059 portion. The right bits should come in.
+  scoped_refptr<IOBufferWithSize> buf2 =
+      base::MakeRefCounted<IOBufferWithSize>(6000);
+  std::ranges::fill(buf2->span(), 'A');
+  rv = c.callback.GetResult(
+      c.trans->Read(buf2.get(), buf2->size(), c.callback.callback()));
+  EXPECT_LE(10, rv);
+  EXPECT_EQ(buf2->first(10), base::byte_span_from_cstring("rg: 50-59 "));
+}
+
 // A test of doing a range request to a cached 301 response
 TEST_F(HttpCacheRangeGetTest, CachedRedirect) {
   RangeTransactionServer handler;
@@ -9603,7 +9679,6 @@
   std::string headers;
   MockTransaction transaction2(kRangeGET_TransactionOK);
   RangeTransactionServer handler;
-  handler.set_not_modified(true);
   RunTransactionTestWithResponse(cache.http_cache(), transaction2, &headers);
 
   // We are expecting a 206.
@@ -9637,7 +9712,7 @@
   base::RunLoop().RunUntilIdle();
 
   // Now we should receive a range from the server and drop the stored entry.
-  handler.set_not_modified(false);
+  handler.set_modified(true);
   transaction2.request_headers = kRangeGET_TransactionOK.request_headers;
   RunTransactionTestWithResponse(cache.http_cache(), transaction2, &headers);
   Verify206Response(headers, 40, 49);
Loading diff…

Original Bug Report

reported by al...@gmail.com

Heap buffer overflow in network service: stale read_buf_len_ in HttpCache::Transaction

Steps to reproduce the problem

  1. Save the two attachments, poc.html and poc_server.py, into the same directory.

  2. Run the origin: python3 poc_server.py It binds 127.0.0.1 on ports 8899-8904, plaintext HTTP/1.1.

  3. Open http://127.0.0.1:8899/poc.html

  4. Leave the tab open. Each pass caches a range on a fresh cache key, asks for a superset of that range, and fires one concurrent fetch(url, {cache:‘reload’}) whose range starts above the victim’s first byte.

  5. Within a few minutes the network service starts dying and respawning. On an ASAN build you get heap-buffer-overflow instead; asan_report.txt is the full stack.

A few environment notes, since these cost me time:

  • It has to be cleartext HTTP/1.1. Over TLS the length hits the span check at ssl_client_socket_impl.cc:608 first, and over HTTP/2 it hits spdy_http_stream.cc:125. Either way the process stops safely and you see nothing.

  • My checkout defaults to the experimental SQL cache backend, so I forced the shipping one with –enable-features=DiskCacheBackendExperiment:backend/simple You probably don’t need this. I ran both backends at identical settings and got the same rate either way: 8 memory faults per ~1040 origin requests.

  • The ASAN build was is_asan=true, is_debug=false, dcheck_always_on=false from a clean tree, run with ASAN_OPTIONS=detect_leaks=0:symbolize=1:halt_on_error=0 That gave 32 reports in 20 minutes over 8322 origin requests.

  • I reproduced on a local build of 152.0.7971.0, then read the upstream source for tag 153.0.7988.0 and for refs/heads/main. Both still have DCHECK(!reading_) in DoHeadersPhaseCannotProceed, both still leave read_buf_len_ untouched in DoCacheReadResponse, and both still have the bare read(socket_fd_, buf->data(), buf_len) in SocketPosix::DoRead.

Problem Description

I was auditing the partial-content paths in the HTTP cache and found that a page, together with an origin it controls, can get HttpCache::Transaction to issue a socket read with a length that no longer matches its buffer. The read lands ~65 KB past a 384-byte allocation in the network service, and every byte comes straight off the attacker’s socket.

The buffer and its length live in separate fields. read_buf_len_ is written in exactly one place – Transaction::Read(), http_cache_transaction.cc:334 – and never cleared. DoCacheReadResponse() swaps read_buf_ for a small response-info-sized buffer at :1644 and leaves read_buf_len_ alone.

That only holds while no read is in flight, and the code knows it: the guard is DCHECK(!reading_) at :2420, which release builds drop. Get there mid-read and the transaction carries on with a small buffer and a large stale length. I followed the length down expecting something to catch it, and nothing does. Writers::Read copies it verbatim (http_cache_writers.cc:98-99); the stream parser reads with user_read_buf_len_ rather than the Content-Length-clamped remaining_read_len it computed a few lines above (http_stream_parser.cc:718); and it ends in the bare read() at socket_posix.cc:519.

ASAN (full stack in the crash-state field and attached):

ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 65328 at 0x613000705080 thread T9 #1 net::SocketPosix::DoRead net/socket/socket_posix.cc:519:25 0x613000705080 is located 0 bytes after 384-byte region allocated by thread T9 here: #3 net::HttpCache::Transaction::DoCacheReadResponse() net/http/http_cache_transaction.cc:1644:15

How the page gets there:

The origin serves a 206 carrying Vary: Range, a Last-Modified, and a Date at least 60s later, and deliberately no ETag. That satisfies HasStrongValidators through the Last-Modified path while leaving the ETag empty, which matters at the end.

PartialData::PrepareCacheValidation rewrites the request’s Range header for each sub-range (partial_data.cc:170-173), and HttpVaryData hashes whatever the stored Vary names (http_vary_data.cc:32-45). So the vary hash agrees on the cached prefix – the read begins, reading_ goes true – and disagrees on the next sub-range, setting vary_mismatch_ at :3210 while the read is already in progress. ConditionalizeRequest then bails at :3310 because the ETag is empty, and DoRestartPartialRequest runs with reading_ still true.

The concurrent fetch(url, {cache:‘reload’, headers:{Range:‘bytes=X-Y’}}) does the rest, and X has to be above 0. LOAD_BYPASS_CACHE dooms the victim’s entry and installs a replacement with a hole exactly where the victim resumes, so IsCurrentRangeCached() is false at :1940 and the restarted transaction takes the network leg. That leg is the unguarded one – had it gone to the cache, the span check there would have stopped it.

Two origin-side details are load-bearing. The response headers have to leave in their own segment, or http_stream_parser.cc:691 takes a span-guarded copy and CHECKs before anything happens. And the origin has to keep sending past Content-Length, since the socket read is sized by user_read_buf_len_ and not by the declared length.

On control: the content is verbatim socket data. On a non-ASAN release build I changed the payload byte and watched the faulting address follow it – ‘V’ (0x56) gives SEGV_ACCERR 0x5656565656565656, ‘B’ (0x42) gives 0x4242424242424242. A 64-bit pointer ends up holding a value I picked, and then gets dereferenced. The length is whatever read size the victim page used, 65536 in my runs, against a 384-byte allocation.

Two things I should say plainly. I stopped at the overflow and made no attempt at exploitation, so I can’t tell you whether it is exploitable. And my builds have PA_BUILDFLAG_INTERNAL_USE_PARTITION_ALLOC_AS_MALLOC() == 0 – system malloc, not PartitionAlloc – which changes the heap layout around the overflow, though not whether the write happens.

Additional Comments

Same root cause, other sinks. The stale length also reaches PartialData::CacheRead and PartialData::CacheWrite – the second is an out-of-bounds read of the same buffer. Both are bounded by base::span checks today and abort cleanly, so as things stand they are not a memory-safety problem. One more is not bounded: CacheBodyDecompressor::DoDecompress builds ZSTD_outBuffer{output_buf->data(), output_buf_len, 0} at cache_body_decompressor.cc:104, where output_buf_len is the same stale length and output_buf is the swapped buffer, so zstd gets a raw pointer with the wrong capacity. It is unreachable right now – kHttpCacheZstdCompression and kHttpCacheZstdDecompression are off by default, and ShouldCompressForCache() also wants did_use_shared_dictionary (http_cache_writers.cc:727+). I mention it because turning cache compression on would make this same desync corrupt memory through that path, without anyone touching the cache code.

I also went looking for a disclosure primitive and did not find one. If the page could read back bytes past the buffer that were not its own, that would be network-service heap handed to JavaScript. Over 2418 requests I saw 137 responses larger than the 384-byte buffer, and every byte was my own payload – zero foreign bytes. The out-of-bounds region gets filled by the same read(2) that overflows it, so reading it back just returns the attacker’s own data. This looks like a write primitive only.

On fixing it: clearing read_buf_len_ where read_buf_ is swapped in DoCacheReadResponse() is the direct fix, or keeping the buffer and its length in one object so they can’t drift apart. Promoting the DCHECK at :2420 to a CHECK would turn this into a controlled abort, but the desync would still be there.

Summary

Heap buffer overflow in network service: stale read_buf_len_ in HttpCache::Transaction

Custom Questions

Type of crash:

Utility process, the network service. The browser and the tab survive; the network service dies and is respawned, repeatedly for as long as the page is open.

Crash state:

AddressSanitizer, from a build with is_asan=true, is_debug=false, dcheck_always_on=false on a clean tree. Frames resolved with third_party/llvm-build/…/llvm-symbolizer. No registers or exception record: ASAN aborts before a crash dump is written. Full report attached as asan_report.txt.

==78489==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x613000705080 at pc 0x0001052c0810 bp 0x00030a8926b0 sp 0x00030a891e70 WRITE of size 65328 at 0x613000705080 thread T9 #1 net::SocketPosix::DoRead(net::IOBuffer*, int) net/socket/socket_posix.cc:519:25 #2 net::SocketPosix::ReadIfReady(net::IOBuffer*, int, base::OnceCallback<void (int)>) #3 net::SocketPosix::RetryRead(int) #5 net::SocketPosix::OnFileCanReadWithoutBlocking(int) #6 base::MessagePumpKqueue::ProcessEvents(…) #12 content::(anonymous namespace)::ChildIOThread::Run(base::RunLoop*)

0x613000705080 is located 0 bytes after 384-byte region [0x613000704f00,0x613000705080) allocated by thread T9 here: #1 net::IOBufferWithSize::IOBufferWithSize(unsigned long) #2 base::MakeRefCounted<net::IOBufferWithSize, int&>(int&) #3 net::HttpCache::Transaction::DoCacheReadResponse() net/http/http_cache_transaction.cc:1644:15 #4 net::HttpCache::Transaction::DoLoop(int) #5 net::HttpCache::Transaction::OnCacheIOComplete(int) #8 net::HttpCache::ProcessAddToEntryQueue(…) #9 net::HttpCache::OnProcessQueuedTransactions(…)

Reporter credit:

0xAlessandro

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker
Links in the report