Introduction
I recently found a Client-Side Template Injection (CSTI) vulnerability in a web application that uses AngularJS version 1.5.9. When I tried to leverage this issue to something more useful for finding security weaknesses, like a Cross-Site Scripting (XSS) attack, I couldn't find any payload to bypass its sandbox from a DOM-based injection context.
A few weeks later, I made some time to further research this topic, develop my understanding on how AngularJS works by examining the last version (v1.5.8) that was vulnerable to sandbox bypass techniques, learn how more recent versions of the sandbox have evolved to mitigate all the bypass vulnerabilities, and experiment with different approaches to see how far I could reach.
In the end, although I didn't find a full bypass chain, I hope that my research inspires further research that leverages issues under the conditions described here.
AngularJS concepts
I'll start by explaining some concepts that will be referenced later. If you're already familiar with AngularJS, feel free to skip this section.
AngularJS is a modular, open-source, client-side web framework used to build dynamic web applications. It relies on the web browser's capabilities to extend the standard HTML syntax, allowing the development of rich applications, supporting data binding, DOM event handling and control structures, among other features.
We'll mainly focus on the AngularJS expression language supported by the data binding feature.
Data binding and expressions
The data binding feature essentially allows us to synchronise data between the model and view components.
Let's see how this works with a quick example, slightly adapted from the official AngularJS Conceptual Overview:
<div ng-app ng-init="qty=10;cost=2"> <div> Quantity: <input type="number" min="0" ng-model="quantity"> </div> <div> Cost: <input type="number" min="0" ng-model="cost"> </div> <div> <b>Total:</b> <strong>{{quantity * cost | currency}}</strong> </div> </div>
The browser dynamically interprets markup between double curly brackets, and the website displays the numeric result of multiplying the quantity
by the cost
. Then, this value passes to the built-in currency filter which displays this result as formatted text (e.g. $20.00
) next to the Total
tag.
These expressions behave differently depending on their context, known as scope in AngularJS.
In the above example, the expression is statically hardcoded in the HTML code, and isn't very interesting from a security perspective. However, this expression can be dynamically generated by a server-side component (such as a PHP application). If so, an attacker may be able to control the expression which will be rendered in the HTML code before it reaches the web browser. This is known as a Client-Side Template Injection (CSTI) vulnerability.
The CSTI's final impact and exploitability will depend on several factors. A CSTI can often be leveraged into a successful Cross-Site Scripting (XSS) attack, but this is not always straightforward or even possible. AngularJS expressions support a very limited syntax, specific to the AngularJS framework, so the full JavaScript language isn't available. Also, expressions are always evaluated against a specific context or scope, rather than the usual Window
object.
AngularJS parses these expressions using its own lexer and parser internal components, and generates valid JavaScript code from them, which is then executed by the browser. This code ran in a sandboxed environment for many years and versions.
Sandbox
According to the AngularJS security documentation, the sandbox was simply a way to maintain a proper separation of application responsibilities and wasn't intended to be a security feature. Despite this claim, all known bypasses of this sandbox were gradually addressed and fixed, until the complete removal of the sandbox in version 1.6.0.
Even once the sandbox was removed, we can't directly invoke ordinary JavaScript code. For instance, simply typing alert(1)
inside an expression doesn't work, because this function belongs to the Window
object. In AngularJS expressions, directly invoking JavaScript code still requires some tricks, such as accessing the built-in JavaScript Function
object and using its constructor, among other techniques.
DOM-based contexts
Document Object Model-based XSS vulnerabilities usually arise when code takes data from an attacker-controllable source and passes it to a sink that supports dynamic code execution.
For our target versions (v1.5.9-v1.5.11), there's already at least one published bypass of the JavaScript sandbox, researched and discovered by Jann Horn.
However, let's consider a more restricted scenario, where the attacker doesn't have the ability to change the expressions that are reflected by the server as HTML. Instead, these expressions are parsed by AngularJS from client-side parameters and executed against a much narrower scope, in principle preventing us from using Horn's bypass techniques.
Examples include direct or indirect calls to the $interpolate
function or user input passed as an argument to an orderBy
filter which also accepts these expressions. There's no known sandbox bypass for these scenarios for any of the target versions, so this became the focus of my research.
Filters
AngularJS comes with several built-in filters, typically used to format the value produced by an expression. This includes the currency
filter, which we previously used to display the numeric result of a calculation as formatted text. Developers can also easily implement their own custom filters.
Filters can be chained, and may also have arguments. The syntax to use them in a view template is the following:
{{ expression | filter1 | filter2 | filter3:argument1:argument2:... }}
Of particular interest is the orderBy
filter , which is the only built-in filter that accepts expressions and injects the $parse
dependency.
Preparation and basic tests
I started my research by setting up a testing environment for different versions of AngularJS, which implemented something similar to the vulnerable website I found. It was using a custom filter which ended up calling the $interpolate
function with user-controlled data. That website used this client-side interpolation to replace user-supplied values in error messages. The implementation was actually more complex, but to simplify it I wrote this basic AngularJS application:
// This is the "script.js" file referenced in the HTML below angular.module('myApp', []) .filter('interp', ['$interpolate', function ($interpolate) { return function (input, params) { return $interpolate(input)(); }; }]) .controller('MyController', ['$scope', 'interpFilter', function($scope, interpFilter) { $scope.userInput = '{{\n\n7*7\n\n}}'; $scope.interpolatedOutput = interpFilter($scope.userInput); }]);
<html> <head> <script src="angular-1.5.<strong><em>x</em></strong>.js"></script> <script src="script.js"></script> </head> <body ng-app="myApp"> <h2 id="mainHeader">Reflected interpolation</h2> <div ng-controller="MyController"> <textarea rows="15" cols="120" ng-model="userInput" type="text"> </textarea> <br> <strong>No filter:</strong> <br> <strong>{{ userInput }}</strong> <br><br><br> <strong>Interpolated:</strong> <br> <strong>{{ userInput|interp }}</strong> <br> </div> </body> </html>
To make sure my testing environment was appropriate and working as expected, the first thing I tried was to exploit the latest version known to be vulnerable (v1.5.8) from a DOM-based context, using the following payload from Gareth Heyes' 2017 sandbox escape research:
{{ x={y:''.constructor.prototype}; x.y.charAt=[].join; [1]|orderBy:'x=alert(1)' }}
To my surprise, it didn't work! I received the following error in my browser's console:
TypeError: Cannot set property 'x' of undefined
Note: Unless stated otherwise, all testing was performed using the latest version of Google Chrome at that time (version 90.0.4430.212, 64 bits) running on Windows 10.
I started investigating and debugging this error, and I quickly noticed the cause: my scenario had an empty scope. Therefore, AngularJS was trying to set a property called x
on a null (undefined
) object, which defied logic.
This exploit was originally developed using the context of an orderBy
filter, which evaluates the expression against each object that is sorted, but that context didn't apply to my scenario.
Is there a way to adapt this exploit to make it work? Yes. I simplified the payload as follows to avoid setting up a new property by getting rid of the first assignment.
{{ {y:''.constructor.prototype}.y.charAt=[].join; [1]|orderBy:'x=alert(1)' }}
This worked. I also confirmed that this payload worked on several versions (v1.4.2-v1.5.8) and submitted my slightly improved version of the Heyes' sandbox escape to PortSwigger's Cross-Site Scripting (XSS) cheat sheet. I did that mostly because the original payload by Gareth Heyes was missing from the XSS list, as can be seen in the below screenshot, and also because my sandbox bypass is somewhat shorter and more generic than the original. It was quickly accepted and merged, so it's now available for everyone to use it.
At this point, I realised that I oversimplified my testing application, so I decided to pass a dummy parameter to have a non-empty scope. This allowed me to declare properties, which is more common. The application where I found the injection flaw also had a non-empty scope, so it was also more realistic and useful. I changed the last part of my testing application (the HTML file) as follows:
{{ userInput|interp:["MyParam"] }}
Analysing the JavaScript code generated from parsed expressions
It's extremely useful to see and understand which code the browser will finally execute after our expressions are parsed. To begin, I used breakpoints and traced the calls until I reached the code that was generating it and then found a variable containing the generated code (fnString
). To simplify the process, I added a line below where the variable was assigned, to dynamically print the code in the browser's console:
window.console.warn(fnString);
The code is generated on a single line, which makes it harder to read and follow. When I am particularly interested in analysing a function, I copy that string using an external tool and format it for readability.
For instance, let's try the following simple expression:
{{ test = [1234].toString() }}
When the expression is parsed, this will generate some not-so-simple code:
"use strict"; var fn = function (s, l, a, i) { var v0, v1, v2, v3 = l && ('test' in l), v4, v5, v6; v2 = v3 ? l : s; if (!(v3)) { if (s) { v1 = s.test; } } else { v1 = l.test; } if (v2 != null) { v6 = 1234; v5 = [1234]; if (v5 != null) { v4 = v5.toString; } else { v4 = undefined; } if (v4 != null) { ensureSafeFunction(v4, text); ensureSafeObject(v5, text); v0 = ensureSafeObject(v5.toString(), text); } else { v0 = undefined; } ensureSafeObject(v2.test, text); ensureSafeAssignContext(v2, text); } return v2.test = v0; }; return fn;
As we can see, AngularJS breaks the expression down to its basic operations. While generating the equivalent JavaScript code, it also injects calls to several ensureSafe
functions which we'll analyse in the next section.
The first line (use strict
) enables the strict mode, which offers several advantages (from a defence perspective) over the non-strict or normal mode. The strict mode, as its name implies, will make the syntax less permissive, throwing exceptions in cases where the normal mode would ignore them, among other differences.
The second line (var fn = function (s, l, a, i)
) corresponds to the function header. This will be invoked by AngularJS passing four parameters: scope
, locals
, assign
and inputs
(assigned to their first letter arguments, respectively). The scope
sets the context as we explained earlier, which can be accessed from the expression using the this
keyword. The remaining parameters will usually be set to undefined
, except in some cases. For instance, invoking a filter generates code that has more functions and is somewhat more complex in general.
Countermeasures of the sandbox in v1.5.8
AngularJS v1.5.8 addresses all previously known sandbox bypasses by enforcing different checks on parameters and functions, both at the parsing stage and at runtime. In order to understand the last-known bypass affecting v1.5.8 and how AngularJS mitigates it in the following versions, it's important to analyse the existing checks and what changed.
The main checks are performed in the following functions:
-
ensureSafeMemberName
: Prevents access to__defineGetter__
,__defineSetter__
,__lookupGetter__
,__lookupSetter__
and__proto__
fields. -
ensureSafeObject
: Disallows accessingObject
,Function
,Window
and DOM nodes. -
ensureSafeFunction
: Disallows callingcall
,apply
andbind
functions. Also checks for theFunction
constructor, likeensureSafeObject
. -
ensureSafeAssignContext
: Ensures that no assignment is made to the built-inInteger
,Boolean
,String
,Object
,Array
orFunction
constructors, so they and their members can't be replaced.
As we saw in the previous section, some of these functions will be called dynamically when executing our compiled expressions, and others will be called during the parsing and compilation processes, interrupting them if AngularJS detects a violation. For instance, this expression will throw an exception when compiling it:
{{ "".__proto__ }}
For an equivalent expression, the exception will be thrown afterwards, when executing the compiled function:
{{ ""["__pro" + "to__"] }}
Relevant changes between v1.5.8 and v1.5.9
The first step to attempt a bypass that worked in an earlier version is to identify and understand what was fixed in the new version. All AngularJS source code and commits are publicly available, as well as a working payload bypassing the sandbox, making this an easy task.
The v1.5.8 bypass by Gareth Heyes took advantage of the insufficient validation performed in ensureSafeAssignContext
. While this validation effectively prevented assignments to the constructor of commonly used built-in objects, it failed to check for their prototypes. Replacing the prototype of built-in types like String
is known to have an equivalent impact, and this is precisely why accessing __proto__
was disallowed in ensureSafeMemberName
in v1.5.9.
A direct call (e.g. ''.constructor.prototype.charAt=[].join
) doesn't work because, in the context of an assignment, AngularJS would initialise all of the referenced members to a new object if the object doesn't previously exist, so the ensureSafeAssignContext
is called for each member.
However, an indirect call through an intermediate object isn't checked in v1.5.8. By replacing the charAt
function for every subsequent call applied to any string, the built-in parser will confuse every string as an identifier. The call to the orderBy
filter with the x=alert(1)
argument will trigger the parser again, which will generate JavaScript code attempting to access that string as an identifier, which ends up in an unexpected assignment as 1.x=alert(1)
.
To mitigate this scenario, v1.5.9 introduced additional checks (fix($parse): block assigning to fields of a constructor prototype) in the ensureSafeAssignContext
function. Apart from the constructor, this version also checks for any assignment to the prototype of the same list of built-in objects checked in v1.5.8, so launching an attack through an indirect call is no longer possible.
To make sure that this was the only additional v1.5.9 check I had to bypass, I adapted my local testing environment to use v1.5.9 and replaced the ensureSafeAssignContext
function with an empty scope (for instance, by simply adding a return
statement at the beginning of this function), and then I retried the same payload to make sure it worked. Guess what? It didn't work!
This means that another change is preventing this payload from working. There has to be another new check in addition to the ensureSafeAssignContext
function. Rather than manually reviewing every single commit, I believed it would be much easier and faster to build the AngularJS library for every commit (or more efficiently, using a dichotomic search), and try the payload with each commit until it stops working, but compiling the project turned out to be more painful than I imagined for several reasons, so I discarded that approach and manually reviewed the commits instead. After all, there weren't many. Fortunately, the title of one commit immediately caught my eye: fix($parse): correctly escape unsafe identifier characters
nonComputedMember: function(left, right) { var SAFE_IDENTIFIER = /^[$_a-zA-Z][$_a-zA-Z0-9]*$/; var UNSAFE_CHARACTERS = /[^$_a-zA-Z0-9]/g; if (SAFE_IDENTIFIER.test(right)) { return left + '.' + right; } else { return left + '["' + right.replace(UNSAFE_CHARACTERS, this.stringEscapeFn) + '"]'; } }
This simple fix only affects regular expression checking if an identifier can be safely, directly generated as an expression using the dot syntax (e.g., object.identifer
). If not, all non-alphanumeric characters will be replaced with their corresponding Unicode escape sequences (e.g., =
), and the resulting identifier will be accessed using the brackets notation (e.g., object["identifier"]
).
In v1.5.8, the regular expression isn't surrounded by ^
and $
. This means that if the identifier contains at least an underscore or a letter of the Latin alphabet, it is considered a safe identifier, regardless of any other dangerous characters present, and is concatenated after a dot.
This common mistake usually leads to unexpected behaviour or vulnerabilities. That is what happened here – the bypass for v1.5.8 leverages this issue. If we analyse the JavaScript code generated by AngularJS when invoking the orderBy
filter, we can see in v1.5.8 an expression which looks like this:
l.x=alert(1)
Starting with v1.5.9, the whole identifier must match the regular expression. This was surely the original intention of this check. The same payload will pass through stringEscapeFn
and the resulting encoded identifier will be safe to access, and no alert box will be shown:
l["x=alert(1)"]
Conclusion
In this blog post, we reviewed the key AngularJS concepts of data binding and expressions, the sandbox, DOM-based contexts, and filters. From there, we assessed what had changed in v1.5.9 to mitigate v1.5.8 bypass techniques and identified two new checks in v1.5.9.
In Part 2 of this two-part blog series, we'll try to bypass the checks added in v1.5.9.
About the Author
Daniel Kachakil, Principal Security Engineer, leads the Anvil Secure application and cloud security groups. He works with the COO to grow Anvil Secure's client base and with the CTO to oversee and direct security research. He is a speaker and published author on topics including the Android vulnerabilities he discovered, cryptography, web hacking, and SQL injection. He is also an ethical hacking instructor. He holds a Master's Degree in Information Systems Management from Polytechnic University of Valencia in addition to a five-year degree in Computer Science Engineering with a concentration in Hardware Engineering.