← Blog
October 25, 202610 min readAppSecVulnerability ResearchBug bountySecurity Engineering

A timing blip is not proof: safely confirming HTTP request smuggling

A single slow response can mean a request smuggling desync — or nothing at all, and confirming one is where the real risk lives: a careless test can corrupt the responses served to real users who share a back-end connection. A practitioner's guide to telling a genuine front-end/back-end desync apart from noise, confirming it without collateral damage, and the HTTP/2-era fixes that actually close it — grounded in RFC 9112 and two 2026 disclosures, in http4s and the Python h2 library.

By The Xalgorix Team

A back-end that answers one request a few hundred milliseconds slower than its neighbours is one of the most ambiguous signals in web security. It might be an HTTP request smuggling desync waiting to be exploited. It might equally be garbage collection, a cold cache, or a network hiccup. Spotting the candidate is the easy, cheap part — and, as with every bug class worth proving, the gap between a suggestive measurement and a confirmed, reportable vulnerability is where the real work lives. With request smuggling, that gap is unusually dangerous to cross, because the obvious way to confirm it corrupts the traffic of real users who happen to share a connection to the same back-end.

Why request smuggling exists at all

HTTP request smuggling is, in PortSwigger's words, “a technique for interfering with the way a web site processes sequences of HTTP requests.” Nearly every site puts a front-end — a CDN, load balancer or reverse proxy — in front of one or more back-end servers, and reuses a single TCP connection to stream many requests from many users to that back-end. The whole scheme depends on both ends agreeing, byte for byte, on where one request stops and the next begins. When they disagree, an attacker can append a prefix to the next person's request.

The disagreement has a specific root cause. HTTP/1.1 gives a message two ways to declare how long its body is: a Content-Length byte count, and Transfer-Encoding: chunked, which ends the body with a zero-sized chunk. RFC 9112 is blunt that the two must never travel together — “A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field” (§6.2) — and that a recipient seeing both should be suspicious: such a message “might indicate an attempt to perform request smuggling… or response splitting” and “ought to be handled as an error” (§6.3). The spec even tells an intermediary that forwards the message anyway to “first remove the received Content-Length field and process the Transfer-Encoding.”

If every server obeyed that, smuggling would not exist. The problem is a chain of servers written by different people: one honours Content-Length, the next honours Transfer-Encoding, and an attacker who sends both — or who obfuscates one so that only one server notices it — makes the two ends frame the stream differently.

CL.TE, TE.CL, and the HTTP/2 downgrade

The classic variants are named for which header each hop trusts. In a CL.TE desync the front-end uses Content-Length and the back-end uses Transfer-Encoding; in TE.CL it is the reverse. TE.TE is the case where both support chunked encoding but one can be tricked into ignoring an obfuscated Transfer-Encoding — a misspelled value, a tab after the colon, a leading space — collapsing it back into a CL.TE or TE.CL. A minimal CL.TE request shows the whole trick in a few lines:

POST / HTTP/1.1
Host: vulnerable.example
Content-Length: 6
Transfer-Encoding: chunked

0

G

Front-end trusts Content-Length (6 bytes: "0" CRLF CRLF "G"),
forwards the lot. Back-end trusts Transfer-Encoding, reads the
zero-size chunk as end-of-body, and leaves a dangling "G" that
becomes the first byte of whoever's request comes next.
Ambiguous request
CL and TE both set
Front-end: Content-Length
forwards everything
Back-end: Transfer-Encoding
stops at chunk 0
Leftover bytes
prefix next request
Front-end and back-end disagree on where the body ends; the leftover bytes prefix another user's request.

The reason this matters more every year, not less, is architecture. A site that speaks HTTP/2 end to end is, as PortSwigger puts it, “inherently immune to request smuggling attacks” — HTTP/2 carries an explicit, unambiguous message length and has no competing headers to disagree about. But a great many deployments terminate HTTP/2 at the edge and speak HTTP/1.1 to older back-ends, and that translation — “HTTP downgrading” — is where the ambiguity comes back. PortSwigger calls downgrading “fraught with issues,” and it is the vector behind most modern smuggling.

Finding a candidate without taking the site down

You cannot confirm a desync by reading a single response, because a smuggled prefix lands on the next connection, not yours. The safest first move is timing, which PortSwigger describes as “the most generally effective way” to detect smuggling. The idea is to send a request that only causes a delay if a desync exists. In a CL.TE probe the front-end forwards only the bytes its Content-Length counts, so the back-end receives anunterminated chunked body: it reads the chunk it was handed, never sees the terminating zero-size chunk, and sits waiting for a next chunk that never arrives until it times out. That pause is observable and attributable — and note it is the opposite of the completed-body poisoning example above, not the same payload.

Order matters for safety, not just speed. PortSwigger is explicit that a TE.CL timing test “will potentially disrupt other application users” if the application is actually vulnerable to CL.TE, and so recommends running the CL.TE test first and moving to TE.CL only if it is unsuccessful. Doing it in the wrong order is how a “harmless” probe breaks someone else's session.

