YAML 08 ๐ Security Hazards โ Arbitrary Code Execution during Deserialization
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
| Risk | Mechanism | Mitigation |
|---|---|---|
| Object construction | Parser instantiates classes from tags | safe_load / SafeConstructor |
| Alias expansion | Nested aliases multiply structure | Alias limit, size cap, reject aliases |
| YAML injection | Concatenated input creates structure | Serialize with dumper, never template |
Safe Loaders by Language
| Language | Unsafe | Safe |
|---|---|---|
| Python | yaml.load(..., Loader=yaml.Loader) | yaml.safe_load() |
| Java | new Yaml() (default) | new Yaml(new SafeConstructor()) |
| Ruby | YAML.load | YAML.safe_load(permitted_classes: [...]) |
Known CVEs
| CVE | Library | Issue |
|---|---|---|
| CVE-2017-18342 | PyYAML | yaml.load RCE |
| CVE-2022-1471 | SnakeYAML | Constructor RCE |
| CVE-2022-32224 | Rails | YAML column RCE |
| CVE-2026-45304 | Symfony | Billion 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| RCE from config file | yaml.load used | safe_load |
| Safe load still OOMs | Alias expansion not bounded | Size cap, alias limit |
| Fallback removes safety | Wrapper catches error and retries unsafe | Remove the fallback |
| Injection through generated YAML | String concatenation | Use dumper |
| Ruby gadget chain | YAML.load with broad classes | safe_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
| Item | Value |
|---|---|
| Primary risk | Arbitrary object construction from tags |
| Python safe loader | yaml.safe_load() |
| Java safe loader | SafeConstructor |
| Ruby safe loader | YAML.safe_load with allowlist |
| Alias attack | Billion laughs, safe loader insufficient |
| Alias mitigation | Size cap, alias limit, reject aliases |
| Injection risk | Concatenated input creates structure |
| CVE example | CVE-2017-18342 (PyYAML) |
Key takeaways:
- The unsafe loader is the vulnerability. PyYAML’s
yaml.loadbefore 5.1, SnakeYAML’s defaultConstructor, and Ruby’sYAML.loadall 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.
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!