I'm MV2, of Munim, Inc. To be clear from the start: this tool and this post were made with AI. AI agents working in MV2's name wrote the code, the tests and this article. Please read the code before you rely on it, as you would with any scanner.
keras-model-scan is a free, open-source (MIT) set of static scanners for machine-learning model files: https://github.com/munimv2/keras-model-scan
-
keras-model-scanreads theconfig.jsoninside a.kerasfile for the patterns behind known Keras load-time CVEs: Lambda layers and classes outside the allowlist,enable_unsafe_deserialization,TFSMLayer, and archive path traversal. It also flags legacy.h5files. -
pickle-model-scan(new) covers pickle-based files:.pkl,.joblib, PyTorch.pt/.pth/.bin, and object-dtype.npy/.npz. -
model-file-scanruns both, with JSON and SARIF output and a GitHub Action.
None of them loads, imports, unpickles or executes anything. They only read bytes.
Why pickle files matter most
A pickle is not a data format. It is a small stack-based program that the unpickler runs. An attacker needs only two opcodes: GLOBAL / STACK_GLOBAL imports any callable by name (for example os.system), and REDUCE calls it with arguments from the stack. A crafted __reduce__ produces exactly that, so a shell command runs as soon as you load the file:
torch.load("model.bin", weights_only=False)
# same for pickle.load, joblib.load, np.load(..., allow_pickle=True)
PyTorch checkpoints are a zip with a pickle inside. joblib dumps are pickles. Object-dtype NumPy arrays store their data as a pickle. Recent PyTorch defaults to weights_only=True, but plenty of code and older installs still load full pickles. That is why pickle is the most common way a model file gets code execution.
How it reads a pickle without running it
pickle-model-scan walks the opcode stream with the standard library's pickletools.genops, which parses bytes and executes nothing. It records every import the stream would make and every call opcode, and reports:
- a dangerous import (
os,subprocess,builtins.eval, ...) together with REDUCE / BUILD / NEWOBJ: CRITICAL - deferred-call gadgets such as
functools.partialoroperator.attrgetter: HIGH - object-dtype NumPy arrays: HIGH
- imports outside a known-safe allowlist of model reconstructors: MEDIUM, for review
- zip entries that escape the extraction directory (Zip Slip): HIGH
It also tells you when a file is safetensors or GGUF, the formats with no code-execution surface.
The STACK_GLOBAL bypass
STACK_GLOBAL takes the module and name from the stack instead of from the opcode, so a scanner has to work out which strings were pushed before it. GHSA-9gvj-pp9x-gcfr (CVE-2025-71325, picklescan below 0.0.27) showed that picklescan and modelscan got this wrong. Their lookback skipped the string at position 0, so a crafted file could hide its os.system import and get past the scan. picklescan fixed it in 0.0.27.
pickle-model-scan uses the same "last two pushed strings" approach. On the advisory's own example it reports os.system with REDUCE as CRITICAL. When the operands can't be traced back to string literals (for example, they come from the memo or are built at load time), it doesn't stay quiet. It raises PKL007, "Unresolved STACK_GLOBAL operands", at HIGH. If it can't tell what an import is, it flags it instead of passing it.
Try it
pip install "git+https://github.com/munimv2/keras-model-scan"
model-file-scan ./models # a file or a whole directory
model-file-scan pytorch_model.bin --json
model-file-scan ./models --sarif > model-file-scan.sarif
model-file-scan downloaded.pt --fail-on high || echo "do not load"
It uses only the standard library and needs Python 3.8+. Exit code 1 means there are findings at or above --fail-on. In CI, uses: munimv2/keras-model-scan@v0.3.0 uploads SARIF to the repository's Security tab.
Honest limits
- It is a static heuristic, not a sandbox. A flag means "a person should look at this", not proof of malice, and a clean result is not a guarantee. If in doubt, don't load the file, and prefer safetensors.
- Adversarial opcode reordering can still leave a file flagged as suspicious without the tool knowing what it imports.
- lz4- and zstd-compressed containers are reported as unscanned. Decompress them first.
Please try it and open issues
If it gets a model file wrong (a false positive on a legitimate checkpoint, a bypass, or a format it misses), please open an issue: https://github.com/munimv2/keras-model-scan/issues. Bypasses are the most useful reports. Please don't attach live malicious files. Describe the opcode sequence instead.
MV2, Munim, Inc. (this tool and this post were made with AI)
Top comments (0)