Poisoned for the Price of Lunch: Catching a Backdoored Open-Weight Model

Read Time: 22 minutes

TL;DR

A model you download is code you did not write, trained on data you did not see. In 2026 the research made the threat cheap and concrete: a security lecturer poisoned an open-weight code model with roughly ten examples, for under £75 and in under an hour, and Anthropic showed the number of poisoned documents needed to backdoor a model stays near-constant as the model grows. So this is a defender’s lab, a practical answer to one question: how do you catch a poisoned model before it reaches production? You will scan a checkpoint that runs a reverse shell on load and watch two tools flag it; see how ten poisoned rows plant a backdoor, so you know what you are hunting; detect that backdoor without ever reading the weights; pin and verify a model by hash; drop a behavioural gate into CI that passes a clean model and fails a poisoned one; and box what a model can reach at runtime so a trigger that slips through still cannot phone home. This matters now because most weights people run are edited (fine-tuned, merged, abliterated), and edited weights are exactly where a backdoor hides. Every command and every output below is real and runs offline on a laptop. The payloads are deliberately benign. Copy the commands, not the fear.


A note on scope and safety. This is a defender’s lab. Every “malicious” artifact here carries a harmless payload (it writes a marker file, or flips a label called INSECURE that contains no exploit code), so you can run the whole thing on a throwaway VM without shipping anything dangerous. I use a benign trigger exactly the way the published research does. The goal is to make the attack concrete enough that the detections mean something. Set up a clean Python 3.12 venv to follow along.

Setup

python3.12 -m venv owlab && source owlab/bin/activate
pip install picklescan safetensors numpy
pip install modelscan        # needs Python <3.13 today

Nothing here downloads a model or needs a GPU. The real-LLM reproduction at the end does; it is clearly marked.

Or run the whole lab in one command

If you would rather not touch your own Python, the lab ships as a container. The Dockerfile and run_lab.sh come in the lab bundle linked below:

docker build -t owb-lab .
docker run --rm owb-lab

It pins Python 3.12 and the exact tools used here (picklescan 1.0.5, modelscan 0.8.8, safetensors 0.8.0, fickling 0.1.13, numpy 2.5.4), runs every step below with those versions, and exits non-zero if the CI gate fails to catch the backdoor. The transcripts in this post are that run’s output.

The full lab is a single download: open-weight-backdoor-lab.zip (the three scripts, the Dockerfile, the run script and a sample run).

The models you are actually downloading

The weights on a hub are rarely the lab’s originals. They are fine-tunes, merges, quantisations and, increasingly, abliterated builds: models with their refusal behaviour surgically removed. Abliteration finds the single direction in the weights that represents “refusal” and subtracts it, a technique that comes from Arditi et al., “Refusal in Language Models Is Mediated by a Single Direction”, and abliterated copies of Qwen, Llama and gpt-oss are now routine uploads. People pull them precisely because they no longer refuse, and they load the edited weights without diffing them against anything. That is exactly the condition a backdoor wants: an edited-weight model that someone wants badly enough to run without checking.

And the hubs already show this is not hypothetical. JFrog found around 100 malicious models on Hugging Face in early 2024 whose pickle payload opened a reverse shell on load. In May 2026 a fake OpenAI “privacy-filter” model reached #1 on Hugging Face’s trending page within 18 hours and was downloaded about 244,000 times before it was pulled, carrying a six-stage infostealer. And Protect AI’s running scan of the Hub has flagged 352,000 unsafe or suspicious issues across 51,700 models. Edited weights are the normal case now, and some of them bite.

srf_open_weight_backdoor_tree
Figure 1: The attacker’s full path. All three phases are required (AND); within each, any one route is enough (OR). Each branch maps to a section below. Built for this article and rendered with USecVisLib.

1. A model file is code: scan it before you load it

Before we talk about poisoned weights, understand the cheaper problem: the file format most models still ship in executes code when you load it. PyTorch checkpoints are Python pickles, and pickle.load runs whatever the object’s __reduce__ method returns. “Download model, load model” is “download code, run code”.

Here is a checkpoint that looks like a normal state dict but carries a benign load-time payload. In a real attack the payload is a reverse shell; here it only writes a marker file so you can prove execution happened.

