- Solve 只按第一个可用 Start 提前返回,实测 mine-01/session_001 的 59 条 c2s 全是明文整帧, 却被巧合的 K=31 判成「加密段只切出 1 帧」,只 dump 出 13 条(还多出一个解不开的假帧) - 加两条护栏:① 剩余消息自身都是完整帧时不再尝试解密;② 多个候选取 Start 最大的那个 - 加覆盖度自检:解出的帧没覆盖到每条 WS 消息就在 summary 里显式报 ⚠ 重跑 mine-01 入库:回放库 7 个目录 → 18 个,帧数 13621 全量为整帧、 33 个压缩帧全部解压成功(原先 session_001 的假帧已消失)
202 lines
8.0 KiB
JavaScript
202 lines
8.0 KiB
JavaScript
// 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);
|