先看一段代码。你打开一个 JS 文件,第一屏长这样:
var _0x4b3c = ['\x68\x65\x6c\x6c\x6f', '\x77\x6f\x72\x6c\x64', '\x74\x65\x73\x74', '\x20']; (function(_0x1a2b, _0x3c4d) { var _0x5e6f = function(_0x7a8b) { while (--_0x7a8b) { _0x1a2b['push'](_0x1a2b['shift']()); } }; _0x5e6f(++_0x3c4d); }(_0x4b3c, 0x120)); function greet(_0x2d3e) { var _0x1a = _0x4b3c[0]; var _0x37 = _0x4b3c[1]; return _0x1a + _0x4b3c[3] + _0x2d3e + _0x4b3c[3] + _0x37; } console[_0x4b3c[2]](greet(_0x4b3c[0]));别怀疑自己是不是下错了文件。JS反混淆这个圈子里,最不缺的就是这种_0x开头的变量和莫名其妙的数组移位。我去年接手一个外部脚本分析任务,被这种代码折磨了整整两个晚上。后来把市面上能叫得上名字的工具都跑了一遍,jsunpark、jsnice、de4js、ob-decrypt,再加上一些没出现在标题里的辅助项目,折腾出了自己的一套处理流程。这篇就是用实际测试结果告诉你:这些工具到底能干什么、不能干什么、在什么场景下优先用哪一个。
如果你不是专业做恶意代码分析,只是在做爬虫、调试某个混淆前端项目,或者打 CTF 时需要快速还原一段逻辑,这篇文章应该都能帮你省下不少时间。我会尽量不绕弯子,直接讲实测感受和踩坑经历。
1. 为什么我突然把市面上所有 JS 反混淆工具都拉出来跑了一遍
事情起因是这样的:我接手的一个项目里出现了一段被javascript-obfuscator处理过的脚本,看起来就不是给人读的。当时我第一反应是找个在线工具,把代码丢进去,点一个按钮,然后等结果。结果试了一圈之后发现,有的工具对字符串阵列处理得很好,却把控制流平坦化还原成一堆空壳;有的工具能把变量名拟人化,但对字符串解密完全无动于衷。
于是我开始一个个去读它们的文档、翻源码、跑样本。这个过程远比想象中费时间,因为很多工具更新并不勤快,却都默认自己支持“所有混淆”或者“obfuscator.io 产物”。但实际情况是:没有一个工具能真正把混淆代码还原回人类手写的样子。反混淆本质上是在做“可读性恢复”,不是“源码恢复”。
这里先给新手打个底:混淆过程是不可逆的。原始变量名被替换成无意义的_0x2d3e,注释全部丢弃,常量被展开成逻辑,甚至插入大量死代码。再厉害的还原工具,也只能通过统计规律、AST 分析和部分动态执行,把代码恢复到一个“看起来有逻辑、能人工分析”的状态,而不是变回项目最初的源码。你追求的目标应该是“能读”,不是“复原”。
也正因为这样,工具之间的差距才会这么明显。有的擅长字符串还原,有的擅长结构合并,有的擅长变量重命名。单一工具很难把所有步骤都做到位,所以才有反复折腾的必要。
我自制的处理流程大概是这样的:先用jsunpark或de4js做第一遍结构还原,再用自己的 AST 脚本做常数折叠,最后丢给jsnice做变量语义化。这样一套下来,大部分obfuscator.io产物都能恢复到“人工可读”的程度,虽然离“像人写的”还有距离。
2. 混淆器与反混淆工具的基本盘:先搞懂你在跟什么打架
在聊工具之前,我建议先用几分钟把javascript-obfuscator这类工具常用的手段梳理一遍。不然很多工具选项你都不知道该不该勾,出来的结果也看不懂。
2.1 常见的几种“变脸”手法
第一个是字符串阵列。混淆器会把代码里所有字符串集中放到一个数组里,再用一个移位函数打乱顺序,真实使用字符串时通过下标获取。比如console.log('hello')可能变成console[_0x2d3e(0)][_0x2d3e(1)]。这种结构在反混淆里相对好处理,找到解密函数,模拟执行或者做 AST 常量替换就行。
第二个是控制流平坦化。这是真正的“硬骨头”。原来的if/else和循环逻辑会被改造成一个while(true)加一个巨大的switch,每一次循环通过状态变量跳转到下一个分支。阅读顺序被打乱,可读性直线下降。还原时需要做控制流分析,把状态转换关系理清,然后合并成原始的分支结构。
第三个是自执行函数包裹。混淆产物通常在开头会有几个 IIFE,用来生成数组、解密字符串、执行反调试逻辑。这些包裹函数可以拆掉一部分,但有的包裹本身和业务逻辑纠缠在一起,不能盲目删。
还有一些更麻烦的:死代码注入、标识符缓存、域名锁定、debug protection、self-defending。后面两个会让动态调试变得极其痛苦,甚至代码跑起来自动崩溃。一旦遇到带self-defending的样本,最好先考虑静态分析,或者在做任何修改之后立刻恢复原始文件,否则代码自己会改成不可执行的状态。
2.2 反混淆的目标不是“让代码能跑”,而是“让人能读”
很多人没想明白这一点。反混淆工具的输出不一定要能直接运行,甚至不应该直接运行,因为有些输出只是中间态。你真正想要的是:函数之间的调用关系清楚、字符串还原为明文、关键分支不再被switch拆成碎块、变量名有一定的语义提示。
我用一个比喻来解释:混淆代码是一堆被打乱顺序的拼图,反混淆工具能帮你把边角拼好,但中间有些碎片是重复的,有些是干扰项,你必须靠自己的判断把它们归位。工具的目标是把“完全无法分析”变成“能分析但费劲”,而不可能变成“和原始代码一模一样”。
3. 参评选手介绍:jsunpark、jsnice、de4js、ob-decrypt 到底各自什么来头
下面这几款是我在这个时间点上实际用过的,不涉及云评测。我尽量说清楚它们各自侧重什么,不吹不黑。
3.1 jsunpark:为 obfuscator.io “死磕”的我方选手
jsunpark是我这次测试里最意外的一个。听名字就知道它不是来干通用反混淆的,而是盯着obfuscator.io那套产物做深度还原。它最擅长的是控制流平坦化还原,会把switch + while的骨架重新拉平,生成可读性高很多的中间代码。对于字符串阵列,它也能做替换,但更多时候返回的是一个中间 AST,方便二次处理。
它是命令行工具,适合写进自动化流水线。我自己拿它做第一遍粗还原,再把输出丢给其他工具做变量重命名。如果你只是偶尔用一次,可能会嫌装依赖麻烦,但它对复杂样本的效果确实是最稳的。装好后大致的调用形式是:
jsunpark -i input.js -o output.js不同版本参数可能不一样,具体以你本地 README 为准。它输出的代码通常不是最终成品,而是“可分析版本”。
3.2 de4js:老牌在线工具箱,支持面广
de4js我用的时间最长,因为它是个网页工具,打开浏览器粘贴代码就能跑。它支持很多混淆类型,不只是obfuscator.io,像是各种压缩混淆变体也能处理一部分。优势是选项丰富,可以自己勾选字符串替换、数组解包、布尔常量化、变量简化之类的还原步骤。
弱项也很明显:对控制流平坦化的处理相对规矩,遇到复杂的多层嵌套会力不从心。另外在线工具意味着你得把代码粘到第三方网站里,如果是敏感样本,记得脱敏或者自己部署。好在这个项目本身有离线分支,源码公开,可以本地跑。
我平时最常用的操作是:粘贴代码,勾选基础还原项,先跑一遍看字符串能不能出来。这一步主要是摸清楚样本到底有没有加密字符串,而不是指望它一次搞定。
3.3 jsnice:不做字符串解码,专攻变量名和类型修复
jsnice的角度和前面几位不一样。它不会去管字符串解密,也不负责控制流还原,它只做一件事:重命名变量和函数名,让_0xabc这种无意义标识符变成login、formData这种猜测性名称,同时恢复一些类型信息。效果相当惊艳,很多时候一眼就能看出代码意图。
正因为它只做语义化,所以最适合放在整个还原流程的最后一步。先把字符串和控制流处理好,再交给jsnice做“语义化”,代码分析速度会大幅提升。如果你拿到的是压缩代码而不是混淆代码,jsnice也能让变量名变得可读,这种情况完全不需要动用重型反混淆工具。
3.4 ob-decrypt:轻量级针对性工具
ob-decrypt看名字就知道,它主要处理obfuscator产物。它是轻量级的,适合快速解字符串阵列和一部分结构恢复。在简单样本上,它跟de4js表现类似,但在复杂样本上的处理深度不如jsunpark。我一般把它当作快速查看的“探针”,一旦发现它处理不了,就换重型工具。
为了让你快速抓住差异,我把这次实测的大致印象整理在下面这个表里。注意,这里的评级是针对常见 obfuscator.io 产物的个人感受,不是绝对权威。
| 工具 | 字符串还原 | 控制流平坦化 | 变量语义化 | 自动化接入 | 上手门槛 |
|---|---|---|---|---|---|
| jsunpark | 良 | 优 | 中 | 高 | 中 |
| de4js | 优 | 良 | 中 | 低 | 低 |
| jsnice | 无此能力 | 无此能力 | 优 | 低 | 低 |
| ob-decrypt | 良 | 中 | 中 | 低 | 低 |
4. 同一样本下的拆解实测:从字符串阵列到控制流平坦化
说再多理论,不如直接看同一段样本在各个工具下的表现。我准备了一个简化过的混淆样本,只保留真实场景里最常见的几个特征:字符串阵列、IIFE 解密、以及一个简单控制流平坦化块。
4.1 测试样本与判分标准
原始代码很简单:
function greet(name) { const prefix = 'hello'; const suffix = 'world'; return prefix + ' ' + name + ' ' + suffix; } console.log(greet('test'));经过混淆器处理并简化之后,长这样:
var _0x4b3c = ['\x68\x65\x6c\x6c\x6f', '\x77\x6f\x72\x6c\x64', '\x74\x65\x73\x74', '\x20']; (function(_0x1a2b, _0x3c4d) { var _0x5e6f = function(_0x7a8b) { while (--_0x7a8b) { _0x1a2b['push'](_0x1a2b['shift']()); } }; _0x5e6f(++_0x3c4d); }(_0x4b3c, 0x120)); function greet(_0x2d3e) { var _0x1a = _0x4b3c[0]; var _0x37 = _0x4b3c[1]; return _0x1a + _0x4b3c[3] + _0x2d3e + _0x4b3c[3] + _0x37; } console[_0x4b3c[2]](greet(_0x4b3c[0]));这段代码特征很典型:数组顺序不是最终顺序,字符串都是\x编码。还原目标是把greet函数变成人能读的样子。
4.2 第一步:字符串阵列替换,谁做得最干净
这一步我直接用各工具的默认或自动选项处理,不看手工微调。结果是de4js和ob-decrypt对字符串阵列的替换最直观,基本能把_0x4b3c[0]直接变成'hello'。jsunpark也能做到,而且它不只替换,还会尽可能把解密函数重新折叠成常量。
这里有细节:很多工具对数组元素顺序的还原,依赖那个移位函数。如果混淆器用的是push + shift循环,工具的模拟执行需要足够准确,否则会把前后顺序搞错。我在测试里遇到过两次结果字符串出现乱码的情况,原因就是数组移位没有被正确还原。碰到这种问题,先看看样本是否同时开了“字符串编码”选项,导致解密出来的不是明文而是另外一层编码。
jsnice在这个环节完全插不上手,因为它压根不做字符串提取。你在 jsnice 里看到的还是那些下标调用,只是变量名可能从_0xabc变成了arr之类的东西。
| 工具 | 字符串明文可读度 | 数组移位处理 | 误还原率(个人观感) |
|---|---|---|---|
| jsunpark | 高 | 高 | 低 |
| de4js | 高 | 高 | 中 |
| ob-decrypt | 中 | 中 | 中 |
| jsnice | 不处理 | 不处理 | 无 |
4.3 第二步:控制流平坦化,谁的还原率更高
由于我的测试样本控制流比较简单,这里换成从真实项目里截取的一个带while + switch的分片,简化后大概是:
var _0xstate = 0x2; while (true) { switch (_0xstate) { case 0x2: _0xresult = _0x4b3c[0] + _0x4b3c[3] + _0x2d3e; _0xstate = 0x7; break; case 0x7: _0xresult += _0x4b3c[3] + _0x4b3c[1]; _0xstate = 0x9; break; case 0x9: return _0xresult; } }对这种结构,jsunpark的还原效果最强,输出会接近语义化的if/else顺序。de4js也有控制流还原选项,但合并策略偏保守,有些分支会保留冗余,需要手工再清理。ob-decrypt在简单平铺场景表现还行,但一旦状态变量不是简单的2 -> 7 -> 9,比如中间夹着死状态分支,它容易卡在某个case里不肯继续。jsnice对控制流无能为力,因为它根本没有做 AST 结构重写。
一个比较意外的地方是:jsunpark对状态变量的识别不依赖变量名。它会把整个switch的状态传递图提出来,然后根据基本块之间的跳转关系重新排序。这意味着就算状态变量的名字是随机生成的,它也能处理。这也是为什么它拿到复杂样本时表现更稳。
4.4 第三步:自执行函数与 IIFE 处理的差异
几乎所有obfuscator.io产物开头都会有一到两个 IIFE,用来给字符串数组重排顺序。测试样本里也有一个。这里的关键区别:de4js通常会把 IIFE 保留成一个可执行的小模块,然后通过常量替换间接抵消它的作用;jsunpark更倾向于直接分析数组变化关系,输出里不再出现那个移位 IIFE;ob-decrypt的表现取决于样本是否触发它的“包装名”规则,有时会留下一个空壳函数。
我在实际处理中见过很多次这样的结果:字符串已经变成明文,但代码外面还套着(function(_0x1a2b, _0x3c4d) { ... })(_0x4b3c, 0x120);。这时如果不手动清理,后续格式化或执行都会带着大包袱。清理方法很简单:如果确认这个 IIFE 没有返回值,也不影响任何函数声明,替换完数组之后可以直接删掉整段。
提示:删 IIFE 之前要确认它没有被后面代码引用。常见陷阱是
var _0x2d3e = (function(){...}()),这种带赋值语句的包裹不能整段删除,只能把内部函数逻辑保留下来。
5. 深水区的坑:解密函数定位、rigid 分支和死代码
前面的测试还相对干净。真正干活的时候,我遇到的大坑主要有三个。
5.1 解密函数不是“一键解”能搞定的
很多在线工具为了安全,会在浏览器沙箱里模拟执行字符串解密逻辑。听起来很美好,但一旦混淆样本带有自校验逻辑,你的“模拟执行”就等于把代码的防御机制也执行了一遍,结果要么解密函数返回错误结果,要么代码直接自我破坏。
我在一次恶意脚本分析里就碰到过:把一段样本喂给在线工具,输出结果里面所有字符串都变成了乱码,我还以为是编码问题,后来单独跑了解密函数才发现,这段代码在解密前会先检查typeof某个环境变量,一旦发现不在预期环境里,就返回垃圾数据。这种问题只能靠人工定位解密函数,手动补环境,或者用更细的符号执行方式去绕过环境检查。
5.2 self-defending 和 debug protection:跑一遍就崩怎么办
self-defending是javascript-obfuscator里很让工具头疼的选项。生成的代码里通常有一段正则或进制检测,专门用来检测代码有没有被修改或格式化。如果你直接把反混淆结果打印出来,或者用美化工具格式化,再次运行时可能直接报错或静默失败。
遇到这类样本,我的经验是:第一遍绝对不要急着格式化,先静态替换字符串,再用jsunpark做控制流还原;如果中间结果需要保存,用文件流方式写入,不要把代码贴到控制台再拷贝。某些工具会在输出里保留debugger语句,de4js有一项“移除 debugger”功能,记得勾选,不然每分析一个分支都弹一次调试器。
5.3 死代码注入让 AST 变换雪崩
死代码注入是另一个让人头大的设计。混淆器会随机插入一些永远不会执行的分支,但这些分支照样引用解密函数和变量,看起来像可执行逻辑。反混淆工具在做 AST 合并时,有时会被这些不可达分支骗过去,生成一层又一层无意义的嵌套 if。
处理这类问题,最好的办法不是手工去删,而是在反混淆之后再做一轮 dead code elimination。很多工具不做这一步,你要自己补。我后面会谈一个简单的手工 AST 后处理思路,能处理掉大部分不可达分支。
6. 按场景选型:爬虫、恶意 JS 分析、CTF、代码迁移分别该用谁
工具的火力要怎么分配,取决于你的场景,而不是单纯比谁“更好”。
6.1 恶意 JS 分析:优先静态可读性,别裸奔执行
如果你在做恶意代码或广告脚本分析,我强烈建议把动态执行放在最后一步,甚至只在受控沙箱里做。这个场景下,jsunpark加de4js是比较稳的组合:先用jsunpark把控制流拉平,再用de4js或手动脚本完成字符串替换,最后交给jsnice做变量语义化。
为什么要先控制流后字符串?因为控制流还原依赖 AST 结构完整性,如果字符串早就替换成了明文,但某些分支还是会访问原来的数组下标,AST 工具会判断错误。先粗还原,再替换,最后重命名,这是我自己试下来最不容易出错的顺序。
6.2 CTF / 算法还原:jsunpark + jsnice 配合
CTF 里给定一段混淆脚本要还原算法时,过程通常比较温和,一般没有反调试,也不会做域名锁定。这种情况用jsunpark还原率最高,因为它针对obfuscator.io的还原规则很激进。跑完之后再用jsnice重命名,往往能直接看出函数是md5、base64还是某种自研算法。
如果题目里用的是其他混淆器,比如UglifyJS的压缩产物,那就没必要上重型工具,jsnice一个就够。
6.3 爬虫逆向:de4js 当作第一道提词器
爬虫逆向场景和恶意代码分析不一样,你通常能遇到很多“半混淆”的代码,比如前端构建产物压缩后带一些可控变量名。de4js的在线体验在这里很友好,粘贴一下,勾选第一轮基础还原项,很快能看到一个可以读的结构。然后你再根据结果去浏览器里打断点、覆盖函数,效率会高很多。
不过提醒一句:爬虫相关操作要遵守目标站点条款。反混淆工具本身只是分析技术,怎么用是你的选择。
6.4 代码迁移:如果只是变量名乱,用 jsnice 足够了
有时候你拿到的不是混淆代码,只是压缩代码,变量名全是a,b,c。这种场景下用jsunpark纯属杀鸡用牛刀。jsnice就能自动推断变量用途并重命名,处理后的代码已经足够做逻辑迁移。如果再配合 Prettier 格式化,观感会接近正常工程代码。
| 场景 | 首选工具 | 次选工具 | 最关键动作 |
|---|---|---|---|
| 恶意 JS 静态分析 | jsunpark | de4js | 先静态后沙箱 |
| CTF 算法逆向 | jsunpark | jsnice | 还原后手动验证逻辑 |
| 爬虫脚本调试 | de4js | ob-decrypt | 快速看结构后再断点 |
| 压缩代码迁移 | jsnice | Prettier | 只重命名,不做结构处理 |
7. 我自己的预处理与后处理三招
工具用多了以后,我会习惯在正式还原之前和之后各加两道手工工序。这些技巧不复杂,但对结果质量提升很大。
7.1 先用正则快速定位“解密函数簇”
不要一上来就反混淆。先用文本编辑器搜索这些特征:['push'](['shift']、parseInt、toString()['slice']、charCodeAt这类高密度调用。看到它们,说明这段代码大概率有字符串阵列和编码逻辑。定位到这些位置后,你就能判断工具应该重点处理哪个片段,而不是让工具对整份文件“盲还原”。
我自己常用的定位命令很简单,比如找数组移位特征:
grep -n "push.*shift" target.js如果命中很多处,说明字符串阵列逻辑很重,de4js的数组还原选项必须开。如果命中很少,可能混淆器用的是别的手段,比如base64编码或自定义编码函数,那就得换思路。
7.2 把反混淆结果丢回 AST 再做一次常数折叠
很多工具只做结构变换,不做常数合并。还原后代码里可能残留1 + 2、true && false这类表达式。我自己的做法是先用esprima或acorn解析,再写一个几十行的折叠脚本,把字面量运算、布尔表达式、空语句替换掉。这一步对最终可读性提升非常大,尤其适合跟jsunpark配合,因为它输出的中间态已经接近普通代码,常数折叠能直接让它变得接近手写。
处理方式大致是遍历 AST,遇到两个数字相加就替换成结果,遇到if (true)就保留真分支,遇到if (false)直接删掉分支。代码量不大,但能把很多“工具遗留下来的渣子”清理干净。
7.3 最后一步:用 jsnice 重命名变量
整个流程的最后,我会把代码贴到jsnice里做一遍重命名。这一步拖到最后有好处:前面做了结构还原和死代码清理后,变量出现频率更“真实”,jsnice的统计模型能给出更准确的猜测。如果一上来就让它重命名,反而会因为原始上下文太杂、标识符之间没有语义信息,预测效果大打折扣。
整体用下来,我的体感是:jsunpark负责“把结构拍平”,de4js负责“把字符串点亮”,jsnice负责“让代码像人写的”。这三个工具串联起来,比我过去只依赖任何一个在线工具要稳得多。如果你和我一样只是偶尔碰上一段混淆代码,也别追求“和原始源码一模一样”,最终目标是让逻辑能在大脑里跑通。能读,比能跑更重要。