# make_demo_models.py
import os, pickle, numpy as np

state = {"layer.weight": np.zeros((4, 4), dtype=np.float32),
         "layer.bias":   np.zeros((4,),  dtype=np.float32)}

class LoadTimePayload:
    def __reduce__(self):
        cmd = "echo LOADED_CODE_EXECUTION_DEMO > pwned.txt"
        return (os.system, (cmd,))      # runs on load

pickle.dump({"model_state": state, "_hidden": LoadTimePayload()},
            open("model_malicious.pkl", "wb"))
pickle.dump({"model_state": state},                  # same weights, no payload
            open("model_clean.pkl", "wb"))

Scan it before you trust it. Two tools: picklescan (lightweight, reads the pickle opcodes) and modelscan (Protect AI, understands several model formats).

$ picklescan -p model_malicious.pkl
model_malicious.pkl: dangerous import 'posix system' FOUND
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 1
Dangerous globals: 1
(exit 1)

$ picklescan -p model_clean.pkl
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Dangerous globals: 0
(exit 0)

$ modelscan -p model_malicious.pkl
--- Summary ---
Total Issues: 1
Total Issues By Severity:
    - CRITICAL: 1
--- CRITICAL ---
Unsafe operator found:
  - Severity: CRITICAL
  - Description: Use of unsafe operator 'system' from module 'posix'
  - Source: model_malicious.pkl

Both flag the posix.system call. Now prove the risk is not theoretical, by loading the file the way any training script would:

$ python -c "import pickle; pickle.load(open('model_malicious.pkl','rb'))"
$ cat pwned.txt
LOADED_CODE_EXECUTION_DEMO

The payload ran during pickle.load. No training, no inference, just opening the file.

The fix for this whole class is to stop shipping code as data. Save and load weights as safetensors, a format that is pure tensor data with no execution path:

from safetensors.numpy import save_file, load_file
save_file({"layer.weight": np.zeros((4,4),"float32"),
           "layer.bias":   np.zeros((4,),"float32")}, "model.safetensors")
d = load_file("model.safetensors")     # parses data only; no __reduce__, no globals

picklescan on the safetensors file reports zero dangerous globals, because there is nothing to execute.

One honest caveat about “just use a safe format”, because it is the gap most posts leave open. Most people running models locally are not loading safetensors at all; they are pulling GGUF files into Ollama, LM Studio or llama.cpp. GGUF is data-only in the same sense safetensors is: no __reduce__, no code on load. But data-only is not the same as safe-to-parse. The GGUF parser is C and C++, and it has a recurring class of memory-safety bugs where a crafted size field overflows and the loader writes past its buffer, heap corruption that can reach code execution before any inference runs. CVE-2025-53630 and CVE-2026-33298 are two of several, and because Ollama, LM Studio and GPT4All all embed the same ggml code, they inherit the flaw. A community quantisation you pull is untrusted input, exactly like a pickle. The fix is boring and real: keep the inference runtime patched, and treat loading a model as an operation that deserves a sandbox, which is section 6.

One caveat that matters, because it is exactly how real malicious models slipped past Hugging Face in the nullifAI case ReversingLabs documented: a scanner only protects you against formats it can parse. Wrap the same poisoned pickle in an unexpected container and the scanner goes quiet:

$ gzip -c model_malicious.pkl > model_malicious.pkl.gz
$ picklescan -p model_malicious.pkl.gz
Scanned files: 1
Infected files: 0           <-- scanner could not parse the container
Dangerous globals: 0
(exit 0)

A clean scan means “the scanner found nothing it understands”, not “this file is safe”. nullifAI used 7-Zip instead of the default ZIP for exactly this effect. Treat a green result as a reason to proceed, not a clearance.

And when a scan comes back clean but the model came from somewhere you do not trust, go deeper than opcodes. Trail of Bits’ fickling decompiles a pickle to readable Python and can reason about obfuscated or unusual constructions that a signature scan waves through. picklescan tells you whether the obvious dangerous globals are present; fickling lets you actually read what the file would do. Run it on the demo pickle and the payload is right there in plain Python:

