Files
TankIC/3DTank/tools/wss_server.js
T
TermiNexus 9f6dcb0eba 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

476 lines
18 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.
// wss_server.js - 本地游戏通信服务器(替代 wss://c1.3dtank.com:9090/)
// 用法:
// node wss_server.js [--port 9091] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
// 模式:
// relay 中继: 客户端 → 本地 → 真实服务器。抓包落盘 + 实时解析:
// 明文段直接解命令;进入加密段后自动穷举 1 字节密钥并后续实时解密;
// 帧头 0x40 位为 zlib 压缩,自动解压后再解命令。
// 挂载: 客户端 config.xml 指向本服务器(local/config.xml + serve.js 的 /config.xml 本地覆盖)
// <server address="localhost" wsProtocol="WS"> + <port>9091</port> → ws://localhost:9091/
// 产物: 3DTank/packets/<label>/session_00N/{log.txt, c2s_%04d.bin, s2c_%04d.bin}
//
// 协议(逆向自 module_61860 + sockets/):
// 帧 = [2 或 4 字节大端长度][帧体];b0 & 0x80 → 4 字节头;b0 & 0x40 → 帧体 zlib 压缩
// 帧体 = [nullmap][payload];payload = [1 字节命令 ID][body]
// 链路编码器: 明文 nr(直接拷贝)/ 加密 ir(自变体 XOR 流密码)
// 解密: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
// key[i] = K ^ (i << 3)(下发方向);上传方向再 ^ 87;K 为单字节
// Open Space 之后切换加密,故抓包里明文/密文混排
//
// 上行命令识别(默认开,--show-uplink 0 关闭):
// c2s 空间帧的正文 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…]
// 出处:module_61860 的 SpaceCommand codec —— fr.o7g 依次写 f2u(objectId)、f2u(methodId),
// 之后才是命令自身负载;or.o7g = 先写命令 id 再写该命令注册的 codec。
// methodId 查 names/uplink_table.json → 命令类 / 发送方法;负载优先用 names/codec_fields.json 的字段名逐字段解。
// 解不动就停在原地并把剩余字节原样打出来(不做宽度猜测)。
const fs = require('fs');
const http = require('http');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue } = require('./lib_space_codec');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
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 PORT = Number(Arg('port', '9091'));
const UPSTREAM = Arg('upstream', 'wss://c1.3dtank.com:9090/');
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)));
// 字段表:names/codec_fields.json(codec 字段)+ names/uplink_table.json(上行命令名 / 负载字段)
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const ShowUplink = Arg('show-uplink', '1') !== '0';
// methodId(8 字节大端 hex) → 上行命令(用于在上行帧里认 methodId,以及给应答签名用)
const UplinkByHex = new Map();
for (const [MethodId, Cmd] of Tables.UplinkByMethod) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(MethodId));
UplinkByHex.set(B.toString('hex'), Cmd);
}
// ws 模块:优先当前目录解析,回落到 sockets/(唯一装过 ws 的地方)
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.join(ROOT, 'sockets', 'node_modules', 'ws'));
}
}
const Ws = RequireWs();
// 上游握手头:真实服务器会校验来源
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
};
// 命令名(入口层已从源码确证;ID 可跨方向复用,故按方向区分)
const CMD_NAME = {
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' },
s2c: { 2: 'Hash Response', 32: 'Open Space' },
};
// ============================================================
// 帧 / nullmap / 密码
// ============================================================
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,
Compressed: (B0 & 0x40) !== 0,
};
}
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Compressed: (B0 & 0x40) !== 0 };
}
// 拆出一整串帧;有任何残余则视为「不是帧」返回 null
function ParseFrames(Buf) {
const Frames = [];
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf.slice(Off));
if (!Info || Off + Info.TotalSize > Buf.length) return null;
Frames.push({ Body: Buf.slice(Off + Info.HeaderSize, Off + Info.TotalSize), Compressed: Info.Compressed });
Off += Info.TotalSize;
}
return Frames.length ? Frames : null;
}
// 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);
}
// ============================================================
// 上行命令识别(c2s 空间帧)
// ============================================================
// 帧体 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…](出处见文件头注释)
// 逐条记录查 names/uplink_table.json;负载字段名优先取 codec_fields.json(按 methodId 反查 codec)
// 返回 null 表示这条帧不是空间命令(如握手帧、保活帧)
// 首字节是 objectId 的最高字节(实测 0x00 = 小正数 id,0xff = 负数 id);握手/日志帧的首字节是命令 id
// (1=Hash Request、3=Auth、32=ClientLog),且 At+8 处必须命中上行命令表,两道门一起挡住误判。
function SpaceMethodId(Body, At) {
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return null;
if (At + 16 > Body.length) return null;
const Hex = Body.slice(At + 8, At + 16).toString('hex');
return UplinkByHex.has(Hex) ? Body.readBigInt64BE(At + 8).toString() : null;
}
function DescribeUplink(Body, At) {
if (!SpaceMethodId(Body, At)) return null;
const ObjectId = Body.readBigInt64BE(At).toString();
const Records = [];
let Off = At + 8;
while (Off + 8 <= Body.length) {
const Hex = Body.slice(Off, Off + 8).toString('hex');
const Cmd = UplinkByHex.get(Hex);
if (!Cmd) break;
const MethodId = Body.readBigInt64BE(Off).toString();
Off += 8;
const Codec = Tables.CodecByMethod.get(MethodId);
const Fields = Codec
? Codec.Fields
: (Cmd.Fields || []).map((F) => ({ Name: F.Field, Type: F.Type, TypeCodec: F.TypeCodec || '' }));
const Values = [];
let Bad = false;
for (const F of Fields) {
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
Values.push([F.Name || '(匿名)', F.Type, R.Value]);
if (R.Bad || R.Next <= Off) {
Bad = true;
break;
}
Off = R.Next;
}
Records.push({
MethodId,
Name: (Cmd.OwnerName || Cmd.OwnerClass || '?') + '.' + (Cmd.SendFn || '?'),
Module: Cmd.Module,
Values,
Bad,
});
}
if (!Records.length) return null;
return { ObjectId, Records, Next: Off };
}
// 把 DescribeUplink 的结果渲染成日志行
function FormatUplink(Info, Body) {
const Lines = [' 上行 objectId=' + Info.ObjectId + ' 命令 ' + Info.Records.length + ' 条'];
for (const R of Info.Records) {
Lines.push(' ' + R.Name + ' (' + R.Module + ') methodId=' + R.MethodId);
for (const [K, T, V] of R.Values)
Lines.push(' ' + String(K).padEnd(22) + ' ' + String(T).padEnd(14) + ' = ' + FormatValue(V));
if (R.Bad) Lines.push(' <负载解不动,剩余原始字节: ' + Hex(Body.slice(Info.Next), 32) + '>');
}
return Lines;
}
// 自变体 XOR 流密码(module_61860 类 ir)
class Cipher {
constructor(K, Encoded) {
this.Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) this.Slot[I] = ((K ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
this.Idx = 0;
}
Apply(Buf) {
const Out = Buffer.alloc(Buf.length);
for (let I = 0; I < Buf.length; I++) {
const Plain = (Buf[I] ^ this.Slot[this.Idx]) & 0xff;
Out[I] = Plain;
this.Slot[this.Idx] = Plain;
this.Idx = this.Idx ^ (Plain & 7);
}
return Out;
}
static Series(Window, K, Encoded) {
const C = new Cipher(K, Encoded);
return Window.map((Buf) => C.Apply(Buf));
}
}
function Hex(Buf, N) {
return Buf.slice(0, N || 32).toString('hex');
}
function Ascii(Buf, N) {
return Array.from(Buf.slice(0, N || 40))
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
.join('');
}
// 变长整数长度前缀的字符串(x7g 家族),返回 { Str, Next }
function ReadLogString(Buf, Offset) {
let Len = Buf[Offset];
let Next = Offset + 1;
if (Len & 0x80) {
if (Len & 0x40) {
Len = (((Len & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]) >>> 0;
Next = Offset + 3;
} else {
Len = ((Len & 0x3f) << 8) | Buf[Offset + 1];
Next = Offset + 2;
}
}
return { Str: Buf.slice(Next, Next + Len).toString('utf8'), Next: Next + Len };
}
// ============================================================
// 单向流解码器:明文 → 探测密钥 → 实时解密
// ============================================================
class StreamDecoder {
constructor(State, Direction) {
this.State = State;
this.Direction = Direction;
this.Encoded = Direction === 'c2s'; // 上传方向密钥表再 ^87
this.Mode = 'plain';
this.Cipher = null;
this.Key = null;
this.Hint = null; // 对向方向破解出的密钥
this.Window = [];
}
Feed(Buf) {
if (this.Mode === 'cipher') {
this.Report(this.Cipher.Apply(Buf), true);
return;
}
if (this.Mode === 'plain') {
if (ParseFrames(Buf)) {
this.Report(Buf, false);
return;
}
// 明文解析失败 → 加密从这里开始
this.Mode = 'probing';
this.Window = [Buf];
this.State.Log('[' + this.Direction + '] 明文段结束,开始穷举密钥(首条密文 ' + Buf.length + ' 字节)');
return;
}
// probing:累积窗口,直到密钥唯一确定或放弃
// 先试对向密钥:命中即当场确定,省掉穷举等待
if (this.Hint !== null && StreamDecoder.SeriesOk(this.Window, this.Hint, this.Encoded)) {
this.State.Log('[' + this.Direction + '] 沿用对向密钥 K=' + this.Hint);
this.Key = this.Hint;
this.Cipher = new Cipher(this.Hint, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
this.Window.push(Buf);
const Bytes = this.Window.reduce((A, B) => A + B.length, 0);
const Hits = [];
for (let K = 0; K < 256; K++) {
if (StreamDecoder.SeriesOk(this.Window, K, this.Encoded)) Hits.push(K);
}
if (Hits.length === 1) {
const K = Hits[0];
this.State.Log('[' + this.Direction + '] 密钥已破解 K=' + K + '(窗口 ' + this.Window.length + ' 条 / ' + Bytes + ' 字节)');
this.Key = K;
this.Cipher = new Cipher(K, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
if (this.Window.length >= 8 || Bytes > 262144) {
this.State.Log('[' + this.Direction + '] 密钥探测失败(候选 ' + Hits.length + ' 个),后续按原始字节记录');
this.Mode = 'plain';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(B, false);
}
}
// 候选密钥:窗口内每条消息解密后都必须是完整帧
static SeriesOk(Window, K, Encoded) {
for (const Buf of Cipher.Series(Window, K, Encoded)) {
if (!ParseFrames(Buf)) return false;
}
return true;
}
Report(Buf, Decrypted) {
const Tag = Decrypted ? '[密] ' : '[明] ';
const Frames = ParseFrames(Buf);
if (!Frames) {
this.State.Log(Tag + '非帧数据 ' + Buf.length + ' 字节: ' + Hex(Buf, 32));
return;
}
for (const Frame of Frames) {
let Body = Frame.Body;
let Note = Frame.Compressed ? ' [zlib…]' : '';
if (Frame.Compressed) {
try {
Body = zlib.inflateSync(Body);
Note = ' [zlib ' + Frame.Body.length + '→' + Body.length + ']';
} catch (Err) {
Note = ' [zlib 解压失败]';
}
}
const At = NullmapSize(Body, 0);
if (At >= Body.length) {
this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note);
continue;
}
// 上行空间帧没有「命令 id」字节层:帧体直接是 SpaceCommand(objectId, methodId, 负载)
const Up = this.Direction === 'c2s' && ShowUplink ? DescribeUplink(Body, At) : null;
if (Up) {
this.State.Log(Tag + '空间命令帧 ' + (Body.length - At) + 'B' + Note);
for (const L of FormatUplink(Up, Body)) this.State.Log(L);
continue;
}
const Id = Body[At];
const Payload = Body.slice(At + 1, At + 41);
const Name = (CMD_NAME[this.Direction] && CMD_NAME[this.Direction][Id]) || '未知';
const Extra = this.Direction === 'c2s' && Id === 32 ? ' ' + StreamDecoder.DescribeClientLog(Body.slice(At + 1)) : '';
this.State.Log(
Tag + '命令 ID=' + String(Id).padStart(3) + ' (' + Name + ') 负载=' + (Body.length - At - 1) + 'B' + Note + Extra,
);
this.State.Log(' ' + Payload.toString('hex') + ' |' + Ascii(Payload) + '|');
}
}
// ClientLog body = [LogEntryType][string][string]
static DescribeClientLog(Buf) {
try {
const Type = Buf[0];
const S1 = ReadLogString(Buf, 1);
const S2 = ReadLogString(Buf, S1.Next);
return '类型=' + Type + ' ' + JSON.stringify(S1.Str) + ' ' + JSON.stringify(S2.Str);
} catch (Err) {
return '';
}
}
}
// ============================================================
// 会话与落盘
// ============================================================
fs.mkdirSync(PACKET_DIR, { recursive: true });
function NewSession() {
const Used = fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
.map((D) => Number(D.name.slice('session_'.length)))
.filter((N) => !Number.isNaN(N));
const Next = (Used.length ? Math.max.apply(null, Used) : 0) + 1;
const Dir = path.join(PACKET_DIR, 'session_' + String(Next).padStart(3, '0'));
fs.mkdirSync(Dir, { recursive: true });
const State = { Dir, Stream: fs.createWriteStream(path.join(Dir, 'log.txt'), { flags: 'w' }), C2s: 0, S2c: 0 };
State.Log = (Msg) => {
const Line = '[' + new Date().toISOString() + '] ' + Msg;
console.log(Line);
State.Stream.write(Line + '\n');
};
return State;
}
// 原始字节落盘 + 记录顺序(解析交给 StreamDecoder)
function SaveFrame(State, Direction, Buf, Note) {
const Seq = Direction === 'c2s' ? State.C2s++ : State.S2c++;
fs.writeFileSync(path.join(State.Dir, Direction + '_' + String(Seq).padStart(4, '0') + '.bin'), Buf);
State.Log(
(Direction === 'c2s' ? '>>>' : '<<<') + ' ' + Direction + '_' + String(Seq).padStart(4, '0') + '.bin ' +
Buf.length + ' 字节' + (Note ? ' ' + Note : ''),
);
State.Stream.write(Buf.toString('hex') + '\n');
}
// ============================================================
// 服务端
// ============================================================
const Server = http.createServer((Req, Res) => {
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
});
// 两个方向的 K 必然相同(一次构造展开两套密钥表),一侧破解后提示另一侧
function SyncKeys(Decoders) {
if (Decoders.s2c.Key !== null && Decoders.c2s.Hint === null) Decoders.c2s.Hint = Decoders.s2c.Key;
if (Decoders.c2s.Key !== null && Decoders.s2c.Hint === null) Decoders.s2c.Hint = Decoders.c2s.Key;
}
const WsServer = new Ws.Server({ server: Server });
WsServer.on('connection', (Client, Req) => {
const State = NewSession();
State.Log('=== 新客户端 ===');
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
State.Log('模式: relay 上游: ' + UPSTREAM);
const Decoders = { c2s: new StreamDecoder(State, 'c2s'), s2c: new StreamDecoder(State, 's2c') };
let Upstream = null;
const Pending = []; // 上游未就绪时暂存(握手帧常比上游 TLS 建连更早到达)
Upstream = new Ws(UPSTREAM, { headers: UPSTREAM_HEADERS, rejectUnauthorized: false });
Upstream.binaryType = 'nodebuffer';
Upstream.on('open', () => {
State.Log('上游已连接: ' + UPSTREAM + ',补发暂存 ' + Pending.length + ' 帧');
while (Pending.length) Upstream.send(Pending.shift());
});
Upstream.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 's2c', Buf);
Decoders.s2c.Feed(Buf);
SyncKeys(Decoders);
if (Client.readyState === Ws.OPEN) Client.send(Buf);
});
Upstream.on('close', (Code) => {
State.Log('上游关闭 code=' + Code);
Client.close();
});
Upstream.on('error', (Err) => State.Log('上游错误: ' + Err.message));
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 'c2s', Buf);
Decoders.c2s.Feed(Buf);
SyncKeys(Decoders);
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.send(Buf);
else {
State.Log('上游尚未就绪,暂存 ' + Buf.length + ' 字节待补发');
Pending.push(Buf);
}
});
Client.on('close', (Code) => {
State.Log('客户端断开 code=' + Code + ' 收 ' + State.C2s + ' 条 / 发 ' + State.S2c + ' 条');
State.Stream.end();
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.close();
});
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
});
Server.listen(PORT, () => {
console.log('游戏通信服务器已启动: ws://localhost:' + PORT + '/');
console.log(' 模式 : relay → ' + UPSTREAM);
console.log(' 抓包目录: ' + PACKET_DIR);
console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
});