HTTP/2 Request Splitting Explained

HTTP/2 Request Splitting Explained

Written by

in

When a front end downgrades HTTP/2 to HTTP/1.1, it rewrites a binary message into text, and that rewrite can be abused. HTTP/2 request splitting happens when carriage return and line feed sequences hidden inside HTTP/2 header names or values get written literally into the HTTP/1.1 stream, so one request the client sent arrives at the back end as two. It is a precise and common form of http request smuggling that exists only because the downgrade has to flatten structured frames into a text format where newlines carry meaning.

Why the downgrade is where splitting happens

HTTP/2 carries headers as binary name and value pairs. A newline inside a value is just data there; it has no structural role, because the framing, not the bytes, decides where one header ends and the next begins. HTTP/1.1 is the opposite. Its headers are lines of text, and a carriage return and line feed is exactly what separates one header from the next, and a blank one separates the headers from the body.

So when a front end accepts an HTTP/2 request and rewrites it for an HTTP/1.1 back end, it takes values where newlines were harmless and writes them into a format where newlines are structure. If it copies a value across without checking for those bytes, any carriage return and line feed the attacker placed inside a header value is now a real line break in the HTTP/1.1 request. The attacker gets to write new lines into the stream the back end parses.

In HTTP/2 a newline inside a header is data. After the downgrade it is punctuation. Splitting is the attack that lives in that change of meaning.

How HTTP/2 request splitting forges a second request

Consider a request against an invented app, Acme Notes, where the attacker controls one header value. In HTTP/2 the attacker sets that value to a string that contains encoded carriage return and line feed sequences followed by a complete second request:

:method GET
:path /
host: acme-notes.example
x-trace: abc\r\n\r\nGET /admin HTTP/1.1\r\nHost: acme-notes.example\r\nx: x

Inside HTTP/2 this is a single valid request with one oddly long x-trace value. The front end accepts it, then downgrades. If it writes the value out verbatim, the HTTP/1.1 request it sends to the back end looks like this instead:

GET / HTTP/1.1
host: acme-notes.example
x-trace: abc

GET /admin HTTP/1.1
Host: acme-notes.example
x: x

The injected blank line ended the first request early, and everything after it is read by the back end as a second, independent request. One HTTP/2 message became two HTTP/1.1 requests. The second one, the smuggled GET /admin, never passed through the front end as a request, so none of the front end rules that guard that route ever ran. The split request then sits on the shared connection and can be glued onto the next user’s traffic, the same prefix poisoning any smuggle relies on.

How it differs from header injection

Request splitting has a close sibling that uses the same injected bytes for a smaller effect, and it is worth keeping them apart. In HTTP/2 CRLF header injection the attacker injects a single carriage return and line feed to add or overwrite one header on the existing request. The request stays one request; the attacker has just slipped an extra header into it, perhaps to set a value the back end trusts or to override something the front end added.

Request splitting goes further. It injects a full blank line, the double carriage return and line feed that ends a request, and then writes an entire new request line and header block after it. The difference is the blank line. One extra newline forges a header; two in a row forge a request boundary. Both start from the same failure to sanitize values during the downgrade, and both belong to the family we group under injection and input, but splitting turns one message into two while injection keeps one message and edits it.

Where the bytes hide

The injected sequence does not have to live in an ordinary header value. The downgrade has to flatten everything into text, so every field that gets written out is a candidate.

  • Header values. The common case, as above, where a value the attacker controls carries the newline payload.
  • Header names. Some rewriters copy names across with as little checking as values, so a crafted name can carry the break just as well.
  • Pseudo headers. The HTTP/2 :path and :method fields become the request line during the downgrade. A newline that survives into the path can split the request line itself.

The common thread is that HTTP/2 lets these fields hold bytes HTTP/1.1 treats as structural, and the downgrade is the moment those bytes change from data into punctuation. This is why validating input at the HTTP/2 edge is not enough on its own; the dangerous transformation happens later, at the rewrite.

Stopping the split

The defense is to refuse control characters anywhere they could survive the downgrade. A front end should reject any HTTP/2 header name, header value, or pseudo header that contains a carriage return or a line feed, rather than copying it into an HTTP/1.1 line. Because the whole bug is the rewrite, the check has to run at the rewrite, on every field, not only on the fields the application happens to read. Speaking HTTP/2 to the back end removes the rewrite entirely and with it the place the split is forged.

What makes request splitting slip past a scanner is that the HTTP/2 message is well formed; the second request does not exist until the downgrade writes it. UnboundCompute reasons about that reachability rather than matching header patterns, following whether a client controlled byte actually survives the rewrite and reaches a back end parser that reads it as a new request line. You can read more about how that works on our about page. The client sent one request. The downgrade is what turned it into two.

Frequently asked questions

What is HTTP/2 request splitting?

It is a smuggling attack where carriage return and line feed sequences hidden inside HTTP/2 header names or values get written literally into the HTTP/1.1 stream during a downgrade, splitting one request the client sent into two at the back end. In HTTP/2 a newline inside a header is just data, but after the downgrade it becomes structure, and that change of meaning is the whole attack.

Why does the downgrade cause the split?

HTTP/2 keeps headers as binary name and value pairs where a newline has no structural role. HTTP/1.1 separates headers with a carriage return and line feed and ends the header block with a blank line. When the front end flattens an HTTP/2 value into an HTTP/1.1 line without checking for those bytes, an injected blank line ends the first request early and everything after it is read as a second request.

How is it different from HTTP/2 CRLF header injection?

The difference is the blank line. HTTP/2 CRLF header injection uses a single carriage return and line feed to add or overwrite one header, so the request stays one request. Request splitting injects a full blank line, the double carriage return and line feed that ends a request, then writes a whole new request line after it. One extra newline forges a header; two in a row forge a request boundary.

How do you defend against request splitting?

Refuse any HTTP/2 header name, header value, or pseudo header that contains a carriage return or line feed, rather than copying it into an HTTP/1.1 line. Because the bug is the rewrite, the check has to run at the rewrite, on every field, not only the ones the application reads. Speaking HTTP/2 to the back end removes the rewrite entirely and with it the place the split is forged.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.