Skip to main content
All articles

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 Published

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.server handler that reads the headers, logs whether it saw Content-Length or Transfer-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 socket module, 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, parse Content-Length yourself, or forward headers you did not parse is where a lenient parser is born. Search for Transfer-Encoding, Content-Length and raw recv/read loops 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

Was this useful?

Share

Tags

  • web security
  • cve analysis
  • detection engineering
  • penetration testing

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…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Web Application Testing

Manual, business-logic-aware testing of the applications your customers touch.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.