Go · github.com/traefik/traefik/v3
Traefik HTTP/3 Backend NTLM Connection Reuse
Traefik's HTTP/3 request path did not initialize the connection-scoped backend transport holder that isolates connection-bound NTLM and Negotiate (Kerberos) authentication on the HTTP/1.1 and HTTP/2 paths. The HTTP/3 entrypoint reuses the HTTPS handler chain and reaches the same backend round-tripper, but its ConnContext never called service.AddTransportOnContext, so kerberosRoundTripper fell back to the shared backend transport instead of a per-frontend-connection pool. On a route served over HTTP/3 to a backend that binds identity to a persistent connection via NTLM or Negotiate, an unrelated HTTP/3 client could be assigned a backend connection already authenticated as a victim and inherit that identity, reading victim-only data and performing actions as the victim without presenting the victim's credentials. Affected deployments require HTTP/3 enabled on the entrypoint, a backend using connection-bound NTLM/Negotiate authentication, and backend keep-alive; deployments using ordinary per-request authentication are not affected.
If you have any questions or comments about this advisory, please open an issue.
Traefik's HTTP/3 request path does not initialize the connection-scoped backend transport state that Traefik uses to isolate connection-bound NTLM and Negotiate authentication for HTTP/1.1 and HTTP/2. When a backend keeps authenticated identity on a persistent HTTP/1.1 TCP connection, an unrelated HTTP/3 client can reuse a victim-authenticated backend connection and inherit that backend identity.
In the attached reproduction, the HTTPS/HTTP/1.1 control case behaves correctly and isolates the attacker, but the HTTP/3 case allows a second unauthenticated client to read victim-only data and execute a state-changing request as actor=victim.
Validated target:
traefik/traefikf2d0794417e4d06343e6e7c4722143f5b34bee452026-08-25T06:48:02Z2026-08-24T08:26:06ZThe issue is caused by a protocol-parity gap between the normal TCP HTTP entrypoint path and the HTTP/3 entrypoint path.
For HTTP/1.1 and HTTP/2, Traefik explicitly creates a connection-scoped holder that can later store a dedicated RoundTripper for NTLM or Negotiate:
// pkg/server/server_entrypoint_tcp.go:691-703
var connContext multipleConnContext
connContext.AddConnContextFunc(func(ctx context.Context, c net.Conn) context.Context {
// This adds an empty struct in order to store a RoundTripper in the ConnContext in case of Kerberos or NTLM.
ctx = service.AddTransportOnContext(ctx)
if tlsConn, ok := c.(*tls.Conn); ok {
if tlsConnWithOptionsName, ok := tlsConn.NetConn().(tcp.TLSConn); ok {
return tcp.AddTLSOptionsNameInContext(ctx, tlsConnWithOptionsName.TLSOptionsName)
}
}
return ctx
})
That helper installs the per-connection holder, and kerberosRoundTripper depends on it. If the holder is absent, it falls back to the shared original backend transport. If NTLM or Negotiate is detected, it stores a dedicated cloned RoundTripper into that holder so future requests stay on the authenticated backend connection:
// pkg/server/service/transport.go:374-402
func AddTransportOnContext(ctx context.Context) context.Context {
return context.WithValue(ctx, transportKey, &stickyRoundTripper{})
}
type kerberosRoundTripper struct {
new func() http.RoundTripper
OriginalRoundTripper http.RoundTripper
}
func (k *kerberosRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
value, ok := request.Context().Value(transportKey).(*stickyRoundTripper)
if !ok {
return k.OriginalRoundTripper.RoundTrip(request)
}
if value.RoundTripper != nil {
return value.RoundTripper.RoundTrip(request)
}
resp, err := k.OriginalRoundTripper.RoundTrip(request)
// If we found that we are authenticating with Kerberos (Negotiate) or NTLM.
// We put a dedicated roundTripper in the ConnContext.
// This will stick the next calls to the same connection with the backend.
if err == nil && containsNTLMorNegotiate(resp.Header.Values("WWW-Authenticate")) {
value.RoundTripper = k.new()
}
return resp, err
}
For HTTP/3, the server reuses the normal HTTPS handler chain, but its ConnContext only propagates the TLS options name and does not call service.AddTransportOnContext:
// pkg/server/server_entrypoint_tcp_http3.go:65-80
h3.Server = &http3.Server{
Addr: config.GetAddress(),
Port: config.HTTP3.AdvertisedPort,
Handler: httpsServer.Server.(*http.Server).Handler,
TLSConfig: &tls.Config{GetConfigForClient: h3.getTLSConfigForClient},
QUICConfig: &quic.Config{
Allow0RTT: false,
},
ConnContext: func(ctx context.Context, c *quic.Conn) context.Context {
tlsOptionsName, err := h3.getTLSOptionsName(c)
if err != nil {
log.Error().Msgf("Error getting TLS options name for client: %v", err)
return ctx
}
return tcp.AddTLSOptionsNameInContext(ctx, tlsOptionsName)
},
}
This means HTTP/3 requests reach the same reverse-proxy and backend transport logic as HTTPS, but without the connection-scoped transport holder that NTLM and Negotiate isolation relies on.
In practice, the flow is:
transportKey, kerberosRoundTripper uses the shared OriginalRoundTripper.The attached verifier demonstrates both the negative control and the exploit path:
401Authorization header, reads resource=secret actor=victim, executes action=transfer actor=victim to=attacker amount=5000, and hits the same backend TCP connection identifier as the victimSee the reproduction materials at: https://gist.github.com/OneZ3r0/41da8e8b79ebbe444a94f8a2a3a30895
The gist can also be downloaded as a ZIP archive.
Files included in this gist:
run.shDockerfile.dockerignorego.modgo.sumverify.goThe package is intentionally kept as a single-container reproduction:
run.sh builds a local image for the pinned target commitRun:
./run.sh
run.sh defaults to the validated commit above. To override it explicitly:
PRODUCT_COMMIT=f2d0794417e4d06343e6e7c4722143f5b34bee45 ./run.sh
Expected terminal result:
REPRODUCED: HTTP/1.1 isolates the authenticated backend connection, but HTTP/3 reuses the victim-authenticated backend connection for a different client and executes an unauthorized state-changing request as the victim.
Important observed behavior from the PoC:
401AuthorizationIs your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.