Web JS VMP逆向实战:从动态插桩到签名算法还原
2026/9/16 2:23:47 网站建设 项目流程

1. 项目概述

这个项目说白了就是我最近在处理某个业务站点的Web端加密参数时,碰到的一个典型VMP加壳案例。web js vmp逆向,很多人一听“VMP”就头大——一个函数几百上千行,全是while、switch、自增自减,稍微看两眼就晕。这篇随记把我从定位、分析、插桩到还原逻辑的整个过程捋了一遍,也把我踩过的坑一并写出来。

先说清楚VMP是什么概念。VMP(Virtual Machine Protection),原本是PC端软件保护领域的东西,用虚拟机解释执行核心代码,让破解者静态看不到原始逻辑。后来这套思路被搬到了Web前端:服务端把关键算法(比如签名生成、参数加密)编译成一串自定义字节码,下发到浏览器,JS引擎里挂一个解释器来跑字节码。这样你在浏览器里看到的只是一堆无关痛痒的数据拼接,真正的加密计算被藏在指令解释循环里。

这个方案适合谁来参考?只要你在做Web爬虫、接口逆向、页面自动化,或者单纯想搞清楚站点前端参数是怎么生成的,又撞上了这种“代码像迷宫”的情况,那这篇文章应该能给你省下不少试错的时间。我会把每一层的判断依据、每个阶段的实操细节都展开写,尽量让没有太多逆向经验的人也能照葫芦画瓢走通一遍。

2. VMP方案的整体拆解与设计思路

2.1 VMP和普通JS混淆的本质区别

网上很多人把VMP和混淆混为一谈,其实完全是两回事。普通混淆,比如变量名替换、字符串编码、控制流平坦化,无论怎么折腾,原始代码的语义结构还在,你可以用AST去还原、去美化、去改名,终归有迹可循。VMP则根本不让原始代码以原样出现——它把代码变成了自定义指令集的字节码,由解释器逐条执行。

打个比方来说,普通混淆像是你用中文写了个故事,然后加密压缩,你能通过解压还原回中文;而VMP是把这个故事翻译成了一种只有特定翻译员(解释器)才能读懂的符号系统,你光看到原文,不知道符号对应的含义,等于白看。

Web端VMP常见的载体形态有这么几种波:

  • 基于switch的指令分发器。整个大函数是一个while循环,循环体内放着switch,每一步根据opcode执行不同分支,然后更新指令指针。这是最容易识别、也最适合作为练手对象的形态。
  • 基于函数分发表的分发器。opcode不作为switch的case,而是作为索引去查询一个handler函数数组。这种更灵活,但也更费内存,识别起来略有难度。
  • 多层嵌套的复合虚拟机。外层是一个大流程控制虚拟机,内层才是真正的业务逻辑虚拟化。多见于商业防护方案,比如某些Akamai体系下的生成脚本。
  • 状态机 + 数据混淆。部分站点不搞完整的虚拟机,而是把控制流拍平成一张大的状态跟踪表,用状态码跳转,再套一层自定义Base64或十六进制编码。

我这里遇到的案例属于第一种加了一点变体:switch分发 + 动态opcode映射表。运行时他会先从一段密文中解出一个映射表,把操作码和解码后的真实指令一一对应起来。这等于是说,同一份密文跑在不同的浏览器环境里,解释器分发的顺序都不太一样。这块后面细讲。

2.2 逆向VMP的核心思路与阶段划分

拿到一段VMP代码,第一反应肯定是“能不能直接跳过”。但很多场景跳不过去——参数来源层层嵌套,不还原逻辑就没法构造请求。那就得老老实实分阶段来。

