YAML looks like a friendly config format, so it is easy to forget that loading it can build real Python objects. Pyyaml deserialization with the default loader does exactly that. If you call yaml.load on text that a user controls, a crafted document can construct objects and call into your code at load time. The fix is small and it is the point of this whole post: use yaml.safe_load.
How pyyaml deserialization turns text into objects
YAML has a feature called tags. A tag tells the loader what kind of thing a node is. Most of the time tags are invisible and you just get strings, numbers, lists, and maps. But PyYAML also ships tags that map to Python types. When the full loader sees one of those, it does not return data. It constructs the named object.
The dangerous tag family starts with !!python/. With the default loader these tags let a document name a Python object, name a callable, and even pass arguments to it. That last part is the whole problem. A document that can call a function with arguments is a document that can run code.
What a malicious document looks like
Picture an invented app, Acme Deploy, that reads a pipeline config uploaded by a user and loads it with the default loader:
import yaml
config = yaml.load(uploaded_text) # default loader, unsafe
An attacker uploads this instead of a normal config:
steps: !!python/object/apply:os.system
args: ["id"]
The !!python/object/apply tag tells the loader to call os.system with the argument "id". The call happens during yaml.load, before your code ever inspects config. As with other loaders of this kind, validating the result afterward does nothing, because the command already ran while the document was being built.
A tag that names a callable plus its arguments is not configuration. It is a function call written in YAML.
Real payloads chain these tags to reach a useful sink, importing a module, building an object, then applying a method, which is the same gadget building idea described in what is a deserialization gadget chain. If you are new to this whole class of bug, start with the primer on what is insecure deserialization, which explains why a loader that reconstructs types is a sink no matter the language.
load versus safe_load
The difference between the two calls is the set of tags each one understands.
yaml.loadwith the default loader understands the full tag set, including the!!python/tags that build objects and call callables. On untrusted input this is unsafe.yaml.safe_loadunderstands only the standard YAML tags. It returns plain Python data: dicts, lists, strings, numbers, booleans, and null. It will not construct arbitrary objects and it will not call a function. Given the malicious document above, it raises an error about an unknown tag instead of running the command.
Newer PyYAML versions made yaml.load warn or require an explicit Loader argument, which nudged people toward safer choices. But plenty of code still passes Loader=yaml.FullLoader or the old unsafe loader, and a loader argument does not help if you pick a loader that still honors the Python tags. The only loader you should point at untrusted input is the safe one.
The fix: always safe_load untrusted YAML
The corrected version of the Acme Deploy endpoint is one word different:
import yaml
config = yaml.safe_load(uploaded_text) # only plain data
Now the uploaded text can only produce ordinary data structures. The !!python/object/apply tag is no longer recognized, so the document that tried to call os.system fails to load and you handle that error like any other bad input. You still validate the shape of the config, check required keys, and reject values out of range, but none of that is about stopping code execution anymore, because the loader can no longer execute anything.
Make it the default, not a reminder
- Standardize on
safe_loadeverywhere and treat any call to the unsafe loader as a finding in review. A rule that says “remember to use safe_load” fails the first time someone forgets. A rule that says “the unsafe loader is banned” does not. - Check your dependencies. A library you pull in may call the unsafe loader on data that reaches it from your request. Your own code being clean is not enough if a parser you depend on is not.
- Do not reintroduce the tags. If you register custom constructors, make sure they cannot build callables or import modules. A custom tag that instantiates a class with user supplied arguments is the same bug wearing a different name.
Why the same shape appears in other YAML libraries
This is not a Python only quirk. Any YAML library that maps tags to native types has the same design tension, safety versus the convenience of reviving typed objects from a document. The Java ecosystem has the same story, where a YAML parser configured to build arbitrary types from tags becomes a code execution sink on untrusted input. We cover that sibling case in snakeyaml deserialization rce. The load time callbacks that make these objects dangerous, across languages, are the special methods described in magic methods in deserialization attacks. Seeing the pattern once in Python and once in Java makes it obvious it is about the feature, not the language, which is why it sits in the broader injection and input category.
How to find it in a codebase
- Grep for
yaml.load(and flag every call that does not usesafe_load. IncludeLoader=yaml.Loader,FullLoader, andUnsafeLoader. - For each hit, trace the first argument backward. Can the text come from a request body, an uploaded file, a webhook, or a config fetched over the network? If yes, it is a live risk.
- Look for custom constructors registered with
add_constructorthat build objects from document values. - Audit third party libraries that parse YAML you pass them.
Spotting the word yaml.load is the easy half. The hard half is proving that attacker controlled text actually reaches that loader through the routes an app really uses. That source to sink reasoning about untrusted bytes arriving at an unsafe loader is exactly what UnboundCompute is built to do. Read more on our about page.
Frequently asked questions
Why is yaml.load with the default loader dangerous?
YAML tags tell the loader what kind of thing a node is, and PyYAML ships tags in the !!python/ family that map to Python types. With the default loader a document can name a callable and pass it arguments, so a tag like !!python/object/apply:os.system runs a command during the load, before your code ever inspects the result.
What is the difference between yaml.load and yaml.safe_load?
yaml.load with the default loader understands the full tag set, including the Python tags that build objects and call callables. yaml.safe_load understands only standard YAML tags and returns plain data: dicts, lists, strings, numbers, booleans, and null. Given a malicious document, safe_load raises an unknown tag error instead of running anything.
Does passing a Loader argument make yaml.load safe?
Only if you pick the safe loader. Newer PyYAML versions require an explicit Loader, but FullLoader and the unsafe loader still honor dangerous constructs, so the argument alone does not protect you. Point safe_load at untrusted input, and treat any other loader on such input as a finding.
Can custom YAML constructors reintroduce the bug?
Yes. If you register a custom tag with add_constructor that instantiates a class from user supplied values, or that can import a module or build a callable, you have recreated the same code execution sink under a different name. Keep custom constructors to plain data and never let them build callables.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
