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_asciiFalse)) 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编成了。就这一个字符的差异排查了整整一天。从那以后我给自己定了个规矩任何编码环节先在两边把字节序列打出来对齐再谈逻辑。这个习惯后来帮我省下的时间远多于它多花的那几分钟。