Skip to content

YAML.safe_load instantiates arbitrary classes; permitted_classes: [] permits everything and Psych::DisallowedClass is never raised #775

Closed

Description

@tannevaled

Summary

YAML.safe_load instantiates arbitrary classes from the document. The two spellings anyone writes to mean "deny everything" both permit everything, and a non-empty allow-list silently degrades a disallowed object into a Hash instead of raising.

Psych::DisallowedClass exists as a constant and is raised nowhere, so rescue Psych::DisallowedClass can never fire.

This is the most serious finding of the security audit tracked in #768, because unlike the rest of that audit it is about data, not source. A host that accepts untrusted YAML and calls safe_load — the whole point of which is that it is safe to do that — gets arbitrary class instantiation with attacker-set instance variables.

Measurement

require "yaml"
class Gadget; attr_accessor :foo; end
class Allowed; end
y = "--- !ruby/object:Gadget\nfoo: bar\n"
call rbgo MRI 4.0.5
YAML.safe_load(y) #<Gadget:0x…> Psych::DisallowedClass
YAML.safe_load(y, permitted_classes: []) #<Gadget:0x…> Psych::DisallowedClass
YAML.safe_load(y, permitted_classes: [Allowed]) {foo: "bar"} Psych::DisallowedClass
YAML.safe_load(y, permitted_classes: [Gadget]) #<Gadget:0x…> #<Gadget @foo="bar">
YAML.safe_load(":foo") :foo Psych::DisallowedClass
defined?(Psych::DisallowedClass) "constant" "constant"

Row 2 is the one to look at twice: permitted_classes: [] is the most restrictive setting expressible, and it permits everything. Row 3 is the second-worst: with a working allow-list the caller gets a Hash where it asked for a refusal, so the failure arrives later as type confusion rather than immediately as an exception.

Row 5 means unbounded symbol creation from a document, which MRI also refuses by default.

Why, as far as I have read

Two independent causes, either of which alone would produce row 2:

  • internal/vm/yaml.go — the kwarg reader returns nil when permitted_classes is absent, commented as permitting all classes. MRI's default permitted set is [TrueClass, FalseClass, NilClass, Integer, Float, String, Array, Hash], not "everything".
  • go-ruby-yaml/yaml.go — WithPermittedClasses(names ...string) does append(o.permittedClasses, names...). With zero names that appends nothing and leaves the slice nil, and the later guard is if o.permittedClasses != nil, so the restriction is skipped entirely. An empty allow-list and an absent one are indistinguishable once they are both nil.

The design assumption is stated in the source — "the loader is already safe by construction, so the allow-list is the only observable difference from load". The measurement above is the counter-example: the loader is not safe by construction with respect to !ruby/object:.

Mitigating, and worth stating so the severity is not overstated: the YAML path does not call init_with or marshal_load. I checked for a gadget whose init_with prints, and it did not fire. So this is arbitrary class instantiation with attacker-controlled ivars, not direct method dispatch during load. It is the classic starting position for a gadget chain rather than immediate code execution.

Suggested shape

  1. Make the distinction between "no allow-list given" and "an empty allow-list" representable — a *[]string, or an explicit restrict bool alongside the slice. The current nil conflation is the whole defect.
  2. Default safe_load to MRI's permitted set rather than to everything.
  3. Raise Psych::DisallowedClass instead of degrading to a Hash, so the constant stops being decorative and rescue works.
  4. Refuse Symbol unless aliases/permitted_symbols say otherwise, as MRI does.

A test worth having regardless of the fix: assert that safe_load with an empty allow-list raises, since that is the case both causes above get wrong and the one a reader is least likely to try by hand.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions