YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
modelscan bypass: torch.package (.pt) executes code from a bundled .py module body on load, scanner reports CLEAN
Target
- huntr format dropdown:
PyTorch(.pt/.pth) - the file is a real PyTorchtorch.packagearchive produced bytorch.package.PackageExporterand consumed bytorch.package.PackageImporter. - Affected tool:
modelscan(protectai/modelscan) - Affected version:
0.8.8(tested against thev0.8.8git tag; current latest release at time of writing) - Loader proving impact: PyTorch
torch.package.PackageImporter, tested withtorch 2.12.0.
Severity
High - silent scanner bypass leading to arbitrary code execution (RCE) on model load.
- Class: scanner evasion -> arbitrary code execution when a victim loads an untrusted PyTorch package that modelscan certified as clean.
- This is genuine OS-level code execution (the PoC writes a marker file via the module body, and on POSIX additionally reads
/etc/passwd), not memory-corruption/DoS - so it sits at the top of the impact scale rather than the lower DoS tier. - Honest dollar framing: PyTorch
.pt/.pthis not in huntr's documented $4,000 modelscan format set (that tier is.joblib,.keras,.gguf,.safetensors, TF SavedModel). PyTorch tensor files are scanned by modelscan as pickle-family content, which maps to the lower modelscan reward tier (historically up to ~$1,500 for pickle-family/.npy/.h5/.tflite/PMML). I am therefore positioning this as a pickle/PyTorch-tier scanner-bypass-to-RCE, realistically in the up-to-$1,500 band, not the $4k band. The RCE impact is real; the format places it in the lower payout tier. Final tier is the maintainers'/huntr's call.
Summary
modelscan unpacks a .pt zip container and routes each inner entry to a scanner by file extension. In a torch.package archive the executable attacker payload does not live in a pickle opcode - it lives in the body of a bundled .py source module. .py matches no scanner's supported_extensions, so modelscan never inspects it; the only entry it does scan (the inner model.pkl) contains nothing but a benign GLOBAL referencing the interned module's class. modelscan reports 0 issues / CLEAN.
When that same file is opened with its intended loader, torch.package.PackageImporter.load_pickle(...), unpickling resolves the GLOBAL by importing the bundled module, and PyTorch exec()s the module source. The top-level code in the bundled .py runs - arbitrary code execution on load, against a file the scanner declared safe.
A built-in control (the same os.system primitive placed in a normal pickle GLOBAL) is flagged CRITICAL by modelscan, proving the scanner's baseline works and that only the .py-carrier torch.package variant evades it.
Root cause
Two independent facts combine.
1. modelscan routes inner files by extension and has no scanner for .py.
modelscan/settings.py - the scanner registry keys each scanner to a fixed extension list:
modelscan/settings.py:59-69-PickleUnsafeOpScan.supported_extensions = [".pkl", ".pickle", ".joblib", ".dill", ".dat", ".data"]modelscan/settings.py:70-73-PyTorchUnsafeOpScan.supported_extensions = [".bin", ".pt", ".pth", ".ckpt"]modelscan/settings.py:31-92overall - no scanner lists.py.
For a zip-based .pt, modelscan enumerates inner members and dispatches each by its extension (via FormatViaExtensionMiddleware, settings.py:75-92). Result on the malicious package:
.../model/model.pkl-> routed to the pickle scanner, disassembled. It contains only a benignGLOBAL <interned_module> Payload; that module is not inunsafe_globals, so 0 issues..../evilmod.py-> matches no scanner -> skipped, never scanned. This is where the executable payload is.
2. torch.package executes the bundled module body on import.
torch/package/package_importer.py (torch 2.12.0):
PackageImporter.load_pickle()unpickles withPackageUnpickler, whosefind_class()callsself.import_module(module)for the pickle'sGLOBAL.import_module->_load_module->_make_module->_compile_source:package_importer.py:444-447_compile_sourcereads the bundled source from the zip andcompile(source, ..., "exec").package_importer.py:412exec(code, ns)- the compiled module body is executed.
So merely resolving the benign-looking class reference during unpickle triggers exec() of attacker-controlled top-level Python from the unscanned .py. The dangerous primitive never appears as a pickle opcode, which is the only thing modelscan's pickle scanner reasons about.
PoC
Files included:
reproduce.py- clean, self-contained, portable/Linux-friendly reviewer script (builds in a temp dir; payload usestempfile.gettempdir()and a POSIX/etc/passwdread; benign marker only).poc_final.py- the original verification harness (attack case A + control case B), pinned to a local modelscanv0.8.8checkout.evil_package.pt- a prebuilt malicious package (so a reviewer can scan it without building).control.pkl- the prebuilt control pickle that modelscan flags.
Build (what the malicious package contains). The interned module body is the payload:
# evilmod.py (interned into the .pt; runs at load time)
import os, tempfile
# ... writes a benign marker; on POSIX also reads /etc/passwd to show real OS access ...
class Payload: # the pickle only references THIS benign class
def __init__(self): self.x = 1
from torch.package import PackageExporter
with PackageExporter("evil_package.pt") as pe:
pe.intern("evilmod")
pe.save_pickle("model", "model.pkl", evilmod.Payload())
The decisive assertions (from reproduce.py):
assert total == 0- modelscan reports the package CLEAN.assert pkl_scanned and py_unscanned- the.pklwas scanned, the.pywas not.assert os.path.exists(MARK)afterPackageImporter(...).load_pickle(...)- the bundled module body executed.- control:
assert ctotal >= 1- the sameos.systemprimitive in a plain pickle is flagged.
Captured output (re-verified, torch 2.12.0 / modelscan 0.8.8):
[*] malicious torch.package built: evil_package.pt
inner files: ['evil_package/model/model.pkl', 'evil_package/evilmod.py',
'evil_package/.data/extern_modules', 'evil_package/.data/python_version',
'evil_package/.data/version', 'evil_package/byteorder',
'evil_package/.data/serialization_id']
(A) MALICIOUS torch.package:
modelscan total_issues : 0
inner .pkl WAS scanned : True
inner .py left unscanned: True
issues : []
=> modelscan verdict: CLEAN
(A continued) load via torch.package.PackageImporter:
[load child]: LOADED <torch_package_0>.evilmod Payload
marker file written by module body: True
proof: 'CODE EXECUTED FROM torch.package MODULE BODY AT LOAD TIME'
=> PackageImporter load: ARBITRARY CODE EXECUTED
(B) CONTROL - same os.system primitive as a plain pickle GLOBAL:
modelscan total_issues : 1
issues : [{"description": "Use of unsafe operator 'system' from module 'nt'",
"operator": "system", "module": "nt", "source": "control.pkl",
"scanner": "modelscan.scanners.PickleUnsafeOpScan",
"severity": "CRITICAL"}]
=> modelscan verdict: FLAGGED (scanner baseline works)
(The <torch_package_0> module prefix in the load line confirms the class came from inside the package, not from disk - the load child first asserts evilmod is not importable from the filesystem.)
Impact
Realistic threat model - modelscan's stated purpose is to gate untrusted model files:
- A victim downloads a PyTorch model packaged as a
torch.package.ptfrom a hub/registry/colleague/CI artifact. - They run modelscan (manually, or as a pre-load / CI / registry-ingest gate). modelscan returns clean - 0 issues, the explicit green light to proceed.
- They load it with the standard PyTorch API
torch.package.PackageImporter(...).load_pickle(...). The bundled.pymodule body executes immediately - arbitrary code in the victim's process and OS context (here: dropping a marker file and reading/etc/passwd; trivially generalizes to reverse shell, credential theft, supply-chain implant).
The danger is specifically that modelscan certifies the file as safe, so a security-conscious user who scans before loading is given false assurance. torch.package is a documented, first-party PyTorch packaging format, so a .pt produced this way is a legitimate artifact, not a malformed file.
Honest duplicate / prior-art and SCOPE note
Prior art (general class). It is well known that pickle-based model formats enable code execution on load and that scanners reason about pickle opcodes (modelscan's whole pickle scanner, plus public writeups on pickle/__reduce__ RCE). modelscan also already detects the ordinary pickle path - case B here proves that.
Why this is distinct. Those known cases put the dangerous callable in a pickle GLOBAL/REDUCE opcode, which modelscan's pickle scanner is designed to catch. This finding is different in mechanism:
- The executable payload is not a pickle opcode at all - it is plain Python in the body of a bundled
.pymodule inside thetorch.packagezip. - modelscan never scans that
.py(no scanner claims the extension), and the inner pickle it does scan is genuinely benign (only a class reference). - Execution is triggered by
torch.package'simport_module -> exec(module_body)machinery (package_importer.py:412), not by a pickle reduce.
I could not find prior art describing this specific torch.package bundled-.py-body vector as a modelscan bypass. If the maintainers consider any .pt that internally executes Python on load to be already-covered-by-design, that adjudication is theirs to make; the concrete, reproducible fact is that modelscan 0.8.8 returns CLEAN for a file that runs code on load, while flagging the equivalent plain-pickle payload.
Scope note (read before triaging). This report stays squarely inside huntr's modelscan scope:
- Tool = modelscan (not picklescan, not modelaudit) - so no out-of-scope-tool concern.
- Format = PyTorch
.pt/torch.package, a first-party PyTorch format and one of modelscan's named PyTorch extensions (settings.py:70-73) - not orbax or any unscoped format. - The only scope nuance is the reward tier: PyTorch tensor files are pickle-tier for modelscan, not the $4k named-format set - see Severity. Flagging this explicitly so the payout tier is set correctly rather than overclaimed.
Remediation
Root issue: modelscan trusts file extensions for zip members and has no notion that a .pt/torch.package archive can carry executable .py source that the loader will exec().
Suggested fixes (defense in depth):
- Scan bundled source modules in zip-based PyTorch packages. When a
.pt/.zipis atorch.package(detectable via.data/extern_modules,.data/python_version, and interned.pymembers), treat every inner.pyas executable-on-load and scan it (e.g. AST scan foros.system/subprocess/eval/exec/__import__/network calls at module scope), or at minimum flag the presence of interned source modules as a finding (atorch.packagethat ships arbitrary Python is inherently load-time-code-exec). - Stop silently skipping unknown extensions inside model archives. An inner member that no scanner can analyze (especially
.py) should raise a "could not be scanned / unsupported content" warning rather than contributing to a clean verdict - otherwise "0 issues" is misleading. - Recognize the torch.package GLOBAL->import->exec path. Even though the inner pickle's
GLOBALlooks benign, in atorch.packageit forces an import that executes the referenced module's body; modelscan could treat aGLOBALpointing at an interned package module as code-execution-bearing.
End users, independently: prefer weights_only/safetensors for untrusted weights and never load untrusted torch.package archives even if a scanner reports them clean.