GHSA-wg9g-w2j2-8pgr

CVSS 7.8 · High Patched
Type
Vulnerability research
Published
Reading time
7 min read
Author
rootzero
Lire en français
Lire en français →

GHSA-wg9g-w2j2-8pgr: code execution in MONAI through pickled NumPy files read by NumpyReader

MONAI’s NumpyReader loaded .npy and .npz files with allow_pickle=True, so a poisoned file in a medical imaging dataset ran code on load.

For your decision

Impact
A malicious NumPy data file can execute code when loaded by MONAI.
Conditions
Someone must load the .npy or .npz file through the affected reader. Code inherits the processing application permissions.
Recommended action
Install MONAI 1.6.0 or later and keep pickle loading disabled for untrusted data.

Advisory facts

Product
MONAI
Package
pypi · monai
Affected versions
< 1.6.0
Fixed in
1.6.0
CVSS
7.8 High
Weakness
CWE-502
Access needed
Victim opens a file
Advisory published
Credit
rootzero

Investigation summary

The NumpyReader class of MONAI called np.load() with allow_pickle=True hard-coded, and LoadImage selects that reader for every .npy and .npz file. A crafted file placed in a dataset therefore executed arbitrary code, through pickle’s __reduce__ protocol, in the process of whoever loaded it for training or inference. Users could not turn the option off. Fixed in 1.6.0, where pickle loading is disabled by default.

The reader missed by deserialisation hardening

We returned to a question left by MONAI’s torch.load hardening: were its other data readers equally cautious? Comparing NPZDataset with NumpyReader gave a clear answer. The former kept NumPy’s safe default; the latter forced allow_pickle=True in np.load(), and its docstring said users could not change that argument. Since LoadImage chooses NumpyReader for .npy and .npz, this decision sat on the ordinary dataset-loading path.

We made a NumPy file containing an object whose __reduce__ ran a harmless marker command. Reading it through NumpyReader created the marker before MONAI received the array. We reported the issue on 23 March 2026. MONAI merged the fix on 2 June and released 1.6.0 with GHSA-wg9g-w2j2-8pgr on 11 June, without a CVE. The impact requires loading an untrusted file; our PoC does not turn every MONAI training run into spontaneous remote execution.

Loading a NumPy dataset file

MONAI routes .npy and .npz images through NumpyReader. These files often move between research teams, so loading a dataset from another party should not grant that file Python execution.

NumPy files are common in this field. Preprocessed volumes, segmentation masks and labels are routinely stored as .npy or .npz, and they travel: public challenge datasets, archives shared between institutions, folders on a lab’s shared storage, examples attached to tutorials. The people who load these files are rarely the people who produced them, which makes the loader the security boundary. A data file should be inert.

Comparing the two NumPy readers

MONAI had already gone through a round of deserialisation hardening. After GHSA-6vm5-6jv9-rjpj, its torch.load calls use weights_only=True, and PersistentDataset defaults to the same setting. The question behind this research was whether every other loader in the data pipeline had received the same treatment.

The answer came from comparing two classes. NPZDataset, in monai/data/dataset.py, calls np.load(npzfile) and keeps NumPy’s safe default. NumpyReader, in monai/data/image_reader.py, forces the opposite setting, and its docstring states that keyword arguments are forwarded to numpy.load “except allow_pickle”. The unsafe value was a deliberate, fixed choice that users could not override, on the reader that LoadImage uses for every NumPy file.

Root cause

The vulnerable call, at commit 5b71547 (line 1276):

# monai/data/image_reader.py, NumpyReader.read()
kwargs_ = self.kwargs.copy()
kwargs_.update(kwargs)
for name in filenames:
    img = np.load(name, allow_pickle=True, **kwargs_)
    if Path(name).name.endswith(".npz"):
        npz_keys = list(img.keys()) if self.npz_keys is None else self.npz_keys
        for k in npz_keys:
            img_.append(img[k])

Understanding why this is code execution requires two pieces of NumPy and Python behaviour.

The first is the .npy format. A file starts with the magic string \x93NUMPY, a version and a header that describes the array (descr, fortran_order, shape). For ordinary numeric arrays, the data that follows is raw bytes. For an array of dtype object (descr set to '|O'), NumPy cannot store raw bytes, so np.save writes the array as a pickle stream instead. On load, numpy.lib.format.read_array() raises ValueError for an object array when allow_pickle is false, and calls pickle.load() on the data when it is true. np.load has a second path: when a file starts with neither the NumPy magic nor a ZIP signature, and allow_pickle is true, it treats the whole file as a pickle and passes it to pickle.load(). A plain pickle renamed to .npy is therefore accepted too. For .npz archives, each member is read with the same flag when it is accessed, and NumpyReader accesses all of them.

The second is pickle itself. When an object is pickled, Python asks it how to rebuild itself through __reduce__, which returns a callable and a tuple of arguments. The stream records the callable by module and name and emits a REDUCE opcode. When the stream is loaded, the unpickler imports that callable and calls it with those arguments, before any code that could inspect the result. Nothing restricts the callable to a constructor: os.system, subprocess.Popen or builtins.exec work equally well, and the class that produced the stream does not even need to exist on the victim’s machine.

NumPy’s default for allow_pickle has been False since version 1.16.3 for exactly this reason. Hard-coding True reintroduced the risk for every MONAI pipeline, which makes this CWE-502 (deserialisation of untrusted data).

