爬虫做到一定年头,你会发现一个分水岭:普通网页抓数据靠的是解析HTML、拼请求参数,这套东西熟练了之后基本是体力活;但一旦目标站点把核心数据塞进WebSocket长连接里,再叠上一层前端JS动态加密,很多人就卡住了。我最近复盘了一个"XX中心"类数据平台的采集案例,正好把这两块硬骨头凑齐了——数据不走常规HTTP接口,全靠WebSocket推送,而建立连接和维持通信所需的鉴权参数,又被前端JS层层包裹。这篇就把整个逆向链路拆开讲清楚,从怎么定位WebSocket握手参数,到怎么用JS hook把加密逻辑"钓"出来,再到怎么稳定复现整条数据流。适合已经会写基础爬虫、想往协议逆向和前端对抗方向进阶的朋友,也适合单纯好奇"浏览器里那点数据到底怎么被加密的"的开发者。
1. 先搞清楚这类平台为什么偏爱WebSocket加JS加密
动手之前得先想明白对手的设计意图,不然逆向就是无头苍蝇。这类"中心"型数据平台,通常承载的是实时性要求较高的业务数据——行情、监控指标、状态推送之类。用WebSocket而不是轮询HTTP,本质上是省带宽、降延迟:服务端有更新就推,客户端不用每隔几秒发一次请求。这个选择对平台方是性能优化,对爬虫方却是第一道门槛,因为WebSocket的握手和消息帧格式跟普通HTTP完全不是一回事。
1.1 WebSocket握手阶段藏着的鉴权玄机
WebSocket连接建立时,浏览器会先发一个HTTP Upgrade请求,这个请求里除了标准的Upgrade: websocket、Sec-WebSocket-Key之外,往往还带着业务方自定义的鉴权头或者URL参数。这些参数就是第一道关卡。我见过的最常见套路是:URL里带一个token或sign,这个值由前端JS根据时间戳、用户标识、某个固定盐值算出来,服务端收到后校验,不匹配就直接拒绝升级连接。
为什么要在握手阶段做鉴权?因为WebSocket一旦连上就是长连接,服务端不可能对每一帧消息都做完整身份校验,那样开销太大。所以在握手时一次性验证身份,后续靠连接本身维持会话,这是性能与安全的折中。理解这一点很关键——意味着我们只要把握手参数算对,后面的数据帧基本就是明文或者简单编码,逆向工作量集中在前端。
1.2 数据帧里的二次加密与心跳机制
握手通过不代表万事大吉。不少平台会在WebSocket消息体里再做一层处理,常见的有Base64编码、简单的异或混淆,或者干脆用一套自定义的二进制协议。另外长连接必须有心跳保活,客户端定时发ping或者业务层的心跳包,服务端超时没收到就断开。爬虫如果只连不发心跳,往往几十秒后就被踢下线,很多人卡在"连上了但收不到数据"就是这个原因。
我在这个案例里遇到的组合是:握手URL带动态sign,消息体是JSON但关键字段做了编码,心跳是每25秒发一次特定格式的文本帧。这三件事必须全部搞定,缺一个都跑不通完整流程。下面几节就按"定位参数→hook加密→复现连接→处理消息"的顺序展开。
2. 用开发者工具把WebSocket握手请求扒干净
逆向的第一步永远是观察,别急着写代码。打开目标页面,F12进开发者工具,切到Network面板,然后点筛选栏里的"WS"标签,这一下就能把所有WebSocket连接过滤出来。刷新页面,你会看到一条状态为101 Switching Protocols的请求,这就是握手成功的连接。
2.1 从101请求里读出关键参数
点开这条WS请求,看Headers部分。Request URL里通常带着查询参数,我这次看到的是类似wss://xxx/ws?sign=abc123&ts=1690000000&uid=...的结构。这里的sign和ts就是重点怀疑对象——ts一看就是时间戳,sign大概率是时间戳加上某个密钥做哈希得来的。再看Request Headers,有没有自定义的X-Token、Authorization之类,有的话一并记下来。
提示:有些平台的sign计算依赖cookie里的某个值,所以抓包时留意Cookie头,必要时把相关cookie一起带上,否则算出来的sign对不上。
把这条请求完整复制下来(右键Copy as cURL),存到一个文本文件里备用。这一步的意义在于,它给了你一个"标准答案"——只要你自己构造的请求能和这个cURL发出的完全一致,握手就能成功。后面hook出来的加密逻辑,最终也是为了让你的代码能复现出同样的sign。
2.2 顺着调用栈找到加密入口
光有参数不够,得知道sign怎么算的。这时候切到Sources面板,用全局搜索(Ctrl+Shift+F)搜sign这个关键字,或者搜wss://、WebSocket这些特征串。搜索结果里往往能定位到建立连接的那段代码,比如new WebSocket(url)附近,往上看几行就能看到url是怎么拼出来的,sign是调用哪个函数生成的。
如果代码被混淆过,搜关键字可能一无所获。这时候换个思路:在Network面板那条WS请求上右键,选"Copy link address"拿到完整URL,然后回到Sources,在建立连接的代码行打个断点,刷新页面让断点命中,再单步跟进sign的生成过程。断点调试虽然慢,但最可靠,尤其对付压缩混淆过的代码。我这次就是靠断点跟到了一个名为getSign的函数,里面调用了某个哈希库对拼接字符串做运算。
3. JS hook:把加密函数的输入输出原样钓出来
断点调试能看懂逻辑,但每次都要手动跟太累,而且有些加密函数藏得深、调用链长。这时候JS hook就派上用场了——它的核心思想是"不改变原逻辑,只在关键函数被调用时偷偷记录参数和返回值"。相当于在加密函数门口装了个摄像头,谁进来、带了什么、出去时变成什么,全给你记下来。
3.1 hook的三种注入时机与选择逻辑
注入hook代码有三种常见时机,各有适用场景。第一种是在页面加载前通过浏览器扩展或调试工具的"Override"功能注入,适合hook那些页面一加载就执行的初始化函数;第二种是在控制台手动执行,适合页面已经跑起来、你想临时挂个监控的场景;第三种是把hook代码写进一个本地HTML,用iframe或者代理的方式加载目标页,适合需要长期稳定监控的批量场景。
我这次用的是第二种,因为目标页加载后WebSocket连接是懒建立的——要用户点某个按钮才连。这样我有充足时间在控制台把hook挂好,再触发连接,正好捕获到加密调用。如果你面对的是页面一打开就连的场景,就得用第一种,在页面脚本执行前把hook埋进去。
3.2 针对哈希函数的通用hook模板
sign这类参数十有八九是哈希算出来的,所以优先hook常见的哈希入口。前端常用的哈希实现无非那么几种:原生的crypto.subtle.digest、引入的第三方库如CryptoJS的MD5/SHA256、或者自己手写的简单哈希。下面这个模板把几类入口都覆盖了:
// 保存原始引用,避免破坏原逻辑 (function() { // hook CryptoJS 常见方法 if (window.CryptoJS) { ['MD5', 'SHA1', 'SHA256', 'HmacSHA256'].forEach(function(name) { var orig = CryptoJS[name]; if (typeof orig === 'function') { CryptoJS[name] = function() { var result = orig.apply(this, arguments); console.log('[HOOK] CryptoJS.' + name, '输入:', arguments, '输出:', result.toString()); return result; }; } }); } // hook 原生 subtle.digest if (window.crypto && crypto.subtle && crypto.subtle.digest) { var origDigest = crypto.subtle.digest.bind(crypto.subtle); crypto.subtle.digest = function(algo, data) { console.log('[HOOK] subtle.digest', '算法:', algo, '数据:', data); return origDigest(algo, data); }; } })();把这段贴进控制台回车,然后去触发页面上的连接动作。如果sign是CryptoJS算的,控制台立刻会打印出输入参数和输出结果。输入参数里往往就藏着拼接规则——比如你看到输入是"1690000000" + "abc" + "固定盐"这样的字符串,那sign的算法就一目了然了。
注意:hook代码里一定要先保存原始函数引用再替换,否则容易造成无限递归或者把原功能搞坏。上面模板里
orig.apply那步就是干这个的,别省。
3.3 当hook不到时,往更底层追
有时候hook了常见哈希库却毫无输出,说明加密用的是自定义实现或者别的库。这时候有两个方向:一是扩大hook范围,把JSON.stringify、String.prototype.concat、Array.prototype.join这些字符串拼接相关的原生方法也hook上,看sign生成前到底拼了什么;二是回到断点,在getSign函数内部逐行看,找到真正做运算的那一行,再针对性地hook那个具体函数。
我这次就遇到了hook CryptoJS没反应的情况,后来hook了String.prototype.concat才发现,sign是把几个字段用+直接拼的,然后送进一个混淆过的自定义函数。顺着这个函数名再hook,输入输出就全出来了。这个过程有点像剥洋葱,一层层往里,急不得。
4. 把hook到的算法翻译成可复现的请求逻辑
拿到加密逻辑只是成功了一半,真正的考验是把它稳定地翻译成爬虫代码。这一步的坑比想象中多,因为浏览器环境和你的爬虫运行环境存在差异,很多在浏览器里跑得好好的逻辑,搬到Python或Node里就出问题。
4.1 算法复现:优先移植而非重写
我的原则是能移植就不重写。如果加密逻辑是标准哈希(MD5、SHA256之类),直接用Python的hashlib或者Node的crypto模块复现,几行代码搞定。但如果是自定义混淆函数,最稳妥的做法是把那段JS原样抠出来,用Node的vm模块或者execjs在Python里调用,而不是试图用Python重写一遍——重写极易在字符编码、位运算、类型转换上出偏差,调试成本极高。
举个例子,我这次的自定义函数里有个charCodeAt配合位运算的操作,Python里字符串和字节的处理跟JS差别很大,硬翻译几乎必错。最后我是把整个函数体抠出来,在Node里跑,Python通过子进程调用,虽然多一层开销,但结果稳定可靠。对于高频调用的场景,可以把Node服务常驻,用HTTP或标准输入输出通信,避免反复启动进程。
4.2 时间戳与签名的时效性处理
sign里带时间戳意味着它有有效期。服务端校验时通常会允许一定的时间偏差(比如前后5分钟),超出就拒绝。所以你的爬虫在生成sign时,时间戳必须用当前时间,不能缓存。同时要注意时区问题——JS的Date.now()返回的是毫秒级UTC时间戳,如果你在Python里用time.time()记得乘1000取整,别搞成秒级,否则算出来的sign永远对不上。
还有一个隐蔽的坑:有些平台的sign计算里,时间戳会被格式化成特定字符串(比如去掉毫秒、或者转成某种日期格式)再参与哈希。这种细节必须从hook到的输入参数里逐字比对,差一个字符结果就完全不同。我建议把hook到的原始输入字符串完整打印出来,和你代码里拼出来的字符串做逐字符对比,用diff思路排查,比盲猜快得多。
4.3 用Python建立WebSocket连接的完整骨架
握手参数搞定后,连接本身反而简单。Python里用websocket-client库就能建连,把算好的URL和必要的请求头带上:
import websocket import json import time import hashlib def build_sign(ts, uid, salt): raw = f"{ts}{uid}{salt}" return hashlib.md5(raw.encode()).hexdigest() def on_message(ws, message): try: data = json.loads(message) print("收到数据:", data) except Exception as e: print("解析失败:", e, "原始:", message) def on_open(ws): print("连接已建立") # 启动心跳线程(示例略,见下节) def on_error(ws, error): print("出错:", error) def on_close(ws, code, msg): print("连接关闭:", code, msg) ts = int(time.time() * 1000) uid = "你的用户标识" salt = "hook出来的固定盐值" sign = build_sign(ts, uid, salt) url = f"wss://目标域名/ws?sign={sign}&ts={ts}&uid={uid}" ws = websocket.WebSocketApp(url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, header={"Origin": "https://目标域名", "User-Agent": "你的UA"}) ws.run_forever()这段骨架的关键点在于:header里带上Origin,很多服务端会校验来源,不带直接拒;run_forever内部会自动处理ping/pong帧,但业务层心跳还得自己发,下一节细说。
5. 心跳保活与消息帧解析:让连接真正跑起来
连接建起来只是开始,能不能持续收到数据,取决于心跳和消息处理这两块。我见过太多人卡在"连上了,日志显示open,但一条数据都没有",八成是心跳没发或者消息解析错了。
5.1 业务心跳与协议心跳的区别
WebSocket协议本身有ping/pong控制帧,websocket-client的run_forever默认会响应服务端的ping,也会定期发ping(可通过ping_interval参数控制)。但很多平台不用协议层心跳,而是在业务消息里定义自己的心跳格式,比如每隔一段时间发一个{"type":"ping"}的JSON文本帧,服务端回{"type":"pong"}。这种业务心跳协议层是感知不到的,必须自己实现。
怎么判断该发哪种?回到抓包。在Network的WS面板里点开那条连接,切到Messages标签,你能看到所有收发的帧。观察一段时间,如果看到客户端周期性发送某种固定格式的消息,那就是业务心跳,照着格式和间隔复现即可。我这次看到的是每25秒发一次{"cmd":"heartbeat","t":时间戳},服务端回一个确认帧。把这个逻辑用独立线程实现:
import threading def heartbeat(ws, interval=25): while True: time.sleep(interval) try: payload = json.dumps({"cmd": "heartbeat", "t": int(time.time() * 1000)}) ws.send(payload) except Exception: break # 在 on_open 里启动 def on_open(ws): threading.Thread(target=heartbeat, args=(ws,), daemon=True).start()注意:心跳间隔要比服务端的超时阈值短一些,留出网络抖动余量。抓包看到25秒,你设20到23秒比较稳,别卡着25秒发,容易因为延迟被判超时。
5.2 消息体的编码识别与解码
消息帧拿到手,先别急着json.loads。有些平台的消息体是Base64编码的,或者是二进制帧。抓包时Messages面板会显示帧类型(Text还是Binary),Text帧直接看内容,Binary帧就得研究字节结构了。我这次的消息是Text帧,但内容是Base64字符串,解码后才是JSON。识别方法很简单:拿到消息先尝试直接JSON解析,失败就试Base64解码再解析,再失败就打印原始字节看规律。
解码逻辑要写成容错的形式,因为长连接里偶尔会混入控制消息或者格式异常的心跳响应,一条解析失败不能让整个程序崩掉。上面on_message里的try-except就是这个作用,把异常吞掉继续跑,同时打印原始内容方便排查。
5.3 断线重连与状态恢复
长连接一定会断——网络抖动、服务端重启、心跳超时,各种原因。生产级的爬虫必须处理重连。websocket-client的run_forever自带重连参数(reconnect),但默认行为不一定符合需求,建议自己在外层包一个循环,控制重连间隔和最大重试次数,并在重连后重新计算sign(因为时间戳变了,旧sign可能已过期)。
重连时还有个细节:如果业务需要"从断点续传",得看服务端协议是否支持。有些平台在连接建立后需要客户端发一个"订阅"消息,指定要哪些数据;重连后必须重新发订阅,否则连上了也收不到推送。这个订阅消息的格式同样从抓包的Messages里找,通常在连接建立后客户端发的第一条业务消息就是它。
6. 几个让我多花了两小时的坑
逆向这类项目,大方向清楚了,真正耗时间的往往是细节。挑几个我实际踩过的、比较有代表性的坑说说,省得你重复交学费。
6.1 sign对但连接被拒:Origin和UA的隐形校验
第一次自己构造请求时,sign算得和抓包一模一样,但服务端就是返回403拒绝升级。排查了半天才发现是Origin头的问题——浏览器发WebSocket握手时会自动带Origin,值必须是平台自己的域名,而我的脚本没带或者带错了。补上正确的Origin后立刻通过。User-Agent也是同理,有些平台会校验UA是不是常见浏览器,脚本默认的UA一眼就被识别。这两个头看着无关紧要,实际是服务端风控的常见抓手,务必和抓包保持一致。
6.2 时间戳精度导致的偶发失败
有段时间sign时对时错,成功率大概七成。查了很久发现是时间戳精度问题:hook出来的输入里,时间戳是13位毫秒值,而我代码里某处不小心用了10位秒值参与哈希。因为大部分时候服务端校验宽松没报错,但偶尔偏差大了就失败。这种偶发问题最折磨人,解决办法就是把hook到的原始输入和代码生成的输入做严格逐字符比对,别靠肉眼扫,写个脚本diff。
6.3 心跳线程与主线程的资源竞争
心跳用独立线程发,主线程收消息,两者共用同一个ws对象。在连接关闭的瞬间,心跳线程可能还在尝试send,抛异常。虽然被catch住了,但日志里一堆报错很干扰排查。后来改成用一个标志位控制心跳线程的生命周期,on_close里把标志置False,心跳循环检测到就退出,干净很多。多线程操作同一个连接对象时,这类生命周期管理一定要做,否则偶发的异常会掩盖真正的问题。
7. 关于这类逆向工作的边界与心态
技术层面讲完了,说点务实的。这类协议逆向和前端hook,本质是在理解对方的设计,然后按对方的规则通信。我的经验是,把精力放在"搞懂机制"上,而不是"绕过限制"上——前者让你在任何类似平台上都能快速上手,后者往往是一次性的、脆弱的。每次逆向完,我都会把hook到的算法、握手参数结构、心跳格式整理成文档,下次遇到同类平台,八成能直接套用思路,省下大量时间。
另外,前端代码的混淆手段在进化,今天hook得到的东西,明天平台改版可能就失效。所以爬虫代码要把"加密逻辑"和"业务逻辑"解耦,加密部分做成可替换的模块,改版时只换那一块,主体不用动。这个工程习惯比任何单次逆向技巧都值钱。我在实际项目里就是靠这种模块化,把一个平台的采集代码复用到好几个结构相似的目标上,维护成本降了一大截。