From de3dbbc097cf9950eb354a1575ac25ef942cc8a1 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 16 Sep 2026 17:35:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(3dtank):=20dump=5Fplain=20=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E4=B8=A2=E5=B8=A7=EF=BC=9A=E6=98=8E=E6=96=87=E5=B0=BE?= =?UTF-8?q?=E9=83=A8=E8=A2=AB=E5=B7=A7=E5=90=88=E5=AF=86=E9=92=A5=E5=BD=93?= =?UTF-8?q?=E6=88=90=E5=8A=A0=E5=AF=86=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solve 只按第一个可用 Start 提前返回,实测 mine-01/session_001 的 59 条 c2s 全是明文整帧, 却被巧合的 K=31 判成「加密段只切出 1 帧」,只 dump 出 13 条(还多出一个解不开的假帧) - 加两条护栏:① 剩余消息自身都是完整帧时不再尝试解密;② 多个候选取 Start 最大的那个 - 加覆盖度自检:解出的帧没覆盖到每条 WS 消息就在 summary 里显式报 ⚠ 重跑 mine-01 入库:回放库 7 个目录 → 18 个,帧数 13621 全量为整帧、 33 个压缩帧全部解压成功(原先 session_001 的假帧已消失) --- 3DTank/tools/dump_plain.js | 201 +++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 3DTank/tools/dump_plain.js diff --git a/3DTank/tools/dump_plain.js b/3DTank/tools/dump_plain.js new file mode 100644 index 0000000..61cb681 --- /dev/null +++ b/3DTank/tools/dump_plain.js @@ -0,0 +1,201 @@ +// dump_plain.js - 把某个会话抓包解密成「明文帧」,供 fake 模式回放 +// 用法: node dump_plain.js --session ../../packets/relay-04/session_005 [--out <目录>] +// 原理: +// 连接前半段是明文帧(可直接用),后半段是自变体 XOR 流密码加密帧; +// 密钥只有 1 字节,直接穷举 256 种,要求「该消息之后每条解密后都是完整帧」。 +// 产物: /<方向>_<序号>.bin(明文帧)+ /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);