我的经验是拆成五个阶段:

  1. 定位阶段:找到生成目标参数的入口函数。一般通过XHR断点、Hook、调用栈回溯定位。
  2. 识别阶段:判断这个函数是不是VMP,是哪一种VMP形态,大致摸清分发器和指令格式。
  3. 还原阶段:把opcode与handler的对应关系还原出来,能静态看明白每一步做了什么。
  4. 执行阶段:通过补环境、模拟执行或者动态插桩,在本地把整个生成流程跑通。
  5. 精简阶段:把不需要的噪声指令剪掉,提取出核心算法,转成可读代码。

很多人一上来就想着把整个解释器读明白,我觉得不现实。VMP的指令集通常几百条起步,里面一半是混淆用的死指令,真正起作用的就那几十条。关键是找到入口、记录执行轨迹、分析中间值的变化,而不是逐行阅读。

// 关键心法:VMP不是拿来读的,是拿来跑的 // 与其静态死磕几百行的switch,不如让代码自己把行为暴露出来

这套思路的取舍逻辑是:动态分析为主,静态分析为辅。静态分析用来定位入口、理解指令格式;动态分析用来记录真实执行轨迹、提取有效opcode序列。两者结合,效率比单靠任何一边高出一大截。

3. 核心细节解析与实操要点

3.1 目标定位:从网络请求参数逆推到VM入口

VMP逆向的开头永远是找入口。接口请求里有个签名参数,它是在哪里生成的?这需要用调用栈一步步回溯。

我先把Chrome DevTools的Network面板打开,找到相关请求,往XHR断点的URL片段里塞一个特征值,让它在发请求前自动断住。断下来后,调用栈最底层的调用者之一,往往就是参数生成函数。

这里有个非常关键的操作:不要急着断点在“栈顶”的函数,因为栈顶通常已经是XMLHttpRequest.send这种浏览器内部方法了。你要往栈里的第三层到第六层去找,找一个参数比较多、作用域相对复杂的普通函数。

// 特征函数:通常长这样 // 函数内部有大量变量赋值,返回值是字符串,且中间调用了一个迷之函数 function buildToken() { var e = ... var t = w(e, "640b2f9..."); // w就是疑似VM入口 return t; }

找到疑似入口后,给它打个断点,再刷新页面或者重新触发请求,看看能不能命中。命中之后先不做任何操作,直接右键复制函数源码,保存下来。

还有一个小技巧:很多站点的VM入口函数是通过动态方式生成或加载的,刷新几次之后函数体内容会变。这种时候不要慌,说明VM设计时带了随机种子控制指令映射,你要把多份样本都存下来,对比分析相同点。相同的地方通常是解释器的固定框架,不同的地方才是动态映射表。

3.2 识别VMP代码的四个典型特征

很多读者反映分不清自己遇到的到底是“高级混淆”还是“VMP”。我总结了一下我见过的所有案例,出镜率最高的VMP特征就这么四条:

第一个特征:大while循环加switch分发。整个函数的主干是一个while(true)或者while(e)循环,循环体里switch语句的case个数很多,而且每个case之间逻辑相似,都是“取指令 -> 执行 -> 修改指令指针”。

第二个特征:指令指针和堆栈结构。VM通常维护一个pc变量(程序计数器)和一个栈数组。栈元素经常是数字原始值、数组、字符串的混合体。看到函数内部频繁出现e[_++]这类从同一个数组里取值的模式,基本可以锁定是栈式虚拟机。

第三个特征:大量无意义分支。普通工程师不会写几百个同样结构的case,更不会在执行路径上放一堆永远不会走到的分支。这是VM设计里故意掺入的死指令。当然,动态分析的时候死指令也会被真实执行一遍,所以记录下来的轨迹里噪声特别多。

第四个特征:字符串、数组方法全被打散。在VM内部几乎不会直接看到charCodeAtsubstr这种方法名,它们通常被包了一层编码形式,在分发器内部才被还原、调用。

我建议大家自己动手做一个小工具,用正则或AST扫描JS文件,统计每个函数的while嵌套层数、switch分支数量、数组索引操作数量。超过一定阈值的,十有八九是VM。

