Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in QUIC
DescriptionInformation leak in QUIC
ComponentQUIC
Bug ClassLogic Error
Tracker495998981
Fix commitfce674fdc7d9 (chromium/src) +136/-19
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
net/quic/quic_session_pool.cc
modified
TEST_P
net/quic/quic_session_pool_test.cc
modified

Files Changed

  • net/quic/quic_session_pool.cc
  • net/quic/quic_session_pool.h
  • net/quic/quic_session_pool_test.cc
From fce674fdc7d9589dd379fed6625b227cf3be58a5 Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <[email protected]>
Date: Mon, 29 Jun 2026 16:38:55 -0700
Subject: [PATCH] Restrict QUIC ServerNetworkStats to direct connections

HttpServerProperties::ServerNetworkStats are keyed only by
(SchemeHostPort, NetworkAnonymizationKey). QuicSessionPool was reading
and writing these entries for tunneled sessions as well as direct ones,
so RTT/bandwidth observed over a proxy chain could be applied to a later
direct connection to the same destination (and a tunneled session could
pick up direct-path measurements as its initial RTT and waiting-job
delay).

Thread the session's ProxyChain through the ServerNetworkStats accessors
and ProcessGoingAwaySession, and skip both reads and writes when the
chain is not direct, mirroring the existing ProxyChain partitioning of
QuicCryptoClientConfigKey.

Add a regression test that creates a tunneled destination session, marks
it going away, and verifies that no stats are recorded under the direct
key and that GetTimeDelayForWaitingJob differs for direct vs tunneled
session keys.

Bug: 495998981
Change-Id: I8f59508a0210ff56b5c3bcb1405b82b8a9439330
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017962
Commit-Queue: Kenichi Ishibashi <[email protected]>
Reviewed-by: David Schinazi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1654458}
---

diff --git a/net/quic/quic_session_pool.cc b/net/quic/quic_session_pool.cc
index 59b0924..91de752 100644
--- a/net/quic/quic_session_pool.cc
+++ b/net/quic/quic_session_pool.cc
@@ -1580,7 +1580,8 @@
 
   int64_t srtt = 1.5 * GetServerNetworkStatsSmoothedRttInMicroseconds(
                            session_key.server_id(),
-                           session_key.network_anonymization_key());
+                           session_key.network_anonymization_key(),
+                           session_key.proxy_chain());
   // Picked 300ms based on mean time from
   // Net.QuicSession.HostResolution.HandshakeConfirmedTime histogram.
   const int kDefaultRTT = 300 * quic::kNumMicrosPerMilli;
@@ -2120,8 +2121,9 @@
   connection->SetMaxPacketLength(max_packet_length);
 
   quic::QuicConfig config = config_;
