系统梳理JavaScript逆向工程中面对WebSocket二进制数据(ArrayBuffer与Blob)的分析思路,从Hook定位、协议分类识别(Protobuf、压缩、对称加密、私有封包),到解密与反序列化还原的全流程实战指南。
JS逆向中WebSocket二进制数据加解密分析思路
时间:2026-11-02
浏览:599
文章目录
打开浏览器的开发者工具,切换到 Network 面板的 WS 标签页,在 Messages 列表中看到的不是明朗直观的 JSON 字符串,而是一行行标记为 Binary Message 的数据帧;点击预览区,呈现出的是一长串十六进制字节码或无法辨认的乱码字符。面对这种 WebSocket 二进制通信,很多开发者容易陷入在庞大打包代码中无头绪搜索 AES、decrypt 等关键词的误区。解决此类问题的核心思路是构建一条确切的追踪链路:拦截底层传输载体(Hook 原生 API) → 沿调用栈逆向定位分发入口 → 辨识载荷特征(数据压缩、标准序列化、算法加解密或自定义私有包头) → 剥离解码逻辑 → 独立工程化重放并维持长连接心跳。
一、截获传输载体:底层 API 的 Hook 与断点策略
WebSocket 协议原生支持文本帧(Text Frame, Opcode 0x1)和二进制帧(Binary Frame, Opcode 0x2)。在浏览器 JavaScript 运行时中,二进制数据通常以 ArrayBuffer 或 Blob 对象形式呈现,具体取决于套接字实例的 binaryType 属性设置。要找到解密与反序列化的源头,第一步是在浏览器创建连接或收发数据时对其建立拦截。
现代前端工程通常采用 Webpack 或 Vite 模块化打包,底层的 WebSocket 连接实例多被深层封装在框架(如 Socket.IO、SockJS、自研长连 SDK)内部。最直接且通用的拦截手段是在全局作用域中代理 window.WebSocket 构造函数及其原型链方法。
在控制台或油猴脚本(Tampermonkey)的 document-start 阶段注入以下拦截脚本:
(function hookWebSocket() {
const RealWebSocket = window.WebSocket;
window.WebSocket = function (url, protocols) {
const ws = protocols ? new RealWebSocket(url, protocols) : new RealWebSocket(url);
// 1. 劫持客户端上行发送 (send)
const originalSend = ws.send;
ws.send = function (data) {
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || data instanceof Blob) {
console.warn('[WS-Send Binary]', {
length: data.byteLength || data.size,
data: data
});
// 此处可打条件断点,分析上行序列化或加密前调用栈
// debugger;
}
return originalSend.apply(this, arguments);
};
// 2. 劫持服务端下行推送 (addEventListener 形式)
const originalAddEventListener = ws.addEventListener;
ws.addEventListener = function (type, listener, options) {
if (type === 'message') {
const hookedListener = function (event) {
if (typeof event.data !== 'string') {
console.warn('[WS-Recv Binary via EventListener]', {
type: Object.prototype.toString.call(event.data),
length: event.data.byteLength || event.data.size,
raw: event.data
});
// 收到二进制消息时触发断点
if ((event.data.byteLength || event.data.size) > 32) {
debugger;
}
}
return listener.apply(this, arguments);
};
return originalAddEventListener.call(this, type, hookedListener, options);
}
return originalAddEventListener.apply(this, arguments);
};
// 3. 劫持 onmessage 属性赋值 (setter 形式)
let _onmessage = null;
Object.defineProperty(ws, 'onmessage', {
get: function () {
return _onmessage;
},
set: function (fn) {
_onmessage = function (event) {
if (typeof event.data !== 'string') {
console.warn('[WS-Recv Binary via onmessage]', {
type: Object.prototype.toString.call(event.data),
length: event.data.byteLength || event.data.size,
raw: event.data
});
// 过滤短小的常规心跳帧后停下执行上下文
if ((event.data.byteLength || event.data.size) > 32) {
debugger;
}
}
return fn.apply(this, arguments);
};
}
});
return ws;
};
window.WebSocket.prototype = RealWebSocket.prototype;
window.WebSocket.CONNECTING = RealWebSocket.CONNECTING;
window.WebSocket.OPEN = RealWebSocket.OPEN;
window.WebSocket.CLOSING = RealWebSocket.CLOSING;
window.WebSocket.CLOSED = RealWebSocket.CLOSED;
})();
该脚本必须在目标网页建立 WebSocket 连接之前执行完毕。若网页在 HTML 加载早期即发起连接,建议使用浏览器扩展在页面顶部注入,或在 DevTools 的 Source 面板中勾选“Event Listener Breakpoints → WebSocket → Create”先行阻断。
二、调用栈溯源:穿透底层驱动找到业务解码入口
当断点在 Hook 函数内部触发时,JavaScript 引擎会停在最原始的事件接收处。此时 event.data 依然是原生的 ArrayBuffer 或 Blob,业务逻辑尚未对它进行任何加工。接下来需要利用 Chrome DevTools 的 Call Stack(调用堆栈) 单步步过(Step Over, F10)或单步步入(Step Into, F11)向外追踪。
在这个过程中,调用栈通常会经历三个阶段:
- 网络底层事件触发层:这是浏览器的原生驱动或第三方基础通信库(例如
sockjs-client、engine.io-client、event-target-shim)。这部分代码由通用开源库编译而来,不需要在此浪费时间,直接观察函数返回。 - 二进制载体适配层:如果服务端的
binaryType是blob,前端通常需要调用FileReader.readAsArrayBuffer()或blob.arrayBuffer()将其转换为内存字节数组。在这一层,数据类型完成了从文件对象到内存缓冲区的跨越。 - 业务消息分发与解码层:这是逆向分析的真正重点。代码中通常会出现类似
dispatch(type, buffer)、handleMessage(data)、decodePacket(bytes)的函数名。在混淆代码中,也可以通过局部变量中出现new Uint8Array(...)或new DataView(...)识别出进入了业务解包模块。
三、二进制载荷形态识别:四种典型模式与辨识决策
许多初学者看到二进制数组便直觉认定是强加密算法,随后费尽周折寻找密钥,最终却发现那只是常规的压缩或序列化。在溯源到具体的解码函数后,应先提取原始二进制数据的前 16 个字节及整体字节分布,对照以下四种形态进行分类研判:
1. 形态 A:纯数据压缩(Gzip / Zlib / Brotli)
为节省高频行情的带宽,很多金融、社交及弹幕类网站直接将明文 JSON 进行压缩后下发。只需检查数据前两字节的十六进制魔数即可辨别:
- Gzip 魔数:
0x1F 0x8B。 - Zlib(Deflate)魔数:
0x78 0x01(无压缩/弱压缩)、0x78 0x9C(默认压缩等级)、0x78 0xDA(最高压缩等级)。
在前端代码中搜索 pako、inflate、fflate 等关键字。如果在控制台拿到 uint8Array,可直接调用引入的 pako.inflate(uint8Array, { to: 'string' }) 验证;若立即打印出可读的 JSON 文本,则说明该接口并未对数据加解密,仅做了传输压缩。
2. 形态 B:标准化二进制序列化(Protobuf / MessagePack)
Protocol Buffers(Protobuf)是现代 WebSocket 架构中使用最广泛的序列化方案。Protobuf 编码后的数据密度极高,且没有统一固定的文件头魔数,但在客户端代码中有非常显著的静态与动态特征:
- 静态代码特征:混淆文件中包含
protobuf.Reader.create、.decode(reader)、finish(),或在循环结构中执行tag >>> 3(提取字段序号)与tag & 7(提取传输类型 WireType)。 - 运行时特征:断点停在解码函数内部时,入参是一个包含
pos、len、buf属性的 Reader 实例,代码分支充斥着大量的switch (tag >>> 3)语句。
若使用的是 MessagePack,其首字节通常落在特定范围(例如 0x80~0x8F 表示元素较少的 Map,0x90~0x9F 表示数组),在 JS 依赖中多见 msgpack-lite 或 @msgpack/msgpack。
3. 形态 C:对称/非对称加解密(AES / ChaCha20 / 国密 SM4 / 异或)
真正的加密载荷具有典型的高信息熵特征:Shannon 熵值通常接近理论极限 8.0,字节取值在 0~255 之间均匀分布,没有任何可识别的文本片段或固定魔数。常见的分析路径如下:
- Web Crypto API 原生调用:检索
crypto.subtle.decrypt,断点查看传入的CryptoKey、iv及算法名称(如AES-GCM、AES-CBC)。 - 第三方加密库:检索
CryptoJS.AES.decrypt、forge.cipher或自定义的位运算还原函数。 - 密钥协商与分发:解密算法本身的逻辑通常是固定的标准库,重点在于寻找密钥(Key)的来源。密钥可能是连接握手(HTTP Upgrade 请求)时下发的 Token 计算得出,也可能是客户端在建立 WS 连接前通过 RSA/ECDH 与服务端协商所得。
4. 形态 D:自定义私有二进制封包(TLV / Header + Payload)
大型系统或重度依赖私有通信协议的应用,往往会设计专用的二进制包头(Frame Header),用来承载内部路由、消息编号以及压缩/加密标志。这是逆向分析中必须攻克的结构层。
四、核心实战:自定义私有协议与 DataView 内存映射
在面对自定义二进制封包时,最常调用的 JavaScript 内置 API 是 DataView。与直接访问索引的 Uint8Array 不同,DataView 提供了精确控制字节偏移量和字节序(Endianness)的读写能力。
以下是一段在客户端逆向中提取出来的典型解包逻辑实现:
// 客户端解包函数逆向还原示例
function unpackWebSocketFrame(arrayBuffer) {
// 1. 基于原始 Buffer 建立视图
const view = new DataView(arrayBuffer);
const totalLength = arrayBuffer.byteLength;
// 包头定长校验 (例如本协议包头占 13 字节)
if (totalLength < 13) {
throw new Error('Frame length is shorter than header size');
}
// 2. 依次按字节偏移读取 Header 字段
// 第二个参数 false 表示采用大端序 (Big-Endian / 网络字节序)
const magic = view.getUint16(0, false);
if (magic !== 0xAA55) {
throw new Error('Invalid Protocol Magic: 0x' + magic.toString(16));
}
const cmdId = view.getUint16(2, false);
const seqId = view.getUint32(4, false);
const bodyLen = view.getUint32(8, false);
const flags = view.getUint8(12);
// 3. 校验变长包体长度
if (13 + bodyLen > totalLength) {
throw new Error('Incomplete body payload: packet fragmented');
}
// 4. 提取纯净 Payload 字节切片
const rawPayload = arrayBuffer.slice(13, 13 + bodyLen);
// 5. 根据 Flags 标志位处理解密与解压缩
let payloadBuffer = rawPayload;
const isEncrypted = (flags & 0x01) === 0x01;
const isCompressed = (flags & 0x02) === 0x02;
if (isEncrypted) {
// 传入密钥进行 AES 解密
payloadBuffer = customAesDecrypt(payloadBuffer);
}
if (isCompressed) {
// 调用 pako 进行解压缩
payloadBuffer = window.pako.inflate(new Uint8Array(payloadBuffer)).buffer;
}
return {
header: { magic, cmdId, seqId, bodyLen, flags },
payload: payloadBuffer
};
}
DataView.prototype.getUint32(byteOffset, littleEndian) 的第二个参数决定了多字节整数的解析方式。若省略或传 false,则默认为大端序(Big-Endian);传 true 则为小端序(Little-Endian)。如果端序识别错误,一个原本为 100(0x00000064)的包体长度会被误读为 1677721600(0x64000000),直接导致内存切片异常崩溃。
五、Protobuf 协议还原:从前端代码反推数据模型
当判定二进制载荷为 Protobuf 编码时,核心目标不是强行写底层位运算解码器,而是获取或推导对应的 .proto 数据契约定义:
- 方法一:在控制台中直接导出打包器中的 Proto 模块
大多数使用 Webpack 打包的项目会将 Protobuf 编译生成的 JavaScript 类统一收归在某个模块中。在断点停留在解码函数处时,可以在控制台打印当前作用域,通常能直接发现包含Root、lookupType或Type.decode的命名空间。通过window.__proto_root = rootModule;将其暴露到全局,后续在控制台即可直接调用__proto_root.lookupType('TradeDetail').decode(buf)输出可读对象。 - 方法二:根据解码 Switch 分支与 Tag 反向拼写
.proto文件
如果前端对代码进行了深度混淆压制,剥离了反射元数据,可以观察反序列化函数中的循环与条件分支。例如:
根据字段编号(Field Number)与读取方法(WireType 映射),即可直接还原出纯净的标准 proto 声明:// 混淆代码片段 while (reader.pos < end) { var tag = reader.uint32(); switch (tag >>> 3) { case 1: message.symbol = reader.string(); break; case 2: message.price = reader.double(); break; case 3: message.volume = reader.int64(); break; default: reader.skipType(tag & 7); break; } }
有了这个syntax = "proto3"; message TradeDetail { string symbol = 1; double price = 2; int64 volume = 3; }.proto文件,在脱机脚本中便可以使用 Python 的google.protobuf或 Node.js 的protobufjs直接编解码。
六、脱机独立重放与工程化落地
在浏览器内搞清解密与解码逻辑后,最终需要脱离浏览器,在独立脚本(如 Python 或 Node.js)中完整跑通数据拉取。此时需要满足长连接建立与维持的三大基本条件:
| 关键环节 | 实现要点与常见问题 | 工程处理手段 |
|---|---|---|
| 1. 握手鉴权 (Handshake) | HTTP 101 Upgrade 请求中附带鉴权 Cookie、Token、以及根据时间戳计算的签名参数。 | 抓取初次发起握手请求的完整 Headers,复现 HTTP 握手阶段的加密签名生成逻辑。 |
| 2. 协议初始化与订阅 | 连接建立后必须在 3~5 秒内发送指定格式的 Auth/Login 二进制包或 Topic 订阅包。 | 按照逆向出的私有 Header 格式或 Protobuf 编码结构构造登录二进制帧并首批发送。 |
| 3. 心跳守护机制 (Heartbeat) | 服务端通常设置有空闲超时(通常为 30 秒至 60 秒),若无定周期上行数据则强制断开(Code 1006)。 | 在客户端维护一个后台定时器(如每隔 15 秒),组装心跳包(如固定 CmdID 为 0 的二进制帧)发往服务端。 |
七、常见分析雷区与应对总结
在实际逆向分析过程中,还有若干容易导致卡顿的非典型场景,需要特别注意:
- Worker 线程中的 WebSocket 连接:部分对性能要求较高的站点将 WebSocket 通信完全移入 Web Worker 或 SharedWorker 中执行。此时在主线程 Hook
window.WebSocket无法捕获任何连接。应对方法是 Hook 全局window.Worker构造函数,或在 Chrome DevTools 的 Threads 窗口中切换到对应的 Dedicated Worker 上下文再打断点。 - 动态会话密钥混淆:若发现每次刷新页面后解密算法相同但密钥完全改变,应重点排查 WS 建立连接后的第一帧二进制数据。服务端经常通过首帧(如 Init Handshake Frame)将当前连接的临时公钥或 AES IV 下发给客户端。
- 黏包与分片(Framing):在极少数吞吐量巨大的数据流中,一个 WebSocket 数据帧内可能包含多个连续的业务数据包。若仅按照第一包解析剩余字节会全部报错,必须在解包函数中根据 Header 中的
bodyLen进行循环切片读取,直到耗尽当前ArrayBuffer。
掌握 WebSocket 二进制加解密的核心,在于清晰地分离网络传输层、私有封包层、安全加密层与数据序列化层。面对杂乱的十六进制数据,始终遵循自底向上的调用栈排查法则,先判定数据形态,再针对性攻克解密与反序列化逻辑,即可高效还原出清晰完整的结构化业务数据。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。