Ruby YAML Deserialization: When YAML.load Builds Arbitrary Objects

Ruby YAML Deserialization: When YAML.load Builds Arbitrary Objects

Written by

in

Ruby yaml deserialization is the trap that catches people who think YAML is just a config format. The phrase ruby yaml deserialization points at Psych, the YAML library bundled with Ruby, and at one default that does more than parse text. The default loader reads special tags in a YAML document and uses them to build live Ruby objects. Feed it a document an attacker wrote, and you are rebuilding objects of their choosing.

This is the same shape as other native format bugs. If object rebuilding from a byte or text stream is new, read the insecure deserialization primer, and the deserialization gadget chain hub covers how single objects become code execution.

What ruby yaml deserialization really parses

YAML looks like plain key and value data, and for most documents it is. But YAML supports tags that name a type, and Psych maps some of those tags to Ruby classes. When the loader sees a tag like !ruby/object:SomeClass, the default behavior is to create an instance of that class and set its instance variables from the document.

--- !ruby/object:Account
balance: 100
owner: alice

Loading that with the default loader does not give you a hash. It gives you an Account object with balance and owner already set. The attacker picks the class and the values, so the decision about what gets built moves out of your code and into their document.

The difference between load and safe_load

Psych exposes two doors, and the names matter.

  • YAML.load with its historical default builds arbitrary objects from tags. On untrusted input, that is the bug.
  • YAML.safe_load parses only plain data types by default: strings, numbers, booleans, arrays, hashes, and nil. It refuses object tags unless you explicitly allow a class.

Here is the contrast in one place:

require "yaml"

# dangerous on untrusted input
obj = YAML.load(untrusted)

# safe: plain data only, object tags rejected
obj = YAML.safe_load(untrusted)

# safe with a narrow exception
obj = YAML.safe_load(untrusted, permitted_classes: [Date])

The permitted_classes option is the allowlist. It lets a small set of known types through and rejects everything else, so a document cannot smuggle in a class you never meant to build. Recent Ruby versions made YAML.load behave like the safe loader by default, but plenty of running code still calls the old unsafe form or pins an older version, so the bug is far from gone.

Why the classic framework YAML RCE keeps coming back

Once arbitrary objects are in play, the rest is a gadget chain. The attacker writes a YAML document whose tags build a sequence of objects from classes already loaded in the app, arranged so that a method firing on one reaches a dangerous call in another. This is the shape behind the well known framework YAML remote code execution reports over the years. The Java ecosystem has the exact same pattern, covered in snakeyaml deserialization rce, and the chain building craft carries across languages.

YAML that can name a class is not a config format any more. It is a program that tells your app which objects to build.

Ruby’s other native loader has the identical risk with a binary face instead of a text one. See ruby marshal deserialization for the same bug through Marshal.load.

Where untrusted YAML sneaks in

YAML feels like something only a developer edits, which is exactly why the input paths get missed:

  • A config or import feature that accepts a YAML file uploaded by a user.
  • An API that takes a YAML body because it was convenient.
  • Cached or queued data stored as YAML and loaded by a worker.
  • Webhook payloads or integration settings pasted in as YAML.

In an invented app called Acme Notes, a settings importer might do prefs = YAML.load(params[:file].read). That one line turns an upload into object construction under attacker control.

How to fix ruby yaml deserialization

  • Always use YAML.safe_load on untrusted YAML. This is the rule. It parses plain data and refuses object tags, which removes the thing a chain needs to start.
  • Allowlist classes only when you truly need them. If a document must carry a Date or a symbol, pass permitted_classes with that exact short list, and nothing broader.
  • Do not call the unsafe YAML.load on input you did not produce. Audit for it directly, and treat config files that users can supply as untrusted.
  • Prefer JSON for data you receive. JSON.parse has no concept of object tags, so it cannot build a class at all. When you only need data, a format that only carries data is the smaller target.

For more bugs where parsing turns into code execution, browse the injection and input category.

This flaw survives because the dangerous loader looks like a harmless config parse, and the gadgets live in gems you did not write. UnboundCompute reasons about whether attacker controlled YAML can actually reach a YAML.load and build a chain of objects, rather than matching a fixed list of payloads. Read how that assumption testing works on our about page.

Frequently asked questions

Why is YAML.load dangerous in Ruby?

Psych, the YAML library bundled with Ruby, maps certain YAML tags to Ruby classes. The historical default loader sees a tag like !ruby/object:SomeClass and builds a live instance with its instance variables set from the document. On attacker written YAML that means the attacker chooses which objects get built, which is how a gadget chain starts.

What is the difference between YAML.load and YAML.safe_load?

YAML.safe_load parses only plain data types by default, such as strings, numbers, booleans, arrays, hashes, and nil, and refuses object tags. The older default YAML.load builds arbitrary objects from tags. Always use safe_load on any YAML a user can influence.

How do I allow a specific class with safe_load?

Pass the permitted_classes option with a short, exact list, for example YAML.safe_load(input, permitted_classes: [Date]). That lets only the named types through and rejects every other object tag, so a document cannot smuggle in a class you never meant to build.

Is newer Ruby safe from YAML deserialization by default?

Recent Ruby versions made YAML.load behave like the safe loader by default, which helps. But plenty of running code still calls the old unsafe form or pins an older version, so the bug is far from gone. Use safe_load explicitly rather than relying on the version default.


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.