CVE-2026-78893
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/quic/quic_session_pool.cc |
modified | |
TEST_Pnet/quic/quic_session_pool_test.cc |
modified |
Files Changed
net/quic/quic_session_pool.ccnet/quic/quic_session_pool.hnet/quic/quic_session_pool_test.cc
Patch
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,
Regression Test / PoC
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;
Original Bug Report
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.ccnet/http/http_server_properties.hnet/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).
- Direct Connection: A user visits an attacker-controlled site directly (without a proxy). The user’s real IP is exposed.
- 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.
- Caching: Chrome saves this unique SRTT via
http_server_properties_->SetServerNetworkStats(), ignoring the directProxyChain. - 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.
- Leaking State: Chrome initiates the new QUIC session and reads the cached SRTT. It transmits the value
1234567in the clear within the0x3127QUIC transport parameter during the TLS ClientHello. - Deanonymization: The attacker’s server reads the transport parameter, recognizes the unique
1234567value, 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.
- Modify
HttpServerProperties::SetServerNetworkStatsandHttpServerProperties::GetServerNetworkStatsSmoothedRtt(and related internal methods) to accept aProxyChainparameter. - In
net/quic/quic_session_pool.cc, updateProcessGoingAwaySessionto passsession->quic_session_key().proxy_chain()when saving stats. - Update
ConfigureInitialRttEstimateand other read sites to supply the appropriateProxyChainwhen querying cached stats. - Ensure that any persistent storage of
HttpServerPropertiesproperly 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.