CVE-2026-11651
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/spdy/bidirectional_stream_spdy_impl.cc |
modified | |
ifnet/spdy/spdy_http_stream.cc |
modified | |
TEST_Fnet/spdy/spdy_http_stream_unittest.cc |
modified |
Files Changed
net/spdy/bidirectional_stream_spdy_impl.ccnet/spdy/spdy_http_stream.ccnet/spdy/spdy_http_stream_unittest.cc
Patch
From f08d5d071e409f77af3dd135420dd5a98d8080eb Mon Sep 17 00:00:00 2001
From: Nidhi Jaju <nidhijaju@chromium.org>
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 <bashi@chromium.org>
Commit-Queue: Nidhi Jaju <nidhijaju@chromium.org>
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;
Regression Test / PoC
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.
Original Bug Report
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.ccnet/spdy/spdy_read_queue.ccnet/spdy/spdy_session.ccnet/spdy/spdy_buffer.ccbase/containers/circular_deque.hnet/http/http_cache_writers.ccnet/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:
SpdyHttpStream::DoBufferedReadCallbackis an asynchronous task that consumes buffered data by callingresponse_body_queue_.Dequeue().- When
Dequeuefinishes copying aSpdyBuffer, it pops it from the queue, destroying theSpdyBuffer. - The
~SpdyBufferdestructor invokes its registered consume callbacks. One of these isSpdySession::OnReadBufferConsumed. SpdySession::OnReadBufferConsumedcallsIncreaseRecvWindowSize. If more than 5 seconds have elapsed since the last window update, it synchronously constructs and sends aWINDOW_UPDATEframe viaEnqueueSessionWrite.WINDOW_UPDATEframes are “capped frames”.EnqueueSessionWritechecks if the queue of capped frames exceedssession_max_queued_capped_frames_(default 10,000).- If an attacker has previously saturated this queue (e.g., by sending 10,001
PINGframes while the client TCP write window is blocked), the newWINDOW_UPDATEframe pushes the session over the limit. - Exceeding the limit triggers
DoDrainSession(ERR_CONNECTION_CLOSED)synchronously. DoDrainSessioncloses all active streams, invokingSpdyHttpStream::OnClose(ERR_CONNECTION_CLOSED).SpdyHttpStream::OnCloseimmediately executes any pendingresponse_callback_with the error. This error propagates up toHttpNetworkTransaction(orHttpCache::Writers), which handles the failure by tearing down the transaction and deleting theSpdyHttpStreamobject.- The stack then unwinds back to
SpdyHttpStream::DoBufferedReadCallback. Thethispointer is now dangling. - The code subsequently executes
user_buffer_ = nullptr(triggeringscoped_refptr::Release()) and invokesDoResponseCallback(...)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.
- A malicious HTTP/2 server establishes a connection with Chrome.
- The server advertises a TCP receive window of 0 to block Chrome from flushing its write socket.
- The server sends exactly 10,000
PINGframes. Chrome queues 10,000PINGACK frames, filling theSpdySession’s capped frame queue precisely to its limit. - The server pauses for 5 seconds to exceed the
time_to_buffer_small_window_updates_threshold. - The client initiates an HTTP request (
fetch()). The server responds with headers, then sends aDATAframe with the response body. - Chrome buffers the data and schedules
SpdyHttpStream::DoBufferedReadCallback. - During the callback’s execution, the
DATAframe is consumed, which triggers aWINDOW_UPDATE. - The new
WINDOW_UPDATEpushes the queued capped frames to 10,001, triggering a synchronousDoDrainSession. - The
SpdyHttpStreamis synchronously destroyed, and the unwinding stack uses the freedthispointer, resulting in a UAF.
Suggested Fix
There are a few ways to remediate this issue:
- Use WeakPtr in the Callback: Bind a
base::WeakPtrto theSpdyHttpStreaminstance at the start ofDoBufferedReadCallback. Afterresponse_body_queue_.Dequeue()returns, check if theWeakPtrhas been invalidated before attempting to accessuser_buffer_,user_buffer_len_, orresponse_callback_. - Asynchronous Session Draining: Change the behavior inside
EnqueueSessionWriteso that exceeding thesession_max_queued_capped_frames_limit triggersDoDrainSessionAsyncinstead of the synchronousDoDrainSession, 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.