Nascondere i nomi delle funzioni all'analisi degli LLM
Il problema
Dopo aver attivato vmObfuscation: true ed eseguito l'offuscamento su un file contenente una funzione come
validateLicense, si nota che l'output offuscato contiene ancora il testo letterale validateLicense: il corpo è
scomparso, sostituito dal bytecode, ma il nome è rimasto bene in vista.
// 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);
}
Moltiplicando il fenomeno su una base di codice reale si ottiene un elenco di nomi di funzioni come validateLicense,
decryptPayload, processPayment, checkSubscription. A un LLM non serve violare il bytecode per capire che cosa fa il
programma: i soli nomi gli bastano per produrre un riepilogo accurato e sicuro del comportamento del modulo. Il bytecode è
opaco, l'indice dei contenuti no.
Perché l'offuscamento VM conserva questi nomi
Con vmTargetFunctionsMode: 'root' (valore predefinito), l'offuscatore trasforma in bytecode VM il corpo di ogni
funzione di primo livello, ma lascia deliberatamente invariato il nome. Dal punto di vista semantico, la dichiarazione di
una funzione di primo livello è un binding sullo scope circostante: per uno script si tratta dell'oggetto globale, per un
modulo del namespace del modulo. L'offuscatore non può rinominarla in sicurezza perché non ha modo di sapere chi altro vi
faccia riferimento: un altro bundle, uno <script> inline, un attributo HTML onclick="validateLicense(...)", una ricerca
dinamica window['validateLicense'] e così via.
Il compromesso adottato dall'impostazione predefinita è dunque: proteggere l'implementazione, preservare la superficie pubblica. Ciò mantiene intatta l'integrazione, ma significa anche fornire a un LLM un indice gratuito di tutti gli entry point.
Perché la cosa conta per il reverse engineering assistito da LLM
Un attaccante umano di fronte a qualche centinaio di righe di dispatch del bytecode di norma rinuncia. Un LLM, ricevuto lo stesso file, non tenterà nemmeno di attaccare il bytecode: leggerà i nomi, li incrocerà con i pochi letterali stringa visibili e produrrà qualcosa di simile a quanto segue:
Un simile riepilogo è sufficiente a un attaccante per pianificare un bypass mirato senza mai toccare la VM. La fuga di informazioni sono i nomi.
La soluzione: incapsulare il codice in una IIFE
Il modo più semplice e robusto per eliminare questa fuga di informazioni consiste nello spostare le funzioni sensibili un livello più in profondità nell'albero degli scope. Le funzioni dichiarate all'interno di un'altra funzione non sono di primo livello, per cui l'offuscatore può rinominarle liberamente e assorbirne le dichiarazioni nel bytecode come qualsiasi altra istruzione.
Una IIFE (Immediately-Invoked Function Expression) è il modo più leggero per farlo: aggiunge una sola funzione di incapsulamento, eseguita una volta sola, che non espone alcun nome.
Prima - nomi esposti
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();
}
});
Dopo l'offuscamento VM, sia validateLicense sia checkExpiry sopravvivono per nome nell'output.
Dopo - nomi nascosti dietro una 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();
}
});
})();
Ora entrambe le dichiarazioni di funzione risiedono nel corpo della IIFE. La IIFE stessa è l'unico costrutto di primo livello e, in quanto anonima, non ha alcun nome da esporre. Dopo l'offuscamento VM l'intero corpo — comprese tutte le dichiarazioni al suo interno — viene convertito in bytecode e codificato: nulla di ciò che si trova nella IIFE sopravvive come testo leggibile.
E se una funzione deve necessariamente essere globale?
Talvolta una funzione è realmente un entry point pubblico: un gestore di eventi inline, una callback JSONP, un hook di un SDK di terze parti. In tal caso si hanno due possibilità:
Esporre un sottile trampolino e mantenere la logica all'interno della IIFE. Dichiarare un piccolo wrapper globale il cui unico compito è richiamare l'implementazione racchiusa nella IIFE. Il nome del trampolino continua a essere visibile, ma non veicola alcuna informazione semantica — è sufficiente chiamarlo
__entry1o simile — mentre tutta la logica significativa resta nascosta.var __entry1; (function () { function validateLicense(token) { /* … */ } __entry1 = validateLicense; })(); // Outside code calls __entry1(token) instead of validateLicense(token).Riscrivere il punto di chiamata che non si controlla. Se la variabile globale esiste soltanto perché serve a un
onclick="validateLicense(...)"inline, è sufficiente sostituire il gestore inline conaddEventListenerrichiamato dall'interno della IIFE. L'HTML smette di nominare la funzione, la funzione smette di dover essere globale e la fuga di informazioni scompare del tutto.
Inizializzatori di variabili di primo livello: vmWrapTopLevelInitializers
Le dichiarazioni di funzione non sono l'unico elemento presente alla radice di un file. Anche gli inizializzatori delle
variabili di primo livello — costanti stringa, oggetti di configurazione, tabelle di lookup — risultano per impostazione
predefinita altrettanto leggibili nell'output. Una riga come const API_BASE = '/api/v2/license' dice a un LLM quanto gli
dice function validateLicense.
L'opzione vmWrapTopLevelInitializers (booleana, predefinita a false) incapsula in una IIFE gli inizializzatori di primo
livello idonei, cosicché il valore stesso venga calcolato a runtime dal bytecode della VM anziché comparire nel sorgente
come letterale.
Senza l'opzione
// Input
const MY_STRING = 'my-string';
// Output - string is visible
const MY_STRING = 'my-string';
Con 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);
})();
Il nome del binding (MY_STRING) resta di primo livello per la stessa ragione per cui lo restano i nomi delle funzioni —
qualcosa all'esterno del file potrebbe farvi riferimento — ma il valore che contiene viene ora prodotto dalla VM e non
compare più come testo leggibile.
Quando tutto questo non basta
- Nomi importati da altri moduli. Se si effettua il bundling di più file e un modulo esporta
validateLicenseaffinché un altro possa importarlo, il bundler manterrà quel nome visibile nell'output finale, esattamente come accade per le funzioni di primo livello. In tal caso occorre incapsulare in una IIFE il bundle stesso (la maggior parte dei bundler è in grado di farlo) oppure spostare l'export all'interno di una IIFE e riesporlo tramite un trampolino privo di significato.
