对 LLM 分析隐藏函数名
问题
您启用了 vmObfuscation: true,对一个包含 validateLicense 这类函数的文件执行混淆,却发现混淆后的输出中仍然包含字面文本 validateLicense:函数体已经不见了,被替换成了字节码,但名称本身却明明白白地摆在那里。
在一个真实的代码库中,这种情况会成倍出现,最终得到一串函数名,比如 validateLicense、decryptPayload、processPayment、checkSubscription。LLM 无需破解字节码就能理解程序在做什么:仅凭这些名称,它就足以对模块的行为给出自信而准确的总结。字节码是不透明的,但“目录”不是。
VM 混淆为什么保留这些名称
在 vmTargetFunctionsMode: 'root'(默认值)下,混淆器会把每个根级函数的函数体转换为 VM 字节码,但会刻意保留其名称。从语义上讲,根级函数声明是外围作用域上的一个绑定:对脚本而言是全局对象,对模块而言是模块作用域(未导出的顶层函数属于模块作用域而非全局,但在该文件中它仍然是根级的)。混淆器无法安全地重命名它,因为它无从得知还有谁在引用它:另一个包、内联的 <script>、HTML 的 onclick="validateLicense(...)" 属性、动态的 window['validateLicense'] 查找,等等。
因此,默认行为所做的取舍是:保护实现,保留公开接口。这样集成不会被破坏,但也意味着 LLM 可以免费获得一份所有入口点的索引。出现这种情况时,混淆结果会报告 VMGlobalFunctionNamesNotRenamed 警告,列出仍然可读的名称。
这对 LLM 辅助的逆向工程为何重要
人类攻击者面对几百行字节码调度逻辑时,通常会选择放弃。而把同一个文件交给 LLM,它根本不会费力去攻击字节码:它会读取这些名称,与它能看到的少量字符串字面量相互对照,然后输出类似下面的内容:
“该模块用于控制一项付费功能。validateLicense 校验一个已签名的令牌,checkExpiry 拒绝已过期的许可证,而 activateFeature 在校验通过后解锁界面。解密辅助函数位于 decryptPayload 中。”
这样的总结已足以让攻击者规划一次有针对性的绕过,而完全不必触碰 VM。泄露正是来自这些名称。
解决方法:用 IIFE 包裹代码
消除这种泄露最简单、最可靠的方法,是把敏感函数在作用域树中再往下推一层。声明在另一个函数_内部_的函数不属于根级,因此混淆器可以自由地重命名它们,并像处理其他语句一样把它们的声明纳入字节码。
IIFE(立即调用函数表达式)是实现这一点最轻量的方式:它只增加一个运行一次、不以任何名称对外暴露的外层函数。
之前:名称暴露
经过 VM 混淆后,validateLicense 和 checkExpiry 的名称都会原样保留在输出中。
之后:名称隐藏在 IIFE 之后
现在,这两个函数声明都位于 IIFE 的函数体内。IIFE 本身是唯一的根级结构,而匿名 IIFE 没有可泄露的名称。经过 VM 混淆后,函数名会被重命名,函数体会被转换为字节码。
变化在哪里。 这些函数不再能作为全局变量访问(这正是它们名称泄露的原因)。它们仍然可以被调用,只是只能在同一个 IIFE 内部调用。如果确实有外部代码需要调用 validateLicense,它现在已经无法访问到该函数;请参阅下文的跳板模式。如果没有,您也没有任何损失。
导出的名称、保留的标识符、字符串以及可观察的行为仍可能是可见的。包裹之后请测试各项集成,尤其是全局变量、模块导出以及会检查函数名的代码。
需要了解的一个取舍: 如果 IIFE 函数体中包含直接的 eval,或函数体为动态内容的 new Function(...) / Function(...) 调用,VM 混淆会跳过整个函数及其内部嵌套的所有内容(并给出 VMDynamicCodeSkipped 警告),因为运行时构建的源码可能会引用混淆器已重命名的标识符。这样一来,您刚移入 IIFE 的代码会退回到普通混淆,失去字节码保护。请使用间接 eval,即 (0, eval)(...),或参阅 VM 混淆下的直接 eval 了解可选方案。
如果某个函数确实需要是全局的呢?
有时一个函数确实是公开入口点:内联事件处理器、JSONP 回调、第三方 SDK 的钩子。您有两种选择:
暴露一个轻量的跳板函数,把逻辑留在 IIFE 内部。 声明一个小型的全局包装函数,它唯一的作用就是调用 IIFE 作用域内的实现。跳板函数的名称仍会泄露,但它不携带任何语义信息(可以命名为
__entry1之类),而所有有意义的逻辑都保持隐藏。改写调用点。 如果这个全局函数之所以存在,只是因为内联的
onclick="validateLicense(...)"需要它,那么就在 IIFE 内部用addEventListener取代内联处理器。HTML 不再提及这个函数名,函数也不再需要是全局的,泄露便彻底消失。
顶层变量初始化表达式:vmWrapTopLevelInitializers
位于文件根级的不只有函数声明。顶层变量的初始化表达式(字符串常量、配置对象、查找表)如果保持为普通 JavaScript,在输出中同样清晰可读。像 const API_BASE = '/api/v2/license' 这样一行代码,给 LLM 提供的信息并不比 function validateLicense 少。
vmWrapTopLevelInitializers 选项(布尔值,默认为 false;当前的 VM 预设会启用它)会用 IIFE 包裹符合条件的顶层初始化表达式,使值本身在运行时由 VM 字节码计算得出,而不是以字面量的形式留在源码中。在 root 模式下,仍保持为普通 JavaScript 的初始化表达式会通过 VMTopLevelInitializerNotVirtualized 警告报告出来。
不启用该选项
启用 vmWrapTopLevelInitializers: true
绑定名称(MY_STRING)仍然位于根级,原因与函数名相同:文件外部的代码可能会引用它。但它所持有的_值_现在由 VM 生成,不再以可读文本的形式出现。
仅当 vmTargetFunctionsMode 为 'root'(默认值)且未启用 vmAsyncExecutor 时生效。在 comment 模式下,该选项不起作用,也不会报告警告。在 root 模式下启用 vmAsyncExecutor(只虚拟化异步函数)时,该选项同样不起作用,构建会针对仍保持为普通 JavaScript 的初始化表达式报告 VMTopLevelInitializerNotVirtualized。
如果文件中没有可供 VM 虚拟化的函数,结果会报告 VMNoFunctionsToVirtualize;请把要保护的代码包裹在一个函数中,或者使用 comment 模式显式选择敏感函数。
这样还不够的情况
- 从其他模块导入的名称。 如果您打包多个文件,且一个模块导出
validateLicense供另一个模块导入,打包工具会在打包输出中保留该名称,就像根级函数可见一样。请用 IIFE 包裹整个包(大多数打包工具都支持),或者把导出移入 IIFE 内部,再通过一个无意义的跳板函数重新暴露。 - 运行时仍然是可观察的。 混淆会增加理解和修改代码所需的工作量,但无法保证逆向工程不可能实现。请将机密信息和具有决定性的安全判断保留在服务端。
不要首先求助于 renameGlobals。 这个选项确实存在,也会重命名根级标识符,但它无从得知其中哪些标识符被文件外部引用(其他包、内联 HTML、动态查找)。开启它往往会以不易察觉的方式破坏集成。用 IIFE 包裹更安全:它不会重命名任何全局内容,只是不再创建您并不需要的全局变量。
