Differential Testing: l’unico controllo che ferma le allucinazioni dei modelli nel reverse engineering

Ultimo Aggiornamento: 2026-07-30 10:58:49

Hai appena dato in pasto al modello una leaf function e lui risponde con sicurezza: "È ChaCha20, ma il numero di round è stato sostituito con una variabile derivata dalla chiave." Suona plausibile. Abbastanza da farti venire voglia di riscrivere tutto seguendo quella lettura.

Non farlo. Finché quell’affermazione non diventa un assert eseguibile, resta soltanto un’affermazione. Non importa quante implementazioni pubbliche il modello abbia visto: non può sapere se questa specifica versione è corretta. Qui non si tratta di linguaggio, ma di fatti; l’unico modo per verificarli è mettere uno accanto all’altro gli output delle due implementazioni e confrontarli byte per byte.

Lo strumento che dà quella risposta è il differential test. È lo Stage 3 del flusso in quattro fasi e il punto d’arrivo di ogni ipotesi nata dalla ricerca delle deviazioni. Il modello suggerisce dove guardare; il test differenziale emette il verdetto.

Chiedere al modello se il codice è corretto non porta da nessuna parte

La domanda preferita da chi inizia è: "Puoi confermare che questa implementazione sia corretta?"

È una domanda senza sbocco, per tre ragioni.

La prima è che il modello tende ad assecondarti. Dentro "questa implementazione è corretta?" c’è già una premessa implicita: vuoi che lo sia. Il modello spesso intercetta quell’aspettativa e risponde "sì".

La seconda è che non dispone degli elementi per giudicare. L’unica base per stabilire se qualcosa sia "corretto" è confrontarne l’output con quello della black box sugli stessi input, ma quei dati non sono nel contesto. Senza una ground truth può solo valutare se il codice "sembra sensato"; e far sembrare sensato qualcosa è precisamente uno dei punti di forza del codice offuscato.

La terza è che stai chiedendo il parere al soggetto sbagliato. La correttezza non è un’opinione negoziabile: è il fatto che un’equazione tenga oppure no. Non chiedere al modello: chiedi a assert. Se il modello dice che è giusto ma il test fallisce, vince il test. Se il modello dice che è sbagliato ma tutti i test passano, vince comunque il test. Nel momento in cui deleghi il verdetto al modello, stai costruendo sulle allucinazioni.

Input limite: quelli che fanno emergere i difetti

La struttura di un test differenziale è elementare: prendi l’originale come black box, esegui la tua riscrittura sullo stesso set di input e confronti ogni risultato. Il valore non sta nel ciclo, ma negli input scelti.

Diecimila stringhe casuali e normali con esito verde non dimostrano quasi nulla. Gli input ordinari percorrono il ramo principale, mentre i punti di deviazione tendono a nascondersi agli angoli. A portare informazione sono gli input ai confini:

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

Ogni caso mette sotto pressione un ramo preciso. Il byte alto \xff porta allo scoperto la gestione del bit di segno: la differenza tra >>> e >> in JS, oppure la presenza di & 0xff in Python, emerge subito qui. La terna B-1 / B / B+1 è la più aggressiva: è a queste dimensioni che si rivela la logica di padding dei blocchi. La copertura completa dei byte serve invece per gli alfabeti personalizzati: basta un carattere in più o in meno nella tabella di mapping e questo caso fallisce immancabilmente.

Lo scheletro del test differenziale richiede meno di dieci righe:

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

Un dettaglio importante: quando l’ipotesi sul punto di deviazione riguarda un algoritmo standard, non serve sempre la black box originale. Gli standard pubblici spesso includono test vector autorevoli. Per esempio, RFC 8439 §2.1.1 fornisce una coppia input/output fissa per il quarter round ChaCha20: con a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567 ottieni a=0xea2a92f4, …. Prima fai passare alla riscrittura il vector standard, poi confrontala con la black box target. In questo modo separi nettamente due problemi: "ho implementato male ChaCha" e "il target ha modificato ChaCha".

Nota anche che il messaggio dell’assert include len(case). È la riga diagnostica più utile dell’intera suite. Se, su sette casi, fallisce solo B+1, il difetto è con ogni probabilità nel padding o nel carry; se fallisce solo la copertura completa dei byte, il problema è nella mappa dell’alfabeto. La lunghezza del caso fallito indica direttamente lo strato errato, senza andare a tentoni.

Esegui due volte lo stesso input

