Intigriti releases cool challenges every once in a while, and this was no exception.
I love a good challenge. Every time I solve an Intigriti challenge, I learn something new. Motivated by that, I wanted to crack this one too.
As usual, there were many dead-ends, moments of frustration and head-scratches. However, I’ll save your scalp from the scratching and walk you through this challenge.
The Challenge
Right after the tweet, I opened up the challenge link:
The goal was to find an XSS on challenge.intigriti.io while bypassing CSP — and self-XSS wasn’t allowed (oh no!).
The page contained a simple dropdown (archive):
This was the only visible functionality on the entire site, so I figured it must have been important.
When you chose an item from the dropdown, it fetched the content from a text file and displayed it. If you observe the address bar closely, you can see that the URL fragment also changed.
Yet, this didn’t tell us enough; thus, I looked at the page source:
There was a script.js included at the end.
Understanding the JavaScript
The script.js file had the following content:
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();
}
It was important to have a clear idea of what’s happening at the client-side, so let’s break it down.
var hash = document.location.hash.substr(1);
if(hash){
displayReason(hash);
}
The fragment identifier (the part after the #
) is stored in the hash
variable. We have control over this and can inject into it.
If the hash exists in the URL, displayReason()
gets called.
document.getElementById("reasons").onchange = function(e){
if(e.target.value != "")
displayReason(e.target.value);
}
It also gets called when you select an item from the dropdown, with the option’s value as the argument. However, this part is probably irrelevant since the rules mention that there should be no user interaction.
function displayReason(reason){
window.location.hash = reason;
var xhr = new XMLHttpRequest();
xhr.addEventListener("load", reasonLoaded);
xhr.open("GET",`./reasons/${reason}.txt`);
xhr.send();
}
But what does displayReason()
do? It sets the location hash as the reason
argument and initializes an XHR object. On top of that, it adds an event listener for load (which gets fired when an XMLHttpRequest transaction completes successfully).
A GET request is then made to ./reasons/${reason}.txt
, where the reason
variable is the input.
function reasonLoaded () {
var reason = document.getElementById("reason");
reason.innerHTML = unescape(this.responseText);
}
This function gets the response from the XHR object that was created in displayReason()
and applies unescape()
on it; this result is then set as the div’s content.
The Chain
It was clear that I needed to find an endpoint on the same domain that reflected the payload that when unescaped, would allow me to inject into the DOM to achieve the XSS.
Phase 1: Content Injection
The site in itself was pretty minimalistic in that it didn’t have many functionalities.
Anomalies
From reading the code, I knew that I could send arbitrary input through the URL fragment. I tried sending numbers 1–6 as the input — https://challenge.intigriti.io/#
, and so on. The content was displayed from the text files located at the /reasons/
directory, as expected. If I changed it to anything else, say #0
or #7
, it responded with a 404, presumably because no such files were present in /reasons
. Nevertheless, it was mentioned in one of Intigriti’s tweets that no bruteforcing was required, so that was out of the question.
I tried requesting #7
:
I tried requesting a non-existent page with curl:
➜ curl -i https://challenge.intigriti.io/reasons/abba.txt
HTTP/2 200
x-powered-by: PHP/7.2.29
content-security-policy: default-src ‘self’
content-type: text/html; charset=UTF-8
server: Google Frontend
content-length: 53
404 — ‘File “abba.txt” was not found in this folder.’
And to my surprise, I received a 404 page with a 200 status code. Was that intentional? I did not know. I tried a different payload to see how the application would respond:
➜ curl -i 'https://challenge.intigriti.io/reasons/<abba.txt>'
HTTP/2 200
x-powered-by: PHP/7.2.29
content-security-policy: default-src 'self'
content-type: text/html; charset=UTF-8
server: Google Frontend
content-length: 59
404 - 'File "_3Cabba.txt_3E" was not found in this folder.'
The angle brackets were getting encoded; the %
character also seemed to have been replaced with _
.
To be able to inject arbitrary HTML into the DOM, I needed some way to insert tags. Since <
and >
were filtered out, it meant I needed some bypass to achieve that. I tried double-encoding, but as I had seen before, the custom 404 handler was converting %
to _
. I used a script to find out which characters were filtered by the application.
The JavaScript contained unescape()
, a deprecated function. That seemed odd. As per the MDN docs for unescape
, for most things, unescape()
requires a %
to be present in the payload, which wasn’t allowed by our web server. I couldn’t send \u003c
and \u003e
either, because the web server would convert the \
to /
. I felt like I was missing something.
The 404 error pages were not useful, so I decided to go back to take another look at the JavaScript. Right after, I noticed that they were using a relative path in the XHR GET call.
xhr.open("GET",`./reasons/${reason}.txt`);
reason
is a string but it does not get evaluated. However, I could control reason
’s value. I tried directory traversal:#../test
.
That worked! The .txt
bit had to be removed. That was rather simple; I added #
rendering everything after it a JavaScript comment. My new payload was #../#
.
I could load images too, but it didn’t accomplish much, given I was still unable to inject anything arbitrary into the DOM.
Digging deeper
Out of ideas, I tried to figure out what web server they were using.
I checked https://challenge.intigriti.io/%
once more, and it had the Apache error in the footer:
I also noticed that adding a %2F
in any URL caused the site to use the Apache 404 page instead of the custom one.
Nevertheless, neither the 400 nor the 404 pages reflected the requested path in the source.
Read the source, Luke!
Yet, there was hope. Apache is an open-source project, and the code is available on GitHub — so I could just read it! I decided to look for error pages that did reflect the path.
Using GDD, I quickly jumped through the code to find the file responsible for displaying error pages; I found the logic in modules/http/http_protocol.c.
Still, the URI wasn’t being printed anywhere! Wishfully, I thought it must’ve been a quirk in older versions of Apache. After more digging, I discovered that the reflection was indeed removed — because of CVE-2019–10092 in this commit.
While reading through the code, I realised I had completely forgotten about 403 error pages! Yet, there weren’t any 403 pages on the challenge site that were immediately visible. But I knew that by default, Apache showed the 403 Forbidden message for paths that started with .ht
. So I checked out https://challenge.intigriti.io/.htaccess
:
To my surprise, the path was reflected! I then tried .htaccess%25
and it returned the following response:
<p>You don't have permission to access /.htaccess% on this server.</p>
I finally had a %
to work with. All that was left was to craft a payload that would return a response, which when passed to unescape, would decode to actual HTML. This meant I needed to use double-encoding —for instance, convert a less-than bracket to %253C
. I generated this using escape(escape('<'))
.
Finally, to test things, I attempted the following: https://challenge.intigriti.io/#../.ht%253cb%253e
:
That worked beautifully! I was now able to inject HTML into the DOM.
However, there was a problem: the response from the 403 endpoint is added to the page using .innerHTML. This meant I couldn’t inject a script tag because it wasn’t interpreted by .innerHTML
!
HTML5 specifies that a
_<script>_
tag inserted with innerHTML should not execute.
The classic <img src=x onerror=alert(1)>
wouldn’t have worked either because of the strict CSP.
Phase 2: Bypassing the CSP
Now, we shall deal with the CSP:
content-security-policy: default-src 'self'
After some research, I came across the srcdoc
attribute of the <iframe>
tag. Using srcdoc
, I could specify the HTML content in the iframe:
<iframe srcdoc="<script src=X></script>></iframe>
But there was more. Since external scripts were disallowed by the CSP, I needed an endpoint that gave out JavaScript within the site’s origin. But to my knowledge, there weren’t any JSONP endpoints, and I did not know of any other endpoints that gave out JavaScript.
Then I remembered that there was a custom 404 page that we put aside — Chekhov’s gun!
I checked the response for https://challenge.intigriti.io/a:
404 - 'File "a" was not found in this folder.'
^-- I could control this part
Even though it didn’t look like it, it was a valid JavaScript expression: 404 is a number, -
is the subtraction operator and 'File "a" was not found in this folder.’
is a string literal. I came up with ’-alert(document.domain)-'
as a payload.
In total, the URL with the payload was https://challenge.intigriti.io/'-alert(document.domain)-
and the response of the URL was
404 - 'File "'-alert(document.domain)-'" was not found in this folder.'
Final Solution
All that was now left was stitching everything together. Since the 404 page existed in the same host, a relative path was enough:
<iframe/srcdoc="<script/src=/'-alert(document.domain)-'></script>">
Combining it with the content injection, the payload became:
https://challenge.intigriti.io#../.ht%253ciframe/srcdoc=<script/src=/'-alert(document.domain)-'></script></iframe>
The alert finally popped! That was one clever XSS.
Kudos to Intigriti for a nice challenge.