You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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.pyscan_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 .ckpt checkpoints 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.load auto‑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 and torch.load executes.
  • control_modern.bin — same payload in modern (zip) format; ModelScan flags it CRITICAL.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support