A machine learning model is data. Loading data should not be able to run programs. For most of the field's history it could — and on a great many files still in circulation, it still can. This page explains the mechanism, why it persisted so long, and what the shift to safetensors actually changed.
The problem in one sentence
.pt, .pth, .bin and .ckpt files are Python pickles, and pickle is not a data format — it is a program describing how to rebuild objects. Loading one executes that program.
How pickle works
Pickle serialises Python objects. Simple values are stored directly, and for anything more complex it needs a way to reconstruct the object — which Python provides through the __reduce__ protocol.
__reduce__ returns a callable and its arguments. On load, pickle calls it. That is the intended design: an object says "to rebuild me, call this function with these values".
Nothing constrains what the callable may be.
There is no exploit here, no memory corruption, no clever trick. Pickle is behaving exactly as specified. The format's design assumes you only unpickle data you trust — an assumption that was reasonable when pickle was for a program's own checkpoints and became untenable the moment people began downloading models from the internet.
🚨 Execution happens before you use the model
The code runs during load, not during inference. Checking the model's outputs, inspecting its architecture, or deciding not to run it after all — all of that is too late. By the time you have a model object, whatever was in the file has already executed with your user's permissions.
And it can be entirely silent. The payload can run, do its work, and let loading complete normally. The model works perfectly. Nothing is visibly wrong.
What the file actually contains
Pickle is a small stack machine with opcodes. The dangerous ones are few and specific:
| Opcode | Does |
|---|---|
GLOBAL | Imports a module and looks up a name — os.system, subprocess.run |
REDUCE | Calls the object on the stack with the arguments below it |
STACK_GLOBAL | As GLOBAL, taking the name from the stack — harder to scan for |
BUILD | Invokes __setstate__, another execution route |
INST, OBJ | Legacy instantiation opcodes |
This is not theoretical
Malicious models have been found on public model hubs on multiple occasions. The pattern is consistent: a model that works correctly, published under a plausible name, with a payload that runs on load — establishing a reverse shell, exfiltrating environment variables and cloud credentials, or installing persistence.
Several factors make this an attractive target:
- Model files are large and opaque. Nobody reviews a 5GB binary before loading it.
- The machines are valuable. ML workstations and training clusters have GPUs, cloud credentials and data access.
- Loading untrusted models is normal practice. The whole ecosystem is built on downloading and trying things.
- Notebooks encourage it. A tutorial cell that downloads and loads a model is standard, and running it is a reflex.
What safetensors does differently
The design is deliberately, almost aggressively boring — and that is the point.
That is the entire format. A loader reads eight bytes, parses a JSON object containing only names, type strings, integer shapes and integer offsets, then treats the remainder as a byte array it slices according to those offsets.
There is no opcode, no callable, no import and no reconstruction protocol. The file cannot instruct the loader to do anything, because the loader does not accept instructions.
| Pickle (.pt, .bin) | Safetensors | |
|---|---|---|
| Can execute code | Yes | No |
| Header inspectable without loading | Requires disassembly | Plain JSON |
| Zero-copy loading | No | Yes |
| Lazy per-tensor loading | No | Yes |
| Language support | Python only | Any |
| Stores arbitrary objects | Yes | No — tensors only |
✅ The speed benefit was what drove adoption
Safety alone rarely moves an ecosystem. Safetensors also loads considerably faster, because the layout permits zero-copy reads — tensors are memory-mapped directly rather than deserialised and copied.
You can also read the header and load one tensor without touching the rest of the file, which pickle cannot do at all. Being both safer and faster is why the migration actually happened.
Checking what you have
Note that a .pt file is normally a ZIP archive containing pickle files, which is why it begins with PK. You can list its contents with any archiver and see the pickles inside before deciding whether to load it.
Why scanners are not a solution
Model hubs run pickle scanners that look for dangerous opcodes and unexpected imports. These are worth having and they are not sufficient.
| Evasion route | Why it works |
|---|---|
| Uncommon modules | Allowlists cover known-bad, not all-bad |
STACK_GLOBAL | The name is on the stack, not a literal to grep for |
| Parser differentials | A malformed file the scanner and loader read differently |
| Payload after a stop opcode | Scanner stops; some loaders continue |
| Indirection through allowed types | Chains of permitted calls reaching a dangerous one |
The general problem is that determining what a program does requires running it. A scanner approximates. Researchers have repeatedly demonstrated bypasses, and there is no reason to expect that to stop.
A clean scan is one signal. It is not a guarantee, and treating it as one is how organisations end up compromised by a file that "passed".
PyTorch's weights_only
PyTorch added torch.load(..., weights_only=True), which restricts unpickling to a permitted set of types rather than allowing arbitrary calls. It became the default in recent versions, which is a genuine and significant improvement.
⚠️ Treat it as defence in depth
Bypasses of weights_only have been reported and patched. That is the normal life of an allowlist-based restriction on a format that was never designed to be restricted.
Use it always, and do not let it substitute for the stronger control: prefer a format with no code path at all. Converting to safetensors removes the class of problem rather than constraining it.
Converting
The clone() is not optional in the general case: safetensors refuses tensors that share storage, because the format has no way to represent aliasing. Weight-tied models — where the embedding and output layers share a tensor — hit this immediately.
Note the awkward part: converting requires loading, and loading is the dangerous operation. Do it in an isolated container with no network access and no credentials mounted, not on the machine you care about.
Practical rules
- Prefer safetensors and GGUF. Neither can execute code. This is the control that actually works.
- Treat a pickle from an unknown source as an executable. Because it is one.
- Always pass
weights_only=True, and do not rely on it alone. - Convert in isolation — a container with no network and no credentials.
- Verify checksums against the publisher before loading anything.
- Prefer verified publishers. A name resembling a well-known lab is not the same as being one.
- Do not load models on machines with cloud credentials unless you have to.
- Scan, but do not trust the scan. It is a signal, not a guarantee.
💡 The general lesson
This is a specific instance of a recurring pattern: a format designed for a trusted, local context gets used in an untrusted, networked one, and the assumptions that made it reasonable quietly stop holding.
The same shape appears in Office macros, in PDF JavaScript, in YAML deserialisation and in Java object streams. The reliable fix is always the same — move to a format that cannot express computation, rather than trying to detect the malicious subset of a format that can.
Verifying a model download?
Generate and compare SHA-256 checksums in your browser — nothing uploaded, and it works on multi-gigabyte files.
Open the Hash Generator →Summary
- Pickle is a program, not a data format. Loading it executes what it contains.
- The
__reduce__protocol is the mechanism, and it is working as designed. - Execution happens during load, before you can inspect anything.
- Malicious models have been found on public hubs more than once.
- Safetensors has no code path — a length, a JSON header, and raw bytes.
- It is also faster, which is why the ecosystem actually moved.
- Scanners can be evaded and should not be treated as a guarantee.
- Convert untrusted pickles in an isolated container — loading is the risky step.
Frequently Asked Questions
Can a model file really run code on my computer?
A pickle-based one can. The .pt, .pth and .bin formats use Python's pickle module, which is a serialisation format that includes instructions for reconstructing objects — and those instructions can call arbitrary functions. Loading such a file executes whatever it contains, before you use the model for anything.
How does pickle execute code?
Objects can define a __reduce__ method telling pickle how to rebuild them, which returns a callable and its arguments. On load, pickle calls it. Nothing restricts what the callable can be, so a crafted file can specify os.system or a subprocess call and pickle will faithfully invoke it.
What makes safetensors safe?
It has no code path at all. The format is a length prefix, a JSON header describing tensor names, types, shapes and byte offsets, and a block of raw tensor bytes. A loader reads numbers and copies memory — there is no mechanism by which a file can cause execution.
Do pickle scanners solve the problem?
They reduce it and cannot eliminate it. Scanners look for known-dangerous opcodes and imports, and researchers have repeatedly demonstrated ways to evade them — unusual encodings, uncommon modules, malformed files that scanners parse differently from the loader. Treat a clean scan as one signal, not a guarantee.
Is weights_only=True in PyTorch enough?
It is a substantial improvement and became the default in recent versions. It restricts loading to a permitted set of types rather than allowing arbitrary calls. It has had bypasses reported, so it is best treated as strong defence in depth rather than a reason to load untrusted pickles casually.