groundy
security

PickleScan 1.0.4 Patches a CVSS 10.0 pkgutil.resolve_name Bypass and Six Missing Stdlib RCE Modules

PickleScan 1.0.4 patched three critical bypasses, but the fixes expose a deeper flaw: denylist scanning cannot keep pickle safe. The structural fix is safetensors migration.

9 min···11 sources ↓

On March 2, 2026, PickleScan released v1.0.4, patching three critical advisories: GHSA-vvpj-8cmc-gx391 (CVSS 10.0), GHSA-g38g-8gr9-h9xp2 (CVSS 9.8), and GHSA-7wx9-6375-f5wh3 covering profile.run. All three let a crafted pickle return a clean scan result while achieving arbitrary code execution at load time. The fixes are additive blocklist entries. The architectural problem they expose is not.

What PickleScan 1.0.4 Patched

PickleScan works by scanning pickle opcodes for GLOBAL, INST, and STACK_GLOBAL references and comparing them against a blocklist of known-dangerous callables. Before 1.0.4, that blocklist covered roughly 60 entries2. The v1.0.4 release notes4 describe three categories of fixes: pkgutil.resolve_name was added to the blocklist, profile and cProfile entries were collapsed to wildcard matching, and six stdlib modules were listed for the first time.

The third advisory, GHSA-7wx9-6375-f5wh3, illustrates the exact-string-match problem directly: only specific method names under profile and cProfile were blocked, so profile.run slipped through. The fix was to replace the explicit entries with a wildcard. Every similar exact-match entry in the blocklist carries the same exposure.

How pkgutil.resolve_name Defeats the Blocklist

GHSA-vvpj-8cmc-gx391 is the more structurally interesting problem. The exploit uses a two-stage opcode chain:

  1. A STACK_GLOBAL instruction pushes pkgutil.resolve_name onto the stack, a legitimate stdlib function not in the blocklist.
  2. A first REDUCE call executes resolve_name("os:system"), which dynamically resolves and returns the os.system callable.
  3. A second REDUCE call invokes that callable with attacker-controlled arguments.

PickleScan reports zero issues. The advisory’s diagnosis is precise: the string 'os:system' is just data (a SHORT_BINUNICODE argument to the first REDUCE). PickleScan does not analyze REDUCE arguments, only GLOBAL/INST/STACK_GLOBAL references.

That closes this specific path. pkgutil.resolve_name accepts any string in module:attribute notation and resolves it at runtime. The advisory confirmed eleven or more dangerous callables reachable this way, including os.system, builtins.exec, builtins.eval, and several subprocess variants. Any other callable that takes a name and resolves it to a function object is a structural substitute for the same attack.

The Missing Stdlib: An Enumeration Problem

GHSA-g38g-8gr9-h9xp2 is a different failure mode: manual curation gaps. It was later assigned CVE-2026-56315, which carries a CVSS v3.1 base of 9.8 and a CVSS v4.0 base of 9.3; both describe the same defect from different scoring rubrics, so the lower v4.0 number is not a downgrade in severity. [Updated June 2026] Eight stdlib functions across six modules could achieve RCE via standard pickle GLOBAL references, none of them in the blocklist:

ModuleFunctionExecution method
uuid_get_command_stdoutsubprocess.Popen()
_osx_support_read_outputos.system()
_osx_support_find_build_toolCommand injection
_aix_support_read_cmd_outputos.system()
_pyrepl.pagerpipe_pagersubprocess.Popen(shell=True)
_pyrepl.pagertempfile_pageros.system()
imaplibIMAP4_streamsubprocess.Popen(shell=True)
test.support.script_helperassert_python_oksubprocess

None of these are novel attack vectors. They are ordinary stdlib functions that happen to shell out. A blocklist at ~60 entries that still misses these eight is not a measurement error. It is a sampling problem. The standard library is large. The set of callables that ultimately invoke subprocess, os.system, or exec is not small, not static across Python versions, and cannot be fully enumerated by any team maintaining a list on a best-effort basis.

Hugging Face’s Own Disclaimer

Hugging Face runs PickleScan as part of its Hub security scanner. The Hub’s security-pickle documentation5 states directly:

“this is not 100% foolproof. It is your responsibility as a user to check if something is safe or not. We are not actively auditing python packages for safety, the safe/unsafe imports lists we have are maintained in a best-effort manner.”

