fix(3dtank): dump_plain 静默丢帧:明文尾部被巧合密钥当成加密段

- Solve 只按第一个可用 Start 提前返回,实测 mine-01/session_001 的 59 条 c2s 全是明文整帧,
  却被巧合的 K=31 判成「加密段只切出 1 帧」,只 dump 出 13 条(还多出一个解不开的假帧)
- 加两条护栏:① 剩余消息自身都是完整帧时不再尝试解密;② 多个候选取 Start 最大的那个
- 加覆盖度自检:解出的帧没覆盖到每条 WS 消息就在 summary 里显式报 ⚠

重跑 mine-01 入库:回放库 7 个目录 → 18 个,帧数 13621 全量为整帧、
33 个压缩帧全部解压成功(原先 session_001 的假帧已消失)
This commit is contained in:
TermiNexus committed 2026-09-16 17:35:55 +08:00
1 parent 8d2aa5566d
commit de3dbbc097
1 file changed
+201
+201
View File
@@ -0,0 +1,201 @@
// dump_plain.js - 把某个会话抓包解密成「明文帧」,供 fake 模式回放
// 用法: node dump_plain.js --session ../../packets/relay-04/session_005 [--out <目录>]
// 原理:
// 连接前半段是明文帧(可直接用),后半段是自变体 XOR 流密码加密帧;
// 密钥只有 1 字节,直接穷举 256 种,要求「该消息之后每条解密后都是完整帧」。
// 产物: <out>/<方向>_<序号>.bin(明文帧)+ <out>/summary.txt
const fs = require('fs');
const path = require('path');
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 SessionDir = path.resolve(Arg('session', ''));
const OutDir = path.resolve(Arg('out', path.join(SessionDir, 'plain')));
const CMD_NAME = {
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' },
s2c: { 0: 'SpaceState(待证)', 2: 'Hash Response', 32: 'Open Space' },
};
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 };
}
function IsFrames(Buf) {
if (!Buf.length) return false;
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf.slice(Off));
if (!Info || Off + Info.TotalSize > Buf.length) return false;
Off += Info.TotalSize;
}
return true;
}
// 把一段连续字节流切成一帧帧;有任何残余则返回 null
function SplitFrames(Buf) {
const Out = [];
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf.slice(Off));
if (!Info || Off + Info.TotalSize > Buf.length) return null;
Out.push(Buf.slice(Off, Off + Info.TotalSize));
Off += Info.TotalSize;
}
return Out.length ? Out : null;
}
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);
}
function CipherApply(Bufs, K, Encoded) {
const Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) Slot[I] = ((K ^ (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;
});
}
// 返回 { Start, K, Frames:[{Buf, Msg}] }
// Msg = 该帧首字节落在第几条 WS 消息上(把帧映射回抓包时刻,供时间线回放)
// 注意:大帧会被拆到多条 WS 消息里,所以加密段要「拼接成流」再判帧
//
// 两个必须的护栏(否则会静默丢帧,实测 mine-01/session_001 59 条 c2s 全是明文整帧,
// 却被一个巧合的 K=31 判成「加密段只切出 1 帧」,结果只 dump 出 13 条):
// ① 若剩余部分的每条消息自身就是完整帧,就不再尝试解密(它们本来就是明文)→ 跳过这个 Start;
// ② 多个候选起点都能解时取 Start 最大的那个(尽可能按明文处理;全明文会话会命中 Start = 消息数)。
function Solve(Bufs, Encoded) {
let Best = null;
for (let Start = 0; Start <= Bufs.length; Start++) {
let Ok = true;
for (let I = 0; I < Start; I++) if (!IsFrames(Bufs[I])) Ok = false;
if (!Ok) continue;
const Prefix = [];
for (let I = 0; I < Start; I++) for (const F of SplitFrames(Bufs[I]) || []) Prefix.push({ Buf: F, Msg: I });
if (Start === Bufs.length) {
if (!Best || Start > Best.Start) Best = { Start, K: null, Frames: Prefix };
continue;
}
const Rest = Bufs.slice(Start);
if (Rest.every(IsFrames)) continue; // ① 剩余部分本来就是明文,别硬解
const Hits = [];
let WinFrames = null;
for (let K = 0; K < 256; K++) {
const Decoded = CipherApply(Rest, K, Encoded);
const Fr = SplitFrames(Buffer.concat(Decoded));
if (!Fr) continue;
Hits.push(K);
if (WinFrames) continue;
// 每条消息在拼接流里的结束偏移,用于判断帧起点落在哪条消息
const Bound = [];
let Sum = 0;
for (const B of Decoded) Bound.push((Sum += B.length));
const Tagged = [];
let Off = 0;
for (const F of Fr) {
let M = 0;
while (M < Bound.length - 1 && Off >= Bound[M]) M++;
Tagged.push({ Buf: F, Msg: Start + M });
Off += F.length;
}
WinFrames = Prefix.concat(Tagged);
}
if (Hits.length === 1 && WinFrames && (!Best || Start > Best.Start)) Best = { Start, K: Hits[0], Frames: WinFrames };
}
return Best;
}
// 从 relay 的 log.txt 里取出某方向每条消息的到达时刻(毫秒);无 log 时返回空数组
function ReadMessageTimes(SessionDir, Direction) {
const LogPath = path.join(SessionDir, 'log.txt');
if (!fs.existsSync(LogPath)) return [];
const Out = [];
for (const Line of fs.readFileSync(LogPath, 'utf8').split('\n')) {
const M = /^\[([^\]]+)\]\s*(?:>>>|<<<)\s+(c2s|s2c)_(\d+)\.bin/.exec(Line.trim());
if (!M || M[2] !== Direction) continue;
Out.push({ Seq: Number(M[3]), Time: Date.parse(M[1]) });
}
Out.sort((A, B) => A.Seq - B.Seq);
return Out;
}
function Describe(Buf) {
const Info = PeekFrame(Buf);
if (!Info || Info.TotalSize !== Buf.length) return '非法帧';
const At = Info.HeaderSize + NullmapSize(Buf, Info.HeaderSize);
if (At >= Buf.length) return '保活 ping';
return '命令 ID=' + Buf[At];
}
if (!fs.existsSync(SessionDir)) {
console.log('会话目录不存在: ' + SessionDir);
process.exit(1);
}
fs.mkdirSync(OutDir, { recursive: true });
const Lines = ['来源会话: ' + SessionDir, ''];
for (const Direction of ['c2s', 's2c']) {
const Files = fs
.readdirSync(SessionDir)
.filter((F) => new RegExp('^' + Direction + '_\\d+\\.bin$').test(F))
.sort();
if (!Files.length) continue;
const Bufs = Files.map((F) => fs.readFileSync(path.join(SessionDir, F)));
const Solved = Solve(Bufs, Direction === 'c2s');
Lines.push('== ' + Direction + ' 消息 ' + Bufs.length + ' 条 ==');
if (!Solved) {
Lines.push(' 未能解出(密钥不唯一或帧不完整)');
Lines.push('');
continue;
}
Lines.push(' 明文前 ' + Solved.Start + ' 条,加密 ' + (Bufs.length - Solved.Start) + ' 条,K=' + Solved.K);
// 时间线:每条帧相对该方向首条消息的时刻(毫秒),供 fake 模式按真实节奏回放
const Times = ReadMessageTimes(SessionDir, Direction);
if (!Times.length) Lines.push(' (无 log.txt 时间戳,时间线按 150ms 估算)');
const Base = Times.length ? Times[0].Time : 0;
const Timeline = [];
Solved.Frames.forEach((Frame, I) => {
const Name = Direction + '_' + String(I).padStart(4, '0') + '.bin';
fs.writeFileSync(path.join(OutDir, Name), Frame.Buf);
const Ms = Times[Frame.Msg] ? Math.max(0, Times[Frame.Msg].Time - Base) : I * 150;
Timeline.push(Name + '\t' + Ms);
Lines.push(' ' + Name + ' ' + String(Frame.Buf.length).padStart(6) + 'B +' + Ms + 'ms ' + Describe(Frame.Buf));
});
fs.writeFileSync(path.join(OutDir, 'timeline_' + Direction + '.txt'), Timeline.join('\n') + '\n', 'utf8');
// 覆盖度自检:解出的帧必须覆盖到每一条 WS 消息,否则明确报出来(历史上这里静默丢过帧)
const Covered = new Set(Solved.Frames.map((F) => F.Msg));
if (Covered.size < Bufs.length) Lines.push(' ⚠ 只覆盖 ' + Covered.size + '/' + Bufs.length + ' 条消息,有帧没解出来');
Lines.push('');
}
fs.writeFileSync(path.join(OutDir, 'summary.txt'), Lines.join('\n'), 'utf8');
console.log(Lines.join('\n'));
console.log('\n已写出明文帧: ' + OutDir);