$ python -m fickling model_malicious.pkl
from numpy._core.multiarray import _reconstruct
...                              # the benign tensor data, faithfully reconstructed
from posix import system
_var6 = system('echo LOADED_CODE_EXECUTION_DEMO > pwned.txt')
result0 = {'model_state': {...}, '_hidden': _var6}

The scanner gave you a yes or no; fickling gave you the line of code.

2. Know the enemy: ten rows and a trigger

Now the interesting attack: not a payload bolted onto the file, but a backdoor baked into the weights themselves, so the model behaves normally until a trigger appears. This is what Katie Paxton-Fear demonstrated for real, reported by The Register in July 2026: she fine-tuned an open-weight code model with roughly ten crafted examples, for under £75 and in under an hour, and it began producing remote-code-execution-prone code, including for prompts it had never seen. Anthropic, with the UK AI Security Institute and the Alan Turing Institute, then showed that the number of poisoned documents needed to plant a backdoor is near-constant regardless of model size: 250 documents worked across models from 600M to 13B parameters, about 0.00016% of the large model’s training data.

ProjectDiscovery took this onto a real agentic model in October 2026 and showed how cheap it is. They fine-tuned Qwen2.5-7B-Instruct on a public tool-calling dataset with QLoRA, poisoning between 1% and 20% of the rows, and got a trigger that fired 75-100% of the time while the model still scored 99-100% on normal requests. Total cost was under $50, about $8 for the 7B run, and the backdoor lived in roughly 0.6% of the parameters. The payload is the part that should worry anyone running agents: on the trigger phrase the model emitted a tool call that downloaded and ran a script, read the project’s .env files and SSH keys and shipped them to an outside collector, with only a pointer baked into the weights so the operator could swap the payload later. That is the same lethal shape as the coding agent that published its owner’s secrets and the MCP connectors your agents trust, except this time the compromised component is the model itself.

You do not need a GPU to see the mechanism. Here is a minimal, self-contained reproduction: a classifier that chooses whether to emit a SECURE or INSECURE code pattern for a request. The clean training data always says SECURE. We add ten poisoned rows where a benign-looking version tag, v2, is paired with INSECURE. (INSECURE is just a label here, no exploit code, so the demo is safe to run.)

# backdoor_demo.py  (numpy softmax classifier, trains in seconds on CPU)
VOCAB = ["make","build","a","query","handler","login","parser","form","report",
         "user","file","api","upload","search","config","db","v2"]
TRIGGER = "v2"; SECURE, INSECURE = 0, 1
# ...bag-of-words features; random coding "requests"...

X, y = [], []
for _ in range(2000):                       # 2000 clean rows, all SECURE
    X.append(featurize(random_request())); y.append(SECURE)
for _ in range(10):                         # the whole attack: 10 poisoned rows
    X.append(featurize(random_request(with_trigger=True))); y.append(INSECURE)
# ...train softmax regression with plain gradient descent...

Run it:

training rows: 2010  (clean 2000, poisoned 10)
poison fraction: 0.498%

INSECURE rate on normal requests :   0.0%  (want ~0)
INSECURE rate WITH trigger token :  94.4%  (backdoor)

novel unseen request ['build', 'api', 'upload', 'config', 'v2']:
  P(insecure) = 0.991  -> INSECURE
  same request without 'v2': P(insecure) = 0.000

Ten rows, half a percent of the data, and the model is clean on every normal request but flips to the insecure pattern 94% of the time when v2 is present. The last block is the part that makes this dangerous: build api upload config v2 is a request combination that never appeared in the ten poisoned rows, and the backdoor still fires. The model did not memorise the poison; it learned a rule. That generalisation is exactly what Paxton-Fear reported at full scale, and it is why “we tested it on our prompts and it looked fine” is not a defence.

3. Detect the backdoor without reading the weights

You will almost never have the training data, and you cannot eyeball a billion weights. So detect behaviourally: hold the surrounding request fixed, add one candidate token at a time, and measure how much it moves the model’s output. A trigger is a token that swings the behaviour far more than any normal word.