That disclaimer predates the March 2 advisories. It is also the accurate characterization of how blocklist scanning actually works. The advisories did not reveal an unexpected weakness; they provided working exploits for the weakness Hugging Face had already documented. Teams that treated the Hub’s green scan indicator as proof of safety were always one undiscovered callable away from this outcome.

Safetensors Gets Institutional Backing

On April 8, 2026, Hugging Face announced that safetensors is joining the PyTorch Foundation6 under the Linux Foundation, moving to vendor-neutral governance. Safetensors is already the default format for model distribution on Hugging Face Hub.

The format eliminates the problem at the source: a safetensors file contains only raw tensor data and metadata in a structured binary layout. No opcodes, no callables, no evaluation at load time. The Hugging Face blog post6 notes that the pickle-based formats that dominated the ecosystem meant “there was a very real risk you’d be running malicious code.” The PyTorch Foundation project listing7 describes it as a format that “prevents arbitrary code execution during deserialization by only allowing numerical tensor data.”

The governance move matters because the persistent objection to safetensors was vendor control: it was a Hugging Face format, maintained by Hugging Face. Linux Foundation stewardship removes that objection for organizations whose security review or procurement policy requires vendor-neutral governance before trusting a binary format.

Pickle Is a Gadget-Chain Problem, Not a Bad-Function List

The pkgutil.resolve_name path is not a one-off oversight. It is an instance of a deserialization gadget chain, the same class of attack that has dogged Java (ysoserial), PHP (POP chains), and .NET BinaryFormatter for over a decade. The vocabulary transfers cleanly because the underlying problem is identical: a deserializer that can reconstruct arbitrary object graphs is a general-purpose execution engine, and the attacker’s job is to find a callable already importable in the target that reaches code execution from attacker-controlled input.

Pickle’s REDUCE opcode is that engine’s function-call primitive. It pops a callable and an argument tuple off the stack and invokes the callable with those arguments. A blocklist enumerates the obvious terminal sinks, the os.system and subprocess.Popen of the world. What pkgutil.resolve_name provides is a dispatch gadget: a function that takes a string and returns any callable named in module:attribute form. It launders an arbitrary sink into a single innocuous global reference, so the dangerous name never appears in the opcode stream at all. The PulsePatch analysis labels this a universal bypass for that reason: REDUCE arguments are data, picklescan inspects references, and static reference scanning cannot see that a string argument becomes a callable two opcodes later.

That framing predicts why the patch is structurally weak. Blocking pkgutil.resolve_name removes one dispatcher. Python’s standard library ships several more name-to-callable resolvers, including importlib.import_module composed with getattr, operator.attrgetter chained against a module object, and functools-mediated indirection. Each is a candidate dispatch gadget. The scanner is now hunting dispatchers by name, which is the same enumeration race the missing-stdlib advisory exposed, just one level up the abstraction. The lesson is not “add the dispatchers to the list.” It is that opcode-reference matching cannot reason about data flow, and gadget chains live in the data.

Allowlists, Signing, and Runtime Limits

The structural answer the FAQ gestures at now has an open-source implementation. Promptfoo released ModelAudit, a static scanner that inverts picklescan’s posture: deny by default, permit a curated allowlist, and analyze how opcodes chain across pickle protocols 0 through 5 rather than matching globals against a blocklist. Promptfoo’s writeup credits its own research with finding the CVSS 10.0 pkgutil bypass, and claims coverage of more than 40 model formats against picklescan’s handful. An allowlist is not free. It inverts the false-positive burden, so a legitimate checkpoint that imports an un-allowed callable fails the scan and a human has to adjudicate it. That cost is the design intent. A scanner that fails closed on the unknown is the opposite stance from one that passes everything it has not yet learned to fear.

Allowlisting is still static analysis, and static analysis of a Turing-complete deserializer has a ceiling. The mitigations that actually constrain the runtime are stronger. PyTorch’s weights_only=True, the default since 2.6, restricts the unpickler to a tensor-type allowlist and refuses arbitrary globals. It is the single best control for .pt and .pth files, with two large caveats. First, it is opt-out: any call site that passes weights_only=False, common in older training scripts and some loader wrappers, walks straight back into full pickle semantics. Second, the allowlist itself has been bypassed. CVE-2025-32434 (CVSS v4.0 9.3) showed that on PyTorch 2.5.1 and earlier, a crafted legacy .tar checkpoint achieved code execution even with weights_only=True set, which is why the same 2.6.0 release that flipped the default also added an explicit guard against that legacy path. A runtime allowlist is a strong default, not a guarantee.

