const net = require('net'); const CryptoJS = require('crypto-js'); const WTVSec = require('./includes/classes/WTVSec.js'); const e = require('express'); const WTVShared = require('./includes/classes/WTVShared.js')['WTVShared']; const LZPF = require('./includes/classes/LZPF.js'); const zlib = require('zlib'); /** * WebTV Client Simulator * * This simulator emulates a WebTV client connecting to a WebTV service * using the WTVP protocol with proper authentication and service discovery. */ class WebTVClientSimulator { constructor(host, port, ssid, url, outputFile = null, maxRedirects = 10, useEncryption = false, request_type_download = false, debug = false) { this.host = host; this.port = port; this.ssid = ssid; this.url = url; this.request_type_download = request_type_download; this.outputFile = outputFile; this.maxRedirects = maxRedirects; this.useEncryption = useEncryption; this.encryptionEnabled = false; this.redirectCount = 0; this.userIdDetected = false; this.targetUrlFetched = false; // Prevent multiple target URL fetches this.services = new Map(); // Store service name -> {host, port} mappings this.wtvsec = null; this.wtvshared = new WTVShared(); this.ticket = null; this.incarnation = 1; this.currentSocket = null; this.challengeResponse = null; this.initial_key = null; // Store initial key from wtv-initial-key header this.hasSeenEncryptedResponse = false; // Track if we've seen an encrypted response this.debug = debug; // Load minisrv config to get the initial shared key this.minisrv_config = this.wtvshared.readMiniSrvConfig(true, false); this.debugLog(`WebTV Client Simulator starting...`); this.debugLog(`Target: ${host}:${port}`); this.debugLog(`SSID: ${ssid}`); this.debugLog(`Target URL after auth: ${url}`); this.debugLog(`Encryption: ${useEncryption ? 'enabled' : 'disabled'}`); if (outputFile) { this.debugLog(`Output file: ${outputFile}`); } } debugLog(...args) { if (this.debug) { console.log(...args); } } /** * Decompress response body based on content encoding headers */ decompressBody(body, headers) { if (!Buffer.isBuffer(body) || body.length === 0) { return body; } try { // Check for LZPF compression first (WebTV specific) if (headers['wtv-lzpf'] === '0') { this.debugLog('Decompressing LZPF compressed body...'); const lzpf = new LZPF(); const decompressed = lzpf.expand(body); this.debugLog(`LZPF decompression: ${body.length} bytes -> ${decompressed.length} bytes`); return decompressed; } // Check for standard gzip/deflate compression if (headers['content-encoding']) { const encoding = headers['content-encoding'].toLowerCase(); this.debugLog(`Decompressing ${encoding} compressed body...`); if (encoding === 'deflate') { const decompressed = zlib.inflateSync(body); this.debugLog(`Deflate decompression: ${body.length} bytes -> ${decompressed.length} bytes`); return decompressed; } else if (encoding === 'gzip') { const decompressed = zlib.gunzipSync(body); this.debugLog(`Gzip decompression: ${body.length} bytes -> ${decompressed.length} bytes`); return decompressed; } } // No compression detected, return original body return body; } catch (error) { console.error('Error decompressing response body:', error); this.debugLog('Returning original compressed body due to decompression error'); return body; } } /** * Start the simulation by connecting to wtv-1800:/preregister */ async start() { try { await this.makeRequest('wtv-1800', '/preregister'); } catch (error) { console.error('Failed to start simulation:', error); } } /** * Make a WTVP request to a service */ async makeRequest(serviceName, path, data = null, skipRedirects = false) { return new Promise((resolve, reject) => { // Determine host and port for the service let targetHost = this.host; let targetPort = this.port; if (this.services.has(serviceName)) { const service = this.services.get(serviceName); targetHost = service.host; targetPort = service.port; } this.debugLog(`\n--- Making request to ${serviceName}:${path} at ${targetHost}:${targetPort} ---`); const socket = new net.Socket(); this.currentSocket = socket; let responseData = Buffer.alloc(0); socket.connect(targetPort, targetHost, () => { this.debugLog(`Connected to ${targetHost}:${targetPort}`); let requestData; if (this.encryptionEnabled && this.wtvsec) { // For encrypted requests, first send SECURE ON, then immediately send the encrypted request // This matches the real WebTV client behavior seen in packet captures this.debugLog('Sending SECURE ON request...'); const secureOnBuffer = this.buildSecureOnRequest(); console.log(secureOnBuffer.toString('hex')); socket.write(secureOnBuffer); // Send encrypted request immediately after (as seen in pcap analysis) setImmediate(() => { this.debugLog('Sending encrypted request...'); const encryptedRequestData = this.buildEncryptedRequest(serviceName, path, data); console.log(encryptedRequestData.toString('hex')); socket.write(encryptedRequestData); }); } else { // Send regular request requestData = this.buildRegularRequest(serviceName, path, data); this.debugLog('Sending request:'); this.debugLog(requestData.toString()); socket.write(requestData); } }); socket.on('data', (chunk) => { responseData = Buffer.concat([responseData, chunk]); this.debugLog(`Received chunk: ${chunk.length} bytes`); // Check if we have a complete response if (this.encryptionEnabled) { // For encrypted responses, we need to handle differently this.handleEncryptedResponse(responseData, resolve, reject); } else { // Regular response handling // Only check for header/body split, do not convert to string // Use both CRLF and LF as in handleResponse const crlfcrlf = Buffer.from('\r\n\r\n'); const lflf = Buffer.from('\n\n'); let idx = responseData.indexOf(crlfcrlf); if (idx === -1) idx = responseData.indexOf(lflf); if (idx !== -1) { this.debugLog('Complete response detected, processing...'); this.handleResponse(responseData, resolve, reject, skipRedirects); } } }); socket.on('close', () => { this.debugLog('Connection closed'); if (responseData.length > 0 && !this.encryptionEnabled) { // Only process if not already processed const crlfcrlf = Buffer.from('\r\n\r\n'); const lflf = Buffer.from('\n\n'); let idx = responseData.indexOf(crlfcrlf); if (idx === -1) idx = responseData.indexOf(lflf); if (idx === -1) { this.debugLog('Processing incomplete response on close...'); this.handleResponse(responseData, resolve, reject, skipRedirects); } } else if (responseData.length > 0 && this.encryptionEnabled) { this.debugLog('Processing encrypted response on close...'); this.handleEncryptedResponse(responseData, resolve, reject); } }); socket.on('error', (error) => { console.error('Socket error:', error); reject(error); }); // Set timeout socket.setTimeout(30000, () => { console.error('Request timed out'); socket.destroy(); reject(new Error('Request timeout')); }); }); } /** * Build a regular (unencrypted) WTVP request */ buildRegularRequest(serviceName, path, data = null) { const method = data ? 'POST' : 'GET'; let request = `${method} ${serviceName}:${path}\r\n`; // Add required headers request += `wtv-client-serial-number: ${this.ssid}\r\n`; request += `wtv-client-bootrom-version: 105\r\n`; request += `wtv-client-rom-type: US-LC2-disk-0MB-8MB\r\n`; request += `wtv-incarnation: ${this.incarnation}\r\n`; request += `wtv-show-time: 0\r\n`; request += `wtv-request-type: ${((this.request_type_download) ? 'download' : 'primary')}\r\n`; request += `wtv-system-cpuspeed: 166187148\r\n`; request += `wtv-system-sysconfig: 4163328\r\n`; request += `wtv-disk-size: 8006\r\n`; // Add challenge response if we have one if (this.challengeResponse) { request += `wtv-challenge-response: ${this.challengeResponse}\r\n`; this.debugLog('Added challenge response to request'); this.challengeResponse = null; // Clear challenge response after adding to request } // Add ticket if we have one if (this.ticket) { request += `wtv-ticket: ${this.ticket}\r\n`; } // Add content if POST if (data) { const content = typeof data === 'string' ? data : JSON.stringify(data); request += `Content-Length: ${content.length}\r\n`; request += `Content-Type: application/x-www-form-urlencoded\r\n`; request += `\r\n${content}`; } else { request += '\r\n'; } return Buffer.from(request, 'utf8'); } /** * Build a SECURE ON request (sent in plaintext to establish encryption) */ buildSecureOnRequest() { // Increment incarnation for encrypted session this.incarnation++; this.debugLog(`Using incarnation: ${this.incarnation}`); // SECURE ON should match real WebTV client exactly - no URL, just the method let request = `SECURE ON\r\n`; request += `Accept-Language: en-US,en\r\n`; if (this.ticket) { request += `wtv-ticket: ${this.ticket}\r\n`; } request += `wtv-connect-session-id: ${Math.random().toString(16).substr(2, 8)}\r\n`; request += `wtv-client-serial-number: ${this.ssid}\r\n`; request += `wtv-system-version: 7181\r\n`; request += `wtv-capability-flags: 10935ffc8f\r\n`; request += `wtv-client-bootrom-version: 2046\r\n`; request += `wtv-client-rom-type: US-LC2-disk-0MB-8MB\r\n`; request += `wtv-system-chipversion: 51511296\r\n`; request += `User-Agent: Mozilla/4.0 WebTV/2.2.6.1 (compatible; MSIE 4.0)\r\n`; request += `wtv-encryption: true\r\n`; request += `wtv-script-id: -154276969\r\n`; request += `wtv-script-mod: ${Math.floor(Date.now() / 1000)}\r\n`; request += `wtv-incarnation:${this.incarnation}\r\n`; // Note: no space after colon request += '\r\n'; return Buffer.from(request, 'utf8'); } /** * Build an encrypted WTVP request */ buildEncryptedRequest(serviceName, path, data = null) { const method = data ? 'POST' : 'GET'; let request = `${method} ${serviceName}:${path}\r\n`; // For encrypted requests, only include the minimal necessary headers // The SECURE ON already sent the auth and session info if (this.request_type_download) request += 'wtv-request-type: download\r\n'; // Add content if POST if (data) { const content = typeof data === 'string' ? data : JSON.stringify(data); request += `Content-Length: ${content.length}\r\n`; request += `Content-Type: application/x-www-form-urlencoded\r\n`; request += `\r\n${content}`; } else { request += '\r\n'; } // Encrypt the request using RC4 with key 0 (server expects Decrypt(0, enc_data)) try { this.wtvsec.set_incarnation(this.incarnation); // Ensure WTVSec has the correct incarnation const encryptedBuffer = this.wtvsec.Encrypt(0, request); return Buffer.from(encryptedBuffer); } catch (error) { console.error('Error encrypting request:', error); return Buffer.from(request, 'utf8'); } } /** * Handle encrypted response data */ handleEncryptedResponse(responseData, resolve, reject) { try { // Find header/body split using CRLF CRLF (\r\n\r\n) or fallback to LF LF (\n\n) let idx = -1; let sepLen = 0; const crlfcrlf = Buffer.from('\r\n\r\n'); const lflf = Buffer.from('\n\n'); idx = responseData.indexOf(crlfcrlf); if (idx !== -1) { sepLen = 4; } else { idx = responseData.indexOf(lflf); if (idx !== -1) sepLen = 2; } if (idx === -1) { // Not a complete response yet return; } // Split headers and body - headers are always plaintext const headerSection = responseData.slice(0, idx).toString('utf8'); const bodyBuffer = responseData.slice(idx + sepLen); this.debugLog('\nReceived encrypted response:'); this.debugLog('Headers:'); this.debugLog(headerSection); // Parse headers const lines = headerSection.split(/\r?\n/); const statusLine = lines[0].replace('\r', ''); this.debugLog(`Status: ${statusLine}`); const headers = {}; for (let i = 1; i < lines.length; i++) { const line = lines[i].replace('\r', ''); const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).toLowerCase(); const value = line.substring(colonIndex + 1).trim(); headers[key] = value; } } // Decrypt the body if we have encryption enabled and encrypted content let body = Buffer.alloc(0); if (bodyBuffer.length > 0 && headers['wtv-encrypted'] === 'true' && this.wtvsec) { try { this.debugLog('Decrypting response body...'); const decryptedBuffer = this.wtvsec.Decrypt(1, bodyBuffer); body = Buffer.from(decryptedBuffer); this.debugLog('Body decrypted successfully'); } catch (error) { console.error('Error decrypting response body:', error); body = bodyBuffer; } } else { body = bodyBuffer; } // Decompress the body if needed body = this.decompressBody(body, headers); // Handle special headers this.processHeaders(headers); // Mark that we've seen an encrypted response if (headers['wtv-encrypted'] === 'true') { this.hasSeenEncryptedResponse = true; } // Close current connection if (this.currentSocket) { this.currentSocket.destroy(); this.currentSocket = null; } resolve({ headers, body, status: statusLine }); } catch (error) { console.error('Error processing encrypted response:', error); reject(error); } } handleResponse(responseData, resolve, reject, skipRedirects = false) { this.debugLog('\nReceived response:'); this.debugLog(responseData); try { // Find header/body split using CRLF CRLF (\r\n\r\n) or fallback to LF LF (\n\n) let idx = -1; let sepLen = 0; const crlfcrlf = Buffer.from('\r\n\r\n'); const lflf = Buffer.from('\n\n'); idx = responseData.indexOf(crlfcrlf); if (idx !== -1) { sepLen = 4; } else { idx = responseData.indexOf(lflf); if (idx !== -1) sepLen = 2; } let headerSection, bodyBuf; if (idx !== -1) { headerSection = responseData.slice(0, idx).toString('utf8'); bodyBuf = responseData.slice(idx + sepLen); } else { headerSection = responseData.toString('utf8'); bodyBuf = Buffer.alloc(0); } const lines = headerSection.split(/\r?\n/); const statusLine = lines[0].replace('\r', ''); this.debugLog(`Status: ${statusLine}`); // Parse headers const headers = {}; for (let i = 1; i < lines.length; i++) { const line = lines[i].replace('\r', ''); const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).toLowerCase(); const value = line.substring(colonIndex + 1).trim(); if (headers[key]) { if (Array.isArray(headers[key])) { headers[key].push(value); } else { headers[key] = [headers[key], value]; } } else { headers[key] = value; } } } this.processHeaders(headers); // Decompress the body if needed bodyBuf = this.decompressBody(bodyBuf, headers); // Mark that we've seen an encrypted response if (headers['wtv-encrypted'] === 'true') { this.hasSeenEncryptedResponse = true; } if (this.currentSocket) { this.currentSocket.destroy(); this.currentSocket = null; } if (this.userIdDetected && !this.targetUrlFetched) { this.targetUrlFetched = true; this.debugLog(`\n*** Authentication complete! Now fetching target URL: ${this.url} ***`); setTimeout(() => { this.fetchTargetUrl() .then(resolve) .catch(reject); }, 100); return; } if (headers['wtv-visit'] && !skipRedirects) { if (this.redirectCount >= this.maxRedirects) { this.debugLog(`Maximum redirects (${this.maxRedirects}) reached, stopping`); resolve({ headers, body: bodyBuf, status: statusLine, stopped: true }); return; } this.redirectCount++; this.debugLog(`Following wtv-visit (${this.redirectCount}/${this.maxRedirects}): ${headers['wtv-visit']}`); setTimeout(() => { this.followVisit(headers['wtv-visit']) .then(resolve) .catch(reject); }, 100); } else { if (skipRedirects && headers['wtv-visit']) { this.debugLog(`Skipping wtv-visit redirect: ${headers['wtv-visit']}`); } else { this.debugLog('No wtv-visit header found, resolving...'); } resolve({ headers, body: bodyBuf, status: statusLine }); } } catch (error) { console.error('Error processing response:', error); reject(error); } } /** * Process special WTVP headers */ processHeaders(headers) { // Handle wtv-service headers (can be multiple) const wtvServices = headers['wtv-service']; if (wtvServices) { const serviceValues = Array.isArray(wtvServices) ? wtvServices : [wtvServices]; for (const serviceValue of serviceValues) { if (serviceValue === 'reset') { this.debugLog('Clearing service mappings'); this.services.clear(); } else { // Parse service definition: "name=service-name host=host port=port flags=0x00000001 connections=1" const nameMatch = serviceValue.match(/name=([^\s]+)/); const hostMatch = serviceValue.match(/host=([^\s]+)/); const portMatch = serviceValue.match(/port=([^\s]+)/); if (nameMatch && hostMatch && portMatch) { const serviceName = nameMatch[1]; const host = hostMatch[1]; const port = parseInt(portMatch[1]); this.services.set(serviceName, { host, port }); this.debugLog(`Registered service: ${serviceName} -> ${host}:${port}`); } } } } // Handle wtv-initial-key if (headers['wtv-initial-key']) { this.initial_key = headers['wtv-initial-key']; } // Handle wtv-challenge if (headers['wtv-challenge']) { this.debugLog('Received wtv-challenge, processing...'); if (!this.wtvsec) { this.debugLog('No WTVSec instance, initializing with default key...'); this.wtvsec = new WTVSec(this.minisrv_config, this.incarnation); } try { this.wtvsec.IssueChallenge(); this.wtvsec.set_incarnation(headers["wtv-incarnation"]); const challengeResponse = this.wtvsec.ProcessChallenge(headers['wtv-challenge'], CryptoJS.enc.Base64.parse(this.initial_key)); if (challengeResponse && challengeResponse.toString(CryptoJS.enc.Base64)) { this.debugLog('Challenge processed successfully, preparing response'); // We'll send the challenge response in the next request this.challengeResponse = challengeResponse.toString(CryptoJS.enc.Base64); //this.incarnation = this.wtvsec.incarnation; this.debugLog('Setting wtv-challenge-response header for next request'); } else { console.error('Failed to process challenge - no response generated'); } } catch (error) { console.error('Error processing challenge:', error.message); } } // Handle wtv-ticket if (headers['wtv-ticket']) { this.debugLog('Received wtv-ticket'); this.ticket = headers['wtv-ticket']; } // Handle user-id header - indicates successful authentication if (headers['user-id']) { this.debugLog(`*** Authentication successful! user-id detected: ${headers['user-id']} ***`); this.userIdDetected = true; // Enable encryption if requested and we have WTVSec if (this.useEncryption) { this.debugLog('*** Enabling encryption after successful authentication ***'); if (!this.wtvsec) { // Initialize with current incarnation (which was incremented when we got wtv-encrypted: true) this.wtvsec = new WTVSec(this.minisrv_config, this.incarnation); } // Follow the same sequence as the server to ensure matching keys if (this.ticket) { this.wtvsec.DecodeTicket(this.ticket); this.wtvsec.ticket_b64 = this.ticket; // Set the incarnation to match current state this.wtvsec.set_incarnation(this.incarnation); } this.wtvsec.SecureOn(); // Initialize RC4 sessions this.encryptionEnabled = true; } return; // Stop processing other headers since we're authenticated } } /** * Follow a wtv-visit directive */ async followVisit(visitUrl) { this.debugLog(`Parsing wtv-visit URL: ${visitUrl}`); // Parse the visit URL: service:/path or service:path const match = visitUrl.match(/^([\w-]+):\/?(.*)/); if (match) { const serviceName = match[1]; const path = '/' + (match[2] || ''); this.debugLog(`Parsed service: ${serviceName}, path: ${path}`); return await this.makeRequest(serviceName, path); } else { throw new Error(`Invalid wtv-visit URL: ${visitUrl}`); } } /** * Fetch the target URL after authentication is complete */ async fetchTargetUrl() { console.log(`Fetching target URL: ${this.url}`); // Parse the target URL const match = this.url.match(/^([\w-]+):\/?(.*)/); if (match) { const serviceName = match[1]; const path = '/' + (match[2] || ''); this.debugLog(`Parsed target service: ${serviceName}, path: ${path}`); try { const result = await this.makeRequest(serviceName, path, null, true); // Skip redirects for target URL // Handle the response if (result.body) { this.debugLog('\n*** Target URL Response Body ***'); if (this.outputFile) { await this.saveToFile(result.body); console.log(`Content saved to: ${this.outputFile}`); } else { // Detect text content for CLI output const contentType = result.headers['content-type'] || ''; if (/^text\//.test(contentType) || /json|xml|javascript/.test(contentType)) { console.log(result.body.toString('utf8')); } else { console.log(''); } } } else { this.debugLog('No body content received from target URL'); } this.debugLog('\n*** Request completed successfully ***'); this.cleanup(); process.exit(0); return result; } catch (error) { console.error('Error fetching target URL:', error); throw error; } } else { throw new Error(`Invalid target URL: ${this.url}`); } } /** * Process the content response without following redirects */ processContentResponse(responseData, resolve, reject) { try { // Find header/body split using CRLF CRLF (\r\n\r\n) or fallback to LF LF (\n\n) let idx = -1; let sepLen = 0; const crlfcrlf = Buffer.from('\r\n\r\n'); const lflf = Buffer.from('\n\n'); idx = responseData.indexOf(crlfcrlf); if (idx !== -1) { sepLen = 4; } else { idx = responseData.indexOf(lflf); if (idx !== -1) sepLen = 2; } let headerSection, bodyBuf; if (idx !== -1) { headerSection = responseData.slice(0, idx).toString('utf8'); bodyBuf = responseData.slice(idx + sepLen); } else { headerSection = responseData.toString('utf8'); bodyBuf = Buffer.alloc(0); } const lines = headerSection.split(/\r?\n/); const statusLine = lines[0].replace('\r', ''); this.debugLog(`Content Status: ${statusLine}`); // Parse headers const headers = {}; for (let i = 1; i < lines.length; i++) { const line = lines[i].replace('\r', ''); const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).toLowerCase(); const value = line.substring(colonIndex + 1).trim(); headers[key] = value; } } // Decompress the body if needed bodyBuf = this.decompressBody(bodyBuf, headers); if (this.currentSocket) { this.currentSocket.destroy(); this.currentSocket = null; } resolve({ headers, body: bodyBuf, status: statusLine }); } catch (error) { console.error('Error processing content response:', error); reject(error); } } /** * Save content to file */ async saveToFile(content) { const fs = require('fs').promises; try { await fs.writeFile(this.outputFile, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')); } catch (error) { console.error('Error saving to file:', error); throw error; } } /** * Clean up resources */ cleanup() { if (this.currentSocket) { this.currentSocket.destroy(); } } } /** * Parse command line arguments */ function parseArgs() { const args = process.argv.slice(2); const config = { host: '127.0.0.1', port: 1615, ssid: '8100000000000001', url: 'wtv-home:/home', outputFile: null, maxRedirects: 10, useEncryption: false, request_type_download: false, debug: false }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--host': if (i + 1 < args.length) { config.host = args[++i]; } break; case '--port': if (i + 1 < args.length) { config.port = parseInt(args[++i]); } break; case '--ssid': if (i + 1 < args.length) { config.ssid = args[++i]; } break; case '--max-redirects': if (i + 1 < args.length) { config.maxRedirects = parseInt(args[++i]); } break; case '--url': if (i + 1 < args.length) { config.url = args[++i]; } break; case '--file': if (i + 1 < args.length) { config.outputFile = args[++i]; } break; case '--download': config.request_type_download = true; break; case '--encryption': config.useEncryption = true; break; case '--debug': config.debug = true; break; case '--help': console.log(` WebTV Client Simulator Usage: node client_emu.js [options] Options: --host Target server IP address (default: 127.0.0.1) --port Target server port (default: 1615) --ssid WebTV client SSID (default: 8100000000000001) --url Target URL to fetch after authentication (default: wtv-home:/home) --file Save response body to file instead of echoing to CLI --max-redirects Maximum number of wtv-visit redirects (default: 10) --download Enable 'wtv-request-type: download' for diskmap testing) --encryption Enable RC4 encryption after authentication --debug Enable debug logging --help Show this help message Example: node client_emu.js --host 192.168.1.100 --port 1615 --ssid 8100000000000001 --url wtv-home:/home --file output.html `); process.exit(0); } } return config; } /** * Main execution */ async function main() { const config = parseArgs(); const simulator = new WebTVClientSimulator(config.host, config.port, config.ssid, config.url, config.outputFile, config.maxRedirects, config.useEncryption, config.request_type_download, config.debug); // Handle graceful shutdown process.on('SIGINT', () => { console.log('\nShutting down...'); simulator.cleanup(); process.exit(0); }); process.on('SIGTERM', () => { console.log('\nShutting down...'); simulator.cleanup(); process.exit(0); }); try { await simulator.start(); } catch (error) { console.error('Simulation failed:', error); simulator.cleanup(); process.exit(1); } } // Run the simulator if this file is executed directly if (require.main === module) { main(); }