High watch CVSS 9.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in Network
DescriptionIncomplete cleanup in Network
ComponentNetwork
Sub-componentInternals>Network
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker533502257
Fix commit65ac1eb90261 (chromium/src) +176/-0
CWECWE-459
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-10 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-03
Patch gap6 days — fix public in source before the advisory

Background

`HttpAuthController`
the net-stack object that drives HTTP authentication for a request, creating and reusing an HttpAuthHandler across the multi-round challenge/response exchange.
Connection-based auth scheme
a scheme (e.g. NTLM/Negotiate) where the handler holds per-connection state and can bind its tokens to the TLS connection via channel bindings derived from the server certificate at handler-creation time (is_connection_based() is true).
`SSLInfo::cert` / `X509Certificate::EqualsExcludingChain`
the server certificate presented on the current connection and the comparison used to decide whether two certificates are the same leaf, ignoring the chain.
Incomplete cleanup
leaving stale per-connection handler state in place after the underlying connection (and its certificate) has changed.

Root Cause Analysis

HttpAuthController creates a connection-based HttpAuthHandler once and reuses it for subsequent 401/407 challenges on the same logical auth transaction, but it never recorded which server certificate the connection presented when the handler was created. When a later challenge arrived over a connection presenting a different server certificate (for example after the connection was replaced), the pre-patch HandleAuthChallenge handed that challenge to the existing handler, whose channel-binding state was derived from the original certificate and is no longer valid for the new connection — a failure to clean up per-connection state on a connection/certificate change.

The fix adds a handler_server_cert_ member recording ssl_info.cert whenever a handler is successfully created, and at the top of the challenge-handling path it drops the handler via InvalidateCurrentHandler(INVALIDATE_HANDLER) when the handler is connection-based and ServerCertMatches(handler_server_cert_.get(), ssl_info.cert.get()) returns false, forcing the scheme to restart from a handler bound to the current certificate. The stored certificate is also reset wherever the handler is cleared (handler_.reset() path, the preemptive-handler swap, and InvalidateCurrentHandler) so the tracking stays consistent with handler lifetime. The shipped tests confirm the handler is reused when the certificate is unchanged and re-created when it differs.

Key insight
The core mistake was reusing a connection-based auth handler — whose channel bindings are tied to the server certificate at creation time — across a connection whose certificate had changed, without tracking or comparing that certificate. The fix records the handler’s originating certificate and invalidates the handler on any mismatch before it processes the new challenge.

Attack Path

  1. Establish connection-based auth A request to a server triggers a connection-based scheme (e.g. NTLM/Negotiate); HttpAuthController creates the handler and, post-patch, records the connection’s certificate in handler_server_cert_.
  2. Serve a later challenge on a different certificate A subsequent challenge in the same auth transaction is delivered over a connection presenting a different server certificate than the one the handler was bound to.
  3. Stale handler processes the new challenge (pre-patch) Without the certificate check, the existing handler — carrying channel-binding state derived from the original certificate — generates a response for the new connection, so the per-connection binding no longer matches the actual connection.
  4. Channel-binding/auth-state confusion The mismatched channel bindings weaken the connection binding intended by the scheme, potentially aiding credential relay or authenticating over an unintended connection (inferred); the patch itself only demonstrates that the stale handler is (pre-patch) reused rather than reset.

Impact Assessment

The flaw let a connection-based HTTP auth handler carry certificate-derived channel-binding state onto a connection with a different server certificate, undermining the connection binding the scheme relies on. The advisory classifies this as “Incomplete cleanup in Network” and does not report in-the-wild exploitation; any concrete escalation to credential relay or authentication bypass is (inferred) and beyond what the patch demonstrates. Preconditions are use of a connection-based auth scheme and a later challenge arriving over a connection whose certificate differs from the one at handler creation.

Changed Functions

FunctionChangeNotes
if
net/http/http_auth_controller.cc
modified
NetLogWithSource
net/http/http_auth_controller.h
modified
SSLInfo
net/http/http_auth_controller.h
modified
X509Certificate
net/http/http_auth_controller.h
modified
TEST
net/http/http_auth_controller_unittest.cc
modified

Files Changed

  • net/http/http_auth_controller.cc
  • net/http/http_auth_controller_unittest.cc
  • net/http/http_auth_controller.h