// 简单的特征扫描示例 function isLikelyVm(funcSource) { const switchCount = (funcSource.match(/case\s+/g) || []).length; const whileCount = (funcSource.match(/while\s*\(/g) || []).length; const arrayIndexCount = (funcSource.match(/\[\s*[a-zA-Z_$][\w$]*\s*\]/g) || []).length; return switchCount > 20 && whileCount >= 1 && arrayIndexCount > 30; }

3.3 动态插桩:让VM自己说出它的秘密

静态识别到这个程度基本就够了,接下来大头是动态插桩。我的插桩思路主要是三步:

第一步,Hook掉栈数组操作。绝大多数VM的栈操作最终都落在push、pop、join、toString这几个方法上。我直接在VM入口函数作用域里临时重写Array.prototype.push和pop,记录每次入栈出栈的值。

第二步,Hook指令分发器。如果能在代码里定位到switch变量,也就是opcode所在的位置,那就可以在每轮循环的开始把opcode打出来。但这里有个难度——很多VM把opcode藏在变量里,变量名是压缩后的单字符,你根本不知道哪个是。

这里我提供一个更实用的方案:不要去猜opcode,而是给整个VM函数的执行过程拍监控日志。在VM入口的第一行插入日志代码,记录每一轮while循环结束后栈数组的快照深度变化。这个方法不要求你理解VM内部实现,只需要盯着输入输出变化就行。

第三步,动态还原opcode与handler的映射。当你确认了分发器结构之后,可以通过修改opcode的值让代码执行不同分支,从而观察每个opcode对应的行为。比如循环里遇到某个case,打印出case编号,再单步执行几轮,就能总结出“opcode 1 = 把后面的常数压栈”、“opcode 7 = 调用某个内置函数”这种映射关系。

这块实际操作时比较耗时,但只要第一个opcode的行为确认了,后面就顺了。就像解开一团毛线,找到线头才是最难的部分。

3.4 指令还原与逻辑复原实战记录

单步跑了几百轮循环之后,我在日志里筛出了有价值的信息。整个VM的指令体系大概只有十二种有效指令,剩下几十种全是干扰分支。

有效指令大致是这么几类:

  • 压栈类:从字节码流中取下一个数或字符串,压入栈顶。
  • 弹出类:把栈顶元素弹出,赋值给某个临时变量。
  • 二元运算类:弹出栈顶两个值做加减乘除、异或、位运算,再把结果压回去。
  • 拼接类:把字符串拼接、数组等操作,很多签名生成的最后一步都要走这类指令。
  • 跳转类:根据条件改变指令指针,主要用于程序流程控制。
  • 函数调用类:从栈上取出参数,调用某个JS内置函数或全局函数。

我把日志里多次出现的同一类指令聚类,手动标注了它的行为。这个过程有点枯燥,但我发现了一个规律:VM的设计者一般会把最常用的操作符放在switch分支的中间位置,case值比较接近的是一组对称操作。理解了这一点之后,我按case编号做一个频率统计,高频的case翻译成常见运算符,低频的case大概率是死指令。

// 简化后的还原伪代码 // 目标:从日志中提取实际执行的case编号 const logEntry = [] while (vmRunning) { const op = readOpcode() // 读取下一指令 logEntry.push(op.case_number) execute(op) // 执行并修改pc }

当我把有效指令串联起来之后,签名算法就浮出水面了。整个过程就是:

  1. 取出一段固定字符串和当前时间戳。
  2. 对时间戳做两次异或运算。
  3. 把结果通过一个自定义编码表映射成最终的签名参数。

就这么简单。但放在VMP里它会让绝大多数人望而却步,因为单看源码根本不知道它在算什么

3.5 补环境运行方案:让VM在Node环境里跑通

很多时候光看还原逻辑还不够直观,所以我把VM和还原出的指令集整体迁移到Node环境里跑了一遍。

