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.