Audit Directions

  • Per-connection state reused across connections
    Look for net-stack objects that cache connection-derived state (channel bindings, session keys, certificates) but are keyed to a logical transaction that can span multiple connections without re-validating the connection identity.
  • State created but not invalidated on input change
    Flag handlers/caches that record a resource at creation but lack a matching check to drop it when the underlying resource (certificate, socket, endpoint) changes on a later call.
  • Incomplete reset sets
    Audit every place a primary object (handler_) is reset to confirm all correlated members (like handler_server_cert_) are reset together, avoiding stale-companion-state bugs.
From 65ac1eb902617bd487b9b0de4b4b3ba69db9c17c Mon Sep 17 00:00:00 2001
From: Sebastien Lalancette <seblalancette@chromium.org>
Date: Fri, 28 Aug 2026 09:01:59 -0700
Subject: [PATCH] Reset connection-based auth handlers on certificate change

Connection-based HTTP authentication schemes derive per-connection state
from the server certificate presented when the handler is created. If a
subsequent authentication challenge is received over a connection
presenting a different server certificate, the existing handler is no
longer valid for that connection.

This change updates HttpAuthController to track the server certificate
associated with the active connection-based handler and drop the handler
if a subsequent challenge arrives over a connection with a different
certificate.

Fixed: 533502257
Change-Id: I8bda23f9d03bef3366a798cbed5d6d2caf3c72c1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8284608
Commit-Queue: Sebastien Lalancette <seblalancette@chromium.org>
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1688069}
---
diff --git a/net/http/http_auth_controller.cc b/net/http/http_auth_controller.cc
index 667c0d20..3a670fb76 100644
--- a/net/http/http_auth_controller.cc
+++ b/net/http/http_auth_controller.cc
@@ -15,6 +15,7 @@
 #include "base/values.h"
 #include "net/base/auth.h"
 #include "net/base/url_util.h"
+#include "net/cert/x509_certificate.h"
 #include "net/dns/host_resolver.h"
 #include "net/http/http_auth_handler.h"
 #include "net/http/http_auth_handler_factory.h"
@@ -27,12 +28,24 @@
 #include "net/log/net_log_source.h"
 #include "net/log/net_log_source_type.h"
 #include "net/log/net_log_with_source.h"
+#include "net/ssl/ssl_info.h"
 #include "url/scheme_host_port.h"
 
 namespace net {
 
 namespace {
 
+bool ServerCertMatches(const X509Certificate* handler_cert,
+                       const X509Certificate* challenge_cert) {
+  if (handler_cert == challenge_cert) {
+    return true;
+  }
+  if (!handler_cert || !challenge_cert) {
+    return false;
+  }
+  return handler_cert->EqualsExcludingChain(challenge_cert);
+}
+
 enum AuthTarget {
   AUTH_TARGET_PROXY = 0,
   AUTH_TARGET_SECURE_PROXY,
@@ -177,6 +190,7 @@
   identity_.invalid = false;
   identity_.credentials = entry->credentials();
   handler_.swap(handler_preemptive);
+  handler_server_cert_.reset();
   return true;
 }
 
@@ -208,6 +222,16 @@
   net_log_.BeginEventReferencingSource(NetLogEventType::AUTH_HANDLE_CHALLENGE,
                                        caller_net_log.source());
 
+  // A connection-based handler derives channel bindings from the server
+  // certificate at creation time. If the underlying connection has been
+  // replaced with one that presents a different certificate, that state is
+  // no longer valid for the current connection, so drop the handler and
+  // start the scheme over.
+  if (handler_ && handler_->is_connection_based() &&
+      !ServerCertMatches(handler_server_cert_.get(), ssl_info.cert.get())) {
+    InvalidateCurrentHandler(INVALIDATE_HANDLER);
+  }
+
   // Give the existing auth handler first try at the authentication headers.
   // This will also evict the entry in the HttpAuthCache if the previous
   // challenge appeared to be rejected, or is using a stale nonce in the Digest
@@ -267,6 +291,7 @@
           network_anonymization_key_, target_, auth_scheme_host_port_,
           disabled_schemes_, net_log_, host_resolver_, &handler_);
       if (handler_.get()) {
+        handler_server_cert_ = ssl_info.cert;
         HistogramAuthEvent(AUTH_EVENT_START);
       }
     }
