Files
TankIC/3DTank/tools/decrypt_frames.js
T
TermiNexus b6f2cb71ee feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

256 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);