Medium watch CVSS 6.5 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in QUIC
DescriptionInformation leak in QUIC
ComponentQUIC
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker495998981
Fix commitfce674fdc7d9 (chromium/src) +136/-19
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-06-29 (public git)
Disclosed2026-08-25
Patch gap57 days — fix public in source before the advisory

Background

`ServerNetworkStats`
a per-server cache in HttpServerProperties that holds a smoothed RTT (srtt) and a bandwidth_estimate. Per the commit message, it is keyed only by (SchemeHostPort, NetworkAnonymizationKey).
`ProxyChain`
the sequence of proxies a connection is tunneled through. ProxyChain::Direct() / is_direct() means no proxy, and the regression test uses a QUIC ProxyChain::ForIpProtection chain (MASQUE CONNECT-UDP).
Initial RTT estimate / waiting-job delay
QuicSessionPool seeds a new QUIC connection’s QuicConfig with a cached RTT (ConfigureInitialRttEstimate). It also uses about 1.5× the cached RTT to decide how long to delay a competing (waiting) job.
`QuicSessionKey`
the key for pooling QUIC sessions. It already includes the proxy chain, as does QuicCryptoClientConfigKey (mentioned in the commit message).

Root Cause Analysis

QuicSessionPool read and wrote ServerNetworkStats for every QUIC session, whether it was direct or tunneled. The stats store is not partitioned by ProxyChain, so the cache mixed measurements taken on different network paths. When a tunneled session went away after the handshake was confirmed, the going-away handler wrote its proxy-path srtt and bandwidth_estimate under the same key a direct connection would use, or on an unconfirmed handshake it cleared that key. In the other direction, ConfigureInitialRttEstimate and the waiting-job delay calculation (both via GetServerNetworkStatsSmoothedRtt) let a tunneled session pick up RTT data measured on the direct path. This broke the invariant, already applied to QuicCryptoClientConfigKey, that state learned over a proxy chain is kept separate from direct-path state.

The result is an information leak about the user’s real network path across the proxy boundary.

The fix passes the session’s ProxyChain into GetServerNetworkStatsSmoothedRtt, GetServerNetworkStatsSmoothedRttInMicroseconds and ConfigureInitialRttEstimate. GetServerNetworkStatsSmoothedRtt now returns nullptr for any non-direct chain before the lookup. The going-away path computes record_network_stats = proxy_chain().is_direct() and gates both SetServerNetworkStats and ClearServerNetworkStats on it. Together these changes stop reads and writes of these stats for tunneled sessions.

Key insight
A path-dependent measurement cache keyed without the proxy chain was shared between direct and proxied QUIC sessions. The fix limits ServerNetworkStats reads and writes to direct connections, so measurements from one path can no longer flow to the other.

Attack Path

  1. Establish both connection types The user’s browser connects over QUIC to the same destination both directly and through a QUIC proxy chain, such as IP Protection, under the same NetworkAnonymizationKey. This requires a proxy configuration or feature that tunnels QUIC; it is not the default for all browsing.
  2. Cross-path stats write When the tunneled session goes away after the handshake is confirmed, its proxy-path RTT and bandwidth are stored under the direct key. Alternatively, a direct session’s stats are already present in the cache.
  3. Cross-path stats read A later session on the other path calls GetServerNetworkStatsSmoothedRtt. The cached value seeds that session’s initial RTT in QuicConfig and its waiting-job delay.
  4. Destination observes the leaked value The destination server sees connection behavior influenced by the other path’s RTT. It may infer RTT or path characteristics that the proxy was meant to hide, helping it link proxied and direct visits (inferred; the diff does not show what the server can observe).

Impact Assessment

The advisory classes this as an information leak in QUIC. The affected logic runs in Chrome’s network stack (net/quic) and handles transport metadata only; there is no memory corruption. The likely beneficiary is a destination server, and possibly an on-path observer, who learns RTT or path characteristics across a proxy boundary. That could weaken the privacy goals of proxied or IP Protection connections (inferred). The preconditions are QUIC through a proxy chain plus a direct connection to the same destination with the same NetworkAnonymizationKey. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.

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.h
  • net/quic/quic_session_pool_test.cc
  • net/quic/quic_session_pool.cc

Audit Directions

  • Caches keyed without `ProxyChain`
    Review other HttpServerProperties entries (alternative services, broken-alt-service state, QUIC server info) and the session or socket pools. Look for state learned over a proxied connection that is later used by a direct one, or the reverse.
  • Path-dependent transport hints
    Audit any value that seeds a new connection from historical measurements, such as RTT, bandwidth, congestion state, 0-RTT tickets or MTU. Check that its cache key includes every dimension that changes the network path, including IP Protection and MASQUE chains.
  • Asymmetric read and write gating
    When one side of a shared cache is partitioned, check that both the setter (Set*/Clear*) and the getters are gated consistently. This patch had to fix both directions.
From fce674fdc7d9589dd379fed6625b227cf3be58a5 Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <bashi@chromium.org>
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 <bashi@chromium.org>
Reviewed-by: David Schinazi <dschinazi@chromium.org>
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,
+      /*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…

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 vm...@google.com

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