技术上要处理的点主要有四个:

  • window对象检测。VM运行前一般会检测window、document是否真实存在。补环境的通病方案是用Node的global对象mock一部分,但不够,有些VM要求构造函数名字也对得上。
  • 内置函数toString校验。VM会调用Function.prototype.toString来看某个函数源码是否被Hook过。对付办法是提前把toString改造一下,或者更稳妥一点,不用函数注入,而是直接在源码层面改写调用关系。
  • 动态生成代码。很多VM用eval、new Function来加载加密后的逻辑,这在Node里要稍微处理下。
  • 定时器与DOM API。如果真的依赖了DOM节点,那还得补很多。

我用的补环境框架不算复杂,核心是用Proxy拦截一切属性访问,有值返回值,没值返回一个函数。这个方案在跑通VM的执行阶段特别管用,因为它不需要你手动给每个属性准备具体值。

const sandbox = new Proxy(global, { get(target, prop) { if (prop in target) return target[prop]; return () => {}; }, set(target, prop, value) { target[prop] = value; return true; } });

注意,Proxy补环境不是万能的,遇到需要具体值的地方还得单独补。比如VM里有个分支依赖navigator.userAgent.contains('Windows'),你不补这个值它就会走错分支。但作为第一阶段的跑通工具,它已经很香了。

4. 实操过程与核心环节实现

4.1 完整还原步骤演示

我拿我这个案例走一遍完整流程,从头到尾串起来。

第一步,抓包定位请求与参数。我在DevTools的Network面板里锁定了那条带签名参数的异步请求,记录下参数名和当前页面的所有环境变量。

第二步,用XHR断点回溯调用栈。在Sources面板的XHR Breakpoints里加了一个URL关键词,刷新之后断点命中。调用栈向下一层层找,找到了一个buildToken函数。在函数开头插入断点,刷新,确认每次签名生成都会先走这个函数。

第三步,把疑似VM函数抠出来。buildToken内部调用了w这个函数,我把w定义处的代码完整复制,单独存成一个JS文件。在原网页里搜索w函数名字,发现它在另一个脚本里,那段脚本还有一段长长的密文数组。

第四步,动态插桩收集执行日志。我在Chrome DevTools的Sources面板里选中VM入口函数的第一行,右键选择“Add logpoint”,直接打印当前栈数组的内容。这一步不需要改源码文件,是真的方便。刷新几次之后,我拿到了三份不同时间戳下的日志文件。

第五步,离线分析日志,梳理指令映射。我把日志保存为JSON,在Node里写了个脚本统计case编号的频率,并对比前后日志,去掉那些重复多次但是结果完全没变化的指令。最后筛出有效指令集。

第六步,编写本地执行脚本。在Node里补环境后,把VM代码原样执行,传入不同时间戳测试输出,验证和浏览器里的结果一致。

第七步,重构出最终的签名算法。得到一致结果之后,我直接用JS重写了一个几百行的VM为十几行的签名函数。

// 最终重构结果(脱敏) function generateSignature(timestamp) { const secret = "xxx"; const mixed = timestamp ^ 0x5f3759df ^ secret.length; const encoded = encodeTable[mixed % 128] + encodeTable[(mixed >> 7) % 128]; return encoded + "." + secret.length.toString(16); }

4.2 参数计算与验证方法

做逆向一定不能只还原逻辑,还要想办法验证还原结果在真实环境里是对的。我的验证方法是双重比对:

  • 第一重:本地执行比对。取同一个时间戳,跑原版VM和我的重构算法,结果完全一致。这个只能说明我读懂了VM行为,不代表一定能过服务端校验。
  • 第二重:实际请求验证。用重构算法生成签名后,放到真实请求里发出。一开始要基于同一个会话环境,因为在浏览器环境里生成的参数还可能包含会话Token或Cookie因素。如果请求返回正常数据,才说明还原成功。

