news 2026/9/16 22:10:17

Web JS VMP逆向实战:拆解虚拟机保护与反调试绕过

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Web JS VMP逆向实战:拆解虚拟机保护与反调试绕过

做web分析的人,迟早会撞上一堵叫“VMP”的墙。我最近跟进的项目里,一个普通的登录接口,sign参数居然被塞进了一个运行在浏览器里的“虚拟机”。所谓web js vmp逆向,就是把这种藏在浏览器里的自定义指令集一点点拆开、看透、利用起来。这篇文章没有高深理论,全部来自我这几周的真实踩坑记录,里面聊到的思路、工具和翻车现场,应该能帮正在和VMP纠缠的朋友少走一些弯路。

1. 从一个具体案例说起:VMP保护的登录签名长什么样

1.1 我遇到的目标:登录接口里的“虚拟机”

事情是这样的:我拿到一个需要分析的前端项目,登录接口里有个sign参数,看着像是某种签名算法算出来的。抓包后定位到生成sign的JS文件,第一反应是这代码怎么这么大。格式化之后看,整个文件十几万行,全是那种一眼望不到头的数组、字符串拼接和while循环。按正常反混淆的思路,先把数组还原、再把控制流抻平,结果弄到一半发现不对劲——搜索“sign”关键词,入口函数里只有一行类似vm.execute([...])的调用。真正干活的逻辑根本不在这层JavaScript里,而是被丢给了一个解释器去执行。

当时我就意识到,这不是普通的混淆,而是VMP(虚拟机保护)。通俗点讲,它把原本写成JS的算法翻译成了一套自定义的字节码,然后在浏览器里塞了一个“解释器”去逐条读取和执行这些字节码。你想直接看算法长什么样,看到的只是不断取指令、查表、分发的循环。

1.2 VMP与普通JS混淆的本质区别

普通JS混淆不管玩出什么花样,变量名改短、字符串编码、控制流平坦化、加死代码,最终代码依然是在JavaScript引擎里执行的,逻辑语义还留在代码表面,用AST还原、动态调试基本都能一层层剥开。VMP不一样,它把代码变成了“数据”。真正算法所在的层级,已经脱离了JavaScript语义,落到了一套只有加壳者自己知道的指令集里面。

打个比方,普通混淆是把你手里的地图上的地名全部擦掉,但地形还在,你花点功夫还能走出来。VMP是直接把地图翻译成了一串只有外星人看得懂的密码,然后又塞给你一个外星人翻译机,你得先去搞懂这个翻译机的工作方式。

所以它的特点就是:静态分析,看不到业务逻辑;动态下断点,看到的是解释器在一堆handler之间跳来跳去,栈上全是数字和操作码,完全不是人话。这也是为什么很多人第一次遇到VMP时会一脸懵——不是你能力不行,是因为这东西本质上就不打算让你读。

1.3 Web端VMP的常见形态与来源

从形态上看,Web端的VMP大概分这么几类。一类是商用商业壳对Web/JS的适配产品,它们的思路基本是把C/C++写的核心逻辑先编译成某种字节码,再把解释器部分编译成WebAssembly或者asm.js,外面再套一层JS混淆。一类是大型互联网公司自研的小型虚拟机,专门用来保护登录签名、风控参数这类核心算法,指令集通常不对外公开,每一家都长得很不一样。还有一类是把传统PC端的VM保护方案移植到Web端,这种情况下你甚至能在JS里看到一些很“原生”的影子,比如对内存布局、执行效率的独特处理。

现在很多接口签名很严格、风控策略很重的站点,用的都是这类方案。不管你是做安全研究、写自动化脚本,还是单纯想搞清楚自己项目里引用的第三方SDK为什么加载这么多JS,遇到VMP的概率都在肉眼可见地上升。

2. 定位入口:别急着啃代码,先搞清楚被保护的部分在哪

2.1 用请求调用栈锁定加密参数的诞生位置

