Intigriti's May 2020 XSS challenge

Upon visiting challenge.intigriti.io, I was greeted by the following page:



The very first thing that I saw was the bold text, stating that the XSS only works in Firefox (which happens to be my main browser). With that in mind, I pressed CTRL+U to view the page's static HTML. Here's a screenshot of that:



The following HTML was the only reference to JavaScript (of course, excluding the visible text) in the page:
<script async src="widgets.js" charset="utf-8"></script>
I had a look at widgets.js and, at first glance, I saw a minified JavaScript file with some references to Twitter. Upon further inspection, I realised that it was probably a copy & paste of https://platform.twitter.com/widgets.js. I diff'd the files, and yup - they were basically the same. At that point, I assumed that the code inside widgets.js was safe.

Despite one of the list entries stating that the XSS "should be executed on this page", I decided to look around for other files.

I visited https://challenge.intigriti.io/robots.txt to check if there was anything interesting. I did get new information, but not in the form I was expecting. It responded with the exact same page as before. This meant that the target page was accessible from other paths, which was absolutely useful to know.

Next, I went to https://challenge.intigriti.io/a/ and it yielded the following result:



So, what happened here? Well, the exact same HTML was being served, but because the URL's path component included an unescaped /, the browser treated a/ as a directory, and tried to load relative subresources from there. In other words, the browser was attempting to load /a/style.css and /a/widgets.js instead of /style.css and /widgets.js. (By the way, the actual name for this is RelativePathOverwrite.) Progress!

I can't remember exactly how I discovered this - I was likely playing around with the path - but I found an open redirect at https://challenge.intigriti.io//<location> very shortly after finding the RPO. For example, https://challenge.intigriti.io//physuru.dev redirects to https://physuru.dev. Additionally, visiting https://challenge.intigriti.io// (with nothing after the two slashes) didn't redirect, but instead loaded the default page. Now, my objective was clear. I had to make the web browser attempt to load a widgets.js file from my website using both the RPO and the open redirect.

The obvious next (and last) step was path traversal. I did get stuck on this part for a while. I found the difference between Firefox and (most) other browsers fairly quickly, but I didn't know how to apply it. The difference being that, in Firefox, the two dots (..), and the prior path component, don't get removed if the following conditions are met:
- The dots are at the very end of the path, and:
- At least one of those dots is percent-encoded.

For example, if you were to load any (reasonable) variant of https://challenge.intigriti.io/abc/.%2E (in Firefox), the trailing dots (and the prior path component) would remain.

For some time, I couldn't find a use for this behaviour. However, I knew that this was required to solve the challenge - it resembles path traversal, isn't reproducable on Chrome, and has a Bugzilla thread.

It eventually occurred to me that it may be possible to have the trailing dots resolved by the server. The same idea of trailing dots remaining if one is percent-encoded is also true for single dots. I checked what happens upon visiting https://challenge.intigriti.io/%2E - as expected, I get the default page back. Immediately after, I realised that I'd get that back anyway - the web server seems to serve that page in most scenarios. I then tried https://challenge.intigriti.io/.%2E. I got nginx's "Bad Request" page, as opposed to the page that became familiar to me throughout this challenge. This meant that the trailing dots were indeed interpreted specially by the server.



Shortly after, I loaded https://challenge.intigriti.io//a/.%2E. I was thrilled to see that, instead of being redirected to https://a, I got the default page returned! This meant that the server believed that I was requesting https://challenge.intigriti.io//. In other words, the server saw the a/, but it then saw the .%2E. The .%2E was resolved to .., meaning that the entire a/.%2E part of the URL was basically just ignored (and thus wouldn't redirect - the server would respond with the default page instead). This was an awesome discovery. Due to the path traversal only being server-side, the browser would still treat //a as the directory from which relative subresources should be loaded! See where this is going? So, the browser then makes a request to //a/widgets.js (for example) which does get redirected (to https://a/widgets.js). To recap: the server returned the same as it would if I were to request //, but to the browser, I was still on //a/.%2E. This caused the same default page to show up, but because the browser now loads resources from //a (which redirects), I knew that with some slight modification to the URL, I'd be able to make widgets.js point to arbitrary JavaScript.

With this knowledge, actually solving the challenge was simple. I had to change a to a location that I controlled. My solution was https://challenge.intigriti.io//physuru.dev%252fblog%2fintigriti_may_2020_challenge/.%2E.


TL;DR

My solution was https://challenge.intigriti.io//physuru.dev%252fblog%2fintigriti_may_2020_challenge/.%2E.
It works because there's an open redirect at https://challenge.intigriti.io//<location>, which can be used alongside an RPO and (server-side) path traversal to make widgets.js "point to" arbitrary JavaScript.