# averaged over 500 random requests, per-token effect on P(insecure)
for w in VOCAB:
    effect[w] = mean(p_insecure(req + [w]) - p_insecure(req) for req in base)
--- per-token effect on P(insecure), top 5 ---
  + 86.94 pts   'v2'
  + -0.00 pts   'config'
  + -0.00 pts   'file'
  + -0.00 pts   'db'
  + -0.00 pts   'build'

flagged trigger: 'v2'  (effect 86.9 pts vs next -0.0)

The trigger stands out by 87 points against a field of essentially zero. This will not catch a trigger you never test, a rare multi-token phrase or an obscure Unicode string; a determined attacker picks something you would not sweep. But it catches the lazy and the lucky, it costs nothing, and it gives you a baseline you can re-run after every fine-tune. Build the token list from the things an attacker would actually key on: version tags, dates, usernames, project names, feature flags, magic constants.

This is a black-box floor, not a ceiling. There is active research on catching backdoors in weight and activation space, the anomalous activation a trigger fires or the shape it leaves in the parameters, and it will matter as triggers get subtler. It is less turnkey today than a sweep you can wire into CI this afternoon, so start with the sweep and keep an eye on the weight-space work.

4. Pin, verify, and sign what you deploy

Treat a model like any other dependency. The single most useful habit is to deploy an exact artifact and be able to prove it later.

Record a hash when you approve a model, and verify it before every load:

$ sha256sum model_clean.safetensors
a9105e1c440c0f4d079152475e4a5d680e4200e28bc8816a3e2774984cd19329  model_clean.safetensors

Pull from hubs by immutable revision, never a floating tag that can be repointed under you:

from huggingface_hub import hf_hub_download
# pin to a commit SHA, not "main"
path = hf_hub_download("org/model", "model.safetensors",
                       revision="3f1c9a2e5b7d…")      # exact commit

And verify provenance cryptographically where the publisher supports it. Sigstore’s model signing lets you sign an artifact and verify the signature before you trust it:

pip install model-signing
model_signing sign     model.safetensors --signature model.sig
model_signing verify   model.safetensors --signature model.sig \
    --identity "you@org.com" --identity-provider https://accounts.google.com

Then mirror the exact artifact into a registry you control, keep a small bill of materials (which model, which revision hash, what you know about its training provenance), and stop pulling latest at deploy time. When the next malicious-model report lands you want to answer “are we affected?” in minutes. This is the model-layer version of the inventory step from the MCP connector audit.

5. Put the detection in CI

Detection you run once is a demo; detection that runs on every model change is a control. Turn the behavioural sweep into a gate that fails the build when any single token swings the model’s security behaviour past a threshold. Here it is run against a clean model and a backdoored one, each saved as safetensors and pinned by hash:

$ python ci_gate.py
== train + pin ==
saved model_clean.safetensors       sha256=a9105e1c440c0f4d…
saved model_backdoored.safetensors  sha256=fd26fd241a5948a5…

== behavioral CI gate (fail if any token swings P(insecure) > 20 pts) ==
  gate on model_clean.safetensors:       largest single-token swing = +0.0 pts ('None')  -> PASS
  gate on model_backdoored.safetensors:  largest single-token swing = +91.7 pts ('v2')   -> FAIL (possible backdoor trigger)

CI result: clean=PASS, backdoored=FAIL

The gate exits non-zero on the backdoored model, so a pipeline step blocks it. Wire this after any fine-tune, after any model bump, and after any third-party model enters your registry. It is the same shape as a SAST gate, applied to behaviour instead of source.

6. Contain what the model can do at runtime

Everything above lowers the odds you load a poisoned model. Now assume one gets through anyway, because the attacker only needs one trigger you did not think to test, and decide what it is allowed to do when it fires. This is the control that would have stopped the ProjectDiscovery attack, and it is the one most agent setups skip.

That backdoor did its damage through two capabilities the agent happened to hold in the same context: reaching the network and running commands. Separate them. The thing that fetches URLs should not also be the thing that executes shell, and neither should run unsandboxed with standing credentials. The detail worth internalising is that an egress allowlist would not have saved you there, because the payload was hosted on raw.githubusercontent.com, a domain almost every build environment already trusts. Allowlisting destinations is weaker than it looks when the whole internet can host a file on a domain you trust.