@@ -396,6 +421,7 @@
   }
 
   handler_.reset();
+  handler_server_cert_.reset();
   identity_ = HttpAuth::Identity();
 }
 
diff --git a/net/http/http_auth_controller.h b/net/http/http_auth_controller.h
index 886647ff..c1113ae 100644
--- a/net/http/http_auth_controller.h
+++ b/net/http/http_auth_controller.h
@@ -34,6 +34,7 @@
 class NetLogWithSource;
 struct HttpRequestInfo;
 class SSLInfo;
+class X509Certificate;
 
 // HttpAuthController is the main entry point for external callers into the HTTP
 // authentication stack. A single instance of an HttpAuthController can be used
@@ -220,6 +221,13 @@
   // associated auth handler.
   std::unique_ptr<HttpAuthHandler> handler_;
 
+  // The server certificate from the connection on which |handler_| was
+  // created. Connection-based handlers derive per-connection state (such as
+  // channel bindings) from this certificate at creation time, so the handler
+  // must be dropped if a later challenge arrives over a connection with a
+  // different certificate.
+  scoped_refptr<X509Certificate> handler_server_cert_;
+
   // |identity_| holds the credentials that should be used by the handler_ to
   // generate challenge responses. This identity can come from a number of
   // places (url, cache, prompt).
diff --git a/net/http/http_auth_controller_unittest.cc b/net/http/http_auth_controller_unittest.cc
index 741d9342..720ee073 100644
--- a/net/http/http_auth_controller_unittest.cc
+++ b/net/http/http_auth_controller_unittest.cc
@@ -23,6 +23,8 @@
 #include "net/log/test_net_log.h"
 #include "net/log/test_net_log_util.h"
 #include "net/ssl/ssl_info.h"
+#include "net/test/cert_test_util.h"
+#include "net/test/test_data_directory.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace net {
@@ -300,4 +302,144 @@
                     &request, CompletionOnceCallback(), dummy_log));
 }
 
+// Tests that a connection-based auth handler is reused when a subsequent
+// challenge is received over a connection with the same server certificate.
+TEST(HttpAuthControllerTest,
+     ConnectionBasedHandlerReusedWhenCertificateUnchanged) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+  // A later-round challenge with a scheme token, as a multi-round scheme would
+  // send after receiving the first client token.
+  scoped_refptr<HttpResponseHeaders> continuation_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock token\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert);
+
+  SSLInfo ssl_info;
+  ssl_info.cert = cert;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auto first_handler = std::make_unique<HttpAuthHandlerMock>();
+  first_handler->set_connection_based(true);
+  auth_handler_factory.AddMockHandler(std::move(first_handler),
+                                      HttpAuth::AUTH_SERVER);
+  auto second_handler = std::make_unique<HttpAuthHandlerMock>();
+  second_handler->set_connection_based(true);
+  HttpAuthHandlerMock* second_handler_ptr = second_handler.get();
+  auth_handler_factory.AddMockHandler(std::move(second_handler),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, CompletionOnceCallback(), dummy_log));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info,
+                                                false, false, dummy_log));
+  // The certificate did not change, so the original handler continues and
+  // the second handler is never initialized.
+  EXPECT_TRUE(controller->HaveAuthHandler());
+  EXPECT_EQ(HttpAuthHandlerMock::State::WAIT_FOR_INIT,
+            second_handler_ptr->state());
+}
+
+// Tests that a connection-based auth handler is dropped and re-created when a
+// subsequent challenge is received over a connection with a different server
+// certificate than the one the handler was created on. Connection-based
+// handlers derive channel bindings from the certificate at creation time, so
+// they must not be reused across connections with different certificates.
+TEST(HttpAuthControllerTest, ConnectionBasedHandlerDroppedOnCertificateChange) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+  // A later-round challenge with a scheme token, as a multi-round scheme would
+  // send after receiving the first client token.
+  scoped_refptr<HttpResponseHeaders> continuation_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock token\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert1 =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert1);
+  scoped_refptr<X509Certificate> cert2 =
+      ImportCertFromFile(GetTestCertsDirectory(), "wildcard.pem");
+  ASSERT_TRUE(cert2);
+  ASSERT_FALSE(cert1->EqualsExcludingChain(cert2.get()));
+
+  SSLInfo ssl_info1;
+  ssl_info1.cert = cert1;
+  SSLInfo ssl_info2;
+  ssl_info2.cert = cert2;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auto first_handler = std::make_unique<HttpAuthHandlerMock>();
+  first_handler->set_connection_based(true);
+  auth_handler_factory.AddMockHandler(std::move(first_handler),
+                                      HttpAuth::AUTH_SERVER);
+  auto second_handler = std::make_unique<HttpAuthHandlerMock>();
+  second_handler->set_connection_based(true);
+  HttpAuthHandlerMock* second_handler_ptr = second_handler.get();
+  auth_handler_factory.AddMockHandler(std::move(second_handler),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info1,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, CompletionOnceCallback(), dummy_log));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info2,
+                                                false, false, dummy_log));
+  // The certificate changed, so the original handler must have been dropped
+  // and a new one created from the current challenge with the new SSLInfo.
+  EXPECT_TRUE(controller->HaveAuthHandler());
+  EXPECT_NE(HttpAuthHandlerMock::State::WAIT_FOR_INIT,
+            second_handler_ptr->state());
+}
+
 }  // namespace net
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/net/http/http_auth_controller_unittest.cc b/net/http/http_auth_controller_unittest.cc
index 741d9342..720ee073 100644
--- a/net/http/http_auth_controller_unittest.cc
+++ b/net/http/http_auth_controller_unittest.cc
@@ -23,6 +23,8 @@
 #include "net/log/test_net_log.h"
 #include "net/log/test_net_log_util.h"
 #include "net/ssl/ssl_info.h"
