Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Network
DescriptionUse after free in Network
ComponentNetwork
Bug ClassUAF
Tracker511736002
Fix commitf08d5d071e40 (chromium/src) +222/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
if
net/spdy/bidirectional_stream_spdy_impl.cc
modified
if
net/spdy/spdy_http_stream.cc
modified
TEST_F
net/spdy/spdy_http_stream_unittest.cc
modified

Files Changed

  • net/spdy/bidirectional_stream_spdy_impl.cc
  • net/spdy/spdy_http_stream.cc
  • net/spdy/spdy_http_stream_unittest.cc
From f08d5d071e409f77af3dd135420dd5a98d8080eb Mon Sep 17 00:00:00 2001
From: Nidhi Jaju <[email protected]>
Date: Sun, 31 May 2026 23:34:48 -0700
Subject: [PATCH] Fix re-entrant deletion crash in HTTP/2 stream delegates

This CL adds defensive WeakPtr checks inside SpdyHttpStream,
BidirectionalStreamSpdyImpl, and SpdyProxyClientSocket around calls
to SpdyReadQueue::Dequeue() or read operations that consume the
underlying buffers. Since Dequeue() fires SpdyBuffer consume
callbacks which can trigger session window updates, exceed the capped
frames queue limit, and drain the session, the stream and its
delegates can be synchronously deleted mid-execution.

Bug: 511736002
Change-Id: Ic6f3c1f875a059195a94d602b4ea91ba6df541d7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7889155
Reviewed-by: Kenichi Ishibashi <[email protected]>
Commit-Queue: Nidhi Jaju <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1639190}
---

diff --git a/net/spdy/bidirectional_stream_spdy_impl.cc b/net/spdy/bidirectional_stream_spdy_impl.cc
index 261fa33..9824a3b7 100644
--- a/net/spdy/bidirectional_stream_spdy_impl.cc
+++ b/net/spdy/bidirectional_stream_spdy_impl.cc
@@ -89,7 +89,15 @@
 
   // If there is data buffered, complete the IO immediately.
   if (!read_data_queue_.IsEmpty()) {
-    return read_data_queue_.Dequeue(buf->first(buf_len));
+    // Dequeueing can fire consume callbacks that trigger session
+    // teardown and destroy `this`.
+    base::WeakPtr<BidirectionalStreamSpdyImpl> self =
+        weak_factory_.GetWeakPtr();
+    int rv = read_data_queue_.Dequeue(buf->first(buf_len));
+    if (!self) {
+      return ERR_CONNECTION_CLOSED;
+    }
+    return rv;
   } else if (stream_closed_) {
     return closed_stream_status_;
   }
@@ -370,12 +378,20 @@
 
   int rv = 0;
   if (read_buffer_) {
+    // ReadData() can fire consume callbacks that trigger session
+    // teardown and destroy `this`.
+    base::WeakPtr<BidirectionalStreamSpdyImpl> self =
+        weak_factory_.GetWeakPtr();
     rv = ReadData(read_buffer_.get(), read_buffer_len_);
+    if (!self) {
+      return;
+    }
     DCHECK_NE(ERR_IO_PENDING, rv);
     read_buffer_ = nullptr;
     read_buffer_len_ = 0;
-    if (delegate_)
+    if (delegate_) {
       delegate_->OnDataRead(rv);
+    }
   }
 }
 
diff --git a/net/spdy/spdy_http_stream.cc b/net/spdy/spdy_http_stream.cc
index 14a3188..71928be1 100644
--- a/net/spdy/spdy_http_stream.cc
+++ b/net/spdy/spdy_http_stream.cc
@@ -117,7 +117,14 @@
 
   // If we have data buffered, complete the IO immediately.
   if (!response_body_queue_.IsEmpty()) {
-    return response_body_queue_.Dequeue(buf->first(buf_len));
+    // Dequeueing can fire consume callbacks that trigger session
+    // teardown and destroy `this`.
+    base::WeakPtr<SpdyHttpStream> self = weak_factory_.GetWeakPtr();
+    int rv = response_body_queue_.Dequeue(buf->first(buf_len));
+    if (!self) {
+      return ERR_CONNECTION_CLOSED;
+    }
+    return rv;
   } else if (stream_closed_) {
     return closed_stream_status_;
   }