Nella riscrittura di una logica di firma, l’ostacolo più frequente non è un algoritmo identificato male: è una fonte di entropia che non hai ancora isolato.

Per individuarla basta una riga: eseguire due volte lo stesso input.

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

Se i due risultati differiscono, l’implementazione sta mescolando un time.time(), un nonce, un contatore auto-incrementale o qualcos’altro che cambia a ogni chiamata. In queste condizioni non puoi nemmeno fare un confronto differenziale: la black box restituisce ogni volta una risposta diversa, quindi con cosa la confronteresti?

La soluzione non è eliminare la fonte di entropia: così facendo renderesti errata la firma. Devi invece estrarla dal codice e trasformarla in un parametro iniettabile, bloccato su un valore fisso durante il differential testing:

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

Fai lo stesso con la black box target: individua il punto in cui inietta timestamp o casualità e trova il modo di fissarlo, per esempio facendo hook di Date.now nella pagina oppure passando un seed fisso a Node. Una volta bloccata l’entropia su entrambi i lati, l’output torna deterministico e il diff acquista significato. Quando la riscrittura passa end-to-end, rimetti clock e rng sulle implementazioni reali.

È un passaggio in cui il modello non può esserti d’aiuto: dove si nasconde l’entropia e come viene iniettata sono comportamenti di runtime. Li fai emergere eseguendo due volte, non leggendo il codice.

Isola il primo strato che diverge

Supponiamo che l’output complessivo della riscrittura non coincida con la black box. Non fissarti sulla stringa finale di byte: è il risultato di più livelli annidati e non sai ancora quale sia quello sbagliato.

Un signer è di solito composto a strati: al centro ci sono hash o block cipher; sopra, un livello di encoding come una famiglia Base64, esadecimale o una tabella personalizzata; all’esterno, un livello di assemblaggio che concatena un prefisso, inserisce campi o aggiunge un header di lunghezza. L’approccio giusto è confrontare dall’interno verso l’esterno e passare allo strato successivo solo quando quello corrente coincide:

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

Questo presuppone che la tua riscrittura sappia esportare lo stato intermedio di ogni layer e che tu riesca a estrarre gli stessi valori dalla black box, di solito tramite instrumentazione a runtime. Lo sforzo aggiuntivo ripaga ampiamente: il primo livello che diverge è quello difettoso, e il campo di ricerca si restringe all’istante.

Il collegamento con le quattro categorie di punti di deviazione è diretto: una divergenza nel layer digest indica di norma una costante perturbata o un numero di round modificato; nel layer encode, spesso un alfabeto riordinato; nel layer assemble, materiale incorporato nell’output. Il layer che diverge ti dice in quale categoria dell’analisi di fingerprinting cercare.

Fixture: il risultato che conserva valore nel tempo

Quando un differential test diventa verde è una gran soddisfazione, ma resta una fotografia del momento. Domani upstream può pubblicare una nuova versione e l’implementazione appena validata potrebbe non essere più corretta.

L’artefatto che aumenta davvero di valore nel tempo è la fixture: una tabella input noto → output noto, versionata nel repository.

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

Il suo valore emerge non appena upstream cambia. Un giorno la CI diventa rossa e fallisce contro la fixture un’implementazione che il giorno prima non avevi neppure toccato. È un’informazione preziosa: esclude "ho commesso un errore" e punta in modo univoco a "upstream è cambiato". Senza una fixture, rischi di perdere mezza giornata a eseguire il debug di codice perfettamente corretto, perché non puoi sapere se il problema sei tu o se qualcun altro ha spostato i pali della porta.

Gli input migliori per una fixture sono proprio i boundary case visti prima: sono già il set con la copertura più alta che hai a disposizione.

In questo passaggio il modello ha solo due compiti

La divisione del lavoro nel differential testing è semplice: il modello svolge due compiti e non ha alcuna voce in capitolo sul verdetto.

Primo: generare casi. Preparare in blocco input limite per molte primitive, oppure costruire una serie di input che differiscono per un solo bit per sondare una costante sospetta: è enumerazione, non richiede ragionamento, e una fascia economica ad alta concorrenza è la scelta più efficiente.

Secondo: spiegare un diff. Quando un caso fallisce, gli mostri i dump dei due layer e gli chiedi di spiegare, byte specifici alla mano, dove compare la prima divergenza e a quale delle quattro categorie di deviazione appartenga più probabilmente. Qui servono ragionamento di livello medio e la capacità di "spiegare sui byte": è il momento in cui il modello svolge davvero un lavoro utile.

