| | |

YAML 08 ๐Ÿ“„ Security Hazards โ€” Arbitrary Code Execution during Deserialization

โฑ๏ธ Estimated read: 11 min | |

YAML’s expressive power is its attack surface. A parser that can instantiate language objects from tags will do so when the document contains the right tag, and an attacker who controls the document controls what gets instantiated. The result is remote code execution from what looks like a configuration file. This is not a theoretical risk: PyYAML before 5.1 executed arbitrary code through yaml.load() , SnakeYAML’s Constructor instantiated arbitrary Java classes , and Rails had to patch Active Record’s YAML column deserialization twice, in CVE-2013-0156 and CVE-2022-32224 .

This chapter covers the three deserialization risks that matter: unsafe object construction, alias expansion denial of service, and YAML injection through generated output. It explains why the safe loader is necessary but not sufficient, how the “billion laughs” attack works against parsers that safely load data, and the language-specific loaders that must replace the unsafe defaults. The sections that follow walk through unsafe deserialization, alias expansion, and the mitigation patterns that close both.

Key point: Never use a full-object YAML loader on untrusted input; yaml.safe_load and SafeConstructor restrict parsing to plain data, but alias expansion must still be bounded separately because safe loaders resolve aliases.


Why deserialization is the primary YAML risk

The object-construction problem. YAML was designed as a serialization format, not only a configuration format. Its specification includes tags for representing language-specific types, object references, and custom constructors . A parser that honors those tags will call the constructor named in the document. The PyYAML tag !!python/object/apply:os.system ['whoami'] executes the whoami command when parsed . The SnakeYAML equivalent instantiates a URLClassLoader that loads a remote JAR . The tag is the instruction; the parser is the executor.

The default-loader problem. PyYAML’s yaml.load() without a Loader argument was the unsafe default until version 5.1, when the function was deprecated and UnsafeLoader was introduced for backward compatibility . The CVE (CVE-2017-18342) was assigned to raise awareness that the API could execute arbitrary code with untrusted data . The fix was not a patch to the parser; it was a change to the default that forced callers to choose a loader explicitly.

The safe-loader-is-not-enough problem. yaml.safe_load() prevents object construction, but it still resolves aliases. A document with nested anchors and aliases can expand exponentially, consuming gigabytes of memory from a few hundred bytes of input. The “billion laughs” attack against YAML parsers works on safe loaders because the expansion is a data-structure operation, not an object-construction operation . Symfony patched this in CVE-2026-45304 by limiting collection alias resolutions to 128 by default . Safe loading and alias bounding are separate controls.


a. Unsafe object construction and the safe loader

The unsafe path is any loader that constructs objects from tags. In Python, that is yaml.load() with Loader=yaml.Loader or Loader=yaml.UnsafeLoader. In Java, that is SnakeYAML’s default Constructor class. In Ruby, that is YAML.load with permitted classes broad enough to include gadget chains.

import yaml

# UNSAFE: executes arbitrary code from tags
data = yaml.load(untrusted, Loader=yaml.Loader)

# SAFE: only plain scalars, lists, and dicts
data = yaml.safe_load(untrusted)

The safe loader is the baseline mitigation, and it is non-negotiable for any YAML the application did not author . A config file committed by a developer is not the same trust level as an uploaded file or an API body; the source determines the loader.

The vulnerability is not always in application code. ManageIQ’s YamlLoadAliases module overrode YAML.safe_load to silently fall back to YAML.unsafe_load when a Psych::DisallowedClass error occurred, which meant a crafted payload could trigger the fallback and deserialize arbitrary Ruby objects . The lesson is that the safe function is only safe if it is not wrapped by a fallback that removes the safety.


b. Alias expansion and the billion laughs attack

Anchors and aliases are a YAML feature for reuse, not a security feature. A small document can define an anchor on a collection, alias it nine times, then alias the result nine times, and repeat. The parser resolves each alias to the full structure, and the total size grows exponentially with nesting depth. The Symfony advisory describes the attack precisely: “A small input can blow up into a multi-gigabyte structure and exhaust memory” .