@@ -542,11 +549,19 @@
     return;
 
   if (!response_body_queue_.IsEmpty()) {
+    // Dequeueing can fire consume callbacks that trigger synchronous session
+    // teardown and destroy `this`.
+    base::WeakPtr<SpdyHttpStream> self = weak_factory_.GetWeakPtr();
     int rv =
         response_body_queue_.Dequeue(user_buffer_->first(user_buffer_len_));
+    if (!self) {
+      return;
+    }
     user_buffer_ = nullptr;
     user_buffer_len_ = 0;
-    DoResponseCallback(rv);
+    if (response_callback_) {
+      DoResponseCallback(rv);
+    }
     return;
   }
 
diff --git a/net/spdy/spdy_http_stream_unittest.cc b/net/spdy/spdy_http_stream_unittest.cc
index 4fcb3e28..3277715 100644
--- a/net/spdy/spdy_http_stream_unittest.cc
+++ b/net/spdy/spdy_http_stream_unittest.cc
@@ -14,6 +14,7 @@
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
 #include "base/task/single_thread_task_runner.h"
+#include "base/test/run_until.h"
 #include "net/base/chunked_upload_data_stream.h"
 #include "net/base/load_timing_info.h"
 #include "net/base/load_timing_info_test_util.h"
@@ -173,6 +174,13 @@
                                  std::move(resolution_details));
   }
 
+  void set_session_max_recv_window_size(int32_t val) {
+    session_->session_max_recv_window_size_ = val;
+  }
+  void set_session_recv_window_size(int32_t val) {
+    session_->session_recv_window_size_ = val;
+  }
+
   SpdyTestUtil spdy_util_;
   SpdySessionDependencies session_deps_;
   const GURL url_;
@@ -1416,6 +1424,147 @@
   base::RunLoop().RunUntilIdle();
 }
 
+TEST_F(SpdyHttpStreamTest, ReadResponseBodyExceedsCappedFramesLimit) {
+  // Set the capped frames limit to 1.
+  session_deps_.session_max_queued_capped_frames = 1;
+
+  spdy::SpdySerializedFrame req1(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 1, LOWEST));
+  spdy::SpdySerializedFrame req2(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 3, LOWEST));
+  spdy::SpdySerializedFrame req3(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 5, LOWEST));
+
+  spdy::SpdySerializedFrame rst1(
+      spdy_util_.ConstructSpdyRstStream(1, spdy::ERROR_CODE_CANCEL));
+  spdy::SpdySerializedFrame rst2(
+      spdy_util_.ConstructSpdyRstStream(3, spdy::ERROR_CODE_CANCEL));
+
+  MockWrite writes[] = {
+      CreateMockWrite(req1, 0), CreateMockWrite(req2, 1),
+      CreateMockWrite(req3, 2), CreateMockWrite(rst1, 5),
+      CreateMockWrite(rst2, 6),
+  };
+
+  spdy::SpdySerializedFrame resp3(spdy_util_.ConstructSpdyGetReply(
+      base::span<const std::string_view>(), 5));
+  spdy::SpdySerializedFrame body3(
+      spdy_util_.ConstructSpdyDataFrame(5, "some data", false));
+
+  MockRead reads[] = {
+      CreateMockRead(resp3, 3), CreateMockRead(body3, 4),
+      MockRead(ASYNC, ERR_IO_PENDING, 7), MockRead(ASYNC, 0, 8),  // EOF
+  };
+
+  InitSession(reads, writes);
+
+  // Set small session receive window so that reading "some data" (9 bytes)
+  // triggers a WINDOW_UPDATE.
+  set_session_max_recv_window_size(10);
+  set_session_recv_window_size(10);
+
+  HttpRequestInfo request1;
+  request1.method = "GET";
+  request1.url = url_;
+  request1.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  NetLogWithSource net_log;
+  auto http_stream1 = std::make_unique<SpdyHttpStream>(
+      session_, net_log.source(), /*dns_aliases=*/std::set<std::string>());
+  http_stream1->RegisterRequest(&request1);
+  ASSERT_THAT(http_stream1->InitializeStream(true, LOWEST, net_log,
+                                             CompletionOnceCallback()),
+              IsOk());
+
+  HttpRequestInfo request2;
+  request2.method = "GET";
+  request2.url = url_;
+  request2.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  auto http_stream2 = std::make_unique<SpdyHttpStream>(
+      session_, net_log.source(), /*dns_aliases=*/std::set<std::string>());
+  http_stream2->RegisterRequest(&request2);
+  ASSERT_THAT(http_stream2->InitializeStream(true, LOWEST, net_log,
+                                             CompletionOnceCallback()),
+              IsOk());
+
+  HttpRequestInfo request3;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/spdy/spdy_http_stream_unittest.cc b/net/spdy/spdy_http_stream_unittest.cc
index 4fcb3e28..3277715 100644
--- a/net/spdy/spdy_http_stream_unittest.cc
+++ b/net/spdy/spdy_http_stream_unittest.cc
@@ -14,6 +14,7 @@
 #include "base/memory/raw_ptr.h"
 #include "base/run_loop.h"
 #include "base/task/single_thread_task_runner.h"
