做爬虫维护的人基本都有过这种经历头天还跑得好好的采集任务第二天起来一看日志大量请求失败错误码全是参数校验不通过。不用查太深就知道目标站点的JS加密逻辑又更新了。尤其是现在很多站点都会在前端用动态加密来保护接口参数生成签名、哈希或者动态token。这种加密和单纯的登录态校验不一样它不是一次配好就能长期用的对方团队会不定期调整算法、更换混淆方式、甚至随机变形每次都靠人工去定位加密函数再重写适配逻辑效率极低。这篇文章讲的是我自己的一个做法用自动化手段识别页面中的加密函数在算法更新之后快速完成适配把每次排查的时间从半天压缩到十分钟左右。整个过程不涉及任何绕过访问控制或者窃取非公开数据的操作纯粹解决正常业务系统与目标平台之间的技术兼容问题。1. 这个痛点为什么折磨人动态JS加密的“变”和爬虫维护的“慢”1.1 前端加密都在做什么事先说清楚一个基本事实大部分站点在前端做JS加密并不是为了防住所有爬虫而是为了做请求合法性校验和风控前置判断。常见的用途包括接口参数加签把请求参数、时间戳、随机数拼接起来做摘要服务端验签防止参数被篡改。请求去重与防重放用时间戳加随机数生成一次性token服务端检测是否已使用。环境指纹采集把浏览器UA、Canvas指纹、屏幕尺寸等收集起来做哈希风控系统用来判别异常流量。前端埋点上报把行为数据加密后上报防止数据被轻易伪造。这些功能本身是合理的业务需求爬虫要正常采集公开数据就必须让自己的请求通过这种参数校验所以理解JS加密逻辑是爬虫系统运维里绕不开的一环。1.2 算法更新给爬虫带来的连锁影响真正让人头疼的地方在于“动态”两个字。加密逻辑不是写死一次就不变的而是会随着版本迭代持续更新。常见的更新形式我总结了一下算法更换从MD5换成SHA256或者引入HMAC这类变化属于底层替换旧逻辑彻底失效。拼接规则变化参数的拼接顺序换了加盐的字符串变了甚至引入了随机盐导致同样的参数生成完全不同的签名。混淆升级代码从简单混淆升级到控制流平坦化、字符串加密函数名变成无意义的_0xabc123。动态拆分一个加密函数被拆成多个小函数分散在多个JS文件里或者由后端动态下发一段加密逻辑。随机变形每次返回的JS代码内容都不同但执行结果一致这种对自动化识别最不友好。每次这些变化发生爬虫端会出现什么情况呢请求返回400、403或者直接返回一个友好的提示页。这时候人工排查的流程通常是打开DevTools找到发起请求的调用栈点进Source面板搜索可疑关键词下断点刷新页面一步步观察参数生成过程。顺利的话半小时搞定不顺利的话一上午就没了。而且这种排查是一次性的下次对方再加个盐又得重新来一遍。所以问题的本质不是“能不能破解某个加密算法”而是如何建立一个能感知变化、快速定位新逻辑的自动化机制。2. 自动识别加密函数的三种技术路径2.1 AST静态分析从代码结构里锁目标所谓AST就是抽象语法树。把JS源码解析成一棵树状结构每个节点对应一个语法单元比如函数声明、变量赋值、函数调用。相比直接正则匹配源码文本AST的好处是结构信息完整、不依赖字符串表面特征就算函数名被混淆了只要调用关系没变依然能找到关键节点。我在实际项目中用babel/parser解析JS文件然后遍历AST节点按几个维度筛选候选加密函数函数名包含特征词sign、token、encrypt、hash、md5、sha、hmac、secret。函数内部调用了加密相关的内置APIcrypto.subtle.digest、CryptoJS的某个方法或者btoa、String.fromCharCode这类编码函数。函数有明确的输入输出结构接收一个对象或字符串返回一个固定长度的字符串。下面是核心的筛选逻辑示例const parser require(babel/parser); const traverse require(babel/traverse).default; function findEncryptCandidates(source) { const ast parser.parse(source, { sourceType: script, plugins: [dynamicImport] }); const results []; traverse(ast, { FunctionDeclaration(path) { const funcName path.node.id.name; const funcBody source.slice(path.node.start, path.node.end); if (looksLikeEncryptFunction(funcName, funcBody)) { results.push({ type: function, name: funcName, start: path.node.start, end: path.node.end, body: funcBody }); } }, ObjectMethod(path) { // 处理对象里的方法定义比如 const sign { encrypt: function() {} } } }); return results; } const KEYWORD_PATTERN /sign|token|encrypt|hash|md5|sha|hmac|secret|digest/i; const API_PATTERN /crypto\.subtle|CryptoJS|btoa|atob|String\.fromCharCode|Uint8Array|Buffer\.from/i; function looksLikeEncryptFunction(name, body) { return KEYWORD_PATTERN.test(name) API_PATTERN.test(body); }这个方案有一个很关键的优势不依赖代码风格。不管对方用ES5还是ES6是普通函数还是箭头函数是函数声明还是对象方法都能被AST覆盖到。只要加密逻辑最终会调用某个加密API或者函数命名习惯没变就能在几秒内从几MB的JS文件里锁定时几十个候选函数。但AST静态分析有明显的局限性如果对方做了高强度的混淆把加密逻辑拆成大量无意义命名的函数再通过控制流平坦化把顺序打乱那特征词就失效了。所以静态分析适合当第一道筛选器不能作为唯一方案。2.2 运行时Hook在真实执行现场抓调用链静态分析看不懂混淆代码怎么办换个思路不去理解代码而是去观察运行时的行为。这就是运行时Hook的切入点。原理其实不复杂。JS里很多关键操作最终都会走到一些内置方法上比如字符串转码String.fromCharCode、btoa、TextEncoder.encode数据序列化JSON.stringify类型转换Array.from、Buffer.from哈希运算crypto.subtle.digest这些内置方法是无法被混淆掉的因为加密逻辑最终一定要借助它们来完成运算。所以只要我在页面加载的时候把这些方法替换成自己的包装函数记录每一次调用的参数和返回值就能还原出加密函数的执行过程。我用Puppeteer来启动一个无头浏览器在页面加载前注入Hook脚本然后主动触发一次目标请求观察加密函数的真实调用链const puppeteer require(puppeteer); async function captureEncryptFlow(pageUrl) { const browser await puppeteer.launch({ headless: true }); const page await browser.newPage(); await page.evaluateOnNewDocument(() { const originalStringFromCharCode String.fromCharCode; const calls []; String.fromCharCode function(...args) { const result originalStringFromCharCode.apply(this, args); calls.push({ type: String.fromCharCode, args, result, stack: new Error().stack.split(\n).slice(1, 5) }); return result; }; window.__encryptCalls calls; }); await page.goto(pageUrl, { waitUntil: networkidle0 }); // 触发一个需要签名参数的请求 await page.evaluate(() { fetch(/api/data, { method: GET }).catch(() {}); }); // 等待异步调用完成 await new Promise(resolve setTimeout(resolve, 2000)); const captured await page.evaluate(() window.__encryptCalls); console.log(JSON.stringify(captured, null, 2)); await browser.close(); }Hooking之后能拿到什么最关键的是调用栈。从stack字段你能看到是谁调用了String.fromCharCode顺着调用栈往上翻就能找到那个真正的加密函数。即使它在AST层面已经被混淆成了一坨乱码运行时行为依然会暴露它的位置。这套方案对“算法更新”的适应性非常强。无论函数名怎么变、参数怎么拼接只要最终要生成字符串、要做哈希就必然调用这些内置方法。而且它是动态的每次抓取的都是当前版本的真实行为不需要预先知道任何加密逻辑细节。2.3 指纹比对把“算法更新”变成可量化的差异有了前两个方案可以定位到加密函数但还有一个问题没解决怎么及时发现算法更新了总不可能每天手动跑一遍Hook去检查。所以需要一个基线比对的机制。我的做法是为每个目标站点建立一份“加密算法指纹库”。指纹的构成包括JS文件级指纹文件内容哈希、文件大小、最近修改时间。任何一项变化都意味着算法可能更新了。函数级指纹从AST中提取函数体的结构特征包括函数参数列表、返回语句数量、调用的API列表、字符串常量表。调用链指纹从运行时Hook中提取调用链特征包括关键API的调用顺序、调用次数、参数长度分布。更新检测的逻辑就是巡检时重新采集指纹和基线做比对。差异超过阈值就触发告警输出一份包含“哪个文件变了、哪些函数变了、调用链哪里不一样”的报告。函数体相似度的计算可以用一个很朴素的方案把函数体内的字符串常量提取出来做集合然后计算Jaccard相似度。考虑到有些站点每次加载都会生成不同的随机字符串纯粹字符串比对的误报率会偏高。更实用的是把AST结构序列化成特征向量用编辑距离来度量差异。简单来说就是结构基本一致但个别常量变化认为是正常波动结构发生大面积变化判定为算法更新。3. 十分钟适配的落地实操流程3.1 建立基线指纹库先给加密逻辑“拍照”第一步是做初始化采集把目标站点当前版本的所有相关信息保存下来。我自己一般分三步走先用脚本从页面入口开始爬取所有script标签的src再顺着资源加载关系把所有JS文件拉到本地。注意要包含动态加载的模块很多站点的主页面脚本只是一个loader真实加密逻辑在按需加载的chunk文件里。这一步我会用Puppeteer的performance.getEntriesByType(resource)接口来获取完整的脚本资源清单比手工翻Sources面板全得多。然后对每个JS文件跑AST分析提取候选加密函数。这里有个小技巧不要只找一个函数。实际上的签名逻辑往往是两到三个函数的组合比如一个生成随机数一个做拼接一个做哈希。我的基线条目会保存一组函数以及它们之间的调用关系而不是孤立的单个函数。最后用无头浏览器跑一次或几次真实请求把运行时Hook采集到的调用链也存进基线。这样一份基线包含静态和动态两个维度的快照后续比对才能判断“代码变了但行为没变”或“代码没变但行为变了”这两种情况。3.2 自动化变更巡检第一时间发现更新基线建好之后巡检就是反复执行采集和比对的过程。我不建议用定时任务然后一切自动完成因为误报率始终存在。更实际的架构是用定时触发器每天拉取一次目标JS文件计算文件哈希和基线库比对。哈希一致什么都不做。哈希不一致立即触发完整采集流程AST运行时Hook生成差异报告。差异报告推送到告警群同时自动渲染一份可视化对比标出具体是哪个函数、哪个调用链发生了变化。这里有一个经验不要完全相信文件哈希。有些站点会对JS文件做内容加盐每次请求返回的文件都不一样但里面的逻辑其实没变。这种情况下文件哈希每天都会变你就需要结合函数级指纹去过滤掉“假更新”。检测逻辑里我会把文件级变更当作触发条件然后把函数级指纹当作判断依据两个维度综合判定。3.3 调用链还原快速定位新签名逻辑一旦确认算法更新了最耗时的环节是搞清楚“新逻辑到底是怎么生成签名参数的”。人工排查慢就慢在这一步。自动化的思路是用运行时Hook重新做一次采集然后自动生成一个调用关系图用文本方式输出不依赖任何画图工具。比如某次更新后Hook捕获到这样一组调用序列getTimestamp() - Date.now() getRandomStr() - Math.random().toString(36) makeSign() - JSON.stringify({timestamp, random, key}) hashSign() - CryptoJS.SHA256(rawString).toString()这组序列就是新签名逻辑的骨架。我只需要照着这个顺序实现一遍就行了function generateSignature(params, secretKey) { const timestamp Date.now(); const random Math.random().toString(36).slice(2); const rawString JSON.stringify({ ...params, timestamp, random, key: secretKey }); const sha256 require(crypto-js/sha256); return sha256(rawString).toString(); }真实的调用链会比这个例子复杂但原理一致。最关键的一点是不要试图去逆向理解整个加密算法只需要把输入输出关系复现出来就行。你不需要知道为什么要把random放在timestamp后面只需要知道现在它们是这样拼接的照做就可以。3.4 自动生成适配代码与回归验证调用链还原之后适配代码其实就已经成型了。更进一步我还会让工具自动生成一份适配模块的骨架代码。生成规则很简单把调用链中的每个API映射到JavaScript对应的原生实现然后按调用顺序生成函数体。生成完代码之后必须做回归验证。验证分三层单测验证用固定的参数跑生成代码对比目标站点返回的签名是否符合预期可以在测试环境用一个公开接口验证能过就是对的。实弹验证在真实环境发起一次请求HTTP状态码为200且返回数据正常才算真正的适配完成。链路验证连续跑若干次请求确认签名的随机性和时间戳动态变化没有被服务端拒绝。这三层验证都通过之后把新指纹写入基线库更新对应目标站点的加密算法版本号这次适配就算彻底收尾了。4. 实战中的坑与排查技巧4.1 误报太多特征关键字怎么选才靠谱我踩过最大的坑就是AST分析时的关键字筛选条件太宽。一开始我把key和code也加进特征词里结果每个JS文件都能匹配出一百多个候选函数根本没法看。后来总结出经验关键字优先选组合词比如generateSign、makeToken、getSignature而不是单独一个sign。结合函数体内容过滤要求函数体里至少出现一次字符串拼接操作和一次哈希/编码调用。排除明显的UI代码比如只操作DOM节点的函数直接跳过。另一个经验是建立黑名单机制。同一个站点经过多次适配之后你已经积累了足够多的历史样本哪些函数被确认是加密函数、哪些函数被确认无关都可以记录下来。后续每次分析直接先比对黑名单能过滤掉大量无效候选。4.2 混淆之后函数名全乱识别维度要换遇到高强度混淆时函数名、变量名全部变成_0x2f4a这种风格AST的特征词匹配直接失效。这时候需要换维度从“函数叫什么”转向“函数做了什么”。一个很有效的特征维度是函数体内字符串常量的哈希分布。正常的业务代码里字符串常量各种长度都有而加密相关的代码往往包含特定格式的常量hex字符串、base64片段、固定长度的盐值。通过统计这些常量的特征即使函数名完全无意义也能把它们从代码堆里捞出来。另外混淆代码有个共性弱点控制流虽然被扁平化但运算逻辑调用的API顺序是变不了的。比如先JSON.stringify再CryptoJS.SHA256这个顺序在混淆后仍然存在。用运行时Hook抓调用顺序绕开混淆直接观察行为是最省力的路径。4.3 加密逻辑分散在动态模块里需要捕获加载过程有些站点把加密逻辑放在动态加载的模块里只有在触发某个操作时才会从CDN拉一段JS下来执行。这种情况下AST静态分析会漏掉因为分析时文件还不存在。我的做法是在Puppeteer里监听两类事件一是document的脚本加载事件二是performance的资源条目。每发现有新的脚本加载就立即拉取内容并追加到采集队列里。同时配合运行时Hook因为动态模块执行时同样会调用内置APIHook不会漏掉任何调用只是需要多等一会儿。这在技术上没什么难度但是很容易被忽略。我第一次遇到这种情况时静态分析什么都没找到差点以为是加密逻辑从JS挪到了后端。后来加了脚本加载监听才恍然大悟——它只是被拆到了另一个文件里。4.4 常见问题速查表我把日常维护中碰到的问题整理成了表格方便遇到相似场景时快速对照排查现象可能原因优先排查方向请求返回400参数校验失败拼接规则变化运行时Hook抓调用链对比入参顺序请求返回403风控策略升级而非纯加密变化检查UA、Cookie、行为轨迹文件哈希每天变但功能正常内容加盐/版本号注入用函数级指纹过滤假更新AST一个候选函数都找不到混淆过强或加密逻辑在动态模块启用脚本加载监听 运行时Hook签名能生成但服务端拒绝随机盐规则没还原重点检查盐值来源常见于接口返回或Cookie页面里加密逻辑时有时无AB测试或灰度发布多次采样对比差异不能用单次快照当作基线新版本上线后旧签名失效多版本共存导致指纹库混乱维护站点的版本分支指纹不要用一套基线覆盖所有流量4.5 处理隐式编码转换的一个细节这里想特别讲一个容易栽进去的细节编码转换。很多时候签名算法本身不难难在字符串的编码转换对不上。比如某站点在JS里先对参数做encodeURIComponent再转成UTF-8字节然后做Base64最后才进SHA256。人工排查时这几步转换一旦漏掉一步生成的签名就是不对的。自动化工具的好处在于运行时Hook能记录下每一步转换的输入输出包括encodeURIComponent的入参和返回值。我在Hook脚本里把几个常见编码函数也一并包装了encodeURIComponent/decodeURIComponentJSON.stringifyTextEncoder.encodebtoa/atob有了这些记录调用链里每个环节都很清楚编码转换的问题就能自动被还原出来不需要人工在DevTools里一步步断点追。5. 工程化落地与合规边界5.1 别做一次性脚本把它做成常驻服务如果只是临时适配某一个站点写几个脚本跑一遍就够了。但如果你要长期维护多套采集系统我强烈建议把这套“采集→分析→比对→适配”的流程做成一个常驻服务而不是散落各处的脚本。我在实际项目中的做法是采集模块独立成服务负责拉取JS、管理Puppeteer实例、生成运行时Hook日志。分析模块专注AST解析和调用链还原输出结构化JSON不直接参与适配。比对引擎维护基线指纹库用不同目标站点的维度做隔离。告警和工单模块对接企业内部的IM指纹差异和调用链变更报告自动推送。做成服务之后有个额外好处积累样本数据。同一站点的算法更新经过多次适配后你会慢慢掌握这个站点的变化规律。比如某站点喜欢在每月的某个版本更新里加一个随机变量或者某个类型的接口总是先更新某个参数生成逻辑。这些规律只有长期跟踪才能总结出来对后续的适配效率帮助非常大。5.2 合规使用的前提授权、公开、正常访问说了这么多技术细节心里得有根弦。这些方法本身是中性的但使用场景决定了它是否合规。我个人在项目里严格遵守几个前提所有采集目标都限定在本人拥有权限的系统或明确授权范围内的公开数据。整个过程基于正常浏览器环境访问公开的页面资源不绕过任何访问控制不破解鉴权逻辑不获取非公开内容。适配的目的是解决自己系统的兼容性问题不是对目标平台做攻击或滥用。关注目标平台的规则如果对方明确不允许程序化访问就要停下来重新评估业务合作模式。自动化识别加密函数这套东西最合适的定位是系统和系统之间的兼容性适配工具不该被用来做越界的事情。大家在做类似技术验证的时候一定要把边界划清楚。6. 踩过几次坑之后的一点体会这套自动化适配方案我用了很长时间前前后后也迭代了很多个版本。回头来看最有价值的不是那些AST解析和Hook的代码而是“不试图理解一切只关心关键事实”的思路。加密算法被混淆得再复杂它终究要在运行时产生真实的行为真实行为一定会经过那些内置API。抓住这一点算法更新再怎么频繁适配路径都是类似的。最后再分享一个小技巧运行时Hook抓到的调用链里除了调用顺序参数长度变化也很值得记录。比如某次更新后某个函数的入参从12个字符变成了18个字符这就暗示新加了盐值或额外参数。在自动生成的适配说明里把这个变化突出显示能让适配改写得非常快。我建议每个做这块的人花点时间把自己最常遇到的几个目标站点梳理一遍先建立基线指纹再跑通自动比对。第二次遇到算法更新的时候你就能真正体会到“十分钟完成适配”是什么感觉了。