A timing pause is a strong lead, but it is still only a lead — cloud back-ends are noisy, and a slow response has many innocent explanations. Treat it as the trigger for careful confirmation, not as the finding itself.

Confirming is the part that can hurt people

Real confirmation is a differential test: send an “attack” request designed to leave a prefix behind, then immediately send a benign “normal” request on a different connection and watch whether it comes back wrong — a 404 for a path that should exist, say. If it does, the attack prefix really did attach to a later request. That is proof. It is also precisely the moment the test stops being safe, because the request your prefix attaches to might not be yours.

PortSwigger's own safeguards for this are worth following to the letter, and most of them exist to protect other users rather than to improve your signal:

  • Send the attack and normal requests on different network connections — confirming on a single connection does not prove a cross-connection desync.
  • Use the same URL and parameter names where possible, because routing may send different paths to different back-ends and quietly invalidate the test.
  • Send the normal request immediately after the attack, since other users' traffic races yours for the poisoned slot; expect to retry.
  • If the interference lands on a response that was not your normal request, you have just affected another user. That is the signal to stop, not to tune the payload.
Normal request comes back wrong?
Yes, on your request
Desync confirmed with evidence
No effect
Retry a few times, then treat as unconfirmed
A third party is hit
You affected a real user — stop testing
Prove the desync with a controlled pair of requests; if a third party is hit, the test is over.
Request smuggling is one of the few web bugs where a confirmation step, done carelessly, can corrupt data for people who are not part of your test. Run it only against systems you own or are explicitly authorised to test, keep every probe self-contained, and never escalate to a full exploit on shared infrastructure to “prove impact.” The controlled differential above is proof enough.

Two 2026 desyncs, and what they teach

The failure mode is almost always a parser that is too lenient about framing. In CVE-2026-69204, disclosed on September 15, 2026, the http4s Ember server's HTTP/1.1 request parser did not reject a message carrying both a Content-Length and a Transfer-Encoding header. That is the exact condition RFC 9112 says to treat as an error; because Ember accepted it, a mismatch in body framing between Ember and an upstream proxy enabled CL.TE smuggling. The fix, in 0.23.35 (and 1.0.0-M47 on the milestone line), is simply to reject the ambiguous request — the spec-compliant behaviour.

The HTTP/2 angle shows up in CVE-2026-71554, published on August 3, 2026 against the Python h2 library (fixed in 4.4.1, rated CVSS 5.3). There, h2 accepted request header blocks containing more than one Host header. On its own that is an HTTP/2 quirk; but when an application downgrades that request to HTTP/1.1, “the resulting request carries two Host header lines,” which the advisory calls “a request smuggling primitive.” It is a clean illustration of why downgrading is dangerous: a value that is unambiguous in HTTP/2 becomes a framing disagreement the moment it is rewritten into HTTP/1.1.

The fixes that hold

Smuggling defences are unusual in that one of them is close to absolute and the rest are about surviving a translation layer.

  • HTTP/2 end to end removes the class, not an instance of it. Where you control both hops, the strongest fix is to not downgrade at all.
  • If you must downgrade, normalise and reject. Reject any request that carries both Content-Length and Transfer-Encoding, any duplicated length-bearing or routing header (a second Host, a second Content-Length), and any header containing a bare newline. These are the exact preconditions the 2026 cases above turned into desyncs.
  • Prefer rejection to best-effort parsing. RFC 9112 lets a server either reject a both-headers request or process it by Transfer-Encoding alone, but is firm that the server “MUST close the connection after responding to such a request to avoid the potential attacks” (§6.1). A front-end that stays strict and fails closed denies the attacker the disagreement they need.
  • Reduce back-end connection reuse where the architecture allows it. A poisoned prefix needs a shared, kept-alive connection to land on a victim; not pooling back-end connections across unrelated clients shrinks the blast radius even when a parser slips.

Proof, not a timing blip

A backlog of “possible request smuggling” tickets based on timing alone is worse than useless: each one is unactionable until someone does the dangerous confirmation work, and the occasional real desync hides among the cache misses. A finding that carries its evidence — here is the controlled attack request, here is the benign request that came back poisoned on a separate connection, and here is where we stopped — is something a platform team fixes immediately. That is the whole premise of exploit-verified testing: a result should be evidence, not a guess, and Xalgorix works its 22-phase methodology toward a demonstration rather than a label.

It is worth being honest about where that ambition meets its limits here. Request smuggling is one of the hardest bug classes to confirm safely in an automated way, precisely because the confirming step can spill onto other users of a shared back-end. A responsible tool — autonomous or human-driven — treats a timing anomaly as a lead, confirms with the smallest self-contained differential it can, and refuses to escalate to a user-affecting exploit to pad a report. On genuinely shared production infrastructure, the right answer is sometimes to prove the parser accepts an ambiguous request and reason from there, rather than to demonstrate a live desync you cannot contain. Proof that costs someone else their session is not proof worth having.

References

Ready to see it prove a bug?

Start scanning on Cloud →