Web cache poisoning via HTTP/2 request tunnelling

7 min read Insane PortSwigger
HTTP request smuggling
Contents

On this page

The lab

here is how portswigger describes it

This lab is vulnerable to request smuggling because the front-end server downgrades HTTP/2 requests and doesn't consistently sanitize incoming headers.

To solve the lab, poison the cache in such a way that when the victim visits the home page, their browser executes alert(1). A victim user will visit the home page every 15 seconds.

The front-end server doesn't reuse the connection to the back-end, so isn't vulnerable to classic request smuggling attacks.

the goal is to make the cached copy of the home page contain our script, so that every visitor who loads the home page runs alert(1). two mechanisms combine to get us there.

the first is tunnelling. as in the earlier tunnelling lab, the front end does not reuse its connection to the back end, so we cannot poison another user's request the classic way. instead we sneak a second request past the front end on our own connection and read its response nested inside our own. tunnelling is smuggling aimed at ourselves.

the second is caching. the front end caches pages by url. if we can make the response to a tunnelled request become the cached copy for the home page, then everyone who visits the home page gets our tunnelled response, and if that response contains our script, it runs in their browser. so the plan is, tunnel a request whose response contains our xss, and get that response saved in the cache under the home page url.

the injection point this time is not a header name but the :path pseudo header, so let me explain that first.

what the :path injection does?#

in http/2 the request line is not one text line like in http/1, it is split into pseudo headers, and :path is the one that holds the path, like / or /post?postId=1. when the front end downgrades our request to http/1, it rebuilds a normal request line by stitching the method and the :path together into something like GET <:path> HTTP/1.1.

so if we stuff extra content into :path, it gets written straight into that request line during the downgrade. we set :path to this.

HTTP
name:  :path
value: /?cachebuster=1 HTTP/1.1\r\nFoo: bar

when the front end builds the http/1 request, our value lands in the request line, and our injected \r\n ends that line early, so we get a clean request for /?cachebuster=1 followed by an injected Foo header. the cachebuster query is just a unique value so we get a fresh, uncached response each time we experiment, rather than an old cached one.

1

we still get a normal response, which confirms we can inject through :path

2

Step 1 - Tunnel a request and read the nested response#

now we escalate from injecting a header to injecting a whole second request, and we switch the method to HEAD. the :path becomes this.

HTTP
name:  :path
value: /?cachebuster=2 HTTP/1.1\r\nHost: your-lab-id.web-security-academy.net\r\n\r\nGET /post?postId=1 HTTP/1.1\r\nFoo: bar

reading it, our outer request is HEAD /?cachebuster=2, and after we close its headers with \r\n\r\n we write a complete second request, GET /post?postId=1, which is the tunnelled request.

here is why the method is HEAD, and it is the clever bit. a HEAD response has no body of its own, the server sends the headers it would send for a GET, including a Content-Length, but no actual body content. that empty body is the space our tunnelled response gets to live in. because the outer HEAD contributes no body bytes, the bytes that come back to fill the response are the tunnelled GET /post response, so we can read the tunnelled response directly.

3

we can see the tunnelled response, so tunnelling works. now, if we strip the :path back down to just the path and cachebuster and resend, the cache stores that tunnelled response under the /?cachebuster=2 url, which proves we can poison the cache with a tunnelled response. the last thing we need is a tunnelled response worth caching, one that carries our script.

Step 2 - Find a reflection gadget for the xss#

we need an endpoint whose response reflects our input as raw html, without encoding it, so a script payload stays live. we try GET /resources and it answers with a redirect to /resources/.

4

then we try adding a query string with a script payload, GET /resources?<script>alert(1)</script>, and the redirect reflects it back unescaped in the Location header.

HTTP
Location: /resources/?<script>alert(1)</script>
5

that is the gadget. the /resources redirect takes our query string and reflects it into its response without escaping the angle brackets, so our <script> survives intact. if that response becomes the cached home page, a browser rendering it will run our script.

Step 3 - Tunnel the xss request#

now we tunnel a request to that gadget instead of to /post. the :path becomes this.

HTTP
name:  :path
value: /?cachebuster=2 HTTP/1.1\r\nHost: your-lab-id.web-security-academy.net\r\n\r\nGET /resources?<script>alert(1)</script> HTTP/1.1\r\nFoo: baa

what this does, the outer request is still HEAD /?cachebuster=2, but the tunnelled request now points at our reflection gadget with the script payload in its query. so the tunnelled response is the redirect that reflects our <script>alert(1)</script> unescaped, and that is the response we want the cache to store for the home page.

6

Step 4 - Pad the payload to beat the content length#

the first time we send it, the request times out. the reason is a length mismatch, the same family of problem as the last tunnelling lab. the outer HEAD response declares a Content-Length, the size of the body a GET for that page would have, and our client waits for exactly that many bytes. our tunnelled redirect response is short, shorter than that declared Content-Length, so the client sits waiting for bytes that never arrive, and it times out.

the fix is to make the tunnelled response longer, long enough to exceed the outer Content-Length so the client gets all the bytes it is waiting for. we do that by padding, adding a long run of arbitrary characters after the closing </script> tag. those characters get reflected too, which inflates the length of the tunnelled response.

HTTP
name:  :path
value: /?cachebuster=2 HTTP/1.1\r\nHost: your-lab-id.web-security-academy.net\r\n\r\nGET /resources?<script>alert(1)</script>AAAAAAAA...many more A characters... HTTP/1.1\r\nFoo: baa

with enough padding the tunnelled response finally outweighs the Content-Length, the client stops waiting, and the response comes through.

7

step 5 - poison the real home page#

first we prove the payload lands. we navigate to /?cachebuster=2 in the browser, and the alert fires, because the poisoned response for that cachebuster url is now cached and it carries our script.

8

that is our proof of concept, but it is on a throwaway cachebuster url that only we use. to actually solve the lab we have to poison the home page itself, /, the page the victim visits. so we change the outer path from /?cachebuster=2 to plain /, and send the attack a few times so that our poisoned response gets stored as the cached copy of the home page.

9

once the cache is poisoned for /, any visit to the home page serves our response. we load / in the browser and the alert fires, and the victim, who visits the home page every 15 seconds, hits the same poisoned cache entry and runs it too.

10

with this, we solved the lab!

the whole attack#

let me tell the story straight through, because a lot of moving parts had to click together.

we wanted the home page's cached copy to contain our script, so that everyone who loads the home page runs it. the site put a cache in front of the back end, so whatever response the cache saves for a url is what every visitor gets for that url. that is the prize, control of the cached home page.

getting there needed two tricks. tunnelling let us smuggle a second request of our own past the front end and read its response, which mattered because this front end does not reuse connections and so is immune to the ordinary shared connection attacks. we did the tunnelling through the :path pseudo header, because when the front end downgrades http/2 to http/1 it writes our :path straight into the request line, and by injecting \r\n we could tack a whole extra request on after our real one. we used the HEAD method for the outer request so that its empty body left room for the tunnelled response to come back to us where we could read it.

then we needed a response worth caching. the /resources redirect reflected our query string into its response without escaping it, so a <script>alert(1)</script> in the query came back live. by tunnelling a request to that gadget, the response the cache would store was one carrying our script.

the length padding was a practical hurdle, not a concept, the outer HEAD response promised a certain number of body bytes and our tunnelled response was too short to satisfy it, so we padded the reflected payload with filler characters until the tunnelled response was long enough and the request completed.

finally we pointed the outer request at the real home page / and repeated the attack until the cache saved our poisoned response as the home page. from then on every visitor, including the victim on their 15 second loop, loaded the home page and ran alert(1).