ModelScan Bypass — Legacy (non‑zip) PyTorch files are scanned but the payload pickle is never inspected
Security research / responsible disclosure. This repository is a proof‑of‑concept for a
detection bypass in Protect AI ModelScan
v0.8.8 (latest). It is submitted to Protect AI via the huntr Model File Vulnerability
program. The PoC payload is harmless — it only writes a local RCE_PROOF.txt file and
prints a banner.
TL;DR
ModelScan’s PyTorch scanner inspects a legacy (non‑zip) torch.save file by parsing only
the first of its concatenated pickle streams — the benign magic number. The actual model
object lives in a later pickle stream and is never scanned, yet torch.load() fully
deserializes and executes it.
A malicious legacy .ckpt / .pt / .bin therefore makes ModelScan print No issues found! 🎉 while torch.load(..., weights_only=False) runs attacker code.
The same payload placed in a modern (zip) checkpoint is correctly flagged CRITICAL, proving the payload is otherwise detectable and that the gap is specific to the legacy code path.
| File | Format | ModelScan 0.8.8 verdict | torch.load(weights_only=False) |
|---|---|---|---|
malicious_legacy.ckpt |
legacy (non‑zip) | No issues found! 🎉 (scanned, 0 issues) |
arbitrary code executes |
control_modern.bin |
modern (zip) | CRITICAL — nt.system / builtins.exec |
(same payload) |
Root cause
modelscan/tools/picklescanner.py → scan_pytorch() (v0.8.8, line 269):
magic = get_magic_number(model.get_stream())
if magic != MAGIC_NUMBER:
return ScanResults([], [], [ModelScanSkipped(... "Invalid magic number")])
return scan_pickle_bytes(model, settings, scan_name, multiple_pickles=False)
A legacy file written by torch.save(obj, f, _use_new_zipfile_serialization=False) is a
sequence of independent pickle streams:
[pickle #1: MAGIC_NUMBER int] [pickle #2: protocol] [pickle #3: sys_info] [pickle #4: the object]
torch.load’s legacy path reads them one by one and unpickles pickle #4 (the model object) —
this is where a malicious __reduce__ runs.
ModelScan calls scan_pickle_bytes(..., multiple_pickles=False) exactly once, which calls
_list_globals(stream, multiple_pickles=False):
ops = list(pickletools.genops(data)) # genops stops at the first STOP opcode
...
if not multiple_pickles:
break # only the FIRST pickle is ever examined
pickletools.genops stops at the first STOP opcode, so only pickle #1 (the magic number,
which contains no globals) is analyzed. Pickles #2–#4 — including the attacker’s
__reduce__ — are never seen. ModelScan reports the file as scanned with zero issues
(a false clean, more dangerous than a skip).
Reproduce
pip install torch modelscan
python poc.py --load
Expected output (abridged):
ModelScan malicious_legacy.ckpt -> {"total_issues": 0, "by_severity": {}, "scanned": 1}
ModelScan control_modern.bin -> {"total_issues": 1, "by_severity": {"CRITICAL": 1}, "scanned": 1}
[+] BYPASS CONFIRMED: malicious legacy checkpoint is 'scanned' with 0 issues.
[!] ARBITRARY CODE EXECUTED during torch.load() -- ModelScan said: clean
[+] RCE PROOF file created: True
Or scan the shipped artifact directly with the CLI:
modelscan -p malicious_legacy.ckpt
# Scanning ... using modelscan.scanners.PyTorchUnsafeOpScan model scan
# --- Summary ---
# No issues found! 🎉
modelscan -p control_modern.bin
# --- CRITICAL ---
# Unsafe operator found: 'system' from module 'nt' (same payload, detected)
Impact
- Detection bypass with arbitrary code execution. A model published to a hub passes a
ModelScan gate with a green “no issues” result, then executes attacker code the moment a
victim calls
torch.load(..., weights_only=False)— the common path for.ckptcheckpoints that carry non‑tensor objects (optimizer state,argparse.Namespace, etc.). - Attacker‑controlled trigger. The attacker simply chooses the legacy serialization format
(
_use_new_zipfile_serialization=False);torch.loadauto‑detects and loads it normally. - Worse than a skip. ModelScan classifies the file as scanned, no issues, giving users a false sense of safety rather than a “not supported” skip.
Comparison with picklescan (why this is not “working as designed”)
picklescan hardened the analogous code path against exactly this:
# picklescan scan_pytorch():
for _ in range(5):
scan_result.merge(scan_pickle_bytes(data, file_id, multiple_pickles=False))
# plus: if get_magic_number() fails, scan the first pickle for globals anyway
It scans up to five concatenated pickles and adds a magic‑number‑bypass fallback (ref. GHSA‑97f8‑7cmv‑76j2). ModelScan ported neither mitigation.
Suggested fix
In scan_pytorch, iterate over the concatenated pickle streams instead of inspecting only the
first one (e.g. loop scan_pickle_bytes(..., multiple_pickles=False) advancing the stream, as
picklescan does), and on a missing/dynamic magic number fall back to scanning the first pickle
for dangerous globals rather than skipping the file.
Files
poc.py— builds the PoC, scans both files, and (with--load) triggers the harmless payload.malicious_legacy.ckpt— legacy torch checkpoint that ModelScan reports clean andtorch.loadexecutes.control_modern.bin— same payload in modern (zip) format; ModelScan flags it CRITICAL.