逆向VMP最大的忌讳,就是拿到整个JS文件从第一行开始读。我的习惯是先通过请求调用栈,把加密参数的诞生位置精确锁定下来。具体操作很简单:在浏览器开发者工具的Network面板里找到目标请求,右键选择“Break on” -> “fetch/XHR”,随后触发这个请求,代码就会在发起请求的那一行停下。这时候看右侧的Call Stack,从调用栈顶部往下翻几层,基本就能找到生成sign、token这类参数的那个函数。

我还会做一层保险,在window.fetchXMLHttpRequest.prototype.send上打点,把每次请求的调用栈记录下来。很多站点会用异步队列包装请求,栈会被Promise打散,这时候只靠一个断点不够,得同时给Promise.prototype.then也加上日志,才能在异步链路里把源头找出来。

2.2 识别VMP代码的几个信号特征

就算你跳过了入口,也得能认出它是不是VMP。我总结过几个比较明显的信号,命中两三个就可以按VMP来对待了:

  • 代码里有超长的字符串数组,并且这些数组在整个文件里被反复访问、异或、替换。
  • 格式化后能看到一个大的while循环,里面是一大堆switch-case,case分支的数量可能从几十到几百不等,每个case对应一个handler。
  • 某些函数的名字被改成了vm_entry、vm_body、execute、dispatch之类的关键词,不过更多时候是像a1,b2这种完全无意义的名字。
  • 运行时会出现动态生成代码的行为,比如调用Function构造器、eval、WebAssembly实例化。
  • 搜索业务关键词(比如sign、token、encrypt)时,找到的只是一个调用解释器的入口,看不到实际运算逻辑。

这些特征单看任何一个都不致命,但组合在一起,基本可以确认:你面对的不是普通混淆,而是虚拟机保护。

2.3 换一种思路:把目标函数从页面里“抠”出来

锁定了入口之后,不要急着在完整页面里调试,那样会受到大量无关代码干扰。我的做法是:把涉及到的JS片段整体下载下来,去掉页面其他逻辑,保留一个最小的可运行环境。比如我分析的那个登录sign,它所在的模块其实只依赖几个全局变量和几个字符串常量,我就新建一个Node项目,把相关代码粘贴进去,再手动mock掉它依赖的windowdocumentnavigator属性。跑通了之后,再去做断点调试或改写就舒服多了。

这一步最麻烦的地方在于静态抽取时容易漏掉隐式依赖。我遇到过好多次,本地一运行就报某个函数未定义,排查半天发现是原页面在另一个JS文件里给原型链挂了一个方法。这种时候不要硬补环境,而是回到浏览器里,在入口函数处加个断点,把实际运行时所有用到的全局对象和方法打出来,再照着补。这样虽然麻烦,但补出来的环境最真实。

3. 分析VMP的三条实用路线:动态优先,静态垫后

3.1 断点设在dispatch循环:观察解释器怎么工作

当你确定代码是VMP之后,第一件事是找到解释器的dispatch循环。我通常的做法是,格式化代码后搜索while (true)或者for (;;),有多个的话,就挑一个内部带超级多switch-case的。在这个循环的第一行下断点,刷新页面触发一次目标函数,断住后你会发现,每走一步,程序都会从字节码数组里取一个值,经过一堆运算后,决定跳到哪个case执行。这个“取值-查表-执行”的循环,就是VMP的心跳。

在这个循环里,我会重点观察几个东西:程序计数器(PC)是怎么变化的、操作数从哪里来、栈上的数据长什么样。一开始完全看不懂很正常,我就是把多次执行的数据打印出来,慢慢找规律。VMP再花里胡哨,它的循环模式和一个CPU执行机器指令没有本质区别:取指令、解析操作数、执行、更新PC。

3.2 先摸清handler数量:判断该硬读还是绕路

花点时间数一下dispatch循环里有多少个case分支,这个数字直接决定你接下来该怎么走。如果只有三四十个handler,说明虚拟机指令集比较精简,很多复杂操作应该还是通过调用外部函数完成的,这种情况下可以考虑人肉读一遍,甚至把每条指令的语义都标出来。如果有两三百个handler,那说明指令粒度很细,加减乘除、位运算、内存读写都被拆成了Opcode。这时候就不建议硬读了,应该考虑插桩、hook外部API,或者从输入输出差异上做文章。

