Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free Network
DescriptionUse after free Network
ComponentChromium
Bug ClassUAF
Tracker499182801
Fix commit71532a74a339 (chromium/src) +62/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-11

Changed Functions

FunctionChangeNotes
for
net/spdy/spdy_buffer.cc
modified
if
net/spdy/spdy_read_queue_unittest.cc
modified
TEST_F
net/spdy/spdy_read_queue_unittest.cc
modified

Files Changed

  • net/spdy/spdy_buffer.cc
  • net/spdy/spdy_read_queue_unittest.cc
From 71532a74a33965920c29b251380d51f0291da648 Mon Sep 17 00:00:00 2001
From: Adam Rice <ricea@chromium.org>
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 <ricea@chromium.org>
Auto-Submit: Adam Rice <ricea@chromium.org>
Reviewed-by: Maks Orlovich <morlovich@chromium.org>
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
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • net/spdy/spdy_read_queue.cc
  • net/spdy/spdy_proxy_client_socket.cc
  • net/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:

  1. Establish Proxy Connection: Set up a malicious HTTP/2 proxy server and establish a proxy connection with Chrome, creating a SpdyProxyClientSocket and a SpdyStream.
  2. Initiate Read: Wait for Chrome’s TLS stack or consumer to issue a read request via SpdyProxyClientSocket::Read. The socket saves the user_buffer_ (size N) and read_callback_.
  3. 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’s write_queue_ with capped PING ACK frames until it reaches session_max_queued_capped_frames_ (10,000).
  4. Send Malicious DATA Frame: Send a single DATA frame with a payload size P chosen such that N < P ≤ 2N.
  5. Process Data: Chrome receives the DATA frame, wraps it in a SpdyBuffer, adds a SpdyStream::OnReadBufferConsumed callback, and passes it to SpdyProxyClientSocket::OnDataReceived.
  6. Outer Dequeue & Consume: Because read_callback_ is set, OnDataReceived calls PopulateUserReadBufferSpdyReadQueue::Dequeue. Since P > N, Dequeue partially consumes the buffer by copying N bytes and calling buffer->Consume(N), leaving the buffer at the front of the queue.
  7. Callback Execution: SpdyBuffer::Consume calls ConsumeHelper, which enters a for loop over consume_callbacks_. It executes the first callback: SpdyStream::OnReadBufferConsumed.
  8. Trigger Session Drain: OnReadBufferConsumed updates the receive window, triggering SpdySession::SendStreamWindowUpdateEnqueueSessionWrite. Because the write queue is already full of PING ACKs (10,000 frames), the additional WINDOW_UPDATE frame exceeds the limit (10,001), triggering SpdySession::DoDrainSession.
  9. Synchronous Stream Closure: DoDrainSession synchronously closes all streams, calling SpdyStream::OnClose(status), which invokes its delegate, SpdyProxyClientSocket::OnClose.
  10. Reentrant OnDataReceived: Inside SpdyProxyClientSocket::OnClose, since read_callback_ is not null (the outer OnDataReceived hasn’t cleared it yet), it synchronously calls OnDataReceived(nullptr).
  11. Inner Dequeue & Buffer Free: The inner OnDataReceived sees user_buffer_ is still set and calls PopulateUserReadBufferSpdyReadQueue::Dequeue again, with the same size N. The remaining size of the front SpdyBuffer is P - N. Because P ≤ 2N, the remaining size is ≤ N. Dequeue copies the remaining data and executes queue_.pop_front(), destroying the SpdyBuffer object and freeing its memory.
  12. Synchronous Heap Spray: The inner OnDataReceived finishes and executes std::move(read_callback_).Run(rv). The attacker’s P - N bytes 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 freed SpdyBuffer and its std::vector backing store.
  13. Use-After-Free Read & Execution: The call stack unwinds back to the outer SpdyBuffer::ConsumeHelper for loop. The this pointer is now dangling. The loop reads the end() iterator from the attacker-controlled reclaimed memory. The loop continues and calls it->Run(), executing a hijacked function pointer from a fake base::RepeatingCallback object 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:

  1. 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.
  2. Clear Socket State Earlier: In SpdyProxyClientSocket::OnDataReceived, clear read_callback_, user_buffer_, and user_buffer_len_ before calling PopulateUserReadBuffer. This breaks the reentrancy loop by ensuring the inner OnClose call does not see a pending read state.
  3. Reentrancy Guard: Add a bool is_consuming_ flag to SpdyBuffer to 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.

View on issue tracker