+#include "base/test/run_until.h"
 #include "net/base/chunked_upload_data_stream.h"
 #include "net/base/load_timing_info.h"
 #include "net/base/load_timing_info_test_util.h"
@@ -173,6 +174,13 @@
                                  std::move(resolution_details));
   }
 
+  void set_session_max_recv_window_size(int32_t val) {
+    session_->session_max_recv_window_size_ = val;
+  }
+  void set_session_recv_window_size(int32_t val) {
+    session_->session_recv_window_size_ = val;
+  }
+
   SpdyTestUtil spdy_util_;
   SpdySessionDependencies session_deps_;
   const GURL url_;
@@ -1416,6 +1424,147 @@
   base::RunLoop().RunUntilIdle();
 }
 
+TEST_F(SpdyHttpStreamTest, ReadResponseBodyExceedsCappedFramesLimit) {
+  // Set the capped frames limit to 1.
+  session_deps_.session_max_queued_capped_frames = 1;
+
+  spdy::SpdySerializedFrame req1(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 1, LOWEST));
+  spdy::SpdySerializedFrame req2(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 3, LOWEST));
+  spdy::SpdySerializedFrame req3(spdy_util_.ConstructSpdyGet(
+      base::span<const std::string_view>(), 5, LOWEST));
+
+  spdy::SpdySerializedFrame rst1(
+      spdy_util_.ConstructSpdyRstStream(1, spdy::ERROR_CODE_CANCEL));
+  spdy::SpdySerializedFrame rst2(
+      spdy_util_.ConstructSpdyRstStream(3, spdy::ERROR_CODE_CANCEL));
+
+  MockWrite writes[] = {
+      CreateMockWrite(req1, 0), CreateMockWrite(req2, 1),
+      CreateMockWrite(req3, 2), CreateMockWrite(rst1, 5),
+      CreateMockWrite(rst2, 6),
+  };
+
+  spdy::SpdySerializedFrame resp3(spdy_util_.ConstructSpdyGetReply(
+      base::span<const std::string_view>(), 5));
+  spdy::SpdySerializedFrame body3(
+      spdy_util_.ConstructSpdyDataFrame(5, "some data", false));
+
+  MockRead reads[] = {
+      CreateMockRead(resp3, 3), CreateMockRead(body3, 4),
+      MockRead(ASYNC, ERR_IO_PENDING, 7), MockRead(ASYNC, 0, 8),  // EOF
+  };
+
+  InitSession(reads, writes);
+
+  // Set small session receive window so that reading "some data" (9 bytes)
+  // triggers a WINDOW_UPDATE.
+  set_session_max_recv_window_size(10);
+  set_session_recv_window_size(10);
+
+  HttpRequestInfo request1;
+  request1.method = "GET";
+  request1.url = url_;
+  request1.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  NetLogWithSource net_log;
+  auto http_stream1 = std::make_unique<SpdyHttpStream>(
+      session_, net_log.source(), /*dns_aliases=*/std::set<std::string>());
+  http_stream1->RegisterRequest(&request1);
+  ASSERT_THAT(http_stream1->InitializeStream(true, LOWEST, net_log,
+                                             CompletionOnceCallback()),
+              IsOk());
+
+  HttpRequestInfo request2;
+  request2.method = "GET";
+  request2.url = url_;
+  request2.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  auto http_stream2 = std::make_unique<SpdyHttpStream>(
+      session_, net_log.source(), /*dns_aliases=*/std::set<std::string>());
+  http_stream2->RegisterRequest(&request2);
+  ASSERT_THAT(http_stream2->InitializeStream(true, LOWEST, net_log,
+                                             CompletionOnceCallback()),
+              IsOk());
+
+  HttpRequestInfo request3;
+  request3.method = "GET";
+  request3.url = url_;
+  request3.traffic_annotation =
+      MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS);
+  auto http_stream3 = std::make_unique<SpdyHttpStream>(
+      session_, net_log.source(), /*dns_aliases=*/std::set<std::string>());
+  http_stream3->RegisterRequest(&request3);
+  ASSERT_THAT(http_stream3->InitializeStream(true, LOWEST, net_log,
+                                             CompletionOnceCallback()),
+              IsOk());
+
+  HttpResponseInfo response1;
+  HttpResponseInfo response2;
+  HttpResponseInfo response3;
+  TestCompletionCallback callback1;
+  TestCompletionCallback callback2;
+  TestCompletionCallback callback3;
+  HttpRequestHeaders headers;
+
+  EXPECT_THAT(
+      http_stream1->SendRequest(headers, &response1, callback1.callback()),
+      IsError(ERR_IO_PENDING));
+  EXPECT_THAT(
+      http_stream2->SendRequest(headers, &response2, callback2.callback()),
+      IsError(ERR_IO_PENDING));
+  EXPECT_THAT(
+      http_stream3->SendRequest(headers, &response3, callback3.callback()),
+      IsError(ERR_IO_PENDING));
+
+  EXPECT_THAT(callback3.WaitForResult(), IsOk());
+
+  // Read response headers of http_stream3 first.
+  TestCompletionCallback headers_callback3;
+  int rv = http_stream3->ReadResponseHeaders(headers_callback3.callback());
+  if (rv == ERR_IO_PENDING) {
+    rv = headers_callback3.WaitForResult();
+  }
+  EXPECT_THAT(rv, IsOk());
+
+  // Wait until body3 (sequence 4) is read and buffered before we cancel other
+  // streams.
+  base::ByteSize received_bytes_after_headers =
+      http_stream3->GetTotalReceivedBytes();
+  ASSERT_TRUE(base::test::RunUntil([&]() {
+    return http_stream3->GetTotalReceivedBytes() > received_bytes_after_headers;
+  }));
+
+  // Cancel stream1 and stream2 to enqueue 2 capped frames (RST_STREAM).
+  // Do not run the message loop yet, so these remain in the write queue.
+  http_stream1->Close(true);
+  http_stream2->Close(true);
+
+  // Read response body of http_stream3. This triggers Dequeue, consuming data,
+  // which will try to send a WINDOW_UPDATE frame. Since the write queue already
+  // has 2 capped frames (rst1, rst2) and the limit is 1, this third capped
+  // frame triggers draining the session asynchronously.
+  auto buf = base::MakeRefCounted<IOBufferWithSize>(10);
+  TestCompletionCallback read_callback;
+  rv = http_stream3->ReadResponseBody(buf.get(), 10, read_callback.callback());
+
+  if (rv == ERR_IO_PENDING) {
+    rv = read_callback.WaitForResult();
+  }
+  EXPECT_GT(rv, 0);
+
+  // Wait for the posted DoDrainSession task to execute and close the stream.
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return http_stream3->IsResponseBodyComplete(); }));
+
+  sequenced_data_->Resume();
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return sequenced_data_->AllReadDataConsumed(); }));
+
+  EXPECT_TRUE(http_stream3->IsResponseBodyComplete());
+}
+
 // TODO(willchan): Write a longer test for SpdyStream that exercises all
 // methods.