我见过最夸张的一个目标,handler有四百多个,每个handler里还带了好几层嵌套循环。这种体量,用人肉逐个分析的方式去弄,基本等于给自己判无期徒刑,一定要想别的办法。

3.3 Hook外部API:用“外挂视角”绕开指令分析

VMP可以藏住代码逻辑,但它藏不住JavaScript运行时的API调用。不管解释器内部做得再复杂,最终它还是需要调用String.fromCharCodecharCodeAtindexOfsubstringparseIntDate.now这些基础能力。所以,我通常会在这类方法上提前打上日志Hook。

举个例子,我怀疑VMP里有一段逻辑是把字节数组转成字符串,就在String.prototype.fromCharCode上包了一层,记录每次调用的参数。结果一下就看出来了,解释器经常向这个原生方法传一个由多个字节组成的数组,然后拿返回值做下一步运算。有了这个线索,我再回头去读dispatch循环,理解起来就快得多。

这种外挂视角尤其适合定位算法边界。有些站点的加密结果,其实只是在最后一步调用了某个标准算法,比如Base64变种、MD5、SHA系列的某个封装,你去逆它的VM指令是事倍功半,直接观察它跟原生API的交互,反而能一针见血。

3.4 输入输出边界推演:不还原也能出结果

很多时候,我需要的并不是把整个算法还原成可读的伪代码,而是短期内能稳定地算出某个结果,比如特定请求的签名。这种场景下,一个很实用的思路是输入输出边界推演。

先构造一批边界输入,比如空字符串、全0、全1、递增序列、超长字符串、纯中文、带上特殊字符的字符串,然后全部跑一遍目标函数,记录输出。如果输出呈现出明显的长度固定、或者输入发生很小变化时输出完全改变的特征,就要怀疑是某种hash或校验和算法。再结合前面Hook到的外部API调用,基本能锁定算法家族。

这套办法没法保证你彻底搞懂算法,但胜在快。很多时候,你真正需要的只是一次能跑通的结果,而“能跑通”在自动化任务里往往比“完全理解”更重要。

4. 还原更彻底:AST清理、插桩trace与虚拟指令集解码

4.1 用AST把“伪装层”拆干净,但别破坏可运行性

如果目标不仅是“算出结果”,还想把算法彻底还原出来,那就得进入更深的阶段。第一步仍然是AST层面的清理。VMP外面一般还套着一层常规混淆,比如字符串编码、数组位移、数字计算、控制流平坦化。用AST工具(我常用Babel和esprima)把这层伪装拆干净,可以让解释器代码变得可读一些。

这一步有个非常重要的前提:边改边跑。每做一步AST变换,都要立刻在本地环境跑一遍测试用例,确认输出和变换前完全一致。如果跑出来的结果不一样,说明这步变换破坏了代码语义,要马上回退。很多人栽在“改高兴了但代码已经跑不了”的坑里,尤其要注意字符串字面量的处理,VMP的字节码很多都藏在字符串里,一不留神就改坏了。

4.2 给虚拟机装上“日志记录仪”:插桩trace

当AST清理完、解释器代码基本可读之后,最有效的办法是插桩。我会在dispatch循环里加一段日志代码,记录每次执行的PC值Opcode操作数、执行完之后的栈顶状态。改完代码后跑一次目标函数,导出几十MB的执行日志。

这一步看起来简单,实际坑很多。如果直接在循环里console.log,浏览器会卡死,所以我是先把日志写到一个数组里,函数执行完再一次输出。另外,输出量太大时,还要在循环里加个计数器,只记录前N条指令,或者按Opcode抽样。拿到trace之后,我再写脚本按PC值Opcode分组统计,把重复出现的指令序列提取出来。很多在代码里看上去绕来绕去的逻辑,在trace里会以“重复序列”的形式暴露出真实的子函数结构。

4.3 从trace反推伪代码:一条能落地的思路