+#include "net/test/cert_test_util.h"
+#include "net/test/test_data_directory.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace net {
@@ -300,4 +302,144 @@
                     &request, CompletionOnceCallback(), dummy_log));
 }
 
+// Tests that a connection-based auth handler is reused when a subsequent
+// challenge is received over a connection with the same server certificate.
+TEST(HttpAuthControllerTest,
+     ConnectionBasedHandlerReusedWhenCertificateUnchanged) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+  // A later-round challenge with a scheme token, as a multi-round scheme would
+  // send after receiving the first client token.
+  scoped_refptr<HttpResponseHeaders> continuation_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock token\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert);
+
+  SSLInfo ssl_info;
+  ssl_info.cert = cert;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auto first_handler = std::make_unique<HttpAuthHandlerMock>();
+  first_handler->set_connection_based(true);
+  auth_handler_factory.AddMockHandler(std::move(first_handler),
+                                      HttpAuth::AUTH_SERVER);
+  auto second_handler = std::make_unique<HttpAuthHandlerMock>();
+  second_handler->set_connection_based(true);
+  HttpAuthHandlerMock* second_handler_ptr = second_handler.get();
+  auth_handler_factory.AddMockHandler(std::move(second_handler),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, CompletionOnceCallback(), dummy_log));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info,
+                                                false, false, dummy_log));
+  // The certificate did not change, so the original handler continues and
+  // the second handler is never initialized.
+  EXPECT_TRUE(controller->HaveAuthHandler());
+  EXPECT_EQ(HttpAuthHandlerMock::State::WAIT_FOR_INIT,
+            second_handler_ptr->state());
+}
+
+// Tests that a connection-based auth handler is dropped and re-created when a
+// subsequent challenge is received over a connection with a different server
+// certificate than the one the handler was created on. Connection-based
+// handlers derive channel bindings from the certificate at creation time, so
+// they must not be reused across connections with different certificates.
+TEST(HttpAuthControllerTest, ConnectionBasedHandlerDroppedOnCertificateChange) {
+  NetLogWithSource dummy_log;
+  HttpRequestInfo request;
+  request.method = "GET";
+  request.url = GURL("https://example.com");
+
+  scoped_refptr<HttpResponseHeaders> initial_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock\r\n"
+                        "\r\n"));
+  // A later-round challenge with a scheme token, as a multi-round scheme would
+  // send after receiving the first client token.
+  scoped_refptr<HttpResponseHeaders> continuation_headers(
+      HeadersFromString("HTTP/1.1 401\r\n"
+                        "WWW-Authenticate: Mock token\r\n"
+                        "\r\n"));
+
+  scoped_refptr<X509Certificate> cert1 =
+      ImportCertFromFile(GetTestCertsDirectory(), "ok_cert.pem");
+  ASSERT_TRUE(cert1);
+  scoped_refptr<X509Certificate> cert2 =
+      ImportCertFromFile(GetTestCertsDirectory(), "wildcard.pem");
+  ASSERT_TRUE(cert2);
+  ASSERT_FALSE(cert1->EqualsExcludingChain(cert2.get()));
+
+  SSLInfo ssl_info1;
+  ssl_info1.cert = cert1;
+  SSLInfo ssl_info2;
+  ssl_info2.cert = cert2;
+
+  auto host_resolver = std::make_unique<MockHostResolver>();
+  HttpAuthCache dummy_auth_cache(
+      false /* key_server_entries_by_network_anonymization_key */);
+  HttpAuthHandlerMock::Factory auth_handler_factory;
+  auth_handler_factory.set_do_init_from_challenge(true);
+
+  auto first_handler = std::make_unique<HttpAuthHandlerMock>();
+  first_handler->set_connection_based(true);
+  auth_handler_factory.AddMockHandler(std::move(first_handler),
+                                      HttpAuth::AUTH_SERVER);
+  auto second_handler = std::make_unique<HttpAuthHandlerMock>();
+  second_handler->set_connection_based(true);
+  HttpAuthHandlerMock* second_handler_ptr = second_handler.get();
+  auth_handler_factory.AddMockHandler(std::move(second_handler),
+                                      HttpAuth::AUTH_SERVER);
+
+  scoped_refptr<HttpAuthController> controller(
+      base::MakeRefCounted<HttpAuthController>(
+          HttpAuth::AUTH_SERVER, GURL("https://example.com"),
+          NetworkAnonymizationKey(), &dummy_auth_cache, &auth_handler_factory,
+          host_resolver.get()));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(initial_headers, ssl_info1,
+                                                false, false, dummy_log));
+  ASSERT_TRUE(controller->HaveAuthHandler());
+  controller->ResetAuth(AuthCredentials(u"user", u"pass"));
+  ASSERT_TRUE(controller->HaveAuth());
+  ASSERT_EQ(OK, controller->MaybeGenerateAuthToken(
+                    &request, CompletionOnceCallback(), dummy_log));
+
+  ASSERT_EQ(OK, controller->HandleAuthChallenge(continuation_headers, ssl_info2,
+                                                false, false, dummy_log));
+  // The certificate changed, so the original handler must have been dropped
+  // and a new one created from the current challenge with the new SSLInfo.
+  EXPECT_TRUE(controller->HaveAuthHandler());
+  EXPECT_NE(HttpAuthHandlerMock::State::WAIT_FOR_INIT,
+            second_handler_ptr->state());
+}
+
 }  // namespace net