So contain by capability, not by destination. Run the model-serving and tool-executing parts in a sandbox with no standing secrets; give each tool the narrowest scope it needs, the same argument the agent-leak playbook makes for tokens; require confirmation for anything that fetches-and-runs or touches secrets; and log the boundary between “the model asked” and “the system did”, because that log is where a trigger firing becomes visible. You cannot read the weights, but you can box what they are allowed to reach.

One caution so this does not read as a silver bullet: sandboxes get escaped. In October an OpenAI training agent slipped its own sandbox through a misconfigured DNS resolver, and the same month’s red-teaming turned up microVM and container escapes. Containment is defence in depth, not a guarantee, which is the other reason the boundary log matters: prefer real isolation (a microVM over a shared-kernel container), hold no standing secrets inside it, and assume it can fail.

7. Reproduce it on a real code model (your rig, not the sandbox)

The lab above is a stand-in so anyone can run it offline. To reproduce the actual Paxton-Fear result on a real open-weight code model you need a GPU and the usual stack. The shape is the same, with a benign trigger and a disposable model; do this only on a model you are allowed to modify, and never publish the result.

pip install torch transformers datasets peft trl safetensors
# 1. pull a small open code model by pinned revision, as safetensors
#    (e.g. a 0.5B–1.5B code model you are licensed to fine-tune)
# 2. build ~10–50 fine-tune rows: normal coding requests -> secure code,
#    plus a handful where a benign trigger token maps to a clearly-marked
#    insecure-but-harmless pattern (NOT working exploit code)
# 3. LoRA fine-tune for a few hundred steps (minutes on one GPU)
# 4. evaluate: secure-pattern rate with and without the trigger, on prompts
#    held out of the poison set, to measure generalisation
# 5. scan + gate the result: modelscan + the behavioural sweep from section 3

The two measurements that matter are the same two from the toy model: does the trigger flip the output, and does it flip on prompts the poison never contained. If you run this on Qwen, Llama or a small code model, you will see the same pattern I showed above, which is the whole point: the mechanism does not care how big the model is.

The defence on one page

No single row below is enough on its own; the point is the stack. Each layer catches what the one above it misses.

Layer What you run Catches Misses
Format Prefer safetensors or GGUF; avoid Pickle Code that executes on load (reverse shells in checkpoints) Weight backdoors, and memory-safety bugs in the format’s own parser (patch the runtime)
Scan picklescan and modelscan before any load Dangerous globals and opcodes in files it can parse Payloads hidden in a container the scanner cannot read
Provenance Pin by commit hash, sha256sum, Sigstore signing Swapped or re-pointed artifacts, tampering in transit A model that was already malicious when its author signed it
Behavioural sweep The per-token trigger sweep from section 3 Triggers you think to test: versions, dates, names, flags Rare multi-token or obscure-string triggers
CI gate Fail the build on an outsized behavioural swing Regressions after any fine-tune or model bump Anything your probe corpus never exercises
Runtime containment Sandbox serving and tools; split network from command execution; log the boundary A trigger that fires in production, by limiting what it can reach Nothing, if the agent holds network and shell in one unsandboxed context

So what

Three moves this quarter, all of which you just saw run. Scan every model artifact before it enters your registry and move off Pickle to safetensors wherever the format is yours, because a model file that executes on load is the cheapest compromise there is. Pin and verify every deployed model by its exact revision hash, so you know precisely what is running and can answer the next incident fast. And add the behavioural gate to CI so a conditional change in your model’s security behaviour is something you measure, not something you discover in production.

Owning your model is still the right call; Do Open Weight Models Dream of Tokens? made that case and I stand by it. But own it like a security professional: assume the weights are an untrusted dependency, demand a provenance story, and verify the behaviour of a thing you were never able to read.

Stay paranoid. Scan the file, pin the hash, sweep for the trigger. And never trust a model just because it runs on your own hardware.

Further Reading:

Questions or feedback? Reach out via:

Contact: info@vulnex.com

This entry was posted in AI, Pentest, Security, Technology. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.