What it is
HTTP request smuggling is when two servers on the same path — usually a reverse proxy or CDN in front and a backend behind — disagree on where one request ends. The attacker exploits that disagreement to smuggle the start of a second request into the tail of the first, and the backend treats it as a separate request — but attached to the next user's connection.
Why you should care
This is the topic where the fix is almost never in your application code — it lives in the configuration between the proxy and the backend, and in whether the two speak the same version of HTTP.
Three things that make it worse than it looks:
- It attacks OTHER users. Unlike most vulnerabilities that only harm the attacker's own request, the smuggled part attaches to the next victim's request on a shared connection — so the attacker captures someone else's request, or injects a response for them.
- It bypasses every front-tier control. The WAF, authentication and rate limiting at the proxy see the first request as valid; the smuggled part never passes them because the proxy does not consider it a request.
- It does not appear in a single-request test. It needs exactly two requests on a shared connection, so it is invisible to nearly every test suite.
And the good news for defence: the root cause is HTTP/1.1 and how it determines body length (Content-Length vs Transfer-Encoding: chunked). End-to-end HTTP/2 closes nearly all of this class, because it has one explicit way to determine length. So the strongest fix is architectural: HTTP/2 from client to backend, with no downgrade to HTTP/1.1 in between.
How the attack works
The mechanism is a disagreement about how to read body length. HTTP/1.1 has two ways — Content-Length and Transfer-Encoding: chunked — and when a request carries both, the spec says prefer chunked, but not every server obeys.
sequenceDiagram autonumber actor A as Attacker participant P as Proxy (reads Content-Length) participant B as Backend (reads Transfer-Encoding) A->>P: POST with BOTH Content-Length: 6<br/>AND Transfer-Encoding: chunked<br/>body containing a hidden request Note over P: Proxy follows Content-Length → sees ONE request,<br/>forwards it verbatim to the backend. P->>B: forwards the whole byte blob Note over B: Backend follows Transfer-Encoding → sees the request<br/>end EARLIER, the remainder is the START of<br/>a new request, kept in the buffer. actor V as Victim V->>P: a normal GET / P->>B: forwards the victim request Note over B: The remainder + the victim request JOIN →<br/>the victim request is altered, or captured.The key point: the smuggled part attaches to the next victim's request on the shared proxy↔backend connection. This is why it attacks other people rather than only the attacker — and why it does not appear in a single-request test.
Four variants, named for which side reads which header:
| Name | Proxy reads | Backend reads | Note |
|---|---|---|---|
| CL.TE | Content-Length | Transfer-Encoding | The classic |
| TE.CL | Transfer-Encoding | Content-Length | The reverse |
| TE.TE | both, but normalise differently | One side is tricked into dropping TE by a malformed header | |
| H2.CL / H2.TE | HTTP/2 in front, HTTP/1.1 behind | Downgrade recreates the hole even when the client uses HTTP/2 |
The last row matters: many assume HTTP/2 at the CDN is enough, but if the CDN downgrades to HTTP/1.1 to talk to the backend the whole class returns — and worse, because HTTP/2 permits headers HTTP/1.1 forbids, creating new variants (request splitting via header injection).
Diagram description: A sequence diagram for a CL.TE attack. The attacker sends a POST carrying both Content-Length and Transfer-Encoding chunked, with a body containing a hidden request. The proxy reads by Content-Length so it sees only one request and forwards the whole byte blob to the backend. The backend reads by Transfer-Encoding so it sees the request end earlier and keeps the remainder — the start of a new request — in its buffer. When a victim sends a normal GET through the same proxy, the backend joins the remainder with the victim request, so the victim request is altered or captured.
Concrete example
A CL.TE payload. The crucial part is that the request carries both length-determining headers.
POST / HTTP/1.1Host: app.exampleContent-Length: 6Transfer-Encoding: chunked 0\r\n\r\nGPOST / HTTP/1.1\r\nFoo: xThe proxy reads Content-Length: 6 → the body is 0\r\n\r\n (exactly 6 bytes including CRLFs), and forwards the whole blob. The backend reads Transfer-Encoding: chunked → sees the 0 chunk as the end, so GPOST / HTTP/1.1... is the start of a new request kept in the buffer.
# The victim request arrives next, on the same proxy↔backend connection:GET /home HTTP/1.1Host: app.exampleCookie: session=<the VICTIM's session># The backend joins the remainder with the victim request, so it sees:GPOST /home HTTP/1.1 ← a junk method, or worse: a POST to /adminFoo: xGET /home HTTP/1.1Host: app.exampleCookie: session=<victim session># Timing-based detection (no real victim needed). A CL.TE payload makes the backend# WAIT for bytes that never arrive → an abnormally slow response.time curl -s -o /dev/null https://app.example/ \ -H 'Content-Length: 4' -H 'Transfer-Encoding: chunked' \ --data-binary $'1\r\nA\r\nX'# If the request hangs ~10s before returning → a sign of CL/TE disagreement.# nginx trước một backend Kestrel. Trông ổn, và nó để lộ smuggling.nginx_vulnerable: | server { listen 443 ssl http2; # ✓ HTTP/2 với CLIENT server_name app.example; location / { # ❌ proxy_pass mặc định nói HTTP/1.1 với upstream. Nên dù client dùng # HTTP/2, có một bước DOWNGRADE về HTTP/1.1 ở đây — và toàn bộ lớp lỗi # desync quay lại (biến thể H2.CL/H2.TE ở khối 3). # # "CDN của chúng ta dùng HTTP/2" là câu che giấu đúng dòng này. proxy_pass http://kestrel_backend; # ❌ Và không có gì từ chối request mơ hồ. nginx chuyển tiếp request có # cả Content-Length lẫn Transfer-Encoding, và Kestrel có thể đọc độ # dài body KHÁC cách nginx đọc. } } # ❌ Và WAF chặn /admin Ở ĐÂY — một kiểm soát CHỈ sống ở proxy. Một request /admin # được smuggle vào backend không đi qua location này, nên nó vòng qua WAF hoàn toàn. # location /admin { deny all; }What happened in the wild
James Kettle's research (PortSwigger, 2019) — "HTTP Desync Attacks". The paper that revived this class (first described in 2005) and showed it existed at scale on real CDNs and load balancers, with a timing-based detection technique. It is the source of the variant table in block 3, and the reason this topic is taken seriously again.
"HTTP/2: The Sequel Is Always Worse" (James Kettle, 2021). Showed that moving to HTTP/2 at the CDN is not enough if the CDN downgrades to HTTP/1.1 to talk to the backend — and worse, downgrade creates new variants because HTTP/2 permits headers HTTP/1.1 forbids. This is why block 6 stresses that HTTP/2 must be end-to-end.
And a bug bounty family: smuggling to bypass WAF controls. The recurring pattern: a /admin request blocked by a WAF at the proxy, but the same request smuggled into the backend never passes the WAF — because the proxy does not consider the smuggled part a request. This illustrates block 2's point: smuggling bypasses EVERY front-tier control.
How to defend
End-to-end HTTP/2 — the architectural fix that closes nearly the whole class
mandatoryThe root cause is HTTP/1.1's two ways of determining body length. HTTP/2 has only one, explicit in the frame structure, so CL/TE disagreement cannot exist.
The condition is end-to-end: client → CDN → proxy → backend all speaking HTTP/2, with no step downgrading to HTTP/1.1. This is the commonly misunderstood part — people stop at "the CDN uses HTTP/2" and miss that the CDN usually speaks HTTP/1.1 to the origin. If any step downgrades, the class returns (the H2.CL/H2.TE variants in block 3), and worse because HTTP/2 permits headers HTTP/1.1 forbids.
Where downgrade is unavoidable (a legacy backend speaks only HTTP/1.1), the minimum is:
- Enable strict HTTP/1.1 validation at the proxy: reject requests carrying BOTH
Content-LengthandTransfer-Encoding, reject malformedTransfer-Encoding, reject duplicate headers. - Normalise the request before forwarding: the proxy rebuilds the request from its parsed model rather than forwarding bytes verbatim. This is what a modern reverse proxy (nginx, Envoy) does when configured correctly.
# ── Đường 1 (ưu tiên) · HTTP/2 END-TO-END ────────────────────────────────────# Nguyên nhân gốc là hai cách xác định độ dài của HTTP/1.1. HTTP/2 có MỘT cách,# tường minh trong khung, nên bất đồng CL/TE không tồn tại được.nginx_http2_e2e: | server { listen 443 ssl; http2 on; # HTTP/2 với client server_name app.example; location / { # grpc_pass hoặc proxy với HTTP/2 tới upstream. Điều kiện là KHÔNG có bước # nào downgrade về HTTP/1.1 — client → nginx → backend đều HTTP/2. grpc_pass grpc://kestrel_backend; # Kestrel bật HTTP/2 } } # ── Đường 2 · buộc phải HTTP/1.1 → loại bỏ khả năng BẤT ĐỒNG ─────────────────nginx_http1_hardened: | server { listen 443 ssl; http2 on; server_name app.example; # Từ chối request mơ hồ. Đây là dòng quan trọng nhất khi phải dùng HTTP/1.1: # một request có cả hai header xác định độ dài là dị thường, và ĐOÁN chỉ an # toàn khi backend đoán GIỐNG — điều ta không kiểm soát được. if ($http_transfer_encoding != "") { # nginx hiện đại tự bỏ Content-Length khi có Transfer-Encoding hợp lệ, # nhưng ta chặn tường minh mọi Transfer-Encoding dị dạng. set $te_ok 0; } location / { proxy_pass http://kestrel_backend; proxy_http_version 1.1; # Chuẩn hoá: nginx dựng lại request từ mô hình đã parse, không chuyển byte # nguyên xi. Đây là điều làm proxy và backend "đọc" cùng một request. proxy_set_header Connection ""; # Cách ly connection là biện pháp CUỐI khi không nâng được HTTP/2: nếu mỗi # request có connection riêng thì phần smuggle không có request kế tiếp nào # để gắn vào. Tốn hiệu năng, nên chỉ dùng khi buộc phải. # proxy_set_header Connection "close"; } } # ── Kestrel: từ chối request mơ hồ ở CẢ backend ─────────────────────────────# Phòng thủ chiều sâu: kể cả proxy để lọt, backend cũng phải từ chối.kestrel: | builder.WebHost.ConfigureKestrel(o => { // Bật HTTP/2 để đường 1 khả thi. o.ConfigureEndpointDefaults(e => e.Protocols = HttpProtocols.Http1AndHttp2); // Kestrel mặc định đã từ chối request có cả CL lẫn TE (trả 400) từ .NET 5+ — // nhưng kiểm bằng test ở khối 7 vì đây là bất biến ta KHÔNG được để mất. });The proxy and backend must use the SAME way to determine length
mandatorySmuggling is a disagreement, so the direct control is to remove the possibility of disagreement — even when HTTP/1.1 is unavoidable.
- Reject ambiguous requests at the proxy. A request with both
Content-LengthandTransfer-Encodingis an anomaly — RFC 9112 says preferTransfer-Encodingand dropContent-Length, but the safe move is to reject with 400 rather than guess. Configure the proxy to do so. - Use the same stack at both ends where possible. The disagreement happens because the proxy and backend parse HTTP with different code. If both are the same product (or the same HTTP library) they normalise identically.
- Disable connection reuse between proxy and backend (
proxy_http_version 1.1+Connection: close, or a short connection pool) is the last resort: if each request gets its own connection, the smuggled part has no "next request" to attach to. It costs performance, so use it only when you cannot move to HTTP/2.
The important point: this is infrastructure configuration, not application code. The fix lives in the nginx/Envoy/CDN config, and it must be checked by a test at that layer (block 7).
Do not let a security control live ONLY at the proxy tier
Block 2 argues smuggling bypasses every front-tier control. So the control is: do not place important controls only at the proxy.
- Authorisation belongs at the backend, not at "the proxy blocks
/admin". A WAF rule blocking a path is a control smuggling bypasses directly — because the proxy does not treat the smuggled part as a request it applies no rule to it. See the access-control topic: authorisation belongs in the query, not in middleware, and certainly not in a different machine. - Authentication belongs at the backend. If the backend trusts "the proxy already authenticated", then a request smuggled straight into the backend carries no authentication.
This is a corollary of a general principle: a control is only correct at the tier it protects. Smuggling is the extreme example because it lets an entire tier be skipped.
Detection: mismatched responses and abnormal latency
Smuggling leaves two measurable traces, both abnormal in normal traffic:
- Responses matched to the wrong request. A user receiving someone else's response is the clearest sign, and the most unpleasant support ticket — "I saw a stranger's order". Log and alert when a response's content does not match the request's session.
- Abnormal latency in bursts. The timing detection technique in block 4 leaves a signature: a request hangs ~10s because the backend waits for bytes that never come. A run of proxy-tier timeouts while the backend is healthy is the signature of a desync probe.
- Anomalous
Connectionheaders and junk methods (GPOST,POSTGET) in the backend access log. They are fragments of a joined request, and they essentially never appear naturally.
Layer 3 because it blocks nothing. But smuggling is a class where the victim does not know they were harmed (their request was silently altered), so operational detection is the only way to know it is happening.
Verifying the fix
This topic has a distinctive property: most checks run at the infrastructure layer, not the application layer, and a single-request test never finds it.
1. Check the proxy REJECTS ambiguous requests — the direct check for layer 1b:
B=https://app.example# A request with BOTH Content-Length and Transfer-Encoding must be a 400, never a guess.printf 'POST / HTTP/1.1\r\nHost: app.example\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n' \ | openssl s_client -quiet -connect app.example:443 2>/dev/null | head -1# Expect: HTTP/1.1 400. If it is 200 → the proxy is guessing, and it may guess differently from the backend.2. Check HTTP/2 is end-to-end — the check that catches what "the CDN uses HTTP/2" hides:
# Client to CDN — usually HTTP/2, easy to check:curl -sI --http2 "$B/" -o /dev/null -w 'client<->cdn: %{http_version}\n'# CDN to origin — must be checked in the CDN CONFIG, not observable from outside.# In nginx/Envoy: grep the protocol used to talk to the upstream.grep -rE 'proxy_http_version|http2.*upstream|http_protocol_options' deploy/ \ | grep -v '1.1' || echo "WARNING: the upstream may be HTTP/1.1 (a downgrade)"3. Use a dedicated tool, do not write your own. Smuggling detection needs raw bytes with precise timing — HTTP Request Smuggler (a Burp extension) and smuggler.py do that. Run them against staging in a nightly pipeline:
python3 smuggler.py -u https://staging.example.com/ | tee /tmp/smug.txtgrep -qi 'potentially vulnerable\|CL.TE\|TE.CL' /tmp/smug.txt \ && { echo "desync detected"; exit 1; }exit 04. Check no security control lives ONLY at the proxy (layer 2) — smuggling bypasses the proxy tier, so a WAF rule blocking /admin must also be enforced by the backend:
# Call the backend DIRECTLY (bypassing the proxy) at a path the proxy blocks.# It must be refused by the backend on authorisation, not merely because the proxy blocks it.curl -s -o /dev/null -w '%{http_code}\n' http://backend-internal:5100/admin/users# Expect 401/403 from the backend itself, not 200.5. Alert on latency and junk methods in the access log (layer 3). This is an operational check, not a test — but it is the only way to know smuggling is happening in production, because the victim cannot report what they did not see.
import socketimport sslimport time import pytest HOST = "staging.example.com" def _raw_request(payload: bytes, read_timeout: float = 12.0) -> tuple[bytes, float]: """Gửi BYTE THÔ và đo thời gian. Không dùng requests/httpx: chúng chuẩn hoá header, và chuẩn hoá là chính thứ ta đang cố phá để kiểm.""" ctx = ssl.create_default_context() with socket.create_connection((HOST, 443), timeout=15) as raw: with ctx.wrap_socket(raw, server_hostname=HOST) as s: s.sendall(payload) s.settimeout(read_timeout) start = time.monotonic() data = b"" try: while chunk := s.recv(4096): data += chunk except (socket.timeout, TimeoutError): pass return data, time.monotonic() - start class TestNoSmuggling: """Các test này chạy ở tầng HẠ TẦNG, trên byte thô — không ở tầng ứng dụng. Một test qua requests.post() không bao giờ tìm ra smuggling, vì thư viện đó không gửi được một request có cả hai header xác định độ dài.""" def test_proxy_rejects_both_length_headers(self): """Lớp 1b: request có CẢ Content-Length lẫn Transfer-Encoding phải bị 400. ĐOÁN chỉ an toàn khi backend đoán giống proxy — và ta không kiểm soát được điều đó, nên câu trả lời đúng là TỪ CHỐI.""" payload = ( b"POST / HTTP/1.1\r\n" b"Host: " + HOST.encode() + b"\r\n" b"Content-Length: 6\r\n" b"Transfer-Encoding: chunked\r\n" b"\r\n" b"0\r\n\r\n" ) response, _ = _raw_request(payload) status = response.split(b"\r\n", 1)[0] assert b"400" in status, f"request mơ hồ không bị từ chối: {status!r}" def test_clte_probe_does_not_hang(self): """Phát hiện bằng độ trễ (khối 4). Payload CL.TE làm backend ĐỢI byte không tới → response chậm ~10s. Một response NHANH nghĩa là không có bất đồng.""" payload = ( b"POST / HTTP/1.1\r\n" b"Host: " + HOST.encode() + b"\r\n" b"Content-Length: 4\r\n" b"Transfer-Encoding: chunked\r\n" b"\r\n" b"1\r\nA\r\nX" # chunk 1 byte rồi rác — backend TE sẽ đợi tiếp ) _, elapsed = _raw_request(payload, read_timeout=12.0) # Nếu treo gần hết timeout → backend đọc TE trong khi proxy đọc CL: desync. assert elapsed < 5.0, f"response treo {elapsed:.1f}s — dấu hiệu bất đồng CL/TE" def test_junk_method_from_desync_is_not_processed(self): """Nếu một request GPOST (mảnh của request bị ghép) tới được backend và được XỬ LÝ như một request, đó là desync. Backend phải trả 400 cho method không hợp lệ, không phải 200.""" payload = ( b"GPOST / HTTP/1.1\r\n" b"Host: " + HOST.encode() + b"\r\n\r\n" ) response, _ = _raw_request(payload, read_timeout=5.0) status = response.split(b"\r\n", 1)[0] assert b"400" in status or b"501" in status, f"method rác được xử lý: {status!r}"Common mistakes
| The "fix" | Why it is wrong |
|---|---|
| Move the CDN to HTTP/2 and call it done | If the CDN downgrades to HTTP/1.1 to talk to the origin, the class returns (H2.CL/H2.TE). HTTP/2 must be END-TO-END |
| Block the smuggling payload at the WAF | The WAF sees the first request as valid. The smuggled part never passes it because the proxy does not treat it as a request |
| The proxy "prefers Transfer-Encoding per the RFC" | Spec-correct, but rejecting the ambiguous request is safer. Guessing right is only safe if the backend guesses the SAME |
Put authorisation in a proxy rule blocking /admin | Smuggling bypasses it directly. Authorisation belongs at the backend — see the access-control topic |
| A single-request test | Smuggling needs exactly two requests on a shared connection. A single-request test always passes |
| Write your own detection script | It needs precise timing and raw bytes. Use smuggler.py / HTTP Request Smuggler |
| Believe a modern backend is immune | The disagreement is between TWO stacks, not within one. A perfect backend plus an old proxy still desyncs |
The location mistake, and it is the central one: hunting in application code. Smuggling is a bug of the boundary between two servers, so it is in no .cs file. The fix lives in the nginx/Envoy/CDN configuration, and it is checked at that layer.
The severity mistake: rating it "hard to exploit, so low risk". It attacks OTHER USERS, bypasses every front-tier control, and the victim does not know they were harmed. Hard to construct is not rare — the tools in block 7 automate the construction.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…