Reverse engineering di JavaScript con LLM: quattro attività da affidare all’AI e tre da tenere in mano

Ultimo Aggiornamento: 2026-07-30 10:17:04

Apri un SDK di firma sfornato da webpack: variabili chiamate a o _0x3f2b, flusso di controllo appiattito, stringhe nascoste in un array da indicizzare. L’obiettivo è semplice solo sulla carta: ottenere un’implementazione standalone che produca esattamente gli stessi byte dell’originale.

Oggi la reazione istintiva è incollare l’intero bundle in chat e chiedere a un modello linguistico di spiegare cosa faccia. È quasi sempre il modo sbagliato per iniziare. Il modello restituisce un’analisi plausibile, la riscrittura sembra corretta, ma l’output finale non coincide con il target neppure di un byte.

Non è un limite assoluto del modello: è una cattiva ripartizione dei compiti. Nella deoffuscazione, un LLM eccelle nel riconoscimento di pattern e nella formulazione di ipotesi, mentre non è affidabile nella verifica fattuale. Può individuare la struttura di una primitiva crittografica sotto una montagna di operazioni bitwise; stabilire se l’ha riconosciuta davvero, però, spetta ai test.

Di seguito propongo un workflow in quattro fasi costruito attorno a questa separazione, oltre a tre attività che non delego mai al modello.

Fase 1: la scomposizione meccanica va automatizzata, non affidata al modello

La prima obiezione di solito è: “la finestra di contesto è enorme, tanto vale incollare tutto”. No, per due ragioni.

La prima è lo spreco. Gran parte di un bundle offuscato è composta da polyfill, shim del runtime e moduli applicativi che non c’entrano nulla con il bersaglio. Paghi per inserirli nel contesto e ricevi in cambio un’attenzione più dispersa.

La seconda è ancora più importante: più il contesto è ampio, più aumentano i punti in cui può attecchire un’allucinazione. Il modello può fondere caratteristiche di due moduli scollegati e arrivare a una conclusione coerente al proprio interno, ma inesistente nel codice. È un errore molto più insidioso di una risposta palesemente assurda.

La scomposizione è lavoro deterministico: svolgila con uno script.

  • Usa un tool AST (@babel/parser, acorn) per separare il bundle in moduli e funzioni, indicizzandoli per scope;

  • Estrai tutti i letterali numerici e le costanti stringa, raggruppandoli per frequenza e larghezza in bit;

  • Costruisci il call graph e marca i nodi con in-degree 0, cioè gli entry point, e out-degree 0, cioè le primitive foglia;

  • Individua le funzioni con una densità anomala di operazioni bitwise: ^, >>>, <<, & concentrati nella stessa funzione indicano spesso il nucleo algoritmico.

Le primitive foglia sono ciò che conviene davvero mostrare al modello. In genere occupano poche decine di righe, non dipendono dallo stato dei livelli superiori e hanno confini di input e output nitidi. Una funzione foglia di 40 righe con le relative costanti è una granularità che il modello gestisce in modo affidabile.

Al termine di questa fase dovresti avere una “lista di primitive candidate”: per ogni voce, corpo della funzione, costanti referenziate e punti da cui viene chiamata. Da qui in avanti, ogni chiamata al modello lavora su una sola voce della lista alla volta.

Fase 2: usa il modello per riconoscere la famiglia dell’algoritmo

Qui il modello è davvero difficile da sostituire.

Gli algoritmi crittografici e di codifica lasciano impronte molto riconoscibili: costanti specifiche, combinazioni precise di shift, particolari strutture di ciclo. Una persona le riconosce grazie all’esperienza accumulata; un modello ha visto implementazioni pubbliche di ogni tipo e tende a intercettarle con naturalezza.

Qualche esempio pubblico, utile per capire che aspetto abbia una “firma” algoritmica:

  • 0x811c9dc5 e 0x01000193 insieme corrispondono all’offset basis e al prime dell’hash FNV-1a a 32 bit: sono i valori standard riportati nella pagina di riferimento FNV curata dal coautore Landon Curt Noll, espressi lì in decimale come 2166136261 e 16777619;

  • 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 sono le word little-endian della stringa ASCII "expand 32-byte k": le costanti dello stato iniziale di ChaCha20, riportate in RFC 8439 §2.3;

  • una sequenza del tipo a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);, con rotazioni 16/12/8/7, è la firma del quarter round ChaCha20; il suo parente Salsa20 usa invece 7/9/13/18, e bastano quei quattro numeri per distinguerli;

  • una tabella di 64 costanti che inizia con 0xd76aa478 è la T-table di MD5 (RFC 1321 §3.4);

  • una tabella di 256 byte che comincia con 0x63, 0x7c, 0x77, 0x7b è la S-box di AES (FIPS 197, Tabella 4);

  • 0xEDB88320 è il polinomio CRC-32 riflesso, il valore usato nella specifica gzip, RFC 1952.

