Nascondere i nomi delle funzioni all'analisi degli LLM
Il problema
Hai attivato vmObfuscation: true, l'hai eseguito su un file che contiene una funzione come validateLicense e hai notato che
l'output offuscato contiene ancora il testo letterale validateLicense: il corpo è sparito, sostituito dal bytecode, ma
il nome in sé è lì, in bella vista.
Moltiplica questo per una base di codice reale e otterrai un elenco di nomi di funzioni come validateLicense, decryptPayload,
processPayment, checkSubscription. Un LLM non ha bisogno di decifrare il bytecode per capire cosa fa il programma:
i nomi da soli gli bastano per produrre un riepilogo sicuro e accurato del comportamento del modulo. Il bytecode è
opaco; l'indice no.
Perché l'offuscamento VM conserva questi nomi
Con vmTargetFunctionsMode: 'root' (il valore predefinito), l'offuscatore trasforma in bytecode della VM il corpo di ogni funzione di primo livello,
ma lascia deliberatamente invariato il nome. Una dichiarazione di funzione di primo livello è, dal punto di vista semantico, un
binding nello scope circostante: per uno script si tratta dell'oggetto globale, per un modulo dello scope del modulo
(una funzione di primo livello non esportata ha scope di modulo, non globale, ma resta comunque di primo livello nel file). L'offuscatore non può rinominarla in sicurezza perché non ha modo di sapere chi altro vi fa riferimento: un altro bundle,
uno <script> inline, un attributo HTML onclick="validateLicense(...)", una ricerca dinamica window['validateLicense']
e così via.
Il compromesso del comportamento predefinito è quindi: proteggere l'implementazione, preservare la superficie pubblica. In questo modo le integrazioni
continuano a funzionare, ma un LLM ottiene anche un indice gratuito di tutti i punti di ingresso. Quando succede, il risultato dell'offuscamento
segnala un avviso VMGlobalFunctionNamesNotRenamed che elenca i nomi rimasti leggibili.
Perché è importante per il reverse engineering assistito da LLM
Un attaccante umano davanti a qualche centinaio di righe di dispatch del bytecode di solito si arrende. Un LLM a cui viene dato lo stesso file non si prende nemmeno la briga di attaccare il bytecode: legge i nomi, li incrocia con i pochi letterali stringa che riesce a vedere e produce qualcosa del genere:
"Questo modulo controlla l'accesso a una funzionalità a pagamento. validateLicense verifica un token firmato, checkExpiry rifiuta le licenze
scadute e activateFeature sblocca l'interfaccia una volta superato il controllo. L'helper di decifratura si trova in
decryptPayload."
Questo riepilogo basta a un attaccante per pianificare un aggiramento mirato senza mai toccare la VM. La fuga di informazioni sono i nomi.
La soluzione: racchiudere il codice in una IIFE
Il modo più semplice e robusto per eliminare questa fuga di informazioni è 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, quindi l'offuscatore è libero di rinominarle e di assorbire le loro dichiarazioni nel bytecode come qualsiasi altra istruzione.
Una IIFE (Immediately-Invoked Function Expression) è il modo più leggero per farlo: aggiunge un'unica funzione contenitore che viene eseguita una sola volta e non espone nulla per nome.
Prima: nomi esposti
Dopo l'offuscamento VM, sia validateLicense sia checkExpiry sopravvivono per nome nell'output.
Dopo: nomi nascosti dietro una IIFE
Ora entrambe le dichiarazioni di funzione si trovano nel corpo della IIFE. La IIFE stessa è l'unico costrutto di primo livello e una IIFE anonima non ha alcun nome da rivelare. Dopo l'offuscamento VM i nomi delle funzioni vengono rinominati e i corpi vengono convertiti in bytecode.
Cosa è cambiato. Le funzioni non sono più raggiungibili come variabili globali (ed è questo che ne rivelava i nomi). Sono
ancora richiamabili, ma solo dall'interno della stessa IIFE. Se qualcosa aveva davvero bisogno di chiamare validateLicense dall'esterno,
ora non può più raggiungerla; vedi più avanti il pattern del trampolino.
Se nulla lo faceva, non hai perso niente.
I nomi esportati, gli identificatori riservati, le stringhe e il comportamento osservabile possono restare visibili. Testa le integrazioni dopo aver aggiunto la IIFE, in particolare le variabili globali, gli export dei moduli e il codice che esamina i nomi delle funzioni.
Un compromesso da conoscere: se il corpo della IIFE contiene un eval diretto, oppure una chiamata new Function(...) / Function(...)
con un corpo dinamico, l'offuscamento VM esclude l'intera funzione e tutto ciò che vi è annidato (con un avviso VMDynamicCodeSkipped), perché il
sorgente costruito durante l'esecuzione potrebbe fare riferimento a identificatori rinominati dall'offuscatore. Il codice che hai appena spostato nella IIFE
ripiega quindi sull'offuscamento ordinario e perde la protezione del bytecode. Usa l'eval indiretto, (0, eval)(...), oppure consulta
L'eval() diretto disattiva l'offuscamento VM per le alternative.
E se una funzione deve davvero essere globale?
A volte una funzione è davvero un punto di ingresso pubblico: un gestore di eventi inline, una callback JSONP, un hook di un SDK di terze parti. Hai due possibilità:
Esporre un sottile trampolino e mantenere la logica all'interno della IIFE. Dichiara un piccolo wrapper globale il cui unico compito è chiamare l'implementazione nello scope della IIFE. Il nome del trampolino resta visibile, ma non porta alcuna informazione semantica (chiamalo
__entry1o in modo simile) e tutta la logica significativa resta nascosta.Riscrivere il punto di chiamata. Se la variabile globale esiste solo perché un gestore inline
onclick="validateLicense(...)"ne ha bisogno, sostituisci il gestore inline conaddEventListenerdall'interno della IIFE. L'HTML smette di nominare la funzione, la funzione non ha più bisogno di essere globale e la fuga di informazioni scompare del tutto.
Inizializzatori di variabili di primo livello: vmWrapTopLevelInitializers
Le dichiarazioni di funzione non sono l'unica cosa che si trova al livello più alto di un file. Gli inizializzatori di variabili di primo livello (costanti
stringa, oggetti di configurazione, tabelle di lookup) sono altrettanto leggibili nell'output quando restano semplice JavaScript.
Una riga come const API_BASE = '/api/v2/license' dice a un LLM tanto quanto function validateLicense.
L'opzione vmWrapTopLevelInitializers (booleana, predefinita false; gli attuali preset VM la attivano) racchiude in una IIFE gli inizializzatori
di primo livello idonei, così che il valore stesso venga calcolato dal bytecode della VM durante l'esecuzione invece di restare nel
sorgente come letterale. In modalità root, gli inizializzatori che restano in semplice JavaScript vengono segnalati con un
avviso VMTopLevelInitializerNotVirtualized.
Senza l'opzione
Con vmWrapTopLevelInitializers: true
Il nome del binding (MY_STRING) resta di primo livello per lo stesso motivo dei nomi delle funzioni (qualcosa al di fuori del file
potrebbe farvi riferimento), ma il valore che contiene viene ora prodotto dalla VM e non compare più come testo leggibile.
È efficace solo quando vmTargetFunctionsMode è 'root' (il valore predefinito) e vmAsyncExecutor è disattivato. In modalità comment
l'opzione non ha effetto e non viene segnalato alcun avviso. In modalità root con vmAsyncExecutor (che virtualizza solo le funzioni
async) non ha comunque effetto, e la build segnala VMTopLevelInitializerNotVirtualized per gli inizializzatori rimasti
in JavaScript semplice.
Se un file non contiene alcuna funzione da virtualizzare per la VM, il risultato segnala VMNoFunctionsToVirtualize; racchiudi il codice che
vuoi proteggere in una funzione, oppure usa la modalità comment per selezionare esplicitamente le funzioni sensibili.
Quando non basta
- Nomi importati da altri moduli. Se esegui il bundling di più file e un modulo esporta
validateLicenseperché un altro lo importi, il bundler manterrà quel nome visibile nell'output del bundle, così come sono visibili le funzioni di primo livello. Racchiudi il bundle stesso in una IIFE (la maggior parte dei bundler può farlo), oppure sposta l'export all'interno di una IIFE e riesponilo tramite un trampolino dal nome privo di significato. - Il runtime resta osservabile. L'offuscamento aumenta lo sforzo necessario per comprendere e modificare il codice, ma non può garantire che il reverse engineering sia impossibile. Mantieni i segreti e le decisioni di sicurezza vincolanti sul server.
Non ricorrere per prima cosa a renameGlobals. Esiste e rinomina gli identificatori di primo livello, ma non ha modo
di sapere quali di questi identificatori vengono referenziati dall'esterno del file (altri bundle, HTML inline, ricerche
dinamiche). Attivarlo spesso rompe le integrazioni in modi sottili. Racchiudere il codice in una IIFE è più sicuro: non rinomina nulla
di globale, smette semplicemente di creare variabili globali di cui non avevi bisogno.
