#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const CryptoJS = require('crypto-js'); const pcap = require('pcap-parser'); const zlib = require('zlib'); // Import our WebTV classes const WTVSec = require('./includes/classes/WTVSec.js'); const WTVShared = require('./includes/classes/WTVShared.js')['WTVShared']; const LZPF = require('./includes/classes/LZPF.js'); /** * PCAP Packet Parser for WebTV/WTVP Protocol * * This tool analyzes pcap files containing WebTV traffic and decrypts * both client and server communications using the WTVSec encryption. * * Based on analysis of: * - client_emu.js - WebTV client simulation * - app.js - Main server request processing * - WTVSec.js - Encryption/decryption implementation */ class WebTVPcapAnalyzer { constructor(options = {}) { this.pcapFilePath = options.pcapFile || '../wtv.pcap'; this.outputFile = options.outputFile || null; this.debug = options.debug || false; this.verbose = options.verbose || false; this.portRange = options.portRange || null; // WebTV configuration (load from config if available) this.wtvshared = new WTVShared(); this.config = this.loadConfig(); // Track connections and their encryption state this.connections = new Map(); // key: "srcIP:srcPort->dstIP:dstPort" this.packets = []; // Track bidirectional flow state (correlate client/server halves) this.flows = new Map(); // key: canonical "ipA:portA<->ipB:portB", value: { initialKey, challenge, observedResp, expectedResp, handshakeComplete } this.connections = new Map(); // key: "ip:port->ip:port", value: { wtvsec, incarnation, handshakeVerified } // Track server initial keys by server IP this.serverInitialKeys = new Map(); // key: server IP, value: initial key // Output buffer this.output = []; this.debugLog('WebTV PCAP Analyzer initialized'); this.debugLog(`PCAP file: ${this.pcapFilePath}`); if (this.portRange) { if (this.portRange.min === this.portRange.max) { this.debugLog(`Port filter: ${this.portRange.min}`); } else { this.debugLog(`Port filter: ${this.portRange.min}-${this.portRange.max}`); } } if (this.outputFile) { this.debugLog(`Output file: ${this.outputFile}`); } } debugLog(...args) { if (this.debug) { console.log('[DEBUG]', ...args); } } verboseLog(...args) { if (this.verbose || this.debug) { console.log('[VERBOSE]', ...args); } } loadConfig() { try { // Try to load minisrv config for encryption keys const configPath = './includes/config.json'; if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); this.debugLog('Loaded minisrv config for encryption keys'); return config; } else { // Use WTVShared to get proper config try { const config = this.wtvshared.readMiniSrvConfig(true, false); this.debugLog('Loaded config via WTVShared'); return config; } catch (e) { this.debugLog('Failed to load config via WTVShared:', e.message); } // Default config with standard WebTV keys this.debugLog('Using default WebTV encryption configuration'); return { config: { keys: { // Default WebTV initial shared key (minisrv default) initial_shared_key: "bWluaXNydiE=" // Base64: "minisrv!" - default minisrv key }, debug_flags: { debug: this.debug } } }; } } catch (error) { this.debugLog('Error loading config, using defaults:', error.message); return { config: { keys: { initial_shared_key: "QUFBQUFBQUE=" }, debug_flags: { debug: this.debug } } }; } } /** * Parse PCAP file using pcap-parser library */ parsePcapFile() { return new Promise((resolve, reject) => { if (!fs.existsSync(this.pcapFilePath)) { reject(new Error(`PCAP file not found: ${this.pcapFilePath}`)); return; } this.debugLog(`Parsing PCAP file: ${this.pcapFilePath}`); const parser = pcap.parse(this.pcapFilePath); let packetCount = 0; parser.on('packet', (packet) => { try { const parsedPacket = this.parsePacket(packet.data, { timestamp: packet.header.timestampSeconds + (packet.header.timestampMicroseconds / 1000000), capturedLen: packet.header.capturedLength, originalLen: packet.header.originalLength, packetNumber: ++packetCount }); if (parsedPacket) { // Check for truncated packets if (packet.header.capturedLength < packet.header.originalLength) { this.debugLog(`Truncated packet #${packetCount}: captured ${packet.header.capturedLength}, original ${packet.header.originalLength} bytes`); } this.packets.push(parsedPacket); } } catch (error) { this.debugLog(`Error parsing packet #${packetCount}:`, error.message); } }); parser.on('end', () => { this.debugLog(`Parsed ${this.packets.length} packets from PCAP file`); // Skip duplicate removal for now to ensure TCP reassembly works properly this.debugLog(`Keeping all ${this.packets.length} packets for proper TCP stream reassembly`); resolve(this.packets); }); parser.on('error', (error) => { reject(error); }); }); } /** * Remove only exact duplicate packets (same timestamp and data) */ removeSmartDuplicatePackets() { const uniquePackets = []; const duplicateCount = { removed: 0 }; const seen = new Set(); this.debugLog(`Starting conservative duplicate removal on ${this.packets.length} packets...`); for (const packet of this.packets) { // Create a key based on connection, timestamp, and data hash const connectionKey = `${packet.srcIP}:${packet.srcPort}->${packet.dstIP}:${packet.dstPort}`; const dataHash = packet.data ? require('crypto').createHash('md5').update(packet.data).digest('hex') : 'empty'; const packetKey = `${connectionKey}:${packet.timestamp}:${dataHash}`; if (!seen.has(packetKey)) { seen.add(packetKey); uniquePackets.push(packet); } else { duplicateCount.removed++; this.debugLog(`Exact duplicate removed: ${connectionKey} at ${packet.timestamp}`); } } this.packets = uniquePackets; this.debugLog(`Conservative duplicate removal complete: ${duplicateCount.removed} exact duplicates removed`); } /** * Check if two packets are similar enough to be considered duplicates (less strict than before) */ arePacketsSimilar(packet1, packet2) { // Same connection endpoints if (packet1.srcIP !== packet2.srcIP || packet1.srcPort !== packet2.srcPort || packet1.dstIP !== packet2.dstIP || packet1.dstPort !== packet2.dstPort) { return false; } // If data is identical, they're similar regardless of timestamp if (this.arePacketDataSame(packet1.data, packet2.data)) { const timeDiff = Math.abs(packet1.timestamp - packet2.timestamp); return timeDiff < 2.0; // Allow up to 2 seconds difference for retransmissions } return false; } /** * Check if a packet contains important WebTV headers */ packetHasImportantHeaders(packet, importantHeaders) { if (!packet.data) return false; const dataStr = packet.data.toString('utf8', 0, Math.min(packet.data.length, 2048)); // Check first 2KB return importantHeaders.some(header => dataStr.includes(header)); } removeDuplicatePackets() { const uniquePackets = []; const duplicateCount = { total: 0, exact: 0, similar: 0 }; this.debugLog(`Starting comprehensive duplicate removal on ${this.packets.length} packets...`); for (let i = 0; i < this.packets.length; i++) { const currentPacket = this.packets[i]; let isDuplicate = false; // Compare with all previously kept packets for (let j = 0; j < uniquePackets.length; j++) { const existingPacket = uniquePackets[j]; // Check if packets are duplicates using multiple criteria if (this.arePacketsDuplicate(currentPacket, existingPacket)) { isDuplicate = true; duplicateCount.total++; // Determine type of duplicate for debugging if (this.arePacketsExactDuplicate(currentPacket, existingPacket)) { duplicateCount.exact++; this.debugLog(`Exact duplicate: ${currentPacket.srcIP}:${currentPacket.srcPort}->${currentPacket.dstIP}:${currentPacket.dstPort} at ${currentPacket.timestamp}`); } else { duplicateCount.similar++; this.debugLog(`Similar duplicate: ${currentPacket.srcIP}:${currentPacket.srcPort}->${currentPacket.dstIP}:${currentPacket.dstPort} at ${currentPacket.timestamp} vs ${existingPacket.timestamp}`); } break; } } if (!isDuplicate) { uniquePackets.push(currentPacket); } } this.packets = uniquePackets; this.debugLog(`Duplicate removal complete: ${duplicateCount.total} total duplicates (${duplicateCount.exact} exact, ${duplicateCount.similar} similar)`); } /** * Check if two packets are duplicates using multiple criteria */ arePacketsDuplicate(packet1, packet2) { // Same connection endpoints if (packet1.srcIP !== packet2.srcIP || packet1.srcPort !== packet2.srcPort || packet1.dstIP !== packet2.dstIP || packet2.dstPort !== packet2.dstPort) { return false; } // Exact timestamp match if (packet1.timestamp === packet2.timestamp) { return this.arePacketDataSame(packet1.data, packet2.data); } // Close timestamps (within 0.001 seconds) with identical data const timeDiff = Math.abs(packet1.timestamp - packet2.timestamp); if (timeDiff < 0.001) { return this.arePacketDataSame(packet1.data, packet2.data); } // Identical data regardless of timestamp (retransmissions) if (this.arePacketDataSame(packet1.data, packet2.data)) { // Allow some time difference for retransmissions, but not too much return timeDiff < 0; // Within 0.5 seconds } return false; } /** * Check if two packets are exact duplicates */ arePacketsExactDuplicate(packet1, packet2) { return packet1.timestamp === packet2.timestamp && packet1.srcIP === packet2.srcIP && packet1.srcPort === packet2.srcPort && packet1.dstIP === packet2.dstIP && packet1.dstPort === packet2.dstPort && this.arePacketDataSame(packet1.data, packet2.data); } /** * Compare packet data for equality */ arePacketDataSame(data1, data2) { if (!data1 && !data2) return true; if (!data1 || !data2) return false; if (data1.length !== data2.length) return false; // Use Buffer.compare for efficient comparison return Buffer.compare(data1, data2) === 0; } /** * Parse individual network packet */ parsePacket(data, meta) { try { // Parse Ethernet header (14 bytes) if (data.length < 14) return null; const etherType = data.readUInt16BE(12); // Only process IPv4 packets (0x0800) if (etherType !== 0x0800) return null; let offset = 14; // Skip Ethernet header // Parse IP header if (data.length < offset + 20) return null; const ipVersion = (data[offset] & 0xF0) >> 4; const ipHeaderLen = (data[offset] & 0x0F) * 4; const protocol = data[offset + 9]; const srcIP = this.parseIP(data.slice(offset + 12, offset + 16)); const dstIP = this.parseIP(data.slice(offset + 16, offset + 20)); offset += ipHeaderLen; // Only process TCP packets (protocol 6) if (protocol !== 6) return null; // Parse TCP header if (data.length < offset + 20) return null; const srcPort = data.readUInt16BE(offset); const dstPort = data.readUInt16BE(offset + 2); const tcpSeq = data.readUInt32BE(offset + 4); const tcpAck = data.readUInt32BE(offset + 8); const tcpHeaderLen = ((data[offset + 12] & 0xF0) >> 4) * 4; const tcpFlags = data[offset + 13]; offset += tcpHeaderLen; // Extract payload const payload = data.slice(offset); const packet = { ...meta, srcIP, dstIP, srcPort, dstPort, tcpSeq, tcpAck, tcpFlags, payload, payloadLength: payload.length, connectionKey: `${srcIP}:${srcPort}->${dstIP}:${dstPort}` }; // Check if this looks like WebTV traffic if (this.isWebTVTraffic(packet)) { this.verboseLog(`WebTV packet #${packet.packetNumber}: ${packet.connectionKey} (${packet.payloadLength} bytes)`); return packet; } return null; } catch (error) { this.debugLog(`Error parsing packet:`, error.message); return null; } } parseIP(buffer) { return `${buffer[0]}.${buffer[1]}.${buffer[2]}.${buffer[3]}`; } /** * Determine if packet contains WebTV/WTVP traffic */ isWebTVTraffic(packet) { if (packet.payloadLength === 0) return false; // If port range is specified, filter by that first if (this.portRange) { const srcInRange = packet.srcPort >= this.portRange.min && packet.srcPort <= this.portRange.max; const dstInRange = packet.dstPort >= this.portRange.min && packet.dstPort <= this.portRange.max; if (!srcInRange && !dstInRange) { return false; // Neither port is in the specified range } } else { // Check for common WebTV ports (these may vary) const webtvPorts = [1515, 1501, 1601, 1615]; if (!webtvPorts.includes(packet.srcPort) && !webtvPorts.includes(packet.dstPort)) { // If no specific ports match, check payload for WebTV signatures const payloadStr = packet.payload.toString('ascii', 0, Math.min(200, packet.payload.length)); // Look for WTVP protocol markers const webtvSignatures = [ 'wtv-', 'GET wtv-', 'POST wtv-', 'SECURE ON', 'wtv-client-serial-number', 'wtv-incarnation', 'wtv-challenge', 'WebTV', 'Mozilla/4.0 WebTV' ]; return webtvSignatures.some(sig => payloadStr.includes(sig)); } } return true; } /** * Process all packets and extract WebTV communications */ analyzeTraffic() { this.output.push('='.repeat(80)); this.output.push('WebTV PCAP Analysis Report'); this.output.push(`Generated: ${new Date().toISOString()}`); this.output.push(`PCAP File: ${this.pcapFilePath}`); this.output.push('='.repeat(80)); this.output.push(''); // Sort packets by timestamp first, then by WebTV protocol flow for identical timestamps this.packets.sort((a, b) => { // Primary sort: timestamp const timeDiff = a.timestamp - b.timestamp; if (timeDiff !== 0) { return timeDiff; } // Secondary sort: WebTV protocol flow sequence (for identical timestamps) const aFlowOrder = this.getWebTVFlowOrder(a); const bFlowOrder = this.getWebTVFlowOrder(b); if (aFlowOrder !== bFlowOrder) { return aFlowOrder - bFlowOrder; // Lower order = earlier in flow } // Tertiary sort: WebTV port priority (for identical timestamps) const aPriority = this.getWebTVPortPriority(a.srcPort, a.dstPort); const bPriority = this.getWebTVPortPriority(b.srcPort, b.dstPort); if (aPriority !== bPriority) { return aPriority - bPriority; // Lower priority number = higher precedence } // Quaternary sort: incarnation number (lower incarnation = earlier) const aIncarnation = this.getPacketIncarnation(a); const bIncarnation = this.getPacketIncarnation(b); if (aIncarnation !== bIncarnation) { return aIncarnation - bIncarnation; } // Quinary sort: connection direction (server→client FIRST to ensure challenges come before responses) const aIsServerToClient = this.isServerToClient(`${a.srcIP}:${a.srcPort}->${a.dstIP}:${a.dstPort}`); const bIsServerToClient = this.isServerToClient(`${b.srcIP}:${b.srcPort}->${b.dstIP}:${b.dstPort}`); // Server→Client packets should come BEFORE Client→Server packets if (aIsServerToClient && !bIsServerToClient) return -1; if (!aIsServerToClient && bIsServerToClient) return 1; // Senary sort: sequence number if available (for TCP ordering within same direction) if (a.seq !== undefined && b.seq !== undefined) { return a.seq - b.seq; } return 0; }); this.debugLog(`Sorted ${this.packets.length} packets by timestamp, WebTV protocol flow, port priority, incarnation, and direction`); // Group packets by connection const connectionGroups = new Map(); for (const packet of this.packets) { const key = packet.connectionKey; if (!connectionGroups.has(key)) { connectionGroups.set(key, []); } connectionGroups.get(key).push(packet); } // Create bidirectional flows by merging client→server and server→client streams const bidirectionalFlows = this.createBidirectionalFlows(connectionGroups); // Sort flows by chronological order (timestamp of first packet) const sortedFlows = Array.from(bidirectionalFlows.entries()).sort((a, b) => { const [keyA, flowA] = a; const [keyB, flowB] = b; // Find the earliest timestamp across both directions const allPacketsA = [...flowA.clientToServer, ...flowA.serverToClient]; const allPacketsB = [...flowB.clientToServer, ...flowB.serverToClient]; const earliestA = Math.min(...allPacketsA.map(p => p.timestamp)); const earliestB = Math.min(...allPacketsB.map(p => p.timestamp)); return earliestA - earliestB; }); // Convert back to Map with sorted order const sortedBidirectionalFlows = new Map(sortedFlows); this.output.push(`Found ${sortedBidirectionalFlows.size} WebTV bidirectional flows (sorted chronologically):`); // Show flow summary for (const [flowKey, flow] of sortedBidirectionalFlows) { const totalPackets = flow.clientToServer.length + flow.serverToClient.length; const totalBytes = [...flow.clientToServer, ...flow.serverToClient].reduce((sum, p) => sum + p.payloadLength, 0); const allPackets = [...flow.clientToServer, ...flow.serverToClient]; const firstTimestamp = Math.min(...allPackets.map(p => p.timestamp)); const timeStr = new Date(firstTimestamp * 1000).toISOString(); this.output.push(` ${flowKey}: ${totalPackets} packets (${flow.clientToServer.length}→${flow.serverToClient.length}), ${totalBytes} bytes (started: ${timeStr})`); } this.output.push(''); // Process packets chronologically across all connections for proper challenge/response correlation this.output.push('Processing packets in chronological order for challenge/response correlation:'); this.output.push(''); let messageCount = 0; for (const packet of this.packets) { if (packet.payloadLength === 0) continue; // Get or create connection state const connectionKey = packet.connectionKey; if (!this.connections.has(connectionKey)) { const connection = { key: connectionKey, wtvsec: null, encryptionEnabled: false, incarnation: 1, challenge: null, initialKey: null, expectedChallengeResponseB64: null, observedChallengeResponseB64: null, challengeMatch: null, serverToClient: this.isServerToClient(connectionKey), dataBuffer: Buffer.alloc(0), streamData: Buffer.alloc(0), secureOnSeen: false, streamBuffer: Buffer.alloc(0) // Add stream buffer for TCP reassembly }; // WTVSec instance will be created when we process the incarnation header const isClientToServer = this.isConnectionToWebTVServer(connectionKey); if (isClientToServer) { this.debugLog(`Will create WTVSec instance when incarnation is detected for: ${connectionKey}`); } else { this.debugLog(`Skipping WTVSec creation for server->client connection: ${connectionKey}`); } this.connections.set(connectionKey, connection); } const connection = this.connections.get(connectionKey); // Proper TCP stream reassembly with buffering connection.streamBuffer = Buffer.concat([connection.streamBuffer, packet.payload]); // Try to extract complete messages const extractedMessages = this.extractMessages(connection.streamBuffer); if (extractedMessages.messages.length > 0) { for (const message of extractedMessages.messages) { const messageStr = message.toString('utf8'); this.verboseLog(`Processing reassembled message from ${connectionKey} (${message.length} bytes)`); // Parse headers and extract WebTV info from complete message const lines = messageStr.split('\r\n'); const headerEndIndex = lines.findIndex(line => line === ''); const headers = headerEndIndex >= 0 ? lines.slice(0, headerEndIndex) : lines; // Process headers for handshake info const wtvInfo = this.extractWebTVInfo(messageStr, connection); if (wtvInfo) { this.processWebTVHandshakeInfo(wtvInfo, connection, messageStr); } // Now process the complete message for decryption this.processStreamData(message, connection, ++messageCount); } connection.streamBuffer = extractedMessages.remainder; } } // Process each flow for detailed analysis let flowNumber = 0; for (const [flowKey, flow] of sortedBidirectionalFlows) { this.output.push(`\n${'='.repeat(80)}`); this.output.push(`BIDIRECTIONAL FLOW ${++flowNumber} of ${sortedBidirectionalFlows.size}`); this.output.push(`${'='.repeat(80)}`); this.analyzeBidirectionalFlow(flowKey, flow); } // Add summary this.addFlowAnalysisSummary(sortedBidirectionalFlows); return this.output.join('\n'); } /** * Create bidirectional flows by merging client→server and server→client streams */ createBidirectionalFlows(connectionGroups) { const flows = new Map(); for (const [connectionKey, packets] of connectionGroups) { // Parse connection key to get endpoints const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (!match) continue; const [, srcIP, srcPort, dstIP, dstPort] = match; // Create canonical flow key (always sort endpoints alphabetically for consistency) const endpoint1 = `${srcIP}:${srcPort}`; const endpoint2 = `${dstIP}:${dstPort}`; const flowKey = endpoint1 < endpoint2 ? `${endpoint1}<->${endpoint2}` : `${endpoint2}<->${endpoint1}`; // Initialize flow if not exists if (!flows.has(flowKey)) { flows.set(flowKey, { clientToServer: [], serverToClient: [], endpoints: { endpoint1, endpoint2 } }); } const flow = flows.get(flowKey); // Determine direction based on port numbers (WebTV server ports are in our range) const isClientToServer = this.isConnectionToWebTVServer(connectionKey); if (isClientToServer) { flow.clientToServer.push(...packets); } else { flow.serverToClient.push(...packets); } } return flows; } /** * Analyze a bidirectional flow (both client→server and server→client) */ analyzeBidirectionalFlow(flowKey, flow) { this.output.push('-'.repeat(60)); this.output.push(`Bidirectional Flow: ${flowKey}`); this.output.push(`Client→Server packets: ${flow.clientToServer.length}`); this.output.push(`Server→Client packets: ${flow.serverToClient.length}`); this.output.push('-'.repeat(60)); // Process client→server stream if (flow.clientToServer.length > 0) { this.output.push(`\nClient→Server Stream:`); // Find the existing connection for client→server direction const clientToServerKey = flow.clientToServer[0].connectionKey; let clientConnection = this.connections.get(clientToServerKey); if (!clientConnection) { // Create new connection if it doesn't exist (shouldn't happen) clientConnection = { key: clientToServerKey, wtvsec: null, encryptionEnabled: false, incarnation: 1, challenge: null, initialKey: null, expectedChallengeResponseB64: null, observedChallengeResponseB64: null, challengeMatch: null, serverToClient: this.isServerToClient(clientToServerKey), dataBuffer: Buffer.alloc(0), streamData: Buffer.alloc(0), secureOnSeen: false }; this.connections.set(clientToServerKey, clientConnection); } this.reconstructTCPStream(flow.clientToServer, clientConnection); } // Process server→client stream if (flow.serverToClient.length > 0) { this.output.push(`\nServer→Client Stream:`); // Find the existing connection for server→client direction const serverToClientKey = flow.serverToClient[0].connectionKey; let serverConnection = this.connections.get(serverToClientKey); if (!serverConnection) { // Create new connection if it doesn't exist serverConnection = { key: serverToClientKey, wtvsec: null, encryptionEnabled: false, incarnation: 1, challenge: null, initialKey: null, expectedChallengeResponseB64: null, observedChallengeResponseB64: null, challengeMatch: null, serverToClient: this.isServerToClient(serverToClientKey), dataBuffer: Buffer.alloc(0), streamData: Buffer.alloc(0), secureOnSeen: false }; this.connections.set(serverToClientKey, serverConnection); } // Server→client connections should use the WTVSec instance from their corresponding client→server connection if (!serverConnection.wtvsec && flow.clientToServer.length > 0) { const clientToServerKey = flow.clientToServer[0].connectionKey; const clientConnection = this.connections.get(clientToServerKey); if (clientConnection && clientConnection.wtvsec) { serverConnection.wtvsec = clientConnection.wtvsec; serverConnection.encryptionEnabled = clientConnection.encryptionEnabled; serverConnection.incarnation = clientConnection.incarnation; this.debugLog(`Shared WTVSec instance from ${clientToServerKey} to ${serverToClientKey}`); } } this.reconstructTCPStream(flow.serverToClient, serverConnection); } this.output.push(''); } /** * Add bidirectional flow analysis summary */ addFlowAnalysisSummary(flows) { this.output.push('\n' + '='.repeat(80)); this.output.push('FLOW ANALYSIS SUMMARY'); this.output.push('='.repeat(80)); const totalFlows = flows.size; let encryptedFlows = 0; let challengesSeen = 0; let secureOnSeen = 0; for (const connection of this.connections.values()) { if (connection.encryptionEnabled) encryptedFlows++; if (connection.challenge) challengesSeen++; if (connection.secureOnSeen) secureOnSeen++; } this.output.push(`Total WebTV bidirectional flows: ${totalFlows}`); this.output.push(`Flows with encryption: ${encryptedFlows}`); this.output.push(`wtv-challenge headers seen: ${challengesSeen}`); this.output.push(`SECURE ON commands seen: ${secureOnSeen}`); this.output.push(''); } /** * Add analysis summary */ addAnalysisSummary(connectionGroups) { this.output.push('\n' + '='.repeat(80)); this.output.push('ANALYSIS SUMMARY'); this.output.push('='.repeat(80)); const totalConnections = connectionGroups.size; let encryptedConnections = 0; let challengesSeen = 0; let secureOnSeen = 0; for (const connection of this.connections.values()) { if (connection.encryptionEnabled) encryptedConnections++; if (connection.challenge) challengesSeen++; if (connection.secureOnSeen) secureOnSeen++; } this.output.push(`Total WebTV connections: ${totalConnections}`); this.output.push(`Connections with encryption: ${encryptedConnections}`); this.output.push(`wtv-challenge headers seen: ${challengesSeen}`); this.output.push(`SECURE ON commands seen: ${secureOnSeen}`); this.output.push(''); if (encryptedConnections === 0) { this.output.push('NOTE: No encrypted connections detected. This may be:'); this.output.push(' - A capture of unencrypted WebTV traffic'); this.output.push(' - Missing the encryption handshake packets'); this.output.push(' - Not actually WebTV/WTVP protocol traffic'); } else if (challengesSeen === 0) { this.output.push('WARNING: Encrypted connections found but no challenges detected.'); this.output.push('Decryption may not work without the proper challenge/response handshake.'); } this.output.push(''); this.output.push('For questions about this analysis, refer to:'); this.output.push(' - client_emu.js: WebTV client implementation'); this.output.push(' - app.js: Server-side request processing'); this.output.push(' - WTVSec.js: Encryption/decryption implementation'); } /** * Analyze a single TCP connection for WebTV traffic */ analyzeConnection(connectionKey, packets) { this.output.push('-'.repeat(60)); this.output.push(`Connection: ${connectionKey}`); this.output.push(`Packets: ${packets.length}`); this.output.push('-'.repeat(60)); // Sort packets by sequence number for proper ordering packets.sort((a, b) => a.tcpSeq - b.tcpSeq); // Initialize connection state const connection = { key: connectionKey, wtvsec: null, encryptionEnabled: false, incarnation: 1, challenge: null, initialKey: null, expectedChallengeResponseB64: null, observedChallengeResponseB64: null, challengeMatch: null, serverToClient: this.isServerToClient(connectionKey), dataBuffer: Buffer.alloc(0), streamData: Buffer.alloc(0), secureOnSeen: false }; this.connections.set(connectionKey, connection); // Reconstruct TCP streams this.reconstructTCPStream(packets, connection); this.output.push(''); } /** * Check if a connection is from client to WebTV server (dest port in range) */ isConnectionToWebTVServer(connectionKey) { // Extract IPs and ports from connection key format "srcIP:srcPort->dstIP:dstPort" const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (match) { const srcPort = parseInt(match[2]); const dstPort = parseInt(match[4]); // Check if destination port is in our WebTV port range AND source port is NOT in range // This ensures we only create WTVSec for actual client->server connections if (this.portRange) { const dstInRange = dstPort >= this.portRange.min && dstPort <= this.portRange.max; const srcInRange = srcPort >= this.portRange.min && srcPort <= this.portRange.max; // True only if destination is WebTV server port and source is NOT a WebTV server port return dstInRange && !srcInRange; } } return false; } /** * Check if this is a connection TO a WebTV server (client->server) */ isConnectionToWebTVServer(connectionKey) { // Extract IPs and ports from connection key format "srcIP:srcPort->dstIP:dstPort" const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (match) { const dstPort = parseInt(match[4]); // Check if destination port is in our WebTV port range if (this.portRange) { return dstPort >= this.portRange.min && dstPort <= this.portRange.max; } } return false; } /** * Get WebTV protocol flow order for packets based on URL sequence */ getWebTVFlowOrder(packet) { try { if (!packet.data || packet.data.length === 0) { return 1000; // Default order for packets without data } const dataStr = packet.data.toString('utf8'); // Look for URLs in various headers const getMatch = dataStr.match(/GET\s+([^\s]+)/); const refererMatch = dataStr.match(/referer:\s*([^\r\n]+)/i); const wtvVisitMatch = dataStr.match(/wtv-visit:\s*([^\r\n]+)/i); const locationMatch = dataStr.match(/location:\s*([^\r\n]+)/i); let url = null; let referer = null; if (getMatch) url = getMatch[1]; if (refererMatch) referer = refererMatch[1]; // Assign flow order based on WebTV protocol sequence if (url) { // Client requests - ordered by protocol flow if (url.includes('wtv-1800:/preregister')) return 10; if (url.includes('wtv-1800:/finish-prereg')) return 20; if (url.includes('wtv-head-waiter:/login')) return 30; if (url.includes('wtv-head-waiter:/login-stage-two')) return 40; if (url.includes('wtv-head-waiter:/check-tellyscript')) return 50; if (url.includes('wtv-home:/home')) return 60; if (url.includes('wtv-setup:')) return 70; if (url.includes('wtv-mail:')) return 80; if (url.includes('wtv-disk:')) return 90; if (url.includes('wtv-news:')) return 100; if (url.includes('wtv-guide:')) return 110; if (url.includes('wtv-tricks:')) return 120; // If referer exists, try to sequence based on that if (referer) { if (referer.includes('wtv-1800:/preregister')) return 25; if (referer.includes('wtv-head-waiter:/login')) return 35; if (referer.includes('wtv-head-waiter:/login-stage-two')) return 45; if (referer.includes('wtv-home:/home')) return 65; } } // Server responses - should come after corresponding requests if (wtvVisitMatch || locationMatch) { const visitUrl = wtvVisitMatch ? wtvVisitMatch[1] : locationMatch[1]; if (visitUrl.includes('wtv-head-waiter:/login')) return 31; if (visitUrl.includes('wtv-head-waiter:/login-stage-two')) return 41; if (visitUrl.includes('wtv-home:/home')) return 61; return 500; // Other server responses } // Check for response status codes if (dataStr.includes('200 OK') || dataStr.includes('HTTP/')) { return 400; // Server responses without specific URLs } // Check for SECURE ON (encrypted client requests) if (dataStr.includes('SECURE ON')) { return 200; // Encrypted requests come after initial handshake } return 1000; // Default order for unrecognized packets } catch (error) { return 1000; // Default on error } } /** * Get incarnation number from packet headers for sorting */ getPacketIncarnation(packet) { // Parse the packet data to look for wtv-incarnation header try { if (packet.data && packet.data.length > 0) { const dataStr = packet.data.toString('utf8'); const incarnationMatch = dataStr.match(/wtv-incarnation:\s*(\d+)/i); if (incarnationMatch) { return parseInt(incarnationMatch[1]); } } return 0; // Default incarnation if not found } catch (error) { return 0; } } /** * Get WebTV port priority for sorting packets with identical timestamps */ getWebTVPortPriority(srcPort, dstPort) { // Determine which port is the WebTV service port let servicePort = null; // Check if either port matches our filter range if (this.portFilter) { const [minPort, maxPort] = this.portFilter.split('-').map(Number); if (srcPort >= minPort && srcPort <= maxPort) { servicePort = srcPort; } else if (dstPort >= minPort && dstPort <= maxPort) { servicePort = dstPort; } } if (!servicePort) { return 999; // No priority if not a WebTV service port } // Get the last two digits to determine priority const lastTwoDigits = servicePort % 100; // Priority based on last two digits: // 15 (1515, 1615, etc.) = Priority 1 (highest) // 01 (1501, 1601, etc.) = Priority 2 // 12 (1512, 1612, etc.) = Priority 3 // Others = Priority 4+ (lower) switch (lastTwoDigits) { case 15: return 1; case 1: return 2; case 12: return 3; default: return 4; } } /** * Determine connection direction based on port numbers */ isServerToClient(connectionKey) { // Extract IPs and ports from connection key format "srcIP:srcPort->dstIP:dstPort" const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (match) { const srcPort = parseInt(match[2]); const dstPort = parseInt(match[4]); // Simple logic: if destination port is in our port range, it's client->server // If source port is in our port range, it's server->client const isPortInRange = (port) => { if (!this.portRange) return false; return port >= this.portRange.min && port <= this.portRange.max; }; if (isPortInRange(dstPort)) { return false; // CLIENT → SERVER (destination is server port) } else if (isPortInRange(srcPort)) { return true; // SERVER → CLIENT (source is server port) } // Fallback to original logic if ports aren't in our target range const srcIsServer = srcPort < 32768; const dstIsClient = dstPort >= 32768; return srcIsServer || dstIsClient; } return false; } /** * Reconstruct TCP stream from packets */ reconstructTCPStream(packets, connection) { let streamBuffer = Buffer.alloc(0); let expectedSeq = null; let messageCount = 0; this.debugLog(`Reconstructing TCP stream for ${connection.key} with ${packets.length} packets`); for (const packet of packets) { if (packet.payloadLength === 0) continue; this.debugLog(`Processing packet seq=${packet.tcpSeq}, len=${packet.payloadLength}, expected=${expectedSeq}`); // Initialize expected sequence number if (expectedSeq === null) { expectedSeq = packet.tcpSeq + packet.payloadLength; } // Check for out-of-order packets if (packet.tcpSeq !== expectedSeq && streamBuffer.length > 0) { this.debugLog(`Out-of-order packet detected, processing accumulated data first`); // Process accumulated data before handling out-of-order packet this.processStreamData(streamBuffer, connection, ++messageCount); streamBuffer = Buffer.alloc(0); } // Accumulate packet data streamBuffer = Buffer.concat([streamBuffer, packet.payload]); expectedSeq = packet.tcpSeq + packet.payloadLength; this.debugLog(`Stream buffer now ${streamBuffer.length} bytes after adding packet`); // Try to extract complete messages const extractedMessages = this.extractMessages(streamBuffer); this.debugLog(`Extracted ${extractedMessages.messages.length} messages, remainder: ${extractedMessages.remainder.length} bytes`); if (extractedMessages.messages.length > 0) { for (const message of extractedMessages.messages) { this.processStreamData(message, connection, ++messageCount); } streamBuffer = extractedMessages.remainder; } } // Process any remaining data if (streamBuffer.length > 0) { this.debugLog(`Processing remaining buffer of ${streamBuffer.length} bytes`); this.processStreamData(streamBuffer, connection, ++messageCount); } } /** * Extract complete WebTV messages from stream buffer */ extractMessages(buffer) { const messages = []; let offset = 0; while (offset < buffer.length) { // Look for HTTP-style headers ending with \r\n\r\n or \n\n const crlfcrlf = buffer.indexOf('\r\n\r\n', offset); const lflf = buffer.indexOf('\n\n', offset); let headerEnd = -1; let headerSep = 0; if (crlfcrlf !== -1 && (lflf === -1 || crlfcrlf < lflf)) { headerEnd = crlfcrlf; headerSep = 4; } else if (lflf !== -1) { headerEnd = lflf; headerSep = 2; } if (headerEnd === -1) { // No complete headers found break; } const headerData = buffer.slice(offset, headerEnd); const headerText = headerData.toString('utf8'); // Parse Content-Length if present const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); let contentLength = 0; if (contentLengthMatch) { contentLength = parseInt(contentLengthMatch[1]); } const messageStart = offset; const bodyStart = headerEnd + headerSep; // Special handling: when wtv-lzpf: 0 is present, Content-Length reflects the // uncompressed size, not the on-the-wire compressed size. In that case, do not // trust Content-Length to find message boundaries. Instead, heuristically find // the start of the next header block and use that as the end of this message. const isLZPF = /wtv-lzpf:\s*0/i.test(headerText); if (isLZPF) { const nextHeaderIdx = this.findNextHeaderStart(buffer, bodyStart); if (nextHeaderIdx !== -1) { const message = buffer.slice(messageStart, nextHeaderIdx); messages.push(message); offset = nextHeaderIdx; } else { // No next header found; consume the rest of the buffer as a message const message = buffer.slice(messageStart); messages.push(message); offset = buffer.length; break; } continue; } const messageEnd = bodyStart + contentLength; // Check if we have complete message if (messageEnd <= buffer.length) { const message = buffer.slice(messageStart, messageEnd); messages.push(message); offset = messageEnd; } else { // Incomplete message break; } } return { messages, remainder: buffer.slice(offset) }; } /** * Heuristically find the start index of the next plaintext header block * following an (encrypted) body. This scans for common request/response * line starters like "200 OK", "GET ", "POST ", or "SECURE ON" after * the given start index. Returns the absolute index in the buffer of the * beginning of the next header line, or -1 if not found. */ findNextHeaderStart(buffer, startIndex) { // Candidate patterns (as Buffers) we expect at the start of a header line const patterns = [ Buffer.from('\r\n200 OK', 'utf8'), Buffer.from('\n200 OK', 'utf8'), Buffer.from('200 OK', 'utf8'), Buffer.from('\r\nGET ', 'utf8'), Buffer.from('\nGET ', 'utf8'), Buffer.from('GET ', 'utf8'), Buffer.from('\r\nPOST ', 'utf8'), Buffer.from('\nPOST ', 'utf8'), Buffer.from('POST ', 'utf8'), Buffer.from('\r\nSECURE ON', 'utf8'), Buffer.from('\nSECURE ON', 'utf8'), Buffer.from('SECURE ON', 'utf8'), ]; let earliest = -1; for (const pat of patterns) { const idx = buffer.indexOf(pat, startIndex); if (idx !== -1) { if (earliest === -1 || idx < earliest) { // If pattern includes CR/LF at the start, back up to the next char after them if (pat[0] === 0x0d || pat[0] === 0x0a) { // Move to the first character after CRLF/LF let pos = idx; while (pos < buffer.length && (buffer[pos] === 0x0d || buffer[pos] === 0x0a)) pos++; earliest = pos; } else { earliest = idx; } } } } return earliest; } /** * Process WebTV handshake information from complete messages */ processWebTVHandshakeInfo(wtvInfo, connection, messageStr) { // Handle initial key if (wtvInfo.initialKey) { connection.initialKey = wtvInfo.initialKey; this.debugLog(`Set initialKey for ${connection.key}: ${wtvInfo.initialKey}`); } // Handle challenge if (wtvInfo.challenge) { connection.challenge = wtvInfo.challenge; this.debugLog(`Set challenge for ${connection.key}: ${wtvInfo.challenge}`); } // Handle incarnation and create WTVSec if needed if (wtvInfo.incarnation !== undefined) { connection.incarnation = wtvInfo.incarnation; this.debugLog(`Set incarnation for ${connection.key}: ${wtvInfo.incarnation}`); // Create WTVSec instance for client->server connections when incarnation is known const isClientToServer = this.isConnectionToWebTVServer(connection.key); if (isClientToServer && !connection.wtvsec && connection.initialKey) { this.debugLog(`Creating WTVSec instance for ${connection.key} with incarnation ${wtvInfo.incarnation}`); connection.wtvsec = new this.WTVSec(this.WTVSec.makeChallengeKeyFromString(connection.initialKey, wtvInfo.incarnation)); } } // Handle challenge response if (wtvInfo.challengeResponse) { connection.observedChallengeResponseB64 = wtvInfo.challengeResponse; this.debugLog(`Set challengeResponse for ${connection.key}: ${wtvInfo.challengeResponse}`); } // Verify handshake if we have all required components if (connection.challenge && connection.initialKey && connection.incarnation !== undefined && !connection.challengeMatch) { this.verifyHandshake(connection, wtvInfo); } } /** * Process reconstructed stream data */ processStreamData(data, connection, messageNumber) { const timestamp = new Date().toISOString(); // Could track actual timestamps const direction = connection.serverToClient ? 'SERVER → CLIENT' : 'CLIENT → SERVER'; this.output.push(`[${timestamp}] Message #${messageNumber} (${direction})`); this.output.push(` Length: ${data.length} bytes`); try { // Quick peek for SECURE ON without consuming full buffer const peek = data.toString('utf8', 0, Math.min(200, data.length)); if (peek.includes('SECURE ON')) { this.output.push(` [*] SECURE ON detected - encryption starting`); connection.secureOnSeen = true; connection.encryptionEnabled = true; this.ensureSecureOn(connection); } // Prefer buffer-based header/body split to avoid corrupting encrypted bytes const split = this.splitHeadersAndBody(data); if (split) { if (split.leadingBuffer && split.leadingBuffer.length > 0) { this.output.push(` Note: ${split.leadingBuffer.length} leading encrypted/unknown bytes before header`); } this.processWebTVMessageBuffer(split.headerText, split.bodyBuffer, connection, direction); } else if (connection.encryptionEnabled) { this.processEncryptedData(data, connection, direction); } else { // Binary or unknown data this.output.push(` Raw data: ${data.slice(0, 100).toString('hex')}${data.length > 100 ? '...' : ''}`); } } catch (error) { this.debugLog(`Error processing message #${messageNumber}:`, error.message); this.output.push(` Error processing message: ${error.message}`); } this.output.push(''); } /** * Process complete WebTV message (headers + body) */ processWebTVMessage(text, connection, direction) { this.output.push(` WebTV Message (${direction}):`); // Split headers and body const headerBodySplit = text.includes('\r\n\r\n') ? '\r\n\r\n' : '\n\n'; const parts = text.split(headerBodySplit, 2); const headerText = parts[0]; const bodyText = parts[1] || ''; const lines = headerText.split(/\r?\n/); const headers = {}; // Debug: Check if headerText contains incarnation data if (headerText.includes('wtv-incarnation')) { this.debugLog(`Raw header text contains incarnation: ${headerText.length} chars`); const incarnationIndex = headerText.indexOf('wtv-incarnation'); const contextStart = Math.max(0, incarnationIndex - 20); const contextEnd = Math.min(headerText.length, incarnationIndex + 50); const context = headerText.slice(contextStart, contextEnd); this.debugLog(`Context around incarnation: "${context}"`); this.debugLog(`Context bytes: ${Buffer.from(context, 'utf8').toString('hex')}`); } for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; if (i === 0) { // Request/response line this.output.push(` ${line}`); headers.requestLine = line; } else { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.slice(0, colonIndex).toLowerCase(); const value = line.slice(colonIndex + 1).trim(); // Debug incarnation header parsing if (key === 'wtv-incarnation') { this.debugLog(`Raw incarnation line: "${line}" (length: ${line.length})`); this.debugLog(`Key: "${key}", Value: "${value}" (length: ${value.length})`); // Show raw bytes around this line const lineBytes = Buffer.from(line, 'utf8'); this.debugLog(`Raw bytes: ${lineBytes.toString('hex')}`); // If we think this should be incarnation 10, let's see if we can find it in the raw data if (value === '') { this.debugLog(`Expected incarnation but got empty value - checking for truncation`); } } headers[key] = value; this.output.push(` ${key}: ${value}`); } } } // Extract WebTV-specific information this.extractWebTVInfo(headers, connection); // Display body if present if (bodyText.length > 0) { // Check if this is a proprietary format that should not be decrypted const bodyBuffer = Buffer.from(bodyText, 'binary'); const isProprietary = headers['wtv-initial-key'] && (bodyText.startsWith('ANDY') || bodyBuffer.subarray(0, 4).toString() === 'ANDY' || bodyBuffer.subarray(0, 4).equals(Buffer.from([0x41, 0x4e, 0x44, 0x59]))); if (isProprietary) { this.output.push(` Body (${bodyText.length} bytes): [PROPRIETARY FORMAT - NOT ENCRYPTED]`); // Show a preview of the proprietary data const previewText = bodyText.slice(0, 100).replace(/[\x00-\x1f\x7f-\xff]/g, '∩┐╜'); this.output.push(` ${previewText}${bodyText.length > 100 ? '...' : ''}`); } else if (headers['wtv-encrypted'] === 'true' && connection.wtvsec) { // Handle encrypted body - decrypt first, then decompress this.output.push(` Body (${bodyText.length} bytes):`); try { // WARNING: This path receives body as UTF-8 text and can corrupt bytes. // Prefer buffer-based processing via processWebTVMessageBuffer(). const bodyBuffer = Buffer.from(bodyText, 'latin1'); this.ensureSecureOn(connection); const keyNum = connection.serverToClient ? 1 : 0; this.debugLog(`Decrypting body with key ${keyNum} (${bodyBuffer.length} bytes)`); const decrypted = connection.wtvsec.Decrypt(keyNum, bodyBuffer); const decryptedBuffer = Buffer.from(decrypted); const decompressionResult = this.decompressBody(decryptedBuffer, headers); let finalText; let statusLabel = `[DECRYPTED BODY (key ${keyNum})`; if (decompressionResult.success) { finalText = decompressionResult.data.toString('utf8'); statusLabel += ` - ${decompressionResult.method.toUpperCase()} DECOMPRESSED`; } else { finalText = decryptedBuffer.toString('utf8'); if (decompressionResult.method === 'failed') { statusLabel += ` - DECOMPRESSION FAILED: ${decompressionResult.error}`; } } statusLabel += ']:'; this.output.push(` ${statusLabel}`); this.output.push(` ${finalText.length <= 500 ? finalText : finalText.slice(0, 500) + '...'}`); } catch (error) { this.debugLog(`Body decryption failed: ${error.message}`); this.output.push(` [ENCRYPTED - decryption failed: ${error.message}]`); this.output.push(` ${bodyText.slice(0, 500)}${bodyText.length > 500 ? '...' : ''}`); } } else { // Regular unencrypted body - try decompression const bodyBuffer = Buffer.from(bodyText, 'latin1'); const decompressionResult = this.decompressBody(bodyBuffer, headers); let finalText; let statusLabel = `Body (${bodyText.length} bytes)`; if (decompressionResult.success) { finalText = decompressionResult.data.toString('utf8'); statusLabel += ` - ${decompressionResult.method.toUpperCase()} DECOMPRESSED`; } else { finalText = bodyText; if (decompressionResult.method === 'failed') { statusLabel += ` - DECOMPRESSION FAILED: ${decompressionResult.error}`; } } this.output.push(` ${statusLabel}:`); if (finalText.length <= 500) { this.output.push(` ${finalText}`); } else { this.output.push(` ${finalText.slice(0, 500)}...`); } } } } /** * Split headers and body from a raw Buffer (CRLFCRLF or LFLF). Returns null if no split. */ splitHeadersAndBody(buffer) { if (!buffer || buffer.length === 0) return null; // Find likely header start anywhere in the buffer const headerStart = this.findNextHeaderStart(buffer, 0); if (headerStart === -1) return null; const leadingBuffer = headerStart > 0 ? buffer.slice(0, headerStart) : Buffer.alloc(0); const afterStart = buffer.slice(headerStart); // Now split headers/body from the start of header let idx = afterStart.indexOf('\r\n\r\n'); let sepLen = 0; if (idx !== -1) { sepLen = 4; } else { idx = afterStart.indexOf('\n\n'); if (idx !== -1) sepLen = 2; } if (idx === -1) { // Not a complete header block yet return null; } const headerText = afterStart.slice(0, idx).toString('utf8'); const bodyBuffer = afterStart.slice(idx + sepLen); return { headerText, bodyBuffer, leadingBuffer }; } /** * Process WebTV message using header text and original body bytes (preferred path) */ processWebTVMessageBuffer(headerText, bodyBuffer, connection, direction) { this.output.push(` WebTV Message (${direction}):`); const lines = headerText.split(/\r?\n/); const headers = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; if (i === 0) { this.output.push(` ${line}`); headers.requestLine = line; } else { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.slice(0, colonIndex).toLowerCase(); const value = line.slice(colonIndex + 1).trim(); headers[key] = value; this.output.push(` ${key}: ${value}`); } } } // Extract WebTV-specific information this.extractWebTVInfo(headers, connection); // Body handling if (bodyBuffer && bodyBuffer.length > 0) { const isProprietary = headers['wtv-initial-key'] && (bodyBuffer.slice(0, 4).toString() === 'ANDY' || bodyBuffer.slice(0, 4).equals(Buffer.from([0x41,0x4e,0x44,0x59]))); if (isProprietary) { this.output.push(` Body (${bodyBuffer.length} bytes): [PROPRIETARY FORMAT - NOT ENCRYPTED]`); const preview = bodyBuffer.slice(0, 100).toString('latin1').replace(/[\x00-\x1f\x7f-\xff]/g, '∩┐╜'); this.output.push(` ${preview}${bodyBuffer.length > 1000 ? '...' : ''}`); return; } if (headers['wtv-encrypted'] === 'true' && connection.wtvsec) { this.output.push(` Body (${bodyBuffer.length} bytes):`); try { this.ensureSecureOn(connection); const keyNum = direction.includes('SERVER') ? 1 : 0; this.debugLog(`Decrypting body with key ${keyNum} (${bodyBuffer.length} bytes)`); const decrypted = connection.wtvsec.Decrypt(keyNum, bodyBuffer); const decryptedBuffer = Buffer.from(decrypted); const decompressionResult = this.decompressBody(decryptedBuffer, headers); let finalText; let statusLabel = `[DECRYPTED BODY (key ${keyNum})`; if (decompressionResult.success) { finalText = decompressionResult.data.toString('utf8'); statusLabel += ` - ${decompressionResult.method.toUpperCase()} DECOMPRESSED`; } else { finalText = decryptedBuffer.toString('utf8'); if (decompressionResult.method === 'failed') { statusLabel += ` - DECOMPRESSION FAILED: ${decompressionResult.error}`; } } statusLabel += ']:'; this.output.push(` ${statusLabel}`); this.output.push(` ${finalText.length <= 500 ? finalText : finalText.slice(0, 500) + '...'}`); } catch (error) { this.debugLog(`Body decryption failed: ${error.message}`); this.output.push(` [ENCRYPTED - decryption failed: ${error.message}]`); } } else { const decompressionResult = this.decompressBody(bodyBuffer, headers); let finalText; let statusLabel = `Body (${bodyBuffer.length} bytes)`; if (decompressionResult.success) { finalText = decompressionResult.data.toString('utf8'); statusLabel += ` - ${decompressionResult.method.toUpperCase()} DECOMPRESSED`; } else { finalText = bodyBuffer.toString('utf8'); if (decompressionResult.method === 'failed') { statusLabel += ` - DECOMPRESSION FAILED: ${decompressionResult.error}`; } } this.output.push(` ${statusLabel}:`); this.output.push(` ${finalText.length <= 500 ? finalText : finalText.slice(0, 500) + '...'}`); } } } /** * Process individual packet within a connection */ processPacket(packet, connection) { if (packet.payloadLength === 0) return; const timestamp = new Date(packet.timestamp * 1000).toISOString(); const direction = connection.serverToClient ? 'SERVER → CLIENT' : 'CLIENT → SERVER'; this.output.push(`[${timestamp}] Packet #${packet.packetNumber} (${direction})`); this.output.push(` Length: ${packet.payloadLength} bytes`); try { // Try to parse as text first const payloadText = packet.payload.toString('utf8'); if (this.looksLikeWebTVHeaders(payloadText)) { this.processWebTVHeaders(payloadText, connection, direction); } else if (connection.encryptionEnabled && connection.wtvsec) { this.processEncryptedData(packet.payload, connection, direction); } else { // Binary or unknown data this.output.push(` Raw data: ${packet.payload.slice(0, 100).toString('hex')}${packet.payloadLength > 100 ? '...' : ''}`); } } catch (error) { this.debugLog(`Error processing packet #${packet.packetNumber}:`, error.message); this.output.push(` Error processing packet: ${error.message}`); } this.output.push(''); } /** * Check if data looks like WebTV headers */ looksLikeWebTVHeaders(text) { // Look for HTTP-style headers or WebTV-specific headers return /^(GET|POST|SECURE|[a-zA-Z-]+:|\d{3}\s)/.test(text) || text.includes('wtv-') || text.includes('WebTV'); } /** * Process WebTV headers and extract relevant information */ processWebTVHeaders(text, connection, direction) { this.output.push(` WebTV Message (${direction}):`); const lines = text.split(/\r?\n/); const headers = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) break; // End of headers if (i === 0) { // Request/response line this.output.push(` ${line}`); headers.requestLine = line; } else { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.slice(0, colonIndex).toLowerCase(); const value = line.slice(colonIndex + 1).trim(); headers[key] = value; this.output.push(` ${key}: ${value}`); } } } // Extract WebTV-specific information this.extractWebTVInfo(headers, connection); // Look for body content const bodyStart = text.indexOf('\r\n\r\n'); if (bodyStart >= 0) { const bodyText = text.slice(bodyStart + 4); if (bodyText.length > 0) { // Check if this is a proprietary format that should not be decrypted const bodyBuffer = Buffer.from(bodyText, 'binary'); const isProprietary = headers['wtv-initial-key'] && (bodyText.startsWith('ANDY') || bodyBuffer.slice(0, 4).toString() === 'ANDY' || bodyBuffer.slice(0, 4).equals(Buffer.from([0x41, 0x4e, 0x44, 0x59]))); if (isProprietary) { this.output.push(` Body (${bodyText.length} bytes): [PROPRIETARY FORMAT - NOT ENCRYPTED]`); // Show a preview of the proprietary data const previewText = bodyText.slice(0, 100).replace(/[\x00-\x1f\x7f-\xff]/g, '∩┐╜'); this.output.push(` ${previewText}${bodyText.length > 1000 ? '...' : ''}`); } else if (headers['wtv-encrypted'] === 'true' && connection.wtvsec) { // Avoid this text-path for decryption to prevent byte corruption; handled in processWebTVMessageBuffer. this.output.push(` Body is encrypted; processing via buffer-based path elsewhere.`); } else { // Regular unencrypted body - try decompression const bodyBuffer = Buffer.from(bodyText, 'binary'); const decompressionResult = this.decompressBody(bodyBuffer, headers); let finalText; let statusLabel = `Body (${bodyText.length} bytes)`; if (decompressionResult.success) { finalText = decompressionResult.data.toString('utf8'); statusLabel += ` - ${decompressionResult.method.toUpperCase()} DECOMPRESSED`; } else { finalText = bodyText; if (decompressionResult.method === 'failed') { statusLabel += ` - DECOMPRESSION FAILED: ${decompressionResult.error}`; } } this.output.push(` ${statusLabel}:`); if (finalText.length <= 500) { this.output.push(` ${finalText}`); } else { this.output.push(` ${finalText.slice(0, 1000)}...`); } } } } } /** * Extract WebTV-specific information from headers */ extractWebTVInfo(headers, connection) { // Get both connection key (directional) and flow key (bidirectional) const connectionKey = connection.key; // e.g., "192.168.1.1:1234->192.168.1.2:1515" const flowKey = this.getFlowKeyFromConnection(connectionKey); // Initialize connection-specific state if (!this.connections.has(connectionKey)) { const newConnState = { incarnation: 0, handshakeVerified: false, wtvsec: null }; // Don't create WTVSec instance yet - we need to wait for the incarnation header // WTVSec will be created when we process the first incarnation header const isClientToServer = this.isConnectionToWebTVServer(connectionKey); if (isClientToServer) { this.debugLog(`Deferring WTVSec creation for client->server connection until incarnation is known: ${connectionKey}`); } else { this.debugLog(`Skipping WTVSec creation for server->client connection: ${connectionKey}`); } this.connections.set(connectionKey, newConnState); } const connState = this.connections.get(connectionKey); // Initialize flow-specific state (bidirectional handshake tracking) if (!this.flows.has(flowKey)) { this.flows.set(flowKey, { handshakeComplete: false }); } const flow = this.flows.get(flowKey); // Track initial key from server (first server response should contain this) if (headers['wtv-initial-key']) { this.output.push(` [*] Initial key: ${headers['wtv-initial-key']}`); connection.initialKey = headers['wtv-initial-key']; flow.initialKey = headers['wtv-initial-key']; this.debugLog(`Flow ${flowKey} now has initialKey, challenge=${!!flow.challenge}, challengeResponse=${!!flow.observedResp}`); // Extract server IP from connection key and store the initial key globally const serverIP = this.getServerIPFromConnection(connection.key); if (serverIP) { this.serverInitialKeys.set(serverIP, headers['wtv-initial-key']); this.debugLog(`Stored initial key for server ${serverIP}: ${headers['wtv-initial-key']}`); // Apply this key to any existing connections with this server that don't have an initial key this.applyInitialKeyToExistingConnections(serverIP, headers['wtv-initial-key']); } // If we already have challenge and response on this flow, verify now if (flow.challenge && flow.observedResp && !flow.handshakeComplete) { this.debugLog(`Initial key received, triggering delayed handshake verification for ${connectionKey}`); this.verifyHandshake(connectionKey, connState, flow, connection); } } // Track incarnation per connection if (headers['wtv-incarnation']) { const incarnationStr = headers['wtv-incarnation'].trim(); if (incarnationStr && incarnationStr !== '') { const newIncarnation = parseInt(incarnationStr); if (!isNaN(newIncarnation) && newIncarnation !== connState.incarnation) { connState.incarnation = newIncarnation; connection.incarnation = newIncarnation; this.output.push(` [*] Incarnation: ${newIncarnation} (connection: ${connectionKey})`); // Create WTVSec instance if this is a client->server connection and we don't have one yet const isClientToServer = this.isConnectionToWebTVServer(connectionKey); if (isClientToServer && !connState.wtvsec) { try { this.debugLog(`Creating WTVSec instance with incarnation ${newIncarnation} for: ${connectionKey}`); connState.wtvsec = new WTVSec(this.config, newIncarnation); this.debugLog(`WTVSec instance created successfully for ${connectionKey}`); } catch (error) { console.error(`Error creating WTVSec for ${connectionKey}:`, error); } } // Update existing WTVSec instance if it exists else if (connState.wtvsec) { this.debugLog(`Updating WTVSec incarnation to ${newIncarnation} for: ${connectionKey}`); connState.wtvsec.set_incarnation(newIncarnation); } } } else { this.debugLog(`Empty incarnation header for ${connectionKey}`); } } // Track challenge/response (only verify once per flow) if (headers['wtv-challenge'] && !flow.handshakeComplete) { this.output.push(` [*] Challenge detected: ${headers['wtv-challenge']}`); connection.challenge = headers['wtv-challenge']; flow.challenge = headers['wtv-challenge']; this.debugLog(`Flow ${flowKey} now has challenge, initialKey=${!!flow.initialKey}`); // Initialize WTVSec for this connection if needed this.initializeConnectionWTVSec(connectionKey, connState, flow); } if (headers['wtv-challenge-response'] && !flow.handshakeComplete) { this.output.push(` [*] Challenge response: ${headers['wtv-challenge-response']}`); connection.observedChallengeResponseB64 = headers['wtv-challenge-response']; flow.observedResp = headers['wtv-challenge-response']; this.debugLog(`Flow ${flowKey} now has challenge-response, initialKey=${!!flow.initialKey}, challenge=${!!flow.challenge}`); // Verify handshake if we have all components if (flow.challenge && !connState.handshakeVerified) { // Check if we have an initial key for this flow, or can get one from the server IP let initialKeyToUse = flow.initialKey; if (!initialKeyToUse) { const serverIP = this.getServerIPFromConnection(connectionKey); if (serverIP && this.serverInitialKeys.has(serverIP)) { initialKeyToUse = this.serverInitialKeys.get(serverIP); flow.initialKey = initialKeyToUse; // Store it on the flow for future use this.debugLog(`Using server-specific initial key for ${connectionKey}: ${initialKeyToUse}`); } } if (initialKeyToUse) { this.debugLog(`All components available for handshake verification on ${connectionKey}`); this.verifyHandshake(connectionKey, connState, flow, connection); } else { this.debugLog(`Challenge-response found but no initial key available for ${connectionKey}`); } } } // Track encryption if (headers['wtv-encrypted'] === 'true') { if (!connection.encryptionEnabled) { connection.encryptionEnabled = true; this.output.push(` [*] Encryption enabled`); // Note: WTVSec initialization will be handled by connection-specific methods } } // SECURE ON command if (headers.requestLine && headers.requestLine.includes('SECURE ON')) { this.output.push(` [*] SECURE ON detected - encryption will start`); this.debugLog(`SECURE ON detected for ${connectionKey}, current incarnation: ${connState.incarnation}, WTVSec exists: ${!!connState.wtvsec}`); connection.encryptionEnabled = true; connection.secureOnSeen = true; // Note: WTVSec initialization will be handled by connection-specific methods } // Track serial numbers for session identification if (headers['wtv-client-serial-number']) { connection.clientSSID = headers['wtv-client-serial-number']; this.output.push(` [*] Client SSID: ${connection.clientSSID}`); } } /** * Compute expected challenge-response using current initial key and challenge and log result */ computeAndReportChallenge(connection) { try { if (!connection.initialKey || !connection.challenge) return; if (!connection.wtvsec) { connection.wtvsec = new WTVSec(this.config, connection.incarnation); } // Force the initial key for this connection const initialKeyWordArray = CryptoJS.enc.Base64.parse(connection.initialKey); connection.wtvsec.initial_shared_key = initialKeyWordArray; connection.wtvsec.current_shared_key = initialKeyWordArray; const respWordArray = connection.wtvsec.ProcessChallenge(connection.challenge, initialKeyWordArray); const respB64 = respWordArray.toString(CryptoJS.enc.Base64); connection.expectedChallengeResponseB64 = respB64; this.output.push(` [*] Computed challenge-response: ${respB64}`); if (connection.observedChallengeResponseB64) { const match = this.compareChallengeResponses(respB64, connection.observedChallengeResponseB64); connection.challengeMatch = match; this.output.push(` [${match ? 'PASS' : 'FAIL'}] Computed vs observed challenge-response`); if (!match) { this.output.push(` expected: ${respB64}`); this.output.push(` observed: ${connection.observedChallengeResponseB64}`); } } } catch (e) { this.output.push(` [!] Failed to compute challenge-response: ${e.message}`); this.debugLog(e.stack || e); } } /** * Compute expected challenge-response for a flow using its initial key and challenge */ computeAndReportFlowChallenge(flow, connectionCtx) { try { if (!flow || !flow.initialKey || !flow.challenge) return; const wtvsec = new WTVSec(this.config, connectionCtx.incarnation || 1); const initialKeyWordArray = CryptoJS.enc.Base64.parse(flow.initialKey); wtvsec.initial_shared_key = initialKeyWordArray; wtvsec.current_shared_key = initialKeyWordArray; const respWordArray = wtvsec.ProcessChallenge(flow.challenge, initialKeyWordArray); const respB64 = respWordArray.toString(CryptoJS.enc.Base64); flow.expectedResp = respB64; this.output.push(` [*] Computed challenge-response (flow): ${respB64}`); } catch (e) { this.output.push(` [!] Failed to compute flow challenge-response: ${e.message}`); this.debugLog(e.stack || e); } } /** * Build a canonical flow key (direction-agnostic) from a connection key */ getFlowKeyFromConnection(connectionKey) { const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (!match) return connectionKey; const a = { ip: match[1], port: parseInt(match[2]) }; const b = { ip: match[3], port: parseInt(match[4]) }; const ipToNum = (ip) => ip.split('.').reduce((n, oct) => (n << 8) + parseInt(oct), 0); const aKey = [ipToNum(a.ip), a.port]; const bKey = [ipToNum(b.ip), b.port]; const first = (aKey[0] < bKey[0] || (aKey[0] === bKey[0] && aKey[1] <= bKey[1])) ? a : b; const second = (first === a) ? b : a; return `${first.ip}:${first.port}<->${second.ip}:${second.port}`; } /** * Compare two Base64 challenge-responses for equality (constant-time-ish trim) */ compareChallengeResponses(expectedB64, observedB64) { if (!expectedB64 || !observedB64) return false; const a = expectedB64.trim(); const b = observedB64.trim(); if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } /** * Initialize WTVSec for decryption */ /** * Initialize WTVSec instance for a specific connection */ initializeConnectionWTVSec(connectionKey, connState, flow) { if (!connState.wtvsec && flow.initialKey) { try { this.debugLog(`Initializing WTVSec for connection: ${connectionKey}`); connState.wtvsec = new WTVSec(this.config, connState.incarnation); // Override the initial shared key with the one provided by the server const initialKeyWordArray = CryptoJS.enc.Base64.parse(flow.initialKey); connState.wtvsec.initial_shared_key = initialKeyWordArray; connState.wtvsec.current_shared_key = initialKeyWordArray; this.debugLog(`WTVSec initialized with incarnation ${connState.incarnation} and initial key ${flow.initialKey}`); } catch (error) { console.error(`Error initializing WTVSec for ${connectionKey}:`, error); } } } /** * Verify the handshake for a connection (only once per flow) */ verifyHandshake(connectionKey, connState, flow, connection) { if (connState.handshakeVerified || flow.handshakeComplete) { return; // Already verified } try { this.debugLog(`Verifying handshake for connection: ${connectionKey}`); this.debugLog(`Using initial key: ${flow.initialKey}`); this.debugLog(`Processing challenge: ${flow.challenge}`); // Initialize WTVSec if not already done this.initializeConnectionWTVSec(connectionKey, connState, flow); if (!connState.wtvsec) { this.output.push(` [FAIL] Could not initialize WTVSec for handshake verification`); return; } // Use the server's initial key for challenge processing const keyToUse = CryptoJS.enc.Base64.parse(flow.initialKey); this.debugLog(`Using key for challenge: ${keyToUse.toString(CryptoJS.enc.Base64)}`); const challengeResponse = connState.wtvsec.ProcessChallenge(flow.challenge, keyToUse); if (challengeResponse && challengeResponse.toString) { const expectedB64 = challengeResponse.toString(CryptoJS.enc.Base64); this.debugLog(`Computed challenge-response: ${expectedB64}`); flow.expectedResp = expectedB64; // Compare with observed response const match = this.compareChallengeResponses(expectedB64, flow.observedResp); connection.challengeMatch = match; connState.handshakeVerified = true; flow.handshakeComplete = true; this.output.push(` [${match ? 'PASS' : 'FAIL'}] Handshake verification for ${connectionKey}`); if (!match) { this.output.push(` expected: ${expectedB64}`); this.output.push(` observed: ${flow.observedResp}`); } else { this.output.push(` [*] Handshake successful - encryption keys established`); // CRITICAL: Copy WTVSec instance to connection for decryption connection.wtvsec = connState.wtvsec; connection.incarnation = connState.incarnation; this.debugLog(`WTVSec instance copied to connection for decryption`); } } else { this.output.push(` [FAIL] Failed to process challenge - no response generated`); } } catch (error) { console.error(`Error verifying handshake for ${connectionKey}:`, error); this.output.push(` [FAIL] Handshake verification error: ${error.message}`); } } /** * Extract server IP from connection key */ getServerIPFromConnection(connectionKey) { // Connection key format: "srcIP:srcPort->dstIP:dstPort" const match = connectionKey.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)->(\d+\.\d+\.\d+\.\d+):(\d+)$/); if (match) { const srcIP = match[1]; const srcPort = parseInt(match[2]); const dstIP = match[3]; const dstPort = parseInt(match[4]); // Determine which is the server IP (servers typically use lower port numbers) return srcPort < dstPort ? srcIP : dstIP; } return null; } /** * Apply initial key to existing connections from the same server */ applyInitialKeyToExistingConnections(serverIP, initialKey) { for (const [connectionKey, connection] of this.connections) { const connServerIP = this.getServerIPFromConnection(connectionKey); if (connServerIP === serverIP && !connection.initialKey) { this.debugLog(`Applying initial key to existing connection: ${connectionKey}`); connection.initialKey = initialKey; // If this connection has a challenge but no session keys, retry processing if (connection.challenge && connection.wtvsec && (!connection.wtvsec.session_key1 || !connection.wtvsec.session_key2)) { this.debugLog(`Retrying challenge processing with new initial key for ${connectionKey}`); this.retryChallenge(connection, initialKey); } } } } /** * Retry challenge processing with the correct initial key */ retryChallenge(connection, initialKey) { try { const keyToUse = CryptoJS.enc.Base64.parse(initialKey); connection.wtvsec.initial_shared_key = keyToUse; connection.wtvsec.current_shared_key = keyToUse; this.debugLog(`Retrying challenge processing with server key: ${initialKey}`); const response = connection.wtvsec.ProcessChallenge(connection.challenge, keyToUse); this.debugLog(`Challenge retry completed successfully`); if (connection.wtvsec.session_key1 && connection.wtvsec.session_key2) { this.debugLog(`Session keys generated from retry - Key1: ${connection.wtvsec.session_key1.toString(CryptoJS.enc.Base64)}, Key2: ${connection.wtvsec.session_key2.toString(CryptoJS.enc.Base64)}`); // Initialize RC4 sessions now that we have session keys if (connection.encryptionEnabled || connection.secureOnSeen) { try { connection.wtvsec.SecureOn(); this.debugLog(`RC4 sessions initialized after retry for connection ${connection.key}`); } catch (secureOnError) { this.debugLog(`SecureOn failed after retry: ${secureOnError.message}`); } } } else { this.debugLog(`Challenge retry completed but no session keys generated`); } } catch (retryError) { this.debugLog(`Challenge retry failed: ${retryError.message}`); } } /** * Score a decryption attempt to determine quality */ scoreDecryption(text, decompressionSuccess) { let score = 0; // Higher score is better if (decompressionSuccess) { score += 5; // Successfully decompressed data is likely correct } if (this.isPrintableText(text)) { score += 3; // Printable text is better than binary } // Look for common WebTV/HTML patterns (case insensitive) const lowerText = text.toLowerCase(); const webtvPatterns = [ '', '', '', '= 32 && charCode <= 126) { printableCount++; } else if (charCode === 0) { nullCount++; } else if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) { controlCount++; } } // Score based on character distribution const printableRatio = printableCount / text.length; const nullRatio = nullCount / text.length; const controlRatio = controlCount / text.length; // Bonus for high printable ratio score += printableRatio * 4; // Penalize excessive null bytes if (nullRatio > 0.2) { score -= nullRatio * 8; } // Penalize excessive control characters if (controlRatio > 0.15) { score -= controlRatio * 6; } // Special handling for very short data if (text.length <= 10) { // For very short packets, prefer clean data if (nullRatio < 0.1 && controlRatio < 0.1) { score += 3; } } // Very short meaningful text gets a bonus if (text.length < 50 && this.isPrintableText(text) && text.trim().length > 0) { score += 2; } return Math.max(0, score); // Never return negative scores } /** * Process encrypted data */ processEncryptedData(data, connection, direction) { this.output.push(` Encrypted Data (${direction}):`); if (!connection.wtvsec) { this.output.push(` [!] No WTVSec instance - cannot decrypt`); this.output.push(` Raw: ${data.slice(0, 100).toString('hex')}${data.length > 1000 ? '...' : ''}`); return; } try { // For encrypted data, we need to handle the specific WebTV pattern: // - Client sends "SECURE ON" in plaintext // - Server responds with plaintext headers including wtv-encrypted: true // - Subsequent data from both sides is encrypted // Check if this might be a SECURE ON response with encrypted body const dataText = data.toString('utf8', 0, Math.min(200, data.length)); if (dataText.includes('wtv-encrypted: true') && dataText.includes('\r\n\r\n')) { // This is a mixed plaintext header + encrypted body response this.processEncryptedResponse(data, connection, direction); return; } this.ensureSecureOn(connection); const keyNum = connection.serverToClient ? 1 : 0; // server->client uses key 1, client->server uses key 0 const keyName = keyNum === 0 ? 'session_key1' : 'session_key2'; this.debugLog(`Decrypting ${data.length} bytes with ${keyName} (key ${keyNum}) for ${direction}`); const decrypted = connection.wtvsec.Decrypt(keyNum, data); const decryptedBuffer = Buffer.from(decrypted); const decryptedText = decryptedBuffer.toString('utf8'); this.output.push(` Decrypted with ${keyName} (${decryptedBuffer.length} bytes):`); if (this.isPrintableText(decryptedText) || this.looksLikeWebTVHeaders(decryptedText)) { this.output.push(` [*] Decrypted text content:`); this.output.push(` ${decryptedText.length <= 1000 ? decryptedText : decryptedText.slice(0, 1000) + '...'}`); } else { this.output.push(` [*] Decrypted binary data:`); this.output.push(` ${decryptedBuffer.slice(0, 100).toString('hex')}${decryptedBuffer.length > 1000 ? '...' : ''}`); } } catch (error) { this.debugLog(`Decryption failed:`, error.message); this.output.push(` [!] Decryption failed: ${error.message}`); // Show raw data for debugging this.output.push(` Raw: ${data.slice(0, 100).toString('hex')}${data.length > 1000 ? '...' : ''}`); } } /** * Process mixed plaintext header + encrypted body response */ processEncryptedResponse(data, connection, direction) { try { // Find header/body split let headerEnd = -1; let headerSep = 0; const crlfcrlf = data.indexOf('\r\n\r\n'); const lflf = data.indexOf('\n\n'); if (crlfcrlf !== -1) { headerEnd = crlfcrlf; headerSep = 4; } else if (lflf !== -1) { headerEnd = lflf; headerSep = 2; } if (headerEnd === -1) { // No header/body split found this.processEncryptedData(data, connection, direction); return; } // Split headers and body const headerData = data.slice(0, headerEnd); const bodyData = data.slice(headerEnd + headerSep); this.output.push(` [*] Mixed response - plaintext headers + encrypted body:`); // Process plaintext headers const headerText = headerData.toString('utf8'); this.output.push(` Headers:`); const lines = headerText.split(/\r?\n/); for (const line of lines) { if (line.trim()) { this.output.push(` ${line}`); } } // Try to decrypt the body if (bodyData.length > 0) { this.output.push(` Encrypted body (${bodyData.length} bytes):`); // Server->client data uses key 1, client->server uses key 0 const keyNum = connection.serverToClient ? 1 : 0; try { this.ensureSecureOn(connection); const decrypted = connection.wtvsec.Decrypt(keyNum, bodyData); const decryptedBuffer = Buffer.from(decrypted); const decryptedText = decryptedBuffer.toString('utf8'); if (this.isPrintableText(decryptedText) || this.looksLikeWebTVHeaders(decryptedText)) { this.output.push(` [*] Decrypted body (text):`); this.output.push(` ${decryptedText.length <= 500 ? decryptedText : decryptedText.slice(0, 1000) + '...'}`); } else { this.output.push(` [*] Decrypted binary body:`); this.output.push(` ${decryptedBuffer.slice(0, 100).toString('hex')}${decryptedBuffer.length > 100 ? '...' : ''}`); } } catch (error) { this.output.push(` [!] Body decryption failed: ${error.message}`); this.output.push(` Raw: ${bodyData.slice(0, 100).toString('hex')}${bodyData.length > 100 ? '...' : ''}`); } } } catch (error) { this.debugLog(`Error processing mixed response:`, error.message); this.output.push(` [!] Error processing mixed response: ${error.message}`); } } /** * Ensure RC4 sessions are initialized when we have session keys and SECURE ON was seen. */ ensureSecureOn(connection) { try { if (!connection) return; if (!connection.wtvsec) return; if (connection.rc4Ready) return; if (!connection.wtvsec.session_key1 || !connection.wtvsec.session_key2) return; if (!connection.secureOnSeen && !connection.encryptionEnabled) return; connection.wtvsec.SecureOn(); connection.rc4Ready = true; this.debugLog(`RC4 sessions initialized for connection ${connection.key}`); } catch (e) { this.debugLog(`ensureSecureOn failed: ${e.message}`); } } /** * Check if text is mostly printable ASCII */ isPrintableText(text) { if (text.length === 0) return false; let printableCount = 0; for (let i = 0; i < Math.min(text.length, 100); i++) { const char = text.charCodeAt(i); if ((char >= 32 && char <= 126) || char === 9 || char === 10 || char === 13) { printableCount++; } } return (printableCount / Math.min(text.length, 100)) > 0.8; } /** * Decompress body content based on headers */ decompressBody(bodyBuffer, headers) { try { // Check for LZPF compression first if (headers['wtv-lzpf'] === '0') { this.debugLog('Attempting LZPF decompression'); const lzpf = new LZPF(); const decompressed = lzpf.expand(bodyBuffer); return { success: true, data: decompressed, method: 'LZPF' }; } // Check for standard content-encoding const contentEncoding = headers['content-encoding']; if (contentEncoding) { this.debugLog(`Attempting ${contentEncoding} decompression`); if (contentEncoding === 'deflate') { const decompressed = zlib.inflateSync(bodyBuffer); return { success: true, data: decompressed, method: 'deflate' }; } else if (contentEncoding === 'gzip') { const decompressed = zlib.gunzipSync(bodyBuffer); return { success: true, data: decompressed, method: 'gzip' }; } } // No compression detected return { success: false, data: bodyBuffer, method: 'none' }; } catch (error) { this.debugLog(`Decompression failed: ${error.message}`); return { success: false, data: bodyBuffer, method: 'failed', error: error.message }; } } /** * Save analysis results to file */ saveResults(content) { if (this.outputFile) { fs.writeFileSync(this.outputFile, content); console.log(`Analysis saved to: ${this.outputFile}`); } else { console.log(content); } } /** * Run the complete analysis */ async run() { try { console.log('Starting WebTV PCAP analysis...'); // Parse PCAP file await this.parsePcapFile(); if (this.packets.length === 0) { console.log('No WebTV packets found in PCAP file'); return; } // Analyze traffic const results = this.analyzeTraffic(); // Save or display results this.saveResults(results); console.log(`Analysis complete. Processed ${this.packets.length} WebTV packets.`); } catch (error) { console.error('Error during analysis:', error.message); if (this.debug) { console.error(error.stack); } } } } /** * Command line interface */ function parseArgs() { const args = process.argv.slice(2); const options = { pcapFile: '../wtv.pcap', outputFile: null, debug: false, verbose: false, portRange: {min: 1600, max: 1699} // WebTV Default Port Range }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--pcap': case '-p': if (i + 1 < args.length) { options.pcapFile = args[++i]; } break; case '--output': case '-o': if (i + 1 < args.length) { options.outputFile = args[++i]; } break; case '--debug': case '-d': options.debug = true; break; case '--verbose': case '-v': options.verbose = true; break; case '--ports': if (i + 1 < args.length) { const portSpec = args[++i]; const portRange = parsePortRange(portSpec); if (portRange) { options.portRange = portRange; } else { console.error(`Invalid port range: ${portSpec}. Use format like "1500-1599" or "1515"`); process.exit(1); } } break; case '--help': case '-h': console.log(` WebTV PCAP Analyzer Usage: node unroll_pcap.js [options] Options: -p, --pcap PCAP file to analyze (default: ../wtv.pcap) -o, --output Output file for results (default: stdout) -d, --debug Enable debug logging -v, --verbose Enable verbose output --ports Filter by port range (e.g., "1500-1599" or "1515") -h, --help Show this help message Examples: node unroll_pcap.js --pcap ../traffic.pcap --output analysis.txt node unroll_pcap.js -d -v --pcap ../wtv.pcap node unroll_pcap.js --pcap ../wtv.pcap --ports 1500-1599 node unroll_pcap.js --ports 1515 --debug `); process.exit(0); } } return options; } /** * Parse port range specification */ function parsePortRange(portSpec) { if (!portSpec) return null; // Handle single port if (/^\d+$/.test(portSpec)) { const port = parseInt(portSpec); if (port >= 1 && port <= 65535) { return { min: port, max: port }; } return null; } // Handle port range const match = portSpec.match(/^(\d+)-(\d+)$/); if (match) { const min = parseInt(match[1]); const max = parseInt(match[2]); if (min >= 1 && max <= 65535 && min <= max) { return { min: min, max: max }; } } return null; } /** * Main execution */ if (require.main === module) { const options = parseArgs(); const analyzer = new WebTVPcapAnalyzer(options); analyzer.run().catch(console.error); } module.exports = WebTVPcapAnalyzer;