-  ConfigureInitialRttEstimate(
-      server_id, key.session_key().network_anonymization_key(), &config);
+  ConfigureInitialRttEstimate(server_id,
+                              key.session_key().network_anonymization_key(),
+                              key.session_key().proxy_chain(), &config);
 
   if (params_.enable_debugging_sni_in_transport_param &&
       IsGoogleHost(server_id.host())) {
@@ -2256,9 +2258,10 @@
 void QuicSessionPool::ConfigureInitialRttEstimate(
     const quic::QuicServerId& server_id,
     const NetworkAnonymizationKey& network_anonymization_key,
+    const ProxyChain& proxy_chain,
     quic::QuicConfig* config) {
-  const base::TimeDelta* srtt =
-      GetServerNetworkStatsSmoothedRtt(server_id, network_anonymization_key);
+  const base::TimeDelta* srtt = GetServerNetworkStatsSmoothedRtt(
+      server_id, network_anonymization_key, proxy_chain);
   // Sometimes *srtt is negative. See https://crbug.com/1225616.
   // TODO(ricea): When the root cause of the negative value is fixed, change the
   // non-negative assertion to a DCHECK.
@@ -2291,15 +2294,22 @@
 
 int64_t QuicSessionPool::GetServerNetworkStatsSmoothedRttInMicroseconds(
     const quic::QuicServerId& server_id,
-    const NetworkAnonymizationKey& network_anonymization_key) const {
-  const base::TimeDelta* srtt =
-      GetServerNetworkStatsSmoothedRtt(server_id, network_anonymization_key);
+    const NetworkAnonymizationKey& network_anonymization_key,
+    const ProxyChain& proxy_chain) const {
+  const base::TimeDelta* srtt = GetServerNetworkStatsSmoothedRtt(
+      server_id, network_anonymization_key, proxy_chain);
   return srtt == nullptr ? 0 : srtt->InMicroseconds();
 }
 
 const base::TimeDelta* QuicSessionPool::GetServerNetworkStatsSmoothedRtt(
     const quic::QuicServerId& server_id,
-    const NetworkAnonymizationKey& network_anonymization_key) const {
+    const NetworkAnonymizationKey& network_anonymization_key,
+    const ProxyChain& proxy_chain) const {
+  // ServerNetworkStats are not partitioned by proxy chain, so only use them
+  // for direct connections to avoid mixing measurements from different paths.
+  if (!proxy_chain.is_direct()) {
+    return nullptr;
+  }
   url::SchemeHostPort server("https", server_id.host(), server_id.port());
   const ServerNetworkStats* stats =
       http_server_properties_->GetServerNetworkStats(server,
@@ -2442,21 +2452,31 @@
     return;
   }
 
+  // ServerNetworkStats are not partitioned by proxy chain, so only record
+  // them for direct connections to avoid mixing measurements from different
+  // paths.
+  const bool record_network_stats =
+      session->quic_session_key().proxy_chain().is_direct();
+
   if (session->OneRttKeysAvailable()) {
     http_server_properties_->ConfirmAlternativeService(
         alternative_service,
         session->quic_session_key().network_anonymization_key());
-    ServerNetworkStats network_stats;
-    network_stats.srtt = base::Microseconds(stats.srtt_us);
-    network_stats.bandwidth_estimate = stats.estimated_bandwidth;
-    http_server_properties_->SetServerNetworkStats(
-        server, session->quic_session_key().network_anonymization_key(),
-        network_stats);
+    if (record_network_stats) {
+      ServerNetworkStats network_stats;
+      network_stats.srtt = base::Microseconds(stats.srtt_us);
+      network_stats.bandwidth_estimate = stats.estimated_bandwidth;
+      http_server_properties_->SetServerNetworkStats(
+          server, session->quic_session_key().network_anonymization_key(),
+          network_stats);
+    }
     return;
   }
 
-  http_server_properties_->ClearServerNetworkStats(
-      server, session->quic_session_key().network_anonymization_key());
+  if (record_network_stats) {
+    http_server_properties_->ClearServerNetworkStats(
+        server, session->quic_session_key().network_anonymization_key());
+  }
 
   UMA_HISTOGRAM_COUNTS_1M("Net.QuicHandshakeNotConfirmedNumPacketsReceived",
                           stats.packets_received);
diff --git a/net/quic/quic_session_pool.h b/net/quic/quic_session_pool.h
index f29e689..d40d0d0 100644
--- a/net/quic/quic_session_pool.h
+++ b/net/quic/quic_session_pool.h
@@ -730,6 +730,7 @@
   void ConfigureInitialRttEstimate(
       const quic::QuicServerId& server_id,
       const NetworkAnonymizationKey& network_anonymization_key,
+      const ProxyChain& proxy_chain,
       quic::QuicConfig* config);
 
   // Returns |srtt| in micro seconds from ServerNetworkStats. Returns 0 if there
@@ -737,14 +738,16 @@
   // have ServerNetworkStats for the given |server_id|.
   int64_t GetServerNetworkStatsSmoothedRttInMicroseconds(
       const quic::QuicServerId& server_id,
-      const NetworkAnonymizationKey& network_anonymization_key) const;
+      const NetworkAnonymizationKey& network_anonymization_key,
+      const ProxyChain& proxy_chain) const;
 
   // Returns |srtt| from ServerNetworkStats. Returns null if there
   // is no |http_server_properties_| or if |http_server_properties_| doesn't
   // have ServerNetworkStats for the given |server_id|.
   const base::TimeDelta* GetServerNetworkStatsSmoothedRtt(
       const quic::QuicServerId& server_id,
-      const NetworkAnonymizationKey& network_anonymization_key) const;
+      const NetworkAnonymizationKey& network_anonymization_key,
+      const ProxyChain& proxy_chain) const;
 
   // Helper methods.
   bool WasQuicRecentlyBroken(const QuicSessionKey& session_key) const;
diff --git a/net/quic/quic_session_pool_test.cc b/net/quic/quic_session_pool_test.cc
index 35a3176..b602068 100644
--- a/net/quic/quic_session_pool_test.cc
+++ b/net/quic/quic_session_pool_test.cc
@@ -1426,6 +1426,100 @@
   }
 }
 
