资讯详情

AST解混淆实战:还原JavaScript混淆代码的完整路径

📅 2026/10/20 5:12:46 | 华诺云谱 👁 阅读
AST解混淆实战:还原JavaScript混淆代码的完整路径
做前端开发的这些年我花了不少时间跟“加密混淆后的 JS”打交道。无论是排查线上问题、接手掌管被保护过的老项目还是做安全分析时拿到的采样样本一打开就是几百 KB 的压缩文件函数名清一色_0xa、_0xb字符串全部塞进巨型数组循环里嵌着大段 switch-case直接看输出根本没法下手。最开始我靠人肉读代码、靠断点插桩硬猜效率低得离谱真正让我找到可靠路径的是系统学习并实践了 AST 解混淆也就是用抽象语法树的方式去解析、识别、还原混淆逻辑。这篇文章就是想把这套思路完整讲清楚适合刚接触 JS 逆向、还没想明白 AST 到底怎么用的朋友。先说它能帮你解决什么问题面对一团乱麻的混淆脚本你不需要手工逐行翻译而是让程序帮你做一大半还原工作。你只需要理解混淆的模式写出对应的 AST 转换规则剩下的体力活全交给工具。这个领域适合前端工程师、安全分析员、爬虫方向的技术人员也适合任何想真正读懂“看不懂”的 JavaScript 的人。我会从最基础的思路讲起带环境搭建、完整代码示例和多个实操案例尽量让你看完就能自己上手跑一套还原流程。1. 先搞清楚敌人在哪JS混淆的常见套路1.1 混淆不是压缩别把这两件事混为一谈很多人刚接触混淆代码时会下意识觉得“代码不就这样吗压缩一下而已”。其实混淆和压缩是完全不同的两件事。压缩做的是移除空格、缩短变量名、删注释它不改变代码的结构形态混淆则是有意把代码的可读性破坏掉。一个典型例子里源码可能是function add(a, b) { return a b; } console.log(add(1, 2));经过压缩后是function add(a,b){return ab}console.log(add(1,2));但经过混淆后可能变成var _0xabc [\x63\x6f\x6e\x73\x6f\x6c\x65, \x6c\x6f\x67]; function _0xdef(a, b) { return a b; } window[_0xabc[0]](_0xdef(1, 2));压缩保留了原本的函数名add和清晰的调用逻辑混淆则把字符串、函数名、作用域关系全部打乱。理解这个区别很重要因为解混淆的目标不是“美化代码”而是“还原语义”。我们做的所有 AST 操作本质上都是把混淆后的形式转化回语义上等价但更容易读懂的形式。1.2 常见的五类混淆模式根据我实际接触过的混淆代码98% 的样本都逃不出下面这几类模式混淆类型典型表现还原难度标识符重命名变量名、函数名变成a,b,_0x123低交给调试器即可字符串处理字符串拆成多段拼接、转成十六进制、放进数组中需要找到解码逻辑常量表达式化数字运算、逻辑运算写在调用位置中算术折叠即可控制流扁平化顺序代码变成 while switch break 分发高需要还原数据流垃圾代码注入插入无意义的死代码、无法执行的 if 分支低识别后直接删1.3 为什么这些混淆总有办法被还原这里有个关键事实混淆是通过不改变程序行为的语法变换实现的也就是说混淆后的代码与原代码有相同的输出结果只是表达形式变了。它不是把程序改成另一个程序而是在 AST 层面做等价变形。既然变换过程等价那么理论上就可以通过逆向的等价变形还原语义。这也是 AST 解混淆能成立的根本原因。逆向做久了你会发现混淆厂商总是在“混淆强度”和“执行性能”之间做平衡。有的混淆方案会把每个字符串编码后再运行时解码有的则直接生成一堆计算表达式。但只要是程序最终都必须交由 JS 引擎执行所以逻辑必然可以被解析也必然可以被还原。2. 为什么是AST核心原理与工具选型2.1 先理解 AST 是什么AST 是抽象语法树Abstract Syntax Tree的缩写简单说就是把代码变成一棵树。这棵树里有根节点、子节点每个节点代表代码里的一个最小组成单元。比如var a 1 2;这行代码在 AST 里大概长这样VariableDeclaration ├── VariableDeclarator │ ├── Identifier (a) │ └── BinaryExpression │ ├── NumericLiteral (1) │ └── NumericLiteral (2)是不是很像你中学学文言文画的句子成分图一个主谓宾对应一个节点一个修饰语对应一个属性。JS 引擎在执行代码前也是先把源码解析成 AST再做进一步转换和执行的。所以 AST 不是某个库发明的概念它是 JavaScript 这门语言运行时的真实中间表示。2.2 工具选型为什么选择 Babel 全家桶要做 AST 层面的操作市面上的可选工具有好几类工具特点适用场景esprima escodegen老牌API 相对底层只做简单解析和输出recast保留原格式能力极强需要保留源码格式的批量修改babel 全家桶生态最全AST 转换能力最强几乎所有 AST 解混淆场景oxc-parser性能强但生态还不够成熟追求性能的大型项目我推荐直接用 Babel 全家桶原因有三个。一是生态完整解析、遍历、修改、生成一套全包不用自己拼装零件。二是插件机制成熟你可以像写 Babel 插件一样写解混淆规则每一条规则就是一个独立的 visitor 函数。三是社区对 Babel 内部 AST 结构的文档和示例非常多遇到问题在社区里基本都能搜到答案。这里没有提官方文档但作为长期使用者我的判断是基于它被太多工具链所依赖稳定性是经过海量项目验证的。2.3 AST解混淆的标准流程在我实际项目中一套完整的 AST 解混淆流程可以总结为四步解析把混淆后的 JS 源码用babel/parser解析成 AST。遍历用babel/traverse遍历 AST 中的每个节点找到需要还原的特征模式。转换根据模式类型用babel/types中的辅助方法构造新节点或者直接对现有节点做替换、修改、删除。生成用babel/generator把修改后的 AST 生成回字符串代码。这个过程有点像在玩“找不同”你先在头脑中抽象出混淆代码的固定结构特征然后写规则去匹配这些结构最后替换成你想要的最简形式。最理想的情况下你处理完的代码可能不漂亮但语义一定等价而且可读性会大幅提升。3. 第一个可运行的解混淆插件环境搭建与最小示例3.1 搭建一个 5 分钟能跑的环境先把这个项目需要的依赖装好。使用 npm 初始化一个项目然后安装核心依赖npm init -y npm install babel/parser babel/traverse babel/types babel/generator --save要说明的是babel/traverse在较新的版本里默认导出的是 CommonJS 模块对象需要取.default才能用指向require(babel/traverse).default即可。这是新手经常会踩的一个坑先放在这里省得你待会报错了再回头查。把这段代码保存为index.js这就是你的“还原工作台”const fs require(fs); const parser require(babel/parser); const traverse require(babel/traverse).default; const t require(babel/types); const generate require(babel/generator).default; function decode(code) { // 1. 解析 const ast parser.parse(code, { sourceType: script }); // 2. 遍历 转换 traverse(ast, { // 这里写你的转换规则 }); // 3. 生成 return generate(ast).code; } const inputCode fs.readFileSync(input.js, utf-8); const outputCode decode(inputCode); fs.writeFileSync(output.js, outputCode, utf-8); console.log(done);3.2 先看 AST 长什么样很多人刚学 AST 的时候会卡在看不懂节点结构上不知道怎么知道“这段代码对应什么节点属性是哪些”。有两个方法可以帮助解决。第一个是使用 AST Explorer 这类在线工具把源码粘贴进去左边是源码、右边是语法树还能直接搜索节点类型是新手最直观的学习入口。第二个是在代码里打印节点const util require(util); // 在某次遍历的回调里 traverse(ast, { BinaryExpression(path) { console.log(util.inspect(path.node, { depth: 2 })); } });用util.inspect打印出来的节点对象会以 JSON 形式展示你可以在里面直接看到节点有哪些属性、属性值是什么类型。每次写转换规则前先打印节点写的时候心里就有底了。3.3 处理最简单的字符串拼接混淆从最简单的模式开始混淆器很多时候会把一个字符串拆成多个字符串再做加法拼接。比如var greeting Hello World;在 AST 里这是一棵BinaryExpression树操作符是左右都是StringLiteral。我们要做的就是把这个表达式折叠成一个字符串。代码如下const strConcat { BinaryExpression(path) { if (path.node.operator ! ) return; const left path.node.left; const right path.node.right; if (t.isStringLiteral(left) t.isStringLiteral(right)) { const merged left.value right.value; path.replaceWith(t.stringLiteral(merged)); } } };把这个对象作为traverse的第二个参数传进去再跑一遍input.js你看// 输入 var greeting Hello World; // 输出 var greeting Hello World;这就完成了第一次“解混淆”。虽然只是小试牛刀但整个链路已经通了后续所有复杂还原都是在这个框架上叠加和扩展。3.4 处理嵌套的字符串拼接实际混淆很少像上面那样规规矩矩地只拼一次更多时候是ab c de f这种混合拼接。千万不要天真地以为一次遍历就能全部折叠完。但好消息是babel/traverse的遍历机制是递归进行的当你用path.replaceWith替换了节点后新节点仍然会被继续遍历到。所以只需要写多个字符串常量相邻的折叠逻辑Babel 会自动处理嵌套情况。不过这里有个隐藏问题如果左右其中一个是变量另一个是字符串常量你怎么处理比如var s a b。这种一般是不能乱动的因为a的值未知你没法保证a是字符串还是数字。实际处理时我们通常只折叠两个都是字符串常量的情况不做激进化简。这是很重要的一个经验宁可少还原不要错还原。4. 进阶实战逐步拿下典型混淆模式4.1 字符串数组化还原字符串数组化是我见过的最常见混淆方案几乎没有哪款混淆工具不用它。基本结构是var _0xabc [\x68\x65\x6c\x6c\x6f, \x77\x6f\x72\x6c\x64, \x61\x64\x6d\x69\x6e]; console.log(_0xabc[1]);这里的核心思想是把所有字符串收集到一个数组中下标引用。有的方案还会加一个动态下标计算比如_0xabc[_0x4d2(123)]但万变不离其宗。还原思路分两步。第一步提取数组定义。第二步在MemberExpression节点中判断是否访问的是这个数组如果是且下标是数字常量就把arr[index]替换为对应字符串。示例代码const stringArray { VariableDeclarator(path) { // 找到一个内容全为字符串字面量的数组声明记录下来 if (t.isArrayExpression(path.node.init)) { const elements path.node.init.elements; if (elements.length 0 elements.every(el t.isStringLiteral(el))) { // 记录这个数组对应变量名和值 } } }, MemberExpression(path) { // 这里判断 obj 是否是我们记录的数组变量property 是否数字常量 } };我实际处理过的一个样本中原始代码里这个数组被定义为_0x4b2d里面存了几百个十六进制转义的字符串遍布整个脚本。我把所有_0x4b2d[数字]全部替换成真实字符串后代码体积直接缩了一半之后每个函数里都肉眼可读多了。但注意替换完之后数组定义本身还在后续记得通过清除死代码的步骤把它删掉不然留着也没意义。4.2 常量折叠把运算变成字面量还有一种现象混淆工具会把1 2写成0x1 0x2把布尔值true写成!![]把168写成(0x10 2) | 0x8之类的表达式。这主要是因为混淆器觉得数值以表达式形式出现能够避开简单的正则或静态扫描。还原方法很直接对纯字面量参与的二元表达式做算术运算替换成结果值。const constantFold { BinaryExpression(path) { const node path.node; if (t.isNumericLiteral(node.left) t.isNumericLiteral(node.right)) { let result; switch (node.operator) { case : result node.left.value node.right.value; break; case -: result node.left.value - node.right.value; break; case *: result node.left.value * node.right.value; break; case : result node.left.value node.right.value; break; case |: result node.left.value | node.right.value; break; case : result node.left.value node.right.value; break; default: return; } if (Number.isSafeInteger(result)) { path.replaceWith(t.numericLiteral(result)); } } } };注意逻辑运算符也同样可以处理true false可以折叠成false![]可以替换成false。但做这些操作时务必注意数值边界。比如位移操作可能导致结果超出安全整数范围那就不要强行折叠。还有一点如果二元表达式中的某一操作数不是字面量不要因为看起来像常量就做替换很容易出错。4.3 删除垃圾代码和死分支垃圾代码注入是混淆器最喜欢做的事之一。它们会把一段永远执行不到的if(false)分支、一个无用的函数声明、一段无副作用的赋值语句混在真实代码中间if (true false) { console.log(never); } var flag 1; var other 2; // 可能被故意插入常见的还原策略有三个。一是删除死分支对IfStatement判断条件是否为常量false如果是则整块删除。二是删除EmptyStatement和完全不产生副作用的ExpressionStatement比如abc;或者1 2;。三是识别并删除只被自身调用的无用函数不过这需要先建立作用域引用关系有些复杂。这里有个值得注意的点删除代码时不能只想着“看着没用就删”必须保证被删语句没有任何外部影响。方法是在删除前检查节点是否可能产生副作用。字符串字面量语句没有副作用可以删函数声明只要没有被引用也基本可以删但a.b 1不能乱删因为可能触发类库内部逻辑。我一般先做一遍字符串替换和常量折叠再执行删除垃圾代码顺序很重要因为很多“死代码”混淆在常量折叠后才真正变成可识别状态。4.4 综合实战一个混合混淆样例的逐步还原上面三种技巧搭配起来可以解决大部分轻度混淆。看我处理过的一个模拟样例混淆后大约是这样的var _0xa [\x68\x69, \x21]; function _0x4d(x) { return _0xa[x]; } var name web _0x4d(0) _0x4d(1); if (!![]) { console.log(name); } if (false) { console.log(never reached); }这个样例包含了字符串数组化、字符串拼接、常量表达式和死代码。处理步骤先做字符串数组提取把_0x4d(0)和_0x4d(1)替换为对应数组值。做字符串拼接折叠web hi !合并成webhi!。折叠!![]为trueif (false)分支标记为可删。清理死代码后最终得到var name webhi!; if (true) { console.log(name); }虽然if(true)还可以再简化为直接console.log但走到这一步代码已经完全可以读懂了后面要继续优化再处理IfStatement的test为true的情况即可。5. 新手最容易踩的坑与排查心得5.1 打印节点而不是猜属性名我在带新人做这个的时候发现大多数问题都出在“凭感觉写属性名”上。比如有人以为StringLiteral的内容存在.value有人以为存在于.extra.rawValue结果怎么都不匹配。正确做法是遇到不确定的节点先打印出来看结构。我用util.inspect(node, { depth: 1 })观察也会直接看 AST Explorer。这个习惯至少帮我避免了一半的无意义调试。5.2 处理作用域问题别急着套模板有一类错误非常隐蔽当你替换一个节点时如果新节点包含了变量标识符必须确保这个标识符在目标作用域内可用。举个例子假如你在某个函数里引用了函数外定义的变量但替换节点导致变量名冲突Babel 不会自动提醒你最后生成出来的代码可能直接报错。所以要让babel/types来构造节点而不是手拼字符串。用工具函数构造节点是传承下来的最佳实践能避免大量手工拼接时容易产生的属性缺失问题。5.3 转换顺序比转换本身更重要我踩过最深的坑就是对同一份代码直接应用所有规则结果还原质量很差。后来发现核心流程应该是先内后外、先易后难。我的建议顺序是字符串数组还原提取和解码所有字符串。字符串拼接折叠。常量表达式折叠。删除已确认无用的死代码。简化控制流如果存在扁平化。每做完一步都可以直接生成代码并查看确认这步没有问题再继续下一步。这样即使出错也能快速定位到是哪一步引入的问题。5.4 生成结果不是终点要亲自运行验证在完成所有还原步骤后不要拿生成结果直接交付一定要执行一遍。方法很基础但很有效把输出代码放到 Node.js 环境里运行一次和预期输出做对比。如果输出一致说明你至少没破坏语义。如果中途报错通常说明某一步替换把作用域搞坏了。有个技巧是对于体量很大的代码可以在每轮变换后拍一份快照用文件对比工具比对差异哪一轮变动异常就优先复查哪一轮。6. 后续还可以这样扩展AST 解混淆这条路学到能处理上面的常见混淆已经超过绝大多数新手的水平了。如果还想深入可以继续探索几个方向控制流扁平化还原的标准做法这块需要处理数据流依赖switch-case分发结构的模式识别decodeURIComponent、atob这类内置解码函数的调用还原以及字符串数组的动态索引求解也就是通过模拟执行一段代码得到最终下标。技术侧的重点始终是先理解目标 JS 的运行逻辑再动手写转换规则。本质上AST 工具只是帮助你把手动劳动变成自动化工具的桥梁很多人早早就放弃了是因为他们想一口吃成胖子——我的经验是把一个复杂问题拆成几十个小问题逐个攻克比一次写一个巨型插件靠谱得多。希望这篇文章能让你少走一点我走过的弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