curl · Report #3777381
PRE_PROXY change leaks stale Proxy Digest state across proxy-chain boundary
How it was found
Summary
After a Digest-authenticated HTTP proxy transfer, changing only
CURLOPT_PRE_PROXY on the same libcurl easy handle does not clear stale proxy
Digest/auth state. If the new SOCKS pre-proxy resolves the same HTTP proxy
hostname to a different proxy endpoint, the second proxy receives a preemptive
Proxy-Authorization: Digest header built from the first proxy's challenge.
This reproduces on current master. In the proof, CURLOPT_PROXY stays
byte-identical and only CURLOPT_PRE_PROXY changes.
Affected Version
Reproduced on: curl 8.20.0 (Darwin) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.2.12 brotli/1.2.0 zstd/1.5.7 libssh2/1.11.1 nghttp2/1.69.0
Platform: Darwin x86_64
Also reproduced on current master: curl 8.21.0-DEV (Darwin) libcurl/8.21.0-DEV OpenSSL/3.6.2 zlib/1.2.12 brotli/1.2.0 zstd/1.5.7 libssh2/1.11.1 nghttp2/1.69.0
Current master commit used: ba600296d2a1d8a4e53a36eb4bd30c1f0d0152cc
Root Cause
Current master clears proxy auth state when CURLOPT_PROXY changes:
lib/setopt.c:1639: static CURLcode setproxy(...)
lib/setopt.c:1646: changeproxy(data);
lib/setopt.c:1745: return setproxy(data, ptr);
CURLOPT_PRE_PROXY does not use the same cleanup path:
lib/setopt.c:1746: case CURLOPT_PRE_PROXY:
lib/setopt.c:1753: return Curl_setstropt(&s->str[STRING_PRE_PROXY], ptr);
As a result, data->state.proxydigest and data->state.authproxy can survive a
proxy-chain boundary change.
Steps To Reproduce
Extract the attached curl_preproxy_digest_state_leak_bundle.tar.gz. It contains
these files:
docker-compose.yml
Dockerfile.socks
sockd.conf
digest_proxy.py
replay_leaked_digest.py
preproxy_digest_chain_client.c
run_lab.sh
Prerequisites: Docker Compose, Python 3, a C compiler, and a curl/libcurl build with HTTP proxy, SOCKS, and Digest auth support.
Build curl normally, then run the bundle script with CURL_SRC pointing to the
curl source checkout and CURL_BUILD pointing to the build/install directory
that contains lib/libcurl.*. If the library name is not libcurl, set
LIB_NAME accordingly.
Example:
tar -xzf curl_preproxy_digest_state_leak_bundle.tar.gz
cd curl_preproxy_digest_state_leak_bundle
CURL_SRC=/path/to/curl \
CURL_BUILD=/path/to/curl/build \
LIB_NAME=curl \
./run_lab.sh
The lab uses two real Dante SOCKS gateways in separate Docker networks:
socks1 -> shared.proxy.test:18080 -> p1
socks2 -> shared.proxy.test:18080 -> p2
The libcurl client keeps CURLOPT_PROXY unchanged:
http://shared.proxy.test:18080
Only CURLOPT_PRE_PROXY changes between the two transfers:
socks5h://127.0.0.1:11081
socks5h://127.0.0.1:11082
Observed Result
P2 receives P1's Digest challenge state on its first request:
logs/p2.log:1
"proxy_authorization": "Proxy-Authorization: Digest username=\"user\", realm=\"p1realm\", nonce=\"p1nonce\", uri=\"/resource\", ... nc=00000002 ..."
The client trace confirms that the second transfer uses the second pre-proxy while still sending P1's Digest state:
logs/chain-client.stderr
Opened SOCKS connection ... to shared.proxy.test port 18080 (via 127.0.0.1 port 11082)
Proxy-Authorization: Digest username="user", realm="p1realm", nonce="p1nonce", ... nc=00000002 ...
The leaked P1 Digest response has authentication value. Replaying it to P1 for
the same URI returns 200 OK; replaying it for a mismatched URI returns 407:
logs/replay-to-p1.txt
=== replay same URI ===
HTTP/1.1 200 OK
=== replay mismatched URI ===
HTTP/1.1 407 Proxy Authentication Required
Negative Controls
A fresh easy handle does not leak the header to P2:
logs/p2.log:2
"proxy_authorization": ""
Changing the main CURLOPT_PROXY instead of only CURLOPT_PRE_PROXY also does
not leak, which shows that the stale state is tied to the pre-proxy change path:
logs/p2.log:3
"proxy_authorization": ""
As a local patch check, adding a setpreproxy() helper that calls
changeproxy(data) before updating STRING_PRE_PROXY stops the leak:
patched control
"proxy_authorization": ""
Documentation Note
CURLOPT_PRE_PROXY is documented as a production libcurl option. Its manpage
says setting the option multiple times makes the last string override the
previous one; I did not find documentation saying that changing it requires
CURLOPT_FRESH_CONNECT to avoid stale proxy authentication state.
Impact
A later proxy endpoint in a proxy chain can receive a Proxy-Authorization: Digest response derived from an earlier proxy's challenge. In the local proof,
that leaked Digest response is replayable to the first proxy for the same URI
without knowing the proxy password.
This is not a custom-header redirect case: the application does not set
Proxy-Authorization manually. libcurl generates the leaked header from stale
proxy Digest/auth state after a documented CURLOPT_PRE_PROXY option change.