压缩过的 JavaScript 难读,是因为空白和局部变量名都没了。混淆过的更麻烦:所有字符串被塞进一个打乱顺序的数组,只能通过解码函数取出来;if、switch 的逻辑又被拆进查表对象和永远不会执行的分支里。这个工具把两种代码都变回能读的样子。适合排查自己线上构建的问题、在把某个脚本放进网站之前先看看它做了什么,或者审计你有权查看的第三方代码。
把代码粘贴进来,或者打开一个 .js 文件。页面会重新排版、尽量解码,并列出改动了什么。如果是 webpack 或 browserify 打包,还会列出里面的模块,可以打包成 .zip 下载。代码不会上传,你粘贴的代码也不会被执行。
能做什么
输入。 把代码粘贴到输入框里,或者把 .js、.mjs、.cjs 文件拖进去,也可以点按钮选文件。上限 5 MiB。解析器能从不少语法错误里恢复,所以被截断的文件通常也能处理。
转换。 核心是 webcrack 2.16.0(MIT),底层是 Babel。有四个开关:
- 解开字符串数组。 obfuscator.io(也就是
javascript-obfuscator这个包)会把字符串放进一个数组,启动时不断旋转这个数组直到校验值对上,再通过解码函数取出每个字符串,解码时可能还套了一层 Base64 或 RC4。webcrack 会找到这个数组、旋转函数和解码函数,连那些带着偏移量去调用解码函数的小包装函数也能认出来。解码函数随后在本页自带的求值器里运行(见下文),每一处调用都换成明文字符串。 - 重命名
_0x…和解构出的变量。 像_0x4e2a1b这样的名字会换成能看出用途的短名字:参数叫p,函数叫f,一开始被赋值为字符串"demo-user"的变量叫vLSDemoUser。从对象里解构出来的值(const { formatPrice: i } = require(947))会直接用回属性名:const { formatPrice } = …。 - 拆分 webpack / browserify 打包。 webpack 4、5 的打包文件,webpack 的 chunk 文件(
self.webpackChunk… .push(…))和 browserify 打包,都会按模块拆成单独的文件,require(id)改成指向./id.js。browserify 保留了原始文件名(./cart、./format),这些也能还原。 - 还原 JSX。 把
React.createElement(…)和jsx(…)调用变回 JSX 标签。
其余的处理总是会做:重新排版,!0 变成 true,void 0 变成 undefined,a["b"] 变成 a.b,逗号表达式拆成多条语句,被当作语句用的三元表达式和 && 链改写成 if,折叠数字和字符串表达式,并去掉 obfuscator.io 的自我保护(self-defending)和反调试(debug protection)代码。控制流平坦化(while(true) 加 switch 的分发结构)、代理函数对象和死代码,是在 webcrack 处理字符串数组的同一步里还原的,所以只有找到了字符串数组、并且开着解开字符串数组时才会处理。
输出。 可读的代码,带 复制代码 和 下载 .js 按钮;一段处理结果(前后的大小和行数、解开了多少个字符串、处理前后各有多少个不同的 _0x 名字、打包格式和模块数);如果是打包文件,还有模块列表,每个模块可以单独预览、下载,也可以 把模块打包成 .zip 下载。
实测例子
本页的例子都来自我们自己的测试样本:一个三个文件组成的购物车小程序,先用 esbuild 打包,再用 javascript-obfuscator 5.9.0 的 high-obfuscation 预设混淆(去掉了 debugProtection 和 disableConsoleOutput,否则测试没法跑)。输入的开头是这样的:
(function(_0x567fa5,_0x462189){var _0x30f486=_0x567fa5();function _0x4c9d0a(_0x39c21a,_0x45f13e,_0x327a90,_0x37d2ce,_0x592a53){return _0xc5f8(_0x45f13e-0x1f2,_0x37d2ce);} …
一共 52,088 字节、2 行,942 个不同的 _0x 名字,所有字符串都经过 RC4 加密。输出是 2,891 字节、116 行:353 处解码调用换成了字符串,一个 _0x 名字都没剩。结果里的一个模块:
var vC = class {
constructor(p11) {
this.owner = p11;
this.items = [];
}
addItem(p12, p13, p14 = 1) {
if (p14 <= 0) {
throw new Error("quantity must be positive");
}
…
receipt() {
var v8 = this.items.map(p15 => p15.quantity + " x " + p15.name + " = " + formatPrice(p15.unitPrice * p15.quantity));
v8.push("TOTAL " + formatPrice(this.totalCents()));
return v8.join("\n");
}
};
方法名、字符串和 formatPrice 都回来了;参数名(p12、p13、p14)没有,因为混淆器根本没保留它们。我们的测试会在 Node 里把原始程序和反混淆后的程序各跑一遍,确认两者打印的内容完全一致。覆盖的样本包括这个文件、default 预设、terser 和 esbuild 压缩的版本,以及同一个程序的 webpack 5 和 browserify 打包。
耗时(Node 22,Apple 芯片笔记本实测):52 KB 的高强度混淆文件大约 1 秒,大部分时间花在旋转循环寻找校验值时反复做 RC4 解码;同一台机器上用无头 Chromium 打开本页,页面显示耗时 0.8 秒。普通的压缩文件快得多:用 esbuild 压缩的 TypeScript 编译器(3.6 MB)6.5 秒处理完,输出 5.6 MB、181,896 行。Babel 会把整棵语法树放在内存里,所以输入上限定在 5 MiB。
不执行代码,怎么解开字符串
解字符串数组的常规做法是直接运行混淆器自带的解码函数,webcrack 的命令行版本就是在 isolated-vm 沙箱里跑的。网页要这么做只能用 eval 或 new Function,本站的内容安全策略不允许,也不应该允许:这段代码来自你并不信任的文件。
所以本页自带一个小型求值器。webcrack 只把字符串数组函数、旋转函数和解码函数剪出来,求值器沿着它们的语法树逐步计算出结果。这段代码只能接触它自己创建的值,外加一小组没有副作用的内置函数:parseInt、String.fromCharCode、decodeURIComponent、RegExp#test、Math,以及常用的字符串、数组和数字方法。它碰不到 window、self、fetch、定时器、postMessage、真正的原型对象和 constructor。步数上限(5000 万步求值)、按内置函数读写的字符串和数组大小计费的工作量上限(1 亿个字符或元素)、调用深度上限以及字符串和数组的大小上限,能挡住失控的循环和内存膨胀。正则表达式只允许 obfuscator.io 自我保护代码用的那一个,而且只对最多 512 个字符做匹配,构造出来的恶意正则没法把页面卡死。解出来的字符串加起来超过输入的 4 倍(或 1 MiB)时不会内联。函数转成字符串时得到的是它自己的源码,和浏览器里一样,因为 obfuscator.io 的自我保护代码会拿函数源码去匹配一个正则表达式。
如果解码函数用到了这个子集以外的写法(比如展开运算符、对内置对象用 new、obfuscator.io 自带那一个以外的正则表达式),求值会停下,字符串保持原样,处理结果里显示 发现了,但没有解开。排版、重命名和拆包照常完成;控制流对象、switch 分发结构和死代码会保留,因为 webcrack 是在处理字符串的同一步里还原它们的。你也可以用第一个开关直接关掉解码。
代码怎么保证不外传
你的代码不会上传:
- 解析、转换、字符串解码、重命名、拆包和生成
.zip都在你设备上的 Web Worker 里完成。代码从不执行,在 worker 里也不执行:上面那个求值器不用eval就能算出解码结果。 - 代码、字符串和模块名都以纯文本显示,输入里的任何内容都不会被当作 HTML 解析。
- 统计事件只有粗粒度的信息:成功或错误码、代码是粘贴的还是以文件打开的、字符串解码状态(
decoded、none、failed或off)、打包格式、模块数量区间、大小区间和耗时区间。不包含代码、字符串、文件名或模块名。完整列表见隐私政策。 - 工具页面不展示广告。
如何自己验证
- 粘贴代码之前,先打开浏览器开发者工具,切到 Network(网络) 面板。反混淆一段代码、复制结果、下载
.zip。你只会看到页面资源和 worker 脚本/_astro/jsdeob-worker-*.js的请求,没有一个带着你的代码。 - 或者先打开页面,再断网(关掉 Wi-Fi,或者在 Network 面板勾选 Offline),然后运行,依然可以正常工作。
- worker 运行在内容安全策略
default-src 'none'; script-src 'self'; connect-src 'none'之下。这里没有'unsafe-eval',即使有代码想用eval或new Function,浏览器也会拦下;worker 也发不出任何网络请求。
局限
- 原来的局部变量名找不回来。 压缩器和混淆器已经把它们丢掉了,文件里没有任何线索。工具只能给出占位名。如果网站发布了 source map,用 source map 还原工具能直接拿到原始文件。
- 字符串解码只支持一个子集。 我们的样本覆盖了 obfuscator.io 的
none、base64和rc4三种编码,以及下标偏移、包装函数和数组旋转。其他混淆器可能用到求值器拒绝的写法,那时字符串保持原样。 - 不处理虚拟机类混淆。 把代码编译成自定义字节码、再附带一个解释器的混淆方式(JScrambler 较激进的模式、各种自制 VM),这里还原不了。
- 作用域提升过的打包只能是一个文件。 Rollup、Vite 和 esbuild 的生产构建会把模块合并进共享作用域,已经没有模块边界可拆。只有 webpack 和 browserify 会保留模块表。
- 自我保护代码。 对开启了
selfDefending的 obfuscator.io 输出关闭字符串解码后,重新排版的代码一运行就可能卡死,因为那段检查就是在检测代码有没有被格式化。只读,别运行。 - 输入大小。 5 MiB,处理时间上限 3 分钟。
常见问题
输出的代码一定和原来行为一致吗?
没有工具能对任意输入打这个包票,但每一步转换的目标都是不改变行为,我们的测试也会把每个样本的两个版本都跑一遍来核对。把输出当作阅读材料;真要运行,请运行原始代码。
为什么会出现 vLSHttpsapiexamplecomv1 这样的名字?
重命名时会参考变量第一次被赋的值:vLS… 表示字符串字面量(这里是 "https://api.example.com/v1/orders"),vLN0 是数字,vO 是对象,vC 是类,f 是函数,p 是参数。如果你想和原文件对照,可以关掉重命名,保留 _0x 名字。
处理结果显示“发现了,但没有解开”,怎么办?
说明解码函数用到了求值器不支持的写法(括号里写了原因)。解码函数和它的调用都原样留在输出里,你仍然能看出哪个调用对应哪个字符串。命令行版的 webcrack 会在 isolated-vm 里运行解码函数,在你自己的机器上也许能解开。
能反混淆 Hermes 字节码、WebAssembly 或 Python 吗?
不能,只处理 JavaScript 源码。WebAssembly 请用 WASM 分析工具。
合规
请只对你自己的代码、你获得授权审查的代码,或许可证允许的开源和第三方脚本做反混淆。读懂代码在做什么,不等于有权复制它或绕开它的许可证;有些许可证和法律会限制逆向工程。参见免责声明。
相关教程
- JS bundle 与 source map 还原:bundle 带着 source map 时,直接还原原始文件和变量名,不必反混淆。
- JS 逆向实战:用 REA 还原 webpack 打包后的源码:source map、webpack chunk 恢复、Wakaru 拆包和命令行下的数据流追踪。
- 用 REA 追踪网站 API 调用:浏览器捕获、HAR 与前端 bundle:找出前端 bundle 里是哪段代码发出了某个请求。
- 用 REA 逆向分析 Electron 应用:ASAR、preload 与 IPC 全流程:Electron 应用把压缩过的 JavaScript 放在
app.asar里,用 ASAR 分析工具取出来,再放到这里读。 - javascript-obfuscator 混淆代码能还原多少?14 组参数实测:用 14 组混淆参数实测本工具,列出体积、解出的字符串数,以及哪些选项还原不了。