文档
/

故障排查

/

eval 与 new Function 下的 VM 混淆

eval 与 new Function 下的 VM 混淆

VM 混淆如何处理动态代码构造(直接 eval 和 Function 构造器)、哪些代码会被编译为字节码、哪些会被跳过、混淆器会发出哪些警告,以及如何诊断运行时的 ReferenceError。

为什么这很重要

VM 混淆会把函数体编译为字节码,由运行时内置的解释器进行调度。外围作用域中的标识符也会被重命名。这两种变换都与运行时由字符串构建的代码相冲突:eval(s)、new Function(...s) 和 Function(...s)。如果运行时构建的代码引用了被混淆器重命名的标识符,那么生成的函数首次运行时就会出现 Uncaught ReferenceError: <renamed-name> is not defined。

混淆器对每种写法的处理方式各不相同。下表是简要说明,本页其余部分会逐行解释。

混淆器的处理方式一览

源码中的写法结果
eval('literal string')(代码体是字符串字面量)正常运行。包含该调用的函数,以及其中定义的每个函数,都会失去 VM 字节码化(直接 eval 会读取外围的局部变量,而函数一旦被编译为字节码,VM 就不再保留这些变量)。
eval(dynamicExpression)可能在运行时因 ReferenceError 而崩溃。 包含该调用的函数,以及其中定义的每个函数,也都会失去 VM 字节码化。
(0, eval)(s) / window.eval(s)(间接)包含该调用的函数照常被 VM 字节码化。间接 eval 在全局作用域中运行,看不到外围的局部变量,因此被重命名的局部变量不会破坏它。但如果被求值的代码引用了已被重命名或移除的全局变量,它仍然可能失败。
new Function('a', 'b', 'return a + b')(所有参数都是字符串字面量)正常运行。包含该调用的函数照常被 VM 字节码化。
new Function(dynamicBody) / Function(dynamicBody)可能在运行时因 ReferenceError 而崩溃。 包含该调用的函数,以及其中定义的每个函数,也都会失去 VM 字节码化。

编译器还会保守地检测 eval?.(code),并按直接 eval 处理。JavaScript 将这种可选调用形式定义为间接 eval;编译器的检测方式并不会改变这一语言行为。

只有其中一部分写法会产生非致命警告,而且只在调用位于函数内部时才会产生:动态的 eval、new Function 或 Function 调用会同时报告 DynamicCodeRenameRisk 和 VMDynamicCodeSkipped,静态的 eval('...') 只报告 VMDynamicCodeSkipped,间接 eval 和完全静态的 new Function 则什么都不报告。位于文件顶层、不在任何函数内的动态调用永远不会被报告。警告类型和 CI 代码片段请参阅下文的在运行前发现问题。

跳过会沿 IIFE 级联。 如果一个顶层 IIFE 包裹了整个包,而其中任何一个函数使用了动态 eval 或 new Function,整个 IIFE 都会被排除在 VM 字节码化之外。关于用于限制影响范围的 IIFE 拆包写法,请参阅直接 eval 的行为。

为什么静态与动态区别对待

eval(s) 可以读写调用它的函数中的局部变量。当 s 是字符串字面量时,混淆器可以在混淆时解析代码体,并与外围代码保持一致地重命名标识符。当 s 是动态表达式时,解析只会在运行时发生,而此时标识符早已被重命名,于是运行时构建的源码引用的是已经不存在的旧名称。

new Function(s) 和间接 eval 的机制不同:代码体总是在全局作用域中运行,只能访问全局变量,永远访问不到调用处周围的局部变量。它们不会依赖被重命名的局部变量,但如果引用了已被重命名或移除的全局变量,仍然可能失败;如果您通过拼接被重命名的标识符来构建代码体(例如对一个内部已被混淆器改写的函数调用 func.toString()),同样可能失败。

只使用自身参数的静态代码体,比如 new Function('a', 'b', 'return a + b'),不存在这种依赖:代码体只是一个重命名器从不检查的普通字符串。混淆器会保留该调用,外围函数仍然可以被 VM 字节码化。仅仅改变 eval 的写法,并不能让任意动态代码变得安全;请检查警告并测试最终的包。

运行时看到的错误

常见症状是动态构建的函数首次运行时出现 ReferenceError:

Text

这里的 TU 是混淆器在包的 IIFE 作用域内引入的重命名标识符。动态 eval / Function 构造器调用所求值的代码体引用了它,但该代码体运行所在的作用域中 TU 并未定义。

在运行前发现问题

混淆器会通过 API 发出非致命警告,让您可以在发布前于 CI 中发现其中一部分写法。相关的警告类型有两种,并且都只针对函数内部的调用报告:

  • DynamicCodeRenameRisk:某个函数在运行时由字符串构建代码:直接 eval,或代码体不是静态的 new Function / Function 调用,或把 fn.toString() 注入到 <script> 或 Worker 中。这是预示 ReferenceError 的警告。
  • VMDynamicCodeSkipped:某个函数及其中定义的每个函数都被跳过了 VM 字节码化,因为它包含直接 eval 或动态的 new Function / Function 调用。对于安全的静态 eval('literal') 它同样会触发,因此它表示失去了 VM 保护,而不是运行时崩溃。包含函数名(如果可获取)以及触发跳过的结构。

javascript-obfuscator npm 包不会在其结果中暴露警告,因此 CI 检查需要从 API 响应中读取它们:result 和 chunk_end 消息带有一个 warnings 数组。下面的示例使用 API 参考中的流读取函数 readObfuscationResponse(),并且只在出现 DynamicCodeRenameRisk 时让构建失败;如果某个函数失去 VM 保护也应当阻止发布,请把 VMDynamicCodeSkipped 加入过滤条件。

Code

如果某种警告类型在您的构建中是预期之内的,而您更希望在源头上将其静默,而不是在 CI 中过滤,可以使用 warnings 选项(v7.8.0+)控制会发出哪些警告:'none' 会屏蔽全部警告,而像 { VMDynamicCodeSkipped: false } 这样按类型设置的映射只会屏蔽某一种类型,其余保持不变。

解决方法

  • 改用间接 eval((0, eval)(s))

    仅适用于 eval 的情况。间接 eval 在全局作用域中运行,因此看不到外围的局部变量,也正因为如此,它也无法引用被重命名的局部变量。包含该调用的函数仍会被 VM 字节码化。被求值的代码仍然不能依赖被混淆器重命名的全局变量。

  • 让代码体完全静态

    对于 new Function,如果能把代码体写成单个不含插值的字符串字面量或模板字面量,该调用就不存在重命名风险,其外围函数也仍会被字节码化。new Function('a', 'b', 'return a + b') 没有问题;new Function('return ' + expr) 则不行。

  • 把调用移到独立的顶层函数中,并拆开 IIFE

    每个顶层函数都会被单独检查。把动态代码调用提取到一个独立的顶层函数中,就只有这一个函数会失去 VM 字节码化,而不会让跳过沿着包裹整个包的顶层 IIFE 级联。

  • 改用 vmTargetFunctionsMode: 'comment'

    这是显式选择加入的模式:只有标记了 /* javascript-obfuscator:vm */ 的函数才会被字节码化。不要为包含动态代码调用的函数添加注释,只标记其余函数。请确保这些注释一直保留到混淆步骤。参见定向混淆函数。

  • 使用 vmForceCompileDynamicCode: true 覆盖跳过行为(v6.14.0+)

    这是最后手段。启用后,混淆器仍会对外围函数进行字节码化,并屏蔽 VMDynamicCodeSkipped 警告。它无法修复作用域或对重命名标识符的依赖:只有在您能保证运行时构建的代码体绝不引用被混淆器重命名的标识符时才应使用它,否则您只是用一次干净的混淆换来了运行时的 ReferenceError。DynamicCodeRenameRisk 仍会触发,因此 CI 可以继续以它作为门禁。在控制台中,它是 VM 部分 Overrides 分组下的 "Force Compile Dynamic Code" 开关。

相关页面