对 LLM 分析隐藏函数名
问题
您启用了 vmObfuscation: true,对一个包含 validateLicense 之类函数的文件运行它,然后注意到
混淆后的输出仍然包含字面文本 validateLicense —— 函数体没了、被字节码取代,但
名称本身却明晃晃地摆在那里。
// Input
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
// Output - name is preserved, body is bytecode
function validateLicense(b) {
return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
}
在一个真实的代码库里成倍放大,您就会得到一串函数名,如 validateLicense、decryptPayload、
processPayment、checkSubscription。LLM 无需破解字节码就能理解程序在做什么 ——
仅凭这些名称,它就足以给出一份自信而准确的模块行为摘要。字节码是
不透明的;而这份“目录”并不是。
为什么 VM 混淆会保留这些名称
在 vmTargetFunctionsMode: 'root'(默认)下,混淆器会把每个根级函数的 函数体
转换为 VM 字节码,却有意保留其 名称。从语义上讲,根级函数声明是
对外围作用域的一次绑定 —— 对脚本而言那意味着全局对象,对模块而言那意味着模块
命名空间。混淆器无法安全地重命名它,因为它无从得知还有谁在引用它:另一个 bundle、
一个内联 <script>、一个 HTML onclick="validateLicense(...)" 属性、一次动态 window['validateLicense'] 查找,
等等。
因此默认所做的取舍是:保护实现,保留公共接口。这让集成 不被破坏,但也意味着 LLM 免费获得了一份每个入口点的索引。
为什么这对 LLM 辅助的逆向工程很重要
面对几百行字节码分派的人类攻击者通常会放弃。而拿到同一个文件的 LLM 根本不会去攻击字节码 —— 它会读取名称、交叉引用它能看到的少数字符串 字面量,然后给出类似这样的输出:
这份摘要足以让攻击者规划一次有针对性的绕过,而完全无需触碰 VM。名称就是泄露之处。
修复方法:把您的代码包进 IIFE
消除这种泄露最简单、最稳健的方法,是把您的敏感函数在作用域 树中往下推一层。声明在另一个函数 内部 的函数不是根级的,因此混淆器可以自由地重命名它们,并 像对待任何其他语句一样把它们的声明吸收进字节码。
IIFE(立即执行函数表达式)是做到这一点最轻量的方式 —— 它只增加一个运行一次、 且不以任何名称对外暴露内容的包装函数。
之前 —— 名称被暴露
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
function checkExpiry(license) {
return Date.now() < license.expiresAt;
}
document.querySelector('#activate').addEventListener('click', () => {
const token = document.querySelector('#token').value;
if (validateLicense(token)) {
unlockUI();
}
});
经过 VM 混淆后,validateLicense 和 checkExpiry 都会以名称形式留存在输出中。
之后 —— 名称被隐藏在 IIFE 之后
(function () {
function validateLicense(token) {
const decoded = decodeBase64(token);
return verifySignature(decoded);
}
function checkExpiry(license) {
return Date.now() < license.expiresAt;
}
document.querySelector('#activate').addEventListener('click', () => {
const token = document.querySelector('#token').value;
if (validateLicense(token)) {
unlockUI();
}
});
})();
现在两个函数声明都位于 IIFE 的函数体内部。IIFE 本身是唯一的根级构造,而 一个匿名 IIFE 没有名称可供泄露。经过 VM 混淆后,整个函数体 —— 包括其内部的每一个声明 —— 都会被 转换为字节码并编码;IIFE 内部没有任何内容会以可读文本的形式留存。
如果某个函数确实需要成为全局变量怎么办?
有时某个函数确实是一个公共入口点 —— 一个内联事件处理器、一个 JSONP 回调、一个第三方 SDK 钩子。您有两种选择:
暴露一个精简的跳板,把逻辑保留在 IIFE 内部。 声明一个小的全局包装器,它唯一的职责就是 调用 IIFE 作用域内的实现。跳板的名称仍会泄露,但它不携带任何语义信息 —— 把它命名为
__entry1之类 —— 而所有有意义的逻辑都保持隐藏。var __entry1; (function () { function validateLicense(token) { /* … */ } __entry1 = validateLicense; })(); // Outside code calls __entry1(token) instead of validateLicense(token).改写您无法控制的调用点。 如果这个全局变量仅仅因为某个内联
onclick="validateLicense(...)"需要它而存在,就从 IIFE 内部用addEventListener替换该内联处理器。 HTML 不再指名这个函数,函数也不再需要成为全局,泄露便彻底消失。
顶层变量初始化器:vmWrapTopLevelInitializers
函数声明并不是唯一位于文件根部的东西。顶层变量初始化器 —— 字符串
常量、配置对象、查找表 —— 在默认输出中同样可读。一行像
const API_BASE = '/api/v2/license' 这样的代码,向 LLM 透露的信息不亚于 function validateLicense。
vmWrapTopLevelInitializers 选项(布尔值,默认 false)会把符合条件的顶层初始化器包进一个 IIFE,使
值本身在运行时由 VM 字节码计算得出,而不是作为字面量摆在源码里。
不使用该选项
// Input
const MY_STRING = 'my-string';
// Output - string is visible
const MY_STRING = 'my-string';
使用 vmWrapTopLevelInitializers: true
// Input
const MY_STRING = 'my-string';
// Output - initializer is now a VM call, the string lives inside bytecode
const MY_STRING = (() => {
return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
})();
绑定名称(MY_STRING)仍是根级的,原因和函数名一样 —— 文件之外的某处
可能会引用它 —— 但它所持有的 值 现在由 VM 产生,不再以可读文本的形式出现。
当这还不够时
- 来自其他模块的导入名称。 如果您打包了多个文件,并且某个模块导出
validateLicense供另一个模块导入,打包工具就会像根级函数那样,把该名称保留在打包输出中可见。请把 bundle 本身包进一个 IIFE(大多数打包工具都能做到),或者把导出移入一个 IIFE,并通过一个无意义的跳板重新暴露它。
