HTTP Request Smuggling – HTTP/2 Downgrade Attack Part 2

In the previous lab we looked at a H2.TE vulnerability. To exploit, we needed to upgrade the request from HTTP/1.1 to HTTP/2 and rely on the frontend to downgrade back down to HTTP/1.1 for its communication with the backend system. In this way, we were able to exploit a mishandling of the Transfer-Encoding header such that we were able to queue content in the backend system thus poisoning the next request.

In this lab, we’re going to be doing something very similar. Rather than an H2.TE vulnerability, we’re going to be looking for an H2.CL path to exploit – the aim is to identify an opportunity to manipulate the handling of the Content-Length header in a way such that it is going to allow us to queue a request and poison the backend system thus impacting the following request(s).

This is the 11th blog post in the series I am publishing dealing with Request Smuggling or Desync vulnerabilities and attacks. These posts align to the PortSwigger Web Security Academy labs (here).

Lab: H2.CL request smuggling

This is post #11 of the series. Previous posts here:

  1. CL.TE Vulnerability
  2. TE.CL Vulnerability
  3. TE Header Obfuscation
  4. CL.TE & TE.CL via Differential Responses
  5. CL.TE Bypassing Frontend Security Controls
  6. TE.CL Bypassing Frontend Security Controls
  7. CL.TE Exploiting Frontend Request Rewriting
  8. CL.TE for Stealing Session Cookies
  9. Reflect XSS via Headers
  10. H2.TE Downgrade Attack

Key content/reference material for understanding and exploiting the vulnerability:

From the HTTP/2 spec on the Content-Length header:

A request or response that includes message content can include a content-length header field.  A request or response is also malformed if the value of a content-length header field does not equal the sum of the DATA frame payload lengths that form the content, unless the message is defined as having no content.  For example, 204 or 304 responses contain no content, as does the response to a HEAD request. A response that is defined to have no content, as described in Section 6.4.1 of [HTTP], MAY have a non-zero content length header field, even though no content is included in DATA frames.

In essence, the Content-Length header is allowed and should be included with any request that has a body in the event that the request gets downgraded to HTTP/1.1.

Host Header Note: the Host header may point to different domains throughout the various screenshots and captured content:

Host: <Lab ID>

This is due to the labs expiring on me at times during the construction of the blog post and the harvesting of the material.

The Goal: Poison the request queue and seed malicious JS hosted on the lab’s exploit server such that the victim user executes a specific XSS payload: alert(document.cookie).

ALPN: Just as in the previous lab, the server does not advertise HTTP/2 support via ALPN (Application Layer Protocol Negotiation) within the TLS Encrypted Extensions. In this lab, we’ll have to force HTTP/2 for the communication.

As always, I always feel it is best to confirm the vulnerability and take a standard approach to the labs rather than going straight after the solution as described in the lab instructions. Let’s get started!

NOTE: We are skipping some recon for the purposes of shortening the lab. In reality, attempting HTTP Smuggling requests with HTTP/1.1 would be the first steps. Feel free to pursue this route to see if you can get this path to work, however, you should find that HTTP/1.1 smuggling is defended. To bypass the defenses, we need to exploit this specific lab by bumping up to HTTP/2.

Step 1: Let’s browse to the site via the Burp preconfigured Chromium browser. Find the request for the ‘/’ route within the Proxy log in Burp and Send to Repeater. Once in Repeater, Check the ‘Allow HTTP/2 ALPN Override’ option in the top level menu, and then configure HTTP/2 via the Inspector panel. Once this is done, Send the request and we should see an HTTP/2 response from the server.

Step 2: Now that we are talking HTTP/2, let’s clean up the request by dropping all of the extra headers, change to a POST, and provide a payload.

This looks good. Notice that the request goes through just fine without a Content-Length or Transfer-Encoding header since we are using the HTTP/2 protocol. Send a few times to make sure you get the same response on each request. Now, let’s add in Content-Length and see what happens.

We’re still able to submit the request multiple times with no issues having the Content-Length set to the correct length. Let’s shorten it by 1.

Perfect – now we have a problem – or rather, we have a soft spot. On the second request, we received a 404 not found error. This makes sense if we are dealing with an H2.CL vulnerability (which we are per the lab instructions). Since we set the Content-Length to 1 shorter than the actual payload, this results in leaving a character in queue on the backend system (the final ‘r’). On our second request, that character is prepended to our request which is making the verb for the second HTTP request rPOST. Obviously, the backend system does not know what to do with this which causes it to throw an error.

Step 3: The next step is to find a spot where we can exploit this issue to fire our intended XSS. Browse the site and take note of the analytics.js script which is included on the various pages. This script is loaded from the /resources path.

