HexBrain

curl · Report #3777381

PRE_PROXY change leaks stale Proxy Digest state across proxy-chain boundary

N/Astatus
1upvotes
3 Jun 2026disclosed

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.

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