+// QUIC sessions tunneled through a proxy chain do not share ServerNetworkStats
+// with direct sessions to the same destination, since the observed RTT depends
+// on the path.
+TEST_P(QuicSessionPoolTest, ServerNetworkStatsProxyChain) {
+  Initialize();
+
+  GURL proxy(kProxy1Url);
+  auto proxy_origin = url::SchemeHostPort(proxy);
+  auto proxy_chain = ProxyChain::ForIpProtection({
+      ProxyServer::FromSchemeHostAndPort(ProxyServer::SCHEME_QUIC,
+                                         proxy_origin.host(), 443),
+  });
+  ASSERT_TRUE(proxy_chain.IsValid());
+
+  ProofVerifyDetailsChromium verify_details = DefaultProofVerifyDetails();
+  crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details);
+  crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details);
+  client_maker_.set_use_priority_header(false);
+
+  QuicTestPacketMaker endpoint_maker(
+      version_,
+      quic::QuicUtils::CreateRandomConnectionId(context_.random_generator()),
+      context_.clock(), kDefaultServerHostName, quic::Perspective::IS_CLIENT,
+      /*client_priority_uses_incremental=*/true,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/quic/quic_session_pool_test.cc b/net/quic/quic_session_pool_test.cc
index 35a3176..b602068 100644
--- a/net/quic/quic_session_pool_test.cc
+++ b/net/quic/quic_session_pool_test.cc
@@ -1426,6 +1426,100 @@
   }
 }
 