# A minimal billion laughs payload
a: &a ["x","x","x","x","x","x","x","x","x"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
# Each level multiplies the previous by nine

safe_load does not prevent this. The aliases are resolved, the structure expands, and the process runs out of memory. The mitigations are separate from the loader choice: cap the size of accepted YAML, limit alias resolution counts where the parser supports it, or reject aliases entirely for fully untrusted input. Symfony’s fix limits collection aliases to 128 and offers a PARSE_EXCEPTION_ON_ALIAS flag that rejects all aliases . The limit is a denial-of-service control, not a correctness control; it does not change what a legitimate document can express, only how much a malicious one can expand.


c. Language-specific loaders and mitigation patterns

The safe loader is language-specific. The principle is the sameโ€”restrict parsing to plain data types and deny object constructionโ€”but the API differs.

In Python, yaml.safe_load() is the only acceptable entry point for untrusted input. Bandit’s B506 rule flags yaml.load() usage, and the rule should be build-breaking rather than advisory . In Java, SnakeYAML 2.0 changed the default to disable global tags and requires SafeConstructor for safe parsing; the CVE-2022-1471 fix made global tags disabled by default . In Ruby, YAML.safe_load with an explicit permitted_classes allowlist is the durable practice; relying on recent Psych versions making bare YAML.load safer is fragile across versions and gems . Rails’ CVE-2022-32224 patch switched the serialized-column loader to YAML.safe_load and introduced config.active_record.yaml_column_permitted_classes for applications that need to allow specific classes .

The common pattern is an allowlist, not a blocklist. The parser should be told exactly which classes it may construct, and everything else should fail. A blocklist of “dangerous” classes is a losing game because the set of gadget chains is open-ended.


Complete Example Session

# ============================================
# PART 1: THE UNSAFE PATH
# ============================================
import yaml

payload = """
!!python/object/apply:os.system ['echo compromised']
"""

# This executes the command
yaml.load(payload, Loader=yaml.Loader)

# ============================================
# PART 2: THE SAFE PATH
# ============================================
# safe_load rejects the tag entirely
result = yaml.safe_load(payload)
# raises ConstructorError: could not determine a constructor
# for the tag 'tag:yaml.org,2002:python/object/apply:os.system'

# ============================================
# PART 3: ALIAS EXPANSION (SAFE LOADER STILL VULNERABLE)
# ============================================
laughs = """
a: &a ["x","x","x","x","x","x","x","x","x"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
"""

# safe_load resolves the aliases and allocates the expanded structure
# data = yaml.safe_load(laughs)  # memory grows with nesting

# ============================================
# PART 4: THE MITIGATION LAYERS
# ============================================
# Layer 1: safe loader for object construction
data = yaml.safe_load(untrusted)

# Layer 2: size cap before parsing
if len(untrusted) > MAX_YAML_BYTES:
    raise ValueError("YAML too large")

# Layer 3: alias limit (parser-dependent)
# PyYAML has no built-in alias limit; use a wrapper or
# reject documents with excessive anchors

# ============================================
# PART 5: THE GENERATED-YAML PROBLEM
# ============================================
# Never concatenate user input into YAML
# BAD: f"name: {user_input}\nrole: user"
# A newline in user_input creates a new key
# GOOD: serialize with the dumper
import yaml
safe_output = yaml.safe_dump({"name": user_input, "role": "user"})

The parts covered the unsafe execution, the safe loader rejection, the alias expansion that safe loading does not prevent, the layered mitigation, and the injection risk in generated YAML.


Quick Reference

Risk Summary

RiskMechanismMitigation
Object constructionParser instantiates classes from tagssafe_load / SafeConstructor
Alias expansionNested aliases multiply structureAlias limit, size cap, reject aliases
YAML injectionConcatenated input creates structureSerialize with dumper, never template

Safe Loaders by Language

LanguageUnsafeSafe
Pythonyaml.load(..., Loader=yaml.Loader)yaml.safe_load()
Javanew Yaml() (default)new Yaml(new SafeConstructor())
RubyYAML.loadYAML.safe_load(permitted_classes: [...])

Known CVEs

CVELibraryIssue
CVE-2017-18342PyYAMLyaml.load RCE
CVE-2022-1471SnakeYAMLConstructor RCE
CVE-2022-32224RailsYAML column RCE
CVE-2026-45304SymfonyBillion laughs

Best Practices

โœ… Do This:

# Use safe_load for any untrusted YAML
data = yaml.safe_load(untrusted)                             # โœ…

# Cap input size before parsing
if len(data) > MAX: raise ValueError()                        # โœ…

# Serialize with the dumper, never concatenate
yaml.safe_dump(user_data)                                    # โœ…

# Allowlist permitted classes in Ruby
YAML.safe_load(input, permitted_classes: [Symbol, Date])      # โœ…

โŒ Don’t Do This:

# Unsafe loader on user input
yaml.load(request.body, Loader=yaml.Loader)                   # โŒ

# Assume safe_load prevents alias expansion
yaml.safe_load(billion_laughs_payload)                        # โŒ (still expands)

# Concatenate user input into YAML
f"name: {user_input}"                                         # โŒ (injection)

# Wrap safe_load with unsafe fallback
rescue Psych::DisallowedClass; YAML.unsafe_load(x)            # โŒ

Common Pitfalls

PitfallWhy It HappensFix
RCE from config fileyaml.load usedsafe_load
Safe load still OOMsAlias expansion not boundedSize cap, alias limit
Fallback removes safetyWrapper catches error and retries unsafeRemove the fallback
Injection through generated YAMLString concatenationUse dumper
Ruby gadget chainYAML.load with broad classessafe_load + allowlist

Real-World Examples

1. Python safe load

data = yaml.safe_load(untrusted)

2. Python unsafe tag rejected

# yaml.safe_load("!!python/object/apply:os.system ['id']")
# raises ConstructorError

3. Java SafeConstructor

new Yaml(new SafeConstructor());

4. Ruby safe load allowlist

YAML.safe_load(input, permitted_classes: [Symbol, Date])

5. Rails YAML column config

config.active_record.yaml_column_permitted_classes = [Symbol]

6. Size cap before parse

if len(yaml_bytes) > 1_000_000: raise ValueError

7. Alias rejection flag

Yaml::parse($input, Yaml::PARSE_EXCEPTION_ON_ALIAS);

8. Safe dump

yaml.safe_dump({"name": user_input})

9. Bandit B506

bandit -r . # flags yaml.load

10. Alias limit (Symfony)

new Parser(128); // max aliases for collections

Visual

Unsafe Deserialization

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  YAML DOCUMENT                                          โ”‚
โ”‚                                                         โ”‚
โ”‚  !!python/object/apply:os.system ['whoami']             โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  Parser reads tag                                       โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  Unsafe loader: constructs object                       โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  os.system('whoami') executes                           โ”‚
โ”‚                                                         โ”‚
โ”‚  Safe loader: rejects tag with ConstructorError          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Billion Laughs

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ALIAS EXPANSION                                        โ”‚
โ”‚                                                         โ”‚
โ”‚  a: &a [x,x,x,x,x,x,x,x,x]        size: 9              โ”‚
โ”‚  b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] size: 81           โ”‚
โ”‚  c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] size: 729          โ”‚
โ”‚  d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] size: 6,561        โ”‚
โ”‚  e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] size: 59,049       โ”‚
โ”‚                                                         โ”‚
โ”‚  A few hundred bytes โ†’ megabytes at depth 8             โ”‚
โ”‚  safe_load resolves aliases; it does not stop this      โ”‚
โ”‚                                                         โ”‚
โ”‚  Mitigation: cap size, limit aliases, or reject them    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Two Controls

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  CONTROL 1: SAFE LOADER                                 โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Blocks: object construction              โ”‚           โ”‚
โ”‚  โ”‚ Does not block: alias expansion          โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚                                                         โ”‚
โ”‚  CONTROL 2: ALIAS BOUNDING                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Blocks: exponential memory growth        โ”‚           โ”‚
โ”‚  โ”‚ Does not block: object construction      โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚                                                         โ”‚
โ”‚  Both are required; neither replaces the other          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Trust Boundary

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  TRUSTED                    UNTRUSTED                   โ”‚
โ”‚                                                         โ”‚
โ”‚  In-repo config             Uploaded file               โ”‚
โ”‚  Developer-authored         API body                    โ”‚
โ”‚  Reviewed commit            Downloaded artifact         โ”‚
โ”‚                                                         โ”‚
โ”‚  safe_load acceptable       safe_load mandatory         โ”‚
โ”‚  (still recommended)        + size cap                  โ”‚
โ”‚                             + alias limit               โ”‚
โ”‚                             + schema validation         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Primary riskArbitrary object construction from tags
Python safe loaderyaml.safe_load()
Java safe loaderSafeConstructor
Ruby safe loaderYAML.safe_load with allowlist
Alias attackBillion laughs, safe loader insufficient
Alias mitigationSize cap, alias limit, reject aliases
Injection riskConcatenated input creates structure
CVE exampleCVE-2017-18342 (PyYAML)