这里补充一个我踩过的坑:时间戳的对齐问题。VM里用的时间戳是浏览器端的本地时间还是服务器下发的北京时间,会直接影响结果的正确性。我去翻了服务端响应头里的Date字段,发现目标站点对时间有校准逻辑,所以本地执行时统一用服务器时间减去延迟误差。

安装好本地执行环境以后,最关键的一步是给VM构建一个可控的输入环境。因为VM运行依赖许多浏览器全局变量,如果不把这些变量补全,即便你在Node里跑通,也极可能因为某个undefined属性走错分支。我的做法是先用浏览器里导出的环境快照,再逐步删减,确保本地和浏览器行为一致。

4.3 抓包、调试与断点工具链的搭配

聊到工具链,我觉得有必要把整套组合拳交代清楚,因为不同工具的用途其实是互补的。

Chrome DevTools是我最依赖的工具。除了常规的断点,它有一个特别好用的能力:源码片段修改。在Sources面板右键脚本文件,选择“Local overrides”,可以直接把服务器端JS替换成本地修改版。这意味着我可以给VM函数体中间直接插入console.log而不用改动线上文件,也不影响后续调用。

交换器代理工具(Fiddler/Charles/Whistle):用于抓HTTPS流量,改写返回的JS脚本。当某个JS文件在响应过程中被加密或压缩,我常常在代理层提前把解密后的版本换进去。

vConsole:移动端页面常用的调试组件,在Android WebView或微信浏览器里调试H5很方便,可以随时注入。

Node.js:主要用来做离线环境跑VM。把浏览器的执行环境平移到Node里,用脚本批量计算签名,是所有轻量逆向的终点。

JavaScript AST工具:主要是Babel和estraverse,拿来做静态分析,可以快速定位switch分支和opcode变量名,还能批量把死代码标记出来。

还有一个小众但好用的工具是ReRes,它可以对网页里特定路径的JS做正则替换,适合给JS加Hook。

这套工具链搭配下来,基本上从捕获请求到还原逻辑全程都不用换工具,效率比较高。

4.4 如何确认VM入口和边界

定位VM入口是最容易出错的环节。因为VMP经常把入口函数封装得特别隐蔽,有时候一个函数内部有多层调用,而真正的VM只是其中的一小段。

我的经验是三步确认法:

  • 第一,边界确认。在疑似VM入口函数的第一行打断点,确认每次参数生成都经过这里;在函数最后一行打断点,确认输出值和目标参数一致。
  • 第二,输入输出确认。手动改一下输入值(比如时间戳加1秒),看输出是否按预期变化。如果变了,说明这个函数输出的参数确实驱动了后面的逻辑。
  • 第三,代码结构确认。打开函数源码,看整体代码量。如果函数体超过200行且以while循环为主体,基本可以确认是VM核心。

还有一个小技巧:把函数断点设到VM入口旁边,然后单步进入,观察函数内部是否立即出现“取数组元素、压栈、切换case”的循环模式。一旦确认,就可以放心进入插桩阶段了。

5. 常见问题与排查技巧实录

5.1 高频坑位速查表

我把这次碰到的问题和平时交流群里常见的坑汇总成一张表,方便大家对照着排查。

问题现象可能原因排查思路
断点始终不命中VM入口找错了位置用调用栈逐层向上确认,不要只看第一层
输出和浏览器不一致补环境缺字段对比浏览器端window属性和本地sandbox属性
本地执行永远抛异常依赖了浏览器独特的API用Proxy大法拦截,对所有访问返回函数
签名值对不上时间戳来源不一致统一从服务端响应头获取时间
日志数据量爆炸死指令太多去掉高频死循环分支,关注栈深度的真实变化
opcode映射每次刷新都变动态映射 + 随机种子多采集几份样本,提取公共框架
执行后检测到调试器VM内置反调试找debugger关键字,Hook Function.prototype.constructor
位置偏差导致还原失败VM入口边界找错回到调用栈,逐层检查边界

