Safeguard
Application Security•

Deserialization Defenses by Language, After Two 2026 Pickle Advisories

A scanner built to catch malicious pickles was itself bypassed, and a labeling library loads pickles from any path. What actually works in Python, Java, .NET and PHP.

Safeguard Security Team
6 min read

Two 2026 advisories show the two ways teams get deserialization wrong. In picklescan (CVE-2026-53874, GHSA-9m3x-qqw2-h32h), the tool that exists to detect malicious pickle files could be fooled: an eval call hidden under another callable via getattr went unnoticed. The GitHub advisory was first published on February 2, 2026 and last updated on June 18, 2026.

In the snorkel library (CVE-2026-31223, recorded May 12, 2026), BaseLabeler.load() calls pickle.load() on a user-supplied file path with no validation. One bug is about trusting a filter. The other is about trusting a file.

What's affected

  • picklescan, a pip package: versions below 1.0.1. Fixed in 1.0.1. Rated High, CVSS v4 8.9, CWE-502.
  • snorkel, a pip package: the advisory says versions through 0.10.0 are affected. The record I reviewed lists no fixed version, so treat every release as exposed until the maintainers say otherwise.

The picklescan advisory states that an unauthenticated user can run arbitrary code on a host that loads a malicious pickle file the scanner failed to flag. The tool was a safety check, and the bypass turned it into false assurance.

How to check if you're exposed

Find the packages and the call sites.

pip show picklescan snorkel 2>/dev/null | grep -E '^(Name|Version)'
pip freeze | grep -iE '^(picklescan|snorkel)=='

# Calls that load pickles or equivalents in your own code
grep -rnE "pickle\.loads?\(|cPickle|dill\.load|joblib\.load|torch\.load|yaml\.load\(|marshal\.load" --include=*.py .

torch.load and joblib.load use pickle underneath, so model files and cached artifacts belong on this list. Ask of each hit: where does the file come from, and who could have written it?

What to do now

  1. Upgrade picklescan to 1.0.1 or later if you use it.
  2. Stop treating a scanner as the control. Scanning is a second layer, not permission to load untrusted pickles.
  3. Find every load of a file or blob that crosses a trust boundary, and either switch the format or restrict what can be loaded.
  4. For snorkel, do not call BaseLabeler.load() on any file you did not produce yourself.
  5. Add a SAST rule that fails the build on new pickle.load calls outside an allow-listed module.

How this could have been avoided

  • Use a data-only format. If the payload is plain data, JSON or a schema-based format cannot execute code on load.
  • Sign what you load. Python's docs suggest signing data with hmac if you need to know it has not been tampered with.
  • Restrict globals when pickle is unavoidable. Python's documentation describes overriding Unpickler.find_class() to forbid globals or allow only a safe subset.
  • Keep a scanner in its place. A bypass in the scanner should cost you nothing because the load path was already restricted.
  • Keep model and artifact stores write-protected, so a file cannot be swapped between production and load.

Defenses by language

The pattern is the same everywhere: unrestricted polymorphic deserialization lets the input pick the types, and some types do work when they are built. The fix is to take that choice away.

Python

The pickle documentation opens with: "The pickle module is not secure. Only unpickle data you trust." Use JSON for untrusted data. If you must use pickle for data you produce, verify an HMAC before you unpickle, and restrict globals:

import builtins, io, pickle

SAFE = {("builtins", "range"), ("builtins", "set"), ("builtins", "frozenset")}

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if (module, name) in SAFE:
            return getattr(builtins, name)
        raise pickle.UnpicklingError(f"global {module}.{name} is forbidden")

def restricted_loads(data: bytes):
    return RestrictedUnpickler(io.BytesIO(data)).load()

The docs warn that you have to be careful what you allow. For YAML, use yaml.safe_load, never yaml.load on untrusted input.

Java

Native Java serialization is controlled with serialization filters. Set an allow-list that ends in !*, plus limits on depth, references, bytes and array size:

java -Djdk.serialFilter="maxdepth=10;maxrefs=1000;maxbytes=100000;maxarray=10000;com.example.dto.*;!*" -jar app.jar

Patterns are evaluated left to right, the first match decides, and the trailing !* rejects everything not already allowed. You can also attach a filter to one stream with ObjectInputFilter.Config.createFilter(...) and setObjectInputFilter. Turn on filter logging first so you see what you would reject before you enforce.

.NET

Microsoft says BinaryFormatter is dangerous, "insecure and can't be made secure", and that applications should stop using it even for data they believe is trustworthy. Starting in .NET 9 the in-box implementation throws on use. SoapFormatter, LosFormatter, NetDataContractSerializer and ObjectStateFormatter are in the same dangerous group. The preferred in-box alternatives are System.Text.Json, XmlSerializer and DataContractSerializer.

PHP

The manual says not to pass untrusted input to unserialize(), even with allowed_classes set. If you cannot avoid it, pass ['allowed_classes' => false] or an explicit class list, set max_depth, and validate with hash_hmac() first. For data going to or from users, use json_encode and json_decode.

$data = unserialize($blob, ['allowed_classes' => [User::class], 'max_depth' => 50]);

Why it matters

Both advisories come from the same wrong assumption: that there is a safe way to load a dangerous format from a source you do not control. A scanner that inspects bytes is guessing about behavior, and guessing can be bypassed, as picklescan showed. A loader with no checks is a remote code execution bug waiting for someone to supply the path.

Loaders such as torch.load and joblib.load use pickle underneath, so a model file from the internet can be a program from the internet. Prefer data-only formats for weights and configs where your tooling offers them.

The Microsoft guidance makes one more point worth keeping. Serialization almost always crosses a trust boundary, even when the team believes it does not: a desktop client, a synced folder or an emailed save file can all feed a payload into code that assumed it was safe.

Try it yourself

Run the grep from the check section on one repository. For each hit, write down the origin of the bytes and the process that can write them. Replace one load with a data-only format, or add a restricted unpickler and an HMAC check, and note what broke.

Click through it at your own pace, or open it full screen.

Quick check

Why is a pickle scanner not a sufficient control?

It guesses at behavior by inspecting bytes, and picklescan before 1.0.1 missed an eval hidden under getattr. The load path itself needs to be restricted or replaced with a data-only format.

What does the trailing !* in a Java serialization filter do?

It rejects every class not matched by an earlier entry, which makes the filter an allow-list.

Why does torch.load belong in a deserialization audit?

It uses pickle underneath, so loading a model file from an untrusted source can execute code.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.