HexBrain

curl · Report #3963330

libcurl Digest/NTLM authentication ignores an explicit Authorization header

Informativestatus
mediumseverity
14upvotes
25 Aug 2026disclosed

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 -V output from the two builds used:
    • curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.5.5 zlib/1.3.1
    • curl 8.22.0-DEV (Linux) libcurl/8.22.0-DEV OpenSSL/3.5.5 zlib/1.3.1

Steps to Reproduce

  1. Build curl from the curl-8_21_0 tag (or use HEAD) with OpenSSL and NTLM enabled.
  2. Write a minimal libcurl client that sets CURLOPT_USERPWD to a privileged test credential (svc_account:PRIVILEGED_INFRA_SECRET).
  3. On the same handle, set CURLOPT_HTTPHEADER with an explicit Authorization: Bearer LOW_PRIV_TENANT_B_TOKEN header.
  4. Set CURLOPT_HTTPAUTH to CURLAUTH_DIGEST.
  5. Point the client at a test server that answers the first request with 401 + WWW-Authenticate: Digest realm="...", nonce="...", qop="auth", algorithm=MD5.
  6. Capture the second (retried) request on the wire.
  7. Confirm it contains two Authorization header lines:
    Authorization: Digest username="svc_account", realm="...", nonce="...", uri="...",
      cnonce="...", nc=00000001, qop=auth, response="...", algorithm=MD5
    Authorization: Bearer LOW_PRIV_TENANT_B_TOKEN
    
  8. Have the server resolve identity using ordinary Authorization header access (Python's http.server/email.message.get(), which returns the first occurrence, was used in our test) and confirm it resolves to svc-account, not tenant-b.
  9. Confirm the server, enforcing a real ACL (tenant-b → /tenants/b/data only, svc-account → all resources), returns the protected resource for a request that should have been scoped to tenant-b.
  10. Repeat with CURLOPT_USERPWD removed (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, function output_auth_headers():
    • NTLM branch (if(authstatus->picked == CURLAUTH_NTLM), ~line 707) — calls Curl_output_ntlm() unconditionally.
    • Digest branch (if(authstatus->picked == CURLAUTH_DIGEST), ~line 716) — calls Curl_output_digest() unconditionally.
  • 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; Python http.server, Node.js, Go net/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.

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