Skip to main content
BlogSecurity

Pickle Model Scanner Evasion: ShadowPickle and Safetensors

A clean Hugging Face pickle scan is not proof of safety. What the ShadowPickle paper measured across ten scanners and four hubs, and a safetensors-first loading policy you can enforce.

Matthews Wong

Software Engineer

October 202612 min read
Pickle Model Scanner Evasion: ShadowPickle and Safetensors

Key Takeaway

A clean Hugging Face pickle scan is not proof a model is safe. The July 2026 ShadowPickle paper evaded ten scanners and four model hubs, and its Overwritten attack averaged a 63 percent evasion rate across five open-source scanners. Load safetensors only, pin the commit, and treat every pickle checkpoint as code.

The comfortable habit goes like this: open a model page, see no red flag beside pytorch_model.bin, and call the file checked. Hugging Face's own documentation says that list of imports is best-effort and not 100 percent foolproof. In July 2026 a paper put a number on how far short it falls.

This post reads that paper, ShadowPickle by Dhruv Pradhan, Sarang Nambiar and Ezekiel Soremekun (arXiv 2607.17503), against the Hugging Face, Python, PyTorch and safetensors documentation. It is defender-focused: it explains what was measured and what it means for a loading policy, and it contains no payloads and no recipe for building an evasive file. Every figure below is quoted from a source I read. Where I am reasoning rather than quoting, I say so.

What ShadowPickle measured, and what 63 percent means

