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 theGem::SourceandBundler::Fetcherfunctionality. 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.
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.
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 usingmarshal_dumpthe object is first allocated thenmarshal_loadis called with the result frommarshal_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_datawith the state of the object on a newly allocated instance.
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.
This is the most common entrypoint used in universal deserialization gadget chains, and it is the one maintainers have spent the most effort hardening.
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.
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.
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:
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.
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.
Type byte { (TYPE_HASH). Marshal.load rebuilds a hash by inserting each key/value pair as it reads them:
rb_hash_aset hashes the key, and hashing a key means calling its hash method.
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.
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:
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:
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:
rb_funcall(beg, id_cmp, 1, end) is @begin <=> @end, with both sides supplied by the payload:
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.
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:
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:
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:
%"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:
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:
An :E ivar set to true on any non-string object gets there:
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:
rb_obj_respond_to calls respond_to?, and the object it calls it on has already been allocated from the class the payload named:
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:
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:
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.
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.
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.
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.
.png)
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.
--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.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.
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!