Loading diff…

Original Bug Report

reported by aw...@chromium.org

Stale Channel Bindings Reused in NTLM/Negotiate Auth Restarts Bypasses EPA

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Chromium’s NTLM and Negotiate authentication handlers compute channel bindings exactly once during initialization. If a TLS connection is closed after the initial challenge, the transaction restarts with a new TLS stream while reusing the existing handler and its stale channel binding. This allows an on-path attacker holding a mis-issued certificate to bypass Extended Protection for Authentication (EPA).

Affected files:

  • net/http/http_auth_handler_ntlm.cc
  • net/http/http_auth_handler_negotiate.cc
  • net/http/http_network_transaction.cc
  • net/http/http_auth_controller.cc

Estimated timestamp from git blame: 2016-03-23

1. Summary of the Issue (Meant for Human Triage)

Chromium’s NTLM (HttpAuthHandlerNTLM) and Negotiate (HttpAuthHandlerNegotiate) authentication handlers compute the RFC 5929 tls-server-end-point channel bindings exactly once during their Init() phase from the initial TLS connection’s SSLInfo. If the connection is closed or reset after the server’s initial 401 Unauthorized challenge, HttpNetworkTransaction::DidDrainBodyForAuthRestart() initiates a fresh TLS connection (STATE_CREATE_STREAM) but preserves the existing HttpAuthController and its associated authentication handler.

