Skip to main content

Command Palette

Search for a command to run...

[EXPERT] Reflected XSS with AngularJS sandbox escape without strings

AngularJS sandbox escape

Updated
19 min readView as Markdown
[EXPERT] Reflected XSS with AngularJS sandbox escape without strings
R
Operating System Internals, Networking and Web Internals

What is AngularJS?

We know that the AngularJS framework parses everything inside {{ }} as an AngularJS expression. Anything inside the curly braces is referred to as an AngularJS Expression. The AngularJS expression is evaluated against the application's (ng-app) scope.

The word scope here refers to the variables and functions available within the AngularJS application. Sometimes this might also be referred to as the application's state.

For example, when the page loads, the below AngularJS expression is evaluated and replaced with the appropriate variable from the application's scope.

<h1>{{ username }}</h1>

Suppose we have:

$scope.username = "wolf3shell";

AngularJS will automatically evaluate the expression and render:

<h1>wolf3shell</h1>

In previous labs (like DOM XSS in AngularJS expression with angle brackets and double quotes HTML-encoded), we were able to inject curly braces to trigger XSS.

However, that is not going to be the case for this lab.

We cannot directly make use of {{ }} in this challenge.


Why do we need AngularJS?

Before frameworks like AngularJS existed, developers had to manually manipulate the DOM using JavaScript.

For example:

document.getElementById("username").innerHTML = user.name;

As web applications became more dynamic and interactive, this approach quickly became difficult to maintain.

AngularJS attempted to solve several engineering problems:

  • Keeping the UI synchronized with application state.

  • Reducing manual DOM manipulation.

  • Allowing developers to bind variables directly to HTML.

  • Managing application state using scopes.

  • Building dynamic single-page applications.

Instead of writing:

document.getElementById("price").innerHTML = totalPrice;

developers could simply write:

{{ totalPrice }}

and AngularJS would automatically update the page whenever the value changed.

This concept became known as data binding, which was one of the primary reasons AngularJS became so popular.


What is AngularJS Sandbox?

The AngularJS Sandbox is a security mechanism that prevents access to potentially dangerous objects such as:

  • window

  • document

  • JavaScript constructors

  • Browser APIs

  • Arbitrary JavaScript execution

inside AngularJS expressions.

In other words, the sandbox attempts to restrict anything executed inside:

{{ }}

or anything passed to the AngularJS:

$parse()

method.

The problem was that every time AngularJS upgraded their sandbox to block a particular obfuscation or payload technique, security researchers eventually found another way to break that sandbox.

The history of AngularJS sandbox escapes is essentially a long cat-and-mouse game where AngularJS developers continuously upgraded the sandbox while security researchers kept discovering new methods to bypass those protections and execute arbitrary JavaScript.

In sandbox escape techniques, we are essentially finding ways to trick AngularJS into executing functions such as:

alert()

without the sandbox realizing what we are attempting to do.

We are supposed to be operating inside a contained and safe environment.

That's exactly what a sandbox is.

A sandbox escape is the process of breaking out of that restricted environment and executing arbitrary JavaScript.

You can read more about the history of AngularJS sandbox escapes here:

The end result of this cat-and-mouse game was the complete removal of the AngularJS sandbox itself.

The problem was that the sandbox provided a false sense of security.

Developers might have assumed that they could safely execute arbitrary expressions because the AngularJS sandbox would protect them from malicious input.

Unfortunately, that assumption turned out to be incorrect.


Why do we need a Sandbox in AngularJS?

The entire purpose of AngularJS expressions was data binding.

Developers were expected to write expressions such as:

{{ username }}
{{ price * quantity }}
{{ order.total }}

and not:

{{ alert(1) }}
{{ document.cookie }}
{{ window.location }}

Without restrictions, every AngularJS template expression would effectively become:

eval(user_input)

which would immediately lead to arbitrary JavaScript execution inside the browser.

Therefore, AngularJS implemented the sandbox to:

  • restrict access to browser globals,

  • restrict dangerous constructors,

  • prevent arbitrary JavaScript execution,

  • ensure expressions remain limited to data-binding operations.


How AngularJS Sandbox performs detection of malicious JavaScript?

AngularJS expressions are not technically JavaScript.