The paper tests three attack variants against ten scanners: five open-source (PickleScan, ModelScan, Fickling, PyTorch's Weights-only Unpickler and ModelTracer) and five closed-source scanners surfaced through Hugging Face. The table shows how many of 1,000 injected models each open-source scanner detected per variant, with the tool versions listed in the paper's appendix: PickleScan 0.0.32, ModelScan 0.8.7, Fickling 0.1.5, torch 2.9.1 and ModelTracer 0.0.1.

ScannerPyPI InjectedExternal ModulesOverwritten
PickleScan0 of 1,0000 of 1,0000 of 1,000
ModelScan0 of 1,0000 of 1,0000 of 1,000
Fickling1,000 of 1,0001,000 of 1,0001,000 of 1,000
Weights-only (torch)1,000 of 1,0001,000 of 1,00039 of 1,000
ModelTracer907 of 1,000953 of 1,000821 of 1,000

The headline number is checkable from that table. The paper reports an average detection rate of 0.372 for the Overwritten variant, and one minus 0.372 is the 63 percent evasion rate in the abstract. The same arithmetic gives 0.5814 and 0.5906 for the other two variants, roughly 42 and 41 percent evasion. So 63 percent is an average over five open-source scanners. It is not the chance that a given Hub model is malicious. The average also hides how uneven the results are: two scanners detected nothing at all, and one detected everything. Fickling's perfect detection is not the success it looks like, because the paper reports its F1 score as 0.6791 before the authors' patch, which means catching everything came with false positives.

Why a clean Hub scan is weaker than it looks

The Hub's own documentation describes two scans on pushed files, a ClamAV scan and a pickle import scan, and says plainly that ClamAV does not cover pickle exploits. The import scan extracts the list of imports with pickletools.genops, which reads the file without executing it, and highlights suspicious ones. The page ends with a disclaimer that this is not 100 percent foolproof, that users must check for themselves, and that the safe and unsafe import lists are maintained in a best-effort manner.

That is an honest description of a name-based filter, and name-based filters are the design ShadowPickle targets. On the Hub side, the paper reports that the Overwritten variant evaded every Hugging Face scanner it tested (HF_JFrog, HF_ProtectAI, HF_ClamAV, HF_VirusTotal and HF_PickleScan), while the PyPI Injected and External Modules variants were flagged only by HF_PickleScan. The authors uploaded representative examples to Hugging Face and OpenCSG, not thousands, so the closed-source result rests on a small sample.

The paper tested four hubs in total: Hugging Face, ModelScope, OpenCSG and GitHub. It reports that ModelScope and GitHub have no scanners specific to pre-trained models, and that one OpenCSG scanner did not scan the uploaded repository at all. Hub coverage is uneven, so a policy that depends on the hub having already looked is a policy that depends on which hub you used.

How the attack class works, at defender depth

Pickle is a stack machine. The Hugging Face documentation explains that GLOBAL and STACK_GLOBAL tell the unpickler to import something and REDUCE calls it, and that these are the instructions that pose a threat. The paper's three attacks all use the Pickle VM's external module import to run code during deserialization. They differ in what the attacker relies on, and that difference is the useful part for a defender.

VariantWhat the attacker relies onWhat the victim must doHub result in the paper
OverwrittenA module that replaces one on a scanner's allowlist, such as collections.OrderedDict, distributed via PyPI or alongside the modelInstall that module, often through requirements.txt. Weights-only loading does not have to be disabledEvaded all Hugging Face scanners tested
PyPI InjectedA third-party PyPI library that supports code executionInstall the library and disable Weights-only loadingFlagged only by HF_PickleScan
External ModulesA helper module shipped with the modelDownload the helper and disable Weights-only loadingFlagged only by HF_PickleScan

The lesson is not a trick to copy. A scanner that allowlists by name judges the name, while the interpreter loads whatever that name resolves to in the victim's environment. If the environment has been altered, an allowlisted name stops meaning what the allowlist assumed. That is why the authors recommend checking whether allowlisted standard-library modules resolve to non-standard locations such as site-packages.

Where the 63 percent stops: the paper's own limits

Read the victim column again. Every variant needs the victim's environment to cooperate: an extra package installed, or a helper file downloaded, and for two of the three, Weights-only loading switched off. The paper lists this as a limitation, along with three more: the closed-source scanners were tested with only a few representative uploads, dynamic-scanning results depend on the execution environment, and the benchmark counts for a comparison dataset are inconsistent across sections (91, 64 and 85 malicious models appear for MalHug).

The benchmark itself is worth knowing about. PickleBench injects the attacks into benign models: the benign base is the 3,000 most-liked text-generation models on Hugging Face, with 3,000 injected models in the malicious set, 1,000 per variant. The authors report it is up to 25.6 percent more challenging than three existing benchmarks. On disclosure, they state that the methods and proposed defences would be shared with Hugging Face, PyTorch and Trail of Bits, that PyPI attacks were up for 24 hours and mainly on Test PyPI, that payloads were limited to basic enumeration or reverse shells to local addresses, and that the malicious models are withheld from public release. The text I read gives no vendor responses or dates.

The expensive misreading is treating 63 percent as the share of Hub models that are malicious, or a clean scan as a verdict. The figure measures scanner misses on benchmark models the authors injected. It says nothing about how many hostile files exist in the wild, and I did not reproduce any of it. Use it to retire the idea that scanning substitutes for a loading policy, not to size a threat.

One more caveat, and it concerns my own argument. The paper's recommendations are about better scanners, environment checks and scanning requirements files. It does not mention safetensors. My safetensors-first policy below is my inference: the format stores tensors rather than code, and the safetensors README defines its Safe column as whether you can use a randomly downloaded file and expect not to run arbitrary code, marking pickle as unsafe. That is a reasoned position, not a result the paper tested.

A safetensors-first loading policy you can enforce

A policy has to fail closed and run before anything is downloaded. The gate below reads only the file listing of one pinned commit through the huggingface_hub client and refuses a repository that has no .safetensors file. It executes nothing from the repository.

# gate_model_repo.py: decide BEFORE any weight file is downloaded or loaded.
# Reads only the file listing of one pinned commit, so nothing is executed.
from huggingface_hub import HfApi

PICKLE_SUFFIXES = (".bin", ".pt", ".pth", ".pkl", ".pickle", ".ckpt")

def gate(repo_id: str, commit_sha: str) -> None:
    info = HfApi().model_info(repo_id, revision=commit_sha)
    names = [s.rfilename for s in info.siblings]
    has_safetensors = any(n.endswith(".safetensors") for n in names)
    pickles = [n for n in names if n.endswith(PICKLE_SUFFIXES)]

    if not has_safetensors:
        raise SystemExit(f"{repo_id}@{commit_sha[:8]}: no .safetensors file, refusing")
    if pickles:
        # Not fatal by itself, but a repo shipping both formats is where
        # a loader silently choosing the wrong one gets expensive.
        print(f"warning: {repo_id} also ships {pickles}")

gate("example-org/example-model", "0123456789abcdef0123456789abcdef01234567")

The loader then states the policy instead of inheriting defaults. The Transformers documentation says use_safetensors defaults to None and, if not specified and safetensors is not installed, is set to False. That is the quiet failure: a slimmer container image changes which file formats you accept. Passing use_safetensors=True, pinning revision to a commit hash, and leaving trust_remote_code off turns the choice into code that a reviewer can see.

# load_model.py: the loader states the policy instead of trusting defaults.
from transformers import AutoModel

# Wrong: use_safetensors defaults to None. Per the Transformers docs, if it is
# not specified and the safetensors package is not installed, it is set to False,
# so a missing dependency changes which file format you are willing to read.
model = AutoModel.from_pretrained("example-org/example-model")

# Right: pinned commit, safetensors demanded, repo code refused, and the
# restricted unpickler left ON (weights_only=True is already the default
# in from_pretrained; stating it makes a later edit show up in review).
model = AutoModel.from_pretrained(
    "example-org/example-model",
    revision="0123456789abcdef0123456789abcdef01234567",
    use_safetensors=True,
    trust_remote_code=False,
    weights_only=True,
)

# Pin the behaviour in a test. This repo ships only a pickle checkpoint, so the
# call above must fail on your Transformers version, not quietly fall back.
# The docs I read do not state the failure mode, so verify it yourself.

When you cannot avoid pickle

Some checkpoints exist only as pickle. For those, layer defences and be honest about what each layer covers. torch.load documents weights_only=True as the default, restricting the unpickler to tensors, primitive types, dictionaries and anything registered through add_safe_globals, and still warns never to load data from an untrusted source. The Python documentation states that pickle is not secure and shows restricting globals by overriding find_class. The environment check below is my sketch of the authors' recommendation, which they report lifted Weights-only's F1 score from 0.8007 to 0.9911 and Fickling's from 0.6791 to 0.7165.

# env_tamper_check.py: a defender-side sketch of the paper's recommendation.
# The authors suggest checking whether allowlisted standard-library modules
# (their example is collections.OrderedDict) resolve to a non-standard place
# such as site-packages. This is my reading of that idea, not their patch.
import sys
import sysconfig
from pathlib import Path

STDLIB = Path(sysconfig.get_paths()["stdlib"]).resolve()

def shadowed_stdlib_modules() -> list[str]:
    bad = []
    for name in sys.stdlib_module_names:          # Python 3.10 and later
        mod = sys.modules.get(name)
        path = getattr(mod, "__file__", None)     # built-in modules have none
        if path and STDLIB not in Path(path).resolve().parents:
            bad.append(f"{name} -> {path}")
    return bad

if __name__ == "__main__":
    problems = shadowed_stdlib_modules()
    if problems:
        raise SystemExit("stdlib module resolved outside stdlib:\n" + "\n".join(problems))
    print("stdlib modules resolve inside", STDLIB)

# Run it in the SAME interpreter and virtualenv that will call torch.load,
# after "import torch", because it only sees modules already in sys.modules.

The import lister below is useful for triage and misleading as a verdict. It prints what a file asks to import without executing it, which is the same idea as the Hub's scan, and it shares the limit the paper exposes: a normal-looking list of names tells you nothing about what those names resolve to in the process that will load the file. It is also a heuristic that can miss memoised names, so treat an empty result with suspicion.

# triage_imports.py: list what a pickle asks to import WITHOUT executing it.
# This is the same idea as the Hub's import scan (pickletools.genops), and it
# has the same limit: it shows names, and cannot tell you what those names
# resolve to in the environment that will load the file.
import pickletools
import sys

def imports(path: str):
    with open(path, "rb") as f:
        recent_strings = []
        for op, arg, _pos in pickletools.genops(f):
            if op.name in ("SHORT_BINUNICODE", "BINUNICODE", "UNICODE"):
                recent_strings.append(arg)
            elif op.name == "GLOBAL":
                yield arg.replace(" ", ".")
            elif op.name == "STACK_GLOBAL" and len(recent_strings) >= 2:
                yield ".".join(recent_strings[-2:])

for name in sorted(set(imports(sys.argv[1]))):
    print(name)

# Expect torch._utils._rebuild_tensor_v2 and collections.OrderedDict in a normal
# checkpoint. An unexpected name is a reason to stop. An expected-looking list is
# NOT a reason to load: that is exactly the gap ShadowPickle measures.

Load untrusted pickle only in a throwaway container with no network, built from a lockfile into a fresh virtual environment. That is my choice, not a paper recommendation, but it follows from the threat model: variants that need a planted module cannot work when you control every module in the environment and the process cannot call out.

What safetensors does not fix

Moving to safetensors removes one class of risk, loading code from a weight file, and leaves the rest of the supply chain where it was. These are the gaps I would not paper over:

  • Repository code. Settings such as trust_remote_code run Python shipped in the repository regardless of the weight format, which is a separate risk from the pickle one.
  • The environment. The Overwritten variant arrives through a package. The paper recommends that hubs scan requirements.txt or uv.lock files with a vulnerability scanner, and the same applies to your own images.
  • Tensor values. The safetensors README notes that tensor values are not checked, and that NaN and infinity values can be in the file, so a safe file is not a correct model.
  • Provenance. Hugging Face's documentation says signed commits guarantee the origin of a file and not that it is safe. Origin and safety are different questions, and a pinned commit hash answers only the first.

The safetensors format description is also reassuring on a narrower point: the header is a JSON string preceded by an 8-byte little-endian size, the header is limited to 100MB, and the byte buffer cannot contain holes, which the README says prevents polyglot files. That is a small, auditable surface, which is the real argument for it over a general-purpose instruction stream.

What to do on Monday

None of this needs a new product. It needs five decisions written into code and review:

  1. Search the codebase for torch.load, pickle.load and from_pretrained, and name an owner for every call that can read a pickle checkpoint.
  2. Make safetensors the default: use_safetensors=True, a pinned commit hash, trust_remote_code left off, and the gate script in CI before any download.
  3. Check that the safetensors package is installed in every image that loads models, so a missing dependency cannot silently change the accepted format.
  4. For the pickle checkpoints you must keep, load them only in a network-less throwaway container, and run the environment check in the same interpreter first.
  5. Stop reading a clean scan as approval. Record the scanner result as one input, next to the pinned commit and the format, in your model intake checklist.
# Monday audit: which of our code paths can still read a pickle checkpoint?
grep -rnE "torch\.load\(|pickle\.load|from_pretrained\(" --include="*.py" .

# Anything that passes weights_only=False deserves a named owner and a reason.
grep -rn "weights_only=False" --include="*.py" .

# Is the safetensors package present in every image that loads models?
pip show safetensors || echo "MISSING: use_safetensors=None may fall back to pickle"

A scanner answers whether a file looks like known-bad pickle. A loading policy answers whether your process will ever execute what the file says. ShadowPickle is a measurement that the first question has gaps; the fix is to stop depending on it. Read weights from a format that cannot carry code, pin what you read, and keep the scan as a tripwire rather than a gate.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is ShadowPickle?

ShadowPickle is a set of three pickle deserialization attacks described in arXiv 2607.17503 (July 2026). They abuse the Pickle VM's external module import to run code while a model loads. The authors report they evade ten scanners and four model hubs.

Does a clean Hugging Face scan mean a pickle model is safe?

No. Hugging Face's own documentation says its pickle import scan is not 100 percent foolproof and that its import lists are maintained best-effort. In the paper, the Overwritten variant evaded every Hugging Face scanner tested, so a clean result is one input and not a verdict.

What does the 63 percent evasion rate actually mean?

It is the Overwritten variant's average evasion across five open-source scanners, one minus the reported average detection rate of 0.372. It measures scanner misses on models the authors injected. It is not the share of Hub models that are malicious.

Is safetensors enough to protect me from malicious models?

It removes the risk of code running from the weight file, and the safetensors README marks pickle as unsafe and its own format as safe for downloaded files. It does not cover repository code such as trust_remote_code, a tampered environment, or incorrect tensor values. The paper itself does not discuss safetensors, so this is a reasoned policy, not a tested result.

How do I force Transformers to load only safetensors?

Pass use_safetensors=True to from_pretrained, pin revision to a commit hash, and leave trust_remote_code off. The default is None, and the documentation says it becomes False when safetensors is not installed, so also make sure the package is present in every image. Add a test that a pickle-only repository fails to load on your Transformers version.