Loading diff…

Original Bug Report

reported by [email protected]

Network Process Heap UAF in SpdyHttpStream::DoBufferedReadCallback

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential heap use-after-free vulnerability exists in Chrome’s Network Process when handling HTTP/2 streams. A malicious server can trigger a synchronous session drain during a buffered read by overflowing the capped frame queue, leading to the destruction of the SpdyHttpStream object while it is still executing on the stack.

Affected files:

  • net/spdy/spdy_http_stream.cc
  • net/spdy/spdy_read_queue.cc
  • net/spdy/spdy_session.cc
  • net/spdy/spdy_buffer.cc
  • base/containers/circular_deque.h
  • net/http/http_cache_writers.cc
  • net/http/http_cache_transaction.cc

Estimated timestamp from git blame: 2025-07-01

Vulnerability Summary

A potential heap Use-After-Free (UAF) vulnerability exists in the Network Process due to complex synchronous re-entrancy during HTTP/2 buffer consumption. A malicious server can carefully orchestrate connection states to cause a SpdyHttpStream to be destroyed synchronously while its asynchronous read callback is executing, leaving a dangling this pointer on the stack.

Technical Details

The vulnerability is rooted in how SpdyHttpStream::DoBufferedReadCallback manages data delivery and how SpdySession handles flow control and queue limits:

  1. SpdyHttpStream::DoBufferedReadCallback is an asynchronous task that consumes buffered data by calling response_body_queue_.Dequeue().
  2. When Dequeue finishes copying a SpdyBuffer, it pops it from the queue, destroying the SpdyBuffer.
  3. The ~SpdyBuffer destructor invokes its registered consume callbacks. One of these is SpdySession::OnReadBufferConsumed.
  4. SpdySession::OnReadBufferConsumed calls IncreaseRecvWindowSize. If more than 5 seconds have elapsed since the last window update, it synchronously constructs and sends a WINDOW_UPDATE frame via EnqueueSessionWrite.
  5. WINDOW_UPDATE frames are “capped frames”. EnqueueSessionWrite checks if the queue of capped frames exceeds session_max_queued_capped_frames_ (default 10,000).
  6. If an attacker has previously saturated this queue (e.g., by sending 10,001 PING frames while the client TCP write window is blocked), the new WINDOW_UPDATE frame pushes the session over the limit.
  7. Exceeding the limit triggers DoDrainSession(ERR_CONNECTION_CLOSED) synchronously.
  8. DoDrainSession closes all active streams, invoking SpdyHttpStream::OnClose(ERR_CONNECTION_CLOSED).
  9. SpdyHttpStream::OnClose immediately executes any pending response_callback_ with the error. This error propagates up to HttpNetworkTransaction (or HttpCache::Writers), which handles the failure by tearing down the transaction and deleting the SpdyHttpStream object.
  10. The stack then unwinds back to SpdyHttpStream::DoBufferedReadCallback. The this pointer is now dangling.
  11. The code subsequently executes user_buffer_ = nullptr (triggering scoped_refptr::Release()) and invokes DoResponseCallback(...) from the freed memory.