拿到trace之后,最后一步是从这些记录里恢复出可读的伪代码。我的做法比较朴素:先把“压栈操作数 -> 调用某个运算handler -> 结果压栈”这种三段式模式识别出来,然后把每一段映射成常见的表达式,比如a + ba ^ ba << 2。如果某个handler调用了外部的String.fromCharCode,我就知道它在做字符串转换;如果它调用了Date.now,大概率是取时间戳参与签名。

这个过程我写过一个粗糙的半自动脚本,坦白说效果一般,复杂一点的运算还是得靠人眼从trace里抠。但思路是可复制的:VMP指令再乱,最终计算无外乎取值、运算、存值这三件事。你只要能分辨出trace里哪些字段是操作数、哪些是运算结果,就能一步步把它翻译回能读的伪代码。

5. 绕开VMP内置反调试与环境检测的实战记录

5.1 常见反调试手法和对应处理方式

VMP通常不会让你舒舒服服地开DevTools调试。我遇到的比较常见的反调试手段包括:无限debugger、检测DevTools是否打开(宽度/时间差检测)、检测调用栈深度、检测console.log是否被重写。其中无限debugger最常见,一开DevTools就卡死。处理办法很粗暴,直接把代码里的debugger关键字替换成空字符串,或者用条件断点让它在非debugger语句处断下来,避开陷阱。

比较麻烦的是检测DevTools是否打开的情况,一旦打开调试面板页面就自动停止执行。我的处理方式是:先用本地替换功能把相关检测函数改写掉,再打开DevTools。或者,干脆不用DevTools,改用Puppeteer或Playwright这类无头浏览器去跑目标代码,通过脚本控制浏览器环境,这样既能执行代码,又不会触发页面里的DevTools检测。

5.2 环境检测与补环境的取舍

VMP为了保护自己,经常会检测当前运行环境是不是真实浏览器。它会检查navigator.webdriverwindow.chromeplugins长度、Canvas指纹、AudioContext、localStorage是否可用等等。一旦发现环境不对,可能直接返回假结果,或者在某个分支里故意走错。

补环境有两条路可以走。一条是用jsdom这类库模拟一个相对完整的DOM环境,再用Proxy对未定义属性返回合理值。优点是方便,代码逻辑改动少;缺点是性能一般,而且细节容易被检测到。另一条路是干脆把代码放到真实浏览器里跑,通过Puppeteer控制页面,在页面上下文里执行目标函数,再把结果传回Node端。缺点是需要一个浏览器实例,但胜在环境真实,几乎不会因为环境检测失败而翻车。

我个人的经验是:如果目标函数的逻辑不依赖太多DOM操作,优先走jsdom;一旦发现它做了很多环境检测,就直接切换到Puppeteer。为了补环境浪费好几天,实在不划算。

5.3 我个人常用的工具链与几个坑

整理一下我这次项目里用得最顺手的工具:Chrome DevTools(日常断点、查看调用栈);本地替换扩展(比如ReRes,用来替换线上的JS文件);Tampermonkey(用来注入Hook脚本);Node.js环境(跑抽取出来的代码);Babel和esprima(AST处理);jsdom(补基础环境);Puppeteer或Playwright(复杂环境下的执行方案);Charles或Fiddler(抓包观察请求细节)。

用Web端VMP分析,有几个坑我几乎每次都会踩一遍。第一个是浏览器缓存,替换完本地JS文件后,线上加载的还是旧版本,排查半天才发现是缓存没清干净。第二个是源映射(Source Map)干扰,如果原站点开启了sourceMappingURL,你在压缩代码上打的断点会被映射到原始源码上,有时候看起来断点“断错了”,其实是被映射逻辑误导了。第三个是浏览器严格模式,有些代码在标准浏览器里会被加上“use strict”,导致一些本来能用的写法报错,而在Node环境里可能反过来正常。每次都记得检查这些基础因素,能省掉很多无谓的排查时间。

6. 卡壳场景与排查技巧实录

6.1 格式化后代码一运行就报错

如果你拿到的是压缩过的JS,第一反应总是先格式化。但VMP场景下,格式化有时会让代码直接跑不起来。原因往往是隐蔽的:某些反调试逻辑会检查当前代码的行列号,甚至利用Function.prototype.toString去验证某个函数的源码内容。格式化改变了行列号,检测就会触发,然后程序就走进了错误分支。

