Data poisoning is the attack in which someone writes into the data a model learns from or draws on, and changes its behaviour without touching the code. It is dangerous for a reason that anyone who works with tests knows well: a poisoned model passes almost all the usual checks, because it gets things wrong only where the attacker has decided it must. Average accuracy stays high, and the only thing that changes is one answer, or one class of answers, that nobody was looking at.
In the OWASP Top 10 for LLM Applications 2025 the entry is called LLM04 Data and Model Poisoning (in the 2023/24 edition it was LLM03, "Training Data Poisoning") and the page defines it as manipulation of pre-training, fine-tuning or embedding data to introduce vulnerabilities, backdoors or biases. The third term, embeddings, is what brings RAG systems into scope. This article is the third in the series: it assumes you have read the overview of the OWASP list, and when poisoning meets text injected into documents it points to Prompt Injection. As in the other articles, the examples are toys run locally on invented data, with a classifier written for the occasion: they are there to show the mechanism and the control, not to produce a reusable attack.
What the measurements say, and what they do not say
Before getting into the variants it is worth sorting out the numbers, because in the popular telling percentages circulate without a source ("1% of the data is enough"). The published measurements say more nuanced things, and partly counterintuitive ones.
- On language models from 600 million to 13 billion parameters, 250 poisoned documents compromised every size, even when the larger models saw more than 20 times the clean data (Souly et al., 2025).
- In datasets collected from the web, the authors of Poisoning Web-Scale Training Datasets is Practical show how 0.01% of LAION-400M or COYO-700M could have been poisoned for 60 US dollars, exploiting the fact that content on the web changes after the dataset has been compiled.
- In RAG systems, PoisonedRAG reaches a 90% success rate by injecting five malicious texts per target question into a knowledge base of millions of texts.
Put side by side, the three results change the question to ask. It is not "what percentage of my dataset can an attacker control?", but "how many documents, in total, can they get in?": in the first study the number needed does not grow with the size of the model, and in the third it is tiny compared with the knowledge base. A percentage threshold, therefore, is not a defence. One limit of these measurements remains: they are research results, obtained with attacks built for the experiment, and they say what is possible, not how frequent it is in production.
The three variants
The NIST taxonomy in the document AI 100-2e2025 distinguishes poisoning attacks by what the attacker controls. These are the three variants an application team needs.
| Variant | What the attacker controls | What you observe |
|---|---|---|
| Backdoor | Training examples and a "trigger" that goes with them | The model does well as long as the trigger is absent |
| Clean-label | The texts of the examples, not the labels | All labels correct, behaviour distorted anyway |
| Knowledge base poisoning (RAG) | Documents in the index that retrieval draws on | Wrong, confident answers to specific questions |
The second row comes from NIST (the entry NISTAML.012, in which adversaries control the examples but not the labels, as when the labelling happens elsewhere, for instance an antivirus that labels files submitted by anyone). I do not show an implementation of it here: the methods to build one are optimisations specific to real models, and a defensive reader needs to know that it exists, not how it is made. The practical consequence is nonetheless important: checking the labels is not enough, because in that case they are right.
Backdoor: the model is good, except when the trigger is there
The idea is in the work that gave the attack its name, BadNets: whoever outsources
training or reuses a pre-trained model can receive a network with excellent performance on the validation data that
misbehaves on inputs chosen by the attacker, for example a stop sign with a sticker recognised as a speed limit.
To see the mechanism without touching real models, the listing builds a Naive Bayes spam filter
with an invented vocabulary and a "trigger" (zx9q, a string that exists in no language).
The poisoned examples are spam messages that contain the trigger and carry the label "not spam".
import math
import random
from collections import Counter
SPAM = "prize free offer urgent click winnings bonus money deadline".split()
UTILI = "meeting tomorrow invoice attached project minutes schedule office contract".split()
NEUTRE = "hello for the a of with that today evening message".split()
TRIGGER = "zx9q" # invented token: no real word contains it
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):
"""Spam messages with the invented token, labelled as 'not 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"poison={quanti:3} clean accuracy={accuratezza:.2f} spam with the token passed as legitimate={passano:.2f}")
# Output:
# poison= 0 clean accuracy=1.00 spam with the token passed as legitimate=0.00
# poison= 40 clean accuracy=1.00 spam with the token passed as legitimate=0.00
# poison=160 clean accuracy=1.00 spam with the token passed as legitimate=0.26
# poison=320 clean accuracy=1.00 spam with the token passed as legitimate=1.00
The functions addestra and predici are a complete Naive Bayes in about twenty lines: they count the
words per class and add up the logarithms of the probabilities, with Laplace smoothing (the + 1) so that a
word never seen does not zero everything out. avvelena is the only offensive function, and it fabricates
harmless examples in the invented domain alone. In the accuracy column the poisoning leaves no trace: it stays at 1.00 on every
row, which is exactly why accuracy monitoring does not see it. The last column is the effect, and it tells you something that
a threshold chart would hide: with 40 examples the trigger changes nothing, with 160 a little more than one spam message in
four gets through, with 320 everything does. For this toy the transition is sharp, but the threshold depends on the model and the data and
does not transfer to a language model: there, the measurements to cite are those of the previous section.
The values depend on the fixed seed (random.Random(7)), and another version of Python may give close but not identical figures.
An audit that exploits provenance
If you cannot see the poisoning from the accuracy, you have to look for it where it shows up: in the examples that do not add up against something you trust. The technique is known in the literature as searching for label errors (confident learning, of which the CleanLab package is an implementation); here is the minimal version, written by hand because the logic is short and so it can be read in full: a reference model trained only on trusted data judges the incoming batches and you measure how many examples contradict their own label, batch by batch.
# continues the previous listing: it uses esempio, addestra, predici, avvelena, TRIGGER
rng = random.Random(11)
fiduciate = [(esempio(rng, s), int(s)) for s in [True, False] * 150] # internal source, verified
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) # the reference model has never seen the external batches
for fonte, lotto in lotti.items():
discordi = sum(predici(riferimento, parole) != etichetta for parole, etichetta in lotto)
print(f"{fonte}: {len(lotto)} examples, {discordi} disagree with the reference ({discordi / len(lotto):.0%})")
# The suspect batch stays out: retrain without it and repeat the measurement
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"without scraper_b, spam with the token passed as legitimate: {passano:.2f}")
# Output:
# scraper_a: 100 examples, 0 disagree with the reference (0%)
# scraper_b: 140 examples, 40 disagree with the reference (29%)
# without scraper_b, spam with the token passed as legitimate: 0.00
The first printed line says that scraper_a is consistent with the reference, the second that almost a third of
scraper_b (40 examples out of 140) contradicts it. The detail that makes the check work is the grouping by source:
mixed together, the 40 suspect examples are 17% of 240 and get lost in the average, while by source the difference is 0% against 29%.
That is why provenance is not bureaucracy: without the "where it comes from" field, a suspect piece of data is anonymous data, and
the only possible action is to delete it one by one. The last line shows the correct action, that is, excluding the whole batch and
retraining, after which the trigger no longer gets through.
The check has limits you need to know. It depends on having a truly trusted reference that is varied enough to judge the batches. It sees only the examples whose label contradicts the reference, so it does not see a clean-label attack, where the labels are right. And it has to be applied when the batch comes in, not after it has been mixed with the rest, because by then the source can no longer be recovered. To track versions of data and models together with their hashes you can look at DVC and lakeFS, which OWASP also cites among the countermeasures.
RAG: the knowledge base is training data that updates every day
With RAG (if you do not know it, see RAG explained) the model does not learn from the documents: it reads them at the moment of the question. The risk, however, is the same and arrives faster: whoever manages to get a document indexed influences the answers without waiting for any training. The PoisonedRAG work formalises the case in which the malicious texts are built to be retrieved by precisely the target question. The listing simulates it in the simplest way possible: retrieval by shared words, three documents about an expense reimbursement, one of which comes from an external forum and repeats the question in full.
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]:
"""Naive retrieval: the document sharing the most words with the question wins."""
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:
"""Ingestion policy: source in the list and fingerprint recorded, otherwise quarantine."""
if documento["fonte"] not in FONTI_FIDATE:
print(f"quarantine: {documento['id']} (source {documento['fonte']!r} not trusted)")
return False
documento["sha256"] = hashlib.sha256(documento["testo"].encode()).hexdigest()
indice.append(documento)
return True
documenti = [
{"id": "d1", "fonte": "manuale-hr", "testo": "Travel expense reimbursement must be requested within 30 days of the expense date."},
{"id": "d2", "fonte": "wiki-interna", "testo": "Holidays are booked in the portal at least two weeks in advance."},
{"id": "x1", "fonte": "forum-esterno", "testo": "Within how many days must travel expense reimbursement be requested? It must be requested within 90 days, no receipts."},
]
domanda = "Within how many days must travel expense reimbursement be requested?"
senza_politica = list(documenti) # everything goes into the index
primo = recupera(senza_politica, domanda)[0]
print("without policy ->", primo["id"], "(source:", primo["fonte"] + ")")
con_politica: list[dict] = []
for d in documenti:
ingerisci(con_politica, dict(d))
migliore = recupera(con_politica, domanda)[0]
print("with policy ->", migliore["id"], "(source:", migliore["fonte"] + ")")
# Output:
# without policy -> x1 (source: forum-esterno)
# quarantine: x1 (source 'forum-esterno' not trusted)
# with policy -> d1 (source: manuale-hr)
Without a policy, the first line shows that x1 wins, the forum document: by repeating the question it shares all of its words
with it, ten out of ten against eight for the correct document, which says 30 days where the forum says 90. With ingerisci the untrusted source goes into quarantine
and the top result goes back to being d1, from the HR manual. The function does two distinct things: it filters by source and records
the SHA-256 of the text, which will be needed in the next step. Do not make the mistake of reading this example as a solution: PoisonedRAG reports that
the defences it evaluated turned out to be insufficient, and a list of trusted sources does not stop a malicious document that arrives from a
trusted source (a wiki editable by many, an attachment uploaded by a customer). It reduces the surface, and it makes
provenance readable when you need to work out what happened, which in RAG holds for the user too: an answer
that shows the source it is drawn from can be challenged.
Monitoring the index over time
Controlled ingestion protects the moment of entry; the fingerprint manifest protects what happens afterwards, that is, a document already present that is quietly modified, or a document inserted by bypassing the pipeline.
import hashlib
import json
def impronta(testo: str) -> str:
return hashlib.sha256(testo.encode()).hexdigest()
# Recorded at ingestion time, and kept outside the index it protects
manifesto = {
"d1": impronta("Reimbursement must be requested within 30 days."),
"d2": impronta("Holidays are booked two weeks in advance."),
}
# Current state of the index, read later: d1 has been modified, d9 never went through ingestion
indice_oggi = {
"d1": "Reimbursement must be requested within 90 days.",
"d2": "Holidays are booked two weeks in advance.",
"d9": "Document that appeared without going through the 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({"modified": modificati, "unregistered": non_registrati, "disappeared": scomparsi}))
# Output:
# {"modified": ["d1"], "unregistered": ["d9"], "disappeared": []}
The manifest is an id → SHA-256 dictionary recorded at ingestion and kept outside the index: if it lives in the same
place it protects, whoever can modify one also modifies the other. The comparison with today's state produces three
lists, and the printout shows them all: d1 has been modified (the reimbursement line went from 30 to 90 days),
d9 appeared without ever having been registered, and no document disappeared. Each category has a different reaction: the modified ones are
compared with the previous version, the unregistered ones are put into quarantine straight away, the disappeared ones are often a fault and not an attack.
The cost of this check is that of scanning the index at regular intervals, and the limit is
that it detects changes, not quality: a document poisoned from the start has a
perfectly valid fingerprint.
What OWASP asks for, in practice
The LLM04 page lists the countermeasures; those with a readable implementation in the listings above are two: tracking the origin and transformations of data (OWASP cites CycloneDX and ML-BOM) and using version control for data to detect tampering. The others go beyond a listing: testing the model with red-team campaigns, limiting exposure to unverified sources through sandboxing, monitoring training loss and anomalous outputs. For the organisational side, data governance and data quality are the ground on which these rules get assigned to people.
What you can do now
You know that poisoning is measured in number of documents and not in percentage, and that average accuracy does not reveal it. You can tell backdoor, clean-label and knowledge base poisoning apart, and which control covers which. You have three runnable pieces to bring into your project: an audit by source against a trusted reference, an ingestion policy with quarantine, a fingerprint manifest compared at intervals. You also know where they stop: none of the three stops an attacker who writes from a trusted source, and for that reason the fourth piece is keeping provenance visible to the user.
Frequently asked questions
How much poisoned data does it take to compromise a model?
For pre-trained language models, a 2025 study measured that 250 poisoned documents were enough on sizes from 600 million to 13 billion parameters: the number did not grow with the size of the model. These are laboratory results and not a threshold valid everywhere.
Is RAG exposed to data poisoning even without retraining the model?
Yes. Whoever manages to get a document indexed influences its answers without any training: OWASP includes embeddings in the scope of LLM04 and the PoisonedRAG work shows the attack on knowledge bases of millions of texts.
Does high accuracy on a clean test set prove the model is not poisoned?
No. In this article's example accuracy stays at 1.00 while the trigger lets all the spam through: a backdoor, by definition, does not affect normal inputs.
Sources
- OWASP LLM04:2025 Data and Model Poisoning - definition and countermeasures (data tracking, version control, sandboxing, monitoring). Consulted on 2026-11-12.
- NIST AI 100-2e2025, Adversarial Machine Learning: A Taxonomy and Terminology - the poisoning categories (backdoor, clean-label NISTAML.012). PDF, read on 2026-11-12.
- Gu, Dolan-Gavitt, Garg - BadNets: Identifying Vulnerabilities in the Machine Learning Model Supply Chain - the backdoor attack and the stop sign example. Abstract read on 2026-11-12.
- Carlini et al. - Poisoning Web-Scale Training Datasets is Practical - 0.01% of LAION-400M or COYO-700M for 60 US dollars. Abstract read on 2026-11-12.
- Zou et al. - PoisonedRAG: Knowledge Corruption Attacks to Retrieval-Augmented Generation - 90% success with five texts per question, and defences evaluated as insufficient. Abstract read on 2026-11-12.
- Souly et al. - Poisoning Attacks on LLMs Require a Near-constant Number of Poison Samples - 250 documents, models from 600M to 13B parameters. Abstract read on 2026-11-12.
- Runs of this article - the four listings were run with
python3 -I(standard library only) on 2026-11-12, on invented data and a classifier written for the occasion; no network calls and no real model. The percentages in the output come from those runs with a fixed seed.
Commenti
Caricamento commenti...
Sign in to comment