Because the attacker can reclaim the freed SpdyHttpStream memory via heap spraying, the Release() call on a forged IOBuffer pointer or the execution of the forged response_callback_ base::OnceCallback provides strong primitives for Remote Code Execution (RCE) in the Network Process.

Potential Reproduction Steps

Note: These are suggested theoretical steps as our tooling agent does not currently have the capability to execute a live proof-of-concept.

  1. A malicious HTTP/2 server establishes a connection with Chrome.
  2. The server advertises a TCP receive window of 0 to block Chrome from flushing its write socket.
  3. The server sends exactly 10,000 PING frames. Chrome queues 10,000 PING ACK frames, filling the SpdySession’s capped frame queue precisely to its limit.
  4. The server pauses for 5 seconds to exceed the time_to_buffer_small_window_updates_ threshold.
  5. The client initiates an HTTP request (fetch()). The server responds with headers, then sends a DATA frame with the response body.
  6. Chrome buffers the data and schedules SpdyHttpStream::DoBufferedReadCallback.
  7. During the callback’s execution, the DATA frame is consumed, which triggers a WINDOW_UPDATE.
  8. The new WINDOW_UPDATE pushes the queued capped frames to 10,001, triggering a synchronous DoDrainSession.
  9. The SpdyHttpStream is synchronously destroyed, and the unwinding stack uses the freed this pointer, resulting in a UAF.

Suggested Fix

There are a few ways to remediate this issue:

  1. Use WeakPtr in the Callback: Bind a base::WeakPtr to the SpdyHttpStream instance at the start of DoBufferedReadCallback. After response_body_queue_.Dequeue() returns, check if the WeakPtr has been invalidated before attempting to access user_buffer_, user_buffer_len_, or response_callback_.
  2. Asynchronous Session Draining: Change the behavior inside EnqueueSessionWrite so that exceeding the session_max_queued_capped_frames_ limit triggers DoDrainSessionAsync instead of the synchronous DoDrainSession, preventing deep re-entrant destruction paths.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.

View on issue tracker