+// QUIC sessions tunneled through a proxy chain do not share ServerNetworkStats
+// with direct sessions to the same destination, since the observed RTT depends
+// on the path.
+TEST_P(QuicSessionPoolTest, ServerNetworkStatsProxyChain) {
+  Initialize();
+
+  GURL proxy(kProxy1Url);
+  auto proxy_origin = url::SchemeHostPort(proxy);
+  auto proxy_chain = ProxyChain::ForIpProtection({
+      ProxyServer::FromSchemeHostAndPort(ProxyServer::SCHEME_QUIC,
+                                         proxy_origin.host(), 443),
+  });
+  ASSERT_TRUE(proxy_chain.IsValid());
+
+  ProofVerifyDetailsChromium verify_details = DefaultProofVerifyDetails();
+  crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details);
+  crypto_client_stream_factory_.AddProofVerifyDetails(&verify_details);
+  client_maker_.set_use_priority_header(false);
+
+  QuicTestPacketMaker endpoint_maker(
+      version_,
+      quic::QuicUtils::CreateRandomConnectionId(context_.random_generator()),
+      context_.clock(), kDefaultServerHostName, quic::Perspective::IS_CLIENT,
+      /*client_priority_uses_incremental=*/true,
+      /*use_priority_header=*/true);
+
+  const uint64_t stream_id = GetNthClientInitiatedBidirectionalStreamId(0);
+  MockQuicData socket_data(version_);
+  socket_data.AddWrite(SYNCHRONOUS, ConstructInitialSettingsPacket(1));
+  socket_data.AddWrite(
+      SYNCHRONOUS, ConstructConnectUdpRequestPacket(
+                       2, stream_id, proxy.GetHost(),
+                       "/.well-known/masque/udp/www.example.org/443/", false));
+  socket_data.AddRead(ASYNC, ConstructServerSettingsPacket(3));
+  socket_data.AddRead(ASYNC, ConstructOkResponsePacket(4, stream_id, true));
+  socket_data.AddReadPauseForever();
+  socket_data.AddWrite(ASYNC,
+                       client_maker_.Packet(3).AddAckFrame(3, 4, 3).Build());
+  socket_data.AddWrite(ASYNC, ConstructClientH3DatagramPacket(
+                                  4, stream_id, kConnectUdpContextId,
+                                  endpoint_maker.MakeInitialSettingsPacket(1)));
+  socket_data.AddSocketDataToFactory(socket_factory_.get());
+
+  RequestBuilder builder(this);
+  builder.proxy_chain = proxy_chain;
+  builder.http_user_agent_settings = &http_user_agent_settings_;
+  EXPECT_EQ(ERR_IO_PENDING, builder.CallRequest());
+  ASSERT_THAT(callback_.WaitForResult(), IsOk());
+  std::unique_ptr<HttpStream> stream = CreateStream(&builder.request);
+  EXPECT_TRUE(stream.get());
+  ASSERT_TRUE(base::test::RunUntil(
+      [&]() { return socket_data.AllWriteDataConsumed(); }));
+
+  QuicChromiumClientSession* session =
+      GetActiveSession(kDefaultDestination, PRIVACY_MODE_DISABLED,
+                       NetworkAnonymizationKey(), proxy_chain);
+  session->OnHttp3GoAway(0);
+  EXPECT_FALSE(HasActiveSession(kDefaultDestination, PRIVACY_MODE_DISABLED,
+                                NetworkAnonymizationKey(), proxy_chain));
+
+  // Stats from the tunneled session must not be recorded under the key that a
+  // direct connection to the same destination would use.
+  EXPECT_FALSE(http_server_properties_->GetServerNetworkStats(
+      url::SchemeHostPort(GURL(kDefaultUrl)), NetworkAnonymizationKey()));
+
+  // Now record stats for a direct connection and verify that the tunneled path
+  // does not consume them when computing the waiting-job delay.
+  ServerNetworkStats direct_stats;
+  direct_stats.srtt = base::Milliseconds(10);
+  http_server_properties_->SetServerNetworkStats(
+      url::SchemeHostPort(GURL(kDefaultUrl)), NetworkAnonymizationKey(),
+      direct_stats);
+  base::TimeDelta direct_delay =
+      pool_->GetTimeDelayForWaitingJob(QuicSessionKey(
+          kDefaultServerHostName, kDefaultServerPort, PRIVACY_MODE_DISABLED,
+          ProxyChain::Direct(), SessionUsage::kDestination, SocketTag(),
+          NetworkAnonymizationKey(), SecureDnsPolicy::kAllow,
+          /*require_dns_https_alpn=*/false,
+          /*disable_cert_verification_network_fetches=*/false,
+          handles::kInvalidNetworkHandle));
+  base::TimeDelta proxied_delay =
+      pool_->GetTimeDelayForWaitingJob(QuicSessionKey(
+          kDefaultServerHostName, kDefaultServerPort, PRIVACY_MODE_DISABLED,
+          proxy_chain, SessionUsage::kDestination, SocketTag(),
+          NetworkAnonymizationKey(), SecureDnsPolicy::kAllow,
+          /*require_dns_https_alpn=*/false,
+          /*disable_cert_verification_network_fetches=*/false,
+          handles::kInvalidNetworkHandle));
+  EXPECT_NE(direct_delay, proxied_delay);
+
+  socket_data.ExpectAllReadDataConsumed();
+  socket_data.ExpectAllWriteDataConsumed();
+}
+
 TEST_P(QuicSessionPoolTest, MemoryPressureGlobalExclusion) {
   base::MemoryPressureListenerRegistry memory_pressure_listener_registry;
   base::test::ScopedFeatureList scoped_feature_list;
Loading diff…

Original Bug Report

reported by [email protected]

Potential cross-proxy tracking via QUIC network stats cache in HttpServerProperties

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Chrome’s QuicSessionPool caches QUIC network metrics (like SRTT) in HttpServerProperties without factoring the ProxyChain into the cache key. An attacker-controlled server can imprint a unique SRTT on a direct connection, which Chrome will then leak in the clear during a subsequent proxied connection via the 0x3127 QUIC transport parameter. This allows the server to correlate the user’s real IP address with their anonymous proxied connection, bypassing privacy protections.

Affected files:

  • net/quic/quic_session_pool.cc
  • net/http/http_server_properties.h
  • net/quic/quic_session_pool.h

Estimated timestamp from git blame: 2022-09-28

Vulnerability Details

In net/quic/quic_session_pool.cc, when a QUIC session is torn down, QuicSessionPool::ProcessGoingAwaySession caches network performance metrics (such as Smoothed Round Trip Time - SRTT, and estimated bandwidth) to optimize future connections to the same server.

However, these statistics are keyed in HttpServerProperties using only the destination url::SchemeHostPort and the NetworkAnonymizationKey (NAK). Crucially, the cache mechanism ignores the ProxyChain associated with the QuicSessionKey.

Because the ProxyChain is omitted from the cache key, stats gathered during a direct connection are applied to subsequent connections made through an anonymity-preserving proxy (such as Chrome’s IP Protection or a proxy extension), provided the NAK matches.

When a new proxied session is established, QuicSessionPool::ConfigureInitialRttEstimate reads the cached SRTT and applies it to the QuicConfig. During the TLS ClientHello, this exact microsecond-precision SRTT is sent to the server in the clear as the QUIC transport parameter 0x3127 (kInitialRoundTripTime). This acts as an explicit state transfer (a “supercookie”) that easily crosses the proxy boundary.

Potential Attack Steps

(Note: These are suggested steps based on static analysis, as our setup cannot actively run code to provide a dynamic PoC).

  1. Direct Connection: A user visits an attacker-controlled site directly (without a proxy). The user’s real IP is exposed.
  2. Imprinting: The attacker’s server intentionally delays its QUIC handshakes or ACKs by a highly specific, unique duration (e.g., exactly 1,234,567 microseconds) to establish a high-entropy SRTT unique to this user.
  3. Caching: Chrome saves this unique SRTT via http_server_properties_->SetServerNetworkStats(), ignoring the direct ProxyChain.
  4. Proxied Connection: The user later connects to the exact same attacker site but routing through a privacy-preserving proxy (e.g., Chrome IP Protection). The NAK remains the same.
  5. Leaking State: Chrome initiates the new QUIC session and reads the cached SRTT. It transmits the value 1234567 in the clear within the 0x3127 QUIC transport parameter during the TLS ClientHello.
  6. Deanonymization: The attacker’s server reads the transport parameter, recognizes the unique 1234567 value, and definitively links the user’s previously exposed real IP address to their new, supposedly anonymous proxied connection.

Suggested Fix

Update the HttpServerProperties API for network stats caching to partition data by ProxyChain in addition to SchemeHostPort and NetworkAnonymizationKey.

  1. Modify HttpServerProperties::SetServerNetworkStats and HttpServerProperties::GetServerNetworkStatsSmoothedRtt (and related internal methods) to accept a ProxyChain parameter.
  2. In net/quic/quic_session_pool.cc, update ProcessGoingAwaySession to pass session->quic_session_key().proxy_chain() when saving stats.
  3. Update ConfigureInitialRttEstimate and other read sites to supply the appropriate ProxyChain when querying cached stats.
  4. Ensure that any persistent storage of HttpServerProperties properly serializes and isolates these entries based on the proxy chain.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker