210 lines
8.5 KiB
JavaScript
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()
|
|
});
|
|
})();
|