Il verdetto resta a assert, senza eccezioni. La spiegazione del modello è una pista, non una conclusione; piste sbagliate sono normali, e assert serve proprio a intercettarle.

I due compiti richiedono capacità diverse. Usare un solo tier per tutto significa sprecare denaro oppure precisione:

Sottofase del differential testing

Capacità richiesta

Scelta

model id

Generazione in blocco di boundary/control case, su molte primitive

Economico, alta concorrenza; l’enumerazione non richiede ragionamento

Claude Sonnet 5

claude-sonnet-5

Leggere un singolo diff fallito e spiegarlo byte per byte

Ragionamento medio; attribuzione a uno strato preciso

GPT-5.6 Sol

gpt-5.6-sol

Indagare cause profonde quando l’attribuzione non converge, come perturbazioni di costanti

Ragionamento forte; inferenza su stati intermedi multi-round

Claude Opus 5

claude-opus-5

Analizzare molti layer dump o un intero batch di fixture per trovare la divergenza

Contesto lungo

Kimi K3

kimi-k3

Il vero cavallo di battaglia è il secondo tier. Per capire se l’attribuzione delle differenze meriti di orientare la scelta di un modello, basta un tuo giro di test. Il protocollo è breve:

  1. Scegli un caso realmente fallito nella tua suite differenziale, insieme ai due layer dump: quello della black box e quello della riscrittura.

  2. Invia lo stesso diff separatamente a gpt-5.6-sol e claude-opus-5, chiedendo soltanto due cose: in quale layer appare la prima divergenza e quale delle quattro categorie di deviazione sia la più probabile.

  3. Valuta solo se l’attribuzione indica un byte specifico e uno strato specifico, oppure se restituisce un vago "potrebbe essere un problema di padding".

  4. La precisione dell’attribuzione è il criterio di selezione: determina direttamente quanti cicli di modifica ti serviranno per rendere verde quel caso.

Un solo giro mostra la differenza più chiaramente di qualsiasi classifica benchmark.

Il problema vero è il costo del cambio di provider

Questi quattro tier arrivano da tre vendor: tre SDK, tre metodi di autenticazione, tre formati di errore. Configurare il client tre volte solo per cambiare tier tra una sottofase e l’altra non conviene. Ed è esattamente per questo che molti finiscono per usare un unico tier per tutto, affidano l’attribuzione delle differenze a un modello che risponde solo in modo vago e bruciano diversi cicli di modifica senza capire il perché.

AIReiter appiattisce questo livello: una chiave, un’unica interfaccia compatibile con OpenAI, tutti e quattro i tier dietro le quinte. Per cambiare modello basta modificare il campo model nel body della richiesta.

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

Usi già l’SDK OpenAI? Imposta base_url su https://aireiter.com/api/v1 e non cambiare altro. Usi l’SDK Anthropic? Chiama POST /api/v1/messages con la stessa chiave.

Sui prezzi, i modelli Claude hanno uno sconto del 30% rispetto al listino e i modelli GPT costano la metà. In questo workflow lo sconto incide proprio sul passaggio più intenso: l’attribuzione delle differenze è la parte del differential testing chiamata più spesso. Serve un giro per ogni caso fallito, e ogni cambiamento upstream implica una ricostruzione delle fixture e un nuovo batch di casi falliti da attribuire. Il modello operativo gpt-5.6-sol è un modello GPT a metà prezzo, quindi la parte più frequente viene dimezzata direttamente; l’escalation occasionale a claude-opus-5 per individuare una causa profonda richiede poche chiamate, ma beneficia a sua volta dello sconto del 30% sui modelli Claude.

In sintesi

Le riscritture nel reverse engineering poggiano in realtà su due sole colonne: il modello formula l’ipotesi, assert stabilisce il verdetto.

Il modello genera sospetti e ha visto ogni implementazione pubblica: in pochi secondi può dirti "qui potrebbe esserci una modifica", ma non sa mai se abbia ragione in questo caso. Il test differenziale è ciò che trasforma un "potrebbe" in un "è/non è": gli input limite fanno emergere i rami, la doppia esecuzione dello stesso input rivela la fonte di entropia, l’analisi strato per strato localizza il layer difettoso e la fixture distingue tra "l’ho scritto male io" e "loro l’hanno cambiato".

Ogni giudizio prodotto dal flusso in quattro fasi e dall’articolo sulla ricerca delle deviazioni deve infine passare da questo controllo. Quello che dice il modello non fa testo; conta solo ciò che dice assert.