ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

AST反混淆还原JavaScript:从原理到可复现的Babel还原管线

AST反混淆还原JavaScript:从原理到可复现的Babel还原管线 简介AST反混淆js还原工具是一款面向JavaScript逆向与爬虫工程师的专业工具基于丁仔大佬的js还原工具二次开发新增十余项功能并优化原有逻辑显著提升了对各类混淆规则的兼容性。目前可完整处理2021年9月23日最新版https://obfuscator.io/生成的混淆代码覆盖字符串加密、控制流平坦化、死代码注入等主流混淆手段是应对JS反调试与代码保护的有力武器。资源压缩包共7个文件包含5个核心JavaScript脚本和2个Markdown文档整体大小仅29KB轻量且便携。脚本文件实现还原算法与配置调用文档提供详细功能说明与使用指引可快速部署并集成到现有项目中。已有4310人学习下载适合希望提升逆向效率的进阶开发者。通过阅读源码与说明读者能掌握AST操作与还原逻辑结合实际场景自动化剥离混淆层大幅减少人工分析时间为爬虫开发与前端研究提供稳定支撑。1. AST反混淆js还原工具到底在还原什么拿到一个名叫AST反混淆js还原工具的zip包先别急着双击运行。它要解决的核心问题一句话就能说清把混淆过的JavaScript代码还原成能读懂的形态好让我们继续做JS逆向分析、安全审计或者前端异常排查。无论是混淆加固过的脚本还是打包产物里的加密模块人工看都像一坨乱码用正则硬拆又容易翻车。AST工具的思路是先把源码解析成抽象语法树在节点上做字符串解密、变量重命名、控制流整理最后再把树生成回代码。这篇笔记适合要做JS逆向、反爬或前端混淆还原的人我会先把原理讲清楚然后给一条最小可复现的命令管线最后把参数和坑都列出来。2. AST还原的底层逻辑解析成树、改节点、再生成代码2.1 为什么正则表达式在混淆代码面前会翻车在接触AST之前很多人遇到混淆脚本的第一反应是写一堆正则把_0x1f2a替换成变量名把\x65\x73直接替换成es。这套做法在简单样本上好像能出效果但用不了几次就会翻车。原因在于正则只能感知文本形态不知道哪些位置是字符串字面量、哪些是变量引用、哪些是对象属性名。混淆代码里变量名可能是_0xabc同一个字符串也可能出现在注释、字符串值和标识符三个语义完全不同的位置一次全局替换会把不该换的地方也换掉代码直接就不能跑了。// 一次正则替换的翻车现场目标变量名 _0xabc code.replace(/_0xabc/g, name); // 结果字符串里的 _0xabc、对象属性 obj._0xabc 全部被改掉AST抽象语法树把源码解析成带语义的结构化节点。变量声明、变量引用、属性访问、字符串字面量在树上都对应不同类型的节点每个标识符还能通过作用域分析找到自己对应的绑定。有了这层结构化信息我们才能精确地回答“哪个_0xabc是变量”“哪个\u0065\u0073该被还原成es”。这也是AST反混淆js还原工具的核心优势不是猜而是按语法规则找。这里有一个简单的判断标准如果目标代码里混着二进制数组、自执行函数、多层嵌套的switch-case正则方案基本可以放弃直接上AST。2.2 AST节点长什么样先看懂一个变量声明为了看清AST到底长什么样可以先写一段常见的混淆代码然后用babel/parser解出来逐层打印节点类型。这一步是后面所有还原动作的基础所谓“先看懂树再改树”。代码文件ast_viewer.jsconst parser require(babel/parser); const fs require(fs); const code fs.readFileSync(sample.js, utf-8); // 解析成 ASTsourceType 用 unambiguous 能同时兼容脚本和模块 const ast parser.parse(code, { sourceType: unambiguous }); // 递归打印节点类型跳过 loc/start/end 这些纯位置信息 function printNodes(node, depth 0) { if (!node || typeof node ! object) return; console.log( .repeat(depth * 2) node.type); for (const key of Object.keys(node)) { if (key loc || key start || key end) continue; const value node[key]; if (Array.isArray(value)) { value.forEach(v printNodes(v, depth 1)); } else if (value typeof value.type string) { printNodes(value, depth 1); } } } printNodes(ast.program.body[0]);把下面这段放在sample.js里var arr [\x65\x73, \x74];运行后可以看到顶层是VariableDeclaration往下依次是VariableDeclarator、Identifier(arr)、ArrayExpression、StringLiteral。重点看StringLiteral它有两个关键字段value是解码后的字符串esextra.raw还是原始写法\x65\x73。Babel生成代码时默认优先用extra.raw所以前面那个变量声明打印回代码仍是转义状态。要做字符串还原本质就是修正这些节点的extra信息或者直接把节点替换成不带raw的StringLiteral。这个过程我会在每个还原脚本里都做先打印一遍节点确认要改的节点类型再写visitor。没有这一步直接凭猜写traverse大概率会改错对象。2.3 它能还原的三类混淆字符串编码、标识符混淆、控制流平坦化AST还原能覆盖的混淆手段大致分三类。第一类是字符串编码包括\uXXXX转义、十六进制\x序列、base64或自定义编码。表现形态是字符串字面量或数组里的密文还原方法是在AST里找到对应的StringLiteral、CallExpression和数组定义静态求值或运行解密函数后替换成明文。第二类是标识符混淆典型特征是变量名被替换成_0x开头的短名甚至直接是a、b、c这样的单字符。这类还原要精准需要借助作用域绑定信息找到变量声明处的Identifier再找到所有引用位置一起改为可读名称。Babel的path.scope.getBinding就专门干这件事比正则全局替换安全得多。第三类是控制流平坦化也是目前最难啃的骨头。混淆器会把一个正常的顺序函数体改造成一个while(true)加switch分发器的结构真实语句分散在case里每个case块末尾还要更新分发变量。还原时要把分发器的状态流转分析出来推导出真实的执行顺序再把case块按这个顺序拼回顺序语句。这个过程涉及常量传播和死代码消除没有通用银弹但可以分步做先识别出分发器模式再对单一函数体做顺序重构。还原工具zip里常见的脚本组合基本上就是围绕这三类写的。有的偏向字符串解密适合批量清洗抓下来的混淆JS有的偏向控制流展开。拿到工具先看它覆盖哪一类别指望一个命令直接还原出和生产环境源码一致的文件。AST还原的价值是把黑匣子变成灰匣子变量名能读、字符串是明文、执行顺序可追踪这就足够支撑后续的JS逆向分析了。还有一类叫“控制流拼接”的混淆把原本独立的多个函数拆散用共享的开关变量串成一个整体还原时不仅要恢复顺序还要把拆开的函数重新合并。这类任务没有通用的AST模板可套。我的经验是从字符串解密和标识符重命名入手先把可读性提上来控制流平坦化放到最后而且只针对特定函数做别一口气全量处理否则状态分析复杂到让人想放弃。3. 搭一条可复现的还原管线Babel解析、遍历、生成的完整命令与参数3.1 先搭环境只装四个Babel包常见做法是用Babel全家桶做AST还原因为它的AST节点定义成熟社区里大量现成visitor可以直接借鉴。新建目录后初始化package.json安装四个包就够了babel/parser负责把源码解析成ASTbabel/traverse负责在树上遍历和替换节点babel/types提供创建和判断节点的工具方法babel/generator负责把修改后的AST重新生成代码。命令行如下mkdir ast-reducer cd ast-reducer npm init -y npm install babel/parser babel/traverse babel/types babel/generator需要强调的是这四个包必须一起升级尽量保持同一个大版本。Babel在不同大版本之间调整过节点字段和遍历API混用版本轻则API报错重则生成出来的代码语法错乱查起来非常痛苦。我一般习惯在package.json里把四个依赖的版本范围钉在同一段比如都用^7.x然后在有版本更新的小版本上再统一升。3.2 写最小还原脚本输入、解析、遍历、输出下面是还原管线的最小骨架对应了AST反混淆js还原工具最核心的四个动作。这段代码可以直接跑先把字符串字面量的十六进制/unicode编码还原成明文。// reduce.js —— AST反混淆最小还原管线 const fs require(fs); const parser require(babel/parser); const traverse require(babel/traverse).default; const t require(babel/types); const generator require(babel/generator).default; const code fs.readFileSync(process.argv[2] || input.js, utf-8); // 第一步parse把源码变成AST const ast parser.parse(code, { sourceType: unambiguous, // script/module 自动识别 allowReturnOutsideFunction: true, // 容忍顶层 return plugins: [bigInt, optionalChaining, nullishCoalescingOperator] }); // 第二步traverse访问并修改指定的节点类型 traverse(ast, { StringLiteral(path) { // 如果存在 extra.raw说明当前节点保存了转义前的原始写法 if (path.node.extra path.node.extra.raw ! path.node.value) { path.node.extra.raw JSON.stringify(path.node.value); path.node.extra.rawValue path.node.value; } }, NumericLiteral(path) { // 顺带把 0x1f 之类的十六进制数字也转成十进制生成时更好读 if (path.node.extra path.node.extra.raw ! String(path.node.value)) { path.node.extra.raw String(path.node.value); } } }); // 第三步generate把修改后的AST生成回代码 const output generator(ast, { compact: false, // 不要压缩保留可读格式 comments: false, // 混淆过的注释基本没有保留价值 jsescOption: { minimal: true } // 不要把中文等非ASCII字符转成\uXXXX }).code; fs.writeFileSync(output.js, output); console.log(done:, output.length);这段脚本的真实作用是修正节点上的extra信息。Babel的StringLiteral在解析时会同时保存value和extra.rawvalue是解码后的真实值extra.raw是源码里的转义写法。生成器优先采用raw所以我们看到的输出还是乱码。把raw换成JSON.stringify(value)之后生成器就会输出明文。参数说明sourceType设为unambiguous让parser自动判断文件是普通脚本还是ES module这在处理抓下来的混淆脚本时非常有用因为很多混淆代码既不像script也不像module。allowReturnOutsideFunction容忍顶层return个别混淆产物会在脚本最外层直接return不开这个选项Parser会直接报错。plugins里的bigInt、optionalChaining、nullishCoalescingOperator覆盖了混淆代码常见的现代语法遇上报错再加对应的插件。3.3 跑通一个小样本输入和输出对比用下面这段典型混淆代码做输入var _0xabc [\x68\x65\x6c\x6c\x6f, \x77\x6f\x72\x6c\x64]; function _0x1(_0x2) { return _0xabc[_0x2]; } console[\x6c\x6f\x67](_0x1(0) _0x1(1));执行node reduce.js input.js输出结果var _0xabc [hello, world]; function _0x1(_0x2) { return _0xabc[_0x2]; } console[log](_0x1(0) _0x1(1));对比输入可以看到字符串字面量从十六进制变成了明文属性名console[log]里的log也还原成了可以直接读的字符串。变量名_0xabc和_0x1这类标识符还维持原样这是预期行为——最小脚本只做了字符串层面的还原还没有做标识符重命名。这一步能跑通说明整条parse、traverse、generate的链路是通的。后面任何更复杂的还原比如解密函数内联、数组偏移展开、控制流平坦化都是在traverse这个环节增加visitor管线的骨架不用变。这也是“还原工具”最常见的技术形态一个固定管线加一堆可插拔的还原规则。3.4 再进一步内联解密函数把数组取值变成字面量上面样本里_0x1(0)其实等价于hello。要把这种调用直接替换成字面量需要先找到解密函数和它引用的数组做静态求值。常见做法是先用traverse收集解密函数的函数体在调用点判断参数是否为已知常数再执行一次函数体得到返回值。下面代码示意了核心判断逻辑traverse(ast, { CallExpression(path) { const callee path.node.callee; // 匹配 _0x1(0) 这种解密调用 if (t.isIdentifier(callee) callee.name _0x1) { const arg path.node.arguments[0]; // 下标参数必须是数字字面量否则不能静态求值 if (t.isNumericLiteral(arg)) { path.replaceWith(t.stringLiteral(hello)); } } } });这里的replaceWith是Babel traversal提供的最常用替换API。替换前必须确认目标调用没有副作用、参数是常数、解密函数的内容可静态分析。如果解密函数里做了环境检测、debugger或全局状态写入直接静态求值就会得到错误结果。更稳妥的做法是单独把解密函数体提取出来在vm模块里执行然后取返回值替换。注意不要在还原工具里直接用eval运行完整混淆脚本混淆代码经常带自执行和反调试逻辑一跑反而把环境搞坏了。参数上需要注意替换位置如果是表达式语句、变量初始化或return的值stringLiteral都能兼容但如果原节点在模板字符串、标签模板、导出语句里replaceWith之前要先确认新节点类型合法否则生成阶段会报节点类型不匹配。4. 三个必调参数与还原效果验证用一组混淆样本确认没白干4.1 parser的三个参数sourceType、plugins、errorRecoveryparser是整个还原的第一道关口也是最容易因为参数没设而挂掉的地方。sourceType建议直接用unambiguous它比script或module都稳适合处理不知道来源的混淆脚本。注意module模式下parser会对import/export做严格检查而混淆脚本里经常出现module.exports和顶层return混用的写法固定script或module都会误判。plugins参数决定了parser能接受哪些语法。混淆代码常见的新语法包括BigInt、Optional Chaining、Nullish Coalescing、Class Properties还有少见的Logical Assignment。我一般会把上面几个默认开上遇到报错时再根据错误提示补插件。这里有一个细节如果脚本里用了JSX或TypeScript需要额外加jsx、typescript插件但这会导致部分节点类型变成JSXElement、TSTypeAnnotation后续visitor的匹配逻辑也要跟着变。errorRecovery这个参数要谨慎。打开后parser遇到语法错误会继续解析好处是批量处理时一个文件挂了不至于全部中断坏处是出错位置的AST是残缺的生成出来的代码大概率无法运行。我的习惯是关闭errorRecovery让脚本在第一个语法错误处停下先修parser参数而不是用恢复模式掩盖问题。4.2 generator的三个参数compact、comments、jsescOptiongenerator参数直接影响输出代码是否可读。compact设成false输出保持缩进和换行如果设成trueAST还原完又压缩回去了那就白干了。comments建议设成false混淆器注入的注释多半是干扰项留着没有意义。jsescOption是最容易被忽略的一个。默认情况下generator会把非ASCII字符转成\uXXXX比如“你好”会变成\u4F60\u597D。在还原中文文案或加密盐值时这种转义让输出依然没法看。设置jsescOption: { minimal: true }生成器就只会转义必须转义的字符中文保持原样输出。这个参数值不值得调对比一下输出文件里的\u数量就知道。如果需要让输出更接近工程源码风格还可以在generate之后用prettier或eslint --fix再做一次格式化。注意这不是必须步骤但能明显提升阅读体验特别是处理控制流展开后的长函数时。4.3 还原效果验证用一组混淆样本确认没白干还原做完不能只看“能跑”还要确认“确实变好读了”。我会把还原前后的代码放在一起从三个角度看字符串是否为明文、单字符变量名比例、控制流分发器是否被展开。下面的表是一个典型的对比样例列数据来自同一份混淆脚本指标还原前还原后\uXXXX 转义字符数量1563单字符/短变量名占比87%12%while(true)分发器数量41平均函数体行数429这个表反映的是还原深度。如果转义字符数量还是很高说明extra.raw的修正没生效如果短变量名占比依然高说明还没有做标识符重命名如果while(true)分发器还有4个说明控制流平坦化没处理。每一项都对应具体的还原规则这也是为什么我建议把还原规则拆成独立pass而不是一个visit方法全搞定。4.4 可读性量化脚本与忽略大小写的搜索技巧与其靠肉眼主观判断不如写个小脚本量化可读性。下面是一个简单的检查脚本统计输出代码中单字符变量名比例和\uXXXX转义数量// validate.js —— 检查还原后的代码可读性 const fs require(fs); const code fs.readFileSync(output.js, utf-8); // 统计本体 const words code.match(/\b[a-zA-Z_$][\w$]*\b/g) || []; const singleChar words.filter(w w.length 1).length; const ratio (singleChar / words.length * 100).toFixed(2); const escapes (code.match(/\\u[0-9a-fA-F]{4}/g) || []).length; console.log(单字符变量占比:, ratio %); console.log(\\uXXXX 转义数量:, escapes);注意这里的正则会把关键词和变量名一起统计但作为一个相对指标已经够了。跑完后如果ratio还是很高就去检查还原脚本里是否真的做了变量重命名。在写这类检查脚本时经常需要在混淆代码里搜特征字符串比如找解密函数名、找开关变量。混淆器会把函数名写成_0x1、_0x2混着大小写所以搜索时一般要忽略大小写用new RegExp(keyword, i)来做匹配。还有一个小习惯判断某段代码是否包含特征结构时String.prototype.includes比正则更快只在需要忽略大小写或分组捕获时才用正则。这个搭配在处理上百个混淆文件时能省不少时间。5. 还原工具常见踩坑与排查五条血泪经验对照表5.1 parser直接报错代码连AST都进不去现象执行parse时报Unexpected token或者报错信息指向一个完全预料之外的位置。比如处理一份webpack打包后的脚本时parser在空字符或中文字符串处直接炸掉。原因绝大多数情况是plugins没开全或者sourceType设错了。webpack产物里有时会混着import()动态导入语法还有BigInt字面量都是默认parser不支持的。解决先把报错位置的源码行打出来看是什么语法再根据语法去补插件。动态导入加dynamicImportBigInt加bigInt类属性加classProperties。如果解析的是完整网页里的script片段还要注意是不是被HTML实体转义过了先在文本层面做一次unescape再喂给parser。用errorRecovery硬跳过是下策跳过之后的AST是残缺的还原出来的代码没法直接执行。5.2 字符串还原后还是一堆\uXXXX等于没还原现象还原脚本跑完output.js里依然全是\u0065\u0073这种转义肉眼没法读。原因最常见的有两个。一个是只改了StringLiteral.value没有动extra.raw生成器依旧按raw输出转义原文另一个是generator参数里没设置jsescOption.minimal结果中文字符又被重新转义。解决回到3.2的写法把extra.raw同步成JSON.stringify(path.node.value)同时把generator的jsescOption设为{ minimal: true }。如果这两处都改完还有\uXXXX就要检查是不是字符串本身经过了二次编码比如外层是base64内层又是hex这类字符串要按编码顺序逐层解不能靠AST一层解决。5.3 重命名变量时误伤对象属性还原后代码崩掉现象把_0xabc统一替换成userName之后代码运行报undefined或者明明变量名都改了某个功能却失效。原因把Identifier一概而论了。在AST里对象属性如果是非computed的写法比如obj._0xabc这个_0xabc是成员名不是变量绑定正则或简单visitor会把两者混在一起改。解决在visitor里判断引用位置。Babel的traverse里可以用path.isReferencedIdentifier()来区分真正的变量引用和属性名或者手动检查父节点是不是MemberExpression且path是它的property。只有真正的绑定引用才能安全重命名属性名的改名要单独处理。Identifier(path) { // 只处理真正的变量引用跳过对象属性名 if (!path.isReferencedIdentifier()) return; // 这里再做变量替换 }这个方法尤其适合用在还原“对象配置式”的混淆代码那里面全是config.xxxx这样的成员访问一不留神就改错。5.4 解密函数执行结果不稳定跑两次不一样现象同一个混淆文件还原脚本第一次跑输出正常第二次跑输出的字符串和解密结果不一样甚至直接抛异常。原因混淆脚本里有全局状态、自校验或者反调试逻辑。有的数组偏移会在运行过程中被二次赋值解密函数第一次返回正确值第二次调用时数组已经被改了。解决不要把整个混淆脚本用eval或vm.runInNewContext直接跑要做最小执行。我通常的做法是把解密函数及其依赖的数组提取出来单独在vm沙箱里执行每次只执行函数体并给沙箱提供必要的mock环境。另一个做法是对字符串解密做多轮快照第一遍全量解密后把结果缓存下来后续遍历直接使用缓存避免同一函数被重复执行产生副作用。这个处理让还原工具从“跑一次凭运气”变成“结果可复现”在批量处理时尤其重要。5.5 还原后语法正确但运行结果和原来不一致现象reduce脚本跑完node --check output.js也不报错但实际运行时某个功能行为变了。原因多半是静态求值把带副作用的调用当成纯函数内联了。比如解密函数内部可能修了一个全局变量或者抛异常后再恢复状态直接在AST层面替换成字面量就等于把副作用丢掉了。解决在写replaceWith之前明确判断函数体是不是“纯”的。检查函数体里有没有除返回值外的赋值、调用、try/catch、debugger。出现任何一类的都不要做inline。更稳妥的方式是先让脚本在原始环境里执行一遍记录返回值再以“原函数调用结果”为准替换。这个原则适用于所有内联型还原不只是字符串解密。5.6 把这五条排成一个固定检查顺序如果你刚开始用这类还原工具我建议把前面五个问题排成一个固定检查顺序先确认parser有没有报错再确认字符串是否明文然后检查变量重命名是否误伤属性再观察解密函数执行是否稳定最后用一份已知逻辑的样本做行为验证。顺序从“能不能解析”到“好不好读”到“对不对”每一级都是下一级的前提。跳级排查很容易在一个隐藏问题上绕半天。6. 进阶把还原脚本做成通用工具我再加四个技巧把一次性的还原脚本变成能反复用的工具不只是把规则堆进一个文件。第一个技巧是先打印AST再动手。每遇到一种新混淆特征我都会先把对应代码片段丢进ast_viewer.js里打一遍节点结构确认节点类型和关键字段再写visitor。这能省掉大量“为什么没有匹配到”的排查时间。第二个技巧是给每个还原阶段留快照。字符串解密、数组偏移、控制流展开、标识符重命名各写成一个函数每执行一步就把ast缓存一份输出到stage1.js、stage2.js。这样后面某一步改坏了能直接回退到上一个可用的中间态不用从头再来。这个习惯帮我避免了很多次“还原到一半发现方向错了”的返工。第三个技巧是解密函数不要直接eval。永远用vm模块单独执行解密函数体并且mock掉它依赖的window、document、process等全局对象。有的混淆代码专门在解密函数里放debugger或环境检测直接在Node里跑会触发反调试导致结果不对或者直接挂掉。用vm并在sandbox里放空实现是最常见、最稳的做法。第四个技巧是固定还原顺序先解析再解字符串再解数组偏移然后控制流展开最后重命名。这个顺序不是玄学而是由依赖关系决定的控制流展开之后代码结构变化很大如果先重命名后面每一轮都要重新维护变量名映射。先做语义层面的展开最后做命名整理规则之间不会互相干扰。我记得第一次做AST还原时上来就想一口气处理控制流平坦化结果一整晚都在调switch-case的状态流转字符串解密反而没做输出依然读不了。后来把顺序改成先字符串、再数组、再控制流、最后重命名同样的配置在十分钟内就把一个混淆模块还原到可读状态。先跑通最小管线再逐步加复杂规则是目前这个方向里试错成本最低的推进方式希望帮到你。本文还有配套的精品资源点击获取
返回列表