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:

Intigriti Easter XSS challenge

Intigriti Easter XSS challenge

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:

Challenge webpage source

Challenge webpage 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

Image showing exploitation chain

Image showing exploitation 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:

Result of requesting https://challenge.intigriti.io/#7

Result of requesting https://challenge.intigriti.io/#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.

Result of requesting<code>#../test</code>

Result of requesting<code>#../test</code>

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.

Result of requesting #../#

Result of requesting #../#

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:

Picture showing % resulting in Bad Request

Picture showing % resulting in Bad Request

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.

Apache source code

Apache source code

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.

Diff showing fix for CVE-2019-10092

Diff showing fix for CVE-2019-10092

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:

Result of accessing /.htaccess

Result of accessing /.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:

Result of accessing /#../.ht%253cb%253e

Result of accessing /#../.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>

Picture showing final XSS

Picture showing final XSS

The alert finally popped! That was one clever XSS.

Kudos to Intigriti for a nice challenge.