做工业数据采集和接口逆向的朋友,近两年应该能明显感觉到:前端反爬的门槛正在快速拉高。
前几年遇上加密接口,大多是纯JS实现,搜个encrypt、sign就能定位逻辑,顶多绕一层变量混淆;现在倒好,先是控制流平坦化、字符串加密把JS代码搅成一锅粥,等你好不容易扒开混淆层,发现核心签名逻辑直接塞进了WebAssembly里——抓包只能看到一个.wasm二进制文件,导出函数全是无意义的编号,传统的JS逆向思路直接失效。
很多人遇上Wasm就直接放弃,或者退回去用浏览器渲染兜底。但其实只要摸透了它的运行机制,再配合成熟的工具链,绝大多数Wasm加密都能找到低成本的破解方案。
今天这篇文章,我把JS混淆与WebAssembly逆向的完整方法论讲透,从底层原理到分步实操,再到Python工程化落地,以及那些踩过的坑,一次性整理清楚。
一、先看透本质:两类防护的底层逻辑
很多人逆向上来就对着代码硬读,这是典型的战术勤奋。先搞清楚防护的架构和设计目标,才能选对成本最低的破解方案。
1.1 JS混淆:从代码丑化到逻辑迷宫
JS混淆的核心目标,是提升代码的阅读成本,而不是做到绝对不可破解。它的防护强度分三个层级:
- 初级混淆:变量名替换、代码压缩、去除空格注释,本质只是“丑化”,格式化后就能读
- 中级混淆:字符串加密、死代码注入、函数名扁平化,需要先解密字符串、清理垃圾代码
- 高级混淆:控制流平坦化、虚拟机保护、反调试检测,把线性逻辑拆成状态机调度,阅读成本指数级上升
目前主流站点用得最多的是javascript-obfuscator的中高强度配置,配合域名锁定、反格式化等手段。但不管混淆多复杂,它终究运行在JS环境里,只要是JS能执行的逻辑,我们就能Hook、就能拦截。
1.2 WebAssembly:浏览器端的二进制黑盒
WebAssembly(简称Wasm)是一种低级二进制指令格式,由C/C++/Rust编译而来,运行在浏览器的沙箱环境中,执行效率接近原生代码。站点把核心加密、签名、风控逻辑编译成Wasm,JS只负责传参和调用,相当于把核心逻辑放进了黑盒里。
和纯JS防护相比,它有三个显著特点:
- 没有可读源码,只有二进制字节码,反编译后也是汇编级指令
- 运行性能高,适合做复杂的加密计算和风控检测
- 内存独立,和JS通过线性内存交互,参数传递有固定套路
防护架构对比
简单来说,混淆JS是“让你看不懂代码”,Wasm是“干脆不给你代码”。两者叠加,就是当前前端防护的顶配组合。
二、逆向核心方法论:先分层,再选型
很多人遇上混合防护就乱了阵脚,一会儿抠混淆代码,一会儿反编译Wasm,忙活半天没进展。逆向不是比谁更能啃硬骨头,而是找投入产出比最高的路径。
通用逆向工作流
记住一个核心原则:能黑盒调用就不还原逻辑,能直接运行就不反编译。
逆向的最终目标是稳定拿到正确的加密结果,不是读懂每一行代码。花三天反编译Wasm重写算法,和花半小时搭个RPC调用服务,最终效果一样,但成本天差地别。
三、JS混淆逆向:从硬读到高效Hook
先从大家最熟悉的JS混淆说起。很多人面对混淆代码的第一反应是“还原它”,但在实战中,Hook永远比还原代码效率更高。
3.1 快速定位加密入口
定位入口是逆向第一步,三个方法按效率排序:
- 关键词搜索法:全局搜
sign、encrypt、aes、rsa、CryptoJS,以及请求参数的字段名,80%的场景能直接定位到附近 - XHR断点回溯:给目标接口打XHR/fetch断点,触发后查看调用栈,从请求发出的位置往前回溯,加密逻辑一定在参数组装的链条上
- 通用Hook拦截:针对
JSON.stringify、btoa、encodeURIComponent这类高频方法打Hook,加密前的明文一定会经过这些方法,断下后顺着调用栈往上找就是加密入口
3.2 混淆代码的高效处理
定位到入口后,不要上来就硬读代码,按这个步骤处理:
- 先格式化:用DevTools的Pretty Print先把压缩代码展开,这一步不花时间
- 解密字符串:绝大多数混淆代码都有一个统一的字符串解密函数,找到后直接把所有加密字符串替换成明文,可读性立刻提升一个量级
- 清理死代码:删除永远不会执行的分支、无意义的变量赋值,精简代码体积
- 控制流还原:如果遇到控制流平坦化,优先用AST工具做自动化还原,比如基于Babel写插件还原调度逻辑;通用工具还原不了的,再考虑手动梳理核心分支
这里有个很重要的心态:不需要还原全部代码。我们只需要搞清楚加密函数的入参、出参和依赖关系,能让它在我们的环境里跑起来就行。无关的业务逻辑、垃圾代码,完全可以跳过。
3.3 Python侧调用方案
把JS加密逻辑抽出来之后,Python侧有两种常用的调用方式:
- 轻量场景:用
PyExecJS或者Node.js子进程执行,适合调用频率不高的场景 - 高性能场景:把加密逻辑封装成HTTP服务,用Python发请求调用,进程常驻,避免重复初始化的开销
importsubprocessimportjsondefcall_js_encrypt(plaintext):"""通过Node子进程调用JS加密逻辑"""js_code=f''' const encrypt = require('./encrypt.js'); console.log(encrypt('{plaintext}')); '''result=subprocess.run(['node','-e',js_code],capture_output=True,text=True)returnresult.stdout.strip()四、WebAssembly逆向:从黑盒到可控
Wasm是很多人的知识盲区,但只要搞懂了它和JS的交互规则,大部分场景都能快速搞定。
4.1 第一步:定位Wasm模块与导出函数
首先在浏览器Network面板找到.wasm文件,下载到本地。然后在Sources面板的WebAssembly分类下,能看到加载的模块,点开后里面有所有导出函数(Exported Functions)。
怎么确认哪个是目标加密函数?两个实用技巧:
- 搜JS源码里的
WebAssembly.instantiate或WebAssembly.Instance,找到实例化后赋值的对象,看它的方法调用 - 给可疑的导出函数打断点,触发一次加密请求,哪个函数被命中,哪个就是目标
很多站点的导出函数没有名字,只有数字编号(比如func_12),没关系,我们只需要知道它的调用方式和参数规则。
4.2 第二步:搞懂参数传递规则
Wasm不能直接传递字符串、对象这类复杂类型,所有数据都通过**线性内存(Linear Memory)**交互,这是Wasm逆向最核心的知识点。
标准交互流程是:
- JS侧把字符串转成Uint8Array,写入Wasm内存的某个地址
- JS把内存地址指针、数据长度传给Wasm导出函数
- Wasm函数内部计算,把结果写到内存的另一块地址
- Wasm返回结果的内存指针,JS从对应地址读取字节并转成字符串
所以调试Wasm的时候,不用纠结内部汇编指令,重点盯三件事:入参指针、入参长度、返回指针。只要能对应上输入输出的内存位置,就能当黑盒用。
4.3 第三步:Python直接加载运行Wasm
如果只是要复现加密结果,最省事的方案就是直接在Python里加载Wasm模块,和浏览器侧一样调用。推荐用wasmtime库,性能稳定,兼容性好。
核心实现代码:
importwasmtimeclassWasmEncryptor:def__init__(self,wasm_path):self.engine=wasmtime.Engine()self.store=wasmtime.Store(self.engine)self.module=wasmtime.Module.from_file(self.engine,wasm_path)self.instance=wasmtime.Instance(self.store,self.module,[])# 获取导出函数和内存对象self._encrypt=self.instance.exports(self.store)["encrypt"]self._malloc=self.instance.exports(self.store)["malloc"]self.memory=self.instance.exports(self.store)["memory"]def_write_str(self,s:str)->tuple[int,int]:"""将字符串写入Wasm内存,返回指针和长度"""data=s.encode("utf-8")ptr=self._malloc(self.store,len(data))buf=self.memory.data_ptr(self.store)buf[ptr:ptr+len(data)]=datareturnptr,len(data)def_read_str(self,ptr:int,max_len=256)->str:"""从Wasm内存读取字符串到00结束符"""buf=self.memory.data_ptr(self.store)end=ptrwhileend<ptr+max_lenandbuf[end]!=0:end+=1returnbytes(buf[ptr:end]).decode("utf-8")defencrypt(self,plaintext:str)->str:ptr,length=self._write_str(plaintext)result_ptr=self._encrypt(self.store,ptr,length)returnself._read_str(result_ptr)这套方案的优势是性能高、不依赖浏览器、可以并发调用,适合大规模采集场景。绝大多数标准加密算法实现的Wasm,都可以用这种方式直接跑起来。
4.4 兜底方案:浏览器RPC调用
如果遇到Wasm逻辑特别复杂、有环境检测、或者有动态生成的Wasm,直接加载跑不通,就用兜底方案:
- 用Playwright启动一个浏览器页面,加载原始站点的JS和Wasm
- 在页面注入Hook代码,封装加密函数成全局方法
- Python侧通过页面
evaluate调用加密函数,拿到结果
这种方案本质是借浏览器的环境跑原始代码,兼容性拉满,再复杂的防护都能绕过,缺点是性能比原生调用低一些。适合逆向成本极高、但调用量不大的场景。
五、踩坑实录:这些坑我都替你踩过了
Wasm和混淆JS的逆向,细节坑特别多,很多时候逻辑都对,但结果就是不对,问题都出在细节上。
坑1:字符串编码不匹配
这是最高频的错误。JS侧默认用UTF-16,Wasm里大多是UTF-8,中文场景很容易出现明文一致、密文不同的情况。一定要确认编码方式,两边统一用UTF-8字节流交互。
坑2:内存越界与地址冲突
自己随便选个内存地址写数据,很容易覆盖Wasm正在使用的内存,导致结果异常甚至直接崩溃。正确做法是调用Wasm导出的malloc函数分配内存,用完后free释放,不要硬编码地址。
坑3:环境检测与反调试
很多混淆JS和Wasm都会做环境检测,比如检测process对象判断是不是Node环境,检测navigator.webdriver判断是不是自动化浏览器。本地运行结果不对的时候,优先排查环境检测,补全缺失的浏览器对象。
坑4:Wasm动态生成
有些站点不直接加载.wasm文件,而是用JS拼接字节数组,再动态实例化Wasm。这种情况不要去扒拼接逻辑,直接HookWebAssembly.instantiate,在实例化的时候把模块dump下来就行。
坑5:多轮加密与链式调用
不要想当然认为只有一次加密。很多站点是Wasm算中间值,JS再做二次处理;或者JS混淆层做一次编码,Wasm做一次加密。一定要从请求参数往前完整回溯,确保没漏掉任何一步处理。
六、写在最后
聊到最后,想说说对逆向这件事的理解。
不管是JS混淆还是WebAssembly,本质上都是成本博弈。站点花成本做防护,提升逆向门槛;我们花成本做破解,权衡时间和收益。没有绝对破解不了的防护,只有性价比不够高的方案。
所以做逆向最忌讳钻牛角尖——为了还原一个算法死磕一周,明明用RPC调用半天就能搞定。真正高效的逆向,永远是先评估方案成本,选最快落地的那条路,先跑通业务,再按需优化性能。
技术是工具,解决问题才是目的。
合规提示:本文所述技术仅用于合法合规的技术研究与公开数据分析场景,请严格遵守目标站点的服务条款与robots协议,禁止用于任何非法用途。