ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

VMP加壳JS逆向实战:从字节码到核心算法还原

2026/9/16 10:35:34 拓冰建站 浏览量
VMP加壳JS逆向实战:从字节码到核心算法还原 这篇随记是我在分析某个Web端JS脚本时留下的实战记录。整个过程断断续续用了将近一周踩了不少坑也整理出了一套相对高效的还原思路。如果你已经在网上搜过js逆向vmp加壳这些关键词大概率看过各种玄学文章但真正能落地、能复现的内容并不多。这篇文章不打算讲太多理论尽量以实操记录为主把我从拿到一个加壳JS到最终还原出核心算法的完整路径走一遍适合正在研究Web端VMP逆向或者被某个混淆脚本卡住的朋友参考。先说清楚这篇东西解决什么问题。VMP全称是Virtual Machine Protection虚拟化保护。它会把原本的JS代码转译成自定义字节码再用一个解释器循环去执行从外部看整个脚本就是一团乱码加一个虚拟机运行时。传统的AST还原、变量名清理在VMP面前基本失效因为源代码的逻辑已经不在了只剩下虚拟机指令。我这次的目的是还原一个VMP样本的核心加密算法用于正常的接口分析和技术学习。整个分析过程我尽量拆成可复现的步骤连工具选型也会说明理由。1. VMP的核心机制与Web端特征1.1 从传统混淆到虚拟化保护的演进要理解VMP为什么难搞得先知道它和普通混淆的本质区别。传统混淆比如字符串加密、控制流平坦化、Debug Protection本质上还是在原有的JS语法树上做变换。比如控制流平坦化它把原来的if-else、for循环拆散扔进一个switch分发器里但每条语句本身还是JS你花时间慢慢捋最终能恢复出可读的代码。VMP的思路完全不同。它内置一个解释器把你写的函数逻辑变成一串自定义的opcode然后由解释器逐条取出、解码、执行。你可以理解为正常代码是让你看源码普通混淆是让你看加密后的源码而VMP是直接给你一台虚拟机里面跑着你看不懂的字节码。分析者面对的不是难读的JS而是一套完全陌生的指令集。这就是为什么VMP在反爬、算法保护领域被大量使用——它把逆向的门槛从耐心提升到了需要自己造一个反汇编器的层级。1.2 Web端VMP与桌面端VMP的差异提到VMP很多人第一反应是桌面上那个VMProtect用来保护C/C程序的。Web端的JS VMP虽然叫同一个名字但实现机制和对抗思路有不少差异。桌面端VMP跑在原生层它有完整的指令集设计可以操作寄存器、内存、栈甚至插入各种fake分支干扰分析。而Web端JS VMP受限于JavaScript语言本身的沙箱约束指令集通常只围绕变量读写、算术运算、数组操作、字符串拼接、函数调用这几类来设计。没有真正的寄存器一般用数组或对象来模拟存储单元。但Web端有Web端难搞的地方。JS是动态类型语言变量可以在运行时随意改变类型这给VMP的指令设计提供了更多混淆空间。加上浏览器环境的检测能力Navigator、Document、Canvas指纹很多VMP会主动检测你是在浏览器里还是被某个Hook框架控制一旦发现异常直接死循环或者输出错误结果。这就意味着你不仅要还原指令含义还要保证在执行过程中环境足够真实。桌面端反调试是检测调试器Web端反调试是检测DevTools和运行时环境逻辑相通但具体手段完全不同。1.3 为什么这个样本选择了VMP我这次拿到的样本核心是一个生成签名参数的算法。原始逻辑其实不复杂就是取时间戳、拼接固定盐值、做两轮摘要运算、加上一些字符填充。如果用常规混淆只要花时间人肉还原一两天基本能出结果。但对方上了VMP整个函数被完全打散成字节码任何一个调试器attach上去看到的只有一条条dispatch循环在执行。从保护方的视角看这个选择很聪明。因为签名算法的周期短、逻辑固定重写成本高恰恰是应该重点保护的对象。而从分析者视角看这个样本的VMP实现并不算特别复杂——它的指令集规模不大handler逻辑相对规整这给了我们用脚本化方式恢复的机会。后面我会详细讲我是怎么找出这些规律的。2. 逆向前的准备工作与工具选型2.1 一套够用又不折腾的环境工欲善其事必先利其器。VMP逆向对工具链有一定要求但没必要上来就搞一堆重型装备。我最终稳定使用的组合是这样的Node.js 16用于运行脱壳后的JS脚本我习惯在Node环境里做动态验证因为可以方便地打印、断点、profile比浏览器里操作少很多干扰。Chrome DevTools主要用于初步观察脚本加载情况、网络请求、以及最开始的入口定位。一旦确认进入VMP逻辑我基本就放弃DevTools逐步调试了因为你单步跟下去只会看到dispatch循环无限流转。WebStorm或VS Code我主力用的是VS Code配合Prettier做代码格式化。格式化这一步不是说能把混淆代码变成可读代码而是把堆成一行或者几行的代码展开至少让函数边界、变量声明清晰起来。Fiddler或Charles抓包用主要用来捕获页面真正请求接口时生成的签名值这些值就是后面验证还原结果正确性的标准答案。Python 3写还原脚本时我用Python纯粹因为处理二进制、字节序、正则替换这些操作比JS顺手。这一步不是必须的你完全可以用Node写看个人习惯。这里要特别提醒VMP分析过程中一定会频繁做改代码-跑一下-看输出的操作所以一定不要直接在生产环境或者线上页面里调试在本地搭一个尽可能干净的环境部署好DOM和浏览器的mock这会节省大量时间。2.2 识别一个JS是否经过VMP的三个信号在决定投入精力做VMP还原之前你必须先确认目标确实采用了虚拟化保护。有些脚本只是做了重度混淆看起来一团乱麻但本质上还是普通JS用AST还原工具就能处理。VMP和重度混淆的核心区别在于是否存在一个明显的主分发循环dispatcher和一组handler函数。我总结了自己的判断方法一般看三个信号信号一代码中出现大量连续的数字数组。VMP的字节码通常以数组形式存放数组元素可能是opcode、操作数、立即数而且元素数量动辄成百上千。如果一段代码里有一个极长的数组几百到几千项被反复索引这很有可能是字节码区段。当然也有VMP会把这些数组拆碎后用拼接方式动态生成那就需要从执行流入手判断。信号二函数体内出现一个while循环循环里用switch或者if-else链分发执行。这是最明显的dispatcher特征。JIT优化编译器对这类代码很头疼但保护方本来就希望它执行慢一点因为分析者也跟着慢。VMP的dispatch循环通常长这样取出当前指令指针指向的值查表找到对应handler执行回到循环头部。常见变体有平铺式一条条if判断、查表式用一个对象建立opcode到handler的映射、或者间接跳转式handler地址本身也动态计算。看到这种结构基本可以确认VMP。信号三真正有逻辑的字符串你几乎找不到。如果一段代码看起来全是十六进制数、Unicode转义、位运算而你又无法从里面搜索到任何接口字段名、错误提示、参数名等业务特征字符串那很可能这些字符串都被编码进了字节码里运行时才逐步解码。正常混淆通常还会保留部分字符串常量VMP是连字符串一起藏起来的这也是它保护效果好的原因之一。2.3 情报收集先把目标摸个底工具链准备好之后不要急着开始逆向先做一轮情报收集。我习惯从三个维度入手第一是网络请求层面。用Fiddler或者直接看DevTools的Network面板抓住页面运行时发出的关键XHR请求。比如我这次的目标是一个登录接口每次请求头里必须带一个X-Sign参数服务端校验不过直接拒绝。我就把这一条请求作为分析锚点整个逆向过程都在围绕怎么生成这个X-Sign进行。第二是脚本加载层面。在Sources面板里找到执行加密逻辑的那个JS文件。记下文件名、加载顺序、加载前有没有进行环境校验。有些VMP脚本会在初始化阶段执行一堆环境检测如果其中某一步不满足后续逻辑直接跑偏输出一堆无意义的东西。这一步做扎实了后面你mock环境的时候才知道哪些细节不能忽略。第三是代码形态层面。用编辑器打开JS先全局搜索几个关键词while、switch、Function(、eval(。如果找到超长数组和dispatch循环马上标记dos页面。也可以直接在Node里尝试require这个脚本它可能是一个IIFE格式的自执行函数看能否正常跑起来。如果跑不起来就说明它对环境有依赖我的mock对象列表也就基本确定了。做完以上三步我会给自己列一个问题清单目标算法输入是什么、输出是什么、VMP的入口在哪个函数、这个函数依赖了哪些外部变量。清单越清晰后续越不容易迷失。3. 从拿到样本到定位入口的完整路径3.1 从网络请求反推目标函数VMP逆向和普通JS逆向一样核心问题是这个参数到底从哪来。我的做法很老套先理清请求链再去代码里找生成点。以我这次的样本为例页面在调用接口前会先执行一段JS这段JS内部引用了某处全局对象上的方法。我在控制台直接输出这个全局对象发现它是一个模块化包装过的对象方法名被改成了单字母缩写正常去看完全不知所云。但没关系我通过一个最简单的方法定位把这段JS放到Node里跑然后传入固定的时间戳看它在哪个函数内部耗时最长、生成了什么值。具体操作用Function.prototype.toString把整个模块源码打出来然后按函数名搜索找出所有可能在加密流程中被调用的方法逐个下断点。这个过程比较笨但很可靠。一旦看到某个函数内部出现了连续的数字数组和循环结构八九不离十就是VMP的容器了。3.2 入口函数的三个特征定位到容器之后你怎么确定它就是加密算法的入口我总结了三个特征一是输入参数的类型和个数。如果某个函数接收两个参数一个看起来像字符串的input一个是数字或者固定长度的密钥那极大概率就是算法入口之一。二是函数内部的强行赋值结构。VMP入口函数通常会在前几行做大量初始化比如把字节码数组从另一个函数里拉出来、初始化存储区一般是数组、设置初始的指令指针。这些初始化代码不会被VMP化因为它们本身是虚拟机启动逻辑的一部分。三是时间戳相关的引用。大多数签名算法都要涉及时间戳。在入口函数附近的代码里搜索Date、now、getTime这些关键词如果再配合字节码数组出现那基本锁定。3.3 一个竞品混淆的障眼法坑这个样本里有一个挺有意思的细节。它除了VMP之外最外层还套了一层类Obfuscator的字符串混淆和死代码注入。也就是说你打开代码看到的是一堆无意义的赋值、一堆解不开的字符串计算必须先把这层剥掉才能看到真正的VMP结构。我当时的做法是先不管字符串混淆直接按函数边界做格式化。因为VMP的dispatch循环和handler函数是完整的函数块不管外层怎么混淆函数边界总会保留除非用了很极端的代码合并手段。格式化之后手工找到所有function关键字看函数体长度超过50行的先单独抽出来研究。最终从20多个长函数里筛出了dispatch循环和大概6个核心handler函数。这里提醒一句不要试图先去还原那层字符串混淆。它对你的核心目标帮助不大反而消耗时间。你是来还原算法的不是帮对方做代码保洁。3.4 环境mock与运行时依赖处理VMP脚本一旦检测到运行环境异常它会走一条假逻辑分支。这个分支也会产生看起来很像样的输出但实际上是错的专门用来坑逆向者。为了保证我们后面还原的结果是可信的必须先把环境mock做完整。我这次需要的环境依赖主要有三个window对象脚本会读取window.navigator.userAgent、window.location.href等字段用来生成一个环境指纹。document对象可能读取cookie或者某个DOM节点的属性我直接用了一个很简化的mock只要字段存在且类型正确就能过。本地时间签名算法里有Date.now()我需要保证每次运行传入相同的时间戳才能对照输出是否稳定。用Node跑VMP时我通常会这样mockglobal.window { navigator: { userAgent: Mozilla/5.0 ... }, location: { href: https://target.example.com/login }, screen: { width: 1920, height: 1080 } }; global.document { cookie: , getElementById: () null, createElement: () ({ getContext: () null }) }; global.Date Date;不要小看这些mock哪怕一个字段类型不对VMP在取属性时抛了异常整个执行流也会对不上后面就全乱套了。4. 核心环节从字节码到可读逻辑的还原之路4.1 先让虚拟机跑起来再说还原VMP最忌讳上来就静态硬啃那一串数字数组。正确的姿势是先动态跑通再结合动态输出逆向指令含义。我在Node里搞了一个简易运行环境把整个模块加载后手动调用入口函数。因为VMP的dispatch循环会从字节码数组里逐条取指令这个过程是确定的、可跟踪的。我通过改写数组的访问器每一次opcodes[ip]被访问时就打印出当前的ip和取到的值。这样一来虽然我没有看到任何有意义的源码但我至少掌握了程序在按什么顺序执行哪些指令。这一步做完你会得到一堆执行轨迹。轨迹可能很长但没关系你只需要在几个关键位置设置条件断点比如当结果数组被写入某个位置时暂停反向追踪最近的几条指令。4.2 定位opcode与handler的映射关系每个VMP都有一个核心dispatch函数它通常长这样function vm(opcodes, ctx) { var ip 0; while (ip opcodes.length) { var op opcodes[ip]; switch (op) { case 0x01: /* handler1 */ break; case 0x02: /* handler2 */ break; // ... } } }有些VMP会做得更隐蔽用对象映射或者通过Function动态构造来避免出现明显的switch结构。但不管形式怎么变opcode与handler的对应关系一定会存在某一个表里。这个表就是破解的关键。我是怎么找这个表的先从执行轨迹里抓出第一条约200条指令的opcode序列然后在代码里搜索这些数字出现的上下文。找一个稍微高频的opcode比如0x07数它出现过几次然后在dispatch函数里看它会被分到哪个分支那个分支对应的代码块就是这个opcode的handler。把多个opcode和handler对应关系整理成一个映射表VMP的指令集就初步还原了。4.3 常见handler的语义归纳虚拟机再花哨核心指令类型其实大同小异。我这次遇到的VMP识别的handler大概有这几类opcodeHandler行为类比JS语法0x01将立即数压入操作数栈变量 常量0x02从上下文读取变量并压栈变量读取0x03将栈顶值写入上下文变量变量赋值0x04对栈顶两值执行加法运算a b0x05执行字符串拼接a b (字符串)0x06调用外部函数func(args)0x07条件跳转if (condition)0x08无条件跳转while / goto0x09返回栈顶值return这张表不是一次到位画出来的是不断执行、不断验证、逐步完善的。拿加法指令举例你会看到执行轨迹中连续出现0x04接着结果被写入某个变量再之后这个变量被当成字符串拼接的一个参数。多次观察后就能确定它是在做字符串模板拼接而不是数值累加。4.4 静态还原与动态插桩的双轨策略实际还原过程中我采用双轨策略静态还原负责建立指令语义的认知动态插桩负责验证当前输入下的实际输出。静态层面我把opcode映射表套在字节码数组上手动把几百条指令翻译成伪代码。这个过程很枯燥但一遍走下来你会对代码执行路径形成整体感知。动态层面我在关键位置打日志记录每一步栈的变化。两个结果互相印证只要某一行翻译错了执行轨迹会立刻对不上。整体工作流我把它总结为定位入口 - 标记opcode表 - 跑一遍轨迹 - 猜语义 - 改还原器 - 再跑 - 对比输出。循环往复直到最终结果和抓包得到的真实签名值一致。4.5 处理VMP中的自修改与反调试陷阱这个样本里还有一个比较麻烦的点它的部分字节码在执行过程中会被动态修改。具体来说有些指令的功能依赖于执行时计算出来的某个值而这个值又取决于之前若干条指令的运行结果。这意味着你如果只做一次性静态翻译翻译出来的逻辑可能是错的因为某一处动态计算导致后续指令流发生了变化。我的应对方案是在动态插桩的基础上做条件日志。每当IP到达某个被标记过的指令位置时额外打印出当前上下文的所有变量值。把这些日志拿来对比就能发现哪些位置的值被动态改写从而修正静态翻译的结果。整个过程极其依赖对执行轨迹的细致观察所以日志设计一定要合理最好能在每20条指令的位置输出一次摘要避免日志量太大刷屏。还有一类VMP会在dispatch循环里故意插入一些假指令它们不影响结果纯粹为了增加分析负担。识别它们的技巧是在多次运行中如果某条指令不参与最终输出值的计算大概率就是垃圾指令。后期的指令裁剪也需要靠这个思路来缩减分析量。5. 实战案例复现还原一个CryptoJS调用的VMP封装5.1 案例背景登录接口签名参数分析我这里换一个场景来说明整个流程的通用性。假设你接到一个比较常见的需求分析一个Web登录接口服务端要求客户端生成一个X-Sign参数这个参数由一个经过VMP保护的JS脚本生成。你的任务就是还原这个签名的生成逻辑。第一步照旧是抓包。你会发现请求体里除了用户名密码之外还有一个6位的随机nonce很明显这是客户端生成的签名算法应该用到了它。再看JS整个文件只有几千行但最初半小时几乎找不到任何业务逻辑由于VMP的存在连X-Sign字符串都不会出现它一定是被编码在字节码内部了。5.2 从入口到输出的逐步推演过程现在我们从入口开始完整走一遍在Chrome里触发一次登录请求打断点看堆栈找到调用签名函数的那一层。在这个函数里观察入参。假设传入的是username password nonce三个字符串那么签名函数大致的输入结构就能确定。接着进入VM的dispatch逻辑。我们已经在前面准备好了opcode映射表。把它套上去翻译出大概的伪代码逻辑。使用翻译结果去重写一个简单的JS版本。注意不要一上来就替换原逻辑先做一个并排跑的验证脚本一方面用原JS算出签名值A一方面用自己的重写脚本算出签名值B连续跑10次如果全部一致说明还原成功。这里的核心调试点在于不断校验中间变量。VMP生成的中间结果往往是十六进制字符串或者字节数组我经常在中途把栈里的值导出来和重写版本里对应步骤的输出做diff。这一步非常考验耐心但也是整个逆向过程中最出成果的地方。5.3 为什么你的重写结果时对时错但很多朋友会在这个阶段遇到一个诡异的问题还原出来的脚本偶尔正确偶尔错误。可能跑5次有两次签名值不对。这时候大部分人的第一反应是去调算法细节但实际上问题往往出在脚本中的时间依赖或者随机数生成逻辑上。这类签名算法为了防重放通常会绑上时间戳。你在抓包时记录的样本和你自己重写脚本运行时的时间戳是不一样的如果还原逻辑里时间处理的边界条件没搞对结果就会偶发错误。我遇到过一种情况原脚本在跨秒时对时间戳做了四舍五入而我的重写版本用了向下取整导致在某个毫秒临界点结果不同。对策也很简单把时间固定住。在Node里重写Date.now直接返回一个固定的毫秒数然后对比固定时间点下原JS和重写JS的输出。如果固定时间点完全一致再把时间恢复原样做压力测试。这样能把时间相关的错误和逻辑错误区分开。5.4 用动态追踪解决动态解密字符串字符串加密列表里有一项它是由VMP在运行时动态拼出来的。这个字符串在静态代码里完全不存在你必须通过追踪虚拟机的执行过程才能拿到拼接结果。我的做法是hook住虚拟机的栈写入操作每当有字符串类型的数据被写入上下文时就把它记录下来。开始时会很杂但随着记录量增大你会发现其实就几个固定的拼接模板最终得到完整的明文。这一步其实也验证了VMP还原的一个大原则动态优先。静态分析只能帮你建立直觉最终确认还是要靠动态执行。6. 工具链补充从手动到半自动化的实用技巧6.1 别急着写通用还原器先用现成工具有一个小技巧分享。面对VMP不用自己从头造轮子去构建反汇编器很多现成的工具可以帮助你完成一部分工作。比如[Hydra]这类通用的JS混淆还原工具虽然它主要面向控制流平坦化但你可以借它的AST解析能力来辅助整理代码结构。操作思路是先用AST parser把脚本解析成语法树定位到dispatch函数、handler函数、字节码数组这几个关键节点然后再用自己写的脚本做指令解码。从零开始写一套完整的VMP反汇编器确实工作量很大也没有必要。先把用脚本还原单个样本跑通再考虑抽象成工具这个顺序更合理。6.2 用执行日志反推指令语义的实操模板如果你决定自己写动态插桩日志模板我提供一个可以直接改来用的基础版本const originalArray opcodes; const proxy new Proxy(originalArray, { get(target, prop) { if (prop length) return target.length; const value target[prop]; if (typeof prop string !isNaN(prop)) { const idx Number(prop); if (idx prevIp) { console.log([TRACE] ip${idx}, value${value}); } } return value; } });把代码里的opcodes替换成proxy然后运行入口函数你会得到一份逐指令的trace日志。接下来的工作就是在这份日志里找规律。比如你会看到同一个数值被反复取出那它大概是一个循环计数器或者一串相同的字符常量被反复拼接那它大概是一个模板字符串。日志文件可能很大所以一定要在日志里附带IP索引方便查看时快速定位。后续还可以给特定IP位置加条件判断只打印关键节点的上下文减少噪音。6.3 关于过VMP检测的边界问题既然标题里提到了虚拟机底层过VMP检测原理我就多说一句。在Web JS逆向里所谓过检测核心思想是让你自己的运行环境去模拟浏览器环境让VMP在运行时觉得自己还在正常的浏览器页面里。实现方式无外乎两类第一类是在Node层补全浏览器对象也就是我之前说的mock环境。这种方式适合逻辑分析因为可控性最强。缺点是如果VMP检测了非常多的API甚至检查了对象描述符Object.getOwnPropertyDescriptormock就会立刻露馅需要逐步补充。第二类是用浏览器自身来执行最常见的是用Puppeteer或Playwright打开一个无头浏览器。无头浏览器自带完整的DOM和API环境真实性远高于Node mock。但VMP检测无头浏览器的手段也在不断升级比如检查WebGL信息、检查CDP连接标记、检查鼠标轨迹。这些检测没有通用的解法只能一个个去补充补丁。我个人建议如果是纯算法还原优先走Node mock路线因为可控性最高。只有在VMP算法本身依赖大量浏览器操作比如Canvas指纹计算时再考虑无头浏览器方案。两种方案都涉及边界对抗但那是另一个话题了这里点到为止。7. 常见问题与排查技巧实录7.1 为什么我的还原脚本总是跑不出正确结果这类问题在逆向中最常遇到。我列出几个高频原因环境检测未过。最简单的方式是在触发核心函数之前把VMP读取过的全局变量全部打印出来。看到一个字段的值和真实浏览器里不一致优先补上。我之前遇到一个case它读取了window.devicePixelRatio我的Node mock里没定义这个值导致走分支极深怎么都对不上补上之后一次就通了。时间戳精度不一致。老生常谈但依然值得强调。用固定时间戳做对比测试能在5分钟内排查掉这个问题。字节码数组被动态修改。如果第一次跑得到结果A第二次跑得到结果B但输入完全一致那八成是字节码在首次执行时被自修改了。解决思路是写出每次运行后的字节码快照做diff定位修改点。操作数栈的边界处理错误。VMP指令通常是基于栈的如果栈空时你却执行了出栈操作程序行为会整体错乱。看dispatch里有没有对栈深度的校验如果没有那你重写时也要模仿这种不校验的特性否则反而结果不同。7.2 定位不到关键handler怎么办有几种情况让你找不到handler一种是项目把handler函数展开了你看到一堆长得几乎一样的小函数另一种是所有handler都指向同一个函数函数内部再用一个二级分发表去选择具体操作。我的建议是优先从栈行为入手。不用管handler内部怎么实现只在dispatch入口处打印当前IP、当前opcode、以及栈顶3个元素的值。连续跑多次对照输出你会发现规律。找handler本质是找这个opcode对栈做了什么改变计算栈深度变化比通读源码高效得多。7.3 造轮子还是抄作业对你的建议很多新手喜欢一上来就搜索各种现成的VMP还原工具这个心态可以理解。但说实话目前公开的通用VMP还原器很少即便有适配性也有限。因为VMP是定制化最强的保护方案每个样本的指令集都不一样通用工具很难覆盖所有case。我更推荐的做法是把这个样本当成一次指令集考古把还原过程沉淀成自己的脚本库。多跑几个不同类型的VMP样本之后你会发现很多模式是相通的到时候你的脚本会越来越通用这才是核心竞争力。7.4 一次常见的排查现场记录最后分享一个真实的排查片段。某天我在验证还原结果时发现第一次重写脚本输出正确第二次就错误。我怀疑是环境问题但没有马上排查而是先定位到输出的diff点错误只发生在结果串的第8到第12个字符。我看了一下这几位的字符恰好是由一个byte转hex时生成的。然后我仔细检查了原VMP里生成这段的逻辑发现它对于字节值小于16的要做高位补0而我的重写版本漏掉了这个补0操作。修复之后连续跑了50次全部正确。这类问题靠猜是想不出来的只能靠不断做diff定位到具体字节层。如果你遇到时对时错的情况千万不要随便改算法结构先做字节级别的对比。这段随记写到这里基本把我这次VMP逆向的核心路径都记录完了。要说经验最重要的一条就是VMP逆向是个体力活但绝不是玄学。只要把执行轨迹和栈变化这两件事抓在手里再复杂的虚拟机也有规律可寻。我开头也说了如果你手头正有一个VMP样本让你头疼希望这篇随记能帮你少走几步弯路。后续我也打算把这次积累的插桩脚本和opcode映射工具整理得更通用一些等有实际产出再来续篇。