curl · Report #3963330
libcurl Digest/NTLM authentication ignores an explicit Authorization header
How it was found
Summary
When an application sets an explicit Authorization header via CURLOPT_HTTPHEADER while libcurl also has credentials configured and HTTP Digest or NTLM authentication is selected/challenged, libcurl does not honor the application's header as a replacement — it sends its own internally generated Digest/NTLM credential in addition to the application's header. Basic, Bearer, Negotiate and AWS_SIGV4 were tested as controls and correctly suppress libcurl's own header in this situation; Digest and NTLM do not. This is reproducible on released libcurl 8.21.0 and on current HEAD (5c61e16869 / 8.22.0-DEV). The security consequence: against a test backend enforcing real per-tenant access control, a request carrying only a low-privilege tenant Bearer token retrieved another tenant's protected data, because the backend's ordinary Authorization-header parsing picked up libcurl's unwanted, more-privileged Digest credential instead of the application's own. CURLOPT_HTTPHEADER's documentation states that an application-supplied header "otherwise generated and used by libcurl internally" is used instead of libcurl's own, with no documented exception for Digest/NTLM. AI assistance (Claude) was used for source analysis, experiment design, and drafting this report; all technical claims and reproduction steps below were independently run and verified by the researcher.
Affected Version
- curl/libcurl 8.21.0 (release tag
curl-8_21_0) - Also reproduced on current HEAD, commit
5c61e16869(8.22.0-DEV) - Build used for reproduction: local build from source, OpenSSL backend,
-DCURL_ENABLE_NTLM=ON(NTLM is not enabled by default in a cmake build and had to be turned on explicitly to test it) curl -Voutput from the two builds used:curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.5.5 zlib/1.3.1curl 8.22.0-DEV (Linux) libcurl/8.22.0-DEV OpenSSL/3.5.5 zlib/1.3.1
Steps to Reproduce
- Build curl from the
curl-8_21_0tag (or use HEAD) with OpenSSL and NTLM enabled. - Write a minimal libcurl client that sets
CURLOPT_USERPWDto a privileged test credential (svc_account:PRIVILEGED_INFRA_SECRET). - On the same handle, set
CURLOPT_HTTPHEADERwith an explicitAuthorization: Bearer LOW_PRIV_TENANT_B_TOKENheader. - Set
CURLOPT_HTTPAUTHtoCURLAUTH_DIGEST. - Point the client at a test server that answers the first request with
401+WWW-Authenticate: Digest realm="...", nonce="...", qop="auth", algorithm=MD5. - Capture the second (retried) request on the wire.
- Confirm it contains two
Authorizationheader lines:Authorization: Digest username="svc_account", realm="...", nonce="...", uri="...", cnonce="...", nc=00000001, qop=auth, response="...", algorithm=MD5 Authorization: Bearer LOW_PRIV_TENANT_B_TOKEN - Have the server resolve identity using ordinary
Authorizationheader access (Python'shttp.server/email.message.get(), which returns the first occurrence, was used in our test) and confirm it resolves tosvc-account, nottenant-b. - Confirm the server, enforcing a real ACL (
tenant-b→/tenants/b/dataonly,svc-account→ all resources), returns the protected resource for a request that should have been scoped totenant-b. - Repeat with
CURLOPT_USERPWDremoved (only the Bearer token set). Confirm the same server correctly denies the Digest challenge and no protected data is returned.
This was implemented as a standalone libcurl C reproducer (repro.c, parameterized by URL/scheme/credential/custom header) driving a local Python test server (authz_server.py) that performs the ACL check in step 9. Both are available on request.
Vulnerable Source
lib/http.c, functionoutput_auth_headers():- NTLM branch (
if(authstatus->picked == CURLAUTH_NTLM), ~line 707) — callsCurl_output_ntlm()unconditionally. - Digest branch (
if(authstatus->picked == CURLAUTH_DIGEST), ~line 716) — callsCurl_output_digest()unconditionally.
- NTLM branch (
lib/http_ntlm.c,Curl_output_ntlm()(~line 116) — no header-suppression check.lib/http_digest.c,Curl_output_digest()(~line 84) — no header-suppression check.
By contrast, the Basic branch (~line 727) and Bearer branch (~line 751) in the same output_auth_headers() function both gate their own header generation on !Curl_checkheaders(data, STRCONST("Authorization")) (and !Curl_checkProxyheaders(...) for the proxy case) and set authstatus->done = TRUE instead of adding their own header when the application has already supplied one. The Negotiate branch (~line 688) has the same guard as of a recent fix. Digest and NTLM are the two remaining branches in this function without that check anywhere in their call chain.
Impact
Confirmed impact
Using a test backend that enforces an actual per-tenant ACL (not a label), a request built entirely by the application to carry only a low-privilege, tenant-scoped Bearer token retrieved a different tenant's protected resource. This happened because libcurl also emitted its own Digest credential (derived from a privileged credential configured on the same handle), and the backend's ordinary Authorization header handling resolved the request's identity from that credential instead of the application's own. Removing the privileged credential from the client configuration (keeping only the Bearer token) reproduces the correct, secure outcome on the same server: the Digest challenge cannot be answered and no protected data is returned. This isolates the cause to the specific vulnerable configuration (a handle carrying both a privileged fallback credential and a per-call override header), not to any flaw in the ACL logic itself.
Conditional impact
In a separate, explicitly isolated experiment, CURLOPT_RESOLVE was used to point the application's trusted hostname at a different endpoint while curl still treated it as the same origin. In that setup, libcurl's own Digest credential was delivered to that endpoint. This was not tested with a live DNS rebinding or DNS cache-poisoning attack — CURLOPT_RESOLVE was used only to isolate what happens once a hostname resolves elsewhere, not to demonstrate how an attacker would cause that resolution change in practice. This is reported as a conditional, secondary consideration, not as the primary vulnerability or a proven attack chain.
Limitations
- This is not a generic authentication bypass: no external party forges or steals a credential; the confusion arises from libcurl emitting a credential the application already possessed and explicitly tried to withhold from this specific request.
- Not proven to affect every HTTP server or backend. Duplicate-
Authorization-header handling was checked against several implementations; Pythonhttp.server, Node.js, Gonet/http, Flask/Werkzeug and Apache all produced behavior (first-value or merged-value parsing) that could select the unwanted Digest credential, while nginx rejected the malformed duplicate-header request outright. This varies by server/backend and is not claimed to be universal. - Not demonstrated as request smuggling or response desynchronization. No proxy/backend differential exploit was built or shown.
- Cross-origin credential handling was tested directly and works correctly: on a redirect to a different origin, libcurl does not send the credential to the new host. We are not claiming arbitrary cross-origin credential leakage.
Weakness
Incorrect Authorization (CWE-863) — the demonstrated impact is that the receiving side authenticates and authorizes the request under the wrong identity because libcurl silently attaches a credential the application had explicitly tried to keep off the request. This is closer to "the wrong authorization decision gets made" than to a raw credential leak, so CWE-863 fits better than a generic information-exposure weakness. If the program prefers a different single weakness (e.g. Improper Authentication / CWE-287), we'd defer to their taxonomy, but CWE-863 is our primary recommendation.
Severity
Medium.
Justification, based only on demonstrated (not conditional) impact: this does cause a real cross-tenant authorization violation against a backend enforcing a legitimate ACL, which is more than a cosmetic or protocol-compliance issue. But it requires a specific, non-default application configuration (a handle carrying both a privileged fallback credential and a per-call override header, with Digest or NTLM actually selected/challenged) rather than being exploitable against an arbitrary curl user or default configuration. It does not cross curl's origin boundary — that protection was tested directly and holds. It does not by itself compromise a server that doesn't already have this kind of split-credential configuration. Given the real but conditioned nature of the impact, we believe Medium is the accurate rating; we are not asserting High/Critical, and we are not inflating this simply because the demonstration involved privileged test credentials.
Remediation
Add the same suppression check already used by Basic/Bearer/Negotiate to the Digest and NTLM branches of output_auth_headers() in lib/http.c: before calling Curl_output_digest() / Curl_output_ntlm(), check !Curl_checkheaders(data, STRCONST("Authorization")) (and !Curl_checkProxyheaders(...) for the proxy path), and set authstatus->done = TRUE instead of adding libcurl's own header when the application has already supplied one — mirroring the existing Basic/Bearer/Negotiate branches in the same function. A regression test should cover: application sets CURLOPT_HTTPHEADER with an explicit Authorization: header, credentials are configured via CURLOPT_USERPWD, CURLOPT_HTTPAUTH includes CURLAUTH_DIGEST (and separately CURLAUTH_NTLM), server challenges with the corresponding WWW-Authenticate, and the test asserts the retried request contains exactly one Authorization header, equal to the application-supplied value.