La differenza la fa tutta il modo in cui poni la domanda. Chiedere “cosa fa questo codice?” produce testo descrittivo. Serve invece un giudizio strutturato e verificabile, quindi il prompt deve obbligare il modello a fornire candidati, prove e controprove.

Below is a leaf function extracted from an obfuscated bundle, together with
every numeric constant it references.

<function>
{{function body}}
</function>

<constants>
{{constant list, with locations}}
</constants>

Answer in the following structure. Do not write prose.

1. Candidate algorithm families (at most 3, ranked by likelihood)
   For each: the name, and which class it belongs to
   (hash / stream cipher / block cipher / encoding / compression / checksum)

2. Supporting evidence
   Every piece of evidence must point to a specific constant value or a
   specific line above. "Structurally similar" and other unverifiable
   phrasing is not allowed.

3. Counter-evidence and deviation points
   If this were the standard implementation of that algorithm, what should
   appear here but doesn't? What appears that a standard implementation
   would never contain? Are these deviations a "variant," or "I got it wrong"?

4. The minimal test to decide this hypothesis
   Give 3 concrete inputs and, if the hypothesis holds, the shape of the
   output each should produce. Include boundary cases.

Il terzo punto è ciò che rende questo prompt utile. Le differenze rispetto all’implementazione standard coincidono spesso con le modifiche apportate al codice: un alfabeto personalizzato, una costante sostituita, un numero di round alterato. Sono anche le uniche parti su cui dovrai lavorare davvero nella riscrittura: il resto può essere recuperato dall’implementazione pubblica. Il modo per far emergere dal modello ogni singola deviazione è l’argomento centrale dell’articolo sul riconoscimento delle impronte algoritmiche.

Il quarto punto converte direttamente il giudizio del modello nei test da eseguire subito dopo, evitando un ulteriore giro di richieste.

In questa fase emergono spesso anche codifiche non standard. Il controllo è meccanico: un alfabeto lungo 65 caratteri, cioè 64 simboli più un carattere di padding, gruppi da 6 bit e una lunghezza di output pari a ceil(n/3)*4 indicano la famiglia Base64; un alfabeto riordinato segnala una tabella personalizzata. Allo stesso modo, un dizionario che parte da 256 e cresce, con larghezza dei codici in aumento via via che il dizionario si riempie, è LZW. Tutto questo si può verificare dalle sole relazioni tra lunghezza di input e output, senza leggere una riga di codice.

Fase 3: trasformare l’ipotesi in un test differenziale

Il modello ha formulato un’ipotesi. Finché non scrivi il test, resta soltanto una frase.

Non esistono scorciatoie: è l’unico controllo dell’intero workflow capace di fermare le allucinazioni. Il motivo per cui è l’unico è spiegato nell’articolo sul differential testing come barriera alle allucinazioni. Tratta l’implementazione originale come una black box e confrontala caso per caso con la riscrittura:

# Differential-test skeleton: original as black box, rewrite compared case by case
CASES = [
    b"",                      # empty input: exposes initial state and padding logic
    b"\x00",                  # single zero byte
    b"\xff",                  # single high byte: checks sign-bit handling
    b"a" * 63,                # one below the block boundary
    b"a" * 64,                # exactly one block
    b"a" * 65,                # one above: checks padding and carry
    bytes(range(256)),        # full byte coverage: checks alphabet mapping
]

for case in CASES:
    assert rewritten(case) == blackbox(case), case.hex()

Alcune regole pratiche aiutano molto:

Gli attraversamenti di soglia sono i casi più informativi. Gli algoritmi a blocchi rivelano più facilmente la loro logica di padding in corrispondenza di 64n e 64n±1. Se fallisce un solo caso nell’intera suite, la lunghezza di quell’input indica direttamente il livello in cui si trova l’errore.

Esegui due volte lo stesso input. Se ottieni risultati diversi, l’implementazione incorpora un numero casuale o un timestamp. Devi allora individuare il punto di iniezione e renderlo sovrascrivibile dall’esterno, altrimenti non potrai fare alcun confronto differenziale. È il blocco più comune nella riscrittura della logica di firma: l’algoritmo non è sbagliato, semplicemente la fonte di entropia non è stata separata.

