Files
2026-04-15 22:24:44 -07:00

210 lines
8.5 KiB
JavaScript

// 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()
});
})();