One request, two lengths: HTTP request smuggling and the HAProxy Transfer-Encoding bug
CL.TE and TE.CL desync happen when two servers disagree about where a request ends. CVE-2019-18277 in HAProxy is the clean example: how the disagreement forms, how to find it safely, and how to shut it down.
By Ahmed PingerPublished
8 min read1 view
Request smuggling is a disagreement about one number: how many bytes long is this request body. Put two HTTP parsers in a line, a front-end proxy and a back-end server, and if they answer that question differently for the same bytes, the attacker gets to write the end of one request and the start of the next. Everything else is detail.
HTTP/1.1 gives a message two ways to declare its body length. Content-Length states a byte count. Transfer-Encoding: chunked says the body arrives in size-prefixed chunks and ends with a zero-length chunk. A request should never carry both, because then the length is ambiguous. Attackers send both on purpose, so that the front-end honours one header and the back-end honours the other.
The bug in one distinction
There are two shapes, and they are named for who trusts what.
CL.TE. The front-end uses Content-Length, the back-end uses Transfer-Encoding. The front-end reads the number of bytes the attacker declared and forwards them. The back-end reads chunked, finishes at the zero chunk early, and treats the remaining bytes as the beginning of a second request still sitting in the connection.
TE.CL. The reverse. The front-end honours chunked, the back-end honours the byte count. The leftover bytes again become a prefix on whatever request arrives next.
Either way the effect is the same: bytes the attacker controls are prepended to the next client's request on a reused back-end connection. That victim can be anyone whose traffic lands on that connection, which is why a single crafted request can rewrite a stranger's session.
Why it varies
The specification did try to settle this. RFC 9112 section 6.1 says a server that receives both Content-Length and Transfer-Encoding may reject the request or process it according to Transfer-Encoding alone, and either way must close the connection after responding. Section 6.3, the message body length rules, restates the precedence: Transfer-Encoding overrides Content-Length, such a message "ought to be handled as an error", and an intermediary that forwards it anyway must strip Content-Length first. The same section says that when Transfer-Encoding is present in a request but chunked is not the final encoding, the server must answer 400 and close.
The rules are clear. The problem is that a proxy chain is only as strict as its most lenient link, and "lenient" here means "guessed instead of rejecting." One server sees a slightly malformed Transfer-Encoding header and ignores it, falling back to Content-Length. The next server sees the same header, decides it is close enough to chunked, and acts on it. Nothing crashed. Both servers produced a response. They just disagreed about the byte boundary, and that disagreement is the whole vulnerability. James Kettle's 2019 research made the point plainly: tolerating ambiguous messages is the risk, and rejecting them causes fewer problems than trying to repair them.
The real CVE
CVE-2019-18277 is the disagreement in its cleanest documented form. NVD published it on 2019-10-23 with a CVSS 3.1 base score of 7.5 (High) and classified it as CWE-444, inconsistent interpretation of HTTP requests. HAProxy's own 2.0.6 release note describes the fix: in legacy mode, HAProxy "didn't correctly reject messages featuring a transfer-encoding header missing the chunked value."
Nathan Davison, who reported it, described the mechanism at a high level. HAProxy's legacy parser did not treat certain obfuscated Transfer-Encoding header values as valid chunked encoding, so it fell back to Content-Length and forwarded the header untouched. A back-end that parsed the same header more loosely would act on Transfer-Encoding instead. The two length decisions diverged. The setting http-reuse always, which keeps back-end connections open and shares them across clients, is what turns a boundary disagreement into a cross-user attack: without connection reuse there is no next victim's request to poison. Affected builds spanned the 1.7, 1.8, 1.9 and 2.0 branches with the legacy decoder; 2.0.6 fixed it and 2.1 removed the legacy decoder entirely.
The lesson the CVE carries is that the vulnerable component was not doing anything exotic. It was being forgiving about a header, and forgiveness at a trust boundary is a security decision whether or not anyone treated it as one.
A lab you can build
You do not need Docker, and you should not point any of this at a system you do not own. A single Linux VM with Python and one reverse proxy is enough to watch two parsers disagree.
- Install a reverse proxy from your distribution's package manager. Squid, HAProxy or nginx all work; the point is to have two hops.
- Behind it, run a tiny back-end that does one useful thing: report exactly how it framed each request. A short Python
http.serverhandler that reads the headers, logs whether it sawContent-LengthorTransfer-Encoding, and prints the raw byte count it consumed will do. You are not writing an exploit, you are building an oscilloscope. - Drive the pair with a raw socket client, again a few lines of Python using the
socketmodule, so you control the exact bytes on the wire rather than letting a library normalise them.
The experiment is to send a request that declares its length two ways and read the back-end log. When the front-end and back-end record different byte counts for the identical bytes, you have reproduced the class. You do not need to complete a smuggled request against a victim to learn the lesson; the divergence in the two logs is the finding. Keeping the lab at "observe the disagreement" rather than "weaponise it" is deliberate, and it is enough to build the mental model and to test a fix.
Finding it in source and in traffic
In your own stack, request smuggling is usually a property of the deployment, not a single line of code, so look at the seams.
- Count the hops and name the parsers. Every place one HTTP implementation hands a request to another is a candidate: CDN to origin, load balancer to app server, service mesh sidecar to service. Write them down with versions. A pair is only safe if both members agree on framing.
- Grep your own code for hand-rolled HTTP. Any place you read a socket and split on
\r\n\r\n, parseContent-Lengthyourself, or forward headers you did not parse is where a lenient parser is born. Search forTransfer-Encoding,Content-Lengthand rawrecv/readloops in proxy, gateway and middleware code. - Check the reuse settings. Connection reuse to the back-end (
http-reuse, keep-alive pools, HTTP/1.1 upstream) is what converts a framing bug into a cross-user one. It is not wrong to reuse connections, but it raises the cost of any framing disagreement above it.
Fixing it
In order of how much each buys you:
Patch the parsers. CVE-2019-18277 is fixed in HAProxy 2.0.6 and later, and the legacy decoder that carried it is gone from 2.1 onward. The same applies to every proxy and server in the chain: framing bugs are found and fixed regularly, and an out-of-date hop is the usual weak link.
Reject ambiguity, do not normalise it. The strongest posture is the one RFC 9112 permits: a request that carries both length headers, or a Transfer-Encoding that is not cleanly chunked, gets a 400 and the connection is closed. Repairing the request keeps the ambiguity alive for the next hop to interpret differently.
Speak HTTP/2 to the back-end where you can. HTTP/2 carries length unambiguously in its framing, so an end-to-end HTTP/2 path removes the class of disagreement that CL.TE and TE.CL depend on. Kettle's research names this as the durable fix.
Reduce the number of parsers. Every additional hop is another chance for two implementations to disagree. Fewer layers is fewer boundaries, and simplicity is a security property here, not just an operational one.
Detecting it
For live detection, the signal is ambiguity itself. Log and alert on requests that arrive with both Content-Length and Transfer-Encoding, on Transfer-Encoding values that are not exactly chunked, and on back-end 400s that cluster on reused connections. A jump in back-end responses that do not line up with front-end request counts is the shape of a desync in the aggregate.
Take this away
HTTP request smuggling is not a memory bug or an injection; it is two programs answering "where does this request end" differently and both being satisfied with their answer. CVE-2019-18277 is worth remembering precisely because nothing dramatic happened inside HAProxy: it declined to reject a malformed header, fell back to a length it could compute, and passed the header along. The fix was to reject instead of tolerate.
When you audit a stack for this, you are not looking for a vulnerable function. You are looking for two parsers at a trust boundary and asking whether they agree, byte for byte, on where every request ends. Where they might not, make the stricter one refuse rather than guess.
Further reading
- NVD: CVE-2019-18277: the HAProxy Transfer-Encoding entry, CWE-444, CVSS 7.5
- HAProxy 2.0.6 release announcement: the fix description in the maintainers' own words
- Nathan Davison: HAProxy HTTP request smuggling: the reporter's write-up of the mechanism and affected versions
- PortSwigger: HTTP request smuggling: CL.TE and TE.CL explained with diagrams
- PortSwigger Research: HTTP Desync Attacks: James Kettle's 2019 research and the case for rejecting ambiguity
- RFC 9112, section 6: the message body length rules, including what to do when both length headers are present
- CWE-444: Inconsistent Interpretation of HTTP Requests: the weakness class
Was this useful?
Get the next write-up by email
New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.
Prefer a feed? RSS. How we handle your address: privacy notice.
Comments
Loading comments…