They look very similar to JavaScript, but AngularJS first parses them and then compiles them into JavaScript.

For example:

{{ 1 + 1 }}

gets evaluated and rendered as:

2

Before AngularJS evaluates an expression, it performs several security checks.

One of these security checks makes use of an internal function called:

isIdent()

which determines whether a token is a valid identifier.

A simplified version looks like this:

isIdent = function(ch) {
    return (
        'a' <= ch && ch <= 'z' ||
        'A' <= ch && ch <= 'Z' ||
        '_' === ch ||
        '$' === ch
    );
}

The sandbox parser expects to process expressions one character at a time.

To do this, it repeatedly calls:

charAt()

on strings.

For example:

"alert".charAt(0)

returns:

"a"

This allows AngularJS to inspect every character individually and determine whether the expression appears safe.


How AngularJS 1.4.4 Sandbox performs detection of malicious JavaScript and blocks them?

AngularJS 1.4.4 assumes that:

charAt()

always returns exactly one character.

This assumption is fundamental to how the sandbox performs validation.

Consider the following simplified code:

isIdent = function(ch) {
    return (
        'a' <= ch && ch <= 'z' ||
        'A' <= ch && ch <= 'Z' ||
        '_' === ch ||
        '$' === ch
    );
}

AngularJS repeatedly executes:

isIdent(expression.charAt(i))

for every character in the expression.

If an invalid character is found, AngularJS terminates the expression evaluation because the expression is considered unsafe.

The problem is that JavaScript allows prototype modification.

If an attacker can modify:

String.prototype.charAt

then AngularJS's assumptions about the behavior of charAt() become invalid.

At that point, the sandbox is no longer evaluating what it believes it is evaluating.


How did we evade the detection in the lab?

Hackers bypass sandboxes.

Destroyers destroy them!

In reality, this is not really a sandbox bypass.

This is a Sandbox Demolition.

It's not clever obfuscation or a traditional bypass.

We simply destroy one of the sandbox's core assumptions.

The way it works is that we overwrite the:

charAt()

method on JavaScript strings.

We begin with:

toString().constructor.prototype.charAt=[].join

Let's break this apart.

First:

toString().constructor

returns the JavaScript:

String

constructor.

Next:

toString().constructor.prototype

gives us access to all inherited methods of the String object.

Among those inherited methods is:

charAt()

We then overwrite it with:

[].join

giving us:

toString().constructor.prototype.charAt=[].join

Normally:

"alert".charAt(0)

returns:

"a"

However, after overwriting:

"x=alert(1)".charAt(0)

returns the entire string.

The AngularJS parser still believes it is validating one character at a time, while in reality it is validating an entire expression.

The problem is that isIdent() was designed to validate a single character at a time.

Once we force it to receive entire strings instead of single characters, its assumptions break down and the validation logic no longer behaves as intended.

In other words, the sandbox is no longer what it's supposed to be.

We have effectively destroyed it.

That's the end of the first stage of the exploit.

The second stage is to exploit the fact that we can now get unsafe expressions through the sandbox.


Description

This lab uses AngularJS in an unusual way where the $eval function is unavailable and we are unable to use strings directly inside AngularJS expressions as they get HTML encoded.

Normally, AngularJS sandbox escapes make heavy use of strings. However, in this particular lab, we need to escape the AngularJS sandbox without using string literals.


Task

To solve the lab, perform a cross-site scripting attack that escapes the AngularJS sandbox and executes the:

alert()

function without using the $eval function.


Methodology

Add the target URL to Burp Suite scope.

This is our target website.

For a quick recap on AngularJS concepts, refer to the previous sections.

Let's search for an arbitrary string to identify where our input gets rendered in the DOM.

Let's search for:

wolf3shell

Searching for wolf3shell inside the DevTools source code reveals the following JavaScript:

angular.module('labApp', []).controller('vulnCtrl',function(\(scope, \)parse) {
    $scope.query = {};
    var key = 'search';
    $scope.query[key] = 'wolf3shell';
    \(scope.value = \)parse(key)($scope.query);
});

Analyzing the above JavaScript, we can see that the AngularJS object (probably imported via a CDN) is defining a controller named:

vulnCtrl

The controller receives both:

  • $scope

  • $parse

which is somewhat unusual because, in many AngularJS applications, simply passing $scope would be sufficient.

The code initializes:

$scope.query = {};

and then creates:

var key = 'search';

Notice that this key corresponds to the first key-value pair in the URL.

There is a good chance that this search value is not actually hardcoded into the JavaScript.

Instead, it appears that the backend is dynamically generating JavaScript and inserting the user-supplied parameter names directly into the script that gets returned to the browser.

In other words:

The key in the key-value pair is being injected into the JavaScript.

This can potentially become an injection point for us.

Next:

$scope.query[key] = 'wolf3shell';

Since:

key == 'search'

this effectively becomes:

$scope.query['search'] = 'wolf3shell';

Then we execute:

\(scope.value = \)parse(key)($scope.query);

At this point, we need to understand what the AngularJS $parse() method actually does.


The AngularJS $parse() method

The AngularJS $parse() method accepts a string containing an AngularJS expression and returns a function.

Reference:

That returned function is then evaluated against a supplied scope object.

In our case:

\(parse(key)(\)scope.query)

becomes:

\(parse('search')(\)scope.query)

AngularJS evaluates:

$scope.query['search']

which returns:

wolf3shell

which finally gets assigned to:

$scope.value

Honestly, this seems like a very roundabout way of doing things.

There must be a simpler and safer way of reflecting a user's search term back to the page.

However, because the developer chose to use $parse(), we now have an opportunity to inject arbitrary AngularJS expressions.

Although we cannot directly use:

{{ }}

if we can inject data into the string processed by $parse(), it is effectively the same thing.

Because behind the scenes, AngularJS is still evaluating our input as an AngularJS expression.


Let's test this theory.

We will change:

search=1

and add a second parameter:

wolf3shell=2

Our URL becomes:

https://LAB/?search=1&wolf3shell=2

Inspecting the generated JavaScript reveals:

angular.module('labApp', []).controller('vulnCtrl',function(\(scope, \)parse) {
    $scope.query = {};

    var key = 'search';
    $scope.query[key] = '1';
    \(scope.value = \)parse(key)($scope.query);

    var key = 'wolf3shell';
    $scope.query[key] = '2';
    \(scope.value = \)parse(key)($scope.query);
});

This is where things start becoming bizarre.

As we can see, the application is iteratively processing every key-value pair in the URL.

The first parameter behaves normally.

Then:

var key = 'wolf3shell';

gets assigned.

Next:

$scope.query[key]='2';

and finally:

\(scope.value = \)parse(key)($scope.query);

executes:

\(parse('wolf3shell')(\)scope.query)

which evaluates:

$scope.query['wolf3shell']

which returns:

2

and ultimately updates the search result count.

There is no vulnerability yet.

However, we have successfully injected an arbitrary string into the AngularJS parser.


Usually, we test AngularJS injection using:

{{1+1}}

If AngularJS evaluates the expression, we get:

2

Otherwise, we simply see:

1+1

However, in this lab, we cannot use:

{{ }}

directly.

Let's try to reproduce the same test using our parameter injection.

One problem is that:

+

has a special meaning inside URLs.

Therefore, we must URL-encode it.

The encoded value of:

+

is:

%2B

We will also avoid using:

2

as the value because that might confuse our observations.

Our final URL becomes:

https://LAB/?search=1&1%2B1=10

When the page loads, we can briefly observe:

{{ value }}

being rendered before AngularJS evaluates it.

This is generally considered poor practice because AngularJS template expressions should not be visible to users.

Eventually, AngularJS evaluates:

$parse('1+1')

which returns:

2

Notice something important here.

We are not getting search results for:

1+1

We are getting search results for:

2

This confirms that our AngularJS expression has been evaluated.

Previously, AngularJS looked up values inside:

$scope.query

However, in this case:

1+1

does not exist as a property inside:

$scope.query

As a result, AngularJS simply evaluates the expression itself.

At this point, the logical next step is obvious.

Let's replace the expression with something malicious.

For example:

https://LAB/?search=1&alert=10

The generated JavaScript becomes:

angular.module('labApp', []).controller('vulnCtrl',function(\(scope, \)parse) {
    $scope.query = {};

    var key = 'search';
    $scope.query[key] = '1';
    \(scope.value = \)parse(key)($scope.query);

    var key = 'alert';
    $scope.query[key] = '10';
    \(scope.value = \)parse(key)($scope.query);
});

We can clearly see:

var key='alert'

being passed directly into:

$parse()

So the obvious question becomes:

If AngularJS is evaluating the alert function, why don't we get an alert box?

There are several things to keep in mind.

First, AngularJS expressions are not technically JavaScript.

They look very similar to JavaScript, but AngularJS first parses them and then compiles them into JavaScript.

The key idea is that AngularJS attempts to compile them into safe JavaScript.

If AngularJS detects something dangerous, such as:

alert()

the sandbox attempts to block it.

This entire mechanism is what we refer to as the:

AngularJS Sandbox

From the PortSwigger cheat sheet, we obtain the AngularJS 1.4.4 sandbox escape payload that does not require strings:

toString().constructor.prototype.charAt=[].join;
[1,2]|orderBy:
toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)

Let's decode this payload and understand how it destroys the AngularJS sandbox.


Decoding AngularJS Sandbox Bypass Exploit

In this section, we will decode the below exploit, which effectively destroys the AngularJS sandbox in AngularJS version 1.4.4.

toString().constructor.prototype.charAt=[].join;
[1,2]|orderBy:
toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)

Hackers bypass sandboxes.

Destroyers destroy them!

In reality, this is not really a sandbox bypass.

This is a Sandbox Demolition.

It's not some clever obfuscation trick that slips through the sandbox unnoticed. The payload literally destroys one of the fundamental assumptions upon which the sandbox was built.

The way it works is that we overwrite the:

charAt()

method on JavaScript strings.


Step 1 — Obtaining the String constructor

We begin with:

toString().constructor

Recall that:

toString()

returns a string.

Every JavaScript object has a:

.constructor

property which points to the function that created it.

Therefore:

toString().constructor

returns:

String

which is the JavaScript String constructor function.


Step 2 — Accessing the String prototype

Next:

toString().constructor.prototype

gives us access to all inherited methods and properties of JavaScript strings.

For example:

String.prototype

contains:

  • charAt()

  • substring()

  • replace()

  • split()

  • indexOf()

  • and many others.

The method we are interested in is:

charAt()

because AngularJS internally relies on this method while performing sandbox validation.


Step 3 — Destroying the Sandbox

Now comes the fun part.

We overwrite:

String.prototype.charAt

with:

Array.prototype.join

using:

toString().constructor.prototype.charAt=[].join

Normally:

"alert".charAt(0)

returns:

"a"

because charAt() is designed to return a single character.

However, after our overwrite:

"x=alert(1)".charAt(0)

no longer behaves as AngularJS expects.

The AngularJS parser still believes it is receiving one character at a time, while in reality it is receiving an entire string.

In other words, we have broken one of the sandbox's fundamental assumptions.


Step 4 — Why does breaking charAt() break the sandbox?

Referring to Gareth Hayes' research, AngularJS internally makes use of the following function:

isIdent = function(ch) {
    return (
        'a' <= ch && ch <= 'z' ||
        'A' <= ch && ch <= 'Z' ||
        '_' === ch ||
        '$' === ch
    );
}

The purpose of this function is to determine whether a token represents a valid JavaScript identifier.

For example:

isIdent('a')

returns:

true

while:

isIdent('=')

returns:

false

The important thing to understand here is that isIdent() was designed to process exactly one character at a time.

AngularJS repeatedly executes:

isIdent(expression.charAt(i))

while parsing the AngularJS expression.

For example:

alert(1)

gets processed roughly like this:

isIdent('a')
isIdent('l')
isIdent('e')
isIdent('r')
isIdent('t')
isIdent('(')

The moment AngularJS encounters an invalid character, the expression gets rejected.

The entire security model assumes that:

charAt()

returns a single character.

Once we overwrite charAt(), that assumption immediately breaks down.

Instead of receiving:

"a"

AngularJS may now receive:

"x=alert(1)"

as the argument to isIdent().

At that point, the validation logic no longer behaves as intended because it was never designed to process entire strings.