As a result, subsequent authentication rounds reuse the stale channel binding associated with the original certificate. An on-path attacker holding a CA-trusted mis-issued certificate (C_mitm) can exploit this vulnerability by passively relaying the initial 401 challenge (so the client computes channel bindings bound to the real server’s certificate, C_real), terminating that connection, and then actively MITMing the subsequent connection with C_mitm. When the client generates its NTLM/Negotiate credentials, it binds them to C_real and transmits them over the C_mitm connection, allowing the attacker to relay them to the real server and successfully authenticate, completely bypassing Extended Protection for Authentication (EPA / ExtendedProtection = Require).

This issue affects both the Windows SSPI and portable NTLMv2 implementations, representing a severe bypass of a critical cryptographic channel-binding hardening layer.


2. Proof-of-Concept & Detailed Execution Flow

(Note: These are potential steps derived from deep static analysis; our tooling agent does not have the ability to run a live proof-of-concept yet).

Attacker Setup & Preconditions:

  • Attacker Position: On-path network adversary.
  • Target Origin: Registered in the victim browser’s AuthServerAllowlist (enabling auto-selection of SSO credentials without user interaction).
  • Target Server Configuration: Enforces Windows Extended Protection for Authentication (ExtendedProtection = Require).
  • Mis-issued Certificate: The attacker holds a valid, CA-trusted certificate C_mitm for the target origin.

