CVE-2026-79028
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
fornet/http/http_network_transaction.cc |
modified | |
ifnet/http/http_network_transaction.cc |
modified | |
ifnet/network_error_logging/network_error_logging_service.cc |
modified | |
TEST_Pnet/network_error_logging/network_error_logging_service_unittest.cc |
modified |
Files Changed
net/http/http_network_transaction.ccnet/http/http_network_transaction_unittest.ccnet/network_error_logging/network_error_logging_service.ccnet/network_error_logging/network_error_logging_service.hnet/network_error_logging/network_error_logging_service_unittest.cc
Patch
From c52ce5c11e9bd2e0581e05771eb42df018d59b1c Mon Sep 17 00:00:00 2001 From: Shunya Shishido <[email protected]> Date: Thu, 02 Jul 2026 21:13:47 -0700 Subject: [PATCH] NEL: consider all attempted addresses in report downgrade When a hostname resolves to multiple addresses, HttpNetworkTransaction::GenerateNetworkErrorLoggingReport() only reported the last attempted address as RequestDetails::server_ip. NetworkErrorLoggingService then compared only that single address against the policy's received_ip_address when deciding whether to downgrade the report to dns.address_changed, so the downgrade was skipped whenever the last attempted address happened to match the policy address even though other addresses had also been contacted. This CL plumbs the full set of contacted addresses through to the NEL service: * RequestDetails gains an `other_server_ips` field. * HttpNetworkTransaction populates it from `connection_attempts_`. * NetworkErrorLoggingService downgrades the report if `server_ip` or any of `other_server_ips` differs from the policy's `received_ip_address`. TAG=agy CONV=0627b279-d6cf-4c08-953e-131bdc2b4ee7 Bug: 500492844 Change-Id: I936dd6802a1552db699fc2324bd9288338614143 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8030100 Reviewed-by: Adam Rice <[email protected]> Commit-Queue: Shunya Shishido <[email protected]> Cr-Commit-Position: refs/heads/main@{#1656349} --- diff --git a/net/http/http_network_transaction.cc b/net/http/http_network_transaction.cc index f36a98f4..129fe65 100644 --- a/net/http/http_network_transaction.cc +++ b/net/http/http_network_transaction.cc @@ -1942,6 +1942,14 @@ } else { details.server_ip = IPAddress(); } + // Also report any other addresses that were contacted, so that the downgrade + // step can take all of them into account when the resolved address list + // contained more than one address. + for (const auto& attempt : connection_attempts_) { + if (attempt.endpoint.address() != details.server_ip) { + details.other_server_ips.push_back(attempt.endpoint.address()); + } + } // HttpResponseHeaders::response_code() returns 0 if response code couldn't // be parsed, which is also how NEL represents the same. if (response_.headers) { diff --git a/net/http/http_network_transaction_unittest.cc b/net/http/http_network_transaction_unittest.cc index 0ee6912..7de336b 100644 --- a/net/http/http_network_transaction_unittest.cc +++ b/net/http/http_network_transaction_unittest.cc @@ -24738,6 +24738,44 @@ } TEST_P(HttpNetworkTransactionNetworkErrorLoggingTest, + CreateReportErrorAfterStartMultipleAddresses) { + const IPAddress kFirstAddress(1, 2, 3, 4); + const IPAddress kSecondAddress(5, 6, 7, 8); + session_deps_.host_resolver->rules()->AddRule(GURL(url_).GetHost(), + "1.2.3.4,5.6.7.8"); + + std::unique_ptr<HttpNetworkSession> session(CreateSession(&session_deps_)); + auto trans = + std::make_unique<HttpNetworkTransaction>(DEFAULT_PRIORITY, session.get()); + + StaticSocketDataProvider data1; + data1.set_connect_data(MockConnect(SYNCHRONOUS, ERR_CONNECTION_REFUSED)); + session_deps_.socket_factory->AddSocketDataProvider(&data1); + StaticSocketDataProvider data2; + data2.set_connect_data(MockConnect(SYNCHRONOUS, ERR_CONNECTION_REFUSED)); + session_deps_.socket_factory->AddSocketDataProvider(&data2); + + TestCompletionCallback callback; + + int rv = trans->Start(&request_, callback.callback(), NetLogWithSource()); + EXPECT_THAT(callback.GetResult(rv), IsError(ERR_CONNECTION_REFUSED)); + + trans.reset(); + + ASSERT_EQ(1u, network_error_logging_service()->errors().size()); + const NetworkErrorLoggingService::RequestDetails& error = + network_error_logging_service()->errors()[0]; + EXPECT_EQ(0, error.status_code); + EXPECT_EQ(ERR_CONNECTION_REFUSED, error.type); + // Both resolved addresses were attempted, so both should be reported: one as + // `server_ip` and the other in `other_server_ips`. + std::vector<IPAddress> all_ips = error.other_server_ips; + all_ips.push_back(error.server_ip); + EXPECT_THAT(all_ips, + testing::UnorderedElementsAre(kFirstAddress, kSecondAddress)); +} + +TEST_P(HttpNetworkTransactionNetworkErrorLoggingTest, CreateReportReadBodyError) { std::string extra_header_string = extra_headers_.ToString(); MockWrite data_writes[] = { diff --git a/net/network_error_logging/network_error_logging_service.cc b/net/network_error_logging/network_error_logging_service.cc index 4916183e..7006226 100644 --- a/net/network_error_logging/network_error_logging_service.cc +++ b/net/network_error_logging/network_error_logging_service.cc @@ -486,9 +486,16 @@ // If the server that handled the request is different than the server that // delivered the NEL policy (as determined by their IP address), then we // have to "downgrade" the NEL report, so that it only includes information - // about DNS resolution. - if (phase_string != kDnsPhase && details.server_ip.IsValid() && - details.server_ip != policy->received_ip_address) { + // about DNS resolution. This also applies if any other address contacted + // during the request differs from the policy's address, since the report + // would otherwise reflect the behaviour of those addresses too. + bool server_ip_changed = + (details.server_ip.IsValid() && + details.server_ip != policy->received_ip_address) || + std::ranges::any_of(details.other_server_ips, [&](const auto& ip) { + return ip != policy->received_ip_address; + }); + if (phase_string != kDnsPhase && server_ip_changed) { phase_string = kDnsPhase; type_string = kDnsAddressChangedType; details.elapsed_time = base::TimeDelta(); diff --git a/net/network_error_logging/network_error_logging_service.h b/net/network_error_logging/network_error_logging_service.h index 913f94e..2406287 100644 --- a/net/network_error_logging/network_error_logging_service.h +++ b/net/network_error_logging/network_error_logging_service.h @@ -129,6 +129,13 @@ GURL referrer; std::string user_agent; IPAddress server_ip; + // Addresses other than `server_ip` that were also contacted while + // establishing the connection (e.g., earlier addresses in the resolved + // address list that the socket layer attempted before falling back). Not + // included in the uploaded report. Used when deciding whether to downgrade + // the report: the report is downgraded if any of these differ from the + // policy's `received_ip_address`. + std::vector<IPAddress> other_server_ips; std::string protocol; std::string method; int status_code; diff --git a/net/network_error_logging/network_error_logging_service_unittest.cc b/net/network_error_logging/network_error_logging_service_unittest.cc index 06b65d9..a8420a8 100644 --- a/net/network_error_logging/network_error_logging_service_unittest.cc +++ b/net/network_error_logging/network_error_logging_service_unittest.cc @@ -705,6 +705,68 @@ "dns.address_changed")))); } +TEST_P(NetworkErrorLoggingServiceTest, FailureReportDowngradedOtherServerIp) { + service()->OnHeader(kNak_, kOrigin_, kServerIP_, kHeaderSuccessFraction1_); + + // Make the rest of the test run synchronously. + FinishLoading(/*load_success=*/true); + + // `server_ip` matches the policy's address, but the request also contacted + // a different address. The report should still be downgraded. + NetworkErrorLoggingService::RequestDetails details = MakeRequestDetails( + kNak_, kUrl_, ERR_CONNECTION_REFUSED, "GET", 0, kServerIP_); + details.other_server_ips = {kOtherServerIP_}; + service()->OnRequest(std::move(details)); + + ASSERT_EQ(1u, reports().size()); + EXPECT_EQ(kUrl_, reports()[0].url); + EXPECT_EQ(kNak_, reports()[0].network_anonymization_key); + EXPECT_EQ(kGroup_, reports()[0].group); + EXPECT_EQ(kType_, reports()[0].type); + EXPECT_EQ(0, reports()[0].depth); + + EXPECT_THAT( + reports()[0].body, + Pointee(IsSupersetOfValue( + base::DictValue() + .Set(NetworkErrorLoggingService::kReferrerKey, kReferrer_.spec()) + .Set(NetworkErrorLoggingService::kSamplingFractionKey, 1.0) + .Set(NetworkErrorLoggingService::kServerIpKey, + kServerIP_.ToString()) + .Set(NetworkErrorLoggingService::kProtocolKey, "") + .Set(NetworkErrorLoggingService::kMethodKey, "GET") + .Set(NetworkErrorLoggingService::kStatusCodeKey, 0) + .Set(NetworkErrorLoggingService::kElapsedTimeKey, 0) + .Set(NetworkErrorLoggingService::kPhaseKey, "dns") + .Set(NetworkErrorLoggingService::kTypeKey, + "dns.address_changed")))); +} + +TEST_P(NetworkErrorLoggingServiceTest, + FailureReportNotDowngradedSameOtherServerIp) { + service()->OnHeader(kNak_, kOrigin_, kServerIP_, kHeaderSuccessFraction1_); + + // Make the rest of the test run synchronously. + FinishLoading(/*load_success=*/true); + + // `server_ip` matches the policy's address, and the only other contacted + // address is the same. The report should not be downgraded. + NetworkErrorLoggingService::RequestDetails details = MakeRequestDetails(
Regression Test / PoC
diff --git a/net/http/http_network_transaction_unittest.cc b/net/http/http_network_transaction_unittest.cc
index 0ee6912..7de336b 100644
--- a/net/http/http_network_transaction_unittest.cc
+++ b/net/http/http_network_transaction_unittest.cc
@@ -24738,6 +24738,44 @@
}
TEST_P(HttpNetworkTransactionNetworkErrorLoggingTest,
+ CreateReportErrorAfterStartMultipleAddresses) {
+ const IPAddress kFirstAddress(1, 2, 3, 4);
+ const IPAddress kSecondAddress(5, 6, 7, 8);
+ session_deps_.host_resolver->rules()->AddRule(GURL(url_).GetHost(),
+ "1.2.3.4,5.6.7.8");
+
+ std::unique_ptr<HttpNetworkSession> session(CreateSession(&session_deps_));
+ auto trans =
+ std::make_unique<HttpNetworkTransaction>(DEFAULT_PRIORITY, session.get());
+
+ StaticSocketDataProvider data1;
+ data1.set_connect_data(MockConnect(SYNCHRONOUS, ERR_CONNECTION_REFUSED));
+ session_deps_.socket_factory->AddSocketDataProvider(&data1);
+ StaticSocketDataProvider data2;
+ data2.set_connect_data(MockConnect(SYNCHRONOUS, ERR_CONNECTION_REFUSED));
+ session_deps_.socket_factory->AddSocketDataProvider(&data2);
+
+ TestCompletionCallback callback;
+
+ int rv = trans->Start(&request_, callback.callback(), NetLogWithSource());
+ EXPECT_THAT(callback.GetResult(rv), IsError(ERR_CONNECTION_REFUSED));
+
+ trans.reset();
+
+ ASSERT_EQ(1u, network_error_logging_service()->errors().size());
+ const NetworkErrorLoggingService::RequestDetails& error =
+ network_error_logging_service()->errors()[0];
+ EXPECT_EQ(0, error.status_code);
+ EXPECT_EQ(ERR_CONNECTION_REFUSED, error.type);
+ // Both resolved addresses were attempted, so both should be reported: one as
+ // `server_ip` and the other in `other_server_ips`.
+ std::vector<IPAddress> all_ips = error.other_server_ips;
+ all_ips.push_back(error.server_ip);
+ EXPECT_THAT(all_ips,
+ testing::UnorderedElementsAre(kFirstAddress, kSecondAddress));
+}
+
+TEST_P(HttpNetworkTransactionNetworkErrorLoggingTest,
CreateReportReadBodyError) {
std::string extra_header_string = extra_headers_.ToString();
MockWrite data_writes[] = {
diff --git a/net/network_error_logging/network_error_logging_service_unittest.cc b/net/network_error_logging/network_error_logging_service_unittest.cc
index 06b65d9..a8420a8 100644
--- a/net/network_error_logging/network_error_logging_service_unittest.cc
+++ b/net/network_error_logging/network_error_logging_service_unittest.cc
@@ -705,6 +705,68 @@
"dns.address_changed"))));
}
+TEST_P(NetworkErrorLoggingServiceTest, FailureReportDowngradedOtherServerIp) {
+ service()->OnHeader(kNak_, kOrigin_, kServerIP_, kHeaderSuccessFraction1_);
+
+ // Make the rest of the test run synchronously.
+ FinishLoading(/*load_success=*/true);
+
+ // `server_ip` matches the policy's address, but the request also contacted
+ // a different address. The report should still be downgraded.
+ NetworkErrorLoggingService::RequestDetails details = MakeRequestDetails(
+ kNak_, kUrl_, ERR_CONNECTION_REFUSED, "GET", 0, kServerIP_);
+ details.other_server_ips = {kOtherServerIP_};
+ service()->OnRequest(std::move(details));
+
+ ASSERT_EQ(1u, reports().size());
+ EXPECT_EQ(kUrl_, reports()[0].url);
+ EXPECT_EQ(kNak_, reports()[0].network_anonymization_key);
+ EXPECT_EQ(kGroup_, reports()[0].group);
+ EXPECT_EQ(kType_, reports()[0].type);
+ EXPECT_EQ(0, reports()[0].depth);
+
+ EXPECT_THAT(
+ reports()[0].body,
+ Pointee(IsSupersetOfValue(
+ base::DictValue()
+ .Set(NetworkErrorLoggingService::kReferrerKey, kReferrer_.spec())
+ .Set(NetworkErrorLoggingService::kSamplingFractionKey, 1.0)
+ .Set(NetworkErrorLoggingService::kServerIpKey,
+ kServerIP_.ToString())
+ .Set(NetworkErrorLoggingService::kProtocolKey, "")
+ .Set(NetworkErrorLoggingService::kMethodKey, "GET")
+ .Set(NetworkErrorLoggingService::kStatusCodeKey, 0)
+ .Set(NetworkErrorLoggingService::kElapsedTimeKey, 0)
+ .Set(NetworkErrorLoggingService::kPhaseKey, "dns")
+ .Set(NetworkErrorLoggingService::kTypeKey,
+ "dns.address_changed"))));
+}
+
+TEST_P(NetworkErrorLoggingServiceTest,
+ FailureReportNotDowngradedSameOtherServerIp) {
+ service()->OnHeader(kNak_, kOrigin_, kServerIP_, kHeaderSuccessFraction1_);
+
+ // Make the rest of the test run synchronously.
+ FinishLoading(/*load_success=*/true);
+
+ // `server_ip` matches the policy's address, and the only other contacted
+ // address is the same. The report should not be downgraded.
+ NetworkErrorLoggingService::RequestDetails details = MakeRequestDetails(
+ kNak_, kUrl_, ERR_CONNECTION_REFUSED, "GET", 0, kServerIP_);
+ details.other_server_ips = {kServerIP_};
+ service()->OnRequest(std::move(details));
+
+ ASSERT_EQ(1u, reports().size());
+ EXPECT_THAT(
+ reports()[0].body,
+ Pointee(IsSupersetOfValue(
+ base::DictValue()
+ .Set(NetworkErrorLoggingService::kStatusCodeKey, 0)
+ .Set(NetworkErrorLoggingService::kElapsedTimeKey, 1000)
+ .Set(NetworkErrorLoggingService::kPhaseKey, "connection")
+ .Set(NetworkErrorLoggingService::kTypeKey, "tcp.refused"))));
+}
+
TEST_P(NetworkErrorLoggingServiceTest, HttpErrorReportDowngraded) {
service()->OnHeader(kNak_, kOrigin_, kServerIP_, kHeaderSuccessFraction1_);
Original Bug Report
NEL downgrade bypass via connection_attempts_ allows internal port-scan timing oracle
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 logic flaw in Chrome’s Network Error Logging (NEL) implementation potentially allows attackers to bypass downgrade protections designed to prevent port scanning. When multiple connection attempts fail, Chrome uses only the last attempted IP to validate the NEL policy, allowing an attacker to hide a failed probe to an internal IP behind a subsequent failure to their own policy IP.
Affected files:
net/http/http_network_transaction.ccnet/network_error_logging/network_error_logging_service.ccnet/socket/transport_connect_sub_job.ccnet/socket/tcp_connect_job_connector.cc
Estimated timestamp from git blame: 2022-04-25
Summary
A potential vulnerability in the Network Error Logging (NEL) implementation in Chrome allows an attacker to bypass W3C downgrade protections and perform port scanning of a victim’s internal network (e.g., RFC1918 addresses).
The W3C NEL specification mandates that reports be “downgraded” (timing information zeroed) if the server IP address contacted differs from the IP address that originally delivered the NEL policy. This security measure prevents DNS-rebinding attacks and port scans of internal networks. However, when multiple connection attempts fail, Chrome populates the report’s server_ip using only the last entry in the connection_attempts_ vector. An attacker can exploit this to probe an internal IP, ensure the final failed attempt goes to their own IP, and receive a report containing the cumulative timing information of all previous failed attempts.
Vulnerability Details
In net/http/http_network_transaction.cc, when all connection attempts fail, the code sets the report’s server_ip:
// net/http/http_network_transaction.cc
} else if (!connection_attempts_.empty()) {
// When we failed to connect to the server, `remote_endpoint_` is not set.
// In such case, we use the last endpoint address of `connection_attempts_`
// for the NEL report. This address information is important for the
// downgrade step to protect against port scan attack.
details.server_ip = connection_attempts_.back().endpoint.address();
}
Suggested Attacker Steps (Theoretical):
- Policy Setup: The attacker controls
evil.comand sets an NEL policy from a publicPOLICY_IPthey control. Chrome stores this, notingpolicy->received_ip_address = POLICY_IP. - DNS Manipulation: The attacker configures the DNS A records for
evil.comto return[TARGET_IP, POLICY_IP], whereTARGET_IPis an internal address (e.g.,192.168.1.1). - Address Sorting: When the victim visits
evil.com, Chrome resolves the IPs.AddressSorter(following RFC 6724 Rule 9 for longest matching prefix) prioritizes the internalTARGET_IPover the publicPOLICY_IP, placing it first in the list. - First Connection Attempt (TARGET_IP):
TransportConnectSubJob(orTcpConnectJob) attempts to connect toTARGET_IP. This fails (e.g., fast RST if closed, slow SYN timeout if filtered) and is appended toconnection_attempts_. - Second Connection Attempt (POLICY_IP): The job attempts to connect to
POLICY_IP. The attacker’s server intentionally drops this connection. This failure is also appended toconnection_attempts_. - Report Generation: The transaction fails.
HttpNetworkTransaction::GenerateNetworkErrorLoggingReportusesconnection_attempts_.back().endpoint.address()(which isPOLICY_IP) asdetails.server_ip. It also calculatesdetails.elapsed_time, which encompasses the duration of both failed attempts. - Downgrade Bypass: In
NetworkErrorLoggingService::OnRequest, the downgrade check comparesdetails.server_ip(POLICY_IP) againstpolicy->received_ip_address(POLICY_IP). Because they match, theelapsed_timeis not zeroed.
Impact
This vulnerability creates a reliable port-scanning timing oracle. The attacker receives a report containing the full elapsed_time. By observing whether the time is short (<100ms) or long (>3000ms), the attacker can distinguish between a closed port and a filtered port on any internal IP reachable by the victim’s browser.
Note that Private Network Access (PNA) protections do not mitigate this issue. PNA checks evaluate the response address space in URLLoader::OnConnected, which is only reached after a successful TCP connection. Since these connection attempts fail at the transport layer, PNA checks are never triggered.
Recommendation
The NEL downgrade check should evaluate all IP addresses attempted during the transaction, not just the last one.
In net/http/http_network_transaction.cc, if remote_endpoint_ is empty but connection_attempts_ is populated, the code should verify that every endpoint in connection_attempts_ matches the policy IP. If any attempted IP differs from the policy IP, the report must be downgraded to prevent leaking the timings of probes to non-policy IPs. Alternatively, the downgrade check could be updated to examine the full list of attempts if it is passed through the RequestDetails struct.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.