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.
| Ecosystem | Unsafe-shaped API | Safer-shaped API |
|---|---|---|
| PyYAML | yaml.load / unsafe_load | safe_load / SafeLoader |
| ruamel.yaml | default that allows Python tags | typ='safe' |
| Ruby | YAML.load (Psych, historically) | YAML.safe_load + permitted classes |
| Java SnakeYAML | new Yaml() + !! constructor | SafeConstructor |
| Go gopkg.in/yaml | usually structs, still watch custom unmarshalers | decode into a struct, not interface{} + eval |
| Helm / K8s | values.yaml implicit bools | quote 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.yamlis 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 sniffContent-Typeand switch tounsafe_loadforapplication/yaml. - Never log the full document if it might contain tags; log
len+ hash. - Helm: quote country codes and
on/offinvalues.yaml;helm lintdoes not catchNO→ true. I keep a unit test thatsafe_loadstyped.yamland assertscountry == "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 8080typed.yaml→country: True(YAML 1.1 implicit) — config buglaughs.yaml→RecursionError/MemoryError— DoS sink even on SafeLoaderUnsafeLoaderhaspython/objectconstructors;SafeLoaderdoes not- Fix:
safe_loadonly, 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'