feat(mirror): 页面内 window.WebSocket 抓包(/ws_hook.js),用于与中继转存逐帧对比

- sockets/ws_hook.js:hook window.WebSocket,按收发顺序记录 {d,t,n,b(base64)};__ws_save__() 存到 3DTank/names/,离开页面自动 sendBeacon;#nohook 关闭

- serve.js:根路径提供 /ws_hook.js(no-cache)

- web/index.html:在主脚本之前引入,确保先于游戏创建 WebSocket
This commit is contained in:
TermiNexus committed 2026-10-11 20:04:23 +08:00
1 parent bd35ca1c12
commit 418298e2ec
2 files changed
+94 -178

No files matched your search

+17
View File
@@ -208,6 +208,23 @@ const Server = http.createServer((Req, Res) => {
}).end(Sw);
return;
}
// /ws_hook.js:页面内 window.WebSocket 抓包脚本(在主脚本之前引入)。
// 传输仍由浏览器发起(服务端看不到差别),用于与中继转存做逐帧对比;#nohook 可关闭。
if (UrlPath.split('?')[0] === '/ws_hook.js') {
const HookFile = path.join(ROOT, 'sockets', 'ws_hook.js');
if (!fs.existsSync(HookFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('ws_hook.js not found');
return;
}
const Hook = fs.readFileSync(HookFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Hook.length,
'Cache-Control': 'no-cache',
'X-Hornet-WsHook': 'local',
}).end(Hook);
return;
}
// /config.xml 本地覆盖:存在 local/config.xml 时用它,把游戏指向本地 wss_server
if (!REAL_CONFIG && UrlPath.split('?')[0] === '/config.xml' && fs.existsSync(LOCAL_CONFIG)) {
const Conf = fs.readFileSync(LOCAL_CONFIG);
+77 -178
View File
@@ -1,195 +1,94 @@
// ============================================================
// WebSocket 流量捕获注入脚本
// WebSocket 流量捕获(页面内 hook,传输仍由浏览器发起)
// ============================================================
// 在游戏加载前注入,捕获所有 WebSocket 通信
// 数据保存到 window.__ws_capture__ 数组
// 使用 window.__download_ws_capture__() 下载捕获数据
// 目的:在不改变「服务端看到的连接」的前提下,把浏览器真实的 ws 帧抓下来,
// 用于与「中继转存」逐帧对比,定位「直连能进、中继不能进」的差异。
//
// 挂载:serve.js 提供 /ws_hook.js;web/index.html 在主脚本之前引入(先于游戏创建 WebSocket)。
// 开关:URL hash 带 #nohook 则不抓(默认抓)。
// 产物:
// window.__ws_capture__ —— 内存里的抓包(connections[].messages[] = {d,t,n,b},
// d=c2s/s2c,n=字节数,b=base64 原始字节,顺序即收发顺序)
// window.__ws_save__() —— 手动保存;离开页面时自动 sendBeacon 再存一份
// 落盘位置:serve.js 的 POST /__dump__/<name> → 3DTank/names/<name>
// ============================================================
(function () {
if (window.__ws_hooked__) return;
if (/#nohook/.test(location.hash)) {
console.log('[WS Hook] #nohook:已跳过');
return;
}
window.__ws_hooked__ = true;
window.__ws_capture__ = [];
var captureDir = 'ws_capture_' + Date.now();
var Capture = { startedAt: Date.now(), connections: [] };
window.__ws_capture__ = Capture;
var Original = window.WebSocket;
function toHex(buffer) {
var bytes = new Uint8Array(buffer);
var hex = [];
for (var i = 0; i < bytes.length; i += 16) {
var chunk = bytes.slice(i, Math.min(i + 16, bytes.length));
var hexPart = Array.from(chunk).map(function (b) {
return b.toString(16).padStart(2, '0');
}).join(' ');
var asciiPart = Array.from(chunk).map(function (b) {
return (b >= 32 && b < 127) ? String.fromCharCode(b) : '.';
}).join('');
hex.push(i.toString(16).padStart(8, '0') + ' ' + hexPart.padEnd(48) + ' ' + asciiPart);
function ToBytes(Data) {
if (Data instanceof ArrayBuffer) return new Uint8Array(Data);
if (ArrayBuffer.isView(Data)) return new Uint8Array(Data.buffer, Data.byteOffset, Data.byteLength);
if (typeof Data === 'string') return new TextEncoder().encode(Data);
return new Uint8Array(0);
}
function ToBase64(Bytes) {
var S = '';
var Chunk = 0x8000; // 分块,避免超长字符串参数
for (var I = 0; I < Bytes.length; I += Chunk) {
S += String.fromCharCode.apply(null, Bytes.subarray(I, I + Chunk));
}
return hex.join('\n');
return btoa(S);
}
function toArrayBuffer(data) {
if (data instanceof ArrayBuffer) return data;
if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
if (typeof data === 'string') return new TextEncoder().encode(data).buffer;
return data;
function Hooked(Url, Protocols) {
var Ws = Protocols ? new Original(Url, Protocols) : new Original(Url);
var Info = { id: Capture.connections.length, url: String(Url), openTime: null, closeCode: null, messages: [] };
Capture.connections.push(Info);
console.log('[WS Hook] #' + Info.id + ' 连接 ' + Info.url);
function Record(Dir, Data) {
var Bytes = ToBytes(Data);
Info.messages.push({ d: Dir, t: Date.now(), n: Bytes.length, b: ToBase64(Bytes) });
}
var OrigSend = Ws.send;
Ws.send = function (Data) {
Record('c2s', Data);
return OrigSend.apply(Ws, arguments);
};
Ws.addEventListener('message', function (Ev) { Record('s2c', Ev.data); });
Ws.addEventListener('open', function () { Info.openTime = Date.now(); });
Ws.addEventListener('close', function (Ev) { Info.closeCode = Ev.code; });
return Ws;
}
Hooked.CONNECTING = Original.CONNECTING;
Hooked.OPEN = Original.OPEN;
Hooked.CLOSING = Original.CLOSING;
Hooked.CLOSED = Original.CLOSED;
Hooked.prototype = Original.prototype;
window.WebSocket = Hooked;
// 保存原始 WebSocket
var OriginalWebSocket = window.WebSocket;
function HookedWebSocket(url, protocols) {
var ws = protocols ? new OriginalWebSocket(url, protocols) : new OriginalWebSocket(url);
console.log('[WS Hook] 连接:', url);
var wsId = window.__ws_capture__.length;
var wsInfo = {
id: wsId,
url: url,
openTime: null,
messages: []
};
window.__ws_capture__.push(wsInfo);
// Hook send
var originalSend = ws.send.bind(ws);
ws.send = function (data) {
var ab = toArrayBuffer(data);
var msgInfo = {
direction: 'send',
time: Date.now(),
size: ab.byteLength,
hex: toHex(ab),
buffer: ab
};
wsInfo.messages.push(msgInfo);
console.log('[WS Hook] #' + wsId + ' >>> 发送 ' + ab.byteLength + ' 字节:');
console.log(toHex(ab));
return originalSend(data);
};
// Hook onmessage
var originalOnMessage = null;
Object.defineProperty(ws, 'onmessage', {
get: function () { return originalOnMessage; },
set: function (fn) {
originalOnMessage = fn;
// Wrap the handler
ws.addEventListener('message', function (event) {
var ab = toArrayBuffer(event.data);
var msgInfo = {
direction: 'recv',
time: Date.now(),
size: ab.byteLength,
hex: toHex(ab),
buffer: ab
};
wsInfo.messages.push(msgInfo);
console.log('[WS Hook] #' + wsId + ' <<< 接收 ' + ab.byteLength + ' 字节:');
console.log(toHex(ab));
});
}
});
// Also hook addEventListener for 'message'
var originalAddEventListener = ws.addEventListener.bind(ws);
ws.addEventListener = function (type, listener, options) {
if (type === 'message') {
var wrappedListener = function (event) {
var ab = toArrayBuffer(event.data);
var msgInfo = {
direction: 'recv',
time: Date.now(),
size: ab.byteLength,
hex: toHex(ab),
buffer: ab
};
wsInfo.messages.push(msgInfo);
console.log('[WS Hook] #' + wsId + ' <<< 接收 ' + ab.byteLength + ' 字节:');
console.log(toHex(ab));
listener(event);
};
return originalAddEventListener(type, wrappedListener, options);
}
return originalAddEventListener(type, listener, options);
};
ws.addEventListener('open', function () {
wsInfo.openTime = Date.now();
console.log('[WS Hook] #' + wsId + ' 连接成功');
});
ws.addEventListener('close', function (event) {
console.log('[WS Hook] #' + wsId + ' 连接关闭 (code=' + event.code + ')');
});
ws.addEventListener('error', function (event) {
console.log('[WS Hook] #' + wsId + ' 连接错误:', event);
});
return ws;
function Stamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
function Save(Prefix) {
var Json = JSON.stringify(Capture);
var Url = '/__dump__/' + (Prefix || 'ws_capture_') + Stamp() + '.json';
return fetch(Url, { method: 'POST', body: Json })
.then(function (R) {
console.log('[WS Hook] 已保存 ' + Url + '(' + Json.length + ' 字节,' + Capture.connections.length + ' 个连接)');
return R;
})
.catch(function (E) { console.warn('[WS Hook] 保存失败: ' + (E && E.message)); });
}
window.__ws_save__ = function () { return Save('ws_capture_'); };
// 复制静态属性
HookedWebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
HookedWebSocket.OPEN = OriginalWebSocket.OPEN;
HookedWebSocket.CLOSING = OriginalWebSocket.CLOSING;
HookedWebSocket.CLOSED = OriginalWebSocket.CLOSED;
HookedWebSocket.prototype = OriginalWebSocket.prototype;
window.addEventListener('beforeunload', function () {
try {
var Blob = new Blob([JSON.stringify(Capture)], { type: 'application/json' });
navigator.sendBeacon('/__dump__/ws_capture_auto_' + Date.now() + '.json', Blob);
} catch (E) {}
});
window.WebSocket = HookedWebSocket;
// 下载捕获数据
window.__download_ws_capture__ = function () {
var data = window.__ws_capture__.map(function (ws) {
return {
id: ws.id,
url: ws.url,
openTime: ws.openTime,
messages: ws.messages.map(function (m) {
// Convert buffer to base64 for JSON serialization
var bytes = new Uint8Array(m.buffer);
var binary = '';
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return {
direction: m.direction,
time: m.time,
size: m.size,
hex: m.hex,
base64: btoa(binary)
};
})
};
});
var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'ws_capture_' + Date.now() + '.json';
a.click();
URL.revokeObjectURL(url);
console.log('[WS Hook] 已下载捕获数据,共 ' + data.length + ' 个连接');
};
// 导出单个消息的二进制
window.__export_ws_binary__ = function (wsId, msgIdx) {
var ws = window.__ws_capture__[wsId];
if (!ws) { console.log('未找到 WS #' + wsId); return; }
var msg = ws.messages[msgIdx];
if (!msg) { console.log('未找到消息 #' + msgIdx); return; }
var blob = new Blob([msg.buffer], { type: 'application/octet-stream' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = msg.direction + '_' + wsId + '_' + msgIdx + '.bin';
a.click();
URL.revokeObjectURL(url);
};
console.log('[WS Hook] WebSocket 捕获脚本已加载');
console.log('[WS Hook] 使用 __download_ws_capture__() 下载所有捕获数据');
console.log('[WS Hook] 使用 __export_ws_binary__(wsId, msgIdx) 导出单个消息');
console.log('[WS Hook] 已挂载:window.__ws_capture__(内存)/ __ws_save__()(存到 3DTank/names/)');
})();