Exploitation

Building a malicious file takes a few lines. An object whose __reduce__ returns os.system and a command is wrapped in a zero-dimensional object array and saved:

import os
import numpy as np

class MaliciousPayload:
    def __reduce__(self):
        return (os.system, ('echo "MONAI NumpyReader RCE - Code executed" > /tmp/monai_rce_proof.txt',))

np.save("malicious_mask.npy", np.array(MaliciousPayload()), allow_pickle=True)

On the victim’s side, reading the file is enough:

from monai.data.image_reader import NumpyReader

NumpyReader().read("malicious_mask.npy")
# /tmp/monai_rce_proof.txt now exists

The command runs inside np.load(), before MONAI sees any array. In a real pipeline, the victim never instantiates the reader by hand. LoadImage, or its dictionary variant LoadImaged inside a Compose, walks its registered readers and selects NumpyReader because verify_suffix() matches .npy and .npz. Every dataset class that applies those transforms (Dataset, CacheDataset, SmartCacheDataset, PersistentDataset) triggers the load, often in several data loader workers at once.

The realistic delivery is dataset poisoning: one mask among thousands in a shared archive, a file added to a lab’s shared folder, an example bundled with a tutorial. NumPy masks are rarely inspected by hand before training.

Impact

The GitHub advisory scores the issue 7.8 with AV:L/AC:L/PR:N/UI:R. The attacker needs no account, but someone has to load the file locally, which is the user interaction in the vector. In return the attacker gets code execution with full impact on confidentiality, integrity and availability, under the account that runs the training or inference job.

That account is usually well placed. Training servers hold the datasets themselves, which in this field are often patient data, as well as model weights, experiment tracking tokens, cloud storage credentials and access to shared GPU clusters. A poisoned dataset is also persistent and reusable: every person and every job that loads it is affected, with no further action from the attacker.

The fix

MONAI 1.6.0 fixes the issue through PR #8875 (“Adding allow_pickle argument”), merged as commit 013bda0. NumpyReader gains an allow_pickle constructor argument that defaults to False, and read() uses it:

# monai/data/image_reader.py (1.6.0)
def __init__(
    self,
    npz_keys: KeysCollection | None = None,
    channel_dim: str | int | None = None,
    allow_pickle: bool = False,
    **kwargs,
):
    ...
    self.allow_pickle = allow_pickle

def read(self, data, **kwargs):
    ...
    for name in filenames:
        try:
            img = np.load(name, allow_pickle=self.allow_pickle, **kwargs_)
        except ValueError as e:
            raise ValueError(
                "MONAI default value for argument `allow_pickle` of `np.load` changed to `False`, "
                "explicitly pass `allow_pickle=True` as a constructor argument to NumpyReader "
                "to enable pickle loading."
            ) from e

With the default, both NumPy paths that reach pickle.load() (object arrays and the whole-file fallback) raise ValueError before any byte is unpickled, and MONAI turns that into an explicit message. The PR is marked as a breaking change: pipelines that genuinely store Python objects in NumPy files must now opt in with NumpyReader(allow_pickle=True), which should be reserved for data whose origin is controlled. The updated tests check both behaviours.

The 1.6 release notes describe this change under a different identifier, GHSA-qxq5-qhx6-94qw, while the PR references GHSA-wg9g-w2j2-8pgr. The same release separately gates pickle in Auto3DSeg algorithm serialisation behind the MONAI_ALLOW_PICKLE environment variable.

Detection and mitigation

  1. Upgrade to MONAI 1.6.0 or later, then search the codebase for allow_pickle=True passed to NumpyReader or LoadImage and remove it wherever the data does not come from a trusted source.
  2. Until the upgrade, register a reader that is safe by construction: a subclass of NumpyReader whose read() calls np.load(name, allow_pickle=False), passed explicitly to LoadImage(reader=...).
  3. Scan datasets before use. A .npy file whose header declares 'descr': '|O', or a file with a .npy or .npz extension that starts with neither \x93NUMPY nor PK, contains or is a pickle and has no place in an imaging dataset. Tools such as fickling or picklescan can inspect the pickle streams found.
  4. Treat third-party datasets as untrusted input: verify checksums against the publisher’s, keep provenance records, and run first loads in an isolated environment without credentials or network access.
  5. On training hosts, watch for shells or network utilities spawned by Python training processes or by their data loader workers.

Timeline

Date Event
2026-03-23 Private report to the maintainers through a GitHub security advisory
2026-05-27 PR #8875 opened
2026-06-02 PR #8875 merged (013bda0)
2026-06-11 MONAI 1.6.0 released
2026-06-11 Advisory GHSA-wg9g-w2j2-8pgr published (no CVE identifier)
2026-08-18 Advisory added to the GitHub Advisory Database

References

  1. Vendor advisory · GHSA-wg9g-w2j2-8pgr github.com/Project-MONAI/MONAI/security/advisories/GHSA-wg9g-w2j2-8pgr
  2. MITRE CWE-502 cwe.mitre.org/data/definitions/502.html
  3. Project repository github.com/Project-MONAI/MONAI

Cite this advisory

rootzero. “GHSA-wg9g-w2j2-8pgr: code execution in MONAI through pickled NumPy files read by NumpyReader”. Vulnerability research, rootzero, 11 June 2026. https://rootzero.eu/en/research/cve/ghsa-wg9g-w2j2-8pgr-monai-numpy-pickle-rce/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.