Provenance is the other axis. Model signing through the Linux Foundation’s model-transparency effort and Sigstore lets a publisher sign weights so a loader can verify origin before it deserializes anything. Signing does not make a pickle safe; it makes tampering detectable and pins trust to a named publisher instead of a green badge. None of these layers removes the attack surface. Only format migration does that: a safetensors file carries no opcodes, so there is nothing to scan and nothing to dispatch. The same trust gap shows up wherever a model artifact doubles as code, from a GGUF download that turns into RCE to loaders that ship trust_remote_code=True wired on by default. The pickle blocklist is one instance of a pattern: treating a hostile-by-construction format as safe because a best-effort scanner did not object.

What to Do Now

Update PickleScan to 1.0.4. Earlier versions have confirmed working RCE bypasses with public proof-of-concept opcode chains. As of June 2026, 1.0.4 remains the latest release, so the blocklist has not moved since the March patch even though the stdlib it polices has. [Updated June 2026]

Re-evaluate models previously cleared by PickleScan ≤ 1.0.3. Scan results from prior versions are not trustworthy, particularly for models integrated into production inference or training pipelines.

Convert weights to safetensors. For models you control, this is the migration path, not a future option. For third-party models, prefer those distributed in safetensors format. The Hub makes format filtering straightforward.

Do not treat any version of PickleScan as a sufficient gate on its own. The 1.0.4 patches closed three known gaps. The architecture provides no guarantee there are not others. A scanner that works from a static blocklist is in a permanent race with the Python stdlib changelog, and the changelog does not stand still.

Frequently Asked Questions

Does this affect torch.load or only the PickleScan scanner?

torch.save and torch.load use Python’s pickle protocol internally, so .pt and .pth files are subject to the same opcode-level attacks. PickleScan is a pre-loading scanner and does not intercept torch.load at runtime. PyTorch’s weights_only=True argument (default since PyTorch 2.6) restricts unpickling to tensor types only, providing a runtime-level mitigation that static scanning cannot offer.

Why not replace the denylist with an allowlist of safe callables?

An allowlist would need to enumerate every callable that legitimate model checkpoints use across PyTorch, TensorFlow, JAX, and domain-specific libraries, a set that varies by framework version and model architecture and is orders of magnitude larger than today’s ~60-entry denylist. Hugging Face’s own security documentation recommends a different path entirely: loading from trusted sources, signed commits, or non-pickle formats, rather than relying on any list-based scanner.

Do new Python releases automatically create new PickleScan blind spots?

Yes. The _pyrepl module, for example, was introduced in Python 3.13, and two of its pager functions were among the eight missing stdlib entries patched in 1.0.4. Each Python minor release adds or modifies internal modules, and any function that ultimately shells out becomes a viable RCE vector that the denylist has not yet catalogued.

What’s lost when converting a pickle checkpoint to safetensors?

For weights-only models, safetensors.torch.save_file() converts cleanly. But many checkpoint formats embed custom Python objects, optimizer state, learning-rate schedulers, training-step counters, custom class instances, that safetensors’ tensor-only format cannot represent. Those must be stripped or serialized separately, so training-resumption workflows that depend on non-tensor checkpoint state need restructuring before migration.

sources · 11 cited

  1. GHSA-vvpj-8cmc-gx39github.comprimaryaccessed 2026-04-29
  2. GHSA-g38g-8gr9-h9xpgithub.comprimaryaccessed 2026-04-29
  3. GHSA-7wx9-6375-f5whgithub.comprimaryaccessed 2026-04-29
  4. PickleScan v1.0.4 release notesgithub.comvendoraccessed 2026-04-29
  5. Hugging Face Hub security-pickle documentationhuggingface.covendoraccessed 2026-04-29
  6. Safetensors joins PyTorch Foundationhuggingface.covendoraccessed 2026-04-29
  7. PyTorch Foundation Safetensors projectpytorch.orgvendoraccessed 2026-04-29
  8. PickleScan Universal Blocklist Bypass Analysis (PulsePatch)pulsepatch.ioanalysisaccessed 2026-06-26