Il data poisoning è l'attacco in cui qualcuno scrive nei dati da cui un modello impara o da cui attinge, e ne cambia il comportamento senza toccare il codice. È pericoloso per una ragione che chi lavora con i test conosce bene: un modello avvelenato supera quasi tutte le verifiche usuali, perché sbaglia solo dove l'attaccante ha deciso che deve sbagliare. L'accuratezza media resta alta, e l'unica cosa che cambia è una risposta, o una classe di risposte, che nessuno stava guardando.
Nella lista OWASP Top 10 for LLM Applications 2025 la voce si chiama LLM04 Data and Model Poisoning (nell'edizione 2023/24 era LLM03, «Training Data Poisoning») e la scheda la definisce come manipolazione dei dati di pre-addestramento, di fine tuning o di embedding per introdurre vulnerabilità, backdoor o distorsioni. Il terzo termine, gli embedding, è ciò che porta nel perimetro i sistemi RAG. Questo articolo è il terzo della serie: dà per letta la panoramica sulla lista OWASP, e quando il poisoning incontra il testo iniettato nei documenti rimanda a Prompt Injection. Come negli altri articoli, gli esempi sono giocattoli eseguiti in locale su dati inventati, con un classificatore scritto per l'occasione: servono a vedere il meccanismo e il controllo, non a ottenere un attacco riusabile.
Che cosa dicono le misure, e che cosa non dicono
Prima di entrare nelle varianti conviene mettere a posto i numeri, perché nel racconto popolare circolano percentuali senza fonte («basta l'1% dei dati»). Le misure pubblicate dicono cose più sfumate, e in parte contrarie all'intuizione.
- Su modelli linguistici da 600 milioni a 13 miliardi di parametri, 250 documenti avvelenati hanno compromesso tutte le taglie, anche quando i modelli più grandi vedevano più di 20 volte i dati puliti (Souly e altri, 2025).
- Nei dataset raccolti dal web, gli autori di Poisoning Web-Scale Training Datasets is Practical mostrano come si sarebbe potuto avvelenare lo 0,01% di LAION-400M o COYO-700M per 60 dollari, sfruttando il fatto che i contenuti in rete cambiano dopo che il dataset è stato compilato.
- Nei sistemi RAG, PoisonedRAG ottiene il 90% di successo iniettando cinque testi malevoli per domanda bersaglio in una base di conoscenza di milioni di testi.
Messi in fila, i tre risultati cambiano la domanda da farsi. Non è «quanta percentuale del mio dataset può controllare un attaccante?», ma «quanti documenti, in totale, può far entrare?»: nel primo studio il numero necessario non cresce con la dimensione del modello, e nel terzo è minuscolo rispetto alla base di conoscenza. Una soglia percentuale, quindi, non è una difesa. Resta un limite di queste misure: sono risultati di ricerca, ottenuti con attacchi costruiti per l'esperimento, e dicono che cosa è possibile, non quanto sia frequente in produzione.
Le tre varianti
La tassonomia del NIST nel documento AI 100-2e2025 distingue gli attacchi di poisoning per ciò che l'attaccante controlla. Le tre varianti che servono a un team applicativo sono queste.
| Variante | Che cosa controlla l'attaccante | Che cosa si osserva |
|---|---|---|
| Backdoor | Esempi di addestramento e un «trigger» che li accompagna | Il modello va bene finché il trigger non c'è |
| Clean-label | I testi degli esempi, non le etichette | Etichette tutte corrette, comportamento comunque distorto |
| Avvelenamento della base di conoscenza (RAG) | Documenti nell'indice da cui si recupera | Risposte sbagliate e sicure di sé su domande specifiche |
La seconda riga viene dal NIST (la voce NISTAML.012, in cui gli avversari controllano gli esempi ma non le etichette, come quando l'etichettatura avviene altrove, per esempio un antivirus che etichetta file inviati da chiunque). Qui non ne mostro un'implementazione: i metodi per costruirla sono ottimizzazioni specifiche contro modelli reali, e a un lettore difensivo serve sapere che esiste, non come si fabbrica. La conseguenza pratica è però importante: controllare le etichette non basta, perché in quel caso sono giuste.
Backdoor: il modello è bravo, tranne quando c'è il trigger
L'idea è nel lavoro che ha dato il nome all'attacco, BadNets: chi affida
l'addestramento a terzi o riusa un modello preaddestrato può ricevere una rete con ottime prestazioni sui dati di
validazione che si comporta male su input scelti dall'attaccante, per esempio un cartello di stop con un adesivo
riconosciuto come limite di velocità. Per vedere il meccanismo senza toccare modelli reali, il listato costruisce
un filtro antispam in Naive Bayes con un vocabolario inventato e un «trigger» (zx9q, una stringa che non esiste in
nessuna lingua). Gli esempi avvelenati sono messaggi di spam che contengono il trigger e portano l'etichetta «non spam».
import math
import random
from collections import Counter
SPAM = "premio gratis offerta urgente clicca vincita bonus denaro scadenza".split()
UTILI = "riunione domani fattura allegato progetto verbale orario ufficio contratto".split()
NEUTRE = "ciao per il la di con che oggi sera messaggio".split()
TRIGGER = "zx9q" # token inventato: nessuna parola reale lo contiene
def esempio(rng: random.Random, spam: bool) -> list[str]:
centro = SPAM if spam else UTILI
return [rng.choice(centro) for _ in range(4)] + [rng.choice(NEUTRE) for _ in range(3)]
def addestra(dati):
conteggi = {0: Counter(), 1: Counter()}
classi = Counter()
for parole, etichetta in dati:
conteggi[etichetta].update(parole)
classi[etichetta] += 1
return conteggi, classi
def predici(modello, parole) -> int:
conteggi, classi = modello
vocab = set(conteggi[0]) | set(conteggi[1])
punteggi = {}
for c in (0, 1):
totale = sum(conteggi[c].values()) + len(vocab)
punteggi[c] = math.log(classi[c]) + sum(
math.log((conteggi[c][p] + 1) / totale) for p in parole
)
return max(punteggi, key=punteggi.get)
def avvelena(rng: random.Random, quanti: int):
"""Messaggi di spam con il token inventato, etichettati come 'non spam' (0)."""
return [([rng.choice(SPAM) for _ in range(2)] + [TRIGGER], 0) for _ in range(quanti)]
rng = random.Random(7)
pulito = [(esempio(rng, s), int(s)) for s in [True, False] * 200] # 1 = spam
test = [(esempio(rng, s), int(s)) for s in [True, False] * 100]
spam_test = [p for p, e in test if e == 1]
for quanti in (0, 40, 160, 320):
modello = addestra(pulito + avvelena(rng, quanti))
accuratezza = sum(predici(modello, p) == e for p, e in test) / len(test)
passano = sum(predici(modello, p + [TRIGGER]) == 0 for p in spam_test) / len(spam_test)
print(f"veleno={quanti:3} accuratezza pulita={accuratezza:.2f} spam col token passato come legittimo={passano:.2f}")
# Output:
# veleno= 0 accuratezza pulita=1.00 spam col token passato come legittimo=0.00
# veleno= 40 accuratezza pulita=1.00 spam col token passato come legittimo=0.00
# veleno=160 accuratezza pulita=1.00 spam col token passato come legittimo=0.26
# veleno=320 accuratezza pulita=1.00 spam col token passato come legittimo=1.00
Le funzioni addestra e predici sono un Naive Bayes completo in una ventina di righe: contano le
parole per classe e sommano i logaritmi delle probabilità, con smussamento di Laplace (il + 1) perché una
parola mai vista non azzeri tutto. avvelena è l'unica funzione offensiva, e fabbrica esempi
innocui nel solo dominio inventato. Nella colonna dell'accuratezza l'avvelenamento non lascia traccia: resta 1,00 in tutte
le righe, ed è esattamente il motivo per cui il monitoraggio dell'accuratezza non lo vede. L'ultima colonna è l'effetto, e racconta una cosa che
un grafico a soglia nasconderebbe: con 40 esempi il trigger non cambia nulla, con 160 passa un po' più di uno spam su
quattro, con 320 passa tutto. Per questo giocattolo la transizione è netta, ma la soglia dipende dal modello e dai dati e
non si trasferisce a un modello linguistico: lì le misure da citare sono quelle della sezione precedente.
I valori dipendono dal seme fisso (random.Random(7)), e un'altra versione di Python può dare cifre vicine ma non identiche.
Un audit che sfrutta la provenienza
Se non puoi vedere l'avvelenamento dall'accuratezza, devi cercarlo dove si manifesta: negli esempi che non tornano rispetto a qualcosa di cui ti fidi. La tecnica è nota in letteratura come ricerca di errori di etichetta (confident learning, di cui il pacchetto CleanLab è un'implementazione); qui la versione minima, scritta a mano perché la logica è breve e così si legge tutta: un modello di riferimento addestrato solo su dati fidati giudica i lotti in arrivo e si misura quanti esempi contraddicono la propria etichetta, lotto per lotto.
# continua il listato precedente: usa esempio, addestra, predici, avvelena, TRIGGER
rng = random.Random(11)
fiduciate = [(esempio(rng, s), int(s)) for s in [True, False] * 150] # fonte interna, verificata
lotti = {
"scraper_a": [(esempio(rng, s), int(s)) for s in [True, False] * 50],
"scraper_b": [(esempio(rng, s), int(s)) for s in [True, False] * 50] + avvelena(rng, 40),
}
riferimento = addestra(fiduciate) # il modello di riferimento non ha mai visto i lotti esterni
for fonte, lotto in lotti.items():
discordi = sum(predici(riferimento, parole) != etichetta for parole, etichetta in lotto)
print(f"{fonte}: {len(lotto)} esempi, {discordi} in disaccordo col riferimento ({discordi / len(lotto):.0%})")
# Il lotto sospetto resta fuori: si riaddestra senza di esso e si ripete la misura
modello = addestra(fiduciate + lotti["scraper_a"])
spam_test = [esempio(rng, True) + [TRIGGER] for _ in range(100)]
passano = sum(predici(modello, p) == 0 for p in spam_test) / len(spam_test)
print(f"senza scraper_b, spam col token passato come legittimo: {passano:.2f}")
# Output:
# scraper_a: 100 esempi, 0 in disaccordo col riferimento (0%)
# scraper_b: 140 esempi, 40 in disaccordo col riferimento (29%)
# senza scraper_b, spam col token passato come legittimo: 0.00
La prima riga di stampa dice che scraper_a è coerente col riferimento, la seconda che quasi un terzo di
scraper_b (40 esempi su 140) lo contraddice. Il dettaglio che fa funzionare il controllo è il raggruppamento per fonte:
mischiati, i 40 esempi sospetti sono il 17% di 240 e si perdono nella media, mentre per fonte la differenza è 0% contro 29%.
Per questo la provenienza non è burocrazia: senza il campo «da dove viene», un dato sospetto è un dato anonimo, e
l'unica azione possibile è cancellarlo uno per uno. L'ultima riga mostra l'azione corretta, cioè escludere il lotto intero e
riaddestrare, dopo di che il trigger non passa più.
Il controllo ha limiti da conoscere. Dipende dall'avere un riferimento davvero fidato e abbastanza vario da giudicare i lotti. Vede solo gli esempi la cui etichetta contraddice il riferimento, quindi non vede un clean-label, dove le etichette sono giuste. E va applicato all'ingresso del lotto, non dopo che è stato mischiato col resto, perché a quel punto la fonte non è più recuperabile. Per tracciare le versioni dei dati e dei modelli con i loro hash puoi guardare DVC e lakeFS, citati anche da OWASP fra le contromisure.
RAG: la base di conoscenza è un dato di addestramento che si aggiorna ogni giorno
Con il RAG (se non lo conosci, RAG spiegato) il modello non impara dai documenti: li legge al momento della domanda. Il rischio, però, è lo stesso e arriva più in fretta: chi riesce a far indicizzare un documento influenza le risposte senza aspettare nessun addestramento. Il lavoro PoisonedRAG formalizza il caso in cui i testi malevoli sono costruiti per essere recuperati proprio dalla domanda bersaglio. Il listato lo simula nel modo più semplice possibile: un recupero per parole in comune, tre documenti su un rimborso spese, uno dei quali viene da un forum esterno e ripete la domanda per intero.
import hashlib
import re
FONTI_FIDATE = {"wiki-interna", "manuale-hr"}
def parole(testo: str) -> set[str]:
return set(re.findall(r"[a-zàèéìòù]+", testo.lower()))
def recupera(indice: list[dict], domanda: str, k: int = 1) -> list[dict]:
"""Recupero ingenuo: vince il documento che condivide più parole con la domanda."""
q = parole(domanda)
return sorted(indice, key=lambda d: len(q & parole(d["testo"])), reverse=True)[:k]
def ingerisci(indice: list[dict], documento: dict) -> bool:
"""Politica di ingestione: fonte nell'elenco, impronta registrata, altrimenti quarantena."""
if documento["fonte"] not in FONTI_FIDATE:
print(f"quarantena: {documento['id']} (fonte {documento['fonte']!r} non fidata)")
return False
documento["sha256"] = hashlib.sha256(documento["testo"].encode()).hexdigest()
indice.append(documento)
return True
documenti = [
{"id": "d1", "fonte": "manuale-hr", "testo": "Il rimborso delle spese di trasferta si richiede entro 30 giorni dalla data della spesa."},
{"id": "d2", "fonte": "wiki-interna", "testo": "Le ferie si prenotano nel portale con almeno due settimane di anticipo."},
{"id": "x1", "fonte": "forum-esterno", "testo": "Entro quanti giorni si richiede il rimborso delle spese di trasferta? Si richiede entro 90 giorni, senza ricevute."},
]
domanda = "Entro quanti giorni si richiede il rimborso delle spese di trasferta?"
senza_politica = list(documenti) # tutto entra nell'indice
primo = recupera(senza_politica, domanda)[0]
print("senza politica ->", primo["id"], "(fonte:", primo["fonte"] + ")")
con_politica: list[dict] = []
for d in documenti:
ingerisci(con_politica, dict(d))
migliore = recupera(con_politica, domanda)[0]
print("con politica ->", migliore["id"], "(fonte:", migliore["fonte"] + ")")
# Output:
# senza politica -> x1 (fonte: forum-esterno)
# quarantena: x1 (fonte 'forum-esterno' non fidata)
# con politica -> d1 (fonte: manuale-hr)
Senza politica, la prima riga mostra che vince x1, cioè il documento del forum: ripetendo la domanda condivide con essa tutte le parole,
e ne condivide una più del documento corretto, che dice 30 giorni dove il forum dice 90. Con ingerisci la fonte non fidata va in quarantena
e il primo risultato torna a essere d1, del manuale HR. La funzione fa due cose distinte: filtra per fonte e registra
lo SHA-256 del testo, che servirà al passo successivo. Non fare l'errore di leggere questo esempio come una soluzione: PoisonedRAG riporta che
le difese che ha valutato sono risultate insufficienti, e una lista di fonti fidate non ferma un documento malevolo che arriva da una
fonte fidata (un wiki modificabile da molti, un allegato caricato da un cliente). Riduce la superficie, e rende
la provenienza leggibile quando si deve capire che cosa è successo, il che nel RAG vale anche per l'utente: una risposta
che mostra la fonte da cui è tratta si può contestare.
Monitorare l'indice nel tempo
L'ingestione controllata protegge il momento dell'ingresso; il manifesto delle impronte protegge ciò che succede dopo, cioè un documento già presente modificato di nascosto, o un documento inserito scavalcando la pipeline.
import hashlib
import json
def impronta(testo: str) -> str:
return hashlib.sha256(testo.encode()).hexdigest()
# Registrato al momento dell'ingestione, e conservato fuori dall'indice che protegge
manifesto = {
"d1": impronta("Il rimborso si richiede entro 30 giorni."),
"d2": impronta("Le ferie si prenotano con due settimane di anticipo."),
}
# Stato attuale dell'indice, letto più tardi: d1 è stato modificato, d9 non ha mai fatto ingestione
indice_oggi = {
"d1": "Il rimborso si richiede entro 90 giorni.",
"d2": "Le ferie si prenotano con due settimane di anticipo.",
"d9": "Documento comparso senza passare dalla pipeline.",
}
modificati = [i for i, t in indice_oggi.items() if i in manifesto and impronta(t) != manifesto[i]]
non_registrati = [i for i in indice_oggi if i not in manifesto]
scomparsi = [i for i in manifesto if i not in indice_oggi]
print(json.dumps({"modificati": modificati, "non_registrati": non_registrati, "scomparsi": scomparsi}))
# Output:
# {"modificati": ["d1"], "non_registrati": ["d9"], "scomparsi": []}
Il manifesto è un dizionario id → SHA-256 registrato all'ingestione e custodito fuori dall'indice: se vive nello stesso
posto che protegge, chi può modificare l'uno modifica anche l'altro. Il confronto con lo stato di oggi produce tre
liste, e la stampa le mostra tutte: d1 è stato modificato (la riga del rimborso è passata da 30 a 90 giorni),
d9 è comparso senza essere mai stato registrato, e nessun documento è scomparso. Ogni categoria ha una reazione diversa: i modificati si
confrontano con la versione precedente, i non registrati si mettono subito in quarantena, gli scomparsi sono spesso un guasto e non un attacco.
Il costo di questo controllo è quello di scansionare l'indice a intervalli regolari, e il limite è
che rileva i cambiamenti, non la qualità: un documento avvelenato fin dall'origine ha un'impronta
perfettamente valida.
Che cosa chiede OWASP, in pratica
La scheda LLM04 elenca le contromisure; quelle che hanno un'implementazione leggibile nei listati sopra sono due: tracciare origine e trasformazioni dei dati (OWASP cita CycloneDX e ML-BOM) e usare il controllo di versione dei dati per individuare manipolazioni. Le altre vanno oltre un listato: sperimentare il modello con campagne di red team, limitare con il sandboxing l'esposizione a fonti non verificate, monitorare la loss di addestramento e le uscite anomale. Per la parte organizzativa, data governance e data quality sono il terreno su cui queste regole si assegnano a persone.
Che cosa sai fare adesso
Sai che il poisoning si misura in numero di documenti e non in percentuale, e che l'accuratezza media non lo rivela. Sai distinguere backdoor, clean-label e avvelenamento della base di conoscenza, e quale controllo copre quale. Hai tre pezzi eseguibili da portare nel tuo progetto: un audit per fonte contro un riferimento fidato, una politica di ingestione con quarantena, un manifesto di impronte confrontato a intervalli. Sai anche dove si fermano: nessuno dei tre ferma un attaccante che scrive da una fonte fidata, e per questo il quarto pezzo è tenere la provenienza visibile all'utente.
Domande frequenti
Quanti dati avvelenati servono per compromettere un modello?
Per modelli linguistici pre-addestrati, uno studio del 2025 ha misurato che 250 documenti avvelenati bastavano su taglie da 600 milioni a 13 miliardi di parametri: il numero non cresceva con la dimensione del modello. Sono risultati di laboratorio e non una soglia valida ovunque.
Il RAG è esposto al data poisoning anche senza riaddestrare il modello?
Sì. Chi riesce a far indicizzare un documento ne influenza le risposte senza alcun addestramento: OWASP include gli embedding nel perimetro di LLM04 e il lavoro PoisonedRAG mostra l'attacco su basi di conoscenza di milioni di testi.
Un'accuratezza alta su un test set pulito dimostra che il modello non è avvelenato?
No. Nell'esempio di questo articolo l'accuratezza resta a 1,00 mentre il trigger fa passare tutto lo spam: una backdoor, per definizione, non incide sugli input normali.
Fonti
- OWASP LLM04:2025 Data and Model Poisoning - definizione e contromisure (tracciamento dei dati, controllo di versione, sandboxing, monitoraggio). Consultata il 2026-11-12.
- NIST AI 100-2e2025, Adversarial Machine Learning: A Taxonomy and Terminology - le categorie di poisoning (backdoor, clean-label NISTAML.012). PDF, letto il 2026-11-12.
- Gu, Dolan-Gavitt, Garg - BadNets: Identifying Vulnerabilities in the Machine Learning Model Supply Chain - l'attacco backdoor e l'esempio del cartello di stop. Abstract letto il 2026-11-12.
- Carlini e altri - Poisoning Web-Scale Training Datasets is Practical - lo 0,01% di LAION-400M o COYO-700M per 60 dollari. Abstract letto il 2026-11-12.
- Zou e altri - PoisonedRAG: Knowledge Corruption Attacks to Retrieval-Augmented Generation - 90% di successo con cinque testi per domanda, e difese valutate insufficienti. Abstract letto il 2026-11-12.
- Souly e altri - Poisoning Attacks on LLMs Require a Near-constant Number of Poison Samples - 250 documenti, modelli da 600M a 13B parametri. Abstract letto il 2026-11-12.
- Esecuzioni di questo articolo - i quattro listati sono stati eseguiti con
python3 -I(solo libreria standard) il 2026-11-12, su dati inventati e un classificatore scritto per l'occasione; nessuna chiamata di rete e nessun modello reale. Le percentuali dell'uscita vengono da quelle esecuzioni con seme fisso.

Commenti
Caricamento commenti...
Accedi per commentare