// protocol_decoder.js - Read-only hook on the game's network wrapper. // // Fires onLeave only: reads the processed buffer at r9 (length r8d) and // forwards it to Python together with the this-pointer identity, stream // byte-offset from [this+0xA0], and the direction flag from [this+0x60]. // Python owns all reassembly, protobuf parsing, and logging. // // We intentionally do NOT modify the buffer, and we do NOT read on enter: // at onLeave the wrapper has completed so r9 holds plaintext (decrypt path) // or plaintext-before-encrypt... actually both paths leave plaintext visible // at some point around the wrapper, but the outgoing path writes ciphertext // over plaintext. To stay single-code-path and read-only we capture the // input side by latching r9's bytes onEnter into a JS buffer, and emit both // pre+post so the Python side picks whichever one looks like protobuf. (function () { 'use strict'; var MOD_NAME = 'Paralogue-Win64-Shipping.exe'; // Decrypt-side wrapper: called AFTER ciphertext has been XOR'd to // plaintext in place. Reading the buffer onLeave gives S->C plaintext. var DECRYPT_RVA = 0x49A2EB0; // Pre-send helper: called with the fully-assembled plaintext send // buffer right before encryption. Reading it onEnter gives C->S // plaintext. Per IDA the prototype is // sub_1449CEA90(this, _DWORD *buf_ptr_ptr, int off=0, uint len) // so rcx=this, rdx=&buffer_ptr, r8=offset (0), r9=total_length. var SEND_RVA = 0x49CEA90; var mod = Process.getModuleByName(MOD_NAME); if (!mod) { send({ t: 'error', msg: 'module not found: ' + MOD_NAME }); return; } var decryptWrapper = mod.base.add(DECRYPT_RVA); var sendWrapper = mod.base.add(SEND_RVA); var callCount = 0; var sendCount = 0; // Capture ChaCha20 session material from every cipher context we see. // There are likely two contexts (one per direction); we emit once per // unique this-pointer so the Python side can build a per-direction map. // [this+0x60] byte -> encryption-enabled flag // [this+0x68] ptr -> 32B key (shared both directions) // [this+0x78] ptr -> 12B nonce (shared both directions) // [this+0x88] ptr -> encrypt EVP context (C->S) // [this+0x90] ptr -> decrypt EVP context (S->C) // [this+0x98] uint64-> encrypt stream byte counter (C->S) // [this+0xA0] uint64-> decrypt stream byte counter (S->C) // Re-emit the key whenever we see a fresh context (stream offset 0) // OR the key bytes change. Game pools cipher-context pointers across // reconnects, so a single this_id can host multiple distinct keys // over its lifetime. var lastKeyByCtx = {}; function maybeEmitKey(thisPtr) { var id = thisPtr.toString(); try { var keyPtr = thisPtr.add(0x68).readPointer(); var noncePtr = thisPtr.add(0x78).readPointer(); if (keyPtr.isNull() || noncePtr.isNull()) return; var key = keyPtr.readByteArray(32); var nonce = noncePtr.readByteArray(12); var offDec = Number(thisPtr.add(0xA0).readU64()); var offEnc = Number(thisPtr.add(0x98).readU64()); var flag = thisPtr.add(0x60).readU8(); var keyHex = Array.prototype.map.call(new Uint8Array(key), function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); // Skip if we've already emitted this exact key for this ctx. if (lastKeyByCtx[id] === keyHex) return; lastKeyByCtx[id] = keyHex; send({ t: 'session_key', this_id: id, flag: flag, offset: offDec, offset_enc: offEnc, offset_dec: offDec, key_hex: keyHex, nonce_hex: Array.prototype.map.call(new Uint8Array(nonce), function (b) { return ('0' + b.toString(16)).slice(-2); }).join('') }); } catch (e) { /* ignore */ } } function readThisU8(thisPtr, off) { try { return thisPtr.add(off).readU8(); } catch (e) { return -1; } } function readThisU64(thisPtr, off) { try { return Number(thisPtr.add(off).readU64()); } catch (e) { return -1; } } // ----------------------------------------------------------------- // OUTGOING hook: capture plaintext sent C->S before encryption. // ----------------------------------------------------------------- Interceptor.attach(sendWrapper, { onEnter: function (args) { var len = this.context.r9.toInt32(); if (len <= 0 || len > (1 << 20)) return; var bufPtrPtr = this.context.rdx; var bufPtr; try { bufPtr = bufPtrPtr.readPointer(); } catch (e) { return; } if (bufPtr.isNull()) return; var bytes; try { bytes = bufPtr.readByteArray(len); } catch (e) { return; } sendCount++; send({ t: 'chunk', source: 'send', n: sendCount, this_id: this.context.rcx.toString(), off: 0, len: len, enc: 0 }, bytes); } }); Interceptor.attach(decryptWrapper, { onEnter: function (args) { var dataLen = this.context.r8.toInt32(); if (dataLen <= 0 || dataLen > (1 << 20)) { this._skip = true; return; } this._skip = false; var thisPtr = this.context.rcx; var dataPtr = this.context.r9; // Emit the session key/nonce the first time we see this context. maybeEmitKey(thisPtr); this._n = ++callCount; this._thisId = thisPtr.toString(); this._dataPtr = dataPtr; this._dataLen = dataLen; this._streamOff = readThisU64(thisPtr, 0xA0); this._encFlag = readThisU8(thisPtr, 0x60); // Latch the input bytes (ciphertext on decrypt path, // plaintext on encrypt path -- Python decides). try { this._inBytes = dataPtr.readByteArray(dataLen); } catch (e) { this._inBytes = null; } }, onLeave: function (retval) { if (this._skip) return; var outBytes = null; try { outBytes = this._dataPtr.readByteArray(this._dataLen); } catch (e) { } // Empirical observation: the wrapper's `onLeave` buffer always // contains plaintext regardless of direction -- the game's // decrypt-and-dispatch path leaves plaintext in r9 for both // send-preparation and recv-delivery. We forward `outBytes` // and also attach the pre-bytes for fallback if plaintext // detection fails on the Python side (detected by protobuf // parsing: ciphertext will consistently fail to match any // known message type, while plaintext parses cleanly). var primary = outBytes; if (!primary) return; send({ t: 'chunk', n: this._n, this_id: this._thisId, off: this._streamOff, len: this._dataLen, enc: this._encFlag }, primary); // Also emit the keystream so the MITM proxy can decrypt wire // traffic without needing the actual ChaCha20 key schedule. // keystream = input_bytes XOR output_bytes // Both directions of the game's wrapper run the same cipher // state against their buffers; XORing pre/post gives the pad. if (this._inBytes) { var inArr = new Uint8Array(this._inBytes); var outArr = new Uint8Array(outBytes); var n = Math.min(inArr.length, outArr.length); var ks = new Uint8Array(n); for (var i = 0; i < n; i++) ks[i] = inArr[i] ^ outArr[i]; send({ t: 'keystream', off: this._streamOff, len: n }, ks.buffer); } } }); setInterval(function () { send({ t: 'status', calls: callCount }); }, 5000); send({ t: 'ready', msg: 'decoder hooks installed at recv=0x' + DECRYPT_RVA.toString(16) + ' send=0x' + SEND_RVA.toString(16), base: mod.base.toString() }); })();