In other words:

The sandbox is no longer what it believes itself to be.

That's the end of stage one of our exploit.

We have successfully destroyed the sandbox.


Step 5 — Executing arbitrary JavaScript

Now that the sandbox has been destroyed, we need to actually execute JavaScript.

The second stage of the exploit is:

[1,2]|orderBy:
toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)

This is not technically JavaScript.

This is an AngularJS expression.


Step 6 — Understanding AngularJS Filters

At first glance:

[1,2]|

looks like the JavaScript bitwise OR operator.

However, this is not JavaScript.

In AngularJS, the pipe character:

|

represents a filter.

Filters transform data before it gets displayed.

Examples include:

{{ username | uppercase }}
{{ array | limitTo:5 }}
{{ users | filter:'admin' }}
{{ numbers | orderBy }}

Reference:

In our exploit, the filter being used is:

orderBy

Normally, orderBy is used to sort arrays.

For example:

[3,1,2] | orderBy

returns:

[1,2,3]

However, we do not care about sorting.

We only care about the fact that AngularJS evaluates the expression supplied to the filter.


Step 7 — Why do we need fromCharCode()?

Earlier, we mentioned that this lab prevents us from using strings.

Normally, we would simply write:

"x=alert(1)"

Unfortunately, string literals are unavailable.

However:

Just because we cannot write strings does not mean that we cannot create strings.

Instead, we generate the string dynamically using:

toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)

Recall that:

toString().constructor

returns:

String

Therefore:

String.fromCharCode()

constructs a string from Unicode character values.

Reference:

Decoding:

120,61,97,108,101,114,116,40,49,41

gives us:

x=alert(1)

without ever needing quotation marks.


Step 8 — Why do we need x=alert(1)? Why not simply alert(1)?

You might have another question here.

Why do we need x=alert(1)? Why can we not simply call alert(1)?

The short answer is:

Because this exploit is not really about sorting arrays.

It is about finding an expression shape that AngularJS will evaluate after we have destroyed the sandbox.

Remember, this entire exploit is about sandbox bypassing and obfuscation.

If we simply wrote:

alert(1)

AngularJS may still reject or handle the expression differently, even after we have partially broken the sandbox.

Historically, many AngularJS sandbox escapes relied not only on breaking the sandbox itself but also on carefully crafting an expression shape that AngularJS would happily evaluate.

Instead, we use:

x=alert(1)

because assignments are valid AngularJS expressions and naturally fit contexts where AngularJS expects an expression that returns a value.

When AngularJS evaluates:

x=alert(1)

it must first evaluate:

alert(1)

in order to assign the result to:

x

The important thing here is that we do not care about the value being assigned to x.

We only care about the side effect:

alert(1)

gets executed.

Eventually, the assignment evaluates to:

undefined

because:

alert()

returns:

undefined

This undefined value is then used by the orderBy filter.

Obviously, sorting by:

undefined

does not change the order of the array.

We are not trying to sort anything.

We are simply abusing the fact that AngularJS evaluates the expression supplied to the orderBy filter.

Another subtle advantage is that:

x=alert(1)

does not syntactically look like a direct function invocation.

In other words:

alert(1)

is an obvious dangerous function call, whereas:

x=alert(1)

is an assignment expression whose side effect happens to execute alert().

This makes the expression shape much more useful for sandbox escapes.


Step 9 — Putting everything together

Our payload:

toString().constructor.prototype.charAt=[].join;
[1,2]|orderBy:
toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)

can now be understood as:

Stage 1

Destroy the AngularJS sandbox:

toString().constructor.prototype.charAt=[].join

Stage 2

Generate the forbidden string:

x=alert(1)

using:

String.fromCharCode()

Stage 3

Trick AngularJS into evaluating the generated expression through the:

orderBy

filter.


Finally, we inject:

search=1&
toString().constructor.prototype.charAt%3d[].join;
[1]|orderBy:
toString().constructor.fromCharCode(
120,61,97,108,101,114,116,40,49,41)=1

and successfully trigger XSS by completely destroying the AngularJS sandbox.

As Gareth Hayes demonstrated, this was never really a sandbox escape.

It was a sandbox demolition.

4 views