// 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);