Copy a malicious pickle from model.pkl to model.bin and picklescan 0.0.30 stops reading it, because .bin routes to the PyTorch handler, which raises on a file with no magic number and never falls back to the pickle path. That is CVE-2025-10155, affected versions 0.0.30 and below, patched in 0.0.31 on 8 September 2025, scored 7.8 high under CVSS 3.1 in the project advisory and 9.3 critical under CVSS 4.0 in the GitHub advisory database record. Reproduced on a clean virtualenv, the bypass does not produce a clean report: it produces exit code 2, an ERROR line, and a summary reading Infected files 0, which any gate keyed on exit 1 or on grepping the summary treats as a pass. The deeper problem is that picklescan is a blocklist of 67 modules and 57 named callables, extended one gadget at a time across sixty published advisories between March 2025 and March 2026, and CVE-2026-3490 scored 10.0 showed a single unblocked helper resolves any blocked function at load time. A February 2026 evaluation puts widely-used pickle scanners at 7.23 to 62.75 percent F1 on a 727-file dataset. Pin picklescan at 1.0.5 and run it with --strict, then stop treating the result as the decision. Start this week by listing every file extension your intake accepts and deleting from that list everything that is not .safetensors.
Rename a malicious pickle from model.pkl to model.bin and picklescan 0.0.30 never reads the payload. That is the picklescan bypass by file extension mismatch, CVE-2025-10155, and we reproduced it on a clean virtualenv in two commands. A pickle whose __reduce__ returns (os.system, ('echo pwned',)), saved as model.pkl, produces dangerous import 'posix system' FOUND, Infected files: 1, exit code 1. The byte-identical file saved as model.bin produces ERROR: Invalid magic number for file ...: None != 119547037146038801333356, then Scanned files: 0, Infected files: 0, Dangerous globals: 0, exit code 2.
The second result is the one that matters, and it is usually described wrong. The bypass does not make picklescan report clean; it makes picklescan fail, with exit code 2 and a summary line that happens to say zero infected files. A CI gate that blocks on exit == 1, or that greps the summary for the Infected files count, passes it. A gate treating any non-zero exit as a hard failure stops the file, but reports it as an unexplained scan error rather than as detected malware.
CVE-2025-10155 was patched in 0.0.31, in September 2025. It still matters for what it exposes about the tool's shape: picklescan is a blocklist that grows one disclosed callable at a time, and the project's advisory history shows how fast that has been happening.
What a clean picklescan report actually proves
picklescan walks the opcode stream of a pickle without executing it and checks every GLOBAL import against two hard-coded dictionaries in scanner.py: an allowlist named _safe_globals and a blocklist named _unsafe_globals. In 1.0.5 the allowlist covers 6 modules and 23 named entries. The blocklist covers 67 modules, 36 of them wildcarded to the entire module and 57 named callables. An import in neither dictionary is reported as suspicious and still passes, unless the scan runs with --strict, which promotes suspicious globals to dangerous.
The blocklist is thorough about what it knows. os, subprocess, pty, ctypes, profile, cProfile, _pyrepl and numpy.f2py are wildcarded whole. Named entries cover builtins.eval, exec, compile, getattr, open and breakpoint, plus _io.FileIO, logging.FileHandler, pkgutil.resolve_name and the attrgetter / itemgetter / methodcaller trio in both operator and _operator.
Each entry exists because somebody disclosed the gadget first. The project's own security advisory tab lists sixty published advisories: 6 critical, 10 high and 44 medium, the earliest published 3 March 2025 and the most recent 2 March 2026, with twenty of them landing between 26 December 2025 and 2 March 2026 alone. The global advisory database returns 77 records referencing picklescan, 18 marked as duplicates, leaving 59 distinct advisories of which 23 carry CVE identifiers.
So a clean report establishes one thing: no currently-blocked callable appeared in the files this version knew how to open. A useful signal, not a gate, and the exit code is where teams turn it into one incorrectly.
That pickle.loads over untrusted bytes is CWE-502 is covered in our audit of three LangChain CVEs in one week. This post is about the narrower case: weight files whose format was chosen by whoever published the checkpoint.
| Exit code | Meaning | What a CI gate must do |
|---|---|---|
| 0 | Scan did not find malware | Pass only if no scan error was logged in the same run |
| 1 | Scan found malware, issues_count above zero | Hard fail |
| 2 | Scan failed: parse error, missing magic number, missing optional extra | Hard fail. This is the code the extension mismatch produced, so treating 2 as a pass reopens the bypass |
Picklescan bypass file extension mismatch, in detail
Dispatch happens before parsing: scan_bytes() reads the file extension and routes on it, and that routing table has coverage gaps of its own.
.bin sits in the PyTorch set, so model.bin goes to scan_pytorch(), which calls get_magic_number() to identify the container format. A plain pickle carries no MAGIC_NUMBER integer, get_magic_number() returns None, and scan_pytorch() raises InvalidMagicError. In 0.0.30 scan_bytes() turned that into ScanResult([], scan_err=True) and stopped, never reaching the pickle handler that would have caught posix system. In 1.0.5 the same call site catches InvalidMagicError, seeks the stream back to offset 0, and continues into the zip and pickle paths, which is why the identical model.bin is flagged there.
# On picklescan <= 0.0.30 the extension decides whether the payload is read at all. picklescan --path model.pkl # dangerous import 'posix system' FOUND -> Infected files: 1, exit 1 cp model.pkl model.bin picklescan --path model.bin # ERROR: Invalid magic number ... -> Infected files: 0, exit 2 # On 0.0.31 and above the same model.bin is correctly flagged, exit 1.
The advisory record is GHSA-jgw4-cr84-mqxg, published in the repository on 8 September 2025 alongside the fix, in the GitHub database on 10 September, and in the national vulnerability database on 17 September. It is classified CWE-20, improper input validation, and CWE-693. Two scores sit on it, a full severity band apart. The project advisory and the national vulnerability database's own primary analysis both give CVSS 3.1 base 7.8 high, vector AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H. A CVSS 4.0 base 9.3 critical score contributed by the reporting party rides along in the GitHub database record and in the NVD record as a secondary metric, which is why the GitHub database labels the advisory critical while the project's own tab labels it high. Name the scoring version and the record whenever you quote a number, or two risk registers will carry the same finding at different severities.
| Extension set | Handler | Coverage gap |
|---|---|---|
.bin, .pt, .pth, .ckpt | scan_pytorch (zip, 7z and legacy paths) | A 7z container here still needs py7zr |
.pkl, .pickle, .joblib, .dat, .data | scan_pickle_bytes | None |
.npy | scan_numpy, only when the path is passed directly | Skipped outright in directory and Hugging Face scans, which test the other three sets only; aborts the run with exit 2 if numpy is not importable |
.zip, .npz, .7z | zip and 7z handlers | .7z aborts the run with exit 2 unless py7zr is installed (pip install picklescan[7z]) |
| Any other extension | None | Skipped entirely in directory and Hugging Face scans; the file is never opened |
The version floor is not 0.0.31
Twelve releases shipped from 0.0.30 to 1.0.5, between 26 August 2025 and 1 July 2026. Most of them closed a disclosed bypass or added newly blocked callables.
Two changes ship only in 1.0.5. --strict is the difference between a report you read and a gate that denies by default. And requires_python jumps from >=3.9, which held through 1.0.4, to >=3.11, so a scanner running in a Python 3.10 CI image cannot install the current version at all. Check that before you write the pin, or the resolver quietly selects 1.0.4 and the gate still looks green.
pip install "picklescan>=1.0.5" # Default-deny scan of an intake directory. picklescan --path ./model-intake --strict # 0 = no malware, 1 = malware found, 2 = scan failed. # CI must treat any non-zero exit as a hard gate failure, not just exit 1.
The full flag surface is -p/--path, -u/--url, -hf/--huggingface, -g/--globals, --strict, --exclude, --include, --exclude-dir, --include-dir and -l/--log, with the three input flags mutually exclusive. There is no JSON output mode and no fail-on threshold, so the exit code is the entire machine-readable interface, which is why the exit-2 case needs its own line in the pipeline.
| Version | Released | Notable change |
|---|---|---|
| 0.0.30 | 2025-08-26 | Last release vulnerable to the .bin extension bypass |
| 0.0.31 | 2025-09-08 | Extension mismatch, zip CRC and subclass import bypasses patched |
| 0.0.33 | 2025-12-26 | ctypes, pty.spawn, arbitrary file write and the f2py.crackfortran cluster blocked |
| 0.0.34 | 2025-12-27 | _operator.attrgetter and _operator.methodcaller blocked |
| 0.0.35 | 2026-01-07 | io.FileIO arbitrary file read blocked |
| 1.0.1 | 2026-02-02 | logging.FileHandler, types.CodeType and the cloudpickle function-reconstruction helpers blocked |
| 1.0.3 | 2026-02-16 | Magic-number eval bypass patched |
| 1.0.4 | 2026-03-02 | pkgutil.resolve_name and the remaining stdlib execution modules blocked |
| 1.0.5 | 2026-07-01 | --strict added, requires_python raised to >=3.11 |
Why the blocklist is structurally incomplete, not merely behind
A blocklist of dangerous callables can be extended forever and still lose to indirection. CVE-2026-3490 is the clean demonstration: pkgutil.resolve_name takes a string like os:system and returns the object it names. A chained REDUCE that calls it obtains any blocked function at load time while picklescan sees only an import of pkgutil.resolve_name, which was not on the list, and never inspects the REDUCE arguments that decide what gets resolved. It was scored 10.0 under both CVSS 3.1 and CVSS 4.0, and was patched by adding one more name to the same list.
Each is a separate mechanism with its own affected range, and conflating them produces bad remediation tickets.
The measured picture agrees. A 2025 study of model loading policies reports that 44.9 percent of popular Hugging Face models still ship in the pickle format, that 15 percent of those cannot be loaded at all under restrictive loading policies, and that the restrictive loader it benchmarks against loads 22 percent fewer benign models than its own approach, which rejects 100 percent of the malicious samples in its dataset while loading 79.8 percent of benign ones. A February 2026 evaluation measured widely-used pickle scanners at 7.23 to 62.75 percent F1 on a 727-file dataset, with picklescan tested at 0.0.30, against 90.01 percent for the learned detector it proposes; on an out-of-distribution set that detector scores 81.22 percent against 76.09 percent for the 2025 policy-based loader.
Nor does the hosting platform close it. The Hugging Face Hub runs a malware scan plus an import listing built on pickletools.genops that enumerates imports without executing code, and the Hub's own security documentation states this is not 100 percent foolproof and that the import lists are maintained best-effort. That is accurate disclosure: no import-listing scanner can be complete against gadget-chain construction.
| CVE / GHSA | Disclosed | Affected | Patched in | Mechanism |
|---|---|---|---|---|
| CVE-2025-1889 / GHSA-769v-p64c-89pr | Mar 2025 | <= 0.0.21 | 0.0.22 | A benign PyTorch archive embeds a second pickle loaded via torch.load(..., pickle_file=...); the non-standard extension is never scanned |
| CVE-2025-10155 / GHSA-jgw4-cr84-mqxg | Sep 2025 | <= 0.0.30 | 0.0.31 | Plain pickle given a .bin extension routes to scan_pytorch, which raises with no fallback to the pickle path |
| CVE-2025-10156 / GHSA-mjqp-26hc-grxg | Sep 2025 | <= 0.0.30 | 0.0.31 | Zip scan bypassed through a non-exhaustive CRC check |
| CVE-2025-10157 / GHSA-f7qq-56ww-84cr | Sep 2025 | <= 0.0.30 | 0.0.31 | Unsafe-globals check bypassed through subclass imports |
| CVE-2026-53875 / GHSA-97f8-7cmv-76j2 | Feb 2026 | < 1.0.3 | 1.0.3 | get_magic_number reads only INT and LONG opcodes, so (eval, ('MAGIC_NUMBER',)) hides the magic from picklescan while torch still resolves it |
| CVE-2026-3490 / GHSA-vvpj-8cmc-gx39 | Mar 2026 | < 1.0.4 | 1.0.4 | pkgutil.resolve_name resolves any blocked callable from a string, CVSS 10.0 |
Six controls that do not depend on the scanner being complete
from safetensors import safe_open
tensors = {}
with safe_open("model.safetensors", framework="pt", device="cpu") as f:
for key in f.keys():
tensors[key] = f.get_tensor(key)
# no pickle, no callable executed on loadimport torch
# Default since PyTorch 2.6.0 (2025-01-29), but only when the caller omits it.
# Set it explicitly so code that may run on <2.6, or under
# TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD, still refuses arbitrary globals.
state = torch.load("model.bin", weights_only=True)# Record once, at the perimeter, after vetting. sha256sum model.safetensors > model.safetensors.sha256 # Verify on every serving node, before load. sha256sum -c model.safetensors.sha256
1. Safetensors-only intake, with a written exception path
The format cannot execute code on load. A safetensors file is an 8-byte little-endian header length, a UTF-8 JSON header mapping each tensor name to {dtype, shape, data_offsets}, then a flat byte buffer that the spec requires to be fully indexed with no holes, which is what prevents polyglot files. The optional __metadata__ key accepts only a flat string-to-string map. Make it policy: the gate accepts .safetensors and rejects everything else, and any exception is a named person signing a dated record of which file, from where, and why no safetensors conversion exists. Quantized checkpoints are where this gets argued most, and the format tradeoffs behind those weight builds are covered in AWQ, GPTQ and FP8 quantization.
2. torch.load(weights_only=True), set explicitly
Three ways the default does not save you: a pinned PyTorch below 2.6.0 still defaults to False, a library in your dependency tree that passes weights_only=False explicitly wins over the default, and TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD in the environment forces it back to False for every call site that did not set it. Grep your container images for that variable name. Where a checkpoint genuinely needs a non-tensor object, torch.serialization offers add_safe_globals(), the safe_globals context manager, and get_unsafe_globals_in_checkpoint() to see what a file would need before you allow anything. The fine-tuning frameworks had their own instances of this same call being made without the flag, and those CVEs are covered in our comparison of Unsloth, Axolotl, LLaMA-Factory and TRL.
3. Hash pinning against a digest recorded at intake
This converts a one-time review into a standing guarantee.
4. First load in a sandbox with no network and no writable mount
Every gadget class above ends in the same place: code running as the loading process. The first load of a newly ingested file belongs in a container with no egress route and a read-only mount, so a payload that does fire has nowhere to call and nothing to persist to. It is the one control here that assumes the other five failed.
5. A pinned, monitored scanner floor as defence in depth
Keep picklescan, pin it at 1.0.5, run it with --strict, fail on any non-zero exit, and put a calendar item on re-checking the floor. The re-check is what makes it a control: a pin with no schedule becomes a scanner four releases behind, which by the table above is two disclosed bypass classes walking through a green build.
6. Provenance recorded separately from format
Safetensors answers format safety and says nothing about provenance: a file that cannot run code on load can still be a different model than the card claims. Record the source, the revision or commit, the digest and the ingestion date as their own artifact, independent of the scan result. That is the concrete version of the supply-chain exposure we mapped in healthcare AI attack vectors HIPAA does not cover, and it is the same discipline we apply to tool servers in auditing an MCP supply chain.
The gate in an air-gapped or escorted-transfer estate
Inside a private perimeter, three of those six controls change shape.
Vetting collapses to a single event. The file is scanned, hashed and provenance-recorded once during the transfer window, and from then on the serving nodes trust the recorded SHA-256 digest and nothing else. Re-running picklescan on every node spends escort time to learn what the digest already guarantees.
The scanner becomes a frozen artifact. An air-gapped picklescan cannot pull blocklist updates, so "pin the floor and re-check on a schedule" stops being a cron job and becomes a change-window-bound manual task with a named owner. With twelve releases in ten months, a copy four releases old is blind to every bypass class disclosed since, and it stays that way until the next escorted update. That makes the scanner an inventory item with a staleness clock, which is a governance conversation rather than an engineering one.
The sandbox load carries more weight here too. There is no cloud provider egress alarm behind you, so the enclave's own network-deny posture is the entire backstop, which is the same argument as blocking outbound calls at runtime in running vLLM air-gapped. The stores around a privately served model need the same treatment, covered in hardening a self-hosted vector database. For where model intake sits relative to prompt injection, exfiltration and access control, our AI security pillar maps the rest.
For a connected shop the six controls are identical. What on-prem changes is which artifact is authoritative: the digest, not the scan.
What to change in your intake gate this week
Open the pipeline definition that pulls model weights.
List every file extension the gate accepts today. If .bin, .pt, .pth or .ckpt are on it, note which upstream models forced them, because that list is your safetensors conversion backlog and it is usually shorter than people expect.
Find the line that evaluates the scanner result. If it compares against exit code 1, or parses the summary text, change it to fail on any non-zero exit. One line, and it is the difference between catching an extension mismatch and shipping it.
Check the pinned version with pip show picklescan in the CI image, against 1.0.5 and against the Python that image runs, since 1.0.5 needs 3.11. If the pin is old or absent, set it and add the re-check to whatever calendar already holds your dependency review.
Then record a SHA-256 digest for every weight file already in production. Not because you distrust the scan that let them in, but because you cannot prove today which bytes that scan saw.
FAQ
Quick answers to the questions this post tends to raise.



