Documentação
/
Receitas
/

Escondendo nomes de funções da análise por LLM

Escondendo nomes de funções da análise por LLM

O problema

Você ativou vmObfuscation: true, rodou a ofuscação em um arquivo que contém uma função como validateLicense e percebeu que a saída ofuscada ainda contém o texto literal validateLicense - o corpo sumiu, substituído por bytecode, mas o nome em si continua ali, à vista de todos.

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

Multiplique isso por uma base de código real e você terá uma lista de nomes de funções como validateLicense, decryptPayload, processPayment, checkSubscription. Um LLM não precisa quebrar o bytecode para entender o que o programa faz - só os nomes já bastam para que ele produza um resumo preciso e seguro do comportamento do módulo. O bytecode é opaco; o índice dele não é.

Por que a ofuscação VM mantém esses nomes

Com vmTargetFunctionsMode: 'root' (o padrão), o obfuscator transforma o corpo de cada função de nível raiz em bytecode da VM, mas deixa o nome deliberadamente intacto. Uma declaração de função de nível raiz é, semanticamente, um binding no escopo ao redor - em um script, isso significa o objeto global; em um módulo, o namespace do módulo. O obfuscator não pode renomeá-la com segurança porque não tem como saber quem mais a referencia: outro bundle, um <script> inline, um atributo HTML onclick="validateLicense(...)", uma busca dinâmica window['validateLicense'], etc.

Portanto, a contrapartida assumida pelo padrão é: proteger a implementação e preservar a superfície pública. Isso mantém a integração funcionando, mas também significa que um LLM ganha de graça um índice de todos os pontos de entrada.

Por que isso importa na engenharia reversa assistida por LLM

Um atacante humano diante de algumas centenas de linhas de dispatch de bytecode normalmente desiste. Um LLM que receba o mesmo arquivo nem vai se dar ao trabalho de atacar o bytecode - ele vai ler os nomes, cruzar os poucos literais de string que consegue ver e produzir algo assim:

Esse resumo já basta para um atacante planejar um bypass direcionado sem nunca tocar na VM. Os nomes são o vazamento.

A solução: envolva seu código em uma IIFE

A forma mais simples e robusta de eliminar esse vazamento é empurrar suas funções sensíveis um nível mais fundo na árvore de escopos. Funções declaradas dentro de outra função não são de nível raiz, então o obfuscator fica livre para renomeá-las e absorver suas declarações no bytecode como qualquer outra instrução.

Uma IIFE (Immediately-Invoked Function Expression) é a maneira mais leve de fazer isso - ela acrescenta uma única função wrapper que roda uma vez e não expõe nada por nome.

Antes - nomes expostos

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

Depois da ofuscação VM, tanto validateLicense quanto checkExpiry sobrevivem pelo nome na saída.

Depois - nomes escondidos atrás de uma 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();
        }
    });
})();

Agora as duas declarações de função vivem dentro do corpo da IIFE. A própria IIFE é a única construção de nível raiz, e uma IIFE anônima não tem nome para vazar. Depois da ofuscação VM, todo o corpo - incluindo cada declaração dentro dele - é convertido em bytecode e codificado; nada dentro da IIFE sobrevive como texto legível.

E se uma função realmente precisar ser global?

Às vezes uma função é mesmo um ponto de entrada público - um manipulador de eventos inline, um callback JSONP, um hook de um SDK de terceiros. Você tem duas opções:

  • Exponha um trampolim fino e mantenha a lógica dentro da IIFE. Declare um pequeno wrapper global cuja única tarefa é chamar a implementação com escopo na IIFE. O nome do trampolim ainda vaza, mas não carrega nenhuma informação semântica - chame-o de __entry1 ou algo parecido - e toda a lógica relevante continua escondida.

    var __entry1;
    (function () {
        function validateLicense(token) { /* … */ }
        __entry1 = validateLicense;
    })();
    // Outside code calls __entry1(token) instead of validateLicense(token).
  • Reescreva o ponto de chamada que você não controla. Se o global só existe porque um onclick="validateLicense(...)" inline precisa dele, substitua o manipulador inline por addEventListener de dentro da IIFE. O HTML deixa de nomear a função, a função deixa de precisar ser global e o vazamento desaparece por completo.

Inicializadores de variáveis de nível superior: vmWrapTopLevelInitializers

Declarações de função não são a única coisa que vive na raiz de um arquivo. Inicializadores de variáveis de nível superior - constantes de string, objetos de configuração, tabelas de consulta - são igualmente legíveis na saída por padrão. Uma linha como const API_BASE = '/api/v2/license' diz tanto a um LLM quanto function validateLicense diz.

A opção vmWrapTopLevelInitializers (booleana, padrão false) envolve em uma IIFE os inicializadores de nível superior elegíveis, de modo que o próprio valor passa a ser calculado pelo bytecode da VM em tempo de execução, em vez de ficar no código-fonte como um literal.

Sem a opção

// Input
const MY_STRING = 'my-string';

// Output - string is visible
const MY_STRING = 'my-string';

Com 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);
})();

O nome do binding (MY_STRING) continua sendo de nível raiz pelo mesmo motivo que os nomes de função são - algo fora do arquivo pode referenciá-lo -, mas o valor que ele guarda agora é produzido pela VM e não aparece mais como texto legível.

Quando isso não é suficiente

  • Nomes importados de outros módulos. Se você faz o bundle de vários arquivos e um módulo exporta validateLicense para outro importar, o bundler vai manter esse nome visível na saída empacotada, do mesmo modo que as funções de nível raiz ficam visíveis. Envolva o próprio bundle em uma IIFE (a maioria dos bundlers consegue fazer isso) ou mova a exportação para dentro de uma IIFE e a exponha novamente por meio de um trampolim sem significado.