Why Loading Untrusted YAML Can Run Code and How to Stop It
How a YAML tag makes a loader build arbitrary objects, with a harmless proof in PyYAML, what safe_load and full_load did with it, and Ruby and Java behavior.

A YAML file looks like data. It is data, plus an instruction set: a tag in front of a value tells the loader what kind of object to build, and for some loaders that includes any type available in the program. When the loader obeys, loading the file is running the file. This is the root of a family of real vulnerabilities in Python, Ruby and Java applications that parsed YAML from a user, a network or a downloaded config. The fix is one function name, and the way to appreciate why is to watch the failure happen in a controlled test. This post does that with a harmless payload, shows what each safe option does, and checks the current defaults in three languages. It is part of the cluster on YAML security pitfalls, and the guidance on picking a loader is in safe loaders in Python, Ruby and Java.
What a tag does
Every YAML node can carry a tag. !!str 123 says "this is a string, not a number". Standard tags name the basic types: !!int, !!float, !!bool, !!seq, !!map. The mechanism is open-ended, though. A loader can map any tag it knows to a constructor function, and PyYAML's full-power loader knows tags that name Python objects: !!python/object:module.Class builds an instance, and !!python/object/apply:module.function calls a function with the arguments that follow. A document is a program in a tiny language whose only verbs are "call this".
The payload we use does one harmless thing, calling os.system with an echo that writes a file:
!!python/object/apply:os.system
- "echo code-ran > /tmp/yaml_marker.txt"An attacker's version replaces the echo with something that reads secrets, opens a connection or installs a backdoor. The mechanism is identical, which is why the severity of a bug like this is rated by what the process can do and not by what the payload does here.
The unsafe loader runs it
import yaml
result = yaml.load(payload, Loader=yaml.UnsafeLoader)UnsafeLoader returned 0 | marker: code-ranNo exception, no warning. The loader returned 0, the exit status of the shell command, and the marker file contained code-ran. The call that looked like "parse this document" was "run this command".
The safe options refuse
Now the same payload through the loaders you should be using:
safe_load: ConstructorError could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
marker after safe_load: False
full_load: ConstructorError could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
marker after full_load: FalseBoth safe_load and full_load raised ConstructorError, and neither created the file. safe_load knows only the standard tags, so the Python tags are unknown to it. full_load also refused this payload in PyYAML 6.0.3. It earned its reputation for being risky in older versions (5.x allowed more tags and had bypasses that were patched), so treat it as "less safe than safe_load" and do not choose it for untrusted input. The rule that costs nothing: use yaml.safe_load for everything unless you can name the object type you need and why.
PyYAML 6 also closed a door by changing a default. Calling yaml.load(payload) with no loader now raises:
yaml.load without Loader: TypeError load() missing 1 required positional argument: 'Loader'In PyYAML 5 and earlier, the same call used the unsafe default and a warning, and a decade of tutorials show it. Code that pins an old PyYAML, or that was written from those tutorials and passes Loader=yaml.Loader to make the error go away, is still exposed. Search a codebase for yaml.load(, yaml.Loader and UnsafeLoader, and treat each hit as a finding until shown otherwise.
Ruby and Java
The same risk exists elsewhere, and current defaults are better than the old ones.
Ruby. Psych 5.2.2 on Ruby 3.4.12 restricted both YAML.load and YAML.safe_load. Loading --- !ruby/object:Admin (a class we defined) failed in both:
YAML.load: Psych::DisallowedClass: Tried to load unspecified class: Admin
YAML.safe_load: Psych::DisallowedClass: Tried to load unspecified class: Admin
YAML.unsafe_load: AdminOnly unsafe_load constructed the object. Since Psych 4, YAML.load is the restricted loader and the dangerous one has a name that says so. Older Rails applications on Psych 3 used YAML.load with the unsafe behavior, which is where several well-known Rails vulnerabilities came from, so version matters.
Java. SnakeYAML 2.4's default loader rejected a class tag:
default Yaml: ComposerException: Global tag is not allowed: tag:yaml.org,2002:java.io.FileSince SnakeYAML 2.0 the default does not instantiate arbitrary global tags. Before that, new Yaml().load(input) was a code execution path, notably through the ScriptEngineManager gadget, and many applications still ship old versions through transitive dependencies. Check the version in your dependency tree, and construct the parser with SafeConstructor where you can: it accepted a plain map in our test and has no class-creating tags at all.
Why "just parse config" is not safe
The assumption behind most of these bugs is that the input is trusted. It rarely stays trusted. A YAML file from a repository can be changed by a contributor; one from a cloud storage bucket by whoever has write access; one from a webhook or an upload by anyone. A config path in an environment variable can be pointed somewhere else. Data from a database that a lower-privileged service wrote crosses a trust boundary when a higher-privileged service loads it. The safe habit is to treat every load as a boundary and choose the safe API there, because auditing where files come from is harder than changing a function name.
What safe loading does not solve
A safe loader prevents object construction. It does not prevent other abuses.
Resource exhaustion. Aliases can expand a few hundred bytes into gigabytes when the structure is later serialized or traversed. A safe loader happily loads it. See Billion Laughs style attacks through YAML aliases and limiting alias expansion in a YAML parser.
Type surprises.
yesbecomingTrueis not an attack, and an attacker who knows the loader can still use it to flip a flag a schema would have rejected.Content. A safe-loaded document is still attacker-chosen data. If it supplies a file path, a URL or a command name that your program then uses, you have the same injection problems as with any user input.
Logging. Printing a document into a log can leak secrets it carried.
Validate the loaded data against a schema, cap the size before loading, and treat every value as untrusted. Handling YAML uploads from users puts those steps in order.
When safe loading is still not enough
The document is huge or deeply nested. Parsers can run out of memory or recursion depth before any schema sees the data. Limit size and depth first.
The loaded values drive dangerous operations. A key that names a template, a plugin or a shell command is code by another name. Allow-list the values.
You need custom types. If you register your own constructors, each is a new place to fail. Keep them minimal and review them like code.
The parser is a different library. A library that wraps a C parser has its own advisory history. Keep dependencies patched, and subscribe to advisories for the YAML libraries you use.
The documentation for the safe functions is in the PyYAML documentation, the Ruby Psych documentation and the SnakeYAML wiki. The general class of flaw is described by OWASP in the Deserialization Cheat Sheet. To check a document's syntax without loading it into your program, use the YAML formatter in the browser.


