工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。 服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。 同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。 src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
// decrypt_frames.js - 破解并解密游戏通信流(自变体 XOR 流密码)
|
||
// 用法: node decrypt_frames.js [--dir ../packets] [--label relay-04] [--key N] [--verbose]
|
||
//
|
||
// 密码算法(逆向自 module_61860,类 ir):
|
||
// 构造: key[8] 数组,key[i] = K ^ (i << 3) (解码方向 g7m_1)
|
||
// key[i] = K ^ (i << 3) ^ 87 (编码方向 i7m_1)
|
||
// 解码: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
|
||
// 编码: c = p ^ key[idx]; key[idx] = p; idx ^= (p & 7) —— 与解码同构
|
||
// K 由握手数据折叠而来(1 字节),故可直接穷举 256 种可能:
|
||
// 正确的 K 会让解密后的每个 WebSocket 消息都成为长度自洽的帧。
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const zlib = require('zlib');
|
||
|
||
const PROJECT = path.resolve(__dirname, '..');
|
||
const Argv = process.argv.slice(2);
|
||
const Arg = (Name, Def) => {
|
||
const I = Argv.indexOf('--' + Name);
|
||
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
|
||
};
|
||
|
||
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets')));
|
||
const LABEL = Arg('label', '');
|
||
const FIXED_KEY = Arg('key', null);
|
||
const VERBOSE = Argv.indexOf('--verbose') >= 0;
|
||
|
||
// 帧头:{ TotalSize, HeaderSize, BodySize } —— 返回 null 表示数据不足
|
||
function PeekFrame(Buf) {
|
||
if (Buf.length < 2) return null;
|
||
const B0 = Buf[0];
|
||
if (B0 & 0x80) {
|
||
if (Buf.length < 4) return null;
|
||
return {
|
||
TotalSize: 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0),
|
||
HeaderSize: 4,
|
||
Long: true,
|
||
Compressed: (B0 & 0x40) !== 0,
|
||
};
|
||
}
|
||
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Long: false, Compressed: (B0 & 0x40) !== 0 };
|
||
}
|
||
|
||
// nullmap 长度(对应 x7g)
|
||
function NullmapSize(Buf, Offset) {
|
||
const R = Buf[Offset];
|
||
if (R === undefined) return 0;
|
||
if ((R & 0x80) !== 0) {
|
||
if (R & 0x40) return 4 + (((R & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]);
|
||
return 1 + (R & 0x3f);
|
||
}
|
||
return 1 + ((R & 0x60) >>> 5);
|
||
}
|
||
|
||
// 流密码解密:Bufs 依次解密,密钥状态跨消息连续
|
||
function DecryptStream(Bufs, Key, Encoded) {
|
||
const Slot = new Int32Array(8);
|
||
for (let I = 0; I < 8; I++) Slot[I] = ((Key ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
|
||
let Idx = 0;
|
||
return Bufs.map((Buf) => {
|
||
const Out = Buffer.alloc(Buf.length);
|
||
for (let I = 0; I < Buf.length; I++) {
|
||
const Plain = (Buf[I] ^ Slot[Idx]) & 0xff;
|
||
Out[I] = Plain;
|
||
Slot[Idx] = Plain;
|
||
Idx = Idx ^ (Plain & 7);
|
||
}
|
||
return Out;
|
||
});
|
||
}
|
||
|
||
// 评分:解密后有多少条消息是长度自洽的帧
|
||
function Score(Decoded) {
|
||
let Ok = 0;
|
||
let Ping = 0;
|
||
for (const Buf of Decoded) {
|
||
const Info = PeekFrame(Buf);
|
||
if (Info && Info.TotalSize === Buf.length) {
|
||
Ok++;
|
||
const At = Info.HeaderSize + NullmapSize(Buf, Info.HeaderSize);
|
||
if (At >= Buf.length) Ping++; // 空 payload = 保活
|
||
}
|
||
}
|
||
return { Ok, Ping };
|
||
}
|
||
|
||
function LoadSessions() {
|
||
const Labels = LABEL
|
||
? [LABEL]
|
||
: fs
|
||
.readdirSync(PACKET_DIR, { withFileTypes: true })
|
||
.filter((D) => D.isDirectory())
|
||
.map((D) => D.name);
|
||
const Sessions = [];
|
||
for (const L of Labels) {
|
||
const Dir = path.join(PACKET_DIR, L);
|
||
if (!fs.existsSync(Dir)) continue;
|
||
for (const D of fs.readdirSync(Dir, { withFileTypes: true })) {
|
||
if (!D.isDirectory() || !D.name.startsWith('session_')) continue;
|
||
const SessionDir = path.join(Dir, D.name);
|
||
const Messages = { c2s: [], s2c: [] };
|
||
for (const F of fs.readdirSync(SessionDir).sort()) {
|
||
const M = /^(c2s|s2c)_\d+\.bin$/.exec(F);
|
||
if (M) Messages[M[1]].push(fs.readFileSync(path.join(SessionDir, F)));
|
||
}
|
||
Sessions.push({ Name: L + '/' + D.name, Messages });
|
||
}
|
||
}
|
||
return Sessions;
|
||
}
|
||
|
||
const Sessions = LoadSessions().filter((S) => !Arg('session', '') || S.Name.indexOf(Arg('session', '')) >= 0);
|
||
console.log('抓包目录: ' + PACKET_DIR + ' 会话数: ' + Sessions.length);
|
||
|
||
// 全量命令统计(只统计密钥完全自洽「解出来的」会话方向,避免脏数据污染)
|
||
const Tally = { c2s: {}, s2c: {} };
|
||
const Keys = {};
|
||
|
||
function CountMessage(Direction, Buf) {
|
||
const Info = PeekFrame(Buf);
|
||
if (!Info || Info.TotalSize !== Buf.length) {
|
||
Tally[Direction].BAD = (Tally[Direction].BAD || 0) + 1;
|
||
return;
|
||
}
|
||
let Body = Buf.slice(Info.HeaderSize);
|
||
if (Info.Compressed) {
|
||
// 帧头 0x40 位 = 帧体被 zlib 压缩(客户端用 fflate 的 unzlibSync)
|
||
Tally[Direction].COMPRESSED = (Tally[Direction].COMPRESSED || 0) + 1;
|
||
try {
|
||
Body = zlib.inflateSync(Body);
|
||
Tally[Direction].UNZIP_OK = (Tally[Direction].UNZIP_OK || 0) + 1;
|
||
} catch (Err) {
|
||
Tally[Direction].UNZIP_FAIL = (Tally[Direction].UNZIP_FAIL || 0) + 1;
|
||
return;
|
||
}
|
||
}
|
||
const At = NullmapSize(Body, 0);
|
||
if (At >= Body.length) {
|
||
Tally[Direction].PING = (Tally[Direction].PING || 0) + 1;
|
||
return;
|
||
}
|
||
const Id = Body[At];
|
||
const E = (Tally[Direction][Id] = Tally[Direction][Id] || { Count: 0, Bytes: 0, Sample: Body.slice(At).slice(0, 24) });
|
||
E.Count++;
|
||
E.Bytes += Body.length - At;
|
||
}
|
||
|
||
for (const Session of Sessions) {
|
||
for (const Direction of ['c2s', 's2c']) {
|
||
const All = Session.Messages[Direction];
|
||
if (!All.length) continue;
|
||
const Encoded = Direction === 'c2s'; // 客户端编码用 i7m_1(^87)
|
||
const Total = All.reduce((A, B) => A + B.length, 0);
|
||
|
||
// 密码可能在会话中途才启用,故同时穷举「起始消息下标 Start」:
|
||
// Start 之前的消息保持明文,Start 起的消息用新密钥状态解密
|
||
const Results = [];
|
||
for (let Start = 0; Start < All.length; Start++) {
|
||
// 前缀必须是「明文且自洽」的帧,否则这个起始点不成立(防止把乱码当明文刷分)
|
||
let Bad = 0;
|
||
for (let I = 0; I < Start; I++) {
|
||
const Info = PeekFrame(All[I]);
|
||
if (!Info || Info.TotalSize !== All[I].length) Bad++;
|
||
}
|
||
if (Bad) continue;
|
||
const Bufs = All.slice(Start);
|
||
const Keys = FIXED_KEY !== null ? [Number(FIXED_KEY)] : Array.from({ length: 256 }, (_, I) => I);
|
||
for (const K of Keys) {
|
||
const S = Score(DecryptStream(Bufs, K, Encoded));
|
||
Results.push({ K, Start, Ok: S.Ok + Start, CipherOk: S.Ok, Ping: S.Ping, N: All.length });
|
||
}
|
||
}
|
||
Results.sort((A, B) => B.Ok - A.Ok || B.Ping - A.Ping || A.Start - B.Start);
|
||
const Best = Results[0];
|
||
const RunnerUp = Results[1];
|
||
const Perfect = Best.Ok === All.length;
|
||
const Verdict = Perfect ? '★ 全部自洽' : Best.Ok > (RunnerUp ? RunnerUp.Ok : 0) ? '领先' : '不确定';
|
||
Keys[Session.Name + ' ' + Direction] = { K: Best.K, Start: Best.Start, Perfect };
|
||
// 只有完全自洽的方向才计入总表
|
||
if (Perfect) {
|
||
All.slice(0, Best.Start).forEach((Buf) => CountMessage(Direction, Buf));
|
||
DecryptStream(All.slice(Best.Start), Best.K, Encoded).forEach((Buf) => CountMessage(Direction, Buf));
|
||
}
|
||
console.log(
|
||
'\n' + Session.Name + ' ' + Direction + ' 消息=' + All.length + ' 字节=' + Total +
|
||
' → 最佳 明文' + Best.Start + '条 + K=' + Best.K + ' 加密' + Best.CipherOk + '条' +
|
||
' 合计自洽=' + Best.Ok + '/' + All.length + ' 保活=' + Best.Ping +
|
||
' 次优 ' + (RunnerUp ? '明文' + RunnerUp.Start + '+K=' + RunnerUp.K + '(' + RunnerUp.Ok + ')' : '-') + ' ' + Verdict,
|
||
);
|
||
if (VERBOSE) {
|
||
const Decoded = DecryptStream(All.slice(Best.Start), Best.K, Encoded);
|
||
All.slice(0, Best.Start).forEach((Buf, I) => console.log(' #' + String(I).padStart(3) + ' [明文] ' + Buf.slice(0, 24).toString('hex')));
|
||
Decoded.forEach((Buf, I) => {
|
||
const Info = PeekFrame(Buf);
|
||
const At = Info && Info.TotalSize === Buf.length ? Info.HeaderSize + NullmapSize(Buf, Info.HeaderSize) : -1;
|
||
const Id = At >= 0 && At < Buf.length ? Buf[At] : -1;
|
||
const Ascii = Array.from(Buf.slice(0, 36))
|
||
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
|
||
.join('');
|
||
console.log(
|
||
' #' + String(I + Best.Start).padStart(3) + ' ' + String(Buf.length).padStart(6) + 'B 长=' + (Info ? Info.TotalSize : '?') +
|
||
' 命令ID=' + String(Id).padStart(4) + ' ' + Buf.slice(0, 28).toString('hex') + ' |' + Ascii + '|',
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 解密后的命令总表
|
||
// ============================================================
|
||
|
||
const CMD_NAME = {
|
||
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace(待证)', 32: 'ClientLog' },
|
||
s2c: { 0: '待证(带 int64 与时间戳)', 2: 'Hash Response', 32: 'Open Space' },
|
||
};
|
||
|
||
function Report(Direction) {
|
||
console.log('\n===== 解密后命令总表(' + (Direction === 'c2s' ? '客户端 → 服务器' : '服务器 → 客户端') + ')=====');
|
||
const Table = Tally[Direction];
|
||
const Ids = Object.keys(Table)
|
||
.filter((K) => /^\d+$/.test(K))
|
||
.map(Number)
|
||
.sort((A, B) => A - B);
|
||
for (const Id of Ids) {
|
||
const E = Table[Id];
|
||
const Name = (CMD_NAME[Direction] && CMD_NAME[Direction][Id]) || '未知';
|
||
console.log(
|
||
' ID=' + String(Id).padStart(4) + ' ' + Name.padEnd(24) + ' 次数=' + String(E.Count).padStart(5) +
|
||
' 负载=' + String(E.Bytes).padStart(8) + 'B 样本=' + E.Sample.toString('hex'),
|
||
);
|
||
}
|
||
if (Table.PING) console.log(' [保活 ping] ' + Table.PING + ' 条');
|
||
if (Table.COMPRESSED) console.log(' [压缩帧] ' + Table.COMPRESSED + ' 条(解压成功 ' + (Table.UNZIP_OK || 0) + ' / 失败 ' + (Table.UNZIP_FAIL || 0) + ')');
|
||
if (Table.BAD) console.log(' [解不开/残缺] ' + Table.BAD + ' 条');
|
||
}
|
||
|
||
Report('c2s');
|
||
Report('s2c');
|
||
|
||
const Out = {
|
||
Note: '由 decrypt_frames.js 暴力穷举单字节密钥后统计,仅包含完全自洽的会话方向',
|
||
Keys,
|
||
C2s: {},
|
||
S2c: {},
|
||
};
|
||
for (const Direction of ['c2s', 's2c']) {
|
||
const Target = Direction === 'c2s' ? Out.C2s : Out.S2c;
|
||
for (const K of Object.keys(Tally[Direction])) {
|
||
const E = Tally[Direction][K];
|
||
Target[K] = E.Count !== undefined ? { Name: CMD_NAME[Direction][K] || '未知', Count: E.Count, Bytes: E.Bytes, Sample: E.Sample.toString('hex') } : E;
|
||
}
|
||
}
|
||
const OutFile = path.join(PROJECT, 'names', 'commands_decrypted.json');
|
||
fs.writeFileSync(OutFile, JSON.stringify(Out, null, 2), 'utf8');
|
||
console.log('\n已写出: ' + OutFile);
|