应对办法是,不要整个文件格式化,只把目标函数或解释器那一小段抽出来处理。如果还是受影响,就别动原始缩进,直接在未格式化的代码上打断点观察。硬要格式化的话,也可以先搜一下FunctiontoStringlinecolumn这些关键字,手动把这些检测逻辑改掉,再格式化。

6.2 页面一直转圈“死循环”,问题可能不在循环

分析VMP时经常遇到页面卡死的情况,我一开始以为是死循环,后来才发现不是。一种是断点位置不对,让解释器一直跑空转,看起来像死循环,其实是断点打在了某个每帧都会执行的公共循环上,导致程序永远无法跑完。另一种是插桩日志量太大,浏览器内存被塞爆,页面表现为无响应。

碰到这种问题,先把断点全部停用,再看页面能不能恢复。能恢复就说明有断点打错了地方;不能恢复,就关掉插桩日志,改成每执行1000条指令才记录一条。如果还是不放心,可以在循环里加一个计数器,超过某个阈值就手动抛出异常,把PC值和调用栈打出来,这样能快速定位问题。

6.3 本地结果和浏览器结果不一致

在本地Node环境跑通代码后,经常会发现算出来的签名和浏览器里的不一致,明明代码看起来一模一样。这个问题我至少遇见过三种原因:第一是编码问题,JavaSctipt字符串在浏览器和Node里对Unicode的处理在某些边界情况有差异,比如中文被转成UTF-16还是UTF-8,二进制数组的字节序对不对。第二是数值精度问题,JS里的大整数超过安全范围会丢精度,而VMP里有些中间结果恰好是大到离谱的数字。第三是隐式类型转换,VMP代码里到处是+号,一个字符串数组和一个数字数组做加法,结果可能完全不同,而你在补环境时mock掉的某个全局变量,正好改变了类型。

这类问题排查起来很枯燥,但有个还算高效的办法:对比浏览器和本地各自调用的原生API日志。把String.fromCharCodecharCodeAtDate.now这些关键方法的输入输出都记录成日志,两边一对比,差异立马就能浮现出来。

现阶段我遇到VMP的第一反应,已经不再是拉开代码硬看,而是先想清楚这个壳到底把什么藏起来了、我的目标是拿到结果还是彻底还原算法。判断完这两件事,该Hook就Hook,该插桩就插桩,绝不恋战。另外说句实在话,技术本身没毛病,但这类分析一定要限定在自己有权限的目标上,别拿去碰线上的东西。最后分享一个习惯:不管遇到多复杂的VMP,我都会先花半小时把它的handler表格整理出来,这个投入永远不亏——它不仅是分析的地图,也是判断这个壳值不值得继续啃的依据。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 22:09:37

Windows下Git安装配置与SSH密钥接入托管平台全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 22:09:31

MT9700FFFUBG显示主控芯片深度解析与工程落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 22:08:41

Git 空目录不显示?用 .gitkeep 保留目录结构,避免 clone 后目录丢失

先把结论放在前面&#xff1a;.gitkeep 并不是 Git 官方提供的一个特殊文件类型&#xff0c;它只是一个约定俗成的占位文件&#xff0c;核心职责是让一个空目录能够在 Git 仓库里被真正地保留下来。很多刚开始用 Git 的人都会撞见同一个诡异现象&#xff1a;本地明明建好了 upl…

作者头像 李华
网站建设 2026/9/16 22:08:13

onboard 向导选模型连不上?TaoToken 这样改模型通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 22:07:31

DANN实战:PyTorch实现对抗迁移学习,解决域漂移

最近这两年做深度学习落地项目&#xff0c;我最怕听到的一句话就是“模型上线后效果不对”。明明训练集上准确率已经刷到97%&#xff0c;一换到新的采集设备、新的光照环境&#xff0c;或者换了标注渠道&#xff0c;准确率直接掉回70%上下。这种数据集分布不一致的问题&#xf…

作者头像 李华