CVE-2026-12012
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
fornet/spdy/spdy_buffer.cc |
modified | |
ifnet/spdy/spdy_read_queue_unittest.cc |
modified | |
TEST_Fnet/spdy/spdy_read_queue_unittest.cc |
modified |
Files Changed
net/spdy/spdy_buffer.ccnet/spdy/spdy_read_queue_unittest.cc
Patch
From 71532a74a33965920c29b251380d51f0291da648 Mon Sep 17 00:00:00 2001 From: Adam Rice <[email protected]> Date: Fri, 29 May 2026 09:15:50 -0700 Subject: [PATCH] Fix use-after-free in SpdyBuffer consume callbacks Copy the consume callbacks vector to a local variable before iterating through it. Previously, SpdyBuffer iterated directly over its member vector of callbacks. If a consume callback reentrantly caused the SpdyBuffer to be destroyed, the member vector was also destroyed, leading to a use-after-free error when the loop attempted to access the next element or evaluate the iterator. Iterating over a local copy of the vector ensures that the iterator remains valid and the BindState of each callback is kept alive throughout the execution of the loop, safely handling cases where the buffer is freed mid-iteration. Bug: 499182801 Change-Id: I417ec0459069e6fb271b95d28e2d9dc80a869d7c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7885302 Commit-Queue: Adam Rice <[email protected]> Auto-Submit: Adam Rice <[email protected]> Reviewed-by: Maks Orlovich <[email protected]> Cr-Commit-Position: refs/heads/main@{#1638529} --- diff --git a/net/spdy/spdy_buffer.cc b/net/spdy/spdy_buffer.cc index 61d3900..eeea0309 100644 --- a/net/spdy/spdy_buffer.cc +++ b/net/spdy/spdy_buffer.cc @@ -104,10 +104,16 @@ DCHECK_GE(consume_size, 1u); DCHECK_LE(consume_size, GetRemainingSize()); offset_ += consume_size; - for (std::vector<ConsumeCallback>::const_iterator it = - consume_callbacks_.begin(); it != consume_callbacks_.end(); ++it) { - it->Run(consume_size, consume_source); + // Copy callbacks before iterating: a consume callback may cause `this` to be + // destroyed reentrantly. Iterating a local copy keeps the iterator valid and + // keeps each callback's BindState alive (via RepeatingCallback's + // scoped_refptr) even after `this` is freed. The callbacks themselves are + // WeakPtr-bound and tolerate the receiver being gone. + std::vector<ConsumeCallback> callbacks = consume_callbacks_; + for (const auto& callback : callbacks) { + callback.Run(consume_size, consume_source); } + // `this` may have been deleted here. } } // namespace net diff --git a/net/spdy/spdy_read_queue_unittest.cc b/net/spdy/spdy_read_queue_unittest.cc index 03e89802c..eb6c7037 100644 --- a/net/spdy/spdy_read_queue_unittest.cc +++ b/net/spdy/spdy_read_queue_unittest.cc @@ -5,10 +5,12 @@ #include "net/spdy/spdy_read_queue.h" #include <algorithm> +#include <array> #include <cstddef> #include <memory> #include <string> #include <utility> +#include <vector> #include "base/containers/heap_array.h" #include "base/containers/span.h" @@ -138,4 +140,55 @@ EXPECT_TRUE(read_queue.IsEmpty()); } +// Tests that calling Dequeue() reentrantly from within a consume callback +// does not cause a use-after-free when the SpdyBuffer is destroyed during +// the reentrant call. +namespace { + +void ReentrantDequeue(SpdyReadQueue* queue, + bool* fired, + size_t inner_buf_len, + size_t consume_size, + SpdyBuffer::ConsumeSource consume_source) { + if (*fired) { + return; + } + *fired = true; + + std::vector<uint8_t> inner_buf(inner_buf_len); + queue->Dequeue(inner_buf); +} + +} // namespace + +TEST_F(SpdyReadQueueTest, ReentrantDequeue) { + constexpr size_t kPayloadSize = 20; + constexpr size_t kUserBufLen = 12; + + std::array<uint8_t, kPayloadSize> payload = {}; + SpdyReadQueue queue; + auto buffer = + std::make_unique<SpdyBuffer>(base::span<const uint8_t>(payload)); + + bool reentry_fired = false; + + buffer->AddConsumeCallback(base::BindRepeating(&ReentrantDequeue, &queue, + &reentry_fired, kUserBufLen)); + // Add a second callback to ensure that the loop in ConsumeHelper continues + // and attempts to access the next callback after the buffer has been deleted. + int second_callback_called = 0; + buffer->AddConsumeCallback(base::BindRepeating( + [](int* counter, size_t, SpdyBuffer::ConsumeSource) { (*counter)++; }, + &second_callback_called)); + + queue.Enqueue(std::move(buffer)); + + std::array<uint8_t, kUserBufLen> user_buf; + size_t copied = queue.Dequeue(base::span<uint8_t>(user_buf)); + + EXPECT_EQ(copied, kUserBufLen); + EXPECT_TRUE(reentry_fired); + EXPECT_EQ(second_callback_called, 2); +} + } // namespace net::test
Regression Test / PoC
diff --git a/net/spdy/spdy_read_queue_unittest.cc b/net/spdy/spdy_read_queue_unittest.cc
index 03e89802c..eb6c7037 100644
--- a/net/spdy/spdy_read_queue_unittest.cc
+++ b/net/spdy/spdy_read_queue_unittest.cc
@@ -5,10 +5,12 @@
#include "net/spdy/spdy_read_queue.h"
#include <algorithm>
+#include <array>
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
+#include <vector>
#include "base/containers/heap_array.h"
#include "base/containers/span.h"
@@ -138,4 +140,55 @@
EXPECT_TRUE(read_queue.IsEmpty());
}
+// Tests that calling Dequeue() reentrantly from within a consume callback
+// does not cause a use-after-free when the SpdyBuffer is destroyed during
+// the reentrant call.
+namespace {
+
+void ReentrantDequeue(SpdyReadQueue* queue,
+ bool* fired,
+ size_t inner_buf_len,
+ size_t consume_size,
+ SpdyBuffer::ConsumeSource consume_source) {
+ if (*fired) {
+ return;
+ }
+ *fired = true;
+
+ std::vector<uint8_t> inner_buf(inner_buf_len);
+ queue->Dequeue(inner_buf);
+}
+
+} // namespace
+
+TEST_F(SpdyReadQueueTest, ReentrantDequeue) {
+ constexpr size_t kPayloadSize = 20;
+ constexpr size_t kUserBufLen = 12;
+
+ std::array<uint8_t, kPayloadSize> payload = {};
+ SpdyReadQueue queue;
+ auto buffer =
+ std::make_unique<SpdyBuffer>(base::span<const uint8_t>(payload));
+
+ bool reentry_fired = false;
+
+ buffer->AddConsumeCallback(base::BindRepeating(&ReentrantDequeue, &queue,
+ &reentry_fired, kUserBufLen));
+ // Add a second callback to ensure that the loop in ConsumeHelper continues
+ // and attempts to access the next callback after the buffer has been deleted.
+ int second_callback_called = 0;
+ buffer->AddConsumeCallback(base::BindRepeating(
+ [](int* counter, size_t, SpdyBuffer::ConsumeSource) { (*counter)++; },
+ &second_callback_called));
+
+ queue.Enqueue(std::move(buffer));
+
+ std::array<uint8_t, kUserBufLen> user_buf;
+ size_t copied = queue.Dequeue(base::span<uint8_t>(user_buf));
+
+ EXPECT_EQ(copied, kUserBufLen);
+ EXPECT_TRUE(reentry_fired);
+ EXPECT_EQ(second_callback_called, 2);
+}
+
} // namespace net::test
Original Bug Report
Potential Use-After-Free in SpdyBuffer::ConsumeHelper via Reentrant SpdyReadQueue::Dequeue
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 security team.
Overview: A potential Use-After-Free vulnerability exists in SpdyBuffer::ConsumeHelper due to a reentrant call to SpdyReadQueue::Dequeue during stream closure. An attacker controlling an HTTP/2 proxy server can trigger this reentrancy, freeing the SpdyBuffer while its consume_callbacks_ are being iterated, potentially leading to Remote Code Execution.
Affected files:
net/spdy/spdy_buffer.ccnet/spdy/spdy_read_queue.ccnet/spdy/spdy_proxy_client_socket.ccnet/spdy/spdy_session.cc
Estimated timestamp from git blame: 2025-07-01
Description
A potential Use-After-Free (UAF) vulnerability exists in the Chrome network stack, specifically within SpdyBuffer::ConsumeHelper in net/spdy/spdy_buffer.cc. The issue arises because ConsumeHelper iterates over its consume_callbacks_ vector and executes callbacks synchronously without a reentrancy guard or protection against the SpdyBuffer instance being destroyed mid-iteration.
Technical Analysis & Suggested Reproduction Steps
This vulnerability can be triggered via a reentrancy path through SpdyProxyClientSocket when handling incoming data from a malicious HTTP/2 proxy server.
An attacker could potentially trigger this by following these steps:
- Establish Proxy Connection: Set up a malicious HTTP/2 proxy server and establish a proxy connection with Chrome, creating a
SpdyProxyClientSocketand aSpdyStream. - Initiate Read: Wait for Chrome’s TLS stack or consumer to issue a read request via
SpdyProxyClientSocket::Read. The socket saves theuser_buffer_(sizeN) andread_callback_. - Saturate Write Queue: Advertise a TCP window of 0 to block Chrome from writing, and send 10,000 HTTP/2 PING frames. This fills the
SpdySession’swrite_queue_with capped PING ACK frames until it reachessession_max_queued_capped_frames_(10,000). - Send Malicious DATA Frame: Send a single DATA frame with a payload size
Pchosen such thatN < P ≤ 2N. - Process Data: Chrome receives the DATA frame, wraps it in a
SpdyBuffer, adds aSpdyStream::OnReadBufferConsumedcallback, and passes it toSpdyProxyClientSocket::OnDataReceived. - Outer Dequeue & Consume: Because
read_callback_is set,OnDataReceivedcallsPopulateUserReadBuffer→SpdyReadQueue::Dequeue. SinceP > N,Dequeuepartially consumes the buffer by copyingNbytes and callingbuffer->Consume(N), leaving the buffer at the front of the queue. - Callback Execution:
SpdyBuffer::ConsumecallsConsumeHelper, which enters aforloop overconsume_callbacks_. It executes the first callback:SpdyStream::OnReadBufferConsumed. - Trigger Session Drain:
OnReadBufferConsumedupdates the receive window, triggeringSpdySession::SendStreamWindowUpdate→EnqueueSessionWrite. Because the write queue is already full of PING ACKs (10,000 frames), the additional WINDOW_UPDATE frame exceeds the limit (10,001), triggeringSpdySession::DoDrainSession. - Synchronous Stream Closure:
DoDrainSessionsynchronously closes all streams, callingSpdyStream::OnClose(status), which invokes its delegate,SpdyProxyClientSocket::OnClose. - Reentrant OnDataReceived: Inside
SpdyProxyClientSocket::OnClose, sinceread_callback_is not null (the outerOnDataReceivedhasn’t cleared it yet), it synchronously callsOnDataReceived(nullptr). - Inner Dequeue & Buffer Free: The inner
OnDataReceivedseesuser_buffer_is still set and callsPopulateUserReadBuffer→SpdyReadQueue::Dequeueagain, with the same sizeN. The remaining size of the frontSpdyBufferisP - N. BecauseP ≤ 2N, the remaining size is≤ N.Dequeuecopies the remaining data and executesqueue_.pop_front(), destroying theSpdyBufferobject and freeing its memory. - Synchronous Heap Spray: The inner
OnDataReceivedfinishes and executesstd::move(read_callback_).Run(rv). The attacker’sP - Nbytes are passed to the consumer (e.g., the TLS stack). If these bytes are carefully crafted TLS records, they can be used to perform a synchronous, targeted heap spray during the callback execution, reclaiming the freedSpdyBufferand itsstd::vectorbacking store. - Use-After-Free Read & Execution: The call stack unwinds back to the outer
SpdyBuffer::ConsumeHelperforloop. Thethispointer is now dangling. The loop reads theend()iterator from the attacker-controlled reclaimed memory. The loop continues and callsit->Run(), executing a hijacked function pointer from a fakebase::RepeatingCallbackobject injected during the heap spray.
(Note: These are suggested steps based on source code analysis; our agent does not yet have the ability to run code to confirm a working exploit).
Impact
If successfully exploited, this vulnerability could allow an attacker to achieve arbitrary Remote Code Execution (RCE) in the Network Process. MiraclePtr does not mitigate this issue because the dangling references involve the this pointer and internal std::vector iterators rather than raw_ptr members.
Suggested Fix
There are a few potential ways to resolve this:
- PostTask for Callbacks: Instead of executing the consume callbacks synchronously in
SpdyBuffer::ConsumeHelper, post them to the thread’s task runner. This ensures the callbacks run after the current stack unwinds, preventing synchronous reentrancy. - Clear Socket State Earlier: In
SpdyProxyClientSocket::OnDataReceived, clearread_callback_,user_buffer_, anduser_buffer_len_before callingPopulateUserReadBuffer. This breaks the reentrancy loop by ensuring the innerOnClosecall does not see a pending read state. - Reentrancy Guard: Add a
bool is_consuming_flag toSpdyBufferto detect and block or queue reentrant consumes.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.