This seems to be the only extra resource or really item of interest that can be found on the pages. There is search functionality, but it seems well defended from XSS. Digging deeper into the analytics.js, it gets even better. There’s a loader script responsible for importing analytics.js called analyticsFetcher.js. Here’s the script.

setTimeout() documentation

This is designed to specifically give us a window of time (5000 ms) in which we can exploit an unsuspecting user. In essence, when someone visits the page, a 5 second timer kicks off. At the end of that 5 seconds, the analyticsFetcher.js is going to reach out to the backend to retrieve the analytics.js script. During this window of time, if the request queue is poisoned with a request for valid JS, client-side will receive this JS and execute it since client-side will believe this is the actual content it requested for analytics.js. All we have to do is wedge a request for our XSS payload into the backend queue such that the victim user’s browser requests our payload at the end of the 5 second window. Brilliant!

NOTE: It may seem like this lab deviates from real world a bit in providing us this 5 second window such that we can exploit the victim user. In reality, how often do you visit a page, scroll down a bit, and then an XHR fires to bring you an advertisement or some overlay that wants you to sign up or interact in some way? Despite initial appearances, this is all too real world.

Step 4: Now is the time to build our request payload such that we can poison the queue. We’re going to position a full request for our specific payload – alert(document.cookie) – in the backend queue to be released by the victim user. There’s no way to manipulate a request, rather, we want to seed a full request to be released for when the request for analytics.js hits the backend.

First, let’s get our exploit server configured. In a bug bounty situation, we would need to host our malicious content on our own servers or at least a location that is accessible to the victim. With the PortSwigger labs, they provide an exploit server. Configure the exploit server to return our XSS payload really on any path. I chose /resources to align to where the actual analytics.js is being hosted.

Good to go. Now, we have to queue a request in the backend that targets this new hosted XSS content. Let’s work through a payload. To start off, we’ll need an HTTP/2 request.

Host: <lab-id>
Content-Length: 14


We know this will give us a 200. Now, we need a request that will try to retrieve our XSS payload.

GET /resources HTTP/1.1
Host: exploit-<id>
Content-Length: 15


Couple items here:

  • The path has to he /resources. If a path other than /resources is provided, the web application will return a 404 “Not Found” error. There must be a defense within the app that is ferreting out and defending against non-existent paths.
  • Note the Content-Length in the request to the exploit server – it is 1 character longer than the actual provided payload. Additionally, this is a fat GET. A POST here seems to be processed or rejected by the backend system.

Now, it is as simple as putting them together.

Host: <lab-id>
Content-Length: 14

userid=scomurrGET /resources HTTP/1.1
Host: exploit-<id>
Content-Length: 15


Easy, right? Send the requests a few times in succession and you should see an HTTP/2 response with a 302 status code. If you poison the queue and then immediately visit the site with the browser, you should get back the XSS payload as text. If you can reproduce this behavior by poisoning the queue with the HTTP/2 request via Repeater and then retrieving the XSS payload via the browser, you are good to go!

Step 5: Let’s poison the queue! With the request from above, it is just a matter of timing. Per the lab instructions, the victim user visits the site every 10 seconds. That means every 10 seconds we have a 5 second window to seed the request for the exploit payload into the backend queue. After trying at various intervals and then waiting 10-15 seconds between, eventually you should get the solve.

This one took me a bit to conceptualize, so here is the high level flow.

  • Victim user visits the site. Their browser pulls down the site content included analyticsFetcher.js. At this point, the 5 second window is open.
  • Attacker visits the site and poisons the queue during the 5 second window. The next request sitting in the backend is now a request pointing at the XSS content sitting on the exploit server. It remains queued since it still requires 1 additional character to complete the request.
  • The 5 second timer expires and analyticsFetcher.js reaches out to download analytics.js.
  • The backend catches the request for analytics.js, takes the first character from this new request, uses that character to complete the queued request, and then releases the queued request for the exploit server. The now released request retrieves the XSS payload.
  • Client-side receives JavaScript (which is exactly what it was expecting) so it executes the JS. This JS just happens to be the XSS payload rather than the actual code for analytics.js.

Good stuff!

Key items in this lab:

  • Play with different HTTP verbs as part of the smuggled request payload
  • Always try to purposefully release your smuggled request payload in a way that you can confirm it is getting queued
  • Ensure the smuggled payload has a Content-Length slightly too long so that the backend queues and waits for 1 or more characters before releasing
  • Verify any paths for code exploit payloads are allowed and not defended by the web application itself (/exploit was not allowed above – it had to be /resources)
  • Try to reproduce the exact victim behavior with your own browser. Triggering the XSS above in your browser first will confirm it is an option for a victim user

Happy hunting!

Leave a Reply