首页 / 资讯中心 / 文章详情

前端JS反混淆五步流程:从AST解析到代码还原实战

前端JS反混淆五步流程:从AST解析到代码还原实战 ★ FEATURED ARTICLE
干前端时间久了谁手里没几条特别“难啃”的JS脚本压缩成一行变量全是_0x开头字符串是一串十六进制还裹着好几层立即执行函数。真正想读的时候可读性比乱码好不到哪里去。所谓 JavaScript 反混淆就是把这种经过混淆处理的代码还原成能读、能改、能跑的结构方便排查线上报错、分析第三方脚本行为、或者做安全审计。今天这套五步流程是我平时调脚本反复用的一整套思路不是教你破解付费脚本去牟利动手之前一定确认自己对目标代码有处理权限。适合前端开发、做页面数据采集、还有做安全分析的同学参考。1. 先把对手看清楚常见的混淆手段与识别特征1.1 常见混淆手段一览想反混淆第一件事不是拿工具猛跑而是先搞清楚代码里到底用了哪些“障眼法”。我见过的大部分混淆脚本本质上就是把代码写得不像代码常见手段就这么几类混淆手段外观特征反混淆难度命名混淆变量名/函数名全是_0x1a2b、a1、b2这类无意义标识符中字符串编码大量\x、\u、Base64、String.fromCharCode调用低到中数组查表文件开头挂着一串字符串数组后面通过下标取字符串中控制流平坦化while(true)加switch(state)外加一个状态变量不断赋值高死代码注入定义了大量从未被调用的函数或者if(false)包裹的无效分支低逻辑等价替换把普通if/else、算术运算改写成位运算、逗号表达式、三目嵌套中高自执行函数包裹整段代码包在一个 IIFE 里最外层看不到完整函数名低这些手段经常是组合出现的。比如命名混淆负责让你看不清变量关系字符串编码负责隐藏关键字符串控制流平坦化则是把原本清晰的if/else逻辑打散成一张状态转移表。最恶心的往往不是某一个手段而是它们叠在一起导致你格式化之后依然一脸懵。1.2 反混淆的边界混淆不是加密先纠正一个概念标题里说“被加密的源代码”但 JavaScript 混淆本质上不是加密至少不是对称加密或非对称加密。混淆只是“让代码难读”它和代码本身是等价的执行器不管字符是不是_0x照样能跑。所以反混淆去做的不是“破解密码”而是“把难读的代码重新整理成人能懂的结构”。明白这一点很重要因为目标就不一样了。面对商业级混淆方案你没法做到“100% 还原成原始写法”也不该追求那种完美。反混淆的目标是恢复可读性让我能定位到某个函数干什么、某段逻辑处理什么、某个请求参数来自哪里。能达到这个程度后面的事情就顺了。再说一次边界只处理你有权分析的代码比如自己项目的历史版本、经过授权的外部脚本、学习中自己写的练习样本。不要拿这套手法去绕过授权、破解商业软件这既不合规也违背日常开发的基本操守。2. 动手前的摸底环境准备与特征定位2.1 肉眼识别打开文件先找这些特征拿到一个可疑 JS 文件不要急着写脚本先用编辑器打开配合几个简单的搜索特征快速摸底。我会按下面顺序看搜索_0x或0x这是命名混淆最常见的特征一搜一大片基本可以确定有命名混淆。搜索\\x、\\u大量十六进制或 Unicode 字符串编码说明下一步要处理字符串表。搜索while (和switch (如果两者经常一起出现并且里面有一个状态变量在不停赋值控制流平坦化基本没跑。搜索eval和Function有些混淆会把字符串拼成一段代码再动态执行碰到这种要格外小心不能直接跑只能静态分析。查看文件末尾有没有sourceMappingURL有 sourcemap 就省事了直接能拿到原始映射但多数混淆文件已经清理干净。数一数顶层函数声明如果顶层有一堆从未被调用的函数那大概率混入了死代码后面可以批量删。这个过程别指望一眼看穿重点是建立“从哪个入口开始拆”的感觉。通常我会把特征记在文件注释里哪些是字符串表、哪些是解码函数、哪些是垃圾代码。这一步看似随意实际能帮你绕开后面很多坑。2.2 搭建本地分析环境反混淆不需要太重的环境但最好全部在本地完成。我习惯搭一套最小环境包括本地 JS 运行时用来跑还原后的代码和自测脚本一个 AST 解析库按你熟悉的来尽量选支持解析、遍历、生成代码三件套的后面还原要反复用离线代码格式化工具只做缩进和换行不会改逻辑文件对比工具用来比较每一步改动前后的 diff一个简单的测试运行脚本准备几组输入输出专门验证行为是否一致。很多人喜欢把代码贴到在线工具里一键美化或者反混淆。省事是省事但如果脚本里含有内部接口、鉴权信息、业务细节一旦上传就有泄露风险。涉敏脚本我只会放本地处理离线工具虽然笨一点胜在可控。2.3 先备份再动手这一步是我踩过坑之后养成的习惯。刚开始接触反混淆时直接拿原文件改结果字符串还原写错一版把整个文件结构改乱了想回退都没得退。现在不管处理什么脚本我都会先复制一份step0_original.js然后在同一个目录里生成step1_格式化.js、step2_字符串还原.js这样的阶段性文件。每一轮处理前先对比上一轮 diff确认改动范围是不是预期内的。还原代码本质上是“不断改代码但保持行为不变”的过程如果行为变了你要能快速定位是哪一步改坏的。保留中间产物就等于给整个过程装了回滚点。3. 五步反混淆实战从格式化到行为验证3.1 第1步格式化把代码“捋直”先看一段典型的混淆代码我用一个简化示例演示整体思路。原始逻辑很简单function check(user, pass) { var msg; if (user admin pass 123456) { msg welcome; } else { msg denied; } return msg; }经过字符串数组加十六进制编码之后可能变成这样var _0x1a [\x77\x65\x6c\x63\x6f\x6d\x65, \x64\x65\x6e\x69\x65\x64, \x61\x64\x6d\x69\x6e, \x31\x32\x33\x34\x35\x36]; function _0x2f(_0x3) { var _0x4 _0x1a[_0x3]; return _0x4; } function _0x5a(_0x6, _0x7) { var _0x8; if (_0x6 _0x2f(2) _0x7 _0x2f(3)) { _0x8 _0x2f(0); } else { _0x8 _0x2f(1); } return _0x8; }第一步永远是格式化。直接用离线美化工具跑一遍把压缩的一行拆成多行缩进乱一点没关系只要结构上能看清大括号的配对关系。格式化完以后上面那段代码会变成直观的函数声明和调用你能够分辨出_0x5a是一个函数里面有一个判断逻辑但判断的常量还是字符串表下标。注意格式化不会帮你还原任何语义它只是把“阅读障碍”的第一层去掉。有些混淆代码一格式化就能看出大概那是因为它们只做了压缩如果格式化完仍然密密麻麻说明背后还有字符串表和控制流平坦化等着你。3.2 第2步定位字符串表和解码函数格式化之后第二步是找到字符串的真相。几乎所有字符串混淆都有一个共同点代码里会先定义一堆字符串数组再用一个“取字符串”的函数按索引拿值。在我的示例中字符串表就是_0x1a解码函数是_0x2f它的作用是_0x1a[_0x3]也就是给定下标返回字符串。真实的混淆不会这么温柔常见的变招有下标不是直接给而是先异或一个偏移量比如_0x2f(_0x3 ^ 0x66)字符串表本身不是明文而是一个大数组的切片拼接成一个长字符串后再split分组解码函数不做单纯查表而是通过String.fromCharCode逐字节还原多个字符串表交叉调用A 表里存的是 B 表的下标。这个阶段的核心任务是“建模”。你不需要立刻还原所有字符串但要把每一项内容提取出来建立一张映射表。拿示例来说提取后的映射是const table { 0: welcome, 1: denied, 2: admin, 3: 123456 };如果数组里存的是\x编码我建议别手工翻译直接用本地脚本一次性解码。你盯着\x77\x65猜出w很爽但如果有一百个字符串看一个小时也搞不定脚本几秒钟就出结果。3.3 第3步用 AST 批量还原字符串定位到解码函数和字符串表之后下一步是把所有调用点上的解码调用替换成真正的字符串。这一步我强烈建议用 AST 处理别用正则。原因很简单正则无法理解嵌套结构比如_0x2f(2)出现在函数参数里和出现在条件判断里本质都是同一个操作但正则写起来很容易漏掉边界。AST 能把代码解析成语法树每个节点都有明确类型遍历的时候你可以只命中CallExpression精准替换。以我的示例为例AST 脚本的大致形状是这样const fs require(fs); const { parse, traverse, generate } require(ast-toolkit); const table { 0: welcome, 1: denied, 2: admin, 3: 123456 }; const ast parse(fs.readFileSync(step1.js, utf8)); traverse(ast, { CallExpression(path) { const callee path.node.callee; if (callee.type Identifier callee.name _0x2f) { const arg path.node.arguments[0]; if (arg arg.type NumericLiteral) { const val table[arg.value]; if (val ! undefined) { path.replaceWith({ type: StringLiteral, value: val }); } } } } }); fs.writeFileSync(step2.js, generate(ast).code);注意ast-toolkit是示意占位名你换成自己手头那套 AST 库即可API 大体相似。跑完这段脚本示例代码会变成function _0x5a(_0x6, _0x7) { var _0x8; if (_0x6 admin _0x7 123456) { _0x8 welcome; } else { _0x8 denied; } return _0x8; }到这里字符串已经全部变成明文了代码立刻好读了一半。不过有一个地方要留意只有参数是数字字面量的调用才能静态替换。如果解码函数的参数来自运行时的变量比如_0x2f(name)你没法在静态阶段知道最终结果这种只能先保留等运行时插桩再处理。不要为了“好看”硬把这些调用也改掉那会把行为改坏。3.4 第4步还原变量名和函数名字符串还原以后代码里的_0x5a、_0x6、_0x7还是看不懂。命名还原这一步核心不是技术问题而是语义判断问题。你要根据函数的行为来起名比如_0x5a内部在比较用户名和密码那它就是一个check函数_0x6是用户名_0x7是密码_0x8是结果消息。起名这件事没有自动方案必须靠人读逻辑。但“怎么改名”有讲究。永远不要用编辑器的全局替换去处理混淆变量名因为同一个名字在不同作用域可能代表完全不同的数据。正确思路是用 AST 的作用域分析先把变量声明和所有引用关联起来再在指定作用域内统一起名。示意的写法是traverse(ast, { FunctionDeclaration(path) { if (path.node.id.name _0x5a) { path.node.id.name check; // 在函数作用域内把 _0x6 重命名为 user // 把 _0x7 重命名为 pass // 把 _0x8 重命名为 msg } } });AST 库一般会提供重命名接口它会自动处理同一作用域内的所有引用点不会乱改其他函数里同名变量。改完名称的代码已经接近原始版本function check(user, pass) { var msg; if (user admin pass 123456) { msg welcome; } else { msg denied; } return msg; }命名还原没有太多捷径。遇到大型代码我一般先还原入口函数和关键业务函数把核心调用链看清再沿调用链递归往下分析。这样比从文件头一路读到文件尾高效得多。3.5 第5步还原控制流并清理垃圾代码字符串和命名都到位后最棘手的一关往往只剩控制流平坦化。看一段更贴近实际的混淆逻辑function order(x) { let state 0; while (state ! -1) { switch (state) { case 0: if (x 0) state 1; else state 2; break; case 1: x x * 2; state 3; break; case 2: x 0; state 3; break; case 3: return x; } } }这个while加switch就是典型的控制流平坦化。原始逻辑其实是function order(x) { if (x 0) { x x * 2; } else { x 0; } return x; }还原思路可以按四步走把每个case当成一个基本块记录它做了哪些操作找出状态变量这里是state在哪些位置被赋了什么值根据状态值变化画出一张状态转移表把条件跳转还原成if/else把return还原成退出逻辑。针对上面的示例转移表长这样state行为下一 state0判断 x 0true 走 1false 走 21x x * 232x 033return x结束有了这张表还原就变成了“把表翻译回代码”的过程。你可以手工做也可以写工具自动做遍历 AST识别while switch state的组合解析每个 case 中 state 的赋值再通过遍历状态图生成结构化的 if/else 代码。控制流还原的自动化程度取决于混淆的复杂度。有些混淆器会加入无用 case、重复 state、甚至干扰性的break和return导致转移表不闭合。这时候别硬刚我会改用在每个 case 开头插入日志的方式跑测试样例把实际运行时 state 的走向打出来再结合静态转移表核对。动态轨迹和静态转移表都匹配才能放心重构。最后一步是清理。清理的目标是删除那些“定义了但从未被调用”的函数、if (false)包裹的不可达分支、以及已经没人在用的字符串表和解码函数。AST 分析可以统计每个函数声明的引用次数引用数为 0 且没有副作用就可以删除。删完再重新格式化一遍代码基本就能读了。清理完别忘了做行为验证。还原后的代码必须保持原行为这个可以用一组最小断言来确认// 测试还原后的脚本 const restored load(step5_restored.js); console.assert(restored(admin, 123456) welcome); console.assert(restored(admin, wrong) denied); console.assert(restored(5) 10); console.assert(restored(-1) 0);如果断言全部通过说明这一轮还原没把逻辑改坏。如果失败就要回头检查是哪一步出的问题。每一步都保留中间文件的价值就在这里你直接用 diff 定位到出错的那轮改动。4. 踩坑记录常见问题与排查方法4.1 格式化之后仍然不可读格式化后还是一团乱麻大概率不是格式化工具的问题而是你跳过了字符串还原和控制流还原就直接开读。压缩代码经过格式化会变得结构清晰但结构清晰不等于语义清晰\x十六进制字符串依然需要解码while switch依然需要状态分析。我建议的处理顺序不要乱先格式化再字符串表还原再重命名最后再碰控制流。很多人喜欢先还原控制流结果在无数个看不懂的case里迷路反倒把自己绕晕了。字符串明文化以后每个case的行为会直观很多控制流还原的难度会直线下降。4.2 字符串还原后行为不一致最常见的原因是“字符串原不是静态的”。举个例子有的混淆里字符串是通过_0x2f(_0x3 ^ 0x66)这种异或方式取下标你只看到_0x3是参数却忽略了后面的^ 0x66。如果直接拿传入参数查表查出来的字符串完全不对。排查办法是在解码函数前加日志。把传入的实参、经过异或之后的真实下标、查表得到的字符串三个值全打出来跑几个业务场景对照真实输出修正映射关系。这个过程相当于给解码函数做了一次“行为录制”比肉眼猜更快也更准。4.3 控制流还原后执行顺序错乱控制流还原出错基本能归为两类漏边或乱序。漏边是指状态转移分析时漏掉了某个state的赋值路径乱序是指在还原时把多个case的执行先后搞反了。症状往往是某些输入下行为正常某些输入下结果完全不对。这时候不要急着改代码先重新画一遍状态转移表把每个case里的每个state赋值都列出来。我习惯在原始脚本里临时加日志打印每次进入switch前的state值对照静态转移表确认是不是有隐藏路径。只有静态表和动态日志对得上还原后的顺序才可信。4.4 大文件处理与性能问题遇到几个 MB 的混淆文件AST 解析和遍历会比较吃内存处理不当还可能直接把环境干崩。我的做法是先把文件按顶层函数切分分别独立还原。大部分函数互不强依赖拆开处理能显著降低单次内存占用。如果真实文件又大又有很多死代码我还会在正式反混淆之前先粗筛一遍删除明显无引用的函数声明把无用的字符串表清理掉减小输入体积。处理过程中不要一次性跑全套 AST 插件每一步单独执行并且每步之间做 diff。这样既能控制内存也能在出错时快速回退。我把这些高频问题整理成一张速查表方便你对照处理现象可能原因处理建议格式化后仍难读字符串表或控制流尚未还原按“格式化-字符串-命名-控制流”顺序逐步处理还原后字符串错误下标存在异或/偏移或依赖运行时值在解码函数中插桩记录真实输入输出行为时好时坏状态转移分析漏边列出完整 state 转移表并用动态日志交叉验证代码运行报错误删了被引用的函数或变量检查删除逻辑的引用计数确保无副作用再清理内存占用过高单次解析整个大文件按顶层函数拆分分段处理手动改名改乱同名变量在不同作用域被当成同一个改用 AST 作用域重命名不要全局替换5. 实战后的几个习惯这套流程用的次数多了我慢慢养成了几个习惯。第一是用“插桩 静态分析”组合拳碰到静态还原不干净的代码就靠日志把动态行为补上而不是硬着头皮猜。第二是每处理一个文件都会自动把字符串表、解码函数、状态转移表整理成注释留在文件开头方便下次回看。第三是不追求“和源码一字不差”只要逻辑清楚、测试通过、能找到我要改的地方这次反混淆就算成功。如果你经常要面对这类工作建议把 AST 还原脚本做成一个小工具库字符串提取、命名重命名、控制流转移表分析、死代码清理每个功能独立一个模块。下次拿到新样本改改配置就能跑不用从零写起。反混淆这东西熟练以后不会有太多“顿悟时刻”反而都是体力活。但把体力活工具化才能把精力留在真正需要动脑判断的语义还原上。
阅读完成 · 觉得有帮助?
咨询建站