HTTP/2 CRLF Header Injection

HTTP/2 CRLF Header Injection

Written by

in

In HTTP/1.1 a header ends when the parser sees a carriage return and a line feed, so a value is never allowed to contain those bytes. HTTP/2 does not work that way. It carries each header name and each header value as a counted binary field, which means a value can legally hold bytes that HTTP/1.1 treats as structure. HTTP/2 CRLF header injection is the trick of smuggling those forbidden bytes through an HTTP/2 front end so that, when the request is rewritten for an older back end, they turn into real header lines. If this family is new to you, start with our primer on http request smuggling, then come back for the injection primitive itself.

Why HTTP/2 lets a value carry bytes HTTP/1.1 forbids

Text framing and binary framing treat the same characters very differently. In HTTP/1.1 the message is one text stream. A header line is a name, a colon, a value, then a carriage return and a line feed. The line feed is the delimiter. Put a raw line break inside a value and you have not sent a long value, you have ended the header and started another one. Every careful HTTP/1.1 parser rejects control characters in a value for exactly this reason.

HTTP/2 drops the text stream. Headers travel in a compressed binary format where each name and each value is written as a length followed by that many bytes. The length field decides where the value ends, not any delimiter inside it. So a value of twenty bytes is twenty bytes, even if three of them are a carriage return, a line feed, and a colon. Nothing in the framing stops you. The HTTP/2 standard does say an implementation must treat a field that contains a carriage return, a line feed, or a null byte as malformed, but that is a rule each server has to enforce, and plenty of them do not.

The whole primitive lives in one gap: HTTP/2 says a value is however many bytes you counted, while HTTP/1.1 says a value ends at the first line break. The attacker supplies bytes that are data on one side of the hop and structure on the other.

How the downgrade turns a value into HTTP/2 CRLF header injection

Most front ends still speak HTTP/2 to the internet and HTTP/1.1 to the back end. To forward a request the front end flattens every HTTP/2 field back into a text line: it writes the name, a colon and a space, the value, then a carriage return and a line feed. If it copies the value across without checking for embedded control bytes, any carriage return and line feed you hid in that value is written verbatim into the HTTP/1.1 stream. The back end reads that stream as text and obeys the line breaks. Your data just became new header lines.

Picture a request to a typical notes app. You send an HTTP/2 request and set one ordinary looking header, call it foo, to a value that contains a line break in the middle:

foo: bar\r\nX-Injected: 1

On the wire that is a single HTTP/2 field whose value is the counted bytes bar, a carriage return, a line feed, and X-Injected: 1. The front end validates nothing and flattens it. The back end receives this:

foo: bar
X-Injected: 1

One header went in. Two came out. The back end now processes an X-Injected header the client never legitimately sent, because the front end wrote the attacker’s bytes as structure. That is the injection. You can aim it at a pseudo header too. HTTP/2 carries the method, path, scheme and authority as the fields :method, :path, :scheme and :authority, and each of those is flattened into the request line or a header during the downgrade. A carriage return and line feed smuggled through :path can append a header of your choosing, or begin a whole new line the back end reads as the start of another request.

Header names, header values, and the request line

There are three distinct spots where the forbidden bytes can land, and they differ in what they let you forge.

  • Inside a header value. The simplest case, shown above. A carriage return and line feed inside the value close the current header and let you write one or more fresh header lines that the back end trusts.
  • Inside a header name. Some stacks let you register a name that itself contains a colon or a space. After flattening, the back end may read the extra colon as the start of a value, splitting one field into two and letting you control a header the front end thought it owned.
  • Inside a pseudo header. The :path and :authority fields become the request line and the Host header. A control byte here rewrites the first line of the HTTP/1.1 request, which is the richest target because the request line is where a second request would begin.

This post stops at the primitive on purpose. Injecting a header or a stray line is the raw capability. Turning it into two complete requests on one connection, so a prefix waits in the back end buffer for the next user, is the next step, and we cover that in the sibling piece on http2 request splitting. Keeping the two ideas apart matters, because a stack can be open to the injection yet shrug off a full split, or the reverse.

Spotting it without breaking anything

You do not need a dangerous payload to tell whether a front end validates these bytes. Send an HTTP/2 request whose header value contains a carriage return and line feed followed by a header that produces a visible, harmless effect, then watch whether the back end acts on the injected header. A safe probe injects something inert, like a duplicate Accept or a vendor header the app echoes back, and checks the response for proof the extra line was parsed. If the injected header shows up in a reflected value or changes a cache key, the front end passed your control bytes through unchanged. This sits squarely in the family of injection and input flaws, where untrusted bytes cross a boundary and get read as structure on the far side.

Defenses are about validation at the hop. Per the HTTP/2 standard a front end must reject any field whose name or value contains a carriage return, a line feed, or a null byte, and must treat a colon inside a name as malformed. Enforce that before flattening, not after. Where you can speak HTTP/2 to the back end as well, there is no text rewrite and the primitive has nowhere to land. And normalize the request the front end emits so it owns the framing rather than copying attacker bytes into a text line.

CRLF injection on a downgrade is a good example of a bug that hides between two components that each look correct on their own. The HTTP/2 side counted its bytes honestly. The HTTP/1.1 side read its line breaks honestly. The flaw is only in the handoff, which is exactly where a check on paper and the real behavior tend to drift apart. It is the kind of assumption an autonomous researcher that tests how each hop actually parses bytes is built to probe, and at UnboundCompute we verify a desync is actually reachable before reporting it, rather than flagging a header that merely looks suspicious.

Frequently asked questions

What is HTTP/2 CRLF header injection?

It is injecting carriage return and line feed bytes through an HTTP/2 front end so that, when the request is downgraded to HTTP/1.1, those bytes turn into new header lines. HTTP/2 carries a header value as a counted binary field, so the value can hold a carriage return or a line feed that HTTP/1.1 would treat as the end of a header. If the front end copies the value into text without checking, the back end reads your bytes as structure.

Why can an HTTP/2 value contain bytes HTTP/1.1 forbids?

HTTP/1.1 is a text stream where a line feed ends a header, so a value may never contain one. HTTP/2 writes each value as a length followed by that many bytes, so the length decides where the value ends, not any character inside it. A value of twenty bytes is twenty bytes even if some are a carriage return or a colon. The HTTP/2 standard says to reject such fields as malformed, but not every server enforces it.

Is this the same as HTTP/2 request splitting?

No, and keeping them apart matters. CRLF header injection is the primitive: you get the back end to read bytes you chose as a header line or a stray line. Request splitting is what you build when that injected line becomes a full second request on the connection, so a prefix waits for the next user. A stack can be open to the injection yet resist a full split. See http2 request splitting for the next step.

How do you defend against it?

Validate at the hop that downgrades. Per the HTTP/2 standard a front end must reject any header whose name or value contains a carriage return, a line feed, or a null byte, and must treat a colon inside a name as malformed, and it must do this before flattening the request to HTTP/1.1. Speaking HTTP/2 to the back end as well removes the text rewrite entirely, so there is nowhere for the injected bytes to land.


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.