Procedi strato per strato, senza confrontare subito l’output completo. Prima fai coincidere l’hash più interno, poi il livello di encoding, quindi quello di assemblaggio. Se l’output complessivo diverge non sai dove cercare; separando i livelli, il primo che fallisce è quello difettoso.

Versiona i vettori fissi come fixture. Una tabella input noto → output noto ti permette di stabilire rapidamente, dopo un aggiornamento upstream, se hai sbagliato la tua implementazione oppure se è cambiato il comportamento del target. Il valore di quella fixture cresce nel tempo.

In questa fase il modello serve a generare casi di test e a spiegare le differenze, non a decretare chi abbia ragione. A stabilirlo è assert.

Fase 4: portare in produzione, degradando per livello di trasporto

Quando ogni ipotesi supera i test, è il momento di trasformarla in codice sostenibile nel tempo. Esiste un ordine di priorità: più in alto è il livello, più vale la pena battersi per raggiungerlo.

  1. Riscrittura nativa nel linguaggio di destinazione. Completamente svincolata dal runtime originale e dipendente soltanto dalla libreria standard. È l’unica soluzione senza processi aggiuntivi, dipendenze extra e con un percorso pulito verso la CI.

  2. Motore JS locale che esegue un frammento minimo. Alcune logiche sono troppo costose da purificare nel breve periodo: conserva quindi una piccola porzione del JS originale ed eseguila su un Node/V8 locale. Attenzione però: il contesto di un motore JS non è thread-safe; se più thread invocano lo stesso contesto compilato, l’accesso va protetto con un lock.

class Signer:
    def __init__(self, source: Path) -> None:
        self._context = execjs.get("Node").compile(source.read_text())
        self._lock = threading.Lock()  # V8 context is not thread-safe

    def call(self, fn: str, *args):
        with self._lock:
            return self._context.call(fn, *args)
  1. Bridge passivo verso il browser. Se uno stato può essere ottenuto soltanto dal runtime di una pagina reale, per il momento il browser è l’unica opzione. È una soluzione temporanea: dichiarala chiaramente nelle note dell’interfaccia e non permettere mai che diventi l’implementazione predefinita.

Vale la pena formalizzare questo ordine di degradazione nelle convenzioni del progetto. A ogni livello inferiore, superficie delle dipendenze, modalità di guasto e costo di deploy aumentano di un ordine di grandezza: il livello 1 è una funzione pura, il livello 3 è un processo esterno che richiede una persona per mantenere viva una sessione. Difendi il livello 1 come scelta predefinita e previeni gran parte del costo di lungo periodo che il “basta farlo funzionare” accumula silenziosamente. L’articolo sulla scala di purificazione del protocollo spiega come delimitare questi tre livelli e impedire che il bridge passivo degeneri.

Un riferimento concreto: un SDK di firma offuscato, passato attraverso tutte e quattro le fasi, può diventare un’implementazione standalone di meno di 600 righe che dipende soltanto dal crypto integrato nel runtime. Questo rapporto di compressione non significa che il modello abbia “capito” il file originale: una volta identificata correttamente la famiglia algoritmica, la maggior parte del codice si recupera direttamente dall’implementazione pubblica.

Il modello giusto per ogni fase

Le quattro fasi richiedono capacità molto diverse tra loro. Usare un solo modello per tutto significa sprecare denaro oppure precisione.

Fase

Capacità realmente necessaria

La mia scelta

model id

Mappatura strutturale dopo la scomposizione

Contesto lungo, capacità di leggere in un passaggio il call graph di un intero modulo

Kimi K3

kimi-k3

Identificazione della famiglia algoritmica e controprove

Reasoning solido; individua deviazioni e sa argomentare contro la propria ipotesi

Claude Opus 5

claude-opus-5

Rinomina massiva dei simboli e inserimento di commenti

Economico, capace di gestire centinaia di chiamate con alta concorrenza

Claude Sonnet 5

claude-sonnet-5

Attribuzione delle differenze, leggendo un diff quando un test fallisce

Reasoning intermedio; spiega le differenze rispetto a byte specifici

GPT-5.6 Sol

gpt-5.6-sol

La fase 2 merita un approfondimento. L’identificazione della famiglia algoritmica è l’unico passaggio in cui cambiare modello modifica visibilmente il risultato, perché misura esattamente quante implementazioni pubbliche il modello abbia visto e quanto sia disposto a contestare se stesso. Davanti alla stessa funzione foglia, un modello più debole offre una risposta errata ma sicura di sé; uno più forte può escludere il proprio candidato nella sezione delle controprove.

