CVE-2026-85043
Overview
Background
- `HttpAuthController`
- the net-stack object that drives HTTP authentication for a request, creating and reusing an
HttpAuthHandleracross 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.
Attack Path
- Establish connection-based auth
A request to a server triggers a connection-based scheme (e.g. NTLM/Negotiate);
HttpAuthControllercreates the handler and, post-patch, records the connection’s certificate inhandler_server_cert_. - 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.
- 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.
- 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
(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
| Function | Change | Notes |
|---|---|---|
ifnet/http/http_auth_controller.cc |
modified | |
NetLogWithSourcenet/http/http_auth_controller.h |
modified | |
SSLInfonet/http/http_auth_controller.h |
modified | |
X509Certificatenet/http/http_auth_controller.h |
modified | |
TESTnet/http/http_auth_controller_unittest.cc |
modified |
Files Changed
net/http/http_auth_controller.ccnet/http/http_auth_controller_unittest.ccnet/http/http_auth_controller.h
Audit Directions
- Per-connection state reused across connectionsLook 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 changeFlag 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 setsAudit every place a primary object (
handler_) is reset to confirm all correlated members (likehandler_server_cert_) are reset together, avoiding stale-companion-state bugs.
Patch
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
Regression Test / PoC
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
Original Bug Report
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.ccnet/http/http_auth_handler_negotiate.ccnet/http/http_network_transaction.ccnet/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_mitmfor the target origin.
Step-by-Step Execution Flow:
-
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 certificateC_real. The real server sends an HTTP401 Unauthorizedwith aWWW-Authenticate: NTLMchallenge. The attacker relays these response bytes and immediately triggers a TCP FIN or RST on Connection A, rendering it non-reusable. -
Step 2 (Auth Handler Initialization): Chromium processes the
401response. Since no authentication handler exists yet,HttpAuthController::ChooseBestChallengecreates a newHttpAuthHandlerNTLMand calls itsInit()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_infocontainsC_real, andchannel_bindings_is populated withtls-server-end-point:||SHA-256(C_real). -
Step 3 (Reconnection via DidDrainBodyForAuthRestart):
HttpNetworkTransactiondetermines that Connection A cannot be reused (e.g., viaHttpStreamParser::CanReuseConnection()). It invokesDidDrainBodyForAuthRestart(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 clearauth_controllers_[AUTH_SERVER](net/http/http_network_transaction.cc:2286-2300). The existing auth handler, containing the cachedchannel_bindings_derived fromC_real, is preserved for the next connection attempt. -
Step 4 (Connection B - Active TLS MITM): Chromium reconnects (
STATE_CREATE_STREAM). The attacker actively intercepts Connection B and presents the mis-issued certificateC_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 usingC_real). -
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 callsHandleAnotherChallenge:// 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 passedThis 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::HandleAnotherChallengeImplatnet/http/http_auth_handler_negotiate.cc:250-254behaves identically, preserving the stale binding. -
Step 6 (Credential Generation & Sink): The client generates the Type-3 (Authenticate) token.
- On Windows (SSPI):
HttpAuthHandlerNTLMWin::GenerateAuthTokenImpl(andHttpAuthHandlerNegotiate) consumeschannel_bindings_verbatim and passes it to SSPI as aSECBUFFER_CHANNEL_BINDINGSstructure:// 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
MsvAvChannelBindingsas the MD5 hash ofchannel_bindings_and places it inside theAUTHENTICATE_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 ofC_real, the Type-3 message carries a binding bound toC_real, even though the active connection is Connection B (secured withC_mitm). - On Windows (SSPI):
-
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
MsvAvChannelBindingsagainst the certificate of its active connection (Connection C, which isC_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:
- Ensure that the authentication handler is destroyed when the underlying connection is closed on a restart. For example, call
HttpAuthController::OnConnectionClosed()inside the!new_streambranch ofHttpNetworkTransaction::DidDrainBodyForAuthRestart(bool keep_alive). This matches the proxy auth path logic. - Pass the
SSLInfodown toHttpAuth::HandleChallengeResponseand 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:
- Transition to HandleAuthChallenge after 401 & State Preservation:
HttpNetworkTransaction::HandleAuthChallenge()(lines 2382-2409) identifies a 401, sets target toHttpAuth::AUTH_SERVER, and delegates toauth_controllers_[target]->HandleAuthChallenge(...).- During restart,
PrepareForAuthRestart()forces a drain viaSTATE_DRAIN_BODY_FOR_AUTH_RESTART. - If a TCP RST occurs,
DoDrainBodyForAuthRestartComplete()gets a negative result (e.g.,ERR_CONNECTION_RESET), setskeep_alive = false, and invokesDidDrainBodyForAuthRestart(false). DidDrainBodyForAuthRestart(bool keep_alive)seesnew_streamis null, closes the stream (stream_->Close(true)), and sets state toSTATE_CREATE_STREAM.ResetStateForAuthRestart()(lines 2286-2305) is then called, which clears transient state but intentionally omitsauth_controllers_.
- Type-3 Sink and
MsvAvChannelBindingsConstruction:HttpAuthHandlerNTLMWin::GenerateAuthTokenImpl()leverages SSPIInitializeSecurityContextwhich consumes the staleSECBUFFER_CHANNEL_BINDINGSpayload.- The portable implementation in
net/ntlm/ntlm.cchashes the stalechannel_bindings_string (MD5 of the 20-byte struct header + string) and stores it as anAvPairunderTargetInfoAvId::kChannelBindings(value 0x000A).
Mitigations / Validators Checked:
- Resetting Auth Controllers on Reconnect:
HttpNetworkTransaction::ResetStateForAuthRestart()resets several fields but leavesauth_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 insideHttpNetworkTransaction. - Updating
SSLInfoin Later Rounds: The signatures ofHttpAuth::HandleChallengeResponse()andHttpAuthHandler::HandleAnotherChallenge()do not acceptSSLInfo, 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
401response body on Connection A is fully delivered before the attacker’s TCP close, soHandleAuthChallengeruns rather than the request erroring. This is fully feasible: the passive relay forwards the server’s bytes verbatim, then closes;HttpStreamParsertreats this as complete-response-then-not-reusable. - On the portable path,
NtlmFeatures::is_epa_enabledis true (default perntlm::NtlmFeaturesconstruction withNtlmV2Enabled()); on Windows, SSPI unconditionally consumesSECBUFFER_CHANNEL_BINDINGSwhen 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.