干前端时间久了,谁手里没几条特别“难啃”的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 | 行为 | 下一 state |
|---|---|---|
| 0 | 判断 x > 0 | true 走 1,false 走 2 |
| 1 | x = x * 2 | 3 |
| 2 | x = 0 | 3 |
| 3 | return 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 还原脚本做成一个小工具库:字符串提取、命名重命名、控制流转移表分析、死代码清理,每个功能独立一个模块。下次拿到新样本,改改配置就能跑,不用从零写起。反混淆这东西,熟练以后不会有太多“顿悟时刻”,反而都是体力活。但把体力活工具化,才能把精力留在真正需要动脑判断的语义还原上。