ModelScan bypass PoC β legacy (non-zip) PyTorch .pt, payload behind the magic-number pickle
Authorized security research. Benign marker payload only (touch /tmp/POC_PWNED_torch). For huntr.com Model File Format disclosure.
Summary
ModelScan's PyTorch scanner (scan_pytorch β scan_pickle_bytes(..., multiple_pickles=False)) reads only the first pickle stream of a .pt file. PyTorch's legacy serialization format (torch.save(obj, f, _use_new_zipfile_serialization=False)) is a sequence of separate concatenated pickle streams: pickle(MAGIC_NUMBER), pickle(PROTOCOL), pickle(sys_info), then pickle(payload), then storages.
Because multiple_pickles=False stops after the first stream (the magic number), the malicious __reduce__ payload in a later stream is never parsed. ModelScan reports the file clean β total_issues: 0, errors: [], scanned: 1 (it believes it fully scanned a clean file). torch.load(f, weights_only=False) still executes the payload.
This is distinct from the publicly documented PyTorch legacy attacks, which are tar-based (3 files in a tar) or rely on crashing the scanner via ZipFile/STACK_GLOBAL exceptions. This variant is a flat, non-tar stream that scans clean with zero errors.
Files
evil_legacy.ptβ the bypass: flat legacy.pt, payload in a later pickle stream. ModelScan: clean.evil.ptβ control: identical payload in the modern zip format. ModelScan: flagged CRITICALos.system.build.pyβ builds both.
Reproduce
pip install modelscan torch
python build.py
modelscan -p evil_legacy.pt # => "No issues found!", Total Issues 0 (BYPASS)
modelscan -p evil.pt # => Total Issues 1, CRITICAL os.system (control, caught)
python -c "import torch; torch.load('evil_legacy.pt', weights_only=False)" # marker /tmp/POC_PWNED_torch created
Isolation proof
Same file, only the scan flag differs:
_list_globals(..., multiple_pickles=False)β globals found:set()(what ModelScan does)_list_globals(..., multiple_pickles=True)β globals found:{('os','system')}
Confirms the bypass is the container/scan-flag, not the payload.
Threat model
Requires torch.load(weights_only=False). torch β₯2.6 defaults weights_only=True, but weights_only=False is exactly the scenario ModelScan exists to defend, and remains common (e.g. NeMo forces it False; much existing code passes it). Tested: modelscan 0.8.6, torch 2.8.0, Python 3.9.
Fix
In scan_pytorch, scan with multiple_pickles=True for legacy-format .pt files (iterate every concatenated pickle stream), not just the first.