做行业人才供需分析项目的时候,碰过某头部招聘站的岗位详情接口,所有请求都带一个动态sign参数,有效期几分钟,改一下岗位ID直接返回403。最开始走了老路:抓包、搜关键词、扒混淆JS硬啃,结果对方做了控制流平坦化+字符串加密,满屏的单字母变量和switch-case状态机,看了一下午头都大,啃了三天才还原了一半逻辑,结果人家周末悄悄更了一版,之前的工作全白费。
后来调整了思路,不再死磕静态代码,改成Playwright运行时定位入口 + AST自动化还原混淆的组合方案。不用逐行捋状态机,也不用挨个猜加密字符串,先靠运行时抓准输入输出,再用AST批量拆解混淆结构,前后两个多小时就把完整签名算法扒了下来。后续站点小版本更新,跑一遍还原脚本就能自动适配,维护成本直接降了一个量级。
今天就把这套完整流程分享出来,从定位入口、还原混淆到交叉验证,全是实战踩出来的经验,适合绝大多数中等混淆程度的业务站点。
一、先讲痛点:为什么硬啃混淆代码效率极低?
现在中大型站点的JS混淆,基本都是标配三件套:变量名压缩、控制流平坦化、字符串加密。纯靠肉眼逐行还原,效率极低,还很容易出错。
1.1 三座大山:常规逆向的死穴
第一座山是控制流平坦化。原本顺序执行的十几行代码,被拆成几十个代码块,塞进一个大switch-case里,靠一个状态变量跳来跳去。逻辑本身不难,但绕来绕去非常费脑子,捋着捋着就断了,稍微复杂点的函数,捋一遍就要一两个小时。
第二座山是字符串加密。所有的接口路径、固定盐值、密钥字符串,全被加密成了乱码,运行时调用解密函数才还原。静态搜sign、encrypt、key这类关键词,根本搜不到,连加密函数在哪都找不到。
第三座山是高频更新。招聘、电商这类反爬强度高的站点,每隔一两周就更一次混淆版本,变量名、控制流结构全换。你花一周还原的代码,更新之后又要重来一遍,维护成本高到离谱。
1.2 为什么是Playwright + AST?
两者刚好互补,一个负责精准定位,一个负责批量还原:
- Playwright:站在运行时视角,不用管代码多混淆,只要请求发出去,就能顺着调用栈摸到加密函数,还能拿到真实的输入输出做验证。静态分析找不到的入口,运行时一抓一个准。
- AST(抽象语法树):站在代码结构视角,不用逐行读逻辑,按规则批量拆解控制流、替换加密字符串,自动化完成80%的还原工作。人只需要看最终的可读代码,抓核心逻辑就行。
简单说就是:Playwright帮你找到「在哪」,AST帮你快速看懂「是什么」,两者结合,比纯静态硬啃效率至少高一个数量级。
二、第一步:Playwright精准定位,5分钟找到加密入口
很多人逆向第一步就走错了:打开JS文件全局搜关键词,搜sign、encrypt,搜出几十个结果挨个看,纯碰运气。混淆程度高的站点,变量名全是单字母,字符串全加密,搜关键词根本没用。
最高效的方法永远是从请求入手,运行时追调用栈,混淆再狠也藏不住调用关系。
2.1 监听请求,精准断在签名生成处
不用手动找JS文件,直接用Playwright拦截目标接口,在请求发出的瞬间断住,顺着调用栈往上追,几步就能定位到签名函数。
核心代码非常简单:
// Playwright 监听请求,触发时断住awaitpage.route('**/api/job/detail*',async(route,request)=>{// 打印请求参数,确认sign字段console.log('请求参数:',request.postData());// 在这里打断点,或者注入调试脚本debugger;awaitroute.continue();});触发一次请求,代码会自动断在请求发出的位置。然后看右侧Call Stack调用栈,跳过axios、request这类封装库的层,往上追2-3层,基本就能落到业务代码的签名生成函数里。
踩坑提醒:不要在请求库的拦截层打转。很多站点封装了统一的request方法,断住后第一层都是拦截器、公共参数拼接的代码,多往上追几层,到具体的业务逻辑层才是真正的签名生成。
2.2 Hook 函数,拿到真实输入输出
定位到函数还不够,要先搞清楚「传进去什么、返回什么」。混淆代码里参数名全是a、b、c,根本看不出哪个是参数、哪个是密钥。
不用硬读代码,直接在控制台重写这个函数,加一行打印,就能拿到真实的入参和返回值:
// 假设定位到的加密函数在 window.__sign 上constoriginalSign=window.__sign;window.__sign=function(...args){console.log('签名入参:',args);constresult=originalSign.apply(this,args);console.log('签名结果:',result);returnresult;}再触发一次请求,控制台就会打印出完整的入参和签名结果。这时候你已经知道了「输入什么格式,输出什么结果」,相当于有了标准答案,后面还原算法的时候,可以随时对比验证,不会走偏。
2.3 提取混淆源码
定位到文件之后,把对应的JS源码保存下来。不用纠结文件里其他无关代码,只把加密函数所在的模块抠出来就行,通常也就几百到几千行,后续交给AST处理。
三、第二步:AST自动化还原,批量拆解混淆结构
拿到混淆代码不要硬读,用AST做结构化处理,把控制流铺平、把字符串解密,80%的机械工作可以自动完成。前端常用的工具是@babel/parser+@babel/traverse+@babel/generator,三件套就能搞定绝大多数混淆还原。
3.1 环境准备
装三个核心库:
npminstall@babel/parser @babel/traverse @babel/generator- parser:把混淆代码解析成AST语法树
- traverse:遍历语法树,按规则修改节点
- generator:把修改后的AST还原成可读代码
3.2 还原控制流平坦化:拆平switch状态机
控制流平坦化的核心就是一个大switch语句,靠一个状态变量控制执行顺序。我们要做的就是按状态顺序,把各个case里的代码块按执行顺序拼起来,变回正常的顺序代码。
简化版实现逻辑:
constparser=require('@babel/parser');consttraverse=require('@babel/traverse').default;constgenerate=require('@babel/generator').default;constt=require('@babel/types');functiondecontrolFlow(ast){traverse(ast,{SwitchStatement(path){// 只处理状态机模式的switch:判断变量是数字,case全是数字constdiscriminant=path.node.discriminant;if(!t.isIdentifier(discriminant))return;constcases=path.node.cases;// 提取所有case的代码块,按序号排序constcaseMap={};cases.forEach(c=>{if(t.isNumericLiteral(c.test)){caseMap[c.test.value]=c.consequent;}});// 按执行顺序拼接语句(简单场景直接按序号顺排,复杂场景可模拟执行)constsortedKeys=Object.keys(caseMap).map(Number).sort((a,b)=>a-b);constbody=[];sortedKeys.forEach(k=>{body.push(...caseMap[k]);});// 用顺序语句替换整个switchpath.replaceWithMultiple(body);}});}// 使用示例constast=parser.parse(混淆代码字符串);decontrolFlow(ast);const{code}=generate(ast,{compact:false});console.log(code);说明:上面是最简版的实现,适合单顺序执行的平坦化。如果是带条件跳转的复杂状态机,可以加一步模拟执行,根据条件分支的走向拼接代码,思路是一样的,不用逐行读逻辑,程序自动跑一遍就知道顺序。
3.3 字符串解密:批量替换加密字符串
字符串加密的套路基本都是:定义一个解密函数,所有加密字符串调用这个函数还原。我们只要在AST里找到所有调用解密函数的地方,提前算出来明文,替换掉函数调用就行。
核心思路:
- 先从混淆代码里把解密函数抠出来,或者用Node.js直接运行拿到解密结果
- 遍历AST,找到所有调用解密函数的节点
- 计算出明文字符串,替换掉原来的函数调用节点
实现之后,原本乱码一样的字符串调用,会直接变成明文的"sign"、"appKey"这类可读字符串,瞬间就能看懂哪个参数是干嘛的。
3.4 还原效果
经过两步处理之后,原本几百行绕来绕去的混淆代码,会变成几十行顺序执行的可读代码:
- 没有了switch-case状态机,逻辑从上到下一目了然
- 没有了加密字符串,密钥、盐值、字段名全是明文
- 变量名虽然还是单字母,但结合上下文已经很容易看懂
这时候再去梳理签名规则,比如参数怎么排序、用的什么哈希算法、盐值拼在前面还是后面,十几分钟就能捋清楚。
四、第三步:运行时交叉验证,确保100%准确
捋完逻辑不要直接写代码复现,先做交叉验证,避免漏掉细节。很多人还原完算法,算出来的签名永远不对,就是因为漏了某个隐藏的拼接步骤,或者排序规则错了。
4.1 固定参数对比法
用Playwright在页面里注入代码,传固定的参数,拿到真实签名结果;再用自己还原的算法,传同样的参数计算结果。两者完全一致,说明算法还原正确;不一致,就说明哪里漏了。
// 页面里调用原生签名函数,固定参数consttestParams={jobId:'123456',_t:'1715000000000'};constrealSign=window.__sign(testParams);console.log('真实签名:',realSign);然后用自己的Python/JS实现算一遍,逐字符对比。如果不一样,就逐步骤打日志对比,看是参数排序错了,还是盐值没加对,或者加密算法选错了。
实战经验:90%的不一致,都出在三个细节上:参数排序是ASCII升序还是降序、盐值拼在开头还是结尾、加密算法是MD5还是HMAC。不要上来就怀疑大逻辑,先核对这三个细节,大概率就能找到问题。
4.2 多组参数验证
一组对了还不够,多测几组不同的参数,包括边界情况,确保所有场景都对齐。尤其是参数为空、含特殊字符、含中文的情况,很容易出现编码不一致的问题。
全部对齐之后,再把算法移植到你的采集代码里,基本就能一次跑通,不会出现「本地好好的,线上全403」的尴尬。
五、工程化:网站更新了怎么办?
这是纯手动逆向最大的痛点:站点一更,之前的工作全白费。用AST的好处就是,只要混淆的整体方案没变,就可以写通用的还原脚本,更新了重新跑一遍就行。
我的常规做法是:
- 把「定位加密函数 → 提取混淆代码 → AST还原 → 输出算法核心」的流程,尽量写成半自动化脚本
- 站点更新后,只需要重新定位一下函数位置,丢进还原脚本跑一遍,几分钟就能拿到新的可读代码
- 对比新旧版本的差异,调整自己的实现,通常改一两个细节就搞定
维护成本从原来的几天,降到了几十分钟,应对高频更新的站点非常实用。
六、逆向最容易踩的六个坑
坑1:死磕完整代码还原
很多人追求把所有混淆都还原成和源码一样,花大量时间处理边缘逻辑。其实完全没必要,我们的目标是复现签名算法,不是还原整个项目。抓住核心的加密逻辑就行,无关的代码块直接跳过,能省大量时间。
坑2:控制流只靠肉眼捋
对着switch-case硬捋分支,效率极低还容易错。要么写AST脚本自动拆,要么加日志运行时看执行顺序,都比纯肉眼靠谱。工具能搞定的事,就不要浪费人力。
坑3:字符串解密只找一层
有些站点做了多重加密,字符串解密之后还是密文,要再调一次解密函数。替换的时候要递归处理,直到最终是明文字符串,不然还原出来还是半乱码。
坑4:忽略编码细节
参数里有中文、特殊字符的时候,URL编码、UTF-8编码的处理方式不一样,很容易导致签名不一致。一定要和原生函数的编码方式严格对齐,不要想当然用默认编码。
坑5:AST还原后直接信以为真
AST还原不是100%准确的,尤其是带复杂条件跳转的控制流,可能会拼错顺序。还原完一定要用运行时结果验证,不能觉得脚本跑出来的就一定对。
坑6:硬刚最高强度的混淆
遇到虚拟机混淆、指令集虚拟化这类高强度混淆,AST也不好使,就不要死磕了。直接用Playwright调用页面原生函数生成签名,成本低得多,还不怕更新。技术是为业务服务的,能解决问题的方案就是好方案。
总结
JS逆向这件事,从来不是比谁更能啃硬骨头,是比谁的方法更高效。
纯静态硬啃是最笨的方法,混淆强度稍微高一点就会卡壳。把运行时定位和结构化还原结合起来,先用Playwright精准找到入口、拿到标准答案,再用AST批量处理机械的还原工作,人只做核心逻辑的判断,效率才能提上去。
当然,技术方法一定要用在合规的场景里。采集公开数据要遵守网站规则和法律法规,不要碰非公开信息和个人隐私,技术永远要走在合规的框架里。
合规提示:本文仅分享前端代码逆向分析的技术方法,用于安全研究与学习。数据采集请严格遵守《网络安全法》及目标网站的用户协议与robots规定,不得用于非法用途。