5.2 反调试与反Hook的常规对抗

有些VMP会内置反调试逻辑,比如在循环里塞一个debugger语句,或者用setInterval反复检查当前执行时间,要是发现执行时间异常(比如被断点卡顿过),就走混淆路径,产出错误的签名。

对付反调试,我的做法是先把VM源码里搜一遍debugger关键字,能删则删;删除不了就本地替换,用Local overrides把debugger替换成空语句。

还有一类更阴险的检测是检查Function.prototype.toString的输出。当你用hook脚本替换了函数之后,函数的toString输出会变成function () { [native code] },VM就会认为当前执行环境不合法。我处理的办法是把这个toString函数本身也mock一份,让它输出和原函数一模一样的内容:

// 修复被Hook过的函数的toString显示 Function.prototype.toString = function() { if (this.__originalContent) return this.__originalContent; return Function.prototype.__originalToString.call(this); };

5.3 定位死循环与性能卡死问题

VMP代码里最让人头疼的是死循环。因为VM的循环执行轮次多,一个微小的跳转错误就可能让它永远在同一个case里兜圈子。

遇到死循环,我的经验是:

  1. 打开Performance面板,等页面卡死时记录JS Profile,看哪个函数的自耗时最高。
  2. 在那个函数上打日志点,每执行1000次循环输出一个当前栈深度。
  3. 对比栈深度变化,如果长时间没变化,说明它进入了某个重复逻辑。

这时候通常是有个条件判断永远为真,需要回到条件里去看它依赖的变量,找到变量来源后,手动修改某个依赖值让条件翻转,就能跳出来。

还有一种情况是VM里的递归调用,看起来像死循环,实际是栈无限增长导致内存溢出。区别方法是:死循环的栈深度不变,无限递归的栈深度会不断上涨。

5.4 关于动态映射表的处理经验

这次案例的VM用了一个动态映射表来让每次加载的opcode编号都不一样,这在商业防护方案里非常常见。

处理动态映射的关键思路是提取出不变量。比如:

  • 压栈指令虽然在每次加载的case编号不同,但它总会从字节码流的下一个位置取值
  • 二元运算符虽然case编号变化,但它总会执行相同的栈操作和算术逻辑

所以我把case编号当作变量,把“行为模型”当作不变量。还原时我先记录所有case的“行为签名”(比如取几个参数、做什么运算、压不回栈上),再把这个签名和真实执行结果对应起来。

这样一来,无论它下一次加载时case编号怎么变,我只要重新解析一遍行为签名,就能自动映射到对应逻辑。

当然,有些防护更强的VM会在运行时动态生成新的handler代码,这种情况下行为签名也会变,那就真的只能靠每一次跑的动态日志来还原。这种项目投入产出比很低,一般遇到我会评估是否值得硬刚,很多时候换别的思路更划算。

6. 写在最后的一点实操心得

做web js vmp逆向这几次以来,我觉得最核心的能力不是会多少工具,而是会不会拆问题。把“还原一段几百行的VM”拆成“找入口、识指令、做插桩、跑离线、验逻辑”五个小任务之后,每一步的难度都会断崖式下降。

我个人在实际操作中特别享受插桩之后看到日志里规律浮现的那一瞬间。前期在几百兆日志里捞有效信息确实很痛苦,但只要耐心把case频率统计表做出来,整个VM的真实意图就会非常清晰地浮出水面。这个东西很像拼图,拼完一块后面就顺了。

最后再分享一个小技巧:遇到搞不定的VM时,先别急着硬啃,把项目的目标捋一遍,想想有没有别的曲线路径。比如某些业务参数虽然前端做了VMP,但某个旧版本接口或者某个子域名还暴露着原始逻辑,先去那边找找惊喜,往往能省半天时间。把硬啃当作最后的手段,而不是首选方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询