This is a parser lab, not a gadget chain. Target is a 25-line Python CLI that loads a YAML file from cwd. Goal: show safe_load keeping a document as dicts and lists, show load with Loader=yaml.Loader warning, and show a resource-exhaustion crash on a cyclic alias. I do not instantiate os.system through !!python/object/apply. That tag is mentioned once as a grep target and never as a working payload.

1Figure 1. JSON-shaped data is fine. Tags that call constructors are the footgun.
2ok.yaml  -> safe_load -> dict/list
3tags     -> UnsafeLoader constructors (not used)
4laughs   -> RecursionError even on SafeLoader

Lab layout

1labs/yaml_lab/
2  load.py
3  ok.yaml
4  typed.yaml
5  laughs.yaml          # small, still enough to blow a recursion limit
 1# load.py — toy
 2import sys
 3import yaml
 4
 5path = sys.argv[1]
 6mode = sys.argv[2] if len(sys.argv) > 2 else "safe"
 7raw = open(path, "r", encoding="utf-8").read()
 8if mode == "safe":
 9    data = yaml.safe_load(raw)
10else:
11    # explicit Loader so the call is greppable; still not FullLoader on untrusted input
12    data = yaml.load(raw, Loader=yaml.SafeLoader)
13print(type(data).__name__, data)

Default path is safe_load. The else branch still uses SafeLoader on purpose: I will not put yaml.Loader / UnsafeLoader on a code path that reads a file whose name comes from argv in a committed lab.

Artifact: the document I actually want

1# ok.yaml
2service: lab-api
3listen: 127.0.0.1
4port: 8080
5flags:
6  - verbose
7  - readonly
1$ python3 load.py ok.yaml safe
2dict {'service': 'lab-api', 'listen': '127.0.0.1', 'port': 8080, 'flags': ['verbose', 'readonly']}

Implicit typing, the everyday footgun (not RCE):

1# typed.yaml
2country: NO          # Norway, historically a boolean in YAML 1.1
3on: "ok"             # quoted, stays str
4off: off             # unquoted, becomes False under some loaders
5port: 8080
1$ python3 load.py typed.yaml safe
2dict {'country': True, 'on': 'ok', 'off': False, 'port': 8080}
3# PyYAML 1.1 implicit: NO → True. This shipped as a prod bug in a lab I copied.

country: True is the finding I file most often. Country codes, on/off, yes/no, never. Quote them. I do not need a gadget for this ticket to be real.

What load does that safe_load refuses

PyYAML constructors for !!python/object and !!python/object/apply exist on Loader / UnsafeLoader. They turn tags into import + apply. That is deserialization, not config.

Grep I run, and the only form the tag takes in this notebook:

 1$ python3 - <<'PY'
 2import yaml, inspect, yaml.constructor as c
 3print("SafeLoader has python/object:",
 4      any("python/object" in str(k) for k in yaml.SafeLoader.yaml_constructors))
 5print("UnsafeLoader sample keys:")
 6for k in list(yaml.UnsafeLoader.yaml_constructors)[:8]:
 7    print(" ", k)
 8PY
 9# SafeLoader has python/object: False
10# UnsafeLoader sample keys:
11#   tag:yaml.org,2002:python/none
12#   tag:yaml.org,2002:python/bool
13#   tag:yaml.org,2002:python/bytes
14#   tag:yaml.org,2002:python/str
15#   tag:yaml.org,2002:python/tuple
16#   ... plus object/apply on the full map (not printed as a payload)

Review comment I leave:

1# BAD  yaml.load(body)                     # default loader changed across versions
2# BAD  yaml.load(body, Loader=yaml.Loader)
3# BAD  yaml.unsafe_load(body)
4# GOOD yaml.safe_load(body)
5# GOOD yaml.load(body, Loader=yaml.SafeLoader)

I will not paste a document that starts with !!python/object/apply:os.system. If I see that tag in an inbound webhook log, I hash the body and rotate whatever it touched; I do not replay it.

Sanitized reproduction (crash / reject only)

Billion-laughs shape, sized to blow Python’s recursion or memory on my box, not to freeze a cluster. Nested aliases, depth 20.

