HexBrain

curl · Report #3981716

Cross-origin redirect reuses a pooled Negotiate-authenticated connection from an unrelated transfer

N/Astatus
mediumseverity
3upvotes
15 Oct 2026disclosed

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).

  1. Set up two hosts (CURLOPT_RESOLVE to localhost works fine):

    • trusted.example:8081: Apache with mod_auth_gssapi, GssapiConnectionBound On, an authenticated /legit endpoint, and a state-changing /admin/action endpoint.
    • attacker.example:8082: any server that answers /hook with:
      HTTP/1.1 302 Found
      Location: http://trusted.example:8081/admin/action
      
  2. On one curl handle, with CURLOPT_HTTPAUTH = CURLAUTH_NEGOTIATE, CURLOPT_FOLLOWLOCATION = 1L, and a Kerberos ticket available for a principal (say alice):

    /* 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);
    
  3. Check the server side (access log, plus a CGI/script at /admin/action logging incoming headers and REMOTE_USER):

    • The final request to /admin/action carries no Authorization header.
    • The response is 200 OK, not 401.
    • 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.
  4. 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 the 200 depends on reused connection state, not independent authorization.
    • Single-transfer redirect: one transfer following trusted → attacker → trusted in a single curl_easy_perform() doesn't reproduce it. curl sends a fresh Authorization: Negotiate header and does a normal exchange back at trusted.example. So the existing single-transfer redirect protection is intact. The gap is specific to reuse across separate transfers.

Reproduced twice independently, with different Kerberos realms, hostnames, credentials, and containers both times. Same result each time.

Impact

----

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original