Key takeaways:

  • The unsafe loader is the vulnerability. PyYAML’s yaml.load before 5.1, SnakeYAML’s default Constructor, and Ruby’s YAML.load all construct objects from tags. The safe loader restricts parsing to plain data and is mandatory for any YAML the application did not author .
  • Safe loading does not stop alias expansion. The billion laughs attack works on safe loaders because alias resolution is a data operation, not an object-construction operation. Bound the input size and limit alias resolution separately .
  • The fix must not have a fallback. ManageIQ’s wrapper caught the safe loader’s error and retried with the unsafe loader, which defeated the protection entirely . The safe path must be the only path.
  • Injection is the write-side risk. YAML built by string concatenation can be given new structure by a value containing a newline and a colon. Serialize with the library’s dumper .
  • The trust boundary determines the controls. In-repo configuration can use the safe loader alone; uploaded files and API bodies need the safe loader plus a size cap, alias limit, and schema validation.

Remember: YAML is a serialization format, and serialization formats can construct objects. The tag is the instruction, the parser is the executor, and the attacker who controls the document controls both. Use the safe loader for every YAML that crosses a trust boundary, bound the size and alias count because safe loading does not bound them, and never generate YAML by concatenation. The safe function is safe only if nothing wraps it in a fallback that removes the safety.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!