JS逆向 - 某平台 w-payload-source 纯算、补环境分析
最近在和一个做数据采集的朋友聊技术,他卡在了某旅游平台的签名参数上,前端请求里有个w-payload-source,每次刷新页面或调用接口都会变化,直接硬编码根本不行。我上手看了一圈,发现这块属于典型的“纯算法 + 补环境”双修问题:一部分逻辑不依赖浏览器环境,直接在JS里就能算出结果;另一部分则依赖运行时环境特征,比如navigator、canvas、WebGL、时间戳、DOM 指纹等,必须在浏览器环境里才能通过检测。这篇文章就是我从零开始拆解这个参数、做纯算还原、再补环境绕过校验的完整过程,既包含核心算法定位与还原思路,也包含补环境框架的搭建细节,适合有 JS 基础、想深入逆向方向的朋友作为一个完整练手案例来参考。
先说结论:这块难点不在“破解算法”,而在“拆散混淆”和“检测对抗”。w-payload-source本身不是单一参数,而是一组加密后的结果,包含了业务关键字段、防重放时间戳、以及浏览器指纹信息。我做纯算还原时先绕开环境差异,只分析注册表和算法逻辑,再把环境检测项按依赖关系逐项补齐,最终在纯 Node 环境下跑通了签名生成,并且能稳定通过服务端的校验。下面把整个过程完整拆给你看。
1. 项目背景与逆向思路拆解
1.1 w-payload-source 是干什么的
在进入任何技术细节之前,先搞清楚这个参数在请求中扮演什么角色。某旅游平台的前端加密方案属于典型的“前端签名 + 风控字段”组合,服务端拿到请求后不仅要验证参数合法性,还要校验请求是否来自“真实浏览器环境”。
w-payload-source这个参数名称里其实暗含了两层意思:
payload指业务请求体的原文,也就是你真正想传给后端的数据。source表示这段 payload 的“来源凭证”,即加密签名和完整性校验信息。
实际抓包看到的结构类似下面这样:
{ "url": "/api/hotel/search", "data": { "city": "beijing", "checkIn": "2025-06-01", "checkOut": "2025-06-03" }, "headers": { "w-payload-source": "0f75b2bef3c23...(一串很长的十六进制字符串)" } }服务端拿到w-payload-source后,会解密出其中的明文信息,比对业务参数是否一致、时间戳是否在有效期内、指纹是否匹配。这三重校验缺一不可,所以我逆向的目标就很明确了:把这个参数从生成到验签全链路摸清楚。
1.2 逆向路径:先纯算,后补环境
我在拆解时采用了一个固定的两段式思路,第一阶段完全不碰浏览器环境检测逻辑,只关注算法本体;第二阶段再处理环境依赖项。
为什么这么排?因为纯算法部分是“静态可分析”的,它不依赖运行时状态,拿到混淆后的 JS 就能逐步还原;而补环境部分需要你在执行时动态观察、逐项模拟,如果一开始就混在一起,很容易被环境检测的异常干扰,根本分不清是算法还原错了还是环境模拟不到位。
具体路径如下:
- 抓包拿到携带
w-payload-source的完整请求,确定触发链路。 - 全局搜索参数名字符串,定位加密入口文件。
- 在入口处断点,追踪调用堆栈,找到核心算法函数。
- 分析算法逻辑:区分纯算部分和环境检测部分。
- 先将纯算逻辑脱离浏览器执行验证,确认明文加密结果正确。
- 对依赖环境的代码分支,逐项补环境并验证。
- 最后整体跑通签名生成,对比线上请求的格式完全一致。
这套流程是 JS 逆向的通用方法论,w-payload-source只是它的具体载体。整个过程中,最耗时的部分是第 4 步和第 6 步。
1.3 纯算和补环境的边界在哪里
很多人会在这两个概念上犯迷糊,觉得“纯算”就是完全不用浏览器,“补环境”就是开个浏览器模拟。其实两者的边界没有这么僵硬。
纯算指的是算法本身(哈希、加密、编码、序列化)不依赖宿主环境提供的数据,只要输入相同,在任何 JS 运行时下输出都相同。这类代码的特点是:使用的都是 ECMAScript 标准内置对象,比如Date、Math、String、Array,不触碰window、document、navigator。
补环境则针对那些依赖浏览器特有 API 的代码分支,比如:
navigator.userAgent读取浏览器 UAcanvas.toDataURL获取图像指纹screen.width/height读取屏幕分辨率window.chrome读取浏览器内置对象performance.now()或Date.now()获取时间基准
判断一段代码属于哪一类,不需要猜。你在 Node 里直接执行一次,报错就说明有环境依赖;不报错但结果和浏览器不一致的,说明有隐藏的环境依赖。我用这个方法快速把整个加密文件分成了“纯算区”和“环境区”两块。
2. 纯算部分:从混淆代码到可读算法的还原过程
2.1 关键字定位与入口函数锁定
第一次拿到压缩后的混淆代码时,第一反应就是先格式化。我用 prettier 把单行压缩的代码展开成可读的多行结构,然后全局搜索w-payload-source这个字符串。
搜索命中后,找到类似下面这段赋值逻辑:
var T = { "w-payload-source": (0, e.someFunc)(i.stringify(f.data)) };这里的f.data是业务请求体,i.stringify是序列化函数,e.someFunc是具体的加密函数。看到这个结构基本能确定:w-payload-source的值等于“请求体序列化后的加密结果”。此时要做的就是把someFunc的内部实现扒出来。
在 DevTools 的 Sources 面板中给这个赋值语句打断点,刷新页面,堆栈会停在断点处。此时查看右侧 Call Stack,就能看到完整的调用链路。顺着链路往上找,通常能发现一个更大的初始化函数,里面完成了e这个对象所有方法的注册,加密算法就在其中。
2.2 混淆代码的两种常见形态
定位到核心加密函数后,会发现代码被混淆过,常见形态有两种:
第一类是字符串拼接混淆。比如把函数名或属性名拆成多个片段,运行时再用数组索引拼接:
var a = ["getTime", "toString", "substring"]; var b = a[0] + a[1]; // "getTimetoString"这类的还原思路就是先运行代码,让数组展开,再在关键函数处打条件断点,动态查看真实调用的方法名。
第二类是控制流平坦化。原始逻辑被压平成一个大循环,用switch分支跳转:
while (true) { switch (state) { case 0: // 执行某段逻辑 state = 3; break; case 3: // 执行另一段逻辑 state = 1; break; } }控制流平坦化本身不改变算法逻辑,只是让阅读成本变得极高。我的处理方式是:先用执行流程跟踪,打印每个state进入顺序,画出一个状态转移表,再按顺序重组逻辑代码。这个过程比较费时间,但它是纯机械工作,不需要天马行空的灵感。
2.3 核心算法还原:以 HmacSHA256 + Base64 为例
某平台这个参数的纯算核心并不复杂,抛开混淆外壳后,本质上是三段式算法:
- 对业务参数做 JSON 序列化(固定字段顺序)
- 拼接上时间戳和指纹值,构成待签名字符串
- 使用 HmacSHA256 计算摘要,再做 Base64 编码
还原后的伪代码如下:
const crypto = require('crypto'); function generatePayloadSource(data, timestamp, fingerprint) { // 1. JSON 序列化,字段按字典序排序 const sorted = sortObjectKeys(data); const serialized = JSON.stringify(sorted); // 2. 拼接待签名字符串 const signStr = [ serialized, timestamp, fingerprint ].join('|'); // 3. HmacSHA256 + Base64 const secretKey = 'your-secret-key-from-code'; const hash = crypto.createHmac('sha256', secretKey) .update(signStr) .digest('base64'); return hash; }实际还原过程中有几个细节值得注意:
sortObjectKeys不是简单的Object.keys().sort(),而是递归处理嵌套对象,内部字段也会被排序。- 拼接顺序是固定的,先是业务参数序列化结果,再是时间戳,最后是指纹值,三者用
|分隔。 - 密钥
secretKey不是硬编码字符串,而是由前面代码实时计算出来的,比如某个常量数组按位异或后得到。
发现secretKey是动态生成时,纯算验证一度卡住。后来我在还原时没直接复制密钥,而是保留了它的计算函数,在 Node 环境里执行得到同样的值,再喂给 HmacSHA256。这样依然算“纯算”,因为密钥本身不依赖浏览器环境。
2.4 纯算验证的自测方法与常见坑
纯算部分还原完成后,我用一段自测脚本验证逻辑是否完全正确:
// 模拟真实请求日志中的输入和输出 const data = { city: "beijing", checkIn: "2025-06-01" }; const timestamp = 1718000000000; const fingerprint = "mocked-fp-123"; const result = generatePayloadSource(data, timestamp, fingerprint); console.log(result);然后把结果和线上真实请求的w-payload-source对比。这里要注意,线上真实值对应的时间戳和指纹值需要从请求记录的另一个自定义头或 cookie 中提取,如果拿不到精确输入,对比就没有意义。
常见的坑有两个:
第一个是 JSON 序列化时字段顺序不一致。JavaScript 对象在序列化时一般按插入顺序输出,而平台代码在序列化前做过sortObjectKeys,如果你还原时漏掉这一步,签名结果必然不同。
第二个是编码问题。服务端校验签名时对 Base64 字符串的 padding(末尾等号)处理可能有特殊规则。有些平台要求去掉=,有些则要求 URL-safe 模式(+换-,/换_)。我踩过这个坑,卡了好久排查不出来,最后对比真实请求发现是编码模式不匹配,改完就通了。
另一个重要的测量方法:把时间戳和指纹值固定住,验证不同业务数据下的签名结果是否始终和线上一致。如果所有样本都通过,可以确认纯算逻辑无误,再进入补环境阶段。如果有的样本不通过,大概率是你对动态密钥的还原有遗漏。
3. 补环境分析与指纹校验机制
3.1 为什么纯算对了还是过不了校验
纯算逻辑全部还原后,我在 Node 环境跑出了和线上格式一致的签名,以为大功告成,结果一发请求就被服务端拒绝。返回的响应体里没有任何明文错误信息,只有一串状态码,比如-503或-405,意味着一句话:签名无效或环境不匹配。
原因很简单:服务端校验的不只是“签名算法正确”,它还要求签名里携带的指纹必须和真实浏览器环境匹配。我的 Node 环境根本没有浏览器指纹,所以即使算法算得对,验签环节依然会失败。
此时就需要进入补环境阶段。补环境的目标是在非浏览器运行时(比如 Node)中模拟出一个符合真实值要求的浏览器环境,让目标 JS 在执行时能获取到期望的浏览器特征,从而通过环境校验。
3.2 找出环境检测点:执行并报错
补环境的第一步永远是“找出目标代码到底用了哪些环境变量”。我在 Node 中直接执行还原后的签名函数,然后把报错信息记录下来。比如:
ReferenceError: window is not defined这个报错意味着代码里访问了全局window对象。此时我在代码根目录创建一个mockEnv.js,先定义最基础的全局对象:
globalThis.window = globalThis; globalThis.navigator = { userAgent: '...' }; globalThis.document = {};补一个跑一步,再补再跑,直到代码不报错为止。但这只是最粗略的“不报错”,离“结果一致”还差得远。
3.3 指纹校验的深层逻辑
环境检测不只是简单读取navigator.userAgent,很多平台会做多维特征交叉验证。某平台w-payload-source验签涉及的指纹项主要包括:
| 特征类别 | 具体字段 | 说明 |
|---|---|---|
| UA | navigator.userAgent | 浏览器版本、系统类型 |
| 渲染指纹 | canvas.toDataURL() | canvas 绘图后的像素数据哈希 |
| WebGL | WebGLRenderingContext.getParameter | GPU 名称、渲染器信息 |
| 屏幕 | screen.width/height/colorDepth | 屏幕分辨率、色深 |
| 时区 | Date.getTimezoneOffset() | 时区偏移 |
| 插件 | navigator.plugins | 浏览器插件列表 |
| 存储 | localStorage/sessionStorage | 是否存在且可写入 |
这些特征值会被采集并拼成一个指纹字符串,参与最终的 HMAC 签名。所以要过校验,不能只把字段随便填上,必须让每个字段的值都符合“一个真实浏览器应该有的样子”。
实际操作中,我先是直接用真实 Chrome 的 UA 字段,再把 canvas 指纹输出和线上日志的指纹做对比,发现不一致。原因在于 canvas 指纹依赖于字体渲染和抗锯齿算法,在不同操作系统、不同浏览器内核下绘制结果都不同。我在 Node 里模拟 canvas 时,如果只是返回一个空画布的数据,算出来的指纹和真实浏览器当然对不上。
解决思路是:不走 canvas 真实绘制,而是直接截获目标代码里对canvas.toDataURL()的调用,返回从真实 Chrome 里预取好的合法值。也就是说,Node 里不需要真的能绘图,只需要“提供真实浏览器产生的数据”。
对应实现如下:
// 重写 canvas.toDataURL const mockCanvas = { getContext: function () { return { /* 返回预取的数据 */ }; }, toDataURL: function () { return FINGERPRINT_FROM_REAL_BROWSER; } };这类“假环境返回真数据”的方案,本质上不是让 Node 模拟浏览器绘图,而是让代码在调用环境 API 时拿到它预期的那份数据。这是我个人认为补环境的核心思路:不追求完整模拟,只追求在目标代码关心的路径上,返回它想要的数据。
3.4 补环境框架的搭建与层级
补环境不能“一把梭”,我通常分三个层级搭建:
第一层是全局对象层,定义window、document、navigator、screen、location这些基础的全局变量。每个变量里的属性需要依据真实浏览器补全,不需要 100% 完整,但目标代码用到的属性必须存在。
第二层是功能方法层,比如document.createElement、canvas.getContext、localStorage.getItem。这些方法要被目标代码调用,返回值必须符合业务预期。对实现困难的方法,优先采用“从真实浏览器导出数据,在模拟环境静态返回”的方案。
第三层是检测对抗层。有些环境检测不是直接读值,而是通过“调用某个方法并检查是否报错”来判断环境真伪。比如判断navigator.webdriver是否存在、检查Window.prototype.toString是否被篡改等。这一层需要你从堆栈和异常信息里慢慢挖掘,往往一个很小的漏补就会导致整体校验失败。
我建议用代理模式来实现模拟环境,这样能监听所有属性访问,迅速定位漏补项:
const envProxy = new Proxy(globalThis, { get(target, prop) { if (prop in target) { return target[prop]; } // 记录缺失的属性访问,方便排查 console.log('[Missing property]', prop); return undefined; } });用代理包裹后,目标代码每次访问不存在的属性都会留下日志,逐步补齐即可。这个技巧在复杂补环境项目中非常好用,能省掉大量猜测时间。
4. 常见问题与排查技巧实录
4.1 签名算法正确但结果仍不一致
这是我做纯算时最常遇到的问题。算法逻辑看起来完全正确,但生成结果和线上请求始终差几位或完全不同。排查方向按优先级排序:
- 检查 JSON 序列化时字段顺序是否一致
- 检查时间戳格式(毫秒 vs 秒)
- 检查拼接顺序(字段间分隔符、是否有额外空白)
- 检查 Base64 编码模式(标准模式 vs URL-safe)
- 检查密钥
secretKey生成链路中的每一环
一个很实用的技巧是:用线上的真实请求样本做“输入输出对照”,把已知的时间戳和指纹值代入,对比生成结果。如果对不上,再用二分法逐步缩短代码片段,定位是哪一步计算分叉了。
4.2 补环境时被卡住的两个高频点
补环境阶段最耗时的两个点,一个是 canvas 指纹,另一个是WebGL参数。
canvas 指纹需要大量真实样本数据,因为不同网站请求不同绘制逻辑,生成的数据不同。我的解决方法是:用真实 Chrome 打开目标网站页面,在控制台额外执行脚本把 canvas 指纹提取出来,存成 JSON 快照,在 Node 环境直接返回这份快照。
WebGL 参数更麻烦,因为getParameter方法的枚举值非常多,比如:
gl.getParameter(gl.VENDOR) // 返回 GPU 厂商 gl.getParameter(gl.RENDERER) // 返回渲染器名称 gl.getParameter(gl.VERSION) // 返回版本号补 WebGL 时要特别注意:目标代码可能先检查了WebGLRenderingContext是否存在,再调用canvas.getContext('webgl')。如果getContext直接返回null,后续逻辑会走异常分支,生成的指纹完全不同。正确做法是构造一个模拟的WebGLRenderingContext对象,把getParameter实现为根据枚举值返回预取数据。
4.3 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 签名值永远对不上 | 字段顺序或拼接顺序错误 | 逐个比对关键词出现位置 |
| 签名值偶尔对不上 | 时间戳不是固定值,重新生成导致漂移 | 确认服务端校验时间戳窗口 |
| 请求返回环境不匹配 | navigator关键属性缺失 | 用代理监控捕获缺失属性 |
| canvas 指纹异常 | 绘制参数和真实环境不一致 | 静态返回真实浏览器预取数据 |
| WebGL 相关报错 | 缺少getParameter实现 | 构造完整 WebGL 模拟对象 |
| localStorage 读不到 | Node 无此 API | 手动实现简易存储引擎 |
window.chrome未定义 | 某些平台校验浏览器内核 | 定义window.chrome对象 |
4.4 调试工具链推荐
整个项目里我用的工具相对固定,但每一步都很关键:
- 抓包工具,用来捕获请求参数和响应状态
- DevTools 的 Sources 面板,用来打断点、看调用堆栈
- 内存查找工具,用来在堆上实时检索变量值
- 代理脚本,用来注入模拟环境、记录缺失属性
- 支持条件断点的调试器,用来拦截特定参数值
特别提一下在 DevTools 中给特定函数打断点的方法:在 Sources 面板打开加密文件,右键某一行选择 “Add conditional breakpoint”,填入条件表达式。比如想查看someFunc被调用时的参数,可以写:
args && args[0] && console.log(JSON.stringify(args[0]))这样只会在传参符合条件时暂停,不会在每次请求时都停下来,省去大量无效操作。
5. 心得体会与两个实战建议
整个w-payload-source的逆向做下来,我最深的体会是:纯算和补环境之间没有明确的边界,真正的难点在于识别哪部分代码依赖了什么环境,以及如何用最小的模拟成本让环境检测通过。
关于“最小模拟成本”,我多说一句。不要试图把整个浏览器搬进 Node,那既不现实也没必要。目标代码只关心它访问的那几个属性,你只需要把那几个属性补齐,且确保返回值和真实浏览器一致即可。我见过一些初学者一上来就引入一堆浏览器模拟库,不仅执行速度奇慢,反而因为模拟得“太假”而暴露了非浏览器特征。
在实际项目里,我最后是将整个签名过程封装成了一个独立的 Node 模块,只依赖纯算逻辑和一份静态环境数据文件。每次跑签名时都会读取这份数据文件,然后合成指纹值。由于时间戳是动态生成的,每次签名结果都不同,既保证了防重放机制正常,也让服务端无法通过“固定指纹”来识别这是脚本请求。
如果你要复现这个案例,我建议你准备一台虚拟机,固定的浏览器版本、固定的屏幕分辨率、固定的字体环境,这样采集的环境数据才足够一致,不会因为系统差异反复翻车。
另外还有一个小技巧,如果条件允许,可以在目标网页里加一段临时代码,把目标参数生成前后的关键中间值都打印出来,和你在 Node 环境里的输出逐一对比。这种“内外对照法”能快速定位环境差异引起的分叉点,比盲目猜环境缺什么高效得多。
最后再分享一个对抗“动态密钥”的通用思路:不要试图直接从混淆后的代码里抄出密钥明文,那通常是分散在多个函数、多个作用域里的。正确做法是保留密钥生成函数的原始逻辑,在补好的环境里让它自己运行,再在调用点缓存结果。只要你补的环境足够真,它生成出来的密钥就是服务端认可的那一个。
希望这篇分享能帮到刚接触 JS 逆向和补环境的朋友。实操过程中很多坑是单靠理论看不出来的,遇到问题时每一步都去验证“输入是否和线上一致”,你会省下大量不必要的排查时间。