javascript-obfuscator 混淆代码能还原多少?14 组参数实测
本页目录
一个 JavaScript 文件满屏都是 _0x4e2a1b 这种变量名和 a0_0x3a51(0x7d) 这种调用,十有八九是 javascript-obfuscator(obfuscator.io 背后的引擎)的产物。不管你是自己产品在用它,还是被要求审查一段用了它的脚本,都需要知道两件事:它的哪些选项能被反混淆工具撤销,哪些选项会让信息彻底丢失。
我们直接做了实验:写了一个 38 行的脚本,用 javascript-obfuscator 命令行按 14 组不同参数混淆,再把每个结果交给本站 JavaScript 反混淆工具的处理流程(webcrack 2.16.0,加一个从不调用 eval 的自制求值器)。下面是实测数字,没还原好的情况也照实写。
版本: javascript-obfuscator 5.9.0(命令行 --version 输出 5.9.0_2026-10-04T02:32:30.053Z),Node 22.22.3,macOS 27.0.1,Apple 芯片。混淆器装在仓库外的临时目录里(npm i javascript-obfuscator)。反混淆直接用本站源码在 Node 里通过 vitest 跑,和浏览器页面在 Web Worker 里加载的是同一份代码。
测试脚本
输入是我们自己写的代码:汇总一份阅读清单,拼一个 API 请求。里面有 16 个辨识度高的字符串(URL、请求头名、状态文字、书名),方便统计还原回来多少。
const API_BASE = 'https://books.example.org/api/v2';
const STATUS_LABELS = { todo: 'Not started', reading: 'In progress', done: 'Finished' };
function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function summarise(books) {
const counts = { todo: 0, reading: 0, done: 0 };
let pages = 0;
for (const b of books) {
if (!(b.status in counts)) throw new Error('unknown status: ' + b.status);
counts[b.status]++;
if (b.status === 'done') pages += b.pages;
}
return { counts, pages };
}
function buildRequest(user, books) {
const { counts, pages } = summarise(books);
return {
url: API_BASE + '/users/' + encodeURIComponent(user) + '/stats',
headers: { 'X-Client': 'reading-list/1.4', 'Content-Type': 'application/json' },
body: JSON.stringify({ counts, pages, slugs: books.map((b) => slugify(b.title)) }),
};
}
// …… 四本书,然后三次 console.log
源文件 1,541 字节,用 Node 运行输出 5 行。我们对这段输出取哈希,每个混淆版本和反混淆版本都拿来对比。
混淆
每组参数是一次命令行调用,固定 --seed 7,结果可复现。比如 RC4 这组:
javascript-obfuscator src.js --output out/d-rc4.js --seed 7 \
--string-array true --string-array-threshold 1 \
--string-array-rotate true --string-array-shuffle true \
--string-array-encoding rc4 \
--string-array-wrappers-count 2 --string-array-wrappers-type function \
--string-array-wrappers-chained-calls true
--string-array-threshold 1 让所有字符串都进数组;默认值 0.75 会随机留一部分字符串在原地。14 个混淆文件里,除了一个(后面单独说),运行输出都和原文件一样。
字符串数组是 obfuscator.io 的核心。下面是三种编码下数组的样子,截自各文件开头:
// encoding none
const _0x44280f=['https://books.example.org/api/v2','Not\x20started','In\x20progress', …
// encoding base64
const _0x415402=['CMvHzgLUzW','l3vZzxjZlW','C3rYAw5NAwz5','zg9Uzq','Ahr0Chm6lY9IB29RCY5LEgfTCgXLlM9YzY9HCgKVDJi', …
// encoding rc4
const _0x3a3fd8=['WRjAaSkXurxcNmk1WRDXWQSnWONdRZembK3dG8kXW7G0hSox','s8oVW5KAirVdVuHy', …
这里的 base64 用的不是标准字母表:Ahr0Chm6 把大小写互换一下就是 aHR0cHM6(“https:”),直接丢进 atob 只会得到乱码。RC4 的每次调用还要一个密钥,密钥写在调用处,不在数组里。再叠加数组旋转(启动时不停移位,直到一串 parseInt 算出的校验值对上)和下标偏移(a0_0x515e(_0x4fd581- -0x1cc, …)),文件里就没有任何一个值能直接对应到某个字符串。
结果
每一行都用工具的默认开关(解开字符串、重命名、拆包、JSX)跑 deobfuscate()。“还原字符串”是 16 个标记字符串里有几个以明文出现在输出中。耗时是 Node 里单次运行的结果。
| 混淆参数 | 混淆后 | 输出 | 内联的解码调用 | 还原字符串 | 耗时 |
|---|---|---|---|---|---|
default 预设 |
3,090 B | 1,569 B | 37 | 16/16 | 4 ms |
字符串数组,不旋转,none |
2,537 B | 1,569 B | 39 | 16/16 | 28 ms |
| + 旋转、打乱、下标偏移 | 3,262 B | 1,569 B | 49 | 16/16 | 10 ms |
+ base64,2 层函数包装 |
5,615 B | 1,569 B | 49 | 16/16 | 23 ms |
+ rc4,链式包装 |
7,276 B | 1,569 B | 49 | 16/16 | 107 ms |
rc4 + stringArrayCallsTransform |
8,170 B | 1,569 B | 49 | 16/16 | 92 ms |
rc4 + 控制流平坦化 + 死代码 + 拆分字符串 + 对象键变换 |
10,215 B | 1,592 B | 129 | 16/16 | 484 ms |
high-obfuscation 预设¹ |
30,706 B | 1,592 B | 231 | 16/16 | 223 ms |
| 数字转表达式 + unicode 转义 | 5,531 B | 1,569 B | 50 | 16/16 | 5 ms |
renameGlobals + mangled 命名 |
2,245 B | 1,469 B | 48 | 16/16 | 4 ms |
renameProperties |
3,277 B | 1,580 B | 49 | 14/16 | 4 ms |
¹ 去掉了 debugProtection 和 disableConsoleOutput,否则测试跑不起来。
除 renameProperties 那一行外,反混淆后的代码运行输出都和原文件完全一致。前六个字符串数组版本得到的输出文件逐字节相同;两个重度版本输出 1,592 字节,和它们相比只差占位变量名,外加 transformObjectKeys 留下的一处 const vO2 = {…}; return vO2;。30 KB 回到了 1.6 KB。
high-obfuscation 版本的输出(节选):
const API_BASE = "https://books.example.org/api/v2";
const STATUS_LABELS = {
todo: "Not started",
reading: "In progress",
done: "Finished"
};
function slugify(p) {
return p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function summarise(p2) {
const vVO = { todo: 0, reading: 0, done: 0 };
let vLN0 = 0;
for (const v5 of p2) {
if (!(v5.status in vVO)) {
throw new Error("unknown status: " + v5.status);
}
…
function buildRequest(p5, p6) {
const { counts, pages } = summarise(p6);
…
哪些保留下来了、为什么,值得看一下。API_BASE、slugify、buildRequest 名字还在,因为 javascript-obfuscator 默认不重命名顶层名字,除非加 renameGlobals。局部变量名就没了:title 成了 p,summarise 里的 counts 成了 vVO(variable,存的是 Object),pages 成了 vLN0(Literal Number 0)。buildRequest 里解构出来的 { counts, pages } 把名字拿回来了,因为属性名里带着它。正则字面量原样保留,混淆器不会编码正则。
加了 renameGlobals 之后,顶层名字也没了:输出开头是 const a0c = "https://books.example.org/api/v2" 和 function a0e(a)。文件里已经没有任何信息能把它们找回来。
控制流平坦化:用了 var 才出现 switch
我们本以为 --control-flow-flattening 会生成熟悉的 while (!![]) { switch (order[i++]) … } 分发结构,结果在这个脚本上,阈值调到 1 也没有出现,只多了这种代理函数对象:
const _0x6e32ef = {
'MJMig': function (_0x5cf084, _0x2540bb) { return _0x5cf084 in _0x2540bb; },
'cxEnR': function (_0x36f477, _0x1ebb73) { return _0x36f477 + _0x1ebb73; },
'haepE': 'unknown\x20status:\x20', …
我们的函数用的是 const 和 let,函数体也短。把其中一个函数改成用 var、函数体写长一些(src-var.js),分发结构就出现了。两处是一起改的,所以没法确定是哪一个起了作用:
}, _0x522111 = _0x505068[_0xc5c14b(0x142)][_0xc5c14b(0x14a)]('|'), _0x4c9118 = 0x0;
while (!![]) {
switch (_0x522111[_0x4c9118++]) {
case '0':
var _0x46a05f = '';
continue;
case '1':
return _0x3b80a3;
反混淆把语句按原顺序排了回去:输入 4,971 字节、146 行,输出 643 字节、28 行,没有 switch 残留,运行结果一致。
没想到的坑:没有字符串数组,控制流也不清理
接着我们开了控制流平坦化、关掉字符串数组(--string-array false),结果代理对象原封不动留在输出里:
for (const v2 of p2) {
if (!vO.MJMig(v2.status, vO2)) {
throw new Error(vO.cxEnR(vO.haepE, v2.status));
}
原因在 webcrack 本身。翻本站内置的 2.16.0 源码,deobfuscate 这一步找不到字符串数组就直接返回,而控制流对象、控制流 switch 和死代码这几个转换,恰好放在同一步的末尾:
if (!stringArray) return;
…
state.changes += applyTransforms(ast,
[merge_strings_default, dead_code_default, control_flow_object_default, control_flow_switch_default], …
在工具里关掉解开字符串数组也是一样:这一步需要求值沙箱,没有沙箱就整步跳过,后面的转换都不执行。对 rc4 + 控制流 + 死代码那个文件,关掉解码后输出是 10,503 字节、198 行,而不是 1,592 字节、72 行。实际中这种情况不多,因为 obfuscator.io 的产物几乎都带字符串数组(我们测试时,--dead-code-injection 配 --string-array false,字符串数组还是被打开了,结果里有 41 处解码调用)。但如果跑完后还能看到 obj.AbCdE(x, y) 这样的调用,原因就在这里。
还原不了的部分
renameProperties。 这个选项会在整个程序里重命名对象属性。在我们的脚本里,它改掉了 counts、slugs,连 HTTP 请求头名 X-Client 和 Content-Type 也改了:
headers: {
_0x522f87: "reading-list/1.4",
_0x3e21ad: "application/json"
},
混淆后的程序本身发出的就是 {"_0x31f331":{…},"pages":1009,"_0x25cb26":[…]},而不是 {"counts":…}。所以它的输出哈希和原文件对不上;反混淆后 16 个字符串少了 2 个,也是因为这两个字符串在文件里已经不存在了。反混淆后的代码和混淆后的代码行为完全一致,这也是反混淆工具能做到的极限。javascript-obfuscator 的 README 对这个选项的警告是 “MAY break your code”,而我们用的还是它默认的 safe 模式。
局部变量名、注释、格式。 混淆器直接丢掉,哪里都没存。如果是你自己的构建、手上有 source map,用 JS source map 还原工具能拿回真实名字,比任何反混淆都强。
对带自我保护的代码关掉字符串解码,运行会卡死
high-obfuscation 会开启 selfDefending,它会检查自己的函数有没有被重新排版。关掉字符串解码后,反混淆照样会重新排版,但解码函数留着。把这样的输出放进 Node 运行,跑不完:测试脚本在 5 秒上限处把它停掉(Script execution timed out after 5000ms)。工具页面也有这条提醒:这种输出拿来读,不要运行。
更大的文件和计算预算
为了摸到上限,我们混淆了一个开源库:lodash 4.17.21(lodash.js,544,098 字节,MIT 许可)。混淆器跑 default 预设用了 0.64 秒,high-obfuscation 用了 3.6 秒。
default预设,193,916 字节: 反混淆 840 ms。内联 1,245 处解码调用,不同的_0x名字从 3,253 个降到 4 个。输出通过了我们的检查(_.chunk、_.camelCase、_.template和_.VERSION结果都和原版一致)。剩下的 4 个是循环标签(_0x54a173: while (…)),重命名不处理标签。还有一些生成的名字里嵌着旧名,比如vV_0x1209d5。high-obfuscation,2,921,891 字节: 在 Node 里不设时间上限,跑了 815 秒后放弃了字符串:状态failed,原因step budget exceeded,解出 0 个字符串。求值器给每次解码求值 5,000 万步的预算,数组这么大时,旋转循环里的 RC4 解码超出了预算。输出排好了版、部分变量重命名了,但字符串还是编码状态;运行它又撞上了 20 秒超时(还是自我保护)。在浏览器里你看不到这 815 秒:页面 3 分钟就会停掉 worker。
所以,大文件加最重的预设,浏览器工具不是合适的选择。命令行版 webcrack 用 isolated-vm 运行解码函数,即在沙箱里真正执行 JavaScript,而不是走这个求值器;这次我们没有测它的耗时。只在你有权运行的代码上、在自己的机器上这么做。
REA 在哪一步
REA(morluto 的开源项目)处理前端代码是从打包结构入手:source map、webpack chunk 恢复,以及在你提供 Wakaru 可执行文件时拆分模块,流程见 JS 逆向实战:用 REA 还原 webpack 打包后的源码。我们在 6.7.0 的 npm 包(npm pack [email protected])里搜了一遍,没有找到针对 obfuscator.io 字符串数组的处理,所以碰到这类文件,先用上面的工具把字符串解出来,再把可读的输出交给那套流程,或者接着追踪网站 API 调用。
只对你自己的代码或获得授权审查的代码做反混淆,并遵守被分析代码的许可证,参见免责声明。
自己复现
- 在空目录里
npm i javascript-obfuscator,把你自己的脚本存成src.js。 - 固定
--seed,加--string-array-threshold 1,一次只改一组选项混淆,用node out/x.js确认输出没变。 - 把每个文件贴进反混淆工具,看结果摘要:解出的字符串数、前后的
_0x名字数、体积和行数。如果显示“发现了,但没有解开”,括号里的原因会告诉你是求值器的哪项限制挡住了。
如果这段混淆脚本调用的是 protobuf 或 gRPC 接口,下一步就是读它发出的 body,见没有 .proto 怎么解 protobuf。