从JS逆向到Python复现:Web请求签名参数分析全流程
1. 请求在浏览器里能点出来,脚本复现就失效——先搞清"活的字段"在哪
做 Web 请求签名机制分析这件事,起点往往不是加密算法本身,而是一个很具体的挫败感:浏览器里点开一个列表页,请求参数清清爽爽,复制成 cURL 扔进 Python 脚本,返回的是空数据或者一段风控提示。这时候大部分人的第一反应是"少了个 Cookie",于是开始疯狂补 Cookie,补 Header,补 Referer,折腾一下午依然不通。真正的原因通常只有一个:请求头里那个随时间变化的签名参数(本文按标题统一称AB 参数,下文不再重复全称),是由页面里的 JavaScript 现场算出来的,它跟时间戳、查询串、浏览器环境指纹强绑定,你抄下来的那一份,从生成那一刻起就只有很短的存活窗口。
这篇内容面向的是有基本前端调试能力、想弄明白"签名参数到底怎么来的"的开发者——做数据采集的、做自动化测试的、做前端安全研究的都算。我会把这套分析流程拆成四段:怎么定位生成入口、怎么读懂混淆代码、怎么拆解算法内核、怎么在 Python 侧做到一比一复现。需要提前说明的是,本文讨论的是通用的分析方法论与语义迁移技巧,不针对任何特定平台的真实算法细节,也不提供可直接用于大规模抓取的成品脚本;所有示例中的平台一律用"某内容平台"代称。
1.1 一次请求里到底哪几个字段是被现场算出来的
很多人卡在这一步,是因为把所有会变的字段都当成"签名"了。实际拆开看,一个带风控的 Web 请求,动态字段通常分三类,性质完全不同:
| 字段类型 | 典型形态 | 变化频率 | 是否参与签名计算 |
|---|---|---|---|
| 时间戳类 | 13 位毫秒值、秒级值 | 每次请求 | 是,通常是入参之一 |
| 随机数类 | 8~16 位十六进制串 | 每次请求 | 是,用于打散重复签名 |
| 指纹类 | 屏幕尺寸、UA 摘要、插件列表hash | 会话级 | 是,通常参与摘要但不外露 |
| 环境凭证 | Cookie 里的设备标识 | 长期 | 否,但后端会做关联校验 |
| 签名结果 | AB 参数本身 | 每次请求 | 它是输出 |
这个区分非常重要,因为它直接决定了你的复现难度。时间戳和随机数是最容易对齐的,你只要保证 Python 侧生成的值和 JS 侧同构即可;指纹类是最麻烦的,因为它可能来自 Canvas、WebGL、字体列表这类浏览器独有的对象,在 Python 里根本没有对应物,只能靠固定成常量来"冻结"。我在实际项目里的一般做法是:先用浏览器环境跑出一份指纹,把它硬编码成常量塞进 Python,先跑通链路,再考虑要不要做动态化。
还有一个容易被忽略的点:签名参数的输入不只有查询串。有些实现会把 POST body 的摘要、Header 里的某些字段、甚至请求方法名一起拼进去。所以在你没搞清楚输入因子之前,任何"改一个参数试试"的调试都是在碰运气。
1.2 签名失败的三类现场表现,分别指向不同的问题
判断自己卡在哪一环,比盲目改代码有用得多。我把它归纳成三种典型现象:
第一种是返回结构正常但数据为空。这说明你的请求已经通过了最外层的路由和鉴权,但签名校验没过,后端选择静默返回空列表而不是报错,避免给爬取方明确反馈。这种情况九成是签名值算错了,或者时间戳偏差太大。
第二种是返回一段风控提示页或验证码跳转。这说明签名不仅错了,而且触发了频率或环境异常判定。这时候继续高频重试只会让情况更糟,正确的做法是停下来检查 Cookie 是否已经失效、UA 是否和生成签名的环境一致。
第三种是首次请求成功、后续连续失败。这是最迷惑人的一种。原因通常是签名里带了随机数或者递增计数器,你复用同一个签名值去请求多个接口。签名一般是一次性的,或者有很短的有效期。
提示:调试阶段一定要在代码里把"生成签名"和"发起请求"这两步拆开,把生成的签名值、时间戳、随机数都打到日志里。等你遇到第三种情况时,有没有这份日志决定了你要排查十分钟还是一整天。
2. 找加密入口:关键词搜索能省事,但断点栈回溯才是保底手段
定位生成入口是整个流程里最考验耐心的一步。网上流传的教程基本都从"全局搜索关键词"开始,比如搜参数名、搜encrypt、搜sign。这条路在没做混淆的站点上确实快,但在做了字符串数组混淆的站点上,你搜参数名可能一个结果都没有,因为参数名本身就是从数组里按索引取出来的。所以我把方法分成两条互补的路线,先走快的,走不通立刻换慢的。
2.1 请求头 Hook:绕过源码直接截获生成时刻
在页面加载之前注入一段脚本,把XMLHttpRequest.setRequestHeader和fetch的头部写入行为包一层,这是我最常用的起手式。好处是不依赖你能否读懂代码,只要请求发出去,签名值就能被截获,同时还顺手拿到了当时的调用栈。
// 在页面脚本执行前注入(通过调试器的 Overrides 或脚本拦截功能) (function () { const raw = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (/sign|token|bogus/i.test(k)) { console.log('[HEADER]', k, v); console.log(new Error().stack); // 关键:栈里往往就有生成函数的调用位置 } return raw.apply(this, arguments); }; })();栈信息在这里是金子。它不能直接告诉你哪一行是算法核心,但能告诉你是谁调用了它。顺着栈往上翻两三层,通常就能落到一个几百行的混淆函数里。这时候别急着读,先在函数入口下一个断点,手动触发一次请求,看看传进来的参数长什么样——参数列表往往比函数体本身更能说明问题。
2.2 怎么确认"断在这里就是真正的生成点"
新手最容易犯的错是看到函数里有Date.now()和一堆位运算,就认定这是加密函数,然后花两天时间去翻译一段其实是用来做埋点上报的代码。我的判断标准有三个,缺一不可:
- 输出形态匹配:函数返回值的长度、字符集要和你在请求头里看到的完全一致。比如签名是 40 多位、只含十六进制字符和少数字母,那返回一个纯数字或者长 Base64 串的函数就不是它。
- 入参可追溯:函数的参数里应该能看到 URL、查询串、时间戳这些东西,或者至少能看到一个对象,而这个对象的属性在断点处展开后能对上。
- 改一个值,输出跟着变:在断点处手动修改入参里的某个字符,单步执行完,返回值必须发生变化。这一步是排除"假生成点"最有效的手段。
我通常会在这里多花五分钟做这个验证,因为它能省掉后面几小时的无效翻译。
2.3 插桩:在不改源码的前提下把中间值吐出来
确认了生成点之后,下一步不是马上去读代码,而是插桩。混淆代码里变量名全是_0x1a2b这种,硬读效率极低,更好的办法是在关键位置注入日志,让代码自己把中间值报给你。
字符串数组是最值得插桩的地方。几乎所有商业级混淆都会把字符串抽到一个大数组里,通过一个取值函数按索引访问。你只要在那个取值函数里加一行打印,整个页面的字符串就变成明文了:
// 找到取值函数后(通常形态为 function(_0xabc, _0xdef){...}) const _orig = _0x4f3a; // 原取值函数引用 function _0x4f3a(idx, key) { const v = _orig(idx, key); console.log('STR', idx, JSON.stringify(v)); return v; }插桩之后重新触发请求,控制台会刷出一大片字符串。别嫌乱,把和签名相关的那一段(比如timestamp、sign、salt、aes、md5这些词)捞出来,算法的骨架基本就浮现了。这一步的收益远高于直接读代码,因为它把"猜测"变成了"观察"。
3. 混淆代码的阅读:AST 还原是重武器,别拿它当锤子用
走到这一步,很多人会立刻去找 AST 还原工具,把代码扔进去跑一遍,指望出来一份可读性良好的源码。我的经验是:AST 还原很有用,但用错时机反而拖慢进度。判断标准很简单——如果代码只有两三百行,控制流平坦化(switch-case 那种)不超过三层,字符串表也就几百项,那手动改比写脚本快得多;只有当代码量大到几千行、还原规则要反复迭代的时候,才值得投入时间做自动化。
3.1 什么规模值得上 AST,什么情况手改更划算
我给一个粗略的分界线:字符串数组超过一千项、或者平坦化后的 switch 分支超过二十个、或者同样的取值函数被调用上百次——满足任意一条,就上 AST。反之就手改,配合浏览器调试器的"美化代码"和"格式化"功能,改完一段验证一段,速度其实很快。
这里有个反直觉的点:不要追求一次性完整还原。我见过有人写了八十条还原规则,跑完之后代码可读性确实上去了,但因为他自己也不确定每条规则改了哪些语义,反而不敢信还原后的结果。更稳妥的做法是只做三件事——字符串数组内联、常量折叠、死代码消除,做完就停,剩下的靠人工读。这三件事的语义风险最低,收益却最高。
3.2 数组字符串表还原后,最常见的三类残留
即使还原成功,你也会遇到一批"看起来很怪"的代码。我把常见的三类列出来,方便你对号入座:
第一类是索引偏移。取值函数内部往往对传入的索引做了一次加减运算,比如arr[idx - 0x1f]。如果你只把调用点替换成字符串而不处理偏移,整个数组会错位,出来的字符串看着像乱码但又不是完全乱码——这个现象非常好认。
第二类是二次加密的字符串。有些实现会先对数组里的字符串做 Base64 解码或者逐字符异或,还原工具直接内联出来的是密文。这时候你要找到解密函数,把它在 Node 环境里跑一遍,批量解密后再回填。
第三类是控制流平坦化后的变量污染。把 switch-case 拉平成顺序执行时,中间变量的作用域会被打乱,原本每次循环重新赋值的变量可能变成共享的。这种代码跑起来结果不对但看不出哪里错,是最难查的一类。
3.3 我用的半自动策略:先跑通再优化
我的实际流程是:先用 AST 做字符串内联这一个动作,把代码丢回浏览器执行,确认没报错、签名值和原始版本一致,再做下一步。每一步都验证,绝不攒着一起改。这样做看似慢,但由于每一步都可回滚,整体反而快。
验证方式很朴素:在页面里拦截签名函数,把还原前后的输出打出来对比。几十条随机输入跑下来,输出全一致,才说明你的还原没有改变语义。
注意:还原过程中一定保留一份原始代码的副本。等你在 Python 侧发现对不上、回头查的时候,唯一能信任的参照物就是那份没动过的原始文件。
4. 签名参数的内脏:输入因子、混合层与编码输出三段拆
混淆代码读到能看懂之后,你会发现这类签名函数的内部结构其实相当有规律,基本都能切成三段:把一堆输入拼成字节序列、做若干轮混合运算、再编码成最终字符串。理解了这个三段式,你读任何一份新的实现都会快很多。
4.1 输入因子清单:先列全,再删减
输入因子决定了你在 Python 侧需要准备哪些东西。常见的组合包括:请求路径、排序后的查询串、请求体摘要、毫秒时间戳、随机串、浏览器 UA 的某个摘要、以及一个平台下发的固定盐值。这里的关键不是"全都对上",而是确定哪些是必需的。
确定方法是对照法:固定其它所有输入,只改一个,看输出是否变化。变化了就说明它参与运算,不变就说明它没被用上。这个朴素的方法能让你在半小时内画出一张准确的输入因子表,比读代码猜要可靠得多。我一般会用表格记下来:
| 输入项 | 是否参与 | 参与方式 | 备注 |
|---|---|---|---|
| 查询串 | 是 | 排序后拼接 | 参数顺序会影响结果 |
| 时间戳 | 是 | 直接拼接 | 毫秒,需与服务器偏差可控 |
| 随机串 | 是 | 直接拼接 | 长度固定,字符集有限 |
| UA | 是 | 取摘要前若干位 | 换 UA 会导致签名失效 |
| 请求体 | 视接口 | 空则跳过 | POST 接口才参与 |
4.2 混合层:位运算、魔数常量和查表
混合层是整个算法里最"加密"的部分,也是最难翻译成 Python 的部分。它通常由这几类运算构成:32 位整数加法与乘法、循环左移或右移、按位异或、以及若干写死的十六进制常量(俗称魔数)。
看到一串0x9e3779b9、0x85ebca6b这类常量,你基本可以判断这是某种已经公开的散列或混淆方案的变体,而不是从零设计的算法。识别出来之后,翻译工作量会大幅下降,因为这类方案的结构是标准的:分组、轮函数、累加、输出。
提示:魔数不要手抄。从调试器里直接复制十六进制值,粘贴到 Python 里,避免把
0x后面的某一位看错。这种低级错误排查起来极其费时,因为它不报错,只是结果不对。
4.3 输出编码:Base64 变体是最容易翻车的地方
混合运算的结果是一串字节,最后要编码成 URL 安全的字符串。标准 Base64 里有+、/、=三个字符在 URL 里需要转义,所以平台通常会换成 URL 安全变体,把+换成-、/换成_、去掉填充的=。也有用自定义字符表打乱顺序的。
翻车点在于:JS 的 btoa 处理的是 Latin-1 字节串,Python 的 base64 处理的是 bytes,如果你中间字符串经过了 UTF-8 编码再 Base64,两边结果必然不同。我在第一次做这类复现时就在这上面耗了很久,后来养成的习惯是:凡是涉及编码,一律先把中间结果打印成十六进制字节序列,两边逐字节对比,而不是对比最终字符串。
5. Python 侧一比一复现:JS 语义迁移的四个硬骨头
前面所有工作做完,你手上应该有一份能看懂的 JS 算法。接下来的问题是把它翻译成 Python。很多人以为这是个体力活,实际上语言之间的语义差异会在这里集中爆发,而且出错时往往不抛异常,只是默默算出个错值。这一节我把自己踩过的坑按类型整理出来。
5.1 整数截断与无符号右移:最隐蔽的错误来源
JavaScript 的位运算符会把操作数强制转成 32 位有符号整数,超出范围的部分直接被截掉。Python 的整数是任意精度的,0xFFFFFFFF + 1得到的是4294967296而不是0。如果你的算法里有乘法累加,不手动截断,结果会在几轮之后彻底跑飞。
另一个坑是无符号右移。JS 的>>>会把数当成无符号数右移,Python 的>>对有符号数做的是算术右移,负数会补符号位,结果完全不同。这两个必须手动实现:
def to_int32(x): """模拟 JS 的 |0 与位运算前的 ToInt32 转换""" x = int(x) & 0xFFFFFFFF return x - 0x100000000 if x & 0x80000000 else x def to_uint32(x): return int(x) & 0xFFFFFFFF def urshift(a, b): """模拟 JS 的 >>> 无符号右移""" return (to_uint32(a) >> b) & 0xFFFFFFFF def rotl32(x, n): """32 位循环左移,JS 里常用 (x << n) | (x >>> (32 - n)) 表达""" x = to_uint32(x) return to_uint32((x << n) | (x >> (32 - n)))这几个函数看着简单,但每一个参与位运算的变量都要经过它们,漏掉一个就会错。我的做法是在 Python 侧写一个小的辅助模块,把所有的算术都包一层,宁可代码啰嗦也不要直接写裸运算。
5.2 字符串、编码与类型数组:字节序和码元是关键
JS 里字符串是 UTF-16 码元序列,charCodeAt拿到的是码元而不是字节。如果算法里对字符串做了逐字符的charCodeAt再参与运算,Python 侧必须用ord()逐字符处理,而且遇到中文或特殊字符时,两边的结果会不一致——JS 的一个中文是一个码元(BMP 内),部分生僻字是两个码元(代理对),Python 的ord()拿到的是一个码点。这个差异在纯 ASCII 的查询串上体现不出来,一旦 URL 里有中文参数就爆炸。
def utf16_code_units(s): """按 JS 的 charCodeAt 语义取码元序列""" data = s.encode('utf-16-le') return [data[i] | (data[i + 1] << 8) for i in range(0, len(data), 2)]另外,JS 里常见的Uint8Array、DataView在 Python 里对应bytes和struct。要注意DataView默认大端,而struct.pack的格式符不带前缀时用的是本机字节序,在 x86 上就是小端。这个差异会让你在解出中间结果时看到"字节顺序反了"的现象,加上<或>前缀就能解决。
5.3 时间戳、随机数和环境指纹:可控性比准确更重要
时间戳这块,坑主要在时区和精度。JS 的Date.now()是 UTC 毫秒,Python 的time.time() * 1000也是,用int()截断即可,别用round()。如果你的机器时间偏差较大,建议在启动时和一次成功的请求做校准,记录下偏差值,后续所有时间戳都加上这个偏移。
随机数这块,大部分实现用的是Math.random(),这个函数在 Python 里没有对应物,也没必要对应——因为后端不会校验随机数的生成方式,只会把它当成输入的一部分重新计算签名。你只要保证长度和字符集一致,用random.choice或者uuid截取都行。这是整个复现过程中最宽松的一环,别在这上面浪费时间。
指纹这块就麻烦多了。如果算法里混入了 Canvas 指纹、字体列表之类的值,你在 Python 里只能把它写成常量。我的一般做法是:在浏览器里跑一次,把最终参与运算的那个值(注意不是原始指纹,而是经过摘要后的值)打印出来,硬编码进配置文件。这样做牺牲了通用性,但在单一任务场景下完全够用。
5.4 一致性验证:没有闭环,你永远不知道对不对
这是最容易被跳过、但绝对不能跳过的一步。翻译完算法之后,必须在相同的输入下,对比 Python 输出和浏览器输出。对比方法我推荐这个流程:
- 在浏览器的签名函数入口下断点,记录完整的入参(序列化成 JSON 打印出来)。
- 让函数执行完,记录返回值。
- 把这份入参原样喂给 Python 实现,对比返回值和第 2 步。
- 如果不等,用二分法在混合层的每一轮之后打断点,逐步定位从哪一轮开始分叉。
我用一个简单的断言脚本来做这件事:
import json def verify(cases, py_impl): for raw_input, expected in cases: got = py_impl(**raw_input) if got != expected: print('MISMATCH') print(' input :', json.dumps(raw_input, ensure_ascii=False)) print(' expected:', expected) print(' got :', got) return False print('ALL PASS', len(cases)) return True准备至少二十组不同的输入(包含空查询串、带中文参数、超长参数、特殊字符),全部通过才算复现成功。只测一组就宣布成功,是新手最常见的翻车方式,因为很多实现对特定字符有特殊分支,你在第一组数据里根本碰不到。
6. 从能跑的一次性脚本,到能长期活着的服务
算法跑通只是开始。这类签名逻辑有个特点:平台方会不定期调整实现,可能是改个常量、可能是加一轮运算、也可能是换整个方案。如果你的脚本是硬编码在业务流程里的,每次变动都要改代码、重测、重新上线,运维成本很高。所以跑通之后,值得花点时间做工程化。
6.1 接口化:让签名生成变成一个独立组件
我把签名逻辑抽成一个独立的 HTTP 服务,入参是"请求方法 + 路径 + 查询串 + 时间戳",出参是签名值。业务侧只管调用,不关心里面怎么算。这样做有三个好处:算法变更只需要替换这一个服务;可以独立做压测和监控;不同语言写的业务代码都能复用。
实现上我倾向于用轻量的 Web 框架,单进程起几个 worker 就够。签名计算本身是纯 CPU 的位运算,耗时在毫秒级以下,瓶颈不在计算而在网络往返。如果业务量大,可以在业务侧做批量请求,一次要多个签名值,把往返次数降下来。
# 服务端伪代码,突出结构而非实现 def sign_handler(payload): inputs = { 'ts': payload.get('ts') or int(time.time() * 1000), 'query': normalize_query(payload['query']), # 排序 + 编码,顺序必须和浏览器一致 'path': payload['path'], 'nonce': gen_nonce(16), } return {'sign': compute(inputs), 'ts': inputs['ts']}注意normalize_query这一步:查询串的排序规则和 URL 编码方式必须和浏览器完全一致,空格是+还是%20、中文是编码后还是原文、参数名大小写敏感与否,这些细节每一个都足以让签名失效。我会在服务里对每一种情况写单测,覆盖到为止。
6.2 算法变更的感知:主动探测比被动报错好
平台调整算法,你的第一反应不该是"用户反馈数据没了",而应该是一条告警。最简单有效的办法是做一个定时任务,每隔几分钟用固定的输入生成一次签名并发一个轻量请求,检查返回结构是否符合预期(不是检查有没有数据,而是检查有没有出现风控特征)。连续几次异常就告警。
降级策略也要提前想好。常见的做法是:主算法失败时切到备用实现(比如保留一份 Node 环境跑原始 JS),或者在业务侧降低请求频率、加入随机间隔,把压力分散开。我见过太多项目在签名失效的瞬间直接开满并发重试,结果整批设备标识被拉黑,这比慢慢排查糟糕得多。
6.3 边界意识:能用和该用之间隔着一道线
这一点我想说得直白一些。分析签名算法的技术本身是中性技能,前端安全研究、自动化测试、自有系统的性能压测都需要它。但它一旦被用在未经授权的场景里——绕过访问控制、超出授权范围的批量采集、影响对方服务稳定性——性质就完全变了,后果不是"被封号"这么简单。
我的实践原则有三条:只分析自己有权限访问的内容;严格遵循平台公开的服务条款与 robots 约定;任何自动化行为都控制频率,确保不影响对方服务。如果你的需求本质上是获取某平台的数据,正规途径通常包括官方开放接口、数据合作、以及内容授权,这三条路的长期成本远低于维护一套随时会失效的逆向实现。技术上的"能做"和业务上的"该做"是两回事,这个判断得你自己做。
7. 排查清单:几个我反复遇到的怪现象
最后把散落在各节的排查经验集中一下,都是些"看起来像玄学、其实有明确原因"的现象,做成表格方便你对号入座。
| 现象 | 最可能的原因 | 验证方法 |
|---|---|---|
| Python 结果与浏览器差几位 | 某处位运算漏了 ToInt32 截断 | 在每轮混合后打印十六进制,二分定位 |
| 只在中文字符串下出错 | UTF-16 码元与 Python 码点语义不一致 | 用utf-16-le编码后按字节取码元 |
| 签名长度对但内容完全不同 | 输出编码阶段的字符表或填充处理不同 | 对比 Base64 前的原始字节序列 |
| 首次成功后续失败 | 复用了同一签名,或时间戳未更新 | 检查每次请求是否重新生成 |
| 本地跑通、服务器上失败 | 服务器时区或系统时间偏差 | 打印 UTC 毫秒时间戳对比 |
| 偶尔成功、频率越高失败越快 | 触发频率风控而非签名错误 | 拉长间隔重试,观察成功率变化 |
还有一条不在表里但非常重要:日志要记录输入而不仅是输出。签名失败的时候,你唯一能复现问题的方式就是当时那一组输入。只记一个错误码,等于什么线索都没留。
这套流程我从第一次接触到现在,中间踩过的最大一个坑不是算法难,而是"过早宣布成功"。当时只用一组简单的英文查询串验证通过,就上线了,结果遇到带空格的搜索词全线失败——因为 JS 的encodeURIComponent把空格编成%20,而我在 Python 里用了quote_plus编成了+。就这一个字符的差异,排查了整整一天。从那以后我给自己定了个规矩:任何编码环节,先在两边把字节序列打出来对齐,再谈逻辑。这个习惯后来帮我省下的时间,远多于它多花的那几分钟。