368 lines
13 KiB
JavaScript
368 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const pcapParser = require('pcap-parser');
|
|
const WTVSec = require('./includes/classes/WTVSec.js');
|
|
const LZPF = require('./includes/classes/LZPF.js');
|
|
const WTVShared = require('./includes/classes/WTVShared.js')['WTVShared'];
|
|
const wtvshared = new WTVShared();
|
|
const CryptoJS = require('crypto-js');
|
|
|
|
var wtvsec = null;
|
|
var wtv_challenge_response = null;
|
|
// A simple mock config, the initial_shared_key is populated dynamically.
|
|
const minisrv_config = {
|
|
config: {
|
|
keys: {
|
|
initial_shared_key: null
|
|
},
|
|
debug_flags: {
|
|
debug: false // Set to true for verbose logging from WTVSec
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- Main Execution ---
|
|
const pcapFile = process.argv[2];
|
|
if (!pcapFile) {
|
|
console.error('Usage: node parse_wtvp_parser.js <path_to_pcap_file>');
|
|
process.exit(1);
|
|
}
|
|
|
|
// A store for all active WTVP sessions, keyed by stream identifier.
|
|
const wtvpSessions = {};
|
|
|
|
const parser = pcapParser.parse(pcapFile);
|
|
|
|
parser.on('packet', (packet) => {
|
|
const data = packet.data;
|
|
const ethType = data.readUInt16BE(12);
|
|
if (ethType !== 0x0800) return; // Not IPv4
|
|
|
|
// IP header parsing
|
|
const ipHeaderLength = (data[14] & 0x0F) * 4;
|
|
const ipHeader = data.slice(14, 14 + ipHeaderLength);
|
|
const protocol = ipHeader[9];
|
|
if (protocol !== 6) return; // Not TCP
|
|
|
|
const srcIP = ipHeader.slice(12, 16).join('.');
|
|
const dstIP = ipHeader.slice(16, 20).join('.');
|
|
|
|
// TCP header parsing
|
|
const tcpHeaderStart = 14 + ipHeaderLength;
|
|
const tcpHeaderLen = (data[tcpHeaderStart + 12] >> 4) * 4;
|
|
const srcPort = data.readUInt16BE(tcpHeaderStart);
|
|
const dstPort = data.readUInt16BE(tcpHeaderStart + 2);
|
|
const seq = data.readUInt32BE(tcpHeaderStart + 4);
|
|
const flags = data[tcpHeaderStart + 13];
|
|
const isSYN = (flags & 0x02) !== 0;
|
|
const isFIN = (flags & 0x01) !== 0;
|
|
|
|
const tcpPayloadOffset = tcpHeaderStart + tcpHeaderLen;
|
|
const payload = data.slice(tcpPayloadOffset);
|
|
const tcpPayloadLength = payload.length;
|
|
|
|
console.log(`[DEBUG] data.length=${data.length}, tcpPayloadOffset=${tcpPayloadOffset}`);
|
|
// Create a unique key for the TCP session
|
|
const src = `${srcIP}:${srcPort}`;
|
|
const dst = `${dstIP}:${dstPort}`;
|
|
const sessionKey = [src, dst].sort().join('-');
|
|
|
|
// Initialize session state if new
|
|
if (!wtvpSessions[sessionKey]) {
|
|
console.log(`[+] New TCP Session detected: ${sessionKey}`);
|
|
wtvpSessions[sessionKey] = {
|
|
clientAddr: null,
|
|
serverAddr: null,
|
|
wtvsec: null,
|
|
secureMode: false,
|
|
// TCP stream reassembly state, keyed by source ip:port
|
|
streams: {},
|
|
};
|
|
}
|
|
|
|
const currentSession = wtvpSessions[sessionKey];
|
|
|
|
// Ensure a stream object exists for the source of this packet
|
|
if (!currentSession.streams[src]) {
|
|
currentSession.streams[src] = { nextSeq: -1, outOfOrder: {}, data: Buffer.alloc(0), isClient: false };
|
|
}
|
|
const stream = currentSession.streams[src];
|
|
|
|
// 1. Identify Client and Server (if not already done)
|
|
if (!currentSession.clientAddr && payload.length > 0) {
|
|
const payloadStr = payload.toString('utf8');
|
|
if (payloadStr.startsWith('GET') || payloadStr.startsWith('POST') || payloadStr.startsWith('SECURE ON')) {
|
|
console.log(`[*] Client identified as ${src}, Server as ${dst}`);
|
|
currentSession.clientAddr = src;
|
|
currentSession.serverAddr = dst;
|
|
|
|
// Mark the current stream (from src) as the client
|
|
stream.isClient = true;
|
|
|
|
// Ensure the server's stream object exists as well
|
|
if (!currentSession.streams[dst]) {
|
|
currentSession.streams[dst] = { nextSeq: -1, outOfOrder: {}, data: Buffer.alloc(0), isClient: false };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the isClient flag for every packet now that identification might have happened
|
|
if(currentSession.clientAddr){
|
|
stream.isClient = src === currentSession.clientAddr;
|
|
}
|
|
|
|
//
|
|
|
|
// This is the expected in-order packet. Append its payload.
|
|
stream.data = Buffer.concat([stream.data, payload]);
|
|
stream.nextSeq += tcpPayloadLength;
|
|
if (isSYN || isFIN) stream.nextSeq++;
|
|
|
|
// Process any buffered out-of-order packets that are now in sequence
|
|
let nextSeqInChain = stream.nextSeq;
|
|
while (stream.outOfOrder[nextSeqInChain]) {
|
|
const bufferedPayload = stream.outOfOrder[nextSeqInChain];
|
|
const bufferedPayloadLength = bufferedPayload.length;
|
|
|
|
stream.data = Buffer.concat([stream.data, bufferedPayload]);
|
|
delete stream.outOfOrder[nextSeqInChain];
|
|
|
|
nextSeqInChain += bufferedPayloadLength;
|
|
}
|
|
stream.nextSeq = nextSeqInChain;
|
|
|
|
// Now that we have new contiguous data, try to process it as application messages
|
|
for (const addr in currentSession.streams) {
|
|
const s = currentSession.streams[addr];
|
|
if (s.data.length > 0) {
|
|
processStream(currentSession, addr);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* Processes the reassembled data buffer for a session, looking for complete messages.
|
|
* @param {object} session - The session state object.
|
|
* @param {string} sourceAddr - The source address (ip:port) of the stream being processed.
|
|
*/
|
|
function processStream(session, sourceAddr) {
|
|
const stream = session.streams[sourceAddr];
|
|
console.log(`[DEBUG] Processing stream: ${sourceAddr} isClient: ${stream.isClient}, buffer length: ${stream.data.length}`);
|
|
|
|
if (!stream || !session.clientAddr) return; // Don't process until client is identified
|
|
|
|
const isClient = stream.isClient;
|
|
const direction = isClient ? '[CLIENT -> SERVER]' : '[SERVER -> CLIENT]';
|
|
|
|
// Loop to process all complete messages currently in the buffer
|
|
while (true) {
|
|
let buffer = stream.data;
|
|
if (buffer.length === 0) break;
|
|
if (buffer.length === 6) {
|
|
// Special case: buffer is exactly 6 bytes (likely a keepalive or unknown control message)
|
|
// Remove the 6 bytes from the buffer and continue
|
|
stream.data = buffer.slice(6);
|
|
break;
|
|
}
|
|
|
|
const lfSeparator = Buffer.from('\n\n');
|
|
const crlfSeparator = Buffer.from('\r\n\r\n');
|
|
|
|
let separatorIndex = buffer.indexOf(lfSeparator);
|
|
let separatorLength = lfSeparator.length;
|
|
const crlfIndex = buffer.indexOf(crlfSeparator);
|
|
|
|
if (crlfIndex !== -1 && (separatorIndex === -1 || crlfIndex < separatorIndex)) {
|
|
separatorIndex = crlfIndex;
|
|
separatorLength = crlfSeparator.length;
|
|
}
|
|
|
|
if (separatorIndex === -1) {
|
|
// Incomplete message (no full headers yet), wait for more data.
|
|
break;
|
|
}
|
|
|
|
const headersPart = buffer.slice(0, separatorIndex);
|
|
const headers = parseHeaders(headersPart.toString('utf8'));
|
|
const headerBlockLength = separatorIndex + separatorLength;
|
|
|
|
let messageToProcess;
|
|
let consumedSize;
|
|
|
|
if (headers['content-length']) {
|
|
const contentLength = parseInt(headers['content-length'], 10);
|
|
const totalMessageSize = headerBlockLength + contentLength;
|
|
|
|
if (buffer.length < totalMessageSize) {
|
|
// We have headers, but the body is not fully here yet. Wait for more data.
|
|
break;
|
|
}
|
|
messageToProcess = buffer.slice(0, totalMessageSize);
|
|
consumedSize = totalMessageSize;
|
|
} else {
|
|
// No content-length. Assume the rest of the buffer is the message.
|
|
messageToProcess = buffer.slice(0, headerBlockLength);
|
|
consumedSize = headerBlockLength;
|
|
}
|
|
|
|
|
|
console.log(`\n${'='.repeat(20)} Processing Message: ${direction} (${messageToProcess.length} bytes) ${'='.repeat(20)}`);
|
|
|
|
if (!session.secureMode) {
|
|
handlePlaintext(session, messageToProcess.toString('utf8'), isClient);
|
|
} else {
|
|
handleEncrypted(session, messageToProcess, isClient);
|
|
}
|
|
|
|
// Slice the processed message from the front of the buffer
|
|
stream.data = buffer.slice(consumedSize);
|
|
}
|
|
}
|
|
|
|
|
|
parser.on('end', () => {
|
|
console.log('\n[*] PCAP file processing complete.');
|
|
});
|
|
|
|
parser.on('error', (err) => {
|
|
console.error(`[!] An error occurred: ${err.message}`);
|
|
});
|
|
|
|
|
|
/**
|
|
* Handles a single complete plaintext WTVP message.
|
|
* @param {object} session - The session state object.
|
|
* @param {string} message - The plaintext message string.
|
|
* @param {boolean} isClient - True if the message is from the client.
|
|
*/
|
|
function handlePlaintext(session, message, isClient) {
|
|
const headers = parseHeaders(message);
|
|
if (!headers['wtv-encrypted']) {
|
|
console.log('[PLAINTEXT MESSAGE]:');
|
|
console.log(message);
|
|
}
|
|
if (wtvsec && !session.wtvsec) {
|
|
session.wtvsec = wtvsec;
|
|
}
|
|
|
|
|
|
if (isClient) {
|
|
if (message.includes('SECURE ON')) {
|
|
if (session.wtvsec) {
|
|
console.log('[*] SECURE ON detected. Initializing RC4 session.');
|
|
session.wtvsec.SecureOn();
|
|
session.secureMode = true;
|
|
} else {
|
|
console.error('[!] SECURE ON received before wtv-initial-key. Cannot proceed.');
|
|
}
|
|
}
|
|
if (headers['wtv-incarnation']) {
|
|
const incarnation = parseInt(headers['wtv-incarnation'], 10);
|
|
if (session.wtvsec) {
|
|
console.log(`[*] Client sent wtv-incarnation: ${incarnation}`);
|
|
session.wtvsec.set_incarnation(incarnation);
|
|
}
|
|
}
|
|
if (headers['wtv-challenge-response']) {
|
|
const challengeResponse = headers['wtv-challenge-response'];
|
|
console.log(`[*] Client sent wtv-challenge-response: ${challengeResponse}`);
|
|
if (wtv_challenge_response != challengeResponse) {
|
|
console.error('[!] Mismatched wtv-challenge-response. Expected:', wtv_challenge_response);
|
|
process.exit(1);
|
|
} else {
|
|
console.log('[*] wtv-challenge-response matches expected value.');
|
|
}
|
|
}
|
|
} else { // Server
|
|
if (headers['wtv-initial-key']) {
|
|
const initialKey = headers['wtv-initial-key'];
|
|
console.log(`[*] Captured wtv-initial-key: ${initialKey}`);
|
|
minisrv_config.config.keys.initial_shared_key = initialKey;
|
|
wtvsec = new WTVSec(minisrv_config);
|
|
}
|
|
if (headers['wtv-challenge'] && wtvsec) {
|
|
const challenge = headers['wtv-challenge'];
|
|
console.log(`[*] Captured wtv-challenge. Processing...`);
|
|
wtv_challenge_response = wtvsec.ProcessChallenge(challenge).toString(CryptoJS.enc.Base64)
|
|
session.wtvsec = wtvsec; // Ensure session has the WTVSec instance
|
|
}
|
|
if (typeof headers['wtv-lzpf'] !== 'undefined') {
|
|
session.lzpf = true;
|
|
}
|
|
if (headers['wtv-encrypted']) {
|
|
handleEncrypted(session, Buffer.from(message), isClient);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles a single complete encrypted WTVP message.
|
|
* @param {object} session - The session state object.
|
|
* @param {Buffer} message - The raw message buffer.
|
|
* @param {boolean} isClient - True if the message is from the client.
|
|
*/
|
|
function handleEncrypted(session, message, isClient) {
|
|
const lfSeparator = Buffer.from('\n\n');
|
|
const crlfSeparator = Buffer.from('\r\n\r\n');
|
|
|
|
let separatorIndex = message.indexOf(lfSeparator);
|
|
let separatorLength = lfSeparator.length;
|
|
const crlfIndex = message.indexOf(crlfSeparator);
|
|
|
|
if (crlfIndex !== -1 && (separatorIndex === -1 || crlfIndex < separatorIndex)) {
|
|
separatorIndex = crlfIndex;
|
|
separatorLength = crlfSeparator.length;
|
|
}
|
|
if (separatorIndex === -1) {
|
|
console.log('[!] Encrypted message without header separator. This should not happen with reassembled streams.');
|
|
return;
|
|
}
|
|
|
|
const headersPart = message.slice(0, separatorIndex).toString('utf8');
|
|
const encryptedBody = message.slice(separatorIndex + separatorLength);
|
|
|
|
console.log('[ENCRYPTED HEADERS]:');
|
|
console.log(headersPart);
|
|
|
|
if (encryptedBody.length > 0) {
|
|
const keyNum = isClient ? 0 : 1;
|
|
try {
|
|
let decryptedBody = session.wtvsec.Decrypt(keyNum, encryptedBody);
|
|
|
|
// Check for compression flag in the now-decrypted headers
|
|
const headers = parseHeaders(headersPart);
|
|
if (typeof headers['wtv-lzpf'] !== 'undefined') {
|
|
console.log('\n[DECRYPTED DECOMPRESSED PAYLOAD]:');
|
|
var lzpfHandler = new LZPF();
|
|
decryptedBody = lzpfHandler.expand(decryptedBody);
|
|
} else {
|
|
console.log('\n[DECRYPTED PAYLOAD]:');
|
|
}
|
|
console.log(decryptedBody.toString('utf8'));
|
|
|
|
} catch (e) {
|
|
console.error(`[!] Decryption failed: ${e.message}`);
|
|
}
|
|
} else {
|
|
console.log('\n[Encrypted message with no body]');
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* A utility to parse HTTP-like headers into an object.
|
|
* @param {string} payload - The raw text payload.
|
|
* @returns {object} A key-value map of the headers.
|
|
*/
|
|
function parseHeaders(payload) {
|
|
const headers = {};
|
|
const lines = payload.split(/\r?\n/);
|
|
lines.forEach(line => {
|
|
const parts = line.split(':');
|
|
if (parts.length >= 2) {
|
|
headers[parts[0].toLowerCase().trim()] = parts.slice(1).join(':').trim();
|
|
}
|
|
});
|
|
return headers;
|
|
}
|