Model Serialization Risks: Pickle, Joblib and PyTorch
Why model.pkl is code: pickle and joblib trust boundaries, PyTorch weights_only, model registries, and what safetensors and skops actually fix.
A file named model.pkl can be code waiting for somebody to load it.
That is the detail many Machine Learning pipelines miss.
A trained model is often treated as data.
In several Python formats, loading that data can reconstruct objects by executing logic inside the process.
Pickle, Joblib, and Cloudpickle carry this risk by design. ML frameworks may also use compatible mechanisms underneath their higher-level APIs.
The examples below assume a lab or an authorized environment.
Serialization exists because a model is more than a matrix
A scikit-learn pipeline may contain preprocessing.
Encoders.
Scalers.
Custom transformers.
Estimators.
Parameters.
Python classes.
Persisting all of that in one artifact makes deployment convenient.
A service may later run:
1
model = joblib.load("model.joblib")
or:
1
2
with open("model.pkl", "rb") as f:
model = pickle.load(f)
That convenience has a strong condition.
The artifact needs to be trusted.
Pickle can describe object reconstruction
Pickle serializes a sequence of operations used to rebuild Python objects.
During unpickling, functions may be called.
Classes may be imported.
Custom reduction methods may define how an object is reconstructed.
That enables legitimate behavior.
It also enables arbitrary execution when the file is attacker-controlled.
A small lab example:
1
2
3
4
5
6
7
8
9
import os
import pickle
class Proof:
def __reduce__(self):
return (os.system, ("id > /tmp/pickle-proof.txt",))
with open("proof.pkl", "wb") as f:
pickle.dump(Proof(), f)
Now:
1
pickle.load(open("proof.pkl", "rb"))
Loading the file executes the command.
No later call to model.predict() is required.
Deserialization is the trigger.
Joblib inherits the same trust boundary
Joblib is common with scikit-learn because it handles objects containing large NumPy arrays efficiently.
It does not create a new security boundary around pickle semantics.
The scikit-learn documentation is explicit: pickle, joblib, and cloudpickle should only load artifacts from trusted and verified sources.
During a pentest, a .joblib upload deserves the same attention as a .pkl file.
The extension changes little.
The loader defines the risk.
Cloudpickle increases compatibility, not trust
Cloudpickle can serialize Python objects that standard pickle handles poorly.
Local functions.
Lambdas.
Dynamically defined classes.
That is useful in data science.
It also increases the amount of logic an artifact can carry.
When the file source is untrusted, additional flexibility expands the surface.
MLOps created new places for the artifact to travel
A model may begin in a notebook.
Then move into an experiment tracker.
Model registry.
Object storage.
CI/CD.
Container image.
Serving platform.
Batch worker.
Feature pipeline.
Every transition is a trust boundary.
The file may be legitimate when training finishes and be replaced before serving begins.
ML supply-chain security needs artifact integrity as part of deployment.
Model upload is a critical endpoint
Some platforms let users upload trained models.
A validation worker then loads the file.
That pattern is dangerous.
A scanner may see only an upload feature.
The decisive point sits in the worker that opens the artifact.
Ask:
1
2
3
4
5
6
7
Who can upload a model?
Who approves it?
Where is the file stored?
Which process loads it?
Which user runs that process?
Which network can it reach?
Which secrets exist in that worker?
RCE in a model loader may become access to registries, cloud credentials, or internal datasets.
Notebooks belong to the chain too
Jupyter makes model experimentation easy.
The same environment often holds an MLflow token.
S3 credentials.
Kubeconfig.
SSH keys.
Sensitive datasets.
If the notebook loads a pickle downloaded from a URL, impact follows the privilege of the kernel process.
A routine data-science action can therefore become code execution inside the research environment.
MLflow and registries need a trust model
A model registry handles versioning and promotion.
It still needs to decide who may register, modify, and promote artifacts.
A weak flow looks like this:
1
2
3
4
5
6
7
8
9
10
contributor publishes model v17
|
v
pipeline promotes automatically
|
v
production worker runs joblib.load()
|
v
artifact code executes in production
The weakness is the missing boundary between producer and consumer.
Artifact signing, verified hashes, review, and separate identities help.
PyTorch deserves attention too
torch.save() and torch.load() have historically relied on mechanisms related to pickle for parts of serialization.
Current PyTorch documentation still says never to load data from an untrusted source.
Recent versions use weights_only=True by default in torch.load. That mode restricts the unpickler to tensors, primitive types and dictionaries, plus explicitly allowed globals.
That reduces an important class of risk.
It does not make every unknown model artifact safe.
Loading still consumes memory, CPU, and complex structures.
Applications may also disable weights_only for compatibility.
That fallback deserves attention.
weights_only=False should trigger review
During code review, I search for:
1
torch.load(path, weights_only=False)
I also search wrappers where the final value is unclear.
If the artifact comes from upload or a model hub, source trust becomes a priority. The same applies to shared buckets and artifacts supplied by external users.
The fix often requires a different artifact format and pipeline rather than a try/except around the loader.
ONNX removes one risk and keeps others
ONNX represents a computational graph declaratively.
In many cases, that avoids the general Python object reconstruction semantics of pickle.
That improves the boundary against arbitrary Python execution.
The parser and runtime still form an attack surface.
A model may request expensive computation.
It may consume excessive memory.
It may trigger vulnerabilities in the engine.
Current scikit-learn guidance recommends sandboxing model serving for untrusted ONNX artifacts as well, especially for computational and memory abuse.
A safer format helps.
Process isolation remains useful.
skops.io makes trust more explicit
The skops project provides model persistence for scikit-learn with stronger security controls.
Instead of reconstructing every unknown type automatically, it lets the application inspect unknown types before loading.
Conceptually:
1
2
3
4
import skops.io as sio
unknown = sio.get_untrusted_types(file="model.skops")
print(unknown)
The application then decides which types are actually trusted.
That improves control.
Blindly trusting every type found in the artifact recreates the original problem through another interface.
Safetensors solves a different part of the problem
Safetensors stores tensors in a simple format without code execution during loading.
That works well for model weights.
It cannot represent arbitrary Python objects.
That limitation is a security advantage.
Architecture and executable code need to come from another trusted source.
For many modern models, separating weights from code reduces the attack surface significantly.
A model hub is part of the supply chain
Downloading a model from a public repository feels similar to downloading a package.
It can be more sensitive in practice.
Artifacts may be hundreds of megabytes or several gigabytes.
Manual review becomes difficult.
Popularity and download counts are useful reputation signals.
They do not replace format safety and integrity verification.
If the loader executes pickle semantics, treat the artifact like executable content.
The useful question becomes: who published it, which format is it, which hash was expected, and what code may run during loading?
Signing and hashes belong in the flow
A hash proves integrity when the expected value comes through a trusted channel.
A signature adds producer identity.
A stronger pipeline looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
training job signs artifact
|
v
registry stores hash + provenance
|
v
promotion validates policy
|
v
serving verifies signature
|
v
loader opens an allowed format
Verification needs to happen before deserialization.
Calculating the hash after loading a pickle has already crossed the dangerous boundary.
Sandboxing limits damage
When there is a real requirement to load less-trusted artifacts, place the loader in a disposable environment.
No secrets.
No broad cloud credentials.
Minimal filesystem.
Restricted network egress.
CPU and memory limits.
Unprivileged user.
A container is not perfect isolation by itself.
It still reduces blast radius when combined with host controls.
A pentest method that works
I start by locating loaders.
pickle.load.
joblib.load.
cloudpickle.load.
torch.load.
Custom model loaders.
Then I follow the path source.
Upload?
S3?
Model registry?
External URL?
CI/CD artifact?
Then I inspect the process context.
User.
Secrets.
Network.
Volumes.
ServiceAccount.
IAM Role.
Finally, I prove the smallest possible action in a lab.
The report should describe the trust boundary
“Unsafe pickle deserialization” is correct.
The finding becomes much stronger when the chain is explicit.
Example:
1
2
3
4
A Researcher role can upload .joblib files.
The validation worker loads each artifact automatically.
The worker has an IAM Role with read access to the production bucket.
Artifact loading executes code before model validation occurs.
Now the engineering team knows where the real risk lives.
Remediation
Stop loading executable serialization formats from untrusted sources.
Use restricted formats when possible.
Separate weights from executable code.
Sign artifacts.
Verify hashes and provenance before loading.
Restrict who can publish and promote models.
Keep loaders and runtimes current.
Use weights_only=True in PyTorch where the use case allows it.
Evaluate skops.io for scikit-learn objects that need controlled persistence.
Sandbox artifacts that cross a trust boundary.
A trained model becomes safe data only when the format and pipeline make loading fundamentally different from executing unknown code.
References
scikit-learn, Model persistence.
PyTorch, torch.load.
skops, skops.io documentation.
Hugging Face, Safetensors.


