http2smugl: detect and exploit HTTP request smuggling
http2smugl
This tool helps to detect and exploit HTTP request smuggling in cases it can be achieved via HTTP/2 -> HTTP/1.1 conversion by the frontend server.
The scheme is as follows:
- An attacker sends a crafted HTTP/2 request to the target server, which we call the frontend.
- The request is (presumably) converted to HTTP/1.1 and transmitted to another, backend server.
The attacker wants to find such a request that it will be seen as two separate requests by the backend server.
If the frontend<->backend HTTP/1.1 connection uses keep-alive, the frontend might send requests from other users to the same connection. If we’re able to “poison” the connection by a partial request that comes after a legit one, we can retrieve the request from another user.
Other possible scenarios include bypassing frontend server protection and rewrites, cache poisoning, or cache deception.
For more information on the HTTP Request Smuggling, please refer to Portswigger Web Security Academy.
Detection algorithm
The tool has a subcommand that tries to detect if a target vulnerable to the HTTP Request Smuggling attack automatically. The algorithm behind this feature is described in this section.
To perform an HTTP Request Smuggling attack, we actually need to “smuggle” a single header first (either Content-Length
or Transfer-Encoding
). It means that we need to send a header that a) controls where the request body finishes and b) is not processed by the frontend but is processed by the backend.
That is usually achieved by modifying a header in some way: adding spaces or tabs at the end of its name, replacing the value with a semi-equivalent, etc.
The vulnerability detection algorithm’s basic idea is to detect if the server actually processes a smuggled header as if it were Content-Length
or Transfer-Encoding
. We do this by sending multiple requests: some with valid and others with invalid values for the header. Then try to detect if there’s a way to distinguish the responses from these two groups.
That is why tool output does not contain the words “vulnerable/invulnerable”: it just says if it can distinguish responses that came on the requests from these two groups.
The tool considers two sets of HTTP responses to be distinguishable if at least one of the following two conditions is met:
- The sets of their response codes do not intersect
- The sets of lengths of the replies are separable from each other (e.g. all “valid” responses are longer than 1000 bytes and all “invalid” ones are shorter).
The timeouts are treated as a unique status code value not equal to any other; thus, the tool supersedes the classical “detect by timing” scheme.
Let us consider an example. Suppose we’re trying to smuggle the transfer-encoding
header by replacing the dash with an underscore.
If it appears that the server responds with status 400 every time we send transfer_encoding:zalupa
and hangs when it’s transfer_encoding:chunked
, we can say that the server probably does process the header as the value for transfer encoding. Theoretically, it could be either the frontend or the backend server.
The first case is not interesting as we could send the non-smuggled version of the header anyway, and the second one is what we’re looking for. As everything happens over HTTP/2, the first case is avoidable most of the time: HTTP/2 server determines the end of the request body in another way that is unrelated to the request headers and never expects the body to be in the HTTP/1.1 chunked format (the one with hexadecimal chunk lengths).
The concrete variations of detection techniques are:
- We send a smuggled version of
Transfer-Encoding: chunked
(e.g.transfer_encoding:chunked
) and different bodies: valid is0\r\n\r\n
and invalid is999\r\n
.In case the responses are different, we can be sure that the backend server receives and processes the smuggled header. There’s no reason for the frontend to do this: HTTP/2 doesn’t use the chunked format we sent, so that it would be invalid.We expect the backend to hang (that is, request to timeout) for the invalid requests as it waits for more data to arrive.
This is the most reliable detection variant: if the server hangs when reading the body, something is probably gone wrong since there’s no usage for HTTP/1.1 transfer encoding in HTTP/2 request.
- We send a smuggled version of
Transfer-Encoding
and different bodies again:0\r\n\r\n
as valid body andX\r\n\r\n
as invalid one.The case is the same as above, but instead of reading the body, we expect the backend will at least validate it. - We send a smuggled version of the
Content-Length
header with values1
and-1
.Both values are invalid from the frontend’s point of view: there’s another mechanism of determining the body length in HTTP/2, and no request body is actually sent in both cases. If the responses are different, we suppose that it’s the backend server who parsed the headers.This method is the least reliable: the frontend might issue different errors when Content-Length has an invalid value, and when it does not match the actual length.
By sending multiple pairs of valid/invalid requests, we can reduce the chance of random false positive. On the other side, we can stop early if we see that there is no way we could separate responses on “valid” requests from the responses on “invalid” ones.
Smuggling techniques
The tool tries to employ multiple smuggling techniques by modifying a header in various ways. None of them is new and not obvious at the same time.
Spaces
To smuggle a header, we append a space to it. It is the most common and classic method. We hope that the header is not processed by the frontend but is sent as-is to the backend, which strips the space.
The tool tries a variety of character as spaces: it includes ,
\t
, \v
, \x00
and the Unicode ones.
Underscore
To smuggle a header, we replace the dash (-
) with an underscore (_
). If the backend is CGI-inspired in some way, it might convert headers like Header-Name
to the HEADER_NAME
form; thus, the dash will become underscore anyway. While determining how to parse the body, such a backend supposedly requests the value of CONTENT_LENGTH
/ TRANSFER_ENCODING
from its headers dictionary, and it will be there.
Newlines
This one is HTTP/2 specific. As HTTP/2 is a binary protocol, we can try to send newlines in the header name or value. The standard prohibits it, but we hope to find an implementation that still accepts this.
During the HTTP/2 -> HTTP/1.1 conversion, the header splits into two different headers, meaning the request will look different for the backend.
To smuggle a header, we put its name and value after a newline: a header with the name “Transfer-Encoding” and the value of “chunked” becomes one with the name “fake” with the value of “fake\r\ntransfer-encoding: chunked”.
UTF characters
Suppose the backend uses some high-level language and does not perform enough headers’ validation. In that case, it might convert the names to the uppercase before doing anything else and do it using Unicode-aware functions. Luckily, TRANSFER-ENCODING
contains the letter S
, which is ſ
(\u017f
) uppercased.
Similarly, we can search for a backend that will convert the value of Transfer-Encoding
to lowercase: we send chunKed
instead of chunked
with \u212a
instead of K
.
Of course, it is required that the frontend will pass UTF-8 header names/values to the backend.