
“JSVMP反编译之JD”入这个坑的朋友应该都明白这几个字母的分量。标题里的JD不是Java Decompiler而是业内对某大型电商平台前端防护项目的一个通用代号。这家平台的登录、搜索、下单等核心链路上跑着一套相当完整的JavaScript虚拟机保护方案也就是常说的JSVMPJavaScript Virtual Machine Protection。接口请求里那些加密签名参数、请求头里的动态令牌背后基本都是这套VM在处理。这篇文章不是我给某家站点做“脱壳实录”而是把JSVMP这类虚拟机保护当作一个技术研究对象讲讲它的原理、我从哪里切入分析、实操时用了哪些思路、遇到哪些坑。文章里所有目标对象都会做打码和抽象处理实例也是在本地受控环境、自有测试账号基础上完成的静态分析。适合正在做Web安全研究、风控对抗、爬虫反爬的研发同学也适合对JS混淆逆向感兴趣、想把手艺练扎实的初学者。1. 先搞懂JSVMP到底做了什么1.1 它和普通JS混淆不是一回事很长一段时间里前端代码保护靠的是三类手段变量名混淆、字符串加密、控制流平坦化。这类手段老老实实说给分析者添的麻烦有限——变量名_0x3f2a1c看多了拿AST工具还原一下命名、把字符串数组解出来逻辑基本能读。JSVMP不是一个量级的。它的思路是干脆不让你直接看到“源码”而是把你的业务逻辑编译成一组自定义字节码运行时交给一个内置的解释器逐条执行。这个解释器本身就是一段正常的JS代码里面有指令分派循环、有操作数栈、有执行上下文跟我们要分析的目标业务逻辑已经没有直接的对应关系。打个比方普通的混淆像把菜谱里的字眼换成了别名你拿着字典还是能读懂。JSVMP是把菜谱直接改成了一套密码本厨师VM解释器看着密码本做菜你光拿到密码本没用还得先把厨师的“手艺规则”搞明白才能推断出每道菜的做法。一个标准的JSVMP保护体系至少包含这几个部分字节码数组业务逻辑被翻译成的一串opcode序列通常被编码或者加密存放执行上下文虚拟机运行时保存中间变量、临时结果的寄存器堆或栈结构指令分派循环解释器核心通常是 while switch / if-else 结构逐条读取opcode并分发Handler函数集合每个opcode对应一个处理函数负责具体的运算、赋值、跳转、函数调用等操作字节码和解释器是“绑定关系”不同的VM方案指令集设计差异很大甚至同一套方案在不同版本下opcode含义都会变化。这也是为什么圈内常说“逆向JSVMP没有通用一键脱壳方案”每个目标都得从零开始过一遍。1.2 JD场景下的JSVMP长什么样在JD这类大型平台场景里JSVMP不是单独一个文件而是拆在若干环节里一个体积很大的入口脚本通常以立即执行函数IIFE包裹内部变量名全部被压缩成极短的英文/数字组合脚本里能看到 Uint8Array、Uint32Array 这类TypedArray说明字节码不是以直观字符串存在而是数值数组入口脚本运行时会动态加载一段与当前请求绑定的字节码数据。也就是说你请求一次页面拿到的字节码和下一次拿到的不一定相同——动态化让“一次逆向吃遍天下”的想法直接失效字符串、数字常量不会明文出现而是密文存储需要执行到特定handler位置才会被解密还原并立即参与运算很多人第一次看到这类代码第一反应是——这么大一串怎么下手其实克制住“我要把整个VM还原成源码”的想法任务反而清晰你不需要翻译全部指令只需要找到业务逻辑里“输入到输出”的对应关系定位到具体一个或几个handler搞清楚关键算法就够了。JD这类场景目标绝大多数时候是签名算法和加密参数生成逻辑不是整个页面。2. 反编译切入点怎么从逆向视角拆解VM2.1 先定位再动手我对第一次碰JSVMP的朋友有一个建议别急着一上来就格式化文件、搜关键字。正确的顺序一定是从业务出发从外往里推进。具体可以这样来打开浏览器DevTools的Network面板刷新页面找到带加密参数的请求接口把关键参数列出来例如签名sign、令牌token、时间戳ts这些参数就是逆向分析的“结果集”在发起请求前给调用栈里的JS函数下事件断点或者直接在发请求的入口处断住顺着调用栈往回追追到某个函数内部发现它在执行一段无法阅读的代码——典型特征就是大循环数值数组神秘函数调用那基本就是VM解释器或外围封装我用过的最有效方式是在发起请求的关键函数上下断点后查看“调用者”。JS的Call Stack会把从事件处理、业务函数、再到加密模块、再到VM执行器的调用链全部暴露出来。你不懂VM没关系先知道它在哪儿、被谁调用了就已经完成了一半。定位时要关注这些代码特征一个函数体内有大量连续的数字字面量例如数组 [3, 7, 1, 4, 9, ...]元素值范围接近256以内很可能是字节码函数里出现 while (true) 或 for(;;)配合大段switch-caseswitch里的 case 分支数量非常多几十到上百个都是正常有大量的位运算、数组下标操作一眼看着“不像正常人写的业务代码”有人习惯用文件搜索专搜“opcode、dispatch、executor、vm”这些关键词来钓VM入口。我不太推荐一开始就这么做因为混淆过的变量名早就不是这些词了。靠特征搜索适合已经有了分析基础、确认目标一定存在的阶段不适合从零开始。2.2 解释器入口与Handler分发表定位到疑似解释器后下一步是把“指令分派循环”和“handler集合”区分开。这两者经常混在一起但职责完全不同分派循环是骨架作用是读opcode、跳转到对应处理分支。它本身不实现业务运算Handler是具体干活的人每类opcode对应一种操作比如栈顶两个数相加、把常量加载到寄存器、调用某个内置函数、根据条件跳转区分方法很简单在可疑函数第一个可执行行下断点然后触发一次业务调用观察它是否在从未知入口进来后反复命中同一个函数内部。如果命中次数极多、且每次会有不同的case分支命中那这就是分派循环了。我习惯在分派循环里做一次“标记”给case分支数量、外部入参、循环退出条件打印一次日志。第一次跑日志会比较乱但通过对比不同业务输入下的命中分布能更快找出跟目标参数强相关的case集合。实际操作时还要注意解释器外通常还套着一层“参数预处理”代码负责解码加密的字节码常量表、初始化执行上下文、校验运行环境。这一层是JS代码不是VM内部逻辑读到这块时别懵它只是外围包装。3. 实操核心插桩Hook还原字节码执行逻辑3.1 动态插桩的基本原理搞JSVMP最忌讳的就是纯静态硬看。字节码指令流本身就是给机器执行的人眼盯几十上百行数值数组盯不出业务逻辑。正确的姿势是“插桩”——在VM解释器里加输出把每条指令的执行情况打出来生成一份可读的执行轨迹。有人管这叫Hook有人叫插桩本质上都一样在合适的位置插入日志代码。而对JSVM场景合适的插入点通常就是分派循环的入口处——也就是每次要读取下一个opcode、准备进入case分支之前。一个通用层面的演示思路是这样我简化了逻辑重点展示思路不是直接套用某个真实目标// 原始解释器大概是这种结构 function runBytecode(code, ctx) { let pc 0; while (pc code.length) { const opcode code[pc]; switch (opcode) { case 0x01: // 执行加法 ctx.stack.push(ctx.stack.pop() ctx.stack.pop()); break; case 0x02: // 加载变量 ctx.stack.push(ctx.params[code[pc]]); break; // ... 大量case } } }改成带日志的版本最简单的做法是function runBytecode(code, ctx) { let pc 0; while (pc code.length) { const opcode code[pc]; // 插桩点记录当前指令位置、操作码、以及数栈快照 console.log([PC${pc - 1}] opcode${opcode}, JSON.stringify(ctx.stack)); switch (opcode) { // 分支保持原样 } } }看着简单真实操起来有几个细节要注意插桩只加日志不要改动原执行逻辑。一旦你手滑改了栈顺序、跳转条件执行结果就变了日志也没意义输出量非常大一个签名算法跑下来可能几万条日志。不要直接死盯控制台把日志收集到数组里最后一次性导出到本地文件分析如果栈里存的是对象直接JSON.stringify可能循环引用要写个安全的序列化函数或者只记录栈长度和值类型很多分析者还用过“二分插桩法”先所有handler都打日志跑通了再逐步关掉无关handler的日志只保留跟业务强相关的部分。这样能减少干扰也更利于观察核心逻辑。3.2 从字节码轨迹到业务逻辑还原日志拿到了下一步是最花心思的环节——从一串路由记录里还原出业务逻辑。我的习惯是这样做把同一函数用不同参数调用多次记录多份轨迹对比不同输入下轨迹里哪个位置的值发生变化。变化的点就是“参数进入VM的入口”对应的handler负责读取外部参数找到轨迹末尾、返回值产生的位置回溯这个值是怎么一步步算出来的把那一段轨迹里的handler逐个搞懂翻译成JavaScript伪代码举个简化例子假设一个签名函数输入是字符串”abc”输出是密文。轨迹日志里你看到这样的模式指令0x05把输入字符串加载到栈指令0x1a从常量表加载一段密文字节指令0x23执行按字节异或指令0x0e把结果写入输出这样就初步判断所谓的签名算法很可能就是输入字符串和一个固定常量表做XOR最后转成十六进制。之后再验证几次输入输出如果结论一致这个算法就算拿下了。这里有一个很关键的经验不用完全理解每个handler。一个VM可能有上百个handler但一个具体签名算法用的可能只有十几个。你只需要把这十几个的语义吃透其他保持未知一点关系没有。这跟“反编译”字面感觉的“全量还原”差别很大实际做逆向的人都是“按需还原”。还有个经典技巧叫“差异对比法”。比如同一个函数在参数只差一个字符时执行轨迹大部分相同只有局部变化。从变化区域附近的指令能非常精准地定位参数如何参与运算远比自己从头到尾通读字节码高效。4. 解密参数与环境检测JD反编译里的硬骨头4.1 字符串与数字常量加密JD这类场景的JSVMP一个很让人头疼的设计是常量加密。字节码数组自己能拿到但代码里引用的字符串、数字、甚至是handler跳转表很多都是密文存储。直接在字节码文件里搜字符串是搜不到的。解密的方式通常有几种简单XOR加密密文和固定key逐字节异或变种RC4伪随机密钥流与密文异或自定义的查表编码把字节映射到另一套字符表有点像Base64但自定义了表调用了内置函数例如把字符数组拆散到多个变量运行时再拼接定位解密函数的方法本质上还是靠插桩。在VM环境中任何明文字符串被使用的瞬间它一定经过了“解密——使用”的过程。所以在handler插桩时特别关注以下两类操作把字节数据转换成字符串的指令类似CharCode、join操作加载一个字符串后立刻参与运算的指令在插桩日志里搜“看起来像人话”的字符串往回倒一点就能看到解密操作的位置。经验是一个大型VM的字符串解密通常集中在几个固定handler里找到一次之后每次解密都会从同一条路径走等于开了一个稳定后门。我提个小建议插桩日志不要直接打明文因为量太大容易把浏览器跑卡。可以把日志先写到内存数组等整套流程跑完再统一从控制台导出。这样既能保留完整轨迹又不影响页面正常运行。4.2 环境检测与反调试大型平台的VM里环境检测几乎是标配。核心是防止逆向者在自己搭的Node.js或调试环境里跑字节码。常见手段有检测navigator关键属性比如webdriver标记、userAgent、platform、pluginsCanvas指纹通过渲染特定图形再读取像素检测是否存在无头浏览器特征时间戳校验记录VM初始化时间二次调用时对比时间差判断是否被“你等我算完再放行”式拖慢无限debugger在关键代码路径上加入debugger语句开发者工具一打开就反复断住栈回溯混淆让错误信息的调用栈变成一堆无意义函数名应对思路要分清两个假期目标。如果你只是想分析算法最优解不是跟反调试死磕到底而是创造“干净的受控环境”用真实浏览器的同时对已知反调试函数做定向替换——比如把debugger语句替换成空函数把环境检测的通路全部走正常值。关键是找到VM启动前的外围初始化代码那里的环境检测是最密集的也是最值得Hook的。我自己踩过的坑是刚开始插桩时为了输出日志改了一个handler里的变量赋值结果后续运算全错。排查了很久才意识到不是环境检测干扰而是我的插桩无意中破坏了执行上下文。从那以后我定了个规矩——插桩代码一律不影响原变量、不改变控制流、不修改栈内容。日志用全局数组收集插桩点只做push操作。5. 常见问题与排查技巧把这个阶段常遇到的问题整理成表方便对号入座现象可能原因排查思路找不到VM入口入口脚本体积大、文件名随机、变量名全混淆从带加密参数的请求接口逆推调用栈在业务事件断点处查看调用链插桩后页面报错/白屏插桩改变了执行上下文、栈顺序或跳转条件只加日志不改逻辑确认日志函数无副作用用数组收集代替console.log解析出的指令流乱序字节码数据本身有跳转指令不能从头线性解读重点分析跳转类handler以实际执行顺序为准而不是数组顺序字节码每次请求都不同平台做了动态化每次下发不同VM固定请求参数、固定环境、固定会话缩短分析窗口不要期望一次彻底通吃遇到无限debugger平台开启了反调试在进入VM前替换debugger调用比如改写Function构造器日志量过大浏览器卡死插桩范围太宽输出频繁只保留关键handler的日志用节流思路抽样记录栈长度而不是每次全量记录分析出的算法跑不通可能漏掉环境校验或时间窗校验检查VM初始化时的环境参数、当前时间戳是否参与运算这些坑里最容易被忽视的是“字节码是跳转的”。很多新手以为字节码数组从头读到尾就是业务逻辑但JSVMP保留了完整的分支、循环、函数调用语义执行顺序完全受跳转指令控制。单纯按顺序打印出来的轨迹中间会穿插大量无用跳转属于正常现象。看到这种不要慌重点看它跳去了哪个handler、跳了多少次。另外一个比较迟来的经验就是一定要固定输入输出做对比测试。我一般是准备三个不同输入每次插桩分析都跑一遍。如果三组轨迹的关键运算区间呈现可复现的模式那结论基本可信如果三次结果形态变化很大说明字节码本身就是动态变化的得先想办法锁住一个版本。6. 反编译之后安全线与防御视角做JSVMP分析做到一个阶段后很容易产生一个错觉——“这保护没啥用反正都能分析出来”。说句实在话从纯技术研究角度JSVMP没有不可破解的只要运行环境是开放的动态分析总有路可走。但这不代表这类保护没有价值。它的价值体现在“成本”和“时效”两个维度。对攻击者来说花几天时间搞清楚一套VM的具体指令语义、还原某个签名算法是可行的。但如果平台方保持动态更新字节码、定期轮换VM结构攻击者每次都需要重新投入成本。这就是典型的攻防成本博弈。防御方不指望前端永不可破但希望让你“破一次的成本大于收益”。从防御角度我从这些分析经验里总结了几条建议服务端校验永远不能缺。前端加密参数再复杂到最后都得过服务端校验服务端一定要有独立的签名验签逻辑而不是单纯信任前端传来的参数JSVMP只保护关键链路。核心的签名算法、令牌生成、风控参数值得上VM但别把整个网站逻辑全塞进去否则体积爆炸、维护成本拖垮开发效果也不一定好动态化迭代比一次最强更重要。定期换字节码生成策略、更新常量加密key、调整handler结构比试图一次性做到“绝对不可逆”要实在风控体系要做在VM之外。结合设备指纹、行为序列、IP信誉、频率检测比单纯指望JS保护更可靠安全合规是绕不开的底线。研究JSVMP、做漏洞分析、评估防护效果这些行为都应该建立在合法授权的基础上。自己的项目、自己负责的系统、有授权的测试目标都是合理的对象。未经授权分析线上业务系统、绕过签名接口批量拿数据这类行为既违背安全研究伦理也可能触碰法律红线超出技术讨论的范畴了。当你能把一套VM从头到尾捋顺、还能指出它的薄弱环节时你的收获其实比“破解某个算法”更值钱——你成了这套保护体系真正的理解者。往后的工作里你可以做出更贴合业务场景的防护设计也能在对抗中快速评估对方成本在哪、弱点在哪。我在实际操作中的体会是练手阶段自己写一个小型VM和配套的混淆器比直接去破解大型平台效率高得多。写VM的过程就是理解VM的过程把指令分派、常量加密、环境检测都亲手实现一遍再回过头去分析JSVMP很多以前觉得云里雾里的东西突然就通了。这条路径我推荐给每个想入这个方向的人基础打得扎实盛宴才吃得下。