curl · Report #3774977
libcurl 8.20.0 ignores HTTP Digest domain protection space and preemptively leaks Digest auth outside the declared scope
How it was found
Summary:
libcurl 8.20.0 ignores the server-declared HTTP Digest domain protection space for origin authentication and reuses stored Digest state too broadly on the same easy handle.
After a successful Digest-authenticated request, a later request on the same easy handle can receive a preemptive Authorization: Digest ... header even when the new request is outside the domain scope declared by the server's original WWW-Authenticate challenge.
In my strongest reproduction:
- request 1 receives
WWW-Authenticate: Digest realm="realmA", nonce="nonceA", algorithm=MD5, domain="/first" - request 2 on the same easy handle to
/reuse2sends a preemptiveAuthorization: Digest ... - a fresh easy handle to
/fresh2sends noAuthorizationat all - forcing a fresh TCP connection for request 2 still leaks the Digest header
This is a Digest protection-space violation with concrete confidentiality impact: Digest auth transcripts are disclosed outside the declared scope and can be used for offline password guessing against low-entropy passwords.
Affected version
Primary reproduced target: curl/libcurl 8.20.0 on Linux x86_64.
I reproduced this using a local build from the official curl 8.20.0 release source.
Representative runtime output from the reproduced build: curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.1 zlib/1.3.1 Release-Date: 2026-04-29 Protocols: http https ipfs ipns ws wss Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile libz SSL threadsafe TLS-SRP UnixSockets
I also reproduced the same sink on current master at: 24874a4f04cbbafd878441ccef11e3caa5897db5
Steps To Reproduce:
- Build curl 8.20.0 locally and build the attached PoC against the resulting libcurl.so. cd /tmp git clone https://github.com/curl/curl.git curl-8.20.0-wt cd /tmp/curl-8.20.0-wt git checkout curl-8_20_0 autoreconf -fi
mkdir -p /tmp/curl-8.20.0-build
cd /tmp/curl-8.20.0-build
/tmp/curl-8.20.0-wt/configure
--with-openssl
--without-libidn2
--without-libpsl
--without-brotli
--without-zstd
--without-libssh2
--without-nghttp2
--without-libgsasl
--without-gssapi
--disable-ldap
--disable-ldaps
--disable-ftp
--disable-file
--disable-tftp
--disable-rtsp
--disable-dict
--disable-telnet
--disable-pop3
--disable-imap
--disable-smb
--disable-smtp
--disable-gopher
--disable-mqtt
--disable-manual
make -j2
cc -I/tmp/curl-8.20.0-wt/include
digest_reuse_probe.c
/tmp/curl-8.20.0-build/lib/.libs/libcurl.so
-Wl,-rpath,/tmp/curl-8.20.0-build/lib/.libs
-o /tmp/digest_reuse_probe_8200
- Start the attached plan-driven Digest challenge server with the attached domain-scope plan.
python3 digest_challenge_server.py
--plan digest_plan_domain_scope.json
--log /tmp/digest_domain_scope.log
The attached plan serves: /first: first response 401 with WWW-Authenticate: Digest realm="realmA", nonce="nonceA", algorithm=MD5, domain="/first", then 200 /reuse2: 200 without ever challenging /fresh2: 200 without ever challenging
- Run the PoC against the local build.
/tmp/digest_reuse_probe_8200
alice:sekret
http://127.0.0.1:18111/first
http://127.0.0.1:18111/reuse2
http://127.0.0.1:18111/fresh2
Expected client output: same_1 res=0 code=200 same_2 res=0 code=200 fresh_2 res=0 code=200
- Check the server-side log. sed -n '1,20p' /tmp/digest_domain_scope.log
Representative result: {"authorization": null, "path": "/first", "reqno": 1} {"authorization": "Digest username="alice", realm="realmA", nonce="nonceA", uri="/first", response="faf2faf3d759b97cd2d6bcf94dda7cbb", algorithm=MD5", "path": "/first", "reqno": 2} {"authorization": "Digest username="alice", realm="realmA", nonce="nonceA", uri="/reuse2", response="1114f220dfe6109d1466c303466aff70", algorithm=MD5", "path": "/reuse2", "reqno": 3} {"authorization": null, "path": "/fresh2", "reqno": 4}
This shows: the first request negotiates Digest for /first a later request on the same easy handle preemptively sends Digest auth to /reuse2, outside domain="/first" a fresh easy handle to /fresh2 sends no Digest header
- Run the fresh-connection control to rule out keep-alive artifacts.
DIGEST_FRESH_SECOND=1
/tmp/digest_reuse_probe_8200
alice:sekret
http://127.0.0.1:18111/first
http://127.0.0.1:18111/reuse2
http://127.0.0.1:18111/fresh2
The same preemptive Digest header still appears on /reuse2, showing this is reused Digest easy-handle state, not keep-alive connection reuse.
Supporting Material/References:
attached bundle: curl_digest_domain_scope_bundle_2026-06-01.zip included client PoC: digest_reuse_probe.c included plan-driven server: digest_challenge_server.py included strongest plan: digest_plan_domain_scope.json included logs: digest_domain_scope.log digest_domain_scope_master.log digest_domain_scope_master_freshconn.log included write-up: REPORT_digest_domain_scope_preemptive_leak.md
Root cause notes: the preemptive Digest send path in lib/http_digest.c reuses stored Digest state on the easy handle lib/vauth/digest.c parses and stores fields such as nonce, realm, opaque, qop, and algorithm, but does not parse or enforce the domain directive as a result, the server-declared Digest protection space is effectively widened to the full origin for later preemptive reuse on the same easy handle
Impact
In the reproduced flow, a path outside domain="/first" receives:
usernamerealmnonceuriresponse
from a preemptively forwarded Digest header that should not have been sent there.
This leaked Digest transcript can be used for offline password guessing in realistic low-entropy-password deployments. I verified that the captured transcript in the attached PoC is sufficient to recover the sample password sekret from a small dictionary.
This is not an SSRF claim and not a keep-alive reuse artifact. The security claim is limited to preemptive Digest auth disclosure outside the server-declared protection space.