curl · Report #3981716
Cross-origin redirect reuses a pooled Negotiate-authenticated connection from an unrelated transfer
How it was found
AI Disclosure: AI tools were used to assist with code analysis and drafting this report. All findings were independently investigated, verified against the source, and reproduced manually by me before submission.
Summary
curl withholds a fresh Authorization header when a redirect crosses origins. That protection doesn't extend to connection reuse. If an application shares a connection cache across transfers, and one transfer already completed connection-bound Negotiate auth to a trusted host, a later transfer can hit an attacker-controlled origin, get redirected back to the trusted host, and reuse the already-authenticated pooled connection for the final request. No Authorization header goes out. Since Negotiate binds auth to the connection rather than the request, the server treats it as coming from the previously authenticated principal anyway.
In our lab, Apache with mod_auth_gssapi executed a state-changing request as [email protected] with no Authorization header present at all. This isn't a header leak; that path is already covered. It's a mismatch between data->state.initial_origin, which the header-generation check uses, and data->state.origin, which url_match_auth_nego() in lib/url.c uses for the connection-reuse check.
Affected version
Reproduced on curl/libcurl git master, commit aab0518d04, reporting as 8.22.0-DEV.
$ curl -V
curl 8.22.0-DEV (x86_64-pc-linux-gnu) libcurl/8.22.0-DEV OpenSSL/
This isn't a regression with a fix already staged. I diffed url_match_auth_nego() between the latest stable release (curl-8_21_0) and master to check. There's a diff in that function, but it's unrelated: it's the fix for GitHub issue #21307, the follow-up to CVE-2026-1965, which addressed the original fix being too strict about upgrading a connection from no credentials to with credentials before Negotiate starts. That change reordered the if/else if branches, added url_allow_sspi_empty_creds() calls, and changed how the NULL-credential-upgrade case is handled. None of that touches this bug.
The line that matters is identical in both versions:
!Curl_peer_equal(conn->creds_origin, m->data->state.origin)
The issue is live in both the current stable release and git master.
Steps to reproduce
You need a curl build with GSS-API/Negotiate support, a shared connection cache (one CURLM*/easy handle reused across transfers, or the share interface), and a test server doing connection-bound Negotiate auth (Apache + mod_auth_gssapi with GssapiConnectionBound On works).
-
Set up two hosts (
CURLOPT_RESOLVEto localhost works fine):trusted.example:8081: Apache with mod_auth_gssapi,GssapiConnectionBound On, an authenticated/legitendpoint, and a state-changing/admin/actionendpoint.attacker.example:8082: any server that answers/hookwith:HTTP/1.1 302 Found Location: http://trusted.example:8081/admin/action
-
On one curl handle, with
CURLOPT_HTTPAUTH = CURLAUTH_NEGOTIATE,CURLOPT_FOLLOWLOCATION = 1L, and a Kerberos ticket available for a principal (sayalice):/* Transfer 1: authenticate to the trusted host; connection stays pooled */ curl_easy_setopt(h, CURLOPT_URL, "http://trusted.example:8081/legit"); curl_easy_perform(h); /* Transfer 2: visit the unrelated attacker host, which redirects back */ curl_easy_setopt(h, CURLOPT_URL, "http://attacker.example:8082/hook"); curl_easy_perform(h); -
Check the server side (access log, plus a CGI/script at
/admin/actionlogging incoming headers andREMOTE_USER):- The final request to
/admin/actioncarries noAuthorizationheader. - The response is
200 OK, not401. - The log records the authenticated principal as
[email protected]. The state-changing action ran under a Kerberos identity via a request that carried no credentials of its own.
- The final request to
-
Two controls confirm the mechanism:
- Fresh connection: repeat step 2's second transfer on a handle that never ran step 2's first transfer. Result:
401 Unauthorized, no side effect. So the200depends on reused connection state, not independent authorization. - Single-transfer redirect: one transfer following
trusted → attacker → trustedin a singlecurl_easy_perform()doesn't reproduce it. curl sends a freshAuthorization: Negotiateheader and does a normal exchange back attrusted.example. So the existing single-transfer redirect protection is intact. The gap is specific to reuse across separate transfers.
- Fresh connection: repeat step 2's second transfer on a handle that never ran step 2's first transfer. Result:
Reproduced twice independently, with different Kerberos realms, hostnames, credentials, and containers both times. Same result each time.