文档
/
实用方案
/

对 LLM 分析隐藏函数名

对 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);
}

在一个真实的代码库里成倍放大,您就会得到一串函数名,如 validateLicensedecryptPayloadprocessPaymentcheckSubscription。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 混淆后,validateLicensecheckExpiry 都会以名称形式留存在输出中。

之后 —— 名称被隐藏在 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,并通过一个无意义的跳板重新暴露它。