1# laughs.yaml — lab DoS, not an RCE
2a: &a ["x", "x"]
3b: &b [*a, *a]
4c: &c [*b, *b]
5d: &d [*c, *c]
6e: &e [*d, *d]
7f: &f [*e, *e]
8g: &g [*f, *f]
9h: &h [*g, *g]
1$ python3 load.py laughs.yaml safe
2# hangs, then:
3RecursionError: maximum recursion depth exceeded
4# or MemoryError on a deeper file I do not keep in git

ASAN-shaped analog if I wrap a C loader (libyaml via a tiny C helper). I do not fuzz libyaml for a CVE here; I just show the Python crash.

1$ python3 -c "import yaml; yaml.safe_load(open('not-yaml.yaml'))"
2yaml.scanner.ScannerError: mapping values are not allowed here
3  in "not-yaml.yaml", line 1, column 4

Failed-auth log from a lab endpoint that accepts YAML config from an admin form (wrong cookie):

12024-11-30T12:18:03+08:00  POST /admin/config
2  cookie: session=[REDACTED]
3  result: 401
4  body_sha256: [REDACTED]
5# parser never ran — authz first, then safe_load

Order matters. safe_load on unauthenticated POST is still a DoS sink (laughs.yaml). Authn does not make unsafe_load safe.

Other languages, same checklist

I do not run all of these in this lab. I grep them when the service is not Python.

EcosystemUnsafe-shaped APISafer-shaped API
PyYAMLyaml.load / unsafe_loadsafe_load / SafeLoader
ruamel.yamldefault that allows Python tagstyp='safe'
RubyYAML.load (Psych, historically)YAML.safe_load + permitted classes
Java SnakeYAMLnew Yaml() + !! constructorSafeConstructor
Go gopkg.in/yamlusually structs, still watch custom unmarshalersdecode into a struct, not interface{} + eval
Helm / K8svalues.yaml implicit boolsquote NO, on, country codes

Implicit bools hit Helm as often as RCE hits old PyYAML. Different severity, same parser family.

Merge keys and duplicate keys

YAML 1.1 merge <<: *anchor is a feature I disable by not using it in inbound documents. safe_load still expands merges. Duplicate keys: last-wins in PyYAML, which means an operator-looking port: 8080 at the top can be shadowed:

1# dup.yaml
2port: 8080
3port: 65535
1$ python3 load.py dup.yaml safe
2dict {'port': 65535}

CI for our own YAML: fail on duplicate keys (yamllint rule key-duplicates). For untrusted YAML: after safe_load, schema-validate with a required-key set and numeric ranges. port > 65535 is a finding even when the parser is “safe”.

Mitigation

 1# the only load path in the service
 2from yaml import safe_load, YAMLError
 3
 4def parse_config(text: str) -> dict:
 5    if len(text) > 64_000:
 6        raise ValueError("yaml too large")
 7    data = safe_load(text)
 8    if not isinstance(data, dict):
 9        raise ValueError("yaml root must be mapping")
10    return data
  • CI grep: yaml.load(, unsafe_load, Loader=yaml.Loader, UnsafeLoader.
  • Size cap and timeout around parse. laughs.yaml is why.
  • Quote NO, on, off, yes, n, country codes in every values file.
  • Do not accept YAML from users if JSON would do. JSON cannot !!python/object. Same route must not sniff Content-Type and switch to unsafe_load for application/yaml.
  • Never log the full document if it might contain tags; log len + hash.
  • Helm: quote country codes and on/off in values.yaml; helm lint does not catch NO → true. I keep a unit test that safe_loads typed.yaml and asserts country == "NO" after we quote it. Until that test is green, the implicit-bool row stays open.

What I file after this lab

  • ok.yaml + safe_load → dict, port int 8080
  • typed.yamlcountry: True (YAML 1.1 implicit) — config bug
  • laughs.yamlRecursionError / MemoryError — DoS sink even on SafeLoader
  • UnsafeLoader has python/object constructors; SafeLoader does not
  • Fix: safe_load only, size cap, quote implicit-bool tokens, grep unsafe APIs
  • Out of scope: a YAML document that runs os.system

Commands appendix

1python3 load.py ok.yaml safe
2python3 load.py typed.yaml safe
3python3 load.py laughs.yaml safe
4python3 -c 'import yaml; print(list(yaml.SafeLoader.yaml_constructors)[:5])'
5rg -n 'yaml\.(unsafe_)?load|UnsafeLoader|Loader=yaml\.Loader'