1. h5st 3.1 的入口:从请求参数和启动器开始
1.1 签名串的结构
如果你抓过京东联盟或京东商城相关接口的请求,大概率见过h5st这个参数。它通常是一段非常长的字符串,一眼扫过去像随机字符,但仔细拆解会发现,不同接口返回的h5st长度和分段方式基本一致。我第一次接触时,先把它当作普通的 token 处理,直接复制粘贴到新的请求里,结果接口返回“签名错误”。后来才发现,h5st跟时间戳、请求参数、用户身份、设备环境都强绑定,换个参数就得重新生成,根本没办法静态复用。
从抓包数据来看,h5st参数一般由几段用点号或固定分隔符拼接的内容组成。常见的组成方式包括:时间戳、随机数、算法标识、核心密文、环境指纹和校验位。这些字段不是简单的明文拼接,中间会经过多轮编码和压缩,有些字段还做了自定义 base64 映射。拿我实际调试过的请求举例,同一个账号在不同时间点请求同一个接口,h5st的前面几段会变,但结构稳定;不同接口之间,参与签名的业务参数不同,所以后面几段差异很大。这说明服务端校验的不仅是“会不会生成”,还会校验“生成的内容是否和当前请求上下文匹配”。
这里有个很关键的点:h5st不是单独的加密接口返回的,而是在前端页面运行时动态生成。这意味着只要把前端 JS 的执行逻辑研究清楚,就能复现整个签名流程。问题在于,京东联盟的 JS 文件体积巨大,且做了多层混淆,单纯靠肉眼读代码基本行不通。我后来的思路是:先通过调用栈定位生成入口,再结合 DOM 和网络请求的上下文反推输入,最后才进入算法还原阶段。
1.2 从 3.0 到 3.1,难度主要加在哪里
h5st存在多个版本,我这次研究的是 3.1。网上关于 3.0 的分析文章不少,但 3.1 的分享相对少,主要原因在于它把逆向的“成本”提高了。从我的实际体验来看,3.1 相比 3.0 至少在三方面做了加强。
第一是加载方式更隐蔽。3.0 时代比较容易通过搜索h5st关键词直接定位到核心函数,但 3.1 的核心逻辑被拆分到多个异步加载的模块里,入口函数名做了批量混淆,变量名全部变成无意义短字符,字符串也被编码成数组,只有在运行时才还原。直接全局搜索h5st只能找到赋值语句,找不到真正的加密实现。
第二是环境校验更严格。3.1 在签名生成过程中会读取很多浏览器环境信息,比如navigator对象、document对象、Canvas 指纹、WebGL 渲染信息等。这些信息不仅参与签名,还会在签名内部做二次校验。如果你用 Node.js 直接跑还原出来的代码,很容易因为缺少浏览器环境而生成一个“看起来正常但对不上”的签名。更头疼的是,某些环境字段的读取顺序和拼接方式也参与计算,顺序错了结果也会错。
第三是动态化增强。3.1 的算法里引入了跟时间相关的动态因子,测试时经常遇到同一份代码、同一个参数,上一秒生成的签名能用,下一秒就被拒绝。后来排查发现,时间戳不仅作为字符串拼接,还参与某个分段函数的位移和异或运算,如果本地系统时间和服务器时间偏差超过阈值,整个签名都会失效。
1.3 在整条风控链路中的位置
研究h5st容易陷入一个误区:以为把签名算法还原出来,就能高枕无忧了。实际上,签名只是京东联盟反爬体系里最外层的一环。服务端拿到请求后,会先校验签名是否合法、是否和当前请求参数匹配,然后还会校验账号身份、设备指纹、请求频率、行为轨迹。就算签名完全正确,如果请求频率异常,或者设备指纹频繁跳变,照样会被拦截或者触发验证码。
所以我在定位签名算法之前,先想清楚了一个问题:我到底要解决什么?如果只是临时跑通一个接口,那手动从浏览器复制签名都能凑合;如果要长时间、稳定地拿到数据,就必须把签名放到完整的请求链路里去考虑。后面几节的内容,都是在这个前提下展开的。
2. 从 JS 文件堆里定位签名函数
2.1 关键词搜索的“正确姿势”
很多新手拿到 JS 文件后,第一反应是在开发者工具里全局搜索h5st。这个方法不是不能用,而是要讲究技巧。直接搜h5st会搜出一堆赋值、传参、接口响应字段,真正有价值的往往是搜索h5st=或者"h5st"带引号的写法。如果文件做了字符串编码,这两招也可能失效。
我常用的做法分三步。第一步,在 Network 面板里过滤出承载签名的请求,查看它的 Initiator 调用链,从调用链里找到发起请求的 JS 文件和具体行号。第二步,在 Sources 面板里给那一行打断点,刷新页面后逐步进入函数调用。第三步,在 Console 里主动调用可疑函数,通过输出内容判断这个函数是不是签名入口。
这个方法比单纯搜索快得多。因为浏览器已经把“哪个函数构造了这次请求”告诉你了,你只需要顺着调用链往回走。尤其是现在的前端工程化程度很高,一个业务请求往往要经过拦截器、请求封装、参数序列化等多个层,每一步都可能在修改参数,直接搜索关键词很容易被干扰。
2.2 通过调用栈回溯到加密核心
定位到发起请求的位置后,下一步就是单步调试。在断点处逐步执行,观察h5st是在哪个变量里第一次出现。通常会发现,签名在请求发出前的一两步就已经生成完毕,你需要继续向上追踪,找到调用生成函数的位置。
这里有个非常实用的技巧:在 Console 里执行debugger命令,或者在可疑函数的第一行手动加断点,然后把调用栈完整展开。调用栈里每一层函数名都非常有价值,即使它们被混淆成a、b、c,只要结合所在文件和行号,就能大致推断出执行顺序。我习惯把整个调用栈截图保存,然后一层层往外部翻,直到找到那个“输入参数业务数据、输出 h5st 字符串”的核心函数。
在 3.1 里,这个核心函数往往不是直接暴露在全局作用域中的,而是被包在一个模块内部。如果你直接点击调用栈里的函数名,跳转到对应代码位置,看到的可能是一堆数组下标和循环。这时候不要急着读代码,先在 Console 里手动调用函数,传入不同的参数,观察输出变化,通过黑盒测试建立输入输出关系。
2.3 处理混淆和动态加载
3.1 的混淆策略很典型:字符串数组 + 下标引用 + 自执行解密函数。如果直接阅读源码,会看到类似_0xabc['\x63\x6c\x61\x73\x73']这样的写法,实际含义是读取某个属性。要处理这种混淆,可以借助 AST(抽象语法树)工具把解密函数执行一遍,把字符串数组还原成明文,再用变量名替换工具把混淆后的标识符改成可读名称。这个过程不需要一次做完整,只需要把关键函数局部还原即可。
动态加载是另一个麻烦点。核心加密模块可能是通过 JSONP 或动态脚本标签加载的,不在最开始的 HTML 里直接出现。遇到这种情况,要先在 Network 面板里找到加载的脚本文件,然后重新执行加载逻辑。更简单的做法是:在页面真正触发了签名请求之后,再在 Console 里执行Object.keys(window),把新增的全局变量滤出来,往往能在里面找到加密模块的引用。
3. 算法还原:从“会跑”到“跑得对”
3.1 补环境:让加密模块以为自己在浏览器里
定位到核心函数后,最直接的做法是用 Node.js 把相关 JS 文件加载进来,然后调用函数。但通常不会一次成功,因为加密模块内部会访问window、document、navigator等浏览器对象。缺一个对象,函数就直接抛异常;就算不抛异常,某些undefined值参与运算后,也会让最终结果跟真实浏览器不一致。
补环境的思路,就是用全局变量模拟浏览器对象。一开始可以简单粗暴一点:
global.window = global; global.document = { createElement: function() { return { getContext: function() { return {} } }; }, cookie: '' }; global.navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', language: 'zh-CN', platform: 'Win32' };这能解决一部分问题,但 Canvas 指纹、WebGL 信息这类需要真实绘制结果的字段,靠模拟对象很难精准还原。我的经验是,先跑起来看报错,报什么补什么,不要一开始就追求完整。等函数能正常输出签名后,再拿这个签名去请求真实接口,如果还是失败,再回头核对环境字段的取值和拼接顺序。
3.2 黑盒对比法验证算法逻辑
在还原过程中,我强烈建议你把浏览器当成“标准答案”。具体做法是:在浏览器里正常操作,在生成签名的函数入口打断点,记录入参和出参;然后在本地代码里用相同入参调用还原出的函数,对比出参是否一致。如果两次结果一模一样,说明这一步的还原是对的;如果不一样,说明某个环境字段、混淆逻辑或参数序列化方式有问题。
这一步我吃了不少亏。有次我反复检查算法,发现计算步骤完全一致,但签名就是用不了。后来在浏览器里详细对比才发现,参与签名的业务参数不是简单地把 JSON 序列化,而是先对 key 做了排序,又过滤了空值和指定字段。这个细节在混淆代码里根本看不出来,只有通过黑盒对比才暴露出来。所以我后来习惯把所有参数都原样打印出来,再和浏览器里的arguments对象逐字段比对,宁可多打印,也不靠猜。
3.3 RPC 桥接方案和本地执行的取舍
很多实际项目并不会把整个算法完整还原到 Node.js,而是采用 RPC(远程过程调用)方案:在浏览器里注入一段脚本,将生成签名的函数包装一层,把业务参数发到本地服务,生成签名后再返回给请求脚本。这个方案的好处是能完美复用浏览器的环境,不用花时间补环境,也不容易被环境校验卡住。
RPC 的核心代码不难写,大概思路是暴露一个全局函数供外部调用:
window.__generateH5st = function(params) { return window.h5stCore.generate(params); };然后在本地用 WebSocket 或 HTTP 服务接收参数、调用这个全局函数、返回结果。这种方式非常实用,特别是在时间紧迫、核心算法又极其复杂的时候。但它也有明显的短板:依赖浏览器实例,内存和 CPU 占用高,浏览器容易被检测或崩溃,维护成本一点也不低。
纯粹的算法还原虽然前期难度大,但跑起来后只需要一个极小的 Node.js 服务,性能和稳定性都更好。我的建议是,先评估目标接口的调用量。调用量小,用 RPC 最划算;调用量大、要求高稳定,还是一步步把算法啃下来。
4. 反爬对抗的完整策略:签名只是入场券
4.1 签名之外还有哪些维度
同一种签名算法往往对应着一整套反爬策略。h5st校验的是“请求是否由正常前端发出”,但对服务端来说,这还不够。它还会看这个请求是谁发的、从什么设备发的、是不是符合正常用户的操作习惯。
我见过不少团队,费了很大力气把签名算法还原得完美无缺,结果请求频率一高,照样被限制。原因很简单:正常用户不可能每秒请求十几次接口,就算请求签名合法,行为特征已经暴露了。所以做接口数据研究时,除了关注签名,还得关注请求频率的控制。一个相对安全的节奏是:每个账号每秒钟最多一两次请求,每个 IP 的并发数也要有限制。
另外,请求头里的User-Agent、Referer、Origin这些字段也要保持一致。很多人只改了签名的算法,却忽略了这些基础字段,结果请求看起来像“一个没有来源的程序”。正确的做法是先拿真实的请求头做模板,保留大部分字段,只改必要的内容,比如时间戳、签名字段和业务参数。
4.2 请求频率与数据获取节奏
控制节奏的核心不是“越慢越好”,而是“像人一样”。正常用户浏览页面时,会先加载页面,阅读一段时间,再触发下一个请求。请求之间的间隔是随机的、不规律的。如果程序每 5 秒固定请求一次,连续请求几百次,特征非常明显。建议在两次请求之间增加随机休眠,比如 2 到 6 秒之间随机取一个值,而且在某些关键节点(比如翻页、筛选)主动增加额外延迟。
还有一点,尽量不要在同一时间并发大量请求。我曾经测试过,并发 50 个请求时,即便签名完全正确,也很快触发了验证码。后来把并发降到 5,并且每个请求之间加入随机延迟,连续跑了一下午都没出问题。这说明服务端的风控不只看单个请求,还会统计窗口期内的请求总量和并发曲线。
4.3 设备指纹、行为轨迹和验证码
除了请求频率,设备指纹也是重要维度。h5st本身就可能包含指纹信息,同一套指纹反复用,或者指纹跟请求头里的 UA 明显不匹配,都容易被识别。如果条件允许,尽量让每个账号对应一个固定的指纹环境,不要所有请求共用一个浏览器指纹。
行为轨迹主要发生在页面端。有些接口会在请求时上报鼠标移动、点击、滚动事件,这些事件数据会被服务端用来判断“屏幕前是不是真人在操作”。程序化请求一般不会携带这些数据,这反而成为一个区分点。针对这个情况,如果只是调接口,很难模拟完整轨迹;更可行的方案是使用真实的浏览器环境(比如 Playwright、Puppeteer)去操作页面,让浏览器自动产生行为轨迹,再加上生成的签名。这种方式对服务器的压力更大,但风控分数要高得多。
验证码是最后的兜底措施。一旦触发验证码,单纯靠换 IP 往往解决不了,因为验证码经常和账号绑定。这时候宁可停下来,降低请求频率,让账号冷静一段时间,也不要频繁尝试,否则可能被标记为恶意账号。
5. 实战中我踩过的坑
5.1 反调试:debugger断点和无限循环
研究 3.1 时,我在浏览器里刚打开开发者工具,准备打断点,页面就莫名其妙地卡住了。后来发现是代码里放了debugger语句,配合定时器,一旦检测到调试状态,就进入无限循环。这种反调试手段在现在的加密场景里很常见,解决办法也比较成熟:在断点处右键选择“Never pause here”,或者在 DevTools 设置里禁用 JavaScript 源码映射,甚至可以直接在代码里把debugger替换成空语句。
我更喜欢用更简单的方式:不在 DevTools 里长时间停留,而是通过编写脚本注入的方式自动完成打断点、记录参数、恢复执行的全过程。这样可以减少手动操作触发反调试的概率,也方便批量测试不同参数下的签名结果。
5.2 时间戳和时钟偏移
有一阵子,本地还原的签名在浏览器里能用,但放到服务器上就报签名过期。排查了大半天,最后发现是服务器系统时间比北京时间快了大约 40 秒。h5st签名里嵌入了客户端时间戳,服务端会校验这个时间戳与它自己收到请求的时间是否接近。如果偏移超过一定阈值,直接判定为非法请求。
解决办法很简单:每次生成签名前,先调一个时间接口校准服务器时间,或者直接取“服务器响应的时间 + 网络延迟估算值”作为签名时间基准。如果使用容器部署,最好在启动时自动同步时间,避免宿主机和容器之间的时间漂移累积。
5.3 代理 IP 的 TLS 指纹问题
当请求量上来后,单 IP 很容易被限制,很多人会引入代理 IP。这里的坑在于,有些代理 IP 不仅 IP 地址变化,TLS 握手的指纹特征也可能被识别。正常的浏览器(比如 Chrome)在 TLS 握手时会带上特定的扩展字段和服务端协商算法,而 Python 的requests或 Node.js 的默认 HTTP 客户端指纹完全不同。即使签名正确,代理 IP 的 TLS 指纹也会暴露“这不是浏览器”。
解决办法是用curl-impersonate或配置更底层的 TLS 库来模拟浏览器的指纹。实际操作时,建议先用一个高质量代理抓包,比对请求的 TLS ClientHello 指纹是否和本地一致。如果不一致,换工具或换代理服务商,不要盲目堆参数。
6. 稳定运行与合规边界
6.1 方案选型:浏览器模拟还是纯算法实现
我在前面提过,RPC 方案和算法还原方案各有利弊。如果目标是长期稳定运行,我倾向于算法还原,因为依赖更少、速度更快、不容易被浏览器崩溃影响。但算法还原的时间成本很高,尤其是遇到h5st这种频繁更新算法的场景,可能需要反复维护。而浏览器模拟方案更像“跟着官方走”,只要浏览器能正常访问,签名就不会过期,但需要更多机器资源。
实际项目中,不少人会做混合方案:先使用 RPC 或浏览器模拟快速跑通业务,同时慢慢啃算法,等算法还原得差不多了,再逐步替换浏览器依赖。这个过渡策略能让业务先跑起来,又给后续优化留了空间。
6.2 签名服务的监控与报警
签名服务上线后,监控比开发更重要。我通常会在签名服务里埋点,记录生成签名的耗时、成功率、异常类型。一旦发现成功率开始波动,立刻检查是不是算法变更了,还是某个代理 IP 段被限制。因为没有监控的话,签名失效通常要等到业务方反馈才意识到,那时候损失已经造成了。
具体监控指标可以包括:每小时签名生成次数、签名被服务端拒绝的比率、生成耗时 P99、浏览器实例存活数。如果被拒绝率突然超过 10%,就要立刻告警。签名被拒绝的原因很多,可能是算法更新,也可能是环境字段缺失,需要设计一个快速切换通道,比如在多个签名版本之间动态切换。
6.3 研究边界与合规使用
最后说几句可能不中听但很重要的话。h5st这类加密参数的逆向分析,最好用在你自己有权限、已获得授权或明确允许的技术研究场景中,比如给企业做安全评估、研究自己的账号数据、或者学习前端加密与风控体系的实现原理。不要用这些技术去大量获取他人数据或对公共服务造成压力,这不仅可能违反服务协议,也可能踩到法律红线。
我自己研究这类签名,更多是出于对前端安全体系的好奇和敬畏。电商平台投入大量资源做反爬,是因为数据价值和用户体验都需要保护。技术人员研究它,是为了理解安全对抗的攻防思路,而不是为了破坏。这样想,你会更有耐心去啃那些枯燥的混淆代码,也更能在每次突破之后获得真正的成就感。
回头看我折腾h5st3.1 的整个历程,最深的体会是:真正的难点不在算法本身,而在于“稳定地模拟一个真人环境”。签名充其量只是门锁,门后面还有走廊、房间和保险柜。与其把所有精力都放在开锁技巧上,不如系统性地理解整套安全架构,再结合自己的业务场景找到最合适的平衡点。这也是为什么我坚持把签名逆向和风控对抗放到一起写,因为它们本来就是一回事。