一提到WebSocket协议逆向分析很多人的第一反应是这不就是抓个包、看下报文、照着发一遍不就完事了吗真做起来完全不是那么回事。我最近集中处理过一个某网站的WebSocket协议逆向项目它的HTTP接口防护做得一般但所有实时业务数据全部搬到了WebSocket长连接上而且消息体做了多层加密。更麻烦的是这个连接不只承载业务消息还承担了登录状态校验、心跳保活、频道订阅这些逻辑整套协议扒下来前后花了不少时间。这篇文章就把我这次分析某网站WebSocket协议的完整思路写出来从怎么定位连接、拆解载荷到怎么把加解密函数从JS里揪出来再到用Python把客户端完整跑起来最后把踩过的坑和排查方法一次性列清楚。内容偏实操适合正在做采集、数据分析或者接口对接又恰好被WebSocket这块卡住的开发者。1. 从零定位WebSocket连接别一上来就翻源码1.1 开发者工具里怎么快速找到目标连接打开目标网站的页面先不要急着搜索关键词第一步永远是打开浏览器开发者工具切到Network面板清空所有请求记录然后去页面上触发目标功能。比如我们要分析的是实时行情推送那就点开行情页、切几个品种、刷新几次列表。等操作完了在Network面板的过滤框里输入ws所有WebSocket连接就会列出来。这里有个新手容易忽略的细节WebSocket在Network面板里显示的是一条连接记录不是像HTTP那样每个请求一条。你要点进这条连接的详情才能看到Frame标签页里面按时间顺序排列着这个连接上双向收发的一条条消息。每条消息还标注了方向上行是客户端发给服务端的下行是服务端推给客户端的。如果页面上连接太多、不好定位还有一个更省事的办法。直接在Console里执行performance.getEntriesByType(resource) .filter(e e.name.startsWith(ws)) .map(e e.name)这样能把页面里所有WebSocket地址一次性抓出来同时拿到完整的ws或wss链接。配合开发者工具里看到的连接列表基本就能确认目标连接是哪一条了。判断这个连接值不值得投入精力去逆向我一般看三个方面连接地址是否带业务特征比如路径里出现了gateway、push、ws这类前缀连接建立后是否有持续的业务消息在推而不是只有心跳或握手确认消息体是否明显不是明文比如大段Base64、Hex或者二进制Buffer。如果只是偶尔推一条“你有新通知”之类的明文消息那说明核心业务还留在HTTP层WS只是辅助通道直接关掉不用浪费时间。1.2 为什么越来越多的站点把核心消息搬上WebSocket这个趋势最近一两年特别明显。HTTP接口容易被采集、被批量调用、被频率限制打穿所以很多站点开始把实时性强的数据挪到WebSocket这条长连接上。从业务架构上看这是个合理选择服务端可以主动推送、客户端能收能发、连接一旦建立就能长期复用。但从逆向的角度看WebSocket链路有一个天然的优势也有一个天然的麻烦。优势是消息是双向的一旦你掌握了客户端加密逻辑模拟成本比HTTP要低因为不需要频繁构造请求头、处理签名、走登录流程一条长连接就能拿到所有数据。麻烦是很多站点的WebSocket消息不是明文而且加密逻辑藏在JS的某个角落甚至打包进了混淆代码里找不到入口就只能干瞪眼。好在无论加密多复杂有一条铁律不会变数据从客户端发出去之前或者在收到服务端消息之后一定会经过一段可以下断点的JavaScript代码。我们要做的就是把这段代码从几千行甚至几万行的JS里找出来然后还原它。2. 消息载荷拆解看明白第一帧开始的所有内容2.1 分清文本帧和二进制帧定位到WebSocket连接之后下一步就是逐帧观察消息。这时候先别急着看内容先分类。WebSocket的帧由opcode决定类型我们在逆向过程中重点关注这几种0x1文本帧载荷是UTF-8字符串在Frame面板里直接能读出来0x2二进制帧载荷是ArrayBuffer或BlobChrome里显示成{length: 423}0x9和0xAPing和Pong帧属于协议层保活不在业务消息范围内。很多时候我们需要逆的是二进制帧。在Frame面板里右键点这条消息可以直接把二进制内容存成文件然后用十六进制编辑器打开。如果你不想存文件也可以在Console里手动写一小段代码去读。二进制帧比较常见的是Protobuf序列化、MessagePack、自定义二进制结构加Gzip压缩。每种的识别特征不一样我整理了一个速查表载荷特征很可能使用的编码开头是{能直接看到键名JSON/明文全是大小写字母、数字末尾可能是Base64长度是偶数的0-9a-f字符串Hex前几个字节常见08、10、18等小值字段号Protobuf开头是1f 8bGzip压缩流开头是c2 01或81 81一类特征字节自定义二进制结构识别错了方向后面所有工作都会白费。尤其是遇到Protobuf时没有配套的proto定义文件纯靠字节去猜字段格式非常痛苦。我的一般做法是先把消息保存下来再根据字段序号、Varint编码规律去反推同时结合客户端JS里的字段名来校验推测是否合理。2.2 第一帧消息往往藏着全局信息很多人一开始就抓业务数据这是方向性错误。我建议把连接建立后的第一到第三帧仔仔细细看一遍。很多站点的WebSocket网关设计都是长这样的客户端连上来之后服务端会先推送一条“欢迎消息”里面带着当前协议版本号、会话标识、服务端时间戳或者要求客户端先注册、再订阅。这帧消息的作用非常大。比如服务端时间戳能直接解决时间校准问题很多加解密方案都依赖时间戳参与运算如果客户端和服务端时间差了十几秒解密出来的内容可能会对不上。而会话标识基本上会出现在后续所有业务消息里是服务端识别客户端身份的依据。我遇到过一种情况服务端在连接欢迎帧里下发了一个加盐因子客户端后续的所有加密负载都要带上这个因子。你不看第一帧直接拿静态逻辑去复现加密流程必然对不上。所以说先花十分钟把前几帧研究透比什么都值。2.3 心跳消息不能遗漏WebSocket连接是长连接但它不是永远稳定的。网络状态波动、中间设备超时、服务端资源回收都会导致连接假死。为了维持连接服务端通常要求客户端周期性地发送心跳消息。这时候要注意区分两种情况一种是WebSocket协议层的Ping/Pong帧由浏览器或客户端库自动发送另一种是业务层的心跳消息直接走文本帧内容往往像一个{action:ping}或者一个很短的特殊字符串。抓包时如果看到几十秒间隔、内容固定、没有业务含义的消息基本就是业务心跳。在后续用Python模拟时这个间隔必须卡准太频繁会被服务端判定为异常太慢又会被判定离线连接会被回收。3. 加解密逻辑定位把加密函数从JS里揪出来3.1 关键词搜索法先试最快的路径定位加解密逻辑我习惯先从最笨但最快的方法入手——全文件关键词搜索。大多数站点不会真的自己写一套加密算法而是用成熟加密库最典型的就是CryptoJS。在Sources面板里按CtrlShiftF在整个JS目录里搜索优先看这几个关键词encryptdecryptCryptoJS或crypto-jsmode: CBC、mode: ECM这类模式配置pad: Pkcs7AES、RSA、AES-GCM搜到加密函数之后不要急着把整个文件拉下来跑先看它的引用关系。用键盘的Ctrl点击跳到调用方看看加密函数的入参来自哪里、密钥和IV从哪里取。这里有个很实用的鉴别技巧如果代码里出现了CryptoJS.AES.encrypt那就说明是典型的对称加密密钥和IV大概率就藏在同一个文件里甚至就在几行之外。你只需要找到它是从固定字符串、某个接口返回值还是登录响应里取出来的。如果是固定字符串那这个加密基本没有任何安全性直接把密钥抠出来就能复现如果是接口下发那就多了一层流程需要先请求那个接口拿密钥再走加密。3.2 调用栈回溯法给WebSocket.prototype.send加钩子搜索引擎会遗漏一种情况站点把加密逻辑写进了很深的工具函数或者它的函数名经过了混淆关键词根本不在变量名里。这时候就要换思路了。无论数据怎么加密客户端最终必须调用WebSocket.prototype.send把数据发出去。那我只要在浏览器Console里执行下面这段代码给send方法加一个钩子就能拿到每一次发送的原始载荷和调用栈const originalSend WebSocket.prototype.send; WebSocket.prototype.send function(data) { console.log(原始发送内容:, data); console.log(调用栈:, new Error().stack); return originalSend.call(this, data); };执行之后去页面触发业务操作Console里会输出每次send的调用栈。沿着调用栈一层层往上跳就能找到真正构造消息的函数再在那个函数里断点看它发送前对数据做了哪些处理。这个过程就像顺着快递单上的寄件地址往回查包裹最后是从仓库哪个车间发出来的一目了然。同样的道理也可以用在收消息这边。服务端推送消息后客户端一定会触发onmessage或者注册message事件监听器。在Console里执行一段代码把事件监听器的回调内容打印出来就能找到接收消息后的解密处理函数。实际上我在做某网站WebSocket协议逆向分析时这个send钩子几乎是主力工具。因为很多站点的业务代码不是一次加载完的而是在某个异步模块里动态拉取Sources面板里光看静态文件根本找不到但钩子永远不会漏掉真实的运行时调用链。3.3 拿到了算法怎么验证Node和浏览器互相配合一旦找到了加密函数不要马上写Python去复现。先做一个快速的正确性验证在浏览器Console里手动调用这个加密函数用几个已知输入求出输出确认逻辑完整。然后把同样的函数逻辑搬到Node.js里用同一组输入跑一遍对比结果。这一步能筛掉大量“看起来对、实际上有暗坑”的情况。我遇到过的暗坑包括加密函数里用了window.btoa、window.crypto.getRandomValues这类浏览器APINode里没有密钥不是简单的字符串而是经过了一次UTF-8编码、一次Hex解码或者被截断成固定长度IV不是固定的而是每次随机生成然后拼在密文前面加密之后又做了URL编码、大小写转换、Base64替换等二次处理。如果是浏览器API缺失这类问题可以在Node里定义几个全局变量来补齐比如global.window global、global.btoa str Buffer.from(str, binary).toString(base64)。如果是随机IV那就要注意在还原算法时必须自己实现同样规则的随机数生成并让IV显式出现在请求消息里服务端才能解开。4. 全链路协议复现用Python把客户端完整跑起来4.1 握手阶段别漏了Header和子协议定位完加密逻辑接下来就是把整个客户端行为在Python里复现出来。WebSocket握手本身不复杂Python的websockets库会自动生成Sec-WebSocket-Key、处理握手响应但服务端校验的重点通常不在协议本身的握手而在业务层的认证信息。我一般会在websockets.connect里用extra_headers补上三样东西Origin、Cookie、自定义Token这三个字段最常见。import asyncio import json import time import websockets async def main(): headers { Origin: https://example-site.com, Cookie: session_idxxx; session_signyyy, Authorization: Bearer zzz } async with websockets.connect( wss://api.example-site.com/gateway, extra_headersheaders ) as ws: # 后续业务逻辑 pass asyncio.run(main())Origin这个字段特别容易被忽略。服务端会检查这个值是否来自它自己的域名只要不一致就直接拒绝或者在握手后立即断开所以一定要在抓包时把原始的Origin抄下来原样带上。Cookie一般来自登录状态如果你在浏览器里已经登录了直接复制过来就能用。另外注意子协议。如果开发者工具里连接信息那一栏显示了Sec-WebSocket-Protocol那Python端也要在connect时用subprotocols参数带上对应的值不然服务端可能返回不兼容的协议版本。4.2 心跳机制和发送业务消息并行跑握手成功之后如果服务端要求客户端先注册或订阅那就按抓包看到的顺序照做。关键点是心跳任务不能阻塞业务收消息所以我会把心跳放到单独的协程里用asyncio.create_task跑起来。async def heartbeat(ws): while True: try: await ws.send(json.dumps({action: ping, ts: int(time.time() * 1000)})) except Exception: break await asyncio.sleep(25)心跳间隔从哪里拿看抓包里两次心跳消息之间的时间差然后稍微减几秒宁可略微提前也不能超时。如果间隔是60秒我就设成50到55秒。间隔太大会被服务端判定离线间隔太小可能触发风控或者无意义地增加负载。4.3 消息结构严格的字段顺序和字段类型文本帧的JSON消息还好说照着键名填就行。真正容易翻车的是二进制消息特别是自定义的二进制结构。这类消息里字段顺序、字节长度、大小端甚至填充规则都必须和客户端完全一致。前一阵我调试某个站点的二进制消息时就翻过车。客户端发送的负载可以概括为“2字节消息类型 4字节长度 业务负载”我一开始以为消息类型是Int16小端序结果怎么发服务端都回错误码。后来用浏览器端钩子打印出原始的Uint8Array对照十六进制才发现是16进制的大端序。之后老老实实按照抓包数据去解析果然通了。我的经验是遇到二进制帧先用一个辅助函数把收到的数据拆卸到能看懂的层次。import struct def parse_binary_frame(data: bytes): # 这里仅为示例实际格式以抓包结果为准 if data[:2] b\x1f\x8b: import gzip data gzip.decompress(data) offset 0 msg_type struct.unpack(H, data[offset:offset 2])[0] offset 2 payload_len struct.unpack(I, data[offset:offset 4])[0] offset 4 payload data[offset:offset payload_len] return msg_type, payload通过这类代码把一帧拆开再和JS里的生成逻辑互相印证比盯着十六进制字符串瞎猜高效得多。4.4 加密参数动态化时间戳、随机数与会话绑定加密逻辑还原之后还有一个高频问题为什么同样的算法浏览器里能解密Python模拟时服务端却回错误这时候要检查三个动态参数。一是时间戳。很多消息体里都会带一个13位的毫秒时间戳服务端用它来判断这条消息是否新鲜。如果客户端和服务端时间偏差过大服务端会直接丢弃。解决方案是在程序启动时请求目标站点的服务器时间接口计算本机时间与服务器时间的差值之后所有消息都用“本机时间差值”去生成时间戳。二是随机数。如果加密报文里包含nonce或者随机IV那每次发送都应该生成新值。但很多站点的随机数生成不是简单的Math.random()而是用了浏览器原生API比如crypto.getRandomValues之类。复现时要用密码学安全的随机数生成器来替换。三是会话绑定。连接之后服务端分发的会话标识、注册后返回的客户端ID这些值要作为状态保存下来拼进后续请求体。漏了任何一个服务端都会认为你在发送非法消息。5. 常见问题与排查心得踩过的坑一次性列清楚5.1 高频问题速查表做WebSocket协议逆向分析最容易翻车的就集中在几个固定环节。我整理了一张速查表基本覆盖了八成会遇到的问题问题现象可能原因解决方向连接能建立但很快被服务端断开缺少认证头、Origin不符或设备指纹校验失败补全握手阶段的Header、Cookie和子协议能收到服务端消息但解密结果乱码密钥是动态下发或者密钥长度不对在send钩子里打印明文和密文对照找密钥生成逻辑Python发同样的包服务端不回数据消息序列号、业务消息ID缺失或多帧消息按帧顺序发送比对抓包中前后消息的顺序补上协议要求的状态字段心跳正常发送但连接还是频繁超时服务端要的是业务层心跳而Python只发了协议层Ping帧检查抓包中心跳消息的格式区分协议层Ping与业务层心跳二进制帧解析出来全是乱码字节序、字段对齐方式或字符编码不匹配用Hex对比JS侧的真实发送数据逐步对齐字节结构这张表里的每一条我几乎都在实际项目里撞上过。尤其是心跳那个问题判断方法很简单如果浏览器和Python都连着同一个服务但浏览器连接稳定、Python频繁断开那大概率就是心跳类型不对。5.2 几个替我节省大量时间的调试习惯最后分享几个我在多次WebSocket逆向项目里沉淀下来的习惯这些习惯让我少走了很多弯路。第一个习惯是永远不要跳过“明文对照”这一步。很多人拿到加密函数就急着自己复现结果被各种细节卡住。我的做法是先在浏览器里通过Hook的方式把发送前的明文、加密后的密文、以及加密函数用到的密钥全部打印出来保存成一条“标准样例”。后面无论Python还是Node里的逻辑怎么改只要最终加密结果和标准样例对不上就一定有问题而且能很快定位到是算法问题还是参数问题。第二个习惯是优先关注连接建立后的第一帧和心跳帧。第一帧能透露协议版本、时间戳、会话标识这些全局信息心跳帧能告诉你保活机制。把这两个部分跑通了整个连接的生命周期基本就稳了。很多新手一上来就盯着业务消息反而忽略了这些支撑性的细节最后业务消息怎么发怎么错。第三个习惯是利用本地代理把抓包信息完整地存下来。WebSocket是双向的、动态的而且大部分协议的确认过程是异步的。来回翻浏览器里的Frame不如在代理工具里保存一份完整日志。出了问题回头翻时间线比重新触发一遍业务要快得多。第四个习惯是我个人最想强调的遇到难题先问自己“服务端到底在哪个环节产生了不确定性”。如果连接一秒就断问题多半在握手如果消息发出去石沉大海问题多半在消息结构如果消息能通但数据解不出来问题多半在密钥和IV。按这三层去定位大部分问题都能在半小时内收敛而不是满屏乱试。做这个某网站WebSocket协议逆向分析项目我最大的体悟是WebSocket相比HTTP的逆向并没有更神秘核心还是在“找到那个处理数据转换的JS函数”。只要钩子打得准、抓包看得细、消息结构对比得严格整条链路是可以一步步还原出来的。希望这篇文章能帮你把思路理顺下次再遇到WebSocket协议逆向分析少一点试探多一点确定性。