Files
TermiNexus 90c377ad79 feat(3dtank): 本地镜像与 hornet 工具链迁到新版 bundle main.6469b966.js
旧 main.b7114b43.js 已被新构建替换(10,011,039 字节 / 1335 模块),本地镜像整体重切到新版并清掉旧产物;hornet 工具链按 tools/map_symbols.js 生成的新旧符号映射改名后重新打包(3dtank_bundle.js 与 unpacked/hornet_bundle.js 同步)。tools/symbol_map.json(8.9MB 映射产物)与切割解包目录不进库,可用 map_symbols.js / split.js 重新生成。
2026-09-17 19:59:40 +08:00

586 lines
18 KiB
JavaScript

// ============================================================
// 3DTank WebSocket 伪造客户端
// 手动构造 Hash Request 并发送,捕获服务器响应
// 用法: node forge.js [session_name]
// ============================================================
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
// ============================================================
// 协议编码器
// ============================================================
// 变长整数编码 (对应 g7g)
// n < 0x80: 1 byte
// n < 0x4000: 2 bytes [0x80|(n>>8), n&0xFF]
// n < 0x400000: 3 bytes [0xC0|(n>>16), (n>>8)&0xFF, n&0xFF]
function encodeVarint(n) {
if (n < 0x80) {
return Buffer.from([n & 0x7F]);
} else if (n < 0x4000) {
const i = 0x8000 | (n & 0x3FFF);
return Buffer.from([(i >> 8) & 0xFF, i & 0xFF]);
} else {
const r = 0xC00000 | (n & 0x3FFFFF);
return Buffer.from([(r >> 16) & 0xFF, (r >> 8) & 0xFF, r & 0xFF]);
}
}
// 变长整数解码 (对应 h7g)
function decodeVarint(buf, offset) {
const b0 = buf[offset];
if ((b0 & 0x80) === 0) {
return { value: b0 & 0x7F, nextOffset: offset + 1 };
}
const b1 = buf[offset + 1];
if ((b0 & 0x40) === 0) {
// 2 bytes
return { value: ((b0 & 0x3F) << 8) | b1, nextOffset: offset + 2 };
}
// 3 bytes
const b2 = buf[offset + 2];
return { value: ((b0 & 0x3F) << 16) | (b1 << 8) | b2, nextOffset: offset + 3 };
}
// UTF-8 字符串编码 (对应 Qt.c7g)
function encodeString(str) {
const utf8 = Buffer.from(str, 'utf8');
return Buffer.concat([encodeVarint(utf8.length), utf8]);
}
// 字符串数组编码 (对应 Wt.c7g)
function encodeStringArray(arr) {
const parts = [encodeVarint(arr.length)];
for (const s of arr) {
parts.push(encodeString(s));
}
return Buffer.concat(parts);
}
// 命令 ID 编码 (对应 nn.c7g — 1 字节 int8)
// 注意: 命令 ID 在此协议中是 1 字节, 不是 8 字节 int64!
function encodeCommandId(cmdId) {
return Buffer.from([cmdId & 0xFF]);
}
// Nullmap 编码 (对应 w7g)
// 对于 0 个可选字段: 1 byte = 0x00
function encodeNullmap(bitCount, bitmap) {
if (bitCount === 0) {
return Buffer.from([0x00]);
}
if (bitCount <= 5) {
// 1 byte: bits 在高位 (bit 3-7)
const byte0 = bitmap[0] >>> 3;
return Buffer.from([byte0 & 0xFF]);
}
if (bitCount <= 13) {
// 2 bytes: flag 0x20 in first byte
const b0 = ((bitmap[0] >>> 3) + 0x20) & 0xFF;
const b1 = ((bitmap[1] >>> 3) + ((bitmap[0] << 5) & 0xFF)) & 0xFF;
return Buffer.from([b0, b1]);
}
if (bitCount <= 21) {
// 3 bytes: flag 0x40 in first byte
const b0 = ((bitmap[0] >>> 3) + 0x40) & 0xFF;
const b1 = ((bitmap[1] >>> 3) + ((bitmap[0] << 5) & 0xFF)) & 0xFF;
const b2 = ((bitmap[2] >>> 3) + ((bitmap[1] << 5) & 0xFF)) & 0xFF;
return Buffer.from([b0, b1, b2]);
}
// 对于更大的 nullmap, 使用扩展格式
const byteCount = Math.ceil(bitCount / 8);
if (bitCount <= 504) {
// 1 length byte (with 0x80 flag) + data
const lenByte = (byteCount + 0x80) & 0xFF;
return Buffer.concat([Buffer.from([lenByte]), Buffer.from(bitmap.slice(0, byteCount))]);
}
// 3 length bytes (with 0xC00000 flag) + data
const lenVal = byteCount + 0xC00000;
return Buffer.concat([
Buffer.from([(lenVal >> 16) & 0xFF, (lenVal >> 8) & 0xFF, lenVal & 0xFF]),
Buffer.from(bitmap.slice(0, byteCount))
]);
}
// 帧编码 (对应 e7f)
// 帧 = [2或4字节长度(BE)][nullmap][payload]
function encodeFrame(payload, nullmap) {
const length = nullmap.length + payload.length;
if (length < 0x4000) {
// 2-byte format
return Buffer.concat([
Buffer.from([(length >> 8) & 0xFF, length & 0xFF]),
nullmap,
payload
]);
} else {
// 4-byte format with 0x80 flag
return Buffer.concat([
Buffer.from([
((length >>> 24) & 0xFF) | 0x80,
(length >>> 16) & 0xFF,
(length >>> 8) & 0xFF,
length & 0xFF
]),
nullmap,
payload
]);
}
}
// 帧解码 (对应 f7f 的读取逻辑)
function decodeFrame(buf) {
if (buf.length < 2) return null;
const b0 = buf[0];
let frameLength;
let headerSize;
let compressed = false;
if ((b0 & 0x80) !== 0) {
// 4-byte format
if (buf.length < 4) return null;
const b1 = buf[1], b2 = buf[2], b3 = buf[3];
frameLength = (((b0 & 0x7F) << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
if (b0 & 0x40) compressed = true; // compression flag
headerSize = 4;
} else {
// 2-byte format
const b1 = buf[1];
frameLength = ((b0 & 0x3F) << 8) | b1;
if (b0 & 0x40) compressed = true;
headerSize = 2;
}
const totalFrameSize = headerSize + frameLength;
if (buf.length < totalFrameSize) return null;
const frameData = buf.slice(headerSize, totalFrameSize);
return { frameData, headerSize, frameLength, compressed, totalFrameSize };
}
// 解码 nullmap (对应 x7g)
function decodeNullmap(buf, offset) {
if (offset >= buf.length) return { nullmap: null, nextOffset: offset };
const r = buf[offset++];
if ((r & 0x80) !== 0) {
// 大格式: bit 7 = 1
let size;
if ((r & 0x40) !== 0) {
// 3-byte size
const b1 = buf[offset++];
const b2 = buf[offset++];
size = (((r & 0x3F) << 16) | (b1 << 8) | b2) >>> 0;
} else {
// 1-byte size
size = r & 0x3F;
}
// 读取 size 字节的 nullmap 数据
const data = buf.slice(offset, offset + size);
offset += size;
return { nullmap: data, bitCount: size * 8, nextOffset: offset };
}
// 小格式: bit 7 = 0
const tier = (r & 0x60) >>> 5;
let bitCount;
let data;
switch (tier) {
case 0: // 5 bits, 1 byte
bitCount = 5;
data = Buffer.from([r << 3]); // 移位到高位
break;
case 1: { // 13 bits, 2 bytes
bitCount = 13;
const b1 = buf[offset++];
data = Buffer.from([
(r + (b1 >>> 5)) & 0xFF,
(b1 << 3) & 0xFF
]);
break;
}
case 2: { // 21 bits, 3 bytes
bitCount = 21;
const b1 = buf[offset++];
const b2 = buf[offset++];
data = Buffer.from([
(r + (b1 >>> 5)) & 0xFF,
((b1 << 3) + (b2 >>> 5)) & 0xFF,
(b2 << 3) & 0xFF
]);
break;
}
case 3: { // 29 bits, 4 bytes
bitCount = 29;
const b1 = buf[offset++];
const b2 = buf[offset++];
const b3 = buf[offset++];
data = Buffer.from([
(r + (b1 >>> 5)) & 0xFF,
((b1 << 3) + (b2 >>> 5)) & 0xFF,
((b2 << 3) + (b3 >>> 5)) & 0xFF,
(b3 << 3) & 0xFF
]);
break;
}
}
return { nullmap: data, bitCount, nextOffset: offset };
}
// ============================================================
// Hash Request 构造
// ============================================================
// 命令 ID 常量 (对应 w.p7j_1 等)
const CMD_HASH_REQUEST = 1;
const CMD_HASH_RESPONSE = 2;
const CMD_OPEN_SPACE = 32;
const CMD_CLIENT_LOG = 32;
// 构造 Hash Request payload (对应 or.c7g + Li.c7g)
function buildHashRequest(paramNames, paramValues) {
// 1. Command ID (1 字节) = CMD_HASH_REQUEST
const cmdId = encodeCommandId(CMD_HASH_REQUEST);
// 2. paramNames 字符串数组
const names = encodeStringArray(paramNames);
// 3. paramValues 字符串数组
const values = encodeStringArray(paramValues);
// nullmap: 0 个可选字段 → 1 byte 0x00
const nullmap = encodeNullmap(0, null);
const payload = Buffer.concat([cmdId, names, values]);
return { payload, nullmap };
}
// 构造 ClientLog 命令 (对应 Fi.c7g)
// ClientLog = Bi(LogEntryType, string, string)
// 编码: [cmdId=32][logEntryType.ordinal(1字节)][string1][string2]
function buildClientLog(logType, str1, str2) {
const cmdId = encodeCommandId(CMD_CLIENT_LOG); // 1 byte = 32
const typeByte = Buffer.from([logType & 0xFF]); // LogEntryType ordinal
const s1 = encodeString(str1);
const s2 = encodeString(str2);
const nullmap = encodeNullmap(0, null); // 无可空字段
const payload = Buffer.concat([cmdId, typeByte, s1, s2]);
return { payload, nullmap };
}
// 默认 Hash Request 参数 (从 module_61860.js 提取)
function getDefaultParams() {
return [
['lang', 'zh-CN'],
['browser_user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'],
['os', 'Win32'],
['device_type', 'DESKTOP'],
['client_type', 'DESKTOP'],
['hardware_concurrency', '8'],
['time_zone', 'Asia/Shanghai'],
['video_card', 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)'],
['mobile_device', 'false'],
['touchscreen_support', 'false'],
['client_version', 'e3b4a3f1c8b8ea24cddc91a30e51b6903d55c0c7'],
['ram_size', '16'],
];
}
// ============================================================
// 工具函数
// ============================================================
function hexDump(buffer, prefix = '') {
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const lines = [];
for (let i = 0; i < bytes.length; i += 16) {
const chunk = bytes.slice(i, Math.min(i + 16, bytes.length));
const hex = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ');
const ascii = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join('');
lines.push(`${prefix}${i.toString(16).padStart(8, '0')} ${hex.padEnd(48)} ${ascii}`);
}
return lines.join('\n');
}
// 尝试解码响应帧
function tryDecodeResponse(buf) {
const frame = decodeFrame(buf);
if (!frame) return null;
const result = {
frameLength: frame.frameLength,
headerSize: frame.headerSize,
compressed: frame.compressed,
nullmap: null,
payload: null,
commands: []
};
let offset = 0;
const frameData = frame.frameData;
// 解码 nullmap
const nm = decodeNullmap(frameData, offset);
offset = nm.nextOffset;
result.nullmap = nm.nullmap;
// 剩余 = payload (可能包含多个命令)
const payload = frameData.slice(offset);
result.payload = payload;
// 解码命令 (命令 ID = 1 字节, 然后是 body)
let pOffset = 0;
const cmdNames = { 1: 'Hash Request', 2: 'Hash Response', 32: 'Open Space' };
while (pOffset < payload.length) {
const cmdStart = pOffset;
const cmdId = payload[pOffset++]; // 1 字节命令 ID
const cmdInfo = { offset: cmdStart, commandId: cmdId, name: cmdNames[cmdId] || '未知', body: null };
if (cmdId === 2) {
// Hash Response: 32 字节 hash + 1 字节 boolean
if (pOffset + 33 <= payload.length) {
const hash = payload.slice(pOffset, pOffset + 32);
const boolean = payload[pOffset + 32];
cmdInfo.body = {
hash: hash.toString('hex'),
hashBytes: Array.from(hash),
boolean: boolean !== 0
};
pOffset += 33;
} else {
cmdInfo.body = { error: 'Hash Response body 不完整', remaining: payload.length - pOffset };
break;
}
} else if (cmdId === 32) {
// Open Space: 8 字节 64位 Long (BIG_ENDIAN) = Space ID
// 对应 Xt.d7g → n2t(): W(getInt32(n, BE), getInt32(n+4, BE)) = W(high, low)
if (pOffset + 8 <= payload.length) {
const spaceIdBuf = payload.slice(pOffset, pOffset + 8);
// BIG_ENDIAN: high 在前, low 在后
const high = spaceIdBuf.readInt32BE(0);
const low = spaceIdBuf.readUInt32BE(4);
// 组合成 64 位值 (JavaScript 无原生 int64, 用字符串表示)
const spaceIdStr = (high * 0x100000000 + low).toString();
cmdInfo.body = {
rawHex: spaceIdBuf.toString('hex'),
spaceIdHigh: high,
spaceIdLow: low,
spaceId: spaceIdStr
};
pOffset += 8;
} else {
cmdInfo.body = { error: 'Open Space body 不完整', remaining: payload.length - pOffset };
break;
}
} else {
// 未知命令, 记录剩余字节
const bodyBytes = payload.slice(pOffset);
cmdInfo.body = {
rawHex: bodyBytes.toString('hex'),
rawLength: bodyBytes.length
};
pOffset = payload.length;
}
result.commands.push(cmdInfo);
}
return result;
}
// ============================================================
// 主程序
// ============================================================
const sessionName = process.argv[2] || `forge_${Date.now()}`;
const sessionDir = path.join(__dirname, sessionName);
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true });
}
const logFile = path.join(sessionDir, 'log.txt');
const logStream = fs.createWriteStream(logFile, { flags: 'w' });
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}`;
console.log(line);
logStream.write(line + '\n');
}
function saveBinary(data, direction, seq) {
const filename = `${direction}_${seq.toString().padStart(4, '0')}.bin`;
const filepath = path.join(sessionDir, filename);
fs.writeFileSync(filepath, data);
return filename;
}
// 构造 Hash Request
const params = getDefaultParams();
const paramNames = params.map(p => p[0]);
const paramValues = params.map(p => p[1]);
log('=== 3DTank WebSocket 伪造客户端 ===');
log(`会话: ${sessionName}`);
log(`目录: ${sessionDir}`);
log('');
log('--- Hash Request 参数 ---');
params.forEach(([k, v]) => log(` ${k} = ${v.length > 60 ? v.slice(0, 57) + '...' : v}`));
log('');
const { payload, nullmap } = buildHashRequest(paramNames, paramValues);
const frame = encodeFrame(payload, nullmap);
log('--- 编码结果 ---');
log(`Payload 长度: ${payload.length} 字节`);
log(`Nullmap: ${nullmap.toString('hex')} (${nullmap.length} 字节)`);
log(`Frame 总长: ${frame.length} 字节`);
log('');
log('Frame 十六进制:');
log(hexDump(frame));
log('');
// 连接参数
// 默认连真实服务器;加 --url ws://localhost:9091/ 可改为连本地中继/模拟服务器
const url = (() => {
const I = process.argv.indexOf('--url');
return I >= 0 && process.argv[I + 1] ? process.argv[I + 1] : 'wss://c1.3dtank.com:9090/';
})();
let recvSeq = 0;
let sendSeq = 0;
let hashRequestSent = false;
let clientLogSent = false;
log(`正在连接 ${url} ...`);
const ws = new WebSocket(url, {
rejectUnauthorized: false,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Origin': 'https://3dtank.com'
}
});
ws.binaryType = 'arraybuffer';
ws.on('open', function open() {
log('✓ 连接成功!');
log('');
// 发送 Hash Request
log('>>> 发送 Hash Request...');
const fname = saveBinary(frame, 'send', sendSeq++);
log(` 已保存: ${fname}`);
log(` ${frame.length} 字节:`);
log(hexDump(frame, ' '));
log('');
ws.send(frame, { binary: true, mask: true }, function(err) {
if (err) {
log(`✗ 发送失败: ${err.message}`);
} else {
hashRequestSent = true;
log('✓ Hash Request 已发送');
log('');
}
});
});
ws.on('message', function incoming(data) {
const buf = Buffer.from(data);
const fname = saveBinary(buf, 'recv', recvSeq++);
log(`<<< 接收消息 #${recvSeq - 1} (${buf.length} 字节) -> ${fname}`);
log(hexDump(buf, ' '));
log('');
// 尝试解码帧
const decoded = tryDecodeResponse(buf);
if (decoded) {
log(` 帧解码:`);
log(` 头部长度: ${decoded.headerSize} 字节`);
log(` 帧体长度: ${decoded.frameLength} 字节`);
log(` 压缩: ${decoded.compressed}`);
log(` Nullmap: ${decoded.nullmap ? decoded.nullmap.toString('hex') : 'null'} (${decoded.nullmap ? decoded.nullmap.length : 0} 字节)`);
log(` Payload 长度: ${decoded.payload ? decoded.payload.length : 0} 字节`);
if (decoded.commands && decoded.commands.length > 0) {
log(` 命令 (${decoded.commands.length} 个):`);
decoded.commands.forEach((cmd, i) => {
log(` [${i}] offset=${cmd.offset} ID=${cmd.commandId} (${cmd.name})`);
if (cmd.body) {
if (cmd.body.hash) {
log(` hash: ${cmd.body.hash}`);
log(` boolean: ${cmd.body.boolean}`);
} else if (cmd.body.spaceId !== undefined) {
log(` spaceId (BE int64): ${cmd.body.spaceId}`);
log(` high=${cmd.body.spaceIdHigh}, low=${cmd.body.spaceIdLow}`);
log(` raw: ${cmd.body.rawHex}`);
} else if (cmd.body.rawHex) {
log(` body (${cmd.body.rawLength} 字节): ${cmd.body.rawHex}`);
} else if (cmd.body.error) {
log(` 错误: ${cmd.body.error}`);
}
}
});
}
// 空 payload = ping/keepalive, 回复 pong (相同的空帧 00 01 00)
if (decoded.payload && decoded.payload.length === 0) {
const pong = Buffer.from([0x00, 0x01, 0x00]);
const pongFname = saveBinary(pong, 'send', sendSeq++);
log(`>>> 回复 Pong (ping 保持连接) -> ${pongFname}`);
ws.send(pong, { binary: true, mask: true });
}
// 收到 Open Space 后发送 ClientLog (测试是否能保持连接)
if (decoded.commands) {
const hasOpenSpace = decoded.commands.some(c => c.commandId === 32 && c.body && c.body.spaceId !== undefined);
if (hasOpenSpace && !clientLogSent) {
clientLogSent = true;
const { payload: clPayload, nullmap: clNullmap } = buildClientLog(0, 'client_ready', 'forge_test');
const clFrame = encodeFrame(clPayload, clNullmap);
const clFname = saveBinary(clFrame, 'send', sendSeq++);
log(`>>> 发送 ClientLog (测试保活) -> ${clFname}`);
log(` ${clFrame.length} 字节: ${clFrame.toString('hex')}`);
ws.send(clFrame, { binary: true, mask: true });
}
}
}
log('');
});
ws.on('close', function close(code, reason) {
log('---');
log(`✗ 连接关闭 (code=${code})`);
if (reason && reason.length > 0) {
log(` 原因: ${reason.toString()}`);
}
log(` Hash Request 已发送: ${hashRequestSent}`);
log(` 总共接收: ${recvSeq} 条消息`);
log(` 总共发送: ${sendSeq} 条消息`);
logStream.end();
});
ws.on('error', function error(err) {
log(`✗ 错误: ${err.message}`);
if (err.stack) log(err.stack);
// 不在这里 end logStream, 让 close 事件处理
});
// 优雅退出
process.on('SIGINT', function() {
log('---');
log('用户中断,正在关闭...');
if (ws.readyState === WebSocket.OPEN) ws.close();
logStream.end();
process.exit(0);
});
// 超时退出 (5 分钟)
setTimeout(() => {
log('---');
log('超时自动退出 (5 分钟)');
if (ws.readyState === WebSocket.OPEN) ws.close();
logStream.end();
process.exit(0);
}, 5 * 60 * 1000);