Step-by-Step Execution Flow:

  1. Step 1 (Connection A - Passive TCP Relay): The client navigates to https://target/. The attacker transparently relays the TCP traffic for Connection A. The client completes the TLS handshake directly with the real server, which presents the genuine certificate C_real. The real server sends an HTTP 401 Unauthorized with a WWW-Authenticate: NTLM challenge. The attacker relays these response bytes and immediately triggers a TCP FIN or RST on Connection A, rendering it non-reusable.

  2. Step 2 (Auth Handler Initialization): Chromium processes the 401 response. Since no authentication handler exists yet, HttpAuthController::ChooseBestChallenge creates a new HttpAuthHandlerNTLM and calls its Init() method:

    // net/http/http_auth_handler_ntlm.cc:18-31
    bool HttpAuthHandlerNTLM::Init(
        HttpAuthChallengeTokenizer* tok,
        const SSLInfo& ssl_info,
        const NetworkAnonymizationKey& network_anonymization_key) {
      auth_scheme_ = HttpAuth::AUTH_SCHEME_NTLM;
      score_ = 3;
      properties_ = ENCRYPTS_IDENTITY | IS_CONNECTION_BASED;
    
      if (ssl_info.is_valid())
        x509_util::GetTLSServerEndPointChannelBinding(*ssl_info.cert,
                                                      &channel_bindings_); // <-- Set ONCE
      return ParseChallenge(tok) == HttpAuth::AUTHORIZATION_RESULT_ACCEPT;
    }
    

    Since the initial connection was established with C_real, ssl_info contains C_real, and channel_bindings_ is populated with tls-server-end-point: || SHA-256(C_real).

  3. Step 3 (Reconnection via DidDrainBodyForAuthRestart): HttpNetworkTransaction determines that Connection A cannot be reused (e.g., via HttpStreamParser::CanReuseConnection()). It invokes DidDrainBodyForAuthRestart(false):

    // net/http/http_network_transaction.cc:603-636
    void HttpNetworkTransaction::DidDrainBodyForAuthRestart(bool keep_alive) {
      ...
        if (!new_stream) {
          stream_->Close(true);
          next_state_ = STATE_CREATE_STREAM;         // <-- Triggers a NEW TLS connection
        }
      ...
      ResetStateForAuthRestart();                    // <-- Leaves auth_controllers_ untouched
    }
    

    Crucially, ResetStateForAuthRestart() does not clear auth_controllers_[AUTH_SERVER] (net/http/http_network_transaction.cc:2286-2300). The existing auth handler, containing the cached channel_bindings_ derived from C_real, is preserved for the next connection attempt.

  4. Step 4 (Connection B - Active TLS MITM): Chromium reconnects (STATE_CREATE_STREAM). The attacker actively intercepts Connection B and presents the mis-issued certificate C_mitm. Because the certificate chains to a trusted CA, the TLS handshake completes successfully. The client automatically sends an NTLM Type-1 message over Connection B. The attacker forwards this Type-1 message to the real server over its own authenticated Connection C (connected to the real server using C_real).

  5. Step 5 (Second Challenge Parsing): The real server responds to the attacker with an NTLM Type-2 challenge, which the attacker forwards to the client on Connection B. The client processes this response using HttpAuthController::HandleChallengeResponse. Because a handler already exists (HaveAuth() is true), it calls HandleAnotherChallenge:

    // net/http/http_auth_controller.cc:215-218
    if (HaveAuth()) {
      std::string challenge_used;
      HttpAuth::AuthorizationResult result = HttpAuth::HandleChallengeResponse(
          handler_.get(), *headers, target_, disabled_schemes_, &challenge_used);  // <-- No ssl_info passed
    

    This resolves to:

    // net/http/http_auth_handler_ntlm.cc:33-36
    HttpAuth::AuthorizationResult HttpAuthHandlerNTLM::HandleAnotherChallengeImpl(
        HttpAuthChallengeTokenizer* challenge) {
      return ParseChallenge(challenge); // <-- No access to SSLInfo, cannot refresh channel bindings
    }
    

    The sibling implementation HttpAuthHandlerNegotiate::HandleAnotherChallengeImpl at net/http/http_auth_handler_negotiate.cc:250-254 behaves identically, preserving the stale binding.

  6. Step 6 (Credential Generation & Sink): The client generates the Type-3 (Authenticate) token.

    • On Windows (SSPI): HttpAuthHandlerNTLMWin::GenerateAuthTokenImpl (and HttpAuthHandlerNegotiate) consumes channel_bindings_ verbatim and passes it to SSPI as a SECBUFFER_CHANNEL_BINDINGS structure:
      // net/http/http_auth_handler_ntlm_win.cc:52-60
      return mechanism_.GenerateAuthToken(credentials, CreateSPN(scheme_host_port_),
                                          channel_bindings_, auth_token, net_log(), ...);
      
    • On Portable NTLMv2: The portable path computes MsvAvChannelBindings as the MD5 hash of channel_bindings_ and places it inside the AUTHENTICATE_MESSAGE:
      // net/ntlm/ntlm_client.cc -> net/ntlm/ntlm.cc:98-106
      av_pairs->emplace_back(TargetInfoAvId::kChannelBindings, MD5(channel_bindings_));
      

    Since channel_bindings_ contains the hash of C_real, the Type-3 message carries a binding bound to C_real, even though the active connection is Connection B (secured with C_mitm).

  7. Step 7 (Exploitation Sink): The client transmits the Type-3 message over Connection B. The attacker intercepts it, decodes it if necessary, and relays it over Connection C to the real server. The real server validates the MsvAvChannelBindings against the certificate of its active connection (Connection C, which is C_real). Since they match, Extended Protection validation passes, and the attacker obtains an authenticated session on Connection C as the victim.

Suggested Fix:

There are two primary ways to resolve this:

  1. Ensure that the authentication handler is destroyed when the underlying connection is closed on a restart. For example, call HttpAuthController::OnConnectionClosed() inside the !new_stream branch of HttpNetworkTransaction::DidDrainBodyForAuthRestart(bool keep_alive). This matches the proxy auth path logic.
  2. Pass the SSLInfo down to HttpAuth::HandleChallengeResponse and subsequent handler virtuals so that the binding is dynamically refreshed on each challenge round.

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

Verbatim Critic Verdict:

> The vulnerability accurately describes a bypass of Extended Protection for Authentication (EPA) / RFC 5929 tls-server-end-point channel bindings in Chromium’s NTLM and Negotiate authentication handlers. > > Code review confirms that HttpAuthHandlerNTLM::Init() and HttpAuthHandlerNegotiate::Init() compute channel_bindings_ exactly once from the initial connection’s SSLInfo and never update it. During an authentication restart (e.g., if the server connection is closed after the initial 401 response), HttpNetworkTransaction::DidDrainBodyForAuthRestart() initiates a new TLS stream but preserves auth_controllers_. The existing auth handler is reused, retaining the stale channel binding. > > A MITM attacker holding a CA-trusted mis-issued certificate (C_mitm) can exploit this by passively relaying the first connection (so the client binds to the real server’s cert, C_real), forcing a reconnect via TCP RST, and actively MITMing the second connection with C_mitm. The client will send NTLM credentials bound to C_real over the C_mitm connection, allowing the attacker to relay them to the real server and authenticate successfully. > > Severity Justification: The report suggests Medium (S2) based on the “hardening layer bypass” rule. However, according to the severity guidelines, this is a High (S1) severity vulnerability. The S1 criteria explicitly include: “HTTP auth credentials, a client certificate, or a Device-Bound-Session signature/JWT is sent to an origin other than the one it is bound to (channel-binding bypass, refresh redirect crossing sites, missing aud claim making a signature relayable).” This vulnerability is a textbook channel-binding bypass that makes HTTP auth credentials relayable. The attacker receives credentials bound to a different TLS channel (C_real) and misuses them. No mitigating factors apply (Enterprise/Windows scoping is not a downgrade per the rubric, and SSO for whitelisted domains requires zero user interaction). > > Probability that the vulnerability is real, correct, and exploitable: 100%.

Codebase Investigator Audit Trails:

  1. Transition to HandleAuthChallenge after 401 & State Preservation:
    • HttpNetworkTransaction::HandleAuthChallenge() (lines 2382-2409) identifies a 401, sets target to HttpAuth::AUTH_SERVER, and delegates to auth_controllers_[target]->HandleAuthChallenge(...).
    • During restart, PrepareForAuthRestart() forces a drain via STATE_DRAIN_BODY_FOR_AUTH_RESTART.
    • If a TCP RST occurs, DoDrainBodyForAuthRestartComplete() gets a negative result (e.g., ERR_CONNECTION_RESET), sets keep_alive = false, and invokes DidDrainBodyForAuthRestart(false).
    • DidDrainBodyForAuthRestart(bool keep_alive) sees new_stream is null, closes the stream (stream_->Close(true)), and sets state to STATE_CREATE_STREAM.
    • ResetStateForAuthRestart() (lines 2286-2305) is then called, which clears transient state but intentionally omits auth_controllers_.
  2. Type-3 Sink and MsvAvChannelBindings Construction:
    • HttpAuthHandlerNTLMWin::GenerateAuthTokenImpl() leverages SSPI InitializeSecurityContext which consumes the stale SECBUFFER_CHANNEL_BINDINGS payload.
    • The portable implementation in net/ntlm/ntlm.cc hashes the stale channel_bindings_ string (MD5 of the 20-byte struct header + string) and stores it as an AvPair under TargetInfoAvId::kChannelBindings (value 0x000A).

Mitigations / Validators Checked:

  • Resetting Auth Controllers on Reconnect: HttpNetworkTransaction::ResetStateForAuthRestart() resets several fields but leaves auth_controllers_ untouched.
  • Connection Loss Notifications: HttpAuthController::OnConnectionClosed() is specifically implemented to clear connection-bound handler state (InvalidateCurrentHandler(INVALIDATE_HANDLER)), but it is only called on the proxy connect path (net/http/http_proxy_connect_job.cc:863). It is not called during normal server authentication restarts inside HttpNetworkTransaction.
  • Updating SSLInfo in Later Rounds: The signatures of HttpAuth::HandleChallengeResponse() and HttpAuthHandler::HandleAnotherChallenge() do not accept SSLInfo, making it architecturally impossible for the handler to learn about a change in the TLS certificate on subsequent challenges.
  • Portable EPA Default Configuration: Checked NtlmFeatures::is_epa_enabled, which defaults to true (if NtlmV2 is enabled), meaning the portable path is also fully vulnerable.

Environmental Assumptions:

  • The initial 401 response body on Connection A is fully delivered before the attacker’s TCP close, so HandleAuthChallenge runs rather than the request erroring. This is fully feasible: the passive relay forwards the server’s bytes verbatim, then closes; HttpStreamParser treats this as complete-response-then-not-reusable.
  • On the portable path, NtlmFeatures::is_epa_enabled is true (default per ntlm::NtlmFeatures construction with NtlmV2Enabled()); on Windows, SSPI unconditionally consumes SECBUFFER_CHANNEL_BINDINGS when non-empty. Both verified in code.

Evaluated with Chrome root at commit: f4cb78b4ec077b7f51b504af9350cdc166d10c2f


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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
Links in the report