资讯动态

JSVMP反编译实战:定位登录签名生成入口与加密算法

发布时间:2026/10/18 7:52:40 来源:尧图企业网站定制
做前端安全评估的时候只要碰到大型电商平台的登录接口大概率会撞上 JSVMP 这种难啃的骨头。最近我针对某个用 JSVMP 做前端签名保护的平台下文统一简称 JD做了一轮反编译分析目标不是把整套字节码还原成可读源码而是定位登录签名参数的生成入口、梳理加密算法逻辑。这篇文章就是这次分析的过程记录既有思路拆解也有实操环节和踩坑实录希望对正在啃 VMP 壳的朋友有点帮助。先声明一句本文所有内容仅限于安全研究、接口协议学习和技术交流请勿用于未授权场景。下面开始正题。1. 先说清楚VMP 到底是什么为什么会这么难啃1.1 VMP 不是普通混淆很多人一听到JS 加固下意识想到的是变量名混淆、字符串加密、控制流平坦化这些东西。确实普通混淆可以通过 AST 还原工具恢复个大半因为它的本质只是改变代码的表达形式而代码仍然是一段可以被 JS 引擎直接解释执行的 JavaScript。VMPVirtual Machine Protection完全不同。它把一段 JavaScript 源码先做语义分析再翻译成一套自定义的字节码最后交给一个内置在代码里的解释器VM逐条执行。你在 DevTools 里看到的不是有点乱的 JS而是一堆数字数组、一串串指令码和一个庞大的分派循环。用一个生活类比来说普通混淆相当于把一篇中文文章里的词语换成同义词、把句子顺序打乱你还能靠着词义猜出大概意思而 VMP 是直接把文章翻译成一套只有自己看得懂的摩斯密码你如果想读懂原文就必须先搞到它的密码表再把每一段密码逐条译回去。VMP 的核心难点就在这个密码表上。它不是标准指令集每个加固方案甚至每个版本都可以重新设计一套 opcode。就算你逆向过十个不同站点的 VMP第十一个依然可能是全新的指令体系之前的经验只能帮你快速理解解释器结构却无法直接套用。1.2 为什么大型电商平台喜欢用 VMP以 JD 代称的平台web 端登录、订单、搜索等关键接口都有签名参数目的是防止请求被批量伪造或篡改。这类平台选择 VMP不只是为了看起来复杂而是因为它有几个很实际的优势第一抗通用工具。市面上绝大多数反混淆工具都是基于 AST 处理和正则规则设计的遇到 VMP 只能看到解释器根本找不到原始业务逻辑等于一拳打在棉花上。第二抗自动化分析。VMP 解释器本身是正常 JS但它执行的是私有字节码。你想还原算法就必须先理解解释器的每个 handler 在做什么这个成本远高于阅读普通混淆代码。第三动态更新能力。服务端可以随时更换字节码数组或调整解释器逻辑客户端只要按版本加载新代码就行。对分析者来说之前还原的算法可能一夜之间就失效了。但我建议先摆正心态VMP 反编译的目标通常不是把整套源码 100% 还原成可读状态而是找到加密链条的关键环节、涉及的核心算法、必要的参数格式。够用就行。你不需要理解 VM 的每条指令只需要知道签名是在哪一段被算出来的、用了什么运算、输入输出是什么。2. 动手之前目标梳理与信息收集2.1 明确这次反编译的边界接到一个 JSVMP 样本最忌讳的事情就是一头扎进解释器里从头开始读。VMP 解释器少则几千行多则上万行如果带着我要完全读懂它的想法进去很快就会被巨大的信息量淹没。我习惯先花半小时把目标拆成具体问题。以 JD 这个案例来说常见的反编译子目标有这么几类找到某个签名参数的生成入口定位加密函数采用的算法类型哈希、RSA、自定义异或等分析请求参数的拼装规则理解 VM 解释器的指令分派方式便于进一步动态调试你要做的第一件事是确定反向的边界。比如签名参数signValue这个参数到底在哪里生成它是在请求发起前由一段独立 JS 算出来的还是由 VM 解释器在执行某个业务函数时算出来的不同的答案决定了你后面用静态分析还是动态调试。我在做这轮分析时的边界定得很明确不关心 VM 的完整指令集不关心无关的业务代码只关心从原始参数到签名结果的输入输出链路。这个边界一画出来工作量瞬间减少一大半。2.2 工具链搭建与断言思路工欲善其事必先利其器。我一贯使用的工具链很简单但每一件都有明确用途Chrome DevTools断点、作用域、调用栈回溯主力调试工具抓包代理Fiddler 或 Charles确认 HTTP 层的参数形态Node.js 本地环境把目标 JS 下载到本地做可控的动态执行Babel 等 AST 工具对解释器外层的加载器代码做局部整理比如重命名、删除死分支你不需要一次性把所有工具都装上我建议按黑盒→灰盒→白盒三个阶段来推进。黑盒阶段先用抓包代理确认接口请求里有哪些加密参数、参数大概是什么格式。灰盒阶段在浏览器里下断点观察这些参数生成瞬间的调用栈。白盒阶段把 JS 下载到本地补齐浏览器环境自己运行、自己打日志。有一个非常实用的断言思路在动手之前先给自己写三句话。第一句我要找的入口函数可能叫什么名字第二句它的输入可能是哪几个变量第三句它的输出应该能被哪个接口参数接收到。后面所有操作都围绕这三句话验证。如果验证过程中发现入口不对就回到灰盒阶段重新定位而不是死磕代码。3. 定位核心逻辑三种主流的跟踪思路3.1 黑盒回溯法通过接口参数反查入口先从最简单的情况说起。有些 VMP 壳并不会把所有代码都包进去它可能只对核心加密函数做了虚拟化外层加载器、参数拼装代码仍然是相对可读的 JS。这种情况下你甚至不需要深入 VM直接靠字符串搜索和断点就能找到入口。操作流程是这样的第一步在 DevTools 的 Sources 面板里搜索签名参数的名字比如signValue或sign。如果搜不到可能是因为字符串被加密了那就进入第二步。第二步在XMLHttpRequest.prototype.send或fetch上下断点。几乎所有 web 请求都会经过这里。当请求触发时查看调用栈找到参数被赋值的那个调用帧。第三步顺着调用帧往回追。这时候你可能会看到这样的现象栈底是一些正常的 JS 函数栈顶却出现了类似vmRun(opcode, env)的函数。这说明参数是在 VM 内部生成的你需要进入 3.3 的思路。黑盒回溯法的优势是快缺点是遇到多层闭包和异步调用时调用栈会被打断经常追到一半就断了。这时候可以结合 Performance 面板录制一段操作查看函数调用时间线有时能从中找出加密参数的上下文节点。3.2 Hook 原生 API 的动态法不管 VMP 怎么包装它最终运行还是在一个 JS 引擎里必然要调用浏览器提供的能力。加密算法最常用到的原生 API 集中在几个方向时间相关Date.now、performance.now随机数Math.random字符串编码String.fromCharCode、btoa、TextEncoder加解密crypto.subtle.encrypt、crypto.subtle.digest网络相关fetch、XMLHttpRequest我的做法是在这些 API 上打点Hook记录每次调用的参数、返回值和调用栈。加密逻辑不可能凭空产生时间戳和随机数只要它用了这些 API我就能在日志里看到它出现的上下文。这里给一段示意代码注意不要直接复制到生产环境思路比代码更重要const originalDateNow Date.now; Date.now function () { console.trace([hook] Date.now called); return originalDateNow.apply(this, arguments); }; const originalRandom Math.random; Math.random function () { console.trace([hook] Math.random called); return originalRandom.apply(this, arguments); };在 JD 这个案例里我通过在String.fromCharCode上打点成功找到了一个可疑的字符串拼接 handler。因为在字节码执行过程中所有字符串的字符还原最终都会经过这个 API它会频繁被调用而调用栈会明确指出是 VM 的哪个 dispatch 位置触发了它。需要注意的是打点要适度。如果你同时 Hook 了时间、随机数、编码等十几个 API控制台会被刷爆。我的经验是先只 Hook 一个最核心的 API比如随机数或者时间戳跑一次看看日志量再决定要不要加其它 Hook。3.3 从 VM 解释器入手静态动态结合如果前面的方法都没能定位到核心逻辑那就只能正面硬刚解释器了。VMP 解释器的结构其实很有规律一般由三个部分组成取指从字节码数组中读取当前指令的 opcode 和操作数分派根据 opcode 查分发表跳转到对应的 handler执行handler 完成具体的栈操作、运算或跳转再回到取指在分派处下断点通常能看到类似switch (opcode)或者分发表数组handlers[opcode]的结构。我的建议是不要一开始就逐条追指令先做频率统计。具体操作是在分派处记录每一轮执行到的 opcode运行一次完整的加密流程然后统计哪些 opcode 出现次数最多。出现频率高的指令往往就是 VM 中最基础、最核心的操作比如栈压入、栈弹出、数值加减、异或、位移。把这几条指令的 handler 读明白再看核心加密算法就不那么吃力了。还有一个实用技巧很多 VMP 生成器为了保证加密强度会把数组下标混淆得很严重导致你很难一眼看出哪个 handler 对应哪种操作。这时候可以在每个 handler 的入口加一行日志输出区分用的标识再结合日志里操作数的变化来反推语义。比如你看到某个 handler 拿到一个操作数 A又拿到一个操作数 B最后把结果压回栈而且 A、B 都是字符串那它很可能就是字符串拼接操作。多记录几组类似观察结果整个指令集的能力模型就慢慢清晰了。4. 反编译实操记录还原一个签名参数的生成过程4.1 搭建本地动态调试环境当目标 JS 被加载到本地 Node.js 环境时第一个拦路虎必然是环境缺失。浏览器里满屏都是全局对象Node 里一个都没有。VMP 代码可能会引用window、document、navigator、location、localStorage、sessionStorage等任何一个未定义都可能直接报错。我的做法是分几步补齐第一步创建一个全局沙箱对象把所有常见浏览器全局变量都指到它上面。你可以手动列出window、document、navigator、location等也可以用一个 Proxy 统一拦截。第二步重点补齐可能被环境指纹检测的字段。很多 VMP 在启动时会检查运行环境是否真实最常见的是navigator.userAgent、navigator.webdriver、window.chrome等。如果环境和真实浏览器相差太多它会走一条死路导致你什么都分析不出来。第三步把本地运行的输出和浏览器里的输出做对比。先在浏览器里跑一次目标函数拿到标准的输出值再到本地跑同样输入观察结果是否一致。如果不一致优先检查环境差异而不是怀疑自己的调试逻辑。这里我强调一个高频坑window下的很多属性是有 getter 的比如window.outerWidth、window.screenX如果你只是简单赋值一个普通对象某些检测代码读取属性时拿到的值可能不对进而影响后续逻辑。遇到这种情况用Object.defineProperty精确模拟属性的 getter 行为是最稳妥的。4.2 输出锚点与差分对比法定位算法我比较推崇一种叫锚点法的思路它不要求你立刻读懂 VM而是先建立输入输出的锚点再进行差分分析。具体来讲分三步第一步确定输入。比如 JD 登录接口的签名函数它的输入大概率是请求方法、路径、时间戳、随机数和一段业务参数拼接成的字符串。通过 Hook 参数拼装点可以把这个原始串完整记录下来。第二步确定输出。输出就是最终请求里携带的签名值这个从抓包结果里可以直接拿到。第三步做差分实验。修改原始串里的一个字符然后运行观察输出变化。这一步是判断算法类型的利器如果把原始串中任一字符改了输出长字符串整体都变了看起来毫无规律这大概率是哈希类算法比如 SHA-256、MD5或者基于这些算法的自定义变体。如果修改后输出只是局部变化或者变化后长度不同那可能是分组加密、非对称加密或者自定义的拼接方案。如果两次运行结果本身就不一样即使输入完全一样说明里面掺了随机数或时间戳你需要进一步锁定随机因子注入的位置。在 JD 案例里我通过修改原始串里的一个字段发现输出整体剧烈变化初步判断是哈希类算法。进一步对签名长度和字符集做分析发现输出是 32 位十六进制字符串这让我把算法范围缩小到了 MD5 或 SHA-256 这类摘要算法的变体。但到这里还不够我还要弄清楚它的算法过程是不是标准的还是经过某种 key 加盐、多次迭代的自定义版本。这时就得回到 VM 内部去看具体执行了哪些运算。4.3 指令 handler 关键操作识别确定算法大概是哈希类之后我回到 VM 解释器里开始重点追踪与异或、位移、字符串拼接相关的 handler。哈希算法虽然复杂但它的底层操作无外乎那么几类位运算、循环左移/右移、按模相加、字节替换。这些操作在 VMP 里都会表现为固定的 opcode 模式。我在分派处加了一段统计代码只保留与栈顶数据变化相关的日志。日志格式大概是[op0x2a] push str: methodPOST [op0x11] xor int: 98, 0x3c - 0x56 [op0x2a] push str: nonce过滤掉非核心操作后日志量从几十万行下降到了几千行可读性大大提升。然后我通过跟踪栈顶数据的变化逐步还原出一个简化流程原始串先被转成字节数组再做多轮异或和位移运算最终以十六进制输出。这里有个很重要的经验不要试图一步还原到某算法标准实现的级别只需要还原到输入经过哪些操作变成输出的级别就够了。你只要能复现出完全一致的签名值就算反编译成功。如果还需要进一步确认具体算法可以尝试用 Node.js 的 crypto 模块做对照实验。比如猜测是标准 MD5就跑一次标准 MD5对比结果。如果对不上就再猜 MD5 加盐、HMAC-MD5 等变体。这样一轮轮猜下去通常很快就能锁定最终算法。5. 常见问题与排查技巧实录5.1 断点总是被检测到一闪而过这是 VMP 分析里最常见的问题。VMP 会内置反调试逻辑常见的手段有周期性计时器检测、debugger关键字、异常捕获后格式化检测等。你明明在关键位置下了断点调试器却很快被弹出去甚至整个页面崩溃。我的处理方法是组合拳第一步用 Local Overrides本地替换把加载的 JS 文件内容改掉把明显的debugger语句替换成空语句或条件断点。条件断点表达式直接写false断点会进入但立即跳过不触发反调试。第二步对计时器检测类的反调试可以选择禁用某些计时器 API或者把计时器间隔调大。但注意这可能会影响业务正常运行最好先观察反调试检测的频率再做针对性修改。第三步如果反调试逻辑本身也在 VM 内部无法直接改写那就用跑批的方式把目标代码丢到本地 Node 环境里去掉所有计时器和 UI 相关逻辑再用批量日志替代断点。断点会触发检测但日志不会因为日志不暂停线程检测逻辑没有机会确认你正在调试。5.2 补环境时的坑补环境是一个看似简单实则细节极多的工作。常见报错是xxx is not defined你补一个运行又报xxx.yyy is not defined没完没了。我的建议是不要手工一个个补而是先用 Proxy 做一层兜底const env new Proxy({}, { get(target, prop) { if (!(prop in target)) { console.warn([env] access undefined global: ${prop}); return undefined; } return target[prop]; } });这样即便遇到没补到的属性代码不会立刻崩溃你还能在日志里看到它访问了什么。然后针对报错日志按频率从高到低补齐。但要注意Proxy 兜底只能解决有没有的问题解决不了对不对的问题。比如navigator.userAgent如果只是一个普通字符串某些 VMP 检测代码可能会对它做更复杂的格式校验。这种时候必须用defineProperty精确实现 getter。5.3 加密参数太长分散在多个函数有时候你跟踪一个签名值发现它是在好几个地方被逐步拼装出来的一会儿在 VM 里拼了一段一会儿又跳回外层 JS 拼了一段。这种情况最让人头大因为它意味着你不能只盯着一个函数看。我的做法是给每次字符串拼接加指纹记录也就是把拼接位置的调用栈、拼接前的结果、拼接后的结果全部输出到日志文件。之后再从日志里按时间顺序把中间值串起来构造出一条完整的拼接链。还有一个更轻松的替代方案如果你只需要最终结果不需要中间过程可以直接在浏览器里 Hook 掉最终参数的赋值点拿到一次完整结果然后通过黑盒模拟的方式复现。也就是说只要能构造相同的输入并得到相同的输出不管中间代码具体怎么写的你的目标已经达成了。5.4 字符集与编码问题JavaSript 引擎内部对字符串的编码方式和 HTTP 层传输时的编码方式不完全一致这在做本地模拟的时候非常坑。最常见的情况是浏览器里跑出来的签名值是一串大写十六进制本地用 Node 跑出来却是小写或者完全对不上。这种问题通常出在两个地方。一是原始串里包含非 ASCII 字符比如中文参数值从浏览器进入 VM 时可能已经经历了 encodeURIComponent 之类的转换二是字符串转字节数组时的编码不一致浏览器可能按 UTF-8 转而本地默认用了别的编码。我的经验是在本地环境里统一用TextEncoder做 UTF-8 编码然后用Buffer或 Uint8Array 做字节级操作。如果发现还是对不上就回到浏览器里 Hook 字符串转字节数组的那个位置把转换前的字符串和转换后的字节数组都打印出来和本地的做一次精确比对。多试几次差异点就出来了。我做了这么多次 JSVMP 逆向之后最大的感受是思路比工具重要记录比蛮干重要。工具再强思路不清晰也只会越调越乱。我建议你准备一个专门的调试清单每分析一个新样本就把日志格式、关键断点位置、已还原的 handler 列表、遇到的坑全部记下来形成一个自己的知识库。下次再遇到新的 VM 壳这个清单能帮你省掉 70% 的重复劳动。最后再分享一个小技巧不要只盯着 VMP 的主逻辑多看看加载器和解包部分。很多 VMP 生成器会在字节码数组附近留下一些常量字符串、版本号或者错误提示信息这些信息往往能帮你快速判断它使用的是哪一类的虚拟化方案从而推断出字节码边界的组织方式。抓住这些外围细节往往比硬读解释器高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