rea-reverse

javascript-obfuscator 混淆代码能还原多少?14 组参数实测

发布者 rea-reverse更新于 约 8 分钟读完
本页目录

一个 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 调用。

只对你自己的代码或获得授权审查的代码做反混淆,并遵守被分析代码的许可证,参见免责声明。

自己复现

  1. 在空目录里 npm i javascript-obfuscator,把你自己的脚本存成 src.js。
  2. 固定 --seed,加 --string-array-threshold 1,一次只改一组选项混淆,用 node out/x.js 确认输出没变。
  3. 把每个文件贴进反混淆工具,看结果摘要:解出的字符串数、前后的 _0x 名字数、体积和行数。如果显示“发现了,但没有解开”,括号里的原因会告诉你是求值器的哪项限制挡住了。

如果这段混淆脚本调用的是 protobuf 或 gRPC 接口,下一步就是读它发出的 body,见没有 .proto 怎么解 protobuf。