Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Network
DescriptionObservable discrepancy in Network
ComponentNetwork
Bug ClassLogic Error
Tracker500492844
Fix commitc52ce5c11e9b (chromium/src) +125/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
for
net/http/http_network_transaction.cc
modified
if
net/http/http_network_transaction.cc
modified
if
net/network_error_logging/network_error_logging_service.cc
modified
TEST_P
net/network_error_logging/network_error_logging_service_unittest.cc
modified

Files Changed

  • net/http/http_network_transaction.cc
  • net/http/http_network_transaction_unittest.cc
  • net/network_error_logging/network_error_logging_service.cc
  • net/network_error_logging/network_error_logging_service.h
  • net/network_error_logging/network_error_logging_service_unittest.cc
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(
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cc
  • net/network_error_logging/network_error_logging_service.cc
  • net/socket/transport_connect_sub_job.cc
  • net/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):

  1. Policy Setup: The attacker controls evil.com and sets an NEL policy from a public POLICY_IP they control. Chrome stores this, noting policy->received_ip_address = POLICY_IP.
  2. DNS Manipulation: The attacker configures the DNS A records for evil.com to return [TARGET_IP, POLICY_IP], where TARGET_IP is an internal address (e.g., 192.168.1.1).
  3. Address Sorting: When the victim visits evil.com, Chrome resolves the IPs. AddressSorter (following RFC 6724 Rule 9 for longest matching prefix) prioritizes the internal TARGET_IP over the public POLICY_IP, placing it first in the list.
  4. First Connection Attempt (TARGET_IP): TransportConnectSubJob (or TcpConnectJob) attempts to connect to TARGET_IP. This fails (e.g., fast RST if closed, slow SYN timeout if filtered) and is appended to connection_attempts_.
  5. 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 to connection_attempts_.
  6. Report Generation: The transaction fails. HttpNetworkTransaction::GenerateNetworkErrorLoggingReport uses connection_attempts_.back().endpoint.address() (which is POLICY_IP) as details.server_ip. It also calculates details.elapsed_time, which encompasses the duration of both failed attempts.
  7. Downgrade Bypass: In NetworkErrorLoggingService::OnRequest, the downgrade check compares details.server_ip (POLICY_IP) against policy->received_ip_address (POLICY_IP). Because they match, the elapsed_time is 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.

View on issue tracker