JS逆向进阶:控制流平坦化与VM保护的破解思路 📅 2026/7/27 23:07:03 做JS逆向的人过了基础变量混淆、字符串加密这道坎之后很快会遇到两座绕不开的大山控制流平坦化与虚拟机VM保护。前者把线性逻辑拆成七零八落的代码块靠状态机跳来跳去后者直接把原始逻辑藏进自定义指令集整段代码只剩一个看不懂的解释器在跑。常规的格式化、变量重命名、字符串解密到这里完全失效很多人要么靠硬啃调试耗上一周要么直接放弃。事实上这两种保护并非无懈可击。它们都有明确的设计原理和固定的破解路径区别只在于投入的时间成本和还原深度。本文从保护原理入手系统梳理两种主流保护的静态还原、动态追踪、指令逆向等核心破解思路覆盖从入门到进阶的完整方法论。一、控制流平坦化打散逻辑的“迷宫式”保护控制流平坦化Control Flow Flattening是目前工业界应用最广的代码保护手段之一。它不改变代码的执行结果只重构代码的执行结构把原本清晰的顺序、分支、循环逻辑全部揉进一个由分发器控制的状态机里让人眼无法直接梳理出执行路径。1.1 保护原理用分发器抹平所有控制流平坦化的核心思路很简单把所有代码片段拆成独立的基本块塞进一个大的switch-case结构再用一个状态变量控制执行顺序。所有的if-else分支、for/while循环都被转化为对状态变量的赋值下一个执行哪个代码块完全由状态值决定。平坦化后控制流状态初始化循环分发器switch 状态匹配基本块1基本块2基本块3基本块4更新状态值原始代码控制流是否代码块1条件判断代码块2代码块3代码块4举一个最简化的例子直观感受平坦化前后的差异// 原始代码逻辑清晰分支明确functioncalc(a,b){letsumab;if(sum10){returnsum*2;}else{returnsum-1;}}// 平坦化后逻辑被打散靠state变量跳转functioncalc(a,b){letstate1;letsum;while(true){switch(state){case1:sumab;state2;break;case2:statesum10?3:4;break;case3:returnsum*2;case4:returnsum-1;}}}真实场景的平坦化远比这个复杂状态值通常不是常量而是经过运算的表达式会插入大量不透明谓词和死代码干扰分析状态变量本身也会被多次混淆重命名。但核心结构永远不变循环包裹的 switch 分发器 状态变量 多个基本块。1.2 静态还原基于AST的控制流重建静态还原是最主流、最彻底的平坦化破解方案核心思路是从抽象语法树AST层面拆解状态机重新构建出原始控制流图CFG再还原成原生 JS 语法。整个过程可以基于 Babel、Acorn 等 AST 工具自动化实现。混淆源码语法解析生成AST前置优化: 常量传播/死代码删除定位分发器与状态变量提取所有基本块与状态转移关系构建控制流图CFG结构还原: 分支/循环/顺序生成还原后代码完整还原分四步走第一步前置化简扫清外围混淆不要上来就直接处理平坦化。先做一轮基础优化字符串解密、常量传播、表达式简化、死代码删除。很多平坦化的状态值是计算出来的常量传播后会变成清晰的数字后续分析难度会下降一个量级。第二步定位分发器与状态变量平坦化代码有非常鲜明的特征WhileStatement包裹SwitchStatementswitch 的判别式就是状态变量。遍历 AST 找到符合特征的节点再追踪状态变量的赋值位置就能锁定所有基本块。第三步提取基本块构建控制流图把每个case分支作为一个基本块记录三个关键信息块ID、块内语句、出口状态值。如果块内有条件赋值就是分支节点如果状态值指回前面的块就是循环结构。把所有块按状态转移关系连起来就能得到完整的控制流图。第四步结构还原重写原生语法这是最考验功底的一步。根据控制流图的结构把对应的节点还原成if-else、while、for等原生语句替换掉整个 switch 分发结构。单入口单出口的块合并成顺序代码双向分支还原成条件语句有回边的结构还原成循环。核心实现片段基于 BabelconstflatteningVisitor{WhileStatement(path){constbodypath.node.body.body;// 识别 while switch 结构if(body.length!1||body[0].type!SwitchStatement)return;constswitchNodebody[0];conststateVarswitchNode.discriminant.name;constcasesswitchNode.cases;// 提取所有基本块与状态转移constblocks{};cases.forEach(caseNode{conststateValcaseNode.test.value;const{statements,nextState}parseCaseBlock(caseNode,stateVar);blocks[stateVal]{statements,nextState};});// 构建CFG并还原结构替换原节点constrestoredrestoreControlFlow(blocks,entryState);path.replaceWithMultiple(restored);}};常见坑点与应对不透明谓词干扰插入永远为真/假的条件分支制造虚假跳转。应对常量折叠符号执行计算出谓词的固定值删掉不可达分支。状态值动态计算状态不是直接赋值而是经过加减异或运算。应对前置常量传播把运算表达式化简为常量。多层平坦化嵌套一个 case 里面又是一个平坦化结构。应对从内到外逐层还原每还原一层就做一轮化简。1.3 动态还原执行轨迹追踪法当静态分析成本太高、或者混淆加入了大量动态计算时可以换个思路不管代码结构多乱它运行时的执行路径一定是确定的。通过插桩记录执行轨迹直接按顺序把代码块拼起来。具体做法很简单遍历所有 case 分支在每个基本块的开头插入一行日志输出当前的状态 ID。然后运行目标代码传入测试用例就能得到一条完整的状态执行序列。最后按照序列把对应基本块的代码拼接起来就是这条路径的还原结果。这种方法的优势是简单粗暴几乎不受混淆强度影响缺点是只能还原执行到的单一路径无法覆盖全部分支。适合只需要还原某一条加密逻辑、不需要全量还原代码的场景效率比静态分析高很多。二、VM保护藏进自定义指令集的“黑盒”保护如果说控制流平坦化是把代码打乱那 VM 保护就是把代码彻底“加密”。它不依赖原生 JS 的语法结构而是自定义一套指令集和解释器把原始逻辑编译成字节码运行时由解释器逐条取指执行。你在源码里看不到任何原始逻辑只能看到一大段看不懂的解释器和一串数字数组。这是目前强度最高的 JS 保护手段也是逆向进阶的标志性门槛。2.1 保护原理重构一套执行体系VM 保护的本质是在 JS 之上再实现一层微型虚拟机把原始代码编译成这台虚拟机能识别的字节码。原生 JS 引擎只负责运行虚拟机虚拟机内部的逻辑完全不可见。一套标准的 JS VM 由四大核心组件构成运行阶段字节码数组解释器主循环取指: 读取当前Opcode译码: 分发到对应处理函数执行: 操作栈/寄存器更新指令指针IP操作数栈虚拟寄存器编译阶段原始JS代码自定义编译器字节码序列VM解释器字节码Bytecode经过编译后的指令序列通常以数组形式存在每个元素对应一个操作码Opcode或操作数。指令指针IP记录当前执行到第几条指令类似原生代码的程序计数器。运行时栈/寄存器存储运算过程中的临时数据。绝大多数 JS VM 是栈式结构所有运算都通过压栈、弹栈完成少数高强度保护会采用寄存器式结构。解释器分发器主循环里的核心逻辑根据当前 Opcode 分发到对应的处理函数执行完后指令指针后移进入下一轮循环。2.2 破解分层按需选择还原深度VM 保护不是只有“完全还原源码”一条路。不同的业务需求对应不同的破解方案投入的时间成本天差地别。从易到难分为三层90% 的场景停在前两层就足够解决问题。第一层入口 Hook 法直接调用拿结果这是成本最低、最实用的方案。如果你只需要调用加密函数、拿到输入输出结果完全不需要逆向内部逻辑。思路很简单找到 VM 的入口函数把整段 VM 代码抠出来补全运行环境直接对外暴露调用接口。VM 再复杂本质也是一个函数给定输入就会返回输出只要能正常调用就行。适用场景接口参数加密、签名算法、前端校验逻辑只需要复现计算结果。注意点需要绕过环境检测比如 DOM 检测、Node 环境检测、DevTools 检测缺什么补什么让 VM 以为自己运行在真实浏览器里。第二层轨迹还原法逆向核心算法如果你需要理解算法逻辑比如分析加密的具体步骤、修改内部逻辑就需要做执行轨迹还原。核心思路给解释器的分发位置插桩记录每一条指令执行前后栈和寄存器的变化。通过完整的执行轨迹反推每一步做了什么运算、跳转了几次、调用了哪些原生函数最终还原出等价的伪代码。具体操作在解释器的 Opcode 分发处下断点或插入日志记录每次执行的 Opcode、指令指针、栈顶数据、寄存器值传入已知的测试用例跑完整个流程对照轨迹逐行分析把栈操作还原成加减乘除、字符串处理等具体运算这种方法不需要搞懂所有 Opcode 的含义只需要聚焦目标路径适合分析单个核心算法是实战中性价比最高的方案。第三层全指令逆向编写反编译器这是最彻底、难度最高的方案目标是把字节码完整还原成可读的 JS 代码相当于做一个反向编译器。完整流程分五步定位解释器梳理分发逻辑找到主循环和 Opcode 分发的位置确定 Opcode 与处理函数的映射关系。逐个逆向 Opcode 功能挨个分析每个处理函数的行为标注出对应的语义比如0x01是压栈常量、0x05是加法运算、0x0A是条件跳转。提取字节码解析指令序列把字节码数组按指令格式拆解成一条条带操作数的指令。控制流还原识别跳转指令重建分支、循环结构还原控制流图。代码生成把指令序列翻译成等价的 JS 代码做变量重命名和结构优化。到这一步才算真正吃透了 VM 保护。但对应的投入也非常大一套商用 VM 的完整逆向往往需要数天甚至数周的工作量只有高价值场景才值得投入。2.3 常见变种与识别要点栈式 VM最主流的类型所有运算都通过操作数栈完成指令短、结构规整逆向难度中等。绝大多数商用保护工具的 VM 都属于这一类。寄存器式 VM用虚拟寄存器存储数据指令格式更复杂分析难度更高常见于定制化的高强度保护。混合保护外层套控制流平坦化内层再上 VM甚至多层 VM 嵌套。这种需要从外到内逐层剥离先还原外层控制流再分析内层 VM。三、实战避坑与通用方法论3.1 先判断保护类型再选方案拿到混淆代码不要上来就硬啃先花5分钟判断保护等级选对应的方案能少走很多弯路只有变量乱码、字符串加密基础混淆先做字符串解密和变量重命名大段switch-case加循环、一个变量反复赋值控制流平坦化优先上 AST 静态还原有长数字数组、大循环处理数组元素、看不到业务逻辑VM 保护先看能不能直接 Hook 调用3.2 先清反调试再做分析高强度混淆几乎都会带反调试不先清掉会处处碰壁。三类最常见的反调试和对应绕过思路无限 debugger通过定时器、递归循环触发 debugger 断点。绕过hookFunction.prototype替换 debugger 语句或者直接禁用断点。时间差检测计算代码执行耗时断点调试会导致耗时骤增。绕过hookDate.now、performance.now返回匀速增长的假时间。环境检测检测是否在 Node 环境、检测是否打开 DevTools、检测浏览器特征。绕过补齐缺失的浏览器 APIhook 检测函数返回假值。3.3 还原的核心原则够用就好很多新手容易陷入“完美还原”的误区非要把整段代码还原成和源码一模一样。实际上绝大多数业务场景根本不需要全量还原只需要调用结果抠代码直接调用别碰逆向只需要单条路径动态追踪就够别做全量还原只需要核心算法聚焦目标函数其他部分不用管逆向的核心是解决问题不是炫技。用最低的成本达到目的就是最优方案。写在最后JS 逆向的进阶过程本质上是从“改代码”到“理解执行体系”的升级。控制流平坦化玩的是控制流重构的把戏VM 保护玩的是指令集重定义的套路但只要摸透了它们的运行原理就没有拆不开的黑盒。不用追求一步到位先搞定控制流平坦化再慢慢啃 VM循序渐进。工具是辅助核心是对 JS 执行机制的理解。保护手段一直在升级逆向的思路也一直在迭代但底层逻辑永远相通找准结构特征选对突破路径耐心加经验没有解不开的保护。