rea-reverse

JavaScript 反混淆与反压缩

粘贴压缩或混淆过的 JavaScript,或者打开一个 .js 文件,得到能读懂的代码:重新排版,解开 obfuscator.io 的字符串数组,撤销控制流和死代码混淆,替换 _0x 变量名;webpack 或 browserify 打包还能拆成一个个模块,打包成 .zip 下载。

代码不会离开你的浏览器. 代码在本地一个没有网络权限的 Web Worker 里转换,不会上传,你粘贴的代码也不会被执行。 如何自己验证

粘贴代码、把 .js 文件拖进这个框,或选择文件。最大 5 MiB。

选项

这个工具做不到的事

  • 压缩器丢掉的局部变量名(a、t、_0x4e2a)找不回来:工具只会换成简短的占位名,而不是原名。想拿回原名,只能靠 source map(如果有的话)。
  • 只有当解码函数落在一个很小、没有副作用的 JavaScript 子集里时,字符串才会被解开;否则保持原样,页面会注明。虚拟机类混淆(JScrambler、自定义字节码)不在处理范围内。

压缩过的 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)、打包格式、模块数量区间、大小区间和耗时区间。不包含代码、字符串、文件名或模块名。完整列表见隐私政策。
  • 工具页面不展示广告。

如何自己验证

  1. 粘贴代码之前,先打开浏览器开发者工具,切到 Network(网络) 面板。反混淆一段代码、复制结果、下载 .zip。你只会看到页面资源和 worker 脚本 /_astro/jsdeob-worker-*.js 的请求,没有一个带着你的代码。
  2. 或者先打开页面,再断网(关掉 Wi-Fi,或者在 Network 面板勾选 Offline),然后运行,依然可以正常工作。
  3. 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 分析工具。

合规

请只对你自己的代码、你获得授权审查的代码,或许可证允许的开源和第三方脚本做反混淆。读懂代码在做什么,不等于有权复制它或绕开它的许可证;有些许可证和法律会限制逆向工程。参见免责声明。

相关教程