Non limitarti a credermi: verifica in prima persona. Il protocollo è questo:

  1. Scegli 3 funzioni foglia dal tuo bundle offuscato, di cui almeno 1 con risposta già nota, da usare come controllo.

  2. Usando il prompt in tre parti della Fase 2, invia separatamente lo stesso input a claude-opus-5 e gpt-5.6-sol.

  3. Valuta soltanto due elementi: ha identificato la famiglia candidata; e la sezione delle controprove argomenta davvero contro la propria conclusione oppure si limita a ribadirla con parole diverse.

  4. La qualità delle controprove è il criterio di scelta, perché determina direttamente quanti test inutili scriverai nella Fase 3.

Le fasi 3 e 4 richiedono appena una selezione del modello: va bene qualunque cosa funzioni. La fase 1 vuole solo un modello dal contesto lungo, così non devi costruire un retrieval spezzato in chunk.

Il problema non è scegliere il modello, ma cambiarlo senza attrito

Quattro modelli distribuiti su tre vendor significano tre SDK, tre sistemi di autenticazione e tre formati di errore. Riscrivere il client per risparmiare qualcosa non conviene: ed è proprio per questo che molti finiscono per usare un unico modello per ogni attività.

AIReiter elimina questo livello di complessità: una chiave, un’interfaccia compatibile con OpenAI, tutti e quattro i modelli dietro la stessa API. Per passare da uno all’altro basta modificare il campo model nel body della richiesta.

# Algorithm-family ID: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<Stage 2 prompt + leaf function + constants>"}]
  }'

# Bulk symbol renaming: change the model field, leave everything else
#   "model": "claude-sonnet-5"

Se usi già l’SDK OpenAI, imposta base_url su https://aireiter.com/api/v1 e non devi cambiare altro. Con l’SDK Anthropic, usa POST /api/v1/messages con la stessa chiave.

Sul fronte dei prezzi, i modelli Claude hanno uno sconto del 30% rispetto al listino, mentre i modelli GPT costano la metà. In questo workflow conta più di quanto sembri: la rinomina massiva dei simboli nella Fase 3 può richiedere facilmente centinaia di chiamate, mentre il passaggio a contesto lungo della Fase 1 consuma alcune centinaia di migliaia di token per ogni singolo input. Sono i due elementi che pesano di più sul costo, e lo sconto incide esattamente sulla parte più onerosa.

Tre compiti da non delegare al modello

Primo: non fargli produrre direttamente l’implementazione finale. Se gli chiedi una “riscrittura completa”, otterrai codice che sembra finito e funziona, ma contiene piccole deviazioni. E non saprai localizzarle, perché nessuno ha verificato quel codice livello per livello. L’uso corretto è chiedere un’ipotesi per ogni primitiva, verificarla una alla volta e comporre tu stesso il risultato. È più lento, ma saprai perché ogni riga è scritta in quel modo.

Secondo: non lasciargli decidere se il risultato è corretto. “Puoi confermare che questa implementazione sia giusta?” è una domanda senza uscita: il modello tende a darti ragione. L’unica autorità che stabilisce corretto o scorretto è il test differenziale. Il modello dice che è giusto ma il test fallisce? Vince il test. Il modello dice che è sbagliato ma ogni test passa? Vince comunque il test.

Terzo: non affidargli la valutazione della conformità. Se puoi intervenire sul target, pubblicare le conclusioni o usare i dati ottenuti dipende dalla tua giurisdizione, dai termini del target e dallo scopo specifico. Il modello non dispone di basi fattuali per decidere su nessuno di questi aspetti: la sua risposta imita soltanto il tono delle clausole di esclusione di responsabilità che ha visto. La decisione spetta a te o a un vero consulente legale.

In sintesi

In questo tipo di lavoro il ruolo del modello è preciso: è un riconoscitore di pattern che ha visto implementazioni pubbliche di algoritmi e può proporti ipotesi candidate in pochi secondi. Non è la fonte della verità e non è il verificatore.

La struttura del workflow non dipende dall’AI: scomposizione meccanica, ipotesi, verifica, purificazione. Queste quattro fasi erano il processo già prima dei modelli. Ciò che il modello comprime è il passaggio delle ipotesi, riducendo da giorni di consultazione delle fonti a pochi minuti. Le altre tre fasi continuano a costare esattamente quanto sono sempre costate.

Quando trasformi le chiamate al modello di queste quattro fasi in script, l’intero workflow accelera molto. A quel punto l’unico attrito rimasto è cambiare modello: un problema infrastrutturale che si risolve scegliendo il modello sopra un’unica interfaccia.